@seanyao/roll 2026.529.5 → 2026.601.2

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 (59) hide show
  1. package/CHANGELOG.md +57 -25
  2. package/README.md +10 -7
  3. package/bin/roll +3952 -317
  4. package/conventions/config.yaml +7 -0
  5. package/lib/__pycache__/github_sync.cpython-314.pyc +0 -0
  6. package/lib/__pycache__/loop_result_eval.cpython-314.pyc +0 -0
  7. package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
  8. package/lib/__pycache__/roll-home.cpython-314.pyc +0 -0
  9. package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
  10. package/lib/__pycache__/roll_git.cpython-314.pyc +0 -0
  11. package/lib/__pycache__/slides-render.cpython-314.pyc +0 -0
  12. package/lib/agent_usage/__init__.py +4 -0
  13. package/lib/agent_usage/__pycache__/__init__.cpython-314.pyc +0 -0
  14. package/lib/agent_usage/__pycache__/gemini.cpython-314.pyc +0 -0
  15. package/lib/agent_usage/__pycache__/kimi.cpython-314.pyc +0 -0
  16. package/lib/agent_usage/__pycache__/openai.cpython-314.pyc +0 -0
  17. package/lib/agent_usage/__pycache__/qwen.cpython-314.pyc +0 -0
  18. package/lib/agent_usage/gemini.py +127 -0
  19. package/lib/agent_usage/kimi.py +127 -0
  20. package/lib/agent_usage/openai.py +126 -0
  21. package/lib/agent_usage/qwen.py +128 -0
  22. package/lib/context_feed_budget.sh +194 -0
  23. package/lib/github_sync.py +876 -0
  24. package/lib/i18n/agent.sh +54 -0
  25. package/lib/i18n/init.sh +22 -0
  26. package/lib/i18n/peer.sh +7 -0
  27. package/lib/i18n/peer_help.sh +4 -0
  28. package/lib/i18n/skills_catalog.sh +30 -0
  29. package/lib/loop-exit-summary.py +393 -0
  30. package/lib/loop-fmt.py +93 -75
  31. package/lib/loop_pick_agent.py +241 -170
  32. package/lib/loop_result_eval.py +469 -0
  33. package/lib/model_prices.py +0 -10
  34. package/lib/roll-home.py +1 -28
  35. package/lib/roll-loop-status.py +330 -40
  36. package/lib/roll-onboard-render.py +378 -0
  37. package/lib/roll-peer.py +1 -1
  38. package/lib/roll-plan-validate.py +165 -0
  39. package/lib/roll_git.py +41 -0
  40. package/lib/slides/components/README.md +8 -2
  41. package/lib/slides/templates/introduction-v3.html +1 -6
  42. package/lib/slides-render.py +305 -15
  43. package/lib/slides-validate.py +195 -7
  44. package/package.json +1 -1
  45. package/skills/roll-.changelog/SKILL.md +67 -56
  46. package/skills/roll-brief/SKILL.md +1 -1
  47. package/skills/roll-build/SKILL.md +14 -12
  48. package/skills/roll-deck/SKILL.md +152 -0
  49. package/skills/roll-design/SKILL.md +13 -6
  50. package/skills/roll-doc/SKILL.md +269 -6
  51. package/skills/roll-fix/SKILL.md +15 -9
  52. package/skills/roll-loop/SKILL.md +9 -7
  53. package/skills/roll-notes/SKILL.md +1 -1
  54. package/skills/roll-onboard/SKILL.md +85 -0
  55. package/skills/roll-peer/SKILL.md +6 -5
  56. package/lib/agent_routes_lint.py +0 -203
  57. package/skills/roll-research/SKILL.md +0 -316
  58. package/skills/roll-research/references/schema.json +0 -166
  59. package/skills/roll-research/scripts/md_to_pdf.py +0 -289
@@ -99,6 +99,12 @@ def _coerce_scalar(v: str):
99
99
  return v
100
100
 
101
101
 
102
+ # Keys whose `- item` children are always flat scalar strings, never coerced
103
+ # into mappings even when an item contains a colon (e.g. `evidence:` citations
104
+ # like `- TODO: see README.md:1`).
105
+ _SCALAR_LIST_KEYS = ("evidence",)
106
+
107
+
102
108
  # A slide section starts at a line matching `^## Slide \d+` and continues
103
109
  # until the next such line or EOF.
104
110
  _SLIDE_HEADER_RE = re.compile(r"^##\s+Slide\s+(\d+)\s*$")
@@ -188,21 +194,21 @@ def _populate_slide(slide: dict, content_lines: list[str]) -> None:
188
194
  block.pop()
189
195
  slide[key] = "\n".join(block) + "\n" if block else ""
190
196
  elif val == "":
191
- # Could be `evidence:` list or an empty scalar.
192
- list_items: list[str] = []
193
- j = i + 1
194
- while j < n:
195
- bl = content_lines[j]
196
- if bl.strip() == "":
197
- j += 1
198
- continue
199
- if bl.lstrip().startswith("- "):
200
- list_items.append(bl.lstrip()[2:].strip())
201
- j += 1
202
- continue
203
- break
204
- if list_items:
205
- slide[key] = list_items
197
+ # A bare `key:` introduces an indented child block, which may be:
198
+ # - a list of scalars (`- one.md:1`) -> list[str]
199
+ # - a list of mappings (`- title_en: "..."`) -> list[dict]
200
+ # - a nested mapping (` left_title_en: …`) -> dict
201
+ # or, if no indented child follows, an empty scalar.
202
+ key_indent = len(raw) - len(raw.lstrip(" "))
203
+ block, j = _collect_indented_block(content_lines, i + 1, key_indent)
204
+ if block:
205
+ # `evidence` is always a flat list of free-form citation
206
+ # strings (e.g. `- TODO: see README.md:1`), so it must never be
207
+ # coerced into a mapping by the generic block parser.
208
+ if key in _SCALAR_LIST_KEYS:
209
+ slide[key] = _parse_scalar_list(block)
210
+ else:
211
+ slide[key] = _parse_block(block)
206
212
  i = j
207
213
  else:
208
214
  slide[key] = ""
@@ -212,6 +218,207 @@ def _populate_slide(slide: dict, content_lines: list[str]) -> None:
212
218
  i += 1
213
219
 
214
220
 
221
+ def _collect_indented_block(
222
+ lines: list[str], start: int, parent_indent: int
223
+ ) -> tuple[list[str], int]:
224
+ """
225
+ Gather lines that belong to the indented child block of a `key:` at
226
+ `parent_indent`. A line belongs to the block if it is blank or indented
227
+ strictly deeper than the parent key. Returns (block_lines, next_index).
228
+
229
+ Trailing blank lines are dropped so an all-blank block reads as empty.
230
+ """
231
+ block: list[str] = []
232
+ j = start
233
+ n = len(lines)
234
+ while j < n:
235
+ bl = lines[j]
236
+ if bl.strip() == "":
237
+ block.append("")
238
+ j += 1
239
+ continue
240
+ indent = len(bl) - len(bl.lstrip(" "))
241
+ if indent <= parent_indent:
242
+ break
243
+ block.append(bl)
244
+ j += 1
245
+ while block and block[-1] == "":
246
+ block.pop()
247
+ return block, j
248
+
249
+
250
+ def _parse_block(block: list[str]):
251
+ """
252
+ Parse an indented YAML-subset block into a list or a dict.
253
+
254
+ - If the first non-blank line starts with `- `, the block is a sequence.
255
+ Each `- ` opens a new item; a scalar follows `- ` directly (list of
256
+ scalars) or a `key: value` does (list of mappings, with subsequent
257
+ deeper-indented `key: value` lines folded into the same item).
258
+ - Otherwise the block is a mapping of `key: value` / `key:` (nested)
259
+ entries.
260
+
261
+ Scalars are coerced via `_coerce_scalar`. Nested `key:` with no inline
262
+ value recurse into another block (supports `compare`'s `left_items:`).
263
+ """
264
+ # Find the indentation of the block's own top level (first non-blank line).
265
+ first = next((b for b in block if b.strip() != ""), "")
266
+ base_indent = len(first) - len(first.lstrip(" "))
267
+
268
+ if first.lstrip().startswith("- "):
269
+ return _parse_sequence(block, base_indent)
270
+ return _parse_mapping(block, base_indent)
271
+
272
+
273
+ def _parse_sequence(block: list[str], base_indent: int) -> list:
274
+ """Parse a `- ...` sequence at `base_indent` into list[str] | list[dict]."""
275
+ items: list = []
276
+ i = 0
277
+ n = len(block)
278
+ while i < n:
279
+ line = block[i]
280
+ if line.strip() == "":
281
+ i += 1
282
+ continue
283
+ stripped = line.lstrip()
284
+ # Only treat a `- ` at the sequence's own indent as an item start.
285
+ indent = len(line) - len(stripped)
286
+ if indent == base_indent and stripped.startswith("- "):
287
+ # Strip the dash and ALL following spaces; `content_col` is the
288
+ # column where the item's first key actually begins, which equals
289
+ # the indent of the item's continuation lines in well-formed YAML.
290
+ after_dash = stripped[1:]
291
+ rest = after_dash.lstrip(" ")
292
+ content_col = indent + 1 + (len(after_dash) - len(rest))
293
+ if ":" in rest and not _looks_like_scalar(rest):
294
+ # List of mappings. Re-indent the inline key to `content_col`
295
+ # so the sub-block has one consistent base indent regardless of
296
+ # how the author spaced the `- ` (fixes hardcoded-indent
297
+ # mis-parse).
298
+ sub = [(" " * content_col) + rest]
299
+ i += 1
300
+ while i < n:
301
+ bl = block[i]
302
+ if bl.strip() == "":
303
+ sub.append("")
304
+ i += 1
305
+ continue
306
+ bi = len(bl) - len(bl.lstrip(" "))
307
+ if bi <= base_indent:
308
+ break
309
+ sub.append(bl)
310
+ i += 1
311
+ items.append(_parse_mapping(sub, content_col))
312
+ else:
313
+ items.append(_coerce_scalar(rest.strip()))
314
+ i += 1
315
+ else:
316
+ # Defensive: ignore stray lines that don't open an item.
317
+ i += 1
318
+ return items
319
+
320
+
321
+ def _parse_mapping(block: list[str], base_indent: int | None = None) -> dict:
322
+ """Parse `key: value` / `key:` (nested / block-literal) lines into a dict.
323
+
324
+ `base_indent` defaults to the indent of the block's first non-blank line so
325
+ callers need not compute it; only top-level keys at that indent are read,
326
+ deeper lines are folded into the value of the key they belong to.
327
+ """
328
+ out: dict = {}
329
+ i = 0
330
+ n = len(block)
331
+ if base_indent is None:
332
+ first = next((b for b in block if b.strip() != ""), "")
333
+ base_indent = len(first) - len(first.lstrip(" "))
334
+ while i < n:
335
+ line = block[i]
336
+ if line.strip() == "" or ":" not in line:
337
+ i += 1
338
+ continue
339
+ indent = len(line) - len(line.lstrip(" "))
340
+ if indent != base_indent:
341
+ # Belongs to a deeper structure handled by recursion; skip.
342
+ i += 1
343
+ continue
344
+ key, _, val = line.partition(":")
345
+ key = key.strip()
346
+ val = val.strip()
347
+ if val == "|":
348
+ block_lines, j = _collect_indented_block(block, i + 1, base_indent)
349
+ out[key] = _dedent_block_literal(block_lines)
350
+ i = j
351
+ elif val == "":
352
+ child, j = _collect_indented_block(block, i + 1, base_indent)
353
+ if child:
354
+ if key in _SCALAR_LIST_KEYS:
355
+ out[key] = _parse_scalar_list(child)
356
+ else:
357
+ out[key] = _parse_block(child)
358
+ i = j
359
+ else:
360
+ out[key] = ""
361
+ i += 1
362
+ else:
363
+ out[key] = _coerce_scalar(val)
364
+ i += 1
365
+ return out
366
+
367
+
368
+ def _dedent_block_literal(block_lines: list[str]) -> str:
369
+ """Strip the common leading indent of a `key: |` block literal and join the
370
+ lines, matching the top-level block-literal handling in `_populate_slide`."""
371
+ out: list[str] = []
372
+ common_indent: int | None = None
373
+ for bl in block_lines:
374
+ if bl.strip() == "":
375
+ out.append("")
376
+ continue
377
+ indent = len(bl) - len(bl.lstrip(" "))
378
+ if common_indent is None:
379
+ common_indent = indent
380
+ elif indent < common_indent:
381
+ common_indent = indent
382
+ if common_indent is None:
383
+ return ""
384
+ for bl in block_lines:
385
+ out.append(bl[common_indent:] if bl.strip() != "" else "")
386
+ while out and out[-1] == "":
387
+ out.pop()
388
+ return "\n".join(out) + "\n" if out else ""
389
+
390
+
391
+ def _parse_scalar_list(block: list[str]) -> list:
392
+ """Parse a `- item` sequence as a flat list of scalar strings, regardless of
393
+ whether an item contains a colon (used for `evidence:` citations)."""
394
+ items: list = []
395
+ for line in block:
396
+ s = line.strip()
397
+ if s.startswith("- "):
398
+ items.append(_coerce_scalar(s[2:].strip()))
399
+ return items
400
+
401
+
402
+ def _looks_like_scalar(rest: str) -> bool:
403
+ """A `- ` item is a scalar (not a mapping) when the text before the first
404
+ colon looks like a value rather than a bare key — e.g. a path `README.md:42`
405
+ where the colon is part of the value. Heuristic: it's a mapping only when
406
+ the segment before the colon is a bare identifier (word chars / underscore)
407
+ and is immediately followed by a space or end-of-string."""
408
+ head, sep, tail = rest.partition(":")
409
+ if not sep:
410
+ return True
411
+ head = head.strip()
412
+ if not head or not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", head):
413
+ return True
414
+ # `key:value` with no space is treated as a scalar (e.g. `README.md:42`
415
+ # never reaches here since `.` breaks the identifier rule, but a bare
416
+ # `word:7` would — require a space after the colon for a mapping).
417
+ if tail and not tail.startswith(" ") and not tail == "":
418
+ return True
419
+ return False
420
+
421
+
215
422
  # ─────────────────────────── Mustache subset ─────────────────────────────────
216
423
 
217
424
 
@@ -418,6 +625,84 @@ def _inline(text: str) -> str:
418
625
  return text
419
626
 
420
627
 
628
+ # ──────────────────────────── layout routing ────────────────────────────────
629
+
630
+
631
+ # The default layout when a slide omits `layout:` — preserves the pre-layout
632
+ # behaviour (a plain lang-en / lang-zh body block).
633
+ DEFAULT_LAYOUT = "plain"
634
+
635
+
636
+ class LayoutResolver:
637
+ """
638
+ Map a slide's `layout` name to its component partial path.
639
+
640
+ Partials live in `lib/slides/components/<layout>.html` next to this module.
641
+ The resolver does not read the file — it only computes and validates the
642
+ path so callers get a clear `Unknown layout: ...` error before any I/O.
643
+ """
644
+
645
+ def __init__(self, components_dir: Path | None = None) -> None:
646
+ if components_dir is None:
647
+ components_dir = Path(__file__).resolve().parent / "slides" / "components"
648
+ self.components_dir = components_dir
649
+
650
+ def available(self) -> list[str]:
651
+ """Return the sorted layout names backed by a `<name>.html` partial."""
652
+ if not self.components_dir.is_dir():
653
+ return []
654
+ names = [
655
+ p.stem
656
+ for p in self.components_dir.glob("*.html")
657
+ ]
658
+ # Locale-independent ordering: plain (the default/fallback) first, then
659
+ # the rest in plain ASCII sort so the error message is deterministic
660
+ # across CI locales.
661
+ rest = sorted(n for n in names if n != DEFAULT_LAYOUT)
662
+ head = [DEFAULT_LAYOUT] if DEFAULT_LAYOUT in names else []
663
+ return head + rest
664
+
665
+ def resolve(self, layout: str) -> Path:
666
+ """
667
+ Return the partial path for `layout`, or raise ValueError with the list
668
+ of available layouts when no matching partial file exists.
669
+
670
+ Layout names are restricted to `[a-z0-9-]+` so a malicious or malformed
671
+ `layout:` field (e.g. `../../etc/passwd`) can never escape
672
+ `components_dir` — anything else is reported as an unknown layout.
673
+ """
674
+ if not re.fullmatch(r"[a-z0-9-]+", layout):
675
+ avail = ", ".join(self.available())
676
+ raise ValueError(f"Unknown layout: {layout}; available: {avail}")
677
+ path = self.components_dir / f"{layout}.html"
678
+ if not path.is_file():
679
+ avail = ", ".join(self.available())
680
+ raise ValueError(f"Unknown layout: {layout}; available: {avail}")
681
+ return path
682
+
683
+
684
+ def render_slide_inner(slide: dict, resolver: LayoutResolver) -> str:
685
+ """
686
+ Resolve the slide's layout to a partial, render that partial against the
687
+ slide dict, and return the inner HTML to inject into the main template's
688
+ slide slot.
689
+
690
+ The result is stripped of the partial's leading `<!-- ... -->` doc comment
691
+ and of surrounding blank lines so that, for the default `plain` layout, the
692
+ output is byte-identical to the pre-layout template body block.
693
+ """
694
+ layout = slide.get("layout") or DEFAULT_LAYOUT
695
+ partial_path = resolver.resolve(layout)
696
+ partial = partial_path.read_text(encoding="utf-8")
697
+ # Drop a leading HTML doc comment (the `<!-- layout: requires ... -->`
698
+ # header every partial carries) so it never leaks into the rendered deck.
699
+ partial = re.sub(
700
+ r"\A\s*<!--.*?-->(?:\s*\n)?", "", partial, count=1, flags=re.DOTALL
701
+ )
702
+ rendered = mustache(partial, slide)
703
+ return rendered.strip("\n")
704
+
705
+
421
706
  # ───────────────────────────── render_deck ──────────────────────────────────
422
707
 
423
708
 
@@ -429,6 +714,8 @@ def render_deck(deck_path: Path, template_path: Path) -> str:
429
714
  fm, body = parse_frontmatter(src)
430
715
  slides = parse_slides(body)
431
716
 
717
+ resolver = LayoutResolver()
718
+
432
719
  # Pre-render body_en / body_zh markdown into HTML strings so the template
433
720
  # can drop them in via {{{body_en_html}}}.
434
721
  for slide in slides:
@@ -437,6 +724,9 @@ def render_deck(deck_path: Path, template_path: Path) -> str:
437
724
  # Provide an `evidence` flag for inverted-section use in templates.
438
725
  if "evidence" not in slide:
439
726
  slide["evidence"] = []
727
+ # US-DECK-018: route each slide through its layout partial and inject
728
+ # the result via the main template's {{{slide_inner_html}}} slot.
729
+ slide["slide_inner_html"] = render_slide_inner(slide, resolver)
440
730
 
441
731
  context: dict = dict(fm)
442
732
  context["slides"] = slides
@@ -45,6 +45,76 @@ REQUIRED_FRONTMATTER = (
45
45
  )
46
46
  REQUIRED_SLIDE_KEYS = ("title_en", "title_zh", "body_en", "body_zh")
47
47
 
48
+ # Every slide must always carry a bilingual title.
49
+ REQUIRED_TITLE_KEYS = ("title_en", "title_zh")
50
+
51
+ # ── Layout schema (US-DECK-017) ──────────────────────────────────────────────
52
+ #
53
+ # The default layout (and the implicit layout for slides with no `layout:`
54
+ # field) is `plain`, which keeps the Phase 1.5 `body_en` / `body_zh` contract.
55
+ # Every other layout declares its required fields so the validator can flag a
56
+ # missing field with a concrete line number + an example snippet.
57
+ #
58
+ # Field contracts mirror the Mustache partials shipped by US-DECK-016 in
59
+ # lib/slides/components/<layout>.html — keep the two in sync.
60
+
61
+ DEFAULT_LAYOUT = "plain"
62
+
63
+ # scalar required fields per layout (besides the always-required title_en/zh).
64
+ _LAYOUT_SCALAR_FIELDS = {
65
+ "plain": ("body_en", "body_zh"),
66
+ "cards-2": (),
67
+ "cards-3": (),
68
+ "cards-4": (),
69
+ "compare": (
70
+ "left_title_en",
71
+ "left_title_zh",
72
+ "right_title_en",
73
+ "right_title_zh",
74
+ ),
75
+ "pipeline": (),
76
+ "timeline": (),
77
+ "quote": ("text_en", "text_zh"),
78
+ "highlight": ("body_en", "body_zh"),
79
+ }
80
+
81
+ # list-of-mapping required fields: layout -> (list_key, (item_field, ...)).
82
+ _LAYOUT_LIST_FIELDS = {
83
+ "cards-2": ("cards", ("title_en", "title_zh", "body_en", "body_zh")),
84
+ "cards-3": ("cards", ("title_en", "title_zh", "body_en", "body_zh")),
85
+ "cards-4": ("cards", ("title_en", "title_zh", "body_en", "body_zh")),
86
+ "pipeline": ("stages", ("title_en", "title_zh", "desc_en", "desc_zh")),
87
+ "timeline": ("items", ("title_en", "title_zh", "body_en", "body_zh")),
88
+ "compare": ("left_items", ("text_en", "text_zh")),
89
+ }
90
+ # compare also requires a right_items list with the same item shape.
91
+ _LAYOUT_EXTRA_LISTS = {
92
+ "compare": (("right_items", ("text_en", "text_zh")),),
93
+ }
94
+
95
+ LAYOUT_WHITELIST = tuple(_LAYOUT_SCALAR_FIELDS.keys())
96
+
97
+ # Minimal example snippet per layout, shown when a required field is missing.
98
+ _LAYOUT_EXAMPLES = {
99
+ "cards-2": 'cards:\n - title_en: "..."\n title_zh: "..."\n '
100
+ 'body_en: "..."\n body_zh: "..."',
101
+ "cards-3": 'cards:\n - title_en: "..."\n title_zh: "..."\n '
102
+ 'body_en: "..."\n body_zh: "..."',
103
+ "cards-4": 'cards:\n - title_en: "..."\n title_zh: "..."\n '
104
+ 'body_en: "..."\n body_zh: "..."',
105
+ "compare": 'left_title_en: "..."\nleft_title_zh: "..."\n'
106
+ 'right_title_en: "..."\nright_title_zh: "..."\n'
107
+ 'left_items:\n - text_en: "..."\n text_zh: "..."\n'
108
+ 'right_items:\n - text_en: "..."\n text_zh: "..."',
109
+ "pipeline": 'stages:\n - title_en: "..."\n title_zh: "..."\n '
110
+ 'desc_en: "..."\n desc_zh: "..."',
111
+ "timeline": 'items:\n - title_en: "..."\n title_zh: "..."\n '
112
+ 'body_en: "..."\n body_zh: "..."',
113
+ "quote": 'text_en: "..."\ntext_zh: "..."',
114
+ "highlight": 'body_en: |\n ...\nbody_zh: |\n ...',
115
+ "plain": 'body_en: |\n ...\nbody_zh: |\n ...',
116
+ }
117
+
48
118
 
49
119
  def _load_renderer():
50
120
  """Import lib/slides-render.py as a module (hyphenated filename, so we
@@ -79,8 +149,34 @@ def validate_frontmatter(fm: dict) -> list[str]:
79
149
  return errors
80
150
 
81
151
 
82
- def validate_slides(fm: dict, slides: list[dict]) -> list[str]:
152
+ def _slide_header_lines(src: str) -> dict:
153
+ """Map slide number -> 1-based source line of its `## Slide N` header."""
154
+ import re
155
+
156
+ header_re = re.compile(r"^##\s+Slide\s+(\d+)\s*$")
157
+ out: dict = {}
158
+ for idx, line in enumerate(src.splitlines(), start=1):
159
+ m = header_re.match(line)
160
+ if m:
161
+ out[int(m.group(1))] = idx
162
+ return out
163
+
164
+
165
+ def slide_layout(slide: dict) -> str:
166
+ """Return a slide's declared layout, defaulting to `plain` when absent."""
167
+ layout = slide.get("layout")
168
+ if not layout:
169
+ return DEFAULT_LAYOUT
170
+ return str(layout)
171
+
172
+
173
+ def _is_empty(v) -> bool:
174
+ return v is None or (isinstance(v, str) and v.strip() == "")
175
+
176
+
177
+ def validate_slides(fm: dict, slides: list[dict], line_of: dict | None = None) -> list[str]:
83
178
  errors: list[str] = []
179
+ line_of = line_of or {}
84
180
  actual = len(slides)
85
181
  declared = fm.get("total_slides")
86
182
  if isinstance(declared, int) and declared != actual:
@@ -89,14 +185,98 @@ def validate_slides(fm: dict, slides: list[dict]) -> list[str]:
89
185
  f"found {actual} `## Slide N` sections"
90
186
  )
91
187
  for slide in slides:
92
- n = slide.get("number", "?")
93
- for key in REQUIRED_SLIDE_KEYS:
94
- v = slide.get(key)
95
- if v is None or (isinstance(v, str) and v.strip() == ""):
96
- errors.append(f"slide {n}: missing or empty {key}")
188
+ errors += validate_slide_layout(slide, line_of)
189
+ return errors
190
+
191
+
192
+ def validate_slide_layout(slide: dict, line_of: dict | None = None) -> list[str]:
193
+ """
194
+ Validate a single slide's required fields for its declared layout.
195
+
196
+ - Title fields are always required.
197
+ - A missing `layout:` is treated as `plain` (no error — backward compat).
198
+ - An unknown layout name is rejected against the whitelist.
199
+ - Per-layout scalar + list-of-mapping required fields are checked, with the
200
+ concrete `deck.md:<line>` location (header line of the slide) and a field
201
+ example for the layout.
202
+ """
203
+ line_of = line_of or {}
204
+ errors: list[str] = []
205
+ n = slide.get("number", "?")
206
+ line = line_of.get(n)
207
+ loc = f"deck.md:{line}" if line else f"slide {n}"
208
+
209
+ # Title is always required regardless of layout.
210
+ for key in REQUIRED_TITLE_KEYS:
211
+ if _is_empty(slide.get(key)):
212
+ errors.append(f"slide {n} ({loc}): missing or empty {key}")
213
+
214
+ layout = slide_layout(slide)
215
+ if layout not in LAYOUT_WHITELIST:
216
+ errors.append(
217
+ f"slide {n} ({loc}): unknown layout {layout!r}; "
218
+ f"allowed: {', '.join(LAYOUT_WHITELIST)}"
219
+ )
220
+ return errors
221
+
222
+ example = _LAYOUT_EXAMPLES.get(layout, "")
223
+
224
+ def missing(field: str) -> None:
225
+ msg = f"slide {n} ({loc}): layout {layout!r} requires field {field!r}"
226
+ if example:
227
+ msg += f"\nHint: example for {layout}:\n{example}"
228
+ errors.append(msg)
229
+
230
+ # Scalar required fields.
231
+ for field in _LAYOUT_SCALAR_FIELDS.get(layout, ()):
232
+ if _is_empty(slide.get(field)):
233
+ missing(field)
234
+
235
+ # List-of-mapping required fields.
236
+ list_specs: list = []
237
+ if layout in _LAYOUT_LIST_FIELDS:
238
+ list_specs.append(_LAYOUT_LIST_FIELDS[layout])
239
+ list_specs += list(_LAYOUT_EXTRA_LISTS.get(layout, ()))
240
+ for list_key, item_fields in list_specs:
241
+ items = slide.get(list_key)
242
+ if not isinstance(items, list) or not items:
243
+ missing(list_key)
244
+ continue
245
+ for idx, item in enumerate(items, start=1):
246
+ if not isinstance(item, dict):
247
+ errors.append(
248
+ f"slide {n} ({loc}): {list_key}[{idx}] must be a mapping "
249
+ f"with {', '.join(item_fields)}"
250
+ )
251
+ continue
252
+ for f in item_fields:
253
+ if _is_empty(item.get(f)):
254
+ errors.append(
255
+ f"slide {n} ({loc}): {list_key}[{idx}] missing {f!r}"
256
+ )
97
257
  return errors
98
258
 
99
259
 
260
+ def lint_slide_layout(slide: dict) -> list[str]:
261
+ """
262
+ Non-fatal layout warnings (returned separately so the caller can print but
263
+ not fail). Currently: declaring a rich layout while also carrying a stray
264
+ `body_en` / `body_zh` that the layout will not consume (possible waste).
265
+ """
266
+ warnings: list[str] = []
267
+ layout = slide_layout(slide)
268
+ if layout in ("plain", "highlight"):
269
+ return warnings # these layouts legitimately consume body_en/zh
270
+ n = slide.get("number", "?")
271
+ for f in ("body_en", "body_zh"):
272
+ if not _is_empty(slide.get(f)):
273
+ warnings.append(
274
+ f"slide {n}: layout {layout!r} does not use {f!r}; "
275
+ f"the field will be ignored (possible waste)"
276
+ )
277
+ return warnings
278
+
279
+
100
280
  def evaluate_grounding(slides: list[dict]) -> tuple[int, int, bool]:
101
281
  """
102
282
  Return (citations, threshold, meets_threshold).
@@ -141,15 +321,23 @@ def main(argv: list[str]) -> int:
141
321
  err(f"failed to parse deck.md: {e}", "解析 deck.md 失败")
142
322
  return 3
143
323
 
324
+ line_of = _slide_header_lines(src)
325
+
144
326
  schema_errors: list[str] = []
145
327
  schema_errors += validate_frontmatter(fm)
146
- schema_errors += validate_slides(fm, slides)
328
+ schema_errors += validate_slides(fm, slides, line_of)
147
329
 
148
330
  if schema_errors:
149
331
  for e in schema_errors:
150
332
  err(e)
151
333
  return 1
152
334
 
335
+ # Non-fatal layout lint warnings (e.g. a rich layout carrying a stray body
336
+ # that it will not consume). These print but do not change the exit code.
337
+ for slide in slides:
338
+ for w in lint_slide_layout(slide):
339
+ err(f"⚠️ {w}")
340
+
153
341
  citations, threshold, ok = evaluate_grounding(slides)
154
342
  if not ok:
155
343
  err(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanyao/roll",
3
- "version": "2026.529.5",
3
+ "version": "2026.601.2",
4
4
  "description": "Roll — Roll out features with AI agents",
5
5
  "scripts": {
6
6
  "test": "bash tests/run.sh"