riskrails 3.0.1 → 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 (99) 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 +29 -15
  5. package/ai/policies/model-routing.md +7 -7
  6. package/ai/policies/production.md +3 -3
  7. package/ai/policies/release.md +2 -2
  8. package/ai/policies/review-economy.md +27 -30
  9. package/bin/riskrails.js +114 -23
  10. package/hooks/ai-path-guard-defaults.json +3 -2
  11. package/hooks/context-guard.py +15 -12
  12. package/hooks/dialog-gate.py +1 -1
  13. package/hooks/guard/common.py +2 -0
  14. package/hooks/guard/dialog.py +107 -27
  15. package/hooks/guard/git.py +12 -3
  16. package/hooks/guard/paths.py +150 -37
  17. package/hooks/project-scaffold.py +3 -6
  18. package/hooks/runtime-gate.py +134 -33
  19. package/hooks/update_notice.py +22 -16
  20. package/instructions/routing.md +1 -1
  21. package/instructions/stub.md +3 -3
  22. package/package.json +1 -1
  23. package/skills/AGENTS.md +1 -1
  24. package/skills/CLAUDE.md +1 -1
  25. package/skills/ai-init/templates/.ai/AGENTS.md +1 -1
  26. package/skills/ai-init/templates/.ai/VERSION +1 -1
  27. package/skills/ai-init/templates/.ai/policies/security.md +7 -1
  28. package/skills/ai-init/templates/.ai/policies/testing.md +19 -6
  29. package/skills/ai-init/templates/.ai/state/README.md +4 -4
  30. package/skills/ai-init/templates/AGENTS.block.md +2 -2
  31. package/skills/ai-init/templates/CLAUDE.block.md +2 -2
  32. package/skills/ai-init/templates/GEMINI.block.md +2 -2
  33. package/skills/ai-init/templates/gitignore.snippet +1 -0
  34. package/skills/ai-init/templates/junie-guidelines.block.md +2 -2
  35. package/skills/ai-task/rr_state/__init__.py +1 -0
  36. package/skills/ai-task/rr_state/cli.py +210 -0
  37. package/skills/ai-task/rr_state/close.py +171 -0
  38. package/skills/ai-task/rr_state/dialog.py +541 -0
  39. package/skills/ai-task/rr_state/dialog_cmd.py +164 -0
  40. package/skills/ai-task/rr_state/fs.py +220 -0
  41. package/skills/ai-task/rr_state/gate.py +125 -0
  42. package/skills/ai-task/rr_state/gate_cmd.py +128 -0
  43. package/skills/ai-task/rr_state/handoff.py +226 -0
  44. package/skills/ai-task/rr_state/journal.py +126 -0
  45. package/skills/ai-task/rr_state/labels.py +99 -0
  46. package/skills/ai-task/rr_state/ledger.py +145 -0
  47. package/skills/ai-task/rr_state/locks.py +84 -0
  48. package/skills/ai-task/rr_state/memory_cmd.py +310 -0
  49. package/skills/ai-task/rr_state/policy.py +378 -0
  50. package/skills/ai-task/rr_state/profile.py +373 -0
  51. package/skills/ai-task/rr_state/questions.py +336 -0
  52. package/skills/ai-task/rr_state/questions_cmd.py +321 -0
  53. package/skills/ai-task/rr_state/report.py +271 -0
  54. package/skills/ai-task/rr_state/report_cmd.py +230 -0
  55. package/skills/ai-task/rr_state/review.py +165 -0
  56. package/skills/ai-task/rr_state/runtime.py +80 -0
  57. package/skills/ai-task/rr_state/start.py +257 -0
  58. package/skills/ai-task/rr_state/steps.py +131 -0
  59. package/skills/ai-task/rr_state/task.py +396 -0
  60. package/skills/ai-task/rr_state/verify.py +231 -0
  61. package/skills/ai-task/sensors.py +37 -4
  62. package/skills/ai-task/state.py +24 -5138
  63. package/skills/project-init/templates/constitution.md +1 -1
  64. package/skills/project-init/templates/sdlc-README.md +20 -14
  65. package/skills/project-update/adopt.py +38 -24
  66. package/skills/project-update/history/blobs/00289f98434fed230eebe768dfb3425f83d737866a3aebc97aa95167c4fa0c6e +131 -0
  67. package/skills/project-update/history/blobs/079a91f976ad69b5cf49ccb9a28849576e4735ddd9485fc26599e528a69fd2f4 +30 -0
  68. package/skills/project-update/history/blobs/07ff7fa6f93d471ff533f553b4e523e4105ccde1f3dab70132c5237728f68422 +13 -0
  69. package/skills/project-update/history/blobs/0ef4a360c76da290173dea0f8073949cb0f8113710a38df95acae5d96bdd1286 +123 -0
  70. package/skills/project-update/history/blobs/18c7a2753ce9cb6f6d3d1e9d5d1125d75950b879d56b7325225b200869631b29 +15 -0
  71. package/skills/project-update/history/blobs/25d4f2a86deb5e2574bb3210b67bb24fcc4afb19f93a7b65a057daa874a9d18e +1 -0
  72. package/skills/project-update/history/blobs/48d9950f16e31d55475f566a80dc95f64b35f1c804d2f51729e313a4f29bac8d +13 -0
  73. package/skills/project-update/history/blobs/5a93f78b4ed4d416c171139b2ce610a117f9c1dd46fbb56ca86729cb4c53d10c +6 -0
  74. package/skills/project-update/history/blobs/6a3be0e624e5ad91e44d08857b7183ee53b7903e1e8d56ff196dbc9dde271e4a +75 -0
  75. package/skills/project-update/history/blobs/7170553410bba904ffb7edb4b231e3b3579b85542d84de717299980938f82c9d +13 -0
  76. package/skills/project-update/history/blobs/774b1e3a04ef0d49d33c44a2cfecd92eb6f6b738a12b6a681103ba7fc13e7b86 +67 -0
  77. package/skills/project-update/history/blobs/7f063e79195a5c85c37b7260dd20c184d3b9bea85e37202e0bbca73ad84bba49 +13 -0
  78. package/skills/project-update/history/blobs/a51517e13e57420d2d88a6bcfb4055d762d4a0d10a8f88450929c5c4572213a9 +67 -0
  79. package/skills/project-update/history/blobs/c8573ca1f810ce6c4ac49456a4c1725b93e0d3b74c54ce95d46d8747777895f0 +13 -0
  80. package/skills/project-update/history/blobs/d6e5b34d475e7187d2a8cb2aac7e7f1a427142baf6461bda387d70ceb4d8a8c1 +30 -0
  81. package/skills/project-update/history/blobs/e120ff4e32b2e950fc94ada4e787d57dc9c790da37223e5a202d74f7e3aadf80 +13 -0
  82. package/skills/project-update/history/index.json +38 -16
  83. package/skills/project-update/migrations/0003_context_diet.py +1 -1
  84. package/skills/project-update/migrations/0007_weight_model.py +130 -23
  85. package/skills/project-update/migrations/0011_retire_risk_tiers.py +37 -0
  86. package/skills/project-update/migrations/README.md +16 -1
  87. package/skills/project-update/migrations/__init__.py +6 -1
  88. package/skills/project-update/render_instructions.py +1 -1
  89. package/skills/project-update/update.py +569 -106
  90. package/skills/rr-build-all/SKILL.md +2 -2
  91. package/skills/rr-plan/SKILL.md +3 -1
  92. package/skills/rr-review/SKILL.md +13 -8
  93. package/skills/rr-setup/SKILL.md +24 -4
  94. package/skills/rr-setup/setup.py +7 -1
  95. package/skills/rr-spec/SKILL.md +4 -2
  96. package/skills/rr-status/status.py +36 -9
  97. package/skills/usage-report/usage-report.py +4 -2
  98. package/skills/ai-init/templates/.ai/policies/risk-tiers.json +0 -628
  99. package/skills/ai-init/templates/.ai/policies/risk-tiers.md +0 -180
@@ -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)