@seanyao/roll 2026.528.1 → 2026.528.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## v2026.528.2
4
+
5
+ ### Added
6
+
7
+ - **loop 换机器跑不会再拿过期 backlog** — 每轮自动拉最新项目元数据 `[loop]`
8
+ - **CI 红了 loop 不再干等** — 先试着自己修,修不好再找人 `[loop]`
9
+
10
+ ### Fixed
11
+
12
+ - **`roll loop log` 现在真的能看了** — 每轮 cycle 的留档修好了 `[loop]`
13
+ - **loop 跑完的终端窗口不再瞬间清空** — 关闭前能看到本轮摘要 `[loop]`
14
+
3
15
  ## v2026.528.1
4
16
 
5
17
  ### Added
package/bin/roll CHANGED
@@ -4,7 +4,7 @@ set -euo pipefail
4
4
  # Roll — AI Agent Convention Manager
5
5
  # Single source of truth for how all AI coding agents behave.
6
6
 
7
- VERSION="2026.528.1"
7
+ VERSION="2026.528.2"
8
8
  ROLL_HOME="${ROLL_HOME:-${HOME}/.roll}"
9
9
  ROLL_CONFIG="${ROLL_HOME}/config.yaml"
10
10
  ROLL_GLOBAL="${ROLL_HOME}/conventions/global"
@@ -747,6 +747,22 @@ _setup_snapshot() {
747
747
  # _ROLL_SETUP_STATE. Caller passes the watch dir(s) plus the command + args.
748
748
  # stdout/stderr of the inner command are suppressed (same as the previous
749
749
  # pattern in cmd_setup) to keep the v2 UI render the only user-visible output.
750
+ # US-INFRA-008: ensure core.hooksPath is set to 'hooks' so TCR pre-commit gate
751
+ # is never silently bypassed in new clones, worktrees, or automated environments.
752
+ # Idempotent: already set to a non-default value → leave it (user knows better).
753
+ # Not a git repo → silently skip.
754
+ _ensure_hooks_path() {
755
+ local repo_path="${1:-$PWD}"
756
+ # Must be a git repo
757
+ git -C "$repo_path" rev-parse --git-dir >/dev/null 2>&1 || return 0
758
+ local current; current=$(git -C "$repo_path" config core.hooksPath 2>/dev/null || echo "")
759
+ # Only set when unset or pointing at the git default (.git/hooks)
760
+ if [[ -z "$current" || "$current" == ".git/hooks" ]]; then
761
+ git -C "$repo_path" config core.hooksPath hooks 2>/dev/null || true
762
+ fi
763
+ return 0
764
+ }
765
+
750
766
  _run_setup_step() {
751
767
  local watch="$1"; shift
752
768
  local before after
@@ -803,6 +819,10 @@ cmd_setup() {
803
819
  _run_setup_step "$ROLL_HOME/.peer-state" _peer_ensure_state_dir
804
820
  _record "$(_state_to_marker "$_ROLL_SETUP_STATE")" "Initialize peer-review state directory"
805
821
 
822
+ # US-INFRA-008: configure git hooks path so TCR pre-commit gate works in this repo
823
+ _run_setup_step "$PWD" _ensure_hooks_path
824
+ _record "$(_state_to_marker "$_ROLL_SETUP_STATE")" "Configure git hooks path"
825
+
806
826
  if command -v tmux >/dev/null 2>&1; then
807
827
  _record skip "Ensure tmux is installed (already present)"
808
828
  else
@@ -2719,7 +2739,7 @@ _peer_dispatch_in_tmux() {
2719
2739
  {
2720
2740
  printf '#!/bin/bash -l\n'
2721
2741
  # FIX-050: portable PATH assembly (was hardcoded /opt/homebrew/bin)
2722
- printf 'for _d in /opt/homebrew/bin /usr/local/bin /opt/local/bin "$HOME/.local/bin"; do\n'
2742
+ printf 'for _d in /opt/homebrew/bin /usr/local/bin /opt/local/bin "$HOME/.local/bin" "$HOME/.kimi-code/bin"; do\n'
2723
2743
  printf ' case ":$PATH:" in *":$_d:"*) ;; *) [ -d "$_d" ] && PATH="$_d:$PATH" ;; esac\n'
2724
2744
  printf 'done; export PATH\n'
2725
2745
  printf '%s > %q 2> %q || true\n' "$cmd_str" "$out_file" "$err_file"
@@ -5563,6 +5583,8 @@ _detect_path_prepend() {
5563
5583
  [[ -d /usr/local/bin ]] && dirs+=("/usr/local/bin")
5564
5584
  [[ -d /opt/local/bin ]] && dirs+=("/opt/local/bin")
5565
5585
  [[ -d "$HOME/.local/bin" ]] && dirs+=("$HOME/.local/bin")
5586
+ # FIX-129: kimi-code installs to ~/.kimi-code/bin (not brew/local), launchd misses it
5587
+ [[ -d "$HOME/.kimi-code/bin" ]] && dirs+=("$HOME/.kimi-code/bin")
5566
5588
  dirs+=("/usr/bin" "/bin" "/usr/sbin" "/sbin")
5567
5589
  for d in "${dirs[@]}"; do
5568
5590
  case ":$seen:" in *":$d:"*) continue ;; esac
@@ -5728,7 +5750,7 @@ set -o pipefail
5728
5750
  # FIX-050: portable PATH assembly — launchd/cron deliver a bare PATH that
5729
5751
  # misses brew-installed tools (tmux, claude, node, …). Iterate candidate
5730
5752
  # dirs; only prepend when present and not already in PATH. Idempotent.
5731
- for _d in /opt/homebrew/bin /usr/local/bin /opt/local/bin "\$HOME/.local/bin"; do
5753
+ for _d in /opt/homebrew/bin /usr/local/bin /opt/local/bin "\$HOME/.local/bin" "\$HOME/.kimi-code/bin"; do
5732
5754
  case ":\$PATH:" in *":\$_d:"*) ;; *) [ -d "\$_d" ] && PATH="\$_d:\$PATH" ;; esac
5733
5755
  done
5734
5756
  export PATH
@@ -6061,6 +6083,10 @@ _phase_begin startup
6061
6083
  _phase_end startup ok
6062
6084
  _phase_begin preflight
6063
6085
  cd "${project_path}" 2>/dev/null || true
6086
+ # US-INFRA-008: ensure git hooks are wired so TCR pre-commit gate can't be bypassed
6087
+ _ensure_hooks_path "${project_path}" 2>/dev/null || true
6088
+ # US-LOOP-056: sync .roll/ meta from roll-meta remote before backlog scan
6089
+ _loop_sync_meta "${project_path}" || true
6064
6090
  # FIX-104: GC stale merged temp branches at cycle entry — before worktree setup
6065
6091
  # and before any early-exit gate (pre-run abort, CI red precheck). The post-claude
6066
6092
  # call site doesn't cover those paths, so merged branches accumulated on origin.
@@ -6372,7 +6398,7 @@ INNER
6372
6398
  # FIX-050: portable PATH assembly before any brew-tool lookup (tmux, caffeinate
6373
6399
  # on some systems, claude). Mirrors the inner script's bootstrap so even when
6374
6400
  # launchd's plist EnvironmentVariables is stale, the runner self-repairs.
6375
- for _d in /opt/homebrew/bin /usr/local/bin /opt/local/bin "\$HOME/.local/bin"; do
6401
+ for _d in /opt/homebrew/bin /usr/local/bin /opt/local/bin "\$HOME/.local/bin" "\$HOME/.kimi-code/bin"; do
6376
6402
  case ":\$PATH:" in *":\$_d:"*) ;; *) [ -d "\$_d" ] && PATH="\$_d:\$PATH" ;; esac
6377
6403
  done
6378
6404
  export PATH
@@ -6459,10 +6485,24 @@ if command -v tmux >/dev/null 2>&1; then
6459
6485
  tmux list-sessions -F "#{session_name}" 2>/dev/null | grep "^roll-loop-${slug}\$" | while read _s; do
6460
6486
  tmux kill-session -t "\$_s" 2>/dev/null || true
6461
6487
  done
6462
- tmux new-session -d -s "\$SESSION" -x 200 -y 50 "bash \"\$INNER_SCRIPT\""
6463
- CYCLE_LOG_RAW="${project_path}/.roll/cycle-logs/.pipe-\$\$.raw"
6488
+ # FIX-132: syntax-check the inner script before spawning the tmux session.
6489
+ # A heredoc quoting regression or mid-cycle regeneration can silently produce
6490
+ # a syntactically broken script; catching it here prevents the session from
6491
+ # starting in a corrupted state and logging a misleading "exited 0, retrying".
6492
+ if ! bash -n "\$INNER_SCRIPT" 2>>"\$LOG"; then
6493
+ echo "[\$(date '+%Y-%m-%dT%H:%M:%S%z')] ABORT: inner script failed syntax check — cycle skipped (see log: \$LOG)" >> "\$LOG"
6494
+ exit 1
6495
+ fi
6496
+ # FIX-130: export ROLL_CYCLE_LOG_RAW BEFORE spawning the tmux session so
6497
+ # the inner script inherits it (env vars are inherited at spawn time, not
6498
+ # retroactively — exporting after new-session means inner never sees it and
6499
+ # _inner_cleanup skips log archiving, leaving only orphan .pipe-*.raw files).
6464
6500
  mkdir -p "${project_path}/.roll/cycle-logs"
6501
+ # Clean orphan .pipe-*.raw files from previous crashed cycles
6502
+ find "${project_path}/.roll/cycle-logs" -name '.pipe-*.raw' -delete 2>/dev/null || true
6503
+ CYCLE_LOG_RAW="${project_path}/.roll/cycle-logs/.pipe-\$\$.raw"
6465
6504
  export ROLL_CYCLE_LOG_RAW="\$CYCLE_LOG_RAW"
6505
+ tmux new-session -d -s "\$SESSION" -x 200 -y 50 "bash \"\$INNER_SCRIPT\""
6466
6506
  tmux pipe-pane -t "\$SESSION" "tee -a \"\$LOG\" >> \"\$ROLL_CYCLE_LOG_RAW\""
6467
6507
  # Auto-attach popup: when not muted, spawn a Terminal.app window attached
6468
6508
  # to the tmux session so the user can watch the loop work in real time.
@@ -6481,7 +6521,9 @@ if command -v tmux >/dev/null 2>&1; then
6481
6521
  # window closes the instant the tmux session ends (cycle_end kills
6482
6522
  # the session) and the entire scrollback disappears with it; the
6483
6523
  # cron-<slug>.log file still has the full transcript as a fallback.
6484
- printf '#!/bin/bash\\ntmux attach -t %s\\necho\\necho "================================================================"\\necho " cycle ended. log: ~/.shared/roll/loop/cron-%s.log"\\necho " press enter to close this window."\\necho "================================================================"\\nread _\\n' \\
6524
+ # FIX-131: after tmux session ends, open the cron log with less so the
6525
+ # user can scroll through the full cycle output instead of seeing nothing.
6526
+ printf '#!/bin/bash\\ntmux attach -t %s 2>/dev/null\\nLOGFILE=~/.shared/roll/loop/cron-%s.log\\necho\\nif [ -f "\$LOGFILE" ]; then\\n echo "================================================================"\\n echo " Cycle ended — showing log (arrows to scroll, q to close)"\\n echo "================================================================"\\n less -R +G "\$LOGFILE"\\nelse\\n echo "================================================================"\\n echo " Cycle ended. Log not found: \$LOGFILE"\\n echo " press enter to close."\\n echo "================================================================"\\n read _\\nfi\\n' \\
6485
6527
  "\$SESSION" "${slug}" > "\$_attach_cmd" 2>/dev/null || true
6486
6528
  chmod +x "\$_attach_cmd" 2>/dev/null || true
6487
6529
  open -g -a Terminal "\$_attach_cmd" >/dev/null 2>&1 || true
@@ -6710,10 +6752,11 @@ cmd_loop() {
6710
6752
  resume) _loop_resume ;;
6711
6753
  reset) _loop_reset ;;
6712
6754
  gc) shift; _loop_gc "$@" ;;
6713
- notify) _notify "${1:-roll}" "${2:-}" ;;
6714
- enforce-tcr) _loop_enforce_tcr "${1:-}" "${2:-}" ;;
6715
- precheck-ci) _loop_precheck_ci ;;
6716
- branches) _loop_branches "$(pwd -P)" ;;
6755
+ notify) _notify "${1:-roll}" "${2:-}" ;;
6756
+ enforce-tcr) _loop_enforce_tcr "${1:-}" "${2:-}" ;;
6757
+ precheck-ci) _loop_precheck_ci ;;
6758
+ hotfix-head-context) _loop_hotfix_head_context "${1:-}" ;;
6759
+ branches) _loop_branches "$(pwd -P)" ;;
6717
6760
  *) cat <<'HELP'
6718
6761
  Usage: roll loop <on|off|now|test|status|monitor|runs|log|story|events|attach|mute|unmute|pause|resume|reset|gc|branches>
6719
6762
 
@@ -7741,6 +7784,66 @@ _ci_wait() {
7741
7784
  }
7742
7785
 
7743
7786
  # Pre-run CI health check — call before picking up new stories.
7787
+ # US-LOOP-056: sync .roll/ (roll-meta private submodule) before each cycle so
7788
+ # the cycle always runs against the latest backlog. Fail-soft: any error emits
7789
+ # a meta_sync event and returns 0 so the cycle continues with stale/existing meta.
7790
+ #
7791
+ # Statuses emitted via _loop_event meta_sync:
7792
+ # ok – fetch + reset --hard succeeded
7793
+ # stale – fetch failed; existing .roll/ used as fallback
7794
+ # skipped – no git remote configured (not a roll-meta managed project)
7795
+ #
7796
+ # Env override: ROLL_LOOP_META_SYNC_TIMEOUT (default 15) controls fetch timeout.
7797
+ _loop_sync_meta() {
7798
+ local project_path="$1"
7799
+ local roll_meta="${project_path}/.roll"
7800
+ local timeout_sec="${ROLL_LOOP_META_SYNC_TIMEOUT:-15}"
7801
+ local cid="${CYCLE_ID:-unknown}"
7802
+ local slug="${_LOOP_PROJ_SLUG:-$(_project_slug 2>/dev/null || echo unknown)}"
7803
+ local shared_dir="${_SHARED_ROOT:-$HOME/.shared/roll}/loop"
7804
+ local fail_counter="${shared_dir}/meta-sync-fail-${slug}"
7805
+
7806
+ # Detect remote via the canonical probe point. If .roll/ has no .git or no
7807
+ # remote configured, treat as "not managed" and skip silently.
7808
+ local remote_url
7809
+ remote_url=$(git -C "$roll_meta" remote get-url origin 2>/dev/null || echo "")
7810
+ if [ -z "$remote_url" ]; then
7811
+ return 0
7812
+ fi
7813
+
7814
+ # Attempt fetch with timeout
7815
+ local _fetch_ok=0
7816
+ if command -v timeout >/dev/null 2>&1; then
7817
+ timeout "$timeout_sec" git -C "$roll_meta" fetch --quiet 2>/dev/null && _fetch_ok=1
7818
+ else
7819
+ git -C "$roll_meta" fetch --quiet 2>/dev/null && _fetch_ok=1
7820
+ fi
7821
+
7822
+ if [ "$_fetch_ok" -eq 1 ]; then
7823
+ if git -C "$roll_meta" reset --hard origin/main --quiet 2>/dev/null; then
7824
+ _loop_event meta_sync "$cid" "ok" "" 2>/dev/null || true
7825
+ # US-LOOP-057: reset consecutive failure counter on success
7826
+ rm -f "$fail_counter" 2>/dev/null || true
7827
+ return 0
7828
+ fi
7829
+ fi
7830
+
7831
+ # Fetch or reset failed — stale .roll/ used; cycle continues
7832
+ _loop_event meta_sync "$cid" "stale" "fetch/reset failed" 2>/dev/null || true
7833
+
7834
+ # US-LOOP-057: increment failure counter; write ALERT after 3 consecutive failures
7835
+ mkdir -p "$shared_dir" 2>/dev/null || true
7836
+ local count=0
7837
+ [ -f "$fail_counter" ] && count=$(cat "$fail_counter" 2>/dev/null || echo 0)
7838
+ count=$(( count + 1 ))
7839
+ printf '%s\n' "$count" > "$fail_counter"
7840
+ if [ "$count" -ge 3 ]; then
7841
+ printf '[%s] roll-meta sync consecutive failures: %d times. Check SSH key / network.\n Last error: fetch/reset failed for %s\n' \
7842
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$count" "$remote_url" >> "${shared_dir}/ALERT-${slug}.md" 2>/dev/null || true
7843
+ fi
7844
+ return 0
7845
+ }
7846
+
7744
7847
  # Refuses to build on a red base (HEAD CI failed). Lenient on unknown states
7745
7848
  # (gh missing, repo unparseable, no runs yet) — the post-build _loop_enforce_ci
7746
7849
  # is the strict gate.
@@ -7773,6 +7876,38 @@ _loop_precheck_ci() {
7773
7876
  run_states=$(echo "$runs" \
7774
7877
  | jq -r '[.[] | "\(.status // "?")/\(.conclusion // "null")"] | unique | join(", ")' \
7775
7878
  2>/dev/null || echo "?")
7879
+
7880
+ # US-LOOP-046/048: check whether hot-fix path is allowed before aborting.
7881
+ # ROLL_LOOP_NO_HEAL=1 or ROLL_LOOP_HEAL_MAX=0 → fall through to original abort.
7882
+ local _heal_max="${ROLL_LOOP_HEAL_MAX:-2}"
7883
+ if [[ "${ROLL_LOOP_NO_HEAL:-}" != "1" ]] && [[ "$_heal_max" -gt 0 ]]; then
7884
+ local _state_file="${_SHARED_ROOT:-$HOME/.shared/roll}/loop/state-${_LOOP_PROJ_SLUG:-$(basename "$PWD")}.yaml"
7885
+ local _heal_key="heal_count_head_${commit:0:8}"
7886
+ local _count=0
7887
+ [[ -f "$_state_file" ]] && _count=$(grep "^${_heal_key}:" "$_state_file" 2>/dev/null | awk '{print $2}' || echo 0)
7888
+ _count=$(( ${_count:-0} + 0 )) # coerce to int
7889
+ if [[ "$_count" -lt "$_heal_max" ]]; then
7890
+ # Increment counter and signal hot-fix path to the agent
7891
+ _count=$(( _count + 1 ))
7892
+ mkdir -p "$(dirname "$_state_file")" 2>/dev/null || true
7893
+ if [[ -f "$_state_file" ]]; then
7894
+ # Update existing key or append
7895
+ if grep -q "^${_heal_key}:" "$_state_file" 2>/dev/null; then
7896
+ local _tmp; _tmp=$(mktemp)
7897
+ grep -v "^${_heal_key}:" "$_state_file" > "$_tmp" 2>/dev/null || true
7898
+ printf '%s: %d\n' "$_heal_key" "$_count" >> "$_tmp"
7899
+ mv "$_tmp" "$_state_file"
7900
+ else
7901
+ printf '%s: %d\n' "$_heal_key" "$_count" >> "$_state_file"
7902
+ fi
7903
+ else
7904
+ printf '%s: %d\n' "$_heal_key" "$_count" > "$_state_file"
7905
+ fi
7906
+ # Exit 2 signals the agent: CI is red, hot-fix path is available
7907
+ return 2
7908
+ fi
7909
+ fi
7910
+
7776
7911
  err "$(msg loop.pre_run_ci_check_head_ci ${short})"
7777
7912
  mkdir -p "$(dirname "$_LOOP_ALERT")"
7778
7913
  cat > "$_LOOP_ALERT" << EOF
@@ -7795,6 +7930,62 @@ EOF
7795
7930
  return 0
7796
7931
  }
7797
7932
 
7933
+ # US-LOOP-047: hot-fix context factory for HEAD CI failures.
7934
+ # Captures failing run logs + recent commit diff, writes to /tmp/roll-heal-head-<sha>.log
7935
+ # Returns 0 and prints the log path on success; 1 if context could not be gathered.
7936
+ _loop_hotfix_head_context() {
7937
+ local commit="${1:-$(git rev-parse HEAD 2>/dev/null)}"
7938
+ [[ -z "$commit" ]] && return 1
7939
+ local short="${commit:0:8}"
7940
+ local outfile="/tmp/roll-heal-head-${short}.log"
7941
+ local slug; _gh_resolve slug || slug="unknown"
7942
+
7943
+ {
7944
+ printf '=== CI Hot-fix Context: HEAD %s ===\n\n' "$short"
7945
+ printf '--- Recent commits ---\n'
7946
+ git log --oneline -5 2>/dev/null || true
7947
+ printf '\n--- Diff of last commit ---\n'
7948
+ git show --stat HEAD 2>/dev/null | head -40 || true
7949
+ printf '\n--- CI failure logs (head 200 lines) ---\n'
7950
+ local run_id
7951
+ run_id=$(gh -R "$slug" run list --commit "$commit" \
7952
+ --json databaseId,conclusion -L 5 2>/dev/null \
7953
+ | jq -r '.[] | select(.conclusion=="failure") | .databaseId' 2>/dev/null | head -1)
7954
+ if [[ -n "$run_id" ]]; then
7955
+ gh -R "$slug" run view --log-failed "$run_id" 2>/dev/null | head -200 || true
7956
+ else
7957
+ printf '(no failed run found for commit %s)\n' "$short"
7958
+ fi
7959
+ } > "$outfile" 2>/dev/null
7960
+ printf '%s\n' "$outfile"
7961
+ return 0
7962
+ }
7963
+
7964
+ # US-LOOP-050: PR hot-fix entry point.
7965
+ # Checks out the PR branch, captures CI failure logs, and prepares context
7966
+ # for a roll-fix invocation on the PR branch.
7967
+ # Usage: _loop_hot_fix_pr <pr_number>
7968
+ _loop_hot_fix_pr() {
7969
+ local pr_num="$1"
7970
+ [[ -z "$pr_num" ]] && return 1
7971
+ local slug; _gh_resolve slug || return 1
7972
+ local outfile="/tmp/roll-heal-pr-${pr_num}.log"
7973
+ local run_id
7974
+ run_id=$(gh -R "$slug" run list --json databaseId,conclusion,headBranch -L 20 2>/dev/null \
7975
+ | jq -r --argjson pr "\"$pr_num\"" \
7976
+ '.[] | select(.conclusion=="failure") | .databaseId' 2>/dev/null | head -1)
7977
+ {
7978
+ printf '=== PR #%s CI Hot-fix Context ===\n\n' "$pr_num"
7979
+ if [[ -n "$run_id" ]]; then
7980
+ gh -R "$slug" run view --log-failed "$run_id" 2>/dev/null | head -200 || true
7981
+ else
7982
+ printf '(no failed run found for PR #%s)\n' "$pr_num"
7983
+ fi
7984
+ } > "$outfile" 2>/dev/null
7985
+ printf '%s\n' "$outfile"
7986
+ return 0
7987
+ }
7988
+
7798
7989
  # _loop_diagnose_open_prs <slug>
7799
7990
  # Appended to ALERT when CI is red on HEAD.
7800
7991
  # For each open PR targeting main: lists CI failing tests + changed files,
@@ -8029,7 +8220,14 @@ _loop_pr_classify() {
8029
8220
  local mergeable="${4:-}"
8030
8221
 
8031
8222
  case "$head_ref" in
8032
- loop/*) echo "loop_self"; return 0 ;;
8223
+ loop/*)
8224
+ # US-LOOP-049: loop/* PRs with CI failure get their own classification
8225
+ # so _loop_pr_inbox can route them to the PR hot-fix path.
8226
+ if [[ "$ci_state" == "failure" ]]; then
8227
+ echo "loop_self_ci_red"; return 0
8228
+ fi
8229
+ echo "loop_self"; return 0
8230
+ ;;
8033
8231
  esac
8034
8232
 
8035
8233
  case "$human_review" in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanyao/roll",
3
- "version": "2026.528.1",
3
+ "version": "2026.528.2",
4
4
  "description": "Roll — Roll out features with AI agents",
5
5
  "scripts": {
6
6
  "test": "bash tests/run.sh"
@@ -125,16 +125,26 @@ readers. The rule mirrors the gate in Step 2.
125
125
  ### Step 1.5 — Pre-run CI Health Check
126
126
 
127
127
  Call `roll loop precheck-ci` before scanning BACKLOG. This is a **defensive gate**
128
- against building on a broken base if the most recent commit on the branch
129
- has red CI, the loop must not stack new commits on top (which would create the
130
- exact stuck-red state FIX-026 traces to).
131
-
132
- - HEAD CI green / pending / no-run-yet proceed to Step 2.
133
- - HEAD CI red write ALERT, **do not pick up any stories this cycle**,
134
- exit cleanly. The next cycle will retry; the human must fix CI manually
135
- (typically by reverting or pushing a green commit) before the loop resumes.
136
- - `gh` missing or repo unparseable → graceful skip (`roll loop precheck-ci`
137
- returns 0); the post-build `_loop_enforce_ci` remains the strict gate.
128
+ against building on a broken base. Check the **exit code** and route accordingly:
129
+
130
+ | Exit code | Meaning | Action |
131
+ |-----------|---------|--------|
132
+ | `0` | CI green / pending / unknown | Proceed to Step 1.6 (PR Inbox) and Step 2 (BACKLOG scan) |
133
+ | `1` | CI red AND heal exhausted or `ROLL_LOOP_NO_HEAL=1` | ALERT already written; exit cleanly this cycle |
134
+ | `2` | CI red AND heal attempt allowed (US-LOOP-046) | **Hot-fix path** — skip BACKLOG, fix CI instead (see below) |
135
+
136
+ `gh` missing or repo unparseable → `precheck-ci` returns `0`; graceful skip.
137
+
138
+ **Hot-fix path (exit code 2) — US-LOOP-046:**
139
+
140
+ Do NOT pick any BACKLOG stories this cycle. Instead:
141
+
142
+ 1. Capture context: `roll loop hotfix-head-context` → prints path to context log
143
+ 2. Invoke `Skill("roll-fix")` with brief:
144
+ `"CI red on HEAD. Failing run logs at <context-log-path>. Diagnose root cause, fix via TCR, commit, push. Do NOT change BACKLOG status."`
145
+ 3. After `roll-fix` completes, re-run `roll ci --wait` to verify the fix
146
+ 4. If CI is still red: run `roll loop precheck-ci` again; if it returns `1` (heal exhausted),
147
+ exit cleanly — ALERT was already written by the precheck
138
148
 
139
149
  ### Step 1.6 — PR Inbox (US-AUTO-034)
140
150