@seanyao/roll 2026.519.3 → 2026.520.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.
@@ -60,8 +60,9 @@
60
60
  - Before pushing any new code commit, verify the **previous** code-changing push's CI
61
61
  is green. Never stack new code commits on top of a red CI (this is the failure
62
62
  mode FIX-026 / `_loop_precheck_ci` exists to prevent for the loop — humans need
63
- the same discipline). docs-only commits (matching CI `paths-ignore`) don't reset
64
- the gate either way.
63
+ the same discipline). Every commit now triggers CI (US-POS-006 removed the
64
+ `paths-ignore` allow-list), so doc-only commits run CI too; treat their result
65
+ the same way as any other push.
65
66
  - If CI is red, the next action is **fix or revert**, not "queue something else".
66
67
  - **Commit message format**:
67
68
  - Format: `<type>: <description>` (Git Hook may auto-prepend type prefix)
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env python3
2
+ """roll-peer — v2 terminal view for `roll peer` (US-VIEW-009).
3
+
4
+ Renders a cross-agent review log as a turn-based ROUND transcript:
5
+ eyebrow + subject + proposer/reviewer overview + ROUND N sections
6
+ (each carrying agent turns with weight chips) + final VERDICT line
7
+ + artifact path / next-step hint.
8
+
9
+ NO_COLOR=1 falls through to glyph + weight + spacing only.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import os
15
+ import sys
16
+
17
+ _LIB_DIR = os.path.dirname(os.path.realpath(__file__))
18
+ if _LIB_DIR not in sys.path:
19
+ sys.path.insert(0, _LIB_DIR)
20
+ import roll_render
21
+ from roll_render import c, row, COLS
22
+
23
+ # ════════════════════════════════════════════════════════════════════════════
24
+ # Agent palette — each agent gets a stable color so reviewer/proposer pairs
25
+ # read at a glance across rounds. Unknown agents fall back to fg.
26
+ # ════════════════════════════════════════════════════════════════════════════
27
+
28
+ _AGENT_COLOR = {
29
+ "claude": "blue",
30
+ "codex": "pink",
31
+ "kimi": "amber",
32
+ "deepseek": "green",
33
+ "gemini": "purple",
34
+ "pi": "yellow",
35
+ "opencode": "muted",
36
+ "trae": "fg",
37
+ }
38
+
39
+ # Weight chip — (glyph, color, label) per turn.weight
40
+ _WEIGHTS = {
41
+ "concern": ("●", "amber", "concern"),
42
+ "nit": ("○", "dim", "nit"),
43
+ "ack": ("✓", "green", "ack"),
44
+ "block": ("✗", "red", "block"),
45
+ }
46
+
47
+
48
+ def _agent_c(name: str) -> str:
49
+ return _AGENT_COLOR.get(name.lower(), "fg")
50
+
51
+
52
+ # ════════════════════════════════════════════════════════════════════════════
53
+ # Demo data — illustrative cross-agent review: claude proposes, codex reviews
54
+ # ════════════════════════════════════════════════════════════════════════════
55
+
56
+ _DEMO_SUBJECT = {
57
+ "story": "US-AUTH-014",
58
+ "title": "Session refresh fallback when refresh-token API 5xx",
59
+ "pr": "#412",
60
+ "diff_stat": "+184 −37 · 6 files",
61
+ "trigger": "complexity=large",
62
+ "proposer": "claude",
63
+ "reviewer": "codex",
64
+ }
65
+
66
+ _DEMO_ROUNDS = [
67
+ {
68
+ "n": 1,
69
+ "hint": "first pass — proposer ships, reviewer probes",
70
+ "turns": [
71
+ ("claude", "concern",
72
+ "Refresh path swallows 503 silently — caller sees a stale session "
73
+ "without any signal that re-auth is needed."),
74
+ ("codex", "nit",
75
+ "Naming: `tryRefresh` reads as best-effort, but the retry budget "
76
+ "actually escalates. Suggest `refreshWithBackoff`."),
77
+ ("codex", "block",
78
+ "Backoff jitter uses Math.random — flakes integration tests. "
79
+ "Inject the rng so tests can pin it."),
80
+ ],
81
+ },
82
+ {
83
+ "n": 2,
84
+ "hint": "proposer revises, reviewer signs off",
85
+ "turns": [
86
+ ("claude", "ack",
87
+ "Renamed to `refreshWithBackoff`; threaded `rng` through the "
88
+ "config object. Added a test that pins seed 42."),
89
+ ("codex", "ack",
90
+ "Looks right — retries fire 3× with jitter, surfaces 503 to "
91
+ "caller after budget exhausted. Approving."),
92
+ ],
93
+ },
94
+ ]
95
+
96
+ _DEMO_VERDICT = {
97
+ "outcome": "approved",
98
+ "reason": "2 rounds · 5 turns · all blocks resolved",
99
+ }
100
+
101
+ _DEMO_ARTIFACT = "~/.roll/.peer-state/logs/20260519_213700_claude_codex.md"
102
+ _DEMO_NEXT = [
103
+ ("Continue execution", "claude resumes work on US-AUTH-014"),
104
+ ("Inspect log", "open the artifact above to replay the transcript"),
105
+ ]
106
+
107
+
108
+ # ════════════════════════════════════════════════════════════════════════════
109
+ # Render primitives
110
+ # ════════════════════════════════════════════════════════════════════════════
111
+
112
+ def _divider(char: str = "─") -> None:
113
+ print(c("dim", char * min(COLS, 80)))
114
+
115
+
116
+ def _eyebrow(trigger: str) -> None:
117
+ left = (" " + c("blue", "PEER", bold=True) +
118
+ c("dim", " · ") +
119
+ c("dim", "roll peer · cross-agent review"))
120
+ right = c("purple", trigger, bold=True) + " "
121
+ print(row(left, right))
122
+
123
+
124
+ def _subject(subj: dict) -> None:
125
+ story = c("blue", subj["story"], bold=True)
126
+ title = c("fg", subj["title"])
127
+ pr = c("amber", subj["pr"], bold=True)
128
+ diff = c("muted", subj["diff_stat"])
129
+ line = " " + story + c("muted", " · ") + title
130
+ print(line)
131
+ print(" " + pr + c("muted", " ") + diff)
132
+
133
+
134
+ def _pair_overview(subj: dict) -> None:
135
+ p_name = subj["proposer"]
136
+ r_name = subj["reviewer"]
137
+ p_c = _agent_c(p_name)
138
+ r_c = _agent_c(r_name)
139
+ proposer = c("dim", "proposer ") + c(p_c, p_name, bold=True)
140
+ reviewer = c("dim", "reviewer ") + c(r_c, r_name, bold=True)
141
+ sep = c("muted", " → ")
142
+ print(" " + proposer + sep + reviewer)
143
+
144
+
145
+ def _round_header(n: int, hint: str) -> None:
146
+ label = c("pink", f"ROUND {n}", bold=True)
147
+ print()
148
+ print(" " + label + c("muted", " · ") + c("dim", hint))
149
+
150
+
151
+ def _weight_chip(weight: str) -> str:
152
+ glyph, color, label = _WEIGHTS.get(weight, ("·", "muted", weight))
153
+ return c(color, glyph + " " + label, bold=(weight in ("ack", "block")))
154
+
155
+
156
+ def _turn(agent: str, weight: str, body: str) -> None:
157
+ agent_c = _agent_c(agent)
158
+ name = c(agent_c, agent, bold=True)
159
+ chip = _weight_chip(weight)
160
+ # First line: agent chip
161
+ print(" " + name + c("muted", " ") + chip)
162
+ # Body wrapped with hanging indent so long sentences stay readable.
163
+ _print_wrapped(body, indent=6, width=min(COLS, 80))
164
+
165
+
166
+ def _print_wrapped(s: str, *, indent: int, width: int) -> None:
167
+ avail = max(20, width - indent)
168
+ line = ""
169
+ pad = " " * indent
170
+ for word in s.split():
171
+ if line and len(line) + 1 + len(word) > avail:
172
+ print(pad + c("dim", line))
173
+ line = word
174
+ else:
175
+ line = (line + " " + word) if line else word
176
+ if line:
177
+ print(pad + c("dim", line))
178
+
179
+
180
+ def _verdict(v: dict) -> None:
181
+ outcome = v["outcome"]
182
+ if outcome == "approved":
183
+ glyph, color, label = "✓", "green", "approved"
184
+ else:
185
+ glyph, color, label = "✗", "red", "changes requested"
186
+ head = c(color, f"{glyph} VERDICT", bold=True) + c("muted", " · ") + c(color, label)
187
+ print()
188
+ print(" " + head)
189
+ print(" " + c("dim", v["reason"]))
190
+
191
+
192
+ def _footer(artifact: str, next_steps: list) -> None:
193
+ print()
194
+ print(" " + c("dim", "artifact ") + c("muted", artifact))
195
+ print()
196
+ print(" " + c("pink", "NEXT", bold=True) + c("dim", " · 下一步"))
197
+ for i, (label, hint) in enumerate(next_steps, start=1):
198
+ num = c("dim", f" {i}.")
199
+ print(f"{num} {c('fg', label, bold=True)}")
200
+ print(" " + c("dim", hint))
201
+ _divider("═")
202
+
203
+
204
+ # ════════════════════════════════════════════════════════════════════════════
205
+ # Top-level render
206
+ # ════════════════════════════════════════════════════════════════════════════
207
+
208
+ def render_demo() -> None:
209
+ _eyebrow(_DEMO_SUBJECT["trigger"])
210
+ _divider()
211
+ print()
212
+ _subject(_DEMO_SUBJECT)
213
+ print()
214
+ _pair_overview(_DEMO_SUBJECT)
215
+ for rd in _DEMO_ROUNDS:
216
+ _round_header(rd["n"], rd["hint"])
217
+ for agent, weight, body in rd["turns"]:
218
+ _turn(agent, weight, body)
219
+ _verdict(_DEMO_VERDICT)
220
+ _footer(_DEMO_ARTIFACT, _DEMO_NEXT)
221
+
222
+
223
+ # ════════════════════════════════════════════════════════════════════════════
224
+ # Entry point
225
+ # ════════════════════════════════════════════════════════════════════════════
226
+
227
+ def main() -> None:
228
+ ap = argparse.ArgumentParser(add_help=False)
229
+ ap.add_argument("--demo", action="store_true")
230
+ ap.add_argument("--no-color", dest="no_color", action="store_true")
231
+ ap.add_argument("--en", action="store_true")
232
+ ap.add_argument("--zh", action="store_true")
233
+ args, _ = ap.parse_known_args()
234
+
235
+ if args.no_color or os.environ.get("NO_COLOR") or not sys.stdout.isatty():
236
+ roll_render.USE_COLOR = False
237
+
238
+ render_demo()
239
+
240
+
241
+ if __name__ == "__main__":
242
+ main()
@@ -88,6 +88,21 @@ def fmt_dur(s: int) -> str:
88
88
  return f"{s // 60}m"
89
89
  return f"{s // 3600}h {(s % 3600) // 60}m"
90
90
 
91
+ def fmt_model(model) -> str:
92
+ """Short label for the cycle row's model column.
93
+
94
+ `claude-opus-4-7-20251001` → `opus-4-7`
95
+ None / empty → `—`
96
+ Non-claude vendor → `?`
97
+ """
98
+ if not model:
99
+ return "—"
100
+ if not model.startswith("claude-"):
101
+ return "?"
102
+ s = model[len("claude-"):]
103
+ s = re.sub(r"-\d{6,8}$", "", s)
104
+ return s if s else "?"
105
+
91
106
  def fmt_tokens(n: int) -> str:
92
107
  """Format a token count with K / M / B unit scaling, 1 decimal place.
93
108
  Uppercase suffix disambiguates from duration's lowercase m / h on the
@@ -298,11 +313,17 @@ def cycle_row(cy: Dict[str, Any], backlog: Dict[str, str]) -> None:
298
313
  time_c = "red" if outcome == "fail" else "fg"
299
314
  sid_c = "red" if outcome == "fail" else "blue"
300
315
 
316
+ model_label = fmt_model(cy.get("model"))
317
+ # Auto-hide model column on narrow screens — keeps the dashboard readable
318
+ # when terminal is < 100 cols (cost / story IDs are higher-priority).
319
+ show_model = COLS >= 100
320
+ model_seg = c("muted", pad(model_label, 11)) + " " if show_model else ""
301
321
  inner = (
302
322
  " " + c(glyph_c, glyph, bold=True) + " " +
303
323
  c(time_c, pad(time_str, 5), bold=(outcome == "fail")) + " " +
304
324
  c("muted", pad(dur, 4, "r")) + " " +
305
325
  c("muted", pad(tok, 6, "r")) + " " +
326
+ model_seg +
306
327
  c("muted", pad(cost, 7, "r")) + " " +
307
328
  c(sid_c, ids_str, bold=True)
308
329
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanyao/roll",
3
- "version": "2026.519.3",
3
+ "version": "2026.520.1",
4
4
  "description": "Roll — Roll out features with AI agents",
5
5
  "scripts": {
6
6
  "test": "bash tests/run.sh"
@@ -201,12 +201,21 @@ Together these mean: only one loop runs at a time per project (LOCK), and within
201
201
 
202
202
  ### Step 3 — Route and Execute
203
203
 
204
- For each item, **before invoking the executor skill**, mark the story 🔨 In Progress in .roll/backlog.md so brief and peer agents can see it's being worked on:
204
+ For each item, **before invoking the executor skill**, mark the story 🔨 In Progress in the **main repo's** .roll/backlog.md so brief and peer agents can see it being worked on. The cycle worktree is gitignored at .roll/, so editing the worktree's own copy + committing carries no change back to main — write directly via the helper instead:
205
205
 
206
- 1. Edit .roll/backlog.md: change the row's Status column from `📋 Todo` to `🔨 In Progress`.
207
- 2. Commit: `git commit -am "chore: mark US-XXX in progress"` (use the actual story id).
206
+ ```bash
207
+ bash -c 'source "$(command -v roll)"; _loop_mark_in_progress US-XXX'
208
+ # Updates ${ROLL_MAIN_PROJECT}/.roll/backlog.md in place: flips the row
209
+ # containing US-XXX from "📋 Todo" to "🔨 In Progress". Idempotent.
210
+ ```
211
+
212
+ If the executor fails (TCR aborts, CI red, etc.), revert the marker so the next cycle can re-pick the story:
213
+
214
+ ```bash
215
+ bash -c 'source "$(command -v roll)"; _loop_mark_todo US-XXX'
216
+ ```
208
217
 
209
- This commit is what makes the work visible without it, tcr micro-commits during execution are invisible to `roll-brief`.
218
+ Status flips happen in main directly no per-cycle commit needed. `roll-brief` reads main's backlog, so the 🔨 marker is visible the moment the helper returns.
210
219
 
211
220
  选定故事后,调用 `_loop_event` 发出 pick_todo 事件,让 dashboard / monitor / attach 都能把"这个 cycle 选了哪个 story"正确归类:
212
221
 
@@ -255,8 +264,7 @@ After each item completes:
255
264
  it derives `owner/repo` from the git remote and uses `gh -R <slug>`, which
256
265
  is required to work through `~/.ssh/config` host rewrites that break gh's
257
266
  auto-detection.
258
- - CI passes → call `_loop_clear_heal_state <story_id>` (idempotent) and
259
- continue normally
267
+ - CI passes → clear any `heal_count:` entry in `~/.shared/roll/loop/state-<slug>.yaml` (idempotent — drop the line if present, no-op otherwise) and continue normally
260
268
  - CI fails / times out / `gh` call fails → enter **CI self-heal** (US-AUTO-041)
261
269
  - `gh` binary not installed (`command -v gh` fails) → skip gracefully
262
270
  (return 0). Any other `gh` error is **not** "gh unavailable" — it is a
@@ -264,9 +272,9 @@ After each item completes:
264
272
 
265
273
  **CI self-heal (US-AUTO-041)** — bounded auto-fix before ALERT.
266
274
 
267
- Call `_loop_self_heal_ci <story_id>` to check if another attempt is permitted.
275
+ Read `heal_count:` from `~/.shared/roll/loop/state-<slug>.yaml`; treat a missing line as `0`. If the count is below `ROLL_LOOP_HEAL_MAX` (default 2) and `ROLL_LOOP_NO_HEAL` is not set, increment it and take Path A. Otherwise take Path B.
268
276
 
269
- **Path A — attempt allowed (exit 0, counter incremented in `state.yaml`):**
277
+ **Path A — attempt allowed (counter incremented in `state.yaml`):**
270
278
 
271
279
  1. Capture failure summary:
272
280
  ```