rvlib-shipkit 2.1.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/package.json +2 -1
  3. package/src/agent-time/mask.ts +43 -0
  4. package/src/agent-time/render.ts +187 -0
  5. package/src/agent-time/report.ts +335 -0
  6. package/src/agent-time/shape.ts +271 -0
  7. package/src/agent-time/signals.ts +84 -0
  8. package/src/agent-time/store.ts +379 -0
  9. package/src/agent-time/timeline.ts +218 -0
  10. package/src/agent-time/turns.ts +135 -0
  11. package/src/assets/kits/identity-kit.ts +7 -3
  12. package/src/assets/run-assets.ts +18 -4
  13. package/src/checks/SK004-gitignore-policy.check.ts +11 -6
  14. package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
  15. package/src/cli/cmd_Calls.ts +53 -0
  16. package/src/cli/cmd_Commands.ts +163 -43
  17. package/src/cli/cmd_Review.ts +948 -0
  18. package/src/cli/cmd_Shot.ts +63 -0
  19. package/src/cli/cmd_Start.ts +8 -4
  20. package/src/cli/cmd_Time.ts +112 -0
  21. package/src/cli/cmd_Trailer.ts +361 -0
  22. package/src/cli/kit/builtins.ts +40 -0
  23. package/src/cli/kit/calls.ts +177 -0
  24. package/src/cli/kit/families.ts +67 -0
  25. package/src/cli/kit/index.ts +8 -0
  26. package/src/cli/kit/pages-check.ts +36 -0
  27. package/src/cli/kit/project-cli.ts +73 -0
  28. package/src/cli/kit/verb.ts +167 -0
  29. package/src/cli.ts +15 -2
  30. package/src/collections/collection-types.ts +6 -1
  31. package/src/collections/providers.ts +3 -0
  32. package/src/collections/review.ts +70 -0
  33. package/src/core/layout.ts +12 -2
  34. package/src/core/migrate-layout.ts +31 -8
  35. package/src/core/one-way-in-policy.ts +12 -4
  36. package/src/reflect/prompt-corpus.ts +15 -114
  37. package/src/reflect/transcript.ts +254 -0
  38. package/src/review/anim.ts +228 -0
  39. package/src/review/auto.ts +141 -0
  40. package/src/review/format.ts +150 -0
  41. package/src/review/http.ts +23 -0
  42. package/src/review/marks.ts +43 -0
  43. package/src/review/media.ts +45 -0
  44. package/src/review/model.ts +854 -0
  45. package/src/review/project.ts +110 -0
  46. package/src/review/role.ts +48 -0
  47. package/src/review/routes.ts +228 -0
  48. package/src/review/store.ts +1002 -0
  49. package/src/review/threads.ts +237 -0
  50. package/src/review/trailer-desk.ts +385 -0
  51. package/src/review/trailer-model.ts +183 -0
  52. package/src/review/trailer-routes.ts +136 -0
  53. package/src/review/unit.ts +165 -0
  54. package/src/review/watchers.ts +54 -0
  55. package/src/scaffold/cli-skeleton.ts +149 -0
  56. package/src/scaffold/files/agent-index.md +2 -2
  57. package/src/scaffold/files/tsconfig.json +3 -0
  58. package/src/scaffold/files/verify-skill.md +1 -1
  59. package/src/scaffold/project-scaffold.ts +90 -12
  60. package/src/serve/embed.ts +35 -0
  61. package/src/serve/hub-app/activity-view.tsx +1 -1
  62. package/src/serve/hub-app/activity.css +0 -4
  63. package/src/serve/hub-app/dock-view.tsx +5 -4
  64. package/src/serve/hub-app/hash-route.ts +28 -1
  65. package/src/serve/hub-app/header-bar.tsx +3 -1
  66. package/src/serve/hub-app/home-st.ts +10 -2
  67. package/src/serve/hub-app/home-strip.tsx +3 -2
  68. package/src/serve/hub-app/home.html +1 -0
  69. package/src/serve/hub-app/hub-app.tsx +80 -11
  70. package/src/serve/hub-app/hub.css +45 -13
  71. package/src/serve/hub-app/icons.tsx +9 -0
  72. package/src/serve/hub-app/index.html +1 -0
  73. package/src/serve/hub-app/insights-view.tsx +4 -3
  74. package/src/serve/hub-app/keys.ts +14 -10
  75. package/src/serve/hub-app/layout-st.ts +63 -2
  76. package/src/serve/hub-app/planned-view.tsx +3 -2
  77. package/src/serve/hub-app/planned.css +0 -1
  78. package/src/serve/hub-app/project-list.css +22 -4
  79. package/src/serve/hub-app/project-list.tsx +41 -8
  80. package/src/serve/hub-app/projects-svc.ts +4 -0
  81. package/src/serve/hub-app/repo-sections.ts +15 -3
  82. package/src/serve/hub-app/repo-st.ts +45 -3
  83. package/src/serve/hub-app/review/clip-st.ts +208 -0
  84. package/src/serve/hub-app/review/kind-view.tsx +171 -0
  85. package/src/serve/hub-app/review/kit.tsx +113 -0
  86. package/src/serve/hub-app/review/review-answer.tsx +239 -0
  87. package/src/serve/hub-app/review/review-board.tsx +46 -0
  88. package/src/serve/hub-app/review/review-list.tsx +156 -0
  89. package/src/serve/hub-app/review/review-page.tsx +101 -0
  90. package/src/serve/hub-app/review/review-st.ts +779 -0
  91. package/src/serve/hub-app/review/review-views.tsx +503 -0
  92. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  93. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  94. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  95. package/src/serve/hub-app/review.css +329 -0
  96. package/src/serve/hub-app/theme-st.ts +56 -0
  97. package/src/serve/hub-app/tree-build.ts +4 -0
  98. package/src/serve/hub-app/tree-row.tsx +1 -1
  99. package/src/serve/hub-app/tree-svc.ts +4 -0
  100. package/src/serve/hub-runtime.ts +5 -3
  101. package/src/serve/same-origin.ts +5 -1
  102. package/src/serve/serve-hub.ts +8 -0
  103. package/src/shipkit-files.ts +12 -4
  104. package/src/shot/client.ts +67 -0
  105. package/src/shot/clip-encoder.ts +146 -0
  106. package/src/shot/dom-clip.ts +124 -0
  107. package/src/shot/serve-shot.ts +54 -0
  108. package/src/shot/shooter.ts +343 -0
  109. package/src/shot/shot-ask.ts +189 -0
  110. package/src/shot/shot.shipkit.ts +14 -0
  111. package/src/smoke/run-smoke.ts +2 -2
  112. package/src/start-repo.ts +29 -14
  113. package/src/suite/judge-suites.ts +56 -10
  114. package/src/suite/select.ts +2 -5
  115. package/src/suite/suite-cli.ts +3 -2
  116. package/src/trailer/adapter.ts +78 -0
  117. package/src/trailer/build.ts +603 -0
  118. package/src/trailer/ffmpeg.ts +72 -0
  119. package/src/trailer/footage.ts +173 -0
  120. package/src/trailer/grammar.ts +1290 -0
  121. package/src/trailer/graph.ts +508 -0
  122. package/src/trailer/holes.ts +72 -0
  123. package/src/trailer/load-unit.ts +31 -0
  124. package/src/trailer/look.ts +124 -0
  125. package/src/trailer/passage.ts +129 -0
  126. package/src/trailer/plan.ts +192 -0
  127. package/src/trailer/prune.ts +193 -0
  128. package/src/trailer/score/compose.ts +240 -0
  129. package/src/trailer/score/dsp.ts +272 -0
  130. package/src/trailer/score/palette.ts +188 -0
  131. package/src/trailer/score/pattern.ts +91 -0
  132. package/src/trailer/score/score.ts +214 -0
  133. package/src/trailer/score/sound.ts +112 -0
  134. package/src/trailer/score/synth.ts +361 -0
  135. package/src/trailer/states.ts +76 -0
  136. package/src/trailer/stitch.ts +233 -0
  137. package/src/trailer/titles.ts +379 -0
  138. package/src/trailer/url.ts +108 -0
  139. package/src/trailer/vertical-draw.ts +286 -0
  140. package/src/trailer/vertical.ts +148 -0
  141. package/src/trailer/vocab.ts +59 -0
  142. package/src/trailer/words.ts +254 -0
@@ -0,0 +1,149 @@
1
+ // the project CLI a started repo gets on day one: `src/cli.ts` on the kit
2
+ // (cli/kit/), named for the project, one example family, and the package.json
3
+ // `cli` script plus a `bin` of the project's name. Pure text here; the scaffold
4
+ // writes it create-only, so a repo that already has any of it keeps its own.
5
+ import { Cli } from 'clipanion'
6
+ import { commandRows, type Definition, newCommandsPage, renderEssentials, renderMarkdown, spliceBlock } from '../cli/cmd_Commands.ts'
7
+ import { builtinCommands } from '../cli/kit/builtins.ts'
8
+ import { type Verb, verbs } from '../cli/kit/verb.ts'
9
+
10
+ /** the entry: the family table and nothing else. `absolute`: the repo's
11
+ * tsconfig maps `src/*`, the house import form; else a relative import */
12
+ export function cliEntry(name: string, absolute = true): string {
13
+ return `#!/usr/bin/env bun
14
+ // ${name}: the project CLI, one command for every act an agent or a human does in this repo.
15
+ // A family is src/cli/cmd_<Family>.ts exporting \`commands\`; the table names the first words
16
+ // each family owns, so a call loads its family alone. \`${name}\` lists every command.
17
+ import { resolve } from 'path'
18
+ import { runProjectCli } from 'rvlib-shipkit/cli-kit'
19
+
20
+ await runProjectCli({
21
+ name: '${name}',
22
+ root: resolve(import.meta.dir, '..'),
23
+ families: [
24
+ { roots: ['dev', 'shot'], load: () => import('${absolute ? 'src/cli/cmd_Dev.ts' : './cli/cmd_Dev.ts'}') },
25
+ ],
26
+ })
27
+ `
28
+ }
29
+
30
+ /** the example family's live row, as data: the source text and the day-one
31
+ * pages are both made from it, so they cannot disagree */
32
+ export function exampleRow(name: string, devScript = false): Verb {
33
+ return {
34
+ path: 'dev',
35
+ category: 'Run',
36
+ description: devScript ? 'the dev process (package.json dev)' : 'the dev process: src/index.ts, restarted on save',
37
+ examples: [['start it', `${name} dev`]],
38
+ run: devScript ? { cmd: ['bun', 'run', 'dev'] } : { cmd: ['bun', '--watch', 'src/index.ts'] },
39
+ essential: {},
40
+ }
41
+ }
42
+
43
+ const q = (s: string): string => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
44
+
45
+ /** the example family: a row over code that exists, and the shot row a visual
46
+ * project uncomments once it has a renderer */
47
+ export function exampleFamily(name: string, devScript = false): string {
48
+ const v = exampleRow(name, devScript)
49
+ const run = 'cmd' in v.run ? `{ cmd: [${v.run.cmd.map(q).join(', ')}] }` : ''
50
+ return `// a command over code that exists is a row: run is { bun: <script> }, { cmd: [...] }
51
+ // or { tool, cold } (a warm host, else the cold script). Mark an everyday row
52
+ // \`essential: {}\`. \`sk ci\` rewrites the agent pages (cli-pages.check.shipkit.ts), or: ${pagesCommand(name)}
53
+ import { verbs } from 'rvlib-shipkit/cli-kit'
54
+
55
+ export const commands = verbs([
56
+ {
57
+ path: ${q(v.path)},
58
+ category: ${q(v.category)},
59
+ description: ${q(v.description)},
60
+ examples: [[${v.examples[0]!.map(q).join(', ')}]],
61
+ run: ${run},
62
+ essential: {},
63
+ },
64
+ // a visual project: any state as a png, the same command filmed by a trailer line.
65
+ // film: 'still' means the script takes --out <path>; 'clip' also takes --seconds <n>.
66
+ // {
67
+ // path: 'shot',
68
+ // category: 'Look',
69
+ // description: 'any state of the app as a png',
70
+ // examples: [['the home screen', '${name} shot home --out shot.png']],
71
+ // run: { bun: 'scripts/shot.ts' },
72
+ // film: 'still',
73
+ // },
74
+ ])
75
+ `
76
+ }
77
+
78
+ /** where a project CLI's two agent pages live */
79
+ export const cliPages = (name: string): { all: string; essentials: string } => ({ all: `agent/${name}.md`, essentials: `agent/${name}-essentials.md` })
80
+
81
+ /** the command that writes both pages */
82
+ export const pagesCommand = (name: string): string => `${name} commands --out ${cliPages(name).all} --essentials ${cliPages(name).essentials}`
83
+
84
+ /** the day-one pages, exactly as \`<name> commands --out … --essentials …\` writes them */
85
+ export function skeletonPages(name: string, devScript = false): { all: string; essentials: string } {
86
+ const cli = new Cli({ binaryName: name })
87
+ const builtins = builtinCommands(name)
88
+ const classes = [...verbs([exampleRow(name, devScript)]), ...builtins.docs, ...builtins.calls]
89
+ for (const c of classes) cli.register(c)
90
+ const defs = cli.definitions() as Definition[]
91
+ const rows = commandRows(defs, (cli.definitions as (o?: { detailed?: boolean }) => Definition[])({ detailed: true }), name, classes)
92
+ return {
93
+ all: spliceBlock(newCommandsPage(name, cliPages(name).all), renderMarkdown(rows, name), name)!,
94
+ essentials: renderEssentials(rows, name, pagesCommand(name)),
95
+ }
96
+ }
97
+
98
+ /** the package.json keys the project CLI needs, only where the key is free.
99
+ * Returns the keys it filled */
100
+ export function addCliKeys(pkg: { scripts?: Record<string, string>; bin?: Record<string, string> | string }, name: string): string[] {
101
+ const filled: string[] = []
102
+ pkg.scripts ??= {}
103
+ if (pkg.scripts.cli == null) { pkg.scripts.cli = 'bun src/cli.ts'; filled.push('scripts.cli') }
104
+ if (pkg.bin == null) { pkg.bin = { [name]: './src/cli.ts' }; filled.push(`bin.${name}`) }
105
+ return filled
106
+ }
107
+
108
+ /** what says a repo already has a CLI, its own or its product's: a `cli`
109
+ * script, any `bin`, any script that runs a cli.ts or cli.js of the repo, a
110
+ * src/cli.ts or a src/cli/ folder. null = the skeleton has room */
111
+ export function existingCli(pkg: { scripts?: Record<string, string>; bin?: unknown } | null, files: { entry: boolean; cliDir: boolean }): string | null {
112
+ if (pkg?.scripts?.cli != null) return `package.json scripts.cli runs \`${pkg.scripts.cli}\``
113
+ if (pkg?.bin != null) return `package.json has a bin (${typeof pkg.bin === 'string' ? pkg.bin : Object.keys(pkg.bin as object).join(', ')})`
114
+ // a script running an installed tool's cli (the house `hub` runs shipkit's) is not the repo's own
115
+ const runner = Object.entries(pkg?.scripts ?? {}).find(([, body]) => body.split(/\s+/).some((w) => /\bcli\.(ts|js)$/.test(w) && !/node_modules\/|rvlib-shipkit/.test(w)))
116
+ if (runner != null) return `package.json scripts.${runner[0]} runs \`${runner[1]}\``
117
+ if (files.entry) return 'src/cli.ts exists'
118
+ if (files.cliDir) return 'src/cli/ exists'
119
+ return null
120
+ }
121
+
122
+ /** whether an installed rvlib-shipkit carries the kit: null = none installed */
123
+ export function kitInstalled(installedPkg: { exports?: Record<string, unknown> } | null): boolean | null {
124
+ if (installedPkg == null) return null
125
+ return installedPkg.exports?.['./cli-kit'] != null
126
+ }
127
+
128
+ /** the check file that keeps both agent pages equal to the registry: the gate
129
+ * fails a stale page and its fix pass writes it again */
130
+ export function pagesCheckFile(name: string): string {
131
+ return `// agent/${name}.md and agent/${name}-essentials.md list the ${name} commands as the registry has them.
132
+ // A stale page sends an agent at a command that does not exist; the gate's fix pass writes both again
133
+ import { check } from 'rvlib-shipkit/src/check.ts'
134
+ import { cliPagesCheck } from 'rvlib-shipkit/src/cli/kit/pages-check.ts'
135
+
136
+ const pages = cliPagesCheck('${name}')
137
+
138
+ export default check({
139
+ description: pages.description,
140
+ blocking: true,
141
+ runs: { onPreCommit: false, onCi: true, onFull: true, onRelease: true, onHealth: true, onSave: 'moved', onClock: false },
142
+ cache: { by: 'files', inputs: pages.inputs, key: null },
143
+ fix: 'ci',
144
+ applies: 'always',
145
+ run: pages.run,
146
+ repair: pages.repair,
147
+ })
148
+ `
149
+ }
@@ -12,14 +12,14 @@ BEFORE the change it describes.
12
12
 
13
13
  **Always loaded every session:**
14
14
 
15
- @coding.md
15
+ @coding.md{{CLI_INCLUDE}}
16
16
 
17
17
  Read the matching page before touching a surface:
18
18
 
19
19
  | read when touching… | page |
20
20
  |---|---|
21
21
  | any source file | `agent/coding.md` |
22
- | recent changes and why | `agent/changelog.md` |
22
+ | recent changes and why | `agent/changelog.md` |{{CLI_ROW}}
23
23
 
24
24
  Grow this table: a subsystem, a service, a runbook each earn their own page,
25
25
  cross-linked from here.
@@ -23,6 +23,9 @@
23
23
  "scripts/*": ["./scripts/*"]
24
24
  },
25
25
 
26
+ // no @types package loads unless named here: bun's globals (Bun, import.meta.dir)
27
+ "types": ["bun"],
28
+
26
29
  // every file is a module even with no import/export (standalone scripts)
27
30
  "moduleDetection": "force",
28
31
 
@@ -12,7 +12,7 @@ description: Prove a change in THIS project actually works — drive the real fl
12
12
  ## The rules (house-wide)
13
13
  - Failing repro test BEFORE the fix. Green checks ≠ verification.
14
14
  - Logic → fast headless bun tests, run through `sk ci --only test` (tiny ~10-line test files reusing fixtures). While you fix one bug, `bun run test:raw <test file>` is the narrow way in, and one test by name is `bun run test:raw -- -t "<name>"`.
15
- - Look & feel → NEVER screenshot-self-verify; the repo owner playtests it.
15
+ - Look & feel → your own screenshot is a check before you report, never the verdict; the repo owner playtests it.
16
16
  - Verify as the end user would: drive the real flow, observe the real signal.
17
17
 
18
18
  ## This project
@@ -13,6 +13,7 @@
13
13
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
14
14
  import { dirname, join } from 'path'
15
15
  import { defaultHubPort } from '../serve/hub-port.ts'
16
+ import { addCliKeys, cliEntry, cliPages, exampleFamily, existingCli, kitInstalled, pagesCheckFile, skeletonPages } from './cli-skeleton.ts'
16
17
 
17
18
  const FILES = join(import.meta.dir, 'files')
18
19
 
@@ -33,6 +34,8 @@ export type ScaffoldOpts = {
33
34
  * a caller that declares a house (start seeds include lines) turns them
34
35
  * on, and the grant is printed the one time it lands */
35
36
  claudePolicy?: boolean
37
+ /** false: write no project CLI (the installed rvlib-shipkit predates the kit) */
38
+ projectCli?: boolean
36
39
  }
37
40
 
38
41
  /** every dep the house toolchain needs for the scripts below to run. The
@@ -110,16 +113,20 @@ export function scaffoldProject(root: string, opts: ScaffoldOpts): string[] {
110
113
  // agent index advertising a port nothing listens on, so the caller that
111
114
  // knows the name it is about to wire passes the port it resolves
112
115
  const port = opts.port ?? defaultHubPort(packageName(root) ?? opts.name)
113
- const vars = {
114
- NAME: opts.name,
115
- PORT: String(port),
116
- DATE: opts.today ?? new Date().toISOString().slice(0, 10),
117
- }
118
116
  const written: string[] = []
119
117
  const skipped: string[] = []
120
118
  const write = (rel: string, body: string): void => { (put(root, rel, body) ? written : skipped).push(rel) }
121
119
 
122
120
  write('src/index.ts', 'export {}\n')
121
+ // the project CLI on the kit, unless the dir already carries one, and its
122
+ // essentials page loaded by the agent index
123
+ const cliKeys = opts.projectCli !== false && writeCli(root, opts.name, write, p)
124
+ const vars = {
125
+ NAME: opts.name,
126
+ PORT: String(port),
127
+ DATE: opts.today ?? new Date().toISOString().slice(0, 10),
128
+ ...cliIndexVars(opts.name, cliKeys),
129
+ }
123
130
  write('tsconfig.json', template('tsconfig.json', vars))
124
131
  write('bunfig.toml', template('bunfig.toml', vars))
125
132
  // settings.json only here: it turns eslint off and keeps tsserver from
@@ -128,7 +135,7 @@ export function scaffoldProject(root: string, opts: ScaffoldOpts): string[] {
128
135
  write('.claude/skills/verify/SKILL.md', template('verify-skill.md', vars))
129
136
  writeHouseFiles(write, vars, opts.claudePolicy === true, written, p)
130
137
 
131
- addScripts(root, written, p)
138
+ addScripts(root, written, p, cliKeys ? opts.name : null)
132
139
  addIgnoreLines(root, written)
133
140
 
134
141
  if (written.length > 0) p(`ok: scaffolded ${written.length} file(s): ${written.slice(0, 6).join(', ')}${written.length > 6 ? `, +${written.length - 6} more` : ''}`)
@@ -166,9 +173,9 @@ function writeHouseFiles(write: (rel: string, body: string) => void, vars: Recor
166
173
  * include lines pull it into the house contract, so it gets the house half
167
174
  * of the day-one shape and nothing that says what the repo IS. Create-only,
168
175
  * so every file the repo already holds wins. Returns the paths it wrote */
169
- export function adoptHouse(root: string, opts: { name: string; today?: string; print?: (line: string) => void }): string[] {
176
+ export function adoptHouse(root: string, opts: { name: string; today?: string; print?: (line: string) => void; cli?: boolean }): string[] {
170
177
  const p = opts.print ?? console.log
171
- const vars = { NAME: opts.name, PORT: String(defaultHubPort(opts.name)), DATE: opts.today ?? new Date().toISOString().slice(0, 10) }
178
+ const vars = { NAME: opts.name, PORT: String(defaultHubPort(opts.name)), DATE: opts.today ?? new Date().toISOString().slice(0, 10), ...cliIndexVars(opts.name, opts.cli === true) }
172
179
  const written: string[] = []
173
180
  writeHouseFiles((rel, body) => { if (put(root, rel, body)) written.push(rel) }, vars, true, written, p)
174
181
  if (written.length > 0) p(`ok: adopted into the house, ${written.length} file(s) seeded: ${written.join(', ')}`)
@@ -193,8 +200,78 @@ What broke, which page or lib, what you expected. Gitignored on purpose,
193
200
  transient by design: it is harvested, then deleted.
194
201
  `
195
202
 
196
- /** the baseline scripts, added only where the key is free */
197
- function addScripts(root: string, written: string[], p: (line: string) => void): void {
203
+ /** the project CLI skeleton (cli-skeleton.ts): the entry, the example family,
204
+ * and whether package.json still needs its `cli` and `bin` keys. A dir that
205
+ * already has a project CLI keeps it alone */
206
+ function writeCli(root: string, name: string, write: (rel: string, body: string) => void, p: (line: string) => void): boolean {
207
+ const pkg = readPackage(root)
208
+ const found = existingCli(pkg, cliFiles(root))
209
+ if (found != null) { p(`skip: project CLI, ${found}`); return false }
210
+ writeSkeleton(root, name, pkg?.scripts?.dev != null, write)
211
+ return true
212
+ }
213
+
214
+ const cliFiles = (root: string): { entry: boolean; cliDir: boolean } => ({ entry: existsSync(join(root, 'src/cli.ts')), cliDir: existsSync(join(root, 'src/cli')) })
215
+
216
+ /** the skeleton's five files: the entry, the example family, its two pages
217
+ * and the check that keeps them current. The entry imports its families
218
+ * the house way when tsconfig maps src/, relatively otherwise */
219
+ function writeSkeleton(root: string, name: string, devScript: boolean, write: (rel: string, body: string) => void): void {
220
+ const pages = skeletonPages(name, devScript)
221
+ let absolute = true
222
+ try { absolute = readFileSync(join(root, 'tsconfig.json'), 'utf8').includes('"src/*"') } catch { absolute = false }
223
+ write('src/cli.ts', cliEntry(name, absolute))
224
+ write('src/cli/cmd_Dev.ts', exampleFamily(name, devScript))
225
+ write('src/cli/cli-pages.check.shipkit.ts', pagesCheckFile(name))
226
+ write(cliPages(name).all, pages.all)
227
+ write(cliPages(name).essentials, pages.essentials)
228
+ }
229
+
230
+ /** the installed rvlib-shipkit's manifest, null when none is installed */
231
+ export function installedLib(root: string): { version?: string; exports?: Record<string, unknown> } | null {
232
+ try { return JSON.parse(readFileSync(join(root, 'node_modules/rvlib-shipkit/package.json'), 'utf8')) as { version?: string; exports?: Record<string, unknown> } }
233
+ catch { return null }
234
+ }
235
+
236
+ /** the agent index lines that load the essentials page and list the full one */
237
+ const cliIndexVars = (name: string, cli: boolean): { CLI_INCLUDE: string; CLI_ROW: string } => ({
238
+ CLI_INCLUDE: cli ? `\n@${name}-essentials.md` : '',
239
+ CLI_ROW: cli ? `\n| every command of the project CLI | \`${cliPages(name).all}\` |` : '',
240
+ })
241
+
242
+ /** an ADOPTED repo asking for the project CLI (`sk start <name> --cli`): the
243
+ * skeleton and its package.json keys, refused when the repo already has a
244
+ * CLI of its own, because a second one splits the one door. Returns the
245
+ * paths it wrote, null on a refusal */
246
+ export function adoptCli(root: string, opts: { name: string; print?: (line: string) => void }): string[] | null {
247
+ const p = opts.print ?? console.log
248
+ const pkg = readPackage(root)
249
+ if (pkg == null) { p(`FAIL: no readable package.json in ${root} — the project CLI needs one`); return null }
250
+ const found = existingCli(pkg, cliFiles(root))
251
+ if (found != null) { p(`skip: ${opts.name} already has a CLI (${found}) — extend it; a project CLI beside a product's bin is wired by hand (build/cli-tool.md)`); return null }
252
+ const lib = installedLib(root)
253
+ if (kitInstalled(lib) === false) { p(`skip: the installed rvlib-shipkit ${lib?.version ?? ''} predates the CLI kit — bun add -d rvlib-shipkit@latest once a release carries it, then re-run`); return null }
254
+ const written: string[] = []
255
+ const write = (rel: string, body: string): void => { if (put(root, rel, body)) written.push(rel) }
256
+ writeSkeleton(root, opts.name, pkg.scripts?.dev != null, write)
257
+ addScripts(root, written, p, opts.name, false)
258
+ const index = join(root, 'agent/index.md')
259
+ const include = `@${opts.name}-essentials.md`
260
+ if (existsSync(index) && !readFileSync(index, 'utf8').split('\n').includes(include)) p(`next: in agent/index.md, load the everyday commands with the line ${include} and list \`${cliPages(opts.name).all}\` in its table`)
261
+ const deps = { ...(pkg as { dependencies?: object }).dependencies, ...(pkg as { devDependencies?: object }).devDependencies }
262
+ p(`ok: project CLI written: ${written.join(', ')} — run \`bun run cli\``)
263
+ if (!('rvlib-shipkit' in deps)) p('next: bun add -d rvlib-shipkit (the CLI imports rvlib-shipkit/cli-kit)')
264
+ return written
265
+ }
266
+
267
+ function readPackage(root: string): { name?: string; scripts?: Record<string, string> } | null {
268
+ try { return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { name?: string; scripts?: Record<string, string> } }
269
+ catch { return null }
270
+ }
271
+
272
+ /** the baseline scripts, added only where the key is free, plus the project
273
+ * CLI's `cli` and `bin` keys when the skeleton was written */
274
+ function addScripts(root: string, written: string[], p: (line: string) => void, cliName: string | null = null, baseline = true): void {
198
275
  const path = join(root, 'package.json')
199
276
  if (!existsSync(path)) return void p('WARN no package.json here — the typecheck script was not written')
200
277
  const raw = readFileSync(path, 'utf8')
@@ -203,11 +280,12 @@ function addScripts(root: string, written: string[], p: (line: string) => void):
203
280
  catch (e) { return void p(`WARN package.json is unparseable (${String(e).slice(0, 80)}) — the typecheck script was not written, repair it then re-run`) }
204
281
  pkg.scripts ??= {}
205
282
  let added = 0
206
- for (const [k, v] of Object.entries(SCRIPTS)) if (pkg.scripts[k] == null) { pkg.scripts[k] = v; added++ }
283
+ if (baseline) for (const [k, v] of Object.entries(SCRIPTS)) if (pkg.scripts[k] == null) { pkg.scripts[k] = v; added++ }
284
+ if (cliName != null) added += addCliKeys(pkg, cliName).length
207
285
  if (added === 0) return
208
286
  const indent = raw.match(/^(\s+)"/m)?.[1] ?? ' '
209
287
  writeFileSync(path, JSON.stringify(pkg, null, indent) + '\n')
210
- written.push(`package.json (${added} script(s))`)
288
+ written.push(`package.json (${added} key(s))`)
211
289
  }
212
290
 
213
291
  function packageName(root: string): string | null {
@@ -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)}
@@ -4,7 +4,8 @@
4
4
  // then the status as small pills that fold into +N rather than wrap. The
5
5
  // state is dock-st.ts; the height is the page's panel (hub-app.tsx).
6
6
  import { observer } from 'mobx-react-lite'
7
- import { useEffect, useState } from 'react'
7
+ import { useEffect, useState, type CSSProperties } from 'react'
8
+ import { idVars } from './projects-svc.ts'
8
9
  import { Tip } from './floating.tsx'
9
10
  import { Icon, ICON } from './icons.tsx'
10
11
  import type { HomeSt } from './home-st.ts'
@@ -57,8 +58,8 @@ const GroupLabel = observer(({ app, g }: { app: HomeSt; g: PlacedGroup }) => {
57
58
  return (
58
59
  <button className="dgroup" onClick={() => app.dock.openRepo(g.key)}
59
60
  title={`${g.key}: open the repo${g.hidden > 0 ? `\n${g.hidden} check run(s) did not fit in its lanes` : ''}`}>
60
- {p?.icon != null ? <img src={p.icon} alt="" width={12} height={12} draggable={false} /> : <i className="dgico" style={{ background: id.colorDark }} />}
61
- <span style={{ color: id.colorDark }}>{g.key}</span>
61
+ {p?.icon != null ? <img src={p.icon} alt="" width={12} height={12} draggable={false} /> : <i className="dgico idb" style={idVars(id) as CSSProperties} />}
62
+ <span className="idc" style={idVars(id) as CSSProperties}>{g.key}</span>
62
63
  {/* slim folds its lanes on purpose: the count is news only open */}
63
64
  {g.hidden > 0 && app.dock.size === 'open' ? <b>+{g.hidden}</b> : null}
64
65
  </button>
@@ -72,7 +73,7 @@ const PillFace = observer(({ app, p }: { app: HomeSt; p: DockPill }) => {
72
73
  <span className={`pchip ${p.tone}`}>
73
74
  {p.repo == null ? null : pj?.icon != null
74
75
  ? <img src={pj.icon} alt="" width={12} height={12} draggable={false} />
75
- : <i className="dgico" style={{ background: app.projects.identityOf(p.repo).colorDark }} />}
76
+ : <i className="dgico idb" style={idVars(app.projects.identityOf(p.repo)) as CSSProperties} />}
76
77
  {p.mark == null ? null : <b className={`pcmark ${p.markTone ?? 'dim'}`}>{p.mark}</b>}
77
78
  <span className="pctext">{p.text}</span>
78
79
  {p.age == null ? null : <span className="pcage">{p.age}</span>}
@@ -35,18 +35,26 @@ export type HashSel =
35
35
  | { t: 'when' }
36
36
  /** the checks one owner ships: shipkit's, or the repo's own */
37
37
  | { t: 'checks'; from: CheckOwner }
38
+ /** the review cards, `id` the open one, null for the first the list shows */
39
+ | { t: 'review'; id: string | null }
40
+ /** a trailer's cuts, played with their notes: a trailer folder and one of its cuts, null for the first; the note
41
+ * open on it, and the notes filter when it is not the page's default (all) */
42
+ | { t: 'trailer'; trailer: string | null; cut: string | null; note?: string | null; filter?: 'open' | 'all' | null }
38
43
 
39
44
  /** whose a gate check is */
40
45
  export type CheckOwner = 'shipkit' | 'repo'
41
46
 
42
47
  export type ParsedHash = {
43
- kind: 'asset' | 'cat' | 'col' | 'item' | 'status' | 'tests' | 'admin' | 'readme' | 'processes' | 'plan' | 'git' | 'when' | 'checks' | 'none'
48
+ kind: 'asset' | 'cat' | 'col' | 'item' | 'status' | 'tests' | 'admin' | 'readme' | 'processes' | 'plan' | 'git' | 'when' | 'checks' | 'review' | 'trailer' | 'none'
44
49
  /** the collection, for an item. null for everything else */
45
50
  col: string | null
46
51
  id: string
47
52
  /** the media tool set, when the address carried one. An empty array is a
48
53
  * choice (every tool off), null is no statement at all */
49
54
  tools: string[] | null
55
+ /** a trailer address only: the note open, the notes filter */
56
+ note?: string
57
+ filter?: 'open' | 'all'
50
58
  }
51
59
 
52
60
  const ITEM = 'item:'
@@ -62,6 +70,8 @@ const PLAN = 'page:plan'
62
70
  const GIT = 'page:git'
63
71
  const WHEN = 'page:when'
64
72
  const CHECKS = 'page:checks:'
73
+ const REVIEW = 'page:review'
74
+ const TRAILER = 'page:trailer'
65
75
 
66
76
  /** what this selection puts in the url. null only for no selection at all,
67
77
  * and then the hash is left alone rather than wiped */
@@ -81,6 +91,12 @@ export function hashFor(sel: HashSel | null): string | null {
81
91
  case 'git': return GIT
82
92
  case 'when': return WHEN
83
93
  case 'checks': return `${CHECKS}${sel.from}`
94
+ case 'review': return sel.id == null ? REVIEW : `${REVIEW}:${sel.id}`
95
+ case 'trailer': {
96
+ // page:trailer:<trailer>/<cut>/<note>?filter=all, each part only when it says something
97
+ const note = sel.trailer != null && sel.note ? `/${sel.cut ?? ''}/${sel.note}` : sel.cut == null ? '' : `/${sel.cut}`
98
+ return `${sel.trailer == null ? TRAILER : `${TRAILER}:${sel.trailer}${note}`}${sel.filter ? `?filter=${sel.filter}` : ''}`
99
+ }
84
100
  }
85
101
  }
86
102
 
@@ -95,6 +111,15 @@ export function parseHash(raw: string): ParsedHash {
95
111
  if (raw === PLAN) return { kind: 'plan', col: null, id: '', tools: null }
96
112
  if (raw === GIT) return { kind: 'git', col: null, id: '', tools: null }
97
113
  if (raw === WHEN) return { kind: 'when', col: null, id: '', tools: null }
114
+ if (raw === REVIEW || raw.startsWith(`${REVIEW}:`)) return { kind: 'review', col: null, id: raw.slice(REVIEW.length + 1), tools: null }
115
+ // the trailer folder rides in `col`, its cut in `id`
116
+ if (raw === TRAILER || raw.startsWith(`${TRAILER}:`) || raw.startsWith(`${TRAILER}?`)) {
117
+ const qi = raw.indexOf('?')
118
+ const path = qi === -1 ? raw : raw.slice(0, qi)
119
+ const [trailer = '', cut = '', note = ''] = path.slice(TRAILER.length + 1).split('/')
120
+ const f = qi === -1 ? null : new URLSearchParams(raw.slice(qi + 1)).get('filter')
121
+ return { kind: 'trailer', col: trailer === '' ? null : trailer, id: cut, tools: null, ...(note ? { note } : {}), ...(f === 'open' || f === 'all' ? { filter: f } : {}) }
122
+ }
98
123
  if (raw === `${CHECKS}shipkit` || raw === `${CHECKS}repo`) return { kind: 'checks', col: null, id: raw.slice(CHECKS.length), tools: null }
99
124
  if (raw.startsWith(`${PROCESSES}:`)) {
100
125
  const id = raw.slice(PROCESSES.length + 1)
@@ -148,6 +173,8 @@ export function selOf(p: ParsedHash): HashSel | null {
148
173
  case 'git': return { t: 'git' }
149
174
  case 'when': return { t: 'when' }
150
175
  case 'checks': return { t: 'checks', from: p.id as CheckOwner }
176
+ case 'review': return { t: 'review', id: p.id === '' ? null : p.id }
177
+ case 'trailer': return { t: 'trailer', trailer: p.col, cut: p.id === '' ? null : p.id, ...(p.note ? { note: p.note } : {}), ...(p.filter ? { filter: p.filter } : {}) }
151
178
  }
152
179
  }
153
180
 
@@ -14,6 +14,8 @@ import { Icon, ICON } from './icons.tsx'
14
14
  import { Pop, Tip } from './floating.tsx'
15
15
  import { FloatCards, type Card } from './float-cards.tsx'
16
16
  import type { RepoSt } from './repo-st.ts'
17
+ import type { CSSProperties } from 'react'
18
+ import { idVars } from './projects-svc.ts'
17
19
 
18
20
  /** the repo's hub has not answered on the home: its reason and its fix show
19
21
  * instead of the page. A screen this browser kept shows meanwhile, until
@@ -113,7 +115,7 @@ export const RepoTools = observer(({ app }: { app: RepoSt }) => (
113
115
  export const HeaderBar = observer(({ app }: { app: RepoSt }) => (
114
116
  <>
115
117
  <header className="hdr">
116
- <span className="hname" style={app.name === '' ? undefined : { color: app.home.projects.identityOf(app.name).colorDark }}>{app.name !== '' ? app.name : 'hub'}</span>
118
+ <span className={app.name === '' ? 'hname' : 'hname idc'} style={app.name === '' ? undefined : (idVars(app.home.projects.identityOf(app.name)) as CSSProperties)}>{app.name !== '' ? app.name : 'hub'}</span>
117
119
  <span className="sp" />
118
120
  <RepoTools app={app} />
119
121
  <RepoRun app={app} />
@@ -14,6 +14,7 @@ import { makeAutoObservable, observableShallow, reaction, type IReactionDisposer
14
14
  import { currentHash, hashFor } from './hash-route.ts'
15
15
  import { MachineSt, type MachineOpts } from './machine-st.ts'
16
16
  import { LayoutSt } from './layout-st.ts'
17
+ import { ThemeSt } from './theme-st.ts'
17
18
  import { DockSt } from './dock-st.ts'
18
19
  import { PaletteSt } from './palette-st.ts'
19
20
  import { PlannedSt } from './planned-st.ts'
@@ -60,6 +61,8 @@ export const plainClick = (e: { button: number; metaKey: boolean; ctrlKey: boole
60
61
  export type HomeOpts = MachineOpts & {
61
62
  /** true on the home's web; false on a hub serving on its own */
62
63
  home: boolean
64
+ /** the one repo's base, read from where the page sits: the embed page under a proxy's prefix (embed.ts) */
65
+ base?: string
63
66
  }
64
67
 
65
68
  export class HomeSt {
@@ -78,6 +81,8 @@ export class HomeSt {
78
81
  layout: LayoutSt
79
82
  /** the run chart docked under the whole page */
80
83
  dock: DockSt
84
+ /** dark, light or the system's */
85
+ readonly theme = new ThemeSt()
81
86
  page: Page
82
87
  /** the key of the repo picked last: its name on the home, '' for the one
83
88
  * repo of a hub of its own. A global page keeps it (only `shown` says
@@ -90,7 +95,10 @@ export class HomeSt {
90
95
  private prefetching = false
91
96
  private readonly onUrl = (): void => this.fromUrl()
92
97
 
98
+ private readonly base: string | undefined
99
+
93
100
  constructor(o: HomeOpts) {
101
+ this.base = o.base
94
102
  this.projects = new ProjectsSvc()
95
103
  this.planned = new PlannedSt(this)
96
104
  this.activity = new ActivitySt(this)
@@ -100,7 +108,7 @@ export class HomeSt {
100
108
  this.dock = new DockSt(this)
101
109
  this.machine = o.home ? new MachineSt(this, o) : null
102
110
  this.page = o.home ? 'overview' : 'repo'
103
- makeAutoObservable<this, 'disposers' | 'onUrl' | 'prefetching'>(this, { machine: false, projects: false, planned: false, activity: false, insights: false, palette: false, layout: false, dock: false, repos: observableShallow, disposers: false, onUrl: false, prefetching: false })
111
+ makeAutoObservable<this, 'disposers' | 'onUrl' | 'prefetching' | 'base'>(this, { base: false, machine: false, projects: false, planned: false, activity: false, insights: false, palette: false, layout: false, theme: false, dock: false, repos: observableShallow, disposers: false, onUrl: false, prefetching: false })
104
112
  const m = this.machine
105
113
  if (m != null) {
106
114
  this.projects.onSelect = (name) => this.select(name)
@@ -143,7 +151,7 @@ export class HomeSt {
143
151
  repo(key: string): RepoSt {
144
152
  let r = this.repos.get(key)
145
153
  if (r == null) {
146
- r = new RepoSt(this, key, this.machine == null ? '' : repoBase(key))
154
+ r = new RepoSt(this, key, this.base ?? (this.machine == null ? '' : repoBase(key)))
147
155
  this.repos.set(key, r)
148
156
  // what this browser kept of it paints before its hub answers
149
157
  void r.cols.restore()
@@ -15,7 +15,8 @@
15
15
  // while its hub cannot answer, with the fix. Every link of the home is a
16
16
  // page of the same app: a plain click moves it in place.
17
17
  import { observer } from 'mobx-react-lite'
18
- import type { ReactNode } from 'react'
18
+ import type { CSSProperties, ReactNode } from 'react'
19
+ import { idVars } from './projects-svc.ts'
19
20
  import type { OpenHow, ProcessIcon, ResourceView } from '../../home/resources.ts'
20
21
  import type { WorkerView } from '../../home/hubs.ts'
21
22
  import { NOT_INSTALLED } from '../../home/home-words.ts'
@@ -238,7 +239,7 @@ const ShownRepo = observer(({ app, name }: { app: HomeSt; name: string }) => {
238
239
  <span className="hrepo">
239
240
  {/* the icon's box is there before the project list is, so the name never moves */}
240
241
  {p?.icon != null ? <img src={p.icon} alt="" width={18} height={18} /> : <i className="hrico" />}
241
- <b className="hrname" style={{ color: id.colorDark }}>{name}</b>
242
+ <b className="hrname idc" style={idVars(id) as CSSProperties}>{name}</b>
242
243
  </span>
243
244
  )
244
245
  })
@@ -14,6 +14,7 @@
14
14
  <link rel="stylesheet" href="./readme.css" />
15
15
  <link rel="stylesheet" href="./dock.css" />
16
16
  <link rel="stylesheet" href="./controls.css" />
17
+ <link rel="stylesheet" href="./review.css" />
17
18
  </head>
18
19
  <body data-home>
19
20
  <div id="root"></div>