@seanyao/roll 2026.517.1 → 2026.517.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,8 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## v2026.517.3
4
+ - **New**: dream 现在察觉功能目录过期 — 落后时不再悄悄无人知晓 `[dream]`
5
+ - **New**: `roll loop events` — 查看 loop 每轮的详细事件流,任务选择、评审结果、CI 状态、合并全都有迹可查 `[loop]`
6
+ - **Improved**: loop 实时输出不再一眼看不出重点 — TCR 纪律、评审决议、CI gate 突出显示,工具日志不再喧宾夺主 `[loop]`
7
+
8
+ ## v2026.517.2
9
+ - **Fixed**: `roll loop runs` 现在能看到刚跑完的循环记录(不再无历史) `[loop]`
10
+ - **Fixed**: `roll dream`、`roll brief`、`roll loop` 的定时任务不再被 Claude 升级后的弹窗拦住,悄悄失效
11
+
3
12
  ## v2026.517.1
4
13
 
5
14
  - **New**: loop 自动修复 story 引入的 CI 红 — 不再每次 CI 红都停下等人,修不好才写 ALERT `[loop]`
15
+ - **New**: Roll 官网上线 — 装、用、原理一站讲清楚
16
+ - **Fixed**: mac 休眠不再打断 loop cycle — 全程保持唤醒 `[loop]`
17
+ - **Fixed**: agent 假死时 loop 自动接管,不再无限挂起 `[loop]`
18
+ - **Fixed**: PR / 合并失败时 — loop 仍能把代码备份到独立分支不丢失 `[loop]`
19
+ - **Fixed**: loop 启动时自动恢复上一轮中断的工作,意外中断的代码不再失踪 `[loop]`
20
+ - **Fixed**: `roll loop now` 现在卡住状态也会先自愈再启动 `[loop]`
21
+ - **Fixed**: 自治 loop 不再被权限弹窗卡住 `[loop]`
22
+ - **Fixed**: `roll peer` 多轮 review 不再中途断线 `[peer]`
23
+ - **Fixed**: `roll loop runs` 现在跨子目录都能显示历史 `[loop]`
24
+ - **Fixed**: loop 空跑也会清理 worktree,不再随时间堆积 `[loop]`
6
25
 
7
26
  ## v2026.515.1
8
27
 
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.517.1"
7
+ VERSION="2026.517.3"
8
8
  ROLL_HOME="${ROLL_HOME:-${HOME}/.roll}"
9
9
  ROLL_CONFIG="${ROLL_HOME}/config.yaml"
10
10
  ROLL_GLOBAL="${ROLL_HOME}/conventions/global"
@@ -1898,6 +1898,20 @@ _agent_cmd_str() {
1898
1898
  printf '%s' "$out"
1899
1899
  }
1900
1900
 
1901
+ # Splice --dangerously-skip-permissions into _AGENT_ARGV for claude. Used by
1902
+ # trusted, human-triggered, or autonomous flows that should not be blocked by
1903
+ # Claude Code's pre-write "approve diff" UX (which silently never gets
1904
+ # approved in `claude -p` pipe mode). No-op for non-claude agents and for
1905
+ # already-bypassed argvs.
1906
+ _agent_bypass_claude_perms() {
1907
+ [[ "${_AGENT_ARGV[0]}" == "claude" ]] || return 0
1908
+ local arg
1909
+ for arg in "${_AGENT_ARGV[@]}"; do
1910
+ [[ "$arg" == "--dangerously-skip-permissions" ]] && return 0
1911
+ done
1912
+ _AGENT_ARGV=("${_AGENT_ARGV[@]:0:2}" --dangerously-skip-permissions "${_AGENT_ARGV[@]:2}")
1913
+ }
1914
+
1901
1915
  _agent_run_skill() {
1902
1916
  local skill="$1"
1903
1917
  local agent; agent=$(_project_agent)
@@ -2059,7 +2073,7 @@ _LAUNCHD_DIR="${HOME}/Library/LaunchAgents"
2059
2073
  # Returns a filesystem-safe slug combining the project basename and a 6-char
2060
2074
  # hash of the full path, ensuring uniqueness across sibling dirs with same name.
2061
2075
  _project_slug() {
2062
- local path="$1"
2076
+ local path="${1:-$(pwd -P 2>/dev/null || pwd)}"
2063
2077
  # FIX-034: when inside a git worktree, git-common-dir returns the main tree's
2064
2078
  # absolute .git path; resolve to the main tree so worktree and main-tree runs
2065
2079
  # produce the same slug.
@@ -2101,6 +2115,55 @@ _loop_derive_minute() {
2101
2115
  echo $(( (hash_dec + offset) % 55 + 1 ))
2102
2116
  }
2103
2117
 
2118
+ # US-LOOP-001: structured event emission for cycle observability.
2119
+ # Writes a tab-separated line to stdout (for tmux/attach display) and appends
2120
+ # a JSON line to the per-project NDJSON event file under _SHARED_ROOT/loop/.
2121
+ # Args: <stage> <label> <detail> <outcome>
2122
+ _loop_event() {
2123
+ local stage="$1" label="$2" detail="$3" outcome="$4"
2124
+ local ts slug evfile json
2125
+ ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
2126
+ slug=$(_project_slug 2>/dev/null || basename "$PWD")
2127
+ evfile="${_SHARED_ROOT:-$HOME/.shared/roll}/loop/events-${slug}.ndjson"
2128
+ mkdir -p "$(dirname "$evfile")"
2129
+
2130
+ # stdout: tab-separated for tmux display
2131
+ printf '%s\t%s\t%s\t%s\t%s\n' "$ts" "$stage" "$label" "$detail" "$outcome"
2132
+
2133
+ # JSON line appended to NDJSON file; serialized with flock (Linux) or
2134
+ # lockf (macOS/BSD) — fall back to unguarded append when neither is available.
2135
+ json=$(printf '{"ts":"%s","stage":"%s","label":"%s","detail":"%s","outcome":"%s"}\n' \
2136
+ "$ts" "$stage" "$label" "$detail" "$outcome")
2137
+ if command -v flock >/dev/null 2>&1; then
2138
+ (
2139
+ flock -x 9
2140
+ printf '%s\n' "$json" >> "$evfile"
2141
+ ) 9>>"${evfile}.lock"
2142
+ elif command -v lockf >/dev/null 2>&1; then
2143
+ lockf -s "${evfile}.lock" sh -c "printf '%s\n' $(printf '%q' "$json") >> $(printf '%q' "$evfile")"
2144
+ else
2145
+ printf '%s\n' "$json" >> "$evfile"
2146
+ fi
2147
+
2148
+ # File rotation: if >10MB, rotate keeping last 5
2149
+ _loop_event_rotate "$evfile"
2150
+ }
2151
+
2152
+ _loop_event_rotate() {
2153
+ local f="$1"
2154
+ local size
2155
+ size=$(stat -f%z "$f" 2>/dev/null || stat -c%s "$f" 2>/dev/null || echo 0)
2156
+ if [ "$size" -gt 10485760 ]; then
2157
+ # rotate: .4→remove, .3→.4, .2→.3, .1→.2, current→.1
2158
+ rm -f "${f}.4"
2159
+ for i in 3 2 1; do
2160
+ [ -f "${f}.$i" ] && mv "${f}.$i" "${f}.$((i+1))"
2161
+ done
2162
+ mv "$f" "${f}.1"
2163
+ touch "$f"
2164
+ fi
2165
+ }
2166
+
2104
2167
  _launchd_label() {
2105
2168
  local service="$1" project_path="$2"
2106
2169
  printf 'com.roll.%s.%s' "$service" "$(_project_slug "$project_path")"
@@ -2223,6 +2286,7 @@ set +e
2223
2286
  # On any failure (no remote, no main, etc.) fall back to running in the
2224
2287
  # project's main tree (degraded — no isolation, like pre-037 behavior).
2225
2288
  CYCLE_ID="\$(date -u +%Y%m%d-%H%M%S)-\$\$"
2289
+ CYCLE_START=\$(date -u +%s)
2226
2290
  WT="\$(_worktree_path "${slug}" "cycle-\${CYCLE_ID}")"
2227
2291
  BRANCH="loop/cycle-\${CYCLE_ID}"
2228
2292
  _USE_WORKTREE=0
@@ -2264,6 +2328,7 @@ if _worktree_fetch_origin main \\
2264
2328
  _USE_WORKTREE=1
2265
2329
  _worktree_submodule_init "\$WT" 2>/dev/null || true
2266
2330
  echo "[loop] cycle \${CYCLE_ID}: worktree \$WT on \$BRANCH"
2331
+ _loop_event cycle_start "\${CYCLE_ID}" "" "" || true
2267
2332
  else
2268
2333
  # P3 fix: skip the cycle entirely when worktree isolation fails.
2269
2334
  # --dangerously-skip-permissions is only safe paired with worktree isolation;
@@ -2288,6 +2353,25 @@ for _attempt in 1 2 3; do
2288
2353
  fi
2289
2354
  done
2290
2355
 
2356
+ # FIX-044: capture cycle data from worktree before cleanup removes it
2357
+ _cycle_tcr=0
2358
+ _cycle_status="idle"
2359
+ _cycle_built="[]"
2360
+ if [ "\$_USE_WORKTREE" = "1" ]; then
2361
+ if [ "\$_exit" -ne 0 ]; then
2362
+ _cycle_status="failed"
2363
+ else
2364
+ _cycle_commits_pre=\$(cd "\$WT" && git rev-list --count origin/main..HEAD 2>/dev/null || echo 0)
2365
+ if [ "\$_cycle_commits_pre" -gt 0 ]; then
2366
+ _cycle_status="built"
2367
+ _cycle_tcr=\$(cd "\$WT" && git log --oneline origin/main..HEAD -- 2>/dev/null | grep -c ' tcr:' || echo 0)
2368
+ if command -v jq >/dev/null 2>&1; then
2369
+ _cycle_built=\$(cd "\$WT" && git diff origin/main -- BACKLOG.md 2>/dev/null | grep '✅ Done' | grep -oE '\[[A-Z]+-[0-9]+\]' | sed 's/^.//;s/.\$//' | jq -R -s 'split("\n") | map(select(length>0))' 2>/dev/null || echo "[]")
2370
+ fi
2371
+ fi
2372
+ fi
2373
+ fi
2374
+
2291
2375
  # US-AUTO-038: diff snapshot vs current and delete any claude/* branches this
2292
2376
  # session pushed to origin. Runs regardless of claude's exit code (cleanup is
2293
2377
  # orthogonal to success/failure) and is silent on non-GitHub / unreachable.
@@ -2306,6 +2390,7 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
2306
2390
  _cycle_commits=\$(cd "\$WT" && git rev-list --count origin/main..HEAD 2>/dev/null || echo 0)
2307
2391
  if [ "\$_cycle_commits" -eq 0 ]; then
2308
2392
  _worktree_cleanup "\$WT" "\$BRANCH"
2393
+ _loop_event idle "\${CYCLE_ID}" "" "" || true
2309
2394
  echo "[loop] cycle \${CYCLE_ID}: idle (no new commits); worktree cleaned"
2310
2395
  else
2311
2396
  if ( cd "\$WT" && _loop_is_doc_only_change ); then
@@ -2316,6 +2401,7 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
2316
2401
  _publish_status=\$?
2317
2402
  if [ "\$_publish_status" -eq 0 ]; then
2318
2403
  _worktree_cleanup "\$WT" "\$BRANCH"
2404
+ _loop_event cycle_end "\${CYCLE_ID}" "" "done" || true
2319
2405
  echo "[loop] cycle \${CYCLE_ID}: published; worktree cleaned"
2320
2406
  elif [ "\$_publish_status" -eq 2 ]; then
2321
2407
  if ( cd "${project_path}" && _worktree_merge_back "\$BRANCH" ); then
@@ -2360,6 +2446,32 @@ fi
2360
2446
 
2361
2447
  # US-AUTO-040: fallback GC — delete remote loop/cycle-* branches already merged to main.
2362
2448
  _loop_cleanup_stale_cycle_branches "${project_path}" || true
2449
+
2450
+ # FIX-044 / Step 5: Write loop cycle run summary to runs.jsonl
2451
+ # Deterministic — runs in shell regardless of whether agent executes SKILL.md Step 5.
2452
+ # Idempotent: skips if a record for this run_id already exists (agent may also write).
2453
+ _runs_dst="${HOME}/.shared/roll/loop/runs.jsonl"
2454
+ mkdir -p "\$(dirname "\$_runs_dst")"
2455
+ _cycle_end=\$(date -u +%s)
2456
+ _cycle_dur=\$(( _cycle_end - CYCLE_START ))
2457
+ _ts=\$(date -u +%Y-%m-%dT%H:%M:%SZ)
2458
+ _run_id="loop-\${CYCLE_ID%-*}"
2459
+ if command -v jq >/dev/null 2>&1 && ! grep -qF "\"run_id\":\"\$_run_id\"" "\$_runs_dst" 2>/dev/null; then
2460
+ jq -nc \\
2461
+ --arg ts "\$_ts" \\
2462
+ --arg project "${slug}" \\
2463
+ --arg run_id "\$_run_id" \\
2464
+ --arg status "\$_cycle_status" \\
2465
+ --argjson built "\$_cycle_built" \\
2466
+ --argjson skipped '[]' \\
2467
+ --argjson alerts '[]' \\
2468
+ --argjson tcr_count "\$_cycle_tcr" \\
2469
+ --argjson duration_sec "\$_cycle_dur" \\
2470
+ '{ts:\$ts, project:\$project, run_id:\$run_id, status:\$status,
2471
+ built:\$built, skipped:\$skipped, alerts:\$alerts,
2472
+ tcr_count:\$tcr_count, duration_sec:\$duration_sec}' \\
2473
+ >> "\$_runs_dst" 2>/dev/null || true
2474
+ fi
2363
2475
  INNER
2364
2476
  chmod +x "$inner_path"
2365
2477
 
@@ -2587,6 +2699,11 @@ _agent_skill_cmd() {
2587
2699
  err "Unknown agent '${agent}'. Run: roll agent use <claude|kimi|deepseek|pi|codex|opencode>"
2588
2700
  return 1
2589
2701
  }
2702
+ # Cron-installed skills (dream / brief / loop) run autonomously and need to
2703
+ # Edit files (docs/dream/, docs/briefs/, BACKLOG, etc.). Claude Code 2.1.x's
2704
+ # pre-write approval UX silently blocks `claude -p` from applying edits in
2705
+ # non-interactive pipe mode — bypass it for the cron context.
2706
+ _agent_bypass_claude_perms
2590
2707
  # In cron context, use absolute claude path so a fresh shell can find it.
2591
2708
  [[ "$agent" == "claude" ]] && _AGENT_ARGV[0]="$(command -v claude 2>/dev/null || echo claude)"
2592
2709
  # Drop the prompt sentinel (always last), re-emit head args + quoted $(strip).
@@ -2607,6 +2724,7 @@ cmd_loop() {
2607
2724
  status) _loop_status ;;
2608
2725
  monitor) _loop_monitor "${1:-3}" ;;
2609
2726
  runs) _loop_runs "$@" ;;
2727
+ events) _loop_event_log "${1:-20}" ;;
2610
2728
  attach) _loop_attach ;;
2611
2729
  mute) _loop_mute ;;
2612
2730
  unmute) _loop_unmute ;;
@@ -2614,7 +2732,7 @@ cmd_loop() {
2614
2732
  resume) _loop_resume ;;
2615
2733
  reset) _loop_reset ;;
2616
2734
  notify) _notify "${1:-roll}" "${2:-}" ;;
2617
- *) err "Usage: roll loop <on|off|now|test|status|monitor|runs|attach|mute|unmute|pause|resume|reset|notify>"; exit 1 ;;
2735
+ *) err "Usage: roll loop <on|off|now|test|status|monitor|runs|events|attach|mute|unmute|pause|resume|reset|notify>"; exit 1 ;;
2618
2736
  esac
2619
2737
  }
2620
2738
 
@@ -3298,8 +3416,12 @@ _loop_diagnose_open_prs() {
3298
3416
  # When gh unavailable: returns 0 (graceful skip).
3299
3417
  _loop_enforce_ci() {
3300
3418
  local story_id="$1"
3301
-
3302
- _ci_wait 300 && return 0
3419
+ local _ci_result
3420
+ if _ci_wait 300; then
3421
+ _loop_event ci "$story_id" "" "ok" 2>/dev/null || true
3422
+ return 0
3423
+ fi
3424
+ _loop_event ci "$story_id" "" "red" 2>/dev/null || true
3303
3425
 
3304
3426
  mkdir -p "$(dirname "$_LOOP_ALERT")"
3305
3427
  cat > "$_LOOP_ALERT" << EOF
@@ -4126,6 +4248,7 @@ _loop_publish_pr() {
4126
4248
  fi
4127
4249
  gh -R "$slug" pr merge "$branch" --auto --squash --delete-branch >/dev/null 2>&1 \
4128
4250
  || _worktree_alert "_loop_publish_pr: gh pr merge --auto failed for ${branch} (PR ${pr_url} left open)"
4251
+ _loop_event pr "$branch" "$pr_url" "ok" 2>/dev/null || true
4129
4252
  echo "$pr_url"
4130
4253
  return 0
4131
4254
  }
@@ -4317,11 +4440,57 @@ _loop_monitor() {
4317
4440
  echo -e " ${YELLOW}(no log yet)${NC}"
4318
4441
  fi
4319
4442
 
4443
+ # Event stream (US-LOOP-001): last 10 events from NDJSON event file
4444
+ local slug; slug=$(_project_slug "$project_path")
4445
+ local evfile="${_SHARED_ROOT}/loop/events-${slug}.ndjson"
4446
+ echo ""
4447
+ echo -e " ─────────────────────────────────────────────────────"
4448
+ echo -e " ${BOLD}Cycle Events 事件流${NC} (last 10)"
4449
+ if [[ -f "$evfile" && -s "$evfile" ]]; then
4450
+ tail -n 10 "$evfile" | python3 -c "
4451
+ import sys, json
4452
+ for line in sys.stdin:
4453
+ try:
4454
+ e = json.loads(line)
4455
+ stage = e.get('stage','')
4456
+ label = e.get('label','')
4457
+ detail = e.get('detail','')
4458
+ outcome = e.get('outcome','')
4459
+ ts = e.get('ts','')
4460
+ print(f' {ts} {stage:<14} {label:<22} {detail} {outcome}')
4461
+ except: pass
4462
+ " 2>/dev/null || tail -n 10 "$evfile" | sed 's/^/ /'
4463
+ else
4464
+ echo -e " ${YELLOW}(no events yet — events are emitted after the first cycle)${NC}"
4465
+ fi
4466
+
4320
4467
  echo ""
4321
4468
  sleep "$interval"
4322
4469
  done
4323
4470
  }
4324
4471
 
4472
+ # _loop_event_log: show last N events from the project's NDJSON event file.
4473
+ # Used by: roll loop events [N]
4474
+ _loop_event_log() {
4475
+ local n="${1:-20}"
4476
+ local project_path; project_path=$(pwd -P)
4477
+ local slug; slug=$(_project_slug "$project_path")
4478
+ local evfile="${_SHARED_ROOT}/loop/events-${slug}.ndjson"
4479
+ if [ ! -f "$evfile" ]; then
4480
+ echo "[monitor] No event log found for project: $slug"
4481
+ return 1
4482
+ fi
4483
+ # Show last N events, formatted
4484
+ tail -n "$n" "$evfile" | python3 -c "
4485
+ import sys, json
4486
+ for line in sys.stdin:
4487
+ try:
4488
+ e = json.loads(line)
4489
+ print(f\" {e.get('ts','')} {e.get('stage',''):12s} {e.get('label',''):20s} {e.get('detail','')} {e.get('outcome','')}\")
4490
+ except: pass
4491
+ "
4492
+ }
4493
+
4325
4494
  # ═══════════════════════════════════════════════════════════════════════════════
4326
4495
  # BRIEF — owner-facing project digest
4327
4496
  # ═══════════════════════════════════════════════════════════════════════════════
package/lib/loop-fmt.py CHANGED
@@ -1,180 +1,254 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- loop-fmt.py — stream-json → human-readable formatter for roll loop tmux output.
3
+ loop-fmt.py — 3-tier stream-json → tmux formatter for roll loop.
4
4
 
5
- Reads stream-json lines from stdin, emits colored, human-readable events.
6
- Skips noise (system/init, hook_started, rate_limit_event) and abbreviates
7
- tool results so the window stays readable.
8
-
9
- Color codes: no external deps, plain ANSI.
5
+ Tier 3 (suppressed): init, thinking, Read/Glob/Grep, non-error results, plain Bash
6
+ Tier 2 (muted): Edit/Write path
7
+ Tier 1 (signal): tcr commit, story skill, peer verdict, ci gate, pr merge, errors
10
8
  """
11
-
12
9
  import sys
13
10
  import json
14
11
  import re
15
- import textwrap
16
-
17
- # ANSI colors
18
- CYAN = "\033[36m"
19
- GREEN = "\033[32m"
20
- YELLOW = "\033[33m"
21
- RED = "\033[31m"
22
- GRAY = "\033[90m"
23
- BOLD = "\033[1m"
24
- RESET = "\033[0m"
25
- DIM = "\033[2m"
26
-
27
- SKIP_SUBTYPES = {"hook_started", "hook_response", "hook_stop_hook_execution",
28
- "hook_stop_hook_active_hooks_ran"}
29
-
30
- def trunc(s, n=120):
12
+ from datetime import datetime, timezone
13
+
14
+ DARK_GRAY = "\033[90m"
15
+ CYAN = "\033[36m"
16
+ WHITE = "\033[97m"
17
+ GREEN = "\033[32m"
18
+ RED = "\033[31m"
19
+ RESET = "\033[0m"
20
+
21
+ SUPPRESS_TOOLS = {"Read", "Glob", "Grep", "ReadMcpResourceTool", "ListMcpResourcesTool",
22
+ "WebFetch", "WebSearch", "TaskCreate", "TaskGet", "TaskList",
23
+ "TaskUpdate", "TaskOutput", "TaskStop"}
24
+
25
+ def now_hms():
26
+ return datetime.now(timezone.utc).strftime("%H:%M:%S")
27
+
28
+ def trunc(s, n=60):
31
29
  s = str(s).replace("\n", " ").strip()
32
30
  return s[:n] + "…" if len(s) > n else s
33
31
 
34
- def fmt_tool_input(name, inp):
35
- if not isinstance(inp, dict):
36
- return trunc(str(inp), 80)
37
- if name == "Bash":
38
- cmd = inp.get("command", "")
39
- # show first non-empty line
40
- lines = [l.strip() for l in cmd.splitlines() if l.strip()]
41
- return trunc(lines[0] if lines else cmd, 100)
42
- if name in ("Read", "Write", "Edit"):
43
- path = inp.get("file_path", inp.get("path", ""))
44
- extra = ""
45
- if name == "Edit":
46
- old = inp.get("old_string", "")
47
- extra = f" ({trunc(old, 40)})"
48
- return f"{path}{extra}"
49
- if name in ("Glob", "Grep"):
50
- return trunc(inp.get("pattern", inp.get("query", str(inp))), 80)
51
- if name == "Skill":
52
- return inp.get("skill", "") + (" " + inp.get("args", "") if inp.get("args") else "")
53
- if name == "Agent":
54
- return trunc(inp.get("description", str(inp)), 80)
55
- return trunc(json.dumps(inp, ensure_ascii=False), 80)
56
-
57
- def fmt_tool_result(content):
58
- if isinstance(content, list):
59
- parts = []
60
- for c in content:
61
- if isinstance(c, dict):
62
- t = c.get("type", "")
63
- if t == "text":
64
- parts.append(c.get("text", ""))
65
- elif t == "image":
66
- parts.append("[image]")
67
- else:
68
- parts.append(str(c))
69
- text = " ".join(parts)
70
- else:
71
- text = str(content) if content is not None else ""
72
- # strip ansi for length check
73
- clean = re.sub(r'\033\[[0-9;]*m', '', text)
74
- lines = [l for l in clean.splitlines() if l.strip()]
75
- if not lines:
76
- return "(empty)"
77
- # show first 3 lines, trim long lines
78
- out = []
79
- for l in lines[:3]:
80
- out.append(" " + trunc(l, 100))
81
- if len(lines) > 3:
82
- out.append(f" {DIM}… ({len(lines)-3} more lines){RESET}")
83
- return "\n".join(out)
84
-
85
- def process_line(line):
86
- line = line.rstrip()
87
- if not line:
88
- return
89
- try:
90
- ev = json.loads(line)
91
- except json.JSONDecodeError:
92
- # plain text passthrough
93
- print(line)
94
- return
95
-
96
- etype = ev.get("type", "")
97
-
98
- # ── system events ──────────────────────────────────────────────
99
- if etype == "system":
100
- subtype = ev.get("subtype", "")
101
- if subtype in SKIP_SUBTYPES:
32
+ def step(category, label, detail="", ok=True):
33
+ cat_color = CYAN
34
+ label_color = GREEN if ok and category in ("ci", "pr") else (RED if not ok else WHITE)
35
+ arrow = f"{DARK_GRAY}→{RESET}"
36
+ cat = f" {cat_color}{category:<6}{RESET}"
37
+ lbl = f" {label_color}{label:<14}{RESET}"
38
+ det = f" {DARK_GRAY}{detail}{RESET}" if detail else ""
39
+ return f"{arrow}{cat}{lbl}{det}"
40
+
41
+ def stamp(text, muted=False):
42
+ ts = f"{DARK_GRAY}{now_hms()}{RESET}"
43
+ body = f"{DARK_GRAY}{text}{RESET}" if muted else text
44
+ return f"{ts} {body}"
45
+
46
+ class LoopFmt:
47
+ def __init__(self):
48
+ self.last_bash_cmd = ""
49
+ self.tcr_count = 0
50
+ self.last_test_count = None
51
+ self.cycle_num = None
52
+ self.pending_commit = False
53
+ self.pending_pr = False
54
+ self.pending_ci = False
55
+
56
+ def _extract_cycle_num(self, text):
57
+ m = re.search(r'cycle[#\s]+(\d+)', text, re.IGNORECASE)
58
+ return m.group(1) if m else "?"
59
+
60
+ def process(self, line):
61
+ line = line.rstrip()
62
+ if not line:
102
63
  return
103
- if subtype == "init":
104
- model = ev.get("model", "")
105
- tools = ev.get("tools", [])
106
- tool_list = ", ".join(tools[:6])
107
- if len(tools) > 6:
108
- tool_list += f" +{len(tools)-6}"
109
- print(f"{DIM}[init] model={model} tools={tool_list}{RESET}")
64
+
65
+ # Plain text passthrough
66
+ try:
67
+ ev = json.loads(line)
68
+ except json.JSONDecodeError:
69
+ self._handle_plain(line)
110
70
  return
111
- # unknown system — show raw briefly
112
- print(f"{DIM}[sys/{subtype}]{RESET}")
113
- return
114
71
 
115
- # ── rate limit ────────────────────────────────────────────────
116
- if etype == "rate_limit_event":
117
- return
72
+ etype = ev.get("type", "")
73
+ if etype == "system":
74
+ return # Tier 3: suppress all system events
75
+ if etype == "assistant":
76
+ self._handle_assistant(ev)
77
+ elif etype == "user":
78
+ self._handle_user(ev)
79
+ elif etype == "result":
80
+ self._handle_result(ev)
81
+ # All other types: suppress
82
+
83
+ def _handle_plain(self, line):
84
+ # [loop] cycle N: ... → Tier 1 stamp
85
+ m = re.search(r'\[loop\]\s+cycle\s+(\d+)[:\s]', line)
86
+ if m:
87
+ self.cycle_num = m.group(1)
88
+ self.tcr_count = 0
89
+ print(stamp(f"cycle #{self.cycle_num} — picking story"))
90
+ return
91
+ # Other plain text: suppress
118
92
 
119
- # ── assistant ────────────────────────────────────────────────
120
- if etype == "assistant":
93
+ def _handle_assistant(self, ev):
121
94
  msg = ev.get("message", {})
122
95
  for blk in msg.get("content", []):
123
96
  btype = blk.get("type", "")
124
- if btype == "tool_use":
125
- name = blk.get("name", "?")
126
- inp = blk.get("input", {})
127
- summary = fmt_tool_input(name, inp)
128
- print(f"{CYAN}→ {BOLD}{name}{RESET}{CYAN}: {summary}{RESET}")
97
+ if btype == "thinking":
98
+ return # Tier 3
129
99
  elif btype == "text":
130
- text = blk.get("text", "").strip()
131
- if text:
132
- # wrap long text
133
- for l in textwrap.wrap(text, 120):
134
- print(f"{GREEN}{l}{RESET}")
135
- elif btype == "thinking":
136
- thought = blk.get("thinking", "").strip()
137
- if thought:
138
- first = trunc(thought, 80)
139
- print(f"{DIM}[thinking] {first}{RESET}")
140
- return
141
-
142
- # ── user (tool results) ───────────────────────────────────────
143
- if etype == "user":
100
+ self._handle_text(blk.get("text", ""))
101
+ elif btype == "tool_use":
102
+ self._handle_tool_use(blk)
103
+
104
+ def _handle_text(self, text):
105
+ text = text.strip()
106
+ if not text:
107
+ return
108
+ # Peer verdict detection
109
+ for verdict in ("AGREE", "REFINE", "OBJECT", "ESCALATE"):
110
+ if verdict in text:
111
+ m = re.search(r'round\s+(\d+)[/\\](\d+)', text, re.IGNORECASE)
112
+ round_str = f"round {m.group(1)}/{m.group(2)}" if m else "round ?"
113
+ # agent names — look for common patterns
114
+ agents = "claude → peer"
115
+ m2 = re.search(r'(\w+)\s*→\s*(\w+)', text)
116
+ if m2:
117
+ agents = f"{m2.group(1)} → {m2.group(2)}"
118
+ print(step("peer", agents, f"{round_str} · {verdict}"))
119
+ return
120
+ # All other text: Tier 3, suppress
121
+
122
+ def _handle_tool_use(self, blk):
123
+ name = blk.get("name", "")
124
+ inp = blk.get("input", {})
125
+
126
+ if name in SUPPRESS_TOOLS:
127
+ return # Tier 3
128
+
129
+ if name in ("Edit", "Write"):
130
+ path = inp.get("file_path") or inp.get("path", "")
131
+ print(f" {DARK_GRAY}✏ {path}{RESET}")
132
+ return # Tier 2
133
+
134
+ if name == "Bash":
135
+ cmd = inp.get("command", "")
136
+ first_line = next((l.strip() for l in cmd.splitlines() if l.strip()), cmd)
137
+ self.last_bash_cmd = first_line
138
+ if re.search(r'git commit.*tcr:', cmd):
139
+ self.pending_commit = True
140
+ elif re.search(r'gh pr (create|merge)', cmd):
141
+ self.pending_pr = True
142
+ elif re.search(r'(roll ci|npm run ci|ci:local)', cmd):
143
+ self.pending_ci = True
144
+ return # Wait for result
145
+
146
+ if name == "Skill":
147
+ skill = inp.get("skill", "")
148
+ args = inp.get("args", "").strip()
149
+ if skill in ("roll-build", "roll-fix"):
150
+ us_id = args.split()[0] if args else "?"
151
+ print()
152
+ print(stamp(f"cycle #{self.cycle_num or '?'} — picking story"))
153
+ print(step("story", us_id, trunc(args, 60)))
154
+ return
155
+
156
+ # All other tools (Agent, ToolSearch, etc.): suppress
157
+
158
+ def _handle_user(self, ev):
144
159
  msg = ev.get("message", {})
145
160
  for blk in msg.get("content", []):
146
- if blk.get("type") == "tool_result":
147
- is_err = blk.get("is_error", False)
148
- content = blk.get("content", "")
149
- result_text = fmt_tool_result(content)
150
- prefix = f"{RED} ✗{RESET}" if is_err else f"{GRAY} ↩{RESET}"
151
- print(f"{prefix} {result_text}")
152
- return
153
-
154
- # ── result (final) ───────────────────────────────────────────
155
- if etype == "result":
161
+ if blk.get("type") != "tool_result":
162
+ continue
163
+ is_err = blk.get("is_error", False)
164
+ content = blk.get("content", "")
165
+ text = self._extract_text(content)
166
+
167
+ # Scan for test count (bats ok N pattern)
168
+ m = re.search(r'\bok\s+(\d+)', text)
169
+ if m:
170
+ self.last_test_count = int(m.group(1))
171
+
172
+ if is_err:
173
+ tool_name = "tool"
174
+ lines = [l for l in text.splitlines() if l.strip()][:3]
175
+ detail = " | ".join(lines)
176
+ print(step("error", tool_name, trunc(detail, 80), ok=False))
177
+ self.pending_commit = self.pending_pr = self.pending_ci = False
178
+ return
179
+
180
+ if self.pending_commit:
181
+ self.pending_commit = False
182
+ # Extract hash and message from git commit output: [branch hash] msg
183
+ m = re.search(r'\[[\w/\-]+ ([0-9a-f]{7,})\]\s*tcr:\s*(.+)', text)
184
+ if m:
185
+ commit_hash = m.group(1)[:7]
186
+ commit_msg = m.group(2).strip()
187
+ self.tcr_count += 1
188
+ test_part = f" · {self.last_test_count} tests" if self.last_test_count else ""
189
+ print(step("tcr", commit_hash, f"{commit_msg}{test_part}"))
190
+ return
191
+
192
+ if self.pending_pr:
193
+ self.pending_pr = False
194
+ m = re.search(r'#(\d+)', text)
195
+ if m:
196
+ pr_num = f"#{m.group(1)}"
197
+ branch = re.search(r'loop/[\w\-]+', self.last_bash_cmd)
198
+ branch_str = branch.group(0) if branch else ""
199
+ detail = f"auto-merged · {branch_str}" if branch_str else "auto-merged"
200
+ print(step("pr", pr_num, detail, ok=True))
201
+ return
202
+
203
+ if self.pending_ci:
204
+ self.pending_ci = False
205
+ has_green = re.search(r'(green|pass|success|all tests)', text, re.IGNORECASE)
206
+ has_red = re.search(r'(red|fail|error)', text, re.IGNORECASE)
207
+ m_dur = re.search(r'(\d+(?:\.\d+)?)\s*s\b', text)
208
+ m_test = re.search(r'(\d+)\s+tests?', text)
209
+ dur_str = f"{m_dur.group(1)}s" if m_dur else ""
210
+ test_str = f"{m_test.group(1)} tests" if m_test else (f"{self.last_test_count} tests" if self.last_test_count else "")
211
+ detail = " · ".join(filter(None, [dur_str, test_str]))
212
+ if has_green and not has_red:
213
+ print(step("ci", "green", detail, ok=True))
214
+ else:
215
+ print(step("ci", "red", detail, ok=False))
216
+ return
217
+
218
+ # Non-matching result: suppress (Tier 3)
219
+
220
+ def _extract_text(self, content):
221
+ if isinstance(content, str):
222
+ return content
223
+ if isinstance(content, list):
224
+ parts = []
225
+ for c in content:
226
+ if isinstance(c, dict) and c.get("type") == "text":
227
+ parts.append(c.get("text", ""))
228
+ return "\n".join(parts)
229
+ return str(content) if content else ""
230
+
231
+ def _handle_result(self, ev):
156
232
  dur_ms = ev.get("duration_ms", 0)
157
233
  cost_usd = ev.get("total_cost_usd", 0)
158
- turns = ev.get("num_turns", "?")
159
234
  dur_s = dur_ms / 1000
160
- cost_str = f"${cost_usd:.4f}" if cost_usd else ""
235
+ cost_str = f"${cost_usd:.2f}" if cost_usd else ""
236
+ tcr_str = f"{self.tcr_count} tcr" if self.tcr_count else ""
237
+ parts = [p for p in [tcr_str, f"{dur_s:.0f}s", cost_str] if p]
238
+ detail = " · ".join(parts)
161
239
  subtype = ev.get("subtype", "")
162
240
  if subtype == "error_max_turns":
163
- print(f"{RED}✗ max turns reached {dur_s:.1f}s{RESET}")
241
+ print(step("error", "max-turns", f"{dur_s:.0f}s", ok=False))
164
242
  else:
165
- cost_part = f" {YELLOW}{cost_str}{RESET}" if cost_str else ""
166
- print(f"\n{GREEN}{BOLD}✓ done{RESET} {dur_s:.1f}s {GRAY}{turns} turns{RESET}{cost_part}")
167
- return
168
-
169
- # ── fallback ────────────────────────────────────────────────
170
- print(f"{DIM}{trunc(line, 160)}{RESET}")
243
+ cycle_str = f"cycle #{self.cycle_num}" if self.cycle_num else "cycle done"
244
+ print(stamp(f"{cycle_str} done · {detail}" if detail else f"{cycle_str} — done", muted=True))
171
245
 
172
246
 
173
247
  def main():
248
+ fmt = LoopFmt()
174
249
  for line in sys.stdin:
175
- process_line(line)
250
+ fmt.process(line)
176
251
  sys.stdout.flush()
177
252
 
178
-
179
253
  if __name__ == "__main__":
180
254
  main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanyao/roll",
3
- "version": "2026.517.1",
3
+ "version": "2026.517.3",
4
4
  "description": "Roll — Roll out features with AI agents",
5
5
  "scripts": {
6
6
  "test": "bash tests/run.sh"
@@ -127,6 +127,18 @@ find docs/ -maxdepth 1 -name '*.md' 2>/dev/null
127
127
 
128
128
  Flag any `.md` file directly in `docs/` root (allowed subdirs: `guide/`, `domain/`, `features/`, `practices/`, `briefs/`, `dream/`).
129
129
 
130
+ **Check D — features.md Feature Coverage (US-DOC-009):**
131
+
132
+ Dependency gate: skip when `docs/features.md` does not exist.
133
+
134
+ Parse BACKLOG.md for all `### Feature: <name>` groups that contain ≥1 ✅ Done story. Parse `docs/features.md` for Feature names. If any Feature group with Done stories is absent from `docs/features.md`, the catalog is stale — flag as REFACTOR:
135
+
136
+ ```markdown
137
+ | REFACTOR-XXX | features.md 功能目录落后于 BACKLOG,N 个已完成功能区未收录,用户无法通过产品目录发现这些功能 — flagged by dream YYYY-MM-DD | 📋 Todo |
138
+ ```
139
+
140
+ The catalog is auto-updated by `scripts/release.sh` at release time (Section 8 of roll-.changelog). Between releases, this check surfaces the coverage gap so it isn't silently skipped.
141
+
130
142
  **REFACTOR entry format for doc findings:**
131
143
 
132
144
  ```markdown
@@ -137,7 +149,8 @@ Flag any `.md` file directly in `docs/` root (allowed subdirs: `guide/`, `domain
137
149
 
138
150
  ```markdown
139
151
  ## 文档覆盖度
140
- {发现内容 或 "文档结构符合规范,无缺口。"}
152
+ - features.md 功能区覆盖:{N}/{M} 个已完成功能区已收录(缺失:{列表 或 ""}
153
+ {其他发现内容 或 "文档结构符合规范,无缺口。"}
141
154
  ```
142
155
 
143
156
  ### Scan 6 — 文档新鲜度 (Doc Freshness)
@@ -302,6 +302,13 @@ When any signal appears, **do not stop — flag it**:
302
302
 
303
303
  Then continue implementing the current Story normally.
304
304
 
305
+ **Event emission** — after all TCR micro-steps for a Story complete, emit a `build` event so the cycle event stream reflects the work done:
306
+
307
+ ```bash
308
+ # _tcr_count = number of "tcr:" prefix commits made during this Story
309
+ _loop_event build "$US_ID" "${_tcr_count} commits" "" 2>/dev/null || true
310
+ ```
311
+
305
312
  ### Phase 5.5: E2E Deposit
306
313
 
307
314
  After TCR micro-steps pass, deposit an E2E test for this Story's core user flow.
@@ -195,6 +195,13 @@ For each item, **before invoking the executor skill**, mark the story 🔨 In Pr
195
195
 
196
196
  This commit is what makes the work visible — without it, tcr micro-commits during execution are invisible to `roll-brief`.
197
197
 
198
+ 选定故事后,调用 `_loop_event` 发出 story 事件,让 monitor 和 attach 能渲染当前进度:
199
+
200
+ ```bash
201
+ # 选定故事后立即 emit(在调用 executor skill 之前)
202
+ _loop_event story "$US_ID" "$story_title" ""
203
+ ```
204
+
198
205
  Then invoke the executor:
199
206
 
200
207
  ```
@@ -276,6 +283,11 @@ After each item completes:
276
283
 
277
284
  ### Step 5 — Write Run Summary
278
285
 
286
+ > **FIX-044**: The inner runner script (`_write_loop_runner_script` in `bin/roll`)
287
+ > now appends this record deterministically at cycle end. The shell write is the
288
+ > authoritative record; the agent should still emit a run summary in the cycle's
289
+ > final report for `cron.log` visibility.
290
+
279
291
  After all items in this cycle:
280
292
 
281
293
  ```yaml
@@ -65,6 +65,14 @@ Allowed states only. No invented words.
65
65
  - **OBJECT**: The proposal is wrong. Provide an alternative. Proceed to next round.
66
66
  - **ESCALATE**: Round 3 reached without AGREE, or a round fails due to API/token error. Hand off to the human user.
67
67
 
68
+ After each round decision, emit a `peer` event to the cycle event stream:
69
+
70
+ ```bash
71
+ # $round = current round number, $total = max rounds, $verdict = AGREE/REFINE/OBJECT/ESCALATE
72
+ # $agents = e.g. "claude→deepseek"
73
+ _loop_event peer "${round}/${total}" "$verdict" "$agents" 2>/dev/null || true
74
+ ```
75
+
68
76
  If information is insufficient:
69
77
  ```
70
78
  REFINE: Need to confirm X/Y/Z with the user first.