@@ -341,6 +341,64 @@ ai_review::timeout_prefix() {
341341 # are the remaining backstops.
342342}
343343
344+ # ── Live progress streaming (local interactive runs only) ───────────────────
345+ # By default `claude -p` is captured into a variable for parsing, so the user
346+ # sees nothing — a blinking cursor — until the whole run finishes. On a local
347+ # interactive run we instead ask claude for its event stream
348+ # (--output-format stream-json --verbose), narrate each step to STDERR (the
349+ # terminal), and emit ONLY the final result text to STDOUT so the dispatcher's
350+ # marker/JSON parse is byte-identical to the plain `-p` path.
351+ #
352+ # Gated so CI is unaffected: stream only when stdout is a TTY, not CI, the user
353+ # hasn't opted out (AI_REVIEW_STREAM=0), and python3 is present to split the
354+ # stream. Otherwise fall back to plain `-p`.
355+ ai_review::should_stream () {
356+ [[ " ${AI_REVIEW_STREAM:- 1} " != " 0" ]] || return 1
357+ [[ -t 1 ]] || return 1
358+ [[ " ${CI:- } " != " true" ]] || return 1
359+ command -v python3 & > /dev/null || return 1
360+ return 0
361+ }
362+
363+ # Reads claude stream-json (NDJSON) on stdin. Narrates assistant text + tool
364+ # calls to STDERR; prints the final result text to STDOUT. Keeps the contract:
365+ # STDOUT carries exactly the agent's final answer (markers + JSON intact).
366+ ai_review::stream_split () {
367+ python3 -c '
368+ import sys, json
369+ def w(s): # progress → stderr, flushed so it appears live
370+ sys.stderr.write(s + "\n"); sys.stderr.flush()
371+ final = ""
372+ for line in sys.stdin:
373+ line = line.strip()
374+ if not line:
375+ continue
376+ try:
377+ e = json.loads(line)
378+ except Exception:
379+ continue
380+ t = e.get("type")
381+ if t == "assistant":
382+ for blk in e.get("message", {}).get("content", []):
383+ bt = blk.get("type")
384+ if bt == "text":
385+ txt = (blk.get("text") or "").strip()
386+ if txt:
387+ w(" ⏺ " + txt)
388+ elif bt == "tool_use":
389+ inp = blk.get("input", {}) or {}
390+ arg = inp.get("command") or inp.get("file_path") or inp.get("pattern") or inp.get("description") or ""
391+ arg = str(arg).replace("\n", " ")
392+ if len(arg) > 80:
393+ arg = arg[:77] + "..."
394+ w(" ⏎ " + str(blk.get("name")) + (" " + arg if arg else ""))
395+ elif t == "result":
396+ final = e.get("result") or ""
397+ # emit the final answer on stdout for the dispatcher to parse
398+ sys.stdout.write(final)
399+ '
400+ }
401+
344402ai_review::invoke_claude () {
345403 ai_review::require_cli " claude" \
346404 " Install Claude Code: npm install -g @anthropic-ai/claude-code"
@@ -354,21 +412,49 @@ ai_review::invoke_claude() {
354412 # span/metric exporter defaults to `none` (its console form writes to STDOUT
355413 # and would corrupt this parsed result); the run is captured via
356414 # OTEL_LOG_RAW_API_BODIES file output instead. So stdout here stays clean.
415+ # On a local interactive run, stream the agent's steps live (see
416+ # should_stream / stream_split): --output-format stream-json --verbose emits
417+ # NDJSON events that stream_split narrates to STDERR while forwarding only the
418+ # final result text to STDOUT — so the captured stdout (and its marker/JSON
419+ # parse) is identical to the plain `-p` path. CI keeps the silent, clean path.
420+ local stream=0
421+ if ai_review::should_stream; then
422+ stream=1
423+ ai_review::info " Streaming the agent's steps below (set AI_REVIEW_STREAM=0 to silence)…" >&2
424+ fi
425+
357426 if (( AI_RUN_SUITE == 1 )) ; then
358427 # Grant execution so the agent can install deps + run the suite headlessly.
359428 # Headless `claude -p` HANGS on any Bash call without a permission grant, so
360429 # this flag set is required for suite-running, not optional. --allowedTools
361430 # scopes it to exactly what the task needs; --max-turns bounds the loop.
362431 #
363- # NOTE: no 2>&1 here — keep the agent's stderr diagnostics OUT of the
364- # captured stdout so they can't corrupt the JSON/marker parse. stderr still
365- # flows to the CI log.
366- $( ai_review::timeout_prefix) claude -p " ${SKILL_PROMPT} " \
367- --permission-mode bypassPermissions \
368- --allowedTools " Bash,Read,Edit" \
369- --max-turns " ${AI_SUITE_MAX_TURNS} "
432+ # NOTE: no 2>&1 — keep the agent's stderr diagnostics OUT of the captured
433+ # stdout so they can't corrupt the JSON/marker parse. stderr still flows to
434+ # the terminal / CI log.
435+ if (( stream == 1 )) ; then
436+ # < /dev/null: `-p` otherwise waits on stdin (the pipe keeps it open) and
437+ # warns "no stdin data received in 3s". We pass the prompt as an arg, so
438+ # there is no stdin to read.
439+ $( ai_review::timeout_prefix) claude -p " ${SKILL_PROMPT} " \
440+ --permission-mode bypassPermissions \
441+ --allowedTools " Bash,Read,Edit" \
442+ --max-turns " ${AI_SUITE_MAX_TURNS} " \
443+ --output-format stream-json --verbose < /dev/null \
444+ | ai_review::stream_split
445+ else
446+ $( ai_review::timeout_prefix) claude -p " ${SKILL_PROMPT} " \
447+ --permission-mode bypassPermissions \
448+ --allowedTools " Bash,Read,Edit" \
449+ --max-turns " ${AI_SUITE_MAX_TURNS} "
450+ fi
370451 else
371- claude -p " ${SKILL_PROMPT} " 2>&1
452+ if (( stream == 1 )) ; then
453+ claude -p " ${SKILL_PROMPT} " --output-format stream-json --verbose < /dev/null \
454+ | ai_review::stream_split
455+ else
456+ claude -p " ${SKILL_PROMPT} " 2>&1
457+ fi
372458 fi
373459}
374460
0 commit comments