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
package/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## 2.5.0
2
+
3
+ - **A review card hands itself to any agent session.** A small copy icon on the card's header (or C) copies one block: its title and state, its repo, `cd <repo> && sk review show <id>`, its link here and on the phone when the repo has a public host; a toast says what was copied. `sk review show <id> --paste` prints the same block.
4
+ - **The overall comment box sits under the card's header**, after the verdicts, on every card and in every layout.
5
+ - **Push every unpushed repo: `sk fleet push` and a button on the activity page.** Each house repo with commits its remote lacks gets a plain `git push`, never a force; a branch with no upstream goes `-u` to its one remote; a branch that is also behind, a gone upstream or a detached head is skipped with the reason. `--dry` plans only.
6
+
7
+ - **A new repo's agent/index.md routes with `→`.** The scaffold's table rows, the project CLI's row and its `next:` hint write `→ agent/<page>.md`, the wiki's form for a page to open, instead of a backticked path.
8
+
1
9
  ## 2.3.0
2
10
 
3
11
  - **A sound is a review view.** `sk review add --audio <sound>`, a sound in `--view`, `add-view <id> "<label>" --audio <sound>`, or a before and after of two sounds: the store keeps the sound and draws its waveform (`wave.png`, `before-wave.png`), and the hub shows each waveform over a player, one row a side. A sound takes reactions and words, never a rectangle; a pair is never a sound and an image.
@@ -15,8 +23,17 @@
15
23
  - **`sk time` shows turn count, what makes agents slow when no command is.** Every call costs a model turn before it. The summary gives, for the main sessions and for the sub-agents, the model time before each call, the share of single read calls and the share sent in parallel. The sub-agent lines lead with a verdict (turn count or command speed, with a fix line when reads pile up one per turn), then their work split (model, tools, their own waits), run length p50 and p90, and calls per run. The `turns` view lists the runs that worked longest with their turns, read and parallel shares and model per turn, then the read shapes by the model time of the turns that issued them. A read is Read, Grep, Glob, or a Bash command of a read shape whose every part reads (a redirect to a file, `sed -i`, `find -delete` or `-exec`, `tee`, or a second command that writes or runs makes it not one), judged when a call is indexed and again when the rules change (`src/agent-time/turns.ts`). A call is parallel when it starts while the previous call still runs, within 2 s of its start. The model stretch before a call comes from the same sweep that cuts the wall. pkm-multi, every recorded session: sub-agents worked 905.4h, model 67%, 7.7 s of model before each of 283,821 calls, 51% single reads, 2.3% in parallel.
16
24
  - **A message relayed into a sub-agent ends its park.** The harness writes "The coordinator sent a message while you were working" (or the user, or another session) into a sub-agent as an `isMeta` user entry with no peer origin, which the transcript reader dropped, so a sub-agent that handed back and sat for hours until someone wrote to it counted those hours as model: 352h of pkm-multi's sub-agent time. It is now a `message` event that counts as a prompt (idle past 30 minutes). The index version moves to 6, so every file is read again once.
17
25
 
18
- ## Unreleased (patch)
19
-
26
+ ## 2.4.0
27
+
28
+ - **The wiki preview opens on the index, beside the graph on the left, right or bottom.** A switch of three icons in the preview's head places it, kept per browser; buttons open the page in VS Code and copy its path. Refs read by kind: on demand (`→`, a link, a path in a code span, written `→ path` whichever the page used) in orange, loaded with the page (`@`) in blue and bold. A page that names `agent/spec.md` opens the agent/ graph's `spec.md`.
29
+ - **The wikis page searches and previews.** A search bar over the graph finds every page by title and full text (`/graph/<id>/search`), a dropdown the arrows walk; enter, or a click on a ball, opens the page in a resizable panel on the right (`/graph/<id>/page`), whose refs open their page and focus its ball. The graph follows the hub's theme. `serve.wikiGraph.dir` names the folder a custom wiki's node ids are relative to (default the repo root).
30
+ - **A choice card.** `sk review add --choice --view "voice A" a.flac --note "low" --view "voice B" b.flac`: every view is an option he scores (1 to 5 stars) and picks one of. His pick is an approval carrying `pick`; `inbox` and `watch` print `picked [voice B]` and each score. An approval with reactions or a pick is no longer counted as a plain one by `inbox`.
31
+ - **Options as thin rows.** A choice, and any card of two clips or sounds or more, shows its views one below the other: ▶, the label, a strip that scrubs it, the time, then stars and « pick » (a choice) or OK, fix, no (a group), ✎ a word, ⤢ the full view. Keys 1 to 9 play a row, ⇧1 to ⇧5 score it, P picks it. « rows | full » switches back to every view stacked.
32
+ - **One clip or sound plays at a time on the whole page.** Any audio or video that starts pauses the one playing (the review page, the trailer page, the atelier's rolls). A card's first clip loops alone; the others wait on their first frame.
33
+ - **The card's header reads.** The title, then one line: the list's own pill, the lane, when it was sent or answered, the commit, an id to copy. No subject row, no id row, no banner. `top` is one column from the top: the header and the verdicts, the views, then the answer. A link to a card on a phone opens the card.
34
+ - **An svg mark is a repo's icon.** `.shipkit/identity/icon.svg` comes right after `icon.png` for the project list's icon and for the app icon, which draws it on the identity plate like a png.
35
+ - **The hub's pages read from the left.** Every page card of a repo (admin, checks, tests, plan, git, when, processes, status, readme) and a review card's pictures start at the left of their pane instead of floating centred; a guard keeps it.
36
+ - **The rail draws bigger icons: each repo's app icon.** The sizes are 24, 32 and 40px (S, M, L) with 6px a side, the rail 36, 44 or 52px; the ask badge sits on the icon's top right corner. Folded, a row wears the repo's app icon, its mark on the identity plate the admin page previews, cropped to the plate (`appIcon` on `/api/projects.json`, `appIconPngs(…, 'plate')`, drawn once per mark and colours); a repo with no png mark keeps its plain icon. A repo closed in VS Code dims its name, never its icon.
20
37
  - **The rail's icons come in three sizes.** Folded, the list's foot shows an S, M or L button: 18, 24 or 32px icons, the rail 44, 50 or 58px wide to fit, kept per browser (`hub:rail-icon`). M is the default.
21
38
  - **The project list folds to an icon rail.** ⌘B, the fold button at the list's foot, or a drag of its separator below the list's min folds it to 44px of icons: the names, the ⌘ hints and the step hint hide, a badge rides its icon's corner, the hover card still names the row. A drag out or ⌘B again unfolds it to its kept width; the fold is kept per browser (`hub:list-rail`). The dock moves to ⌘⇧B.
22
39
  - **⌘; opens the insights**, its filter focused, so a package name is one key and a word away.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
4
4
  "funding": "https://github.com/sponsors/rvion",
5
5
  "description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
6
6
  "license": "MIT",
@@ -36,7 +36,7 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "@floating-ui/react": "^0.27.20",
39
- "@napi-rs/canvas": "^1.0.2",
39
+ "@napi-rs/canvas": "^1.1.0",
40
40
  "clipanion": "^4.0.0-rc.4",
41
41
  "comfy-ts": "^2.9.0",
42
42
  "ink": "^7.1.1",
@@ -16,6 +16,7 @@ import { dirname, join } from 'path'
16
16
  import sharp from 'sharp'
17
17
  import { defineKind, type CanvasGen, type ComfyGen, type Kind } from '../kit-config.ts'
18
18
  import { HOUSE_FAMILIES, type TrailerLookInput } from '../../trailer/look.ts'
19
+ import type { TrailerSoundInput } from '../../trailer/sound/brand.ts'
19
20
 
20
21
  export type Brand = {
21
22
  name: string
@@ -31,8 +32,10 @@ export type Brand = {
31
32
  * name/tagline right); the hook owns compositing the mark if it wants one */
32
33
  drawOg?: (ctx: SKRSContext2D, brand: Brand, size: { w: number; h: number }) => void
33
34
  /** the look every trailer of the repo wears (titles, card, face, logo);
34
- * each value left out is derived from the palette (trailer/look.ts) */
35
- trailer?: TrailerLookInput
35
+ * each value left out is derived from the palette (trailer/look.ts);
36
+ * its sound section (`comfy`, `elevenKey`, `voice`, `voices`) makes the
37
+ * voice, sfx and music lines (trailer/sound/brand.ts) */
38
+ trailer?: TrailerLookInput & TrailerSoundInput
36
39
  }
37
40
 
38
41
  export const FAVICON_SIZE = 512
@@ -5,6 +5,7 @@
5
5
  // (the kit in cli/kit/) come out of these functions.
6
6
  import { Command, Option } from 'clipanion'
7
7
  import { spliceMarkedBlock } from '../core/doc-block.ts'
8
+ import { FAMILY_PAGES } from './family-pages.ts'
8
9
  import { type Essential, type Film, marksOf, ROW_OF, runText } from './kit/verb.ts'
9
10
 
10
11
  export type CommandOption = { definition: string; description: string }
@@ -20,6 +21,8 @@ export type CommandRow = {
20
21
  /** a filmable command's take version and length (build/trailer.md, Film lines) */
21
22
  version?: number | string
22
23
  length?: 'own'
24
+ words?: Record<string, unknown>
25
+ stamp?: string[]
23
26
  /** listed on the essentials page */
24
27
  essential?: Essential
25
28
  /** what a kit row runs */
@@ -81,6 +84,8 @@ export function commandRows(plain: Definition[], detailed: Definition[] = [], bi
81
84
  if (m.film != null) row.film = m.film
82
85
  if (m.version != null) row.version = m.version
83
86
  if (m.length != null) row.length = m.length
87
+ if (m.words != null) row.words = m.words
88
+ if (m.stamp != null) row.stamp = m.stamp
84
89
  if (m.essential != null) row.essential = m.essential
85
90
  const v = ROW_OF.get(c)
86
91
  if (v != null) row.runs = runText(v.run)
@@ -113,12 +118,13 @@ export function renderMarkdown(rows: CommandRow[], bin = 'sk'): string {
113
118
 
114
119
  /** the eager form: one line per command, its positional arguments and its
115
120
  * description. Flags and examples are `<bin> <command> --help`, so a page every
116
- * session loads costs a line per command, not a screen */
117
- export function renderCompact(rows: CommandRow[], bin = 'sk'): string {
121
+ * session loads costs a line per command, not a screen. `pages` puts each
122
+ * family's owner page in its heading: `### Trailer → build/trailer.md` */
123
+ export function renderCompact(rows: CommandRow[], bin = 'sk', pages: Readonly<Record<string, string>> | null = null): string {
118
124
  const out: string[] = []
119
125
  let cat = ''
120
126
  for (const r of rows) {
121
- if (r.category !== cat) { cat = r.category; out.push(`\n### ${cat}\n`) }
127
+ if (r.category !== cat) { cat = r.category; out.push(`\n### ${cat}${pages?.[cat] != null ? ` → ${pages[cat]}` : ''}\n`) }
122
128
  const sig = r.usage.replace(/^\$0/, bin).replace(/\s+\[(?:-|<\w+>)[^\]]*\]/g, '').trim() || `${bin} ${r.path}`
123
129
  out.push(`- \`${sig}\` — ${r.description}`)
124
130
  }
@@ -150,6 +156,15 @@ export function renderEssentials(rows: CommandRow[], bin: string, command: strin
150
156
  return `${out.join('\n')}\n`
151
157
  }
152
158
 
159
+ /** a family with no owner page, and a page for a family no command has: one line each */
160
+ export function familyProblems(rows: CommandRow[], pages: Readonly<Record<string, string>>): string[] {
161
+ const families = new Set(rows.map((r) => r.category))
162
+ return [
163
+ ...[...families].filter((f) => pages[f] == null).map((f) => `family ${f}: no owner page in src/cli/family-pages.ts`),
164
+ ...Object.keys(pages).filter((f) => !families.has(f)).map((f) => `family ${f}: src/cli/family-pages.ts names a page for a family no command has`),
165
+ ]
166
+ }
167
+
153
168
  /** what a project CLI's registry must hold for its pages and its listing to
154
169
  * teach right: a category, a description under the cap, and an example that
155
170
  * starts with the command's own words. One line per breach */
@@ -205,6 +220,9 @@ export class CommandsCommand extends Command {
205
220
  * a breach), and its --out creates a missing page */
206
221
  static shapeRule = false
207
222
 
223
+ /** each family's owner page, named in the compact page's headings; a project CLI has none */
224
+ static familyPages: Readonly<Record<string, string>> | null = FAMILY_PAGES
225
+
208
226
  json = Option.Boolean('--json', false, { description: 'machine-readable rows, with film and essential where a command carries them' })
209
227
  out = Option.String('--out', { description: 'host file carrying the <bin>:commands markers. The block between them is rewritten' })
210
228
  check = Option.Boolean('--check', false, { description: 'with --out or --essentials: exit 1 if a page is stale, write nothing' })
@@ -221,10 +239,17 @@ export class CommandsCommand extends Command {
221
239
  classes,
222
240
  )
223
241
  const write = (s: string): void => void this.context.stdout.write(s)
242
+ const pages = (this.constructor as typeof CommandsCommand).familyPages
243
+ const render = (): string => (this.compact ? renderCompact(rows, bin, pages) : renderMarkdown(rows, bin))
224
244
  if (this.out == null && this.essentials == null) {
225
- write(this.json ? `${JSON.stringify(rows)}\n` : `${(this.compact ? renderCompact : renderMarkdown)(rows, bin)}\n`)
245
+ write(this.json ? `${JSON.stringify(rows)}\n` : `${render()}\n`)
226
246
  return 0
227
247
  }
248
+ if (this.out != null && this.compact && pages != null) {
249
+ const bad = familyProblems(rows, pages)
250
+ for (const b of bad) write(`FAIL: ${b}\n`)
251
+ if (bad.length > 0) { write(' fix: one row per family in src/cli/family-pages.ts, the wiki page that owns it\n'); return 1 }
252
+ }
228
253
  if ((this.constructor as typeof CommandsCommand).shapeRule) {
229
254
  const bad = shapeProblems(rows, bin)
230
255
  for (const b of bad) write(`FAIL: ${b}\n`)
@@ -246,7 +271,7 @@ export class CommandsCommand extends Command {
246
271
  const project = (this.constructor as typeof CommandsCommand).shapeRule
247
272
  if (!existsSync(this.out) && (!project || this.check)) { write(`FAIL: ${this.out} does not exist\n`); return 1 }
248
273
  const host = existsSync(this.out) ? readFileSync(this.out, 'utf8') : newCommandsPage(bin, this.out)
249
- const next = spliceBlock(host, (this.compact ? renderCompact : renderMarkdown)(rows, bin), bin)
274
+ const next = spliceBlock(host, render(), bin)
250
275
  if (next == null) { write(`FAIL: ${this.out} has no ${begin} / ${end} markers\n`); return 1 }
251
276
  if (next === host) { write(`ok: ${this.out} command index current (${rows.length} commands)\n`); return code }
252
277
  if (this.check) {
@@ -0,0 +1,47 @@
1
+ // shipkit fleet push — every house repo that holds commits its remote does
2
+ // not, pushed: a plain git push, never a force (fleet/push-fleet.ts, the same
3
+ // path as the activity page's push button). A repo that fell behind waits for
4
+ // a pull, and says so.
5
+ import { Command, Option } from 'clipanion'
6
+ import { homedir } from 'os'
7
+ import { gitRoot } from '../core/git-root.ts'
8
+ import { pushFleet } from '../fleet/push-fleet.ts'
9
+ import { houseRepos } from '../serve/project-gutter.ts'
10
+ import { listFlag } from './list-flag.ts'
11
+
12
+ export class FleetPushCommand extends Command {
13
+ static override paths = [['fleet', 'push']]
14
+
15
+ static override usage = Command.Usage({
16
+ category: 'Health',
17
+ description: 'push every house repo with unpushed commits: a plain git push, never a force',
18
+ examples: [
19
+ ['push what is unpushed, everywhere', 'sk fleet push'],
20
+ ['what would go out', 'sk fleet push --dry'],
21
+ ['some repos only', 'sk fleet push --only my-app --only other'],
22
+ ],
23
+ })
24
+
25
+ dry = Option.Boolean('--dry', false, { description: 'plan only: say what would be pushed and what is skipped, push nothing' })
26
+ only = Option.Array('--only', { description: 'limit to these repo names (repeatable)' })
27
+ all = Option.Boolean('--all', false, { description: 'also print the repos that have nothing to push' })
28
+
29
+ async execute(): Promise<number> {
30
+ const root = gitRoot(process.cwd())?.top ?? process.cwd()
31
+ let repos = houseRepos(root, homedir()).repos.map((r) => ({ name: r.name, path: r.path }))
32
+ const only = listFlag(this.only)
33
+ if (only.length > 0) repos = repos.filter((r) => only.includes(r.name))
34
+ const w = (l: string) => this.context.stdout.write(`${l}\n`)
35
+ if (repos.length === 0) {
36
+ w(`skip: no house repo found from ${root}`)
37
+ return 0
38
+ }
39
+ const res = await pushFleet(repos, { dry: this.dry })
40
+ for (const r of res) if (this.all || r.action !== 'none') w(r.line)
41
+ const pushed = res.filter((r) => r.ok && (r.action === 'push' || r.action === 'push-upstream')).length
42
+ const failed = res.filter((r) => !r.ok && r.action !== 'skip').length
43
+ const skipped = res.filter((r) => r.action === 'skip').length
44
+ w(`${failed > 0 ? 'FAIL' : 'ok'}: ${repos.length} repos, ${this.dry ? `${res.filter((r) => r.action === 'push' || r.action === 'push-upstream').length} would push` : `${pushed} pushed`}, ${skipped} skipped${failed > 0 ? `, ${failed} failed` : ''}`)
45
+ return failed > 0 ? 1 : 0
46
+ }
47
+ }
@@ -28,7 +28,7 @@ export type GlobalProcessesAnswer = {
28
28
  export type GlobalProcessesDeps = {
29
29
  pm: () => Promise<PmAnswer | null>
30
30
  /** the daemon's identity, for its version; null when it does not answer */
31
- daemon?: () => Promise<{ version: string } | null>
31
+ daemon?: () => Promise<{ version: string; held?: string | null } | null>
32
32
  /** the declared units, read like pm reads them; null when no house is found */
33
33
  declared: () => Promise<Pick<PmConfig, 'processes' | 'errors'> | null>
34
34
  /** the project list's order */
@@ -43,18 +43,18 @@ const localPorts = () => ({ pm: pmPort(), daemon: daemonPort(), web: homePort()
43
43
  * pm does not answer */
44
44
  export async function globalProcessesAnswer(d: GlobalProcessesDeps): Promise<GlobalProcessesAnswer> {
45
45
  const [pm, daemon] = await Promise.all([d.pm(), d.daemon?.() ?? null])
46
- const core = { daemonVersion: daemon?.version ?? null, ports: localPorts() }
46
+ const core = { daemonVersion: daemon?.version ?? null, daemonHeld: daemon?.held ?? null, ports: localPorts() }
47
47
  if (pm != null) return { pm: { pid: pm.pid, version: pm.version }, now: d.now, groups: processGroups(pm, d.order, core), errors: pm.unitErrors, house: true }
48
48
  const c = await d.declared()
49
49
  return { pm: null, now: d.now, groups: [coreGroup(null, core), ...groupRows((c?.processes ?? []).map(declaredRow), d.order)], errors: c?.errors ?? [], house: c != null }
50
50
  }
51
51
 
52
52
  /** the daemon's identity at /hub.json, or null */
53
- export async function askDaemon(url = daemonUrl()): Promise<{ version: string } | null> {
53
+ export async function askDaemon(url = daemonUrl()): Promise<{ version: string; held: string | null } | null> {
54
54
  try {
55
55
  const r = await fetch(`${url}/hub.json`, { signal: AbortSignal.timeout(3_000) })
56
- const j = r.ok ? ((await r.json()) as { hub?: string; version?: string }) : null
57
- return j?.hub === 'shipkit-home' && typeof j.version === 'string' ? { version: j.version } : null
56
+ const j = r.ok ? ((await r.json()) as { hub?: string; version?: string; held?: string | null }) : null
57
+ return j?.hub === 'shipkit-home' && typeof j.version === 'string' ? { version: j.version, held: j.held ?? null } : null
58
58
  } catch {
59
59
  return null
60
60
  }
@@ -83,7 +83,7 @@ function tileOf(arg: string): { file: string; label: string } {
83
83
 
84
84
  type ViewInput = import('../review/store.ts').ViewInput
85
85
 
86
- /** `--view "<label>" a.png [b.png]` and `--gallery "<label>" a.png b.png …` out of the args */
86
+ /** `--view "<label>" a.png [b.png] [--note "<text>"]` and `--gallery "<label>" a.png b.png …` out of the args */
87
87
  function takeViews(args: readonly string[]): { views: ViewInput[]; rest: string[] } {
88
88
  const views: ViewInput[] = []
89
89
  const rest: string[] = []
@@ -100,6 +100,13 @@ function takeViews(args: readonly string[]): { views: ViewInput[]; rest: string[
100
100
  if (a === '--gallery') views.push({ label, gallery: files.map(tileOf) })
101
101
  else views.push(files.length === 1 ? { label, image: files[0] } : { label, before: files[0], after: files[1] })
102
102
  i += 1 + files.length
103
+ if (args[i + 1] === '--note') {
104
+ const note = args[i + 2]
105
+ if (note === undefined || note.startsWith('--')) throw new Error(`--view "${label}" … --note "<text>": a line on that view`)
106
+ const v = views[views.length - 1]
107
+ if (v) v.note = note
108
+ i += 2
109
+ }
103
110
  }
104
111
  return { views, rest }
105
112
  }
@@ -139,7 +146,8 @@ const ADD_FLAGS = `flags:
139
146
  --audio <sound.wav> a sound: a waveform and a player (wav, mp3, flac, ogg, opus, m4a)
140
147
  --before <a.png> --after <b.png> a before and after: two images, two clips of one size, or two sounds
141
148
  --label "<label>" the first view's label
142
- --view "<label>" <a.png> [<b.png>] another view of the same subject (repeat it)
149
+ --view "<label>" <a.png> [<b.png>] another view of the same subject (repeat it); --note "<text>" right after it: a line on that view
150
+ --choice every view is an option he scores and picks one of (two --view or more)
143
151
  --gallery "<label>" <a.png> … a grid of small images; "<tile label>=<c.png>" names a tile (repeat it)
144
152
  --topic <topic> what part of the project it is about (sk review topics)
145
153
  --subject <kind> what is judged (default shot; the repo's unit names its kinds)
@@ -150,6 +158,19 @@ const ADD_FLAGS = `flags:
150
158
  --keeps "<what stays>" with --supersedes on an approved look: what of it stays
151
159
  --new --why "<reason>" a new card beside an active one of the same subject`
152
160
 
161
+ /** the help of add or add-view, then the flags this repo's review unit adds (any kind's, then each kind's under
162
+ * `--subject <kind>`); a repo with no unit, or one that does not load, says so on one line */
163
+ async function helpWithUnit(context: Ctx, usage: string, kinds: boolean): Promise<number> {
164
+ context.stdout.write(usage)
165
+ await run(context, (h) => {
166
+ const lines = Object.entries(h.unit?.flags ?? {}).map(([k, d]) => ` --${k} ${d}`)
167
+ if (kinds) for (const [name, k] of Object.entries(h.unit?.kinds ?? {})) for (const [f, d] of Object.entries(k.flags ?? {})) lines.push(` --${f} ${d} (with --subject ${name})`)
168
+ h.out(lines.length > 0 ? `\nthis repo's review unit adds:\n${lines.join('\n')}` : "\nthis repo's review unit adds no flags")
169
+ return 0
170
+ })
171
+ return 0
172
+ }
173
+
153
174
  export class ReviewAddCommand extends Command {
154
175
  static override paths = [['review', 'add']]
155
176
  static override usage = Command.Usage({
@@ -160,14 +181,14 @@ export class ReviewAddCommand extends Command {
160
181
  ['one image', 'sk review add --title "the new menu" --what "the menu, bigger" --image shots/menu.png'],
161
182
  ['a before and after', 'sk review add --title "lamps lit" --what "..." --before a.png --after b.png'],
162
183
  ['a second version after his answer', 'sk review add --title "lamps lit" --what "..." --image c.png --supersedes <id>'],
184
+ ['a pick among three voices', 'sk review add --choice --title "the narrator" --what "..." --view "voice 0" a.flac --note "low, breathy" --view "voice 1" b.flac --view "voice 2" c.flac'],
163
185
  ],
164
186
  })
165
187
  args = Option.Proxy()
166
188
 
167
189
  async execute(): Promise<number> {
168
190
  if (this.args.some(isHelp)) {
169
- this.context.stdout.write(this.cli.usage(ReviewAddCommand, { detailed: true }))
170
- return 0
191
+ return helpWithUnit(this.context, this.cli.usage(ReviewAddCommand, { detailed: true }), true)
171
192
  }
172
193
  return run(this.context, async (h) => {
173
194
  const { addItem } = await import('../review/store.ts')
@@ -176,7 +197,7 @@ export class ReviewAddCommand extends Command {
176
197
  const kindName = rest[rest.indexOf('--subject') + 1]
177
198
  const kind = rest.includes('--subject') && kindName ? h.unit?.kinds?.[kindName] : undefined
178
199
  const own = [...Object.keys(kind?.flags ?? {}), ...Object.keys(h.unit?.flags ?? {})]
179
- const f = flagsOf(rest, ['title', 'what', 'image', 'video', 'audio', 'before', 'after', 'label', 'topic', 'subject', 'commit', 'lane', 'supersedes', 'keeps', 'why', 'live'], ['answers', ...own], ['new', 'wip'])
200
+ const f = flagsOf(rest, ['title', 'what', 'image', 'video', 'audio', 'before', 'after', 'label', 'topic', 'subject', 'commit', 'lane', 'supersedes', 'keeps', 'why', 'live'], ['answers', ...own], ['new', 'wip', 'choice'])
180
201
  if (f.positionals.length > 0) throw new Error(`unexpected ${f.positionals.join(' ')}`)
181
202
  const o = f.one
182
203
  const repoFlags = Object.fromEntries(own.flatMap((k) => (f.many[k] ? [[k, f.many[k]]] : [])))
@@ -209,6 +230,7 @@ export class ReviewAddCommand extends Command {
209
230
  ...(o.why ? { why: o.why } : {}),
210
231
  ...(answers.length > 0 ? { answers } : {}),
211
232
  ...(f.on.has('new') ? { fresh: true } : {}),
233
+ ...(f.on.has('choice') ? { choice: true } : {}),
212
234
  ...(o.live !== undefined ? { image: wipViews[0]?.image ?? '', views: wipViews.slice(1), wip: { live: o.live } } : { views }),
213
235
  }
214
236
  if (h.unit?.add) input = await h.unit.add(input, repoFlags)
@@ -225,27 +247,29 @@ export class ReviewAddViewCommand extends Command {
225
247
  static override usage = Command.Usage({
226
248
  category: 'Review',
227
249
  description: 'another angle on a card he has not answered yet: an image, a pair, a clip or a gallery',
228
- details: `${DETAILS}\n\nsk review add-view <id> "<label>" <a.png> [<b.png>] | --video <clip.mp4> | --gallery <a.png> <b.png> …`,
250
+ details: `${DETAILS}\n\nsk review add-view <id> "<label>" <a.png> [<b.png>] | --video <clip.mp4> | --audio <sound.wav> | --gallery <a.png> <b.png> … [--note "<a line on that view>"]`,
229
251
  examples: [['a view from above', 'sk review add-view <id> "from above" top.png']],
230
252
  })
231
253
  args = Option.Proxy()
232
254
 
233
255
  async execute(): Promise<number> {
234
256
  if (this.args.some(isHelp)) {
235
- this.context.stdout.write(this.cli.usage(ReviewAddViewCommand, { detailed: true }))
236
- return 0
257
+ return helpWithUnit(this.context, this.cli.usage(ReviewAddViewCommand, { detailed: true }), false)
237
258
  }
238
259
  return run(this.context, async (h) => {
239
260
  const { addView } = await import('../review/store.ts')
240
261
  const own = Object.keys(h.unit?.flags ?? {})
241
262
  const repoFlags: Record<string, string[]> = {}
242
263
  const args: string[] = []
264
+ let note: string | undefined
243
265
  for (let i = 0; i < this.args.length; i++) {
244
266
  const a = this.args[i] ?? ''
245
267
  const name = a.startsWith('--') ? a.slice(2) : ''
246
268
  if (own.includes(name)) (repoFlags[name] ??= []).push(this.args[++i] ?? '')
269
+ else if (name === 'note') note = this.args[++i]
247
270
  else args.push(a)
248
271
  }
272
+ if (this.args.includes('--note') && (note === undefined || note.startsWith('--'))) throw new Error('--note "<text>": a line on that view')
249
273
  const [id, label, ...rest] = args
250
274
  if (!id || !label) throw new Error('add-view <id> "<label>" <media>')
251
275
  const v: ViewInput =
@@ -262,6 +286,7 @@ export class ReviewAddViewCommand extends Command {
262
286
  : (() => {
263
287
  throw new Error('add-view <id> "<label>" <a.png> [<b.png>] | --video <clip> | --gallery <a.png> …')
264
288
  })()
289
+ if (note !== undefined) v.note = note
265
290
  const extra = h.unit?.addView ? await h.unit.addView(v, repoFlags) : {}
266
291
  const item = await addView(h.dir, id, v, extra)
267
292
  h.out(`${id}: view ${item.views.length} "${label}" added`)
@@ -320,8 +345,14 @@ export class ReviewLsCommand extends Command {
320
345
 
321
346
  export class ReviewShowCommand extends Command {
322
347
  static override paths = [['review', 'show']]
323
- static override usage = Command.Usage({ category: 'Review', description: 'one item and its thread: the verdict, rectangles, reactions, the whole conversation', details: `${DETAILS}\n\nTakes an item id or a player report's ref (live#3).` })
348
+ static override usage = Command.Usage({
349
+ category: 'Review',
350
+ description: 'one item and its thread: the verdict, rectangles, reactions, the whole conversation',
351
+ details: `${DETAILS}\n\nTakes an item id or a player report's ref (live#3). --paste prints the short block that hands the card to another agent session (the hub's copy button gives the same).`,
352
+ examples: [['hand a card to another session', 'sk review show <id> --paste']],
353
+ })
324
354
  id = Option.String()
355
+ paste = Option.Boolean('--paste', false, { description: 'the block another agent session acts on: title, repo, this command, the links' })
325
356
 
326
357
  async execute(): Promise<number> {
327
358
  return run(this.context, async (h) => {
@@ -332,6 +363,15 @@ export class ReviewShowCommand extends Command {
332
363
  const all = readAllCached(h.dir)
333
364
  const x = all.items.find((y) => y.item.id === id)
334
365
  if (!x) throw new Error(`no item ${this.id}`)
366
+ if (this.paste) {
367
+ const { dirname } = await import('node:path')
368
+ const { pasteHosts, cardUrl } = await import('../review/paste.ts')
369
+ const { pasteBlock, STATUS_LABEL } = await import('../review/model.ts')
370
+ const { statusOf } = await import('../review/threads.ts')
371
+ const w = await pasteHosts(dirname(dirname(h.dir)))
372
+ h.out(pasteBlock(x.item, STATUS_LABEL[statusOf(x, all.items)], { root: w.root, open: cardUrl(w.open, x.item.id), phone: w.phone ? cardUrl(w.phone, x.item.id) : null }))
373
+ return 0
374
+ }
335
375
  const t = threadOfItem(x.item.id, threadsOf(all.items))
336
376
  h.out(t && (t.head.item.id === x.item.id || t.answers.length > 0) ? threadDetail(t, h.dir, true, h.say) : detail(x, h.dir, true, false, h.say))
337
377
  return 0
@@ -380,10 +420,11 @@ export class ReviewInboxCommand extends Command {
380
420
  const followed = followedIds(all.items)
381
421
  // what he or a player raised and nobody closed is the agents' move, read or not
382
422
  const open = all.items.filter((x) => of(x) && x.item.origin.by !== 'agent' && moveOf(stateOf(x, followed.has(x.item.id)).state) === 'agents' && !news.includes(x))
383
- // a plain approval (no comment, no rectangle, no reply) asks for no work: counted on one line unless --all
423
+ // a plain approval (no comment, no rectangle, no reaction, no pick, no reply) asks for no work: counted on one
424
+ // line unless --all
384
425
  const plain = (x: (typeof news)[number]): boolean => {
385
426
  const v = verdictOf(x)
386
- return v?.status === 'approved' && !v.seen && !v.comment && v.rects.length === 0 && unreadBy(x, 'agent').length === 0
427
+ return v?.status === 'approved' && !v.seen && !v.comment && v.rects.length === 0 && !v.reactions && v.pick === undefined && unreadBy(x, 'agent').length === 0
387
428
  }
388
429
  const shown = [...(this.all ? news : news.filter((x) => !plain(x))), ...open]
389
430
  if (shown.length === 0 && news.length === 0) h.out('review: no unseen feedback or reply')