@seanyao/roll 2026.529.4 → 2026.529.5
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 +7 -0
- package/bin/roll +50 -10
- package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
- package/lib/roll-loop-status.py +123 -14
- package/package.json +1 -1
- package/skills/roll-loop/SKILL.md +6 -2
package/CHANGELOG.md
CHANGED
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.529.
|
|
7
|
+
VERSION="2026.529.5"
|
|
8
8
|
ROLL_HOME="${ROLL_HOME:-${HOME}/.roll}"
|
|
9
9
|
ROLL_CONFIG="${ROLL_HOME}/config.yaml"
|
|
10
10
|
ROLL_GLOBAL="${ROLL_HOME}/conventions/global"
|
|
@@ -6079,8 +6079,12 @@ _heartbeat_writer() {
|
|
|
6079
6079
|
sleep 60
|
|
6080
6080
|
done
|
|
6081
6081
|
}
|
|
6082
|
-
_heartbeat_writer
|
|
6083
|
-
|
|
6082
|
+
# FIX-138: _heartbeat_writer is started AFTER sourcing bin/roll below, NOT here.
|
|
6083
|
+
# Backgrounding a function forks a subshell snapshot of the current shell;
|
|
6084
|
+
# _loop_event (defined in bin/roll, sourced ~240 lines later) is undefined in
|
|
6085
|
+
# that snapshot, so every phase_tick call silently failed (2>/dev/null || true)
|
|
6086
|
+
# -> zero heartbeat across the whole cycle. The publish_wait_merge ticks seen
|
|
6087
|
+
# were its own 30s poll loop, not this writer. Starting post-source fixes it.
|
|
6084
6088
|
# FIX-057: cycle hard timeout — 45 minute SLA per loop cycle. If a cycle runs
|
|
6085
6089
|
# longer, kill claude / loop-fmt.py / all backgrounded children, mark the
|
|
6086
6090
|
# in-progress backlog item Blocked (caller decides), and exit cleanly so the
|
|
@@ -6310,9 +6314,8 @@ _runs_append "failed" 0 "[]" "\$_phases_t" 2>/dev/null || true
|
|
|
6310
6314
|
sed -E 's/\x1b\[[0-9;]*[A-Za-z]//g; s/\r$//' "\$ROLL_CYCLE_LOG_RAW" \
|
|
6311
6315
|
> "\${_log_dir}/\${CYCLE_ID}.log" 2>/dev/null || true
|
|
6312
6316
|
rm -f "\$ROLL_CYCLE_LOG_RAW"
|
|
6313
|
-
#
|
|
6314
|
-
|
|
6315
|
-
( cd "\$_log_dir" && ls -t *.log 2>/dev/null | tail -n +\$((_keep + 1)) | xargs -r rm -f ) 2>/dev/null || true
|
|
6317
|
+
# FIX-139: keep ALL per-cycle logs — no rotation cap. Each log is ~2KB,
|
|
6318
|
+
# full-year retention is a few MB. Owner: "不做 50 的限制,所有的都要留下来".
|
|
6316
6319
|
fi
|
|
6317
6320
|
rm -f "\$INNER_LOCK" "\$HEARTBEAT_FILE" "\$HEARTBEAT_PHASE_FILE"
|
|
6318
6321
|
exit "\$_rc"
|
|
@@ -6326,6 +6329,11 @@ trap '_inner_cleanup' EXIT
|
|
|
6326
6329
|
source "${roll_bin}"
|
|
6327
6330
|
set +e
|
|
6328
6331
|
|
|
6332
|
+
# FIX-138: start heartbeat now that _loop_event is defined (see note at its
|
|
6333
|
+
# definition above). Forking earlier loses _loop_event in the subshell snapshot.
|
|
6334
|
+
_heartbeat_writer &
|
|
6335
|
+
_HEARTBEAT_PID=\$!
|
|
6336
|
+
|
|
6329
6337
|
# FIX-052: bin/roll initializes loop state paths from cwd at source time, but
|
|
6330
6338
|
# the inner script may be launched from anywhere. Override to this project's
|
|
6331
6339
|
# slug (baked at template generation) so helpers like _worktree_alert write
|
|
@@ -6620,6 +6628,11 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
|
|
|
6620
6628
|
_phase_begin publish_wait_merge
|
|
6621
6629
|
if ! ( cd "\$WT" && _loop_wait_pr_merge "\$BRANCH" ); then
|
|
6622
6630
|
_worktree_alert "cycle \${CYCLE_ID}: FIX-047: PR not merged within timeout — code may not be in main (BRANCH=\${BRANCH})"
|
|
6631
|
+
# FIX-140: PR not merged => commits are NOT on main => story is NOT
|
|
6632
|
+
# done. Revert the routed story ✅ Done -> 📋 Todo so backlog reflects
|
|
6633
|
+
# reality instead of a false Done. (FIX-141's open-PR gate keeps the
|
|
6634
|
+
# next cycle from opening a duplicate PR while this one is still open.)
|
|
6635
|
+
[ -n "\${ROLL_LOOP_ROUTED_STORY:-}" ] && _loop_mark_todo "\$ROLL_LOOP_ROUTED_STORY" 2>/dev/null || true
|
|
6623
6636
|
_phase_end publish_wait_merge fail
|
|
6624
6637
|
else
|
|
6625
6638
|
_phase_end publish_wait_merge ok
|
|
@@ -6769,7 +6782,11 @@ fi
|
|
|
6769
6782
|
LOCK="\$(dirname "\$0")/.LOCK-\$(basename "\$0" .sh | sed 's/^run-//')"
|
|
6770
6783
|
SESSION="roll-loop-\$(basename "\$0" .sh | sed 's/^run-//')"
|
|
6771
6784
|
INNER_SCRIPT="${inner_path}"
|
|
6785
|
+
# FIX-139: machine/ops log path is supplied by the caller (now project-local
|
|
6786
|
+
# .roll/loop/cron.log — see the _write_loop_runner_script call site). Ensure its
|
|
6787
|
+
# directory exists before the early LOCK-skip echoes append to it.
|
|
6772
6788
|
LOG="${log_path}"
|
|
6789
|
+
mkdir -p "\$(dirname "\$LOG")" 2>/dev/null || true
|
|
6773
6790
|
if [ -f "\$LOCK" ]; then
|
|
6774
6791
|
prev_pid=\$(head -1 "\$LOCK" 2>/dev/null || echo "")
|
|
6775
6792
|
if [ -n "\$prev_pid" ] && kill -0 "\$prev_pid" 2>/dev/null; then
|
|
@@ -6809,7 +6826,10 @@ if command -v tmux >/dev/null 2>&1; then
|
|
|
6809
6826
|
CYCLE_LOG_RAW="${project_path}/.roll/cycle-logs/.pipe-\$\$.raw"
|
|
6810
6827
|
export ROLL_CYCLE_LOG_RAW="\$CYCLE_LOG_RAW"
|
|
6811
6828
|
tmux new-session -d -s "\$SESSION" -x 200 -y 50 "bash \"\$INNER_SCRIPT\""
|
|
6812
|
-
|
|
6829
|
+
# FIX-139: pane output goes ONLY to the per-cycle raw (-> <CYCLE_ID>.log),
|
|
6830
|
+
# no cumulative tee into LOG — per-cycle logs are the single source, all kept
|
|
6831
|
+
# (no cap). Machine/ops events still append to LOG directly.
|
|
6832
|
+
tmux pipe-pane -t "\$SESSION" "cat >> \"\$ROLL_CYCLE_LOG_RAW\""
|
|
6813
6833
|
# Auto-attach popup: when not muted, spawn a Terminal.app window attached
|
|
6814
6834
|
# to the tmux session so the user can watch the loop work in real time.
|
|
6815
6835
|
# FIX-054: terminal selection removed — fixed to macOS Terminal.app for
|
|
@@ -6829,8 +6849,10 @@ if command -v tmux >/dev/null 2>&1; then
|
|
|
6829
6849
|
# cron-<slug>.log file still has the full transcript as a fallback.
|
|
6830
6850
|
# FIX-131: after tmux session ends, open the cron log with less so the
|
|
6831
6851
|
# user can scroll through the full cycle output instead of seeing nothing.
|
|
6832
|
-
|
|
6833
|
-
|
|
6852
|
+
# FIX-139: after the session ends, show ONLY this cycle's per-cycle log
|
|
6853
|
+
# (newest in .roll/cycle-logs/), not the global cumulative transcript.
|
|
6854
|
+
printf '#!/bin/bash\\ntmux attach -t %s 2>/dev/null\\nLOGFILE=\$(ls -t "%s"/.roll/cycle-logs/*.log 2>/dev/null | head -1)\\necho\\nif [ -n "\$LOGFILE" ] && [ -f "\$LOGFILE" ]; then\\n echo "================================================================"\\n echo " Cycle ended — showing this cycle log (arrows to scroll, q to close)"\\n echo "================================================================"\\n less -R +G "\$LOGFILE"\\nelse\\n echo "================================================================"\\n echo " Cycle ended. No per-cycle log yet."\\n echo " press enter to close."\\n echo "================================================================"\\n read _\\nfi\\n' \\
|
|
6855
|
+
"\$SESSION" "${project_path}" > "\$_attach_cmd" 2>/dev/null || true
|
|
6834
6856
|
chmod +x "\$_attach_cmd" 2>/dev/null || true
|
|
6835
6857
|
open -g -a Terminal "\$_attach_cmd" >/dev/null 2>&1 || true
|
|
6836
6858
|
fi
|
|
@@ -6972,7 +6994,11 @@ _install_launchd_plists() {
|
|
|
6972
6994
|
local cmd; cmd=$(_agent_skill_cmd "${sd}/${skill}/SKILL.md" 2>/dev/null || echo "roll loop now")
|
|
6973
6995
|
|
|
6974
6996
|
if [[ "$svc" == "loop" ]]; then
|
|
6975
|
-
|
|
6997
|
+
# FIX-139: loop machine/ops log is project-local (.roll/loop/cron.log),
|
|
6998
|
+
# not the global cron-<slug>.log. Reader (FIX-137) reads it project-local;
|
|
6999
|
+
# per-cycle transcripts live in .roll/cycle-logs/ (single source, no dup).
|
|
7000
|
+
local loop_log="${project_path}/.roll/loop/cron.log"
|
|
7001
|
+
_write_loop_runner_script "$runner" "$project_path" "cd \"${project_path}\" && ${cmd}" "$loop_log" "$active_start" "$active_end" "${sd}/${skill}/SKILL.md"
|
|
6976
7002
|
else
|
|
6977
7003
|
_write_runner_script "$runner" "$project_path" "cd \"${project_path}\" && ${cmd}" "$log"
|
|
6978
7004
|
fi
|
|
@@ -9650,6 +9676,15 @@ _loop_pick_next_story() {
|
|
|
9650
9676
|
local backlog="${1:-.roll/backlog.md}"
|
|
9651
9677
|
[ -f "$backlog" ] || return 1
|
|
9652
9678
|
|
|
9679
|
+
# FIX-141: fetch open PR titles ONCE so we can skip any story that already
|
|
9680
|
+
# has an open PR (it's in review / awaiting merge — re-picking it produces a
|
|
9681
|
+
# duplicate PR, as happened with FIX-137 #257 -> #258). gh runs in the cycle
|
|
9682
|
+
# worktree (has the remote); empty/unavailable gh => no skipping (safe).
|
|
9683
|
+
local _open_pr_titles=""
|
|
9684
|
+
if command -v gh >/dev/null 2>&1; then
|
|
9685
|
+
_open_pr_titles=$(gh pr list --state open --json title --jq '.[].title' 2>/dev/null || echo "")
|
|
9686
|
+
fi
|
|
9687
|
+
|
|
9653
9688
|
# Two passes over the file, once per type prefix, return first hit.
|
|
9654
9689
|
local prefix
|
|
9655
9690
|
for prefix in FIX US REFACTOR; do
|
|
@@ -9670,6 +9705,11 @@ _loop_pick_next_story() {
|
|
|
9670
9705
|
if ! _loop_check_depends_on "$id" "$backlog" >/dev/null 2>&1; then
|
|
9671
9706
|
continue
|
|
9672
9707
|
fi
|
|
9708
|
+
# Gate 3 (FIX-141): skip if an open PR already references this story id
|
|
9709
|
+
# (in its title) — avoids duplicate PRs for a story already in review.
|
|
9710
|
+
if [ -n "$_open_pr_titles" ] && printf '%s\n' "$_open_pr_titles" | grep -qE "${id}([^0-9A-Za-z]|$)"; then
|
|
9711
|
+
continue
|
|
9712
|
+
fi
|
|
9673
9713
|
printf '%s\n' "$id"
|
|
9674
9714
|
return 0
|
|
9675
9715
|
done < "$backlog"
|
|
Binary file
|
package/lib/roll-loop-status.py
CHANGED
|
@@ -121,16 +121,101 @@ def _git_remote_url(repo_path: str) -> Optional[str]:
|
|
|
121
121
|
def shared_root() -> Path:
|
|
122
122
|
return Path(os.environ.get("ROLL_SHARED_ROOT") or os.path.expanduser("~/.shared/roll"))
|
|
123
123
|
|
|
124
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
125
|
+
# Project path resolution — mirrors bin/roll's _loop_resolve_project_path
|
|
126
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
127
|
+
def _resolve_project_path(slug: str) -> Optional[Path]:
|
|
128
|
+
"""Mirror bin/roll's _loop_resolve_project_path: resolve a project slug
|
|
129
|
+
back to the absolute project root directory.
|
|
130
|
+
|
|
131
|
+
Priority chain (same as bash):
|
|
132
|
+
1. ROLL_MAIN_PROJECT env var (set by cycle runner)
|
|
133
|
+
2. macOS launchd plist WorkingDirectory for com.roll.loop.<slug>
|
|
134
|
+
3. crontab entry referencing run-<slug>.sh
|
|
135
|
+
4. inner runner script at ~/.shared/roll/loop/run-<slug>-inner.sh
|
|
136
|
+
"""
|
|
137
|
+
# 1. Env var
|
|
138
|
+
env_proj = os.environ.get("ROLL_MAIN_PROJECT", "").strip()
|
|
139
|
+
if env_proj and Path(env_proj).is_dir():
|
|
140
|
+
return Path(env_proj)
|
|
141
|
+
|
|
142
|
+
# 2. macOS launchd plist
|
|
143
|
+
if sys.platform == "darwin":
|
|
144
|
+
plist = Path.home() / "Library" / "LaunchAgents" / f"com.roll.loop.{slug}.plist"
|
|
145
|
+
if plist.exists():
|
|
146
|
+
try:
|
|
147
|
+
text = plist.read_text(errors="ignore")
|
|
148
|
+
m = re.search(
|
|
149
|
+
r"<key>WorkingDirectory</key>\s*<string>([^<]+)</string>",
|
|
150
|
+
text
|
|
151
|
+
)
|
|
152
|
+
if m:
|
|
153
|
+
proj = Path(m.group(1))
|
|
154
|
+
if proj.is_dir():
|
|
155
|
+
return proj
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
# 3. crontab
|
|
160
|
+
try:
|
|
161
|
+
cron_out = subprocess.check_output(
|
|
162
|
+
["crontab", "-l"], stderr=subprocess.DEVNULL, text=True
|
|
163
|
+
)
|
|
164
|
+
for line in cron_out.splitlines():
|
|
165
|
+
if f"run-{slug}.sh" in line:
|
|
166
|
+
# Match: cd "<path>"
|
|
167
|
+
m = re.search(r'cd\s+"([^"]+)"', line)
|
|
168
|
+
if m:
|
|
169
|
+
proj = Path(m.group(1))
|
|
170
|
+
if proj.is_dir():
|
|
171
|
+
return proj
|
|
172
|
+
except Exception:
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
# 4. Inner runner script (grep for ROLL_MAIN_PROJECT=)
|
|
176
|
+
inner_script = shared_root() / "loop" / f"run-{slug}-inner.sh"
|
|
177
|
+
if inner_script.exists():
|
|
178
|
+
try:
|
|
179
|
+
text = inner_script.read_text(errors="ignore")
|
|
180
|
+
m = re.search(r'export ROLL_MAIN_PROJECT="([^"]+)"', text)
|
|
181
|
+
if m:
|
|
182
|
+
proj = Path(m.group(1))
|
|
183
|
+
if proj.is_dir():
|
|
184
|
+
return proj
|
|
185
|
+
except Exception:
|
|
186
|
+
pass
|
|
187
|
+
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _loop_runtime_dir_py(slug: str) -> Optional[Path]:
|
|
192
|
+
"""Mirror bin/roll's _loop_runtime_dir: return <project>/.roll/loop."""
|
|
193
|
+
proj = _resolve_project_path(slug)
|
|
194
|
+
if proj is None:
|
|
195
|
+
return None
|
|
196
|
+
return proj / ".roll" / "loop"
|
|
197
|
+
|
|
124
198
|
# ════════════════════════════════════════════════════════════════════════════
|
|
125
199
|
# Loaders
|
|
126
200
|
# ════════════════════════════════════════════════════════════════════════════
|
|
127
201
|
def load_events(slug: str, days: int) -> List[Dict[str, Any]]:
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
#
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
202
|
+
# FIX-137: read from project-local .roll/loop/events.ndjson first
|
|
203
|
+
# (mirrors _loop_event writer), fall back to shared events-<slug>.ndjson
|
|
204
|
+
# for historical data that hasn't been migrated yet.
|
|
205
|
+
candidates: List[Path] = []
|
|
206
|
+
|
|
207
|
+
# Primary: project-local (same path as _loop_event writer, US-LOOP-020)
|
|
208
|
+
rt_dir = _loop_runtime_dir_py(slug)
|
|
209
|
+
if rt_dir is not None:
|
|
210
|
+
head = rt_dir / "events.ndjson"
|
|
211
|
+
candidates.append(head)
|
|
212
|
+
candidates.extend(head.with_suffix(f".ndjson.{i}") for i in range(1, 5))
|
|
213
|
+
|
|
214
|
+
# Fallback: shared (old data pre-US-LOOP-020, migration not yet done)
|
|
215
|
+
shared_head = shared_root() / "loop" / f"events-{slug}.ndjson"
|
|
216
|
+
candidates.append(shared_head)
|
|
217
|
+
candidates.extend(shared_head.with_suffix(f".ndjson.{i}") for i in range(1, 5))
|
|
218
|
+
|
|
134
219
|
existing = [p for p in candidates if p.exists()]
|
|
135
220
|
if not existing:
|
|
136
221
|
return []
|
|
@@ -171,8 +256,16 @@ _CRON_PAT = re.compile(
|
|
|
171
256
|
)
|
|
172
257
|
|
|
173
258
|
def load_cron_log(slug: str) -> List[Dict[str, Any]]:
|
|
174
|
-
"""Return ordered list of cron entries with local HH:MM:SS + extracted fields.
|
|
175
|
-
|
|
259
|
+
"""Return ordered list of cron entries with local HH:MM:SS + extracted fields.
|
|
260
|
+
|
|
261
|
+
FIX-137: checks project-local .roll/loop/cron.log first, falls back to
|
|
262
|
+
shared cron-<slug>.log."""
|
|
263
|
+
# Primary: project-local
|
|
264
|
+
rt_dir = _loop_runtime_dir_py(slug)
|
|
265
|
+
path = (rt_dir / "cron.log") if rt_dir is not None else None
|
|
266
|
+
if path is None or not path.exists():
|
|
267
|
+
# Fallback: shared
|
|
268
|
+
path = shared_root() / "loop" / f"cron-{slug}.log"
|
|
176
269
|
if not path.exists():
|
|
177
270
|
return []
|
|
178
271
|
out: List[Dict[str, Any]] = []
|
|
@@ -193,8 +286,16 @@ def load_cron_log(slug: str) -> List[Dict[str, Any]]:
|
|
|
193
286
|
return out
|
|
194
287
|
|
|
195
288
|
def load_state(slug: str) -> Dict[str, str]:
|
|
196
|
-
"""Tiny YAML reader — only the flat keys bin/roll writes.
|
|
197
|
-
|
|
289
|
+
"""Tiny YAML reader — only the flat keys bin/roll writes.
|
|
290
|
+
|
|
291
|
+
FIX-137: checks project-local .roll/loop/state.yaml first, falls back to
|
|
292
|
+
shared state-<slug>.yaml."""
|
|
293
|
+
# Primary: project-local
|
|
294
|
+
rt_dir = _loop_runtime_dir_py(slug)
|
|
295
|
+
path = (rt_dir / "state.yaml") if rt_dir is not None else None
|
|
296
|
+
if path is None or not path.exists():
|
|
297
|
+
# Fallback: shared
|
|
298
|
+
path = shared_root() / "loop" / f"state-{slug}.yaml"
|
|
198
299
|
if not path.exists():
|
|
199
300
|
return {}
|
|
200
301
|
out: Dict[str, str] = {}
|
|
@@ -564,10 +665,18 @@ def repair_orphan_cycles_from_git(cycles: List[Dict[str, Any]], git_merges: Dict
|
|
|
564
665
|
|
|
565
666
|
def load_runs(slug: str) -> Dict[str, Dict[str, Any]]:
|
|
566
667
|
"""Map run_id → run row for the current project (filters out other slugs
|
|
567
|
-
sharing
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
668
|
+
sharing runs.jsonl). Lenient slug matching salvages entries written under
|
|
669
|
+
buggy slugs (FIX-053): the bare project basename (e.g. 'Roll') or worktree
|
|
670
|
+
paths (e.g. '{slug}-cycle-XXX').
|
|
671
|
+
|
|
672
|
+
FIX-137: reads from project-local .roll/loop/runs.jsonl first, falls back
|
|
673
|
+
to shared runs.jsonl."""
|
|
674
|
+
# Primary: project-local
|
|
675
|
+
rt_dir = _loop_runtime_dir_py(slug)
|
|
676
|
+
path = (rt_dir / "runs.jsonl") if rt_dir is not None else None
|
|
677
|
+
if path is None or not path.exists():
|
|
678
|
+
# Fallback: shared (cross-project)
|
|
679
|
+
path = shared_root() / "loop" / "runs.jsonl"
|
|
571
680
|
if not path.exists():
|
|
572
681
|
return {}
|
|
573
682
|
base = slug.split("-")[0] # 'Roll-a43d1b' → 'Roll'
|
package/package.json
CHANGED
|
@@ -344,8 +344,12 @@ After each item completes:
|
|
|
344
344
|
|
|
345
345
|
**Path B — heal exhausted (≥`ROLL_LOOP_HEAL_MAX`, default 2) or disabled (`ROLL_LOOP_NO_HEAL=1`) (exit 1):**
|
|
346
346
|
|
|
347
|
-
1.
|
|
348
|
-
|
|
347
|
+
1. Do NOT force ✅ Done here. CI red means the PR will not merge, and
|
|
348
|
+
FIX-140's merge gate (in `bin/roll`, after `publish_wait_merge`) is the
|
|
349
|
+
single authority on final status: if the PR actually merges, the story
|
|
350
|
+
stays ✅ Done; if it does not merge, the gate reverts it ✅ Done → 📋 Todo
|
|
351
|
+
so BACKLOG never shows a false Done for code that isn't on main. (FIX-141
|
|
352
|
+
then stops the next cycle re-opening a duplicate PR while this one is open.)
|
|
349
353
|
2. Write ALERT to `~/.shared/roll/loop/ALERT-<slug>.md` with:
|
|
350
354
|
- story ID, time, commit SHA
|
|
351
355
|
- heal attempts made (read `heal_count:` from `state-<slug>.yaml`)
|