@ramarivera/coding-buddy 0.7.3 → 0.9.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 (67) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +23 -4
  4. package/adapters/omp/context.ts +1 -1
  5. package/adapters/omp/events.ts +286 -81
  6. package/adapters/omp/index.ts +3 -1
  7. package/adapters/omp/renderers.ts +4 -54
  8. package/adapters/omp/storage.ts +6 -1
  9. package/adapters/omp/ui.ts +30 -1
  10. package/adapters/pi/events.ts +280 -83
  11. package/adapters/pi/index.ts +11 -2
  12. package/adapters/pi/renderers.ts +3 -47
  13. package/adapters/pi/storage.ts +6 -1
  14. package/adapters/pi/ui.ts +30 -1
  15. package/adapters/shared/file-storage.ts +21 -0
  16. package/adapters/shared/widget-layout.ts +134 -2
  17. package/cli/settings.ts +1 -1
  18. package/cli/tui.tsx +1 -1
  19. package/core/command-service.ts +13 -0
  20. package/core/engine.ts +11 -2
  21. package/core/identity.ts +15 -0
  22. package/core/model.ts +2 -0
  23. package/core/ports.ts +4 -0
  24. package/core/reactions.ts +29 -2
  25. package/core/render-model.ts +4 -3
  26. package/hooks/file-type-react.sh +1 -1
  27. package/hooks/mood-react.sh +1 -1
  28. package/hooks/name-react.sh +1 -1
  29. package/hooks/react.sh +1 -1
  30. package/package.json +6 -2
  31. package/scripts/ci/Dockerfile.vhs +125 -0
  32. package/scripts/ci/check-statusline.sh +338 -0
  33. package/scripts/ci/check-vhs-goldens.sh +97 -0
  34. package/scripts/ci/fonts.conf +37 -0
  35. package/scripts/ci/goldens/baseline-40.png +0 -0
  36. package/scripts/ci/goldens/baseline-80.png +0 -0
  37. package/scripts/ci/goldens/cjk-80.png +0 -0
  38. package/scripts/ci/goldens/emoji-80.png +0 -0
  39. package/scripts/ci/render-statusline-pty.py +99 -0
  40. package/scripts/ci/render-vhs-goldens.sh +235 -0
  41. package/scripts/ci/statusline-fixture/buddy-state/config.json +1 -0
  42. package/scripts/ci/statusline-fixture/buddy-state/status.json +39 -0
  43. package/scripts/ci/statusline.tape +9 -0
  44. package/scripts/ci/vhs-run-statusline.sh +16 -0
  45. package/scripts/gen-emoji-widths.ts +41 -17
  46. package/scripts/paths.sh +3 -1
  47. package/server/art.ts +45 -13
  48. package/server/hooks/buddy-comment.ts +166 -17
  49. package/server/hooks/common.ts +33 -0
  50. package/server/hooks/file-type-react.ts +147 -0
  51. package/server/hooks/mood-react.ts +107 -0
  52. package/server/hooks/name-react.ts +98 -0
  53. package/server/hooks/react.ts +413 -0
  54. package/server/hooks/reaction-data.ts +2722 -0
  55. package/server/index.ts +67 -27
  56. package/server/path.ts +3 -0
  57. package/server/state.ts +68 -17
  58. package/skills/buddy/SKILL.md +1 -1
  59. package/statusline/buddy-status.sh +351 -59
  60. package/statusline/emoji-text.data +3 -0
  61. package/statusline/emoji-widths.data +2 -3
  62. package/statusline/substatus.sh +73 -10
  63. package/hooks/legacy/file-type-react.sh +0 -403
  64. package/hooks/legacy/mood-react.sh +0 -210
  65. package/hooks/legacy/name-react.sh +0 -178
  66. package/hooks/legacy/react.sh +0 -1364
  67. package/server/hooks/legacy-bash.ts +0 -34
@@ -0,0 +1,338 @@
1
+ #!/usr/bin/env bash
2
+ # CI golden check for the Claude Code statusline.
3
+ #
4
+ # Primary signal: loud text assertions over a width matrix + CJK / wide-emoji
5
+ # fixtures. A broken render fails the build. VHS image goldens are a separate
6
+ # step (scripts/ci/check-vhs-goldens.sh).
7
+ #
8
+ # Run from the repo root:
9
+ # bash scripts/ci/check-statusline.sh
10
+ set -euo pipefail
11
+
12
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
13
+ cd "$ROOT"
14
+
15
+ fixture_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/statusline-fixture" && pwd)"
16
+ pty_helper="$ROOT/scripts/ci/render-statusline-pty.py"
17
+ payload='{"session_id":"ci","model":{"display_name":"CI"},"workspace":{"current_dir":"."}}'
18
+
19
+ # Widths that have historically sheared the card / truncated the right edge.
20
+ WIDTHS=(20 40 60 80 93 120 200)
21
+
22
+ # Claude Code kills statusline scripts around 1s; stay under that budget.
23
+ TIME_BUDGET_MS=1000
24
+
25
+ fail() {
26
+ echo "FAIL: $1" >&2
27
+ if [ -n "${2:-}" ]; then
28
+ printf '%s\n' "$2" >&2
29
+ fi
30
+ exit 1
31
+ }
32
+
33
+ # Strip SGR / CSI color sequences. Keep the rest of the glyphs intact so
34
+ # display-width math matches what a terminal would paint.
35
+ strip_ansi() {
36
+ # shellcheck disable=SC2001
37
+ printf '%s' "$1" | sed $'s/\x1b\\[[0-9;]*[A-Za-z]//g'
38
+ }
39
+
40
+ # Display width — mirrors statusline/buddy-status.sh:dwidth and
41
+ # server/art.ts:displayWidth (Emoji_Presentation + CJK + VS16 upgrade).
42
+ EMOJI_WIDTHS_DATA="$ROOT/statusline/emoji-widths.data"
43
+ EMOJI_PRES_2600="$(grep -v '^#' "$EMOJI_WIDTHS_DATA" 2>/dev/null | tr -d '\n' || true)"
44
+
45
+ dwidth() {
46
+ printf '%s' "$1" | iconv -f UTF-8 -t UTF-32LE 2>/dev/null | od -An -tu4 -v | awk -v pres="$EMOJI_PRES_2600" '
47
+ function load_ranges(value, target, n, i, count, bounds, start, end, cp) {
48
+ n = split(value, ranges, ",")
49
+ for (i = 1; i <= n; i++) {
50
+ count = split(ranges[i], bounds, "-")
51
+ start = bounds[1] + 0
52
+ end = (count == 2) ? bounds[2] + 0 : start
53
+ for (cp = start; cp <= end; cp++) target[cp] = 1
54
+ }
55
+ }
56
+ BEGIN {
57
+ load_ranges(pres, wide)
58
+ }
59
+ function char_width(cp) {
60
+ if (cp >= 126976) return 2
61
+ if (cp >= 9728 && cp <= 10175) return (cp in wide) ? 2 : 1
62
+ if (cp >= 9472 && cp <= 9631) return 1
63
+ if (cp >= 12288 && cp <= 40959) return 2
64
+ if (cp >= 65281 && cp <= 65376) return 2
65
+ return 1
66
+ }
67
+ {
68
+ for (i = 1; i <= NF; i++) {
69
+ cp = $i + 0
70
+ if (cp == 65039) {
71
+ if (upgradable) { w += 1; upgradable = 0 }
72
+ continue
73
+ }
74
+ if ((cp >= 65024 && cp <= 65038) || cp == 8205) { upgradable = 0; continue }
75
+ cw = char_width(cp)
76
+ w += cw
77
+ upgradable = (cw == 1 && cp >= 9728 && cp <= 10175) ? 1 : 0
78
+ }
79
+ }
80
+ END { print w + 0 }
81
+ '
82
+ }
83
+
84
+ now_ms() {
85
+ if command -v python3 >/dev/null 2>&1; then
86
+ python3 -c 'import time; print(int(time.time() * 1000))'
87
+ else
88
+ echo $(( $(date +%s) * 1000 ))
89
+ fi
90
+ }
91
+
92
+ # Render statusline against a fixture dir at a fixed terminal width.
93
+ # Uses a pseudo-TTY so buddy-status.sh's stty/PTY width probe sees the
94
+ # requested cols (COLUMNS alone is ignored when the parent TTY is wide).
95
+ # Sets globals: OUTPUT, PLAIN, ELAPSED_MS, EFFECTIVE_COLS
96
+ render_at() {
97
+ local config_dir="$1"
98
+ local cols="$2"
99
+ local rendered errfile
100
+ errfile=$(mktemp)
101
+ # The fixtures write reaction.default.json, but paths.sh derives the
102
+ # per-session filename from CLAUDE_CODE_SESSION_ID / TMUX_PANE. Whichever of
103
+ # those happens to be set in the ambient environment decides which reaction
104
+ # file is read, so the check passed locally and failed on CI (no bubble
105
+ # rendered, because the reaction was looked up under a different session id).
106
+ # Pin them empty so the session id is always "default" and the result does
107
+ # not depend on where the check runs.
108
+ rendered="$(
109
+ CLAUDE_CODE_SESSION_ID="" \
110
+ TMUX_PANE="" \
111
+ STATUSLINE_PAYLOAD="$payload" \
112
+ python3 "$pty_helper" \
113
+ "$cols" \
114
+ "$ROOT" \
115
+ "$config_dir" \
116
+ "$ROOT/statusline/buddy-status.sh" \
117
+ 2>"$errfile"
118
+ )"
119
+ # Prefer the child-only timer from the PTY helper (excludes harness import).
120
+ ELAPSED_MS=$(sed -n 's/^STATUSLINE_ELAPSED_MS=//p' "$errfile" | tail -n1)
121
+ rm -f "$errfile"
122
+ case "$ELAPSED_MS" in
123
+ ''|*[!0-9]*) ELAPSED_MS=9999 ;;
124
+ esac
125
+ OUTPUT="$(printf '%s' "$rendered" | tr -d '\r')"
126
+ PLAIN="$(strip_ansi "$OUTPUT")"
127
+ # buddy-status.sh currently floors detected widths below 40 up to 125
128
+ # (owned by the resize worker). Assert against the budget the script
129
+ # actually honors so the matrix stays honest without forking that logic.
130
+ if [ "$cols" -lt 40 ]; then
131
+ EFFECTIVE_COLS=125
132
+ else
133
+ EFFECTIVE_COLS="$cols"
134
+ fi
135
+ }
136
+
137
+ assert_render() {
138
+ local label="$1"
139
+ local cols="$2"
140
+ local expect_name="$3"
141
+ local expect_substr="${4:-}"
142
+ local budget="${EFFECTIVE_COLS:-$cols}"
143
+
144
+ local plain_lines line_count max_w w line
145
+ plain_lines=$(printf '%s\n' "$PLAIN")
146
+ line_count=$(printf '%s\n' "$plain_lines" | wc -l | tr -d ' ')
147
+
148
+ printf '%s\n' "$PLAIN" | grep -Fq "$expect_name" \
149
+ || fail "[$label] pet name '$expect_name' missing from output" "$PLAIN"
150
+
151
+ [ "$line_count" -ge 4 ] \
152
+ || fail "[$label] expected >=4 output lines, got $line_count" "$PLAIN"
153
+
154
+ if [ -n "$expect_substr" ]; then
155
+ printf '%s\n' "$PLAIN" | grep -Fq "$expect_substr" \
156
+ || fail "[$label] expected substring missing: $expect_substr" "$PLAIN"
157
+ fi
158
+
159
+ # Sprite content must stay art, not descriptor words.
160
+ if printf '%s\n' "$PLAIN" | grep -Eq '<\([[:alpha:]]{2,}'; then
161
+ fail "[$label] sprite looks corrupted (word leaked into eye slot)" "$PLAIN"
162
+ fi
163
+ if printf '%s\n' "$PLAIN" | grep -Fq '{E}'; then
164
+ fail "[$label] unresolved {E} eye placeholder in output" "$PLAIN"
165
+ fi
166
+
167
+ max_w=0
168
+ while IFS= read -r line || [ -n "$line" ]; do
169
+ [ -z "$line" ] && continue
170
+ w=$(dwidth "$line")
171
+ if [ "$w" -gt "$max_w" ]; then
172
+ max_w=$w
173
+ fi
174
+ if [ "$w" -gt "$budget" ]; then
175
+ fail "[$label] row display width $w exceeds budget $budget (requested cols=$cols): $(printf '%q' "$line")" "$PLAIN"
176
+ fi
177
+ done <<< "$plain_lines"
178
+
179
+ # Bubble box: border rows and text rows must agree in outer width.
180
+ # Bubble and art share a line ("| text |-- <(° ..."), so measure only the
181
+ # box segment. Ignore short art glyphs like the duck feet (`--') that also
182
+ # start with a backtick.
183
+ local box_w="" border_w text_w trimmed first bubble_only
184
+ while IFS= read -r line || [ -n "$line" ]; do
185
+ [ -z "$line" ] && continue
186
+ trimmed=$(printf '%s' "$line" | sed $'s/^[ \t\xE2\xA0\x80]*//')
187
+ first=$(printf '%s' "$trimmed" | cut -c1)
188
+ bubble_only=""
189
+ if [ "$first" = "." ]; then
190
+ bubble_only=$(printf '%s' "$trimmed" | sed -n 's/^\(\.[-][-]*\.\).*/\1/p')
191
+ elif [ "$first" = '`' ]; then
192
+ bubble_only=$(printf '%s' "$trimmed" | sed -n "s/^\\(\`[-][-]*'\).*/\\1/p")
193
+ elif [ "$first" = "|" ]; then
194
+ bubble_only=$(printf '%s' "$trimmed" | sed -n 's/^\(|[^|]*|\).*/\1/p')
195
+ fi
196
+ [ -z "$bubble_only" ] && continue
197
+ # Real speech boxes are wide (INNER_W defaults to 44 → outer 48). Skip
198
+ # any short accidental match.
199
+ local seg_w
200
+ seg_w=$(dwidth "$bubble_only")
201
+ if [ "$seg_w" -lt 20 ]; then
202
+ continue
203
+ fi
204
+ if [ "$first" = "|" ]; then
205
+ if [ -n "$box_w" ] && [ "$seg_w" -ne "$box_w" ]; then
206
+ fail "[$label] bubble text width $seg_w != border $box_w" "$PLAIN"
207
+ fi
208
+ else
209
+ if [ -z "$box_w" ]; then
210
+ box_w=$seg_w
211
+ elif [ "$seg_w" -ne "$box_w" ]; then
212
+ fail "[$label] bubble border width $seg_w != $box_w" "$PLAIN"
213
+ fi
214
+ fi
215
+ done <<< "$plain_lines"
216
+
217
+ # Name row must not outrun a reasonable panel width.
218
+ local name_line name_w
219
+ name_line=$(printf '%s\n' "$PLAIN" | grep -F "$expect_name" | head -n1 || true)
220
+ if [ -n "$name_line" ]; then
221
+ name_w=$(dwidth "$(printf '%s' "$name_line" | sed $'s/^[ \t\xE2\xA0\x80]*//')")
222
+ if [ "$name_w" -gt 24 ]; then
223
+ fail "[$label] name row display width $name_w looks wider than the panel" "$PLAIN"
224
+ fi
225
+ fi
226
+
227
+ if [ "$ELAPSED_MS" -gt "$TIME_BUDGET_MS" ]; then
228
+ fail "[$label] statusline took ${ELAPSED_MS}ms (budget ${TIME_BUDGET_MS}ms)" "$PLAIN"
229
+ fi
230
+
231
+ echo "OK: $label requested_cols=$cols budget=$budget lines=$line_count max_row_w=$max_w time=${ELAPSED_MS}ms name='$expect_name'"
232
+ }
233
+
234
+ # ─── Fixture 1: committed baseline (pikachu / Cobalt) ────────────────────────
235
+ base_name="$(jq -r .name "$fixture_dir/buddy-state/status.json")"
236
+ # Cold-start warmup (not timed). Claude Code also keeps the process hot after
237
+ # the first tick; the budget is for steady-state, not first-ever spawn.
238
+ render_at "$fixture_dir" 80 >/dev/null || true
239
+ for cols in "${WIDTHS[@]}"; do
240
+ render_at "$fixture_dir" "$cols"
241
+ assert_render "baseline@${cols}" "$cols" "$base_name"
242
+ done
243
+
244
+ # ─── Fixture 2: duck + CJK reaction (real prior breakage) ────────────────────
245
+ cjk_dir=$(mktemp -d)
246
+ cleanup() { rm -rf "$cjk_dir" "${emoji_dir:-}" "${corrupt_dir:-}"; }
247
+ trap cleanup EXIT
248
+ mkdir -p "$cjk_dir/buddy-state"
249
+ cp "$fixture_dir/buddy-state/config.json" "$cjk_dir/buddy-state/config.json"
250
+
251
+ bun -e "
252
+ import { writeFileSync } from 'fs';
253
+ import { getStatusFrames } from './server/art.ts';
254
+ const bones = {
255
+ rarity: 'common', species: 'duck', eye: '°', hat: 'none', shiny: false,
256
+ stats: { DEBUGGING: 50, PATIENCE: 50, CHAOS: 50, WISDOM: 50, SNARK: 50 },
257
+ peak: 'DEBUGGING', dump: 'PATIENCE',
258
+ };
259
+ const { frames, frameSequence } = getStatusFrames(bones);
260
+ writeFileSync(process.argv[1], JSON.stringify({
261
+ name: 'Daffodil', species: 'duck', rarity: 'common', stars: '★',
262
+ face: '(°>', eye: '°', shiny: false, hat: 'none', reaction: '',
263
+ muted: false, achievement: '', frames, frameSequence,
264
+ level: 1, xp: 0, mood: 'focused',
265
+ }));
266
+ " "$cjk_dir/buddy-state/status.json"
267
+
268
+ _ts=$(now_ms)
269
+ printf '{"reaction":"编译通过了 你好世界","timestamp":%s,"reason":"turn"}\n' "$_ts" \
270
+ > "$cjk_dir/buddy-state/reaction.default.json"
271
+
272
+ # Bubble rows use INNER_W=44 (box ~48 cols) today; the resize worker owns
273
+ # shrinking/dropping bubbles under that floor. Exercise CJK width accounting
274
+ # at budgets that can actually hold the current box.
275
+ for cols in 80 93 120 200; do
276
+ render_at "$cjk_dir" "$cols"
277
+ assert_render "cjk@${cols}" "$cols" "Daffodil" "你好"
278
+ printf '%s\n' "$PLAIN" | grep -Fq '<(°' \
279
+ || fail "[cjk@${cols}] duck eye row missing degree-sign glyph" "$PLAIN"
280
+ done
281
+
282
+ # ─── Fixture 3: wide-emoji reaction (VS16 / presentation) ────────────────────
283
+ emoji_dir=$(mktemp -d)
284
+ mkdir -p "$emoji_dir/buddy-state"
285
+ cp "$cjk_dir/buddy-state/status.json" "$emoji_dir/buddy-state/status.json"
286
+ cp "$fixture_dir/buddy-state/config.json" "$emoji_dir/buddy-state/config.json"
287
+ _ts=$(now_ms)
288
+ printf '{"reaction":"ship it 🎉 ❤️ ✨","timestamp":%s,"reason":"success"}\n' "$_ts" \
289
+ > "$emoji_dir/buddy-state/reaction.default.json"
290
+
291
+ for cols in 80 93 120 200; do
292
+ render_at "$emoji_dir" "$cols"
293
+ assert_render "emoji@${cols}" "$cols" "Daffodil" "🎉"
294
+ done
295
+
296
+ # ─── Fixture 4: corrupt eye descriptor must not reach the sprite ─────────────
297
+ # Regression for the live "<(normal ..." bug: even if bones.eye is the word
298
+ # "normal", getStatusFrames must bake a real glyph.
299
+ corrupt_dir=$(mktemp -d)
300
+ mkdir -p "$corrupt_dir/buddy-state"
301
+ cp "$fixture_dir/buddy-state/config.json" "$corrupt_dir/buddy-state/config.json"
302
+
303
+ bun -e "
304
+ import { writeFileSync } from 'fs';
305
+ import { getStatusFrames } from './server/art.ts';
306
+ const bones = {
307
+ rarity: 'common', species: 'duck', eye: 'normal', hat: 'none', shiny: false,
308
+ stats: { DEBUGGING: 50, PATIENCE: 50, CHAOS: 50, WISDOM: 50, SNARK: 50 },
309
+ peak: 'DEBUGGING', dump: 'PATIENCE',
310
+ };
311
+ const { frames, frameSequence } = getStatusFrames(bones);
312
+ for (const body of frames) {
313
+ if (body.includes('normal')) {
314
+ console.error('getStatusFrames leaked descriptor into frame:', body);
315
+ process.exit(2);
316
+ }
317
+ if (body.includes('{E}')) {
318
+ console.error('unresolved placeholder:', body);
319
+ process.exit(2);
320
+ }
321
+ }
322
+ writeFileSync(process.argv[1], JSON.stringify({
323
+ name: 'Daffodil', species: 'duck', rarity: 'common', stars: '★',
324
+ face: '(°>', eye: '°', shiny: false, hat: 'none', reaction: '',
325
+ muted: false, achievement: '', frames, frameSequence,
326
+ level: 1, xp: 0, mood: 'focused',
327
+ }));
328
+ " "$corrupt_dir/buddy-state/status.json"
329
+
330
+ render_at "$corrupt_dir" 80
331
+ assert_render "corrupt-eye@80" 80 "Daffodil"
332
+ if printf '%s\n' "$PLAIN" | grep -Fq 'normal'; then
333
+ fail "[corrupt-eye@80] literal normal appeared in render" "$PLAIN"
334
+ fi
335
+ printf '%s\n' "$PLAIN" | grep -Fq '<(°' \
336
+ || fail "[corrupt-eye@80] expected sanitized degree-sign eye" "$PLAIN"
337
+
338
+ echo "OK: all statusline golden assertions passed"
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env bash
2
+ # Compare freshly rendered VHS PNGs against committed goldens.
3
+ #
4
+ # Usage (from repo root, after render-vhs-goldens.sh):
5
+ # bash scripts/ci/check-vhs-goldens.sh
6
+ #
7
+ # To re-baseline intentionally (MUST use the linux/amd64 container path):
8
+ # bash scripts/ci/render-vhs-goldens.sh --update
9
+ # # or: bun run ci:vhs:update
10
+ # Never re-baseline from host macOS vhs — CI will fail on font SSIM.
11
+ set -euo pipefail
12
+
13
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
14
+ OUT_DIR="$ROOT/scripts/ci/vhs-out"
15
+ GOLDEN_DIR="$ROOT/scripts/ci/goldens"
16
+
17
+ if [ ! -d "$GOLDEN_DIR" ]; then
18
+ echo "FAIL: missing $GOLDEN_DIR — run: bash scripts/ci/render-vhs-goldens.sh --update" >&2
19
+ exit 1
20
+ fi
21
+
22
+ if [ ! -d "$OUT_DIR" ]; then
23
+ echo "FAIL: missing $OUT_DIR — run render-vhs-goldens.sh first" >&2
24
+ exit 1
25
+ fi
26
+
27
+ shopt -s nullglob
28
+ goldens=("$GOLDEN_DIR"/*.png)
29
+ if [ ${#goldens[@]} -eq 0 ]; then
30
+ echo "FAIL: no golden PNGs in $GOLDEN_DIR" >&2
31
+ exit 1
32
+ fi
33
+
34
+ fail=0
35
+ for golden in "${goldens[@]}"; do
36
+ base=$(basename "$golden")
37
+ got="$OUT_DIR/$base"
38
+ if [ ! -f "$got" ]; then
39
+ echo "FAIL: rendered output missing for golden $base" >&2
40
+ fail=1
41
+ continue
42
+ fi
43
+
44
+ # Exact byte identity is ideal; fall back to a tight perceptual threshold
45
+ # via ffmpeg's ssim if metadata/filters differ across ffmpeg builds.
46
+ if cmp -s "$golden" "$got"; then
47
+ echo "OK: $base (byte-identical)"
48
+ continue
49
+ fi
50
+
51
+ # Dimension check first — a size drift is always a real regression.
52
+ read -r gw gh < <(ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0:s=x "$golden")
53
+ read -r ow oh < <(ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0:s=x "$got")
54
+ if [ "$gw" != "$ow" ] || [ "$gh" != "$oh" ]; then
55
+ echo "FAIL: $base size golden=${gw}x${gh} got=${ow}x${oh}" >&2
56
+ fail=1
57
+ continue
58
+ fi
59
+
60
+ # SSIM mean across the frame. 0.99+ is effectively identical for terminal
61
+ # screenshots; lower means glyphs/spacing shifted.
62
+ ssim_line=$(ffmpeg -v error -i "$golden" -i "$got" -lavfi ssim=stats_file=/dev/stdout -f null - 2>/dev/null | tail -n1 || true)
63
+ # stats look like: n:1 Y:0.998 U:0.999 V:0.999 All:0.998 (inf)
64
+ ssim_all=$(printf '%s' "$ssim_line" | sed -n 's/.*All:\([0-9.]*\).*/\1/p')
65
+ if [ -z "$ssim_all" ]; then
66
+ echo "FAIL: $base differs and ssim could not be computed" >&2
67
+ echo " golden: $golden" >&2
68
+ echo " got: $got" >&2
69
+ fail=1
70
+ continue
71
+ fi
72
+ ok=$(awk -v s="$ssim_all" 'BEGIN { print (s + 0 >= 0.99) ? "yes" : "no" }')
73
+ if [ "$ok" = "yes" ]; then
74
+ echo "OK: $base (ssim All=$ssim_all)"
75
+ else
76
+ echo "FAIL: $base visual regression (ssim All=$ssim_all < 0.99)" >&2
77
+ echo " golden: $golden" >&2
78
+ echo " got: $got" >&2
79
+ echo " re-baseline (linux/amd64 container only):" >&2
80
+ echo " bash scripts/ci/render-vhs-goldens.sh --update" >&2
81
+ fail=1
82
+ fi
83
+ done
84
+
85
+ # Every rendered PNG that we care about should have a golden.
86
+ for got in "$OUT_DIR"/*.png; do
87
+ base=$(basename "$got")
88
+ if [ ! -f "$GOLDEN_DIR/$base" ]; then
89
+ echo "FAIL: rendered $base has no committed golden (forget to --update?)" >&2
90
+ fail=1
91
+ fi
92
+ done
93
+
94
+ if [ "$fail" -ne 0 ]; then
95
+ exit 1
96
+ fi
97
+ echo "OK: all vhs goldens matched"
@@ -0,0 +1,37 @@
1
+ <?xml version="1.0"?>
2
+ <!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
3
+ <!-- Stable font selection for VHS goldens. Keep in lockstep with Dockerfile.vhs. -->
4
+ <fontconfig>
5
+ <alias>
6
+ <family>monospace</family>
7
+ <prefer>
8
+ <family>DejaVu Sans Mono</family>
9
+ <family>Noto Sans Mono</family>
10
+ <family>Noto Sans Mono CJK SC</family>
11
+ <family>Noto Color Emoji</family>
12
+ </prefer>
13
+ </alias>
14
+
15
+ <alias>
16
+ <family>DejaVu Sans Mono</family>
17
+ <prefer>
18
+ <family>DejaVu Sans Mono</family>
19
+ </prefer>
20
+ </alias>
21
+
22
+ <!-- Prefer color emoji glyphs over monochrome fallbacks. -->
23
+ <match target="pattern">
24
+ <test name="family"><string>emoji</string></test>
25
+ <edit name="family" mode="prepend" binding="strong">
26
+ <string>Noto Color Emoji</string>
27
+ </edit>
28
+ </match>
29
+
30
+ <!-- CJK: route Han to Noto CJK SC so 编译/你好 paint deterministically. -->
31
+ <match target="pattern">
32
+ <test name="lang"><string>zh-cn</string></test>
33
+ <edit name="family" mode="prepend" binding="strong">
34
+ <string>Noto Sans CJK SC</string>
35
+ </edit>
36
+ </match>
37
+ </fontconfig>
Binary file
Binary file
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env python3
2
+ """Render buddy-status.sh under a fixed PTY winsize.
3
+
4
+ buddy-status.sh probes the parent TTY via stty, so COLUMNS alone is not
5
+ enough when CI (or a wide agent terminal) already has a PTY. This helper
6
+ forks a PTY, sets TIOCSWINSZ to the requested cols, and prints the raw
7
+ statusline bytes to stdout.
8
+
9
+ Elapsed wall time for the child (statusline) is printed on stderr as:
10
+ STATUSLINE_ELAPSED_MS=<int>
11
+ so the golden harness can budget Claude's ~1s kill without counting
12
+ Python/import overhead outside the child.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import fcntl
18
+ import os
19
+ import pty
20
+ import select
21
+ import struct
22
+ import sys
23
+ import termios
24
+ import time
25
+
26
+
27
+ def main() -> int:
28
+ if len(sys.argv) != 5:
29
+ print(
30
+ "usage: render-statusline-pty.py <cols> <root> <config_dir> <script>",
31
+ file=sys.stderr,
32
+ )
33
+ return 2
34
+
35
+ cols = int(sys.argv[1])
36
+ root = sys.argv[2]
37
+ config_dir = sys.argv[3]
38
+ script = sys.argv[4]
39
+ rows = 30
40
+ payload = os.environ.get(
41
+ "STATUSLINE_PAYLOAD",
42
+ '{"session_id":"ci","model":{"display_name":"CI"},"workspace":{"current_dir":"."}}',
43
+ )
44
+
45
+ # Build a tiny pipeline so buddy-status.sh can `cat` its JSON stdin.
46
+ cmd = (
47
+ "printf %s "
48
+ + repr(payload)
49
+ + " | "
50
+ + f"COLUMNS={cols} BUDDY_FAKE_NOW=0 "
51
+ + f"CLAUDE_CONFIG_DIR={config_dir!r} "
52
+ + "CLAUDE_CODE_SESSION_ID= TMUX_PANE= BUDDY_SHELL= "
53
+ + f"bash {script!r}"
54
+ )
55
+
56
+ t0 = time.perf_counter()
57
+ pid, fd = pty.fork()
58
+ if pid == 0:
59
+ os.chdir(root)
60
+ os.execvp("bash", ["bash", "-lc", cmd])
61
+
62
+ fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
63
+ out = bytearray()
64
+ deadline = time.time() + 5.0
65
+ while time.time() < deadline:
66
+ readable, _, _ = select.select([fd], [], [], 0.1)
67
+ if fd in readable:
68
+ try:
69
+ chunk = os.read(fd, 8192)
70
+ except OSError:
71
+ break
72
+ if not chunk:
73
+ break
74
+ out.extend(chunk)
75
+ continue
76
+
77
+ waited = os.waitpid(pid, os.WNOHANG)
78
+ if waited[0] == pid:
79
+ while True:
80
+ readable, _, _ = select.select([fd], [], [], 0.05)
81
+ if fd not in readable:
82
+ break
83
+ try:
84
+ chunk = os.read(fd, 8192)
85
+ except OSError:
86
+ chunk = b""
87
+ if not chunk:
88
+ break
89
+ out.extend(chunk)
90
+ break
91
+
92
+ elapsed_ms = int((time.perf_counter() - t0) * 1000)
93
+ print(f"STATUSLINE_ELAPSED_MS={elapsed_ms}", file=sys.stderr)
94
+ sys.stdout.buffer.write(bytes(out))
95
+ return 0
96
+
97
+
98
+ if __name__ == "__main__":
99
+ raise SystemExit(main())