rvlib-shipkit 0.5.0 → 0.7.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,62 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0 — 2026-07-22
4
+
5
+ New (the hub becomes THE dashboard; autonomy over nagging):
6
+
7
+ - hub UI rebuilt as a **Bun-native React app** (Bun.serve + HTML import, no
8
+ vite, no build step; MobX store). The old template-string landing +
9
+ status pages are DELETED.
10
+ - `/` IS the **attention dashboard**: failing checks (STATUS.md), open
11
+ asks, missing/unpicked/stale assets, contract gaps, planned posts — every
12
+ line links to its owning surface. Data via `/api/attention.json` +
13
+ `/api/status.json`; sources are failure-isolated (a broken collector
14
+ reports itself, never blanks the page).
15
+ - a thin left **rail** (⚡🕸🤖🖼📊, active-tab marked, red dot when
16
+ anything red) on EVERY hub page — React pages natively, legacy pages
17
+ (graph pages, studio SPA) via server-side injection until they migrate.
18
+ - **derived assets**: `from: ['parentId']` on an AssetSpec + the
19
+ `gemini({ model? })` backend (plain fetch, key from env GEMINI_API_KEY —
20
+ unset dies loud). The asset's prompt is the EDIT instruction; parents'
21
+ PICKED rolls feed generation as context images. Unknown refs, cycles and
22
+ non-gemini `from` die at defineKit. Each roll records its parent picks in
23
+ its meta sidecar.
24
+ - **autonomy contract — machine-clearable red clears itself**: re-picking a
25
+ parent AUTO-enqueues every now-stale child (no button, no nag; spend
26
+ gated on the intentful pick). Identity self-heals the same way: brand.ts
27
+ content hash in `.rv-identity/brand.hash`; `assets check` and the
28
+ attention collector detect drift and RE-RENDER public/* themselves —
29
+ only a missing pick still reaches a human. The old
30
+ "WARN outputs missing — run identity" line is gone.
31
+ - fill/add walk parents-first (topo) so derived children never generate
32
+ before their parents; a blocked derived asset FAILs naming the parent.
33
+ - studio: stale = orange sidebar dot; `/api/state` gains
34
+ `derived`/`stale` per asset.
35
+
36
+ ## 0.6.0 — 2026-07-22
37
+
38
+ New (wave 3 of the shipkit expansion — media + manual assets):
39
+
40
+ - `manual()` generator backend — human-provided assets, any media: no
41
+ Generate button (POST /api/generate 400s; fill/add skip with a visible
42
+ line; generate-cat excludes them and an all-manual category 400s with an
43
+ upload pointer — its +N buttons are hidden in the SPA), the studio's
44
+ upload (button or drag-drop on the roll strip, POST /api/upload multipart,
45
+ ext-gated per media, seed-style `v<seed>` names — no same-ms overwrite) is
46
+ the only roll source. Upload itself works on EVERY kind (touched-up
47
+ images welcome). A manual asset with NO rolls is RED in the sidebar and
48
+ FAILs `shipkit assets check` with its curation brief; rolls-but-no-pick
49
+ WARNs. The asset's `prompt` is the brief.
50
+ - `ship.dir` is now honored lib-side: with no via/fn hook, picking copies
51
+ the roll to `<dir>/<assetId>.<ext>` (zero-config ship); hooks still win
52
+ and read `dir` as data. Previously `dir` was inert without a repo hook.
53
+ - `media: 'audio'` on kinds — rolls `v<n>.mp3|wav|ogg|m4a`, studio renders
54
+ players (roll strip, stage, grid card) instead of thumbnails; audio kinds
55
+ require `manual()` (generation backends are image-only today) and reject
56
+ geometry panels, both validated loud at defineKit. `isAudioRoll` exported.
57
+ - barrel: `manual`, `mediaOf`, `MEDIA_EXTS`, `isAudioRoll` + types
58
+ `Media`/`ManualGen` exported.
59
+
3
60
  ## 0.5.0 — 2026-07-22
4
61
 
5
62
  New (wave 2 of the shipkit expansion — generation UX):
@@ -46,7 +103,7 @@ New (wave 1 of the shipkit expansion — uniform repo setup):
46
103
 
47
104
  - fix: `react-dom` was missing from dependencies AND the CLI entry loaded
48
105
  the react/ink chain eagerly — every command crashed in a fresh consumer
49
- repo without react-dom (found by the rabbits-vs-sheeps swap in the field,
106
+ repo without react-dom (found by a consumer-repo swap in the field,
50
107
  confirmed against the 0.3.0 tarball). tui now lazy-loads its body; deps
51
108
  carry react-dom. Proven by a fresh-repo install test (init → ci green in
52
109
  a react-less repo).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,7 +20,8 @@
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/bun": "^1.3.14",
23
- "@types/react": "^19.2.17"
23
+ "@types/react": "^19.2.17",
24
+ "@types/react-dom": "^19.2.3"
24
25
  },
25
26
  "dependencies": {
26
27
  "@napi-rs/canvas": "^1.0.2",
@@ -0,0 +1,83 @@
1
+ // Derived assets — `from: ['parentId']` on an AssetSpec: the parents'
2
+ // PICKED rolls feed generation as context images; each generated roll
3
+ // records the parent picks in its meta sidecar; a parent re-pick flips
4
+ // children to STALE. Shared by the studio queue and the fill/gen CLI.
5
+ import { join } from 'path'
6
+ import { findAsset, type FoundAsset, type KitConfig } from '../kit-config.ts'
7
+ import { assetDir, listRolls, metaPath, readPick } from '../roll-workspace.ts'
8
+ import { readJson, updateJson } from '../json-store.ts'
9
+ import type { ContextImage } from './gemini-gen.ts'
10
+
11
+ const MIME: Record<string, string> = { png: 'image/png', webp: 'image/webp' }
12
+
13
+ export type ParentContext = { images: ContextImage[]; parents: Record<string, string> }
14
+
15
+ export const parentIdsOf = (f: FoundAsset): string[] => f.asset.from ?? []
16
+
17
+ /** Parents' picked rolls as base64 context — or the list of blockers
18
+ * (unpicked/unknown parents), never a silent partial. */
19
+ export async function resolveParentContext(kit: KitConfig, workspace: string, f: FoundAsset): Promise<ParentContext | { missing: string[] }> {
20
+ const missing: string[] = []
21
+ const images: ContextImage[] = []
22
+ const parents: Record<string, string> = {}
23
+ for (const id of parentIdsOf(f)) {
24
+ const parent = findAsset(kit, id)
25
+ if (parent == null) { missing.push(`${id} (not in kit)`); continue }
26
+ const dir = assetDir(workspace, id)
27
+ const pick = (await readPick(dir)).pick
28
+ if (pick == null) { missing.push(`${id} (no pick)`); continue }
29
+ const ext = pick.split('.').at(-1)!
30
+ const file = Bun.file(join(dir, pick))
31
+ if (!(await file.exists())) { missing.push(`${id} (picked roll ${pick} vanished)`); continue }
32
+ images.push({ mime: MIME[ext] ?? 'image/png', base64: Buffer.from(await file.arrayBuffer()).toString('base64') })
33
+ parents[id] = pick
34
+ }
35
+ if (missing.length > 0) return { missing }
36
+ return { images, parents }
37
+ }
38
+
39
+ /** Stamp the parent picks onto every roll of this seed (v<seed>.*) — called
40
+ * after a successful derived generation. */
41
+ export async function recordParents(destDir: string, seed: number, parents: Record<string, string>): Promise<void> {
42
+ if (Object.keys(parents).length === 0) return
43
+ for (const roll of listRolls(destDir).filter((r) => r.startsWith(`v${seed}.`) || r.startsWith(`v${seed}-`)))
44
+ await updateJson<{ parents?: Record<string, string> }>(metaPath(destDir, roll), {}, (m) => { m.parents = parents })
45
+ }
46
+
47
+ /** A derived asset is STALE when its PICKED roll's recorded parent picks
48
+ * differ from the parents' CURRENT picks (or were never recorded). An
49
+ * unpicked asset is never stale — it's just unpicked. */
50
+ export async function isStale(kit: KitConfig, workspace: string, f: FoundAsset): Promise<boolean> {
51
+ const parentIds = parentIdsOf(f)
52
+ if (parentIds.length === 0) return false
53
+ const dir = assetDir(workspace, f.asset.id)
54
+ const pick = (await readPick(dir)).pick
55
+ if (pick == null) return false
56
+ const meta = await readJson<{ parents?: Record<string, string> }>(metaPath(dir, pick), {})
57
+ for (const id of parentIds) {
58
+ const current = (await readPick(assetDir(workspace, id))).pick
59
+ if (current == null) continue // parent lost its pick — child can't regen anyway; blocker surfaces at generate
60
+ if (meta.parents?.[id] !== current) return true
61
+ }
62
+ return false
63
+ }
64
+
65
+ /** Parents-first order for batch generation (fill/add, regen-stale).
66
+ * Cycles are rejected at defineKit; a stray one here throws loud. */
67
+ export function topoSort(assets: FoundAsset[]): FoundAsset[] {
68
+ const byId = new Map(assets.map((f) => [f.asset.id, f]))
69
+ const done = new Set<string>()
70
+ const visiting = new Set<string>()
71
+ const out: FoundAsset[] = []
72
+ const visit = (f: FoundAsset): void => {
73
+ if (done.has(f.asset.id)) return
74
+ if (visiting.has(f.asset.id)) throw new Error(`derived-asset cycle through '${f.asset.id}'`)
75
+ visiting.add(f.asset.id)
76
+ for (const id of parentIdsOf(f)) { const p = byId.get(id); if (p != null) visit(p) }
77
+ visiting.delete(f.asset.id)
78
+ done.add(f.asset.id)
79
+ out.push(f)
80
+ }
81
+ for (const f of assets) visit(f)
82
+ return out
83
+ }
@@ -0,0 +1,77 @@
1
+ // Gemini image backend — plain fetch, no SDK. THE backend for
2
+ // edit-from-parent flows (context images = parents' picked rolls); comfy
3
+ // stays for from-scratch art. Key from env (default GEMINI_API_KEY) — a
4
+ // missing key dies loud at generation, never silently.
5
+ import { mkdirSync } from 'fs'
6
+ import { join } from 'path'
7
+
8
+ export type GeminiModel = 'flash' | 'pro'
9
+ export const GEMINI_MODELS: Record<GeminiModel, string> = {
10
+ flash: 'gemini-2.5-flash-image',
11
+ pro: 'gemini-3-pro-image-preview',
12
+ }
13
+
14
+ export type GeminiOpts = {
15
+ /** env var carrying the API key (default GEMINI_API_KEY) */
16
+ apiKeyEnv?: string
17
+ /** test seam / self-host — default the real Google endpoint */
18
+ host?: string
19
+ }
20
+
21
+ export type ContextImage = { mime: string; base64: string }
22
+
23
+ const HOST = 'https://generativelanguage.googleapis.com'
24
+
25
+ export function geminiKey(o: GeminiOpts): string {
26
+ const env = o.apiKeyEnv ?? 'GEMINI_API_KEY'
27
+ const key = process.env[env]
28
+ if (key == null || key === '')
29
+ throw new Error(`env ${env} unset — the gemini() backend needs it (mint: aistudio.google.com/apikey, export it in the repo's scripts)`)
30
+ return key
31
+ }
32
+
33
+ type GenerateContentResponse = {
34
+ candidates?: { content?: { parts?: { inlineData?: { mimeType?: string; data?: string }; text?: string }[] } }[]
35
+ error?: { message?: string }
36
+ }
37
+
38
+ /** One generation: prompt + context images → image roll(s) saved as
39
+ * <destDir>/<name>.png (suffix -2… on multi-image). Returns saved paths. */
40
+ export async function generateGemini(
41
+ o: GeminiOpts,
42
+ args: { prompt: string; model?: GeminiModel; destDir: string; name: string; context: ContextImage[] },
43
+ ): Promise<string[]> {
44
+ const key = geminiKey(o)
45
+ const model = GEMINI_MODELS[args.model ?? 'flash']
46
+ const res = await fetch(`${o.host ?? HOST}/v1beta/models/${model}:generateContent`, {
47
+ method: 'POST',
48
+ headers: { 'Content-Type': 'application/json', 'x-goog-api-key': key },
49
+ body: JSON.stringify({
50
+ contents: [{ parts: [{ text: args.prompt }, ...args.context.map((c) => ({ inlineData: { mimeType: c.mime, data: c.base64 } }))] }],
51
+ }),
52
+ })
53
+ if (!res.ok) throw new Error(`gemini ${model} → ${res.status}: ${(await res.text()).slice(0, 200)}`)
54
+ const body = (await res.json()) as GenerateContentResponse
55
+ const parts = body.candidates?.[0]?.content?.parts ?? []
56
+ mkdirSync(args.destDir, { recursive: true })
57
+ const saved: string[] = []
58
+ for (const p of parts) {
59
+ if (p.inlineData?.data == null) continue
60
+ // roll names must satisfy the workspace regex (v<seed>.png|webp) or
61
+ // the studio never lists them — an invisible paid roll is the worst
62
+ // failure mode. Unexpected mimes die loud; extra images get their
63
+ // own seed-valid names instead of an unlistable -2 suffix.
64
+ const mime = p.inlineData.mimeType ?? 'image/png'
65
+ const ext = mime === 'image/webp' ? 'webp' : mime === 'image/png' ? 'png' : null
66
+ if (ext == null) throw new Error(`gemini returned mime '${mime}' — only png/webp land as rolls (got ${parts.length} part(s))`)
67
+ const name = saved.length === 0 ? args.name : `v${Math.floor(Math.random() * 2_000_000_000) + 1}`
68
+ const dest = join(args.destDir, `${name}.${ext}`)
69
+ await Bun.write(dest, Buffer.from(p.inlineData.data, 'base64'))
70
+ saved.push(dest)
71
+ }
72
+ if (saved.length === 0) {
73
+ const text = parts.find((p) => p.text != null)?.text?.slice(0, 160)
74
+ throw new Error(`gemini ${model} returned no image${text != null ? ` — model said: ${text}` : ''} (blocked prompt? wrong model?)`)
75
+ }
76
+ return saved
77
+ }
@@ -8,6 +8,8 @@ import { mkdirSync } from 'fs'
8
8
  import { join } from 'path'
9
9
  import type { FoundAsset } from '../kit-config.ts'
10
10
  import { generate, type OnProgress } from './comfy-gen.ts'
11
+ import { recordParents, type ParentContext } from './derived.ts'
12
+ import { generateGemini } from './gemini-gen.ts'
11
13
  import { loadWorkflows, workflowFor, type Workflow } from './workflow-loader.ts'
12
14
 
13
15
  /** roll canvas master size (square) — favicon/logo/sprites derive from it */
@@ -20,10 +22,21 @@ export type ComfyResolver = () => Promise<ComfyRuntime>
20
22
  * v<seed>.<ext> (comfy). `resolveComfy` is only awaited for comfy backends. */
21
23
  export async function generateRoll(
22
24
  found: FoundAsset,
23
- args: { prompt: string; seed: number; destDir: string; onProgress?: OnProgress },
25
+ args: { prompt: string; seed: number; destDir: string; onProgress?: OnProgress; context?: ParentContext },
24
26
  resolveComfy: ComfyResolver,
25
27
  ): Promise<void> {
26
28
  const gen = found.kind.generate
29
+ if (gen.backend === 'manual')
30
+ throw new Error(`'${found.asset.id}' is a manual asset — upload a file in the studio (button or drag-drop), nothing generates it`)
31
+ if (gen.backend === 'gemini') {
32
+ // derived assets NEED their parents' picked rolls — the caller
33
+ // resolves them (queue/CLI) so a blocker names the parent, loud
34
+ if ((found.asset.from?.length ?? 0) > 0 && args.context == null)
35
+ throw new Error(`'${found.asset.id}' derives from ${found.asset.from!.join(', ')} — parent context missing (are the parents picked?)`)
36
+ await generateGemini({}, { prompt: args.prompt, model: gen.model, destDir: args.destDir, name: `v${args.seed}`, context: args.context?.images ?? [] })
37
+ if (args.context != null) await recordParents(args.destDir, args.seed, args.context.parents)
38
+ return
39
+ }
27
40
  if (gen.backend === 'canvas') {
28
41
  const { createCanvas } = await import('@napi-rs/canvas')
29
42
  const c = createCanvas(CANVAS_SIZE, CANVAS_SIZE)
@@ -10,18 +10,36 @@ import type { GroundRect } from './panels/geometry-rects.ts'
10
10
 
11
11
  export type AssetSpec = {
12
12
  id: string
13
- /** FULL prompt text incl. style — the studio shows/edits exactly what generates */
13
+ /** FULL prompt text incl. style — the studio shows/edits exactly what
14
+ * generates. On a DERIVED asset it is the EDIT instruction. */
14
15
  prompt: string
15
16
  label?: string
17
+ /** derived asset: parent asset ids whose PICKED rolls feed generation as
18
+ * context images (gemini kinds only). Parent re-pick auto-regens. */
19
+ from?: string[]
16
20
  /** freeform data passed through to the ship hook */
17
21
  ship?: unknown
18
22
  }
19
23
 
20
24
  export type ComfyGen = { backend: 'comfy'; workflow?: string; bg?: string }
21
25
  export type CanvasGen = { backend: 'canvas'; draw: (ctx: unknown, asset: AssetSpec) => void | Promise<void> }
22
- export type GeneratorSpec = ComfyGen | CanvasGen
26
+ /** Human-provided assets (any media): the studio's upload mints rolls, a
27
+ * manual asset with no rolls is RED + FAILs `shipkit assets check`. The
28
+ * asset's `prompt` is the curation brief. */
29
+ export type ManualGen = { backend: 'manual' }
30
+ /** Gemini image backend (edit-from-parent flows; comfy stays for from-scratch). */
31
+ export type GeminiGen = { backend: 'gemini'; model?: 'flash' | 'pro' }
32
+ export type GeneratorSpec = ComfyGen | CanvasGen | ManualGen | GeminiGen
23
33
  export const comfy = (p?: { workflow?: string; bg?: string }): ComfyGen => ({ backend: 'comfy', workflow: p?.workflow, bg: p?.bg })
24
34
  export const canvas = (draw: CanvasGen['draw']): CanvasGen => ({ backend: 'canvas', draw })
35
+ export const manual = (): ManualGen => ({ backend: 'manual' })
36
+ export const gemini = (p?: { model?: 'flash' | 'pro' }): GeminiGen => ({ backend: 'gemini', model: p?.model })
37
+
38
+ export type Media = 'image' | 'audio'
39
+ export const mediaOf = (kind: Kind): Media => kind.media ?? 'image'
40
+ /** roll/upload extensions per media — ONE source for the workspace regex,
41
+ * the upload route, and the SPA's player-vs-img decision */
42
+ export const MEDIA_EXTS: Record<Media, string[]> = { image: ['webp', 'png'], audio: ['mp3', 'wav', 'ogg', 'm4a'] }
25
43
 
26
44
  export type GeometryPanelSpec = {
27
45
  panel: 'geometry'
@@ -81,10 +99,14 @@ export type Kind = {
81
99
  category: string
82
100
  assets: AssetSpec[]
83
101
  generate: GeneratorSpec
102
+ /** roll media (default 'image') — audio kinds render players, take no geometry */
103
+ media?: Media
84
104
  /** icon kinds get no geometry, thumbnails only */
85
105
  mode?: 'sprite' | 'icon'
86
106
  /** via = repo hook module path (default .rv-assets/ship.ts) · fn = a
87
- * built-in lib hook (e.g. identityKinds re-rendering the set) — never both */
107
+ * built-in lib hook (e.g. identityKinds re-rendering the set) — never
108
+ * both. With NO hook, `dir` alone is the zero-config ship: the picked
109
+ * roll is copied to <dir>/<assetId>.<ext>; hooks read dir as data. */
88
110
  ship?: { dir?: string; via?: string; fn?: ShipFn }
89
111
  meta?: KindMeta
90
112
  }
@@ -109,6 +131,15 @@ export function validateKit(k: KitConfig): string[] {
109
131
  if (kindIds.has(kind.id)) errs.push(`duplicate kind id '${kind.id}'`)
110
132
  kindIds.add(kind.id)
111
133
  if (kind.generate?.backend === 'comfy' && k.comfy == null) errs.push(`kind '${kind.id}' uses the comfy backend but the kit has no comfy config`)
134
+ if (kind.media != null && !(kind.media in MEDIA_EXTS))
135
+ errs.push(`kind '${kind.id}': unknown media '${String(kind.media)}' (${Object.keys(MEDIA_EXTS).join('/')})`)
136
+ if (kind.media === 'audio' && kind.generate?.backend !== 'manual')
137
+ errs.push(`kind '${kind.id}': media 'audio' supports only the manual() backend today (generation backends are image-only)`)
138
+ if (kind.media === 'audio' && (kind.meta?.panels ?? []).some((p) => p.panel === 'geometry'))
139
+ errs.push(`kind '${kind.id}': audio kinds take no geometry panel`)
140
+ for (const a of kind.assets ?? [])
141
+ if ((a.from?.length ?? 0) > 0 && kind.generate?.backend !== 'gemini')
142
+ errs.push(`kind '${kind.id}': asset '${a.id}' derives (from:) but the backend is '${kind.generate?.backend}' — only gemini() takes context images`)
112
143
  if (kind.ship?.fn != null && kind.ship?.via != null) errs.push(`kind '${kind.id}': ship.fn and ship.via are two hooks for one pick — keep one`)
113
144
  if (!Array.isArray(kind.assets) || kind.assets.length === 0) errs.push(`kind '${kind.id}' has no assets — a silent no-op kind is a config mistake`)
114
145
  for (const a of kind.assets ?? []) {
@@ -127,6 +158,19 @@ export function validateKit(k: KitConfig): string[] {
127
158
  errs.push(`kind '${kind.id}' panel '${p.id}': script must be repo-root-relative without '..' (got '${p.script}')`)
128
159
  }
129
160
  }
161
+ // derived-asset graph: every from-ref must exist, and the graph is a DAG
162
+ const fromOf = new Map<string, string[]>()
163
+ for (const kind of k.kinds ?? []) for (const a of kind.assets ?? []) fromOf.set(a.id, a.from ?? [])
164
+ for (const [id, refs] of fromOf) for (const r of refs) if (!fromOf.has(r)) errs.push(`asset '${id}' derives from unknown asset '${r}'`)
165
+ const state = new Map<string, 'visiting' | 'done'>()
166
+ const visit = (id: string, path: string[]): void => {
167
+ if (state.get(id) === 'done') return
168
+ if (state.get(id) === 'visiting') { errs.push(`derived-asset cycle: ${[...path, id].join(' → ')}`); return }
169
+ state.set(id, 'visiting')
170
+ for (const r of fromOf.get(id) ?? []) if (fromOf.has(r)) visit(r, [...path, id])
171
+ state.set(id, 'done')
172
+ }
173
+ for (const id of fromOf.keys()) visit(id, [])
130
174
  return errs
131
175
  }
132
176
 
@@ -15,7 +15,9 @@ export const PICK_INIT: PickState = { pick: null, alts: [], shippedAt: null }
15
15
  export type PromptOverlay = { prompt: string | null }
16
16
  export const PROMPT_INIT: PromptOverlay = { prompt: null }
17
17
 
18
- export const isRollFile = (f: string): boolean => /^v\d+\.(webp|png)$/.test(f)
18
+ // extension list mirrors kit-config MEDIA_EXTS (audio added 0.6.0)
19
+ export const isRollFile = (f: string): boolean => /^v\d+\.(webp|png|mp3|wav|ogg|m4a)$/.test(f)
20
+ export const isAudioRoll = (f: string): boolean => /\.(mp3|wav|ogg|m4a)$/.test(f)
19
21
 
20
22
  export const assetDir = (workspace: string, assetId: string): string => join(workspace, 'studio', assetId)
21
23
 
@@ -5,6 +5,7 @@ import { existsSync } from 'fs'
5
5
  import { join, relative } from 'path'
6
6
  import { randomSeed } from './gen/comfy-gen.ts'
7
7
  import { comfyResolver, generateRoll, type ComfyResolver } from './gen/generate-roll.ts'
8
+ import { parentIdsOf, resolveParentContext, topoSort, type ParentContext } from './gen/derived.ts'
8
9
  import { loadWorkflows } from './gen/workflow-loader.ts'
9
10
  import { allAssets, findAsset, loadKit, type ResolvedKit } from './kit-config.ts'
10
11
  import { NO_PICK_FIX, renderIdentity, type Brand } from './kits/identity-kit.ts'
@@ -28,11 +29,19 @@ async function rollOnce(root: string, io: AssetsIo, kit: ResolvedKit, resolveCom
28
29
  if (f == null) die(`unknown asset '${assetId}'`)
29
30
  const dir = assetDir(join(root, kit.workspace), assetId)
30
31
  const prompt = (await readPromptOverlay(dir)).prompt ?? f!.asset.prompt
32
+ // derived assets need their parents' picks as context — a blocker names
33
+ // the parent and fails the whole asset, never a partial
34
+ let context: ParentContext | undefined
35
+ if (parentIdsOf(f!).length > 0) {
36
+ const r = await resolveParentContext(kit, join(root, kit.workspace), f!)
37
+ if ('missing' in r) { io.printErr(`FAIL: ${assetId} blocked — parents not ready: ${r.missing.join(', ')}`); return count }
38
+ context = r
39
+ }
31
40
  let failed = 0
32
41
  for (let i = 0; i < count; i++) {
33
42
  const seed = randomSeed()
34
43
  try {
35
- await generateRoll(f!, { prompt, seed, destDir: dir }, resolveComfy)
44
+ await generateRoll(f!, { prompt, seed, destDir: dir, context }, resolveComfy)
36
45
  io.print(`ok: ${assetId} v${seed}`)
37
46
  } catch (e) {
38
47
  failed++
@@ -65,13 +74,17 @@ export const runAssetsTopUp = (root: string, io: AssetsIo, o: { mode: 'fill' | '
65
74
  guarded(io, async () => {
66
75
  const kit = await loadKit(root)
67
76
  const resolveComfy = comfyFor(root, kit)
68
- // canvas AND comfy kinds top up (A30 — canvas kinds draw in-process)
69
- const targets = allAssets(kit)
70
- .filter((f) => o.categories.length === 0 || o.categories.includes(f.kind.category))
77
+ // canvas AND comfy kinds top up (A30 — canvas kinds draw in-process);
78
+ // manual kinds have nothing to generate — visible skip, never silence
79
+ const inScope = allAssets(kit).filter((f) => o.categories.length === 0 || o.categories.includes(f.kind.category))
80
+ const manualCount = inScope.filter((f) => f.kind.generate.backend === 'manual').length
81
+ if (manualCount > 0) io.print(`skip: ${manualCount} manual asset(s) — upload in the studio`)
82
+ // topo (parents-first) replaced sparsest-first: derived children need
83
+ // their parents' rolls to exist before they can ever generate
84
+ const targets = topoSort(inScope.filter((f) => f.kind.generate.backend !== 'manual'))
71
85
  .map((f) => ({ f, have: listRolls(assetDir(join(root, kit.workspace), f.asset.id)).length }))
72
86
  .map((t) => ({ ...t, want: o.mode === 'add' ? o.n : Math.max(0, o.n - t.have) }))
73
87
  .filter((t) => t.want > 0)
74
- .sort((a, b) => a.have - b.have) // sparsest first
75
88
  let failed = 0
76
89
  for (const t of targets) failed += await rollOnce(root, io, kit, resolveComfy, t.f.asset.id, t.want)
77
90
  io.print(`${o.mode}: ${targets.length} asset(s) topped up${failed > 0 ? ` — ${failed} roll(s) FAILED` : ''}`)
@@ -85,26 +98,57 @@ export const runAssetsGen = (root: string, io: AssetsIo, o: { assetId: string; n
85
98
  return failed > 0 ? 1 : 0
86
99
  })
87
100
 
101
+ // ── identity: ONE render path + a brand.ts content hash so drift is
102
+ // machine-detectable — and machine-CLEARED (auto re-render), never a nag.
103
+ // Only a missing PICK still asks a human (taste).
104
+ const brandHashPath = (root: string): string => join(root, '.rv-identity/brand.hash')
105
+ const brandHash = async (root: string): Promise<string> =>
106
+ new Bun.CryptoHasher('sha256').update(await Bun.file(join(root, '.rv-identity/brand.ts')).text()).digest('hex').slice(0, 16)
107
+
108
+ export type IdentityRender = { written: string[] } | { blocked: string }
109
+
110
+ export async function renderIdentityNow(root: string): Promise<IdentityRender> {
111
+ const brandPath = join(root, '.rv-identity/brand.ts')
112
+ if (!existsSync(brandPath)) return { blocked: 'no .rv-identity/brand.ts — create one exporting a Brand (name, palette, mark)' }
113
+ // cache-bust: bun caches dynamic imports for the process lifetime — a
114
+ // long-running hub must re-render the EDITED brand, not the cached one
115
+ const mod = (await import(`${brandPath}?v=${await brandHash(root)}`)) as { default?: Brand }
116
+ if (mod.default == null) return { blocked: '.rv-identity/brand.ts must default-export the Brand' }
117
+ const brand = mod.default
118
+ let markFile: string | undefined
119
+ if (brand.mark.backend === 'comfy') {
120
+ // comfy marks render from the PICKED identity-logo roll — the kit
121
+ // owns the workspace where picks live
122
+ let kit: ResolvedKit
123
+ try { kit = await loadKit(root) } catch (e) { return { blocked: `comfy mark needs the kit's roll workspace — ${(e as Error).message}` } }
124
+ const dir = assetDir(join(root, kit.workspace), 'identity-logo')
125
+ const pick = await readPick(dir)
126
+ if (pick.pick == null) return { blocked: `brand '${brand.name}' uses a comfy mark and none is picked — ${NO_PICK_FIX}` }
127
+ markFile = join(dir, pick.pick)
128
+ if (!existsSync(markFile)) return { blocked: `picked roll ${pick.pick} vanished from ${dir} — re-pick in the studio` }
129
+ }
130
+ const written = await renderIdentity(brand, root, { markFile })
131
+ await Bun.write(brandHashPath(root), (await brandHash(root)) + '\n')
132
+ return { written }
133
+ }
134
+
135
+ /** brand.ts drifted since the last render → re-render NOW (autonomous).
136
+ * Returns what happened; 'blocked' = only a human pick can clear it. */
137
+ export async function healIdentity(root: string): Promise<{ state: 'fresh' | 'rendered' | 'blocked' | 'absent'; detail?: string }> {
138
+ if (!existsSync(join(root, '.rv-identity/brand.ts'))) return { state: 'absent' }
139
+ const outputsMissing = ['public/favicon.png', 'public/og.png', 'public/logo.png'].some((p) => !existsSync(join(root, p)))
140
+ const stored = existsSync(brandHashPath(root)) ? (await Bun.file(brandHashPath(root)).text()).trim() : null
141
+ if (!outputsMissing && stored === (await brandHash(root))) return { state: 'fresh' }
142
+ const r = await renderIdentityNow(root)
143
+ if ('blocked' in r) return { state: 'blocked', detail: r.blocked }
144
+ return { state: 'rendered', detail: r.written.join(', ') }
145
+ }
146
+
88
147
  export const runAssetsIdentity = (root: string, io: AssetsIo): Promise<number> =>
89
148
  guarded(io, async () => {
90
- const brandPath = join(root, '.rv-identity/brand.ts')
91
- if (!existsSync(brandPath)) die('no .rv-identity/brand.ts — create one exporting a Brand (name, palette, mark)')
92
- const mod = (await import(brandPath)) as { default?: Brand }
93
- if (mod.default == null) die('.rv-identity/brand.ts must default-export the Brand')
94
- const brand = mod.default!
95
- let markFile: string | undefined
96
- if (brand.mark.backend === 'comfy') {
97
- // comfy marks render from the PICKED identity-logo roll — the kit
98
- // owns the workspace where picks live
99
- const kit = await loadKit(root).catch((e: Error) => die(`comfy mark needs the kit's roll workspace — ${e.message}`))
100
- const dir = assetDir(join(root, kit.workspace), 'identity-logo')
101
- const pick = await readPick(dir)
102
- if (pick.pick == null) die(`brand '${brand.name}' uses a comfy mark and none is picked — ${NO_PICK_FIX}`)
103
- markFile = join(dir, pick.pick!)
104
- if (!existsSync(markFile)) die(`picked roll ${pick.pick} vanished from ${dir} — re-pick in the studio`)
105
- }
106
- const written = await renderIdentity(brand, root, { markFile })
107
- for (const w of written) io.print(`ok: ${w}`)
149
+ const r = await renderIdentityNow(root)
150
+ if ('blocked' in r) die(r.blocked)
151
+ for (const w of (r as { written: string[] }).written) io.print(`ok: ${w}`)
108
152
  return 0
109
153
  })
110
154
 
@@ -133,10 +177,23 @@ export const runAssetsCheck = (root: string, io: AssetsIo): Promise<number> =>
133
177
  const up = await fetch(`${kit.comfy.host}/system_stats`, { signal: AbortSignal.timeout(3000) }).then((r) => r.ok).catch(() => false)
134
178
  io.print(up ? `ok: comfy backend reachable (${kit.comfy.host})` : `WARN comfy backend unreachable (${kit.comfy.host})${kit.comfy.heal != null ? ` — ${kit.comfy.heal}` : ''}`)
135
179
  }
136
- if (existsSync(join(root, '.rv-identity/brand.ts'))) {
137
- const outs = ['public/favicon.png', 'public/og.png', 'public/logo.png'].filter((p) => !existsSync(join(root, p)))
138
- if (outs.length > 0) io.print(`WARN identity outputs missing: ${outs.join(', ')} — run: shipkit assets identity`)
139
- else io.print('ok: identity outputs present')
180
+ // machine-clearable red clears itself: drifted/missing identity
181
+ // outputs re-render HERE; only a missing pick (taste) reaches a human
182
+ const idn = await healIdentity(root)
183
+ if (idn.state === 'fresh') io.print('ok: identity outputs present + brand.ts unchanged')
184
+ else if (idn.state === 'rendered') io.print(`ok: identity re-rendered (brand.ts changed) — ${idn.detail}`)
185
+ else if (idn.state === 'blocked') { io.print(`FAIL: identity blocked — ${idn.detail}`); bad++ }
186
+ // manual assets: no rolls = nothing to ship = RED (the missing list
187
+ // is the shopping list — build/audio doctrine); rolls-but-no-pick = WARN
188
+ const manuals = allAssets(kit).filter((f) => f.kind.generate.backend === 'manual')
189
+ let manualBad = 0
190
+ for (const f of manuals) {
191
+ const dir = assetDir(join(root, kit.workspace), f.asset.id)
192
+ const rolls = listRolls(dir)
193
+ if (rolls.length === 0) { io.print(`FAIL: manual asset '${f.asset.id}' has no file — upload in the studio (brief: ${f.asset.prompt.slice(0, 60)})`); manualBad++ }
194
+ else if ((await readPick(dir)).pick == null) io.print(`WARN manual asset '${f.asset.id}' has ${rolls.length} file(s), none picked`)
140
195
  }
196
+ bad += manualBad
197
+ if (manuals.length > 0 && manualBad === 0) io.print(`ok: ${manuals.length} manual asset(s) provided`)
141
198
  return bad > 0 ? 1 : 0
142
199
  })