@seanyao/roll 2026.524.1 → 2026.524.2

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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## v2026.524.2
4
+
5
+ ### Added
6
+
7
+ - **用 pi / deepseek / kimi 跑的 loop 也能在终端里实时看到进度** — 不再黑屏 `[loop]`
8
+ - **`roll slides new` 可以用项目自己的模板** — 不只用内置的了 `[deck]`
9
+ - **`roll slides list` 一眼看出 slide 状态** — 哪些能看、哪些生成失败、失败原因也能查 `[deck]`
10
+
11
+ ### Fixed
12
+
13
+ - **loop 不再一次吞太多故事** — 每次只做一个,时间可预期 `[loop]`
14
+ - **dashboard 不再对非 Claude 模型显示空白** — 能看出每轮谁跑的 `[loop]`
15
+
3
16
  ## v2026.524.1
4
17
 
5
18
  ### Added
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.524.1"
7
+ VERSION="2026.524.2"
8
8
  ROLL_HOME="${ROLL_HOME:-${HOME}/.roll}"
9
9
  ROLL_CONFIG="${ROLL_HOME}/config.yaml"
10
10
  ROLL_GLOBAL="${ROLL_HOME}/conventions/global"
@@ -3342,11 +3342,13 @@ USAGE 用法
3342
3342
  roll slides new "<topic>" [--template <name>] [--no-build]
3343
3343
  Generate deck.md via AI, then auto-build + open HTML
3344
3344
  通过 AI 生成 deck.md,自动渲染并打开 HTML
3345
- roll slides list List all decks under .roll/slides/ as a table
3346
- 列出 .roll/slides/ 下所有幻灯片
3345
+ roll slides list List all decks (built / stale / failed / unbuilt)
3346
+ 列出 .roll/slides/ 下所有幻灯片(四态)
3347
3347
  roll slides preview <slug> [--no-open]
3348
3348
  Open .roll/slides/<slug>.html in the default browser
3349
3349
  在浏览器中打开已渲染的幻灯片
3350
+ roll slides logs <slug> Show the last build failure log for a deck
3351
+ 显示幻灯片上次构建失败日志
3350
3352
 
3351
3353
  OPTIONS 选项
3352
3354
  --no-open Skip auto-opening the rendered HTML in a browser
@@ -3367,6 +3369,12 @@ _slides_lib() {
3367
3369
  # Returns 0 + prints the path if the template exists, else returns 1.
3368
3370
  _slides_template_path() {
3369
3371
  local name="$1"
3372
+ # US-DECK-013: project-level template override takes priority over built-in.
3373
+ local proj_tpl=".roll/slides/templates/${name}.html"
3374
+ if [[ -f "$proj_tpl" ]]; then
3375
+ printf '%s' "$proj_tpl"
3376
+ return 0
3377
+ fi
3370
3378
  local tpl="${ROLL_PKG_DIR}/lib/slides/templates/${name}.html"
3371
3379
  if [[ -f "$tpl" ]]; then
3372
3380
  printf '%s' "$tpl"
@@ -3459,8 +3467,19 @@ cmd_slides_build() {
3459
3467
  return 1
3460
3468
  fi
3461
3469
 
3470
+ # US-DECK-011: capture failure to .last-build.err for slides logs
3471
+ local err_file=".roll/slides/${slug}/.last-build.err"
3472
+
3462
3473
  # 1. Validate first (fail-fast on AI-generated decks).
3463
- if ! python3 "$validator" "$deck"; then
3474
+ # Exit 2 = grounding warning (schema OK, evidence below threshold) — warn but don't fail.
3475
+ local val_out val_exit
3476
+ val_out=$(python3 "$validator" "$deck" 2>&1) || val_exit=$?
3477
+ if [[ "${val_exit:-0}" -eq 2 ]]; then
3478
+ echo "[roll] ${val_out}" >&2
3479
+ elif [[ "${val_exit:-0}" -ne 0 ]]; then
3480
+ local ts; ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
3481
+ mkdir -p ".roll/slides/${slug}"
3482
+ printf '[%s] stage=validate\n%s\n' "$ts" "$val_out" > "$err_file"
3464
3483
  err "deck.md validation failed — fix the issues above before building."
3465
3484
  echo " deck.md 校验失败,请先修复上方提示再重试。" >&2
3466
3485
  return 1
@@ -3470,16 +3489,25 @@ cmd_slides_build() {
3470
3489
  local tpl_name; tpl_name=$(_slides_template_for_deck "$deck")
3471
3490
  local tpl_path
3472
3491
  if ! tpl_path=$(_slides_template_path "$tpl_name"); then
3492
+ local ts; ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
3493
+ mkdir -p ".roll/slides/${slug}"
3494
+ printf '[%s] stage=template\ntemplate not found: %s\n' "$ts" "$tpl_name" > "$err_file"
3473
3495
  err "Template not found: ${tpl_name} 未找到模板:${tpl_name}"
3474
3496
  return 1
3475
3497
  fi
3476
3498
 
3477
3499
  local out=".roll/slides/${slug}.html"
3478
3500
  mkdir -p ".roll/slides"
3479
- if ! python3 "$renderer" "$deck" "$tpl_path" "$out"; then
3501
+ local render_out; render_out=$(python3 "$renderer" "$deck" "$tpl_path" "$out" 2>&1) || {
3502
+ local ts; ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
3503
+ mkdir -p ".roll/slides/${slug}"
3504
+ printf '[%s] stage=render\n%s\n' "$ts" "$render_out" > "$err_file"
3480
3505
  err "Render failed for ${deck} 渲染失败:${deck}"
3481
3506
  return 1
3482
- fi
3507
+ }
3508
+
3509
+ # US-DECK-011: build succeeded — remove any stale .last-build.err
3510
+ rm -f "$err_file" 2>/dev/null || true
3483
3511
 
3484
3512
  # 3. Default-ignore the HTML artefact so it doesn't accidentally get committed.
3485
3513
  _slides_ensure_gitignore
@@ -3571,31 +3599,42 @@ cmd_slides_list() {
3571
3599
  IFS=$'\n' sorted_slugs=($(printf '%s\n' "${slugs[@]}" | sort))
3572
3600
  unset IFS
3573
3601
 
3574
- printf '%-20s %-20s %-12s %-12s %-5s %s\n' \
3602
+ printf '%-20s %-20s %-12s %-12s %-8s %s\n' \
3575
3603
  "slug" "template" "total_slides" "created" "built" "size"
3576
- printf '%-20s %-20s %-12s %-12s %-5s %s\n' \
3577
- "----" "--------" "------------" "-------" "-----" "----"
3604
+ printf '%-20s %-20s %-12s %-12s %-8s %s\n' \
3605
+ "----" "--------" "------------" "-------" "------" "----"
3578
3606
 
3579
- local s deck html template total created built size bytes
3607
+ local s deck html err_file template total created built size bytes
3580
3608
  for s in "${sorted_slugs[@]}"; do
3581
3609
  deck="${slides_dir}/${s}/deck.md"
3582
3610
  html="${slides_dir}/${s}.html"
3611
+ err_file="${slides_dir}/${s}/.last-build.err"
3583
3612
  template=$(_slides_frontmatter_field "$deck" "template")
3584
3613
  [[ -z "$template" ]] && template="-"
3585
3614
  total=$(_slides_frontmatter_field "$deck" "total_slides")
3586
3615
  [[ -z "$total" ]] && total="-"
3587
3616
  created=$(_slides_frontmatter_field "$deck" "created")
3588
3617
  [[ -z "$created" ]] && created="-"
3589
- if [[ -f "$html" ]]; then
3590
- built=""
3591
- bytes=$(wc -c <"$html" 2>/dev/null | tr -d ' ')
3592
- [[ -z "$bytes" ]] && bytes=0
3593
- size=$(_slides_human_size "$bytes")
3618
+ # US-DECK-011: 4-state built column
3619
+ if [[ -f "$err_file" ]]; then
3620
+ built="⚠ failed"
3621
+ size="-"
3622
+ elif [[ -f "$html" ]]; then
3623
+ # Check if deck.md is newer than the HTML (stale)
3624
+ if [[ "$deck" -nt "$html" ]]; then
3625
+ built="≈ stale"
3626
+ size="-"
3627
+ else
3628
+ built="✓ built"
3629
+ bytes=$(wc -c <"$html" 2>/dev/null | tr -d ' ')
3630
+ [[ -z "$bytes" ]] && bytes=0
3631
+ size=$(_slides_human_size "$bytes")
3632
+ fi
3594
3633
  else
3595
- built="✗"
3634
+ built="✗ unbuilt"
3596
3635
  size="-"
3597
3636
  fi
3598
- printf '%-20s %-20s %-12s %-12s %-5s %s\n' \
3637
+ printf '%-20s %-20s %-12s %-12s %-8s %s\n' \
3599
3638
  "$s" "$template" "$total" "$created" "$built" "$size"
3600
3639
  done
3601
3640
  return 0
@@ -3645,6 +3684,46 @@ cmd_slides_preview() {
3645
3684
  return 0
3646
3685
  }
3647
3686
 
3687
+ # ─── US-DECK-011 ─────────────────────────────────────────────────────────────
3688
+ cmd_slides_logs() {
3689
+ local slug=""
3690
+ while [[ $# -gt 0 ]]; do
3691
+ case "$1" in
3692
+ --help|-h) _slides_help; return 0 ;;
3693
+ --*) err "Unknown option: $1 未知选项: $1"; return 1 ;;
3694
+ *)
3695
+ if [[ -z "$slug" ]]; then
3696
+ slug="$1"; shift
3697
+ else
3698
+ err "Unexpected argument: $1 多余参数: $1"; return 1
3699
+ fi
3700
+ ;;
3701
+ esac
3702
+ done
3703
+
3704
+ if [[ -z "$slug" ]]; then
3705
+ err "Usage: roll slides logs <slug>"
3706
+ echo "用法: roll slides logs <slug>" >&2
3707
+ return 1
3708
+ fi
3709
+
3710
+ local deck_dir=".roll/slides/${slug}"
3711
+ local err_file="${deck_dir}/.last-build.err"
3712
+
3713
+ if [[ ! -d "$deck_dir" ]] || [[ ! -f "${deck_dir}/deck.md" ]]; then
3714
+ err "Deck not found: ${slug} 未找到幻灯片:${slug}"
3715
+ return 1
3716
+ fi
3717
+
3718
+ if [[ ! -f "$err_file" ]]; then
3719
+ info "No failure records for ${slug} 该幻灯片没有失败记录"
3720
+ return 0
3721
+ fi
3722
+
3723
+ cat "$err_file"
3724
+ return 0
3725
+ }
3726
+
3648
3727
  # ─── US-DECK-004 ─────────────────────────────────────────────────────────────
3649
3728
  # Turn a topic string into a kebab-case slug.
3650
3729
  # Lower-cases, replaces any run of non-alphanumerics with a single dash,
@@ -4001,6 +4080,9 @@ cmd_slides() {
4001
4080
  preview)
4002
4081
  cmd_slides_preview "$@"
4003
4082
  ;;
4083
+ logs)
4084
+ cmd_slides_logs "$@"
4085
+ ;;
4004
4086
  --help|-h|help)
4005
4087
  _slides_help
4006
4088
  return 0
@@ -5083,6 +5165,7 @@ if _worktree_fetch_origin main \\
5083
5165
  _worktree_sync_meta "\$WT" 2>/dev/null || true
5084
5166
  echo "[loop] cycle \${CYCLE_ID}: worktree \$WT on \$BRANCH"
5085
5167
  _loop_event cycle_start "\${CYCLE_ID}" "" "" || true
5168
+ _loop_event agent_used "\${CYCLE_ID}" "\$(_project_agent)" "primary" || true
5086
5169
  _phase_end worktree_setup ok
5087
5170
  else
5088
5171
  # P3 fix: skip the cycle entirely when worktree isolation fails.
@@ -5107,6 +5190,9 @@ FMT="${fmt_script}"
5107
5190
  export LOOP_PROJECT_SLUG="${slug}"
5108
5191
  export LOOP_CYCLE_ID="\${CYCLE_ID}"
5109
5192
  export LOOP_SHARED_ROOT="\${_SHARED_ROOT:-\$HOME/.shared/roll}"
5193
+ # US-LOOP-010: tell loop-fmt.py which agent is running so it can branch
5194
+ # rendering: claude → stream-json parser, others → transparent passthrough.
5195
+ export ROLL_LOOP_AGENT="\$(_project_agent)"
5110
5196
  _phase_begin claude_invoke
5111
5197
  # FIX-115: fallback agent support — when primary fails 3 attempts, try fallback 3 more
5112
5198
  FALLBACK_AGENT_NAME="${fallback_agent}"
@@ -5153,8 +5239,12 @@ done
5153
5239
  # fallback_agent is configured, try the fallback for 3 more attempts.
5154
5240
  if [ "\$_CYCLE_TIMED_OUT" -eq 0 ] && [ "\$_exit" -ne 0 ] && [ -n "\$FALLBACK_AGENT_NAME" ] && [ -n "\$FALLBACK_CMD" ]; then
5155
5241
  _loop_event agent_switch "\$FALLBACK_AGENT_NAME" "" "primary failed after 3 attempts" || true
5242
+ _loop_event agent_used "\${CYCLE_ID}" "\$FALLBACK_AGENT_NAME" "fallback" || true
5156
5243
  echo "[loop] primary agent failed after 3 attempts — switching to fallback: \$FALLBACK_AGENT_NAME"
5157
5244
  _USE_FALLBACK=1
5245
+ # US-LOOP-010: tell loop-fmt which fallback agent is running so it renders
5246
+ # correctly (passthrough for non-claude, stream-json for claude).
5247
+ export ROLL_LOOP_AGENT="\$FALLBACK_AGENT_NAME"
5158
5248
  for _attempt in 1 2 3; do
5159
5249
  _CYCLE_TIMED_OUT=0
5160
5250
  ( sleep "\$LOOP_CYCLE_TIMEOUT_SEC" && {
@@ -5166,7 +5256,11 @@ if [ "\$_CYCLE_TIMED_OUT" -eq 0 ] && [ "\$_exit" -ne 0 ] && [ -n "\$FALLBACK_AGE
5166
5256
  pkill -KILL -f "\$WT" 2>/dev/null
5167
5257
  } ) &
5168
5258
  _WATCHDOG_PID=\$!
5169
- ( cd "\$WT" && \$FALLBACK_CMD )
5259
+ if [ -f "\$FMT" ]; then
5260
+ ( cd "\$WT" && \$FALLBACK_CMD ) | python3 "\$FMT"
5261
+ else
5262
+ ( cd "\$WT" && \$FALLBACK_CMD )
5263
+ fi
5170
5264
  _exit=\$?
5171
5265
  kill "\$_WATCHDOG_PID" 2>/dev/null
5172
5266
  wait "\$_WATCHDOG_PID" 2>/dev/null
@@ -5637,7 +5731,7 @@ cmd_loop() {
5637
5731
  on) _loop_on ;;
5638
5732
  off) _loop_off ;;
5639
5733
  now) _loop_now ;;
5640
- test) _loop_test ;;
5734
+ test) shift; _loop_test "$@" ;;
5641
5735
  status) _loop_status "$@" ;;
5642
5736
  monitor) _loop_monitor "${1:-3}" ;;
5643
5737
  runs) _loop_runs "$@" ;;
@@ -5852,17 +5946,36 @@ _loop_test() {
5852
5946
  local log="${_SHARED_ROOT}/loop/cron-${slug}.log"
5853
5947
  local test_runner="${_SHARED_ROOT}/loop/run-${slug}-test.sh"
5854
5948
 
5949
+ # US-LOOP-010: --agent <name> lets integration tests exercise the
5950
+ # multi-agent passthrough path without needing a real pi binary.
5951
+ local agent="claude"
5952
+ local agent_cmd=""
5953
+ while [[ $# -gt 0 ]]; do
5954
+ case "$1" in
5955
+ --agent) agent="${2:-claude}"; shift 2 ;;
5956
+ --cmd) agent_cmd="${2}"; shift 2 ;;
5957
+ *) shift ;;
5958
+ esac
5959
+ done
5960
+ if [[ -z "$agent_cmd" ]]; then
5961
+ if [[ "$agent" == "claude" ]]; then
5962
+ agent_cmd='claude -p "Reply with a single word: hello"; sleep 10'
5963
+ else
5964
+ agent_cmd="echo 'mock ${agent} output line 1'; echo 'mock ${agent} output line 2'"
5965
+ fi
5966
+ fi
5967
+
5855
5968
  # FIX-054: terminal preference removed — runner always uses Terminal.app.
5856
5969
  local active_start active_end
5857
5970
  active_start=$(_config_read_int "loop_active_start" "10")
5858
5971
  active_end=$(_config_read_int "loop_active_end" "18")
5859
5972
 
5860
- info "Generating test runner... 正在生成测试启动脚本..."
5973
+ info "Generating test runner (agent: ${agent})... 正在生成测试启动脚本 (agent: ${agent})..."
5861
5974
  _write_loop_runner_script "$test_runner" "$project_path" \
5862
- 'claude -p "Reply with a single word: hello"; sleep 10' \
5975
+ "${agent_cmd}" \
5863
5976
  "$log" "$active_start" "$active_end"
5864
5977
 
5865
- info "Starting smoke test (real claude, trivial prompt)... 正在运行 smoke 测试(真实 claude)..."
5978
+ info "Starting smoke test (agent: ${agent})... 正在运行 smoke 测试 (agent: ${agent})..."
5866
5979
  info "Watch for: tmux session + terminal popup + stream-json events flowing"
5867
5980
  info "观察:tmux 会话 + 终端弹窗 + stream-json 事件流"
5868
5981
 
@@ -5872,9 +5985,9 @@ _loop_test() {
5872
5985
  local elapsed=$(( $(date +%s) - start_time ))
5873
5986
 
5874
5987
  if [[ $exit_code -eq 0 ]]; then
5875
- ok "Smoke test passed (${elapsed}s) smoke 测试通过 (${elapsed})"
5988
+ ok "Smoke test passed (${elapsed}s, agent: ${agent}) smoke 测试通过 (${elapsed}秒, agent: ${agent})"
5876
5989
  else
5877
- err "Smoke test failed (exit ${exit_code}, ${elapsed}s) smoke 测试失败 (退出码 ${exit_code}, ${elapsed})"
5990
+ err "Smoke test failed (exit ${exit_code}, ${elapsed}s, agent: ${agent}) smoke 测试失败 (退出码 ${exit_code}, ${elapsed}s, agent: ${agent})"
5878
5991
  return 1
5879
5992
  fi
5880
5993
  }
package/lib/loop-fmt.py CHANGED
@@ -441,11 +441,73 @@ class LoopFmt:
441
441
  pass # best-effort; never break tmux output
442
442
 
443
443
 
444
- def main():
445
- fmt = LoopFmt()
444
+ def _passthrough_main(agent):
445
+ """Transparent forwarding for non-claude agents (pi, deepseek, kimi, …).
446
+
447
+ Writes every stdin line to stdout with a HH:MM:SS timestamp prefix so
448
+ tmux shows real-time progress. Also appends each line as a lightweight
449
+ 'usage'-type event to the per-slug events ndjson — token / cost fields
450
+ are set to null (agent-specific parsing is out of scope for this US).
451
+ """
452
+ slug = os.environ.get("LOOP_PROJECT_SLUG")
453
+ cycle = os.environ.get("LOOP_CYCLE_ID")
454
+ shared = os.environ.get("LOOP_SHARED_ROOT") or os.path.expanduser("~/.shared/roll")
455
+ evfile = None
456
+ if slug and cycle:
457
+ evfile = os.path.join(shared, "loop", f"events-{slug}.ndjson")
458
+ try:
459
+ os.makedirs(os.path.dirname(evfile), exist_ok=True)
460
+ except Exception:
461
+ evfile = None
462
+
446
463
  for line in sys.stdin:
447
- fmt.process(line)
464
+ if not line.rstrip():
465
+ continue
466
+ # Timestamp prefix so tmux shows activity (even if agent output has
467
+ # no timestamps of its own).
468
+ ts = datetime.now(timezone.utc).strftime("%H:%M:%S")
469
+ out = f"{DARK_GRAY}{ts}{RESET} {line.rstrip()}"
470
+ sys.stdout.write(out + "\n")
448
471
  sys.stdout.flush()
472
+ # Emit a lightweight usage event so the cycle has *some* event trace
473
+ # (token/cost are null — parsing those is agent-specific and out of
474
+ # scope for the minimal transparent-passthrough US).
475
+ if evfile:
476
+ _emit_passthrough_event(evfile, cycle, agent, line.rstrip())
477
+
478
+
479
+ def _emit_passthrough_event(evfile, cycle, agent, text):
480
+ """Best-effort append a usage-type event to evfile."""
481
+ payload = {
482
+ "model": agent,
483
+ "input_tokens": None,
484
+ "output_tokens": None,
485
+ "cost_list_usd": None,
486
+ "duration_ms": None,
487
+ }
488
+ record = json.dumps({
489
+ "ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
490
+ "stage": "usage",
491
+ "label": cycle,
492
+ "detail": payload,
493
+ "outcome": "ok",
494
+ }) + "\n"
495
+ try:
496
+ with open(evfile, "a") as f:
497
+ f.write(record)
498
+ except Exception:
499
+ pass
500
+
501
+
502
+ def main():
503
+ agent = os.environ.get("ROLL_LOOP_AGENT", "claude")
504
+ if agent == "claude":
505
+ fmt = LoopFmt()
506
+ for line in sys.stdin:
507
+ fmt.process(line)
508
+ sys.stdout.flush()
509
+ else:
510
+ _passthrough_main(agent)
449
511
 
450
512
  if __name__ == "__main__":
451
513
  main()
@@ -231,6 +231,12 @@ def aggregate(events: List[Dict[str, Any]], cron: List[Dict[str, Any]]) -> List[
231
231
  sid = _extract_story_id(detail)
232
232
  if sid:
233
233
  cy["story"] = sid
234
+ elif stage == "agent_used":
235
+ # FIX-119: non-claude agents don't expose model in stream-json.
236
+ # The inner runner emits an agent_used event with the agent name
237
+ # so the dashboard can show it when cy["model"] is None.
238
+ if detail:
239
+ cy["agent"] = detail
234
240
  elif stage == "usage":
235
241
  # US-LOOP-004: loop-fmt emits this with full token / cost data.
236
242
  # Detail is a dict (not the legacy string form).
@@ -336,6 +336,11 @@ def cycle_row(cy: Dict[str, Any], backlog: Dict[str, str]) -> None:
336
336
  sid_c = "red" if outcome == "fail" else "blue"
337
337
 
338
338
  model_label = fmt_model(cy.get("model"))
339
+ # FIX-119: fall back to cy["agent"] (from agent_used event) when model
340
+ # is unknown — non-claude agents (pi, deepseek, kimi) don't expose model
341
+ # info in stream-json, leaving a "—" or "?" on the dashboard.
342
+ if model_label in ("—", "?") and cy.get("agent"):
343
+ model_label = cy["agent"]
339
344
  # Auto-hide model column on narrow screens — keeps the dashboard readable
340
345
  # when terminal is < 100 cols (cost / story IDs are higher-priority).
341
346
  show_model = COLS >= 100
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanyao/roll",
3
- "version": "2026.524.1",
3
+ "version": "2026.524.2",
4
4
  "description": "Roll — Roll out features with AI agents",
5
5
  "scripts": {
6
6
  "test": "bash tests/run.sh"
@@ -75,13 +75,25 @@ denied operations and the cycle will idle-exit.
75
75
  loop:
76
76
  primary_agent: claude # claude | deepseek | kimi
77
77
  fallback_agent: deepseek # used when primary fails
78
- max_items_per_run: 3 # limit parallel risk; adjust as needed
78
+ max_items_per_run: 1 # one story per cycle atomic delivery, predictable cycle time
79
79
  brief_on_feature_complete: true
80
80
  retry_backoff: [2, 4, 8, 16] # seconds, exponential
81
81
  ```
82
82
 
83
83
  ## Workflow
84
84
 
85
+ > **One story per cycle (强约束)**: 每个 cycle 只 pick 一个 Todo、跑完 Step 4
86
+ > 立刻退出,不再回 Step 2 找下一个。理由:
87
+ > - cycle 时间可预测(不会因贪心一连串 PR 撞 45 分钟 hard timeout)
88
+ > - PR / events / dashboard 每行一个故事,归因清晰
89
+ > - 一个故事一个 PR 一次 review,blast radius 最小
90
+ >
91
+ > 唯一例外是依赖修复(CI self-heal 等)已经内嵌在当前故事的 Step 4 里——
92
+ > 那部分不算"新挑故事"。
93
+ >
94
+ > 实现层:max_items_per_run 默认 1,executor skill 跑完 Step 5 必须 exit。
95
+ > 不要在同一个 cycle 内多次 emit `pick_todo` 事件。
96
+
85
97
  ### Step 1 — Orphan 🔨 Recovery
86
98
 
87
99
  Process-level crash recovery (LOCK, heartbeat, retry budget) is handled by
@@ -311,6 +323,8 @@ After each item completes:
311
323
  3. Update state file: `status: idle`
312
324
  4. Check if a Feature is now fully complete (all its Stories ✅)
313
325
  5. If yes and `brief_on_feature_complete: true` → invoke `Skill("roll-brief")`
326
+ 6. **EXIT the cycle.** 不要回 Step 2 找下一个故事,不要再 emit `pick_todo`。
327
+ 一个 cycle 只交付一个故事;剩下的 Todo 等下一个 launchd tick 起新 cycle 处理。
314
328
 
315
329
  ### Step 5 — Write Run Summary
316
330