@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.
- package/CHANGELOG.md +717 -0
- package/LICENSE +21 -0
- package/README.md +65 -165
- package/bin/dream-test-quality-scan +110 -0
- package/bin/roll +14897 -815
- package/conventions/config.yaml +17 -1
- package/conventions/global/AGENTS.md +146 -100
- package/conventions/global/CLAUDE.md +1 -21
- package/conventions/global/GEMINI.md +8 -22
- package/conventions/global/project_rules.md +9 -0
- package/conventions/templates/backend-service/AGENTS.md +30 -81
- package/conventions/templates/backend-service/GEMINI.md +3 -3
- package/conventions/templates/backend-service/project_rules.md +16 -0
- package/conventions/templates/cli/AGENTS.md +31 -58
- package/conventions/templates/cli/CLAUDE.md +3 -5
- package/conventions/templates/cli/GEMINI.md +3 -3
- package/conventions/templates/cli/project_rules.md +16 -0
- package/conventions/templates/frontend-only/AGENTS.md +29 -64
- package/conventions/templates/frontend-only/GEMINI.md +3 -3
- package/conventions/templates/frontend-only/project_rules.md +14 -0
- package/conventions/templates/fullstack/AGENTS.md +31 -79
- package/conventions/templates/fullstack/CLAUDE.md +1 -1
- package/conventions/templates/fullstack/GEMINI.md +3 -3
- package/conventions/templates/fullstack/project_rules.md +15 -0
- package/lib/README.md +42 -0
- package/lib/__pycache__/github_sync.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop-fmt.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop_result_eval.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop_unstick.cpython-314.pyc +0 -0
- package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
- package/lib/__pycache__/prices_fetcher.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll-home.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll_git.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll_render.cpython-314.pyc +0 -0
- package/lib/__pycache__/slides-render.cpython-314.pyc +0 -0
- package/lib/agent_usage/README.md +49 -0
- package/lib/agent_usage/__init__.py +108 -0
- package/lib/agent_usage/__pycache__/__init__.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/gemini.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/kimi.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/openai.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/pi.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/pi_emit.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/qwen.cpython-314.pyc +0 -0
- package/lib/agent_usage/gemini.py +127 -0
- package/lib/agent_usage/kimi.py +278 -0
- package/lib/agent_usage/kimi_emit.py +123 -0
- package/lib/agent_usage/openai.py +126 -0
- package/lib/agent_usage/pi.py +200 -0
- package/lib/agent_usage/pi_emit.py +135 -0
- package/lib/agent_usage/qwen.py +128 -0
- package/lib/backfill-pi-usage.py +243 -0
- package/lib/changelog_audit.py +155 -0
- package/lib/changelog_generate.py +263 -0
- package/lib/context_feed_budget.sh +194 -0
- package/lib/github_sync.py +876 -0
- package/lib/i18n/README.md +54 -0
- package/lib/i18n/agent.sh +75 -0
- package/lib/i18n/alert.sh +20 -0
- package/lib/i18n/backlog.sh +96 -0
- package/lib/i18n/brief.sh +5 -0
- package/lib/i18n/changelog.sh +5 -0
- package/lib/i18n/ci.sh +15 -0
- package/lib/i18n/debug.sh +0 -0
- package/lib/i18n/doctor.sh +44 -0
- package/lib/i18n/dream.sh +0 -0
- package/lib/i18n/init.sh +91 -0
- package/lib/i18n/lang.sh +10 -0
- package/lib/i18n/loop.sh +140 -0
- package/lib/i18n/migrate.sh +74 -0
- package/lib/i18n/offboard.sh +31 -0
- package/lib/i18n/onboard.sh +0 -0
- package/lib/i18n/peer.sh +41 -0
- package/lib/i18n/peer_help.sh +25 -0
- package/lib/i18n/peer_reset.sh +7 -0
- package/lib/i18n/peer_status.sh +5 -0
- package/lib/i18n/prices.sh +3 -0
- package/lib/i18n/prices_refresh.sh +17 -0
- package/lib/i18n/prices_show.sh +7 -0
- package/lib/i18n/propose.sh +0 -0
- package/lib/i18n/release.sh +0 -0
- package/lib/i18n/research.sh +0 -0
- package/lib/i18n/review_pr.sh +0 -0
- package/lib/i18n/sentinel.sh +0 -0
- package/lib/i18n/setup.sh +3 -0
- package/lib/i18n/shared.sh +157 -0
- package/lib/i18n/skills/roll-brief.sh +47 -0
- package/lib/i18n/skills/roll-build.sh +97 -0
- package/lib/i18n/skills/roll-design.sh +18 -0
- package/lib/i18n/skills/roll-fix.sh +53 -0
- package/lib/i18n/skills/roll-loop.sh +28 -0
- package/lib/i18n/skills/roll-onboard.sh +33 -0
- package/lib/i18n/skills_catalog.sh +30 -0
- package/lib/i18n/slides.sh +3 -0
- package/lib/i18n/slides_build.sh +38 -0
- package/lib/i18n/slides_delete.sh +19 -0
- package/lib/i18n/slides_list.sh +14 -0
- package/lib/i18n/slides_logs.sh +12 -0
- package/lib/i18n/slides_new.sh +15 -0
- package/lib/i18n/slides_preview.sh +14 -0
- package/lib/i18n/slides_templates.sh +7 -0
- package/lib/i18n/status.sh +21 -0
- package/lib/i18n/update.sh +24 -0
- package/lib/i18n.sh +211 -0
- package/lib/loop-exit-summary.py +393 -0
- package/lib/loop-fmt.py +589 -0
- package/lib/loop_pick_agent.py +316 -0
- package/lib/loop_result_eval.py +469 -0
- package/lib/loop_unstick.py +180 -0
- package/lib/model_prices.py +186 -0
- package/lib/prices/README.md +35 -0
- package/lib/prices/snapshot-2026-05-22.json +22 -0
- package/lib/prices/snapshot-2026-05-23-deepseek.json +15 -0
- package/lib/prices/snapshot-2026-05-23-kimi.json +14 -0
- package/lib/prices_fetcher.py +285 -0
- package/lib/roll-backlog.py +225 -0
- package/lib/roll-brief.py +286 -0
- package/lib/roll-help.py +158 -0
- package/lib/roll-home.py +556 -0
- package/lib/roll-init.py +156 -0
- package/lib/roll-loop-status.py +1683 -0
- package/lib/roll-loop-story.py +191 -0
- package/lib/roll-onboard-render.py +378 -0
- package/lib/roll-peer.py +252 -0
- package/lib/roll-plan-validate.py +386 -0
- package/lib/roll-setup.py +102 -0
- package/lib/roll-status.py +367 -0
- package/lib/roll_git.py +41 -0
- package/lib/roll_render.py +414 -0
- package/lib/slides/components/README.md +123 -0
- package/lib/slides/components/cards-2.html +9 -0
- package/lib/slides/components/cards-3.html +9 -0
- package/lib/slides/components/cards-4.html +9 -0
- package/lib/slides/components/compare.html +22 -0
- package/lib/slides/components/highlight.html +9 -0
- package/lib/slides/components/pipeline.html +12 -0
- package/lib/slides/components/plain.html +7 -0
- package/lib/slides/components/quote.html +4 -0
- package/lib/slides/components/timeline.html +9 -0
- package/lib/slides/templates/introduction-v3.html +571 -0
- package/lib/slides/templates/pitch.html +0 -0
- package/lib/slides-render.py +778 -0
- package/lib/slides-validate.py +357 -0
- package/lib/test_quality_gate.py +143 -0
- package/package.json +8 -7
- package/skills/roll-.changelog/SKILL.md +406 -33
- package/skills/roll-.clarify/SKILL.md +5 -2
- package/skills/roll-.dream/SKILL.md +374 -0
- package/skills/roll-.echo/SKILL.md +5 -2
- package/skills/roll-.qa/SKILL.md +57 -3
- package/skills/roll-.review/SKILL.md +42 -3
- package/skills/roll-brief/SKILL.md +209 -0
- package/skills/roll-build/SKILL.md +308 -63
- package/skills/roll-debug/SKILL.md +341 -162
- package/skills/roll-debug/injectable-bb.js +263 -0
- package/skills/roll-deck/SKILL.md +296 -0
- package/skills/roll-design/ENGINEERING_CHECKLIST.md +1 -1
- package/skills/roll-design/SKILL.md +727 -94
- package/skills/roll-doc/SKILL.md +595 -0
- package/skills/roll-doctor/SKILL.md +192 -0
- package/skills/roll-fix/SKILL.md +149 -32
- package/skills/{roll-jot → roll-idea}/SKILL.md +18 -10
- package/skills/roll-loop/SKILL.md +578 -0
- package/skills/roll-notes/SKILL.md +103 -0
- package/skills/roll-onboard/SKILL.md +234 -0
- package/skills/roll-peer/SKILL.md +336 -0
- package/skills/roll-propose/SKILL.md +157 -0
- package/skills/roll-review-pr/SKILL.md +58 -0
- package/skills/roll-sentinel/SKILL.md +11 -2
- package/skills/roll-spar/SKILL.md +8 -6
- package/template/.github/workflows/ci.yml +5 -2
- package/template/AGENTS.md +20 -74
- package/skills/roll-research/SKILL.md +0 -307
- package/skills/roll-research/references/schema.json +0 -162
- package/skills/roll-research/scripts/md_to_pdf.py +0 -289
- package/tools/roll-fetch/SKILL.md +0 -182
- package/tools/roll-fetch/package.json +0 -15
- package/tools/roll-fetch/smart-web-fetch.js +0 -558
- package/tools/roll-probe/SKILL.md +0 -84
- /package/template/{BACKLOG.md → .roll/backlog.md} +0 -0
package/lib/roll-peer.py
ADDED
|
@@ -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))
|