A read-only view of how this machine's repositories are set up for the devbook stack, inside Claude Code: /rollout opens a pane whose matrix has one row per…

Every repository on this machine that has adopted the devbook stack, drawn inside Claude Code itself — which version of each component it is stamped at, how far that is from the newest, and how one of them is wired — from the .devbook/config.json files already on disk.
A viewer, not a writer. devbook-config owns .devbook/config.json and its skills are what change it; this plugin reads the file in every repository it finds and draws it. It writes no file, runs no skill, and declares no dependency: uninstalling it costs a view, never a capability. It sits beside devbook-config rather than inside it for the reason delivery-run-view sits beside delivery — it is function-hook modules, which only Claude Code loads, and a plugin whose every skill both hosts load should not carry a Claude-only pane.
claude plugin marketplace add JSdotNet/devbook
Then enable devbook-config-view with /plugin, and type /rollout.
The plugin is function-hook modules — hooks/hooks.json names ./register.tsx under modules — and Copilot has no equivalent. So it carries the Claude manifest alone, per .devbook/arc42/05-building-block-view.md under Plugin Folder, as delivery-run-view does.
/rollout reads every repository and opens the pane. It reads again only on ↻ refresh: nothing polls.
| View | Shows |
|---|---|
| Matrix | One row per repository, one column per components.<name> stamp — devbook, derived, openspec, delivery, schedule, then any other stamp carrying a pluginVersion. Each column is as wide as its name. Each cell is the stamped pluginVersion, green when it is the newest the installed marketplace lists, yellow when a minor or patch release behind, red when a major behind, – when not stamped. Dots for the adopted devbook folders (arc42 domain tech design ai) and procedures (run capture diagnose estimate prototype). Every retired key the config still carries, in red under the row. Buttons: doctor and update put /devbook-config:doctor or /devbook-config:update for that repository in the prompt box — never sent, so you read it and press Enter yourself — and map opens the row in the config map |
| Config map | One repository, ‹ › to step: each flow's phase map as a chain — flow-code and flow-spec side by side once the pane is 110 columns wide, stacked below that — one row per phase — its mode (inline, delegate, fork; dimmed when it is the phase's default rather than the entry's choice), agent, model and effort, skill, MCP servers, phase-verify's app provider, and before and after chores — with Ready and Personal Validation where the engine runs them and each configured gate before or after its phase. Then policy and bindings |
The mode is the one the flow-runner would resolve from the committed entry, per plugins/delivery/resources/phase-resolution.md: agent: null is inline, any of agent, model, or effort delegates — inherit does not — and an entry that sets none takes the phase's default from Phases in plugins/delivery/resources/engine-contract.md. A personal overlay is not read: the map is the team's default, not what one machine runs.
| What | From |
|---|---|
| Repositories | Each root, and each of its direct children, that holds .devbook/config.json. Roots are the roots option, semicolon-separated; empty means the folder holding this repository's main checkout — D:\Repos for D:\Repos\devbook, a worktree resolved to its main checkout first. A folder under .claude/worktrees/ is never a row |
| Newest versions | The marketplace option's entry in <profile>/plugins/known_marketplaces.json, else <profile>/plugins/marketplaces/<name>, then its .claude-plugin/marketplace.json; <profile> is CLAUDE_CONFIG_DIR or ~/.claude. Not found, cells are grey and nothing is graded |
Set either option with /config, or under pluginConfigs["devbook-config-view"].options in settings.
A hooks module reads no other plugin's code, so three tables in hooks/register.tsx mirror their owners and change with them: the stamp keys and their plugins, the folded stamps, and the retired keys are plugins/devbook-config/scripts/report.mjs's; the default mode of each phase is the Phases table in plugins/delivery/resources/engine-contract.md.
types/index.d.ts declares the plugin's $.state — rollout, view, selected under devbook-config-view — and the manifest's types key points at it. The .claude-plugin/types/ folder tsconfig.json extends is written by the host and is ignored.
hooks/register.tsx 557 lines1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2
3import type { Distance, FlowMap, GateLine, PhaseLink, PhaseMode, RepoRow, Rollout, StampCell } from '../types'
4
5const PANE = 'devbook-config-view'
6const TITLE = 'Rollout'
7
8const ROLLOUT = { plugin: 'devbook-config-view', key: 'rollout' } as const
9const VIEW = { plugin: 'devbook-config-view', key: 'view' } as const
10const SELECTED = { plugin: 'devbook-config-view', key: 'selected' } as const
11
12// ── What the stack defines, repeated from where it is owned ──────────────────
13// A hooks module reads no other plugin's code, so these tables mirror their sources and
14// change with them: the stamps, folds, and retired keys are devbook-config's
15// `scripts/report.mjs`; the default modes are delivery's `resources/engine-contract.md`.
16
17/** Stamp key → the plugin whose marketplace version it is measured against, in reconcile order. */
18const COMPONENTS: Record<string, string> = {
19 devbook: 'devbook',
20 derived: 'devbook-derived',
21 openspec: 'devbook-openspec',
22 delivery: 'delivery',
23 schedule: 'delivery-schedule',
24}
25
26/** A stamp whose plugin folded into another; present at all, it is retired. */
27const FOLDED_STAMPS = ['devbook-procedures']
28
29const RETIRED_BINDINGS = ['delivery.roles', 'delivery.mcp']
30const RETIRED_FLOWS = ['flow-update-packages', 'flow-project']
31const RETIRED_POLICY = ['validate.retryBudget', 'phases.verification', 'phases.workItemUpdate']
32const RETIRED_UNATTENDED = ['skip-point']
33const RETIRED_GATE_POINTS = ['session.start', 'flow.start', 'spec', 'validate', 'data.prepare', 'app.start', 'qa.run', 'deliver', 'flow.end']
34
35const FOLDERS = ['arc42', 'domain', 'tech', 'design', 'ai']
36const PROCEDURES = ['run', 'capture', 'diagnose', 'estimate', 'prototype']
37
38/** How a phase runs when its entry sets none of agent, model, effort. */
39const DEFAULT_MODE: Record<string, PhaseMode> = {
40 'phase-update-base': 'inline',
41 'phase-scope': 'fork',
42 'phase-plan': 'delegate',
43 'phase-implement': 'fork',
44 'phase-review': 'fork',
45 'phase-build-test': 'delegate',
46 'phase-verify': 'delegate',
47 'phase-spec-check': 'delegate',
48 'phase-drafting': 'delegate',
49 'phase-check-review': 'inline',
50 'phase-create-pr': 'inline',
51 'phase-report-back': 'delegate',
52 'phase-summary': 'inline',
53}
54
55// ── Reading one config ───────────────────────────────────────────────────────
56
57const parts = (v: string) => v.split(/[.+-]/).slice(0, 3).map(n => Number.parseInt(n, 10) || 0)
58
59function distance(version: string | null | undefined, newest: string | undefined): Distance {
60 if (!version || !newest) return 'unknown'
61 const [a = 0, b = 0, c = 0] = parts(version)
62 const [x = 0, y = 0, z = 0] = parts(newest)
63 if (a !== x) return a < x ? 'major' : 'ahead'
64 if (b !== y) return b < y ? 'minor' : 'ahead'
65 if (c !== z) return c < z ? 'minor' : 'ahead'
66 return 'current'
67}
68
69/** Every key the stack retired that this config still carries. */
70function retiredKeys(config: any): string[] {
71 if (!config || typeof config !== 'object') return []
72 const gates: any[] = Array.isArray(config.gates) ? config.gates : []
73 return [
74 ...('extensions' in config ? ['extensions'] : []),
75 ...RETIRED_BINDINGS.filter(k => config.bindings && k in config.bindings).map(k => `bindings["${k}"]`),
76 ...RETIRED_FLOWS.filter(f => config.phases && f in config.phases).map(f => `phases.${f}`),
77 ...RETIRED_POLICY.filter(k => config.policy && k in config.policy).map(k => `policy["${k}"]`),
78 ...gates.flatMap((g, i) => [
79 RETIRED_GATE_POINTS.includes(g?.at) ? `gates[${i}].at "${g.at}"` : null,
80 RETIRED_UNATTENDED.includes(g?.unattended) ? `gates[${i}].unattended "${g.unattended}"` : null,
81 ]).filter((k): k is string => k !== null),
82 ...FOLDED_STAMPS.filter(k => config.components && k in config.components).map(k => `components.${k}`),
83 ]
84}
85
86const list = (v: unknown): string[] =>
87 Array.isArray(v) ? v.map(x => (typeof x === 'string' ? x : String((x as any)?.skill ?? (x as any)?.id ?? ''))).filter(Boolean) : []
88
89const isSet = (v: unknown) => typeof v === 'string' && v !== 'inherit'
90
91/** One phase entry, read the way the flow-runner resolves it: agent null is inline, any of agent, model, effort delegates. */
92function phaseLink(phase: string, entry: any): PhaseLink {
93 const e = entry && typeof entry === 'object' ? entry : {}
94 const bare = phase.split(':')[0] ?? phase
95 const forcedInline = 'agent' in e && e.agent === null
96 const delegated = isSet(e.agent) || isSet(e.model) || isSet(e.effort)
97 return {
98 phase,
99 mode: forcedInline ? 'inline' : delegated ? 'delegate' : (DEFAULT_MODE[bare] ?? 'inline'),
100 isConfigured: forcedInline || delegated,
101 agent: typeof e.agent === 'string' ? e.agent : null,
102 skill: typeof e.skill === 'string' ? e.skill : null,
103 model: typeof e.model === 'string' ? e.model : null,
104 effort: typeof e.effort === 'string' ? e.effort : null,
105 mcp: e.mcp === null ? [] : e.mcp === undefined ? null : list(e.mcp),
106 app: e.app === null ? 'none' : typeof e.app === 'string' ? e.app : typeof e.app?.provider === 'string' ? e.app.provider : null,
107 before: list(e.before),
108 after: list(e.after),
109 }
110}
111
112const show = (v: unknown) => (typeof v === 'string' ? v : JSON.stringify(v))
113
114function repoRow(path: string, text: string, newest: Record<string, string>): RepoRow {
115 const name = path.split('/').pop() || path
116 const empty: RepoRow = { name, path, id: '', stamps: {}, folders: [], procedures: [], retired: [], flows: [], gates: [], policy: [], bindings: [], error: null }
117 let config: any
118 try {
119 config = JSON.parse(text)
120 } catch (err) {
121 return { ...empty, error: `does not parse: ${(err as Error).message}` }
122 }
123 const components = config?.components && typeof config.components === 'object' ? config.components : {}
124 const stamps: Record<string, StampCell> = {}
125 for (const [key, stamp] of Object.entries<any>(components)) {
126 if (typeof stamp?.pluginVersion !== 'string') continue
127 stamps[key] = { version: stamp.pluginVersion, distance: distance(stamp.pluginVersion, newest[key]) }
128 }
129 const devbook = components.devbook ?? {}
130 const procedures = list(devbook.procedures?.adopted ?? components['devbook-procedures']?.adopted)
131 const phases = config?.phases && typeof config.phases === 'object' ? config.phases : {}
132 const flows: FlowMap[] = Object.entries<any>(phases).map(([flow, map]) => ({
133 flow,
134 phases: Object.entries<any>(map ?? {}).map(([phase, entry]) => phaseLink(phase, entry)),
135 }))
136 const gates: GateLine[] = (Array.isArray(config?.gates) ? config.gates : []).map((g: any) => ({
137 at: String(g?.at ?? '?'),
138 when: String(g?.when ?? 'before'),
139 purpose: String(g?.purpose ?? 'approval'),
140 unattended: String(g?.unattended ?? 'block'),
141 }))
142 const bindings = Object.entries<any>(config?.bindings ?? {}).map(([k, v]): [string, string] => [
143 k,
144 v && typeof v === 'object' && 'provider' in v ? show(v.provider) : show(v),
145 ])
146 return {
147 ...empty,
148 id: String(config?.id ?? ''),
149 stamps,
150 folders: list(devbook.adopted),
151 procedures,
152 retired: retiredKeys(config),
153 flows,
154 gates,
155 policy: Object.entries<any>(config?.policy ?? {}).map(([k, v]): [string, string] => [k, show(v)]),
156 bindings,
157 }
158}
159
160// ── Finding the repositories and the newest versions ─────────────────────────
161
162const slashed = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '')
163
164/** The configured roots, else the folder that holds this repository's main checkout. */
165async function rootsOf($: EngineInterface, options: PluginOptions): Promise<string[]> {
166 const configured = String(options.roots ?? '').split(/[;\n]/).map(r => slashed(r.trim())).filter(Boolean)
167 if (configured.length > 0) return configured
168 const root = slashed(await $.session.root())
169 const [main = root] = root.split('/.claude/worktrees/')
170 const parent = main.slice(0, main.lastIndexOf('/'))
171 return [parent || main]
172}
173
174/** Each root itself and its direct children that carry `.devbook/config.json`; a worktree is never one. */
175async function discover($: EngineInterface, roots: string[]): Promise<string[]> {
176 const found: string[] = []
177 for (const root of roots) {
178 if (!(await $.fs.exists(root))) continue
179 const children = (await $.fs.list(root)).filter(e => e.kind === 'dir' && !e.name.startsWith('.')).map(e => `${root}/${e.name}`)
180 for (const dir of [root, ...children]) {
181 if (dir.includes('/.claude/worktrees/') || found.includes(dir)) continue
182 if (await $.fs.exists(`${dir}/.devbook/config.json`)) found.push(dir)
183 }
184 }
185 return found
186}
187
188async function readJson($: EngineInterface, path: string): Promise<any> {
189 try {
190 return (await $.fs.exists(path)) ? JSON.parse(String(await $.fs.read(path))) : null
191 } catch {
192 return null
193 }
194}
195
196/** The installed marketplace's folder: where `known_marketplaces.json` says, else its default place. */
197async function marketplaceDir($: EngineInterface, name: string): Promise<string> {
198 const config = await $.env.get('CLAUDE_CONFIG_DIR')
199 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
200 const profile = slashed(config || `${home}/.claude`)
201 const known = await readJson($, `${profile}/plugins/known_marketplaces.json`)
202 const at = known?.[name]?.installLocation
203 return typeof at === 'string' ? slashed(at) : `${profile}/plugins/marketplaces/${name}`
204}
205
206async function load($: EngineInterface, options: PluginOptions): Promise<Rollout> {
207 const name = String(options.marketplace || 'jsdotnet-devbook')
208 const dir = await marketplaceDir($, name)
209 const catalog = await readJson($, `${dir}/.claude-plugin/marketplace.json`)
210 const plugins: any[] = Array.isArray(catalog?.plugins) ? catalog.plugins : []
211 const newest: Record<string, string> = {}
212 for (const [key, plugin] of Object.entries(COMPONENTS)) {
213 const version = plugins.find(p => p?.name === plugin)?.version
214 if (typeof version === 'string') newest[key] = version
215 }
216
217 const roots = await rootsOf($, options)
218 const repos: RepoRow[] = []
219 for (const path of await discover($, roots)) {
220 const file = `${path}/.devbook/config.json`
221 let text = ''
222 try {
223 text = String(await $.fs.read(file))
224 } catch (err) {
225 repos.push({ ...repoRow(path, '{}', newest), error: `unreadable: ${(err as Error).message}` })
226 continue
227 }
228 repos.push(repoRow(path, text, newest))
229 }
230 repos.sort((a, b) => a.name.localeCompare(b.name))
231 return { repos, newest, marketplace: catalog ? dir : null, roots, loadedAt: await $.clock.now() }
232}
233
234// ── Drawing ──────────────────────────────────────────────────────────────────
235
236const DISTANCE_COLOR: Record<Distance, string> = { current: 'green', minor: 'yellow', major: 'red', ahead: 'cyan', unknown: 'gray' }
237const MODE_COLOR: Record<PhaseMode, string> = { inline: 'gray', delegate: 'cyan', fork: 'blue' }
238
239const columnsOf = (rollout: Rollout) => {
240 const extra = new Set<string>()
241 for (const repo of rollout.repos) for (const key of Object.keys(repo.stamps)) if (!(key in COMPONENTS)) extra.add(key)
242 return [...Object.keys(COMPONENTS), ...[...extra].sort()]
243}
244
245/** The prompt a row's button puts in the box; never sent. */
246const promptFor = (skill: 'doctor' | 'update', repo: RepoRow) =>
247 `/devbook-config:${skill} for the repository at ${repo.path.replace(/\//g, '\\')}`
248
249const short = (phase: string) => phase.replace(/^phase-/, '')
250
251/** A stamp column is as wide as its name, never narrower than a version. */
252const widthOf = (column: string) => Math.max(10, column.length + 2)
253
254async function refresh($: EngineInterface, options: PluginOptions) {
255 await $.state.set(ROLLOUT, await load($, options))
256}
257
258async function fill($: EngineInterface, text: string) {
259 const done = await $.prompt.fill({ text })
260 $.ui.toast(done.isFilled ? 'Prompt filled — review it and press Enter to run it.' : 'The prompt box is busy; nothing was filled.')
261}
262
263export const register: Register = (on, options) => {
264 on('session.start', async ($, e, next) => {
265 await $.command.register({
266 name: 'rollout',
267 description: 'Show which devbook stack version every repository on this machine is on, and how one is configured',
268 })
269 return next(e)
270 })
271
272 on('command.run', { command: 'rollout' }, async $ => {
273 await refresh($, options)
274 await $.ui.open({ id: PANE, title: TITLE })
275 const { value } = await $.state.get(ROLLOUT)
276 return { text: `Rollout pane opened: ${value?.repos.length ?? 0} repositories.` }
277 })
278
279 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
280 const { Box, Text, Button } = $.ui.resolve(e)
281 const { value: rollout } = await $.state.get(ROLLOUT)
282 const view = (await $.state.get(VIEW)).value ?? 'matrix'
283 const columns = (e.props as any)?.bodyColumns ?? e.viewport?.columns ?? 80
284 const isWide = columns >= 100
285
286 const toolbar = (
287 <Box flexDirection="row" gap={2} marginBottom={1}>
288 <Button key="matrix" plain onPress={() => $.state.set(VIEW, 'matrix')}>
289 {view === 'matrix' ? '[matrix]' : 'matrix'}
290 </Button>
291 <Button key="config" plain onPress={() => $.state.set(VIEW, 'config')}>
292 {view === 'config' ? '[config map]' : 'config map'}
293 </Button>
294 <Button key="refresh" plain onPress={() => refresh($, options)}>
295 ↻ refresh
296 </Button>
297 </Box>
298 )
299
300 if (!rollout) {
301 return (
302 <Box flexDirection="column">
303 {toolbar}
304 <Text dimColor>Nothing read yet. Press refresh.</Text>
305 </Box>
306 )
307 }
308 if (rollout.repos.length === 0) {
309 return (
310 <Box flexDirection="column">
311 {toolbar}
312 <Text dimColor>No repository with .devbook/config.json under {rollout.roots.join(', ')}.</Text>
313 <Text dimColor>Set the plugin's roots option to look elsewhere.</Text>
314 </Box>
315 )
316 }
317
318 const index = Math.min((await $.state.get(SELECTED)).value ?? 0, rollout.repos.length - 1)
319 const newest = Object.entries(rollout.newest).map(([k, v]) => `${k} ${v}`).join(' · ')
320 const header = (
321 <Text dimColor wrap="truncate-end">
322 {rollout.repos.length} repositories under {rollout.roots.join(', ')} ·{' '}
323 {rollout.marketplace ? `newest ${newest}` : 'marketplace not found: versions not compared'}
324 </Text>
325 )
326
327 if (view === 'config') return configView($, Box, Text, Button, toolbar, rollout.repos, index, columns)
328
329 const cols = columnsOf(rollout)
330 const dots = (all: string[], have: string[]) =>
331 all.map(f => (
332 <Text color={have.includes(f) ? 'green' : 'gray'} dimColor={!have.includes(f)}>
333 {have.includes(f) ? '●' : '·'}
334 </Text>
335 ))
336
337 return (
338 <Box flexDirection="column">
339 {toolbar}
340 {header}
341 <Box flexDirection="row" gap={2} marginBottom={1}>
342 <Text color="green">current</Text>
343 <Text color="yellow">minor behind</Text>
344 <Text color="red">major behind</Text>
345 <Text dimColor>● folders {FOLDERS.join(' ')} · procedures {PROCEDURES.join(' ')}</Text>
346 </Box>
347 <Box flexDirection="row">
348 <Box width={18}>
349 <Text bold>repository</Text>
350 </Box>
351 {cols.map(c => (
352 <Box width={widthOf(c)}>
353 <Text bold wrap="truncate-end">
354 {c}
355 </Text>
356 </Box>
357 ))}
358 {isWide && (
359 <Box width={16}>
360 <Text bold>folders · procs</Text>
361 </Box>
362 )}
363 </Box>
364 {rollout.repos.map((repo, i) => {
365 const cells = cols.map(c => {
366 const cell = repo.stamps[c]
367 return (
368 <Box width={widthOf(c)}>
369 <Text color={cell ? DISTANCE_COLOR[cell.distance] : 'gray'} dimColor={!cell}>
370 {cell?.version ?? '–'}
371 </Text>
372 </Box>
373 )
374 })
375 const marks = (
376 <Box flexDirection="row" width={16}>
377 {dots(FOLDERS, repo.folders)}
378 <Text> </Text>
379 {dots(PROCEDURES, repo.procedures)}
380 </Box>
381 )
382 const actions = (
383 <Box flexDirection="row" gap={1}>
384 <Button key={`doctor-${i}`} plain onPress={() => fill($, promptFor('doctor', repo))}>
385 doctor
386 </Button>
387 <Button key={`update-${i}`} plain onPress={() => fill($, promptFor('update', repo))}>
388 update
389 </Button>
390 <Button
391 key={`map-${i}`}
392 plain
393 onPress={async () => {
394 await $.state.set(SELECTED, i)
395 await $.state.set(VIEW, 'config')
396 }}
397 >
398 map
399 </Button>
400 </Box>
401 )
402 return (
403 <Box key={`r${i}`} flexDirection="column">
404 <Box flexDirection="row">
405 <Box width={18}>
406 <Text color={repo.error || repo.retired.length ? 'red' : undefined} wrap="truncate-end">
407 {repo.name}
408 </Text>
409 </Box>
410 {cells}
411 {isWide && marks}
412 {isWide && actions}
413 </Box>
414 {!isWide && (
415 <Box flexDirection="row" gap={2} marginLeft={2}>
416 {marks}
417 {actions}
418 </Box>
419 )}
420 {repo.error && (
421 <Text color="red" wrap="truncate-end">
422 {' '}✗ {repo.error}
423 </Text>
424 )}
425 {repo.retired.length > 0 && (
426 <Text color="red" wrap="truncate-end">
427 {' '}✗ retired: {repo.retired.join(', ')}
428 </Text>
429 )}
430 </Box>
431 )
432 })}
433 </Box>
434 )
435 })
436}
437
438/** One repository's wiring: each flow's phases as a chain, with the gates where they sit, then policy and bindings. */
439function configView($: EngineInterface, Box: any, Text: any, Button: any, toolbar: unknown, repos: RepoRow[], index: number, columns: number) {
440 // Two flows side by side once each column still fits a phase row; stacked below that.
441 const isSideBySide = columns >= 110 && (repos[index]?.flows.length ?? 0) > 1
442 const repo = repos[index]!
443 const step = (by: number) => $.state.set(SELECTED, (index + by + repos.length) % repos.length)
444 const who = (p: PhaseLink) =>
445 [p.agent, [p.model, p.effort].filter(Boolean).join('/'), p.skill ? `skill ${p.skill}` : '', p.mcp === null ? '' : `mcp ${p.mcp.join(', ') || 'none'}`, p.app ? `app ${p.app}` : '']
446 .filter(Boolean)
447 .join(' · ')
448 const chores = (p: PhaseLink) =>
449 [p.before.length ? `before ${p.before.join(', ')}` : '', p.after.length ? `after ${p.after.join(', ')}` : ''].filter(Boolean).join(' · ')
450 const gateAt = (phase: string, when: string) =>
451 repo.gates.filter(g => g.at === short(phase.split(':')[0] ?? phase) && g.when === when).map(g => (
452 <Text color="magenta" wrap="truncate-end">
453 ◆ gate {g.when} {g.at}: {g.purpose} · unattended {g.unattended}
454 </Text>
455 ))
456 const fixed = (name: string, isGate: boolean) => (
457 <Text color={isGate ? 'magenta' : 'gray'} wrap="truncate-end">
458 {isGate ? '◆' : '○'} {name} <Text dimColor>· always inline, never configured</Text>
459 </Text>
460 )
461
462 return (
463 <Box flexDirection="column">
464 {toolbar as any}
465 <Box flexDirection="row" justifyContent="space-between">
466 <Text bold wrap="truncate-end">
467 {repo.name}
468 {repo.id && repo.id !== repo.name ? <Text dimColor> · {repo.id}</Text> : ''}
469 </Text>
470 <Box flexDirection="row" gap={1} flexShrink={0}>
471 <Button key="prev" plain onPress={() => step(-1)}>
472 ‹
473 </Button>
474 <Text dimColor>
475 {index + 1}/{repos.length}
476 </Text>
477 <Button key="next" plain onPress={() => step(1)}>
478 ›
479 </Button>
480 </Box>
481 </Box>
482 <Box flexDirection="row" gap={2} marginBottom={1}>
483 <Text color={MODE_COLOR.inline}>○ inline</Text>
484 <Text color={MODE_COLOR.delegate}>▶ delegate</Text>
485 <Text color={MODE_COLOR.fork}>⑂ fork</Text>
486 <Text color="magenta">◆ gate</Text>
487 <Text dimColor>dim = the phase's default</Text>
488 </Box>
489 {repo.retired.length > 0 && (
490 <Text color="red" wrap="wrap">
491 ✗ retired keys: {repo.retired.join(', ')} — devbook-config:update migrates them
492 </Text>
493 )}
494 {repo.flows.length === 0 && <Text dimColor>No phase maps: the engine is not configured here.</Text>}
495 <Box flexDirection={isSideBySide ? 'row' : 'column'} gap={isSideBySide ? 4 : 0}>
496 {repo.flows.map(flow => (
497 <Box key={flow.flow} flexDirection="column" marginBottom={1} flexShrink={1} width={isSideBySide ? '50%' : undefined}>
498 <Text bold>{flow.flow}</Text>
499 {flow.phases.map((p, i) => {
500 const isLast = i === flow.phases.length - 1
501 const extra = chores(p)
502 return (
503 <Box key={`${flow.flow}-${p.phase}`} flexDirection="column">
504 {p.phase === 'phase-create-pr' && fixed('ready', false)}
505 {p.phase === 'phase-create-pr' && fixed('personal-validation', true)}
506 {gateAt(p.phase, 'before')}
507 <Box flexDirection="row" gap={1}>
508 <Text color={MODE_COLOR[p.mode]}>{p.mode === 'inline' ? '○' : p.mode === 'fork' ? '⑂' : '▶'}</Text>
509 <Box width={18}>
510 <Text wrap="truncate-end">{short(p.phase)}</Text>
511 </Box>
512 <Box width={9}>
513 <Text color={MODE_COLOR[p.mode]} dimColor={!p.isConfigured}>
514 {p.mode}
515 </Text>
516 </Box>
517 <Text dimColor wrap="truncate-end">
518 {who(p)}
519 </Text>
520 </Box>
521 {extra && (
522 <Text dimColor wrap="truncate-end">
523 {isLast ? ' ' : '│'} {' '}
524 {extra}
525 </Text>
526 )}
527 {gateAt(p.phase, 'after')}
528 </Box>
529 )
530 })}
531 </Box>
532 ))}
533 </Box>
534 {repo.policy.length > 0 && (
535 <Box flexDirection="column" marginBottom={1}>
536 <Text bold>policy</Text>
537 {repo.policy.map(([k, v]) => (
538 <Text wrap="truncate-end">
539 <Text dimColor>{k}</Text> {v}
540 </Text>
541 ))}
542 </Box>
543 )}
544 {repo.bindings.length > 0 && (
545 <Box flexDirection="column">
546 <Text bold>bindings</Text>
547 {repo.bindings.map(([k, v]) => (
548 <Text wrap="truncate-end">
549 <Text dimColor>{k}</Text> {v}
550 </Text>
551 ))}
552 </Box>
553 )}
554 </Box>
555 )
556}
557types/index.d.ts 61 lines1/** How far a stamped version is from the newest the installed marketplace lists. */
2export type Distance = 'current' | 'minor' | 'major' | 'ahead' | 'unknown'
3
4/** One `components.<name>` stamp, read as the matrix shows it. */
5export type StampCell = { version: string | null; distance: Distance }
6
7/** How a phase runs, derived from its map entry the way the flow-runner resolves it. */
8export type PhaseMode = 'inline' | 'delegate' | 'fork'
9
10export type PhaseLink = {
11 phase: string
12 mode: PhaseMode
13 /** True when the entry chose the mode; false when it is the phase's default. */
14 isConfigured: boolean
15 agent: string | null
16 skill: string | null
17 model: string | null
18 effort: string | null
19 mcp: string[] | null
20 /** `phase-verify` only: the provider that starts the app, `none` for `app: null`, null when unset. */
21 app: string | null
22 before: string[]
23 after: string[]
24}
25
26export type FlowMap = { flow: string; phases: PhaseLink[] }
27
28export type GateLine = { at: string; when: string; purpose: string; unattended: string }
29
30export type RepoRow = {
31 name: string
32 path: string
33 id: string
34 stamps: Record<string, StampCell>
35 folders: string[]
36 procedures: string[]
37 /** Every key the stack retired that this config still carries, as `extensions` or `bindings["delivery.roles"]`. */
38 retired: string[]
39 flows: FlowMap[]
40 gates: GateLine[]
41 policy: [string, string][]
42 bindings: [string, string][]
43 /** Set when the file does not parse. */
44 error: string | null
45}
46
47export type Rollout = {
48 repos: RepoRow[]
49 /** Newest version per stamp key, from the installed marketplace. */
50 newest: Record<string, string>
51 marketplace: string | null
52 roots: string[]
53 loadedAt: number
54}
55
56declare module 'claude-code' {
57 interface PluginState {
58 'devbook-config-view': { rollout: Rollout | null; view: 'matrix' | 'config'; selected: number }
59 }
60}
61