codebyplan 1.10.3 → 1.11.1

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.
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env python3
2
+ # @hook: NOT-A-HOOK (subagentStatusLine renderer, invoked via the cbp-subagent-statusline.sh dispatcher)
3
+ # Claude Code Subagent Status Line — python renderer. Byte-identical output to the
4
+ # bash renderer in cbp-subagent-statusline.sh and the node renderer in
5
+ # cbp-subagent-statusline.mjs. Output protocol: one {"id":...,"content":...} JSON
6
+ # line per task. See the .sh file for the full option/env/seam contract.
7
+ # Never throws to stdout.
8
+
9
+ import json
10
+ import math
11
+ import os
12
+ import sys
13
+ import time
14
+
15
+
16
+ def main():
17
+ raw_input = sys.stdin.read()
18
+ try:
19
+ data = json.loads(raw_input)
20
+ except Exception:
21
+ data = {}
22
+ if not isinstance(data, dict):
23
+ data = {}
24
+
25
+ root = os.environ.get("CBP_STATUSLINE_ROOT") or (sys.argv[1] if len(sys.argv) > 1 else "")
26
+ if not root:
27
+ root = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".."))
28
+
29
+ try:
30
+ columns = math.trunc(float(data.get("columns") or 0))
31
+ except (TypeError, ValueError):
32
+ columns = 0
33
+ tasks = data.get("tasks")
34
+ if not isinstance(tasks, list):
35
+ tasks = []
36
+
37
+ # ---- Config: no_color from statusline.json -------------------------------
38
+ cfg_no_color = False
39
+ try:
40
+ with open(os.path.join(root, ".codebyplan", "statusline.json"), "r", encoding="utf-8") as fh:
41
+ parsed = json.load(fh)
42
+ if isinstance(parsed, dict) and isinstance(parsed.get("no_color"), bool):
43
+ cfg_no_color = parsed["no_color"]
44
+ except Exception:
45
+ pass
46
+
47
+ no_color = (
48
+ (os.environ.get("NO_COLOR") not in (None, ""))
49
+ or os.environ.get("CBP_SUBAGENT_STATUSLINE_NO_COLOR") == "1"
50
+ or cfg_no_color is True
51
+ )
52
+ if no_color:
53
+ RST = DIM = BOLD = GREEN = RED = BLUE = ""
54
+ else:
55
+ RST = "\x1b[0m"
56
+ DIM = "\x1b[2m"
57
+ BOLD = "\x1b[1m"
58
+ GREEN = "\x1b[32m"
59
+ RED = "\x1b[31m"
60
+ BLUE = "\x1b[34m"
61
+
62
+ def fmt_k(val):
63
+ v = float(val)
64
+ if v >= 1000000:
65
+ t = math.floor((v + 50000) / 100000)
66
+ return "%d.%dM" % (t // 10, t % 10)
67
+ elif v >= 1000:
68
+ t = math.floor((v + 50) / 100)
69
+ return "%d.%dK" % (t // 10, t % 10)
70
+ return str(int(v))
71
+
72
+ def cbp_now():
73
+ nv = os.environ.get("CBP_STATUSLINE_NOW")
74
+ if nv not in (None, ""):
75
+ return math.trunc(float(nv))
76
+ return math.floor(time.time())
77
+
78
+ def fmt_age_secs(start):
79
+ delta = cbp_now() - math.trunc(float(start))
80
+ if delta < 0:
81
+ delta = 0
82
+ if delta >= 3600:
83
+ return "%dh%dm" % (delta // 3600, (delta % 3600) // 60)
84
+ if delta >= 60:
85
+ return "%dm%ds" % (delta // 60, delta % 60)
86
+ return "%ds" % delta
87
+
88
+ def is_ascii_letter(b):
89
+ return (0x41 <= b <= 0x5A) or (0x61 <= b <= 0x7A)
90
+
91
+ def truncate(rendered):
92
+ # ANSI-aware byte-based truncation — mirrors the bash byte walk exactly.
93
+ if not (columns > 0):
94
+ return rendered
95
+ buf = rendered.encode("utf-8")
96
+ raw_len = len(buf)
97
+ target = columns - 3
98
+ if target < 0:
99
+ target = 0
100
+ vis = 0
101
+ i = 0
102
+ in_esc = False
103
+ while i < raw_len and vis < target:
104
+ b = buf[i]
105
+ if in_esc:
106
+ if is_ascii_letter(b):
107
+ in_esc = False
108
+ elif b == 0x1B:
109
+ in_esc = True
110
+ else:
111
+ vis += 1
112
+ i += 1
113
+ while i < raw_len:
114
+ b = buf[i]
115
+ if in_esc:
116
+ if is_ascii_letter(b):
117
+ in_esc = False
118
+ i += 1
119
+ elif b == 0x1B:
120
+ in_esc = True
121
+ i += 1
122
+ else:
123
+ break
124
+ if i < raw_len:
125
+ head = buf[:i]
126
+ tail = (RST + "...").encode("utf-8")
127
+ return (head + tail).decode("utf-8", errors="replace")
128
+ return rendered
129
+
130
+ out = []
131
+ for task in tasks:
132
+ if not isinstance(task, dict):
133
+ continue
134
+ t_id = "" if task.get("id") is None else str(task.get("id"))
135
+ if t_id == "":
136
+ continue
137
+ t_name = "" if task.get("name") is None else str(task.get("name"))
138
+ t_type = "" if task.get("type") is None else str(task.get("type"))
139
+ t_status = "" if task.get("status") is None else str(task.get("status"))
140
+ t_desc = "" if task.get("description") is None else str(task.get("description"))
141
+ try:
142
+ t_start = math.trunc(float(task.get("startTime") or 0))
143
+ except (TypeError, ValueError):
144
+ t_start = 0
145
+ try:
146
+ t_tokens = math.trunc(float(task.get("tokenCount") or 0))
147
+ except (TypeError, ValueError):
148
+ t_tokens = 0
149
+
150
+ if t_status == "running":
151
+ icon = "%s●%s" % (GREEN, RST)
152
+ elif t_status == "pending":
153
+ icon = "%s○%s" % (DIM, RST)
154
+ elif t_status == "completed":
155
+ icon = "%s✓%s" % (BLUE, RST)
156
+ elif t_status == "failed":
157
+ icon = "%s✗%s" % (RED, RST)
158
+ else:
159
+ icon = "%s•%s" % (DIM, RST)
160
+
161
+ row = "%s %s%s%s" % (icon, BOLD, t_name, RST)
162
+ if t_type:
163
+ row += " %s(%s)%s" % (DIM, t_type, RST)
164
+ if os.environ.get("CBP_SUBAGENT_STATUSLINE_HIDE_DESCRIPTION") != "1" and t_desc:
165
+ row += " %s—%s %s" % (DIM, RST, t_desc)
166
+ if os.environ.get("CBP_SUBAGENT_STATUSLINE_HIDE_TOKENS") != "1" and t_tokens > 0:
167
+ row += " %s·%s %stokens:%s%s" % (DIM, RST, DIM, RST, fmt_k(t_tokens))
168
+ if t_start > 0:
169
+ row += " %s·%s %sage:%s%s" % (DIM, RST, DIM, RST, fmt_age_secs(t_start))
170
+
171
+ rendered = truncate(row)
172
+ out.append(
173
+ '{"id":%s,"content":%s}'
174
+ % (json.dumps(t_id, ensure_ascii=False), json.dumps(rendered, ensure_ascii=False))
175
+ )
176
+
177
+ sys.stdout.write(("\n".join(out) + "\n") if out else "")
178
+
179
+
180
+ try:
181
+ main()
182
+ except Exception:
183
+ sys.exit(0)
@@ -1,22 +1,65 @@
1
1
  #!/bin/bash
2
2
  # Portability: bash 3.2+ (macOS default /bin/bash). UTF-8 multibyte chars in
3
3
  # user-controlled fields are byte-iterated under 3.2 (over-counts visible width
4
- # for non-ASCII content); acceptable safe-direction approximation.
5
- # @hook: NOT-A-HOOK (subagentStatusLine renderer, invoked by settings.json subagentStatusLine.command)
6
- # Claude Code Subagent Status Line one JSON line per active subagent
7
- # Purpose: Renders one row per running subagent. Output protocol per upstream docs:
4
+ # for non-ASCII content); acceptable safe-direction approximation — and the node
5
+ # (.mjs) / python (.py) renderers iterate the SAME UTF-8 bytes so all three agree.
6
+ # @hook: NOT-A-HOOK (subagentStatusLine renderer, invoked via settings.json subagentStatusLine.command)
7
+ # Claude Code Subagent Status Line multi-runtime dispatcher + bash renderer.
8
+ # One JSON line per active subagent. Output protocol per upstream docs:
8
9
  # {"id": "<task_id>", "content": "<rendered_body>"}
9
10
  # - Omitted tasks keep their default Claude Code rendering
10
11
  # - Empty content strings hide the row
11
12
  # NOT a PreToolUse/PostToolUse/Notification hook — do NOT register in hooks[].
12
13
  #
13
- # ENV-VAR TOGGLES set to 1 to suppress a column / color
14
+ # RENDERER SELECTION (per-device, gitignored .codebyplan/statusline.local.json):
15
+ # { "renderer": "bash" | "node" | "python" } — default bash, falls back to bash
16
+ # when the chosen runtime is unavailable. Set via `codebyplan statusline …`.
17
+ #
18
+ # CONFIG: honours `no_color` from the committed .codebyplan/statusline.json. The six
19
+ # main-statusline line toggles do NOT apply to subagent rows.
20
+ #
21
+ # ENV-VAR OVERRIDES (env > config > default)
14
22
  # CBP_SUBAGENT_STATUSLINE_HIDE_DESCRIPTION=1 omit DESCRIPTION segment
15
23
  # CBP_SUBAGENT_STATUSLINE_HIDE_TOKENS=1 omit tokens:N segment
16
24
  # CBP_SUBAGENT_STATUSLINE_NO_COLOR=1 strip all ANSI codes (also honoured by $NO_COLOR)
25
+ #
26
+ # TEST SEAMS (no effect in normal use)
27
+ # CBP_STATUSLINE_ROOT=<dir> override the .codebyplan/ lookup root (default: repo root)
28
+ # CBP_STATUSLINE_NOW=<epoch> override "now" for age rendering (determinism)
29
+
30
+ # ============================================================
31
+ # DISPATCHER — resolve root + renderer, delegate when possible
32
+ # ============================================================
33
+ CBP_HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd 2>/dev/null)" || CBP_HOOK_DIR="."
34
+ CBP_ROOT="${CBP_STATUSLINE_ROOT:-}"
35
+ if [ -z "$CBP_ROOT" ]; then
36
+ CBP_ROOT="$(cd "$CBP_HOOK_DIR/../.." && pwd 2>/dev/null)" || CBP_ROOT="$PWD"
37
+ fi
38
+
39
+ CBP_RENDERER="bash"
40
+ CBP_LOCAL_CFG="$CBP_ROOT/.codebyplan/statusline.local.json"
41
+ if [ -f "$CBP_LOCAL_CFG" ] && command -v jq >/dev/null 2>&1; then
42
+ _r="$(jq -r '.renderer // empty' "$CBP_LOCAL_CFG" 2>/dev/null)"
43
+ case "$_r" in bash|node|python) CBP_RENDERER="$_r" ;; esac
44
+ fi
45
+
46
+ if [ "$CBP_RENDERER" = "node" ] && command -v node >/dev/null 2>&1 \
47
+ && [ -f "$CBP_HOOK_DIR/cbp-subagent-statusline.mjs" ]; then
48
+ CBP_STATUSLINE_ROOT="$CBP_ROOT" exec node "$CBP_HOOK_DIR/cbp-subagent-statusline.mjs"
49
+ fi
50
+ if [ "$CBP_RENDERER" = "python" ] && command -v python3 >/dev/null 2>&1 \
51
+ && [ -f "$CBP_HOOK_DIR/cbp-subagent-statusline.py" ]; then
52
+ CBP_STATUSLINE_ROOT="$CBP_ROOT" exec python3 "$CBP_HOOK_DIR/cbp-subagent-statusline.py"
53
+ fi
54
+ # Fall through → inline bash renderer (default + universal fallback).
17
55
 
18
56
  INPUT=$(cat)
19
57
 
58
+ # Byte-deterministic string ops: force the C locale so the truncation walk below
59
+ # counts UTF-8 BYTES (locale-independent), matching the .mjs/.py byte iteration.
60
+ # Output bytes are unaffected (multibyte content passes through verbatim).
61
+ export LC_ALL=C
62
+
20
63
  # One jq pass for base fields (cwd, session_id, columns)
21
64
  eval "$(echo "$INPUT" | jq -r '
22
65
  @sh "CWD=\(.cwd // "")",
@@ -24,9 +67,17 @@ eval "$(echo "$INPUT" | jq -r '
24
67
  @sh "COLUMNS=\(.columns // 0)"
25
68
  ')"
26
69
 
27
- # ---- Colour setup ----------------------------------------------------------
70
+ # ---- Config: no_color from .codebyplan/statusline.json -----------------------
71
+ CFG_NO_COLOR=false
72
+ CBP_CFG="$CBP_ROOT/.codebyplan/statusline.json"
73
+ if [ -f "$CBP_CFG" ] && command -v jq >/dev/null 2>&1; then
74
+ # `== true` (NOT jq `//`): only an explicit true enables no_color.
75
+ eval "$(jq -r '"CFG_NO_COLOR=\(.no_color == true)"' "$CBP_CFG" 2>/dev/null)"
76
+ fi
77
+
78
+ # ---- Colour setup (env > config) ---------------------------------------------
28
79
  # Only the colours referenced by the render block below are defined here.
29
- if [ -n "${NO_COLOR:-}" ] || [ "${CBP_SUBAGENT_STATUSLINE_NO_COLOR:-0}" = "1" ]; then
80
+ if [ -n "${NO_COLOR:-}" ] || [ "${CBP_SUBAGENT_STATUSLINE_NO_COLOR:-0}" = "1" ] || [ "$CFG_NO_COLOR" = "true" ]; then
30
81
  RST=''; DIM=''; BOLD=''; GREEN=''; RED=''; BLUE=''
31
82
  else
32
83
  RST='\033[0m'
@@ -37,25 +88,29 @@ else
37
88
  BLUE='\033[34m'
38
89
  fi
39
90
 
40
- # ---- Token/size formatter K / M (verbatim from cbp-statusline.sh L96-105) ---
91
+ # ---- Token/size formatter K / M integer round-half-up (cross-runtime) ------
41
92
  fmt_k() {
42
93
  local val=$1
43
94
  if [ "$val" -ge 1000000 ] 2>/dev/null; then
44
- printf "%.1fM" "$(echo "$val / 1000000" | bc -l)"
95
+ local t=$(( (val + 50000) / 100000 ))
96
+ printf "%d.%dM" $(( t / 10 )) $(( t % 10 ))
45
97
  elif [ "$val" -ge 1000 ] 2>/dev/null; then
46
- printf "%.1fK" "$(echo "$val / 1000" | bc -l)"
98
+ local t=$(( (val + 50) / 100 ))
99
+ printf "%d.%dK" $(( t / 10 )) $(( t % 10 ))
47
100
  else
48
101
  printf "%d" "$val"
49
102
  fi
50
103
  }
51
104
 
52
- # ---- Age formatter: epoch seconds → "Xs" / "XmYs" / "XhYm" -----------------
53
- # NEW helper — cannot reuse fmt_dur (which divides by 1000 for ms input).
54
- # startTime is a Unix epoch in seconds, same as rate_limits.*.resets_at.
105
+ # ---- "now" with test seam ----------------------------------------------------
106
+ cbp_now() {
107
+ if [ -n "${CBP_STATUSLINE_NOW:-}" ]; then printf '%s' "$CBP_STATUSLINE_NOW"; else date +%s; fi
108
+ }
109
+
110
+ # ---- Age formatter: epoch seconds → "Xs" / "XmYs" / "XhYm" ------------------
55
111
  fmt_age_secs() {
56
112
  local start=$1
57
- local now
58
- now=$(date +%s)
113
+ local now; now=$(cbp_now)
59
114
  local delta=$(( now - start ))
60
115
  [ "$delta" -lt 0 ] && delta=0
61
116
  if [ "$delta" -ge 3600 ]; then
@@ -67,18 +122,16 @@ fmt_age_secs() {
67
122
  fi
68
123
  }
69
124
 
70
- # ---- Strip ANSI codes for visible-length measurement (column truncation) ----
71
- strip_ansi() {
72
- printf '%s' "$1" | sed $'s/\033\\[[0-9;]*m//g'
73
- }
74
-
75
125
  # ---- JSON-string-escape a bash variable (embed in {"id":..., "content":...}) -
76
126
  json_escape() {
77
127
  printf '%s' "$1" | jq -Rs '.'
78
128
  }
79
129
 
80
- # ---- Per-task render: one jq TSV emission, looped in bash ------------------
81
- # Single jq pass for tasks[] (locked decision #3 two jq calls total).
130
+ # ---- Per-task render: one jq US-delimited emission, looped in bash ----------
131
+ # Join with the Unit Separator (U+001F, non-whitespace) rather than @tsv: a tab is
132
+ # IFS-whitespace, so `IFS=$'\t' read` collapses consecutive tabs and an EMPTY field
133
+ # (e.g. blank description) would shift every later field. US is non-whitespace, so
134
+ # empty fields are preserved — matching the .mjs/.py object iteration.
82
135
  echo "$INPUT" | jq -r '
83
136
  .tasks // [] | .[] | [
84
137
  .id // "",
@@ -88,8 +141,8 @@ echo "$INPUT" | jq -r '
88
141
  .description // "",
89
142
  (.startTime // 0 | tostring),
90
143
  (.tokenCount // 0 | tostring)
91
- ] | @tsv
92
- ' | while IFS=$'\t' read -r T_ID T_NAME T_TYPE T_STATUS T_DESC T_START T_TOKENS; do
144
+ ] | join("\u001f")
145
+ ' | while IFS=$'\037' read -r T_ID T_NAME T_TYPE T_STATUS T_DESC T_START T_TOKENS; do
93
146
  # Skip blank rows (empty tasks[] → no output)
94
147
  [ -z "$T_ID" ] && continue
95
148
 
@@ -123,14 +176,12 @@ echo "$INPUT" | jq -r '
123
176
  # Apply printf to interpret escape sequences, then truncate to $COLUMNS
124
177
  RENDERED=$(printf '%b' "$ROW")
125
178
  if [ "$COLUMNS" -gt 0 ] 2>/dev/null; then
126
- # ANSI-aware iterative truncation: walk RENDERED char-by-char treating
127
- # \033[...letter escape sequences as zero-width and ordinary chars as
179
+ # ANSI-aware iterative truncation: walk RENDERED byte-by-byte treating
180
+ # \033[...letter escape sequences as zero-width and ordinary bytes as
128
181
  # one-width. Stop when the visible counter reaches COLUMNS - 3, then
129
- # append RST + ellipsis. Correct under any ANSI-density distribution,
130
- # unlike a single OVERHEAD-add formula which assumes uniform spread.
131
- # Pure bash (no awk/perl subprocess); byte-based slicing means non-ASCII
132
- # multi-byte UTF-8 in user-controlled descriptions counts as multiple
133
- # visible units — acceptable approximation for the ASCII-dominant case.
182
+ # append RST + ellipsis. Byte-based slicing means non-ASCII multi-byte
183
+ # UTF-8 counts as multiple visible units the .mjs/.py renderers iterate
184
+ # the same UTF-8 bytes, so all three stay byte-identical.
134
185
  TARGET=$(( COLUMNS - 3 ))
135
186
  [ "$TARGET" -lt 0 ] && TARGET=0
136
187
  RAW_LEN=${#RENDERED}
@@ -150,12 +201,11 @@ echo "$INPUT" | jq -r '
150
201
  fi
151
202
  i=$(( i + 1 ))
152
203
  done
153
- # Post-loop drain: every rendered row ends with a trailing ${RST} from the
154
- # last segment. Without this drain, a row whose visible content fits within
155
- # COLUMNS still has `i < RAW_LEN` (pointing inside the trailing reset),
156
- # which would spuriously trigger truncation. Walk past any in-progress
157
- # escape and any further escape sequences until we hit a real visible byte
158
- # or end-of-string.
204
+ # Post-loop drain: every rendered row ends with a trailing ${RST}. Without
205
+ # this drain a row whose visible content fits within COLUMNS still has
206
+ # `i < RAW_LEN` (pointing inside the trailing reset), spuriously triggering
207
+ # truncation. Walk past any in-progress escape and any further escapes until
208
+ # a real visible byte or end-of-string.
159
209
  while [ $i -lt $RAW_LEN ]; do
160
210
  ch="${RENDERED:$i:1}"
161
211
  if [ "$in_esc" = "1" ]; then
@@ -171,8 +221,6 @@ echo "$INPUT" | jq -r '
171
221
  fi
172
222
  done
173
223
  if [ $i -lt $RAW_LEN ]; then
174
- # printf '%b' converts the literal-text RST (`\033[0m`) into an actual
175
- # ESC byte sequence, matching the byte semantics of RENDERED's prefix.
176
224
  RENDERED="${RENDERED:0:$i}$(printf '%b' "$RST")..."
177
225
  fi
178
226
  fi
@@ -76,7 +76,7 @@ Stop here.
76
76
 
77
77
  Collect QA results from all tasks and rounds. Build checkpoint-level QA summary:
78
78
  - Count auto checks passed/failed
79
- - List user QA items pending review
79
+ - List default-checklist items still pending review
80
80
 
81
81
  If any critical failures, warn the user but don't block.
82
82
 
@@ -184,7 +184,7 @@ For each screenshot in `e2e_screenshots[]`:
184
184
 
185
185
  Populate `screenshot_review` totals.
186
186
 
187
- **Do not attempt to auto-fix `rendered_visual` or `baseline_regression` findings** — they feed into user QA and the fix loop, because the root cause is typically in app code/data, not in the SCSS.
187
+ **Do not attempt to auto-fix `rendered_visual` or `baseline_regression` findings** — they surface as a blocking gate at `/cbp-round-end` Step 7 (accept-or-fix) and feed the fix loop, because the root cause is typically in app code/data, not in the SCSS.
188
188
 
189
189
  ### Phase 7: Aggregate Findings
190
190
 
@@ -258,5 +258,5 @@ Go beyond fixing violations — actively improve visual quality. If spacing coul
258
258
  - **Also invoked by**: `/cbp-checkpoint-check` (TASK-2 deliverable, future) with screenshots from a whole-checkpoint e2e run
259
259
  - **Consumes**: `e2e_screenshots[]` from `round.context.e2e_output.screenshots` (populated by `test-e2e-agent` at `/cbp-round-execute` Step 5)
260
260
  - **Output written to**: `round.context.frontend_ui_review` — when invoked twice per round, the second invocation merges with the first
261
- - **User-QA construction (downstream)**: this skill emits `findings[]` only it does NOT construct user_qa items. `/cbp-round-end` Step 3b reads `round.context.frontend_ui_review.findings[]` and aggregates baseline-regression + rendered-visual-critical entries into the round's `user_qa[]`. Single source of truth for the user_qa schema lives at round-end.
261
+ - **Downstream gate**: this skill emits `findings[]` only. Baseline-regression findings surface as a BLOCKING gate at `/cbp-round-end` Step 7 (baselines never auto-accepted); rendered-visual critical findings are surfaced in the Step 7 findings presentation.
262
262
  - **Paired with**: `frontend-design` (pre-implementation aesthetic decision), `frontend-ux` (interaction-quality self-review, also Step 3.8)
@@ -55,46 +55,26 @@ Build the files list with approval status:
55
55
  **claude_approved**: `true` if cbp-testing-qa-agent passed for this file. `false` if issues remain.
56
56
  **user_approved**: Always `false` initially. User approves via git staging or web UI.
57
57
 
58
- ### Step 3: Collect and Aggregate QA Results
58
+ ### Step 3: Collect QA Results
59
59
 
60
60
  **No QA runs here** — all QA was already executed by per-wave `cbp-testing-qa-agent` inside `/cbp-round-execute` Step 5.
61
61
 
62
- This step is the **canonical user_qa[] construction site**. cbp-testing-qa-agent and cbp-test-e2e-agent are fully independent producers (no cross-read); cross-source user_qa items that combine signals from multiple producers are built here, at the user-facing boundary. Single-source user_qa items (derivable from one producer's data alone) stay with that producer.
62
+ #### 3a Collect items from agent outputs
63
63
 
64
- #### 3a — Collect single-source items from agent outputs
65
-
66
- Collect from round context (all three sources are agent-derived):
64
+ Collect from round context:
67
65
 
68
66
  - **Auto items**: from `testing_qa_output.auto_qa.items`
69
- - **User items (single-source)**: from `testing_qa_output.user_qa.items` (Phase 4b.1 design-source comparison + Phase 4b.2 mechanical-sweep spot-check + Phase 4b.0 connection smoke — all derivable from cbp-testing-qa-agent's own data)
70
67
  - **Default items**: from `testing_qa_output.default_checklist.items`
71
68
 
72
- #### 3b Construct cross-source user_qa from frontend_ui_review (NEW)
73
-
74
- Read `round.context.frontend_ui_review.findings[]` (populated by the `cbp-frontend-ui` skill at `/cbp-round-execute` Step 5b under `phase: 'screenshot_review'`). For each finding, emit a `user_qa` item per the rules below.
75
-
76
- | Finding `category` | Finding `severity` | Emitted user_qa item |
77
- |--------------------|-------------------|---------------------|
78
- | `baseline_regression` | any | `{ type: 'user', check: 'Visual baseline regression — {page_or_screen}', status: 'pending', round_number: N, instructions: 'Open the diff PNG at `{screenshot}` (the `-diff.png` sibling shows pixel differences). Pixel-diff was `{baseline_diff_pct}%`. Decide: (a) regression — add a task to fix, OR (b) new rendering is correct — run `pnpm exec playwright test --update-snapshots` in `apps/{app}` and commit the updated baselines. Do NOT proceed until a decision is recorded.' }` |
79
- | `rendered_visual` | `critical` | `{ type: 'user', check: 'Rendered-visual critical — {page_or_screen}', status: 'pending', round_number: N, instructions: 'Open the screenshot at `{screenshot}`. The cbp-frontend-ui review flagged a critical rendering issue: `{finding.issue}`. Suggested fix: `{finding.suggestion}`. Decide whether this needs a fix-round before proceeding.' }` |
80
- | `rendered_visual` | `warning` | (no user_qa item; finding stays in `frontend_ui_review.findings` and surfaces via Step 7 findings presentation if relevant) |
81
- | Other categories | any | (no user_qa item from this step) |
82
-
83
- Skip Step 3b entirely when `round.context.frontend_ui_review` is absent (no e2e ran, or screenshot-review phase didn't execute).
84
-
85
- This is the required user gate for baseline updates — baselines are NEVER auto-accepted.
86
-
87
- #### 3c — Merge
88
-
89
- Combine the single-source items (3a) and cross-source items (3b) into a single `user_qa[]` for the round. Merge with previous rounds (supersede items for re-modified files, preserve verified items).
69
+ Merge with previous rounds (supersede items for re-modified files, preserve verified items).
90
70
 
91
71
  ### Step 4: Update Task Files and QA
92
72
 
93
73
  Update via MCP:
94
74
 
95
75
  - `update_task(task_id, files_changed: [...])` — merge with existing
96
- - `update_round(round_id, files_changed: [...], qa: {items: [...]})` — round-specific
97
- - `update_task(task_id, qa: {items: [...]})` — aggregated
76
+ - `update_round(round_id, files_changed: [...], qa: {items: [auto_qa items + default_checklist items]})` — round-specific
77
+ - `update_task(task_id, qa: {items: [auto_qa items + default_checklist items]})` — aggregated
98
78
 
99
79
  ### Step 5: Present Summary
100
80
 
@@ -134,6 +114,16 @@ Wait for agent to complete. If the spawn fails for any reason, apply the inline-
134
114
 
135
115
  ### Step 7: Present Findings
136
116
 
117
+ **Baseline-regression blocking gate**: before presenting code-review findings, check `round.context.frontend_ui_review.findings[]` for any entry with `category: 'baseline_regression'` (any severity). When one or more such findings exist, surface them as a BLOCKING decision that MUST be resolved before routing to `/cbp-round-update`:
118
+
119
+ - Present each regression: screenshot path, `baseline_diff_pct`, affected page/screen.
120
+ - Ask the user via AskUserQuestion to choose:
121
+ - **(a) Treat as regression** — add a fix-round to address the visual change, OR
122
+ - **(b) Accept the new baseline** — run `pnpm exec playwright test --update-snapshots` in `apps/{app}` and commit the updated baselines.
123
+ - Do NOT route to `/cbp-round-update` until the decision is recorded. Baselines are NEVER auto-accepted.
124
+
125
+ `rendered_visual` critical findings from `round.context.frontend_ui_review.findings[]` are surfaced in the normal findings presentation below (not as a separate gate).
126
+
137
127
  **If `status: 'no_findings'`:** show `### Code Review\nNo issues found. Code looks good.` and skip to Step 8.
138
128
 
139
129
  **If findings exist**, present them grouped by severity (table + per-finding details), then ask the user via AskUserQuestion which to fix: `all`, `1,2` (specific numbers), `none`, or `inline` (only when all findings qualify under the Trivial-Resolution Exception).
@@ -149,13 +149,13 @@ On pass, synthesise `testing_qa_output` inline per the procedure in `reference/i
149
149
 
150
150
  Input contracts: `cbp-testing-qa-agent` receives `executor_output`, `testing_profile`, `has_ui_work` (see `agents/cbp-testing-qa-agent.md` Input Contract). `cbp-test-e2e-agent` receives `repo_id`, `round_number`, `files_changed`, `prior_round_files_changed` (full task aggregate when round_number ≥ 2), `whole_checkpoint_mode: false`, `test_strategy`, `pages_affected`, `has_auth`, `dev_server_port` (see `agents/cbp-test-e2e-agent.md` Input Contract for the full shape).
151
151
 
152
- **Independence**: neither agent reads the other's output. Cross-source user_qa items (cbp-frontend-ui + agent data) are constructed downstream at `/cbp-round-end` Step 3b. Per-wave spawns MAY run in parallel with the next wave's executor when dependency order allows.
152
+ **Independence**: neither agent reads the other's output. Baseline-regression findings surface as a BLOCKING gate at `/cbp-round-end` Step 7 (an explicit accept-or-fix user decision; baselines are NEVER auto-accepted). Per-wave spawns MAY run in parallel with the next wave's executor when dependency order allows.
153
153
 
154
154
  ### Step 5b: Post-E2E Screenshot Review (cbp-frontend-ui Phase 6.5)
155
155
 
156
156
  When `round.context.e2e_output.screenshots[]` is non-empty, invoke the `cbp-frontend-ui` skill with `phase: 'screenshot_review'` (input: `files_changed`, `e2e_screenshots: round.context.e2e_output.screenshots`, `context: { checkpoint_goal, round_requirements }`). Under this phase the skill runs only Phase 6.5 (Rendered-Output Visual Review) + 7 + 8 — Phases 1-6 (style) already ran inline at executor Step 3.8 with `phase: 'style_only'`.
157
157
 
158
- Persist findings to `round.context.frontend_ui_review` (merge with Step 3.8's style-only output if present). `/cbp-round-end` Step 3b emits user_qa items for any `category: 'baseline_regression'` (any severity) and any `category: 'rendered_visual' + severity: 'critical'` neither auto-fails the round. cbp-testing-qa-agent does NOT read these findings (full independence per Step 5).
158
+ Persist findings to `round.context.frontend_ui_review` (merge with Step 3.8's style-only output if present). Baseline-regression findings surface as a BLOCKING gate at `/cbp-round-end` Step 7 (an explicit accept-or-fix user decision; baselines are NEVER auto-accepted); rendered_visual critical findings are surfaced in the Step 7 findings presentation. Neither auto-fails the round. cbp-testing-qa-agent does NOT read these findings (full independence per Step 5).
159
159
 
160
160
  **Skip** when `round.context.e2e_output` is absent, `screenshots` is empty, or `testing_profile === 'claude_only'`.
161
161