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