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,282 @@
1
+ #!/usr/bin/env python3
2
+ """Deep-review canonical renderer and final gate (writes only under --out).
3
+
4
+ Renders review.md from findings.json per references/output-contracts.md and
5
+ appends the round to state.json (fingerprint ledger: open/resolved/dismissed).
6
+ Refuses to render on a drifted checkout (--no-freeze-check to skip). The
7
+ verdict is derived, never asserted: SHIP requires zero open Critical/Major
8
+ defects (advisories never affect it); otherwise FIX_BEFORE_SHIP. REWORK is the
9
+ orchestrator's structural judgment — pass --rework "<rationale>"; the script
10
+ refuses REWORK without open Critical/Major and refuses SHIP with them.
11
+
12
+ The `## Review details` block is generated from manifest.json, jobs.json, and
13
+ rules.json; walkthrough.md is a publish-only artifact and is never inlined.
14
+ Exit codes: 0 ok, 1 contract violation (drifted source, illegal verdict).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import sys
21
+ from collections import Counter, defaultdict
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+
25
+ sys.dont_write_bytecode = True # keep the tracked skill tree free of __pycache__
26
+
27
+ from _common import SEVERITY_RANK, check_freeze, read_json, repo_root, write_json
28
+
29
+ SEVERITY_BADGE = {
30
+ "critical": "🔴 Critical", "major": "🟠 Major",
31
+ "minor": "🟡 Minor", "trivial": "🔵 Trivial",
32
+ }
33
+ CATEGORY_BADGE = {
34
+ "potential-issue": "⚠️ Potential issue",
35
+ "refactor": "🛠️ Refactor suggestion",
36
+ "nitpick": "🧹 Nitpick",
37
+ }
38
+ def review_details(out: Path, manifest: dict) -> list[str]:
39
+ """Script-derived facts about the round — no prose, no estimate."""
40
+ files = Counter(item.get("disposition") for item in manifest.get("files", []))
41
+ jobs = read_json(out / "jobs.json")["jobs"] if (out / "jobs.json").is_file() else []
42
+ kinds = ", ".join(f"{count} {kind}" for kind, count in sorted(Counter(job["kind"] for job in jobs).items()))
43
+ registry = read_json(out / "rules.json")
44
+ applied = sum(1 for row in registry.get("sources", []) if row.get("status") == "applied")
45
+ return [
46
+ "## Review details", "",
47
+ f"- **Scope**: {manifest['base'][:12]} → {manifest['head'][:12]} ({manifest['mode']}, round {manifest['round']})",
48
+ f"- **Files**: {files['selected']} selected · {files['ignored']} ignored · {files['skipped']} skipped · {files['carried']} carried",
49
+ f"- **Jobs**: {len(jobs)}" + (f" ({kinds})" if kinds else ""),
50
+ f"- **Concurrency**: {manifest['concurrency']}",
51
+ f"- **Rules**: {applied} sources → {len(registry.get('rules', []))} rules",
52
+ "",
53
+ ]
54
+
55
+
56
+ def one_line(value: str) -> str:
57
+ return " ".join(value.replace("|", "\\|").split())
58
+
59
+
60
+ def claim(title: str) -> str:
61
+ value = one_line(title)
62
+ return value if value.endswith((".", "!", "?")) else f"{value}."
63
+
64
+
65
+ def line_range(finding: dict) -> str:
66
+ end = finding.get("end_line") or finding["line"]
67
+ return f"{finding['line']}-{end}" if end != finding["line"] else str(finding["line"])
68
+
69
+
70
+ def root_cause(certificate: str) -> str:
71
+ """`<Premise> → <Path>` of a `Premise → Path → Verdict` certificate; the whole certificate when either is missing."""
72
+ clauses = dict(part.strip().split(":", 1) for part in certificate.split("→") if ":" in part)
73
+ if "Premise" in clauses and "Path" in clauses:
74
+ return f"{one_line(clauses['Premise'])} → {one_line(clauses['Path'])}"
75
+ return one_line(certificate)
76
+
77
+
78
+ def render_finding(finding: dict, rules_by_id: dict[str, dict]) -> str:
79
+ badges = [f"_{CATEGORY_BADGE[finding['category']]}_", f"_{SEVERITY_BADGE[finding['severity']]}_"]
80
+ if finding.get("quick_win"):
81
+ badges.append("_⚡ Quick win_")
82
+ lines = [" | ".join(badges), "", f"**{claim(finding['title'])}**", "", finding["body"].strip()]
83
+ if finding.get("also_applies"):
84
+ lines += ["", f"Also applies to: {', '.join(finding['also_applies'])}"]
85
+ for rule_id in finding.get("rule_ids") or []:
86
+ rule = rules_by_id.get(rule_id)
87
+ if rule:
88
+ lines += ["", f"As per coding guidelines [{rule_id}] (`{rule['source']}`): \"{one_line(rule['guideline'])}\""]
89
+ evidence = finding.get("evidence") or []
90
+ if evidence:
91
+ lines += ["", f"Certificate: {one_line(evidence[0])}"]
92
+ suggestion = (finding.get("suggestion") or "").strip()
93
+ if suggestion:
94
+ safe = suggestion.replace("```", "'''")
95
+ lines += [
96
+ "", "<details>", "<summary>📝 Committable suggestion</summary>", "",
97
+ f"> ‼️ **IMPORTANT**: review before committing — generated against lines {line_range(finding)}.",
98
+ "", "```suggestion", *safe.splitlines(), "```", "</details>",
99
+ ]
100
+ plan = repair_plan(finding)
101
+ if plan:
102
+ lines += ["", "<details>", "<summary>🛠️ Repair plan</summary>", "", *plan, "</details>"]
103
+ lines.append(f"<!-- wtk-deep-review:fp:{finding['fingerprint']} -->")
104
+ return "\n".join(lines)
105
+
106
+
107
+ def repair_plan(finding: dict) -> list[str]:
108
+ """The five repair-plan steps review.md shows; empty for trivial results."""
109
+ if finding["severity"] not in {"critical", "major", "minor"}:
110
+ return []
111
+ evidence, suggestion = finding.get("evidence") or [], (finding.get("suggestion") or "").strip()
112
+ anchor = f"{finding['file']}:{finding['line']}"
113
+ return [
114
+ f"1. Root cause: {root_cause(evidence[0]) if evidence else 'see the finding body'}",
115
+ f"2. Fix every site: {', '.join([anchor, *(finding.get('also_applies') or [])])}",
116
+ f"3. Before editing, grep every caller of the symbol at {anchor}; fix at the owning layer.",
117
+ "4. Extend the nearest test so it fails on the Premise, then fix until it passes.",
118
+ f"5. Suggested change: {one_line(suggestion) if suggestion else 'none'}",
119
+ ]
120
+
121
+
122
+ def by_file_sections(findings: list[dict], rules_by_id: dict[str, dict]) -> list[str]:
123
+ grouped: dict[str, list[dict]] = defaultdict(list)
124
+ for finding in findings:
125
+ grouped[finding["file"]].append(finding)
126
+ ordered = sorted(
127
+ grouped,
128
+ key=lambda path: (-max(SEVERITY_RANK[f["severity"]] for f in grouped[path]), path),
129
+ )
130
+ lines: list[str] = []
131
+ for path in ordered:
132
+ lines += [f"### `{path}`", ""]
133
+ for finding in sorted(grouped[path],
134
+ key=lambda f: (-SEVERITY_RANK[f["severity"]], f["line"])):
135
+ lines += [render_finding(finding, rules_by_id), ""]
136
+ return lines
137
+
138
+
139
+ def main() -> int:
140
+ parser = argparse.ArgumentParser(description=__doc__)
141
+ parser.add_argument("--out", required=True)
142
+ parser.add_argument("--rework", metavar="RATIONALE",
143
+ help="escalate to REWORK with this structural rationale")
144
+ parser.add_argument("--no-freeze-check", action="store_true")
145
+ args = parser.parse_args()
146
+ repo = repo_root()
147
+ out = Path(args.out).resolve()
148
+ try:
149
+ if not args.no_freeze_check:
150
+ drift = check_freeze(repo, out, "render")
151
+ if drift:
152
+ raise RuntimeError(drift[0])
153
+ manifest = read_json(out / "manifest.json")
154
+ ledger = read_json(out / "findings.json")
155
+ rules_by_id = {rule["id"]: rule for rule in read_json(out / "rules.json")["rules"]}
156
+ details = review_details(out, manifest)
157
+
158
+ findings = ledger["findings"]
159
+ advisories = ledger.get("advisories", [])
160
+ new = [f for f in findings if f["round_status"] == "new"]
161
+ duplicates = [f for f in findings if f["round_status"] == "duplicate"]
162
+ new_advisories = [f for f in advisories if f["round_status"] == "new"]
163
+ duplicate_advisories = [f for f in advisories if f["round_status"] == "duplicate"]
164
+ open_findings = new + duplicates
165
+ prior_state = read_json(out / "state.json") if (out / "state.json").is_file() else {"target": manifest["target"], "rounds": [], "ledger": {}}
166
+ prior_ledger = prior_state.get("ledger", {})
167
+ reconciliation = ledger.get("reconciliation", {})
168
+ # Prior open entries without a disposition stay open and count in the verdict.
169
+ carried = [prior_ledger[fp] for fp in reconciliation.get("still_open_unreviewed", []) if fp in prior_ledger]
170
+ open_cm = [f for f in [*open_findings, *carried] if f["severity"] in {"critical", "major"}]
171
+
172
+ if args.rework is not None:
173
+ if not open_cm:
174
+ raise RuntimeError("--rework refused: no open Critical/Major supports a structural verdict")
175
+ if not args.rework.strip():
176
+ raise RuntimeError("--rework requires a non-empty rationale")
177
+ verdict, rationale = "REWORK", args.rework.strip()
178
+ elif open_cm:
179
+ verdict = "FIX_BEFORE_SHIP"
180
+ counts = defaultdict(int)
181
+ for finding in open_cm:
182
+ counts[finding["severity"]] += 1
183
+ rationale = (
184
+ f"{counts['critical']} Critical and {counts['major']} Major findings "
185
+ "remain open; each names a bounded fix"
186
+ )
187
+ else:
188
+ verdict = "SHIP"
189
+ rationale = "no Critical or Major finding remains open"
190
+ except RuntimeError as error:
191
+ sys.stderr.write(f"{error}\n")
192
+ return 1
193
+ except KeyError as error:
194
+ sys.stderr.write(f"manifest.json lacks key {error}\n")
195
+ return 1
196
+
197
+ sev_counts = defaultdict(int)
198
+ for finding in new:
199
+ sev_counts[finding["severity"]] += 1
200
+ resolved = reconciliation.get("resolved", [])
201
+ round_n = manifest["round"]
202
+
203
+ review: list[str] = [
204
+ f"# Deep Review — {manifest['target']} (round {round_n})",
205
+ "",
206
+ f"**Verdict: {verdict}** — {rationale}",
207
+ f"**Defects: {len(new)}** (🔴 {sev_counts['critical']} · 🟠 {sev_counts['major']} · 🟡 {sev_counts['minor']}) · "
208
+ f"advisories: {len(new_advisories)} · duplicates: {len(duplicates) + len(duplicate_advisories) + len(reconciliation.get('still_open_unreviewed', []))} · "
209
+ f"resolved since last round: {len(resolved)} · merged duplicate reports: {ledger['summary']['merged_raw']}",
210
+ "",
211
+ *details,
212
+ "## Findings",
213
+ "",
214
+ *by_file_sections([f for f in new if f["in_diff"]], rules_by_id),
215
+ "## Outside diff range",
216
+ "",
217
+ ]
218
+ outside = [f for f in new if not f["in_diff"]]
219
+ review += by_file_sections(outside, rules_by_id) if outside else ["None.", ""]
220
+
221
+ review += [f"## Duplicates (unresolved from round {round_n - 1})", ""]
222
+ duplicate_lines = [
223
+ f"- _{SEVERITY_BADGE[f['severity']]}_ · {claim(f['title'])} — first raised round "
224
+ f"{f.get('first_round', '?')} <!-- wtk-deep-review:fp:{f['fingerprint']} -->"
225
+ for f in sorted([*duplicates, *duplicate_advisories], key=lambda f: -SEVERITY_RANK[f["severity"]])
226
+ ] + [
227
+ f"- _{SEVERITY_BADGE[row['severity']]}_ · {claim(row['title'])} — round {row['round']}, file unchanged"
228
+ for fp, row in sorted(prior_ledger.items())
229
+ if fp in set(reconciliation.get("still_open_unreviewed", []))
230
+ ]
231
+ review += duplicate_lines + [""] if duplicate_lines else ["None.", ""]
232
+
233
+ review += ["## Advisories", ""]
234
+ review += by_file_sections(new_advisories, rules_by_id) if new_advisories else ["None.", ""]
235
+
236
+ stats = ledger.get("review_stats", {})
237
+ coverage = stats.get("coverage", {})
238
+ review += [
239
+ "## Review observability", "",
240
+ f"- Candidates investigated: {stats.get('candidates', 0)}",
241
+ f"- Reported before deduplication: {stats.get('reported', 0)}",
242
+ f"- Suppressed with recorded reason: {stats.get('suppressed', 0)}",
243
+ f"- Selected hunk lines covered by the defect lane: {coverage.get('selected_hunk_lines', 0)}",
244
+ "",
245
+ ]
246
+ (out / "review.md").write_text("\n".join(review), encoding="utf-8")
247
+
248
+ state_ledger = dict(prior_ledger)
249
+ head = manifest["head"]
250
+ for fp in resolved:
251
+ state_ledger[fp] = {**state_ledger[fp], "status": "resolved", "resolved_in": head}
252
+ for finding in [*findings, *advisories]:
253
+ if finding["round_status"] == "new":
254
+ state_ledger[finding["fingerprint"]] = {
255
+ "file": finding["file"], "title": finding["title"],
256
+ "severity": finding["severity"], "status": "open",
257
+ "round": round_n, "result_kind": finding["result_kind"],
258
+ "comment_id": None, "resolved_in": None, "line": finding["line"],
259
+ "certificate": (finding.get("evidence") or [""])[0],
260
+ "also_applies": finding.get("also_applies") or [],
261
+ }
262
+ rounds = [r for r in prior_state.get("rounds", []) if r["n"] != round_n]
263
+ rounds.append({"n": round_n, "base": manifest["base"], "head": head, "verdict": verdict,
264
+ "reviewed_at": datetime.now(timezone.utc).isoformat()})
265
+ write_json(out / "state.json", {"target": manifest["target"], "rounds": rounds, "ledger": state_ledger})
266
+
267
+ structural = [
268
+ f for f in open_cm
269
+ if len([j for j in (f.get("source_jobs") or []) if j.startswith("cohort-")]) >= 3
270
+ ]
271
+ print(f"review -> {out / 'review.md'}; state -> {out / 'state.json'}")
272
+ print(f"verdict={verdict} defects={len(new)} "
273
+ f"(critical={sev_counts['critical']} major={sev_counts['major']} minor={sev_counts['minor']}) "
274
+ f"advisories={len(new_advisories)} duplicates={len(duplicates) + len(duplicate_advisories)} resolved={len(resolved)} "
275
+ f"merged={ledger['summary']['merged_raw']}")
276
+ if structural and verdict != "REWORK":
277
+ print(f"hint: {len(structural)} open Critical/Major span ≥3 cohorts — weigh --rework per the verdict rule")
278
+ return 0
279
+
280
+
281
+ if __name__ == "__main__":
282
+ sys.exit(main())