riskrails 3.0.1 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/README.md +29 -15
  5. package/ai/policies/model-routing.md +7 -7
  6. package/ai/policies/production.md +3 -3
  7. package/ai/policies/release.md +2 -2
  8. package/ai/policies/review-economy.md +27 -30
  9. package/bin/riskrails.js +114 -23
  10. package/hooks/ai-path-guard-defaults.json +3 -2
  11. package/hooks/context-guard.py +15 -12
  12. package/hooks/dialog-gate.py +1 -1
  13. package/hooks/guard/common.py +2 -0
  14. package/hooks/guard/dialog.py +107 -27
  15. package/hooks/guard/git.py +12 -3
  16. package/hooks/guard/paths.py +150 -37
  17. package/hooks/project-scaffold.py +3 -6
  18. package/hooks/runtime-gate.py +134 -33
  19. package/hooks/update_notice.py +22 -16
  20. package/instructions/routing.md +1 -1
  21. package/instructions/stub.md +3 -3
  22. package/package.json +1 -1
  23. package/skills/AGENTS.md +1 -1
  24. package/skills/CLAUDE.md +1 -1
  25. package/skills/ai-init/templates/.ai/AGENTS.md +1 -1
  26. package/skills/ai-init/templates/.ai/VERSION +1 -1
  27. package/skills/ai-init/templates/.ai/policies/security.md +7 -1
  28. package/skills/ai-init/templates/.ai/policies/testing.md +19 -6
  29. package/skills/ai-init/templates/.ai/state/README.md +4 -4
  30. package/skills/ai-init/templates/AGENTS.block.md +2 -2
  31. package/skills/ai-init/templates/CLAUDE.block.md +2 -2
  32. package/skills/ai-init/templates/GEMINI.block.md +2 -2
  33. package/skills/ai-init/templates/gitignore.snippet +1 -0
  34. package/skills/ai-init/templates/junie-guidelines.block.md +2 -2
  35. package/skills/ai-task/rr_state/__init__.py +1 -0
  36. package/skills/ai-task/rr_state/cli.py +210 -0
  37. package/skills/ai-task/rr_state/close.py +171 -0
  38. package/skills/ai-task/rr_state/dialog.py +541 -0
  39. package/skills/ai-task/rr_state/dialog_cmd.py +164 -0
  40. package/skills/ai-task/rr_state/fs.py +220 -0
  41. package/skills/ai-task/rr_state/gate.py +125 -0
  42. package/skills/ai-task/rr_state/gate_cmd.py +128 -0
  43. package/skills/ai-task/rr_state/handoff.py +226 -0
  44. package/skills/ai-task/rr_state/journal.py +126 -0
  45. package/skills/ai-task/rr_state/labels.py +99 -0
  46. package/skills/ai-task/rr_state/ledger.py +145 -0
  47. package/skills/ai-task/rr_state/locks.py +84 -0
  48. package/skills/ai-task/rr_state/memory_cmd.py +310 -0
  49. package/skills/ai-task/rr_state/policy.py +378 -0
  50. package/skills/ai-task/rr_state/profile.py +373 -0
  51. package/skills/ai-task/rr_state/questions.py +336 -0
  52. package/skills/ai-task/rr_state/questions_cmd.py +321 -0
  53. package/skills/ai-task/rr_state/report.py +271 -0
  54. package/skills/ai-task/rr_state/report_cmd.py +230 -0
  55. package/skills/ai-task/rr_state/review.py +165 -0
  56. package/skills/ai-task/rr_state/runtime.py +80 -0
  57. package/skills/ai-task/rr_state/start.py +257 -0
  58. package/skills/ai-task/rr_state/steps.py +131 -0
  59. package/skills/ai-task/rr_state/task.py +396 -0
  60. package/skills/ai-task/rr_state/verify.py +231 -0
  61. package/skills/ai-task/sensors.py +37 -4
  62. package/skills/ai-task/state.py +24 -5138
  63. package/skills/project-init/templates/constitution.md +1 -1
  64. package/skills/project-init/templates/sdlc-README.md +20 -14
  65. package/skills/project-update/adopt.py +38 -24
  66. package/skills/project-update/history/blobs/00289f98434fed230eebe768dfb3425f83d737866a3aebc97aa95167c4fa0c6e +131 -0
  67. package/skills/project-update/history/blobs/079a91f976ad69b5cf49ccb9a28849576e4735ddd9485fc26599e528a69fd2f4 +30 -0
  68. package/skills/project-update/history/blobs/07ff7fa6f93d471ff533f553b4e523e4105ccde1f3dab70132c5237728f68422 +13 -0
  69. package/skills/project-update/history/blobs/0ef4a360c76da290173dea0f8073949cb0f8113710a38df95acae5d96bdd1286 +123 -0
  70. package/skills/project-update/history/blobs/18c7a2753ce9cb6f6d3d1e9d5d1125d75950b879d56b7325225b200869631b29 +15 -0
  71. package/skills/project-update/history/blobs/25d4f2a86deb5e2574bb3210b67bb24fcc4afb19f93a7b65a057daa874a9d18e +1 -0
  72. package/skills/project-update/history/blobs/48d9950f16e31d55475f566a80dc95f64b35f1c804d2f51729e313a4f29bac8d +13 -0
  73. package/skills/project-update/history/blobs/5a93f78b4ed4d416c171139b2ce610a117f9c1dd46fbb56ca86729cb4c53d10c +6 -0
  74. package/skills/project-update/history/blobs/6a3be0e624e5ad91e44d08857b7183ee53b7903e1e8d56ff196dbc9dde271e4a +75 -0
  75. package/skills/project-update/history/blobs/7170553410bba904ffb7edb4b231e3b3579b85542d84de717299980938f82c9d +13 -0
  76. package/skills/project-update/history/blobs/774b1e3a04ef0d49d33c44a2cfecd92eb6f6b738a12b6a681103ba7fc13e7b86 +67 -0
  77. package/skills/project-update/history/blobs/7f063e79195a5c85c37b7260dd20c184d3b9bea85e37202e0bbca73ad84bba49 +13 -0
  78. package/skills/project-update/history/blobs/a51517e13e57420d2d88a6bcfb4055d762d4a0d10a8f88450929c5c4572213a9 +67 -0
  79. package/skills/project-update/history/blobs/c8573ca1f810ce6c4ac49456a4c1725b93e0d3b74c54ce95d46d8747777895f0 +13 -0
  80. package/skills/project-update/history/blobs/d6e5b34d475e7187d2a8cb2aac7e7f1a427142baf6461bda387d70ceb4d8a8c1 +30 -0
  81. package/skills/project-update/history/blobs/e120ff4e32b2e950fc94ada4e787d57dc9c790da37223e5a202d74f7e3aadf80 +13 -0
  82. package/skills/project-update/history/index.json +38 -16
  83. package/skills/project-update/migrations/0003_context_diet.py +1 -1
  84. package/skills/project-update/migrations/0007_weight_model.py +130 -23
  85. package/skills/project-update/migrations/0011_retire_risk_tiers.py +37 -0
  86. package/skills/project-update/migrations/README.md +16 -1
  87. package/skills/project-update/migrations/__init__.py +6 -1
  88. package/skills/project-update/render_instructions.py +1 -1
  89. package/skills/project-update/update.py +569 -106
  90. package/skills/rr-build-all/SKILL.md +2 -2
  91. package/skills/rr-plan/SKILL.md +3 -1
  92. package/skills/rr-review/SKILL.md +13 -8
  93. package/skills/rr-setup/SKILL.md +24 -4
  94. package/skills/rr-setup/setup.py +7 -1
  95. package/skills/rr-spec/SKILL.md +4 -2
  96. package/skills/rr-status/status.py +36 -9
  97. package/skills/usage-report/usage-report.py +4 -2
  98. package/skills/ai-init/templates/.ai/policies/risk-tiers.json +0 -628
  99. package/skills/ai-init/templates/.ai/policies/risk-tiers.md +0 -180
@@ -59,21 +59,18 @@ WHY_APPROVE = T(
59
59
  "and tells the session to run state.py questions --sync. An unattended run\n"
60
60
  "exports AI_UNATTENDED=1 in the launcher's environment, and the journal then\n"
61
61
  "records the approval as unattended for ever. See .ai/policies/safety.md.")
62
- WHY_SET_BY = T(
63
- "\n\nA task gets review_rounds_max review verdicts; after that the human decides,\n"
64
- "in their own terminal:\n"
65
- " python3 <plugin>/skills/ai-task/state.py --root . set review_status <value> --by \"<name>\"\n"
66
- "Show them the findings in .ai/reports/<task-id>/task.md §Review. An unattended\n"
67
- "run exports AI_UNATTENDED=1 in the launcher's environment. See .ai/policies/safety.md.")
68
62
  WHY_WORK_CAP = T(
69
63
  "This command names more paths than the guard can check within its timeout; split it into "
70
64
  "smaller commands, or pass the list through a file and xargs.")
71
65
 
72
66
  TOKEN_END = "([[:space:]\"';&|)<>]|$)"
73
67
 
74
- # The --confirm-delete rule.
68
+ # The --confirm-delete rule, and --confirm-cleanup (the same human gate; any argparse prefix).
75
69
  SEP = "(([^|;&]|[<>]&|&>)*[[:space:]])?" + Q
76
- CONFIRM = "--co(n(f(i(r(m(-(d(e(l(e(t(e)?)?)?)?)?)?)?)?)?)?)?)?(=|[[:space:]]|['\"]|$)"
70
+ CONFIRM_DEL = "d(e(l(e(t(e)?)?)?)?)?"
71
+ CONFIRM_CLEAN = "c(l(e(a(n(u(p)?)?)?)?)?)?"
72
+ CONFIRM = ("--co(n(f(i(r(m(-(" + CONFIRM_DEL + "|" + CONFIRM_CLEAN + ")?)?)?)?)?)?)?"
73
+ "(=|[[:space:]]|['\"]|$)")
77
74
  APPLY = "--ap(p(l(y)?)?)?" + END
78
75
  HEAD = "(^|[[:space:]'\"/|;&(`])(update\\.py|-[A-Za-z]*m[[:space:]]*" + Q + "update|" + VAR + ")" + Q + "[[:space:]]"
79
76
  CONFIRM_RE = HEAD + SEP + CONFIRM + SEP + APPLY + "|" + HEAD + SEP + APPLY + SEP + CONFIRM
@@ -84,22 +81,36 @@ VERB = "([[:space:]]+--)?[[:space:]]+" + Q + "approve" + END
84
81
  BOUND = "(^|[|;&([:space:]`])"
85
82
  APPROVE_RE = (BOUND + "(python3?[[:space:]]+)?" + Q + "[^[:space:]'\"]*state\\.py" + Q + ARGS + VERB + "|"
86
83
  + BOUND + "(python3?[[:space:]]+)?" + Q + VAR + Q + ARGS + VERB)
87
- SETBY_VERB = ("([[:space:]]+--)?[[:space:]]+" + Q + "set" + END + "([^|;&`]*[[:space:]])?" + Q + "--by?"
88
- + Q + "(=|[[:space:]]|$)")
89
- SETBY_RE = (BOUND + "(python3?[[:space:]]+)?" + Q + "[^[:space:]'\"]*state\\.py" + Q + ARGS + SETBY_VERB + "|"
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).
84
+ # R36.11: the other human-only state.py commands — `profile set` (dialog on only)
85
+ # and the --by forms of verify, review and reject (dialog on or off) — run from an
86
+ # agent session only as the next live pick, like approve. `start --mode light --by`
87
+ # is denied with the dialog off only: `light` is the one --mode value that can
88
+ # never raise a mode; with the dialog on it reaches state.py, which opens the
89
+ # lowering decision (review H1, T-2026-10-28-010); any other `start --mode X --by`
90
+ # reaches state.py too (T-2026-10-30-0758 Q1, Q2).
96
91
  PROFILE_SET_VERB = "([[:space:]]+--)?[[:space:]]+" + Q + "profile" + Q + "[[:space:]]+" + Q + "set" + END
97
92
  PROFILE_SET_RE = (BOUND + "(python3?[[:space:]]+)?" + Q + "[^[:space:]'\"]*state\\.py" + Q + ARGS + PROFILE_SET_VERB
98
93
  + "|" + 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)
94
+ BY_TOK = Q + "--by?" + Q + "(=|[[:space:]]|$)"
95
+ MODE_LIGHT = Q + "--m(o(d(e)?)?)?" + Q + "(=|[[:space:]]+)" + Q + "light" + END
96
+ SKIP = "([^|;&`]*[[:space:]])?"
97
+ HUMAN_BY_VERB = ("([[:space:]]+--)?[[:space:]]+" + Q + "(verify|review|reject)" + END + SKIP + BY_TOK)
98
+ START_LIGHT_VERB = ("([[:space:]]+--)?[[:space:]]+" + Q + "start" + END + SKIP + "("
99
+ + MODE_LIGHT + SKIP + BY_TOK + "|" + BY_TOK + SKIP + MODE_LIGHT + ")")
100
+
101
+
102
+ def state_verb_re(verb):
103
+ """The state.py command whose verb part is `verb`, as an ERE: by path or by a variable."""
104
+ return (BOUND + "(python3?[[:space:]]+)?" + Q + "[^[:space:]'\"]*state\\.py" + Q + ARGS
105
+ + "(" + verb + ")|" + BOUND + "(python3?[[:space:]]+)?" + Q + VAR + Q + ARGS + "(" + verb + ")")
106
+
107
+
108
+ HUMAN_BY_RE = state_verb_re(HUMAN_BY_VERB)
109
+ START_LIGHT_RE = state_verb_re(START_LIGHT_VERB)
110
+ WHY_HUMAN = T(
111
+ "\n\nThis command is the human's, whether or not the dialog is on. A human runs it in their\n"
112
+ "own terminal, outside the agent session; an unattended run exports AI_UNATTENDED=1 in the\n"
113
+ "launcher's environment. See .ai/policies/safety.md.")
103
114
  WHY_PICK = T(
104
115
  "\n\nWith the dialog on, this command runs from an agent session only as the human's pick.\n"
105
116
  "The command that needs it opened a decision: 'state.py decision show' prints the dialog;\n"
@@ -137,21 +148,106 @@ LN_SRC_RE = ".*(^|[|;&[:space:]])ln([[:space:]]+-[^[:space:]]+)*[[:space:]]+([^[
137
148
  LONG_CMD = 4096
138
149
  # Whole words only, so --cov, --config and approve_invoice do not trip it.
139
150
  LONG_TRIGGER_RE = ("(^|[^A-Za-z0-9_])approve([^A-Za-z0-9_]|$)|--by(=|[[:space:]]|$)|"
140
- "--con?f?i?r?m?-?d?e?l?e?t?e?(=|[[:space:]]|$)|state\\.py|update\\.py")
151
+ "--con?f?i?r?m?-?(d?e?l?e?t?e?|c?l?e?a?n?u?p?)(=|[[:space:]]|$)|state\\.py|update\\.py")
141
152
  # Tools that write a file named on their command line without a redirect; the
142
153
  # movers do not parse them, so any protected path they name is refused (H1).
143
154
  FETCHERS = "(^|[|;&[:space:]])(scp|tar|zip|unzip|curl|wget)([[:space:]]+[^;&|[:space:]]+)*[[:space:]]+"
144
155
  # An interpreter one-liner is not parsed, its text is scanned: one that names a
145
156
  # secret, or one that names a control file and looks like a write, is refused (H3).
146
- INTERP_RE = "(^|[|;&[:space:]])(python3?|perl|ruby|node|php)([[:space:]]+-[^[:space:]]+)*[[:space:]]+-(c|e)([[:space:]]|$)"
157
+ INTERP_RE = ("(^|[|;&[:space:]])(python3?|perl|ruby|node|php)([[:space:]]+-[^[:space:]]+)*[[:space:]]+-(c|e)([[:space:]]|$)|"
158
+ "(^|[|;&[:space:]])php([[:space:]]+-[^[:space:]]+)*[[:space:]]+-r([[:space:]]|$)")
147
159
  CONTROL_TEXT_RE = "\\.ai/state/|\\.ai/reports/"
148
- INPLACE_RE = ("(^|[|;&[:space:]])(perl|ruby)([[:space:]]+-([CEIMmr][[:space:]]*[^[:space:]]+|[^[:space:]]+))*"
149
- "[[:space:]]+-[acdfghlnpsStTuUvwWXy]*i")
160
+ # perl or ruby with -i, behind any blank, separator, ( ` { or quote, so every wrapper and
161
+ # position is caught; Guard.inplace_edit carves out the segments of a text tool (grep, echo,
162
+ # git log ...) that merely name it. An -e/-E/-ne code argument may be one quoted word.
163
+ INPLACE_RE = ("(^|[|;&(`{'\"[:space:]])"
164
+ "(perl|ruby)([[:space:]]+-([CEIMmr][[:space:]]*[^[:space:]]+|[a-zA-Z]*[eE][[:space:]]+('[^']*'|\"[^\"]*\")|"
165
+ "[^[:space:]]+))*[[:space:]]+-[acdfghlnpsStTuUvwWXy]*i")
166
+ _TEXT_TOOLS = {"grep", "egrep", "fgrep", "rg", "ag", "ack", "echo", "printf"}
167
+ _GIT_TEXT = {"log", "grep", "commit", "show", "diff", "tag", "notes"}
168
+ _STD_WRITE = re.compile(r"(writeSync|writevSync)\([ \t]*(1|2|process\.std(out|err)\.fd)[ \t]*,|"
169
+ r"v?fprintf\([ \t]*STD(OUT|ERR)[ \t]*,")
170
+
171
+
172
+ _SHELL_SINKS = {"sh", "bash", "zsh", "dash", "ksh", "ssh", "source", ".", "eval"}
173
+ _XARGS_ARG = {"-I", "-n", "-P", "-L", "-s", "-E", "-d", "-a"}
174
+ _GIT_RUNS = ("GIT_", "PAGER", "EDITOR", "VISUAL", "SSH_COMMAND")
175
+ _GIT_CFG = re.compile(r"[A-Za-z0-9_.-]+=[A-Za-z0-9_./:-]*$")
176
+
177
+
178
+ def _tool_word(w):
179
+ """The bare word, /bin/<w> or /usr/bin/<w>; any other path is a program of its own."""
180
+ if w.startswith("/bin/"):
181
+ return w[5:]
182
+ if w.startswith("/usr/bin/"):
183
+ return w[9:]
184
+ return w
185
+
186
+
187
+ def shell_sink(seg):
188
+ """A segment that runs its input or its arguments as shell text."""
189
+ words = seg.split()
190
+ if not words:
191
+ return False
192
+ w = _tool_word(words[0])
193
+ if w in _SHELL_SINKS:
194
+ return True
195
+ return w == "xargs" and any(_tool_word(x) in _SHELL_SINKS for x in words[1:])
196
+
197
+
198
+ def _plain_text_tool(words, i, git_runs):
199
+ if i >= len(words):
200
+ return False
201
+ w = _tool_word(words[i])
202
+ if w in _TEXT_TOOLS:
203
+ return True
204
+ if w != "git" or git_runs:
205
+ return False
206
+ if any(x.startswith(("-O", "--open-files-in-pager", "--config-env")) for x in words[i + 1:]):
207
+ return False
208
+ k = i + 1
209
+ while k < len(words):
210
+ x = words[k]
211
+ if x in ("-C", "-c"):
212
+ if x == "-c" and (k + 1 >= len(words) or not _GIT_CFG.match(words[k + 1])):
213
+ return False
214
+ k += 2
215
+ elif x.startswith("-"):
216
+ k += 1
217
+ else:
218
+ return x in _GIT_TEXT
219
+ return False
220
+
221
+
222
+ def text_tool_segment(seg, git_runs=False):
223
+ """A segment whose command word is a text tool: a program named in it is an argument.
224
+ xargs and find -exec are looked through; git_runs is set when the line may hand git a
225
+ command to run (a stripped GIT_PAGER=... assignment)."""
226
+ words = seg.split()
227
+ if not words:
228
+ return False
229
+ w = _tool_word(words[0])
230
+ if w == "xargs":
231
+ i = 1
232
+ while i < len(words) and words[i].startswith("-"):
233
+ i += 2 if words[i] in _XARGS_ARG else 1
234
+ return _plain_text_tool(words, i, git_runs)
235
+ if w == "find":
236
+ found = False
237
+ for k, x in enumerate(words):
238
+ if x in ("-exec", "-execdir", "-ok", "-okdir"):
239
+ if not _plain_text_tool(words, k + 1, git_runs):
240
+ return False
241
+ found = True
242
+ return found
243
+ return _plain_text_tool(words, 0, git_runs)
244
+
245
+
150
246
  EVAL_WORD_RE = "(^|[|;&[:space:]])eval([[:space:]]|$)"
151
247
  SECRET_TEXT_RE = "\\.env|secrets?/|credentials?|id_rsa|\\.pem|\\.aws"
152
248
  WRITE_CALL_RE = (
153
249
  "(^|[^A-Za-z0-9_])(write|writelines|write_text|write_bytes|write_file|writeFile|writeFileSync|appendFile|"
154
- "appendFileSync|createWriteStream|binwrite|syswrite|fwrite|fputs|file_put_contents|dump|unlink|unlinkSync|"
250
+ "appendFileSync|createWriteStream|binwrite|syswrite|fwrite|fputs|fprintf|vfprintf|writeSync|writevSync|file_put_contents|dump|unlink|unlinkSync|"
155
251
  "remove|removedirs|rename|renames|renameSync|rmdir|rmdirSync|rm|rmSync|rmtree|mkdir|mkdirSync|makedirs|"
156
252
  "mkdtemp|truncate|truncateSync|ftruncate|symlink|symlinkSync|symlink_to|hardlink_to|link|linkSync|link_to|"
157
253
  "touch|chmod|chown|utime|utimes|copyFile|copyFileSync|cp|cpSync|move|delete|system|exec|execSync|execFile|"
@@ -1171,7 +1267,8 @@ class PathGuard: # pylint: disable=too-many-instance-
1171
1267
  p = self.p
1172
1268
  if p.tool not in ("Read", "Edit", "Write", "MultiEdit", "NotebookEdit", "Bash"):
1173
1269
  return
1174
- unattended = bool(env_b("AI_UNATTENDED"))
1270
+ # AI_UNATTENDED is set when non-empty and not 0, false or no: one rule (risk 0587)
1271
+ unattended = env_b("AI_UNATTENDED").strip().lower() not in ("", "0", "false", "no")
1175
1272
  if p.tool == "Bash" and not unattended:
1176
1273
  self.confirm_delete(p.bash_command())
1177
1274
  root = find_ai_root(p.cwd, self.ctx.pwd)
@@ -1216,6 +1313,22 @@ class PathGuard: # pylint: disable=too-many-instance-
1216
1313
  command = b_bytes(cmd).decode("utf-8", "replace").strip()
1217
1314
  return True, bool(pick and pick.get("label") == command)
1218
1315
 
1316
+ def inplace_edit(self, cmd):
1317
+ """perl or ruby -i as a command: INPLACE_RE on the line less the segments of a text tool."""
1318
+ m = self.m
1319
+ if not m.ere_match(INPLACE_RE, cmd):
1320
+ return False
1321
+ if "<<" in cmd: # a heredoc body is read by whoever reads it
1322
+ return True
1323
+ segs = shell_segments(cmd).split("\n")
1324
+ if "|" in cmd and any(shell_sink(sg) for sg in segs):
1325
+ return True # a text tool's output may run as shell text
1326
+ git_runs = any(t in cmd for t in _GIT_RUNS)
1327
+ if git_runs and any(_tool_word(sg.split(None, 1)[0]) == "git" for sg in segs if sg.strip()):
1328
+ return True # the assignment that runs a command is not in the segment
1329
+ keep = [sg for sg in segs if not text_tool_segment(sg, git_runs)]
1330
+ return m.ere_match(INPLACE_RE, "\n".join(keep))
1331
+
1219
1332
  def interpreter_text(self, cmd):
1220
1333
  """The text of an interpreter one-liner or an eval, scanned whole (H3)."""
1221
1334
  m = self.m
@@ -1224,10 +1337,10 @@ class PathGuard: # pylint: disable=too-many-instance-
1224
1337
  if (is_eval or is_interp) and m.ere_match(SECRET_TEXT_RE, cmd):
1225
1338
  raise Deny("Refusing an interpreter one-liner that references a sensitive path." + WHY_SENSITIVE)
1226
1339
  if m.ere_match(CONTROL_TEXT_RE, cmd):
1227
- if is_eval or m.ere_match(INPLACE_RE, cmd):
1340
+ if is_eval or self.inplace_edit(cmd):
1228
1341
  raise Deny("Refusing an interpreter one-liner that writes the task's own control files." + WHY_APPROVE)
1229
1342
  if is_interp:
1230
- scan = cmd.replace("stdout.write", " ").replace("stderr.write", " ")
1343
+ scan = _STD_WRITE.sub(" ", cmd.replace("stdout.write", " ").replace("stderr.write", " "))
1231
1344
  pscan = cmd.replace("copy.copy(", " ")
1232
1345
  if (m.ere_match(WRITE_CALL_RE, scan) or m.ere_match(WRITE_WORD_RE, cmd)
1233
1346
  or (m.ere_match("Path", cmd)
@@ -1255,14 +1368,14 @@ class PathGuard: # pylint: disable=too-many-instance-
1255
1368
  on, picked = self.dialog_consent(cmd)
1256
1369
  if m.ere_match(APPROVE_RE, cmd_joined) and not unattended and self.task_in_flight() and not picked:
1257
1370
  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)
1263
- if m.ere_match(SETBY_RE, cmd_joined) and not unattended and self.task_in_flight():
1264
- raise Deny(T("Refusing 'state.py set \u2026 --by' from an agent session: a verdict with --by is the "
1265
- "human's, given when the review budget is spent.") + WHY_SET_BY)
1371
+ if on and not picked and not unattended and m.ere_match(PROFILE_SET_RE, cmd_joined):
1372
+ raise Deny("Refusing 'state.py profile set' from an agent session." + WHY_PICK)
1373
+ if not picked and not unattended and m.ere_match(HUMAN_BY_RE, cmd_joined):
1374
+ raise Deny(T("Refusing a verdict with 'state.py … --by' from an agent session.")
1375
+ + (WHY_PICK if on else WHY_HUMAN))
1376
+ if not on and not picked and not unattended and m.ere_match(START_LIGHT_RE, cmd_joined):
1377
+ raise Deny(T("Refusing 'state.py start --mode light … --by' from an agent session: lowering the "
1378
+ "mode is the human's.") + WHY_HUMAN)
1266
1379
  self.interpreter_text(cmd)
1267
1380
 
1268
1381
  # Top-level splitting only: the string a sh -c or an eval runs is not
@@ -204,7 +204,8 @@ class Scaffold:
204
204
  if os.path.exists(os.path.join(d, ".gitkeep")):
205
205
  return
206
206
  os.makedirs(d, exist_ok=True)
207
- open(os.path.join(d, ".gitkeep"), "wb").close()
207
+ with open(os.path.join(d, ".gitkeep"), "wb"):
208
+ pass
208
209
  self.created.append(rel + "/.gitkeep")
209
210
 
210
211
  def append_once(self, path, marker, template, label):
@@ -236,12 +237,8 @@ def main(argv):
236
237
  on = runtimes(runtime, root)
237
238
  s = Scaffold(tpl, root)
238
239
 
239
- # shared SDLC
240
+ # shared SDLC (the intent/spec/plan/adr templates ship with the plugin; a project TEMPLATE.md is only an override)
240
241
  s.put("sdlc-README.md", "docs/sdlc/README.md")
241
- s.put("intent.md", "docs/sdlc/intent/TEMPLATE.md")
242
- s.put("spec.md", "docs/sdlc/specs/TEMPLATE.md")
243
- s.put("plan.md", "docs/sdlc/plans/TEMPLATE.md")
244
- s.put("adr.md", "docs/sdlc/adr/TEMPLATE.md")
245
242
  s.put("constitution.md", "docs/sdlc/constitution.md", render=True)
246
243
 
247
244
  # per runtime
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env python3
2
- """Runtime gate: one hook for both runtimes, replacing fable-gate.py and
3
- codex-model-gate.py (which remain as shims that exec this file).
2
+ """Runtime gate: one hook for both runtimes (Claude Code and Codex). The older
3
+ fable-gate.py and codex-model-gate.py remain only as shims that exec this file.
4
4
 
5
5
  The most capable model of a plan can stop serving an account — a rate limit, a
6
6
  used-up usage limit, a model the account cannot reach — and the runtime's own
@@ -138,7 +138,7 @@ def profile_tier(home, tier, field, default):
138
138
  with open(profile_file(home), encoding="utf-8") as fh:
139
139
  value = json.load(fh)["tiers"][tier][field]
140
140
  return value if isinstance(value, str) and value else default
141
- except (OSError, ValueError, KeyError, TypeError):
141
+ except (OSError, ValueError, KeyError, TypeError, RecursionError):
142
142
  return default
143
143
 
144
144
 
@@ -148,7 +148,7 @@ def profile_fable(home):
148
148
  try:
149
149
  with open(profile_file(home), encoding="utf-8") as fh:
150
150
  return bool(json.load(fh).get("fable", True))
151
- except (OSError, ValueError, AttributeError):
151
+ except (OSError, ValueError, AttributeError, RecursionError):
152
152
  return True
153
153
 
154
154
 
@@ -205,7 +205,7 @@ def load(path=None):
205
205
  return data if isinstance(data, dict) else {}
206
206
  except OSError:
207
207
  return import_legacy() if path in (None, STATE) else {}
208
- except ValueError:
208
+ except (ValueError, RecursionError):
209
209
  return {}
210
210
 
211
211
 
@@ -218,7 +218,7 @@ def import_legacy():
218
218
  try:
219
219
  with open(LEGACY_STATE, encoding="utf-8") as fh:
220
220
  old = json.load(fh)
221
- except (OSError, ValueError):
221
+ except (OSError, ValueError, RecursionError):
222
222
  return {}
223
223
  data = {"imported_from": os.path.basename(LEGACY_STATE)}
224
224
  if isinstance(old, dict) and isinstance(old.get("unavailable"), dict):
@@ -341,7 +341,7 @@ def claude_code_version(path):
341
341
  continue
342
342
  try:
343
343
  version = json.loads(line).get("version")
344
- except (ValueError, AttributeError):
344
+ except (ValueError, AttributeError, RecursionError):
345
345
  continue
346
346
  m = re.match(r"(\d+)\.(\d+)\.(\d+)", version) if isinstance(version, str) else None
347
347
  if m:
@@ -415,10 +415,10 @@ def claude_pre_tool_use(payload):
415
415
  "hookSpecificOutput": {
416
416
  "hookEventName": "PreToolUse",
417
417
  "permissionDecision": "allow",
418
- "permissionDecisionReason": f"fable-gate: {why}; {name} runs on {FALLBACK}",
418
+ "permissionDecisionReason": f"runtime-gate: {why}; {name} runs on {FALLBACK}",
419
419
  "updatedInput": {**tool_input, "model": FALLBACK},
420
420
  "additionalContext": (
421
- f"fable-gate routed this {name} agent to {FALLBACK}: {why}. "
421
+ f"runtime-gate routed this {name} agent to {FALLBACK}: {why}. "
422
422
  f"Its answer is {FALLBACK}'s, not Fable's. "
423
423
  "`~/.claude/hooks/runtime-gate.py clear` re-enables Fable early."
424
424
  ),
@@ -444,7 +444,7 @@ def last_transcript_model(path):
444
444
  for line in reversed(transcript_tail(path)):
445
445
  try:
446
446
  model = (json.loads(line).get("message") or {}).get("model")
447
- except (ValueError, AttributeError):
447
+ except (ValueError, AttributeError, RecursionError):
448
448
  continue
449
449
  if isinstance(model, str) and model and not model.startswith("<"):
450
450
  return model
@@ -504,9 +504,10 @@ ROLLOUT_TAIL = 64 * 1024
504
504
 
505
505
  def number(value):
506
506
  try:
507
- return None if value is None else float(value)
507
+ x = None if value is None else float(value)
508
508
  except (TypeError, ValueError):
509
509
  return None
510
+ return x if x is None or math.isfinite(x) else None # NaN/Infinity is no reading
510
511
 
511
512
 
512
513
  def record_quota(weekly, five_hour, resets_at, source, seen_at=None):
@@ -564,7 +565,7 @@ def rollout_limits(path):
564
565
  continue
565
566
  try:
566
567
  event = json.loads(line)
567
- except ValueError:
568
+ except (ValueError, RecursionError):
568
569
  continue
569
570
  limits = (event.get("payload") or {}).get("rate_limits") or {}
570
571
  weekly = five = None
@@ -801,10 +802,10 @@ def codex_pre_tool_use(payload):
801
802
  journal_fallback(payload, label, model, to, rec)
802
803
  out["hookSpecificOutput"]["permissionDecision"] = "allow"
803
804
  out["hookSpecificOutput"]["updatedInput"] = updated
804
- context = (f"codex-model-gate: {why}. {label} runs on {to}{via} instead, so its answer is "
805
+ context = (f"runtime-gate: {why}. {label} runs on {to}{via} instead, so its answer is "
805
806
  f"{to}'s, not {EXPERT}'s. Say so when you report it. ")
806
807
  else:
807
- context = (f"codex-model-gate: {why}. {label} is not rerouted ({not_why}): it runs on {EXPERT} "
808
+ context = (f"runtime-gate: {why}. {label} is not rerouted ({not_why}): it runs on {EXPERT} "
808
809
  "and may fail the same way. Say so when you report it. ")
809
810
  out["hookSpecificOutput"]["additionalContext"] = context + "`runtime-gate.py clear` re-enables the expert model early."
810
811
  respond(out)
@@ -864,7 +865,7 @@ def profile():
864
865
  with open(profile_file(HOME), encoding="utf-8") as fh:
865
866
  data = json.load(fh)
866
867
  return data if isinstance(data, dict) else {}
867
- except (OSError, ValueError):
868
+ except (OSError, ValueError, RecursionError):
868
869
  return {}
869
870
 
870
871
 
@@ -919,7 +920,7 @@ def project_task(cwd):
919
920
  try:
920
921
  with open(path, encoding="utf-8") as fh:
921
922
  task = json.load(fh).get("task_id")
922
- except (OSError, ValueError, AttributeError):
923
+ except (OSError, ValueError, AttributeError, RecursionError):
923
924
  return None, None
924
925
  return (here, task) if task else (None, None)
925
926
  parent = os.path.dirname(here)
@@ -985,11 +986,18 @@ def codex_missed_reroute(payload):
985
986
  "or an unavailable model, stop and say so instead of retrying.")}})
986
987
 
987
988
 
989
+ def ai_unattended():
990
+ """AI_UNATTENDED is set when it is non-empty and not 0, false or no (case
991
+ and blanks ignored): one rule in every reader (risk 0587)."""
992
+ return os.environ.get("AI_UNATTENDED", "").strip().lower() not in ("", "0", "false", "no")
993
+
994
+
988
995
  def unattended():
989
- """Nobody can answer an ask: AI_UNATTENDED=1, or Claude Code's own
990
- CLAUDE_CODE_SESSION_ATTENDED=0 (set by `claude -p` and the SDK). Unset —
991
- an older Claude Code — still asks."""
992
- return os.environ.get("AI_UNATTENDED") == "1" or os.environ.get("CLAUDE_CODE_SESSION_ATTENDED") == "0"
996
+ """Nobody can answer an ask: AI_UNATTENDED set (ai_unattended), or Claude
997
+ Code's own CLAUDE_CODE_SESSION_ATTENDED=0 (set by `claude -p` and the SDK).
998
+ Unset — an older Claude Code — still asks. dialog.json background_sessions
999
+ is the dialog's opt-in only: this gate still treats ATTENDED=0 as unattended."""
1000
+ return ai_unattended() or os.environ.get("CLAUDE_CODE_SESSION_ATTENDED") == "0"
993
1001
 
994
1002
 
995
1003
  def will_ask():
@@ -1117,24 +1125,31 @@ def disabled():
1117
1125
 
1118
1126
 
1119
1127
  def context_line(payload):
1120
- """`Opus 5.5 [1m] · 123k/1000k (12%)` from a Claude Code statusline payload, or ""
1128
+ """`Opus 5.5 [1m] high · 123k/1000k (12%)` (the effort right after the model,
1129
+ when the payload names one) from a Claude Code statusline payload, or ""
1121
1130
  when it names neither the model nor the context. The context_window fields differ
1122
1131
  between Claude Code versions: the tokens come from current_usage (input plus cache
1123
1132
  reads and writes, what the next turn re-reads), else from used_percentage."""
1124
1133
  model = payload.get("model")
1125
1134
  name = str(model.get("display_name") or model.get("id") or "") if isinstance(model, dict) else ""
1135
+ effort = payload.get("effort")
1136
+ effort = effort.get("level") if isinstance(effort, dict) else None
1137
+ if name and isinstance(effort, str) and effort.strip():
1138
+ name = "%s %s" % (name, effort.strip())
1126
1139
  window = payload.get("context_window")
1127
1140
  window = window if isinstance(window, dict) else {}
1128
1141
 
1129
- def number(value):
1130
- return value if isinstance(value, (int, float)) and not isinstance(value, bool) and value >= 0 else None
1142
+ def finite_number(value):
1143
+ # finite only: Infinity makes round() raise and the whole line would be lost
1144
+ return (value if isinstance(value, (int, float)) and not isinstance(value, bool)
1145
+ and math.isfinite(value) and value >= 0 else None)
1131
1146
 
1132
- size = number(window.get("context_window_size"))
1133
- pct = number(window.get("used_percentage"))
1147
+ size = finite_number(window.get("context_window_size"))
1148
+ pct = finite_number(window.get("used_percentage"))
1134
1149
  usage = window.get("current_usage")
1135
1150
  used = None
1136
1151
  if isinstance(usage, dict):
1137
- parts = [number(usage.get(k)) for k in
1152
+ parts = [finite_number(usage.get(k)) for k in
1138
1153
  ("input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens")]
1139
1154
  if any(p is not None for p in parts):
1140
1155
  used = sum(p or 0 for p in parts)
@@ -1148,15 +1163,96 @@ def context_line(payload):
1148
1163
  return " · ".join(part for part in (name, context) if part)
1149
1164
 
1150
1165
 
1166
+ SL_RESET, SL_SEP = "\033[00m", " \033[02m|\033[00m "
1167
+
1168
+
1169
+ def reset_text(value, fmt):
1170
+ """A rate-limit window's resets_at (epoch seconds or an ISO timestamp) as
1171
+ local time in `fmt`, or ""."""
1172
+ try:
1173
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
1174
+ when = dt.datetime.fromtimestamp(value)
1175
+ elif isinstance(value, str) and value.strip():
1176
+ text = value.strip()
1177
+ when = (dt.datetime.fromtimestamp(float(text)) if re.fullmatch(r"\d+(\.\d+)?", text)
1178
+ else dt.datetime.fromisoformat(text.replace("Z", "+00:00")).astimezone())
1179
+ else:
1180
+ return ""
1181
+ return when.strftime(fmt)
1182
+ except (ValueError, OverflowError, OSError):
1183
+ return ""
1184
+
1185
+
1186
+ def git_branch(cwd):
1187
+ """The branch checked out in cwd, else the short commit, else ""."""
1188
+ for args in (["symbolic-ref", "--short", "-q", "HEAD"], ["rev-parse", "--short", "HEAD"]):
1189
+ try:
1190
+ done = subprocess.run(["git", "--no-optional-locks", "-C", cwd] + args, capture_output=True,
1191
+ text=True, timeout=2, check=False)
1192
+ except (OSError, subprocess.SubprocessError):
1193
+ return ""
1194
+ if done.returncode == 0 and done.stdout.strip():
1195
+ return done.stdout.strip()
1196
+ return ""
1197
+
1198
+
1199
+ def second_line(payload):
1200
+ """`user@host:cwd | branch | 5h:12% (↻14:00) 7d:41% (↻Fri 03:04) | $1.23`, the
1201
+ colours of a Debian colour PS1; each part after the prompt is skipped when its
1202
+ data is missing."""
1203
+ workspace = payload.get("workspace")
1204
+ cwd = workspace.get("current_dir") if isinstance(workspace, dict) else None
1205
+ cwd = cwd if isinstance(cwd, str) and cwd else os.getcwd()
1206
+ user = os.environ.get("USER") or os.environ.get("LOGNAME") or ""
1207
+ if not user:
1208
+ try:
1209
+ import getpass # pylint: disable=import-outside-toplevel
1210
+ user = getpass.getuser()
1211
+ except (ImportError, KeyError, OSError):
1212
+ user = "?"
1213
+ import socket # pylint: disable=import-outside-toplevel
1214
+ host = socket.gethostname().split(".")[0]
1215
+ out = "\033[01;32m%s@%s%s:\033[01;34m%s%s" % (user, host, SL_RESET, cwd, SL_RESET)
1216
+ branch = git_branch(cwd) if os.path.isdir(cwd) else ""
1217
+ if branch:
1218
+ out += "%s\033[35m%s%s" % (SL_SEP, branch, SL_RESET)
1219
+ limits = payload.get("rate_limits")
1220
+ limits = limits if isinstance(limits, dict) else {}
1221
+ parts = []
1222
+ for key, label, fmt in (("five_hour", "5h", "%H:%M"), ("seven_day", "7d", "%a %H:%M")):
1223
+ window = limits.get(key)
1224
+ pct = window.get("used_percentage") if isinstance(window, dict) else None
1225
+ if isinstance(pct, (int, float)) and not isinstance(pct, bool) and math.isfinite(pct):
1226
+ reset = reset_text(window.get("resets_at"), fmt)
1227
+ parts.append("%s:%d%%%s" % (label, round(pct), " (↻%s)" % reset if reset else ""))
1228
+ if parts:
1229
+ out += "%s\033[33m%s%s" % (SL_SEP, " ".join(parts), SL_RESET)
1230
+ cost = payload.get("cost")
1231
+ cost = cost.get("total_cost_usd") if isinstance(cost, dict) else None
1232
+ if isinstance(cost, (int, float)) and not isinstance(cost, bool) and math.isfinite(cost):
1233
+ out += "%s\033[32m$%.2f%s" % (SL_SEP, cost, SL_RESET)
1234
+ return out
1235
+
1236
+
1237
+ def emit(text):
1238
+ """One statusline line as UTF-8 whatever the locale (`·` and `↻` are not ASCII)."""
1239
+ sys.stdout.flush()
1240
+ sys.stdout.buffer.write((text + "\n").encode("utf-8", "replace"))
1241
+ sys.stdout.buffer.flush()
1242
+
1243
+
1151
1244
  def statusline_main(argv):
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."""
1245
+ """`statusline [--context] [--line] [--then <command>]`: check the weekly limit,
1246
+ print the model, effort and context use with --context (cyan), the prompt line
1247
+ (user@host:cwd | branch | limits | cost) with --line, then hand the same input
1248
+ to the user's own statusline command and pass its output and exit code through.
1249
+ Neither the check nor the lines ever get in the way of the statusline."""
1156
1250
  data = sys.stdin.buffer.read()
1157
- show_context = argv[1:2] == ["--context"]
1158
- if show_context:
1251
+ flags = set()
1252
+ while argv[1:2] in (["--context"], ["--line"]):
1253
+ flags.add(argv[1])
1159
1254
  argv = argv[:1] + argv[2:]
1255
+ show_context = "--context" in flags
1160
1256
  payload = None
1161
1257
  try:
1162
1258
  payload = json.loads(data.decode("utf-8", "replace"))
@@ -1171,7 +1267,12 @@ def statusline_main(argv):
1171
1267
  try:
1172
1268
  line = context_line(payload)
1173
1269
  if line:
1174
- print(line)
1270
+ emit("\033[36m%s%s" % (line, SL_RESET))
1271
+ except Exception: # pylint: disable=broad-exception-caught # fail open, as above
1272
+ pass
1273
+ if "--line" in flags and isinstance(payload, dict):
1274
+ try:
1275
+ emit(second_line(payload))
1175
1276
  except Exception: # pylint: disable=broad-exception-caught # fail open, as above
1176
1277
  pass
1177
1278
  if len(argv) >= 3 and argv[1] == "--then" and argv[2].strip():