SLOPSHOPPER

ops-board

Ops board for skillator: a pane of the TICKETS.md board, a band above the prompt with machine and usage facts, and /list-tickets, /pending, /ops commands.

newpanebandguardcommandstatus
★ 1v0.9.0MITupdated 2026-10-26bodish-coder/skillator/plugin/mods/ops-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ops-board
│ ┃ ops board ✕ › fix the failing auth test and add an audit log call │ ┃ [ Tickets ] [ Workflows ] [ Health ] [ Messa │ ┃ app all ▾ [ ] done ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ no TICKETS.md at /work/app ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ 0 open ⏺ Bash(bun test) │ ┃ ↑↓ move a: assign d: decide f: filter x: ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /ops │ ⎿ ops-board: ops board opened. │ │ ● ops disk ? mem ? 5h 31% 7d - locks ? ▲ skillator scripts not found - set SKILLATOR_ROOT - no disk, memory or lock facts [ ok ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ ops-board: ops - · 5h 31%

Draws

Band
● ops disk ? mem ? 5h 31% 7d - locks ? ▲ skillator scripts not found - set SKILLATOR_ROOT - no disk, memory or lock facts [ ok ]
Pane · ops board
[ Tickets ] [ Workflows ] [ Health ] [ Messages ] [ Explorer app all ▾ [ ] done no TICKETS.md at /work/app 0 open ↑↓ move a: assign d: decide f: filter x: done t: team
README

ops-board

The skillator ops board: the TICKETS.md board, workflows, health, messages, explorer and build tabs (in the Claude Code pane, Workflows also lists the session's agents; build is the Build tab's word, run an alias). One data layer, drawn four ways. Needs node 22.18 or newer.

Launch it, per host

HostHow
Claude CodeThe plugin: its pane, the band above the prompt, and /ops.
pi/ops (the installer's extension); /ops --once for one frame.
Antigravity IDECommand palette: skillator: Ops board.
Codex, Cursor, Gemini CLI, opencode, any shellThe terminal app, below.

The terminal app, after install.sh / install.ps1 put ops on PATH (a ~/.local/bin/ops shim, or function ops in the PowerShell profile):

ops [repo-dir] --open          # a new tab of the current Windows Terminal window
ops [repo-dir]                 # here, in this terminal
ops [repo-dir] --once          # one frame to stdout, then exit
ops --help

Without the shim, from the installed copy (<skills> is the host's skills dir; in this repo it is mods/ops-board/ops/):

sh <skills>/ops-board/ops/ops.sh <repo> --open
powershell -NoProfile -ExecutionPolicy Bypass -File <skills>\ops-board\ops\ops.ps1 -Repo <repo> -Open

An agent starts the board with --open (-Open), never as a bare process: that spawns a separate console window. --open uses wt -w 0 new-tab when Windows Terminal's wt is on PATH (on Git Bash too), else runs the board in place, as without the flag. macOS and Linux terminals get no new tab: run ops in a tab of your own.

Colour, highlights, font

  • The selected row, the focused field and the active tab are bold, bright white on blue, with the › marker at the selected row.
  • NO_COLOR (any value) or --no-color drops the colours only; bold, inverse and the › marker stay. --ascii swaps the glyphs a raster console font lacks.
  • A terminal app cannot pick its font; the terminal does. On Windows the installer writes a Windows Terminal profile, skillator ops, as a fragment: %LOCALAPPDATA%\Microsoft\Windows Terminal\Fragments\skillator\ops.json (source: ops/wt-fragment.json). Font: Cascadia Mono 11 - it ships with Windows Terminal and draws every glyph the board uses. A dark scheme of the same name keeps the highlight blue readable. --open opens its tab in that profile once installed (Windows Terminal reads fragments when it starts). Remove it by deleting that skillator folder.
Source 30 files
hooks/register.tsx 596 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SiteScroll } from 'claude-code'
3import type { Board, Machine, Member, Usage, Watch } from '../types'
4import { listView, parseBoard } from './lib/board'
5import { meOf, parseTeam } from './lib/team'
6import { classify, levelLine, MUTE_FILE, MUTED_HOW, muteKv, mutedWords, muteText, parseMachine, unmuteDue, usageFrom } from './lib/facts'
7import { gitBin, NO_SCRIPTS, readUsage, runCall } from './lib/host'
8import { parseRuns } from './lib/runs'
9import { HIDDEN, restoreClaudeMd, RESTORE_NOTE } from './lib/restore'
10import { countsOf, renderBand, statusText } from './band'
11import { buildModel, lineKey, offsetFor, PANE_ID, PANE_TITLE, readView, renderPane } from './pane'
12import type { Tab } from './pane'
13import { cancelPerson, clearForm, isFormUp } from './assign'
14import { applyReconcile } from './reconcile'
15import { closeEngage, dropEngage, isEngageUp, pollRuns, RUNS_POLL_MS } from './runstab'
16import { AGENTS_POLL_MS, noteActivity, noteSpawn, noteTurnEnd, noteTurnStart, noteWorkflowCall, pollAgents } from './agentstab'
17import { closeSetup, enterMessages, isSetupUp, pollInbox } from './messagestab'
18import { closeRunForm, dropRunForm, enterRun, isRunFormUp } from './runtab'
19import { arrowExplorer, enterExplorer, focusExplorer, isExplorerItem } from './explorertab'
20
21// Wiring only (design §4): /ops, the 2 s TICKETS.md poll, team and me, the pane
22// and its keys; the 60 s machine / relay / usage poll, the band, the watchdog hold on
23// red, /ops resume, the status line and the CLAUDE.md restore (F48).
24
25// The engine lists what a module reads and writes from consts of the file itself,
26// so each file names its own atoms (same literal refs as pane.tsx).
27const boardA = atom({ plugin: 'ops-board', key: 'board' } as const, null)
28const teamA = atom({ plugin: 'ops-board', key: 'team' } as const, [])
29const meA = atom({ plugin: 'ops-board', key: 'me' } as const, null)
30const tabA = atom({ plugin: 'ops-board', key: 'tab' } as const, 'tickets')
31const selectedA = atom({ plugin: 'ops-board', key: 'selected' } as const, null)
32const narrowA = atom({ plugin: 'ops-board', key: 'narrow' } as const, false)
33const machineA = atom({ plugin: 'ops-board', key: 'machine' } as const, null)
34const usageA = atom({ plugin: 'ops-board', key: 'usage' } as const, null)
35const watchA = atom({ plugin: 'ops-board', key: 'watch' } as const, { level: 'green', reasons: [] })
36const notesA = atom({ plugin: 'ops-board', key: 'notes' } as const, [])
37const relayA = atom({ plugin: 'ops-board', key: 'relay' } as const, null)
38const runsOpenA = atom({ plugin: 'ops-board', key: 'runsOpen' } as const, ['runs', 'workflows', 'agents'])
39
40const MAX_READ = 4 * 1024 * 1024
41const POLL_MS = 2000
42const SLOW_MS = 60_000                 // machine facts, relay, usage
43const norm = (p: string) => p.replace(/\\/g, '/').replace(/(.)\/+$/, '$1')
44
45// Module state is per load: a hot reload starts a fresh module, so it re-arms.
46let armed = false
47let email: string | null = null
48let envMe: string | null = null            // F80: SKILLATOR_ME, as ticket-meta whoami reads it
49let teamSeen = ''
50let lastScroll: SiteScroll = { offset: 0, bodyRows: 0 }
51let lastCols = 54
52let lastStatus: string | null = null
53let lockSig = ''
54let scriptsNoted = false
55let decidedLine: string | null = null      // the level line relay-morpheus last decided on
56let holdOutcome: string | null = null      // while red: '' held, else why the hold failed; null = not red
57
58async function setBoard($: EngineInterface, next: Board, force = false) {
59  const cur = await read($, boardA)
60  if (!force && cur && cur.path === next.path && cur.error === next.error && cur.mtimeMs === next.mtimeMs && cur.size === next.size) return
61  await update($, boardA, () => next)
62}
63
64const failed = (path: string, error: string): Board => ({ path, mtimeMs: 0, size: 0, tickets: [], error })
65const TOO_BIG = 'TICKETS.md is over 4 MiB - not read; /archive-tickets moves the closed rows out'
66const notLoaded = (e: unknown) =>
67  `TICKETS.md not loaded: ${String((e as Error)?.message ?? e)} - /archive-tickets moves the closed rows out`
68
69// The board at path. Only a stat that fails is "no TICKETS.md"; a read the engine
70// refuses or a parse that throws names its error (a big board read as missing, before).
71async function loadBoard($: EngineInterface, path: string, st: { mtimeMs: number; size: number }): Promise<Board> {
72  if (st.size > MAX_READ) return failed(path, TOO_BIG)
73  try { return parseBoard(await $.fs.read(path), path, st.mtimeMs, st.size) } catch (e) { return failed(path, notLoaded(e)) }
74}
75
76// B4: the pane holds the board in one $.state value, which the host caps at 4194304
77// characters of JSON. Title and line both carry a ticket's text, so a 3.8 MB board of
78// long lines came to 7.7 M and never loaded. The pane draws a title on one row and a
79// line wrapped under it, so it keeps the head of each; /list-tickets reads the file whole.
80const TITLE_MAX = 200
81const LINE_MAX = 2000
82const clip = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '…' : s)
83const held = (b: Board): Board => (b.error ? b
84  : { ...b, tickets: b.tickets.map(t => ({ ...t, title: clip(t.title, TITLE_MAX), line: clip(t.line, LINE_MAX) })) })
85
86// One poll: TICKETS.md re-read when its mtime or size moved; team.md likewise.
87// force (F78's `r`): both re-read whatever their stat says.
88async function poll($: EngineInterface, force = false) {
89  const repo = await $.session.repo()
90  if (!repo) return setBoard($, failed('', 'not a git repo - no TICKETS.md board here'))
91  const root = norm(repo.root)
92  const path = root + '/TICKETS.md'
93  let st: { mtimeMs: number; size: number } | null = null
94  try { st = await $.fs.stat(path) } catch { /* no board */ }
95  if (!st) await setBoard($, failed(path, `no TICKETS.md at ${root}`))
96  else {
97    const cur = await read($, boardA)
98    if (force || !cur || cur.error || cur.path !== path || cur.mtimeMs !== st.mtimeMs || cur.size !== st.size) {
99      // the engine can refuse to hold the parsed board too - say so, not "missing"
100      try { await setBoard($, held(await loadBoard($, path, st)), force) } catch (e) { await setBoard($, failed(path, notLoaded(e))) }
101    }
102  }
103  const teamPath = root + '/.skillator/team.md'
104  let seen = 'none'
105  try { const st = await $.fs.stat(teamPath); seen = `${st.mtimeMs}:${st.size}` } catch { /* no team file */ }
106  if (force || seen !== teamSeen) {
107    teamSeen = seen
108    let t: Member[] = []
109    if (seen !== 'none') { try { t = parseTeam(await $.fs.read(teamPath)) } catch { t = [] } }
110    await update($, teamA, () => t)
111    await update($, meA, () => meOf(t, email ?? '', envMe))
112  }
113  await pollInbox($).catch(() => {})       // F66: the Teams inbox, re-read when it moved
114  await updateStatus($)
115}
116
117// F78: the Tickets tab's `r` - a forced poll, then a toast with what loaded.
118async function refresh($: EngineInterface) {
119  await poll($, true)
120  const b = await read($, boardA)
121  $.ui.toast(b?.error ? `ops board: ${b.error}`
122    : `ops board: refreshed - ${b?.tickets.length ?? 0} tickets, ${(await read($, teamA)).length} people`)
123}
124
125// ---- F48: machine facts, the watchdog, relay, usage, status line, notes ----
126
127async function addNotes($: EngineInterface, add: string[], drop: (n: string) => boolean = () => false) {
128  await update($, notesA, xs => [...xs.filter(n => !drop(n)), ...add.filter(n => !xs.includes(n) || drop(n))])
129}
130
131// The status line (F95: pending | in progress | total · 5h), set only when its text changes.
132async function updateStatus($: EngineInterface) {
133  const board = await read($, boardA)
134  if (!board) return
135  const text = statusText(countsOf(board), await read($, usageA), await $.clock.now())
136  if (text === lastStatus) return
137  lastStatus = text
138  $.ui.status(text)
139}
140
141function redFix(w: Watch): string {
142  const disk = w.reasons.some(r => r.startsWith('disk')), mem = w.reasons.some(r => r.startsWith('mem'))
143  return disk && mem ? 'Free disk space and memory' : mem ? 'Free memory' : 'Free space'
144}
145const isLockNote = (n: string) => /^▲ \d+ stale locks?, not deleted:/.test(n)
146const isHiddenNote = (n: string) => n.startsWith('▲ ') && n.includes(HIDDEN)
147
148function lockNote(m: Machine): string {
149  const w = Math.max(30, ...m.locks.map(l => l.path.length + 2))
150  const n = m.locks.length
151  return [`▲ ${n} stale lock${n === 1 ? '' : 's'}, not deleted:`,
152    ...m.locks.map(l => `    ${l.path.padEnd(w)}${`${l.ageMin} min`.padEnd(9)}${l.holder}`.trimEnd()),
153    '  remove them yourself, or ask Claude to check and clear them'].join('\n')
154}
155
156// New facts: classify; while red, every reading checks the hold (its HOLD fact) and,
157// when it is not set, holds through relay-morpheus - so a failed hold is tried again
158// and a release from outside is held again. A toast says why and how to clear it only
159// when the outcome changes. Stale locks are listed in a note, never deleted.
160async function applyMachine($: EngineInterface, m: Machine) {
161  if (unmuteDue(m)) {
162    try {
163      await writeMute($, '')
164      const { mute: _, ...rest } = m
165      m = rest
166      $.ui.toast('watchdog unmuted - memory reads green; memory red holds again')
167    } catch { /* the next reading tries again */ }
168  }
169  const w = classify(m)
170  await update($, machineA, () => m)
171  await update($, watchA, () => w)
172  if (w.level !== 'red') holdOutcome = null
173  else if (m.hold === null) {
174    const r = await runCall($, 'relay-hold', [w.reasons.join('; ')])
175    const ran = r.argv.length ? `\nran: ${r.argv.join(' ')}` : ''
176    const why = r.code === 0 ? '' : r.err.trim() || r.out.trim() || `exit ${r.code}`
177    if (why !== holdOutcome) {
178      if (!why) {
179        $.ui.toast(`■ HOLD - ${w.reasons.join('. ')}. New relay stages and GREEN runs are held. ${redFix(w)}, then /ops resume.${ran}`)
180      } else {
181        $.ui.toast(`■ ${w.reasons.join('. ')} - the watchdog hold could not be set: ${why}${ran}`)
182        await addNotes($, [`■ watchdog hold not set: ${why}${ran.replace('\n', ' - ')}`])
183      }
184    }
185    holdOutcome = why
186  } else if (holdOutcome === null) holdOutcome = ''          // red and already held: nothing to do
187  const sig = m.locks.map(l => l.path).sort().join('|')
188  if (sig !== lockSig) {
189    lockSig = sig
190    await addNotes($, m.locks.length ? [lockNote(m)] : [], isLockNote)
191  }
192}
193
194// F87: the mute file beside the hold - repo.root is the main checkout's for a worktree.
195async function mutePath($: EngineInterface): Promise<string> {
196  const repo = await $.session.repo()
197  if (!repo) throw new Error('not a git repo')
198  return norm(repo.root) + '/' + MUTE_FILE
199}
200async function readMute($: EngineInterface): Promise<string> {
201  try { return muteKv(await $.fs.read(await mutePath($))) } catch { return '' }   // none: not muted
202}
203async function writeMute($: EngineInterface, text: string) {
204  await $.fs.write(await mutePath($), text ? text + '\n' : '')
205}
206
207async function pollMachine($: EngineInterface) {
208  const r = await runCall($, 'pending-machine', [])
209  if (r.code === 0 && r.out.trim()) return applyMachine($, parseMachine(r.out + await readMute($), await $.clock.now()))
210  // Failed: the last facts stay (the band shows their age).
211  if (r.err === NO_SCRIPTS && !scriptsNoted) {
212    scriptsNoted = true
213    await addNotes($, [`▲ ${NO_SCRIPTS} - no disk, memory or lock facts`])
214  }
215}
216
217// relay: the first run of `relay-morpheus list` not all done, "RUN-0012 2/4". On the
218// Runs tab its own 30 s poll lists relay and sets this too: one relay list per cycle.
219async function pollRelay($: EngineInterface) {
220  if ((await read($, tabA)) === 'runs') return
221  const r = await runCall($, 'relay-list', [])
222  if (r.code !== 0) return
223  const run = parseRuns(r.out, {}).find(x => x.done < x.total)
224  const text = run ? `${run.id} ${run.done}/${run.total}` : null
225  if (text !== await read($, relayA)) await update($, relayA, () => text)
226}
227
228// A reading with no window keeps the last one's: it then shows its age, never as live.
229async function setUsage($: EngineInterface, u: Usage) {
230  await update($, usageA, prev => ({
231    ...u,
232    five: u.five ?? prev?.five ?? null,
233    seven: u.seven ?? prev?.seven ?? null,
234    decision: prev?.decision ?? null,
235  }))
236  await updateStatus($)
237  await decide($).catch(() => {})
238}
239
240// F49: the hold decision is relay-morpheus's own (`chain decide --level`), on this
241// session's line - stale figures left out, never empty. Asked again only when the line
242// changes (a new figure, or one going stale); a failed ask leaves the decision unknown.
243async function decide($: EngineInterface) {
244  const u = await read($, usageA)
245  if (!u) return
246  const line = levelLine(u, await $.clock.now())
247  if (line === decidedLine) return
248  decidedLine = line
249  const r = await runCall($, 'relay-decide', [line])
250  const word = r.code === 0
251    ? r.out.split('\n').map(s => s.trim()).find(s => /^(RELEASE|HOLD) \S+( 7d)?$|^UNOBSERVABLE$/.test(s)) ?? null
252    : null
253  if (!word) decidedLine = null
254  await update($, usageA, cur => (cur ? { ...cur, decision: word } : cur))
255}
256
257async function pollUsage($: EngineInterface) {
258  let u: Usage
259  try { u = await readUsage($, await $.clock.now()) } catch { return }
260  await setUsage($, u)
261}
262
263async function slowPoll($: EngineInterface) {
264  await pollMachine($).catch(() => {})
265  await pollRelay($).catch(() => {})
266  await pollUsage($).catch(() => {})
267}
268
269// /ops resume: re-read the facts; only a fresh reading that is not red releases (a
270// failed or empty reading refuses - an earlier poll's facts never release a hold);
271// relay-morpheus releases it, and the answer shows what it said and the command that ran.
272async function resume($: EngineInterface): Promise<string> {
273  const r = await runCall($, 'pending-machine', [])
274  if (r.code !== 0 || !r.out.trim()) return 'could not read the machine facts - not released'
275  const m = parseMachine(r.out + await readMute($), await $.clock.now())
276  await applyMachine($, m)
277  const w = await read($, watchA)          // as applied: an unmute there counts
278  if (w.level === 'red') return `still red: ${w.reasons.join('; ')} - not released`
279  return release($)
280}
281
282// relay-morpheus releases the hold; the answer shows what it said and the command that ran.
283async function release($: EngineInterface, after = ''): Promise<string> {
284  const rel = await runCall($, 'relay-release', [])
285  if (!rel.argv.length) return rel.err || NO_SCRIPTS
286  const said = rel.out.trim() || rel.err.trim()
287  const ran = `ran: ${rel.argv.join(' ')}`
288  if (rel.code !== 0) return `release failed (exit ${rel.code}): ${said || 'no output'}\n${ran}`
289  const cur = await read($, machineA)
290  if (cur) {
291    const freed: Machine = { ...cur, hold: null }
292    await update($, machineA, () => freed)
293    await update($, watchA, () => classify(freed))
294  }
295  return `${said}${after}\n${ran}`
296}
297
298// F87: /ops resume force - releases whatever the reading, after writing the mute (so no
299// poll, here or in the ops app, holds again on memory red in between). The free memory at
300// the time rides in the mute file; disk red still holds on the next reading.
301async function forceResume($: EngineInterface): Promise<string> {
302  const r = await runCall($, 'pending-machine', [])
303  const now = await $.clock.now()
304  const m = r.code === 0 && r.out.trim() ? parseMachine(r.out, now) : null
305  const mute = muteText(now, m?.memFree ?? null)
306  try { await writeMute($, mute) } catch (e) { return `could not write ${MUTE_FILE}: ${String((e as Error)?.message ?? e)} - not released` }
307  if (m) await update($, machineA, () => ({ ...m, mute }))
308  const out = await release($, `\n${mutedWords(mute)}, ${mute.slice(mute.indexOf(' ') + 1)} - ${MUTED_HOW}`)
309  if (m) await applyMachine($, { ...m, hold: null, mute })      // disk red holds again here
310  return out
311}
312
313// F87: /ops watchdog on - the mute ends; a red reading holds again now.
314async function watchdogOn($: EngineInterface): Promise<string> {
315  try { await writeMute($, '') } catch (e) { return `could not write ${MUTE_FILE}: ${String((e as Error)?.message ?? e)}` }
316  await pollMachine($).catch(() => {})
317  return 'watchdog on: memory red holds again'
318}
319
320// At start: a hidden CLAUDE.md is reported (never moved: a GREEN may be running), and
321// what the last session's end restored is shown once.
322async function startNotes($: EngineInterface) {
323  const add: string[] = []
324  try {
325    const kept = await $.store.get(RESTORE_NOTE)
326    if (Array.isArray(kept)) add.push(...kept.filter((x): x is string => typeof x === 'string'))
327    if (kept !== undefined) await $.store.delete(RESTORE_NOTE)
328  } catch { /* no store: nothing kept */ }
329  try { add.push(...await restoreClaudeMd($, 'report')) } catch { /* reported next start */ }
330  await addNotes($, add, isHiddenNote)
331}
332
333async function loadEmail($: EngineInterface) {
334  try { envMe = (await $.env.get('SKILLATOR_ME')) || null } catch { envMe = null }
335  const repo = await $.session.repo()
336  try {
337    const git = await gitBin($)                    // A150: absolute on Windows; none: no email
338    if (!git) { email = null; return }
339    const r = await $.process.run([git, 'config', 'user.email'], repo ? { cwd: repo.root, timeoutMs: 10000 } : { timeoutMs: 10000 })
340    email = r.exitCode === 0 ? r.stdout.trim().toLowerCase() || null : null
341  } catch { email = null }
342}
343
344// Arms the poll once per load: from session.start, or from the first draw or
345// command when the module was reloaded mid-session (session.start does not re-fire).
346async function arm($: EngineInterface) {
347  if (armed) return
348  armed = true
349  await loadEmail($)
350  await poll($)
351  $.clock.every(POLL_MS, () => { void poll($).catch(() => {}) })
352  await slowPoll($)
353  $.clock.every(SLOW_MS, () => { void slowPoll($).catch(() => {}) })
354  // F50: the Runs tab re-reads relay every 30 s while it is the tab shown.
355  $.clock.every(RUNS_POLL_MS, () => {
356    void (async () => { if ((await read($, tabA)) === 'runs') await pollRuns($) })().catch(() => {})
357  })
358  // F53: the agents list is re-read every 5 s while Workflows (F93: its Agents section) is the tab shown.
359  $.clock.every(AGENTS_POLL_MS, () => {
360    void (async () => { if ((await read($, tabA)) === 'runs') await pollAgents($) })().catch(() => {})
361  })
362}
363
364// closeOnEscape is set anew by every ui.open: while a form is up it stays, so Esc is
365// still the form's Cancel after a re-open.
366async function openPane($: EngineInterface, asked: boolean) {
367  const esc = (await isFormUp($)) || (await isRunFormUp($)) || (await isEngageUp($)) ? { closeOnEscape: true as const } : {}
368  const r = await $.ui.open(asked ? { id: PANE_ID, title: PANE_TITLE, focus: true, ...esc } : { id: PANE_ID, title: PANE_TITLE, ...esc })
369  await update($, narrowA, () => !r.isPlaced)
370  return r
371}
372
373const TAB_NAME: Record<Tab, string> = { tickets: 'Tickets', runs: 'Workflows', health: 'Health', messages: 'Messages', explorer: 'Explorer', run: 'Build' }
374// F76: /ops workflows opens the Workflows tab (id runs) with its Workflows section open; /ops runs is
375// an alias that opens it with its Runs section open. F93: /ops agents opens it with its Agents section
376// open; /ops build opens tab run, /ops run an alias.
377const WORDS = ['tickets', 'workflows', 'runs', 'health', 'messages', 'agents', 'explorer', 'build', 'run']
378const SECTION_OF: Record<string, 'runs' | 'workflows' | 'agents'> = { runs: 'runs', workflows: 'workflows', agents: 'agents' }
379const USAGE = 'usage: /ops [tickets|workflows|health|messages|explorer|build|resume [force]|watchdog on] - runs and agents open Workflows too, run opens Build;'
380  + ' resume force releases the hold whatever the reading and mutes memory red until it reads green once (watchdog on unmutes)'
381
382export const register: Register = on => {
383  on('session.start', async ($, e, next) => {
384    await $.command.register({
385      name: 'ops',
386      description: 'Open the ops board pane: the TICKETS.md board, workflows (relay runs, Workflow runs and agents), health, Teams messages, the explorer and the build entries',
387      argumentHint: '[tickets|workflows|health|messages|explorer|build|resume [force]|watchdog on]',
388    })
389    await $.command.register({
390      name: 'list-tickets',
391      description: 'List the TICKETS.md board (tickets-zordon text format), no model turn',
392      argumentHint: '[status] [app:<a>] [@person] [prefix|ID] [all]',
393    })
394    await $.command.register({
395      name: 'pending',
396      description: 'What is pending: board counts, runs, sessions, machine (pending script), no model turn',
397    })
398    void startNotes($).catch(() => {})
399    void arm($).catch(() => {})
400    void openPane($, false).catch(() => {})
401    return next(e)
402  })
403
404  // F50: /list-tickets and /pending answer on their own - { text }, no next, no model turn.
405  on('command.run', { command: 'list-tickets' }, async ($, e) => {
406    const repo = await $.session.repo()
407    if (!repo) return { text: 'not a git repo - no TICKETS.md board here' }
408    const path = norm(repo.root) + '/TICKETS.md'
409    let st: { mtimeMs: number; size: number }
410    try { st = await $.fs.stat(path) } catch { return { text: `no TICKETS.md at ${norm(repo.root)}` } }
411    const board = await loadBoard($, path, st)
412    return { text: board.error ?? listView(board, e.args) }
413  })
414
415  on('command.run', { command: 'pending' }, async ($) => {
416    const r = await runCall($, 'pending', [])
417    if (!r.argv.length) return { text: r.err || NO_SCRIPTS }
418    if (r.code === 0) return { text: r.out }
419    const why = r.err.trim() || r.out.trim() || `exit ${r.code}`
420    return { text: `pending failed (exit ${r.code}): ${why}\nran: ${r.argv.join(' ')}` }
421  })
422
423  on('command.run', { command: 'ops' }, async ($, e) => {
424    void arm($).catch(() => {})
425    const word = e.args.trim().toLowerCase().split(/\s+/).join(' ')
426    if (word === 'resume') return { text: await resume($) }
427    if (word === 'resume force') return { text: await forceResume($) }       // F87
428    if (word === 'watchdog on') return { text: await watchdogOn($) }
429    if (word && !WORDS.includes(word)) return { text: USAGE }
430    const tab: Tab | null = word ? (SECTION_OF[word] ? 'runs' : word === 'build' ? 'run' : word as Tab) : null
431    const sec = SECTION_OF[word]
432    // A form draws only on Tickets: leaving it drops the form, or a later Esc / close
433    // on that tab would be taken as the hidden form's Cancel.
434    if (tab && tab !== 'tickets') await clearForm($)
435    if (tab && tab !== 'run') await dropRunForm($)
436    if (tab && tab !== 'runs') await dropEngage($)        // F85: the engage confirm likewise
437    if (tab) await update($, tabA, () => tab)
438    if (sec) await update($, runsOpenA, x => (x.includes(sec) ? x : [...x, sec]))
439    if (tab === 'runs') void pollRuns($).catch(() => {})
440    if (tab === 'runs') void pollAgents($).catch(() => {})
441    if (word === 'messages') void enterMessages($).catch(() => {})
442    if (word === 'explorer') void enterExplorer($).catch(() => {})
443    if (tab === 'run') void enterRun($).catch(() => {})     // F79: the Build tab, first open scans
444    const r = await openPane($, true)
445    if (!r.isPlaced) return { text: `ops board is open but not drawn: ${r.reason}` }
446    if (word === 'runs') return { text: 'ops board opened on Workflows, Runs open.' }
447    if (word === 'agents') return { text: 'ops board opened on Workflows, Agents open.' }
448    return { text: tab ? `ops board opened on ${TAB_NAME[tab]}.` : 'ops board opened.' }
449  })
450
451  on('ui.render', { component: 'Pane', requestId: 'ops-board' }, async ($, e) => {
452    if (!armed) $.clock.after(0, () => { void arm($).catch(() => {}) })
453    lastScroll = e.props.scroll
454    lastCols = Math.max(20, e.props.bodyColumns)
455    return renderPane($, e)
456  })
457
458  // F48: the band above the prompt, always drawn (green when fine); a survey has it.
459  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
460    if (e.props.hasSurvey) return next(e)
461    if (!armed) $.clock.after(0, () => { void arm($).catch(() => {}) })
462    return renderBand($, e)
463  })
464
465  on('session.measure', async ($, e, next) => {
466    const now = await $.clock.now()
467    if (e.rateLimits.length) await setUsage($, usageFrom(e.rateLimits, e.cost?.usd ?? null, now)).catch(() => {})
468    else await pollUsage($).catch(() => {})
469    return next(e)
470  })
471
472  // Session end: a CLAUDE.md the baseline harness left hidden goes back, said in the
473  // log and kept for the next session's band.
474  on('session.end', async ($, e, next) => {
475    try {
476      const lines = await restoreClaudeMd($, 'restore', next.budget.remainingMs - 500)
477      for (const l of lines) $.ui.log(l)
478      if (lines.length) await addNotes($, lines, isHiddenNote)
479    } catch { /* the next start reports it */ }
480    return next(e)
481  })
482
483  // F53: the Agents tab and the Workflows tab's Workflows section read the engine's own events; each passes its
484  // event through as it came (the spawn, the turn, the tool call untouched).
485  on('agent.spawn', async ($, e, next) => {
486    const r = await next(e)
487    await noteSpawn($, e, r).catch(() => {})
488    return r
489  }).catch(($, e, next) => next(e))
490
491  on('turn.start', async ($, e, next) => {
492    if (e.agentId !== undefined) await noteTurnStart($, e.agentId).catch(() => {})
493    return next(e)
494  }).catch(($, e, next) => next(e))
495
496  on('turn.complete', async ($, e, next) => {
497    if (e.agentId !== undefined) await noteTurnEnd($, e.agentId, e.reason).catch(() => {})
498    return next(e)
499  }).catch(($, e, next) => next(e))
500
501  // Every tool call in an agent's loop stamps its last activity, unawaited so no tool
502  // waits on it; the Workflow tool's answer names its run.
503  on('tool.call', async ($, e, next) => {
504    if (e.agentId !== undefined) void noteActivity($, e.agentId).catch(() => {})
505    if (e.tool !== 'Workflow') return next(e)
506    const r = await next(e)
507    await noteWorkflowCall($, e.tool_use_id, r).catch(() => {})
508    return r
509  }).catch(($, e, next) => next(e))
510
511  // B5: while an assign / add person form is up in the board, Esc (the form opens
512  // the pane closeOnEscape) or the close mark is its Cancel: the form goes, the
513  // view it came from is back, the board stays open. An unload passes, form cleared.
514  on('ui.close', async ($, e, next) => {
515    // F66: the Teams setup form up - Esc is its Cancel (an unload just drops it)
516    if (e.id === PANE_ID && (await isSetupUp($))) {
517      await closeSetup($)
518      if (e.origin.kind === 'unload') return next(e)
519      return { value: undefined }
520    }
521    // F79: the Run tab's confirm / rename form up - Esc is its Cancel
522    if (e.id === PANE_ID && e.origin.kind === 'unload') await dropRunForm($)
523    if (e.id === PANE_ID && (await isRunFormUp($))) {
524      await closeRunForm($)
525      return { value: undefined }
526    }
527    // F85: the engage confirm up - Esc is its Cancel
528    if (e.id === PANE_ID && e.origin.kind === 'unload') await dropEngage($)
529    if (e.id === PANE_ID && (await isEngageUp($))) {
530      await closeEngage($)
531      return { value: undefined }
532    }
533    if (e.id !== PANE_ID || !(await isFormUp($))) return next(e)
534    if (e.origin.kind === 'unload') { await clearForm($); return next(e) }
535    await cancelPerson($)                                   // what the form's Cancel does
536    return { value: undefined }
537  }).catch(($, e, next) => next(e))
538
539  // Up/down: the arrows on a focused pane arrive as scrolls with no pointer. One
540  // row moves the selection instead of the window; the window follows only when
541  // the selection would leave it. Wheel ticks and page keys scroll as ever.
542  // F61: the selection walks the group headers and the rows of open groups, and the
543  // focus ring follows it, so Enter opens or closes the header shown selected.
544  on('ui.scroll', { requestId: 'ops-board' }, async ($, e, next) => {
545    if (e.pointer || e.origin.kind !== 'person' || Math.abs(e.by) !== 1) return next(e)
546    if ((await read($, tabA)) === 'explorer') {           // F75: the Explorer's own items
547      const o = await arrowExplorer($, e.by, lastScroll.offset, e.bodyRows, lastCols)
548      return o === undefined ? next(e) : o === null || e.contentRows <= e.bodyRows ? {} : next({ ...e, offset: o })
549    }
550    if ((await read($, tabA)) !== 'tickets' || (await isFormUp($))) return next(e)   // a form scrolls as itself
551    const m = buildModel(await readView($, lastCols))
552    const at = m.selected === null ? -1 : m.items.indexOf(m.selected)
553    const to = m.items[at + e.by]
554    if (at < 0 || !to) return next(e)
555    await update($, selectedA, () => to)
556    void $.ui.focus({ requestId: PANE_ID, key: lineKey(to) }).catch(() => {})   // a site not holding the keys: no ring to move
557    if (e.contentRows <= e.bodyRows) return {}
558    const offset = offsetFor(m, to, lastScroll.offset, e.bodyRows)
559    return offset === null ? {} : next({ ...e, offset })
560  }).catch(($, e, next) => next(e))   // failing, the arrow scrolls as the engine would
561
562  // F78: the Tickets tab's `r` - a forced poll here, where the poll lives.
563  on('ui.press', { plugin: 'ops-board', element: 'key:refresh' }, async ($, e) => {
564    await refresh($)
565    return { element: e.element }
566  })
567
568  // F87: the Health tab's `f` - /ops resume force, answered in a toast.
569  on('ui.press', { plugin: 'ops-board', element: 'key:force' }, async ($, e) => {
570    $.ui.toast(await forceResume($))
571    return { element: e.element }
572  })
573
574  // F81: the reconcile form's Accept all / Apply picked - ticket-meta writes each row
575  // (reconcile.tsx), then the board is re-read here, where the poll lives.
576  on('ui.press', { plugin: 'ops-board', element: 'reconcile:all' }, async ($, e) => {
577    await applyReconcile($, true)
578    await poll($, true)
579    return { element: e.element }
580  })
581  on('ui.press', { plugin: 'ops-board', element: 'reconcile:picked' }, async ($, e) => {
582    await applyReconcile($, false)
583    await poll($, true)
584    return { element: e.element }
585  })
586
587  // Tab and shift-Tab walk the row and header buttons: the ring landing on one selects it.
588  on('ui.focus', { requestId: 'ops-board' }, async ($, e, next) => {
589    const el = e.element
590    if (el?.startsWith('row:')) await update($, selectedA, () => el.slice(4))
591    else if (el?.startsWith('head:')) await update($, selectedA, () => el)
592    else if (el && isExplorerItem(el)) await focusExplorer($, el)          // F75
593    return next(e)
594  }).catch(($, e, next) => next(e))
595}
596
hooks/lib/board.ts 174 lines
1import type { Color } from 'claude-code'
2import type { Board, Box, Filter, Ticket } from '../../types'
3
4export type StateColor = 'amber' | 'cyan' | 'red' | 'violet' | 'neutral' | 'dim' | 'green'
5// The pane shows state by colour; listView() keeps the bracket codes.
6export const STATE_COLOR: Record<Box, StateColor> = {
7  '~': 'amber', '%': 'cyan', '!': 'red', '?': 'violet', ' ': 'neutral', '>': 'dim', 'x': 'green', '-': 'dim',
8}
9// Theme keys follow the person's theme on every surface; dim states are drawn dim.
10export const PAINT: Record<StateColor, Color> = {
11  amber: 'warning', cyan: 'suggestion', red: 'error', violet: 'permission', neutral: 'text', dim: 'inactive', green: 'success',
12}
13export const paint = (b: Box): Color => PAINT[STATE_COLOR[b]]
14// Open = [ ] [~] [%] [!] [?] (tickets-zordon, pending); deferred and closed are not.
15// F62: [%] is testing - built, waiting on a test result.
16const OPEN: Box[] = [' ', '~', '%', '!', '?']
17export const isOpen = (t: Ticket) => OPEN.includes(t.box)
18// F77: the ticket a typed ID names on the board - case-insensitive, blanks ignored
19// (`f12` is F12); null when none does or nothing is typed.
20export function ticketById(tickets: readonly Ticket[] | undefined, text: string): Ticket | null {
21  const id = text.trim().toUpperCase()
22  return id ? tickets?.find(t => t.id.toUpperCase() === id) ?? null : null
23}
24export const STATE_NAME: Record<Box, string> = {
25  '~': 'in progress', '%': 'testing', '!': 'blocked', '?': 'pending decision', ' ': 'pending', '>': 'deferred', 'x': 'done', '-': 'cancelled',
26}
27export const GROUPS: { box: Box; title: string }[] = [
28  { box: '~', title: 'IN PROGRESS' }, { box: '%', title: 'TESTING' }, { box: '!', title: 'BLOCKED' }, { box: '?', title: 'PENDING DECISION' },
29  { box: ' ', title: 'PENDING' }, { box: '>', title: 'DEFERRED' }, { box: 'x', title: 'DONE' }, { box: '-', title: 'CANCELLED' },
30]
31
32// A ticket line as ticket-meta and pending read it: `- [b]`, blanks, the id
33// ([A-Z]{1,3}[0-9]+[a-z]?), then an em dash, an en dash or a plain `-`.
34const TICKET = /^([ \t]*)- \[([ ~%!?>x-])\][ \t]+([A-Z]{1,3}[0-9]+[a-z]?)[ \t]*(?:—|–|-)[ \t]*(.*)$/
35// Anchored on the id, so `F3—re-run the GREEN` keeps "re-run the GREEN".
36const ID_DASH = /^[A-Z]{1,3}[0-9]+[a-z]?[ \t]*(?:—|–|-)[ \t]*/
37const handles = (s: string) => s.split(',').map(x => x.trim().toLowerCase()).filter(Boolean)
38const tag = (s: string, name: string) => new RegExp('\\[' + name + ':\\s*([^\\]]*)\\]').exec(s)?.[1]?.trim() ?? null
39const SESSION_TAIL = /\s\((@[\w-]+)\)\s*$/
40
41export function parseBoard(text: string, path: string, mtimeMs: number, size: number): Board {
42  const tickets: Ticket[] = []
43  const stack: Ticket[] = []
44  let section = ''
45  for (const raw of text.split(/\r?\n/)) {
46    const h = /^#{1,6}\s+(.*?)\s*$/.exec(raw)
47    if (h) { section = h[1]!; stack.length = 0; continue }
48    const m = TICKET.exec(raw)
49    if (!m) continue
50    const indent = m[1]!.length
51    const line = raw.slice(indent + 5).replace(/^[ \t]+/, '')   // text after "- [b] "
52    let body = m[4]!
53    let session: string | null = null
54    const s = SESSION_TAIL.exec(body)
55    if (s) { session = s[1]!; body = body.slice(0, s.index) }
56    let cut = body.search(/ \[(?:app|decide|owner|by):/)
57    if (cut < 0) cut = body.search(/ \([^()]*\)\s*$/)
58    const title = (cut < 0 ? body : body.slice(0, cut)).trim()
59    while (stack.length && stack[stack.length - 1]!.indent >= indent) stack.pop()
60    const t: Ticket = {
61      id: m[3]!, box: m[2] as Box, title, line, parent: stack.length ? stack[stack.length - 1]!.id : null,
62      indent, section,
63      apps: handles(tag(body, 'app') ?? ''), decide: tag(body, 'decide')?.toLowerCase() ?? null,
64      owner: tag(body, 'owner')?.toLowerCase() ?? null, by: handles(tag(body, 'by') ?? ''), session,
65    }
66    tickets.push(t)
67    stack.push(t)
68  }
69  return { path, mtimeMs, size, tickets, error: null }
70}
71
72export function visible(b: Board, f: Filter, me: string | null): Ticket[] {
73  const who = (f.mine ? me : f.who)?.toLowerCase() ?? null
74  const app = f.app?.toLowerCase() ?? null
75  return b.tickets.filter(t => {
76    if (!f.showDone && (t.box === 'x' || t.box === '-')) return false
77    if (app && !t.apps.includes(app)) return false
78    if (f.mine && !me) return false
79    if (who && t.owner !== who && t.decide !== who && !t.by.includes(who)) return false
80    return true
81  })
82}
83
84export function countLine(ts: Ticket[]): string {
85  const n = (b: Box) => ts.filter(t => t.box === b).length
86  const open = n(' ') + n('~') + n('%') + n('!') + n('?')
87  const closed = n('x') + n('-')
88  return `${open} open (${n(' ')} pending · ${n('~')} in-progress · ${n('%')} testing · ${n('!')} blocked · ${n('?')} pending decision)` +
89    ` · ${n('>')} deferred · ${closed} closed (${n('x')} done · ${n('-')} cancelled) · ${ts.length} total`
90}
91
92const TYPE_NAME: Record<string, string> = { B: 'Bugs', F: 'Features', A: 'Agent-found' }
93const TYPE_ORDER = ['B', 'F', 'A']
94const STATUS_RANK: Box[] = ['~', '%', '!', '?', '>', ' ']
95const STATUS_VIEW: { box: Box; title: string }[] = [
96  { box: '~', title: 'In progress' }, { box: '%', title: 'Testing' }, { box: '!', title: 'Blocked' }, { box: '?', title: 'Pending decision' },
97  { box: '>', title: 'Deferred' }, { box: ' ', title: 'Pending' }, { box: 'x', title: 'Done' }, { box: '-', title: 'Cancelled' },
98]
99const prefixOf = (id: string) => (/^[A-Za-z]+/.exec(id)?.[0] ?? id).toUpperCase()
100const numOf = (id: string) => Number(/\d+/.exec(id)?.[0] ?? 0)
101
102// The ticket's own words with the bracket code; the session tag is bookkeeping and is left off.
103// Any of the three dashes the file may hold is shown as the em dash.
104const shown = (t: Ticket) => `[${t.box}] ${t.id} — ${t.line.replace(ID_DASH, '').replace(SESSION_TAIL, '')}`
105
106// /list-tickets body: tickets-zordon "Showing the board". Args: status, all, app:<a>, @person, <prefix>, <ID>.
107export function listView(b: Board, args: string): string {
108  let status = false, all = false
109  let app: string | null = null, person: string | null = null, prefix: string | null = null, id: string | null = null
110  for (const w of args.split(/\s+/).filter(Boolean)) {
111    const lw = w.toLowerCase()
112    if (lw === 'status') status = true
113    else if (lw === 'all') all = true
114    else if (lw.startsWith('app:')) app = lw.slice(4)
115    else if (w.startsWith('@')) person = lw
116    else if (/^[A-Za-z]+$/.test(w)) prefix = w.toUpperCase()
117    else if (/^[A-Za-z]+\d+[A-Za-z]*$/.test(w)) id = lw
118  }
119  const pool = b.tickets.filter(t =>
120    (!app || t.apps.includes(app)) &&
121    (!person || t.owner === person || t.by.includes(person)) &&
122    (!prefix || (prefixOf(t.id) === prefix && /^\d/.test(t.id.slice(prefix.length)))) &&
123    (!id || t.id.toLowerCase() === id))
124  const list = pool.filter(t => all || (t.box !== 'x' && t.box !== '-'))
125  const ids = new Set(list.map(t => t.id))
126  const byId = new Map(b.tickets.map(t => [t.id, t] as const))
127  const openCount = pool.filter(isOpen).length
128  const big = openCount >= 30 && !prefix
129  const out: string[] = []
130
131  if (status) {
132    for (const g of STATUS_VIEW) {
133      const rows = list.filter(t => t.box === g.box)
134      if (!rows.length) continue
135      out.push(g.title)
136      for (const t of rows) out.push('  ' + shown(t) + (t.parent ? ` (${t.parent})` : ''))
137      out.push('')
138    }
139  } else {
140    const types = [...new Set(list.map(t => prefixOf(t.id)))].sort((a, c) => {
141      const ia = TYPE_ORDER.indexOf(a), ic = TYPE_ORDER.indexOf(c)
142      return (ia < 0 ? 99 : ia) - (ic < 0 ? 99 : ic) || a.localeCompare(c)
143    })
144    for (const ty of types) {
145      let rows = list.filter(t => prefixOf(t.id) === ty)
146      let extra = 0
147      if (big) {
148        rows = [...rows].sort((p, q) => {
149          const rp = STATUS_RANK.indexOf(p.box), rq = STATUS_RANK.indexOf(q.box)
150          return (rp < 0 ? 9 : rp) - (rq < 0 ? 9 : rq) || numOf(p.id) - numOf(q.id)
151        })
152        const drop = new Set(rows.filter(t => t.box === ' ').slice(20))
153        extra = drop.size
154        rows = rows.filter(t => !drop.has(t))
155      }
156      out.push(TYPE_NAME[ty] ?? ty)
157      for (const t of rows) {
158        // Nest under a shown parent; a parent that is not shown is named in trailing parentheses.
159        let depth = 0, gone: string | null = null
160        for (let p = t.parent; p && !big; p = byId.get(p)?.parent ?? null) {
161          if (ids.has(p)) depth++
162          else { gone = p; break }
163        }
164        const hang = big ? (t.parent ? ` (${t.parent})` : '') : (gone ? ` (${gone})` : '')
165        out.push('  '.repeat(1 + depth) + shown(t) + hang)
166      }
167      if (extra) out.push(`… ${extra} more pending in ${TYPE_NAME[ty] ?? ty} — \`list tickets ${ty}\` for all of them`)
168      out.push('')
169    }
170  }
171  out.push(countLine(pool))
172  return out.join('\n')
173}
174
hooks/lib/team.ts 55 lines
1import type { Board, Member } from '../../types'
2import { isOpen } from './board'
3
4// `.skillator/team.md`, read as ticket-meta reads it (team_rows / team_members): a
5// member line is `^[ \t]*[-*][ \t]*@[A-Za-z0-9_.-]+[ \t]*:` then emails, comma-split
6// and lower-cased; the handle is kept as written, since ticket-meta assign / decide /
7// team remove match it exactly. An indented `  name: Full Name` line straight under a
8// member names it (the last one wins, as in ticket-meta); any other line ends that.
9// F80: the first `lead: @handle` line (ticket-meta lead_of) marks that member lead.
10const MEMBER = /^[ \t]*[-*][ \t]*(@[A-Za-z0-9_.-]+)[ \t]*:(.*)$/
11const NAME = /^[ \t]+name:[ \t]*(.*?)[ \t]*$/
12const LEAD = /^lead:[ \t]*(@[A-Za-z0-9_.-]+)[ \t]*$/m
13
14export function parseTeam(text: string): Member[] {
15  const out: Member[] = []
16  let last: Member | null = null
17  const lead = LEAD.exec(text.replace(/\r/g, ''))?.[1]
18  for (const raw of text.split(/\r?\n/)) {
19    const m = MEMBER.exec(raw)
20    if (m) {
21      const emails = m[2]!.split(',').map(s => s.trim().toLowerCase()).filter(Boolean)
22      last = { handle: m[1]!, emails, ...(sameHandle(m[1], lead) ? { lead: true as const } : {}) }
23      out.push(last)
24      continue
25    }
26    const n = NAME.exec(raw)
27    if (n && last) {
28      if (n[1]) last.name = n[1]
29      else delete last.name
30    } else last = null
31  }
32  return out
33}
34
35// Handles compare without case where the board is read (its tags are lower-cased).
36export const sameHandle = (a: string | null | undefined, b: string | null | undefined) =>
37  !!a && !!b && a.toLowerCase() === b.toLowerCase()
38
39// F80: as ticket-meta whoami - SKILLATOR_ME (a member as written, @ optional) overrides the email.
40export function meOf(team: Member[], email: string, envMe?: string | null): string | null {
41  if (envMe) { const h = envMe.startsWith('@') ? envMe : '@' + envMe; return team.find(m => m.handle === h)?.handle ?? null }
42  const e = email.trim().toLowerCase()
43  if (!e) return null
44  return team.find(m => m.emails.includes(e))?.handle ?? null
45}
46
47// "lead · (you) · 3 owned · 1 decide" over the open tickets ([ ] [~] [%] [!] [?]).
48export function ticketsOf(m: Member, board: Board | null, me: string | null): string {
49  const open = (board?.tickets ?? []).filter(isOpen)
50  const owned = open.filter(t => sameHandle(t.owner, m.handle)).length
51  const decide = open.filter(t => sameHandle(t.decide, m.handle)).length
52  const parts = [...(m.lead ? ['lead'] : []), ...(m.handle === me ? ['(you)'] : []), ...(owned ? [`${owned} owned`] : []), ...(decide ? [`${decide} decide`] : [])]
53  return parts.join(' · ') || '-'
54}
55
hooks/lib/facts.ts 194 lines
1import type { Level, Lock, Machine, Usage, Watch, Window } from '../../types'
2
3// Pure: the facts the band and the Health tab draw from (design §6, §7, §8).
4
5export const GiB = 1073741824
6export const STALE_MS = 15 * 60 * 1000          // a usage figure older than this is stale
7export const HOLD_UNKNOWN = '? -'               // HOLD= of an empty hold file: no since, no reason
8
9// A hold in words: its own "<since> <reason>", or "held (reason unknown)" for `? -`.
10export function holdWords(hold: string): string {
11  return hold === HOLD_UNKNOWN ? 'held (reason unknown)' : hold
12}
13
14const num = (v: string | undefined): number | null => {
15  if (v === undefined || !/^\d+(\.\d+)?$/.test(v.trim())) return null
16  return Number(v.trim())
17}
18
19// `pending --machine` (or the same keys inside `pending --kv`): DRIVE, DISK_FREE,
20// DISK_TOTAL, MEM_FREE, LOCK=<path>|<age min>|<holder> (0..n), HOLD=<since> <reason> | -, BOARD (F82).
21// Keys it does not know are skipped, so the whole --kv output parses too.
22export function parseMachine(kv: string, now: number): Machine {
23  const m: Machine = { at: now, drive: '', diskFree: null, diskTotal: null, memFree: null, locks: [], hold: null }
24  for (const raw of kv.split('\n')) {
25    const line = raw.replace(/\r$/, '')
26    const eq = line.indexOf('=')
27    if (eq < 1) continue
28    const key = line.slice(0, eq), val = line.slice(eq + 1)
29    switch (key) {
30      case 'DRIVE': m.drive = val.trim(); break
31      case 'DISK_FREE': m.diskFree = num(val); break
32      case 'DISK_TOTAL': m.diskTotal = num(val); break
33      case 'MEM_FREE': m.memFree = num(val); break
34      // R6: only `-` (no hold file) is no hold; any other value, an empty one too, is held.
35      case 'HOLD': m.hold = val.trim() === '-' ? null : val.trim() || HOLD_UNKNOWN; break
36      // F87: MUTE= is not pending's: each host appends the mute file's first line (muteKv)
37      case 'MUTE': if (val.trim()) m.mute = val.trim(); break
38      // F82: BOARD=stale|<behind>|<upstream>|- or BOARD=refused|<behind>|<upstream>|<reason>
39      case 'BOARD': {
40        const [state, behind, up, ...why] = val.split('|')
41        if (state === 'stale' || state === 'refused') m.board = { state, behind: num(behind) ?? 0, up: up ?? '', reason: state === 'refused' ? why.join('|') : '' }
42        break
43      }
44      case 'LOCK': {
45        const a = val.indexOf('|'), b = a < 0 ? -1 : val.indexOf('|', a + 1)
46        if (a < 0) break
47        const lock: Lock = {
48          path: val.slice(0, a),
49          ageMin: num(b < 0 ? val.slice(a + 1) : val.slice(a + 1, b)) ?? 0,
50          holder: b < 0 ? '' : val.slice(b + 1),
51        }
52        m.locks.push(lock)
53        break
54      }
55    }
56  }
57  return m
58}
59
60// "17 GB" | "3.2 GB" | "?": one decimal below 10, a whole number from 10.
61// Given the threshold a figure is below (`limit`, bytes), its text never reads as that
62// threshold: where rounding would reach it ("1.0 GB" for 0.97 GB below 1 GB), the
63// figure is floored instead ("0.9 GB"), to two decimals should one still reach it.
64export function gb(bytes: number | null, limit?: number): string {
65  if (bytes === null || !Number.isFinite(bytes) || bytes < 0) return '?'
66  const v = bytes / GiB
67  const tenth = Math.round(v * 10) / 10
68  const text = tenth < 10 ? tenth.toFixed(1) : String(Math.round(v))
69  if (limit === undefined || bytes >= limit || Number(text) * GiB < limit) return text + ' GB'
70  const floor1 = Math.floor(v * 10) / 10
71  if (floor1 * GiB < limit) return floor1.toFixed(1) + ' GB'
72  return (Math.floor(v * 100) / 100).toFixed(2) + ' GB'
73}
74
75// Thresholds (design CONSTRAINTS): disk free red < 1 GB, amber < 5 GB; memory free
76// red < 1 GB, amber < 2 GB.
77export const DISK = { red: GiB, amber: 5 * GiB } as const
78export const MEM = { red: GiB, amber: 2 * GiB } as const
79export type Limits = { readonly red: number; readonly amber: number }
80
81// One figure as the band and the Health tab show it: its level (null when unknown)
82// and its text, floored against the threshold it is below.
83export function figure(bytes: number | null, t: Limits): { level: Level | null; text: string } {
84  if (bytes === null || !Number.isFinite(bytes) || bytes < 0) return { level: null, text: '?' }
85  if (bytes < t.red) return { level: 'red', text: gb(bytes, t.red) }
86  if (bytes < t.amber) return { level: 'amber', text: gb(bytes, t.amber) }
87  return { level: 'green', text: gb(bytes) }
88}
89
90// red: disk or memory free < 1 GB. amber: disk < 5 GB, memory < 2 GB, a stale lock
91// (the script lists only locks older than 10 min), or a watchdog hold still set.
92// An unknown figure is never red. `reasons` are the band's words for the level.
93// F87: while muted, memory red is amber (it never holds); disk red stays red.
94export function classify(m: Machine): Watch {
95  const red: string[] = [], amber: string[] = []
96  for (const [name, bytes, t] of [['disk', m.diskFree, DISK], ['mem', m.memFree, MEM]] as const) {
97    const f = figure(bytes, t)
98    if (f.level === 'red') (name === 'mem' && m.mute ? amber : red).push(`${name} ${f.text} free (below 1 GB)`)
99    else if (f.level === 'amber') amber.push(`${name} ${f.text} low`)
100  }
101  if (red.length) return { level: 'red', reasons: red }
102  if (m.mute) amber.push(mutedWords(m.mute))
103  if (m.locks.length) amber.push(`locks ${m.locks.length} stale`)
104  if (m.hold !== null) amber.push('held - /ops resume to release')
105  return amber.length ? { level: 'amber', reasons: amber } : { level: 'green', reasons: [] }
106}
107
108// F87: /ops resume force mutes memory red until memory reads green once (or /ops watchdog
109// on). The mute lives beside relay-morpheus's watchdog.hold, in the main checkout, so the
110// pane and the ops app (two processes) both read it; "<since> mem <figure> free", blank =
111// not muted (the pane's fs cannot delete, so unmuting writes it empty).
112export const MUTE_FILE = '.skillator/watchdog.mute'
113export const muteText = (now: number, memFree: number | null) =>
114  `${new Date(now).toISOString().slice(0, 16)}Z mem ${gb(memFree)} free`
115// The kv line a host appends to pending --machine's output: the file's first line, or none.
116export const muteKv = (text: string | null) => {
117  const line = (text ?? '').split(/\r?\n/)[0]!.trim()
118  return line ? `\nMUTE=${line}\n` : ''
119}
120export function mutedWords(mute: string): string {
121  const hm = /T(\d\d:\d\d)/.exec(mute.split(' ')[0] ?? '')?.[1]
122  return `watchdog muted (forced ${hm ? hm + 'Z' : mute.split(' ')[0]})`
123}
124// Memory read green (2 GB or more free) while muted: the mute ends.
125export const unmuteDue = (m: Machine) => !!m.mute && figure(m.memFree, MEM).level === 'green'
126export const MUTED_HOW = 'memory red does not hold until memory reads green once, or /ops watchdog on; disk red still holds'
127
128const mkWindow =(pct: number, resetsAt: string | null, source: Window['source'], at: number): Window =>
129  ({ pct, resetsAt, source, at })
130
131// `$.session.usage().rateLimits` (the status line's windows) and `cost.usd`.
132export function usageFrom(rate: { kind: string; percentUsed: number; resetsAt?: string }[], cost: number | null, now: number): Usage {
133  const pick = (kind: string) => rate.find(r => r.kind === kind && Number.isFinite(r.percentUsed))
134  const five = pick('five_hour'), seven = pick('seven_day'), spend = pick('spend_limit')
135  return {
136    five: five ? mkWindow(five.percentUsed, five.resetsAt ?? null, 'host', now) : null,
137    seven: seven ? mkWindow(seven.percentUsed, seven.resetsAt ?? null, 'host', now) : null,
138    spendLimitPct: spend ? spend.percentUsed : null,
139    costUsd: cost,
140    decision: null,
141  }
142}
143
144// `usage-watch level`: "src=<claude-code|codex|none> 5h=<pct|-> 7d=<pct|-> ctx=<pct|-> max=<pct|->".
145// src=none or a '-' figure is null. Never throws. The hold decision stays relay's.
146export function usageFromLevel(line: string, now: number): Usage {
147  const kv = new Map<string, string>()
148  for (const w of line.replace(/\r/g, '').trim().split(/\s+/)) { const i = w.indexOf('='); if (i > 0) kv.set(w.slice(0, i), w.slice(i + 1)) }
149  const pct = (k: string) => { const v = kv.get(k); return v !== undefined && /^\d+(\.\d+)?$/.test(v) ? Number(v) : null }
150  const w = (p: number | null) => (p === null || kv.get('src') === 'none' ? null : mkWindow(p, null, 'watch-cortana', now))
151  return { five: w(pct('5h')), seven: w(pct('7d')), spendLimitPct: null, costUsd: null, decision: null }
152}
153
154// The first number in a watch-cortana flag file (a BOM, CR or stray space never counts).
155export function pctOf(text: string): number | null {
156  const m = /[0-9]+(\.[0-9]+)?/.exec(text)
157  return m ? Number(m[0]) : null
158}
159
160export type Flag = { text: string; mtimeMs: number }
161// Fallback only: this session's own `<id>` (5-hour) and `<id>.weekly` flag files; the
162// reading's time is the file's mtime, so its age shows.
163export function usageFromFlags(five: Flag | null, weekly: Flag | null, cost: number | null): Usage {
164  const w = (f: Flag | null) => {
165    const p = f ? pctOf(f.text) : null
166    return f && p !== null ? mkWindow(p, null, 'watch-cortana', f.mtimeMs) : null
167  }
168  return { five: w(five), seven: w(weekly), spendLimitPct: null, costUsd: cost, decision: null }
169}
170
171export function isStale(w: Window, now: number): boolean {
172  return now - w.at > STALE_MS
173}
174
175// "7m old" | "2h old" | "3d old"
176export function ageText(w: Window, now: number): string {
177  const min = Math.max(0, Math.floor((now - w.at) / 60000))
178  if (min < 60) return `${min}m old`
179  if (min < 24 * 60) return `${Math.floor(min / 60)}h old`
180  return `${Math.floor(min / (24 * 60))}d old`
181}
182
183// The line `relay-morpheus chain decide --level` reads (usage-watch `level` format):
184// "src=host 5h=41 7d=55 ctx=- max=-". Given `now`, a figure past 15 minutes is left out,
185// so a stale reading never decides a hold; nothing left is `src=none` (UNOBSERVABLE).
186export function levelLine(u: Usage, now?: number): string {
187  const ok = (w: Window | null) => (w && (now === undefined || !isStale(w, now)) ? w : null)
188  const five = ok(u.five), seven = ok(u.seven)
189  const src = !five && !seven ? 'none'
190    : [five, seven].some(w => w?.source === 'watch-cortana') ? 'watch-cortana' : 'host'
191  const p = (w: Window | null) => (w ? String(w.pct) : '-')
192  return `src=${src} 5h=${p(five)} 7d=${p(seven)} ctx=- max=-`
193}
194
hooks/lib/host.ts 142 lines
1import type { FsEntry, ProcessRunInit, ProcessRunResult, SessionRepo, SessionUsage } from 'claude-code'
2import type { Usage } from '../../types'
3import { usageFrom, usageFromFlags } from './facts'
4import type { Flag } from './facts'
5import { argvFor, findExe, findNode, isNodeCall, MARKER, NO_NODE, NO_SCRIPTS, NO_SH, norm, shellFor, up } from './calls'
6import type { Call } from './calls'
7export { argvFor, findNode, NO_SCRIPTS } from './calls'
8export type { Call } from './calls'
9
10// $-using: the platform, where skillator's scripts live (the skills dir), and running a
11// call from the table in ./calls (design §2, D2/D3). No shell: argv only.
12
13// The part of `$` this file uses. The plugin's `$` (EngineInterface) is one.
14export type HostApi = {
15  plugin: { root: string }
16  env: { get: (name: string) => Promise<string | undefined> }
17  process: { run: (argv: readonly string[], init?: ProcessRunInit) => Promise<ProcessRunResult> }
18  fs: {
19    exists: (path: string) => Promise<boolean>
20    read: (path: string) => Promise<string>
21    list: (path?: string) => Promise<FsEntry[]>
22  }
23  session: {
24    repo: () => Promise<SessionRepo | null>
25    usage: () => Promise<SessionUsage>
26    id: () => Promise<string>
27  }
28}
29
30export async function isWindows($: HostApi): Promise<boolean> {
31  return (await $.env.get('OS')) === 'Windows_NT'
32}
33
34async function has($: HostApi, dir: string): Promise<boolean> {
35  try { return await $.fs.exists(dir + '/' + MARKER) } catch { return false }
36}
37
38async function configDir($: HostApi): Promise<string | null> {
39  const cfg = await $.env.get('CLAUDE_CONFIG_DIR')
40  if (cfg) return norm(cfg)
41  const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
42  return home ? norm(home) + '/.claude' : null
43}
44
45// The skills dir (the directory that contains tickets-zordon/): the first that holds
46// tickets-zordon/scripts/ticket-meta.sh of $SKILLATOR_ROOT/skills, $SKILLATOR_ROOT itself
47// (a flat install dest), the plugin folder two up + /skills (a repo checkout), then each
48// installPath + /skills of skillator@skillator in <config dir>/plugins/installed_plugins.json.
49// None: null. A136: never the session's repo - any repo opened would otherwise have its
50// own scripts run by the band's poll, unasked. Developing in a checkout: set SKILLATOR_ROOT.
51export async function skillatorRoot($: HostApi): Promise<string | null> {
52  const env = await $.env.get('SKILLATOR_ROOT')
53  const tries = env ? [norm(env) + '/skills', norm(env)] : []
54  tries.push(up($.plugin.root, 2) + '/skills')
55  for (const t of tries) if (await has($, t)) return t
56  const cfg = await configDir($)
57  if (!cfg) return null
58  let installed: unknown
59  try { installed = JSON.parse(await $.fs.read(cfg + '/plugins/installed_plugins.json')) } catch { return null }
60  const entries = (installed as { plugins?: Record<string, unknown> })?.plugins?.['skillator@skillator']
61  if (!Array.isArray(entries)) return null
62  for (const e of entries) {
63    const p = (e as { installPath?: unknown })?.installPath
64    if (typeof p === 'string' && p && await has($, norm(p) + '/skills')) return norm(p) + '/skills'
65  }
66  return null
67}
68
69async function pathOf($: HostApi): Promise<string | undefined> {
70  return (await $.env.get('PATH')) ?? (await $.env.get('Path'))
71}
72async function systemRootOf($: HostApi): Promise<string | undefined> {
73  return (await $.env.get('SystemRoot')) ?? (await $.env.get('SYSTEMROOT'))
74}
75
76// A150: the git the mod runs (cwd = the repo). Windows: git.exe in the first absolute PATH
77// entry holding one, else none - never a bare `git`, which a Windows spawn may look up in
78// the cwd first. Elsewhere `git` as is (Ruling: execvp searches PATH only, never the cwd,
79// unless PATH itself names '.' or an empty entry - the user's own shell does the same).
80export async function gitBin($: HostApi): Promise<string | null> {
81  return await isWindows($) ? findExe('git', await pathOf($), true, p => $.fs.exists(p)) : 'git'
82}
83
84// Runs one call in the main checkout (the session's cwd outside a repo). Never rejects:
85// a command that cannot start or outlasts its budget comes back as code -1, its reason
86// in `err`. 30 s budget; `pending --machine` (the band's poll) 15 s.
87// `stdin` (F66: the setup form's values for team-config-save) is written to the child's
88// standard input - never put on argv.
89export async function runCall($: HostApi, call: Call, args: string[], stdin?: string): Promise<{ code: number; out: string; err: string; argv: string[] }> {
90  const win = await isWindows($)
91  const repo = await $.session.repo()
92  const skills = await skillatorRoot($)
93  if (!skills) return { code: 127, out: '', err: NO_SCRIPTS, argv: [] }
94  // F66 / A150: node, sh and powershell all by absolute path, never a bare name the
95  // child's cwd (the repo) could supply. node: found on PATH (absolute entries) - the mod
96  // has no node of its own to reuse; powershell: System32's; sh: /bin/sh.
97  let bin: string
98  const exists = (p: string) => $.fs.exists(p)
99  if (isNodeCall(call)) {
100    const found = await findNode(await pathOf($), win, exists)
101    if (!found) return { code: 127, out: '', err: NO_NODE, argv: [] }
102    bin = found
103  } else {
104    const sh = await shellFor(win, win, { path: await pathOf($), systemRoot: await systemRootOf($) }, exists)
105    if (!sh) return { code: 127, out: '', err: NO_SH, argv: [] }
106    bin = sh
107  }
108  const argv = argvFor(call, win, skills, args, bin)
109  const timeoutMs = call === 'pending-machine' ? 15000 : 30000
110  const init: ProcessRunInit = { ...(repo ? { cwd: repo.root } : {}), timeoutMs, ...(stdin !== undefined ? { stdin } : {}) }
111  try {
112    const r = await $.process.run(argv, init)
113    return { code: r.exitCode, out: r.stdout, err: r.stderr, argv }
114  } catch (e) {
115    return { code: -1, out: '', err: e instanceof Error ? e.message : String(e), argv }
116  }
117}
118
119// Usage: this session's own host reading first. Only when the host has none, the
120// watch-cortana flag files named by THIS session's id (`<id>`, `<id>.weekly` in
121// $CLAUDE_USAGE_WATCH_DIR, else $CLAUDE_CONFIG_DIR/handoff-watch, else
122// ~/.claude/handoff-watch - the order usage-watch writes them), dated by their mtime.
123export async function readUsage($: HostApi, now: number): Promise<Usage> {
124  const u = await $.session.usage()
125  const cost = u.cost?.usd ?? null
126  if (u.rateLimits.length) return usageFrom(u.rateLimits, cost, now)
127  const dirEnv = await $.env.get('CLAUDE_USAGE_WATCH_DIR')
128  const cfgEnv = await $.env.get('CLAUDE_CONFIG_DIR')
129  const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
130  const dir = dirEnv ? norm(dirEnv) : cfgEnv ? norm(cfgEnv) + '/handoff-watch' : home ? norm(home) + '/.claude/handoff-watch' : null
131  const id = await $.session.id()
132  if (!dir || !id) return usageFromFlags(null, null, cost)
133  let entries: FsEntry[] = []
134  try { entries = await $.fs.list(dir) } catch { return usageFromFlags(null, null, cost) }
135  const flag = async (name: string): Promise<Flag | null> => {
136    const e = entries.find(x => x.name === name && x.kind === 'file')
137    if (!e) return null
138    try { return { text: await $.fs.read(dir + '/' + name), mtimeMs: e.mtimeMs } } catch { return null }
139  }
140  return usageFromFlags(await flag(id), await flag(id + '.weekly'), cost)
141}
142
hooks/lib/runs.ts 157 lines
1import type { Flight, PlanItem, RunView, Stage } from '../../types'
2
3// Pure: `relay-morpheus list` plus, per run, `relay-morpheus resume <id>` (F85: the run
4// file whole - before it, `chain status`, its `## Chain` and `## Decisions` only). A text
5// holding the run's `## Stages` table gives exact stage states. Otherwise a stage is `open`
6// when the list row names it open, `pending` while the run is planned, and `-` (not known) else.
7
8// cmd_list: printf 'RUN-%s  %-42s  %s/%s done' [ '  (stage %s open)' ]
9const ROW = /^(RUN-\S+)  (.*?)\s+(\d+)\/(\d+) done(?:  \(stage ([\d,]+) open\))?\s*$/
10const BLOCK = /^### (\S+) - stage (\d+) - (.*)$/
11const TABLE_ROW = /^\|\s*(\d+)\s*\|([^|]*)\|([^|]*)\|/
12
13type Parsed = { stages: Stage[]; hasChain: boolean; states: Map<number, string>; decisions: string[]
14  mode: string | null; driver: string | null; flights: Flight[] }
15
16// F85: the Chain header is
17// `driver: <cell>     hold: <cell>     mode: <mode>` (relay-morpheus.sh chain_hdr); an
18// In-flight block `### stage <n> - <name>  (dispatched <time>, @tag)` then `run: <id>`.
19const HDR = /^driver: (.*?)     hold: .*?     mode: (\S*)\s*$/
20const FLIGHT = /^### stage (\d+) - .*?(@[^\s)]+)?\)?\s*$/
21
22// A `### Q<k> <state> <time>` block of `## Decisions` (relay-morpheus.sh park). Only
23// `open` and `asked` ones still wait on an answer; answered, ruled, moot... do not.
24type Q = { k: string; state: string; fields: Map<string, string> }
25const Q_HEAD = /^### (Q\d+) (\S+)/
26
27function decisionText(q: Q): string | null {
28  if (q.state !== 'open' && q.state !== 'asked') return null
29  const f = (name: string) => (q.fields.get(name) ?? '').trim()
30  const ticket = f('ticket').split(/\s+/)[0] || '-'
31  const what = f('question') || f('reason') || f('header') || 'a decision'
32  const parked = f('parked') || 'the run'
33  return `${q.k} ${ticket} ${what} - ${parked} waits on it`
34}
35
36function parseChain(text: string): Parsed {
37  const stages: Stage[] = []
38  const states = new Map<number, string>()
39  const qs: Q[] = []
40  const flights: Flight[] = []
41  let section = '', cur: Stage | null = null, hasChain = false, q: Q | null = null
42  let mode: string | null = null, driver: string | null = null
43  for (const raw of text.split('\n')) {
44    const line = raw.replace(/\r$/, '')
45    if (line.startsWith('## ')) {
46      section = line.slice(3).trim(); cur = null; q = null
47      if (section === 'Chain') hasChain = true
48      continue
49    }
50    if (section === 'Decisions') {
51      if (line.startsWith('### ')) {
52        const h = Q_HEAD.exec(line)
53        q = h ? { k: h[1]!, state: h[2]!, fields: new Map() } : null
54        if (q) qs.push(q)
55        continue
56      }
57      // Unindented `name: value` lines only: options are indented.
58      const fv = /^([a-z]+): (.*)$/.exec(line)
59      if (q && fv && !q.fields.has(fv[1]!)) q.fields.set(fv[1]!, fv[2]!)
60      continue
61    }
62    if (section === 'Stages') {
63      const t = TABLE_ROW.exec(line)
64      if (t) states.set(Number(t[1]), t[3]!.trim() || 'pending')
65      continue
66    }
67    if (section === 'In flight') {
68      const fl = FLIGHT.exec(line)
69      if (fl) flights.push({ n: Number(fl[1]), by: fl[2] ?? null, run: null })
70      const rn = /^run: (\S+)/.exec(line)
71      if (rn && flights.length && rn[1] !== '-') flights.at(-1)!.run = rn[1]!
72      continue
73    }
74    if (section !== 'Chain') continue
75    const h: RegExpExecArray | null = !cur && mode === null ? HDR.exec(line) : null
76    if (h) { mode = h[2] || null; driver = h[1]!.trim() === '-' ? null : h[1]!.trim(); continue }
77    const b = BLOCK.exec(line)
78    if (b) {
79      const code = b[1]!
80      let title = b[3]!.trim()
81      if (title.startsWith(code + ' ')) title = title.slice(code.length + 1)
82      cur = { n: Number(b[2]), code, ticket: '', title, after: '-', files: '-', state: '' }
83      stages.push(cur)
84      continue
85    }
86    if (!cur) continue
87    // Unindented fields only: a prompt's lines are indented two spaces.
88    const f = /^(tickets|files|after): (.*)$/.exec(line)
89    if (!f) continue
90    const val = f[2]!.trim()
91    if (f[1] === 'tickets') cur.ticket = val.split(' . ').map(p => p.trim().split(/\s+/)[0]!).filter(Boolean).join(' ')
92    else if (f[1] === 'files') cur.files = val || '-'
93    else cur.after = val || '-'
94  }
95  const decisions = qs.map(decisionText).filter((d): d is string => d !== null)
96  return { stages, hasChain, states, decisions, mode, driver, flights }
97}
98
99// F85: a /marathon-plan script's PLAN, read as data - never imported or run. The template
100// writes `const PLAN = <JSON>` closed by a `}` at column 0; null when it is not that.
101export function parsePlan(js: string): PlanItem[][] | null {
102  const i = js.indexOf('const PLAN = ')
103  const j = i < 0 ? -1 : js.indexOf('\n}', i)
104  if (j < 0) return null
105  try {
106    const p = JSON.parse(js.slice(i + 'const PLAN = '.length, j + 2)) as { phases?: unknown }
107    if (!Array.isArray(p.phases)) return null
108    return p.phases.map(ph => (Array.isArray(ph) ? ph : []).map((it: { stage?: unknown; ticket?: unknown }) =>
109      ({ n: Number(it.stage), ticket: String(it.ticket ?? '') })))
110  } catch { return null }
111}
112
113// `resume` opens `# Resuming <run file>`; the script beside it (`run-0019-x.md` -> `run-0019-x.workflow.js`), joined to the
114// repo root when resume named it relative; null when the text names no run file.
115export function scriptPathOf(text: string, root: string): string | null {
116  const m = /^# Resuming (.+\.md)\s*$/m.exec(text)
117  if (!m) return null
118  const f = m[1]!.trim().replace(/\\/g, '/')
119  return (/^([A-Za-z]:)?\//.test(f) ? f : root.replace(/\/+$/, '') + '/' + f).replace(/\.md$/, '.workflow.js')
120}
121
122export function parseRuns(list: string, chains: Record<string, string>, scripts: Record<string, string> = {}): RunView[] {
123  const out: RunView[] = []
124  let cur: RunView | null = null
125  const open = new Map<string, Set<number>>()
126  for (const raw of list.split('\n')) {
127    const line = raw.replace(/\r$/, '')
128    const r = ROW.exec(line)
129    if (r) {
130      cur = { id: r[1]!, title: r[2]!.trim(), done: Number(r[3]), total: Number(r[4]), owner: null, stages: [], planned: false, decisions: [],
131        mode: null, driver: null, flights: [], phases: null }
132      open.set(cur.id, new Set((r[5] ?? '').split(',').filter(Boolean).map(Number)))
133      out.push(cur)
134      continue
135    }
136    const o = /^\s+owner: (\S+)\s*$/.exec(line)
137    if (o && cur) cur.owner = o[1]!
138  }
139  for (const run of out) {
140    const text = chains[run.id]
141    if (text === undefined) continue
142    const p = parseChain(text)
143    const openSet = open.get(run.id) ?? new Set<number>()
144    run.planned = p.hasChain && p.stages.length > 0 && run.done === 0 && openSet.size === 0 &&
145      [...p.states.values()].every(s => s === 'pending')
146    run.decisions = p.decisions
147    Object.assign(run, { mode: p.mode, driver: p.driver, flights: p.flights })
148    const js = scripts[run.id]
149    if (js !== undefined) run.phases = parsePlan(js)
150    run.stages = p.stages.map(s => ({
151      ...s,
152      state: p.states.get(s.n) ?? (openSet.has(s.n) ? 'open' : run.planned ? 'pending' : '-'),
153    }))
154  }
155  return out
156}
157
hooks/lib/restore.ts 217 lines
1import type { ProcessRunInit, ProcessRunResult } from 'claude-code'
2import { BIN_SH, powershellExe, tasklistExe } from './calls'
3
4// F48, design §9: a CLAUDE.md the baseline harness hid as CLAUDE.md.skillator-hidden
5// (an interrupted GREEN run) is put back. Both ~/.claude and $CLAUDE_CONFIG_DIR are
6// checked; a folder is touched only while CLAUDE.md is absent AND the hidden file is
7// there. `report` (session start) says so and moves nothing, since another session may
8// be mid-GREEN with the file hidden on purpose; `restore` (session end) puts it back:
9// the text is written in-process ($.fs.write, fast, inside session end's budget) and
10// read back to check it, and only then is the hidden copy removed by a host command
11// ($.fs cannot delete). A removal that fails or times out leaves CLAUDE.md restored;
12// the line then says the hidden copy is left behind, and where. Contract R1b: a
13// `<hidden>.owner` naming a live process (a GREEN run still going) blocks the restore,
14// as does a check that cannot be made, or an owner file over 6 hours old; a dead
15// owner's file goes with the hidden copy.
16
17// The part of `$` this file uses. The plugin's `$` is one.
18export type RestoreApi = {
19  env: { get: (name: string) => Promise<string | undefined> }
20  clock: { now: () => Promise<number> }
21  fs: {
22    exists: (path: string) => Promise<boolean>
23    read: (path: string) => Promise<string>
24    write: (path: string, text: string) => Promise<void>
25  }
26  process: { run: (argv: readonly string[], init?: ProcessRunInit) => Promise<ProcessRunResult> }
27  store: { set: (key: string, value: unknown) => Promise<void> }
28}
29
30export const HIDDEN = 'CLAUDE.md.skillator-hidden'
31const OWNER_EXT = '.owner'                      // <hidden>.owner: the GREEN run holding it
32export const RESTORE_NOTE = 'restoreNote'      // $.store key: the lines the next session shows
33const OWNER_OLD_S = 6 * 3600                    // an owner file older than this is never restored by itself
34const MIN_BUDGET_MS = 1000
35
36const norm = (p: string) => p.replace(/\\/g, '/').replace(/(.)\/+$/, '$1')
37const same = (a: string, b: string, win: boolean) => (win ? a.toLowerCase() === b.toLowerCase() : a === b)
38const quote = (p: string) => `'${p.replace(/'/g, "''")}'`
39
40function moveArgv(win: boolean, from: string, to: string): string[] {
41  return win
42    ? ['powershell', '-NoProfile', '-Command', `Move-Item -LiteralPath ${quote(from)} -Destination ${quote(to)}`]
43    : ['mv', '-n', from, to]
44}
45
46// A150: what runs does so by absolute path - System32's powershell / tasklist, /bin/sh -
47// never a bare name the cwd (the session's repo) could supply on Windows. `rm` stays bare
48// (Ruling: POSIX execvp never searches the cwd unless PATH names '.' or an empty entry).
49// moveArgv above is only ever shown, for a person to type: it keeps the short names.
50function removeArgv(win: boolean, sysRoot: string | undefined, paths: string[]): string[] {
51  return win
52    ? [powershellExe(sysRoot), '-NoProfile', '-Command', `Remove-Item -LiteralPath ${paths.map(quote).join(', ')}`]
53    : ['rm', '-f', '--', ...paths]
54}
55
56// Contract R1b: while a GREEN run has CLAUDE.md hidden, the harness keeps
57// `<hidden>.owner` = "<pid> <epoch> <winpid>" (winpid `-` when it has none; the older
58// "<pid> <epoch>" reads as winpid `-`). The owner at session end: none (no owner file),
59// dead, alive, or unknown (the file cannot be read or parsed, the check cannot be run
60// or read, or Windows with winpid `-`). On Windows a numeric winpid is checked with
61// tasklist (alive iff its output names that pid); elsewhere `kill -0 <pid>`.
62type Owner = { state: 'none' }
63  | { state: 'dead' | 'alive'; pid: string; epoch: number }
64  | { state: 'unknown'; why: string; epoch: number | null }
65
66// `timeout` hands out the check's time only when a check runs.
67async function ownerOf($: RestoreApi, owner: string, win: boolean, sysRoot: string | undefined, timeout: () => number): Promise<Owner> {
68  if (!await exists($, owner)) return { state: 'none' }
69  let text: string
70  try { text = await $.fs.read(owner) } catch (e) { return { state: 'unknown', why: errText(e), epoch: null } }
71  const f = /^\s*(\d+)\s+(\d+)(?:\s+(\d+|-))?\s*$/.exec(text)
72  if (!f) return { state: 'unknown', why: `${OWNER_EXT} is not "<pid> <epoch> <winpid>"`, epoch: null }
73  const pid = f[1]!, epoch = Number(f[2]), winpid = f[3] ?? '-'
74  const unknown = (why: string): Owner => ({ state: 'unknown', why, epoch })
75  if (win && winpid === '-') return unknown(`${OWNER_EXT} names no Windows pid`)
76  const timeoutMs = timeout()
77  if (timeoutMs < 1) return unknown('no time left in the session end budget')
78  if (win) {
79    try {
80      const r = await $.process.run([tasklistExe(sysRoot), '/FI', `PID eq ${winpid}`, '/NH'], { timeoutMs })
81      if (r.exitCode !== 0) return unknown(r.stderr.trim() || r.stdout.trim() || `tasklist exit ${r.exitCode}`)
82      if (!r.stdout.trim()) return unknown('tasklist said nothing')
83      const alive = new RegExp(`(^|\\s)${winpid}(\\s|$)`).test(r.stdout)
84      return { state: alive ? 'alive' : 'dead', pid: winpid, epoch }
85    } catch (e) {
86      return unknown(errText(e))
87    }
88  }
89  try {
90    const r = await $.process.run([BIN_SH, '-c', `kill -0 ${pid}`], { timeoutMs })
91    if (r.exitCode === 0) return { state: 'alive', pid, epoch }
92    const err = r.stderr.trim()
93    if (/not permitted/i.test(err)) return { state: 'alive', pid, epoch }   // EPERM: there, another user's
94    if (r.exitCode === 126 || r.exitCode === 127) return unknown(err || `exit ${r.exitCode}`)
95    return { state: 'dead', pid, epoch }
96  } catch (e) {
97    return unknown(errText(e))
98  }
99}
100
101const errText = (e: unknown) => (e instanceof Error ? e.message : String(e))
102
103async function exists($: RestoreApi, p: string): Promise<boolean> {
104  try { return await $.fs.exists(p) } catch { return false }
105}
106
107// `remainingMs` is session end's budget: not a number, or under a second, counts as a
108// second. It is split across the folders to restore, and each host command's timeout
109// comes from what is left of it; the note is stored before any removal runs.
110export async function restoreClaudeMd($: RestoreApi, mode: 'report' | 'restore', remainingMs = 5000): Promise<string[]> {
111  const win = (await $.env.get('OS')) === 'Windows_NT'
112  const sysRoot = win ? (await $.env.get('SystemRoot')) ?? (await $.env.get('SYSTEMROOT')) : undefined
113  const homeRaw = win
114    ? (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
115    : (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
116  const home = homeRaw ? norm(homeRaw) : null
117  const cfg = await $.env.get('CLAUDE_CONFIG_DIR')
118  const dirs: string[] = []
119  if (home) dirs.push(home + '/.claude')
120  if (cfg && !dirs.some(d => same(d, norm(cfg), win))) dirs.push(norm(cfg))
121  const shown = (p: string) => (home && p.length > home.length && same(p.slice(0, home.length + 1), home + '/', win)
122    ? '~' + p.slice(home.length) : p)
123
124  const todo: string[] = []
125  for (const dir of dirs) if (!await exists($, dir + '/CLAUDE.md') && await exists($, dir + '/' + HIDDEN)) todo.push(dir)
126
127  const lines: string[] = []
128  if (mode === 'report') {
129    for (const dir of todo) {
130      const md = dir + '/CLAUDE.md', argv = moveArgv(win, dir + '/' + HIDDEN, md)
131      lines.push(`▲ ${shown(md)} is hidden as ${HIDDEN} - left as is now (a baseline GREEN may be running); it is put back at session end. By hand: ${byHand(argv)}`)
132    }
133    return lines
134  }
135
136  // The budget: what is left is the budget less the larger of the time gone by and the
137  // timeouts already handed out (a frozen clock still cannot overspend it).
138  const budget = Number.isFinite(remainingMs) && remainingMs >= MIN_BUDGET_MS ? remainingMs : MIN_BUDGET_MS
139  const t0 = await nowMs($)
140  let given = 0
141  const left = async () => Math.max(0, budget - Math.max(given, (await nowMs($)) - t0))
142  const take = (ms: number) => { const t = Math.max(0, Math.floor(ms)); given += t; return t }
143  const save = async (extra: string[]) => {
144    try { await $.store.set(RESTORE_NOTE, [...lines, ...extra]) } catch { /* the log line still says it */ }
145  }
146
147  for (let i = 0; i < todo.length; i++) {
148    const dir = todo[i]!, md = dir + '/CLAUDE.md', hidden = dir + '/' + HIDDEN
149    const cmd = byHand(moveArgv(win, hidden, md))
150    const ownerPath = hidden + OWNER_EXT
151    const share = (await left()) / (todo.length - i)
152    const owner = await ownerOf($, ownerPath, win, sysRoot, () => take(Math.min(2000, share / 2)))
153    if (owner.state === 'alive') {
154      lines.push(`▲ ${shown(md)} is hidden by a running GREEN run (pid ${owner.pid}) - left alone`)
155      continue
156    }
157    const old = owner.state !== 'none' && owner.epoch !== null && (await nowMs($)) / 1000 - owner.epoch > OWNER_OLD_S
158    const then = `Once no GREEN run is going, by hand: ${cmd}; then delete ${ownerPath}`
159    if (owner.state === 'unknown') {
160      lines.push(`■ ${shown(md)} not restored from ${HIDDEN}: could not check the run holding it (${owner.why})${old ? '; its owner file is over 6 hours old' : ''}. ${then}`)
161      continue
162    }
163    if (owner.state === 'dead' && old) {
164      lines.push(`■ ${shown(md)} not restored from ${HIDDEN}: the run holding it (pid ${owner.pid}) is not running, but its owner file is over 6 hours old - not restored by itself. ${then}`)
165      continue
166    }
167    const gone = owner.state === 'dead' ? [hidden, ownerPath] : [hidden]
168    lines.push(await restoreOne($, win, sysRoot, md, hidden, gone, shown, save,
169      async () => take((await left()) / (todo.length - i))))
170  }
171  if (lines.length) await save([])
172  return lines
173}
174
175async function nowMs($: RestoreApi): Promise<number> {
176  try { const t = await $.clock.now(); return Number.isFinite(t) ? t : 0 } catch { return 0 }
177}
178
179// The move as a person types it: the shell line, or the PowerShell command.
180const byHand = (argv: string[]) => (argv[0] === 'mv' ? argv.join(' ') : argv[3]!)
181
182// One folder: write CLAUDE.md from the hidden copy, check it, store the note, then
183// remove the copy. `gone` = the hidden copy, and its owner file when there was one:
184// removed together. `timeout` = what the removal may take, asked for when it runs.
185async function restoreOne($: RestoreApi, win: boolean, sysRoot: string | undefined, md: string, hidden: string, gone: string[],
186  shown: (p: string) => string, save: (extra: string[]) => Promise<void>, timeout: () => Promise<number>): Promise<string> {
187  const failed = (why: string) =>
188    `■ Could not restore ${shown(md)} from ${HIDDEN}: ${why}. The hidden copy is kept at ${hidden}.`
189  let text: string
190  try { text = await $.fs.read(hidden) } catch (e) { return failed(errText(e)) }
191  // Checked again right before the write: CLAUDE.md is never written over.
192  if (await exists($, md)) return failed(`${shown(md)} appeared meanwhile - not written over`)
193  try { await $.fs.write(md, text) } catch (e) { return failed(errText(e)) }
194  let back: string | null = null
195  try { back = await $.fs.read(md) } catch { back = null }
196  if (back !== text) return failed(`${shown(md)} does not read back as written - compare it with the hidden copy`)
197
198  const argv = removeArgv(win, sysRoot, gone)
199  const head = `✓ Restored ${shown(md)} from ${HIDDEN} (an interrupted baseline run left it hidden).`
200  // Stored first: should session end be cut short during the removal, the note still says it.
201  await save([`${head} ▲ If ${hidden} is still there, remove it by hand: ${argv.join(' ')}`])
202  let why = ''
203  const ms = await timeout()
204  if (ms < 1) why = 'no time left in the session end budget'
205  else {
206    try {
207      const r = await $.process.run(argv, { timeoutMs: ms })
208      if (r.exitCode !== 0) why = r.stderr.trim() || r.stdout.trim() || `exit ${r.exitCode}`
209    } catch (e) {
210      why = errText(e)
211    }
212  }
213  if (!why) for (const p of gone) if (await exists($, p)) { why = `${p} is still there`; break }
214  const leftBehind = why ? ` ▲ The hidden copy is left behind at ${hidden}: ${why} - remove it by hand.` : ''
215  return `${head}${leftBehind} ran: ${argv.join(' ')}`
216}
217
hooks/band.tsx 49 lines
1import { atom, read, update } from 'claude-code'
2import type { Color, EngineInterface, RenderElement, RenderInput } from 'claude-code'
3import { bandSegs, countsOf, NARROW_COLUMNS } from './lib/bandlines'
4import type { BandInput, Seg } from './lib/bandlines'
5export { bandLines, bandSegs, countsOf, NARROW_COLUMNS, statusText } from './lib/bandlines'
6export type { BandInput, Counts, Seg } from './lib/bandlines'
7
8// F48, design §7: the band above the prompt - always on, green when fine - the
9// narrow two-line fold, the notes under it, and the status line's text.
10
11const machineA = atom({ plugin: 'ops-board', key: 'machine' } as const, null)
12const usageA = atom({ plugin: 'ops-board', key: 'usage' } as const, null)
13const watchA = atom({ plugin: 'ops-board', key: 'watch' } as const, { level: 'green', reasons: [] })
14const notesA = atom({ plugin: 'ops-board', key: 'notes' } as const, [])
15const relayA = atom({ plugin: 'ops-board', key: 'relay' } as const, null)
16const boardA = atom({ plugin: 'ops-board', key: 'board' } as const, null)
17const narrowA = atom({ plugin: 'ops-board', key: 'narrow' } as const, false)
18
19// The AbovePrompt hook's body: the band, then the notes, each with its own [ ok ].
20export async function renderBand($: EngineInterface, e: RenderInput<'AbovePrompt'>): Promise<RenderElement> {
21  const { Box, Text, Button } = $.ui.resolve(e)
22  const cols = Math.max(20, e.props.bodyColumns)
23  const narrow = (await read($, narrowA)) || (e.viewport !== undefined && e.viewport.columns < NARROW_COLUMNS)
24  const v: BandInput = {
25    watch: await read($, watchA), machine: await read($, machineA), usage: await read($, usageA),
26    relay: await read($, relayA), counts: countsOf(await read($, boardA)), narrow, cols, now: await $.clock.now(),
27  }
28  const notes = await read($, notesA)
29  const seg = (s: Seg) => <Text color={s.color} dimColor={s.dim} inverse={s.inverse}>{s.text}</Text>
30  const lines = bandSegs(v).map((l, i) => (
31    <Box key={'line:' + i}><Text wrap={v.watch.level === 'red' ? 'wrap' : 'truncate-end'}>{l.map(seg)}</Text></Box>
32  ))
33  const noteEls = notes.map((n, i) => {
34    const rows = n.split('\n')
35    const head = rows[0] ?? ''
36    const color: Color | undefined = head.startsWith('✓') ? 'success' : head.startsWith('▲') ? 'warning' : head.startsWith('■') ? 'error' : undefined
37    return (
38      <Box key={'note:' + i} flexDirection="row" gap={2}>
39        <Box flexDirection="column">
40          <Text wrap="wrap"><Text color={color}>{head.slice(0, 1)}</Text>{head.slice(1)}</Text>
41          {rows.slice(1).map(r => <Text dimColor={r.startsWith('  ') && !r.startsWith('    ')} wrap="truncate-end">{r}</Text>)}
42        </Box>
43        <Button key={'ok:' + i} onPress={() => { void update($, notesA, xs => xs.filter((_, j) => j !== i)) }}>ok</Button>
44      </Box>
45    )
46  })
47  return <Box flexDirection="column">{lines}{noteEls}</Box>
48}
49
hooks/pane.tsx 333 lines
1import { atom, read, update } from 'claude-code'
2import type { BoxProps, ButtonProps, Color, ElementConstructor, EngineInterface, RenderElement, RenderInput,
3  SelectProps, TextProps } from 'claude-code'
4import type { Filter } from '../types'
5import { STATE_COLOR } from './lib/board'
6import { buildModel, chipsOf, headKey, isDecidable, toggleGroup } from './lib/ticketsmodel'
7import type { Model, View } from './lib/ticketsmodel'
8export { buildModel, lineKey, offsetFor } from './lib/ticketsmodel'
9export type { View, Model } from './lib/ticketsmodel'
10import { openAssign, renderForm, runDecided } from './assign'
11import { openReconcile, renderReconcile } from './reconcile'
12import { paint } from './paint'
13import { TeamTab } from './teamview'
14import { HealthTab, readHealth } from './health'
15import { EngageConfirm, openRuns, pollRuns, readRuns, RunsTab } from './runstab'
16import { runStatus, RUNS_SECTIONS, sectionHead, sectionStatus, statusColor } from './lib/runlines'
17import type { RunsSection } from './lib/runlines'
18import { workflowStatus } from './lib/agents'
19import { AgentsTab, pollAgents, readAgents, WorkflowsTab } from './agentstab'
20import { enterMessages, MessagesTab, renderSetup, unreadOf } from './messagestab'
21import { enterRun, isRunFormUp, RunTab } from './runtab'
22import { enterExplorer, ExplorerTab, readExplorer } from './explorertab'
23
24// The ops board pane (design §5): tab bar, the Tickets tab with its filter row,
25// state shown by colour (a `●` per row, headings in the same colour), footer keys.
26
27export const PANE_ID = 'ops-board'
28export const PANE_TITLE = 'ops board'
29
30// F76: one tab, id 'runs', labelled Workflows (owner: relay runs and workflows are one thing):
31// a Runs section and a Workflows section. /ops workflows opens it; /ops runs is an alias.
32// F93: its Agents section is the old Agents tab; tab 'run' is labelled Build.
33export type Tab = 'tickets' | 'runs' | 'health' | 'messages' | 'explorer' | 'run'
34// Each file names the atoms it reads or writes as its own consts: the engine's scan
35// lists state access from those, not from an import (register.tsx repeats them).
36const boardA = atom({ plugin: 'ops-board', key: 'board' } as const, null)
37const teamA = atom({ plugin: 'ops-board', key: 'team' } as const, [])
38const meA = atom({ plugin: 'ops-board', key: 'me' } as const, null)
39const tabA = atom({ plugin: 'ops-board', key: 'tab' } as const, 'tickets')
40const selectedA = atom({ plugin: 'ops-board', key: 'selected' } as const, null)
41const expandedA = atom({ plugin: 'ops-board', key: 'expanded' } as const, null)
42const filterA = atom({ plugin: 'ops-board', key: 'filter' } as const, { app: null, who: null, mine: false, showDone: false })
43const filterOpenA = atom({ plugin: 'ops-board', key: 'filterOpen' } as const, false)
44const teamViewA = atom({ plugin: 'ops-board', key: 'teamView' } as const, null)
45// F61: the boxes of the open groups, this session; empty = every group collapsed. Only
46// the set is held, never rows: the board's own value stays within $.state's cap (B4).
47const openA = atom({ plugin: 'ops-board', key: 'openGroups' } as const, [])
48// F76: the Workflows tab's open sections (Runs, Workflows, F93 Agents), this session; all start open.
49const runsOpenA = atom({ plugin: 'ops-board', key: 'runsOpen' } as const, ['runs', 'workflows', 'agents'])
50
51const CHIP_KEY: Color = 'permission'
52const CHIP_VALUE: Color = 'suggestion'
53
54const cut = (s: string, room: number) => s.length <= room ? s : s.slice(0, Math.max(1, room - 1)) + '…'
55
56type Els = {
57  Box: ElementConstructor<BoxProps>
58  Text: ElementConstructor<TextProps>
59  Button: ElementConstructor<ButtonProps>
60  Select: ElementConstructor<SelectProps> | null
61  canType?: boolean       // the surface draws an Input (F77's typed Ticket field)
62}
63
64// The Tickets tab body: filter row, groups, count line and keys. Handlers write state.
65export function TicketsTab($: EngineInterface, els: Els, v: View, m: Model): RenderElement[] {
66  const { Box, Text, Button, Select } = els
67  const out: RenderElement[] = []
68  const f = v.filter
69  const hasTeam = v.team.length > 0
70  const setFilter = (fn: (f: Filter) => Filter) => { void update($, filterA, fn) }
71
72  // filter row
73  if (v.filterOpen && Select) {
74    const apps = [...new Set((v.board?.tickets ?? []).flatMap(t => t.apps))].sort()
75    out.push(
76      <Box key="filters" flexDirection="row" gap={2}>
77        <Select key="sel:app" label="app" value={f.app ?? 'all'}
78          options={[{ value: 'all', label: 'all' }, ...apps.map(a => ({ value: a, label: a }))]}
79          onSelect={val => setFilter(x => ({ ...x, app: val === 'all' ? null : val }))} />
80        {hasTeam && <Select key="sel:who" label="who" value={f.who ?? 'all'}
81          options={[{ value: 'all', label: 'all' }, ...v.team.map(p => ({ value: p.handle, label: p.handle }))]}
82          onSelect={val => setFilter(x => ({ ...x, who: val === 'all' ? null : val, mine: false }))} />}
83        {hasTeam && v.me && <Button key="key:mine" plain onPress={() => setFilter(x => ({ ...x, mine: !x.mine, who: null }))}>
84          {(f.mine ? '[x]' : '[ ]') + ' mine'}</Button>}
85      </Box>,
86    )
87  } else {
88    out.push(
89      <Box key="filters"><Text wrap="truncate-end">
90        <Text dimColor>app</Text>{` ${f.app ?? 'all'} ▾  `}
91        {hasTeam && <Text dimColor>who</Text>}{hasTeam ? ` ${f.mine ? 'me' : f.who ?? 'all'} ▾  ${f.mine ? '[x]' : '[ ]'} mine  ` : ''}
92        {`${f.showDone ? '[x]' : '[ ]'} done`}
93      </Text></Box>,
94    )
95  }
96  out.push(<Text> </Text>)
97
98  if (!v.board || v.board.error) {
99    out.push(<Text dimColor>{v.board?.error ?? 'reading TICKETS.md…'}</Text>)
100  } else if (!m.groups.length) {   // F61: m.rows holds only open groups' rows; test the groups
101    out.push(<Text dimColor>{m.filterActive ? 'no ticket matches the filter' : 'no open tickets'}</Text>)
102  }
103
104  const shownIds = new Set(m.rows.map(t => t.id))
105  for (const g of m.groups) {
106    // F61: a header is a Button - Enter under the focus or a click opens or closes it.
107    const hk = headKey(g.box)
108    const head = cut(`${g.open ? '▾' : '▸'} ${g.title} (${g.tickets.length})`, v.cols - 1)
109    out.push(
110      <Button key={hk} plain onPress={() => {
111        // the header stands for a ticket hidden in it (moved there by an edit): opening lands on it
112        void update($, selectedA, x => (hk === m.selected && x && !x.startsWith('head:') && g.tickets.some(t => t.id === x) ? x : hk))
113        void update($, openA, x => toggleGroup(x, g.box))
114      }}>
115        <Text color={paint(g.box)} bold inverse={hk === m.selected}>{head}</Text>
116      </Button>,
117    )
118    if (!g.open) continue
119    for (const t of g.tickets) {
120      const nested = !!t.parent && shownIds.has(t.parent) && g.tickets.some(p => p.id === t.parent)
121      const lead = nested ? '  ' : ''
122      const tail = t.parent && !nested ? ` (${t.parent})` : ''
123      const room = v.cols - lead.length - 2 - t.id.length - 2 - tail.length
124      const body = ` ${t.id}  ${cut(t.title, Math.max(4, room))}${tail}`
125      const isSel = t.id === m.selected
126      out.push(
127        <Button key={'row:' + t.id} plain onPress={() => {
128          void update($, selectedA, () => t.id)
129          void update($, expandedA, x => (x === t.id ? null : t.id))
130        }}>
131          {lead}
132          <Text color={paint(t.box)}>●</Text>
133          {isSel ? <Text inverse>{body.padEnd(Math.max(body.length, v.cols - lead.length - 1))}</Text>
134            : <Text dimColor={STATE_COLOR[t.box] === 'dim'}>{body}</Text>}
135        </Button>,
136      )
137      if (chipsOf(t)) {
138        const parts: (RenderElement | string)[] = ['    ']
139        const chip = (k: string, val: string) => [<Text color={CHIP_KEY}>{k + ':'}</Text>, <Text color={CHIP_VALUE}>{val}</Text>]
140        const groupsOf: (RenderElement | string)[][] = []
141        if (t.apps.length) groupsOf.push(t.apps.flatMap((a, i) => [...(i ? [' '] : []), ...chip('app', a)]))
142        if (t.owner) groupsOf.push(chip('owner', t.owner))
143        if (t.decide) groupsOf.push(chip('decide', t.decide))
144        if (t.by.length) groupsOf.push(chip('by', t.by.join(' ')))
145        if (t.session) groupsOf.push([<Text dimColor>{t.session}</Text>])
146        groupsOf.forEach((gr, i) => { if (i) parts.push('  '); parts.push(...gr) })
147        out.push(
148          <Box key={'chips:' + t.id} flexDirection="row" gap={3}>
149            <Text wrap="truncate-end">{parts}</Text>
150            {isDecidable(t) && <Button key={'decided:' + t.id} onPress={() => { void runDecided($, t.id) }}>Decided</Button>}
151          </Box>,
152        )
153      }
154      if (v.expanded === t.id) {
155        out.push(<Box key={'raw:' + t.id}><Text dimColor>{`    - [${t.box}] ${t.line}`}</Text></Box>)
156      }
157    }
158  }
159
160  out.push(<Text> </Text>)
161  const c = m.count
162  out.push(m.filterActive
163    ? <Box key="count"><Text dimColor>{c.text}</Text></Box>
164    : <Box key="count"><Text>
165        <Text dimColor>{`${c.open} open`}</Text>
166        {c.dots.flatMap(d => [' ', <Text color={paint(d.box)}>{'●' + d.n}</Text>])}
167        {f.showDone
168          ? (c.closed.length ? [<Text dimColor> ·</Text>, ...c.closed.flatMap(d => [' ', <Text color={paint(d.box)}>{'●' + d.n}</Text>]), <Text dimColor> closed</Text>] : [])
169          : (c.closed.length ? [<Text dimColor>{` · ${c.closed.reduce((s, d) => s + d.n, 0)} done hidden`}</Text>] : [])}
170      </Text></Box>)
171  out.push(
172    <Box key="keys" flexDirection="row" gap={2}>
173      <Text dimColor>↑↓ move</Text>
174      <Button key="key:assign" plain dimColor hotkey="a" onPress={() => { void openAssign($, 'owner', m.ticket, els.canType) }}>assign</Button>
175      <Button key="key:decide" plain dimColor hotkey="d" onPress={() => { void openAssign($, 'decide', m.ticket, els.canType) }}>decide</Button>
176      <Button key="key:filter" plain dimColor hotkey="f" onPress={() => { void update($, filterOpenA, x => !x) }}>filter</Button>
177      <Button key="key:done" plain dimColor hotkey="x" onPress={() => setFilter(x => ({ ...x, showDone: !x.showDone }))}>done</Button>
178      <Button key="key:team" plain dimColor hotkey="t" onPress={() => { void update($, teamViewA, () => ({ selected: null, confirm: false })) }}>team</Button>
179      <Button key="key:reconcile" plain dimColor hotkey="c" onPress={() => { void openReconcile($) }}>reconcile</Button>
180      {/* F78: register.tsx takes this press (ui.press) - the forced poll lives there */}
181      <Button key="key:refresh" plain dimColor hotkey="r" onPress={() => {}}>refresh</Button>
182    </Box>,
183  )
184  return out
185}
186
187export async function readView($: EngineInterface, cols: number): Promise<View> {
188  return {
189    board: await read($, boardA), team: await read($, teamA), me: await read($, meA),
190    filter: await read($, filterA), filterOpen: await read($, filterOpenA),
191    selected: await read($, selectedA), expanded: await read($, expandedA), open: await read($, openA), cols,
192  }
193}
194
195const TABS: { id: Tab; label: string }[] = [{ id: 'tickets', label: 'Tickets' }, { id: 'runs', label: 'Workflows' },
196  { id: 'health', label: 'Health' }, { id: 'messages', label: 'Messages' },
197  { id: 'explorer', label: 'Explorer' },   // F75: git-tracked files and recent commits (explorertab.tsx)
198  { id: 'run', label: 'Build' }]   // F79: the repo's entry points (runtab.tsx) - not Workflows (id 'runs')
199
200// F76: the Workflows tab (id 'runs') - a Runs section (relay runs, F50) and a Workflows section (F53),
201// each a header Button with its count that Enter or a click opens and closes, as F61's
202// status groups; an open section draws its body as the old tab did.
203async function RunsAndWorkflows($: EngineInterface, els: Els, cols: number): Promise<RenderElement[]> {
204  const { Text, Button } = els
205  const open = await read($, runsOpenA)
206  const runs = await readRuns($, cols)
207  const agents = await readAgents($, cols)
208  const shown = openRuns(runs.runs)
209  const out: RenderElement[] = []
210  for (const sec of [...RUNS_SECTIONS, 'agents'] as RunsSection[]) {   // F93: the pane's agents last
211    const on = open.includes(sec)
212    const n = sec === 'runs' ? shown.length : sec === 'workflows' ? agents.workflows.length : agents.agents.length
213    // F76: the header in the colour of the most pressing status under it, as a ticket group's
214    // (F93: Agents uncoloured - each agent row carries its own status colour)
215    const st = sec === 'agents' ? null
216      : sectionStatus(sec === 'runs' ? shown.map(runStatus) : agents.workflows.map(r => workflowStatus(agents.agents, r)))
217    if (sec !== RUNS_SECTIONS[0]) out.push(<Text> </Text>)
218    out.push(
219      <Button key={'sect:' + sec} plain onPress={() => { void update($, runsOpenA, x => toggleGroup(x, sec)) }}>
220        <Text bold color={st ? statusColor(st) : undefined}>{cut(sectionHead(sec, on, n), cols - 1)}</Text>
221      </Button>,
222    )
223    if (on) out.push(...(sec === 'runs' ? RunsTab($, els, runs, agents) : sec === 'workflows' ? WorkflowsTab(els, agents)
224      : AgentsTab(els, { ...agents, runs: shown, board: runs.board })))
225  }
226  return out
227}
228
229export async function renderPane($: EngineInterface, e: RenderInput<'Pane'>): Promise<RenderElement> {
230  const t = $.ui.resolve(e)
231  const els: Els = { Box: t.Box, Text: t.Text, Button: t.Button, Select: e.surface !== 'mobile' && 'Select' in t ? t.Select : null,
232    canType: e.surface !== 'mobile' && 'Input' in t }
233  const { Box, Text, Button } = els
234  const cols = Math.max(20, e.props.bodyColumns)
235  const tab = await read($, tabA)
236  const unread = await unreadOf($)          // F66: the unread count rides on the Messages tab
237  const bar = (
238    <Box key="tabs" flexDirection="row" gap={1}>
239      {TABS.map(x => (
240        <Button key={'tab:' + x.id} variant={x.id === tab ? 'primary' : undefined} dimColor={x.id !== tab}
241          onPress={() => {
242            void update($, tabA, () => x.id)
243            if (x.id === 'runs') void pollRuns($).catch(() => {})       // read on entering the tab
244            if (x.id === 'runs') void pollAgents($).catch(() => {})     // F93: its Agents section likewise
245            if (x.id === 'messages' && tab !== 'messages') void enterMessages($).catch(() => {})
246            if (x.id === 'explorer') void enterExplorer($).catch(() => {})        // F75: read on entering the tab
247            if (x.id === 'run') void enterRun($).catch(() => {})            // F79: first open scans
248          }}>{x.id === 'messages' && unread ? `${x.label} ${unread}` : x.label}</Button>
249      ))}
250    </Box>
251  )
252  // F66: the Teams setup form draws in place of any view, as the assign forms do
253  const setup = await renderSetup($, e)
254  if (setup) return setup
255  if (tab === 'messages') {
256    return (
257      <Box flexDirection="column">
258        {bar}
259        <Text> </Text>
260        {await MessagesTab($, els, cols)}
261      </Box>
262    )
263  }
264  if (tab === 'health') {
265    return (
266      <Box flexDirection="column">
267        {bar}
268        <Text> </Text>
269        {HealthTab(els, await readHealth($))}
270        <Text> </Text>
271        {/* F87: register.tsx takes this press (ui.press) - /ops resume force */}
272        <Box key="keys" flexDirection="row" gap={2}>
273          <Button key="key:force" plain dimColor hotkey="f" onPress={() => {}}>force resume (whatever the reading; mutes memory red)</Button>
274        </Box>
275      </Box>
276    )
277  }
278  // A Run form up draws without the tab bar, as the assign forms do: a tab press would
279  // leave it up, and Esc on that tab (the pane still closeOnEscape) close the board.
280  if (tab === 'run') {
281    const formUp = await isRunFormUp($)
282    return (
283      <Box flexDirection="column">
284        {!formUp && bar}
285        {!formUp && <Text> </Text>}
286        {await RunTab($, e, els, cols)}
287      </Box>
288    )
289  }
290  if (tab === 'explorer') {
291    return (
292      <Box flexDirection="column">
293        {bar}
294        <Text> </Text>
295        {ExplorerTab($, els, await readExplorer($), cols)}
296      </Box>
297    )
298  }
299  if (tab === 'runs') {
300    // F85: the engage confirm draws without the tab bar, as the Run tab's form does
301    const g = (await readRuns($, cols)).engage
302    if (g) return <Box flexDirection="column">{EngageConfirm($, els, g)}</Box>
303    return (
304      <Box flexDirection="column">
305        {bar}
306        <Text> </Text>
307        {await RunsAndWorkflows($, els, cols)}
308      </Box>
309    )
310  }
311  // B5: an assign / decide / add person form up draws in place of the view it came
312  // from (Tickets or Team); Esc or Cancel brings that view back.
313  const form = (await renderForm($, e)) ?? (await renderReconcile($, e))   // F81: the reconcile form likewise
314  if (form) return form
315  const v = await readView($, cols)
316  const tv = await read($, teamViewA)
317  if (tv) {
318    return (
319      <Box flexDirection="column">
320        {bar}
321        {TeamTab($, els, tv, v.team, v.board, v.me, cols)}
322      </Box>
323    )
324  }
325  const m = buildModel(v)
326  return (
327    <Box flexDirection="column">
328      {bar}
329      {TicketsTab($, els, v, m)}
330    </Box>
331  )
332}
333
hooks/assign.tsx 325 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, RenderElement, RenderInput } from 'claude-code'
3import type { AssignDraft, Box as State, Member, PersonDraft, Ticket } from '../types'
4import { STATE_NAME, ticketById } from './lib/board'
5export { isOpen } from './lib/board'
6import { runCall } from './lib/host'
7import { sameHandle } from './lib/team'
8import type { Call } from './lib/host'
9import { paint } from './paint'
10
11// F47: the assign / decide form, the Decided button's call and the Add person
12// form. Every write is a ticket-meta run; the mod writes no file.
13//
14// B5: the forms are a mode of the ops-board pane's own body, never a second pane.
15// `a` / `d` / add are pressed while the ops-board pane holds the keys, and the host
16// refuses another pane's focus then (PaneOpenArgs.focus): a dialog pane opened as an
17// unshown tab behind it, so the press showed nothing. A form up is the assign or
18// person atom set; renderPane draws it in place of the view it came from.
19
20const PANE = 'ops-board'          // pane.tsx's PANE_ID (it imports this file)
21const PANE_NAME = 'ops board'     // pane.tsx's PANE_TITLE
22
23// While a form is up, Esc (closeOnEscape) raises ui.close on the pane, which the
24// register hook refuses and turns into Cancel; the ring goes to the form's first
25// element. A deny (the pane does not hold the keys) leaves the ring to the person.
26async function showForm($: EngineInterface, ring: string) {
27  await $.ui.open({ id: PANE, title: PANE_NAME, closeOnEscape: true })
28  void $.ui.focus({ requestId: PANE, key: ring }).catch(() => {})
29}
30
31// Each file names the atoms it reads or writes as its own consts (see pane.tsx).
32const boardA = atom({ plugin: 'ops-board', key: 'board' } as const, null)
33const teamA = atom({ plugin: 'ops-board', key: 'team' } as const, [])
34const meA = atom({ plugin: 'ops-board', key: 'me' } as const, null)
35const assignA = atom({ plugin: 'ops-board', key: 'assign' } as const, null)
36const personA = atom({ plugin: 'ops-board', key: 'person' } as const, null)
37const reconcileA = atom({ plugin: 'ops-board', key: 'reconcile' } as const, null)   // F81: reconcile.tsx's form
38
39const HANDLE = /^@[a-z0-9_-]+$/
40
41// A state in words for the Change line: `?` reads "decision" there.
42const word = (b: State) => (b === '?' ? 'decision' : STATE_NAME[b])
43const cut = (s: string, room: number) => (s.length <= room ? s : s.slice(0, Math.max(1, room - 1)) + '…')
44const quote = (a: string) => (/[\s"]/.test(a) || a === '' ? '"' + a.replace(/"/g, '\\"') + '"' : a)
45const ran = (argv: string[]) => 'ran: ' + argv.map(quote).join(' ')
46// ticket-meta's output less the ticket line it echoes: warnings, "no change", the rest.
47const notes = (out: string) => out.split(/\r?\n/).map(l => l.trimEnd()).filter(l => l && !/^\s*- \[/.test(l))
48
49// F77: the heading names the ticket the Ticket field resolves to, else "a ticket".
50const titleOf = (d: AssignDraft, t: Ticket | null) => `Assign ${t ? t.id : 'a ticket'}${d.kind === 'decide' ? ' for a decision' : ''}`
51// The ticket an ID names on the loaded board, typed in any case (F77).
52async function findTicket($: EngineInterface, id: string): Promise<Ticket | null> {
53  return ticketById((await read($, boardA))?.tickets, id)
54}
55
56// The handle a fresh dialog starts on: the ticket's own (owner or decider) when
57// on the team, else me, else the first member - always as team.md writes it, since
58// ticket-meta matches the handle exactly (the board's tags are read lower-cased).
59// No ticket yet (F77: the ID still to be typed): me, else the first member.
60function startHandle(t: Ticket | null, kind: AssignDraft['kind'], team: Member[], me: string | null): string | null {
61  const on = (h: string | null) => team.find(m => sameHandle(m.handle, h))?.handle ?? null
62  return on(t ? (kind === 'owner' ? t.owner : t.decide) : null) ?? on(me) ?? team[0]?.handle ?? null
63}
64
65// `a` / `d`: the dialog on the ticket the pane shows selected (its model resolves a
66// stale selection to the first item). F61: a header selected - or a ticket hidden in a
67// collapsed group, shown as its header - is no ticket. F77: then the form opens all the
68// same, its Ticket field empty and the ring on it, for the ID to be typed; with a ticket
69// the field is prefilled with its ID and can still be changed. A surface with no Input
70// (mobile, `canType` false) cannot fill the field: there the old refusal stands.
71export async function openAssign($: EngineInterface, kind: AssignDraft['kind'], shown: string | null, canType = true) {
72  const t = shown ? ((await read($, boardA))?.tickets.find(x => x.id === shown) ?? null) : null
73  if (!t && !canType) { $.ui.toast('ops board: select a ticket first'); return }
74  const draft: AssignDraft = { id: t?.id ?? '', kind, handle: startHandle(t, kind, await read($, teamA), await read($, meA)), result: null }
75  await update($, personA, () => null)
76  await update($, assignA, () => draft)
77  await showForm($, t ? whoKey(draft) : 'ticket')
78}
79
80const whoKey = (d: AssignDraft) => (d.handle ? 'who:' + d.handle : 'who:+add')
81
82export async function isFormUp($: EngineInterface): Promise<boolean> {
83  return (await read($, assignA)) !== null || (await read($, personA)) !== null || (await read($, reconcileA)) !== null
84}
85
86export async function clearForm($: EngineInterface) {
87  await update($, assignA, () => null)
88  await update($, personA, () => null)
89  await update($, reconcileA, () => null)
90}
91
92// Done or Cancel: back to the view the form came from, Esc the pane's own again
93// (it hands the keys back, the board stays). A form already gone (Esc during a
94// run) leaves the pane as it is.
95export async function closeForm($: EngineInterface) {
96  if (!(await isFormUp($))) return
97  await clearForm($)
98  await $.ui.open({ id: PANE, title: PANE_NAME })
99}
100
101// Assign: ticket-meta assign|decide <ID> @h. Done: a toast with what it said and
102// the command that ran, the dialog closed. Refused: its stderr in the dialog.
103async function confirm($: EngineInterface) {
104  const d = await read($, assignA)
105  if (!d || !d.handle) return
106  // F77: Assign only on a ticket the field resolves to, under the board's own ID.
107  const t = await findTicket($, d.id)
108  if (!t) return
109  const call: Call = d.kind === 'decide' ? 'meta-decide' : 'meta-assign'
110  const r = await runCall($, call, [t.id, d.handle])
111  if (r.code !== 0) {
112    const why = (r.err.trim() || r.out.trim() || `ticket-meta exited ${r.code}`)
113    await update($, assignA, x => (x ? { ...x, result: why } : x))
114    return
115  }
116  const closed = t.box === 'x' || t.box === '-'
117  const state = d.kind === 'decide' ? 'pending decision' : 'pending'
118  const role = d.kind === 'decide' ? `for ${d.handle} to decide` : `owned by ${d.handle}`
119  const head = `✓ ${t.id} ${closed ? 'reopened as' : 'now'} ${state}, ${role}: ${t.title}`
120  $.ui.toast([head, ...notes(r.out), ran(r.argv)].join('\n'), { timeoutMs: 8000 })
121  await closeForm($)
122}
123
124// The Decided button: ticket-meta decided <ID> ([?] -> [ ], the [decide:] tag kept).
125export async function runDecided($: EngineInterface, id: string) {
126  const t = await findTicket($, id)
127  const r = await runCall($, 'meta-decided', [id])
128  if (r.code !== 0) {
129    $.ui.toast([`✗ ticket-meta decided ${id} refused: ${r.err.trim() || r.out.trim() || 'exit ' + r.code}`, ran(r.argv)].join('\n'), { timeoutMs: 8000 })
130    return
131  }
132  $.ui.toast([`✓ ${id} decided, now pending${t ? ': ' + t.title : ''}`, ...notes(r.out), ran(r.argv)].join('\n'), { timeoutMs: 8000 })
133}
134
135// --- team: Add person (a form in the pane) and remove (from the Team view) ---
136
137export async function openAddPerson($: EngineInterface, back: PersonDraft['back']) {
138  if (back === 'team') await update($, assignA, () => null)
139  await update($, personA, () => ({ handle: '', name: '', emails: '', result: null, back }))
140  await showForm($, 'handle')
141}
142
143const handleOf = (s: string) => '@' + s.trim().replace(/^@+/, '').toLowerCase()
144const emailsOf = (s: string) => s.split(/[,;\s]+/).map(x => x.trim()).filter(Boolean).join(',')
145const nameOf = (p: PersonDraft) => p.name.trim() || handleOf(p.handle).slice(1)
146const addArgs = (p: PersonDraft) => [handleOf(p.handle), nameOf(p), emailsOf(p.emails)]
147
148async function addPerson($: EngineInterface) {
149  const p = await read($, personA)
150  if (!p) return
151  const [h, name, emails] = addArgs(p) as [string, string, string]
152  const bad = !HANDLE.test(h) ? 'handle: lower-case a-z 0-9 _ - after the @'
153    : !emails ? 'emails: at least one (a@x, b@y)' : null
154  if (bad) { await update($, personA, x => (x ? { ...x, result: bad } : x)); return }
155  const r = await runCall($, 'meta-team-add', [h, name, emails])
156  if (r.code !== 0) {
157    const why = r.err.trim() || r.out.trim() || `ticket-meta exited ${r.code}`
158    await update($, personA, x => (x ? { ...x, result: why } : x))
159    return
160  }
161  $.ui.toast([`✓ ${h} added to .skillator/team.md`, ran(r.argv)].join('\n'), { timeoutMs: 8000 })
162  const d = await read($, assignA)
163  if (p.back === 'assign' && d) {
164    // Back on the assign form, the new person picked; the 2 s poll lists them.
165    const next = { ...d, handle: h, result: `✓ ${h} added to .skillator/team.md` }
166    await update($, personA, () => null)
167    await update($, assignA, () => next)
168    await showForm($, whoKey(next))
169    return
170  }
171  await closeForm($)
172}
173
174// Cancel, and Esc (the register hook's refused ui.close): one level back - Add person
175// opened from the assign form returns to it, any other form goes.
176export async function cancelPerson($: EngineInterface) {
177  const p = await read($, personA)
178  const d = await read($, assignA)
179  if (p?.back === 'assign' && d) {
180    await update($, personA, () => null)
181    await showForm($, whoKey(d))
182    return
183  }
184  await closeForm($)
185}
186
187export async function removeMember($: EngineInterface, h: string) {
188  const r = await runCall($, 'meta-team-remove', [h])
189  if (r.code !== 0) {
190    $.ui.toast([`✗ ticket-meta team remove ${h} refused: ${r.err.trim() || r.out.trim() || 'exit ' + r.code}`, ran(r.argv)].join('\n'), { timeoutMs: 8000 })
191    return
192  }
193  const said = notes(r.out).filter(l => !/^removed:/.test(l))
194  $.ui.toast([`✓ ${h} removed from .skillator/team.md`, ...said, ran(r.argv)].join('\n'), { timeoutMs: 10000 })
195}
196
197// F80: ticket-meta decides who may (the lead hands over; none set, anyone sets the first).
198export async function makeLead($: EngineInterface, h: string) {
199  const r = await runCall($, 'meta-team-lead', [h])
200  if (r.code !== 0) {
201    $.ui.toast([`✗ ticket-meta team lead ${h} refused: ${r.err.trim() || r.out.trim() || 'exit ' + r.code}`, ran(r.argv)].join('\n'), { timeoutMs: 8000 })
202    return
203  }
204  const said = notes(r.out).filter(l => !/^lead:/.test(l))
205  $.ui.toast([`✓ ${h} is the lead`, ...said, ran(r.argv)].join('\n'), { timeoutMs: 10000 })
206}
207
208// --- drawing ---
209
210// The form up in the ops-board pane, or null when none is (the pane draws its view).
211// Cancel is a plain Button, not role="dismiss": a desktop would draw that as the
212// whole pane's close control.
213export async function renderForm($: EngineInterface, e: RenderInput<'Pane'>): Promise<RenderElement | null> {
214  const p = await read($, personA)
215  const d = await read($, assignA)
216  if (!p && !d) return null
217  const t = $.ui.resolve(e)
218  const { Box, Text, Button } = t
219  const Input = e.surface !== 'mobile' && 'Input' in t ? t.Input : null
220  const cols = Math.max(20, e.props.bodyColumns)
221  const team = await read($, teamA)
222  const label = (s: string) => <Text dimColor>{s}</Text>
223  const heading = (s: string) => <Box key="form:title"><Text bold>{s}</Text><Text dimColor>{'  Esc cancels'}</Text></Box>
224
225  if (p) {
226    const [who, name, emails] = addArgs(p) as [string, string, string]   // never `h`: the JSX factory
227    const set = (k: 'handle' | 'name' | 'emails') => (v: string) => { void update($, personA, x => (x ? { ...x, [k]: v, result: null } : x)) }
228    return (
229      <Box flexDirection="column">
230        {heading('Add person')}
231        <Text> </Text>
232        {Input
233          ? [
234              <Input key="handle" label="Handle  " placeholder="@handle" value={p.handle} autoFocus onInput={set('handle')} onSubmit={set('handle')} />,
235              <Input key="name" label="Name    " placeholder="Full Name" value={p.name} onInput={set('name')} onSubmit={set('name')} />,
236              <Input key="emails" label="Emails  " placeholder="a@x, b@y" value={p.emails} onInput={set('emails')} onSubmit={set('emails')} />,
237            ]
238          : <Text dimColor>Adding a person needs a keyboard surface (terminal or desktop).</Text>}
239        <Text> </Text>
240        <Box key="writes" flexDirection="row">{label('Writes  ')}<Text>.skillator/team.md</Text>{label(' (committed later)')}</Box>
241        <Box key="runs" flexDirection="row">{label('Runs')}<Text wrap="wrap">{`    ticket-meta team add ${who} ${quote(name)} ${emails}`}</Text></Box>
242        {p.result && <Box key="result"><Text color="error" wrap="wrap">{p.result}</Text></Box>}
243        <Text> </Text>
244        <Box flexDirection="row" gap={4}>
245          <Button key="add" variant="primary" onPress={() => { void addPerson($) }}>Add</Button>
246          <Button key="cancel" onPress={() => { void cancelPerson($) }}>Cancel</Button>
247        </Box>
248      </Box>
249    )
250  }
251
252  if (!d) return null
253  const tk = await findTicket($, d.id)
254  const from: State = tk?.box ?? ' '
255  const to: State = d.kind === 'decide' ? '?' : ' '
256  const set = (fn: (x: AssignDraft) => AssignDraft) => { void update($, assignA, x => (x ? fn({ ...x, result: null }) : x)) }
257  const radio = (kind: AssignDraft['kind'], text: string) => (
258    <Button key={kind === 'owner' ? 'kind:implementation' : 'kind:decision'} plain onPress={() => set(x => ({ ...x, kind }))}>
259      <Text color={d.kind === kind ? 'success' : undefined}>{d.kind === kind ? '(•)' : '( )'}</Text>{' ' + text}
260    </Button>
261  )
262  const people = [...team]
263  if (d.handle && !people.some(m => m.handle === d.handle)) people.push({ handle: d.handle, emails: [] })
264  const who = people.map(m => {
265    const isSel = m.handle === d.handle
266    const body = `${isSel ? '› ' : '  '}${m.handle.padEnd(9)}${m.name ?? ''}`
267    return (
268      <Button key={'who:' + m.handle} plain onPress={() => set(x => ({ ...x, handle: m.handle }))}>
269        {isSel ? <Text inverse>{body.padEnd(Math.max(body.length, 24))}</Text> : body}
270      </Button>
271    )
272  })
273  const verb = d.kind === 'decide' ? 'decide' : 'assign'
274  // F77: the Ticket field - typed in any case; its line under it says what it resolves to.
275  const typed = d.id.trim()
276  const ticketField = Input
277    ? <Input key="ticket" label="Ticket  " placeholder="F12" value={d.id}
278        onInput={v => set(x => ({ ...x, id: v }))} onSubmit={v => set(x => ({ ...x, id: v }))} />
279    : <Box key="ticket" flexDirection="row">{label('Ticket  ')}<Text>{typed || '(none - needs a keyboard surface)'}</Text></Box>
280  const said = tk
281    ? <Box key="title" flexDirection="row">
282        <Text color={paint(tk.box)}>{'● ' + STATE_NAME[tk.box]}</Text>
283        <Text dimColor wrap="truncate-end">{'  ' + cut(tk.title, Math.max(4, cols - STATE_NAME[tk.box].length - 4))}</Text>
284      </Box>
285    : <Box key="title">{typed ? <Text color="error">no such ticket</Text> : <Text dimColor>type a ticket ID, e.g. F12</Text>}</Box>
286  const warn = tk?.box === '~'
287    ? (tk.session
288        ? `! ${tk.id} is in progress under ${tk.session} - this moves it to ${word(to)}; the tag stays - tell that session`
289        : `! ${tk.id} is in progress - this moves it to ${word(to)}; tell whoever is working it`)
290    : null
291  const isGood = !!d.result?.startsWith('✓')
292  return (
293    <Box flexDirection="column">
294      {heading(titleOf(d, tk))}
295      {ticketField}
296      {said}
297      <Text> </Text>
298      <Box key="for" flexDirection="row" gap={3}>{label('For')}{radio('owner', 'implementation')}{radio('decide', 'decision')}</Box>
299      <Box key="who" flexDirection="row">
300        {label('Who   ')}
301        <Box flexDirection="column">
302          {team.length === 0 && <Box key="noteam"><Text color="warning">no .skillator/team.md yet - add a person first</Text></Box>}
303          {who}
304          <Button key="who:+add" plain dimColor onPress={() => { void openAddPerson($, 'assign') }}>{'  + add person'}</Button>
305        </Box>
306      </Box>
307      {tk && <Box key="change" flexDirection="row">
308        <Text dimColor>Change  </Text>
309        <Text color={paint(from)}>{'● ' + word(from)}</Text>
310        <Text>{' -> '}</Text>
311        <Text color={paint(to)}>{'● ' + word(to)}</Text>
312        <Text>{`  ${d.kind === 'decide' ? 'decide' : 'owner'} ${d.handle ?? '(pick someone)'}`}</Text>
313      </Box>}
314      {warn && <Box key="warn"><Text color="warning" wrap="wrap">{warn}</Text></Box>}
315      <Box key="runs" flexDirection="row">{label('Runs')}<Text>{`    ticket-meta ${verb} ${tk?.id ?? '<ID>'} ${d.handle ?? '@?'}`}</Text></Box>
316      {d.result && <Box key="result"><Text color={isGood ? 'success' : 'error'} wrap="wrap">{d.result}</Text></Box>}
317      <Text> </Text>
318      <Box flexDirection="row" gap={4}>
319        {tk && d.handle && <Button key="go" variant="primary" onPress={() => { void confirm($) }}>Assign</Button>}
320        <Button key="cancel" onPress={() => { void closeForm($) }}>Cancel</Button>
321      </Box>
322    </Box>
323  )
324}
325
hooks/reconcile.tsx 82 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, RenderElement, RenderInput } from 'claude-code'
3import type { Proposal } from '../types'
4import { STATE_NAME } from './lib/board'
5import { runCall } from './lib/host'
6import { appliedHead, appliedLine, applyOf, canApply, parseReconcile } from './lib/ticketsmodel'
7import { closeForm } from './assign'
8import { paint } from './paint'
9
10// F81: `c` on the Tickets tab - ticket-meta reconcile's proposals as a form in the
11// ops-board pane (B5: never a second pane): accept all, pick, or none. Each accepted
12// row is written by its ticket-meta subcommand; the mod writes no file. Esc and the
13// form's close go through assign.tsx's form handling (isFormUp / clearForm see it);
14// the two apply buttons are taken in register.tsx, which re-reads the board after.
15
16const PANE = 'ops-board'
17const PANE_NAME = 'ops board'
18const reconcileA = atom({ plugin: 'ops-board', key: 'reconcile' } as const, null)   // assign.tsx's twin: the hooks scan reads only this file's consts
19
20const why = (r: { code: number; out: string; err: string }) => r.err.trim() || r.out.trim() || `exit ${r.code}`
21
22export async function openReconcile($: EngineInterface) {
23  const r = await runCall($, 'meta-reconcile', [])
24  if (r.code !== 0) { $.ui.toast(`✗ ticket-meta reconcile refused: ${why(r)}`, { timeoutMs: 8000 }); return }
25  const rows = parseReconcile(r.out)
26  if (!rows.length) { $.ui.toast('ops board: reconcile - every [!] [ ] [~] row matches its evidence'); return }
27  await update($, reconcileA, () => ({ rows, picked: [] }))
28  await $.ui.open({ id: PANE, title: PANE_NAME, closeOnEscape: true })
29  const first = rows.find(canApply)
30  void $.ui.focus({ requestId: PANE, key: first ? 'rec:' + first.id : 'reconcile:none' }).catch(() => {})
31}
32
33// all: every row that changes; else the picked ones. One toast says what each run did.
34export async function applyReconcile($: EngineInterface, all: boolean) {
35  const v = await read($, reconcileA)
36  if (!v) return
37  const todo = v.rows.filter(p => canApply(p) && (all || v.picked.includes(p.id)))
38  const lines: string[] = []
39  for (const p of todo) {
40    const { call, args } = applyOf(p)
41    const r = await runCall($, call, args)
42    lines.push(appliedLine(p, r.code === 0 ? null : why(r)))
43  }
44  const ok = lines.filter(l => l.startsWith('✓')).length
45  $.ui.toast([appliedHead(ok, todo.length), ...lines].join('\n'), { timeoutMs: 10000 })
46  await closeForm($)
47}
48
49export async function renderReconcile($: EngineInterface, e: RenderInput<'Pane'>): Promise<RenderElement | null> {
50  const v = await read($, reconcileA)
51  if (!v) return null
52  const { Box, Text, Button } = $.ui.resolve(e)
53  const n = v.rows.filter(canApply).length
54  const dot = (b: Proposal['to']) => <Text color={paint(b)}>{'● ' + STATE_NAME[b]}</Text>
55  const rows = v.rows.map(p => {
56    if (!canApply(p)) {
57      return <Box key={'rec:' + p.id} flexDirection="row"><Text dimColor>{`    ${p.id}  `}</Text>{dot(p.from)}<Text dimColor wrap="wrap">{'  stays - ' + p.why}</Text></Box>
58    }
59    const on = v.picked.includes(p.id)
60    const toggle = () => { void update($, reconcileA, x => (x ? { ...x, picked: on ? x.picked.filter(i => i !== p.id) : [...x.picked, p.id] } : x)) }
61    return (
62      <Button key={'rec:' + p.id} plain onPress={toggle}>
63        <Text color={on ? 'success' : undefined}>{on ? '[x]' : '[ ]'}</Text>{` ${p.id}  `}{dot(p.from)}{' -> '}{dot(p.to)}<Text dimColor wrap="wrap">{'  ' + p.why}</Text>
64      </Button>
65    )
66  })
67  return (
68    <Box flexDirection="column">
69      <Box key="form:title"><Text bold>Reconcile tickets</Text><Text dimColor>{'  Esc cancels'}</Text></Box>
70      <Text dimColor>{`${n} to change, from ticket-meta reconcile - nothing is written until you apply`}</Text>
71      <Text> </Text>
72      {rows}
73      <Text> </Text>
74      <Box flexDirection="row" gap={4}>
75        {n > 0 && <Button key="reconcile:all" variant="primary" onPress={() => {}}>Accept all</Button>}
76        {v.picked.length > 0 && <Button key="reconcile:picked" onPress={() => {}}>{`Apply picked (${v.picked.length})`}</Button>}
77        <Button key="reconcile:none" onPress={() => { void closeForm($) }}>None</Button>
78      </Box>
79    </Box>
80  )
81}
82
hooks/runstab.tsx 180 lines
1import { atom, read, update } from 'claude-code'
2import type { BoxProps, ButtonProps, ElementConstructor, EngineInterface, RenderElement, TextProps } from 'claude-code'
3import type { Board, Engage, RunView } from '../types'
4import type { AgentsView } from './agentstab'
5import { runCall } from './lib/host'
6import { parseRuns, scriptPathOf } from './lib/runs'
7import { continuePrompt, engageOf, runLines, stageTickets as ticketsOf } from './lib/runlines'
8import type { RunLine } from './lib/runlines'
9import { treeLines } from './lib/agents'
10export { continuePrompt, runLines, waves } from './lib/runlines'
11
12// F50, design §8: the Runs tab (mock m-plan). relay-morpheus list, then resume (F85: the
13// run file whole) for each run not all done; a planned run shows its stage table, what runs
14// in parallel and its parked decisions. F85: a run's mode, its script's phases (the
15// .workflow.js beside the run file, read as data, never run) and, under it, the Workflow
16// runs and agents serving its stages. Engage asks first, in this pane (B5), then submits
17// /skillator:marathon-run (a script) or queues the run and submits its continue prompt.
18// Ticket state is a coloured dot from the board, never a bracket code.
19
20const runsA = atom({ plugin: 'ops-board', key: 'runs' } as const, [])
21const relayA = atom({ plugin: 'ops-board', key: 'relay' } as const, null)
22const boardA = atom({ plugin: 'ops-board', key: 'board' } as const, null)
23const engageA = atom({ plugin: 'ops-board', key: 'engage' } as const, null)
24const tabA = atom({ plugin: 'ops-board', key: 'tab' } as const, 'tickets')
25
26const PANE = 'ops-board'          // pane.tsx's PANE_ID (it imports this file)
27const PANE_NAME = 'ops board'     // pane.tsx's PANE_TITLE
28
29export const RUNS_POLL_MS = 30_000
30const CHAIN_STATUS_AT_ONCE = 2
31let polling = false
32
33// One read of relay: the list, then each open run's file and its script. A failed list keeps what was shown.
34export async function pollRuns($: EngineInterface): Promise<void> {
35  if (polling) return
36  polling = true
37  try {
38    const r = await runCall($, 'relay-list', [])
39    if (r.code !== 0) return
40    const chains: Record<string, string> = {}
41    const scripts: Record<string, string> = {}
42    const root = (await $.session.repo())?.root ?? ''
43    const open = parseRuns(r.out, {}).filter(x => x.done < x.total)
44    // The open runs' resume calls overlap, at most CHAIN_STATUS_AT_ONCE at a time.
45    let next = 0
46    const worker = async () => {
47      while (next < open.length) {
48        const run = open[next++]!
49        const c = await runCall($, 'relay-resume', [run.id])
50        if (c.code !== 0) continue
51        chains[run.id] = c.out
52        const js = scriptPathOf(c.out, root)
53        if (js) { try { scripts[run.id] = await $.fs.read(js) } catch { /* no script: an agents run */ } }
54      }
55    }
56    await Promise.all(Array.from({ length: Math.min(CHAIN_STATUS_AT_ONCE, open.length) }, worker))
57    const runs = parseRuns(r.out, chains, scripts)
58    await update($, runsA, () => runs)
59    const first = runs.find(x => x.done < x.total)
60    const text = first ? `${first.id} ${first.done}/${first.total}` : null
61    if (text !== await read($, relayA)) await update($, relayA, () => text)
62  } finally {
63    polling = false
64  }
65}
66
67// F85: the confirm is up while the Workflows tab is the one shown (as the Run tab's form).
68export async function isEngageUp($: EngineInterface): Promise<boolean> {
69  return (await read($, tabA)) === 'runs' && (await read($, engageA)) !== null
70}
71// /ops to another tab and an unload: the confirm just goes.
72export async function dropEngage($: EngineInterface) {
73  if ((await read($, engageA)) !== null) await update($, engageA, () => null)
74}
75// Cancel, Esc and done: the tab back, Esc the pane's own again.
76export async function closeEngage($: EngineInterface) {
77  if ((await read($, engageA)) === null) return
78  await update($, engageA, () => null)
79  await $.ui.open({ id: PANE, title: PANE_NAME })
80}
81
82// Engage: refused at once (a toast naming the driver) while another driver is live, else
83// the confirm, Esc its Cancel.
84export async function openEngage($: EngineInterface, id: string): Promise<void> {
85  const run = (await read($, runsA)).find(r => r.id === id)
86  if (!run) return
87  const e = engageOf(run, await $.clock.now())
88  if ('refused' in e) { $.ui.toast(e.refused); return }
89  await update($, engageA, () => ({ id, ...e }))
90  await $.ui.open({ id: PANE, title: PANE_NAME, closeOnEscape: true })
91  void $.ui.focus({ requestId: PANE, key: 'engage:go' }).catch(() => {})
92}
93
94// Engage confirmed: checked again (a driver may have come since), then submitted - a run
95// with no script after relay-morpheus queue-run add, said with what relay said and what ran.
96export async function engageGo($: EngineInterface): Promise<void> {
97  const g = await read($, engageA)
98  await closeEngage($)
99  if (!g) return
100  const run = (await read($, runsA)).find(r => r.id === g.id)
101  const e = run ? engageOf(run, await $.clock.now()) : { refused: `■ ${g.id} is no longer an open run` }
102  if ('refused' in e) { $.ui.toast(e.refused); return }
103  if (!e.queue) {
104    // a slash command is run as one, never submitted as a prompt (the host refuses that)
105    const [command, ...args] = e.text.slice(1).split(' ')
106    $.ui.toast(`✓ ${g.id} engaged: ${e.text}`)
107    void $.command.run({ command: command!, args: args.join(' ') })
108      .catch(err => { $.ui.toast(`■ ${g.id} not engaged: ${e.text} - ${err instanceof Error ? err.message : String(err)}`) })
109    return
110  }
111  const r = await runCall($, 'relay-queue-add', [g.id])
112  const ran = r.argv.length ? `\nran: ${r.argv.join(' ')}` : ''
113  const said = r.out.trim() || r.err.trim()
114  if (r.code !== 0) {
115    $.ui.toast(`■ ${g.id} not queued: ${r.err.trim() || r.out.trim() || `exit ${r.code}`}${ran}`)
116    return
117  }
118  $.ui.toast(`✓ ${g.id} queued${said ? ` - ${said}` : ''}. Its continue prompt runs as the next turn.${ran}`)
119  void $.prompt.submit({ text: continuePrompt(g.id) })
120  void pollRuns($).catch(() => {})
121}
122
123export type RunsView = { runs: RunView[]; board: Board | null; cols: number; engage: Engage | null }
124
125// The runs the tab draws: those not all done (the rest are counted, not drawn).
126export const openRuns = (runs: RunView[]) => runs.filter(r => !(r.total > 0 && r.done >= r.total))
127
128export async function readRuns($: EngineInterface, cols: number): Promise<RunsView> {
129  return { runs: await read($, runsA), board: await read($, boardA), cols, engage: await read($, engageA) }
130}
131
132type Els = { Box: ElementConstructor<BoxProps>; Text: ElementConstructor<TextProps>; Button: ElementConstructor<ButtonProps> }
133
134// F85: the engage confirm, drawn in place of the tab (B5: never a second pane).
135export function EngageConfirm($: EngineInterface, els: Els, g: Engage): RenderElement[] {
136  const { Box, Text, Button } = els
137  return [
138    <Box key="engage:title"><Text bold>{`Engage ${g.id}?`}</Text><Text dimColor>{'  Esc cancels'}</Text></Box>,
139    <Text> </Text>,
140    ...(g.queue ? [<Box key="engage:queue" flexDirection="row"><Text dimColor>{'First    '}</Text><Text wrap="wrap">{`relay-morpheus queue-run add ${g.id}`}</Text></Box>] : []),
141    <Box key="engage:submits" flexDirection="row"><Text dimColor>{'Submits  '}</Text><Text wrap="wrap">{g.text}</Text></Box>,
142    <Text> </Text>,
143    <Box flexDirection="row" gap={4}>
144      <Button key="engage:go" variant="primary" onPress={() => { void engageGo($) }}>Engage</Button>
145      <Button key="engage:cancel" onPress={() => { void closeEngage($) }}>Cancel</Button>
146    </Box>,
147  ]
148}
149
150export function RunsTab($: EngineInterface, els: Els, v: RunsView, a: AgentsView): RenderElement[] {
151  const { Box, Text, Button } = els
152  const out: RenderElement[] = []
153  const draw = (l: RunLine) =>
154    <Box key={l.key}><Text wrap="truncate-end">{l.segs.map(s => <Text color={s.color} dimColor={s.dim}>{s.text}</Text>)}</Text></Box>
155  const open = openRuns(v.runs)
156  const finished = v.runs.length - open.length
157  if (!open.length) out.push(<Box key="none"><Text dimColor>{'no relay run open - /skillator:plan-runs <tickets> plans one'}</Text></Box>)
158  open.forEach((run, i) => {
159    if (i) out.push(<Text> </Text>)
160    out.push(...runLines(run, v.board).map(draw))
161    out.push(...treeLines(run, open, a.agents, a.workflows, v.board).map(draw))
162    const ids = [...new Set(run.stages.flatMap(ticketsOf))]
163    out.push(
164      <Box flexDirection="row" gap={3}>
165        <Button key={'engage:' + run.id} variant="primary" onPress={() => { void openEngage($, run.id) }}>Engage</Button>
166        {run.planned && <Box key={'edit:' + run.id}><Text dimColor>{`Edit in chat: /skillator:plan-runs ${ids.join(' ')}`}</Text></Box>}
167      </Box>,
168    )
169  })
170  const loose = treeLines(null, open, a.agents, a.workflows, v.board)
171  if (loose.length) {
172    out.push(<Text> </Text>, <Box key="norun"><Text bold>no run</Text></Box>, ...loose.map(draw))
173  }
174  if (finished) {
175    out.push(<Text> </Text>)
176    out.push(<Box key="finished"><Text dimColor>{`${finished} finished run${finished === 1 ? '' : 's'} not shown`}</Text></Box>)
177  }
178  return out
179}
180