rvlib-shipkit 2.2.0 → 2.4.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 (140) hide show
  1. package/CHANGELOG.md +47 -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/assets/kits/identity-kit.ts +5 -2
  12. package/src/cli/cmd_Commands.ts +9 -0
  13. package/src/cli/cmd_Review.ts +974 -0
  14. package/src/cli/cmd_Sound.ts +209 -0
  15. package/src/cli/cmd_Time.ts +112 -0
  16. package/src/cli/cmd_Trailer.ts +264 -6
  17. package/src/cli/kit/verb.ts +22 -4
  18. package/src/cli.ts +8 -4
  19. package/src/collections/collection-types.ts +6 -1
  20. package/src/collections/providers.ts +3 -0
  21. package/src/collections/review.ts +70 -0
  22. package/src/core/app-icon.ts +10 -6
  23. package/src/core/layout.ts +2 -0
  24. package/src/reflect/prompt-corpus.ts +15 -119
  25. package/src/reflect/transcript.ts +254 -0
  26. package/src/repo-config.ts +3 -0
  27. package/src/review/anim.ts +228 -0
  28. package/src/review/auto.ts +141 -0
  29. package/src/review/format.ts +158 -0
  30. package/src/review/http.ts +23 -0
  31. package/src/review/marks.ts +43 -0
  32. package/src/review/media.ts +45 -0
  33. package/src/review/model.ts +896 -0
  34. package/src/review/project.ts +126 -0
  35. package/src/review/role.ts +48 -0
  36. package/src/review/routes.ts +239 -0
  37. package/src/review/store.ts +1011 -0
  38. package/src/review/threads.ts +237 -0
  39. package/src/review/trailer-desk.ts +385 -0
  40. package/src/review/trailer-model.ts +183 -0
  41. package/src/review/trailer-routes.ts +136 -0
  42. package/src/review/unit.ts +183 -0
  43. package/src/review/watchers.ts +54 -0
  44. package/src/serve/embed.ts +35 -0
  45. package/src/serve/hub-app/activity-view.tsx +1 -1
  46. package/src/serve/hub-app/activity.css +0 -4
  47. package/src/serve/hub-app/admin-svc.ts +1 -1
  48. package/src/serve/hub-app/admin-view.tsx +1 -1
  49. package/src/serve/hub-app/dock-view.tsx +5 -4
  50. package/src/serve/hub-app/hash-route.ts +36 -1
  51. package/src/serve/hub-app/header-bar.tsx +3 -1
  52. package/src/serve/hub-app/home-st.ts +17 -3
  53. package/src/serve/hub-app/home-strip.tsx +3 -2
  54. package/src/serve/hub-app/home.html +1 -0
  55. package/src/serve/hub-app/hub-app.tsx +80 -11
  56. package/src/serve/hub-app/hub.css +83 -14
  57. package/src/serve/hub-app/icons.tsx +15 -0
  58. package/src/serve/hub-app/index.html +1 -0
  59. package/src/serve/hub-app/insights-view.tsx +4 -3
  60. package/src/serve/hub-app/keys.ts +14 -10
  61. package/src/serve/hub-app/layout-st.ts +63 -2
  62. package/src/serve/hub-app/md.ts +32 -0
  63. package/src/serve/hub-app/planned-view.tsx +3 -2
  64. package/src/serve/hub-app/planned.css +0 -1
  65. package/src/serve/hub-app/project-list.css +26 -6
  66. package/src/serve/hub-app/project-list.tsx +46 -10
  67. package/src/serve/hub-app/projects-svc.ts +7 -2
  68. package/src/serve/hub-app/repo-sections.ts +15 -3
  69. package/src/serve/hub-app/repo-st.ts +61 -4
  70. package/src/serve/hub-app/review/clip-st.ts +258 -0
  71. package/src/serve/hub-app/review/kind-view.tsx +240 -0
  72. package/src/serve/hub-app/review/kit.tsx +113 -0
  73. package/src/serve/hub-app/review/review-answer.tsx +269 -0
  74. package/src/serve/hub-app/review/review-board.tsx +46 -0
  75. package/src/serve/hub-app/review/review-list.tsx +156 -0
  76. package/src/serve/hub-app/review/review-page.tsx +96 -0
  77. package/src/serve/hub-app/review/review-st.ts +902 -0
  78. package/src/serve/hub-app/review/review-views.tsx +648 -0
  79. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  80. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  81. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  82. package/src/serve/hub-app/review.css +389 -0
  83. package/src/serve/hub-app/theme-st.ts +56 -0
  84. package/src/serve/hub-app/tree-build.ts +4 -0
  85. package/src/serve/hub-app/tree-row.tsx +1 -1
  86. package/src/serve/hub-app/tree-svc.ts +4 -0
  87. package/src/serve/hub-app/wiki-st.ts +190 -0
  88. package/src/serve/hub-app/wikis-view.tsx +134 -5
  89. package/src/serve/hub-runtime.ts +9 -7
  90. package/src/serve/project-gutter-route.ts +25 -1
  91. package/src/serve/project-gutter.ts +1 -1
  92. package/src/serve/same-origin.ts +5 -1
  93. package/src/serve/serve-hub.ts +15 -0
  94. package/src/shipkit-files.ts +9 -5
  95. package/src/shot/client.ts +17 -5
  96. package/src/shot/shooter.ts +19 -3
  97. package/src/shot/shot-ask.ts +9 -1
  98. package/src/shot/stepped-clip.ts +53 -0
  99. package/src/trailer/adapter.ts +90 -6
  100. package/src/trailer/build.ts +459 -59
  101. package/src/trailer/ffmpeg.ts +22 -0
  102. package/src/trailer/fonts.ts +55 -0
  103. package/src/trailer/footage.ts +112 -43
  104. package/src/trailer/grammar.ts +864 -127
  105. package/src/trailer/graph.ts +308 -49
  106. package/src/trailer/holes.ts +72 -0
  107. package/src/trailer/load-unit.ts +20 -3
  108. package/src/trailer/look.ts +20 -3
  109. package/src/trailer/motion.ts +207 -0
  110. package/src/trailer/passage.ts +129 -0
  111. package/src/trailer/plan.ts +82 -23
  112. package/src/trailer/prune.ts +204 -0
  113. package/src/trailer/score/compose.ts +256 -0
  114. package/src/trailer/score/dsp.ts +272 -0
  115. package/src/trailer/score/gm-render.ts +43 -0
  116. package/src/trailer/score/gm.ts +90 -0
  117. package/src/trailer/score/midi.ts +143 -0
  118. package/src/trailer/score/palette.ts +188 -0
  119. package/src/trailer/score/pattern.ts +98 -0
  120. package/src/trailer/score/score.ts +228 -0
  121. package/src/trailer/score/sound.ts +123 -0
  122. package/src/trailer/score/synth.ts +383 -0
  123. package/src/trailer/sound/audio.ts +148 -0
  124. package/src/trailer/sound/brand.ts +42 -0
  125. package/src/trailer/sound/comfy.ts +38 -0
  126. package/src/trailer/sound/eleven.ts +72 -0
  127. package/src/trailer/sound/fluid.ts +71 -0
  128. package/src/trailer/sound/gen.ts +139 -0
  129. package/src/trailer/sound/index.ts +60 -0
  130. package/src/trailer/sound/presets.ts +69 -0
  131. package/src/trailer/sound/run.ts +189 -0
  132. package/src/trailer/states.ts +76 -0
  133. package/src/trailer/stitch.ts +92 -9
  134. package/src/trailer/titles.ts +471 -150
  135. package/src/trailer/url.ts +18 -3
  136. package/src/trailer/vocab.ts +34 -4
  137. package/src/trailer/words.ts +275 -0
  138. package/src/wiki/graph-mount.ts +3 -0
  139. package/src/wiki/graph-page.ts +45 -9
  140. package/src/wiki/graph-pages.ts +128 -0
@@ -0,0 +1,974 @@
1
+ // sk review: an agent shows the owner something as one card of the one store and reads his answer; a dispatcher routes
2
+ // what he, a player or a rule raised, a worker claims and closes it (review/store.ts, wiki build/shipkit/review.md).
3
+ // Every module loads inside execute: the cli registers this file on every `sk` call
4
+ import { Command, Option } from 'clipanion'
5
+
6
+ const DETAILS = `the one feedback store, .shipkit/review/<id>/: one subject is one card (other angles are views of it, a follow up --supersedes it), he answers it in the hub's review entry (approved, ok with a fix, rejected, archived, rectangles, reactions per view), you read his answer with inbox or watch. What he, a player or a rule raises is an item of the same store: a dispatcher routes it to a topic, a worker claims it and closes it. The repo's review unit names its kinds, topics, sources and rules. SK_AGENT names your lane. Page: wiki build/shipkit/review.md`
7
+
8
+ type Ctx = { stdout: NodeJS.WritableStream; stderr: NodeJS.WritableStream }
9
+ type Unit = import('../review/unit.ts').ReviewUnit
10
+ type Say = import('../review/format.ts').Say
11
+
12
+ /** what a verb works on: the store, its repo's unit (null when the verb reads no unit, or the repo has none), and how the unit's lines print */
13
+ interface Here {
14
+ dir: string
15
+ unit: Unit | null
16
+ say: Say
17
+ out: (l: string) => void
18
+ err: (l: string) => void
19
+ }
20
+
21
+ /** the store of the repo this runs in, its unit read when the verb needs it, and the body; a refusal is one FAIL line with its reason, exit 1 */
22
+ async function run(context: Ctx, body: (h: Here) => Promise<number> | number, o: { unit?: boolean } = {}): Promise<number> {
23
+ const { findRepoRoot } = await import('../ts/config.ts')
24
+ const out = (l: string): void => void context.stdout.write(`${l}\n`)
25
+ const err = (l: string): void => void context.stderr.write(`${l}\n`)
26
+ try {
27
+ const root = findRepoRoot(process.cwd())
28
+ if (o.unit === false) {
29
+ const { storeDir } = await import('../review/store.ts')
30
+ return await body({ dir: storeDir(root), unit: null, say: { spec: (await import('../review/model.ts')).NO_SPEC, more: () => [] }, out, err })
31
+ }
32
+ const { openReview, factLines } = await import('../review/project.ts')
33
+ const { specOf } = await import('../review/store.ts')
34
+ const { dir, unit } = await openReview(root)
35
+ return await body({ dir, unit, say: { spec: specOf(dir), more: (x) => factLines(unit, x) }, out, err })
36
+ } catch (e) {
37
+ err(`FAIL: review: ${e instanceof Error ? e.message : String(e)}`)
38
+ return 1
39
+ }
40
+ }
41
+
42
+ const isHelp = (a: string): boolean => a === '--help' || a === '-h'
43
+
44
+ /** the agent that acts: --as, else SK_AGENT; `need` refuses an unnamed one */
45
+ function actor(as: string | undefined, need: boolean, verb: string): string {
46
+ const n = as?.trim() || process.env.SK_AGENT?.trim()
47
+ if (n === 'owner') throw new Error('the owner acts from the hub, not from here')
48
+ if (n) return n
49
+ if (need) throw new Error(`${verb} needs --as <name> (or SK_AGENT)`)
50
+ return 'agent'
51
+ }
52
+
53
+ /** an item id or a player report's ref (`live#3`) to its item id */
54
+ async function idOf(dir: string, ref: string): Promise<string> {
55
+ const { itemOfRef } = await import('../review/store.ts')
56
+ const x = itemOfRef(dir, ref)
57
+ if (!x) throw new Error(`no item ${ref}`)
58
+ return x.item.id
59
+ }
60
+
61
+ /** the sources pulled before a read: a line per source down, said once a process */
62
+ const downSaid = new Set<string>()
63
+ async function pull(h: Here): Promise<void> {
64
+ if (!h.unit?.sources?.length) return
65
+ const { pullSources } = await import('../review/project.ts')
66
+ const now = new Set<string>()
67
+ const got = await pullSources(h.dir, h.unit, (l) => {
68
+ now.add(l)
69
+ if (!downSaid.has(l)) h.err(l)
70
+ })
71
+ downSaid.clear()
72
+ for (const l of now) downSaid.add(l)
73
+ if (got.length > 0) h.err(`review: ${got.length} new items pulled`)
74
+ }
75
+
76
+ /** a gallery tile as given: `<label>=<file>`, or a file labelled by its name */
77
+ function tileOf(arg: string): { file: string; label: string } {
78
+ const eq = arg.lastIndexOf('=')
79
+ if (eq > 0) return { label: arg.slice(0, eq), file: arg.slice(eq + 1) }
80
+ const name = arg.split('/').pop() ?? arg
81
+ return { file: arg, label: name.replace(/\.[a-z0-9]+$/i, '') }
82
+ }
83
+
84
+ type ViewInput = import('../review/store.ts').ViewInput
85
+
86
+ /** `--view "<label>" a.png [b.png] [--note "<text>"]` and `--gallery "<label>" a.png b.png …` out of the args */
87
+ function takeViews(args: readonly string[]): { views: ViewInput[]; rest: string[] } {
88
+ const views: ViewInput[] = []
89
+ const rest: string[] = []
90
+ for (let i = 0; i < args.length; i++) {
91
+ const a = args[i] ?? ''
92
+ if (a !== '--gallery' && a !== '--view') {
93
+ rest.push(a)
94
+ continue
95
+ }
96
+ const label = args[i + 1]
97
+ const files: string[] = []
98
+ for (let j = i + 2; j < args.length && (a === '--gallery' || files.length < 2) && !(args[j] ?? '').startsWith('--'); j++) files.push(args[j] ?? '')
99
+ if (label === undefined || label.startsWith('--') || files.length === 0) throw new Error(a === '--gallery' ? '--gallery "<label>" a.png b.png …: a label, then its images' : '--view "<label>" a.png [b.png]: a label, then an image or a before and an after')
100
+ if (a === '--gallery') views.push({ label, gallery: files.map(tileOf) })
101
+ else views.push(files.length === 1 ? { label, image: files[0] } : { label, before: files[0], after: files[1] })
102
+ i += 1 + files.length
103
+ if (args[i + 1] === '--note') {
104
+ const note = args[i + 2]
105
+ if (note === undefined || note.startsWith('--')) throw new Error(`--view "${label}" … --note "<text>": a line on that view`)
106
+ const v = views[views.length - 1]
107
+ if (v) v.note = note
108
+ i += 2
109
+ }
110
+ }
111
+ return { views, rest }
112
+ }
113
+
114
+ /** the flags of `add`, parsed by name: a value flag repeats where it says so */
115
+ function flagsOf(args: readonly string[], values: readonly string[], repeat: readonly string[], bools: readonly string[]): { one: Record<string, string>; many: Record<string, string[]>; on: Set<string>; positionals: string[] } {
116
+ const one: Record<string, string> = {}
117
+ const many: Record<string, string[]> = {}
118
+ const on = new Set<string>()
119
+ const positionals: string[] = []
120
+ for (let i = 0; i < args.length; i++) {
121
+ const a = args[i] ?? ''
122
+ if (!a.startsWith('--')) {
123
+ positionals.push(a)
124
+ continue
125
+ }
126
+ const name = a.slice(2)
127
+ if (bools.includes(name)) {
128
+ on.add(name)
129
+ continue
130
+ }
131
+ if (!values.includes(name) && !repeat.includes(name)) throw new Error(`${a}: no such flag (sk review add --help)`)
132
+ const v = args[i + 1]
133
+ if (v === undefined) throw new Error(`${a}: needs a value`)
134
+ i++
135
+ if (repeat.includes(name)) (many[name] ??= []).push(v)
136
+ else one[name] = v
137
+ }
138
+ return { one, many, on, positionals }
139
+ }
140
+
141
+ const ADD_FLAGS = `flags:
142
+ --title "<title>" one short line: what he looks at
143
+ --what "<what>" what changed and why, short markdown: one line, then - bullets
144
+ --image <a.png> the subject; a clip (webm, mp4) goes anywhere an image goes
145
+ --video <clip.mp4> a clip
146
+ --audio <sound.wav> a sound: a waveform and a player (wav, mp3, flac, ogg, opus, m4a)
147
+ --before <a.png> --after <b.png> a before and after: two images, two clips of one size, or two sounds
148
+ --label "<label>" the first view's label
149
+ --view "<label>" <a.png> [<b.png>] another view of the same subject (repeat it); --note "<text>" right after it: a line on that view
150
+ --choice every view is an option he scores and picks one of (two --view or more)
151
+ --gallery "<label>" <a.png> … a grid of small images; "<tile label>=<c.png>" names a tile (repeat it)
152
+ --topic <topic> what part of the project it is about (sk review topics)
153
+ --subject <kind> what is judged (default shot; the repo's unit names its kinds)
154
+ --commit <sha> the commit it shows
155
+ --lane <id> the lane that made it; else SK_AGENT
156
+ --supersedes <id> the card it follows up, after his answer
157
+ --answers <id | live#3> the item it answers (a note he raised, a player's report), one thread (repeat it)
158
+ --keeps "<what stays>" with --supersedes on an approved look: what of it stays
159
+ --new --why "<reason>" a new card beside an active one of the same subject`
160
+
161
+ /** the help of add or add-view, then the flags this repo's review unit adds (any kind's, then each kind's under
162
+ * `--subject <kind>`); a repo with no unit, or one that does not load, says so on one line */
163
+ async function helpWithUnit(context: Ctx, usage: string, kinds: boolean): Promise<number> {
164
+ context.stdout.write(usage)
165
+ await run(context, (h) => {
166
+ const lines = Object.entries(h.unit?.flags ?? {}).map(([k, d]) => ` --${k} ${d}`)
167
+ if (kinds) for (const [name, k] of Object.entries(h.unit?.kinds ?? {})) for (const [f, d] of Object.entries(k.flags ?? {})) lines.push(` --${f} ${d} (with --subject ${name})`)
168
+ h.out(lines.length > 0 ? `\nthis repo's review unit adds:\n${lines.join('\n')}` : "\nthis repo's review unit adds no flags")
169
+ return 0
170
+ })
171
+ return 0
172
+ }
173
+
174
+ export class ReviewAddCommand extends Command {
175
+ static override paths = [['review', 'add']]
176
+ static override usage = Command.Usage({
177
+ category: 'Review',
178
+ description: 'show the owner a change as one card he answers in the hub: an image, a clip, a pair, views',
179
+ details: `${DETAILS}\n\n${ADD_FLAGS}`,
180
+ examples: [
181
+ ['one image', 'sk review add --title "the new menu" --what "the menu, bigger" --image shots/menu.png'],
182
+ ['a before and after', 'sk review add --title "lamps lit" --what "..." --before a.png --after b.png'],
183
+ ['a second version after his answer', 'sk review add --title "lamps lit" --what "..." --image c.png --supersedes <id>'],
184
+ ['a pick among three voices', 'sk review add --choice --title "the narrator" --what "..." --view "voice 0" a.flac --note "low, breathy" --view "voice 1" b.flac --view "voice 2" c.flac'],
185
+ ],
186
+ })
187
+ args = Option.Proxy()
188
+
189
+ async execute(): Promise<number> {
190
+ if (this.args.some(isHelp)) {
191
+ return helpWithUnit(this.context, this.cli.usage(ReviewAddCommand, { detailed: true }), true)
192
+ }
193
+ return run(this.context, async (h) => {
194
+ const { addItem } = await import('../review/store.ts')
195
+ const { views, rest } = takeViews(this.args)
196
+ // the repo's own flags: the subject kind's (--where), and the unit's for any kind (--mark), each repeatable
197
+ const kindName = rest[rest.indexOf('--subject') + 1]
198
+ const kind = rest.includes('--subject') && kindName ? h.unit?.kinds?.[kindName] : undefined
199
+ const own = [...Object.keys(kind?.flags ?? {}), ...Object.keys(h.unit?.flags ?? {})]
200
+ const f = flagsOf(rest, ['title', 'what', 'image', 'video', 'audio', 'before', 'after', 'label', 'topic', 'subject', 'commit', 'lane', 'supersedes', 'keeps', 'why', 'live'], ['answers', ...own], ['new', 'wip', 'choice'])
201
+ if (f.positionals.length > 0) throw new Error(`unexpected ${f.positionals.join(' ')}`)
202
+ const o = f.one
203
+ const repoFlags = Object.fromEntries(own.flatMap((k) => (f.many[k] ? [[k, f.many[k]]] : [])))
204
+ const parsed = kind?.parse ? kind.parse(Object.fromEntries(Object.keys(kind.flags ?? {}).map((k) => [k, f.many[k] ?? []]))) : null
205
+ if (f.on.has('wip') !== (o.live !== undefined)) throw new Error('--wip --live <name>: a draft card names its live shot, and a live shot makes a draft card')
206
+ let wipViews: ViewInput[] = []
207
+ if (o.live !== undefined) {
208
+ if (!h.unit?.live) throw new Error("--live: the repo's review unit declares no live shots (live: { frames })")
209
+ if (o.image || o.video || o.audio || o.before || o.after || views.length > 0) throw new Error('--wip: the card shows the live shot alone, no --image, --video, pair or --view beside it')
210
+ const fr = h.unit.live.frames(o.live)
211
+ wipViews = [{ label: '', image: fr.image }, ...(fr.history.length > 0 ? [{ label: 'history', gallery: fr.history }] : [])]
212
+ }
213
+ const answers = await Promise.all((f.many.answers ?? []).map((a) => idOf(h.dir, a)))
214
+ const topic = o.topic ?? parsed?.topic
215
+ let input: import('../review/store.ts').AddInput = {
216
+ title: o.title ?? '',
217
+ what: o.what ?? '',
218
+ ...(parsed ? { subject: parsed.subject } : o.subject ? { subject: { kind: o.subject } } : {}),
219
+ ...(topic ? { topic } : {}),
220
+ ...(o.image ? { image: o.image } : {}),
221
+ ...(o.video ? { video: o.video } : {}),
222
+ ...(o.audio ? { audio: o.audio } : {}),
223
+ ...(o.before ? { before: o.before } : {}),
224
+ ...(o.after ? { after: o.after } : {}),
225
+ ...(o.label ? { label: o.label } : {}),
226
+ ...(o.commit ? { commit: o.commit } : {}),
227
+ ...(o.lane ? { lane: o.lane } : {}),
228
+ ...(o.supersedes ? { supersedes: o.supersedes } : {}),
229
+ ...(o.keeps ? { keeps: o.keeps } : {}),
230
+ ...(o.why ? { why: o.why } : {}),
231
+ ...(answers.length > 0 ? { answers } : {}),
232
+ ...(f.on.has('new') ? { fresh: true } : {}),
233
+ ...(f.on.has('choice') ? { choice: true } : {}),
234
+ ...(o.live !== undefined ? { image: wipViews[0]?.image ?? '', views: wipViews.slice(1), wip: { live: o.live } } : { views }),
235
+ }
236
+ if (h.unit?.add) input = await h.unit.add(input, repoFlags)
237
+ const item = await addItem(h.dir, input)
238
+ h.out(item.id)
239
+ if (item.origin.by === 'agent' && !item.origin.lane) h.err('no lane on the card: pass --lane <id> (or SK_AGENT), so the hub colours it')
240
+ return 0
241
+ })
242
+ }
243
+ }
244
+
245
+ export class ReviewAddViewCommand extends Command {
246
+ static override paths = [['review', 'add-view']]
247
+ static override usage = Command.Usage({
248
+ category: 'Review',
249
+ description: 'another angle on a card he has not answered yet: an image, a pair, a clip or a gallery',
250
+ details: `${DETAILS}\n\nsk review add-view <id> "<label>" <a.png> [<b.png>] | --video <clip.mp4> | --audio <sound.wav> | --gallery <a.png> <b.png> … [--note "<a line on that view>"]`,
251
+ examples: [['a view from above', 'sk review add-view <id> "from above" top.png']],
252
+ })
253
+ args = Option.Proxy()
254
+
255
+ async execute(): Promise<number> {
256
+ if (this.args.some(isHelp)) {
257
+ return helpWithUnit(this.context, this.cli.usage(ReviewAddViewCommand, { detailed: true }), false)
258
+ }
259
+ return run(this.context, async (h) => {
260
+ const { addView } = await import('../review/store.ts')
261
+ const own = Object.keys(h.unit?.flags ?? {})
262
+ const repoFlags: Record<string, string[]> = {}
263
+ const args: string[] = []
264
+ let note: string | undefined
265
+ for (let i = 0; i < this.args.length; i++) {
266
+ const a = this.args[i] ?? ''
267
+ const name = a.startsWith('--') ? a.slice(2) : ''
268
+ if (own.includes(name)) (repoFlags[name] ??= []).push(this.args[++i] ?? '')
269
+ else if (name === 'note') note = this.args[++i]
270
+ else args.push(a)
271
+ }
272
+ if (this.args.includes('--note') && (note === undefined || note.startsWith('--'))) throw new Error('--note "<text>": a line on that view')
273
+ const [id, label, ...rest] = args
274
+ if (!id || !label) throw new Error('add-view <id> "<label>" <media>')
275
+ const v: ViewInput =
276
+ rest[0] === '--video' && rest[1]
277
+ ? { label, video: rest[1] }
278
+ : rest[0] === '--audio' && rest[1]
279
+ ? { label, audio: rest[1] }
280
+ : rest[0] === '--gallery'
281
+ ? { label, gallery: rest.slice(1).map(tileOf) }
282
+ : rest.length === 1
283
+ ? { label, image: rest[0] }
284
+ : rest.length === 2
285
+ ? { label, before: rest[0], after: rest[1] }
286
+ : (() => {
287
+ throw new Error('add-view <id> "<label>" <a.png> [<b.png>] | --video <clip> | --gallery <a.png> …')
288
+ })()
289
+ if (note !== undefined) v.note = note
290
+ const extra = h.unit?.addView ? await h.unit.addView(v, repoFlags) : {}
291
+ const item = await addView(h.dir, id, v, extra)
292
+ h.out(`${id}: view ${item.views.length} "${label}" added`)
293
+ return 0
294
+ })
295
+ }
296
+ }
297
+
298
+ export class ReviewLsCommand extends Command {
299
+ static override paths = [['review'], ['review', 'ls']]
300
+ static override usage = Command.Usage({
301
+ category: 'Review',
302
+ description: "the store's active threads, one line each: status, id, title; `sk review <verb>` runs a unit's verb",
303
+ details: `${DETAILS}\n\nsk review [--all] lists; sk review <verb> … runs a verb the repo's review unit declares (verbs: {...}).`,
304
+ examples: [['what is open', 'sk review ls'], ['every thread', 'sk review ls --all']],
305
+ })
306
+ args = Option.Proxy()
307
+
308
+ async execute(): Promise<number> {
309
+ const [verb, ...rest] = this.args
310
+ if (verb !== undefined && isHelp(verb)) {
311
+ this.context.stdout.write(this.cli.usage(ReviewLsCommand, { detailed: true }))
312
+ return 0
313
+ }
314
+ // a word that is no flag is a verb of the repo's review unit: a report replayed, the town composer
315
+ if (verb !== undefined && !verb.startsWith('--'))
316
+ return run(this.context, async (h) => {
317
+ const verbs = h.unit?.verbs ?? {}
318
+ const v = verbs[verb]
319
+ if (!v) throw new Error(`no verb « ${verb} »: sk review --help lists sk's own; the repo's unit declares ${Object.keys(verbs).join(', ') || 'none'}`)
320
+ const { dirname } = await import('node:path')
321
+ return v.run(rest, { dir: h.dir, root: dirname(dirname(h.dir)), say: h.out, err: h.err })
322
+ })
323
+ const every = rest.length === 0 && verb === '--all'
324
+ if (verb !== undefined && !every) {
325
+ this.context.stderr.write(`FAIL: review: unexpected ${this.args.join(' ')} (sk review --help)\n`)
326
+ return 1
327
+ }
328
+ // the listing reads no unit: it stays the fast answer, and the unit's refusals show in inbox and the hub
329
+ return run(
330
+ this.context,
331
+ async (h) => {
332
+ const { readAll } = await import('../review/store.ts')
333
+ const { isActive, orderedThreads, statusOf } = await import('../review/threads.ts')
334
+ const all = readAll(h.dir)
335
+ for (const b of all.broken) h.err(`review: FAIL ${b.id}: ${b.error}`)
336
+ const threads = orderedThreads(all.items).filter((t) => every || isActive(t))
337
+ if (threads.length === 0) h.out(every ? 'review: the store is empty' : 'review: nothing active (--all shows every thread)')
338
+ for (const t of threads) h.out(`${statusOf(t.head, all.items).padEnd(17)} ${t.head.item.id} ${t.head.item.title}${t.versions.length > 1 ? ` (v${t.versions.length})` : ''}`)
339
+ return all.broken.length > 0 ? 1 : 0
340
+ },
341
+ { unit: false },
342
+ )
343
+ }
344
+ }
345
+
346
+ export class ReviewShowCommand extends Command {
347
+ static override paths = [['review', 'show']]
348
+ static override usage = Command.Usage({ category: 'Review', description: 'one item and its thread: the verdict, rectangles, reactions, the whole conversation', details: `${DETAILS}\n\nTakes an item id or a player report's ref (live#3).` })
349
+ id = Option.String()
350
+
351
+ async execute(): Promise<number> {
352
+ return run(this.context, async (h) => {
353
+ const { readAllCached } = await import('../review/store.ts')
354
+ const { threadDetail, detail } = await import('../review/format.ts')
355
+ const { threadOfItem, threadsOf } = await import('../review/threads.ts')
356
+ const id = await idOf(h.dir, this.id)
357
+ const all = readAllCached(h.dir)
358
+ const x = all.items.find((y) => y.item.id === id)
359
+ if (!x) throw new Error(`no item ${this.id}`)
360
+ const t = threadOfItem(x.item.id, threadsOf(all.items))
361
+ h.out(t && (t.head.item.id === x.item.id || t.answers.length > 0) ? threadDetail(t, h.dir, true, h.say) : detail(x, h.dir, true, false, h.say))
362
+ return 0
363
+ })
364
+ }
365
+ }
366
+
367
+ /** --mine or --session <id>: only the cards that session opened; null keeps every item */
368
+ async function sessionOf(mine: boolean, session: string | undefined): Promise<string | null> {
369
+ if (session !== undefined) return session
370
+ if (!mine) return null
371
+ const { sessionId } = await import('../review/store.ts')
372
+ const s = sessionId()
373
+ if (s === null) throw new Error('--mine: no Claude Code session here (CLAUDE_CODE_SESSION_ID): --session <id>')
374
+ return s
375
+ }
376
+
377
+ export class ReviewInboxCommand extends Command {
378
+ static override paths = [['review', 'inbox']]
379
+ static override usage = Command.Usage({
380
+ category: 'Review',
381
+ description: 'every answer not marked seen and every open item he raised, frame notes included',
382
+ details: DETAILS,
383
+ examples: [['what waits on you', 'sk review inbox'], ['his notes on trailer frames', 'sk review inbox --subject frame'], ['the cards this session sent', 'sk review inbox --mine']],
384
+ })
385
+ mine = Option.Boolean('--mine', false, { description: "only this Claude Code session's cards" })
386
+ session = Option.String('--session', { description: "only that session's cards" })
387
+ subject = Option.String('--subject', { description: 'only items of this subject kind (frame, shot…)' })
388
+ all = Option.Boolean('--all', false, { description: 'a plain approval too, not only the counted line' })
389
+
390
+ async execute(): Promise<number> {
391
+ return run(this.context, async (h) => {
392
+ const { liveWatchers } = await import('../review/watchers.ts')
393
+ const { readAll } = await import('../review/store.ts')
394
+ const { BUILTIN_KINDS, coversCard, moveOf, stateOf, unreadBy, untakenOf, verdictOf } = await import('../review/model.ts')
395
+ const { followedIds, threadOfItem, threadsOf, unseen } = await import('../review/threads.ts')
396
+ const { detail, threadDetail, untakenLine } = await import('../review/format.ts')
397
+ const kinds = [...Object.keys(h.say.spec.kinds), ...Object.keys(BUILTIN_KINDS)]
398
+ if (this.subject !== undefined && Object.keys(h.say.spec.kinds).length > 0 && !kinds.includes(this.subject)) throw new Error(`--subject: one of ${kinds.join(', ')}`)
399
+ const session = await sessionOf(this.mine, this.session)
400
+ await pull(h)
401
+ const all = readAll(h.dir)
402
+ for (const b of all.broken) h.err(`review: FAIL ${b.id}: ${b.error}`)
403
+ const of = (x: (typeof all.items)[number]): boolean => coversCard({ session, lane: null }, x) && (this.subject === undefined || x.item.subject.kind === this.subject)
404
+ const news = unseen(all.items).filter(of)
405
+ const followed = followedIds(all.items)
406
+ // what he or a player raised and nobody closed is the agents' move, read or not
407
+ const open = all.items.filter((x) => of(x) && x.item.origin.by !== 'agent' && moveOf(stateOf(x, followed.has(x.item.id)).state) === 'agents' && !news.includes(x))
408
+ // a plain approval (no comment, no rectangle, no reaction, no pick, no reply) asks for no work: counted on one
409
+ // line unless --all
410
+ const plain = (x: (typeof news)[number]): boolean => {
411
+ const v = verdictOf(x)
412
+ return v?.status === 'approved' && !v.seen && !v.comment && v.rects.length === 0 && !v.reactions && v.pick === undefined && unreadBy(x, 'agent').length === 0
413
+ }
414
+ const shown = [...(this.all ? news : news.filter((x) => !plain(x))), ...open]
415
+ if (shown.length === 0 && news.length === 0) h.out('review: no unseen feedback or reply')
416
+ const threads = threadsOf(all.items)
417
+ const printed = new Set<string>()
418
+ for (const x of shown) {
419
+ const t = threadOfItem(x.item.id, threads)
420
+ if (t && printed.has(t.head.item.id)) continue
421
+ if (t) printed.add(t.head.item.id)
422
+ h.out(`${t ? threadDetail(t, h.dir, false, h.say) : detail(x, h.dir, false, false, h.say)}\n`)
423
+ }
424
+ const watchers = liveWatchers(h.dir)
425
+ const untaken = all.items.flatMap((x) => {
426
+ const why = untakenOf(x, followed.has(x.item.id), watchers, new Date(), h.say.spec.dispatcher)
427
+ return why === null ? [] : [untakenLine(x, why, null)]
428
+ })
429
+ if (untaken.length > 0) h.out(`not taken:\n${untaken.join('\n')}\n`)
430
+ const approvals = this.all ? [] : news.filter(plain)
431
+ if (approvals.length > 0) h.out(`review: ${approvals.length} plain approval(s), nothing to do: sk review seen ${approvals.map((x) => x.item.id).join(' ')}`)
432
+ if (shown.length > 0) h.out('mark each handled: sk review seen <id>; close a raised one: sk review fixed <id> --note "<what was done>"')
433
+ return all.broken.length > 0 ? 1 : 0
434
+ })
435
+ }
436
+ }
437
+
438
+ export class ReviewSeenCommand extends Command {
439
+ static override paths = [['review', 'seen']]
440
+ static override usage = Command.Usage({ category: 'Review', description: 'mark his answers and replies on these items read, once you acted on them', details: DETAILS })
441
+ ids = Option.Rest({ required: 1 })
442
+
443
+ async execute(): Promise<number> {
444
+ return run(this.context, async (h) => {
445
+ const { markSeen } = await import('../review/store.ts')
446
+ let bad = 0
447
+ for (const ref of this.ids) {
448
+ const id = await idOf(h.dir, ref).catch(() => ref)
449
+ if (markSeen(h.dir, id)) h.out(`${ref}: seen`)
450
+ else {
451
+ h.err(`FAIL: review: ${ref}: no such item, or nothing of his to read on it`)
452
+ bad++
453
+ }
454
+ }
455
+ return bad > 0 ? 1 : 0
456
+ })
457
+ }
458
+ }
459
+
460
+ export class ReviewReplyCommand extends Command {
461
+ static override paths = [['review', 'reply']]
462
+ static override usage = Command.Usage({ category: 'Review', description: "your message on a card's thread: what you changed, the answer to his question", details: DETAILS })
463
+ id = Option.String()
464
+ text = Option.String()
465
+ image = Option.String('--image', { description: 'an image beside the words' })
466
+
467
+ async execute(): Promise<number> {
468
+ return run(this.context, async (h) => {
469
+ const { addMessage, agentName } = await import('../review/store.ts')
470
+ const id = await idOf(h.dir, this.id)
471
+ const m = addMessage(h.dir, id, agentName(), this.text, this.image)
472
+ h.out(`${id}: ${m.id}`)
473
+ return 0
474
+ })
475
+ }
476
+ }
477
+
478
+ export class ReviewWithdrawCommand extends Command {
479
+ static override paths = [['review', 'withdraw']]
480
+ static override usage = Command.Usage({ category: 'Review', description: 'take an unanswered card back, with the reason', details: DETAILS })
481
+ ids = Option.Rest({ required: 1 })
482
+ why = Option.String('--why', { required: true, description: 'why it goes' })
483
+
484
+ async execute(): Promise<number> {
485
+ return run(this.context, async (h) => {
486
+ const { withdraw } = await import('../review/store.ts')
487
+ for (const id of this.ids) if (!withdraw(h.dir, id, this.why)) throw new Error(`${id}: no such card, or he answered it already`)
488
+ h.out(`review: ${this.ids.length} withdrawn`)
489
+ return 0
490
+ })
491
+ }
492
+ }
493
+
494
+ export class ReviewReopenCommand extends Command {
495
+ static override paths = [['review', 'reopen']]
496
+ static override usage = Command.Usage({ category: 'Review', description: 'an answered card waits for his answer again; the old verdict stays in the thread', details: DETAILS })
497
+ ids = Option.Rest({ required: 1 })
498
+
499
+ async execute(): Promise<number> {
500
+ return run(this.context, async (h) => {
501
+ const { reopen } = await import('../review/store.ts')
502
+ for (const id of this.ids) if (!reopen(h.dir, id)) throw new Error(`${id}: no such card, or no answer to reopen`)
503
+ h.out(`review: ${this.ids.length} reopened`)
504
+ return 0
505
+ })
506
+ }
507
+ }
508
+
509
+ type ActOp = 'fixed' | 'wontfix' | 'claim' | 'release' | 'blocked' | 'done' | 'dispatch' | 'ask' | 'drop' | 'reassign'
510
+
511
+ /** one state change: by a worker on its claim, or by the dispatcher on what it routes; the item, then the new state */
512
+ function actCommand(op: ActOp, description: string, o: { note?: 'required' | 'optional'; text?: string; topic?: 'required' | 'optional'; named: boolean; examples: [string, string][] }) {
513
+ return class extends Command {
514
+ static override paths = [['review', op]]
515
+ static override usage = Command.Usage({ category: 'Review', description, details: `${DETAILS}\n\nTakes an item id or a player report's ref (live#3). ${o.named ? 'Who acts: --as <name>, else SK_AGENT.' : 'Who acts: --as, else SK_AGENT, else agent.'}`, examples: o.examples })
516
+ id = Option.String()
517
+ words = o.text !== undefined || o.topic !== undefined ? Option.String({ required: false, name: o.text ?? 'topic' }) : undefined
518
+ note = o.note === 'required' ? Option.String('--note', { required: true, description: 'what was done, why not, or what is needed' }) : Option.String('--note', { description: 'what was done, or the reason' })
519
+ commit = Option.String('--commit', { description: 'the commit that holds the fix' })
520
+ as = Option.String('--as', { description: 'who acts (else SK_AGENT)' })
521
+
522
+ async execute(): Promise<number> {
523
+ return run(this.context, async (h) => {
524
+ const { act, readItem } = await import('../review/store.ts')
525
+ const { stateOf } = await import('../review/model.ts')
526
+ const { detail } = await import('../review/format.ts')
527
+ if (this.words !== undefined && o.text === undefined && o.topic === undefined) throw new Error(`unexpected ${this.words}`)
528
+ if (o.text && !this.words?.trim()) throw new Error(`${op} <id> "<${o.text}>"`)
529
+ if (o.topic === 'required' && !this.words) throw new Error(`${op} <id> <topic> (sk review topics)`)
530
+ const id = await idOf(h.dir, this.id)
531
+ const who = actor(this.as, o.named, op)
532
+ act(h.dir, id, op, who, {
533
+ ...(this.note ? { note: this.note } : {}),
534
+ ...(op === 'ask' && this.words ? { text: this.words } : {}),
535
+ ...(op === 'done' && this.words ? { note: this.words } : {}),
536
+ ...(o.topic && this.words ? { topic: this.words } : {}),
537
+ ...(this.commit ? { commit: this.commit } : {}),
538
+ })
539
+ const x = readItem(h.dir, id)
540
+ const st = x ? stateOf(x, false) : null
541
+ h.out(`${id} ${op}: ${st ? `${st.state}${st.why ? ` (${st.why})` : ''}${st.topic ? ` ${st.topic}` : ''}${st.worker ? ` ${st.worker}` : ''}` : 'gone'}`)
542
+ if (op === 'claim' && x) h.out(detail(x, h.dir, false, false, h.say))
543
+ return 0
544
+ })
545
+ }
546
+ }
547
+ }
548
+
549
+ export const ReviewFixedCommand = actCommand('fixed', 'close an item he raised as done: the note says what was done (and --commit where)', { note: 'optional', named: false, examples: [['done, with its commit', 'sk review fixed <id> --note "the lamp lit" --commit abc1234']] })
550
+ export const ReviewWontfixCommand = actCommand('wontfix', 'close an item he raised as not done, with the reason', { note: 'required', named: false, examples: [['not done', 'sk review wontfix <id> --note "the room is gone"']] })
551
+ export const ReviewClaimCommand = actCommand('claim', 'take an item to work on it: the hub shows the card in work by your lane', { named: true, examples: [['take it', 'sk review claim <id> --as L12']] })
552
+ export const ReviewReleaseCommand = actCommand('release', 'give back an item you claimed', { note: 'optional', named: true, examples: [['give it back', 'sk review release <id> --as L12']] })
553
+ export const ReviewBlockedCommand = actCommand('blocked', 'an item you claimed goes back to the owner with what is needed', { note: 'required', named: true, examples: [['his decision is needed', 'sk review blocked <id> --note "which of the two doors?" --as L12']] })
554
+ export const ReviewDoneCommand = actCommand('done', 'an « ok with a fix » card made with no new card: closed, the words say what changed', { text: 'what changed', named: false, examples: [['the fix made', 'sk review done <id> "the sign moved one tile up"']] })
555
+ export const ReviewDispatchCommand = actCommand('dispatch', 'the dispatcher sends a raised item to a topic', { note: 'optional', topic: 'required', named: true, examples: [['to a topic', 'sk review dispatch <id> lieux --as orchestrator']] })
556
+ export const ReviewAskCommand = actCommand('ask', 'the dispatcher asks the owner a question on a raised item; his reply answers it', { text: 'question', named: true, examples: [['a question', 'sk review ask <id> "which tower?" --as orchestrator']] })
557
+ export const ReviewDropCommand = actCommand('drop', 'the dispatcher closes a raised item with the reason (a duplicate, already fixed)', { note: 'required', named: true, examples: [['a duplicate', 'sk review drop <id> --note "same as <other id>" --as orchestrator']] })
558
+ export const ReviewReassignCommand = actCommand('reassign', 'the dispatcher takes a claim back, or sends the item to another topic', { note: 'optional', topic: 'optional', named: true, examples: [['a stale claim', 'sk review reassign <id> --as orchestrator']] })
559
+
560
+ export class ReviewDispatcherCommand extends Command {
561
+ static override paths = [['review', 'dispatcher']]
562
+ static override usage = Command.Usage({
563
+ category: 'Review',
564
+ description: 'take the dispatcher role: one session at a time routes what is raised',
565
+ details: `${DETAILS}\n\nRefused while another holder acted in the last 30 minutes, unless --take. Only in a repo whose review unit declares a dispatcher.`,
566
+ examples: [['take the role', 'sk review dispatcher --as orchestrator']],
567
+ })
568
+ as = Option.String('--as', { description: 'who takes it (else SK_AGENT)' })
569
+ take = Option.Boolean('--take', false, { description: 'take it from an active holder' })
570
+
571
+ async execute(): Promise<number> {
572
+ return run(this.context, async (h) => {
573
+ if (!h.say.spec.dispatcher) throw new Error('this repo routes nothing: its review unit declares no dispatcher (dispatcher: true)')
574
+ const { takeRole } = await import('../review/role.ts')
575
+ const name = actor(this.as, true, 'dispatcher')
576
+ const r = takeRole(h.dir, name, this.take)
577
+ if (!r.ok) throw new Error(r.why)
578
+ h.out(`${name} holds the dispatcher role${r.was && r.was.name !== name ? ` (taken from ${r.was.name})` : ''}`)
579
+ return 0
580
+ })
581
+ }
582
+ }
583
+
584
+ export class ReviewNextCommand extends Command {
585
+ static override paths = [['review', 'next']]
586
+ static override usage = Command.Usage({
587
+ category: 'Review',
588
+ description: 'claim the oldest open item of a topic, or raised to a lane, and show it with the pages to read first',
589
+ details: DETAILS,
590
+ examples: [['the next of a topic', 'sk review next lieux --as L12'], ['the next raised to a lane', 'sk review next --lane interaction --as interaction']],
591
+ })
592
+ topic = Option.String({ required: false })
593
+ lane = Option.String('--lane', { description: 'the lane he or a rule raised it to' })
594
+ as = Option.String('--as', { description: 'who claims (else SK_AGENT)' })
595
+
596
+ async execute(): Promise<number> {
597
+ return run(this.context, async (h) => {
598
+ const { claimNext } = await import('../review/store.ts')
599
+ const { stateOf } = await import('../review/model.ts')
600
+ const { detail } = await import('../review/format.ts')
601
+ const lane = this.lane?.trim()
602
+ if (!lane && !this.topic) throw new Error(`next <topic> | next --lane <lane>${Object.keys(h.say.spec.topics).length ? ` (topics: ${Object.keys(h.say.spec.topics).join(', ')})` : ''}`)
603
+ if (this.topic && Object.keys(h.say.spec.topics).length > 0 && !h.say.spec.topics[this.topic]) throw new Error(`no topic « ${this.topic} »: ${Object.keys(h.say.spec.topics).join(', ')}`)
604
+ await pull(h)
605
+ const got = claimNext(h.dir, lane ? { lane } : { topic: this.topic ?? '' }, actor(this.as, true, 'next'))
606
+ if (!got) {
607
+ h.out(`nothing open on ${lane ?? this.topic}`)
608
+ return 0
609
+ }
610
+ const topic = stateOf(got, false).topic
611
+ const read = topic ? h.say.spec.topics[topic]?.read : undefined
612
+ h.out(`${detail(got, h.dir, false, false, h.say)}${read?.length ? `\nread first: ${read.join(', ')}` : ''}`)
613
+ return 0
614
+ })
615
+ }
616
+ }
617
+
618
+ export class ReviewTopicsCommand extends Command {
619
+ static override paths = [['review', 'topics']]
620
+ static override usage = Command.Usage({ category: 'Review', description: 'the topics a raised item is routed to, and the pages a worker reads first', details: DETAILS })
621
+
622
+ async execute(): Promise<number> {
623
+ return run(this.context, (h) => {
624
+ const t = Object.entries(h.say.spec.topics)
625
+ if (t.length === 0) h.out('no topic: the repo\'s review unit declares none (topics: {...})')
626
+ for (const [id, v] of t) h.out(`${id.padEnd(11)} ${v.label}${v.covers ? `: ${v.covers}` : ''}${v.read?.length ? `\n${' '.repeat(12)}read: ${v.read.join(', ')}` : ''}`)
627
+ return 0
628
+ })
629
+ }
630
+ }
631
+
632
+ export class ReviewBoardCommand extends Command {
633
+ static override paths = [['review', 'board']]
634
+ static override usage = Command.Usage({
635
+ category: 'Review',
636
+ description: 'every open raised item by state, topic and worker, stale claims marked; who is the dispatcher',
637
+ details: DETAILS,
638
+ examples: [['what is open', 'sk review board'], ['one topic, closed ones too', 'sk review board --topic lieux --all']],
639
+ })
640
+ topic = Option.String('--topic', { description: 'one topic' })
641
+ all = Option.Boolean('--all', false, { description: 'the closed ones too' })
642
+
643
+ async execute(): Promise<number> {
644
+ return run(this.context, async (h) => {
645
+ const { readAll } = await import('../review/store.ts')
646
+ const { roleOf } = await import('../review/role.ts')
647
+ const { isMarkItem, isStale, stateOf } = await import('../review/model.ts')
648
+ const { followedIds } = await import('../review/threads.ts')
649
+ if (this.topic && Object.keys(h.say.spec.topics).length > 0 && !h.say.spec.topics[this.topic]) throw new Error(`--topic: one of ${Object.keys(h.say.spec.topics).join(', ')}`)
650
+ await pull(h)
651
+ const all = readAll(h.dir)
652
+ for (const b of all.broken) h.err(`review: FAIL ${b.id}: ${b.error}`)
653
+ if (h.say.spec.dispatcher) {
654
+ const role = roleOf(h.dir)
655
+ h.out(role ? `dispatcher: ${role.name} (since ${role.since.slice(0, 16)})` : 'dispatcher: nobody (sk review dispatcher --as <name>)')
656
+ }
657
+ const now = new Date()
658
+ const followed = followedIds(all.items)
659
+ const rows = all.items
660
+ .filter((x) => x.item.origin.by !== 'agent' && !isMarkItem(x))
661
+ .map((x) => ({ x, st: stateOf(x, followed.has(x.item.id)) }))
662
+ .filter((r) => (this.all || r.st.state !== 'closed') && (this.topic === undefined || r.st.topic === this.topic))
663
+ for (const state of ['new', 'question', 'blocked', 'open', 'claimed', 'closed'] as const) {
664
+ const of = rows.filter((r) => r.st.state === state)
665
+ if (of.length === 0) continue
666
+ h.out(`${state} (${of.length})`)
667
+ for (const { x, st } of of) h.out(` ${x.item.id} ${st.topic ?? ''}${st.worker ? ` ${st.worker}` : ''}${st.why ? ` (${st.why})` : ''}${isStale(x, now) ? ' (stale claim)' : ''} ${x.item.title}`)
668
+ }
669
+ if (rows.length === 0) h.out('nothing open')
670
+ return all.broken.length > 0 ? 1 : 0
671
+ })
672
+ }
673
+ }
674
+
675
+ export class ReviewReadyCommand extends Command {
676
+ static override paths = [['review', 'ready']]
677
+ static override usage = Command.Usage({ category: 'Review', description: 'a draft card becomes a card he answers: its last frame, or the image given, stays', details: DETAILS })
678
+ id = Option.String()
679
+ image = Option.String('--image', { description: 'the image that stays' })
680
+
681
+ async execute(): Promise<number> {
682
+ return run(this.context, async (h) => {
683
+ const { readItem, readyItem } = await import('../review/store.ts')
684
+ const live = readItem(h.dir, this.id)?.item.wip?.live
685
+ const r = await readyItem(h.dir, this.id, this.image ? { image: this.image } : {})
686
+ if (live !== undefined) h.unit?.live?.stop?.(live, 'ready')
687
+ h.out(`${r.id} ready: he can answer it`)
688
+ return 0
689
+ })
690
+ }
691
+ }
692
+
693
+ export class ReviewDeleteCommand extends Command {
694
+ static override paths = [['review', 'delete']]
695
+ static override usage = Command.Usage({
696
+ category: 'Review',
697
+ description: "a player's test report removed at its source and here, or an item the owner raised removed here",
698
+ details: DETAILS,
699
+ examples: [['a test report', 'sk review delete live#3']],
700
+ })
701
+ refs = Option.Rest({ required: 1 })
702
+
703
+ async execute(): Promise<number> {
704
+ return run(this.context, async (h) => {
705
+ const { deleteRaised, deleteReport, itemOfRef } = await import('../review/store.ts')
706
+ for (const ref of this.refs) {
707
+ const x = itemOfRef(h.dir, ref)
708
+ if (!x) throw new Error(`no item ${ref}`)
709
+ if (x.item.origin.by === 'player') {
710
+ const removers = (h.unit?.sources ?? []).filter((s) => s.remove)
711
+ if (removers.length === 0) throw new Error(`${ref}: no source of the repo's review unit removes a report`)
712
+ for (const s of removers) await s.remove?.(x)
713
+ deleteReport(h.dir, x.item.id)
714
+ h.out(`review: ${ref} deleted at its source and here`)
715
+ } else {
716
+ deleteRaised(h.dir, x.item.id)
717
+ h.out(`review: ${ref} deleted`)
718
+ }
719
+ }
720
+ return 0
721
+ })
722
+ }
723
+ }
724
+
725
+ export class ReviewMarksCommand extends Command {
726
+ static override paths = [['review', 'marks']]
727
+ static override usage = Command.Usage({
728
+ category: 'Review',
729
+ description: "the owner's marks kept in the store: per subject its latest stars, mark and note",
730
+ details: DETAILS,
731
+ examples: [['every mark', 'sk review marks'], ['one kind, for a script', 'sk review marks --subject song --json']],
732
+ })
733
+ subject = Option.String('--subject', { description: 'one subject kind' })
734
+ json = Option.Boolean('--json', false, { description: 'every subject key, its latest mark and its history count' })
735
+
736
+ async execute(): Promise<number> {
737
+ return run(this.context, async (h) => {
738
+ const { readAll } = await import('../review/store.ts')
739
+ const { marksOf } = await import('../review/marks.ts')
740
+ const all = readAll(h.dir)
741
+ for (const b of all.broken) h.err(`review: FAIL ${b.id}: ${b.error}`)
742
+ const marks = marksOf(all.items, h.say.spec).filter((m) => this.subject === undefined || m.kind === this.subject)
743
+ if (this.json) {
744
+ h.out(JSON.stringify(marks, null, 4))
745
+ return 0
746
+ }
747
+ for (const m of marks) {
748
+ const r = m.rate
749
+ const parts = Object.entries(m.parts).map(([p, s]) => `${p} ${s}★`)
750
+ h.out(`${(r.stars ? `${'★'.repeat(r.stars)}${'☆'.repeat(5 - r.stars)}` : (r.mark ?? '')).padEnd(8)} ${m.key}${parts.length ? ` (${parts.join(', ')})` : ''}${m.text ? ` ${m.text}` : ''}`)
751
+ }
752
+ h.out(`${marks.length} subjects marked`)
753
+ return 0
754
+ })
755
+ }
756
+ }
757
+
758
+ export class ReviewAnimCommand extends Command {
759
+ static override paths = [['review', 'anim']]
760
+ static override usage = Command.Usage({
761
+ category: 'Review',
762
+ description: "a card's before and after as a looping animation to send: a cut, a fade or a wipe, mp4 or gif",
763
+ details: `${DETAILS}\n\nsk review anim <id> [--view n] | --before a.png --after b.png. Made once and kept under .shipkit/review/.anims/; --out <file | dir/> copies it out (default .shipkit/tmp/).`,
764
+ examples: [
765
+ ['a fade, mp4', 'sk review anim <id>'],
766
+ ['a wipe of the second view, gif', 'sk review anim <id> --view 1 --mode wipe --format gif'],
767
+ ],
768
+ })
769
+ id = Option.String({ required: false })
770
+ view = Option.String('--view', '0', { description: 'the view, 0 the first' })
771
+ mode = Option.String('--mode', 'fade', { description: 'cut, fade or wipe' })
772
+ format = Option.String('--format', 'mp4', { description: 'mp4 or gif' })
773
+ labels = Option.String('--labels', 'en', { description: 'the corner labels: en, fr or none' })
774
+ before = Option.String('--before', { description: 'an image, with --after: no card' })
775
+ after = Option.String('--after', { description: 'an image, with --before' })
776
+ out = Option.String('--out', { description: 'where it goes' })
777
+
778
+ async execute(): Promise<number> {
779
+ return run(this.context, async (h) => {
780
+ const { copyFileSync, mkdirSync, statSync } = await import('node:fs')
781
+ const { dirname, join, resolve } = await import('node:path')
782
+ const { ANIM_FORMATS, ANIM_LABELS, ANIM_MODES, fileAnimation, isAnimFormat, isAnimLabels, isAnimMode, pairAnimation } = await import('../review/anim.ts')
783
+ const pair = this.before && this.after && !this.id ? { before: this.before, after: this.after } : undefined
784
+ if (!pair && (this.before || this.after || !this.id)) throw new Error('anim (<id> [--view n] | --before a.png --after b.png) [--mode cut|fade|wipe] [--format mp4|gif] [--labels en|fr|none] [--out f]')
785
+ const { mode, format, labels } = this
786
+ if (!isAnimMode(mode)) throw new Error(`--mode ${mode}: one of ${ANIM_MODES.join(', ')}`)
787
+ if (!isAnimFormat(format)) throw new Error(`--format ${format}: one of ${ANIM_FORMATS.join(', ')}`)
788
+ if (!isAnimLabels(labels)) throw new Error(`--labels ${labels}: one of ${ANIM_LABELS.join(', ')}`)
789
+ const root = dirname(dirname(h.dir))
790
+ const font = h.unit?.anim?.font ? join(root, h.unit.anim.font) : undefined
791
+ const ask = { view: Number(this.view) || 0, mode, format, labels, ...(font ? { font } : {}) }
792
+ const a = pair ? await fileAnimation(pair.before, pair.after, ask, join(root, '.shipkit/tmp/anims')) : await pairAnimation(h.dir, await idOf(h.dir, this.id ?? ''), ask)
793
+ const o = this.out ?? join(root, '.shipkit/tmp/')
794
+ const to = o.endsWith('/') ? join(o, a.name) : o
795
+ mkdirSync(dirname(resolve(to)), { recursive: true })
796
+ copyFileSync(a.file, to)
797
+ h.out(`${to} ${(statSync(to).size / 1e6).toFixed(2)} MB, ${a.ms ? `${a.ms} ms` : 'cached'}`)
798
+ return 0
799
+ })
800
+ }
801
+ }
802
+
803
+ export class ReviewAutoCommand extends Command {
804
+ static override paths = [['review', 'auto']]
805
+ static override usage = Command.Usage({
806
+ category: 'Review',
807
+ description: "the repo's auto rules: period, last run and answer, last item, next run; `auto run <id>` runs one",
808
+ details: `${DETAILS}\n\nsk review auto | sk review auto run <id> [--dry]. The hub's daemon runs them on their clocks.`,
809
+ examples: [['every rule', 'sk review auto'], ['what one would raise', 'sk review auto run veille --dry']],
810
+ })
811
+ verb = Option.String({ required: false })
812
+ rule = Option.String({ required: false })
813
+ dry = Option.Boolean('--dry', false, { description: 'print what it would raise or hold, write nothing' })
814
+
815
+ async execute(): Promise<number> {
816
+ return run(this.context, async (h) => {
817
+ const { autoLogDir, nextRun, readAutoLog, runRule } = await import('../review/auto.ts')
818
+ const { readAll } = await import('../review/store.ts')
819
+ const rules = h.unit?.auto ?? []
820
+ if (this.verb === undefined) {
821
+ if (rules.length === 0) h.out("no auto rule: the repo's review unit declares none (auto: [...])")
822
+ const now = Date.now()
823
+ const ago = (t: number): string => `${Math.round(Math.abs(now - t) / 60_000)} min`
824
+ for (const r of rules) {
825
+ const log = readAutoLog(h.dir, r.id)
826
+ const next = nextRun(r, log, now)
827
+ h.out(
828
+ `${r.id} every ${r.every} ${log ? `ran ${ago(Date.parse(log.at))} ago (${log.ms.toFixed(1)} ms): ${log.answer}${log.error ? `: ${log.error}` : ''}` : 'never ran'}` +
829
+ `${log?.lastRaised ? ` last item ${log.lastRaised.id}` : ''} next ${next <= now ? 'due now' : `in ${ago(next)}`}`,
830
+ )
831
+ }
832
+ if (rules.length > 0) h.out(`logs: ${autoLogDir(h.dir)}; the hub's daemon runs them`)
833
+ return 0
834
+ }
835
+ if (this.verb !== 'run' || !this.rule) throw new Error('auto | auto run <id> [--dry]')
836
+ const rule = rules.find((r) => r.id === this.rule)
837
+ if (!rule) throw new Error(`no auto rule « ${this.rule} »: ${rules.map((r) => r.id).join(', ') || 'none declared'}`)
838
+ const outcomes = await runRule(h.dir, rule, { items: readAll(h.dir).items, dry: this.dry })
839
+ if (outcomes.length === 0) h.out(`${rule.id}: nothing to raise`)
840
+ for (const o of outcomes)
841
+ h.out(
842
+ 'raised' in o
843
+ ? `${rule.id}: raised ${o.raised} to ${o.raise.lane}: ${o.raise.title}`
844
+ : 'held' in o
845
+ ? `${rule.id}: held by ${o.held} (key ${o.key}, still open): ${o.raise.title}`
846
+ : `${rule.id}: would raise to ${o.raise.lane} (key ${o.key}): ${o.raise.title}\n${o.raise.what}`,
847
+ )
848
+ return 0
849
+ })
850
+ }
851
+ }
852
+
853
+ export class ReviewWatchCommand extends Command {
854
+ static override paths = [['review', 'watch']]
855
+ static override usage = Command.Usage({
856
+ category: 'Review',
857
+ description: 'one line per verdict, reply or raised note as it lands; every unseen one at start; never exits',
858
+ details: `${DETAILS}\n\nRun it under a monitor, unfiltered (a grep holds a burst's last line). It registers itself, so the hub shows who listens. An item nobody takes prints 2 minutes after its last message, then every 2 hours.`,
859
+ examples: [['the cards this session sent', 'sk review watch --mine'], ["one lane's items", 'sk review watch --lane interaction']],
860
+ })
861
+ mine = Option.Boolean('--mine', false, { description: "only this Claude Code session's cards" })
862
+ session = Option.String('--session', { description: "only that session's cards" })
863
+ lane = Option.String('--lane', { description: "only that lane's items" })
864
+
865
+ async execute(): Promise<number> {
866
+ return run(this.context, async (h) => {
867
+ const { existsSync, mkdirSync, watch } = await import('node:fs')
868
+ const { readAll, scanStamp } = await import('../review/store.ts')
869
+ const { AGENT_RE, coversCard, laneOf, UNTAKEN_AGAIN_MS, untakenOf } = await import('../review/model.ts')
870
+ const { followedIds, unseen } = await import('../review/threads.ts')
871
+ const { raisedLine, untakenLine, watchLines } = await import('../review/format.ts')
872
+ const { liveWatchers, registerWatch } = await import('../review/watchers.ts')
873
+ const session = await sessionOf(this.mine, this.session)
874
+ const lane = this.lane?.trim() || null
875
+ if (lane !== null && !AGENT_RE.test(lane)) throw new Error(`--lane: « ${lane} » is no lane id (${AGENT_RE.source})`)
876
+ const mine = (x: Parameters<typeof coversCard>[1]): boolean => coversCard({ session, lane }, x)
877
+ registerWatch(h.dir, { session, lane })
878
+ const parent = process.ppid
879
+ const shown = new Set<string>()
880
+ const tick = (): void => {
881
+ const all = readAll(h.dir)
882
+ for (const b of all.broken) {
883
+ const key = `broken ${b.id} ${b.error}`
884
+ if (!shown.has(key)) h.err(`review: FAIL ${b.id}: ${b.error}`)
885
+ shown.add(key)
886
+ }
887
+ for (const x of unseen(all.items).filter(mine))
888
+ for (const l of watchLines(x, all.items)) {
889
+ if (shown.has(l.key)) continue
890
+ shown.add(l.key)
891
+ h.out(l.text)
892
+ }
893
+ for (const x of all.items.filter(mine)) {
894
+ const l = raisedLine(x)
895
+ if (l === null || shown.has(l.key)) continue
896
+ shown.add(l.key)
897
+ h.out(l.text)
898
+ }
899
+ // an item nobody takes: a --lane watch hears its own lane's, an unfiltered or --mine watch every one
900
+ const now = new Date()
901
+ const followed = followedIds(all.items)
902
+ const watchers = liveWatchers(h.dir)
903
+ for (const x of all.items) {
904
+ if (lane !== null && laneOf(x) !== lane) continue
905
+ const why = untakenOf(x, followed.has(x.item.id), watchers, now, h.say.spec.dispatcher)
906
+ if (why === null) continue
907
+ const round = Math.floor((now.getTime() - Date.parse(x.thread.at(-1)?.at ?? x.item.created)) / UNTAKEN_AGAIN_MS)
908
+ const key = `untaken ${x.item.id} ${why} ${round}`
909
+ if (shown.has(key)) continue
910
+ shown.add(key)
911
+ h.out(untakenLine(x, why, lane))
912
+ }
913
+ }
914
+ tick()
915
+ // the grace and the reminder pass with time alone: one read a minute
916
+ setInterval(tick, 60_000)
917
+ // a change under the folder asks again (debounced); a cheap stat stamp every 5 s catches a missed event
918
+ let soon: ReturnType<typeof setTimeout> | null = null
919
+ const again = (): void => {
920
+ if (soon) clearTimeout(soon)
921
+ soon = setTimeout(() => {
922
+ soon = null
923
+ tick()
924
+ }, 150)
925
+ }
926
+ if (!existsSync(h.dir)) mkdirSync(h.dir, { recursive: true })
927
+ watch(h.dir, { recursive: true }, again)
928
+ // the sources: pulled now and every 30 s, a new item lands and the tick prints it
929
+ await pull(h)
930
+ if (h.unit?.sources?.length) setInterval(() => void pull(h), 30_000)
931
+ let stamp = scanStamp(h.dir)
932
+ setInterval(() => {
933
+ // a watch whose parent died (a monitor's timeout) exits
934
+ if (process.ppid !== parent) process.exit(0)
935
+ const now = scanStamp(h.dir)
936
+ if (now !== stamp) again()
937
+ stamp = now
938
+ }, 5000)
939
+ return new Promise<number>(() => {})
940
+ })
941
+ }
942
+ }
943
+
944
+ export const REVIEW_COMMANDS = [
945
+ ReviewLsCommand,
946
+ ReviewAddCommand,
947
+ ReviewAddViewCommand,
948
+ ReviewShowCommand,
949
+ ReviewInboxCommand,
950
+ ReviewSeenCommand,
951
+ ReviewReplyCommand,
952
+ ReviewWithdrawCommand,
953
+ ReviewReopenCommand,
954
+ ReviewFixedCommand,
955
+ ReviewWontfixCommand,
956
+ ReviewClaimCommand,
957
+ ReviewReleaseCommand,
958
+ ReviewBlockedCommand,
959
+ ReviewDoneCommand,
960
+ ReviewDispatcherCommand,
961
+ ReviewDispatchCommand,
962
+ ReviewAskCommand,
963
+ ReviewDropCommand,
964
+ ReviewReassignCommand,
965
+ ReviewNextCommand,
966
+ ReviewTopicsCommand,
967
+ ReviewBoardCommand,
968
+ ReviewReadyCommand,
969
+ ReviewDeleteCommand,
970
+ ReviewMarksCommand,
971
+ ReviewAnimCommand,
972
+ ReviewAutoCommand,
973
+ ReviewWatchCommand,
974
+ ]