@seanyao/roll 2026.517.2 → 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,6 +1,12 @@
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
+
3
8
  ## v2026.517.2
9
+ - **Fixed**: `roll loop runs` 现在能看到刚跑完的循环记录(不再无历史) `[loop]`
4
10
  - **Fixed**: `roll dream`、`roll brief`、`roll loop` 的定时任务不再被 Claude 升级后的弹窗拦住,悄悄失效
5
11
 
6
12
  ## v2026.517.1
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.2"
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"
@@ -2073,7 +2073,7 @@ _LAUNCHD_DIR="${HOME}/Library/LaunchAgents"
2073
2073
  # Returns a filesystem-safe slug combining the project basename and a 6-char
2074
2074
  # hash of the full path, ensuring uniqueness across sibling dirs with same name.
2075
2075
  _project_slug() {
2076
- local path="$1"
2076
+ local path="${1:-$(pwd -P 2>/dev/null || pwd)}"
2077
2077
  # FIX-034: when inside a git worktree, git-common-dir returns the main tree's
2078
2078
  # absolute .git path; resolve to the main tree so worktree and main-tree runs
2079
2079
  # produce the same slug.
@@ -2115,6 +2115,55 @@ _loop_derive_minute() {
2115
2115
  echo $(( (hash_dec + offset) % 55 + 1 ))
2116
2116
  }
2117
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
+
2118
2167
  _launchd_label() {
2119
2168
  local service="$1" project_path="$2"
2120
2169
  printf 'com.roll.%s.%s' "$service" "$(_project_slug "$project_path")"
@@ -2237,6 +2286,7 @@ set +e
2237
2286
  # On any failure (no remote, no main, etc.) fall back to running in the
2238
2287
  # project's main tree (degraded — no isolation, like pre-037 behavior).
2239
2288
  CYCLE_ID="\$(date -u +%Y%m%d-%H%M%S)-\$\$"
2289
+ CYCLE_START=\$(date -u +%s)
2240
2290
  WT="\$(_worktree_path "${slug}" "cycle-\${CYCLE_ID}")"
2241
2291
  BRANCH="loop/cycle-\${CYCLE_ID}"
2242
2292
  _USE_WORKTREE=0
@@ -2278,6 +2328,7 @@ if _worktree_fetch_origin main \\
2278
2328
  _USE_WORKTREE=1
2279
2329
  _worktree_submodule_init "\$WT" 2>/dev/null || true
2280
2330
  echo "[loop] cycle \${CYCLE_ID}: worktree \$WT on \$BRANCH"
2331
+ _loop_event cycle_start "\${CYCLE_ID}" "" "" || true
2281
2332
  else
2282
2333
  # P3 fix: skip the cycle entirely when worktree isolation fails.
2283
2334
  # --dangerously-skip-permissions is only safe paired with worktree isolation;
@@ -2302,6 +2353,25 @@ for _attempt in 1 2 3; do
2302
2353
  fi
2303
2354
  done
2304
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
+
2305
2375
  # US-AUTO-038: diff snapshot vs current and delete any claude/* branches this
2306
2376
  # session pushed to origin. Runs regardless of claude's exit code (cleanup is
2307
2377
  # orthogonal to success/failure) and is silent on non-GitHub / unreachable.
@@ -2320,6 +2390,7 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
2320
2390
  _cycle_commits=\$(cd "\$WT" && git rev-list --count origin/main..HEAD 2>/dev/null || echo 0)
2321
2391
  if [ "\$_cycle_commits" -eq 0 ]; then
2322
2392
  _worktree_cleanup "\$WT" "\$BRANCH"
2393
+ _loop_event idle "\${CYCLE_ID}" "" "" || true
2323
2394
  echo "[loop] cycle \${CYCLE_ID}: idle (no new commits); worktree cleaned"
2324
2395
  else
2325
2396
  if ( cd "\$WT" && _loop_is_doc_only_change ); then
@@ -2330,6 +2401,7 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
2330
2401
  _publish_status=\$?
2331
2402
  if [ "\$_publish_status" -eq 0 ]; then
2332
2403
  _worktree_cleanup "\$WT" "\$BRANCH"
2404
+ _loop_event cycle_end "\${CYCLE_ID}" "" "done" || true
2333
2405
  echo "[loop] cycle \${CYCLE_ID}: published; worktree cleaned"
2334
2406
  elif [ "\$_publish_status" -eq 2 ]; then
2335
2407
  if ( cd "${project_path}" && _worktree_merge_back "\$BRANCH" ); then
@@ -2374,6 +2446,32 @@ fi
2374
2446
 
2375
2447
  # US-AUTO-040: fallback GC — delete remote loop/cycle-* branches already merged to main.
2376
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
2377
2475
  INNER
2378
2476
  chmod +x "$inner_path"
2379
2477
 
@@ -2626,6 +2724,7 @@ cmd_loop() {
2626
2724
  status) _loop_status ;;
2627
2725
  monitor) _loop_monitor "${1:-3}" ;;
2628
2726
  runs) _loop_runs "$@" ;;
2727
+ events) _loop_event_log "${1:-20}" ;;
2629
2728
  attach) _loop_attach ;;
2630
2729
  mute) _loop_mute ;;
2631
2730
  unmute) _loop_unmute ;;
@@ -2633,7 +2732,7 @@ cmd_loop() {
2633
2732
  resume) _loop_resume ;;
2634
2733
  reset) _loop_reset ;;
2635
2734
  notify) _notify "${1:-roll}" "${2:-}" ;;
2636
- *) 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 ;;
2637
2736
  esac
2638
2737
  }
2639
2738
 
@@ -3317,8 +3416,12 @@ _loop_diagnose_open_prs() {
3317
3416
  # When gh unavailable: returns 0 (graceful skip).
3318
3417
  _loop_enforce_ci() {
3319
3418
  local story_id="$1"
3320
-
3321
- _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
3322
3425
 
3323
3426
  mkdir -p "$(dirname "$_LOOP_ALERT")"
3324
3427
  cat > "$_LOOP_ALERT" << EOF
@@ -4145,6 +4248,7 @@ _loop_publish_pr() {
4145
4248
  fi
4146
4249
  gh -R "$slug" pr merge "$branch" --auto --squash --delete-branch >/dev/null 2>&1 \
4147
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
4148
4252
  echo "$pr_url"
4149
4253
  return 0
4150
4254
  }
@@ -4336,11 +4440,57 @@ _loop_monitor() {
4336
4440
  echo -e " ${YELLOW}(no log yet)${NC}"
4337
4441
  fi
4338
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
+
4339
4467
  echo ""
4340
4468
  sleep "$interval"
4341
4469
  done
4342
4470
  }
4343
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
+
4344
4494
  # ═══════════════════════════════════════════════════════════════════════════════
4345
4495
  # BRIEF — owner-facing project digest
4346
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.2",
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.