rvlib-shipkit 0.11.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 (60) hide show
  1. package/CHANGELOG.md +166 -0
  2. package/README.md +17 -3
  3. package/package.json +5 -2
  4. package/src/asks/asks-queue.ts +8 -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_Reflect.ts +116 -0
  12. package/src/cli/cmd_Rules.ts +32 -0
  13. package/src/cli/cmd_Serve.ts +1 -1
  14. package/src/cli.ts +5 -2
  15. package/src/collections/actions.ts +19 -0
  16. package/src/collections/collection-types.ts +13 -0
  17. package/src/collections/graphs.ts +34 -0
  18. package/src/collections/pings.ts +5 -1
  19. package/src/collections/posts.ts +20 -1
  20. package/src/collections/providers.ts +163 -50
  21. package/src/contracts/apply-contracts.ts +47 -0
  22. package/src/contracts/run-contracts.ts +8 -3
  23. package/src/core/canonical-scripts.ts +46 -0
  24. package/src/core/check-suite.ts +6 -3
  25. package/src/core/locked-write.ts +39 -0
  26. package/src/core/motherhome.ts +87 -0
  27. package/src/core/run-check.ts +28 -15
  28. package/src/core/run-ci.ts +4 -0
  29. package/src/core/write-status.ts +7 -26
  30. package/src/init-repo.ts +24 -11
  31. package/src/reflect/prompt-corpus.ts +305 -0
  32. package/src/repo-config.ts +20 -38
  33. package/src/rules/SK001-package-json-shape.ts +156 -0
  34. package/src/rules/SK002-contracts-fulfillness.ts +19 -0
  35. package/src/rules/SK003-claude-md-shape.ts +64 -0
  36. package/src/rules/SK004-gitignore-policy.ts +105 -0
  37. package/src/rules/SK005-rvlib-versions.ts +99 -0
  38. package/src/rules/SK006-npm-publish-safety.ts +42 -0
  39. package/src/rules/SK007-github-visibility.ts +63 -0
  40. package/src/rules/rule-registry.ts +32 -0
  41. package/src/rules/rule.ts +62 -0
  42. package/src/rules/run-fix.ts +57 -0
  43. package/src/serve/hub-app/col-card.tsx +192 -0
  44. package/src/serve/hub-app/cols-svc.ts +136 -0
  45. package/src/serve/hub-app/geometry-view.tsx +155 -0
  46. package/src/serve/hub-app/health-svc.ts +49 -0
  47. package/src/serve/hub-app/hub-app.tsx +236 -215
  48. package/src/serve/hub-app/hub-st.ts +169 -0
  49. package/src/serve/hub-app/hub-types.ts +72 -0
  50. package/src/serve/hub-app/hub.css +286 -59
  51. package/src/serve/hub-app/index.html +1 -1
  52. package/src/serve/hub-app/md.ts +24 -0
  53. package/src/serve/hub-app/media-svc.ts +429 -0
  54. package/src/serve/hub-app/media-view.tsx +299 -0
  55. package/src/serve/run-serve.ts +23 -9
  56. package/src/serve/serve-hub.ts +62 -47
  57. package/src/shipkit.ts +9 -5
  58. package/src/assets/server/studio-page.ts +0 -687
  59. package/src/core/package-json-check.ts +0 -88
  60. package/src/serve/hub-pages.ts +0 -38
@@ -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
+ })
@@ -9,9 +9,9 @@ import { existsSync, readFileSync } from 'fs'
9
9
  import { join } from 'path'
10
10
  import { fileURLToPath } from 'url'
11
11
  import { loadKit, type ResolvedKit } from '../assets/kit-config.ts'
12
- import { page as studioPage } from '../assets/server/studio-page.ts'
13
12
  import { studioRoutes, type StudioRoutes } from '../assets/server/studio-server.ts'
14
13
  import { projectNeeds } from '../collections/collection-types.ts'
14
+ import { graphsCollection } from '../collections/graphs.ts'
15
15
  import { pingTick } from '../collections/pings.ts'
16
16
  import { buildCollections } from '../collections/providers.ts'
17
17
  import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
@@ -34,35 +34,47 @@ export async function resolveHubDeps(root: string, c: RepoConfig, io: ServeIo, o
34
34
  const agentGraph = existsSync(agentRoot)
35
35
  ? { title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }) }
36
36
  : null
37
- 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.
38
40
  let kit: ResolvedKit | null = null
39
41
  if (existsSync(join(root, '.rv-assets/kit.ts'))) {
40
- // a broken kit must not take the graphs and status down with it —
41
- // WARN + mount everything else
42
42
  try {
43
43
  kit = await loadKit(root)
44
- studio = await studioRoutes({ kit, root, page: studioPage })
45
44
  } catch (e) {
46
- kit = null
47
- 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}`)
48
46
  }
49
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
+ }
50
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)
51
61
  return {
52
62
  root,
53
63
  name: c.name,
54
- port: o?.port ?? c.serve?.port ?? defaultHubPort(c.name),
64
+ port,
55
65
  wikiGraph,
56
66
  agentGraph,
57
67
  studio,
58
68
  checkCmd: [process.execPath, cli, 'check'],
59
- needs: () => projectNeeds(buildCollections(root, c, kit, { bootVersion: BOOT_VERSION, libVersion })),
69
+ needs: () => projectNeeds(allCollections()),
70
+ collections: allCollections,
60
71
  asksDir: c.asks?.dir ?? 'asks',
61
72
  // RED-only pings while serving; notify command is repo config data.
62
73
  // Delivery failure must be LOUD — a silently broken notify path means
63
74
  // he assumes no blockers exist
64
75
  pingTick: (needs) =>
65
76
  pingTick(root, c.asks?.dir ?? 'asks', needs, {
77
+ link: c.serve?.publicHost != null ? `http://${c.serve.publicHost}:${port}/?f=needs` : undefined,
66
78
  run: (cmd) => {
67
79
  const p = Bun.spawn(cmd, { stdout: 'ignore', stderr: 'pipe' })
68
80
  void p.exited.then(async (code) => {
@@ -95,6 +107,8 @@ export async function runServe(root: string, io: ServeIo, o?: { port?: number })
95
107
  const base = `http://localhost:${hub.server.port}`
96
108
  io.print(`ok: ${c.name} hub → ${base}`)
97
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}/`)
98
112
  await new Promise(() => {}) // the hub owns the process — never resolve
99
113
  return 0
100
114
  } catch (e) {
@@ -1,20 +1,16 @@
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.
1
+ // shipkit serve — the repo's ONE Bun.serve, serving the ONE page (his
2
+ // rules 2026-07-23: one server per repo, one surface, mobx+react). `/` is
3
+ // the hub app (Bun HTML import — media studio + asks + creds + posts +
4
+ // health + graphs as sections); the studio route table mounts as the media
5
+ // API under /media/*; graph canvases stay as drill-down pages. Route logic
6
+ // only — resolving WHAT to mount from repo.config is run-serve.ts's job.
7
7
  import { existsSync } from 'fs'
8
8
  import { join } from 'path'
9
9
  import { graphHtml } from 'rvlib-shipkit/src/wiki/graph-page.ts'
10
10
  import type { MdGraph } from 'rvlib-shipkit/src/wiki/md-graph.ts'
11
11
  import type { StudioRoutes } from '../assets/server/studio-server.ts'
12
- import { appendPostFeedback, askDone, askProof, markPosted, pickPostVariant } from '../collections/actions.ts'
13
- import type { NeedItem } from '../collections/collection-types.ts'
14
- import { withRail } from './hub-pages.ts'
15
- // the hub UI is a Bun-native React app (HTML import, no vite, no build) —
16
- // build/webapp doctrine; legacy pages (graphs, studio SPA) keep the
17
- // server-injected rail until they migrate
12
+ import { appendAskRemark, appendAssetNote, appendPostFeedback, askDone, askProof, markPosted, pickPostVariant } from '../collections/actions.ts'
13
+ import type { Collection, Instance, NeedItem } from '../collections/collection-types.ts'
18
14
  import hubIndex from './hub-app/index.html'
19
15
 
20
16
  export type HubGraph = { title: string; load: () => MdGraph }
@@ -27,13 +23,17 @@ export type HubDeps = {
27
23
  wikiGraph: HubGraph
28
24
  /** null = no agent/ dir */
29
25
  agentGraph: HubGraph | null
30
- /** null = no .rv-assets/kit.ts (or it failed to load — caller prints the WARN) */
26
+ /** the media API (mounted under /media/*); null only if the route
27
+ * table failed to build — the page shows the WARN, never a second page */
31
28
  studio: StudioRoutes | null
32
- /** argv shelling the check run streamed by /status/run (test seam) */
29
+ /** argv shelling the check run streamed by /api/check/run (test seam) */
33
30
  checkCmd: string[]
34
- /** the / needs feed — every human-action item, red/yellow, gathered per
35
- * request from the collection providers (run-serve builds it) */
31
+ /** every human-action item, red/yellow, gathered per request from the
32
+ * collection providers (run-serve builds it) */
36
33
  needs: () => Promise<NeedItem[]>
34
+ /** BROWSE-ALL: the collections with every instance incl. healthy —
35
+ * the page shows everything that exists (his trust rule) */
36
+ collections: () => Collection[]
37
37
  /** asks dir (repo-root-relative) for the act-in-place ask verbs */
38
38
  asksDir: string
39
39
  /** RED-only ping tick, called on an interval while serving (null = off) */
@@ -41,28 +41,27 @@ export type HubDeps = {
41
41
  }
42
42
  export type Hub = { server: ReturnType<typeof Bun.serve>; tools: HubTool[]; stop: () => void }
43
43
 
44
- // the toolbox as data — the landing page, /hub.json, and the stdout ok:
45
- // lines all render THIS list, so they can never disagree
46
- export function hubTools(d: Pick<HubDeps, 'wikiGraph' | 'agentGraph' | 'studio'>): HubTool[] {
47
- const tools: HubTool[] = [{ path: '/graph/wiki', label: 'wiki graph', desc: d.wikiGraph.title }]
44
+ // the toolbox as data — stdout ok: lines and /hub.json render THIS list,
45
+ // so they can never disagree
46
+ export function hubTools(d: Pick<HubDeps, 'wikiGraph' | 'agentGraph'>): HubTool[] {
47
+ const tools: HubTool[] = [{ path: '/', label: 'hub', desc: 'THE page — media · asks · creds · posts · health · graphs' }]
48
+ tools.push({ path: '/graph/wiki', label: 'wiki graph', desc: d.wikiGraph.title })
48
49
  if (d.agentGraph != null) tools.push({ path: '/graph/agent', label: 'agent graph', desc: d.agentGraph.title })
49
- if (d.studio != null) tools.push({ path: '/assets/', label: 'assets studio', desc: 'roll, curate, pick, ship' })
50
- tools.push({ path: '/status', label: 'status', desc: 'STATUS.md + run check' })
51
50
  return tools
52
51
  }
53
52
 
53
+ // drill-down pages (graph canvases) get one fixed home link back to THE page
54
+ const HOME_LINK = `<style>.skhome{position:fixed;left:10px;top:10px;z-index:9999;font-size:17px;text-decoration:none;
55
+ background:#17140f;border:1px solid #332b22;border-radius:9px;padding:5px 9px}</style><a class=skhome href="/" title="hub — THE page">⌂</a>`
56
+ const withHome = (html: string): string => html.replace(/<body([^>]*)>/, `<body$1>${HOME_LINK}`)
57
+
54
58
  export function serveHub(d: HubDeps): Hub {
55
59
  const tools = hubTools(d)
56
- // every HTML page carries the rail — one keystroke between surfaces;
57
- // the red dot self-fetches client-side (no collector on passive navs)
58
- const shell = (s: string, active: string): Response =>
59
- new Response(withRail(s, { tools, active }), { headers: { 'content-type': 'text/html; charset=utf-8' } })
60
60
  const server: ReturnType<typeof Bun.serve> = Bun.serve({
61
61
  hostname: '127.0.0.1',
62
62
  port: d.port,
63
- // / (attention dashboard) and /status are the React app — one HTML
64
- // bundle, view picked by pathname client-side
65
- routes: { '/': hubIndex, '/status': hubIndex },
63
+ // `/` IS the page — the react app; there is no other surface
64
+ routes: { '/': hubIndex },
66
65
  async fetch(req): Promise<Response> {
67
66
  const path = new URL(req.url).pathname
68
67
  // identity probe: lets a second launch tell "same repo, another
@@ -72,9 +71,22 @@ export function serveHub(d: HubDeps): Hub {
72
71
  const needs = await d.needs().catch((e: Error) => [{ collection: 'hub', id: 'hub:broken', text: `needs projection broke: ${e.message}`, severity: 'red' as const }])
73
72
  return Response.json({ needs })
74
73
  }
74
+ if (path === '/api/collections.json') {
75
+ // browse-all: every instance of every collection, healthy
76
+ // included — failure-isolated per collection
77
+ const out: { id: string; title: string; media: string; instances: Instance[] }[] = []
78
+ for (const col of d.collections()) {
79
+ try {
80
+ out.push({ id: col.id, title: col.title, media: col.media, instances: await col.list() })
81
+ } catch (e) {
82
+ out.push({ id: col.id, title: col.title, media: col.media, instances: [{ id: `${col.id}:broken`, text: `collector broke: ${(e as Error).message}`, severity: 'red' }] })
83
+ }
84
+ }
85
+ return Response.json({ collections: out })
86
+ }
75
87
  if (path.startsWith('/api/act/') && req.method === 'POST') {
76
88
  // act-in-place verbs — every mutation hits a git-tracked truth
77
- // file; errors surface as 400 text the tile shows verbatim
89
+ // file; errors surface as 400 text the card shows verbatim
78
90
  try {
79
91
  const b = (await req.json()) as Record<string, string>
80
92
  if (path === '/api/act/ask-done') askDone(d.root, d.asksDir, b.file ?? '')
@@ -86,6 +98,16 @@ export function serveHub(d: HubDeps): Hub {
86
98
  markPosted(d.root, b.channel ?? '', b.file ?? '', b.url ?? '', rating)
87
99
  }
88
100
  else if (path === '/api/act/post-feedback') appendPostFeedback(d.root, b.channel ?? '', b.file ?? '', b.text ?? '')
101
+ else if (path === '/api/act/remark') {
102
+ // ONE remark verb, routed by collection — his words
103
+ // land in the item's own truth file
104
+ const text = (b.text ?? '').trim()
105
+ if (text === '') return new Response('empty remark', { status: 400 })
106
+ if (b.kind === 'ask') appendAskRemark(d.root, d.asksDir, b.file ?? '', text)
107
+ else if (b.kind === 'media') appendAssetNote(d.root, b.id ?? '', text)
108
+ else if (b.kind === 'post') appendPostFeedback(d.root, b.channel ?? '', b.file ?? '', text)
109
+ else return new Response('unknown remark kind', { status: 400 })
110
+ }
89
111
  else return new Response('unknown act', { status: 404 })
90
112
  return Response.json({ ok: true })
91
113
  } catch (e) {
@@ -96,28 +118,21 @@ export function serveHub(d: HubDeps): Hub {
96
118
  const p = join(d.root, 'STATUS.md')
97
119
  return Response.json({ statusMd: existsSync(p) ? await Bun.file(p).text() : null })
98
120
  }
99
- if (path === '/graph/wiki') return shell(graphHtml(d.wikiGraph.load(), { title: d.wikiGraph.title }), path)
100
- if (path === '/graph/wiki/graph.json') return Response.json(d.wikiGraph.load())
101
- if (d.agentGraph != null && path === '/graph/agent') return shell(graphHtml(d.agentGraph.load(), { title: d.agentGraph.title }), path)
102
- if (d.agentGraph != null && path === '/graph/agent/graph.json') return Response.json(d.agentGraph.load())
103
- if (path === '/status/run' && req.method === 'POST') {
121
+ if (path === '/api/check/run' && req.method === 'POST') {
104
122
  // stream wire lines as the check emits them; sh merges stderr
105
123
  // so FAIL lines reach the page, not just the terminal
106
124
  const proc = Bun.spawn(['sh', '-c', 'exec "$0" "$@" 2>&1', ...d.checkCmd], { cwd: d.root, stdout: 'pipe', stderr: 'ignore' })
107
125
  return new Response(proc.stdout, { headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } })
108
126
  }
109
- if (d.studio != null) {
110
- // the studio page fetches RELATIVE urls — it must live under
111
- // the slash-terminated prefix for them to resolve
112
- if (path === '/assets') return new Response(null, { status: 307, headers: { location: '/assets/' } })
113
- if (path === '/assets/' && req.method === 'GET') {
114
- // the SPA document gets the rail too; API routes untouched
115
- const res = await d.studio.handle(req, '/')
116
- return shell(await res.text(), '/assets/')
117
- }
118
- if (path.startsWith('/assets/')) return d.studio.handle(req, path.slice('/assets'.length))
119
- }
120
- return new Response('not found', { status: 404 })
127
+ const page = (s: string): Response => new Response(withHome(s), { headers: { 'content-type': 'text/html; charset=utf-8' } })
128
+ if (path === '/graph/wiki') return page(graphHtml(d.wikiGraph.load(), { title: d.wikiGraph.title }))
129
+ if (path === '/graph/wiki/graph.json') return Response.json(d.wikiGraph.load())
130
+ if (d.agentGraph != null && path === '/graph/agent') return page(graphHtml(d.agentGraph.load(), { title: d.agentGraph.title }))
131
+ if (d.agentGraph != null && path === '/graph/agent/graph.json') return Response.json(d.agentGraph.load())
132
+ // the media API namespace — the studio route table, ONE prefix strip
133
+ if (d.studio != null && path.startsWith('/media/')) return d.studio.handle(req, path.slice('/media'.length))
134
+ // stale bookmarks (/assets/, /status, …) land here — name the way home
135
+ return new Response('not found — the hub page is at /', { status: 404 })
121
136
  },
122
137
  })
123
138
  // RED-only pings ride the serving process: one tick now (state file
package/src/shipkit.ts CHANGED
@@ -1,12 +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
- export { defineRepo, loadRepoConfig, resolveFloor, allTasks, contractsTask, contractsConfigFrom, activeContracts } from './repo-config.ts'
3
+ export { defineRepo, loadRepoConfig, resolveFloor, allTasks, contractsTask } from './repo-config.ts'
4
4
  export type { RepoConfig, ServeConfig, FloorResolution, FloorSource } from './repo-config.ts'
5
+ export { contractsConfigFrom, activeContracts, applyContractsResult } from './contracts/apply-contracts.ts'
5
6
  // the hub (`shipkit serve`): one server, one port, every tool a click away
6
7
  export { serveHub, hubTools } from './serve/serve-hub.ts'
7
8
  export type { Hub, HubDeps, HubGraph, HubTool } from './serve/serve-hub.ts'
8
9
  export { runServe, resolveHubDeps, defaultHubPort } from './serve/run-serve.ts'
9
- export { withRail } from './serve/hub-pages.ts'
10
10
  export { projectNeeds, redCount } from './collections/collection-types.ts'
11
11
  export type { Collection, Instance, NeedItem, NeedSeverity, CurationVerb } from './collections/collection-types.ts'
12
12
  export { buildCollections } from './collections/providers.ts'
@@ -27,6 +27,11 @@ export { writeStatus } from './core/write-status.ts'
27
27
  export type { StatusInput, StatusOutcome } from './core/write-status.ts'
28
28
  export { formatLine, parseDuration, fmtAge, parseWireOutput } from './core/wire.ts'
29
29
  export type { Line } from './core/wire.ts'
30
+ // the SK rule registry — stable-id invariants `shipkit check` enforces;
31
+ // consumers (e.g. a wiki lint verifying mentioned ids exist) read RULES
32
+ export { RULES, ruleById } from './rules/rule-registry.ts'
33
+ export { tagRuleLines } from './rules/rule.ts'
34
+ export type { Rule, RuleEnv, FixResult } from './rules/rule.ts'
30
35
  export { evaluateContracts, pngDims } from './contracts/contracts-engine.ts'
31
36
  export type { ContractsConfig, ContractsResult, ContractOutcome } from './contracts/contracts-engine.ts'
32
37
  export { defineContract, validateManifest, file, png, glob, md, dir, custom, script, fileContains, dirExists } from './contracts/contract-types.ts'
@@ -41,9 +46,8 @@ export { defineKit, defineKind, comfy, canvas, manual, gemini, mediaOf, MEDIA_EX
41
46
  export type { KitConfig, ResolvedKit, Kind, KindMeta, Media, AssetSpec, GeneratorSpec, ComfyGen, CanvasGen, ManualGen, GeminiGen, PanelSpec, GeometryPanelSpec, GridPanelSpec, CustomPanelSpec, FoundAsset, ShipContext, ShipFn } from './assets/kit-config.ts'
42
47
  export { identityKinds, renderIdentity, renderIdentityFromMark, NO_PICK_FIX, FAVICON_SIZE, OG_W, OG_H, LOGO_SIZE } from './assets/kits/identity-kit.ts'
43
48
  export type { Brand } from './assets/kits/identity-kit.ts'
44
- export { serveStudio, studioRoutes } from './assets/server/studio-server.ts'
45
- export type { Studio, StudioDeps, StudioRoutes } from './assets/server/studio-server.ts'
46
- export { page as studioPage } from './assets/server/studio-page.ts'
49
+ export { studioRoutes } from './assets/server/studio-server.ts'
50
+ export type { StudioDeps, StudioRoutes } from './assets/server/studio-server.ts'
47
51
  export { StudioQueue } from './assets/server/studio-queue.ts'
48
52
  export type { Job, QueueState } from './assets/server/studio-queue.ts'
49
53
  export { cleanRect, liveRect, defaultLegs, migrateCircle } from './assets/panels/geometry-rects.ts'