rvlib-shipkit 2.3.0 → 2.5.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 (104) hide show
  1. package/CHANGELOG.md +19 -2
  2. package/package.json +2 -2
  3. package/src/assets/kits/identity-kit.ts +5 -2
  4. package/src/cli/cmd_Commands.ts +30 -5
  5. package/src/cli/cmd_FleetPush.ts +47 -0
  6. package/src/cli/cmd_GlobalProcesses.ts +5 -5
  7. package/src/cli/cmd_Review.ts +52 -11
  8. package/src/cli/cmd_Sound.ts +209 -0
  9. package/src/cli/cmd_Trailer.ts +11 -3
  10. package/src/cli/family-pages.ts +49 -0
  11. package/src/cli/kit/builtins.ts +1 -0
  12. package/src/cli/kit/verb.ts +11 -3
  13. package/src/cli/registry.ts +137 -0
  14. package/src/cli.ts +47 -133
  15. package/src/core/app-icon.ts +10 -6
  16. package/src/core/last-good.ts +217 -0
  17. package/src/core/load-probe.ts +71 -0
  18. package/src/core/reexec.ts +5 -3
  19. package/src/fleet/push-fleet.ts +80 -0
  20. package/src/home/home-routes.ts +12 -2
  21. package/src/home/lib-watch.ts +110 -20
  22. package/src/home/process-rows.ts +4 -2
  23. package/src/home/run-daemon.ts +1 -1
  24. package/src/repo-config.ts +3 -0
  25. package/src/review/format.ts +11 -3
  26. package/src/review/model.ts +66 -5
  27. package/src/review/paste.ts +30 -0
  28. package/src/review/project.ts +20 -4
  29. package/src/review/routes.ts +22 -4
  30. package/src/review/store.ts +15 -6
  31. package/src/review/unit.ts +19 -1
  32. package/src/scaffold/files/agent-index.md +2 -2
  33. package/src/scaffold/project-scaffold.ts +2 -2
  34. package/src/serve/hub-app/activity-st.ts +31 -0
  35. package/src/serve/hub-app/activity-view.tsx +9 -0
  36. package/src/serve/hub-app/activity.css +6 -0
  37. package/src/serve/hub-app/admin-svc.ts +1 -1
  38. package/src/serve/hub-app/admin-view.tsx +1 -1
  39. package/src/serve/hub-app/hash-route.ts +12 -4
  40. package/src/serve/hub-app/home-st.ts +7 -1
  41. package/src/serve/hub-app/home-strip.tsx +3 -1
  42. package/src/serve/hub-app/hub.css +38 -1
  43. package/src/serve/hub-app/icons.tsx +6 -0
  44. package/src/serve/hub-app/layout-st.ts +2 -2
  45. package/src/serve/hub-app/machine-st.ts +1 -1
  46. package/src/serve/hub-app/md.ts +32 -0
  47. package/src/serve/hub-app/project-list.css +9 -7
  48. package/src/serve/hub-app/project-list.tsx +5 -2
  49. package/src/serve/hub-app/projects-svc.ts +3 -2
  50. package/src/serve/hub-app/repo-st.ts +22 -7
  51. package/src/serve/hub-app/review/clip-st.ts +57 -7
  52. package/src/serve/hub-app/review/kind-view.tsx +78 -9
  53. package/src/serve/hub-app/review/review-answer.tsx +67 -58
  54. package/src/serve/hub-app/review/review-page.tsx +17 -15
  55. package/src/serve/hub-app/review/review-st.ts +191 -20
  56. package/src/serve/hub-app/review/review-views.tsx +154 -9
  57. package/src/serve/hub-app/review.css +73 -13
  58. package/src/serve/hub-app/wiki-st.ts +190 -0
  59. package/src/serve/hub-app/wikis-view.tsx +134 -5
  60. package/src/serve/hub-runtime.ts +4 -4
  61. package/src/serve/project-gutter-route.ts +25 -1
  62. package/src/serve/project-gutter.ts +1 -1
  63. package/src/serve/run-serve.ts +25 -0
  64. package/src/serve/serve-hub.ts +15 -1
  65. package/src/serve/stale-restart.ts +19 -2
  66. package/src/shot/client.ts +17 -5
  67. package/src/shot/shooter.ts +19 -3
  68. package/src/shot/shot-ask.ts +9 -1
  69. package/src/shot/stepped-clip.ts +53 -0
  70. package/src/trailer/adapter.ts +33 -4
  71. package/src/trailer/build.ts +182 -33
  72. package/src/trailer/ffmpeg.ts +21 -1
  73. package/src/trailer/fonts.ts +55 -0
  74. package/src/trailer/footage.ts +4 -4
  75. package/src/trailer/grammar.ts +429 -67
  76. package/src/trailer/graph.ts +130 -56
  77. package/src/trailer/look.ts +3 -0
  78. package/src/trailer/motion.ts +207 -0
  79. package/src/trailer/plan.ts +2 -2
  80. package/src/trailer/prune.ts +13 -2
  81. package/src/trailer/score/compose.ts +18 -2
  82. package/src/trailer/score/gm-render.ts +43 -0
  83. package/src/trailer/score/gm.ts +90 -0
  84. package/src/trailer/score/midi.ts +143 -0
  85. package/src/trailer/score/pattern.ts +13 -6
  86. package/src/trailer/score/score.ts +28 -14
  87. package/src/trailer/score/sound.ts +12 -1
  88. package/src/trailer/score/synth.ts +24 -2
  89. package/src/trailer/sound/audio.ts +148 -0
  90. package/src/trailer/sound/brand.ts +42 -0
  91. package/src/trailer/sound/comfy.ts +38 -0
  92. package/src/trailer/sound/eleven.ts +72 -0
  93. package/src/trailer/sound/fluid.ts +71 -0
  94. package/src/trailer/sound/gen.ts +139 -0
  95. package/src/trailer/sound/index.ts +60 -0
  96. package/src/trailer/sound/presets.ts +69 -0
  97. package/src/trailer/sound/run.ts +189 -0
  98. package/src/trailer/titles.ts +451 -235
  99. package/src/trailer/url.ts +18 -3
  100. package/src/trailer/vocab.ts +12 -1
  101. package/src/trailer/words.ts +29 -8
  102. package/src/wiki/graph-mount.ts +3 -0
  103. package/src/wiki/graph-page.ts +45 -9
  104. package/src/wiki/graph-pages.ts +128 -0
@@ -7,6 +7,7 @@ import { existsSync } from 'fs'
7
7
  import { dirname, join } from 'path'
8
8
  import { fileURLToPath } from 'url'
9
9
  import { installedVersion, isBehind } from '../fleet/scan-fleet.ts'
10
+ import { heldNow, heldText } from '../core/last-good.ts'
10
11
  import { LIB_VERSION } from '../core/lib-version.ts'
11
12
  import { projectGutter, type ProjectRow } from '../serve/project-gutter-route.ts'
12
13
  import type { WiredRepo } from '../serve/project-gutter.ts'
@@ -20,6 +21,7 @@ import { ActivityCache, repoActivity, windowMinutes } from './activity.ts'
20
21
  import { devRepos, InsightsCache, type Scope } from './insights.ts'
21
22
  import { GLOBAL_CONTEXT_EVERY_MS, houseContext, writeIfChanged } from './global-context.ts'
22
23
  import { HouseRuns } from '../serve/house-runs.ts'
24
+ import { pushFleet } from '../fleet/push-fleet.ts'
23
25
 
24
26
  /** how far back a repo's git page lists its commits */
25
27
  export const GIT_PAGE_MS = 7 * 24 * 3600_000
@@ -38,6 +40,8 @@ export type HomeAnswer = {
38
40
  /** repo → its installed shipkit, for every repo behind this home's */
39
41
  behind: Record<string, string>
40
42
  modernize: Record<string, ModernizeView>
43
+ /** the daemon runs older code than its tree, and why (core/last-good.ts) */
44
+ held?: string | null
41
45
  }
42
46
 
43
47
  export type HomeDeps = {
@@ -156,7 +160,8 @@ export function homeRoutes(d: HomeDeps) {
156
160
  }
157
161
  const modernize: Record<string, ModernizeView> = {}
158
162
  for (const [k, m] of modernizing) modernize[k] = { status: m.status, code: m.code, tail: tailLines(m.log, 40) }
159
- return { version: LIB_VERSION, workers: d.hubs.views(repos), active: d.state.data.active, behind, modernize }
163
+ const h = heldNow()
164
+ return { version: LIB_VERSION, workers: d.hubs.views(repos), active: d.state.data.active, behind, modernize, held: h == null ? null : heldText(h) }
160
165
  }
161
166
 
162
167
  const find = (name: unknown): WiredRepo | null => repos.find((r) => r.name === name) ?? null
@@ -171,9 +176,14 @@ export function homeRoutes(d: HomeDeps) {
171
176
  }
172
177
  if (path === '/hub.json')
173
178
  // the identity a client probes (homeUp), through the web or here
174
- return Response.json({ hub: 'shipkit-home', name: d.name, root: d.root, version: LIB_VERSION }, { headers: { 'access-control-allow-origin': '*' } })
179
+ return Response.json({ hub: 'shipkit-home', name: d.name, root: d.root, version: LIB_VERSION, held: heldNow() == null ? null : heldText(heldNow()!) }, { headers: { 'access-control-allow-origin': '*' } })
175
180
  if (!sameOrigin(req)) return new Response('refused: a request that changes something must come from the home page', { status: 403 })
176
181
  if (path.startsWith('/api/projects')) return d.gutter.handle(req, path)
182
+ // the activity page's push button: every house repo with unpushed commits, never a force (fleet/push-fleet.ts)
183
+ if (path === '/api/home/push-all' && req.method === 'POST') {
184
+ if (repos.length === 0) await refresh()
185
+ return Response.json(await pushFleet(repos.map((r) => ({ name: r.name, path: r.path }))))
186
+ }
177
187
  // the web asks which page routes to serve: one per house repo
178
188
  if (path === '/api/home/repos.json') {
179
189
  if (repos.length === 0) await refresh()
@@ -5,10 +5,14 @@
5
5
  // started through cli.ts holds every command (one edit would restart all
6
6
  // three). This watch covers the lib source, filtered to the files the role's
7
7
  // own entry module reaches, and says a change once the burst of writes settles.
8
- import { existsSync, readFileSync, watch, type FSWatcher } from 'fs'
8
+ // A restart lands only on code that loads (core/load-probe.ts): while a file
9
+ // of the tree fails, the role keeps its running image and says why once. The
10
+ // daemon also keeps the last good copy of the tree (core/last-good.ts).
11
+ import { existsSync, readFileSync, realpathSync, statSync, watch, type FSWatcher } from 'fs'
9
12
  import { dirname, extname, join, relative, resolve } from 'path'
10
- import { LIB_ROOT } from '../core/lib-root.ts'
11
- import { diskLibVersion, LIB_VERSION } from '../core/lib-version.ts'
13
+ import { copyStamp, heldNow, homeLib, LAST_GOOD_ENV, lastGood, OWN_LIB, setHeld, takeLastGood } from '../core/last-good.ts'
14
+ import { LIB_VERSION } from '../core/lib-version.ts'
15
+ import { bootEntries, probeLoad, TreeProbe } from '../core/load-probe.ts'
12
16
  import { reexecBlocker, reexecSelf } from '../core/reexec.ts'
13
17
  import { StaleRestart } from '../serve/stale-restart.ts'
14
18
 
@@ -56,6 +60,9 @@ export function libGraph(entry: string, o: { bundles?: boolean } = {}): Set<stri
56
60
  export class LibWatch {
57
61
  private w: FSWatcher | null = null
58
62
  private last: { file: string; at: number } | null = null
63
+ private any: { file: string; at: number } | null = null
64
+ /** every code write under src, own or not */
65
+ seq = 0
59
66
  readonly error: string | null = null
60
67
  private now: () => number
61
68
  private settleMs: number
@@ -64,11 +71,25 @@ export class LibWatch {
64
71
  constructor(private root: string, o: { settleMs?: number; now?: () => number; only?: Set<string> } = {}) {
65
72
  this.now = o.now ?? Date.now
66
73
  this.settleMs = o.settleMs ?? 1_000
74
+ // an APFS clone of the tree (the last good copy, a scratch checkout)
75
+ // fires a change on every source file it reads: only a moved mtime,
76
+ // or a file gone, is a write
77
+ const mtimes = new Map<string, number>()
78
+ const mtimeOf = (f: string): number | null => {
79
+ try { return statSync(f).mtimeMs } catch { return null }
80
+ }
81
+ for (const f of new Bun.Glob('**/*').scanSync(join(root, 'src'))) if (CODE.test(f)) mtimes.set(join(root, 'src', f), mtimeOf(join(root, 'src', f)) ?? -1)
67
82
  try {
68
83
  this.w = watch(join(root, 'src'), { recursive: true }, (_ev, file) => {
69
84
  if (file == null || !CODE.test(String(file))) return
85
+ const full = join(root, 'src', String(file))
86
+ const m = mtimeOf(full)
87
+ if (m != null && m === mtimes.get(full)) return
88
+ mtimes.set(full, m ?? -1)
89
+ this.seq++
90
+ this.any = { file: String(file), at: this.now() }
70
91
  if (o.only != null && !o.only.has(join(root, 'src', String(file)))) return
71
- this.last = { file: String(file), at: this.now() }
92
+ this.last = this.any
72
93
  })
73
94
  } catch (e) {
74
95
  this.error = (e as Error).message
@@ -86,41 +107,92 @@ export class LibWatch {
86
107
  return `the shipkit source moved (src/${this.last.file})`
87
108
  }
88
109
 
110
+ /** no write anywhere under src for settleMs */
111
+ settled(): boolean {
112
+ return this.any == null || this.now() - this.any.at >= this.settleMs
113
+ }
114
+
115
+ /** one line on the last write anywhere under src, once settled, else null */
116
+ whyAny(): string | null {
117
+ return this.any == null || !this.settled() ? null : `the shipkit source moved (src/${this.any.file})`
118
+ }
119
+
89
120
  close(): void {
90
121
  this.w?.close()
91
122
  this.w = null
92
123
  }
93
124
  }
94
125
 
126
+ /** how often the daemon may probe the tree to keep its last good copy */
127
+ export const KEEP_EVERY_MS = 60_000
128
+
129
+ const realOf = (p: string): string => {
130
+ try { return realpathSync(p) } catch { return p }
131
+ }
132
+
133
+ const versionAt = (root: string): string => {
134
+ try { return (JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version?: string }).version ?? '?' } catch { return '?' }
135
+ }
136
+
95
137
  /** a machine process restarts itself in place, same pid, once its own code
96
- * moved and nothing it runs is in flight. `before` tears down what must not
97
- * outlive the image (its server); what it started detached keeps running */
138
+ * moved, nothing it runs is in flight and the new code loads. `before`
139
+ * tears down what must not outlive the image (its server); what it started
140
+ * detached keeps running. On the last good copy it watches the tree and
141
+ * goes back to it once it loads. `keep`: this process keeps the copy */
98
142
  export function reloadOnOwnCode(o: {
99
- role: string
143
+ role: 'pm' | 'daemon' | 'web'
100
144
  /** the role's runner module: its graph is what the role reloads on */
101
145
  entry: string
102
146
  bundles?: boolean
103
147
  busy?: () => boolean
104
148
  before: () => void
149
+ keep?: boolean
105
150
  io: { print: (l: string) => void; printErr: (l: string) => void }
106
151
  }): { stop: () => void; files: number } {
107
- const graph = libGraph(o.entry, { bundles: o.bundles })
108
- const lib = new LibWatch(LIB_ROOT, { only: graph })
152
+ const onCopy = copyStamp(OWN_LIB) != null
153
+ const tree = homeLib()
154
+ const graph = libGraph(onCopy ? join(tree, relative(OWN_LIB, o.entry)) : o.entry, { bundles: o.bundles })
155
+ const lib = new LibWatch(tree, { only: onCopy ? undefined : graph })
109
156
  if (lib.error != null) o.io.printErr(`WARN no watch on the shipkit source (${lib.error}), so an edit of the ${o.role} needs a restart of it`)
157
+ const probe = new TreeProbe(() => probeLoad(tree, bootEntries(tree, o.role)), () => `${lib.seq}|${versionAt(tree)}`)
158
+ let since: number | null = null
159
+ let said = ''
160
+ const hold = (v: { file: string; error: string } | null): void => {
161
+ if (v == null) {
162
+ if (since != null) o.io.print(`ok: the shipkit source loads again`)
163
+ since = null
164
+ said = ''
165
+ if (!onCopy) setHeld(null)
166
+ return
167
+ }
168
+ since ??= heldNow()?.since ?? Date.now()
169
+ const text = `${v.file} fails to load (${v.error})`
170
+ if (text !== said) {
171
+ said = text
172
+ o.io.printErr(`FAIL: ${text}: sk runs the last good copy, and the ${o.role} keeps its running code until the tree loads again`)
173
+ }
174
+ setHeld({ since, file: v.file, error: v.error, from: tree, copyAt: lastGood(tree)?.at ?? null })
175
+ }
110
176
  const stale = new StaleRestart({
111
177
  boot: LIB_VERSION,
112
- disk: diskLibVersion,
113
- moved: () => lib.why(),
178
+ disk: () => versionAt(tree),
179
+ // on the copy, any write may heal the tree, and the first look is at once
180
+ moved: () => (onCopy ? (lib.whyAny() ?? (lib.seq === 0 ? 'this process runs the last good copy' : null)) : lib.why()),
114
181
  busy: o.busy ?? (() => false),
182
+ loads: () => probe.loads(),
183
+ hold,
115
184
  restart: (why) => {
116
185
  const blocked = reexecBlocker()
117
186
  if (blocked != null) throw new Error(blocked)
118
- o.io.print(`ok: ${why}: the ${o.role} restarts in place`)
119
- stale.stop()
120
- lib.close()
187
+ o.io.print(`ok: ${why}: the ${o.role} restarts in place${onCopy ? ' on the tree' : ''}`)
188
+ stop()
121
189
  o.before()
122
190
  try {
123
- reexecSelf()
191
+ if (!onCopy) reexecSelf()
192
+ const env = { ...process.env }
193
+ delete env[LAST_GOOD_ENV]
194
+ // the same script in the tree: cli.ts for most roles, run-pm.ts for pm
195
+ reexecSelf(env, join(tree, relative(realOf(OWN_LIB), realOf(process.argv[1] ?? join(OWN_LIB, 'src/cli.ts')))))
124
196
  } catch (e) {
125
197
  o.io.printErr(`FAIL: the ${o.role} stopped to restart and could not: ${(e as Error).message}`)
126
198
  process.exit(1)
@@ -128,12 +200,30 @@ export function reloadOnOwnCode(o: {
128
200
  },
129
201
  say: (text) => { if (text != null) o.io.printErr(`WARN ${text}`) },
130
202
  })
203
+ // the daemon keeps the copy: after a settled burst that loads, at most every KEEP_EVERY_MS
204
+ // once a minute counts from the last copy's own stamp (a restart keeps it), or from a try that failed
205
+ let kept = -1
206
+ let triedAt = 0
207
+ const keeper = o.keep === true && !onCopy
208
+ ? setInterval(() => {
209
+ if (lib.seq === kept || !lib.settled() || Date.now() - Math.max(lastGood(tree)?.at ?? 0, triedAt) < KEEP_EVERY_MS) return
210
+ const v = probe.loads()
211
+ if (v === 'pending') return
212
+ if (v !== 'ok') return hold(v)
213
+ hold(null)
214
+ kept = lib.seq
215
+ triedAt = Date.now()
216
+ void takeLastGood(tree).catch((e: Error) => o.io.printErr(`WARN the last good copy of the shipkit source could not be kept: ${e.message}`))
217
+ }, 2_000)
218
+ : null
219
+ function stop(): void {
220
+ stale.stop()
221
+ lib.close()
222
+ if (keeper != null) clearInterval(keeper)
223
+ }
131
224
  stale.start()
132
225
  return {
133
- files: [...graph].filter((f) => !relative(LIB_ROOT, f).startsWith('..')).length,
134
- stop: () => {
135
- stale.stop()
136
- lib.close()
137
- },
226
+ files: [...graph].filter((f) => !relative(tree, f).startsWith('..')).length,
227
+ stop,
138
228
  }
139
229
  }
@@ -146,6 +146,8 @@ export const CORE_ABOUT = {
146
146
  export type CoreOpts = {
147
147
  /** the daemon's own version, asked of the daemon; null when it does not answer */
148
148
  daemonVersion?: string | null
149
+ /** the daemon runs older code than its tree, and why; null when it does not */
150
+ daemonHeld?: string | null
149
151
  /** where each listens, when pm does not say (an older pm, or pm down) */
150
152
  ports?: CorePorts | null
151
153
  /** one of shipkit's own not answering the page, and why: its row says
@@ -174,9 +176,9 @@ export function coreGroup(pm: PmAnswer | null, o: CoreOpts = {}): ProcessGroup {
174
176
  const c = pm?.children.find((x) => x.id === id)
175
177
  const port = ports?.[id] ?? null
176
178
  const version = id === 'daemon' ? (o.daemonVersion ?? null) : null
177
- if (c == null) rows.push(coreRow(id, { status: 'unknown', pid: null, since: null, note: null, tail: [], version, port, restart: false }))
179
+ if (c == null) rows.push(coreRow(id, { status: 'unknown', pid: null, since: null, note: id === 'daemon' ? (o.daemonHeld ?? null) : null, tail: [], version, port, restart: false }))
178
180
  else {
179
- const note = [c.note, c.restarts > 0 ? `${c.restarts} restart(s)` : null].filter((x) => x != null).join(', ')
181
+ const note = [id === 'daemon' ? (o.daemonHeld ?? null) : null, c.note, c.restarts > 0 ? `${c.restarts} restart(s)` : null].filter((x) => x != null).join(', ')
180
182
  rows.push(coreRow(id, { status: c.status, pid: c.pid, since: c.since, note: note === '' ? null : note, tail: c.tail, version, port, restart: true }))
181
183
  }
182
184
  }
@@ -115,7 +115,7 @@ export async function runDaemon(root: string, io: HomeIo, o: { port?: number } =
115
115
  // the rows every gate filed land here, one commit a day at most
116
116
  also: (now) => { for (const n of landMotherhome({ now })) io.printErr(n) },
117
117
  })
118
- const code = reloadOnOwnCode({ role: 'daemon', entry: fileURLToPath(import.meta.url), busy: () => hubs.busy(), before: () => { sweeps.stop(); home.stop() }, io })
118
+ const code = reloadOnOwnCode({ role: 'daemon', entry: fileURLToPath(import.meta.url), busy: () => hubs.busy(), before: () => { sweeps.stop(); home.stop() }, keep: true, io })
119
119
  io.print(` ${house.repos.length} repos, state in ${dir}, reloads on ${code.files} lib files`)
120
120
  for (const sig of ['SIGINT', 'SIGTERM'] as const)
121
121
  process.on(sig, () => {
@@ -57,6 +57,9 @@ export type ServeConfig = {
57
57
  * one failed, green when all passed. Default: the built-in md lint
58
58
  * (orphans and dangling refs) over the repo root */
59
59
  lint?: string[]
60
+ /** the folder its node ids are relative to, where the wikis page reads
61
+ * a page to search and preview (default: the repo root) */
62
+ dir?: string
60
63
  }
61
64
  /** agent/ graph entrypoint (default: index.md AND README.md) */
62
65
  agentIndex?: string
@@ -19,7 +19,13 @@ const atText = (t: number | undefined): string => (t === undefined ? '' : ` at $
19
19
 
20
20
  /** each reaction to one view or tile: its place by label, its status, its comment */
21
21
  const reactionTexts = (x: ReviewItem): string[] =>
22
- (verdictOf(x)?.reactions ?? []).map((q) => `${placeText(q, x.item.views)}${q.status ? ` ${q.status}` : ''}${q.comment ? `: ${flat(q.comment)}` : ''}`)
22
+ (verdictOf(x)?.reactions ?? []).map((q) => `${placeText(q, x.item.views)}${q.status ? ` ${q.status}` : ''}${q.stars ? ` ★${q.stars}` : ''}${q.comment ? `: ${flat(q.comment)}` : ''}`)
23
+
24
+ /** the view he picked on a choice card, `picked [voice 1]`; null when he picked none */
25
+ const pickText = (x: ReviewItem): string | null => {
26
+ const p = verdictOf(x)?.pick
27
+ return p === undefined ? null : `picked ${placeText({ view: p }, x.item.views)}`
28
+ }
23
29
 
24
30
  /** one line per feedback, for a monitor: the status, the overall comment, each view's reaction, then each rectangle's comment */
25
31
  export function line(x: ReviewItem): string {
@@ -28,7 +34,8 @@ export function line(x: ReviewItem): string {
28
34
  const views = x.item.views
29
35
  const many = views.length > 1
30
36
  const rects = f.rects.map((q, i) => `#${i + 1}${many ? ` ${placeText(q, views)}` : ''}${q.side ? ` ${q.side}` : ''}${atText(q.t)} ${flat(q.comment) || '(no comment)'}`)
31
- const parts = [...reactionTexts(x), ...rects]
37
+ const pick = pickText(x)
38
+ const parts = [...(pick ? [pick] : []), ...reactionTexts(x), ...rects]
32
39
  return `${x.item.id} ${f.status} "${x.item.title}": ${flat(f.comment) || '(no overall comment)'}${parts.length ? ` | ${parts.join(' | ')}` : ''}`
33
40
  }
34
41
 
@@ -131,7 +138,8 @@ export function detail(x: ReviewItem, dir: string, full = false, followed = fals
131
138
  ].join('\n')
132
139
  }
133
140
  if (!f) return [`${r.id} NO VERDICT YET`, head, ...sub, ...replies].join('\n')
134
- const out = [`${r.id} ${f.status.toUpperCase()} ${f.at}`, head, ...sub, ` comment: ${f.comment || '(none)'}`]
141
+ const pick = pickText(x)
142
+ const out = [`${r.id} ${f.status.toUpperCase()} ${f.at}`, head, ...sub, ...(pick ? [` ${pick}`] : []), ` comment: ${f.comment || '(none)'}`]
135
143
  for (const t of reactionTexts(x)) out.push(` view ${t}`)
136
144
  const px = (v: number, of: number): number => Math.round(v * of)
137
145
  const views = r.views
@@ -124,6 +124,8 @@ export interface MediaView {
124
124
  clip?: ReviewClip
125
125
  audio?: ReviewAudio
126
126
  marks?: ReviewMark[]
127
+ /** a line on this view alone (an option's description on a choice card) */
128
+ note?: string
127
129
  }
128
130
 
129
131
  /** one image of a gallery view, a file of the folder (`v<n>-g<k>.<ext>`) */
@@ -168,6 +170,39 @@ export interface Item {
168
170
  light?: 'review'
169
171
  /** a draft card of a repo that shoots live: no verdict until it is ready */
170
172
  wip?: { live: string; frame: number }
173
+ /** a choice: every view is an option, he scores them and picks one */
174
+ choice?: true
175
+ }
176
+
177
+ /** a choice card: he picks one of its views */
178
+ export const isChoice = (i: Item): boolean => i.choice === true
179
+
180
+ /** a view that plays on its own: one clip or one sound, never a pair */
181
+ export const isPlayable = (v: ReviewView | undefined): v is MediaView => v !== undefined && !isGallery(v) && !isPair(v.files) && (v.clip !== undefined || v.audio !== undefined)
182
+
183
+ /** a card shown as options, one thin row a view: a choice, or two views or more that each play on their own */
184
+ export const asOptions = (i: Item): boolean => isChoice(i) || (i.views.length >= 2 && i.views.every(isPlayable))
185
+
186
+ /** the most stars a reaction gives */
187
+ export const STARS = 5
188
+
189
+ /** where a card is, for a fresh agent session in any repo: the repo as `~/dev/<repo>`, the card's url where it is open
190
+ * now, and its phone url when the repo has a public host */
191
+ export interface CardWhere {
192
+ root: string
193
+ open: string
194
+ phone?: string | null
195
+ }
196
+
197
+ /** one paste that hands a card to any agent session: what it is, where, the command that shows it whole, its links */
198
+ export function pasteBlock(i: Item, state: string, w: CardWhere): string {
199
+ return [
200
+ `review card "${i.title}" (${state}), id ${i.id}`,
201
+ `repo: ${w.root}`,
202
+ `see it whole: cd ${w.root} && sk review show ${i.id}`,
203
+ `open: ${w.open}`,
204
+ ...(w.phone ? [`phone: ${w.phone}`] : []),
205
+ ].join('\n')
171
206
  }
172
207
 
173
208
  /** a view's name as the page and the CLI show it: its label, or « view n » */
@@ -219,6 +254,8 @@ export interface ReviewReaction {
219
254
  tile?: number
220
255
  status?: ReviewStatus
221
256
  comment: string
257
+ /** his score of the view, 1 to STARS */
258
+ stars?: number
222
259
  }
223
260
 
224
261
  /** a state change, or a read mark (`seen`, with the ids it read in `of`) */
@@ -238,6 +275,8 @@ export interface Message {
238
275
  rects?: Rect[]
239
276
  reactions?: ReviewReaction[]
240
277
  verdict?: ReviewStatus
278
+ /** the view he picked on a choice card, its index */
279
+ pick?: number
241
280
  /** a repo's own mark on a subject (stars, a song's mark): kept as written */
242
281
  rate?: { stars?: number; mark?: string; part?: string }
243
282
  op?: Op
@@ -313,6 +352,8 @@ export interface Verdict {
313
352
  comment: string
314
353
  rects: Rect[]
315
354
  reactions?: ReviewReaction[]
355
+ /** the view he picked, on a choice card */
356
+ pick?: number
316
357
  at: string
317
358
  seen: boolean
318
359
  }
@@ -338,6 +379,7 @@ export function verdictOf(x: ReviewItem): Verdict | null {
338
379
  comment: text ?? '',
339
380
  rects: rects ?? [],
340
381
  ...(m.reactions && m.reactions.length > 0 ? { reactions: m.reactions } : {}),
382
+ ...(m.pick !== undefined ? { pick: m.pick } : {}),
341
383
  at: m.at,
342
384
  seen: readBy(x, 'agent').has(m.id),
343
385
  }
@@ -677,6 +719,7 @@ export function itemProblems(v: unknown): string[] {
677
719
  for (const k of ['what', 'commit', 'supersedes', 'why', 'keeps']) if (v[k] !== undefined && typeof v[k] !== 'string') out.push(`${k}: a text`)
678
720
  if (v.light !== undefined && v.light !== 'review') out.push('light: review, or absent')
679
721
  if (v.wip !== undefined && !(obj(v.wip) && typeof v.wip.live === 'string' && v.wip.live !== '' && Number.isInteger(v.wip.frame))) out.push('wip: { live, frame }, or absent')
722
+ if (v.choice !== undefined && !(v.choice === true && Array.isArray(v.views) && v.views.length >= 2)) out.push('choice: true on a card of two views or more, or absent')
680
723
  return out
681
724
  }
682
725
 
@@ -690,6 +733,7 @@ export function messageProblems(v: unknown, name: string): string[] {
690
733
  if (v.op !== undefined && !OPS.includes(v.op as Op)) out.push(`op: one of ${OPS.join(', ')}`)
691
734
  if (v.verdict !== undefined && !STATUSES.includes(v.verdict as ReviewStatus)) out.push(`verdict: one of ${STATUSES.join(', ')}`)
692
735
  for (const k of ['text', 'image', 'note']) if (v[k] !== undefined && typeof v[k] !== 'string') out.push(`${k}: a text`)
736
+ if (v.pick !== undefined && !(Number.isInteger(v.pick) && (v.pick as number) >= 0 && v.verdict !== undefined)) out.push('pick: a view index, on a verdict')
693
737
  if (v.op === 'seen' && !(Array.isArray(v.of) && v.of.every((x) => typeof x === 'string'))) out.push('of: the ids a seen message read')
694
738
  if (v.op === undefined && v.verdict === undefined && v.rate === undefined && typeof v.text !== 'string' && !Array.isArray(v.rects))
695
739
  out.push('a message says something: text, rects, verdict, rate or op')
@@ -713,6 +757,7 @@ function galleryProblems(v: Record<string, unknown>, at: string): string[] {
713
757
  function viewProblems(v: Record<string, unknown>, at: string): string[] {
714
758
  const out: string[] = []
715
759
  if (typeof v.label !== 'string') out.push(`${at}label: a text`)
760
+ if (v.note !== undefined && typeof v.note !== 'string') out.push(`${at}note: a text`)
716
761
  const f = v.files
717
762
  const okImage = (n: unknown): boolean => typeof n === 'string' && FILE_RE.test(n) && !isClipFile(n) && !isAudioFile(n)
718
763
  const okClip = (n: unknown): boolean => typeof n === 'string' && FILE_RE.test(n) && isClipFile(n)
@@ -757,7 +802,7 @@ function viewProblems(v: Record<string, unknown>, at: string): string[] {
757
802
  }
758
803
 
759
804
  /** the verdict the page sends for an item, checked: every rectangle inside its view, a side exactly on a pair */
760
- export function verdictMessageOf(body: unknown, item: Item): { ok: true; message: Pick<Message, 'verdict' | 'text' | 'rects' | 'reactions'> } | { ok: false; errors: string[] } {
805
+ export function verdictMessageOf(body: unknown, item: Item): { ok: true; message: Pick<Message, 'verdict' | 'text' | 'rects' | 'reactions' | 'pick'> } | { ok: false; errors: string[] } {
761
806
  if (!obj(body)) return { ok: false, errors: ['not an object'] }
762
807
  const errors: string[] = []
763
808
  if (!STATUSES.includes(body.status as ReviewStatus)) errors.push(`status: one of ${STATUSES.join(', ')}`)
@@ -800,7 +845,13 @@ export function verdictMessageOf(body: unknown, item: Item): { ok: true; message
800
845
  if (clip && typeof t === 'number') rect.t = t
801
846
  out.push(rect)
802
847
  }
803
- const reactions = reactionsOf(body.reactions, views, errors)
848
+ const reactions = reactionsOf(body.reactions, views, errors, isChoice(item))
849
+ const pick = body.pick
850
+ if (pick !== undefined) {
851
+ if (!isChoice(item)) errors.push('pick: only on a choice card')
852
+ else if (!(typeof pick === 'number' && Number.isInteger(pick) && pick >= 0 && pick < views.length)) errors.push(`pick: the index of one of the card's ${views.length} views`)
853
+ else if (!isApproved(body.status as ReviewStatus)) errors.push('pick: goes with approved or approved-with-fix; rejecting every option is a plain rejected')
854
+ }
804
855
  if (errors.length > 0) return { ok: false, errors }
805
856
  const comment = typeof body.comment === 'string' ? body.comment : ''
806
857
  return {
@@ -810,13 +861,14 @@ export function verdictMessageOf(body: unknown, item: Item): { ok: true; message
810
861
  ...(comment ? { text: comment } : {}),
811
862
  ...(out.length > 0 ? { rects: out } : {}),
812
863
  ...(reactions.length > 0 ? { reactions } : {}),
864
+ ...(typeof pick === 'number' ? { pick } : {}),
813
865
  },
814
866
  }
815
867
  }
816
868
 
817
869
  /** the reactions the page sends, checked against the card's views: each on a view, a tile only on a gallery, one per view
818
870
  * or tile; an empty one is dropped */
819
- function reactionsOf(v: unknown, views: readonly ReviewView[], errors: string[]): ReviewReaction[] {
871
+ function reactionsOf(v: unknown, views: readonly ReviewView[], errors: string[], choice: boolean): ReviewReaction[] {
820
872
  if (v === undefined) return []
821
873
  if (!Array.isArray(v)) {
822
874
  errors.push('reactions: a list')
@@ -835,12 +887,21 @@ function reactionsOf(v: unknown, views: readonly ReviewView[], errors: string[])
835
887
  if (tile !== undefined && !(typeof tile === 'number' && Number.isInteger(tile) && tile >= 0 && tile < tiles)) errors.push(`reactions[${i}].tile: the index of one tile of a gallery view`)
836
888
  if (r.status !== undefined && !STATUSES.includes(r.status as ReviewStatus)) errors.push(`reactions[${i}].status: one of ${STATUSES.join(', ')}`)
837
889
  if (r.comment !== undefined && typeof r.comment !== 'string') errors.push(`reactions[${i}].comment: a text`)
890
+ const stars = r.stars
891
+ if (stars !== undefined && !(choice && typeof stars === 'number' && Number.isInteger(stars) && stars >= 1 && stars <= STARS))
892
+ errors.push(choice ? `reactions[${i}].stars: 1 to ${STARS}` : `reactions[${i}].stars: only on a choice card`)
838
893
  const key = `${String(r.view)}/${String(tile ?? '')}`
839
894
  if (seen.has(key)) errors.push(`reactions[${i}]: a second reaction on the same view or tile`)
840
895
  seen.add(key)
841
896
  const comment = typeof r.comment === 'string' ? r.comment.trim() : ''
842
- if (r.status === undefined && comment === '') continue
843
- out.push({ view: r.view as number, ...(typeof tile === 'number' ? { tile } : {}), ...(r.status !== undefined ? { status: r.status as ReviewStatus } : {}), comment })
897
+ if (r.status === undefined && comment === '' && stars === undefined) continue
898
+ out.push({
899
+ view: r.view as number,
900
+ ...(typeof tile === 'number' ? { tile } : {}),
901
+ ...(r.status !== undefined ? { status: r.status as ReviewStatus } : {}),
902
+ comment,
903
+ ...(typeof stars === 'number' ? { stars } : {}),
904
+ })
844
905
  }
845
906
  return out
846
907
  }
@@ -0,0 +1,30 @@
1
+ // where a card lives, for the paste that hands it to another agent session (model.ts pasteBlock): the repo's path as
2
+ // `~/dev/<repo>`, the house page's url, the phone's url through the repo's public host. Node side: the page reads it
3
+ // from /api/review/spec
4
+ import { homedir } from 'node:os'
5
+ import { homePort } from '../home/home-paths.ts'
6
+ import { loadRepoConfig } from '../repo-config.ts'
7
+ import { defaultHubPort } from '../serve/hub-port.ts'
8
+
9
+ export interface PasteHosts {
10
+ /** the repo's path, `~` for the home folder */
11
+ root: string
12
+ /** the house page of the repo, `#page:review:<id>` goes after it */
13
+ open: string
14
+ /** the repo's hub through its public host, null when it has none */
15
+ phone: string | null
16
+ }
17
+
18
+ export async function pasteHosts(root: string): Promise<PasteHosts> {
19
+ const c = await loadRepoConfig(root)
20
+ const home = homedir()
21
+ const host = c.serve?.publicHost
22
+ return {
23
+ root: root.startsWith(`${home}/`) ? `~${root.slice(home.length)}` : root,
24
+ open: `http://localhost:${homePort()}/r/${encodeURIComponent(c.name)}/`,
25
+ phone: host ? `http://${host}:${c.serve?.port ?? defaultHubPort(c.name)}/` : null,
26
+ }
27
+ }
28
+
29
+ /** a card's url under a hub base */
30
+ export const cardUrl = (base: string, id: string): string => `${base}#page:review:${id}`
@@ -2,12 +2,12 @@
2
2
  // walked dirs' times and its text, imported, its spec bound to the store; its sources pulled, its kinds' facts and data
3
3
  // view computed on the server, so the hub never bundles project code
4
4
  import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
5
- import { join } from 'node:path'
5
+ import { isAbsolute, join, relative, resolve } from 'node:path'
6
6
  import { importFresh, repoUnitFiles } from '../core/discover-checks.ts'
7
7
  import { LAYOUT } from '../core/layout.ts'
8
8
  import type { ReviewItem } from './model.ts'
9
9
  import { bindSpec, readAll, storeDir } from './store.ts'
10
- import { specOfUnit, type Fact, type Link, type ReviewUnit, type ViewText } from './unit.ts'
10
+ import { specOfUnit, type Fact, type Link, type ReviewUnit, type ViewPart, type ViewText } from './unit.ts'
11
11
 
12
12
  /** the walk's answer kept in the cache: the unit files and every dir it read, each with its time; a dir whose time moved
13
13
  * means a file came or went, so the walk runs again */
@@ -90,7 +90,12 @@ export function factLines(unit: ReviewUnit | null, x: ReviewItem): string[] {
90
90
  }
91
91
 
92
92
  /** a kind's data view of one item, what the hub draws: its facts, links, live url and text; a function that throws says so as a fact */
93
- export function viewOfItem(unit: ReviewUnit | null, x: ReviewItem): { facts: Fact[]; links: Link[]; live: string | null; text: ViewText | null } {
93
+ /** a part as the page reads it: its media a file of the item's `part-<n>` route, with its kind */
94
+ export type PartData = Omit<ViewPart, 'media'> & { media?: 'image' | 'video' }
95
+
96
+ const VIDEO = /\.(mp4|webm|mov|m4v)$/i
97
+
98
+ export function viewOfItem(unit: ReviewUnit | null, x: ReviewItem): { facts: Fact[]; links: Link[]; live: string | null; text: ViewText | null; parts: PartData[] } {
94
99
  const k = unit?.kinds?.[x.item.subject.kind]
95
100
  const tryOr = <T>(what: string, f: (() => T) | undefined, none: T, errors: Fact[]): T => {
96
101
  try {
@@ -106,5 +111,16 @@ export function viewOfItem(unit: ReviewUnit | null, x: ReviewItem): { facts: Fac
106
111
  const links = tryOr('links', k?.links && (() => k.links?.(x) ?? []), [] as Link[], errors)
107
112
  const live = tryOr<string | null>('live', k?.live && (() => k.live?.(x) ?? null), null, errors)
108
113
  const text = tryOr<ViewText | null>('text', k?.text && (() => k.text?.(x) ?? null), null, errors)
109
- return { facts: [...facts, ...source, ...errors], links, live, text }
114
+ const parts = tryOr('parts', k?.parts && (() => k.parts?.(x) ?? []), [] as ViewPart[], errors).map(({ media, ...p }): PartData => (media ? { ...p, media: VIDEO.test(media) ? 'video' : 'image' } : p))
115
+ return { facts: [...facts, ...source, ...errors], links, live, text, parts }
116
+ }
117
+
118
+ /** the repo file of part `n` of item `x`'s subject, inside the repo; null when the part names none or it is not there */
119
+ export function partFile(root: string, unit: ReviewUnit | null, x: ReviewItem, n: number): string | null {
120
+ const media = unit?.kinds?.[x.item.subject.kind]?.parts?.(x)[n]?.media
121
+ if (!media) return null
122
+ const abs = resolve(root, media)
123
+ const rel = relative(root, abs)
124
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) return null
125
+ return existsSync(abs) ? abs : null
110
126
  }
@@ -3,12 +3,13 @@
3
3
  // relative to the repo hub's base. null: not a path of these
4
4
  import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
5
5
  import { tmpdir } from 'node:os'
6
- import { extname, join } from 'node:path'
6
+ import { dirname, extname, join } from 'node:path'
7
7
  import { isAnimFormat, isAnimLabels, isAnimMode, pairAnimation } from './anim.ts'
8
8
  import { startAuto } from './auto.ts'
9
9
  import { bad, KEEP, NO_STORE, ranged, TYPES } from './http.ts'
10
10
  import type { Rect, Subject } from './model.ts'
11
- import { openReview, viewOfItem } from './project.ts'
11
+ import { pasteHosts } from './paste.ts'
12
+ import { openReview, partFile, viewOfItem } from './project.ts'
12
13
  import { roleOf } from './role.ts'
13
14
  import { addMessage, imageFile, markRead, raiseItem, rateSubject, readAllCached, readItem, specOf, storeDir, storeStamp, threadImage, writeVerdict } from './store.ts'
14
15
  import { trailerRoutes } from './trailer-routes.ts'
@@ -110,8 +111,14 @@ async function reviewRoute(req: Request, path: string, dir: string, unit: Review
110
111
  }
111
112
  if (path === '/api/review/watchers' && req.method === 'GET') return Response.json(liveWatchers(dir), { headers: NO_STORE })
112
113
  // the unit's words, the dispatcher's holder: what the page draws the list's filters, labels and board with
113
- if (path === '/api/review/spec' && req.method === 'GET')
114
- return Response.json({ ...specData(specOf(dir)), role: roleOf(dir), sources: (unit?.sources ?? []).map((s) => s.id), auto: (unit?.auto ?? []).map((r) => ({ id: r.id, every: r.every })) }, { headers: NO_STORE })
114
+ if (path === '/api/review/spec' && req.method === 'GET') {
115
+ // where a card lives, for the paste that hands it to another agent session; a config that does not load leaves it out
116
+ const where = await pasteHosts(dirname(dirname(dir))).catch(() => null)
117
+ return Response.json(
118
+ { ...specData(specOf(dir)), role: roleOf(dir), sources: (unit?.sources ?? []).map((s) => s.id), auto: (unit?.auto ?? []).map((r) => ({ id: r.id, every: r.every })), where },
119
+ { headers: NO_STORE },
120
+ )
121
+ }
115
122
  if ((path === '/api/review/raise' || path === '/api/review/rate') && req.method === 'POST') {
116
123
  let img: ReturnType<typeof dataUrlFile> = null
117
124
  try {
@@ -196,6 +203,17 @@ async function reviewRoute(req: Request, path: string, dir: string, unit: Review
196
203
  return bad(e)
197
204
  }
198
205
  }
206
+ // a part's media (a draft's clip beside its text): the file the unit names for that part, never a path the page sends
207
+ const part = /^part-(\d+)$/.exec(name)
208
+ if (part) {
209
+ try {
210
+ const x = readItem(dir, id)
211
+ const f = x ? partFile(join(dir, '..', '..'), unit, x, Number(part[1])) : null
212
+ return f ? ranged(req, f) : bad(`no media for part ${part[1]} of ${id}`, 404)
213
+ } catch (e) {
214
+ return bad(e)
215
+ }
216
+ }
199
217
  if (name === 'anim') {
200
218
  const q = new URL(req.url).searchParams
201
219
  const mode = q.get('mode') ?? 'fade'