riskrails 4.2.0 → 4.3.0

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 (42) 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 +25 -15
  5. package/agents/ai-reviewer.md.tmpl +14 -1
  6. package/ai/agents/implementer.md +6 -0
  7. package/ai/agents/reviewer.md +8 -1
  8. package/bin/ansi.js +12 -0
  9. package/bin/menu.js +71 -12
  10. package/bin/riskrails.js +240 -14
  11. package/bin/uninstall.js +115 -0
  12. package/bin/wizard.js +72 -0
  13. package/hooks/context-guard.py +11 -1
  14. package/hooks/dialog-gate.py +3 -2
  15. package/hooks/guard/decision.py +4 -3
  16. package/hooks/guard/dialog.py +12 -2
  17. package/hooks/guard/ere.py +29 -10
  18. package/hooks/guard/git.py +2 -1
  19. package/hooks/guard/paths_base.py +7 -0
  20. package/hooks/guard/paths_bash.py +3 -3
  21. package/hooks/runtime-gate.py +87 -0
  22. package/hooks/update_notice.py +23 -8
  23. package/package.json +1 -1
  24. package/skills/ai-init/templates/.ai/policies/coding.md +9 -0
  25. package/skills/ai-task/rr_state/dialog.py +32 -7
  26. package/skills/ai-task/rr_state/dialog_cmd.py +38 -5
  27. package/skills/ai-task/rr_state/gate.py +10 -4
  28. package/skills/ai-task/rr_state/labels.py +33 -4
  29. package/skills/ai-task/rr_state/questions_cmd.py +5 -4
  30. package/skills/ai-task/rr_state/report.py +73 -10
  31. package/skills/ai-task/rr_state/report_cmd.py +2 -0
  32. package/skills/ai-task/rr_state/review.py +18 -14
  33. package/skills/ai-task/rr_state/start.py +12 -9
  34. package/skills/ai-task/rr_state/steps.py +16 -7
  35. package/skills/ai-task/rr_state/verify.py +17 -12
  36. package/skills/project-update/history/blobs/dac90781793b8a1c8e5f3e0307665b6394d922140c3831c5508d019f79e1ed0a +41 -0
  37. package/skills/project-update/history/index.json +6 -3
  38. package/skills/rr-build/SKILL.md +5 -2
  39. package/skills/rr-ship/SKILL.md +2 -2
  40. package/skills/rr-spec/SKILL.md +6 -0
  41. package/skills/rr-status/SKILL.md +2 -0
  42. package/skills/rr-status/status.py +5 -3
@@ -45,6 +45,7 @@ INTERPRETER_RE = re.compile(r"^python3?$")
45
45
  INTERPRETER_FLAG_RE = re.compile(r"^-[IS]+$")
46
46
  PUSH_REF_RE = re.compile(r"^[A-Za-z0-9._/-]+$")
47
47
  NPX_PACKAGE_RE = re.compile(r"^riskrails@[0-9A-Za-z.+-]+$")
48
+ BATCH_ON = {"--by": True, "--why": False} # `batch on` options, each at most once; True = required. rr_state/labels.py keeps the same table
48
49
  TS = "%Y-%m-%dT%H:%M:%SZ"
49
50
 
50
51
  # hooks/guard/dialog.py -> the plugin root; the Stop reminder names its
@@ -217,12 +218,12 @@ def validate_label(label):
217
218
  if state_label_ok(tokens):
218
219
  return None
219
220
  return ("a label starts with git commit, git push, a human-only state.py command (approve, reject, "
220
- "profile set, verify/review/start --by, memory enable) or npx riskrails@<v>")
221
+ "profile set, verify/review/start --by, batch on --by, memory enable) or npx riskrails@<v>")
221
222
 
222
223
 
223
224
  def state_label_ok(tokens):
224
225
  """python3 [-I|-S…] <…/state.py> approve|reject|profile set|verify --by|
225
- review --by|start --mode … --by|memory enable [--author S] [--period month|quarter|year]
226
+ review --by|start --mode … --by|batch on --by S [--why S]|memory enable [--author S] [--period month|quarter|year]
226
227
  [--index none|sqlite]: each option at most once, as two words, in any order, and no other
227
228
  memory verb. rr_state/labels.py keeps the same rule; tests/test_dialog_contract.py pins the
228
229
  two equal."""
@@ -236,6 +237,11 @@ def state_label_ok(tokens):
236
237
  rest = tokens[i + 1:]
237
238
  while rest[:1] in (["--root"], ["--runtime"]) and len(rest) >= 2:
238
239
  rest = rest[2:]
240
+ if rest[:2] == ["batch", "on"]:
241
+ names, values = rest[2::2], rest[3::2]
242
+ return (len(names) == len(values) == len(set(names)) and set(names) <= set(BATCH_ON)
243
+ and all(n in names for n, need in BATCH_ON.items() if need)
244
+ and all(v[:1] not in ("", "-") for v in values))
239
245
  if rest[:1] in (["approve"], ["reject"]) or rest[:2] == ["profile", "set"]:
240
246
  return True
241
247
  if rest[:1] in (["verify"], ["review"]):
@@ -589,6 +595,10 @@ def is_git_label(label):
589
595
  return (label or "").split()[:1] == ["git"]
590
596
 
591
597
 
598
+ def is_npx_label(label):
599
+ return (label or "").split()[:1] == ["npx"]
600
+
601
+
592
602
  def used_note(pick):
593
603
  """What a deny after the gate spent a git pick adds (review focus 1)."""
594
604
  return ("\n\nThe pick %s was used by this call and is spent; the command did not run. To ask the human "
@@ -528,11 +528,18 @@ class _Found: # pylint: disable=too-few-public-met
528
528
 
529
529
 
530
530
  class Compiled:
531
- """One pattern in one mode. `rx` is None when the pattern is invalid."""
532
- __slots__ = ("rx", "required", "anchored", "dfa")
531
+ """One pattern in one mode. `rx` is None when the pattern is invalid.
532
+
533
+ The pattern is parsed here (parse errors are caught here), but the regex
534
+ and the DFA are built on the first read of `rx`: `search` runs the cheap
535
+ `required` prefilter first, so a subject it rejects never pays for
536
+ `re.compile` (a fresh process holds ~34 patterns, most never get past it).
537
+ """
538
+ __slots__ = ("_rx", "_pending", "required", "anchored", "dfa")
533
539
 
534
540
  def __init__(self, pattern, mode, capture):
535
- self.rx = None
541
+ self._rx = None
542
+ self._pending = None # (tree, mode, capture) until built
536
543
  self.required = None
537
544
  self.anchored = False
538
545
  self.dfa = None
@@ -546,18 +553,30 @@ class Compiled:
546
553
  tree = _strip_leading(tree)
547
554
  elif capture:
548
555
  self.anchored = _starts_with_dotstar(tree)
549
- flags = re.ASCII if mode == "b" else 0
550
- self.rx = re.compile(_emit(tree, mode), flags)
551
- if not capture and _nested(tree) and _dfa_ok(tree):
552
- self.dfa = _Dfa(tree, mode)
556
+ self._pending = (tree, mode, capture)
553
557
  except (EreError, re.error, RecursionError, OverflowError):
554
- self.rx = None
558
+ self._pending = None
559
+
560
+ @property
561
+ def rx(self):
562
+ if self._pending is not None:
563
+ tree, mode, capture = self._pending
564
+ self._pending = None
565
+ try:
566
+ flags = re.ASCII if mode == "b" else 0
567
+ self._rx = re.compile(_emit(tree, mode), flags)
568
+ if not capture and _nested(tree) and _dfa_ok(tree):
569
+ self.dfa = _Dfa(tree, mode)
570
+ except (EreError, re.error, RecursionError, OverflowError):
571
+ self._rx = None
572
+ self.dfa = None
573
+ return self._rx
555
574
 
556
575
  def search(self, subject):
557
- if self.rx is None:
558
- return None
559
576
  if self.required is not None and not any(r in subject for r in self.required):
560
577
  return None
578
+ if self.rx is None:
579
+ return None
561
580
  if self.dfa is not None:
562
581
  return _Found() if self.dfa.search(subject) else None
563
582
  return self.rx.match(subject) if self.anchored else self.rx.search(subject)
@@ -285,7 +285,8 @@ class GitGuard:
285
285
  return
286
286
  except Exception: # pylint: disable=broad-exception-caught
287
287
  pass
288
- raise Deny(text_to_b("CONSENT_REQUIRED \u2014 no live pick of this session is this command.") + WHY_CONSENT)
288
+ raise Deny(text_to_b("CONSENT_REQUIRED \u2014 no live pick of this session is this command. Ask the human in "
289
+ "the dialog; or they run it in their own terminal.") + WHY_CONSENT)
289
290
 
290
291
  def _drop_status(self, path):
291
292
  """`[ "${#path}" -lt 3 ] || path="${path:3}"`, counted in the locale's characters."""
@@ -42,6 +42,13 @@ WHY_APPROVE = T(
42
42
  "and tells the session to run state.py questions --sync. An unattended run\n"
43
43
  "exports AI_UNATTENDED=1 in the launcher's environment, and the journal then\n"
44
44
  "records the approval as unattended for ever. See .ai/policies/safety.md.")
45
+ WHY_APPROVE_DIALOG = T(
46
+ "\n\nApproval is the human's. Ask them in the dialog: when the task's gate is open, state.py has opened the\n"
47
+ "approval decision; send it ('state.py decision show'), then run the command they pick, verbatim, once.\n"
48
+ "Or they run, in their own terminal:\n"
49
+ " python3 <plugin>/skills/ai-task/state.py --root . approve --by \"<name>\"\n"
50
+ "An unattended run exports AI_UNATTENDED=1 in the launcher's environment, and the journal then\n"
51
+ "records the approval as unattended for ever. See .ai/policies/safety.md.")
45
52
  WHY_WORK_CAP = T(
46
53
  "This command names more paths than the guard can check within its timeout; split it into "
47
54
  "smaller commands, or pass the list through a file and xargs.")
@@ -7,8 +7,8 @@ Moved from paths.py in WP5 (small-oop-modules), unchanged.
7
7
  import re
8
8
  from guard.common import (Allow, Deny, abs_path, chomp_all, ere_escape, exists, herestring_lines,
9
9
  real_path, realpath_strict, strip1)
10
- from guard.paths_base import (SEGMENT, T, WHY_APPROVE, WHY_HUMAN, WHY_PICK, WHY_PROTECTED,
11
- WHY_SENSITIVE, WHY_TASK)
10
+ from guard.paths_base import (SEGMENT, T, WHY_APPROVE, WHY_APPROVE_DIALOG, WHY_HUMAN, WHY_PICK,
11
+ WHY_PROTECTED, WHY_SENSITIVE, WHY_TASK)
12
12
  from guard.paths_shell import shell_segments
13
13
  from guard.paths_human import (APPROVE_RE, DIALOG_GATE_RE, HUMAN_BY_RE, PROFILE_SET_RE,
14
14
  START_LIGHT_RE)
@@ -56,7 +56,7 @@ def check_bash(self, unattended): # pylint: disable=too-many-branches,
56
56
  "('state.py decision settle --id D<n> --pick <n>'). See .ai/policies/safety.md.")
57
57
  on, picked = self.dialog_consent(cmd)
58
58
  if m.ere_match(APPROVE_RE, cmd_joined) and not unattended and self.task_in_flight() and not picked:
59
- raise Deny("Refusing 'state.py approve' from an agent session." + WHY_APPROVE)
59
+ raise Deny("Refusing 'state.py approve' from an agent session." + (WHY_APPROVE_DIALOG if on else WHY_APPROVE))
60
60
  if on and not picked and not unattended and m.ere_match(PROFILE_SET_RE, cmd_joined):
61
61
  raise Deny("Refusing 'state.py profile set' from an agent session." + WHY_PICK)
62
62
  if not picked and not unattended and m.ere_match(HUMAN_BY_RE, cmd_joined):
@@ -1160,6 +1160,90 @@ def git_branch(cwd):
1160
1160
  return ""
1161
1161
 
1162
1162
 
1163
+ def read_json_file(path):
1164
+ """The parsed JSON file, or None when it is missing, unreadable or not JSON."""
1165
+ try:
1166
+ with open(path, encoding="utf-8") as fh:
1167
+ return json.load(fh)
1168
+ except (OSError, ValueError, RecursionError):
1169
+ return None
1170
+
1171
+
1172
+ def still_waiting(decisions, path):
1173
+ """The decisions guard/dialog.py `pending` keeps, from the consent ledger at path: not
1174
+ settled, and not every command pick of the latest dialog call used. A missing or
1175
+ torn ledger leaves them all."""
1176
+ used, settled, rounds = set(), set(), {}
1177
+ try:
1178
+ with open(path, encoding="utf-8") as fh:
1179
+ for raw in fh:
1180
+ try:
1181
+ entry = json.loads(raw)
1182
+ except (ValueError, RecursionError):
1183
+ continue
1184
+ kind = entry.get("type") if isinstance(entry, dict) and entry.get("v") == 1 else None
1185
+ if kind == "use":
1186
+ used.add(entry.get("id"))
1187
+ elif kind == "settle":
1188
+ settled.add(entry.get("decision"))
1189
+ elif kind == "pick" and isinstance(entry.get("order"), int) and not isinstance(entry["order"], bool):
1190
+ call, chain = rounds.get(entry.get("decision"), (None, []))
1191
+ if chain and call == entry.get("tool_use_id"):
1192
+ chain.append(entry)
1193
+ else:
1194
+ rounds[entry.get("decision")] = (entry.get("tool_use_id"), [entry])
1195
+ except OSError:
1196
+ return decisions
1197
+ out = []
1198
+ for decision in decisions:
1199
+ options = decision.get("options")
1200
+ commands = {o.get("n") for o in options if isinstance(o, dict) and o.get("kind") == "command"} \
1201
+ if isinstance(options, list) else set()
1202
+ picks = [p for p in rounds.get(decision.get("id"), (None, []))[1] if p.get("option") in commands]
1203
+ if decision.get("id") not in settled and not (picks and all(p.get("id") in used for p in picks)):
1204
+ out.append(decision)
1205
+ return out
1206
+
1207
+
1208
+ def waiting_ids(decisions, state):
1209
+ """The ids that wait on the human, `D3 D4 Q2 G1`: at most three per kind, then `+n`."""
1210
+ state = state or {}
1211
+ asked, approval = state.get("questions"), state.get("human_approval")
1212
+ questions = asked.get("pending") if isinstance(asked, dict) else None
1213
+ approval = approval if isinstance(approval, dict) else {}
1214
+ kinds = ([d.get("id") for d in decisions],
1215
+ [q for q in questions if not str(q).startswith("G")] if isinstance(questions, list) else [],
1216
+ [approval["gate_id"]] if approval.get("requested_at") and approval.get("gate_id") else [])
1217
+ out = []
1218
+ for ids in kinds:
1219
+ ids = [str(i) for i in ids if i]
1220
+ out += ids[:3] + (["+%d" % (len(ids) - 3)] if len(ids) > 3 else [])
1221
+ return " ".join(out)
1222
+
1223
+
1224
+ def task_segment(cwd):
1225
+ """`<task> · <mode> · <stage> waiting on you: D3 Q2`, or `riskrails · waiting on you: D3`
1226
+ with no task, read from the .ai/state files at or above cwd; "" when nothing is in flight
1227
+ or waits."""
1228
+ here = os.path.abspath(cwd)
1229
+ while not os.path.isdir(os.path.join(here, ".ai", "state")):
1230
+ parent = os.path.dirname(here)
1231
+ if parent == here:
1232
+ return ""
1233
+ here = parent
1234
+ folder = os.path.join(here, ".ai", "state")
1235
+ state = read_json_file(os.path.join(folder, "state.json"))
1236
+ state = state if isinstance(state, dict) and state.get("task_id") else None
1237
+ decisions = read_json_file(os.path.join(folder, "decision.json"))
1238
+ opened = decisions.get("open") if isinstance(decisions, dict) else None
1239
+ opened = [d for d in opened if isinstance(d, dict)] if isinstance(opened, list) else []
1240
+ waiting = waiting_ids(still_waiting(opened, os.path.join(folder, "consent.jsonl")) if opened else [], state)
1241
+ if not state:
1242
+ return "riskrails · waiting on you: " + waiting if waiting else ""
1243
+ return " · ".join(str(state.get(k)) for k in ("task_id", "mode", "stage")) \
1244
+ + (" waiting on you: " + waiting if waiting else "")
1245
+
1246
+
1163
1247
  def second_line(payload):
1164
1248
  """`user@host:cwd | branch | 5h:12% (↻14:00) 7d:41% (↻Fri 03:04) | $1.23`, the
1165
1249
  colours of a Debian colour PS1; each part after the prompt is skipped when its
@@ -1180,6 +1264,9 @@ def second_line(payload):
1180
1264
  branch = git_branch(cwd) if os.path.isdir(cwd) else ""
1181
1265
  if branch:
1182
1266
  out += "%s\033[35m%s%s" % (SL_SEP, branch, SL_RESET)
1267
+ segment = task_segment(cwd)
1268
+ if segment:
1269
+ out += "%s\033[36m%s%s" % (SL_SEP, segment, SL_RESET)
1183
1270
  limits = payload.get("rate_limits")
1184
1271
  limits = limits if isinstance(limits, dict) else {}
1185
1272
  parts = []
@@ -80,8 +80,9 @@ def spawn_refresh(path):
80
80
  pass
81
81
 
82
82
 
83
- def notice(runtime, home, plugin_root, now):
84
- """(systemMessage, additionalContext) when the cache knows a newer release, else ("", "")."""
83
+ def notice(runtime, home, plugin_root, now, dialog=False):
84
+ """(systemMessage, additionalContext) when the cache knows a newer release, else ("", "").
85
+ dialog: the caller's answer to "is the dialog on"; the message then names the dialog first."""
85
86
  if disabled():
86
87
  return "", ""
87
88
  riskrails = os.path.join(home, "riskrails")
@@ -95,12 +96,26 @@ def notice(runtime, home, plugin_root, now):
95
96
  have, new = parse(installed), parse(latest)
96
97
  if not have or not new or new <= have:
97
98
  return "", ""
98
- message = (f"RiskRails {latest} is available (installed {installed}). Update: {COMMAND} "
99
- f"— inside Claude Code or Codex CLI: ! {COMMAND} — then /rr-setup --update in each project.")
100
- context = (f"RiskRails {latest} is available on npm; {installed} is installed. The user updates it "
101
- f"with `{COMMAND}` in a terminal, or `! {COMMAND}` inside Claude Code or Codex CLI, "
102
- "then `/rr-setup --update` in each project. Mention this once to the user if it is "
103
- "relevant; never run the update yourself — installing is the user's act.")
99
+ if dialog:
100
+ message = (f'RiskRails {latest} is available (installed {installed}). Say "update RiskRails" and the '
101
+ f"session asks you in the dialog — or run {COMMAND} in a terminal; "
102
+ "then /rr-setup --update in each project.")
103
+ else:
104
+ message = (f"RiskRails {latest} is available (installed {installed}). Update: {COMMAND} "
105
+ f"— inside Claude Code or Codex CLI: ! {COMMAND} — then /rr-setup --update in each project.")
106
+ if dialog:
107
+ context = (f"RiskRails {latest} is available on npm; {installed} is installed. When the user asks to "
108
+ "update, open the install decision — state.py decision open --kind install "
109
+ f'--command "npx riskrails@{latest} --yes" — send the dialog it prints and run the '
110
+ "command the user picks, verbatim, once; never run the update without that pick — "
111
+ "installing is the user's act. A task in flight refuses it (INSTALL_DURING_TASK): close "
112
+ "the task first. Afterwards the user starts a new session and runs /rr-setup --update "
113
+ "in each project. Mention this once to the user if it is relevant.")
114
+ else:
115
+ context = (f"RiskRails {latest} is available on npm; {installed} is installed. The user updates it "
116
+ f"with `{COMMAND}` in a terminal, or `! {COMMAND}` inside Claude Code or Codex CLI, "
117
+ "then `/rr-setup --update` in each project. Mention this once to the user if it is "
118
+ "relevant; never run the update yourself — installing is the user's act.")
104
119
  return message, context
105
120
 
106
121
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "riskrails",
3
- "version": "4.2.0",
3
+ "version": "4.3.0",
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",
@@ -18,6 +18,15 @@
18
18
  install and reviewable in git.
19
19
  - Dead code is deleted only in a task that is about deleting it, with evidence
20
20
  that it is dead.
21
+ - A function does one thing and its name says which. When the name needs
22
+ "and", split it.
23
+ - Inputs come in as parameters and the result goes out as the return value;
24
+ files, the network and the clock stay at the edge, in the caller.
25
+ - An error message says what failed, what was expected, what was found, and
26
+ the way out.
27
+
28
+ In existing code the file's conventions win; the last three lines hold for the
29
+ functions you add.
21
30
 
22
31
  ## Comments
23
32
 
@@ -1,7 +1,7 @@
1
1
  """dialog: the human decision dialog and the authority that tells a human from the agent -- decisions, consent labels, picks, the approval offer.
2
2
 
3
- shell: human_route, human_turn, refuse_approval, DIALOG_PICK, dialog_policy, dialog_tool_verified, dialog_on, open_decision, print_dialog, offer_decision, settle_decision, settle_written, prune_decisions, mirror_ledger, sync_dialog, dialog_pick, valid_decisions, retire_approvals, task_facts, audit_pick, approve_command, offer_approval
4
- core: DIALOG_TOOL, SESSION_ENV, DECISIONS_MAX, QUESTION_MAX, HEADER_MAX, JSON_DEPTH_MAX, json_loads_capped, dialog_questions, option_description, part_question, dialog_payload, MIRROR, pick_data
3
+ shell: human_route, human_turn, open_approval_id, refuse_approval, DIALOG_PICK, dialog_policy, dialog_tool_verified, dialog_on, open_decision, print_dialog, offer_decision, settle_decision, settle_written, prune_decisions, mirror_ledger, sync_dialog, dialog_pick, valid_decisions, retire_approvals, task_facts, audit_pick, approve_command, offer_approval
4
+ core: DIALOG_TOOL, SESSION_ENV, DECISIONS_MAX, QUESTION_MAX, HEADER_MAX, JSON_DEPTH_MAX, json_loads_capped, dialog_questions, option_description, human_refusal, part_question, dialog_payload, MIRROR, pick_data
5
5
  """
6
6
  import json
7
7
  import os
@@ -77,16 +77,29 @@ def human_turn(root, state):
77
77
  return True, ""
78
78
 
79
79
 
80
+ def open_approval_id(root, rt=rr_runtime.Runtime()):
81
+ """The id of the open approval decision, or None with the dialog off or none open: the refusals name the dialog then (R7)."""
82
+ if not dialog_on(root, runtime=rt.name):
83
+ return None
84
+ try:
85
+ with open(fs.decision_path(root), encoding="utf-8") as fh:
86
+ return next((d["id"] for d in json.load(fh)["open"] if d.get("kind") == "approval"), None)
87
+ except (OSError, ValueError, KeyError, TypeError, AttributeError, RecursionError):
88
+ return None # an unreadable decision.json names no decision: the refusal stays today's
89
+
90
+
80
91
  def refuse_approval(root, state, gate_id, by, why):
81
92
  # Quoted: this project's own path has a space in it, and the command the
82
93
  # refusal prints is the only route a human without the flag has.
83
- fs.die("APPROVAL_REFUSED — approval happens outside the agent. Run in your own terminal:\n"
84
- " python3 %s --root %s approve --by %s\n"
94
+ cmd = "python3 %s --root %s approve --by %s" % (shlex.quote(fs.ENTRY), shlex.quote(root), shlex.quote(by))
95
+ asked = None if state["mode"] == "full_irreversible" else human_refusal("'state.py approve'", cmd, why, open_approval_id(root))
96
+ questions = os.path.relpath(fs.questions_path(root, state, None), root)
97
+ if asked:
98
+ fs.die("%s Or set [Answer]: A on %s in %s and tell the session to sync." % (asked, gate_id, questions), 5)
99
+ fs.die("APPROVAL_REFUSED — approval happens outside the agent. Run in your own terminal:\n %s\n"
85
100
  "or set [Answer]: A on %s in %s and tell the session to sync. An unattended run exports "
86
101
  "AI_UNATTENDED=1 in the launcher's environment; the journal then records the approval as "
87
- "unattended.\nRefused because %s."
88
- % (shlex.quote(fs.ENTRY), shlex.quote(root), shlex.quote(by), gate_id,
89
- os.path.relpath(fs.questions_path(root, state, None), root), why), 5)
102
+ "unattended.\nRefused because %s." % (cmd, gate_id, questions, why), 5)
90
103
 
91
104
 
92
105
  # ---------------------------------------------- the dialog (R36, step 7c)
@@ -254,6 +267,9 @@ def open_decision(root, state, kind, header, question, options, opened_by, multi
254
267
  "question": (question or "").strip()[:QUESTION_MAX],
255
268
  "options": [dict(option, n=n) for n, option in enumerate(options, 1)],
256
269
  }, **extra)
270
+ for option in decision["options"]:
271
+ if option["kind"] == "command" and not option.get("description"):
272
+ option["description"] = labels.describe(shlex.split(option["label"]))
257
273
  data["open"].append(decision)
258
274
  rr_ledger.write_decisions(root, data)
259
275
  if state and state.get("task_id"):
@@ -270,6 +286,15 @@ def option_description(option):
270
286
  return "the session runs exactly this, once" if option["kind"] == "command" else "nothing runs"
271
287
 
272
288
 
289
+ def human_refusal(what, cmd, why, decision):
290
+ """The refusal text that names the dialog first, or None without a decision: the site prints its own text then (R7)."""
291
+ if not decision:
292
+ return None
293
+ return ("APPROVAL_REFUSED — %s is the human's. Ask them in the dialog: send DIALOG_PENDING %s (printed above), "
294
+ "then run the command they pick, verbatim, once. Or they run, in their own terminal:\n %s\n"
295
+ "Refused because %s." % (what, decision, cmd, why))
296
+
297
+
273
298
  def part_question(question, i, n):
274
299
  """The question text of part i of n: a split decision's parts must differ,
275
300
  Claude keys tool_response.answers by text; cut so the whole fits QUESTION_MAX."""
@@ -1,12 +1,13 @@
1
1
  """dialog_cmd: the consent and decision commands -- decision open/show/settle/fallback, the pick applied, and the read-only consent view.
2
2
 
3
- core: SETTLE_EFFECT, SETUP_VALUES, SETUP_VALUE_MAX, SETUP_SPEC_MAX, setup_option_why, setup_decision_why, setup_spec_why
4
- shell: cmd_decision, decision_open, read_setup_spec, setup_command_why, check_setup_labels, setup_open, decision_settle, apply_choice, cmd_consent
3
+ core: SETTLE_EFFECT, INSTALL_RE, SETUP_VALUES, SETUP_VALUE_MAX, SETUP_SPEC_MAX, setup_option_why, setup_decision_why, setup_spec_why
4
+ shell: cmd_decision, decision_open, install_open, read_setup_spec, setup_command_why, check_setup_labels, setup_open, decision_settle, apply_choice, cmd_consent
5
5
  """
6
6
  # pylint: disable=unused-argument # uniform cli dispatch: cmd_consent takes rt like every command
7
7
  import argparse
8
8
  import json
9
9
  import os
10
+ import re
10
11
  import shlex
11
12
  import sys
12
13
 
@@ -22,6 +23,7 @@ SETUP_VALUES = {"memory": ("off", "keep"), "runtime": ("auto", "claude", "codex"
22
23
  "survey": ("light", "full", "keep"), "verify": ("", "other", "keep", "edit")}
23
24
  SETUP_VALUE_MAX = 200
24
25
  SETUP_SPEC_MAX = 262144
26
+ INSTALL_RE = re.compile(r"^npx riskrails@(\d+\.\d+\.\d+) --yes$") # the one command an install decision offers
25
27
 
26
28
 
27
29
  def cmd_decision(args, root, rt=rr_runtime.Runtime()):
@@ -61,13 +63,17 @@ def cmd_decision(args, root, rt=rr_runtime.Runtime()):
61
63
 
62
64
 
63
65
  def decision_open(args, root, rt=rr_runtime.Runtime()):
64
- """The openers the model may call: --kind commit (git commit and git push) and --kind setup (the /rr-setup
65
- questions: choices that set a value, and this project's `memory enable`); nothing else."""
66
+ """The openers the model may call: --kind commit (git commit and git push), --kind setup (the /rr-setup
67
+ questions: choices that set a value, and this project's `memory enable`) and --kind install (one
68
+ `npx riskrails@X.Y.Z --yes`); nothing else."""
66
69
  if args.kind == "setup":
67
70
  setup_open(args, root, rt=rt)
68
71
  return
72
+ if args.kind == "install":
73
+ install_open(args, root, rt=rt)
74
+ return
69
75
  if args.kind != "commit":
70
- fs.die("decision open takes --kind commit or --kind setup; every other decision is opened by the "
76
+ fs.die("decision open takes --kind commit or --kind setup (or --kind install, one npx riskrails@X.Y.Z --yes); every other decision is opened by the "
71
77
  "command that needs it (close, ask, the budgets, start --mode)")
72
78
  if args.spec is not None:
73
79
  fs.die("decision open --kind commit takes --command, not --spec", 2)
@@ -97,6 +103,33 @@ def decision_open(args, root, rt=rr_runtime.Runtime()):
97
103
  dialog.print_dialog([decision], rt=rt)
98
104
 
99
105
 
106
+ def install_open(args, root, rt=rr_runtime.Runtime()):
107
+ """decision open --kind install --command "npx riskrails@X.Y.Z --yes": the update offered in the dialog (R4).
108
+ The command is exactly that, once; no task may be in flight, because the install rewrites the hooks."""
109
+ match = INSTALL_RE.match(args.commands[0]) if len(args.commands) == 1 else None
110
+ if args.spec is not None or not match:
111
+ fs.die("CONSENT_LABEL_INVALID — decision open --kind install takes one --command, exactly "
112
+ "\"npx riskrails@X.Y.Z --yes\" (X.Y.Z a plain version); found: %s"
113
+ % (" | ".join(args.commands)[:120] or "none"), 6)
114
+ if not dialog.dialog_on(root, runtime=rt.name):
115
+ fs.die("the dialog is off here (.ai/policies/dialog.json, the runtime's dialog tool, a session id): "
116
+ "suggest the command; the human runs it outside the agent")
117
+ state = task.in_flight(root, rt=rt)
118
+ if state:
119
+ fs.die("INSTALL_DURING_TASK — an install rewrites the hooks and skills this task runs on, and task %s is in "
120
+ "flight; finish or abandon it, then open the install decision again" % state["task_id"], 6)
121
+ runtime = rr_runtime.own_runtime(runtime=rt.name)
122
+ have = profile.read_profile(runtime, rt=rt).get("plugin_version") or "unknown"
123
+ question = ("Install RiskRails %s (installed %s)? It rewrites the hooks, skills and settings under %s; "
124
+ "restart the session afterwards." % (match.group(1), have, rr_runtime.runtime_home(runtime)))
125
+ options = [{"kind": "choice", "effect": "defer", "label": "Not now — keep %s" % have},
126
+ {"kind": "command", "label": args.commands[0]}]
127
+ decision, why = dialog.open_decision(root, None, "install", "Install", question, options, "decision open", rt=rt)
128
+ if decision is None:
129
+ fs.die(why, 6)
130
+ dialog.print_dialog([decision], rt=rt)
131
+
132
+
100
133
  def read_setup_spec(path):
101
134
  """--spec FILE|- as JSON: a spec that cannot be read, is longer than SETUP_SPEC_MAX characters, is not JSON or
102
135
  nests deeper than the dialog's own cap is a bad spec (exit 2)."""
@@ -5,8 +5,9 @@ shell: guard_pending, request_gate, close_gate
5
5
  """
6
6
  import os
7
7
  import re
8
+ import shlex
8
9
 
9
- from rr_state import fs
10
+ from rr_state import dialog, fs
10
11
  from rr_state import journal
11
12
  from rr_state import locks
12
13
  from rr_state import questions as questions_mod # the bodies keep a local `questions`; the module is aliased
@@ -36,12 +37,17 @@ def guard_pending(root, command, rt=rr_runtime.Runtime()):
36
37
  if any(q["kind"] == "G" for q in blocking):
37
38
  # `answer` refuses a G id, so the general text would send the human to a
38
39
  # command that cannot work. A gate is closed from a terminal.
40
+ gates = ", ".join(q["id"] for q in blocking if q["kind"] == "G")
41
+ cmd = "python3 %s --root %s approve --by \"<name>\"" % (shlex.quote(fs.ENTRY), shlex.quote(root))
42
+ asked = dialog.human_refusal("the approval gate", cmd, "approve and reject are the human's, and %s is open" % gates,
43
+ dialog.open_approval_id(root, rt=rt))
44
+ if asked:
45
+ fs.die("QUESTIONS_PENDING — %d unanswered in %s: %s. Stage stays at %s.\n%s"
46
+ % (len(blocking), os.path.relpath(path, root), ", ".join(ids), state.get("current_stage"), asked), 4)
39
47
  fs.die("QUESTIONS_PENDING — %d unanswered in %s: %s. %s is the approval gate: run "
40
48
  "'state.py approve --by \"<name>\"' or 'state.py reject --by \"<name>\" --why \"<text>\"' "
41
49
  "in your own terminal. Stage stays at %s."
42
- % (len(blocking), os.path.relpath(path, root), ", ".join(ids),
43
- ", ".join(q["id"] for q in blocking if q["kind"] == "G"),
44
- state.get("current_stage")), 4)
50
+ % (len(blocking), os.path.relpath(path, root), ", ".join(ids), gates, state.get("current_stage")), 4)
45
51
  fs.die("QUESTIONS_PENDING — %d unanswered in %s: %s. Answer with 'state.py answer %s' or fill "
46
52
  "the [Answer]: lines and run 'state.py questions --sync'. Stage stays at %s."
47
53
  % (len(blocking), os.path.relpath(path, root), ", ".join(ids),
@@ -1,7 +1,7 @@
1
1
  """labels: the consent labels a decision may offer as commands -- the one-simple-command rule, the state.py command a label runs, and the absolute command the agent and the human share.
2
2
 
3
3
  shell: validate_label, human_name, state_label
4
- core: LABEL_MAX, LABEL_FORBIDDEN, INTERPRETER_RE, PUSH_REF_RE, INTERPRETER_FLAG_RE, NPX_PACKAGE_RE, label_argv, subcommand, state_label_ok, subcommand_prefix
4
+ core: BATCH_ON, DESCRIPTIONS, describe, LABEL_MAX, LABEL_FORBIDDEN, INTERPRETER_RE, PUSH_REF_RE, INTERPRETER_FLAG_RE, NPX_PACKAGE_RE, label_argv, subcommand, state_label_ok, subcommand_prefix
5
5
  """
6
6
 
7
7
  import os
@@ -16,6 +16,17 @@ INTERPRETER_RE = re.compile(r"^python3?$") # a bare name: a path could be any
16
16
  PUSH_REF_RE = re.compile(r"^[A-Za-z0-9._/-]+$") # no '+' (a force push), no ':' (a refspec), no option
17
17
  INTERPRETER_FLAG_RE = re.compile(r"^-[IS]+$")
18
18
  NPX_PACKAGE_RE = re.compile(r"^riskrails@[0-9A-Za-z.+-]+$")
19
+ BATCH_ON = {"--by": True, "--why": False} # `batch on` options, each at most once; True = required. hooks/guard/dialog.py keeps the same table
20
+ DESCRIPTIONS = { # what each offered command does, in words a person without a terminal reads
21
+ ("approve",): "Approve the task's result as it is now, so it can be closed.",
22
+ ("reject",): "Reject the task's result and send it back for changes.",
23
+ ("profile", "set"): "Change the plan profile, which sets the models the work runs on.",
24
+ ("verify",): "Run the test suite once more, past the number of runs this task allows.",
25
+ ("review",): "Record the next review verdict as yours, past the review passes this task allows.",
26
+ ("start",): "Start the task in the mode the command names instead of the one its weight gives.",
27
+ ("memory", "enable"): "Turn on the project's compact record of tasks.",
28
+ ("batch", "on"): "Turn the batch loop on for this task: more test runs and review passes before you are asked.",
29
+ }
19
30
 
20
31
 
21
32
  def label_argv(label):
@@ -44,10 +55,15 @@ def subcommand(argv):
44
55
 
45
56
  def state_label_ok(argv):
46
57
  """approve | reject | profile set | verify --by | review --by | start --mode … --by |
47
- memory enable [--author S] [--period month|quarter|year] [--index none|sqlite]: each option at most
58
+ batch on --by S [--why S] | memory enable [--author S] [--period month|quarter|year] [--index none|sqlite]: each option at most
48
59
  once, as two words, in any order, and no other memory verb. hooks/guard/dialog.py keeps the same
49
60
  rule; tests/test_dialog_contract.py pins the two equal."""
50
61
  rest = subcommand(argv)
62
+ if rest[:2] == ["batch", "on"]:
63
+ names, values = rest[2::2], rest[3::2]
64
+ return (len(names) == len(values) == len(set(names)) and set(names) <= set(BATCH_ON)
65
+ and all(n in names for n, need in BATCH_ON.items() if need)
66
+ and all(v[:1] not in ("", "-") for v in values))
51
67
  if rest[:1] in (["approve"], ["reject"]) or rest[:2] == ["profile", "set"]:
52
68
  return True
53
69
  if rest[:1] in (["verify"], ["review"]):
@@ -89,8 +105,8 @@ def validate_label(label):
89
105
  and state_label_ok(script[1]):
90
106
  return None
91
107
  return ("a label starts with git commit, git push <remote> <branch>, state.py approve|reject|"
92
- "profile set|verify --by|review --by|start --mode --by|memory enable [--author S] "
93
- "[--period P] [--index none|sqlite], or npx riskrails@<v> … --yes")
108
+ "profile set|verify --by|review --by|start --mode --by|batch on --by S [--why S]|memory enable "
109
+ "[--author S] [--period P] [--index none|sqlite], or npx riskrails@<v> … --yes")
94
110
 
95
111
 
96
112
  def human_name(root):
@@ -109,3 +125,16 @@ def state_label(root, argv):
109
125
  def subcommand_prefix(argv):
110
126
  """The global options in front of the subcommand."""
111
127
  return list(argv)[:len(argv) - len(subcommand(argv))]
128
+
129
+
130
+ def describe(argv):
131
+ """One plain-words line for the command a label runs; argv is the label split into words."""
132
+ if argv[:2] == ["git", "commit"]:
133
+ return "Save the finished work as a commit in git."
134
+ if argv[:2] == ["git", "push"]:
135
+ return "Send the committed work to the remote repository."
136
+ if argv[:1] == ["npx"]:
137
+ return "Install this RiskRails version into your runtime's home; start a new session afterwards."
138
+ script = label_argv(shlex.join(argv))
139
+ rest = subcommand(script[1]) if script else []
140
+ return DESCRIPTIONS.get(tuple(rest[:2])) or DESCRIPTIONS.get(tuple(rest[:1])) or "Run a command you have read."
@@ -280,10 +280,11 @@ def close_gate_from_file(root, state, question, by, rt=rr_runtime.Runtime()):
280
280
  state.update(fresh)
281
281
  seen, why = (True, "") if unattended else dialog.human_turn(root, state)
282
282
  if not seen:
283
- fs.die("APPROVAL_REFUSED — %s. Fill the answer, tell the session, and sync again, or run "
284
- "in your own terminal:\n python3 %s --root %s approve --by %s"
285
- % (why, shlex.quote(fs.ENTRY), shlex.quote(root),
286
- shlex.quote(by)), 5)
283
+ cmd = "python3 %s --root %s approve --by %s" % (shlex.quote(fs.ENTRY), shlex.quote(root), shlex.quote(by))
284
+ asked = dialog.human_refusal("'state.py approve'", cmd, why, dialog.open_approval_id(root, rt=rt))
285
+ fs.die(asked + " Or fill the [Answer]: line, tell the session, and sync again." if asked else
286
+ "APPROVAL_REFUSED — %s. Fill the answer, tell the session, and sync again, or run "
287
+ "in your own terminal:\n %s" % (why, cmd), 5)
287
288
  approved = question["choice"] == "A"
288
289
  session = fs.read_session(root)
289
290
  task.claim_runtime(root, state, rt=rt)