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.
- 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,541 @@
|
|
|
1
|
+
"""dialog: the human decision dialog and the authority that tells a human from the agent -- decisions, consent labels, picks, the approval offer.
|
|
2
|
+
|
|
3
|
+
shell: human_route, human_turn, refuse_approval, DIALOG_PICK, dialog_policy, dialog_tool_verified, dialog_on, open_decision, print_dialog, offer_decision, settle_decision, settle_written, prune_decisions, mirror_ledger, sync_dialog, dialog_pick, valid_decisions, retire_approvals, task_facts, audit_pick, approve_command, offer_approval
|
|
4
|
+
core: DIALOG_TOOL, SESSION_ENV, DECISIONS_MAX, QUESTION_MAX, HEADER_MAX, JSON_DEPTH_MAX, json_loads_capped, dialog_questions, option_description, part_question, dialog_payload, MIRROR, pick_data
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shlex
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from rr_state import fs, journal, labels, locks
|
|
12
|
+
from rr_state import ledger as rr_ledger
|
|
13
|
+
from rr_state import policy as rr_policy
|
|
14
|
+
from rr_state import runtime as rr_runtime
|
|
15
|
+
from rr_state import task
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def human_route(root=None, allow_dialog=False, rt=rr_runtime.Runtime()):
|
|
19
|
+
"""(via, why): "terminal" for a terminal outside any agent session,
|
|
20
|
+
"unattended" for a launcher that declared the run unattended, else None and
|
|
21
|
+
what was seen. A terminal alone is not enough — an agent can open a pty — so
|
|
22
|
+
a terminal that carries an agent marker is the agent's. The same condition
|
|
23
|
+
as adopt.human_gate().
|
|
24
|
+
|
|
25
|
+
With allow_dialog, and only with the dialog on, a third route comes before
|
|
26
|
+
the agent-marker refusal: "dialog", when a live pick of this session is this
|
|
27
|
+
very command (dialog_pick, R36.10). The pick is spent here; DIALOG_PICK
|
|
28
|
+
names it for the caller's journal line."""
|
|
29
|
+
markers = rr_runtime.agent_markers()
|
|
30
|
+
if os.isatty(0) and not markers:
|
|
31
|
+
return "terminal", ""
|
|
32
|
+
if fs.ai_unattended():
|
|
33
|
+
return "unattended", ""
|
|
34
|
+
if allow_dialog and root and dialog_pick(root, rt=rt):
|
|
35
|
+
return "dialog", ""
|
|
36
|
+
if markers:
|
|
37
|
+
return None, ("this run is inside an agent session (%s %s set); run it in a terminal "
|
|
38
|
+
"outside Claude Code or Codex, not through the `!` prefix"
|
|
39
|
+
% (", ".join(markers), "is" if len(markers) == 1 else "are"))
|
|
40
|
+
return None, "this run has no terminal"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def human_turn(root, state):
|
|
44
|
+
"""Did a human take a turn in this project since the gate was requested?
|
|
45
|
+
|
|
46
|
+
The evidence is .ai/state/session.json, which only context-guard.py writes,
|
|
47
|
+
on UserPromptSubmit. It is what makes the file route different from the
|
|
48
|
+
agent filling in the answer itself: the agent cannot advance last_prompt_at
|
|
49
|
+
without the human sending a prompt. Weaker than a TTY, and documented as
|
|
50
|
+
such — but it is a turn the human took, at a moment they could see the plan.
|
|
51
|
+
"""
|
|
52
|
+
session = fs.read_session(root)
|
|
53
|
+
relative = os.path.relpath(fs.session_path(root), root)
|
|
54
|
+
turn = session.get("last_prompt_at")
|
|
55
|
+
requested = (state.get("human_approval") or {}).get("requested_at")
|
|
56
|
+
if not isinstance(turn, str) or not turn:
|
|
57
|
+
return False, "there is no %s, so no human turn can be seen" % relative
|
|
58
|
+
if not requested or turn < requested:
|
|
59
|
+
return False, ("the last prompt in %s (%s) is older than the approval request (%s)"
|
|
60
|
+
% (relative, turn, requested))
|
|
61
|
+
opened_in = (state.get("human_approval") or {}).get("requested_session")
|
|
62
|
+
if not opened_in:
|
|
63
|
+
# The gate was opened without a session on record — the hook had never
|
|
64
|
+
# run in this project (Codex before /hooks, or a task older than the
|
|
65
|
+
# install). There is then nothing to bind the turn to, and any later
|
|
66
|
+
# session's prompt would do. The terminal route is unaffected; this
|
|
67
|
+
# route fails closed, because it is the weaker of the two.
|
|
68
|
+
return False, ("no session was recorded when the gate was requested, so a prompt in %s "
|
|
69
|
+
"cannot be tied to it — approve from your own terminal instead" % relative)
|
|
70
|
+
if session.get("last_prompt_session") != opened_in:
|
|
71
|
+
# One file per project, last writer wins, so session.json's own two
|
|
72
|
+
# fields always agree after any real second session. The turn has to
|
|
73
|
+
# come from the session the plan was presented in.
|
|
74
|
+
return False, ("the last prompt in %s came from another session (%s, not %s, "
|
|
75
|
+
"which is where the gate was requested)"
|
|
76
|
+
% (relative, session.get("last_prompt_session"), opened_in))
|
|
77
|
+
return True, ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def refuse_approval(root, state, gate_id, by, why):
|
|
81
|
+
# Quoted: this project's own path has a space in it, and the command the
|
|
82
|
+
# refusal prints is the only route a human without the flag has.
|
|
83
|
+
fs.die("APPROVAL_REFUSED — approval happens outside the agent. Run in your own terminal:\n"
|
|
84
|
+
" python3 %s --root %s approve --by %s\n"
|
|
85
|
+
"or set [Answer]: A on %s in %s and tell the session to sync. An unattended run exports "
|
|
86
|
+
"AI_UNATTENDED=1 in the launcher's environment; the journal then records the approval as "
|
|
87
|
+
"unattended.\nRefused because %s."
|
|
88
|
+
% (shlex.quote(fs.ENTRY), shlex.quote(root), shlex.quote(by), gate_id,
|
|
89
|
+
os.path.relpath(fs.questions_path(root, state, None), root), why), 5)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------- the dialog (R36, step 7c)
|
|
93
|
+
#
|
|
94
|
+
# A guarded command or a decision, offered in the runtime's own dialog. state.py
|
|
95
|
+
# is the only writer of decision.json and prepares every question; the recorder
|
|
96
|
+
# hook (7d1) appends the human's picks to consent.jsonl from the harness's
|
|
97
|
+
# tool_response, and state.py appends `use` and `settle`. With the switch off —
|
|
98
|
+
# no .ai/policies/dialog.json, every project until 7f ships the template — none
|
|
99
|
+
# of this runs and every command prints and refuses as before (R36.2).
|
|
100
|
+
|
|
101
|
+
DIALOG_TOOL = {"claude": "AskUserQuestion", "codex": "request_user_input"}
|
|
102
|
+
# The runtime's session id as the Bash tool's environment carries it. Both
|
|
103
|
+
# mappings are verified by the 2026-10-31 spike (docs/dialog-spike.md, "Session
|
|
104
|
+
# ids and the Stop block"): the variable equals the hook payload's session_id.
|
|
105
|
+
# It must equal hooks/guard/dialog.py SESSION_ENV; tests/test_dialog_contract.py
|
|
106
|
+
# checks that and feeds both sides the same switch fixtures.
|
|
107
|
+
SESSION_ENV = {"claude": "CLAUDE_CODE_SESSION_ID", "codex": "CODEX_THREAD_ID"}
|
|
108
|
+
DECISIONS_MAX = 4 # questions one dialog call carries (R36.3)
|
|
109
|
+
QUESTION_MAX, HEADER_MAX = 1200, 12
|
|
110
|
+
|
|
111
|
+
# The pick human_route() consumed in this process, held in a one-slot list changed in place:
|
|
112
|
+
# `global` is refused under rr_state/ (R4).
|
|
113
|
+
DIALOG_PICK = [None]
|
|
114
|
+
|
|
115
|
+
JSON_DEPTH_MAX = 64 # one nesting cap for both switches, not the stack's
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def json_loads_capped(text):
|
|
119
|
+
"""json.loads(text), except that a text nested deeper than JSON_DEPTH_MAX is
|
|
120
|
+
malformed (ValueError). The parser's own limit moves with the stack depth, so
|
|
121
|
+
the hook and state.py could disagree; this scan does not recurse."""
|
|
122
|
+
depth, in_str, esc = 0, False, False
|
|
123
|
+
for ch in text:
|
|
124
|
+
if in_str:
|
|
125
|
+
if esc:
|
|
126
|
+
esc = False
|
|
127
|
+
elif ch == "\\":
|
|
128
|
+
esc = True
|
|
129
|
+
elif ch == '"':
|
|
130
|
+
in_str = False
|
|
131
|
+
elif ch == '"':
|
|
132
|
+
in_str = True
|
|
133
|
+
elif ch in "[{":
|
|
134
|
+
depth += 1
|
|
135
|
+
if depth > JSON_DEPTH_MAX:
|
|
136
|
+
raise ValueError("JSON nested deeper than %d" % JSON_DEPTH_MAX)
|
|
137
|
+
elif ch in "]}":
|
|
138
|
+
depth -= 1
|
|
139
|
+
return json.loads(text)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def dialog_policy(root):
|
|
143
|
+
""".ai/policies/dialog.json when it says enabled: true, else None (R36.1)."""
|
|
144
|
+
try:
|
|
145
|
+
with open(os.path.join(root, ".ai", "policies", "dialog.json"), encoding="utf-8") as fh:
|
|
146
|
+
data = json_loads_capped(fh.read())
|
|
147
|
+
except (OSError, ValueError, RecursionError):
|
|
148
|
+
return None
|
|
149
|
+
if not isinstance(data, dict) or data.get("enabled") is not True:
|
|
150
|
+
return None
|
|
151
|
+
window = data.get("window_seconds", 120)
|
|
152
|
+
window = window if isinstance(window, int) and not isinstance(window, bool) else 120
|
|
153
|
+
return {"window_seconds": min(600, max(30, window)),
|
|
154
|
+
"background_sessions": data.get("background_sessions") is True}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def dialog_tool_verified(runtime):
|
|
158
|
+
"""profile.json dialog.tool read from <home>/riskrails/profile.json only, as the
|
|
159
|
+
hooks' dialog_tool() does: an installer from 7b on writes dialog.tool, always
|
|
160
|
+
there, so the pre-3.4b claude-agentic/ fallback of read_profile() is not
|
|
161
|
+
consulted. A missing, unreadable, malformed, too deeply nested or non-object
|
|
162
|
+
file is no tool."""
|
|
163
|
+
try:
|
|
164
|
+
with open(os.path.join(rr_runtime.runtime_home(runtime), "riskrails", "profile.json"), encoding="utf-8") as fh:
|
|
165
|
+
data = json_loads_capped(fh.read())
|
|
166
|
+
return (data.get("dialog") or {}).get("tool")
|
|
167
|
+
except (OSError, ValueError, AttributeError, RecursionError):
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def dialog_on(root, runtime="unknown"):
|
|
172
|
+
"""The effective switch, state.py's copy of the hooks' switch_state() (7d1):
|
|
173
|
+
the project says on, nobody declared the run unattended, the runtime's dialog
|
|
174
|
+
tool is the one the installer verified (read from riskrails/profile.json
|
|
175
|
+
only, never the old claude-agentic/ file), and the session has an id to bind
|
|
176
|
+
a pick to. The hooks also compare the payload's session id with the
|
|
177
|
+
environment's, which this copy never sees: a differing payload id is "closed"
|
|
178
|
+
there while this returns a dict, and the hook refuses the call, so the dict is
|
|
179
|
+
not acted on. A payload id with no environment id is "on" there and None here:
|
|
180
|
+
no pick can be bound, so the commit is denied.
|
|
181
|
+
|
|
182
|
+
None covers both of the hooks' "off" and "closed": no session id in the
|
|
183
|
+
environment means a pick cannot be bound, so it fails closed to the
|
|
184
|
+
terminal route (every approval and commit command prints for the human's
|
|
185
|
+
terminal). A dict is the hooks' "on"."""
|
|
186
|
+
if not root:
|
|
187
|
+
return None
|
|
188
|
+
policy = dialog_policy(root)
|
|
189
|
+
if not policy or fs.ai_unattended():
|
|
190
|
+
return None
|
|
191
|
+
# the runtime's unattended marker (a background job, `claude -p`, the SDK)
|
|
192
|
+
# turns the dialog off unless the project opts in, as the hooks' unattended();
|
|
193
|
+
# the opt-in never covers an SDK entrypoint (sdk-*: `claude -p`, an SDK host — risk ffae)
|
|
194
|
+
background = policy["background_sessions"] and \
|
|
195
|
+
not os.environ.get("CLAUDE_CODE_ENTRYPOINT", "").startswith("sdk-")
|
|
196
|
+
if os.environ.get("CLAUDE_CODE_SESSION_ATTENDED") == "0" and not background:
|
|
197
|
+
return None
|
|
198
|
+
runtime = rr_runtime.own_runtime(runtime=runtime)
|
|
199
|
+
tool = dialog_tool_verified(runtime)
|
|
200
|
+
session = os.environ.get(SESSION_ENV.get(runtime, ""))
|
|
201
|
+
if runtime not in DIALOG_TOOL or tool != DIALOG_TOOL[runtime] or not session:
|
|
202
|
+
return None
|
|
203
|
+
return dict(policy, runtime=runtime, tool=tool, session=session)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def dialog_questions(decision):
|
|
207
|
+
"""One decision as dialog questions: a command list is one multi-select,
|
|
208
|
+
unless a label holds ", " — a joined answer would be ambiguous — and then
|
|
209
|
+
one single-select per command, each with the safe option first (R36.4)."""
|
|
210
|
+
options = decision["options"]
|
|
211
|
+
commands = [o for o in options if o["kind"] == "command"]
|
|
212
|
+
if decision.get("multi_select") and any(", " in o["label"] for o in commands):
|
|
213
|
+
safe = [o for o in options if o["kind"] == "choice"][:1]
|
|
214
|
+
return [(safe + [o], False) for o in commands]
|
|
215
|
+
return [(options, bool(decision.get("multi_select")))]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def open_decision(root, state, kind, header, question, options, opened_by, multi=False, rt=rr_runtime.Runtime(), **extra):
|
|
219
|
+
"""Write one decision; return (it, "") or (None, why) (R36.3, R36.4)."""
|
|
220
|
+
data = rr_ledger.read_decisions(root)
|
|
221
|
+
# Two open decisions with one question text can never be answered in the
|
|
222
|
+
# dialog: the recorder reads both as ambiguous (risks afd2, 712d). Checked
|
|
223
|
+
# first, so a twin at the limit is named at once, not after a settle (risk 4f78).
|
|
224
|
+
shown = (question or "").strip()[:QUESTION_MAX]
|
|
225
|
+
session = (dialog_on(root, runtime=rt.name) or {}).get("session")
|
|
226
|
+
twin = next((d for d in data["open"] if d.get("question") == shown), None)
|
|
227
|
+
if twin:
|
|
228
|
+
tid = twin.get("id")
|
|
229
|
+
if twin.get("session") != session:
|
|
230
|
+
# decision show lists only this session's decisions (risk e3ae).
|
|
231
|
+
return None, ("DECISION_DUPLICATE — %s is open with this very question from another session; "
|
|
232
|
+
"it cannot be sent from here: 'state.py decision fallback --id %s --why …' "
|
|
233
|
+
"before opening it again" % (tid, tid))
|
|
234
|
+
return None, ("DECISION_DUPLICATE — %s is open with this very question; send it ('state.py decision show') "
|
|
235
|
+
"and run the pick, or 'state.py decision fallback --id %s --why …', before opening it again"
|
|
236
|
+
% (tid, tid))
|
|
237
|
+
questions = sum(len(dialog_questions(d)) for d in data["open"])
|
|
238
|
+
if questions + len(dialog_questions({"options": options, "multi_select": multi})) > DECISIONS_MAX:
|
|
239
|
+
return None, "DECISION_LIMIT — %d questions are open; one dialog call carries %d" % (
|
|
240
|
+
questions, DECISIONS_MAX)
|
|
241
|
+
if not 2 <= len(options) <= 4:
|
|
242
|
+
return None, "a decision has 2 to 4 options"
|
|
243
|
+
for option in options:
|
|
244
|
+
why = labels.validate_label(option["label"]) if option["kind"] == "command" else (
|
|
245
|
+
None if 0 < len(option["label"]) <= labels.LABEL_MAX and option["label"].isprintable()
|
|
246
|
+
else "a choice label is 1 to %d printable characters" % labels.LABEL_MAX)
|
|
247
|
+
if why:
|
|
248
|
+
return None, "CONSENT_LABEL_INVALID — %s: %s" % (why, option["label"][:120])
|
|
249
|
+
if len({o["label"] for o in options}) != len(options):
|
|
250
|
+
return None, "CONSENT_LABEL_INVALID — two options carry the same label"
|
|
251
|
+
decision = dict({
|
|
252
|
+
"id": rr_ledger.next_decision_id(data, rr_ledger.read_ledger(root)), "kind": kind, "task": (state or {}).get("task_id"),
|
|
253
|
+
"runtime": rr_runtime.own_runtime(runtime=rt.name), "session": session,
|
|
254
|
+
"tree": fs.current_tree(root), "opened_at": fs.now(), "opened_by": opened_by,
|
|
255
|
+
"header": fs.one_line(header, HEADER_MAX), "multi_select": bool(multi),
|
|
256
|
+
"question": (question or "").strip()[:QUESTION_MAX],
|
|
257
|
+
"options": [dict(option, n=n) for n, option in enumerate(options, 1)],
|
|
258
|
+
}, **extra)
|
|
259
|
+
data["open"].append(decision)
|
|
260
|
+
rr_ledger.write_decisions(root, data)
|
|
261
|
+
if state and state.get("task_id"):
|
|
262
|
+
journal.append_journal(root, state["task_id"], journal.journal_line(
|
|
263
|
+
state["task_id"], "decision_opened", "agent", state.get("current_stage"),
|
|
264
|
+
"%s %s by %s" % (decision["id"], kind, opened_by),
|
|
265
|
+
{"decision": decision["id"], "kind": kind, "options": len(options)}, runtime=rt.name))
|
|
266
|
+
return decision, ""
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def option_description(option):
|
|
270
|
+
if option.get("description"):
|
|
271
|
+
return option["description"]
|
|
272
|
+
return "the session runs exactly this, once" if option["kind"] == "command" else "nothing runs"
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def part_question(question, i, n):
|
|
276
|
+
"""The question text of part i of n: a split decision's parts must differ,
|
|
277
|
+
Claude keys tool_response.answers by text; cut so the whole fits QUESTION_MAX."""
|
|
278
|
+
if n < 2:
|
|
279
|
+
return question
|
|
280
|
+
suffix = " (%d of %d)" % (i, n)
|
|
281
|
+
return (question or "")[:QUESTION_MAX - len(suffix)] + suffix
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def dialog_payload(decisions, runtime):
|
|
285
|
+
"""The dialog tool's input for these decisions, in file order: AskUserQuestion
|
|
286
|
+
for Claude, request_user_input for Codex (keyed by a question id)."""
|
|
287
|
+
questions = []
|
|
288
|
+
for decision in decisions:
|
|
289
|
+
parts = dialog_questions(decision)
|
|
290
|
+
for i, (options, multi) in enumerate(parts, 1):
|
|
291
|
+
item = {"question": part_question(decision["question"], i, len(parts)), "header": decision["header"],
|
|
292
|
+
"options": [{"label": o["label"], "description": option_description(o)} for o in options]}
|
|
293
|
+
if runtime == "codex":
|
|
294
|
+
item = dict({"id": "%s%s" % (decision["id"].lower(), "_%d" % i if len(parts) > 1 else "")},
|
|
295
|
+
**item)
|
|
296
|
+
else:
|
|
297
|
+
item["multiSelect"] = multi
|
|
298
|
+
questions.append(item)
|
|
299
|
+
return {"questions": questions[:DECISIONS_MAX]}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def print_dialog(decisions, rt=rr_runtime.Runtime()):
|
|
303
|
+
"""DIALOG_PENDING and the JSON the agent sends to the dialog tool."""
|
|
304
|
+
runtime = rr_runtime.own_runtime(runtime=rt.name)
|
|
305
|
+
print("DIALOG_PENDING %s — call %s with exactly this JSON, then run the picked command verbatim "
|
|
306
|
+
"or 'state.py decision settle --id %s':"
|
|
307
|
+
% (" ".join(d["id"] for d in decisions), DIALOG_TOOL.get(runtime, "AskUserQuestion"),
|
|
308
|
+
decisions[0]["id"]))
|
|
309
|
+
print(json.dumps(dialog_payload(decisions, runtime), ensure_ascii=False))
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def offer_decision(root, state, kind, header, question, options, opened_by, rt=rr_runtime.Runtime(), **extra):
|
|
313
|
+
"""An opener's whole dialog branch: with the switch on, open and print the
|
|
314
|
+
decision and return it; off, or refused, return None and the caller prints
|
|
315
|
+
today's terminal text (R36.2)."""
|
|
316
|
+
if not dialog_on(root, runtime=rt.name):
|
|
317
|
+
return None
|
|
318
|
+
decision, why = open_decision(root, state, kind, header, question, options, opened_by, rt=rt, **extra)
|
|
319
|
+
if decision is None:
|
|
320
|
+
print("state.py: no dialog — %s" % why, file=sys.stderr)
|
|
321
|
+
return None
|
|
322
|
+
print_dialog([decision], rt=rt)
|
|
323
|
+
return decision
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def settle_decision(root, decision_id, effect, pick=None):
|
|
327
|
+
"""Write the settle line and drop the decision from decision.json."""
|
|
328
|
+
with locks.ledger_lock(root):
|
|
329
|
+
rr_ledger.ledger_append(root, {"type": "settle", "decision": decision_id, "pick": pick, "effect": effect})
|
|
330
|
+
data = rr_ledger.read_decisions(root)
|
|
331
|
+
data["open"] = [d for d in data["open"] if d.get("id") != decision_id]
|
|
332
|
+
rr_ledger.write_decisions(root, data)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def settle_written(root, entry):
|
|
336
|
+
"""True when the settle line is in the ledger; a decision leaves decision.json
|
|
337
|
+
only with its line (43ae-I4)."""
|
|
338
|
+
try:
|
|
339
|
+
with locks.ledger_lock(root):
|
|
340
|
+
rr_ledger.ledger_append(root, entry)
|
|
341
|
+
except OSError:
|
|
342
|
+
return False
|
|
343
|
+
return True
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def prune_decisions(root):
|
|
347
|
+
"""On every run: a decision with a settle line, or whose latest round's
|
|
348
|
+
command picks are all used, leaves decision.json (R36.14); with the
|
|
349
|
+
project's dialog switch off, every open one falls back (R36.15)."""
|
|
350
|
+
try:
|
|
351
|
+
with open(fs.decision_path(root), encoding="utf-8") as fh:
|
|
352
|
+
data = json.load(fh)
|
|
353
|
+
keep = list(data["open"])
|
|
354
|
+
except (OSError, ValueError, KeyError, TypeError, RecursionError):
|
|
355
|
+
return # loud in the decision commands, not in every command
|
|
356
|
+
ledger = rr_ledger.read_ledger(root)
|
|
357
|
+
settled = {e.get("decision") for e in ledger if e["type"] == "settle"}
|
|
358
|
+
used = {e.get("id") for e in ledger if e["type"] == "use"}
|
|
359
|
+
rounds = rr_ledger.latest_rounds(ledger)
|
|
360
|
+
off = dialog_policy(root) is None
|
|
361
|
+
for decision in list(keep):
|
|
362
|
+
commands = {o.get("n") for o in decision.get("options") or [] if o.get("kind") == "command"}
|
|
363
|
+
picks = [e for e in rounds.get(decision.get("id"), []) if e.get("option") in commands]
|
|
364
|
+
if decision.get("id") in settled:
|
|
365
|
+
keep.remove(decision)
|
|
366
|
+
elif off:
|
|
367
|
+
if settle_written(root, {"type": "settle", "decision": decision["id"], "pick": None,
|
|
368
|
+
"effect": "fallback"}):
|
|
369
|
+
keep.remove(decision)
|
|
370
|
+
elif picks and all(p.get("id") in used for p in picks):
|
|
371
|
+
parsed = labels.label_argv(picks[-1].get("label") or "")
|
|
372
|
+
effect = "declined" if parsed and labels.subcommand(parsed[1])[:1] == ["reject"] else "approved"
|
|
373
|
+
if settle_written(root, {"type": "settle", "decision": decision["id"], "pick": picks[-1].get("id"),
|
|
374
|
+
"effect": effect}):
|
|
375
|
+
keep.remove(decision)
|
|
376
|
+
if len(keep) != len(data["open"]):
|
|
377
|
+
data["open"] = keep
|
|
378
|
+
rr_ledger.write_decisions(root, data)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
MIRROR = {"pick": "consent_recorded", "use": "consent_used", "void": "consent_voided",
|
|
382
|
+
"decline": "decision_declined"}
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def mirror_ledger(root, rt=rr_runtime.Runtime()):
|
|
386
|
+
"""New ledger lines into the journal of the task in flight (R36.24); the
|
|
387
|
+
byte cursor is the state's, so each line is mirrored once."""
|
|
388
|
+
state = task.load(root, required=False, claim=False, rt=rt)
|
|
389
|
+
path = fs.ledger_path(root)
|
|
390
|
+
if state is None or not task.is_v7(state) or not os.path.exists(path):
|
|
391
|
+
return
|
|
392
|
+
dialog = state.setdefault("dialog", {})
|
|
393
|
+
position = dialog.get("ledger_pos", 0)
|
|
394
|
+
try:
|
|
395
|
+
with open(path, "rb") as fh:
|
|
396
|
+
fh.seek(position)
|
|
397
|
+
chunk = fh.read()
|
|
398
|
+
except OSError:
|
|
399
|
+
return
|
|
400
|
+
end = chunk.rfind(b"\n") + 1
|
|
401
|
+
if not end:
|
|
402
|
+
return
|
|
403
|
+
for raw in chunk[:end].decode("utf-8", "replace").splitlines():
|
|
404
|
+
try:
|
|
405
|
+
entry = json.loads(raw)
|
|
406
|
+
except (ValueError, RecursionError):
|
|
407
|
+
continue
|
|
408
|
+
if not isinstance(entry, dict) or entry.get("type") not in rr_ledger.LEDGER_TYPES or entry["type"] == "offer":
|
|
409
|
+
continue
|
|
410
|
+
event = MIRROR.get(entry["type"]) or ("decision_fallback" if entry.get("effect") == "fallback"
|
|
411
|
+
else "decision_settled")
|
|
412
|
+
data = {k: entry[k] for k in ("id", "ids", "decision", "option", "order", "by", "why", "pick",
|
|
413
|
+
"effect", "tool_use_id", "session") if k in entry}
|
|
414
|
+
journal.append_journal(root, state["task_id"], journal.journal_line(
|
|
415
|
+
state["task_id"], event, "hook", state.get("current_stage"),
|
|
416
|
+
fs.one_line(entry.get("label") or entry.get("id") or entry.get("decision"), 200), data, runtime=rt.name))
|
|
417
|
+
dialog["ledger_pos"] = position + end
|
|
418
|
+
task.save(root, state)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def sync_dialog(root, rt=rr_runtime.Runtime()):
|
|
422
|
+
"""Prune, then mirror: what every state-changing run does first."""
|
|
423
|
+
if os.path.exists(fs.decision_path(root)):
|
|
424
|
+
prune_decisions(root)
|
|
425
|
+
if os.path.exists(fs.ledger_path(root)):
|
|
426
|
+
mirror_ledger(root, rt=rt)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def dialog_pick(root, rt=rr_runtime.Runtime()):
|
|
430
|
+
"""The dialog branch of human_route() (R36.10): a live pick of this session
|
|
431
|
+
whose label runs this very state.py with exactly this argv. Its `use` is
|
|
432
|
+
appended before the caller acts, so the pick is spent even when the command
|
|
433
|
+
then fails; when it was the decision's last pick, the decision settles.
|
|
434
|
+
|
|
435
|
+
A pick counts only for a decision that is still open, and an approval's
|
|
436
|
+
only while the gate and the tree it was offered for are the ones in force:
|
|
437
|
+
an approve label is the same string for every gate of a project, so a pick
|
|
438
|
+
left over from a rejected or superseded gate must not sign the next one."""
|
|
439
|
+
switch = dialog_on(root, runtime=rt.name)
|
|
440
|
+
if not switch:
|
|
441
|
+
return None
|
|
442
|
+
here, argv = os.path.realpath(fs.ENTRY), sys.argv[1:]
|
|
443
|
+
current = valid_decisions(root, rt=rt)
|
|
444
|
+
try:
|
|
445
|
+
with locks.ledger_lock(root):
|
|
446
|
+
for pick in rr_ledger.live_picks(rr_ledger.read_ledger(root), switch["session"]):
|
|
447
|
+
parsed = labels.label_argv(pick.get("label") or "")
|
|
448
|
+
if parsed and os.path.realpath(parsed[0]) == here and parsed[1] == argv \
|
|
449
|
+
and pick.get("decision") in current:
|
|
450
|
+
rr_ledger.ledger_append(root, {"type": "use", "id": pick["id"],
|
|
451
|
+
"by": "state.py %s" % " ".join(labels.subcommand(argv)[:1])})
|
|
452
|
+
DIALOG_PICK[0] = pick
|
|
453
|
+
break
|
|
454
|
+
except OSError:
|
|
455
|
+
return None # no ledger, no lock: no consent (fails closed)
|
|
456
|
+
if DIALOG_PICK[0]:
|
|
457
|
+
prune_decisions(root)
|
|
458
|
+
return DIALOG_PICK[0]
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def valid_decisions(root, rt=rr_runtime.Runtime()):
|
|
462
|
+
"""The ids of the open decisions a pick may still count for: every open
|
|
463
|
+
one, except an approval whose gate or tree is no longer the one in force."""
|
|
464
|
+
try:
|
|
465
|
+
with open(fs.decision_path(root), encoding="utf-8") as fh:
|
|
466
|
+
decisions = [d for d in json.load(fh)["open"] if isinstance(d, dict)]
|
|
467
|
+
except (OSError, ValueError, KeyError, TypeError, RecursionError):
|
|
468
|
+
return set()
|
|
469
|
+
state = task.load(root, required=False, claim=False, rt=rt) if any(d.get("kind") == "approval" for d in decisions) else None
|
|
470
|
+
tree = fs.current_tree(root) if state else None
|
|
471
|
+
return {d.get("id") for d in decisions
|
|
472
|
+
if d.get("kind") != "approval" or (state and d.get("task") == state.get("task_id")
|
|
473
|
+
and d.get("gate_id") and d.get("gate_id") == task.open_gate(state)
|
|
474
|
+
and d.get("tree") == tree)}
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def retire_approvals(root, task_id, effect):
|
|
478
|
+
"""Settle the task's open approval decisions: the gate they were offered for
|
|
479
|
+
was granted, rejected or asked again (H1)."""
|
|
480
|
+
if not os.path.exists(fs.decision_path(root)):
|
|
481
|
+
return
|
|
482
|
+
for decision in rr_ledger.read_decisions(root)["open"]:
|
|
483
|
+
if decision.get("kind") == "approval" and decision.get("task") == task_id:
|
|
484
|
+
settle_decision(root, decision["id"], effect)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def pick_data(pick):
|
|
488
|
+
"""What a grant through the dialog adds to its journal line (R36.10)."""
|
|
489
|
+
return {"consent": pick.get("id"), "decision": pick.get("decision"),
|
|
490
|
+
"tool_use_id": pick.get("tool_use_id")} if pick else {}
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def task_facts(root, state):
|
|
494
|
+
"""The self-contained head of a decision's question: the modal hides the
|
|
495
|
+
output above it (R36.3)."""
|
|
496
|
+
review, weight_ = state.get("review") or {}, state.get("weight") or {}
|
|
497
|
+
findings = [f.get("label") for f in state.get("findings") or [] if f.get("status") == "open"]
|
|
498
|
+
reversibility = (weight_.get("axes") or {}).get("reversibility", 0)
|
|
499
|
+
rollback = "git revert" if not reversibility else "git revert is not enough — reversibility %s: %s" % (
|
|
500
|
+
reversibility, fs.one_line((weight_.get("evidence") or {}).get("reversibility"), 160))
|
|
501
|
+
return ("%s — %s\nmode %s · W %s · tree %s\nverify: %s · review: %s%s\nrollback: %s"
|
|
502
|
+
% (state.get("task_id"), fs.one_line(state.get("goal"), 200), state.get("mode"),
|
|
503
|
+
weight_.get("w"), (fs.current_tree(root) or "?")[:9],
|
|
504
|
+
"green" if rr_policy.suite_green(state) else "not green", review.get("status") or "none",
|
|
505
|
+
" (open: %s)" % ", ".join(findings) if findings else "", rollback))
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def audit_pick(pick):
|
|
509
|
+
"""ok: the transcript holds the pick's tool_use_id; mismatch: it does not —
|
|
510
|
+
a line no dialog produced; unverifiable: no transcript to read."""
|
|
511
|
+
path, tool_use = pick.get("transcript"), pick.get("tool_use_id")
|
|
512
|
+
if not path or not tool_use or not os.path.isfile(path):
|
|
513
|
+
return "unverifiable"
|
|
514
|
+
try:
|
|
515
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
516
|
+
return "ok" if any(tool_use in line for line in fh) else "mismatch"
|
|
517
|
+
except OSError:
|
|
518
|
+
return "unverifiable"
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def approve_command(root, state):
|
|
522
|
+
ops = state["weight"].get("operations") or []
|
|
523
|
+
confirm = (" --confirm \"<what it does, naming %s>\"" % (" and ".join(ops) or "the operation")
|
|
524
|
+
if state["mode"] == "full_irreversible" else "")
|
|
525
|
+
return "python3 %s --root %s approve --by \"<name>\"%s" % (
|
|
526
|
+
shlex.quote(fs.ENTRY), shlex.quote(root), confirm)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def offer_approval(root, state, facts, rt=rr_runtime.Runtime()):
|
|
530
|
+
"""close's dialog branch (R36.3): Not now, approve, reject — full mode only;
|
|
531
|
+
full_irreversible keeps the terminal command (R36.10)."""
|
|
532
|
+
name = labels.human_name(root)
|
|
533
|
+
retire_approvals(root, state["task_id"], "superseded")
|
|
534
|
+
return offer_decision(
|
|
535
|
+
root, state, "approval", "Approve",
|
|
536
|
+
"%s\nfacts: (a) %s · (b) %s\nApprove this tree for %s?"
|
|
537
|
+
% (task_facts(root, state), facts["a"][1], facts["b"][1], state["task_id"]),
|
|
538
|
+
[{"kind": "choice", "effect": "defer", "label": "Not now — leave the gate open"},
|
|
539
|
+
{"kind": "command", "label": labels.state_label(root, ["approve", "--by", name])},
|
|
540
|
+
{"kind": "command", "label": labels.state_label(root, ["reject", "--by", name, "--why", "rejected in the dialog"])}],
|
|
541
|
+
"close", gate_id=state["human_approval"]["gate_id"], rt=rt)
|