@seanyao/roll 2.602.5 → 2.604.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/bin/roll +500 -792
  3. package/lib/README.md +0 -1
  4. package/lib/changelog_audit.py +139 -145
  5. package/lib/changelog_generate.py +237 -30
  6. package/lib/consistency_check.py +409 -0
  7. package/lib/i18n/consistency.sh +8 -0
  8. package/lib/loop-fmt.py +2 -2
  9. package/lib/prices/snapshot-2026-05-22.json +1 -7
  10. package/lib/prices/snapshot-2026-05-23-deepseek.json +0 -2
  11. package/lib/prices/snapshot-2026-06-02-kimi.json +0 -1
  12. package/lib/prices_fetcher.py +312 -63
  13. package/lib/roll-loop-status.py +1 -1
  14. package/package.json +1 -1
  15. package/skills/roll-.changelog/SKILL.md +1 -1
  16. package/skills/roll-loop/SKILL.md +7 -23
  17. package/lib/__pycache__/github_sync.cpython-314.pyc +0 -0
  18. package/lib/__pycache__/loop-fmt.cpython-314.pyc +0 -0
  19. package/lib/__pycache__/loop_result_eval.cpython-314.pyc +0 -0
  20. package/lib/__pycache__/loop_unstick.cpython-314.pyc +0 -0
  21. package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
  22. package/lib/__pycache__/prices_fetcher.cpython-314.pyc +0 -0
  23. package/lib/__pycache__/roll-home.cpython-314.pyc +0 -0
  24. package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
  25. package/lib/__pycache__/roll_git.cpython-314.pyc +0 -0
  26. package/lib/__pycache__/roll_render.cpython-314.pyc +0 -0
  27. package/lib/__pycache__/slides-render.cpython-314.pyc +0 -0
  28. package/lib/agent_usage/__pycache__/__init__.cpython-314.pyc +0 -0
  29. package/lib/agent_usage/__pycache__/gemini.cpython-314.pyc +0 -0
  30. package/lib/agent_usage/__pycache__/kimi.cpython-314.pyc +0 -0
  31. package/lib/agent_usage/__pycache__/openai.cpython-314.pyc +0 -0
  32. package/lib/agent_usage/__pycache__/pi.cpython-314.pyc +0 -0
  33. package/lib/agent_usage/__pycache__/pi_emit.cpython-314.pyc +0 -0
  34. package/lib/agent_usage/__pycache__/qwen.cpython-314.pyc +0 -0
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="2.602.5"
7
+ VERSION="2.604.1"
8
8
  ROLL_HOME="${ROLL_HOME:-${HOME}/.roll}"
9
9
  ROLL_CONFIG="${ROLL_HOME}/config.yaml"
10
10
  ROLL_GLOBAL="${ROLL_HOME}/conventions/global"
@@ -4046,6 +4046,11 @@ _peer_call() {
4046
4046
  _watchdog_pid=$!
4047
4047
  wait "$_peer_pid" 2>/dev/null || _peer_exit=$?
4048
4048
  # Cancel watchdog if agent finished on time.
4049
+ # FIX-181: kill children (sleep) first so they cannot outlive the
4050
+ # watchdog and later hit a reused PID, then kill the watchdog itself.
4051
+ if command -v pkill >/dev/null 2>&1; then
4052
+ pkill -P "$_watchdog_pid" 2>/dev/null || true
4053
+ fi
4049
4054
  kill "$_watchdog_pid" 2>/dev/null || true
4050
4055
  wait "$_watchdog_pid" 2>/dev/null || true
4051
4056
  output="$(cat "$_out" 2>/dev/null || true)"
@@ -5519,26 +5524,20 @@ import prices_fetcher as pf
5519
5524
 
5520
5525
  snapshot_dir = os.path.join(lib_dir, "prices")
5521
5526
 
5522
- # FIX-116: vendor-specific default URLs.
5523
- VENDOR_URLS = {
5524
- "anthropic": "https://platform.claude.com/docs/en/about-claude/pricing",
5525
- "deepseek": "https://api-docs.deepseek.com/quick_start/pricing",
5526
- "kimi": "https://platform.kimi.com/docs/pricing/chat",
5527
- }
5528
-
5529
5527
  if vendor:
5530
- default_url = VENDOR_URLS.get(vendor)
5531
- if not default_url:
5528
+ if vendor not in pf.VENDOR_REGISTRY:
5532
5529
  print(f"$(msg prices_refresh.roll_unknown_vendor_vendor)", file=sys.stderr)
5533
5530
  print(f"$(msg prices_refresh.roll_known_vendors_join_sorted_vendor)", file=sys.stderr)
5534
5531
  sys.exit(1)
5535
- url = override_url or default_url
5536
- pf.DEFAULT_SOURCE_URL = url
5537
5532
  else:
5538
- url = override_url or pf.DEFAULT_SOURCE_URL
5533
+ vendor = "anthropic"
5539
5534
 
5540
5535
  try:
5541
- action, changes = pf.refresh(snapshot_dir=snapshot_dir, url=url)
5536
+ action, changes = pf.refresh(
5537
+ snapshot_dir=snapshot_dir,
5538
+ vendor=vendor,
5539
+ url=override_url or None,
5540
+ )
5542
5541
  except pf.FetchError as exc:
5543
5542
  print(f"[roll] fetch failed: {exc}", file=sys.stderr)
5544
5543
  print("[roll] keeping existing snapshot, no changes written 保留旧快照,未写入新文件",
@@ -5577,42 +5576,155 @@ cmd_prices() {
5577
5576
  esac
5578
5577
  }
5579
5578
 
5579
+ # FIX-178: AI-style a deterministic changelog draft into the project voice via
5580
+ # the configured agent (roll agent use). Content-preserving polish only — same
5581
+ # items + (ID)s, bold-lead style anchored on the last 3 released versions. Echoes
5582
+ # the styled draft on success; empty output on any failure so the caller falls
5583
+ # back to the deterministic raw draft. Bounded by a 150s watchdog (macOS lacks
5584
+ # timeout(1)). Uses the same agent path as peer review (_agent_argv).
5585
+ _changelog_ai_style() {
5586
+ local raw="$1"
5587
+ [ -n "$raw" ] || return 1
5588
+ local agent; agent=$(_project_agent)
5589
+ local anchors; anchors=$(_changelog_style_anchors CHANGELOG.md 2>/dev/null || true)
5590
+ local prompt
5591
+ prompt="你是 roll 的 changelog 编辑。把【原始草稿】改写成发布说明,严格遵守:
5592
+ 1) 不增删条目、不改事实;每条保留它的(ID)。只润色措辞。
5593
+ 2) 每条格式:\`- **简短标题(ID)** — 补充说明\`;若原条目结尾有 \`[loop]\` 则保留。
5594
+ 3) 保留每个 ### 分类标题与分组。只输出 markdown,从 '## Unreleased' 开始,前后不要任何解释。
5595
+
5596
+ 【风格锚点·最近版本】
5597
+ ${anchors}
5598
+
5599
+ 【原始草稿】
5600
+ ${raw}"
5601
+ _agent_argv "$agent" text "$prompt" 2>/dev/null || return 1
5602
+ _agent_bypass_claude_perms
5603
+ local out; out=$(mktemp)
5604
+ "${_AGENT_ARGV[@]}" >"$out" 2>/dev/null &
5605
+ local pid=$!
5606
+ local waited=0
5607
+ while kill -0 "$pid" 2>/dev/null; do
5608
+ sleep 1; waited=$((waited + 1))
5609
+ if [ "$waited" -ge 150 ]; then
5610
+ kill "$pid" 2>/dev/null; sleep 1; kill -9 "$pid" 2>/dev/null; break
5611
+ fi
5612
+ done
5613
+ wait "$pid" 2>/dev/null
5614
+ # Normalize agent chrome before extracting: some agents (e.g. kimi) prefix the
5615
+ # rendered answer with a "• " marker and indent the body. Changelog markdown is
5616
+ # flat (##, ###, -, blank), so stripping a leading "• " and any leading
5617
+ # whitespace is safe and is a no-op for plain agents (e.g. claude -p). Then
5618
+ # keep from the first '## Unreleased' line onward (drops any preamble).
5619
+ sed -E 's/^[[:space:]]*•[[:space:]]?//; s/^[[:space:]]+//' "$out" \
5620
+ | awk '/^## Unreleased/{f=1} f{print}'
5621
+ rm -f "$out"
5622
+ }
5623
+
5624
+ # FIX-178: replace (or insert) the ## Unreleased section of CHANGELOG.md with
5625
+ # the given draft (which itself begins with '## Unreleased'). getline-from-file
5626
+ # keeps the multi-line draft intact.
5627
+ _changelog_write_unreleased() {
5628
+ local draft="$1" cl="${2:-CHANGELOG.md}"
5629
+ local dfile; dfile=$(mktemp); printf '%s\n' "$draft" > "$dfile"
5630
+ local tmp; tmp=$(mktemp)
5631
+ if [ -f "$cl" ] && grep -q '^## Unreleased' "$cl"; then
5632
+ awk -v df="$dfile" '
5633
+ /^## Unreleased/ && !done { while ((getline line < df) > 0) print line; print ""; skip=1; done=1; next }
5634
+ skip && /^## / { skip=0 }
5635
+ skip { next }
5636
+ { print }
5637
+ ' "$cl" > "$tmp"
5638
+ else
5639
+ { [ -f "$cl" ] && head -1 "$cl" || echo "# Changelog"; echo; cat "$dfile"; echo;
5640
+ [ -f "$cl" ] && tail -n +2 "$cl"; } > "$tmp"
5641
+ fi
5642
+ mv "$tmp" "$cl"; rm -f "$dfile"
5643
+ }
5644
+
5580
5645
  # FIX-113: changelog audit — list PRs merged to main since latest release
5581
5646
  # tag that don't appear in CHANGELOG.md's ## Unreleased section.
5582
5647
  # US-CL-006: changelog generate — deterministic draft from backlog Done stories.
5648
+ # FIX-178: generate now AI-styles the deterministic draft via the configured
5649
+ # agent by default (content-preserving); --no-ai / --json stay deterministic.
5583
5650
  cmd_changelog() {
5584
- local subcmd="${1:-audit}"
5651
+ local subcmd="${1:-generate}"
5585
5652
  shift || true
5586
5653
  case "$subcmd" in
5587
- audit)
5588
- python3 "${ROLL_PKG_DIR}/lib/changelog_audit.py" "$@"
5589
- ;;
5590
5654
  generate)
5591
- python3 "${ROLL_PKG_DIR}/lib/changelog_generate.py" "$@"
5655
+ local want_ai=1 to_write=0 is_json=0 pyargs=()
5656
+ local a
5657
+ for a in "$@"; do
5658
+ case "$a" in
5659
+ --no-ai) want_ai=0 ;;
5660
+ --write) to_write=1 ;;
5661
+ --json) is_json=1; want_ai=0; pyargs+=("$a") ;;
5662
+ *) pyargs+=("$a") ;;
5663
+ esac
5664
+ done
5665
+ local raw
5666
+ raw=$(python3 "${ROLL_PKG_DIR}/lib/changelog_generate.py" ${pyargs[@]+"${pyargs[@]}"}) || return 1
5667
+ if [ "$is_json" = 1 ]; then printf '%s\n' "$raw"; return 0; fi
5668
+ local final="$raw"
5669
+ if [ "$want_ai" = 1 ]; then
5670
+ local styled; styled=$(_changelog_ai_style "$raw" 2>/dev/null || true)
5671
+ if [ -n "$styled" ] && printf '%s' "$styled" | grep -q '^- '; then
5672
+ final="$styled"
5673
+ else
5674
+ warn "changelog: AI 润色不可用/失败,输出确定性草稿(可加 --no-ai 跳过)"
5675
+ fi
5676
+ fi
5677
+ if [ "$to_write" = 1 ]; then
5678
+ _changelog_write_unreleased "$final"
5679
+ info "Updated CHANGELOG.md"
5680
+ else
5681
+ printf '%s\n' "$final"
5682
+ fi
5592
5683
  ;;
5593
5684
  --help|-h|help)
5594
5685
  cat <<EOF
5595
- Usage: roll changelog <subcommand>
5596
-
5597
- audit [--since <tag>] [--json] 发版前体检
5598
- Scan PRs merged to main since latest v* tag; flag ones that look user-visible
5599
- (carry US-/FIX-/REFACTOR- id) but are missing from CHANGELOG.md '## Unreleased'.
5600
-
5601
- generate [--write] [--json] 从 backlog 生成草稿
5602
- Extract Done stories from .roll/backlog.md, filter internal entries,
5603
- apply lint, and produce a draft ## Unreleased section.
5604
-
5605
- roll changelog audit # check against latest v* tag
5606
- roll changelog audit --since v2026.520.1
5607
- roll changelog audit --json # machine-readable
5608
- roll changelog generate # preview draft to stdout
5609
- roll changelog generate --write # append draft to CHANGELOG.md
5610
- roll changelog generate --json # machine-readable
5686
+ Usage: roll changelog generate [options]
5687
+
5688
+ backlog Done 故事 + 上次发布以来的提交,生成 ## Unreleased 发布说明。
5689
+ 默认用配置的 agent(roll agent use)按项目风格润色;失败自动回退确定性草稿。
5690
+
5691
+ roll changelog generate # 预览(AI 润色)
5692
+ roll changelog generate --write # 写入 CHANGELOG.md(AI 润色)
5693
+ roll changelog generate --no-ai # 仅确定性草稿,不调 AI
5694
+ roll changelog generate --json # 机器可读(确定性)
5611
5695
  EOF
5612
5696
  ;;
5613
5697
  *)
5614
5698
  err "$(msg changelog.unknown_subcommand ${subcmd})"
5615
- err "Try: roll changelog audit | roll changelog generate"
5699
+ err "Try: roll changelog generate"
5700
+ return 1
5701
+ ;;
5702
+ esac
5703
+ }
5704
+
5705
+ # ─── roll consistency check — unified consistency orchestrator (US-CONSIST-001) ──
5706
+ cmd_consistency() {
5707
+ local subcmd="${1:-check}"
5708
+ shift || true
5709
+ case "$subcmd" in
5710
+ check)
5711
+ python3 "${ROLL_PKG_DIR}/lib/consistency_check.py" "$@"
5712
+ ;;
5713
+ --help|-h|help)
5714
+ cat <<EOF
5715
+ Usage: roll consistency <subcommand>
5716
+
5717
+ check [--json] [--project-dir DIR] 逐维度跑一致性检查
5718
+ Run checks across five dimensions (code, docs, i18n, tests, site)
5719
+ and produce a structured pass/gap report.
5720
+
5721
+ roll consistency check # human-readable report
5722
+ roll consistency check --json # machine-readable JSON
5723
+ EOF
5724
+ ;;
5725
+ *)
5726
+ err "$(msg consistency.unknown_sub "$subcmd")"
5727
+ err "Try: roll consistency check"
5616
5728
  return 1
5617
5729
  ;;
5618
5730
  esac
@@ -6113,14 +6225,14 @@ cmd_review_pr() {
6113
6225
 
6114
6226
  local slug; slug=$(_gh_repo_slug) || { err "Not a GitHub repo — review-pr requires GitHub remote"; return 1; }
6115
6227
 
6116
- local pr_json
6117
- pr_json=$(gh -R "$slug" pr view "$pr_number" --json title,body,diff 2>&1) \
6228
+ local pr_json diff
6229
+ pr_json=$(gh -R "$slug" pr view "$pr_number" --json title,body 2>&1) \
6118
6230
  || { err "gh pr view failed: ${pr_json}"; return 1; }
6231
+ diff=$(gh -R "$slug" pr diff "$pr_number" 2>/dev/null) || true
6119
6232
 
6120
6233
  local title body diff
6121
6234
  title=$(echo "$pr_json" | jq -r '.title // ""')
6122
6235
  body=$(echo "$pr_json" | jq -r '.body // ""')
6123
- diff=$(echo "$pr_json" | jq -r '.diff // ""')
6124
6236
 
6125
6237
  if echo "$body" | grep -qF '[skip-ai-review]'; then
6126
6238
  gh -R "$slug" pr review "$pr_number" --approve -b "Auto-approved: [skip-ai-review] detected" 2>/dev/null || true
@@ -8222,86 +8334,6 @@ PRRUNNER
8222
8334
  chmod +x "$script_path"
8223
8335
  }
8224
8336
 
8225
- # _write_ci_loop_runner_script <script_path> <project_path> <roll_bin> <log_path>
8226
- # US-AUTO-045 Phase 2: the script the com.roll.ci.<slug> launchd plist runs
8227
- # every 5 min. Mirrors _write_pr_loop_runner_script — lightweight (no agent,
8228
- # no tmux): portable PATH, a single-flight re-entry lock (pid+ts, 15-min
8229
- # staleness so a crashed pass self-heals next tick), then drives the _ci_scan
8230
- # orchestrator via the `roll _ci_scan` dispatch.
8231
- _write_ci_loop_runner_script() {
8232
- local script_path="$1" project_path="$2" roll_bin="$3" log_path="$4"
8233
- mkdir -p "$(dirname "$script_path")"
8234
- local lock="${project_path}/.roll/loop/.ci-loop.lock"
8235
- cat > "$script_path" << CIRUNNER
8236
- #!/bin/bash -l
8237
- set -o pipefail
8238
- # Portable PATH: launchd delivers a bare PATH missing brew/local tools. Idempotent.
8239
- for _d in /opt/homebrew/bin /usr/local/bin /opt/local/bin "\$HOME/.local/bin" "\$HOME/.kimi-code/bin"; do
8240
- case ":\$PATH:" in *":\$_d:"*) ;; *) [ -d "\$_d" ] && PATH="\$_d:\$PATH" ;; esac
8241
- done
8242
- export PATH
8243
- # Single-flight re-entry guard: one CI-loop pass at a time. 5-min cadence;
8244
- # 15-min (900s) staleness so a crashed/hung pass self-heals on the next tick.
8245
- LOCK="${lock}"
8246
- mkdir -p "\$(dirname "\$LOCK")"
8247
- if [ -f "\$LOCK" ]; then
8248
- _pp=""; _pt=""
8249
- IFS=: read -r _pp _pt < "\$LOCK" 2>/dev/null || true
8250
- _now=\$(date -u +%s)
8251
- if [ -n "\$_pp" ] && [ -n "\$_pt" ] && kill -0 "\$_pp" 2>/dev/null && [ "\$((_now - _pt))" -lt 900 ]; then
8252
- exit 0
8253
- fi
8254
- rm -f "\$LOCK"
8255
- fi
8256
- printf '%s:%s\n' "\$\$" "\$(date -u +%s)" > "\$LOCK"
8257
- trap 'rm -f "\$LOCK"' EXIT
8258
- cd "${project_path}" || exit 0
8259
- bash "${roll_bin}" _ci_scan >> "${log_path}" 2>&1 || true
8260
- CIRUNNER
8261
- chmod +x "$script_path"
8262
- }
8263
-
8264
- # _write_alert_loop_runner_script <script_path> <project_path> <roll_bin> <log_path>
8265
- # US-AUTO-046 Phase 2: the script the com.roll.alert.<slug> launchd plist runs
8266
- # every 1 min. Mirrors _write_ci_loop_runner_script — lightweight (no agent,
8267
- # no tmux): portable PATH, a single-flight re-entry lock (pid+ts), then drives
8268
- # the Phase-1 _alert_dispatch consumer via the `roll _alert_dispatch` dispatch.
8269
- # _alert_dispatch reads $_LOOP_ALERT, parses + notifies + records to
8270
- # alert-log.jsonl, then rotates the file. Staleness is 180s (3 ticks at the
8271
- # 1-min cadence) so a crashed/hung pass self-heals quickly.
8272
- _write_alert_loop_runner_script() {
8273
- local script_path="$1" project_path="$2" roll_bin="$3" log_path="$4"
8274
- mkdir -p "$(dirname "$script_path")"
8275
- local lock="${project_path}/.roll/loop/.alert-loop.lock"
8276
- cat > "$script_path" << ALERTRUNNER
8277
- #!/bin/bash -l
8278
- set -o pipefail
8279
- # Portable PATH: launchd delivers a bare PATH missing brew/local tools. Idempotent.
8280
- for _d in /opt/homebrew/bin /usr/local/bin /opt/local/bin "\$HOME/.local/bin" "\$HOME/.kimi-code/bin"; do
8281
- case ":\$PATH:" in *":\$_d:"*) ;; *) [ -d "\$_d" ] && PATH="\$_d:\$PATH" ;; esac
8282
- done
8283
- export PATH
8284
- # Single-flight re-entry guard: one alert-loop pass at a time. 1-min cadence;
8285
- # 180s staleness so a crashed/hung pass self-heals within a few ticks.
8286
- LOCK="${lock}"
8287
- mkdir -p "\$(dirname "\$LOCK")"
8288
- if [ -f "\$LOCK" ]; then
8289
- _pp=""; _pt=""
8290
- IFS=: read -r _pp _pt < "\$LOCK" 2>/dev/null || true
8291
- _now=\$(date -u +%s)
8292
- if [ -n "\$_pp" ] && [ -n "\$_pt" ] && kill -0 "\$_pp" 2>/dev/null && [ "\$((_now - _pt))" -lt 180 ]; then
8293
- exit 0
8294
- fi
8295
- rm -f "\$LOCK"
8296
- fi
8297
- printf '%s:%s\n' "\$\$" "\$(date -u +%s)" > "\$LOCK"
8298
- trap 'rm -f "\$LOCK"' EXIT
8299
- cd "${project_path}" || exit 0
8300
- bash "${roll_bin}" _alert_dispatch >> "${log_path}" 2>&1 || true
8301
- ALERTRUNNER
8302
- chmod +x "$script_path"
8303
- }
8304
-
8305
8337
  # Like _write_runner_script but prepends an active window guard.
8306
8338
  # Silently exits when current hour is outside [active_start, active_end).
8307
8339
  # When tmux is available, wraps the inner command in a detached tmux session
@@ -8559,6 +8591,11 @@ _runs_append() {
8559
8591
  if [ -n "\$_story_field" ]; then
8560
8592
  _stype_field="\${_story_field%%-*}"
8561
8593
  fi
8594
+ # US-LOOP-068: target field — roll-meta for roll-meta stories, empty otherwise
8595
+ local _target_field=""
8596
+ if [ "\${_ROLL_META_TARGET:-0}" = "1" ]; then
8597
+ _target_field="roll-meta"
8598
+ fi
8562
8599
  # US-EVAL-002: compute the objective result_eval block from this cycle's
8563
8600
  # facts via the US-EVAL-001 pure-function rubric. Best-effort — when python3
8564
8601
  # or the scorer script is unavailable the row is written WITHOUT result_eval
@@ -8608,6 +8645,7 @@ _runs_append() {
8608
8645
  --arg tier "\$_tier_field" \\
8609
8646
  --arg fallback_from "\$_fallback_from_field" \\
8610
8647
  --arg story_type "\$_stype_field" \\
8648
+ --arg target "\$_target_field" \\
8611
8649
  --argjson built "\$_built" \\
8612
8650
  --argjson skipped '[]' \\
8613
8651
  --argjson alerts '[]' \\
@@ -8618,6 +8656,7 @@ _runs_append() {
8618
8656
  cycle_id:\$cycle_id, agent:\$agent, tier:\$tier, fallback_from:\$fallback_from,
8619
8657
  story_type:\$story_type, built:\$built, skipped:\$skipped, alerts:\$alerts,
8620
8658
  tcr_count:\$tcr_count, duration_sec:\$duration_sec, phases:\$phases}
8659
+ + (if \$target == "" then {} else {target:\$target} end)
8621
8660
  + (if \$result_eval == null then {} else {result_eval:\$result_eval} end)' \\
8622
8661
  > "\$_tmp" 2>/dev/null || { rm -f "\$_tmp"; return 0; }
8623
8662
  # FIX-157: ensure target exists before append; missing file silently drops
@@ -8849,6 +8888,10 @@ for _orphan_wt in "\${_SHARED_ROOT}/worktrees/${slug}-cycle-"*; do
8849
8888
  ( cd "\$_orphan_wt" && _loop_publish_pr "\$_orphan_branch" "recover orphan \${_orphan_branch}" ) && _orphan_ok=1
8850
8889
  fi
8851
8890
  if [ "\$_orphan_ok" -eq 1 ]; then
8891
+ # US-LOOP-068: if orphan contains a roll-meta worktree, clean it first
8892
+ if [ -d "\${_orphan_wt}/.roll/.git" ]; then
8893
+ _loop_roll_meta_worktree_cleanup "\$_orphan_wt" "\$_orphan_branch" "${project_path}" 2>/dev/null || true
8894
+ fi
8852
8895
  _worktree_cleanup "\$_orphan_wt" "\$_orphan_branch"
8853
8896
  echo "[loop] FIX-040: orphan recovered and cleaned: \$_orphan_branch"
8854
8897
  else
@@ -8856,6 +8899,10 @@ for _orphan_wt in "\${_SHARED_ROOT}/worktrees/${slug}-cycle-"*; do
8856
8899
  fi
8857
8900
  else
8858
8901
  echo "[loop] FIX-040: orphan worktree \$_orphan_wt has no commits; cleaning up"
8902
+ # US-LOOP-068: if orphan contains a roll-meta worktree, clean it first
8903
+ if [ -d "\${_orphan_wt}/.roll/.git" ]; then
8904
+ _loop_roll_meta_worktree_cleanup "\$_orphan_wt" "\$_orphan_branch" "${project_path}" 2>/dev/null || true
8905
+ fi
8859
8906
  _worktree_cleanup "\$_orphan_wt" "\$_orphan_branch"
8860
8907
  fi
8861
8908
  done
@@ -8873,6 +8920,16 @@ if _worktree_fetch_origin main \\
8873
8920
  # because .roll/ is gitignored and the clean clone has no backlog
8874
8921
  # for Claude to read or skill entry points to dispatch to.
8875
8922
  _worktree_sync_meta "\$WT" 2>/dev/null || true
8923
+ # US-LOOP-068: for roll-meta stories, replace the synced .roll/ with a
8924
+ # real roll-meta git worktree so commits land in roll-meta remote.
8925
+ if [ "\${_ROLL_META_TARGET:-0}" = "1" ]; then
8926
+ if _loop_roll_meta_worktree_setup "\$WT" "\$BRANCH" "${project_path}" 2>/dev/null; then
8927
+ echo "[loop] cycle \${CYCLE_ID}: roll-meta worktree \${WT}/.roll on \$BRANCH"
8928
+ else
8929
+ echo "[loop] cycle \${CYCLE_ID}: roll-meta worktree setup failed — falling back to normal"
8930
+ _ROLL_META_TARGET=0
8931
+ fi
8932
+ fi
8876
8933
  echo "[loop] cycle \${CYCLE_ID}: worktree \$WT on \$BRANCH"
8877
8934
  _loop_event cycle_start "\${CYCLE_ID}" "" "" || true
8878
8935
  # US-AGENT-006: per-story routing — pick the next Todo, route to an agent
@@ -8929,6 +8986,15 @@ if _worktree_fetch_origin main \\
8929
8986
  fi
8930
8987
  CYCLE_AGENT="\${ROLL_LOOP_ROUTED_AGENT:-\$(_project_agent)}"
8931
8988
  _loop_event agent_used "\${CYCLE_ID}" "\${CYCLE_AGENT}" "primary" || true
8989
+ # US-LOOP-068: detect roll-meta target after routing is complete
8990
+ _ROLL_META_TARGET=0
8991
+ if [ -n "\$ROLL_LOOP_ROUTED_STORY" ]; then
8992
+ if _loop_is_roll_meta_story "\$ROLL_LOOP_ROUTED_STORY" "\${ROLL_MAIN_PROJECT}/.roll/backlog.md" 2>/dev/null; then
8993
+ _ROLL_META_TARGET=1
8994
+ echo "[loop] story \${ROLL_LOOP_ROUTED_STORY} is roll-meta target"
8995
+ fi
8996
+ fi
8997
+ export ROLL_LOOP_ROLL_META_TARGET="\${_ROLL_META_TARGET}"
8932
8998
  _phase_end worktree_setup ok
8933
8999
  else
8934
9000
  # P3 fix: skip the cycle entirely when worktree isolation fails.
@@ -9071,12 +9137,25 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
9071
9137
  if [ "\$_exit" -ne 0 ]; then
9072
9138
  _cycle_status="failed"
9073
9139
  else
9074
- _cycle_commits_pre=\$(cd "\$WT" && git rev-list --count origin/main..HEAD 2>/dev/null || echo 0)
9140
+ # US-LOOP-068: for roll-meta stories, count commits in the roll-meta worktree
9141
+ if [ "\${_ROLL_META_TARGET:-0}" = "1" ]; then
9142
+ _cycle_commits_pre=\$(cd "\$WT/.roll" && git rev-list --count origin/main..HEAD 2>/dev/null || echo 0)
9143
+ else
9144
+ _cycle_commits_pre=\$(cd "\$WT" && git rev-list --count origin/main..HEAD 2>/dev/null || echo 0)
9145
+ fi
9075
9146
  if [ "\$_cycle_commits_pre" -gt 0 ]; then
9076
9147
  _cycle_status="built"
9077
- _cycle_tcr=\$(cd "\$WT" && git log --oneline origin/main..HEAD -- 2>/dev/null | grep -c ' tcr:' || echo 0)
9148
+ if [ "\${_ROLL_META_TARGET:-0}" = "1" ]; then
9149
+ _cycle_tcr=\$(cd "\$WT/.roll" && git log --oneline origin/main..HEAD -- 2>/dev/null | grep -c ' tcr:' || echo 0)
9150
+ else
9151
+ _cycle_tcr=\$(cd "\$WT" && git log --oneline origin/main..HEAD -- 2>/dev/null | grep -c ' tcr:' || echo 0)
9152
+ fi
9078
9153
  if command -v jq >/dev/null 2>&1; then
9079
- _cycle_built=\$(cd "\$WT" && git diff origin/main -- .roll/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 "[]")
9154
+ if [ "\${_ROLL_META_TARGET:-0}" = "1" ]; then
9155
+ _cycle_built=\$(cd "\$WT/.roll" && 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 "[]")
9156
+ else
9157
+ _cycle_built=\$(cd "\$WT" && git diff origin/main -- .roll/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 "[]")
9158
+ fi
9080
9159
  fi
9081
9160
  fi
9082
9161
  fi
@@ -9097,8 +9176,17 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
9097
9176
  if [ "\$_exit" -eq 0 ]; then
9098
9177
  # Idle cycle — no commits ahead of origin/main means nothing was built;
9099
9178
  # skip publish and reclaim the worktree immediately.
9100
- _cycle_commits=\$(cd "\$WT" && git rev-list --count origin/main..HEAD 2>/dev/null || echo 0)
9179
+ # US-LOOP-068: for roll-meta stories, count commits in roll-meta worktree
9180
+ if [ "\${_ROLL_META_TARGET:-0}" = "1" ]; then
9181
+ _cycle_commits=\$(cd "\$WT/.roll" && git rev-list --count origin/main..HEAD 2>/dev/null || echo 0)
9182
+ else
9183
+ _cycle_commits=\$(cd "\$WT" && git rev-list --count origin/main..HEAD 2>/dev/null || echo 0)
9184
+ fi
9101
9185
  if [ "\$_cycle_commits" -eq 0 ]; then
9186
+ # US-LOOP-068: clean up roll-meta worktree before product worktree
9187
+ if [ "\${_ROLL_META_TARGET:-0}" = "1" ]; then
9188
+ _loop_roll_meta_worktree_cleanup "\$WT" "\$BRANCH" "${project_path}" 2>/dev/null || true
9189
+ fi
9102
9190
  _worktree_cleanup "\$WT" "\$BRANCH"
9103
9191
  _loop_event idle "\${CYCLE_ID}" "" "" || true
9104
9192
  # FIX-F (2026-05-25): explicitly write the terminal "idle" cycle_end +
@@ -9115,12 +9203,42 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
9115
9203
  echo "[loop] cycle \${CYCLE_ID}: idle (no new commits); worktree cleaned"
9116
9204
  else
9117
9205
  _is_doc_only=0
9118
- ( cd "\$WT" && _loop_is_doc_only_change ) && _is_doc_only=1
9206
+ # US-LOOP-068: for roll-meta stories, doc-only check runs in roll-meta worktree
9207
+ if [ "\${_ROLL_META_TARGET:-0}" = "1" ]; then
9208
+ ( cd "\$WT/.roll" && _loop_is_doc_only_change ) && _is_doc_only=1
9209
+ else
9210
+ ( cd "\$WT" && _loop_is_doc_only_change ) && _is_doc_only=1
9211
+ fi
9212
+ # US-LOOP-069: roll-meta boundary guard — abort if a roll-meta story touched product files
9213
+ if ! _loop_guard_roll_meta_boundary "\$WT" "\$ROLL_LOOP_ROUTED_STORY"; then
9214
+ _loop_event cycle_end "\${CYCLE_ID}" "" "failed" || true; _CYCLE_END_WRITTEN=1
9215
+ _phases_guard=\$(_phases_to_json 2>/dev/null); [ -z "\$_phases_guard" ] && _phases_guard='{}'
9216
+ _runs_append "failed" 0 "[]" "\$_phases_guard" 2>/dev/null || true
9217
+ echo "[loop] cycle \${CYCLE_ID}: US-LOOP-069 blocked — roll-meta story touched product files; worktree preserved at \$WT"
9218
+ trap '_inner_cleanup' EXIT
9219
+ exit 0
9220
+ fi
9221
+ # US-LOOP-068: roll-meta test gate — run roll-meta tests when ops/ changed
9222
+ if [ "\${_ROLL_META_TARGET:-0}" = "1" ]; then
9223
+ if ! _loop_roll_meta_test_gate "\$WT"; then
9224
+ _loop_event cycle_end "\${CYCLE_ID}" "" "failed" || true; _CYCLE_END_WRITTEN=1
9225
+ _phases_test=\$(_phases_to_json 2>/dev/null); [ -z "\$_phases_test" ] && _phases_test='{}'
9226
+ _runs_append "failed" 0 "[]" "\$_phases_test" 2>/dev/null || true
9227
+ echo "[loop] cycle \${CYCLE_ID}: roll-meta test gate failed; worktree preserved at \$WT"
9228
+ trap '_inner_cleanup' EXIT
9229
+ exit 0
9230
+ fi
9231
+ fi
9119
9232
  _phase_begin publish_push
9120
- if [ "\$_is_doc_only" -eq 1 ]; then
9121
- ( cd "\$WT" && _loop_publish_doc_pr "\$BRANCH" "doc: loop cycle \${CYCLE_ID}" )
9233
+ # US-LOOP-068: roll-meta stories publish to roll-meta remote
9234
+ if [ "\${_ROLL_META_TARGET:-0}" = "1" ]; then
9235
+ _loop_roll_meta_publish "\$WT" "\$BRANCH" "loop cycle \${CYCLE_ID}"
9122
9236
  else
9123
- ( cd "\$WT" && _loop_publish_pr "\$BRANCH" "loop cycle \${CYCLE_ID}" )
9237
+ if [ "\$_is_doc_only" -eq 1 ]; then
9238
+ ( cd "\$WT" && _loop_publish_doc_pr "\$BRANCH" "doc: loop cycle \${CYCLE_ID}" )
9239
+ else
9240
+ ( cd "\$WT" && _loop_publish_pr "\$BRANCH" "loop cycle \${CYCLE_ID}" )
9241
+ fi
9124
9242
  fi
9125
9243
  _publish_status=\$?
9126
9244
  if [ "\$_publish_status" -eq 0 ]; then
@@ -9140,6 +9258,10 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
9140
9258
  # so dashboard renders #NN ✓/↩/… correctly. Must run while branch ref
9141
9259
  # is still resolvable on remote — gh pr view <branch> needs the head ref.
9142
9260
  _loop_emit_pr_final "\$BRANCH" 2>/dev/null || true
9261
+ # US-LOOP-068: clean up roll-meta worktree before product worktree
9262
+ if [ "\${_ROLL_META_TARGET:-0}" = "1" ]; then
9263
+ _loop_roll_meta_worktree_cleanup "\$WT" "\$BRANCH" "${project_path}" 2>/dev/null || true
9264
+ fi
9143
9265
  _worktree_cleanup "\$WT" "\$BRANCH"
9144
9266
  _loop_event cycle_end "\${CYCLE_ID}" "" "done" || true; _CYCLE_END_WRITTEN=1
9145
9267
  # US-OBS-014: normal-completion path — push a fresh status snapshot.
@@ -9147,15 +9269,42 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
9147
9269
  _phase_end cleanup ok
9148
9270
  echo "[loop] cycle \${CYCLE_ID}: published; worktree cleaned"
9149
9271
  elif [ "\$_publish_status" -eq 2 ]; then
9150
- if ( cd "${project_path}" && _worktree_merge_back "\$BRANCH" ); then
9272
+ # US-LOOP-068: for roll-meta, merge_back doesn't apply skip straight to
9273
+ # the orphan-push safety net (roll-meta commits live on a different remote).
9274
+ _merged_back=0
9275
+ if [ "\${_ROLL_META_TARGET:-0}" = "1" ]; then
9276
+ :
9277
+ elif ( cd "${project_path}" && _worktree_merge_back "\$BRANCH" ); then
9151
9278
  _worktree_cleanup "\$WT" "\$BRANCH"
9152
9279
  # US-LOOP-005 T3: gh unavailable + ff merge_back OK → cycle_end done
9153
9280
  _loop_event cycle_end "\${CYCLE_ID}" "" "done" || true; _CYCLE_END_WRITTEN=1
9154
9281
  echo "[loop] cycle \${CYCLE_ID}: gh unavailable; merged via ff and cleaned up"
9282
+ _merged_back=1
9283
+ fi
9284
+ # FIX-039: gh unavailable + merge_back failed — push orphan branch+tag to origin
9285
+ # as final safety net so code is never local-only before worktree cleanup.
9286
+ # Skip entirely when merge_back already finalized the cycle (the worktree is
9287
+ # gone, so re-pushing from \$WT would spuriously fail and raise a false alert).
9288
+ # US-LOOP-068: for roll-meta, push from roll-meta worktree
9289
+ if [ "\$_merged_back" -ne 1 ]; then
9290
+ _orphan_tag="loop-orphan-\${CYCLE_ID}"
9291
+ if [ "\${_ROLL_META_TARGET:-0}" = "1" ]; then
9292
+ if ( cd "\$WT/.roll" && git push origin "\$BRANCH" 2>/dev/null \
9293
+ && git tag "\$_orphan_tag" 2>/dev/null \
9294
+ && git push origin "\$_orphan_tag" 2>/dev/null ); then
9295
+ _loop_roll_meta_worktree_cleanup "\$WT" "\$BRANCH" "${project_path}" 2>/dev/null || true
9296
+ _worktree_cleanup "\$WT" "\$BRANCH"
9297
+ # US-LOOP-005 T4: gh unavailable + orphan push OK → cycle_end orphan
9298
+ _loop_event cycle_end "\${CYCLE_ID}" "" "orphan" || true; _CYCLE_END_WRITTEN=1
9299
+ _worktree_alert "cycle \${CYCLE_ID}: gh+merge_back failed; FIX-039 pushed orphan+tag \${_orphan_tag}; worktree cleaned"
9300
+ echo "[loop] cycle \${CYCLE_ID}: FIX-039: orphan branch+tag \${_orphan_tag} pushed; worktree cleaned"
9301
+ else
9302
+ # US-LOOP-005 T5: gh unavailable + all failed → cycle_end failed
9303
+ _loop_event cycle_end "\${CYCLE_ID}" "" "failed" || true; _CYCLE_END_WRITTEN=1
9304
+ _worktree_alert "cycle \${CYCLE_ID}: gh+merge_back+push all failed; worktree preserved at \$WT"
9305
+ echo "[loop] cycle \${CYCLE_ID}: all publish paths failed; worktree preserved at \$WT"
9306
+ fi
9155
9307
  else
9156
- # FIX-039: gh unavailable + merge_back failed — push orphan branch+tag to origin
9157
- # as final safety net so code is never local-only before worktree cleanup.
9158
- _orphan_tag="loop-orphan-\${CYCLE_ID}"
9159
9308
  if ( cd "\$WT" && git push origin "\$BRANCH" 2>/dev/null \
9160
9309
  && git tag "\$_orphan_tag" 2>/dev/null \
9161
9310
  && git push origin "\$_orphan_tag" 2>/dev/null ); then
@@ -9171,23 +9320,43 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
9171
9320
  echo "[loop] cycle \${CYCLE_ID}: all publish paths failed; worktree preserved at \$WT"
9172
9321
  fi
9173
9322
  fi
9323
+ fi
9174
9324
  else
9175
9325
  # FIX-039: PR publish failed — push orphan branch+tag to origin as safety net.
9176
9326
  # (_loop_publish_pr may have already pushed the branch; git push is idempotent.)
9327
+ # US-LOOP-068: for roll-meta, push from roll-meta worktree
9177
9328
  _orphan_tag="loop-orphan-\${CYCLE_ID}"
9178
- if ( cd "\$WT" && git push origin "\$BRANCH" 2>/dev/null \
9179
- && git tag "\$_orphan_tag" 2>/dev/null \
9180
- && git push origin "\$_orphan_tag" 2>/dev/null ); then
9181
- _worktree_cleanup "\$WT" "\$BRANCH"
9182
- # US-LOOP-005 T6: PR publish failed + orphan push OK → cycle_end orphan
9183
- _loop_event cycle_end "\${CYCLE_ID}" "" "orphan" || true; _CYCLE_END_WRITTEN=1
9184
- _worktree_alert "cycle \${CYCLE_ID}: PR publish failed; FIX-039 pushed orphan+tag \${_orphan_tag}; worktree cleaned"
9185
- echo "[loop] cycle \${CYCLE_ID}: FIX-039: orphan branch+tag \${_orphan_tag} pushed; worktree cleaned"
9329
+ if [ "\${_ROLL_META_TARGET:-0}" = "1" ]; then
9330
+ if ( cd "\$WT/.roll" && git push origin "\$BRANCH" 2>/dev/null \
9331
+ && git tag "\$_orphan_tag" 2>/dev/null \
9332
+ && git push origin "\$_orphan_tag" 2>/dev/null ); then
9333
+ _loop_roll_meta_worktree_cleanup "\$WT" "\$BRANCH" "${project_path}" 2>/dev/null || true
9334
+ _worktree_cleanup "\$WT" "\$BRANCH"
9335
+ # US-LOOP-005 T6: PR publish failed + orphan push OK → cycle_end orphan
9336
+ _loop_event cycle_end "\${CYCLE_ID}" "" "orphan" || true; _CYCLE_END_WRITTEN=1
9337
+ _worktree_alert "cycle \${CYCLE_ID}: PR publish failed; FIX-039 pushed orphan+tag \${_orphan_tag}; worktree cleaned"
9338
+ echo "[loop] cycle \${CYCLE_ID}: FIX-039: orphan branch+tag \${_orphan_tag} pushed; worktree cleaned"
9339
+ else
9340
+ # US-LOOP-005 T7: PR publish failed + orphan push failed → cycle_end failed
9341
+ _loop_event cycle_end "\${CYCLE_ID}" "" "failed" || true; _CYCLE_END_WRITTEN=1
9342
+ _worktree_alert "cycle \${CYCLE_ID}: PR publish failed; worktree preserved at \$WT (branch \$BRANCH)"
9343
+ echo "[loop] cycle \${CYCLE_ID}: PR publish failed; worktree preserved at \$WT"
9344
+ fi
9186
9345
  else
9187
- # US-LOOP-005 T7: PR publish failed + orphan push failed cycle_end failed
9188
- _loop_event cycle_end "\${CYCLE_ID}" "" "failed" || true; _CYCLE_END_WRITTEN=1
9189
- _worktree_alert "cycle \${CYCLE_ID}: PR publish failed; worktree preserved at \$WT (branch \$BRANCH)"
9190
- echo "[loop] cycle \${CYCLE_ID}: PR publish failed; worktree preserved at \$WT"
9346
+ if ( cd "\$WT" && git push origin "\$BRANCH" 2>/dev/null \
9347
+ && git tag "\$_orphan_tag" 2>/dev/null \
9348
+ && git push origin "\$_orphan_tag" 2>/dev/null ); then
9349
+ _worktree_cleanup "\$WT" "\$BRANCH"
9350
+ # US-LOOP-005 T6: PR publish failed + orphan push OK → cycle_end orphan
9351
+ _loop_event cycle_end "\${CYCLE_ID}" "" "orphan" || true; _CYCLE_END_WRITTEN=1
9352
+ _worktree_alert "cycle \${CYCLE_ID}: PR publish failed; FIX-039 pushed orphan+tag \${_orphan_tag}; worktree cleaned"
9353
+ echo "[loop] cycle \${CYCLE_ID}: FIX-039: orphan branch+tag \${_orphan_tag} pushed; worktree cleaned"
9354
+ else
9355
+ # US-LOOP-005 T7: PR publish failed + orphan push failed → cycle_end failed
9356
+ _loop_event cycle_end "\${CYCLE_ID}" "" "failed" || true; _CYCLE_END_WRITTEN=1
9357
+ _worktree_alert "cycle \${CYCLE_ID}: PR publish failed; worktree preserved at \$WT (branch \$BRANCH)"
9358
+ echo "[loop] cycle \${CYCLE_ID}: PR publish failed; worktree preserved at \$WT"
9359
+ fi
9191
9360
  fi
9192
9361
  fi
9193
9362
  fi
@@ -9507,15 +9676,11 @@ _install_launchd_plists() {
9507
9676
 
9508
9677
  # US-AUTO-044: "pr" is the 4th service — a 5-min PR Loop (period=5, empty hour
9509
9678
  # → StartInterval=300). No skill (it drives _loop_pr_inbox, not an agent).
9510
- # US-AUTO-045: "ci" is the 5th service — a 5-min CI Loop (period=5, empty hour
9511
- # → StartInterval=300). No skill (it drives _ci_scan, not an agent).
9512
- # US-AUTO-046: "alert" is the 6th service — a 1-min Alert Loop (period=1, empty
9513
- # hour → StartInterval=60). No skill (it drives _alert_dispatch, not an agent).
9514
- local services=("loop" "dream" "brief" "pr" "ci" "alert")
9515
- local skill_names=("roll-loop" "roll-.dream" "roll-brief" "" "" "")
9516
- local periods=("$loop_period" "60" "60" "5" "5" "1")
9517
- local offsets=("$loop_offset" "$dream_minute" "$brief_minute" "0" "0" "0")
9518
- local hours=("" "$dream_hour" "$brief_hour" "" "" "")
9679
+ local services=("loop" "dream" "pr")
9680
+ local skill_names=("roll-loop" "roll-.dream" "")
9681
+ local periods=("$loop_period" "60" "5")
9682
+ local offsets=("$loop_offset" "$dream_minute" "0")
9683
+ local hours=("" "$dream_hour" "")
9519
9684
 
9520
9685
  local updated=0
9521
9686
  local slug; slug=$(_project_slug "$project_path")
@@ -9548,22 +9713,8 @@ _install_launchd_plists() {
9548
9713
  local pr_log="${project_path}/.roll/loop/pr.log"
9549
9714
  mkdir -p "${project_path}/.roll/loop"
9550
9715
  _write_pr_loop_runner_script "$runner" "$project_path" "${ROLL_PKG_DIR}/bin/roll" "$pr_log"
9551
- elif [[ "$svc" == "ci" ]]; then
9552
- # US-AUTO-045 Phase 2: lightweight CI Loop runner — drives _ci_scan every
9553
- # 5 min (no agent, no tmux). Records run timing, auto-reruns transient
9554
- # failures, and surfaces flaky / degradation stories.
9555
- local ci_log="${project_path}/.roll/loop/ci.log"
9556
- mkdir -p "${project_path}/.roll/loop"
9557
- _write_ci_loop_runner_script "$runner" "$project_path" "${ROLL_PKG_DIR}/bin/roll" "$ci_log"
9558
- elif [[ "$svc" == "alert" ]]; then
9559
- # US-AUTO-046 Phase 2: lightweight Alert Loop runner — drives _alert_dispatch
9560
- # every 1 min (no agent, no tmux). Consumes _LOOP_ALERT: parse → notify →
9561
- # record to alert-log.jsonl → rotate the file.
9562
- local alert_log="${project_path}/.roll/loop/alert.log"
9563
- mkdir -p "${project_path}/.roll/loop"
9564
- _write_alert_loop_runner_script "$runner" "$project_path" "${ROLL_PKG_DIR}/bin/roll" "$alert_log"
9565
9716
  else
9566
- # IDEA-051: dream/brief cron logs are project-local, mirroring loop (FIX-139).
9717
+ # dream cron log is project-local, mirroring loop (FIX-139).
9567
9718
  local log="${project_path}/.roll/${svc}/cron.log"
9568
9719
  mkdir -p "${project_path}/.roll/${svc}"
9569
9720
  _write_runner_script "$runner" "$project_path" "cd \"${project_path}\" && ${cmd}" "$log"
@@ -9761,7 +9912,7 @@ _loop_on() {
9761
9912
  # does not disturb the overrides DB.
9762
9913
  local uid; uid=$(id -u)
9763
9914
  local all_loaded=true
9764
- for svc in loop dream brief pr ci alert; do
9915
+ for svc in loop dream pr; do
9765
9916
  local label; label=$(_launchd_label "$svc" "$project_path")
9766
9917
  local plist; plist=$(_launchd_plist_path "$svc" "$project_path")
9767
9918
  if ! _launchd_is_loaded "$label"; then
@@ -9828,7 +9979,7 @@ _loop_off() {
9828
9979
  if [[ "$(uname)" == "Darwin" ]]; then
9829
9980
  local any_loaded=false
9830
9981
  local _skip_off; _launchd_should_skip_registry && _skip_off=1 || _skip_off=0
9831
- for svc in loop dream brief pr ci alert; do
9982
+ for svc in loop dream pr; do
9832
9983
  local label; label=$(_launchd_label "$svc" "$project_path")
9833
9984
  if _launchd_is_loaded "$label"; then
9834
9985
  any_loaded=true
@@ -9843,7 +9994,7 @@ _loop_off() {
9843
9994
  fi
9844
9995
  local slug; slug=$(_project_slug "$project_path")
9845
9996
  local uid; uid=$(id -u)
9846
- for svc in loop dream brief pr ci alert; do
9997
+ for svc in loop dream pr; do
9847
9998
  rm -f "${_SHARED_ROOT}/${svc}/run-${slug}.sh"
9848
9999
  # FIX-081: reverse the FIX-059 auto-bootstrap guard. `_install_launchd_plists`
9849
10000
  # writes `launchctl disable gui/<UID>/<label>` for every brand-new plist
@@ -10179,7 +10330,7 @@ _legacy_loop_status() {
10179
10330
  echo ""
10180
10331
  if [[ "$(uname)" == "Darwin" ]]; then
10181
10332
  echo -e " Services Agent: ${CYAN}${agent}${NC}"
10182
- for svc in loop dream brief pr ci alert; do
10333
+ for svc in loop dream pr; do
10183
10334
  local state; state=$(_launchd_svc_state "$svc" "$project_path")
10184
10335
  if [[ "$svc" == "loop" ]] && $_is_paused; then
10185
10336
  local _paused_at; _paused_at=$(grep '^paused_at:' "$_LOOP_STATE" 2>/dev/null | awk '{print $2}' | tr -d '"')
@@ -10193,7 +10344,7 @@ _legacy_loop_status() {
10193
10344
  echo -e " ${YELLOW}loop ⏸ paused${NC}${_dur} run: roll loop resume"
10194
10345
  else
10195
10346
  local _tick_age=""
10196
- case "$svc" in pr|ci|alert)
10347
+ case "$svc" in pr)
10197
10348
  _tick_age=$(_loop_tick_age "$svc")
10198
10349
  [ -n "$_tick_age" ] && _tick_age=" tick ${_tick_age}"
10199
10350
  esac
@@ -11375,7 +11526,7 @@ _loop_pr_heal_self() {
11375
11526
 
11376
11527
  local agent; agent="$(_project_agent 2>/dev/null)"; agent="${agent:-claude}"
11377
11528
 
11378
- ( echo "$BASHPID" > "$lock"
11529
+ ( echo "${BASHPID:-$$}" > "$lock"
11379
11530
  _loop_pr_do_heal "$num" "$head_ref" "$slug" "$agent" >/dev/null 2>&1
11380
11531
  rm -f "$lock"
11381
11532
  ) &
@@ -11434,7 +11585,10 @@ _loop_pr_do_heal() {
11434
11585
  _loop_pr_merge_approved() {
11435
11586
  local num="$1" ci_state="$2" mergeable="$3" slug="$4"
11436
11587
  [ -n "$num" ] && [ -n "$slug" ] || return 0
11437
- [ "$ci_state" = "success" ] && [ "$mergeable" = "MERGEABLE" ] || return 0
11588
+ [ "$ci_state" = "success" ] || return 0
11589
+ # MERGEABLE (GraphQL mergeable) or CLEAN (mergeStateStatus) — see
11590
+ # _loop_pr_merge_self_eager for why both spellings must be accepted.
11591
+ case "$mergeable" in MERGEABLE|CLEAN) ;; *) return 0 ;; esac
11438
11592
  if gh -R "$slug" pr merge "$num" --squash --delete-branch >/dev/null 2>&1; then
11439
11593
  info "PR #${num}: human-approved + CI green — merged"
11440
11594
  else
@@ -11490,7 +11644,7 @@ EOF
11490
11644
  # can enforce them at Step 2 (story pickup). Pure functions, no side effects.
11491
11645
  #
11492
11646
  # BACKLOG row format (relevant fragments):
11493
- # | [US-AUTO-033](...) | desc `depends-on:US-AUTO-037` `manual-only:true` | 📋 Todo |
11647
+ # | [US-AUTO-033](...) | desc `depends-on:US-AUTO-037` | 📋 Todo |
11494
11648
  # | FIX-100 | desc `depends-on:US-A,US-B` | 📋 Todo |
11495
11649
  #
11496
11650
  # Row matching is anchored on `^| [?<id>\b` so a story-id appearing in some
@@ -11539,15 +11693,14 @@ _loop_check_depends_on() {
11539
11693
  return 0
11540
11694
  }
11541
11695
 
11542
- # _loop_is_manual_only <story-id> [backlog-path]
11543
- # Exit 0: story's own row carries any manual-only:<value> tag.
11544
- # Exit 1: tag absent, story-id not found, or backlog missing.
11545
- #
11546
- # FIX-109: loop used to accept only the literal `manual-only:true` value;
11547
- # anything else (e.g. `manual-only:roll-meta`, `manual-only:sean-yao`) was
11548
- # ignored and the story got picked. Now any non-empty `manual-only:<value>`
11549
- # qualifies — lets users tag rows reserved for a specific human or scope.
11550
- _loop_is_manual_only() {
11696
+ # FIX-172: detect whether a story routes its output to roll-meta — path-based,
11697
+ # never tag-based. A story is a roll-meta target iff its declared deliverable
11698
+ # **Files:** include a path under .roll/ that is NOT a status-management file
11699
+ # (.roll/backlog.md, .roll/features/). Every cycle flips its own backlog/feature
11700
+ # status under .roll/, so "touches .roll/" must NOT flag a product story; only a
11701
+ # genuine roll-meta deliverable (e.g. .roll/ops/watch.sh) counts.
11702
+ # Returns 0 when the story delivers to roll-meta.
11703
+ _loop_is_roll_meta_story() {
11551
11704
  local id="$1"
11552
11705
  local backlog="${2:-.roll/backlog.md}"
11553
11706
  [ -n "$id" ] || return 1
@@ -11557,7 +11710,30 @@ _loop_is_manual_only() {
11557
11710
  row=$(grep -E "^\| \[?${id}[]| ]" "$backlog" | head -1)
11558
11711
  [ -n "$row" ] || return 1
11559
11712
 
11560
- echo "$row" | grep -qE 'manual-only:[A-Za-z0-9_.-]+'
11713
+ # Resolve the feature file from the row's markdown link: [id](path.md#anchor).
11714
+ # Links appear in two relative forms — ".roll/features/x.md" (rel. to product
11715
+ # root) or "features/x.md" (rel. to .roll). Normalise from "features/" onward
11716
+ # and resolve against the backlog's own directory (the roll-meta root).
11717
+ local link featrel metadir feat
11718
+ link=$(printf '%s\n' "$row" | grep -oE '\([^)]+\.md' | head -1 | sed -E 's/^\(//')
11719
+ [ -n "$link" ] || return 1
11720
+ featrel=$(printf '%s\n' "$link" | sed -E 's#^.*(features/.*)#\1#')
11721
+ metadir=$(dirname "$backlog")
11722
+ feat="${metadir}/${featrel}"
11723
+ [ -f "$feat" ] || return 1
11724
+
11725
+ # Extract this story's **Files:** block, then test for a roll-meta deliverable
11726
+ # path (under .roll/ but not the universal status-management files).
11727
+ awk -v id="$id" '
11728
+ $0 ~ ("^## " id "( |$)") {insec=1; next}
11729
+ insec && /^## / {exit}
11730
+ insec && /^\*\*Files:\*\*/ {infiles=1; next}
11731
+ insec && infiles && /^\*\*/ {exit}
11732
+ insec && infiles {print}
11733
+ ' "$feat" \
11734
+ | grep -oE '\.roll/[A-Za-z0-9_./-]+' \
11735
+ | grep -vE '^\.roll/backlog\.md$|^\.roll/features/' \
11736
+ | grep -q .
11561
11737
  }
11562
11738
 
11563
11739
  # US-AUTO-034: PR-first inbox — loop processes open PRs before scanning BACKLOG.
@@ -11577,40 +11753,25 @@ _loop_is_manual_only() {
11577
11753
 
11578
11754
  # _loop_pr_classify <head_ref> <human_review_state> <ci_state> <mergeable_state>
11579
11755
  # Prints one of:
11580
- # loop_self
11581
- # blocked_human_request_changes
11582
- # blocked_human_approved
11583
- # stale
11584
- # eligible
11585
- # Exit 0 always — callers parse the printed token.
11756
+ # ci_red — CI failed → heal
11757
+ # stale — needs rebase / conflicting / behind
11758
+ # ready — CI green + clean → merge
11759
+ # Human review intentionally irrelevant — CI is the only gate.
11586
11760
  _loop_pr_classify() {
11587
11761
  local head_ref="${1:-}"
11588
11762
  local human_review="${2:-}"
11589
11763
  local ci_state="${3:-}"
11590
11764
  local mergeable="${4:-}"
11591
11765
 
11592
- case "$head_ref" in
11593
- loop/*)
11594
- # US-LOOP-049: loop/* PRs with CI failure get their own classification
11595
- # so _loop_pr_inbox can route them to the PR hot-fix path.
11596
- if [[ "$ci_state" == "failure" ]]; then
11597
- echo "loop_self_ci_red"; return 0
11598
- fi
11599
- echo "loop_self"; return 0
11600
- ;;
11601
- esac
11602
-
11603
- case "$human_review" in
11604
- CHANGES_REQUESTED) echo "blocked_human_request_changes"; return 0 ;;
11605
- APPROVED) echo "blocked_human_approved"; return 0 ;;
11766
+ case "$mergeable" in
11767
+ BEHIND|DIRTY|CONFLICTING) echo "stale"; return 0 ;;
11606
11768
  esac
11607
11769
 
11608
- if [ "$ci_state" = "failure" ] || [ "$mergeable" = "CONFLICTING" ] || [ "$mergeable" = "BEHIND" ]; then
11609
- echo "stale"
11610
- return 0
11770
+ if [ "$ci_state" = "failure" ]; then
11771
+ echo "ci_red"; return 0
11611
11772
  fi
11612
11773
 
11613
- echo "eligible"
11774
+ echo "ready"
11614
11775
  }
11615
11776
 
11616
11777
  # _loop_pr_rebase_circuit <pr_number>
@@ -11748,6 +11909,9 @@ _loop_pr_rebase_stale() {
11748
11909
  fi
11749
11910
 
11750
11911
  git fetch origin "$head_ref" 2>/dev/null || return 0
11912
+ # Reset local tracking branch to the freshly-fetched remote state
11913
+ # before rebasing, otherwise force-push destroys commits pushed by others.
11914
+ git checkout -B "$head_ref" "origin/$head_ref" 2>/dev/null || return 0
11751
11915
 
11752
11916
  # FIX-159: save original branch so we can restore it unconditionally
11753
11917
  local _orig
@@ -11795,7 +11959,12 @@ _loop_pr_rebase_stale() {
11795
11959
  # spec name. Both coexist until Phase 2 collapses the inbox path.
11796
11960
  _loop_pr_merge_self_eager() {
11797
11961
  local num="$1" ci_state="$2" mergeable="$3" slug="$4"
11798
- [ "$ci_state" = "success" ] && [ "$mergeable" = "MERGEABLE" ] || return 0
11962
+ [ "$ci_state" = "success" ] || return 0
11963
+ # Accept both the GraphQL `mergeable` enum (MERGEABLE) and the
11964
+ # `mergeStateStatus` enum (CLEAN). _loop_pr_inbox feeds mergeStateStatus, so
11965
+ # a ready-to-merge PR arrives as CLEAN in production — without it the eager
11966
+ # merge never fired and self-PRs silently waited on repo-level auto-merge.
11967
+ case "$mergeable" in MERGEABLE|CLEAN) ;; *) return 0 ;; esac
11799
11968
  gh -R "$slug" pr merge "$num" --squash --delete-branch >/dev/null 2>&1 \
11800
11969
  && info "PR #${num}: loop_self CI green — merged" \
11801
11970
  || warn "PR #${num}: loop_self merge failed — left open"
@@ -11847,7 +12016,7 @@ _loop_pr_inbox() {
11847
12016
  # All gates cleared (bot-approved + CI green + no conflicts) → merge directly.
11848
12017
  # Relying on repo-level auto-merge being configured is not reliable; loop
11849
12018
  # owns the decision here since it already ran the review.
11850
- if [ "$ci_state" = "success" ] && [ "$mergeable" = "MERGEABLE" ]; then
12019
+ if [ "$ci_state" = "success" ] && { [ "$mergeable" = "MERGEABLE" ] || [ "$mergeable" = "CLEAN" ]; }; then
11851
12020
  gh -R "$slug" pr merge "$num" --squash --delete-branch >/dev/null 2>&1 \
11852
12021
  && info "PR #${num}: bot-approved + CI green — merged" \
11853
12022
  || warn "PR #${num}: merge failed (bot-approved + CI green) — left open"
@@ -11865,30 +12034,29 @@ _loop_pr_inbox() {
11865
12034
  verdict=$(_loop_pr_classify "$head_ref" "$human_review" "$ci_state" "$mergeable")
11866
12035
 
11867
12036
  case "$verdict" in
11868
- loop_self)
11869
- _loop_pr_merge_self_eager "$num" "$ci_state" "$mergeable" "$slug"
11870
- ;;
11871
- loop_self_ci_red)
11872
- # US-LOOP-062a: a red loop/* PR (classified by US-LOOP-049) is now
11873
- # background-healed: bounded retries via heal budget + dynamic agent,
11874
- # falling back to the deduped [TYPE:loop-pr-ci-red] ALERT (FIX-158's
11875
- # surfacing) when heal is disabled/exhausted. Re-wires US-LOOP-050.
12037
+ ci_red)
11876
12038
  _loop_pr_heal_self "$num" "$head_ref" "$slug" || true
11877
12039
  ;;
11878
- blocked_human_request_changes)
11879
- : # skip — last human review requested changes; wait for the author
11880
- ;;
11881
- blocked_human_approved)
11882
- # US-LOOP-062b: human approved — merge directly when green + mergeable
11883
- # (don't wait for repo auto-merge, which may be off).
11884
- _loop_pr_merge_approved "$num" "$ci_state" "$mergeable" "$slug" || true
11885
- ;;
11886
12040
  stale)
11887
12041
  _loop_pr_rebase_circuit "$num" || true
11888
- _loop_pr_rebase_stale "$num" "$head_ref" || true
12042
+ if _loop_pr_rebase_stale "$num" "$head_ref" || true; then
12043
+ # Re-fetch PR state after rebase — if now clean, merge immediately.
12044
+ local _re_view
12045
+ _re_view=$(gh -R "$slug" pr view "$num" --json mergeStateStatus,statusCheckRollup 2>/dev/null) || true
12046
+ if [ -n "$_re_view" ]; then
12047
+ local _re_ci _re_mb
12048
+ _re_ci=$(echo "$_re_view" | jq -r '
12049
+ if (.statusCheckRollup | length) == 0 then ""
12050
+ elif any(.statusCheckRollup[]?; .conclusion == "FAILURE") then "failure"
12051
+ elif all(.statusCheckRollup[]?; .conclusion == "SUCCESS" or .conclusion == "SKIPPED") then "success"
12052
+ else "pending" end' 2>/dev/null)
12053
+ _re_mb=$(echo "$_re_view" | jq -r '.mergeStateStatus // ""' 2>/dev/null)
12054
+ _loop_pr_merge_self_eager "$num" "$_re_ci" "$_re_mb" "$slug"
12055
+ fi
12056
+ fi
11889
12057
  ;;
11890
- eligible)
11891
- _loop_pr_review_external "$num" || true
12058
+ ready)
12059
+ _loop_pr_merge_self_eager "$num" "$ci_state" "$mergeable" "$slug"
11892
12060
  ;;
11893
12061
  esac
11894
12062
 
@@ -11902,7 +12070,7 @@ _loop_pr_inbox() {
11902
12070
  #
11903
12071
  # Loop-safe building blocks for the future PR Loop (com.roll.pr.<slug>.plist,
11904
12072
  # 5-min cadence). Phase 1 ships the helpers + tests only; Phase 2 (runner /
11905
- # plist / main-loop wiring) is manual-only and out of scope here.
12073
+ # plist / main-loop wiring) is wired by hand and out of scope here.
11906
12074
  #
11907
12075
  # All helpers are lenient on a missing `gh` binary or any gh failure: they
11908
12076
  # return 0 so a loop iteration never aborts on a single bad PR. State writes
@@ -12086,558 +12254,13 @@ _loop_pr_route() {
12086
12254
  return 0
12087
12255
  }
12088
12256
 
12089
- # US-AUTO-045 Phase 1: dedicated CI Loop helpers (loop-safe pure additions).
12090
- #
12091
- # These six helpers collect CI timing data, classify failures, auto-rerun
12092
- # transient flakes, and surface flaky / degradation signals as backlog
12093
- # entries. They are NOT yet wired into any runner or launchd plist — that is
12094
- # Phase 2 (manual-only:true). Each is unit-tested in
12095
- # tests/unit/roll_loop_ci_loop.bats with gh stubbed. Do not delete or inline.
12096
- #
12097
- # State lives under project-local .roll/state/:
12098
- # ci-timing.jsonl append-only NDJSON, one line per recorded CI run
12099
- # ci-rerun-state.yaml minimal YAML: rerun attempt count per run_id
12100
- # _LOOP_ALERT is the existing shared alert file (real failures, rerun limits).
12101
-
12102
- # _ci_state_dir
12103
- # Echo the project-local CI state directory, creating it if needed.
12104
- # Resolves relative to the current working dir's .roll/ (tests cd into a
12105
- # sandbox; the live loop runner cds into the project root).
12106
- _ci_state_dir() {
12257
+ # _alert_log_file echo path to alert-log.jsonl (used by `roll alert log` CLI).
12258
+ _alert_log_file() {
12107
12259
  local dir=".roll/state"
12108
12260
  mkdir -p "$dir" 2>/dev/null || true
12109
- echo "$dir"
12110
- }
12111
-
12112
- # _ci_record_timing <run_json>
12113
- # Parse one `gh run list --json ...` object and append a flat NDJSON line to
12114
- # ci-timing.jsonl. Idempotent: a run_id already present in the file is
12115
- # skipped. Duration is computed from createdAt → updatedAt (gh exposes no
12116
- # native duration field). Returns 0 always (loop-safe).
12117
- _ci_record_timing() {
12118
- local json="$1"
12119
- [ -n "$json" ] || return 0
12120
-
12121
- local run_id workflow conclusion status created updated
12122
- run_id=$(echo "$json" | jq -r '.databaseId // ""' 2>/dev/null)
12123
- [ -n "$run_id" ] || return 0
12124
-
12125
- local dir; dir=$(_ci_state_dir)
12126
- local file="${dir}/ci-timing.jsonl"
12127
-
12128
- # Idempotency: skip if this run_id is already recorded.
12129
- if [ -f "$file" ] && grep -q "\"run_id\":${run_id}," "$file" 2>/dev/null; then
12130
- return 0
12131
- fi
12132
-
12133
- workflow=$(echo "$json" | jq -r '.workflowName // .name // ""' 2>/dev/null)
12134
- conclusion=$(echo "$json" | jq -r '.conclusion // ""' 2>/dev/null)
12135
- status=$(echo "$json" | jq -r '.status // ""' 2>/dev/null)
12136
- created=$(echo "$json" | jq -r '.createdAt // ""' 2>/dev/null)
12137
- updated=$(echo "$json" | jq -r '.updatedAt // ""' 2>/dev/null)
12138
-
12139
- # Duration in seconds from ISO-8601 timestamps; 0 if either is missing or
12140
- # unparseable. `date -j` (BSD) and `date -d` (GNU) differ — try both.
12141
- local dur=0 c_epoch u_epoch
12142
- if [ -n "$created" ] && [ -n "$updated" ]; then
12143
- c_epoch=$(_ci_iso_to_epoch "$created")
12144
- u_epoch=$(_ci_iso_to_epoch "$updated")
12145
- if [ -n "$c_epoch" ] && [ -n "$u_epoch" ] && [ "$u_epoch" -ge "$c_epoch" ] 2>/dev/null; then
12146
- dur=$((u_epoch - c_epoch))
12147
- fi
12148
- fi
12149
-
12150
- printf '{"run_id":%s,"workflow":"%s","conclusion":"%s","status":"%s","duration_sec":%s,"recorded_at":"%s"}\n' \
12151
- "$run_id" "$workflow" "$conclusion" "$status" "$dur" \
12152
- "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$file"
12153
- return 0
12154
- }
12155
-
12156
- # _ci_iso_to_epoch <iso8601>
12157
- # Convert an ISO-8601 UTC timestamp (2026-05-30T10:00:00Z) to epoch seconds.
12158
- # Echoes nothing on failure. Handles both BSD (macOS) and GNU date.
12159
- _ci_iso_to_epoch() {
12160
- local iso="$1"
12161
- [ -n "$iso" ] || return 0
12162
- local e
12163
- # GNU date
12164
- e=$(date -u -d "$iso" +%s 2>/dev/null) && { echo "$e"; return 0; }
12165
- # BSD date (strip trailing Z, parse explicit format)
12166
- local trimmed="${iso%Z}"
12167
- e=$(date -u -j -f "%Y-%m-%dT%H:%M:%S" "$trimmed" +%s 2>/dev/null) && { echo "$e"; return 0; }
12168
- return 0
12169
- }
12170
-
12171
- # _ci_classify_failure <run_id>
12172
- # Inspect `gh run view <id> --log-failed` and classify the failure as
12173
- # "transient" (infra flake: network, timeout, runner death) or "real"
12174
- # (genuine test/build failure). Echoes "transient" or "real".
12175
- # Empty / unavailable logs default to "real" (fail safe — don't auto-rerun
12176
- # something we can't read).
12177
- _ci_classify_failure() {
12178
- local run_id="$1"
12179
- [ -n "$run_id" ] || { echo "real"; return 0; }
12180
- local slug; _gh_resolve slug 2>/dev/null || slug=""
12181
-
12182
- local log
12183
- if [ -n "$slug" ]; then
12184
- log=$(gh -R "$slug" run view "$run_id" --log-failed 2>/dev/null)
12185
- else
12186
- log=$(gh run view "$run_id" --log-failed 2>/dev/null)
12187
- fi
12188
-
12189
- # Transient signatures: network/infra failures that a rerun typically clears.
12190
- if echo "$log" | grep -qiE 'ETIMEDOUT|ECONNRESET|ENOTFOUND|EAI_AGAIN|shutdown signal|runner.*(error|lost|terminated)|The runner has received a shutdown|503 Service|connection reset|TLS handshake|i/o timeout|could not resolve host'; then
12191
- echo "transient"
12192
- return 0
12193
- fi
12194
- echo "real"
12195
- return 0
12196
- }
12197
-
12198
- # _ci_rerun_state_file
12199
- # Echo path to ci-rerun-state.yaml (creating the dir).
12200
- _ci_rerun_state_file() {
12201
- local dir; dir=$(_ci_state_dir)
12202
- echo "${dir}/ci-rerun-state.yaml"
12203
- }
12204
-
12205
- # _ci_rerun_attempts <run_id>
12206
- # Echo the recorded rerun attempt count for <run_id> (0 if none).
12207
- _ci_rerun_attempts() {
12208
- local run_id="$1"
12209
- local file; file=$(_ci_rerun_state_file)
12210
- [ -f "$file" ] || { echo 0; return 0; }
12211
- local n
12212
- n=$(awk -v key="\"${run_id}\":" '$1 == key { print $2 }' "$file" 2>/dev/null | head -1)
12213
- case "$n" in
12214
- ''|*[!0-9]*) echo 0 ;;
12215
- *) echo "$n" ;;
12216
- esac
12217
- }
12218
-
12219
- # _ci_rerun_state_write <run_id> <attempts>
12220
- # Set the attempt count for <run_id> in ci-rerun-state.yaml. Minimal YAML
12221
- # writer (we own the schema): one `"<run_id>": <n>` line per run.
12222
- _ci_rerun_state_write() {
12223
- local run_id="$1" attempts="$2"
12224
- local file; file=$(_ci_rerun_state_file)
12225
- [ -f "$file" ] || : > "$file"
12226
- local tmp; tmp=$(mktemp)
12227
- awk -v key="\"${run_id}\":" -v val="$attempts" '
12228
- $1 == key { print key " " val; found=1; next }
12229
- { print }
12230
- END { if (!found) print key " " val }
12231
- ' "$file" > "$tmp" && mv "$tmp" "$file"
12232
- }
12233
-
12234
- # _ci_rerun_transient <run_id>
12235
- # Auto-rerun a transient CI failure, capped at 2 attempts. attempt<2 →
12236
- # `gh run rerun`; attempt>=2 → write an error ALERT. Echoes the action taken
12237
- # ("rerun" / "limit"). Loop-safe (returns 0).
12238
- _ci_rerun_transient() {
12239
- local run_id="$1"
12240
- [ -n "$run_id" ] || return 0
12241
- local slug; _gh_resolve slug 2>/dev/null || slug=""
12242
-
12243
- local attempts; attempts=$(_ci_rerun_attempts "$run_id")
12244
- if [ "$attempts" -ge 2 ]; then
12245
- local alert="$_LOOP_ALERT"
12246
- mkdir -p "$(dirname "$alert")" 2>/dev/null || true
12247
- printf '[%s] [error] [TYPE:ci-rerun-limit] CI rerun reached limit: run #%s (%s attempts)\n' \
12248
- "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$run_id" "$attempts" >> "$alert"
12249
- echo "limit"
12250
- return 0
12251
- fi
12252
-
12253
- if [ -n "$slug" ]; then
12254
- gh -R "$slug" run rerun "$run_id" >/dev/null 2>&1 || true
12255
- else
12256
- gh run rerun "$run_id" >/dev/null 2>&1 || true
12257
- fi
12258
- _ci_rerun_state_write "$run_id" "$((attempts + 1))"
12259
- echo "rerun"
12260
- return 0
12261
- }
12262
-
12263
- # _ci_open_story <type> <title>
12264
- # Append a FIX or US row to .roll/backlog.md's `| ID | Description | Status |`
12265
- # table. Idempotent: if a 📋 Todo row with the same title already exists, skip
12266
- # (echo "skip"). New IDs auto-increment from the max existing <TYPE>-NNN.
12267
- # Echoes the new ID on success, "skip" if already queued.
12268
- _ci_open_story() {
12269
- local type="$1" title="$2"
12270
- [ -n "$type" ] && [ -n "$title" ] || return 0
12271
-
12272
- # Resolve the backlog file (project-local).
12273
- local backlog=".roll/backlog.md"
12274
- [ -f "$backlog" ] || { echo "skip"; return 0; }
12275
-
12276
- # Idempotency: same title already queued as Todo → skip.
12277
- if grep -F "$title" "$backlog" 2>/dev/null | grep -q '📋 Todo'; then
12278
- echo "skip"
12279
- return 0
12280
- fi
12281
-
12282
- # Auto-increment: find the max existing <TYPE>-NNN id.
12283
- local prefix max next
12284
- prefix=$(echo "$type" | tr '[:lower:]' '[:upper:]')
12285
- max=$(grep -oE "${prefix}-[0-9]+" "$backlog" 2>/dev/null \
12286
- | sed "s/${prefix}-//" \
12287
- | sort -n | tail -1)
12288
- case "$max" in ''|*[!0-9]*) max=0 ;; esac
12289
- # 10# prefix forces base-10: a zero-padded id like 008/009 would otherwise be
12290
- # parsed as octal and either misnumber (010→8) or error ("value too great").
12291
- next=$((10#$max + 1))
12292
- local id
12293
- id=$(printf '%s-%03d' "$prefix" "$next")
12294
-
12295
- printf '| %s | %s | 📋 Todo |\n' "$id" "$title" >> "$backlog"
12296
- echo "$id"
12297
- return 0
12298
- }
12299
-
12300
- # _ci_detect_flaky
12301
- # Scan the last 20 ci-timing.jsonl lines, group by workflow, and flag any
12302
- # workflow whose recent runs have a 20%–80% failure rate (2..8 failures of
12303
- # the last 10) as flaky — opening a FIX story. Returns 0 (loop-safe).
12304
- _ci_detect_flaky() {
12305
- local dir; dir=$(_ci_state_dir)
12306
- local file="${dir}/ci-timing.jsonl"
12307
- [ -f "$file" ] || return 0
12308
-
12309
- # Per workflow: count total + failures over the most recent 10 records.
12310
- # awk reads last 20 lines (tail), keeps last 10 per workflow. Output is
12311
- # collected into a variable (not piped to `while`) so an empty result or
12312
- # an intermediate nonzero exit cannot trip a caller's ERR trap.
12313
- local flaky_wfs
12314
- flaky_wfs=$(tail -n 20 "$file" 2>/dev/null | awk '
12315
- {
12316
- # crude field extraction from flat JSON line
12317
- wf=""; concl="";
12318
- if (match($0, /"workflow":"[^"]*"/)) { wf=substr($0,RSTART+12,RLENGTH-13) }
12319
- if (match($0, /"conclusion":"[^"]*"/)) { concl=substr($0,RSTART+14,RLENGTH-15) }
12320
- if (wf=="") next
12321
- order[wf]=order[wf]" "NR
12322
- val[wf"|"NR]=concl
12323
- }
12324
- END {
12325
- for (wf in order) {
12326
- n=split(order[wf], idx, " ")
12327
- # keep most recent 10
12328
- start=1; if (n-10 > 0) start=n-9
12329
- total=0; fail=0
12330
- for (i=start;i<=n;i++) {
12331
- if (idx[i]=="") continue
12332
- total++
12333
- c=val[wf"|"idx[i]]
12334
- if (c=="failure" || c=="timed_out" || c=="cancelled") fail++
12335
- }
12336
- if (total>=4 && fail>=2 && fail<=8 && fail*100 <= total*80 && fail*100 >= total*20) {
12337
- print wf
12338
- }
12339
- }
12340
- }
12341
- ' || true)
12342
-
12343
- local wf
12344
- for wf in $flaky_wfs; do
12345
- [ -n "$wf" ] && _ci_open_story FIX "flaky: ${wf}" >/dev/null || true
12346
- done
12347
- return 0
12348
- }
12349
-
12350
- # _ci_detect_degradation
12351
- # Scan the last 20 ci-timing.jsonl lines, compute mean duration per workflow,
12352
- # and open a US story when a workflow crosses its threshold:
12353
- # unit* > 300s (5 min)
12354
- # integration* > 900s (15 min)
12355
- # Returns 0 (loop-safe).
12356
- _ci_detect_degradation() {
12357
- local dir; dir=$(_ci_state_dir)
12358
- local file="${dir}/ci-timing.jsonl"
12359
- [ -f "$file" ] || return 0
12360
-
12361
- local degraded
12362
- degraded=$(tail -n 20 "$file" 2>/dev/null | awk '
12363
- {
12364
- wf=""; dur=0;
12365
- if (match($0, /"workflow":"[^"]*"/)) { wf=substr($0,RSTART+12,RLENGTH-13) }
12366
- if (match($0, /"duration_sec":[0-9]+/)) { dur=substr($0,RSTART+15,RLENGTH-15)+0 }
12367
- if (wf=="") next
12368
- sum[wf]+=dur; cnt[wf]++
12369
- }
12370
- END {
12371
- for (wf in sum) {
12372
- if (cnt[wf]==0) continue
12373
- avg=sum[wf]/cnt[wf]
12374
- lc=tolower(wf)
12375
- if (index(lc,"unit")>0 && avg>300) { print wf "\t" int(avg) }
12376
- else if (index(lc,"integration")>0 && avg>900) { print wf "\t" int(avg) }
12377
- }
12378
- }
12379
- ' || true)
12380
-
12381
- local line wf avg
12382
- # IFS=newline so each "wf<TAB>avg" record is one iteration; field-split on TAB.
12383
- local _oifs="$IFS"
12384
- IFS='
12385
- '
12386
- for line in $degraded; do
12387
- IFS="$_oifs"
12388
- wf=$(printf '%s' "$line" | cut -f1)
12389
- avg=$(printf '%s' "$line" | cut -f2)
12390
- [ -n "$wf" ] && _ci_open_story US "CI degradation: ${wf} avg ${avg}s exceeds threshold" >/dev/null || true
12391
- IFS='
12392
- '
12393
- done
12394
- IFS="$_oifs"
12395
- return 0
12396
- }
12397
-
12398
- # _ci_scan
12399
- # US-AUTO-045 Phase 2 orchestrator: the entry the CI Loop runner drives every
12400
- # 5 min. Lists recent `main`-branch CI runs, records each run's timing, and on
12401
- # a `failure` conclusion classifies it — auto-rerunning transient infra
12402
- # flakes. After the loop it runs the flaky + degradation detectors over the
12403
- # accumulated history. Lenient on gh unavailability (missing / failed list →
12404
- # return 0) so the service never errors out a tick.
12405
- _ci_scan() {
12406
- local slug; _gh_resolve slug 2>/dev/null || { _loop_write_tick "ci" "idle" "gh_unavailable"; return 0; }
12407
-
12408
- local runs_json
12409
- runs_json=$(gh -R "$slug" run list --branch main \
12410
- --json databaseId,workflowName,name,conclusion,status,createdAt,updatedAt \
12411
- 2>/dev/null) || { _loop_write_tick "ci" "idle" "gh_error"; return 0; }
12412
- [ -n "$runs_json" ] || { _loop_write_tick "ci" "idle" "empty_response"; return 0; }
12413
-
12414
- # An empty list ("[]") still falls through to the detectors below: they run
12415
- # over accumulated history, not just this tick's runs.
12416
- local count; count=$(echo "$runs_json" | jq 'length' 2>/dev/null || echo 0)
12417
- case "$count" in ''|*[!0-9]*) count=0 ;; esac
12418
-
12419
- local i=0
12420
- while [ "$i" -lt "$count" ]; do
12421
- local run_json conclusion run_id
12422
- run_json=$(echo "$runs_json" | jq -c ".[$i]" 2>/dev/null)
12423
- _ci_record_timing "$run_json"
12424
-
12425
- conclusion=$(echo "$run_json" | jq -r '.conclusion // ""' 2>/dev/null)
12426
- if [ "$conclusion" = "failure" ]; then
12427
- run_id=$(echo "$run_json" | jq -r '.databaseId // ""' 2>/dev/null)
12428
- if [ -n "$run_id" ]; then
12429
- local kind; kind=$(_ci_classify_failure "$run_id")
12430
- [ "$kind" = "transient" ] && _ci_rerun_transient "$run_id" >/dev/null
12431
- fi
12432
- fi
12433
- i=$((i + 1))
12434
- done
12435
-
12436
- _ci_detect_flaky
12437
- _ci_detect_degradation
12438
- _loop_write_tick "ci" "acted" "scan_done"
12439
- return 0
12440
- }
12441
-
12442
- # ═══════════════════════════════════════════════════════════════════════════════
12443
- # US-AUTO-046 Phase 1: dedicated Alert Loop helpers (loop-safe, pure bash)
12444
- # ═══════════════════════════════════════════════════════════════════════════════
12445
- # These consume the existing $_LOOP_ALERT file — until now a write-only dumb file
12446
- # that every loop appends to but nobody reads. The Alert Loop turns it into a
12447
- # real consumer: parse → dedup (1h per category) → notify (error always) →
12448
- # log → rotate. They are NOT yet wired into any runner or launchd plist — that
12449
- # is Phase 2 (manual-only:true). Each is unit-tested in
12450
- # tests/unit/roll_loop_alert_loop.bats with _notify stubbed. Do not delete or
12451
- # inline.
12452
- #
12453
- # State lives under project-local .roll/state/ (shared with the CI Loop):
12454
- # alert-log.jsonl append-only NDJSON, one line per consumed alert
12455
- # $_LOOP_ALERT.prev is the rotated copy (kept for debugging).
12456
- #
12457
- # Line format ($_LOOP_ALERT) — new tagged format, old format read-compatible:
12458
- # [2026-05-26T10:00:00] [error] [TYPE:ci-real-failure] CI failed: run #123
12459
- # [2026-05-26T10:00:00] some legacy message → level=warn category=legacy
12460
-
12461
- # _alert_parse_file [file]
12462
- # Parse each non-empty line of $_LOOP_ALERT (or <file>) into a TAB-separated
12463
- # record `ts<TAB>level<TAB>category<TAB>message`, one per output line. The
12464
- # leading `[ts]` is extracted when present; optional `[level]` and
12465
- # `[TYPE:category]` tags follow. Untagged (legacy) lines default to
12466
- # level=warn, category=legacy, with the whole remainder as the message.
12467
- # Markdown headers / ack footers (lines starting with `#` or `**`) are skipped.
12468
- # Echoes nothing for a missing/empty file. Loop-safe (returns 0).
12469
- _alert_parse_file() {
12470
- local file="${1:-$_LOOP_ALERT}"
12471
- [ -n "$file" ] && [ -f "$file" ] || return 0
12472
-
12473
- awk '
12474
- {
12475
- line=$0
12476
- # skip blank lines and markdown chrome (headers, ack footers)
12477
- if (line ~ /^[ \t]*$/) next
12478
- if (line ~ /^[ \t]*#/) next
12479
- if (line ~ /^[ \t]*\*\*/) next
12480
-
12481
- ts=""; level=""; category=""
12482
-
12483
- # leading [timestamp]
12484
- if (match(line, /^\[[^]]*\]/)) {
12485
- ts=substr(line, RSTART+1, RLENGTH-2)
12486
- line=substr(line, RSTART+RLENGTH)
12487
- sub(/^[ \t]+/, "", line)
12488
- }
12489
- # optional [level] (error|warn|info)
12490
- if (match(line, /^\[(error|warn|info)\]/)) {
12491
- level=substr(line, RSTART+1, RLENGTH-2)
12492
- line=substr(line, RSTART+RLENGTH)
12493
- sub(/^[ \t]+/, "", line)
12494
- }
12495
- # optional [TYPE:category]
12496
- if (match(line, /^\[TYPE:[^]]*\]/)) {
12497
- category=substr(line, RSTART+6, RLENGTH-7)
12498
- line=substr(line, RSTART+RLENGTH)
12499
- sub(/^[ \t]+/, "", line)
12500
- }
12501
-
12502
- # legacy "ALERT:" prefix on the remaining message — strip the keyword
12503
- sub(/^ALERT:[ \t]*/, "", line)
12504
-
12505
- if (level=="") level="warn"
12506
- if (category=="") category="legacy"
12507
-
12508
- printf "%s\t%s\t%s\t%s\n", ts, level, category, line
12509
- }
12510
- ' "$file"
12511
- return 0
12512
- }
12513
-
12514
- # _alert_log_file
12515
- # Echo path to .roll/state/alert-log.jsonl (creating the dir). Reuses the
12516
- # CI Loop's _ci_state_dir so both loops share one project-local state dir.
12517
- _alert_log_file() {
12518
- local dir; dir=$(_ci_state_dir)
12519
12261
  echo "${dir}/alert-log.jsonl"
12520
12262
  }
12521
12263
 
12522
- # _alert_should_notify <category> <level>
12523
- # Decide whether an alert should fire a notification.
12524
- # error → always true (immediate, never throttled)
12525
- # warn | info → true unless a same-category alert was already notified
12526
- # within the last hour (rate-limit / dedup)
12527
- # The 1h window is read from alert-log.jsonl (notified=1 entries only).
12528
- # Echoes "true" / "false".
12529
- _alert_should_notify() {
12530
- local category="$1" level="$2"
12531
- [ "$level" = "error" ] && { echo "true"; return 0; }
12532
-
12533
- local file; file=$(_alert_log_file)
12534
- [ -f "$file" ] || { echo "true"; return 0; }
12535
-
12536
- local now; now=$(date -u +%s)
12537
- # Most recent notified=1 entry for this category → its recorded_at epoch.
12538
- local last
12539
- last=$(grep -F "\"category\":\"${category}\"" "$file" 2>/dev/null \
12540
- | grep -F '"notified":1' \
12541
- | tail -1 \
12542
- | sed -n 's/.*"recorded_at":"\([^"]*\)".*/\1/p')
12543
- [ -n "$last" ] || { echo "true"; return 0; }
12544
-
12545
- local last_epoch; last_epoch=$(_ci_iso_to_epoch "$last")
12546
- [ -n "$last_epoch" ] || { echo "true"; return 0; }
12547
-
12548
- # Within 1h (3600s) → throttle (false); otherwise allow.
12549
- if [ "$((now - last_epoch))" -lt 3600 ] 2>/dev/null; then
12550
- echo "false"
12551
- else
12552
- echo "true"
12553
- fi
12554
- return 0
12555
- }
12556
-
12557
- # _alert_write_log <ts> <level> <category> <message> <notified>
12558
- # Append one NDJSON record to alert-log.jsonl. <notified> is the literal
12559
- # string "true"/"false" (or 1/0) and is normalized to 1/0. recorded_at is the
12560
- # consumption time (UTC), distinct from the alert's own <ts>. Quotes in the
12561
- # message are escaped so the line stays valid JSON. Loop-safe (returns 0).
12562
- _alert_write_log() {
12563
- local ts="$1" level="$2" category="$3" message="$4" notified="$5"
12564
- local file; file=$(_alert_log_file)
12565
-
12566
- local n=0
12567
- case "$notified" in true|1) n=1 ;; esac
12568
-
12569
- # Escape backslashes then double-quotes for JSON string safety.
12570
- local esc
12571
- esc=$(printf '%s' "$message" | sed 's/\\/\\\\/g; s/"/\\"/g')
12572
-
12573
- printf '{"ts":"%s","level":"%s","category":"%s","message":"%s","notified":%s,"recorded_at":"%s"}\n' \
12574
- "$ts" "$level" "$category" "$esc" "$n" \
12575
- "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$file"
12576
- return 0
12577
- }
12578
-
12579
- # _alert_rotate [file]
12580
- # Snapshot $_LOOP_ALERT (or <file>) to <file>.prev and truncate it in place.
12581
- # Idempotent: a missing source is a no-op (the .prev from a prior run is
12582
- # left untouched). Loop-safe (returns 0).
12583
- #
12584
- # US-AUTO-046 (kimi peer-review Q2): copy+truncate instead of mv. `mv` swaps
12585
- # the inode at the path, so a producer loop (main/pr/ci) that opened its `>>`
12586
- # fd *before* the rotation but writes *after* it would land in `.prev` and be
12587
- # silently lost. Copying keeps the original inode at the path; the subsequent
12588
- # `:>` truncates that same inode, so any concurrent appender's fd still points
12589
- # at the live alert file and its write is read on the next 1-min tick.
12590
- _alert_rotate() {
12591
- local file="${1:-$_LOOP_ALERT}"
12592
- [ -n "$file" ] || return 0
12593
- if [ -f "$file" ]; then
12594
- cat "$file" > "${file}.prev" 2>/dev/null || true
12595
- : > "$file"
12596
- fi
12597
- return 0
12598
- }
12599
-
12600
- # _alert_dispatch [file]
12601
- # Main consumer entry point. Parse $_LOOP_ALERT → for each alert decide
12602
- # notify → fire _notify + record to alert-log.jsonl → rotate the file.
12603
- # A missing/empty alert file is a no-op (no rotate, no log). Loop-safe.
12604
- _alert_dispatch() {
12605
- local file="${1:-$_LOOP_ALERT}"
12606
- [ -n "$file" ] && [ -f "$file" ] || { _loop_write_tick "alert" "idle" "no_file"; return 0; }
12607
- # Empty file → nothing to consume, leave it in place.
12608
- [ -s "$file" ] || { _loop_write_tick "alert" "idle" "empty_file"; return 0; }
12609
-
12610
- local parsed; parsed=$(_alert_parse_file "$file")
12611
- [ -n "$parsed" ] || { _alert_rotate "$file"; _loop_write_tick "alert" "idle" "no_parsed"; return 0; }
12612
-
12613
- local line ts level category message notify
12614
- local _oifs="$IFS"
12615
- IFS='
12616
- '
12617
- for line in $parsed; do
12618
- IFS="$_oifs"
12619
- ts=$(printf '%s' "$line" | cut -f1)
12620
- level=$(printf '%s' "$line" | cut -f2)
12621
- category=$(printf '%s' "$line" | cut -f3)
12622
- message=$(printf '%s' "$line" | cut -f4-)
12623
-
12624
- notify=$(_alert_should_notify "$category" "$level")
12625
- if [ "$notify" = "true" ]; then
12626
- _notify "roll alert: ${level}" "${message}" || true
12627
- _alert_write_log "$ts" "$level" "$category" "$message" "true"
12628
- else
12629
- _alert_write_log "$ts" "$level" "$category" "$message" "false"
12630
- fi
12631
- IFS='
12632
- '
12633
- done
12634
- IFS="$_oifs"
12635
-
12636
- _alert_rotate "$file"
12637
- _loop_write_tick "alert" "acted" "dispatch_done"
12638
- return 0
12639
- }
12640
-
12641
12264
  # FIX-070: flip a story row in the main repo's .roll/backlog.md between
12642
12265
  # 📋 Todo and 🔨 In Progress. The cycle worktree is gitignored at .roll/,
12643
12266
  # so editing the worktree copy + committing leaves no trace in git — and
@@ -12856,7 +12479,13 @@ _skill_write_self_score() {
12856
12479
  *) err "_skill_write_self_score: verdict must be good / ok / regression (got '$verdict')"; return 1 ;;
12857
12480
  esac
12858
12481
 
12482
+ # FIX-176: anchor notes to <project>/.roll/notes regardless of cwd. When
12483
+ # invoked with cwd already inside the .roll dir (e.g. a self-score run from
12484
+ # within .roll), the bare ".roll/notes" doubled to .roll/.roll/notes.
12859
12485
  local notes_dir=".roll/notes"
12486
+ if [ "$(basename "$PWD")" = ".roll" ]; then
12487
+ notes_dir="notes"
12488
+ fi
12860
12489
  mkdir -p "$notes_dir"
12861
12490
  local ts; ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
12862
12491
  local date_part="${ts%%T*}"
@@ -13113,7 +12742,7 @@ _changelog_audit_gate() {
13113
12742
  # US-AUTO-036: worktree helpers (loop-safe pure additions).
13114
12743
  #
13115
12744
  # Phase 1 of worktree isolation — these helpers are NOT yet called by
13116
- # runner.sh. US-AUTO-037 (manual-only) wires them into
12745
+ # runner.sh. US-AUTO-037 (wired by hand) wires them into
13117
12746
  # _write_loop_runner_script. Do not delete or inline; they are unit-tested
13118
12747
  # in tests/unit/roll_worktree.bats.
13119
12748
 
@@ -13214,6 +12843,58 @@ _worktree_sync_meta() {
13214
12843
  fi
13215
12844
  }
13216
12845
 
12846
+ # US-LOOP-068: set up .roll/ inside the product worktree as a roll-meta git worktree.
12847
+ # Must be called after the product worktree is created.
12848
+ # Returns 0 on success, 1 on failure.
12849
+ _loop_roll_meta_worktree_setup() {
12850
+ local wt="$1" branch="$2" project_path="$3"
12851
+ local meta_repo="${project_path}/.roll"
12852
+ [ -d "$meta_repo/.git" ] || { echo "[loop] roll-meta setup: ${meta_repo} is not a git repo"; return 1; }
12853
+ rm -rf "${wt}/.roll"
12854
+ git -C "$meta_repo" worktree add "${wt}/.roll" -b "$branch" "origin/main"
12855
+ }
12856
+
12857
+ # US-LOOP-068: clean up the roll-meta worktree inside the product worktree.
12858
+ _loop_roll_meta_worktree_cleanup() {
12859
+ local wt="$1" branch="$2" project_path="$3"
12860
+ local meta_repo="${project_path}/.roll"
12861
+ [ -d "$meta_repo/.git" ] || return 0
12862
+ git -C "$meta_repo" worktree remove --force "${wt}/.roll" 2>/dev/null || true
12863
+ rm -rf "${wt}/.roll" 2>/dev/null || true
12864
+ git -C "$meta_repo" branch -D "$branch" 2>/dev/null || true
12865
+ }
12866
+
12867
+ # US-LOOP-068: publish a PR from the roll-meta worktree.
12868
+ _loop_roll_meta_publish() {
12869
+ local wt="$1" branch="$2" title="$3"
12870
+ (cd "${wt}/.roll" && _loop_publish_pr "$branch" "$title")
12871
+ }
12872
+
12873
+ # US-LOOP-068: run roll-meta test gate when .roll/ops/ files are modified.
12874
+ # Returns 0 when no ops changes or tests pass; 1 when tests fail.
12875
+ _loop_roll_meta_test_gate() {
12876
+ local wt="$1"
12877
+ local _ops_changed
12878
+ _ops_changed=$(cd "${wt}/.roll" && git diff --name-only origin/main..HEAD 2>/dev/null | grep '^ops/' || true)
12879
+ [ -n "$_ops_changed" ] || return 0
12880
+ if ! command -v bats >/dev/null 2>&1; then
12881
+ echo "[loop] roll-meta ops/ changed but bats not installed — skipping test gate"
12882
+ return 0
12883
+ fi
12884
+ local _test_dir="${wt}/.roll/ops/tests"
12885
+ if [ ! -d "$_test_dir" ]; then
12886
+ echo "[loop] roll-meta ops/ changed but no $_test_dir found — skipping test gate"
12887
+ return 0
12888
+ fi
12889
+ echo "[loop] roll-meta test gate: bats ${_test_dir}"
12890
+ if ! bats "${_test_dir}" 2>/dev/null; then
12891
+ echo "[loop] roll-meta test gate failed"
12892
+ return 1
12893
+ fi
12894
+ echo "[loop] roll-meta test gate passed"
12895
+ return 0
12896
+ }
12897
+
13217
12898
  # _worktree_merge_back <branch>
13218
12899
  # Caller must be in the main worktree (cwd = main). Steps:
13219
12900
  # 1. git pull --ff-only origin main (sync local main with remote)
@@ -13412,8 +13093,8 @@ refs/heads/claude/*"
13412
13093
  # v1 type/est/risk routing). lib/agent_routes_lint.py is no longer invoked.
13413
13094
 
13414
13095
  # FIX-146: per-story eligibility gate, reusable for pick and re-validation.
13415
- # Exit 0 when the story id is eligible to be worked (📋 Todo, not manual-only,
13416
- # deps satisfied, no open PR). Exit 1 otherwise.
13096
+ # Exit 0 when the story id is eligible to be worked (📋 Todo, deps satisfied,
13097
+ # no open PR). Exit 1 otherwise.
13417
13098
  # Args: <story-id> [backlog-path] [open-pr-titles]
13418
13099
  _loop_story_is_eligible() {
13419
13100
  local id="$1"
@@ -13431,13 +13112,10 @@ _loop_story_is_eligible() {
13431
13112
  _status=$(printf '%s\n' "$row" | awk -F'|' '{for(i=NF;i>=1;i--) if($i ~ /[^ \t]/) {gsub(/^[ \t]+|[ \t]+$/, "", $i); print $i; exit}}')
13432
13113
  [ "$_status" = "📋 Todo" ] || return 1
13433
13114
 
13434
- # Gate 1: manual-only
13435
- _loop_is_manual_only "$id" "$backlog" 2>/dev/null && return 1
13436
-
13437
- # Gate 2: depends-on
13115
+ # Gate 1: depends-on
13438
13116
  _loop_check_depends_on "$id" "$backlog" >/dev/null 2>&1 || return 1
13439
13117
 
13440
- # Gate 3 (FIX-141): skip if an open PR already references this story id.
13118
+ # Gate 2 (FIX-141): skip if an open PR already references this story id.
13441
13119
  if [ -n "$open_pr_titles" ] && printf '%s\n' "$open_pr_titles" | grep -qE "${id}([^0-9A-Za-z]|$)"; then
13442
13120
  return 1
13443
13121
  fi
@@ -13448,9 +13126,8 @@ _loop_story_is_eligible() {
13448
13126
  # US-AGENT-006: pick the next eligible 📋 Todo story from .roll/backlog.md
13449
13127
  # applying the same gates as the roll-loop SKILL Step 2:
13450
13128
  # 1. Status = 📋 Todo
13451
- # 2. NOT manual-only:* tagged
13452
- # 3. depends-on:* (if any) all Done
13453
- # 4. Priority order: FIX > US > REFACTOR
13129
+ # 2. depends-on:* (if any) all ✅ Done
13130
+ # 3. Priority order: FIX > US > REFACTOR
13454
13131
  #
13455
13132
  # stdout: chosen story id (single line)
13456
13133
  # exit 0 when picked, 1 when nothing eligible.
@@ -13947,6 +13624,38 @@ _loop_is_doc_only_change() {
13947
13624
  return 0
13948
13625
  }
13949
13626
 
13627
+ # _loop_guard_roll_meta_boundary <worktree> <story_id>
13628
+ # US-LOOP-069: for roll-meta-target stories (deliverable under .roll/), verify
13629
+ # that no product-repo tracked files were modified in the worktree. Returns 0 if
13630
+ # safe (or not a roll-meta story), returns 1 if product files were touched and
13631
+ # writes ALERT. FIX-172: roll-meta-ness is path-based, not tag-based.
13632
+ _loop_guard_roll_meta_boundary() {
13633
+ local wt="$1" story_id="$2"
13634
+ [ -n "$story_id" ] || return 0
13635
+ [ -d "$wt" ] || return 0
13636
+
13637
+ local _backlog="${ROLL_MAIN_PROJECT:-.}/.roll/backlog.md"
13638
+ [ -f "$_backlog" ] || return 0
13639
+
13640
+ _loop_is_roll_meta_story "$story_id" "$_backlog" || return 0
13641
+
13642
+ local _changed _violations
13643
+ _changed=$(cd "$wt" && git diff --name-only origin/main..HEAD 2>/dev/null || true)
13644
+ [ -n "$_changed" ] || return 0
13645
+
13646
+ _violations=$(printf '%s\n' "$_changed" | grep -v '^\.roll/' || true)
13647
+ if [ -n "$_violations" ]; then
13648
+ local _alert
13649
+ _alert="US-LOOP-069 guard blocked roll-meta story ${story_id}"
13650
+ _alert="${_alert}"$'\n'"Touched product files (only .roll/ is allowed):"
13651
+ _alert="${_alert}"$'\n'"${_violations}"
13652
+ _worktree_alert "$_alert"
13653
+ return 1
13654
+ fi
13655
+
13656
+ return 0
13657
+ }
13658
+
13950
13659
  # _loop_publish_doc_pr <branch> [title]
13951
13660
  # Like _loop_publish_pr but merges immediately with --admin (no CI wait).
13952
13661
  # For doc-only changes where CI is not meaningful.
@@ -14099,7 +13808,7 @@ _loop_monitor() {
14099
13808
  dream_sched=$(printf "%02d:%02d" "$dream_hour" "$dream_minute")
14100
13809
  brief_sched=$(printf "%02d:%02d" "$brief_hour" "$brief_minute")
14101
13810
 
14102
- local svcs=("loop" "dream" "brief")
13811
+ local svcs=("loop" "dream" "pr")
14103
13812
  local scheds=("$loop_sched" "$dream_sched" "$brief_sched")
14104
13813
  for i in "${!svcs[@]}"; do
14105
13814
  local svc="${svcs[$i]}" schedule="${scheds[$i]}"
@@ -15528,11 +15237,10 @@ main() {
15528
15237
  test) cmd_test "$@" ;;
15529
15238
  prices) cmd_prices "$@" ;;
15530
15239
  changelog) cmd_changelog "$@" ;;
15240
+ consistency) cmd_consistency "$@" ;;
15531
15241
  config) cmd_config "$@" ;;
15532
15242
  _loop_render_exit_summary) _loop_render_exit_summary "$@" ;;
15533
15243
  _loop_pr_inbox) _loop_pr_inbox "$@" ;;
15534
- _ci_scan) _ci_scan "$@" ;;
15535
- _alert_dispatch) _alert_dispatch "$@" ;;
15536
15244
  version|--version|-v) echo "roll v${VERSION}" ;;
15537
15245
  help|--help|-h) _help "$@" ;;
15538
15246
  "") [[ -f ".roll/backlog.md" ]] && _home || { _help; _show_changelog; } ;;