@seanyao/roll 2026.529.3 → 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 +17 -0
- package/bin/roll +76 -21
- 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-doc/SKILL.md +55 -0
- package/skills/roll-loop/SKILL.md +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v2026.529.5
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- loop dashboard 恢复显示当天跑完的 cycle(读取端对齐项目本地事件)`[loop]`
|
|
8
|
+
- 非 Claude agent 执行阶段恢复存活心跳(修复心跳子进程 fork 早于 source)`[loop]`
|
|
9
|
+
|
|
10
|
+
## v2026.529.4
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- 非 Claude agent 跑 loop 不再黑屏 `[loop]`
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- `roll-doc` 现在追踪数据流和调用链 `[loop]`
|
|
19
|
+
|
|
3
20
|
## v2026.529.3
|
|
4
21
|
|
|
5
22
|
### Fixed
|
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"
|
|
@@ -6031,6 +6031,10 @@ printf '%s:%s\n' "\$\$" "\$(date -u +%s)" > "\$INNER_LOCK"
|
|
|
6031
6031
|
# readers see "still alive in <phase>" during long-running silences (e.g.
|
|
6032
6032
|
# agent_invoke 5-45 min). CURRENT_PHASE is maintained by _phase_begin/_phase_end.
|
|
6033
6033
|
HEARTBEAT_FILE="\${_SHARED_ROOT:-\${HOME}/.shared/roll}/loop/.heartbeat-${slug}"
|
|
6034
|
+
# FIX-136: file-based phase tracking so forked heartbeat child can see phase changes.
|
|
6035
|
+
# CURRENT_PHASE is inherited at fork time — changes after fork are invisible to child.
|
|
6036
|
+
# Writing phase state to a file decouples the heartbeat from fork-time snapshot.
|
|
6037
|
+
HEARTBEAT_PHASE_FILE="\${_SHARED_ROOT:-\${HOME}/.shared/roll}/loop/.phase-${slug}"
|
|
6034
6038
|
CURRENT_PHASE=""
|
|
6035
6039
|
# bash 3.2 (macOS /bin/bash) lacks associative arrays — use namespaced
|
|
6036
6040
|
# variables via 'printf -v' + indirect '\${!VAR}' expansion instead.
|
|
@@ -6042,6 +6046,8 @@ _phase_begin() {
|
|
|
6042
6046
|
local _name="\$1"
|
|
6043
6047
|
printf -v "_PHASE_START_\${_name}" '%s' "\$(date +%s)"
|
|
6044
6048
|
CURRENT_PHASE="\$_name"
|
|
6049
|
+
# FIX-136: write phase+start_ts to file so forked heartbeat child can read it
|
|
6050
|
+
printf '%s %s' "\$_name" "\$(date +%s)" > "\$HEARTBEAT_PHASE_FILE"
|
|
6045
6051
|
_loop_event phase_start "\$_name" "" "" || true
|
|
6046
6052
|
}
|
|
6047
6053
|
_phase_end() {
|
|
@@ -6053,24 +6059,32 @@ _phase_end() {
|
|
|
6053
6059
|
printf -v "_PHASE_DUR_\${_name}" '%s' "\$_dur"
|
|
6054
6060
|
case " \$_PHASE_NAMES_DONE " in *" \$_name "*) ;; *) _PHASE_NAMES_DONE="\${_PHASE_NAMES_DONE} \$_name" ;; esac
|
|
6055
6061
|
CURRENT_PHASE=""
|
|
6062
|
+
# FIX-136: clear phase file so heartbeat knows no active phase
|
|
6063
|
+
echo -n > "\$HEARTBEAT_PHASE_FILE" 2>/dev/null || true
|
|
6056
6064
|
_loop_event phase_end "\$_name" "\${_dur}s" "\$_outcome" || true
|
|
6057
6065
|
}
|
|
6058
6066
|
_heartbeat_writer() {
|
|
6059
6067
|
while true; do
|
|
6060
6068
|
echo "\$(date -u +%s)" > "\$HEARTBEAT_FILE"
|
|
6061
|
-
|
|
6062
|
-
|
|
6063
|
-
|
|
6064
|
-
|
|
6065
|
-
|
|
6066
|
-
|
|
6069
|
+
# FIX-136: read phase from file — CURRENT_PHASE is inherited at fork and
|
|
6070
|
+
# never updated. The parent writes phase+start_ts on _phase_begin, clears
|
|
6071
|
+
# on _phase_end. No phase file = no active phase = skip tick.
|
|
6072
|
+
if [ -f "\$HEARTBEAT_PHASE_FILE" ]; then
|
|
6073
|
+
read -r _hb_phase _hb_start_ts < "\$HEARTBEAT_PHASE_FILE" 2>/dev/null || true
|
|
6074
|
+
if [ -n "\$_hb_phase" ] && [ -n "\$_hb_start_ts" ]; then
|
|
6075
|
+
local _el=\$(( \$(date +%s) - _hb_start_ts ))
|
|
6076
|
+
_loop_event phase_tick "\$_hb_phase" "\${_el}s elapsed" "" 2>/dev/null || true
|
|
6067
6077
|
fi
|
|
6068
6078
|
fi
|
|
6069
6079
|
sleep 60
|
|
6070
6080
|
done
|
|
6071
6081
|
}
|
|
6072
|
-
_heartbeat_writer
|
|
6073
|
-
|
|
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.
|
|
6074
6088
|
# FIX-057: cycle hard timeout — 45 minute SLA per loop cycle. If a cycle runs
|
|
6075
6089
|
# longer, kill claude / loop-fmt.py / all backgrounded children, mark the
|
|
6076
6090
|
# in-progress backlog item Blocked (caller decides), and exit cleanly so the
|
|
@@ -6300,11 +6314,10 @@ _runs_append "failed" 0 "[]" "\$_phases_t" 2>/dev/null || true
|
|
|
6300
6314
|
sed -E 's/\x1b\[[0-9;]*[A-Za-z]//g; s/\r$//' "\$ROLL_CYCLE_LOG_RAW" \
|
|
6301
6315
|
> "\${_log_dir}/\${CYCLE_ID}.log" 2>/dev/null || true
|
|
6302
6316
|
rm -f "\$ROLL_CYCLE_LOG_RAW"
|
|
6303
|
-
#
|
|
6304
|
-
|
|
6305
|
-
( 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 的限制,所有的都要留下来".
|
|
6306
6319
|
fi
|
|
6307
|
-
rm -f "\$INNER_LOCK" "\$HEARTBEAT_FILE"
|
|
6320
|
+
rm -f "\$INNER_LOCK" "\$HEARTBEAT_FILE" "\$HEARTBEAT_PHASE_FILE"
|
|
6308
6321
|
exit "\$_rc"
|
|
6309
6322
|
}
|
|
6310
6323
|
trap '_inner_cleanup' EXIT
|
|
@@ -6316,6 +6329,11 @@ trap '_inner_cleanup' EXIT
|
|
|
6316
6329
|
source "${roll_bin}"
|
|
6317
6330
|
set +e
|
|
6318
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
|
+
|
|
6319
6337
|
# FIX-052: bin/roll initializes loop state paths from cwd at source time, but
|
|
6320
6338
|
# the inner script may be launched from anywhere. Override to this project's
|
|
6321
6339
|
# slug (baked at template generation) so helpers like _worktree_alert write
|
|
@@ -6468,6 +6486,11 @@ export LOOP_SHARED_ROOT="\${_SHARED_ROOT:-\$HOME/.shared/roll}"
|
|
|
6468
6486
|
export ROLL_LOOP_AGENT="\${CYCLE_AGENT:-\$(_project_agent)}"
|
|
6469
6487
|
export ROLL_LOOP_ROUTED_STORY ROLL_LOOP_ROUTED_AGENT ROLL_LOOP_ROUTED_RULE
|
|
6470
6488
|
_phase_begin agent_invoke
|
|
6489
|
+
# FIX-136: non-claude agents (pi/deepseek/kimi) buffer stdout when piped.
|
|
6490
|
+
# Force a pseudo-TTY via script(1) so loop-fmt.py's passthrough receives
|
|
6491
|
+
# output in real time — without this, tmux is black for the entire phase.
|
|
6492
|
+
_AGENT_PTY_PREFIX=""
|
|
6493
|
+
[ "\$ROLL_LOOP_AGENT" != "claude" ] && _AGENT_PTY_PREFIX="script -q /dev/null"
|
|
6471
6494
|
for _attempt in 1 2 3; do
|
|
6472
6495
|
# FIX-068: defensive reset before each attempt — _CYCLE_TIMED_OUT carries
|
|
6473
6496
|
# the SIGTERM result of the previous attempt and would otherwise force an
|
|
@@ -6493,11 +6516,11 @@ for _attempt in 1 2 3; do
|
|
|
6493
6516
|
# FIX-134: prefer the runtime-rebuilt command (routing-aware); fall back to
|
|
6494
6517
|
# the baked command (project agent at \`roll loop on\` time) when empty.
|
|
6495
6518
|
if [ -f "\$FMT" ]; then
|
|
6496
|
-
if [ -n "\$_CYCLE_CMD" ]; then ( cd "\$WT" && eval "\$_CYCLE_CMD" ) | python3 "\$FMT"
|
|
6497
|
-
else ( cd "\$WT" && ${agent_cmd} ) | python3 "\$FMT"; fi
|
|
6519
|
+
if [ -n "\$_CYCLE_CMD" ]; then ( cd "\$WT" && eval \$_AGENT_PTY_PREFIX "\$_CYCLE_CMD" ) | python3 "\$FMT"
|
|
6520
|
+
else ( cd "\$WT" && \$_AGENT_PTY_PREFIX ${agent_cmd} ) | python3 "\$FMT"; fi
|
|
6498
6521
|
else
|
|
6499
|
-
if [ -n "\$_CYCLE_CMD" ]; then ( cd "\$WT" && eval "\$_CYCLE_CMD" )
|
|
6500
|
-
else ( cd "\$WT" && ${agent_cmd} ); fi
|
|
6522
|
+
if [ -n "\$_CYCLE_CMD" ]; then ( cd "\$WT" && eval \$_AGENT_PTY_PREFIX "\$_CYCLE_CMD" )
|
|
6523
|
+
else ( cd "\$WT" && \$_AGENT_PTY_PREFIX ${agent_cmd} ); fi
|
|
6501
6524
|
fi
|
|
6502
6525
|
_exit=\$?
|
|
6503
6526
|
kill "\$_WATCHDOG_PID" 2>/dev/null
|
|
@@ -6605,6 +6628,11 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
|
|
|
6605
6628
|
_phase_begin publish_wait_merge
|
|
6606
6629
|
if ! ( cd "\$WT" && _loop_wait_pr_merge "\$BRANCH" ); then
|
|
6607
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
|
|
6608
6636
|
_phase_end publish_wait_merge fail
|
|
6609
6637
|
else
|
|
6610
6638
|
_phase_end publish_wait_merge ok
|
|
@@ -6754,7 +6782,11 @@ fi
|
|
|
6754
6782
|
LOCK="\$(dirname "\$0")/.LOCK-\$(basename "\$0" .sh | sed 's/^run-//')"
|
|
6755
6783
|
SESSION="roll-loop-\$(basename "\$0" .sh | sed 's/^run-//')"
|
|
6756
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.
|
|
6757
6788
|
LOG="${log_path}"
|
|
6789
|
+
mkdir -p "\$(dirname "\$LOG")" 2>/dev/null || true
|
|
6758
6790
|
if [ -f "\$LOCK" ]; then
|
|
6759
6791
|
prev_pid=\$(head -1 "\$LOCK" 2>/dev/null || echo "")
|
|
6760
6792
|
if [ -n "\$prev_pid" ] && kill -0 "\$prev_pid" 2>/dev/null; then
|
|
@@ -6794,7 +6826,10 @@ if command -v tmux >/dev/null 2>&1; then
|
|
|
6794
6826
|
CYCLE_LOG_RAW="${project_path}/.roll/cycle-logs/.pipe-\$\$.raw"
|
|
6795
6827
|
export ROLL_CYCLE_LOG_RAW="\$CYCLE_LOG_RAW"
|
|
6796
6828
|
tmux new-session -d -s "\$SESSION" -x 200 -y 50 "bash \"\$INNER_SCRIPT\""
|
|
6797
|
-
|
|
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\""
|
|
6798
6833
|
# Auto-attach popup: when not muted, spawn a Terminal.app window attached
|
|
6799
6834
|
# to the tmux session so the user can watch the loop work in real time.
|
|
6800
6835
|
# FIX-054: terminal selection removed — fixed to macOS Terminal.app for
|
|
@@ -6814,8 +6849,10 @@ if command -v tmux >/dev/null 2>&1; then
|
|
|
6814
6849
|
# cron-<slug>.log file still has the full transcript as a fallback.
|
|
6815
6850
|
# FIX-131: after tmux session ends, open the cron log with less so the
|
|
6816
6851
|
# user can scroll through the full cycle output instead of seeing nothing.
|
|
6817
|
-
|
|
6818
|
-
|
|
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
|
|
6819
6856
|
chmod +x "\$_attach_cmd" 2>/dev/null || true
|
|
6820
6857
|
open -g -a Terminal "\$_attach_cmd" >/dev/null 2>&1 || true
|
|
6821
6858
|
fi
|
|
@@ -6957,7 +6994,11 @@ _install_launchd_plists() {
|
|
|
6957
6994
|
local cmd; cmd=$(_agent_skill_cmd "${sd}/${skill}/SKILL.md" 2>/dev/null || echo "roll loop now")
|
|
6958
6995
|
|
|
6959
6996
|
if [[ "$svc" == "loop" ]]; then
|
|
6960
|
-
|
|
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"
|
|
6961
7002
|
else
|
|
6962
7003
|
_write_runner_script "$runner" "$project_path" "cd \"${project_path}\" && ${cmd}" "$log"
|
|
6963
7004
|
fi
|
|
@@ -9635,6 +9676,15 @@ _loop_pick_next_story() {
|
|
|
9635
9676
|
local backlog="${1:-.roll/backlog.md}"
|
|
9636
9677
|
[ -f "$backlog" ] || return 1
|
|
9637
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
|
+
|
|
9638
9688
|
# Two passes over the file, once per type prefix, return first hit.
|
|
9639
9689
|
local prefix
|
|
9640
9690
|
for prefix in FIX US REFACTOR; do
|
|
@@ -9655,6 +9705,11 @@ _loop_pick_next_story() {
|
|
|
9655
9705
|
if ! _loop_check_depends_on "$id" "$backlog" >/dev/null 2>&1; then
|
|
9656
9706
|
continue
|
|
9657
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
|
|
9658
9713
|
printf '%s\n' "$id"
|
|
9659
9714
|
return 0
|
|
9660
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
package/skills/roll-doc/SKILL.md
CHANGED
|
@@ -217,6 +217,61 @@ detection rule and a target output file. Skip any topic whose target file alread
|
|
|
217
217
|
| Agent 入口 (AGENTS.md) | Project root has no `AGENTS.md` AND `src/` (or equivalent source root) has ≥ 3 subdirectories | `AGENTS.md` |
|
|
218
218
|
| 高引用目录 | Directory imported by ≥ 5 other source files, even if directory itself has < 3 source files | `<dir>/README.md` |
|
|
219
219
|
|
|
220
|
+
#### Data Flow / Import Chain Tracing
|
|
221
|
+
|
|
222
|
+
**Entry point selection:** start from entry files — any file matching patterns:
|
|
223
|
+
`bin/*`, `cmd/**/*`, `main.*` (e.g. `main.ts`, `main.py`), `index.*` (e.g. `index.ts`, `index.jsx`),
|
|
224
|
+
`App.*`, `server.*`. Exclude `node_modules/`, `dist/`, `build/`, test files (`*.test.*`, `*.spec.*`, `tests/`).
|
|
225
|
+
|
|
226
|
+
**Chain construction:**
|
|
227
|
+
1. For each entry file, read its imports from the symbol table's `imports` field.
|
|
228
|
+
2. Recursively follow each imported file to its own imports, building a directed call graph.
|
|
229
|
+
3. Stop at leaf nodes — files that import nothing or whose imports all point to:
|
|
230
|
+
- External packages (node_modules / stdlib / third-party)
|
|
231
|
+
- Already-visited nodes (cycle termination)
|
|
232
|
+
4. Each distinct path from an entry file to a leaf is one call chain.
|
|
233
|
+
|
|
234
|
+
**Threshold (cross-directory filter):**
|
|
235
|
+
A call chain is valid for inclusion only if it spans **≥ 3 distinct source directories**.
|
|
236
|
+
Count based on the unique parent directories of files in the chain:
|
|
237
|
+
`src/cli/main.ts → src/commands/build.ts → lib/utils/fs.ts` = 3 directories ✅
|
|
238
|
+
`src/cli/main.ts → src/cli/config.ts → lib/utils/fs.ts` = 2 directories ❌
|
|
239
|
+
If no chain meets the ≥ 3 directory threshold, skip generation entirely (no empty `docs/data-flows.md`).
|
|
240
|
+
|
|
241
|
+
**Output document structure** (`docs/data-flows.md`):
|
|
242
|
+
|
|
243
|
+
```markdown
|
|
244
|
+
> **Draft** — auto-generated by roll-doc on YYYY-MM-DD. Review before treating as authoritative.
|
|
245
|
+
|
|
246
|
+
# Data Flows
|
|
247
|
+
|
|
248
|
+
## Flow: {short descriptive name from entry file purpose}
|
|
249
|
+
|
|
250
|
+
**Entry point:** `{entry_file}:{line}`
|
|
251
|
+
**Directories spanned:** N ({comma-separated list})
|
|
252
|
+
|
|
253
|
+
### Complete Call Chain
|
|
254
|
+
|
|
255
|
+
{entry_file}
|
|
256
|
+
→ import {symbol} from "{file}" ({line})
|
|
257
|
+
→ import {symbol} from "{file}" ({line})
|
|
258
|
+
→ ... (leaf node)
|
|
259
|
+
|
|
260
|
+
### Files Involved
|
|
261
|
+
|
|
262
|
+
| Step | File:Line | Function / Method |
|
|
263
|
+
|------|-----------|-------------------|
|
|
264
|
+
| 1 | `path/to/file:12` | `functionName` |
|
|
265
|
+
| 2 | `path/to/file:34` | `otherFunction` |
|
|
266
|
+
| ... | ... | ... |
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
- Sort flows by number of directories spanned, descending (widest cross-cutting flow first).
|
|
270
|
+
- If an entry file produces multiple distinct call chains, list each one as a separate flow entry.
|
|
271
|
+
- `file:line` annotations must come from actual symbol table records — do not fabricate.
|
|
272
|
+
|
|
273
|
+
**Idempotency:** skip (do not overwrite) if `docs/data-flows.md` already exists, unless `--force`.
|
|
274
|
+
|
|
220
275
|
### Step 3 — Source Annotations
|
|
221
276
|
|
|
222
277
|
Every topic document generated in Step 2 must cite `file:line` for each claim (function call,
|
|
@@ -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`)
|