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,177 @@
1
+ // every run of a CLI, logged: a start line at launch, an end line at any exit
2
+ // with its time, CPU and memory, the same id on both. A start with no end was
3
+ // killed or still runs. JSON lines, one append each, so runs at once never mix.
4
+ // `<cli> calls` reads it back, so a speed pass starts from what agents waited on.
5
+ // `<CLI>_LOG=off` writes nothing, and neither does a run under `bun test`. Past
6
+ // 2 MB the log moves to `<file>.1` and starts again, so it never grows unbounded.
7
+ import { appendFileSync, mkdirSync, renameSync, statSync } from 'fs'
8
+ import { dirname, join } from 'path'
9
+ import { LAYOUT } from '../../core/layout.ts'
10
+
11
+ export type CallStart = {
12
+ id: string
13
+ at: string
14
+ argv: string[]
15
+ cwd: string
16
+ pid: number
17
+ /** `<CLI>_AGENT`, else null: who ran it */
18
+ agent: string | null
19
+ /** the run of the same CLI that spawned this one, null for a run of its own */
20
+ parent: string | null
21
+ }
22
+
23
+ export type CallEnd = {
24
+ id: string
25
+ /** the command's path, null when none matched (a listing, a typo) */
26
+ cmd: string | null
27
+ /** from the process start */
28
+ ms: number
29
+ /** building the registry: the import of the families the call needed */
30
+ loadMs: number
31
+ cpuMs: number
32
+ rssMb: number
33
+ code: number
34
+ }
35
+
36
+ /** where a CLI's calls go: `<root>/.shipkit/logs/<bin>-calls.jsonl` */
37
+ export const callsFile = (root: string, bin: string): string => join(root, LAYOUT.logs, `${bin}-calls.jsonl`)
38
+
39
+ /** the env prefix of a CLI: `my-tool` → `MY_TOOL` */
40
+ export const envPrefix = (bin: string): string => bin.toUpperCase().replace(/[^A-Z0-9]/g, '_')
41
+
42
+ /** past this size the log moves to `<file>.1` (replacing the older one) and starts again */
43
+ export const CALLS_MAX_BYTES = 2 * 1024 * 1024
44
+
45
+ /** a long argument (a pasted text) is cut, so a line stays one append */
46
+ const ARG_MAX = 300
47
+
48
+ /** logs this run's start, and its end on any exit. The caller names the
49
+ * command and the registry load time once it knows them */
50
+ export function logCall(file: string, bin: string, argv: readonly string[], env: NodeJS.ProcessEnv = process.env): { matched(cmd: string | null, loadMs: number): void } {
51
+ const pre = envPrefix(bin)
52
+ if (env[`${pre}_LOG`] === 'off' || env.NODE_ENV === 'test') return { matched: () => {} }
53
+ let warned = false
54
+ try { if (statSync(file).size > CALLS_MAX_BYTES) renameSync(file, `${file}.1`) } catch { /* no log yet */ }
55
+ const append = (line: object): void => {
56
+ try {
57
+ mkdirSync(dirname(file), { recursive: true })
58
+ appendFileSync(file, `${JSON.stringify(line)}\n`)
59
+ } catch (e) {
60
+ if (!warned) console.error(`WARN ${bin}: the call log could not be written (${file}): ${(e as Error).message}`)
61
+ warned = true
62
+ }
63
+ }
64
+ const id = `${Date.now().toString(36)}-${process.pid}`
65
+ const end: Pick<CallEnd, 'cmd' | 'loadMs'> = { cmd: null, loadMs: 0 }
66
+ const start: CallStart = {
67
+ id,
68
+ at: new Date().toISOString(),
69
+ argv: argv.map((a) => (a.length > ARG_MAX ? `${a.slice(0, ARG_MAX)}…` : a)),
70
+ cwd: process.cwd(),
71
+ pid: process.pid,
72
+ agent: env[`${pre}_AGENT`]?.trim() || null,
73
+ parent: env[`${pre}_CALL`] ?? null,
74
+ }
75
+ append(start)
76
+ env[`${pre}_CALL`] = id
77
+ process.on('exit', (code) => {
78
+ const cpu = process.cpuUsage()
79
+ append({ id, ...end, ms: Math.round(performance.now()), cpuMs: Math.round((cpu.user + cpu.system) / 1000), rssMb: Math.round(process.resourceUsage().maxRSS / 1024), code } satisfies CallEnd)
80
+ })
81
+ return { matched: (cmd, loadMs) => { end.cmd = cmd; end.loadMs = Math.round(loadMs) } }
82
+ }
83
+
84
+ export type CallsQuery = {
85
+ /** epoch ms: only the runs that started after */
86
+ since?: number
87
+ /** a command path prefix */
88
+ cmd?: string
89
+ /** a folder: only the runs started inside it */
90
+ under?: string
91
+ /** keep the runs another run of the same CLI spawned */
92
+ nested?: boolean
93
+ /** how many slowest runs to list */
94
+ top?: number
95
+ /** the id of the run asking, which has no end yet */
96
+ self?: string
97
+ }
98
+
99
+ export type CallsRow = { cmd: string; runs: number; noEnd: number; failed: number; p50: number; p95: number; max: number; totalS: number; loadP50: number; rssMax: number }
100
+ export type CallsReport = { runs: number; rows: CallsRow[]; slowest: { ms: number; at: string; agent: string | null; argv: string }[] }
101
+
102
+ /** `30m`, `2h`, `3d` or a date, as epoch ms; null when unreadable */
103
+ export function sinceOf(s: string, now: number): number | null {
104
+ const m = /^(\d+)([mhd])$/.exec(s)
105
+ if (m) return now - Number(m[1]) * { m: 60e3, h: 3600e3, d: 86400e3 }[m[2] as 'm' | 'h' | 'd']
106
+ const t = Date.parse(s)
107
+ return Number.isNaN(t) ? null : t
108
+ }
109
+
110
+ const quantile = (xs: number[], p: number): number => {
111
+ if (xs.length === 0) return 0
112
+ const s = [...xs].sort((a, b) => a - b)
113
+ return s[Math.min(s.length - 1, Math.floor(p * s.length))] ?? 0
114
+ }
115
+
116
+ /** the call log read back: per command how often, how long, how heavy, and the slowest runs */
117
+ export function callsReport(text: string, q: CallsQuery = {}): CallsReport {
118
+ const starts = new Map<string, CallStart>()
119
+ const ends = new Map<string, CallEnd>()
120
+ for (const line of text.split('\n')) {
121
+ if (line.trim() === '') continue
122
+ let r: CallStart | CallEnd
123
+ try { r = JSON.parse(line) as CallStart | CallEnd } catch { continue }
124
+ if ('at' in r) starts.set(r.id, r)
125
+ else ends.set(r.id, r)
126
+ }
127
+ type Run = { s: CallStart; e: CallEnd | null; cmd: string }
128
+ const runs: Run[] = []
129
+ for (const s of starts.values()) {
130
+ if (s.id === q.self || Date.parse(s.at) < (q.since ?? 0) || (q.nested !== true && s.parent != null)) continue
131
+ if (q.under != null && s.cwd !== q.under && !s.cwd.startsWith(`${q.under}/`)) continue
132
+ const e = ends.get(s.id) ?? null
133
+ const flag = s.argv.findIndex((a) => a.startsWith('-'))
134
+ const words = flag === -1 ? s.argv : s.argv.slice(0, flag)
135
+ const cmd = e?.cmd ?? (words.slice(0, 2).join(' ') || '(none)')
136
+ if (q.cmd != null && !cmd.startsWith(q.cmd)) continue
137
+ runs.push({ s, e, cmd })
138
+ }
139
+ const byCmd = new Map<string, Run[]>()
140
+ for (const r of runs) byCmd.set(r.cmd, [...(byCmd.get(r.cmd) ?? []), r])
141
+ const rows = [...byCmd].map(([cmd, rs]): CallsRow => {
142
+ const done = rs.flatMap((r) => (r.e ? [r.e] : []))
143
+ const ms = done.map((e) => e.ms)
144
+ return {
145
+ cmd,
146
+ runs: rs.length,
147
+ noEnd: rs.length - done.length,
148
+ failed: done.filter((e) => e.code !== 0).length,
149
+ p50: quantile(ms, 0.5),
150
+ p95: quantile(ms, 0.95),
151
+ max: Math.max(0, ...ms),
152
+ totalS: Math.round(ms.reduce((a, b) => a + b, 0) / 1000),
153
+ loadP50: quantile(done.map((e) => e.loadMs), 0.5),
154
+ rssMax: Math.max(0, ...done.map((e) => e.rssMb)),
155
+ }
156
+ })
157
+ rows.sort((a, b) => b.totalS - a.totalS || b.p95 - a.p95 || a.cmd.localeCompare(b.cmd))
158
+ const slowest = runs
159
+ .flatMap((r) => (r.e ? [{ ms: r.e.ms, at: r.s.at, agent: r.s.agent, argv: r.s.argv.join(' ') }] : []))
160
+ .sort((a, b) => b.ms - a.ms)
161
+ .slice(0, q.top ?? 10)
162
+ return { runs: runs.length, rows, slowest }
163
+ }
164
+
165
+ /** the report as a table, then the slowest runs */
166
+ export function renderCallsReport(bin: string, r: CallsReport, file: string): string {
167
+ const head = ['command', 'runs', 'no end', 'failed', 'p50 ms', 'p95 ms', 'max ms', 'total s', 'load p50', 'rss max MB']
168
+ const table = [head, ...r.rows.map((x) => [x.cmd, x.runs, x.noEnd, x.failed, x.p50, x.p95, x.max, x.totalS, x.loadP50, x.rssMax].map(String))]
169
+ const widths = head.map((_, i) => Math.max(...table.map((t) => (t[i] ?? '').length)))
170
+ const out = [`${r.runs} runs · ${file}`, 'a run with no end line was killed or still runs; time is from the process start, load is building the registry', '']
171
+ for (const t of table) out.push(t.map((c, i) => (i === 0 ? c.padEnd(widths[i] ?? 0) : c.padStart(widths[i] ?? 0))).join(' '))
172
+ if (r.slowest.length > 0) {
173
+ out.push('', `the ${r.slowest.length} slowest runs`)
174
+ for (const s of r.slowest) out.push(`${String(s.ms).padStart(8)} ms ${s.at.slice(0, 19)} ${s.agent ?? '-'} ${bin} ${s.argv}`)
175
+ }
176
+ return `${out.join('\n')}\n`
177
+ }
@@ -0,0 +1,67 @@
1
+ // a project CLI is a table of families: the first word of a call loads the
2
+ // family that owns it and nothing else, a word nobody owns (a listing, a typo)
3
+ // loads them all. A family file exports `commands` and imports nothing heavy
4
+ // at its top, so even the full load answers in milliseconds.
5
+ import type { CommandClass } from './verb.ts'
6
+
7
+ export type Family = {
8
+ /** the first words its commands start with */
9
+ roots: string[]
10
+ load: () => Promise<{ commands: CommandClass[] }>
11
+ /** its commands read the whole registry (the docs): every family loads with it */
12
+ all?: true
13
+ }
14
+
15
+ /** the families a call needs: the one owning its first word, else every one */
16
+ export function familiesFor<F extends Family>(argv: readonly string[], families: readonly F[]): F[] {
17
+ const own = families.find((f) => f.roots.includes(argv[0] ?? ''))
18
+ return own != null && own.all !== true ? [own] : [...families]
19
+ }
20
+
21
+ /** the first words two families both claim: a call would load the wrong one */
22
+ export function rootClashes(families: readonly Family[]): string[] {
23
+ const seen = new Set<string>()
24
+ const out = new Set<string>()
25
+ for (const f of families) for (const r of f.roots) (seen.has(r) ? out : seen).add(r)
26
+ return [...out]
27
+ }
28
+
29
+ /** what clipanion tells of a registered command */
30
+ export type ListedDefinition = { path: string; category?: string | null; description?: string | null }
31
+
32
+ const HELP_WORDS = ['--help', '-h', 'help']
33
+
34
+ /** a definition's path without the binary name clipanion puts in front */
35
+ export const stripBin = (bin: string, path: string): string => (path.startsWith(`${bin} `) ? path.slice(bin.length + 1) : path)
36
+
37
+ /** the family word a call asks a listing of: '' for every command, a first
38
+ * word for its family, null to run the call. A word that is itself a command runs */
39
+ export function listingOf(bin: string, argv: readonly string[], defs: readonly ListedDefinition[]): string | null {
40
+ if (argv.length === 0 || (argv.length === 1 && HELP_WORDS.includes(argv[0]!))) return ''
41
+ if (argv.length > 2 || (argv.length === 2 && !HELP_WORDS.includes(argv[1]!))) return null
42
+ const word = argv[0]!
43
+ const paths = defs.map((d) => stripBin(bin, d.path))
44
+ if (paths.includes(word) || !paths.some((p) => p.startsWith(`${word} `))) return null
45
+ return word
46
+ }
47
+
48
+ /** every command of a family (all when ''), one line each, grouped by category */
49
+ export function listing(bin: string, defs: readonly ListedDefinition[], word: string, label?: string): string {
50
+ const rows = defs
51
+ .map((d) => ({ path: stripBin(bin, d.path), category: (d.category ?? '').trim() || 'Other', description: (d.description ?? '').trim() }))
52
+ .filter((r) => !r.path.startsWith('-') && (word === '' || r.path === word || r.path.startsWith(`${word} `)))
53
+ .sort((a, b) => a.category.localeCompare(b.category) || a.path.localeCompare(b.path))
54
+ const width = Math.max(0, ...rows.map((r) => r.path.length)) + bin.length + 3
55
+ const out = word === '' && label != null ? [label, ''] : []
56
+ let category = ''
57
+ for (const r of rows) {
58
+ if (r.category !== category) {
59
+ if (category !== '') out.push('')
60
+ out.push(r.category)
61
+ category = r.category
62
+ }
63
+ out.push(` ${`${bin} ${r.path}`.padEnd(width)}${r.description}`)
64
+ }
65
+ out.push('', `${bin} <command> --help: its usage, its examples and its flags`)
66
+ return `${out.join('\n')}\n`
67
+ }
@@ -0,0 +1,8 @@
1
+ // the project CLI kit: what a repo's `src/cli.ts` and its family files import
2
+ // (`rvlib-shipkit/cli-kit`). Command and Option come from here, so a project
3
+ // needs no clipanion of its own and its classes match the kit's.
4
+ export { Command, Option } from 'clipanion'
5
+ export { type Family, familiesFor, listing, listingOf } from './families.ts'
6
+ export { callsFile, logCall } from './calls.ts'
7
+ export { type ProjectCliSpec, projectCli, runProjectCli } from './project-cli.ts'
8
+ export { argvOf, type AskTool, type CommandClass, type Essential, type Film, filmCall, type KitContext, type Run, type Verb, verbs } from './verb.ts'
@@ -0,0 +1,36 @@
1
+ // the body of a project CLI's pages check (the file `sk start` writes in
2
+ // src/cli/): the CLI's `commands --check` says whether both agent pages match
3
+ // the registry, and the repair writes them. The check file itself declares
4
+ // where it runs, so the policy stays readable in the repo.
5
+ import type { FixResult } from '../../check.ts'
6
+ import type { CheckCtx } from '../../core/check-ctx.ts'
7
+
8
+ /** the first FAIL line of a run, else what it printed on one line */
9
+ const said = (out: string): string => {
10
+ const lines = out.trim().split('\n')
11
+ return lines.find((l) => l.startsWith('FAIL')) ?? lines.join('; ')
12
+ }
13
+
14
+ export function cliPagesCheck(name: string, entry = 'src/cli.ts'): {
15
+ description: string
16
+ inputs: string[]
17
+ run: (c: CheckCtx) => Promise<void>
18
+ repair: (c: CheckCtx) => Promise<FixResult>
19
+ } {
20
+ const all = `agent/${name}.md`
21
+ const essentials = `agent/${name}-essentials.md`
22
+ const gen = ['bun', entry, 'commands', '--out', all, '--essentials', essentials]
23
+ return {
24
+ description: `the ${name} command pages match its registry`,
25
+ inputs: [entry, entry.replace(/\.ts$/, ''), all, essentials],
26
+ run: async (c) => {
27
+ const r = await c.exec([...gen, '--check'], { cwd: c.root, env: { [`${name.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_LOG`]: 'off' } })
28
+ if (r.code === 0) c.ok(said(r.out))
29
+ else c.fail(said(r.out), `${name} ${gen.slice(2).join(' ')}`)
30
+ },
31
+ repair: async (c) => {
32
+ const r = await c.exec(gen, { cwd: c.root })
33
+ return { changed: r.code === 0 && r.out.includes('rewritten'), summary: said(r.out) }
34
+ },
35
+ }
36
+ }
@@ -0,0 +1,73 @@
1
+ // a project's own CLI in one call: `runProjectCli({ name, root, families })`.
2
+ // It loads only the family the first word needs, lists one line per command
3
+ // when asked for a listing, logs every run, and adds `commands` (the agent
4
+ // pages, generated) and `calls` (the log, read back). The bar: the bare
5
+ // listing answers in well under a second.
6
+ import { type BaseContext, Builtins, Cli } from 'clipanion'
7
+ import { callsFile, logCall } from './calls.ts'
8
+ import { type Family, familiesFor, listing, listingOf, rootClashes } from './families.ts'
9
+ import type { AskTool, CommandClass, KitContext } from './verb.ts'
10
+
11
+ export type ProjectCliSpec = {
12
+ /** the binary name: the listing, the examples, the log file and its env prefix use it */
13
+ name: string
14
+ /** the repo root: rows run from it, the call log lives under it */
15
+ root: string
16
+ families: Family[]
17
+ /** one line above the bare listing */
18
+ label?: string
19
+ version?: string
20
+ /** the warm path of `{ tool }` rows */
21
+ askTool?: AskTool
22
+ }
23
+
24
+ /** the kit's own families, after the project's */
25
+ function kitFamilies(bin: string): Family[] {
26
+ const builtins = async () => (await import('./builtins.ts')).builtinCommands(bin)
27
+ return [
28
+ { roots: ['commands'], load: async () => ({ commands: (await builtins()).docs }), all: true },
29
+ { roots: ['calls'], load: async () => ({ commands: (await builtins()).calls }) },
30
+ ]
31
+ }
32
+
33
+ /** what the kit adds to clipanion's context */
34
+ export type KitExtra = Omit<KitContext, keyof BaseContext>
35
+
36
+ /** the cli with the families `argv` needs, and the context its commands run in */
37
+ export async function projectCli(spec: ProjectCliSpec, argv: readonly string[], cwd = process.cwd()): Promise<{ cli: Cli<KitContext>; context: KitExtra }> {
38
+ const families = [...spec.families, ...kitFamilies(spec.name)]
39
+ const clash = rootClashes(families)
40
+ if (clash.length > 0) throw new Error(`${spec.name}: two families claim the first word ${clash.map((w) => `'${w}'`).join(', ')}, give each word one family`)
41
+ const cli = new Cli<KitContext>({ binaryLabel: spec.label ?? spec.name, binaryName: spec.name, binaryVersion: spec.version, enableCapture: true, enableColors: process.stdout.isTTY === true && (process.env.NO_COLOR ?? '') === '' })
42
+ cli.register(Builtins.HelpCommand)
43
+ if (spec.version != null) cli.register(Builtins.VersionCommand)
44
+ const need = familiesFor(argv, families)
45
+ const classes: CommandClass[] = []
46
+ for (const mod of await Promise.all(need.map((f) => f.load()))) for (const c of mod.commands) { cli.register(c); classes.push(c) }
47
+ return { cli, context: { root: spec.root, cwd, classes, askTool: spec.askTool } }
48
+ }
49
+
50
+ /** the path of the command a call runs, null when none matches */
51
+ function commandOf(cli: Cli<KitContext>, argv: string[], context: KitExtra): string | null {
52
+ try {
53
+ const path = (cli.process(argv, context).constructor as { paths?: string[][] }).paths?.[0]?.join(' ') ?? null
54
+ return path?.startsWith('-') === true ? 'help' : path
55
+ } catch {
56
+ return null
57
+ }
58
+ }
59
+
60
+ const closedPipe = (e: unknown): boolean => e instanceof Error && 'code' in e && e.code === 'EPIPE'
61
+
62
+ /** the whole entry of a project CLI: log, load, then list or run */
63
+ export async function runProjectCli(spec: ProjectCliSpec, argv: string[] = process.argv.slice(2)): Promise<void> {
64
+ // a reader that closes early (`<cli> | head`) ends the run quietly
65
+ process.stdout.on('error', (e) => (closedPipe(e) ? process.exit(0) : undefined))
66
+ const call = logCall(callsFile(spec.root, spec.name), spec.name, argv)
67
+ const t0 = performance.now()
68
+ const { cli, context } = await projectCli(spec, argv)
69
+ call.matched(commandOf(cli, argv, context), performance.now() - t0)
70
+ const word = listingOf(spec.name, argv, cli.definitions())
71
+ if (word !== null) { process.stdout.write(listing(spec.name, cli.definitions(), word, spec.label)); return }
72
+ await cli.runExit(argv, context)
73
+ }
@@ -0,0 +1,167 @@
1
+ // a project CLI command over code that already exists is a ROW, never a second
2
+ // implementation: `verbs([...])` turns rows into clipanion commands that spawn
3
+ // the script, ask the warm tool, or run the command line, from the repo root.
4
+ // A family file imports this module and nothing heavy at its top: the bare
5
+ // listing loads every family.
6
+ import { type BaseContext, Command, type CommandClass as ClipanionClass, Option } from 'clipanion'
7
+ import { existsSync } from 'fs'
8
+ import { isAbsolute, resolve } from 'path'
9
+
10
+ /** what a kit command sees in `this.context`, beside clipanion's streams */
11
+ export type KitContext = BaseContext & {
12
+ /** the repo root: every row runs from it */
13
+ root: string
14
+ /** where the caller stood: a filmable row's `--out` resolves against it */
15
+ cwd: string
16
+ /** every command class registered in this process (the docs read their marks) */
17
+ classes: CommandClass[]
18
+ /** the warm path of a `{ tool }` row, when the project has a warm host */
19
+ askTool?: AskTool
20
+ }
21
+
22
+ export type CommandClass = ClipanionClass<KitContext>
23
+
24
+ /** asks a warm process for a tool's answer; null = no warm host, run the cold script */
25
+ export type AskTool = (tool: string, args: string[]) => Promise<number | null>
26
+
27
+ /** how a row runs */
28
+ export type Run =
29
+ /** `bun <script> ...pre ...args` */
30
+ | { bun: string; pre?: string[] }
31
+ /** the warm host's answer when it runs (`askTool`), else `bun <cold> ...pre ...args` */
32
+ | { tool: string; cold: string; pre?: string[] }
33
+ /** any command line, the args appended */
34
+ | { cmd: string[] }
35
+
36
+ /** a row that shoots a state: `still` writes one image at `--out`, `clip` a video, its length `--seconds` */
37
+ export type Film = 'still' | 'clip'
38
+
39
+ /** an everyday command, listed on the essentials page every session loads */
40
+ export type Essential = {
41
+ /** its arguments as an agent types them: '<x> <y>' */
42
+ args?: string
43
+ /** how many of its examples the page shows under its line */
44
+ examples?: number
45
+ }
46
+
47
+ export type Verb = {
48
+ /** the words after the binary: 'world at' */
49
+ path: string
50
+ /** the group the listing prints it under */
51
+ category: string
52
+ /** one line, under 100 characters */
53
+ description: string
54
+ /** the longer text `--help` prints */
55
+ details?: string
56
+ /** [label, command line], each line starting with the binary and the row's words */
57
+ examples: [string, string][]
58
+ run: Run
59
+ /** 'own': `--help` also runs the tool with `--help`, which prints its flags and exits 0 */
60
+ help?: 'own'
61
+ essential?: Essential
62
+ /** filmable: takes `--out <path>`, a clip also `--seconds <n>` (a trailer `film` line calls it) */
63
+ film?: Film
64
+ /** with film: the row's take version, a bump films every trailer take of the row again */
65
+ version?: number | string
66
+ /** with film: `own`, a clip that films as long as it plays (no `--seconds`) */
67
+ length?: 'own'
68
+ }
69
+
70
+ /** the argv a row runs */
71
+ export function argvOf(run: Run, args: readonly string[]): string[] {
72
+ if ('bun' in run) return ['bun', run.bun, ...(run.pre ?? []), ...args]
73
+ if ('tool' in run) return ['bun', run.cold, ...(run.pre ?? []), ...args]
74
+ return [...run.cmd, ...args]
75
+ }
76
+
77
+ /** what a row runs, as the commands page prints it */
78
+ export function runText(run: Run): string {
79
+ if ('bun' in run) return [run.bun, ...(run.pre ?? [])].join(' ')
80
+ if ('tool' in run) return `tool ${run.tool} (cold: ${[run.cold, ...(run.pre ?? [])].join(' ')})`
81
+ return run.cmd.join(' ')
82
+ }
83
+
84
+ /** the value after `flag` in args (`--out x` or `--out=x`), with its index */
85
+ function flagAt(args: readonly string[], flag: string): { i: number; value: string; joined: boolean } | null {
86
+ for (let i = 0; i < args.length; i++) {
87
+ const a = args[i]!
88
+ if (a === flag && i + 1 < args.length) return { i, value: args[i + 1]!, joined: false }
89
+ if (a.startsWith(`${flag}=`)) return { i, value: a.slice(flag.length + 1), joined: true }
90
+ }
91
+ return null
92
+ }
93
+
94
+ /** the film contract on one call: a usage error, or the args with `--out`
95
+ * made absolute against the caller's cwd and the file the run must leave */
96
+ export function filmCall(film: Film | undefined, args: readonly string[], cwd: string): { error: string } | { args: string[]; out: string | null } {
97
+ if (film == null) return { args: [...args], out: null }
98
+ if (film === 'still' && flagAt(args, '--seconds') != null) return { error: '--seconds films a clip, and this row films a still' }
99
+ const out = flagAt(args, '--out')
100
+ if (out == null) return { args: [...args], out: null }
101
+ const abs = isAbsolute(out.value) ? out.value : resolve(cwd, out.value)
102
+ const next = [...args]
103
+ if (out.joined) next[out.i] = `--out=${abs}`
104
+ else next[out.i + 1] = abs
105
+ return { args: next, out: abs }
106
+ }
107
+
108
+ const isHelp = (a: string): boolean => a === '--help' || a === '-h'
109
+
110
+ /** runs an argv from `cwd` with the terminal handed over; its exit code */
111
+ export async function spawnHere(argv: string[], cwd: string): Promise<number> {
112
+ const p = Bun.spawn(argv, { cwd, env: process.env, stdin: 'inherit', stdout: 'inherit', stderr: 'inherit' })
113
+ return await p.exited
114
+ }
115
+
116
+ /** the row each class was made from, so the docs print what it runs */
117
+ export const ROW_OF = new WeakMap<object, Verb>()
118
+
119
+ /** one clipanion command per row: the args after the path go to the code as typed */
120
+ export function verbs(rows: Verb[]): CommandClass[] {
121
+ return rows.map((v) => {
122
+ class VerbCommand extends Command<KitContext> {
123
+ static override paths = [v.path.split(' ')]
124
+ static override usage = Command.Usage({ category: v.category, description: v.description, details: v.details, examples: v.examples })
125
+ static essential = v.essential
126
+ static film = v.film
127
+ static filmVersion = v.version
128
+ static filmLength = v.length
129
+ args = Option.Proxy()
130
+ async execute(): Promise<number> {
131
+ const { root, cwd } = this.context
132
+ if (this.args.some(isHelp)) {
133
+ this.context.stdout.write(this.cli.usage(VerbCommand, { detailed: true }))
134
+ if (v.help !== 'own') return 0
135
+ this.context.stdout.write(`\nthe tool's own help:\n\n`)
136
+ return spawnHere(argvOf(v.run, this.args), root)
137
+ }
138
+ const call = filmCall(v.film, this.args, cwd)
139
+ if ('error' in call) { this.context.stderr.write(`FAIL: ${v.path}: ${call.error}\n`); return 2 }
140
+ let code: number | null = null
141
+ if ('tool' in v.run && this.context.askTool != null) code = await this.context.askTool(v.run.tool, [...(v.run.pre ?? []), ...call.args])
142
+ code ??= await spawnHere(argvOf(v.run, call.args), root)
143
+ if (code === 0 && call.out != null && !existsSync(call.out)) {
144
+ this.context.stderr.write(`FAIL: ${v.path} exited 0 and wrote no ${call.out}\n fix: a filmable row writes its --out path\n`)
145
+ return 1
146
+ }
147
+ return code
148
+ }
149
+ }
150
+ ROW_OF.set(VerbCommand, v)
151
+ return VerbCommand
152
+ })
153
+ }
154
+
155
+ /** the marks a command class carries: set by `verbs()` from its row, or
156
+ * declared as statics on a hand written class */
157
+ export function marksOf(c: object): { essential?: Essential; film?: Film; version?: number | string; length?: 'own' } {
158
+ const o = c as { essential?: unknown; film?: unknown; filmVersion?: unknown; filmLength?: unknown }
159
+ const out: { essential?: Essential; film?: Film; version?: number | string; length?: 'own' } = {}
160
+ if (typeof o.essential === 'object' && o.essential !== null) out.essential = o.essential as Essential
161
+ if (o.film === 'still' || o.film === 'clip') {
162
+ out.film = o.film
163
+ if (typeof o.filmVersion === 'number' || typeof o.filmVersion === 'string') out.version = o.filmVersion
164
+ if (o.filmLength === 'own') out.length = 'own'
165
+ }
166
+ return out
167
+ }
package/src/cli.ts CHANGED
@@ -7,6 +7,8 @@ import { Builtins, Cli } from 'clipanion'
7
7
  import { AskNewCommand, AsksCheckCommand, AsksCommand } from './cli/cmd_Asks.ts'
8
8
  import { AssetsAddCommand, AssetsCheckCommand, AssetsFillCommand, AssetsGenCommand, AssetsIdentityCommand } from './cli/cmd_Assets.ts'
9
9
  import { BumpCommand } from './cli/cmd_Bump.ts'
10
+ import { CallsCommand } from './cli/cmd_Calls.ts'
11
+ import { TimeCommand } from './cli/cmd_Time.ts'
10
12
  import { CheckCommand } from './cli/cmd_Check.ts'
11
13
  import { CommandsCommand } from './cli/cmd_Commands.ts'
12
14
  import { aliasArgs, CrossRepoActivityCommand } from './cli/cmd_CrossRepoActivity.ts'
@@ -35,8 +37,11 @@ import { CacheAuditCommand } from './cli/cmd_CacheAudit.ts'
35
37
  import { TraceAuditCommand } from './cli/cmd_TraceAudit.ts'
36
38
  import { ServeCommand } from './cli/cmd_Serve.ts'
37
39
  import { SmokeCommand } from './cli/cmd_Smoke.ts'
40
+ import { ShotCommand } from './cli/cmd_Shot.ts'
38
41
  import { StatusCommand } from './cli/cmd_Status.ts'
39
42
  import { StartCommand } from './cli/cmd_Start.ts'
43
+ import { TRAILER_COMMANDS } from './cli/cmd_Trailer.ts'
44
+ import { REVIEW_COMMANDS } from './cli/cmd_Review.ts'
40
45
  import { TuiCommand } from './cli/cmd_Tui.ts'
41
46
  import { WikiLintCommand, WikiServeCommand } from './cli/cmd_Wiki.ts'
42
47
 
@@ -67,6 +72,8 @@ export function buildCli(): Cli {
67
72
  cli.register(MvCommand)
68
73
  cli.register(CheckCommand)
69
74
  cli.register(TuiCommand)
75
+ for (const c of TRAILER_COMMANDS) cli.register(c)
76
+ for (const c of REVIEW_COMMANDS) cli.register(c)
70
77
  cli.register(ServeCommand)
71
78
  cli.register(PmCommand)
72
79
  cli.register(DaemonCommand)
@@ -91,6 +98,7 @@ export function buildCli(): Cli {
91
98
  cli.register(FleetCommand)
92
99
  cli.register(PbCollectionCommand)
93
100
  cli.register(SmokeCommand)
101
+ cli.register(ShotCommand)
94
102
  cli.register(ReflectCommand)
95
103
  cli.register(KeysCommand)
96
104
  cli.register(GlobalProcessesCommand)
@@ -109,6 +117,8 @@ export function buildCli(): Cli {
109
117
  cli.register(TraceAuditCommand)
110
118
  cli.register(UnitsAliasCommand)
111
119
  cli.register(CommandsCommand)
120
+ cli.register(CallsCommand)
121
+ cli.register(TimeCommand)
112
122
  cli.register(ConfigKeysCommand)
113
123
 
114
124
  return cli
@@ -117,14 +127,17 @@ export function buildCli(): Cli {
117
127
  // only when this file IS the process, so importing it costs nothing. A repo's
118
128
  // installed copy first hands the command to the house source (handoff.ts). One copy
119
129
  // of the lib per process: a repo config that imports its own install lands on
120
- // this one (one-lib.ts), so the machine's version runs in every repo. Then pm
121
- // is started when it is down (autostart.ts), before the command runs
130
+ // this one (one-lib.ts), so the machine's version runs in every repo. The run
131
+ // is logged (cli/kit/calls.ts, read back by `sk calls`). Then pm is started
132
+ // when it is down (autostart.ts), before the command runs
122
133
  if (import.meta.main) {
123
134
  const argv = aliasArgs(process.argv.slice(2))
124
135
  const { handoff } = await import('./home/handoff.ts')
125
136
  handoff(argv)
126
137
  const { installOneLib } = await import('./core/one-lib.ts')
127
138
  installOneLib()
139
+ const { callsFile, logCall } = await import('./cli/kit/calls.ts')
140
+ logCall(callsFile((await import('os')).homedir(), 'sk'), 'sk', argv)
128
141
  const { autostart } = await import('./home/autostart.ts')
129
142
  await autostart(argv)
130
143
  void buildCli().runExit(argv)
@@ -31,7 +31,12 @@ export const NO_CHECK = 'in no check'
31
31
  * section a row lives under (hub-app/tree-build.ts KIND_SECTION). There is no
32
32
  * catch all kind: a row belongs to an owner, and what belongs to the hub
33
33
  * itself is its status (serve/hub-problems.ts), never a row */
34
- export const RESOURCE_TYPES = ['ask', 'secret', 'asset', 'post', 'workflow', 'wiki', 'contract', 'check', 'test', 'reflection', 'process'] as const
34
+ export const RESOURCE_TYPES = ['ask', 'secret', 'asset', 'post', 'workflow', 'wiki', 'contract', 'check', 'test', 'reflection', 'process', 'card', 'cut'] as const
35
+
36
+ /** the collection of the review cards that wait on him (collections/review.ts) */
37
+ export const REVIEW_COL = 'review'
38
+ /** the collection of the trailer cuts, one row a cut (collections/review.ts) */
39
+ export const TRAILER_COL = 'trailers'
35
40
 
36
41
  export type ResourceType = (typeof RESOURCE_TYPES)[number]
37
42
 
@@ -27,6 +27,7 @@ import { mdBody } from './md-body.ts'
27
27
  import { socialPostsCollection } from './posts.ts'
28
28
  import { assetState, secretState, type SecretLevel, type SecretProbe } from './state-rules.ts'
29
29
  import { reflectionsCollection } from './reflections.ts'
30
+ import { reviewCollection, trailersCollection } from './review.ts'
30
31
  import { testsCollection } from './tests.ts'
31
32
  import { checksCollection } from './checks.ts'
32
33
  import { LAYOUT } from '../core/layout.ts'
@@ -417,5 +418,7 @@ export function buildCollections(root: string, c: RepoConfig, kit: ResolvedKit |
417
418
  socialPostsCollection(root),
418
419
  reflectionsCollection(root),
419
420
  testsCollection(root, c),
421
+ // only where the repo has a review store or a trailer folder: the section of a repo with none is greyed
422
+ ...[reviewCollection(root), trailersCollection(root)].filter((x): x is Collection => x !== null),
420
423
  ]
421
424
  }