@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 +25 -0
- package/README.md +13 -1
- package/bin/roll +91 -31
- package/lib/roll-home.py +23 -3
- package/lib/roll-init.py +101 -0
- package/lib/roll-loop-status.py +19 -3
- package/package.json +1 -1
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
|
-
[](https://seanyao.github.io/roll/)
|
|
15
15
|
[](LICENSE)
|
|
16
16
|
[](https://www.npmjs.com/package/@seanyao/roll)
|
|
17
17
|
[](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.
|
|
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
|
|
2808
|
-
CYCLE_START=\$(date
|
|
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
|
-
#
|
|
3007
|
-
|
|
3008
|
-
|
|
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
|
-
|
|
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(),
|
package/lib/roll-init.py
ADDED
|
@@ -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()
|
package/lib/roll-loop-status.py
CHANGED
|
@@ -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
|
-
"""
|
|
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 =
|
|
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)
|