rvlib-shipkit 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts CHANGED
@@ -10,6 +10,7 @@ import { CheckCommand } from './cli/cmd_Check.ts'
10
10
  import { CiCommand } from './cli/cmd_Ci.ts'
11
11
  import { ContractsCommand } from './cli/cmd_Contracts.ts'
12
12
  import { InitCommand } from './cli/cmd_Init.ts'
13
+ import { ServeCommand } from './cli/cmd_Serve.ts'
13
14
  import { TuiCommand } from './cli/cmd_Tui.ts'
14
15
  import { WikiLintCommand, WikiServeCommand } from './cli/cmd_Wiki.ts'
15
16
 
@@ -28,6 +29,7 @@ cli.register(Builtins.VersionCommand)
28
29
  cli.register(CiCommand)
29
30
  cli.register(CheckCommand)
30
31
  cli.register(TuiCommand)
32
+ cli.register(ServeCommand)
31
33
  cli.register(ContractsCommand)
32
34
  cli.register(AsksCommand)
33
35
  cli.register(AsksCheckCommand)
package/src/init-repo.ts CHANGED
@@ -54,6 +54,16 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
54
54
  const floor = resolveFloor(defineRepo({ name }), root)
55
55
  for (const t of floor.tasks) console.log(`ok: floor ${t.id} ← ${floor.sources[t.id] ?? 'unknown'}`)
56
56
  for (const u of floor.unfilled) console.log(`skip: ${u.id} (${u.reason})`)
57
+ // scripts call the `shipkit` bin — dead on arrival unless the package is
58
+ // actually installed (field bug 2026-07-22: init via bunx, no dep, exit 127)
59
+ if (opts.bin == null) {
60
+ try {
61
+ Bun.resolveSync('rvlib-shipkit', root)
62
+ } catch {
63
+ console.log('WARN rvlib-shipkit not installed here — the scripts above call its bin and will exit 127')
64
+ console.log(' fix: bun add -d rvlib-shipkit')
65
+ }
66
+ }
57
67
  console.log('next: fill venues/social in repo.config.ts, then `bun run ci`')
58
68
  console.log('next: identity+assets — mint .rv-identity/brand.ts + .rv-assets/kit.ts, `shipkit assets identity` (the identity contract expects public/ favicon+og+logo)')
59
69
  return 0
@@ -17,6 +17,19 @@ import type { ContractManifest } from './contracts/contract-types.ts'
17
17
  import type { TaskDef } from './core/task-runner.ts'
18
18
  import type { CheckCtx } from './core/check-ctx.ts'
19
19
  import type { TuiPanelDef } from './tui/tui-panel.ts'
20
+ import type { MdGraph } from './wiki/md-graph.ts'
21
+
22
+ // `shipkit serve` — the ONE hub server per repo (run-serve.ts resolves the
23
+ // rest by convention: agent/ graph when the dir exists, studio when the
24
+ // kit loads)
25
+ export type ServeConfig = {
26
+ /** hub port — house range 4800+; default hashes the repo name into 4800-4979 */
27
+ port?: number
28
+ /** custom wiki-graph provider for repos with their own link semantics; default: generic md scan of the repo root */
29
+ wikiGraph?: { title?: string; load: () => MdGraph }
30
+ /** agent/ graph entrypoint (default: index.md AND README.md) */
31
+ agentIndex?: string
32
+ }
20
33
 
21
34
  export type RepoConfig = {
22
35
  name: string
@@ -35,10 +48,12 @@ export type RepoConfig = {
35
48
  contracts?: { exempt?: Record<string, string>; extra?: ContractManifest[] }
36
49
  /** extra dashboard panels appended after the built-in Tasks/Checks (`shipkit tui`) */
37
50
  tui?: { panels?: TuiPanelDef[] }
51
+ /** the `shipkit serve` hub: port + optional graph hooks */
52
+ serve?: ServeConfig
38
53
  logDir?: string
39
54
  }
40
55
 
41
- const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'contracts', 'tui', 'logDir'])
56
+ const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'contracts', 'tui', 'serve', 'logDir'])
42
57
 
43
58
  export function defineRepo(c: RepoConfig): RepoConfig {
44
59
  if (typeof c.name !== 'string' || c.name === '') throw new Error('defineRepo: name is required')
@@ -0,0 +1,84 @@
1
+ // The hub's two no-build pages (served by serve-hub.ts): the landing page
2
+ // whose links ARE the toolbox, and the /status page (STATUS.md + a
3
+ // "run check" button streaming wire lines). Same style family as the
4
+ // studio: one template string, vanilla JS, no build step, no CDN.
5
+ import type { HubTool } from './serve-hub.ts'
6
+
7
+ const esc = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;')
8
+
9
+ const SHELL = `
10
+ :root{--bg:#141210;--panel:#1e1a15;--panel2:#262019;--line:#332b22;--ink:#f3ebe0;--dim:#a89a88;
11
+ --accent:#e88c28;--gold:#ffcf5c;--teal:#58c0b0;--red:#e05252}
12
+ *{box-sizing:border-box}
13
+ body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 system-ui,sans-serif;min-height:100vh}
14
+ .wrap{max-width:860px;margin:0 auto;padding:40px 24px}
15
+ h1{font-size:20px;margin:0}
16
+ h1 small{color:var(--dim);font-weight:400;font-size:13px;margin-left:10px}
17
+ .crumb{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--accent);margin-bottom:6px}
18
+ .crumb a{color:var(--accent);text-decoration:none}.crumb a:hover{color:var(--gold)}
19
+ `
20
+
21
+ export function landingPage(o: { name: string; port: number; tools: HubTool[] }): string {
22
+ const cards = o.tools
23
+ .map((t) => `<a class=card href="${esc(t.path)}"><b>${esc(t.label)}</b><span>${esc(t.desc)}</span><code>${esc(t.path)}</code></a>`)
24
+ .join('\n')
25
+ return `<!doctype html><html><head><meta charset=utf-8>
26
+ <meta name=viewport content="width=device-width,initial-scale=1"><title>${esc(o.name)} · shipkit</title>
27
+ <style>${SHELL}
28
+ .cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px;margin-top:26px}
29
+ .card{display:flex;flex-direction:column;gap:6px;background:var(--panel);border:1px solid var(--line);border-radius:12px;
30
+ padding:16px;text-decoration:none;color:var(--ink)}
31
+ .card:hover{border-color:var(--accent);background:var(--panel2)}
32
+ .card b{font-size:15px}.card span{font-size:13px;color:var(--dim)}
33
+ .card code{font:11px ui-monospace,Menlo,monospace;color:var(--teal)}
34
+ </style></head><body><div class=wrap>
35
+ <div class=crumb>shipkit hub</div>
36
+ <h1>${esc(o.name)}<small>http://localhost:${o.port}</small></h1>
37
+ <div class=cards>
38
+ ${cards}
39
+ </div>
40
+ </div></body></html>`
41
+ }
42
+
43
+ export function statusPage(o: { name: string; statusMd: string | null }): string {
44
+ const body = o.statusMd ?? 'no STATUS.md yet — hit "run check" (or run: shipkit check)'
45
+ return `<!doctype html><html><head><meta charset=utf-8>
46
+ <meta name=viewport content="width=device-width,initial-scale=1"><title>${esc(o.name)} · status</title>
47
+ <style>${SHELL}
48
+ .bar{display:flex;align-items:center;gap:12px;margin:18px 0 12px}
49
+ button{background:var(--accent);color:#1a1206;border:0;border-radius:9px;padding:8px 14px;font-weight:650;cursor:pointer}
50
+ button:disabled{opacity:.45;cursor:default}
51
+ #note{font-size:12px;color:var(--dim)}
52
+ pre{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px;overflow-x:auto;
53
+ font:12.5px/1.55 ui-monospace,Menlo,monospace;white-space:pre-wrap}
54
+ .ok{color:var(--teal)}.warn{color:var(--gold)}.fail{color:var(--red)}.dim{color:var(--dim)}
55
+ </style></head><body><div class=wrap>
56
+ <div class=crumb><a href="/">shipkit hub</a> / status</div>
57
+ <h1>${esc(o.name)}<small>STATUS.md</small></h1>
58
+ <div class=bar><button id=run>run check</button><span id=note>shells <code>shipkit check</code>, wire lines stream in below</span></div>
59
+ <pre id=out></pre>
60
+ <script>
61
+ const out=document.getElementById('out'),btn=document.getElementById('run'),note=document.getElementById('note')
62
+ // wire-line coloring: ok/WARN/FAIL/skip prefixes → the house palette
63
+ const esc=s=>s.replace(/&/g,'&amp;').replace(/</g,'&lt;')
64
+ const cls=l=>{const t=l.trimStart()
65
+ if(t.startsWith('FAIL')||t.startsWith('✗'))return 'fail'
66
+ if(t.startsWith('WARN')||t.startsWith('⚠'))return 'warn'
67
+ if(t.startsWith('ok')||t.startsWith('✓'))return 'ok'
68
+ if(t.startsWith('skip')||t.startsWith('fix:')||t.startsWith('#'))return 'dim'
69
+ return ''}
70
+ const paint=text=>{out.innerHTML=text.split('\\n').map(l=>'<span class="'+cls(l)+'">'+esc(l)+'</span>').join('\\n')}
71
+ paint(${JSON.stringify(body).replaceAll('<', '\\u003c')})
72
+ btn.onclick=async()=>{
73
+ btn.disabled=true;note.textContent='running…';let text=''
74
+ try{
75
+ const r=await fetch('status/run',{method:'POST'})
76
+ const rd=r.body.getReader(),dec=new TextDecoder()
77
+ for(;;){const{done,value}=await rd.read();if(done)break;text+=dec.decode(value,{stream:true});paint(text)}
78
+ note.textContent='done — STATUS.md refreshed on disk'
79
+ }catch(e){text+='\\nFAIL stream broke: '+e;paint(text);note.textContent='stream broke'}
80
+ btn.disabled=false
81
+ }
82
+ </script>
83
+ </div></body></html>`
84
+ }
@@ -0,0 +1,85 @@
1
+ // shipkit serve — resolve the repo's toolbox and run the hub. What mounts
2
+ // comes from convention + repo.config's `serve` block: the wiki graph is
3
+ // always on (custom provider via serve.wikiGraph, else the generic md
4
+ // scan), agent/ graph when the dir exists, the studio when .rv-assets/
5
+ // kit.ts loads. Port: --port > serve.port > a deterministic house-range
6
+ // default (4800-4979, hashed from the repo name — stable across launches;
7
+ // pin serve.port when two repos collide).
8
+ import { existsSync } from 'fs'
9
+ import { join } from 'path'
10
+ import { fileURLToPath } from 'url'
11
+ import { loadKit } from '../assets/kit-config.ts'
12
+ import { page as studioPage } from '../assets/server/studio-page.ts'
13
+ import { studioRoutes, type StudioRoutes } from '../assets/server/studio-server.ts'
14
+ import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
15
+ import { scanMdGraph } from '../wiki/scan-md.ts'
16
+ import { serveHub, type HubDeps } from './serve-hub.ts'
17
+
18
+ export type ServeIo = { print: (l: string) => void; printErr: (l: string) => void }
19
+
20
+ export function defaultHubPort(name: string): number {
21
+ let h = 0
22
+ for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0
23
+ return 4800 + (h % 180)
24
+ }
25
+
26
+ export async function resolveHubDeps(root: string, c: RepoConfig, io: ServeIo, o?: { port?: number }): Promise<HubDeps> {
27
+ const wikiGraph = c.serve?.wikiGraph != null
28
+ ? { title: c.serve.wikiGraph.title ?? `${c.name} wiki`, load: c.serve.wikiGraph.load }
29
+ : { title: `${c.name} wiki`, load: () => scanMdGraph({ root }) }
30
+ const agentRoot = join(root, 'agent')
31
+ const agentGraph = existsSync(agentRoot)
32
+ ? { title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }) }
33
+ : null
34
+ let studio: StudioRoutes | null = null
35
+ if (existsSync(join(root, '.rv-assets/kit.ts'))) {
36
+ // a broken kit must not take the graphs and status down with it —
37
+ // WARN + mount everything else
38
+ try {
39
+ studio = await studioRoutes({ kit: await loadKit(root), root, page: studioPage })
40
+ } catch (e) {
41
+ io.print(`WARN assets studio not mounted: ${(e as Error).message}`)
42
+ }
43
+ }
44
+ const cli = fileURLToPath(new URL('../cli.ts', import.meta.url))
45
+ return {
46
+ root,
47
+ name: c.name,
48
+ port: o?.port ?? c.serve?.port ?? defaultHubPort(c.name),
49
+ wikiGraph,
50
+ agentGraph,
51
+ studio,
52
+ checkCmd: [process.execPath, cli, 'check'],
53
+ }
54
+ }
55
+
56
+ export async function runServe(root: string, io: ServeIo, o?: { port?: number }): Promise<number> {
57
+ const c = await loadRepoConfig(root)
58
+ const deps = await resolveHubDeps(root, c, io, o)
59
+ try {
60
+ const hub = serveHub(deps)
61
+ const base = `http://localhost:${hub.server.port}`
62
+ io.print(`ok: ${c.name} hub → ${base}`)
63
+ for (const t of hub.tools) io.print(`ok: ${t.label} → ${base}${t.path}`)
64
+ await new Promise(() => {}) // the hub owns the process — never resolve
65
+ return 0
66
+ } catch (e) {
67
+ if (e instanceof Error && e.message.includes('in use')) {
68
+ // same repo in another window is fine; a different repo hashed
69
+ // onto this port is a real collision — never claim its hub
70
+ const owner = await fetch(`http://127.0.0.1:${deps.port}/hub.json`, { signal: AbortSignal.timeout(1500) })
71
+ .then((r) => r.json() as Promise<{ hub?: string; name?: string; tools?: { path: string; label: string }[] }>)
72
+ .catch(() => null)
73
+ if (owner?.hub === 'shipkit' && owner.name === c.name) {
74
+ // a re-run terminal stays as clickable as a first run — the
75
+ // owner's /hub.json tool list is the same source of truth
76
+ io.print(`ok: hub already served → http://localhost:${deps.port} (another window owns it)`)
77
+ for (const t of owner.tools ?? []) io.print(`ok: ${t.label} → http://localhost:${deps.port}${t.path}`)
78
+ return 0
79
+ }
80
+ io.printErr(`FAIL: port ${deps.port} is taken by ${owner?.name != null ? `repo '${owner.name}'` : 'another process'} — set serve: { port } in repo.config.ts to a free one`)
81
+ return 1
82
+ }
83
+ throw e
84
+ }
85
+ }
@@ -0,0 +1,77 @@
1
+ // shipkit serve — ONE hub server per repo, ONE port (his call 2026-07-22:
2
+ // "a single shipkit task in vscode with multiple urls I can click"). The
3
+ // landing page's links are the whole toolbox: /graph/wiki · /graph/agent ·
4
+ // /assets/ (the studio, mounted IN-PROCESS) · /status (STATUS.md + run
5
+ // check). Route logic only — resolving WHAT to mount from repo.config is
6
+ // run-serve.ts's job.
7
+ import { existsSync } from 'fs'
8
+ import { join } from 'path'
9
+ import { graphHtml } from 'rvlib-shipkit/src/wiki/graph-page.ts'
10
+ import type { MdGraph } from 'rvlib-shipkit/src/wiki/md-graph.ts'
11
+ import type { StudioRoutes } from '../assets/server/studio-server.ts'
12
+ import { landingPage, statusPage } from './hub-pages.ts'
13
+
14
+ export type HubGraph = { title: string; load: () => MdGraph }
15
+ export type HubTool = { path: string; label: string; desc: string }
16
+ export type HubDeps = {
17
+ root: string
18
+ name: string
19
+ /** 0 = random (tests); real launches pass repo.config/default */
20
+ port: number
21
+ wikiGraph: HubGraph
22
+ /** null = no agent/ dir */
23
+ agentGraph: HubGraph | null
24
+ /** null = no .rv-assets/kit.ts (or it failed to load — caller prints the WARN) */
25
+ studio: StudioRoutes | null
26
+ /** argv shelling the check run streamed by /status/run (test seam) */
27
+ checkCmd: string[]
28
+ }
29
+ export type Hub = { server: ReturnType<typeof Bun.serve>; tools: HubTool[]; stop: () => void }
30
+
31
+ // the toolbox as data — the landing page, /hub.json, and the stdout ok:
32
+ // lines all render THIS list, so they can never disagree
33
+ export function hubTools(d: Pick<HubDeps, 'wikiGraph' | 'agentGraph' | 'studio'>): HubTool[] {
34
+ const tools: HubTool[] = [{ path: '/graph/wiki', label: 'wiki graph', desc: d.wikiGraph.title }]
35
+ if (d.agentGraph != null) tools.push({ path: '/graph/agent', label: 'agent graph', desc: d.agentGraph.title })
36
+ if (d.studio != null) tools.push({ path: '/assets/', label: 'assets studio', desc: 'roll, curate, pick, ship' })
37
+ tools.push({ path: '/status', label: 'status', desc: 'STATUS.md + run check' })
38
+ return tools
39
+ }
40
+
41
+ export function serveHub(d: HubDeps): Hub {
42
+ const tools = hubTools(d)
43
+ const html = (s: string): Response => new Response(s, { headers: { 'content-type': 'text/html; charset=utf-8' } })
44
+ const server: ReturnType<typeof Bun.serve> = Bun.serve({
45
+ hostname: '127.0.0.1',
46
+ port: d.port,
47
+ async fetch(req): Promise<Response> {
48
+ const path = new URL(req.url).pathname
49
+ if (path === '/') return html(landingPage({ name: d.name, port: server.port ?? d.port, tools }))
50
+ // identity probe: lets a second launch tell "same repo, another
51
+ // window" from "a DIFFERENT repo hashed onto my port"
52
+ if (path === '/hub.json') return Response.json({ hub: 'shipkit', name: d.name, tools })
53
+ if (path === '/graph/wiki') return html(graphHtml(d.wikiGraph.load(), { title: d.wikiGraph.title }))
54
+ if (path === '/graph/wiki/graph.json') return Response.json(d.wikiGraph.load())
55
+ if (d.agentGraph != null && path === '/graph/agent') return html(graphHtml(d.agentGraph.load(), { title: d.agentGraph.title }))
56
+ if (d.agentGraph != null && path === '/graph/agent/graph.json') return Response.json(d.agentGraph.load())
57
+ if (path === '/status') {
58
+ const p = join(d.root, 'STATUS.md')
59
+ return html(statusPage({ name: d.name, statusMd: existsSync(p) ? await Bun.file(p).text() : null }))
60
+ }
61
+ if (path === '/status/run' && req.method === 'POST') {
62
+ // stream wire lines as the check emits them; sh merges stderr
63
+ // so FAIL lines reach the page, not just the terminal
64
+ const proc = Bun.spawn(['sh', '-c', 'exec "$0" "$@" 2>&1', ...d.checkCmd], { cwd: d.root, stdout: 'pipe', stderr: 'ignore' })
65
+ return new Response(proc.stdout, { headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } })
66
+ }
67
+ if (d.studio != null) {
68
+ // the studio page fetches RELATIVE urls — it must live under
69
+ // the slash-terminated prefix for them to resolve
70
+ if (path === '/assets') return new Response(null, { status: 307, headers: { location: '/assets/' } })
71
+ if (path.startsWith('/assets/')) return d.studio.handle(req, path.slice('/assets'.length))
72
+ }
73
+ return new Response('not found', { status: 404 })
74
+ },
75
+ })
76
+ return { server, tools, stop: () => server.stop(true) }
77
+ }
package/src/shipkit.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  // rvlib-shipkit — barrel. One import per repo: defineRepo in repo.config.ts;
2
2
  // everything else is the machinery behind the shipkit CLI.
3
3
  export { defineRepo, loadRepoConfig, resolveFloor, allTasks, contractsTask, contractsConfigFrom, activeContracts } from './repo-config.ts'
4
- export type { RepoConfig, FloorResolution, FloorSource } from './repo-config.ts'
4
+ export type { RepoConfig, ServeConfig, FloorResolution, FloorSource } from './repo-config.ts'
5
+ // the hub (`shipkit serve`): one server, one port, every tool a click away
6
+ export { serveHub, hubTools } from './serve/serve-hub.ts'
7
+ export type { Hub, HubDeps, HubGraph, HubTool } from './serve/serve-hub.ts'
8
+ export { runServe, resolveHubDeps, defaultHubPort } from './serve/run-serve.ts'
9
+ export { landingPage, statusPage } from './serve/hub-pages.ts'
5
10
  export { discoverChecks } from './core/discover-checks.ts'
6
11
  export type { DiscoveredCheck } from './core/discover-checks.ts'
7
12
  export { runTasks, selectTasks } from './core/task-runner.ts'
@@ -29,11 +34,11 @@ export type { Ask, CreateOpts } from './asks/asks-queue.ts'
29
34
  // package is frozen). A repo's .rv-assets/kit.ts imports defineKit and
30
35
  // declares its kinds; `shipkit assets …` derives the rest.
31
36
  export { defineKit, defineKind, comfy, canvas, geometryPanel, gridPanel, customPanel, validateKit, missingPanelScripts, loadKit, findAsset, allAssets, categoriesOf } from './assets/kit-config.ts'
32
- export type { KitConfig, ResolvedKit, Kind, KindMeta, AssetSpec, GeneratorSpec, ComfyGen, CanvasGen, PanelSpec, GeometryPanelSpec, GridPanelSpec, CustomPanelSpec, FoundAsset } from './assets/kit-config.ts'
33
- export { identityKinds, renderIdentity, FAVICON_SIZE, OG_W, OG_H, LOGO_SIZE } from './assets/kits/identity-kit.ts'
37
+ export type { KitConfig, ResolvedKit, Kind, KindMeta, AssetSpec, GeneratorSpec, ComfyGen, CanvasGen, PanelSpec, GeometryPanelSpec, GridPanelSpec, CustomPanelSpec, FoundAsset, ShipContext, ShipFn } from './assets/kit-config.ts'
38
+ export { identityKinds, renderIdentity, renderIdentityFromMark, NO_PICK_FIX, FAVICON_SIZE, OG_W, OG_H, LOGO_SIZE } from './assets/kits/identity-kit.ts'
34
39
  export type { Brand } from './assets/kits/identity-kit.ts'
35
- export { serveStudio } from './assets/server/studio-server.ts'
36
- export type { Studio, StudioDeps } from './assets/server/studio-server.ts'
40
+ export { serveStudio, studioRoutes } from './assets/server/studio-server.ts'
41
+ export type { Studio, StudioDeps, StudioRoutes } from './assets/server/studio-server.ts'
37
42
  export { page as studioPage } from './assets/server/studio-page.ts'
38
43
  export { StudioQueue } from './assets/server/studio-queue.ts'
39
44
  export type { Job, QueueState } from './assets/server/studio-queue.ts'
@@ -0,0 +1,25 @@
1
+ // The tui command body — a separate module so cli.ts NEVER loads
2
+ // react/ink/mobx for non-tui commands (a fresh consumer running `shipkit ci`
3
+ // must not touch the react dependency chain; field bug 2026-07-22: 0.3.0
4
+ // crashed every command on repos without react-dom).
5
+ import React from 'react'
6
+ import { render } from 'ink'
7
+ import { configure } from 'mobx'
8
+ import { loadRepoConfig } from '../repo-config.ts'
9
+ import { checksPanel } from './checks-panel.ts'
10
+ import { tasksPanel } from './tasks-panel.ts'
11
+ import { TuiApp } from './tui-app.tsx'
12
+ import { TuiStore } from './tui-store.ts'
13
+
14
+ export async function runTui(root: string): Promise<number> {
15
+ const c = await loadRepoConfig(root)
16
+ // playbook (build/mobx): enforceActions warns on stray mutations — the
17
+ // only signal. Read-warning flags stay off: useInput handler reads are
18
+ // legitimately untracked and would false-positive.
19
+ configure({ enforceActions: 'always' })
20
+ const store = new TuiStore(c.name, [tasksPanel(root, c), checksPanel(root), ...(c.tui?.panels ?? [])])
21
+ store.refresh() // instant static paint; results stream in
22
+ const app = render(React.createElement(TuiApp, { store }))
23
+ await app.waitUntilExit()
24
+ return 0
25
+ }