@seanyao/roll 2026.520.1 → 2026.521.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## v2026.521.1
4
+
5
+ ### Added
6
+
7
+ - **dashboard cycle 行现在标出模型和按公开单价算的成本** — 跨账号 / 跨项目可以横向对比和加总,不再被订阅折扣藏掉真实开销 `[loop]`
8
+
9
+ ### Improved
10
+
11
+ - **`roll setup` 重跑能看出"已是最新"还是"刷新了 X 项"** — 每步上报 changed / unchanged / failed,强制覆盖用 `~` 标出 `[loop]`
12
+ - **`roll-peer` 评审第一轮要先独立判断** — 不再被评审方预设结论带跑,跨 agent 才真的是二次判断 `[loop]`
13
+
14
+ ### Fixed
15
+
16
+ - **`roll init` 默认不再打"Project ready"假提示** — 老项目进引导分支时不再骗你说已就绪 `[legacy-onboard]`
17
+
3
18
  ## v2026.520.1
4
19
 
5
20
  ### Added
package/bin/roll CHANGED
@@ -4,7 +4,7 @@ set -euo pipefail
4
4
  # Roll — AI Agent Convention Manager
5
5
  # Single source of truth for how all AI coding agents behave.
6
6
 
7
- VERSION="2026.520.1"
7
+ VERSION="2026.521.1"
8
8
  ROLL_HOME="${ROLL_HOME:-${HOME}/.roll}"
9
9
  ROLL_CONFIG="${ROLL_HOME}/config.yaml"
10
10
  ROLL_GLOBAL="${ROLL_HOME}/conventions/global"
@@ -572,53 +572,154 @@ _ensure_tmux() {
572
572
  return 0
573
573
  }
574
574
 
575
+ # FIX-075: snapshot the content of watched directories so cmd_setup can detect
576
+ # whether a step actually changed any file. Uses `cksum` (mtime-independent) so
577
+ # a re-copy with identical content is recognised as a no-op even when the inner
578
+ # helper rewrites the file. Watch is a colon-separated list of directories;
579
+ # missing dirs are skipped silently.
580
+ _setup_snapshot() {
581
+ local watch="$1"
582
+ local -a dirs
583
+ IFS=':' read -r -a dirs <<<"$watch"
584
+ local d
585
+ {
586
+ for d in "${dirs[@]}"; do
587
+ [[ -d "$d" ]] || continue
588
+ find "$d" -type f -print0 2>/dev/null | xargs -0 cksum 2>/dev/null
589
+ done
590
+ } | sort
591
+ }
592
+
593
+ # FIX-075: run a setup step and report changed/unchanged/failed via the global
594
+ # _ROLL_SETUP_STATE. Caller passes the watch dir(s) plus the command + args.
595
+ # stdout/stderr of the inner command are suppressed (same as the previous
596
+ # pattern in cmd_setup) to keep the v2 UI render the only user-visible output.
597
+ _run_setup_step() {
598
+ local watch="$1"; shift
599
+ local before after
600
+ before=$(_setup_snapshot "$watch")
601
+ if "$@" >/dev/null 2>&1; then
602
+ after=$(_setup_snapshot "$watch")
603
+ if [[ "$before" == "$after" ]]; then
604
+ _ROLL_SETUP_STATE="unchanged"
605
+ else
606
+ _ROLL_SETUP_STATE="changed"
607
+ fi
608
+ else
609
+ _ROLL_SETUP_STATE="failed"
610
+ fi
611
+ }
612
+
575
613
  cmd_setup() {
576
614
  local force=false
577
- local demo=false
578
615
  while [[ $# -gt 0 ]]; do
579
616
  case "$1" in
580
617
  --force|-f) force=true; shift ;;
581
- --demo) demo=true; shift ;;
582
618
  *) err "Unknown argument: $1 未知参数: $1"; exit 1 ;;
583
619
  esac
584
620
  done
585
621
 
586
- if [[ "${ROLL_UI:-v2}" == "v2" ]] || [[ "$demo" == "true" ]]; then
587
- python3 "${ROLL_PKG_DIR}/lib/roll-setup.py" --demo
588
- if [[ "$demo" == "true" ]]; then return; fi
589
- fi
622
+ # Capture per-step outcomes for the v2 UI render at the end.
623
+ local steps_buf=()
624
+ _record() { steps_buf+=("$1|$2"); }
625
+
626
+ # Map snapshot-detected state to v2 UI marker. -f rewrites "changed" to
627
+ # "forced" so the user can tell a forced reinstall apart from a fresh
628
+ # install — both produce diff'd files, only -f was explicitly requested.
629
+ _state_to_marker() {
630
+ local s="$1"
631
+ case "$s" in
632
+ changed) [[ "$force" == "true" ]] && echo forced || echo ok ;;
633
+ unchanged) echo skip ;;
634
+ failed) echo fail ;;
635
+ *) echo fail ;;
636
+ esac
637
+ }
590
638
 
591
- info "Setting up Roll on this machine... 正在初始化 Roll..."
592
- echo ""
639
+ local _ai_dirs="$HOME/.claude:$HOME/.gemini:$HOME/.kimi:$HOME/.codex:$HOME/.cursor:$HOME/.trae:$HOME/.config/opencode:$HOME/.openclaw:$HOME/.pi:$HOME/.deepseek"
593
640
 
594
- _install_local "$force"
595
- echo ""
596
- info "Syncing to AI tools... 正在同步到 AI 工具..."
597
- _sync_conventions "$force"
598
- echo ""
599
- _sync_skills "$force"
641
+ _run_setup_step "$ROLL_HOME" _install_local "$force"
642
+ _record "$(_state_to_marker "$_ROLL_SETUP_STATE")" "Install templates & conventions to ~/.roll"
600
643
 
601
- echo ""
602
- info "Setting up peer review state... 正在初始化 peer review 状态..."
603
- _peer_ensure_state_dir
604
- ok "Peer state directory ready. Peer 状态目录已就绪。"
644
+ _run_setup_step "$_ai_dirs" _sync_conventions "$force"
645
+ _record "$(_state_to_marker "$_ROLL_SETUP_STATE")" "Sync conventions to AI tools"
605
646
 
606
- echo ""
607
- info "Ensuring tmux is installed (required for visible loop runs)... 正在确认 tmux 已安装..."
608
- _ensure_tmux
647
+ _run_setup_step "$_ai_dirs" _sync_skills "$force"
648
+ _record "$(_state_to_marker "$_ROLL_SETUP_STATE")" "Install skills to ~/.claude"
649
+
650
+ _run_setup_step "$ROLL_HOME/.peer-state" _peer_ensure_state_dir
651
+ _record "$(_state_to_marker "$_ROLL_SETUP_STATE")" "Initialize peer-review state directory"
652
+
653
+ if command -v tmux >/dev/null 2>&1; then
654
+ _record skip "Ensure tmux is installed (already present)"
655
+ else
656
+ if _ensure_tmux >/dev/null 2>&1 && command -v tmux >/dev/null 2>&1; then
657
+ _record ok "Ensure tmux is installed"
658
+ else
659
+ _record fail "Ensure tmux is installed"
660
+ fi
661
+ fi
609
662
 
610
663
  if [[ "$(uname)" == "Darwin" ]]; then
611
- echo ""
612
- info "Installing launchd plists... 正在安装 LaunchAgents..."
613
- _install_launchd_plists "$(pwd -P)"
664
+ _run_setup_step "$HOME/Library/LaunchAgents" _install_launchd_plists "$(pwd -P)"
665
+ _record "$(_state_to_marker "$_ROLL_SETUP_STATE")" "Install launchd plists (macOS)"
614
666
  fi
615
667
 
616
- echo ""
617
- ok "Setup complete. 初始化完成。"
668
+ _emit_setup_v2_ui "${steps_buf[@]}"
669
+ }
618
670
 
619
- echo ""
620
- info "Next: run ${BOLD}roll init${NC} inside a project to initialize it. 下一步:在项目目录运行 roll init"
621
- info "Optional gates: run ${BOLD}roll doctor${NC} inside a repo to see which PR review extras are still off. 可选闸门:在仓库内运行 roll doctor 查看 PR 评审两档开关状态"
671
+ # FIX-073: Render the cmd_setup v2 UI from per-step outcomes captured above.
672
+ # FIX-075: footer composition depends on how many steps actually changed
673
+ # all unchanged "no changes"; some forced (~) "re-installed (forced)";
674
+ # any failed → "Setup incomplete"; otherwise → "X items refreshed".
675
+ _emit_setup_v2_ui() {
676
+ local color_flag=""
677
+ if [[ -n "${NO_COLOR:-}" ]] || ! [ -t 1 ]; then
678
+ color_flag="--no-color"
679
+ fi
680
+
681
+ python3 - "$@" <<'PY' \
682
+ | python3 "${ROLL_PKG_DIR}/lib/roll-setup.py" $color_flag
683
+ import json, sys
684
+ entries = sys.argv[1:]
685
+ steps = []
686
+ for i, entry in enumerate(entries, start=1):
687
+ status, _sep, label = entry.partition("|")
688
+ steps.append({"num": i, "label": label, "status": status})
689
+
690
+ n_failed = sum(1 for s in steps if s["status"] == "fail")
691
+ n_forced = sum(1 for s in steps if s["status"] == "forced")
692
+ n_changed = sum(1 for s in steps if s["status"] == "ok")
693
+
694
+ if n_failed:
695
+ footer_status = "fail"
696
+ label = "Setup incomplete"
697
+ hint = None
698
+ elif n_forced:
699
+ footer_status = "ok"
700
+ label = f"Setup re-installed (forced — {n_forced} item{'s' if n_forced != 1 else ''})"
701
+ hint = "run roll init inside a project"
702
+ elif n_changed == 0:
703
+ footer_status = "ok"
704
+ label = "Setup complete (no changes)"
705
+ hint = "everything already up to date"
706
+ else:
707
+ footer_status = "ok"
708
+ label = f"Setup complete ({n_changed} item{'s' if n_changed != 1 else ''} refreshed)"
709
+ hint = "run roll init inside a project"
710
+
711
+ payload = {
712
+ "header_label": "SETUP",
713
+ "subtitle": "初始化",
714
+ "steps": steps,
715
+ "footer": {
716
+ "status": footer_status,
717
+ "label": label,
718
+ "hint": hint,
719
+ },
720
+ }
721
+ print(json.dumps(payload))
722
+ PY
622
723
  }
623
724
 
624
725
  # ─── PR pipeline hint ────────────────────────────────────────────────────────
@@ -917,21 +1018,20 @@ _merge_claude_to_project() {
917
1018
  # Existing AGENTS.md: re-merges global conventions (section-level, non-destructive)
918
1019
  # ═══════════════════════════════════════════════════════════════════════════════
919
1020
  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[@]:-}"
1021
+ # US-ONBOARD-009: --apply consumes onboard-plan.yaml produced by $roll-onboard
1022
+ if [[ "${1:-}" == "--apply" ]]; then
1023
+ if [[ ! -d "$ROLL_TEMPLATES" ]]; then
1024
+ err "No templates found. Run 'roll setup' first. 未找到模板,请先运行 'roll setup'。"
1025
+ exit 1
1026
+ fi
1027
+ shift
1028
+ _init_apply "$@"
1029
+ return $?
1030
+ fi
931
1031
 
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
1032
+ if [[ "${1:-}" == -* ]]; then
1033
+ err "Unknown flag: $1 未知参数: $1"
1034
+ exit 1
935
1035
  fi
936
1036
 
937
1037
  if [[ ! -d "$ROLL_TEMPLATES" ]]; then
@@ -939,13 +1039,6 @@ cmd_init() {
939
1039
  exit 1
940
1040
  fi
941
1041
 
942
- # US-ONBOARD-009: --apply consumes onboard-plan.yaml produced by $roll-onboard
943
- if [[ "${1:-}" == "--apply" ]]; then
944
- shift
945
- _init_apply "$@"
946
- return $?
947
- fi
948
-
949
1042
  local project_dir
950
1043
  project_dir="$(pwd)"
951
1044
  local has_agents=false
@@ -953,7 +1046,6 @@ cmd_init() {
953
1046
 
954
1047
  if [[ -f "$project_dir/AGENTS.md" ]]; then
955
1048
  has_agents=true
956
- info "Re-merging global conventions... 正在重新合并全局约定..."
957
1049
  else
958
1050
  # US-ONBOARD-006: legacy project detection — guide user through $roll-onboard
959
1051
  # instead of blindly scaffolding files into an existing codebase.
@@ -963,25 +1055,90 @@ cmd_init() {
963
1055
  fi
964
1056
  fi
965
1057
 
966
- _merge_global_to_project "$project_dir"
967
- _merge_claude_to_project "$project_dir"
968
- _write_backlog "$project_dir/.roll/backlog.md"
969
- _ensure_features_dir "$project_dir/.roll/features"
970
- _write_features_md "$project_dir/.roll/features.md"
971
- print_merge_summary
1058
+ # FIX-073: Suppress per-step echoes — outcomes are captured into
1059
+ # _ROLL_MERGE_SUMMARY and rendered through the v2 UI below.
1060
+ {
1061
+ _merge_global_to_project "$project_dir"
1062
+ _merge_claude_to_project "$project_dir"
1063
+ _write_backlog "$project_dir/.roll/backlog.md"
1064
+ _ensure_features_dir "$project_dir/.roll/features"
1065
+ _write_features_md "$project_dir/.roll/features.md"
1066
+ } >/dev/null
972
1067
 
973
- echo ""
974
- info "Syncing conventions to AI tools... 正在同步约定到 AI 工具..."
975
- _sync_conventions
976
- echo ""
1068
+ local sync_status="ok"
1069
+ if ! _sync_conventions >/dev/null 2>&1; then
1070
+ sync_status="fail"
1071
+ fi
977
1072
 
978
- _install_launchd_plists "$project_dir"
1073
+ _install_launchd_plists "$project_dir" >/dev/null 2>&1 || true
979
1074
 
1075
+ _emit_init_v2_ui "$project_dir" "$has_agents" "$sync_status"
1076
+ }
1077
+
1078
+ # FIX-073: Build a real-data JSON payload from _ROLL_MERGE_SUMMARY and pipe it
1079
+ # to lib/roll-init.py for v2 UI rendering. Replaces the previous demo-only
1080
+ # render path.
1081
+ _emit_init_v2_ui() {
1082
+ local project_dir="$1"
1083
+ local has_agents="$2"
1084
+ local sync_status="${3:-ok}"
1085
+
1086
+ local header_label="INIT" subtitle="项目初始化" footer_label="Initialized"
980
1087
  if [[ "$has_agents" == "true" ]]; then
981
- ok "Done. 完成。"
982
- else
983
- ok "Initialized. 已初始化。"
1088
+ header_label="REINIT"
1089
+ subtitle="重新合并约定"
1090
+ footer_label="Re-merged"
984
1091
  fi
1092
+
1093
+ local color_flag=""
1094
+ if [[ -n "${NO_COLOR:-}" ]] || ! [ -t 1 ]; then
1095
+ color_flag="--no-color"
1096
+ fi
1097
+
1098
+ python3 - "$project_dir" "$header_label" "$subtitle" "$footer_label" "$sync_status" "${_ROLL_MERGE_SUMMARY[@]}" <<'PY' \
1099
+ | python3 "${ROLL_PKG_DIR}/lib/roll-init.py" $color_flag
1100
+ import json, sys
1101
+ project_dir, header_label, subtitle, footer_label, sync_status, *summary = sys.argv[1:]
1102
+ STATUS = {"created":"ok","merged":"ok","unchanged":"skip","overwritten":"ok","kept":"skip"}
1103
+ OP = {"created":"+","merged":"~","unchanged":"·","overwritten":"~","kept":"·"}
1104
+ by_file = {}
1105
+ for entry in summary:
1106
+ action, _sep, fname = entry.partition("|")
1107
+ if fname:
1108
+ by_file[fname] = action
1109
+
1110
+ def step(num, label, fname):
1111
+ act = by_file.get(fname)
1112
+ if not act:
1113
+ return {"num": num, "label": label, "status": "skip",
1114
+ "note": "not modified"}
1115
+ return {"num": num, "label": label, "status": STATUS.get(act, "ok"),
1116
+ "files": [[OP.get(act, "·"), fname]]}
1117
+
1118
+ steps = [
1119
+ {"num": 1, "label": "Detect project type", "status": "ok"},
1120
+ step(2, "Create AGENTS.md", "AGENTS.md"),
1121
+ step(3, "Create .roll/backlog.md", ".roll/backlog.md"),
1122
+ step(4, "Create .roll/features/", ".roll/features/"),
1123
+ step(5, "Merge existing CLAUDE.md", ".claude/CLAUDE.md"),
1124
+ {"num": 6, "label": "Link skills to AI clients", "status": sync_status},
1125
+ ]
1126
+ footer_status = "fail" if any(s["status"] == "fail" for s in steps) else "ok"
1127
+ payload = {
1128
+ "header_label": header_label,
1129
+ "subtitle": subtitle,
1130
+ "project_path": project_dir,
1131
+ "steps": steps,
1132
+ "footer": {"status": footer_status,
1133
+ "label": footer_label if footer_status == "ok" else "Init incomplete"},
1134
+ "next": [
1135
+ ["Edit .roll/backlog.md", "open the backlog and add your first US"],
1136
+ ["Run roll loop now", "execute one cycle manually to test the flow"],
1137
+ ["Enable loop scheduling", "roll loop on — let it run hourly"],
1138
+ ],
1139
+ }
1140
+ print(json.dumps(payload))
1141
+ PY
985
1142
  }
986
1143
 
987
1144
  # US-ONBOARD-006: Legacy detection
@@ -2219,9 +2376,6 @@ cmd_peer() {
2219
2376
  local context_file=""
2220
2377
  local yolo=false
2221
2378
  local subcmd=""
2222
- # US-VIEW-009: parse --demo before any side effects so the v2 renderer can
2223
- # run standalone (no peer state, no tmux session, no agent call).
2224
- local demo=false
2225
2379
 
2226
2380
  while [[ $# -gt 0 ]]; do
2227
2381
  case "$1" in
@@ -2231,7 +2385,6 @@ cmd_peer() {
2231
2385
  --tag) tag="$2"; shift 2 ;;
2232
2386
  --context) context_file="$2"; shift 2 ;;
2233
2387
  --yes|--yolo) yolo=true; shift ;;
2234
- --demo) demo=true; shift ;;
2235
2388
  status) subcmd="status"; shift ;;
2236
2389
  reset) subcmd="reset"; shift; break ;;
2237
2390
  help|--help|-h) subcmd="help"; shift ;;
@@ -2239,13 +2392,6 @@ cmd_peer() {
2239
2392
  esac
2240
2393
  done
2241
2394
 
2242
- # US-VIEW-009: ROLL_UI=v2 (default) + --demo routes to the redesigned Python view.
2243
- # Set ROLL_UI=v1 to fall back to the legacy bash implementation.
2244
- if [[ "$demo" == "true" ]] && { [[ "${ROLL_UI:-v2}" == "v2" ]] || [[ "$demo" == "true" ]]; }; then
2245
- python3 "${ROLL_PKG_DIR}/lib/roll-peer.py" --demo
2246
- return
2247
- fi
2248
-
2249
2395
  case "$subcmd" in
2250
2396
  status) cmd_peer_status; return ;;
2251
2397
  reset) cmd_peer_reset "$@"; return ;;
package/lib/loop-fmt.py CHANGED
@@ -116,10 +116,15 @@ class LoopFmt:
116
116
  self.pending_ci = False
117
117
  self.pending_story = False
118
118
  self.spinner = Spinner()
119
- # Track the most recent usage / model seen on assistant turns so
120
- # the result event handler can emit a 'usage' event even when
121
- # result.usage is missing.
122
- self._last_usage = None
119
+ # Accumulate token usage across all assistant turns in the cycle so
120
+ # the trailing result event can emit a 'usage' event carrying the
121
+ # cumulative totals (result.usage only carries the last turn's).
122
+ self._usage_totals = {
123
+ "input_tokens": 0,
124
+ "output_tokens": 0,
125
+ "cache_creation_tokens": 0,
126
+ "cache_read_tokens": 0,
127
+ }
123
128
  self._last_model = None
124
129
 
125
130
  def _extract_cycle_num(self, text):
@@ -161,10 +166,14 @@ class LoopFmt:
161
166
 
162
167
  def _handle_assistant(self, ev):
163
168
  msg = ev.get("message", {})
164
- # Remember the latest usage / model so the trailing result event
165
- # can emit a 'usage' event even if result.usage is empty.
166
- if msg.get("usage"):
167
- self._last_usage = msg["usage"]
169
+ # Sum token usage across turns; result.usage only carries the last
170
+ # turn so accumulating here is the only way to get cumulative totals.
171
+ u = msg.get("usage") or {}
172
+ if u:
173
+ self._usage_totals["input_tokens"] += int(u.get("input_tokens") or 0)
174
+ self._usage_totals["output_tokens"] += int(u.get("output_tokens") or 0)
175
+ self._usage_totals["cache_creation_tokens"] += int(u.get("cache_creation_input_tokens") or 0)
176
+ self._usage_totals["cache_read_tokens"] += int(u.get("cache_read_input_tokens") or 0)
168
177
  if msg.get("model"):
169
178
  self._last_model = msg["model"]
170
179
  for blk in msg.get("content", []):
@@ -341,18 +350,17 @@ class LoopFmt:
341
350
  shared = os.environ.get("LOOP_SHARED_ROOT") or os.path.expanduser("~/.shared/roll")
342
351
  if not (slug and cycle):
343
352
  return
344
- # Pull usage off the result event itself if present, otherwise off
345
- # the most recent assistant turn we observed.
346
- usage = (result_ev.get("usage") or self._last_usage or {})
353
+ # Use the cumulative totals accumulated across all assistant turns;
354
+ # result.usage is per-turn (last only) so it would under-count badly.
347
355
  model = result_ev.get("model") or self._last_model or ""
348
356
  payload = {
349
- "model": model,
350
- "input_tokens": int(usage.get("input_tokens") or 0),
351
- "output_tokens": int(usage.get("output_tokens") or 0),
352
- "cache_creation_tokens": int(usage.get("cache_creation_input_tokens") or 0),
353
- "cache_read_tokens": int(usage.get("cache_read_input_tokens") or 0),
354
- "cost_reported_usd": float(cost_usd or 0),
355
- "duration_ms": int(dur_ms or 0),
357
+ "model": model,
358
+ "input_tokens": self._usage_totals["input_tokens"],
359
+ "output_tokens": self._usage_totals["output_tokens"],
360
+ "cache_creation_tokens": self._usage_totals["cache_creation_tokens"],
361
+ "cache_read_tokens": self._usage_totals["cache_read_tokens"],
362
+ "cost_reported_usd": float(cost_usd or 0),
363
+ "duration_ms": int(dur_ms or 0),
356
364
  }
357
365
  evfile = os.path.join(shared, "loop", f"events-{slug}.ndjson")
358
366
  line = json.dumps({
@@ -211,51 +211,14 @@ def render(path: str) -> None:
211
211
 
212
212
  def main() -> None:
213
213
  args = sys.argv[1:]
214
- demo = "--demo" in args
215
214
  no_color = "--no-color" in args or not sys.stdout.isatty() or os.getenv("NO_COLOR")
216
215
  rr.USE_COLOR = not no_color
217
216
 
218
217
  backlog = ".roll/backlog.md"
219
- if not demo and not os.path.isfile(backlog):
218
+ if not os.path.isfile(backlog):
220
219
  print(f"Error: {backlog} not found — run 'roll init' first", file=sys.stderr)
221
220
  sys.exit(1)
222
-
223
- if demo:
224
- _write_demo(backlog)
225
- try:
226
- render(backlog)
227
- finally:
228
- os.unlink(backlog)
229
- else:
230
- render(backlog)
231
-
232
-
233
- def _write_demo(path: str) -> None:
234
- with open(path, "w") as f:
235
- f.write("""# Project Backlog
236
-
237
- ## 🐛 Bug Fixes
238
- | ID | Description | Status |
239
- |----|-------------|--------|
240
- | FIX-042 | Fix outer runner tmux kill matching wrong session | 🔨 In Progress |
241
- | FIX-043 | Handle stale state in loop now command | 📋 Todo |
242
-
243
- ## Epic: Autonomous Evolution
244
- ### Feature: autonomous-evolution
245
- | Story | Description | Status |
246
- |-------|-------------|--------|
247
- | [US-AUTO-042](.roll/features/autonomous-evolution/autonomous-evolution.md) | Loop cost telemetry — write model and token data per cycle | 📋 Todo |
248
-
249
- ## ♻️ Refactor
250
- | ID | Description | Status |
251
- |----|-------------|--------|
252
- | REFACTOR-010 | Simplify CI test parallelism strategy | 🔒 Blocked [waiting on CI infra] |
253
-
254
- ## 💡 Ideas
255
- | ID | Description | Status |
256
- |----|-------------|--------|
257
- | IDEA-025 | Dashboard cost from list-price tokens | ⏸ Deferred [design pending] |
258
- """)
221
+ render(backlog)
259
222
 
260
223
 
261
224
  if __name__ == "__main__":
package/lib/roll-help.py CHANGED
@@ -7,7 +7,7 @@ Compact wordmark + grouped commands (AUTONOMY / PROJECT / MACHINE) + examples.
7
7
  Usage:
8
8
  python3 lib/roll-help.py # live
9
9
  python3 lib/roll-help.py --no-color
10
- python3 lib/roll-help.py --demo # same as live, no fixture needed
10
+ python3 lib/roll-help.py # static layout, no fixture needed
11
11
  """
12
12
 
13
13
  from __future__ import annotations
@@ -142,7 +142,6 @@ def render(version: str) -> None:
142
142
  # ════════════════════════════════════════════════════════════════════════════
143
143
  def main() -> None:
144
144
  ap = argparse.ArgumentParser(add_help=False)
145
- ap.add_argument("--demo", action="store_true")
146
145
  ap.add_argument("--no-color", dest="no_color", action="store_true")
147
146
  ap.add_argument("--en", action="store_true")
148
147
  ap.add_argument("--zh", action="store_true")
package/lib/roll-home.py CHANGED
@@ -10,7 +10,7 @@ Usage:
10
10
  python3 lib/roll-home.py # live data
11
11
  python3 lib/roll-home.py --no-color
12
12
  python3 lib/roll-home.py --en | --zh # collapse bilingual rows
13
- python3 lib/roll-home.py --demo # render with fixture data
13
+ ROLL_RENDER_FIXTURE=1 python3 lib/roll-home.py # render with fixture data (test only)
14
14
  """
15
15
 
16
16
  from __future__ import annotations
@@ -262,9 +262,9 @@ def _ac_completion(feature_link: str) -> Tuple[int, int]:
262
262
  return (done, total)
263
263
 
264
264
  # ════════════════════════════════════════════════════════════════════════════
265
- # Demo fixture
265
+ # Fixture data (test-only; opt in via ROLL_RENDER_FIXTURE=1)
266
266
  # ════════════════════════════════════════════════════════════════════════════
267
- def _demo_data() -> Dict[str, Any]:
267
+ def _fixture_data() -> Dict[str, Any]:
268
268
  return dict(
269
269
  project_name="myapp", version="2026.518.3",
270
270
  agent="claude", git_branch="main", git_status="✓",
@@ -455,7 +455,6 @@ def render(d: Dict[str, Any]) -> None:
455
455
  # ════════════════════════════════════════════════════════════════════════════
456
456
  def main() -> None:
457
457
  ap = argparse.ArgumentParser(add_help=False)
458
- ap.add_argument("--demo", action="store_true")
459
458
  ap.add_argument("--no-color", dest="no_color", action="store_true")
460
459
  ap.add_argument("--en", action="store_true")
461
460
  ap.add_argument("--zh", action="store_true")
@@ -464,8 +463,8 @@ def main() -> None:
464
463
  if args.no_color or os.environ.get("NO_COLOR") or not sys.stdout.isatty():
465
464
  roll_render.USE_COLOR = False
466
465
 
467
- if args.demo:
468
- d = _demo_data()
466
+ if os.environ.get("ROLL_RENDER_FIXTURE"):
467
+ d = _fixture_data()
469
468
  else:
470
469
  slug = _project_slug()
471
470
  config = _load_config()
package/lib/roll-init.py CHANGED
@@ -1,8 +1,38 @@
1
1
  #!/usr/bin/env python3
2
- """roll-init — v2 terminal view for `roll init` (US-VIEW-008)."""
2
+ """roll-init — v2 terminal view for `roll init`.
3
+
4
+ Reads a single JSON document from stdin describing real step outcomes
5
+ captured by the bash flow in `bin/roll cmd_init`. Renders the v2 UI
6
+ (banner + horizontal rule + numbered steps + NEXT block) preserving the
7
+ visual style of US-VIEW-008 but reflecting what actually happened.
8
+
9
+ Input schema (single JSON object on stdin):
10
+ {
11
+ "kind": "init", # informational; UI label uses header_label
12
+ "header_label": "INIT", # banner label (e.g. "INIT" / "REINIT")
13
+ "subtitle": "项目初始化", # banner subtitle
14
+ "project_path": "/path/to/project", # right-aligned banner text
15
+ "steps": [
16
+ {"num": 1, "label": "Detect project type", "status": "ok"},
17
+ {"num": 2, "label": "Create AGENTS.md", "status": "ok",
18
+ "files": [["+", "AGENTS.md"]]},
19
+ {"num": 3, "label": "...", "status": "skip", "note": "already exists"},
20
+ {"num": 4, "label": "...", "status": "fail", "error": "permission denied"}
21
+ ],
22
+ "footer": {"status": "ok", "label": "Project ready"},
23
+ "next": [["Edit .roll/backlog.md", "open the backlog and add your first US"]]
24
+ }
25
+
26
+ `status` values: ok | skip | fail.
27
+ `files` ops: "+" created "~" merged "·" unchanged "✗" failed.
28
+
29
+ If stdin is empty or invalid JSON, exit 1 with a short message — the
30
+ renderer is no longer runnable standalone, it always renders real data.
31
+ """
3
32
  from __future__ import annotations
4
33
 
5
34
  import argparse
35
+ import json
6
36
  import os
7
37
  import sys
8
38
 
@@ -12,32 +42,6 @@ if _LIB_DIR not in sys.path:
12
42
  import roll_render
13
43
  from roll_render import c, row, COLS
14
44
 
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
45
 
42
46
  def _divider(char: str = "─") -> None:
43
47
  print(c("dim", char * min(COLS, 80)))
@@ -48,53 +52,104 @@ def _op_marker(op: str) -> str:
48
52
  return c("green", "+", bold=True)
49
53
  if op == "~":
50
54
  return c("amber", "~", bold=True)
55
+ if op == "·":
56
+ return c("dim", "·")
57
+ if op == "✗":
58
+ return c("red", "✗", bold=True)
51
59
  return c("dim", op)
52
60
 
53
61
 
54
- def render_demo(project_path: str = "~/myproject") -> None:
55
- left = " " + c("blue", "INIT", bold=True) + c("dim", " · ") + c("dim", "项目初始化")
62
+ def _step_icon(status: str) -> str:
63
+ if status == "ok":
64
+ return c("green", "✓")
65
+ if status == "skip":
66
+ return c("amber", "↷")
67
+ if status == "fail":
68
+ return c("red", "✗", bold=True)
69
+ return c("dim", "·")
70
+
71
+
72
+ def _file_color(op: str) -> str:
73
+ if op == "+":
74
+ return "green"
75
+ if op == "~":
76
+ return "amber"
77
+ if op == "✗":
78
+ return "red"
79
+ return "dim"
80
+
81
+
82
+ def render(payload: dict) -> None:
83
+ header_label = payload.get("header_label", "INIT")
84
+ subtitle = payload.get("subtitle", "项目初始化")
85
+ project_path = payload.get("project_path", "")
86
+
87
+ left = " " + c("blue", header_label, bold=True) + c("dim", " · ") + c("dim", subtitle)
56
88
  right = c("dim", project_path) + " "
57
89
  print(row(left, right))
58
90
  _divider()
59
91
  print()
60
92
 
61
- for i, (label, files) in enumerate(_DEMO_STEPS, start=1):
62
- num = c("dim", f" {i}.")
63
- icon = c("green", "")
93
+ for step in payload.get("steps", []):
94
+ num = c("dim", f" {step.get('num', 0)}.")
95
+ icon = _step_icon(step.get("status", "ok"))
96
+ label = step.get("label", "")
64
97
  print(f"{num} {icon} {label}")
65
- for op, fname in files:
98
+ for entry in step.get("files", []) or []:
99
+ op, fname = entry[0], entry[1]
66
100
  mark = _op_marker(op)
67
- color = "green" if op == "+" else "amber"
101
+ color = _file_color(op)
68
102
  print(" " + mark + " " + c(color, fname))
103
+ note = step.get("note") or step.get("error")
104
+ if note:
105
+ tone = "red" if step.get("status") == "fail" else "dim"
106
+ print(" " + c(tone, str(note)))
69
107
 
70
108
  print()
71
109
  _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))
110
+
111
+ footer = payload.get("footer") or {}
112
+ f_status = footer.get("status", "ok")
113
+ f_label = footer.get("label", "Done")
114
+ icon_color = "green" if f_status == "ok" else "red"
115
+ icon = "✓" if f_status == "ok" else "✗"
116
+ print(" " + c(icon_color, icon) + " " + c(icon_color, f_label, bold=True))
117
+
118
+ next_items = payload.get("next") or []
119
+ if next_items:
120
+ print()
121
+ print(" " + c("pink", "NEXT", bold=True) + c("dim", " · 下一步"))
122
+ for i, entry in enumerate(next_items, start=1):
123
+ label = entry[0]
124
+ hint = entry[1] if len(entry) > 1 else ""
125
+ num = c("dim", f" {i}.")
126
+ print(f"{num} {c('fg', label, bold=True)}")
127
+ if hint:
128
+ print(" " + c("dim", hint))
79
129
  _divider("═")
80
130
 
81
131
 
82
- # ════════════════════════════════════════════════════════════════════════════
83
- # Entry point
84
- # ════════════════════════════════════════════════════════════════════════════
132
+ def _read_payload() -> dict:
133
+ raw = sys.stdin.read()
134
+ if not raw.strip():
135
+ sys.stderr.write("roll-init.py: expected JSON on stdin\n")
136
+ sys.exit(1)
137
+ try:
138
+ return json.loads(raw)
139
+ except json.JSONDecodeError as exc:
140
+ sys.stderr.write(f"roll-init.py: invalid JSON on stdin: {exc}\n")
141
+ sys.exit(1)
142
+
85
143
 
86
144
  def main() -> None:
87
145
  ap = argparse.ArgumentParser(add_help=False)
88
- ap.add_argument("--demo", action="store_true")
89
146
  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
147
  args, _ = ap.parse_known_args()
93
148
 
94
149
  if args.no_color or os.environ.get("NO_COLOR") or not sys.stdout.isatty():
95
150
  roll_render.USE_COLOR = False
96
151
 
97
- render_demo(project_path=os.getcwd())
152
+ render(_read_payload())
98
153
 
99
154
 
100
155
  if __name__ == "__main__":
@@ -17,7 +17,7 @@ Usage:
17
17
  python3 lib/roll-loop-status.py --days 7
18
18
  python3 lib/roll-loop-status.py --no-color
19
19
  python3 lib/roll-loop-status.py --en | --zh # collapse bilingual rows
20
- python3 lib/roll-loop-status.py --demo # render with fixture data
20
+ ROLL_RENDER_FIXTURE=1 python3 lib/roll-loop-status.py # render with fixture data (test only)
21
21
 
22
22
  Wire it in bin/roll under `loop status` (replace _loop_status with a call to
23
23
  this script).
@@ -206,9 +206,19 @@ def aggregate(events: List[Dict[str, Any]], cron: List[Dict[str, Any]]) -> List[
206
206
  elif stage == "usage":
207
207
  # US-LOOP-004: loop-fmt emits this with full token / cost data.
208
208
  # Detail is a dict (not the legacy string form).
209
+ # US-VIEW-010: token counts are per-turn deltas — sum across events
210
+ # so list-price cost computed from totals matches actual API usage.
211
+ # Non-additive fields (model, cost_reported_usd, duration_ms) take
212
+ # the last value seen.
209
213
  d = e.get("detail") or {}
210
214
  if isinstance(d, dict):
211
- cy["usage_event"] = d
215
+ prev = cy.get("usage_event") or {}
216
+ merged = dict(prev)
217
+ merged.update(d)
218
+ for k in ("input_tokens", "output_tokens",
219
+ "cache_creation_tokens", "cache_read_tokens"):
220
+ merged[k] = int(prev.get(k) or 0) + int(d.get(k) or 0)
221
+ cy["usage_event"] = merged
212
222
  elif stage in ("test", "build") and e.get("outcome") == "fail":
213
223
  cy["fail_detail"] = detail or stage
214
224
 
@@ -315,8 +325,7 @@ def backfill_usage_from_claude_sessions(cycles: List[Dict[str, Any]], slug: str)
315
325
  for cy in cycles:
316
326
  # Path 1: usage event written by loop-fmt at result time.
317
327
  ue = cy.get("usage_event")
318
- if isinstance(ue, dict) and (ue.get("input_tokens") or ue.get("output_tokens")
319
- or ue.get("cost_reported_usd")):
328
+ if isinstance(ue, dict) and (ue.get("input_tokens") or ue.get("output_tokens")):
320
329
  cy["tokens"] = mp.total_tokens(
321
330
  input_tokens=ue.get("input_tokens", 0),
322
331
  output_tokens=ue.get("output_tokens", 0),
@@ -324,20 +333,18 @@ def backfill_usage_from_claude_sessions(cycles: List[Dict[str, Any]], slug: str)
324
333
  cache_read_tokens=ue.get("cache_read_tokens", 0),
325
334
  )
326
335
  cy["model"] = ue.get("model")
327
- # FIX-060: cost_reported_usd is the authoritative cumulative session
328
- # cost written by loop-fmt; use it directly instead of recomputing
329
- # from the last individual API-call token counts (which would only
330
- # reflect one small call and badly undercount the true cycle cost).
331
- if ue.get("cost_reported_usd"):
332
- cy["cost_list"] = float(ue["cost_reported_usd"])
333
- else:
334
- cy["cost_list"] = mp.compute_list_cost(
335
- ue.get("model"),
336
- input_tokens=ue.get("input_tokens", 0),
337
- output_tokens=ue.get("output_tokens", 0),
338
- cache_creation_tokens=ue.get("cache_creation_tokens", 0),
339
- cache_read_tokens=ue.get("cache_read_tokens", 0),
340
- )
336
+ # US-VIEW-010: aggregate now sums per-turn usage tokens, so the
337
+ # totals in `ue` reflect the whole cycle. Always compute cost at
338
+ # list price for cross-account comparability supersedes FIX-060
339
+ # which preferred cost_reported_usd as a workaround for
340
+ # last-event-only token counts (that root cause is now gone).
341
+ cy["cost_list"] = mp.compute_list_cost(
342
+ ue.get("model"),
343
+ input_tokens=ue.get("input_tokens", 0),
344
+ output_tokens=ue.get("output_tokens", 0),
345
+ cache_creation_tokens=ue.get("cache_creation_tokens", 0),
346
+ cache_read_tokens=ue.get("cache_read_tokens", 0),
347
+ )
341
348
  if ue.get("duration_ms") and not cy.get("duration_s"):
342
349
  cy["duration_s"] = int(ue["duration_ms"] / 1000)
343
350
  continue
@@ -733,9 +740,9 @@ def _next_cron_hint(state: Dict[str, str], zh: bool = False) -> str:
733
740
  return nxt.strftime("%H:%M") + f" · in {mins}m {secs:02d}s"
734
741
 
735
742
  # ════════════════════════════════════════════════════════════════════════════
736
- # Demo fixture lets you preview the output without real data
743
+ # Fixture data (test-only; opt in via ROLL_RENDER_FIXTURE=1)
737
744
  # ════════════════════════════════════════════════════════════════════════════
738
- def _demo_data():
745
+ def _fixture_data():
739
746
  now = datetime.now(timezone.utc)
740
747
  events, cron = [], []
741
748
  cycle_id = 0
@@ -788,7 +795,6 @@ def main(argv=None):
788
795
  p.add_argument("--no-color", action="store_true", help="strip ANSI (also honors NO_COLOR=1)")
789
796
  p.add_argument("--en", action="store_true", help="EN rows only")
790
797
  p.add_argument("--zh", action="store_true", help="ZH rows only")
791
- p.add_argument("--demo", action="store_true", help="render with fixture data")
792
798
  args = p.parse_args(argv)
793
799
 
794
800
  roll_render.USE_COLOR = (not args.no_color
@@ -797,10 +803,12 @@ def main(argv=None):
797
803
 
798
804
  lang = "en" if args.en else ("zh" if args.zh else "both")
799
805
 
800
- if args.demo:
801
- events, cron, state, backlog = _demo_data()
806
+ use_fixture = bool(os.environ.get("ROLL_RENDER_FIXTURE"))
807
+ if use_fixture:
808
+ events, cron, state, backlog = _fixture_data()
802
809
  runs = {}
803
810
  git_merges = {}
811
+ slug = None
804
812
  else:
805
813
  slug = project_slug()
806
814
  events = load_events(slug, args.days)
@@ -812,7 +820,7 @@ def main(argv=None):
812
820
 
813
821
  render(events, cron, state, backlog, days=args.days, lang=lang,
814
822
  runs=runs, git_merges=git_merges,
815
- claude_slug=None if args.demo else slug)
823
+ claude_slug=slug)
816
824
 
817
825
  if __name__ == "__main__":
818
826
  try:
package/lib/roll-peer.py CHANGED
@@ -50,10 +50,11 @@ def _agent_c(name: str) -> str:
50
50
 
51
51
 
52
52
  # ════════════════════════════════════════════════════════════════════════════
53
- # Demo data — illustrative cross-agent review: claude proposes, codex reviews
53
+ # Fixture data (test-only; opt in via ROLL_RENDER_FIXTURE=1)
54
+ # Illustrative cross-agent review: claude proposes, codex reviews
54
55
  # ════════════════════════════════════════════════════════════════════════════
55
56
 
56
- _DEMO_SUBJECT = {
57
+ _FIXTURE_SUBJECT = {
57
58
  "story": "US-AUTH-014",
58
59
  "title": "Session refresh fallback when refresh-token API 5xx",
59
60
  "pr": "#412",
@@ -63,7 +64,7 @@ _DEMO_SUBJECT = {
63
64
  "reviewer": "codex",
64
65
  }
65
66
 
66
- _DEMO_ROUNDS = [
67
+ _FIXTURE_ROUNDS = [
67
68
  {
68
69
  "n": 1,
69
70
  "hint": "first pass — proposer ships, reviewer probes",
@@ -93,13 +94,13 @@ _DEMO_ROUNDS = [
93
94
  },
94
95
  ]
95
96
 
96
- _DEMO_VERDICT = {
97
+ _FIXTURE_VERDICT = {
97
98
  "outcome": "approved",
98
99
  "reason": "2 rounds · 5 turns · all blocks resolved",
99
100
  }
100
101
 
101
- _DEMO_ARTIFACT = "~/.roll/.peer-state/logs/20260519_213700_claude_codex.md"
102
- _DEMO_NEXT = [
102
+ _FIXTURE_ARTIFACT = "~/.roll/.peer-state/logs/20260519_213700_claude_codex.md"
103
+ _FIXTURE_NEXT = [
103
104
  ("Continue execution", "claude resumes work on US-AUTH-014"),
104
105
  ("Inspect log", "open the artifact above to replay the transcript"),
105
106
  ]
@@ -205,19 +206,19 @@ def _footer(artifact: str, next_steps: list) -> None:
205
206
  # Top-level render
206
207
  # ════════════════════════════════════════════════════════════════════════════
207
208
 
208
- def render_demo() -> None:
209
- _eyebrow(_DEMO_SUBJECT["trigger"])
209
+ def render_fixture() -> None:
210
+ _eyebrow(_FIXTURE_SUBJECT["trigger"])
210
211
  _divider()
211
212
  print()
212
- _subject(_DEMO_SUBJECT)
213
+ _subject(_FIXTURE_SUBJECT)
213
214
  print()
214
- _pair_overview(_DEMO_SUBJECT)
215
- for rd in _DEMO_ROUNDS:
215
+ _pair_overview(_FIXTURE_SUBJECT)
216
+ for rd in _FIXTURE_ROUNDS:
216
217
  _round_header(rd["n"], rd["hint"])
217
218
  for agent, weight, body in rd["turns"]:
218
219
  _turn(agent, weight, body)
219
- _verdict(_DEMO_VERDICT)
220
- _footer(_DEMO_ARTIFACT, _DEMO_NEXT)
220
+ _verdict(_FIXTURE_VERDICT)
221
+ _footer(_FIXTURE_ARTIFACT, _FIXTURE_NEXT)
221
222
 
222
223
 
223
224
  # ════════════════════════════════════════════════════════════════════════════
@@ -226,7 +227,6 @@ def render_demo() -> None:
226
227
 
227
228
  def main() -> None:
228
229
  ap = argparse.ArgumentParser(add_help=False)
229
- ap.add_argument("--demo", action="store_true")
230
230
  ap.add_argument("--no-color", dest="no_color", action="store_true")
231
231
  ap.add_argument("--en", action="store_true")
232
232
  ap.add_argument("--zh", action="store_true")
@@ -235,7 +235,17 @@ def main() -> None:
235
235
  if args.no_color or os.environ.get("NO_COLOR") or not sys.stdout.isatty():
236
236
  roll_render.USE_COLOR = False
237
237
 
238
- render_demo()
238
+ # FIX-076: this standalone entrypoint only knows how to render the fixture
239
+ # transcript (for UI tests). Real peer review is orchestrated by bin/roll
240
+ # and never invokes this main(). Require an explicit opt-in so a stray
241
+ # `python3 lib/roll-peer.py` invocation can't masquerade as live output.
242
+ if not os.environ.get("ROLL_RENDER_FIXTURE"):
243
+ print("Error: lib/roll-peer.py only renders fixture data; "
244
+ "set ROLL_RENDER_FIXTURE=1 to use it (test-only).",
245
+ file=sys.stderr)
246
+ sys.exit(2)
247
+
248
+ render_fixture()
239
249
 
240
250
 
241
251
  if __name__ == "__main__":
package/lib/roll-setup.py CHANGED
@@ -1,61 +1,101 @@
1
1
  #!/usr/bin/env python3
2
- """roll-setup — v2 terminal view for `roll setup`."""
2
+ """roll-setup — v2 terminal view for `roll setup`.
3
+
4
+ Reads a single JSON document from stdin describing the real outcomes of
5
+ `bin/roll cmd_setup`'s step sequence (detect platform / install skills /
6
+ sync conventions / etc). Renders the v2 UI preserving the visual style
7
+ of US-VIEW-007 while reflecting actual results.
8
+
9
+ Input schema matches `lib/roll-init.py` (see that file for details).
10
+ """
3
11
  from __future__ import annotations
4
12
 
13
+ import argparse
14
+ import json
5
15
  import os
6
16
  import sys
7
17
 
8
18
  _LIB_DIR = os.path.dirname(os.path.realpath(__file__))
9
19
  if _LIB_DIR not in sys.path:
10
20
  sys.path.insert(0, _LIB_DIR)
21
+ import roll_render
11
22
  from roll_render import c, row, COLS
12
23
 
13
- # ════════════════════════════════════════════════════════════════════════════
14
- # Demo data
15
- # ════════════════════════════════════════════════════════════════════════════
16
-
17
- _DEMO_STEPS = [
18
- "Detect platform & shell",
19
- "Fetch latest roll version",
20
- "Install skills to ~/.claude",
21
- "Symlink bin/roll to PATH",
22
- "Check for config drift",
23
- "Apply convention templates",
24
- ]
25
-
26
- # ════════════════════════════════════════════════════════════════════════════
27
- # Rendering
28
- # ════════════════════════════════════════════════════════════════════════════
29
24
 
30
25
  def _divider(char: str = "─") -> None:
31
26
  print(c("dim", char * min(COLS, 80)))
32
27
 
33
28
 
34
- def render_demo() -> None:
35
- left = " " + c("blue", "SETUP", bold=True) + c("dim", " · ") + c("dim", "初始化")
36
- right = c("dim", "--demo")
37
- print(row(left, " " + right))
29
+ def _step_icon(status: str) -> str:
30
+ if status == "ok":
31
+ return c("green", "")
32
+ if status == "skip":
33
+ return c("amber", "↷")
34
+ if status == "forced":
35
+ return c("blue", "~")
36
+ if status == "fail":
37
+ return c("red", "✗", bold=True)
38
+ return c("dim", "·")
39
+
40
+
41
+ def render(payload: dict) -> None:
42
+ header_label = payload.get("header_label", "SETUP")
43
+ subtitle = payload.get("subtitle", "初始化")
44
+ right_text = payload.get("project_path") or payload.get("right", "")
45
+
46
+ left = " " + c("blue", header_label, bold=True) + c("dim", " · ") + c("dim", subtitle)
47
+ right = c("dim", right_text) + " " if right_text else ""
48
+ print(row(left, right))
38
49
  _divider()
39
50
  print()
40
51
 
41
- for i, label in enumerate(_DEMO_STEPS, start=1):
42
- num = c("dim", f" {i}.")
43
- icon = c("green", "")
52
+ for step in payload.get("steps", []):
53
+ num = c("dim", f" {step.get('num', 0)}.")
54
+ icon = _step_icon(step.get("status", "ok"))
55
+ label = step.get("label", "")
44
56
  print(f"{num} {icon} {label}")
57
+ note = step.get("note") or step.get("error")
58
+ if note:
59
+ tone = "red" if step.get("status") == "fail" else "dim"
60
+ print(" " + c(tone, str(note)))
45
61
 
46
62
  print()
47
63
  _divider()
48
- msg = c("green", "Setup complete")
49
- print(f" {msg} — run {c('fg', 'roll init', bold=True)} inside a project to begin")
64
+
65
+ footer = payload.get("footer") or {}
66
+ f_status = footer.get("status", "ok")
67
+ f_label = footer.get("label", "Setup complete")
68
+ icon_color = "green" if f_status == "ok" else "red"
69
+ msg = c(icon_color, f_label)
70
+ next_hint = footer.get("hint")
71
+ if next_hint:
72
+ print(f" {msg} — {next_hint}")
73
+ else:
74
+ print(f" {msg}")
50
75
  _divider("═")
51
76
 
52
77
 
53
- # ════════════════════════════════════════════════════════════════════════════
54
- # Entry point
55
- # ════════════════════════════════════════════════════════════════════════════
78
+ def _read_payload() -> dict:
79
+ raw = sys.stdin.read()
80
+ if not raw.strip():
81
+ sys.stderr.write("roll-setup.py: expected JSON on stdin\n")
82
+ sys.exit(1)
83
+ try:
84
+ return json.loads(raw)
85
+ except json.JSONDecodeError as exc:
86
+ sys.stderr.write(f"roll-setup.py: invalid JSON on stdin: {exc}\n")
87
+ sys.exit(1)
88
+
56
89
 
57
90
  def main() -> None:
58
- render_demo()
91
+ ap = argparse.ArgumentParser(add_help=False)
92
+ ap.add_argument("--no-color", dest="no_color", action="store_true")
93
+ args, _ = ap.parse_known_args()
94
+
95
+ if args.no_color or os.environ.get("NO_COLOR") or not sys.stdout.isatty():
96
+ roll_render.USE_COLOR = False
97
+
98
+ render(_read_payload())
59
99
 
60
100
 
61
101
  if __name__ == "__main__":
@@ -8,7 +8,7 @@ project templates, and this-project metrics.
8
8
  Usage:
9
9
  python3 lib/roll-status.py # live data
10
10
  python3 lib/roll-status.py --no-color
11
- python3 lib/roll-status.py --demo # render with fixture data
11
+ ROLL_RENDER_FIXTURE=1 python3 lib/roll-status.py # render with fixture data (test only)
12
12
  """
13
13
 
14
14
  from __future__ import annotations
@@ -149,9 +149,9 @@ def _launchd_state(service: str, slug: str) -> str:
149
149
  return "installed-off"
150
150
 
151
151
  # ════════════════════════════════════════════════════════════════════════════
152
- # Demo fixture
152
+ # Fixture data (test-only; opt in via ROLL_RENDER_FIXTURE=1)
153
153
  # ════════════════════════════════════════════════════════════════════════════
154
- def _demo_data() -> Dict[str, Any]:
154
+ def _fixture_data() -> Dict[str, Any]:
155
155
  return dict(
156
156
  conventions=[
157
157
  ("AGENTS.md", True), ("CLAUDE.md", True), ("GEMINI.md", False),
@@ -347,7 +347,6 @@ def _live_data() -> Dict[str, Any]:
347
347
  # ════════════════════════════════════════════════════════════════════════════
348
348
  def main() -> None:
349
349
  ap = argparse.ArgumentParser(add_help=False)
350
- ap.add_argument("--demo", action="store_true")
351
350
  ap.add_argument("--no-color", dest="no_color", action="store_true")
352
351
  ap.add_argument("--en", action="store_true")
353
352
  ap.add_argument("--zh", action="store_true")
@@ -356,7 +355,7 @@ def main() -> None:
356
355
  if args.no_color or os.environ.get("NO_COLOR") or not sys.stdout.isatty():
357
356
  roll_render.USE_COLOR = False
358
357
 
359
- d = _demo_data() if args.demo else _live_data()
358
+ d = _fixture_data() if os.environ.get("ROLL_RENDER_FIXTURE") else _live_data()
360
359
 
361
360
  _render_health(d)
362
361
  _render_global_conventions(d["conventions"])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanyao/roll",
3
- "version": "2026.520.1",
3
+ "version": "2026.521.1",
4
4
  "description": "Roll — Roll out features with AI agents",
5
5
  "scripts": {
6
6
  "test": "bash tests/run.sh"
@@ -105,11 +105,67 @@ Rules:
105
105
  - Do not copy README text. List file pointers only.
106
106
  - Never include secrets, tokens, credentials, or `.env` content.
107
107
  - Even if logically a continuation, treat as round=1 if the peer has **no prior context**.
108
+ - **Do NOT** prefill the peer with your own root-cause analysis, proposed fix, or leading questions — see the *Independent Judgment Rule* below. The handoff card is for context, not conclusions.
108
109
 
109
110
  ### Anti-Hallucination Rule
110
111
 
111
112
  When mentioning specific paths, function names, commands, line numbers, or tool results, **must cite the source** ("I read X at line Y"). If unverified, state "unverified" explicitly.
112
113
 
114
+ ### Independent Judgment Rule (round=1)
115
+
116
+ The whole point of peer review is to surface a **second, independent** read. If
117
+ the reviewer's own root-cause analysis, fix diff, and leading questions are sent
118
+ to the peer up front, the peer can only AGREE inside the reviewer's frame — and
119
+ that AGREE carries no signal. The reviewer **must complete their own analysis
120
+ before opening round=1**; skipping that step turns peer review into a search for
121
+ endorsement.
122
+
123
+ Round=1 message **must NOT include**:
124
+
125
+ - The reviewer's own root-cause analysis ("the bug is in function X at line Y because…").
126
+ - The reviewer's own proposed fix, patch, or diff.
127
+ - Leading questions of the form "do you agree with my conclusion on X?" / "is the change I made on Y safe?" — these lock the peer into the reviewer's framing.
128
+ - Specific line numbers, function names, or branch points the reviewer has already identified as relevant — let the peer locate them.
129
+
130
+ Round=1 message **should include**:
131
+
132
+ - The Project Handoff Card (above).
133
+ - Symptoms exactly as observed: the user's reported error, terminal output verbatim, the precise commands that triggered it.
134
+ - Necessary external context: the goal of the work, the date / version under test, anything the peer cannot infer from the repo alone.
135
+ - Key file pointers as **entry points** (paths only — let the peer choose what to read and how deep).
136
+ - An open invitation: "independently identify the root cause, propose a fix, and call out any test gaps."
137
+
138
+ After receiving the peer's round=1 reply, the reviewer **compares** their own
139
+ conclusion to the peer's and routes the next action:
140
+
141
+ | Reviewer's own conclusion vs. peer's conclusion | Next action |
142
+ |---|---|
143
+ | Same root cause + same fix direction | High confidence — AGREE and proceed to execution |
144
+ | Same root cause, different fix direction | REFINE — open round=2 to reconcile the fix |
145
+ | Different root cause | OBJECT — open round=2; at least one of the two analyses is wrong |
146
+ | Peer asks for more context | REFINE — supply the missing context, then re-evaluate |
147
+
148
+ #### Example (bad — endorsement-seeking)
149
+
150
+ ```
151
+ Bug is in `cmd_init` at line 932 — the v2 demo renderer fires unconditionally.
152
+ My fix: gate it behind `--demo`. Q1: is this over-killed? Q2: should I
153
+ refactor the renderer instead? Q3: are the tests strong enough?
154
+ ```
155
+
156
+ The peer can only AGREE or quibble inside the reviewer's framing.
157
+
158
+ #### Example (good — independent analysis)
159
+
160
+ ```
161
+ Symptoms: user ran `roll init` on /path/X and saw [verbatim terminal output A];
162
+ then ran `roll backlog` and saw [verbatim terminal output B]. Project background:
163
+ [project shape]. Entry points: `bin/roll`, `lib/roll-init.py`, `tests/`.
164
+ Independently identify the root cause and propose a fix.
165
+ ```
166
+
167
+ The peer reads, locates, and proposes on its own. The reviewer then compares.
168
+
113
169
  ## State Machine
114
170
 
115
171
  ### Per Negotiation (Single Task)
@@ -208,8 +264,10 @@ When peer review is manually triggered by a human (via `/peer`, "叫上 peer", e
208
264
 
209
265
  **Rules:**
210
266
  - Peer CLI calls must be **synchronous** (do NOT use background/async execution).
267
+ - The outgoing round=1 message must follow the *Independent Judgment Rule* above — no root-cause analysis, no fix diff, no leading questions.
211
268
  - Show the outgoing message **before** calling the peer, so the user sees what's being asked.
212
269
  - Relay the peer's response **verbatim** before adding your own analysis.
270
+ - After the peer's reply, the reviewer's own analysis block must explicitly state whether the peer's root cause and fix direction match the reviewer's own (independent) conclusion — that comparison is what determines the next round's action.
213
271
  - If a peer call fails or times out, report it immediately inline and either retry or ESCALATE.
214
272
  - Negotiation log is still written to `~/.shared/roll/peer/logs/` as usual.
215
273