@seanyao/roll 2026.517.2 → 2026.517.4
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 +16 -0
- package/bin/roll +259 -200
- package/lib/loop-fmt.py +295 -146
- package/package.json +1 -1
- package/skills/roll-.changelog/SKILL.md +4 -0
- package/skills/roll-.dream/SKILL.md +14 -1
- package/skills/roll-.review/SKILL.md +32 -1
- package/skills/roll-build/SKILL.md +40 -15
- package/skills/roll-loop/SKILL.md +45 -31
- package/skills/roll-peer/SKILL.md +8 -0
package/lib/loop-fmt.py
CHANGED
|
@@ -1,180 +1,329 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
loop-fmt.py — stream-json →
|
|
3
|
+
loop-fmt.py — 3-tier stream-json → tmux formatter for roll loop.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
12
|
+
import os
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
|
|
17
|
+
_SPIN_ENABLED = os.environ.get("LOOP_FMT_NO_SPIN", "0") != "1"
|
|
18
|
+
SPIN_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
19
|
+
|
|
20
|
+
DARK_GRAY = "\033[90m"
|
|
21
|
+
CYAN = "\033[36m"
|
|
22
|
+
WHITE = "\033[97m"
|
|
23
|
+
GREEN = "\033[32m"
|
|
24
|
+
RED = "\033[31m"
|
|
25
|
+
YELLOW = "\033[33m"
|
|
26
|
+
RESET = "\033[0m"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Spinner:
|
|
30
|
+
"""Animated wait indicator for long-running operations.
|
|
31
|
+
|
|
32
|
+
In production (LOOP_FMT_NO_SPIN=0): background thread writes frames using \\r.
|
|
33
|
+
In test mode (LOOP_FMT_NO_SPIN=1): writes a static ⏳ line to stdout instead.
|
|
34
|
+
"""
|
|
35
|
+
def __init__(self):
|
|
36
|
+
self._thread = None
|
|
37
|
+
self._running = False
|
|
38
|
+
self._label = ""
|
|
39
|
+
self._lock = threading.Lock()
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def active(self):
|
|
43
|
+
return self._running
|
|
44
|
+
|
|
45
|
+
def start(self, label):
|
|
46
|
+
with self._lock:
|
|
47
|
+
if self._running:
|
|
48
|
+
self._label = label # update without restart
|
|
49
|
+
return
|
|
50
|
+
self._label = label
|
|
51
|
+
self._running = True
|
|
52
|
+
if _SPIN_ENABLED:
|
|
53
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
54
|
+
self._thread.start()
|
|
55
|
+
else:
|
|
56
|
+
sys.stdout.write(f" {YELLOW}⏳ {label}...{RESET}\n")
|
|
57
|
+
sys.stdout.flush()
|
|
58
|
+
|
|
59
|
+
def stop(self):
|
|
60
|
+
with self._lock:
|
|
61
|
+
was_running = self._running
|
|
62
|
+
self._running = False
|
|
63
|
+
if self._thread:
|
|
64
|
+
self._thread.join(timeout=0.3)
|
|
65
|
+
self._thread = None
|
|
66
|
+
if _SPIN_ENABLED and was_running:
|
|
67
|
+
sys.stdout.write(f"\r{' ' * 60}\r")
|
|
68
|
+
sys.stdout.flush()
|
|
69
|
+
|
|
70
|
+
def _run(self):
|
|
71
|
+
i = 0
|
|
72
|
+
while self._running:
|
|
73
|
+
with self._lock:
|
|
74
|
+
label = self._label
|
|
75
|
+
frame = SPIN_FRAMES[i % len(SPIN_FRAMES)]
|
|
76
|
+
sys.stdout.write(f"\r {YELLOW}{frame} {label}...{RESET}")
|
|
77
|
+
sys.stdout.flush()
|
|
78
|
+
time.sleep(0.12)
|
|
79
|
+
i += 1
|
|
80
|
+
sys.stdout.write(f"\r{' ' * 60}\r")
|
|
81
|
+
sys.stdout.flush()
|
|
82
|
+
|
|
83
|
+
SUPPRESS_TOOLS = {"Read", "Glob", "Grep", "ReadMcpResourceTool", "ListMcpResourcesTool",
|
|
84
|
+
"WebFetch", "WebSearch", "TaskCreate", "TaskGet", "TaskList",
|
|
85
|
+
"TaskUpdate", "TaskOutput", "TaskStop"}
|
|
86
|
+
|
|
87
|
+
def now_hms():
|
|
88
|
+
return datetime.now(timezone.utc).strftime("%H:%M:%S")
|
|
89
|
+
|
|
90
|
+
def trunc(s, n=60):
|
|
31
91
|
s = str(s).replace("\n", " ").strip()
|
|
32
92
|
return s[:n] + "…" if len(s) > n else s
|
|
33
93
|
|
|
34
|
-
def
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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:
|
|
94
|
+
def step(category, label, detail="", ok=True):
|
|
95
|
+
cat_color = CYAN
|
|
96
|
+
label_color = GREEN if ok and category in ("ci", "pr") else (RED if not ok else WHITE)
|
|
97
|
+
arrow = f"{DARK_GRAY}→{RESET}"
|
|
98
|
+
cat = f" {cat_color}{category:<6}{RESET}"
|
|
99
|
+
lbl = f" {label_color}{label:<14}{RESET}"
|
|
100
|
+
det = f" {DARK_GRAY}{detail}{RESET}" if detail else ""
|
|
101
|
+
return f"{arrow}{cat}{lbl}{det}"
|
|
102
|
+
|
|
103
|
+
def stamp(text, muted=False):
|
|
104
|
+
ts = f"{DARK_GRAY}{now_hms()}{RESET}"
|
|
105
|
+
body = f"{DARK_GRAY}{text}{RESET}" if muted else text
|
|
106
|
+
return f"{ts} {body}"
|
|
107
|
+
|
|
108
|
+
class LoopFmt:
|
|
109
|
+
def __init__(self):
|
|
110
|
+
self.last_bash_cmd = ""
|
|
111
|
+
self.tcr_count = 0
|
|
112
|
+
self.last_test_count = None
|
|
113
|
+
self.cycle_num = None
|
|
114
|
+
self.pending_commit = False
|
|
115
|
+
self.pending_pr = False
|
|
116
|
+
self.pending_ci = False
|
|
117
|
+
self.pending_story = False
|
|
118
|
+
self.spinner = Spinner()
|
|
119
|
+
|
|
120
|
+
def _extract_cycle_num(self, text):
|
|
121
|
+
m = re.search(r'cycle[#\s]+(\d+)', text, re.IGNORECASE)
|
|
122
|
+
return m.group(1) if m else "?"
|
|
123
|
+
|
|
124
|
+
def process(self, line):
|
|
125
|
+
line = line.rstrip()
|
|
126
|
+
if not line:
|
|
102
127
|
return
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
print(f"{DIM}[init] model={model} tools={tool_list}{RESET}")
|
|
128
|
+
|
|
129
|
+
# Plain text passthrough
|
|
130
|
+
try:
|
|
131
|
+
ev = json.loads(line)
|
|
132
|
+
except json.JSONDecodeError:
|
|
133
|
+
self._handle_plain(line)
|
|
110
134
|
return
|
|
111
|
-
# unknown system — show raw briefly
|
|
112
|
-
print(f"{DIM}[sys/{subtype}]{RESET}")
|
|
113
|
-
return
|
|
114
135
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
136
|
+
etype = ev.get("type", "")
|
|
137
|
+
if etype == "system":
|
|
138
|
+
return # Tier 3: suppress all system events
|
|
139
|
+
if etype == "assistant":
|
|
140
|
+
self._handle_assistant(ev)
|
|
141
|
+
elif etype == "user":
|
|
142
|
+
self._handle_user(ev)
|
|
143
|
+
elif etype == "result":
|
|
144
|
+
self._handle_result(ev)
|
|
145
|
+
# All other types: suppress
|
|
146
|
+
|
|
147
|
+
def _handle_plain(self, line):
|
|
148
|
+
# [loop] cycle N: ... → Tier 1 stamp
|
|
149
|
+
m = re.search(r'\[loop\]\s+cycle\s+(\d+)[:\s]', line)
|
|
150
|
+
if m:
|
|
151
|
+
self.cycle_num = m.group(1)
|
|
152
|
+
self.tcr_count = 0
|
|
153
|
+
print(stamp(f"cycle #{self.cycle_num} — picking story"))
|
|
154
|
+
return
|
|
155
|
+
# Other plain text: suppress
|
|
118
156
|
|
|
119
|
-
|
|
120
|
-
if etype == "assistant":
|
|
157
|
+
def _handle_assistant(self, ev):
|
|
121
158
|
msg = ev.get("message", {})
|
|
122
159
|
for blk in msg.get("content", []):
|
|
123
160
|
btype = blk.get("type", "")
|
|
124
|
-
if btype == "
|
|
125
|
-
|
|
126
|
-
inp = blk.get("input", {})
|
|
127
|
-
summary = fmt_tool_input(name, inp)
|
|
128
|
-
print(f"{CYAN}→ {BOLD}{name}{RESET}{CYAN}: {summary}{RESET}")
|
|
161
|
+
if btype == "thinking":
|
|
162
|
+
return # Tier 3
|
|
129
163
|
elif btype == "text":
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
164
|
+
self._handle_text(blk.get("text", ""))
|
|
165
|
+
elif btype == "tool_use":
|
|
166
|
+
self._handle_tool_use(blk)
|
|
167
|
+
|
|
168
|
+
def _handle_text(self, text):
|
|
169
|
+
text = text.strip()
|
|
170
|
+
if not text:
|
|
171
|
+
return
|
|
172
|
+
# Peer verdict detection
|
|
173
|
+
for verdict in ("AGREE", "REFINE", "OBJECT", "ESCALATE"):
|
|
174
|
+
if verdict in text:
|
|
175
|
+
m = re.search(r'round\s+(\d+)[/\\](\d+)', text, re.IGNORECASE)
|
|
176
|
+
round_str = f"round {m.group(1)}/{m.group(2)}" if m else "round ?"
|
|
177
|
+
# agent names — look for common patterns
|
|
178
|
+
agents = "claude → peer"
|
|
179
|
+
m2 = re.search(r'(\w+)\s*→\s*(\w+)', text)
|
|
180
|
+
if m2:
|
|
181
|
+
agents = f"{m2.group(1)} → {m2.group(2)}"
|
|
182
|
+
print(step("peer", agents, f"{round_str} · {verdict}"))
|
|
183
|
+
return
|
|
184
|
+
# All other text: Tier 3, suppress
|
|
185
|
+
|
|
186
|
+
def _handle_tool_use(self, blk):
|
|
187
|
+
name = blk.get("name", "")
|
|
188
|
+
inp = blk.get("input", {})
|
|
189
|
+
|
|
190
|
+
if name in SUPPRESS_TOOLS:
|
|
191
|
+
return # Tier 3
|
|
192
|
+
|
|
193
|
+
if name in ("Edit", "Write"):
|
|
194
|
+
path = inp.get("file_path") or inp.get("path", "")
|
|
195
|
+
print(f" {DARK_GRAY}✏ {path}{RESET}")
|
|
196
|
+
return # Tier 2
|
|
197
|
+
|
|
198
|
+
if name == "Bash":
|
|
199
|
+
cmd = inp.get("command", "")
|
|
200
|
+
first_line = next((l.strip() for l in cmd.splitlines() if l.strip()), cmd)
|
|
201
|
+
self.last_bash_cmd = first_line
|
|
202
|
+
if re.search(r'git commit.*tcr:', cmd):
|
|
203
|
+
self.pending_commit = True
|
|
204
|
+
elif re.search(r'gh pr (create|merge)', cmd):
|
|
205
|
+
self.pending_pr = True
|
|
206
|
+
self.spinner.start("merging PR")
|
|
207
|
+
elif re.search(r'(roll ci|npm run ci|_ci_wait|ci:local)', cmd):
|
|
208
|
+
self.pending_ci = True
|
|
209
|
+
self.spinner.start("waiting for CI")
|
|
210
|
+
return # Wait for result
|
|
211
|
+
|
|
212
|
+
if name == "Skill":
|
|
213
|
+
skill = inp.get("skill", "")
|
|
214
|
+
args = inp.get("args", "").strip()
|
|
215
|
+
if skill in ("roll-build", "roll-fix"):
|
|
216
|
+
us_id = args.split()[0] if args else "?"
|
|
217
|
+
print()
|
|
218
|
+
print(stamp(f"cycle #{self.cycle_num or '?'} — picking story"))
|
|
219
|
+
print(step("story", us_id, trunc(args, 60)))
|
|
220
|
+
self.pending_story = True
|
|
221
|
+
self.spinner.start("executing story")
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
# All other tools (Agent, ToolSearch, etc.): suppress
|
|
225
|
+
|
|
226
|
+
def _handle_user(self, ev):
|
|
144
227
|
msg = ev.get("message", {})
|
|
145
228
|
for blk in msg.get("content", []):
|
|
146
|
-
if blk.get("type")
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
229
|
+
if blk.get("type") != "tool_result":
|
|
230
|
+
continue
|
|
231
|
+
is_err = blk.get("is_error", False)
|
|
232
|
+
content = blk.get("content", "")
|
|
233
|
+
text = self._extract_text(content)
|
|
234
|
+
|
|
235
|
+
# Scan for test count (bats ok N pattern)
|
|
236
|
+
m = re.search(r'\bok\s+(\d+)', text)
|
|
237
|
+
if m:
|
|
238
|
+
self.last_test_count = int(m.group(1))
|
|
239
|
+
|
|
240
|
+
if is_err:
|
|
241
|
+
tool_name = "tool"
|
|
242
|
+
lines = [l for l in text.splitlines() if l.strip()][:3]
|
|
243
|
+
detail = " | ".join(lines)
|
|
244
|
+
print(step("error", tool_name, trunc(detail, 80), ok=False))
|
|
245
|
+
self.pending_commit = self.pending_pr = self.pending_ci = False
|
|
246
|
+
return
|
|
247
|
+
|
|
248
|
+
if self.pending_commit:
|
|
249
|
+
self.pending_commit = False
|
|
250
|
+
# Extract hash and message from git commit output: [branch hash] msg
|
|
251
|
+
m = re.search(r'\[[\w/\-]+ ([0-9a-f]{7,})\]\s*tcr:\s*(.+)', text)
|
|
252
|
+
if m:
|
|
253
|
+
commit_hash = m.group(1)[:7]
|
|
254
|
+
commit_msg = m.group(2).strip()
|
|
255
|
+
self.tcr_count += 1
|
|
256
|
+
test_part = f" · {self.last_test_count} tests" if self.last_test_count else ""
|
|
257
|
+
print(step("tcr", commit_hash, f"{commit_msg}{test_part}"))
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
if self.pending_story:
|
|
261
|
+
self.pending_story = False
|
|
262
|
+
self.spinner.stop()
|
|
263
|
+
return # story result content suppressed; TCR events showed the work
|
|
264
|
+
|
|
265
|
+
if self.pending_pr:
|
|
266
|
+
self.spinner.stop()
|
|
267
|
+
self.pending_pr = False
|
|
268
|
+
m = re.search(r'#(\d+)', text)
|
|
269
|
+
if m:
|
|
270
|
+
pr_num = f"#{m.group(1)}"
|
|
271
|
+
branch = re.search(r'loop/[\w\-]+', self.last_bash_cmd)
|
|
272
|
+
branch_str = branch.group(0) if branch else ""
|
|
273
|
+
detail = f"auto-merged · {branch_str}" if branch_str else "auto-merged"
|
|
274
|
+
print(step("pr", pr_num, detail, ok=True))
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
if self.pending_ci:
|
|
278
|
+
self.spinner.stop()
|
|
279
|
+
self.pending_ci = False
|
|
280
|
+
has_green = re.search(r'(green|pass|success|all tests)', text, re.IGNORECASE)
|
|
281
|
+
has_red = re.search(r'(red|fail|error)', text, re.IGNORECASE)
|
|
282
|
+
m_dur = re.search(r'(\d+(?:\.\d+)?)\s*s\b', text)
|
|
283
|
+
m_test = re.search(r'(\d+)\s+tests?', text)
|
|
284
|
+
dur_str = f"{m_dur.group(1)}s" if m_dur else ""
|
|
285
|
+
test_str = f"{m_test.group(1)} tests" if m_test else (f"{self.last_test_count} tests" if self.last_test_count else "")
|
|
286
|
+
detail = " · ".join(filter(None, [dur_str, test_str]))
|
|
287
|
+
if has_green and not has_red:
|
|
288
|
+
print(step("ci", "green", detail, ok=True))
|
|
289
|
+
else:
|
|
290
|
+
print(step("ci", "red", detail, ok=False))
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
# Non-matching result: suppress (Tier 3)
|
|
294
|
+
|
|
295
|
+
def _extract_text(self, content):
|
|
296
|
+
if isinstance(content, str):
|
|
297
|
+
return content
|
|
298
|
+
if isinstance(content, list):
|
|
299
|
+
parts = []
|
|
300
|
+
for c in content:
|
|
301
|
+
if isinstance(c, dict) and c.get("type") == "text":
|
|
302
|
+
parts.append(c.get("text", ""))
|
|
303
|
+
return "\n".join(parts)
|
|
304
|
+
return str(content) if content else ""
|
|
305
|
+
|
|
306
|
+
def _handle_result(self, ev):
|
|
156
307
|
dur_ms = ev.get("duration_ms", 0)
|
|
157
308
|
cost_usd = ev.get("total_cost_usd", 0)
|
|
158
|
-
turns = ev.get("num_turns", "?")
|
|
159
309
|
dur_s = dur_ms / 1000
|
|
160
|
-
cost_str = f"${cost_usd:.
|
|
310
|
+
cost_str = f"${cost_usd:.2f}" if cost_usd else ""
|
|
311
|
+
tcr_str = f"{self.tcr_count} tcr" if self.tcr_count else ""
|
|
312
|
+
parts = [p for p in [tcr_str, f"{dur_s:.0f}s", cost_str] if p]
|
|
313
|
+
detail = " · ".join(parts)
|
|
161
314
|
subtype = ev.get("subtype", "")
|
|
162
315
|
if subtype == "error_max_turns":
|
|
163
|
-
print(
|
|
316
|
+
print(step("error", "max-turns", f"{dur_s:.0f}s", ok=False))
|
|
164
317
|
else:
|
|
165
|
-
|
|
166
|
-
print(f"
|
|
167
|
-
return
|
|
168
|
-
|
|
169
|
-
# ── fallback ────────────────────────────────────────────────
|
|
170
|
-
print(f"{DIM}{trunc(line, 160)}{RESET}")
|
|
318
|
+
cycle_str = f"cycle #{self.cycle_num}" if self.cycle_num else "cycle done"
|
|
319
|
+
print(stamp(f"{cycle_str} — done · {detail}" if detail else f"{cycle_str} — done", muted=True))
|
|
171
320
|
|
|
172
321
|
|
|
173
322
|
def main():
|
|
323
|
+
fmt = LoopFmt()
|
|
174
324
|
for line in sys.stdin:
|
|
175
|
-
|
|
325
|
+
fmt.process(line)
|
|
176
326
|
sys.stdout.flush()
|
|
177
327
|
|
|
178
|
-
|
|
179
328
|
if __name__ == "__main__":
|
|
180
329
|
main()
|
package/package.json
CHANGED
|
@@ -416,6 +416,10 @@ prompt 会包含:
|
|
|
416
416
|
(即使没有 deep doc 也要列)
|
|
417
417
|
- Feature 名跟 `docs/features/<file>.md` 文件名一致时,加链接到该 md
|
|
418
418
|
- 没有对应 deep doc 的 Feature,**只写 plain text 不加链接**
|
|
419
|
+
- **Planning distinction(US-DOC-011)**:
|
|
420
|
+
- 该 Feature 下**所有** Story 均为 `📋 Todo` → 在描述末尾追加 `*(规划中)*`
|
|
421
|
+
- 只要有 **≥1 个** `✅ Done` Story → 正常展示,**不加**任何标记
|
|
422
|
+
- 一眼可见:规划中的 Feature 在每个 Epic 分组的末尾列出
|
|
419
423
|
- 描述写 1 句话 **产品视角**:用户能用它做什么,避免实现细节
|
|
420
424
|
- 分组用 BACKLOG 的 Epic 名,原序,不重排
|
|
421
425
|
- Core Highlights 从所有 Features 里挑 3-5 个最能代表产品定位的,
|
|
@@ -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)
|
|
@@ -45,7 +45,9 @@ $roll-.review unstaged
|
|
|
45
45
|
$roll-.review files src/utils.ts
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
-
## Review Dimensions (
|
|
48
|
+
## Review Dimensions (7 Core Dimensions)
|
|
49
|
+
|
|
50
|
+
Original 6 dimensions plus Reuse (added in REFACTOR-022, simplify three-axis integration):
|
|
49
51
|
|
|
50
52
|
```
|
|
51
53
|
┌─────────────────────────────────────────────────────────┐
|
|
@@ -54,13 +56,42 @@ $roll-.review files src/utils.ts
|
|
|
54
56
|
│ ✅ Correctness - Logic is correct, no bugs │
|
|
55
57
|
│ ✅ Security - No vulnerabilities, input valid. │
|
|
56
58
|
│ ✅ Maintainability - Clear naming, sound structure │
|
|
59
|
+
│ Quality anti-patterns (check each): │
|
|
60
|
+
│ □ Redundant state / cached values that could be │
|
|
61
|
+
│ derived directly │
|
|
62
|
+
│ □ Parameter sprawl — new param vs. restructure │
|
|
63
|
+
│ □ Copy-paste with slight variation (near-dup) │
|
|
64
|
+
│ □ Leaky abstraction — exposes internal details │
|
|
65
|
+
│ □ Stringly-typed — raw string where constant │
|
|
66
|
+
│ / enum exists │
|
|
67
|
+
│ □ Unnecessary JSX nesting (no layout value) │
|
|
68
|
+
│ □ Nested conditionals ≥3 deep (ternary chains, │
|
|
69
|
+
│ nested if/else) — flatten with early return │
|
|
70
|
+
│ □ Unnecessary comments explaining WHAT │
|
|
57
71
|
│ ✅ Performance - No performance pitfalls │
|
|
72
|
+
│ Efficiency anti-patterns (check each): │
|
|
73
|
+
│ □ Redundant computation / repeated file read / │
|
|
74
|
+
│ duplicate API call / N+1 pattern │
|
|
75
|
+
│ □ Missed concurrency — independent ops sequential │
|
|
76
|
+
│ □ Hot-path bloat — blocking work in startup or │
|
|
77
|
+
│ per-request path │
|
|
78
|
+
│ □ Loop no-op updates — missing change-detection │
|
|
79
|
+
│ guard │
|
|
80
|
+
│ □ TOCTOU existence pre-check — operate directly + │
|
|
81
|
+
│ handle error instead │
|
|
82
|
+
│ □ Memory — unbounded structures / missing cleanup │
|
|
83
|
+
│ □ Overly broad op — reading full file for a slice │
|
|
58
84
|
│ ✅ Testability - Easy to test, edge cases covered │
|
|
59
85
|
│ ✅ Scope - Focused on current task, no │
|
|
60
86
|
│ unrelated changes │
|
|
87
|
+
│ ✅ Reuse - No new code duplicating existing │
|
|
88
|
+
│ □ New function duplicates existing utility/helper │
|
|
89
|
+
│ □ Inline logic replaceable by existing tool │
|
|
61
90
|
└─────────────────────────────────────────────────────────┘
|
|
62
91
|
```
|
|
63
92
|
|
|
93
|
+
**Usage in TCR**: Each micro-step review is a lightweight self-check against this checklist — no sub-agents, zero extra token cost. The three-axis deep review with parallel agents runs once per Story in `$roll-build` Phase 7.
|
|
94
|
+
|
|
64
95
|
## Severity Levels and Decisions
|
|
65
96
|
|
|
66
97
|
| Level | Definition | Decision |
|