@seanyao/roll 2026.519.1 → 2026.519.3

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,10 +1,55 @@
1
1
  # Changelog
2
2
 
3
+ ## v2026.519.3
4
+
5
+ ### Major
6
+
7
+ - **`.roll/` 拆为独立私有仓库** — 过程文件(backlog / brief / features / dream / domain / briefs)整体迁入嵌套私有 repo `roll-meta`,主仓只留产品文档。AGENTS.md 新增 §9 说明双 repo 协作约定 `[meta]`
8
+
9
+ ### Improved
10
+
11
+ - **`.roll/features/` 按 Epic 分组** — features 目录由扁平改为 Epic 子目录,附带 loop 架构经验文档 `[docs]`
12
+ - **站点登陆页对齐 v2.0 架构** — `docs/site/` 文案与 2.0 实际产物对齐,去掉过期描述 `[docs]`
13
+
14
+ ### Fixed
15
+
16
+ - **loop 异常退出** — dashboard 不再卡在"运行中",崩溃 / 被 kill / 超时退出都会写下结束标记 `[loop]`
17
+ - **`FIX-065` loop 共享状态隔离** — 测试运行的 loop 不再污染生产 backlog/brief,sandbox 化共享路径 `[loop]`
18
+
19
+ ## v2026.519.2
20
+
21
+ ### Improved
22
+
23
+ - **`roll init`** — 初始化流程现在显示 6 步编号进度,新建文件用绿色 `+`、合并已有用琥珀 `~`,结尾给三步上手指南 `[loop]`
24
+
25
+ ### Fixed
26
+
27
+ - **`roll --help` 中 `init` 描述** — 文案从 `+ docs/` 改为 `+ .roll/features/`,对齐 2.0 实际产物和 README;v2 (`lib/roll-help.py`) 与 legacy (`bin/roll`) 两份 help 同步修正 `[FIX-064]`
28
+
3
29
  ## v2026.519.1
4
30
 
31
+ ### Major(大版本重构)
32
+
33
+ - **项目结构重组 — 过程文件迁入 `.roll/`** — Phase 1 of Legacy Onboard Epic。`BACKLOG.md`、`PROPOSALS.md`、`docs/{features,briefs,dream,design,domain}/` 全部搬入 `.roll/`;`docs/guide/`、`docs/site/`、`docs/intro/` 上移到根级。一次性 breaking change,迁移指南见 `guide/{en,zh}/migration-2.0.md` `[legacy-onboard]`
34
+ - **新命令 `roll migrate`** — 老项目一键迁到新结构:dry-run 预览 + `git mv` 保留历史 + 单原子 commit + 三态幂等(仅老 / 仅新 / 并存) `[legacy-onboard]`
35
+ - **新版 `roll init` 识别 Legacy 项目** — 检测到现有源码无 `AGENTS.md` 时引导用户进入 onboard 流程:列出本机 AI agent、显式告知 token 消耗、引导运行 `$roll-onboard` `[legacy-onboard]`
36
+ - **新技能 `$roll-onboard`** — 三组九问 ≤ 3 分钟,AI 读懂项目后生成 `.roll/onboard-plan.yaml`,bash 侧 `roll init --apply` 执行 `[legacy-onboard]`
37
+ - **新命令 `roll init --apply`** — 消费 onboard plan 创建 `.roll/` 结构,按用户选择写 `.gitignore`、同步 AI 工具约定 `[legacy-onboard]`
38
+ - **结构强制检测** — 新版 Roll 在老结构项目上拒绝运行 + 引导 `roll migrate`(`setup` / `update` / `version` / `help` / `init` 豁免;`ROLL_SKIP_STRUCTURE_CHECK=1` 旁路) `[legacy-onboard]`
39
+
40
+ ### Improved
41
+
42
+ - `AGENTS.md` §8 Documentation Conventions 重写匹配新目录结构,明确"过程默认对内、产品默认对外"原则 `[docs]`
43
+ - `guide/{en,zh}/practices/` 收入工程规范文档(原 `docs/practices/engineering-common-sense.md`) `[docs]`
44
+ - 新增 Python 校验器 `lib/roll-plan-validate.py`,验证 onboard plan 完整性、`generated_at` 24h 时效、版本兼容 `[legacy-onboard]`
45
+
5
46
  ### Fixed
6
47
 
7
48
  - **`roll setup` 后从未开启 loop 的项目** — 不再被 macOS 自动激活、每小时弹出终端窗口 `[loop]`
49
+ - **`_write_backlog` 缺 `mkdir -p` 导致 `cmd_init` 在 `.roll/` 不存在时崩** `[legacy-onboard]`
50
+ - **`release.sh` 多 feature 时 awk `newline in string` 错误** — macOS BSD awk 不容忍 `-v var=多行`;改用 `ENVIRON` 读取 `[release]`
51
+ - **GitHub 仓库改名 Roll → roll** — 内部代码、测试 fixture、文档引用全部同步小写命名 `[chore]`
52
+ - **`.roll/backlog.md` 和 `guide/*` 中残留 `docs/features` 等老路径引用** — Story 5 sed 漏覆盖 `.roll/` 和 `guide/` 文件,dream 巡检发现后补齐 `[legacy-onboard]`
8
53
 
9
54
  ## v2026.518.4
10
55
 
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  **[中文版 README](README_CN.md)**
13
13
 
14
- [![Website](https://img.shields.io/badge/Website-seanyao.github.io%2FRoll-blue)](https://seanyao.github.io/Roll/)
14
+ [![Website](https://img.shields.io/badge/Website-seanyao.github.io%2Froll-blue)](https://seanyao.github.io/roll/)
15
15
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
16
16
  [![npm version](https://img.shields.io/npm/v/@seanyao/roll.svg)](https://www.npmjs.com/package/@seanyao/roll)
17
17
  [![CI](https://github.com/seanyao/roll/actions/workflows/ci.yml/badge.svg)](https://github.com/seanyao/roll/actions/workflows/ci.yml)
@@ -28,6 +28,15 @@ Roll is an autonomous delivery system for software teams — AI agents pick stor
28
28
 
29
29
  _Works with Claude, Cursor, Codex, or your own agent._
30
30
 
31
+ ## What's New in 2.0 (May 2026)
32
+
33
+ Roll 2.0 introduces a **process/product split** to keep open-source projects clean:
34
+
35
+ - **`.roll/` directory convention** — all process artifacts (backlog, features, briefs, dream logs) move out of root into `.roll/`. User-facing `guide/` and `site/` move up to root. Old `docs/` directory disappears.
36
+ - **`roll migrate`** — one-command migration for projects on pre-2.0 layout. `git mv` preserves history, single atomic commit, three-state idempotent.
37
+ - **`$roll-onboard`** — interactive skill for adopting Roll on legacy codebases without rewrites. AI reads your code, asks 9 questions in ≤ 3 minutes, produces a plan; `roll init --apply` executes it.
38
+ - **Three adoption patterns** — `seed` (new project), `graft` (legacy, non-invasive), `replant` (legacy, clean rebuild). See [guide/en/patterns/](guide/en/patterns/).
39
+
31
40
  ## Evolution
32
41
 
33
42
  Roll didn't start as a framework. It started as a question: *what if the AI didn't just write code, but actually shipped it?*
@@ -54,6 +63,20 @@ roll loop on # optional: let the agent work unattended
54
63
 
55
64
  ---
56
65
 
66
+ ## Adoption Paths
67
+
68
+ Three ways to bring Roll into a project. Not sure which fits? Just run `roll init` — it detects legacy code and routes you to `$roll-onboard` automatically.
69
+
70
+ | Path | When to use | How to start |
71
+ |------|-------------|--------------|
72
+ | **Seed** | Brand-new project, starting from zero | `roll init` (the Quick Start path above) |
73
+ | **Graft** | Existing codebase, keep current workflow intact | `roll init` → AI prompts `$roll-onboard` → 9 questions in ≤ 3 min → `roll init --apply` |
74
+ | **Replant** | Existing codebase, ready to realign on Roll conventions | Same as Graft, choose "clean rebuild" during onboarding |
75
+
76
+ Details: [seed](guide/en/patterns/seed-pattern.md) · [graft](guide/en/patterns/graft-pattern.md) · [replant](guide/en/patterns/replant-pattern.md)
77
+
78
+ ---
79
+
57
80
  ## Documentation Index
58
81
 
59
82
  | Topic | English | 中文 |
@@ -66,7 +89,7 @@ roll loop on # optional: let the agent work unattended
66
89
  | Configuration (env vars) | [guide/en/configuration.md](guide/en/configuration.md) | [guide/zh/configuration.md](guide/zh/configuration.md) |
67
90
  | Skill selection guide | [guide/en/skills.md](guide/en/skills.md) | [guide/zh/skills.md](guide/zh/skills.md) |
68
91
  | FAQ (troubleshooting) | [guide/en/faq.md](guide/en/faq.md) | [guide/zh/faq.md](guide/zh/faq.md) |
69
- | Domain model (DDD) | [domain/context-map.md](.roll/domain/context-map.md) | — |
92
+ | Adoption patterns | [guide/en/patterns/](guide/en/patterns/) | [guide/zh/patterns/](guide/zh/patterns/) |
70
93
  | Engineering common sense | [practices/engineering-common-sense.md](guide/en/practices/engineering-common-sense.md) | — |
71
94
 
72
95
  ---
@@ -75,15 +98,23 @@ roll loop on # optional: let the agent work unattended
75
98
 
76
99
  | Command | Description |
77
100
  |---------|-------------|
101
+ | **Autonomy · daily use** | |
102
+ | `roll loop <on\|off\|now\|status\|monitor>` | 🤖 Manage the autonomous BACKLOG executor |
103
+ | `roll brief` | 🤖 Show latest owner brief |
104
+ | `roll backlog [block\|defer\|…]` | View and manage pending tasks |
105
+ | `roll peer` | 🤖 Cross-agent negotiation & review |
106
+ | `roll alert` | View / clear loop alerts |
107
+ | **Project · per repo** | |
108
+ | `roll init` | Create AGENTS.md + .roll/backlog.md + .roll/features/ |
109
+ | `roll status` | Show current state and drift |
110
+ | `roll agent [use <name>]` | Per-project agent selection (Claude / Cursor / Codex / Kimi / …) |
111
+ | `roll ci [--wait]` | Show or wait for current commit's CI status |
112
+ | `roll release` | 🤖 Run the release script (human-only) |
113
+ | `roll review-pr <number>` | 🤖 AI-powered code review for a PR |
114
+ | **Machine · global** | |
78
115
  | `roll setup [-f]` | First-time install or re-sync conventions to all AI clients |
79
- | `roll update` | Upgrade to latest version |
80
- | `roll init` | Initialize project: AGENTS.md + .roll/backlog.md + .roll/features/ |
81
- | `roll status` | Show sync state, skill links, detected AI tools |
82
- | `roll backlog` | Show pending tasks from .roll/backlog.md |
83
- | `roll loop <on\|off\|now\|status\|monitor>` | 🤖 Manage autonomous executor |
84
- | `roll brief` | 🤖 Show latest owner digest |
85
- | `roll peer` | 🤖 Cross-agent code review |
86
- | `roll release` | 🤖 Version + tag + npm publish + GitHub Release |
116
+ | `roll update` | Upgrade to latest + re-sync |
117
+ | `roll version` | Print installed roll version |
87
118
 
88
119
  ---
89
120
 
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.519.1"
7
+ VERSION="2026.519.3"
8
8
  ROLL_HOME="${ROLL_HOME:-${HOME}/.roll}"
9
9
  ROLL_CONFIG="${ROLL_HOME}/config.yaml"
10
10
  ROLL_GLOBAL="${ROLL_HOME}/conventions/global"
@@ -917,6 +917,23 @@ _merge_claude_to_project() {
917
917
  # Existing AGENTS.md: re-merges global conventions (section-level, non-destructive)
918
918
  # ═══════════════════════════════════════════════════════════════════════════════
919
919
  cmd_init() {
920
+ # US-VIEW-008: parse --demo before any side effects so the v2 renderer can
921
+ # run standalone (no templates, no project mutation).
922
+ local demo=false
923
+ local args=()
924
+ while [[ $# -gt 0 ]]; do
925
+ case "$1" in
926
+ --demo) demo=true; shift ;;
927
+ *) args+=("$1"); shift ;;
928
+ esac
929
+ done
930
+ set -- "${args[@]:-}"
931
+
932
+ if [[ "${ROLL_UI:-v2}" == "v2" ]] || [[ "$demo" == "true" ]]; then
933
+ python3 "${ROLL_PKG_DIR}/lib/roll-init.py" --demo
934
+ if [[ "$demo" == "true" ]]; then return; fi
935
+ fi
936
+
920
937
  if [[ ! -d "$ROLL_TEMPLATES" ]]; then
921
938
  err "No templates found. Run 'roll setup' first. 未找到模板,请先运行 'roll setup'。"
922
939
  exit 1
@@ -2326,7 +2343,13 @@ cmd_review_pr() {
2326
2343
  local output
2327
2344
  info "Reviewing PR #${pr_number} with ${agent}..."
2328
2345
  _agent_argv "$agent" text "$prompt" || { err "Unknown agent '${agent}'"; return 1; }
2329
- output=$("${_AGENT_ARGV[@]}" 2>/dev/null)
2346
+ local _stderr_log; _stderr_log=$(mktemp)
2347
+ output=$("${_AGENT_ARGV[@]}" 2>"$_stderr_log")
2348
+ if [[ -z "$output" && -s "$_stderr_log" ]]; then
2349
+ err "agent ${agent} produced no output. stderr (first 5 lines):"
2350
+ head -5 "$_stderr_log" | sed 's/^/ /' >&2
2351
+ fi
2352
+ rm -f "$_stderr_log"
2330
2353
 
2331
2354
  echo "$output"
2332
2355
 
@@ -2409,6 +2432,13 @@ cmd_agent() {
2409
2432
  # Returns a filesystem-safe slug combining the project basename and a 6-char
2410
2433
  # hash of the full path, ensuring uniqueness across sibling dirs with same name.
2411
2434
  _project_slug() {
2435
+ # US-LOOP-006: cycle wrapper exports ROLL_MAIN_SLUG so any subshell — worktree,
2436
+ # tmp cwd, or unrelated path — writes events / runs.jsonl under the main project
2437
+ # identity instead of fragmenting into tmp-* / cycle-* phantom slugs.
2438
+ if [[ -n "${ROLL_MAIN_SLUG:-}" ]]; then
2439
+ printf '%s' "$ROLL_MAIN_SLUG"
2440
+ return 0
2441
+ fi
2412
2442
  local path="${1:-$(pwd -P 2>/dev/null || pwd)}"
2413
2443
  # FIX-056: normalize path to canonical case on macOS case-insensitive filesystem.
2414
2444
  # Two paths differing only in case point to the same directory; realpath
@@ -2521,6 +2551,35 @@ PYEOF
2521
2551
  }
2522
2552
 
2523
2553
  _LOOP_TAG="# roll-loop"
2554
+ # FIX-065: when sourced in a test context with no explicit override, route
2555
+ # shared state into a per-process /tmp path instead of falling back to
2556
+ # production ~/.shared/roll/. Without this safety net, tests that source
2557
+ # bin/roll (directly or via a generated inner runner under /var/folders/)
2558
+ # would write ALERT / state / events / runs.jsonl into the live loop
2559
+ # daemon's monitored directory and trigger false aborts.
2560
+ #
2561
+ # Test context is detected via three signals (any one is enough):
2562
+ # 1. BATS_TEST_FILENAME is set (works for direct test invocations)
2563
+ # 2. The caller's file path lives under /tmp or /var/folders (catches the
2564
+ # generated runner-inner.sh path that bats subprocesses spawn —
2565
+ # BATS_* env can be lost across `bash -l` + nested forks)
2566
+ # 3. PWD is under /tmp or /var/folders (catches helpers that cd'd in)
2567
+ if [ -z "${_SHARED_ROOT:-}" ]; then
2568
+ _roll_in_test_ctx=0
2569
+ if [ -n "${BATS_TEST_FILENAME:-}" ]; then
2570
+ _roll_in_test_ctx=1
2571
+ else
2572
+ _roll_caller="${BASH_SOURCE[1]:-}"
2573
+ case "$_roll_caller" in /tmp/*|/private/tmp/*|/var/folders/*) _roll_in_test_ctx=1 ;; esac
2574
+ case "$PWD" in /tmp/*|/private/tmp/*|/var/folders/*) _roll_in_test_ctx=1 ;; esac
2575
+ fi
2576
+ if [ "$_roll_in_test_ctx" = 1 ]; then
2577
+ _SHARED_ROOT="${TMPDIR:-/tmp}/roll-test-shared.$$"
2578
+ mkdir -p "${_SHARED_ROOT}/loop"
2579
+ export _SHARED_ROOT
2580
+ fi
2581
+ unset _roll_in_test_ctx _roll_caller
2582
+ fi
2524
2583
  : "${_SHARED_ROOT:=${HOME}/.shared/roll}"
2525
2584
  # FIX-052: per-project loop state — ALERT/state/mute were globally shared,
2526
2585
  # causing one project's alerts to surface in another project's session and
@@ -2529,7 +2588,9 @@ _LOOP_TAG="# roll-loop"
2529
2588
  : "${_LOOP_PROJ_SLUG:=$(_project_slug 2>/dev/null || echo default)}"
2530
2589
  : "${_LOOP_STATE:=${_SHARED_ROOT}/loop/state-${_LOOP_PROJ_SLUG}.yaml}"
2531
2590
  : "${_LOOP_ALERT:=${_SHARED_ROOT}/loop/ALERT-${_LOOP_PROJ_SLUG}.md}"
2532
- _LOOP_RUNS="${HOME}/.shared/roll/loop/runs.jsonl"
2591
+ # FIX-065: was hardcoded to ${HOME}/.shared/roll/loop/runs.jsonl which ignored
2592
+ # _SHARED_ROOT overrides and silently leaked test runs.jsonl writes into prod.
2593
+ _LOOP_RUNS="${_SHARED_ROOT}/loop/runs.jsonl"
2533
2594
  : "${_LOOP_MUTE_FILE:=${_SHARED_ROOT}/loop/mute-${_LOOP_PROJ_SLUG}}"
2534
2595
  _LAUNCHD_DIR="${HOME}/Library/LaunchAgents"
2535
2596
 
@@ -2540,6 +2601,13 @@ _config_read_int() {
2540
2601
  if [[ "$val" =~ ^[0-9]+$ ]]; then echo "$val"; else echo "$default"; fi
2541
2602
  }
2542
2603
 
2604
+ # REFACTOR-031: cross-platform file mtime in epoch seconds.
2605
+ # Replaces the `stat -c %Y ... || stat -f %m ... || echo 0` pattern that was
2606
+ # copy-pasted in four places (dashboard age widgets, briefs, dream, peer).
2607
+ _file_mtime() {
2608
+ stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0
2609
+ }
2610
+
2543
2611
  # Derive a minute in [1,55] from project path hash + offset so different projects
2544
2612
  # and different services within a project don't fire at the same time.
2545
2613
  # Offsets used: loop=0, dream=2, brief=4 → always three distinct values (2<55).
@@ -2565,6 +2633,24 @@ _loop_event() {
2565
2633
  ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
2566
2634
  slug=$(_project_slug 2>/dev/null || basename "$PWD")
2567
2635
  evfile="${_SHARED_ROOT:-$HOME/.shared/roll}/loop/events-${slug}.ndjson"
2636
+ # FIX-065 tripwire: in a test context (BATS or temp cwd), refuse to write
2637
+ # into production ~/.shared/roll/. Catching this in code is the last line
2638
+ # of defense if some unusual path bypassed the auto-sandbox at source-time.
2639
+ # Skipped when HOME itself has been redirected to a sandbox dir — then
2640
+ # $HOME/.shared/roll IS the sandbox, not prod.
2641
+ case "${HOME:-}" in
2642
+ /tmp/*|/private/tmp/*|*/var/folders/*|*/tmp.*) ;;
2643
+ *)
2644
+ if [ -n "${HOME:-}" ] && [ "${evfile#${HOME}/.shared/roll/}" != "$evfile" ]; then
2645
+ case "${BATS_TEST_FILENAME:-}${PWD}" in
2646
+ */tmp.*|*/var/folders/*|/tmp/*|/private/tmp/*|*.bats)
2647
+ echo "[FIX-065] refusing prod _loop_event write: $evfile (test context)" >&2
2648
+ return 1
2649
+ ;;
2650
+ esac
2651
+ fi
2652
+ ;;
2653
+ esac
2568
2654
  mkdir -p "$(dirname "$evfile")"
2569
2655
 
2570
2656
  # stdout: tab-separated for tmux display
@@ -2755,7 +2841,7 @@ fi
2755
2841
  printf '%s:%s\n' "\$\$" "\$(date -u +%s)" > "\$INNER_LOCK"
2756
2842
  # FIX-038: background heartbeat writer — outer script uses this as primary liveness signal
2757
2843
  # to detect stale execution without relying on PID reuse heuristics.
2758
- HEARTBEAT_FILE="${HOME}/.shared/roll/loop/.heartbeat-${slug}"
2844
+ HEARTBEAT_FILE="\${_SHARED_ROOT:-\${HOME}/.shared/roll}/loop/.heartbeat-${slug}"
2759
2845
  _heartbeat_writer() {
2760
2846
  while true; do echo "\$(date -u +%s)" > "\$HEARTBEAT_FILE"; sleep 60; done
2761
2847
  }
@@ -2767,8 +2853,45 @@ _HEARTBEAT_PID=\$!
2767
2853
  # next cron tick can proceed. Overridable via env for tests.
2768
2854
  LOOP_CYCLE_TIMEOUT_SEC="\${ROLL_LOOP_CYCLE_TIMEOUT_SEC:-2700}"
2769
2855
  _CYCLE_TIMED_OUT=0
2856
+ # IDEA-028 / FIX-066: track whether cycle_end has been emitted via any of the
2857
+ # explicit completion paths (publish/merge_back/orphan-push/claude-failed).
2858
+ # When zero, the EXIT trap emits a fallback so cycle_start never orphans the
2859
+ # dashboard into a phantom "still running" row.
2860
+ _CYCLE_END_WRITTEN=0
2770
2861
  _on_sigterm() { _CYCLE_TIMED_OUT=1; }
2771
2862
  trap '_on_sigterm' TERM
2863
+ # US-LOOP-005: idempotent runs.jsonl writer shared by normal exit, timeout
2864
+ # trap, and worktree-setup-failure early exit. Guards on jq + run_id dedupe so
2865
+ # multiple callers in the same cycle are safe.
2866
+ _runs_append() {
2867
+ local _status="\$1"; local _tcr="\${2:-0}"; local _built="\${3:-[]}"
2868
+ local _runs_dst="\${_SHARED_ROOT:-\${HOME}/.shared/roll}/loop/runs.jsonl"
2869
+ command -v jq >/dev/null 2>&1 || return 0
2870
+ local _cid="\${CYCLE_ID:-pre-cycle-\$\$}"
2871
+ local _rid="loop-\${_cid%-*}"
2872
+ grep -qF "\"run_id\":\"\$_rid\"" "\$_runs_dst" 2>/dev/null && return 0
2873
+ mkdir -p "\$(dirname "\$_runs_dst")"
2874
+ local _ts_now; _ts_now=\$(date -u +%Y-%m-%dT%H:%M:%SZ)
2875
+ local _start="\${CYCLE_START:-\$(date -u +%s)}"
2876
+ local _dur=\$(( \$(date -u +%s) - _start ))
2877
+ [ "\$_dur" -lt 0 ] && _dur=0
2878
+ jq -nc \\
2879
+ --arg ts "\$_ts_now" \\
2880
+ --arg project "${slug}" \\
2881
+ --arg run_id "\$_rid" \\
2882
+ --arg status "\$_status" \\
2883
+ --arg cycle_id "\$_cid" \\
2884
+ --argjson built "\$_built" \\
2885
+ --argjson skipped '[]' \\
2886
+ --argjson alerts '[]' \\
2887
+ --argjson tcr_count "\$_tcr" \\
2888
+ --argjson duration_sec "\$_dur" \\
2889
+ '{ts:\$ts, project:\$project, run_id:\$run_id, status:\$status,
2890
+ cycle_id:\$cycle_id,
2891
+ built:\$built, skipped:\$skipped, alerts:\$alerts,
2892
+ tcr_count:\$tcr_count, duration_sec:\$duration_sec}' \\
2893
+ >> "\$_runs_dst" 2>/dev/null || true
2894
+ }
2772
2895
  _inner_cleanup() {
2773
2896
  local _rc=\$?
2774
2897
  # Kill heartbeat + every remaining background job (watchdog, orphan
@@ -2778,8 +2901,21 @@ _inner_cleanup() {
2778
2901
  for _pid in \$(jobs -p); do kill "\$_pid" 2>/dev/null; done
2779
2902
  if [ "\${_CYCLE_TIMED_OUT:-0}" -eq 1 ]; then
2780
2903
  _loop_event cycle_end "\${CYCLE_ID:-unknown}" "\${BRANCH:-}" "blocked" 2>/dev/null || true
2904
+ _CYCLE_END_WRITTEN=1
2905
+ # US-LOOP-005 T9: timeout path must also write runs.jsonl row so dashboard
2906
+ # has a terminal record (cycle_end alone is insufficient — runs.jsonl is
2907
+ # the canonical history feed for 'roll loop runs').
2908
+ _runs_append "failed" 0 "[]" 2>/dev/null || true
2781
2909
  _worktree_alert "cycle \${CYCLE_ID:-unknown}: \${LOOP_CYCLE_TIMEOUT_SEC}s timeout — claude/python killed; in-progress story marked Blocked" 2>/dev/null || true
2782
2910
  fi
2911
+ # IDEA-028 / FIX-066: catch every other abort (SIGKILL, set -e fire, ALERT
2912
+ # poisoning that bypasses the retry budget, etc.). Without this, cycle_start
2913
+ # is emitted but cycle_end never is, and dashboard renders the cycle as
2914
+ # "still running" until the next successful cycle rolls past it.
2915
+ if [ "\${_CYCLE_END_WRITTEN:-0}" -eq 0 ] && [ -n "\${CYCLE_ID:-}" ]; then
2916
+ _loop_event cycle_end "\${CYCLE_ID}" "\${BRANCH:-}" "aborted" 2>/dev/null || true
2917
+ _runs_append "aborted" 0 "[]" 2>/dev/null || true
2918
+ fi
2783
2919
  rm -f "\$INNER_LOCK" "\$HEARTBEAT_FILE"
2784
2920
  exit "\$_rc"
2785
2921
  }
@@ -2800,12 +2936,16 @@ _LOOP_PROJ_SLUG="${slug}"
2800
2936
  _LOOP_ALERT="\${_SHARED_ROOT}/loop/ALERT-${slug}.md"
2801
2937
  _LOOP_STATE="\${_SHARED_ROOT}/loop/state-${slug}.yaml"
2802
2938
  _LOOP_MUTE_FILE="\${_SHARED_ROOT}/loop/mute-${slug}"
2939
+ # US-LOOP-006: ROLL_MAIN_SLUG is the canonical identity for any subprocess —
2940
+ # claude, loop-fmt.py, _loop_event in arbitrary cwd. _project_slug honors this
2941
+ # env var first, so writes never fragment into tmp-* / cycle-* phantom slugs.
2942
+ export ROLL_MAIN_SLUG="${slug}"
2803
2943
 
2804
2944
  # Pre-claude: try to create a per-cycle isolated worktree on origin/main.
2805
2945
  # On any failure (no remote, no main, etc.) fall back to running in the
2806
2946
  # project's main tree (degraded — no isolation, like pre-037 behavior).
2807
- CYCLE_ID="\$(date -u +%Y%m%d-%H%M%S)-\$\$"
2808
- CYCLE_START=\$(date -u +%s)
2947
+ CYCLE_ID="\$(date +%Y%m%d-%H%M%S)-\$\$"
2948
+ CYCLE_START=\$(date +%s)
2809
2949
  WT="\$(_worktree_path "${slug}" "cycle-\${CYCLE_ID}")"
2810
2950
  BRANCH="loop/cycle-\${CYCLE_ID}"
2811
2951
  _USE_WORKTREE=0
@@ -2859,6 +2999,10 @@ else
2859
2999
  # falling back to the main tree without isolation is unacceptable.
2860
3000
  _worktree_alert "cycle \${CYCLE_ID}: worktree setup failed — skipping cycle to avoid running without isolation"
2861
3001
  echo "[loop] cycle \${CYCLE_ID}: worktree setup failed; skipping cycle (no isolation)"
3002
+ # US-LOOP-005 T10: worktree-setup-failed path leaves no commits and never
3003
+ # emits cycle_start, but dashboard still needs a runs.jsonl row marking the
3004
+ # cycle as failed (otherwise the scheduled tick appears to have vanished).
3005
+ _runs_append "failed" 0 "[]" 2>/dev/null || true
2862
3006
  exit 0
2863
3007
  fi
2864
3008
 
@@ -2955,11 +3099,13 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
2955
3099
  fi
2956
3100
  fi
2957
3101
  _worktree_cleanup "\$WT" "\$BRANCH"
2958
- _loop_event cycle_end "\${CYCLE_ID}" "" "done" || true
3102
+ _loop_event cycle_end "\${CYCLE_ID}" "" "done" || true; _CYCLE_END_WRITTEN=1
2959
3103
  echo "[loop] cycle \${CYCLE_ID}: published; worktree cleaned"
2960
3104
  elif [ "\$_publish_status" -eq 2 ]; then
2961
3105
  if ( cd "${project_path}" && _worktree_merge_back "\$BRANCH" ); then
2962
3106
  _worktree_cleanup "\$WT" "\$BRANCH"
3107
+ # US-LOOP-005 T3: gh unavailable + ff merge_back OK → cycle_end done
3108
+ _loop_event cycle_end "\${CYCLE_ID}" "" "done" || true; _CYCLE_END_WRITTEN=1
2963
3109
  echo "[loop] cycle \${CYCLE_ID}: gh unavailable; merged via ff and cleaned up"
2964
3110
  else
2965
3111
  # FIX-039: gh unavailable + merge_back failed — push orphan branch+tag to origin
@@ -2969,9 +3115,13 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
2969
3115
  && git tag "\$_orphan_tag" 2>/dev/null \
2970
3116
  && git push origin "\$_orphan_tag" 2>/dev/null ); then
2971
3117
  _worktree_cleanup "\$WT" "\$BRANCH"
3118
+ # US-LOOP-005 T4: gh unavailable + orphan push OK → cycle_end orphan
3119
+ _loop_event cycle_end "\${CYCLE_ID}" "" "orphan" || true; _CYCLE_END_WRITTEN=1
2972
3120
  _worktree_alert "cycle \${CYCLE_ID}: gh+merge_back failed; FIX-039 pushed orphan+tag \${_orphan_tag}; worktree cleaned"
2973
3121
  echo "[loop] cycle \${CYCLE_ID}: FIX-039: orphan branch+tag \${_orphan_tag} pushed; worktree cleaned"
2974
3122
  else
3123
+ # US-LOOP-005 T5: gh unavailable + all failed → cycle_end failed
3124
+ _loop_event cycle_end "\${CYCLE_ID}" "" "failed" || true; _CYCLE_END_WRITTEN=1
2975
3125
  _worktree_alert "cycle \${CYCLE_ID}: gh+merge_back+push all failed; worktree preserved at \$WT"
2976
3126
  echo "[loop] cycle \${CYCLE_ID}: all publish paths failed; worktree preserved at \$WT"
2977
3127
  fi
@@ -2984,15 +3134,21 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
2984
3134
  && git tag "\$_orphan_tag" 2>/dev/null \
2985
3135
  && git push origin "\$_orphan_tag" 2>/dev/null ); then
2986
3136
  _worktree_cleanup "\$WT" "\$BRANCH"
3137
+ # US-LOOP-005 T6: PR publish failed + orphan push OK → cycle_end orphan
3138
+ _loop_event cycle_end "\${CYCLE_ID}" "" "orphan" || true; _CYCLE_END_WRITTEN=1
2987
3139
  _worktree_alert "cycle \${CYCLE_ID}: PR publish failed; FIX-039 pushed orphan+tag \${_orphan_tag}; worktree cleaned"
2988
3140
  echo "[loop] cycle \${CYCLE_ID}: FIX-039: orphan branch+tag \${_orphan_tag} pushed; worktree cleaned"
2989
3141
  else
3142
+ # US-LOOP-005 T7: PR publish failed + orphan push failed → cycle_end failed
3143
+ _loop_event cycle_end "\${CYCLE_ID}" "" "failed" || true; _CYCLE_END_WRITTEN=1
2990
3144
  _worktree_alert "cycle \${CYCLE_ID}: PR publish failed; worktree preserved at \$WT (branch \$BRANCH)"
2991
3145
  echo "[loop] cycle \${CYCLE_ID}: PR publish failed; worktree preserved at \$WT"
2992
3146
  fi
2993
3147
  fi
2994
3148
  fi
2995
3149
  else
3150
+ # US-LOOP-005 T8: claude session failed after retry budget → cycle_end failed
3151
+ _loop_event cycle_end "\${CYCLE_ID}" "" "failed" || true; _CYCLE_END_WRITTEN=1
2996
3152
  _worktree_alert "cycle \${CYCLE_ID}: claude exited \$_exit; worktree preserved at \$WT (branch \$BRANCH)"
2997
3153
  echo "[loop] cycle \${CYCLE_ID}: claude failed (exit \$_exit); worktree preserved at \$WT"
2998
3154
  fi
@@ -3003,31 +3159,9 @@ _loop_cleanup_stale_cycle_branches "${project_path}" || true
3003
3159
 
3004
3160
  # FIX-044 / Step 5: Write loop cycle run summary to runs.jsonl
3005
3161
  # Deterministic — runs in shell regardless of whether agent executes SKILL.md Step 5.
3006
- # Idempotent: skips if a record for this run_id already exists (agent may also write).
3007
- _runs_dst="${HOME}/.shared/roll/loop/runs.jsonl"
3008
- mkdir -p "\$(dirname "\$_runs_dst")"
3009
- _cycle_end=\$(date -u +%s)
3010
- _cycle_dur=\$(( _cycle_end - CYCLE_START ))
3011
- _ts=\$(date -u +%Y-%m-%dT%H:%M:%SZ)
3012
- _run_id="loop-\${CYCLE_ID%-*}"
3013
- if command -v jq >/dev/null 2>&1 && ! grep -qF "\"run_id\":\"\$_run_id\"" "\$_runs_dst" 2>/dev/null; then
3014
- jq -nc \\
3015
- --arg ts "\$_ts" \\
3016
- --arg project "${slug}" \\
3017
- --arg run_id "\$_run_id" \\
3018
- --arg status "\$_cycle_status" \\
3019
- --arg cycle_id "\$CYCLE_ID" \\
3020
- --argjson built "\$_cycle_built" \\
3021
- --argjson skipped '[]' \\
3022
- --argjson alerts '[]' \\
3023
- --argjson tcr_count "\$_cycle_tcr" \\
3024
- --argjson duration_sec "\$_cycle_dur" \\
3025
- '{ts:\$ts, project:\$project, run_id:\$run_id, status:\$status,
3026
- cycle_id:\$cycle_id,
3027
- built:\$built, skipped:\$skipped, alerts:\$alerts,
3028
- tcr_count:\$tcr_count, duration_sec:\$duration_sec}' \\
3029
- >> "\$_runs_dst" 2>/dev/null || true
3030
- fi
3162
+ # US-LOOP-005: now routed through _runs_append so timeout/worktree-setup-fail
3163
+ # share the same write logic. _runs_append is idempotent on run_id.
3164
+ _runs_append "\$_cycle_status" "\$_cycle_tcr" "\$_cycle_built" 2>/dev/null || true
3031
3165
  INNER
3032
3166
  chmod +x "$inner_path"
3033
3167
 
@@ -3059,13 +3193,13 @@ if [ -z "\$ROLL_LOOP_FORCE" ] && [ -f "\$PAUSE" ]; then exit 0; fi
3059
3193
  HEARTBEAT_TIMEOUT="\${ROLL_HEARTBEAT_TIMEOUT:-1800}"
3060
3194
  # FIX-052: per-project STATE_FILE (was global state.yaml — caused two projects
3061
3195
  # to clobber each other's cycle state).
3062
- STATE_FILE="${HOME}/.shared/roll/loop/state-${slug}.yaml"
3196
+ STATE_FILE="\${_SHARED_ROOT:-\${HOME}/.shared/roll}/loop/state-${slug}.yaml"
3063
3197
  if [ -f "\$STATE_FILE" ]; then
3064
3198
  _state=\$(grep '^status:' "\$STATE_FILE" | awk '{print \$2}' 2>/dev/null || echo "")
3065
3199
  if [ "\$_state" = "running" ]; then
3066
3200
  _still_active=false
3067
3201
  # FIX-038: heartbeat is primary signal
3068
- _heartbeat_file="${HOME}/.shared/roll/loop/.heartbeat-${slug}"
3202
+ _heartbeat_file="\${_SHARED_ROOT:-\${HOME}/.shared/roll}/loop/.heartbeat-${slug}"
3069
3203
  if [ -f "\$_heartbeat_file" ]; then
3070
3204
  _hb_ts=\$(cat "\$_heartbeat_file" 2>/dev/null || echo "0")
3071
3205
  _now=\$(date -u +%s)
@@ -4543,7 +4677,7 @@ _changelog_lint_bullet() {
4543
4677
  if [ "${len:-0}" -gt 50 ]; then
4544
4678
  echo "over-length"
4545
4679
  fi
4546
- if printf '%s' "$stripped" | grep -qE '(^|[^A-Za-z0-9_])(docs|bin|tests|scripts)/'; then
4680
+ if printf '%s' "$stripped" | grep -qE '(^|[^A-Za-z0-9_])(\.roll|docs|bin|tests|scripts)/'; then
4547
4681
  echo "path-fragment"
4548
4682
  fi
4549
4683
  return 0
@@ -4600,7 +4734,7 @@ _changelog_audit_bullet() {
4600
4734
 
4601
4735
  # Rule 3: file suffix / path fragment outside backticks.
4602
4736
  if printf '%s' "$stripped" | grep -qE '\.(md|sh|yml|ts|bats)([^A-Za-z0-9]|$)' \
4603
- || printf '%s' "$stripped" | grep -qE '(^|[^A-Za-z0-9_])(docs|bin|tests|scripts)/'; then
4737
+ || printf '%s' "$stripped" | grep -qE '(^|[^A-Za-z0-9_])(\.roll|docs|bin|tests|scripts)/'; then
4604
4738
  echo "path-or-suffix"
4605
4739
  fi
4606
4740
 
@@ -4936,7 +5070,9 @@ _loop_is_doc_only_change() {
4936
5070
  local changed
4937
5071
  changed=$(git diff --name-only origin/main HEAD 2>/dev/null) || return 1
4938
5072
  [ -z "$changed" ] && return 1
4939
- echo "$changed" | grep -qvE '^(BACKLOG\.md|CHANGELOG\.md|PROPOSALS\.md|docs/|\.claude/)' && return 1
5073
+ # Post-Phase-1: process artifacts moved into .roll/; user-facing docs at guide/ + site/.
5074
+ # Legacy paths (BACKLOG.md, PROPOSALS.md, docs/) kept as fallback for pre-2.0 projects.
5075
+ echo "$changed" | grep -qvE '^(\.roll/|CHANGELOG\.md|guide/|site/|\.claude/|BACKLOG\.md|PROPOSALS\.md|docs/)' && return 1
4940
5076
  return 0
4941
5077
  }
4942
5078
 
@@ -5229,7 +5365,7 @@ cmd_brief() {
5229
5365
  latest=$(ls "${briefs_dir}"/*.md 2>/dev/null | sort | tail -1 || true)
5230
5366
  else
5231
5367
  local mod_time now age
5232
- mod_time=$(stat -c %Y "$latest" 2>/dev/null || stat -f %m "$latest" 2>/dev/null || echo 0)
5368
+ mod_time=$(_file_mtime "$latest")
5233
5369
  now=$(date +%s); age=$(( now - mod_time ))
5234
5370
  if (( age > 86400 )); then
5235
5371
  info "Brief is $(( age / 3600 ))h old — regenerating... 简报已 $(( age / 3600 )) 小时未更新,重新生成..."
@@ -5606,7 +5742,7 @@ _dash_last_dream_hours() {
5606
5742
  local dream_log="${HOME}/.shared/roll/dream/log.md"
5607
5743
  [[ -f "$dream_log" ]] || return 0
5608
5744
  local mod_time now
5609
- mod_time=$(stat -c %Y "$dream_log" 2>/dev/null || stat -f %m "$dream_log" 2>/dev/null || echo 0)
5745
+ mod_time=$(_file_mtime "$dream_log")
5610
5746
  now=$(date +%s)
5611
5747
  echo $(( (now - mod_time) / 3600 ))
5612
5748
  }
@@ -5626,7 +5762,7 @@ _dash_last_peer() {
5626
5762
  local result
5627
5763
  result=$(grep -oE '(AGREE|REFINE|OBJECT|ESCALATE)' "$latest" 2>/dev/null | tail -1 || true)
5628
5764
  local mod_time now days
5629
- mod_time=$(stat -c %Y "$latest" 2>/dev/null || stat -f %m "$latest" 2>/dev/null || echo 0)
5765
+ mod_time=$(_file_mtime "$latest")
5630
5766
  now=$(date +%s)
5631
5767
  days=$(( (now - mod_time) / 86400 ))
5632
5768
  printf '%s|%s' "${result:-—}" "${days}"
@@ -5919,7 +6055,7 @@ _legacy_home() {
5919
6055
  local latest_brief; latest_brief=$(ls .roll/briefs/*.md 2>/dev/null | sort | tail -1 || true)
5920
6056
  if [[ -n "$latest_brief" ]]; then
5921
6057
  local mod_time now age summary
5922
- mod_time=$(stat -c %Y "$latest_brief" 2>/dev/null || stat -f %m "$latest_brief" 2>/dev/null || echo 0)
6058
+ mod_time=$(_file_mtime "$latest_brief")
5923
6059
  now=$(date +%s); age=$(( (now - mod_time) / 3600 ))
5924
6060
  summary=$(_dash_brief_summary "$latest_brief")
5925
6061
  printf " Brief ${CYAN}%sh${NC} ago — %s\n" "$age" "${summary:-—}"
@@ -5956,7 +6092,7 @@ _legacy_help() {
5956
6092
  echo "Commands:"
5957
6093
  echo " setup [-f] [Machine] First-time install or re-sync 首次安装或重新同步"
5958
6094
  echo " update [Upgrade] npm install latest + re-sync 一键升级到最新版"
5959
- echo " init [Project] Create AGENTS.md + .roll/backlog.md + docs/ 初始化项目工作流文件"
6095
+ echo " init [Project] Create AGENTS.md + .roll/backlog.md + .roll/features/ 初始化项目工作流文件"
5960
6096
  echo " status [Diagnostic] Show current state 显示当前状态"
5961
6097
  echo " peer [Peer Review] Cross-agent negotiation 跨 Agent 协商对审"
5962
6098
  echo " loop <on|off|now|status|monitor|resume|reset> [Autonomous] Manage scheduled BACKLOG executor 管理自主执行循环"
@@ -244,7 +244,7 @@ def _write_demo(path: str) -> None:
244
244
  ### Feature: autonomous-evolution
245
245
  | Story | Description | Status |
246
246
  |-------|-------------|--------|
247
- | [US-AUTO-042](.roll/features/autonomous-evolution.md) | Loop cost telemetry — write model and token data per cycle | 📋 Todo |
247
+ | [US-AUTO-042](.roll/features/autonomous-evolution/autonomous-evolution.md) | Loop cost telemetry — write model and token data per cycle | 📋 Todo |
248
248
 
249
249
  ## ♻️ Refactor
250
250
  | ID | Description | Status |
package/lib/roll-help.py CHANGED
@@ -45,7 +45,7 @@ AUTONOMY = [
45
45
  ]
46
46
 
47
47
  PROJECT = [
48
- ("init", "", "create AGENTS.md + .roll/backlog.md + docs/", "初始化项目工作流文件", False),
48
+ ("init", "", "create AGENTS.md + .roll/backlog.md + .roll/features/", "初始化项目工作流文件", False),
49
49
  ("status", "", "show current state and drift", "显示当前状态和漂移项", False),
50
50
  ("agent", "[use <name>]", "per-project agent selection", "切换项目 agent", False),
51
51
  ("ci", "[--wait]", "show or wait for current commit's CI status", "查看 / 等待 CI 状态", False),
package/lib/roll-home.py CHANGED
@@ -116,6 +116,26 @@ def _launchd_svc_state(service: str, slug: str) -> str:
116
116
  except Exception:
117
117
  return "installed-off"
118
118
 
119
+ def _read_plist_schedule(service: str, slug: str) -> Optional[Dict[str, int]]:
120
+ """FIX-063: read actual Minute/Hour from launchd plist (truth source).
121
+ Returns {'minute': N, 'hour': N|None} or None if plist missing.
122
+ Dashboard must reflect what launchd actually fires, not a hardcoded default.
123
+ """
124
+ label = f"com.roll.{service}.{slug}"
125
+ plist = Path(os.path.expanduser("~/Library/LaunchAgents")) / f"{label}.plist"
126
+ if not plist.exists():
127
+ return None
128
+ try:
129
+ text = plist.read_text(errors="ignore")
130
+ except Exception:
131
+ return None
132
+ # Parse <key>Minute</key><integer>N</integer> (and Hour)
133
+ m = re.search(r"<key>Minute</key>\s*<integer>(\d+)</integer>", text)
134
+ h = re.search(r"<key>Hour</key>\s*<integer>(\d+)</integer>", text)
135
+ if not m:
136
+ return None
137
+ return {"minute": int(m.group(1)), "hour": int(h.group(1)) if h else None}
138
+
119
139
  def _dream_last_hours() -> Optional[int]:
120
140
  log = _shared_root() / "dream" / "log.md"
121
141
  if not log.exists():
@@ -469,12 +489,12 @@ def main() -> None:
469
489
  timestamp = datetime.now().strftime("%H:%M"),
470
490
  state = state,
471
491
  loop_state = _launchd_svc_state("loop", slug),
472
- loop_minute = _ci("loop_minute", 38),
492
+ loop_minute = (_read_plist_schedule("loop", slug) or {}).get("minute") or _ci("loop_minute", 38),
473
493
  loop_active_start = _ci("loop_active_start", 10),
474
494
  loop_active_end = _ci("loop_active_end", 18),
475
495
  dream_state = _launchd_svc_state("dream", slug),
476
- dream_hour = _ci("loop_dream_hour", 3),
477
- dream_minute = _ci("loop_dream_minute", 12),
496
+ dream_hour = (_read_plist_schedule("dream", slug) or {}).get("hour") or _ci("loop_dream_hour", 3),
497
+ dream_minute = (_read_plist_schedule("dream", slug) or {}).get("minute") or _ci("loop_dream_minute", 12),
478
498
  dream_last_hours = _dream_last_hours(),
479
499
  refactor_pending = refactor_pending,
480
500
  peer_last = _peer_last(),
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env python3
2
+ """roll-init — v2 terminal view for `roll init` (US-VIEW-008)."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+
9
+ _LIB_DIR = os.path.dirname(os.path.realpath(__file__))
10
+ if _LIB_DIR not in sys.path:
11
+ sys.path.insert(0, _LIB_DIR)
12
+ import roll_render
13
+ from roll_render import c, row, COLS
14
+
15
+ # ════════════════════════════════════════════════════════════════════════════
16
+ # Demo data — 6 steps mirror cmd_init's actual phases.
17
+ # AC text: detect → AGENTS.md → BACKLOG.md → docs/features/ → merge CLAUDE.md → link skills
18
+ # Each entry: (label, [(op, filename)]) where op ∈ {"+", "~"}.
19
+ # ════════════════════════════════════════════════════════════════════════════
20
+
21
+ _DEMO_STEPS = [
22
+ ("Detect project type", []),
23
+ ("Create AGENTS.md", [("+", "AGENTS.md")]),
24
+ ("Create .roll/backlog.md", [("+", ".roll/backlog.md")]),
25
+ ("Create .roll/features/", [("+", ".roll/features/")]),
26
+ ("Merge existing CLAUDE.md", [("~", "CLAUDE.md")]),
27
+ ("Link skills to AI clients", [("+", "~/.claude/skills/roll-build"),
28
+ ("+", "~/.claude/skills/roll-fix")]),
29
+ ]
30
+
31
+ _NEXT_STEPS = [
32
+ ("Edit .roll/backlog.md", "open the backlog and add your first US"),
33
+ ("Run roll loop now", "execute one cycle manually to test the flow"),
34
+ ("Enable loop scheduling", "roll loop on — let it run hourly"),
35
+ ]
36
+
37
+
38
+ # ════════════════════════════════════════════════════════════════════════════
39
+ # Render
40
+ # ════════════════════════════════════════════════════════════════════════════
41
+
42
+ def _divider(char: str = "─") -> None:
43
+ print(c("dim", char * min(COLS, 80)))
44
+
45
+
46
+ def _op_marker(op: str) -> str:
47
+ if op == "+":
48
+ return c("green", "+", bold=True)
49
+ if op == "~":
50
+ return c("amber", "~", bold=True)
51
+ return c("dim", op)
52
+
53
+
54
+ def render_demo(project_path: str = "~/myproject") -> None:
55
+ left = " " + c("blue", "INIT", bold=True) + c("dim", " · ") + c("dim", "项目初始化")
56
+ right = c("dim", project_path) + " "
57
+ print(row(left, right))
58
+ _divider()
59
+ print()
60
+
61
+ for i, (label, files) in enumerate(_DEMO_STEPS, start=1):
62
+ num = c("dim", f" {i}.")
63
+ icon = c("green", "✓")
64
+ print(f"{num} {icon} {label}")
65
+ for op, fname in files:
66
+ mark = _op_marker(op)
67
+ color = "green" if op == "+" else "amber"
68
+ print(" " + mark + " " + c(color, fname))
69
+
70
+ print()
71
+ _divider()
72
+ print(" " + c("green", "✓") + " " + c("green", "Project ready", bold=True))
73
+ print()
74
+ print(" " + c("pink", "NEXT", bold=True) + c("dim", " · 下一步"))
75
+ for i, (label, hint) in enumerate(_NEXT_STEPS, start=1):
76
+ num = c("dim", f" {i}.")
77
+ print(f"{num} {c('fg', label, bold=True)}")
78
+ print(" " + c("dim", hint))
79
+ _divider("═")
80
+
81
+
82
+ # ════════════════════════════════════════════════════════════════════════════
83
+ # Entry point
84
+ # ════════════════════════════════════════════════════════════════════════════
85
+
86
+ def main() -> None:
87
+ ap = argparse.ArgumentParser(add_help=False)
88
+ ap.add_argument("--demo", action="store_true")
89
+ ap.add_argument("--no-color", dest="no_color", action="store_true")
90
+ ap.add_argument("--en", action="store_true")
91
+ ap.add_argument("--zh", action="store_true")
92
+ args, _ = ap.parse_known_args()
93
+
94
+ if args.no_color or os.environ.get("NO_COLOR") or not sys.stdout.isatty():
95
+ roll_render.USE_COLOR = False
96
+
97
+ render_demo(project_path=os.getcwd())
98
+
99
+
100
+ if __name__ == "__main__":
101
+ main()
@@ -701,11 +701,27 @@ def render(events, cron, state, backlog, *, days=3, lang="both", now=None,
701
701
  c("muted", " ") +
702
702
  c("dim", "more ") + c("blue", "roll loop --days 7"))
703
703
 
704
+ def _read_plist_loop_minute() -> int:
705
+ """FIX-063: read actual loop Minute from launchd plist (truth source).
706
+ Falls back to 48 only when plist missing/unparseable.
707
+ """
708
+ import re as _re
709
+ slug = project_slug()
710
+ plist = Path(os.path.expanduser("~/Library/LaunchAgents")) / f"com.roll.loop.{slug}.plist"
711
+ if not plist.exists():
712
+ return 48
713
+ try:
714
+ text = plist.read_text(errors="ignore")
715
+ except Exception:
716
+ return 48
717
+ m = _re.search(r"<key>Minute</key>\s*<integer>(\d+)</integer>", text)
718
+ return int(m.group(1)) if m else 48
719
+
720
+
704
721
  def _next_cron_hint(state: Dict[str, str], zh: bool = False) -> str:
705
- """Best-effort next-cron string. The real schedule lives in launchd/cron;
706
- we only have access to last_run here, so we approximate to the next :48."""
722
+ """Compute next cron fire time from the actual launchd plist Minute (FIX-063)."""
707
723
  now = datetime.now().astimezone()
708
- minute_target = 48 # bin/roll default; per-project may differ
724
+ minute_target = _read_plist_loop_minute()
709
725
  nxt = now.replace(minute=minute_target, second=0, microsecond=0)
710
726
  if nxt <= now:
711
727
  nxt += timedelta(hours=1)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanyao/roll",
3
- "version": "2026.519.1",
3
+ "version": "2026.519.3",
4
4
  "description": "Roll — Roll out features with AI agents",
5
5
  "scripts": {
6
6
  "test": "bash tests/run.sh"
@@ -278,7 +278,7 @@ When any signal appears, **do not stop — flag it**:
278
278
  # 1. Append to .roll/backlog.md under ## ♻️ Refactor
279
279
  # REFACTOR-XXX | <one-line description> | 📋 Todo
280
280
 
281
- # 2. Append a brief entry to .roll/features/refactor-log.md
281
+ # 2. Append a brief entry to .roll/features/autonomous-evolution/refactor-log.md
282
282
  ```
283
283
 
284
284
  **REFACTOR entry format in .roll/backlog.md:**
@@ -287,7 +287,7 @@ When any signal appears, **do not stop — flag it**:
287
287
  | REFACTOR-001 | {one-line plain-language description} | 📋 Todo |
288
288
  ```
289
289
 
290
- 描述写法:参见 AGENTS.md "Backlog descriptions" 规则。说清楚"什么需要改"以及"不改会怎样",技术细节写在 `.roll/features/refactor-log.md`。
290
+ 描述写法:参见 AGENTS.md "Backlog descriptions" 规则。说清楚"什么需要改"以及"不改会怎样",技术细节写在 `.roll/features/autonomous-evolution/refactor-log.md`。
291
291
 
292
292
  **refactor-log.md entry format:**
293
293
 
@@ -208,11 +208,14 @@ For each item, **before invoking the executor skill**, mark the story 🔨 In Pr
208
208
 
209
209
  This commit is what makes the work visible — without it, tcr micro-commits during execution are invisible to `roll-brief`.
210
210
 
211
- 选定故事后,调用 `_loop_event` 发出 story 事件,让 monitor attach 能渲染当前进度:
211
+ 选定故事后,调用 `_loop_event` 发出 pick_todo 事件,让 dashboard / monitor / attach 都能把"这个 cycle 选了哪个 story"正确归类:
212
212
 
213
213
  ```bash
214
214
  # 选定故事后立即 emit(在调用 executor skill 之前)
215
- _loop_event story "$US_ID" "$story_title" ""
215
+ # label 必须是 cycle_id(来自 bin/roll 注入的 LOOP_CYCLE_ID 环境变量),
216
+ # 不是 US_ID — dashboard 按 label 聚类,US_ID 当 label 会让事件分到错的桶
217
+ # 里,cycle 看起来"有 token 没 ID"。
218
+ _loop_event pick_todo "$LOOP_CYCLE_ID" "$US_ID" ""
216
219
  ```
217
220
 
218
221
  Then invoke the executor: