@seanyao/roll 0.5.0 → 2.602.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 (181) hide show
  1. package/CHANGELOG.md +717 -0
  2. package/LICENSE +21 -0
  3. package/README.md +65 -165
  4. package/bin/dream-test-quality-scan +110 -0
  5. package/bin/roll +14897 -815
  6. package/conventions/config.yaml +17 -1
  7. package/conventions/global/AGENTS.md +146 -100
  8. package/conventions/global/CLAUDE.md +1 -21
  9. package/conventions/global/GEMINI.md +8 -22
  10. package/conventions/global/project_rules.md +9 -0
  11. package/conventions/templates/backend-service/AGENTS.md +30 -81
  12. package/conventions/templates/backend-service/GEMINI.md +3 -3
  13. package/conventions/templates/backend-service/project_rules.md +16 -0
  14. package/conventions/templates/cli/AGENTS.md +31 -58
  15. package/conventions/templates/cli/CLAUDE.md +3 -5
  16. package/conventions/templates/cli/GEMINI.md +3 -3
  17. package/conventions/templates/cli/project_rules.md +16 -0
  18. package/conventions/templates/frontend-only/AGENTS.md +29 -64
  19. package/conventions/templates/frontend-only/GEMINI.md +3 -3
  20. package/conventions/templates/frontend-only/project_rules.md +14 -0
  21. package/conventions/templates/fullstack/AGENTS.md +31 -79
  22. package/conventions/templates/fullstack/CLAUDE.md +1 -1
  23. package/conventions/templates/fullstack/GEMINI.md +3 -3
  24. package/conventions/templates/fullstack/project_rules.md +15 -0
  25. package/lib/README.md +42 -0
  26. package/lib/__pycache__/github_sync.cpython-314.pyc +0 -0
  27. package/lib/__pycache__/loop-fmt.cpython-314.pyc +0 -0
  28. package/lib/__pycache__/loop_result_eval.cpython-314.pyc +0 -0
  29. package/lib/__pycache__/loop_unstick.cpython-314.pyc +0 -0
  30. package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
  31. package/lib/__pycache__/prices_fetcher.cpython-314.pyc +0 -0
  32. package/lib/__pycache__/roll-home.cpython-314.pyc +0 -0
  33. package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
  34. package/lib/__pycache__/roll_git.cpython-314.pyc +0 -0
  35. package/lib/__pycache__/roll_render.cpython-314.pyc +0 -0
  36. package/lib/__pycache__/slides-render.cpython-314.pyc +0 -0
  37. package/lib/agent_usage/README.md +49 -0
  38. package/lib/agent_usage/__init__.py +108 -0
  39. package/lib/agent_usage/__pycache__/__init__.cpython-314.pyc +0 -0
  40. package/lib/agent_usage/__pycache__/gemini.cpython-314.pyc +0 -0
  41. package/lib/agent_usage/__pycache__/kimi.cpython-314.pyc +0 -0
  42. package/lib/agent_usage/__pycache__/openai.cpython-314.pyc +0 -0
  43. package/lib/agent_usage/__pycache__/pi.cpython-314.pyc +0 -0
  44. package/lib/agent_usage/__pycache__/pi_emit.cpython-314.pyc +0 -0
  45. package/lib/agent_usage/__pycache__/qwen.cpython-314.pyc +0 -0
  46. package/lib/agent_usage/gemini.py +127 -0
  47. package/lib/agent_usage/kimi.py +278 -0
  48. package/lib/agent_usage/kimi_emit.py +123 -0
  49. package/lib/agent_usage/openai.py +126 -0
  50. package/lib/agent_usage/pi.py +200 -0
  51. package/lib/agent_usage/pi_emit.py +135 -0
  52. package/lib/agent_usage/qwen.py +128 -0
  53. package/lib/backfill-pi-usage.py +243 -0
  54. package/lib/changelog_audit.py +155 -0
  55. package/lib/changelog_generate.py +263 -0
  56. package/lib/context_feed_budget.sh +194 -0
  57. package/lib/github_sync.py +876 -0
  58. package/lib/i18n/README.md +54 -0
  59. package/lib/i18n/agent.sh +75 -0
  60. package/lib/i18n/alert.sh +20 -0
  61. package/lib/i18n/backlog.sh +96 -0
  62. package/lib/i18n/brief.sh +5 -0
  63. package/lib/i18n/changelog.sh +5 -0
  64. package/lib/i18n/ci.sh +15 -0
  65. package/lib/i18n/debug.sh +0 -0
  66. package/lib/i18n/doctor.sh +44 -0
  67. package/lib/i18n/dream.sh +0 -0
  68. package/lib/i18n/init.sh +91 -0
  69. package/lib/i18n/lang.sh +10 -0
  70. package/lib/i18n/loop.sh +140 -0
  71. package/lib/i18n/migrate.sh +74 -0
  72. package/lib/i18n/offboard.sh +31 -0
  73. package/lib/i18n/onboard.sh +0 -0
  74. package/lib/i18n/peer.sh +41 -0
  75. package/lib/i18n/peer_help.sh +25 -0
  76. package/lib/i18n/peer_reset.sh +7 -0
  77. package/lib/i18n/peer_status.sh +5 -0
  78. package/lib/i18n/prices.sh +3 -0
  79. package/lib/i18n/prices_refresh.sh +17 -0
  80. package/lib/i18n/prices_show.sh +7 -0
  81. package/lib/i18n/propose.sh +0 -0
  82. package/lib/i18n/release.sh +0 -0
  83. package/lib/i18n/research.sh +0 -0
  84. package/lib/i18n/review_pr.sh +0 -0
  85. package/lib/i18n/sentinel.sh +0 -0
  86. package/lib/i18n/setup.sh +3 -0
  87. package/lib/i18n/shared.sh +157 -0
  88. package/lib/i18n/skills/roll-brief.sh +47 -0
  89. package/lib/i18n/skills/roll-build.sh +97 -0
  90. package/lib/i18n/skills/roll-design.sh +18 -0
  91. package/lib/i18n/skills/roll-fix.sh +53 -0
  92. package/lib/i18n/skills/roll-loop.sh +28 -0
  93. package/lib/i18n/skills/roll-onboard.sh +33 -0
  94. package/lib/i18n/skills_catalog.sh +30 -0
  95. package/lib/i18n/slides.sh +3 -0
  96. package/lib/i18n/slides_build.sh +38 -0
  97. package/lib/i18n/slides_delete.sh +19 -0
  98. package/lib/i18n/slides_list.sh +14 -0
  99. package/lib/i18n/slides_logs.sh +12 -0
  100. package/lib/i18n/slides_new.sh +15 -0
  101. package/lib/i18n/slides_preview.sh +14 -0
  102. package/lib/i18n/slides_templates.sh +7 -0
  103. package/lib/i18n/status.sh +21 -0
  104. package/lib/i18n/update.sh +24 -0
  105. package/lib/i18n.sh +211 -0
  106. package/lib/loop-exit-summary.py +393 -0
  107. package/lib/loop-fmt.py +589 -0
  108. package/lib/loop_pick_agent.py +316 -0
  109. package/lib/loop_result_eval.py +469 -0
  110. package/lib/loop_unstick.py +180 -0
  111. package/lib/model_prices.py +186 -0
  112. package/lib/prices/README.md +35 -0
  113. package/lib/prices/snapshot-2026-05-22.json +22 -0
  114. package/lib/prices/snapshot-2026-05-23-deepseek.json +15 -0
  115. package/lib/prices/snapshot-2026-05-23-kimi.json +14 -0
  116. package/lib/prices_fetcher.py +285 -0
  117. package/lib/roll-backlog.py +225 -0
  118. package/lib/roll-brief.py +286 -0
  119. package/lib/roll-help.py +158 -0
  120. package/lib/roll-home.py +556 -0
  121. package/lib/roll-init.py +156 -0
  122. package/lib/roll-loop-status.py +1683 -0
  123. package/lib/roll-loop-story.py +191 -0
  124. package/lib/roll-onboard-render.py +378 -0
  125. package/lib/roll-peer.py +252 -0
  126. package/lib/roll-plan-validate.py +386 -0
  127. package/lib/roll-setup.py +102 -0
  128. package/lib/roll-status.py +367 -0
  129. package/lib/roll_git.py +41 -0
  130. package/lib/roll_render.py +414 -0
  131. package/lib/slides/components/README.md +123 -0
  132. package/lib/slides/components/cards-2.html +9 -0
  133. package/lib/slides/components/cards-3.html +9 -0
  134. package/lib/slides/components/cards-4.html +9 -0
  135. package/lib/slides/components/compare.html +22 -0
  136. package/lib/slides/components/highlight.html +9 -0
  137. package/lib/slides/components/pipeline.html +12 -0
  138. package/lib/slides/components/plain.html +7 -0
  139. package/lib/slides/components/quote.html +4 -0
  140. package/lib/slides/components/timeline.html +9 -0
  141. package/lib/slides/templates/introduction-v3.html +571 -0
  142. package/lib/slides/templates/pitch.html +0 -0
  143. package/lib/slides-render.py +778 -0
  144. package/lib/slides-validate.py +357 -0
  145. package/lib/test_quality_gate.py +143 -0
  146. package/package.json +8 -7
  147. package/skills/roll-.changelog/SKILL.md +406 -33
  148. package/skills/roll-.clarify/SKILL.md +5 -2
  149. package/skills/roll-.dream/SKILL.md +374 -0
  150. package/skills/roll-.echo/SKILL.md +5 -2
  151. package/skills/roll-.qa/SKILL.md +57 -3
  152. package/skills/roll-.review/SKILL.md +42 -3
  153. package/skills/roll-brief/SKILL.md +209 -0
  154. package/skills/roll-build/SKILL.md +308 -63
  155. package/skills/roll-debug/SKILL.md +341 -162
  156. package/skills/roll-debug/injectable-bb.js +263 -0
  157. package/skills/roll-deck/SKILL.md +296 -0
  158. package/skills/roll-design/ENGINEERING_CHECKLIST.md +1 -1
  159. package/skills/roll-design/SKILL.md +727 -94
  160. package/skills/roll-doc/SKILL.md +595 -0
  161. package/skills/roll-doctor/SKILL.md +192 -0
  162. package/skills/roll-fix/SKILL.md +149 -32
  163. package/skills/{roll-jot → roll-idea}/SKILL.md +18 -10
  164. package/skills/roll-loop/SKILL.md +578 -0
  165. package/skills/roll-notes/SKILL.md +103 -0
  166. package/skills/roll-onboard/SKILL.md +234 -0
  167. package/skills/roll-peer/SKILL.md +336 -0
  168. package/skills/roll-propose/SKILL.md +157 -0
  169. package/skills/roll-review-pr/SKILL.md +58 -0
  170. package/skills/roll-sentinel/SKILL.md +11 -2
  171. package/skills/roll-spar/SKILL.md +8 -6
  172. package/template/.github/workflows/ci.yml +5 -2
  173. package/template/AGENTS.md +20 -74
  174. package/skills/roll-research/SKILL.md +0 -307
  175. package/skills/roll-research/references/schema.json +0 -162
  176. package/skills/roll-research/scripts/md_to_pdf.py +0 -289
  177. package/tools/roll-fetch/SKILL.md +0 -182
  178. package/tools/roll-fetch/package.json +0 -15
  179. package/tools/roll-fetch/smart-web-fetch.js +0 -558
  180. package/tools/roll-probe/SKILL.md +0 -84
  181. /package/template/{BACKLOG.md → .roll/backlog.md} +0 -0
@@ -0,0 +1,357 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ US-DECK-002: deck.md schema + grounding validator.
4
+
5
+ Reads a `deck.md` file, parses it with the same parser used by the renderer
6
+ (`lib/slides-render.py`), and verifies:
7
+
8
+ 1. Required frontmatter fields are present:
9
+ template, slug, title_en, title_zh, total_slides, created
10
+ 2. frontmatter.total_slides matches the actual `## Slide N` section count.
11
+ 3. Each slide has non-empty title_en / title_zh / body_en / body_zh.
12
+ 4. Grounding threshold: at least ceil(N/3) evidence citations across all
13
+ slides (i.e. >= 1 per 3 slides). If the deck has fewer, the validator
14
+ exits non-zero with a ⚠️ grounding warning so callers (e.g.
15
+ `roll slides build`) can flag it.
16
+
17
+ Usage:
18
+ python3 slides-validate.py <deck.md>
19
+
20
+ Exit codes:
21
+ 0 valid (schema OK + grounding threshold met)
22
+ 1 schema error (missing field, mismatch, missing slide body, etc.)
23
+ 2 grounding warning (schema OK but evidence below threshold)
24
+ 3 file not found / unreadable / parse error
25
+
26
+ Error messages are written to stderr in English + Chinese (Roll bilingual
27
+ convention).
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import importlib.util
33
+ import math
34
+ import sys
35
+ from pathlib import Path
36
+
37
+
38
+ REQUIRED_FRONTMATTER = (
39
+ "template",
40
+ "slug",
41
+ "title_en",
42
+ "title_zh",
43
+ "total_slides",
44
+ "created",
45
+ )
46
+ REQUIRED_SLIDE_KEYS = ("title_en", "title_zh", "body_en", "body_zh")
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
+
118
+
119
+ def _load_renderer():
120
+ """Import lib/slides-render.py as a module (hyphenated filename, so we
121
+ can't `import slides_render` directly)."""
122
+ here = Path(__file__).resolve().parent
123
+ spec = importlib.util.spec_from_file_location(
124
+ "slides_render", str(here / "slides-render.py")
125
+ )
126
+ if spec is None or spec.loader is None:
127
+ raise ImportError("could not load slides-render.py")
128
+ mod = importlib.util.module_from_spec(spec)
129
+ spec.loader.exec_module(mod)
130
+ return mod
131
+
132
+
133
+ def err(msg_en: str, msg_zh: str = "") -> None:
134
+ print(f"[slides-validate] {msg_en}", file=sys.stderr)
135
+ if msg_zh:
136
+ print(f"[slides-validate] {msg_zh}", file=sys.stderr)
137
+
138
+
139
+ def validate_frontmatter(fm: dict) -> list[str]:
140
+ errors: list[str] = []
141
+ for key in REQUIRED_FRONTMATTER:
142
+ if key not in fm or fm[key] == "" or fm[key] is None:
143
+ errors.append(f"missing required frontmatter field: {key}")
144
+ if "total_slides" in fm and not isinstance(fm["total_slides"], int):
145
+ errors.append(
146
+ f"total_slides must be an integer, got "
147
+ f"{type(fm['total_slides']).__name__}: {fm['total_slides']!r}"
148
+ )
149
+ return errors
150
+
151
+
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]:
178
+ errors: list[str] = []
179
+ line_of = line_of or {}
180
+ actual = len(slides)
181
+ declared = fm.get("total_slides")
182
+ if isinstance(declared, int) and declared != actual:
183
+ errors.append(
184
+ f"total_slides mismatch: frontmatter declares {declared} but "
185
+ f"found {actual} `## Slide N` sections"
186
+ )
187
+ for slide in slides:
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
+ )
257
+ return errors
258
+
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
+
280
+ def evaluate_grounding(slides: list[dict]) -> tuple[int, int, bool]:
281
+ """
282
+ Return (citations, threshold, meets_threshold).
283
+
284
+ The threshold is `ceil(len(slides) / 3)` — i.e. at least one evidence
285
+ citation per three slides. An empty deck trivially meets the threshold
286
+ (threshold = 0).
287
+ """
288
+ citations = 0
289
+ for slide in slides:
290
+ ev = slide.get("evidence")
291
+ if isinstance(ev, list):
292
+ citations += len(ev)
293
+ threshold = math.ceil(len(slides) / 3) if slides else 0
294
+ return citations, threshold, citations >= threshold
295
+
296
+
297
+ def main(argv: list[str]) -> int:
298
+ if len(argv) < 2:
299
+ err(
300
+ "usage: slides-validate.py <deck.md>",
301
+ "用法: slides-validate.py <deck.md>",
302
+ )
303
+ return 3
304
+
305
+ path = Path(argv[1])
306
+ if not path.is_file():
307
+ err(f"deck file not found: {path}", f"未找到 deck 文件:{path}")
308
+ return 3
309
+
310
+ try:
311
+ renderer = _load_renderer()
312
+ except Exception as e:
313
+ err(f"could not load renderer module: {e}")
314
+ return 3
315
+
316
+ try:
317
+ src = path.read_text(encoding="utf-8")
318
+ fm, body = renderer.parse_frontmatter(src)
319
+ slides = renderer.parse_slides(body)
320
+ except (ValueError, OSError) as e:
321
+ err(f"failed to parse deck.md: {e}", "解析 deck.md 失败")
322
+ return 3
323
+
324
+ line_of = _slide_header_lines(src)
325
+
326
+ schema_errors: list[str] = []
327
+ schema_errors += validate_frontmatter(fm)
328
+ schema_errors += validate_slides(fm, slides, line_of)
329
+
330
+ if schema_errors:
331
+ for e in schema_errors:
332
+ err(e)
333
+ return 1
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
+
341
+ citations, threshold, ok = evaluate_grounding(slides)
342
+ if not ok:
343
+ err(
344
+ f"⚠️ grounding below threshold: {citations} evidence citation(s) "
345
+ f"for {len(slides)} slides (need >= {threshold}). "
346
+ f"Each slide group of 3 must include at least one evidence entry.",
347
+ f"⚠️ 证据引用不足:{len(slides)} 张幻灯片仅有 {citations} 条 evidence,"
348
+ f"至少需要 {threshold} 条(每 3 张 ≥ 1 条)。",
349
+ )
350
+ return 2
351
+
352
+ # Valid — silent success.
353
+ return 0
354
+
355
+
356
+ if __name__ == "__main__":
357
+ sys.exit(main(sys.argv))
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env python3
2
+ """Test quality merge gate (US-QA-012).
3
+
4
+ Scan bats test files for rubric ❼ (inline external-tool behaviour) and ❽
5
+ (file outside this repo) violations. Loop's auto-merge runs this between
6
+ CI green and merge; non-zero exit holds the PR until either the test is
7
+ fixed or PR description carries `[skip-test-quality]` (US-QA-013).
8
+
9
+ Usage:
10
+ test_quality_gate.py [--skip] <bats-file> [<bats-file> …]
11
+
12
+ Exit:
13
+ 0 — clean OR --skip flag set
14
+ 1 — one or more violations
15
+ 2 — usage error
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ import sys
21
+ from pathlib import Path
22
+ from typing import List, Tuple
23
+
24
+
25
+ # ❼ — inline external-tool patterns. We flag when a single line contains
26
+ # TWO OR MORE of these distinct tool markers, which signals a hand-rolled
27
+ # pipeline duplicating what a project helper should own. A lone `grep -q`
28
+ # or `awk` (no pipe-chain) is fine.
29
+ INLINE_TOOL_PATTERNS = [
30
+ re.compile(r"\bsed\s+[^|]*[s/]"), # sed with substitution / address
31
+ re.compile(r"\bawk\s+'"), # awk with script
32
+ re.compile(r"\bgrep\s+-[a-zA-Z]*o"), # grep -o / -oE (extraction)
33
+ re.compile(r"\bfind\s+[^|]*-name"), # find -name (path scanning)
34
+ re.compile(r"\bcut\s+-f"), # cut -f (column extraction)
35
+ re.compile(r"\btr\s+-d"), # tr -d (char deletion)
36
+ ]
37
+
38
+ # ❽ — paths outside this repo. We flag `~/.<name>` (dotfile dirs) and
39
+ # absolute system paths. `BATS_TMPDIR` is the sandbox marker and is fine.
40
+ OUTSIDE_PATTERNS = [
41
+ re.compile(r"~/\.[A-Za-z]"), # ~/.codex, ~/.kimi, ~/.roll, etc.
42
+ re.compile(r"(?<![A-Za-z0-9])/etc/[A-Za-z]"),
43
+ re.compile(r"(?<![A-Za-z0-9])/usr/[A-Za-z]"),
44
+ re.compile(r"(?<![A-Za-z0-9])/var/[A-Za-z]"),
45
+ ]
46
+ OUTSIDE_ALLOW = re.compile(r"BATS_TMPDIR")
47
+
48
+
49
+ def _scan_lines(text: str) -> List[Tuple[int, str, str]]:
50
+ """Return list of (line_no, kind, snippet). kind is "❼" or "❽"."""
51
+ findings: List[Tuple[int, str, str]] = []
52
+ in_heredoc = False
53
+ heredoc_terminator: str = ""
54
+ lines = text.splitlines()
55
+ for idx, raw_line in enumerate(lines, start=1):
56
+ line = raw_line.rstrip("\n")
57
+ stripped = line.lstrip()
58
+
59
+ if in_heredoc:
60
+ if line.strip() == heredoc_terminator:
61
+ in_heredoc = False
62
+ continue
63
+
64
+ # Skip comments — comments can legitimately discuss sed/awk in prose.
65
+ if stripped.startswith("#"):
66
+ continue
67
+
68
+ # Skip @test header lines — bats decorators carry the test name
69
+ # which often quotes the patterns the test exercises (false positive).
70
+ if stripped.startswith("@test "):
71
+ continue
72
+
73
+ # Explicit allow marker for lines that legitimately exercise the
74
+ # gate itself (test fixture content), or for project doc-validation
75
+ # awks that don't test production code.
76
+ if "test-quality:allow" in line:
77
+ continue
78
+
79
+ # Heredoc start: << 'EOF' or <<EOF (optional quotes).
80
+ # After the heredoc terminator on this line, subsequent lines are
81
+ # data until the terminator appears alone on a line.
82
+ m = re.search(r"<<\s*['\"]?([A-Z_]+)['\"]?", line)
83
+ if m:
84
+ heredoc_terminator = m.group(1)
85
+ in_heredoc = True
86
+ # Don't scan this declarator line further — the leading code
87
+ # before "<<" might still contain tool patterns, but we'd be
88
+ # double-flagging here vs the line that actually executes.
89
+ continue
90
+
91
+ # ❼: any inline extraction/parsing tool on this line flags. Each
92
+ # pattern intentionally describes parsing intent (sed substitution,
93
+ # awk script, grep -o / -oE, find -name, cut -f, tr -d) — single
94
+ # grep -q without -o doesn't match and stays untouched.
95
+ if any(pat.search(line) for pat in INLINE_TOOL_PATTERNS):
96
+ findings.append((idx, "❼", line.strip()))
97
+
98
+ # ❽: any outside-path hit unless BATS_TMPDIR appears (sandbox marker).
99
+ if OUTSIDE_ALLOW.search(line):
100
+ continue
101
+ for pat in OUTSIDE_PATTERNS:
102
+ if pat.search(line):
103
+ findings.append((idx, "❽", line.strip()))
104
+ break # one ❽ finding per line is enough
105
+
106
+ return findings
107
+
108
+
109
+ def scan_file(path: Path) -> List[Tuple[int, str, str]]:
110
+ try:
111
+ text = path.read_text(errors="ignore")
112
+ except FileNotFoundError:
113
+ return [(0, "?", f"file not found: {path}")]
114
+ return _scan_lines(text)
115
+
116
+
117
+ def main() -> int:
118
+ args = sys.argv[1:]
119
+ skip = False
120
+ files: List[str] = []
121
+ for a in args:
122
+ if a in ("--skip", "--skip-test-quality"):
123
+ skip = True
124
+ else:
125
+ files.append(a)
126
+ if not files:
127
+ print("usage: test_quality_gate.py [--skip] <bats-file> [<bats-file> …]",
128
+ file=sys.stderr)
129
+ return 2
130
+ if skip:
131
+ return 0
132
+
133
+ total = 0
134
+ for f in files:
135
+ findings = scan_file(Path(f))
136
+ for line_no, kind, snippet in findings:
137
+ print(f"{f}:{line_no}: {kind} {snippet}")
138
+ total += 1
139
+ return 1 if total > 0 else 0
140
+
141
+
142
+ if __name__ == "__main__":
143
+ sys.exit(main())
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@seanyao/roll",
3
- "version": "0.5.0",
3
+ "version": "2.602.1",
4
4
  "description": "Roll — Roll out features with AI agents",
5
5
  "scripts": {
6
- "test": "find tests/unit tests/integration -name '*.bats' | sort | xargs ./tests/helpers/bats-core/bin/bats"
6
+ "test": "bash tests/run.sh"
7
7
  },
8
8
  "keywords": [
9
9
  "ai",
@@ -15,19 +15,20 @@
15
15
  "homepage": "https://github.com/seanyao/roll",
16
16
  "repository": {
17
17
  "type": "git",
18
- "url": "https://github.com/seanyao/roll.git"
18
+ "url": "git+https://github.com/seanyao/roll.git"
19
19
  },
20
20
  "bin": {
21
- "roll": "./bin/roll"
21
+ "roll": "bin/roll"
22
22
  },
23
23
  "license": "MIT",
24
- "author": "Sean Yao <seanyao@gmail.com>",
24
+ "author": "Sean Yao <sean.dlut@gmail.com>",
25
25
  "files": [
26
26
  "bin/",
27
27
  "conventions/",
28
+ "lib/",
28
29
  "skills/",
29
- "tools/",
30
30
  "template/",
31
- "README.md"
31
+ "README.md",
32
+ "CHANGELOG.md"
32
33
  ]
33
34
  }