rvlib-shipkit 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/package.json +1 -1
  3. package/src/agent-time/mask.ts +43 -0
  4. package/src/agent-time/render.ts +187 -0
  5. package/src/agent-time/report.ts +335 -0
  6. package/src/agent-time/shape.ts +271 -0
  7. package/src/agent-time/signals.ts +84 -0
  8. package/src/agent-time/store.ts +379 -0
  9. package/src/agent-time/timeline.ts +218 -0
  10. package/src/agent-time/turns.ts +135 -0
  11. package/src/assets/kits/identity-kit.ts +5 -2
  12. package/src/cli/cmd_Commands.ts +9 -0
  13. package/src/cli/cmd_Review.ts +974 -0
  14. package/src/cli/cmd_Sound.ts +209 -0
  15. package/src/cli/cmd_Time.ts +112 -0
  16. package/src/cli/cmd_Trailer.ts +264 -6
  17. package/src/cli/kit/verb.ts +22 -4
  18. package/src/cli.ts +8 -4
  19. package/src/collections/collection-types.ts +6 -1
  20. package/src/collections/providers.ts +3 -0
  21. package/src/collections/review.ts +70 -0
  22. package/src/core/app-icon.ts +10 -6
  23. package/src/core/layout.ts +2 -0
  24. package/src/reflect/prompt-corpus.ts +15 -119
  25. package/src/reflect/transcript.ts +254 -0
  26. package/src/repo-config.ts +3 -0
  27. package/src/review/anim.ts +228 -0
  28. package/src/review/auto.ts +141 -0
  29. package/src/review/format.ts +158 -0
  30. package/src/review/http.ts +23 -0
  31. package/src/review/marks.ts +43 -0
  32. package/src/review/media.ts +45 -0
  33. package/src/review/model.ts +896 -0
  34. package/src/review/project.ts +126 -0
  35. package/src/review/role.ts +48 -0
  36. package/src/review/routes.ts +239 -0
  37. package/src/review/store.ts +1011 -0
  38. package/src/review/threads.ts +237 -0
  39. package/src/review/trailer-desk.ts +385 -0
  40. package/src/review/trailer-model.ts +183 -0
  41. package/src/review/trailer-routes.ts +136 -0
  42. package/src/review/unit.ts +183 -0
  43. package/src/review/watchers.ts +54 -0
  44. package/src/serve/embed.ts +35 -0
  45. package/src/serve/hub-app/activity-view.tsx +1 -1
  46. package/src/serve/hub-app/activity.css +0 -4
  47. package/src/serve/hub-app/admin-svc.ts +1 -1
  48. package/src/serve/hub-app/admin-view.tsx +1 -1
  49. package/src/serve/hub-app/dock-view.tsx +5 -4
  50. package/src/serve/hub-app/hash-route.ts +36 -1
  51. package/src/serve/hub-app/header-bar.tsx +3 -1
  52. package/src/serve/hub-app/home-st.ts +17 -3
  53. package/src/serve/hub-app/home-strip.tsx +3 -2
  54. package/src/serve/hub-app/home.html +1 -0
  55. package/src/serve/hub-app/hub-app.tsx +80 -11
  56. package/src/serve/hub-app/hub.css +83 -14
  57. package/src/serve/hub-app/icons.tsx +15 -0
  58. package/src/serve/hub-app/index.html +1 -0
  59. package/src/serve/hub-app/insights-view.tsx +4 -3
  60. package/src/serve/hub-app/keys.ts +14 -10
  61. package/src/serve/hub-app/layout-st.ts +63 -2
  62. package/src/serve/hub-app/md.ts +32 -0
  63. package/src/serve/hub-app/planned-view.tsx +3 -2
  64. package/src/serve/hub-app/planned.css +0 -1
  65. package/src/serve/hub-app/project-list.css +26 -6
  66. package/src/serve/hub-app/project-list.tsx +46 -10
  67. package/src/serve/hub-app/projects-svc.ts +7 -2
  68. package/src/serve/hub-app/repo-sections.ts +15 -3
  69. package/src/serve/hub-app/repo-st.ts +61 -4
  70. package/src/serve/hub-app/review/clip-st.ts +258 -0
  71. package/src/serve/hub-app/review/kind-view.tsx +240 -0
  72. package/src/serve/hub-app/review/kit.tsx +113 -0
  73. package/src/serve/hub-app/review/review-answer.tsx +269 -0
  74. package/src/serve/hub-app/review/review-board.tsx +46 -0
  75. package/src/serve/hub-app/review/review-list.tsx +156 -0
  76. package/src/serve/hub-app/review/review-page.tsx +96 -0
  77. package/src/serve/hub-app/review/review-st.ts +902 -0
  78. package/src/serve/hub-app/review/review-views.tsx +648 -0
  79. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  80. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  81. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  82. package/src/serve/hub-app/review.css +389 -0
  83. package/src/serve/hub-app/theme-st.ts +56 -0
  84. package/src/serve/hub-app/tree-build.ts +4 -0
  85. package/src/serve/hub-app/tree-row.tsx +1 -1
  86. package/src/serve/hub-app/tree-svc.ts +4 -0
  87. package/src/serve/hub-app/wiki-st.ts +190 -0
  88. package/src/serve/hub-app/wikis-view.tsx +134 -5
  89. package/src/serve/hub-runtime.ts +9 -7
  90. package/src/serve/project-gutter-route.ts +25 -1
  91. package/src/serve/project-gutter.ts +1 -1
  92. package/src/serve/same-origin.ts +5 -1
  93. package/src/serve/serve-hub.ts +15 -0
  94. package/src/shipkit-files.ts +9 -5
  95. package/src/shot/client.ts +17 -5
  96. package/src/shot/shooter.ts +19 -3
  97. package/src/shot/shot-ask.ts +9 -1
  98. package/src/shot/stepped-clip.ts +53 -0
  99. package/src/trailer/adapter.ts +90 -6
  100. package/src/trailer/build.ts +459 -59
  101. package/src/trailer/ffmpeg.ts +22 -0
  102. package/src/trailer/fonts.ts +55 -0
  103. package/src/trailer/footage.ts +112 -43
  104. package/src/trailer/grammar.ts +864 -127
  105. package/src/trailer/graph.ts +308 -49
  106. package/src/trailer/holes.ts +72 -0
  107. package/src/trailer/load-unit.ts +20 -3
  108. package/src/trailer/look.ts +20 -3
  109. package/src/trailer/motion.ts +207 -0
  110. package/src/trailer/passage.ts +129 -0
  111. package/src/trailer/plan.ts +82 -23
  112. package/src/trailer/prune.ts +204 -0
  113. package/src/trailer/score/compose.ts +256 -0
  114. package/src/trailer/score/dsp.ts +272 -0
  115. package/src/trailer/score/gm-render.ts +43 -0
  116. package/src/trailer/score/gm.ts +90 -0
  117. package/src/trailer/score/midi.ts +143 -0
  118. package/src/trailer/score/palette.ts +188 -0
  119. package/src/trailer/score/pattern.ts +98 -0
  120. package/src/trailer/score/score.ts +228 -0
  121. package/src/trailer/score/sound.ts +123 -0
  122. package/src/trailer/score/synth.ts +383 -0
  123. package/src/trailer/sound/audio.ts +148 -0
  124. package/src/trailer/sound/brand.ts +42 -0
  125. package/src/trailer/sound/comfy.ts +38 -0
  126. package/src/trailer/sound/eleven.ts +72 -0
  127. package/src/trailer/sound/fluid.ts +71 -0
  128. package/src/trailer/sound/gen.ts +139 -0
  129. package/src/trailer/sound/index.ts +60 -0
  130. package/src/trailer/sound/presets.ts +69 -0
  131. package/src/trailer/sound/run.ts +189 -0
  132. package/src/trailer/states.ts +76 -0
  133. package/src/trailer/stitch.ts +92 -9
  134. package/src/trailer/titles.ts +471 -150
  135. package/src/trailer/url.ts +18 -3
  136. package/src/trailer/vocab.ts +34 -4
  137. package/src/trailer/words.ts +275 -0
  138. package/src/wiki/graph-mount.ts +3 -0
  139. package/src/wiki/graph-page.ts +45 -9
  140. package/src/wiki/graph-pages.ts +128 -0
@@ -0,0 +1,335 @@
1
+ // `sk time` from the index rows: where the wall went, which commands ate the
2
+ // time, what failed and ran again, and what he said while it ran.
3
+ //
4
+ // The constraint a reader breaks first: one session can sit in two or three
5
+ // config roots (a session imported into another account to resume it there).
6
+ // Streams are merged by session and agent, and calls deduped by their tool
7
+ // use id, or every imported session counts twice.
8
+ import { type PromptEntry, dedupePrompts } from '../reflect/prompt-corpus.ts'
9
+ import type { Outcome } from '../reflect/transcript.ts'
10
+ import type { Masker } from './mask.ts'
11
+ import { type Signal, signalsOf } from './signals.ts'
12
+ import type { FileRow, Loaded } from './store.ts'
13
+ import { type ReadCost, type TurnStats, addTurns, emptyTurns, turnsOf } from './turns.ts'
14
+ import { type Breakdown, type Call, type Mark, type Stream, FAILED, IDLE_MS, addBreakdown, callMs, type Closed, closeCalls, emptyBreakdown, quantileSorted, ranBefore, retries, wallBreakdown } from './timeline.ts'
15
+
16
+ export type ShapeRow = { shape: string; tool: string; calls: number; ms: number; approvals: number; approvalMs: number; p50: number; p95: number; max: number; failed: number; timeouts: number; blocked: number; retried: number; blind: number; failedMs: number }
17
+
18
+ export type SlowCall = { ms: number; shape: string; cmd: string; at: number; account: string; session: string; agent: boolean; outcome: Outcome | null; open: boolean }
19
+
20
+ export type Complaint = { at: number; session: string; account: string; text: string; signals: { signal: Signal; rule: string }[]; ran: { shape: string; cmd: string; ms: number; open: boolean }[] }
21
+
22
+ /** one session or one sub-agent run, by its turns */
23
+ export type TurnRow = { session: string; agent: string; account: string; start: number; work: number; wall: number; turns: TurnStats }
24
+
25
+ /** turn count: the main sessions and the sub-agents apart, the runs with the
26
+ * most wall, and the read shapes by the model time of the turns that issued them */
27
+ export type TurnReport = {
28
+ main: TurnStats
29
+ sub: TurnStats & { runs: number; work: Breakdown; runP50: number; runP90: number; callsP50: number }
30
+ rows: TurnRow[]
31
+ reads: ReadCost[]
32
+ }
33
+
34
+ export type SessionRow = { session: string; account: string; repo: string; start: number; wall: Breakdown; calls: number; subagents: number; first: string }
35
+
36
+ export type TimeReport = {
37
+ repos: string[]
38
+ from: number | null
39
+ to: number
40
+ accounts: string[]
41
+ sessions: number
42
+ subagentRuns: number
43
+ /** history whose transcript Claude Code already pruned, still in the index */
44
+ prunedSessions: number
45
+ wall: Breakdown
46
+ subagentWork: number
47
+ callCount: number
48
+ callMs: number
49
+ shapes: ShapeRow[]
50
+ tools: ShapeRow[]
51
+ slowest: SlowCall[]
52
+ feedback: { prompts: number; queued: number; counts: Record<Signal, number>; rules: Record<string, number>; hits: Complaint[] }
53
+ sessionRows: SessionRow[]
54
+ turns: TurnReport
55
+ /** the measured waste, biggest first: each with the number behind it */
56
+ insights: Insight[]
57
+ }
58
+
59
+ export type Insight = { id: 'classifier' | 'bash-edits' | 'polling' | 'failed' | 'approval'; ms: number; estimate: boolean; text: string }
60
+
61
+ /** commands that cost nothing to run: their time is the harness around them */
62
+ const CHEAP = new Set(['grep', 'sed', 'cat', 'ls', 'head', 'tail', 'wc', 'git status', 'echo', 'pwd', 'rg'])
63
+
64
+ const isEdit = (shape: string): boolean => shape.endsWith('(edit)') || shape === 'cat > (write)'
65
+ const isPolling = (shape: string): boolean => shape.startsWith('(until)') || shape.startsWith('(while)') || shape === 'sleep'
66
+
67
+ const fmt = (ms: number): string => (ms < 1000 ? `${(ms / 1000).toFixed(1)}s` : ms < 60_000 ? `${Math.round(ms / 1000)}s` : ms < 3_600_000 ? `${Math.round(ms / 60_000)}m` : `${(ms / 3_600_000).toFixed(1)}h`)
68
+
69
+ const p50Of = (xs: number[]): number => quantileSorted(xs.sort((a, b) => a - b), 0.5)
70
+
71
+ /** the waste the rows add up to, each a cause with its number */
72
+ export function insightsOf(calls: Closed[], wall: Breakdown, shapes: ShapeRow[]): Insight[] {
73
+ const out: Insight[] = []
74
+ const bash = calls.filter((c) => c.tool === 'Bash')
75
+ const cheap = bash.filter((c) => CHEAP.has(c.shape))
76
+ const viaClassifier = cheap.filter((c) => c.perm === 'classifier').map(callMs)
77
+ const viaRule = cheap.filter((c) => c.perm === 'rule').map(callMs)
78
+ const classified = bash.filter((c) => c.perm === 'classifier').length
79
+ if (viaClassifier.length >= 20 && viaRule.length >= 20) {
80
+ const c = p50Of(viaClassifier)
81
+ const r = p50Of(viaRule)
82
+ if (c > r) out.push({ id: 'classifier', ms: (c - r) * classified, estimate: true, text: `the auto mode classifier checked ${classified.toLocaleString('en-US')} Bash calls before they ran: a cheap command (grep, sed, cat, ls) takes p50 ${fmt(c)} through it, ${fmt(r)} when a rule allows it. fix: allow rules for the read only commands agents repeat` })
83
+ }
84
+ const edits = bash.filter((c) => isEdit(c.shape))
85
+ const editTool = calls.filter((c) => c.tool === 'Edit').map(callMs)
86
+ if (edits.length > 0) {
87
+ const ms = edits.map(callMs)
88
+ const bashP50 = p50Of(ms)
89
+ const toolP50 = editTool.length >= 20 ? p50Of(editTool) : null
90
+ const saved = toolP50 == null || toolP50 >= bashP50 ? '' : `, about ${fmt((bashP50 - toolP50) * edits.length)} back at the Edit tool's p50 ${fmt(toolP50)}`
91
+ out.push({ id: 'bash-edits', ms: ms.reduce((a, b) => a + b, 0), estimate: false, text: `${edits.length.toLocaleString('en-US')} file edits through Bash (python3 heredocs, sed -i, perl -i, cat >), p50 ${fmt(bashP50)}${saved}` })
92
+ }
93
+ const polls = bash.filter((c) => isPolling(c.shape))
94
+ if (polls.length > 0) {
95
+ const timeouts = polls.filter((c) => c.outcome === 'timeout').length
96
+ const top = shapes.filter((s) => isPolling(s.shape)).slice(0, 3).map((s) => `${s.shape} ${fmt(s.ms)}`).join(', ')
97
+ out.push({ id: 'polling', ms: polls.map(callMs).reduce((a, b) => a + b, 0), estimate: false, text: `${polls.length.toLocaleString('en-US')} polling waits (until and while loops, sleep), ${timeouts} hit the Bash timeout: ${top}` })
98
+ }
99
+ const failed = bash.filter((c) => c.outcome != null && FAILED.has(c.outcome))
100
+ if (failed.length > 0) {
101
+ const rerun = shapes.reduce((a, s) => a + s.retried, 0)
102
+ const blind = shapes.reduce((a, s) => a + s.blind, 0)
103
+ out.push({ id: 'failed', ms: failed.map(callMs).reduce((a, b) => a + b, 0), estimate: false, text: `${failed.length.toLocaleString('en-US')} failed Bash runs (non zero exit, error, timeout), ${rerun} run again, ${blind} of them unchanged` })
104
+ }
105
+ if (wall.approval > 0) {
106
+ const n = calls.filter((c) => c.approval).length
107
+ const top = [...shapes].filter((s) => s.approvals > 0).sort((a, b) => b.approvalMs - a.approvalMs).slice(0, 3).map((s) => `${s.shape} ${fmt(s.approvalMs)}`).join(', ')
108
+ out.push({ id: 'approval', ms: wall.approval, estimate: false, text: `waiting for you to allow ${n} call${n === 1 ? '' : 's'}: ${top}` })
109
+ }
110
+ return out.sort((a, b) => b.ms - a.ms)
111
+ }
112
+
113
+ type Group = { key: string; session: string; agent: string; files: FileRow[]; stream: Stream; closed: Closed[] }
114
+
115
+ /** one stream per session and agent, every copy of it merged */
116
+ export function mergeStreams(loaded: Loaded): Group[] {
117
+ const groups = new Map<string, Group>()
118
+ for (const f of loaded.files) {
119
+ const key = `${f.session}/${f.agent}`
120
+ const g = groups.get(key) ?? { key, session: f.session, agent: f.agent, files: [], stream: { calls: [], marks: [] }, closed: [] }
121
+ g.files.push(f)
122
+ groups.set(key, g)
123
+ }
124
+ for (const g of groups.values()) {
125
+ const calls = new Map<string, Call>()
126
+ const marks = new Map<string, Mark>()
127
+ for (const f of g.files) {
128
+ for (const c of loaded.calls.get(f.id) ?? []) {
129
+ const have = calls.get(c.id)
130
+ if (have == null || (have.end == null && c.end != null)) calls.set(c.id, c)
131
+ }
132
+ for (const m of loaded.marks.get(f.id) ?? []) marks.set(`${m.at}/${m.kind}`, m)
133
+ }
134
+ g.stream = { calls: [...calls.values()], marks: [...marks.values()] }
135
+ g.closed = closeCalls(g.stream)
136
+ }
137
+ return [...groups.values()]
138
+ }
139
+
140
+ function pushTo<K, V>(m: Map<K, V[]>, k: K, v: V): void {
141
+ const list = m.get(k)
142
+ if (list == null) m.set(k, [v])
143
+ else list.push(v)
144
+ }
145
+
146
+ /** the k items of largest `key`, largest first, without sorting the list */
147
+ function topBy<T>(xs: T[], k: number, key: (x: T) => number): T[] {
148
+ const top: { x: T; v: number }[] = []
149
+ for (const x of xs) {
150
+ const v = key(x)
151
+ if (top.length === k && v <= (top[k - 1] as { v: number }).v) continue
152
+ let i = top.length
153
+ while (i > 0 && (top[i - 1] as { v: number }).v < v) i--
154
+ top.splice(i, 0, { x, v })
155
+ if (top.length > k) top.pop()
156
+ }
157
+ return top.map((t) => t.x)
158
+ }
159
+
160
+ const isFailed = (o: Outcome | null): boolean => o != null && FAILED.has(o)
161
+
162
+ function rowsBy(calls: { c: Closed; key: string }[], retriesByKey: Map<string, { retried: number; blind: number }>): ShapeRow[] {
163
+ const by = new Map<string, Closed[]>()
164
+ for (const { c, key } of calls) {
165
+ const list = by.get(key)
166
+ if (list == null) by.set(key, [c])
167
+ else list.push(c)
168
+ }
169
+ return [...by].map(([key, cs]): ShapeRow => {
170
+ const ms = new Float64Array(cs.length)
171
+ const row: ShapeRow = { shape: key, tool: cs[0]?.tool ?? '', calls: cs.length, ms: 0, approvals: 0, approvalMs: 0, p50: 0, p95: 0, max: 0, failed: 0, timeouts: 0, blocked: 0, retried: 0, blind: 0, failedMs: 0 }
172
+ cs.forEach((c, i) => {
173
+ const d = callMs(c)
174
+ ms[i] = d
175
+ row.ms += d
176
+ if (c.approval) {
177
+ row.approvals++
178
+ row.approvalMs += d
179
+ }
180
+ if (isFailed(c.outcome)) {
181
+ row.failed++
182
+ row.failedMs += d
183
+ }
184
+ if (c.outcome === 'timeout') row.timeouts++
185
+ if (c.outcome === 'blocked' || c.outcome === 'denied') row.blocked++
186
+ })
187
+ ms.sort()
188
+ const sorted = Array.from(ms)
189
+ const r = retriesByKey.get(key)
190
+ return { ...row, p50: quantileSorted(sorted, 0.5), p95: quantileSorted(sorted, 0.95), max: sorted[sorted.length - 1] ?? 0, retried: r?.retried ?? 0, blind: r?.blind ?? 0 }
191
+ }).sort((a, b) => b.ms - a.ms || b.calls - a.calls)
192
+ }
193
+
194
+ export function buildReport(loaded: Loaded, q: { repos: string[]; from: number | null; to: number; repoOf: (project: string) => string }, mask: Masker): TimeReport {
195
+ const from = q.from ?? -Infinity
196
+ const groups = mergeStreams(loaded)
197
+ const accounts = new Set<string>()
198
+ const wall = emptyBreakdown()
199
+ let subagentWork = 0
200
+ const all: { c: Closed; g: Group }[] = []
201
+ const retryShape = new Map<string, { retried: number; blind: number }>()
202
+ const sessionRows: SessionRow[] = []
203
+ const subsBySession = new Map<string, number>()
204
+ for (const g of groups) if (g.agent !== '') subsBySession.set(g.session, (subsBySession.get(g.session) ?? 0) + 1)
205
+ const promptsByFile = new Map<number, Loaded['prompts']>()
206
+ for (const p of loaded.prompts) pushTo(promptsByFile, p.file_id, p)
207
+ const mainTurns = emptyTurns()
208
+ const subTurns = emptyTurns()
209
+ const subWork = emptyBreakdown()
210
+ const runMs: number[] = []
211
+ const runCalls: number[] = []
212
+ const turnRows: TurnRow[] = []
213
+ const readCosts = new Map<string, ReadCost>()
214
+
215
+ for (const g of groups) {
216
+ for (const f of g.files) accounts.add(f.account)
217
+ const turnModel = new Float64Array(g.closed.length)
218
+ const b = wallBreakdown(g.stream, from, q.to, IDLE_MS, g.closed, turnModel)
219
+ const t = turnsOf(g.closed, b.model, turnModel, readCosts)
220
+ const work = b.model + b.tools + b.approval + b.subagent + b.background
221
+ if (t.calls > 0) turnRows.push({ session: g.session, agent: g.agent, account: g.files[0]?.account ?? '', start: g.closed[0]?.start ?? 0, work: b.model + b.tools + b.approval, wall: work, turns: t })
222
+ if (g.agent === '') addTurns(mainTurns, t)
223
+ else {
224
+ addTurns(subTurns, t)
225
+ addBreakdown(subWork, b)
226
+ runMs.push(work)
227
+ runCalls.push(t.calls)
228
+ }
229
+ if (g.agent === '') {
230
+ addBreakdown(wall, b)
231
+ const firstPrompt = g.files.flatMap((f) => promptsByFile.get(f.id) ?? []).sort((a, b2) => a.at - b2.at)[0]
232
+ sessionRows.push({
233
+ session: g.session,
234
+ account: g.files[0]?.account ?? '',
235
+ repo: q.repoOf(g.files[0]?.project ?? ''),
236
+ start: [...g.stream.marks.map((m) => m.at), ...g.stream.calls.map((c) => c.start)].reduce((a, b) => (b < a ? b : a), Infinity),
237
+ wall: b,
238
+ calls: g.stream.calls.length,
239
+ subagents: subsBySession.get(g.session) ?? 0,
240
+ first: firstPrompt == null ? '' : mask(firstPrompt.text.replace(/\s+/g, ' ').slice(0, 70)),
241
+ })
242
+ } else subagentWork += work
243
+ for (const c of g.closed) all.push({ c, g })
244
+ for (const r of retries(g.closed)) {
245
+ const x = retryShape.get(r.failed.shape) ?? { retried: 0, blind: 0 }
246
+ x.retried++
247
+ if (r.blind) x.blind++
248
+ retryShape.set(r.failed.shape, x)
249
+ }
250
+ }
251
+
252
+ const bash = all.filter(({ c }) => c.tool === 'Bash')
253
+ const shapes = rowsBy(all.filter(({ c }) => c.tool !== 'Agent' && c.tool !== 'Task').map(({ c }) => ({ c, key: c.shape })), retryShape)
254
+ // the Bash shapes first: they are the commands; other tools stay in the table
255
+ shapes.sort((a, b) => b.ms - a.ms)
256
+ const tools = rowsBy(all.map(({ c }) => ({ c, key: c.tool })), new Map())
257
+ const slowest: SlowCall[] = topBy(bash, 40, ({ c }) => callMs(c))
258
+ .map(({ c, g }) => ({ ms: callMs(c), shape: c.shape, cmd: mask(c.cmd.replace(/\s+/g, ' ').slice(0, 120)), at: c.start, account: g.files[0]?.account ?? '', session: g.session, agent: g.agent !== '', outcome: c.outcome, open: c.open }))
259
+
260
+ // feedback: his prompts, deduped across copies, signals by rule, and for a
261
+ // slowness complaint what every stream of that session was running
262
+ const fileById = new Map(loaded.files.map((f) => [f.id, f]))
263
+ const groupsBySession = new Map<string, Group[]>()
264
+ for (const g of groups) pushTo(groupsBySession, g.session, g)
265
+ const counts: Record<Signal, number> = { status: 0, slow: 0, correction: 0 }
266
+ const rules: Record<string, number> = {}
267
+ const hits: Complaint[] = []
268
+ const typed: (PromptEntry & { file: FileRow })[] = []
269
+ for (const p of loaded.prompts) {
270
+ const f = fileById.get(p.file_id)
271
+ if (f == null || f.agent !== '') continue
272
+ typed.push({ uuid: `${p.at}/${p.origin}/${p.text}`, timestamp: new Date(p.at).toISOString(), sessionId: f.session, account: f.account, text: p.text, origin: p.origin, file: f })
273
+ }
274
+ typed.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
275
+ const kept = dedupePrompts(typed) as (PromptEntry & { file: FileRow })[]
276
+ const repoGroups = (session: string): Group[] => groupsBySession.get(session) ?? []
277
+ let sortedAll: Closed[] | null = null
278
+ const allSorted = (): Closed[] => (sortedAll ??= all.map(({ c }) => c).sort((a, b) => a.start - b.start))
279
+ for (const p of kept) {
280
+ const signals = signalsOf(p.text)
281
+ if (signals.length === 0) continue
282
+ for (const s of signals) {
283
+ counts[s.signal]++
284
+ rules[s.rule] = (rules[s.rule] ?? 0) + 1
285
+ }
286
+ const at = Date.parse(p.timestamp)
287
+ const notAgent = (c: Closed) => c.tool !== 'Agent' && c.tool !== 'Task'
288
+ // his session first; a fresh session had nothing running, so then every
289
+ // session of the repo in those minutes
290
+ let ran = repoGroups(p.file.session).flatMap((g) => ranBefore(g.closed, at)).filter(notAgent)
291
+ if (ran.length === 0) ran = ranBefore(allSorted(), at).filter(notAgent)
292
+ hits.push({
293
+ at,
294
+ session: p.file.session,
295
+ account: p.account,
296
+ text: mask(p.text.replace(/\s+/g, ' ').slice(0, 160)),
297
+ signals,
298
+ ran: ran.sort((a, b) => callMs(b) - callMs(a)).slice(0, 3).map((c) => ({ shape: c.shape, cmd: mask(c.cmd.replace(/\s+/g, ' ').slice(0, 80)), ms: callMs(c), open: c.open })),
299
+ })
300
+ }
301
+ const prompts = kept.length
302
+ const queued = kept.filter((p) => p.origin === 'queued').length
303
+
304
+ const mains = groups.filter((g) => g.agent === '')
305
+ return {
306
+ repos: q.repos,
307
+ from: q.from,
308
+ to: q.to,
309
+ accounts: [...accounts].sort(),
310
+ sessions: mains.length,
311
+ subagentRuns: groups.length - mains.length,
312
+ prunedSessions: mains.filter((g) => g.files.every((f) => f.gone === 1)).length,
313
+ wall,
314
+ subagentWork,
315
+ callCount: all.length,
316
+ callMs: all.reduce((a, { c }) => a + callMs(c), 0),
317
+ shapes,
318
+ tools,
319
+ slowest,
320
+ feedback: { prompts, queued, counts, rules, hits },
321
+ sessionRows: sessionRows.sort((a, b) => total(b.wall, false) - total(a.wall, false)),
322
+ turns: {
323
+ main: mainTurns,
324
+ sub: { ...subTurns, runs: runMs.length, work: subWork, runP50: quantileSorted(runMs.sort((a, b) => a - b), 0.5), runP90: quantileSorted(runMs, 0.9), callsP50: quantileSorted(runCalls.sort((a, b) => a - b), 0.5) },
325
+ rows: topBy(turnRows, 200, (r) => r.work),
326
+ reads: [...readCosts.values()].sort((a, b) => b.model - a.model),
327
+ },
328
+ insights: insightsOf(all.map(({ c }) => c), wall, shapes),
329
+ }
330
+ }
331
+
332
+ /** the wall of a breakdown; idle left out unless asked */
333
+ export const total = (b: Breakdown, idle: boolean): number => b.model + b.tools + b.approval + b.subagent + b.background + b.human + (idle ? b.idle : 0)
334
+
335
+ export { IDLE_MS }
@@ -0,0 +1,271 @@
1
+ // A tool call's SHAPE: the name its time is grouped under. A Bash command
2
+ // becomes its program and subcommand (`git commit`, `bun test <file>`,
3
+ // `sk ci --only lint`), so five hundred variants group into a few dozen rows.
4
+ //
5
+ // The constraint a reader breaks first: a shape never keeps a free value (a
6
+ // path, a message, a hash), or every call becomes its own row again. The only
7
+ // value kept is `sk ci --only <check>`, a small closed set that says WHICH
8
+ // check is slow.
9
+
10
+ /** bumped with any rule below: the index shapes every stored call again */
11
+ export const SHAPE_VERSION = 4
12
+
13
+ /** programs whose first word is never a subcommand */
14
+ const NO_SUBCOMMAND = new Set([
15
+ 'awk', 'cat', 'chmod', 'cp', 'curl', 'date', 'diff', 'du', 'echo', 'ffmpeg', 'ffprobe', 'file', 'find', 'grep', 'head',
16
+ 'jq', 'kill', 'killall', 'less', 'ln', 'ls', 'lsof', 'mkdir', 'mv', 'open', 'pgrep', 'pkill', 'printf', 'ps', 'pwd', 'rg',
17
+ 'rm', 'rmdir', 'sed', 'sleep', 'sort', 'sqlite3', 'stat', 'tail', 'tee', 'test', 'touch', 'tr', 'tree', 'uniq', 'uptime',
18
+ 'wc', 'wget', 'which', 'xargs', 'perl', 'magick', 'convert', 'zip', 'unzip', 'tar', 'osascript', 'say', 'afplay', 'screencapture', 'sips', 'top',
19
+ ])
20
+
21
+ /** programs whose shape keeps two subcommand words (`gh pr view`, `sk gp start`) */
22
+ const TWO_LEVELS = new Set(['sk', 'shipkit', 'gh', 'docker', 'kubectl', 'gcloud', 'wrangler', 'chi', 'comfy-ts', 'cflow', 'brew'])
23
+
24
+ /** bun's own subcommands; any other first word is a package script */
25
+ const BUN_BUILTINS = new Set(['test', 'run', 'x', 'install', 'i', 'add', 'remove', 'rm', 'build', 'pm', 'link', 'unlink', 'init', 'create', 'upgrade', 'lint', 'fmt', 'outdated', 'update', 'publish', 'patch', 'why', 'audit', 'exec', 'repl', 'info'])
26
+
27
+ /** a segment that only sets the scene for the next one */
28
+ const SETUP = new Set(['cd', 'export', 'set', 'source', '.', 'pushd', 'popd', 'unset', 'ulimit', 'shopt', 'true', ':'])
29
+
30
+ /** words that run the rest of the line as the real command */
31
+ const WRAPPERS: Record<string, number> = { nice: 0, time: 0, timeout: 1, command: 0, exec: 0, env: 0, sudo: 0, caffeinate: 0, stdbuf: 0, nohup: 0 }
32
+
33
+ /** flags that eat the next word, per program, before its subcommand */
34
+ const ARG_FLAGS: Record<string, Set<string>> = { git: new Set(['-C', '-c']), nice: new Set(['-n']), bun: new Set(['--cwd', '--filter']) }
35
+
36
+ /** shell words, quotes removed; enough of a shell to split a command line */
37
+ export function shellWords(segment: string): string[] {
38
+ const words: string[] = []
39
+ let cur = ''
40
+ let quote: string | null = null
41
+ let any = false
42
+ for (let i = 0; i < segment.length; i++) {
43
+ const c = segment[i] as string
44
+ if (quote != null) {
45
+ if (c === quote) quote = null
46
+ else if (c === '\\' && quote === '"' && i + 1 < segment.length) cur += segment[++i]
47
+ else cur += c
48
+ } else if (c === '"' || c === "'") {
49
+ quote = c
50
+ any = true
51
+ } else if (c === '\\' && i + 1 < segment.length) cur += segment[++i]
52
+ else if (/\s/.test(c)) {
53
+ if (cur !== '' || any) words.push(cur)
54
+ cur = ''
55
+ any = false
56
+ } else cur += c
57
+ }
58
+ if (cur !== '' || any) words.push(cur)
59
+ return words
60
+ }
61
+
62
+ /** a heredoc's body is data, never a command: `cat > f <<'EOF'` keeps its
63
+ * first line, the lines up to the marker go */
64
+ export function stripHeredocs(command: string): string {
65
+ const out: string[] = []
66
+ let marker: string | null = null
67
+ for (const line of command.split('\n')) {
68
+ if (marker != null) {
69
+ if (line.trim() === marker) marker = null
70
+ continue
71
+ }
72
+ out.push(line)
73
+ const m = /<<-?\s*['"]?([A-Za-z_][A-Za-z0-9_]*)['"]?/.exec(line)
74
+ if (m?.[1] != null) marker = m[1]
75
+ }
76
+ return out.join('\n')
77
+ }
78
+
79
+ /** the statements of a command line (split on `&&`, `||`, `;` and newlines
80
+ * outside quotes), each one only its first pipeline part: in `grep x | head`
81
+ * the time is the producer's */
82
+ export function statements(command: string): string[] {
83
+ const out: string[] = []
84
+ let cur = ''
85
+ let piped = false
86
+ let quote: string | null = null
87
+ const text = stripHeredocs(command).replace(/\\\n/g, ' ')
88
+ const flush = (): void => {
89
+ out.push(cur)
90
+ cur = ''
91
+ piped = false
92
+ }
93
+ for (let i = 0; i < text.length; i++) {
94
+ const c = text[i] as string
95
+ if (quote != null) {
96
+ if (c === quote) quote = null
97
+ if (!piped) cur += c
98
+ continue
99
+ }
100
+ if (c === '"' || c === "'") {
101
+ quote = c
102
+ if (!piped) cur += c
103
+ continue
104
+ }
105
+ const two = text.slice(i, i + 2)
106
+ if (two === '&&' || two === '||') {
107
+ flush()
108
+ i++
109
+ } else if (c === ';' || c === '\n') flush()
110
+ else if (c === '|') piped = true
111
+ else if (!piped) cur += c
112
+ }
113
+ flush()
114
+ return out.map((x) => x.trim()).filter((x) => x !== '')
115
+ }
116
+
117
+ const isWord = (w: string): boolean => /^[a-z][a-z0-9:_-]*$/i.test(w) && !/\.[a-z0-9]+$/i.test(w)
118
+ const isPathLike = (w: string): boolean => w.includes('/') || /\.[a-z0-9]{1,5}$/i.test(w) || w.startsWith('~')
119
+ const isScript = (w: string): boolean => /\.(?:sh|bash|zsh|ts|tsx|js|mjs|cjs|py|rb|pl)$/i.test(w) || (w.includes('/') && !w.startsWith('-'))
120
+ const base = (w: string): string => w.replace(/\/+$/, '').split('/').pop() ?? w
121
+
122
+ /** a statement's words: redirections, a leading `X=$(` and env assignments gone */
123
+ function wordsOf(statement: string): string[] {
124
+ // `cat > f`, `cat a > b`: a file write, whatever it writes
125
+ if (/^cat\b[^|]*?(?<![0-9&>])>{1,2}(?!&)/.test(statement)) return ['cat', '>']
126
+ const w = shellWords(statement.replace(/^[A-Za-z_][A-Za-z0-9_]*=\$\(/, '').replace(/^\(+|\)+$/g, '').replace(/\s\d?>>?&?\d?\s*\S*/g, ' ').replace(/\s<\S+/g, ' '))
127
+ while (w.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(w[0] as string)) w.shift()
128
+ return w
129
+ }
130
+
131
+ /** statements that cost nothing next to the one they come with: a shape skips
132
+ * them when a heavier statement follows (`uptime; sk ci`, `mkdir -p x && bun y.ts`) */
133
+ const LIGHT = new Set(['echo', 'printf', 'uptime', 'date', 'pwd', 'mkdir', 'touch', 'which', 'clear', 'rm', 'cat'])
134
+
135
+ const isLight = (w: string[]): boolean => LIGHT.has(w[0] as string) && (w[0] !== 'cat' || w.length === 1 || w[1] === '>' || w.every((x, i) => i === 0 || x.startsWith('-')))
136
+
137
+ /** a python heredoc that opens a file to write it: an edit through Bash */
138
+ const PY_EDIT = /\bpython3?\s+-\s*<</
139
+ const PY_WRITES = /open\([^)]*,\s*['"][wa]|\.write(?:_text)?\(/
140
+
141
+ /** the shape of one Bash command line */
142
+ export function commandShape(command: string): string {
143
+ const stmts = statements(command.trim()).map(wordsOf).filter((w) => w.length > 0)
144
+ if (stmts.length === 0) return '(empty)'
145
+ const shape = pick(stmts) ?? (base(stmts[0]?.[0] ?? '') || '(empty)')
146
+ if (shape.startsWith('python') && !shape.includes(' ') && PY_EDIT.test(command) && PY_WRITES.test(command)) return `${shape} (edit)`
147
+ return shape
148
+ }
149
+
150
+ /** the statement the time belongs to, shaped: loops by what they run */
151
+ function pick(stmts: string[][]): string | null {
152
+ let light: string[] | null = null
153
+ for (let i = 0; i < stmts.length; i++) {
154
+ const w = stmts[i] as string[]
155
+ const head = w[0] as string
156
+ if (head === 'for' || head === 'while' || head === 'until' || head === 'select') return loopShape(head, w, stmts.slice(i + 1))
157
+ if (head === 'if') return '(if)'
158
+ if (/^[A-Za-z_][A-Za-z0-9_-]*\(\)/.test(head) || head === 'function') return '(function)'
159
+ if (SETUP.has(head) || head === 'do' || head === 'done' || head === 'then' || head === 'fi') continue
160
+ if (isLight(w)) {
161
+ light ??= w
162
+ continue
163
+ }
164
+ return shapeOfWords(w)
165
+ }
166
+ return light == null ? null : shapeOfWords(light)
167
+ }
168
+
169
+ /** `(for) bun <file>`: a for loop by its body, a while or until loop by its
170
+ * condition (a `[ … ]` test says nothing, so by its body then) */
171
+ function loopShape(head: string, w: string[], after: string[][]): string {
172
+ const label = head === 'select' ? 'for' : head
173
+ const doAt = after.findIndex((x) => x[0] === 'do')
174
+ const body = doAt === -1 ? [] : [after[doAt]?.slice(1) ?? [], ...after.slice(doAt + 1)].filter((x) => x.length > 0)
175
+ const end = body.findIndex((x) => x[0] === 'done')
176
+ const inner = pick(end === -1 ? body : body.slice(0, end))
177
+ if (label === 'for') return `(for) ${inner ?? '…'}`
178
+ const cond = w.slice(1).filter((x) => x !== '!').map((x, i) => (i === 0 ? x.replace(/^[A-Za-z_][A-Za-z0-9_]*=\$\(/, '') : x))
179
+ const condShape = cond.length === 0 || cond[0] === '[' || cond[0] === '[[' || cond[0] === 'test' || cond[0] === 'true' || cond[0] === ':' ? null : shapeOfWords(cond)
180
+ return `(${label}) ${condShape ?? inner ?? '…'}`
181
+ }
182
+
183
+ function shapeOfWords(input: string[]): string {
184
+ let words = input
185
+ // unwrap `nice -n 15 timeout 60 env X=1 <cmd>`
186
+ for (;;) {
187
+ const head = words[0]
188
+ if (head == null || !(head in WRAPPERS)) break
189
+ let i = 1
190
+ const eats = ARG_FLAGS[head]
191
+ while (i < words.length && (words[i] as string).startsWith('-')) i += eats?.has(words[i] as string) ? 2 : 1
192
+ i += WRAPPERS[head] as number
193
+ while (i < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i] as string)) i++
194
+ if (i >= words.length) break
195
+ words = words.slice(i)
196
+ }
197
+
198
+ if (words.length === 0) return '(empty)'
199
+ if (isScript(words[0] as string)) return base(words[0] as string)
200
+ let program = base(words[0] as string)
201
+ let rest = words.slice(1)
202
+ if ((program === 'bunx' || program === 'npx' || program === 'pnpx') && rest.length > 0) {
203
+ const pkg = rest.findIndex((w) => !w.startsWith('-'))
204
+ if (pkg !== -1) {
205
+ program = `${program} ${base(rest[pkg] as string)}`
206
+ rest = rest.slice(pkg + 1)
207
+ }
208
+ }
209
+ if (program.startsWith('python')) {
210
+ if (rest[0] === '-c' || rest[0] === '-m') return `${program} ${rest[0]}${rest[0] === '-m' && rest[1] != null ? ` ${rest[1]}` : ''}`
211
+ return rest.some((w) => isPathLike(w)) ? `${program} <file>` : program
212
+ }
213
+ if ((program === 'sed' || program === 'perl') && rest.some((w) => /^-[a-zA-Z]*i/.test(w) || w === '--in-place')) return `${program} -i (edit)`
214
+ if (program === 'cat' && rest[0] === '>') return 'cat > (write)'
215
+ if (NO_SUBCOMMAND.has(program)) return program
216
+
217
+ const parts = [program]
218
+ const eats = ARG_FLAGS[program]
219
+ const depth = TWO_LEVELS.has(program) || program === 'bun' || program === 'npm' || program === 'pnpm' ? 2 : 1
220
+ let subs = 0
221
+ for (let i = 0; i < rest.length; i++) {
222
+ const w = rest[i] as string
223
+ if (w.startsWith('-')) {
224
+ if (eats?.has(w)) i++
225
+ if (subs > 0) break
226
+ continue
227
+ }
228
+ if (isWord(w) && subs < depth) {
229
+ parts.push(w)
230
+ subs++
231
+ // `bun test x`: one level, then the argument is what it runs.
232
+ // `bun run x y` and `bun x y` (a package script, often the
233
+ // project CLI) keep the script and its first word
234
+ if (program === 'bun' && subs === 1 && BUN_BUILTINS.has(w) && w !== 'run') break
235
+ if (program === 'bun' && subs === 2 && parts[1] === 'run' && i + 1 < rest.length && isWord(rest[i + 1] as string)) {
236
+ parts.push(rest[i + 1] as string)
237
+ break
238
+ }
239
+ if ((program === 'npm' || program === 'pnpm') && subs === 1 && w !== 'run') break
240
+ continue
241
+ }
242
+ if (isPathLike(w) && subs === 0) parts.push('<file>')
243
+ break
244
+ }
245
+ if ((program === 'sk' || program === 'shipkit') && parts[1] === 'ci') {
246
+ const only = onlyValues(rest)
247
+ if (only.length > 0) return `${program} ci --only ${only.join(',')}`
248
+ }
249
+ if (program === 'bun' && parts[1] === 'test' && parts.length === 2 && rest.some((w) => isPathLike(w))) parts.push('<file>')
250
+ return parts.join(' ')
251
+ }
252
+
253
+ /** the `--only` values of an `sk ci` line, sorted: `--only a --only b,c` */
254
+ function onlyValues(words: string[]): string[] {
255
+ const out: string[] = []
256
+ for (let i = 0; i < words.length; i++) {
257
+ const w = words[i] as string
258
+ if (w === '--only' && words[i + 1] != null) out.push(...(words[++i] as string).split(','))
259
+ else if (w.startsWith('--only=')) out.push(...w.slice(7).split(','))
260
+ }
261
+ return [...new Set(out.filter((x) => x !== ''))].sort()
262
+ }
263
+
264
+ /** the shape of any tool call: Bash by its command, a sub-agent by its type,
265
+ * a skill by its name, everything else by the tool name */
266
+ export function toolShape(name: string, detail: string): string {
267
+ if (name === 'Bash') return commandShape(detail)
268
+ if (name === 'Agent' || name === 'Task') return `Agent ${detail.split(':')[0] || 'general-purpose'}`
269
+ if (name === 'Skill' && detail !== '') return `Skill ${detail}`
270
+ return name
271
+ }