@seanyao/roll 2.602.4 → 2.603.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.
@@ -1,14 +1,19 @@
1
1
  """
2
- prices_fetcher — fetch + parse + diff + write Claude API pricing snapshots.
2
+ prices_fetcher — fetch + parse + diff + write multi-vendor pricing snapshots.
3
3
 
4
4
  US-VIEW-013: replaces the hardcoded PRICES table in ``model_prices.py`` with
5
5
  versioned JSON snapshots under ``lib/prices/``. The fetcher pulls the live
6
6
  pricing docs page, extracts the model rate rows, and writes a new snapshot
7
7
  only when the rates differ from the most recent one on disk.
8
8
 
9
+ US-VIEW-023: vendor-registry architecture — ``fetch``/``parse``/``refresh``
10
+ dispatch by vendor. Adding a new vendor is a registry entry, not a change to
11
+ the fetch/parse/refresh orchestration.
12
+
9
13
  Design:
10
14
  * ``fetch_pricing_html(url, timeout)`` — pure I/O, raises ``FetchError``
11
- * ``parse_pricing_html(html)`` — pure parser, raises ``ParseError``
15
+ * ``parse_pricing_html(html, vendor)`` — dispatches to vendor parser,
16
+ raises ``ParseError``
12
17
  * ``diff_prices(old, new)`` — pure diff, returns list of changes
13
18
  * ``write_snapshot(prices, ...)`` — pure I/O, returns the path written
14
19
  * ``refresh(...)`` — orchestrator; the only function with side effects on
@@ -22,17 +27,14 @@ import json
22
27
  import os
23
28
  import re
24
29
  import sys
30
+ from dataclasses import dataclass
25
31
  from html.parser import HTMLParser
26
- from typing import Any, Dict, List, Optional, Tuple
32
+ from typing import Any, Callable, Dict, List, Optional, Tuple
27
33
  from urllib.error import URLError
28
34
  from urllib.request import Request, urlopen
29
35
 
30
- DEFAULT_SOURCE_URL = "https://platform.claude.com/docs/en/about-claude/pricing"
31
36
  DEFAULT_TIMEOUT = 15
32
37
 
33
- _MODEL_RE = re.compile(r"claude-(?:opus|sonnet|haiku)-[0-9](?:-[0-9])?")
34
- _DOLLAR_RE = re.compile(r"\$\s*([0-9]+(?:\.[0-9]+)?)")
35
-
36
38
 
37
39
  class FetchError(RuntimeError):
38
40
  """Raised when fetching the pricing page fails."""
@@ -42,7 +44,235 @@ class ParseError(ValueError):
42
44
  """Raised when the pricing HTML cannot be parsed into a prices map."""
43
45
 
44
46
 
45
- def fetch_pricing_html(url: str = DEFAULT_SOURCE_URL,
47
+ # ─── Vendor registry ──────────────────────────────────────────────────────────
48
+
49
+ @dataclass(frozen=True)
50
+ class VendorConfig:
51
+ """Configuration for a single pricing vendor."""
52
+
53
+ name: str
54
+ source_url: str
55
+ currency: str
56
+ parse: Callable[[str], Dict[str, Dict[str, float]]]
57
+
58
+
59
+ def _parse_claude_html(html: str) -> Dict[str, Dict[str, float]]:
60
+ """Parse Anthropic/Claude pricing HTML into a {model: rates} map."""
61
+ model_re = re.compile(r"claude-(?:opus|sonnet|haiku)-[0-9](?:-[0-9])?")
62
+ dollar_re = re.compile(r"\$\s*([0-9]+(?:\.[0-9]+)?)")
63
+
64
+ extractor = _TableTextExtractor()
65
+ extractor.feed(html)
66
+
67
+ prices: Dict[str, Dict[str, float]] = {}
68
+ for row in extractor.rows:
69
+ text = " ".join(row)
70
+ model_match = model_re.search(text)
71
+ if not model_match:
72
+ continue
73
+ model = model_match.group(0)
74
+ amounts = [float(m.group(1)) for m in dollar_re.finditer(text)]
75
+ if len(amounts) < 4:
76
+ continue
77
+ in_rate, cache_create, cache_read, out_rate = amounts[:4]
78
+ prices[model] = {
79
+ "in": in_rate,
80
+ "out": out_rate,
81
+ "cache_create": cache_create,
82
+ "cache_read": cache_read,
83
+ }
84
+
85
+ if not prices:
86
+ raise ParseError("no price rows found in HTML; page layout may have changed")
87
+ return prices
88
+
89
+
90
+ def _parse_deepseek_html(html: str) -> Dict[str, Dict[str, float]]:
91
+ """Parse DeepSeek pricing HTML into a {model: rates} map.
92
+
93
+ Handles both the Chinese (元) and English ($) pricing pages.
94
+ Extracts deepseek-v4-flash and deepseek-v4-pro rates, then adds
95
+ deepseek-chat and deepseek-reasoner as aliases for flash.
96
+ """
97
+ extractor = _TableTextExtractor()
98
+ extractor.feed(html)
99
+
100
+ # Find the header row with model names.
101
+ model_names: List[str] = []
102
+ header_idx = -1
103
+ for i, row in enumerate(extractor.rows):
104
+ if any(k in ' '.join(row) for k in ('模型', 'MODEL')):
105
+ # Cells after the label are model names.
106
+ # Strip footnote markers like (1) and HTML tags.
107
+ names = [
108
+ re.sub(r'<[^>]+>', '', re.sub(r'\s*\(\d+\)', '', cell)).strip()
109
+ for cell in row[1:]
110
+ if cell.strip()
111
+ ]
112
+ if len(names) >= 2:
113
+ model_names = names
114
+ header_idx = i
115
+ break
116
+
117
+ if len(model_names) < 2:
118
+ raise ParseError('no model header row found; page layout may have changed')
119
+
120
+ # Walk rows after header to find pricing data.
121
+ cache_hit: List[float] = []
122
+ cache_miss: List[float] = []
123
+ output: List[float] = []
124
+
125
+ for row in extractor.rows[header_idx + 1:]:
126
+ text = ' '.join(row)
127
+ # Skip non-pricing rows.
128
+ if not any(k in text for k in ('缓存命中', 'CACHE HIT', '缓存未命中', 'CACHE MISS', '输出', 'OUTPUT')):
129
+ continue
130
+
131
+ # Extract numeric values followed by 元 or $.
132
+ values: List[float] = []
133
+ for cell in row:
134
+ # Match numbers like 0.02元, $0.14, 1元, etc.
135
+ m = re.search(r'(?:\$)?\s*([0-9]+(?:\.[0-9]+)?)\s*(?:元|¥)?', cell)
136
+ if m:
137
+ values.append(float(m.group(1)))
138
+
139
+ if len(values) < len(model_names):
140
+ continue
141
+
142
+ if any(k in text for k in ('缓存命中', 'CACHE HIT')):
143
+ cache_hit = values[:len(model_names)]
144
+ elif any(k in text for k in ('缓存未命中', 'CACHE MISS')):
145
+ cache_miss = values[:len(model_names)]
146
+ elif any(k in text for k in ('输出', 'OUTPUT')):
147
+ output = values[:len(model_names)]
148
+
149
+ if not cache_miss or not output:
150
+ raise ParseError('no price rows found in HTML; page layout may have changed')
151
+
152
+ prices: Dict[str, Dict[str, float]] = {}
153
+ for idx, model in enumerate(model_names):
154
+ if model in ('deepseek-v4-flash', 'deepseek-v4-pro'):
155
+ prices[model] = {
156
+ 'in': cache_miss[idx],
157
+ 'out': output[idx],
158
+ 'cache_create': cache_miss[idx],
159
+ 'cache_read': cache_hit[idx] if cache_hit else 0.0,
160
+ }
161
+
162
+ if not prices:
163
+ raise ParseError('no price rows found in HTML; page layout may have changed')
164
+
165
+ # Aliases: deepseek-chat and deepseek-reasoner both map to v4-flash.
166
+ if 'deepseek-v4-flash' in prices:
167
+ flash = prices['deepseek-v4-flash']
168
+ prices['deepseek-chat'] = dict(flash)
169
+ prices['deepseek-reasoner'] = dict(flash)
170
+
171
+ return prices
172
+
173
+
174
+ def _try_parse_kimi_pricing(html: str) -> Optional[Dict[str, Dict[str, float]]]:
175
+ """Try to parse Kimi pricing from HTML/MDX content.
176
+
177
+ Handles the JSX ``DocTable`` format used by Kimi's ``.md`` endpoints:
178
+ rows contain [model, unit, cache-hit, cache-miss, output, context].
179
+ """
180
+ prices: Dict[str, Dict[str, float]] = {}
181
+ price_re = re.compile(r"¥\s*([0-9]+(?:\.[0-9]+)?)")
182
+ row_re = re.compile(
183
+ r'\[\s*"([^"]+)"\s*,\s*"[^"]+"\s*,\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*"[^"]+"\s*\]'
184
+ )
185
+
186
+ for m in row_re.finditer(html):
187
+ model, cache_hit_str, cache_miss_str, output_str = m.groups()
188
+ cache_hit_m = price_re.search(cache_hit_str)
189
+ cache_miss_m = price_re.search(cache_miss_str)
190
+ output_m = price_re.search(output_str)
191
+ if not all((cache_hit_m, cache_miss_m, output_m)):
192
+ continue
193
+ prices[model] = {
194
+ "in": float(cache_miss_m.group(1)),
195
+ "out": float(output_m.group(1)),
196
+ "cache_create": float(cache_miss_m.group(1)),
197
+ "cache_read": float(cache_hit_m.group(1)),
198
+ }
199
+
200
+ return prices if prices else None
201
+
202
+
203
+ def _parse_kimi_html(html: str) -> Dict[str, Dict[str, float]]:
204
+ """Parse Kimi pricing HTML into a {model: rates} map.
205
+
206
+ Kimi pricing is split across sub-pages (``pricing/chat-k25``,
207
+ ``pricing/chat-k26``). The parser first tries to extract prices from the
208
+ provided HTML; if none found, it fetches the ``.md`` sub-pages and parses
209
+ those.
210
+ """
211
+ prices = _try_parse_kimi_pricing(html)
212
+ if prices:
213
+ if "kimi-k2.6" in prices:
214
+ prices["kimi-for-coding"] = dict(prices["kimi-k2.6"])
215
+ if "kimi-k2" not in prices:
216
+ prices["kimi-k2"] = {
217
+ "in": 1.00,
218
+ "out": 4.00,
219
+ "cache_create": 1.00,
220
+ "cache_read": 0.25,
221
+ }
222
+ return prices
223
+
224
+ sub_urls = [
225
+ "https://platform.kimi.com/docs/pricing/chat-k25.md",
226
+ "https://platform.kimi.com/docs/pricing/chat-k26.md",
227
+ ]
228
+ combined = html
229
+ for url in sub_urls:
230
+ try:
231
+ combined += "\n" + fetch_pricing_html(url)
232
+ except FetchError as exc:
233
+ raise ParseError(f"could not fetch kimi sub-page {url}: {exc}")
234
+
235
+ prices = _try_parse_kimi_pricing(combined)
236
+ if not prices:
237
+ raise ParseError("no price rows found in kimi pages")
238
+
239
+ if "kimi-k2.6" in prices:
240
+ prices["kimi-for-coding"] = dict(prices["kimi-k2.6"])
241
+ if "kimi-k2" not in prices:
242
+ prices["kimi-k2"] = {
243
+ "in": 1.00,
244
+ "out": 4.00,
245
+ "cache_create": 1.00,
246
+ "cache_read": 0.25,
247
+ }
248
+ return prices
249
+
250
+
251
+ VENDOR_REGISTRY: Dict[str, VendorConfig] = {
252
+ "anthropic": VendorConfig(
253
+ name="anthropic",
254
+ source_url="https://platform.claude.com/docs/en/about-claude/pricing",
255
+ currency="USD",
256
+ parse=_parse_claude_html,
257
+ ),
258
+ "deepseek": VendorConfig(
259
+ name="deepseek",
260
+ source_url="https://api-docs.deepseek.com/zh-cn/quick_start/pricing/",
261
+ currency="CNY",
262
+ parse=_parse_deepseek_html,
263
+ ),
264
+ "kimi": VendorConfig(
265
+ name="kimi",
266
+ source_url="https://platform.kimi.com/docs/pricing/chat",
267
+ currency="CNY",
268
+ parse=_parse_kimi_html,
269
+ ),
270
+ }
271
+
272
+
273
+ # ─── Network I/O ──────────────────────────────────────────────────────────────
274
+
275
+ def fetch_pricing_html(url: str,
46
276
  timeout: float = DEFAULT_TIMEOUT) -> str:
47
277
  """Fetch the pricing docs page and return its raw HTML."""
48
278
  req = Request(url, headers={"User-Agent": "roll/prices_fetcher"})
@@ -55,6 +285,8 @@ def fetch_pricing_html(url: str = DEFAULT_SOURCE_URL,
55
285
  raise FetchError(f"could not fetch {url}: {exc}") from exc
56
286
 
57
287
 
288
+ # ─── HTML parsing helpers ─────────────────────────────────────────────────────
289
+
58
290
  class _TableTextExtractor(HTMLParser):
59
291
  """Walk an HTML document and yield <tr> cell-text lists per row."""
60
292
 
@@ -88,39 +320,22 @@ class _TableTextExtractor(HTMLParser):
88
320
  self._cur.append(data)
89
321
 
90
322
 
91
- def parse_pricing_html(html: str) -> Dict[str, Dict[str, float]]:
323
+ # ─── Parser dispatch ──────────────────────────────────────────────────────────
324
+
325
+ def parse_pricing_html(html: str, vendor: str = "anthropic") -> Dict[str, Dict[str, float]]:
92
326
  """Parse pricing docs HTML into a {model: rates} map.
93
327
 
94
- The parser is intentionally tolerant: it scans every table row, looks for
95
- one ``claude-*`` model identifier and four dollar amounts on that row, and
96
- treats them as ``in / cache_create / cache_read / out`` in the order they
97
- appear. (Anthropic's table renders columns in that order.)
328
+ Dispatches to the vendor-specific parser registered in ``VENDOR_REGISTRY``.
98
329
  """
99
- parser = _TableTextExtractor()
100
- parser.feed(html)
330
+ config = VENDOR_REGISTRY.get(vendor)
331
+ if not config:
332
+ raise ParseError(
333
+ f"unknown vendor {vendor!r}; known: {', '.join(sorted(VENDOR_REGISTRY))}"
334
+ )
335
+ return config.parse(html)
101
336
 
102
- prices: Dict[str, Dict[str, float]] = {}
103
- for row in parser.rows:
104
- text = " ".join(row)
105
- model_match = _MODEL_RE.search(text)
106
- if not model_match:
107
- continue
108
- model = model_match.group(0)
109
- amounts = [float(m.group(1)) for m in _DOLLAR_RE.finditer(text)]
110
- if len(amounts) < 4:
111
- continue
112
- in_rate, cache_create, cache_read, out_rate = amounts[:4]
113
- prices[model] = {
114
- "in": in_rate,
115
- "out": out_rate,
116
- "cache_create": cache_create,
117
- "cache_read": cache_read,
118
- }
119
-
120
- if not prices:
121
- raise ParseError("no price rows found in HTML; page layout may have changed")
122
- return prices
123
337
 
338
+ # ─── Diff & formatting ────────────────────────────────────────────────────────
124
339
 
125
340
  def diff_prices(old: Dict[str, Dict[str, float]],
126
341
  new: Dict[str, Dict[str, float]]
@@ -168,10 +383,41 @@ def format_diff(changes: List[Tuple[str, str, str, Optional[float], Optional[flo
168
383
  return "\n".join(lines)
169
384
 
170
385
 
386
+ # ─── Snapshot I/O ─────────────────────────────────────────────────────────────
387
+
388
+ _SNAPSHOT_NAME_RE = re.compile(r"snapshot-(\d{4}-\d{2}-\d{2})(?:-([a-z]+))?\.json")
389
+
390
+
391
+ def _extract_vendor_from_filename(name: str) -> Optional[str]:
392
+ """Extract vendor from snapshot filename.
393
+
394
+ snapshot-2026-05-22.json → anthropic
395
+ snapshot-2026-05-22-deepseek.json → deepseek
396
+ snapshot-2026-06-02-kimi.json → kimi
397
+ """
398
+ m = _SNAPSHOT_NAME_RE.match(name)
399
+ if not m:
400
+ return None
401
+ return m.group(2) or "anthropic"
402
+
403
+
404
+ def _latest_snapshot_path(snapshot_dir: str, vendor: str = "anthropic") -> Optional[str]:
405
+ if not os.path.isdir(snapshot_dir):
406
+ return None
407
+ snaps = sorted(
408
+ os.path.join(snapshot_dir, n)
409
+ for n in os.listdir(snapshot_dir)
410
+ if _SNAPSHOT_NAME_RE.match(n) and _extract_vendor_from_filename(n) == vendor
411
+ )
412
+ return snaps[-1] if snaps else None
413
+
414
+
171
415
  def write_snapshot(prices: Dict[str, Dict[str, float]],
172
416
  *,
173
417
  snapshot_dir: str,
174
- source_url: str = DEFAULT_SOURCE_URL,
418
+ source_url: str,
419
+ vendor: str = "anthropic",
420
+ currency: str = "USD",
175
421
  effective_at: Optional[str] = None,
176
422
  default_model: Optional[str] = None,
177
423
  notes: Optional[str] = None) -> str:
@@ -182,12 +428,15 @@ def write_snapshot(prices: Dict[str, Dict[str, float]],
182
428
  "version": today,
183
429
  "effective_at": today,
184
430
  "source_url": source_url,
431
+ "vendor": vendor,
432
+ "currency": currency,
185
433
  "default_model": default_model or _pick_default(prices),
186
434
  "prices": prices,
187
435
  }
188
436
  if notes:
189
437
  payload["notes"] = notes
190
- dest = os.path.join(snapshot_dir, f"snapshot-{today}.json")
438
+ suffix = f"-{vendor}" if vendor != "anthropic" else ""
439
+ dest = os.path.join(snapshot_dir, f"snapshot-{today}{suffix}.json")
191
440
  with open(dest, "w", encoding="utf-8") as f:
192
441
  json.dump(payload, f, indent=2, sort_keys=False)
193
442
  f.write("\n")
@@ -202,9 +451,12 @@ def _pick_default(prices: Dict[str, Dict[str, float]]) -> str:
202
451
  return next(iter(prices))
203
452
 
204
453
 
454
+ # ─── Orchestrator ─────────────────────────────────────────────────────────────
455
+
205
456
  def refresh(*,
206
457
  snapshot_dir: str,
207
- url: str = DEFAULT_SOURCE_URL,
458
+ vendor: str = "anthropic",
459
+ url: Optional[str] = None,
208
460
  timeout: float = DEFAULT_TIMEOUT,
209
461
  html: Optional[str] = None,
210
462
  ) -> Tuple[str, List[Tuple[str, str, str, Optional[float], Optional[float]]]]:
@@ -215,14 +467,26 @@ def refresh(*,
215
467
  ``"written:<path>"`` — new snapshot written at <path>
216
468
  ``"first:<path>"`` — no prior snapshot existed; baseline written
217
469
  """
470
+ config = VENDOR_REGISTRY.get(vendor)
471
+ if not config:
472
+ raise ParseError(
473
+ f"unknown vendor {vendor!r}; known: {', '.join(sorted(VENDOR_REGISTRY))}"
474
+ )
475
+
476
+ source_url = url or config.source_url
218
477
  if html is None:
219
- html = fetch_pricing_html(url, timeout=timeout)
220
- new_prices = parse_pricing_html(html)
478
+ html = fetch_pricing_html(source_url, timeout=timeout)
479
+ new_prices = parse_pricing_html(html, vendor=vendor)
221
480
 
222
- # Load latest if any
223
- latest = _latest_snapshot_path(snapshot_dir)
481
+ latest = _latest_snapshot_path(snapshot_dir, vendor=vendor)
224
482
  if latest is None:
225
- dest = write_snapshot(new_prices, snapshot_dir=snapshot_dir, source_url=url)
483
+ dest = write_snapshot(
484
+ new_prices,
485
+ snapshot_dir=snapshot_dir,
486
+ source_url=source_url,
487
+ vendor=vendor,
488
+ currency=config.currency,
489
+ )
226
490
  return f"first:{dest}", diff_prices({}, new_prices)
227
491
 
228
492
  with open(latest, "r", encoding="utf-8") as f:
@@ -230,34 +494,38 @@ def refresh(*,
230
494
  changes = diff_prices(old, new_prices)
231
495
  if not changes:
232
496
  return "unchanged", []
233
- dest = write_snapshot(new_prices, snapshot_dir=snapshot_dir, source_url=url)
234
- return f"written:{dest}", changes
235
-
236
-
237
- def _latest_snapshot_path(snapshot_dir: str) -> Optional[str]:
238
- if not os.path.isdir(snapshot_dir):
239
- return None
240
- snaps = sorted(
241
- os.path.join(snapshot_dir, n)
242
- for n in os.listdir(snapshot_dir)
243
- if n.startswith("snapshot-") and n.endswith(".json")
497
+ dest = write_snapshot(
498
+ new_prices,
499
+ snapshot_dir=snapshot_dir,
500
+ source_url=source_url,
501
+ vendor=vendor,
502
+ currency=config.currency,
244
503
  )
245
- return snaps[-1] if snaps else None
504
+ return f"written:{dest}", changes
246
505
 
247
506
 
248
- # CLI entry — `python3 lib/prices_fetcher.py refresh|show` is the fallback when
507
+ # ─── CLI entry — `python3 lib/prices_fetcher.py refresh|show` is the fallback when
249
508
  # bin/roll is unavailable (e.g. running tests directly).
250
509
  def _main(argv: List[str]) -> int:
251
510
  snapshot_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "prices")
252
511
  if not argv or argv[0] in ("-h", "--help", "help"):
253
- print("usage: prices_fetcher.py refresh|show [--url URL]")
512
+ print("usage: prices_fetcher.py refresh|show [--url URL] [--vendor VENDOR]")
254
513
  return 0
255
514
  cmd = argv[0]
256
- url = DEFAULT_SOURCE_URL
257
- if "--url" in argv:
258
- url = argv[argv.index("--url") + 1]
515
+ url: Optional[str] = None
516
+ vendor = "anthropic"
517
+ i = 1
518
+ while i < len(argv):
519
+ if argv[i] == "--url" and i + 1 < len(argv):
520
+ url = argv[i + 1]
521
+ i += 2
522
+ elif argv[i] == "--vendor" and i + 1 < len(argv):
523
+ vendor = argv[i + 1]
524
+ i += 2
525
+ else:
526
+ i += 1
259
527
  if cmd == "show":
260
- latest = _latest_snapshot_path(snapshot_dir)
528
+ latest = _latest_snapshot_path(snapshot_dir, vendor=vendor)
261
529
  if not latest:
262
530
  print("no snapshot found", file=sys.stderr)
263
531
  return 1
@@ -266,7 +534,7 @@ def _main(argv: List[str]) -> int:
266
534
  return 0
267
535
  if cmd == "refresh":
268
536
  try:
269
- action, changes = refresh(snapshot_dir=snapshot_dir, url=url)
537
+ action, changes = refresh(snapshot_dir=snapshot_dir, vendor=vendor, url=url)
270
538
  except FetchError as exc:
271
539
  print(f"fetch failed: {exc}", file=sys.stderr)
272
540
  return 2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanyao/roll",
3
- "version": "2.602.4",
3
+ "version": "2.603.1",
4
4
  "description": "Roll — Roll out features with AI agents",
5
5
  "scripts": {
6
6
  "test": "bash tests/run.sh"
@@ -48,7 +48,7 @@ Create mode:
48
48
  CHANGELOG 是给**使用者**看的,不是给维护者看的。一句话讲清"用户能做什么 / 不再被什么坑",能不写就不写。
49
49
 
50
50
  **BACKLOG 描述写好了,CHANGELOG 就是复制 + 过滤,不是重写。**
51
- 如果 BACKLOG 描述已经是人话、一句话、说用户价值,直接用它(去掉 `depends-on:` / `manual-only:` 等功能性标签)。
51
+ 如果 BACKLOG 描述已经是人话、一句话、说用户价值,直接用它(去掉 `depends-on:` 等功能性标签)。
52
52
  只有 BACKLOG 描述包含实现细节或技术黑话时,才需要改写。
53
53
 
54
54
  **FIX 条目的 filter 规则**:BACKLOG 的 FIX 描述通常是 `<用户症状> — <修复手段>` 结构。
@@ -103,24 +103,12 @@ this point, any `🔨 In Progress` row in `.roll/backlog.md` belongs to a
103
103
  previous cycle that crashed before flipping it back; reclaim it before
104
104
  scanning.
105
105
 
106
- **Important — skip `manual-only:*` rows.** A row tagged `manual-only:*`
107
- means a human (or another non-loop process) has explicitly claimed it;
108
- it is not loop's to reclaim. Reverting it would silently undo the
109
- human's claim and cause confusing churn for `roll-brief` / dashboard
110
- readers. The rule mirrors the gate in Step 2.
111
-
112
106
  1. Scan .roll/backlog.md for all rows whose Status column contains `🔨 In Progress`.
113
- 2. For each candidate row, run the manual-only gate before touching it:
114
- ```bash
115
- bash -c 'source "$(command -v roll)"; _loop_is_manual_only "<story-id>" .roll/backlog.md'
116
- # exit 0 → row has `manual-only:*` → SKIP (human-claimed; not orphan)
117
- # exit 1 → reclaimable orphan; continue to step 3
118
- ```
119
- 3. For each row that passes the gate: revert the status back to
107
+ 2. For each such row: revert the status back to
120
108
  `📋 Todo`, commit `chore: revert orphan 🔨 US-XXX to 📋`, and append
121
109
  a line to `~/.shared/roll/loop/ALERT-<slug>.md` recording the orphan
122
110
  id and time so the next brief surfaces it.
123
- 4. After orphan sweep, proceed to Step 1.5 (Pre-run CI health check) before scanning.
111
+ 3. After orphan sweep, proceed to Step 1.5 (Pre-run CI health check) before scanning.
124
112
 
125
113
  ### Step 1.5 — Pre-run CI Health Check
126
114
 
@@ -157,7 +145,7 @@ Call `_loop_pr_inbox` after the pre-run CI check passes. It walks
157
145
 
158
146
  | Classification | Action |
159
147
  |---|---|
160
- | `loop_self` (head ref starts with `loop/`, CI not red) | Skiplet GitHub auto-merge handle it; never AI-review your own commit |
148
+ | `loop_self` (head ref starts with `loop/` **or** `claude/`, CI not red) | `_loop_pr_merge_self_eager`squash-merge directly when CI green + clean; if the PR is BEHIND/CONFLICTING with main, `_loop_pr_rebase_stale` rebases it first (circuit-gated) so it merges on a later tick. Never AI-review your own commit. (Agent-authored `claude/*` PRs are loop-owned the same way; a CI-red `claude/*` PR is **not** auto-healed — it falls through for a human to decide.) |
161
149
  | `loop_self_ci_red` (loop/* PR whose CI went red) | **US-LOOP-062a**: `_loop_pr_heal_self` — background-heal (per-PR lock + heal budget `ROLL_LOOP_HEAL_MAX`, default 2, via `_project_agent`); on `ROLL_LOOP_NO_HEAL=1` / budget exhausted → deduped `[TYPE:loop-pr-ci-red]` ALERT (never silently dropped) |
162
150
  | `blocked_human_request_changes` | Skip — last human review requested changes; wait for the author to push fixes |
163
151
  | `blocked_human_approved` | **US-LOOP-062b**: `_loop_pr_merge_approved` — merge directly (`gh pr merge --squash`) when CI green + mergeable, instead of relying on repo auto-merge (which may be off); merge failure is non-fatal (retried next tick) |
@@ -202,20 +190,16 @@ bash -c 'source "$(command -v roll)"; _loop_pr_claimed_stories'
202
190
  **Dependency gate** (FIX-032). For each `📋 Todo` candidate, before picking:
203
191
 
204
192
  ```bash
205
- # Source bin/roll once per cycle, then call the helpers per candidate.
193
+ # Source bin/roll once per cycle, then call the helper per candidate.
206
194
  source "$(command -v roll)"
207
195
 
208
- bash -c 'source "$(command -v roll)"; _loop_is_manual_only "<story-id>" .roll/backlog.md'
209
- # exit 0 → row has `manual-only:true` → SKIP this story, log to runs.jsonl
210
- # `skipped`, append INFO line ("manual-only — requires $roll-build")
211
-
212
196
  bash -c 'source "$(command -v roll)"; _loop_check_depends_on "<story-id>" .roll/backlog.md'
213
197
  # exit 0 → all `depends-on:US-X,US-Y` are ✅ Done → eligible
214
198
  # exit 1 → stdout lists unsatisfied dep IDs; SKIP this story, log to
215
199
  # runs.jsonl `skipped` with reason "depends-on: <unsatisfied>"
216
200
  ```
217
201
 
218
- Move to the next candidate when skipping. The two gates are pure functions
202
+ Move to the next candidate when skipping. The gate is a pure function
219
203
  over .roll/backlog.md text — no side effects, no LOCK interaction.
220
204
 
221
205
  Cap at `max_items_per_run` to limit blast radius per cycle.
@@ -244,14 +228,14 @@ Together these mean: only one loop runs at a time per project (LOCK), and within
244
228
  > **US-AGENT-006 — Per-story agent routing (pre-cycle)**
245
229
  >
246
230
  > Before this skill even starts, the runner inner script has already:
247
- > 1. Picked the next eligible Todo via `_loop_pick_next_story` (priority FIX > US > REFACTOR, manual-only / depends-on gates respected)
231
+ > 1. Picked the next eligible Todo via `_loop_pick_next_story` (priority FIX > US > REFACTOR, depends-on gate respected)
248
232
  > 2. Read its Agent profile (est_min / risk_zone) and routed an agent via `_loop_pick_agent_for_story` (hard rules from `.roll/agent-routes.yaml` + soft preference from `runs.jsonl`)
249
233
  > 3. Exported `ROLL_LOOP_ROUTED_STORY` / `ROLL_LOOP_ROUTED_AGENT` / `ROLL_LOOP_ROUTED_RULE` and printed `[loop] story <id> routed to <agent> via <rule_kind>` to cron.log
250
234
  >
251
235
  > When `ROLL_LOOP_ROUTED_STORY` is set, prefer it as `US_ID` for this cycle. The story has already been chosen by hard+soft routing rules — and, per FIX-146, the runner re-validates it against the authoritative backlog right before handing it to you (re-picking the next eligible Todo if it went ✅ Done / In Progress / ineligible between pick and handoff, emitting a `story_stale` event). So treat `ROLL_LOOP_ROUTED_STORY` as already-eligible and just work it. Only if you still find at cycle start that it is no longer 📋 Todo in BACKLOG (a residual concurrent flip), re-pick the next eligible Todo via `_loop_pick_next_story` rather than idling the whole cycle.
252
236
  >
253
237
  > Old single-agent fallback (`primary_agent` from `~/.roll/config.yaml`) still applies when:
254
- > - no story is pickable (empty Todo / all manual-only)
238
+ > - no story is pickable (empty Todo / all blocked by depends-on)
255
239
  > - the matching agent-routes.yaml has no agent that fits the story profile (then `cold_start_default` is used)
256
240
 
257
241
  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: