@seanyao/roll 2026.529.5 → 2026.601.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -25
- package/README.md +10 -7
- package/bin/roll +3952 -317
- package/conventions/config.yaml +7 -0
- package/lib/__pycache__/github_sync.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop_result_eval.cpython-314.pyc +0 -0
- package/lib/__pycache__/model_prices.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__/slides-render.cpython-314.pyc +0 -0
- package/lib/agent_usage/__init__.py +4 -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__/qwen.cpython-314.pyc +0 -0
- package/lib/agent_usage/gemini.py +127 -0
- package/lib/agent_usage/kimi.py +127 -0
- package/lib/agent_usage/openai.py +126 -0
- package/lib/agent_usage/qwen.py +128 -0
- package/lib/context_feed_budget.sh +194 -0
- package/lib/github_sync.py +876 -0
- package/lib/i18n/agent.sh +54 -0
- package/lib/i18n/init.sh +22 -0
- package/lib/i18n/peer.sh +7 -0
- package/lib/i18n/peer_help.sh +4 -0
- package/lib/i18n/skills_catalog.sh +30 -0
- package/lib/loop-exit-summary.py +393 -0
- package/lib/loop-fmt.py +93 -75
- package/lib/loop_pick_agent.py +241 -170
- package/lib/loop_result_eval.py +469 -0
- package/lib/model_prices.py +0 -10
- package/lib/roll-home.py +1 -28
- package/lib/roll-loop-status.py +330 -40
- package/lib/roll-onboard-render.py +378 -0
- package/lib/roll-peer.py +1 -1
- package/lib/roll-plan-validate.py +165 -0
- package/lib/roll_git.py +41 -0
- package/lib/slides/components/README.md +8 -2
- package/lib/slides/templates/introduction-v3.html +1 -6
- package/lib/slides-render.py +305 -15
- package/lib/slides-validate.py +195 -7
- package/package.json +1 -1
- package/skills/roll-.changelog/SKILL.md +67 -56
- package/skills/roll-brief/SKILL.md +1 -1
- package/skills/roll-build/SKILL.md +14 -12
- package/skills/roll-deck/SKILL.md +152 -0
- package/skills/roll-design/SKILL.md +13 -6
- package/skills/roll-doc/SKILL.md +269 -6
- package/skills/roll-fix/SKILL.md +15 -9
- package/skills/roll-loop/SKILL.md +9 -7
- package/skills/roll-notes/SKILL.md +1 -1
- package/skills/roll-onboard/SKILL.md +85 -0
- package/skills/roll-peer/SKILL.md +6 -5
- package/lib/agent_routes_lint.py +0 -203
- package/skills/roll-research/SKILL.md +0 -316
- package/skills/roll-research/references/schema.json +0 -166
- package/skills/roll-research/scripts/md_to_pdf.py +0 -289
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
US-ONBOARD-017: render the three Phase 2 analysis sections of onboard-plan.yaml
|
|
4
|
+
(domain_model / tech_analysis / test_assessment, produced by US-ONBOARD-016)
|
|
5
|
+
into human-readable markdown inside the onboarded project, and surface the
|
|
6
|
+
BACKLOG / FIX seed candidates for bash to gate behind a [Y/n] confirm.
|
|
7
|
+
|
|
8
|
+
This script NEVER writes to BACKLOG and NEVER touches the offboard changeset.
|
|
9
|
+
Those mutations stay in bash (bin/roll `_init_apply`) so the confirm gate and
|
|
10
|
+
the rollback registry remain the single source of truth. This script only:
|
|
11
|
+
|
|
12
|
+
1. Renders three markdown files into <project_dir>/.roll/ , deterministically
|
|
13
|
+
(same plan.yaml -> byte-identical markdown; no wall-clock timestamps or
|
|
14
|
+
random IDs are embedded in the body).
|
|
15
|
+
2. Emits a machine-parseable manifest on stdout describing exactly what it
|
|
16
|
+
wrote and what could be seeded, in a pipe-delimited line format that bash
|
|
17
|
+
parses with `IFS='|' read` (no jq dependency):
|
|
18
|
+
|
|
19
|
+
FILE|.roll/domain/context-map.md
|
|
20
|
+
FILE|.roll/tech-analysis.md
|
|
21
|
+
FILE|.roll/test-assessment.md
|
|
22
|
+
SEED|US-SEED-001|add a macOS CI runner
|
|
23
|
+
FIX|FIX-SEED-001|no automated test run on macOS bash 3.2
|
|
24
|
+
|
|
25
|
+
`FILE|` lines are project-relative paths bash records into the changeset's
|
|
26
|
+
`files_created` (so `roll offboard` removes them). `SEED|` / `FIX|` lines
|
|
27
|
+
are candidates; bash prints a preview and only writes them on explicit
|
|
28
|
+
confirmation.
|
|
29
|
+
|
|
30
|
+
Atomicity (per peer review): all three files are staged to temp paths inside
|
|
31
|
+
the project dir and atomically renamed into place. If rendering any file fails,
|
|
32
|
+
nothing is left half-written and no manifest is emitted, so bash records no
|
|
33
|
+
changeset entries for files that do not exist.
|
|
34
|
+
|
|
35
|
+
Usage:
|
|
36
|
+
python3 roll-onboard-render.py <plan.yaml> <project_dir>
|
|
37
|
+
|
|
38
|
+
Exit codes:
|
|
39
|
+
0 rendered OK (manifest on stdout)
|
|
40
|
+
1 plan unreadable / not a mapping / render failure
|
|
41
|
+
2 plan has none of the three sections (nothing to render; no-op)
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import os
|
|
47
|
+
import sys
|
|
48
|
+
import tempfile
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
import yaml # PyYAML
|
|
53
|
+
except ImportError:
|
|
54
|
+
print(
|
|
55
|
+
"[onboard-render] PyYAML not installed. Install with: pip install pyyaml\n"
|
|
56
|
+
"[onboard-render] PyYAML 未安装,请运行: pip install pyyaml",
|
|
57
|
+
file=sys.stderr,
|
|
58
|
+
)
|
|
59
|
+
sys.exit(1)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Project-relative output paths (the AC fixes these three exact locations).
|
|
63
|
+
CONTEXT_MAP_REL = ".roll/domain/context-map.md"
|
|
64
|
+
TECH_ANALYSIS_REL = ".roll/tech-analysis.md"
|
|
65
|
+
TEST_ASSESSMENT_REL = ".roll/test-assessment.md"
|
|
66
|
+
|
|
67
|
+
# HIGH-severity risks are the only ones eligible to seed as FIX candidates.
|
|
68
|
+
HIGH_SEVERITY = "HIGH"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _as_list(value) -> list:
|
|
72
|
+
"""Coerce a possibly-missing field into a list, preserving order.
|
|
73
|
+
|
|
74
|
+
A scalar becomes a one-item list; None/missing becomes []. Never sorts —
|
|
75
|
+
determinism comes from honouring the author's order in the plan.
|
|
76
|
+
"""
|
|
77
|
+
if value is None:
|
|
78
|
+
return []
|
|
79
|
+
if isinstance(value, list):
|
|
80
|
+
return value
|
|
81
|
+
return [value]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _term_text(term) -> str:
|
|
85
|
+
"""Render a ubiquitous_language entry, which may be a bare string or a
|
|
86
|
+
{term, definition} mapping (the schema allows both)."""
|
|
87
|
+
if isinstance(term, dict):
|
|
88
|
+
name = str(term.get("term", "")).strip()
|
|
89
|
+
definition = str(term.get("definition", "")).strip()
|
|
90
|
+
if name and definition:
|
|
91
|
+
return f"{name} — {definition}"
|
|
92
|
+
return name or definition
|
|
93
|
+
return str(term).strip()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _claim_text(claim) -> tuple[str, str]:
|
|
97
|
+
"""Return (text, evidence) for a test_assessment claim.
|
|
98
|
+
|
|
99
|
+
Claims are validated upstream as mappings carrying an `evidence` tag, but we
|
|
100
|
+
stay defensive so a malformed-but-parseable plan still renders rather than
|
|
101
|
+
crashing.
|
|
102
|
+
"""
|
|
103
|
+
if isinstance(claim, dict):
|
|
104
|
+
text = str(claim.get("claim", "")).strip()
|
|
105
|
+
evidence = str(claim.get("evidence", "")).strip()
|
|
106
|
+
return text, evidence
|
|
107
|
+
return str(claim).strip(), ""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def render_context_map(domain_model: dict | None) -> str:
|
|
111
|
+
"""Render .roll/domain/context-map.md from domain_model. Deterministic."""
|
|
112
|
+
lines: list[str] = ["# Domain Context Map", ""]
|
|
113
|
+
lines.append(
|
|
114
|
+
"> Generated by `roll init --apply` from `.roll/onboard-plan.yaml` "
|
|
115
|
+
"(US-ONBOARD-016/017). Regenerate by re-running onboard."
|
|
116
|
+
)
|
|
117
|
+
lines.append("")
|
|
118
|
+
contexts = _as_list((domain_model or {}).get("bounded_contexts"))
|
|
119
|
+
if not contexts:
|
|
120
|
+
lines.append("_No bounded contexts were inferred from the codebase._")
|
|
121
|
+
lines.append("")
|
|
122
|
+
return "\n".join(lines) + "\n"
|
|
123
|
+
|
|
124
|
+
for ctx in contexts:
|
|
125
|
+
if not isinstance(ctx, dict):
|
|
126
|
+
continue
|
|
127
|
+
name = str(ctx.get("name", "")).strip() or "(unnamed context)"
|
|
128
|
+
lines.append(f"## {name}")
|
|
129
|
+
lines.append("")
|
|
130
|
+
aggregates = _as_list(ctx.get("aggregates"))
|
|
131
|
+
lines.append("**Aggregates**")
|
|
132
|
+
lines.append("")
|
|
133
|
+
if aggregates:
|
|
134
|
+
for agg in aggregates:
|
|
135
|
+
lines.append(f"- {str(agg).strip()}")
|
|
136
|
+
else:
|
|
137
|
+
lines.append("- _none identified_")
|
|
138
|
+
lines.append("")
|
|
139
|
+
language = _as_list(ctx.get("ubiquitous_language"))
|
|
140
|
+
lines.append("**Ubiquitous language**")
|
|
141
|
+
lines.append("")
|
|
142
|
+
if language:
|
|
143
|
+
for term in language:
|
|
144
|
+
text = _term_text(term)
|
|
145
|
+
if text:
|
|
146
|
+
lines.append(f"- {text}")
|
|
147
|
+
else:
|
|
148
|
+
lines.append("- _none identified_")
|
|
149
|
+
lines.append("")
|
|
150
|
+
return "\n".join(lines) + "\n"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def render_tech_analysis(tech: dict | None) -> str:
|
|
154
|
+
"""Render .roll/tech-analysis.md from tech_analysis. Deterministic."""
|
|
155
|
+
lines: list[str] = ["# Technical Analysis", ""]
|
|
156
|
+
lines.append(
|
|
157
|
+
"> Generated by `roll init --apply` from `.roll/onboard-plan.yaml` "
|
|
158
|
+
"(US-ONBOARD-016/017). Regenerate by re-running onboard."
|
|
159
|
+
)
|
|
160
|
+
lines.append("")
|
|
161
|
+
tech = tech or {}
|
|
162
|
+
|
|
163
|
+
def _bullet_section(title: str, key: str) -> None:
|
|
164
|
+
lines.append(f"## {title}")
|
|
165
|
+
lines.append("")
|
|
166
|
+
items = _as_list(tech.get(key))
|
|
167
|
+
if items:
|
|
168
|
+
for item in items:
|
|
169
|
+
lines.append(f"- {str(item).strip()}")
|
|
170
|
+
else:
|
|
171
|
+
lines.append("- _none detected_")
|
|
172
|
+
lines.append("")
|
|
173
|
+
|
|
174
|
+
_bullet_section("Stack", "stack")
|
|
175
|
+
_bullet_section("Dependencies", "dependencies")
|
|
176
|
+
_bullet_section("Architecture notes", "architecture_notes")
|
|
177
|
+
|
|
178
|
+
lines.append("## Risks")
|
|
179
|
+
lines.append("")
|
|
180
|
+
risks = _as_list(tech.get("risks"))
|
|
181
|
+
if not risks:
|
|
182
|
+
lines.append("- _none detected_")
|
|
183
|
+
lines.append("")
|
|
184
|
+
return "\n".join(lines) + "\n"
|
|
185
|
+
|
|
186
|
+
for risk in risks:
|
|
187
|
+
if not isinstance(risk, dict):
|
|
188
|
+
lines.append(f"- {str(risk).strip()}")
|
|
189
|
+
continue
|
|
190
|
+
desc = str(risk.get("description", "")).strip()
|
|
191
|
+
severity = str(risk.get("severity", "")).strip()
|
|
192
|
+
evidence = str(risk.get("evidence", "")).strip()
|
|
193
|
+
tags = []
|
|
194
|
+
if severity:
|
|
195
|
+
tags.append(f"severity: {severity}")
|
|
196
|
+
if evidence:
|
|
197
|
+
tags.append(f"evidence: {evidence}")
|
|
198
|
+
suffix = f" ({', '.join(tags)})" if tags else ""
|
|
199
|
+
lines.append(f"- {desc}{suffix}")
|
|
200
|
+
lines.append("")
|
|
201
|
+
return "\n".join(lines) + "\n"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def render_test_assessment(test: dict | None) -> str:
|
|
205
|
+
"""Render .roll/test-assessment.md from test_assessment. Deterministic.
|
|
206
|
+
|
|
207
|
+
Distinguishes `detected` (a real scan finding) from `inferred` (a judgement)
|
|
208
|
+
by labelling each bullet, honouring US-ONBOARD-016's evidence contract.
|
|
209
|
+
"""
|
|
210
|
+
lines: list[str] = ["# Test Coverage Assessment", ""]
|
|
211
|
+
lines.append(
|
|
212
|
+
"> Generated by `roll init --apply` from `.roll/onboard-plan.yaml` "
|
|
213
|
+
"(US-ONBOARD-016/017). Every claim is evidence-tagged: **detected** "
|
|
214
|
+
"(found by a filesystem scan) or **inferred** (a judgement traceable to "
|
|
215
|
+
"a detected fact)."
|
|
216
|
+
)
|
|
217
|
+
lines.append("")
|
|
218
|
+
test = test or {}
|
|
219
|
+
|
|
220
|
+
def _claims_section(title: str, key: str) -> None:
|
|
221
|
+
lines.append(f"## {title}")
|
|
222
|
+
lines.append("")
|
|
223
|
+
claims = _as_list(test.get(key))
|
|
224
|
+
if claims:
|
|
225
|
+
for claim in claims:
|
|
226
|
+
text, evidence = _claim_text(claim)
|
|
227
|
+
if not text:
|
|
228
|
+
continue
|
|
229
|
+
if evidence:
|
|
230
|
+
lines.append(f"- {text} _(evidence: {evidence})_")
|
|
231
|
+
else:
|
|
232
|
+
lines.append(f"- {text}")
|
|
233
|
+
else:
|
|
234
|
+
lines.append("- _none recorded_")
|
|
235
|
+
lines.append("")
|
|
236
|
+
|
|
237
|
+
_claims_section("Current layers", "current_layers")
|
|
238
|
+
_claims_section("Gaps", "gaps")
|
|
239
|
+
_claims_section("Recommended actions", "recommended_actions")
|
|
240
|
+
return "\n".join(lines) + "\n"
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def collect_seed_candidates(plan: dict) -> tuple[list[str], list[str]]:
|
|
244
|
+
"""Return (story_titles, fix_titles) in plan order.
|
|
245
|
+
|
|
246
|
+
Stories come from test_assessment.recommended_actions (each becomes a
|
|
247
|
+
candidate BACKLOG story). FIX candidates come from tech_analysis.risks whose
|
|
248
|
+
severity == HIGH. Titles are the human-readable claim/description text;
|
|
249
|
+
bash assigns the deterministic US-SEED-NNN / FIX-SEED-NNN ids.
|
|
250
|
+
"""
|
|
251
|
+
stories: list[str] = []
|
|
252
|
+
test = plan.get("test_assessment") or {}
|
|
253
|
+
for claim in _as_list(test.get("recommended_actions")):
|
|
254
|
+
text, _ = _claim_text(claim)
|
|
255
|
+
if text and text.lower() != "none detected":
|
|
256
|
+
stories.append(text)
|
|
257
|
+
|
|
258
|
+
fixes: list[str] = []
|
|
259
|
+
tech = plan.get("tech_analysis") or {}
|
|
260
|
+
for risk in _as_list(tech.get("risks")):
|
|
261
|
+
if not isinstance(risk, dict):
|
|
262
|
+
continue
|
|
263
|
+
if str(risk.get("severity", "")).strip().upper() == HIGH_SEVERITY:
|
|
264
|
+
desc = str(risk.get("description", "")).strip()
|
|
265
|
+
if desc:
|
|
266
|
+
fixes.append(desc)
|
|
267
|
+
return stories, fixes
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _atomic_write(project_dir: Path, rel: str, content: str) -> None:
|
|
271
|
+
"""Write content to <project_dir>/<rel> atomically (temp + os.replace).
|
|
272
|
+
|
|
273
|
+
The temp file is created in the same directory as the target so the rename
|
|
274
|
+
is atomic on the same filesystem.
|
|
275
|
+
"""
|
|
276
|
+
target = project_dir / rel
|
|
277
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
278
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
279
|
+
prefix=f".{target.name}.", suffix=".tmp", dir=str(target.parent)
|
|
280
|
+
)
|
|
281
|
+
try:
|
|
282
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
283
|
+
f.write(content)
|
|
284
|
+
os.replace(tmp_name, str(target))
|
|
285
|
+
except BaseException:
|
|
286
|
+
# Clean up the temp file on any failure so we never leak half-writes.
|
|
287
|
+
try:
|
|
288
|
+
os.unlink(tmp_name)
|
|
289
|
+
except OSError:
|
|
290
|
+
pass
|
|
291
|
+
raise
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _emit(line: str) -> None:
|
|
295
|
+
sys.stdout.write(line + "\n")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def main(argv: list[str]) -> int:
|
|
299
|
+
if len(argv) < 3:
|
|
300
|
+
print(
|
|
301
|
+
"[onboard-render] usage: roll-onboard-render.py <plan.yaml> <project_dir>",
|
|
302
|
+
file=sys.stderr,
|
|
303
|
+
)
|
|
304
|
+
return 1
|
|
305
|
+
|
|
306
|
+
plan_path = Path(argv[1])
|
|
307
|
+
project_dir = Path(argv[2])
|
|
308
|
+
|
|
309
|
+
if not plan_path.is_file():
|
|
310
|
+
print(f"[onboard-render] plan not found: {plan_path}", file=sys.stderr)
|
|
311
|
+
return 1
|
|
312
|
+
if not project_dir.is_dir():
|
|
313
|
+
print(
|
|
314
|
+
f"[onboard-render] project dir not found: {project_dir}", file=sys.stderr
|
|
315
|
+
)
|
|
316
|
+
return 1
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
with plan_path.open("r", encoding="utf-8") as f:
|
|
320
|
+
plan = yaml.safe_load(f)
|
|
321
|
+
except (yaml.YAMLError, OSError) as e:
|
|
322
|
+
print(f"[onboard-render] failed to parse plan: {e}", file=sys.stderr)
|
|
323
|
+
return 1
|
|
324
|
+
|
|
325
|
+
if not isinstance(plan, dict):
|
|
326
|
+
print("[onboard-render] plan must be a top-level mapping", file=sys.stderr)
|
|
327
|
+
return 1
|
|
328
|
+
|
|
329
|
+
has_dm = isinstance(plan.get("domain_model"), dict)
|
|
330
|
+
has_ta = isinstance(plan.get("tech_analysis"), dict)
|
|
331
|
+
has_test = isinstance(plan.get("test_assessment"), dict)
|
|
332
|
+
if not (has_dm or has_ta or has_test):
|
|
333
|
+
# No Phase 2 content at all — nothing to render. Bash treats exit 2 as a
|
|
334
|
+
# clean no-op (an old plan, or a minimal onboard).
|
|
335
|
+
return 2
|
|
336
|
+
|
|
337
|
+
# Render bodies first (pure, in-memory) so a render bug fails before we
|
|
338
|
+
# touch the filesystem at all.
|
|
339
|
+
try:
|
|
340
|
+
bodies = [
|
|
341
|
+
(CONTEXT_MAP_REL, render_context_map(plan.get("domain_model"))),
|
|
342
|
+
(TECH_ANALYSIS_REL, render_tech_analysis(plan.get("tech_analysis"))),
|
|
343
|
+
(TEST_ASSESSMENT_REL, render_test_assessment(plan.get("test_assessment"))),
|
|
344
|
+
]
|
|
345
|
+
except Exception as e: # pragma: no cover - defensive
|
|
346
|
+
print(f"[onboard-render] render failed: {e}", file=sys.stderr)
|
|
347
|
+
return 1
|
|
348
|
+
|
|
349
|
+
# Stage + atomically write all three. If any write fails, roll back the
|
|
350
|
+
# ones already renamed so we never leave partial state.
|
|
351
|
+
written: list[str] = []
|
|
352
|
+
try:
|
|
353
|
+
for rel, body in bodies:
|
|
354
|
+
_atomic_write(project_dir, rel, body)
|
|
355
|
+
written.append(rel)
|
|
356
|
+
except OSError as e:
|
|
357
|
+
for rel in written:
|
|
358
|
+
try:
|
|
359
|
+
os.unlink(str(project_dir / rel))
|
|
360
|
+
except OSError:
|
|
361
|
+
pass
|
|
362
|
+
print(f"[onboard-render] write failed: {e}", file=sys.stderr)
|
|
363
|
+
return 1
|
|
364
|
+
|
|
365
|
+
stories, fixes = collect_seed_candidates(plan)
|
|
366
|
+
|
|
367
|
+
# Emit the manifest only after every file is on disk.
|
|
368
|
+
for rel, _ in bodies:
|
|
369
|
+
_emit(f"FILE|{rel}")
|
|
370
|
+
for i, title in enumerate(stories, start=1):
|
|
371
|
+
_emit(f"SEED|US-SEED-{i:03d}|{title}")
|
|
372
|
+
for i, desc in enumerate(fixes, start=1):
|
|
373
|
+
_emit(f"FIX|FIX-SEED-{i:03d}|{desc}")
|
|
374
|
+
return 0
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
if __name__ == "__main__":
|
|
378
|
+
sys.exit(main(sys.argv))
|
package/lib/roll-peer.py
CHANGED
|
@@ -99,7 +99,7 @@ _FIXTURE_VERDICT = {
|
|
|
99
99
|
"reason": "2 rounds · 5 turns · all blocks resolved",
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
_FIXTURE_ARTIFACT = "
|
|
102
|
+
_FIXTURE_ARTIFACT = ".roll/peer/logs/20260519_213700_claude_codex.md"
|
|
103
103
|
_FIXTURE_NEXT = [
|
|
104
104
|
("Continue execution", "claude resumes work on US-AUTH-014"),
|
|
105
105
|
("Inspect log", "open the artifact above to replay the transcript"),
|
|
@@ -34,6 +34,38 @@ Schema (v1):
|
|
|
34
34
|
gitignore_dot_roll: bool
|
|
35
35
|
sync_targets: [str]
|
|
36
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).
|
|
37
69
|
"""
|
|
38
70
|
|
|
39
71
|
from __future__ import annotations
|
|
@@ -58,6 +90,14 @@ MAX_AGE_HOURS = 24
|
|
|
58
90
|
VALID_PROJECT_TYPES = {"backend-service", "frontend-only", "fullstack", "cli"}
|
|
59
91
|
VALID_SCOPE_ITEMS = {"backlog", "features", "domain", "briefs"}
|
|
60
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
|
+
|
|
61
101
|
|
|
62
102
|
def err(msg_en: str, msg_zh: str = "") -> None:
|
|
63
103
|
"""Print bilingual error to stderr."""
|
|
@@ -162,6 +202,126 @@ def validate_privacy(plan: dict) -> list[str]:
|
|
|
162
202
|
return errors
|
|
163
203
|
|
|
164
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
|
+
|
|
165
325
|
def main(argv: list[str]) -> int:
|
|
166
326
|
if len(argv) < 2:
|
|
167
327
|
err("usage: roll-plan-validate.py <plan.yaml>", "用法: roll-plan-validate.py <plan.yaml>")
|
|
@@ -189,6 +349,11 @@ def main(argv: list[str]) -> int:
|
|
|
189
349
|
schema_errors += validate_project_understanding(plan)
|
|
190
350
|
schema_errors += validate_scope(plan)
|
|
191
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)
|
|
192
357
|
|
|
193
358
|
freshness_errors, is_stale = validate_freshness(plan)
|
|
194
359
|
|
package/lib/roll_git.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""
|
|
2
|
+
roll_git — shared git helpers for roll CLI scripts.
|
|
3
|
+
|
|
4
|
+
Standalone entry scripts (roll-home.py, roll-loop-status.py, ...) insert
|
|
5
|
+
_LIB_DIR into sys.path and import these helpers, mirroring the roll_render
|
|
6
|
+
import pattern. Keeps git/subprocess plumbing out of the pure-rendering
|
|
7
|
+
roll_render module and gives slug derivation a single source of truth.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
import subprocess
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def git_remote_url(repo_path: str) -> Optional[str]:
|
|
16
|
+
"""Return the remote URL for a git repo — origin first, then any — or None."""
|
|
17
|
+
try:
|
|
18
|
+
url = subprocess.check_output(
|
|
19
|
+
["git", "-C", repo_path, "remote", "get-url", "origin"],
|
|
20
|
+
stderr=subprocess.DEVNULL, text=True,
|
|
21
|
+
).strip()
|
|
22
|
+
if url:
|
|
23
|
+
return url
|
|
24
|
+
except Exception:
|
|
25
|
+
pass
|
|
26
|
+
# Fallback: first available remote
|
|
27
|
+
try:
|
|
28
|
+
remotes = subprocess.check_output(
|
|
29
|
+
["git", "-C", repo_path, "remote"],
|
|
30
|
+
stderr=subprocess.DEVNULL, text=True,
|
|
31
|
+
).strip().splitlines()
|
|
32
|
+
if remotes:
|
|
33
|
+
url = subprocess.check_output(
|
|
34
|
+
["git", "-C", repo_path, "remote", "get-url", remotes[0]],
|
|
35
|
+
stderr=subprocess.DEVNULL, text=True,
|
|
36
|
+
).strip()
|
|
37
|
+
if url:
|
|
38
|
+
return url
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
return None
|
|
@@ -97,8 +97,14 @@ new class names — the template's CSS is the single source of truth.
|
|
|
97
97
|
## Usage
|
|
98
98
|
|
|
99
99
|
Partials are consumed by `lib/slides-render.py` when a `deck.md` slide declares
|
|
100
|
-
a `layout` field
|
|
101
|
-
|
|
100
|
+
a `layout` field. A slide that omits `layout` renders as `plain`.
|
|
101
|
+
|
|
102
|
+
For the user-facing walkthrough — per-layout `deck.md` examples, rendered
|
|
103
|
+
screenshots, and how `$roll-deck` picks a layout — see the Layouts section of
|
|
104
|
+
the slides guide: [`guide/en/slides.md`](../../../guide/en/slides.md#layouts)
|
|
105
|
+
([中文](../../../guide/zh/slides.md#layouts布局)). Field names in this file,
|
|
106
|
+
that guide, and `skills/roll-deck/SKILL.md` are kept in sync — change one,
|
|
107
|
+
change all three.
|
|
102
108
|
|
|
103
109
|
```markdown
|
|
104
110
|
### Slide 3: Architecture Overview
|
|
@@ -465,12 +465,7 @@
|
|
|
465
465
|
{{#slides}}
|
|
466
466
|
<!-- {{number}}: {{title_en}} -->
|
|
467
467
|
<div class="slide{{#is_cover}} active{{/is_cover}}" data-slide="{{number}}">
|
|
468
|
-
|
|
469
|
-
{{{body_en}}}
|
|
470
|
-
</div>
|
|
471
|
-
<div class="lang-zh">
|
|
472
|
-
{{{body_zh}}}
|
|
473
|
-
</div>
|
|
468
|
+
{{{slide_inner_html}}}
|
|
474
469
|
{{^is_cover}}<span class="slide-number">{{number_padded}} / {{total_slides}}</span>{{/is_cover}}
|
|
475
470
|
</div>
|
|
476
471
|
{{/slides}}
|