SLOPSHOPPER

devbook-config-view

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…

newpanecommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · devbook-config-view
│ ┃ Rollout ✕ › fix the failing auth test and add an audit log call │ ┃ [matrix] config map ↻ refresh │ ┃ ⏺ Read(src/auth.ts) │ ┃ No repository with .devbook/config.json ⎿ Read 6 lines │ ┃ under /work. ⏺ Update(src/auth.ts) │ ┃ Set the plugin's roots option to look ⎿ Added 2 lines, removed 1 line │ ┃ elsewhere. ⏺ 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 │ │ › /rollout │ ⎿ devbook-config-view: Rollout pane opened: 0 repositories. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Rollout
[matrix] config map ↻ refresh No repository with .devbook/config.json under /work. Set the plugin's roots option to look elsewhere.
README

devbook-config-view

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.

Installation

claude plugin marketplace add JSdotNet/devbook

Then enable devbook-config-view with /plugin, and type /rollout.

Claude Code only

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.

What it draws

/rollout reads every repository and opens the pane. It reads again only on ↻ refresh: nothing polls.

ViewShows
MatrixOne 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 mapOne 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.

Where it reads

WhatFrom
RepositoriesEach 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 versionsThe 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.

What it repeats

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.

The state contract

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.

Source 2 files
hooks/register.tsx 557 lines
1import 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}
557
types/index.d.ts 61 lines
1/** 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