@seanyao/roll 2.602.5 → 2.604.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/bin/roll +500 -792
  3. package/lib/README.md +0 -1
  4. package/lib/changelog_audit.py +139 -145
  5. package/lib/changelog_generate.py +237 -30
  6. package/lib/consistency_check.py +409 -0
  7. package/lib/i18n/consistency.sh +8 -0
  8. package/lib/loop-fmt.py +2 -2
  9. package/lib/prices/snapshot-2026-05-22.json +1 -7
  10. package/lib/prices/snapshot-2026-05-23-deepseek.json +0 -2
  11. package/lib/prices/snapshot-2026-06-02-kimi.json +0 -1
  12. package/lib/prices_fetcher.py +312 -63
  13. package/lib/roll-loop-status.py +1 -1
  14. package/package.json +1 -1
  15. package/skills/roll-.changelog/SKILL.md +1 -1
  16. package/skills/roll-loop/SKILL.md +7 -23
  17. package/lib/__pycache__/github_sync.cpython-314.pyc +0 -0
  18. package/lib/__pycache__/loop-fmt.cpython-314.pyc +0 -0
  19. package/lib/__pycache__/loop_result_eval.cpython-314.pyc +0 -0
  20. package/lib/__pycache__/loop_unstick.cpython-314.pyc +0 -0
  21. package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
  22. package/lib/__pycache__/prices_fetcher.cpython-314.pyc +0 -0
  23. package/lib/__pycache__/roll-home.cpython-314.pyc +0 -0
  24. package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
  25. package/lib/__pycache__/roll_git.cpython-314.pyc +0 -0
  26. package/lib/__pycache__/roll_render.cpython-314.pyc +0 -0
  27. package/lib/__pycache__/slides-render.cpython-314.pyc +0 -0
  28. package/lib/agent_usage/__pycache__/__init__.cpython-314.pyc +0 -0
  29. package/lib/agent_usage/__pycache__/gemini.cpython-314.pyc +0 -0
  30. package/lib/agent_usage/__pycache__/kimi.cpython-314.pyc +0 -0
  31. package/lib/agent_usage/__pycache__/openai.cpython-314.pyc +0 -0
  32. package/lib/agent_usage/__pycache__/pi.cpython-314.pyc +0 -0
  33. package/lib/agent_usage/__pycache__/pi_emit.cpython-314.pyc +0 -0
  34. package/lib/agent_usage/__pycache__/qwen.cpython-314.pyc +0 -0
@@ -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,216 @@ 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
+ return prices
166
+
167
+
168
+ def _try_parse_kimi_pricing(html: str) -> Optional[Dict[str, Dict[str, float]]]:
169
+ """Try to parse Kimi pricing from HTML/MDX content.
170
+
171
+ Handles the JSX ``DocTable`` format used by Kimi's ``.md`` endpoints:
172
+ rows contain [model, unit, cache-hit, cache-miss, output, context].
173
+ """
174
+ prices: Dict[str, Dict[str, float]] = {}
175
+ price_re = re.compile(r"¥\s*([0-9]+(?:\.[0-9]+)?)")
176
+ row_re = re.compile(
177
+ r'\[\s*"([^"]+)"\s*,\s*"[^"]+"\s*,\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*"[^"]+"\s*\]'
178
+ )
179
+
180
+ for m in row_re.finditer(html):
181
+ model, cache_hit_str, cache_miss_str, output_str = m.groups()
182
+ cache_hit_m = price_re.search(cache_hit_str)
183
+ cache_miss_m = price_re.search(cache_miss_str)
184
+ output_m = price_re.search(output_str)
185
+ if not all((cache_hit_m, cache_miss_m, output_m)):
186
+ continue
187
+ prices[model] = {
188
+ "in": float(cache_miss_m.group(1)),
189
+ "out": float(output_m.group(1)),
190
+ "cache_create": float(cache_miss_m.group(1)),
191
+ "cache_read": float(cache_hit_m.group(1)),
192
+ }
193
+
194
+ return prices if prices else None
195
+
196
+
197
+ def _parse_kimi_html(html: str) -> Dict[str, Dict[str, float]]:
198
+ """Parse Kimi pricing HTML into a {model: rates} map.
199
+
200
+ Kimi pricing is split across sub-pages (``pricing/chat-k25``,
201
+ ``pricing/chat-k26``). The parser first tries to extract prices from the
202
+ provided HTML; if none found, it fetches the ``.md`` sub-pages and parses
203
+ those.
204
+ """
205
+ prices = _try_parse_kimi_pricing(html)
206
+ if prices:
207
+ if "kimi-k2.6" in prices:
208
+ prices["kimi-for-coding"] = dict(prices["kimi-k2.6"])
209
+ return prices
210
+
211
+ sub_urls = [
212
+ "https://platform.kimi.com/docs/pricing/chat-k25.md",
213
+ "https://platform.kimi.com/docs/pricing/chat-k26.md",
214
+ ]
215
+ combined = html
216
+ for url in sub_urls:
217
+ try:
218
+ combined += "\n" + fetch_pricing_html(url)
219
+ except FetchError as exc:
220
+ raise ParseError(f"could not fetch kimi sub-page {url}: {exc}")
221
+
222
+ prices = _try_parse_kimi_pricing(combined)
223
+ if not prices:
224
+ raise ParseError("no price rows found in kimi pages")
225
+
226
+ if "kimi-k2.6" in prices:
227
+ prices["kimi-for-coding"] = dict(prices["kimi-k2.6"])
228
+
229
+ return prices
230
+
231
+
232
+ VENDOR_REGISTRY: Dict[str, VendorConfig] = {
233
+ "anthropic": VendorConfig(
234
+ name="anthropic",
235
+ source_url="https://platform.claude.com/docs/en/about-claude/pricing",
236
+ currency="USD",
237
+ parse=_parse_claude_html,
238
+ ),
239
+ "deepseek": VendorConfig(
240
+ name="deepseek",
241
+ source_url="https://api-docs.deepseek.com/zh-cn/quick_start/pricing/",
242
+ currency="CNY",
243
+ parse=_parse_deepseek_html,
244
+ ),
245
+ "kimi": VendorConfig(
246
+ name="kimi",
247
+ source_url="https://platform.kimi.com/docs/pricing/chat",
248
+ currency="CNY",
249
+ parse=_parse_kimi_html,
250
+ ),
251
+ }
252
+
253
+
254
+ # ─── Network I/O ──────────────────────────────────────────────────────────────
255
+
256
+ def fetch_pricing_html(url: str,
46
257
  timeout: float = DEFAULT_TIMEOUT) -> str:
47
258
  """Fetch the pricing docs page and return its raw HTML."""
48
259
  req = Request(url, headers={"User-Agent": "roll/prices_fetcher"})
@@ -55,6 +266,8 @@ def fetch_pricing_html(url: str = DEFAULT_SOURCE_URL,
55
266
  raise FetchError(f"could not fetch {url}: {exc}") from exc
56
267
 
57
268
 
269
+ # ─── HTML parsing helpers ─────────────────────────────────────────────────────
270
+
58
271
  class _TableTextExtractor(HTMLParser):
59
272
  """Walk an HTML document and yield <tr> cell-text lists per row."""
60
273
 
@@ -88,39 +301,22 @@ class _TableTextExtractor(HTMLParser):
88
301
  self._cur.append(data)
89
302
 
90
303
 
91
- def parse_pricing_html(html: str) -> Dict[str, Dict[str, float]]:
304
+ # ─── Parser dispatch ──────────────────────────────────────────────────────────
305
+
306
+ def parse_pricing_html(html: str, vendor: str = "anthropic") -> Dict[str, Dict[str, float]]:
92
307
  """Parse pricing docs HTML into a {model: rates} map.
93
308
 
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.)
309
+ Dispatches to the vendor-specific parser registered in ``VENDOR_REGISTRY``.
98
310
  """
99
- parser = _TableTextExtractor()
100
- parser.feed(html)
311
+ config = VENDOR_REGISTRY.get(vendor)
312
+ if not config:
313
+ raise ParseError(
314
+ f"unknown vendor {vendor!r}; known: {', '.join(sorted(VENDOR_REGISTRY))}"
315
+ )
316
+ return config.parse(html)
101
317
 
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
318
 
319
+ # ─── Diff & formatting ────────────────────────────────────────────────────────
124
320
 
125
321
  def diff_prices(old: Dict[str, Dict[str, float]],
126
322
  new: Dict[str, Dict[str, float]]
@@ -168,10 +364,41 @@ def format_diff(changes: List[Tuple[str, str, str, Optional[float], Optional[flo
168
364
  return "\n".join(lines)
169
365
 
170
366
 
367
+ # ─── Snapshot I/O ─────────────────────────────────────────────────────────────
368
+
369
+ _SNAPSHOT_NAME_RE = re.compile(r"snapshot-(\d{4}-\d{2}-\d{2})(?:-([a-z]+))?\.json")
370
+
371
+
372
+ def _extract_vendor_from_filename(name: str) -> Optional[str]:
373
+ """Extract vendor from snapshot filename.
374
+
375
+ snapshot-2026-05-22.json → anthropic
376
+ snapshot-2026-05-22-deepseek.json → deepseek
377
+ snapshot-2026-06-02-kimi.json → kimi
378
+ """
379
+ m = _SNAPSHOT_NAME_RE.match(name)
380
+ if not m:
381
+ return None
382
+ return m.group(2) or "anthropic"
383
+
384
+
385
+ def _latest_snapshot_path(snapshot_dir: str, vendor: str = "anthropic") -> Optional[str]:
386
+ if not os.path.isdir(snapshot_dir):
387
+ return None
388
+ snaps = sorted(
389
+ os.path.join(snapshot_dir, n)
390
+ for n in os.listdir(snapshot_dir)
391
+ if _SNAPSHOT_NAME_RE.match(n) and _extract_vendor_from_filename(n) == vendor
392
+ )
393
+ return snaps[-1] if snaps else None
394
+
395
+
171
396
  def write_snapshot(prices: Dict[str, Dict[str, float]],
172
397
  *,
173
398
  snapshot_dir: str,
174
- source_url: str = DEFAULT_SOURCE_URL,
399
+ source_url: str,
400
+ vendor: str = "anthropic",
401
+ currency: str = "USD",
175
402
  effective_at: Optional[str] = None,
176
403
  default_model: Optional[str] = None,
177
404
  notes: Optional[str] = None) -> str:
@@ -182,12 +409,15 @@ def write_snapshot(prices: Dict[str, Dict[str, float]],
182
409
  "version": today,
183
410
  "effective_at": today,
184
411
  "source_url": source_url,
412
+ "vendor": vendor,
413
+ "currency": currency,
185
414
  "default_model": default_model or _pick_default(prices),
186
415
  "prices": prices,
187
416
  }
188
417
  if notes:
189
418
  payload["notes"] = notes
190
- dest = os.path.join(snapshot_dir, f"snapshot-{today}.json")
419
+ suffix = f"-{vendor}" if vendor != "anthropic" else ""
420
+ dest = os.path.join(snapshot_dir, f"snapshot-{today}{suffix}.json")
191
421
  with open(dest, "w", encoding="utf-8") as f:
192
422
  json.dump(payload, f, indent=2, sort_keys=False)
193
423
  f.write("\n")
@@ -202,9 +432,12 @@ def _pick_default(prices: Dict[str, Dict[str, float]]) -> str:
202
432
  return next(iter(prices))
203
433
 
204
434
 
435
+ # ─── Orchestrator ─────────────────────────────────────────────────────────────
436
+
205
437
  def refresh(*,
206
438
  snapshot_dir: str,
207
- url: str = DEFAULT_SOURCE_URL,
439
+ vendor: str = "anthropic",
440
+ url: Optional[str] = None,
208
441
  timeout: float = DEFAULT_TIMEOUT,
209
442
  html: Optional[str] = None,
210
443
  ) -> Tuple[str, List[Tuple[str, str, str, Optional[float], Optional[float]]]]:
@@ -215,14 +448,26 @@ def refresh(*,
215
448
  ``"written:<path>"`` — new snapshot written at <path>
216
449
  ``"first:<path>"`` — no prior snapshot existed; baseline written
217
450
  """
451
+ config = VENDOR_REGISTRY.get(vendor)
452
+ if not config:
453
+ raise ParseError(
454
+ f"unknown vendor {vendor!r}; known: {', '.join(sorted(VENDOR_REGISTRY))}"
455
+ )
456
+
457
+ source_url = url or config.source_url
218
458
  if html is None:
219
- html = fetch_pricing_html(url, timeout=timeout)
220
- new_prices = parse_pricing_html(html)
459
+ html = fetch_pricing_html(source_url, timeout=timeout)
460
+ new_prices = parse_pricing_html(html, vendor=vendor)
221
461
 
222
- # Load latest if any
223
- latest = _latest_snapshot_path(snapshot_dir)
462
+ latest = _latest_snapshot_path(snapshot_dir, vendor=vendor)
224
463
  if latest is None:
225
- dest = write_snapshot(new_prices, snapshot_dir=snapshot_dir, source_url=url)
464
+ dest = write_snapshot(
465
+ new_prices,
466
+ snapshot_dir=snapshot_dir,
467
+ source_url=source_url,
468
+ vendor=vendor,
469
+ currency=config.currency,
470
+ )
226
471
  return f"first:{dest}", diff_prices({}, new_prices)
227
472
 
228
473
  with open(latest, "r", encoding="utf-8") as f:
@@ -230,34 +475,38 @@ def refresh(*,
230
475
  changes = diff_prices(old, new_prices)
231
476
  if not changes:
232
477
  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")
478
+ dest = write_snapshot(
479
+ new_prices,
480
+ snapshot_dir=snapshot_dir,
481
+ source_url=source_url,
482
+ vendor=vendor,
483
+ currency=config.currency,
244
484
  )
245
- return snaps[-1] if snaps else None
485
+ return f"written:{dest}", changes
246
486
 
247
487
 
248
- # CLI entry — `python3 lib/prices_fetcher.py refresh|show` is the fallback when
488
+ # ─── CLI entry — `python3 lib/prices_fetcher.py refresh|show` is the fallback when
249
489
  # bin/roll is unavailable (e.g. running tests directly).
250
490
  def _main(argv: List[str]) -> int:
251
491
  snapshot_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "prices")
252
492
  if not argv or argv[0] in ("-h", "--help", "help"):
253
- print("usage: prices_fetcher.py refresh|show [--url URL]")
493
+ print("usage: prices_fetcher.py refresh|show [--url URL] [--vendor VENDOR]")
254
494
  return 0
255
495
  cmd = argv[0]
256
- url = DEFAULT_SOURCE_URL
257
- if "--url" in argv:
258
- url = argv[argv.index("--url") + 1]
496
+ url: Optional[str] = None
497
+ vendor = "anthropic"
498
+ i = 1
499
+ while i < len(argv):
500
+ if argv[i] == "--url" and i + 1 < len(argv):
501
+ url = argv[i + 1]
502
+ i += 2
503
+ elif argv[i] == "--vendor" and i + 1 < len(argv):
504
+ vendor = argv[i + 1]
505
+ i += 2
506
+ else:
507
+ i += 1
259
508
  if cmd == "show":
260
- latest = _latest_snapshot_path(snapshot_dir)
509
+ latest = _latest_snapshot_path(snapshot_dir, vendor=vendor)
261
510
  if not latest:
262
511
  print("no snapshot found", file=sys.stderr)
263
512
  return 1
@@ -266,7 +515,7 @@ def _main(argv: List[str]) -> int:
266
515
  return 0
267
516
  if cmd == "refresh":
268
517
  try:
269
- action, changes = refresh(snapshot_dir=snapshot_dir, url=url)
518
+ action, changes = refresh(snapshot_dir=snapshot_dir, vendor=vendor, url=url)
270
519
  except FetchError as exc:
271
520
  print(f"fetch failed: {exc}", file=sys.stderr)
272
521
  return 2
@@ -737,7 +737,7 @@ def merge_runs_into_cycles(cycles: List[Dict[str, Any]], runs: Dict[str, Dict[st
737
737
  # Outcome: runs.jsonl wins when events stream was vacuous or
738
738
  # misleading (idle/failed emitted by _loop_event even though the
739
739
  # agent completed work and _runs_append recorded built).
740
- if cy.get("outcome") in ("unknown", "running", "idle", "failed") and r.get("status"):
740
+ if cy.get("outcome") in ("unknown", "running", "idle", "failed", "orphan") and r.get("status"):
741
741
  cy["outcome"] = {"built": "done", "interrupted": "fail"}.get(r["status"], r["status"])
742
742
  if not cy.get("story") and r["built"]:
743
743
  cy["story"] = r["built"][0]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanyao/roll",
3
- "version": "2.602.5",
3
+ "version": "2.604.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: