riskrails 3.0.0 → 3.1.0
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 +2 -2
- package/README.md +259 -812
- package/agents/ai-reviewer.md.tmpl +6 -4
- 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 +34 -32
- package/ai/using-riskrails.md +15 -0
- package/bin/claude-1m +15 -14
- package/bin/riskrails.js +113 -37
- package/codex/hooks.json +25 -2
- package/hooks/AGENTS.md +2 -2
- package/hooks/CLAUDE.md +2 -2
- package/hooks/ai-guard.py +29 -8
- package/hooks/ai-path-guard-defaults.json +3 -1
- package/hooks/codex-hooks.json +25 -2
- package/hooks/context-guard.py +252 -36
- package/hooks/dialog-gate.py +105 -0
- package/hooks/guard/common.py +7 -0
- package/hooks/guard/decision.py +123 -0
- package/hooks/guard/dialog.py +598 -0
- package/hooks/guard/git.py +49 -3
- package/hooks/guard/paths.py +89 -19
- package/hooks/hooks.json +37 -2
- package/hooks/memory-pre-push.py +28 -0
- package/hooks/project-scaffold.py +1 -5
- package/hooks/runtime-gate.py +65 -18
- package/hooks/update_notice.py +141 -0
- package/instructions/routing.md +4 -4
- package/instructions/stub.md +3 -3
- package/package.json +1 -1
- package/profiles/max.json +3 -1
- package/profiles/pro.json +3 -1
- package/scripts/merge-codex-config.py +134 -0
- package/scripts/resolve-profile.py +15 -3
- package/settings.common.json +39 -2
- package/skills/AGENTS.md +3 -3
- package/skills/CLAUDE.md +3 -3
- package/skills/ai-init/templates/.ai/AGENTS.md +3 -3
- package/skills/ai-init/templates/.ai/VERSION +1 -1
- package/skills/ai-init/templates/.ai/policies/dialog.json +1 -0
- package/skills/ai-init/templates/.ai/policies/safety.md +22 -2
- 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/policies/weight.json +1 -0
- package/skills/ai-init/templates/.ai/policies/weight.md +2 -2
- package/skills/ai-init/templates/.ai/state/README.md +9 -6
- 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/memory_compact.py +968 -0
- package/skills/ai-task/memory_format.py +775 -0
- package/skills/ai-task/memory_store.py +528 -0
- package/skills/ai-task/memory_sync.py +1571 -0
- package/skills/ai-task/sensors.py +25 -5
- package/skills/ai-task/state.py +999 -79
- package/skills/ai-task/weight.py +3 -3
- package/skills/project-init/templates/constitution.md +1 -1
- package/skills/project-init/templates/sdlc-README.md +20 -14
- 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/164cd34c182de88c33de7df35beb29200d792419e1c13a5f0b2104f944e57be7 +1 -0
- package/skills/project-update/history/blobs/18c7a2753ce9cb6f6d3d1e9d5d1125d75950b879d56b7325225b200869631b29 +15 -0
- package/skills/project-update/history/blobs/1d1d907449ec74594d0ee923ac142ae746b9aedf0a1eda9469f075a44d23e3d7 +13 -0
- package/skills/project-update/history/blobs/23a42febbacaa7b52a802d428033a99c0694391c81d51fe3b6e1dff608a641d5 +67 -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/5353ca529c46f2443e12947ec205a6887fb594a4de1f87f0eb1c0e6c0f826133 +49 -0
- package/skills/project-update/history/blobs/5a93f78b4ed4d416c171139b2ce610a117f9c1dd46fbb56ca86729cb4c53d10c +6 -0
- package/skills/project-update/history/blobs/5f65bc7ab3fc3c4e75ae05d49eaf4331fafc398c661abe33c69f7380a720344c +32 -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/7be527cd648b27cca6e267de30f8e33a2ce17d5631a82694f23ae5c0623efe1f +15 -0
- package/skills/project-update/history/blobs/7ee99edafe7cec45ff577376c0d0178fdb2690134d4656488f46e129aba3cc5a +67 -0
- package/skills/project-update/history/blobs/7f063e79195a5c85c37b7260dd20c184d3b9bea85e37202e0bbca73ad84bba49 +13 -0
- package/skills/project-update/history/blobs/8b3ef9586491a426767c01da5d01bdd6b0655c576c68c4cc6dfb093367817dc0 +67 -0
- package/skills/project-update/history/blobs/917df3320d778ddbaa5c5c7742bc4046bf803c36ed2b050f30844ed206783469 +1 -0
- package/skills/project-update/history/blobs/935af1019931058ec3a0b834fe230e127ec914f75edcc4a6c589cfe733b0b979 +13 -0
- package/skills/project-update/history/blobs/ae5ce131d6bae401199ff32d8c3dc4d8aec188010a9111624ac64506e2116ff4 +84 -0
- package/skills/project-update/history/blobs/c8573ca1f810ce6c4ac49456a4c1725b93e0d3b74c54ce95d46d8747777895f0 +13 -0
- package/skills/project-update/history/blobs/ca4499d829268c8766fb3b825a515e88dc55c237e9d202816b50959b4dac031f +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/blobs/e9adb7a9905eb719ac78ab96671f055da53e5001810f5ad6d6c94306b0323fe7 +74 -0
- package/skills/project-update/history/blobs/fae3064e648cdc55be4aeb1177525b302a5e34ebd752e4a904f47dc0f4557b96 +71 -0
- package/skills/project-update/history/index.json +60 -20
- package/skills/project-update/migrations/0009_state_file.py +25 -0
- package/skills/project-update/migrations/0010_dialog_switch.py +30 -0
- package/skills/project-update/migrations/0011_retire_risk_tiers.py +37 -0
- package/skills/project-update/migrations/README.md +2 -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 +385 -89
- package/skills/rr-build/SKILL.md +14 -5
- package/skills/rr-build-all/SKILL.md +190 -0
- package/skills/rr-plan/SKILL.md +16 -4
- package/skills/rr-review/SKILL.md +24 -9
- package/skills/rr-setup/SKILL.md +15 -2
- package/skills/rr-setup/setup.py +7 -1
- package/skills/rr-ship/SKILL.md +16 -8
- package/skills/rr-spec/SKILL.md +4 -2
- package/skills/rr-status/SKILL.md +2 -2
- package/skills/usage-report/usage-report.py +45 -10
- 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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Context guard: keep long sessions short, and keep what matters across compaction.
|
|
3
3
|
|
|
4
|
-
One script,
|
|
4
|
+
One script, four Claude Code events:
|
|
5
5
|
|
|
6
6
|
UserPromptSubmit
|
|
7
7
|
Reads the session's current context size from the transcript (the last
|
|
@@ -11,14 +11,19 @@ UserPromptSubmit
|
|
|
11
11
|
block threshold it holds the prompt back once: sending the same prompt again
|
|
12
12
|
lets it through, so the guard can be overridden but not ignored.
|
|
13
13
|
|
|
14
|
-
Both thresholds follow the
|
|
15
|
-
fires about 33k tokens under
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
14
|
+
Both thresholds follow the window compaction runs against. Auto-compaction
|
|
15
|
+
fires about 33k tokens under it (measured over 133 automatic compactions: a
|
|
16
|
+
150 000 window compacts at 115–125k, 117k most often), and Claude Code caps
|
|
17
|
+
that window at the model's own: 200k, or 1M for a [1m] model. The window is
|
|
18
|
+
CLAUDE_CODE_AUTO_COMPACT_WINDOW, else settings.json's `autoCompactWindow`, else
|
|
19
|
+
the economy window in <home>/riskrails/profile.json (`context`, written by
|
|
20
|
+
`--context economy`), else the model's own; on Codex the first two, Claude Code's
|
|
21
|
+
own, are skipped. The guard warns at 80% of that
|
|
22
|
+
point. It blocks at 120% of it — only reached when auto-compaction is off or did
|
|
23
|
+
not run — and only when a window was configured: in the default mode the runtime
|
|
24
|
+
compacts on its own and the guard never holds a prompt back. An 800 000 window
|
|
25
|
+
therefore means, on a 200k model, compaction near 167k and a warning from 133k;
|
|
26
|
+
on a [1m] model, compaction near 767k, a warning from 613k and a block from 920k.
|
|
22
27
|
|
|
23
28
|
PreCompact
|
|
24
29
|
Writes a deterministic snapshot of the session — files edited, the latest
|
|
@@ -37,6 +42,26 @@ SessionStart
|
|
|
37
42
|
it: the snapshot belongs to the compaction it was written for, and a startup or a
|
|
38
43
|
/clear simply finds nothing to put back.
|
|
39
44
|
|
|
45
|
+
It also asks state.py for the plan drift (R36.18): `profile detect --open --json`
|
|
46
|
+
with the session id handed on. A drift opens the plan_drift decision (dialog on) or
|
|
47
|
+
prints the installer command (dialog off, or no .ai/); the account file's mtime is
|
|
48
|
+
recorded in session.json once detect has answered.
|
|
49
|
+
|
|
50
|
+
On a plain startup it also says when npm has a newer `riskrails` than the installed
|
|
51
|
+
plugin, with the update command (update_notice.py). The hook reads a one-file cache
|
|
52
|
+
and never waits for the network: a stale cache starts a detached refresh, so the
|
|
53
|
+
notice appears from the session after the one that found the release. Not repeated
|
|
54
|
+
on resume, /clear or compact.
|
|
55
|
+
|
|
56
|
+
Notification
|
|
57
|
+
auth_success only (the login may have changed the plan): the same detect, which opens
|
|
58
|
+
the decision and prints nothing. The recorded mtime is left alone, so the next
|
|
59
|
+
UserPromptSubmit asks again and carries the dialog JSON.
|
|
60
|
+
|
|
61
|
+
UserPromptSubmit also compares the account file's mtime (~/.claude.json, Codex's
|
|
62
|
+
auth.json) with the one recorded in session.json and, when it moved, runs the drift
|
|
63
|
+
check once for that mtime.
|
|
64
|
+
|
|
40
65
|
Both runtimes: the script derives which one it serves from its own location, and no
|
|
41
66
|
decision here depends on a payload key. The transcript-derived snapshot stays
|
|
42
67
|
Claude-only — it is built from a Claude transcript and the rollout format differs —
|
|
@@ -49,6 +74,7 @@ Environment:
|
|
|
49
74
|
AI_CONTEXT_GUARD_STATE state directory, default ~/.claude/state/context-guard
|
|
50
75
|
AI_HOOK_RUNTIME claude|codex, for tests: normally the script's own path decides
|
|
51
76
|
AI_HANDOFF_NO_PROMPT 1 keeps the user's prompt out of .ai/state/session.json
|
|
77
|
+
RISKRAILS_NO_UPDATE_CHECK non-empty turns the SessionStart update notice off (CI does too)
|
|
52
78
|
"""
|
|
53
79
|
import hashlib
|
|
54
80
|
import json
|
|
@@ -71,7 +97,7 @@ FILES_KEPT = 40
|
|
|
71
97
|
STATUS_LINES = 40
|
|
72
98
|
EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
|
|
73
99
|
|
|
74
|
-
|
|
100
|
+
ECONOMY_WINDOW = 800000 # profiles/max.json riskrails.economy; Codex economy uses it too
|
|
75
101
|
MODEL_WINDOW, MODEL_WINDOW_1M = 200000, 1000000
|
|
76
102
|
COMPACT_RESERVE = 33000 # auto-compaction fires this far under the window
|
|
77
103
|
WARN_PCT, BLOCK_PCT = 80, 120 # of the point where auto-compaction fires
|
|
@@ -122,25 +148,59 @@ def model_window(session_id, ctx):
|
|
|
122
148
|
return MODEL_WINDOW
|
|
123
149
|
|
|
124
150
|
|
|
151
|
+
def profile_context():
|
|
152
|
+
"""profile.json's `context` ({mode, window}) in this runtime's home; {} when absent."""
|
|
153
|
+
home = config_dir()
|
|
154
|
+
if hook_runtime() == "codex":
|
|
155
|
+
home = os.environ.get("CODEX_HOME") or os.path.expanduser("~/.codex")
|
|
156
|
+
try:
|
|
157
|
+
with open(os.path.join(home, "riskrails", "profile.json"), encoding="utf-8") as fh:
|
|
158
|
+
context = json.load(fh).get("context")
|
|
159
|
+
return context if isinstance(context, dict) else {}
|
|
160
|
+
except (OSError, ValueError, AttributeError):
|
|
161
|
+
return {}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def configured_window():
|
|
165
|
+
"""The window someone set — the env var, settings.json, or the economy profile — or 0.
|
|
166
|
+
The first two are Claude Code's own and never decide for Codex, whose only source is
|
|
167
|
+
its profile.json; economy without a window of its own (a Codex profile) keeps the 800k
|
|
168
|
+
it always had."""
|
|
169
|
+
if hook_runtime() != "codex":
|
|
170
|
+
for window in (positive_int(os.environ.get("CLAUDE_CODE_AUTO_COMPACT_WINDOW")),
|
|
171
|
+
positive_int(user_settings().get("autoCompactWindow"))):
|
|
172
|
+
if window > COMPACT_RESERVE:
|
|
173
|
+
return window
|
|
174
|
+
context = profile_context()
|
|
175
|
+
if context.get("mode") == "economy":
|
|
176
|
+
window = positive_int(context.get("window"))
|
|
177
|
+
return window if window > COMPACT_RESERVE else ECONOMY_WINDOW
|
|
178
|
+
return 0
|
|
179
|
+
|
|
180
|
+
|
|
125
181
|
def compact_window(session_id, ctx):
|
|
126
|
-
"""The window
|
|
127
|
-
Claude Code caps
|
|
128
|
-
so one 800k setting compacts a 1M session near 767k and a 200k
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
window = window if window > COMPACT_RESERVE else DEFAULT_WINDOW
|
|
133
|
-
return min(window, model_window(session_id, ctx))
|
|
182
|
+
"""The window the runtime compacts against: a configured one, else the model's own.
|
|
183
|
+
Claude Code caps a configured window at the model's (\"capped to … by model\" in
|
|
184
|
+
/autocompact), so one 800k setting compacts a 1M session near 767k and a 200k
|
|
185
|
+
session near 167k."""
|
|
186
|
+
model = model_window(session_id, ctx)
|
|
187
|
+
return min(configured_window() or model, model)
|
|
134
188
|
|
|
135
189
|
|
|
136
190
|
def thresholds(session_id, ctx):
|
|
191
|
+
"""Warn and block, in tokens; 0 is off. With no window configured the runtime
|
|
192
|
+
compacts on its own: the guard warns and blocks only when AI_CONTEXT_BLOCK_TOKENS
|
|
193
|
+
asks it to."""
|
|
137
194
|
fires_at = compact_window(session_id, ctx) - COMPACT_RESERVE
|
|
195
|
+
blocks = bool(configured_window())
|
|
138
196
|
|
|
139
|
-
def one(name, pct):
|
|
197
|
+
def one(name, pct, on=True):
|
|
140
198
|
raw = os.environ.get(name, "").strip()
|
|
141
|
-
|
|
199
|
+
if raw.lstrip("-").isdigit():
|
|
200
|
+
return positive_int(raw)
|
|
201
|
+
return fires_at * pct // 100 if on else 0
|
|
142
202
|
|
|
143
|
-
return one("AI_CONTEXT_WARN_TOKENS", WARN_PCT), one("AI_CONTEXT_BLOCK_TOKENS", BLOCK_PCT)
|
|
203
|
+
return one("AI_CONTEXT_WARN_TOKENS", WARN_PCT), one("AI_CONTEXT_BLOCK_TOKENS", BLOCK_PCT, blocks)
|
|
144
204
|
|
|
145
205
|
|
|
146
206
|
def state_dir():
|
|
@@ -267,9 +327,9 @@ def scan_session(path):
|
|
|
267
327
|
return files, prompts[-PROMPTS_KEPT:], todos
|
|
268
328
|
|
|
269
329
|
|
|
270
|
-
def run(cmd, cwd):
|
|
330
|
+
def run(cmd, cwd, env=None):
|
|
271
331
|
try:
|
|
272
|
-
out = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=3, check=False)
|
|
332
|
+
out = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=3, check=False, env=env)
|
|
273
333
|
return out.stdout.strip() if out.returncode == 0 else ""
|
|
274
334
|
except (OSError, subprocess.SubprocessError):
|
|
275
335
|
return ""
|
|
@@ -366,14 +426,97 @@ def write_session(root, updates):
|
|
|
366
426
|
pass
|
|
367
427
|
|
|
368
428
|
|
|
369
|
-
def
|
|
429
|
+
def prompt_count(root):
|
|
430
|
+
"""session.json's count of human turns; 0 when there is none yet."""
|
|
431
|
+
try:
|
|
432
|
+
with open(os.path.join(root, ".ai", "state", "session.json"), encoding="utf-8") as fh:
|
|
433
|
+
count = json.load(fh).get("prompt_count")
|
|
434
|
+
except (OSError, ValueError, AttributeError):
|
|
435
|
+
return 0
|
|
436
|
+
return count if isinstance(count, int) and not isinstance(count, bool) else 0
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def state_py(root, *args, session=None):
|
|
370
440
|
"""state.py is the only thing that renders a handoff or reads the questions.
|
|
371
441
|
Three seconds inside a fifteen-second hook, and an empty string on anything
|
|
372
|
-
at all going wrong: the guard fails open.
|
|
442
|
+
at all going wrong: the guard fails open. A session id is handed on as the
|
|
443
|
+
runtime's own variable, which is what state.py binds a dialog to."""
|
|
373
444
|
if not os.path.exists(STATE_PY):
|
|
374
445
|
return ""
|
|
446
|
+
env = None
|
|
447
|
+
if session:
|
|
448
|
+
env = dict(os.environ, **{"CLAUDE_CODE_SESSION_ID" if hook_runtime() == "claude"
|
|
449
|
+
else "CODEX_THREAD_ID": session})
|
|
375
450
|
return run([sys.executable or "python3", STATE_PY, "--root", root,
|
|
376
|
-
"--runtime", hook_runtime(), *args], root)
|
|
451
|
+
"--runtime", hook_runtime(), *args], root, env)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
# ------------------------------------------------------------------ plan drift
|
|
455
|
+
|
|
456
|
+
def account_file():
|
|
457
|
+
"""The file whose change means the account changed (R36.18)."""
|
|
458
|
+
if hook_runtime() == "codex":
|
|
459
|
+
return os.path.join(os.environ.get("CODEX_HOME") or os.path.expanduser("~/.codex"), "auth.json")
|
|
460
|
+
return os.path.join(os.environ.get("HOME") or os.path.expanduser("~"), ".claude.json")
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def account_mtime():
|
|
464
|
+
try:
|
|
465
|
+
return os.path.getmtime(account_file())
|
|
466
|
+
except OSError:
|
|
467
|
+
return None
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def recorded_mtime(root):
|
|
471
|
+
try:
|
|
472
|
+
with open(os.path.join(root, ".ai", "state", "session.json"), encoding="utf-8") as fh:
|
|
473
|
+
return json.load(fh).get("account_mtime")
|
|
474
|
+
except (OSError, ValueError, AttributeError):
|
|
475
|
+
return None
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def detect_drift(root, cwd, session, open_it=True, mtime=None):
|
|
479
|
+
"""`state.py profile detect --open --json`, parsed; None unless it says drift.
|
|
480
|
+
Without .ai/ there is nowhere to open a decision: the same call, read-only.
|
|
481
|
+
`mtime`, when given, is recorded in session.json only once detect printed JSON."""
|
|
482
|
+
if root:
|
|
483
|
+
raw = state_py(root, "profile", "detect", *(("--open",) if open_it else ()), "--json", session=session)
|
|
484
|
+
elif os.path.exists(STATE_PY):
|
|
485
|
+
raw = run([sys.executable or "python3", STATE_PY, "--runtime", hook_runtime(), "profile", "detect",
|
|
486
|
+
"--json"], cwd or os.getcwd())
|
|
487
|
+
else:
|
|
488
|
+
raw = ""
|
|
489
|
+
try:
|
|
490
|
+
found = json.loads(raw)
|
|
491
|
+
except ValueError:
|
|
492
|
+
return None
|
|
493
|
+
if root and mtime is not None and isinstance(found, dict):
|
|
494
|
+
write_session(root, {"account_mtime": mtime})
|
|
495
|
+
return found if isinstance(found, dict) and found.get("status") == "drift" else None
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def drift_notice(root, found, session):
|
|
499
|
+
"""(systemMessage, additionalContext) for one drift. With a decision open
|
|
500
|
+
the context carries the dialog JSON; otherwise the terminal command."""
|
|
501
|
+
installed = (found.get("installed") or {}).get("plan") or "unknown"
|
|
502
|
+
live = (found.get("live") or {}).get("plan") or "unknown"
|
|
503
|
+
names = {"max20": "Max 20x", "max": "Max", "pro": "Pro", "team-pro": "Team Pro", "team-max": "Team Max",
|
|
504
|
+
"plus": "Plus"}
|
|
505
|
+
account = names.get(live, live)
|
|
506
|
+
installed = names.get(installed, installed)
|
|
507
|
+
decision = found.get("decision")
|
|
508
|
+
if decision and root:
|
|
509
|
+
payload = state_py(root, "decision", "show", "--id", decision, "--json", session=session)
|
|
510
|
+
if payload:
|
|
511
|
+
tool = "request_user_input" if hook_runtime() == "codex" else "AskUserQuestion"
|
|
512
|
+
return (f"RiskRails: this account is {account}; RiskRails is installed for {installed}. "
|
|
513
|
+
"The session asks before anything else.",
|
|
514
|
+
f"DIALOG_PENDING {decision} — call {tool} with exactly this JSON, then run the picked "
|
|
515
|
+
f"command verbatim or 'state.py decision settle --id {decision}': {payload}")
|
|
516
|
+
command = found.get("command") or ""
|
|
517
|
+
return (f"RiskRails: this account is {account}; RiskRails is installed for {installed}.",
|
|
518
|
+
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}")
|
|
377
520
|
|
|
378
521
|
|
|
379
522
|
def handoff_block(root, reason):
|
|
@@ -409,7 +552,7 @@ def task_frame(root, reason):
|
|
|
409
552
|
tail.append(f"## Pending questions ({count}) — answer with `state.py answer …` "
|
|
410
553
|
"or fill the file")
|
|
411
554
|
tail.append(prose or ids)
|
|
412
|
-
tail.append("Resume with: /
|
|
555
|
+
tail.append("Resume with: /rr-build --resume")
|
|
413
556
|
if len("\n".join([head] + tail)) > HANDOFF_INJECT_MAX_CHARS and len(tail) > 1:
|
|
414
557
|
tail[1] = ids # the questions collapse to their ids
|
|
415
558
|
return head[:HANDOFF_INJECT_MAX_CHARS], "\n".join(tail)
|
|
@@ -465,6 +608,7 @@ def on_prompt(payload):
|
|
|
465
608
|
transcript = payload.get("transcript_path")
|
|
466
609
|
session = payload.get("session_id") or "unknown"
|
|
467
610
|
root = ai_root(payload.get("cwd"))
|
|
611
|
+
update = None
|
|
468
612
|
if task_in_flight(root):
|
|
469
613
|
# The evidence that a human took a turn — what the gate's file route
|
|
470
614
|
# (R13) reads back. Recorded before any early exit below.
|
|
@@ -472,7 +616,41 @@ def on_prompt(payload):
|
|
|
472
616
|
"last_prompt_at": stamp()}
|
|
473
617
|
if not os.environ.get("AI_HANDOFF_NO_PROMPT"):
|
|
474
618
|
update["last_prompt"] = (payload.get("prompt") or "")[:PROMPT_MAX_CHARS]
|
|
619
|
+
if root and (update is not None or os.path.isfile(os.path.join(root, ".ai", "state", "decision.json"))):
|
|
620
|
+
# R36.13: the human turn dialog-gate.py's Stop counter resets on.
|
|
621
|
+
update = dict(update or {}, prompt_count=prompt_count(root) + 1)
|
|
622
|
+
if update is not None:
|
|
475
623
|
write_session(root, update)
|
|
624
|
+
doc = prompt_warning(payload, session, transcript)
|
|
625
|
+
if (doc or {}).get("decision") != "block":
|
|
626
|
+
doc = with_drift(doc, root, payload, session)
|
|
627
|
+
if doc:
|
|
628
|
+
emit(doc)
|
|
629
|
+
sys.exit(0)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def with_drift(doc, root, payload, session):
|
|
633
|
+
"""R36.18: when the account file's mtime is not the one recorded, ask for the
|
|
634
|
+
drift again and put what it says into the prompt's output. Unchanged mtime,
|
|
635
|
+
no .ai/ or no account file: `doc` as it was."""
|
|
636
|
+
mtime = account_mtime()
|
|
637
|
+
if not root or mtime is None or mtime == recorded_mtime(root):
|
|
638
|
+
return doc
|
|
639
|
+
found = detect_drift(root, payload.get("cwd"), session, mtime=mtime)
|
|
640
|
+
if not found:
|
|
641
|
+
return doc
|
|
642
|
+
message, context = drift_notice(root, found, session)
|
|
643
|
+
doc = dict(doc or {})
|
|
644
|
+
out = dict(doc.get("hookSpecificOutput") or {}, hookEventName="UserPromptSubmit")
|
|
645
|
+
out["additionalContext"] = "\n\n".join(p for p in (context, out.get("additionalContext")) if p)
|
|
646
|
+
doc["hookSpecificOutput"] = out
|
|
647
|
+
doc["systemMessage"] = "\n".join(p for p in (message, doc.get("systemMessage")) if p)
|
|
648
|
+
return doc
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def prompt_warning(payload, session, transcript):
|
|
652
|
+
"""The context-size warning or block for this prompt, as the hook's output
|
|
653
|
+
document, or None."""
|
|
476
654
|
ctx = current_context(transcript) if transcript else None
|
|
477
655
|
marker = state_path(session, "json")
|
|
478
656
|
try:
|
|
@@ -489,7 +667,7 @@ def on_prompt(payload):
|
|
|
489
667
|
if ctx is None or (not warn or ctx < warn) and (not block or ctx < block):
|
|
490
668
|
if state:
|
|
491
669
|
os.remove(marker) # back under the threshold, e.g. after a compaction
|
|
492
|
-
|
|
670
|
+
return None
|
|
493
671
|
|
|
494
672
|
k = round(ctx / 1000)
|
|
495
673
|
if block and ctx >= block:
|
|
@@ -497,20 +675,20 @@ def on_prompt(payload):
|
|
|
497
675
|
if state.get("blocked") != digest:
|
|
498
676
|
state["blocked"] = digest
|
|
499
677
|
save()
|
|
500
|
-
|
|
678
|
+
return ({"decision": "block", "reason": (
|
|
501
679
|
f"Context is {k}k tokens, past the {block // 1000}k limit: every turn re-reads all of it. "
|
|
502
680
|
"Run /compact (or /clear if this is a new task) and send the prompt again. "
|
|
503
681
|
"To go ahead anyway, send the same prompt once more.")})
|
|
504
682
|
state.pop("blocked")
|
|
505
683
|
save()
|
|
506
|
-
|
|
684
|
+
return None # the same prompt a second time: the user has decided
|
|
507
685
|
|
|
508
686
|
band = ctx // 10000
|
|
509
687
|
if band <= state.get("band", -1):
|
|
510
|
-
|
|
688
|
+
return None
|
|
511
689
|
state["band"] = band
|
|
512
690
|
save()
|
|
513
|
-
|
|
691
|
+
return ({
|
|
514
692
|
"systemMessage": f"Context is {k}k tokens (warning from {warn // 1000}k). "
|
|
515
693
|
"/compact keeps the thread, /clear starts a new task.",
|
|
516
694
|
"hookSpecificOutput": {
|
|
@@ -561,8 +739,26 @@ def take_snapshot(session, transcript):
|
|
|
561
739
|
return text
|
|
562
740
|
|
|
563
741
|
|
|
742
|
+
def update_notice_for(payload):
|
|
743
|
+
"""The npm update notice for a startup, as (systemMessage, additionalContext); fails open."""
|
|
744
|
+
if payload.get("source") not in (None, "", "startup"):
|
|
745
|
+
return "", ""
|
|
746
|
+
try:
|
|
747
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
748
|
+
import update_notice # pylint: disable=import-outside-toplevel
|
|
749
|
+
runtime = hook_runtime()
|
|
750
|
+
home = config_dir()
|
|
751
|
+
if runtime == "codex":
|
|
752
|
+
home = os.environ.get("CODEX_HOME") or os.path.expanduser("~/.codex")
|
|
753
|
+
plugin_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
754
|
+
return update_notice.notice(runtime, home, plugin_root, time.time())
|
|
755
|
+
except Exception: # pylint: disable=broad-except
|
|
756
|
+
return "", ""
|
|
757
|
+
|
|
758
|
+
|
|
564
759
|
def on_session_start(payload):
|
|
565
760
|
session = payload.get("session_id") or "unknown"
|
|
761
|
+
drift_session = payload.get("session_id") or None
|
|
566
762
|
model = payload.get("model")
|
|
567
763
|
if isinstance(model, str) and model:
|
|
568
764
|
with open(state_path(session, "model"), "w", encoding="utf-8") as fh:
|
|
@@ -580,11 +776,19 @@ def on_session_start(payload):
|
|
|
580
776
|
snapshot = take_snapshot(session, payload.get("transcript_path"))
|
|
581
777
|
if snapshot:
|
|
582
778
|
head = snapshot # it already carries handoff.md verbatim, at its head
|
|
583
|
-
|
|
779
|
+
# R36.18: a drift between the installed plan and the account's, asked about first.
|
|
780
|
+
mtime = account_mtime()
|
|
781
|
+
found = detect_drift(root, payload.get("cwd"), drift_session, mtime=mtime)
|
|
782
|
+
message, notice = drift_notice(root, found, session) if found else ("", "")
|
|
783
|
+
update, update_context = update_notice_for(payload)
|
|
784
|
+
message = "\n".join(part for part in (message, update) if part)
|
|
785
|
+
text = "\n\n".join(part for part in (notice, head, tail, update_context) if part)
|
|
584
786
|
if not text:
|
|
585
787
|
sys.exit(0)
|
|
586
|
-
|
|
587
|
-
|
|
788
|
+
doc = {"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": text}}
|
|
789
|
+
if message:
|
|
790
|
+
doc = dict({"systemMessage": message}, **doc)
|
|
791
|
+
print(json.dumps(doc))
|
|
588
792
|
sys.stdout.flush()
|
|
589
793
|
if snapshot:
|
|
590
794
|
try:
|
|
@@ -596,11 +800,23 @@ def on_session_start(payload):
|
|
|
596
800
|
sys.exit(0)
|
|
597
801
|
|
|
598
802
|
|
|
803
|
+
def on_notification(payload):
|
|
804
|
+
"""auth_success (Claude): the login may have changed the plan. Only opens the
|
|
805
|
+
decision and prints nothing; account_mtime is left as it was, so the next
|
|
806
|
+
prompt asks again and carries the JSON (R36.18)."""
|
|
807
|
+
if payload.get("notification_type") not in (None, "auth_success"):
|
|
808
|
+
sys.exit(0)
|
|
809
|
+
root = ai_root(payload.get("cwd"))
|
|
810
|
+
if root:
|
|
811
|
+
detect_drift(root, payload.get("cwd"), payload.get("session_id") or None)
|
|
812
|
+
sys.exit(0)
|
|
813
|
+
|
|
814
|
+
|
|
599
815
|
def main():
|
|
600
816
|
try:
|
|
601
817
|
payload = json.load(sys.stdin)
|
|
602
818
|
handler = {"UserPromptSubmit": on_prompt, "PreCompact": on_precompact,
|
|
603
|
-
"SessionStart": on_session_start}.get(payload.get("hook_event_name"))
|
|
819
|
+
"SessionStart": on_session_start, "Notification": on_notification}.get(payload.get("hook_event_name"))
|
|
604
820
|
if handler:
|
|
605
821
|
handler(payload)
|
|
606
822
|
except Exception: # pylint: disable=broad-exception-caught # fail open: the guard never blocks work by crashing
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""RiskRails: the dialog's recorder and Stop reminder (R36.6, R36.13).
|
|
3
|
+
|
|
4
|
+
PostToolUse on the dialog tool (Claude AskUserQuestion, Codex
|
|
5
|
+
request_user_input): the human's answers, read from tool_response, become
|
|
6
|
+
consent-ledger lines (.ai/state/consent.jsonl) — a pick per offered
|
|
7
|
+
command chosen, or a decline. Prints nothing.
|
|
8
|
+
Stop: while a decision is open, blocks the stop with the JSON to send, at
|
|
9
|
+
most stop_blocks_max times per decision per human turn (the turn is
|
|
10
|
+
session.json's prompt_count, which context-guard.py counts); then lets
|
|
11
|
+
the turn end with a systemMessage naming the terminal way out.
|
|
12
|
+
Anything else: exit 0, silent.
|
|
13
|
+
|
|
14
|
+
Never a deny, and inert unless the effective switch is on (hooks/guard/dialog.py):
|
|
15
|
+
with .ai/policies/dialog.json missing or off it reads nothing more and writes
|
|
16
|
+
nothing. Fails open: any error exits 0 with no output. Run as
|
|
17
|
+
`python3 -I -S`: stdlib only.
|
|
18
|
+
"""
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import shlex
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def ai_root(cwd):
|
|
26
|
+
"""Nearest ancestor of cwd holding a .ai/ directory, or None."""
|
|
27
|
+
d = os.path.abspath(cwd) if cwd else None
|
|
28
|
+
while d:
|
|
29
|
+
if os.path.isdir(os.path.join(d, ".ai")):
|
|
30
|
+
return d
|
|
31
|
+
parent = os.path.dirname(d)
|
|
32
|
+
d = None if parent == d else parent
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def prompt_count(root):
|
|
37
|
+
try:
|
|
38
|
+
with open(os.path.join(root, ".ai", "state", "session.json"), encoding="utf-8") as fh:
|
|
39
|
+
count = json.load(fh).get("prompt_count")
|
|
40
|
+
except (OSError, ValueError, AttributeError):
|
|
41
|
+
return None
|
|
42
|
+
return count if isinstance(count, int) and not isinstance(count, bool) else None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def on_stop(dialog, root, runtime, payload):
|
|
46
|
+
# Only the session and runtime that opened a decision answer it (7d2).
|
|
47
|
+
decisions = dialog.pending(dialog.bound(dialog.read_decisions(root), runtime, payload.get("session_id")),
|
|
48
|
+
dialog.read_ledger(root))
|
|
49
|
+
policy = dialog.dialog_policy(root)
|
|
50
|
+
if not decisions or policy is None:
|
|
51
|
+
return None
|
|
52
|
+
ids = [d["id"] for d in decisions]
|
|
53
|
+
turn, session = prompt_count(root), payload.get("session_id")
|
|
54
|
+
state = shlex.quote(dialog.STATE_PY)
|
|
55
|
+
escape = ("python3 %s --root %s decision settle --id %s --pick <n> (or: decision fallback --id %s "
|
|
56
|
+
"--why \"<why>\")" % (state, shlex.quote(root), ids[0], ids[0]))
|
|
57
|
+
# Without a turn to count by, stop_hook_active is the loop breaker.
|
|
58
|
+
if turn is None and payload.get("stop_hook_active"):
|
|
59
|
+
blocks = policy["stop_blocks_max"]
|
|
60
|
+
else:
|
|
61
|
+
blocks = sum(1 for e in dialog.read_ledger(root, types={dialog.STOP_BLOCK})
|
|
62
|
+
if e.get("session") == session and e.get("turn") == turn and e.get("decisions") == ids)
|
|
63
|
+
if blocks >= policy["stop_blocks_max"]:
|
|
64
|
+
return {"systemMessage": "DECISION_PENDING %s is still open. Settle it in your terminal: %s"
|
|
65
|
+
% (" ".join(ids), escape)}
|
|
66
|
+
with dialog.ledger_lock(root):
|
|
67
|
+
dialog.ledger_append(root, {"type": dialog.STOP_BLOCK, "session": session, "turn": turn,
|
|
68
|
+
"decisions": ids})
|
|
69
|
+
tool = dialog.DIALOG_TOOL[runtime]
|
|
70
|
+
return {"decision": "block", "reason": (
|
|
71
|
+
"DECISION_PENDING %s — call %s with exactly this JSON, then run the picked command verbatim, "
|
|
72
|
+
"or 'state.py decision settle --id %s': %s"
|
|
73
|
+
% (" ".join(ids), tool, ids[0], json.dumps(dialog.dialog_payload(decisions, runtime), ensure_ascii=False)))}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def main():
|
|
77
|
+
try:
|
|
78
|
+
hook_dir = os.path.dirname(os.path.abspath(__file__))
|
|
79
|
+
if hook_dir not in sys.path:
|
|
80
|
+
sys.path.insert(0, hook_dir)
|
|
81
|
+
from guard import dialog # pylint: disable=import-outside-toplevel
|
|
82
|
+
payload = json.loads(sys.stdin.buffer.read().decode("utf-8", "replace"))
|
|
83
|
+
if not isinstance(payload, dict):
|
|
84
|
+
return 0
|
|
85
|
+
event = payload.get("hook_event_name")
|
|
86
|
+
if event not in ("PostToolUse", "Stop"):
|
|
87
|
+
return 0
|
|
88
|
+
root = ai_root(payload.get("cwd") or os.getcwd())
|
|
89
|
+
runtime = dialog.payload_runtime(payload, __file__)
|
|
90
|
+
if not root or not dialog.effective_switch(root, runtime, payload):
|
|
91
|
+
return 0
|
|
92
|
+
if event == "PostToolUse":
|
|
93
|
+
dialog.record(root, runtime, payload)
|
|
94
|
+
return 0
|
|
95
|
+
out = on_stop(dialog, root, runtime, payload)
|
|
96
|
+
if out:
|
|
97
|
+
sys.stdout.write(json.dumps(out, ensure_ascii=False))
|
|
98
|
+
sys.stdout.flush()
|
|
99
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
100
|
+
return 0
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
if __name__ == "__main__":
|
|
105
|
+
sys.exit(main())
|
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:
|
|
@@ -341,7 +343,12 @@ class Payload:
|
|
|
341
343
|
elif isinstance(ti, dict) and _plain(ti.get("command")):
|
|
342
344
|
self.command = chomp_all(_sh(ti.get("command")))
|
|
343
345
|
self.command_ok = True
|
|
346
|
+
# R36 (7d1): what binds a dialog pick to its session; empty when absent.
|
|
347
|
+
for name in ("session_id", "transcript_path", "tool_use_id"):
|
|
348
|
+
setattr(self, name, chomp_all(_sh(v.get(name))))
|
|
344
349
|
else:
|
|
350
|
+
for name in ("session_id", "transcript_path", "tool_use_id"):
|
|
351
|
+
setattr(self, name, chomp_all(jq_field_r(self.values, name)[0]))
|
|
345
352
|
out, ok = jq_field_r(self.values, "tool_name")
|
|
346
353
|
if not ok:
|
|
347
354
|
raise Allow()
|