riskrails 3.1.0 → 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.
- 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
package/hooks/runtime-gate.py
CHANGED
|
@@ -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:
|
|
@@ -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
|
-
|
|
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
|
|
@@ -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
|
|
990
|
-
CLAUDE_CODE_SESSION_ATTENDED=0 (set by `claude -p` and the SDK).
|
|
991
|
-
an older Claude Code — still asks.
|
|
992
|
-
|
|
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%)`
|
|
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
|
|
1130
|
-
|
|
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 =
|
|
1133
|
-
pct =
|
|
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 = [
|
|
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,
|
|
1153
|
-
model and
|
|
1154
|
-
|
|
1155
|
-
|
|
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
|
-
|
|
1158
|
-
|
|
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
|
-
|
|
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():
|
package/hooks/update_notice.py
CHANGED
|
@@ -51,7 +51,7 @@ def installed_version(runtime, plugin_root, home=None):
|
|
|
51
51
|
try:
|
|
52
52
|
with open(path, encoding="utf-8") as fh:
|
|
53
53
|
version = json.load(fh).get(key)
|
|
54
|
-
except (OSError, ValueError, AttributeError):
|
|
54
|
+
except (OSError, ValueError, AttributeError, RecursionError):
|
|
55
55
|
continue
|
|
56
56
|
if parse(version):
|
|
57
57
|
return version
|
|
@@ -63,7 +63,7 @@ def read_cache(path):
|
|
|
63
63
|
with open(path, encoding="utf-8") as fh:
|
|
64
64
|
data = json.load(fh)
|
|
65
65
|
return data if isinstance(data, dict) else {}
|
|
66
|
-
except (OSError, ValueError):
|
|
66
|
+
except (OSError, ValueError, RecursionError):
|
|
67
67
|
return {}
|
|
68
68
|
|
|
69
69
|
|
|
@@ -130,7 +130,7 @@ def refresh(path):
|
|
|
130
130
|
version = json.loads(resp.read(MAX_BODY).decode("utf-8")).get("version")
|
|
131
131
|
if parse(version):
|
|
132
132
|
latest = version
|
|
133
|
-
except (OSError, ValueError, AttributeError):
|
|
133
|
+
except (OSError, ValueError, AttributeError, RecursionError):
|
|
134
134
|
pass
|
|
135
135
|
write_cache(path, latest if latest is not None else previous)
|
|
136
136
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "riskrails",
|
|
3
|
-
"version": "3.1
|
|
3
|
+
"version": "3.2.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",
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# Task state
|
|
2
2
|
|
|
3
3
|
`state.json` is the machine-readable record of the task in flight. It is
|
|
4
|
-
written by `skills/ai-task/state.py` (atomically), read by `/
|
|
4
|
+
written by `skills/ai-task/state.py` (atomically), read by `/rr-status`, and
|
|
5
5
|
enforced by `ai-scope-guard`, which derives the files the current step may touch
|
|
6
6
|
from it.
|
|
7
7
|
|
|
8
8
|
Two other writers exist, and only these two. A schema migration in
|
|
9
|
-
`/
|
|
9
|
+
`/rr-setup --update` may change the state through `ctx.patch_state()`, with the
|
|
10
10
|
same atomic write and a `schema_migrated` history entry — that is how a task in
|
|
11
11
|
flight survives a change to the shape of this file. And `hooks/context-guard.py`
|
|
12
12
|
writes `session.json`, the sidecar beside this file, and nothing else: it never
|
|
@@ -14,7 +14,7 @@ touches `state.json`. Nothing else writes any of it — an agent never edits
|
|
|
14
14
|
them by hand, and the path guard refuses when one tries.
|
|
15
15
|
|
|
16
16
|
It is **not committed**: it describes a session, not the repository. The
|
|
17
|
-
`.gitignore` entry added by `/
|
|
17
|
+
`.gitignore` entry added by `/rr-setup` keeps it out of git.
|
|
18
18
|
|
|
19
19
|
When a task closes, `state.py close` writes the final state to
|
|
20
20
|
`.ai/state/logs/<task-id>/state.json`, beside the task's verify logs — local,
|
|
@@ -37,7 +37,7 @@ created_at, updated_at, history[]
|
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
`state.py` applies the schema-2 keys in memory as it loads, so a project that has
|
|
40
|
-
not run `/
|
|
40
|
+
not run `/rr-setup --update` yet still answers every command; `save()` then writes
|
|
41
41
|
them. A reader that opens this file directly sees what is on disk, not those
|
|
42
42
|
defaults.
|
|
43
43
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""rr_state: the task state machine behind skills/ai-task/state.py."""
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""cli: the argument parser and the dispatch -- the one place that resolves the runtime and builds the Runtime every command receives.
|
|
2
|
+
|
|
3
|
+
shell: BLOCKING_COMMANDS, MUTATING_COMMANDS, DIALOG_SYNC_COMMANDS, memory_parsers, main
|
|
4
|
+
"""
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
from rr_state import close, dialog, dialog_cmd, fs, gate, gate_cmd, memory_cmd, policy, questions_cmd
|
|
9
|
+
from rr_state import report_cmd, review, start, steps, task, verify
|
|
10
|
+
from rr_state import runtime as rr_runtime
|
|
11
|
+
|
|
12
|
+
# A pending question stops a stage from moving; it never stops a command that
|
|
13
|
+
# records, reads or answers (R3). close --abandon is exempt: abandoning a task is
|
|
14
|
+
# how an unanswerable question is closed.
|
|
15
|
+
BLOCKING_COMMANDS = {"step-done", "approve", "close", "verify"}
|
|
16
|
+
|
|
17
|
+
# The commands that change the task. Only these take ownership of it: reading a
|
|
18
|
+
# task from the other runtime, or writing a note about it, is not a handoff.
|
|
19
|
+
MUTATING_COMMANDS = {"step-done", "ask", "answer", "close", "start", "verify", "review", "batch"}
|
|
20
|
+
# What prunes decision.json and mirrors consent.jsonl first (R36.14, R36.24):
|
|
21
|
+
# every command that changes something; status and consent stay read-only.
|
|
22
|
+
DIALOG_SYNC_COMMANDS = MUTATING_COMMANDS | {"approve", "reject", "decision"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def memory_parsers(sub):
|
|
26
|
+
p = sub.add_parser("recall", help="what the record holds about these files, within a token budget")
|
|
27
|
+
p.add_argument("--files", default=""); p.add_argument("--budget", type=int)
|
|
28
|
+
p.add_argument("--kinds", default=""); p.add_argument("--all-risks", dest="all_risks", action="store_true")
|
|
29
|
+
p.add_argument("--offset", type=int, default=0); p.add_argument("--json", action="store_true")
|
|
30
|
+
p.set_defaults(func=memory_cmd.cmd_recall)
|
|
31
|
+
p = sub.add_parser("memory", help="the compact project record (docs/local-memory.md)")
|
|
32
|
+
p.set_defaults(func=memory_cmd.cmd_memory)
|
|
33
|
+
msub = p.add_subparsers(dest="memory_action", required=True)
|
|
34
|
+
m = msub.add_parser("status"); m.add_argument("--json", action="store_true")
|
|
35
|
+
m = msub.add_parser("enable"); m.add_argument("--author"); m.add_argument("--period", choices=["month", "quarter", "year"])
|
|
36
|
+
m.add_argument("--index", choices=["none", "sqlite"])
|
|
37
|
+
msub.add_parser("disable")
|
|
38
|
+
m = msub.add_parser("export"); m.add_argument("ids", nargs="*"); m.add_argument("--pending", action="store_true")
|
|
39
|
+
m = msub.add_parser("verify"); m.add_argument("ids", nargs="*"); m.add_argument("--all", action="store_true")
|
|
40
|
+
m.add_argument("--range"); m.add_argument("--fresh-clone", dest="fresh_clone", action="store_true")
|
|
41
|
+
m.add_argument("--json", action="store_true")
|
|
42
|
+
m = msub.add_parser("show"); m.add_argument("id"); m.add_argument("--md", action="store_true")
|
|
43
|
+
m.add_argument("--json", action="store_true"); m.add_argument("--entry")
|
|
44
|
+
m = msub.add_parser("search"); m.add_argument("query", nargs="?", default="")
|
|
45
|
+
for flag in ("--kind", "--by", "--file", "--task", "--since", "--until"):
|
|
46
|
+
m.add_argument(flag)
|
|
47
|
+
m.add_argument("--limit", type=int, default=20); m.add_argument("--offset", type=int, default=0)
|
|
48
|
+
m.add_argument("--json", action="store_true")
|
|
49
|
+
m = msub.add_parser("risk"); rsub = m.add_subparsers(dest="risk_action", required=True)
|
|
50
|
+
r = rsub.add_parser("add"); r.add_argument("--title", required=True); r.add_argument("--where", default="")
|
|
51
|
+
r.add_argument("--label", required=True); r.add_argument("--what", required=True)
|
|
52
|
+
r.add_argument("--likely"); r.add_argument("--fix"); r.add_argument("--status", default="open")
|
|
53
|
+
r = rsub.add_parser("close"); r.add_argument("rkey"); r.add_argument("--status", required=True)
|
|
54
|
+
r.add_argument("--why", required=True)
|
|
55
|
+
m = msub.add_parser("risks"); m.add_argument("--status", choices=["open", "ticketed", "accepted", "fixed", "all"],
|
|
56
|
+
default="open")
|
|
57
|
+
m.add_argument("--md", action="store_true"); m.add_argument("--json", action="store_true")
|
|
58
|
+
m = msub.add_parser("migrate"); g = m.add_mutually_exclusive_group(required=True)
|
|
59
|
+
g.add_argument("--report", action="store_true"); g.add_argument("--apply", action="store_true")
|
|
60
|
+
g.add_argument("--move-logs", dest="move_logs", action="store_true")
|
|
61
|
+
m.add_argument("--risks", action="store_true"); m.add_argument("--confirm-delete", dest="confirm_delete")
|
|
62
|
+
msub.add_parser("rebuild")
|
|
63
|
+
m = msub.add_parser("cleanup"); m.add_argument("ids", nargs="*"); m.add_argument("--all", action="store_true")
|
|
64
|
+
m.add_argument("--confirm-delete", dest="confirm_delete")
|
|
65
|
+
m = msub.add_parser("restore"); m.add_argument("ids", nargs="*"); m.add_argument("--all", action="store_true")
|
|
66
|
+
m = msub.add_parser("pack"); m.add_argument("--period", required=True)
|
|
67
|
+
m.add_argument("--confirm-delete", dest="confirm_delete")
|
|
68
|
+
m = msub.add_parser("redact"); m.add_argument("rid"); m.add_argument("--field", required=True)
|
|
69
|
+
m.add_argument("--why", required=True)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def main():
|
|
73
|
+
# description: the first line of state.py's docstring, which argparse printed as __doc__ before the split
|
|
74
|
+
parser = argparse.ArgumentParser(
|
|
75
|
+
description="Task state for /ai-task — the small, machine-readable record of one task.")
|
|
76
|
+
parser.add_argument("--root", default=".", help="project directory (default: search upwards for .ai/)")
|
|
77
|
+
parser.add_argument("--runtime", choices=rr_runtime.RUNTIMES,
|
|
78
|
+
help="overrides AI_RUNTIME and detection for this call")
|
|
79
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
80
|
+
|
|
81
|
+
p = sub.add_parser("step-done"); p.add_argument("step_id")
|
|
82
|
+
p.add_argument("--force", action="store_true",
|
|
83
|
+
help="accepted for older callers; it forces nothing")
|
|
84
|
+
p.set_defaults(func=steps.cmd_step_done_v7)
|
|
85
|
+
|
|
86
|
+
who = os.environ.get("USER") or "unknown"
|
|
87
|
+
p = sub.add_parser("ask"); p.add_argument("question", nargs="?")
|
|
88
|
+
p.add_argument("--option", action="append", default=[]); p.add_argument("--recommend")
|
|
89
|
+
p.add_argument("--context", default=""); p.add_argument("--gate")
|
|
90
|
+
p.add_argument("--batch"); p.add_argument("--topic"); p.add_argument("--by", default="")
|
|
91
|
+
p.set_defaults(func=questions_cmd.cmd_ask)
|
|
92
|
+
|
|
93
|
+
p = sub.add_parser("answer"); p.add_argument("assignment", nargs="*")
|
|
94
|
+
p.add_argument("--prose"); p.add_argument("--by", default=who)
|
|
95
|
+
# `file` is not offered: it is what --sync writes, and it is the one route
|
|
96
|
+
# that is recorded as a human's.
|
|
97
|
+
p.add_argument("--via", choices=["picker", "prose"], default="prose")
|
|
98
|
+
p.add_argument("--topic"); p.set_defaults(func=questions_cmd.cmd_answer)
|
|
99
|
+
|
|
100
|
+
p = sub.add_parser("questions"); p.add_argument("--pending", action="store_true")
|
|
101
|
+
p.add_argument("--sync", action="store_true"); p.add_argument("--topic")
|
|
102
|
+
p.add_argument("--by", default=who)
|
|
103
|
+
p.add_argument("--format", choices=["md", "prose", "json"], default="prose")
|
|
104
|
+
p.set_defaults(func=questions_cmd.cmd_questions)
|
|
105
|
+
|
|
106
|
+
p = sub.add_parser("handoff")
|
|
107
|
+
p.add_argument("--print", dest="print_it", action="store_true")
|
|
108
|
+
p.add_argument("--reason", choices=["stage", "precompact", "manual", "session-start"],
|
|
109
|
+
default="manual")
|
|
110
|
+
p.add_argument("--to", choices=rr_runtime.RUNTIMES)
|
|
111
|
+
p.add_argument("--for", dest="for_", choices=["continue", "review"], default="continue")
|
|
112
|
+
p.add_argument("--why", default="")
|
|
113
|
+
p.set_defaults(func=report_cmd.cmd_handoff)
|
|
114
|
+
|
|
115
|
+
p = sub.add_parser("profile"); p.add_argument("action", nargs="?", choices=["detect", "set", "ack"])
|
|
116
|
+
p.add_argument("plan", nargs="?")
|
|
117
|
+
p.add_argument("--field")
|
|
118
|
+
p.add_argument("--runtime", dest="detect_runtime", choices=rr_runtime.RUNTIMES)
|
|
119
|
+
p.add_argument("--open", action="store_true")
|
|
120
|
+
p.add_argument("--tier", choices=["FAST", "BALANCED", "STRONG", "EXPERT"])
|
|
121
|
+
p.add_argument("--other", action="store_true"); p.add_argument("--json", action="store_true")
|
|
122
|
+
p.set_defaults(func=report_cmd.cmd_profile)
|
|
123
|
+
|
|
124
|
+
p = sub.add_parser("weight"); p.add_argument("--files", default="")
|
|
125
|
+
p.add_argument("--goal", default=""); p.add_argument("--declare", action="append", default=[])
|
|
126
|
+
out = p.add_mutually_exclusive_group()
|
|
127
|
+
out.add_argument("--json", action="store_true"); out.add_argument("--long", action="store_true")
|
|
128
|
+
out.add_argument("--mirror", action="store_true"); p.set_defaults(func=start.cmd_weight)
|
|
129
|
+
|
|
130
|
+
p = sub.add_parser("event"); p.add_argument("type"); p.add_argument("--detail", default="")
|
|
131
|
+
p.add_argument("--data"); p.set_defaults(func=report_cmd.cmd_event)
|
|
132
|
+
|
|
133
|
+
p = sub.add_parser("approve"); p.add_argument("--by", required=True)
|
|
134
|
+
p.add_argument("--note", default="")
|
|
135
|
+
p.add_argument("--confirm", default="", help="the operation a full_irreversible task performs")
|
|
136
|
+
p.set_defaults(func=gate_cmd.cmd_approve_v7)
|
|
137
|
+
|
|
138
|
+
p = sub.add_parser("reject"); p.add_argument("--by", required=True)
|
|
139
|
+
p.add_argument("--why", required=True); p.set_defaults(func=gate_cmd.cmd_reject)
|
|
140
|
+
|
|
141
|
+
p = sub.add_parser("close")
|
|
142
|
+
p.add_argument("--abandon", action="store_true", help="archive without the checks")
|
|
143
|
+
p.set_defaults(func=close.cmd_close_v7)
|
|
144
|
+
|
|
145
|
+
p = sub.add_parser("start"); p.add_argument("--goal", default="")
|
|
146
|
+
p.add_argument("--workflow", default=""); p.add_argument("--files", default="")
|
|
147
|
+
p.add_argument("--tests", default=""); p.add_argument("--declare", action="append", default=[])
|
|
148
|
+
p.add_argument("--mode"); p.add_argument("--by", default=""); p.add_argument("--why", default="")
|
|
149
|
+
p.add_argument("--steps", default=""); p.add_argument("--plan-file", dest="plan_file")
|
|
150
|
+
p.add_argument("--task-id", help="an id of your own; a legacy T-YYYY-MM-DD-NNN id is still accepted")
|
|
151
|
+
p.add_argument("--slug", help="3-24 characters of [a-z0-9] words joined by '-': the id becomes "
|
|
152
|
+
"T-YYYY-MM-DD-HHMM-<slug> (UTC). Default: derived from --goal")
|
|
153
|
+
p.add_argument("--amend", action="store_true")
|
|
154
|
+
p.add_argument("--batch", action="store_true", help="the batch loop of /rr-build-all (needs --by)")
|
|
155
|
+
p.add_argument("--no-batch", dest="no_batch", action="store_true", help="opt out of weight.json batch.default")
|
|
156
|
+
p.set_defaults(func=start.cmd_start)
|
|
157
|
+
|
|
158
|
+
p = sub.add_parser("steps"); p.add_argument("--ready", action="store_true")
|
|
159
|
+
p.add_argument("--json", action="store_true"); p.set_defaults(func=steps.cmd_steps)
|
|
160
|
+
|
|
161
|
+
p = sub.add_parser("batch"); p.add_argument("action", choices=["on", "off"])
|
|
162
|
+
p.add_argument("--by", default=""); p.add_argument("--why", default="")
|
|
163
|
+
p.set_defaults(func=steps.cmd_batch)
|
|
164
|
+
|
|
165
|
+
p = sub.add_parser("verify")
|
|
166
|
+
p.add_argument("--scope", required=True, choices=["step", "suite", "e2e", "all"])
|
|
167
|
+
p.add_argument("--files", default=""); p.add_argument("--expect", choices=["fail"])
|
|
168
|
+
p.add_argument("--e2e", nargs=2, metavar=("not-applicable", "WHY"))
|
|
169
|
+
p.add_argument("--after-fix", dest="after_fix", default="")
|
|
170
|
+
p.add_argument("--by", default=""); p.set_defaults(func=verify.cmd_verify)
|
|
171
|
+
|
|
172
|
+
p = sub.add_parser("review"); p.add_argument("--verdict", choices=sorted(policy.VERDICT_STATUS))
|
|
173
|
+
p.add_argument("--tier", choices=review.REVIEW_TIERS); p.add_argument("--focus", choices=["security"])
|
|
174
|
+
p.add_argument("--report", default=""); p.add_argument("--closes", default="")
|
|
175
|
+
p.add_argument("--by", default=""); p.set_defaults(func=review.cmd_review)
|
|
176
|
+
|
|
177
|
+
p = sub.add_parser("status"); p.add_argument("--json", action="store_true")
|
|
178
|
+
p.set_defaults(func=report_cmd.cmd_status)
|
|
179
|
+
|
|
180
|
+
p = sub.add_parser("decision"); p.add_argument("action", choices=["show", "open", "settle", "fallback"])
|
|
181
|
+
p.add_argument("--id"); p.add_argument("--kind"); p.add_argument("--command", dest="commands", action="append", default=[])
|
|
182
|
+
p.add_argument("--pick", type=int); p.add_argument("--why", default="")
|
|
183
|
+
p.add_argument("--json", action="store_true"); p.set_defaults(func=dialog_cmd.cmd_decision)
|
|
184
|
+
|
|
185
|
+
p = sub.add_parser("consent"); p.add_argument("action", nargs="?", choices=["audit"])
|
|
186
|
+
p.add_argument("--json", action="store_true"); p.set_defaults(func=dialog_cmd.cmd_consent)
|
|
187
|
+
|
|
188
|
+
memory_parsers(sub)
|
|
189
|
+
|
|
190
|
+
args = parser.parse_args()
|
|
191
|
+
if getattr(args, "topic", None):
|
|
192
|
+
# Topic mode never touches .ai/: it must work where there is none (I11).
|
|
193
|
+
args.func(args, fs.find_docs_root(args.root), rt=rr_runtime.Runtime())
|
|
194
|
+
return
|
|
195
|
+
if args.command == "profile":
|
|
196
|
+
# Read-only and task-free: it answers where there is no .ai/ at all.
|
|
197
|
+
name = args.runtime or os.environ.get("AI_RUNTIME") or \
|
|
198
|
+
("claude" if os.environ.get("CLAUDECODE") else "unknown")
|
|
199
|
+
args.func(args, None, rt=rr_runtime.Runtime(name=name))
|
|
200
|
+
return
|
|
201
|
+
root = fs.find_root(args.root)
|
|
202
|
+
rt = rr_runtime.Runtime(name=rr_runtime.detect_runtime(args.runtime, root),
|
|
203
|
+
mutating=args.command in MUTATING_COMMANDS)
|
|
204
|
+
task.route_surface(root, args, rt=rt)
|
|
205
|
+
if args.command in BLOCKING_COMMANDS and not getattr(args, "abandon", False):
|
|
206
|
+
gate.guard_pending(root, args.command, rt=rt)
|
|
207
|
+
# After the guard: an exit-4 refusal leaves every file as it was.
|
|
208
|
+
if args.command in DIALOG_SYNC_COMMANDS:
|
|
209
|
+
dialog.sync_dialog(root, rt=rt)
|
|
210
|
+
args.func(args, root, rt=rt)
|