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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +25 -15
- package/agents/ai-reviewer.md.tmpl +14 -1
- package/ai/agents/implementer.md +6 -0
- package/ai/agents/reviewer.md +8 -1
- package/bin/ansi.js +12 -0
- package/bin/menu.js +71 -12
- package/bin/riskrails.js +240 -14
- package/bin/uninstall.js +115 -0
- package/bin/wizard.js +72 -0
- package/hooks/context-guard.py +11 -1
- package/hooks/dialog-gate.py +3 -2
- package/hooks/guard/decision.py +4 -3
- package/hooks/guard/dialog.py +12 -2
- package/hooks/guard/ere.py +29 -10
- package/hooks/guard/git.py +2 -1
- package/hooks/guard/paths_base.py +7 -0
- package/hooks/guard/paths_bash.py +3 -3
- package/hooks/runtime-gate.py +87 -0
- package/hooks/update_notice.py +23 -8
- package/package.json +1 -1
- package/skills/ai-init/templates/.ai/policies/coding.md +9 -0
- package/skills/ai-task/rr_state/dialog.py +32 -7
- package/skills/ai-task/rr_state/dialog_cmd.py +38 -5
- package/skills/ai-task/rr_state/gate.py +10 -4
- package/skills/ai-task/rr_state/labels.py +33 -4
- package/skills/ai-task/rr_state/questions_cmd.py +5 -4
- package/skills/ai-task/rr_state/report.py +73 -10
- package/skills/ai-task/rr_state/report_cmd.py +2 -0
- package/skills/ai-task/rr_state/review.py +18 -14
- package/skills/ai-task/rr_state/start.py +12 -9
- package/skills/ai-task/rr_state/steps.py +16 -7
- package/skills/ai-task/rr_state/verify.py +17 -12
- package/skills/project-update/history/blobs/dac90781793b8a1c8e5f3e0307665b6394d922140c3831c5508d019f79e1ed0a +41 -0
- package/skills/project-update/history/index.json +6 -3
- package/skills/rr-build/SKILL.md +5 -2
- package/skills/rr-ship/SKILL.md +2 -2
- package/skills/rr-spec/SKILL.md +6 -0
- package/skills/rr-status/SKILL.md +2 -0
- package/skills/rr-status/status.py +5 -3
package/hooks/guard/dialog.py
CHANGED
|
@@ -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 "
|
package/hooks/guard/ere.py
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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)
|
package/hooks/guard/git.py
CHANGED
|
@@ -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.
|
|
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,
|
|
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):
|
package/hooks/runtime-gate.py
CHANGED
|
@@ -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 = []
|
package/hooks/update_notice.py
CHANGED
|
@@ -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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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.
|
|
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
|
-
|
|
84
|
-
|
|
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)
|
|
65
|
-
questions: choices that set a value, and this project's `memory enable`)
|
|
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|
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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)
|