rvlib-shipkit 2.2.0 → 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 (107) hide show
  1. package/CHANGELOG.md +38 -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/cli/cmd_Commands.ts +5 -0
  12. package/src/cli/cmd_Review.ts +948 -0
  13. package/src/cli/cmd_Time.ts +112 -0
  14. package/src/cli/cmd_Trailer.ts +259 -6
  15. package/src/cli/kit/verb.ts +14 -4
  16. package/src/cli.ts +6 -4
  17. package/src/collections/collection-types.ts +6 -1
  18. package/src/collections/providers.ts +3 -0
  19. package/src/collections/review.ts +70 -0
  20. package/src/core/layout.ts +2 -0
  21. package/src/reflect/prompt-corpus.ts +15 -119
  22. package/src/reflect/transcript.ts +254 -0
  23. package/src/review/anim.ts +228 -0
  24. package/src/review/auto.ts +141 -0
  25. package/src/review/format.ts +150 -0
  26. package/src/review/http.ts +23 -0
  27. package/src/review/marks.ts +43 -0
  28. package/src/review/media.ts +45 -0
  29. package/src/review/model.ts +854 -0
  30. package/src/review/project.ts +110 -0
  31. package/src/review/role.ts +48 -0
  32. package/src/review/routes.ts +228 -0
  33. package/src/review/store.ts +1002 -0
  34. package/src/review/threads.ts +237 -0
  35. package/src/review/trailer-desk.ts +385 -0
  36. package/src/review/trailer-model.ts +183 -0
  37. package/src/review/trailer-routes.ts +136 -0
  38. package/src/review/unit.ts +165 -0
  39. package/src/review/watchers.ts +54 -0
  40. package/src/serve/embed.ts +35 -0
  41. package/src/serve/hub-app/activity-view.tsx +1 -1
  42. package/src/serve/hub-app/activity.css +0 -4
  43. package/src/serve/hub-app/dock-view.tsx +5 -4
  44. package/src/serve/hub-app/hash-route.ts +28 -1
  45. package/src/serve/hub-app/header-bar.tsx +3 -1
  46. package/src/serve/hub-app/home-st.ts +10 -2
  47. package/src/serve/hub-app/home-strip.tsx +3 -2
  48. package/src/serve/hub-app/home.html +1 -0
  49. package/src/serve/hub-app/hub-app.tsx +80 -11
  50. package/src/serve/hub-app/hub.css +45 -13
  51. package/src/serve/hub-app/icons.tsx +9 -0
  52. package/src/serve/hub-app/index.html +1 -0
  53. package/src/serve/hub-app/insights-view.tsx +4 -3
  54. package/src/serve/hub-app/keys.ts +14 -10
  55. package/src/serve/hub-app/layout-st.ts +63 -2
  56. package/src/serve/hub-app/planned-view.tsx +3 -2
  57. package/src/serve/hub-app/planned.css +0 -1
  58. package/src/serve/hub-app/project-list.css +22 -4
  59. package/src/serve/hub-app/project-list.tsx +41 -8
  60. package/src/serve/hub-app/projects-svc.ts +4 -0
  61. package/src/serve/hub-app/repo-sections.ts +15 -3
  62. package/src/serve/hub-app/repo-st.ts +45 -3
  63. package/src/serve/hub-app/review/clip-st.ts +208 -0
  64. package/src/serve/hub-app/review/kind-view.tsx +171 -0
  65. package/src/serve/hub-app/review/kit.tsx +113 -0
  66. package/src/serve/hub-app/review/review-answer.tsx +239 -0
  67. package/src/serve/hub-app/review/review-board.tsx +46 -0
  68. package/src/serve/hub-app/review/review-list.tsx +156 -0
  69. package/src/serve/hub-app/review/review-page.tsx +101 -0
  70. package/src/serve/hub-app/review/review-st.ts +779 -0
  71. package/src/serve/hub-app/review/review-views.tsx +503 -0
  72. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  73. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  74. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  75. package/src/serve/hub-app/review.css +329 -0
  76. package/src/serve/hub-app/theme-st.ts +56 -0
  77. package/src/serve/hub-app/tree-build.ts +4 -0
  78. package/src/serve/hub-app/tree-row.tsx +1 -1
  79. package/src/serve/hub-app/tree-svc.ts +4 -0
  80. package/src/serve/hub-runtime.ts +5 -3
  81. package/src/serve/same-origin.ts +5 -1
  82. package/src/serve/serve-hub.ts +8 -0
  83. package/src/shipkit-files.ts +9 -5
  84. package/src/trailer/adapter.ts +58 -3
  85. package/src/trailer/build.ts +300 -41
  86. package/src/trailer/ffmpeg.ts +22 -0
  87. package/src/trailer/footage.ts +109 -40
  88. package/src/trailer/grammar.ts +444 -69
  89. package/src/trailer/graph.ts +199 -14
  90. package/src/trailer/holes.ts +72 -0
  91. package/src/trailer/load-unit.ts +20 -3
  92. package/src/trailer/look.ts +17 -3
  93. package/src/trailer/passage.ts +129 -0
  94. package/src/trailer/plan.ts +80 -21
  95. package/src/trailer/prune.ts +193 -0
  96. package/src/trailer/score/compose.ts +240 -0
  97. package/src/trailer/score/dsp.ts +272 -0
  98. package/src/trailer/score/palette.ts +188 -0
  99. package/src/trailer/score/pattern.ts +91 -0
  100. package/src/trailer/score/score.ts +214 -0
  101. package/src/trailer/score/sound.ts +112 -0
  102. package/src/trailer/score/synth.ts +361 -0
  103. package/src/trailer/states.ts +76 -0
  104. package/src/trailer/stitch.ts +92 -9
  105. package/src/trailer/titles.ts +123 -18
  106. package/src/trailer/vocab.ts +23 -4
  107. package/src/trailer/words.ts +254 -0
@@ -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
+ }
@@ -0,0 +1,84 @@
1
+ // What his typed prompts say about the work, by rule, no model: a status ping
2
+ // (he is waiting and asks where things stand), a slowness complaint, a
3
+ // correction. The rules are this visible table, so every count is explainable
4
+ // by the row that matched.
5
+ //
6
+ // The constraint a reader breaks first: his prompts are also SPECS, and a spec
7
+ // says "slow" about the game ("the camera pans too slow"). A slowness rule
8
+ // names the agent's world (tests, the gate, a tool, "takes forever"), never
9
+ // the bare word.
10
+
11
+ export type Signal = 'status' | 'slow' | 'correction'
12
+
13
+ export type Rule = { signal: Signal; id: string; re: RegExp }
14
+
15
+ const AGENT_WORLD = String.raw`(?:tests?|ci|gate|tools?|builds?|agents?|commands?|machine|cpu|checks?|deploys?|hub|servers?|sessions?|lanes?|you|sk|shipkit|typecheck|tsc|lint|everything|things|projects?|progress|it all|shots?|renders?|indexing)`
16
+
17
+ export const RULES: Rule[] = [
18
+ { signal: 'status', id: 'where-stand', re: /\bwhere (?:do|are) we (?:stand|at)\b/i },
19
+ { signal: 'status', id: 'progress', re: /\b(?:good progress|any progress|how(?:'s| is) it going|any news|status update|still (?:running|working|on it)|are you (?:done|finished|still))\b/i },
20
+ { signal: 'status', id: 'done-q', re: /^\s*(?:done|finished|status|progress|eta|so)\s*\?+\s*$/i },
21
+ { signal: 'status', id: 'fr-avance', re: /\b(?:ça avance|ca avance|où en (?:est|sommes)|ou en est)\b/i },
22
+
23
+ { signal: 'slow', id: 'too-slow', re: new RegExp(String.raw`\b${AGENT_WORLD}\b[^.?!\n]{0,40}\b(?:too|so|very|really|super|crazy|insanely|extremely|painfully)\s+slow|\b(?:too|so|very|really|super|crazy|insanely|extremely|painfully)\s+slow\b[^.?!\n]{0,30}\b${AGENT_WORLD}\b`, 'i') },
24
+ { signal: 'slow', id: 'takes-long', re: /\b(?:takes?|taking|took)\s+(?:so\s+|too\s+|way\s+too\s+|really\s+)?(?:long|forever|ages|minutes)\b/i },
25
+ { signal: 'slow', id: 'world-slow', re: new RegExp(String.raw`\b${AGENT_WORLD}\b[^.?!\n]{0,40}\b(?:slow|sluggish|hangs?|stuck)\b`, 'i') },
26
+ { signal: 'slow', id: 'slow-world', re: new RegExp(String.raw`\b(?:slow|sluggish)\b[^.?!\n]{0,20}\b${AGENT_WORLD}\b`, 'i') },
27
+ { signal: 'slow', id: 'make-faster', re: new RegExp(String.raw`\b(?:make|making|get)\s+${AGENT_WORLD}\s+(?:\w+\s+)?faster\b`, 'i') },
28
+ { signal: 'slow', id: 'still-waiting', re: /\b(?:(?:i'?m|i am|we'?re|we are) still waiting|waiting for ages|been waiting|wait(?:ing)? forever|my cpu|save my machine)\b/i },
29
+ { signal: 'slow', id: 'slow-equals', re: /\bslow\s*=\s*wrong\b/i },
30
+
31
+ { signal: 'correction', id: 'double-bang', re: /!!/ },
32
+ { signal: 'correction', id: 'already-said', re: /\b(?:i (?:already|just) (?:said|told|asked)|how many times|as i said|i said (?:it|that) before|again\s*\?!|stop doing)\b/i },
33
+ { signal: 'correction', id: 'swear', re: /\b(?:wtf|fuck\w*|ffs|ridiculous|seriously\s*\?|are you kidding)\b/i },
34
+ { signal: 'correction', id: 'not-what', re: /\b(?:that'?s not what i (?:asked|said|meant)|you (?:didn'?t|did not) (?:read|listen)|you misread|wrong again)\b/i },
35
+ ]
36
+
37
+ /** short words in capitals that are names, never shouting */
38
+ const CAPS_OK = new Set([
39
+ 'API', 'URL', 'URLS', 'JSON', 'HTML', 'CSS', 'PR', 'OK', 'TODO', 'README', 'CLI', 'TUI', 'DSL', 'TLDR', 'ASAP', 'GPU', 'CPU', 'IDS',
40
+ 'FAIL', 'WARN', 'GO', 'UI', 'UX', 'SVG', 'PNG', 'MP4', 'SQL', 'HTTP', 'HTTPS', 'SSH', 'NPM', 'TS', 'JS', 'MD', 'IMO', 'FYI', 'LLM',
41
+ 'AGENTS', 'CLAUDE', 'STE', 'VAT', 'TVA', 'OVH', 'DNS', 'CDN', 'OG', 'RGB', 'FPS', 'HMR', 'MCP', 'SDK', 'AI', 'GIF', 'PDF', 'EN', 'FR',
42
+ 'WIP', 'MVP', 'VS', 'ID', 'NPC', 'NPCS', 'HUD', 'HP', 'XP', 'UTC', 'CET', 'ENV', 'EOF', 'RAM', 'SSD', 'USB', 'AND', 'OR', 'NOT',
43
+ ])
44
+
45
+ /** code and pastes say nothing about his mood: fenced blocks, inline code and
46
+ * quoted pastes are dropped before any rule runs */
47
+ export function prose(text: string): string {
48
+ return text
49
+ .replace(/<pasted_content\b[\s\S]*?(?:<\/pasted_content>|$)/g, ' ')
50
+ .replace(/```[\s\S]*?(?:```|$)/g, ' ')
51
+ .replace(/`[^`\n]*`/g, ' ')
52
+ .split('\n')
53
+ // quoted lines, tables, stack frames, shell lines, and an agent's report
54
+ // pasted back (its lines open on a skim marker)
55
+ .filter((l) => !/^\s*(?:>|\||\d+\s*[|:]|at |\$ |[✅💬🔴🔶🧹💡❓🤝🚧🔼🚀🔒💥💸])/u.test(l))
56
+ .join('\n')
57
+ }
58
+
59
+ /** two or more shouted words (three letters or more, not a known name) */
60
+ export function shouts(text: string): number {
61
+ const words = text.match(/\b[A-Z][A-Z']{2,}\b/g) ?? []
62
+ return words.filter((w) => !CAPS_OK.has(w.replace(/'.*$/, '')) && !/^[A-Z]{2,}\d/.test(w)).length
63
+ }
64
+
65
+ export type SignalHit = { signal: Signal; rule: string }
66
+
67
+ /** every signal one prompt carries, with the rule that matched each */
68
+ /** joy shouts too: `!!` and capitals next to these words are delight, not a correction */
69
+ const JOY = /\b(?:love|amazing|great|awesome|perfect|fantastic|wonderful|beautiful|wow|nice|yes|excellent|brilliant|bravo|incredible|gorgeous)\b/i
70
+
71
+ export function signalsOf(text: string): SignalHit[] {
72
+ const body = prose(text).slice(0, 2000)
73
+ const hits: SignalHit[] = []
74
+ const seen = new Set<Signal>()
75
+ const joy = JOY.test(body)
76
+ for (const r of RULES) {
77
+ if (joy && r.id === 'double-bang') continue
78
+ if (seen.has(r.signal) || !r.re.test(body)) continue
79
+ seen.add(r.signal)
80
+ hits.push({ signal: r.signal, rule: r.id })
81
+ }
82
+ if (!seen.has('correction') && !joy && shouts(body) >= 2) hits.push({ signal: 'correction', rule: 'caps' })
83
+ return hits
84
+ }