riskrails 3.0.1 → 3.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +29 -15
- package/ai/policies/model-routing.md +7 -7
- package/ai/policies/production.md +3 -3
- package/ai/policies/release.md +2 -2
- package/ai/policies/review-economy.md +27 -30
- package/bin/riskrails.js +114 -23
- package/hooks/ai-path-guard-defaults.json +3 -2
- package/hooks/context-guard.py +15 -12
- package/hooks/dialog-gate.py +1 -1
- package/hooks/guard/common.py +2 -0
- package/hooks/guard/dialog.py +107 -27
- package/hooks/guard/git.py +12 -3
- package/hooks/guard/paths.py +150 -37
- package/hooks/project-scaffold.py +3 -6
- package/hooks/runtime-gate.py +134 -33
- package/hooks/update_notice.py +22 -16
- package/instructions/routing.md +1 -1
- package/instructions/stub.md +3 -3
- package/package.json +1 -1
- package/skills/AGENTS.md +1 -1
- package/skills/CLAUDE.md +1 -1
- package/skills/ai-init/templates/.ai/AGENTS.md +1 -1
- package/skills/ai-init/templates/.ai/VERSION +1 -1
- package/skills/ai-init/templates/.ai/policies/security.md +7 -1
- package/skills/ai-init/templates/.ai/policies/testing.md +19 -6
- package/skills/ai-init/templates/.ai/state/README.md +4 -4
- package/skills/ai-init/templates/AGENTS.block.md +2 -2
- package/skills/ai-init/templates/CLAUDE.block.md +2 -2
- package/skills/ai-init/templates/GEMINI.block.md +2 -2
- package/skills/ai-init/templates/gitignore.snippet +1 -0
- package/skills/ai-init/templates/junie-guidelines.block.md +2 -2
- package/skills/ai-task/rr_state/__init__.py +1 -0
- package/skills/ai-task/rr_state/cli.py +210 -0
- package/skills/ai-task/rr_state/close.py +171 -0
- package/skills/ai-task/rr_state/dialog.py +541 -0
- package/skills/ai-task/rr_state/dialog_cmd.py +164 -0
- package/skills/ai-task/rr_state/fs.py +220 -0
- package/skills/ai-task/rr_state/gate.py +125 -0
- package/skills/ai-task/rr_state/gate_cmd.py +128 -0
- package/skills/ai-task/rr_state/handoff.py +226 -0
- package/skills/ai-task/rr_state/journal.py +126 -0
- package/skills/ai-task/rr_state/labels.py +99 -0
- package/skills/ai-task/rr_state/ledger.py +145 -0
- package/skills/ai-task/rr_state/locks.py +84 -0
- package/skills/ai-task/rr_state/memory_cmd.py +310 -0
- package/skills/ai-task/rr_state/policy.py +378 -0
- package/skills/ai-task/rr_state/profile.py +373 -0
- package/skills/ai-task/rr_state/questions.py +336 -0
- package/skills/ai-task/rr_state/questions_cmd.py +321 -0
- package/skills/ai-task/rr_state/report.py +271 -0
- package/skills/ai-task/rr_state/report_cmd.py +230 -0
- package/skills/ai-task/rr_state/review.py +165 -0
- package/skills/ai-task/rr_state/runtime.py +80 -0
- package/skills/ai-task/rr_state/start.py +257 -0
- package/skills/ai-task/rr_state/steps.py +131 -0
- package/skills/ai-task/rr_state/task.py +396 -0
- package/skills/ai-task/rr_state/verify.py +231 -0
- package/skills/ai-task/sensors.py +37 -4
- package/skills/ai-task/state.py +24 -5138
- package/skills/project-init/templates/constitution.md +1 -1
- package/skills/project-init/templates/sdlc-README.md +20 -14
- package/skills/project-update/adopt.py +38 -24
- package/skills/project-update/history/blobs/00289f98434fed230eebe768dfb3425f83d737866a3aebc97aa95167c4fa0c6e +131 -0
- package/skills/project-update/history/blobs/079a91f976ad69b5cf49ccb9a28849576e4735ddd9485fc26599e528a69fd2f4 +30 -0
- package/skills/project-update/history/blobs/07ff7fa6f93d471ff533f553b4e523e4105ccde1f3dab70132c5237728f68422 +13 -0
- package/skills/project-update/history/blobs/0ef4a360c76da290173dea0f8073949cb0f8113710a38df95acae5d96bdd1286 +123 -0
- package/skills/project-update/history/blobs/18c7a2753ce9cb6f6d3d1e9d5d1125d75950b879d56b7325225b200869631b29 +15 -0
- package/skills/project-update/history/blobs/25d4f2a86deb5e2574bb3210b67bb24fcc4afb19f93a7b65a057daa874a9d18e +1 -0
- package/skills/project-update/history/blobs/48d9950f16e31d55475f566a80dc95f64b35f1c804d2f51729e313a4f29bac8d +13 -0
- package/skills/project-update/history/blobs/5a93f78b4ed4d416c171139b2ce610a117f9c1dd46fbb56ca86729cb4c53d10c +6 -0
- package/skills/project-update/history/blobs/6a3be0e624e5ad91e44d08857b7183ee53b7903e1e8d56ff196dbc9dde271e4a +75 -0
- package/skills/project-update/history/blobs/7170553410bba904ffb7edb4b231e3b3579b85542d84de717299980938f82c9d +13 -0
- package/skills/project-update/history/blobs/774b1e3a04ef0d49d33c44a2cfecd92eb6f6b738a12b6a681103ba7fc13e7b86 +67 -0
- package/skills/project-update/history/blobs/7f063e79195a5c85c37b7260dd20c184d3b9bea85e37202e0bbca73ad84bba49 +13 -0
- package/skills/project-update/history/blobs/a51517e13e57420d2d88a6bcfb4055d762d4a0d10a8f88450929c5c4572213a9 +67 -0
- package/skills/project-update/history/blobs/c8573ca1f810ce6c4ac49456a4c1725b93e0d3b74c54ce95d46d8747777895f0 +13 -0
- package/skills/project-update/history/blobs/d6e5b34d475e7187d2a8cb2aac7e7f1a427142baf6461bda387d70ceb4d8a8c1 +30 -0
- package/skills/project-update/history/blobs/e120ff4e32b2e950fc94ada4e787d57dc9c790da37223e5a202d74f7e3aadf80 +13 -0
- package/skills/project-update/history/index.json +38 -16
- package/skills/project-update/migrations/0003_context_diet.py +1 -1
- package/skills/project-update/migrations/0007_weight_model.py +130 -23
- package/skills/project-update/migrations/0011_retire_risk_tiers.py +37 -0
- package/skills/project-update/migrations/README.md +16 -1
- package/skills/project-update/migrations/__init__.py +6 -1
- package/skills/project-update/render_instructions.py +1 -1
- package/skills/project-update/update.py +569 -106
- package/skills/rr-build-all/SKILL.md +2 -2
- package/skills/rr-plan/SKILL.md +3 -1
- package/skills/rr-review/SKILL.md +13 -8
- package/skills/rr-setup/SKILL.md +24 -4
- package/skills/rr-setup/setup.py +7 -1
- package/skills/rr-spec/SKILL.md +4 -2
- package/skills/rr-status/status.py +36 -9
- package/skills/usage-report/usage-report.py +4 -2
- package/skills/ai-init/templates/.ai/policies/risk-tiers.json +0 -628
- package/skills/ai-init/templates/.ai/policies/risk-tiers.md +0 -180
|
@@ -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)
|