@seanyao/roll 2026.529.5 → 2026.601.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +57 -25
  2. package/README.md +10 -7
  3. package/bin/roll +3952 -317
  4. package/conventions/config.yaml +7 -0
  5. package/lib/__pycache__/github_sync.cpython-314.pyc +0 -0
  6. package/lib/__pycache__/loop_result_eval.cpython-314.pyc +0 -0
  7. package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
  8. package/lib/__pycache__/roll-home.cpython-314.pyc +0 -0
  9. package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
  10. package/lib/__pycache__/roll_git.cpython-314.pyc +0 -0
  11. package/lib/__pycache__/slides-render.cpython-314.pyc +0 -0
  12. package/lib/agent_usage/__init__.py +4 -0
  13. package/lib/agent_usage/__pycache__/__init__.cpython-314.pyc +0 -0
  14. package/lib/agent_usage/__pycache__/gemini.cpython-314.pyc +0 -0
  15. package/lib/agent_usage/__pycache__/kimi.cpython-314.pyc +0 -0
  16. package/lib/agent_usage/__pycache__/openai.cpython-314.pyc +0 -0
  17. package/lib/agent_usage/__pycache__/qwen.cpython-314.pyc +0 -0
  18. package/lib/agent_usage/gemini.py +127 -0
  19. package/lib/agent_usage/kimi.py +127 -0
  20. package/lib/agent_usage/openai.py +126 -0
  21. package/lib/agent_usage/qwen.py +128 -0
  22. package/lib/context_feed_budget.sh +194 -0
  23. package/lib/github_sync.py +876 -0
  24. package/lib/i18n/agent.sh +54 -0
  25. package/lib/i18n/init.sh +22 -0
  26. package/lib/i18n/peer.sh +7 -0
  27. package/lib/i18n/peer_help.sh +4 -0
  28. package/lib/i18n/skills_catalog.sh +30 -0
  29. package/lib/loop-exit-summary.py +393 -0
  30. package/lib/loop-fmt.py +93 -75
  31. package/lib/loop_pick_agent.py +241 -170
  32. package/lib/loop_result_eval.py +469 -0
  33. package/lib/model_prices.py +0 -10
  34. package/lib/roll-home.py +1 -28
  35. package/lib/roll-loop-status.py +330 -40
  36. package/lib/roll-onboard-render.py +378 -0
  37. package/lib/roll-peer.py +1 -1
  38. package/lib/roll-plan-validate.py +165 -0
  39. package/lib/roll_git.py +41 -0
  40. package/lib/slides/components/README.md +8 -2
  41. package/lib/slides/templates/introduction-v3.html +1 -6
  42. package/lib/slides-render.py +305 -15
  43. package/lib/slides-validate.py +195 -7
  44. package/package.json +1 -1
  45. package/skills/roll-.changelog/SKILL.md +67 -56
  46. package/skills/roll-brief/SKILL.md +1 -1
  47. package/skills/roll-build/SKILL.md +14 -12
  48. package/skills/roll-deck/SKILL.md +152 -0
  49. package/skills/roll-design/SKILL.md +13 -6
  50. package/skills/roll-doc/SKILL.md +269 -6
  51. package/skills/roll-fix/SKILL.md +15 -9
  52. package/skills/roll-loop/SKILL.md +9 -7
  53. package/skills/roll-notes/SKILL.md +1 -1
  54. package/skills/roll-onboard/SKILL.md +85 -0
  55. package/skills/roll-peer/SKILL.md +6 -5
  56. package/lib/agent_routes_lint.py +0 -203
  57. package/skills/roll-research/SKILL.md +0 -316
  58. package/skills/roll-research/references/schema.json +0 -166
  59. package/skills/roll-research/scripts/md_to_pdf.py +0 -289
@@ -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 = "~/.roll/.peer-state/logs/20260519_213700_claude_codex.md"
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
 
@@ -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 (US-DECK-017). Until then, all partials render the same
101
- `plain` layout by default.
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
- <div class="lang-en">
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}}