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
@@ -0,0 +1,373 @@
1
+ """profile: the plan profile, the live plan detection for both runtimes, the drift acknowledgement and the profile resolver.
2
+
3
+ core: js_number, js_str, js_truthy, jwt_plan_type, SAME_TIER, context_mode, installer_command, PLAN_PROFILES, DIRECT_MODE_CAP
4
+ shell: profile_file, read_profile, read_quota, preferred_runtime, advisory_line, advise, read_json_object, detect_claude, detect_codex, profile_detect, acknowledged, drift_ack_path, read_drift_ack, write_drift_ack, open_drift, load_resolver
5
+ """
6
+ import base64
7
+ import decimal
8
+ import hashlib
9
+ import importlib.util
10
+ import json
11
+ import os
12
+ import re
13
+ import shlex
14
+ import sys
15
+ from datetime import datetime, timezone
16
+
17
+ from rr_state import dialog
18
+ from rr_state import fs
19
+ from rr_state import ledger
20
+ from rr_state import runtime as rr_runtime
21
+ from rr_state import task
22
+
23
+
24
+ def profile_file(home):
25
+ """<home>/riskrails/profile.json, or <home>/claude-agentic/profile.json where
26
+ the installer has not run since 3.4b (R35, one version)."""
27
+ path = os.path.join(home, "riskrails", "profile.json")
28
+ return path if os.path.exists(path) else os.path.join(home, "claude-agentic", "profile.json")
29
+
30
+
31
+ def read_profile(runtime=None, rt=rr_runtime.Runtime()):
32
+ """<home>/riskrails/profile.json, written by the installer; {} when absent,
33
+ which turns every plan budget into a no-op."""
34
+ try:
35
+ with open(profile_file(rr_runtime.runtime_home(runtime or rr_runtime.own_runtime(runtime=rt.name))), encoding="utf-8") as fh:
36
+ data = dialog.json_loads_capped(fh.read())
37
+ return data if isinstance(data, dict) else {}
38
+ except (OSError, ValueError, RecursionError):
39
+ return {}
40
+
41
+
42
+ def read_quota(runtime):
43
+ """The weekly percentage runtime-gate last recorded, or None when unknown
44
+ or stale — advice never rests on a reading older than a day."""
45
+ try:
46
+ with open(os.path.join(rr_runtime.runtime_home(runtime), "state", "runtime-gate.json"),
47
+ encoding="utf-8") as fh:
48
+ quota = json.load(fh).get("quota") or {}
49
+ seen, resets = float(quota.get("seen_at") or 0), float(quota.get("resets_at") or 0)
50
+ now_s = datetime.now(timezone.utc).timestamp()
51
+ if not seen or now_s - seen > 86400 or (resets and now_s > resets):
52
+ return None
53
+ pct = quota.get("weekly_pct")
54
+ return float(pct) if pct is not None else None
55
+ except (OSError, ValueError, AttributeError, TypeError, RecursionError):
56
+ return None
57
+
58
+
59
+ def preferred_runtime(workflow, rt=rr_runtime.Runtime()):
60
+ """(runtime, reason) when the plan's table or the quota rule names the
61
+ other runtime and it is installed; None otherwise. Advice only (D3)."""
62
+ me = rr_runtime.own_runtime(runtime=rt.name)
63
+ other = rr_runtime.other_runtime(me, rt=rt)
64
+ if not rr_runtime.installed(other):
65
+ return None
66
+ pref = read_profile(me, rt=rt).get("preferred_runtime") or {}
67
+ named = (pref.get("by_workflow") or {}).get(workflow) or pref.get("default") or "same"
68
+ if named == other:
69
+ return other, "plan table: %s -> %s" % (workflow, other)
70
+ limit = pref.get("quota_pct")
71
+ mine, theirs = read_quota(me), read_quota(other)
72
+ if limit is not None and mine is not None and mine >= float(limit) \
73
+ and (theirs is None or theirs < float(limit)):
74
+ return other, "quota %.0f%% >= %.0f%%" % (mine, float(limit))
75
+ return None
76
+
77
+
78
+ def advisory_line(state, rt=rr_runtime.Runtime()):
79
+ found = preferred_runtime(state.get("workflow"), rt=rt)
80
+ return "preferred runtime: %s (%s)" % found if found else None
81
+
82
+
83
+ def advise(state, rt=rr_runtime.Runtime()):
84
+ line = advisory_line(state, rt=rt)
85
+ if line:
86
+ print(line, file=sys.stderr) # stdout carries the task id callers capture
87
+
88
+
89
+ def js_number(value):
90
+ """Number.prototype.toString for a JSON number: shortest digits, exponent
91
+ form below 1e-6 and from 1e21."""
92
+ f = float(value)
93
+ if f == 0:
94
+ return "0"
95
+ d = decimal.Decimal(repr(f))
96
+ if 1e-6 <= abs(f) < 1e21:
97
+ text = format(d, "f")
98
+ return text.rstrip("0").rstrip(".") if "." in text else text
99
+ sign, digits, exp = d.normalize().as_tuple()
100
+ mant = "".join(map(str, digits))
101
+ power = exp + len(mant) - 1
102
+ return "%s%s%s%se%s%d" % ("-" if sign else "", mant[0], "." if len(mant) > 1 else "", mant[1:],
103
+ "+" if power >= 0 else "-", abs(power))
104
+
105
+
106
+ def js_str(value):
107
+ """String(value) as bin/riskrails.js wrote it, for the detection port; None
108
+ is "" because every JS caller had already turned null into ''."""
109
+ if value is None:
110
+ return ""
111
+ if isinstance(value, bool):
112
+ return "true" if value else "false"
113
+ if isinstance(value, int):
114
+ return str(value) if abs(value) <= 2 ** 53 else js_number(value)
115
+ if isinstance(value, float):
116
+ return js_number(value)
117
+ if isinstance(value, list):
118
+ return ",".join(js_str(v) for v in value)
119
+ if isinstance(value, dict):
120
+ return "[object Object]"
121
+ return str(value)
122
+
123
+
124
+ def js_truthy(value):
125
+ return not (value is None or value is False or value == "" or
126
+ (isinstance(value, (int, float)) and not isinstance(value, bool) and value == 0))
127
+
128
+
129
+ def read_json_object(path):
130
+ try:
131
+ with open(path, encoding="utf-8") as fh:
132
+ data = json.load(fh)
133
+ except (OSError, ValueError, RecursionError):
134
+ return None
135
+ return data if isinstance(data, dict) else {}
136
+
137
+
138
+ def detect_claude():
139
+ """The port of detectClaudePlan (bin/riskrails.js before R36.16): ~/.claude.json's
140
+ oauthAccount, the same regular expressions in the same order."""
141
+ home = os.environ.get("HOME") or os.path.expanduser("~")
142
+ account = read_json_object(os.path.join(home, ".claude.json")) or {}
143
+ oauth = account.get("oauthAccount")
144
+ o = oauth if isinstance(oauth, dict) else {}
145
+
146
+ def s(value):
147
+ return "" if value is False else js_str(value)
148
+ org = s(o.get("organizationType")) or s(account.get("organizationType"))
149
+ ratelimit = s(o.get("organizationRateLimitTier"))
150
+ plan, why = None, ""
151
+ if re.search(r"^claude_max|max", org):
152
+ if "max_20x" in ratelimit:
153
+ plan, why = "max20", "organizationRateLimitTier %s" % ratelimit
154
+ else:
155
+ plan, why = "max", "organizationType %s%s" % (org, ", " + ratelimit if ratelimit else "")
156
+ elif re.search(r"pro", org):
157
+ plan, why = "pro", "organizationType %s" % org
158
+ elif re.search(r"team|enterprise", org):
159
+ seat = " ".join(js_str(v) for v in (o.get("seatTier"), o.get("userRateLimitTier"),
160
+ o.get("organizationRateLimitTier")) if v is not None and v != "")
161
+ if re.search(r"premium|max", seat):
162
+ plan, why = "team-max", "%s, seat '%s'" % (org, seat)
163
+ elif re.search(r"standard|pro", seat):
164
+ plan, why = "team-pro", "%s, seat '%s'" % (org, seat)
165
+ else:
166
+ why = "team"
167
+ api = not isinstance(oauth, dict) and any(os.environ.get(k) for k in (
168
+ "ANTHROPIC_API_KEY", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX"))
169
+ fields = {k: js_str(o.get(k)) for k in ("organizationType", "organizationRateLimitTier", "seatTier",
170
+ "userRateLimitTier")}
171
+ fields.update(accountOrganizationType=js_str(account.get("organizationType")), api=api)
172
+ return {"plan": plan, "from": why or None, "org": org}, api, fields
173
+
174
+
175
+ def jwt_plan_type(token):
176
+ """chatgpt_plan_type from a JWT's body, decoded as leniently as Node's
177
+ Buffer.from(body, 'base64url'): characters outside the alphabet are skipped,
178
+ decoding stops at '=', bad UTF-8 becomes U+FFFD. The value is returned as
179
+ JSON gave it; the caller applies JS truthiness."""
180
+ try:
181
+ body = token.split(".")[1].split("=")[0].replace("+", "-").replace("/", "_")
182
+ body = re.sub(r"[^A-Za-z0-9_-]", "", body)
183
+ body = body[:len(body) - 1] if len(body) % 4 == 1 else body
184
+ text = base64.urlsafe_b64decode(body + "=" * (-len(body) % 4)).decode("utf-8", "replace")
185
+ auth = json.loads(text).get("https://api.openai.com/auth") or {}
186
+ value = auth.get("chatgpt_plan_type")
187
+ return value if js_truthy(value) else ""
188
+ except (IndexError, ValueError, AttributeError, TypeError, RecursionError):
189
+ return ""
190
+
191
+
192
+ def detect_codex():
193
+ """The port of codexDetectPlan and codexRender's mapping: chatgpt_plan_type from
194
+ the JWT in <CODEX_HOME>/auth.json, then ~/.codex/auth.json."""
195
+ home = os.environ.get("HOME") or os.path.expanduser("~")
196
+ raw, first = "", None
197
+ for path in (os.path.join(rr_runtime.runtime_home("codex"), "auth.json"), os.path.join(home, ".codex", "auth.json")):
198
+ data = read_json_object(path)
199
+ if data is None:
200
+ continue
201
+ first = data if first is None else first
202
+ tokens = data.get("tokens") or {}
203
+ tokens = tokens if isinstance(tokens, dict) else {}
204
+ for key in ("id_token", "access_token"):
205
+ value = tokens.get(key)
206
+ found = jwt_plan_type(js_str(value) if js_truthy(value) else "")
207
+ if js_truthy(found):
208
+ raw = js_str(found).lower()
209
+ break
210
+ if raw:
211
+ break
212
+ plan, why = None, ""
213
+ if raw in ("plus", "pro"):
214
+ plan, why = raw, "chatgpt_plan_type"
215
+ elif re.search(r"^(team|business|enterprise|edu)", raw):
216
+ plan, why = "pro", "chatgpt_plan_type %s, uses the pro profile" % raw
217
+ first = first or {}
218
+ mode = first.get("auth_mode")
219
+ api = (bool(first.get("OPENAI_API_KEY")) and not first.get("tokens")) or \
220
+ (mode is not None and mode != "chatgpt")
221
+ fields = {"chatgpt_plan_type": raw, "auth_mode": js_str(mode), "api": api}
222
+ return {"plan": plan, "from": why or None, "raw": raw}, api, fields
223
+
224
+
225
+ # Pairs whose profiles differ only in profile.json (R36.17), so `profile set` fixes a
226
+ # drift; any other pair needs the installer. Codex plus and pro differ in tiers, session
227
+ # and agents, so every Codex change is the installer's.
228
+ SAME_TIER = {"claude": ({"max", "max20"}, {"pro", "team-pro"}, {"max", "team-max"}), "codex": ()}
229
+
230
+
231
+ def context_mode(prof):
232
+ """The record's --context: "economy" or None (default, or a record from before it)."""
233
+ context = (prof or {}).get("context")
234
+ return "economy" if isinstance(context, dict) and context.get("mode") == "economy" else None
235
+
236
+
237
+ def installer_command(runtime, plan, version, context=None):
238
+ return "npx riskrails%s --target %s %s %s%s --yes" % (
239
+ "@%s" % version if version else "", runtime, "--plan" if runtime == "claude" else "--codex-plan", plan,
240
+ " --context %s" % context if context else "")
241
+
242
+
243
+ def profile_detect(runtime, rt=rr_runtime.Runtime()):
244
+ live, api, fields = detect_claude() if runtime == "claude" else detect_codex()
245
+ live["fingerprint"] = "sha256:" + hashlib.sha256(
246
+ json.dumps(fields, sort_keys=True).encode("utf-8")).hexdigest()
247
+ prof = read_profile(runtime, rt=rt)
248
+ installed = {k: prof.get(k) for k in ("plan", "profile", "plugin_version")} if prof else None
249
+ have = (installed or {}).get("plan")
250
+ render = command = ack = None
251
+ if api:
252
+ status = "unsupported"
253
+ elif not live["plan"]:
254
+ status = "unknown"
255
+ elif not installed:
256
+ status = "no_profile"
257
+ elif have == live["plan"] or (runtime == "claude" and have == "balanced-max" and live["plan"] in ("max", "max20")):
258
+ status = "same"
259
+ elif acknowledged(runtime, live["fingerprint"], have):
260
+ status = "acknowledged" # the human kept this very plan for this very account (R36.18)
261
+ else:
262
+ status = "drift"
263
+ if any({have, live["plan"]} <= pair for pair in SAME_TIER[runtime]):
264
+ render = "profile"
265
+ command = "python3 %s profile set %s --runtime %s" % (
266
+ shlex.quote(fs.ENTRY), live["plan"], runtime)
267
+ else:
268
+ render = "installer"
269
+ command = installer_command(runtime, live["plan"], installed.get("plugin_version"),
270
+ context_mode(prof))
271
+ ack = "python3 %s profile ack --runtime %s" % (shlex.quote(fs.ENTRY), runtime)
272
+ return {"runtime": runtime, "status": status, "installed": installed, "live": live,
273
+ "render": render, "command": command, "ack": ack, "decision": None}
274
+
275
+
276
+ def acknowledged(runtime, fingerprint, installed_plan):
277
+ """True when drift-ack.json holds this fingerprint AND the plan kept is the installed one."""
278
+ entry = read_drift_ack(runtime).get(runtime)
279
+ return isinstance(entry, dict) and entry.get("fingerprint") == fingerprint and \
280
+ entry.get("kept") == installed_plan
281
+
282
+
283
+ def drift_ack_path(runtime):
284
+ return os.path.join(rr_runtime.runtime_home(runtime), "riskrails", "drift-ack.json")
285
+
286
+
287
+ def read_drift_ack(runtime):
288
+ """<home>/riskrails/drift-ack.json, or {}: per user, never per project (D2)."""
289
+ return read_json_object(drift_ack_path(runtime)) or {}
290
+
291
+
292
+ def write_drift_ack(runtime, fingerprint, kept, via):
293
+ """"Keep" on a plan_drift decision: the fingerprint, the plan kept and when. Plan
294
+ fields hashed, never an email, an id or a token (R36.16). Atomic."""
295
+ data = read_drift_ack(runtime)
296
+ data = {"version": 1, "claude": data.get("claude"), "codex": data.get("codex")}
297
+ data[runtime] = {"fingerprint": fingerprint, "kept": kept, "at": fs.now(), "via": via}
298
+ path = drift_ack_path(runtime)
299
+ os.makedirs(os.path.dirname(path), exist_ok=True)
300
+ tmp = "%s.%d.tmp" % (path, os.getpid())
301
+ with open(tmp, "w", encoding="utf-8") as fh:
302
+ json.dump(data, fh, indent=2)
303
+ fh.write("\n")
304
+ os.replace(tmp, path)
305
+
306
+
307
+ def open_drift(root, found, rt=rr_runtime.Runtime()):
308
+ """`profile detect --open` (R36.18): on drift, with the dialog on, the plan_drift
309
+ decision — option 1 keeps the installed plan (and writes drift-ack.json when
310
+ settled), option 2 is the `command` detect prints. The id of the decision, an
311
+ open one for the same fingerprint included; None when nothing was opened."""
312
+ runtime = found["runtime"]
313
+ if not root or runtime != rr_runtime.own_runtime(runtime=rt.name):
314
+ return None
315
+ fingerprint = found["live"]["fingerprint"]
316
+ on = dialog.dialog_on(root, runtime=rt.name) if found["status"] == "drift" else None
317
+ session = (on or {}).get("session")
318
+ reuse = None
319
+ for decision in ledger.read_decisions(root)["open"]:
320
+ drift = decision.get("drift") or {}
321
+ if decision.get("kind") != "plan_drift" or drift.get("runtime") != runtime:
322
+ continue
323
+ if on and reuse is None and drift.get("fingerprint") == fingerprint and \
324
+ decision.get("session") == session:
325
+ reuse = decision["id"]
326
+ else:
327
+ # resolved, acknowledged elsewhere, another account or another session's: stale
328
+ dialog.settle_decision(root, decision["id"], "fallback")
329
+ if not on:
330
+ return None
331
+ if reuse:
332
+ return reuse
333
+ kept = found["installed"]["plan"]
334
+ live = found["live"]
335
+ question = "This %s account is %s (%s); RiskRails is installed for %s.\nKeep %s, or run the command below?" % (
336
+ runtime, live["plan"], live["from"] or "no detail", kept, kept)
337
+ state = task.load(root, required=False, claim=False, rt=rt)
338
+ state = state if state and task.is_v7(state) else None
339
+ options = [{"kind": "choice", "effect": "ack", "label": "Keep %s — do not ask again for this account" % kept},
340
+ {"kind": "command", "label": found["command"]}]
341
+ decision, why = dialog.open_decision(root, state, "plan_drift", "Plan drift", question, options, "profile detect",
342
+ drift={"runtime": runtime, "fingerprint": fingerprint, "kept": kept}, rt=rt)
343
+ if decision is None:
344
+ print("state.py: no dialog — %s" % why, file=sys.stderr)
345
+ return None
346
+ return decision["id"]
347
+
348
+
349
+ # plan -> (profile, label), as bin/riskrails.js maps --plan and --codex-plan; the
350
+ # installer and `profile set` must resolve a plan to the same profile.json.
351
+ PLAN_PROFILES = {
352
+ "claude": {"pro": ("pro", "Pro"), "team-pro": ("pro", "Team Pro"), "team-max": ("max", "Team Max"),
353
+ "max": ("max", "Max"), "max20": ("max20", "Max 20x"), "balanced-max": ("balanced-max", "BalancedMax")},
354
+ "codex": {"plus": ("codex-plus", "Plus"), "pro": ("codex-pro", "Pro"),
355
+ "balanced-max": ("codex-balanced-max", "BalancedMax")},
356
+ }
357
+
358
+
359
+ def load_resolver(runtime):
360
+ """The installed scripts/resolve-profile.py beside its profiles/ (the installer copies
361
+ both to <home>/riskrails/), else the plugin's, else this checkout's; None when none."""
362
+ here = os.path.dirname(os.path.dirname(os.path.dirname(fs.ENTRY)))
363
+ for base in (os.path.join(rr_runtime.runtime_home(runtime), "riskrails"), os.environ.get("CLAUDE_PLUGIN_ROOT"), here):
364
+ path = os.path.join(base, "scripts", "resolve-profile.py") if base else ""
365
+ if path and os.path.isfile(path) and os.path.isdir(os.path.join(base, "profiles")):
366
+ spec = importlib.util.spec_from_file_location("resolve_profile", path)
367
+ module = importlib.util.module_from_spec(spec)
368
+ spec.loader.exec_module(module)
369
+ return module, base
370
+ return None, None
371
+
372
+
373
+ DIRECT_MODE_CAP = "T4"