riskrails 3.0.0 → 3.0.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 (81) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/README.md +246 -812
  5. package/agents/ai-reviewer.md.tmpl +6 -4
  6. package/ai/policies/review-economy.md +7 -2
  7. package/ai/using-riskrails.md +15 -0
  8. package/bin/claude-1m +15 -14
  9. package/bin/riskrails.js +108 -32
  10. package/codex/hooks.json +25 -2
  11. package/hooks/AGENTS.md +2 -2
  12. package/hooks/CLAUDE.md +2 -2
  13. package/hooks/ai-guard.py +29 -8
  14. package/hooks/ai-path-guard-defaults.json +3 -1
  15. package/hooks/codex-hooks.json +25 -2
  16. package/hooks/context-guard.py +251 -35
  17. package/hooks/dialog-gate.py +105 -0
  18. package/hooks/guard/common.py +5 -0
  19. package/hooks/guard/decision.py +123 -0
  20. package/hooks/guard/dialog.py +598 -0
  21. package/hooks/guard/git.py +49 -3
  22. package/hooks/guard/paths.py +62 -3
  23. package/hooks/hooks.json +37 -2
  24. package/hooks/memory-pre-push.py +28 -0
  25. package/hooks/runtime-gate.py +59 -12
  26. package/hooks/update_notice.py +135 -0
  27. package/instructions/routing.md +4 -4
  28. package/instructions/stub.md +2 -2
  29. package/package.json +1 -1
  30. package/profiles/max.json +3 -1
  31. package/profiles/pro.json +3 -1
  32. package/scripts/merge-codex-config.py +134 -0
  33. package/scripts/resolve-profile.py +15 -3
  34. package/settings.common.json +39 -2
  35. package/skills/AGENTS.md +2 -2
  36. package/skills/CLAUDE.md +2 -2
  37. package/skills/ai-init/templates/.ai/AGENTS.md +2 -2
  38. package/skills/ai-init/templates/.ai/VERSION +1 -1
  39. package/skills/ai-init/templates/.ai/policies/dialog.json +1 -0
  40. package/skills/ai-init/templates/.ai/policies/safety.md +22 -2
  41. package/skills/ai-init/templates/.ai/policies/weight.json +1 -0
  42. package/skills/ai-init/templates/.ai/policies/weight.md +2 -2
  43. package/skills/ai-init/templates/.ai/state/README.md +9 -6
  44. package/skills/ai-init/templates/AGENTS.block.md +1 -1
  45. package/skills/ai-init/templates/CLAUDE.block.md +1 -1
  46. package/skills/ai-init/templates/GEMINI.block.md +1 -1
  47. package/skills/ai-init/templates/junie-guidelines.block.md +1 -1
  48. package/skills/ai-task/memory_compact.py +968 -0
  49. package/skills/ai-task/memory_format.py +775 -0
  50. package/skills/ai-task/memory_store.py +528 -0
  51. package/skills/ai-task/memory_sync.py +1571 -0
  52. package/skills/ai-task/sensors.py +2 -2
  53. package/skills/ai-task/state.py +967 -70
  54. package/skills/ai-task/weight.py +3 -3
  55. package/skills/project-init/templates/constitution.md +1 -1
  56. package/skills/project-update/history/blobs/164cd34c182de88c33de7df35beb29200d792419e1c13a5f0b2104f944e57be7 +1 -0
  57. package/skills/project-update/history/blobs/1d1d907449ec74594d0ee923ac142ae746b9aedf0a1eda9469f075a44d23e3d7 +13 -0
  58. package/skills/project-update/history/blobs/23a42febbacaa7b52a802d428033a99c0694391c81d51fe3b6e1dff608a641d5 +67 -0
  59. package/skills/project-update/history/blobs/5353ca529c46f2443e12947ec205a6887fb594a4de1f87f0eb1c0e6c0f826133 +49 -0
  60. package/skills/project-update/history/blobs/5f65bc7ab3fc3c4e75ae05d49eaf4331fafc398c661abe33c69f7380a720344c +32 -0
  61. package/skills/project-update/history/blobs/7be527cd648b27cca6e267de30f8e33a2ce17d5631a82694f23ae5c0623efe1f +15 -0
  62. package/skills/project-update/history/blobs/7ee99edafe7cec45ff577376c0d0178fdb2690134d4656488f46e129aba3cc5a +67 -0
  63. package/skills/project-update/history/blobs/8b3ef9586491a426767c01da5d01bdd6b0655c576c68c4cc6dfb093367817dc0 +67 -0
  64. package/skills/project-update/history/blobs/917df3320d778ddbaa5c5c7742bc4046bf803c36ed2b050f30844ed206783469 +1 -0
  65. package/skills/project-update/history/blobs/935af1019931058ec3a0b834fe230e127ec914f75edcc4a6c589cfe733b0b979 +13 -0
  66. package/skills/project-update/history/blobs/ae5ce131d6bae401199ff32d8c3dc4d8aec188010a9111624ac64506e2116ff4 +84 -0
  67. package/skills/project-update/history/blobs/ca4499d829268c8766fb3b825a515e88dc55c237e9d202816b50959b4dac031f +13 -0
  68. package/skills/project-update/history/blobs/e9adb7a9905eb719ac78ab96671f055da53e5001810f5ad6d6c94306b0323fe7 +74 -0
  69. package/skills/project-update/history/blobs/fae3064e648cdc55be4aeb1177525b302a5e34ebd752e4a904f47dc0f4557b96 +71 -0
  70. package/skills/project-update/history/index.json +32 -13
  71. package/skills/project-update/migrations/0009_state_file.py +25 -0
  72. package/skills/project-update/migrations/0010_dialog_switch.py +30 -0
  73. package/skills/project-update/migrations/README.md +2 -1
  74. package/skills/project-update/update.py +12 -3
  75. package/skills/rr-build/SKILL.md +14 -5
  76. package/skills/rr-build-all/SKILL.md +190 -0
  77. package/skills/rr-plan/SKILL.md +13 -3
  78. package/skills/rr-review/SKILL.md +18 -8
  79. package/skills/rr-ship/SKILL.md +16 -8
  80. package/skills/rr-status/SKILL.md +2 -2
  81. package/skills/usage-report/usage-report.py +41 -8
@@ -3,13 +3,13 @@
3
3
  "hooks": {
4
4
  "PreToolUse": [
5
5
  {
6
- "matcher": "Bash|apply_patch|Edit|Write",
6
+ "matcher": ".*",
7
7
  "hooks": [
8
8
  {
9
9
  "type": "command",
10
10
  "command": "python3 -I -S \"${CLAUDE_PLUGIN_ROOT}/hooks/ai-guard.py\"",
11
11
  "timeout": 15,
12
- "statusMessage": "Checking git, paths and scope"
12
+ "statusMessage": "Checking git, paths, scope and open decisions"
13
13
  }
14
14
  ]
15
15
  },
@@ -36,6 +36,17 @@
36
36
  "statusMessage": "Recording expert-model availability"
37
37
  }
38
38
  ]
39
+ },
40
+ {
41
+ "matcher": "^request_user_input$",
42
+ "hooks": [
43
+ {
44
+ "type": "command",
45
+ "command": "python3 -I -S \"${CLAUDE_PLUGIN_ROOT}/hooks/dialog-gate.py\"",
46
+ "timeout": 5,
47
+ "statusMessage": "Recording the dialog's answer"
48
+ }
49
+ ]
39
50
  }
40
51
  ],
41
52
  "SubagentStart": [
@@ -98,6 +109,18 @@
98
109
  }
99
110
  ]
100
111
  }
112
+ ],
113
+ "Stop": [
114
+ {
115
+ "hooks": [
116
+ {
117
+ "type": "command",
118
+ "command": "python3 -I -S \"${CLAUDE_PLUGIN_ROOT}/hooks/dialog-gate.py\"",
119
+ "timeout": 5,
120
+ "statusMessage": "Checking for an open decision"
121
+ }
122
+ ]
123
+ }
101
124
  ]
102
125
  }
103
126
  }
@@ -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, three Claude Code events:
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 one knob the profile already sets. Auto-compaction
15
- fires about 33k tokens under `autoCompactWindow` (measured over 133 automatic
16
- compactions: a 150 000 window compacts at 115–125k, 117k most often), and
17
- Claude Code caps that window at the model's own: 200k, or 1M for a [1m] model.
18
- The guard warns at 80% of that point and blocks at 120% of it, which is only
19
- reached when auto-compaction is off or did not run. An 800 000 window therefore
20
- means, on a 200k model, compaction near 167k and a warning from 133k; on a [1m]
21
- model, compaction near 767k, a warning from 613k and a block from 920k.
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
- DEFAULT_WINDOW = 800000 # profiles/max.json; capped per model below
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 Claude Code compacts against: the env var wins over settings.json, and
127
- Claude Code caps either at the model's window (\"capped to … by model\" in /autocompact),
128
- so one 800k setting compacts a 1M session near 767k and a 200k session near 167k."""
129
- window = positive_int(os.environ.get("CLAUDE_CODE_AUTO_COMPACT_WINDOW"))
130
- if not window:
131
- window = positive_int(user_settings().get("autoCompactWindow"))
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
- return positive_int(raw) if raw.lstrip("-").isdigit() else fires_at * pct // 100
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 state_py(root, *args):
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):
@@ -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
- sys.exit(0)
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
- emit({"decision": "block", "reason": (
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
- sys.exit(0) # the same prompt a second time: the user has decided
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
- sys.exit(0)
688
+ return None
511
689
  state["band"] = band
512
690
  save()
513
- emit({
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
- text = "\n\n".join(part for part in (head, tail) if part)
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
- print(json.dumps({"hookSpecificOutput": {
587
- "hookEventName": "SessionStart", "additionalContext": text}}))
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())
@@ -341,7 +341,12 @@ class Payload:
341
341
  elif isinstance(ti, dict) and _plain(ti.get("command")):
342
342
  self.command = chomp_all(_sh(ti.get("command")))
343
343
  self.command_ok = True
344
+ # R36 (7d1): what binds a dialog pick to its session; empty when absent.
345
+ for name in ("session_id", "transcript_path", "tool_use_id"):
346
+ setattr(self, name, chomp_all(_sh(v.get(name))))
344
347
  else:
348
+ for name in ("session_id", "transcript_path", "tool_use_id"):
349
+ setattr(self, name, chomp_all(jq_field_r(self.values, name)[0]))
345
350
  out, ok = jq_field_r(self.values, "tool_name")
346
351
  if not ok:
347
352
  raise Allow()