@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 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.3"
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
- if [ -n "\$CURRENT_PHASE" ]; then
6062
- local _start_var="_PHASE_START_\${CURRENT_PHASE}"
6063
- local _phase_start="\${!_start_var:-}"
6064
- if [ -n "\$_phase_start" ]; then
6065
- local _el=\$(( \$(date +%s) - _phase_start ))
6066
- _loop_event phase_tick "\$CURRENT_PHASE" "\${_el}s elapsed" "" 2>/dev/null || true
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
- _HEARTBEAT_PID=\$!
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
- # Rotate: keep newest ROLL_CYCLE_LOG_KEEP (default 50) .log files
6304
- _keep="\${ROLL_CYCLE_LOG_KEEP:-50}"
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
- tmux pipe-pane -t "\$SESSION" "tee -a \"\$LOG\" >> \"\$ROLL_CYCLE_LOG_RAW\""
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
- printf '#!/bin/bash\\ntmux attach -t %s 2>/dev/null\\nLOGFILE=~/.shared/roll/loop/cron-%s.log\\necho\\nif [ -f "\$LOGFILE" ]; then\\n echo "================================================================"\\n echo " Cycle ended — showing log (arrows to scroll, q to close)"\\n echo "================================================================"\\n less -R +G "\$LOGFILE"\\nelse\\n echo "================================================================"\\n echo " Cycle ended. Log not found: \$LOGFILE"\\n echo " press enter to close."\\n echo "================================================================"\\n read _\\nfi\\n' \\
6818
- "\$SESSION" "${slug}" > "\$_attach_cmd" 2>/dev/null || true
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
- _write_loop_runner_script "$runner" "$project_path" "cd \"${project_path}\" && ${cmd}" "$log" "$active_start" "$active_end" "${sd}/${skill}/SKILL.md"
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"
@@ -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
- # US-LOOP-023: read the head NDJSON plus its rotated siblings .1..4.
129
- # bin/roll rotates events-<slug>.ndjson at 10MB keeping 4 archives; without
130
- # this loop the dashboard silently dropped any cycle whose events landed in
131
- # a rotated file (the "永久留存" promise of US-LOOP-004 only held on disk).
132
- head = shared_root() / "loop" / f"events-{slug}.ndjson"
133
- candidates = [head] + [head.with_suffix(f".ndjson.{i}") for i in range(1, 5)]
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
- path = shared_root() / "loop" / f"cron-{slug}.log"
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
- path = shared_root() / "loop" / f"state-{slug}.yaml"
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 ~/.shared/roll/loop/runs.jsonl). Lenient slug matching salvages
568
- entries written under buggy slugs (FIX-053): the bare project basename
569
- (e.g. 'Roll') or worktree paths (e.g. '{slug}-cycle-XXX')."""
570
- path = shared_root() / "loop" / "runs.jsonl"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanyao/roll",
3
- "version": "2026.529.3",
3
+ "version": "2026.529.5",
4
4
  "description": "Roll — Roll out features with AI agents",
5
5
  "scripts": {
6
6
  "test": "bash tests/run.sh"
@@ -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. Keep story as ✅ Done commits are already on main; CI red is a follow-up
348
- problem, not a story failure.
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`)