riskrails 3.1.0 → 3.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +12 -11
- package/bin/riskrails.js +109 -18
- package/hooks/ai-path-guard-defaults.json +3 -2
- package/hooks/context-guard.py +14 -11
- package/hooks/dialog-gate.py +1 -1
- package/hooks/guard/dialog.py +107 -27
- package/hooks/guard/git.py +12 -3
- package/hooks/guard/paths.py +109 -7
- package/hooks/project-scaffold.py +2 -1
- package/hooks/runtime-gate.py +128 -27
- package/hooks/update_notice.py +3 -3
- package/package.json +1 -1
- package/skills/ai-init/templates/.ai/state/README.md +4 -4
- package/skills/ai-task/rr_state/__init__.py +1 -0
- package/skills/ai-task/rr_state/cli.py +210 -0
- package/skills/ai-task/rr_state/close.py +171 -0
- package/skills/ai-task/rr_state/dialog.py +541 -0
- package/skills/ai-task/rr_state/dialog_cmd.py +164 -0
- package/skills/ai-task/rr_state/fs.py +220 -0
- package/skills/ai-task/rr_state/gate.py +125 -0
- package/skills/ai-task/rr_state/gate_cmd.py +128 -0
- package/skills/ai-task/rr_state/handoff.py +226 -0
- package/skills/ai-task/rr_state/journal.py +126 -0
- package/skills/ai-task/rr_state/labels.py +99 -0
- package/skills/ai-task/rr_state/ledger.py +145 -0
- package/skills/ai-task/rr_state/locks.py +84 -0
- package/skills/ai-task/rr_state/memory_cmd.py +310 -0
- package/skills/ai-task/rr_state/policy.py +378 -0
- package/skills/ai-task/rr_state/profile.py +373 -0
- package/skills/ai-task/rr_state/questions.py +336 -0
- package/skills/ai-task/rr_state/questions_cmd.py +321 -0
- package/skills/ai-task/rr_state/report.py +271 -0
- package/skills/ai-task/rr_state/report_cmd.py +230 -0
- package/skills/ai-task/rr_state/review.py +165 -0
- package/skills/ai-task/rr_state/runtime.py +80 -0
- package/skills/ai-task/rr_state/start.py +257 -0
- package/skills/ai-task/rr_state/steps.py +131 -0
- package/skills/ai-task/rr_state/task.py +396 -0
- package/skills/ai-task/rr_state/verify.py +231 -0
- package/skills/ai-task/sensors.py +16 -3
- package/skills/ai-task/state.py +24 -5161
- package/skills/project-update/adopt.py +38 -24
- package/skills/project-update/history/blobs/a51517e13e57420d2d88a6bcfb4055d762d4a0d10a8f88450929c5c4572213a9 +67 -0
- package/skills/project-update/history/index.json +2 -1
- package/skills/project-update/migrations/0003_context_diet.py +1 -1
- package/skills/project-update/migrations/0007_weight_model.py +130 -23
- package/skills/project-update/migrations/README.md +16 -1
- package/skills/project-update/update.py +202 -26
- package/skills/rr-setup/SKILL.md +9 -2
- package/skills/rr-status/status.py +36 -9
|
@@ -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"
|