@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.
- package/CHANGELOG.md +61 -0
- package/bin/roll +500 -792
- package/lib/README.md +0 -1
- package/lib/changelog_audit.py +139 -145
- package/lib/changelog_generate.py +237 -30
- package/lib/consistency_check.py +409 -0
- package/lib/i18n/consistency.sh +8 -0
- package/lib/loop-fmt.py +2 -2
- package/lib/prices/snapshot-2026-05-22.json +1 -7
- package/lib/prices/snapshot-2026-05-23-deepseek.json +0 -2
- package/lib/prices/snapshot-2026-06-02-kimi.json +0 -1
- package/lib/prices_fetcher.py +312 -63
- package/lib/roll-loop-status.py +1 -1
- package/package.json +1 -1
- package/skills/roll-.changelog/SKILL.md +1 -1
- package/skills/roll-loop/SKILL.md +7 -23
- package/lib/__pycache__/github_sync.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop-fmt.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop_result_eval.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop_unstick.cpython-314.pyc +0 -0
- package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
- package/lib/__pycache__/prices_fetcher.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll-home.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll_git.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll_render.cpython-314.pyc +0 -0
- package/lib/__pycache__/slides-render.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/__init__.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/gemini.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/kimi.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/openai.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/pi.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/pi_emit.cpython-314.pyc +0 -0
- 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.
|
|
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
|
-
|
|
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
|
-
|
|
5533
|
+
vendor = "anthropic"
|
|
5539
5534
|
|
|
5540
5535
|
try:
|
|
5541
|
-
action, changes = pf.refresh(
|
|
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:-
|
|
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
|
-
|
|
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
|
|
5596
|
-
|
|
5597
|
-
|
|
5598
|
-
|
|
5599
|
-
|
|
5600
|
-
|
|
5601
|
-
generate
|
|
5602
|
-
|
|
5603
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
9179
|
-
|
|
9180
|
-
|
|
9181
|
-
|
|
9182
|
-
|
|
9183
|
-
|
|
9184
|
-
|
|
9185
|
-
|
|
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
|
-
|
|
9188
|
-
|
|
9189
|
-
|
|
9190
|
-
|
|
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
|
-
|
|
9511
|
-
|
|
9512
|
-
|
|
9513
|
-
|
|
9514
|
-
local
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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" ]
|
|
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`
|
|
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
|
-
#
|
|
11543
|
-
#
|
|
11544
|
-
#
|
|
11545
|
-
#
|
|
11546
|
-
#
|
|
11547
|
-
#
|
|
11548
|
-
#
|
|
11549
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
11581
|
-
#
|
|
11582
|
-
#
|
|
11583
|
-
#
|
|
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 "$
|
|
11593
|
-
|
|
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" ]
|
|
11609
|
-
echo "
|
|
11610
|
-
return 0
|
|
11770
|
+
if [ "$ci_state" = "failure" ]; then
|
|
11771
|
+
echo "ci_red"; return 0
|
|
11611
11772
|
fi
|
|
11612
11773
|
|
|
11613
|
-
echo "
|
|
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" ]
|
|
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
|
-
|
|
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
|
-
|
|
11891
|
-
|
|
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
|
|
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
|
-
#
|
|
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 (
|
|
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,
|
|
13416
|
-
#
|
|
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:
|
|
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
|
|
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.
|
|
13452
|
-
# 3.
|
|
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" "
|
|
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; } ;;
|