riskrails 3.1.0 → 3.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/README.md +12 -11
  5. package/bin/riskrails.js +109 -18
  6. package/hooks/ai-path-guard-defaults.json +3 -2
  7. package/hooks/context-guard.py +14 -11
  8. package/hooks/dialog-gate.py +1 -1
  9. package/hooks/guard/dialog.py +107 -27
  10. package/hooks/guard/git.py +12 -3
  11. package/hooks/guard/paths.py +109 -7
  12. package/hooks/project-scaffold.py +2 -1
  13. package/hooks/runtime-gate.py +128 -27
  14. package/hooks/update_notice.py +3 -3
  15. package/package.json +1 -1
  16. package/skills/ai-init/templates/.ai/state/README.md +4 -4
  17. package/skills/ai-task/rr_state/__init__.py +1 -0
  18. package/skills/ai-task/rr_state/cli.py +210 -0
  19. package/skills/ai-task/rr_state/close.py +171 -0
  20. package/skills/ai-task/rr_state/dialog.py +541 -0
  21. package/skills/ai-task/rr_state/dialog_cmd.py +164 -0
  22. package/skills/ai-task/rr_state/fs.py +220 -0
  23. package/skills/ai-task/rr_state/gate.py +125 -0
  24. package/skills/ai-task/rr_state/gate_cmd.py +128 -0
  25. package/skills/ai-task/rr_state/handoff.py +226 -0
  26. package/skills/ai-task/rr_state/journal.py +126 -0
  27. package/skills/ai-task/rr_state/labels.py +99 -0
  28. package/skills/ai-task/rr_state/ledger.py +145 -0
  29. package/skills/ai-task/rr_state/locks.py +84 -0
  30. package/skills/ai-task/rr_state/memory_cmd.py +310 -0
  31. package/skills/ai-task/rr_state/policy.py +378 -0
  32. package/skills/ai-task/rr_state/profile.py +373 -0
  33. package/skills/ai-task/rr_state/questions.py +336 -0
  34. package/skills/ai-task/rr_state/questions_cmd.py +321 -0
  35. package/skills/ai-task/rr_state/report.py +271 -0
  36. package/skills/ai-task/rr_state/report_cmd.py +230 -0
  37. package/skills/ai-task/rr_state/review.py +165 -0
  38. package/skills/ai-task/rr_state/runtime.py +80 -0
  39. package/skills/ai-task/rr_state/start.py +257 -0
  40. package/skills/ai-task/rr_state/steps.py +131 -0
  41. package/skills/ai-task/rr_state/task.py +396 -0
  42. package/skills/ai-task/rr_state/verify.py +231 -0
  43. package/skills/ai-task/sensors.py +16 -3
  44. package/skills/ai-task/state.py +24 -5161
  45. package/skills/project-update/adopt.py +38 -24
  46. package/skills/project-update/history/blobs/a51517e13e57420d2d88a6bcfb4055d762d4a0d10a8f88450929c5c4572213a9 +67 -0
  47. package/skills/project-update/history/index.json +2 -1
  48. package/skills/project-update/migrations/0003_context_diet.py +1 -1
  49. package/skills/project-update/migrations/0007_weight_model.py +130 -23
  50. package/skills/project-update/migrations/README.md +16 -1
  51. package/skills/project-update/update.py +202 -26
  52. package/skills/rr-setup/SKILL.md +9 -2
  53. package/skills/rr-status/status.py +36 -9
@@ -0,0 +1,378 @@
1
+ """policy: the weight table's reads, the modes and the budgets they set, the documents rule and the weighing, the suite facts, and the next action each state asks for.
2
+
3
+ core: MODES, MODE_TIER, VERDICT_STATUS, mode_for, higher_mode, mode_setting, BATCH_DEFAULTS, BATCH_TIERS, batch_on, next_action_for, full_suite, suite_green, NEVER_DOCUMENTS, is_document, DOCUMENTS_ZEROED, weigh, review_next_action
4
+ shell: weight_policy, batch_policy, budget, added_lines, rescore_weight, rescore_v7, documents_cover
5
+ """
6
+ import copy
7
+ import os
8
+ import posixpath
9
+ import re
10
+
11
+ from rr_state import fs, journal
12
+ from rr_state import runtime as rr_runtime
13
+
14
+ # weight.py and sensors.py sit beside state.py; the entry puts that directory on sys.path.
15
+ try:
16
+ import sensors # measurement only; it never writes this file
17
+ except ImportError: # an old install that shipped state.py alone
18
+ sensors = None
19
+ try:
20
+ import weight # pure computation; it writes nothing
21
+ except ImportError:
22
+ weight = None
23
+
24
+ MODES = ["light", "guarded", "full", "full_irreversible"]
25
+ # tier_set compat (R18): the old budget tables price a task by its T-label.
26
+ MODE_TIER = {"light": "T2", "guarded": "T4", "full": "T5", "full_irreversible": "T6"}
27
+
28
+ VERDICT_STATUS = {"pass": "passed", "pass_with_findings": "passed_with_findings",
29
+ "blockers_open": "blockers_open"}
30
+
31
+
32
+ def weight_policy(root):
33
+ if weight is None:
34
+ fs.die("weight.py is missing beside state.py: reinstall the plugin")
35
+ try:
36
+ return weight.load_policy(root)[0]
37
+ except (OSError, ValueError, RecursionError) as exc:
38
+ fs.die("cannot read the weight table: %s" % exc)
39
+ return None # unreachable: die() exits
40
+
41
+
42
+ def mode_for(axes, policy, irreversible=False):
43
+ """The mode six axis values earn under `policy`'s thresholds — weight.compute's
44
+ rule, applied to re-scored axes; an irreversible_content hit is
45
+ full_irreversible whatever the sum."""
46
+ modes = policy.get("modes") or {}
47
+ full = modes.get("full") or {}
48
+ hard = full.get("hard_axes") or ["reversibility", "domain"]
49
+ light_max = int((modes.get("light") or {}).get("max_w", 2))
50
+ guarded_max = int((modes.get("guarded") or {}).get("max_w", 7))
51
+ w = sum(int(axes.get(a) or 0) for a in weight.AXES)
52
+ if irreversible or all(int(axes.get(a) or 0) >= 2 for a in hard):
53
+ return "full_irreversible"
54
+ if w >= int(full.get("min_w", guarded_max + 1)) or any(int(axes.get(a) or 0) >= 2 for a in hard):
55
+ return "full"
56
+ return "light" if w <= light_max else "guarded"
57
+
58
+
59
+ def higher_mode(a, b):
60
+ return max(a, b, key=lambda m: MODES.index(m) if m in MODES else -1)
61
+
62
+
63
+ def mode_setting(policy, mode, key, default=None):
64
+ """modes.<mode>.<key>; full_irreversible inherits what it does not say from full."""
65
+ modes = policy.get("modes") or {}
66
+ for name in ([mode, "full"] if mode == "full_irreversible" else [mode]):
67
+ if key in (modes.get(name) or {}):
68
+ return modes[name][key]
69
+ return default
70
+
71
+
72
+ # The batch loop of /rr-build-all: one task for a whole plan, its own budgets.
73
+ BATCH_DEFAULTS = {"default": False, "review_rounds_max": 3, "max_suite_runs": 8, "parallel": 3, "tier": "BALANCED"}
74
+
75
+
76
+ BATCH_TIERS = ["FAST", "BALANCED", "STRONG", "EXPERT"]
77
+
78
+
79
+ def batch_policy(policy, strict=False):
80
+ """weight.json's batch section with every key filled in and checked. A table
81
+ from before the section runs with the shipped numbers (R13). A bad value is
82
+ refused where the task starts or the loop is switched (strict); a reader —
83
+ status, the handoff — falls back to the shipped value and never crashes."""
84
+ section = policy.get("batch") if isinstance(policy.get("batch"), dict) else {}
85
+ out, problems = dict(BATCH_DEFAULTS), []
86
+ for key, value in section.items():
87
+ if key not in BATCH_DEFAULTS:
88
+ continue
89
+ if key == "default":
90
+ if isinstance(value, bool):
91
+ out[key] = value
92
+ else:
93
+ problems.append("batch.default must be true or false, not %r" % (value,))
94
+ elif key == "tier":
95
+ if isinstance(value, str) and value.upper() in BATCH_TIERS:
96
+ out[key] = value.upper()
97
+ else:
98
+ problems.append("batch.tier must be one of %s, not %r" % ("/".join(BATCH_TIERS), value))
99
+ elif isinstance(value, bool) or not isinstance(value, int) or value < 1:
100
+ problems.append("batch.%s must be a whole number of at least 1, not %r" % (key, value))
101
+ else:
102
+ out[key] = value
103
+ if out["parallel"] >= 3 and out["tier"] not in ("FAST", "BALANCED"):
104
+ problems.append("batch.tier %s with batch.parallel %d: three or more parallel agents run at FAST or "
105
+ "BALANCED (model-routing.md, Fan-out)" % (out["tier"], out["parallel"]))
106
+ if problems and strict:
107
+ fs.die("weight.json: " + "; ".join(problems))
108
+ return out
109
+
110
+
111
+ def batch_on(state):
112
+ return bool((state.get("batch") or {}).get("on"))
113
+
114
+
115
+ def budget(policy, state, key):
116
+ """(limit, the table key the refusal names) for review_rounds_max or
117
+ max_suite_runs: the batch section's while the batch loop is on, else the
118
+ standard one — a task switched off mid-way keeps what it already spent."""
119
+ if batch_on(state):
120
+ return int(batch_policy(policy)[key]), "batch.%s" % key
121
+ if key == "max_suite_runs":
122
+ return int((policy.get("tests") or {}).get(key, 2)), "tests.max_suite_runs"
123
+ return int(policy.get(key, 2)), key
124
+
125
+
126
+ def added_lines(root, base_tree, rel):
127
+ """The lines `rel` gained since `base_tree` — all of them for a new file — so
128
+ content that was already there never raises a re-score."""
129
+ before = ""
130
+ if base_tree and sensors is not None:
131
+ code, text = sensors.git(root, ["cat-file", "-p", "%s:%s" % (base_tree, rel)])
132
+ before = text if code == 0 and "\0" not in text[:8192] else ""
133
+ seen = {}
134
+ for line in before.splitlines():
135
+ seen[line] = seen.get(line, 0) + 1
136
+ out = []
137
+ for line in fs.read_text(root, rel).splitlines():
138
+ if seen.get(line):
139
+ seen[line] -= 1
140
+ else:
141
+ out.append(line)
142
+ return out
143
+
144
+
145
+ def rescore_weight(root, base_tree, files, current, policy):
146
+ """Reversibility and domain from the diff's `files`, only upward (R26).
147
+
148
+ It lives here, not in weight.py, because weight.py is at the spec's 300-line
149
+ cap; the rules it applies are weight.json's, through weight.match. Returns
150
+ {"axes", "raised": [{axis, from, to, evidence}], "operations", "irreversible"}.
151
+ A pattern of `irreversible_content` in the lines a `needs_step` file gained is
152
+ reversibility 2 and full_irreversible. The patterns are case-sensitive, as
153
+ the SQL keywords they name are written: `rename()` in Python is not RENAME."""
154
+ spec = policy.get("axes") or {}
155
+ rules = policy.get("rules") or {}
156
+ rev, dom = spec.get("reversibility") or {}, spec.get("domain") or {}
157
+ axes = dict(current or {})
158
+ found, operations = {}, []
159
+ files = [f for f in dict.fromkeys(files or []) if f]
160
+ for rel in files:
161
+ hit = weight.match(rel, rev.get("irreversible"))
162
+ if hit:
163
+ found["reversibility"] = (2, "%s matches irreversible %s" % (rel, hit))
164
+ elif weight.match(rel, rev.get("needs_step")):
165
+ gained = added_lines(root, base_tree, rel)
166
+ for pattern in rev.get("irreversible_content") or []:
167
+ try:
168
+ rx = re.compile(pattern)
169
+ except re.error:
170
+ continue
171
+ line = next((ln for ln in gained if rx.search(ln)), None)
172
+ if line is not None:
173
+ # The operation's keyword (DROP, ALTER …), not the pattern's
174
+ # source: it is what a human names in approve --confirm.
175
+ # The first word of the match; a match of only blanks or punctuation
176
+ # is named IRREVERSIBLE, never an IndexError.
177
+ word = re.search(r"[A-Za-z]+", rx.search(line).group(0))
178
+ operations.append(word.group(0).upper() if word else "IRREVERSIBLE")
179
+ if found.get("reversibility", (0,))[0] < 2:
180
+ found["reversibility"] = (2, "%s adds %r (irreversible_content %r)"
181
+ % (rel, line.strip()[:60], pattern))
182
+ found.setdefault("reversibility", (1, "%s matches needs_step %s"
183
+ % (rel, weight.match(rel, rev.get("needs_step")))))
184
+ for value, key, said in ((2, "modules", "matches modules"), (1, "customer_visible", "is customer-visible:")):
185
+ hit = weight.match(rel, dom.get(key))
186
+ if hit and value > found.get("domain", (0,))[0]:
187
+ found["domain"] = (value, "%s %s %s" % (rel, said, hit))
188
+ break
189
+ for hint in policy.get("hints") or []:
190
+ hit = next((f for f in files if weight.match(f, hint.get("paths"))), None)
191
+ if hit and hint.get("axis") == "domain":
192
+ value, why = found.get("domain", (0, ""))
193
+ found["domain"] = (min(2, value + int(rules.get("hint_raises_by", 1))),
194
+ "%shint %s (%s)" % (why + "; " if why else "", hint.get("note", ""), hit))
195
+ break
196
+ raised = []
197
+ for axis in rules.get("rescore_axes") or ["reversibility", "domain"]:
198
+ if axis not in found:
199
+ continue
200
+ value, why = found[axis]
201
+ if value > int(axes.get(axis) or 0):
202
+ raised.append({"axis": axis, "from": int(axes.get(axis) or 0), "to": value, "evidence": why})
203
+ axes[axis] = value
204
+ return {"axes": axes, "raised": raised, "operations": sorted(set(operations)),
205
+ "irreversible": bool(operations)}
206
+
207
+
208
+ def next_action_for(state):
209
+ if batch_on(state):
210
+ return ("batch loop: 'steps --ready' → implementers, step-done each; 'verify --scope all', red fixed "
211
+ "as one batch; review, findings fixed as one batch; again until it passes; the pass past the "
212
+ "batch budget is the human's")
213
+ return {
214
+ "light": "edit, then 'state.py verify --scope all', then 'state.py close'",
215
+ "guarded": "edit, 'state.py verify --scope all', one review ('state.py review'), then 'state.py close'",
216
+ "full": "edit, 'state.py verify --scope all', one STRONG review, then 'state.py close' "
217
+ "requests the human's approval",
218
+ "full_irreversible": "edit, 'state.py verify --scope all', one STRONG review, then 'state.py close'; "
219
+ "the human approves with --confirm \"<operation>\"",
220
+ }.get(state.get("mode"), "edit, verify, close")
221
+
222
+
223
+ def rescore_v7(root, state, policy, at, rt=rr_runtime.Runtime()):
224
+ """R26 at verify and close: raises are applied, journaled and printed; the
225
+ task never restarts and the other four axes are not recomputed."""
226
+ if sensors is None:
227
+ return
228
+ diff = fs.safe_sensor(sensors.diff_files, root, state["diff"].get("base_tree"), fs.current_tree(root))
229
+ if not diff:
230
+ return
231
+ current = state["weight"].get("axes") or {}
232
+ got = rescore_weight(root, state["diff"].get("base_tree"), [d["path"] for d in diff], current, policy)
233
+ # The documents rule (faster-task-cycle (c)): the axes it zeroed come back, upward, once the
234
+ # diff holds a path that is neither a document nor a test — `start --amend` does not re-weigh.
235
+ zeroed = (state["weight"].get("documents_only") or {}).get("zeroed") or {}
236
+ tests = ((policy.get("axes") or {}).get("coverage") or {}).get("test_globs")
237
+ outside = next((d["path"] for d in diff if not is_document(d["path"], policy)
238
+ and not weight.match(d["path"], tests)), None)
239
+ if outside:
240
+ for axis, value in zeroed.items():
241
+ if int(got["axes"].get(axis) or 0) < int(value):
242
+ got["raised"].append({"axis": axis, "from": int(got["axes"].get(axis) or 0), "to": int(value),
243
+ "evidence": "a non-document changed (%s)" % outside})
244
+ got["axes"][axis] = int(value)
245
+ before = state["mode"]
246
+ ops = sorted(set(state["weight"].get("operations") or []) | set(got["operations"]))
247
+ # Only what the diff moved can move the mode: an unchanged measurement must
248
+ # not undo a mode a human lowered (R27).
249
+ was = mode_for(current, policy, bool(state["weight"].get("operations")))
250
+ now_measured = mode_for(got["axes"], policy, bool(ops))
251
+ mode = higher_mode(before, now_measured) if now_measured != was else before
252
+ if not got["raised"] and mode == before and ops == (state["weight"].get("operations") or []):
253
+ return
254
+ state["weight"].update(axes=got["axes"], w=sum(got["axes"].values()), operations=ops)
255
+ state["mode"] = mode
256
+ for r in got["raised"]:
257
+ journal.emit(root, state, "weight_raised", "%s %d -> %d at %s: %s" % (r["axis"], r["from"], r["to"], at,
258
+ fs.one_line(r["evidence"], 200)),
259
+ dict(r, mode_from=before, mode_to=mode), rt=rt)
260
+ state["weight"]["rescores"].append("%s at %s: %s %d → %d — %s"
261
+ % (fs.now(), at, r["axis"], r["from"], r["to"], r["evidence"]))
262
+ print("re-score: %s %d → %d — %s" % (r["axis"], r["from"], r["to"], fs.one_line(r["evidence"], 120)))
263
+ if mode != before:
264
+ state["weight"]["rescores"].append("%s at %s: mode %s → %s%s" % (
265
+ fs.now(), at, before, mode, " (irreversible: %s)" % ", ".join(ops) if got["irreversible"] else ""))
266
+ journal.emit(root, state, "tier_set", "%s (mode %s, compat)" % (MODE_TIER[mode], mode),
267
+ {"tier": MODE_TIER[mode], "mode": mode, "w": state["weight"]["w"]}, rt=rt)
268
+ state["next_action"] = next_action_for(state)
269
+ print("mode: %s → %s — %s" % (before, mode, state["next_action"]))
270
+
271
+
272
+ def full_suite(run):
273
+ """A run of the whole suite: one narrowed with --files proves only those files."""
274
+ return run.get("scope") == "suite" and not run.get("expected_fail") and not run.get("files")
275
+
276
+
277
+ def suite_green(state):
278
+ return any(full_suite(r) and r.get("exit") == 0 for r in state["verify"]["runs"])
279
+
280
+
281
+ # Never a document, whatever weight.json's `documents` says: the policies and the per-directory rules bind the agents.
282
+ NEVER_DOCUMENTS = (".ai/policies/**", ".ai/rules/**")
283
+
284
+
285
+ def is_document(path, policy):
286
+ """The one definition of a document (faster-task-cycle D4): `documents.globs`, minus
287
+ `documents.except`, NEVER_DOCUMENTS, code and tests; no `documents` key, no document."""
288
+ docs = policy.get("documents") or {}
289
+ cov = (policy.get("axes") or {}).get("coverage") or {}
290
+ return bool(weight.match(path, docs.get("globs"))
291
+ and not any(weight.match(path, pats) for pats in (
292
+ docs.get("except"), NEVER_DOCUMENTS, cov.get("code_globs"), cov.get("test_globs"))))
293
+
294
+
295
+ DOCUMENTS_ZEROED = ("behaviour", "domain", "novelty")
296
+
297
+
298
+ def weigh(root, files, declared, goal, policy, globs=()):
299
+ """weight.compute, plus the documents rule (faster-task-cycle (c), in state.py because
300
+ weight.py is at its line cap). Predicate P: at least one concrete file, every file a
301
+ document (is_document) or a test, at least one a document. When P holds, file text no
302
+ longer scores behaviour (the goal's keyword still does, exactly as in compute), and
303
+ behaviour, domain and novelty still UNKNOWN become 0 with the evidence "documents only"; hints and
304
+ declarations were applied inside compute. `documents_only` is {"zeroed": {axis: what it
305
+ would have scored}}, None when P does not hold or the policy has no `documents` key.
306
+ `globs` are the task's glob entries, which score nothing themselves: P fails when one is
307
+ not itself a document or test glob."""
308
+ concrete = [posixpath.normpath(f.strip().replace(os.sep, "/")) for f in files if f.strip()]
309
+ tests = ((policy.get("axes") or {}).get("coverage") or {}).get("test_globs")
310
+ inside = all(not f.startswith(("/", "../")) and f != ".." for f in concrete)
311
+ if not (inside and all(is_document(g, policy) or weight.match(g, tests) for g in globs)
312
+ and any(is_document(f, policy) for f in concrete)
313
+ and all(is_document(f, policy) or weight.match(f, tests) for f in concrete)):
314
+ result = weight.compute(root, files, declared, goal, policy=policy)
315
+ result.documents_only = None
316
+ return result
317
+ scored = copy.deepcopy(policy)
318
+ keywords = list(((policy.get("axes") or {}).get("behaviour") or {}).get("keywords") or [])
319
+ if not (goal and any(re.search(r"\b%s\b" % re.escape(k), goal, re.I) for k in keywords)):
320
+ # No goal keyword: file text must not score. With one, compute reads the goal first
321
+ # (value 1 whatever the files hold), so keeping the list scores exactly as plain compute does.
322
+ scored["axes"]["behaviour"] = dict(scored["axes"].get("behaviour") or {}, keywords=[])
323
+ result = weight.compute(root, files, declared, goal, policy=scored)
324
+ zeroed = {}
325
+ for axis in DOCUMENTS_ZEROED:
326
+ if axis not in result.unknown:
327
+ continue
328
+ if result.axes[axis] != 0:
329
+ zeroed[axis] = result.axes[axis]
330
+ result.axes[axis], result.evidence[axis] = 0, "documents only"
331
+ result.unknown.remove(axis)
332
+ result.w = sum(result.axes.values())
333
+ result.mode = mode_for(result.axes, policy)
334
+ result.documents_only = {"zeroed": zeroed}
335
+ return result
336
+
337
+
338
+ def documents_cover(root, state, policy, tree):
339
+ """Fact (a) through the documents rule (faster-task-cycle D1-D5): (ok, why, facts).
340
+ why is "" when the rule is off or does not apply: today's refusal text stands."""
341
+ off = (False, "", None)
342
+ tests = (fs.safe_sensor(sensors.read_commands, root) or {}).get("doc_tests", "").split()
343
+ if not (policy.get("documents") or {}).get("globs") or not tests or tests == ["none"]:
344
+ return off # D1
345
+ runs = [r for r in state["verify"]["runs"] if full_suite(r) and r.get("exit") is not None and r.get("tree")]
346
+ g = runs[-1] if runs else None
347
+ if not g or g["exit"] != 0 or any(r["tree"] == g["tree"] and r["exit"] != 0 for r in runs):
348
+ return off # D2
349
+ # A full-suite run after G that is not green stops the rule, whatever its tree (it may be None).
350
+ if any(full_suite(r) and r["n"] > g["n"] and r.get("exit") != 0 for r in state["verify"]["runs"]):
351
+ return off
352
+ diff = fs.safe_sensor(sensors.diff_files, root, g["tree"], tree)
353
+ if not diff or not all(is_document(d["path"], policy) for d in diff):
354
+ return off # D3, D4
355
+ paths = [d["path"] for d in diff]
356
+ steps = [r for r in state["verify"]["runs"] if r.get("scope") == "step" and not r.get("expected_fail")
357
+ and r.get("tree") == tree]
358
+ s = steps[-1] if steps else None # D5
359
+ if not (s and s["n"] > g["n"] and s.get("exit") == 0 and all(t in (s.get("files") or []) for t in tests)):
360
+ return (False, "only documents changed since run %d (%d files): 'verify --scope step --files \"%s\"'"
361
+ % (g["n"], len(paths), ",".join(tests)), None)
362
+ return (True, "documents only since run %d (%d files: %s); doc_tests green in run %d on %s"
363
+ % (g["n"], len(paths), ", ".join(paths[:5]) + (", …" if len(paths) > 5 else ""), s["n"], tree[:9]),
364
+ {"since_run": g["n"], "since_tree": g["tree"], "run": s["n"], "paths": paths[:20], "tests": tests})
365
+
366
+
367
+ def review_next_action(state):
368
+ """What follows the last verdict — also after `batch off`, which changes what
369
+ a blockers_open verdict asks for: a full pass in batch, a scoped re-review outside."""
370
+ status = state["review"].get("status")
371
+ if status == "blockers_open":
372
+ if batch_on(state):
373
+ return ("fix every BLOCKER and HIGH as one parallel batch, 'state.py verify --scope all "
374
+ "--after-fix \"…\"', then a full review pass again")
375
+ return "fix the blockers, 'state.py verify --scope all --after-fix \"…\"', one scoped re-review"
376
+ if status in VERDICT_STATUS.values():
377
+ return "'state.py close'"
378
+ return next_action_for(state)