@seanyao/roll 0.5.0 → 2.602.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +736 -0
- package/LICENSE +21 -0
- package/README.md +65 -165
- package/bin/dream-test-quality-scan +110 -0
- package/bin/roll +15030 -814
- 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 +194 -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 +15 -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 +733 -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 +579 -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
|
@@ -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))
|