@seanyao/roll 2026.517.2 → 2026.517.4

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/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.517.2"
7
+ VERSION="2026.517.4"
8
8
  ROLL_HOME="${ROLL_HOME:-${HOME}/.roll}"
9
9
  ROLL_CONFIG="${ROLL_HOME}/config.yaml"
10
10
  ROLL_GLOBAL="${ROLL_HOME}/conventions/global"
@@ -872,167 +872,6 @@ cmd_init() {
872
872
  fi
873
873
  }
874
874
 
875
-
876
- # ─── Helper: merge global preamble + template into output ────────────────────
877
- merge_convention() {
878
- local filename="$1"
879
- local tpl_dir="$2"
880
- local out_dir="$3"
881
- local display="${4:-$filename}"
882
- local global_file="$ROLL_GLOBAL/$filename"
883
- local tpl_file="$tpl_dir/$filename"
884
- local out_file="$out_dir/$filename"
885
-
886
- if [[ ! -f "$tpl_file" ]]; then
887
- return
888
- fi
889
-
890
- local merged
891
- merged="$(cat "$global_file" 2>/dev/null || true)"
892
- merged+=$'\n\n'
893
- merged+="---"
894
- merged+=$'\n\n'
895
- merged+="$(cat "$tpl_file")"
896
-
897
- if [[ -f "$out_file" ]]; then
898
- if diff -q <(echo "$merged") "$out_file" &>/dev/null; then
899
- _ROLL_MERGE_SUMMARY+=("unchanged|$display")
900
- return # identical, nothing to do
901
- fi
902
- warn "File exists: $display 文件已存在: $display"
903
- echo -n " [o] Overwrite [k] Keep [M] Merge (default): "
904
- read -r answer
905
- answer="${answer:-M}"
906
- case "$answer" in
907
- o|O)
908
- echo "$merged" > "$out_file"
909
- ok "Overwritten: $display 已覆盖: $display"
910
- _ROLL_MERGE_SUMMARY+=("overwritten|$display")
911
- return
912
- ;;
913
- k|K)
914
- info "Kept: $display 已保留: $display"
915
- _ROLL_MERGE_SUMMARY+=("kept|$display")
916
- return
917
- ;;
918
- m|M|"")
919
- # Merge: for each ## section in template:
920
- # - if missing in output → append
921
- # - if present but content differs → show diff, prompt [u/k]
922
- # - if present and identical → skip
923
-
924
- # Helper: extract a section's body (lines after heading until next ## or EOF)
925
- # Usage: _extract_section "$file" "$heading"
926
- _extract_section() {
927
- local file="$1"
928
- local heading="$2"
929
- local in_section=false
930
- local body=""
931
- while IFS= read -r ln; do
932
- if [[ "$ln" == "$heading" ]]; then
933
- in_section=true
934
- continue
935
- fi
936
- if [[ "$in_section" == "true" ]]; then
937
- if [[ "$ln" =~ ^##\ ]]; then
938
- break
939
- fi
940
- body+="$ln"$'\n'
941
- fi
942
- done < "$file"
943
- printf '%s' "$body"
944
- }
945
-
946
- local current_heading=""
947
- local current_body=""
948
-
949
- # _process_section: check/append/update a completed section
950
- _process_section() {
951
- local heading="$1"
952
- local body="$2"
953
- local out="$3"
954
- if ! grep -qF "$heading" "$out" 2>/dev/null; then
955
- # Section missing → append
956
- printf '\n%s\n%s' "$heading" "$body" >> "$out"
957
- else
958
- # Section exists → compare content
959
- local existing
960
- existing="$(_extract_section "$out" "$heading")"
961
- if [[ "$body" != "$existing" ]]; then
962
- echo ""
963
- warn "Section \"$heading\" exists but content differs:"
964
- diff <(printf '%s' "$existing") <(printf '%s' "$body") || true
965
- echo -n " [u] update with template [k] keep mine (default): "
966
- local sec_ans
967
- read -r sec_ans
968
- sec_ans="${sec_ans:-k}"
969
- if [[ "$sec_ans" == "u" || "$sec_ans" == "U" ]]; then
970
- # Replace existing section with template section
971
- local tmp_out
972
- tmp_out="$(mktemp)"
973
- local skip=false
974
- while IFS= read -r fline; do
975
- if [[ "$fline" == "$heading" ]]; then
976
- skip=true
977
- printf '%s\n%s' "$heading" "$body" >> "$tmp_out"
978
- continue
979
- fi
980
- if [[ "$skip" == "true" ]]; then
981
- if [[ "$fline" =~ ^##\ ]]; then
982
- skip=false
983
- printf '%s\n' "$fline" >> "$tmp_out"
984
- fi
985
- continue
986
- fi
987
- printf '%s\n' "$fline" >> "$tmp_out"
988
- done < "$out"
989
- mv "$tmp_out" "$out"
990
- fi
991
- fi
992
- fi
993
- }
994
-
995
- # Parse template sections, reading from file (not herestring) to keep stdin free
996
- # for interactive user prompts inside _process_section
997
- local tpl_sections=()
998
- local tpl_bodies=()
999
- local cur_h="" cur_b=""
1000
- while IFS= read -r line; do
1001
- if [[ "$line" =~ ^##\ ]]; then
1002
- if [[ -n "$cur_h" ]]; then
1003
- tpl_sections+=("$cur_h")
1004
- tpl_bodies+=("$cur_b")
1005
- fi
1006
- cur_h="$line"
1007
- cur_b=""
1008
- elif [[ -n "$cur_h" ]]; then
1009
- cur_b+="$line"$'\n'
1010
- fi
1011
- done < "$tpl_file"
1012
- # Capture last section
1013
- if [[ -n "$cur_h" ]]; then
1014
- tpl_sections+=("$cur_h")
1015
- tpl_bodies+=("$cur_b")
1016
- fi
1017
-
1018
- # Now process sections (stdin is free for user input)
1019
- local i
1020
- for (( i=0; i<${#tpl_sections[@]}; i++ )); do
1021
- _process_section "${tpl_sections[$i]}" "${tpl_bodies[$i]}" "$out_file"
1022
- done
1023
-
1024
- ok "Merged: $display 已合并: $display"
1025
- _ROLL_MERGE_SUMMARY+=("merged|$display")
1026
- return
1027
- ;;
1028
- esac
1029
- fi
1030
-
1031
- echo "$merged" > "$out_file"
1032
- ok "Created: $display 已创建: $display"
1033
- _ROLL_MERGE_SUMMARY+=("created|$display")
1034
- }
1035
-
1036
875
  # ─── Helper: print a tidy summary of merge actions ───────────────────────────
1037
876
  print_merge_summary() {
1038
877
  if [[ ${#_ROLL_MERGE_SUMMARY[@]} -eq 0 ]]; then
@@ -2073,7 +1912,7 @@ _LAUNCHD_DIR="${HOME}/Library/LaunchAgents"
2073
1912
  # Returns a filesystem-safe slug combining the project basename and a 6-char
2074
1913
  # hash of the full path, ensuring uniqueness across sibling dirs with same name.
2075
1914
  _project_slug() {
2076
- local path="$1"
1915
+ local path="${1:-$(pwd -P 2>/dev/null || pwd)}"
2077
1916
  # FIX-034: when inside a git worktree, git-common-dir returns the main tree's
2078
1917
  # absolute .git path; resolve to the main tree so worktree and main-tree runs
2079
1918
  # produce the same slug.
@@ -2115,6 +1954,55 @@ _loop_derive_minute() {
2115
1954
  echo $(( (hash_dec + offset) % 55 + 1 ))
2116
1955
  }
2117
1956
 
1957
+ # US-LOOP-001: structured event emission for cycle observability.
1958
+ # Writes a tab-separated line to stdout (for tmux/attach display) and appends
1959
+ # a JSON line to the per-project NDJSON event file under _SHARED_ROOT/loop/.
1960
+ # Args: <stage> <label> <detail> <outcome>
1961
+ _loop_event() {
1962
+ local stage="$1" label="$2" detail="$3" outcome="$4"
1963
+ local ts slug evfile json
1964
+ ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1965
+ slug=$(_project_slug 2>/dev/null || basename "$PWD")
1966
+ evfile="${_SHARED_ROOT:-$HOME/.shared/roll}/loop/events-${slug}.ndjson"
1967
+ mkdir -p "$(dirname "$evfile")"
1968
+
1969
+ # stdout: tab-separated for tmux display
1970
+ printf '%s\t%s\t%s\t%s\t%s\n' "$ts" "$stage" "$label" "$detail" "$outcome"
1971
+
1972
+ # JSON line appended to NDJSON file; serialized with flock (Linux) or
1973
+ # lockf (macOS/BSD) — fall back to unguarded append when neither is available.
1974
+ json=$(printf '{"ts":"%s","stage":"%s","label":"%s","detail":"%s","outcome":"%s"}\n' \
1975
+ "$ts" "$stage" "$label" "$detail" "$outcome")
1976
+ if command -v flock >/dev/null 2>&1; then
1977
+ (
1978
+ flock -x 9
1979
+ printf '%s\n' "$json" >> "$evfile"
1980
+ ) 9>>"${evfile}.lock"
1981
+ elif command -v lockf >/dev/null 2>&1; then
1982
+ lockf -s "${evfile}.lock" sh -c "printf '%s\n' $(printf '%q' "$json") >> $(printf '%q' "$evfile")"
1983
+ else
1984
+ printf '%s\n' "$json" >> "$evfile"
1985
+ fi
1986
+
1987
+ # File rotation: if >10MB, rotate keeping last 5
1988
+ _loop_event_rotate "$evfile"
1989
+ }
1990
+
1991
+ _loop_event_rotate() {
1992
+ local f="$1"
1993
+ local size
1994
+ size=$(stat -f%z "$f" 2>/dev/null || stat -c%s "$f" 2>/dev/null || echo 0)
1995
+ if [ "$size" -gt 10485760 ]; then
1996
+ # rotate: .4→remove, .3→.4, .2→.3, .1→.2, current→.1
1997
+ rm -f "${f}.4"
1998
+ for i in 3 2 1; do
1999
+ [ -f "${f}.$i" ] && mv "${f}.$i" "${f}.$((i+1))"
2000
+ done
2001
+ mv "$f" "${f}.1"
2002
+ touch "$f"
2003
+ fi
2004
+ }
2005
+
2118
2006
  _launchd_label() {
2119
2007
  local service="$1" project_path="$2"
2120
2008
  printf 'com.roll.%s.%s' "$service" "$(_project_slug "$project_path")"
@@ -2194,6 +2082,13 @@ _write_loop_runner_script() {
2194
2082
  # _install_launchd_plists prepend it). The runner now manages cwd itself
2195
2083
  # — pointing at the worktree when isolation succeeds, project_path otherwise.
2196
2084
  local claude_cmd; claude_cmd="${cmd_verbose#cd \"*\" && }"
2085
+ # FIX-048: Claude Code resolves project root from the worktree's .git file to
2086
+ # the main repo, placing worktree absolute paths outside its sandbox. Inject
2087
+ # --add-dir "$WT" so the worktree directory is explicitly allowed. Only applies
2088
+ # to claude (the --output-format stream-json flag is exclusive to claude runs).
2089
+ if [[ "$claude_cmd" == *"--output-format stream-json"* ]]; then
2090
+ claude_cmd="${claude_cmd/--output-format stream-json/--output-format stream-json --add-dir \"\$WT\"}"
2091
+ fi
2197
2092
  local slug; slug=$(_project_slug "$project_path")
2198
2093
  cat > "$inner_path" << INNER
2199
2094
  #!/bin/bash -l
@@ -2237,6 +2132,7 @@ set +e
2237
2132
  # On any failure (no remote, no main, etc.) fall back to running in the
2238
2133
  # project's main tree (degraded — no isolation, like pre-037 behavior).
2239
2134
  CYCLE_ID="\$(date -u +%Y%m%d-%H%M%S)-\$\$"
2135
+ CYCLE_START=\$(date -u +%s)
2240
2136
  WT="\$(_worktree_path "${slug}" "cycle-\${CYCLE_ID}")"
2241
2137
  BRANCH="loop/cycle-\${CYCLE_ID}"
2242
2138
  _USE_WORKTREE=0
@@ -2253,6 +2149,11 @@ for _orphan_wt in "\${_SHARED_ROOT}/worktrees/${slug}-cycle-"*; do
2253
2149
  _orphan_commits=\$(cd "\$_orphan_wt" && git rev-list --count origin/main..HEAD 2>/dev/null || echo 0)
2254
2150
  if [ "\$_orphan_commits" -gt 0 ]; then
2255
2151
  echo "[loop] FIX-040: recovering orphan worktree \$_orphan_wt (branch \$_orphan_branch, \${_orphan_commits} commits)"
2152
+ # FIX-045: rebase onto origin/main before publishing — avoids BEHIND state on GitHub
2153
+ if ! ( cd "\$_orphan_wt" && git fetch origin main 2>/dev/null && git rebase origin/main 2>/dev/null ); then
2154
+ echo "[loop] FIX-045: orphan \$_orphan_branch rebase failed — skipping recovery (conflict or network error)"
2155
+ continue
2156
+ fi
2256
2157
  _orphan_ok=0
2257
2158
  if ( cd "\$_orphan_wt" && _loop_is_doc_only_change ); then
2258
2159
  ( cd "\$_orphan_wt" && _loop_publish_doc_pr "\$_orphan_branch" "doc: recover orphan \${_orphan_branch}" ) && _orphan_ok=1
@@ -2278,6 +2179,7 @@ if _worktree_fetch_origin main \\
2278
2179
  _USE_WORKTREE=1
2279
2180
  _worktree_submodule_init "\$WT" 2>/dev/null || true
2280
2181
  echo "[loop] cycle \${CYCLE_ID}: worktree \$WT on \$BRANCH"
2182
+ _loop_event cycle_start "\${CYCLE_ID}" "" "" || true
2281
2183
  else
2282
2184
  # P3 fix: skip the cycle entirely when worktree isolation fails.
2283
2185
  # --dangerously-skip-permissions is only safe paired with worktree isolation;
@@ -2302,6 +2204,25 @@ for _attempt in 1 2 3; do
2302
2204
  fi
2303
2205
  done
2304
2206
 
2207
+ # FIX-044: capture cycle data from worktree before cleanup removes it
2208
+ _cycle_tcr=0
2209
+ _cycle_status="idle"
2210
+ _cycle_built="[]"
2211
+ if [ "\$_USE_WORKTREE" = "1" ]; then
2212
+ if [ "\$_exit" -ne 0 ]; then
2213
+ _cycle_status="failed"
2214
+ else
2215
+ _cycle_commits_pre=\$(cd "\$WT" && git rev-list --count origin/main..HEAD 2>/dev/null || echo 0)
2216
+ if [ "\$_cycle_commits_pre" -gt 0 ]; then
2217
+ _cycle_status="built"
2218
+ _cycle_tcr=\$(cd "\$WT" && git log --oneline origin/main..HEAD -- 2>/dev/null | grep -c ' tcr:' || echo 0)
2219
+ if command -v jq >/dev/null 2>&1; then
2220
+ _cycle_built=\$(cd "\$WT" && git diff origin/main -- BACKLOG.md 2>/dev/null | grep '✅ Done' | grep -oE '\[[A-Z]+-[0-9]+\]' | sed 's/^.//;s/.\$//' | jq -R -s 'split("\n") | map(select(length>0))' 2>/dev/null || echo "[]")
2221
+ fi
2222
+ fi
2223
+ fi
2224
+ fi
2225
+
2305
2226
  # US-AUTO-038: diff snapshot vs current and delete any claude/* branches this
2306
2227
  # session pushed to origin. Runs regardless of claude's exit code (cleanup is
2307
2228
  # orthogonal to success/failure) and is silent on non-GitHub / unreachable.
@@ -2320,16 +2241,26 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
2320
2241
  _cycle_commits=\$(cd "\$WT" && git rev-list --count origin/main..HEAD 2>/dev/null || echo 0)
2321
2242
  if [ "\$_cycle_commits" -eq 0 ]; then
2322
2243
  _worktree_cleanup "\$WT" "\$BRANCH"
2244
+ _loop_event idle "\${CYCLE_ID}" "" "" || true
2323
2245
  echo "[loop] cycle \${CYCLE_ID}: idle (no new commits); worktree cleaned"
2324
2246
  else
2325
- if ( cd "\$WT" && _loop_is_doc_only_change ); then
2247
+ _is_doc_only=0
2248
+ ( cd "\$WT" && _loop_is_doc_only_change ) && _is_doc_only=1
2249
+ if [ "\$_is_doc_only" -eq 1 ]; then
2326
2250
  ( cd "\$WT" && _loop_publish_doc_pr "\$BRANCH" "doc: loop cycle \${CYCLE_ID}" )
2327
2251
  else
2328
2252
  ( cd "\$WT" && _loop_publish_pr "\$BRANCH" "loop cycle \${CYCLE_ID}" )
2329
2253
  fi
2330
2254
  _publish_status=\$?
2331
2255
  if [ "\$_publish_status" -eq 0 ]; then
2256
+ # FIX-047: CI green ≠ delivered — wait for actual PR merge before cycle complete
2257
+ if [ "\$_is_doc_only" -eq 0 ]; then
2258
+ if ! ( cd "\$WT" && _loop_wait_pr_merge "\$BRANCH" ); then
2259
+ _worktree_alert "cycle \${CYCLE_ID}: FIX-047: PR not merged within timeout — code may not be in main (BRANCH=\${BRANCH})"
2260
+ fi
2261
+ fi
2332
2262
  _worktree_cleanup "\$WT" "\$BRANCH"
2263
+ _loop_event cycle_end "\${CYCLE_ID}" "" "done" || true
2333
2264
  echo "[loop] cycle \${CYCLE_ID}: published; worktree cleaned"
2334
2265
  elif [ "\$_publish_status" -eq 2 ]; then
2335
2266
  if ( cd "${project_path}" && _worktree_merge_back "\$BRANCH" ); then
@@ -2374,6 +2305,32 @@ fi
2374
2305
 
2375
2306
  # US-AUTO-040: fallback GC — delete remote loop/cycle-* branches already merged to main.
2376
2307
  _loop_cleanup_stale_cycle_branches "${project_path}" || true
2308
+
2309
+ # FIX-044 / Step 5: Write loop cycle run summary to runs.jsonl
2310
+ # Deterministic — runs in shell regardless of whether agent executes SKILL.md Step 5.
2311
+ # Idempotent: skips if a record for this run_id already exists (agent may also write).
2312
+ _runs_dst="${HOME}/.shared/roll/loop/runs.jsonl"
2313
+ mkdir -p "\$(dirname "\$_runs_dst")"
2314
+ _cycle_end=\$(date -u +%s)
2315
+ _cycle_dur=\$(( _cycle_end - CYCLE_START ))
2316
+ _ts=\$(date -u +%Y-%m-%dT%H:%M:%SZ)
2317
+ _run_id="loop-\${CYCLE_ID%-*}"
2318
+ if command -v jq >/dev/null 2>&1 && ! grep -qF "\"run_id\":\"\$_run_id\"" "\$_runs_dst" 2>/dev/null; then
2319
+ jq -nc \\
2320
+ --arg ts "\$_ts" \\
2321
+ --arg project "${slug}" \\
2322
+ --arg run_id "\$_run_id" \\
2323
+ --arg status "\$_cycle_status" \\
2324
+ --argjson built "\$_cycle_built" \\
2325
+ --argjson skipped '[]' \\
2326
+ --argjson alerts '[]' \\
2327
+ --argjson tcr_count "\$_cycle_tcr" \\
2328
+ --argjson duration_sec "\$_cycle_dur" \\
2329
+ '{ts:\$ts, project:\$project, run_id:\$run_id, status:\$status,
2330
+ built:\$built, skipped:\$skipped, alerts:\$alerts,
2331
+ tcr_count:\$tcr_count, duration_sec:\$duration_sec}' \\
2332
+ >> "\$_runs_dst" 2>/dev/null || true
2333
+ fi
2377
2334
  INNER
2378
2335
  chmod +x "$inner_path"
2379
2336
 
@@ -2626,14 +2583,17 @@ cmd_loop() {
2626
2583
  status) _loop_status ;;
2627
2584
  monitor) _loop_monitor "${1:-3}" ;;
2628
2585
  runs) _loop_runs "$@" ;;
2586
+ events) _loop_event_log "${1:-20}" ;;
2629
2587
  attach) _loop_attach ;;
2630
2588
  mute) _loop_mute ;;
2631
2589
  unmute) _loop_unmute ;;
2632
2590
  pause) _loop_pause ;;
2633
2591
  resume) _loop_resume ;;
2634
2592
  reset) _loop_reset ;;
2635
- notify) _notify "${1:-roll}" "${2:-}" ;;
2636
- *) err "Usage: roll loop <on|off|now|test|status|monitor|runs|attach|mute|unmute|pause|resume|reset|notify>"; exit 1 ;;
2593
+ notify) _notify "${1:-roll}" "${2:-}" ;;
2594
+ enforce-tcr) _loop_enforce_tcr "${1:-}" "${2:-}" ;;
2595
+ precheck-ci) _loop_precheck_ci ;;
2596
+ *) err "Usage: roll loop <on|off|now|test|status|monitor|runs|events|attach|mute|unmute|pause|resume|reset|notify|enforce-tcr|precheck-ci>"; exit 1 ;;
2637
2597
  esac
2638
2598
  }
2639
2599
 
@@ -3152,6 +3112,20 @@ _gh_repo_slug() {
3152
3112
  printf "%s\n" "$url"
3153
3113
  }
3154
3114
 
3115
+ # Returns 0 if gh CLI is installed and executable, 1 otherwise.
3116
+ _gh_available() { command -v gh >/dev/null 2>&1; }
3117
+
3118
+ # Resolve the GitHub owner/repo slug and set <outvar> to it.
3119
+ # Returns 0 on success. Returns 1 (no output) if gh is unavailable or the
3120
+ # remote is not a GitHub URL — caller decides how to handle failure.
3121
+ _gh_resolve() {
3122
+ local _outvar="$1"
3123
+ _gh_available || return 1
3124
+ local _slug
3125
+ _slug=$(_gh_repo_slug 2>/dev/null) || return 1
3126
+ printf -v "$_outvar" '%s' "$_slug"
3127
+ }
3128
+
3155
3129
  # Poll gh run list until current commit's CI completes.
3156
3130
  # Returns 0 on success (or when gh binary missing — graceful skip).
3157
3131
  # Returns 1 on CI failure, timeout, or any gh call failure.
@@ -3160,7 +3134,7 @@ _ci_wait() {
3160
3134
  local interval=15
3161
3135
  local elapsed=0
3162
3136
 
3163
- command -v gh &>/dev/null || { warn "gh not installed — skipping CI gate gh 未安装,跳过 CI 检查"; return 0; }
3137
+ _gh_available || { warn "gh not installed — skipping CI gate gh 未安装,跳过 CI 检查"; return 0; }
3164
3138
 
3165
3139
  local commit; commit=$(git rev-parse HEAD 2>/dev/null) || { err "Not a git repo 非 git 仓库"; return 1; }
3166
3140
  local short; short=$(git rev-parse --short HEAD 2>/dev/null)
@@ -3182,6 +3156,17 @@ _ci_wait() {
3182
3156
  }
3183
3157
 
3184
3158
  if [[ -z "$runs" || "$runs" == "[]" ]]; then
3159
+ # FIX-046: CI only fires on pull_request events — without a PR, runs will never appear.
3160
+ # Check if an open PR exists; if not, skip the gate gracefully.
3161
+ local _branch; _branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
3162
+ if [[ -n "$_branch" ]]; then
3163
+ local _pr_json; _pr_json=$(gh -R "$repo_slug" pr list --head "$_branch" --state open --json number 2>/dev/null || echo "1")
3164
+ local _pr_count; _pr_count=$(echo "$_pr_json" | jq 'length' 2>/dev/null || echo "1")
3165
+ if [[ "$_pr_count" == "0" ]]; then
3166
+ warn "No open PR for ${_branch} — CI not triggered; skipping CI gate 当前分支无 PR,CI 未触发,跳过"
3167
+ return 0
3168
+ fi
3169
+ fi
3185
3170
  (( elapsed == 0 )) && echo " No CI runs found yet, waiting... 尚无 CI 记录,等待触发..."
3186
3171
  sleep "$interval"
3187
3172
  elapsed=$(( elapsed + interval ))
@@ -3221,10 +3206,9 @@ _ci_wait() {
3221
3206
  # Returns 0: ok to proceed (green / pending / unknown / no gh).
3222
3207
  # Returns 1: HEAD CI is definitively red → ALERT written, do not build.
3223
3208
  _loop_precheck_ci() {
3224
- command -v gh &>/dev/null || return 0
3209
+ local slug; _gh_resolve slug || return 0
3225
3210
 
3226
3211
  local commit; commit=$(git rev-parse HEAD 2>/dev/null) || return 0
3227
- local slug; slug=$(_gh_repo_slug) || return 0
3228
3212
 
3229
3213
  local runs
3230
3214
  runs=$(gh -R "$slug" run list --commit "$commit" --json conclusion 2>/dev/null) || return 0
@@ -3317,8 +3301,12 @@ _loop_diagnose_open_prs() {
3317
3301
  # When gh unavailable: returns 0 (graceful skip).
3318
3302
  _loop_enforce_ci() {
3319
3303
  local story_id="$1"
3320
-
3321
- _ci_wait 300 && return 0
3304
+ local _ci_result
3305
+ if _ci_wait 300; then
3306
+ _loop_event ci "$story_id" "" "ok" 2>/dev/null || true
3307
+ return 0
3308
+ fi
3309
+ _loop_event ci "$story_id" "" "red" 2>/dev/null || true
3322
3310
 
3323
3311
  mkdir -p "$(dirname "$_LOOP_ALERT")"
3324
3312
  cat > "$_LOOP_ALERT" << EOF
@@ -3355,13 +3343,22 @@ _loop_self_heal_ci() {
3355
3343
  [[ -z "$story_id" ]] && return 1
3356
3344
  [[ "${ROLL_LOOP_NO_HEAL:-0}" == "1" ]] && return 1
3357
3345
  local max="${ROLL_LOOP_HEAL_MAX:-2}"
3358
- local dir; dir=$(_loop_heal_dir)
3359
- local counter="${dir}/${story_id}.count"
3360
- mkdir -p "$dir"
3361
- local current; current=$(<"$counter") 2>/dev/null || current=0
3362
- [[ "$current" =~ ^[0-9]+$ ]] || current=0
3346
+ local state="${_LOOP_STATE:-${HOME}/.shared/roll/loop/state.yaml}"
3347
+ local current=0
3348
+ if [[ -f "$state" ]]; then
3349
+ local raw; raw=$(grep '^heal_count:' "$state" 2>/dev/null | awk '{print $2}')
3350
+ [[ "$raw" =~ ^[0-9]+$ ]] && current="$raw"
3351
+ fi
3363
3352
  [[ "$current" -ge "$max" ]] && return 1
3364
- echo $((current + 1)) > "$counter"
3353
+ local new=$((current + 1))
3354
+ mkdir -p "$(dirname "$state")"
3355
+ if grep -q '^heal_count:' "$state" 2>/dev/null; then
3356
+ local tmp; tmp=$(mktemp)
3357
+ sed "s/^heal_count:.*/heal_count: ${new}/" "$state" > "$tmp"
3358
+ mv "$tmp" "$state"
3359
+ else
3360
+ echo "heal_count: ${new}" >> "$state"
3361
+ fi
3365
3362
  return 0
3366
3363
  }
3367
3364
 
@@ -3370,7 +3367,11 @@ _loop_self_heal_ci() {
3370
3367
  _loop_clear_heal_state() {
3371
3368
  local story_id="$1"
3372
3369
  [[ -z "$story_id" ]] && return 0
3373
- rm -f "$(_loop_heal_dir)/${story_id}.count" 2>/dev/null
3370
+ local state="${_LOOP_STATE:-${HOME}/.shared/roll/loop/state.yaml}"
3371
+ [[ ! -f "$state" ]] && return 0
3372
+ local tmp; tmp=$(mktemp)
3373
+ grep -v '^heal_count:' "$state" > "$tmp"
3374
+ mv "$tmp" "$state"
3374
3375
  return 0
3375
3376
  }
3376
3377
 
@@ -3643,7 +3644,7 @@ _loop_pr_rebase_stale() {
3643
3644
  local pr="$1" head_ref="$2"
3644
3645
  [ -n "$pr" ] && [ -n "$head_ref" ] || return 0
3645
3646
 
3646
- local slug; slug=$(_gh_repo_slug 2>/dev/null) || return 0
3647
+ local slug; _gh_resolve slug || return 0
3647
3648
 
3648
3649
  local pr_json
3649
3650
  pr_json=$(gh -R "$slug" pr view "$pr" --json headRepository,headRepositoryOwner,isCrossRepository 2>/dev/null) || return 0
@@ -3677,9 +3678,7 @@ _loop_pr_rebase_stale() {
3677
3678
  # Walks open PRs and routes each by classification.
3678
3679
  # Lenient on gh unavailability — returns 0 so the loop continues to BACKLOG.
3679
3680
  _loop_pr_inbox() {
3680
- command -v gh >/dev/null 2>&1 || return 0
3681
-
3682
- local slug; slug=$(_gh_repo_slug 2>/dev/null) || return 0
3681
+ local slug; _gh_resolve slug || return 0
3683
3682
  local prs_json
3684
3683
  prs_json=$(gh -R "$slug" pr list --state open \
3685
3684
  --json number,headRefName,author,title \
@@ -4116,15 +4115,10 @@ _loop_cleanup_stale_cycle_branches() {
4116
4115
  _loop_publish_pr() {
4117
4116
  local branch="$1"
4118
4117
  local title="${2:-loop cycle ${branch#loop/}}"
4119
- if ! command -v gh >/dev/null 2>&1; then
4120
- _worktree_alert "_loop_publish_pr: gh not installed; cannot publish PR for ${branch}"
4121
- return 2
4122
- fi
4123
- local slug; slug=$(_gh_repo_slug 2>/dev/null) || slug=""
4124
- if [ -z "$slug" ]; then
4125
- _worktree_alert "_loop_publish_pr: origin remote is not a github repo; cannot publish PR for ${branch}"
4118
+ local slug; _gh_resolve slug || {
4119
+ _worktree_alert "_loop_publish_pr: gh not installed or origin is not a github repo; cannot publish PR for ${branch}"
4126
4120
  return 2
4127
- fi
4121
+ }
4128
4122
  local _push_err
4129
4123
  _push_err=$(git push origin "$branch" 2>&1) || {
4130
4124
  _worktree_alert "_loop_publish_pr: push origin ${branch} failed: ${_push_err}"
@@ -4145,10 +4139,34 @@ _loop_publish_pr() {
4145
4139
  fi
4146
4140
  gh -R "$slug" pr merge "$branch" --auto --squash --delete-branch >/dev/null 2>&1 \
4147
4141
  || _worktree_alert "_loop_publish_pr: gh pr merge --auto failed for ${branch} (PR ${pr_url} left open)"
4142
+ _loop_event pr "$branch" "$pr_url" "ok" 2>/dev/null || true
4148
4143
  echo "$pr_url"
4149
4144
  return 0
4150
4145
  }
4151
4146
 
4147
+ # _loop_wait_pr_merge <branch>
4148
+ # FIX-047: poll GitHub until PR for <branch> is merged (confirms delivery).
4149
+ # Returns 0: merged. Returns 1: CLOSED or timeout.
4150
+ # Gracefully skips (returns 0) when gh is unavailable or slug unparseable.
4151
+ # Timeout: ROLL_PR_MERGE_TIMEOUT (default 600s).
4152
+ _loop_wait_pr_merge() {
4153
+ local branch="$1"
4154
+ local timeout="${ROLL_PR_MERGE_TIMEOUT:-600}"
4155
+ local interval=30
4156
+ local elapsed=0
4157
+ local slug; _gh_resolve slug || return 0
4158
+ while (( elapsed < timeout )); do
4159
+ local state; state=$(gh -R "$slug" pr view "$branch" --json state -q .state 2>/dev/null || echo "UNKNOWN")
4160
+ case "$state" in
4161
+ MERGED) return 0 ;;
4162
+ CLOSED) return 1 ;;
4163
+ esac
4164
+ sleep "$interval"
4165
+ elapsed=$(( elapsed + interval ))
4166
+ done
4167
+ return 1
4168
+ }
4169
+
4152
4170
  # _loop_is_doc_only_change
4153
4171
  # Returns 0 if every file changed since origin/main is doc-only
4154
4172
  # (BACKLOG.md, CHANGELOG.md, PROPOSALS.md, docs/, .claude/).
@@ -4167,15 +4185,10 @@ _loop_is_doc_only_change() {
4167
4185
  _loop_publish_doc_pr() {
4168
4186
  local branch="$1"
4169
4187
  local title="${2:-doc update ${branch#loop/}}"
4170
- if ! command -v gh >/dev/null 2>&1; then
4171
- _worktree_alert "_loop_publish_doc_pr: gh not installed; cannot publish PR for ${branch}"
4188
+ local slug; _gh_resolve slug || {
4189
+ _worktree_alert "_loop_publish_doc_pr: gh not installed or origin is not a github repo; cannot publish PR for ${branch}"
4172
4190
  return 2
4173
- fi
4174
- local slug; slug=$(_gh_repo_slug 2>/dev/null) || slug=""
4175
- if [ -z "$slug" ]; then
4176
- _worktree_alert "_loop_publish_doc_pr: origin remote is not a github repo; cannot publish PR for ${branch}"
4177
- return 2
4178
- fi
4191
+ }
4179
4192
  if ! git push origin "$branch" --quiet 2>/dev/null; then
4180
4193
  _worktree_alert "_loop_publish_doc_pr: push origin ${branch} failed"
4181
4194
  return 1
@@ -4336,11 +4349,57 @@ _loop_monitor() {
4336
4349
  echo -e " ${YELLOW}(no log yet)${NC}"
4337
4350
  fi
4338
4351
 
4352
+ # Event stream (US-LOOP-001): last 10 events from NDJSON event file
4353
+ local slug; slug=$(_project_slug "$project_path")
4354
+ local evfile="${_SHARED_ROOT}/loop/events-${slug}.ndjson"
4355
+ echo ""
4356
+ echo -e " ─────────────────────────────────────────────────────"
4357
+ echo -e " ${BOLD}Cycle Events 事件流${NC} (last 10)"
4358
+ if [[ -f "$evfile" && -s "$evfile" ]]; then
4359
+ tail -n 10 "$evfile" | python3 -c "
4360
+ import sys, json
4361
+ for line in sys.stdin:
4362
+ try:
4363
+ e = json.loads(line)
4364
+ stage = e.get('stage','')
4365
+ label = e.get('label','')
4366
+ detail = e.get('detail','')
4367
+ outcome = e.get('outcome','')
4368
+ ts = e.get('ts','')
4369
+ print(f' {ts} {stage:<14} {label:<22} {detail} {outcome}')
4370
+ except: pass
4371
+ " 2>/dev/null || tail -n 10 "$evfile" | sed 's/^/ /'
4372
+ else
4373
+ echo -e " ${YELLOW}(no events yet — events are emitted after the first cycle)${NC}"
4374
+ fi
4375
+
4339
4376
  echo ""
4340
4377
  sleep "$interval"
4341
4378
  done
4342
4379
  }
4343
4380
 
4381
+ # _loop_event_log: show last N events from the project's NDJSON event file.
4382
+ # Used by: roll loop events [N]
4383
+ _loop_event_log() {
4384
+ local n="${1:-20}"
4385
+ local project_path; project_path=$(pwd -P)
4386
+ local slug; slug=$(_project_slug "$project_path")
4387
+ local evfile="${_SHARED_ROOT}/loop/events-${slug}.ndjson"
4388
+ if [ ! -f "$evfile" ]; then
4389
+ echo "[monitor] No event log found for project: $slug"
4390
+ return 1
4391
+ fi
4392
+ # Show last N events, formatted
4393
+ tail -n "$n" "$evfile" | python3 -c "
4394
+ import sys, json
4395
+ for line in sys.stdin:
4396
+ try:
4397
+ e = json.loads(line)
4398
+ print(f\" {e.get('ts','')} {e.get('stage',''):12s} {e.get('label',''):20s} {e.get('detail','')} {e.get('outcome','')}\")
4399
+ except: pass
4400
+ "
4401
+ }
4402
+
4344
4403
  # ═══════════════════════════════════════════════════════════════════════════════
4345
4404
  # BRIEF — owner-facing project digest
4346
4405
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -4507,7 +4566,7 @@ cmd_ci() {
4507
4566
  return
4508
4567
  fi
4509
4568
 
4510
- command -v gh &>/dev/null || { warn "gh not installed gh 未安装"; return 0; }
4569
+ _gh_available || { warn "gh not installed gh 未安装"; return 0; }
4511
4570
  local commit; commit=$(git rev-parse HEAD 2>/dev/null) || { err "Not a git repo 非 git 仓库"; return 1; }
4512
4571
  local runs
4513
4572
  runs=$(gh run list --commit "$commit" --json status,conclusion,name 2>/dev/null) || { warn "gh run list failed"; return 0; }
@@ -4812,7 +4871,7 @@ _dash_ac_completion() {
4812
4871
  # ④ DoD CI signal — query gh for HEAD's most-recent run conclusion.
4813
4872
  # Returns: success | pending | failure | none
4814
4873
  _dash_ci_status() {
4815
- command -v gh &>/dev/null || { echo "none"; return; }
4874
+ _gh_available || { echo "none"; return; }
4816
4875
  local commit; commit=$(git rev-parse HEAD 2>/dev/null) || { echo "none"; return; }
4817
4876
  local slug; slug=$(_gh_repo_slug 2>/dev/null) || true
4818
4877
  local out