riskrails 3.1.0 → 3.2.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 (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,396 @@
1
+ """task: the task record — its schema, defaults, load and save, the task id, the steps and their order, and the old-surface routing.
2
+
3
+ core: TASK_COMMANDS, apply_defaults, TASK_ID_RE, open_gate, SCHEMA, is_v7, apply_defaults_v7, task_files, _overlap, order_shared_files, ready_steps, old_surface_refusal
4
+ shell: load, claim_runtime, save, sealed_task_ids, next_task_id, valid_task_id, load_v7, parse_steps, check_steps, abandon_old, route_surface, in_flight
5
+ """
6
+ # pylint: disable=protected-access # parse_steps calls fs._split_files, which partition.json assigns to fs
7
+ import contextlib
8
+ import json
9
+ import os
10
+ import re
11
+ from datetime import datetime, timezone
12
+
13
+ from rr_state import fs
14
+ from rr_state import journal
15
+ from rr_state import runtime as rr_runtime
16
+
17
+ try:
18
+ import weight # pure computation; it writes nothing
19
+ except ImportError:
20
+ weight = None
21
+
22
+
23
+ # The commands that work on the task in flight; a task the old surface started
24
+ # refuses them all but close --abandon (route_surface).
25
+ TASK_COMMANDS = {"step-done", "ask", "answer", "questions", "approve", "reject", "close", "verify",
26
+ "review", "steps", "batch"}
27
+
28
+
29
+ def apply_defaults(state):
30
+ """Schema 2 keys (I6) in memory, so a v1 state answers every command before
31
+ migration 0002 has run; save() then persists them. A reader that opens
32
+ the state file directly — update.py does — sees the file, not these."""
33
+ if is_v7(state):
34
+ return apply_defaults_v7(state)
35
+ task_id = state.get("task_id") or ""
36
+ state.setdefault("owner_runtime", None)
37
+ state.setdefault("resume_point", None)
38
+ # A hand-edited or half-migrated file may hold null where v1 held an object.
39
+ # load() promises to be loud about a file it cannot read and silent about one
40
+ # it can, so the defaults are total rather than assuming the v1 shape.
41
+ if not isinstance(state.get("questions"), dict):
42
+ state["questions"] = {"file": ".ai/reports/%s/task.md" % task_id, "pending": []}
43
+ if not isinstance(state.get("handoff"), dict):
44
+ state["handoff"] = {"file": ".ai/state/handoff.md", "written_at": None, "reason": None}
45
+ if not isinstance(state.get("human_approval"), dict):
46
+ state["human_approval"] = {"required": True, "granted": False,
47
+ "granted_by": None, "granted_at": None}
48
+ for key, default in (("requested_at", None), ("gate_id", None), ("requested_session", None),
49
+ ("rejected_at", None), ("via", None), ("unattended", False)):
50
+ state["human_approval"].setdefault(key, default)
51
+ # Schema 4 (WP4 I2): a task that started before the gates existed has no
52
+ # tree to measure from, and one cannot be invented afterwards.
53
+ if not isinstance(state.get("diff"), dict):
54
+ state["diff"] = {}
55
+ state["diff"].setdefault("base_commit", None)
56
+ state["diff"].setdefault("base_tree", None)
57
+ # Schema 5 (WP5 I4): a task handed to the other runtime, and a review asked of it.
58
+ state["handoff"].setdefault("pending_to", None)
59
+ state["handoff"].setdefault("pending_since", None)
60
+ state.setdefault("cross_vendor_review", None)
61
+ return state
62
+
63
+
64
+ def load(root, required=True, claim=None, rt=rr_runtime.Runtime()):
65
+ path = fs.state_path(root)
66
+ if not os.path.exists(path):
67
+ if required:
68
+ fs.die("no task in flight (%s does not exist). Start one with state.py start." % path)
69
+ return None
70
+ try:
71
+ with open(path, encoding="utf-8") as fh:
72
+ data = json.load(fh)
73
+ except (OSError, ValueError, RecursionError) as exc:
74
+ # Loud on purpose: a corrupt state file must never be silently replaced,
75
+ # because ai-scope-guard's boundaries are derived from it.
76
+ fs.die("%s is unreadable (%s). Inspect it by hand; do not delete it blindly." % (path, exc))
77
+ state = apply_defaults(data)
78
+ # Ownership is taken before the command's own events, so the journal and
79
+ # history both show the handoff ahead of the work it covers.
80
+ if rt.mutating if claim is None else claim:
81
+ claim_runtime(root, state, rt=rt)
82
+ return state
83
+
84
+
85
+ def claim_runtime(root, state, rt=rr_runtime.Runtime()):
86
+ """Called from load() for the commands in MUTATING_COMMANDS, so ownership is
87
+ taken once rather than in fifteen call sites. A task that changes runtime says
88
+ so in the journal; WP5 is what will move a task on purpose."""
89
+ pending = (state.get("handoff") or {}).get("pending_to")
90
+ if pending in rr_runtime.RUNTIMES:
91
+ if rt.name != pending:
92
+ back = ("state.py handoff --to %s" % rt.name if rt.name in rr_runtime.RUNTIMES else
93
+ "state.py --runtime %s handoff --to %s (this shell is neither runtime; "
94
+ "name the one taking it)" % ((rr_runtime.other_runtime(pending, rt=rt),) * 2))
95
+ fs.die("RUNTIME_HANDOFF_PENDING: task %s was handed to %s at %s. Resume it there "
96
+ "(/rr-build), or take it back here with: %s"
97
+ % (state.get("task_id"), pending, state["handoff"].get("pending_since"), back), 7)
98
+ # The receiving runtime's first change: the handoff is complete. The
99
+ # move itself was journaled when it was made, so there is no second event.
100
+ state["handoff"]["pending_to"] = None
101
+ state["handoff"]["pending_since"] = None
102
+ previous = state.get("owner_runtime")
103
+ if rt.name not in rr_runtime.RUNTIMES or previous == rt.name:
104
+ return
105
+ if previous in rr_runtime.RUNTIMES:
106
+ journal.emit(root, state, "runtime_handoff", "%s -> %s" % (previous, rt.name),
107
+ {"from": previous, "to": rt.name, "via": "resume"}, rt=rt)
108
+ state["owner_runtime"] = rt.name
109
+
110
+
111
+ def save(root, state):
112
+ state["updated_at"] = fs.now()
113
+ path = fs.state_path(root)
114
+ try:
115
+ os.makedirs(os.path.dirname(path), exist_ok=True)
116
+ tmp = "%s.%d.tmp" % (path, os.getpid())
117
+ with open(tmp, "w", encoding="utf-8") as fh:
118
+ json.dump(state, fh, indent=2, ensure_ascii=False)
119
+ fh.write("\n")
120
+ os.replace(tmp, path)
121
+ except OSError as exc:
122
+ fs.die("cannot write %s (%s) — the state is the contract, so nothing was recorded" % (path, exc))
123
+
124
+
125
+ def sealed_task_ids(root):
126
+ """The task ids a package under .ai/memory/ seals; empty in files mode."""
127
+ found = set()
128
+ base = os.path.join(root, ".ai", "memory")
129
+ for name in sorted(os.listdir(base)) if os.path.isdir(base) else []:
130
+ try:
131
+ with open(os.path.join(base, name), "rb") as fh:
132
+ for raw in fh:
133
+ if b'"kind":"task"' not in raw:
134
+ continue
135
+ with contextlib.suppress(ValueError, UnicodeDecodeError, AttributeError, RecursionError):
136
+ found.add(json.loads(raw.decode("utf-8")).get("task"))
137
+ except OSError:
138
+ continue
139
+ return found
140
+
141
+
142
+ def next_task_id(root, goal="", slug=None, at=None):
143
+ """R1: T-YYYY-MM-DD-HHMM-<slug>, from the clock and the goal alone. A count
144
+ of the day's report directories gave two clones the same id and handed a
145
+ deleted directory's id to the next task. A local hit — a report directory,
146
+ the raw store under .ai/state/logs/, a sealed key — gets -2 … -9."""
147
+ import memory_format # pylint: disable=import-outside-toplevel
148
+ stamp = at or datetime.now(timezone.utc)
149
+ base = slug or memory_format.slug_of(goal)
150
+ sealed = sealed_task_ids(root)
151
+ for n in range(1, 10):
152
+ task_id = memory_format.new_task_id(stamp, memory_format.with_suffix(base, n))
153
+ if not (os.path.exists(os.path.join(root, ".ai", "reports", task_id))
154
+ or os.path.exists(os.path.join(root, ".ai", "state", "logs", task_id)) or task_id in sealed):
155
+ return task_id
156
+ fs.die("%s and -2 … -9 are all taken this minute: start again with another --slug \"<3-24 characters>\""
157
+ % memory_format.new_task_id(stamp, base), 2)
158
+ return None
159
+
160
+
161
+ TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
162
+
163
+
164
+ def valid_task_id(task_id, flag="--task-id"):
165
+ """A task id is a directory name under .ai/reports/, and everything the
166
+ journal and the questions file are worth rests on it staying one. A '/' or
167
+ a '..' in it puts both outside the project — and outside the path guard's
168
+ `.ai/reports/<id>/` patterns, which is how a writable task.md, and
169
+ with it a self-granted gate, would come back."""
170
+ if not isinstance(task_id, str) or not TASK_ID_RE.match(task_id) or ".." in task_id:
171
+ fs.die("%s takes a directory name — letters, digits, '.', '_' and '-', "
172
+ "no path separators — not %r" % (flag, task_id))
173
+ return task_id
174
+
175
+
176
+ def open_gate(state):
177
+ """The one signal that a gate is open, and the one thing that names it.
178
+
179
+ R11 words this as "a gate_requested event exists", and the first draft read
180
+ the journal for one. It must not: events.jsonl is append-only, unprotected,
181
+ and keyed on a caller-supplied task id, so a single hand-written line — or
182
+ an `init --task-id` that reuses an old id — satisfied the precondition. The
183
+ state is the contract and the path guard protects it; `stage human_approval`
184
+ is the only writer, and approve/reject consume what it wrote.
185
+ """
186
+ approval = state.get("human_approval") or {}
187
+ if approval.get("requested_at") and approval.get("gate_id"):
188
+ return approval["gate_id"]
189
+ return None
190
+
191
+
192
+ SCHEMA = 7
193
+
194
+
195
+ def is_v7(state):
196
+ return isinstance(state, dict) and state.get("schema") == SCHEMA
197
+
198
+
199
+ def apply_defaults_v7(state):
200
+ """The keys every shared helper reads (questions, handoff, the gate) and the
201
+ schema-7 ones; none of the old surface's own keys, so a schema-7 file holds
202
+ only what its commands use."""
203
+ task_id = state.get("task_id") or ""
204
+ state.setdefault("owner_runtime", None)
205
+ state.setdefault("resume_point", None)
206
+ state.setdefault("cross_vendor_review", None)
207
+ if not isinstance(state.get("questions"), dict):
208
+ state["questions"] = {"file": ".ai/reports/%s/task.md" % task_id, "pending": []}
209
+ if not isinstance(state.get("handoff"), dict):
210
+ state["handoff"] = {"file": ".ai/state/handoff.md", "written_at": None, "reason": None}
211
+ state["handoff"].setdefault("pending_to", None)
212
+ state["handoff"].setdefault("pending_since", None)
213
+ if not isinstance(state.get("human_approval"), dict):
214
+ state["human_approval"] = {"required": False, "granted": False,
215
+ "granted_by": None, "granted_at": None}
216
+ for key, default in (("requested_at", None), ("gate_id", None), ("requested_session", None),
217
+ ("rejected_at", None), ("via", None), ("unattended", False)):
218
+ state["human_approval"].setdefault(key, default)
219
+ for key, default in (("files", []), ("tests", []), ("steps", []), ("amendments", []),
220
+ ("findings", []), ("diff", {}), ("gate", None), ("mode_override", None)):
221
+ if not isinstance(state.get(key), type(default)) and not (default is None and key in state):
222
+ state[key] = default
223
+ for step in state["steps"]:
224
+ if not isinstance(step.get("after"), list):
225
+ step["after"] = [] # a step from before the batch loop depends on nothing
226
+ if not isinstance(state.get("batch"), dict):
227
+ state["batch"] = {"on": False, "by": None, "why": None, "since": None}
228
+ if not isinstance(state.get("verify"), dict):
229
+ state["verify"] = {}
230
+ for key, default in (("status", "not_run"), ("e2e", "not_run"), ("suite_runs", 0), ("runs", [])):
231
+ state["verify"].setdefault(key, default)
232
+ if not isinstance(state.get("review"), dict):
233
+ state["review"] = {}
234
+ for key, default in (("status", "not_started"), ("passes", 0), ("tier", None),
235
+ ("findings", []), ("tree", None), ("log", []), ("closed", [])):
236
+ state["review"].setdefault(key, default)
237
+ return state
238
+
239
+
240
+ def load_v7(root, rt=rr_runtime.Runtime()):
241
+ state = load(root, rt=rt)
242
+ if not is_v7(state):
243
+ fs.die(old_surface_refusal(state))
244
+ return state
245
+
246
+
247
+ def task_files(state):
248
+ """Every file and glob the task may touch: its files, its tests, every step's."""
249
+ out = list(state.get("files") or []) + list(state.get("tests") or [])
250
+ for step in state.get("steps") or []:
251
+ out += list(step.get("files") or []) + list(step.get("tests") or [])
252
+ return list(dict.fromkeys(out))
253
+
254
+
255
+ def parse_steps(text, plan_file):
256
+ """--steps "id:a,b:t;id2:c" or --plan-file F.json ([{id, files, tests, after}]);
257
+ `after` (plan-file only) names the steps this one waits for (R2)."""
258
+ raw = []
259
+ if plan_file:
260
+ try:
261
+ with open(plan_file, encoding="utf-8") as fh:
262
+ raw = json.load(fh)
263
+ except (OSError, ValueError, RecursionError) as exc:
264
+ fs.die("--plan-file %s: %s" % (plan_file, exc))
265
+ if not isinstance(raw, list) or not all(isinstance(s, dict) for s in raw):
266
+ fs.die("--plan-file holds a JSON list of {\"id\", \"files\", \"tests\", \"after\"}")
267
+ for s in raw:
268
+ after = s.get("after") or []
269
+ if not isinstance(after, list) or not all(isinstance(a, str) and a.strip() for a in after):
270
+ fs.die("step %s: \"after\" is a JSON list of step ids" % (s.get("id") or "?"))
271
+ raw = [(str(s.get("id") or ""), ",".join(s.get("files") or []), ",".join(s.get("tests") or []),
272
+ [fs.one_line(a, 40) for a in s.get("after") or []]) for s in raw]
273
+ for chunk in [c for c in (text or "").split(";") if c.strip()]:
274
+ parts = chunk.split(":")
275
+ raw.append((parts[0], parts[1] if len(parts) > 1 else "", parts[2] if len(parts) > 2 else "", []))
276
+ steps = []
277
+ for step_id, files, tests, after in raw:
278
+ step_id = fs.one_line(step_id, 40)
279
+ if not step_id or not fs._split_files(files):
280
+ fs.die("a step is id:files[:tests], with at least one file: %r" % ":".join((step_id, files, tests)))
281
+ if step_id in [s["id"] for s in steps]:
282
+ fs.die("step id %s appears twice" % step_id)
283
+ steps.append({"id": step_id, "files": fs._split_files(files), "tests": fs._split_files(tests),
284
+ "after": list(dict.fromkeys(after)), "status": "pending", "tree_before": None,
285
+ "tree_after": None})
286
+ return steps
287
+
288
+
289
+ def _overlap(a, b):
290
+ """One path or pattern covers the other: equal once normalised, or one
291
+ matches the other as a glob — so `src/*.py` and `./src/a.py` count as shared."""
292
+ a, b = os.path.normpath(a), os.path.normpath(b)
293
+ return a == b or bool(weight and (weight.match(a, [b]) or weight.match(b, [a])))
294
+
295
+
296
+ def order_shared_files(steps):
297
+ """Two steps that name one file or pattern — among their files or their
298
+ tests — never run side by side: the later one is put after the earlier (R2).
299
+ Returns the implied edges as (later, earlier, what they share)."""
300
+ implied = []
301
+ for n, later in enumerate(steps):
302
+ for earlier in steps[:n]:
303
+ shared = [f for f in later["files"] + later["tests"]
304
+ if any(_overlap(f, g) for g in earlier["files"] + earlier["tests"])]
305
+ if shared and earlier["id"] not in later["after"]:
306
+ later["after"].append(earlier["id"])
307
+ implied.append((later["id"], earlier["id"], ", ".join(dict.fromkeys(shared))))
308
+ return implied
309
+
310
+
311
+ def check_steps(steps, implied=()):
312
+ """Every `after` names another, existing step, and the steps form no cycle (R2).
313
+ A cycle closed by an implied edge (order_shared_files) names that edge."""
314
+ ids = [s["id"] for s in steps]
315
+ for step in steps:
316
+ for dep in step["after"]:
317
+ if dep == step["id"]:
318
+ fs.die("step %s lists itself in after" % step["id"])
319
+ if dep not in ids:
320
+ fs.die("step %s is after %s, which is not a step (have: %s)" % (step["id"], dep, ", ".join(ids)))
321
+ by_id = {s["id"]: s for s in steps}
322
+ seen = {} # id -> "visiting" | "done"
323
+
324
+ def visit(step_id, trail):
325
+ if seen.get(step_id) == "done":
326
+ return
327
+ if seen.get(step_id) == "visiting":
328
+ loop = trail + [step_id]
329
+ hints = ["%s after %s is implied: they share %s" % edge for edge in implied
330
+ if edge[0] in loop and edge[1] in loop]
331
+ fs.die("the steps' after form a cycle: %s%s" % (
332
+ " -> ".join(loop), " (%s — list the dependent row after the one it needs)" % "; ".join(hints)
333
+ if hints else ""))
334
+ seen[step_id] = "visiting"
335
+ for dep in by_id[step_id]["after"]:
336
+ visit(dep, trail + [step_id])
337
+ seen[step_id] = "done"
338
+
339
+ for step in steps:
340
+ visit(step["id"], [])
341
+
342
+
343
+ def ready_steps(state):
344
+ """The steps not yet done whose `after` are all done, in plan order: the next
345
+ wave of /rr-build-all, and the step step-done starts next."""
346
+ steps = state.get("steps") or []
347
+ done = {s["id"] for s in steps if s.get("status") == "done"}
348
+ return [s for s in steps if s.get("status") != "done" and all(d in done for d in s.get("after") or [])]
349
+
350
+
351
+ def old_surface_refusal(state):
352
+ return ("task %s runs on the old surface (schema %s), whose commands were removed in redesign 2.6: "
353
+ "finish it with the plugin version that started it, or drop it with 'state.py close --abandon'"
354
+ % (state.get("task_id"), state.get("schema") or "<7"))
355
+
356
+
357
+ def abandon_old(_args, root, rt=rr_runtime.Runtime()):
358
+ """close --abandon for a task the old surface started: the record moves to
359
+ .ai/state/logs/<task>/state.json as it is, and nothing else is measured."""
360
+ state = load(root, rt=rt)
361
+ state["result"] = "abandoned"
362
+ state["closed_at"] = fs.now()
363
+ journal.emit(root, state, "task_closed", "abandoned", {"abandoned": True, "surface": "old"}, rt=rt)
364
+ fs.ensure_logs(root, state["task_id"])
365
+ target = os.path.join(fs.logs_dir(root, state["task_id"]), "state.json")
366
+ state["updated_at"] = fs.now()
367
+ tmp = "%s.%d.tmp" % (target, os.getpid())
368
+ with open(tmp, "w", encoding="utf-8") as fh:
369
+ json.dump(state, fh, indent=2, ensure_ascii=False)
370
+ fh.write("\n")
371
+ os.replace(tmp, target)
372
+ with contextlib.suppress(OSError):
373
+ os.remove(fs.handoff_path(root))
374
+ fs.remove_state(root)
375
+ print("%s abandoned (old surface)" % state["task_id"])
376
+ print("state: %s" % os.path.relpath(target, root))
377
+
378
+
379
+ def route_surface(root, args, rt=rr_runtime.Runtime()):
380
+ """A task the old surface started (schema < 7) is read (status, handoff,
381
+ questions without --sync) or dropped (close --abandon); every command that
382
+ works on it refuses."""
383
+ if args.command not in TASK_COMMANDS:
384
+ return
385
+ state = load(root, required=False, claim=False, rt=rt)
386
+ if state is None or is_v7(state) or (args.command == "questions" and not args.sync):
387
+ return
388
+ if args.command == "close" and args.abandon:
389
+ args.func = abandon_old
390
+ return
391
+ fs.die(old_surface_refusal(state))
392
+
393
+
394
+ def in_flight(root, rt=rr_runtime.Runtime()):
395
+ state = load(root, required=False, claim=False, rt=rt)
396
+ return state if state and state.get("current_stage") != "done" and state.get("task_id") else None
@@ -0,0 +1,231 @@
1
+ """verify: the `verify` command -- run one scope's command to the end, record it, re-score; the e2e declarations and the moved-tree detail.
2
+
3
+ shell: moved_detail, run_v7, e2e_na_refusal, e2e_na, e2e_none, cmd_verify
4
+ core: E2E_NONE
5
+ """
6
+ # pylint: disable=protected-access # run_v7 calls fs._split_files, which partition.json assigns to fs
7
+ # pylint: disable=unused-argument # uniform cli dispatch: cmd_verify takes rt like every command
8
+ import os
9
+ import shlex
10
+ import sys
11
+
12
+ from rr_state import dialog, fs, handoff, journal, labels, report, task
13
+ from rr_state import policy as rr_policy
14
+ from rr_state import runtime as rr_runtime
15
+
16
+ # sensors.py sits beside state.py; the entry puts that directory on sys.path.
17
+ try:
18
+ import sensors # measurement only; it never writes this file
19
+ except ImportError: # an old install that shipped state.py alone
20
+ sensors = None
21
+
22
+
23
+ def moved_detail(root, before, after, limit=5):
24
+ """Lines naming what changed between the two snapshots of a run that moved the tree,
25
+ and saying so when the new paths are untracked (a suite that writes a cache or a
26
+ coverage file moves the tree on every run). [] when the paths cannot be measured."""
27
+ diff = fs.safe_sensor(sensors.diff_files, root, before, after)
28
+ if not diff:
29
+ return []
30
+ paths = [d["path"] for d in diff]
31
+ tracked = set()
32
+ code, out = fs.safe_sensor(sensors.git, root, ["ls-files", "-z", "--"] + paths) or (1, "")
33
+ if code == 0:
34
+ tracked = set(f for f in out.split("\0") if f)
35
+ new = [d["path"] for d in diff if d["status"] == "A" and d["path"] not in tracked]
36
+ def esc(path):
37
+ # A name from `git diff -z` is data: a newline or ESC in it would forge a verify line.
38
+ return "".join(c if c.isprintable() else repr(c)[1:-1] for c in path)
39
+ shown = ", ".join("%s (%s)" % (esc(d["path"]), "untracked" if d["path"] in new else
40
+ {"A": "added", "D": "deleted", "T": "type changed"}.get(d["status"], "changed"))
41
+ for d in diff[:limit])
42
+ more = " and %d more" % (len(diff) - limit) if len(diff) > limit else ""
43
+ lines = ["changed during the run: %s%s" % (shown, more)]
44
+ if new:
45
+ lines.append("untracked files written by the suite move the tree on every run: add them to "
46
+ ".gitignore or stop the suite writing them")
47
+ return lines
48
+
49
+
50
+ def run_v7(root, state, scope, args, tcfg, rt=rr_runtime.Runtime()):
51
+ """One scope's command, to the end, logged under .ai/state/logs/; returns the run."""
52
+ command = fs.safe_sensor(sensors.command_for, root, scope) or ""
53
+ if not command:
54
+ fs.die("no command for scope '%s' in .ai/policies/testing.md — write it down first" % scope)
55
+ files = fs._split_files(args.files)
56
+ if files:
57
+ command = fs.safe_sensor(sensors.expand_files, command, files) or command
58
+ runs = state["verify"]["runs"]
59
+ n = len(runs) + 1
60
+ fs.ensure_logs(root, state["task_id"])
61
+ log = os.path.join(fs.logs_dir(root, state["task_id"]), "%s-%d.log" % (scope, n))
62
+ before = fs.current_tree(root)
63
+ result = fs.safe_sensor(sensors.run_command, root, command, log, tcfg.get("timeout_seconds", 1800),
64
+ tcfg.get("log_max_bytes", 2097152))
65
+ if result is None:
66
+ fs.die("sensors.py is not installed beside state.py — run the project's command yourself")
67
+ after = fs.current_tree(root)
68
+ # A tree that moved during the run was not the tree the run tested: the run
69
+ # is kept in the record, but stands for no tree at all.
70
+ run = {"n": n, "scope": scope, "exit": result["exit"], "tree": after if after == before else None,
71
+ "tree_moved": after != before,
72
+ "log": os.path.relpath(log, root), "ts": fs.now(), "expected_fail": args.expect == "fail",
73
+ "runtime": rt.name, "command": command, "duration_s": result["duration_s"],
74
+ "after_fix": args.after_fix or None, "files": files}
75
+ runs.append(run)
76
+ if scope == "suite":
77
+ state["verify"]["suite_runs"] += 1
78
+ state["verify"]["status"] = ("passing" if run["exit"] == 0 else
79
+ "env_failure" if run["exit"] is None else "failing")
80
+ elif scope == "e2e":
81
+ state["verify"]["e2e"] = "passing" if run["exit"] == 0 else "failing"
82
+ state["verify"]["e2e_tree"] = run["tree"]
83
+ if run["tree_moved"]:
84
+ print("tree moved during the run: run %d stands for no tree — run it again on a still tree" % n)
85
+ for line in moved_detail(root, before, after):
86
+ print(line)
87
+ journal.emit(root, state, "verify_run", "%s run %d: exit %s" % (scope, n, run["exit"]),
88
+ {k: run[k] for k in ("n", "scope", "exit", "tree", "log", "expected_fail")}, rt=rt)
89
+ if rr_policy.full_suite(run) and run["exit"] is not None and run["tree"]:
90
+ other = [r for r in runs[:-1] if rr_policy.full_suite(r) and r.get("tree") == run["tree"]
91
+ and r.get("runtime") != rt.name and (r.get("exit") == 0) != (run["exit"] == 0)
92
+ and r.get("exit") is not None]
93
+ red, green = (run, other[-1]) if other and run["exit"] != 0 else \
94
+ (other[-1], run) if other else (None, None)
95
+ if red:
96
+ # R19: the same tree green under one runtime and red under the other, in
97
+ # either order, goes into task.md. A red under Codex is best effort and
98
+ # never a gate; a red under any other runtime is the change's, and the
99
+ # green elsewhere does not answer it (two_facts).
100
+ state["findings"].append("%s: suite red under %s (run %d) and green under %s (run %d) on %s"
101
+ % (fs.now(), red.get("runtime"), red["n"], green.get("runtime"),
102
+ green["n"], run["tree"][:9]))
103
+ if red.get("runtime") == "codex":
104
+ print("finding: red under %s on a tree green under %s — recorded in task.md, close is not "
105
+ "blocked" % (red.get("runtime"), green.get("runtime")))
106
+ else:
107
+ print("red under %s on a tree green under %s — recorded in task.md; the red is the change's "
108
+ "and close is blocked until it is green under %s"
109
+ % (red.get("runtime"), green.get("runtime"), red.get("runtime")))
110
+ print("%s run %d: exit %s in %ds" % (scope, n, run["exit"], run["duration_s"]))
111
+ print("command: %s" % command)
112
+ print("log: %s" % run["log"])
113
+ if result.get("detail"):
114
+ print(result["detail"])
115
+ return run
116
+
117
+
118
+ def e2e_na_refusal(root, state):
119
+ """Why e2e cannot be declared not applicable now, or None. The last e2e run
120
+ decides, on whatever tree it ran: a red flow is fixed and run again, never
121
+ declared away by an edit and a declaration; a run that could not finish
122
+ (exit None) is run again."""
123
+ runs = [r for r in state["verify"]["runs"] if r.get("scope") == "e2e"]
124
+ if not runs or runs[-1].get("exit") == 0:
125
+ return None
126
+ last, tree = runs[-1], fs.current_tree(root)
127
+ if last.get("exit") is None:
128
+ return ("e2e run %d did not finish: run it again with 'verify --scope e2e' — an environment "
129
+ "failure is not a reason to declare e2e not applicable" % last["n"])
130
+ where = "on this tree" if tree and last.get("tree") == tree else \
131
+ "on %s, and no e2e run since has passed" % (last.get("tree") or "a moving tree")[:9]
132
+ return ("e2e run %d is red %s: a failing flow is fixed and 'verify --scope e2e' run again, not "
133
+ "declared not applicable" % (last["n"], where))
134
+
135
+
136
+ def e2e_na(root, state, why, rt=rr_runtime.Runtime()):
137
+ refusal = e2e_na_refusal(root, state)
138
+ if refusal:
139
+ fs.die(refusal)
140
+ tree = fs.current_tree(root)
141
+ state["verify"]["e2e_tree"] = tree
142
+ state["verify"]["e2e"] = "not_applicable"
143
+ state["verify"]["e2e_why"] = why
144
+ journal.emit(root, state, "verify_run", "e2e not applicable: %s" % fs.one_line(why, 200),
145
+ {"scope": "e2e", "exit": None, "not_applicable": True, "why": why}, rt=rt)
146
+ print("e2e: not applicable — %s" % fs.one_line(why, 200))
147
+
148
+
149
+ E2E_NONE = "e2e_command: none in .ai/policies/testing.md"
150
+
151
+
152
+ def e2e_none(root):
153
+ return (fs.safe_sensor(sensors.command_for, root, "e2e") or "").strip() == "none"
154
+
155
+
156
+ def cmd_verify(args, root, rt=rr_runtime.Runtime()):
157
+ """Run, record, re-score up; exit 0 green, 1 red, 6 SUITE_BUDGET (R20)."""
158
+ state = task.load_v7(root, rt=rt)
159
+ policy = rr_policy.weight_policy(root)
160
+ tcfg = policy.get("tests") or {}
161
+ scope = args.scope
162
+ if args.expect and (scope != "step" or not args.files):
163
+ fs.die("--expect fail is for one test shown failing before the fix: --scope step --files <test>")
164
+ if args.e2e and (scope not in ("all", "e2e") or args.e2e[0] != "not-applicable"
165
+ or not args.e2e[1].strip()):
166
+ fs.die('--e2e takes: not-applicable "<why the change cannot reach an e2e flow>", with --scope all or e2e')
167
+ if scope == "all" and args.files:
168
+ fs.die("--files narrows one scope; --scope all runs the whole suite and e2e")
169
+ if args.e2e and e2e_na_refusal(root, state):
170
+ fs.die(e2e_na_refusal(root, state)) # before the suite runs, not after (N3)
171
+ max_runs, budget_name = rr_policy.budget(policy, state, "max_suite_runs")
172
+ if scope in ("suite", "all") and state["verify"]["suite_runs"] >= max_runs:
173
+ if not args.by:
174
+ dialog.offer_decision(root, state, "human_set", "Budget",
175
+ "%s\nSUITE_BUDGET: %d suite runs; weight.json allows %d%s. Run it once more?"
176
+ % (dialog.task_facts(root, state), state["verify"]["suite_runs"], max_runs,
177
+ " (%s)" % budget_name if rr_policy.batch_on(state) else ""),
178
+ [{"kind": "choice", "effect": "keep", "label": "Keep the budget — stop here"},
179
+ {"kind": "command",
180
+ "label": labels.state_label(root, sys.argv[1:] + ["--by", labels.human_name(root)])}], "verify", rt=rt)
181
+ fs.die("SUITE_BUDGET — %d suite runs; weight.json allows %d (%s). Running it "
182
+ "again is not the next move: the human decides. In their own terminal:\n python3 %s "
183
+ "--root %s verify --scope %s --by \"<name>\""
184
+ % (state["verify"]["suite_runs"], max_runs, budget_name, shlex.quote(fs.ENTRY),
185
+ shlex.quote(root), scope), 6)
186
+ via, why = dialog.human_route(root, allow_dialog=True, rt=rt)
187
+ if via is None:
188
+ fs.die("APPROVAL_REFUSED — a run past the suite budget is the human's, from their own terminal."
189
+ "\nRefused because %s." % why, 5)
190
+ journal.emit(root, state, "human_set", "suite run %d past the budget by %s via %s"
191
+ % (state["verify"]["suite_runs"] + 1, args.by, via),
192
+ dict({"field": "suite_runs", "value": state["verify"]["suite_runs"] + 1, "by": args.by,
193
+ "via": via, "unattended": via == "unattended"}, **dialog.pick_data(dialog.DIALOG_PICK[0])),
194
+ actor="human" if via == "terminal" else "agent", rt=rt)
195
+ green_before = rr_policy.suite_green(state)
196
+ state["stage"] = "verify"
197
+ code = 0
198
+ if scope == "e2e" and args.e2e:
199
+ e2e_na(root, state, args.e2e[1], rt=rt)
200
+ elif scope == "e2e" and e2e_none(root):
201
+ e2e_na(root, state, E2E_NONE, rt=rt) # `none` is a declaration, not a command (N1)
202
+ else:
203
+ run = run_v7(root, state, "suite" if scope == "all" else scope, args, tcfg, rt=rt)
204
+ code = run["exit"]
205
+ if args.expect == "fail":
206
+ # exit 1 is a failing test; the runner's own usage error (2) is not, unless
207
+ # testing.md's bite_codes names it (pytest 1,2 — 2 is a collection error)
208
+ run["bites"] = code in (fs.safe_sensor(sensors.bite_codes, root) or (1,))
209
+ print("expected fail: %s" % ("the test fails without the fix — it bites" if run["bites"]
210
+ else "the test passes without the fix — it does not bite yet"))
211
+ code = 0 if run["bites"] else 1
212
+ elif scope == "all" and code == 0:
213
+ if args.e2e:
214
+ e2e_na(root, state, args.e2e[1], rt=rt)
215
+ elif not rr_policy.mode_setting(policy, state["mode"], "e2e", False):
216
+ state["verify"]["e2e"] = "not_required"
217
+ print("e2e: not required in %s mode" % state["mode"])
218
+ elif e2e_none(root):
219
+ e2e_na(root, state, E2E_NONE, rt=rt)
220
+ else:
221
+ code = run_v7(root, state, "e2e", args, tcfg, rt=rt)["exit"]
222
+ elif scope == "all":
223
+ print("e2e: not run — the suite is red")
224
+ rr_policy.rescore_v7(root, state, policy, "verify", rt=rt)
225
+ if not green_before and rr_policy.suite_green(state):
226
+ handoff.write_handoff(root, state, "verify", rt=rt)
227
+ task.save(root, state)
228
+ report.write_task_md(root, state)
229
+ if scope in ("suite", "all"):
230
+ print("suite runs used: %d of %d" % (state["verify"]["suite_runs"], max_runs))
231
+ sys.exit(0 if code == 0 else 1)
@@ -245,15 +245,28 @@ def read_commands(root):
245
245
  return commands
246
246
 
247
247
 
248
+ def _escaped(text):
249
+ """A value from a file, made safe to print: a control character or an ESC
250
+ sequence is shown as its escape, never sent to the terminal."""
251
+ return "".join(c if c.isprintable() else repr(c)[1:-1] for c in text)
252
+
253
+
248
254
  def bite_codes(root):
249
255
  """The exit codes that count as "the test fails" under --expect fail: the
250
- testing.md `bite_codes` list of integers, 1 when absent or not a clean list."""
256
+ testing.md `bite_codes` list of integers 1-255, 1 when absent or not a clean list
257
+ (an empty part, a non-integer, 0, a negative or a value over 255 all fall back)."""
251
258
  raw = read_commands(root).get("bite_codes", "")
252
259
  try:
253
260
  codes = tuple(int(part) for part in raw.split(","))
261
+ usable = all(1 <= code <= 255 for code in codes)
254
262
  except ValueError:
255
- return (1,)
256
- return codes or (1,)
263
+ usable = False
264
+ if usable:
265
+ return codes
266
+ if raw: # present but unusable: say so (stderr keeps stdout's contract)
267
+ print('sensors: bite_codes "%s" is not a list of 1-255 exit codes - using 1' % _escaped(raw),
268
+ file=sys.stderr)
269
+ return (1,)
257
270
 
258
271
 
259
272
  def command_for(root, scope):