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,183 @@
1
+ // the trailer view's shapes (wiki build/trailer.md, Frame notes): what the hub answers about a repo's cuts, and a frame
2
+ // note as the view shows it, read from its item of the one store. Pure: the routes and the page share them
3
+ import { isFrame, isReply, partyOf, stateOf, type ReviewItem } from './model.ts'
4
+
5
+ export type NoteState = 'open' | 'done' | 'wontfix'
6
+
7
+ export interface NotePin {
8
+ /** 0 to 1 over the frame, from the left and from the top */
9
+ x: number
10
+ y: number
11
+ }
12
+
13
+ /** one note as the view shows it: an item of the one store whose subject is a frame */
14
+ export interface TrailerNote {
15
+ /** its item's id */
16
+ id: string
17
+ /** its number on the trailer, in the order written: the label of its pin */
18
+ n: number
19
+ trailer: string
20
+ cut: string
21
+ /** seconds on the cut it was written on, the frame's middle */
22
+ time: number
23
+ frame: number
24
+ shot: string
25
+ /** seconds into the shot */
26
+ shotTime: number
27
+ section: string | null
28
+ text: string
29
+ pin?: NotePin
30
+ state: NoteState
31
+ /** done: the commit or the answer; wontfix: the reason */
32
+ reply?: string
33
+ written: string
34
+ closed?: string
35
+ /** its still, a file of its item's folder */
36
+ still: string
37
+ }
38
+
39
+ /** a frame item as a note: its words and pin are the owner's latest, its state the lifecycle's (fixed is done); null
40
+ * when the item is no frame */
41
+ export function noteOf(x: ReviewItem, n: number): TrailerNote | null {
42
+ const s = x.item.subject
43
+ if (!isFrame(s)) return null
44
+ const said = x.thread.filter((m) => partyOf(m.who) === 'owner' && m.op === undefined)
45
+ const text = said.filter(isReply).at(-1)?.text ?? ''
46
+ const pinned = said.filter((m) => m.rects !== undefined).at(-1)?.rects?.[0]
47
+ const st = stateOf(x, false)
48
+ const closing = st.state === 'closed' ? x.thread.filter((m) => m.op === 'fixed' || m.op === 'wontfix').at(-1) : undefined
49
+ const first = x.item.views[0]
50
+ return {
51
+ id: x.item.id,
52
+ n,
53
+ trailer: s.trailer,
54
+ cut: s.cut,
55
+ time: s.time,
56
+ frame: s.frame,
57
+ shot: s.shot,
58
+ shotTime: s.shotTime,
59
+ section: s.section,
60
+ text,
61
+ ...(pinned ? { pin: { x: pinned.x, y: pinned.y } } : {}),
62
+ state: closing?.op === 'wontfix' ? 'wontfix' : closing ? 'done' : 'open',
63
+ ...(closing?.note ? { reply: closing.note } : {}),
64
+ written: x.item.created,
65
+ ...(closing ? { closed: closing.at } : {}),
66
+ still: first && 'files' in first && 'image' in first.files ? first.files.image : 'image.png',
67
+ }
68
+ }
69
+
70
+ /** every frame note of one trailer, numbered in the order written */
71
+ export function notesOf(items: readonly ReviewItem[], trailer: string): TrailerNote[] {
72
+ return items
73
+ .filter((x) => isFrame(x.item.subject) && x.item.subject.trailer === trailer)
74
+ .sort((a, b) => a.item.created.localeCompare(b.item.created))
75
+ .flatMap((x, i) => noteOf(x, i + 1) ?? [])
76
+ }
77
+
78
+ export interface CaptionSpan {
79
+ text: string
80
+ style: string
81
+ /** seconds on the cut */
82
+ start: number
83
+ end: number
84
+ }
85
+
86
+ export interface ShotSpan {
87
+ name: string
88
+ /** its line in the .trailer file */
89
+ line: number
90
+ start: number
91
+ /** where the next shot starts (the cut's end for the last): the span the timeline draws */
92
+ end: number
93
+ /** its whole length, the cut into the next included */
94
+ length: number
95
+ section: string | null
96
+ captions: CaptionSpan[]
97
+ }
98
+
99
+ export interface SectionSpan {
100
+ name: string
101
+ start: number
102
+ end: number
103
+ }
104
+
105
+ export interface Timeline {
106
+ total: number
107
+ fps: number
108
+ size: [number, number]
109
+ shots: ShotSpan[]
110
+ sections: SectionSpan[]
111
+ }
112
+
113
+ /** one cut of a trailer folder: a `.trailer` file and its render `out/<name>.mp4`, or a render with no file beside it */
114
+ export interface CutInfo {
115
+ /** the `.trailer` file's stem, else the render's */
116
+ cut: string
117
+ /** the render's stem: the file's `trailer <name>` line */
118
+ name: string
119
+ /** repo relative: the render, its strip, its montage page */
120
+ files: { trailer: string | null; video: string; strip: string; montage: string | null }
121
+ /** the timeline the render on screen was made from, else the .trailer file's as it stands; null when neither parses */
122
+ timeline: Timeline | null
123
+ /** the timeline is the render's own (the file was saved before it) */
124
+ timelineFromRender: boolean
125
+ errors: string[]
126
+ /** the render on screen: its version (mtime in ms), length and rate; null when there is none */
127
+ video: { mtime: number; duration: number; fps: number } | null
128
+ /** a new render is being written: `video` is still the last finished one */
129
+ rendering: boolean
130
+ /** the .trailer file changed since that render */
131
+ edited: boolean
132
+ /** the render's contact strip (one frame a shot, `cols` a row), null when absent or not of this timeline */
133
+ strip: { cols: number; tileW: number; tileH: number; pad: number } | null
134
+ /** the .trailer file's mtime in ms, else the render's */
135
+ fileMtime: number
136
+ /** a kept version (`versions/<v>.trailer` and `versions/<v>/`): it never changes */
137
+ version: boolean
138
+ }
139
+
140
+ export interface TrailerInfo {
141
+ id: string
142
+ cuts: CutInfo[]
143
+ notes: TrailerNote[]
144
+ }
145
+
146
+ export interface TrailerDesk {
147
+ trailers: TrailerInfo[]
148
+ }
149
+
150
+ /** the shot on screen at `t`: the last one started */
151
+ export function shotAt(tl: Pick<Timeline, 'shots'>, t: number): ShotSpan | null {
152
+ let out: ShotSpan | null = null
153
+ for (const s of tl.shots) {
154
+ if (s.start > t + 1e-6) break
155
+ out = s
156
+ }
157
+ return out ?? tl.shots[0] ?? null
158
+ }
159
+
160
+ /** where a note sits on a cut: its shot's start there plus its time into it, clamped to the shot; null when the shot is
161
+ * not in it */
162
+ export function noteTime(n: Pick<TrailerNote, 'shot' | 'shotTime'>, tl: Pick<Timeline, 'shots'>): number | null {
163
+ const s = tl.shots.find((x) => x.name === n.shot)
164
+ if (!s) return null
165
+ return s.start + Math.max(0, Math.min(s.length, n.shotTime))
166
+ }
167
+
168
+ /** the cuts newest first, by the later of the file's edit and its render; the kept versions after them, newest first too */
169
+ export function cutsByRecent<C extends Pick<CutInfo, 'fileMtime' | 'video' | 'version'>>(cuts: readonly C[]): C[] {
170
+ const last = (c: C): number => Math.max(c.fileMtime, c.video?.mtime ?? 0)
171
+ return [...cuts].sort((a, b) => Number(a.version) - Number(b.version) || last(b) - last(a))
172
+ }
173
+
174
+ /** `m:ss.cc` */
175
+ export function clock(t: number): string {
176
+ const s = Math.max(0, t)
177
+ const m = Math.floor(s / 60)
178
+ const r = s - m * 60
179
+ return `${m}:${r < 10 ? '0' : ''}${r.toFixed(2)}`
180
+ }
181
+
182
+ export const TRAILER_ID_RE = /^[a-z0-9][a-z0-9-]*$/
183
+ export const CUT_RE = /^[a-z0-9][a-z0-9-]*$/
@@ -0,0 +1,136 @@
1
+ // the hub's trailer routes (wiki build/trailer.md, Frame notes): a repo's cuts with their timelines and notes, a kept
2
+ // render with byte ranges, and the owner's frame notes, each an item of the one review store. Paths are relative to the
3
+ // repo hub's base. null: not a path of these
4
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs'
5
+ import { tmpdir } from 'node:os'
6
+ import { join, relative } from 'node:path'
7
+ import { bad, NO_STORE, ranged } from './http.ts'
8
+ import { ID_RE, isFrame } from './model.ts'
9
+ import { act, appendMessage, deleteRaised, raiseItem, readAllCached, readItem } from './store.ts'
10
+ import { cutOf, frameNote, montagePage, trailerDir, trailerIds, trailerInfo, videoOf, viewStrip, viewVideo } from './trailer-desk.ts'
11
+ import { CUT_RE, noteOf, TRAILER_ID_RE, type NotePin, type NoteState, type TrailerDesk } from './trailer-model.ts'
12
+
13
+ const clamp = (v: number): number => Number(Math.min(1, Math.max(0, v)).toFixed(4))
14
+
15
+ const pinOf = (v: unknown): NotePin | undefined => {
16
+ const p = v as { x?: unknown; y?: unknown } | null | undefined
17
+ return p && typeof p.x === 'number' && typeof p.y === 'number' ? { x: p.x, y: p.y } : undefined
18
+ }
19
+
20
+ const STATES: readonly NoteState[] = ['open', 'done', 'wontfix']
21
+
22
+ /** a note's title in the list: its first words */
23
+ const titleOf = (text: string): string => {
24
+ const t = text.trim().split('\n')[0] ?? ''
25
+ return t.length > 80 ? `${t.slice(0, 79).trimEnd()}…` : t
26
+ }
27
+
28
+ /** the commit the tree stands at, for the record of a note; none when git does not answer */
29
+ function headCommit(root: string): string | undefined {
30
+ const r = Bun.spawnSync(['git', 'rev-parse', '--short=10', 'HEAD'], { cwd: root, stdout: 'pipe', stderr: 'ignore' })
31
+ const sha = r.stdout.toString().trim()
32
+ return r.exitCode === 0 && /^[0-9a-f]{7,40}$/.test(sha) ? sha : undefined
33
+ }
34
+
35
+ /** a note as the view shows it, after a change: numbered among its trailer's notes */
36
+ function noteAfter(dir: string, nid: string): Response {
37
+ const x = readItem(dir, nid)
38
+ if (!x || !isFrame(x.item.subject)) return bad(`no note ${nid}`, 404)
39
+ const trailer = x.item.subject.trailer
40
+ const n = readAllCached(dir).items.filter((y) => isFrame(y.item.subject) && y.item.subject.trailer === trailer && y.item.created <= x.item.created).length
41
+ const note = noteOf(x, Math.max(1, n))
42
+ return note ? Response.json(note, { headers: NO_STORE }) : bad(`no note ${nid}`, 404)
43
+ }
44
+
45
+ export async function trailerRoutes(req: Request, path: string, root: string, dir: string): Promise<Response | null> {
46
+ const API = '/api/trailer'
47
+ if (path !== API && !path.startsWith(`${API}/`)) return null
48
+ try {
49
+ if (path === API && req.method === 'GET') {
50
+ const desk: TrailerDesk = { trailers: trailerIds(root).flatMap((id) => trailerInfo(root, id) ?? []) }
51
+ return Response.json(desk, { headers: NO_STORE })
52
+ }
53
+ const media = /^\/api\/trailer\/(video|strip)\/([^/]+)\/([^/]+)\/(\d+)\.(mp4|png)$/.exec(path)
54
+ if (media && req.method === 'GET') {
55
+ const [, kind, id = '', cut = '', version = ''] = media
56
+ if (!TRAILER_ID_RE.test(id) || !CUT_RE.test(cut)) return bad(`${path}?`, 404)
57
+ return ranged(req, kind === 'video' ? viewVideo(root, id, cut, Number(version)) : viewStrip(root, id, cut, Number(version)))
58
+ }
59
+ const mont = /^\/api\/trailer\/montage\/([^/]+)\/([^/]+)$/.exec(path)
60
+ if (mont && req.method === 'GET') {
61
+ const page = montagePage(root, mont[1] ?? '', mont[2] ?? '')
62
+ return page === null ? bad('no montage page: sk trailer writes it with the render', 404) : new Response(page, { headers: { 'Content-Type': 'text/markdown; charset=utf-8', ...NO_STORE } })
63
+ }
64
+ if (path === `${API}/reveal` && req.method === 'POST') {
65
+ // the render on screen shown in the Finder, so a cut already on this disk is never downloaded
66
+ const b = (await req.json()) as Record<string, unknown>
67
+ const dir = trailerDir(root, String(b.trailer ?? ''))
68
+ const c = dir ? cutOf(dir, String(b.cut ?? '')) : null
69
+ if (!dir || !c) return bad(`no cut ${String(b.trailer)}/${String(b.cut)}`, 404)
70
+ const video = videoOf(dir, c)
71
+ if (!existsSync(video)) return bad(`${c.cut} is not rendered yet`, 404)
72
+ if (process.platform !== 'darwin') return bad('the Finder is macOS only', 501)
73
+ const r = Bun.spawnSync(['open', '-R', video], { stdout: 'ignore', stderr: 'pipe' })
74
+ if (r.exitCode !== 0) return bad(`open -R: ${r.stderr.toString().trim() || `exit ${r.exitCode}`}`, 500)
75
+ return Response.json({ ok: true, file: relative(root, video) }, { headers: NO_STORE })
76
+ }
77
+ if (path === `${API}/notes` && req.method === 'POST') {
78
+ const b = (await req.json()) as Record<string, unknown>
79
+ const id = String(b.trailer ?? '')
80
+ if (!trailerDir(root, id)) return bad(`no trailer ${id}`)
81
+ const text = String(b.text ?? '')
82
+ if (text.trim() === '') return bad('a note says something')
83
+ const pin = pinOf(b.pin)
84
+ const tmp = mkdtempSync(join(tmpdir(), 'trailer-note-'))
85
+ try {
86
+ const still = join(tmp, 'still.png')
87
+ const subject = await frameNote(root, id, { cut: String(b.cut ?? ''), version: Number(b.version), time: Number(b.time) || 0 }, still)
88
+ const commit = headCommit(root)
89
+ const x = await raiseItem(dir, {
90
+ subject,
91
+ origin: { by: 'owner', from: 'trailer' },
92
+ topic: 'film',
93
+ title: titleOf(text),
94
+ text,
95
+ ...(pin ? { rects: [{ x: clamp(pin.x), y: clamp(pin.y), w: 0, h: 0, comment: '' }] } : {}),
96
+ image: still,
97
+ ...(commit ? { commit } : {}),
98
+ })
99
+ return noteAfter(dir, x.item.id)
100
+ } finally {
101
+ rmSync(tmp, { recursive: true, force: true })
102
+ }
103
+ }
104
+ const one = /^\/api\/trailer\/notes\/([^/]+)\/([^/]+)$/.exec(path)
105
+ if (one) {
106
+ const [, id = '', nid = ''] = one
107
+ const x = trailerDir(root, id) && ID_RE.test(nid) ? readItem(dir, nid) : null
108
+ if (!x || !isFrame(x.item.subject) || x.item.subject.trailer !== id) return bad(`no note ${id}/${nid}`, 404)
109
+ if (req.method === 'DELETE') {
110
+ deleteRaised(dir, nid)
111
+ return Response.json({ ok: true }, { headers: NO_STORE })
112
+ }
113
+ if (req.method !== 'POST') return null
114
+ const b = (await req.json()) as Record<string, unknown>
115
+ if (typeof b.state === 'string' && !STATES.includes(b.state as NoteState)) return bad(`state ${b.state}?`)
116
+ const text = typeof b.text === 'string' ? b.text.trim() : undefined
117
+ if (text === '') return bad('a note says something')
118
+ const pin = b.pin === null ? null : pinOf(b.pin)
119
+ if (text !== undefined || pin !== undefined)
120
+ appendMessage(dir, nid, {
121
+ who: 'owner',
122
+ ...(text !== undefined ? { text } : {}),
123
+ ...(pin !== undefined ? { rects: pin ? [{ x: clamp(pin.x), y: clamp(pin.y), w: 0, h: 0, comment: '' }] : [] } : {}),
124
+ })
125
+ const reply = typeof b.reply === 'string' ? b.reply : undefined
126
+ if (b.state === 'open') act(dir, nid, 'reopen', 'owner')
127
+ else if (b.state === 'done') act(dir, nid, 'fixed', 'owner', reply?.trim() ? { note: reply } : {})
128
+ else if (b.state === 'wontfix') act(dir, nid, 'wontfix', 'owner', { note: reply?.trim() || 'not done' })
129
+ return noteAfter(dir, nid)
130
+ }
131
+ return bad(`${req.method} ${path}?`, 404)
132
+ } catch (e) {
133
+ console.error(`FAIL: ${req.method} ${path}: ${(e as Error).message}`)
134
+ return bad(e)
135
+ }
136
+ }
@@ -0,0 +1,183 @@
1
+ // a repo's parts of the one store (wiki build/shipkit/review.md, A project's parts): its subject kinds, topics, sources,
2
+ // auto rules and the dispatcher switch, declared once in a `review({...})` unit of any `<name>.shipkit.ts`. Every unit
3
+ // file imports this module, so it stays light: the store reads the pure part through specOf(dir), project.ts finds,
4
+ // imports and serves the rest
5
+ import { AGENT_RE, type ReviewItem, type ReviewMark, type Spec, type SpecKind, type SpecTopic, type Subject } from './model.ts'
6
+ import type { AddInput, ViewInput } from './store.ts'
7
+
8
+ /** one line of a kind's data view: a fact the hub and `show` print */
9
+ export interface Fact {
10
+ label: string
11
+ value: string
12
+ }
13
+
14
+ /** a link the hub opens. `host`: a page of the tool that frames the hub (pkm-multi's admin), its url relative to it:
15
+ * in a frame it is posted to the host (`{ shipkit: 'open', url }`), alone it opens in a new tab */
16
+ export interface Link {
17
+ label: string
18
+ url: string
19
+ host?: boolean
20
+ }
21
+
22
+ /** how a part's change reads: new, changed, gone or the same */
23
+ export type PartTone = 'new' | 'changed' | 'gone' | 'same'
24
+
25
+ /** one part of a subject the hub draws as a card of its own: a message of a post, a page of a script. `icon` an image
26
+ * url (a data url carries a logo), `media` a repo relative file the hub serves beside the text, `badge` its change */
27
+ export interface ViewPart {
28
+ label: string
29
+ detail?: string
30
+ icon?: string
31
+ badge?: { text: string; tone: PartTone }
32
+ media?: string
33
+ text: ViewText
34
+ }
35
+
36
+ /** a text the hub shows, or a change of one shown as a diff */
37
+ export type ViewText = { text: string } | { before: string; after: string }
38
+
39
+ /** one subject kind: its words, its key, near rule and problems (the model's), and its data view, all server side */
40
+ export interface KindDef<S extends Subject = Subject> extends SpecKind {
41
+ key?(s: S): string | null
42
+ near?(a: S, b: S): boolean
43
+ problems?(s: S): string[]
44
+ facts?(x: ReviewItem): Fact[]
45
+ links?(x: ReviewItem): Link[]
46
+ /** a url the hub opens in a frame: the subject live */
47
+ live?(x: ReviewItem): string | null
48
+ text?(x: ReviewItem): ViewText | null
49
+ /** the subject in parts, each a card: a post's messages, with their logo, media and change */
50
+ parts?(x: ReviewItem): ViewPart[]
51
+ /** the `sk review add` flags this kind reads, each with its help line: `--where <zone>:<x>,<y>` */
52
+ flags?: Record<string, string>
53
+ /** the subject (and its topic) a card of this kind gets from those flags, every flag as the list of its values;
54
+ * throws with the reason a flag is wrong */
55
+ parse?(flags: Record<string, string[]>): { subject: S; topic?: string }
56
+ }
57
+
58
+ /** what a rule or a source raises: an item to a lane, open from the start; `key` names what it is about */
59
+ export interface Raise {
60
+ title: string
61
+ what: string
62
+ lane: string
63
+ subject: Subject
64
+ key?: string
65
+ }
66
+
67
+ export interface AutoCtx {
68
+ items: readonly ReviewItem[]
69
+ now: Date
70
+ }
71
+
72
+ /** a rule on a clock: its answer is raised as an item, one open item a rule and key */
73
+ export interface AutoRule {
74
+ id: string
75
+ every: `${number}m`
76
+ when(ctx: AutoCtx): Raise | readonly Raise[] | null
77
+ }
78
+
79
+ /** what the repo adds to a view: its rings, the review light */
80
+ export interface ViewExtra {
81
+ marks?: ReviewMark[]
82
+ light?: 'review'
83
+ }
84
+
85
+ /** what a verb of the repo gets: its store and root, and its two outputs */
86
+ export interface VerbCtx {
87
+ dir: string
88
+ root: string
89
+ say(line: string): void
90
+ err(line: string): void
91
+ }
92
+
93
+ /** the live shots a draft card follows: its frames now, and the shot stopped when the card is ready */
94
+ export interface LiveShots {
95
+ frames(name: string): { image: string; history: { file: string; label: string }[] }
96
+ stop?(name: string, why: string): void
97
+ }
98
+
99
+ /** where items come from beyond this machine: `pull` writes each new one through raiseItem and returns their ids */
100
+ export interface ReviewSource {
101
+ id: string
102
+ pull(o: { dir: string; items: readonly ReviewItem[]; say: (line: string) => void }): Promise<string[]>
103
+ /** a test report deleted at its source; the store's folder goes after it */
104
+ remove?(x: ReviewItem): Promise<void>
105
+ /** what `show` prints of an item this source raised, beyond its words */
106
+ facts?(x: ReviewItem): Fact[]
107
+ }
108
+
109
+ export interface ReviewDef {
110
+ kinds?: Record<string, KindDef>
111
+ topics?: Record<string, SpecTopic>
112
+ dispatcher?: boolean
113
+ sources?: readonly ReviewSource[]
114
+ auto?: readonly AutoRule[]
115
+ /** a repo relative font file the animation's labels are drawn in */
116
+ anim?: { font?: string }
117
+ /** the `sk review add` and `add-view` flags of any kind (a ring over the subject), each with its help line */
118
+ flags?: Record<string, string>
119
+ /** what the repo checks and fills on a card before it is written (marks read from its own shots, the review light,
120
+ * a lane from a commit, a refused contact sheet); throws to refuse it */
121
+ add?(input: AddInput, flags: Record<string, string[]>): AddInput | Promise<AddInput>
122
+ /** the same on a view added to an open card */
123
+ addView?(view: ViewInput, flags: Record<string, string[]>): ViewExtra | Promise<ViewExtra>
124
+ /** a draft card's live shots: `sk review add --wip --live <name>`, `ready` stops the shot */
125
+ live?: LiveShots
126
+ /** the repo's own verbs under `sk review <verb>`: a report replayed in the game, the town composer */
127
+ verbs?: Record<string, { description: string; usage?: string; run(args: string[], ctx: VerbCtx): number | Promise<number> }>
128
+ }
129
+
130
+ export type ReviewUnit = ReviewDef & { kind: 'review' }
131
+
132
+ const WORD = /^[a-z][a-z0-9-]*$/
133
+
134
+ /** why a review unit cannot be read, or null */
135
+ export function reviewProblem(d: ReviewDef): string | null {
136
+ for (const [k, v] of Object.entries(d.kinds ?? {})) {
137
+ if (!WORD.test(k)) return `review: kind '${k}' is a lowercase word`
138
+ if (typeof v.label !== 'string' || v.label.trim() === '') return `review: kind '${k}' has its label`
139
+ for (const f of ['key', 'near', 'problems', 'facts', 'links', 'live', 'text'] as const)
140
+ if (v[f] !== undefined && typeof v[f] !== 'function') return `review: kind '${k}'.${f} is a function`
141
+ }
142
+ for (const [k, v] of Object.entries(d.topics ?? {})) {
143
+ if (!WORD.test(k)) return `review: topic '${k}' is a lowercase word`
144
+ if (typeof v.label !== 'string' || v.label.trim() === '') return `review: topic '${k}' has its label`
145
+ }
146
+ const ids = new Set<string>()
147
+ for (const r of d.auto ?? []) {
148
+ if (!AGENT_RE.test(r.id ?? '')) return `review: auto rule '${String(r.id)}' is a word (${AGENT_RE.source})`
149
+ if (ids.has(r.id)) return `review: two auto rules named '${r.id}'`
150
+ ids.add(r.id)
151
+ if (!/^\d+(\.\d+)?m$/.test(r.every ?? '')) return `review: auto rule '${r.id}' runs every '<minutes>m', like '10m'`
152
+ if (typeof r.when !== 'function') return `review: auto rule '${r.id}'.when is a function`
153
+ }
154
+ const core = new Set(['ls', 'add', 'add-view', 'show', 'inbox', 'seen', 'reply', 'withdraw', 'reopen', 'fixed', 'wontfix', 'claim', 'release', 'blocked', 'done', 'dispatcher', 'dispatch', 'ask', 'drop', 'reassign', 'next', 'topics', 'board', 'ready', 'delete', 'marks', 'anim', 'auto', 'watch'])
155
+ for (const [v, def] of Object.entries(d.verbs ?? {})) {
156
+ if (!WORD.test(v)) return `review: verb '${v}' is a lowercase word`
157
+ if (core.has(v)) return `review: verb '${v}' is one of sk review's own`
158
+ if (typeof def.run !== 'function') return `review: verb '${v}'.run is a function`
159
+ }
160
+ for (const [k, v] of Object.entries(d.kinds ?? {})) if (v.flags && typeof v.parse !== 'function') return `review: kind '${k}' reads flags, so it has a parse`
161
+ if (d.live && typeof d.live.frames !== 'function') return 'review: live.frames is a function'
162
+ for (const s of d.sources ?? []) {
163
+ if (!AGENT_RE.test(s.id ?? '')) return `review: source '${String(s.id)}' is a word`
164
+ if (typeof s.pull !== 'function') return `review: source '${s.id}'.pull is a function`
165
+ }
166
+ return null
167
+ }
168
+
169
+ export function review(d: ReviewDef): ReviewUnit {
170
+ const why = reviewProblem(d)
171
+ if (why !== null) throw new Error(why)
172
+ return { ...d, kind: 'review' }
173
+ }
174
+
175
+ /** the model's part of a unit: its kinds, topics and dispatcher switch */
176
+ export const specOfUnit = (u: ReviewDef | null): Spec => ({ kinds: u?.kinds ?? {}, topics: u?.topics ?? {}, dispatcher: u?.dispatcher === true })
177
+
178
+ /** the words of a spec the browser reads: no function crosses */
179
+ export const specData = (spec: Spec): Spec => ({
180
+ kinds: Object.fromEntries(Object.entries(spec.kinds).map(([k, v]) => [k, { label: v.label, ...(v.icon ? { icon: v.icon } : {}) }])),
181
+ topics: spec.topics,
182
+ dispatcher: spec.dispatcher,
183
+ })
@@ -0,0 +1,54 @@
1
+ // who listens: each `sk review watch` registers one file per process beside the store (`.shipkit/review-watchers/`),
2
+ // out of its watch, so a registration never wakes the watchers. A file alone is never trusted: its pid is probed
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import type { WatchFilter, Watcher } from './model.ts'
6
+
7
+ const watchersDir = (dir: string): string => join(dir, '..', 'review-watchers')
8
+
9
+ /** the process is there: a signal 0 reaches it, or it exists under another user */
10
+ export function pidAlive(pid: number): boolean {
11
+ try {
12
+ process.kill(pid, 0)
13
+ return true
14
+ } catch (e) {
15
+ return (e as NodeJS.ErrnoException).code === 'EPERM'
16
+ }
17
+ }
18
+
19
+ /** this process watches what `filter` keeps until it exits: its file goes on exit and on a signal */
20
+ export function registerWatch(dir: string, filter: WatchFilter, now = new Date()): void {
21
+ const folder = watchersDir(dir)
22
+ mkdirSync(folder, { recursive: true })
23
+ const file = join(folder, `${process.pid}.json`)
24
+ const w: Watcher = { pid: process.pid, session: filter.session, lane: filter.lane, started: now.toISOString() }
25
+ writeFileSync(`${file}.tmp`, `${JSON.stringify(w)}\n`)
26
+ renameSync(`${file}.tmp`, file)
27
+ const gone = (): void => rmSync(file, { force: true })
28
+ process.on('exit', gone)
29
+ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const)
30
+ process.on(sig, () => {
31
+ gone()
32
+ process.exit(0)
33
+ })
34
+ }
35
+
36
+ /** every live watcher, by pid; a file whose process is gone is deleted */
37
+ export function liveWatchers(dir: string, alive: (pid: number) => boolean = pidAlive): Watcher[] {
38
+ const folder = watchersDir(dir)
39
+ if (!existsSync(folder)) return []
40
+ const out: Watcher[] = []
41
+ for (const name of readdirSync(folder)) {
42
+ if (!name.endsWith('.json')) continue
43
+ const file = join(folder, name)
44
+ let w: Watcher | null = null
45
+ try {
46
+ w = JSON.parse(readFileSync(file, 'utf8')) as Watcher
47
+ } catch {
48
+ w = null
49
+ }
50
+ if (w && Number.isInteger(w.pid) && alive(w.pid)) out.push({ pid: w.pid, session: w.session ?? null, lane: w.lane ?? null, started: w.started })
51
+ else rmSync(file, { force: true })
52
+ }
53
+ return out.sort((a, b) => a.pid - b.pid)
54
+ }
@@ -0,0 +1,35 @@
1
+ // a repo's review or trailer page alone, for another tool to frame (wiki build/shipkit/review.md, The review page):
2
+ // `<base>/embed/?page=review` serves the same app bundled with RELATIVE asset paths, so the page and every request it
3
+ // makes work under any prefix a proxy puts in front of the hub (pkm-multi's admin frames it under its own origin). The
4
+ // bundle is made once a process, in memory
5
+ import { join } from 'node:path'
6
+
7
+ const INDEX = join(import.meta.dir, 'hub-app', 'index.html')
8
+
9
+ let bundle: Promise<Map<string, Blob>> | null = null
10
+
11
+ async function build(): Promise<Map<string, Blob>> {
12
+ const r = await Bun.build({ entrypoints: [INDEX], publicPath: './', target: 'browser', minify: true })
13
+ if (!r.success) throw new Error(r.logs.map(String).join('\n'))
14
+ return new Map(r.outputs.map((o) => [o.path.split('/').pop() ?? o.path, o]))
15
+ }
16
+
17
+ /** the embed page and its assets under `<base>/embed/`; null for any other path */
18
+ export async function embedRoute(path: string): Promise<Response | null> {
19
+ if (path === '/embed') return new Response(null, { status: 308, headers: { location: 'embed/' } })
20
+ if (!path.startsWith('/embed/')) return null
21
+ bundle ??= build().catch((e: Error) => {
22
+ bundle = null
23
+ throw e
24
+ })
25
+ let files: Map<string, Blob>
26
+ try {
27
+ files = await bundle
28
+ } catch (e) {
29
+ return new Response(`FAIL: the embed page does not build: ${(e as Error).message}`, { status: 500 })
30
+ }
31
+ const name = path.slice('/embed/'.length) || 'index.html'
32
+ const f = files.get(name)
33
+ if (!f) return new Response(`no ${name} in the embed page`, { status: 404 })
34
+ return new Response(f, { headers: { 'content-type': f.type || 'application/octet-stream', 'cache-control': name === 'index.html' ? 'no-cache' : 'private, max-age=86400' } })
35
+ }
@@ -32,7 +32,7 @@ const RepoName = observer(({ app, name }: { app: HomeSt; name: string }) => {
32
32
 
33
33
  /** a bar of exclusive buttons */
34
34
  const Bar = <T extends number>({ label, values, value, words, set }: { label: string; values: readonly T[]; value: number; words: (v: T) => string; set: (v: T) => void }) => (
35
- <div className="acwin" role="radiogroup" aria-label={label}>
35
+ <div className="seg" role="radiogroup" aria-label={label}>
36
36
  {values.map((v) => (
37
37
  <button key={v} role="radio" aria-checked={value === v} className={value === v ? 'on' : ''} onClick={() => set(v)}>{words(v)}</button>
38
38
  ))}
@@ -4,10 +4,6 @@
4
4
  on main, orange elsewhere; a commit orange until pushed; dirt red. */
5
5
  .activity{font-size:13px;container-type:inline-size}
6
6
  .actop{display:flex;align-items:center;flex-wrap:wrap;gap:var(--s3);margin-bottom:var(--s3)}
7
- .acwin{display:inline-flex;border:1px solid var(--line);border-radius:4px;overflow:hidden}
8
- .acwin button{border:0;border-radius:0;padding:var(--s0) var(--s2);background:none;color:var(--dim);font-size:12px}
9
- .acwin button:hover{color:var(--ink);background:var(--panel2)}
10
- .acwin button.on{color:var(--accent);background:var(--sel)}
11
7
  .accap{display:inline-flex;align-items:center;gap:var(--s1);font-size:12px}
12
8
  .accap input{width:44px;height:22px;box-sizing:border-box;background:var(--bg);color:var(--ink);border:1px solid var(--line);border-radius:4px;padding:var(--s0) var(--s1);font:inherit;font-variant-numeric:tabular-nums}
13
9
  .accap input:focus{outline:none;border-color:var(--accent)}
@@ -16,7 +16,7 @@ export class AdminSvc {
16
16
  /** the colours the preview keeps while a picker moves, so a drag draws a
17
17
  * few icons, not one per frame; null follows the repo's colours */
18
18
  held: Identity | null = null
19
- /** the repo has no png mark, or it would not draw: the app wears the default icon */
19
+ /** the repo has no mark, or it would not draw: the app wears the default icon */
20
20
  noIcon = false
21
21
  private rest: ReturnType<typeof setTimeout> | null = null
22
22
 
@@ -27,7 +27,7 @@ const IdentityBox = observer(({ app }: { app: RepoSt }) => {
27
27
  <div className="adicon">
28
28
  <img src={a.previewSrc} alt={`${name} app icon`} width={128} height={128} hidden={a.noIcon}
29
29
  onLoad={() => a.previewLoaded()} onError={() => a.previewFailed()} />
30
- {a.noIcon ? <span className="dim">no png mark (.shipkit/identity/icon.png, public/logo.png or public/favicon.png): its app wears the default icon</span> : null}
30
+ {a.noIcon ? <span className="dim">no mark (.shipkit/identity/icon.png or icon.svg, public/logo.png or public/favicon.png): its app wears the default icon</span> : null}
31
31
  </div>
32
32
  <div className="adtext">
33
33
  <b>identity</b>