@seanyao/roll 2026.519.1 → 2026.519.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,10 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## v2026.519.2
4
+
5
+ ### Improved
6
+
7
+ - **`roll init`** — 初始化流程现在显示 6 步编号进度,新建文件用绿色 `+`、合并已有用琥珀 `~`,结尾给三步上手指南 `[loop]`
8
+
3
9
  ## v2026.519.1
4
10
 
11
+ ### Major(大版本重构)
12
+
13
+ - **项目结构重组 — 过程文件迁入 `.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]`
14
+ - **新命令 `roll migrate`** — 老项目一键迁到新结构:dry-run 预览 + `git mv` 保留历史 + 单原子 commit + 三态幂等(仅老 / 仅新 / 并存) `[legacy-onboard]`
15
+ - **新版 `roll init` 识别 Legacy 项目** — 检测到现有源码无 `AGENTS.md` 时引导用户进入 onboard 流程:列出本机 AI agent、显式告知 token 消耗、引导运行 `$roll-onboard` `[legacy-onboard]`
16
+ - **新技能 `$roll-onboard`** — 三组九问 ≤ 3 分钟,AI 读懂项目后生成 `.roll/onboard-plan.yaml`,bash 侧 `roll init --apply` 执行 `[legacy-onboard]`
17
+ - **新命令 `roll init --apply`** — 消费 onboard plan 创建 `.roll/` 结构,按用户选择写 `.gitignore`、同步 AI 工具约定 `[legacy-onboard]`
18
+ - **结构强制检测** — 新版 Roll 在老结构项目上拒绝运行 + 引导 `roll migrate`(`setup` / `update` / `version` / `help` / `init` 豁免;`ROLL_SKIP_STRUCTURE_CHECK=1` 旁路) `[legacy-onboard]`
19
+
20
+ ### Improved
21
+
22
+ - `AGENTS.md` §8 Documentation Conventions 重写匹配新目录结构,明确"过程默认对内、产品默认对外"原则 `[docs]`
23
+ - `guide/{en,zh}/practices/` 收入工程规范文档(原 `docs/practices/engineering-common-sense.md`) `[docs]`
24
+ - 新增 Python 校验器 `lib/roll-plan-validate.py`,验证 onboard plan 完整性、`generated_at` 24h 时效、版本兼容 `[legacy-onboard]`
25
+
5
26
  ### Fixed
6
27
 
7
28
  - **`roll setup` 后从未开启 loop 的项目** — 不再被 macOS 自动激活、每小时弹出终端窗口 `[loop]`
29
+ - **`_write_backlog` 缺 `mkdir -p` 导致 `cmd_init` 在 `.roll/` 不存在时崩** `[legacy-onboard]`
30
+ - **`release.sh` 多 feature 时 awk `newline in string` 错误** — macOS BSD awk 不容忍 `-v var=多行`;改用 `ENVIRON` 读取 `[release]`
31
+ - **GitHub 仓库改名 Roll → roll** — 内部代码、测试 fixture、文档引用全部同步小写命名 `[chore]`
32
+ - **`.roll/backlog.md` 和 `guide/*` 中残留 `docs/features` 等老路径引用** — Story 5 sed 漏覆盖 `.roll/` 和 `guide/` 文件,dream 巡检发现后补齐 `[legacy-onboard]`
8
33
 
9
34
  ## v2026.518.4
10
35
 
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,18 @@ 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
+
40
+ 📖 Upgrading from 1.x? Read [guide/en/migration-2.0.md](guide/en/migration-2.0.md).
41
+ 📖 New legacy project? Read [guide/en/legacy-onboarding.md](guide/en/legacy-onboarding.md).
42
+
31
43
  ## Evolution
32
44
 
33
45
  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?*
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.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"
@@ -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
@@ -2409,6 +2426,13 @@ cmd_agent() {
2409
2426
  # Returns a filesystem-safe slug combining the project basename and a 6-char
2410
2427
  # hash of the full path, ensuring uniqueness across sibling dirs with same name.
2411
2428
  _project_slug() {
2429
+ # US-LOOP-006: cycle wrapper exports ROLL_MAIN_SLUG so any subshell — worktree,
2430
+ # tmp cwd, or unrelated path — writes events / runs.jsonl under the main project
2431
+ # identity instead of fragmenting into tmp-* / cycle-* phantom slugs.
2432
+ if [[ -n "${ROLL_MAIN_SLUG:-}" ]]; then
2433
+ printf '%s' "$ROLL_MAIN_SLUG"
2434
+ return 0
2435
+ fi
2412
2436
  local path="${1:-$(pwd -P 2>/dev/null || pwd)}"
2413
2437
  # FIX-056: normalize path to canonical case on macOS case-insensitive filesystem.
2414
2438
  # Two paths differing only in case point to the same directory; realpath
@@ -2769,6 +2793,38 @@ LOOP_CYCLE_TIMEOUT_SEC="\${ROLL_LOOP_CYCLE_TIMEOUT_SEC:-2700}"
2769
2793
  _CYCLE_TIMED_OUT=0
2770
2794
  _on_sigterm() { _CYCLE_TIMED_OUT=1; }
2771
2795
  trap '_on_sigterm' TERM
2796
+ # US-LOOP-005: idempotent runs.jsonl writer shared by normal exit, timeout
2797
+ # trap, and worktree-setup-failure early exit. Guards on jq + run_id dedupe so
2798
+ # multiple callers in the same cycle are safe.
2799
+ _runs_append() {
2800
+ local _status="\$1"; local _tcr="\${2:-0}"; local _built="\${3:-[]}"
2801
+ local _runs_dst="${HOME}/.shared/roll/loop/runs.jsonl"
2802
+ command -v jq >/dev/null 2>&1 || return 0
2803
+ local _cid="\${CYCLE_ID:-pre-cycle-\$\$}"
2804
+ local _rid="loop-\${_cid%-*}"
2805
+ grep -qF "\"run_id\":\"\$_rid\"" "\$_runs_dst" 2>/dev/null && return 0
2806
+ mkdir -p "\$(dirname "\$_runs_dst")"
2807
+ local _ts_now; _ts_now=\$(date -u +%Y-%m-%dT%H:%M:%SZ)
2808
+ local _start="\${CYCLE_START:-\$(date -u +%s)}"
2809
+ local _dur=\$(( \$(date -u +%s) - _start ))
2810
+ [ "\$_dur" -lt 0 ] && _dur=0
2811
+ jq -nc \\
2812
+ --arg ts "\$_ts_now" \\
2813
+ --arg project "${slug}" \\
2814
+ --arg run_id "\$_rid" \\
2815
+ --arg status "\$_status" \\
2816
+ --arg cycle_id "\$_cid" \\
2817
+ --argjson built "\$_built" \\
2818
+ --argjson skipped '[]' \\
2819
+ --argjson alerts '[]' \\
2820
+ --argjson tcr_count "\$_tcr" \\
2821
+ --argjson duration_sec "\$_dur" \\
2822
+ '{ts:\$ts, project:\$project, run_id:\$run_id, status:\$status,
2823
+ cycle_id:\$cycle_id,
2824
+ built:\$built, skipped:\$skipped, alerts:\$alerts,
2825
+ tcr_count:\$tcr_count, duration_sec:\$duration_sec}' \\
2826
+ >> "\$_runs_dst" 2>/dev/null || true
2827
+ }
2772
2828
  _inner_cleanup() {
2773
2829
  local _rc=\$?
2774
2830
  # Kill heartbeat + every remaining background job (watchdog, orphan
@@ -2778,6 +2834,10 @@ _inner_cleanup() {
2778
2834
  for _pid in \$(jobs -p); do kill "\$_pid" 2>/dev/null; done
2779
2835
  if [ "\${_CYCLE_TIMED_OUT:-0}" -eq 1 ]; then
2780
2836
  _loop_event cycle_end "\${CYCLE_ID:-unknown}" "\${BRANCH:-}" "blocked" 2>/dev/null || true
2837
+ # US-LOOP-005 T9: timeout path must also write runs.jsonl row so dashboard
2838
+ # has a terminal record (cycle_end alone is insufficient — runs.jsonl is
2839
+ # the canonical history feed for 'roll loop runs').
2840
+ _runs_append "failed" 0 "[]" 2>/dev/null || true
2781
2841
  _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
2842
  fi
2783
2843
  rm -f "\$INNER_LOCK" "\$HEARTBEAT_FILE"
@@ -2800,12 +2860,16 @@ _LOOP_PROJ_SLUG="${slug}"
2800
2860
  _LOOP_ALERT="\${_SHARED_ROOT}/loop/ALERT-${slug}.md"
2801
2861
  _LOOP_STATE="\${_SHARED_ROOT}/loop/state-${slug}.yaml"
2802
2862
  _LOOP_MUTE_FILE="\${_SHARED_ROOT}/loop/mute-${slug}"
2863
+ # US-LOOP-006: ROLL_MAIN_SLUG is the canonical identity for any subprocess —
2864
+ # claude, loop-fmt.py, _loop_event in arbitrary cwd. _project_slug honors this
2865
+ # env var first, so writes never fragment into tmp-* / cycle-* phantom slugs.
2866
+ export ROLL_MAIN_SLUG="${slug}"
2803
2867
 
2804
2868
  # Pre-claude: try to create a per-cycle isolated worktree on origin/main.
2805
2869
  # On any failure (no remote, no main, etc.) fall back to running in the
2806
2870
  # 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)
2871
+ CYCLE_ID="\$(date +%Y%m%d-%H%M%S)-\$\$"
2872
+ CYCLE_START=\$(date +%s)
2809
2873
  WT="\$(_worktree_path "${slug}" "cycle-\${CYCLE_ID}")"
2810
2874
  BRANCH="loop/cycle-\${CYCLE_ID}"
2811
2875
  _USE_WORKTREE=0
@@ -2859,6 +2923,10 @@ else
2859
2923
  # falling back to the main tree without isolation is unacceptable.
2860
2924
  _worktree_alert "cycle \${CYCLE_ID}: worktree setup failed — skipping cycle to avoid running without isolation"
2861
2925
  echo "[loop] cycle \${CYCLE_ID}: worktree setup failed; skipping cycle (no isolation)"
2926
+ # US-LOOP-005 T10: worktree-setup-failed path leaves no commits and never
2927
+ # emits cycle_start, but dashboard still needs a runs.jsonl row marking the
2928
+ # cycle as failed (otherwise the scheduled tick appears to have vanished).
2929
+ _runs_append "failed" 0 "[]" 2>/dev/null || true
2862
2930
  exit 0
2863
2931
  fi
2864
2932
 
@@ -2960,6 +3028,8 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
2960
3028
  elif [ "\$_publish_status" -eq 2 ]; then
2961
3029
  if ( cd "${project_path}" && _worktree_merge_back "\$BRANCH" ); then
2962
3030
  _worktree_cleanup "\$WT" "\$BRANCH"
3031
+ # US-LOOP-005 T3: gh unavailable + ff merge_back OK → cycle_end done
3032
+ _loop_event cycle_end "\${CYCLE_ID}" "" "done" || true
2963
3033
  echo "[loop] cycle \${CYCLE_ID}: gh unavailable; merged via ff and cleaned up"
2964
3034
  else
2965
3035
  # FIX-039: gh unavailable + merge_back failed — push orphan branch+tag to origin
@@ -2969,9 +3039,13 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
2969
3039
  && git tag "\$_orphan_tag" 2>/dev/null \
2970
3040
  && git push origin "\$_orphan_tag" 2>/dev/null ); then
2971
3041
  _worktree_cleanup "\$WT" "\$BRANCH"
3042
+ # US-LOOP-005 T4: gh unavailable + orphan push OK → cycle_end orphan
3043
+ _loop_event cycle_end "\${CYCLE_ID}" "" "orphan" || true
2972
3044
  _worktree_alert "cycle \${CYCLE_ID}: gh+merge_back failed; FIX-039 pushed orphan+tag \${_orphan_tag}; worktree cleaned"
2973
3045
  echo "[loop] cycle \${CYCLE_ID}: FIX-039: orphan branch+tag \${_orphan_tag} pushed; worktree cleaned"
2974
3046
  else
3047
+ # US-LOOP-005 T5: gh unavailable + all failed → cycle_end failed
3048
+ _loop_event cycle_end "\${CYCLE_ID}" "" "failed" || true
2975
3049
  _worktree_alert "cycle \${CYCLE_ID}: gh+merge_back+push all failed; worktree preserved at \$WT"
2976
3050
  echo "[loop] cycle \${CYCLE_ID}: all publish paths failed; worktree preserved at \$WT"
2977
3051
  fi
@@ -2984,15 +3058,21 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
2984
3058
  && git tag "\$_orphan_tag" 2>/dev/null \
2985
3059
  && git push origin "\$_orphan_tag" 2>/dev/null ); then
2986
3060
  _worktree_cleanup "\$WT" "\$BRANCH"
3061
+ # US-LOOP-005 T6: PR publish failed + orphan push OK → cycle_end orphan
3062
+ _loop_event cycle_end "\${CYCLE_ID}" "" "orphan" || true
2987
3063
  _worktree_alert "cycle \${CYCLE_ID}: PR publish failed; FIX-039 pushed orphan+tag \${_orphan_tag}; worktree cleaned"
2988
3064
  echo "[loop] cycle \${CYCLE_ID}: FIX-039: orphan branch+tag \${_orphan_tag} pushed; worktree cleaned"
2989
3065
  else
3066
+ # US-LOOP-005 T7: PR publish failed + orphan push failed → cycle_end failed
3067
+ _loop_event cycle_end "\${CYCLE_ID}" "" "failed" || true
2990
3068
  _worktree_alert "cycle \${CYCLE_ID}: PR publish failed; worktree preserved at \$WT (branch \$BRANCH)"
2991
3069
  echo "[loop] cycle \${CYCLE_ID}: PR publish failed; worktree preserved at \$WT"
2992
3070
  fi
2993
3071
  fi
2994
3072
  fi
2995
3073
  else
3074
+ # US-LOOP-005 T8: claude session failed after retry budget → cycle_end failed
3075
+ _loop_event cycle_end "\${CYCLE_ID}" "" "failed" || true
2996
3076
  _worktree_alert "cycle \${CYCLE_ID}: claude exited \$_exit; worktree preserved at \$WT (branch \$BRANCH)"
2997
3077
  echo "[loop] cycle \${CYCLE_ID}: claude failed (exit \$_exit); worktree preserved at \$WT"
2998
3078
  fi
@@ -3003,31 +3083,9 @@ _loop_cleanup_stale_cycle_branches "${project_path}" || true
3003
3083
 
3004
3084
  # FIX-044 / Step 5: Write loop cycle run summary to runs.jsonl
3005
3085
  # 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
3086
+ # US-LOOP-005: now routed through _runs_append so timeout/worktree-setup-fail
3087
+ # share the same write logic. _runs_append is idempotent on run_id.
3088
+ _runs_append "\$_cycle_status" "\$_cycle_tcr" "\$_cycle_built" 2>/dev/null || true
3031
3089
  INNER
3032
3090
  chmod +x "$inner_path"
3033
3091
 
@@ -4543,7 +4601,7 @@ _changelog_lint_bullet() {
4543
4601
  if [ "${len:-0}" -gt 50 ]; then
4544
4602
  echo "over-length"
4545
4603
  fi
4546
- if printf '%s' "$stripped" | grep -qE '(^|[^A-Za-z0-9_])(docs|bin|tests|scripts)/'; then
4604
+ if printf '%s' "$stripped" | grep -qE '(^|[^A-Za-z0-9_])(\.roll|docs|bin|tests|scripts)/'; then
4547
4605
  echo "path-fragment"
4548
4606
  fi
4549
4607
  return 0
@@ -4600,7 +4658,7 @@ _changelog_audit_bullet() {
4600
4658
 
4601
4659
  # Rule 3: file suffix / path fragment outside backticks.
4602
4660
  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
4661
+ || printf '%s' "$stripped" | grep -qE '(^|[^A-Za-z0-9_])(\.roll|docs|bin|tests|scripts)/'; then
4604
4662
  echo "path-or-suffix"
4605
4663
  fi
4606
4664
 
@@ -4936,7 +4994,9 @@ _loop_is_doc_only_change() {
4936
4994
  local changed
4937
4995
  changed=$(git diff --name-only origin/main HEAD 2>/dev/null) || return 1
4938
4996
  [ -z "$changed" ] && return 1
4939
- echo "$changed" | grep -qvE '^(BACKLOG\.md|CHANGELOG\.md|PROPOSALS\.md|docs/|\.claude/)' && return 1
4997
+ # Post-Phase-1: process artifacts moved into .roll/; user-facing docs at guide/ + site/.
4998
+ # Legacy paths (BACKLOG.md, PROPOSALS.md, docs/) kept as fallback for pre-2.0 projects.
4999
+ echo "$changed" | grep -qvE '^(\.roll/|CHANGELOG\.md|guide/|site/|\.claude/|BACKLOG\.md|PROPOSALS\.md|docs/)' && return 1
4940
5000
  return 0
4941
5001
  }
4942
5002
 
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.2",
4
4
  "description": "Roll — Roll out features with AI agents",
5
5
  "scripts": {
6
6
  "test": "bash tests/run.sh"