rvlib-shipkit 0.10.0 → 0.13.0

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.
Files changed (61) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/README.md +17 -3
  3. package/package.json +5 -2
  4. package/src/asks/asks-queue.ts +42 -0
  5. package/src/assets/kit-config.ts +2 -3
  6. package/src/assets/roll-workspace.ts +2 -1
  7. package/src/assets/run-assets.ts +0 -11
  8. package/src/assets/server/studio-server.ts +7 -24
  9. package/src/cli/cmd_Assets.ts +3 -14
  10. package/src/cli/cmd_Check.ts +22 -4
  11. package/src/cli/cmd_Needs.ts +47 -0
  12. package/src/cli/cmd_Reflect.ts +116 -0
  13. package/src/cli/cmd_Rules.ts +32 -0
  14. package/src/cli/cmd_Serve.ts +1 -1
  15. package/src/cli.ts +7 -2
  16. package/src/collections/actions.ts +85 -0
  17. package/src/collections/collection-types.ts +65 -0
  18. package/src/collections/graphs.ts +34 -0
  19. package/src/collections/pings.ts +72 -0
  20. package/src/collections/posts.ts +125 -0
  21. package/src/collections/providers.ts +275 -0
  22. package/src/contracts/apply-contracts.ts +47 -0
  23. package/src/contracts/run-contracts.ts +8 -3
  24. package/src/core/canonical-scripts.ts +46 -0
  25. package/src/core/check-suite.ts +6 -3
  26. package/src/core/locked-write.ts +39 -0
  27. package/src/core/motherhome.ts +87 -0
  28. package/src/core/run-check.ts +28 -15
  29. package/src/core/run-ci.ts +4 -0
  30. package/src/core/write-status.ts +7 -26
  31. package/src/init-repo.ts +24 -11
  32. package/src/reflect/prompt-corpus.ts +305 -0
  33. package/src/repo-config.ts +26 -38
  34. package/src/rules/SK001-package-json-shape.ts +156 -0
  35. package/src/rules/SK002-contracts-fulfillness.ts +19 -0
  36. package/src/rules/SK003-claude-md-shape.ts +64 -0
  37. package/src/rules/SK004-gitignore-policy.ts +105 -0
  38. package/src/rules/SK005-rvlib-versions.ts +99 -0
  39. package/src/rules/SK006-npm-publish-safety.ts +42 -0
  40. package/src/rules/SK007-github-visibility.ts +63 -0
  41. package/src/rules/rule-registry.ts +32 -0
  42. package/src/rules/rule.ts +62 -0
  43. package/src/rules/run-fix.ts +57 -0
  44. package/src/serve/hub-app/col-card.tsx +192 -0
  45. package/src/serve/hub-app/cols-svc.ts +136 -0
  46. package/src/serve/hub-app/geometry-view.tsx +155 -0
  47. package/src/serve/hub-app/health-svc.ts +49 -0
  48. package/src/serve/hub-app/hub-app.tsx +240 -127
  49. package/src/serve/hub-app/hub-st.ts +169 -0
  50. package/src/serve/hub-app/hub-types.ts +72 -0
  51. package/src/serve/hub-app/hub.css +286 -35
  52. package/src/serve/hub-app/index.html +1 -1
  53. package/src/serve/hub-app/md.ts +24 -0
  54. package/src/serve/hub-app/media-svc.ts +429 -0
  55. package/src/serve/hub-app/media-view.tsx +299 -0
  56. package/src/serve/run-serve.ts +46 -91
  57. package/src/serve/serve-hub.ts +99 -49
  58. package/src/shipkit.ts +13 -6
  59. package/src/assets/server/studio-page.ts +0 -687
  60. package/src/core/package-json-check.ts +0 -88
  61. package/src/serve/hub-pages.ts +0 -45
@@ -0,0 +1,299 @@
1
+ // Media views — topbar, info chips, stage (image/audio + geometry), roll
2
+ // strip, category grid + size-true lineup, drawer (asset json · roll meta ·
3
+ // repo-defined custom panels). Feature parity with the deleted vanilla
4
+ // studio SPA; state lives in MediaSvc (components read and call).
5
+ import { runInAction } from 'mobx'
6
+ import { observer } from 'mobx-react-lite'
7
+ import { useEffect, useRef, useState, type DragEvent, type ReactNode } from 'react'
8
+ import type { HubSt } from './hub-st.ts'
9
+ import { isAud, rollUrl, thumbUrl } from './media-svc.ts'
10
+ import { GeometryLayer, Rectbar, measureBox, type Box } from './geometry-view.tsx'
11
+ import type { CatRow } from './hub-types.ts'
12
+
13
+ const TEX = 1024
14
+ const MIX_UNIT = 24
15
+ const MIX_GROUND = 44
16
+
17
+ export const MediaTopbar = observer(({ app }: { app: HubSt }) => {
18
+ const m = app.media
19
+ const fileRef = useRef<HTMLInputElement>(null)
20
+ const taRef = useRef<HTMLTextAreaElement>(null)
21
+ const manual = m.cur?.manual === true
22
+ const det = m.deterministic
23
+ // auto-grow on EVERY prompt change, programmatic (selection) included —
24
+ // the css max-height caps it, overflow scrolls only past ~14 lines
25
+ useEffect(() => {
26
+ const t = taRef.current
27
+ if (t == null) return
28
+ t.style.height = 'auto'
29
+ t.style.height = `${t.scrollHeight + 2}px`
30
+ }, [m.prompt])
31
+ return (
32
+ <div className="bar">
33
+ {/* the prompt AUTOSAVES (debounced) — no save button, no fear */}
34
+ <textarea className="prompt" ref={taRef} value={m.prompt} placeholder="select an asset ←"
35
+ onChange={(e) => m.setPrompt(e.target.value)} />
36
+ <div className="toolbar">
37
+ {!manual ? <button disabled={m.curId == null} onClick={() => void m.generate()}>Generate</button> : null}
38
+ {!manual ? (
39
+ <label className="xn" title="rolls per generate">×
40
+ <select value={m.n} disabled={det} onChange={(e) => runInAction(() => { m.n = e.target.value })}>
41
+ {['1', '3', '6', '9', '12'].map((v) => <option key={v}>{v}</option>)}
42
+ </select>
43
+ </label>
44
+ ) : null}
45
+ <button className="ghost" title="add files as rolls (or drag-drop onto the strip)" onClick={() => fileRef.current?.click()}>upload</button>
46
+ <input type="file" ref={fileRef} multiple style={{ display: 'none' }}
47
+ accept={m.cur?.media === 'audio' ? '.mp3,.wav,.ogg,.m4a' : '.png,.webp'}
48
+ onChange={(e) => { if (e.target.files != null) void m.upload(e.target.files); e.target.value = '' }} />
49
+ <button className="ghost" title="copy the prompt" onClick={() => m.copyPrompt()}>copy</button>
50
+ {det || manual ? <span className="detnote">{manual ? 'manual — upload files (drag-drop works)' : 'deterministic — rolls identical'}</span> : null}
51
+ <span className="sp" />
52
+ {m.generating ? <button className="ghost" title="interrupt the current job (queued jobs stay)" onClick={() => m.stopCurrent()}>stop current</button> : null}
53
+ {m.queued > 0 ? <button className="ghost" title="drop queued jobs" onClick={() => m.clearQueue()}>clear queue ({m.queued})</button> : null}
54
+ {m.generating && m.progress?.max ? (
55
+ <span className="progbar"><i style={{ width: `${Math.round((100 * m.progress.value) / m.progress.max)}%` }} /></span>
56
+ ) : null}
57
+ <span className="status">{m.promptMsg !== '' ? m.promptMsg : m.generating ? m.genLine : m.statusLine}</span>
58
+ </div>
59
+ </div>
60
+ )
61
+ })
62
+
63
+ export const InfoChips = observer(({ app }: { app: HubSt }) => {
64
+ const info = app.media.detail?.info
65
+ if (info == null) return null
66
+ const chips: { k: string; v: ReactNode; warn?: boolean }[] = [{ k: 'backend', v: info.backend }]
67
+ if (info.workflow != null) {
68
+ const w = info.workflow
69
+ if (w.id != null) chips.push({ k: 'workflow', v: <>{w.id}{w.path != null ? <> <code>{w.path}</code></> : null}</> })
70
+ else chips.push({ k: 'workflow', v: w.note ?? 'unresolved', warn: true })
71
+ }
72
+ const sh = info.ship
73
+ chips.push({ k: 'ship', v: sh.mode === 'hook' ? <code>{sh.path}</code> : sh.mode === 'built-in' ? `built-in hook — ${sh.note ?? ''}` : sh.note ?? 'pick.json only' })
74
+ if (info.metaFile != null) chips.push({ k: 'meta', v: <code>{info.metaFile}</code> })
75
+ chips.push({ k: 'kit', v: <><code>{info.kitSource}</code> · {info.kindId}</> })
76
+ return (
77
+ <div className="info" style={{ display: 'flex' }}>
78
+ {chips.map((c, i) => <span key={i} className={`chip${c.warn === true ? ' warn' : ''}`}><b>{c.k}</b> {c.v}</span>)}
79
+ </div>
80
+ )
81
+ })
82
+
83
+ export const AssetStage = observer(({ app }: { app: HubSt }) => {
84
+ const m = app.media
85
+ const stageRef = useRef<HTMLDivElement>(null)
86
+ const imgRef = useRef<HTMLImageElement>(null)
87
+ const [box, setBox] = useState<Box | null>(null)
88
+ const remeasure = (): void => setBox(measureBox(stageRef.current, imgRef.current))
89
+ useEffect(() => {
90
+ window.addEventListener('resize', remeasure)
91
+ return () => window.removeEventListener('resize', remeasure)
92
+ }, [])
93
+ const rolls = m.detail?.rolls ?? []
94
+ const genHere = m.curId != null && m.pending.includes(m.curId)
95
+ const shown = m.hover ?? m.active
96
+ let content: ReactNode
97
+ if (shown == null) content = <div className="ph">{genHere ? 'generating…' : rolls.length === 0 ? 'no rolls yet' : 'Pick an asset on the left, then Generate.'}</div>
98
+ else if (isAud(shown)) content = (
99
+ // key = the roll identity: heartbeats re-render but never recreate
100
+ // the element, so playback survives; the src carries no cache-bust
101
+ <div className="stageaud" key={`${m.curId}/${shown}`}>🔊<audio controls src={rollUrl(m.curId!, shown)} /></div>
102
+ )
103
+ else content = <img className="roll" src={rollUrl(m.curId!, shown)} ref={imgRef} onLoad={remeasure} />
104
+ return (
105
+ <div className="stage" ref={stageRef}>
106
+ {content}
107
+ {shown != null && !isAud(shown) ? <GeometryLayer app={app} box={box} /> : null}
108
+ <Rectbar app={app} />
109
+ </div>
110
+ )
111
+ })
112
+
113
+ export const RollStrip = observer(({ app }: { app: HubSt }) => {
114
+ const m = app.media
115
+ const rolls = m.detail?.rolls ?? []
116
+ const genHere = m.curId != null && m.pending.includes(m.curId)
117
+ const onDrop = (e: DragEvent<HTMLDivElement>): void => { e.preventDefault(); void m.upload(e.dataTransfer.files) }
118
+ if (rolls.length === 0) return (
119
+ <div className="strip" onDragOver={(e) => e.preventDefault()} onDrop={onDrop}>
120
+ {genHere ? <div className="vig gen"><div className="spin" />generating…</div>
121
+ : <div className="ph">{m.cur?.manual === true ? 'no files — upload (or drag-drop here)' : 'no rolls — hit Generate'}</div>}
122
+ </div>
123
+ )
124
+ return (
125
+ <div className="strip" onDragOver={(e) => e.preventDefault()} onDrop={onDrop}>
126
+ {/* rolls are newest-first; generation lands where the eye is */}
127
+ {genHere ? <div className="vig gen"><div className="spin" />generating…</div> : null}
128
+ {rolls.map((x) => (
129
+ <div key={x.name}
130
+ className={`vig${isAud(x.name) ? ' aud' : ''}${x.name === m.active ? ' active' : ''}${x.picked ? ' picked' : ''}${x.alt ? ' alt' : ''}`}
131
+ // hover-preview stays image-only — swapping an <audio> mid-playback would cut the sound
132
+ onMouseEnter={() => { if (!isAud(x.name)) runInAction(() => { m.hover = x.name }) }}
133
+ onMouseLeave={() => runInAction(() => { m.hover = null })}
134
+ onClick={() => m.setActive(x.name)}>
135
+ {isAud(x.name) ? <>🔊<audio controls src={rollUrl(m.curId!, x.name)} /></> : <img src={rollUrl(m.curId!, x.name)} />}
136
+ {x.picked ? <span className="st">★</span> : null}
137
+ <div className="btns">
138
+ <button className="b-pick" onClick={(e) => { e.stopPropagation(); void m.pick(x.name) }}>pick</button>
139
+ <button className="b-alt" onClick={(e) => { e.stopPropagation(); void m.alt(x.name) }}>alt</button>
140
+ <button className="b-del" onClick={(e) => { e.stopPropagation(); void m.discard(x.name) }}>✕</button>
141
+ </div>
142
+ </div>
143
+ ))}
144
+ </div>
145
+ )
146
+ })
147
+
148
+ // ── category grid + size-true lineup ─────────────────────────────────────
149
+ const MixLineup = observer(({ rows }: { rows: CatRow[] }) => {
150
+ const items = rows.filter((r) => !r.icon && r.rect != null).sort((a, b) => a.rect!.size - b.rect!.size || a.label.localeCompare(b.label))
151
+ const boxH = 420
152
+ const baseY = boxH - MIX_GROUND
153
+ let cx = 30
154
+ const sprites: ReactNode[] = []
155
+ for (const it of items) {
156
+ const rect = it.rect!
157
+ const ts = (rect.size * MIX_UNIT) / rect.w
158
+ const slot = Math.max(rect.size * MIX_UNIT * 1.35, TEX * ts * 0.62, 40)
159
+ cx += slot / 2
160
+ const fx = rect.flip === true ? TEX - rect.x - rect.w : rect.x
161
+ sprites.push(
162
+ <img key={it.id} className="mixsprite" src={thumbUrl(it.id)} style={{
163
+ width: TEX * ts, height: TEX * ts,
164
+ left: cx - (fx + rect.w / 2) * ts, top: baseY - (rect.y + rect.h / 2) * ts,
165
+ transform: rect.flip === true ? 'scaleX(-1)' : undefined, zIndex: Math.round(cx),
166
+ }} />,
167
+ <div key={`${it.id}-lb`} className="mixlabel" style={{ left: cx, bottom: 14 }}>{it.label} · {+rect.size.toFixed(2)}</div>,
168
+ )
169
+ cx += slot / 2 + 10
170
+ }
171
+ const rulers: ReactNode[] = []
172
+ for (let u = 5; u * MIX_UNIT < baseY; u += 5)
173
+ rulers.push(<div key={u} className={`rlabel${u % 10 === 0 ? ' b' : ''}`} style={{ bottom: MIX_GROUND + u * MIX_UNIT - 5 }}>{u}</div>)
174
+ return (
175
+ <div id="mixsec"><div id="mixbox"><div id="mixinner" style={{ width: cx + 30, minWidth: '100%' }}>
176
+ <div className="ruler" />
177
+ <div id="mixground" />
178
+ {sprites}{rulers}
179
+ </div></div></div>
180
+ )
181
+ })
182
+
183
+ export const CatGrid = observer(({ app }: { app: HubSt }) => {
184
+ const m = app.media
185
+ const cat = m.catMode
186
+ if (cat == null) return null
187
+ const pc = (v: number): string => `${v / (TEX / 100)}%`
188
+ return (
189
+ <div id="catgrid" style={{ display: 'block' }}>
190
+ <div id="allbar">
191
+ <label>size for ALL {cat} <input type="number" step={0.1} min={0.1} value={m.allSize}
192
+ onChange={(e) => runInAction(() => { m.allSize = e.target.value })} /></label>
193
+ <button className="ghost" onClick={() => void m.applyAllSize()}>apply</button>
194
+ <span className="jmsg">{m.allMsg}</span>
195
+ </div>
196
+ <div className="cards">
197
+ {m.catRows.map((row) => {
198
+ const fl = row.rect?.flip === true
199
+ return (
200
+ // the WHOLE card jumps into the asset (his order
201
+ // 2026-07-23) — inner controls stopPropagation
202
+ <div className="card" key={row.id} onClick={() => app.selectAsset(row.id)}>
203
+ {isAud(row.name)
204
+ ? <div className="aud">🔊<audio controls src={rollUrl(row.id, row.name)} onClick={(e) => e.stopPropagation()} />{m.pending.includes(row.id) ? <div className="spin" /> : null}</div>
205
+ : <div className="th">
206
+ <img src={thumbUrl(row.id)} style={fl ? { transform: 'scaleX(-1)' } : undefined} />
207
+ {row.rect != null ? <div className="ovmini" style={{
208
+ left: pc(fl ? TEX - row.rect.x - row.rect.w : row.rect.x), top: pc(row.rect.y),
209
+ width: pc(row.rect.w), height: pc(row.rect.h),
210
+ }} /> : null}
211
+ {m.pending.includes(row.id) ? <div className="spin" /> : null}
212
+ </div>}
213
+ <div className="nm">{row.label}</div>
214
+ {row.icon || isAud(row.name) ? null : (
215
+ <div className="row" onClick={(e) => e.stopPropagation()}>
216
+ {/* commit-on-blur/Enter like the vanilla onchange (one POST,
217
+ not one per keystroke); the size in the key remounts the
218
+ input when apply-all changes it programmatically */}
219
+ <label>size <input key={`${row.id}:${row.rect?.size ?? 1}`} type="number" step={0.1} min={0.1}
220
+ defaultValue={row.rect?.size ?? 1}
221
+ onBlur={(e) => void m.setSize(row.id, row.name, +e.target.value)}
222
+ onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }} /></label>
223
+ <button className="ghost" onClick={() => void m.toggleFlip(row.id, row.name)}>⇆ flip</button>
224
+ </div>
225
+ )}
226
+ </div>
227
+ )
228
+ })}
229
+ </div>
230
+ <MixLineup rows={m.catRows} />
231
+ </div>
232
+ )
233
+ })
234
+
235
+ // ── drawer: asset json (read-only) · roll meta · custom panels ───────────
236
+ const PanelHost = observer(({ app }: { app: HubSt }) => {
237
+ const m = app.media
238
+ const ref = useRef<HTMLDivElement>(null)
239
+ const c = m.detail?.asset.custom.find((x) => `c:${x.id}` === m.tab) ?? null
240
+ const key = `${m.curId}|${m.active}|${c?.id ?? ''}`
241
+ useEffect(() => {
242
+ const host = ref.current
243
+ if (host == null) return
244
+ if (c == null || m.curId == null || m.active == null) {
245
+ host.textContent = c == null ? 'panel not on this asset' : 'no roll selected — generate or pick one first'
246
+ return
247
+ }
248
+ let dead = false
249
+ let mod: Record<string, unknown> | null = null
250
+ void (async () => {
251
+ try {
252
+ // repo-defined editor module, served by the media API
253
+ mod = (await import(`/media/${c.url}`)) as Record<string, unknown>
254
+ if (typeof mod.mount !== 'function') throw new Error(`${c.url} must export mount(el, api)`)
255
+ if (!dead) await (mod.mount as (el: HTMLElement, api: unknown) => unknown)(host, m.panelApi(c))
256
+ } catch (e) {
257
+ host.textContent = `panel FAILED: ${(e as Error).message}` // loud in the drawer, never blank
258
+ }
259
+ })()
260
+ return () => {
261
+ dead = true
262
+ if (typeof mod?.unmount === 'function') void (mod.unmount as () => unknown)()
263
+ host.innerHTML = ''
264
+ }
265
+ }, [key])
266
+ return <div ref={ref} />
267
+ })
268
+
269
+ export const Drawer = observer(({ app }: { app: HubSt }) => {
270
+ const m = app.media
271
+ const customs = m.detail?.asset.custom ?? []
272
+ const isCustom = m.tab.startsWith('c:')
273
+ return (
274
+ <div className="drawer">
275
+ <div className="tabs">
276
+ {[{ id: 'asset', label: 'asset' }, { id: 'meta', label: 'roll meta' }, ...customs.map((c) => ({ id: `c:${c.id}`, label: c.id }))].map((t) => (
277
+ <button key={t.id} className={m.tab === t.id ? 'on' : ''} onClick={() => m.setTab(t.id)}>{t.label}</button>
278
+ ))}
279
+ <span className="sp" />
280
+ <button className="ghost" onClick={() => runInAction(() => { m.drawerOpen = !m.drawerOpen })}>JSON ▾</button>
281
+ </div>
282
+ {m.drawerOpen ? (
283
+ <div id="drawerbody" className="on">
284
+ {isCustom ? <PanelHost app={app} /> : (
285
+ <div>
286
+ <textarea id="jsonta" spellCheck={false} readOnly={m.tab === 'asset'} value={m.jsonText}
287
+ onChange={(e) => runInAction(() => { m.jsonText = e.target.value })} />
288
+ <div className="jrow">
289
+ {/* kind/asset structure is git-reviewed TS — read-only here */}
290
+ <button disabled={m.tab === 'asset'} onClick={() => void m.saveMeta()}>save</button>
291
+ <span className="jmsg">{m.jsonMsg}</span>
292
+ </div>
293
+ </div>
294
+ )}
295
+ </div>
296
+ ) : null}
297
+ </div>
298
+ )
299
+ })
@@ -5,20 +5,17 @@
5
5
  // kit.ts loads. Port: --port > serve.port > a deterministic house-range
6
6
  // default (4800-4979, hashed from the repo name — stable across launches;
7
7
  // pin serve.port when two repos collide).
8
- import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
8
+ import { existsSync, readFileSync } from 'fs'
9
9
  import { join } from 'path'
10
10
  import { fileURLToPath } from 'url'
11
- import { loadKit, allAssets, type ResolvedKit } from '../assets/kit-config.ts'
12
- import { isStale } from '../assets/gen/derived.ts'
13
- import { assetDir, listRolls, readPick } from '../assets/roll-workspace.ts'
14
- import { healIdentity } from '../assets/run-assets.ts'
15
- import { page as studioPage } from '../assets/server/studio-page.ts'
11
+ import { loadKit, type ResolvedKit } from '../assets/kit-config.ts'
16
12
  import { studioRoutes, type StudioRoutes } from '../assets/server/studio-server.ts'
17
- import { openAsks } from '../asks/asks-queue.ts'
18
- import { evaluateContracts } from '../contracts/contracts-engine.ts'
19
- import { activeContracts, contractsConfigFrom, loadRepoConfig, type RepoConfig } from '../repo-config.ts'
13
+ import { projectNeeds } from '../collections/collection-types.ts'
14
+ import { graphsCollection } from '../collections/graphs.ts'
15
+ import { pingTick } from '../collections/pings.ts'
16
+ import { buildCollections } from '../collections/providers.ts'
17
+ import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
20
18
  import { scanMdGraph } from '../wiki/scan-md.ts'
21
- import type { AttentionSection } from './hub-pages.ts'
22
19
  import { serveHub, type HubDeps } from './serve-hub.ts'
23
20
 
24
21
  export type ServeIo = { print: (l: string) => void; printErr: (l: string) => void }
@@ -37,29 +34,55 @@ export async function resolveHubDeps(root: string, c: RepoConfig, io: ServeIo, o
37
34
  const agentGraph = existsSync(agentRoot)
38
35
  ? { title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }) }
39
36
  : null
40
- let studio: StudioRoutes | null = null
37
+ // ONE page for every repo (his rule 2026-07-23): the studio ALWAYS
38
+ // mounts — kit-less repos get an empty media section, never a second
39
+ // page. A broken kit degrades to the same empty kit, loudly.
41
40
  let kit: ResolvedKit | null = null
42
41
  if (existsSync(join(root, '.rv-assets/kit.ts'))) {
43
- // a broken kit must not take the graphs and status down with it —
44
- // WARN + mount everything else
45
42
  try {
46
43
  kit = await loadKit(root)
47
- studio = await studioRoutes({ kit, root, page: studioPage })
48
44
  } catch (e) {
49
- kit = null
50
- io.print(`WARN assets studio not mounted: ${(e as Error).message}`)
45
+ io.print(`WARN kit not loaded (media section empty): ${(e as Error).message}`)
51
46
  }
52
47
  }
48
+ const emptyKit: ResolvedKit = { kinds: [], workspace: '.rv-assets/out' }
49
+ let studio: StudioRoutes | null = null
50
+ try {
51
+ studio = await studioRoutes({ kit: kit ?? emptyKit, root })
52
+ } catch (e) {
53
+ io.print(`WARN media API not mounted: ${(e as Error).message}`)
54
+ }
53
55
  const cli = fileURLToPath(new URL('../cli.ts', import.meta.url))
56
+ const allCollections = () => [
57
+ ...buildCollections(root, c, kit, { bootVersion: BOOT_VERSION, libVersion }),
58
+ graphsCollection(root, { customWiki: c.serve?.wikiGraph != null, agentIndex: c.serve?.agentIndex }),
59
+ ]
60
+ const port = o?.port ?? c.serve?.port ?? defaultHubPort(c.name)
54
61
  return {
55
62
  root,
56
63
  name: c.name,
57
- port: o?.port ?? c.serve?.port ?? defaultHubPort(c.name),
64
+ port,
58
65
  wikiGraph,
59
66
  agentGraph,
60
67
  studio,
61
68
  checkCmd: [process.execPath, cli, 'check'],
62
- attention: attentionCollector(root, c, kit),
69
+ needs: () => projectNeeds(allCollections()),
70
+ collections: allCollections,
71
+ asksDir: c.asks?.dir ?? 'asks',
72
+ // RED-only pings while serving; notify command is repo config data.
73
+ // Delivery failure must be LOUD — a silently broken notify path means
74
+ // he assumes no blockers exist
75
+ pingTick: (needs) =>
76
+ pingTick(root, c.asks?.dir ?? 'asks', needs, {
77
+ link: c.serve?.publicHost != null ? `http://${c.serve.publicHost}:${port}/?f=needs` : undefined,
78
+ run: (cmd) => {
79
+ const p = Bun.spawn(cmd, { stdout: 'ignore', stderr: 'pipe' })
80
+ void p.exited.then(async (code) => {
81
+ if (code !== 0) io.printErr(`WARN ping delivery failed (exit ${code}): ${await new Response(p.stderr).text().then((s) => s.trim().slice(0, 160))}`)
82
+ })
83
+ },
84
+ notifyCommand: c.notifyCommand,
85
+ }),
63
86
  }
64
87
  }
65
88
 
@@ -72,79 +95,9 @@ const libVersion = (): string => {
72
95
  }
73
96
  const BOOT_VERSION = libVersion()
74
97
 
75
- // ── the / dashboard's data: everything red, one collector per source, each
76
- // failure-isolated (a broken source reports itself, never blanks the page)
77
- export function attentionCollector(root: string, c: RepoConfig, kit: ResolvedKit | null): () => Promise<AttentionSection[]> {
78
- const guard = async (title: string, tone: 'red' | 'warn', body: () => Promise<AttentionSection['lines']>): Promise<AttentionSection> => {
79
- try { return { title, tone, lines: await body() } } catch (e) { return { title, tone: 'red', lines: [{ text: `collector broke: ${(e as Error).message}` }] } }
80
- }
81
- return async () => {
82
- const sections = await Promise.all([
83
- guard('hub process', 'red', async () => {
84
- const now = libVersion()
85
- if (now === BOOT_VERSION) return []
86
- return [{ text: `hub is running shipkit ${BOOT_VERSION} but ${now} is on disk (stale process, stale pages, old studio code). Restart the 🚀 shipkit task` }]
87
- }),
88
- guard('failing checks', 'red', async () => {
89
- const p = join(root, 'STATUS.md')
90
- if (!existsSync(p)) return []
91
- // a cred FAIL whose ask already sits in the asks section is
92
- // ONE action shown twice — the ask (his hands) wins
93
- const askDir = join(root, c.asks?.dir ?? 'asks')
94
- const askText = existsSync(askDir) ? openAsks(askDir).map((a) => a.title.toLowerCase()).join(' ') : ''
95
- return (await Bun.file(p).text()).split('\n')
96
- .filter((l) => l.trimStart().startsWith('FAIL'))
97
- .filter((l) => {
98
- const cred = l.match(/cred rv\/([a-z0-9-]+)\/[a-z0-9-]+ missing/)?.[1]
99
- return cred == null || !askText.includes(cred)
100
- })
101
- .map((text) => ({ text, href: '/status' }))
102
- }),
103
- guard('asks — waiting on you', 'red', async () => {
104
- const dir = join(root, c.asks?.dir ?? 'asks')
105
- if (!existsSync(dir)) return [] // read path must not mint an asks/ dir
106
- return openAsks(dir).map((a) => ({ text: `${a.title}${a.kind !== '' ? ` (${a.kind})` : ''}` }))
107
- }),
108
- guard('assets', 'red', async () => {
109
- // self-heal first: identity re-renders itself on brand.ts
110
- // drift; only pick-blocked (taste) surfaces below
111
- const idn = await healIdentity(root)
112
- const lines: AttentionSection['lines'] = []
113
- if (idn.state === 'blocked') lines.push({ text: `identity — ${idn.detail ?? 'blocked'}`, href: '/assets/#identity-logo' })
114
- if (kit == null) return lines
115
- for (const f of allAssets(kit)) {
116
- const dir = assetDir(join(root, kit.workspace), f.asset.id)
117
- const rolls = listRolls(dir)
118
- const manual = f.kind.generate.backend === 'manual'
119
- if (manual && rolls.length === 0) lines.push({ text: `${f.asset.id} — manual, nothing uploaded`, href: `/assets/#${f.asset.id}` })
120
- else if (rolls.length > 0 && (await readPick(dir)).pick == null) lines.push({ text: `${f.asset.id} — ${rolls.length} roll(s), none picked`, href: `/assets/#${f.asset.id}` })
121
- else if (await isStale(kit, join(root, kit.workspace), f)) lines.push({ text: `${f.asset.id} — stale (a parent was re-picked)`, href: `/assets/#${f.asset.id}` })
122
- }
123
- return lines
124
- }),
125
- guard('contracts', 'red', async () => {
126
- const r = await evaluateContracts(activeContracts(c), contractsConfigFrom(c, root))
127
- return r.lines.filter((l) => l.kind === 'fail').map((l) => ({ text: l.text }))
128
- }),
129
- guard('contract warnings', 'warn', async () => {
130
- const r = await evaluateContracts(activeContracts(c), contractsConfigFrom(c, root))
131
- return r.lines.filter((l) => l.kind === 'warn').map((l) => ({ text: l.text }))
132
- }),
133
- guard('posts to write', 'warn', async () => {
134
- const social = join(root, '.rv-social')
135
- if (!existsSync(social)) return []
136
- const lines: AttentionSection['lines'] = []
137
- for (const ch of readdirSync(social)) {
138
- const d = join(social, ch)
139
- if (!statSync(d).isDirectory()) continue
140
- for (const f of readdirSync(d).filter((x) => x.endsWith('.md'))) lines.push({ text: `${ch}/${f}` })
141
- }
142
- return lines
143
- }),
144
- ])
145
- return sections
146
- }
147
- }
98
+ // the / dashboard's data now comes from the collection providers
99
+ // (src/collections/providers.ts) — the old attentionCollector was DELETED
100
+ // when they landed; never reintroduce a parallel projection.
148
101
 
149
102
  export async function runServe(root: string, io: ServeIo, o?: { port?: number }): Promise<number> {
150
103
  const c = await loadRepoConfig(root)
@@ -154,6 +107,8 @@ export async function runServe(root: string, io: ServeIo, o?: { port?: number })
154
107
  const base = `http://localhost:${hub.server.port}`
155
108
  io.print(`ok: ${c.name} hub → ${base}`)
156
109
  for (const t of hub.tools) io.print(`ok: ${t.label} → ${base}${t.path}`)
110
+ // the off-machine door (phone, other machines) — publicHost is config
111
+ if (c.serve?.publicHost != null) io.print(`ok: public → http://${c.serve.publicHost}:${hub.server.port}/`)
157
112
  await new Promise(() => {}) // the hub owns the process — never resolve
158
113
  return 0
159
114
  } catch (e) {