workflow-toolkit 1.0.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 (142) hide show
  1. package/.agents/skills/ponytail/SKILL.md +111 -0
  2. package/.agents/skills/ponytail-audit/SKILL.md +37 -0
  3. package/.agents/skills/ponytail-debt/SKILL.md +41 -0
  4. package/.agents/skills/ponytail-gain/SKILL.md +47 -0
  5. package/.agents/skills/ponytail-help/SKILL.md +70 -0
  6. package/.agents/skills/ponytail-review/SKILL.md +52 -0
  7. package/.agents/skills/prompt-review/SKILL.md +60 -0
  8. package/.agents/skills/wtk/SKILL.md +64 -0
  9. package/.agents/skills/wtk/references/artifacts.md +82 -0
  10. package/.agents/skills/wtk/references/evidence.md +68 -0
  11. package/.agents/skills/wtk/references/git.md +75 -0
  12. package/.agents/skills/wtk/references/test-contract.md +76 -0
  13. package/.agents/skills/wtk/references/validation.md +127 -0
  14. package/.agents/skills/wtk-config/SKILL.md +102 -0
  15. package/.agents/skills/wtk-config/assets/agents/claude/deep-reviewer.md +40 -0
  16. package/.agents/skills/wtk-config/assets/agents/claude/designer.md +57 -0
  17. package/.agents/skills/wtk-config/assets/agents/claude/explorer.md +41 -0
  18. package/.agents/skills/wtk-config/assets/agents/claude/implementer.md +57 -0
  19. package/.agents/skills/wtk-config/assets/agents/claude/planner.md +68 -0
  20. package/.agents/skills/wtk-config/assets/agents/claude/verifier.md +80 -0
  21. package/.agents/skills/wtk-config/assets/agents/codex/deep-reviewer.toml +41 -0
  22. package/.agents/skills/wtk-config/assets/agents/codex/designer.toml +55 -0
  23. package/.agents/skills/wtk-config/assets/agents/codex/explorer.toml +35 -0
  24. package/.agents/skills/wtk-config/assets/agents/codex/implementer.toml +52 -0
  25. package/.agents/skills/wtk-config/assets/agents/codex/planner.toml +66 -0
  26. package/.agents/skills/wtk-config/assets/agents/codex/verifier.toml +78 -0
  27. package/.agents/skills/wtk-config/assets/agents/cursor/deep-reviewer.md +38 -0
  28. package/.agents/skills/wtk-config/assets/agents/cursor/designer.md +55 -0
  29. package/.agents/skills/wtk-config/assets/agents/cursor/explorer.md +39 -0
  30. package/.agents/skills/wtk-config/assets/agents/cursor/implementer.md +55 -0
  31. package/.agents/skills/wtk-config/assets/agents/cursor/planner.md +66 -0
  32. package/.agents/skills/wtk-config/assets/agents/cursor/verifier.md +79 -0
  33. package/.agents/skills/wtk-config/scripts/ad-index.py +123 -0
  34. package/.agents/skills/wtk-config/scripts/repository_intelligence.py +671 -0
  35. package/.agents/skills/wtk-config/scripts/workflow_config.py +972 -0
  36. package/.agents/skills/wtk-deep-review/SKILL.md +164 -0
  37. package/.agents/skills/wtk-deep-review/assets/PROMPT.md +56 -0
  38. package/.agents/skills/wtk-deep-review/assets/REVIEW_UI.html +1340 -0
  39. package/.agents/skills/wtk-deep-review/assets/findings.schema.json +113 -0
  40. package/.agents/skills/wtk-deep-review/references/context-pack.md +88 -0
  41. package/.agents/skills/wtk-deep-review/references/orchestration.md +164 -0
  42. package/.agents/skills/wtk-deep-review/references/output-contracts.md +149 -0
  43. package/.agents/skills/wtk-deep-review/references/publish-github.md +83 -0
  44. package/.agents/skills/wtk-deep-review/references/state-and-learnings.md +64 -0
  45. package/.agents/skills/wtk-deep-review/references/subagent-runtimes.md +33 -0
  46. package/.agents/skills/wtk-deep-review/references/taxonomy.md +70 -0
  47. package/.agents/skills/wtk-deep-review/scripts/_common.py +371 -0
  48. package/.agents/skills/wtk-deep-review/scripts/build_jobs.py +561 -0
  49. package/.agents/skills/wtk-deep-review/scripts/build_knowledge.py +349 -0
  50. package/.agents/skills/wtk-deep-review/scripts/build_manifest.py +479 -0
  51. package/.agents/skills/wtk-deep-review/scripts/graft_context.py +160 -0
  52. package/.agents/skills/wtk-deep-review/scripts/graphify_context.py +75 -0
  53. package/.agents/skills/wtk-deep-review/scripts/merge_findings.py +313 -0
  54. package/.agents/skills/wtk-deep-review/scripts/render_html.py +240 -0
  55. package/.agents/skills/wtk-deep-review/scripts/render_review.py +282 -0
  56. package/.agents/skills/wtk-deep-review/scripts/run_jobs.py +436 -0
  57. package/.agents/skills/wtk-deep-review/scripts/token_metrics.py +369 -0
  58. package/.agents/skills/wtk-discover/SKILL.md +270 -0
  59. package/.agents/skills/wtk-discover/references/document-format.md +149 -0
  60. package/.agents/skills/wtk-implement/SKILL.md +95 -0
  61. package/.agents/skills/wtk-implement/references/checklist-format.md +116 -0
  62. package/.agents/skills/wtk-implement/references/screens.md +129 -0
  63. package/.agents/skills/wtk-implement/references/test-policy.md +152 -0
  64. package/.agents/skills/wtk-implement/references/verify.md +297 -0
  65. package/.agents/skills/wtk-knowledge-check/SKILL.md +16 -0
  66. package/.agents/skills/wtk-knowledge-check/scripts/check.ts +502 -0
  67. package/.agents/skills/wtk-knowledge-check/scripts/cli.ts +32 -0
  68. package/.agents/skills/wtk-knowledge-check/scripts/frontmatter.ts +44 -0
  69. package/.agents/skills/wtk-lean/.skill-meta.json +6 -0
  70. package/.agents/skills/wtk-lean/NOTICE.md +18 -0
  71. package/.agents/skills/wtk-lean/SKILL.md +137 -0
  72. package/.agents/skills/wtk-lean/references/build.md +168 -0
  73. package/.agents/skills/wtk-lean/references/checks.md +253 -0
  74. package/.agents/skills/wtk-lean/references/memory.md +156 -0
  75. package/.agents/skills/wtk-lean/references/plan.md +514 -0
  76. package/.agents/skills/wtk-lean/references/verify.md +336 -0
  77. package/.agents/skills/wtk-lean/scripts/check_commit.py +121 -0
  78. package/.agents/skills/wtk-lean/scripts/fixtures/checks.md +98 -0
  79. package/.agents/skills/wtk-lean/scripts/fixtures/plan.md +119 -0
  80. package/.agents/skills/wtk-lean/scripts/fixtures/verification.md +41 -0
  81. package/.agents/skills/wtk-lean/scripts/lessons.py +412 -0
  82. package/.agents/skills/wtk-lean/scripts/selftest.py +401 -0
  83. package/.agents/skills/wtk-lean/scripts/validate_checks.py +417 -0
  84. package/.agents/skills/wtk-lean/scripts/validate_plan.py +580 -0
  85. package/.agents/skills/wtk-lean/scripts/validate_verification.py +357 -0
  86. package/.agents/skills/wtk-plan/SKILL.md +102 -0
  87. package/.agents/skills/wtk-plan/references/document-format.md +222 -0
  88. package/.agents/skills/wtk-qa/SKILL.md +14 -0
  89. package/.agents/skills/wtk-qa-execute/SKILL.md +115 -0
  90. package/.agents/skills/wtk-qa-execute/references/fix-loop.md +23 -0
  91. package/.agents/skills/wtk-qa-execute/references/session-protocol.md +25 -0
  92. package/.agents/skills/wtk-qa-plan/SKILL.md +111 -0
  93. package/.agents/skills/wtk-qa-plan/references/profile.md +35 -0
  94. package/.agents/skills/wtk-ship/SKILL.md +49 -0
  95. package/.agents/skills/wtk-ship/remediation.py +170 -0
  96. package/.agents/skills/wtk-ship/scripts/close_feature.py +83 -0
  97. package/.agents/skills/wtk-ship/scripts/review_convergence.py +373 -0
  98. package/.wtk.toml.example +93 -0
  99. package/AGENTS.md +129 -0
  100. package/NOTICE.md +12 -0
  101. package/README.md +440 -0
  102. package/bin/wtk.js +25 -0
  103. package/docs/toolkit/README.md +56 -0
  104. package/docs/toolkit/decisions.md +61 -0
  105. package/docs/toolkit/guidelines/CONTEXT-BUDGET.md +64 -0
  106. package/docs/toolkit/guidelines/DX.md +67 -0
  107. package/docs/toolkit/guidelines/FRONTEND.md +73 -0
  108. package/docs/toolkit/guidelines/KNOWLEDGE-WIKI.md +128 -0
  109. package/docs/toolkit/guidelines/MODELING.md +134 -0
  110. package/docs/toolkit/guidelines/QA-EXECUTION.md +46 -0
  111. package/docs/toolkit/guidelines/QA-SCENARIOS.md +165 -0
  112. package/docs/toolkit/guidelines/REVIEW-ROUNDS.md +160 -0
  113. package/docs/toolkit/guidelines/SECURITY.md +160 -0
  114. package/docs/toolkit/guidelines/UI-UX.md +117 -0
  115. package/docs/toolkit/guidelines/WORKFLOW-MEMORY.md +73 -0
  116. package/docs/toolkit/guidelines.md +50 -0
  117. package/docs/toolkit/loop.md +77 -0
  118. package/docs/toolkit/purpose.md +51 -0
  119. package/docs/toolkit/repository-intelligence.md +53 -0
  120. package/docs/toolkit/reviews.md +77 -0
  121. package/knowledge/AGENTS.md +316 -0
  122. package/knowledge/raw/README.md +17 -0
  123. package/package.json +74 -0
  124. package/scripts/install_security_skills.py +816 -0
  125. package/scripts/installer/engine.js +254 -0
  126. package/scripts/installer/knowledge.js +28 -0
  127. package/scripts/installer/packets.js +77 -0
  128. package/scripts/installer/terminal.js +98 -0
  129. package/scripts/installer/transaction.js +75 -0
  130. package/skills-lock.json +92 -0
  131. package/templates/adoption/agents/core.md +23 -0
  132. package/templates/adoption/agents/quality.md +3 -0
  133. package/templates/adoption/knowledge/wiki/architecture/index.md +3 -0
  134. package/templates/adoption/knowledge/wiki/decisions/index.md +3 -0
  135. package/templates/adoption/knowledge/wiki/design/index.md +3 -0
  136. package/templates/adoption/knowledge/wiki/domain/index.md +3 -0
  137. package/templates/adoption/knowledge/wiki/index.md +3 -0
  138. package/templates/adoption/knowledge/wiki/log.md +3 -0
  139. package/templates/adoption/knowledge/wiki/open-questions/index.md +3 -0
  140. package/templates/adoption/knowledge/wiki/product/index.md +3 -0
  141. package/templates/adoption/knowledge/wiki/research/index.md +3 -0
  142. package/templates/adoption/product/AGENT-CONTEXT.md +25 -0
@@ -0,0 +1,75 @@
1
+ """Conditional Graphify context preparation for Deep Review."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ RI_SCRIPTS = Path(__file__).resolve().parents[2] / "wtk-config" / "scripts"
11
+ if str(RI_SCRIPTS) not in sys.path:
12
+ sys.path.insert(0, str(RI_SCRIPTS))
13
+
14
+ import repository_intelligence as ri # noqa: E402
15
+
16
+
17
+ def question_hash(question: str) -> str:
18
+ return hashlib.sha256(question.encode("utf-8")).hexdigest()
19
+
20
+
21
+ def _fallback(path: Path, digest: str, reason: str) -> dict[str, str]:
22
+ path.parent.mkdir(parents=True, exist_ok=True)
23
+ path.write_text(
24
+ "\n".join([
25
+ "# Graphify context",
26
+ "",
27
+ "status: degraded",
28
+ f"question_hash: {digest}",
29
+ f"reason: {reason}",
30
+ "",
31
+ "Graphify context is unavailable; use targeted repository inspection.",
32
+ ]) + "\n",
33
+ encoding="utf-8",
34
+ )
35
+ return {"status": "degraded", "path": str(path), "question_hash": digest, "reason": reason}
36
+
37
+
38
+ def _result_error(error: Exception) -> str:
39
+ if isinstance(error, ri.IntelligenceError):
40
+ return ri._redact(error.reason)
41
+ return "Graphify query failed"
42
+
43
+
44
+ def prepare_graphify_context(repo: Path, out: Path, question: str) -> dict[str, str]:
45
+ """Run one bounded Graphify question and persist only its content-safe hash."""
46
+ path = out / "graphify-context.md"
47
+ digest = question_hash(question)
48
+ try:
49
+ result: Any = ri._run_context(repo, "graphify", "query", [question])
50
+ except Exception as error: # adapter converts expected tool failures to IntelligenceError
51
+ return _fallback(path, digest, _result_error(error))
52
+ if isinstance(result, dict) and result.get("status") == "degraded":
53
+ return _fallback(path, digest, ri._redact(str(result.get("reason") or "Graphify query failed")))
54
+ context = str(result.get("context", "")).strip() if isinstance(result, dict) else ""
55
+ if not context:
56
+ return _fallback(path, digest, "Graphify returned insufficient context")
57
+ status = str(result.get("status", "ready")) if isinstance(result, dict) else "ready"
58
+ path.parent.mkdir(parents=True, exist_ok=True)
59
+ path.write_text(
60
+ "\n".join([
61
+ "# Graphify context",
62
+ "",
63
+ f"status: {status}",
64
+ f"question_hash: {digest}",
65
+ "",
66
+ "Use this bounded architecture context as orientation; verify every claim against the checkout.",
67
+ "",
68
+ "## Architecture context",
69
+ "```text",
70
+ context[:12000],
71
+ "```",
72
+ ]) + "\n",
73
+ encoding="utf-8",
74
+ )
75
+ return {"status": status, "path": str(path), "question_hash": digest}
@@ -0,0 +1,313 @@
1
+ #!/usr/bin/env python3
2
+ """Deep-review finding merger (bootstrap helper; writes only under --out).
3
+
4
+ Mechanically folds every reviewer/sweep output into the canonical ledger —
5
+ no agents involved. Collects defects, advisories, suppressions, and coverage
6
+ from jobs.json outputs (validating each against the schema), merges duplicates
7
+ by identical fingerprint only, and reconciles against any prior state.json ledger: new /
8
+ duplicate (still open from a prior round) / suppressed (dismissed before;
9
+ never re-raised) — plus the resolved sweep for prior findings the fix
10
+ removed. Emits findings.json, the single input render_review.py consumes.
11
+
12
+ Exit codes: 0 ok, 1 missing/invalid outputs.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import re
20
+ import sys
21
+ from collections import Counter, defaultdict
22
+ from pathlib import Path
23
+
24
+ sys.dont_write_bytecode = True # keep the tracked skill tree free of __pycache__
25
+
26
+ from _common import (
27
+ SEVERITY_RANK,
28
+ fingerprint,
29
+ hunk_text,
30
+ load_jobs,
31
+ read_json,
32
+ repo_root,
33
+ validate_job_output,
34
+ write_json,
35
+ )
36
+
37
+
38
+ class UnionFind:
39
+ def __init__(self, values: list[str]) -> None:
40
+ self.parent = {value: value for value in values}
41
+
42
+ def find(self, value: str) -> str:
43
+ parent = self.parent[value]
44
+ if parent != value:
45
+ self.parent[value] = self.find(parent)
46
+ return self.parent[value]
47
+
48
+ def union(self, left: str, right: str) -> None:
49
+ left_root, right_root = self.find(left), self.find(right)
50
+ if left_root != right_root:
51
+ self.parent[right_root] = left_root
52
+
53
+
54
+ def unique(values: list) -> list:
55
+ seen, result = set(), []
56
+ for value in values:
57
+ if value not in seen:
58
+ seen.add(value)
59
+ result.append(value)
60
+ return result
61
+
62
+
63
+ def anchor(finding: dict) -> str:
64
+ end_line = finding.get("end_line") or finding["line"]
65
+ suffix = str(finding["line"]) if end_line == finding["line"] else f"{finding['line']}-{end_line}"
66
+ return f"{finding['file']}:{suffix}"
67
+
68
+
69
+ def collect(repo: Path, out: Path) -> dict[str, list[dict]]:
70
+ results: dict[str, list[dict]] = {
71
+ "defects": [], "advisories": [], "suppressions": [],
72
+ "hunk_coverage": [], "rule_coverage": [], "dispositions": [],
73
+ }
74
+ for job in load_jobs(out / "jobs.json"):
75
+ try:
76
+ validate_job_output(repo, out, job)
77
+ except ValueError as error:
78
+ raise RuntimeError(f"reviewer output invalid — {error}") from error
79
+ payload = read_json(repo / job["output"])
80
+ for key, result_kind, prefix in (
81
+ ("defects", "defect", "RD"), ("advisories", "advisory", "RA")
82
+ ):
83
+ for item in payload[key]:
84
+ decorated = {"result_kind": result_kind, **item}
85
+ results[key].append({
86
+ "raw_id": f"{prefix}{len(results[key]) + 1:04d}",
87
+ "source_job": job["label"],
88
+ "fingerprint": fingerprint(decorated),
89
+ **decorated,
90
+ })
91
+ for item in payload.get("suppressions", []):
92
+ results["suppressions"].append({
93
+ "source_job": job["label"], "lane": job["lane"], **item,
94
+ })
95
+ for item in payload["coverage"]["hunks"]:
96
+ results["hunk_coverage"].append({
97
+ "source_job": job["label"], "lane": job["lane"], **item,
98
+ })
99
+ for item in payload["coverage"].get("rules", []):
100
+ results["rule_coverage"].append({
101
+ "source_job": job["label"], "lane": job["lane"], **item,
102
+ })
103
+ for item in payload.get("prior_findings", []):
104
+ results["dispositions"].append({"source_job": job["label"], **item})
105
+ return results
106
+
107
+
108
+ def group_duplicates(findings: list[dict]) -> list[list[dict]]:
109
+ """Union identical fingerprints only; distinct defects never merge."""
110
+ uf = UnionFind([finding["raw_id"] for finding in findings])
111
+ by_fingerprint: dict[str, list[dict]] = {}
112
+ for finding in findings:
113
+ by_fingerprint.setdefault(finding["fingerprint"], []).append(finding)
114
+ for members in by_fingerprint.values():
115
+ for member in members[1:]:
116
+ uf.union(members[0]["raw_id"], member["raw_id"])
117
+ grouped: dict[str, list[dict]] = {}
118
+ for finding in findings:
119
+ grouped.setdefault(uf.find(finding["raw_id"]), []).append(finding)
120
+ return list(grouped.values())
121
+
122
+
123
+ def merge_group(members: list[dict]) -> dict:
124
+ ordered = sorted(
125
+ members, key=lambda f: (-SEVERITY_RANK[f["severity"]], f["line"], f["raw_id"])
126
+ )
127
+ canonical = ordered[0]
128
+ return {
129
+ **canonical,
130
+ "raw_ids": [m["raw_id"] for m in ordered],
131
+ "source_jobs": unique([m["source_job"] for m in ordered]),
132
+ "rule_ids": unique([rule_id for m in ordered for rule_id in (m.get("rule_ids") or [])]),
133
+ "also_applies": unique([
134
+ *(canonical.get("also_applies") or []),
135
+ *[anchor(m) for m in ordered[1:]],
136
+ *[target for m in ordered for target in (m.get("also_applies") or [])],
137
+ ]),
138
+ "evidence": unique([e for m in ordered for e in m.get("evidence", [])]),
139
+ }
140
+
141
+
142
+ def raw_ledger_entries(members: list[dict], merged: dict) -> list[dict]:
143
+ entries = []
144
+ for member in members:
145
+ entry = {
146
+ "raw_id": member["raw_id"], "source_job": member["source_job"],
147
+ "fingerprint": member["fingerprint"], "file": member["file"],
148
+ "line": member["line"], "severity": member["severity"],
149
+ "title": member["title"], "result_kind": member["result_kind"],
150
+ }
151
+ if member["raw_id"] != merged["raw_ids"][0]:
152
+ entry["status"] = "merged"
153
+ entry["duplicate_of"] = merged["fingerprint"]
154
+ else:
155
+ entry["status"] = "canonical" if len(members) > 1 else "unique"
156
+ entries.append(entry)
157
+ return entries
158
+
159
+
160
+ def reconcile(canonical: list[dict], found_fps: set[str], prior_state: dict | None,
161
+ dispositions: list[dict]) -> dict:
162
+ ledger = (prior_state or {}).get("ledger", {})
163
+ for finding in canonical:
164
+ row = ledger.get(finding["fingerprint"])
165
+ if row is None or row.get("status") == "resolved":
166
+ finding["round_status"] = "new"
167
+ elif row["status"] == "open":
168
+ finding["round_status"] = "duplicate"
169
+ finding["first_round"] = row.get("round")
170
+ else: # dismissed — overruled before; never re-raise
171
+ finding["round_status"] = "suppressed"
172
+ finding["suppressed_as"] = row["status"]
173
+ # A prior open finding is resolved only by an explicit reviewer disposition;
174
+ # absence from the new output never means fixed.
175
+ resolved_fps = {d["fingerprint"] for d in dispositions if d["status"] == "resolved"}
176
+ resolved, still_open = [], []
177
+ for fp, row in ledger.items():
178
+ if row.get("status") != "open" or fp in found_fps:
179
+ continue
180
+ (resolved if fp in resolved_fps else still_open).append(fp)
181
+ return {
182
+ "resolved": sorted(resolved),
183
+ "still_open_unreviewed": sorted(still_open),
184
+ "prior_rounds": len((prior_state or {}).get("rounds", [])),
185
+ }
186
+
187
+
188
+ HUNK_RE = re.compile(r"^(new|old):(\d+)-(\d+)$")
189
+
190
+
191
+ def hunk_lines(file: str, hunk: str) -> Counter:
192
+ match = HUNK_RE.fullmatch(hunk)
193
+ if match is None:
194
+ raise RuntimeError(f"invalid canonical hunk {file}:{hunk}")
195
+ side, start, end = match.group(1), int(match.group(2)), int(match.group(3))
196
+ if end < start:
197
+ raise RuntimeError(f"invalid descending hunk {file}:{hunk}")
198
+ return Counter((file, side, line) for line in range(start, end + 1))
199
+
200
+
201
+ def coverage_ledger(manifest: dict, collected: dict[str, list[dict]]) -> dict:
202
+ expected = Counter()
203
+ for file in manifest["files"]:
204
+ if file["disposition"] != "selected":
205
+ continue
206
+ for hunk in file["hunks"]:
207
+ expected += hunk_lines(file["path"], hunk_text(hunk))
208
+
209
+ lane_stats = {}
210
+ for lane in ("defect",):
211
+ actual = Counter()
212
+ rows = [row for row in collected["hunk_coverage"] if row["lane"] == lane]
213
+ for row in rows:
214
+ actual += hunk_lines(row["file"], row["hunk"])
215
+ missing, extra = expected - actual, actual - expected
216
+ if missing or extra:
217
+ raise RuntimeError(
218
+ f"{lane} coverage incomplete: missing_lines={sum(missing.values())} "
219
+ f"duplicated_or_extra_lines={sum(extra.values())}"
220
+ )
221
+ lane_stats[lane] = {
222
+ "rows": len(rows), "covered_lines": sum(actual.values()), "complete": True,
223
+ }
224
+
225
+ rule_status = defaultdict(Counter)
226
+ for row in collected["rule_coverage"]:
227
+ rule_status[row["rule_id"]][row["status"]] += 1
228
+ return {
229
+ "hunks": collected["hunk_coverage"],
230
+ "rules": collected["rule_coverage"],
231
+ "summary": {
232
+ "selected_hunk_lines": sum(expected.values()),
233
+ "lanes": lane_stats,
234
+ "rules": {rule: dict(sorted(counts.items())) for rule, counts in sorted(rule_status.items())},
235
+ },
236
+ }
237
+
238
+
239
+ def main() -> int:
240
+ parser = argparse.ArgumentParser(description=__doc__)
241
+ parser.add_argument("--out", required=True)
242
+ args = parser.parse_args()
243
+ repo = repo_root()
244
+ out = Path(args.out).resolve()
245
+ try:
246
+ manifest = read_json(out / "manifest.json")
247
+ collected = collect(repo, out)
248
+ coverage = coverage_ledger(manifest, collected)
249
+ except RuntimeError as error:
250
+ sys.stderr.write(f"{error}\n")
251
+ return 1
252
+
253
+ canonical_by_kind: dict[str, list[dict]] = {"defects": [], "advisories": []}
254
+ raw_ledger = []
255
+ for key in ("defects", "advisories"):
256
+ for members in group_duplicates(collected[key]):
257
+ merged = merge_group(members)
258
+ canonical_by_kind[key].append(merged)
259
+ raw_ledger.extend(raw_ledger_entries(members, merged))
260
+ canonical_by_kind[key].sort(key=lambda f: (f["file"], f["line"], f["raw_ids"][0]))
261
+ raw_ledger.sort(key=lambda item: item["raw_id"])
262
+
263
+ canonical_results = [*canonical_by_kind["defects"], *canonical_by_kind["advisories"]]
264
+
265
+ prior_state = read_json(out / "state.json") if (out / "state.json").is_file() else None
266
+ found_fps = {finding["fingerprint"] for finding in canonical_results}
267
+ reconciliation = reconcile(canonical_results, found_fps, prior_state, collected["dispositions"])
268
+
269
+ raw_count = len(collected["defects"]) + len(collected["advisories"])
270
+ canonical_count = len(canonical_results)
271
+ suppression_reasons = Counter(item["reason"] for item in collected["suppressions"])
272
+ review_stats = {
273
+ "candidates": raw_count + len(collected["suppressions"]),
274
+ "reported": raw_count,
275
+ "suppressed": len(collected["suppressions"]),
276
+ "suppression_reasons": dict(sorted(suppression_reasons.items())),
277
+ "raw_defects": len(collected["defects"]),
278
+ "raw_advisories": len(collected["advisories"]),
279
+ "canonical_defects": len(canonical_by_kind["defects"]),
280
+ "canonical_advisories": len(canonical_by_kind["advisories"]),
281
+ "coverage": coverage["summary"],
282
+ }
283
+
284
+ payload = {
285
+ "source_snapshot": manifest.get("worktree_snapshot"),
286
+ "summary": {
287
+ "raw": raw_count,
288
+ "canonical": canonical_count,
289
+ "merged_raw": raw_count - canonical_count,
290
+ "round_status": dict(sorted(Counter(f["round_status"] for f in canonical_results).items())),
291
+ "defect_severity": dict(sorted(Counter(f["severity"] for f in canonical_by_kind["defects"]).items())),
292
+ "advisory_category": dict(sorted(Counter(f["category"] for f in canonical_by_kind["advisories"]).items())),
293
+ },
294
+ "findings": canonical_by_kind["defects"],
295
+ "advisories": canonical_by_kind["advisories"],
296
+ "suppressions": collected["suppressions"],
297
+ "dispositions": collected["dispositions"],
298
+ "coverage": coverage,
299
+ "review_stats": review_stats,
300
+ "raw_ledger": raw_ledger,
301
+ "reconciliation": reconciliation,
302
+ }
303
+ write_json(out / "findings.json", payload)
304
+ write_json(out / "review-stats.json", review_stats)
305
+ print(f"findings ledger -> {out / 'findings.json'}")
306
+ print(f"review stats -> {out / 'review-stats.json'}")
307
+ print(json.dumps(payload["summary"], sort_keys=True))
308
+ print(json.dumps(reconciliation, sort_keys=True))
309
+ return 0
310
+
311
+
312
+ if __name__ == "__main__":
313
+ sys.exit(main())
@@ -0,0 +1,240 @@
1
+ #!/usr/bin/env python3
2
+ """Deep-review HTML report generator (bootstrap helper; writes only under --out).
3
+
4
+ Hydrates assets/REVIEW_UI.html — the fixed, self-contained report UI — with the
5
+ round's artifacts: defects/advisories/suppressions/coverage from findings.json
6
+ and manifest.json (required), plus state.json, walkthrough.md, rules.json,
7
+ review.md and archived rounds when present. Emits
8
+ <out>/review.html, the human-facing view of the review; agents keep consuming
9
+ the JSON artifacts. Cheap and idempotent — re-run it after merge_findings.py
10
+ and after render_review.py so the open dashboard (which auto-reloads) always
11
+ shows the current round. Before render_review.py has run, the verdict renders
12
+ as a neutral "round in progress" state.
13
+
14
+ Exit codes: 0 ok, 1 missing/invalid inputs.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ import re
22
+ import sys
23
+ import webbrowser
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+
27
+ sys.dont_write_bytecode = True # keep the tracked skill tree free of __pycache__
28
+
29
+ from _common import ASSETS_DIR, read_json, rel, repo_root
30
+ from render_review import line_range, one_line, repair_plan
31
+
32
+ TEMPLATE = ASSETS_DIR / "REVIEW_UI.html"
33
+ PLACEHOLDER = "__DEEP_REVIEW_DATA__"
34
+
35
+
36
+ def read_optional_json(path: Path):
37
+ return read_json(path) if path.is_file() else None
38
+
39
+
40
+ def read_optional_text(path: Path) -> str | None:
41
+ return path.read_text(encoding="utf-8") if path.is_file() else None
42
+
43
+
44
+ def ai_prompt(finding: dict) -> str | None:
45
+ """The same prompt render_review.py embeds in review.md, as copyable text."""
46
+ if finding.get("result_kind") != "defect" or finding["severity"] not in {"critical", "major"}:
47
+ return None
48
+ suggestion = one_line(finding.get("suggestion") or "")
49
+ remediation = suggestion or (
50
+ "correct the failure mode described in the finding at the owning layer"
51
+ )
52
+ rule_ids = ", ".join(finding.get("rule_ids") or []) or "review evidence"
53
+ return "\n".join([
54
+ "Verify this finding against the current code and fix it only if still valid.",
55
+ f"In {finding['file']} around lines {line_range(finding)}, {remediation}",
56
+ f"Reference anchor: {finding['file']}:{finding['line']}; rules: {rule_ids}.",
57
+ ])
58
+
59
+
60
+ def verdict_for(state: dict | None, round_n: int, review_md: str | None) -> tuple[str, str | None]:
61
+ """Verdict from the round's state entry; rationale from review.md's header."""
62
+ verdict = "PENDING"
63
+ for entry in (state or {}).get("rounds", []):
64
+ if entry.get("n") == round_n:
65
+ verdict = entry.get("verdict", "PENDING")
66
+ rationale = None
67
+ if review_md:
68
+ match = re.search(r"^\*\*Verdict: (\w+)\*\* — (.+)$", review_md, re.M)
69
+ if match and match.group(1) == verdict:
70
+ rationale = match.group(2).strip()
71
+ return verdict, rationale
72
+
73
+
74
+ def round_stats(ledger: dict | None) -> dict | None:
75
+ """Open-finding counts from a findings.json payload (live or archived)."""
76
+ if not ledger:
77
+ return None
78
+ open_findings = [
79
+ f for f in [*ledger.get("findings", []), *ledger.get("advisories", [])]
80
+ if f.get("round_status") in {"new", "duplicate"}
81
+ ]
82
+ defects = [f for f in open_findings if f.get("result_kind") == "defect"]
83
+ return {
84
+ "open": len(open_findings),
85
+ "critical": sum(1 for f in defects if f["severity"] == "critical"),
86
+ "major": sum(1 for f in defects if f["severity"] == "major"),
87
+ "advisories": sum(1 for f in open_findings if f.get("result_kind") == "advisory"),
88
+ }
89
+
90
+
91
+ def rounds_timeline(out: Path, state: dict | None, manifest: dict,
92
+ live_ledger: dict, verdict: str) -> list[dict]:
93
+ current_n = manifest["round"]
94
+ rounds = []
95
+ for entry in (state or {}).get("rounds", []):
96
+ n = entry.get("n")
97
+ ledger = live_ledger if n == current_n else read_optional_json(
98
+ out / "rounds" / f"round-{n}" / "findings.json"
99
+ )
100
+ rounds.append({
101
+ "n": n, "base": entry.get("base"), "head": entry.get("head"),
102
+ "verdict": entry.get("verdict"), "reviewed_at": entry.get("reviewed_at"),
103
+ "current": n == current_n, "stats": round_stats(ledger),
104
+ })
105
+ if not any(r["current"] for r in rounds): # render_review has not run yet
106
+ rounds.append({
107
+ "n": current_n, "base": manifest.get("base"), "head": manifest.get("head"),
108
+ "verdict": verdict, "reviewed_at": None, "current": True,
109
+ "stats": round_stats(live_ledger),
110
+ })
111
+ return sorted(rounds, key=lambda r: r["n"])
112
+
113
+
114
+ def ledger_rows(state: dict | None, fingerprints: list[str]) -> list[dict]:
115
+ ledger = (state or {}).get("ledger", {})
116
+ rows = []
117
+ for fp in fingerprints:
118
+ row = ledger.get(fp) or {}
119
+ rows.append({
120
+ "fingerprint": fp, "file": row.get("file", "?"),
121
+ "title": row.get("title", fp), "severity": row.get("severity", "minor"),
122
+ "round": row.get("round"), "resolved_in": row.get("resolved_in"),
123
+ })
124
+ return rows
125
+
126
+
127
+ def dismissed_rows(state: dict | None) -> list[dict]:
128
+ ledger = (state or {}).get("ledger", {})
129
+ return [
130
+ {"fingerprint": fp, "file": row.get("file", "?"), "title": row.get("title", fp),
131
+ "severity": row.get("severity", "minor"), "round": row.get("round")}
132
+ for fp, row in sorted(ledger.items())
133
+ if row.get("status") == "dismissed"
134
+ ]
135
+
136
+
137
+ def build_payload(repo: Path, out: Path) -> dict:
138
+ manifest = read_json(out / "manifest.json")
139
+ try:
140
+ ledger = read_json(out / "findings.json")
141
+ except RuntimeError as error:
142
+ raise RuntimeError(f"{error} — run merge_findings.py first") from error
143
+ state = read_optional_json(out / "state.json")
144
+ rules = read_optional_json(out / "rules.json") or {"rules": []}
145
+ rules_by_id = {rule["id"]: rule for rule in rules["rules"]}
146
+ review_md = read_optional_text(out / "review.md")
147
+ verdict, rationale = verdict_for(state, manifest["round"], review_md)
148
+
149
+ findings = []
150
+ for finding in [*ledger.get("findings", []), *ledger.get("advisories", [])]:
151
+ resolved_rules = [
152
+ {"id": rid, "source": rules_by_id[rid]["source"],
153
+ "guideline": rules_by_id[rid]["guideline"]}
154
+ for rid in (finding.get("rule_ids") or []) if rid in rules_by_id
155
+ ]
156
+ findings.append({
157
+ **finding,
158
+ "rules": resolved_rules,
159
+ "ai_prompt": ai_prompt(finding),
160
+ "repair_plan": repair_plan(finding),
161
+ })
162
+
163
+ reconciliation = ledger.get("reconciliation", {})
164
+ return {
165
+ "schema_version": 2,
166
+ "generated_at": datetime.now(timezone.utc).isoformat(),
167
+ "target": manifest["target"],
168
+ "pr": manifest.get("pr"),
169
+ "mode": manifest.get("mode"),
170
+ "round": manifest["round"],
171
+ "base": manifest.get("base"),
172
+ "head": manifest.get("head"),
173
+ "counts": manifest.get("counts", {}),
174
+ "out_dir": rel(out, repo),
175
+ "verdict": verdict,
176
+ "verdict_rationale": rationale,
177
+ "summary": ledger.get("summary", {}),
178
+ "findings": findings,
179
+ "suppressions": ledger.get("suppressions", []),
180
+ "coverage": ledger.get("coverage", {}),
181
+ "review_stats": ledger.get("review_stats", {}),
182
+ "resolved": ledger_rows(state, reconciliation.get("resolved", [])),
183
+ "unreviewed_open": ledger_rows(state, reconciliation.get("still_open_unreviewed", [])),
184
+ "dismissed": dismissed_rows(state),
185
+ "rounds": rounds_timeline(out, state, manifest, ledger, verdict),
186
+ "walkthrough": read_optional_text(out / "walkthrough.md"),
187
+ }
188
+
189
+
190
+ def hydrate(payload: dict) -> str:
191
+ template = TEMPLATE.read_text(encoding="utf-8")
192
+ occurrences = template.count(PLACEHOLDER)
193
+ if occurrences != 1:
194
+ raise RuntimeError(
195
+ f"template {TEMPLATE} must contain the data placeholder exactly once "
196
+ f"(found {occurrences})"
197
+ )
198
+ # Escape sequences that would let JSON text break out of the <script> data
199
+ # block: </ (closes the tag) and <!-- (opens an HTML comment-escaped state).
200
+ data = (
201
+ json.dumps(payload, ensure_ascii=False)
202
+ .replace("</", "<\\/")
203
+ .replace("<!--", "<\\u0021--")
204
+ )
205
+ return template.replace(PLACEHOLDER, data, 1)
206
+
207
+
208
+ def main() -> int:
209
+ parser = argparse.ArgumentParser(description=__doc__)
210
+ parser.add_argument("--out", required=True)
211
+ parser.add_argument("--open", action="store_true", dest="open_browser",
212
+ help="open the generated report in the default browser")
213
+ args = parser.parse_args()
214
+ try:
215
+ repo = repo_root()
216
+ out = Path(args.out).resolve()
217
+ payload = build_payload(repo, out)
218
+ (out / "review.html").write_text(hydrate(payload), encoding="utf-8")
219
+ except RuntimeError as error:
220
+ sys.stderr.write(f"{error}\n")
221
+ return 1
222
+
223
+ open_findings = [f for f in payload["findings"]
224
+ if f.get("round_status") in {"new", "duplicate"}]
225
+ defects = [f for f in open_findings if f.get("result_kind") == "defect"]
226
+ advisories = [f for f in open_findings if f.get("result_kind") == "advisory"]
227
+ severities = {s: sum(1 for f in open_findings if f["severity"] == s)
228
+ for s in ("critical", "major", "minor", "trivial")}
229
+ print(f"report -> {out / 'review.html'}")
230
+ print(f"verdict={payload['verdict']} defects={len(defects)} advisories={len(advisories)} "
231
+ f"(critical={severities['critical']} major={severities['major']} "
232
+ f"minor={severities['minor']} trivial={severities['trivial']}) "
233
+ f"resolved={len(payload['resolved'])} rounds={len(payload['rounds'])}")
234
+ if args.open_browser:
235
+ webbrowser.open((out / "review.html").as_uri())
236
+ return 0
237
+
238
+
239
+ if __name__ == "__main__":
240
+ sys.exit(main())