prizmkit 1.1.125 → 1.1.127

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 (25) hide show
  1. package/bundled/VERSION.json +3 -3
  2. package/bundled/dev-pipeline/prizmkit_runtime/reset.py +49 -3
  3. package/bundled/dev-pipeline/prizmkit_runtime/runners.py +75 -14
  4. package/bundled/dev-pipeline/prizmkit_runtime/runtime_helper.py +37 -0
  5. package/bundled/dev-pipeline/prizmkit_runtime/task_checkout.py +267 -0
  6. package/bundled/dev-pipeline/scripts/generate-recovery-prompt.py +3 -2
  7. package/bundled/dev-pipeline/templates/bootstrap-prompt.md +1 -1
  8. package/bundled/dev-pipeline/templates/bootstrap-tier3.md +6 -18
  9. package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +6 -15
  10. package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +8 -9
  11. package/bundled/dev-pipeline/templates/sections/bugfix-phase-review.md +9 -9
  12. package/bundled/dev-pipeline/templates/sections/phase-review-agent.md +15 -18
  13. package/bundled/dev-pipeline/templates/sections/phase-review-full.md +11 -17
  14. package/bundled/dev-pipeline/templates/sections/refactor-phase-review.md +9 -9
  15. package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +8 -5
  16. package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +1 -1
  17. package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +1 -1
  18. package/bundled/dev-pipeline/tests/test_python_runner_parity.py +206 -3
  19. package/bundled/dev-pipeline/tests/test_runtime_helper.py +36 -0
  20. package/bundled/dev-pipeline/tests/test_unified_cli.py +10 -7
  21. package/bundled/skills/_metadata.json +1 -1
  22. package/bundled/skills/prizmkit-code-review/SKILL.md +128 -95
  23. package/bundled/skills/prizmkit-code-review/references/review-report-template.md +89 -25
  24. package/bundled/skills/prizmkit-code-review/scripts/render_review_report.py +246 -114
  25. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """Render the final review-report.md from minimal review data."""
2
+ """Initialize and append validated sections to review-report.md."""
3
3
 
4
4
  from __future__ import annotations
5
5
 
@@ -10,153 +10,285 @@ from pathlib import Path
10
10
  from typing import Any
11
11
 
12
12
  VERDICTS = {"PASS", "NEEDS_FIXES", "BLOCKED"}
13
- DISPOSITIONS = ("fixed", "rejected", "unresolved")
13
+ RISKS = {"low", "medium", "high"}
14
+ EVENTS = {
15
+ "risk-assessment",
16
+ "main-review-round",
17
+ "repair-verification",
18
+ "reviewer-running",
19
+ "reviewer-result",
20
+ "infrastructure-failure",
21
+ "final-verification",
22
+ }
23
+ FINAL_HEADING = "## Final Result"
14
24
 
15
25
 
16
26
  class ReportStateError(ValueError):
17
- """Raised when review data cannot produce a valid final report."""
27
+ """Raised when an operation would produce an invalid report lifecycle."""
18
28
 
19
29
 
20
- def _require_count(data: dict[str, Any], name: str) -> int:
30
+ def _require_object(data: Any) -> dict[str, Any]:
31
+ if not isinstance(data, dict):
32
+ raise ReportStateError("input must be a JSON object")
33
+ return data
34
+
35
+
36
+ def _require_text(data: dict[str, Any], name: str) -> str:
37
+ value = data.get(name)
38
+ if not isinstance(value, str) or not value.strip():
39
+ raise ReportStateError(f"{name} must be a non-empty string")
40
+ return value.strip()
41
+
42
+
43
+ def _require_count(data: dict[str, Any], name: str, *, maximum: int | None = None) -> int:
21
44
  value = data.get(name)
22
45
  if not isinstance(value, int) or isinstance(value, bool) or value < 0:
23
46
  raise ReportStateError(f"{name} must be a non-negative integer")
47
+ if maximum is not None and value > maximum:
48
+ raise ReportStateError(f"{name} cannot exceed {maximum}")
24
49
  return value
25
50
 
26
51
 
27
- def validate_data(data: Any) -> dict[str, Any]:
28
- if not isinstance(data, dict):
29
- raise ReportStateError("review data must be a JSON object")
52
+ def _require_risk(data: dict[str, Any]) -> str:
53
+ risk = data.get("risk")
54
+ if risk not in RISKS:
55
+ raise ReportStateError(f"risk has invalid value: {risk!r}")
56
+ return risk
57
+
58
+
59
+ def _report_text(path: Path) -> str:
60
+ try:
61
+ return path.read_text(encoding="utf-8")
62
+ except FileNotFoundError as error:
63
+ raise ReportStateError("report must be initialized before append") from error
64
+
65
+
66
+ def _ensure_appendable(path: Path) -> None:
67
+ text = _report_text(path)
68
+ if "## Status: IN_PROGRESS" not in text:
69
+ raise ReportStateError("report is not an initialized in-progress execution")
70
+ if FINAL_HEADING in text:
71
+ raise ReportStateError("cannot append after Final Result")
72
+
73
+
74
+ def _append_section(path: Path, section: str) -> None:
75
+ with path.open("a", encoding="utf-8") as report:
76
+ report.write(section.rstrip() + "\n\n")
77
+
78
+
79
+ def initialize_report(path: Path) -> None:
80
+ """Start a new execution and remove stale prior execution content."""
81
+ path.parent.mkdir(parents=True, exist_ok=True)
82
+ path.write_text("# Review Report\n\n## Status: IN_PROGRESS\n\n", encoding="utf-8")
30
83
 
31
- verdict = data.get("verdict")
32
- if verdict not in VERDICTS:
33
- raise ReportStateError(f"verdict has invalid value: {verdict!r}")
34
84
 
35
- rounds_completed = _require_count(data, "rounds_completed")
36
- if rounds_completed > 10:
37
- raise ReportStateError("rounds_completed cannot exceed ten")
38
-
39
- findings = data.get("findings")
40
- if not isinstance(findings, list):
41
- raise ReportStateError("findings must be an array")
42
-
43
- summary = data.get("summary")
44
- if not isinstance(summary, str) or not summary.strip():
45
- raise ReportStateError("summary must be a non-empty string")
46
-
47
- for index, finding in enumerate(findings, start=1):
48
- if not isinstance(finding, dict):
49
- raise ReportStateError(f"finding {index} must be an object")
50
- required = {
51
- "title",
52
- "severity",
53
- "location",
54
- "problem",
55
- "failure_scenario",
56
- "disposition",
57
- }
58
- missing = sorted(required - finding.keys())
59
- if missing:
60
- raise ReportStateError(
61
- f"finding {index} missing fields: {', '.join(missing)}"
62
- )
63
- disposition = finding["disposition"]
64
- if not isinstance(disposition, str) or not disposition.startswith(DISPOSITIONS):
65
- raise ReportStateError(f"finding {index} has invalid disposition")
66
-
67
- unresolved = sum(
68
- finding["disposition"].startswith("unresolved") for finding in findings
85
+ def _render_event(data: dict[str, Any]) -> str:
86
+ event = data.get("event")
87
+ if event not in EVENTS:
88
+ raise ReportStateError(f"event has invalid value: {event!r}")
89
+
90
+ if event == "risk-assessment":
91
+ risk = _require_risk(data)
92
+ reasons = data.get("reasons")
93
+ if not isinstance(reasons, list) or not reasons or not all(
94
+ isinstance(reason, str) and reason.strip() for reason in reasons
95
+ ):
96
+ raise ReportStateError("reasons must be a non-empty string array")
97
+ limit = {"low": 0, "medium": 1, "high": 2}[risk]
98
+ lines = [
99
+ "## Risk Assessment",
100
+ "",
101
+ f"- Risk: {risk}",
102
+ f"- Independent Reviewer Limit: {limit}",
103
+ "- Reasons:",
104
+ ]
105
+ lines.extend(f" - {reason.strip()}" for reason in reasons)
106
+ return "\n".join(lines)
107
+
108
+ if event == "main-review-round":
109
+ round_number = _require_count(data, "round", maximum=10)
110
+ if round_number < 1:
111
+ raise ReportStateError("round must be at least 1")
112
+ findings = _require_count(data, "findings")
113
+ accepted = _require_count(data, "accepted")
114
+ rejected = _require_count(data, "rejected")
115
+ if accepted + rejected != findings:
116
+ raise ReportStateError("accepted + rejected must equal findings")
117
+ return "\n".join(
118
+ [
119
+ f"## Main Review Round {round_number}",
120
+ "",
121
+ "- Status: COMPLETED",
122
+ f"- Findings: {findings}",
123
+ f"- Accepted: {accepted}",
124
+ f"- Rejected: {rejected}",
125
+ f"- Next: {_require_text(data, 'next')}",
126
+ ]
127
+ )
128
+
129
+ if event == "repair-verification":
130
+ source = _require_text(data, "source")
131
+ if source not in {"main", "reviewer-1", "reviewer-2"}:
132
+ raise ReportStateError(f"source has invalid value: {source!r}")
133
+ return "\n".join(
134
+ [
135
+ "## Repair Verification",
136
+ "",
137
+ f"- Source: {source}",
138
+ f"- Fixed Findings: {_require_count(data, 'fixed_findings')}",
139
+ f"- Verification: {_require_text(data, 'verification')}",
140
+ f"- Next: {_require_text(data, 'next')}",
141
+ ]
142
+ )
143
+
144
+ if event == "reviewer-running":
145
+ reviewer = _require_count(data, "reviewer", maximum=2)
146
+ if reviewer < 1:
147
+ raise ReportStateError("reviewer must be 1 or 2")
148
+ risk = _require_risk(data)
149
+ if risk == "low" or (risk == "medium" and reviewer == 2):
150
+ raise ReportStateError("reviewer exceeds the semantic risk limit")
151
+ attempt = data.get("attempt", 1)
152
+ if attempt not in {1, 2}:
153
+ raise ReportStateError("attempt must be 1 or 2")
154
+ return "\n".join(
155
+ [
156
+ f"## Independent Reviewer {reviewer}",
157
+ "",
158
+ "- Status: RUNNING",
159
+ f"- Risk Trigger: {risk}",
160
+ f"- Attempt: {attempt}",
161
+ ]
162
+ )
163
+
164
+ if event == "reviewer-result":
165
+ reviewer = _require_count(data, "reviewer", maximum=2)
166
+ if reviewer < 1:
167
+ raise ReportStateError("reviewer must be 1 or 2")
168
+ result = data.get("result")
169
+ if result not in {"PASS", "FINDINGS", "BLOCKED"}:
170
+ raise ReportStateError(f"result has invalid value: {result!r}")
171
+ findings = _require_count(data, "findings")
172
+ accepted = _require_count(data, "accepted")
173
+ rejected = _require_count(data, "rejected")
174
+ accepted_high = _require_count(data, "accepted_high")
175
+ if accepted + rejected != findings or accepted_high > accepted:
176
+ raise ReportStateError("reviewer finding counts are inconsistent")
177
+ status = "BLOCKED" if result == "BLOCKED" else "COMPLETED"
178
+ return "\n".join(
179
+ [
180
+ f"## Independent Reviewer {reviewer} Result",
181
+ "",
182
+ f"- Status: {status}",
183
+ f"- Result: {result}",
184
+ f"- Findings: {findings}",
185
+ f"- Accepted: {accepted}",
186
+ f"- Rejected: {rejected}",
187
+ f"- Accepted High: {accepted_high}",
188
+ f"- Next: {_require_text(data, 'next')}",
189
+ ]
190
+ )
191
+
192
+ if event == "infrastructure-failure":
193
+ reviewer = _require_count(data, "reviewer", maximum=2)
194
+ attempt = _require_count(data, "attempt", maximum=2)
195
+ if reviewer < 1 or attempt < 1:
196
+ raise ReportStateError("reviewer and attempt must be at least 1")
197
+ return "\n".join(
198
+ [
199
+ f"## Independent Reviewer {reviewer} Infrastructure Failure",
200
+ "",
201
+ f"- Attempt: {attempt}",
202
+ f"- Evidence: {_require_text(data, 'evidence')}",
203
+ f"- Next: {_require_text(data, 'next')}",
204
+ ]
205
+ )
206
+
207
+ return "\n".join(
208
+ [
209
+ "## Final Verification",
210
+ "",
211
+ f"- Status: {_require_text(data, 'status')}",
212
+ f"- Evidence: {_require_text(data, 'evidence')}",
213
+ ]
69
214
  )
70
- blocker = data.get("blocker")
71
215
 
216
+
217
+ def append_event(path: Path, data: Any) -> None:
218
+ """Append one validated progress event to an active execution."""
219
+ _ensure_appendable(path)
220
+ _append_section(path, _render_event(_require_object(data)))
221
+
222
+
223
+ def append_final_result(path: Path, data: Any) -> None:
224
+ """Append the sole terminal result for the current execution."""
225
+ _ensure_appendable(path)
226
+ valid = _require_object(data)
227
+ verdict = valid.get("verdict")
228
+ if verdict not in VERDICTS:
229
+ raise ReportStateError(f"verdict has invalid value: {verdict!r}")
230
+ risk = _require_risk(valid)
231
+ main_rounds = _require_count(valid, "main_review_rounds", maximum=10)
232
+ reviewers = _require_count(valid, "independent_reviewers_started", maximum=2)
233
+ if reviewers > {"low": 0, "medium": 1, "high": 2}[risk]:
234
+ raise ReportStateError("independent_reviewers_started exceeds risk limit")
235
+ accepted = _require_count(valid, "accepted_findings")
236
+ fixed = _require_count(valid, "fixed_findings")
237
+ rejected = _require_count(valid, "rejected_findings")
238
+ unresolved = _require_count(valid, "unresolved_findings")
72
239
  if verdict == "PASS" and unresolved:
73
240
  raise ReportStateError("PASS cannot contain unresolved findings")
74
241
  if verdict == "NEEDS_FIXES" and not unresolved:
75
- raise ReportStateError("NEEDS_FIXES requires an unresolved finding")
76
- if verdict == "BLOCKED":
77
- if not isinstance(blocker, str) or not blocker.strip():
78
- raise ReportStateError("BLOCKED requires a non-empty blocker")
79
- if unresolved:
80
- raise ReportStateError("BLOCKED cannot classify code findings as unresolved")
81
- elif blocker is not None:
82
- raise ReportStateError("only BLOCKED may include blocker")
242
+ raise ReportStateError("NEEDS_FIXES requires unresolved findings")
83
243
 
84
- return data
244
+ section = "\n".join(
245
+ [
246
+ FINAL_HEADING,
247
+ "",
248
+ f"- Verdict: {verdict}",
249
+ f"- Risk: {risk}",
250
+ f"- Main Review Rounds: {main_rounds}",
251
+ f"- Independent Reviewers Started: {reviewers}",
252
+ f"- Accepted Findings: {accepted}",
253
+ f"- Fixed Findings: {fixed}",
254
+ f"- Rejected Findings: {rejected}",
255
+ f"- Unresolved Findings: {unresolved}",
256
+ f"- Summary: {_require_text(valid, 'summary')}",
257
+ ]
258
+ )
259
+ _append_section(path, section)
85
260
 
86
261
 
87
- def _finding_totals(findings: list[dict[str, Any]]) -> tuple[int, int, int]:
88
- fixed = sum(finding["disposition"].startswith("fixed") for finding in findings)
89
- rejected = sum(
90
- finding["disposition"].startswith("rejected") for finding in findings
91
- )
92
- unresolved = sum(
93
- finding["disposition"].startswith("unresolved") for finding in findings
94
- )
95
- return fixed, rejected, unresolved
96
-
97
-
98
- def render_report(data: Any) -> str:
99
- valid = validate_data(data)
100
- findings = valid["findings"]
101
- fixed, rejected, unresolved = _finding_totals(findings)
102
-
103
- lines = [
104
- "# Review Report",
105
- "",
106
- f"## Verdict: {valid['verdict']}",
107
- f"## Rounds Completed: {valid['rounds_completed']}",
108
- f"## Total Findings: {len(findings)} → Fixed: {fixed}, Rejected: {rejected}, Unresolved: {unresolved}",
109
- "",
110
- ]
111
-
112
- if findings:
113
- lines.extend(["## Findings", ""])
114
- for index, finding in enumerate(findings, start=1):
115
- lines.extend(
116
- [
117
- f"### Finding {index}: {finding['title']}",
118
- "",
119
- f"- Severity: {finding['severity']}",
120
- f"- Location: {finding['location']}",
121
- f"- Problem: {finding['problem']}",
122
- f"- Failure Scenario: {finding['failure_scenario']}",
123
- f"- Disposition: {finding['disposition']}",
124
- "",
125
- ]
126
- )
127
-
128
- if valid["verdict"] == "BLOCKED":
129
- lines.extend(["## Blocker", "", valid["blocker"], ""])
130
-
131
- lines.extend(["## Summary", "", valid["summary"], ""])
132
- return "\n".join(lines)
262
+ def _read_json_input() -> Any:
263
+ try:
264
+ return json.loads(sys.stdin.read())
265
+ except json.JSONDecodeError as error:
266
+ raise ReportStateError(f"invalid JSON input: {error}") from error
133
267
 
134
268
 
135
269
  def parse_args() -> argparse.Namespace:
136
270
  parser = argparse.ArgumentParser(description=__doc__)
137
- parser.add_argument("output", type=Path, help="review-report.md destination")
138
- parser.add_argument(
139
- "input",
140
- nargs="?",
141
- type=Path,
142
- help="optional JSON input path; reads standard input when omitted",
143
- )
271
+ subparsers = parser.add_subparsers(dest="command", required=True)
272
+ for command in ("init", "append", "finalize"):
273
+ subparser = subparsers.add_parser(command)
274
+ subparser.add_argument("report", type=Path)
144
275
  return parser.parse_args()
145
276
 
146
277
 
147
278
  def main() -> int:
148
279
  args = parse_args()
149
280
  try:
150
- raw = args.input.read_text(encoding="utf-8") if args.input else sys.stdin.read()
151
- data = json.loads(raw)
152
- report = render_report(data)
153
- args.output.parent.mkdir(parents=True, exist_ok=True)
154
- args.output.write_text(report, encoding="utf-8")
155
- except (OSError, json.JSONDecodeError, ReportStateError, KeyError) as error:
281
+ if args.command == "init":
282
+ initialize_report(args.report)
283
+ elif args.command == "append":
284
+ append_event(args.report, _read_json_input())
285
+ else:
286
+ append_final_result(args.report, _read_json_input())
287
+ except (OSError, ReportStateError, KeyError) as error:
156
288
  print(json.dumps({"error": str(error)}, sort_keys=True))
157
289
  return 1
158
290
  return 0
159
291
 
160
292
 
161
293
  if __name__ == "__main__":
162
- sys.exit(main())
294
+ raise SystemExit(main())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prizmkit",
3
- "version": "1.1.125",
3
+ "version": "1.1.127",
4
4
  "description": "Create a new PrizmKit-powered project with clean initialization — no framework dev files, just what you need.",
5
5
  "type": "module",
6
6
  "bin": {