rvlib-shipkit 2.2.0 → 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 (107) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +1 -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/cli/cmd_Commands.ts +5 -0
  12. package/src/cli/cmd_Review.ts +948 -0
  13. package/src/cli/cmd_Time.ts +112 -0
  14. package/src/cli/cmd_Trailer.ts +259 -6
  15. package/src/cli/kit/verb.ts +14 -4
  16. package/src/cli.ts +6 -4
  17. package/src/collections/collection-types.ts +6 -1
  18. package/src/collections/providers.ts +3 -0
  19. package/src/collections/review.ts +70 -0
  20. package/src/core/layout.ts +2 -0
  21. package/src/reflect/prompt-corpus.ts +15 -119
  22. package/src/reflect/transcript.ts +254 -0
  23. package/src/review/anim.ts +228 -0
  24. package/src/review/auto.ts +141 -0
  25. package/src/review/format.ts +150 -0
  26. package/src/review/http.ts +23 -0
  27. package/src/review/marks.ts +43 -0
  28. package/src/review/media.ts +45 -0
  29. package/src/review/model.ts +854 -0
  30. package/src/review/project.ts +110 -0
  31. package/src/review/role.ts +48 -0
  32. package/src/review/routes.ts +228 -0
  33. package/src/review/store.ts +1002 -0
  34. package/src/review/threads.ts +237 -0
  35. package/src/review/trailer-desk.ts +385 -0
  36. package/src/review/trailer-model.ts +183 -0
  37. package/src/review/trailer-routes.ts +136 -0
  38. package/src/review/unit.ts +165 -0
  39. package/src/review/watchers.ts +54 -0
  40. package/src/serve/embed.ts +35 -0
  41. package/src/serve/hub-app/activity-view.tsx +1 -1
  42. package/src/serve/hub-app/activity.css +0 -4
  43. package/src/serve/hub-app/dock-view.tsx +5 -4
  44. package/src/serve/hub-app/hash-route.ts +28 -1
  45. package/src/serve/hub-app/header-bar.tsx +3 -1
  46. package/src/serve/hub-app/home-st.ts +10 -2
  47. package/src/serve/hub-app/home-strip.tsx +3 -2
  48. package/src/serve/hub-app/home.html +1 -0
  49. package/src/serve/hub-app/hub-app.tsx +80 -11
  50. package/src/serve/hub-app/hub.css +45 -13
  51. package/src/serve/hub-app/icons.tsx +9 -0
  52. package/src/serve/hub-app/index.html +1 -0
  53. package/src/serve/hub-app/insights-view.tsx +4 -3
  54. package/src/serve/hub-app/keys.ts +14 -10
  55. package/src/serve/hub-app/layout-st.ts +63 -2
  56. package/src/serve/hub-app/planned-view.tsx +3 -2
  57. package/src/serve/hub-app/planned.css +0 -1
  58. package/src/serve/hub-app/project-list.css +22 -4
  59. package/src/serve/hub-app/project-list.tsx +41 -8
  60. package/src/serve/hub-app/projects-svc.ts +4 -0
  61. package/src/serve/hub-app/repo-sections.ts +15 -3
  62. package/src/serve/hub-app/repo-st.ts +45 -3
  63. package/src/serve/hub-app/review/clip-st.ts +208 -0
  64. package/src/serve/hub-app/review/kind-view.tsx +171 -0
  65. package/src/serve/hub-app/review/kit.tsx +113 -0
  66. package/src/serve/hub-app/review/review-answer.tsx +239 -0
  67. package/src/serve/hub-app/review/review-board.tsx +46 -0
  68. package/src/serve/hub-app/review/review-list.tsx +156 -0
  69. package/src/serve/hub-app/review/review-page.tsx +101 -0
  70. package/src/serve/hub-app/review/review-st.ts +779 -0
  71. package/src/serve/hub-app/review/review-views.tsx +503 -0
  72. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  73. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  74. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  75. package/src/serve/hub-app/review.css +329 -0
  76. package/src/serve/hub-app/theme-st.ts +56 -0
  77. package/src/serve/hub-app/tree-build.ts +4 -0
  78. package/src/serve/hub-app/tree-row.tsx +1 -1
  79. package/src/serve/hub-app/tree-svc.ts +4 -0
  80. package/src/serve/hub-runtime.ts +5 -3
  81. package/src/serve/same-origin.ts +5 -1
  82. package/src/serve/serve-hub.ts +8 -0
  83. package/src/shipkit-files.ts +9 -5
  84. package/src/trailer/adapter.ts +58 -3
  85. package/src/trailer/build.ts +300 -41
  86. package/src/trailer/ffmpeg.ts +22 -0
  87. package/src/trailer/footage.ts +109 -40
  88. package/src/trailer/grammar.ts +444 -69
  89. package/src/trailer/graph.ts +199 -14
  90. package/src/trailer/holes.ts +72 -0
  91. package/src/trailer/load-unit.ts +20 -3
  92. package/src/trailer/look.ts +17 -3
  93. package/src/trailer/passage.ts +129 -0
  94. package/src/trailer/plan.ts +80 -21
  95. package/src/trailer/prune.ts +193 -0
  96. package/src/trailer/score/compose.ts +240 -0
  97. package/src/trailer/score/dsp.ts +272 -0
  98. package/src/trailer/score/palette.ts +188 -0
  99. package/src/trailer/score/pattern.ts +91 -0
  100. package/src/trailer/score/score.ts +214 -0
  101. package/src/trailer/score/sound.ts +112 -0
  102. package/src/trailer/score/synth.ts +361 -0
  103. package/src/trailer/states.ts +76 -0
  104. package/src/trailer/stitch.ts +92 -9
  105. package/src/trailer/titles.ts +123 -18
  106. package/src/trailer/vocab.ts +23 -4
  107. package/src/trailer/words.ts +254 -0
@@ -0,0 +1,112 @@
1
+ // `sk time`: where the agents' time went in a repo, read from every Claude
2
+ // Code transcript on the machine (every config root, sub-agents included)
3
+ // through the agent time index (agent-time/store.ts). REPORT ONLY: exit 0 with
4
+ // a report, 1 when nothing matched, 2 on a bad flag.
5
+ import { Command, Option } from 'clipanion'
6
+ import { resolve } from 'path'
7
+
8
+ const BIG_BYTES = 50 * 1024 * 1024
9
+
10
+ export class TimeCommand extends Command {
11
+ static override paths = [['time']]
12
+
13
+ static override usage = Command.Usage({
14
+ category: 'Health',
15
+ description: 'where agent time went in a repo, every account: waits, slow and failing commands, feedback',
16
+ details: [
17
+ 'Reads every Claude Code transcript under every config root (~/.claude, ~/.claude-<name>), sub-agent runs included, through an index at ~/.shipkit/agent-time.sqlite.',
18
+ 'The first run builds the index (niced, resumable, progress on stderr); later runs read only what changed. The index keeps sessions Claude Code has pruned.',
19
+ 'Views: summary (default), commands, tools, slow, fails, feedback, sessions, turns. A Bash command is grouped by its shape (`git commit`, `bun test <file>`, `sk ci --only lint`).',
20
+ 'Turns: every call costs a model turn before it, so the summary gives the model time before each call, the share of single read calls and the share sent in parallel, for the main sessions and the sub-agents; `turns` lists the runs and the read shapes by the model time they cost.',
21
+ 'Wall time is cut once per moment: tools in flight, a sub-agent in flight, waiting on a background task, waiting on you (an idle gap past 30 minutes is left out), else the model.',
22
+ 'Commands and prompts are masked through the leak guard keyword list and token shapes before they print.',
23
+ ].join(' '),
24
+ examples: [
25
+ ['this repo, the last 7 days', 'sk time'],
26
+ ['every command shape, 30 days', 'sk time commands --since 30d'],
27
+ ['what he said while it ran', 'sk time feedback --since all'],
28
+ ['the runs by their turns, and what the reads cost', 'sk time turns --since 30d'],
29
+ ['two repos, every recorded session', 'sk time ~/dev/pkm-multi ~/dev/procedural-creatures --since all'],
30
+ ['every repo on the machine', 'sk time --all'],
31
+ ['for a script', 'sk time --json'],
32
+ ],
33
+ })
34
+
35
+ args = Option.Rest()
36
+ all = Option.Boolean('--all', false, { description: 'every repo with transcripts on this machine' })
37
+ since = Option.String('--since', '7d', { description: '30m, 2h, 7d, a date, or all' })
38
+ top = Option.String('--top', '20', { description: 'rows per list in a sub view' })
39
+ json = Option.Boolean('--json', false, { description: 'the whole report as json' })
40
+
41
+ async execute(): Promise<number> {
42
+ const { VIEWS, render } = await import('../agent-time/render.ts')
43
+ const { sinceOf } = await import('./kit/calls.ts')
44
+ const err = this.context.stderr
45
+ const tty = (err as { isTTY?: boolean }).isTTY === true
46
+ const views = this.args.filter((a) => (VIEWS as readonly string[]).includes(a))
47
+ const repoArgs = this.args.filter((a) => !views.includes(a))
48
+ const bad = repoArgs.find((a) => !a.includes('/') && !a.startsWith('.') && !a.startsWith('~'))
49
+ if (views.length > 1 || bad != null) {
50
+ err.write(`FAIL: ${bad == null ? `one view at a time, not ${views.join(' and ')}` : `unknown view '${bad}'`}: ${VIEWS.join(', ')}, or a repo path\n`)
51
+ return 2
52
+ }
53
+ const view = (views[0] ?? 'summary') as (typeof VIEWS)[number]
54
+ const now = Date.now()
55
+ const from = this.since === 'all' ? null : sinceOf(this.since, now)
56
+ if (from === null && this.since !== 'all') {
57
+ err.write(`FAIL: --since ${this.since}: 30m, 2h, 7d, a date, or all\n`)
58
+ return 2
59
+ }
60
+ const top = Number(this.top)
61
+ if (!Number.isInteger(top) || top <= 0) {
62
+ err.write(`FAIL: --top ${this.top}: a positive whole number\n`)
63
+ return 2
64
+ }
65
+
66
+ const { indexTranscripts, listTranscripts, loadWindow, markGone, openIndex, repoOfProject, agentTimeDbPath } = await import('../agent-time/store.ts')
67
+ const { discoverConfigRoots, projectSlug } = await import('../reflect/prompt-corpus.ts')
68
+ const { buildReport } = await import('../agent-time/report.ts')
69
+ const { loadMasker } = await import('../agent-time/mask.ts')
70
+
71
+ const repos = repoArgs.length > 0 ? repoArgs.map((r) => resolve(r)) : [process.cwd()]
72
+ const slugs = repos.map(projectSlug)
73
+ const keep = this.all ? () => true : (p: string) => slugs.some((s) => p === s || p.startsWith(`${s}--claude-worktrees-`))
74
+ const roots = discoverConfigRoots()
75
+ const db = openIndex()
76
+ try {
77
+ const files = listTranscripts(roots, keep)
78
+ const pending = files.length
79
+ const tIndex = performance.now()
80
+ let lastPrint = 0
81
+ let niced = false
82
+ const res = indexTranscripts(db, files, (p) => {
83
+ if (p.bytes < BIG_BYTES) return
84
+ if (!niced) {
85
+ niced = true
86
+ try { (require('os') as typeof import('os')).setPriority(0, 15) } catch { /* not allowed: index at the normal priority */ }
87
+ }
88
+ const t = performance.now()
89
+ if (t - lastPrint < (tty ? 200 : 10_000) && p.done < p.files) return
90
+ lastPrint = t
91
+ const line = `index: ${p.done}/${p.files} files, ${(p.doneBytes / 1e9).toFixed(1)}/${(p.bytes / 1e9).toFixed(1)} GB, ${Math.round((t - tIndex) / 1000)}s`
92
+ err.write(tty ? `\r${line}` : `${line}\n`)
93
+ })
94
+ if (res.bytes >= BIG_BYTES) err.write(`${tty ? '\n' : ''}index: read ${res.read} of ${pending} files, ${(res.bytes / 1e9).toFixed(2)} GB in ${(res.ms / 1000).toFixed(1)}s (${agentTimeDbPath()})\n`)
95
+ markGone(db, files, keep)
96
+ const loaded = loadWindow(db, keep, from ?? 0, now)
97
+ const labels = this.all ? [...new Set(loaded.files.map((f) => repoOfProject(f.project)))].sort() : slugs.map((s) => repoOfProject(s))
98
+ if (loaded.files.length === 0) {
99
+ err.write(`FAIL: no agent session in ${labels.join(', ') || 'any repo'} ${from == null ? 'ever' : `since ${new Date(from).toISOString().slice(0, 10)}`}\n`)
100
+ err.write(` looked under: ${roots.map((r) => r.projects).join(' · ')}\n`)
101
+ err.write(` fix: widen it, --since all, or name a repo that ran Claude Code sessions\n`)
102
+ return 1
103
+ }
104
+ const report = buildReport(loaded, { repos: labels, from, to: now, repoOf: (p) => repoOfProject(p) }, loadMasker(repos[0] ?? null))
105
+ if (this.json) this.context.stdout.write(`${JSON.stringify(report)}\n`)
106
+ else this.context.stdout.write(render(report, view, top))
107
+ return 0
108
+ } finally {
109
+ db.close()
110
+ }
111
+ }
112
+ }
@@ -2,7 +2,7 @@
2
2
  // Every module loads inside execute: the cli registers this file on every `sk` call
3
3
  import { Command, Option } from 'clipanion'
4
4
 
5
- const DETAILS = `the trailer language: shots with a film line each (clip <file>, image <file>, card, run "<cmd {out}>", url <route> through the shot service, or a film row of the project CLI), titles in the brand's look, cuts, sections in bars, music, sfx and voice; a \`vertical\` header finishes a short with the brand's lockup, watermark and end card. Outputs land in trailer/<trailer>/out/. Page: wiki build/trailer.md`
5
+ const DETAILS = `the trailer language: shots with a film line each (clip <file>, image <file>, card, run "<cmd {out}>", url <route> through the shot service, grid and split of other takes, or a film row of the project CLI), footage blocks, titles in the brand's look, cuts, sections in bars, music, sfx and voice; a \`vertical\` header finishes a short with the brand's lockup, watermark and end card. A repo plugs in one trailer({...}) unit: sounds by id, the world's paths, content checks, a line's own take. Outputs land in trailer/<trailer>/out/. Page: wiki build/trailer.md`
6
6
 
7
7
  /** --refilm alone: every stale take; --refilm a,b: those shots */
8
8
  const refilmOf = (v: string | boolean | undefined): 'stale' | ReadonlySet<string> | null => (v === undefined || v === false ? null : v === true ? 'stale' : new Set(v.split(',')))
@@ -42,7 +42,7 @@ export class TrailerCommand extends Command {
42
42
  async execute(): Promise<number> {
43
43
  return run(this.context, async (say, root, cwd) => {
44
44
  const b = await import('../trailer/build.ts')
45
- const l = b.load(b.trailerFile(this.file, root, cwd), root)
45
+ const l = await b.load(b.trailerFile(this.file, root, cwd), root)
46
46
  if (!b.sayErrors(l, say, cwd)) return 1
47
47
  await b.build(l, { refilm: refilmOf(this.refilm), final: this.final, say, cwd })
48
48
  return 0
@@ -69,16 +69,41 @@ export class TrailerCheckCommand extends Command {
69
69
  return run(this.context, async (say, root, cwd) => {
70
70
  const t0 = performance.now()
71
71
  const b = await import('../trailer/build.ts')
72
- const l = b.load(b.trailerFile(this.file, root, cwd), root)
72
+ const l = await b.load(b.trailerFile(this.file, root, cwd), root)
73
73
  if (!b.sayErrors(l, say, cwd)) return 1
74
- const { takesOf, worldStamp } = await import('../trailer/footage.ts')
75
- for (const line of b.timeline(l, takesOf(l.t, root), worldStamp(root), cwd)) say(line)
74
+ for (const line of b.timeline(l, b.takesOfLoaded(l), await b.worldOf(l), cwd)) say(line)
76
75
  say(`ok: checked in ${((performance.now() - t0) / 1000).toFixed(2)} s`)
77
76
  return 0
78
77
  })
79
78
  }
80
79
  }
81
80
 
81
+ export class TrailerScoreCommand extends Command {
82
+ static override paths = [['trailer', 'score']]
83
+
84
+ static override usage = Command.Usage({
85
+ category: 'Trailer',
86
+ description: 'the score alone, no picture: its wav, its waveform png, each section\'s level and the loudness',
87
+ details: `${DETAILS}. The score renders offline in Bun from the file's score, track, drums, code, section and play lines; a section whose notes are silent fails`,
88
+ examples: [
89
+ ['the score of the one cut', 'sk trailer score'],
90
+ ['the score of a named cut', 'sk trailer score trailer/launch'],
91
+ ],
92
+ })
93
+
94
+ file = Option.String({ required: false })
95
+
96
+ async execute(): Promise<number> {
97
+ return run(this.context, async (say, root, cwd) => {
98
+ const b = await import('../trailer/build.ts')
99
+ const l = await b.load(b.trailerFile(this.file, root, cwd), root)
100
+ if (!b.sayErrors(l, say, cwd)) return 1
101
+ await b.auditionScore(l, { refilm: null, final: false, say, cwd })
102
+ return 0
103
+ })
104
+ }
105
+ }
106
+
82
107
  export class TrailerShotCommand extends Command {
83
108
  static override paths = [['trailer', 'shot']]
84
109
 
@@ -99,10 +124,238 @@ export class TrailerShotCommand extends Command {
99
124
  async execute(): Promise<number> {
100
125
  return run(this.context, async (say, root, cwd) => {
101
126
  const b = await import('../trailer/build.ts')
102
- const l = b.load(b.trailerFile(this.file, root, cwd), root)
127
+ const l = await b.load(b.trailerFile(this.file, root, cwd), root)
103
128
  if (!b.sayErrors(l, say, cwd)) return 1
104
129
  await b.previewShot(l, this.name, { refilm: refilmOf(this.refilm), final: false, say, cwd })
105
130
  return 0
106
131
  })
107
132
  }
108
133
  }
134
+
135
+ /** a cut loaded and checked, its errors said; null when it has one */
136
+ async function loaded(file: string | undefined, say: (l: string) => void, root: string, cwd: string) {
137
+ const b = await import('../trailer/build.ts')
138
+ const l = await b.load(b.trailerFile(file, root, cwd), root)
139
+ return b.sayErrors(l, say, cwd) ? l : null
140
+ }
141
+
142
+ export class TrailerSectionCommand extends Command {
143
+ static override paths = [['trailer', 'section']]
144
+
145
+ static override usage = Command.Usage({
146
+ category: 'Trailer',
147
+ description: 'one section alone with its window of the sound: out/<cut>-<section>.mp4',
148
+ details: `${DETAILS}. \`start\` is the shots before the first section; only a section the cache misses renders`,
149
+ examples: [['a section of the one cut', 'sk trailer section drop']],
150
+ })
151
+
152
+ name = Option.String({ required: true })
153
+ file = Option.String({ required: false })
154
+ final = Option.Boolean('--final', false, { description: 'encode with x264 for a release' })
155
+
156
+ async execute(): Promise<number> {
157
+ return run(this.context, async (say, root, cwd) => {
158
+ const l = await loaded(this.file, say, root, cwd)
159
+ if (!l) return 1
160
+ await (await import('../trailer/build.ts')).previewSection(l, this.name, { refilm: null, final: this.final, say, cwd })
161
+ return 0
162
+ })
163
+ }
164
+ }
165
+
166
+ export class TrailerLevelsCommand extends Command {
167
+ static override paths = [['trailer', 'levels']]
168
+
169
+ static override usage = Command.Usage({
170
+ category: 'Trailer',
171
+ description: 'each section\'s mean and peak in the built mp4, and its step from the section before',
172
+ details: DETAILS,
173
+ examples: [['the levels of the one cut', 'sk trailer levels']],
174
+ })
175
+
176
+ file = Option.String({ required: false })
177
+
178
+ async execute(): Promise<number> {
179
+ return run(this.context, async (say, root, cwd) => {
180
+ const l = await loaded(this.file, say, root, cwd)
181
+ if (!l) return 1
182
+ await (await import('../trailer/passage.ts')).levels(l, say, cwd)
183
+ return 0
184
+ })
185
+ }
186
+ }
187
+
188
+ export class TrailerClipCommand extends Command {
189
+ static override paths = [['trailer', 'clip']]
190
+
191
+ static override usage = Command.Usage({
192
+ category: 'Trailer',
193
+ description: 'a passage of the built cut with its sound, from a section, mark, shot or second',
194
+ details: `${DETAILS}. --version cuts the same passage of a kept version beside it, read from versions/<v>.trailer`,
195
+ examples: [
196
+ ['a section', 'sk trailer clip drop'],
197
+ ['from a shot to a mark, before and after', 'sk trailer clip hero hit --version v3'],
198
+ ],
199
+ })
200
+
201
+ from = Option.String({ required: true })
202
+ rest = Option.Rest({ required: 0 })
203
+ version = Option.String('--version', { description: 'a kept version (v<n>): the same passage of it beside' })
204
+ pad = Option.String('--pad', { description: 'seconds of room before and after' })
205
+
206
+ async execute(): Promise<number> {
207
+ return run(this.context, async (say, root, cwd) => {
208
+ const { existsSync } = await import('node:fs')
209
+ const { resolve } = await import('node:path')
210
+ // the second word is a moment unless it names a file or a folder
211
+ const [a, b] = this.rest
212
+ const isPath = (w: string | undefined): boolean => w !== undefined && (w.endsWith('.trailer') || existsSync(resolve(cwd, w)))
213
+ const to = a !== undefined && !isPath(a) ? a : this.from
214
+ const file = a !== undefined && !isPath(a) ? b : a
215
+ const l = await loaded(file, say, root, cwd)
216
+ if (!l) return 1
217
+ const pad = Number(this.pad ?? 0)
218
+ if (!Number.isFinite(pad) || pad < 0) throw new Error(`--pad wants seconds, got ${this.pad}`)
219
+ await (await import('../trailer/passage.ts')).clip(l, this.from, to, pad, this.version, say, cwd)
220
+ return 0
221
+ })
222
+ }
223
+ }
224
+
225
+ export class TrailerKeepCommand extends Command {
226
+ static override paths = [['trailer', 'keep']]
227
+
228
+ static override usage = Command.Usage({
229
+ category: 'Trailer',
230
+ description: 'keep the built cut as a version: versions/v<n>.trailer, its video, strip and takes',
231
+ details: `${DETAILS}. Refuses a version already kept; prints the index row, commit and tag left to do`,
232
+ examples: [['keep the one cut', 'sk trailer keep v4']],
233
+ })
234
+
235
+ version = Option.String({ required: true })
236
+ file = Option.String({ required: false })
237
+
238
+ async execute(): Promise<number> {
239
+ return run(this.context, async (say, root, cwd) => {
240
+ const l = await loaded(this.file, say, root, cwd)
241
+ if (!l) return 1
242
+ ;(await import('../trailer/passage.ts')).keep(l, this.version, say, cwd)
243
+ return 0
244
+ })
245
+ }
246
+ }
247
+
248
+ export class TrailerFootageCommand extends Command {
249
+ static override paths = [['trailer', 'footage']]
250
+
251
+ static override usage = Command.Usage({
252
+ category: 'Trailer',
253
+ description: 'the take file of each shot or footage block, the raw take before the stitch trims it',
254
+ details: DETAILS,
255
+ examples: [['two shots of the one cut', 'sk trailer footage intro,hero']],
256
+ })
257
+
258
+ names = Option.String({ required: true })
259
+ file = Option.String({ required: false })
260
+
261
+ async execute(): Promise<number> {
262
+ return run(this.context, async (say, root, cwd) => {
263
+ const l = await loaded(this.file, say, root, cwd)
264
+ if (!l) return 1
265
+ ;(await import('../trailer/passage.ts')).footage(l, this.names.split(','), say, cwd)
266
+ return 0
267
+ })
268
+ }
269
+ }
270
+
271
+ export class TrailerWatchCommand extends Command {
272
+ static override paths = [['trailer', 'watch']]
273
+
274
+ static override usage = Command.Usage({
275
+ category: 'Trailer',
276
+ description: 'build again after each save in the cut\'s folder (ctrl-c stops)',
277
+ details: DETAILS,
278
+ examples: [['the one cut', 'sk trailer watch']],
279
+ })
280
+
281
+ file = Option.String({ required: false })
282
+ final = Option.Boolean('--final', false, { description: 'encode with x264 for a release' })
283
+
284
+ async execute(): Promise<number> {
285
+ return run(this.context, async (say, root, cwd) => {
286
+ const b = await import('../trailer/build.ts')
287
+ const { watch } = await import('node:fs')
288
+ const { dirname, relative } = await import('node:path')
289
+ const file = b.trailerFile(this.file, root, cwd)
290
+ let busy = false
291
+ let again = false
292
+ const once = async (): Promise<void> => {
293
+ if (busy) {
294
+ again = true
295
+ return
296
+ }
297
+ busy = true
298
+ try {
299
+ const l = await b.load(file, root, { fresh: true })
300
+ if (b.sayErrors(l, say, cwd)) await b.build(l, { refilm: null, final: this.final, say, cwd })
301
+ } catch (e) {
302
+ say(`FAIL: ${e instanceof Error ? e.message : String(e)}`)
303
+ }
304
+ busy = false
305
+ if (again) {
306
+ again = false
307
+ await once()
308
+ }
309
+ }
310
+ await once()
311
+ say(`watching ${relative(cwd, file)} and its folder's recipes (ctrl-c stops)`)
312
+ let timer: ReturnType<typeof setTimeout> | null = null
313
+ watch(dirname(file), (_, f) => {
314
+ if (!f || f.startsWith('out') || f.startsWith('versions') || f.endsWith('.md')) return
315
+ if (timer) clearTimeout(timer)
316
+ timer = setTimeout(() => void once(), 200)
317
+ })
318
+ await new Promise(() => undefined)
319
+ return 0
320
+ })
321
+ }
322
+ }
323
+
324
+ export class TrailerPruneCommand extends Command {
325
+ static override paths = [['trailer', 'prune']]
326
+
327
+ static override usage = Command.Usage({
328
+ category: 'Trailer',
329
+ description: 'trim the trailer cache to what the cuts use, what each folder frees',
330
+ details: `${DETAILS}. Every build, section and shot runs it after its stitch; a file younger than an hour stays`,
331
+ examples: [
332
+ ['what it would free', 'sk trailer prune --dry'],
333
+ ['free it', 'sk trailer prune'],
334
+ ],
335
+ })
336
+
337
+ dry = Option.Boolean('--dry', false, { description: 'say what each folder would free, delete nothing' })
338
+
339
+ async execute(): Promise<number> {
340
+ return run(this.context, async (say, root) => {
341
+ const { prune, sayPruned } = await import('../trailer/prune.ts')
342
+ for (const line of sayPruned(await prune(root, { dry: this.dry }), this.dry)) say(line)
343
+ return 0
344
+ })
345
+ }
346
+ }
347
+
348
+ /** every `sk trailer` verb, registered by cli.ts */
349
+ export const TRAILER_COMMANDS = [
350
+ TrailerCommand,
351
+ TrailerCheckCommand,
352
+ TrailerScoreCommand,
353
+ TrailerShotCommand,
354
+ TrailerSectionCommand,
355
+ TrailerLevelsCommand,
356
+ TrailerClipCommand,
357
+ TrailerKeepCommand,
358
+ TrailerFootageCommand,
359
+ TrailerWatchCommand,
360
+ TrailerPruneCommand,
361
+ ]
@@ -61,6 +61,10 @@ export type Verb = {
61
61
  essential?: Essential
62
62
  /** filmable: takes `--out <path>`, a clip also `--seconds <n>` (a trailer `film` line calls it) */
63
63
  film?: Film
64
+ /** with film: the row's take version, a bump films every trailer take of the row again */
65
+ version?: number | string
66
+ /** with film: `own`, a clip that films as long as it plays (no `--seconds`) */
67
+ length?: 'own'
64
68
  }
65
69
 
66
70
  /** the argv a row runs */
@@ -120,6 +124,8 @@ export function verbs(rows: Verb[]): CommandClass[] {
120
124
  static override usage = Command.Usage({ category: v.category, description: v.description, details: v.details, examples: v.examples })
121
125
  static essential = v.essential
122
126
  static film = v.film
127
+ static filmVersion = v.version
128
+ static filmLength = v.length
123
129
  args = Option.Proxy()
124
130
  async execute(): Promise<number> {
125
131
  const { root, cwd } = this.context
@@ -148,10 +154,14 @@ export function verbs(rows: Verb[]): CommandClass[] {
148
154
 
149
155
  /** the marks a command class carries: set by `verbs()` from its row, or
150
156
  * declared as statics on a hand written class */
151
- export function marksOf(c: object): { essential?: Essential; film?: Film } {
152
- const o = c as { essential?: unknown; film?: unknown }
153
- const out: { essential?: Essential; film?: Film } = {}
157
+ export function marksOf(c: object): { essential?: Essential; film?: Film; version?: number | string; length?: 'own' } {
158
+ const o = c as { essential?: unknown; film?: unknown; filmVersion?: unknown; filmLength?: unknown }
159
+ const out: { essential?: Essential; film?: Film; version?: number | string; length?: 'own' } = {}
154
160
  if (typeof o.essential === 'object' && o.essential !== null) out.essential = o.essential as Essential
155
- if (o.film === 'still' || o.film === 'clip') out.film = o.film
161
+ if (o.film === 'still' || o.film === 'clip') {
162
+ out.film = o.film
163
+ if (typeof o.filmVersion === 'number' || typeof o.filmVersion === 'string') out.version = o.filmVersion
164
+ if (o.filmLength === 'own') out.length = 'own'
165
+ }
156
166
  return out
157
167
  }
package/src/cli.ts CHANGED
@@ -8,6 +8,7 @@ import { AskNewCommand, AsksCheckCommand, AsksCommand } from './cli/cmd_Asks.ts'
8
8
  import { AssetsAddCommand, AssetsCheckCommand, AssetsFillCommand, AssetsGenCommand, AssetsIdentityCommand } from './cli/cmd_Assets.ts'
9
9
  import { BumpCommand } from './cli/cmd_Bump.ts'
10
10
  import { CallsCommand } from './cli/cmd_Calls.ts'
11
+ import { TimeCommand } from './cli/cmd_Time.ts'
11
12
  import { CheckCommand } from './cli/cmd_Check.ts'
12
13
  import { CommandsCommand } from './cli/cmd_Commands.ts'
13
14
  import { aliasArgs, CrossRepoActivityCommand } from './cli/cmd_CrossRepoActivity.ts'
@@ -39,7 +40,8 @@ import { SmokeCommand } from './cli/cmd_Smoke.ts'
39
40
  import { ShotCommand } from './cli/cmd_Shot.ts'
40
41
  import { StatusCommand } from './cli/cmd_Status.ts'
41
42
  import { StartCommand } from './cli/cmd_Start.ts'
42
- import { TrailerCheckCommand, TrailerCommand, TrailerShotCommand } from './cli/cmd_Trailer.ts'
43
+ import { TRAILER_COMMANDS } from './cli/cmd_Trailer.ts'
44
+ import { REVIEW_COMMANDS } from './cli/cmd_Review.ts'
43
45
  import { TuiCommand } from './cli/cmd_Tui.ts'
44
46
  import { WikiLintCommand, WikiServeCommand } from './cli/cmd_Wiki.ts'
45
47
 
@@ -70,9 +72,8 @@ export function buildCli(): Cli {
70
72
  cli.register(MvCommand)
71
73
  cli.register(CheckCommand)
72
74
  cli.register(TuiCommand)
73
- cli.register(TrailerCommand)
74
- cli.register(TrailerCheckCommand)
75
- cli.register(TrailerShotCommand)
75
+ for (const c of TRAILER_COMMANDS) cli.register(c)
76
+ for (const c of REVIEW_COMMANDS) cli.register(c)
76
77
  cli.register(ServeCommand)
77
78
  cli.register(PmCommand)
78
79
  cli.register(DaemonCommand)
@@ -117,6 +118,7 @@ export function buildCli(): Cli {
117
118
  cli.register(UnitsAliasCommand)
118
119
  cli.register(CommandsCommand)
119
120
  cli.register(CallsCommand)
121
+ cli.register(TimeCommand)
120
122
  cli.register(ConfigKeysCommand)
121
123
 
122
124
  return cli
@@ -31,7 +31,12 @@ export const NO_CHECK = 'in no check'
31
31
  * section a row lives under (hub-app/tree-build.ts KIND_SECTION). There is no
32
32
  * catch all kind: a row belongs to an owner, and what belongs to the hub
33
33
  * itself is its status (serve/hub-problems.ts), never a row */
34
- export const RESOURCE_TYPES = ['ask', 'secret', 'asset', 'post', 'workflow', 'wiki', 'contract', 'check', 'test', 'reflection', 'process'] as const
34
+ export const RESOURCE_TYPES = ['ask', 'secret', 'asset', 'post', 'workflow', 'wiki', 'contract', 'check', 'test', 'reflection', 'process', 'card', 'cut'] as const
35
+
36
+ /** the collection of the review cards that wait on him (collections/review.ts) */
37
+ export const REVIEW_COL = 'review'
38
+ /** the collection of the trailer cuts, one row a cut (collections/review.ts) */
39
+ export const TRAILER_COL = 'trailers'
35
40
 
36
41
  export type ResourceType = (typeof RESOURCE_TYPES)[number]
37
42
 
@@ -27,6 +27,7 @@ import { mdBody } from './md-body.ts'
27
27
  import { socialPostsCollection } from './posts.ts'
28
28
  import { assetState, secretState, type SecretLevel, type SecretProbe } from './state-rules.ts'
29
29
  import { reflectionsCollection } from './reflections.ts'
30
+ import { reviewCollection, trailersCollection } from './review.ts'
30
31
  import { testsCollection } from './tests.ts'
31
32
  import { checksCollection } from './checks.ts'
32
33
  import { LAYOUT } from '../core/layout.ts'
@@ -417,5 +418,7 @@ export function buildCollections(root: string, c: RepoConfig, kit: ResolvedKit |
417
418
  socialPostsCollection(root),
418
419
  reflectionsCollection(root),
419
420
  testsCollection(root, c),
421
+ // only where the repo has a review store or a trailer folder: the section of a repo with none is greyed
422
+ ...[reviewCollection(root), trailersCollection(root)].filter((x): x is Collection => x !== null),
420
423
  ]
421
424
  }
@@ -0,0 +1,70 @@
1
+ // the review store and the trailer cuts as tree rows (wiki build/shipkit/review.md). Under the review section, one row a
2
+ // card that waits on HIM (the owner's verdict, his answer to a question): yellow, so `sk needs` lists it too. Under the trailer
3
+ // section, one row a cut, its open frame notes in the slot: they wait on agents, never on him, so a cut is never yellow.
4
+ // Each collection exists only where the repo has its folder, so a repo with none draws the section greyed
5
+ import { existsSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+ import { STATUS_LABEL, isFrame, stateOf } from '../review/model.ts'
8
+ import { readAllCached, storeDir } from '../review/store.ts'
9
+ import { statusOf, waitingOnOwner } from '../review/threads.ts'
10
+ import { cutsOf, trailerIds } from '../review/trailer-desk.ts'
11
+ import { REVIEW_COL, TRAILER_COL, type Collection, type Instance } from './collection-types.ts'
12
+
13
+ export function reviewCollection(root: string): Collection | null {
14
+ const dir = storeDir(root)
15
+ if (!existsSync(dir)) return null
16
+ return {
17
+ id: REVIEW_COL,
18
+ title: 'review',
19
+ kind: 'card',
20
+ media: 'image',
21
+ list: async () => {
22
+ const { items } = readAllCached(dir)
23
+ return waitingOnOwner(items).map(({ head: x }): Instance => ({
24
+ id: `card:${x.item.id}`,
25
+ text: `${x.item.title} (${STATUS_LABEL[statusOf(x, items)]})`,
26
+ label: x.item.title,
27
+ slot: x.item.origin.by === 'agent' ? (x.item.origin.lane ?? 'card') : 'question',
28
+ type: 'card',
29
+ path: `.shipkit/review/${x.item.id}`,
30
+ severity: 'yellow',
31
+ verbs: ['approve'],
32
+ }))
33
+ },
34
+ }
35
+ }
36
+
37
+ export function trailersCollection(root: string): Collection | null {
38
+ const ids = trailerIds(root)
39
+ if (ids.length === 0) return null
40
+ return {
41
+ id: TRAILER_COL,
42
+ title: 'trailer',
43
+ kind: 'cut',
44
+ media: 'image',
45
+ list: async () => {
46
+ const dir = storeDir(root)
47
+ const items = existsSync(dir) ? readAllCached(dir).items : []
48
+ const open = new Map<string, number>()
49
+ for (const x of items) {
50
+ const s = x.item.subject
51
+ if (isFrame(s) && stateOf(x, false).state !== 'closed') open.set(`${s.trailer}/${s.cut}`, (open.get(`${s.trailer}/${s.cut}`) ?? 0) + 1)
52
+ }
53
+ return ids.flatMap((id) =>
54
+ cutsOf(join(root, 'trailer', id)).map((c): Instance => {
55
+ const n = open.get(`${id}/${c.cut}`) ?? 0
56
+ const rendered = existsSync(join(root, 'trailer', id, 'out', `${c.name}.mp4`))
57
+ return {
58
+ id: `cut:${id}/${c.cut}`,
59
+ text: `${id} ${c.cut}${rendered ? '' : ' (not rendered)'}${n > 0 ? `, ${n} open note${n > 1 ? 's' : ''}` : ''}`,
60
+ label: ids.length > 1 ? `${id} · ${c.cut}` : c.cut,
61
+ slot: n > 0 ? `${n} open` : rendered ? '' : 'no render',
62
+ type: 'cut',
63
+ path: c.trailer === null ? `trailer/${id}/out/${c.name}.mp4` : `trailer/${id}/${c.cut}.trailer`,
64
+ state: rendered ? 'idle' : 'quiet',
65
+ }
66
+ }),
67
+ )
68
+ },
69
+ }
70
+ }
@@ -36,6 +36,8 @@ export const LAYOUT = {
36
36
  discord: sk('discord'),
37
37
  // a review with no output file named by its caller (YYYYMMDD-HHMMSS-<name>.md)
38
38
  reviews: sk('reviews'),
39
+ // the one feedback store: review cards and frame notes, one folder an item (review/store.ts)
40
+ review: sk('review'),
39
41
  // gitignored scratch, never a plan home: logs, leases, detached runs, screenshots
40
42
  tmp: sk('tmp'),
41
43
  // a CLI's call log, `<cli>-calls.jsonl`: local, never tracked