@seanyao/roll 0.5.0 → 2.602.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/CHANGELOG.md +717 -0
  2. package/LICENSE +21 -0
  3. package/README.md +65 -165
  4. package/bin/dream-test-quality-scan +110 -0
  5. package/bin/roll +14897 -815
  6. package/conventions/config.yaml +17 -1
  7. package/conventions/global/AGENTS.md +146 -100
  8. package/conventions/global/CLAUDE.md +1 -21
  9. package/conventions/global/GEMINI.md +8 -22
  10. package/conventions/global/project_rules.md +9 -0
  11. package/conventions/templates/backend-service/AGENTS.md +30 -81
  12. package/conventions/templates/backend-service/GEMINI.md +3 -3
  13. package/conventions/templates/backend-service/project_rules.md +16 -0
  14. package/conventions/templates/cli/AGENTS.md +31 -58
  15. package/conventions/templates/cli/CLAUDE.md +3 -5
  16. package/conventions/templates/cli/GEMINI.md +3 -3
  17. package/conventions/templates/cli/project_rules.md +16 -0
  18. package/conventions/templates/frontend-only/AGENTS.md +29 -64
  19. package/conventions/templates/frontend-only/GEMINI.md +3 -3
  20. package/conventions/templates/frontend-only/project_rules.md +14 -0
  21. package/conventions/templates/fullstack/AGENTS.md +31 -79
  22. package/conventions/templates/fullstack/CLAUDE.md +1 -1
  23. package/conventions/templates/fullstack/GEMINI.md +3 -3
  24. package/conventions/templates/fullstack/project_rules.md +15 -0
  25. package/lib/README.md +42 -0
  26. package/lib/__pycache__/github_sync.cpython-314.pyc +0 -0
  27. package/lib/__pycache__/loop-fmt.cpython-314.pyc +0 -0
  28. package/lib/__pycache__/loop_result_eval.cpython-314.pyc +0 -0
  29. package/lib/__pycache__/loop_unstick.cpython-314.pyc +0 -0
  30. package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
  31. package/lib/__pycache__/prices_fetcher.cpython-314.pyc +0 -0
  32. package/lib/__pycache__/roll-home.cpython-314.pyc +0 -0
  33. package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
  34. package/lib/__pycache__/roll_git.cpython-314.pyc +0 -0
  35. package/lib/__pycache__/roll_render.cpython-314.pyc +0 -0
  36. package/lib/__pycache__/slides-render.cpython-314.pyc +0 -0
  37. package/lib/agent_usage/README.md +49 -0
  38. package/lib/agent_usage/__init__.py +108 -0
  39. package/lib/agent_usage/__pycache__/__init__.cpython-314.pyc +0 -0
  40. package/lib/agent_usage/__pycache__/gemini.cpython-314.pyc +0 -0
  41. package/lib/agent_usage/__pycache__/kimi.cpython-314.pyc +0 -0
  42. package/lib/agent_usage/__pycache__/openai.cpython-314.pyc +0 -0
  43. package/lib/agent_usage/__pycache__/pi.cpython-314.pyc +0 -0
  44. package/lib/agent_usage/__pycache__/pi_emit.cpython-314.pyc +0 -0
  45. package/lib/agent_usage/__pycache__/qwen.cpython-314.pyc +0 -0
  46. package/lib/agent_usage/gemini.py +127 -0
  47. package/lib/agent_usage/kimi.py +278 -0
  48. package/lib/agent_usage/kimi_emit.py +123 -0
  49. package/lib/agent_usage/openai.py +126 -0
  50. package/lib/agent_usage/pi.py +200 -0
  51. package/lib/agent_usage/pi_emit.py +135 -0
  52. package/lib/agent_usage/qwen.py +128 -0
  53. package/lib/backfill-pi-usage.py +243 -0
  54. package/lib/changelog_audit.py +155 -0
  55. package/lib/changelog_generate.py +263 -0
  56. package/lib/context_feed_budget.sh +194 -0
  57. package/lib/github_sync.py +876 -0
  58. package/lib/i18n/README.md +54 -0
  59. package/lib/i18n/agent.sh +75 -0
  60. package/lib/i18n/alert.sh +20 -0
  61. package/lib/i18n/backlog.sh +96 -0
  62. package/lib/i18n/brief.sh +5 -0
  63. package/lib/i18n/changelog.sh +5 -0
  64. package/lib/i18n/ci.sh +15 -0
  65. package/lib/i18n/debug.sh +0 -0
  66. package/lib/i18n/doctor.sh +44 -0
  67. package/lib/i18n/dream.sh +0 -0
  68. package/lib/i18n/init.sh +91 -0
  69. package/lib/i18n/lang.sh +10 -0
  70. package/lib/i18n/loop.sh +140 -0
  71. package/lib/i18n/migrate.sh +74 -0
  72. package/lib/i18n/offboard.sh +31 -0
  73. package/lib/i18n/onboard.sh +0 -0
  74. package/lib/i18n/peer.sh +41 -0
  75. package/lib/i18n/peer_help.sh +25 -0
  76. package/lib/i18n/peer_reset.sh +7 -0
  77. package/lib/i18n/peer_status.sh +5 -0
  78. package/lib/i18n/prices.sh +3 -0
  79. package/lib/i18n/prices_refresh.sh +17 -0
  80. package/lib/i18n/prices_show.sh +7 -0
  81. package/lib/i18n/propose.sh +0 -0
  82. package/lib/i18n/release.sh +0 -0
  83. package/lib/i18n/research.sh +0 -0
  84. package/lib/i18n/review_pr.sh +0 -0
  85. package/lib/i18n/sentinel.sh +0 -0
  86. package/lib/i18n/setup.sh +3 -0
  87. package/lib/i18n/shared.sh +157 -0
  88. package/lib/i18n/skills/roll-brief.sh +47 -0
  89. package/lib/i18n/skills/roll-build.sh +97 -0
  90. package/lib/i18n/skills/roll-design.sh +18 -0
  91. package/lib/i18n/skills/roll-fix.sh +53 -0
  92. package/lib/i18n/skills/roll-loop.sh +28 -0
  93. package/lib/i18n/skills/roll-onboard.sh +33 -0
  94. package/lib/i18n/skills_catalog.sh +30 -0
  95. package/lib/i18n/slides.sh +3 -0
  96. package/lib/i18n/slides_build.sh +38 -0
  97. package/lib/i18n/slides_delete.sh +19 -0
  98. package/lib/i18n/slides_list.sh +14 -0
  99. package/lib/i18n/slides_logs.sh +12 -0
  100. package/lib/i18n/slides_new.sh +15 -0
  101. package/lib/i18n/slides_preview.sh +14 -0
  102. package/lib/i18n/slides_templates.sh +7 -0
  103. package/lib/i18n/status.sh +21 -0
  104. package/lib/i18n/update.sh +24 -0
  105. package/lib/i18n.sh +211 -0
  106. package/lib/loop-exit-summary.py +393 -0
  107. package/lib/loop-fmt.py +589 -0
  108. package/lib/loop_pick_agent.py +316 -0
  109. package/lib/loop_result_eval.py +469 -0
  110. package/lib/loop_unstick.py +180 -0
  111. package/lib/model_prices.py +186 -0
  112. package/lib/prices/README.md +35 -0
  113. package/lib/prices/snapshot-2026-05-22.json +22 -0
  114. package/lib/prices/snapshot-2026-05-23-deepseek.json +15 -0
  115. package/lib/prices/snapshot-2026-05-23-kimi.json +14 -0
  116. package/lib/prices_fetcher.py +285 -0
  117. package/lib/roll-backlog.py +225 -0
  118. package/lib/roll-brief.py +286 -0
  119. package/lib/roll-help.py +158 -0
  120. package/lib/roll-home.py +556 -0
  121. package/lib/roll-init.py +156 -0
  122. package/lib/roll-loop-status.py +1683 -0
  123. package/lib/roll-loop-story.py +191 -0
  124. package/lib/roll-onboard-render.py +378 -0
  125. package/lib/roll-peer.py +252 -0
  126. package/lib/roll-plan-validate.py +386 -0
  127. package/lib/roll-setup.py +102 -0
  128. package/lib/roll-status.py +367 -0
  129. package/lib/roll_git.py +41 -0
  130. package/lib/roll_render.py +414 -0
  131. package/lib/slides/components/README.md +123 -0
  132. package/lib/slides/components/cards-2.html +9 -0
  133. package/lib/slides/components/cards-3.html +9 -0
  134. package/lib/slides/components/cards-4.html +9 -0
  135. package/lib/slides/components/compare.html +22 -0
  136. package/lib/slides/components/highlight.html +9 -0
  137. package/lib/slides/components/pipeline.html +12 -0
  138. package/lib/slides/components/plain.html +7 -0
  139. package/lib/slides/components/quote.html +4 -0
  140. package/lib/slides/components/timeline.html +9 -0
  141. package/lib/slides/templates/introduction-v3.html +571 -0
  142. package/lib/slides/templates/pitch.html +0 -0
  143. package/lib/slides-render.py +778 -0
  144. package/lib/slides-validate.py +357 -0
  145. package/lib/test_quality_gate.py +143 -0
  146. package/package.json +8 -7
  147. package/skills/roll-.changelog/SKILL.md +406 -33
  148. package/skills/roll-.clarify/SKILL.md +5 -2
  149. package/skills/roll-.dream/SKILL.md +374 -0
  150. package/skills/roll-.echo/SKILL.md +5 -2
  151. package/skills/roll-.qa/SKILL.md +57 -3
  152. package/skills/roll-.review/SKILL.md +42 -3
  153. package/skills/roll-brief/SKILL.md +209 -0
  154. package/skills/roll-build/SKILL.md +308 -63
  155. package/skills/roll-debug/SKILL.md +341 -162
  156. package/skills/roll-debug/injectable-bb.js +263 -0
  157. package/skills/roll-deck/SKILL.md +296 -0
  158. package/skills/roll-design/ENGINEERING_CHECKLIST.md +1 -1
  159. package/skills/roll-design/SKILL.md +727 -94
  160. package/skills/roll-doc/SKILL.md +595 -0
  161. package/skills/roll-doctor/SKILL.md +192 -0
  162. package/skills/roll-fix/SKILL.md +149 -32
  163. package/skills/{roll-jot → roll-idea}/SKILL.md +18 -10
  164. package/skills/roll-loop/SKILL.md +578 -0
  165. package/skills/roll-notes/SKILL.md +103 -0
  166. package/skills/roll-onboard/SKILL.md +234 -0
  167. package/skills/roll-peer/SKILL.md +336 -0
  168. package/skills/roll-propose/SKILL.md +157 -0
  169. package/skills/roll-review-pr/SKILL.md +58 -0
  170. package/skills/roll-sentinel/SKILL.md +11 -2
  171. package/skills/roll-spar/SKILL.md +8 -6
  172. package/template/.github/workflows/ci.yml +5 -2
  173. package/template/AGENTS.md +20 -74
  174. package/skills/roll-research/SKILL.md +0 -307
  175. package/skills/roll-research/references/schema.json +0 -162
  176. package/skills/roll-research/scripts/md_to_pdf.py +0 -289
  177. package/tools/roll-fetch/SKILL.md +0 -182
  178. package/tools/roll-fetch/package.json +0 -15
  179. package/tools/roll-fetch/smart-web-fetch.js +0 -558
  180. package/tools/roll-probe/SKILL.md +0 -84
  181. /package/template/{BACKLOG.md → .roll/backlog.md} +0 -0
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ roll-loop-story — compact per-story rollup for `roll loop story <ID>`.
4
+
5
+ Loads the same event / cron / runs / git-merge sources as roll-loop-status,
6
+ filters cycles to one story id (case-insensitive), and renders a single
7
+ panel covering cycles count, span, duration, tokens, cost, model, PR list,
8
+ and recent cycle lines.
9
+
10
+ Usage:
11
+ roll loop story US-LOOP-004
12
+ roll loop story us-loop-004 # case-insensitive
13
+ roll loop story US-LOOP-004 --json # machine-readable
14
+ roll loop story US-LOOP-004 --days 30 # widen window (default 30)
15
+
16
+ Exit codes:
17
+ 0 — at least one cycle found
18
+ 2 — story id has no cycles in the event window
19
+ """
20
+ from __future__ import annotations
21
+ import argparse, importlib.util, json, os, sys
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+ from typing import Any, Dict, List
25
+
26
+ _LIB_DIR = os.path.dirname(os.path.realpath(__file__))
27
+
28
+ # Reuse the loaders + aggregator from roll-loop-status.py. importlib because the
29
+ # filename has a hyphen and isn't import-safe.
30
+ _spec = importlib.util.spec_from_file_location(
31
+ "_rls", os.path.join(_LIB_DIR, "roll-loop-status.py")
32
+ )
33
+ rls = importlib.util.module_from_spec(_spec)
34
+ _spec.loader.exec_module(rls)
35
+
36
+
37
+ def collect_cycles(days: int) -> List[Dict[str, Any]]:
38
+ slug = rls.project_slug()
39
+ events = rls.load_events(slug, days)
40
+ cron = rls.load_cron_log(slug)
41
+ runs = rls.load_runs(slug)
42
+ git_merges = rls.load_pr_merges_from_git(days)
43
+ cycles = rls.aggregate(events, cron)
44
+ if runs:
45
+ rls.merge_runs_into_cycles(cycles, runs)
46
+ if git_merges:
47
+ rls.repair_orphan_cycles_from_git(cycles, git_merges)
48
+ rls.backfill_usage_from_claude_sessions(cycles, slug)
49
+ return cycles
50
+
51
+
52
+ def _outcome_glyph(o: str) -> str:
53
+ return {"fail": "✗", "running": "⏵", "idle": "·"}.get(o, "✓")
54
+
55
+
56
+ def _fmt_dt(dt: datetime) -> str:
57
+ return dt.astimezone().strftime("%Y-%m-%d %H:%M")
58
+
59
+
60
+ def _fmt_dur(s: int) -> str:
61
+ if not s:
62
+ return "—"
63
+ h, rem = divmod(s, 3600)
64
+ m, _ = divmod(rem, 60)
65
+ return f"{h}h {m:02d}m" if h else f"{m}m"
66
+
67
+
68
+ def _fmt_tokens(n: int) -> str:
69
+ if not n:
70
+ return "0"
71
+ if n >= 1_000_000:
72
+ return f"{n/1_000_000:.1f}M"
73
+ if n >= 1_000:
74
+ return f"{n/1_000:.0f}k"
75
+ return str(n)
76
+
77
+
78
+ def _fmt_pr(p: Dict[str, Any]) -> str:
79
+ g = {"merged": "✓", "closed": "✗"}.get(p["outcome"], "⏵")
80
+ return f"#{p['num']} {g}"
81
+
82
+
83
+ def render_panel(r: Dict[str, Any], description: str = "") -> str:
84
+ head = f"── {r['story_id']}"
85
+ if description:
86
+ head += f" · {description}"
87
+ head += " " + "─" * max(0, 78 - len(head))
88
+
89
+ cycles = r["cycles"]
90
+ span = "—"
91
+ if r.get("span_start") and r.get("span_end"):
92
+ span = f"{_fmt_dt(r['span_start'])} → {_fmt_dt(r['span_end'])}"
93
+ elif r.get("span_start"):
94
+ span = f"{_fmt_dt(r['span_start'])} → (running)"
95
+
96
+ counts = f" cycles {r['count']} (✓ {r['ok_count']} ✗ {r['fail_count']} ⏵ {r['running_count']})"
97
+ line_span = f" span {span}"
98
+ line_dur = (f" duration {_fmt_dur(r['duration_s'])}"
99
+ f" tokens in {_fmt_tokens(r['input_tokens'])}"
100
+ f" out {_fmt_tokens(r['output_tokens'])}"
101
+ f" cache w {_fmt_tokens(r['cache_creation_tokens'])}"
102
+ f" r {_fmt_tokens(r['cache_read_tokens'])}")
103
+ model = r.get("model") or "—"
104
+ line_cost = f" cost ${r['cost']:.2f} model {model}"
105
+
106
+ prs = r.get("prs") or []
107
+ line_prs = " PRs " + (" ".join(_fmt_pr(p) for p in prs[:8]) if prs else "—")
108
+
109
+ # Recent 3 cycles (oldest → newest of the matched set; mirror confirmed layout).
110
+ recent = sorted(cycles, key=lambda c: c.get("start") or datetime.min.replace(tzinfo=timezone.utc))[-3:]
111
+ recent_lines: List[str] = []
112
+ for i, cy in enumerate(recent):
113
+ label = cy.get("label", "—")
114
+ glyph = _outcome_glyph(cy.get("outcome", ""))
115
+ cost = cy.get("cost_list")
116
+ cost_s = f"${cost:.2f}" if cost is not None else "—"
117
+ prefix = " recent " if i == 0 else " "
118
+ recent_lines.append(f"{prefix} {label} {glyph} {cost_s}")
119
+ if not recent_lines:
120
+ recent_lines.append(" recent —")
121
+
122
+ return "\n".join([head, counts, line_span, line_dur, line_cost, line_prs] + recent_lines)
123
+
124
+
125
+ def to_json(r: Dict[str, Any]) -> str:
126
+ def conv(o: Any) -> Any:
127
+ if isinstance(o, datetime):
128
+ return o.astimezone(timezone.utc).isoformat()
129
+ raise TypeError(f"{type(o)} not serializable")
130
+
131
+ payload = {k: v for k, v in r.items() if k != "cycles"}
132
+ payload["cycles"] = [
133
+ {
134
+ "label": cy.get("label"),
135
+ "start": cy.get("start"),
136
+ "end": cy.get("end"),
137
+ "outcome": cy.get("outcome"),
138
+ "duration_s": cy.get("duration_s"),
139
+ "input_tokens": cy.get("input_tokens"),
140
+ "output_tokens": cy.get("output_tokens"),
141
+ "cache_creation_tokens": cy.get("cache_creation_tokens"),
142
+ "cache_read_tokens": cy.get("cache_read_tokens"),
143
+ "cost_list": cy.get("cost_list"),
144
+ "model": cy.get("model"),
145
+ "pr_num": cy.get("pr_num"),
146
+ "pr_outcome": cy.get("pr_outcome"),
147
+ }
148
+ for cy in r.get("cycles", [])
149
+ ]
150
+ return json.dumps(payload, default=conv, indent=2)
151
+
152
+
153
+ def _backlog_description(story_id: str) -> str:
154
+ bl = rls.load_backlog()
155
+ return bl.get(story_id.upper(), "")
156
+
157
+
158
+ def main(argv=None) -> int:
159
+ p = argparse.ArgumentParser(
160
+ description="roll loop story — per-story cycle rollup")
161
+ p.add_argument("story_id", help="Story ID (case-insensitive, e.g. US-LOOP-004)")
162
+ p.add_argument("--days", type=int, default=30,
163
+ help="event window in days (default 30)")
164
+ p.add_argument("--json", action="store_true",
165
+ help="emit machine-readable JSON instead of the panel")
166
+ args = p.parse_args(argv)
167
+
168
+ cycles = collect_cycles(args.days)
169
+ r = rls.rollup_for_story(cycles, args.story_id)
170
+
171
+ if args.json:
172
+ print(to_json(r))
173
+ return 0 if r["count"] > 0 else 2
174
+
175
+ if r["count"] == 0:
176
+ sys.stderr.write(
177
+ f"roll loop story: no cycles found for {args.story_id} "
178
+ f"in the last {args.days} days\n"
179
+ f"未找到 {args.story_id} 在最近 {args.days} 天内的循环\n"
180
+ )
181
+ return 2
182
+
183
+ print(render_panel(r, _backlog_description(args.story_id.upper())))
184
+ return 0
185
+
186
+
187
+ if __name__ == "__main__":
188
+ try:
189
+ sys.exit(main())
190
+ except BrokenPipeError:
191
+ pass
@@ -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))