prizmkit 1.1.123 → 1.1.124

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.
@@ -1,186 +1,380 @@
1
1
  #!/usr/bin/env python3
2
- """
3
- Loop Exit Gatekeeper for prizmkit-code-review skill.
4
-
5
- Stateless design: all loop state is passed via stdin and returned via stdout.
6
- The caller (AI model) tracks `findings_history` and `max_rounds` between calls.
7
- No runtime files are created — the script is a pure function over JSON.
8
-
9
- The model supplies the current round in each call — the script does not auto-increment
10
- rounds, because the gate is called twice per loop iteration (after Reviewer returns
11
- and after Main Agent filters), and only the model knows when a full cycle completes.
12
-
13
- Usage:
14
- echo '{"reviewer_result":"NEEDS_FIXES","accepted_count":2,"findings_count":5,"round":1,"findings_history":[],"max_rounds":3,"filtering_done":true}' | python3 check_loop.py
15
-
16
- Input JSON schema:
17
- {
18
- "reviewer_result": "PASS" | "NEEDS_FIXES",
19
- "accepted_count": int,
20
- "findings_count": int,
21
- "round": int,
22
- "findings_history": [int, ...],
23
- "max_rounds": int,
24
- "filtering_done": bool
25
- }
26
-
27
- Output JSON schema:
28
- {
29
- "endLoop": bool,
30
- "reason": str,
31
- "verdict": "PASS" | "NEEDS_FIXES" | null,
32
- "round": int,
33
- "maxRounds": int,
34
- "divergenceWarning": bool,
35
- "findings_history": [int, ...]
36
- }
37
-
38
- Exit conditions (checked in order):
39
- 1. Reviewer returned PASS → endLoop=true, verdict=PASS
40
- 2. NEEDS_FIXES before filtering → endLoop=false, wait for filtering
41
- 3. Filtered findings all rejected → endLoop=true, verdict=PASS
42
- 4. Max rounds reached after filtering → endLoop=true, verdict=NEEDS_FIXES
43
- 5. Otherwise → endLoop=false
2
+ """Strict execution and convergence gate for prizmkit-code-review protocol v2."""
44
3
 
45
- Divergence detection:
46
- If findings_count strictly increases for 3 consecutive rounds, set divergenceWarning=true.
47
- This is a warning only — it does not force loop exit.
48
- """
4
+ from __future__ import annotations
49
5
 
50
6
  import json
51
7
  import sys
8
+ from typing import Any
9
+
10
+ PROTOCOL_VERSION = 2
11
+ REVIEW_SAFETY_LIMIT = 10
12
+
13
+ CAPABILITY_FIELDS = {
14
+ "mutation_capability",
15
+ "delegation_equivalent_capability",
16
+ "downstream_execution_creation",
17
+ "orchestration_reentry_capability",
18
+ "review_execution",
19
+ "status",
20
+ }
21
+ PREFLIGHT_FIELDS = {
22
+ "protocol_version",
23
+ "gate_stage",
24
+ "round",
25
+ "reviewers_started_this_round",
26
+ "reviewers_started_total",
27
+ "fresh_reviewer",
28
+ "workspace_snapshot_ready",
29
+ "capability_attestation",
30
+ }
31
+ RESULT_BASE_FIELDS = {
32
+ "protocol_version",
33
+ "gate_stage",
34
+ "filtering_done",
35
+ "round",
36
+ "reviewers_started_this_round",
37
+ "reviewers_started_total",
38
+ "fresh_reviewer",
39
+ "descendant_execution_units_observed",
40
+ "delegation_depth_observed",
41
+ "capability_attestation",
42
+ "orchestration_status",
43
+ "workspace_status",
44
+ "review_complete",
45
+ "reviewer_result",
46
+ "findings_count",
47
+ }
48
+ RESULT_FILTER_FIELDS = {"accepted_count", "rejected_count"}
49
+
50
+
51
+ class ProtocolError(ValueError):
52
+ """Raised when a payload violates the strict protocol schema."""
52
53
 
53
- DEFAULT_MAX_ROUNDS = 3
54
54
 
55
+ def _is_int(value: Any) -> bool:
56
+ return isinstance(value, int) and not isinstance(value, bool)
55
57
 
56
- def check_divergence(findings_history):
57
- """Return True if the last 3 entries in findings_history are strictly increasing."""
58
- if len(findings_history) < 3:
59
- return False
60
- last_three = findings_history[-3:]
61
- return last_three[0] < last_three[1] < last_three[2]
62
58
 
59
+ def _require_exact_fields(data: dict[str, Any], required: set[str]) -> None:
60
+ missing = sorted(required - data.keys())
61
+ unknown = sorted(data.keys() - required)
62
+ if missing:
63
+ raise ProtocolError(f"missing required fields: {', '.join(missing)}")
64
+ if unknown:
65
+ raise ProtocolError(f"unknown fields: {', '.join(unknown)}")
66
+
67
+
68
+ def _require_bool(data: dict[str, Any], name: str) -> bool:
69
+ value = data[name]
70
+ if not isinstance(value, bool):
71
+ raise ProtocolError(f"{name} must be a boolean")
72
+ return value
73
+
74
+
75
+ def _require_count(data: dict[str, Any], name: str) -> int:
76
+ value = data[name]
77
+ if not _is_int(value) or value < 0:
78
+ raise ProtocolError(f"{name} must be a non-negative integer")
79
+ return value
80
+
81
+
82
+ def _require_round(data: dict[str, Any]) -> int:
83
+ current_round = _require_count(data, "round")
84
+ if not 1 <= current_round <= REVIEW_SAFETY_LIMIT:
85
+ raise ProtocolError(
86
+ f"round must be between 1 and {REVIEW_SAFETY_LIMIT}"
87
+ )
88
+ return current_round
63
89
 
64
- def record_findings_count(findings_history, current_round, findings_count):
65
- """Record findings_count once per round index, updating duplicate calls for the same round."""
66
- if current_round <= 0:
67
- return findings_history
68
90
 
69
- target_index = current_round - 1
70
- while len(findings_history) < target_index:
71
- findings_history.append(0)
91
+ def _require_enum(data: dict[str, Any], name: str, allowed: set[str]) -> str:
92
+ value = data[name]
93
+ if value not in allowed:
94
+ choices = ", ".join(sorted(allowed))
95
+ raise ProtocolError(f"{name} must be one of: {choices}")
96
+ return value
72
97
 
73
- if len(findings_history) == target_index:
74
- findings_history.append(findings_count)
75
- else:
76
- findings_history[target_index] = findings_count
77
98
 
78
- return findings_history
99
+ def _validate_protocol_header(data: dict[str, Any], stage: str) -> None:
100
+ if data["protocol_version"] != PROTOCOL_VERSION:
101
+ raise ProtocolError(
102
+ f"protocol_version must be {PROTOCOL_VERSION}"
103
+ )
104
+ if data["gate_stage"] != stage:
105
+ raise ProtocolError(f"gate_stage must be {stage}")
79
106
 
80
107
 
81
- def check_exit_condition(data):
82
- """Determine whether the loop should exit. Pure function — no side effects.
108
+ def _validate_attestation(value: Any) -> bool:
109
+ if not isinstance(value, dict):
110
+ raise ProtocolError("capability_attestation must be an object")
111
+ _require_exact_fields(value, CAPABILITY_FIELDS)
83
112
 
84
- Round is model-supplied — the script does not auto-increment it because
85
- the gate is called twice per loop iteration (after Step 2 and Step 3).
86
- """
87
- reviewer_result = data.get("reviewer_result")
88
- accepted_count = data.get("accepted_count", 0)
89
- findings_count = data.get("findings_count", 0)
90
- current_round = data.get("round", 0)
91
- findings_history = list(data.get("findings_history", []))
92
- max_rounds = data.get("max_rounds", DEFAULT_MAX_ROUNDS)
93
- filtering_done = data.get("filtering_done", reviewer_result == "PASS")
113
+ unavailable_fields = (
114
+ "mutation_capability",
115
+ "delegation_equivalent_capability",
116
+ "downstream_execution_creation",
117
+ "orchestration_reentry_capability",
118
+ )
119
+ for name in unavailable_fields:
120
+ _require_enum(value, name, {"available", "unavailable"})
121
+ _require_enum(
122
+ value,
123
+ "review_execution",
124
+ {"foreground-single-authority", "unsupported"},
125
+ )
126
+ _require_enum(value, "status", {"FAILED", "VERIFIED"})
94
127
 
95
- findings_history = record_findings_count(
96
- findings_history, current_round, findings_count
128
+ return (
129
+ value["status"] == "VERIFIED"
130
+ and value["review_execution"] == "foreground-single-authority"
131
+ and all(value[name] == "unavailable" for name in unavailable_fields)
97
132
  )
98
- divergence_warning = check_divergence(findings_history)
99
-
100
- # Condition 1: Reviewer returned PASS
101
- if reviewer_result == "PASS":
102
- return {
103
- "endLoop": True,
104
- "reason": f"Round {current_round}: Reviewer returned PASS",
105
- "verdict": "PASS",
106
- "round": current_round,
107
- "maxRounds": max_rounds,
108
- "divergenceWarning": False,
109
- "findings_history": findings_history,
110
- }
111
-
112
- # Condition 2: Reviewer returned findings, but filtering has not happened yet
113
- if reviewer_result == "NEEDS_FIXES" and not filtering_done:
114
- return {
115
- "endLoop": False,
116
- "reason": f"Round {current_round}: reviewer returned findings; filter before applying exit conditions",
117
- "verdict": None,
118
- "round": current_round,
119
- "maxRounds": max_rounds,
120
- "divergenceWarning": divergence_warning,
121
- "findings_history": findings_history,
122
- }
123
-
124
- # Condition 3: All findings rejected after filtering
125
- if reviewer_result == "NEEDS_FIXES" and accepted_count == 0:
126
- return {
127
- "endLoop": True,
128
- "reason": f"Round {current_round}: all findings rejected by main agent",
129
- "verdict": "PASS",
130
- "round": current_round,
131
- "maxRounds": max_rounds,
132
- "divergenceWarning": False,
133
- "findings_history": findings_history,
134
- }
135
-
136
- # Condition 4: Max rounds reached
137
- if current_round >= max_rounds:
138
- return {
139
- "endLoop": True,
140
- "reason": f"Hard limit: max {max_rounds} rounds reached",
141
- "verdict": "NEEDS_FIXES",
142
- "round": current_round,
143
- "maxRounds": max_rounds,
144
- "divergenceWarning": divergence_warning,
145
- "findings_history": findings_history,
146
- }
147
-
148
- # Condition 5: Continue loop
149
- next_round = current_round + 1
133
+
134
+
135
+ def _response(
136
+ *,
137
+ action: str,
138
+ verdict: str | None,
139
+ blocker_code: str | None = None,
140
+ launch_authorized: bool = False,
141
+ result_usable: bool = False,
142
+ end_loop: bool = False,
143
+ reason: str,
144
+ ) -> dict[str, Any]:
150
145
  return {
151
- "endLoop": False,
152
- "reason": f"Round {current_round}: {accepted_count} findings accepted, continuing to round {next_round}",
153
- "verdict": None,
154
- "round": current_round,
155
- "maxRounds": max_rounds,
156
- "divergenceWarning": divergence_warning,
157
- "findings_history": findings_history,
146
+ "action": action,
147
+ "launchAuthorized": launch_authorized,
148
+ "resultUsable": result_usable,
149
+ "endLoop": end_loop,
150
+ "verdict": verdict,
151
+ "blockerCode": blocker_code,
152
+ "reason": reason,
158
153
  }
159
154
 
160
155
 
161
- def main():
162
- # Read input from stdin
156
+ def check_preflight(data: dict[str, Any]) -> dict[str, Any]:
157
+ """Authorize one safe Reviewer launch or return a fail-closed blocker."""
158
+ _require_exact_fields(data, PREFLIGHT_FIELDS)
159
+ _validate_protocol_header(data, "preflight")
160
+ current_round = _require_round(data)
161
+ started_this_round = _require_count(data, "reviewers_started_this_round")
162
+ started_total = _require_count(data, "reviewers_started_total")
163
+ fresh_reviewer = _require_bool(data, "fresh_reviewer")
164
+ snapshot_ready = _require_bool(data, "workspace_snapshot_ready")
165
+ attested = _validate_attestation(data["capability_attestation"])
166
+
167
+ if started_this_round != 0:
168
+ raise ProtocolError(
169
+ "preflight requires reviewers_started_this_round to equal 0"
170
+ )
171
+ if started_total != current_round - 1:
172
+ raise ProtocolError(
173
+ "preflight reviewers_started_total must equal round - 1"
174
+ )
175
+ if not fresh_reviewer:
176
+ return _response(
177
+ action="STOP",
178
+ verdict="NEEDS_FIXES",
179
+ blocker_code="REVIEW_INFRASTRUCTURE_BLOCKED",
180
+ end_loop=True,
181
+ reason="Reviewer execution is not fresh",
182
+ )
183
+ if not snapshot_ready:
184
+ return _response(
185
+ action="STOP",
186
+ verdict="NEEDS_FIXES",
187
+ blocker_code="REVIEW_INFRASTRUCTURE_BLOCKED",
188
+ end_loop=True,
189
+ reason="Workspace snapshot is not ready",
190
+ )
191
+ if not attested:
192
+ return _response(
193
+ action="STOP",
194
+ verdict="NEEDS_FIXES",
195
+ blocker_code="REVIEW_INFRASTRUCTURE_BLOCKED",
196
+ end_loop=True,
197
+ reason="Mandatory Reviewer capability boundary is not verified",
198
+ )
199
+
200
+ return _response(
201
+ action="AUTHORIZE_LAUNCH",
202
+ verdict=None,
203
+ launch_authorized=True,
204
+ reason=f"Round {current_round} may launch one fresh Reviewer",
205
+ )
206
+
207
+
208
+ def _validate_result_structure(data: dict[str, Any]) -> tuple[int, int, bool]:
209
+ filtering_done = _require_bool(data, "filtering_done")
210
+ required_fields = RESULT_BASE_FIELDS | (
211
+ RESULT_FILTER_FIELDS if filtering_done else set()
212
+ )
213
+ _require_exact_fields(data, required_fields)
214
+ _validate_protocol_header(data, "result")
215
+
216
+ current_round = _require_round(data)
217
+ started_this_round = _require_count(data, "reviewers_started_this_round")
218
+ started_total = _require_count(data, "reviewers_started_total")
219
+ fresh_reviewer = _require_bool(data, "fresh_reviewer")
220
+ _require_count(data, "descendant_execution_units_observed")
221
+ _require_count(data, "delegation_depth_observed")
222
+ _require_bool(data, "review_complete")
223
+ findings_count = _require_count(data, "findings_count")
224
+ reviewer_result = _require_enum(
225
+ data, "reviewer_result", {"NEEDS_FIXES", "PASS"}
226
+ )
227
+ _require_enum(
228
+ data,
229
+ "orchestration_status",
230
+ {"COMPLIANT", "UNVERIFIABLE", "VIOLATION"},
231
+ )
232
+ _require_enum(data, "workspace_status", {"MATCHED", "MISMATCH"})
233
+ _validate_attestation(data["capability_attestation"])
234
+
235
+ if started_this_round != 1:
236
+ raise ProtocolError(
237
+ "result requires exactly one Reviewer started in the round"
238
+ )
239
+ if started_total != current_round:
240
+ raise ProtocolError("result reviewers_started_total must equal round")
241
+ if not fresh_reviewer:
242
+ raise ProtocolError("result requires a fresh Reviewer execution")
243
+ if reviewer_result == "PASS" and findings_count != 0:
244
+ raise ProtocolError("PASS requires findings_count to equal 0")
245
+ if reviewer_result == "NEEDS_FIXES" and findings_count == 0:
246
+ raise ProtocolError("NEEDS_FIXES requires at least one finding")
247
+
248
+ if filtering_done:
249
+ accepted_count = _require_count(data, "accepted_count")
250
+ rejected_count = _require_count(data, "rejected_count")
251
+ if accepted_count + rejected_count != findings_count:
252
+ raise ProtocolError(
253
+ "accepted_count + rejected_count must equal findings_count"
254
+ )
255
+ if reviewer_result == "PASS" and (accepted_count or rejected_count):
256
+ raise ProtocolError("PASS cannot contain finding classifications")
257
+
258
+ return current_round, findings_count, filtering_done
259
+
260
+
261
+ def check_result(data: dict[str, Any]) -> dict[str, Any]:
262
+ """Validate topology and workspace before filtering, then decide convergence."""
263
+ current_round, _, filtering_done = _validate_result_structure(data)
264
+ attested = _validate_attestation(data["capability_attestation"])
265
+
266
+ if (
267
+ not attested
268
+ or data["descendant_execution_units_observed"] != 0
269
+ or data["delegation_depth_observed"] != 0
270
+ or data["orchestration_status"] != "COMPLIANT"
271
+ ):
272
+ return _response(
273
+ action="STOP",
274
+ verdict="NEEDS_FIXES",
275
+ blocker_code="REVIEW_ORCHESTRATION_VIOLATION",
276
+ end_loop=True,
277
+ reason="Reviewer execution topology is non-compliant or unverifiable",
278
+ )
279
+
280
+ if data["workspace_status"] != "MATCHED":
281
+ return _response(
282
+ action="STOP",
283
+ verdict="NEEDS_FIXES",
284
+ blocker_code="WORKSPACE_MISMATCH",
285
+ end_loop=True,
286
+ reason="Reviewer workspace does not match the authorized snapshot",
287
+ )
288
+
289
+ if not data["review_complete"]:
290
+ return _response(
291
+ action="STOP",
292
+ verdict="NEEDS_FIXES",
293
+ blocker_code="INVALID_REVIEW_RESULT",
294
+ end_loop=True,
295
+ reason="Reviewer output is incomplete",
296
+ )
297
+
298
+ if not filtering_done:
299
+ if data["reviewer_result"] == "PASS":
300
+ return _response(
301
+ action="PASS",
302
+ verdict="PASS",
303
+ result_usable=True,
304
+ end_loop=True,
305
+ reason=f"Round {current_round} completed with no findings",
306
+ )
307
+ return _response(
308
+ action="AUTHORIZE_FILTERING",
309
+ verdict=None,
310
+ result_usable=True,
311
+ reason=f"Round {current_round} findings are authorized for filtering",
312
+ )
313
+
314
+ if data["accepted_count"] == 0:
315
+ return _response(
316
+ action="PASS",
317
+ verdict="PASS",
318
+ result_usable=True,
319
+ end_loop=True,
320
+ reason=f"Round {current_round} converged with zero accepted findings",
321
+ )
322
+
323
+ if current_round == REVIEW_SAFETY_LIMIT:
324
+ return _response(
325
+ action="STOP",
326
+ verdict="NEEDS_FIXES",
327
+ blocker_code="REVIEW_SAFETY_LIMIT_REACHED",
328
+ result_usable=True,
329
+ end_loop=True,
330
+ reason="Accepted findings remain at the internal safety limit",
331
+ )
332
+
333
+ return _response(
334
+ action="CONTINUE",
335
+ verdict=None,
336
+ result_usable=True,
337
+ reason=(
338
+ f"Round {current_round} has {data['accepted_count']} accepted "
339
+ "findings; repair and start a fresh complete review"
340
+ ),
341
+ )
342
+
343
+
344
+ def check_gate(data: Any) -> dict[str, Any]:
345
+ """Validate a strict v2 payload and dispatch to the requested stage."""
346
+ if not isinstance(data, dict):
347
+ raise ProtocolError("input must be a JSON object")
348
+ stage = data.get("gate_stage")
349
+ if stage == "preflight":
350
+ return check_preflight(data)
351
+ if stage == "result":
352
+ return check_result(data)
353
+ raise ProtocolError("gate_stage must be preflight or result")
354
+
355
+
356
+ def main() -> int:
163
357
  try:
164
358
  data = json.load(sys.stdin)
165
- except json.JSONDecodeError as e:
359
+ result = check_gate(data)
360
+ except (json.JSONDecodeError, ProtocolError) as error:
166
361
  print(
167
362
  json.dumps(
168
- {
169
- "endLoop": False,
170
- "reason": f"Invalid JSON input: {e}",
171
- "verdict": None,
172
- "round": 0,
173
- "maxRounds": DEFAULT_MAX_ROUNDS,
174
- "divergenceWarning": False,
175
- "findings_history": [],
176
- }
363
+ _response(
364
+ action="STOP",
365
+ verdict="NEEDS_FIXES",
366
+ blocker_code="INVALID_REVIEW_RESULT",
367
+ end_loop=True,
368
+ reason=f"Invalid protocol payload: {error}",
369
+ ),
370
+ sort_keys=True,
177
371
  )
178
372
  )
179
- sys.exit(1)
373
+ return 1
180
374
 
181
- result = check_exit_condition(data)
182
- print(json.dumps(result))
375
+ print(json.dumps(result, sort_keys=True))
376
+ return 0
183
377
 
184
378
 
185
379
  if __name__ == "__main__":
186
- main()
380
+ sys.exit(main())