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,271 @@
1
+ """report: the text the state is rendered to — the two close facts, the status lines, the schema line, task.md and its writer.
2
+
3
+ core: md_cell
4
+ shell: two_facts, status_lines, schema_line, task_md_lines, write_task_md
5
+ """
6
+ import contextlib
7
+ import os
8
+ import re
9
+ import sys
10
+
11
+ from rr_state import fs, journal, locks, questions, task
12
+ from rr_state import policy as rr_policy # status_lines names a local `policy`, as state.py does
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
+
25
+ def two_facts(root, state, tree):
26
+ """R6: (a) a green suite on the tree as it is now; (b) the diff since the base
27
+ tree inside the task's files. Each is (green, why)."""
28
+ runs = [r for r in state["verify"]["runs"] if rr_policy.full_suite(r)]
29
+ here = [r for r in runs if tree and r.get("tree") == tree]
30
+ green = [r for r in here if r.get("exit") == 0]
31
+ documents = None
32
+ if not tree:
33
+ a = (False, "the tree cannot be measured (not a git repository)")
34
+ elif not runs:
35
+ a = (False, "no whole-suite 'verify --scope suite|all' has run")
36
+ elif not here:
37
+ a = (False, "the tree changed after the last verify (run %d on %s, now %s)"
38
+ % (runs[-1]["n"], (runs[-1].get("tree") or "?")[:9], tree[:9]))
39
+ ok, why, documents = rr_policy.documents_cover(root, state, rr_policy.weight_policy(root), tree)
40
+ if ok or why:
41
+ a = (ok, why)
42
+ elif not green:
43
+ a = (False, "the suite is red on this tree (run %d, exit %s)" % (here[-1]["n"], here[-1].get("exit")))
44
+ else:
45
+ # R19: a red under Codex on a tree green under another runtime is waived,
46
+ # in either order (Codex is best effort). Any other red stands until a
47
+ # later green under its own runtime: a green elsewhere does not answer it.
48
+ others = any(g.get("runtime") != "codex" for g in green)
49
+
50
+ def answered(r):
51
+ return (r.get("runtime") == "codex" and others) or any(
52
+ g["n"] > r["n"] and g.get("runtime") == r.get("runtime") for g in green)
53
+ unwaived = [r for r in here if r.get("exit") != 0 and not answered(r)]
54
+ before = [g for g in green if unwaived and g["n"] < unwaived[-1]["n"]]
55
+ if unwaived and before:
56
+ a = (False, "the suite went red on this tree after run %d (run %d under %s)"
57
+ % (before[-1]["n"], unwaived[-1]["n"], unwaived[-1].get("runtime")))
58
+ elif unwaived:
59
+ a = (False, "the suite is red on this tree under %s (run %d); green under %s (run %d) does "
60
+ "not answer it" % (unwaived[-1].get("runtime"), unwaived[-1]["n"],
61
+ green[-1].get("runtime"), green[-1]["n"]))
62
+ else:
63
+ a = (True, "suite green on %s (run %d)" % (tree[:9], green[-1]["n"]))
64
+ diff = fs.safe_sensor(sensors.diff_files, root, state["diff"].get("base_tree"), tree)
65
+ allowed = task.task_files(state)
66
+ outside = [d["path"] for d in diff or [] if not sensors.scope_any(d["path"], allowed)]
67
+ if diff is None:
68
+ b = (False, "the diff since the base tree cannot be measured")
69
+ elif outside:
70
+ b = (False, "outside the task's files: %s" % ", ".join(outside[:5])
71
+ + (" (+%d more)" % (len(outside) - 5) if len(outside) > 5 else ""))
72
+ else:
73
+ b = (True, "%d changed files, all in the task's files" % len(diff))
74
+ return {"a": a, "b": b, "diff": diff or [], "outside": outside, "documents": documents}
75
+
76
+ def status_lines(root, state):
77
+ """≤ 20 lines; read-only (the facts are measured, nothing is written)."""
78
+ lines = ["root: %s" % root]
79
+ if not state or not state.get("task_id"):
80
+ return lines + ["task: none — no task in flight", schema_line(root)]
81
+ events, _ = journal.read_journal(root, state["task_id"])
82
+ last = events[-1] if events else {}
83
+ pending = (state.get("questions") or {}).get("pending") or []
84
+ if task.is_v7(state):
85
+ # The cache lags a hand-filled [Answer]:; the file is the truth, as guard_pending reads it.
86
+ with contextlib.suppress(OSError, ValueError, KeyError):
87
+ _, asked = questions.parse_questions(fs.questions_path(root, state, None))
88
+ gate_id = task.open_gate(state)
89
+ pending = [q["id"] for q in asked if (q["kind"] != "G" and questions.is_pending(q)) or q["id"] == gate_id]
90
+ lines.append("task: %s — %s" % (state["task_id"], fs.one_line(state.get("goal"), 140)))
91
+ if not task.is_v7(state):
92
+ lines += ["tier: %s · workflow %s · stage %s" % (state.get("risk_tier") or "?", state.get("workflow"),
93
+ state.get("current_stage")),
94
+ "next: %s" % (fs.one_line(state.get("next_action"), 160) or "?"),
95
+ "old surface (schema < 7): /status reads it directly"]
96
+ else:
97
+ w = state.get("weight") or {}
98
+ policy = rr_policy.weight_policy(root)
99
+ tree = fs.current_tree(root)
100
+ facts = two_facts(root, state, tree)
101
+ steps = state.get("steps") or []
102
+ cur = next((s for s in steps if s.get("status") == "in_progress"), None)
103
+ review, ver, gate = state["review"], state["verify"], state.get("gate") or {}
104
+ override = state.get("mode_override") or {}
105
+ lines += [
106
+ "mode: %s · W %s · workflow %s%s" % (state["mode"], w.get("w"), state.get("workflow"),
107
+ " · override %s → %s by %s" % (override.get("from"),
108
+ override.get("to"),
109
+ override.get("by"))
110
+ if override else ""),
111
+ "axes: %s" % " ".join("%s %s" % (a, (w.get("axes") or {}).get(a, "?")) for a in weight.AXES),
112
+ "stage: %s · next: %s" % (state.get("stage"), fs.one_line(state.get("next_action"), 140)),
113
+ ("steps: %d of %d done — current %s" % (len([s for s in steps if s["status"] == "done"]), len(steps),
114
+ cur["id"] if cur else "none")
115
+ if steps else "steps: one scope"),
116
+ "files: %d in scope — %s" % (len(task.task_files(state)), ", ".join(task.task_files(state)[:4])
117
+ + (" (+%d more)" % (len(task.task_files(state)) - 4)
118
+ if len(task.task_files(state)) > 4 else "")),
119
+ "verify: suite %s · runs %d/%s · e2e %s" % (ver["status"], ver["suite_runs"],
120
+ rr_policy.budget(policy, state, "max_suite_runs")[0],
121
+ ver.get("e2e")),
122
+ "facts: (a) %s — %s" % ("green" if facts["a"][0] else "RED", facts["a"][1]),
123
+ " (b) %s — %s" % ("green" if facts["b"][0] else "RED", facts["b"][1]),
124
+ "review: %s (%d of %s) · tier %s%s" % (review["status"], review["passes"],
125
+ rr_policy.budget(policy, state, "review_rounds_max")[0],
126
+ review.get("tier") or "-",
127
+ " · open %s" % ", ".join(review["findings"])
128
+ if review["findings"] else ""),
129
+ "gate: %s" % ("approved by %s%s" % (gate["approved_by"], " · confirm \"%s\"" % gate["confirm"]
130
+ if gate.get("confirm") else "")
131
+ if gate.get("approved_by") else "requested %s, waiting for the human" % gate.get("gate_id")
132
+ if task.open_gate(state) else "not requested"
133
+ if state["mode"] in ("full", "full_irreversible") else "none in %s mode" % state["mode"]),
134
+ "findings: %d recorded in task.md" % len(state.get("findings") or []),
135
+ ]
136
+ if rr_policy.batch_on(state):
137
+ # Before the truncation point, so the 20-line cap never cuts it.
138
+ lines.insert(3, "batch: on by %s · review %d/%d · suite %d/%d · ready %s" % (
139
+ fs.one_line(state["batch"].get("by"), 40), review["passes"],
140
+ rr_policy.budget(policy, state, "review_rounds_max")[0], ver["suite_runs"],
141
+ rr_policy.budget(policy, state, "max_suite_runs")[0],
142
+ ", ".join(s["id"] for s in task.ready_steps(state)) or "none"))
143
+ lines += ["questions: %d pending%s" % (len(pending), " — %s" % ", ".join(map(str, pending[:4])) if pending else ""),
144
+ schema_line(root),
145
+ "last event: %s" % (" ".join(fs.one_line(last.get(k), 80) for k in ("ts", "event", "detail")
146
+ if last.get(k)) or "none")]
147
+ return lines[:20]
148
+
149
+ def schema_line(root):
150
+ try:
151
+ with open(os.path.join(root, ".ai", "VERSION"), encoding="utf-8") as fh:
152
+ project = fh.read().strip() or "0"
153
+ except OSError:
154
+ project = "0"
155
+ here = os.path.dirname(os.path.dirname(fs.ENTRY))
156
+ try:
157
+ names = os.listdir(os.path.join(here, "project-update", "migrations"))
158
+ except OSError:
159
+ names = []
160
+ numbers = [int(n[:4]) for n in names if re.match(r"\d{4}_", n)]
161
+ plugin = str(max(numbers)) if numbers else "?"
162
+ behind = project.isdigit() and plugin.isdigit() and int(project) < int(plugin)
163
+ return "schema: project %s · plugin %s%s" % (project, plugin, " — behind: run /rr-setup --update" if behind else "")
164
+
165
+ def md_cell(text):
166
+ return fs.one_line(text, 200).replace("|", "\\|")
167
+
168
+ def task_md_lines(root, state):
169
+ """A pure function of the state and the journal: eight sections (§Interfaces).
170
+ §Questions is "none" here; write_task_md keeps the section already on disk."""
171
+ events, _ = journal.read_journal(root, state["task_id"])
172
+ w = state.get("weight") or {}
173
+ override = state.get("mode_override")
174
+ out = ["# %s — %s" % (state["task_id"], fs.one_line(state.get("goal"))), "",
175
+ "%s · %s · started %s · %s" % (state.get("workflow"), state.get("mode"), state.get("created"),
176
+ state.get("owner_runtime") or "unknown"), "",
177
+ "## Weight", "", "```"] + list(w.get("lines") or []) + ["```"]
178
+ if override:
179
+ out.append("override: %s → %s by %s at %s%s" % (override.get("from"), override.get("to"),
180
+ override.get("by"), override.get("ts"),
181
+ " — %s" % fs.one_line(override["why"])
182
+ if override.get("why") else ""))
183
+ out += ["re-score %s" % fs.one_line(line, 300) for line in w.get("rescores") or []]
184
+ if rr_policy.batch_on(state):
185
+ batch = state["batch"]
186
+ out.append("batch: on by %s at %s%s" % (fs.one_line(batch.get("by"), 80), batch.get("since"),
187
+ " — %s" % fs.one_line(batch["why"]) if batch.get("why") else ""))
188
+ out += ["", "## Plan", "", "files: %s" % (", ".join(state.get("files") or []) or "none"),
189
+ "tests: %s" % (", ".join(state.get("tests") or []) or "none")]
190
+ for step in state.get("steps") or []:
191
+ out.append("- %s · %s · %s · %s%s" % (step["id"], ", ".join(step["files"]),
192
+ ", ".join(step["tests"]) or "no tests", step["status"],
193
+ " · after %s" % ", ".join(step["after"]) if step.get("after") else ""))
194
+ out += ["amended %s: +%s — %s" % (a["ts"], ", ".join(a["added"]), fs.one_line(a["why"]))
195
+ for a in state.get("amendments") or []]
196
+ out += ["", questions.QUESTIONS_SECTION, "", "none"]
197
+ out += ["", "## Verify", "", "| n | scope | exit | tree | log | expected_fail |", "|---|---|---|---|---|---|"]
198
+ for r in state["verify"]["runs"]:
199
+ out.append("| %s | %s | %s | %s | %s | %s |" % (r["n"], r["scope"], r.get("exit"),
200
+ (r.get("tree") or "-")[:9], md_cell(r.get("log")),
201
+ "yes" if r.get("expected_fail") else "no"))
202
+ out.append("")
203
+ out.append("suite %s · e2e %s%s · suite runs %d" % (
204
+ state["verify"]["status"], state["verify"].get("e2e"),
205
+ " (%s)" % fs.one_line(state["verify"]["e2e_why"]) if state["verify"].get("e2e_why") else "",
206
+ state["verify"]["suite_runs"]))
207
+ out += ["finding: %s" % fs.one_line(f, 300) for f in state.get("findings") or []]
208
+ out += ["", "## Review", ""]
209
+ for p in state["review"]["log"]:
210
+ out += ["### Pass %d — %s, %s%s%s" % (p["n"], p["tier"], p["verdict"],
211
+ " (focus %s)" % p["focus"] if p.get("focus") else "",
212
+ " by %s" % p["by"] if p.get("by") else ""), ""]
213
+ # A fence longer than any backtick run in the text, so a report quoting
214
+ # code cannot close it early.
215
+ fence = "`" * max([3] + [len(run) + 1 for run in re.findall(r"`+", p.get("text") or "")])
216
+ out += ([fence] + p["text"].rstrip("\n").split("\n") + [fence]) if p.get("text", "").strip() \
217
+ else ["no FINDINGS block"]
218
+ out.append("")
219
+ if not state["review"]["log"]:
220
+ out += ["none yet", ""]
221
+ if state["review"].get("closed"):
222
+ out += ["closed by a green run: %s" % ", ".join(state["review"]["closed"]), ""]
223
+ out += ["## Changes", ""]
224
+ changes = state.get("changes")
225
+ if changes is None:
226
+ out.append("measured at close")
227
+ else:
228
+ kind = {"A": "New", "D": "Delete"}
229
+ out += ["| File | Change | New/Edit/Delete |", "|---|---|---|"]
230
+ out += ["| %s | %s | %s |" % (md_cell(c["path"]), "binary" if c.get("binary") else
231
+ "+%d −%d" % (c["added"], c["deleted"]), kind.get(c["status"], "Edit"))
232
+ for c in changes]
233
+ out += ["", "+%d −%d lines, %d files" % (sum(c["added"] for c in changes),
234
+ sum(c["deleted"] for c in changes), len(changes))]
235
+ gate, facts = state.get("gate") or {}, state.get("facts") or {}
236
+ handoffs = len([e for e in events if e.get("event") == "handoff_written"])
237
+ out += ["", "## Verdict", "",
238
+ "close: %s" % (state.get("result") or "open — %s" % fs.one_line(state.get("next_action"))),
239
+ "facts: (a) %s · (b) %s" % tuple(
240
+ "%s — %s" % ("green" if facts[k][0] else "RED", facts[k][1]) if facts.get(k) else "not measured"
241
+ for k in ("a", "b")),
242
+ "gate: %s" % ("requested %s at %s; approved by %s at %s via %s" % (
243
+ gate.get("gate_id"), gate.get("requested_at"), gate.get("approved_by") or "-",
244
+ gate.get("approved_at") or "-", gate.get("via") or "-") if gate else "none"),
245
+ "confirm: %s" % (gate.get("confirm") or "none"),
246
+ "handoffs: %d · suite runs: %d · review passes: %d" % (handoffs, state["verify"]["suite_runs"],
247
+ state["review"]["passes"])]
248
+ return out
249
+
250
+ def write_task_md(root, state):
251
+ """Rewritten whole and atomically after every change; best effort, because
252
+ the state is the contract and task.md is rendered from it — except
253
+ §Questions, which is read back from disk under the questions lock, so an
254
+ [Answer]: line a human filled survives until `questions --sync` reads it."""
255
+ path = fs.task_md_path(root, state)
256
+ tmp = "%s.%d.tmp" % (path, os.getpid())
257
+ try:
258
+ os.makedirs(os.path.dirname(path), exist_ok=True)
259
+ with locks.question_lock(path):
260
+ before, _, after = questions.split_task_md(task_md_lines(root, state))
261
+ section = questions.question_lines(path)
262
+ if not any(questions.HEADING_RE.match(line) for line in section):
263
+ section = ["", "none"]
264
+ text = "\n".join(before + section + ([""] if section[-1] else []) + after) + "\n"
265
+ with open(tmp, "w", encoding="utf-8") as fh:
266
+ fh.write(text)
267
+ os.replace(tmp, path)
268
+ except Exception as exc: # pylint: disable=broad-exception-caught # derived artefact: never fatal
269
+ with contextlib.suppress(OSError):
270
+ os.unlink(tmp)
271
+ print("state.py: task.md not written (%s)" % exc, file=sys.stderr)
@@ -0,0 +1,230 @@
1
+ """report_cmd: the commands that report -- the plan profile (read, detect, ack, set), the journal event line, the handoff and the status.
2
+
3
+ shell: cmd_profile, cmd_profile_detect, cmd_profile_ack, cmd_profile_set, cmd_event, cmd_handoff, cmd_status
4
+ """
5
+ # pylint: disable=unused-argument # uniform cli dispatch: every cmd_* takes rt
6
+ import contextlib
7
+ import json
8
+ import os
9
+ import sys
10
+
11
+ from rr_state import dialog, fs, handoff, journal, profile, report, task
12
+ from rr_state import runtime as rr_runtime
13
+
14
+
15
+ def cmd_profile(args, _root, rt=rr_runtime.Runtime()):
16
+ if args.action == "detect":
17
+ cmd_profile_detect(args, fs.find_root_quiet(args.root), rt=rt)
18
+ return
19
+ if args.action == "set":
20
+ cmd_profile_set(args, rt=rt)
21
+ return
22
+ if args.action == "ack":
23
+ cmd_profile_ack(args, rt=rt)
24
+ return
25
+ if args.plan:
26
+ fs.die("unrecognized argument %r: profile takes detect, set or ack" % args.plan, 2)
27
+ if args.open or args.detect_runtime:
28
+ fs.die("--open and --runtime after profile belong to profile detect")
29
+ runtime = rr_runtime.other_runtime(rt=rt) if args.other else rr_runtime.own_runtime(runtime=rt.name)
30
+ prof = profile.read_profile(runtime, rt=rt)
31
+ if not prof:
32
+ fs.die("no plan profile for %s (%s/riskrails/profile.json); npx riskrails writes it"
33
+ % (runtime, rr_runtime.runtime_home(runtime)))
34
+ if args.tier:
35
+ model = ((prof.get("tiers") or {}).get(args.tier) or {}).get("model")
36
+ if not model:
37
+ fs.die("the %s profile has no %s tier" % (runtime, args.tier))
38
+ print(model)
39
+ return
40
+ value = prof
41
+ if args.field:
42
+ for part in args.field.split("."):
43
+ if not isinstance(value, dict) or part not in value:
44
+ fs.die("the %s profile has no %s" % (runtime, args.field))
45
+ value = value[part]
46
+ if args.json or isinstance(value, (dict, list)):
47
+ print(json.dumps(value, indent=None if args.field else 2, ensure_ascii=False))
48
+ else:
49
+ print(value)
50
+
51
+
52
+ def cmd_profile_detect(args, root, rt=rr_runtime.Runtime()):
53
+ """Read-only, exit 0 always (R36.16); `--open` also opens the plan_drift decision (R36.18)."""
54
+ if args.field or args.tier or args.other or args.plan:
55
+ fs.die("--field, --tier, --other and a plan belong to the profile reader or profile set, not to profile detect")
56
+ found = profile.profile_detect(args.detect_runtime or rr_runtime.own_runtime(runtime=rt.name), rt=rt)
57
+ if args.open:
58
+ try:
59
+ found["decision"] = profile.open_drift(root, found, rt=rt)
60
+ except SystemExit: # decision.json unreadable: stay read-only, exit 0 (R36.16)
61
+ found["decision"] = None
62
+ if args.json:
63
+ print(json.dumps(found, ensure_ascii=False))
64
+ return
65
+ have = (found["installed"] or {}).get("plan") or "none"
66
+ live = found["live"]
67
+ print("plan: installed %s · account %s%s → %s" % (
68
+ have, live["plan"] or "unknown", " (%s)" % live["from"] if live["from"] else "", found["status"]))
69
+ if found["command"]:
70
+ print("fix: %s" % found["command"])
71
+ if found["ack"]:
72
+ print("keep: %s" % found["ack"])
73
+ if found["decision"]:
74
+ print("decision: %s" % found["decision"])
75
+
76
+
77
+ def cmd_profile_ack(args, rt=rr_runtime.Runtime()):
78
+ """32ee: the terminal route to "Keep" — with the dialog off no plan_drift decision
79
+ opens, so this is the only way to silence a drift the human chose to stay on. The
80
+ same drift-ack.json the decision's Keep writes; any plan_drift decision still open
81
+ is settled as stale by the next `detect --open`."""
82
+ if args.open or args.field or args.tier or args.other or args.json or args.plan:
83
+ fs.die("profile ack takes --runtime only")
84
+ runtime = args.detect_runtime or rr_runtime.own_runtime(runtime=rt.name)
85
+ via, why = dialog.human_route(None, rt=rt)
86
+ if not via:
87
+ fs.die("profile ack is the human's: %s" % why, 5)
88
+ found = profile.profile_detect(runtime, rt=rt)
89
+ status = found["status"]
90
+ if status == "same":
91
+ print("no drift: nothing to acknowledge")
92
+ return
93
+ if status == "acknowledged":
94
+ print("already acknowledged; nothing written")
95
+ return
96
+ if status != "drift":
97
+ fs.die("nothing to acknowledge: plan %s" % status)
98
+ kept = found["installed"]["plan"]
99
+ profile.write_drift_ack(runtime, found["live"]["fingerprint"], kept, via)
100
+ print("drift-ack.json: keep %s for this %s account (%s); delete %s to be asked again"
101
+ % (kept, runtime, found["live"]["plan"], profile.drift_ack_path(runtime)))
102
+
103
+
104
+ def cmd_profile_set(args, rt=rr_runtime.Runtime()):
105
+ """R36.17: the human route only. A same-tier plan rewrites profile.json alone;
106
+ one whose settings or tiers differ is the installer's (exit 6)."""
107
+ if args.open or args.field or args.tier or args.other or args.json:
108
+ fs.die("profile set takes a plan and --runtime only")
109
+ runtime = args.detect_runtime or rr_runtime.own_runtime(runtime=rt.name)
110
+ plans = profile.PLAN_PROFILES.get(runtime) or {}
111
+ if args.plan not in plans:
112
+ fs.die("profile set needs a %s plan: %s (got %r)" % (runtime, ", ".join(plans), args.plan or ""))
113
+ via, why = dialog.human_route(fs.find_root_quiet(args.root), allow_dialog=True, rt=rt)
114
+ if not via:
115
+ fs.die("profile set is the human's: %s" % why, 5)
116
+ prof = profile.read_profile(runtime, rt=rt)
117
+ if not prof:
118
+ fs.die("no plan profile for %s (%s/riskrails/profile.json); npx riskrails writes it"
119
+ % (runtime, rr_runtime.runtime_home(runtime)))
120
+ have = prof.get("plan")
121
+ if have == args.plan:
122
+ print("profile.json: already %s (%s); nothing written" % (have, runtime))
123
+ return
124
+ refusal = "PROFILE_NEEDS_INSTALLER — %s → %s also changes %s: %s" % (
125
+ have, args.plan, "settings.json and the agents" if runtime == "claude" else "config.toml and the agents",
126
+ profile.installer_command(runtime, args.plan, prof.get("plugin_version"), profile.context_mode(prof)))
127
+ if have not in plans or not any({have, args.plan} <= pair for pair in profile.SAME_TIER[runtime]):
128
+ fs.die(refusal, 6)
129
+ resolver, src = profile.load_resolver(runtime)
130
+ if resolver is None:
131
+ fs.die("no resolve-profile.py under %s/riskrails/scripts or the plugin; npx riskrails installs it"
132
+ % rr_runtime.runtime_home(runtime))
133
+ fable = bool(prof.get("fable"))
134
+ # Only an economy record passes context: a resolver installed before --context
135
+ # has no such parameter, and only a later one writes an economy record.
136
+ extra = {"context": "economy"} if profile.context_mode(prof) else {}
137
+ try:
138
+ old = resolver.resolve(src, plans[have][0], have, plans[have][1], fable, **extra)
139
+ new = resolver.resolve(src, plans[args.plan][0], args.plan, plans[args.plan][1], fable, **extra)
140
+ except (OSError, ValueError) as exc:
141
+ fs.die("cannot resolve the %s profile: %s" % (args.plan, exc))
142
+ key = resolver.KEY
143
+
144
+ def rendered(data):
145
+ return ({k: v for k, v in data.items() if k != key}, data[key].get("tiers"))
146
+ if rendered(old) != rendered(new):
147
+ fs.die(refusal, 6)
148
+ out = dict(new[key], runtime=runtime, written_at=fs.now(), set_by={"via": via, "at": fs.now()})
149
+ for kept in ("plugin_version", "dialog"):
150
+ if kept in prof:
151
+ out[kept] = prof[kept]
152
+ target = os.path.join(rr_runtime.runtime_home(runtime), "riskrails", "profile.json")
153
+ tmp = "%s.%d.tmp" % (target, os.getpid())
154
+ try:
155
+ os.makedirs(os.path.dirname(target), exist_ok=True)
156
+ with open(tmp, "w", encoding="utf-8") as fh:
157
+ json.dump(out, fh, indent=2, ensure_ascii=False)
158
+ fh.write("\n")
159
+ os.replace(tmp, target)
160
+ except OSError as exc:
161
+ with contextlib.suppress(OSError):
162
+ os.unlink(tmp)
163
+ fs.die("cannot write %s: %s" % (target, exc))
164
+ print("profile.json: %s → %s (%s); settings and agents unchanged; the managed block and routing.md "
165
+ "keep the label '%s' until the next npx riskrails" % (have, args.plan, runtime, plans[have][1]))
166
+
167
+
168
+ def cmd_event(args, root, rt=rr_runtime.Runtime()):
169
+ """For hooks and for WP5's model fallback: one journal line, no state write.
170
+ The state file keeps its two writers; this command is not one of them."""
171
+ if args.type not in journal.EVENT_TYPES:
172
+ fs.die("unknown event type '%s' (have: %s)" % (args.type, ", ".join(journal.EVENT_TYPES)))
173
+ if args.type in journal.FORGEABLE_EVENTS:
174
+ # The journal is the compensating control for every route the gate
175
+ # cannot block, so the actor it audits may not author its lines. These
176
+ # types are emitted by the state transition that earns them, and by
177
+ # nothing else: an agent that took the unattended route could otherwise
178
+ # append a second, honest-looking gate_approved next to it.
179
+ fs.die("'%s' is emitted by the state transition that earns it, not by 'event'. "
180
+ "A journal the agent can write is not an audit trail." % args.type, 5)
181
+ data = {}
182
+ if args.data:
183
+ try:
184
+ data = json.loads(args.data)
185
+ except (ValueError, RecursionError) as exc:
186
+ fs.die("--data must be a JSON object (%s)" % exc)
187
+ if not isinstance(data, dict):
188
+ fs.die("--data must be a JSON object")
189
+ state = task.load(root, rt=rt)
190
+ journal.append_journal(root, state["task_id"], journal.journal_line(
191
+ state["task_id"], args.type, "agent" if args.type == "note" else "hook",
192
+ state.get("current_stage"), args.detail, data, runtime=rt.name))
193
+ print("%s recorded" % args.type)
194
+
195
+
196
+ def cmd_handoff(args, root, rt=rr_runtime.Runtime()):
197
+ if args.to:
198
+ handoff.handoff_to(args, root, rt=rt)
199
+ return
200
+ state = task.load(root, rt=rt)
201
+ lines = handoff.write_handoff(root, state, args.reason, rt=rt)
202
+ if lines is None:
203
+ print("state.py: could not write %s" % os.path.relpath(fs.handoff_path(root), root),
204
+ file=sys.stderr)
205
+ # --print still answers from the render the caller asked for.
206
+ lines = handoff.handoff_lines(root, state, args.reason, rt=rt)
207
+ if args.print_it:
208
+ for line in lines:
209
+ print(line)
210
+ else:
211
+ print(os.path.relpath(fs.handoff_path(root), root))
212
+
213
+
214
+ def cmd_status(args, root, rt=rr_runtime.Runtime()):
215
+ state = task.load(root, required=False, claim=False, rt=rt)
216
+ if args.json:
217
+ out = {"root": root, "task_id": (state or {}).get("task_id"), "schema": (state or {}).get("schema")}
218
+ if task.is_v7(state):
219
+ tree = fs.current_tree(root)
220
+ facts = report.two_facts(root, state, tree)
221
+ out.update({k: state.get(k) for k in ("goal", "workflow", "mode", "stage", "next_action",
222
+ "files", "tests", "steps", "verify", "review", "gate",
223
+ "mode_override", "findings")})
224
+ out["weight"] = {k: (state.get("weight") or {}).get(k) for k in ("w", "axes", "hints", "unknown",
225
+ "operations")}
226
+ out["facts"] = {"a": list(facts["a"]), "b": list(facts["b"]), "tree": tree}
227
+ out["questions_pending"] = len((state.get("questions") or {}).get("pending") or [])
228
+ print(json.dumps(out, indent=2, ensure_ascii=False))
229
+ return
230
+ print("\n".join(report.status_lines(root, state)))