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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +29 -15
- package/ai/policies/model-routing.md +7 -7
- package/ai/policies/production.md +3 -3
- package/ai/policies/release.md +2 -2
- package/ai/policies/review-economy.md +27 -30
- package/bin/riskrails.js +114 -23
- package/hooks/ai-path-guard-defaults.json +3 -2
- package/hooks/context-guard.py +15 -12
- package/hooks/dialog-gate.py +1 -1
- package/hooks/guard/common.py +2 -0
- package/hooks/guard/dialog.py +107 -27
- package/hooks/guard/git.py +12 -3
- package/hooks/guard/paths.py +150 -37
- package/hooks/project-scaffold.py +3 -6
- package/hooks/runtime-gate.py +134 -33
- package/hooks/update_notice.py +22 -16
- package/instructions/routing.md +1 -1
- package/instructions/stub.md +3 -3
- package/package.json +1 -1
- package/skills/AGENTS.md +1 -1
- package/skills/CLAUDE.md +1 -1
- package/skills/ai-init/templates/.ai/AGENTS.md +1 -1
- package/skills/ai-init/templates/.ai/VERSION +1 -1
- package/skills/ai-init/templates/.ai/policies/security.md +7 -1
- package/skills/ai-init/templates/.ai/policies/testing.md +19 -6
- package/skills/ai-init/templates/.ai/state/README.md +4 -4
- package/skills/ai-init/templates/AGENTS.block.md +2 -2
- package/skills/ai-init/templates/CLAUDE.block.md +2 -2
- package/skills/ai-init/templates/GEMINI.block.md +2 -2
- package/skills/ai-init/templates/gitignore.snippet +1 -0
- package/skills/ai-init/templates/junie-guidelines.block.md +2 -2
- 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 +37 -4
- package/skills/ai-task/state.py +24 -5138
- package/skills/project-init/templates/constitution.md +1 -1
- package/skills/project-init/templates/sdlc-README.md +20 -14
- package/skills/project-update/adopt.py +38 -24
- package/skills/project-update/history/blobs/00289f98434fed230eebe768dfb3425f83d737866a3aebc97aa95167c4fa0c6e +131 -0
- package/skills/project-update/history/blobs/079a91f976ad69b5cf49ccb9a28849576e4735ddd9485fc26599e528a69fd2f4 +30 -0
- package/skills/project-update/history/blobs/07ff7fa6f93d471ff533f553b4e523e4105ccde1f3dab70132c5237728f68422 +13 -0
- package/skills/project-update/history/blobs/0ef4a360c76da290173dea0f8073949cb0f8113710a38df95acae5d96bdd1286 +123 -0
- package/skills/project-update/history/blobs/18c7a2753ce9cb6f6d3d1e9d5d1125d75950b879d56b7325225b200869631b29 +15 -0
- package/skills/project-update/history/blobs/25d4f2a86deb5e2574bb3210b67bb24fcc4afb19f93a7b65a057daa874a9d18e +1 -0
- package/skills/project-update/history/blobs/48d9950f16e31d55475f566a80dc95f64b35f1c804d2f51729e313a4f29bac8d +13 -0
- package/skills/project-update/history/blobs/5a93f78b4ed4d416c171139b2ce610a117f9c1dd46fbb56ca86729cb4c53d10c +6 -0
- package/skills/project-update/history/blobs/6a3be0e624e5ad91e44d08857b7183ee53b7903e1e8d56ff196dbc9dde271e4a +75 -0
- package/skills/project-update/history/blobs/7170553410bba904ffb7edb4b231e3b3579b85542d84de717299980938f82c9d +13 -0
- package/skills/project-update/history/blobs/774b1e3a04ef0d49d33c44a2cfecd92eb6f6b738a12b6a681103ba7fc13e7b86 +67 -0
- package/skills/project-update/history/blobs/7f063e79195a5c85c37b7260dd20c184d3b9bea85e37202e0bbca73ad84bba49 +13 -0
- package/skills/project-update/history/blobs/a51517e13e57420d2d88a6bcfb4055d762d4a0d10a8f88450929c5c4572213a9 +67 -0
- package/skills/project-update/history/blobs/c8573ca1f810ce6c4ac49456a4c1725b93e0d3b74c54ce95d46d8747777895f0 +13 -0
- package/skills/project-update/history/blobs/d6e5b34d475e7187d2a8cb2aac7e7f1a427142baf6461bda387d70ceb4d8a8c1 +30 -0
- package/skills/project-update/history/blobs/e120ff4e32b2e950fc94ada4e787d57dc9c790da37223e5a202d74f7e3aadf80 +13 -0
- package/skills/project-update/history/index.json +38 -16
- 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/0011_retire_risk_tiers.py +37 -0
- package/skills/project-update/migrations/README.md +16 -1
- package/skills/project-update/migrations/__init__.py +6 -1
- package/skills/project-update/render_instructions.py +1 -1
- package/skills/project-update/update.py +569 -106
- package/skills/rr-build-all/SKILL.md +2 -2
- package/skills/rr-plan/SKILL.md +3 -1
- package/skills/rr-review/SKILL.md +13 -8
- package/skills/rr-setup/SKILL.md +24 -4
- package/skills/rr-setup/setup.py +7 -1
- package/skills/rr-spec/SKILL.md +4 -2
- package/skills/rr-status/status.py +36 -9
- package/skills/usage-report/usage-report.py +4 -2
- package/skills/ai-init/templates/.ai/policies/risk-tiers.json +0 -628
- package/skills/ai-init/templates/.ai/policies/risk-tiers.md +0 -180
package/hooks/context-guard.py
CHANGED
|
@@ -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: /
|
|
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():
|
package/hooks/dialog-gate.py
CHANGED
|
@@ -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
|
|
package/hooks/guard/common.py
CHANGED
|
@@ -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:
|
package/hooks/guard/dialog.py
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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 =
|
|
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 = (
|
|
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
|
-
"""
|
|
116
|
-
|
|
117
|
-
(
|
|
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"),
|
|
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)
|
package/hooks/guard/git.py
CHANGED
|
@@ -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
|
-
|
|
264
|
+
state = dialog.switch_state(root, dialog.payload_runtime(raw, hook_file), raw)
|
|
261
265
|
except Exception: # pylint: disable=broad-exception-caught
|
|
262
|
-
|
|
263
|
-
if
|
|
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
|