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
@@ -88,6 +88,31 @@ SETBY_VERB = ("([[:space:]]+--)?[[:space:]]+" + Q + "set" + END + "([^|;&`]*[[:s
88
88
  + Q + "(=|[[:space:]]|$)")
89
89
  SETBY_RE = (BOUND + "(python3?[[:space:]]+)?" + Q + "[^[:space:]'\"]*state\\.py" + Q + ARGS + SETBY_VERB + "|"
90
90
  + BOUND + "(python3?[[:space:]]+)?" + Q + VAR + Q + ARGS + SETBY_VERB)
91
+ # R36.11, with the dialog on only: the other human-only state.py commands —
92
+ # `profile set` and the --by forms of verify, review and reject — run from an
93
+ # agent session only as the next live pick, like approve. `start --mode --by`
94
+ # reaches state.py: raising a mode needs no human, and a lowering opens its own
95
+ # decision there before human_route() refuses it (review H1, T-2026-10-28-010).
96
+ PROFILE_SET_VERB = "([[:space:]]+--)?[[:space:]]+" + Q + "profile" + Q + "[[:space:]]+" + Q + "set" + END
97
+ PROFILE_SET_RE = (BOUND + "(python3?[[:space:]]+)?" + Q + "[^[:space:]'\"]*state\\.py" + Q + ARGS + PROFILE_SET_VERB
98
+ + "|" + BOUND + "(python3?[[:space:]]+)?" + Q + VAR + Q + ARGS + PROFILE_SET_VERB)
99
+ HUMAN_BY_VERB = ("([[:space:]]+--)?[[:space:]]+" + Q + "(verify|review|reject)" + END + "([^|;&`]*[[:space:]])?"
100
+ + Q + "--by?" + Q + "(=|[[:space:]]|$)")
101
+ HUMAN_BY_RE = (BOUND + "(python3?[[:space:]]+)?" + Q + "[^[:space:]'\"]*state\\.py" + Q + ARGS + HUMAN_BY_VERB + "|"
102
+ + BOUND + "(python3?[[:space:]]+)?" + Q + VAR + Q + ARGS + HUMAN_BY_VERB)
103
+ WHY_PICK = T(
104
+ "\n\nWith the dialog on, this command runs from an agent session only as the human's pick.\n"
105
+ "The command that needs it opened a decision: 'state.py decision show' prints the dialog;\n"
106
+ "send it and run the picked command verbatim, once. Otherwise the human runs it in their\n"
107
+ "own terminal. See .ai/policies/safety.md.")
108
+ # R36.11: running the dialog's recorder by hand would forge the human's answer.
109
+ # dialog-gate.py in command position — the program itself, or the script a
110
+ # python runs — after a separator, a quote, exec/env/command/nohup/time/sudo
111
+ # and their words; naming it as an argument (grep, git diff, chmod) is not running it.
112
+ DIALOG_GATE_RE = ("(^|[|;&(`{'\"]|\\$\\()[[:space:]]*((exec|env|command|nohup|time|sudo)([[:space:]]+[^[:space:]|;&]+)*"
113
+ "[[:space:]]+)?(" + Q + "[^[:space:]'\"|;&]*python[0-9.]*" + Q
114
+ + "([[:space:]]+-[^[:space:]]+)*[[:space:]]+)?" + Q + "[^[:space:]'\"|;&]*dialog-gate\\.py" + Q
115
+ + "([[:space:]|;&)<>]|$)")
91
116
  VERBS_RE = ("(^|[^A-Za-z0-9_.-])(cp|mv|install|rsync|rm|rmdir|unlink|tee|truncate|shred|touch|ln|chmod|chown|"
92
117
  "dd|sed|perl|ruby)([^A-Za-z0-9_.-]|$)")
93
118
  SEGMENT = "^[[:space:]]*"
@@ -206,7 +231,7 @@ def realpath_m(path):
206
231
 
207
232
  def find_print(start, maxdepth, prune):
208
233
  """`find START -maxdepth N \\( -name A -o … \\) -prune -o -print`, in
209
- readdir order, links not followed, errors silent."""
234
+ name order (not find's readdir order), links not followed, errors silent."""
210
235
  def walk(path, name, depth):
211
236
  if name in prune:
212
237
  return
@@ -218,7 +243,9 @@ def find_print(start, maxdepth, prune):
218
243
  entries = list(it)
219
244
  except OSError:
220
245
  return
221
- for e in entries:
246
+ # by name, not readdir order: which held file a refusal names must not depend
247
+ # on the filesystem (ext4 here put .ai/policies first, the CI runner .ai/state)
248
+ for e in sorted(entries, key=lambda e: e.name):
222
249
  try:
223
250
  is_d = e.is_dir(follow_symlinks=False)
224
251
  except OSError:
@@ -1168,6 +1195,27 @@ class PathGuard: # pylint: disable=too-many-instance-
1168
1195
  return
1169
1196
  self.check_bash(unattended)
1170
1197
 
1198
+ def dialog_consent(self, cmd):
1199
+ """(the dialog is on for this call, the command is the next live pick
1200
+ of this session) — R36.11's pass-through. state.py spends the pick
1201
+ after its own argv check (R36.10); the guard only lets it reach it."""
1202
+ if not self.root or not exists(self.root + "/.ai/policies/dialog.json"):
1203
+ return False, False
1204
+ from datetime import datetime, timezone # pylint: disable=import-outside-toplevel
1205
+ from guard import dialog # pylint: disable=import-outside-toplevel
1206
+ root = os.fsdecode(b_bytes(self.root))
1207
+ values = self.p.values
1208
+ raw = values[0] if len(values) == 1 and isinstance(values[0], dict) else None
1209
+ hook_file = os.path.join(os.fsdecode(b_bytes(self.ctx.hook_dir)), "ai-guard.py")
1210
+ try:
1211
+ if raw is None or not dialog.effective_switch(root, dialog.payload_runtime(raw, hook_file), raw):
1212
+ return False, False
1213
+ pick = dialog.next_live_pick(root, raw.get("session_id"), datetime.now(timezone.utc))
1214
+ except Exception: # pylint: disable=broad-exception-caught
1215
+ return True, False
1216
+ command = b_bytes(cmd).decode("utf-8", "replace").strip()
1217
+ return True, bool(pick and pick.get("label") == command)
1218
+
1171
1219
  def interpreter_text(self, cmd):
1172
1220
  """The text of an interpreter one-liner or an eval, scanned whole (H3)."""
1173
1221
  m = self.m
@@ -1199,8 +1247,19 @@ class PathGuard: # pylint: disable=too-many-instance-
1199
1247
  if length(cmd) > LONG_CMD and (m.search_c(LONG_TRIGGER_RE, cmd) or m.matches_joined(cmd, self.loose)):
1200
1248
  raise Deny("Refusing a %d-character shell command that names a guarded path or a state.py or "
1201
1249
  "update.py verb: write it to a file and run that, or split it." % length(cmd))
1202
- if m.ere_match(APPROVE_RE, cmd_joined) and not unattended and self.task_in_flight():
1250
+ if m.ere_match(DIALOG_GATE_RE, cmd_joined):
1251
+ raise Deny("Refusing to run hooks/dialog-gate.py from a shell: it records the human's answer to "
1252
+ "the dialog, and only the runtime calls it, after the dialog tool. To answer a decision, "
1253
+ "send the dialog ('state.py decision show'); a human settles one in their own terminal "
1254
+ "('state.py decision settle --id D<n> --pick <n>'). See .ai/policies/safety.md.")
1255
+ on, picked = self.dialog_consent(cmd)
1256
+ if m.ere_match(APPROVE_RE, cmd_joined) and not unattended and self.task_in_flight() and not picked:
1203
1257
  raise Deny("Refusing 'state.py approve' from an agent session." + WHY_APPROVE)
1258
+ if on and not picked and not unattended:
1259
+ if m.ere_match(PROFILE_SET_RE, cmd_joined):
1260
+ raise Deny("Refusing 'state.py profile set' from an agent session." + WHY_PICK)
1261
+ if m.ere_match(HUMAN_BY_RE, cmd_joined):
1262
+ raise Deny(T("Refusing a 'state.py … --by' verdict from an agent session.") + WHY_PICK)
1204
1263
  if m.ere_match(SETBY_RE, cmd_joined) and not unattended and self.task_in_flight():
1205
1264
  raise Deny(T("Refusing 'state.py set \u2026 --by' from an agent session: a verdict with --by is the "
1206
1265
  "human's, given when the review budget is spent.") + WHY_SET_BY)
package/hooks/hooks.json CHANGED
@@ -14,13 +14,13 @@
14
14
  ]
15
15
  },
16
16
  {
17
- "matcher": "Read|Edit|Write|MultiEdit|NotebookEdit|Bash",
17
+ "matcher": "*",
18
18
  "hooks": [
19
19
  {
20
20
  "type": "command",
21
21
  "command": "python3 -I -S \"${CLAUDE_PLUGIN_ROOT}/hooks/ai-guard.py\"",
22
22
  "timeout": 15,
23
- "statusMessage": "Checking git, paths and scope"
23
+ "statusMessage": "Checking git, paths, scope and open decisions"
24
24
  }
25
25
  ]
26
26
  },
@@ -84,7 +84,30 @@
84
84
  ]
85
85
  }
86
86
  ],
87
+ "Notification": [
88
+ {
89
+ "matcher": "auth_success",
90
+ "hooks": [
91
+ {
92
+ "type": "command",
93
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/context-guard.py\"",
94
+ "timeout": 15
95
+ }
96
+ ]
97
+ }
98
+ ],
87
99
  "PostToolUse": [
100
+ {
101
+ "matcher": "AskUserQuestion",
102
+ "hooks": [
103
+ {
104
+ "type": "command",
105
+ "command": "python3 -I -S \"${CLAUDE_PLUGIN_ROOT}/hooks/dialog-gate.py\"",
106
+ "timeout": 5,
107
+ "statusMessage": "Recording the dialog's answer"
108
+ }
109
+ ]
110
+ },
88
111
  {
89
112
  "matcher": "Agent",
90
113
  "hooks": [
@@ -117,6 +140,18 @@
117
140
  }
118
141
  ]
119
142
  }
143
+ ],
144
+ "Stop": [
145
+ {
146
+ "hooks": [
147
+ {
148
+ "type": "command",
149
+ "command": "python3 -I -S \"${CLAUDE_PLUGIN_ROOT}/hooks/dialog-gate.py\"",
150
+ "timeout": 5,
151
+ "statusMessage": "Checking for an open decision"
152
+ }
153
+ ]
154
+ }
120
155
  ]
121
156
  }
122
157
  }
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env python3
2
+ """riskrails memory pre-push validator (spec compact-project-memory §D10, §I5).
3
+
4
+ memory-pre-push.py <remote> <url> < "<lref> <lsha> <rref> <rsha>"… git hook
5
+ memory-pre-push.py --range OLD..NEW [--repo DIR] CI or server
6
+ memory-pre-push.py --tree REV [--repo DIR] structure only
7
+
8
+ Checks the pushed commits' blobs, never the working tree. stderr carries one
9
+ line per finding; exit 0 clean, 1 findings (the push stops), 3 the validator
10
+ itself failed (fail closed). It writes no file, uses no network and imports
11
+ no state.py. A git hook, not a runtime hook: no tool call ever runs it. The
12
+ logic is memory_sync.pre_push, which the shim also runs directly where an
13
+ install copied skills/ai-task/ but not this file.
14
+ """
15
+ import os
16
+ import sys
17
+
18
+ HERE = os.path.dirname(os.path.abspath(__file__))
19
+ sys.path.insert(0, os.path.join(os.path.dirname(HERE), "skills", "ai-task"))
20
+
21
+ if __name__ == "__main__":
22
+ try:
23
+ import memory_sync # pylint: disable=import-outside-toplevel
24
+ except ImportError as exc:
25
+ print("memory-pre-push: validator error: %s — run 'state.py memory verify --all'; a human may push "
26
+ "with 'git push --no-verify'" % exc, file=sys.stderr)
27
+ sys.exit(3)
28
+ sys.exit(memory_sync.pre_push_cli(sys.argv[1:]))
@@ -12,8 +12,9 @@ Claude Code (model: fable -> opus):
12
12
  StopFailure (rate_limit|model_not_found) records that Fable is unavailable
13
13
  PostToolUse:Agent records a Fable agent that fell back (overload), briefly
14
14
  PreToolUse:Agent while a record is live, rewrites model fable -> opus
15
- statusline [--then <command>] records it when the weekly limit is nearly used, then
16
- runs the user's own statusline command on the same input
15
+ statusline [--context] [--then <command>] records it when the weekly limit is nearly used, prints
16
+ the model and the context use with --context, then runs
17
+ the user's own statusline command on the same input
17
18
 
18
19
  Codex (EXPERT model -> STRONG model); an agent launch is `spawn_agent`, or
19
20
  `collaborationspawn_agent` under multi-agent v2:
@@ -43,7 +44,7 @@ A record expires on its own. The gate never denies, always exits 0, and any
43
44
  error inside it fails open: the call goes through unchanged.
44
45
 
45
46
  runtime-gate.py status | quota [--json] | clear | set <seconds> [reason]
46
- | statusline [--then <cmd>]
47
+ | statusline [--context] [--then <cmd>]
47
48
 
48
49
  Quota (advice only, it never refuses anything): Claude Code's comes from the
49
50
  statusline payload, Codex's from the newest rollout's token_count event. Both
@@ -1102,7 +1103,7 @@ def cli(argv):
1102
1103
  if cmd == "set" and len(argv) >= 2 and argv[1].isdigit():
1103
1104
  mark(time.time() + int(argv[1]), " ".join(argv[2:]) or "manual", "cli")
1104
1105
  return cli(["status"])
1105
- print("usage: runtime-gate.py status | quota [--json] | clear | set <seconds> [reason] | statusline [--then <cmd>] < json",
1106
+ print("usage: runtime-gate.py status | quota [--json] | clear | set <seconds> [reason] | statusline [--context] [--then <cmd>] < json",
1106
1107
  file=sys.stderr)
1107
1108
  return 2
1108
1109
 
@@ -1115,17 +1116,63 @@ def disabled():
1115
1116
  return False
1116
1117
 
1117
1118
 
1119
+ def context_line(payload):
1120
+ """`Opus 5.5 [1m] · 123k/1000k (12%)` from a Claude Code statusline payload, or ""
1121
+ when it names neither the model nor the context. The context_window fields differ
1122
+ between Claude Code versions: the tokens come from current_usage (input plus cache
1123
+ reads and writes, what the next turn re-reads), else from used_percentage."""
1124
+ model = payload.get("model")
1125
+ name = str(model.get("display_name") or model.get("id") or "") if isinstance(model, dict) else ""
1126
+ window = payload.get("context_window")
1127
+ window = window if isinstance(window, dict) else {}
1128
+
1129
+ def number(value):
1130
+ return value if isinstance(value, (int, float)) and not isinstance(value, bool) and value >= 0 else None
1131
+
1132
+ size = number(window.get("context_window_size"))
1133
+ pct = number(window.get("used_percentage"))
1134
+ usage = window.get("current_usage")
1135
+ used = None
1136
+ if isinstance(usage, dict):
1137
+ parts = [number(usage.get(k)) for k in
1138
+ ("input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens")]
1139
+ if any(p is not None for p in parts):
1140
+ used = sum(p or 0 for p in parts)
1141
+ if used is None and pct is not None and size:
1142
+ used = size * pct / 100
1143
+ if pct is None and used is not None and size:
1144
+ pct = used * 100 / size
1145
+ context = ""
1146
+ if used is not None and size:
1147
+ context = f"{round(used / 1000)}k/{round(size / 1000)}k ({round(pct)}%)"
1148
+ return " · ".join(part for part in (name, context) if part)
1149
+
1150
+
1118
1151
  def statusline_main(argv):
1119
- """`statusline [--then <command>]`: check the weekly limit, then hand the same
1120
- input to the user's own statusline command and pass its output and exit code
1121
- through. The check never gets in the way of the statusline."""
1152
+ """`statusline [--context] [--then <command>]`: check the weekly limit, print the
1153
+ model and the context use with --context, then hand the same input to the user's
1154
+ own statusline command and pass its output and exit code through. Neither the
1155
+ check nor the line ever gets in the way of the statusline."""
1122
1156
  data = sys.stdin.buffer.read()
1123
- if not disabled() and RUNTIME == "claude":
1157
+ show_context = argv[1:2] == ["--context"]
1158
+ if show_context:
1159
+ argv = argv[:1] + argv[2:]
1160
+ payload = None
1161
+ try:
1162
+ payload = json.loads(data.decode("utf-8", "replace"))
1163
+ except Exception: # pylint: disable=broad-exception-caught # fail open: a bad payload never breaks the statusline
1164
+ pass
1165
+ if isinstance(payload, dict) and not disabled() and RUNTIME == "claude":
1166
+ try:
1167
+ statusline(payload)
1168
+ except Exception: # pylint: disable=broad-exception-caught # fail open, as above
1169
+ pass
1170
+ if show_context and isinstance(payload, dict):
1124
1171
  try:
1125
- payload = json.loads(data.decode("utf-8", "replace"))
1126
- if isinstance(payload, dict):
1127
- statusline(payload)
1128
- except Exception: # pylint: disable=broad-exception-caught # fail open: a bad payload never breaks the statusline
1172
+ line = context_line(payload)
1173
+ if line:
1174
+ print(line)
1175
+ except Exception: # pylint: disable=broad-exception-caught # fail open, as above
1129
1176
  pass
1130
1177
  if len(argv) >= 3 and argv[1] == "--then" and argv[2].strip():
1131
1178
  shell = shutil.which("bash") or "/bin/sh"
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env python3
2
+ """Update notice: tell the user at session start when npm has a newer `riskrails`.
3
+
4
+ Imported by context-guard.py (SessionStart) and run as a script for the refresh:
5
+
6
+ update_notice.py --refresh <cache path>
7
+
8
+ The hook itself never touches the network. It reads <home>/riskrails/update-check.json
9
+ ({"checked_at": epoch, "latest": "x.y.z"}); when that file is missing or older than a
10
+ day it starts the refresh as a detached child and answers from the cache as it is, so
11
+ the first session after an install says nothing and a later one does. The child asks
12
+ the registry (3 s timeout) and writes the cache atomically; any error is silent, and a
13
+ failed fetch only moves checked_at, so the next try waits a day.
14
+
15
+ Environment:
16
+ RISKRAILS_NO_UPDATE_CHECK non-empty turns the check off (no read, no spawn)
17
+ CI non-empty turns it off as well
18
+ RISKRAILS_REGISTRY_URL instead of https://registry.npmjs.org/riskrails/latest (file:// works)
19
+ """
20
+ import json
21
+ import os
22
+ import re
23
+ import subprocess
24
+ import sys
25
+ import time
26
+ import urllib.request
27
+
28
+ REGISTRY_URL = "https://registry.npmjs.org/riskrails/latest"
29
+ MAX_AGE = 24 * 3600
30
+ TIMEOUT = 3
31
+ COMMAND = "npx -y riskrails@latest --yes"
32
+ VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") # no prerelease, no build metadata
33
+
34
+
35
+ def parse(version):
36
+ """(major, minor, patch) of a plain X.Y.Z, else None."""
37
+ match = VERSION_RE.match(version.strip()) if isinstance(version, str) else None
38
+ return tuple(int(n) for n in match.groups()) if match else None
39
+
40
+
41
+ def installed_version(runtime, plugin_root, home=None):
42
+ """The installed release: profile.json's plugin_version (written by the installer into
43
+ <home>/riskrails/), else the plugin manifest beside the hooks (plugin-cache layout).
44
+ Only a plain X.Y.Z counts; anything else is None."""
45
+ manifest = ".codex-plugin" if runtime == "codex" else ".claude-plugin"
46
+ sources = [(os.path.join(plugin_root, manifest, "plugin.json"), "version")]
47
+ if home:
48
+ sources.insert(0, (os.path.join(home, "riskrails", "profile.json"), "plugin_version"))
49
+ for path, key in sources:
50
+ try:
51
+ with open(path, encoding="utf-8") as fh:
52
+ version = json.load(fh).get(key)
53
+ except (OSError, ValueError, AttributeError):
54
+ continue
55
+ if parse(version):
56
+ return version
57
+ return None
58
+
59
+
60
+ def read_cache(path):
61
+ try:
62
+ with open(path, encoding="utf-8") as fh:
63
+ data = json.load(fh)
64
+ return data if isinstance(data, dict) else {}
65
+ except (OSError, ValueError):
66
+ return {}
67
+
68
+
69
+ def disabled():
70
+ return bool(os.environ.get("RISKRAILS_NO_UPDATE_CHECK") or os.environ.get("CI"))
71
+
72
+
73
+ def spawn_refresh(path):
74
+ try:
75
+ subprocess.Popen([sys.executable, "-I", os.path.abspath(__file__), "--refresh", path], # pylint: disable=consider-using-with
76
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
77
+ stderr=subprocess.DEVNULL, start_new_session=True)
78
+ except OSError:
79
+ pass
80
+
81
+
82
+ def notice(runtime, home, plugin_root, now):
83
+ """(systemMessage, additionalContext) when the cache knows a newer release, else ("", "")."""
84
+ if disabled():
85
+ return "", ""
86
+ riskrails = os.path.join(home, "riskrails")
87
+ path = os.path.join(riskrails, "update-check.json")
88
+ cache = read_cache(path)
89
+ checked = cache.get("checked_at")
90
+ fresh = isinstance(checked, (int, float)) and 0 <= now - checked < MAX_AGE
91
+ if not fresh and os.path.isdir(riskrails):
92
+ spawn_refresh(path)
93
+ installed, latest = installed_version(runtime, plugin_root, home), cache.get("latest")
94
+ have, new = parse(installed), parse(latest)
95
+ if not have or not new or new <= have:
96
+ return "", ""
97
+ message = (f"RiskRails {latest} is available (installed {installed}). Update: {COMMAND} "
98
+ f"— inside Claude Code or Codex CLI: ! {COMMAND} — then /rr-setup --update in each project.")
99
+ context = (f"RiskRails {latest} is available on npm; {installed} is installed. The user updates it "
100
+ f"with `{COMMAND}` in a terminal, or `! {COMMAND}` inside Claude Code or Codex CLI, "
101
+ "then `/rr-setup --update` in each project. Mention this once to the user if it is "
102
+ "relevant; never run the update yourself — installing is the user's act.")
103
+ return message, context
104
+
105
+
106
+ def refresh(path):
107
+ """Ask the registry and write the cache; on failure only move checked_at."""
108
+ latest = None
109
+ try:
110
+ url = os.environ.get("RISKRAILS_REGISTRY_URL") or REGISTRY_URL
111
+ with urllib.request.urlopen(url, timeout=TIMEOUT) as resp: # nosec: the registry, or a test's file URL
112
+ version = json.loads(resp.read().decode("utf-8")).get("version")
113
+ if parse(version):
114
+ latest = version
115
+ except (OSError, ValueError, AttributeError):
116
+ pass
117
+ if latest is None:
118
+ previous = read_cache(path).get("latest")
119
+ latest = previous if isinstance(previous, str) else None
120
+ tmp = f"{path}.{os.getpid()}.tmp"
121
+ try:
122
+ with open(tmp, "w", encoding="utf-8") as fh:
123
+ json.dump({"checked_at": int(time.time()), "latest": latest}, fh)
124
+ os.replace(tmp, path)
125
+ except OSError:
126
+ try:
127
+ os.remove(tmp)
128
+ except OSError:
129
+ pass
130
+
131
+
132
+ if __name__ == "__main__":
133
+ if len(sys.argv) == 3 and sys.argv[1] == "--refresh" and not disabled():
134
+ refresh(sys.argv[2])
135
+ sys.exit(0)
@@ -38,7 +38,7 @@ Facts are collected cheaply; thinking is paid for. Session model {{SESSION_MODEL
38
38
  - One subagent returning twenty lines beats five tool calls whose output stays in context all session.
39
39
  - Deterministic tools first: `rg`, `git`, `jq`, the framework CLI, the test runner.
40
40
  - `/clear` between tasks, and after every task that ran a review. Compaction near {{COMPACT_WINDOW}} tokens is a summary of the old task, not a clean start; until then every turn re-reads the whole history.
41
- - `context-guard` measures the context on every prompt: a one-line warning from {{CONTEXT_WARN}} tokens, and from {{CONTEXT_BLOCK}} the prompt is held back once — sending it again goes through. When its note says the prompt starts a different task, suggest `/clear` before doing the work. Before a compaction it saves the edited files, the latest instructions verbatim, the todo list and the git state, and hands them back afterwards as "Session state before compaction": trust that block over the summary where they differ, and re-read a file before editing it rather than relying on what the summary says it contains.
41
+ - `context-guard` measures the context on every prompt: a one-line warning from {{CONTEXT_WARN}} tokens, {{CONTEXT_BLOCK}}. When its note says the prompt starts a different task, suggest `/clear` before doing the work. Before a compaction it saves the edited files, the latest instructions verbatim, the todo list and the git state, and hands them back afterwards as "Session state before compaction": trust that block over the summary where they differ, and re-read a file before editing it rather than relying on what the summary says it contains.
42
42
  - Run tests once, to the end, and fix every failure as one batch. Never one failure, one fix, one run.
43
43
  - `/usage-report` shows where the tokens went, at zero model cost; `--provider both` puts Codex next to Claude Code.
44
44
 
@@ -46,8 +46,8 @@ Facts are collected cheaply; thinking is paid for. Session model {{SESSION_MODEL
46
46
  # Guards
47
47
 
48
48
  <!-- stub: runtime=claude -->
49
- - `ai-git-guard` runs in every repository: no force push, history rewrite, push or merge to a protected branch, `--no-verify`, staged secret or production deploy. `ai-path-guard` and `ai-scope-guard` arm only where `.ai/` exists. No agent commits, merges or deploys on its own; the pipeline ends at human approval.
50
- - `context-guard` measures the context on every prompt and holds a prompt back once at the block threshold above; its "Session state before compaction" block and `.ai/state/handoff.md` are what a resumed session trusts over the summary.
49
+ - `ai-git-guard` runs in every repository: no force push, history rewrite, push or merge to a protected branch, `--no-verify`, staged secret or production deploy. `ai-path-guard` and `ai-scope-guard` arm only where `.ai/` exists. No agent merges or deploys; commit, push and approval run only as the command the human picked in the dialog — verbatim, once — or the human runs them outside the agent.
50
+ - `context-guard` measures the context on every prompt and holds a prompt back once at the block threshold above, when there is one; its "Session state before compaction" block and `.ai/state/handoff.md` are what a resumed session trusts over the summary.
51
51
 
52
52
  <!-- stub: runtime=codex -->
53
53
  # Model allocation by task and scope (host-wide, ChatGPT {{CODEX_PLAN}} plan)
@@ -130,7 +130,7 @@ The main session re-reads its whole context every turn, so context length — mo
130
130
  # Guards
131
131
 
132
132
  <!-- stub: runtime=codex -->
133
- - `ai-git-guard` runs in every repository: no force push, history rewrite, push or merge to a protected branch, `--no-verify`, staged secret or production deploy. `ai-path-guard` and `ai-scope-guard` arm only where `.ai/` exists. All three see `Bash` and `apply_patch`; run `/hooks` once after installing to review and trust them, or they are skipped. No agent commits, merges or deploys on its own; the pipeline ends at human approval.
133
+ - `ai-git-guard` runs in every repository: no force push, history rewrite, push or merge to a protected branch, `--no-verify`, staged secret or production deploy. `ai-path-guard` and `ai-scope-guard` arm only where `.ai/` exists. All three see `Bash` and `apply_patch`; run `/hooks` once after installing to review and trust them, or they are skipped. No agent merges or deploys; commit, push and approval run only as the command the human picked in the dialog — verbatim, once — or the human runs them outside the agent.
134
134
  - `architect` is for design questions outside a task, in a repository without `.ai/`, and in a full-mode task whose design is open (`/rr-plan`). Otherwise the session plans, and an EXPERT spawn is the escalation.
135
135
 
136
136
  <!-- stub: runtime=claude,codex -->
@@ -24,7 +24,7 @@ Rules for every repository; everything else loads on demand. `{{HOME}}/riskrails
24
24
  - With `.ai/`: read `.ai/AGENTS.md` first, run every change through `/rr-build <request>`, treat `.ai/policies/` as binding; `/rr-setup --update` after a plugin reinstall. Without `.ai/`: `/rr-setup` first.
25
25
  - Production behaviour is the source of truth: document problems outside the task, do not fix them. Never mix a refactoring with a feature change.
26
26
  - Each step names the files it may touch; an edit outside them is refused — answer `SCOPE_CHANGE_REQUIRED`, do not widen the step.{{MULTI_EDIT}}
27
- - No agent commits, pushes, merges or deploys; the pipeline ends at human approval, given outside the agent. A guard's refusal is not a hint to reword.{{TRUST}}
27
+ - No agent merges or deploys. Commit, push and approval run only as the command the human picked in the dialog — verbatim, once — or the human runs them outside the agent. A guard's refusal is not a hint to reword.{{TRUST}}
28
28
  - Verify before reporting done: the verification command once, to the end, every failure fixed as one batch, output shown.
29
29
  - Context: `grep -n`, then read the hits; never `cat` logs, lockfiles or generated code; a file over ~{{READ_LINES}} lines is read by a FAST reader (`Explore`, `log-reader`) that returns `file:line` excerpts, never the file. Deterministic tools first. {{NEW_CONTEXT}} between tasks. `/rr-status --cost` shows where tokens went.
30
30
  - Models are tiers, not names: runners and verbatim extraction on FAST at `low`, readers that judge on BALANCED; the session implements; STRONG and EXPERT only on a named trigger, one agent at a time, never a fleet on the top tier; a failed thinking task is never retried on a cheaper model.
@@ -47,7 +47,7 @@ This repository runs an agentic pipeline under `.ai/`. Read `.ai/AGENTS.md` firs
47
47
 
48
48
  <!-- stub: scope=project runtime=* -->
49
49
  - Verify before reporting done: `verify_command` from `.ai/policies/testing.md` once, to the end, then `e2e_command` once; every failure fixed as one batch; show the output.
50
- - No agent commits, merges or deploys; approval is given by a human outside the agent. When a review flags the same mistake twice, the correction goes into this file.
50
+ - No agent merges or deploys; commit, push, approval only as the dialog picks, else a human outside. When a review flags the same mistake twice, the correction goes into this file.
51
51
  - Files, not chat, carry decisions: `.ai/reports/<task-id>/task.md` (answer by filling `[Answer]:`), `.ai/state/handoff.md` (read first when resuming), `docs/sdlc/constitution.md` (this project's principles).
52
52
 
53
53
  <!-- stub: scope=skeleton,skeleton-sdlc runtime=* -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "riskrails",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "RiskRails: a weight-based engineering pipeline for Claude Code and Codex — scoped steps, enforcement hooks, adversarial review, model routing.",
5
5
  "keywords": ["claude-code", "codex", "sdlc", "agents", "guardrails", "code-review"],
6
6
  "homepage": "https://github.com/vanssata/claude-agentic-sdlc-module#readme",
package/profiles/max.json CHANGED
@@ -10,12 +10,14 @@
10
10
  "claude-sonnet-5": { "effortLevel": "medium" },
11
11
  "claude-haiku-4-5-20251001": { "effortLevel": "low" }
12
12
  },
13
- "autoCompactWindow": 800000,
14
13
  "riskrails": {
15
14
  "inherits": null,
16
15
  "plan": "max",
17
16
  "label": "Max",
18
17
  "runtime": "claude",
18
+ "economy": {
19
+ "autoCompactWindow": 800000
20
+ },
19
21
  "tiers": {
20
22
  "FAST": {
21
23
  "model": "haiku",
package/profiles/pro.json CHANGED
@@ -8,12 +8,14 @@
8
8
  "claude-sonnet-5": { "effortLevel": "medium" },
9
9
  "claude-haiku-4-5-20251001": { "effortLevel": "high" }
10
10
  },
11
- "autoCompactWindow": 300000,
12
11
  "riskrails": {
13
12
  "inherits": null,
14
13
  "plan": "pro",
15
14
  "label": "Pro",
16
15
  "runtime": "claude",
16
+ "economy": {
17
+ "autoCompactWindow": 300000
18
+ },
17
19
  "tiers": {
18
20
  "FAST": {
19
21
  "model": "haiku",