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
@@ -126,7 +126,7 @@ def user_settings():
126
126
  with open(os.path.join(config_dir(), "settings.json"), encoding="utf-8") as fh:
127
127
  settings = json.load(fh)
128
128
  return settings if isinstance(settings, dict) else {}
129
- except (OSError, ValueError):
129
+ except (OSError, ValueError, RecursionError):
130
130
  return {}
131
131
 
132
132
 
@@ -157,7 +157,7 @@ def profile_context():
157
157
  with open(os.path.join(home, "riskrails", "profile.json"), encoding="utf-8") as fh:
158
158
  context = json.load(fh).get("context")
159
159
  return context if isinstance(context, dict) else {}
160
- except (OSError, ValueError, AttributeError):
160
+ except (OSError, ValueError, RecursionError, AttributeError):
161
161
  return {}
162
162
 
163
163
 
@@ -253,7 +253,7 @@ def current_context(path):
253
253
  continue
254
254
  try:
255
255
  entry = json.loads(raw)
256
- except ValueError:
256
+ except (ValueError, RecursionError):
257
257
  continue
258
258
  if entry.get("type") == "system" and entry.get("subtype") == "compact_boundary":
259
259
  return None # the numbers before it describe the old context
@@ -301,7 +301,7 @@ def scan_session(path):
301
301
  continue
302
302
  try:
303
303
  entry = json.loads(raw)
304
- except ValueError:
304
+ except (ValueError, RecursionError):
305
305
  continue
306
306
  if entry.get("isSidechain"):
307
307
  continue
@@ -395,7 +395,7 @@ def task_in_flight(root):
395
395
  try:
396
396
  with open(state, encoding="utf-8") as fh:
397
397
  return (json.load(fh) or {}).get("current_stage") != "done"
398
- except (OSError, ValueError):
398
+ except (OSError, ValueError, RecursionError):
399
399
  return False
400
400
 
401
401
 
@@ -412,7 +412,7 @@ def write_session(root, updates):
412
412
  with open(path, encoding="utf-8") as fh:
413
413
  data = json.load(fh)
414
414
  data = data if isinstance(data, dict) else {}
415
- except (OSError, ValueError):
415
+ except (OSError, ValueError, RecursionError):
416
416
  data = {}
417
417
  data.update(updates)
418
418
  try:
@@ -431,7 +431,7 @@ def prompt_count(root):
431
431
  try:
432
432
  with open(os.path.join(root, ".ai", "state", "session.json"), encoding="utf-8") as fh:
433
433
  count = json.load(fh).get("prompt_count")
434
- except (OSError, ValueError, AttributeError):
434
+ except (OSError, ValueError, RecursionError, AttributeError):
435
435
  return 0
436
436
  return count if isinstance(count, int) and not isinstance(count, bool) else 0
437
437
 
@@ -471,7 +471,7 @@ def recorded_mtime(root):
471
471
  try:
472
472
  with open(os.path.join(root, ".ai", "state", "session.json"), encoding="utf-8") as fh:
473
473
  return json.load(fh).get("account_mtime")
474
- except (OSError, ValueError, AttributeError):
474
+ except (OSError, ValueError, RecursionError, AttributeError):
475
475
  return None
476
476
 
477
477
 
@@ -488,7 +488,7 @@ def detect_drift(root, cwd, session, open_it=True, mtime=None):
488
488
  raw = ""
489
489
  try:
490
490
  found = json.loads(raw)
491
- except ValueError:
491
+ except (ValueError, RecursionError):
492
492
  return None
493
493
  if root and mtime is not None and isinstance(found, dict):
494
494
  write_session(root, {"account_mtime": mtime})
@@ -514,9 +514,12 @@ def drift_notice(root, found, session):
514
514
  f"DIALOG_PENDING {decision} — call {tool} with exactly this JSON, then run the picked "
515
515
  f"command verbatim or 'state.py decision settle --id {decision}': {payload}")
516
516
  command = found.get("command") or ""
517
+ ack = found.get("ack")
518
+ keep = f" — or, to keep {installed} and not be told again for this account: {ack}" if ack else ""
517
519
  return (f"RiskRails: this account is {account}; RiskRails is installed for {installed}.",
518
520
  f"RiskRails: the account's plan is {account} but RiskRails is installed for {installed}, so its "
519
- f"budgets do not match. Tell the user; in their own terminal, outside the agent, they run: {command}")
521
+ f"budgets do not match. Tell the user; in their own terminal, outside the agent, they run: {command}"
522
+ f"{keep}")
520
523
 
521
524
 
522
525
  def handoff_block(root, reason):
@@ -552,7 +555,7 @@ def task_frame(root, reason):
552
555
  tail.append(f"## Pending questions ({count}) — answer with `state.py answer …` "
553
556
  "or fill the file")
554
557
  tail.append(prose or ids)
555
- tail.append("Resume with: /ai-task --resume")
558
+ tail.append("Resume with: /rr-build --resume")
556
559
  if len("\n".join([head] + tail)) > HANDOFF_INJECT_MAX_CHARS and len(tail) > 1:
557
560
  tail[1] = ids # the questions collapse to their ids
558
561
  return head[:HANDOFF_INJECT_MAX_CHARS], "\n".join(tail)
@@ -656,7 +659,7 @@ def prompt_warning(payload, session, transcript):
656
659
  try:
657
660
  with open(marker, encoding="utf-8") as fh:
658
661
  state = json.load(fh)
659
- except (OSError, ValueError):
662
+ except (OSError, ValueError, RecursionError):
660
663
  state = {}
661
664
 
662
665
  def save():
@@ -37,7 +37,7 @@ def prompt_count(root):
37
37
  try:
38
38
  with open(os.path.join(root, ".ai", "state", "session.json"), encoding="utf-8") as fh:
39
39
  count = json.load(fh).get("prompt_count")
40
- except (OSError, ValueError, AttributeError):
40
+ except (OSError, ValueError, AttributeError, RecursionError):
41
41
  return None
42
42
  return count if isinstance(count, int) and not isinstance(count, bool) else None
43
43
 
@@ -165,6 +165,8 @@ _WS = " \t\n\r"
165
165
  def parse_stream(text):
166
166
  """(values, ok): every JSON value of a jq input stream, and False when a
167
167
  parse error ended it (the values before the error are kept)."""
168
+ if text.startswith("\ufeff"): # jq skips one leading UTF-8 BOM
169
+ text = text[1:]
168
170
  values, i, n = [], 0, len(text)
169
171
  while True:
170
172
  while i < n and text[i] in _WS:
@@ -7,11 +7,16 @@ tests/test_dialog_contract.py feeds both the same fixtures, so they cannot
7
7
  drift. Nothing here imports state.py: hooks run under `python3 -I -S`, stdlib
8
8
  only.
9
9
 
10
- Off is today: with .ai/policies/dialog.json missing or not `enabled: true`,
11
- no AI_UNATTENDED, the runtime's verified dialog tool, the main session and a
12
- session id all required, effective_switch() is False and no caller writes or
13
- decides anything (R36.1, R36.2). Readers fail open: a missing or unreadable
14
- file reads as "nothing open, nothing picked".
10
+ The switch has three states (switch_state). Off is today: with
11
+ .ai/policies/dialog.json missing or not `enabled: true`, AI_UNATTENDED set or
12
+ the runtime's verified dialog tool missing, no caller writes or decides
13
+ anything (R36.1, R36.2). Closed: the project's switch is on but the call
14
+ cannot be bound to a session that can answer a dialog (a subagent, no session
15
+ id, a session id that is not the hook environment's own); the dialog and the
16
+ ledger treat it like off, the git consent rule refuses (terminal route). On:
17
+ the main session with a session id. effective_switch() is True only for on.
18
+ Readers fail open: a missing or unreadable file reads as "nothing open,
19
+ nothing picked".
15
20
  """
16
21
  import contextlib
17
22
  import json
@@ -28,6 +33,10 @@ LOCK_STALE_S = 2.0
28
33
  LINE_MAX = 4096 # one write; O_APPEND keeps a line whole up to this
29
34
 
30
35
  DIALOG_TOOL = {"claude": "AskUserQuestion", "codex": "request_user_input"}
36
+ # The runtime's own session variable; the same mapping as state.py's. A hook's
37
+ # environment carries it for Claude only (spike 2026-10-31: no CODEX_* variable
38
+ # reaches a Codex hook), so it is compared only when present and non-empty.
39
+ SESSION_ENV = {"claude": "CLAUDE_CODE_SESSION_ID", "codex": "CODEX_THREAD_ID"}
31
40
  LEDGER_TYPES = {"offer", "pick", "use", "void", "decline", "settle"}
32
41
  STOP_BLOCK = "stop_block" # the Stop loop breaker's count; no reader of consent reads it
33
42
  LABEL_FORBIDDEN = (";", "&", "|", "\n", "\r", "`", "$(", "<", ">")
@@ -63,13 +72,39 @@ def canonical(obj):
63
72
 
64
73
 
65
74
  # -- the switch ---------------------------------------------------------------
75
+ JSON_DEPTH_MAX = 64 # one nesting cap for both switches, not the stack's
76
+
77
+
78
+ def json_loads_capped(text):
79
+ """json.loads(text), except that a text nested deeper than JSON_DEPTH_MAX is
80
+ malformed (ValueError). The parser's own limit moves with the stack depth, so
81
+ the hook and state.py could disagree; this scan does not recurse."""
82
+ depth, in_str, esc = 0, False, False
83
+ for ch in text:
84
+ if in_str:
85
+ if esc:
86
+ esc = False
87
+ elif ch == "\\":
88
+ esc = True
89
+ elif ch == '"':
90
+ in_str = False
91
+ elif ch == '"':
92
+ in_str = True
93
+ elif ch in "[{":
94
+ depth += 1
95
+ if depth > JSON_DEPTH_MAX:
96
+ raise ValueError("JSON nested deeper than %d" % JSON_DEPTH_MAX)
97
+ elif ch in "]}":
98
+ depth -= 1
99
+ return json.loads(text)
100
+
66
101
 
67
102
  def dialog_policy(root):
68
103
  """.ai/policies/dialog.json when it says enabled: true, else None."""
69
104
  try:
70
105
  with open(os.path.join(root, ".ai", "policies", "dialog.json"), encoding="utf-8") as fh:
71
- data = json.load(fh)
72
- except (OSError, ValueError, TypeError):
106
+ data = json_loads_capped(fh.read())
107
+ except (OSError, ValueError, TypeError, RecursionError):
73
108
  return None
74
109
  if not isinstance(data, dict) or data.get("enabled") is not True:
75
110
  return None
@@ -79,7 +114,29 @@ def dialog_policy(root):
79
114
  value = value if isinstance(value, int) and not isinstance(value, bool) else default
80
115
  return min(high, max(low, value))
81
116
  return {"window_seconds": bounded("window_seconds", *WINDOW),
82
- "stop_blocks_max": bounded("stop_blocks_max", STOP_BLOCKS_MAX, 0, STOP_BLOCKS_MAX)}
117
+ "stop_blocks_max": bounded("stop_blocks_max", STOP_BLOCKS_MAX, 0, STOP_BLOCKS_MAX),
118
+ "background_sessions": data.get("background_sessions") is True}
119
+
120
+
121
+ def ai_unattended():
122
+ """AI_UNATTENDED is set when it is non-empty and not 0, false or no (case
123
+ and blanks ignored): one rule in every reader (risk 0587)."""
124
+ return os.environ.get("AI_UNATTENDED", "").strip().lower() not in ("", "0", "false", "no")
125
+
126
+
127
+ def unattended(policy):
128
+ """True when the run is declared unattended: AI_UNATTENDED always; the
129
+ runtime's own marker (CLAUDE_CODE_SESSION_ATTENDED=0: a background job,
130
+ `claude -p`, the SDK) unless the project opts in with background_sessions:
131
+ true, for background jobs of the CLI a human is still chatting in. The
132
+ opt-in never covers an SDK entrypoint (CLAUDE_CODE_ENTRYPOINT sdk-*:
133
+ `claude -p`, an SDK host — nobody there answers the dialog; risk ffae).
134
+ state.py's dialog_on() keeps the same rule."""
135
+ if ai_unattended():
136
+ return True
137
+ background = policy.get("background_sessions") and \
138
+ not os.environ.get("CLAUDE_CODE_ENTRYPOINT", "").startswith("sdk-")
139
+ return os.environ.get("CLAUDE_CODE_SESSION_ATTENDED") == "0" and not background
83
140
 
84
141
 
85
142
  def runtime_home(runtime):
@@ -94,8 +151,8 @@ def dialog_tool(runtime):
94
151
  Only an installer from 7b on writes dialog.tool, always under riskrails/."""
95
152
  try:
96
153
  with open(os.path.join(runtime_home(runtime), "riskrails", "profile.json"), encoding="utf-8") as fh:
97
- tool = (json.load(fh).get("dialog") or {}).get("tool")
98
- except (OSError, ValueError, AttributeError):
154
+ tool = (json_loads_capped(fh.read()).get("dialog") or {}).get("tool")
155
+ except (OSError, ValueError, AttributeError, RecursionError):
99
156
  return None
100
157
  return tool if runtime in DIALOG_TOOL and tool == DIALOG_TOOL[runtime] else None
101
158
 
@@ -111,18 +168,28 @@ def payload_runtime(payload, hook_file):
111
168
  return "codex" if "%s.codex%s" % (os.sep, os.sep) in os.path.abspath(hook_file) else "claude"
112
169
 
113
170
 
171
+ def switch_state(root, runtime, payload):
172
+ """"off", "closed" or "on". Off is today's route: no root, the project's
173
+ switch not on, AI_UNATTENDED or the runtime's unattended marker set, or no
174
+ verified dialog tool. Closed: the switch is on but this call cannot be bound
175
+ to a session that can answer a dialog: a payload that is not an object, a
176
+ subagent (agent_id), no session id, or a session id other than the one the
177
+ hook's own environment carries. On: otherwise."""
178
+ policy = dialog_policy(root) if root else None
179
+ if policy is None or unattended(policy):
180
+ return "off"
181
+ if dialog_tool(runtime) is None:
182
+ return "off"
183
+ if not isinstance(payload, dict) or payload.get("agent_id") or not payload.get("session_id"):
184
+ return "closed"
185
+ own = os.environ.get(SESSION_ENV.get(runtime, ""))
186
+ return "closed" if own and own != payload.get("session_id") else "on"
187
+
188
+
114
189
  def effective_switch(root, runtime, payload):
115
- """On only when the project says so, nobody declared the run unattended,
116
- the runtime's dialog tool is the verified one, this is the main session
117
- (no agent_id: a subagent cannot answer a dialog) and the payload has a
118
- session id to bind a pick to. Every other combination is today's route."""
119
- if not root or dialog_policy(root) is None:
120
- return False
121
- if os.environ.get("AI_UNATTENDED") or os.environ.get("CLAUDE_CODE_SESSION_ATTENDED") == "0":
122
- return False
123
- if dialog_tool(runtime) is None or not isinstance(payload, dict):
124
- return False
125
- return not payload.get("agent_id") and bool(payload.get("session_id"))
190
+ """True only when switch_state() is "on"; "off" and "closed" are both no
191
+ dialog, no decision and no ledger write for every caller."""
192
+ return switch_state(root, runtime, payload) == "on"
126
193
 
127
194
 
128
195
  # -- labels -------------------------------------------------------------------
@@ -180,7 +247,7 @@ def read_decisions(root):
180
247
  try:
181
248
  with open(os.path.join(root, ".ai", "state", "decision.json"), encoding="utf-8") as fh:
182
249
  data = json.load(fh)
183
- except (OSError, ValueError, TypeError):
250
+ except (OSError, ValueError, TypeError, RecursionError):
184
251
  return []
185
252
  opened = data.get("open") if isinstance(data, dict) else None
186
253
  return [d for d in opened if isinstance(d, dict) and d.get("id")] if isinstance(opened, list) else []
@@ -198,6 +265,18 @@ def decision_parts(decision):
198
265
  return [(options, bool(decision.get("multi_select")))]
199
266
 
200
267
 
268
+ QUESTION_MAX = 1200 # state.py's
269
+
270
+
271
+ def part_question(question, i, n):
272
+ """state.py's part_question: part i of n of a split decision is
273
+ "<question> (i of n)", the question cut so the whole fits QUESTION_MAX."""
274
+ if n < 2:
275
+ return question
276
+ suffix = " (%d of %d)" % (i, n)
277
+ return (question or "")[:QUESTION_MAX - len(suffix)] + suffix
278
+
279
+
201
280
  def offered(decisions, runtime):
202
281
  """What a dialog call for these decisions carries, question by question:
203
282
  (decision, options, the question as sent) — state.py's dialog_payload."""
@@ -205,7 +284,8 @@ def offered(decisions, runtime):
205
284
  for decision in decisions:
206
285
  parts = decision_parts(decision)
207
286
  for i, (options, multi) in enumerate(parts, 1):
208
- item = {"question": decision.get("question"), "header": decision.get("header"),
287
+ item = {"question": part_question(decision.get("question"), i, len(parts)),
288
+ "header": decision.get("header"),
209
289
  "options": [{"label": o.get("label"), "description": o.get("description") or (
210
290
  "the session runs exactly this, once" if o.get("kind") == "command" else "nothing runs")}
211
291
  for o in options]}
@@ -248,14 +328,14 @@ def adapt_response(runtime, payload):
248
328
 
249
329
  Claude: answers keyed by question text, a multi-select's labels joined
250
330
  with ", "; two questions with one text cannot be told apart, so both read
251
- as unanswerable. Codex: tool_response is a JSON string,
331
+ as unanswerable (a split decision's parts are numbered "(i of n)" to differ). Codex: tool_response is a JSON string,
252
332
  answers[<id>].answers is a list; "None of the above" and "user_note: …"
253
333
  are items like any other, and match no label."""
254
334
  questions = response_questions(runtime, payload)
255
335
  if runtime == "codex":
256
336
  try:
257
337
  answers = json.loads(payload.get("tool_response") or "").get("answers")
258
- except (ValueError, TypeError, AttributeError):
338
+ except (ValueError, TypeError, AttributeError, RecursionError):
259
339
  answers = None
260
340
  out = []
261
341
  for q in questions:
@@ -332,7 +412,7 @@ def write_line(root, data):
332
412
  os.close(fd)
333
413
 
334
414
 
335
- def read_ledger(root, path=None, types=LEDGER_TYPES):
415
+ def read_ledger(root, path=None, types=LEDGER_TYPES): # pylint: disable=dangerous-default-value # read-only: `types` is only tested with `in`, never mutated
336
416
  """Every well-formed line of the given types, in order; a torn or foreign
337
417
  line is skipped, so a damaged ledger shows fewer picks, never more."""
338
418
  entries = []
@@ -341,7 +421,7 @@ def read_ledger(root, path=None, types=LEDGER_TYPES):
341
421
  for raw in fh:
342
422
  try:
343
423
  entry = json.loads(raw)
344
- except ValueError:
424
+ except (ValueError, RecursionError):
345
425
  continue
346
426
  if isinstance(entry, dict) and entry.get("v") == 1 and entry.get("type") in types:
347
427
  entries.append(entry)
@@ -32,6 +32,10 @@ WHY_CONSENT = ("\n\nWith the dialog on, a commit or push runs only when the huma
32
32
  "send the dialog it prints, then run the picked command verbatim, once. See\n"
33
33
  ".ai/policies/safety.md.")
34
34
 
35
+ WHY_CONSENT_CLOSED = ("\n\nWith the dialog on, a commit or push runs only when the human picked it in the\n"
36
+ "dialog, and only a session that can answer the dialog can have a pick. See\n"
37
+ ".ai/policies/safety.md.")
38
+
35
39
  SPACE = "[[:space:]]"
36
40
  CHEAP_RE = ("(^|[|;&[:space:]])(git|gh|argocd|helm|kubectl|terraform|dep|deployer|cap|flyctl|fly|"
37
41
  "vercel|netlify|npm)([[:space:]]|$)|/npm([[:space:]]|$)")
@@ -257,14 +261,19 @@ class GitGuard:
257
261
  raw = values[0] if len(values) == 1 and isinstance(values[0], dict) else None
258
262
  hook_file = os.path.join(os.fsdecode(b_bytes(self.ctx.hook_dir)), "ai-guard.py")
259
263
  try:
260
- on = raw is not None and dialog.effective_switch(root, dialog.payload_runtime(raw, hook_file), raw)
264
+ state = dialog.switch_state(root, dialog.payload_runtime(raw, hook_file), raw)
261
265
  except Exception: # pylint: disable=broad-exception-caught
262
- on = False
263
- if not on:
266
+ state = "closed"
267
+ if state == "off":
264
268
  return
265
269
  command = b_bytes(cmd).decode("utf-8", "replace").strip()
266
270
  if not dialog.needs_consent(command):
267
271
  return
272
+ if state == "closed":
273
+ raise Deny(text_to_b("CONSENT_REQUIRED — this session cannot answer the dialog (a subagent, a "
274
+ "call with no session id, a session id other than the hook's own, or an "
275
+ "unreadable call). "
276
+ "The human runs this command in their own terminal.") + WHY_CONSENT_CLOSED)
268
277
  spent = getattr(self.ctx, "consent", None)
269
278
  if spent and spent.get("label") == command:
270
279
  return