rvlib-shipkit 0.6.0 → 0.8.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,56 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0 — 2026-07-22
4
+
5
+ New:
6
+
7
+ - `shipkit mv <old.md> <new.md>` in every repo: rename an md page + its
8
+ sibling asset dir + `<name>.check.ts`, sweeping every tracked file for
9
+ refs (full path, docs-root id, check filename, boundary-guarded so
10
+ lookalikes survive). Dry by default, `--write` applies, `--commit` adds
11
+ one conventional commit behind the repo's pre-commit gate. A repo
12
+ `mv:after` script (NOT `postmv`, bun npm-compat auto-runs post* names)
13
+ runs after the moves for repo-specific re-stamps. `movePage` exported.
14
+ `init` scaffolds the `mv` script; the package.json probe requires it.
15
+ Upgraders: re-run `shipkit init` (idempotent, only adds the gap).
16
+ - the hub detects its own staleness: lib version read at boot vs on disk
17
+ per attention load. A mismatched running process shows a red line telling
18
+ you to restart the task (field bug: a morning process served old studio
19
+ code all day and a pick looked broken).
20
+
21
+ ## 0.7.0 — 2026-07-22
22
+
23
+ New (the hub becomes THE dashboard; autonomy over nagging):
24
+
25
+ - hub UI rebuilt as a **Bun-native React app** (Bun.serve + HTML import, no
26
+ vite, no build step; MobX store). The old template-string landing +
27
+ status pages are DELETED.
28
+ - `/` IS the **attention dashboard**: failing checks (STATUS.md), open
29
+ asks, missing/unpicked/stale assets, contract gaps, planned posts — every
30
+ line links to its owning surface. Data via `/api/attention.json` +
31
+ `/api/status.json`; sources are failure-isolated (a broken collector
32
+ reports itself, never blanks the page).
33
+ - a thin left **rail** (⚡🕸🤖🖼📊, active-tab marked, red dot when
34
+ anything red) on EVERY hub page — React pages natively, legacy pages
35
+ (graph pages, studio SPA) via server-side injection until they migrate.
36
+ - **derived assets**: `from: ['parentId']` on an AssetSpec + the
37
+ `gemini({ model? })` backend (plain fetch, key from env GEMINI_API_KEY —
38
+ unset dies loud). The asset's prompt is the EDIT instruction; parents'
39
+ PICKED rolls feed generation as context images. Unknown refs, cycles and
40
+ non-gemini `from` die at defineKit. Each roll records its parent picks in
41
+ its meta sidecar.
42
+ - **autonomy contract — machine-clearable red clears itself**: re-picking a
43
+ parent AUTO-enqueues every now-stale child (no button, no nag; spend
44
+ gated on the intentful pick). Identity self-heals the same way: brand.ts
45
+ content hash in `.rv-identity/brand.hash`; `assets check` and the
46
+ attention collector detect drift and RE-RENDER public/* themselves —
47
+ only a missing pick still reaches a human. The old
48
+ "WARN outputs missing — run identity" line is gone.
49
+ - fill/add walk parents-first (topo) so derived children never generate
50
+ before their parents; a blocked derived asset FAILs naming the parent.
51
+ - studio: stale = orange sidebar dot; `/api/state` gains
52
+ `derived`/`stale` per asset.
53
+
3
54
  ## 0.6.0 — 2026-07-22
4
55
 
5
56
  New (wave 3 of the shipkit expansion — media + manual assets):
@@ -70,7 +121,7 @@ New (wave 1 of the shipkit expansion — uniform repo setup):
70
121
 
71
122
  - fix: `react-dom` was missing from dependencies AND the CLI entry loaded
72
123
  the react/ink chain eagerly — every command crashed in a fresh consumer
73
- repo without react-dom (found by the rabbits-vs-sheeps swap in the field,
124
+ repo without react-dom (found by a consumer-repo swap in the field,
74
125
  confirmed against the 0.3.0 tarball). tui now lazy-loads its body; deps
75
126
  carry react-dom. Proven by a fresh-repo install test (init → ci green in
76
127
  a react-less repo).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.6.0",
3
+ "version": "0.8.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,12 +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
27
29
  if (gen.backend === 'manual')
28
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
+ }
29
40
  if (gen.backend === 'canvas') {
30
41
  const { createCanvas } = await import('@napi-rs/canvas')
31
42
  const c = createCanvas(CANVAS_SIZE, CANVAS_SIZE)
@@ -10,9 +10,13 @@ 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
  }
@@ -23,10 +27,13 @@ export type CanvasGen = { backend: 'canvas'; draw: (ctx: unknown, asset: AssetSp
23
27
  * manual asset with no rolls is RED + FAILs `shipkit assets check`. The
24
28
  * asset's `prompt` is the curation brief. */
25
29
  export type ManualGen = { backend: 'manual' }
26
- export type GeneratorSpec = ComfyGen | CanvasGen | ManualGen
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
27
33
  export const comfy = (p?: { workflow?: string; bg?: string }): ComfyGen => ({ backend: 'comfy', workflow: p?.workflow, bg: p?.bg })
28
34
  export const canvas = (draw: CanvasGen['draw']): CanvasGen => ({ backend: 'canvas', draw })
29
35
  export const manual = (): ManualGen => ({ backend: 'manual' })
36
+ export const gemini = (p?: { model?: 'flash' | 'pro' }): GeminiGen => ({ backend: 'gemini', model: p?.model })
30
37
 
31
38
  export type Media = 'image' | 'audio'
32
39
  export const mediaOf = (kind: Kind): Media => kind.media ?? 'image'
@@ -130,6 +137,9 @@ export function validateKit(k: KitConfig): string[] {
130
137
  errs.push(`kind '${kind.id}': media 'audio' supports only the manual() backend today (generation backends are image-only)`)
131
138
  if (kind.media === 'audio' && (kind.meta?.panels ?? []).some((p) => p.panel === 'geometry'))
132
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`)
133
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`)
134
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`)
135
145
  for (const a of kind.assets ?? []) {
@@ -148,6 +158,19 @@ export function validateKit(k: KitConfig): string[] {
148
158
  errs.push(`kind '${kind.id}' panel '${p.id}': script must be repo-root-relative without '..' (got '${p.script}')`)
149
159
  }
150
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, [])
151
174
  return errs
152
175
  }
153
176
 
@@ -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++
@@ -70,12 +79,12 @@ export const runAssetsTopUp = (root: string, io: AssetsIo, o: { mode: 'fill' | '
70
79
  const inScope = allAssets(kit).filter((f) => o.categories.length === 0 || o.categories.includes(f.kind.category))
71
80
  const manualCount = inScope.filter((f) => f.kind.generate.backend === 'manual').length
72
81
  if (manualCount > 0) io.print(`skip: ${manualCount} manual asset(s) — upload in the studio`)
73
- const targets = inScope
74
- .filter((f) => f.kind.generate.backend !== 'manual')
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'))
75
85
  .map((f) => ({ f, have: listRolls(assetDir(join(root, kit.workspace), f.asset.id)).length }))
76
86
  .map((t) => ({ ...t, want: o.mode === 'add' ? o.n : Math.max(0, o.n - t.have) }))
77
87
  .filter((t) => t.want > 0)
78
- .sort((a, b) => a.have - b.have) // sparsest first
79
88
  let failed = 0
80
89
  for (const t of targets) failed += await rollOnce(root, io, kit, resolveComfy, t.f.asset.id, t.want)
81
90
  io.print(`${o.mode}: ${targets.length} asset(s) topped up${failed > 0 ? ` — ${failed} roll(s) FAILED` : ''}`)
@@ -89,26 +98,57 @@ export const runAssetsGen = (root: string, io: AssetsIo, o: { assetId: string; n
89
98
  return failed > 0 ? 1 : 0
90
99
  })
91
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
+
92
147
  export const runAssetsIdentity = (root: string, io: AssetsIo): Promise<number> =>
93
148
  guarded(io, async () => {
94
- const brandPath = join(root, '.rv-identity/brand.ts')
95
- if (!existsSync(brandPath)) die('no .rv-identity/brand.ts — create one exporting a Brand (name, palette, mark)')
96
- const mod = (await import(brandPath)) as { default?: Brand }
97
- if (mod.default == null) die('.rv-identity/brand.ts must default-export the Brand')
98
- const brand = mod.default!
99
- let markFile: string | undefined
100
- if (brand.mark.backend === 'comfy') {
101
- // comfy marks render from the PICKED identity-logo roll — the kit
102
- // owns the workspace where picks live
103
- const kit = await loadKit(root).catch((e: Error) => die(`comfy mark needs the kit's roll workspace — ${e.message}`))
104
- const dir = assetDir(join(root, kit.workspace), 'identity-logo')
105
- const pick = await readPick(dir)
106
- if (pick.pick == null) die(`brand '${brand.name}' uses a comfy mark and none is picked — ${NO_PICK_FIX}`)
107
- markFile = join(dir, pick.pick!)
108
- if (!existsSync(markFile)) die(`picked roll ${pick.pick} vanished from ${dir} — re-pick in the studio`)
109
- }
110
- const written = await renderIdentity(brand, root, { markFile })
111
- 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}`)
112
152
  return 0
113
153
  })
114
154
 
@@ -137,13 +177,14 @@ export const runAssetsCheck = (root: string, io: AssetsIo): Promise<number> =>
137
177
  const up = await fetch(`${kit.comfy.host}/system_stats`, { signal: AbortSignal.timeout(3000) }).then((r) => r.ok).catch(() => false)
138
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}` : ''}`)
139
179
  }
140
- if (existsSync(join(root, '.rv-identity/brand.ts'))) {
141
- const outs = ['public/favicon.png', 'public/og.png', 'public/logo.png'].filter((p) => !existsSync(join(root, p)))
142
- if (outs.length > 0) io.print(`WARN identity outputs missing: ${outs.join(', ')} — run: shipkit assets identity`)
143
- else io.print('ok: identity outputs present')
144
- }
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++ }
145
186
  // manual assets: no rolls = nothing to ship = RED (the missing list
146
- // is the shopping list — build/audio doctrine); rolls-but-no-pick = WARN
187
+ // is the shopping list — build/game-audio doctrine); rolls-but-no-pick = WARN
147
188
  const manuals = allAssets(kit).filter((f) => f.kind.generate.backend === 'manual')
148
189
  let manualBad = 0
149
190
  for (const f of manuals) {
@@ -49,6 +49,7 @@ select,input[type=number]{background:var(--bg);color:var(--ink);border:1px solid
49
49
  .vig.gen{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;color:var(--dim);font-size:11px}
50
50
  .card .th .spin{position:absolute;top:8px;right:8px}
51
51
  .dot.miss{background:#e5484d;border-color:#e5484d}
52
+ .dot.stale{background:#f5a524;border-color:#f5a524}
52
53
  .vig.aud{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;font-size:20px}
53
54
  .vig.aud audio{width:100%;height:24px}
54
55
  .stageaud{display:flex;flex-direction:column;align-items:center;gap:14px;font-size:48px;padding:40px}
@@ -225,7 +226,7 @@ window.addEventListener('hashchange',restoreFromHash)
225
226
  function toggleFold(c){openCats.has(c)?openCats.delete(c):openCats.add(c);lastSideSig=null;renderSide(assets)}
226
227
  function renderSide(list){
227
228
  assets=list;$('#count').textContent=list.length
228
- const sig=JSON.stringify(list.map(a=>[a.id,a.rolls,a.picked]))+'|'+cur+'|'+catMode+'|'+[...openCats].join(',')+'|'+pending.join(',')
229
+ const sig=JSON.stringify(list.map(a=>[a.id,a.rolls,a.picked,a.stale]))+'|'+cur+'|'+catMode+'|'+[...openCats].join(',')+'|'+pending.join(',')
229
230
  if(sig===lastSideSig&&$('#list').innerHTML!=='')return; lastSideSig=sig
230
231
  const cats=[...new Set(list.map(a=>a.cat))]
231
232
  $('#list').innerHTML=cats.map(c=>{
@@ -236,7 +237,7 @@ function renderSide(list){
236
237
  if(!open)return head
237
238
  return head+list.filter(a=>a.cat===c).map(a=>
238
239
  '<div class="arow'+(a.id===cur?' sel':'')+'" data-id="'+esc(a.id)+'" onclick="selectAsset(\\''+jsq(a.id)+'\\')"><div class=lb><b>'+esc(a.label)+
239
- '</b><span>'+a.rolls+' rolls</span></div>'+(pending.includes(a.id)?'<div class=spin></div>':'<div class="dot '+(a.picked?'done':(a.rolls?'some':(a.manual?'miss':'')))+'"'+(a.manual&&!a.rolls?' title="manual asset — nothing uploaded yet"':'')+'></div>')+'</div>').join('')
240
+ '</b><span>'+a.rolls+' rolls</span></div>'+(pending.includes(a.id)?'<div class=spin></div>':'<div class="dot '+(a.stale?'stale':(a.picked?'done':(a.rolls?'some':(a.manual?'miss':''))))+'"'+(a.manual&&!a.rolls?' title="manual asset — nothing uploaded yet"':'')+'></div>')+'</div>').join('')
240
241
  }).join('')
241
242
  }
242
243
  // ── per-asset info surface (W2-2): backend, workflow provenance, ship target,
@@ -8,6 +8,7 @@ import { existsSync, mkdirSync, unlinkSync } from 'fs'
8
8
  import { join, relative } from 'path'
9
9
  import { findAsset, allAssets, mediaOf, missingPanelScripts, MEDIA_EXTS, type CustomPanelSpec, type FoundAsset, type GeometryPanelSpec, type ResolvedKit, type ShipFn } from '../kit-config.ts'
10
10
  import { interrupt, randomSeed, type OnProgress } from '../gen/comfy-gen.ts'
11
+ import { isStale, parentIdsOf, resolveParentContext, type ParentContext } from '../gen/derived.ts'
11
12
  import { generateRoll, type ComfyRuntime } from '../gen/generate-roll.ts'
12
13
  import { loadWorkflows, type Workflow } from '../gen/workflow-loader.ts'
13
14
  import { alphaHeight, toPng } from '../image-ops.ts'
@@ -20,15 +21,16 @@ export type StudioDeps = {
20
21
  kit: ResolvedKit
21
22
  root: string
22
23
  page: () => string
23
- /** test seam — defaults to the comfy engine */
24
- generateOne?: (found: FoundAsset, prompt: string, seed: number, destDir: string, onProgress?: OnProgress) => Promise<void>
24
+ /** test seam — defaults to the real engines. `context` = a derived
25
+ * asset's resolved parent picks (queue resolves it at run time) */
26
+ generateOne?: (found: FoundAsset, prompt: string, seed: number, destDir: string, onProgress?: OnProgress, context?: ParentContext) => Promise<void>
25
27
  }
26
28
 
27
29
  export type Studio = { server: ReturnType<typeof Bun.serve>; queue: StudioQueue; stop: () => void }
28
30
 
29
31
  // what the studio detail view shows about an asset's plumbing (W2-2)
30
32
  export type AssetInfo = {
31
- backend: 'canvas' | 'comfy' | 'manual'
33
+ backend: 'canvas' | 'comfy' | 'manual' | 'gemini'
32
34
  /** canvas rolls have no seed input — every roll is identical */
33
35
  deterministic: boolean
34
36
  /** comfy only: resolved workflow id + its .rv-comfy path (note = why unresolved) */
@@ -100,15 +102,36 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
100
102
  if (kit.comfy == null || workflows == null) throw new Error('kit has no comfy config — only canvas kinds can generate')
101
103
  return { workflows, host: kit.comfy.host, heal: kit.comfy.heal }
102
104
  }
103
- const generateOne = deps.generateOne ?? ((found, prompt, seed, destDir, onProgress) =>
104
- generateRoll(found, { prompt, seed, destDir, onProgress }, resolveComfy))
105
+ const generateOne = deps.generateOne ?? ((found, prompt, seed, destDir, onProgress, context?: ParentContext) =>
106
+ generateRoll(found, { prompt, seed, destDir, onProgress, context }, resolveComfy))
105
107
 
106
108
  const queue = new StudioQueue(async (job, seed, onProgress) => {
107
109
  const found = findAsset(kit, job.assetId)
108
110
  if (found == null) throw new Error(`asset '${job.assetId}' not in the kit — job dropped`)
109
- await generateOne(found, job.prompt, seed, dirOf(job.assetId), onProgress)
111
+ // derived assets: parents' picks resolved at RUN time (a pick made
112
+ // while the job queued is honored); a blocker names the parent
113
+ let context: ParentContext | undefined
114
+ if (parentIdsOf(found).length > 0) {
115
+ const r = await resolveParentContext(kit, workspace, found)
116
+ if ('missing' in r) throw new Error(`'${job.assetId}' blocked — parents not ready: ${r.missing.join(', ')}`)
117
+ context = r
118
+ }
119
+ await generateOne(found, job.prompt, seed, dirOf(job.assetId), onProgress, context)
110
120
  }, randomSeed)
111
121
 
122
+ // machine-clearable red clears itself: re-picking a PARENT auto-enqueues
123
+ // every now-stale child (1 roll each) — no button, no nag. Generation
124
+ // spend is gated on the intentful pick, never on passive page loads.
125
+ const regenStaleChildren = async (parentId: string): Promise<void> => {
126
+ for (const f of allAssets(kit)) {
127
+ if (!parentIdsOf(f).includes(parentId)) continue
128
+ if (!(await isStale(kit, workspace, f))) continue
129
+ if (queue.pendingIds().includes(f.asset.id)) continue // already queued — never double-spend
130
+ queue.enqueue([{ assetId: f.asset.id, prompt: await promptOf(f), count: 1 }])
131
+ console.log(`ok: ${f.asset.id} auto-regen queued (parent '${parentId}' re-picked)`)
132
+ }
133
+ }
134
+
112
135
  // ── session/live rect plumbing ─────────────────────────────────────────
113
136
  const rectPath = (id: string, roll: string): string => join(dirOf(id), `${roll}.rect.json`)
114
137
  const sessionRect = async (f: FoundAsset, roll: string): Promise<GroundRect> => {
@@ -207,6 +230,7 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
207
230
  id: f.asset.id, cat: f.kind.category, label: f.asset.label ?? f.asset.id,
208
231
  prompt: await promptOf(f), rolls: listRolls(dir).length, picked: pick.pick != null,
209
232
  icon: f.kind.mode === 'icon', media: mediaOf(f.kind), manual: f.kind.generate.backend === 'manual',
233
+ derived: parentIdsOf(f).length > 0, stale: await isStale(kit, workspace, f),
210
234
  }
211
235
  }))
212
236
  return json({ ...queue.state, queued: queue.queuedCount, pending: queue.pendingIds(), errors: queue.errors.slice(-5), assets })
@@ -355,6 +379,7 @@ export async function studioRoutes(deps: StudioDeps): Promise<StudioRoutes> {
355
379
  s.pick = name
356
380
  s.shippedAt = res.shipped != null ? new Date().toISOString() : s.shippedAt
357
381
  })
382
+ void regenStaleChildren(f.asset.id).catch((e: Error) => queue.pushError(`auto-regen after '${f.asset.id}' pick: ${e.message}`))
358
383
  return json(res)
359
384
  } catch (e) {
360
385
  queue.pushError(`ship ${String(b.id)}: ${(e as Error).message}`)
@@ -0,0 +1,34 @@
1
+ // shipkit mv — md-page rename with repo-wide ref sweep. Thin clipanion
2
+ // surface; logic in mv/mv-md.ts.
3
+ import { Command, Option } from 'clipanion'
4
+ import { movePage } from '../mv/mv-md.ts'
5
+
6
+ export class MvCommand extends Command {
7
+ static override paths = [['mv']]
8
+
9
+ static override usage = Command.Usage({
10
+ category: 'Wiki',
11
+ description: 'rename an md page + sibling dir + check.ts, sweeping every tracked ref; renames must be CHEAP',
12
+ examples: [
13
+ ['dry run (default): show what would change', 'shipkit mv docs/old-name.md docs/new-name.md'],
14
+ ['apply + one conventional commit', 'shipkit mv docs/old-name.md docs/new-name.md --commit'],
15
+ ],
16
+ })
17
+
18
+ oldPath = Option.String()
19
+ newPath = Option.String()
20
+ write = Option.Boolean('--write', false, { description: 'apply (default is a dry run)' })
21
+ commit = Option.Boolean('--commit', false, { description: 'apply + git commit (pre-commit hook is the gate)' })
22
+
23
+ async execute(): Promise<number> {
24
+ try {
25
+ return movePage(process.cwd(), this.oldPath, this.newPath, {
26
+ print: (l) => this.context.stdout.write(`${l}\n`),
27
+ printErr: (l) => this.context.stderr.write(`${l}\n`),
28
+ }, { write: this.write, commit: this.commit })
29
+ } catch (e) {
30
+ this.context.stderr.write(`FAIL: ${e instanceof Error ? e.message : String(e)}\n`)
31
+ return 1
32
+ }
33
+ }
34
+ }
package/src/cli.ts CHANGED
@@ -10,6 +10,7 @@ import { CheckCommand } from './cli/cmd_Check.ts'
10
10
  import { CiCommand } from './cli/cmd_Ci.ts'
11
11
  import { ContractsCommand } from './cli/cmd_Contracts.ts'
12
12
  import { InitCommand } from './cli/cmd_Init.ts'
13
+ import { MvCommand } from './cli/cmd_Mv.ts'
13
14
  import { ServeCommand } from './cli/cmd_Serve.ts'
14
15
  import { TuiCommand } from './cli/cmd_Tui.ts'
15
16
  import { WikiLintCommand, WikiServeCommand } from './cli/cmd_Wiki.ts'
@@ -27,6 +28,7 @@ const cli = new Cli({
27
28
  cli.register(Builtins.HelpCommand)
28
29
  cli.register(Builtins.VersionCommand)
29
30
  cli.register(CiCommand)
31
+ cli.register(MvCommand)
30
32
  cli.register(CheckCommand)
31
33
  cli.register(TuiCommand)
32
34
  cli.register(ServeCommand)