SLOPSHOPPER

moai-board

Read-only side panel for the MoAI kanban queue, factory lanes and SPEC documents. Actions: refresh, open, pick (confirmed).

newpanecommandtoastprocesstimer
★ 1,231v0.1.0Apache-2.0updated 2026-10-02modu-ai/moai-adk/mods/moai-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · moai-board
│ ┃ moai-board ✕ › fix the failing auth test and add an audit log call │ ┃ moai-board /work/app x: close │ ┃ [ Queue ] [ Lanes ] [ SPEC ] r: refresh ⏺ Read(src/auth.ts) │ ┃ Picked 0 · Queued 0 · Held 0 ⎿ Read 6 lines │ ┃ The queue is empty. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /moai-board │ ⎿ moai-board: moai-board pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · moai-board
moai-board /work/app x: close [ Queue ] [ Lanes ] [ SPEC ] r: refresh Picked 0 · Queued 0 · Held 0 The queue is empty.
README

moai-board

A read-only side panel for Claude Code that shows the MoAI kanban queue, the factory lanes and SPEC documents in one pane. A mod: a plugin of function hooks that runs inside the session (Claude Code 2.1.287; the plugin API is early access and moves between releases).

Prototype for card t1436, SPEC-MOAI-BOARD-MOD-001. It lives in this folder only: it is not embedded in the moai binary, not deployed by moai init or moai update, and not part of the release archives. Where it finally loads from (user, project or repository) waits on card t1434.

What it does

/moai-board opens the pane moai-board with three tabs (hotkeys 1, 2, 3) and a refresh button (r):

TabShowsReads
QueuePicked / Queued / Held counts, the cards grouped by state, a card's full textmoai gtd list --json (text list --limit 0 as the fallback)
Lanesfactory cards by owner with state, stage, SPEC id and a "lease expired" marker; sessions with a heartbeat in the last 24 h (newest first, 20 at most, no alive/dead verdict)moai factory status --json, moai session list --json
SPECSPECs filtered by status (default draft and in-progress), 15 per page; one file of a SPEC at a time as Markdownmoai spec status --list, then the file through the engine's file calls

The pane polls every 15 seconds at the fastest, one poll at a time, and only while it is open. A failed read shows a one-line cause and keeps the last good data, dimmed. moai missing from PATH is one of those causes; the session carries on.

The boundary

Three actions only: refresh, open and pick. Everything else (editing, dropping, completing, holding, moving, merging, pushing, pull requests, writing a file) is out of scope.

Pick promotes one queued card to picked with moai gtd next <id> --expect <prefix>. It runs only after you press the pick button on that card and answer the confirmation dialog with Pick. Any other answer, Cancel, a dismissed dialog, or no one to ask (claude -p) runs nothing. A lane session cannot mutate the queue (moai refuses); the refusal text is shown as is.

Every command is an argv list from a fixed table in hooks/data.ts and goes through one call site in hooks/register.tsx; there is no shell string, no network call, no file write.

Run it

claude --plugin-dir /absolute/path/to/mods/moai-board
# then type /moai-board

The engine lays its typings into .claude-plugin/types/ of a mod it loads this way; the repository .gitignore ignores that folder. The mod's own contract, types/index.d.ts, is tracked.

Check it

From the repository root:

claude plugin validate mods/moai-board

Engine tests (hook dispatch, timers, the pick flow, the pane mounted on terminal and desktop). The runner is refused while the account's rollout switch is off; an empty config directory has run it regardless:

mkdir -p /tmp/mbm-claude-cfg
CLAUDE_CONFIG_DIR=/tmp/mbm-claude-cfg claude plugin test mods/moai-board

Pure tests (parsers, reducers, the SPEC guard, the chunker). They need bun, which nothing else in this repository uses, so they are developer-local evidence only. Judge them from the junit file:

bun test mods/moai-board/tests/pure/ --reporter=junit --reporter-outfile=/tmp/mbm-junit.xml

Test file names decide the runner: *.test.ts and *.test.tsx under tests/ are engine tests, *.spec.ts under tests/pure/ are pure tests (and are excluded from tsconfig.json).

Layout

.claude-plugin/plugin.json   manifest; names the state contract under "types"
hooks/hooks.json             one hooks module
hooks/register.tsx           the only file that spells the engine calls: hooks, timer, press handlers
hooks/data.ts                argv table, run classification, queue and lane parsers, pick helpers
hooks/specs.ts               SPEC list, id and file allow-lists, real-path guard, Markdown chunker
hooks/view.tsx               the three tabs, drawn with Box, Text, Button and Markdown only
types/index.d.ts             the plugin's state contract
tests/                       engine tests; tests/pure/ holds the bun tests

The engine refuses the engine handle passed into a function imported from another file, so the helper modules receive plain functions (run, stat, read) or the resolved element table and never the handle itself.

Source 5 files
hooks/register.tsx 303 lines
1// moai-board hooks module. The only file that spells `$.…`: helper modules
2// (data.ts, specs.ts, view.tsx) receive functions or resolved element tables,
3// never `$` (the engine refuses `$` passed into an imported function, spec.md §4).
4import type { EngineInterface, Register } from 'claude-code'
5import type { MoaiBoardCard, MoaiBoardTab, MoaiBoardView } from '../types'
6import {
7  CANCEL_LABEL,
8  CMD_TIMEOUT_MS,
9  CONFIRM_LABEL,
10  POLL_INTERVAL_MS,
11  buildPickArgv,
12  canPick,
13  clampInterval,
14  createFlightGate,
15  createQueueReaders,
16  cutChars,
17  emptyFeed,
18  executePick,
19  firstLine,
20  isConfirmed,
21  isStillQueued,
22  pickPrefix,
23  readLanes,
24  readQueue,
25  sameFeed,
26} from './data'
27import { chunkMarkdown, isSpecFile, readSpecFile, readSpecList } from './specs'
28import type { Actions, Model } from './view'
29import { drawBoard } from './view'
30
31const PANE = 'moai-board'
32
33// Typed references: plugin and key are literals, used for nothing but $.state calls.
34const viewRef = { plugin: 'moai-board', key: 'view' } as const
35const queueRef = { plugin: 'moai-board', key: 'queue' } as const
36const lanesRef = { plugin: 'moai-board', key: 'lanes' } as const
37const specsRef = { plugin: 'moai-board', key: 'specs' } as const
38const docRef = { plugin: 'moai-board', key: 'doc' } as const
39const noticeRef = { plugin: 'moai-board', key: 'notice' } as const
40
41const DEFAULT_VIEW: MoaiBoardView = { tab: 'queue', card: '', spec: '', file: '', status: 'active', page: 0, root: '' }
42
43// @MX:NOTE: [AUTO] the module's only child-process call site (REQ-MBM-013); every argv that reaches it
44// comes from the fixed table or the pick builder in data.ts. Helpers receive it as a function.
45const runMoai = ($: EngineInterface, argv: readonly string[]) =>
46  $.process.run(argv, { timeoutMs: CMD_TIMEOUT_MS })
47
48const errorText = (err: unknown): string => (err instanceof Error ? err.message : String(err))
49
50// ---- state helpers ---------------------------------------------------------------------
51const readView = async ($: EngineInterface): Promise<MoaiBoardView> => (await $.state.get(viewRef)).value ?? DEFAULT_VIEW
52
53const patchView = async ($: EngineInterface, patch: Partial<MoaiBoardView>): Promise<void> => {
54  await $.state.set(viewRef, { ...(await readView($)), ...patch })
55}
56
57const say = async ($: EngineInterface, text: string): Promise<void> => {
58  await $.state.set(noticeRef, text)
59}
60
61// Fail-soft (REQ-MBM-010): a handler that throws leaves a notice, never an exception in the hook chain.
62const soft = async ($: EngineInterface, body: () => Promise<void>): Promise<void> => {
63  try {
64    await body()
65  } catch (err) {
66    try {
67      await say($, `moai-board: ${errorText(err)}`)
68    } catch {
69      // nothing left to try; the session continues unaffected
70    }
71  }
72}
73
74// ---- polling (REQ-MBM-006, REQ-MBM-007) --------------------------------------------
75// Module variables hold only what a hot reload may lose at the cost of one re-parse
76// or one timer restart; view and data state live in $.state (REQ-MBM-015).
77const readers = createQueueReaders()
78const gate = createFlightGate()
79let timer: { cancel: () => void } | undefined
80
81type Wanted = { lanes: boolean; spec: boolean }
82
83let pending: Wanted | undefined
84
85const merge = (a: Wanted | undefined, b: Wanted): Wanted => ({
86  lanes: (a?.lanes ?? false) || b.lanes,
87  spec: (a?.spec ?? false) || b.spec,
88})
89
90const pollOnce = async ($: EngineInterface, want: Wanted): Promise<void> => {
91  const run = (argv: readonly string[]) => runMoai($, argv)
92  const now = Date.now()
93  const view = await readView($)
94  const prevQueue = (await $.state.get(queueRef)).value ?? emptyFeed()
95  const q = await readQueue(run, readers, prevQueue, now)
96  if (q.isChanged) await $.state.set(queueRef, q.feed)
97  if (want.lanes || view.tab === 'lanes') {
98    const prevLanes = (await $.state.get(lanesRef)).value ?? emptyFeed()
99    const next = await readLanes(run, prevLanes, now)
100    if (!sameFeed(prevLanes, next)) await $.state.set(lanesRef, next)
101  }
102  // The SPEC list is read on tab open and on refresh only, never by the timer.
103  if (want.spec) {
104    const prevSpecs = (await $.state.get(specsRef)).value ?? emptyFeed()
105    const next = await readSpecList(run, prevSpecs, now)
106    if (!sameFeed(prevSpecs, next)) await $.state.set(specsRef, next)
107  }
108}
109
110// At most one poll in flight. A user action that arrives while one runs is remembered
111// and served right after it; a timer tick that finds one running is dropped. A user action
112// resolves only once its own read has been served, so a caller that reads the feed afterwards
113// sees data read after the call (REQ-MBM-005: the pick confirmation).
114let running: Promise<void> | undefined
115
116const refresh = async ($: EngineInterface, want: Wanted, isTick = false): Promise<void> => {
117  if (!isTick) pending = merge(pending, want)
118  if (!gate.tryStart()) {
119    if (!isTick) await running
120    return
121  }
122  running = (async () => {
123    try {
124      let next: Wanted | undefined = isTick ? merge(pending, want) : pending
125      pending = undefined
126      while (next !== undefined) {
127        await pollOnce($, next)
128        next = pending
129        pending = undefined
130      }
131    } catch (err) {
132      await soft($, async () => say($, `The board could not refresh: ${errorText(err)}`))
133    } finally {
134      gate.finish()
135    }
136  })()
137  await running
138}
139
140const stopPolling = (): void => {
141  timer?.cancel()
142  timer = undefined
143}
144
145const startPolling = ($: EngineInterface): void => {
146  stopPolling()
147  timer = $.clock.every(clampInterval(POLL_INTERVAL_MS), () => {
148    void refresh($, { lanes: false, spec: false }, true)
149  })
150}
151
152// The pane's own close control. A plugin's own $.ui.close does not reach its own ui.close
153// hook (measured with the test kit), so the timer is stopped here; the person's close mark
154// and Esc do reach the hook below.
155const closePane = async ($: EngineInterface): Promise<void> => {
156  stopPolling()
157  await $.ui.close({ id: PANE })
158}
159
160// ---- view navigation: local view state, and only argv of the fixed read-only table ------------
161const switchTab = async ($: EngineInterface, tab: MoaiBoardTab): Promise<void> => {
162  await patchView($, { tab, card: '', spec: '', file: '', page: 0 })
163  await say($, '')
164  await refresh($, { lanes: tab === 'lanes', spec: tab === 'spec' })
165}
166
167const refreshNow = async ($: EngineInterface): Promise<void> => {
168  const view = await readView($)
169  await say($, '')
170  await refresh($, { lanes: view.tab === 'lanes', spec: view.tab === 'spec' })
171}
172
173// ---- the one write-capable action: pick (REQ-MBM-003 to REQ-MBM-005) ---------------------------
174const onPickPress = async ($: EngineInterface, card: MoaiBoardCard): Promise<void> => {
175  const argv = canPick(card) ? buildPickArgv(card.id, pickPrefix(card.text)) : undefined
176  if (argv === undefined) return
177  let answer: string
178  try {
179    answer = await $.ui.ask(`Pick card ${card.id} ("${cutChars(firstLine(card.text), 120)}")?`, {
180      options: [CONFIRM_LABEL, CANCEL_LABEL],
181      header: 'Pick card',
182    })
183  } catch {
184    return // dismissed, or nobody to ask (-p run): no process, no change
185  }
186  if (!isConfirmed(answer)) return
187  const result = await executePick(argv => runMoai($, argv), argv)
188  await refresh($, { lanes: false, spec: false })
189  const queued = (await $.state.get(queueRef)).value?.data
190  const isUnconfirmed = result.kind === 'ok' && queued !== undefined && isStillQueued(queued.cards, card.id)
191  const message =
192    result.kind === 'failed'
193      ? `Pick failed: ${result.text}`
194      : isUnconfirmed
195        ? 'The pick reported success but the card still reads queued (unconfirmed).'
196        : `Picked ${card.id}.`
197  await say($, message)
198  if (result.kind === 'failed' || isUnconfirmed) $.ui.toast(message)
199}
200
201// ---- SPEC reading: the file calls are wired here, the guard itself is pure (specs.ts) -------------
202const openSpecFile = async ($: EngineInterface, file: string): Promise<void> => {
203  const view = await readView($)
204  const root = await rootOf($)
205  const io = {
206    stat: (path: string) => $.fs.stat(path, { resolve: true }),
207    read: (path: string) => $.fs.read(path),
208  }
209  const result = isSpecFile(file) ? await readSpecFile(io, root, view.spec, file) : undefined
210  const label = `${view.spec}/${file}`
211  const doc =
212    result?.ok === true
213      ? { spec: view.spec, file, ...chunkMarkdown(result.text, label) }
214      : { spec: view.spec, file, chunks: [], notice: result?.ok === false ? result.reason : `"${file}" is not a SPEC file.` }
215  await patchView($, { file, root })
216  await $.state.set(docRef, doc)
217}
218
219const closeSpec = async ($: EngineInterface): Promise<void> => {
220  await patchView($, { card: '', spec: '', file: '' })
221  await $.state.set(docRef, undefined)
222}
223
224// ---- drawing ---------------------------------------------------------------------------------------
225const readModel = async ($: EngineInterface, cols: number): Promise<Model> => ({
226  cols,
227  now: Date.now(),
228  view: await readView($),
229  queue: (await $.state.get(queueRef)).value ?? emptyFeed(),
230  lanes: (await $.state.get(lanesRef)).value ?? emptyFeed(),
231  specs: (await $.state.get(specsRef)).value ?? emptyFeed(),
232  doc: (await $.state.get(docRef)).value,
233  notice: (await $.state.get(noticeRef)).value ?? '',
234})
235
236const makeActions = ($: EngineInterface): Actions => ({
237  tab: tab => void soft($, () => switchTab($, tab)),
238  refresh: () => void soft($, () => refreshNow($)),
239  back: () => void soft($, () => closeSpec($)),
240  close: () => void soft($, () => closePane($)),
241  openCard: id => void soft($, () => patchView($, { card: id })),
242  pick: card => void soft($, () => onPickPress($, card)),
243  openSpec: id => void soft($, () => patchView($, { spec: id, file: '' })),
244  openFile: file => void soft($, () => openSpecFile($, file)),
245  setStatus: status => void soft($, () => patchView($, { status, page: 0 })),
246  setPage: page => void soft($, () => patchView($, { page })),
247})
248
249const rootOf = async ($: EngineInterface): Promise<string> => {
250  try {
251    return await $.session.root()
252  } catch {
253    return ''
254  }
255}
256
257export const register: Register = on => {
258  on('session.start', async ($, e, next) => {
259    try {
260      await $.command.register({
261        name: 'moai-board',
262        description: 'Open the moai board pane: queue, lanes, SPECs (read-only; pick asks first)',
263      })
264    } catch {
265      // the command is simply absent; the session continues unaffected
266    }
267    return next(e)
268  })
269
270  on('command.run', { command: 'moai-board' }, async $ => {
271    try {
272      await $.ui.open({ id: PANE, title: 'moai-board' })
273      await patchView($, { root: await rootOf($) })
274      startPolling($)
275      await refresh($, { lanes: false, spec: false })
276      return { text: 'moai-board pane opened.' }
277    } catch (err) {
278      return { text: `moai-board could not open the pane: ${errorText(err)}` }
279    }
280  })
281
282  on('ui.close', async ($, e, next) => {
283    if (e.id === PANE) stopPolling()
284    return next(e)
285  })
286
287  on('session.end', async ($, e, next) => {
288    stopPolling()
289    return next(e)
290  })
291
292  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
293    const table = $.ui.resolve(e)
294    try {
295      const cols = typeof e.props.bodyColumns === 'number' ? e.props.bodyColumns : 80
296      return drawBoard(table, await readModel($, cols), makeActions($))
297    } catch (err) {
298      const { Text } = table
299      return <Text dimColor>{`moai-board could not draw: ${errorText(err)}`}</Text>
300    }
301  })
302}
303
hooks/data.ts 420 lines
1// moai-board data layer: `$`-free. Everything here is a pure function or takes a
2// `run` function from hooks/register.tsx, because the engine refuses `$` passed
3// into an imported function (spec.md §4). Tested with bun (tests/pure/).
4import type {
5  MoaiBoardCard,
6  MoaiBoardCardState,
7  MoaiBoardFeed,
8  MoaiBoardLaneCard,
9  MoaiBoardLanes,
10  MoaiBoardQueue,
11  MoaiBoardSession,
12} from '../types'
13
14// ---- constants (plan.md §G) ------------------------------------------------
15export const POLL_MIN_MS = 15_000
16export const POLL_INTERVAL_MS = 15_000
17export const CMD_TIMEOUT_MS = 20_000
18export const STDERR_SHOW = 400
19export const EXPECT_PREFIX_LEN = 40
20export const CONFIRM_LABEL = 'Pick'
21export const CANCEL_LABEL = 'Cancel'
22
23// ---- the process boundary --------------------------------------------------
24export type RunResult = {
25  exitCode: number
26  stdout: string
27  stderr: string
28  isStdoutTruncated: boolean
29}
30/** What register.tsx hands over in place of `$`: one argv list in, one result out (rejects when it cannot run). */
31export type Run = (argv: readonly string[]) => Promise<RunResult>
32
33// ---- the fixed argv table (REQ-MBM-002, REQ-MBM-013) ------------------------
34/** Read-only commands. The one write-capable argv is built by the pick builder below, nowhere else. */
35export const ARGV = {
36  queueJson: ['moai', 'gtd', 'list', '--json'],
37  queueText: ['moai', 'gtd', 'list', '--limit', '0'],
38  lanes: ['moai', 'factory', 'status', '--json'],
39  sessions: ['moai', 'session', 'list', '--json'],
40  specList: ['moai', 'spec', 'status', '--list'],
41} as const satisfies Record<string, readonly string[]>
42
43// ---- pick (plan.md §B.3, §B.8) ----------------------------------------------
44const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$/
45
46export const isCardId = (id: string): boolean => ID_RE.test(id)
47
48/** The first 40 code points of the card text as polled. */
49export const pickPrefix = (text: string): string => Array.from(text).slice(0, EXPECT_PREFIX_LEN).join('')
50
51const isPrefixOk = (prefix: string): boolean => prefix.length > 0 && !prefix.startsWith('-')
52
53// @MX:NOTE: [AUTO] the one write-capable argv in the mod. Only the confirmed press handler in
54// register.tsx may call it (SPEC-MOAI-BOARD-MOD-001 REQ-MBM-002, REQ-MBM-003).
55/** The one write-capable argv. Undefined when the id or the prefix fails its check. */
56export const buildPickArgv = (id: string, prefix: string): readonly string[] | undefined =>
57  isCardId(id) && isPrefixOk(prefix) ? ['moai', 'gtd', 'next', id, '--expect', prefix] : undefined
58
59/** A card gets a pick button only when it is queued and its id and prefix pass the checks. */
60export const canPick = (card: Pick<MoaiBoardCard, 'id' | 'state' | 'text'>): boolean =>
61  card.state === 'queued' && isCardId(card.id) && isPrefixOk(pickPrefix(card.text))
62
63/** Only the exact confirm label confirms: other text typed under "Other", a dismissal, or Cancel do not. */
64export const isConfirmed = (answer: unknown): boolean => answer === CONFIRM_LABEL
65
66// ---- small text helpers ------------------------------------------------------
67const SEP = ' · '
68
69/** Cut to n code points (never inside a surrogate pair). */
70export const cutChars = (text: string, n: number): string => {
71  const chars = Array.from(text)
72  return chars.length <= n ? text : chars.slice(0, n).join('')
73}
74
75const oneLine = (text: string, n: number): string => cutChars(text.replace(/\s+/g, ' ').trim(), n)
76
77export const firstLine = (text: string): string => text.split('\n', 1)[0] ?? ''
78
79const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
80
81const str = (v: unknown): string => (typeof v === 'string' ? v : '')
82
83const safeJson = (parse: (s: string) => unknown, raw: string): unknown => {
84  try {
85    return parse(raw)
86  } catch {
87    return undefined
88  }
89}
90
91/** "5m ago" style age; the board states ages only, never a verdict about a session. */
92export const ageText = (ms: number): string => {
93  if (!(ms >= 60_000)) return 'just now'
94  const minutes = Math.floor(ms / 60_000)
95  if (minutes < 60) return `${minutes}m ago`
96  const hours = Math.floor(minutes / 60)
97  return hours < 48 ? `${hours}h ago` : `${Math.floor(hours / 24)}d ago`
98}
99
100// ---- run outcomes and fail-soft causes (REQ-MBM-010) -----------------------------
101export type Outcome =
102  | { kind: 'ok'; stdout: string; isTruncated: boolean }
103  | { kind: 'exit'; code: number; stderr: string }
104  | { kind: 'timeout' }
105  | { kind: 'cannot-start'; message: string }
106
107export type Failure = Exclude<Outcome, { kind: 'ok' }> | { kind: 'unparseable' }
108
109const TIMEOUT_RE = /time(d)?[ -]?out|still running/i
110
111/** Never throws: a rejected run becomes timeout or cannot-start, any exit code a value. */
112export const runOutcome = async (run: Run, argv: readonly string[]): Promise<Outcome> => {
113  try {
114    const r = await run(argv)
115    return r.exitCode === 0
116      ? { kind: 'ok', stdout: r.stdout, isTruncated: r.isStdoutTruncated }
117      : { kind: 'exit', code: r.exitCode, stderr: r.stderr }
118  } catch (err) {
119    const message = err instanceof Error ? err.message : String(err)
120    return TIMEOUT_RE.test(message) ? { kind: 'timeout' } : { kind: 'cannot-start', message }
121  }
122}
123
124export type Source = { cmd: string; what: string }
125export const SRC_QUEUE: Source = { cmd: 'moai gtd list', what: 'the queue' }
126export const SRC_LANES: Source = { cmd: 'moai factory status', what: 'the lanes' }
127export const SRC_SESSIONS: Source = { cmd: 'moai session list', what: 'the sessions' }
128export const SRC_SPECS: Source = { cmd: 'moai spec status', what: 'the SPEC list' }
129
130/** One plain-language line for the affected tab. */
131export const describeFailure = (src: Source, failure: Failure): string => {
132  switch (failure.kind) {
133    case 'cannot-start':
134      return `moai was not found on PATH, so the board cannot read ${src.what}.`
135    case 'exit': {
136      const detail = oneLine(failure.stderr, 200)
137      return `\`${src.cmd}\` failed (exit ${failure.code})${detail === '' ? '.' : `: ${detail}`}`
138    }
139    case 'timeout':
140      return `\`${src.cmd}\` took longer than ${CMD_TIMEOUT_MS / 1000} s and was stopped.`
141    case 'unparseable':
142      return `\`${src.cmd}\` returned output the board could not read.`
143  }
144}
145
146// ---- feeds: last good data + latest failure --------------------------------------
147export const emptyFeed = <T>(): MoaiBoardFeed<T> => ({ data: undefined, at: 0, error: '' })
148export const feedOk = <T>(data: T, now: number): MoaiBoardFeed<T> => ({ data, at: now, error: '' })
149/** A failure keeps the last good data (dimmed by the view) and records the cause. */
150export const feedFail = <T>(feed: MoaiBoardFeed<T>, cause: string): MoaiBoardFeed<T> => ({ ...feed, error: cause })
151export const sameFeed = <T>(a: MoaiBoardFeed<T>, b: MoaiBoardFeed<T>): boolean =>
152  a.error === b.error && JSON.stringify(a.data) === JSON.stringify(b.data)
153
154/** Data is dimmed while its latest read failed; `at` is the last time the data changed. */
155export const feedStatus = <T>(feed: MoaiBoardFeed<T>, now: number): { isDim: boolean; age: string; cause: string } => ({
156  isDim: feed.data !== undefined && feed.error !== '',
157  age: feed.data === undefined ? '' : `last updated ${ageText(now - feed.at)}`,
158  cause: feed.error,
159})
160
161// ---- polling policy (REQ-MBM-006) ---------------------------------------------------
162/** Never below the 15,000 ms floor; NaN and negatives read as the floor. */
163export const clampInterval = (ms: number): number => (Number.isFinite(ms) ? Math.max(POLL_MIN_MS, ms) : POLL_MIN_MS)
164
165/** At most one poll in flight. */
166export const createFlightGate = () => {
167  let isBusy = false
168  return {
169    tryStart: (): boolean => {
170      if (isBusy) return false
171      isBusy = true
172      return true
173    },
174    finish: (): void => {
175      isBusy = false
176    },
177  }
178}
179
180// ---- queue (REQ-MBM-007, REQ-MBM-008) --------------------------------------------------
181const STATES: readonly MoaiBoardCardState[] = ['picked', 'queued', 'hold']
182
183const asCardState = (v: unknown): MoaiBoardCardState | undefined => STATES.find(s => s === v)
184
185/** Non-dropped items only; archived, runtime, findings and unknown keys never leave this function. */
186export const reduceQueue = (parsed: unknown): MoaiBoardCard[] | undefined => {
187  if (!isRecord(parsed) || !Array.isArray(parsed['items'])) return undefined
188  const cards: MoaiBoardCard[] = []
189  for (const item of parsed['items']) {
190    if (!isRecord(item)) continue
191    const state = asCardState(item['state'])
192    const id = str(item['id'])
193    if (state === undefined || id === '') continue
194    cards.push({ id, state, text: str(item['text']), addedAt: str(item['added_at']), specId: str(item['spec_id']) })
195  }
196  return cards
197}
198
199const QUEUE_ROW_RE = /^([A-Za-z0-9][A-Za-z0-9_-]*)\t(picked|queued|hold)\t(.*)$/
200const QUEUE_META_RE = /^(?:by|lease)=[^\t]*\t/
201
202/** The `moai gtd list` text form: `id<TAB>state<TAB>[by=… <TAB>lease=… <TAB>]text`; continuation lines and the footer are not rows. */
203export const parseQueueText = (text: string): MoaiBoardCard[] => {
204  const cards: MoaiBoardCard[] = []
205  for (const line of text.split('\n')) {
206    const m = QUEUE_ROW_RE.exec(line)
207    const state = asCardState(m?.[2])
208    if (m === null || state === undefined) continue
209    let rest = m[3] ?? ''
210    for (let g = QUEUE_META_RE.exec(rest); g !== null; g = QUEUE_META_RE.exec(rest)) rest = rest.slice(g[0].length)
211    cards.push({ id: m[1] ?? '', state, text: rest, addedAt: '', specId: '' })
212  }
213  return cards
214}
215
216type Reader<C> = ((raw: string) => { isChanged: boolean; cards: C }) & { forget: () => void }
217
218/** A raw payload equal to the previous one is neither re-parsed nor re-reduced. */
219const createReader = <C>(derive: (raw: string) => C): Reader<C> => {
220  let lastRaw: string | undefined
221  let last: C | undefined
222  const read = (raw: string) => {
223    if (raw === lastRaw) return { isChanged: false, cards: last as C }
224    lastRaw = raw
225    last = derive(raw)
226    return { isChanged: true, cards: last }
227  }
228  return Object.assign(read, {
229    forget: () => {
230      lastRaw = undefined
231      last = undefined
232    },
233  })
234}
235
236export type QueueReaders = {
237  json: Reader<MoaiBoardCard[] | undefined>
238  text: Reader<MoaiBoardCard[]>
239}
240
241export const createQueueReaders = (parse: (s: string) => unknown = JSON.parse): QueueReaders => ({
242  json: createReader(raw => reduceQueue(safeJson(parse, raw))),
243  text: createReader(parseQueueText),
244})
245
246export const REDUCED_NOTICE =
247  'Showing a reduced list: the full queue data could not be read in one piece, so detail columns are limited.'
248export const DEGRADED_CAUSE = 'The queue data could not be read.'
249
250export type QueuePlan =
251  | { step: 'unchanged' }
252  | { step: 'done'; cards: MoaiBoardCard[]; isReduced: boolean }
253  | { step: 'fallback'; argv: readonly string[]; notice: string }
254  | { step: 'failed'; cause: string }
255
256/** Decide what the JSON read means: use it, read the text list instead, or fail. */
257export const planQueue = (readers: QueueReaders, out: Outcome): QueuePlan => {
258  if (out.kind !== 'ok') return { step: 'failed', cause: describeFailure(SRC_QUEUE, out) }
259  if (out.isTruncated) return { step: 'fallback', argv: ARGV.queueText, notice: REDUCED_NOTICE }
260  const r = readers.json(out.stdout)
261  if (r.cards === undefined) return { step: 'fallback', argv: ARGV.queueText, notice: REDUCED_NOTICE }
262  return r.isChanged ? { step: 'done', cards: r.cards, isReduced: false } : { step: 'unchanged' }
263}
264
265/** The text-list read after a fallback; when it fails too the tab is degraded. */
266export const finishQueueFallback = (readers: QueueReaders, out: Outcome): QueuePlan => {
267  if (out.kind !== 'ok' || out.isTruncated) return { step: 'failed', cause: DEGRADED_CAUSE }
268  const r = readers.text(out.stdout)
269  return r.isChanged ? { step: 'done', cards: r.cards, isReduced: true } : { step: 'unchanged' }
270}
271
272type QueueRead = { feed: MoaiBoardFeed<MoaiBoardQueue>; isChanged: boolean }
273
274const applyQueuePlan = (prev: MoaiBoardFeed<MoaiBoardQueue>, plan: QueuePlan, now: number): QueueRead => {
275  switch (plan.step) {
276    case 'done':
277      return { feed: feedOk({ cards: plan.cards, isReduced: plan.isReduced }, now), isChanged: true }
278    case 'failed':
279      return { feed: feedFail(prev, plan.cause), isChanged: prev.error !== plan.cause }
280    case 'unchanged':
281      return prev.error === '' ? { feed: prev, isChanged: false } : { feed: { ...prev, error: '' }, isChanged: true }
282    case 'fallback':
283      return { feed: feedFail(prev, DEGRADED_CAUSE), isChanged: prev.error !== DEGRADED_CAUSE }
284  }
285}
286
287/** One queue poll: JSON first, the text list only when the JSON cannot be used. */
288export const readQueue = async (
289  run: Run,
290  readers: QueueReaders,
291  prev: MoaiBoardFeed<MoaiBoardQueue>,
292  now: number,
293): Promise<QueueRead> => {
294  let plan = planQueue(readers, await runOutcome(run, ARGV.queueJson))
295  if (plan.step === 'fallback') {
296    readers.json.forget()
297    plan = finishQueueFallback(readers, await runOutcome(run, plan.argv))
298  } else if (plan.step === 'done') {
299    readers.text.forget()
300  }
301  return applyQueuePlan(prev, plan, now)
302}
303
304/** `Picked N · Queued N · Held N`; Picked is an operator promotion, not evidence that anyone works on the card. */
305export const summaryLine = (cards: readonly MoaiBoardCard[]): string => {
306  const n = (state: MoaiBoardCardState) => cards.filter(c => c.state === state).length
307  return `Picked ${n('picked')}${SEP}Queued ${n('queued')}${SEP}Held ${n('hold')}`
308}
309
310// ---- Lanes tab (REQ-MBM-009) --------------------------------------------------------------
311export const HEARTBEAT_WINDOW_MS = 24 * 3_600_000
312export const SESSION_CAP = 20
313
314export const parseLanes = (stdout: string): MoaiBoardLaneCard[] | undefined => {
315  const parsed = safeJson(JSON.parse, stdout)
316  if (!isRecord(parsed) || !Array.isArray(parsed['cards'])) return undefined
317  const cards: MoaiBoardLaneCard[] = []
318  for (const c of parsed['cards']) {
319    if (!isRecord(c) || c['legacy'] === true || c['state'] === 'completed') continue
320    cards.push({
321      id: str(c['card_id']),
322      owner: str(c['owner']),
323      state: str(c['state']),
324      stage: str(c['stage']),
325      specId: str(c['spec_id']),
326      isLeaseExpired: c['lease_expired'] === true,
327    })
328  }
329  return cards
330}
331
332/** Sessions with a heartbeat inside the window, newest first, capped. No verdict on any session. */
333export const parseSessions = (stdout: string, now: number): MoaiBoardSession[] | undefined => {
334  const parsed = safeJson(JSON.parse, stdout)
335  if (!Array.isArray(parsed)) return undefined
336  const sessions: MoaiBoardSession[] = []
337  for (const s of parsed) {
338    if (!isRecord(s)) continue
339    const heartbeatMs = Date.parse(str(s['last_heartbeat']))
340    if (Number.isNaN(heartbeatMs) || now - heartbeatMs > HEARTBEAT_WINDOW_MS) continue
341    sessions.push({
342      sessionId: str(s['session_id']),
343      specId: str(s['spec_id']),
344      phase: str(s['phase']),
345      heartbeatMs,
346    })
347  }
348  return sessions.sort((a, b) => b.heartbeatMs - a.heartbeatMs).slice(0, SESSION_CAP)
349}
350
351type Settled<T> = { value: T | undefined; cause: string }
352
353const settle = <T>(src: Source, out: Outcome, parse: (stdout: string) => T | undefined): Settled<T> => {
354  if (out.kind !== 'ok') return { value: undefined, cause: describeFailure(src, out) }
355  const value = parse(out.stdout)
356  return value === undefined
357    ? { value: undefined, cause: describeFailure(src, { kind: 'unparseable' }) }
358    : { value, cause: '' }
359}
360
361/** One Lanes poll: factory cards and sessions. Any failure keeps the previous data and names the first cause. */
362export const readLanes = async (
363  run: Run,
364  prev: MoaiBoardFeed<MoaiBoardLanes>,
365  now: number,
366): Promise<MoaiBoardFeed<MoaiBoardLanes>> => {
367  const cards = settle(SRC_LANES, await runOutcome(run, ARGV.lanes), parseLanes)
368  const sessions = settle(SRC_SESSIONS, await runOutcome(run, ARGV.sessions), out => parseSessions(out, now))
369  const cause = cards.cause || sessions.cause
370  if (cause !== '' || cards.value === undefined || sessions.value === undefined) return feedFail(prev, cause)
371  return feedOk({ cards: cards.value, sessions: sessions.value }, now)
372}
373
374export const groupLanes = (cards: readonly MoaiBoardLaneCard[]): { owner: string; cards: MoaiBoardLaneCard[] }[] => {
375  const groups: { owner: string; cards: MoaiBoardLaneCard[] }[] = []
376  for (const card of cards) {
377    const owner = card.owner === '' ? '(no owner)' : card.owner
378    const group = groups.find(g => g.owner === owner)
379    if (group === undefined) groups.push({ owner, cards: [card] })
380    else group.cards.push(card)
381  }
382  return groups
383}
384
385export const laneCardText = (c: MoaiBoardLaneCard): string =>
386  `${c.id}  ${c.state}${c.stage !== '' && c.stage !== c.state ? ` / ${c.stage}` : ''}` +
387  `${c.specId === '' ? '' : SEP + c.specId}${c.isLeaseExpired ? `${SEP}lease expired` : ''}`
388
389const noneToEmpty = (v: string): string => (v === '(none)' ? '' : v)
390
391export const sessionText = (s: MoaiBoardSession, now: number): string => {
392  const detail = [noneToEmpty(s.phase), noneToEmpty(s.specId)].filter(v => v !== '').join(' ')
393  return `${s.sessionId.slice(0, 8)}${detail === '' ? '' : `  ${detail}`}${SEP}heartbeat ${ageText(now - s.heartbeatMs)}`
394}
395
396// ---- pick outcome (REQ-MBM-005) ---------------------------------------------------------------
397export type PickResult = { kind: 'ok' } | { kind: 'failed'; text: string }
398
399/** Runs the pick argv exactly once, never retries; a failure reads as the first 400 characters of stderr (or the start error). */
400export const executePick = async (run: Run, argv: readonly string[]): Promise<PickResult> => {
401  const out = await runOutcome(run, argv)
402  switch (out.kind) {
403    case 'ok':
404      return { kind: 'ok' }
405    case 'exit':
406      return {
407        kind: 'failed',
408        text: cutChars(out.stderr.trim() === '' ? `moai exited with code ${out.code}` : out.stderr, STDERR_SHOW),
409      }
410    case 'timeout':
411      return { kind: 'failed', text: `The pick command took longer than ${CMD_TIMEOUT_MS / 1000} s and was stopped.` }
412    case 'cannot-start':
413      return { kind: 'failed', text: cutChars(out.message, STDERR_SHOW) }
414  }
415}
416
417/** After a pick that exited 0: the card still reading `queued` means the pick is unconfirmed. */
418export const isStillQueued = (cards: readonly MoaiBoardCard[], id: string): boolean =>
419  cards.some(c => c.id === id && c.state === 'queued')
420
hooks/specs.ts 221 lines
1// moai-board SPEC layer: `$`-free. The list parser, the id and file allow-lists, the
2// real-path guard (it takes its stat and read as an injected `io`, so bun can test it)
3// and the Markdown chunker. register.tsx wires `io` to the engine's file calls.
4import type { MoaiBoardFeed, MoaiBoardSpecRow, MoaiBoardSpecs } from '../types'
5import { ARGV, SRC_SPECS, describeFailure, feedFail, feedOk, runOutcome, type Run } from './data'
6
7// ---- ids and files (REQ-MBM-011, REQ-MBM-012) ----------------------------------------------------
8/** The SPEC-id rule (spec.md §3), stated once and used by the list and by the reader. */
9const SPEC_ID_RE = /^SPEC(-[A-Z][A-Z0-9]*)+-[0-9]{3}$/
10
11export const SPEC_FILES = ['spec.md', 'plan.md', 'acceptance.md', 'design.md', 'research.md', 'progress.md'] as const
12
13export const isSpecId = (id: string): boolean => SPEC_ID_RE.test(id)
14
15export const isSpecFile = (file: string): boolean => (SPEC_FILES as readonly string[]).includes(file)
16
17const STATUS_RE = /^[a-z][a-z-]*$/
18
19/** `moai spec status --list`: only rows whose first column is a SPEC id and whose second is a status word. */
20export const parseSpecList = (text: string): MoaiBoardSpecRow[] => {
21  const rows: MoaiBoardSpecRow[] = []
22  for (const line of text.split('\n')) {
23    const [id = '', status = ''] = line.trim().split(/\s+/)
24    if (isSpecId(id) && STATUS_RE.test(status)) rows.push({ id, status })
25  }
26  return rows
27}
28
29/** One SPEC list poll (on tab open and on refresh only). */
30export const readSpecList = async (
31  run: Run,
32  prev: MoaiBoardFeed<MoaiBoardSpecs>,
33  now: number,
34): Promise<MoaiBoardFeed<MoaiBoardSpecs>> => {
35  const out = await runOutcome(run, ARGV.specList)
36  return out.kind === 'ok' ? feedOk({ rows: parseSpecList(out.stdout) }, now) : feedFail(prev, describeFailure(SRC_SPECS, out))
37}
38
39// ---- filter and pages ---------------------------------------------------------------------------------
40export const PAGE_SIZE = 15
41const ACTIVE_STATUSES = ['draft', 'in-progress']
42const STATUS_ORDER = ['planned', 'implemented', 'completed', 'superseded', 'archived', 'rejected', 'unknown']
43
44/** 'active' is draft plus in-progress (the default); any other value is one status word. */
45export const filterSpecs = (rows: readonly MoaiBoardSpecRow[], status: string): MoaiBoardSpecRow[] =>
46  rows.filter(r => (status === 'active' ? ACTIVE_STATUSES.includes(r.status) : r.status === status))
47
48/** The filter chips: `active` first, then every other status present, in lifecycle order. */
49export const statusChips = (rows: readonly MoaiBoardSpecRow[]): string[] => {
50  const present = [...new Set(rows.map(r => r.status))].filter(s => !ACTIVE_STATUSES.includes(s))
51  const rank = (s: string) => (STATUS_ORDER.includes(s) ? STATUS_ORDER.indexOf(s) : STATUS_ORDER.length)
52  return ['active', ...present.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b))]
53}
54
55export const pageOf = <T>(rows: readonly T[], page: number): { rows: T[]; page: number; pages: number } => {
56  const pages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE))
57  const at = Math.min(Math.max(0, Math.trunc(page) || 0), pages - 1)
58  return { rows: rows.slice(at * PAGE_SIZE, (at + 1) * PAGE_SIZE), page: at, pages }
59}
60
61// ---- reading one SPEC file (REQ-MBM-012) -----------------------------------------------------------------
62export type SpecIo = {
63  stat: (path: string) => Promise<{ kind: 'file' | 'dir' | 'other'; realPath?: string | undefined }>
64  read: (path: string) => Promise<string>
65}
66
67export type SpecRead = { ok: true; text: string; realPath: string } | { ok: false; reason: string }
68
69const refuse = (reason: string): SpecRead => ({ ok: false, reason })
70
71// @MX:NOTE: [AUTO] the read guard (REQ-MBM-012): allow-lists first, then both stats, then a read of the
72// resolved real path only. A deny-list on spellings would be best effort; this is the allow-list form.
73/**
74 * Reads `<root>/.moai/specs/<id>/<file>` only when the id and the file name pass their allow-lists and the
75 * file's resolved real path lies below the resolved real path of `<root>/.moai/specs`. Both paths are
76 * resolved before anything is read, and what is read is the real path, never the spelling asked for.
77 */
78export const readSpecFile = async (io: SpecIo, root: string, id: string, file: string): Promise<SpecRead> => {
79  if (!isSpecId(id)) return refuse(`"${id}" is not a SPEC id, so nothing was read.`)
80  if (!isSpecFile(file)) return refuse(`"${file}" is not one of the SPEC files, so nothing was read.`)
81  const base = `${root.replace(/[\\/]+$/, '')}/.moai/specs`
82  let baseReal: string | undefined
83  let fileStat: Awaited<ReturnType<SpecIo['stat']>>
84  try {
85    baseReal = (await io.stat(base)).realPath
86    fileStat = await io.stat(`${base}/${id}/${file}`)
87  } catch {
88    return refuse(`${id}/${file} was not found under ${base}.`)
89  }
90  const fileReal = fileStat.realPath
91  if (baseReal === undefined || fileReal === undefined || fileStat.kind !== 'file') return refuse(`${id}/${file} could not be resolved to a file.`)
92  const sep = baseReal.includes('\\') && !baseReal.includes('/') ? '\\' : '/'
93  const below = `${baseReal.replace(/[\\/]+$/, '')}${sep}`
94  if (!fileReal.startsWith(below)) return refuse(`${id}/${file} resolves outside the SPEC folder, so it was not read.`)
95  try {
96    return { ok: true, text: await io.read(fileReal), realPath: fileReal }
97  } catch (err) {
98    return refuse(`${id}/${file} could not be read: ${err instanceof Error ? err.message : String(err)}`)
99  }
100}
101
102// ---- Markdown chunker (REQ-MBM-012) -------------------------------------------------------------------------
103export const MAX_CHUNK = 9000
104export const MAX_CHUNKS = 12
105
106const FENCE_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/
107
108/** Cuts a line to parts of at most `limit` UTF-16 units, never between the halves of a surrogate pair. */
109const cutLine = (line: string, limit: number): string[] => {
110  const parts: string[] = []
111  let rest = line
112  while (rest.length > limit) {
113    const last = rest.charCodeAt(limit - 1)
114    const at = last >= 0xd800 && last <= 0xdbff ? limit - 1 : limit
115    parts.push(rest.slice(0, at))
116    rest = rest.slice(at)
117  }
118  parts.push(rest)
119  return parts
120}
121
122/** Blocks split at blank lines outside fenced code. */
123const splitBlocks = (text: string): string[] => {
124  const blocks: string[] = []
125  let cur: string[] = []
126  let marker = ''
127  const flush = () => {
128    if (cur.length > 0) blocks.push(cur.join('\n'))
129    cur = []
130  }
131  for (const line of text.replace(/\r\n/g, '\n').split('\n')) {
132    const fence = FENCE_RE.exec(line)
133    if (marker === '') {
134      if (line.trim() === '') {
135        flush()
136        continue
137      }
138      if (fence !== null) marker = fence[1] ?? ''
139    } else if (fence !== null && (fence[1] ?? '')[0] === marker[0] && (fence[1] ?? '').length >= marker.length && (fence[2] ?? '').trim() === '') {
140      marker = ''
141    }
142    cur.push(line)
143  }
144  flush()
145  return blocks
146}
147
148const LINE_LIMIT = MAX_CHUNK - 200
149
150/** One block larger than a chunk, split at line boundaries; a fence it is inside is closed and reopened. */
151const splitBlock = (block: string, max: number): string[] => {
152  const pieces: string[] = []
153  let lines: string[] = []
154  let length = 0
155  let opener = '' // the opening fence line to repeat, '' outside a fence
156  let marker = ''
157  const reopened = () => (opener === '' ? [] : [opener])
158  const lengthOf = (ls: readonly string[]) => ls.reduce((n, l) => n + l.length, 0) + Math.max(0, ls.length - 1)
159  lines = reopened()
160  length = lengthOf(lines)
161  for (const raw of block.split('\n')) {
162    const parts = cutLine(raw, LINE_LIMIT)
163    const fence = parts.length === 1 ? FENCE_RE.exec(raw) : null
164    for (const part of parts) {
165      const room = opener === '' ? max : max - 1 - marker.length
166      if (lines.length > reopened().length && (lines.length === 0 ? 0 : length + 1) + part.length > room) {
167        pieces.push((opener === '' ? lines : [...lines, marker]).join('\n'))
168        lines = reopened()
169        length = lengthOf(lines)
170      }
171      length += (lines.length === 0 ? 0 : 1) + part.length
172      lines.push(part)
173    }
174    if (fence !== null) {
175      const m = fence[1] ?? ''
176      if (opener === '') {
177        marker = m
178        opener = `${m}${(fence[2] ?? '').slice(0, 80)}`
179      } else if (m[0] === marker[0] && m.length >= marker.length && (fence[2] ?? '').trim() === '') {
180        opener = ''
181        marker = ''
182      }
183    }
184  }
185  pieces.push(lines.join('\n'))
186  return pieces
187}
188
189/** Every chunk of the text: at most `max` characters, split at blank lines, fenced code kept whole where it fits. */
190export const chunkAll = (text: string, max = MAX_CHUNK): string[] => {
191  const chunks: string[] = []
192  let cur = ''
193  for (const block of splitBlocks(text)) {
194    if (block.length > max) {
195      if (cur !== '') chunks.push(cur)
196      cur = ''
197      chunks.push(...splitBlock(block, max))
198    } else if (cur === '') {
199      cur = block
200    } else if (cur.length + 2 + block.length <= max) {
201      cur += `\n\n${block}`
202    } else {
203      chunks.push(cur)
204      cur = block
205    }
206  }
207  if (cur !== '') chunks.push(cur)
208  return chunks
209}
210
211/** At most 12 chunks of at most 9,000 characters, then a notice that names the file. */
212export const chunkMarkdown = (text: string, file: string): { chunks: string[]; notice: string } => {
213  const all = chunkAll(text)
214  return all.length <= MAX_CHUNKS
215    ? { chunks: all, notice: '' }
216    : {
217        chunks: all.slice(0, MAX_CHUNKS),
218        notice: `Showing the first ${MAX_CHUNKS} of ${all.length} parts of ${file}; the rest is not shown.`,
219      }
220}
221
hooks/view.tsx 236 lines
1// moai-board view layer: `$`-free. register.tsx resolves the element table of the
2// surface being drawn and hands it in with the model and the press handlers, so this
3// module draws with Box, Text, Button and Markdown only (present on every surface).
4import type {
5  MoaiBoardCard,
6  MoaiBoardDoc,
7  MoaiBoardFeed,
8  MoaiBoardLanes,
9  MoaiBoardQueue,
10  MoaiBoardSpecs,
11  MoaiBoardTab,
12  MoaiBoardView,
13} from '../types'
14import {
15  canPick,
16  cutChars,
17  feedStatus,
18  firstLine,
19  groupLanes,
20  laneCardText,
21  REDUCED_NOTICE,
22  sessionText,
23  summaryLine,
24} from './data'
25import { SPEC_FILES, chunkMarkdown, filterSpecs, pageOf, statusChips } from './specs'
26
27/** The four constructors every surface's element table has (the typings' `Elements`). */
28// The table is a per-surface union; only these four constructors are drawn, typed loosely on purpose.
29export type Table = { Box: any; Text: any; Button: any; Markdown: any }
30
31export type Model = {
32  /** The pane's body width in character cells. */
33  cols: number
34  now: number
35  view: MoaiBoardView
36  queue: MoaiBoardFeed<MoaiBoardQueue>
37  lanes: MoaiBoardFeed<MoaiBoardLanes>
38  specs: MoaiBoardFeed<MoaiBoardSpecs>
39  doc: MoaiBoardDoc | undefined
40  notice: string
41}
42
43export type Actions = {
44  tab: (tab: MoaiBoardTab) => void
45  refresh: () => void
46  back: () => void
47  close: () => void
48  openCard: (id: string) => void
49  pick: (card: MoaiBoardCard) => void
50  openSpec: (id: string) => void
51  openFile: (file: string) => void
52  setStatus: (status: string) => void
53  setPage: (page: number) => void
54}
55
56const STATE_GROUPS = [
57  { state: 'picked', label: 'Picked' },
58  { state: 'queued', label: 'Queued' },
59  { state: 'hold', label: 'Held' },
60] as const
61
62const rowLabel = (card: MoaiBoardCard, cols: number): string =>
63  `${card.id}  ${cutChars(firstLine(card.text).replace(/\s+/g, ' '), Math.max(10, cols - card.id.length - 6))}`
64
65export const drawBoard = (T: Table, m: Model, a: Actions) => {
66  const { Box, Text, Button, Markdown } = T
67  const { view } = m
68
69  // Text takes no key, so every line a test or a reader must find sits in a keyed Box.
70  const line = (key: string, text: string, props: Record<string, unknown> = {}) => (
71    <Box key={key}>
72      <Text {...props}>{text}</Text>
73    </Box>
74  )
75
76  const tabButton = (tab: MoaiBoardTab, label: string, hotkey: string) => (
77    <Button
78      key={`tab-${tab}`}
79      label={label}
80      hotkey={hotkey}
81      {...(view.tab === tab ? { variant: 'primary' } : {})}
82      onPress={() => a.tab(tab)}
83    />
84  )
85
86  // A failed read keeps the last good rows (dimmed) and names the cause on one line.
87  const status = feedStatus(view.tab === 'lanes' ? m.lanes : view.tab === 'spec' ? m.specs : m.queue, m.now)
88  const statusText = status.cause === '' ? '' : `${status.cause}${status.age === '' ? '' : ` (${status.age})`}`
89
90  const queueBody = () => {
91    const feed = m.queue
92    const data = feed.data
93    if (data === undefined)
94      return line('queue-wait', feed.error === '' ? 'Reading the queue...' : 'No queue data to show.', { dimColor: true })
95    const dim = feedStatus(feed, m.now).isDim
96    if (view.card !== '') {
97      const card = data.cards.find(c => c.id === view.card)
98      if (card === undefined) return line('card-gone', `Card ${view.card} is no longer in the queue.`, { dimColor: true })
99      const meta = [card.state, card.addedAt === '' ? '' : `added ${card.addedAt.slice(0, 10)}`, card.specId].filter(v => v !== '')
100      return (
101        <Box key="card-detail" flexDirection="column">
102          {line('card-meta', `${card.id}  ${meta.join(' \u00b7 ')}`, { bold: true })}
103          {chunkMarkdown(card.text, card.id).chunks.map((chunk, i) => (
104            <Markdown key={`card-text-${i}`} dimColor={dim} text={chunk} />
105          ))}
106          <Box flexDirection="row" gap={1}>
107            <Button key="back" label="back" hotkey="b" plain onPress={() => a.back()} />
108            {canPick(card) && <Button key={`pick:${card.id}`} label="pick" hotkey="p" variant="primary" onPress={() => a.pick(card)} />}
109          </Box>
110        </Box>
111      )
112    }
113    if (data.cards.length === 0) return line('queue-empty', 'The queue is empty.', { dimColor: true })
114    return (
115      <Box key="queue-list" flexDirection="column">
116        {data.isReduced && line('reduced', REDUCED_NOTICE, { dimColor: true, wrap: 'wrap' })}
117        {STATE_GROUPS.map(g => ({ ...g, cards: data.cards.filter(c => c.state === g.state) }))
118          .filter(g => g.cards.length > 0)
119          .map(g => (
120            <Box key={`group-${g.state}`} flexDirection="column">
121              {line(`head-${g.state}`, `${g.label} (${g.cards.length})`, { bold: true })}
122              {g.cards.map(c => (
123                <Button key={`open:${c.id}`} plain dimColor={dim} label={rowLabel(c, m.cols)} onPress={() => a.openCard(c.id)} />
124              ))}
125            </Box>
126          ))}
127      </Box>
128    )
129  }
130
131  const lanesBody = () => {
132    const data = m.lanes.data
133    if (data === undefined)
134      return line('lanes-wait', m.lanes.error === '' ? 'Reading the lanes...' : 'No lane data to show.', { dimColor: true })
135    const dim = feedStatus(m.lanes, m.now).isDim
136    const groups = groupLanes(data.cards)
137    return (
138      <Box key="lanes-list" flexDirection="column">
139        {groups.length === 0 && line('lanes-none', 'No factory cards are running.', { dimColor: true })}
140        {groups.map(g => (
141          <Box key={`owner-${g.owner}`} flexDirection="column">
142            {line(`owner:${g.owner}`, g.owner, { bold: true })}
143            {g.cards.map(c => line(`lane:${c.id}`, `  ${laneCardText(c)}`, { dimColor: dim }))}
144          </Box>
145        ))}
146        {line('sessions-head', 'Sessions with a heartbeat in the last 24 h', { bold: true })}
147        {data.sessions.length === 0 && line('sessions-none', 'None.', { dimColor: true })}
148        {data.sessions.map(s => line(`session:${s.sessionId}`, `  ${sessionText(s, m.now)}`, { dimColor: dim }))}
149      </Box>
150    )
151  }
152
153  const specsBody = () => {
154    const data = m.specs.data
155    if (view.spec !== '') {
156      const doc = m.doc !== undefined && m.doc.spec === view.spec && m.doc.file === view.file ? m.doc : undefined
157      return (
158        <Box key="spec-detail" flexDirection="column">
159          {line('spec-head', view.spec, { bold: true })}
160          <Box flexDirection="row" gap={1}>
161            {SPEC_FILES.map(f => (
162              <Button key={`file:${f}`} plain label={f} {...(view.file === f ? { variant: 'primary' } : {})} onPress={() => a.openFile(f)} />
163            ))}
164          </Box>
165          <Box flexDirection="row" gap={1}>
166            <Button key="back" label="back" hotkey="b" plain onPress={() => a.back()} />
167          </Box>
168          {view.file === '' && line('spec-pick', 'Choose a file to read.', { dimColor: true })}
169          {doc !== undefined && doc.notice !== '' && line('doc-notice', doc.notice, { wrap: 'wrap' })}
170          {doc?.chunks.map((chunk, i) => <Markdown key={`doc-${i}`} text={chunk} />)}
171        </Box>
172      )
173    }
174    if (data === undefined)
175      return line('specs-wait', m.specs.error === '' ? 'Reading the SPEC list...' : 'No SPEC data to show.', { dimColor: true })
176    const dim = feedStatus(m.specs, m.now).isDim
177    const filtered = filterSpecs(data.rows, view.status)
178    const pg = pageOf(filtered, view.page)
179    return (
180      <Box key="specs-list" flexDirection="column">
181        <Box flexDirection="row" gap={1}>
182          {statusChips(data.rows).map(s => (
183            <Button
184              key={`filter:${s}`}
185              plain
186              label={s === 'active' ? 'active (draft, in-progress)' : s}
187              {...(view.status === s ? { variant: 'primary' } : {})}
188              onPress={() => a.setStatus(s)}
189            />
190          ))}
191        </Box>
192        {filtered.length === 0 && line('specs-none', 'No SPECs with this status.', { dimColor: true })}
193        {pg.rows.map(r => (
194          <Button key={`spec:${r.id}`} plain dimColor={dim} label={`${r.id}  ${r.status}`} onPress={() => a.openSpec(r.id)} />
195        ))}
196        <Box flexDirection="row" gap={1}>
197          {pg.page > 0 && <Button key="page-prev" label="previous page" plain onPress={() => a.setPage(pg.page - 1)} />}
198          {line('page', `page ${pg.page + 1} of ${pg.pages} \u00b7 ${filtered.length} SPECs`, { dimColor: true })}
199          {pg.page < pg.pages - 1 && <Button key="page-next" label="next page" plain onPress={() => a.setPage(pg.page + 1)} />}
200        </Box>
201      </Box>
202    )
203  }
204
205  const body = () => {
206    switch (view.tab) {
207      case 'queue':
208        return queueBody()
209      case 'lanes':
210        return lanesBody()
211      case 'spec':
212        return specsBody()
213    }
214  }
215
216  return (
217    <Box flexDirection="column">
218      <Box flexDirection="row" gap={1}>
219        <Text bold>moai-board</Text>
220        {view.root !== '' && <Text dimColor>{cutChars(view.root, Math.max(10, m.cols - 24))}</Text>}
221        <Button key="close" label="close" hotkey="x" plain onPress={() => a.close()} />
222      </Box>
223      <Box flexDirection="row" gap={1}>
224        {tabButton('queue', 'Queue', '1')}
225        {tabButton('lanes', 'Lanes', '2')}
226        {tabButton('spec', 'SPEC', '3')}
227        <Button key="refresh" label="refresh" hotkey="r" plain onPress={() => a.refresh()} />
228      </Box>
229      {line('summary', m.queue.data === undefined ? 'Queue not read yet' : summaryLine(m.queue.data.cards))}
230      {statusText !== '' && line('status', statusText, { bold: true, wrap: 'wrap' })}
231      {m.notice !== '' && line('notice', m.notice, { wrap: 'wrap' })}
232      {body()}
233    </Box>
234  )
235}
236
types/index.d.ts 92 lines
1// State contract of the moai-board plugin (Claude Code function hooks, 2.1.287).
2// Self-contained on purpose: the engine requires a contract with no import, its
3// exported names led by the plugin's PascalCase name, and `PluginState` declared
4// for the plugin's own name. `plugin.json` names this file under "types".
5
6export type MoaiBoardTab = 'queue' | 'lanes' | 'spec'
7
8export type MoaiBoardCardState = 'picked' | 'queued' | 'hold'
9
10/** One non-dropped queue item. `addedAt` and `specId` are '' when the source did not carry them. */
11export type MoaiBoardCard = {
12  id: string
13  state: MoaiBoardCardState
14  text: string
15  addedAt: string
16  specId: string
17}
18
19/** The last good data of one source, when it was read, and the cause of the latest failure ('' when it succeeded). */
20export type MoaiBoardFeed<T> = {
21  data: T | undefined
22  at: number
23  error: string
24}
25
26export type MoaiBoardQueue = {
27  cards: MoaiBoardCard[]
28  /** True when the rows came from the text list: detail columns (spec id, added date) are missing. */
29  isReduced: boolean
30}
31
32export type MoaiBoardLaneCard = {
33  id: string
34  owner: string
35  state: string
36  stage: string
37  specId: string
38  isLeaseExpired: boolean
39}
40
41export type MoaiBoardSession = {
42  sessionId: string
43  specId: string
44  phase: string
45  heartbeatMs: number
46}
47
48export type MoaiBoardLanes = {
49  cards: MoaiBoardLaneCard[]
50  sessions: MoaiBoardSession[]
51}
52
53export type MoaiBoardSpecRow = { id: string; status: string }
54
55export type MoaiBoardSpecs = { rows: MoaiBoardSpecRow[] }
56
57/** The SPEC file shown in the SPEC tab: at most 12 chunks of at most 9,000 characters, plus a notice. */
58export type MoaiBoardDoc = {
59  spec: string
60  file: string
61  chunks: string[]
62  notice: string
63}
64
65export type MoaiBoardView = {
66  tab: MoaiBoardTab
67  /** Card id whose detail is open, '' for the list. */
68  card: string
69  /** SPEC id whose files are open, '' for the list. */
70  spec: string
71  /** The SPEC file shown, '' while none is chosen. */
72  file: string
73  /** 'active' (draft and in-progress) or one status word. */
74  status: string
75  page: number
76  /** The session's project root, shown in the header. */
77  root: string
78}
79
80declare module 'claude-code' {
81  interface PluginState {
82    'moai-board': {
83      view: MoaiBoardView
84      queue: MoaiBoardFeed<MoaiBoardQueue>
85      lanes: MoaiBoardFeed<MoaiBoardLanes>
86      specs: MoaiBoardFeed<MoaiBoardSpecs>
87      doc: MoaiBoardDoc | undefined
88      notice: string
89    }
90  }
91}
92