rvlib-shipkit 2.1.1 → 2.3.0

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