SLOPSHOPPER

dashboard-probe

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…

newpanecommandtoasttimer
v0.4.0no licenseupdated 2026-10-06bankielewicz/DevForgeAI/src/tools/dashboard-probe
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dashboard-probe
│ ┃ DevForgeAI Dashboard ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ DevForgeAI Dashboard │ ┃ ROUTE 2 done · 1 ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /dashboard-probe │ ┃ ⎿ dashboard-probe: DevForgeAI Dashboard opened: keys 1-7 phases, t │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · DevForgeAI Dashboard
◆ DevForgeAI Dashboard Default ROUTE 2 done · 1 in progress · [1 ▄▀ keys 1-7 open a phase · t look · m mode · q ▄▄▀▀▀▀ RPM: no model step measured yet ▀▀▀▀▀▀▀ [ Look: Default ] [ Switch to observe ] [ ▀▀▀▀▀▀▀▀
README

DevForgeAI

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.

Prerequisites

  • Claude Code. The recorded eval runs used versions 2.1.283 to 2.1.289; each specification's §9 names the version of each run.
  • Python 3, used by the skills' scripts: the brainstorm validator, the policy and context checkers, the documents updater's Markdown checker, the git scripts, the spec lookup and the progress tracker's evaluator.
  • For the 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.

Quick start

  1. From the project where you want the planning documents, start Claude Code with the plugin loaded, replacing <devforgeai> with the path to this repository:
   claude --plugin-dir <devforgeai>/src/claude/DevForgeAI
  1. Start a brainstorm, for example /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.
  2. Turn the promoted ideas into a PRD:
   /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.

  1. Define the architecture the PRD's epics must share:
   /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.

  1. Write the project's context documents (tech stack, source tree, testing and each layer's conventions) in 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).

  1. Group the ready requirements into epics:
   /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.

Skills

SkillInvokeWhat it doesStatus
brainstorm/devforgeai:brainstorm [topic]Runs a structured brainstorm and writes a BRN document with problems, ideas, assumptions and the dispositions you confirmedImplemented (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 policyImplemented (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 editsImplemented (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 epicsImplemented (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 whyImplemented (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 practiceImplemented (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 agentImplemented (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 worktreesImplemented; 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.

Progress tracker

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.

  • Each run's checklist steps appear in the status line and in a band above the prompt. The skills keep their checklist in Claude Code's task list, and tag each question with the step it belongs to.
  • Each run is recorded under 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).
  • Observe mode, the default, only reports. Enforce mode, switched on from the band, refuses a question that isn't tagged with the step marked in progress, and a write that records a decision of yours without your answer. The choice is saved in .claude/devforgeai.local.md.
  • When your request says to proceed without questions, the skill asks once whether to proceed without questions; the tracker records your answer as a waiver. At the end of a run it reviews any refusals and flags with you.
  • Set the plugin's tracking setting to off to turn the tracker off; the skills work the same either way.

Where documents go

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.

Evaluate the skills

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.

Documentation

  • Specifications: what each skill must do, with its verification items.
  • ADRs: the build and deploy process, the architecture step, and the policy contract.
  • Brainstorm manual test runbook: the checks evals can't automate.
  • CLAUDE.md and AGENTS.md: instructions for AI agents working in this repository.
  • Changelog: notable changes, all unreleased so far.
  • Codex port: the brainstorm, prd, architecture and documents-updater skills adapted for Codex, as source only; the prd and architecture ports are drafts (see their import reports).
Source 3 files
hooks/register.tsx 269 lines
1// 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}
269
hooks/dashboard.ts 487 lines
1// @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}
487
types/index.d.ts 15 lines
1export 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