riskrails 3.1.0 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -0,0 +1,164 @@
1
+ """dialog_cmd: the consent and decision commands -- decision open/show/settle/fallback, the pick applied, and the read-only consent view.
2
+
3
+ core: SETTLE_EFFECT
4
+ shell: cmd_decision, decision_open, decision_settle, apply_choice, cmd_consent
5
+ """
6
+ # pylint: disable=unused-argument # uniform cli dispatch: cmd_consent takes rt like every command
7
+ import argparse
8
+ import json
9
+ import os
10
+ import sys
11
+
12
+ from rr_state import dialog, fs, labels, profile, questions_cmd, task
13
+ from rr_state import ledger as rr_ledger
14
+ from rr_state import runtime as rr_runtime
15
+
16
+ SETTLE_EFFECT = {"defer": "defer", "keep": "ack", "ack": "ack", "answer": "answered"}
17
+
18
+
19
+ def cmd_decision(args, root, rt=rr_runtime.Runtime()):
20
+ """decision show|open|settle|fallback (R36.3, R36.14, R36.15)."""
21
+ if args.action == "open":
22
+ decision_open(args, root, rt=rt)
23
+ return
24
+ data = rr_ledger.read_decisions(root)
25
+ if args.action == "show":
26
+ # A session sees the decisions it opened (and those that name none):
27
+ # the hooks' gate and Stop reminder hold only the opener to them.
28
+ session = os.environ.get(dialog.SESSION_ENV.get(rr_runtime.own_runtime(runtime=rt.name), ""))
29
+ chosen = [d for d in data["open"] if (not args.id or d["id"] == args.id) and (
30
+ not session or (d.get("session") in (None, "", session) and d.get("runtime") in (None, "", rr_runtime.own_runtime(runtime=rt.name))))]
31
+ if not chosen:
32
+ fs.die("no open decision%s" % (" " + args.id if args.id else ""))
33
+ if args.json:
34
+ print(json.dumps(dialog.dialog_payload(chosen, rr_runtime.own_runtime(runtime=rt.name)), ensure_ascii=False))
35
+ else:
36
+ dialog.print_dialog(chosen, rt=rt)
37
+ return
38
+ decision = next((d for d in data["open"] if d["id"] == args.id), None)
39
+ if decision is None:
40
+ fs.die("no open decision %s (open: %s)" % (args.id or "(--id)",
41
+ ", ".join(d["id"] for d in data["open"]) or "none"))
42
+ if args.action == "fallback":
43
+ if not args.why:
44
+ fs.die("decision fallback needs --why \"<why the dialog cannot be used>\"")
45
+ dialog.settle_decision(root, decision["id"], "fallback")
46
+ print("%s falls back to the terminal (%s). The human runs, outside the agent:"
47
+ % (decision["id"], fs.one_line(args.why, 200)))
48
+ for option in decision["options"]:
49
+ if option["kind"] == "command":
50
+ print(" %s" % option["label"])
51
+ return
52
+ decision_settle(args, root, decision, rt=rt)
53
+
54
+
55
+ def decision_open(args, root, rt=rr_runtime.Runtime()):
56
+ """The one opener the model may call: commit and push, nothing else."""
57
+ if args.kind != "commit":
58
+ fs.die("decision open takes --kind commit; every other decision is opened by the command that "
59
+ "needs it (close, ask, the budgets, start --mode)")
60
+ if not args.commands:
61
+ fs.die("decision open --kind commit needs --command \"git commit …\" [--command \"git push <remote> <branch>\"]")
62
+ for command in args.commands:
63
+ why = labels.validate_label(command)
64
+ if why or command.split()[:2] not in (["git", "commit"], ["git", "push"]):
65
+ fs.die("CONSENT_LABEL_INVALID — %s: %s" % (why or "a commit decision offers git commit and git push only",
66
+ command[:120]), 6)
67
+ if not dialog.dialog_on(root, runtime=rt.name):
68
+ fs.die("the dialog is off here (.ai/policies/dialog.json, the runtime's dialog tool, a session id): "
69
+ "suggest the commands; the human runs them outside the agent")
70
+ state = task.load(root, required=False, claim=False, rt=rt)
71
+ state = state if state and task.is_v7(state) else None
72
+ head = dialog.task_facts(root, state) + "\n" if state else ""
73
+ question = "%sbranch %s · %s\nRun these, in this order?\n%s" % (
74
+ head, fs.run_text(root, ["git", "rev-parse", "--abbrev-ref", "HEAD"]) or "?",
75
+ fs.run_text(root, ["git", "diff", "--shortstat", "HEAD"]) or "no uncommitted change",
76
+ "\n".join(" %s" % c for c in args.commands))
77
+ options = [{"kind": "choice", "effect": "defer", "label": "Not now — commit nothing"}] + \
78
+ [{"kind": "command", "label": c} for c in args.commands]
79
+ decision, why = dialog.open_decision(root, state, "commit", "Commit", question, options, "decision open",
80
+ multi=len(args.commands) > 1, rt=rt)
81
+ if decision is None:
82
+ fs.die(why, 6)
83
+ dialog.print_dialog([decision], rt=rt)
84
+
85
+
86
+ def decision_settle(args, root, decision, rt=rr_runtime.Runtime()):
87
+ """(b) apply what the recorder wrote — a choice pick or a decline; (c) a
88
+ human in a terminal picks an option by number (R36.14)."""
89
+ if args.pick is not None:
90
+ via, why = dialog.human_route(rt=rt)
91
+ if via is None:
92
+ fs.die("APPROVAL_REFUSED — 'decision settle --pick' is the human's, from their own terminal.\n"
93
+ "Refused because %s." % why, 5)
94
+ option = next((o for o in decision["options"] if o["n"] == args.pick), None)
95
+ if option is None:
96
+ fs.die("%s has no option %d" % (decision["id"], args.pick))
97
+ if option["kind"] == "command":
98
+ fs.die("option %d is a command: run it in this terminal instead —\n %s" % (args.pick, option["label"]))
99
+ apply_choice(root, decision, option, None, labels.human_name(root), via, rt=rt)
100
+ return
101
+ # The human's last answer decides: a decline after a pick wins, and only the
102
+ # latest round's choice counts.
103
+ ledger = rr_ledger.read_ledger(root)
104
+ mine = [e for e in ledger if e.get("decision") == decision["id"] and e["type"] in ("pick", "decline")]
105
+ choices = {o["n"]: o for o in decision["options"] if o["kind"] == "choice"}
106
+ picked = [e for e in rr_ledger.latest_rounds(ledger).get(decision["id"], []) if e.get("option") in choices]
107
+ if picked and mine[-1]["type"] == "pick":
108
+ apply_choice(root, decision, choices[picked[-1]["option"]], picked[-1].get("id"), labels.human_name(root),
109
+ "dialog", rt=rt)
110
+ return
111
+ if mine and mine[-1]["type"] == "decline":
112
+ dialog.settle_decision(root, decision["id"], "declined")
113
+ print("%s settled: declined in the dialog (%s); nothing runs"
114
+ % (decision["id"], mine[-1].get("why") or "no answer"))
115
+ return
116
+ live = [p for p in rr_ledger.live_picks(ledger, (dialog.dialog_on(root, runtime=rt.name) or {}).get("session"))
117
+ if p.get("decision") == decision["id"]]
118
+ if live:
119
+ fs.die("%s has a live pick: run it verbatim —\n %s" % (decision["id"], live[0].get("label")))
120
+ fs.die("nothing is recorded for %s: send the dialog ('state.py decision show'), or "
121
+ "'state.py decision fallback --id %s --why …'" % (decision["id"], decision["id"]))
122
+
123
+
124
+ def apply_choice(root, decision, option, pick_id, by, via, rt=rr_runtime.Runtime()):
125
+ effect = SETTLE_EFFECT.get(option.get("effect"), "defer")
126
+ drift = decision.get("drift") or {}
127
+ if decision.get("kind") == "plan_drift" and effect == "ack" and drift.get("fingerprint"):
128
+ profile.write_drift_ack(drift.get("runtime") or rr_runtime.own_runtime(runtime=rt.name), drift["fingerprint"], drift.get("kept"), via)
129
+ if effect == "answered":
130
+ questions_cmd.cmd_answer(argparse.Namespace(topic=None, prose=None, by=by, via=via,
131
+ assignment=["%s=%s" % (decision.get("question_id"), option["answer"])]),
132
+ root, rt=rt)
133
+ dialog.settle_decision(root, decision["id"], effect, pick_id)
134
+ print("%s settled: %s (%s)" % (decision["id"], option["label"], effect))
135
+
136
+
137
+ def cmd_consent(args, root, rt=rr_runtime.Runtime()):
138
+ """Read-only: the live picks and the ledger tail; `audit` checks each pick's
139
+ tool_use_id against its transcript (R36.24)."""
140
+ ledger = rr_ledger.read_ledger(root)
141
+ if args.action == "audit":
142
+ rows = [{"id": p.get("id"), "decision": p.get("decision"), "result": dialog.audit_pick(p)}
143
+ for p in ledger if p["type"] == "pick"]
144
+ if args.json:
145
+ print(json.dumps(rows, ensure_ascii=False))
146
+ else:
147
+ for row in rows:
148
+ print("%s %s %s" % (row["id"], row["decision"], row["result"]))
149
+ print("%d picks: %s" % (len(rows), ", ".join("%d %s" % (sum(r["result"] == k for r in rows), k)
150
+ for k in ("ok", "mismatch", "unverifiable"))))
151
+ if any(r["result"] == "mismatch" for r in rows):
152
+ sys.exit(1)
153
+ return
154
+ switch = dialog.dialog_on(root, runtime=rt.name)
155
+ live = rr_ledger.live_picks(ledger, (switch or {}).get("session"))
156
+ if args.json:
157
+ print(json.dumps({"dialog": bool(switch), "live": live, "tail": ledger[-10:]}, ensure_ascii=False))
158
+ return
159
+ print("dialog: %s · live picks: %d" % ("on" if switch else "off", len(live)))
160
+ for pick in live:
161
+ print(" %s %s until %s: %s" % (pick.get("id"), pick.get("decision"), pick.get("expires_at"),
162
+ pick.get("label")))
163
+ for entry in ledger[-10:]:
164
+ print(" %s %s %s" % (entry.get("at"), entry["type"], entry.get("id") or entry.get("decision") or ""))
@@ -0,0 +1,220 @@
1
+ """The store: where the state, the session, the journal and the logs live, and the clock, the root search and the one subprocess runner.
2
+
3
+ shell: ENTRY, now, now_ms, find_root, find_root_quiet, die, state_path, remove_state, read_session, safe_sensor, find_docs_root, questions_path, read_lines, run_text, read_text, current_tree, ensure_logs
4
+ core: STATE_NAMES, session_path, journal_path, _split_files, SLUG_RE, one_line, handoff_path, decision_path, ledger_path, logs_dir, task_md_path
5
+ """
6
+
7
+ import contextlib
8
+ import json
9
+ import os
10
+ import re
11
+ import subprocess
12
+ import sys
13
+ from datetime import datetime, timezone
14
+
15
+ try:
16
+ import sensors # measurement only; it never writes this file
17
+ except ImportError: # an old install that shipped state.py alone
18
+ sensors = None
19
+
20
+ ENTRY = "" # the entry's path: the entry sets it (fs.ENTRY = os.path.abspath(__file__)), code here reads it at call time
21
+
22
+
23
+ def now():
24
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
25
+
26
+
27
+ def now_ms():
28
+ """Millisecond precision, because two journal lines can share a second."""
29
+ stamp = datetime.now(timezone.utc)
30
+ return stamp.strftime("%Y-%m-%dT%H:%M:%S.") + "%03dZ" % (stamp.microsecond // 1000)
31
+
32
+
33
+ def find_root(start):
34
+ d = os.path.abspath(start)
35
+ while True:
36
+ if os.path.isdir(os.path.join(d, ".ai")):
37
+ return d
38
+ parent = os.path.dirname(d)
39
+ if parent == d:
40
+ die("no .ai/ directory found above %s — run /rr-setup first" % start)
41
+ d = parent
42
+
43
+
44
+ def find_root_quiet(start):
45
+ """find_root without the refusal: profile runs where there is no .ai/."""
46
+ d = os.path.abspath(start or ".")
47
+ while not os.path.isdir(os.path.join(d, ".ai")):
48
+ if os.path.dirname(d) == d:
49
+ return None
50
+ d = os.path.dirname(d)
51
+ return d
52
+
53
+
54
+ def die(message, code=1):
55
+ """1 validation · 2 argparse · 4 QUESTIONS_PENDING · 5 APPROVAL_REFUSED ·
56
+ 6 SCOPE_CHANGE_REQUIRED / run budget / REVIEW_BUDGET (I1, WP4 I4) ·
57
+ 7 RUNTIME_HANDOFF_PENDING · 8 DIRECT_MODE_CAP (WP5 I4; one meaning per code)."""
58
+ print("state.py: %s" % message, file=sys.stderr)
59
+ sys.exit(code)
60
+
61
+
62
+ STATE_NAMES = ("state.json", "current.json")
63
+
64
+
65
+ def state_path(root):
66
+ """The task's state file, read and written where it is: state.json, or
67
+ current.json when that is the file a task in flight sits in. A new task
68
+ starts in state.json (R34's contract half, migration 0009 moves an old one);
69
+ both names stay readable, so a revert of that move still finds the task."""
70
+ new, legacy = (os.path.join(root, ".ai", "state", name) for name in STATE_NAMES)
71
+ return legacy if not os.path.exists(new) and os.path.exists(legacy) else new
72
+
73
+
74
+ def remove_state(root):
75
+ for path in (os.path.join(root, ".ai", "state", name) for name in STATE_NAMES):
76
+ with contextlib.suppress(FileNotFoundError):
77
+ os.remove(path)
78
+
79
+
80
+ def session_path(root):
81
+ return os.path.join(root, ".ai", "state", "session.json")
82
+
83
+
84
+ def ai_unattended():
85
+ """AI_UNATTENDED is set when it is non-empty and not 0, false or no (case
86
+ and blanks ignored): one rule in every reader (risk 0587)."""
87
+ return os.environ.get("AI_UNATTENDED", "").strip().lower() not in ("", "0", "false", "no")
88
+
89
+
90
+ def read_session(root):
91
+ """The hook's sidecar (I5). Absent, half-written or unreadable is normal."""
92
+ try:
93
+ with open(session_path(root), encoding="utf-8") as fh:
94
+ data = json.load(fh)
95
+ except (OSError, ValueError, RecursionError):
96
+ return {}
97
+ return data if isinstance(data, dict) else {}
98
+
99
+
100
+ def journal_path(root, task_id):
101
+ return os.path.join(root, ".ai", "reports", task_id, "events.jsonl")
102
+
103
+
104
+ def safe_sensor(call, *args, **kwargs):
105
+ """A bug in a sensor must never be able to stop a task. Anything unexpected
106
+ reads as 'unavailable', which keeps every review the tier asked for."""
107
+ if sensors is None:
108
+ return None
109
+ try:
110
+ return call(*args, **kwargs)
111
+ except Exception: # pylint: disable=broad-except
112
+ return None
113
+
114
+
115
+ def _split_files(text):
116
+ return [f.strip() for f in (text or "").split(",") if f.strip()]
117
+
118
+
119
+ def find_docs_root(start):
120
+ """--topic resolves its root from docs/sdlc/, never from .ai/: an intent's
121
+ questions belong to the document they serve, and the sdlc-* skills must work
122
+ in a repository that has no .ai/ at all (I11)."""
123
+ d = os.path.abspath(start)
124
+ while True:
125
+ if os.path.isdir(os.path.join(d, "docs", "sdlc")):
126
+ return d
127
+ parent = os.path.dirname(d)
128
+ if parent == d:
129
+ die("no docs/sdlc/ directory found above %s — run /rr-spec first" % start)
130
+ d = parent
131
+
132
+
133
+ SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
134
+
135
+
136
+ def questions_path(root, state, topic):
137
+ if topic:
138
+ # Topic mode is the one writer that runs without .ai/ and therefore
139
+ # without the path guard, so the slug is validated here rather than
140
+ # trusted: it names a file, it never steers one.
141
+ if not SLUG_RE.match(topic):
142
+ die("--topic takes a slug of lowercase letters, digits and hyphens, not %r" % topic)
143
+ return os.path.join(root, "docs", "sdlc", "intent", "%s.questions.md" % topic)
144
+ return task_md_path(root, state)
145
+
146
+
147
+ def read_lines(path):
148
+ try:
149
+ with open(path, encoding="utf-8") as fh:
150
+ return fh.read().split("\n")
151
+ except OSError:
152
+ return None
153
+
154
+
155
+ def one_line(text, limit=None):
156
+ """Everything a caller writes into a file is collapsed to one line: a newline
157
+ in an answer would otherwise produce a second [Answer]: line, and the file a
158
+ human reads would disagree with the record every command acts on.
159
+
160
+ Total on purpose. session.json is written by a hook and the journal takes
161
+ typed data from one, so a value that is not a string is normal input here,
162
+ never a reason to fail the command that was reading it."""
163
+ if text is None:
164
+ return ""
165
+ if not isinstance(text, str):
166
+ text = str(text)
167
+ text = " ".join(text.split())
168
+ return text[:limit] if limit else text
169
+
170
+
171
+ def handoff_path(root):
172
+ return os.path.join(root, ".ai", "state", "handoff.md")
173
+
174
+
175
+ def decision_path(root):
176
+ return os.path.join(root, ".ai", "state", "decision.json")
177
+
178
+
179
+ def ledger_path(root):
180
+ return os.path.join(root, ".ai", "state", "consent.jsonl")
181
+
182
+
183
+ def run_text(root, argv):
184
+ try:
185
+ return subprocess.run(argv, cwd=root, capture_output=True, text=True, check=False,
186
+ timeout=10).stdout.strip()
187
+ except (OSError, subprocess.SubprocessError):
188
+ return ""
189
+
190
+
191
+ def read_text(root, rel):
192
+ try:
193
+ with open(os.path.join(root, rel), "rb") as fh:
194
+ data = fh.read(1 << 20)
195
+ except OSError:
196
+ return ""
197
+ return "" if b"\0" in data[:8192] else data.decode("utf-8", "replace")
198
+
199
+
200
+ def current_tree(root):
201
+ return safe_sensor(sensors.snapshot_tree, root)
202
+
203
+
204
+ def logs_dir(root, task_id):
205
+ return os.path.join(root, ".ai", "state", "logs", task_id)
206
+
207
+
208
+ def ensure_logs(root, task_id):
209
+ """.ai/state/logs/<task>/, ignored by its own .gitignore (D4), so the shipped
210
+ gitignore snippet stays as it is."""
211
+ base = os.path.join(root, ".ai", "state", "logs")
212
+ os.makedirs(os.path.join(base, task_id), exist_ok=True)
213
+ ignore = os.path.join(base, ".gitignore")
214
+ if not os.path.exists(ignore):
215
+ with open(ignore, "w", encoding="utf-8") as fh:
216
+ fh.write("*\n")
217
+
218
+
219
+ def task_md_path(root, state):
220
+ return os.path.join(root, ".ai", "reports", state["task_id"], "task.md")
@@ -0,0 +1,125 @@
1
+ """gate: the approval gate — the pending-question guard, the gate request and its close, and what close accepts as an approval.
2
+
3
+ core: gate_question_text, confirm_names, gate_approved_for
4
+ shell: guard_pending, request_gate, close_gate
5
+ """
6
+ import os
7
+ import re
8
+
9
+ from rr_state import fs
10
+ from rr_state import journal
11
+ from rr_state import locks
12
+ from rr_state import questions as questions_mod # the bodies keep a local `questions`; the module is aliased
13
+ from rr_state import runtime as rr_runtime
14
+ from rr_state import task
15
+
16
+
17
+ def guard_pending(root, command, rt=rr_runtime.Runtime()):
18
+ """The file is re-parsed on every stage-moving command — questions.pending is
19
+ only a cache — and nothing is written, so exit 4 leaves the state byte-identical."""
20
+ state = task.load(root, required=False, claim=False, rt=rt)
21
+ if state is None:
22
+ return
23
+ path = fs.questions_path(root, state, None)
24
+ _, questions = questions_mod.parse_questions(path)
25
+ # For a question the file is the truth. For the gate it is not: an
26
+ # authorization is closed by approve or reject, so a hand-filled [Answer]: A
27
+ # must not unblock the pipeline on its own — the state says when the gate is
28
+ # done, and until then its question keeps blocking.
29
+ gate_id = task.open_gate(state)
30
+ blocking = [q for q in questions if q["kind"] != "G" and questions_mod.is_pending(q)]
31
+ if command != "approve" and command != "reject" and gate_id:
32
+ blocking += [q for q in questions if q["id"] == gate_id]
33
+ if not blocking:
34
+ return
35
+ ids = [q["id"] for q in blocking]
36
+ if any(q["kind"] == "G" for q in blocking):
37
+ # `answer` refuses a G id, so the general text would send the human to a
38
+ # command that cannot work. A gate is closed from a terminal.
39
+ fs.die("QUESTIONS_PENDING — %d unanswered in %s: %s. %s is the approval gate: run "
40
+ "'state.py approve --by \"<name>\"' or 'state.py reject --by \"<name>\" --why \"<text>\"' "
41
+ "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)
45
+ fs.die("QUESTIONS_PENDING — %d unanswered in %s: %s. Answer with 'state.py answer %s' or fill "
46
+ "the [Answer]: lines and run 'state.py questions --sync'. Stage stays at %s."
47
+ % (len(blocking), os.path.relpath(path, root), ", ".join(ids),
48
+ " ".join("%s=<letter>" % i for i in ids), state.get("current_stage")), 4)
49
+
50
+
51
+ def gate_question_text(task_id):
52
+ return "Approve %s for implementation?" % task_id
53
+
54
+
55
+ def request_gate(root, state, gate, rt=rr_runtime.Runtime()):
56
+ """stage human_approval is the request, and the only way to make one: it
57
+ writes the gate's own question, records *which* question it is, and starts
58
+ the gate ungranted. A grant belongs to one gate, never to the task."""
59
+ path = fs.questions_path(root, state, None)
60
+ with locks.question_lock(path):
61
+ lines, questions = questions_mod.parse_questions(path)
62
+ number = max([q["number"] for q in questions if q["kind"] == "G"] or [0]) + 1
63
+ gate_id = "G%d" % number
64
+ while lines and not lines[-1].strip():
65
+ lines.pop()
66
+ lines = questions_mod.with_preamble(path, lines, state["task_id"])
67
+ lines.extend(questions_mod.question_block({
68
+ "id": gate_id,
69
+ "question": "%s (gate: %s)" % (gate_question_text(state["task_id"]), gate),
70
+ "options": [("A", "Approve"), ("B", "Reject — answer as `B: <reason>`")],
71
+ }, "human_approval", None))
72
+ questions_mod.write_questions(path, lines)
73
+ _, questions = questions_mod.parse_questions(path)
74
+ state["human_approval"].update({
75
+ "required": True, "granted": False, "granted_by": None, "granted_at": None,
76
+ "requested_at": fs.now(), "gate_id": gate_id, "rejected_at": None,
77
+ "via": None, "unattended": False,
78
+ # The session the plan was presented in. The file route requires the
79
+ # human's turn to come from *this* session — comparing session.json's two
80
+ # own fields proves nothing, because whichever session ran last wrote
81
+ # both of them.
82
+ "requested_session": fs.read_session(root).get("session_id"),
83
+ })
84
+ questions_mod.set_pending(root, state, questions, path)
85
+ journal.emit(root, state, "gate_requested", "%s: %s" % (gate_id, gate),
86
+ {"gate": gate, "gate_id": gate_id,
87
+ "requested_at": state["human_approval"]["requested_at"]}, rt=rt)
88
+
89
+
90
+ def close_gate(root, state, gate_id, choice, text, by, via):
91
+ """approve and reject are the gate question's `answer`. Without this its
92
+ question would stay pending and block the very next command (Risks 3), and
93
+ `answer` still refuses a G id, because this is the only route that may close
94
+ one. It closes the gate the state names — never "the last G in the file"."""
95
+ path = fs.questions_path(root, state, None)
96
+ with locks.question_lock(path):
97
+ lines, questions = questions_mod.parse_questions(path)
98
+ gates = [q for q in questions if q["id"] == gate_id]
99
+ if not gates:
100
+ return False
101
+ lines[gates[0]["answer_at"]] = questions_mod.answer_line(choice, text, by, via)
102
+ questions_mod.write_questions(path, lines)
103
+ _, questions = questions_mod.parse_questions(path)
104
+ questions_mod.set_pending(root, state, questions, path)
105
+ return True
106
+
107
+
108
+ def confirm_names(confirm, ops):
109
+ """Every operation keyword (DROP, ALTER …) appears as a word in the confirm text."""
110
+ words = set(re.findall(r"[A-Za-z]+", (confirm or "").upper()))
111
+ return bool((confirm or "").strip()) and all(op.upper() in words for op in ops)
112
+
113
+
114
+ def gate_approved_for(state, tree):
115
+ """The approval close accepts, whichever route granted it (terminal or the
116
+ questions file): granted after the gate was requested, for the tree the gate
117
+ was requested on — which must be the tree now — and, for full_irreversible,
118
+ with a confirm that names every operation."""
119
+ gate, approval = state.get("gate") or {}, state["human_approval"]
120
+ if not (approval.get("granted") and tree and gate.get("tree") == tree
121
+ and (approval.get("granted_at") or "") >= (gate.get("requested_at") or "~")):
122
+ return False
123
+ if state["mode"] == "full_irreversible":
124
+ return confirm_names(gate.get("confirm"), state["weight"].get("operations") or [])
125
+ return True
@@ -0,0 +1,128 @@
1
+ """gate_cmd: the approval gate's commands -- approve (the human's grant), reject, and approve with the tree check and the irreversible confirm.
2
+
3
+ shell: cmd_approve, cmd_reject, cmd_approve_v7
4
+ """
5
+ import os
6
+ import sys
7
+
8
+ from rr_state import dialog, fs, handoff, journal, report, task
9
+ from rr_state import ledger as rr_ledger
10
+ from rr_state import gate as rr_gate # cmd_approve_v7 binds a local `gate`
11
+ from rr_state import runtime as rr_runtime
12
+
13
+
14
+ def cmd_approve(args, root, rt=rr_runtime.Runtime()):
15
+ """The one thing the agent cannot do. A pending Q blocks this at exit 4
16
+ before we get here; what is left is whether a gate is open and whether the
17
+ approval comes from outside the agent (R11)."""
18
+ state = task.load(root, rt=rt)
19
+ gate_id = task.open_gate(state)
20
+ if gate_id is None:
21
+ if state["human_approval"].get("granted"):
22
+ # The gate that was open has been granted and consumed. One approval
23
+ # per gate, or the unattended signal in the journal is unreadable.
24
+ print("already approved by %s at %s (via %s)"
25
+ % (state["human_approval"].get("granted_by"),
26
+ state["human_approval"].get("granted_at"),
27
+ state["human_approval"].get("via")))
28
+ return
29
+ fs.die("APPROVAL_REFUSED — no gate was requested: 'state.py close' requests it for a full-mode "
30
+ "task.", 5)
31
+ tty = os.isatty(0)
32
+ # A terminal is the stronger evidence: a launcher variable left in a human's
33
+ # shell must not downgrade an approval they gave in person.
34
+ # full_irreversible never takes the dialog: its --confirm is the human's own
35
+ # words, so the terminal command is offered as before (R36.10).
36
+ via, why = dialog.human_route(root, allow_dialog=state.get("mode") != "full_irreversible", rt=rt)
37
+ if via is None:
38
+ dialog.refuse_approval(root, state, gate_id, args.by, why)
39
+ task.claim_runtime(root, state, rt=rt)
40
+ state["human_approval"].update({
41
+ "required": True, "granted": True, "granted_by": args.by, "granted_at": fs.now(),
42
+ "requested_at": None, "gate_id": None, "requested_session": None,
43
+ "rejected_at": None, "via": via, "unattended": via == "unattended",
44
+ })
45
+ journal.record(state, "human_approval", "granted by %s via %s" % (args.by, via))
46
+ handoff.write_handoff(root, state, "stage", rt=rt)
47
+ task.save(root, state) # durable first: the rest is derived
48
+ journal.append_journal(root, state["task_id"], journal.journal_line(
49
+ state["task_id"], "gate_approved", "human" if via == "terminal" else "agent",
50
+ state.get("current_stage"), "granted by %s via %s" % (args.by, via),
51
+ dict({"by": args.by, "gate_id": gate_id, "via": via,
52
+ "unattended": via == "unattended", "tty": tty}, **dialog.pick_data(dialog.DIALOG_PICK[0])), runtime=rt.name))
53
+ if not rr_gate.close_gate(root, state, gate_id, "A", args.note, args.by, via):
54
+ print("state.py: %s is not in %s — the gate was granted, answer it by hand"
55
+ % (gate_id, os.path.relpath(fs.questions_path(root, state, None), root)),
56
+ file=sys.stderr)
57
+ task.save(root, state)
58
+ dialog.retire_approvals(root, state["task_id"], "approved")
59
+ print("approved by %s (via %s)%s" % (args.by, via, " — recorded as unattended"
60
+ if via == "unattended" else ""))
61
+
62
+
63
+ def cmd_reject(args, root, rt=rr_runtime.Runtime()):
64
+ """Rejection needs no terminal: it cannot let anything through. It needs an
65
+ open gate, though — otherwise it would erase a grant it never opened."""
66
+ state = task.load(root, rt=rt)
67
+ gate_id = task.open_gate(state)
68
+ if gate_id is None:
69
+ fs.die("APPROVAL_REFUSED — no gate was requested: 'state.py close' requests it for a full-mode "
70
+ "task.", 5)
71
+ # A rejection needs no route, but a picked reject spends its pick, so the
72
+ # approval decision settles.
73
+ pick = dialog.dialog_pick(root, rt=rt)
74
+ if os.path.exists(fs.decision_path(root)):
75
+ rr_ledger.read_decisions(root) # an unreadable file refuses before anything is recorded (5580)
76
+ task.claim_runtime(root, state, rt=rt)
77
+ state["human_approval"].update({
78
+ "required": True, "granted": False, "granted_by": None, "granted_at": None,
79
+ "requested_at": None, "gate_id": None, "via": None, "unattended": False,
80
+ })
81
+ state["human_approval"]["rejected_at"] = fs.now()
82
+ state["next_action"] = "address the rejection: %s" % fs.one_line(args.why)
83
+ journal.record(state, "gate_rejected", "%s: %s" % (args.by, args.why))
84
+ handoff.write_handoff(root, state, "stage", rt=rt)
85
+ task.save(root, state)
86
+ journal.append_journal(root, state["task_id"], journal.journal_line(
87
+ state["task_id"], "gate_rejected", "human", state.get("current_stage"),
88
+ "%s: %s" % (args.by, args.why),
89
+ dict({"by": args.by, "gate_id": gate_id, "why": args.why}, **dialog.pick_data(pick)), runtime=rt.name))
90
+ rr_gate.close_gate(root, state, gate_id, "B", args.why, args.by,
91
+ "dialog" if pick else "terminal" if os.isatty(0) else "prose")
92
+ task.save(root, state)
93
+ dialog.retire_approvals(root, state["task_id"], "declined")
94
+ print("rejected by %s; stage stays at %s" % (args.by, state["current_stage"]))
95
+
96
+
97
+ # ---------------------------------------------- the dialog (R36, step 7c)
98
+ #
99
+
100
+
101
+ def cmd_approve_v7(args, root, rt=rr_runtime.Runtime()):
102
+ """approve, plus the gate's tree and, for full_irreversible, the operation
103
+ the human names (--confirm). The grant itself is cmd_approve's, unchanged."""
104
+ state = task.load_v7(root, rt=rt)
105
+ ops = state["weight"].get("operations") or []
106
+ confirm = (args.confirm or "").strip()
107
+ gate = state.get("gate") or {}
108
+ if task.open_gate(state):
109
+ tree = fs.current_tree(root)
110
+ if gate.get("tree") != tree:
111
+ # The work changed after close put it up: approving now would sign
112
+ # a tree nobody presented.
113
+ fs.die("APPROVAL_REFUSED — the tree changed after close asked for this approval (%s, now %s). "
114
+ "Reject it ('reject --by \"<name>\" --why \"the tree changed\"'); the next close asks "
115
+ "again for the tree as it is." % ((gate.get("tree") or "?")[:9], (tree or "?")[:9]), 5)
116
+ if state["mode"] == "full_irreversible" and not rr_gate.confirm_names(confirm, ops):
117
+ fs.die("APPROVAL_REFUSED — full_irreversible needs --confirm \"<what it does>\" naming %s"
118
+ % (" and ".join(ops) or "the irreversible operation"), 5)
119
+ cmd_approve(args, root, rt=rt)
120
+ state = task.load(root, claim=False, rt=rt)
121
+ gate = state.get("gate") or {}
122
+ if state["human_approval"].get("granted") and gate and not gate.get("approved_by"):
123
+ gate.update(approved_by=args.by, approved_at=state["human_approval"].get("granted_at"),
124
+ via=state["human_approval"].get("via"), confirm=confirm or None,
125
+ approved_tree=gate.get("tree"))
126
+ state["gate"] = gate
127
+ task.save(root, state)
128
+ report.write_task_md(root, state)