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,429 @@
1
+ // Media (atelier) service — the studio state, per build/app-state-tree:
2
+ // observable state + actions + computeds live HERE; components read and
3
+ // call. Talks to the media API the hub mounts at /media/*. Kind/asset
4
+ // structure is read-only (kit.ts is code-reviewed); this edits WORKSPACE
5
+ // state: picks, alts, prompt overlays, meta sidecars, geometry rects.
6
+ import { makeAutoObservable, runInAction } from 'mobx'
7
+ import type { HubSt } from './hub-st.ts'
8
+ import type { AssetDetail, CatRow, Rect, SideAsset } from './hub-types.ts'
9
+
10
+ const MEDIA = '/media'
11
+ // thumbs change in place when a pick changes → ~8s cache buckets; roll
12
+ // files are immutable (one name = one file forever), so their URLs carry
13
+ // no bust — an <audio> src that never changes never restarts playback
14
+ const V = (): number => Date.now() >> 13
15
+ export const rollUrl = (id: string, name: string): string => `${MEDIA}/img?id=${encodeURIComponent(id)}&name=${encodeURIComponent(name)}`
16
+ export const thumbUrl = (id: string): string => `${MEDIA}/api/thumb?id=${encodeURIComponent(id)}&t=${V()}`
17
+ export const isAud = (n: string): boolean => ['mp3', 'wav', 'ogg', 'm4a'].includes(String(n ?? '').split('.').pop() ?? '')
18
+
19
+ const j = async (u: string): Promise<any> => {
20
+ const r = await fetch(MEDIA + u)
21
+ if (!r.ok) throw new Error(`${u}: ${r.status}`)
22
+ return r.json()
23
+ }
24
+ const post = async (u: string, b?: unknown): Promise<any> => {
25
+ const r = await fetch(MEDIA + u, { method: 'POST', body: JSON.stringify(b ?? {}) })
26
+ return r.json()
27
+ }
28
+
29
+ export class MediaSvc {
30
+ assets: SideAsset[] = []
31
+ pending: string[] = []
32
+ generating = false
33
+ genLine = ''
34
+ queued = 0
35
+ errors: string[] = []
36
+ progress: { value: number; max: number } | null = null
37
+ /** the media API vanished (mount failed) — shown loud, polling stops */
38
+ dead: string | null = null
39
+
40
+ detail: AssetDetail | null = null
41
+ active: string | null = null
42
+ /** strip hover preview (image rolls only — audio must not restart) */
43
+ hover: string | null = null
44
+ prompt = ''
45
+ promptMsg = ''
46
+ statusLine = ''
47
+ n = '3'
48
+
49
+ rect: Rect | null = null
50
+ rectRatio = 0.6
51
+
52
+ catRows: CatRow[] = []
53
+ allSize = ''
54
+ allMsg = ''
55
+
56
+ openCats = new Set<string>()
57
+ tab: string = 'asset'
58
+ drawerOpen = false
59
+ jsonText = ''
60
+ jsonMsg = ''
61
+
62
+ private hbTimer: ReturnType<typeof setInterval> | null = null
63
+ private rectTimer: ReturnType<typeof setTimeout> | null = null
64
+ private promptTimer: ReturnType<typeof setTimeout> | null = null
65
+ /** last text known saved for the CURRENT asset — an equal edit is not a save */
66
+ private savedPrompt = ''
67
+
68
+ constructor(private st: HubSt) {
69
+ makeAutoObservable<this, 'st' | 'hbTimer' | 'rectTimer' | 'promptTimer' | 'savedPrompt'>(
70
+ this, { st: false, hbTimer: false, rectTimer: false, promptTimer: false, savedPrompt: false })
71
+ }
72
+
73
+ start(): void {
74
+ void this.heartbeat()
75
+ this.hbTimer = setInterval(() => void this.heartbeat(), 1500)
76
+ }
77
+ dispose(): void {
78
+ if (this.hbTimer != null) clearInterval(this.hbTimer)
79
+ if (this.rectTimer != null) clearTimeout(this.rectTimer)
80
+ if (this.promptTimer != null) clearTimeout(this.promptTimer)
81
+ }
82
+
83
+ get curId(): string | null {
84
+ return this.st.sel?.t === 'asset' ? this.st.sel.id : null
85
+ }
86
+ get catMode(): string | null {
87
+ return this.st.sel?.t === 'cat' ? this.st.sel.cat : null
88
+ }
89
+ get cats(): string[] {
90
+ return [...new Set(this.visibleAssets.map((a) => a.cat))]
91
+ }
92
+ /** attention predicates — mirror the collection severities for media
93
+ * (providers.ts: manual+empty = red; unpicked rolls / stale = yellow) */
94
+ needsAsset = (a: SideAsset): boolean => a.stale || (a.manual && a.rolls === 0) || (a.rolls > 0 && !a.picked)
95
+ redAsset = (a: SideAsset): boolean => a.manual && a.rolls === 0
96
+ get visibleAssets(): SideAsset[] {
97
+ const f = this.st.filter
98
+ const base = f === 'all' ? this.assets : this.assets.filter(f === 'red' ? this.redAsset : this.needsAsset)
99
+ const q = this.st.query.trim().toLowerCase()
100
+ if (q === '') return base
101
+ return base.filter((a) => a.label.toLowerCase().includes(q) || a.id.toLowerCase().includes(q))
102
+ }
103
+ get cur(): SideAsset | null {
104
+ return this.assets.find((a) => a.id === this.curId) ?? null
105
+ }
106
+ get deterministic(): boolean {
107
+ return this.detail?.info.deterministic === true
108
+ }
109
+ get hasGeom(): boolean {
110
+ return this.detail?.asset.geometry === true && this.cur?.icon !== true
111
+ }
112
+
113
+ async heartbeat(): Promise<void> {
114
+ if (this.dead != null) return
115
+ let s: any
116
+ try {
117
+ const r = await fetch(MEDIA + '/api/state')
118
+ if (r.status === 404) {
119
+ // the mount failed at boot — it will not come back without a
120
+ // restart; say so once, loudly, and stop wasting the wire
121
+ runInAction(() => { this.dead = 'not mounted — fix the kit, restart the serve task' })
122
+ if (this.hbTimer != null) clearInterval(this.hbTimer)
123
+ return
124
+ }
125
+ if (!r.ok) throw new Error(String(r.status))
126
+ s = await r.json()
127
+ } catch {
128
+ // transient (serve task restarting, network blip) — KEEP polling;
129
+ // only a boot-time 404 is permanent (collections self-heal, so must we)
130
+ return
131
+ }
132
+ runInAction(() => {
133
+ this.assets = s.assets
134
+ this.pending = s.pending ?? []
135
+ this.generating = s.generating
136
+ this.queued = s.queued ?? 0
137
+ this.progress = s.progress ?? null
138
+ this.errors = s.errors ?? []
139
+ const p = s.progress
140
+ this.genLine = s.generating
141
+ ? `generating ${s.current ?? '…'} ${s.done}/${s.total}${s.queued ? ` · ${s.queued} queued` : ''}${p?.max ? ` · step ${p.value}/${p.max}` : ''}`
142
+ : ''
143
+ })
144
+ if (this.curId != null) await this.refreshRolls()
145
+ if (this.catMode != null) await this.fillCat()
146
+ this.st.afterFirstBeat()
147
+ }
148
+
149
+ async select(id: string): Promise<void> {
150
+ this.active = null
151
+ this.rect = null
152
+ this.detail = null
153
+ this.statusLine = ''
154
+ const a = this.assets.find((x) => x.id === id)
155
+ if (a != null) this.openCats.add(a.cat)
156
+ const r = (await j(`/api/asset?id=${encodeURIComponent(id)}`)) as AssetDetail
157
+ if (this.curId !== id) return // selection changed mid-fetch — never clobber
158
+ runInAction(() => {
159
+ this.detail = r
160
+ this.prompt = r.asset.prompt
161
+ this.savedPrompt = r.asset.prompt
162
+ this.promptMsg = ''
163
+ if (this.deterministic) this.n = '1'
164
+ if (this.tab.startsWith('c:') && !r.asset.custom.some((c) => `c:${c.id}` === this.tab)) this.tab = 'asset'
165
+ this.pickActive(r)
166
+ })
167
+ if (this.hasGeom) await this.loadRect()
168
+ void this.loadTab()
169
+ }
170
+
171
+ private pickActive(r: AssetDetail): void {
172
+ if (this.active == null || !r.rolls.some((x) => x.name === this.active))
173
+ this.active = (r.rolls.find((x) => x.picked) ?? r.rolls[0])?.name ?? null
174
+ }
175
+
176
+ async refreshRolls(): Promise<void> {
177
+ const id = this.curId
178
+ if (id == null) return
179
+ const r = (await j(`/api/asset?id=${encodeURIComponent(id)}`)) as AssetDetail
180
+ if (this.curId !== id) return
181
+ runInAction(() => {
182
+ if (this.detail == null) this.detail = r
183
+ else this.detail.rolls = r.rolls
184
+ this.pickActive(r)
185
+ })
186
+ }
187
+
188
+ setActive(name: string): void {
189
+ this.active = name
190
+ if (this.hasGeom) void this.loadRect()
191
+ if (this.tab !== 'asset') void this.loadTab()
192
+ }
193
+
194
+ async generate(): Promise<void> {
195
+ if (this.curId == null) return
196
+ await post('/api/generate', { id: this.curId, prompt: this.prompt, count: +this.n })
197
+ void this.heartbeat()
198
+ }
199
+ async genCat(cat: string, count: number): Promise<void> {
200
+ const r = await post('/api/generate-cat', { cat, count })
201
+ if (r?.error != null) runInAction(() => { this.statusLine = r.error })
202
+ void this.heartbeat()
203
+ }
204
+ clearQueue(): void {
205
+ void post('/api/queue/clear')
206
+ }
207
+ stopCurrent(): void {
208
+ void post('/api/interrupt')
209
+ }
210
+
211
+ async pick(name: string): Promise<void> {
212
+ const res = await post('/api/pick', { id: this.curId, name })
213
+ runInAction(() => {
214
+ this.statusLine = res.error != null ? `ship FAILED: ${res.error}` : res.shipped != null ? `shipped → ${res.shipped}` : 'picked (pick.json)'
215
+ this.active = name
216
+ })
217
+ await this.refreshRolls()
218
+ }
219
+ async alt(name: string): Promise<void> {
220
+ await post('/api/alt', { id: this.curId, name })
221
+ await this.refreshRolls()
222
+ }
223
+ async discard(name: string): Promise<void> {
224
+ await post('/api/discard', { id: this.curId, name })
225
+ runInAction(() => {
226
+ if (this.active === name) this.active = null
227
+ // the ✕ sits on the hovered tile and its unmount fires no
228
+ // mouseleave — a latched hover would 404 the stage
229
+ if (this.hover === name) this.hover = null
230
+ })
231
+ await this.refreshRolls()
232
+ }
233
+
234
+ async upload(files: FileList | File[]): Promise<void> {
235
+ if (this.curId == null) return
236
+ for (const f of Array.from(files)) {
237
+ const fd = new FormData()
238
+ fd.append('id', this.curId)
239
+ fd.append('file', f)
240
+ const r = await fetch(`${MEDIA}/api/upload`, { method: 'POST', body: fd }).then((x) => x.json()).catch((e: Error) => ({ error: String(e) }))
241
+ if (r.error != null) runInAction(() => { this.statusLine = `upload FAIL: ${r.error}` })
242
+ }
243
+ await this.refreshRolls()
244
+ }
245
+
246
+ /** prompt AUTOSAVES — edit, pause, it lands in the overlay (the server
247
+ * clears the overlay when the text equals the kit prompt). Guards: no
248
+ * save before the detail is in, no save of an unchanged text, and the
249
+ * asset id is captured at SCHEDULE time — a selection switch inside
250
+ * the debounce window must never write onto the wrong asset. */
251
+ setPrompt(v: string): void {
252
+ this.prompt = v
253
+ if (this.detail == null) return // nothing selected/loaded — nowhere to save
254
+ const id = this.curId
255
+ if (id == null) return
256
+ if (this.promptTimer != null) clearTimeout(this.promptTimer)
257
+ this.promptTimer = setTimeout(() => {
258
+ this.promptTimer = null
259
+ if (this.curId !== id) return // selection moved on — drop, never cross-write
260
+ const text = this.prompt
261
+ if (text === this.savedPrompt) return // unchanged — not a save
262
+ void (async () => {
263
+ const r = await post('/api/prompt', { id, prompt: text })
264
+ runInAction(() => {
265
+ if (this.curId !== id) return // selection moved while the POST flew — a late response must not corrupt the new asset's baseline
266
+ if (r.error != null) {
267
+ this.promptMsg = `save FAIL: ${r.error}`
268
+ return
269
+ }
270
+ this.savedPrompt = text
271
+ this.promptMsg = 'prompt saved'
272
+ })
273
+ setTimeout(() => runInAction(() => { if (this.promptMsg === 'prompt saved') this.promptMsg = '' }), 1500)
274
+ })()
275
+ }, 800)
276
+ }
277
+ copyPrompt(): void {
278
+ void navigator.clipboard.writeText(this.prompt)
279
+ this.promptMsg = 'copied'
280
+ setTimeout(() => runInAction(() => { this.promptMsg = '' }), 900)
281
+ }
282
+
283
+ toggleFold(cat: string): void {
284
+ if (this.st.query.trim() !== '') return // folds yield to a live search
285
+ this.openCats.has(cat) ? this.openCats.delete(cat) : this.openCats.add(cat)
286
+ }
287
+ /** rail 🖼 click — every category unfolds at once */
288
+ openAllCats(): void {
289
+ for (const a of this.assets) this.openCats.add(a.cat)
290
+ }
291
+
292
+ // ── geometry ──────────────────────────────────────────────────────────
293
+ async loadRect(): Promise<void> {
294
+ if (!this.hasGeom || this.curId == null || this.active == null) {
295
+ this.rect = null
296
+ return
297
+ }
298
+ const id = this.curId
299
+ const name = this.active
300
+ const r = await j(`/api/rect?id=${encodeURIComponent(id)}&name=${encodeURIComponent(name)}`).catch(() => null)
301
+ if (this.curId !== id || this.active !== name) return // roll switched mid-fetch
302
+ runInAction(() => {
303
+ this.rect = r?.rect ?? null
304
+ if (this.rect != null && this.rect.w > 0) this.rectRatio = this.rect.h / this.rect.w
305
+ })
306
+ }
307
+ /** 250ms trailing debounce → POST; server re-cleans + measures art.
308
+ * (id, name, rect) are captured at SCHEDULE time — an arrow-key roll
309
+ * switch inside the debounce window must never write the old roll's
310
+ * rect onto the new one */
311
+ saveRect(): void {
312
+ const id = this.curId
313
+ const name = this.active
314
+ const rect = this.rect
315
+ if (id == null || name == null || rect == null) return
316
+ if (this.rectTimer != null) clearTimeout(this.rectTimer)
317
+ this.rectTimer = setTimeout(() => {
318
+ void (async () => {
319
+ const r = await post('/api/rect', { id, name, rect })
320
+ runInAction(() => {
321
+ if (this.curId !== id || this.active !== name) return // selection moved on — never adopt a stale rect
322
+ if (r.rect != null) this.rect = r.rect
323
+ else if (r.error != null) this.statusLine = `rect FAIL: ${r.error}`
324
+ })
325
+ })()
326
+ }, 250)
327
+ }
328
+ async legsDefault(): Promise<void> {
329
+ if (this.curId == null || this.active == null) return
330
+ const r = await post('/api/legs-default', { id: this.curId, name: this.active })
331
+ if (r.rect != null) runInAction(() => { this.rect = r.rect })
332
+ }
333
+ removeLegs(): void {
334
+ if (this.rect?.legs == null) return
335
+ delete this.rect.legs
336
+ delete this.rect.legColor
337
+ this.saveRect()
338
+ }
339
+
340
+ // ── category grid ─────────────────────────────────────────────────────
341
+ async fillCat(): Promise<void> {
342
+ const mode = this.catMode
343
+ if (mode == null) return
344
+ const r = await j(`/api/catrects?cat=${encodeURIComponent(mode)}`)
345
+ if (this.catMode !== mode) return // navigated away mid-fetch
346
+ runInAction(() => { this.catRows = r.rows ?? [] })
347
+ }
348
+ async setSize(id: string, name: string, v: number): Promise<void> {
349
+ const row = this.catRows.find((r) => r.id === id)
350
+ if (row?.rect == null) return
351
+ const nv = Math.max(0.1, v || row.rect.size)
352
+ if (nv === row.rect.size) return // blur without a change is not a save
353
+ row.rect.size = nv
354
+ await post('/api/rect', { id, name, rect: row.rect })
355
+ }
356
+ async toggleFlip(id: string, name: string): Promise<void> {
357
+ await post('/api/flip', { id, name })
358
+ await this.fillCat()
359
+ }
360
+ async applyAllSize(): Promise<void> {
361
+ const v = Math.max(0.1, +this.allSize || 0)
362
+ if (!v) return
363
+ const rows = this.catRows.filter((r) => !r.icon && r.rect != null)
364
+ // one action for all mutations (post-await writes would land outside
365
+ // it — mobx strict mode), then the POSTs
366
+ runInAction(() => { for (const row of rows) row.rect!.size = v })
367
+ for (const row of rows) await post('/api/rect', { id: row.id, name: row.name, rect: row.rect })
368
+ runInAction(() => { this.allMsg = 'applied' })
369
+ setTimeout(() => runInAction(() => { this.allMsg = '' }), 1200)
370
+ await this.fillCat()
371
+ }
372
+
373
+ // ── drawer: asset json (read-only) · roll meta sidecar · custom panels ─
374
+ setTab(tab: string): void {
375
+ this.tab = tab
376
+ this.drawerOpen = true
377
+ void this.loadTab()
378
+ }
379
+ async loadTab(): Promise<void> {
380
+ this.jsonMsg = ''
381
+ if (this.tab.startsWith('c:')) return // panel host owns that surface
382
+ if (this.tab === 'asset') {
383
+ const text = this.detail != null ? JSON.stringify(this.detail.asset, null, 2) : ''
384
+ runInAction(() => { this.jsonText = text })
385
+ } else {
386
+ const r = this.curId != null && this.active != null ? await post('/api/meta', { id: this.curId, name: this.active }) : { text: '' }
387
+ runInAction(() => { this.jsonText = r.text ?? '' })
388
+ }
389
+ }
390
+ async saveMeta(): Promise<void> {
391
+ if (this.tab !== 'meta' || this.curId == null || this.active == null) return
392
+ const r = await post('/api/meta', { id: this.curId, name: this.active, text: this.jsonText })
393
+ runInAction(() => { this.jsonMsg = r.error != null ? `FAIL: ${r.error}` : 'saved' })
394
+ }
395
+ /** frozen per mount: a roll/asset switch remounts with a fresh api */
396
+ panelApi(c: { kind: string }): Record<string, unknown> {
397
+ const id = this.curId!
398
+ const name = this.active!
399
+ return {
400
+ assetId: id,
401
+ kindId: c.kind,
402
+ roll: name,
403
+ imgUrl: rollUrl(id, name),
404
+ getMeta: async () => {
405
+ const r = await post('/api/meta', { id, name })
406
+ if (r.error != null) throw new Error(r.error)
407
+ return r.text ? JSON.parse(r.text) : null
408
+ },
409
+ saveMeta: async (data: unknown) => {
410
+ const r = await post('/api/meta', { id, name, text: JSON.stringify(data) })
411
+ if (r.error != null) throw new Error(r.error)
412
+ },
413
+ }
414
+ }
415
+
416
+ // keyboard verbs (←/→ roll · Enter pick · A alt · X discard)
417
+ key(k: string): boolean {
418
+ const rolls = this.detail?.rolls ?? []
419
+ if (this.curId == null || rolls.length === 0) return false
420
+ const i = rolls.findIndex((x) => x.name === this.active)
421
+ if (k === 'ArrowRight' && i < rolls.length - 1) this.setActive(rolls[i + 1]!.name)
422
+ else if (k === 'ArrowLeft' && i > 0) this.setActive(rolls[i - 1]!.name)
423
+ else if (k === 'Enter' && this.active != null) void this.pick(this.active)
424
+ else if ((k === 'a' || k === 'A') && this.active != null) void this.alt(this.active)
425
+ else if ((k === 'x' || k === 'X') && this.active != null) void this.discard(this.active)
426
+ else return false
427
+ return true
428
+ }
429
+ }