@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +23 -4
- package/adapters/omp/context.ts +1 -1
- package/adapters/omp/events.ts +286 -81
- package/adapters/omp/index.ts +3 -1
- package/adapters/omp/renderers.ts +4 -54
- package/adapters/omp/storage.ts +6 -1
- package/adapters/omp/ui.ts +30 -1
- package/adapters/pi/events.ts +280 -83
- package/adapters/pi/index.ts +11 -2
- package/adapters/pi/renderers.ts +3 -47
- package/adapters/pi/storage.ts +6 -1
- package/adapters/pi/ui.ts +30 -1
- package/adapters/shared/file-storage.ts +21 -0
- package/adapters/shared/widget-layout.ts +134 -2
- package/cli/settings.ts +1 -1
- package/cli/tui.tsx +1 -1
- package/core/command-service.ts +13 -0
- package/core/engine.ts +11 -2
- package/core/identity.ts +15 -0
- package/core/model.ts +2 -0
- package/core/ports.ts +4 -0
- package/core/reactions.ts +29 -2
- package/core/render-model.ts +4 -3
- package/hooks/file-type-react.sh +1 -1
- package/hooks/mood-react.sh +1 -1
- package/hooks/name-react.sh +1 -1
- package/hooks/react.sh +1 -1
- package/package.json +6 -2
- package/scripts/ci/Dockerfile.vhs +125 -0
- package/scripts/ci/check-statusline.sh +338 -0
- package/scripts/ci/check-vhs-goldens.sh +97 -0
- package/scripts/ci/fonts.conf +37 -0
- package/scripts/ci/goldens/baseline-40.png +0 -0
- package/scripts/ci/goldens/baseline-80.png +0 -0
- package/scripts/ci/goldens/cjk-80.png +0 -0
- package/scripts/ci/goldens/emoji-80.png +0 -0
- package/scripts/ci/render-statusline-pty.py +99 -0
- package/scripts/ci/render-vhs-goldens.sh +235 -0
- package/scripts/ci/statusline-fixture/buddy-state/config.json +1 -0
- package/scripts/ci/statusline-fixture/buddy-state/status.json +39 -0
- package/scripts/ci/statusline.tape +9 -0
- package/scripts/ci/vhs-run-statusline.sh +16 -0
- package/scripts/gen-emoji-widths.ts +41 -17
- package/scripts/paths.sh +3 -1
- package/server/art.ts +45 -13
- package/server/hooks/buddy-comment.ts +166 -17
- package/server/hooks/common.ts +33 -0
- package/server/hooks/file-type-react.ts +147 -0
- package/server/hooks/mood-react.ts +107 -0
- package/server/hooks/name-react.ts +98 -0
- package/server/hooks/react.ts +413 -0
- package/server/hooks/reaction-data.ts +2722 -0
- package/server/index.ts +67 -27
- package/server/path.ts +3 -0
- package/server/state.ts +68 -17
- package/skills/buddy/SKILL.md +1 -1
- package/statusline/buddy-status.sh +351 -59
- package/statusline/emoji-text.data +3 -0
- package/statusline/emoji-widths.data +2 -3
- package/statusline/substatus.sh +73 -10
- package/hooks/legacy/file-type-react.sh +0 -403
- package/hooks/legacy/mood-react.sh +0 -210
- package/hooks/legacy/name-react.sh +0 -178
- package/hooks/legacy/react.sh +0 -1364
- 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
|
|
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())
|