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.
Files changed (53) 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 +12 -11
  5. package/bin/riskrails.js +109 -18
  6. package/hooks/ai-path-guard-defaults.json +3 -2
  7. package/hooks/context-guard.py +14 -11
  8. package/hooks/dialog-gate.py +1 -1
  9. package/hooks/guard/dialog.py +107 -27
  10. package/hooks/guard/git.py +12 -3
  11. package/hooks/guard/paths.py +109 -7
  12. package/hooks/project-scaffold.py +2 -1
  13. package/hooks/runtime-gate.py +128 -27
  14. package/hooks/update_notice.py +3 -3
  15. package/package.json +1 -1
  16. package/skills/ai-init/templates/.ai/state/README.md +4 -4
  17. package/skills/ai-task/rr_state/__init__.py +1 -0
  18. package/skills/ai-task/rr_state/cli.py +210 -0
  19. package/skills/ai-task/rr_state/close.py +171 -0
  20. package/skills/ai-task/rr_state/dialog.py +541 -0
  21. package/skills/ai-task/rr_state/dialog_cmd.py +164 -0
  22. package/skills/ai-task/rr_state/fs.py +220 -0
  23. package/skills/ai-task/rr_state/gate.py +125 -0
  24. package/skills/ai-task/rr_state/gate_cmd.py +128 -0
  25. package/skills/ai-task/rr_state/handoff.py +226 -0
  26. package/skills/ai-task/rr_state/journal.py +126 -0
  27. package/skills/ai-task/rr_state/labels.py +99 -0
  28. package/skills/ai-task/rr_state/ledger.py +145 -0
  29. package/skills/ai-task/rr_state/locks.py +84 -0
  30. package/skills/ai-task/rr_state/memory_cmd.py +310 -0
  31. package/skills/ai-task/rr_state/policy.py +378 -0
  32. package/skills/ai-task/rr_state/profile.py +373 -0
  33. package/skills/ai-task/rr_state/questions.py +336 -0
  34. package/skills/ai-task/rr_state/questions_cmd.py +321 -0
  35. package/skills/ai-task/rr_state/report.py +271 -0
  36. package/skills/ai-task/rr_state/report_cmd.py +230 -0
  37. package/skills/ai-task/rr_state/review.py +165 -0
  38. package/skills/ai-task/rr_state/runtime.py +80 -0
  39. package/skills/ai-task/rr_state/start.py +257 -0
  40. package/skills/ai-task/rr_state/steps.py +131 -0
  41. package/skills/ai-task/rr_state/task.py +396 -0
  42. package/skills/ai-task/rr_state/verify.py +231 -0
  43. package/skills/ai-task/sensors.py +16 -3
  44. package/skills/ai-task/state.py +24 -5161
  45. package/skills/project-update/adopt.py +38 -24
  46. package/skills/project-update/history/blobs/a51517e13e57420d2d88a6bcfb4055d762d4a0d10a8f88450929c5c4572213a9 +67 -0
  47. package/skills/project-update/history/index.json +2 -1
  48. package/skills/project-update/migrations/0003_context_diet.py +1 -1
  49. package/skills/project-update/migrations/0007_weight_model.py +130 -23
  50. package/skills/project-update/migrations/README.md +16 -1
  51. package/skills/project-update/update.py +202 -26
  52. package/skills/rr-setup/SKILL.md +9 -2
  53. package/skills/rr-status/status.py +36 -9
@@ -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
- 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
@@ -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():
@@ -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.0",
3
+ "version": "3.2.2",
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 `/ai-status`, and
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
- `/project-update` may change the state through `ctx.patch_state()`, with the
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 `/ai-init` keeps it out of git.
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 `/project-update` yet still answers every command; `save()` then writes
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)