@seanyao/roll 2.602.4 → 2.603.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.
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.4"
7
+ VERSION="2.603.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"
@@ -5519,26 +5519,20 @@ import prices_fetcher as pf
5519
5519
 
5520
5520
  snapshot_dir = os.path.join(lib_dir, "prices")
5521
5521
 
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
5522
  if vendor:
5530
- default_url = VENDOR_URLS.get(vendor)
5531
- if not default_url:
5523
+ if vendor not in pf.VENDOR_REGISTRY:
5532
5524
  print(f"$(msg prices_refresh.roll_unknown_vendor_vendor)", file=sys.stderr)
5533
5525
  print(f"$(msg prices_refresh.roll_known_vendors_join_sorted_vendor)", file=sys.stderr)
5534
5526
  sys.exit(1)
5535
- url = override_url or default_url
5536
- pf.DEFAULT_SOURCE_URL = url
5537
5527
  else:
5538
- url = override_url or pf.DEFAULT_SOURCE_URL
5528
+ vendor = "anthropic"
5539
5529
 
5540
5530
  try:
5541
- action, changes = pf.refresh(snapshot_dir=snapshot_dir, url=url)
5531
+ action, changes = pf.refresh(
5532
+ snapshot_dir=snapshot_dir,
5533
+ vendor=vendor,
5534
+ url=override_url or None,
5535
+ )
5542
5536
  except pf.FetchError as exc:
5543
5537
  print(f"[roll] fetch failed: {exc}", file=sys.stderr)
5544
5538
  print("[roll] keeping existing snapshot, no changes written 保留旧快照,未写入新文件",
@@ -5577,42 +5571,127 @@ cmd_prices() {
5577
5571
  esac
5578
5572
  }
5579
5573
 
5574
+ # FIX-178: AI-style a deterministic changelog draft into the project voice via
5575
+ # the configured agent (roll agent use). Content-preserving polish only — same
5576
+ # items + (ID)s, bold-lead style anchored on the last 3 released versions. Echoes
5577
+ # the styled draft on success; empty output on any failure so the caller falls
5578
+ # back to the deterministic raw draft. Bounded by a 150s watchdog (macOS lacks
5579
+ # timeout(1)). Uses the same agent path as peer review (_agent_argv).
5580
+ _changelog_ai_style() {
5581
+ local raw="$1"
5582
+ [ -n "$raw" ] || return 1
5583
+ local agent; agent=$(_project_agent)
5584
+ local anchors; anchors=$(_changelog_style_anchors CHANGELOG.md 2>/dev/null || true)
5585
+ local prompt
5586
+ prompt="你是 roll 的 changelog 编辑。把【原始草稿】改写成发布说明,严格遵守:
5587
+ 1) 不增删条目、不改事实;每条保留它的(ID)。只润色措辞。
5588
+ 2) 每条格式:\`- **简短标题(ID)** — 补充说明\`;若原条目结尾有 \`[loop]\` 则保留。
5589
+ 3) 保留每个 ### 分类标题与分组。只输出 markdown,从 '## Unreleased' 开始,前后不要任何解释。
5590
+
5591
+ 【风格锚点·最近版本】
5592
+ ${anchors}
5593
+
5594
+ 【原始草稿】
5595
+ ${raw}"
5596
+ _agent_argv "$agent" text "$prompt" 2>/dev/null || return 1
5597
+ _agent_bypass_claude_perms
5598
+ local out; out=$(mktemp)
5599
+ "${_AGENT_ARGV[@]}" >"$out" 2>/dev/null &
5600
+ local pid=$!
5601
+ local waited=0
5602
+ while kill -0 "$pid" 2>/dev/null; do
5603
+ sleep 1; waited=$((waited + 1))
5604
+ if [ "$waited" -ge 150 ]; then
5605
+ kill "$pid" 2>/dev/null; sleep 1; kill -9 "$pid" 2>/dev/null; break
5606
+ fi
5607
+ done
5608
+ wait "$pid" 2>/dev/null
5609
+ # Normalize agent chrome before extracting: some agents (e.g. kimi) prefix the
5610
+ # rendered answer with a "• " marker and indent the body. Changelog markdown is
5611
+ # flat (##, ###, -, blank), so stripping a leading "• " and any leading
5612
+ # whitespace is safe and is a no-op for plain agents (e.g. claude -p). Then
5613
+ # keep from the first '## Unreleased' line onward (drops any preamble).
5614
+ sed -E 's/^[[:space:]]*•[[:space:]]?//; s/^[[:space:]]+//' "$out" \
5615
+ | awk '/^## Unreleased/{f=1} f{print}'
5616
+ rm -f "$out"
5617
+ }
5618
+
5619
+ # FIX-178: replace (or insert) the ## Unreleased section of CHANGELOG.md with
5620
+ # the given draft (which itself begins with '## Unreleased'). getline-from-file
5621
+ # keeps the multi-line draft intact.
5622
+ _changelog_write_unreleased() {
5623
+ local draft="$1" cl="${2:-CHANGELOG.md}"
5624
+ local dfile; dfile=$(mktemp); printf '%s\n' "$draft" > "$dfile"
5625
+ local tmp; tmp=$(mktemp)
5626
+ if [ -f "$cl" ] && grep -q '^## Unreleased' "$cl"; then
5627
+ awk -v df="$dfile" '
5628
+ /^## Unreleased/ && !done { while ((getline line < df) > 0) print line; print ""; skip=1; done=1; next }
5629
+ skip && /^## / { skip=0 }
5630
+ skip { next }
5631
+ { print }
5632
+ ' "$cl" > "$tmp"
5633
+ else
5634
+ { [ -f "$cl" ] && head -1 "$cl" || echo "# Changelog"; echo; cat "$dfile"; echo;
5635
+ [ -f "$cl" ] && tail -n +2 "$cl"; } > "$tmp"
5636
+ fi
5637
+ mv "$tmp" "$cl"; rm -f "$dfile"
5638
+ }
5639
+
5580
5640
  # FIX-113: changelog audit — list PRs merged to main since latest release
5581
5641
  # tag that don't appear in CHANGELOG.md's ## Unreleased section.
5582
5642
  # US-CL-006: changelog generate — deterministic draft from backlog Done stories.
5643
+ # FIX-178: generate now AI-styles the deterministic draft via the configured
5644
+ # agent by default (content-preserving); --no-ai / --json stay deterministic.
5583
5645
  cmd_changelog() {
5584
- local subcmd="${1:-audit}"
5646
+ local subcmd="${1:-generate}"
5585
5647
  shift || true
5586
5648
  case "$subcmd" in
5587
- audit)
5588
- python3 "${ROLL_PKG_DIR}/lib/changelog_audit.py" "$@"
5589
- ;;
5590
5649
  generate)
5591
- python3 "${ROLL_PKG_DIR}/lib/changelog_generate.py" "$@"
5650
+ local want_ai=1 to_write=0 is_json=0 pyargs=()
5651
+ local a
5652
+ for a in "$@"; do
5653
+ case "$a" in
5654
+ --no-ai) want_ai=0 ;;
5655
+ --write) to_write=1 ;;
5656
+ --json) is_json=1; want_ai=0; pyargs+=("$a") ;;
5657
+ *) pyargs+=("$a") ;;
5658
+ esac
5659
+ done
5660
+ local raw
5661
+ raw=$(python3 "${ROLL_PKG_DIR}/lib/changelog_generate.py" "${pyargs[@]}") || return 1
5662
+ if [ "$is_json" = 1 ]; then printf '%s\n' "$raw"; return 0; fi
5663
+ local final="$raw"
5664
+ if [ "$want_ai" = 1 ]; then
5665
+ local styled; styled=$(_changelog_ai_style "$raw" 2>/dev/null || true)
5666
+ if [ -n "$styled" ] && printf '%s' "$styled" | grep -q '^- '; then
5667
+ final="$styled"
5668
+ else
5669
+ warn "changelog: AI 润色不可用/失败,输出确定性草稿(可加 --no-ai 跳过)"
5670
+ fi
5671
+ fi
5672
+ if [ "$to_write" = 1 ]; then
5673
+ _changelog_write_unreleased "$final"
5674
+ info "Updated CHANGELOG.md"
5675
+ else
5676
+ printf '%s\n' "$final"
5677
+ fi
5592
5678
  ;;
5593
5679
  --help|-h|help)
5594
5680
  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
5681
+ Usage: roll changelog generate [options]
5682
+
5683
+ backlog Done 故事 + 上次发布以来的提交,生成 ## Unreleased 发布说明。
5684
+ 默认用配置的 agent(roll agent use)按项目风格润色;失败自动回退确定性草稿。
5685
+
5686
+ roll changelog generate # 预览(AI 润色)
5687
+ roll changelog generate --write # 写入 CHANGELOG.md(AI 润色)
5688
+ roll changelog generate --no-ai # 仅确定性草稿,不调 AI
5689
+ roll changelog generate --json # 机器可读(确定性)
5611
5690
  EOF
5612
5691
  ;;
5613
5692
  *)
5614
5693
  err "$(msg changelog.unknown_subcommand ${subcmd})"
5615
- err "Try: roll changelog audit | roll changelog generate"
5694
+ err "Try: roll changelog generate"
5616
5695
  return 1
5617
5696
  ;;
5618
5697
  esac
@@ -8273,6 +8352,7 @@ _write_alert_loop_runner_script() {
8273
8352
  local script_path="$1" project_path="$2" roll_bin="$3" log_path="$4"
8274
8353
  mkdir -p "$(dirname "$script_path")"
8275
8354
  local lock="${project_path}/.roll/loop/.alert-loop.lock"
8355
+ local slug; slug=$(_project_slug "${project_path}")
8276
8356
  cat > "$script_path" << ALERTRUNNER
8277
8357
  #!/bin/bash -l
8278
8358
  set -o pipefail
@@ -8297,6 +8377,15 @@ fi
8297
8377
  printf '%s:%s\n' "\$\$" "\$(date -u +%s)" > "\$LOCK"
8298
8378
  trap 'rm -f "\$LOCK"' EXIT
8299
8379
  cd "${project_path}" || exit 0
8380
+ # FIX-171: bake the project-local runtime dir directly; do not rely on
8381
+ # _loop_runtime_dir which may fail to resolve in fresh shells. Set
8382
+ # _LOOP_ALERT so the dispatched roll reads the project-local ALERT file,
8383
+ # but do not override an externally-supplied value (test sandboxes).
8384
+ _LOOP_RT_DIR="${project_path}/.roll/loop"
8385
+ if [ -d "\$_LOOP_RT_DIR" ]; then
8386
+ : "\${_LOOP_ALERT:=\${_LOOP_RT_DIR}/ALERT-${slug}.md}"
8387
+ export _LOOP_ALERT
8388
+ fi
8300
8389
  bash "${roll_bin}" _alert_dispatch >> "${log_path}" 2>&1 || true
8301
8390
  ALERTRUNNER
8302
8391
  chmod +x "$script_path"
@@ -8559,6 +8648,11 @@ _runs_append() {
8559
8648
  if [ -n "\$_story_field" ]; then
8560
8649
  _stype_field="\${_story_field%%-*}"
8561
8650
  fi
8651
+ # US-LOOP-068: target field — roll-meta for roll-meta stories, empty otherwise
8652
+ local _target_field=""
8653
+ if [ "\${_ROLL_META_TARGET:-0}" = "1" ]; then
8654
+ _target_field="roll-meta"
8655
+ fi
8562
8656
  # US-EVAL-002: compute the objective result_eval block from this cycle's
8563
8657
  # facts via the US-EVAL-001 pure-function rubric. Best-effort — when python3
8564
8658
  # or the scorer script is unavailable the row is written WITHOUT result_eval
@@ -8608,6 +8702,7 @@ _runs_append() {
8608
8702
  --arg tier "\$_tier_field" \\
8609
8703
  --arg fallback_from "\$_fallback_from_field" \\
8610
8704
  --arg story_type "\$_stype_field" \\
8705
+ --arg target "\$_target_field" \\
8611
8706
  --argjson built "\$_built" \\
8612
8707
  --argjson skipped '[]' \\
8613
8708
  --argjson alerts '[]' \\
@@ -8618,6 +8713,7 @@ _runs_append() {
8618
8713
  cycle_id:\$cycle_id, agent:\$agent, tier:\$tier, fallback_from:\$fallback_from,
8619
8714
  story_type:\$story_type, built:\$built, skipped:\$skipped, alerts:\$alerts,
8620
8715
  tcr_count:\$tcr_count, duration_sec:\$duration_sec, phases:\$phases}
8716
+ + (if \$target == "" then {} else {target:\$target} end)
8621
8717
  + (if \$result_eval == null then {} else {result_eval:\$result_eval} end)' \\
8622
8718
  > "\$_tmp" 2>/dev/null || { rm -f "\$_tmp"; return 0; }
8623
8719
  # FIX-157: ensure target exists before append; missing file silently drops
@@ -8849,6 +8945,10 @@ for _orphan_wt in "\${_SHARED_ROOT}/worktrees/${slug}-cycle-"*; do
8849
8945
  ( cd "\$_orphan_wt" && _loop_publish_pr "\$_orphan_branch" "recover orphan \${_orphan_branch}" ) && _orphan_ok=1
8850
8946
  fi
8851
8947
  if [ "\$_orphan_ok" -eq 1 ]; then
8948
+ # US-LOOP-068: if orphan contains a roll-meta worktree, clean it first
8949
+ if [ -d "\${_orphan_wt}/.roll/.git" ]; then
8950
+ _loop_roll_meta_worktree_cleanup "\$_orphan_wt" "\$_orphan_branch" "${project_path}" 2>/dev/null || true
8951
+ fi
8852
8952
  _worktree_cleanup "\$_orphan_wt" "\$_orphan_branch"
8853
8953
  echo "[loop] FIX-040: orphan recovered and cleaned: \$_orphan_branch"
8854
8954
  else
@@ -8856,6 +8956,10 @@ for _orphan_wt in "\${_SHARED_ROOT}/worktrees/${slug}-cycle-"*; do
8856
8956
  fi
8857
8957
  else
8858
8958
  echo "[loop] FIX-040: orphan worktree \$_orphan_wt has no commits; cleaning up"
8959
+ # US-LOOP-068: if orphan contains a roll-meta worktree, clean it first
8960
+ if [ -d "\${_orphan_wt}/.roll/.git" ]; then
8961
+ _loop_roll_meta_worktree_cleanup "\$_orphan_wt" "\$_orphan_branch" "${project_path}" 2>/dev/null || true
8962
+ fi
8859
8963
  _worktree_cleanup "\$_orphan_wt" "\$_orphan_branch"
8860
8964
  fi
8861
8965
  done
@@ -8873,6 +8977,16 @@ if _worktree_fetch_origin main \\
8873
8977
  # because .roll/ is gitignored and the clean clone has no backlog
8874
8978
  # for Claude to read or skill entry points to dispatch to.
8875
8979
  _worktree_sync_meta "\$WT" 2>/dev/null || true
8980
+ # US-LOOP-068: for roll-meta stories, replace the synced .roll/ with a
8981
+ # real roll-meta git worktree so commits land in roll-meta remote.
8982
+ if [ "\${_ROLL_META_TARGET:-0}" = "1" ]; then
8983
+ if _loop_roll_meta_worktree_setup "\$WT" "\$BRANCH" "${project_path}" 2>/dev/null; then
8984
+ echo "[loop] cycle \${CYCLE_ID}: roll-meta worktree \${WT}/.roll on \$BRANCH"
8985
+ else
8986
+ echo "[loop] cycle \${CYCLE_ID}: roll-meta worktree setup failed — falling back to normal"
8987
+ _ROLL_META_TARGET=0
8988
+ fi
8989
+ fi
8876
8990
  echo "[loop] cycle \${CYCLE_ID}: worktree \$WT on \$BRANCH"
8877
8991
  _loop_event cycle_start "\${CYCLE_ID}" "" "" || true
8878
8992
  # US-AGENT-006: per-story routing — pick the next Todo, route to an agent
@@ -8929,6 +9043,15 @@ if _worktree_fetch_origin main \\
8929
9043
  fi
8930
9044
  CYCLE_AGENT="\${ROLL_LOOP_ROUTED_AGENT:-\$(_project_agent)}"
8931
9045
  _loop_event agent_used "\${CYCLE_ID}" "\${CYCLE_AGENT}" "primary" || true
9046
+ # US-LOOP-068: detect roll-meta target after routing is complete
9047
+ _ROLL_META_TARGET=0
9048
+ if [ -n "\$ROLL_LOOP_ROUTED_STORY" ]; then
9049
+ if _loop_is_roll_meta_story "\$ROLL_LOOP_ROUTED_STORY" "\${ROLL_MAIN_PROJECT}/.roll/backlog.md" 2>/dev/null; then
9050
+ _ROLL_META_TARGET=1
9051
+ echo "[loop] story \${ROLL_LOOP_ROUTED_STORY} is roll-meta target"
9052
+ fi
9053
+ fi
9054
+ export ROLL_LOOP_ROLL_META_TARGET="\${_ROLL_META_TARGET}"
8932
9055
  _phase_end worktree_setup ok
8933
9056
  else
8934
9057
  # P3 fix: skip the cycle entirely when worktree isolation fails.
@@ -9071,12 +9194,25 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
9071
9194
  if [ "\$_exit" -ne 0 ]; then
9072
9195
  _cycle_status="failed"
9073
9196
  else
9074
- _cycle_commits_pre=\$(cd "\$WT" && git rev-list --count origin/main..HEAD 2>/dev/null || echo 0)
9197
+ # US-LOOP-068: for roll-meta stories, count commits in the roll-meta worktree
9198
+ if [ "\${_ROLL_META_TARGET:-0}" = "1" ]; then
9199
+ _cycle_commits_pre=\$(cd "\$WT/.roll" && git rev-list --count origin/main..HEAD 2>/dev/null || echo 0)
9200
+ else
9201
+ _cycle_commits_pre=\$(cd "\$WT" && git rev-list --count origin/main..HEAD 2>/dev/null || echo 0)
9202
+ fi
9075
9203
  if [ "\$_cycle_commits_pre" -gt 0 ]; then
9076
9204
  _cycle_status="built"
9077
- _cycle_tcr=\$(cd "\$WT" && git log --oneline origin/main..HEAD -- 2>/dev/null | grep -c ' tcr:' || echo 0)
9205
+ if [ "\${_ROLL_META_TARGET:-0}" = "1" ]; then
9206
+ _cycle_tcr=\$(cd "\$WT/.roll" && git log --oneline origin/main..HEAD -- 2>/dev/null | grep -c ' tcr:' || echo 0)
9207
+ else
9208
+ _cycle_tcr=\$(cd "\$WT" && git log --oneline origin/main..HEAD -- 2>/dev/null | grep -c ' tcr:' || echo 0)
9209
+ fi
9078
9210
  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 "[]")
9211
+ if [ "\${_ROLL_META_TARGET:-0}" = "1" ]; then
9212
+ _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 "[]")
9213
+ else
9214
+ _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 "[]")
9215
+ fi
9080
9216
  fi
9081
9217
  fi
9082
9218
  fi
@@ -9097,8 +9233,17 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
9097
9233
  if [ "\$_exit" -eq 0 ]; then
9098
9234
  # Idle cycle — no commits ahead of origin/main means nothing was built;
9099
9235
  # skip publish and reclaim the worktree immediately.
9100
- _cycle_commits=\$(cd "\$WT" && git rev-list --count origin/main..HEAD 2>/dev/null || echo 0)
9236
+ # US-LOOP-068: for roll-meta stories, count commits in roll-meta worktree
9237
+ if [ "\${_ROLL_META_TARGET:-0}" = "1" ]; then
9238
+ _cycle_commits=\$(cd "\$WT/.roll" && git rev-list --count origin/main..HEAD 2>/dev/null || echo 0)
9239
+ else
9240
+ _cycle_commits=\$(cd "\$WT" && git rev-list --count origin/main..HEAD 2>/dev/null || echo 0)
9241
+ fi
9101
9242
  if [ "\$_cycle_commits" -eq 0 ]; then
9243
+ # US-LOOP-068: clean up roll-meta worktree before product worktree
9244
+ if [ "\${_ROLL_META_TARGET:-0}" = "1" ]; then
9245
+ _loop_roll_meta_worktree_cleanup "\$WT" "\$BRANCH" "${project_path}" 2>/dev/null || true
9246
+ fi
9102
9247
  _worktree_cleanup "\$WT" "\$BRANCH"
9103
9248
  _loop_event idle "\${CYCLE_ID}" "" "" || true
9104
9249
  # FIX-F (2026-05-25): explicitly write the terminal "idle" cycle_end +
@@ -9115,12 +9260,42 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
9115
9260
  echo "[loop] cycle \${CYCLE_ID}: idle (no new commits); worktree cleaned"
9116
9261
  else
9117
9262
  _is_doc_only=0
9118
- ( cd "\$WT" && _loop_is_doc_only_change ) && _is_doc_only=1
9263
+ # US-LOOP-068: for roll-meta stories, doc-only check runs in roll-meta worktree
9264
+ if [ "\${_ROLL_META_TARGET:-0}" = "1" ]; then
9265
+ ( cd "\$WT/.roll" && _loop_is_doc_only_change ) && _is_doc_only=1
9266
+ else
9267
+ ( cd "\$WT" && _loop_is_doc_only_change ) && _is_doc_only=1
9268
+ fi
9269
+ # US-LOOP-069: roll-meta boundary guard — abort if a roll-meta story touched product files
9270
+ if ! _loop_guard_roll_meta_boundary "\$WT" "\$ROLL_LOOP_ROUTED_STORY"; then
9271
+ _loop_event cycle_end "\${CYCLE_ID}" "" "failed" || true; _CYCLE_END_WRITTEN=1
9272
+ _phases_guard=\$(_phases_to_json 2>/dev/null); [ -z "\$_phases_guard" ] && _phases_guard='{}'
9273
+ _runs_append "failed" 0 "[]" "\$_phases_guard" 2>/dev/null || true
9274
+ echo "[loop] cycle \${CYCLE_ID}: US-LOOP-069 blocked — roll-meta story touched product files; worktree preserved at \$WT"
9275
+ trap '_inner_cleanup' EXIT
9276
+ exit 0
9277
+ fi
9278
+ # US-LOOP-068: roll-meta test gate — run roll-meta tests when ops/ changed
9279
+ if [ "\${_ROLL_META_TARGET:-0}" = "1" ]; then
9280
+ if ! _loop_roll_meta_test_gate "\$WT"; then
9281
+ _loop_event cycle_end "\${CYCLE_ID}" "" "failed" || true; _CYCLE_END_WRITTEN=1
9282
+ _phases_test=\$(_phases_to_json 2>/dev/null); [ -z "\$_phases_test" ] && _phases_test='{}'
9283
+ _runs_append "failed" 0 "[]" "\$_phases_test" 2>/dev/null || true
9284
+ echo "[loop] cycle \${CYCLE_ID}: roll-meta test gate failed; worktree preserved at \$WT"
9285
+ trap '_inner_cleanup' EXIT
9286
+ exit 0
9287
+ fi
9288
+ fi
9119
9289
  _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}" )
9290
+ # US-LOOP-068: roll-meta stories publish to roll-meta remote
9291
+ if [ "\${_ROLL_META_TARGET:-0}" = "1" ]; then
9292
+ _loop_roll_meta_publish "\$WT" "\$BRANCH" "loop cycle \${CYCLE_ID}"
9122
9293
  else
9123
- ( cd "\$WT" && _loop_publish_pr "\$BRANCH" "loop cycle \${CYCLE_ID}" )
9294
+ if [ "\$_is_doc_only" -eq 1 ]; then
9295
+ ( cd "\$WT" && _loop_publish_doc_pr "\$BRANCH" "doc: loop cycle \${CYCLE_ID}" )
9296
+ else
9297
+ ( cd "\$WT" && _loop_publish_pr "\$BRANCH" "loop cycle \${CYCLE_ID}" )
9298
+ fi
9124
9299
  fi
9125
9300
  _publish_status=\$?
9126
9301
  if [ "\$_publish_status" -eq 0 ]; then
@@ -9140,6 +9315,10 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
9140
9315
  # so dashboard renders #NN ✓/↩/… correctly. Must run while branch ref
9141
9316
  # is still resolvable on remote — gh pr view <branch> needs the head ref.
9142
9317
  _loop_emit_pr_final "\$BRANCH" 2>/dev/null || true
9318
+ # US-LOOP-068: clean up roll-meta worktree before product worktree
9319
+ if [ "\${_ROLL_META_TARGET:-0}" = "1" ]; then
9320
+ _loop_roll_meta_worktree_cleanup "\$WT" "\$BRANCH" "${project_path}" 2>/dev/null || true
9321
+ fi
9143
9322
  _worktree_cleanup "\$WT" "\$BRANCH"
9144
9323
  _loop_event cycle_end "\${CYCLE_ID}" "" "done" || true; _CYCLE_END_WRITTEN=1
9145
9324
  # US-OBS-014: normal-completion path — push a fresh status snapshot.
@@ -9147,15 +9326,42 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
9147
9326
  _phase_end cleanup ok
9148
9327
  echo "[loop] cycle \${CYCLE_ID}: published; worktree cleaned"
9149
9328
  elif [ "\$_publish_status" -eq 2 ]; then
9150
- if ( cd "${project_path}" && _worktree_merge_back "\$BRANCH" ); then
9329
+ # US-LOOP-068: for roll-meta, merge_back doesn't apply skip straight to
9330
+ # the orphan-push safety net (roll-meta commits live on a different remote).
9331
+ _merged_back=0
9332
+ if [ "\${_ROLL_META_TARGET:-0}" = "1" ]; then
9333
+ :
9334
+ elif ( cd "${project_path}" && _worktree_merge_back "\$BRANCH" ); then
9151
9335
  _worktree_cleanup "\$WT" "\$BRANCH"
9152
9336
  # US-LOOP-005 T3: gh unavailable + ff merge_back OK → cycle_end done
9153
9337
  _loop_event cycle_end "\${CYCLE_ID}" "" "done" || true; _CYCLE_END_WRITTEN=1
9154
9338
  echo "[loop] cycle \${CYCLE_ID}: gh unavailable; merged via ff and cleaned up"
9339
+ _merged_back=1
9340
+ fi
9341
+ # FIX-039: gh unavailable + merge_back failed — push orphan branch+tag to origin
9342
+ # as final safety net so code is never local-only before worktree cleanup.
9343
+ # Skip entirely when merge_back already finalized the cycle (the worktree is
9344
+ # gone, so re-pushing from \$WT would spuriously fail and raise a false alert).
9345
+ # US-LOOP-068: for roll-meta, push from roll-meta worktree
9346
+ if [ "\$_merged_back" -ne 1 ]; then
9347
+ _orphan_tag="loop-orphan-\${CYCLE_ID}"
9348
+ if [ "\${_ROLL_META_TARGET:-0}" = "1" ]; then
9349
+ if ( cd "\$WT/.roll" && git push origin "\$BRANCH" 2>/dev/null \
9350
+ && git tag "\$_orphan_tag" 2>/dev/null \
9351
+ && git push origin "\$_orphan_tag" 2>/dev/null ); then
9352
+ _loop_roll_meta_worktree_cleanup "\$WT" "\$BRANCH" "${project_path}" 2>/dev/null || true
9353
+ _worktree_cleanup "\$WT" "\$BRANCH"
9354
+ # US-LOOP-005 T4: gh unavailable + orphan push OK → cycle_end orphan
9355
+ _loop_event cycle_end "\${CYCLE_ID}" "" "orphan" || true; _CYCLE_END_WRITTEN=1
9356
+ _worktree_alert "cycle \${CYCLE_ID}: gh+merge_back failed; FIX-039 pushed orphan+tag \${_orphan_tag}; worktree cleaned"
9357
+ echo "[loop] cycle \${CYCLE_ID}: FIX-039: orphan branch+tag \${_orphan_tag} pushed; worktree cleaned"
9358
+ else
9359
+ # US-LOOP-005 T5: gh unavailable + all failed → cycle_end failed
9360
+ _loop_event cycle_end "\${CYCLE_ID}" "" "failed" || true; _CYCLE_END_WRITTEN=1
9361
+ _worktree_alert "cycle \${CYCLE_ID}: gh+merge_back+push all failed; worktree preserved at \$WT"
9362
+ echo "[loop] cycle \${CYCLE_ID}: all publish paths failed; worktree preserved at \$WT"
9363
+ fi
9155
9364
  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
9365
  if ( cd "\$WT" && git push origin "\$BRANCH" 2>/dev/null \
9160
9366
  && git tag "\$_orphan_tag" 2>/dev/null \
9161
9367
  && git push origin "\$_orphan_tag" 2>/dev/null ); then
@@ -9171,23 +9377,43 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
9171
9377
  echo "[loop] cycle \${CYCLE_ID}: all publish paths failed; worktree preserved at \$WT"
9172
9378
  fi
9173
9379
  fi
9380
+ fi
9174
9381
  else
9175
9382
  # FIX-039: PR publish failed — push orphan branch+tag to origin as safety net.
9176
9383
  # (_loop_publish_pr may have already pushed the branch; git push is idempotent.)
9384
+ # US-LOOP-068: for roll-meta, push from roll-meta worktree
9177
9385
  _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"
9386
+ if [ "\${_ROLL_META_TARGET:-0}" = "1" ]; then
9387
+ if ( cd "\$WT/.roll" && git push origin "\$BRANCH" 2>/dev/null \
9388
+ && git tag "\$_orphan_tag" 2>/dev/null \
9389
+ && git push origin "\$_orphan_tag" 2>/dev/null ); then
9390
+ _loop_roll_meta_worktree_cleanup "\$WT" "\$BRANCH" "${project_path}" 2>/dev/null || true
9391
+ _worktree_cleanup "\$WT" "\$BRANCH"
9392
+ # US-LOOP-005 T6: PR publish failed + orphan push OK → cycle_end orphan
9393
+ _loop_event cycle_end "\${CYCLE_ID}" "" "orphan" || true; _CYCLE_END_WRITTEN=1
9394
+ _worktree_alert "cycle \${CYCLE_ID}: PR publish failed; FIX-039 pushed orphan+tag \${_orphan_tag}; worktree cleaned"
9395
+ echo "[loop] cycle \${CYCLE_ID}: FIX-039: orphan branch+tag \${_orphan_tag} pushed; worktree cleaned"
9396
+ else
9397
+ # US-LOOP-005 T7: PR publish failed + orphan push failed → cycle_end failed
9398
+ _loop_event cycle_end "\${CYCLE_ID}" "" "failed" || true; _CYCLE_END_WRITTEN=1
9399
+ _worktree_alert "cycle \${CYCLE_ID}: PR publish failed; worktree preserved at \$WT (branch \$BRANCH)"
9400
+ echo "[loop] cycle \${CYCLE_ID}: PR publish failed; worktree preserved at \$WT"
9401
+ fi
9186
9402
  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"
9403
+ if ( cd "\$WT" && git push origin "\$BRANCH" 2>/dev/null \
9404
+ && git tag "\$_orphan_tag" 2>/dev/null \
9405
+ && git push origin "\$_orphan_tag" 2>/dev/null ); then
9406
+ _worktree_cleanup "\$WT" "\$BRANCH"
9407
+ # US-LOOP-005 T6: PR publish failed + orphan push OK → cycle_end orphan
9408
+ _loop_event cycle_end "\${CYCLE_ID}" "" "orphan" || true; _CYCLE_END_WRITTEN=1
9409
+ _worktree_alert "cycle \${CYCLE_ID}: PR publish failed; FIX-039 pushed orphan+tag \${_orphan_tag}; worktree cleaned"
9410
+ echo "[loop] cycle \${CYCLE_ID}: FIX-039: orphan branch+tag \${_orphan_tag} pushed; worktree cleaned"
9411
+ else
9412
+ # US-LOOP-005 T7: PR publish failed + orphan push failed → cycle_end failed
9413
+ _loop_event cycle_end "\${CYCLE_ID}" "" "failed" || true; _CYCLE_END_WRITTEN=1
9414
+ _worktree_alert "cycle \${CYCLE_ID}: PR publish failed; worktree preserved at \$WT (branch \$BRANCH)"
9415
+ echo "[loop] cycle \${CYCLE_ID}: PR publish failed; worktree preserved at \$WT"
9416
+ fi
9191
9417
  fi
9192
9418
  fi
9193
9419
  fi
@@ -11434,7 +11660,10 @@ _loop_pr_do_heal() {
11434
11660
  _loop_pr_merge_approved() {
11435
11661
  local num="$1" ci_state="$2" mergeable="$3" slug="$4"
11436
11662
  [ -n "$num" ] && [ -n "$slug" ] || return 0
11437
- [ "$ci_state" = "success" ] && [ "$mergeable" = "MERGEABLE" ] || return 0
11663
+ [ "$ci_state" = "success" ] || return 0
11664
+ # MERGEABLE (GraphQL mergeable) or CLEAN (mergeStateStatus) — see
11665
+ # _loop_pr_merge_self_eager for why both spellings must be accepted.
11666
+ case "$mergeable" in MERGEABLE|CLEAN) ;; *) return 0 ;; esac
11438
11667
  if gh -R "$slug" pr merge "$num" --squash --delete-branch >/dev/null 2>&1; then
11439
11668
  info "PR #${num}: human-approved + CI green — merged"
11440
11669
  else
@@ -11490,7 +11719,7 @@ EOF
11490
11719
  # can enforce them at Step 2 (story pickup). Pure functions, no side effects.
11491
11720
  #
11492
11721
  # BACKLOG row format (relevant fragments):
11493
- # | [US-AUTO-033](...) | desc `depends-on:US-AUTO-037` `manual-only:true` | 📋 Todo |
11722
+ # | [US-AUTO-033](...) | desc `depends-on:US-AUTO-037` | 📋 Todo |
11494
11723
  # | FIX-100 | desc `depends-on:US-A,US-B` | 📋 Todo |
11495
11724
  #
11496
11725
  # Row matching is anchored on `^| [?<id>\b` so a story-id appearing in some
@@ -11539,15 +11768,14 @@ _loop_check_depends_on() {
11539
11768
  return 0
11540
11769
  }
11541
11770
 
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() {
11771
+ # FIX-172: detect whether a story routes its output to roll-meta — path-based,
11772
+ # never tag-based. A story is a roll-meta target iff its declared deliverable
11773
+ # **Files:** include a path under .roll/ that is NOT a status-management file
11774
+ # (.roll/backlog.md, .roll/features/). Every cycle flips its own backlog/feature
11775
+ # status under .roll/, so "touches .roll/" must NOT flag a product story; only a
11776
+ # genuine roll-meta deliverable (e.g. .roll/ops/watch.sh) counts.
11777
+ # Returns 0 when the story delivers to roll-meta.
11778
+ _loop_is_roll_meta_story() {
11551
11779
  local id="$1"
11552
11780
  local backlog="${2:-.roll/backlog.md}"
11553
11781
  [ -n "$id" ] || return 1
@@ -11557,7 +11785,30 @@ _loop_is_manual_only() {
11557
11785
  row=$(grep -E "^\| \[?${id}[]| ]" "$backlog" | head -1)
11558
11786
  [ -n "$row" ] || return 1
11559
11787
 
11560
- echo "$row" | grep -qE 'manual-only:[A-Za-z0-9_.-]+'
11788
+ # Resolve the feature file from the row's markdown link: [id](path.md#anchor).
11789
+ # Links appear in two relative forms — ".roll/features/x.md" (rel. to product
11790
+ # root) or "features/x.md" (rel. to .roll). Normalise from "features/" onward
11791
+ # and resolve against the backlog's own directory (the roll-meta root).
11792
+ local link featrel metadir feat
11793
+ link=$(printf '%s\n' "$row" | grep -oE '\([^)]+\.md' | head -1 | sed -E 's/^\(//')
11794
+ [ -n "$link" ] || return 1
11795
+ featrel=$(printf '%s\n' "$link" | sed -E 's#^.*(features/.*)#\1#')
11796
+ metadir=$(dirname "$backlog")
11797
+ feat="${metadir}/${featrel}"
11798
+ [ -f "$feat" ] || return 1
11799
+
11800
+ # Extract this story's **Files:** block, then test for a roll-meta deliverable
11801
+ # path (under .roll/ but not the universal status-management files).
11802
+ awk -v id="$id" '
11803
+ $0 ~ ("^## " id "( |$)") {insec=1; next}
11804
+ insec && /^## / {exit}
11805
+ insec && /^\*\*Files:\*\*/ {infiles=1; next}
11806
+ insec && infiles && /^\*\*/ {exit}
11807
+ insec && infiles {print}
11808
+ ' "$feat" \
11809
+ | grep -oE '\.roll/[A-Za-z0-9_./-]+' \
11810
+ | grep -vE '^\.roll/backlog\.md$|^\.roll/features/' \
11811
+ | grep -q .
11561
11812
  }
11562
11813
 
11563
11814
  # US-AUTO-034: PR-first inbox — loop processes open PRs before scanning BACKLOG.
@@ -11598,6 +11849,17 @@ _loop_pr_classify() {
11598
11849
  fi
11599
11850
  echo "loop_self"; return 0
11600
11851
  ;;
11852
+ claude/*)
11853
+ # Claude-agent-authored PRs are loop-owned for autonomous merge/rebase
11854
+ # once green — same treatment as loop/* — so they close within a
11855
+ # PR-loop tick instead of waiting on a human or a GHA bot review.
11856
+ # CI-red claude/* PRs are deliberately NOT routed to background heal
11857
+ # (no agent re-spawn); they fall through to the stale/eligible paths
11858
+ # below so a human decides what to do with a failing run.
11859
+ if [[ "$ci_state" != "failure" ]]; then
11860
+ echo "loop_self"; return 0
11861
+ fi
11862
+ ;;
11601
11863
  esac
11602
11864
 
11603
11865
  case "$human_review" in
@@ -11605,7 +11867,10 @@ _loop_pr_classify() {
11605
11867
  APPROVED) echo "blocked_human_approved"; return 0 ;;
11606
11868
  esac
11607
11869
 
11608
- if [ "$ci_state" = "failure" ] || [ "$mergeable" = "CONFLICTING" ] || [ "$mergeable" = "BEHIND" ]; then
11870
+ # CONFLICTING is the GraphQL `mergeable` enum; DIRTY/BEHIND are
11871
+ # `mergeStateStatus` values (_loop_pr_inbox feeds the latter). Accept both
11872
+ # spellings so a conflicting/out-of-date PR is reliably routed to rebase.
11873
+ if [ "$ci_state" = "failure" ] || [ "$mergeable" = "CONFLICTING" ] || [ "$mergeable" = "DIRTY" ] || [ "$mergeable" = "BEHIND" ]; then
11609
11874
  echo "stale"
11610
11875
  return 0
11611
11876
  fi
@@ -11795,7 +12060,12 @@ _loop_pr_rebase_stale() {
11795
12060
  # spec name. Both coexist until Phase 2 collapses the inbox path.
11796
12061
  _loop_pr_merge_self_eager() {
11797
12062
  local num="$1" ci_state="$2" mergeable="$3" slug="$4"
11798
- [ "$ci_state" = "success" ] && [ "$mergeable" = "MERGEABLE" ] || return 0
12063
+ [ "$ci_state" = "success" ] || return 0
12064
+ # Accept both the GraphQL `mergeable` enum (MERGEABLE) and the
12065
+ # `mergeStateStatus` enum (CLEAN). _loop_pr_inbox feeds mergeStateStatus, so
12066
+ # a ready-to-merge PR arrives as CLEAN in production — without it the eager
12067
+ # merge never fired and self-PRs silently waited on repo-level auto-merge.
12068
+ case "$mergeable" in MERGEABLE|CLEAN) ;; *) return 0 ;; esac
11799
12069
  gh -R "$slug" pr merge "$num" --squash --delete-branch >/dev/null 2>&1 \
11800
12070
  && info "PR #${num}: loop_self CI green — merged" \
11801
12071
  || warn "PR #${num}: loop_self merge failed — left open"
@@ -11847,7 +12117,7 @@ _loop_pr_inbox() {
11847
12117
  # All gates cleared (bot-approved + CI green + no conflicts) → merge directly.
11848
12118
  # Relying on repo-level auto-merge being configured is not reliable; loop
11849
12119
  # owns the decision here since it already ran the review.
11850
- if [ "$ci_state" = "success" ] && [ "$mergeable" = "MERGEABLE" ]; then
12120
+ if [ "$ci_state" = "success" ] && { [ "$mergeable" = "MERGEABLE" ] || [ "$mergeable" = "CLEAN" ]; }; then
11851
12121
  gh -R "$slug" pr merge "$num" --squash --delete-branch >/dev/null 2>&1 \
11852
12122
  && info "PR #${num}: bot-approved + CI green — merged" \
11853
12123
  || warn "PR #${num}: merge failed (bot-approved + CI green) — left open"
@@ -11866,7 +12136,21 @@ _loop_pr_inbox() {
11866
12136
 
11867
12137
  case "$verdict" in
11868
12138
  loop_self)
11869
- _loop_pr_merge_self_eager "$num" "$ci_state" "$mergeable" "$slug"
12139
+ # Green self-PR: merge when clean, else rebase onto main first. A
12140
+ # loop/* or claude/* PR that fell BEHIND or now CONFLICTS with main can
12141
+ # never auto-merge until rebased — eager-merge alone would leave it
12142
+ # stuck open forever. Rebase is circuit-gated (≥3 attempts/24h → ALERT)
12143
+ # and merges on a later tick once the rebased head is green + clean.
12144
+ case "$mergeable" in
12145
+ BEHIND|DIRTY|CONFLICTING)
12146
+ if _loop_pr_rebase_circuit "$num"; then
12147
+ _loop_pr_rebase_stale "$num" "$head_ref" || true
12148
+ fi
12149
+ ;;
12150
+ *)
12151
+ _loop_pr_merge_self_eager "$num" "$ci_state" "$mergeable" "$slug"
12152
+ ;;
12153
+ esac
11870
12154
  ;;
11871
12155
  loop_self_ci_red)
11872
12156
  # US-LOOP-062a: a red loop/* PR (classified by US-LOOP-049) is now
@@ -11902,7 +12186,7 @@ _loop_pr_inbox() {
11902
12186
  #
11903
12187
  # Loop-safe building blocks for the future PR Loop (com.roll.pr.<slug>.plist,
11904
12188
  # 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.
12189
+ # plist / main-loop wiring) is wired by hand and out of scope here.
11906
12190
  #
11907
12191
  # All helpers are lenient on a missing `gh` binary or any gh failure: they
11908
12192
  # return 0 so a loop iteration never aborts on a single bad PR. State writes
@@ -12091,7 +12375,7 @@ _loop_pr_route() {
12091
12375
  # These six helpers collect CI timing data, classify failures, auto-rerun
12092
12376
  # transient flakes, and surface flaky / degradation signals as backlog
12093
12377
  # 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
12378
+ # Phase 2 (wired by hand). Each is unit-tested in
12095
12379
  # tests/unit/roll_loop_ci_loop.bats with gh stubbed. Do not delete or inline.
12096
12380
  #
12097
12381
  # State lives under project-local .roll/state/:
@@ -12125,9 +12409,20 @@ _ci_record_timing() {
12125
12409
  local dir; dir=$(_ci_state_dir)
12126
12410
  local file="${dir}/ci-timing.jsonl"
12127
12411
 
12128
- # Idempotency: skip if this run_id is already recorded.
12412
+ # Idempotency: skip if this run_id is already recorded with a non-empty
12413
+ # conclusion. If the existing record has an empty conclusion and the new
12414
+ # data has a conclusion, update in-place so in-progress runs are completed.
12129
12415
  if [ -f "$file" ] && grep -q "\"run_id\":${run_id}," "$file" 2>/dev/null; then
12130
- return 0
12416
+ local existing_conclusion new_conclusion
12417
+ existing_conclusion=$(grep "\"run_id\":${run_id}," "$file" 2>/dev/null | jq -r '.conclusion // ""' 2>/dev/null)
12418
+ new_conclusion=$(echo "$json" | jq -r '.conclusion // ""' 2>/dev/null)
12419
+ if [ -n "$existing_conclusion" ] || [ -z "$new_conclusion" ]; then
12420
+ return 0
12421
+ fi
12422
+ # Remove the stale line so the new record can be appended below.
12423
+ local tmpfile="${file}.tmp.$$"
12424
+ grep -v "\"run_id\":${run_id}," "$file" > "$tmpfile" 2>/dev/null || true
12425
+ mv "$tmpfile" "$file"
12131
12426
  fi
12132
12427
 
12133
12428
  workflow=$(echo "$json" | jq -r '.workflowName // .name // ""' 2>/dev/null)
@@ -12446,7 +12741,7 @@ _ci_scan() {
12446
12741
  # that every loop appends to but nobody reads. The Alert Loop turns it into a
12447
12742
  # real consumer: parse → dedup (1h per category) → notify (error always) →
12448
12743
  # 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
12744
+ # is Phase 2 (wired by hand). Each is unit-tested in
12450
12745
  # tests/unit/roll_loop_alert_loop.bats with _notify stubbed. Do not delete or
12451
12746
  # inline.
12452
12747
  #
@@ -12856,7 +13151,13 @@ _skill_write_self_score() {
12856
13151
  *) err "_skill_write_self_score: verdict must be good / ok / regression (got '$verdict')"; return 1 ;;
12857
13152
  esac
12858
13153
 
13154
+ # FIX-176: anchor notes to <project>/.roll/notes regardless of cwd. When
13155
+ # invoked with cwd already inside the .roll dir (e.g. a self-score run from
13156
+ # within .roll), the bare ".roll/notes" doubled to .roll/.roll/notes.
12859
13157
  local notes_dir=".roll/notes"
13158
+ if [ "$(basename "$PWD")" = ".roll" ]; then
13159
+ notes_dir="notes"
13160
+ fi
12860
13161
  mkdir -p "$notes_dir"
12861
13162
  local ts; ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
12862
13163
  local date_part="${ts%%T*}"
@@ -13113,7 +13414,7 @@ _changelog_audit_gate() {
13113
13414
  # US-AUTO-036: worktree helpers (loop-safe pure additions).
13114
13415
  #
13115
13416
  # Phase 1 of worktree isolation — these helpers are NOT yet called by
13116
- # runner.sh. US-AUTO-037 (manual-only) wires them into
13417
+ # runner.sh. US-AUTO-037 (wired by hand) wires them into
13117
13418
  # _write_loop_runner_script. Do not delete or inline; they are unit-tested
13118
13419
  # in tests/unit/roll_worktree.bats.
13119
13420
 
@@ -13214,6 +13515,58 @@ _worktree_sync_meta() {
13214
13515
  fi
13215
13516
  }
13216
13517
 
13518
+ # US-LOOP-068: set up .roll/ inside the product worktree as a roll-meta git worktree.
13519
+ # Must be called after the product worktree is created.
13520
+ # Returns 0 on success, 1 on failure.
13521
+ _loop_roll_meta_worktree_setup() {
13522
+ local wt="$1" branch="$2" project_path="$3"
13523
+ local meta_repo="${project_path}/.roll"
13524
+ [ -d "$meta_repo/.git" ] || { echo "[loop] roll-meta setup: ${meta_repo} is not a git repo"; return 1; }
13525
+ rm -rf "${wt}/.roll"
13526
+ git -C "$meta_repo" worktree add "${wt}/.roll" -b "$branch" "origin/main"
13527
+ }
13528
+
13529
+ # US-LOOP-068: clean up the roll-meta worktree inside the product worktree.
13530
+ _loop_roll_meta_worktree_cleanup() {
13531
+ local wt="$1" branch="$2" project_path="$3"
13532
+ local meta_repo="${project_path}/.roll"
13533
+ [ -d "$meta_repo/.git" ] || return 0
13534
+ git -C "$meta_repo" worktree remove --force "${wt}/.roll" 2>/dev/null || true
13535
+ rm -rf "${wt}/.roll" 2>/dev/null || true
13536
+ git -C "$meta_repo" branch -D "$branch" 2>/dev/null || true
13537
+ }
13538
+
13539
+ # US-LOOP-068: publish a PR from the roll-meta worktree.
13540
+ _loop_roll_meta_publish() {
13541
+ local wt="$1" branch="$2" title="$3"
13542
+ (cd "${wt}/.roll" && _loop_publish_pr "$branch" "$title")
13543
+ }
13544
+
13545
+ # US-LOOP-068: run roll-meta test gate when .roll/ops/ files are modified.
13546
+ # Returns 0 when no ops changes or tests pass; 1 when tests fail.
13547
+ _loop_roll_meta_test_gate() {
13548
+ local wt="$1"
13549
+ local _ops_changed
13550
+ _ops_changed=$(cd "${wt}/.roll" && git diff --name-only origin/main..HEAD 2>/dev/null | grep '^ops/' || true)
13551
+ [ -n "$_ops_changed" ] || return 0
13552
+ if ! command -v bats >/dev/null 2>&1; then
13553
+ echo "[loop] roll-meta ops/ changed but bats not installed — skipping test gate"
13554
+ return 0
13555
+ fi
13556
+ local _test_dir="${wt}/.roll/ops/tests"
13557
+ if [ ! -d "$_test_dir" ]; then
13558
+ echo "[loop] roll-meta ops/ changed but no $_test_dir found — skipping test gate"
13559
+ return 0
13560
+ fi
13561
+ echo "[loop] roll-meta test gate: bats ${_test_dir}"
13562
+ if ! bats "${_test_dir}" 2>/dev/null; then
13563
+ echo "[loop] roll-meta test gate failed"
13564
+ return 1
13565
+ fi
13566
+ echo "[loop] roll-meta test gate passed"
13567
+ return 0
13568
+ }
13569
+
13217
13570
  # _worktree_merge_back <branch>
13218
13571
  # Caller must be in the main worktree (cwd = main). Steps:
13219
13572
  # 1. git pull --ff-only origin main (sync local main with remote)
@@ -13412,8 +13765,8 @@ refs/heads/claude/*"
13412
13765
  # v1 type/est/risk routing). lib/agent_routes_lint.py is no longer invoked.
13413
13766
 
13414
13767
  # 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.
13768
+ # Exit 0 when the story id is eligible to be worked (📋 Todo, deps satisfied,
13769
+ # no open PR). Exit 1 otherwise.
13417
13770
  # Args: <story-id> [backlog-path] [open-pr-titles]
13418
13771
  _loop_story_is_eligible() {
13419
13772
  local id="$1"
@@ -13431,13 +13784,10 @@ _loop_story_is_eligible() {
13431
13784
  _status=$(printf '%s\n' "$row" | awk -F'|' '{for(i=NF;i>=1;i--) if($i ~ /[^ \t]/) {gsub(/^[ \t]+|[ \t]+$/, "", $i); print $i; exit}}')
13432
13785
  [ "$_status" = "📋 Todo" ] || return 1
13433
13786
 
13434
- # Gate 1: manual-only
13435
- _loop_is_manual_only "$id" "$backlog" 2>/dev/null && return 1
13436
-
13437
- # Gate 2: depends-on
13787
+ # Gate 1: depends-on
13438
13788
  _loop_check_depends_on "$id" "$backlog" >/dev/null 2>&1 || return 1
13439
13789
 
13440
- # Gate 3 (FIX-141): skip if an open PR already references this story id.
13790
+ # Gate 2 (FIX-141): skip if an open PR already references this story id.
13441
13791
  if [ -n "$open_pr_titles" ] && printf '%s\n' "$open_pr_titles" | grep -qE "${id}([^0-9A-Za-z]|$)"; then
13442
13792
  return 1
13443
13793
  fi
@@ -13448,9 +13798,8 @@ _loop_story_is_eligible() {
13448
13798
  # US-AGENT-006: pick the next eligible 📋 Todo story from .roll/backlog.md
13449
13799
  # applying the same gates as the roll-loop SKILL Step 2:
13450
13800
  # 1. Status = 📋 Todo
13451
- # 2. NOT manual-only:* tagged
13452
- # 3. depends-on:* (if any) all Done
13453
- # 4. Priority order: FIX > US > REFACTOR
13801
+ # 2. depends-on:* (if any) all ✅ Done
13802
+ # 3. Priority order: FIX > US > REFACTOR
13454
13803
  #
13455
13804
  # stdout: chosen story id (single line)
13456
13805
  # exit 0 when picked, 1 when nothing eligible.
@@ -13947,6 +14296,38 @@ _loop_is_doc_only_change() {
13947
14296
  return 0
13948
14297
  }
13949
14298
 
14299
+ # _loop_guard_roll_meta_boundary <worktree> <story_id>
14300
+ # US-LOOP-069: for roll-meta-target stories (deliverable under .roll/), verify
14301
+ # that no product-repo tracked files were modified in the worktree. Returns 0 if
14302
+ # safe (or not a roll-meta story), returns 1 if product files were touched and
14303
+ # writes ALERT. FIX-172: roll-meta-ness is path-based, not tag-based.
14304
+ _loop_guard_roll_meta_boundary() {
14305
+ local wt="$1" story_id="$2"
14306
+ [ -n "$story_id" ] || return 0
14307
+ [ -d "$wt" ] || return 0
14308
+
14309
+ local _backlog="${ROLL_MAIN_PROJECT:-.}/.roll/backlog.md"
14310
+ [ -f "$_backlog" ] || return 0
14311
+
14312
+ _loop_is_roll_meta_story "$story_id" "$_backlog" || return 0
14313
+
14314
+ local _changed _violations
14315
+ _changed=$(cd "$wt" && git diff --name-only origin/main..HEAD 2>/dev/null || true)
14316
+ [ -n "$_changed" ] || return 0
14317
+
14318
+ _violations=$(printf '%s\n' "$_changed" | grep -v '^\.roll/' || true)
14319
+ if [ -n "$_violations" ]; then
14320
+ local _alert
14321
+ _alert="US-LOOP-069 guard blocked roll-meta story ${story_id}"
14322
+ _alert="${_alert}"$'\n'"Touched product files (only .roll/ is allowed):"
14323
+ _alert="${_alert}"$'\n'"${_violations}"
14324
+ _worktree_alert "$_alert"
14325
+ return 1
14326
+ fi
14327
+
14328
+ return 0
14329
+ }
14330
+
13950
14331
  # _loop_publish_doc_pr <branch> [title]
13951
14332
  # Like _loop_publish_pr but merges immediately with --admin (no CI wait).
13952
14333
  # For doc-only changes where CI is not meaningful.
@@ -15576,19 +15957,30 @@ _invalidate_update_cache() {
15576
15957
  rm -f "${ROLL_HOME}/.update-check"
15577
15958
  }
15578
15959
 
15960
+ # FIX-170: the cache file is `<ts> <latest> <writer-version>` — the 3rd field
15961
+ # binds it to the binary version that wrote it. FIX-166's explicit invalidation
15962
+ # only covers `roll update` run by a NEW binary; an upgrade executed by an old
15963
+ # binary (the 2.602.2→2.602.4 transition) or out-of-band (npm -g / brew / git)
15964
+ # left a stale cache that reverse-nagged for up to 24h. A writer-version
15965
+ # mismatch now means "stale" regardless of TTL: refetch, and stay silent until
15966
+ # the refetch lands. Legacy 2-field caches have no writer → auto-invalidated.
15579
15967
  _check_update_async() {
15580
15968
  local cache="${ROLL_HOME}/.update-check"
15581
15969
  local now; now=$(date +%s)
15582
- local last=0
15583
- [[ -f "$cache" ]] && last=$(awk '{print $1}' "$cache" 2>/dev/null || echo 0)
15584
- (( now - last < 86400 )) && return
15970
+ local last=0 writer=""
15971
+ if [[ -f "$cache" ]]; then
15972
+ last=$(awk '{print $1}' "$cache" 2>/dev/null || echo 0)
15973
+ writer=$(awk '{print $3}' "$cache" 2>/dev/null || true)
15974
+ fi
15975
+ [[ "$writer" == "$VERSION" ]] && (( now - ${last:-0} < 86400 )) && return
15585
15976
 
15586
15977
  {
15587
15978
  local latest
15588
15979
  latest=$(curl -sf --max-time 5 \
15589
15980
  "https://api.github.com/repos/seanyao/roll/releases/latest" \
15590
15981
  | grep '"tag_name"' | sed 's/.*"v\([^"]*\)".*/\1/' 2>/dev/null || true)
15591
- echo "$now ${latest:-}" > "$cache"
15982
+ # `-` placeholder keeps the field positions stable when the fetch fails.
15983
+ echo "$now ${latest:--} $VERSION" > "$cache"
15592
15984
  } &
15593
15985
  disown
15594
15986
  }
@@ -15596,8 +15988,13 @@ _check_update_async() {
15596
15988
  _notify_update() {
15597
15989
  local cache="${ROLL_HOME}/.update-check"
15598
15990
  [[ -f "$cache" ]] || return 0
15599
- local latest; latest=$(awk '{print $2}' "$cache" 2>/dev/null || true)
15600
- [[ -z "$latest" || "$latest" == "$VERSION" ]] && return
15991
+ local latest writer
15992
+ latest=$(awk '{print $2}' "$cache" 2>/dev/null || true)
15993
+ writer=$(awk '{print $3}' "$cache" 2>/dev/null || true)
15994
+ # FIX-170: cache written by a different binary version is stale — stay
15995
+ # silent; _check_update_async has already kicked off the refetch.
15996
+ [[ "$writer" != "$VERSION" ]] && return
15997
+ [[ -z "$latest" || "$latest" == "-" || "$latest" == "$VERSION" ]] && return
15601
15998
  # FIX-163: the cached `latest` is GitHub's releases/latest — the newest
15602
15999
  # release by created_at, NOT by semver. Under the MAJOR.MMDD scheme a plain
15603
16000
  # `sort -V` mis-ranks versions across the year-based→MAJOR.MMDD transition