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

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.
/moai-board opens the pane moai-board with three tabs (hotkeys 1, 2, 3) and a refresh button (r):
| Tab | Shows | Reads |
|---|---|---|
| Queue | Picked / Queued / Held counts, the cards grouped by state, a card's full text | moai gtd list --json (text list --limit 0 as the fallback) |
| Lanes | factory 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 |
| SPEC | SPECs filtered by status (default draft and in-progress), 15 per page; one file of a SPEC at a time as Markdown | moai 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.
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.
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.
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).
.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.
hooks/register.tsx 303 lines1// 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}
303hooks/data.ts 420 lines1// 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')
420hooks/specs.ts 221 lines1// 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}
221hooks/view.tsx 236 lines1// 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}
236types/index.d.ts 92 lines1// 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