Prototype of the DevForgeAI Dashboard (docs/specs/devforgeai-dashboard.md): the dashboard in a pane, with probes for its open platform questions. Not part of…

DevForgeAI is a Claude Code plugin, devforgeai, of spec-driven planning skills. It takes an idea from a brainstorm through a product requirements document (PRD) and its architecture to epics. Each step is written as a Markdown document with stable item IDs and traceable links, and every judgment call it records (which ideas to pursue, priorities, what ships now) is left to you. A context skill writes the project's stack, layout and convention documents from the architecture's decisions. Three skills work outside that chain: a documents updater for a repository's README, CHANGELOG and guides, a git workflow skill that takes work through commits and pull requests to a merge, and a spec lookup that cites what a project's specs and ADRs already decided before Claude proposes anything new. A progress tracker shows each brainstorm and architecture run's checklist steps as they happen.
It is for people who plan software with Claude Code. It is unreleased: load the plugin from this repository's source.
git skill: git, and for its pr and merge phases the GitHub CLI (gh), signed in. GitHub is the only supported host; the other phases work with any git remote.<devforgeai> with the path to this repository: claude --plugin-dir <devforgeai>/src/claude/DevForgeAI
/devforgeai:brainstorm ways to cut appointment no-shows, or ask in plain words to brainstorm. Confirm which ideas to promote and whether the brainstorm is done. The skill writes docs/specs/brainstorm/BRN-001.md in your project. /devforgeai:prd BRN-001
The skill asks only about what the brainstorm leaves open, writes docs/specs/prd/PRD-001.md (the next free number), and names the next step.
/devforgeai:architecture PRD-001
The skill settles each shared architectural question only by your decision, an accepted ADR or approved policy, writes docs/specs/arch/ARCH-001.md plus an ADR for each decision you make, and reports which requirements are ready for epics.
docs/specs/context/ from the architecture's decisions and the conventions you confirm: /devforgeai:context
The epic skill doesn't read them; they are written for the story and spec steps (ADR-004).
/devforgeai:epic PRD-001
The skill proposes a grouping of the ready, current-release requirements, writes docs/specs/epic/EPIC-NNN.md for each epic once you confirm it, and lists every requirement it left out with the reason.
| Skill | Invoke | What it does | Status | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
brainstorm | /devforgeai:brainstorm [topic] | Runs a structured brainstorm and writes a BRN document with problems, ideas, assumptions and the dispositions you confirmed | Implemented (SPEC-001) | ||||||||
prd | /devforgeai:prd [BRN-NNN] | Drafts a PRD from a converged brainstorm's promoted ideas, interviews you only for the gaps, and applies any approved policy | Implemented (SPEC-002) | ||||||||
documents-updater | /devforgeai:documents-updater [base-revision-or-range] [propose] | Updates a repository's README, CHANGELOG and guides from its git changes, or proposes the edits | Implemented (SPEC-006) | ||||||||
architecture | /devforgeai:architecture [PRD-NNN] | Identifies the architectural questions separate epics must share, settles each only by your decision, an accepted ADR or approved policy, and writes an ARCH document with ADRs and a report of which requirements are ready for epics | Implemented (SPEC-003) | ||||||||
epic | /devforgeai:epic [PRD-NNN] | Groups a PRD's ready, current-release requirements into epics you confirm, and reports every requirement left out and why | Implemented (SPEC-004) | ||||||||
context | /devforgeai:context [document] | Writes and maintains the context documents in docs/specs/context/ from accepted ADRs, approved policy, the ARCH and conventions you confirm; cites every decision and labels observed practice | Implemented (SPEC-011) | ||||||||
spec-lookup | /devforgeai:spec-lookup [ID or term] | Searches the project's docs/specs/ and cites each match by file, line, version and status; anything with no match is a question for you, never something to build. Other skills run it through the devforgeai:spec-lookup agent | Implemented (SPEC-014) | ||||||||
git | `/devforgeai:git [status\ | connect\ | start\ | commit\ | push\ | pr\ | merge\ | sync\ | prune] [details]` | Commits and pushes work from its own branch and worktree, opens or updates a GitHub pull request, merges only a PR that an independent QA session approved for its head commit and you confirm, fast-forwards the default branch without discarding local edits, and prunes merged worktrees | Implemented; the source is v3, which is in review and not yet evaluated (v2 is the last approved version) (SPEC-007, qualification) |
The planning chain is Brainstorm → PRD → Architecture Definition → Epic → Story → Spec; the first four steps are implemented. The story skill is specified as a draft (SPEC-009); until it is built, the epic skill's handoff says to write stories by hand from the story template.
The plugin includes a progress tracker for the brainstorm and architecture skills (SPEC-012, SPEC-013). It is a Claude Code mod, so it runs only where Claude Code loads plugin hook modules.
devforgeai/progress/ in the project. Files older than 30 days are removed when a later session starts its first tracked run (the plugin's retentionDays setting)..claude/devforgeai.local.md.tracking setting to off to turn the tracker off; the skills work the same either way.Skills write to docs/specs/<type>/<ID>.md in your project, such as docs/specs/prd/PRD-002.md, and allocate each ID themselves. Values you haven't decided stay null or carry a [NEEDS CLARIFICATION] marker instead of a guess. The document format, IDs, links and provenance rules are in the templates README, and the JSON Schemas are in src/schemas/.
The prd, architecture and context skills also read optional policy: approved organization or project policy documents in docs/specs/policy/ (template: src/templates/policy.md), and a user-local .claude/devforgeai.local.md for interaction defaults such as interview.max_calls. ADR-003 defines the rules. The epic skill reads policy only to check that each setting the architecture relied on is still approved, active and at the version it linked.
Each implemented skill has an eval suite in src/claude/DevForgeAI/evals/<skill>/. Run it from a plain terminal, not inside a Claude Code session, at the repository root:
A="--allow-tools Write Edit Bash --scaffold --judge-model sonnet --threshold 0.8"
claude plugin eval src/claude/DevForgeAI --tag prd $A --output-dir tmp/eval-results/prd
The run prints a score per case; the bar is 0.8 per case over three runs. CLAUDE.md covers the options, the manual checks and how the plugin is deployed.
hooks/register.tsx 269 lines1// dashboard-probe: a prototype of the DevForgeAI Dashboard (docs/specs/devforgeai-dashboard.md), drawn live in a
2// pane, with probes for the design's open platform questions. Not part of the devforgeai plugin.
3//
4// Open it with /dashboard-probe, or /dashboard-probe:dashboard (the command-file route, commands/dashboard.md).
5// The layout follows the pane: 160x48 or 120x36 above the prompt, 64x48 docked. The seven tiles are keyed Boxes with
6// Buttons laid over the Raster (clickable where the terminal reports clicks; keys 1-7 everywhere). t switches the
7// look and writes it to /config, m the mode (drawing only), a pauses, q closes.
8//
9// Probes: /dashboard-probe menu N opens tile N's menu (run it while Claude works: the mid-turn case);
10// /dashboard-probe arm makes the next turn end call $.command.run from session.measure (SPEC-013 v21 BEH-36's path).
11// RPM (turn.step timing) and fuel (session.measure) are live; every other value is sample data.
12import { atom, read, update } from 'claude-code'
13import type { EngineInterface, Register } from 'claude-code'
14
15import { frame, setBoxTiles, SIZES, spriteCells, THEMES, tileModel, tileRects } from './dashboard'
16import type { Layout } from './dashboard'
17
18const PLUGIN = 'dashboard-probe'
19const PANE = 'dashboard-probe'
20const LOOKS = ['A', 'B', 'C'] as const
21const NAMES = { A: 'Default', B: 'Night drive', C: 'Race telemetry' } as const
22const BY_NAME: Record<string, 'A' | 'B' | 'C'> = { Default: 'A', 'Night drive': 'B', 'Race telemetry': 'C' }
23
24const theme = atom({ plugin: 'dashboard-probe', key: 'theme' } as const, 'A' as 'A' | 'B' | 'C')
25const character = atom({ plugin: 'dashboard-probe', key: 'character' } as const, 'Ember')
26const isAnimating = atom({ plugin: 'dashboard-probe', key: 'isAnimating' } as const, true)
27// The tracker's mode as this prototype shows it. The real button runs SPEC-013 BEH-13 (IF-02); this one only redraws.
28const trackMode = atom({ plugin: 'dashboard-probe', key: 'trackMode' } as const, 'enforce' as 'enforce' | 'observe')
29const note = atom({ plugin: 'dashboard-probe', key: 'note' } as const, 'keys 1-7 open a phase · t look · m mode · q close')
30const rpmNote = atom({ plugin: 'dashboard-probe', key: 'rpmNote' } as const, 'RPM: no model step measured yet')
31
32// The module's own values; a reload starts them over.
33const live = { isOpen: false, layout: 'dock' as Layout, isArmed: false, presses: 0 }
34
35setBoxTiles(true)
36
37// RPM: tokens per second, timed from turn.step (see the design, section 5).
38const rpm = { value: 0, at: 0, isStreaming: false, chars: 0, start: 0, last: '' }
39function rpmNow(now: number): number {
40 if (rpm.isStreaming && rpm.start > 0 && now - rpm.start > 300) return Math.min(200, rpm.chars / 4 / ((now - rpm.start) / 1000))
41 return Math.max(0, rpm.value * (1 - (now - rpm.at) / 5000))
42}
43
44const SKILLS = ['brainstorm', 'prd', 'architecture', 'context', 'epic', 'story', 'spec']
45const LABELS = ['Brainstorm', 'PRD', 'Architecture', 'Context', 'Epic', 'Story', 'Spec']
46
47// The tile menu: a question, then $.command.run with the person's pick.
48async function tileMenu($: EngineInterface, i: number) {
49 if (i >= 5) {
50 $.ui.toast(`${LABELS[i]}: the skill isn't built yet`)
51 return
52 }
53 const options = [`Start ${LABELS[i]}`, 'Cancel']
54 const pick = await $.ui.ask(`${LABELS[i]}: what would you like to do?`, { options, header: 'Dashboard' }).catch(() => 'Cancel')
55 if (pick !== options[0]) {
56 await update($, note, () => `menu ${i + 1}: cancelled`)
57 return
58 }
59 const line = await $.command
60 .run({ command: `devforgeai:${SKILLS[i]!}`, args: '' })
61 .then(() => `menu ${i + 1}: $.command.run /devforgeai:${SKILLS[i]} accepted`)
62 .catch((err: unknown) => `menu ${i + 1}: $.command.run refused: ${String(err)}`)
63 await update($, note, () => line)
64}
65
66function layoutFor(placement: string, columns: number): Layout {
67 if (placement === 'dock') return 'dock'
68 if (columns >= 160) return 'wide'
69 if (columns >= 120) return 'compact'
70 return 'dock'
71}
72
73async function fuelOf($: EngineInterface): Promise<number> {
74 const usage = await $.session.usage().catch(() => undefined)
75 const pct = usage?.context.percent
76 return typeof pct === 'number' ? 100 - pct : 100
77}
78
79// The probe for "t writes /config": find this plugin's dashboardTheme row, set it, read it back.
80async function nextLook($: EngineInterface) {
81 const look = await update($, theme, t => LOOKS[(LOOKS.indexOf(t) + 1) % LOOKS.length]!)
82 const rows = await $.config.list().catch(() => [])
83 const row = rows.find(r => r.key.includes('dashboardTheme'))
84 if (!row) {
85 await $.store.set('dashboard-probe:theme', look).catch(() => {})
86 await update($, note, () => `config: no dashboardTheme row in /config (${rows.length} rows); kept in $.store`)
87 return
88 }
89 const r = await $.config.set({ key: row.key, value: NAMES[look] }).catch((err: unknown) => ({ deny: String(err) }))
90 const back = (await $.config.list().catch(() => [])).find(x => x.key === row.key)
91 const text = 'deny' in r && r.deny
92 ? `config: set ${row.key} refused: ${r.deny}; kept in $.store`
93 : `config: set ${row.key} = ${NAMES[look]} ✓ (reads back ${String(back?.value)})`
94 if ('deny' in r && r.deny) await $.store.set('dashboard-probe:theme', look).catch(() => {})
95 await update($, note, () => text)
96}
97
98async function tick($: EngineInterface) {
99 if (rpm.last && (await read($, rpmNote)) !== rpm.last) await update($, rpmNote, () => rpm.last)
100 if (!live.isOpen || !(await read($, isAnimating))) return
101 const look = await read($, theme)
102 const [columns, rows] = SIZES[live.layout]
103 const ms = await $.clock.now()
104 const r = await $.ui.blit({ requestId: PANE, key: 'dash', cells: frame(look, live.layout, ms, await fuelOf($), 'pace', await read($, trackMode), rpmNow(ms)), columns, rows })
105 if ('deny' in r && /size|columns|rows/.test(String(r.deny))) $.ui.invalidate('ui.render')
106 const who = await read($, character)
107 if (who !== 'none') await $.ui.blit({ requestId: PANE, key: 'char', cells: spriteCells(who, THEMES[look].panel, ms), columns: 14, rows: 4 })
108}
109
110async function openPane($: EngineInterface): Promise<string> {
111 live.isOpen = true
112 const opened = await $.ui.open({ id: PANE, title: 'DevForgeAI Dashboard', focus: true, rows: 54, columns: 66 })
113 return opened.isPlaced ? 'DevForgeAI Dashboard opened: keys 1-7 phases, t look, m mode, a pause, q close.' : `Not placed: ${'reason' in opened ? String(opened.reason) : 'unknown'}`
114}
115
116export const register: Register = (on, options) => {
117 const startLook = BY_NAME[String(options.dashboardTheme ?? 'Default')] ?? 'A'
118 const startCharacter = ['Ember', 'Clawd', 'none'].includes(String(options.dashboardCharacter)) ? String(options.dashboardCharacter) : 'Ember'
119
120 on('session.start', async ($, e, next) => {
121 const r = await next(e)
122 const saved = await $.store.get('dashboard-probe:theme').catch(() => undefined)
123 await update($, theme, () => (saved === 'A' || saved === 'B' || saved === 'C' ? saved : startLook))
124 await update($, character, () => startCharacter)
125 await $.command
126 .register({ name: 'dashboard-probe', description: 'DevForgeAI Dashboard prototype (menu N, arm)', argumentHint: '[look | menu N | arm]', immediate: true })
127 .catch(() => {})
128 $.clock.every(200, () => {
129 void tick($).catch(() => {})
130 })
131 return r
132 })
133
134 on('turn.step', async function* ($, e, next) {
135 if (e.agentId) return yield* next(e)
136 const began = Date.now()
137 let first = 0
138 rpm.isStreaming = true
139 rpm.chars = 0
140 rpm.start = 0
141 const stream = next(e)
142 try {
143 for (;;) {
144 const { value, done } = await stream.next()
145 if (done) return value
146 const c = value as { kind: string; text?: string; json?: string; usage?: { output_tokens?: number } | null }
147 if (c.kind === 'text' || c.kind === 'thinking' || c.kind === 'input') {
148 if (!first) {
149 first = Date.now()
150 rpm.start = first
151 }
152 rpm.chars += (c.text ?? c.json ?? '').length
153 }
154 if (c.kind === 'stop' && c.usage && typeof c.usage.output_tokens === 'number') {
155 const secs = (Date.now() - (first || began)) / 1000
156 if (secs > 0.2) {
157 rpm.value = Math.min(200, c.usage.output_tokens / secs)
158 rpm.at = Date.now()
159 rpm.last = `RPM: last step ${c.usage.output_tokens} tok in ${secs.toFixed(1)} s = ${Math.round(c.usage.output_tokens / secs)} tok/s`
160 }
161 }
162 yield value
163 }
164 } finally {
165 rpm.isStreaming = false
166 }
167 })
168
169 // Every press this plugin's elements receive, so clicks can be told apart from keys.
170 on('ui.press', async ($, e, next) => {
171 if (e.plugin === PLUGIN) {
172 live.presses++
173 await update($, note, () => `press #${live.presses}: ${e.element}`)
174 }
175 return next(e)
176 })
177
178 on('session.measure', async ($, e, next) => {
179 const r = await next(e)
180 if (live.isArmed) {
181 live.isArmed = false
182 void $.command
183 .run({ command: 'devforgeai:spec-lookup', args: 'SPEC-014' })
184 .then(() => update($, note, () => 'arm: $.command.run from session.measure accepted'))
185 .catch((err: unknown) => update($, note, () => `arm: $.command.run from session.measure refused: ${String(err)}`))
186 }
187 return r
188 })
189
190 // The command-file route: commands/dashboard.md lists /dashboard-probe:dashboard; answering it here, without
191 // next(e), should open the pane and load no prompt.
192 on('command.run', { command: 'dashboard-probe:dashboard' }, async $ => {
193 const text = await openPane($)
194 await update($, note, () => 'command file route: /dashboard-probe:dashboard answered by the hook ✓')
195 return { text }
196 })
197
198 on('command.run', { command: 'dashboard-probe' }, async ($, e) => {
199 const args = e.args.trim().split(/\s+/)
200 if (args[0] === 'arm') {
201 live.isArmed = true
202 return { text: 'Armed: when the next turn ends, session.measure calls $.command.run.' }
203 }
204 if (args[0] === 'look') {
205 await nextLook($)
206 return { text: `Look: ${NAMES[await read($, theme)]}; ${await read($, note)}` }
207 }
208 if (args[0] === 'menu') {
209 const i = Number(args[1]) - 1
210 if (!(i >= 0 && i < 7)) return { text: 'Usage: /dashboard-probe menu 1-7' }
211 void tileMenu($, i).catch(() => {})
212 return { text: `Menu ${i + 1} asked (mid-turn if Claude is working).` }
213 }
214 return { text: await openPane($) }
215 })
216
217 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
218 if (e.surface !== 'terminal') {
219 const { Text } = $.ui.resolve(e)
220 return <Text dimColor>The terminal draws this prototype; the desktop gets the SVG version later.</Text>
221 }
222 const { Box, Text, Button, Raster } = $.ui.resolve(e)
223 const look = await read($, theme)
224 const who = await read($, character)
225 const anim = await read($, isAnimating)
226 const pmode = await read($, trackMode)
227 const said = await read($, note)
228 const rpmSaid = await read($, rpmNote)
229 const ms = await $.clock.now()
230 live.layout = layoutFor(e.props.placement, e.props.bodyColumns)
231 const [columns, rows] = SIZES[live.layout]
232 const rects = tileRects(live.layout)
233 const isDock = live.layout === 'dock'
234 return (
235 <Box flexDirection="column">
236 <Box position="relative" width={columns} height={rows}>
237 <Raster key="dash" columns={columns} rows={rows} cells={frame(look, live.layout, ms, await fuelOf($), 'pace', pmode, rpmNow(ms))} />
238 {rects.map((rc, i) => {
239 const m = tileModel(look, i, live.layout)
240 const room = rc.w - 5
241 const label = m.label.length > room ? `${m.label.slice(0, room - 1)}…` : m.label
242 return (
243 <Box key={`tile-${i}`} position="absolute" top={rc.y} left={rc.x} width={rc.w} height={rc.h} borderStyle={m.style} borderColor={m.border} backgroundColor={m.bg} flexDirection={isDock ? 'row' : 'column'} paddingX={isDock ? 1 : 0} columnGap={isDock ? 2 : 0}>
244 <Button key={`tile-btn-${i}`} hotkey={String(i + 1)} plain label={isDock ? m.label : label} onPress={() => void tileMenu($, i).catch(() => {})} />
245 <Text color={m.docColor} wrap="truncate-end">{m.doc}</Text>
246 <Text color={m.stateColor} wrap="truncate-end">{m.state}</Text>
247 {!isDock && m.bar !== '' && <Text color={m.barColor}>{m.bar}</Text>}
248 </Box>
249 )
250 })}
251 </Box>
252 <Box flexDirection="row" columnGap={2}>
253 {who !== 'none' && <Raster key="char" columns={14} rows={4} cells={spriteCells(who, THEMES[look].panel, ms)} />}
254 <Box flexDirection="column">
255 <Text dimColor wrap="truncate-end">{said}</Text>
256 <Text dimColor wrap="truncate-end">{rpmSaid}</Text>
257 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
258 <Button key="look" hotkey="t" label={`Look: ${NAMES[look]}`} onPress={() => void nextLook($).catch(() => {})} />
259 <Button key="mode" hotkey="m" label={pmode === 'enforce' ? 'Switch to observe' : 'Switch to enforce'} onPress={() => void update($, trackMode, x => (x === 'enforce' ? 'observe' : 'enforce'))} />
260 <Button key="anim" hotkey="a" label={anim ? 'Pause' : 'Animate'} onPress={() => void update($, isAnimating, x => !x)} />
261 <Button key="close" hotkey="q" role="dismiss" label="Close" onPress={() => { live.isOpen = false; void $.ui.close({ id: PANE }) }} />
262 </Box>
263 </Box>
264 </Box>
265 </Box>
266 )
267 })
268}
269hooks/dashboard.ts 487 lines1// @ts-nocheck
2// The DevForgeAI Dashboard drawing engine, ported from the design canvas (Main.dc.html): pure functions that lay out the
3// whole dashboard on a character grid and pack it as Raster cells. No $ here.
4const CW = 7.2
5const CH = 18
6
7function hx(h) { return [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)] }
8function toHex(a) { return '#' + a.map(v => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('') }
9export function mix(a, b, t) { const A = hx(a), B = hx(b); return toHex(A.map((v, i) => v + (B[i] - v) * t)) }
10
11export { PHASES }
12export const THEMES = {
13 A: {
14 name: 'Default', bg: '#111316', bg2: '#111316', panel: '#171A1F', panel2: '#171A1F', bar: '#0B0D10',
15 border: '#2C323B', text: '#E8EAED', dim: '#8A93A0', faint: '#4C535E', accent: '#3E8BFF', ink: '#06122B',
16 done: '#9CC3FF', inprog: '#3E8BFF', next: '#E8EAED', notbuilt: '#353B44', warn: '#F2B84B', danger: '#FF5D5D',
17 ok: '#5BD49B', run: '#F5C542', needle: '#FFFFFF', odoBg: '#0A0C0F', odoFg: '#E8EAED',
18 phase: ['#9CC3FF', '#9CC3FF', '#3E8BFF', '#8A93A0', '#8A93A0', '#4C535E', '#4C535E'],
19 tile: 'round', panelStyle: 'round', arcTh: [0], ticks: 10, major: 5, halo: false,
20 },
21 B: {
22 name: 'Night drive', bg: '#0A0F2E', bg2: '#1C0A38', panel: '#110E38', panel2: '#1B1150', bar: '#070A22',
23 border: '#3D2F86', text: '#F1ECFF', dim: '#A49BDD', faint: '#5A519E', accent: '#FF4FD8', ink: '#14062B',
24 done: '#2BFFC6', inprog: '#FF4FD8', next: '#26D9FF', notbuilt: '#3A3470', warn: '#FFB443', danger: '#FF3D71',
25 ok: '#2BFFC6', run: '#FFE45E', needle: '#FF4FD8', odoBg: '#070720', odoFg: '#26D9FF',
26 phase: ['#FF4FD8', '#B24BFF', '#4F7BFF', '#26D9FF', '#2BFFC6', '#5B5296', '#5B5296'],
27 tile: 'heavy', panelStyle: 'heavy', arcTh: [0, -3.6, -7.2], ticks: 20, major: 5, halo: true,
28 },
29 C: {
30 name: 'Race telemetry', bg: '#050505', bg2: '#050505', panel: '#0B0B0B', panel2: '#0B0B0B', bar: '#000000',
31 border: '#2E2E2E', text: '#F4F4F4', dim: '#9A9A9A', faint: '#555555', accent: '#FFB000', ink: '#000000',
32 done: '#B6FF3B', inprog: '#FFB000', next: '#FFFFFF', notbuilt: '#333333', warn: '#FF8A00', danger: '#FF2E2E',
33 ok: '#B6FF3B', run: '#FFE14D', needle: '#FFFFFF', odoBg: '#1A1200', odoFg: '#FFB000',
34 phase: ['#B6FF3B', '#7CFFB2', '#FFB000', '#FF8A00', '#FFE14D', '#4A4A4A', '#4A4A4A'],
35 tile: 'light', panelStyle: 'light', arcTh: [0, -3.6], ticks: 40, major: 10, halo: false,
36 },
37}
38
39const PHASES = [
40 { n: 'Brainstorm', g: '✦', doc: 'BRN-001', st: 'done', steps: '9/9', dur: '38m', t: '38m' },
41 { n: 'PRD', g: '≡', doc: 'PRD-002', st: 'done', steps: '11/11', dur: '1h 04m', t: '1h04' },
42 { n: 'Architecture', g: '⌂', doc: 'ARCH-001 draft', st: 'prog', cur: 7, tot: 11, dur: '41m', t: '41m ▸' },
43 { n: 'Context', g: '◎', doc: 'needs ARCH-001', st: 'next', est: '~35m est.', t: '~35m' },
44 { n: 'Epic', g: '▲', doc: 'needs CTX docs', st: 'todo', est: '~50m est.', t: '~50m' },
45 { n: 'Story', g: '¶', doc: 'skill not built', st: 'nb', t: '-' },
46 { n: 'Spec', g: '§', doc: 'skill not built', st: 'nb', t: '-' },
47]
48
49// The character: a few pixel frames, two pixels a cell (top in the glyph's colour, bottom in its background).
50const SPRITES = {
51 Ember: { palette: { r: '#FF5A1F', o: '#FF9A2E', y: '#FFE07A', k: '#1A0F08', w: '#FFFFFF' }, frames: [
52 ['......r.......', '.....rr.......', '....rorr......', '...rooor......', '..rooyoor.....', '..royyyor.....', '..rokyykor....', '...royyor.....'],
53 ['.......r......', '......rr......', '.....rorr.....', '...rroor......', '..rooyoor.....', '..royyyyr.....', '..rokyykor....', '...royyor.....'],
54 ] },
55 Clawd: { palette: { o: '#D97757', d: '#A85A3E', k: '#1A1A1A' }, frames: [
56 ['..............', '...oooooooo...', '..oooooooooo..', '..ookoooooko..', '.oooooooooooo.', '.oooooooooooo.', '..o.o....o.o..', '..o.o....o.o..'],
57 ['..............', '...oooooooo...', '..oooooooooo..', '..oookooooko..', '.oooooooooooo.', '.oooooooooooo.', '...o.o..o.o...', '..o.o....o.o..'],
58 ] },
59}
60
61// The character as Raster cells (14x4) on a background, frame by time.
62export function spriteCells(name, bgHex, ms) {
63 const S = SPRITES[name]
64 const f = S.frames[Math.floor(ms / 450) % S.frames.length]
65 const words = new Uint32Array(14 * 4 * 3)
66 const bg = parseInt(bgHex.slice(1), 16)
67 const col = ch => (ch === '.' ? null : parseInt(S.palette[ch].slice(1), 16))
68 let k = 0
69 for (let cy = 0; cy < 4; cy++) for (let x = 0; x < 14; x++) {
70 const top = col(f[cy * 2][x]), bot = col(f[cy * 2 + 1][x])
71 if (top === null && bot === null) { words[k++] = 0x20; words[k++] = bg; words[k++] = bg }
72 else if (top !== null && bot !== null) { words[k++] = 0x2580; words[k++] = top; words[k++] = bot }
73 else if (top !== null) { words[k++] = 0x2580; words[k++] = top; words[k++] = bg }
74 else { words[k++] = 0x2584; words[k++] = bot; words[k++] = bg }
75 }
76 return new Uint8Array(words.buffer).toBase64()
77}
78
79const AGENTS = [
80 { pre: '', txt: 'main', st: 'run', t: '4m 12s', agent: true },
81 { pre: '├─ ', txt: '◆ spec-lookup', st: 'done', t: '0:38', agent: true },
82 { pre: '│ ├─ ', txt: 'Read SPEC-013.md', st: 'done', t: '0.2s' },
83 { pre: '│ ├─ ', txt: 'Grep "precompact"', st: 'done', t: '0.4s' },
84 { pre: '│ └─ ', txt: 'Bash find_spec.py', st: 'done', t: '1.9s' },
85 { pre: '├─ ', txt: '◆ drafts-review', st: 'run', t: '1:05', agent: true },
86 { pre: '│ ├─ ', txt: 'Read ARCH-001.md', st: 'done', t: '0.3s' },
87 { pre: '│ ├─ ', txt: 'WebFetch adr.github.io', st: 'fail', t: '2.1s' },
88 { pre: '│ └─ ', txt: 'Grep "NEEDS ADR"', st: 'run', t: '...' },
89 { pre: '└─ ', txt: '◆ eval-probe', st: 'done', t: '0:12', agent: true },
90]
91
92function term(W, H, bgAt, fg) {
93 const rows = []
94 for (let y = 0; y < H; y++) { const r = []; for (let x = 0; x < W; x++) r.push({ c: ' ', f: fg, b: bgAt(x, y) }); rows.push(r) }
95 return { W, H, rows }
96}
97function cell(t, x, y) { return (y >= 0 && y < t.H && x >= 0 && x < t.W) ? t.rows[y][x] : null }
98function put(t, x, y, s, f, b) {
99 const a = Array.from(String(s))
100 for (let i = 0; i < a.length; i++) { const c = cell(t, Math.round(x) + i, y); if (!c) continue; c.c = a[i]; if (f) c.f = f; if (b) c.b = b }
101 return a.length
102}
103function len(s) { return Array.from(String(s)).length }
104function putR(t, xr, y, s, f, b) { put(t, xr - len(s) + 1, y, s, f, b) }
105function putC(t, xc, y, s, f, b) { put(t, Math.round(xc - len(s) / 2), y, s, f, b) }
106function fit(s, n) { const a = Array.from(String(s)); return a.length <= n ? String(s) : a.slice(0, Math.max(0, n - 1)).join('') + '…' }
107function fill(t, x, y, w, h, b, ch, f) {
108 for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) {
109 const k = cell(t, x + i, y + j); if (!k) continue
110 if (b) k.b = (typeof b === 'function') ? b(i, j) : b
111 if (ch !== undefined) { k.c = ch; if (f) k.f = f }
112 }
113}
114const BOX = { round: '╭╮╰╯─│', light: '┌┐└┘─│', heavy: '┏┓┗┛━┃', double: '╔╗╚╝═║', dash: '╭╮╰╯┄┆' }
115function box(t, x, y, w, h, o) {
116 const s = BOX[o.style || 'round']
117 if (o.fill) fill(t, x + 1, y + 1, w - 2, h - 2, o.fill, ' ', o.f)
118 for (let i = 1; i < w - 1; i++) { put(t, x + i, y, s[4], o.f); put(t, x + i, y + h - 1, s[4], o.f) }
119 for (let j = 1; j < h - 1; j++) { put(t, x, y + j, s[5], o.f); put(t, x + w - 1, y + j, s[5], o.f) }
120 put(t, x, y, s[0], o.f); put(t, x + w - 1, y, s[1], o.f); put(t, x, y + h - 1, s[2], o.f); put(t, x + w - 1, y + h - 1, s[3], o.f)
121 if (o.title) put(t, x + 2, y, ' ' + o.title + ' ', o.tf || o.f, o.tb)
122 if (o.right) putR(t, x + w - 3, y, ' ' + o.right + ' ', o.rf || o.f, o.tb)
123}
124
125function gauge(t, T, x, y, w, h, o) {
126 const m = new Map()
127 const dot = (X, Y, f, p) => {
128 const px = Math.round(X / 3.6), py = Math.round(Y / 4.5)
129 if (px < 0 || py < 0 || px >= w * 2 || py >= h * 4) return
130 const cx = px >> 1, cy = py >> 2, dx = px & 1, dy = py & 3
131 const bit = dy < 3 ? (dx ? [8, 16, 32][dy] : [1, 2, 4][dy]) : (dx ? 128 : 64)
132 const k = cy * w + cx
133 const e = m.get(k) || { m: 0, f, p: -1 }
134 e.m |= bit; if (p >= e.p) { e.p = p; e.f = f }
135 m.set(k, e)
136 }
137 const Wp = w * CW, Hp = h * CH
138 const R = Math.min(Wp / 2 - 4, (Hp - 2 * CH - 6) / 1.71), cx = Wp / 2, cy = R + 5
139 const inner = R + Math.min(...T.arcTh)
140 const ang = a => (225 - 270 * a) * Math.PI / 180
141 const v = Math.max(0, Math.min(1, o.v))
142 for (let s = 0; s <= 900; s++) {
143 const a = s / 900, col = o.color(a), lit = a <= v, c = lit ? col : mix(col, T.panel, 0.8)
144 for (const th of T.arcTh) dot(cx + (R + th) * Math.cos(ang(a)), cy - (R + th) * Math.sin(ang(a)), c, lit ? 1.5 : 1)
145 }
146 for (let i = 0; i <= T.ticks; i++) {
147 const ta = i / T.ticks, major = (i % T.major) === 0
148 for (let rr = inner - (major ? 9 : 5); rr <= inner - 3; rr++) dot(cx + rr * Math.cos(ang(ta)), cy - rr * Math.sin(ang(ta)), major ? T.text : T.faint, 2)
149 }
150 for (const mk of (o.marks || [])) for (let mr = inner - 12; mr <= R + 3; mr++) dot(cx + mr * Math.cos(ang(mk.a)), cy - mr * Math.sin(ang(mk.a)), mk.c, 2.6)
151 const av = ang(v), nl = inner - 5
152 for (let ns = 0; ns <= 1; ns += 0.004) {
153 const nx = cx + ns * nl * Math.cos(av), ny = cy - ns * nl * Math.sin(av)
154 dot(nx, ny, T.needle, 3)
155 if (T.halo) {
156 dot(nx + 2.6 * Math.sin(av), ny + 2.6 * Math.cos(av), mix(T.needle, T.panel, 0.45), 2.8)
157 dot(nx - 2.6 * Math.sin(av), ny - 2.6 * Math.cos(av), mix(T.needle, T.panel, 0.45), 2.8)
158 }
159 }
160 for (let hr = 0; hr <= 4.5; hr += 0.9) for (let ha = 0; ha < 6.3; ha += 0.3) dot(cx + hr * Math.cos(ha), cy + hr * Math.sin(ha), T.needle, 3)
161 for (const [k, e] of m) { const c = cell(t, x + (k % w), y + Math.floor(k / w)); if (c) { c.c = String.fromCharCode(0x2800 + e.m); c.f = e.f } }
162 if (o.tl) for (const lb of o.tl) {
163 const lr = inner - 20
164 putC(t, x + (cx + lr * Math.cos(ang(lb.a))) / CW, y + Math.floor((cy - lr * Math.sin(ang(lb.a))) / CH), lb.s, T.dim)
165 }
166 const vr = y + Math.floor((cy + 16) / CH)
167 putC(t, x + w / 2, vr, o.val, o.vcol || T.text)
168 putC(t, x + w / 2, vr + 1, o.unit, T.dim)
169 putC(t, x + w / 2, y + h - 2, o.label, o.lcol || T.text)
170 putC(t, x + w / 2, y + h - 1, o.sub, o.subcol || T.faint)
171}
172
173// The third instrument, by mode: pace along the route (with wheel-spin when RPM is high and pace near zero),
174// efficiency (prompt-cache hit rate), burn ($ per hour), or none (two dials, centred).
175export const SPEED_MODES = ['pace', 'efficiency', 'burn', 'none']
176export function demoSpeed(mode, ms) {
177 const cyc = (ms / 1000) % 20
178 if (mode === 'pace') return cyc < 14 ? { v: 0.45 + 0.2 * Math.sin(ms / 1500), spin: false } : { v: 0.03, spin: true }
179 if (mode === 'efficiency') return { v: 0.78 + 0.16 * Math.sin(ms / 4000), spin: false }
180 if (mode === 'burn') return { v: (5.5 + 2.5 * Math.sin(ms / 3500)) / 20, spin: false }
181 return { v: 0, spin: false }
182}
183function dials(t, T, theme, x0, y0, gw, gh, gstep, fuel, rpm, short, mode, sp) {
184 if (mode === 'none') { x0 += Math.floor(gstep / 2) }
185 const rpmCol = a => a >= 0.75 ? T.danger : (theme === 'B' ? mix(T.phase[3], T.phase[0], a / 0.75) : theme === 'C' ? T.done : T.accent)
186 const fuelCol = a => a <= 0.2 ? T.danger : a <= 0.3 ? T.warn : (theme === 'B' ? T.phase[4] : T.ok)
187 const mphCol = a => theme === 'B' ? mix(T.phase[1], T.phase[3], a) : theme === 'C' ? T.text : '#9CC3FF'
188 const tl = theme === 'C'
189 gauge(t, T, x0, y0, gw, gh, { v: rpm / 200, color: rpmCol, val: String(Math.round(rpm)), unit: 'tok/s', label: 'RPM', sub: 'redline 150',
190 tl: tl ? [{ a: 0.25, s: '50' }, { a: 0.5, s: '100' }, { a: 0.75, s: '150' }] : null, marks: [{ a: 0.75, c: T.danger }] })
191 gauge(t, T, x0 + gstep, y0, gw, gh, { v: fuel / 100, color: fuelCol, val: Math.round(fuel) + '%', vcol: fuel <= 20 ? T.danger : fuel <= 30 ? T.warn : T.text,
192 unit: short ? 'context left' : 'context left', label: 'FUEL', sub: short ? '▼20% precompact' : '▼ 20% precompact runs here', subcol: T.danger,
193 tl: tl ? [{ a: 0.3, s: '30' }, { a: 0.5, s: '50' }] : null, marks: [{ a: 0.3, c: T.warn }, { a: 0.2, c: T.danger }] })
194 if (mode === 'pace') {
195 const mins = sp.spin ? '—' : Math.round(60 / Math.max(1, sp.v * 12)) + ' min/step'
196 gauge(t, T, x0 + 2 * gstep, y0, gw, gh, { v: sp.v, color: mphCol, val: String(Math.round(sp.v * 100)), vcol: sp.spin ? T.warn : T.text, unit: sp.spin ? '' : mins, label: 'PACE',
197 lcol: sp.spin ? T.warn : T.text, sub: sp.spin ? '▲ WHEEL-SPIN' : (short ? 'route speed' : 'progress along route'), subcol: sp.spin ? T.warn : T.faint, marks: [] })
198 } else if (mode === 'efficiency') {
199 gauge(t, T, x0 + 2 * gstep, y0, gw, gh, { v: sp.v, color: a => a < 0.5 ? T.warn : (theme === 'B' ? T.phase[3] : T.ok), val: Math.round(sp.v * 100) + '%', unit: 'cache hits', label: 'EFFICIENCY', sub: short ? 'cold after /clear' : 'cold after /clear or idle', marks: [] })
200 } else if (mode === 'burn') {
201 gauge(t, T, x0 + 2 * gstep, y0, gw, gh, { v: sp.v, color: a => a > 0.6 ? T.danger : a > 0.4 ? T.warn : mphCol(a), val: '$' + (sp.v * 20).toFixed(2), unit: 'per hour', label: 'BURN', sub: short ? 'session $11.80' : 'session $11.80 · $20/h max', marks: [] })
202 }
203}
204
205function drawTile(t, T, theme, i, p, x, y, w, h, C) {
206 const acc = T.phase[i], st = p.st
207 const bcol = st === 'done' ? T.done : st === 'prog' ? T.inprog : st === 'next' ? T.next : st === 'nb' ? T.notbuilt : T.border
208 let inner
209 if (theme === 'B') {
210 const s0 = st === 'nb' ? 0.05 : (st === 'todo' ? 0.1 : 0.32), s1 = st === 'nb' ? 0.02 : 0.07
211 if (st === 'prog' || st === 'next') fill(t, x - 1, y - 1, w + 2, h + 2, (ii, jj) => mix(mix(T.bg, T.bg2, (y - 1 + jj) / (t.H - 1)), acc, 0.16))
212 inner = (ii, jj) => mix(mix(T.bg, T.bg2, (y + jj) / (t.H - 1)), acc, s0 + (s1 - s0) * (ii / Math.max(1, w - 3)))
213 } else if (theme === 'A') inner = st === 'prog' ? mix(T.panel, T.accent, 0.13) : (st === 'nb' ? T.bg : T.panel)
214 else inner = st === 'prog' ? '#140E00' : T.panel
215 const style = st === 'nb' ? 'dash' : (theme === 'B' && st === 'prog' ? 'double' : T.tile)
216 const fcol = (theme === 'B' && (st === 'done' || st === 'prog' || st === 'next')) ? acc : bcol
217 box(t, x, y, w, h, { style, f: fcol, fill: inner })
218 const dark = st === 'todo' || st === 'nb'
219 if (theme === 'C') {
220 fill(t, x, y, w, 1, st === 'nb' ? '#1A1A1A' : (st === 'todo' ? '#262626' : acc))
221 put(t, x + 1, y, fit(p.g + ' ' + p.n.toUpperCase(), w - 5), dark ? T.dim : '#000000')
222 putR(t, x + w - 2, y, 'P' + (i + 1), dark ? T.dim : '#000000')
223 }
224 const ix = x + 2, iw = w - 4
225 let r = y + 1
226 if (theme !== 'C') { put(t, ix, r, p.g, st === 'nb' ? T.faint : acc); put(t, ix + 2, r, fit(p.n, iw - 2), st === 'nb' ? T.faint : T.text); r++ }
227 put(t, ix, r, fit(p.doc, iw), st === 'nb' ? T.faint : T.dim); r++
228 if (!C) r++
229 if (st === 'next') {
230 if (theme === 'C') put(t, ix, r, '◉ NEXT ▸', T.next)
231 else put(t, ix, r, ' NEXT ▸ ', T.ink, theme === 'A' ? T.accent : T.next)
232 } else {
233 const lab = { done: ['✓ done', T.done], prog: ['● in progress', T.inprog], todo: ['○ not started', T.dim], nb: ['◌ coming soon', T.faint] }[st]
234 put(t, ix, r, lab[0], theme === 'B' && st !== 'todo' && st !== 'nb' ? acc : lab[1])
235 }
236 r++
237 if (st === 'prog') {
238 const n = Math.round(iw * p.cur / p.tot)
239 for (let k = 0; k < iw; k++) put(t, ix + k, r, k < n ? '█' : '░', k < n ? (theme === 'B' ? mix(T.phase[1], T.phase[3], k / Math.max(1, iw - 1)) : acc) : T.faint)
240 r++
241 put(t, ix, r, C ? 'step ' + p.cur + '/' + p.tot : 'step ' + p.cur + ' of ' + p.tot, T.text)
242 if (theme === 'C' && !C) putR(t, ix + iw - 1, r, p.dur, T.inprog)
243 r++
244 } else if (st === 'done') {
245 put(t, ix, r, (C ? '' : 'steps ') + p.steps, T.dim)
246 if (!C || theme === 'C') putR(t, ix + iw - 1, r, p.dur, theme === 'C' ? T.done : T.faint)
247 } else if ((st === 'next' || st === 'todo') && !C) put(t, ix, r, p.est, st === 'next' ? T.dim : T.faint)
248 if (!C || theme !== 'C') putR(t, x + w - 3, y + h - 2, '[' + (i + 1) + ']', st === 'nb' ? T.faint : T.dim)
249}
250
251// When the tiles are drawn as keyed Boxes over the Raster (clickable), the Raster leaves their cells empty.
252const BOX_TILES = { on: false }
253
254// Where each tile sits, in cells, for a layout: the same numbers drawWide and drawDock use.
255export function tileRects(layout) {
256 if (layout === 'dock') return Array.from({ length: 7 }, (_, i) => ({ x: 1, y: 2 + i * 3, w: 62, h: 3 }))
257 const C = layout === 'compact', W = C ? 120 : 160
258 const ty = C ? 2 : 3, th = C ? 7 : 9, tw = C ? 15 : 19, g = C ? 2 : 3
259 const x0 = Math.floor((W - (7 * tw + 6 * g)) / 2)
260 return Array.from({ length: 7 }, (_, i) => ({ x: x0 + i * (tw + g), y: ty, w: tw, h: th }))
261}
262
263// What a Box tile shows: border style and colour, a solid background, and its lines of text.
264export function tileModel(theme, i, layout) {
265 const T = THEMES[theme], p = PHASES[i], st = p.st, acc = T.phase[i]
266 const border = st === 'done' ? T.done : st === 'prog' ? T.inprog : st === 'next' ? T.next : st === 'nb' ? T.notbuilt : T.border
267 const bg = theme === 'B' ? mix(T.bg, acc, st === 'nb' ? 0.05 : st === 'todo' ? 0.1 : 0.22) : theme === 'A' ? (st === 'prog' ? mix(T.panel, T.accent, 0.13) : T.panel) : (st === 'prog' ? '#140E00' : T.panel)
268 const style = theme === 'A' ? 'round' : theme === 'B' ? (st === 'prog' ? 'double' : 'bold') : 'single'
269 const state = { done: '✓ done', prog: '● ' + p.cur + '/' + p.tot, next: '◉ NEXT ▸', todo: '○ not started', nb: '◌ coming soon' }[st]
270 const stateColor = st === 'done' ? T.done : st === 'prog' ? T.inprog : st === 'next' ? T.next : T.dim
271 const bar = st === 'prog' ? '█'.repeat(Math.round(6 * p.cur / p.tot)) + '░'.repeat(6 - Math.round(6 * p.cur / p.tot)) : ''
272 return { label: p.g + ' ' + p.n, labelColor: st === 'nb' ? T.faint : T.text, border: theme === 'B' && st !== 'nb' && st !== 'todo' ? acc : border, bg, style, doc: p.doc, docColor: st === 'nb' ? T.faint : T.dim, state, stateColor, bar, barColor: acc, dashed: st === 'nb' }
273}
274
275export function setBoxTiles(on) { BOX_TILES.on = on }
276
277function chip(t, x, y, s, f, b) { put(t, x, y, s, f, b); return x + len(s) + 1 }
278
279function odometer(t, T, x, y, C, ms) {
280 put(t, x, y + 1, 'ODO', T.dim)
281 const n = 1284903 + Math.floor((ms / 400) % 100000)
282 const digits = String(n).padStart(8, '0')
283 let dx = x + 5
284 for (let i = 0; i < digits.length; i++) {
285 const last = i === digits.length - 1
286 put(t, dx, y, '▄▄▄', T.odoBg)
287 put(t, dx, y + 1, ' ' + digits[i] + ' ', last ? T.accent : T.odoFg, T.odoBg)
288 put(t, dx, y + 2, '▀▀▀', T.odoBg)
289 dx += 3
290 if (i === 1 || i === 4) { put(t, dx, y + 1, ',', T.dim); dx += 1 }
291 }
292 put(t, dx + 1, y + 1, C ? 'tok' : 'tok · lifetime, this project', T.dim)
293}
294
295function statusBar(t, T, W, sy, fuel, short, mode) {
296 fill(t, 0, sy, W, 1, T.bar)
297 let sx = chip(t, 1, sy, short ? ' ◆ arch 7/11 ' : ' ◆ architecture 7/11 ', T.ink, T.accent)
298 sx = mode === 'enforce' ? chip(t, sx, sy, ' ENFORCE ', T.ink, T.ok) : chip(t, sx, sy, ' OBSERVE ', T.text, mix(T.border, T.bar, 0.2))
299 if (!short) sx = chip(t, sx, sy, ' ! 2 flags ', T.text, mix(T.warn, T.bar, 0.7))
300 const f = Math.round(fuel)
301 if (f <= 20) chip(t, sx, sy, short ? ' ● fuel ' + f + '% · precompacting ' : ' ● Fuel ' + f + '% · running /devforgeai:precompact ', '#FFFFFF', T.danger)
302 else if (f <= 30) chip(t, sx, sy, short ? ' ▲ fuel ' + f + '% · precompact at 20% ' : ' ▲ Fuel ' + f + '% · precompact runs at 20% ', T.ink, T.warn)
303 else chip(t, sx, sy, ' Fuel ' + f + '% ', T.dim, mix(T.border, T.bar, 0.3))
304}
305
306function drawWide(theme, C, fuel, rpm, ms, mode, sp, pmode) {
307 const T = THEMES[theme]
308 const W = C ? 120 : 160, H = C ? 36 : 48
309 const bgAt = (x, y) => T.bg2 !== T.bg ? mix(T.bg, T.bg2, y / (H - 1)) : T.bg
310 const t = term(W, H, bgAt, T.text)
311 const panelFill = T.panel2 !== T.panel ? (i, j) => mix(T.panel, T.panel2, j / 18) : T.panel
312 fill(t, 0, 0, W, 1, T.bar)
313 put(t, 1, 0, '◆ DevForgeAI Dashboard', T.accent)
314 put(t, 22, 0, '~/Projects/DevForgeAI', T.dim)
315 if (!C) put(t, 46, 0, 'theme: ' + T.name + ' (t switches)', T.faint)
316 putR(t, W - 2, 0, '● LIVE', T.ok)
317 const ty = C ? 2 : 3, th = C ? 7 : 9, tw = C ? 15 : 19, g = C ? 2 : 3
318 const total = 7 * tw + 6 * g, x0 = Math.floor((W - total) / 2), cur = 2
319 put(t, x0, ty - 1, 'ROUTE', T.dim)
320 put(t, x0 + 6, ty - 1, '· 2 done · 1 in progress · 1 next · 2 coming soon', T.faint)
321 putR(t, x0 + total - 1, ty - 1, 'click a phase to drive [1-7]', T.faint)
322 const txs = [], cxs = []
323 for (let i = 0; i < 7; i++) { txs.push(x0 + i * (tw + g)); cxs.push(x0 + i * (tw + g) + Math.floor(tw / 2)) }
324 if (!BOX_TILES.on) for (let i = 0; i < 7; i++) drawTile(t, T, theme, i, PHASES[i], txs[i], ty, tw, th, C)
325 for (let i = 0; i < 6; i++) {
326 const lit = PHASES[i].st === 'done'
327 put(t, txs[i] + tw, ty + Math.floor(th / 2), g === 3 ? '━━▶' : '━▶', lit ? (theme === 'B' ? T.phase[i + 1] : T.done) : T.faint)
328 }
329 const ny = ty + th + 1
330 put(t, x0, ny, 'NAV', T.dim)
331 put(t, cxs[cur], ny, '▼ YOU ARE HERE', T.accent)
332 putR(t, x0 + total - 1, ny, ' ETA ~1 h 50 m to Epic · Spec - ', T.ink, theme === 'C' ? T.accent : mix(T.accent, T.text, 0.15))
333 for (let lx = x0; lx < x0 + total; lx++) {
334 const trav = lx <= cxs[cur]
335 put(t, lx, ny + 1, trav ? '━' : '┄', trav ? (theme === 'B' ? mix(T.phase[0], T.phase[2], (lx - x0) / (cxs[cur] - x0)) : T.accent) : T.faint)
336 }
337 put(t, x0, ny + 1, '●', T.done)
338 put(t, x0 + total - 1, ny + 1, '■', T.faint)
339 for (let i = 0; i < 7; i++) {
340 const st = PHASES[i].st
341 const node = st === 'done' ? ['●', T.done] : st === 'prog' ? ['◉', T.accent] : st === 'next' ? ['○', T.next] : st === 'todo' ? ['○', T.dim] : ['◌', T.faint]
342 put(t, cxs[i], ny + 1, node[0], theme === 'B' && st !== 'nb' && st !== 'todo' ? T.phase[i] : node[1])
343 putC(t, cxs[i] + 0.5, ny + 2, PHASES[i].t, st === 'prog' ? T.accent : (st === 'nb' ? T.faint : T.dim))
344 }
345 if (!C) { put(t, x0, ny + 2, 'Start', T.dim); putR(t, x0 + total - 1, ny + 2, 'Ship', T.dim) }
346 const y1 = ny + 4
347 const cbx = C ? 0 : 1, cbw = C ? 76 : 100, cbh = C ? 15 : 19
348 const abx = cbx + cbw + 1, abw = W - abx - (C ? 0 : 1)
349 const pst = T.panelStyle
350 box(t, cbx, y1, cbw, cbh, { style: pst, f: T.border, fill: panelFill, title: 'INSTRUMENTS', tf: T.dim, right: 'turn 57 · live', rf: T.faint })
351 dials(t, T, theme, cbx + (C ? 3 : 4), y1 + (C ? 1 : 2), C ? 22 : 26, C ? 10 : 12, C ? 24 : 31, fuel, rpm, C, mode, sp)
352 odometer(t, T, cbx + 3, y1 + cbh - 4, C, ms)
353 if (!C) putR(t, cbx + cbw - 4, y1 + cbh - 3, 'session 182,410 tok · $11.80', T.faint)
354 box(t, abx, y1, abw, cbh, { style: pst, f: T.border, fill: panelFill, title: 'AGENTS', tf: T.dim, right: '3 live', rf: T.faint })
355 agents(t, T, theme, abx, y1, abw, AGENTS, ms)
356 put(t, abx + 2, y1 + cbh - 2, fit('3 agents · 7 tool calls · 1 failed', abw - 4), T.faint)
357 const gyy = y1 + cbh + 1, gbh = C ? 5 : 9, gbw = C ? W : 100, gbx = C ? 0 : 1
358 box(t, gbx, gyy, gbw, gbh, { style: pst, f: T.border, fill: panelFill, title: 'GUARDRAILS', tf: T.dim, right: C ? '' : 'mods', rf: T.faint })
359 const chips = [' ✓ write gate ', ' ✓ question gate ', ' ✓ origin filter ', ' ✓ precompact @20% fuel ', ' ✓ exit confirm ']
360 let cxp = gbx + 2, cy0 = gyy + 1
361 if (!C) { put(t, cxp, cy0, '◈ 5 mods armed', T.accent); putR(t, gbx + gbw - 3, cy0, mode === 'enforce' ? ' ENFORCE ' : ' OBSERVE ', mode === 'enforce' ? T.ink : T.text, mode === 'enforce' ? T.ok : mix(T.border, T.bar, 0.2)); cy0 += 2 } else cxp = chip(t, cxp, cy0, '◈ 5 armed', T.accent)
362 for (const c of chips) cxp = chip(t, cxp, cy0, c, T.ok, mix(T.ok, T.panel, 0.84))
363 const hy = cy0 + (C ? 1 : 2)
364 put(t, gbx + 2, hy, '▲ LAST HOLD', T.warn)
365 put(t, gbx + 14, hy, C ? 'write gate held step 8 until you answered · resolved ✓' : 'write gate held step 8 until you answered · 14:02 · resolved ✓', T.text)
366 put(t, gbx + 2, hy + 1, C ? 'fails open: if the tracker stops, work goes on' : 'fails open: if the tracker stops, the work goes on and you are told', T.faint)
367 if (!C) {
368 const lbx = 102, lbw = W - lbx - 1
369 if (theme === 'C') {
370 box(t, lbx, gyy, lbw, gbh, { style: pst, f: T.border, fill: panelFill, title: 'TELEMETRY', tf: T.dim, right: 'lap 41:07', rf: T.accent })
371 const tel = [['IN', '412,880'], ['OUT', '38,204'], ['CACHE', '91.4%'], ['TURNS', '57'], ['TOOLS', '214'], ['T/MIN', '12.3'], ['COST', '$11.80'], ['$/H', '4.20'], ['FLAGS', '2'], ['P50 TTFT', '1.8s'], ['P95', '6.2s'], ['ERR', '0']]
372 tel.forEach((kv, k) => { const xx = lbx + 2 + (k % 3) * 18, yy = gyy + 1 + Math.floor(k / 3) * 2; put(t, xx, yy, kv[0], T.dim); putR(t, xx + 15, yy, kv[1], k === 6 ? T.accent : T.done) })
373 } else {
374 box(t, lbx, gyy, lbw, gbh, { style: pst, f: T.border, fill: panelFill, title: 'LOG', tf: T.dim, right: 'this run', rf: T.faint })
375 const log = [['14:06', 'step 7 marked in progress', T.text], ['14:05', 'answer · step 6 · ADR-003 accepted', T.text], ['14:02', '▲ write gate held step 8', T.warn], ['13:58', 'spec-lookup returned 4 matches', T.dim], ['13:51', 'run continued from step 5', T.dim], ['13:49', 'session started · observe → enforce', T.faint]]
376 log.forEach((l, k) => { put(t, lbx + 2, gyy + 1 + k, l[0], T.faint); put(t, lbx + 9, gyy + 1 + k, fit(l[1], lbw - 12), l[2]) })
377 }
378 }
379 statusBar(t, T, W, H - 1, fuel, false, pmode)
380 return t
381}
382
383function agents(t, T, theme, abx, y1, abw, list, ms) {
384 let ay = y1 + 1
385 const blink = Math.floor(ms / 500) % 2 === 0
386 for (const a2 of list) {
387 let ax = abx + 2
388 const dc = a2.st === 'run' ? (blink ? T.run : mix(T.run, T.panel, 0.5)) : a2.st === 'fail' ? T.danger : T.ok
389 put(t, ax, ay, a2.pre, T.faint); ax += len(a2.pre)
390 put(t, ax, ay, '●', dc); ax += 2
391 const room = abx + abw - 3 - len(a2.t) - 1 - ax
392 if (a2.agent) put(t, ax, ay, fit(a2.txt, room), T.text)
393 else {
394 const sp = a2.txt.indexOf(' ')
395 put(t, ax, ay, a2.txt.slice(0, sp), theme === 'C' ? T.accent : T.dim)
396 put(t, ax + sp, ay, fit(a2.txt.slice(sp), Math.max(0, room - sp)), a2.st === 'fail' ? T.danger : T.dim)
397 }
398 putR(t, abx + abw - 3, ay, a2.t, a2.st === 'run' ? T.run : T.faint)
399 ay++
400 }
401}
402
403function drawDockTile(t, T, theme, i, p, x, y, w) {
404 const acc = T.phase[i], st = p.st
405 const bcol = st === 'done' ? T.done : st === 'prog' ? T.inprog : st === 'next' ? T.next : st === 'nb' ? T.notbuilt : T.border
406 let inner
407 if (theme === 'B') {
408 const s0 = st === 'nb' ? 0.05 : (st === 'todo' ? 0.1 : 0.34), s1 = st === 'nb' ? 0.02 : 0.06
409 inner = (ii, jj) => mix(mix(T.bg, T.bg2, (y + jj) / (t.H - 1)), acc, s0 + (s1 - s0) * (ii / Math.max(1, w - 3)))
410 } else if (theme === 'A') inner = st === 'prog' ? mix(T.panel, T.accent, 0.13) : (st === 'nb' ? T.bg : T.panel)
411 else inner = st === 'prog' ? '#140E00' : T.panel
412 const style = st === 'nb' ? 'dash' : (theme === 'B' && st === 'prog' ? 'double' : T.tile)
413 const fcol = ((theme === 'B' || theme === 'C') && (st === 'done' || st === 'prog' || st === 'next')) ? acc : bcol
414 box(t, x, y, w, 3, { style, f: fcol, fill: inner })
415 const r = y + 1
416 let nx = x + 4
417 if (theme === 'C') { put(t, x + 2, r, 'P' + (i + 1), st === 'nb' || st === 'todo' ? T.dim : acc); nx = x + 6 } else put(t, x + 2, r, p.g, st === 'nb' ? T.faint : acc)
418 put(t, nx, r, p.n, st === 'nb' ? T.faint : T.text)
419 put(t, x + 19, r, fit(p.doc, 14), st === 'nb' ? T.faint : T.dim)
420 const sx = x + 35
421 if (st === 'prog') {
422 for (let k = 0; k < 10; k++) { const on = k < Math.round(10 * p.cur / p.tot); put(t, sx + k, r, on ? '█' : '░', on ? (theme === 'B' ? mix(T.phase[1], T.phase[3], k / 9) : acc) : T.faint) }
423 put(t, sx + 11, r, p.cur + '/' + p.tot, T.text)
424 } else if (st === 'done') { put(t, sx, r, '✓ done', theme === 'B' ? acc : T.done); put(t, sx + 8, r, p.dur, theme === 'C' ? T.done : T.faint) }
425 else if (st === 'next') { if (theme === 'C') put(t, sx, r, '◉ NEXT ▸', T.next); else put(t, sx, r, ' NEXT ▸ ', T.ink, theme === 'A' ? T.accent : T.next); put(t, sx + 9, r, '~35m', T.dim) }
426 else if (st === 'todo') put(t, sx, r, '○ ~50m est.', T.dim)
427 else put(t, sx, r, '◌ coming soon', T.faint)
428 putR(t, x + w - 3, r, '[' + (i + 1) + ']', st === 'nb' ? T.faint : T.dim)
429}
430
431function drawDock(theme, fuel, rpm, ms, mode, sp, pmode) {
432 const T = THEMES[theme], W = 64, H = 48
433 const bgAt = (x, y) => T.bg2 !== T.bg ? mix(T.bg, T.bg2, y / (H - 1)) : T.bg
434 const t = term(W, H, bgAt, T.text)
435 const panelFill = T.panel2 !== T.panel ? (i, j) => mix(T.panel, T.panel2, j / 14) : T.panel
436 const pst = T.panelStyle
437 fill(t, 0, 0, W, 1, T.bar)
438 put(t, 1, 0, '◆ DevForgeAI Dashboard', T.accent)
439 putR(t, W - 2, 0, T.name + ' · t', T.faint)
440 put(t, 1, 1, 'ROUTE', T.dim)
441 putR(t, W - 2, 1, '2 done · 1 in progress · [1-7]', T.faint)
442 for (let i = 0; i < 7; i++) {
443 const y = 2 + i * 3
444 if (!BOX_TILES.on) drawDockTile(t, T, theme, i, PHASES[i], 1, y, W - 2)
445 if (i < 6) put(t, 5, y + 2, '▼', PHASES[i].st === 'done' ? (theme === 'B' ? T.phase[i + 1] : T.done) : T.faint)
446 }
447 put(t, 1, 24, '▼ YOU ARE HERE · Architecture', T.accent)
448 putR(t, W - 2, 24, ' ETA ~1 h 50 m to Epic ', T.ink, theme === 'C' ? T.accent : mix(T.accent, T.text, 0.15))
449 box(t, 0, 25, W, 14, { style: pst, f: T.border, fill: panelFill, title: 'INSTRUMENTS', tf: T.dim, right: 'turn 57', rf: T.faint })
450 dials(t, T, theme, 1, 26, 20, 9, 21, fuel, rpm, true, mode, sp)
451 odometer(t, T, 2, 35, true, ms)
452 putR(t, W - 3, 36, '$11.80', T.faint)
453 box(t, 0, 39, W, 7, { style: pst, f: T.border, fill: panelFill, title: 'AGENTS', tf: T.dim, right: '3 live · 1 failed', rf: T.faint })
454 agents(t, T, theme, 0, 39, W, [
455 { pre: '', txt: 'main', st: 'run', t: '4m 12s', agent: true },
456 { pre: '├─ ', txt: '◆ spec-lookup · 3 tools', st: 'done', t: '0:38', agent: true },
457 { pre: '├─ ', txt: '◆ drafts-review', st: 'run', t: '1:05', agent: true },
458 { pre: '│ └─ ', txt: 'Grep "NEEDS ADR"', st: 'run', t: '...' },
459 { pre: '└─ ', txt: '◆ eval-probe', st: 'done', t: '0:12', agent: true },
460 ], ms)
461 put(t, 1, 46, '◈ 5 mods', T.accent)
462 put(t, 10, 46, '▲ write gate held step 8 · resolved ✓', T.warn)
463 statusBar(t, T, W, 47, fuel, true, pmode)
464 return t
465}
466
467export type Layout = 'wide' | 'compact' | 'dock'
468export const SIZES = { wide: [160, 48], compact: [120, 36], dock: [64, 48] }
469
470// The whole dashboard as Raster cells for one layout and one moment.
471// fuel: the share of the context window still free, 100 right after /clear or a compaction.
472// rpmLive: measured output tokens per second (null: the sample wander).
473export function frame(theme: 'A' | 'B' | 'C', layout: Layout, ms: number, fuel: number, mode = 'pace', pmode = 'enforce', rpmLive: number | null = null): string {
474 let sp = demoSpeed(mode, ms)
475 if (rpmLive !== null) sp = { v: sp.spin ? 0.45 : sp.v, spin: false }
476 const rpm = rpmLive !== null ? rpmLive : sp.spin ? 168 + 8 * Math.sin(ms / 300) : 100 + 80 * Math.sin(ms / 1300) * Math.sin(ms / 3100 + 1)
477 const t = layout === 'dock' ? drawDock(theme, fuel, Math.max(0, rpm), ms, mode, sp, pmode) : drawWide(theme, layout === 'compact', fuel, Math.max(0, rpm), ms, mode, sp, pmode)
478 const words = new Uint32Array(t.W * t.H * 3)
479 let k = 0
480 for (const row of t.rows) for (const c of row) {
481 words[k++] = c.c.codePointAt(0)
482 words[k++] = parseInt(c.f.slice(1), 16)
483 words[k++] = parseInt(c.b.slice(1), 16)
484 }
485 return new Uint8Array(words.buffer).toBase64()
486}
487types/index.d.ts 15 lines1export type Look = 'A' | 'B' | 'C'
2
3declare module 'claude-code' {
4 interface PluginState {
5 'dashboard-probe': {
6 theme: Look
7 character: string
8 isAnimating: boolean
9 trackMode: 'enforce' | 'observe'
10 note: string
11 rpmNote: string
12 }
13 }
14}
15