@seanyao/roll 0.5.0 → 2.602.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 (181) hide show
  1. package/CHANGELOG.md +736 -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 +15030 -814
  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 +194 -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 +15 -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 +733 -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 +579 -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,252 @@
1
+ #!/usr/bin/env python3
2
+ """roll-peer — v2 terminal view for `roll peer` (US-VIEW-009).
3
+
4
+ Renders a cross-agent review log as a turn-based ROUND transcript:
5
+ eyebrow + subject + proposer/reviewer overview + ROUND N sections
6
+ (each carrying agent turns with weight chips) + final VERDICT line
7
+ + artifact path / next-step hint.
8
+
9
+ NO_COLOR=1 falls through to glyph + weight + spacing only.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import os
15
+ import sys
16
+
17
+ _LIB_DIR = os.path.dirname(os.path.realpath(__file__))
18
+ if _LIB_DIR not in sys.path:
19
+ sys.path.insert(0, _LIB_DIR)
20
+ import roll_render
21
+ from roll_render import c, row, COLS
22
+
23
+ # ════════════════════════════════════════════════════════════════════════════
24
+ # Agent palette — each agent gets a stable color so reviewer/proposer pairs
25
+ # read at a glance across rounds. Unknown agents fall back to fg.
26
+ # ════════════════════════════════════════════════════════════════════════════
27
+
28
+ _AGENT_COLOR = {
29
+ "claude": "blue",
30
+ "codex": "pink",
31
+ "kimi": "amber",
32
+ "deepseek": "green",
33
+ "agy": "purple", # Antigravity (formerly Gemini CLI)
34
+ "pi": "yellow",
35
+ "opencode": "muted",
36
+ "trae": "fg",
37
+ }
38
+
39
+ # Weight chip — (glyph, color, label) per turn.weight
40
+ _WEIGHTS = {
41
+ "concern": ("●", "amber", "concern"),
42
+ "nit": ("○", "dim", "nit"),
43
+ "ack": ("✓", "green", "ack"),
44
+ "block": ("✗", "red", "block"),
45
+ }
46
+
47
+
48
+ def _agent_c(name: str) -> str:
49
+ return _AGENT_COLOR.get(name.lower(), "fg")
50
+
51
+
52
+ # ════════════════════════════════════════════════════════════════════════════
53
+ # Fixture data (test-only; opt in via ROLL_RENDER_FIXTURE=1)
54
+ # Illustrative cross-agent review: claude proposes, codex reviews
55
+ # ════════════════════════════════════════════════════════════════════════════
56
+
57
+ _FIXTURE_SUBJECT = {
58
+ "story": "US-AUTH-014",
59
+ "title": "Session refresh fallback when refresh-token API 5xx",
60
+ "pr": "#412",
61
+ "diff_stat": "+184 −37 · 6 files",
62
+ "trigger": "complexity=large",
63
+ "proposer": "claude",
64
+ "reviewer": "codex",
65
+ }
66
+
67
+ _FIXTURE_ROUNDS = [
68
+ {
69
+ "n": 1,
70
+ "hint": "first pass — proposer ships, reviewer probes",
71
+ "turns": [
72
+ ("claude", "concern",
73
+ "Refresh path swallows 503 silently — caller sees a stale session "
74
+ "without any signal that re-auth is needed."),
75
+ ("codex", "nit",
76
+ "Naming: `tryRefresh` reads as best-effort, but the retry budget "
77
+ "actually escalates. Suggest `refreshWithBackoff`."),
78
+ ("codex", "block",
79
+ "Backoff jitter uses Math.random — flakes integration tests. "
80
+ "Inject the rng so tests can pin it."),
81
+ ],
82
+ },
83
+ {
84
+ "n": 2,
85
+ "hint": "proposer revises, reviewer signs off",
86
+ "turns": [
87
+ ("claude", "ack",
88
+ "Renamed to `refreshWithBackoff`; threaded `rng` through the "
89
+ "config object. Added a test that pins seed 42."),
90
+ ("codex", "ack",
91
+ "Looks right — retries fire 3× with jitter, surfaces 503 to "
92
+ "caller after budget exhausted. Approving."),
93
+ ],
94
+ },
95
+ ]
96
+
97
+ _FIXTURE_VERDICT = {
98
+ "outcome": "approved",
99
+ "reason": "2 rounds · 5 turns · all blocks resolved",
100
+ }
101
+
102
+ _FIXTURE_ARTIFACT = ".roll/peer/logs/20260519_213700_claude_codex.md"
103
+ _FIXTURE_NEXT = [
104
+ ("Continue execution", "claude resumes work on US-AUTH-014"),
105
+ ("Inspect log", "open the artifact above to replay the transcript"),
106
+ ]
107
+
108
+
109
+ # ════════════════════════════════════════════════════════════════════════════
110
+ # Render primitives
111
+ # ════════════════════════════════════════════════════════════════════════════
112
+
113
+ def _divider(char: str = "─") -> None:
114
+ print(c("dim", char * min(COLS, 80)))
115
+
116
+
117
+ def _eyebrow(trigger: str) -> None:
118
+ left = (" " + c("blue", "PEER", bold=True) +
119
+ c("dim", " · ") +
120
+ c("dim", "roll peer · cross-agent review"))
121
+ right = c("purple", trigger, bold=True) + " "
122
+ print(row(left, right))
123
+
124
+
125
+ def _subject(subj: dict) -> None:
126
+ story = c("blue", subj["story"], bold=True)
127
+ title = c("fg", subj["title"])
128
+ pr = c("amber", subj["pr"], bold=True)
129
+ diff = c("muted", subj["diff_stat"])
130
+ line = " " + story + c("muted", " · ") + title
131
+ print(line)
132
+ print(" " + pr + c("muted", " ") + diff)
133
+
134
+
135
+ def _pair_overview(subj: dict) -> None:
136
+ p_name = subj["proposer"]
137
+ r_name = subj["reviewer"]
138
+ p_c = _agent_c(p_name)
139
+ r_c = _agent_c(r_name)
140
+ proposer = c("dim", "proposer ") + c(p_c, p_name, bold=True)
141
+ reviewer = c("dim", "reviewer ") + c(r_c, r_name, bold=True)
142
+ sep = c("muted", " → ")
143
+ print(" " + proposer + sep + reviewer)
144
+
145
+
146
+ def _round_header(n: int, hint: str) -> None:
147
+ label = c("pink", f"ROUND {n}", bold=True)
148
+ print()
149
+ print(" " + label + c("muted", " · ") + c("dim", hint))
150
+
151
+
152
+ def _weight_chip(weight: str) -> str:
153
+ glyph, color, label = _WEIGHTS.get(weight, ("·", "muted", weight))
154
+ return c(color, glyph + " " + label, bold=(weight in ("ack", "block")))
155
+
156
+
157
+ def _turn(agent: str, weight: str, body: str) -> None:
158
+ agent_c = _agent_c(agent)
159
+ name = c(agent_c, agent, bold=True)
160
+ chip = _weight_chip(weight)
161
+ # First line: agent chip
162
+ print(" " + name + c("muted", " ") + chip)
163
+ # Body wrapped with hanging indent so long sentences stay readable.
164
+ _print_wrapped(body, indent=6, width=min(COLS, 80))
165
+
166
+
167
+ def _print_wrapped(s: str, *, indent: int, width: int) -> None:
168
+ avail = max(20, width - indent)
169
+ line = ""
170
+ pad = " " * indent
171
+ for word in s.split():
172
+ if line and len(line) + 1 + len(word) > avail:
173
+ print(pad + c("dim", line))
174
+ line = word
175
+ else:
176
+ line = (line + " " + word) if line else word
177
+ if line:
178
+ print(pad + c("dim", line))
179
+
180
+
181
+ def _verdict(v: dict) -> None:
182
+ outcome = v["outcome"]
183
+ if outcome == "approved":
184
+ glyph, color, label = "✓", "green", "approved"
185
+ else:
186
+ glyph, color, label = "✗", "red", "changes requested"
187
+ head = c(color, f"{glyph} VERDICT", bold=True) + c("muted", " · ") + c(color, label)
188
+ print()
189
+ print(" " + head)
190
+ print(" " + c("dim", v["reason"]))
191
+
192
+
193
+ def _footer(artifact: str, next_steps: list) -> None:
194
+ print()
195
+ print(" " + c("dim", "artifact ") + c("muted", artifact))
196
+ print()
197
+ print(" " + c("pink", "NEXT", bold=True) + c("dim", " · 下一步"))
198
+ for i, (label, hint) in enumerate(next_steps, start=1):
199
+ num = c("dim", f" {i}.")
200
+ print(f"{num} {c('fg', label, bold=True)}")
201
+ print(" " + c("dim", hint))
202
+ _divider("═")
203
+
204
+
205
+ # ════════════════════════════════════════════════════════════════════════════
206
+ # Top-level render
207
+ # ════════════════════════════════════════════════════════════════════════════
208
+
209
+ def render_fixture() -> None:
210
+ _eyebrow(_FIXTURE_SUBJECT["trigger"])
211
+ _divider()
212
+ print()
213
+ _subject(_FIXTURE_SUBJECT)
214
+ print()
215
+ _pair_overview(_FIXTURE_SUBJECT)
216
+ for rd in _FIXTURE_ROUNDS:
217
+ _round_header(rd["n"], rd["hint"])
218
+ for agent, weight, body in rd["turns"]:
219
+ _turn(agent, weight, body)
220
+ _verdict(_FIXTURE_VERDICT)
221
+ _footer(_FIXTURE_ARTIFACT, _FIXTURE_NEXT)
222
+
223
+
224
+ # ════════════════════════════════════════════════════════════════════════════
225
+ # Entry point
226
+ # ════════════════════════════════════════════════════════════════════════════
227
+
228
+ def main() -> None:
229
+ ap = argparse.ArgumentParser(add_help=False)
230
+ ap.add_argument("--no-color", dest="no_color", action="store_true")
231
+ ap.add_argument("--en", action="store_true")
232
+ ap.add_argument("--zh", action="store_true")
233
+ args, _ = ap.parse_known_args()
234
+
235
+ if args.no_color or os.environ.get("NO_COLOR") or not sys.stdout.isatty():
236
+ roll_render.USE_COLOR = False
237
+
238
+ # FIX-076: this standalone entrypoint only knows how to render the fixture
239
+ # transcript (for UI tests). Real peer review is orchestrated by bin/roll
240
+ # and never invokes this main(). Require an explicit opt-in so a stray
241
+ # `python3 lib/roll-peer.py` invocation can't masquerade as live output.
242
+ if not os.environ.get("ROLL_RENDER_FIXTURE"):
243
+ print("Error: lib/roll-peer.py only renders fixture data; "
244
+ "set ROLL_RENDER_FIXTURE=1 to use it (test-only).",
245
+ file=sys.stderr)
246
+ sys.exit(2)
247
+
248
+ render_fixture()
249
+
250
+
251
+ if __name__ == "__main__":
252
+ main()
@@ -0,0 +1,386 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ US-ONBOARD-007: onboard-plan.yaml validator.
4
+
5
+ Validates that a plan file produced by $roll-onboard is structurally complete,
6
+ fresh (generated_at within 24h), and version-compatible with the consuming
7
+ bin/roll. Called by `roll init --apply` before any side effects.
8
+
9
+ Usage:
10
+ python3 roll-plan-validate.py <path-to-plan.yaml>
11
+
12
+ Exit codes:
13
+ 0 plan is valid
14
+ 1 schema / required field error
15
+ 2 plan is stale (generated_at > 24h)
16
+ 3 plan version not supported
17
+ 4 plan file unreadable / not YAML
18
+
19
+ Error messages are written to stderr in both English and Chinese.
20
+
21
+ Schema (v1):
22
+ version: 1
23
+ generated_at: ISO 8601 timestamp (UTC or with tz offset)
24
+ project_understanding:
25
+ type: backend-service | frontend-only | fullstack | cli
26
+ description: str
27
+ domains: [str]
28
+ key_modules: [str]
29
+ scope:
30
+ approved: [str] # subset of {backlog, features, domain, briefs}
31
+ declined: [str]
32
+ include_existing: [str]
33
+ privacy:
34
+ gitignore_dot_roll: bool
35
+ sync_targets: [str]
36
+ enable_loop: bool
37
+
38
+ US-ONBOARD-016 — Phase 2 analysis sections (all OPTIONAL, pure-incremental,
39
+ backward compatible; an old plan that omits them still validates). When
40
+ present, each is validated for structure:
41
+
42
+ domain_model:
43
+ bounded_contexts:
44
+ - name: str
45
+ aggregates: [str]
46
+ ubiquitous_language: [str] # or [{term, definition}]
47
+ tech_analysis:
48
+ stack: [str]
49
+ dependencies: [str]
50
+ architecture_notes: [str]
51
+ risks:
52
+ - description: str
53
+ severity: LOW | MEDIUM | HIGH # optional
54
+ evidence: detected | inferred # optional
55
+ test_assessment:
56
+ current_layers: [<claim>]
57
+ gaps: [<claim>]
58
+ recommended_actions:[<claim>]
59
+
60
+ ANTI-HALLUCINATION HARD CONSTRAINT (the heart of US-ONBOARD-016):
61
+ Every test_assessment claim MUST be a mapping carrying an `evidence` key whose
62
+ value is exactly `detected` or `inferred`. A schema validator cannot re-run the
63
+ filesystem scan, so the data contract is the lever: free-floating untagged
64
+ strings (e.g. a hallucinated "needs more E2E tests") are REJECTED. When a scan
65
+ finds nothing the skill must still emit a tagged claim such as
66
+ `{claim: "none detected", evidence: detected}` — never invent filler. A scan
67
+ that ran and returned zero matches is a genuine detection, so "none detected"
68
+ carries `evidence: detected` (not a third enum value).
69
+ """
70
+
71
+ from __future__ import annotations
72
+
73
+ import sys
74
+ from datetime import datetime, timezone, timedelta
75
+ from pathlib import Path
76
+
77
+ try:
78
+ import yaml # PyYAML
79
+ except ImportError:
80
+ print(
81
+ "[plan-validate] PyYAML not installed. Install with: pip install pyyaml\n"
82
+ "[plan-validate] PyYAML 未安装,请运行: pip install pyyaml",
83
+ file=sys.stderr,
84
+ )
85
+ sys.exit(4)
86
+
87
+
88
+ SUPPORTED_VERSIONS = {1}
89
+ MAX_AGE_HOURS = 24
90
+ VALID_PROJECT_TYPES = {"backend-service", "frontend-only", "fullstack", "cli"}
91
+ VALID_SCOPE_ITEMS = {"backlog", "features", "domain", "briefs"}
92
+
93
+ # US-ONBOARD-016: anti-hallucination evidence tags. Every test_assessment claim
94
+ # must carry one of these; risks[].evidence (when present) uses the same enum.
95
+ VALID_EVIDENCE = {"detected", "inferred"}
96
+ # test_assessment buckets whose entries are evidence-tagged claims.
97
+ TEST_ASSESSMENT_CLAIM_KEYS = ("current_layers", "gaps", "recommended_actions")
98
+ # Optional severity enum for tech_analysis.risks[].severity.
99
+ VALID_RISK_SEVERITY = {"LOW", "MEDIUM", "HIGH"}
100
+
101
+
102
+ def err(msg_en: str, msg_zh: str = "") -> None:
103
+ """Print bilingual error to stderr."""
104
+ print(f"[plan-validate] {msg_en}", file=sys.stderr)
105
+ if msg_zh:
106
+ print(f"[plan-validate] {msg_zh}", file=sys.stderr)
107
+
108
+
109
+ def validate_required_top_level(plan: dict) -> list[str]:
110
+ """Return list of missing/invalid top-level fields."""
111
+ errors = []
112
+ required = ["version", "generated_at", "project_understanding", "scope", "privacy"]
113
+ for key in required:
114
+ if key not in plan:
115
+ errors.append(f"missing required field: {key}")
116
+ return errors
117
+
118
+
119
+ def validate_version(plan: dict) -> list[str]:
120
+ v = plan.get("version")
121
+ if not isinstance(v, int):
122
+ return [f"version must be int, got {type(v).__name__}"]
123
+ if v not in SUPPORTED_VERSIONS:
124
+ return [f"version {v} not supported (supported: {sorted(SUPPORTED_VERSIONS)})"]
125
+ return []
126
+
127
+
128
+ def validate_freshness(plan: dict) -> tuple[list[str], bool]:
129
+ """Returns (errors, is_stale). Stale uses exit code 2."""
130
+ raw = plan.get("generated_at")
131
+ if not raw:
132
+ return ["generated_at missing"], False
133
+ try:
134
+ if isinstance(raw, datetime):
135
+ ts = raw
136
+ else:
137
+ ts = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
138
+ except (ValueError, TypeError) as e:
139
+ return [f"generated_at not a valid ISO 8601 timestamp: {e}"], False
140
+ if ts.tzinfo is None:
141
+ ts = ts.replace(tzinfo=timezone.utc)
142
+ now = datetime.now(timezone.utc)
143
+ age = now - ts
144
+ if age > timedelta(hours=MAX_AGE_HOURS):
145
+ return [
146
+ f"plan is stale: generated {age.total_seconds() / 3600:.1f}h ago "
147
+ f"(max allowed: {MAX_AGE_HOURS}h)"
148
+ ], True
149
+ if age < timedelta(seconds=-300):
150
+ # Plan in future >5 min — clock skew or fabricated timestamp
151
+ return [
152
+ f"plan timestamp is in the future (clock skew?): generated_at={ts.isoformat()}"
153
+ ], False
154
+ return [], False
155
+
156
+
157
+ def validate_project_understanding(plan: dict) -> list[str]:
158
+ errors = []
159
+ pu = plan.get("project_understanding")
160
+ if not isinstance(pu, dict):
161
+ return ["project_understanding must be a mapping"]
162
+ t = pu.get("type")
163
+ if t is None:
164
+ errors.append("project_understanding.type missing")
165
+ elif t not in VALID_PROJECT_TYPES:
166
+ errors.append(
167
+ f"project_understanding.type='{t}' not in {sorted(VALID_PROJECT_TYPES)}"
168
+ )
169
+ if "description" not in pu:
170
+ errors.append("project_understanding.description missing")
171
+ return errors
172
+
173
+
174
+ def validate_scope(plan: dict) -> list[str]:
175
+ errors = []
176
+ scope = plan.get("scope")
177
+ if not isinstance(scope, dict):
178
+ return ["scope must be a mapping"]
179
+ approved = scope.get("approved", [])
180
+ if not isinstance(approved, list):
181
+ errors.append("scope.approved must be a list")
182
+ else:
183
+ for item in approved:
184
+ if item not in VALID_SCOPE_ITEMS:
185
+ errors.append(
186
+ f"scope.approved contains unknown item '{item}' "
187
+ f"(valid: {sorted(VALID_SCOPE_ITEMS)})"
188
+ )
189
+ return errors
190
+
191
+
192
+ def validate_privacy(plan: dict) -> list[str]:
193
+ errors = []
194
+ privacy = plan.get("privacy")
195
+ if not isinstance(privacy, dict):
196
+ return ["privacy must be a mapping"]
197
+ g = privacy.get("gitignore_dot_roll")
198
+ if not isinstance(g, bool):
199
+ errors.append(
200
+ f"privacy.gitignore_dot_roll must be bool, got {type(g).__name__}"
201
+ )
202
+ return errors
203
+
204
+
205
+ def validate_domain_model(plan: dict) -> list[str]:
206
+ """US-ONBOARD-016: validate the optional domain_model section.
207
+
208
+ Absent → no errors (pure-incremental). When present it must be a mapping
209
+ with a bounded_contexts list; each context is a mapping with a name and
210
+ list-typed aggregates / ubiquitous_language.
211
+ """
212
+ errors: list[str] = []
213
+ if "domain_model" not in plan:
214
+ return errors
215
+ dm = plan.get("domain_model")
216
+ if not isinstance(dm, dict):
217
+ return ["domain_model must be a mapping"]
218
+ contexts = dm.get("bounded_contexts")
219
+ if contexts is None:
220
+ return ["domain_model.bounded_contexts missing"]
221
+ if not isinstance(contexts, list):
222
+ return ["domain_model.bounded_contexts must be a list"]
223
+ for i, ctx in enumerate(contexts):
224
+ where = f"domain_model.bounded_contexts[{i}]"
225
+ if not isinstance(ctx, dict):
226
+ errors.append(f"{where} must be a mapping")
227
+ continue
228
+ if not ctx.get("name"):
229
+ errors.append(f"{where}.name missing or empty")
230
+ for list_key in ("aggregates", "ubiquitous_language"):
231
+ if list_key in ctx and not isinstance(ctx[list_key], list):
232
+ errors.append(f"{where}.{list_key} must be a list")
233
+ return errors
234
+
235
+
236
+ def _validate_evidence_value(value, where: str) -> list[str]:
237
+ """Shared check: a value must be exactly one of VALID_EVIDENCE."""
238
+ if value is None:
239
+ return [f"{where}.evidence missing (must be one of {sorted(VALID_EVIDENCE)})"]
240
+ if value not in VALID_EVIDENCE:
241
+ return [
242
+ f"{where}.evidence='{value}' invalid "
243
+ f"(must be one of {sorted(VALID_EVIDENCE)})"
244
+ ]
245
+ return []
246
+
247
+
248
+ def validate_tech_analysis(plan: dict) -> list[str]:
249
+ """US-ONBOARD-016: validate the optional tech_analysis section.
250
+
251
+ Absent → no errors. When present: stack / dependencies / architecture_notes
252
+ (if given) must be lists; risks (if given) must be a list of mappings each
253
+ with a description, an optional severity in VALID_RISK_SEVERITY, and an
254
+ optional evidence tag in VALID_EVIDENCE.
255
+ """
256
+ errors: list[str] = []
257
+ if "tech_analysis" not in plan:
258
+ return errors
259
+ ta = plan.get("tech_analysis")
260
+ if not isinstance(ta, dict):
261
+ return ["tech_analysis must be a mapping"]
262
+ for list_key in ("stack", "dependencies", "architecture_notes"):
263
+ if list_key in ta and not isinstance(ta[list_key], list):
264
+ errors.append(f"tech_analysis.{list_key} must be a list")
265
+ if "risks" in ta:
266
+ risks = ta["risks"]
267
+ if not isinstance(risks, list):
268
+ errors.append("tech_analysis.risks must be a list")
269
+ else:
270
+ for i, risk in enumerate(risks):
271
+ where = f"tech_analysis.risks[{i}]"
272
+ if not isinstance(risk, dict):
273
+ errors.append(f"{where} must be a mapping")
274
+ continue
275
+ if not risk.get("description"):
276
+ errors.append(f"{where}.description missing or empty")
277
+ sev = risk.get("severity")
278
+ if sev is not None and sev not in VALID_RISK_SEVERITY:
279
+ errors.append(
280
+ f"{where}.severity='{sev}' invalid "
281
+ f"(must be one of {sorted(VALID_RISK_SEVERITY)})"
282
+ )
283
+ if "evidence" in risk:
284
+ errors += _validate_evidence_value(risk["evidence"], where)
285
+ return errors
286
+
287
+
288
+ def validate_test_assessment(plan: dict) -> list[str]:
289
+ """US-ONBOARD-016 anti-hallucination HARD constraint.
290
+
291
+ Absent → no errors. When present, every entry in current_layers / gaps /
292
+ recommended_actions MUST be a mapping carrying an `evidence` tag of exactly
293
+ `detected` or `inferred`. This is the mechanical lever: untagged free-text
294
+ claims (hallucinated filler) are rejected. An empty bucket is allowed — that
295
+ is how "the section ran but had nothing in this dimension" is expressed; the
296
+ skill represents a zero-result scan as a tagged `{claim: "none detected",
297
+ evidence: detected}` entry rather than inventing a recommendation.
298
+ """
299
+ errors: list[str] = []
300
+ if "test_assessment" not in plan:
301
+ return errors
302
+ ta = plan.get("test_assessment")
303
+ if not isinstance(ta, dict):
304
+ return ["test_assessment must be a mapping"]
305
+ for key in TEST_ASSESSMENT_CLAIM_KEYS:
306
+ if key not in ta:
307
+ continue
308
+ claims = ta[key]
309
+ if not isinstance(claims, list):
310
+ errors.append(f"test_assessment.{key} must be a list")
311
+ continue
312
+ for i, claim in enumerate(claims):
313
+ where = f"test_assessment.{key}[{i}]"
314
+ if not isinstance(claim, dict):
315
+ errors.append(
316
+ f"{where} must be a mapping carrying an 'evidence' tag "
317
+ f"(got {type(claim).__name__}); untagged claims are rejected "
318
+ f"to block unverifiable filler"
319
+ )
320
+ continue
321
+ errors += _validate_evidence_value(claim.get("evidence"), where)
322
+ return errors
323
+
324
+
325
+ def main(argv: list[str]) -> int:
326
+ if len(argv) < 2:
327
+ err("usage: roll-plan-validate.py <plan.yaml>", "用法: roll-plan-validate.py <plan.yaml>")
328
+ return 4
329
+
330
+ path = Path(argv[1])
331
+ if not path.is_file():
332
+ err(f"plan file not found: {path}", f"未找到 plan 文件:{path}")
333
+ return 4
334
+
335
+ try:
336
+ with path.open("r", encoding="utf-8") as f:
337
+ plan = yaml.safe_load(f)
338
+ except (yaml.YAMLError, OSError) as e:
339
+ err(f"failed to parse plan as YAML: {e}", "无法解析 plan YAML")
340
+ return 4
341
+
342
+ if not isinstance(plan, dict):
343
+ err("plan must be a top-level mapping", "plan 顶层必须是 mapping")
344
+ return 1
345
+
346
+ schema_errors: list[str] = []
347
+ schema_errors += validate_required_top_level(plan)
348
+ schema_errors += validate_version(plan)
349
+ schema_errors += validate_project_understanding(plan)
350
+ schema_errors += validate_scope(plan)
351
+ schema_errors += validate_privacy(plan)
352
+ # US-ONBOARD-016: optional Phase 2 analysis sections (validated only when
353
+ # present so old plans stay compatible).
354
+ schema_errors += validate_domain_model(plan)
355
+ schema_errors += validate_tech_analysis(plan)
356
+ schema_errors += validate_test_assessment(plan)
357
+
358
+ freshness_errors, is_stale = validate_freshness(plan)
359
+
360
+ # Version errors take precedence — if version is wrong, the rest of the
361
+ # validation may be unreliable.
362
+ version_errors = [e for e in schema_errors if e.startswith("version")]
363
+ if version_errors:
364
+ for e in version_errors:
365
+ err(e)
366
+ return 3
367
+
368
+ if is_stale:
369
+ for e in freshness_errors:
370
+ err(e, "plan 已过期,请重新运行 $roll-onboard 生成新 plan")
371
+ return 2
372
+
373
+ all_errors = [e for e in schema_errors if not e.startswith("version")] + [
374
+ e for e in freshness_errors if not is_stale
375
+ ]
376
+ if all_errors:
377
+ for e in all_errors:
378
+ err(e)
379
+ return 1
380
+
381
+ # Valid — silent success (bash caller treats exit 0 as OK).
382
+ return 0
383
+
384
+
385
+ if __name__ == "__main__":
386
+ sys.exit(main(sys.argv))