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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +12 -11
- package/bin/riskrails.js +109 -18
- package/hooks/ai-path-guard-defaults.json +3 -2
- package/hooks/context-guard.py +14 -11
- package/hooks/dialog-gate.py +1 -1
- package/hooks/guard/dialog.py +107 -27
- package/hooks/guard/git.py +12 -3
- package/hooks/guard/paths.py +109 -7
- package/hooks/project-scaffold.py +2 -1
- package/hooks/runtime-gate.py +128 -27
- package/hooks/update_notice.py +3 -3
- package/package.json +1 -1
- package/skills/ai-init/templates/.ai/state/README.md +4 -4
- package/skills/ai-task/rr_state/__init__.py +1 -0
- package/skills/ai-task/rr_state/cli.py +210 -0
- package/skills/ai-task/rr_state/close.py +171 -0
- package/skills/ai-task/rr_state/dialog.py +541 -0
- package/skills/ai-task/rr_state/dialog_cmd.py +164 -0
- package/skills/ai-task/rr_state/fs.py +220 -0
- package/skills/ai-task/rr_state/gate.py +125 -0
- package/skills/ai-task/rr_state/gate_cmd.py +128 -0
- package/skills/ai-task/rr_state/handoff.py +226 -0
- package/skills/ai-task/rr_state/journal.py +126 -0
- package/skills/ai-task/rr_state/labels.py +99 -0
- package/skills/ai-task/rr_state/ledger.py +145 -0
- package/skills/ai-task/rr_state/locks.py +84 -0
- package/skills/ai-task/rr_state/memory_cmd.py +310 -0
- package/skills/ai-task/rr_state/policy.py +378 -0
- package/skills/ai-task/rr_state/profile.py +373 -0
- package/skills/ai-task/rr_state/questions.py +336 -0
- package/skills/ai-task/rr_state/questions_cmd.py +321 -0
- package/skills/ai-task/rr_state/report.py +271 -0
- package/skills/ai-task/rr_state/report_cmd.py +230 -0
- package/skills/ai-task/rr_state/review.py +165 -0
- package/skills/ai-task/rr_state/runtime.py +80 -0
- package/skills/ai-task/rr_state/start.py +257 -0
- package/skills/ai-task/rr_state/steps.py +131 -0
- package/skills/ai-task/rr_state/task.py +396 -0
- package/skills/ai-task/rr_state/verify.py +231 -0
- package/skills/ai-task/sensors.py +16 -3
- package/skills/ai-task/state.py +24 -5161
- package/skills/project-update/adopt.py +38 -24
- package/skills/project-update/history/blobs/a51517e13e57420d2d88a6bcfb4055d762d4a0d10a8f88450929c5c4572213a9 +67 -0
- package/skills/project-update/history/index.json +2 -1
- package/skills/project-update/migrations/0003_context_diet.py +1 -1
- package/skills/project-update/migrations/0007_weight_model.py +130 -23
- package/skills/project-update/migrations/README.md +16 -1
- package/skills/project-update/update.py +202 -26
- package/skills/rr-setup/SKILL.md +9 -2
- 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)))
|