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,1011 @@
1
+ // the one feedback store on disk (wiki build/shipkit/review.md): `.shipkit/review/<id>/`, gitignored: `item.json`, the
2
+ // media, and `thread/`, one file per message, never rewritten. An item is built in a dot folder and renamed, every file
3
+ // written beside its place and renamed, so no reader sees half of one. The format is pkm-multi's verbatim. A folder of
4
+ // the old shape (request.json, feedback.json, a message with `seen`) fails loud
5
+ import * as fs from 'node:fs'
6
+ import { existsSync, readdirSync, readFileSync, statSync, watch } from 'node:fs'
7
+ import { basename, dirname, extname, join, resolve } from 'node:path'
8
+ import { LAYOUT } from '../core/layout.ts'
9
+ import { ffmpeg } from '../trailer/ffmpeg.ts'
10
+ import { probe, sizeOf, stillAt } from './media.ts'
11
+ import {
12
+ AGENT_RE,
13
+ allowed,
14
+ FILE_RE,
15
+ ID_RE,
16
+ idOf,
17
+ isApproved,
18
+ isAudioFile,
19
+ isClipFile,
20
+ isMarkItem,
21
+ itemProblems,
22
+ MESSAGE_FILE_RE,
23
+ MESSAGE_RE,
24
+ messageIdOf,
25
+ messageProblems,
26
+ NO_SPEC,
27
+ specProblems,
28
+ stateOf,
29
+ subjectKey,
30
+ laneOf,
31
+ textOf,
32
+ toAcknowledge,
33
+ verdictMessageOf,
34
+ verdictOf,
35
+ withdrawnOf,
36
+ type GalleryTile,
37
+ type Item,
38
+ type Message,
39
+ type Op,
40
+ type Origin,
41
+ type Party,
42
+ type Rect,
43
+ type ReviewClip,
44
+ type ReviewFiles,
45
+ type ReviewItem,
46
+ type ReviewMark,
47
+ type ReviewView,
48
+ type Spec,
49
+ type Subject,
50
+ } from './model.ts'
51
+ import { beatRole, roleOf } from './role.ts'
52
+ import { chainOf, coveringHead, followedIds, isFollowed } from './threads.ts'
53
+ import { liveWatchers } from './watchers.ts'
54
+
55
+ /** the store of a repo */
56
+ export const storeDir = (root: string): string => join(root, LAYOUT.review)
57
+
58
+ /** each store's spec, the model's part of its repo's review unit (unit.ts), bound by whoever loaded the unit: the cli,
59
+ * the hub. A store no one bound reads with NO_SPEC */
60
+ const specs = new Map<string, Spec>()
61
+
62
+ export const bindSpec = (dir: string, spec: Spec): void => void specs.set(resolve(dir), spec)
63
+ export const specOf = (dir: string): Spec => specs.get(resolve(dir)) ?? NO_SPEC
64
+
65
+ const IMAGE_EXT = /^\.(png|jpe?g|webp)$/
66
+ const CLIP_EXT = /^\.(webm|mp4)$/
67
+
68
+ /** this process's writes to a store, counted at once: the listing tag moves before the watcher reports them */
69
+ let wrote = 0
70
+ const renameSync = (a: string, b: string): void => (fs.renameSync(a, b), touched(a), touched(b))
71
+ const rmSync = (p: string, o?: fs.RmOptions): void => (fs.rmSync(p, o), touched(p))
72
+ const copyFileSync = (a: string, b: string): void => (fs.copyFileSync(a, b), touched(b))
73
+ const writeFileSync = (p: string, d: string): void => (fs.writeFileSync(p, d), touched(p))
74
+ const mkdirSync = (p: string, o?: fs.MakeDirectoryOptions): void => (fs.mkdirSync(p, o), touched(p))
75
+
76
+ function writeJson(file: string, v: unknown): void {
77
+ const tmp = `${file}.tmp`
78
+ writeFileSync(tmp, `${JSON.stringify(v, null, 4)}\n`)
79
+ renameSync(tmp, file)
80
+ }
81
+
82
+ /** one view as given: a label, and an image, a clip, a sound or a pair (a clip or a sound goes anywhere an image goes,
83
+ * by its extension), or a gallery of images */
84
+ export interface ViewInput {
85
+ label: string
86
+ image?: string
87
+ video?: string
88
+ audio?: string
89
+ before?: string
90
+ after?: string
91
+ /** a list of small images shown as a wrapping grid, each with its label; never the first view */
92
+ gallery?: { file: string; label: string }[]
93
+ /** a line on this view alone: an option's description on a choice card */
94
+ note?: string
95
+ }
96
+
97
+ export interface AddInput {
98
+ title: string
99
+ what: string
100
+ /** what is judged; a plain shot by default */
101
+ subject?: Subject
102
+ topic?: string
103
+ image?: string
104
+ video?: string
105
+ audio?: string
106
+ before?: string
107
+ after?: string
108
+ commit?: string
109
+ /** the lane that made it; else SK_AGENT */
110
+ lane?: string
111
+ supersedes?: string
112
+ /** the raised items (a report, his note) this card answers, by id */
113
+ answers?: string[]
114
+ /** what a new version of an approved look keeps of it: required then */
115
+ keeps?: string
116
+ /** another subject on purpose, next to an active card: needs `why` */
117
+ fresh?: boolean
118
+ why?: string
119
+ /** the first view's label, and the other views of the same subject */
120
+ label?: string
121
+ views?: ViewInput[]
122
+ /** the rings over the subject, per view, as the repo read them from its own shots */
123
+ marks?: ReviewMark[][]
124
+ /** the shots used the review light over a dark place */
125
+ light?: 'review'
126
+ /** a draft card on a live shot: its image a live frame, no verdict until it is ready */
127
+ wip?: { live: string }
128
+ /** every view is an option he picks one of */
129
+ choice?: boolean
130
+ now?: Date
131
+ }
132
+
133
+ /** a view whose files were checked: its sources by name, its one size, and on a clip its length, its rate and the file
134
+ * its poster comes from */
135
+ interface Loaded {
136
+ label: string
137
+ sources: [name: 'image' | 'video' | 'audio' | 'before' | 'after', path: string][]
138
+ size: { w: number; h: number }
139
+ clip?: { duration: number; fps: number; from: string }
140
+ /** a sound: its length, and the files its waveforms are drawn from (the after's or the one's, then the before's) */
141
+ audio?: { duration: number; from: string; before?: string }
142
+ tiles?: { path: string; label: string; size: { w: number; h: number } }[]
143
+ }
144
+
145
+ /** a view's files checked and sized, a pair of one size (and one length, two clips); throws with the reason, naming the view */
146
+ async function loadView(v: ViewInput, which: string): Promise<Loaded> {
147
+ if (v.gallery !== undefined) {
148
+ if (v.image !== undefined || v.video !== undefined || v.audio !== undefined || v.before !== undefined || v.after !== undefined)
149
+ throw new Error(`${which}: a gallery is a list of images, no --image, --video or pair beside it`)
150
+ if (v.gallery.length === 0) throw new Error(`${which}: a gallery needs at least one image`)
151
+ const tiles = await Promise.all(
152
+ v.gallery.map(async (t) => {
153
+ const path = resolve(t.file)
154
+ if (!existsSync(path)) throw new Error(`${path}: no such file`)
155
+ if (!IMAGE_EXT.test(extname(path).toLowerCase())) throw new Error(`${which}: ${path}: a gallery holds png, jpg or webp images, never a clip`)
156
+ return { path, label: t.label.trim(), size: await sizeOf(path) }
157
+ }),
158
+ )
159
+ return { label: v.label.trim(), sources: [], size: tiles[0]?.size ?? { w: 0, h: 0 }, tiles }
160
+ }
161
+ if ([v.image, v.video, v.audio].filter((x) => x !== undefined).length > 1) throw new Error(`${which}: give one of an image, a clip or a sound`)
162
+ const one = v.image ?? v.video ?? v.audio
163
+ const pair = v.before !== undefined || v.after !== undefined
164
+ if ((one !== undefined) === pair || (pair && (v.before === undefined || v.after === undefined))) throw new Error(`${which}: give one image, clip or sound, or a before and an after`)
165
+ const sources: Loaded['sources'] =
166
+ one !== undefined
167
+ ? [[isClipFile(one) ? 'video' : isAudioFile(one) ? 'audio' : 'image', resolve(one)]]
168
+ : [
169
+ ['before', resolve(v.before ?? '')],
170
+ ['after', resolve(v.after ?? '')],
171
+ ]
172
+ for (const [, p] of sources) {
173
+ if (!existsSync(p)) throw new Error(`${p}: no such file`)
174
+ const ext = extname(p).toLowerCase()
175
+ if (v.video !== undefined && !CLIP_EXT.test(ext)) throw new Error(`--video ${p}: a webm or mp4`)
176
+ if (v.audio !== undefined && !isAudioFile(p)) throw new Error(`--audio ${p}: a wav, mp3, flac, ogg, opus or m4a`)
177
+ if (!IMAGE_EXT.test(ext) && !CLIP_EXT.test(ext) && !isAudioFile(p)) throw new Error(`${p}: a png, jpg or webp, a webm or mp4 clip, or a wav, mp3, flac, ogg, opus or m4a sound`)
178
+ }
179
+ const clips = sources.filter(([, p]) => isClipFile(p)).length
180
+ const sounds = sources.filter(([, p]) => isAudioFile(p)).length
181
+ if ((clips > 0 && clips < sources.length) || (sounds > 0 && sounds < sources.length)) throw new Error(`${which}: a pair is two images, two clips or two sounds, never one of each`)
182
+ if (sounds > 0) {
183
+ const lengths = sources.map(([, p]) => soundLength(p))
184
+ const main = sources[sources.length - 1] as Loaded['sources'][number]
185
+ return {
186
+ label: v.label.trim(),
187
+ sources,
188
+ size: WAVE_SIZE,
189
+ audio: { duration: Math.max(...lengths), from: main[1], ...(sources.length > 1 ? { before: sources[0]?.[1] ?? '' } : {}) },
190
+ }
191
+ }
192
+ if (clips === 0) {
193
+ const sizes = await Promise.all(sources.map(([, p]) => sizeOf(p)))
194
+ const size = sizes[0] ?? { w: 0, h: 0 }
195
+ const odd = sizes.find((s) => s.w !== size.w || s.h !== size.h)
196
+ if (odd) throw new Error(`${which}: before is ${size.w}x${size.h}, after is ${odd.w}x${odd.h}: a pair shares one size (shoot both at the same size)`)
197
+ return { label: v.label.trim(), sources, size }
198
+ }
199
+ const infos = sources.map(([, p]) => clipInfo(p))
200
+ const a = infos[0] as (typeof infos)[number]
201
+ const b = infos[infos.length - 1] as (typeof infos)[number]
202
+ if (a.w !== b.w || a.h !== b.h || a.fps !== b.fps || Math.abs(a.duration - b.duration) > 1 / a.fps + 1e-3)
203
+ throw new Error(
204
+ `${which}: before is ${a.w}x${a.h}, ${a.duration} s at ${a.fps} fps, after is ${b.w}x${b.h}, ${b.duration} s at ${b.fps} fps: a pair of clips shares one size and one length`,
205
+ )
206
+ const main = sources[sources.length - 1] as Loaded['sources'][number]
207
+ return { label: v.label.trim(), sources, size: { w: b.w, h: b.h }, clip: { duration: b.duration, fps: b.fps, from: main[1] } }
208
+ }
209
+
210
+ /** what a card records of a clip: its size, its length and its rate, to the millisecond */
211
+ function clipInfo(file: string): { w: number; h: number; duration: number; fps: number } {
212
+ let p: ReturnType<typeof probe>
213
+ try {
214
+ p = probe(file)
215
+ } catch (e) {
216
+ throw new Error(`${file}: not a clip ffprobe reads (${(e as Error).message})`)
217
+ }
218
+ if (!p.video || !(p.video.fps > 0)) throw new Error(`${file}: no video stream with a size, a length and a frame rate`)
219
+ const ms = (n: number): number => Math.round(n * 1000) / 1000
220
+ return { w: p.video.width, h: p.video.height, duration: ms(p.duration), fps: ms(p.video.fps) }
221
+ }
222
+
223
+ /** a sound's length in seconds, to the millisecond */
224
+ function soundLength(file: string): number {
225
+ try {
226
+ return Math.round(probe(file).duration * 1000) / 1000
227
+ } catch (e) {
228
+ throw new Error(`${file}: not a sound ffprobe reads (${(e as Error).message})`)
229
+ }
230
+ }
231
+
232
+ /** the waveform a sound view shows, one size for every sound so a pair reads side by side */
233
+ const WAVE_SIZE = { w: 1600, h: 240 }
234
+
235
+ /** view `n`'s waveforms, drawn into `folder` beside their place and renamed: each column's peak on a square root scale,
236
+ * so a voice's quiet syllables show; drawn white on transparent, the page fills it with its own colour (a mask) */
237
+ async function writeWaves(v: Loaded, n: number, folder: string): Promise<void> {
238
+ if (!v.audio) return
239
+ const draw = async (from: string, name: string): Promise<void> => {
240
+ const to = join(folder, `${prefix(n)}${name}.png`)
241
+ await ffmpeg(['-i', from, '-filter_complex', `showwavespic=s=${WAVE_SIZE.w}x${WAVE_SIZE.h}:split_channels=0:scale=sqrt:filter=peak:colors=white`, '-frames:v', '1', '-f', 'image2', '-c:v', 'png', `${to}.tmp`])
242
+ renameSync(`${to}.tmp`, to)
243
+ }
244
+ await draw(v.audio.from, 'wave')
245
+ if (v.audio.before) await draw(v.audio.before, 'before-wave')
246
+ }
247
+
248
+ const prefix = (n: number): string => (n > 0 ? `v${n}-` : '')
249
+
250
+ /** the file name of tile `k` of gallery view `n` */
251
+ const tileName = (n: number, k: number, path: string): string => `v${n}-g${k}${extname(path).toLowerCase()}`
252
+
253
+ function viewOf(v: Loaded, n: number, marks: readonly ReviewMark[] = [], note?: string): ReviewView {
254
+ if (v.tiles) return { label: v.label, gallery: v.tiles.map((t, k): GalleryTile => ({ file: tileName(n, k, t.path), label: t.label, size: t.size })) }
255
+ const audio = v.audio ? { audio: { duration: v.audio.duration, wave: `${prefix(n)}wave.png`, ...(v.audio.before ? { waveBefore: `${prefix(n)}before-wave.png` } : {}) } } : {}
256
+ return { label: v.label, files: filesOf(v, n), size: v.size, ...clipOf(v, n), ...audio, ...(marks.length > 0 ? { marks: [...marks] } : {}), ...(note?.trim() ? { note: note.trim() } : {}) }
257
+ }
258
+
259
+ /** the file names of view `n` in the folder: `image.png` on the first, `v2-before.png` on a later one */
260
+ const filesOf = (v: Loaded, n: number): ReviewFiles => Object.fromEntries(v.sources.map(([name, p]) => [name, `${prefix(n)}${name}${extname(p).toLowerCase()}`])) as ReviewFiles
261
+
262
+ const clipOf = (v: Loaded, n: number): { clip: ReviewClip } | Record<string, never> =>
263
+ v.clip ? { clip: { poster: `${prefix(n)}poster.png`, duration: v.clip.duration, fps: v.clip.fps } } : {}
264
+
265
+ /** the first frame of view `n`'s clip, written into `folder` beside its place and renamed */
266
+ async function writePoster(v: Loaded, n: number, folder: string): Promise<void> {
267
+ if (!v.clip) return
268
+ const to = join(folder, `${prefix(n)}poster.png`)
269
+ await stillAt(v.clip.from, 0, `${to}.tmp`)
270
+ renameSync(`${to}.tmp`, to)
271
+ }
272
+
273
+ /** why an active card refuses a new one, and every way out */
274
+ function coveredText(c: { head: ReviewItem; by: 'subject' | 'near' | 'title' }): string {
275
+ const r = c.head.item
276
+ const by = c.by === 'subject' ? 'the same subject' : c.by === 'near' ? 'the same topic and a subject near it' : 'a title this close'
277
+ return [
278
+ `one subject is one card: the active card ${r.id} "${r.title}" (${r.topic ?? 'no topic'}) already covers it, ${by}.`,
279
+ ` a new version of it: --supersedes ${r.id}`,
280
+ ` another angle of it: sk review add-view ${r.id} "<label>" <image> [<after>]`,
281
+ ` truly another subject: --new --why "<reason>"`,
282
+ ].join('\n')
283
+ }
284
+
285
+ /** the Claude Code session that runs this process, null outside one */
286
+ export const sessionId = (): string | null => process.env.CLAUDE_CODE_SESSION_ID?.trim() || null
287
+
288
+ /** the agent that acts: its lane name (SK_AGENT), else `agent` */
289
+ export const agentName = (): string => process.env.SK_AGENT?.trim() || 'agent'
290
+
291
+ /** copies the media in and writes item.json; throws with the reason on a bad input */
292
+ export async function addItem(dir: string, input: AddInput): Promise<Item> {
293
+ const spec = specOf(dir)
294
+ if (!input.title.trim()) throw new Error('--title: required')
295
+ if (!input.what.trim()) throw new Error('--what: required, what changed and why')
296
+ const first = input.image !== undefined || input.video !== undefined || input.audio !== undefined || input.before !== undefined || input.after !== undefined
297
+ const given: ViewInput[] = [...(first ? [{ label: input.label ?? '', image: input.image, video: input.video, audio: input.audio, before: input.before, after: input.after }] : []), ...(input.views ?? [])].map(
298
+ (v) => Object.fromEntries(Object.entries(v).filter(([, x]) => x !== undefined)) as unknown as ViewInput,
299
+ )
300
+ if (given.length === 0) throw new Error('give --image, --video or --audio, or --before and --after, or --view "<label>" <image>')
301
+ for (const [i, v] of given.entries()) if (i > 0 && !v.label.trim()) throw new Error('--view: every view after the first needs its label')
302
+ if (given[0]?.gallery) throw new Error('a gallery is never the first view: give the card an --image (or a pair, or a clip) first')
303
+ if (input.choice && given.length < 2) throw new Error('--choice: a choice has two options or more, one --view each')
304
+ for (const v of given) if (v.note !== undefined && v.gallery) throw new Error(`--view "${v.label}": a note goes on an image, a clip or a sound, never a gallery`)
305
+ const loaded = await Promise.all(given.map((v, i) => loadView(v, i === 0 ? 'the first view' : `--view "${v.label}"`)))
306
+ if (input.supersedes !== undefined && !existsSync(join(dir, input.supersedes, 'item.json'))) throw new Error(`--supersedes ${input.supersedes}: no such card in ${dir}`)
307
+ if (input.supersedes !== undefined && readItem(dir, input.supersedes)?.item.origin.by !== 'agent')
308
+ throw new Error(`--supersedes ${input.supersedes}: a report or a note is answered, never superseded: --answers ${input.supersedes}`)
309
+ const answers = [...new Set(input.answers ?? [])]
310
+ for (const a of answers) {
311
+ const y = ID_RE.test(a) ? readItem(dir, a) : null
312
+ if (!y) throw new Error(`--answers ${a}: no such item in ${dir}`)
313
+ if (y.item.origin.by === 'agent') throw new Error(`--answers ${a}: an agent's card; a new version of it passes --supersedes ${a}`)
314
+ }
315
+ const subject: Subject = input.subject ?? { kind: 'shot' }
316
+ const topic = input.topic?.trim() || undefined
317
+ const wrong = specProblems({ subject, ...(topic ? { topic } : {}) } as Item, spec)
318
+ if (wrong.length > 0) throw new Error(wrong.join('; '))
319
+ if (input.wip && (given.length > 2 || given.slice(1).some((v) => !v.gallery))) throw new Error('--wip: the card shows the live frame, and its history as a gallery, nothing else')
320
+ if (input.marks && input.marks.length > given.length) throw new Error(`marks: ${input.marks.length} lists for ${given.length} views`)
321
+ if (input.supersedes !== undefined && !input.keeps?.trim()) {
322
+ const all = readAll(dir).items
323
+ const old = all.find((x) => x.item.id === input.supersedes)
324
+ const answered = old ? chainOf(old, all).find((x) => verdictOf(x)) : undefined
325
+ const v = answered ? verdictOf(answered) : null
326
+ if (answered && v && isApproved(v.status))
327
+ throw new Error(
328
+ `${answered.item.id} "${answered.item.title}" was approved: an approval locks the look. Keep what he approved, say what stays with --keeps "<what of the approved version stays>", and say the change in --what`,
329
+ )
330
+ }
331
+ if (input.supersedes === undefined) {
332
+ if (input.fresh === true) {
333
+ if (!input.why?.trim()) throw new Error('--new needs --why "<reason>": why this is another subject than the active card next to it')
334
+ } else {
335
+ const others = readAll(dir).items.filter((x) => !answers.includes(x.item.id))
336
+ const c = coveringHead({ title: input.title, ...(topic ? { topic } : {}), subject }, others, spec)
337
+ if (c) throw new Error(coveredText(c))
338
+ }
339
+ }
340
+ const lane = input.lane?.trim() || process.env.SK_AGENT?.trim() || undefined
341
+ if (lane !== undefined && !AGENT_RE.test(lane)) throw new Error(`--lane: « ${lane} » is no lane id (${AGENT_RE.source})`)
342
+ mkdirSync(dir, { recursive: true })
343
+ const now = input.now ?? new Date()
344
+ const base = idOf(now, input.title)
345
+ let id = base
346
+ for (let n = 2; existsSync(join(dir, id)); n++) id = `${base}-${n}`
347
+ const session = sessionId()
348
+ const item: Item = {
349
+ id,
350
+ created: now.toISOString(),
351
+ subject,
352
+ origin: { by: 'agent', ...(lane ? { lane } : {}), ...(session ? { session } : {}) },
353
+ title: input.title.trim(),
354
+ what: input.what.trim(),
355
+ ...(topic ? { topic } : {}),
356
+ ...(input.commit ? { commit: input.commit } : {}),
357
+ views: loaded.map((v, n) => viewOf(v, n, input.marks?.[n], given[n]?.note)),
358
+ ...(input.supersedes ? { supersedes: input.supersedes } : {}),
359
+ ...(answers.length > 0 ? { answers } : {}),
360
+ ...(input.fresh && input.why ? { why: input.why.trim() } : {}),
361
+ ...(input.supersedes && input.keeps?.trim() ? { keeps: input.keeps.trim() } : {}),
362
+ ...(input.light ? { light: input.light } : {}),
363
+ ...(input.wip ? { wip: { live: input.wip.live, frame: 1 } } : {}),
364
+ ...(input.choice ? { choice: true as const } : {}),
365
+ }
366
+ const problems = itemProblems(item)
367
+ if (problems.length > 0) throw new Error(problems.join('; '))
368
+ const tmp = join(dir, `.${id}.tmp`)
369
+ rmSync(tmp, { recursive: true, force: true })
370
+ mkdirSync(tmp)
371
+ loaded.forEach((v, n) => copyFiles(v, n, tmp))
372
+ await Promise.all(loaded.map((v, n) => Promise.all([writePoster(v, n, tmp), writeWaves(v, n, tmp)])))
373
+ writeJson(join(tmp, 'item.json'), item)
374
+ renameSync(tmp, join(dir, id))
375
+ return item
376
+ }
377
+
378
+ /** every file of view `n` into the folder, each as `<name>.tmp` renamed, so no reader sees half of one */
379
+ function copyFiles(v: Loaded, n: number, into: string): void {
380
+ const files = filesOf(v, n) as Record<string, string>
381
+ const copies: [string, string][] = [...v.sources.map(([name, p]): [string, string] => [p, files[name] ?? name]), ...(v.tiles ?? []).map((t, k): [string, string] => [t.path, tileName(n, k, t.path)])]
382
+ for (const [from, name] of copies) {
383
+ const to = join(into, name)
384
+ copyFileSync(from, `${to}.tmp`)
385
+ renameSync(`${to}.tmp`, to)
386
+ }
387
+ }
388
+
389
+ /** another angle of an open card's subject, appended as its next view; refused once he answered */
390
+ export async function addView(dir: string, id: string, v: ViewInput, o: { marks?: ReviewMark[]; light?: 'review' } = {}): Promise<Item> {
391
+ const x = readItem(dir, id)
392
+ if (!x) throw new Error(`no card ${id}`)
393
+ if (verdictOf(x)) throw new Error(`${id} has his answer already: a new version passes --supersedes ${id}`)
394
+ if (v.note !== undefined && v.gallery) throw new Error(`"${v.label}": a note goes on an image, a clip or a sound, never a gallery`)
395
+ if (!v.label.trim()) throw new Error('add-view: the view needs its label')
396
+ const loaded = await loadView(v, `the view "${v.label}"`)
397
+ const n = x.item.views.length
398
+ const folder = join(dir, id)
399
+ // the files land before the head names them, so no reader sees a view without its images
400
+ copyFiles(loaded, n, folder)
401
+ await writePoster(loaded, n, folder)
402
+ await writeWaves(loaded, n, folder)
403
+ const item: Item = { ...x.item, views: [...x.item.views, viewOf(loaded, n, o.marks, v.note)], ...(x.item.light || o.light ? { light: 'review' as const } : {}) }
404
+ writeJson(join(folder, 'item.json'), item)
405
+ return item
406
+ }
407
+
408
+ export interface ReadAll {
409
+ items: ReviewItem[]
410
+ /** a folder whose item.json or a message does not read, or still of the old shape: loud, never skipped in silence */
411
+ broken: { id: string; error: string }[]
412
+ }
413
+
414
+ const OLD_FILE = /^(request|feedback(\..+)?)\.json$/
415
+
416
+ /** one item as its repo's unit reads it: a subject or a topic the unit refuses throws, like a broken file */
417
+ export function readItem(dir: string, id: string): ReviewItem | null {
418
+ const x = readRaw(dir, id)
419
+ const wrong = x ? specProblems(x.item, specOf(dir)) : []
420
+ if (wrong.length > 0) throw new Error(`item.json: ${wrong.join('; ')}`)
421
+ return x
422
+ }
423
+
424
+ /** one item as the file says, whatever the unit: what the index keeps */
425
+ function readRaw(dir: string, id: string): ReviewItem | null {
426
+ if (!ID_RE.test(id)) return null
427
+ const folder = join(dir, id)
428
+ if (!existsSync(folder)) return null
429
+ const old = readdirSync(folder).filter((n) => OLD_FILE.test(n))
430
+ if (old.length > 0) throw new Error(`the old shape (${old.join(', ')}): written by a process older than the one store`)
431
+ const f = join(folder, 'item.json')
432
+ if (!existsSync(f)) return null
433
+ const item = JSON.parse(readFileSync(f, 'utf8')) as unknown
434
+ const problems = itemProblems(item)
435
+ if (problems.length > 0) throw new Error(`item.json: ${problems.join('; ')}`)
436
+ return { item: item as Item, thread: readThread(dir, id) }
437
+ }
438
+
439
+ /** the messages of an item, oldest first; a message file that does not read throws, never skipped in silence */
440
+ function readThread(dir: string, id: string): Message[] {
441
+ const t = join(dir, id, 'thread')
442
+ if (!existsSync(t)) return []
443
+ const names = readdirSync(t).filter((n) => n.endsWith('.json'))
444
+ const odd = names.find((n) => !MESSAGE_RE.test(n.slice(0, -5)))
445
+ if (odd) throw new Error(`thread/${odd}: not a message name`)
446
+ // by id, never by file name: `.json` sorts after `-`, so `…-owner-2.json` would come before `…-owner.json`
447
+ return names
448
+ .sort((a, b) => (a.slice(0, -5) < b.slice(0, -5) ? -1 : 1))
449
+ .map((n) => {
450
+ const m = JSON.parse(readFileSync(join(t, n), 'utf8')) as unknown
451
+ const problems = messageProblems(m, n.slice(0, -5))
452
+ if (problems.length > 0) throw new Error(`thread/${n}: ${problems.join('; ')}`)
453
+ return m as Message
454
+ })
455
+ }
456
+
457
+ /** appends one message to an item's thread, its image copied in beside it; the id never collides */
458
+ export function appendMessage(dir: string, id: string, m: Omit<Message, 'id' | 'at'>, o: { image?: string; now?: Date } = {}): Message {
459
+ const now = o.now ?? new Date()
460
+ if (o.image !== undefined) {
461
+ if (!existsSync(o.image)) throw new Error(`${o.image}: no such file`)
462
+ if (!IMAGE_EXT.test(extname(o.image).toLowerCase())) throw new Error(`${o.image}: a png, jpg or webp`)
463
+ }
464
+ const folder = join(dir, id, 'thread')
465
+ mkdirSync(folder, { recursive: true })
466
+ const base = messageIdOf(now, m.who)
467
+ let mid = base
468
+ for (let n = 2; existsSync(join(folder, `${mid}.json`)); n++) mid = `${base}-${n}`
469
+ const file = o.image === undefined ? undefined : `${mid}${extname(o.image).toLowerCase()}`
470
+ if (o.image !== undefined && file) copyFileSync(o.image, join(folder, file))
471
+ const out: Message = { id: mid, at: now.toISOString(), ...m, ...(file ? { image: file } : {}) }
472
+ writeJson(join(folder, `${mid}.json`), out)
473
+ return out
474
+ }
475
+
476
+ /** a reply on an item from `who` (the owner, or an agent by its name), with an optional image; throws with the reason */
477
+ export function addMessage(dir: string, id: string, who: string, text: unknown, image?: string, now = new Date()): Message {
478
+ if (!readItem(dir, id)) throw new Error(`no card ${id}`)
479
+ const t = textOf(text)
480
+ if ('error' in t) throw new Error(t.error)
481
+ return appendMessage(dir, id, { who, text: t.text }, { ...(image !== undefined ? { image } : {}), now })
482
+ }
483
+
484
+ /** `party` read what the other party wrote on an item: one `seen` message naming them; how many it read */
485
+ export function markRead(dir: string, id: string, party: Party, who: string = party === 'owner' ? 'owner' : agentName(), now = new Date()): number {
486
+ const x = ID_RE.test(id) ? readItem(dir, id) : null
487
+ if (!x) return 0
488
+ const of = toAcknowledge(x, party)
489
+ if (of.length > 0) appendMessage(dir, id, { who, op: 'seen', of }, { now })
490
+ return of.length
491
+ }
492
+
493
+ /** the path of a message's image, null for any other name */
494
+ export function threadImage(dir: string, id: string, name: string): string | null {
495
+ if (!ID_RE.test(id) || !MESSAGE_FILE_RE.test(name)) return null
496
+ const f = join(dir, id, 'thread', name)
497
+ return existsSync(f) ? f : null
498
+ }
499
+
500
+ /** an item's stamp: its folder and its thread. Every write renames a file into one of them (the head into the folder, a
501
+ * message into thread/), which moves that directory's change time, so any write moves it. Two stats an item, never
502
+ * three: on a 3000 item store each stat is 10 ms of every read */
503
+ function stampOf(folder: string): string {
504
+ const one = (f: string): string => {
505
+ const s = statSync(f, { bigint: true, throwIfNoEntry: false })
506
+ return s ? `${s.ctimeNs}.${s.size}.${s.ino}` : '-'
507
+ }
508
+ return `${one(folder)}:${one(`${folder}/thread`)}`
509
+ }
510
+
511
+ /** the index of a store: beside it, never inside it. Its own name, never `.review-index.json`: a second code that reads
512
+ * the same store keys its index on its own code, and two codes sharing one file each throw the other's away */
513
+ export const indexFile = (dir: string): string => join(dirname(resolve(dir)), `.${basename(dir)}-index.shipkit.json`)
514
+
515
+ type Read = { x: ReviewItem } | { error: string }
516
+
517
+ interface StoreIndex {
518
+ /** the code that reads and checks an item: another code reads every item again */
519
+ code: string
520
+ items: Record<string, { stamp: string; read: Read }>
521
+ }
522
+
523
+ let codeKey: string | null = null
524
+ const indexCode = (): string => (codeKey ??= new Bun.CryptoHasher('sha1').update(readFileSync(import.meta.path)).update(readFileSync(join(import.meta.dir, 'model.ts'))).digest('hex'))
525
+
526
+ function readOne(dir: string, id: string): Read {
527
+ try {
528
+ const x = readRaw(dir, id)
529
+ return x ? { x } : { error: ID_RE.test(id) ? 'no item.json' : 'not a review id' }
530
+ } catch (e) {
531
+ return { error: (e as Error).message }
532
+ }
533
+ }
534
+
535
+ /** every item of the store as its unit reads it: an item its unit refuses (a subject kind or a topic it does not
536
+ * declare, a subject's problems) is broken; the index keeps the raw reads, so a unit's change never invalidates it */
537
+ export const readAll = (dir: string): ReadAll => checked(readRaws(dir), specOf(dir))
538
+
539
+ const checks = new WeakMap<ReadAll, { spec: Spec; all: ReadAll }>()
540
+
541
+ function checked(raw: ReadAll, spec: Spec): ReadAll {
542
+ if (spec === NO_SPEC) return raw
543
+ const held = checks.get(raw)
544
+ if (held?.spec === spec) return held.all
545
+ const all: ReadAll = { items: [], broken: [...raw.broken] }
546
+ for (const x of raw.items) {
547
+ const wrong = specProblems(x.item, spec)
548
+ if (wrong.length === 0) all.items.push(x)
549
+ else all.broken.push({ id: x.item.id, error: `item.json: ${wrong.join('; ')}` })
550
+ }
551
+ all.broken.sort((a, b) => (a.id < b.id ? -1 : 1))
552
+ checks.set(raw, { spec, all })
553
+ return all
554
+ }
555
+
556
+ /** every item of the store, through its index: an item whose stamp moved is read again */
557
+ function readRaws(dir: string): ReadAll {
558
+ const out: ReadAll = { items: [], broken: [] }
559
+ if (!existsSync(dir)) return out
560
+ const file = indexFile(dir)
561
+ const code = indexCode()
562
+ let was: StoreIndex['items'] = {}
563
+ try {
564
+ const ix = JSON.parse(readFileSync(file, 'utf8')) as StoreIndex
565
+ if (ix.code === code) was = ix.items
566
+ } catch {
567
+ // no index yet, or one of another shape: read every item
568
+ }
569
+ const next: StoreIndex = { code, items: {} }
570
+ let moved = false
571
+ for (const id of readdirSync(dir).sort()) {
572
+ if (id.startsWith('.')) continue
573
+ const stamp = stampOf(join(dir, id))
574
+ const held = was[id]
575
+ const read = held && held.stamp === stamp ? held.read : readOne(dir, id)
576
+ if (read !== held?.read) moved = true
577
+ next.items[id] = { stamp, read }
578
+ if ('x' in read) out.items.push(read.x)
579
+ else out.broken.push({ id, error: read.error })
580
+ }
581
+ if (moved || Object.keys(was).length !== Object.keys(next.items).length)
582
+ try {
583
+ const tmp = `${file}.${process.pid}.tmp`
584
+ fs.writeFileSync(tmp, JSON.stringify(next))
585
+ fs.renameSync(tmp, file)
586
+ } catch {
587
+ // a read only store: the next read pays the whole read again
588
+ }
589
+ return out
590
+ }
591
+
592
+ /** the owner's verdict, checked against its item, appended as his message */
593
+ export function writeVerdict(dir: string, id: string, body: unknown, now = new Date()): { ok: true; message: Message } | { ok: false; errors: string[] } {
594
+ const x = readItem(dir, id)
595
+ if (!x) return { ok: false, errors: [`no card ${id}`] }
596
+ if (x.item.wip) return { ok: false, errors: [`${id} is a draft card: the agent makes it ready before a verdict; a comment goes in a reply`] }
597
+ if (x.item.origin.by !== 'agent') return { ok: false, errors: [`${id} was raised, not shown by an agent: a verdict goes on the card that shows its fix; a word goes in a reply`] }
598
+ const r = verdictMessageOf(body, x.item)
599
+ if (!r.ok) return r
600
+ return { ok: true, message: appendMessage(dir, id, { who: 'owner', ...r.message }, { now }) }
601
+ }
602
+
603
+ /** an agent acted on the verdict and the owner's replies; false when the item has neither */
604
+ export function markSeen(dir: string, id: string, now = new Date()): boolean {
605
+ const x = ID_RE.test(id) ? readItem(dir, id) : null
606
+ if (!x) return false
607
+ const read = markRead(dir, id, 'agent', agentName(), now)
608
+ return verdictOf(x) !== null || read > 0
609
+ }
610
+
611
+ /** takes an unanswered card back: false when it is unknown or already answered */
612
+ export function withdraw(dir: string, id: string, why: string, now = new Date()): boolean {
613
+ const x = ID_RE.test(id) ? readItem(dir, id) : null
614
+ if (!x || verdictOf(x) || withdrawnOf(x) !== undefined) return false
615
+ appendMessage(dir, id, { who: agentName(), op: 'withdraw', note: why }, { now })
616
+ return true
617
+ }
618
+
619
+ /** an answered card waits for his answer again: its verdict stays in the thread, before the reopen; false when it has none */
620
+ export function reopen(dir: string, id: string, now = new Date()): boolean {
621
+ const x = ID_RE.test(id) ? readItem(dir, id) : null
622
+ if (!x || !verdictOf(x)) return false
623
+ appendMessage(dir, id, { who: agentName(), op: 'reopen' }, { now })
624
+ return true
625
+ }
626
+
627
+ /** the path of one image, clip or poster of an item, null for any other name */
628
+ export function imageFile(dir: string, id: string, name: string): string | null {
629
+ if (!ID_RE.test(id) || !FILE_RE.test(name)) return null
630
+ const f = join(dir, id, name)
631
+ return existsSync(f) ? f : null
632
+ }
633
+
634
+ export interface RaiseInput {
635
+ /** its id when the caller names it (a player's report: by its server and number); else from its time and title */
636
+ id?: string
637
+ subject: Subject
638
+ origin: Exclude<Origin, { by: 'agent' }>
639
+ title: string
640
+ /** an auto item's markdown, what its rule saw: shown as a card's `what` */
641
+ what?: string
642
+ topic?: string
643
+ commit?: string
644
+ /** his or the player's words: the first message; none on an item opened for marks or raised by a rule */
645
+ text?: string
646
+ /** the rectangles or pins drawn with them, on the image */
647
+ rects?: Rect[]
648
+ /** the image the item shows, copied in as its first view */
649
+ image?: string
650
+ now?: Date
651
+ }
652
+
653
+ /** a record the owner, a player or a rule raises: its image the first view, the words and rectangles the first message;
654
+ * built in a dot folder and renamed, so two pullers of one report write it once */
655
+ export async function raiseItem(dir: string, input: RaiseInput): Promise<ReviewItem> {
656
+ const text = input.text?.trim()
657
+ if (input.text !== undefined && !text) throw new Error('a note says something')
658
+ const lane = input.origin.by === 'player' ? undefined : input.origin.lane
659
+ if (lane !== undefined && !AGENT_RE.test(lane)) throw new Error(`origin.lane: « ${lane} » is no lane id (${AGENT_RE.source})`)
660
+ if (input.origin.by === 'auto' && input.text !== undefined) throw new Error('an auto item says what its rule saw in `what`, never as a message')
661
+ if (input.image !== undefined && (!existsSync(input.image) || !IMAGE_EXT.test(extname(input.image).toLowerCase()))) throw new Error(`${input.image}: a png, jpg or webp`)
662
+ const now = input.now ?? new Date()
663
+ mkdirSync(dir, { recursive: true })
664
+ if (input.id !== undefined && (!ID_RE.test(input.id) || existsSync(join(dir, input.id)))) throw new Error(`${input.id}: no item id, or taken`)
665
+ const base = input.id ?? idOf(now, input.title)
666
+ let id = base
667
+ for (let n = 2; existsSync(join(dir, id)); n++) id = `${base}-${n}`
668
+ const image = input.image === undefined ? undefined : `image${extname(input.image).toLowerCase()}`
669
+ const item: Item = {
670
+ id,
671
+ created: now.toISOString(),
672
+ subject: input.subject,
673
+ origin: input.origin,
674
+ title: input.title.trim(),
675
+ ...(input.what?.trim() ? { what: input.what.trim() } : {}),
676
+ ...(input.topic ? { topic: input.topic } : {}),
677
+ ...(input.commit ? { commit: input.commit } : {}),
678
+ views: input.image !== undefined && image ? [{ label: '', files: { image }, size: await sizeOf(input.image) }] : [],
679
+ }
680
+ const problems = [...itemProblems(item), ...specProblems(item, specOf(dir))]
681
+ if (problems.length > 0) throw new Error(problems.join('; '))
682
+ const tmp = join(dir, `.${id}.tmp`)
683
+ rmSync(tmp, { recursive: true, force: true })
684
+ mkdirSync(tmp)
685
+ if (input.image !== undefined && image) copyFileSync(input.image, join(tmp, image))
686
+ writeJson(join(tmp, 'item.json'), item)
687
+ renameSync(tmp, join(dir, id))
688
+ const who = input.origin.by === 'player' ? 'player' : 'owner'
689
+ if (text) appendMessage(dir, id, { who, text, ...(input.rects && input.rects.length > 0 ? { rects: input.rects } : {}) }, { now })
690
+ return readItem(dir, id) as ReviewItem
691
+ }
692
+
693
+
694
+
695
+ /** the owner's mark on a subject (stars, a mark and a note): one `rate` message on the subject's marks item, opened on
696
+ * the first mark; the older marks stay as its history */
697
+ export async function rateSubject(
698
+ dir: string,
699
+ o: { subject: Subject; from: string; title: string; topic?: string; rate: NonNullable<Message['rate']>; text?: string; now?: Date },
700
+ ): Promise<Message> {
701
+ const spec = specOf(dir)
702
+ const key = subjectKey(o.subject, spec)
703
+ if (key === null) throw new Error(`a mark names its subject: kind « ${o.subject.kind} » names none (its key)`)
704
+ const mine = readAllCached(dir)
705
+ .items.filter((x) => isMarkItem(x) && subjectKey(x.item.subject, spec) === key)
706
+ .sort((a, b) => b.item.created.localeCompare(a.item.created))[0]
707
+ const x = mine ?? (await raiseItem(dir, { subject: o.subject, origin: { by: 'owner', from: o.from }, title: o.title, ...(o.topic ? { topic: o.topic } : {}), ...(o.now ? { now: o.now } : {}) }))
708
+ const text = o.text?.trim()
709
+ return appendMessage(dir, x.item.id, { who: 'owner', rate: o.rate, ...(text ? { text } : {}) }, o.now ? { now: o.now } : {})
710
+ }
711
+
712
+ /** the owner approves a subject he raised from a tool (an entry he checked), or takes it back: a verdict with the commit, or a
713
+ * reopen, on the newest item he raised on that subject from that tool */
714
+ export async function ownerVerdict(dir: string, o: { subject: Subject; from: string; title: string; ok: boolean; commit?: string; now?: Date }): Promise<void> {
715
+ const spec = specOf(dir)
716
+ const key = subjectKey(o.subject, spec)
717
+ if (key === null) throw new Error(`a verdict on a subject names it: kind « ${o.subject.kind} » names none (its key)`)
718
+ const mine = readAllCached(dir)
719
+ .items.filter((x) => x.item.origin.by === 'owner' && x.item.origin.from === o.from && subjectKey(x.item.subject, spec) === key)
720
+ .sort((a, b) => b.item.created.localeCompare(a.item.created))[0]
721
+ if (!o.ok && (!mine || !verdictOf(mine))) return
722
+ const x = mine ?? (await raiseItem(dir, { subject: o.subject, origin: { by: 'owner', from: o.from }, title: o.title, ...(o.now ? { now: o.now } : {}) }))
723
+ const at = o.now ? { now: o.now } : {}
724
+ if (o.ok) appendMessage(dir, x.item.id, { who: 'owner', verdict: 'approved', ...(o.commit ? { fix: { commit: o.commit } } : {}) }, at)
725
+ else appendMessage(dir, x.item.id, { who: 'owner', op: 'reopen' }, at)
726
+ }
727
+
728
+ /** the lock of one item while an act is checked and written: a folder made atomically, waited on for at most 2 s */
729
+ function locked<T>(dir: string, id: string, fn: () => T): T {
730
+ const lock = join(dir, id, '.lock')
731
+ const until = Date.now() + 2000
732
+ for (;;) {
733
+ try {
734
+ mkdirSync(lock)
735
+ break
736
+ } catch {
737
+ if (Date.now() > until) {
738
+ // a lock left by a dead process: older than the wait itself
739
+ if (Date.now() - statSync(lock).mtimeMs > 2000) {
740
+ rmSync(lock, { recursive: true, force: true })
741
+ continue
742
+ }
743
+ throw new Error(`${id}: locked by another process`)
744
+ }
745
+ Bun.sleepSync(10)
746
+ }
747
+ }
748
+ try {
749
+ return fn()
750
+ } finally {
751
+ rmSync(lock, { recursive: true, force: true })
752
+ }
753
+ }
754
+
755
+ /** a state change on an item by `who`, checked by `allowed` under the item's lock, so two workers claiming at once
756
+ * never both win; throws with the reason it is refused. The dispatcher's ops need the repo's unit to declare one, and
757
+ * its holder; a topic is one the unit declares */
758
+ export function act(dir: string, id: string, op: Exclude<Op, 'seen'>, who: string, o: { note?: string; text?: string; commit?: string; topic?: string; now?: Date } = {}): Message {
759
+ const spec = specOf(dir)
760
+ if (who !== 'owner' && who !== 'agent' && !AGENT_RE.test(who)) throw new Error(`« ${who} » is no agent name (${AGENT_RE.source})`)
761
+ if (!ID_RE.test(id) || !readItem(dir, id)) throw new Error(`no item ${id}`)
762
+ if ((op === 'wontfix' || op === 'blocked' || op === 'drop') && !o.note?.trim()) throw new Error(`${op} says why (--note)`)
763
+ if ((op === 'ask' || op === 'answer') && !o.text?.trim()) throw new Error(`${op} says something`)
764
+ if (op === 'dispatch' && !o.topic) throw new Error('dispatch names a topic')
765
+ if (o.topic !== undefined && Object.keys(spec.topics).length > 0 && !spec.topics[o.topic]) throw new Error(`no topic « ${o.topic} »: ${Object.keys(spec.topics).join(', ')}`)
766
+ if (o.commit !== undefined && !/^[0-9a-f]{7,40}$/.test(o.commit)) throw new Error(`« ${o.commit} » is no commit`)
767
+ const now = o.now ?? new Date()
768
+ return locked(dir, id, () => {
769
+ const x = readItem(dir, id) as ReviewItem
770
+ const superseded = isFollowed(id, readAll(dir).items)
771
+ const role = spec.dispatcher ? (roleOf(dir)?.name ?? null) : null
772
+ const why = op === 'claim' && superseded && x.item.origin.by === 'agent' ? 'a newer card redoes it: claim that one' : allowed(stateOf(x, superseded), op, who, role, spec.dispatcher)
773
+ if (why) throw new Error(`${id}: ${why}`)
774
+ const m = appendMessage(
775
+ dir,
776
+ id,
777
+ {
778
+ who,
779
+ op,
780
+ ...(o.text?.trim() ? { text: o.text.trim() } : {}),
781
+ ...(o.note?.trim() ? { note: o.note.trim() } : {}),
782
+ ...(o.commit ? { fix: { commit: o.commit } } : {}),
783
+ ...(o.topic ? { topic: o.topic } : {}),
784
+ },
785
+ { now },
786
+ )
787
+ if (role !== null && role === who) beatRole(dir, now)
788
+ return m
789
+ })
790
+ }
791
+
792
+ /** `who` claims the oldest open item raised to a lane, or of a topic, and gets it read back; null when none is open.
793
+ * Each claim runs under the item's lock, so two workers never take one item */
794
+ export function claimNext(dir: string, by: { lane: string } | { topic: string }, who: string): ReviewItem | null {
795
+ const store = readAll(dir).items
796
+ const followed = followedIds(store)
797
+ const routed = specOf(dir).dispatcher
798
+ const open = store
799
+ .filter((x) => x.item.origin.by !== 'agent' && ('lane' in by ? laneOf(x) === by.lane : true))
800
+ .filter((x) => {
801
+ const st = stateOf(x, followed.has(x.item.id))
802
+ return (st.state === 'open' || (!routed && st.state === 'new')) && ('topic' in by ? st.topic === by.topic : true)
803
+ })
804
+ .sort((a, b) => a.item.created.localeCompare(b.item.created) || a.item.id.localeCompare(b.item.id))
805
+ for (const x of open) {
806
+ try {
807
+ act(dir, x.item.id, 'claim', who)
808
+ return readItem(dir, x.item.id)
809
+ } catch {
810
+ // taken by another worker meanwhile: the next one
811
+ }
812
+ }
813
+ return null
814
+ }
815
+
816
+ /** the owner deletes what he raised himself (a note he takes back): its folder goes; an agent's card is never deleted
817
+ * this way */
818
+ export function deleteRaised(dir: string, id: string): void {
819
+ const x = ID_RE.test(id) ? readItem(dir, id) : null
820
+ if (!x) throw new Error(`no item ${id}`)
821
+ if (x.item.origin.by !== 'owner') throw new Error(`${id} was not raised by the owner: never deleted from the hub`)
822
+ rmSync(join(dir, id), { recursive: true, force: true })
823
+ }
824
+
825
+ /** a player's report removed from the store, once its source deleted it there */
826
+ export function deleteReport(dir: string, id: string): void {
827
+ const x = ID_RE.test(id) ? readItem(dir, id) : null
828
+ if (!x) throw new Error(`no item ${id}`)
829
+ if (x.item.origin.by !== 'player') throw new Error(`${id} is no player's report`)
830
+ rmSync(join(dir, id), { recursive: true, force: true })
831
+ }
832
+
833
+ /** a player's report by its ref (`live#3`: its server and number), or an item by its id. A pulled report's item is named
834
+ * after it (`…-live-3`): its name first, the whole store only when no name holds it */
835
+ export function itemOfRef(dir: string, ref: string): ReviewItem | null {
836
+ const m = /^([a-z][a-z0-9-]*)#(\d+)$/.exec(ref.trim())
837
+ if (!m) return ID_RE.test(ref) ? readItem(dir, ref) : null
838
+ const holds = (x: ReviewItem | null): x is ReviewItem => x !== null && x.item.origin.by === 'player' && x.item.origin.server === m[1] && x.item.origin.serverId === Number(m[2])
839
+ const tail = `-${m[1]}-${m[2]}`
840
+ for (const n of existsSync(dir) ? readdirSync(dir).filter((n) => n.endsWith(tail)).sort() : []) {
841
+ try {
842
+ const x = readItem(dir, n)
843
+ if (holds(x)) return x
844
+ } catch {
845
+ // a broken folder of that name: the whole store next
846
+ }
847
+ }
848
+ return readAll(dir).items.find(holds) ?? null
849
+ }
850
+
851
+ /** a player's report as the owner names it: `live#85`; any other item by its id */
852
+ export const refOf = (x: ReviewItem): string => (x.item.origin.by === 'player' ? `${x.item.origin.server}#${x.item.origin.serverId}` : x.item.id)
853
+
854
+ /** a draft card's live frame and its history again: the first view's image replaced, the history a gallery view; the
855
+ * draft's frame count moves */
856
+ export async function refreshDraft(dir: string, id: string, o: { image: string; history?: { file: string; label: string }[] }): Promise<Item> {
857
+ const x = readItem(dir, id)
858
+ if (!x) throw new Error(`no card ${id}`)
859
+ const r = x.item
860
+ if (!r.wip || verdictOf(x)) throw new Error(`${id} is not a draft card waiting`)
861
+ const f0 = r.views[0]
862
+ if (!f0 || !('files' in f0) || !('image' in f0.files)) throw new Error(`${id}: its first view is no image`)
863
+ const folder = join(dir, id)
864
+ const main = await loadView({ label: f0.label, image: o.image }, 'the live frame')
865
+ const image = f0.files.image
866
+ copyFileSync(main.sources[0]?.[1] ?? '', join(folder, `${image}.tmp`))
867
+ renameSync(join(folder, `${image}.tmp`), join(folder, image))
868
+ // a draft's one gallery is its history
869
+ const label = r.views.find((v) => 'gallery' in v)?.label ?? HISTORY
870
+ let views: ReviewView[] = [{ ...f0, size: main.size }, ...r.views.slice(1).filter((v) => !('gallery' in v))]
871
+ if (o.history && o.history.length > 0) {
872
+ const n = views.length
873
+ const loaded = await loadView({ label, gallery: o.history }, label)
874
+ for (const f of readdirSync(folder)) if (new RegExp(`^v${n}-g\\d+\\.`).test(f)) rmSync(join(folder, f))
875
+ copyFiles(loaded, n, folder)
876
+ views = [...views, viewOf(loaded, n)]
877
+ }
878
+ const next: Item = { ...r, views, wip: { live: r.wip.live, frame: r.wip.frame + 1 } }
879
+ writeJson(join(folder, 'item.json'), next)
880
+ return next
881
+ }
882
+
883
+ /** the label of a draft card's history view */
884
+ export const HISTORY = 'history'
885
+
886
+ /** a draft card made a card he answers: its image the last frame or `image`, the draft state gone */
887
+ export async function readyItem(dir: string, id: string, o: { image?: string; now?: Date } = {}): Promise<Item> {
888
+ const x = readItem(dir, id)
889
+ if (!x) throw new Error(`no card ${id}`)
890
+ const { wip, ...rest } = x.item
891
+ if (!wip) throw new Error(`${id} is not a draft card: it already takes a verdict`)
892
+ let item: Item = rest
893
+ if (o.image !== undefined) {
894
+ const f0 = rest.views[0]
895
+ if (!f0 || !('files' in f0)) throw new Error(`${id} has no image to replace`)
896
+ const loaded = await loadView({ label: f0.label, image: o.image }, '--image')
897
+ const folder = join(dir, id)
898
+ const old = 'image' in f0.files ? f0.files.image : undefined
899
+ copyFiles(loaded, 0, folder)
900
+ const files = filesOf(loaded, 0)
901
+ const now = 'image' in files ? files.image : undefined
902
+ if (old && old !== now) rmSync(join(folder, old), { force: true })
903
+ item = { ...rest, views: [{ ...f0, files, size: loaded.size }, ...rest.views.slice(1)] }
904
+ }
905
+ writeJson(join(dir, id, 'item.json'), item)
906
+ appendMessage(dir, id, { who: agentName(), op: 'ready' }, o.now ? { now: o.now } : {})
907
+ return item
908
+ }
909
+
910
+ /** a store watched by this process: `moved` counts the writes under it, by any process; `dirty` names the items they
911
+ * touched since the last read, `held` every item as last read (null until a first whole read); null once the watch failed */
912
+ interface Watched {
913
+ moved: number
914
+ dirty: Set<string>
915
+ held: Map<string, Read> | null
916
+ all: ReadAll | null
917
+ }
918
+ const watched = new Map<string, Watched | null>()
919
+ const BOOT = Date.now().toString(36)
920
+
921
+ /** a write by this process: the item it touched is dirty at once, before the watcher reports it */
922
+ function touched(path: string): void {
923
+ wrote++
924
+ const p = resolve(path)
925
+ for (const [key, w] of watched) if (w && p.startsWith(`${key}/`)) w.dirty.add(p.slice(key.length + 1).split('/')[0] ?? '')
926
+ }
927
+
928
+ function watchOf(dir: string): Watched | null {
929
+ const key = resolve(dir)
930
+ if (watched.has(key)) return watched.get(key) ?? null
931
+ const w: Watched = { moved: 0, dirty: new Set(), held: null, all: null }
932
+ try {
933
+ // a thumbnail or an item's lock is no change of the list
934
+ const watcher = watch(key, { recursive: true }, (_, name) => {
935
+ if (name && (name.startsWith('.thumbs') || name.endsWith('.lock'))) return
936
+ w.moved++
937
+ if (name) w.dirty.add(name.split('/')[0] ?? '')
938
+ else w.held = null
939
+ })
940
+ watcher.on('error', () => watched.set(key, null))
941
+ watcher.unref()
942
+ watched.set(key, w)
943
+ return w
944
+ } catch {
945
+ watched.set(key, null)
946
+ return null
947
+ }
948
+ }
949
+
950
+ /** changes when anything the list is built from changes: watched, a count of writes; else a scan. The list's ETag */
951
+ export function storeStamp(dir: string): string {
952
+ if (!existsSync(dir)) return 'none'
953
+ const w = watchOf(dir)
954
+ return w ? `${BOOT}.${w.moved}.${wrote}` : scanStamp(dir)
955
+ }
956
+
957
+ /** the store's stamp and who listens (the live watchers' pids), so a watch that starts or dies moves it too */
958
+ export const listingStamp = (dir: string): string => `${storeStamp(dir)}~${liveWatchers(dir).map((w) => w.pid).join(',')}`
959
+
960
+ /** the stamp from a scan of every item's files: the safety net of a watcher that may miss an event */
961
+ export function scanStamp(dir: string): string {
962
+ const h = new Bun.CryptoHasher('sha1')
963
+ if (!existsSync(dir)) return 'none'
964
+ const mark = (f: string): void => {
965
+ const s = statSync(f, { throwIfNoEntry: false })
966
+ h.update(`${f}:${s ? `${s.mtimeMs}:${s.size}` : '-'}\n`)
967
+ }
968
+ for (const id of readdirSync(dir).sort()) {
969
+ if (id.startsWith('.')) continue
970
+ for (const f of ['', 'item.json']) mark(join(dir, id, f))
971
+ const thread = join(dir, id, 'thread')
972
+ if (existsSync(thread)) for (const n of readdirSync(thread).sort()) mark(join(thread, n))
973
+ }
974
+ return h.digest('hex').slice(0, 16)
975
+ }
976
+
977
+ let cached: { dir: string; tag: string; all: ReadAll } | null = null
978
+
979
+ /** readAll kept while nothing it reads changed: watched, only the items written since are read again */
980
+ export const readAllCached = (dir: string): ReadAll => checked(readRawsCached(dir), specOf(dir))
981
+
982
+ function readRawsCached(dir: string): ReadAll {
983
+ const w = existsSync(dir) ? watchOf(dir) : null
984
+ if (!w) {
985
+ const tag = listingStamp(dir)
986
+ if (cached?.dir !== dir || cached.tag !== tag) cached = { dir, tag, all: readRaws(dir) }
987
+ return cached.all
988
+ }
989
+ if (!w.held || !w.all) {
990
+ w.dirty.clear()
991
+ const all = readRaws(dir)
992
+ w.held = new Map<string, Read>([...all.items.map((x) => [x.item.id, { x }] as const), ...all.broken.map((b) => [b.id, { error: b.error }] as const)])
993
+ w.all = all
994
+ return all
995
+ }
996
+ if (w.dirty.size === 0) return w.all
997
+ for (const id of w.dirty) {
998
+ if (id === '' || id.startsWith('.')) continue
999
+ if (existsSync(join(dir, id))) w.held.set(id, readOne(dir, id))
1000
+ else w.held.delete(id)
1001
+ }
1002
+ w.dirty.clear()
1003
+ const out: ReadAll = { items: [], broken: [] }
1004
+ for (const id of [...w.held.keys()].sort()) {
1005
+ const read = w.held.get(id) as Read
1006
+ if ('x' in read) out.items.push(read.x)
1007
+ else out.broken.push({ id, error: read.error })
1008
+ }
1009
+ w.all = out
1010
+ return out
1011
+ }