rvlib-shipkit 2.1.1 → 2.3.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 (142) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/package.json +2 -1
  3. package/src/agent-time/mask.ts +43 -0
  4. package/src/agent-time/render.ts +187 -0
  5. package/src/agent-time/report.ts +335 -0
  6. package/src/agent-time/shape.ts +271 -0
  7. package/src/agent-time/signals.ts +84 -0
  8. package/src/agent-time/store.ts +379 -0
  9. package/src/agent-time/timeline.ts +218 -0
  10. package/src/agent-time/turns.ts +135 -0
  11. package/src/assets/kits/identity-kit.ts +7 -3
  12. package/src/assets/run-assets.ts +18 -4
  13. package/src/checks/SK004-gitignore-policy.check.ts +11 -6
  14. package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
  15. package/src/cli/cmd_Calls.ts +53 -0
  16. package/src/cli/cmd_Commands.ts +163 -43
  17. package/src/cli/cmd_Review.ts +948 -0
  18. package/src/cli/cmd_Shot.ts +63 -0
  19. package/src/cli/cmd_Start.ts +8 -4
  20. package/src/cli/cmd_Time.ts +112 -0
  21. package/src/cli/cmd_Trailer.ts +361 -0
  22. package/src/cli/kit/builtins.ts +40 -0
  23. package/src/cli/kit/calls.ts +177 -0
  24. package/src/cli/kit/families.ts +67 -0
  25. package/src/cli/kit/index.ts +8 -0
  26. package/src/cli/kit/pages-check.ts +36 -0
  27. package/src/cli/kit/project-cli.ts +73 -0
  28. package/src/cli/kit/verb.ts +167 -0
  29. package/src/cli.ts +15 -2
  30. package/src/collections/collection-types.ts +6 -1
  31. package/src/collections/providers.ts +3 -0
  32. package/src/collections/review.ts +70 -0
  33. package/src/core/layout.ts +12 -2
  34. package/src/core/migrate-layout.ts +31 -8
  35. package/src/core/one-way-in-policy.ts +12 -4
  36. package/src/reflect/prompt-corpus.ts +15 -114
  37. package/src/reflect/transcript.ts +254 -0
  38. package/src/review/anim.ts +228 -0
  39. package/src/review/auto.ts +141 -0
  40. package/src/review/format.ts +150 -0
  41. package/src/review/http.ts +23 -0
  42. package/src/review/marks.ts +43 -0
  43. package/src/review/media.ts +45 -0
  44. package/src/review/model.ts +854 -0
  45. package/src/review/project.ts +110 -0
  46. package/src/review/role.ts +48 -0
  47. package/src/review/routes.ts +228 -0
  48. package/src/review/store.ts +1002 -0
  49. package/src/review/threads.ts +237 -0
  50. package/src/review/trailer-desk.ts +385 -0
  51. package/src/review/trailer-model.ts +183 -0
  52. package/src/review/trailer-routes.ts +136 -0
  53. package/src/review/unit.ts +165 -0
  54. package/src/review/watchers.ts +54 -0
  55. package/src/scaffold/cli-skeleton.ts +149 -0
  56. package/src/scaffold/files/agent-index.md +2 -2
  57. package/src/scaffold/files/tsconfig.json +3 -0
  58. package/src/scaffold/files/verify-skill.md +1 -1
  59. package/src/scaffold/project-scaffold.ts +90 -12
  60. package/src/serve/embed.ts +35 -0
  61. package/src/serve/hub-app/activity-view.tsx +1 -1
  62. package/src/serve/hub-app/activity.css +0 -4
  63. package/src/serve/hub-app/dock-view.tsx +5 -4
  64. package/src/serve/hub-app/hash-route.ts +28 -1
  65. package/src/serve/hub-app/header-bar.tsx +3 -1
  66. package/src/serve/hub-app/home-st.ts +10 -2
  67. package/src/serve/hub-app/home-strip.tsx +3 -2
  68. package/src/serve/hub-app/home.html +1 -0
  69. package/src/serve/hub-app/hub-app.tsx +80 -11
  70. package/src/serve/hub-app/hub.css +45 -13
  71. package/src/serve/hub-app/icons.tsx +9 -0
  72. package/src/serve/hub-app/index.html +1 -0
  73. package/src/serve/hub-app/insights-view.tsx +4 -3
  74. package/src/serve/hub-app/keys.ts +14 -10
  75. package/src/serve/hub-app/layout-st.ts +63 -2
  76. package/src/serve/hub-app/planned-view.tsx +3 -2
  77. package/src/serve/hub-app/planned.css +0 -1
  78. package/src/serve/hub-app/project-list.css +22 -4
  79. package/src/serve/hub-app/project-list.tsx +41 -8
  80. package/src/serve/hub-app/projects-svc.ts +4 -0
  81. package/src/serve/hub-app/repo-sections.ts +15 -3
  82. package/src/serve/hub-app/repo-st.ts +45 -3
  83. package/src/serve/hub-app/review/clip-st.ts +208 -0
  84. package/src/serve/hub-app/review/kind-view.tsx +171 -0
  85. package/src/serve/hub-app/review/kit.tsx +113 -0
  86. package/src/serve/hub-app/review/review-answer.tsx +239 -0
  87. package/src/serve/hub-app/review/review-board.tsx +46 -0
  88. package/src/serve/hub-app/review/review-list.tsx +156 -0
  89. package/src/serve/hub-app/review/review-page.tsx +101 -0
  90. package/src/serve/hub-app/review/review-st.ts +779 -0
  91. package/src/serve/hub-app/review/review-views.tsx +503 -0
  92. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  93. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  94. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  95. package/src/serve/hub-app/review.css +329 -0
  96. package/src/serve/hub-app/theme-st.ts +56 -0
  97. package/src/serve/hub-app/tree-build.ts +4 -0
  98. package/src/serve/hub-app/tree-row.tsx +1 -1
  99. package/src/serve/hub-app/tree-svc.ts +4 -0
  100. package/src/serve/hub-runtime.ts +5 -3
  101. package/src/serve/same-origin.ts +5 -1
  102. package/src/serve/serve-hub.ts +8 -0
  103. package/src/shipkit-files.ts +12 -4
  104. package/src/shot/client.ts +67 -0
  105. package/src/shot/clip-encoder.ts +146 -0
  106. package/src/shot/dom-clip.ts +124 -0
  107. package/src/shot/serve-shot.ts +54 -0
  108. package/src/shot/shooter.ts +343 -0
  109. package/src/shot/shot-ask.ts +189 -0
  110. package/src/shot/shot.shipkit.ts +14 -0
  111. package/src/smoke/run-smoke.ts +2 -2
  112. package/src/start-repo.ts +29 -14
  113. package/src/suite/judge-suites.ts +56 -10
  114. package/src/suite/select.ts +2 -5
  115. package/src/suite/suite-cli.ts +3 -2
  116. package/src/trailer/adapter.ts +78 -0
  117. package/src/trailer/build.ts +603 -0
  118. package/src/trailer/ffmpeg.ts +72 -0
  119. package/src/trailer/footage.ts +173 -0
  120. package/src/trailer/grammar.ts +1290 -0
  121. package/src/trailer/graph.ts +508 -0
  122. package/src/trailer/holes.ts +72 -0
  123. package/src/trailer/load-unit.ts +31 -0
  124. package/src/trailer/look.ts +124 -0
  125. package/src/trailer/passage.ts +129 -0
  126. package/src/trailer/plan.ts +192 -0
  127. package/src/trailer/prune.ts +193 -0
  128. package/src/trailer/score/compose.ts +240 -0
  129. package/src/trailer/score/dsp.ts +272 -0
  130. package/src/trailer/score/palette.ts +188 -0
  131. package/src/trailer/score/pattern.ts +91 -0
  132. package/src/trailer/score/score.ts +214 -0
  133. package/src/trailer/score/sound.ts +112 -0
  134. package/src/trailer/score/synth.ts +361 -0
  135. package/src/trailer/states.ts +76 -0
  136. package/src/trailer/stitch.ts +233 -0
  137. package/src/trailer/titles.ts +379 -0
  138. package/src/trailer/url.ts +108 -0
  139. package/src/trailer/vertical-draw.ts +286 -0
  140. package/src/trailer/vertical.ts +148 -0
  141. package/src/trailer/vocab.ts +59 -0
  142. package/src/trailer/words.ts +254 -0
@@ -0,0 +1,343 @@
1
+ // the browser half of the shots: ONE headless chromium, ONE booted page kept
2
+ // warm, one request at a time on it (`Turns`). A still reuses the page when
3
+ // it already shows the url (its own HMR keeps it current), so a warm shot is
4
+ // a ready check plus chromium's fast capture. A clip films on a page of its
5
+ // own in the same browser, so it never disturbs the warm page: a page that IS
6
+ // a canvas through the page's WebCodecs (clip-encoder.ts), any other page
7
+ // through chromium's screencast and ffmpeg (dom-clip.ts).
8
+ //
9
+ // it never slows the machine's other work: the browser runs niced, the page
10
+ // stays live only LIVE_MS after a request and is then frozen (no frame, no
11
+ // timer, no cpu), a frozen page is loaded again before its next still (an
12
+ // edit it missed while frozen never reaches a shot), and the browser closes
13
+ // after BROWSER_IDLE_MS.
14
+ //
15
+ // the constraint a reader breaks first: Playwright is NOT a dependency of the
16
+ // lib (a toolkit every repo installs carries no browser). It is resolved from
17
+ // the repo that runs this, through the one resolver `loadPlaywright`.
18
+ import { mkdirSync } from 'fs'
19
+ import { setPriority } from 'os'
20
+ import { dirname } from 'path'
21
+ import { loadPlaywright } from '../smoke/run-smoke.ts'
22
+ import { CLIP_INIT, CLIP_QUANTIZER, clipDone, installClip, KEY_EVERY, muxArgs, type PageClipAsk, type PageClipDone, TAKE, takeClip } from './clip-encoder.ts'
23
+ import { type ClipPath, clipPathOf, domEncodeArgs, FrameTimeline, largestCanvas, screencastParams } from './dom-clip.ts'
24
+ import { capMsOf, type ClipAsk, pageMove, readMark, type ShotAsk, slotKey, Turns, waitReady } from './shot-ask.ts'
25
+
26
+ /** the slice of playwright the shots touch, so the lib types nothing it does not ship */
27
+ type Cdp = {
28
+ send(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>>
29
+ on(event: string, cb: (e: Record<string, unknown>) => void): unknown
30
+ off(event: string, cb: (e: Record<string, unknown>) => void): unknown
31
+ }
32
+ type Page = {
33
+ goto(url: string, o?: { waitUntil?: string; timeout?: number }): Promise<unknown>
34
+ reload(o?: { waitUntil?: string; timeout?: number }): Promise<unknown>
35
+ evaluate<R, A>(fn: (a: A) => R | Promise<R>, arg?: A): Promise<R>
36
+ waitForTimeout(ms: number): Promise<void>
37
+ on(event: string, cb: (x: { message?: string; text?(): string; type?(): string }) => void): void
38
+ close(): Promise<void>
39
+ isClosed(): boolean
40
+ }
41
+ type Context = { newPage(): Promise<Page>; newCDPSession(p: Page): Promise<Cdp>; addInitScript(s: { content: string }): Promise<void>; close(): Promise<void> }
42
+ type Browser = {
43
+ newContext(o: { viewport: { width: number; height: number }; deviceScaleFactor: number; locale?: string }): Promise<Context>
44
+ close(): Promise<void>
45
+ isConnected(): boolean
46
+ on(event: 'disconnected', cb: () => void): void
47
+ }
48
+ type Playwright = { chromium: { launch(o: { headless: boolean; args: string[] }): Promise<Browser> } }
49
+
50
+ /** WebGL on the GPU over metal on a mac: in software a 3D page crawls. Audio plays with no gesture */
51
+ export const BROWSER_ARGS = [...(process.platform === 'darwin' ? ['--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist'] : []), '--autoplay-policy=no-user-gesture-required']
52
+
53
+ /** the page keeps running this long after a request (an agent's burst of shots), then it is frozen */
54
+ export const LIVE_MS = 10_000
55
+ /** the browser closes after this long with no request; the next shot pays its launch again (about 1 s) */
56
+ export const BROWSER_IDLE_MS = 60_000
57
+ /** the niceness of the process that launches the browser: chromium inherits it */
58
+ export const SHOT_NICE = 10
59
+ /** a page that paints nothing once the screencast starts is filmed from one still taken after this */
60
+ const FIRST_FRAME_MS = 1000
61
+
62
+ export type ShotDone = { out: string; ms: number; phases: Record<string, number>; errors: string[]; note: string }
63
+
64
+ type Slot = { key: string; url: string; ctx: Context; page: Page; cdp: Cdp; errors: string[]; frozen: boolean }
65
+
66
+ /** the roots tried for playwright, in order: the repo that runs the shots, then wherever this lib sits */
67
+ export async function resolvePlaywright(roots: string[]): Promise<Playwright> {
68
+ let last: unknown = null
69
+ for (const r of roots) {
70
+ try {
71
+ return await loadPlaywright<Playwright>(r)
72
+ } catch (e) {
73
+ last = e
74
+ }
75
+ }
76
+ throw last instanceof Error ? last : new Error(String(last))
77
+ }
78
+
79
+ const ms = (t0: number): number => Math.round(performance.now() - t0)
80
+
81
+ const withCap = <T>(what: string, capMs: number, p: Promise<T>): Promise<T> =>
82
+ Promise.race([p, new Promise<T>((_, fail) => setTimeout(() => fail(new Error(`${what} took past ${capMs / 1000} s`)), capMs).unref?.())])
83
+
84
+ export class Shooter {
85
+ readonly #roots: string[]
86
+ readonly #turns = new Turns()
87
+ #browser: Browser | null = null
88
+ #launching: Promise<Browser> | null = null
89
+ #slot: Slot | null = null
90
+ #idle: ReturnType<typeof setTimeout> | null = null
91
+ #live: ReturnType<typeof setTimeout> | null = null
92
+
93
+ constructor(roots: string[]) {
94
+ this.#roots = roots
95
+ }
96
+
97
+ get warm(): { browser: boolean; url: string | null; waiting: number } {
98
+ return { browser: this.#browser?.isConnected() === true, url: this.#slot?.url ?? null, waiting: this.#turns.waiting }
99
+ }
100
+
101
+ /** a png of the url, written at `a.out` */
102
+ shot(a: ShotAsk): Promise<ShotDone> {
103
+ return this.#turns.run(() => withCap(`the shot of ${a.url}`, capMsOf(a), this.#shot(a)).finally(() => this.#touch()))
104
+ }
105
+
106
+ /** an mp4 of `a.seconds` of the url, written at `a.out`: its canvas when the canvas is the page, else the page */
107
+ clip(a: ClipAsk): Promise<ShotDone> {
108
+ return this.#turns.run(() => withCap(`the clip of ${a.url}`, capMsOf(a), this.#clip(a)).finally(() => this.#touch()))
109
+ }
110
+
111
+ async close(): Promise<void> {
112
+ if (this.#idle) clearTimeout(this.#idle)
113
+ if (this.#live) clearTimeout(this.#live)
114
+ this.#slot = null
115
+ const b = this.#browser
116
+ this.#browser = null
117
+ await b?.close().catch(() => undefined)
118
+ }
119
+
120
+ #touch(): void {
121
+ if (this.#idle) clearTimeout(this.#idle)
122
+ this.#idle = setTimeout(() => void this.close(), BROWSER_IDLE_MS)
123
+ this.#idle.unref?.()
124
+ if (this.#live) clearTimeout(this.#live)
125
+ this.#live = setTimeout(() => void this.#turns.run(() => this.#freeze()), LIVE_MS)
126
+ this.#live.unref?.()
127
+ }
128
+
129
+ async #freeze(): Promise<void> {
130
+ const s = this.#slot
131
+ if (s == null || s.frozen || s.page.isClosed()) return
132
+ await s.cdp.send('Page.setWebLifecycleState', { state: 'frozen' }).catch(() => undefined)
133
+ s.frozen = true
134
+ }
135
+
136
+ #ensureBrowser(): Promise<Browser> {
137
+ if (this.#browser?.isConnected()) return Promise.resolve(this.#browser)
138
+ this.#launching ??= (async () => {
139
+ const pw = await resolvePlaywright(this.#roots)
140
+ try {
141
+ setPriority(SHOT_NICE)
142
+ } catch {}
143
+ const b = await pw.chromium.launch({ headless: true, args: BROWSER_ARGS })
144
+ b.on('disconnected', () => {
145
+ if (this.#browser === b) {
146
+ this.#browser = null
147
+ this.#slot = null
148
+ }
149
+ })
150
+ this.#browser = b
151
+ return b
152
+ })().finally(() => (this.#launching = null))
153
+ return this.#launching
154
+ }
155
+
156
+ async #context(a: ShotAsk): Promise<Context> {
157
+ const b = await this.#ensureBrowser()
158
+ return b.newContext({ viewport: a.viewport, deviceScaleFactor: a.dpr, locale: 'en-US' })
159
+ }
160
+
161
+ static #listen(page: Page, errors: string[]): void {
162
+ page.on('pageerror', (e) => errors.push(`pageerror: ${e.message ?? ''}`))
163
+ page.on('console', (m) => {
164
+ if (m.type?.() === 'error') errors.push(`console: ${m.text?.() ?? ''}`)
165
+ })
166
+ }
167
+
168
+ async #shot(a: ShotAsk): Promise<ShotDone> {
169
+ const phases: Record<string, number> = {}
170
+ const t0 = performance.now()
171
+ let move = pageMove(this.#slot == null || this.#slot.page.isClosed() ? null : { url: this.#slot.url, key: this.#slot.key }, a)
172
+ if (move === 'boot') {
173
+ const old = this.#slot
174
+ this.#slot = null
175
+ await old?.ctx.close().catch(() => undefined)
176
+ const t = performance.now()
177
+ const ctx = await this.#context(a)
178
+ const page = await ctx.newPage()
179
+ const errors: string[] = []
180
+ Shooter.#listen(page, errors)
181
+ this.#slot = { key: slotKey(a), url: a.url, ctx, page, cdp: await ctx.newCDPSession(page), errors, frozen: false }
182
+ phases['browser'] = ms(t)
183
+ }
184
+ const slot = this.#slot!
185
+ slot.errors.length = 0
186
+ const thawed = slot.frozen
187
+ if (slot.frozen) {
188
+ await slot.cdp.send('Page.setWebLifecycleState', { state: 'active' })
189
+ slot.frozen = false
190
+ }
191
+ // a frozen page may have missed an edit: it loads again
192
+ if (move === 'none' && thawed) move = 'reload'
193
+ if (move !== 'none') {
194
+ const t = performance.now()
195
+ slot.url = a.url
196
+ if (move === 'reload') await slot.page.reload({ waitUntil: 'load', timeout: 60_000 })
197
+ else await slot.page.goto(a.url, { waitUntil: 'load', timeout: 60_000 })
198
+ phases['load'] = ms(t)
199
+ }
200
+ let t = performance.now()
201
+ const how = await waitReady(() => slot.page.evaluate(readMark), move === 'none' ? 0 : a.waitMs, { now: () => performance.now(), sleep: (n) => slot.page.waitForTimeout(n) })
202
+ phases['ready'] = ms(t)
203
+ t = performance.now()
204
+ const r = await slot.cdp.send('Page.captureScreenshot', { format: 'png', optimizeForSpeed: true })
205
+ phases['capture'] = ms(t)
206
+ mkdirSync(dirname(a.out), { recursive: true })
207
+ await Bun.write(a.out, Buffer.from(String(r['data']), 'base64'))
208
+ const note = `${move === 'none' ? 'warm page' : thawed ? 'frozen page reloaded' : move}, ${how === 'ready' ? 'ready mark' : move === 'none' ? 'no wait' : `${a.waitMs} ms after load`}`
209
+ return { out: a.out, ms: ms(t0), phases, errors: [...slot.errors], note }
210
+ }
211
+
212
+ async #clip(a: ClipAsk): Promise<ShotDone> {
213
+ const phases: Record<string, number> = {}
214
+ const t0 = performance.now()
215
+ const ctx = await this.#context(a)
216
+ const errors: string[] = []
217
+ try {
218
+ await ctx.addInitScript({ content: CLIP_INIT })
219
+ const page = await ctx.newPage()
220
+ Shooter.#listen(page, errors)
221
+ let t = performance.now()
222
+ await page.goto(a.url, { waitUntil: 'load', timeout: 60_000 })
223
+ await waitReady(() => page.evaluate(readMark), a.waitMs, { now: () => performance.now(), sleep: (n) => page.waitForTimeout(n) })
224
+ phases['load'] = ms(t)
225
+ t = performance.now()
226
+ const path: ClipPath = clipPathOf({ dom: a.dom === true, canvas: await page.evaluate(largestCanvas), viewport: a.viewport })
227
+ const why = path === 'canvas' ? await page.evaluate(installClip, { seconds: a.seconds, fps: a.fps, quantizer: CLIP_QUANTIZER, keyEvery: KEY_EVERY } satisfies PageClipAsk) : null
228
+ if (path === 'dom' || why != null) {
229
+ const note = await filmPage(await ctx.newCDPSession(page), page, a)
230
+ phases['film'] = ms(t)
231
+ return { out: a.out, ms: ms(t0), phases, errors, note: why == null ? note : `${note}; the canvas path refused: ${why}` }
232
+ }
233
+ const parts: Buffer[] = []
234
+ let done: PageClipDone | null = null
235
+ let failed: unknown = null
236
+ void page.evaluate(clipDone).then(
237
+ (d) => (done = d),
238
+ (e) => (failed = e),
239
+ )
240
+ const drain = async (): Promise<void> => {
241
+ for (;;) {
242
+ const data = await page.evaluate(takeClip, TAKE)
243
+ if (data === '') return
244
+ parts.push(Buffer.from(data, 'base64'))
245
+ }
246
+ }
247
+ while (done == null && failed == null) {
248
+ await Bun.sleep(250)
249
+ await drain()
250
+ }
251
+ if (failed != null) throw failed instanceof Error ? failed : new Error(String(failed))
252
+ await drain()
253
+ phases['film'] = ms(t)
254
+ t = performance.now()
255
+ const stream = Buffer.concat(parts)
256
+ await mux(stream, a.fps, a.out)
257
+ phases['mux'] = ms(t)
258
+ const d = done as unknown as PageClipDone
259
+ const mb = (stream.length / 2 ** 20).toFixed(1)
260
+ const note = `${d.frames} frames ${d.width}x${d.height} at ${a.fps} fps (${d.repeated} repeated for a slow page), canvas ${d.canvasOfViewport}, ${mb} MB H.264 from the page's WebCodecs, muxed`
261
+ return { out: a.out, ms: ms(t0), phases, errors, note }
262
+ } finally {
263
+ await ctx.close().catch(() => undefined)
264
+ }
265
+ }
266
+ }
267
+
268
+ const ffmpegBin = (): string => {
269
+ const ffmpeg = Bun.which('ffmpeg') ?? (process.platform === 'darwin' ? '/opt/homebrew/bin/ffmpeg' : null)
270
+ if (ffmpeg == null) throw new Error('ffmpeg is not installed: brew install ffmpeg')
271
+ return ffmpeg
272
+ }
273
+
274
+ const ffmpegFailed = (what: string, err: string, code: number | null): Error => new Error(`ffmpeg ${what}: ${err.trim().split('\n').slice(-6).join('\n') || `exit ${code}`}`)
275
+
276
+ /** the page's H.264 stream into an mp4 by ffmpeg, no second encode; loud with ffmpeg's own words */
277
+ export async function mux(stream: Buffer, fps: number, out: string): Promise<void> {
278
+ const ffmpeg = ffmpegBin()
279
+ mkdirSync(dirname(out), { recursive: true })
280
+ const p = Bun.spawn([ffmpeg, ...muxArgs(fps, out)], { stdin: new Blob([new Uint8Array(stream)]), stdout: 'ignore', stderr: 'pipe' })
281
+ const err = await new Response(p.stderr).text()
282
+ if ((await p.exited) !== 0) throw ffmpegFailed(`mux → ${out}`, err, p.exitCode)
283
+ }
284
+
285
+ /** `a.seconds` of the page as chromium paints it, encoded by ffmpeg as the frames arrive; the note says what it filmed */
286
+ async function filmPage(cdp: Cdp, page: Page, a: ClipAsk): Promise<string> {
287
+ const ffmpeg = ffmpegBin()
288
+ mkdirSync(dirname(a.out), { recursive: true })
289
+ const tl = new FrameTimeline(a.fps, a.seconds)
290
+ const p = Bun.spawn([ffmpeg, ...domEncodeArgs(a.fps, tl.total, a.out)], { stdin: 'pipe', stdout: 'ignore', stderr: 'pipe' })
291
+ const stderr = new Response(p.stderr).text()
292
+ let last: Uint8Array | null = null
293
+ let writing: Promise<unknown> = Promise.resolve()
294
+ const put = (copies: number, frame: Uint8Array | null): void => {
295
+ if (copies <= 0 || frame == null) return
296
+ writing = writing.then(() => {
297
+ for (let i = 0; i < copies; i++) p.stdin.write(frame)
298
+ return p.stdin.flush()
299
+ })
300
+ }
301
+ let stop!: () => void
302
+ const stopped = new Promise<void>((ok) => (stop = ok))
303
+ let timer: ReturnType<typeof setTimeout> | null = null
304
+ const frame = (t: number, data: Uint8Array): void => {
305
+ timer ??= setTimeout(stop, a.seconds * 1000)
306
+ const { previous, past } = tl.arrive(t)
307
+ put(previous, last)
308
+ if (past) stop()
309
+ else last = data
310
+ }
311
+ let px = `${a.viewport.width}x${a.viewport.height}`
312
+ const onFrame = (e: Record<string, unknown>): void => {
313
+ void cdp.send('Page.screencastFrameAck', { sessionId: e['sessionId'] }).catch(() => undefined)
314
+ const m = e['metadata'] as { timestamp?: number; deviceWidth?: number; deviceHeight?: number } | undefined
315
+ if (timer == null && m?.deviceWidth != null && m.deviceHeight != null) px = `${Math.round(m.deviceWidth)}x${Math.round(m.deviceHeight)}`
316
+ frame(m?.timestamp ?? Date.now() / 1000, Buffer.from(String(e['data']), 'base64'))
317
+ }
318
+ cdp.on('Page.screencastFrame', onFrame)
319
+ try {
320
+ await cdp.send('Page.startScreencast', screencastParams(a.viewport))
321
+ await page.waitForTimeout(FIRST_FRAME_MS)
322
+ if (timer == null) {
323
+ const still = await cdp.send('Page.captureScreenshot', { format: 'jpeg', quality: 90 })
324
+ if (timer == null) frame(Date.now() / 1000, Buffer.from(String(still['data']), 'base64'))
325
+ }
326
+ await stopped
327
+ } finally {
328
+ cdp.off('Page.screencastFrame', onFrame)
329
+ if (timer != null) clearTimeout(timer)
330
+ await cdp.send('Page.stopScreencast').catch(() => undefined)
331
+ }
332
+ put(tl.end(), last)
333
+ const broke = await writing.then(
334
+ () => null,
335
+ (e: unknown) => e,
336
+ )
337
+ await Promise.resolve(p.stdin.end()).catch(() => undefined)
338
+ const err = await stderr
339
+ if ((await p.exited) !== 0) throw ffmpegFailed(`encode → ${a.out}`, err, p.exitCode)
340
+ if (broke != null) throw broke instanceof Error ? broke : new Error(String(broke))
341
+ const dpr = a.dpr === 1 ? '' : ` (css pixels: the screencast ignores --dpr ${a.dpr})`
342
+ return `${tl.total} frames ${px}${dpr} at ${a.fps} fps from ${tl.shown} the page painted (${tl.realFps.toFixed(1)} fps), filmed by chromium's screencast, H.264 by ffmpeg`
343
+ }
@@ -0,0 +1,189 @@
1
+ // what a shot or a clip asks the warm shot service, and every pure rule about
2
+ // it: the flags mapped to an ask, the ready mark read, the default file. No
3
+ // browser, no disk, no process here: the suite tests all of it.
4
+ import { join, resolve } from 'path'
5
+ import { LAYOUT } from '../core/layout.ts'
6
+
7
+ export type Viewport = { width: number; height: number }
8
+
9
+ export type ShotAsk = {
10
+ url: string
11
+ viewport: Viewport
12
+ /** device pixel ratio: a png holds viewport × dpr pixels */
13
+ dpr: number
14
+ /** absolute: the service writes the file here */
15
+ out: string
16
+ /** load the page again even when the warm page already shows this url */
17
+ reload: boolean
18
+ /** a page with no ready mark is shot this long after its load (a warm page at once) */
19
+ waitMs: number
20
+ }
21
+
22
+ export type ClipAsk = ShotAsk & {
23
+ seconds: number
24
+ fps: number
25
+ /** film the page itself even when a canvas fills it (dom-clip.ts); a page with no big canvas is filmed so anyway */
26
+ dom?: boolean
27
+ }
28
+
29
+ export const DEFAULT_SHOT_PORT = 4796
30
+
31
+ export const shotPort = (): number => {
32
+ const env = Number(process.env.SHIPKIT_SHOT_PORT)
33
+ return Number.isInteger(env) && env > 0 ? env : DEFAULT_SHOT_PORT
34
+ }
35
+
36
+ export const shotUrlBase = (port = shotPort()): string => `http://127.0.0.1:${port}`
37
+
38
+ export const DEFAULT_VIEWPORT: Viewport = { width: 1280, height: 720 }
39
+ export const DEFAULT_WAIT_MS = 300
40
+ export const DEFAULT_FPS = 30
41
+ /** a clip past this length needs `--long`: a long clip is a long wait and a big file */
42
+ export const MAX_CLIP_SECONDS = 30
43
+
44
+ /** `1280,720` or `1280x720` */
45
+ export function parseViewport(s: string): Viewport {
46
+ const m = /^\s*(\d+)\s*[,x]\s*(\d+)\s*$/.exec(s)
47
+ const width = Number(m?.[1])
48
+ const height = Number(m?.[2])
49
+ if (!m || width < 16 || height < 16 || width > 7680 || height > 4320) throw new Error(`--viewport '${s}' is not w,h in pixels (16 to 7680 by 4320), like 1280,720`)
50
+ return { width, height }
51
+ }
52
+
53
+ /** a bare `localhost:5173/x` or `/x` against nothing reads as http */
54
+ export function normalizeUrl(s: string): string {
55
+ const t = s.trim()
56
+ if (/^https?:\/\//.test(t)) return t
57
+ if (/^(localhost|127\.0\.0\.1|\[::1\]|[\w.-]+\.\w+)(:\d+)?(\/|$)/.test(t)) return `http://${t}`
58
+ if (/^:\d+(\/|$)/.test(t)) return `http://localhost${t}`
59
+ throw new Error(`'${s}' is not a url: give http://host:port/path, localhost:5173/path or :5173/path`)
60
+ }
61
+
62
+ /** the file a shot lands in when no `--out` is given: under the repo's local scratch, named after the url */
63
+ export function defaultOut(url: string, ext: 'png' | 'mp4'): string {
64
+ const u = new URL(url)
65
+ const slug = `${u.hostname}-${u.port || (u.protocol === 'https:' ? '443' : '80')}${u.pathname}${u.search}${u.hash}`
66
+ .replace(/[^\w.-]+/g, '-')
67
+ .replace(/-+/g, '-')
68
+ .replace(/^-|-$/g, '')
69
+ .slice(0, 80)
70
+ return join(LAYOUT.tmp, 'shots', `${slug}.${ext}`)
71
+ }
72
+
73
+ export type ShotFlags = {
74
+ url: string
75
+ viewport?: string
76
+ dpr?: string
77
+ out?: string
78
+ reload?: boolean
79
+ wait?: string
80
+ video?: string
81
+ fps?: string
82
+ long?: boolean
83
+ dom?: boolean
84
+ }
85
+
86
+ const positive = (flag: string, s: string, max: number): number => {
87
+ const n = Number(s)
88
+ if (!Number.isFinite(n) || n <= 0 || n > max) throw new Error(`${flag} '${s}' must be a number above 0 and at most ${max}`)
89
+ return n
90
+ }
91
+
92
+ /** the command line mapped to the ask the service runs: every default here, every refusal loud */
93
+ export function askOf(f: ShotFlags, cwd: string): ShotAsk | ClipAsk {
94
+ const url = normalizeUrl(f.url)
95
+ const seconds = f.video == null ? null : positive('--video', f.video, 600)
96
+ if (seconds != null && seconds > MAX_CLIP_SECONDS && f.long !== true) throw new Error(`--video ${seconds}: a clip past ${MAX_CLIP_SECONDS} s needs --long (a long clip is a long wait and a big file)`)
97
+ if (seconds == null && f.fps != null) throw new Error('--fps only applies to a clip: add --video <seconds>')
98
+ if (seconds == null && f.dom === true) throw new Error('--dom only applies to a clip: add --video <seconds>')
99
+ const base: ShotAsk = {
100
+ url,
101
+ viewport: f.viewport == null ? DEFAULT_VIEWPORT : parseViewport(f.viewport),
102
+ dpr: f.dpr == null ? 1 : positive('--dpr', f.dpr, 4),
103
+ out: resolve(cwd, f.out ?? defaultOut(url, seconds == null ? 'png' : 'mp4')),
104
+ reload: f.reload === true,
105
+ waitMs: f.wait == null ? DEFAULT_WAIT_MS : f.wait === '0' ? 0 : positive('--wait', f.wait, 60_000),
106
+ }
107
+ if (seconds == null) return base
108
+ if (!base.out.endsWith('.mp4')) throw new Error(`--out '${f.out}': a clip is an mp4 (H.264)`)
109
+ return { ...base, seconds, fps: f.fps == null ? DEFAULT_FPS : positive('--fps', f.fps, 60), dom: f.dom === true }
110
+ }
111
+
112
+ export const isClip = (a: ShotAsk | ClipAsk): a is ClipAsk => 'seconds' in a
113
+
114
+ /** a request held past this (a clip: past its own seconds) fails loud and frees the turn */
115
+ export const REQUEST_MAX_MS = 120_000
116
+ export const capMsOf = (a: ShotAsk | ClipAsk): number => REQUEST_MAX_MS + (isClip(a) ? a.seconds * 1000 : 0)
117
+
118
+ // ── the ready mark ──────────────────────────────────────────────────────────
119
+ // A page that knows when it is drawn says so: `<meta name="shot-ready"
120
+ // content="wait">` in its head while it settles, `content="ready"` once it is
121
+ // drawn. A page with the meta is shot the moment it says ready, and fails
122
+ // loud at the cap when it never does. A page without it is shot `waitMs`
123
+ // after its load.
124
+
125
+ export type ReadyState = 'ready' | 'pending' | 'silent'
126
+
127
+ /** what the page's meta says: null content means the page has no meta */
128
+ export const readyOf = (content: string | null): ReadyState => (content == null ? 'silent' : content.trim().toLowerCase() === 'ready' ? 'ready' : 'pending')
129
+
130
+ /** run IN the page: its source goes to the browser, so it reads nothing but the document */
131
+ export const readMark = (): string | null => document.querySelector('meta[name="shot-ready"]')?.getAttribute('content') ?? (document.querySelector('meta[name="shot-ready"]') ? '' : null)
132
+
133
+ export const READY_CAP_MS = 30_000
134
+ const POLL_MS = 16
135
+
136
+ export interface Clock {
137
+ now(): number
138
+ sleep(ms: number): Promise<void>
139
+ }
140
+
141
+ /** polls the page until it is ready to shoot: 'ready' once its mark says so, 'fallback' once a page with no mark had
142
+ * its wait. A page whose mark never says ready fails loud at the cap */
143
+ export async function waitReady(probe: () => Promise<string | null>, fallbackMs: number, clock: Clock, capMs = READY_CAP_MS): Promise<'ready' | 'fallback'> {
144
+ const t0 = clock.now()
145
+ for (;;) {
146
+ const s = readyOf(await probe())
147
+ if (s === 'ready') return 'ready'
148
+ const spent = clock.now() - t0
149
+ if (s === 'silent' && spent >= fallbackMs) return 'fallback'
150
+ if (s === 'pending' && spent >= capMs) throw new Error(`the page has <meta name="shot-ready"> and never set content="ready" in ${capMs / 1000} s`)
151
+ await clock.sleep(Math.min(POLL_MS, Math.max(1, (s === 'silent' ? fallbackMs : capMs) - spent)))
152
+ }
153
+ }
154
+
155
+ // ── the turn ────────────────────────────────────────────────────────────────
156
+
157
+ /** one request at a time on the one warm page: a job runs once every job asked before it settled, and a job that
158
+ * throws frees the turn like one that returns */
159
+ export class Turns {
160
+ #tail: Promise<unknown> = Promise.resolve()
161
+ #waiting = 0
162
+
163
+ get waiting(): number {
164
+ return this.#waiting
165
+ }
166
+
167
+ run<T>(job: () => Promise<T>): Promise<T> {
168
+ this.#waiting++
169
+ const mine = this.#tail.then(
170
+ () => job(),
171
+ () => job(),
172
+ )
173
+ this.#tail = mine.then(
174
+ () => (this.#waiting--, undefined),
175
+ () => (this.#waiting--, undefined),
176
+ )
177
+ return mine
178
+ }
179
+ }
180
+
181
+ /** what the slot of the one warm page is keyed by: a request of another screen boots its own page */
182
+ export const slotKey = (a: Pick<ShotAsk, 'viewport' | 'dpr'>): string => `${a.viewport.width}x${a.viewport.height}@${a.dpr}`
183
+
184
+ /** what the warm page must do before a still: nothing, navigate, or reload */
185
+ export function pageMove(current: { url: string; key: string } | null, a: ShotAsk): 'boot' | 'goto' | 'reload' | 'none' {
186
+ if (current == null || current.key !== slotKey(a)) return 'boot'
187
+ if (current.url !== a.url) return 'goto'
188
+ return a.reload ? 'reload' : 'none'
189
+ }
@@ -0,0 +1,14 @@
1
+ // the warm shot service as a machine process: pm keeps it up, and `sk shot`
2
+ // starts it when it is down. One browser for every repo of the machine.
3
+ import { join } from 'path'
4
+ import { globalProcess } from '../shipkit-files.ts'
5
+ import { shotUrlBase } from './shot-ask.ts'
6
+
7
+ export default globalProcess({
8
+ id: 'shot',
9
+ label: 'shots',
10
+ description: 'warm headless browser: sk shot <url> gives a png, --video a clip, for every repo',
11
+ cmd: ['bun', join(import.meta.dir, 'serve-shot.ts')],
12
+ probe: `${shotUrlBase()}/health`,
13
+ icon: '📸',
14
+ })
@@ -262,14 +262,14 @@ export const PLAYWRIGHT_HINT = 'FAIL playwright is not installed in this repo
262
262
 
263
263
  /** resolved from the REPO, never from this package: the browser is the
264
264
  * consumer's dependency, and the message names the two commands that fix it */
265
- export async function loadPlaywright(root: string): Promise<Playwright> {
265
+ export async function loadPlaywright<T = Playwright>(root: string): Promise<T> {
266
266
  let resolved: string
267
267
  try {
268
268
  resolved = Bun.resolveSync('playwright', root)
269
269
  } catch {
270
270
  throw new Error(PLAYWRIGHT_HINT)
271
271
  }
272
- return (await import(resolved)) as Playwright
272
+ return (await import(resolved)) as T
273
273
  }
274
274
 
275
275
  const isSelector = (spec: string): boolean => /^[#.[]/.test(spec) || /^[a-z][\w-]*(\[|:|$)/.test(spec) || spec.includes(' > ')
package/src/start-repo.ts CHANGED
@@ -13,7 +13,8 @@ import { join } from 'path'
13
13
  import { ensureGitRepo, ensureIgnoreBaseline, firstGhLine, isScratch, publishRepo, runner, type Run } from './git-bootstrap.ts'
14
14
  import { initRepo } from './init-repo.ts'
15
15
  import { AGENT_LINE } from './checks/SK003-claude-md-shape.check.ts'
16
- import { adoptHouse, scaffoldProject, TOOLCHAIN } from './scaffold/project-scaffold.ts'
16
+ import { adoptCli, adoptHouse, installedLib, scaffoldProject, TOOLCHAIN } from './scaffold/project-scaffold.ts'
17
+ import { kitInstalled } from './scaffold/cli-skeleton.ts'
17
18
  import { defaultHubPort } from './serve/hub-port.ts'
18
19
 
19
20
  /** the one template: `shipkit start <name>` is the only thing anyone types,
@@ -53,6 +54,8 @@ export type StartOpts = {
53
54
  github?: boolean
54
55
  /** how `gh` runs — injected by tests, which never touch a real account */
55
56
  gh?: Run | null
57
+ /** a repo with history gets the project CLI skeleton too (a started repo always does) */
58
+ cli?: boolean
56
59
  /** how `bun` runs for the dep install — injected by tests, which never
57
60
  * touch the network. An explicit null means no bun on this machine */
58
61
  bun?: Run | null
@@ -121,18 +124,6 @@ export async function startRepo(name: string, opts: StartOpts = {}): Promise<num
121
124
  // all (a downloaded template, one stray line) otherwise skipped
122
125
  // the baseline and refused the first commit over 7000 dep files
123
126
  ensureIgnoreBaseline(dir)
124
- // the day-one shape: tsconfig, vscode tasks, the agent wiki, the
125
- // verify skill. Create-only, so a dir that already holds any of
126
- // them keeps its own. The .claude/ policy files ride on the
127
- // include lines: a caller that declares a house gets the house
128
- // defaults, a bare `shipkit start` never has its agent
129
- // permissions widened by a scaffold
130
- // the port comes from the name being WIRED, which is the one on the
131
- // command line: init takes the caller's name over the manifest's,
132
- // and a dir found holding a forked package.json carries a name
133
- // nothing will listen on
134
- try { scaffoldProject(dir, { name, port: defaultHubPort(name), print, claudePolicy: house.length > 0 }) }
135
- catch (e) { console.error(`WARN scaffold incomplete in ${dir}: ${e instanceof Error ? e.message : String(e)} — the dir is still yours`) }
136
127
  // the wired scripts call node_modules binaries, so a scaffold
137
128
  // whose scripts exit 127 is not a started repo. Skips mirror the
138
129
  // github half's guards: an injected runner wins, the env switch
@@ -150,12 +141,36 @@ export async function startRepo(name: string, opts: StartOpts = {}): Promise<num
150
141
  if (r.code === 0) console.error(`ok: installed ${deps.join(', ')}`)
151
142
  else console.error(`WARN dependency install failed: ${firstGhLine(r.out)}`)
152
143
  }
144
+ // the day-one shape: tsconfig, vscode tasks, the agent wiki, the
145
+ // verify skill. Create-only, so a dir that already holds any of
146
+ // them keeps its own. The .claude/ policy files ride on the
147
+ // include lines: a caller that declares a house gets the house
148
+ // defaults, a bare `shipkit start` never has its agent
149
+ // permissions widened by a scaffold
150
+ // the port comes from the name being WIRED, which is the one on the
151
+ // command line: init takes the caller's name over the manifest's,
152
+ // and a dir found holding a forked package.json carries a name
153
+ // nothing will listen on
154
+ // The install runs first, so the project CLI is written only when the
155
+ // installed lib carries the kit its entry imports
156
+ const kit = kitInstalled(installedLib(dir))
157
+ if (kit === false) console.error(`skip: project CLI — the installed rvlib-shipkit ${installedLib(dir)?.version ?? ''} predates the CLI kit: \`sk start ${name} --cli\` once a release carries it`)
158
+ try { scaffoldProject(dir, { name, port: defaultHubPort(name), print, claudePolicy: house.length > 0, projectCli: kit !== false }) }
159
+ catch (e) { console.error(`WARN scaffold incomplete in ${dir}: ${e instanceof Error ? e.message : String(e)} — the dir is still yours`) }
153
160
  }
154
161
  // a repo with history that a house adopts: its own files win, and the
155
162
  // house half of the day-one shape lands beside them, so the include
156
163
  // lines below route at an agent wiki that exists
164
+ // the project CLI on an adopted repo is asked for, never implied: its
165
+ // src/cli.ts may be the product's own bin, or its CLI may live
166
+ // elsewhere. Before the house files, so a fresh agent index loads it
167
+ let cliWritten = false
168
+ if (wired && !startable && opts.cli === true) {
169
+ try { cliWritten = adoptCli(dir, { name, print }) != null }
170
+ catch (e) { console.error(`WARN could not write the project CLI in ${dir}: ${e instanceof Error ? e.message : String(e)} — the dir is still yours`) }
171
+ }
157
172
  if (wired && !startable && house.length > 0) {
158
- try { adoptHouse(dir, { name, print }) }
173
+ try { adoptHouse(dir, { name, print, cli: cliWritten }) }
159
174
  catch (e) { console.error(`WARN could not seed the house files in ${dir}: ${e instanceof Error ? e.message : String(e)} — the dir is still yours`) }
160
175
  }
161
176
  let unscaffolded = false