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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +12 -11
- package/bin/riskrails.js +109 -18
- package/hooks/ai-path-guard-defaults.json +3 -2
- package/hooks/context-guard.py +14 -11
- package/hooks/dialog-gate.py +1 -1
- package/hooks/guard/dialog.py +107 -27
- package/hooks/guard/git.py +12 -3
- package/hooks/guard/paths.py +109 -7
- package/hooks/project-scaffold.py +2 -1
- package/hooks/runtime-gate.py +128 -27
- package/hooks/update_notice.py +3 -3
- package/package.json +1 -1
- package/skills/ai-init/templates/.ai/state/README.md +4 -4
- 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 +16 -3
- package/skills/ai-task/state.py +24 -5161
- package/skills/project-update/adopt.py +38 -24
- package/skills/project-update/history/blobs/a51517e13e57420d2d88a6bcfb4055d762d4a0d10a8f88450929c5c4572213a9 +67 -0
- package/skills/project-update/history/index.json +2 -1
- 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/README.md +16 -1
- package/skills/project-update/update.py +202 -26
- package/skills/rr-setup/SKILL.md +9 -2
- package/skills/rr-status/status.py +36 -9
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""review: the `review` command -- one reviewer pass recorded, the verdict checked against the findings block, the findings closed by a green suite.
|
|
2
|
+
|
|
3
|
+
core: REVIEW_TIERS, FINDING_RE, OUT_OF_SCOPE_RE, open_findings, VERDICT_LINE_RE
|
|
4
|
+
shell: read_report, verdict_matches_findings, cmd_review, review_closes
|
|
5
|
+
"""
|
|
6
|
+
# pylint: disable=protected-access # cmd_review calls fs._split_files, which partition.json assigns to fs
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shlex
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from rr_state import dialog, fs, handoff, journal, task
|
|
13
|
+
from rr_state import labels as rr_labels
|
|
14
|
+
from rr_state import policy as rr_policy
|
|
15
|
+
from rr_state import report as rr_report
|
|
16
|
+
from rr_state import runtime as rr_runtime
|
|
17
|
+
|
|
18
|
+
REVIEW_TIERS = ["BALANCED", "STRONG"]
|
|
19
|
+
FINDING_RE = re.compile(r"\[(BLOCKER|HIGH|MEDIUM|LOW|INFO)\]\s+([A-Z][0-9]+)\b")
|
|
20
|
+
OUT_OF_SCOPE_RE = re.compile(r"in_scope:\s*no\b", re.I)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def read_report(source):
|
|
24
|
+
if not source:
|
|
25
|
+
return ""
|
|
26
|
+
if source == "-":
|
|
27
|
+
return sys.stdin.read()
|
|
28
|
+
try:
|
|
29
|
+
with open(source, encoding="utf-8") as fh:
|
|
30
|
+
return fh.read()
|
|
31
|
+
except OSError as exc:
|
|
32
|
+
fs.die("--report %s: %s" % (source, exc))
|
|
33
|
+
return "" # unreachable: die() exits
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def open_findings(text, severities=("BLOCKER", "HIGH")):
|
|
37
|
+
"""Labels of `severities` whose block does not say in_scope: no."""
|
|
38
|
+
hits = list(FINDING_RE.finditer(text or ""))
|
|
39
|
+
out = []
|
|
40
|
+
for i, m in enumerate(hits):
|
|
41
|
+
block = text[m.start():hits[i + 1].start() if i + 1 < len(hits) else len(text)]
|
|
42
|
+
if m.group(1) in severities and not OUT_OF_SCOPE_RE.search(block):
|
|
43
|
+
out.append(m.group(2))
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# "VERDICT pass", "VERDICT: pass", "## VERDICT pass" and the shipped reviewer's
|
|
48
|
+
# "## VERDICT" section with its "verdict: pass" line (ai/agents/reviewer.md).
|
|
49
|
+
VERDICT_LINE_RE = re.compile(r"^(?:#+[ \t]*)?verdict[ \t]*:?[ \t]*(pass_with_findings|blockers_open|pass)\b",
|
|
50
|
+
re.M | re.I)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def verdict_matches_findings(verdict, text):
|
|
54
|
+
"""The verdict a reviewer's FINDINGS block allows: an in-scope BLOCKER is
|
|
55
|
+
blockers_open, an in-scope HIGH is not a pass, and a VERDICT line in the
|
|
56
|
+
block is the verdict. The block is the evidence; a verdict without it is a
|
|
57
|
+
claim nobody can audit."""
|
|
58
|
+
if not (text or "").strip() or "FINDINGS" not in text:
|
|
59
|
+
fs.die("review needs the reviewer's FINDINGS block: --report - (stdin) or --report FILE")
|
|
60
|
+
blockers, highs = (open_findings(text, (sev,)) for sev in ("BLOCKER", "HIGH"))
|
|
61
|
+
said = VERDICT_LINE_RE.search(text)
|
|
62
|
+
if said and said.group(1) != verdict:
|
|
63
|
+
fs.die("--verdict %s, but the report's VERDICT line says %s" % (verdict, said.group(1)))
|
|
64
|
+
if blockers and verdict != "blockers_open":
|
|
65
|
+
fs.die("the report has an in-scope BLOCKER (%s): the verdict is blockers_open, not %s"
|
|
66
|
+
% (", ".join(blockers), verdict))
|
|
67
|
+
if highs and verdict == "pass":
|
|
68
|
+
fs.die("the report has an in-scope HIGH (%s): the verdict is pass_with_findings or blockers_open"
|
|
69
|
+
% ", ".join(highs))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def cmd_review(args, root, rt=rr_runtime.Runtime()):
|
|
73
|
+
"""One pass recorded; the pass past review_rounds_max is the human's (exit 6)."""
|
|
74
|
+
state = task.load_v7(root, rt=rt)
|
|
75
|
+
policy = rr_policy.weight_policy(root)
|
|
76
|
+
review = state["review"]
|
|
77
|
+
if args.closes:
|
|
78
|
+
review_closes(root, state, fs._split_files(args.closes), rt=rt)
|
|
79
|
+
return
|
|
80
|
+
if not args.verdict or not args.tier:
|
|
81
|
+
fs.die("review needs --verdict pass|pass_with_findings|blockers_open and --tier BALANCED|STRONG")
|
|
82
|
+
rounds, budget_name = rr_policy.budget(policy, state, "review_rounds_max")
|
|
83
|
+
by_human = None
|
|
84
|
+
if review["passes"] >= rounds:
|
|
85
|
+
if not args.by:
|
|
86
|
+
report = ["--report", os.path.abspath(args.report)] if args.report and os.path.isfile(args.report) else []
|
|
87
|
+
dialog.offer_decision(root, state, "human_set", "Review",
|
|
88
|
+
"%s\nREVIEW_BUDGET: %d review passes; weight.json allows %d%s. The next verdict is yours."
|
|
89
|
+
% (dialog.task_facts(root, state), review["passes"], rounds,
|
|
90
|
+
" (%s)" % budget_name if rr_policy.batch_on(state) else ""),
|
|
91
|
+
[{"kind": "choice", "effect": "keep", "label": "Keep it open — no verdict now"}] +
|
|
92
|
+
[{"kind": "command",
|
|
93
|
+
"label": rr_labels.state_label(root, ["review", "--verdict", verdict, "--tier", args.tier] +
|
|
94
|
+
report + ["--by", rr_labels.human_name(root)])}
|
|
95
|
+
for verdict in ("pass", "pass_with_findings", "blockers_open")], "review", rt=rt)
|
|
96
|
+
fs.die("REVIEW_BUDGET — %d review passes; weight.json allows %d (%s). The next "
|
|
97
|
+
"verdict is the human's, in their own terminal:\n python3 %s --root %s review --verdict "
|
|
98
|
+
"<verdict> --tier %s --by \"<name>\"" % (review["passes"], rounds, budget_name,
|
|
99
|
+
shlex.quote(fs.ENTRY),
|
|
100
|
+
shlex.quote(root), args.tier), 6)
|
|
101
|
+
via, why = dialog.human_route(root, allow_dialog=True, rt=rt)
|
|
102
|
+
if via is None:
|
|
103
|
+
fs.die("APPROVAL_REFUSED — a verdict past the review budget is the human's, from their own "
|
|
104
|
+
"terminal.\nRefused because %s." % why, 5)
|
|
105
|
+
by_human = (args.by, via)
|
|
106
|
+
wanted = rr_policy.mode_setting(policy, state["mode"], "reviewer", "BALANCED")
|
|
107
|
+
if wanted in REVIEW_TIERS and REVIEW_TIERS.index(args.tier) < REVIEW_TIERS.index(wanted):
|
|
108
|
+
fs.die("%s mode needs a %s review (weight.json modes.%s.reviewer); this one was %s"
|
|
109
|
+
% (state["mode"], wanted, state["mode"], args.tier))
|
|
110
|
+
text = read_report(args.report)
|
|
111
|
+
if not by_human:
|
|
112
|
+
verdict_matches_findings(args.verdict, text)
|
|
113
|
+
labels = [m.group(2) for m in FINDING_RE.finditer(text)]
|
|
114
|
+
status = rr_policy.VERDICT_STATUS[args.verdict]
|
|
115
|
+
tree = fs.current_tree(root)
|
|
116
|
+
review.update(status=status, passes=review["passes"] + (0 if by_human else 1), tier=args.tier,
|
|
117
|
+
findings=open_findings(text), tree=tree, ts=fs.now(),
|
|
118
|
+
runs_at=len(state["verify"]["runs"]))
|
|
119
|
+
review["log"].append({"n": len(review["log"]) + 1, "tier": args.tier, "verdict": args.verdict,
|
|
120
|
+
"focus": args.focus, "text": text[:20000], "tree": tree, "ts": fs.now(),
|
|
121
|
+
"runtime": rt.name, "by": by_human[0] if by_human else None})
|
|
122
|
+
state["stage"] = "review"
|
|
123
|
+
if by_human:
|
|
124
|
+
journal.emit(root, state, "human_set", "review %s by %s via %s" % (args.verdict, by_human[0], by_human[1]),
|
|
125
|
+
dict({"field": "review", "value": status, "by": by_human[0], "via": by_human[1],
|
|
126
|
+
"unattended": by_human[1] == "unattended"}, **dialog.pick_data(dialog.DIALOG_PICK[0])),
|
|
127
|
+
actor="human" if by_human[1] == "terminal" else "agent", rt=rt)
|
|
128
|
+
journal.emit(root, state, "review_verdict", "%s on %s (pass %d)" % (args.verdict, args.tier, review["passes"]),
|
|
129
|
+
{"verdict": args.verdict, "tier": args.tier, "pass_n": review["passes"], "labels": labels,
|
|
130
|
+
"focus": args.focus}, rt=rt)
|
|
131
|
+
state["next_action"] = rr_policy.review_next_action(state)
|
|
132
|
+
handoff.write_handoff(root, state, "review", rt=rt)
|
|
133
|
+
task.save(root, state)
|
|
134
|
+
rr_report.write_task_md(root, state)
|
|
135
|
+
print("review: %s (%s, pass %d of %d)%s" % (status, args.tier, review["passes"], rounds,
|
|
136
|
+
" — open: %s" % ", ".join(review["findings"])
|
|
137
|
+
if review["findings"] else ""))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def review_closes(root, state, labels, rt=rr_runtime.Runtime()):
|
|
141
|
+
"""passed_with_findings → passed once a green suite run after the verdict, on
|
|
142
|
+
a tree the fix changed, is the tree now (§3a, without a second review)."""
|
|
143
|
+
review = state["review"]
|
|
144
|
+
if review["status"] != "passed_with_findings":
|
|
145
|
+
fs.die("--closes is for a passed_with_findings review; this one is %s" % review["status"])
|
|
146
|
+
unknown = [l for l in labels if l not in review["findings"]]
|
|
147
|
+
if not labels or unknown:
|
|
148
|
+
fs.die("--closes names open findings (%s); not open: %s"
|
|
149
|
+
% (", ".join(review["findings"]) or "none", ", ".join(unknown) or "none given"))
|
|
150
|
+
tree = fs.current_tree(root)
|
|
151
|
+
runs = [r for r in state["verify"]["runs"][review.get("runs_at") or 0:]
|
|
152
|
+
if rr_policy.full_suite(r) and r.get("exit") == 0]
|
|
153
|
+
if not runs or runs[-1].get("tree") != tree or tree == review.get("tree"):
|
|
154
|
+
fs.die("findings %s stay open: they close on a green 'state.py verify --scope suite|all --after-fix "
|
|
155
|
+
"\"…\"' after the verdict, on a tree the fix changed, which is the tree now" % ", ".join(labels))
|
|
156
|
+
review["findings"] = [l for l in review["findings"] if l not in labels]
|
|
157
|
+
review["closed"] = list(review.get("closed") or []) + labels
|
|
158
|
+
if not review["findings"]:
|
|
159
|
+
review["status"] = "passed"
|
|
160
|
+
journal.emit(root, state, "findings_closed", "%s closed by suite run %d on %s"
|
|
161
|
+
% (", ".join(labels), runs[-1]["n"], tree[:9]),
|
|
162
|
+
{"labels": labels, "run": runs[-1]["n"], "tree": tree}, rt=rt)
|
|
163
|
+
task.save(root, state)
|
|
164
|
+
rr_report.write_task_md(root, state)
|
|
165
|
+
print("findings closed: %s; review %s" % (", ".join(labels), review["status"]))
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""The runtime: which runtime (claude or codex) this process acts as, where each one is installed, and the agent markers in the environment.
|
|
2
|
+
|
|
3
|
+
shell: Runtime, installed_runtime, detect_runtime, runtime_home, own_runtime, other_runtime, installed, agent_markers
|
|
4
|
+
core: RUNTIMES, AGENT_MARKERS
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import dataclasses
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
from rr_state import fs
|
|
11
|
+
|
|
12
|
+
RUNTIMES = ["claude", "codex"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclasses.dataclass(frozen=True)
|
|
16
|
+
class Runtime:
|
|
17
|
+
"""The runtime this process acts as and whether the command mutates state; built once by cli.main from the three resolutions, never changed (R4)."""
|
|
18
|
+
name: str = "unknown"
|
|
19
|
+
mutating: bool = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def installed_runtime():
|
|
23
|
+
"""The runtime whose home this copy is installed in, or None for a checkout.
|
|
24
|
+
Both skills call state.py from their own home ($AI_HOME), so this answers
|
|
25
|
+
for every call a runtime makes."""
|
|
26
|
+
here = fs.ENTRY
|
|
27
|
+
for runtime in ("codex", "claude"):
|
|
28
|
+
home = os.path.abspath(runtime_home(runtime)).rstrip(os.sep) + os.sep
|
|
29
|
+
if here.startswith(home):
|
|
30
|
+
return runtime
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def detect_runtime(explicit, root):
|
|
35
|
+
"""--runtime > AI_RUNTIME > install location > CLAUDECODE > session.json >
|
|
36
|
+
unknown (I10). session.json names whichever runtime was prompted last, so it
|
|
37
|
+
comes after everything the process itself can tell: read first, it let one
|
|
38
|
+
runtime's change pass as the other's while both were working. The install
|
|
39
|
+
location outranks CLAUDECODE because an environment variable is inherited:
|
|
40
|
+
codex started from a Claude Code shell carries CLAUDECODE=1 (seen live)."""
|
|
41
|
+
for candidate in (explicit, os.environ.get("AI_RUNTIME"), installed_runtime(),
|
|
42
|
+
"claude" if os.environ.get("CLAUDECODE") else None,
|
|
43
|
+
fs.read_session(root).get("runtime")):
|
|
44
|
+
if candidate in RUNTIMES:
|
|
45
|
+
return candidate
|
|
46
|
+
return "unknown"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def runtime_home(runtime):
|
|
50
|
+
if runtime == "codex":
|
|
51
|
+
return os.environ.get("CODEX_HOME") or os.path.join(os.path.expanduser("~"), ".codex")
|
|
52
|
+
return os.environ.get("CLAUDE_CONFIG_DIR") or os.path.join(os.path.expanduser("~"), ".claude")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def own_runtime(runtime="unknown"):
|
|
56
|
+
"""This process's runtime; an undetected one is read from where this file
|
|
57
|
+
is installed, so a plain shell under ~/.codex still reads the Codex plan."""
|
|
58
|
+
if runtime in RUNTIMES:
|
|
59
|
+
return runtime
|
|
60
|
+
return "codex" if "%s.codex%s" % (os.sep, os.sep) in fs.ENTRY else "claude"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def other_runtime(runtime=None, rt=Runtime()):
|
|
64
|
+
return "codex" if (runtime or own_runtime(runtime=rt.name)) == "claude" else "claude"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def installed(runtime):
|
|
68
|
+
return os.path.isfile(os.path.join(runtime_home(runtime), "skills", "ai-task", "state.py"))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# Set by the runtimes in every process they start, the `!` prefix's shell
|
|
72
|
+
# included. Duplicated on purpose from skills/project-update/adopt.py
|
|
73
|
+
# (AGENT_MARKERS): the two skills are installed independently and neither
|
|
74
|
+
# imports the other.
|
|
75
|
+
AGENT_MARKERS = ("CLAUDECODE", "CODEX_THREAD_ID", "CODEX_SESSION_ID", "CODEX_CI",
|
|
76
|
+
"CODEX_SANDBOX", "CODEX_SANDBOX_NETWORK_DISABLED", "CODEX_VERSION")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def agent_markers():
|
|
80
|
+
return [name for name in AGENT_MARKERS if name in os.environ]
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""start: the `weight` and `start` commands -- the six axes and a mode from weight.json, and the task started under them with the scope guard armed.
|
|
2
|
+
|
|
3
|
+
shell: cmd_weight, parse_declares, cmd_start, start_amend
|
|
4
|
+
core: WORKFLOWS, arm_scope
|
|
5
|
+
"""
|
|
6
|
+
# pylint: disable=protected-access # cmd_weight, cmd_start and start_amend call fs._split_files, which partition.json assigns to fs
|
|
7
|
+
# pylint: disable=unused-argument # cmd_weight takes the same arguments as every command, rt included (uniform cli dispatch)
|
|
8
|
+
import contextlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shlex
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from rr_state import fs, journal, task, dialog, labels, report, profile, handoff
|
|
16
|
+
from rr_state import policy as rr_policy
|
|
17
|
+
from rr_state import runtime as rr_runtime
|
|
18
|
+
|
|
19
|
+
# sensors.py and weight.py sit beside state.py; the entry puts that directory on sys.path.
|
|
20
|
+
try:
|
|
21
|
+
import sensors # measurement only; it never writes this file
|
|
22
|
+
except ImportError: # an old install that shipped state.py alone
|
|
23
|
+
sensors = None
|
|
24
|
+
try:
|
|
25
|
+
import weight # pure computation; it writes nothing
|
|
26
|
+
except ImportError:
|
|
27
|
+
weight = None
|
|
28
|
+
|
|
29
|
+
WORKFLOWS = ["feature", "bugfix", "refactoring", "hotfix", "investigation"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def cmd_weight(args, root, rt=rr_runtime.Runtime()):
|
|
33
|
+
"""Six axes and a mode from weight.json. Read-only, and it needs no task:
|
|
34
|
+
the weight is what decides how a task is run, so it comes before one."""
|
|
35
|
+
if weight is None:
|
|
36
|
+
fs.die("weight.py is missing beside state.py: reinstall the plugin")
|
|
37
|
+
try:
|
|
38
|
+
policy, path = weight.load_policy(root)
|
|
39
|
+
except (OSError, ValueError, RecursionError) as exc:
|
|
40
|
+
fs.die("cannot read the weight table: %s" % exc)
|
|
41
|
+
if args.mirror:
|
|
42
|
+
own = os.path.join(root, ".ai", "policies", "weight.json")
|
|
43
|
+
if path != own:
|
|
44
|
+
fs.die("%s has no .ai/policies/weight.json: the mirror is written beside the project's own table"
|
|
45
|
+
% root)
|
|
46
|
+
target = os.path.join(os.path.dirname(own), "weight.md")
|
|
47
|
+
tmp = "%s.%d.tmp" % (target, os.getpid())
|
|
48
|
+
try:
|
|
49
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
50
|
+
fh.write(weight.mirror(own))
|
|
51
|
+
os.replace(tmp, target)
|
|
52
|
+
except OSError as exc:
|
|
53
|
+
with contextlib.suppress(OSError):
|
|
54
|
+
os.unlink(tmp)
|
|
55
|
+
fs.die("cannot write %s: %s" % (target, exc))
|
|
56
|
+
print(target)
|
|
57
|
+
return
|
|
58
|
+
files = fs._split_files(args.files)
|
|
59
|
+
if not files:
|
|
60
|
+
fs.die("weight needs --files: the declared files are what it measures")
|
|
61
|
+
declared = {}
|
|
62
|
+
for item in args.declare:
|
|
63
|
+
m = re.match(r"^(\w+)=(\d{1,3}):(.*\S.*)$", item, re.S)
|
|
64
|
+
if not m:
|
|
65
|
+
fs.die("--declare %r: write axis=n:\"evidence\" — a declaration without its evidence is not one"
|
|
66
|
+
% item)
|
|
67
|
+
declared[m.group(1)] = (int(m.group(2)), m.group(3).strip())
|
|
68
|
+
try:
|
|
69
|
+
result = rr_policy.weigh(root, files, declared, args.goal, policy)
|
|
70
|
+
except ValueError as exc:
|
|
71
|
+
fs.die(str(exc))
|
|
72
|
+
except (TypeError, AttributeError) as exc: # a table whose values have the wrong shape
|
|
73
|
+
fs.die("%s is malformed: %s" % (path, exc))
|
|
74
|
+
if args.json:
|
|
75
|
+
out = dict(result.as_dict(), policy=path)
|
|
76
|
+
if result.documents_only is not None:
|
|
77
|
+
out["documents_only"] = result.documents_only
|
|
78
|
+
print(json.dumps(out, indent=2, ensure_ascii=False))
|
|
79
|
+
else:
|
|
80
|
+
print(weight.render(result, long=args.long))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def arm_scope(state):
|
|
84
|
+
"""The legacy view the scope guard (hooks/guard/scope.py) reads (D2): stage implementation and one
|
|
85
|
+
step holding every file of the task, for as long as the task is open."""
|
|
86
|
+
state["current_stage"] = "implementation"
|
|
87
|
+
state["approved_plan"] = {"ref": "inline", "current_step_id": "task", "steps": [{
|
|
88
|
+
"step_id": "task", "description": state.get("goal"), "allowed_files": task.task_files(state),
|
|
89
|
+
"forbidden_files": [], "forbidden_reason": "not part of this task",
|
|
90
|
+
"required_tests": list(state.get("tests") or []), "status": "in_progress"}]}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def parse_declares(items):
|
|
94
|
+
declared = {}
|
|
95
|
+
for item in items or []:
|
|
96
|
+
m = re.match(r"^(\w+)=(\d{1,3}):(.*\S.*)$", item, re.S)
|
|
97
|
+
if not m:
|
|
98
|
+
fs.die("--declare %r: write axis=n:\"evidence\" — a declaration without its evidence is not one"
|
|
99
|
+
% item)
|
|
100
|
+
declared[m.group(1)] = (int(m.group(2)), m.group(3).strip())
|
|
101
|
+
return declared
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def cmd_start(args, root, rt=rr_runtime.Runtime()):
|
|
105
|
+
"""The task, its weight and its mode in one call; the scope guard is armed."""
|
|
106
|
+
if args.amend:
|
|
107
|
+
start_amend(args, root, rt=rt)
|
|
108
|
+
return
|
|
109
|
+
missing = [flag for flag, value in (("--goal", args.goal), ("--workflow", args.workflow),
|
|
110
|
+
("--files", args.files)) if not value]
|
|
111
|
+
if missing:
|
|
112
|
+
fs.die("start needs %s (or --amend to widen the task in flight)" % ", ".join(missing))
|
|
113
|
+
if args.workflow not in WORKFLOWS:
|
|
114
|
+
fs.die("workflow must be one of: %s" % ", ".join(WORKFLOWS))
|
|
115
|
+
existing = task.load(root, required=False, claim=False, rt=rt)
|
|
116
|
+
if existing and existing.get("current_stage") != "done":
|
|
117
|
+
fs.die("task %s is in flight: finish it with 'state.py close' (or 'close --abandon'), or widen it "
|
|
118
|
+
"with 'state.py start --amend --files \"+a\" --why \"…\"'" % existing.get("task_id"))
|
|
119
|
+
files, tests = fs._split_files(args.files), fs._split_files(args.tests)
|
|
120
|
+
if not files:
|
|
121
|
+
# An empty list would arm a scope guard that allows everything.
|
|
122
|
+
fs.die("--files must name at least one file or glob the task may touch")
|
|
123
|
+
if fs.current_tree(root) is None:
|
|
124
|
+
fs.die("start needs a git repository: the two facts close decides by are measured on its tree")
|
|
125
|
+
steps = task.parse_steps(args.steps, args.plan_file)
|
|
126
|
+
policy = rr_policy.weight_policy(root)
|
|
127
|
+
table = rr_policy.batch_policy(policy, strict=True) # a malformed section is refused here, not at status
|
|
128
|
+
if args.batch and args.no_batch:
|
|
129
|
+
fs.die("--batch and --no-batch exclude each other")
|
|
130
|
+
if args.batch and not args.by:
|
|
131
|
+
fs.die("--batch needs --by \"<who asked for the batch loop>\": it is journaled with its author")
|
|
132
|
+
batch = {"on": False, "by": None, "why": None, "since": None}
|
|
133
|
+
if args.batch:
|
|
134
|
+
batch = {"on": True, "by": args.by, "why": args.why or None, "since": fs.now()}
|
|
135
|
+
elif not args.no_batch and table["default"]:
|
|
136
|
+
batch = {"on": True, "by": "weight.json batch.default", "why": None, "since": fs.now()}
|
|
137
|
+
# Shared files order steps only in a batch task, where steps run side by
|
|
138
|
+
# side; a standard task's steps run one after another, as before.
|
|
139
|
+
implied = task.order_shared_files(steps) if batch["on"] else []
|
|
140
|
+
task.check_steps(steps, implied)
|
|
141
|
+
concrete = [f for f in files if not any(c in f for c in "*?[")]
|
|
142
|
+
try:
|
|
143
|
+
result = rr_policy.weigh(root, concrete, parse_declares(args.declare), args.goal, policy,
|
|
144
|
+
[f for f in files if f not in concrete])
|
|
145
|
+
except ValueError as exc:
|
|
146
|
+
fs.die(str(exc))
|
|
147
|
+
except (TypeError, AttributeError) as exc:
|
|
148
|
+
fs.die("the weight table is malformed: %s" % exc)
|
|
149
|
+
mode, override = result.mode, None
|
|
150
|
+
if args.mode:
|
|
151
|
+
if args.mode not in rr_policy.MODES:
|
|
152
|
+
fs.die("--mode takes one of: %s" % ", ".join(rr_policy.MODES))
|
|
153
|
+
if not args.by:
|
|
154
|
+
fs.die("--mode needs --by \"<who>\": an override is journaled with its author")
|
|
155
|
+
if args.mode == mode:
|
|
156
|
+
args.mode = None # the weight already says so: nothing to override
|
|
157
|
+
via = None
|
|
158
|
+
if args.mode:
|
|
159
|
+
if rr_policy.MODES.index(args.mode) < rr_policy.MODES.index(mode):
|
|
160
|
+
via, why = dialog.human_route(root, allow_dialog=True, rt=rt)
|
|
161
|
+
if via is None:
|
|
162
|
+
dialog.offer_decision(root, None, "human_set", "Mode",
|
|
163
|
+
"%s — W %s, the weight says %s.\nLower the mode from %s to %s (by %s)?"
|
|
164
|
+
% (fs.one_line(args.goal, 300), result.w, mode, mode, args.mode, args.by),
|
|
165
|
+
[{"kind": "choice", "effect": "keep", "label": "Keep mode %s" % mode},
|
|
166
|
+
{"kind": "command", "label": labels.state_label(root, sys.argv[1:])}], "start --mode", rt=rt)
|
|
167
|
+
fs.die("APPROVAL_REFUSED — lowering the mode from %s to %s is a human's decision. Run it in "
|
|
168
|
+
"your own terminal:\n python3 %s --root %s start … --mode %s --by %s\nRefused because %s."
|
|
169
|
+
% (mode, args.mode, shlex.quote(fs.ENTRY), shlex.quote(root),
|
|
170
|
+
args.mode, shlex.quote(args.by), why), 5)
|
|
171
|
+
override = {"from": mode, "to": args.mode, "by": args.by, "why": args.why, "ts": fs.now()}
|
|
172
|
+
mode = args.mode
|
|
173
|
+
if args.slug is not None:
|
|
174
|
+
import memory_format # pylint: disable=import-outside-toplevel
|
|
175
|
+
if args.task_id:
|
|
176
|
+
fs.die("--slug and --task-id both name the id: pass one", 2)
|
|
177
|
+
if not memory_format.SLUG_RE.match(args.slug):
|
|
178
|
+
fs.die("--slug takes 3-24 characters of [a-z0-9] words joined by '-', not %r" % args.slug, 2)
|
|
179
|
+
task_id = task.valid_task_id(args.task_id) if args.task_id else task.next_task_id(root, args.goal, args.slug)
|
|
180
|
+
base_tree = fs.current_tree(root)
|
|
181
|
+
state = {
|
|
182
|
+
"schema": task.SCHEMA, "task_id": task_id, "goal": args.goal, "workflow": args.workflow,
|
|
183
|
+
"mode": mode, "weight": dict(result.as_dict(), lines=weight.render(result).split("\n"),
|
|
184
|
+
tree=base_tree, computed_at=fs.now(), rescores=[], operations=[]),
|
|
185
|
+
"mode_override": override, "stage": "build", "files": files, "tests": tests,
|
|
186
|
+
"steps": steps, "amendments": [], "findings": [], "batch": batch,
|
|
187
|
+
"diff": {"base_tree": base_tree, "base_commit": fs.safe_sensor(sensors.head_commit, root)},
|
|
188
|
+
"gate": None, "owner_runtime": rt.name if rt.name in rr_runtime.RUNTIMES else None,
|
|
189
|
+
"created": fs.now(), "created_at": fs.now(), "history": [],
|
|
190
|
+
}
|
|
191
|
+
if result.documents_only is not None:
|
|
192
|
+
state["weight"]["documents_only"] = result.documents_only
|
|
193
|
+
if os.path.exists(fs.ledger_path(root)):
|
|
194
|
+
# The ledger is per project: a task mirrors only the lines after its start.
|
|
195
|
+
state["dialog"] = {"ledger_pos": os.path.getsize(fs.ledger_path(root))}
|
|
196
|
+
task.apply_defaults_v7(state)
|
|
197
|
+
if steps:
|
|
198
|
+
# The first step whose dependencies are done — steps[0] in a plan without `after`.
|
|
199
|
+
next(iter(task.ready_steps(state)), steps[0]).update(status="in_progress", tree_before=base_tree)
|
|
200
|
+
arm_scope(state)
|
|
201
|
+
state["next_action"] = rr_policy.next_action_for(state)
|
|
202
|
+
journal.emit(root, state, "task_started", fs.one_line(args.goal, 200),
|
|
203
|
+
{"goal": args.goal, "workflow": args.workflow, "mode": mode, "schema": task.SCHEMA}, rt=rt)
|
|
204
|
+
journal.emit(root, state, "weight_set", "W %d → %s" % (result.w, result.mode),
|
|
205
|
+
{"w": result.w, "mode": result.mode, "axes": result.axes, "hints": result.hints,
|
|
206
|
+
"unknown": result.unknown}, rt=rt)
|
|
207
|
+
if override:
|
|
208
|
+
journal.emit(root, state, "mode_set", "%s -> %s by %s" % (override["from"], mode, args.by),
|
|
209
|
+
dict({"from": override["from"], "to": mode, "by": args.by, "why": args.why},
|
|
210
|
+
**(dict(via=via, **dialog.pick_data(dialog.DIALOG_PICK[0])) if via else {})),
|
|
211
|
+
actor="human" if rr_policy.MODES.index(mode) < rr_policy.MODES.index(override["from"]) else "agent", rt=rt)
|
|
212
|
+
journal.emit(root, state, "tier_set", "%s (mode %s, compat)" % (rr_policy.MODE_TIER[mode], mode),
|
|
213
|
+
{"tier": rr_policy.MODE_TIER[mode], "mode": mode, "w": result.w}, rt=rt)
|
|
214
|
+
if batch["on"]:
|
|
215
|
+
journal.emit(root, state, "batch_set", "on by %s" % batch["by"],
|
|
216
|
+
{"on": True, "by": batch["by"], "why": batch["why"], "via": None, "review_passes": 0, "suite_runs": 0}, rt=rt)
|
|
217
|
+
handoff.set_resume_point(state, rt=rt)
|
|
218
|
+
handoff.write_handoff(root, state, "start", rt=rt)
|
|
219
|
+
task.save(root, state)
|
|
220
|
+
report.write_task_md(root, state)
|
|
221
|
+
print(weight.render(result))
|
|
222
|
+
if override:
|
|
223
|
+
print("mode: %s (override of %s by %s)" % (mode, override["from"], args.by))
|
|
224
|
+
for later, earlier, shared in implied:
|
|
225
|
+
print("ordered: %s after %s (shares %s)" % (later, earlier, shared))
|
|
226
|
+
if batch["on"]:
|
|
227
|
+
print("batch: on by %s · review %s passes · suite %s runs · %s at once on %s"
|
|
228
|
+
% (batch["by"], table["review_rounds_max"], table["max_suite_runs"], table["parallel"], table["tier"]))
|
|
229
|
+
advice = int((policy.get("rules") or {}).get("steps_advice_over", 8))
|
|
230
|
+
if len(steps) > advice:
|
|
231
|
+
print("advice: %d steps, over %d — fewer, larger steps cost fewer handoffs (not a gate)"
|
|
232
|
+
% (len(steps), advice))
|
|
233
|
+
print("%s started: %s · %d files in scope · %s"
|
|
234
|
+
% (task_id, mode, len(task.task_files(state)), os.path.relpath(fs.task_md_path(root, state), root)))
|
|
235
|
+
print("next: %s" % state["next_action"])
|
|
236
|
+
profile.advise(state, rt=rt)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def start_amend(args, root, rt=rr_runtime.Runtime()):
|
|
240
|
+
"""R25: the task's files grow in place, journaled, never back to a plan."""
|
|
241
|
+
state = task.load_v7(root, rt=rt)
|
|
242
|
+
added = [f.lstrip("+").strip() for f in fs._split_files(args.files)]
|
|
243
|
+
added = [f for f in added if f]
|
|
244
|
+
if not added or not (args.why or "").strip():
|
|
245
|
+
fs.die("--amend needs --files \"+a,+b\" and --why \"<why the task needs them>\"")
|
|
246
|
+
new = [f for f in added if f not in state["files"]]
|
|
247
|
+
if not new:
|
|
248
|
+
print("scope unchanged: %s already in the task" % ", ".join(added))
|
|
249
|
+
return
|
|
250
|
+
state["files"] += new
|
|
251
|
+
state["amendments"].append({"ts": fs.now(), "added": new, "why": args.why})
|
|
252
|
+
arm_scope(state)
|
|
253
|
+
journal.emit(root, state, "scope_change", "+%s — %s" % (", ".join(new), fs.one_line(args.why, 200)),
|
|
254
|
+
{"added": new, "why": args.why}, rt=rt)
|
|
255
|
+
task.save(root, state)
|
|
256
|
+
report.write_task_md(root, state)
|
|
257
|
+
print("scope amended: +%s (%d files in scope)" % (", ".join(new), len(task.task_files(state))))
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""steps: the `steps`, `step-done` and `batch` commands -- a multi-step task's steps and the batch loop's switch.
|
|
2
|
+
|
|
3
|
+
shell: cmd_step_done_v7, cmd_steps, cmd_batch
|
|
4
|
+
"""
|
|
5
|
+
# pylint: disable=unused-argument
|
|
6
|
+
# (uniform cli dispatch: every cmd_* takes rt last, whether it uses it or not)
|
|
7
|
+
import json
|
|
8
|
+
import shlex
|
|
9
|
+
|
|
10
|
+
from rr_state import dialog, fs, handoff, journal, report, task
|
|
11
|
+
from rr_state import policy as rr_policy # cmd_batch binds a local `policy`
|
|
12
|
+
from rr_state import runtime as rr_runtime
|
|
13
|
+
|
|
14
|
+
# sensors.py sits beside state.py; the entry puts that directory on sys.path.
|
|
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
|
+
|
|
21
|
+
def cmd_step_done_v7(args, root, rt=rr_runtime.Runtime()):
|
|
22
|
+
"""A multi-step task's step is done when its diff stays inside the task."""
|
|
23
|
+
state = task.load_v7(root, rt=rt)
|
|
24
|
+
steps = state["steps"]
|
|
25
|
+
if not steps:
|
|
26
|
+
fs.die("step-done is for a multi-step guarded or full task; this one is one scope — verify, then close")
|
|
27
|
+
step = next((s for s in steps if s["id"] == args.step_id), None)
|
|
28
|
+
if step is None:
|
|
29
|
+
fs.die("no step %s (have: %s)" % (args.step_id, ", ".join(s["id"] for s in steps)))
|
|
30
|
+
if step["status"] == "done":
|
|
31
|
+
print("step %s already done" % step["id"])
|
|
32
|
+
return
|
|
33
|
+
tree = fs.current_tree(root)
|
|
34
|
+
diff = fs.safe_sensor(sensors.diff_files, root, step.get("tree_before") or state["diff"].get("base_tree"), tree)
|
|
35
|
+
outside = [d["path"] for d in diff or [] if not sensors.scope_any(d["path"], task.task_files(state))]
|
|
36
|
+
if outside:
|
|
37
|
+
fs.die("SCOPE_CHANGE_REQUIRED — step %s changed %s, outside the task's files. Widen the task in "
|
|
38
|
+
"place: state.py start --amend --files \"%s\" --why \"…\""
|
|
39
|
+
% (step["id"], ", ".join(outside), ",".join("+" + p for p in outside)), 6)
|
|
40
|
+
# In a wave the diff since the step began holds other steps' work too. The
|
|
41
|
+
# journal says which changed paths were this step's own (its files and
|
|
42
|
+
# tests) and which were not, so a step that strayed into another's files is
|
|
43
|
+
# visible; `unready` names dependencies it was recorded ahead of.
|
|
44
|
+
own_scope = list(step.get("files") or []) + list(step.get("tests") or [])
|
|
45
|
+
own = [d["path"] for d in diff or [] if sensors.scope_any(d["path"], own_scope)]
|
|
46
|
+
others = [d["path"] for d in diff or [] if d["path"] not in own]
|
|
47
|
+
unready = [dep for dep in step.get("after") or [] if any(s["id"] == dep and s["status"] != "done" for s in steps)]
|
|
48
|
+
step.update(status="done", tree_after=tree)
|
|
49
|
+
journal.emit(root, state, "step_done", "%s: %d files changed" % (step["id"], len(diff or [])),
|
|
50
|
+
{"step_id": step["id"], "files": len(diff or []), "own": own, "others": others, "unready": unready},
|
|
51
|
+
legacy="step_completed", rt=rt)
|
|
52
|
+
# The first pending step whose dependencies are done — the first pending one
|
|
53
|
+
# when no step has `after`, as before the batch loop.
|
|
54
|
+
following = next((s for s in task.ready_steps(state) if s["status"] == "pending"), None)
|
|
55
|
+
if following:
|
|
56
|
+
following.update(status="in_progress", tree_before=tree)
|
|
57
|
+
journal.emit(root, state, "step_started", following["id"], {"step_id": following["id"], "kind": "step"}, rt=rt)
|
|
58
|
+
task.save(root, state)
|
|
59
|
+
report.write_task_md(root, state)
|
|
60
|
+
open_steps = [s["id"] for s in steps if s["status"] != "done"]
|
|
61
|
+
if unready:
|
|
62
|
+
print("note: %s was done before %s, which it is after" % (step["id"], ", ".join(unready)))
|
|
63
|
+
if others and rr_policy.batch_on(state):
|
|
64
|
+
print("note: the diff also holds %d file(s) outside this step's own — %s"
|
|
65
|
+
% (len(others), ", ".join(others[:4]) + (" …" if len(others) > 4 else "")))
|
|
66
|
+
print("step %s done%s" % (step["id"], "; next: %s" % following["id"] if following
|
|
67
|
+
else "; %d still open: %s" % (len(open_steps), ", ".join(open_steps)) if open_steps
|
|
68
|
+
else "; all steps done"))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def cmd_steps(args, root, rt=rr_runtime.Runtime()):
|
|
72
|
+
"""Read-only: the steps, their status and `after`; --ready prints the next
|
|
73
|
+
wave (R3), nothing when there is none."""
|
|
74
|
+
state = task.load_v7(root, rt=rt)
|
|
75
|
+
steps = state.get("steps") or []
|
|
76
|
+
ready = [s["id"] for s in task.ready_steps(state)]
|
|
77
|
+
if args.json:
|
|
78
|
+
print(json.dumps({"task_id": state["task_id"], "batch": rr_policy.batch_on(state), "ready": ready, "steps": steps,
|
|
79
|
+
"batch_policy": rr_policy.batch_policy(rr_policy.weight_policy(root))}, indent=2, ensure_ascii=False))
|
|
80
|
+
return
|
|
81
|
+
if args.ready:
|
|
82
|
+
for step_id in ready:
|
|
83
|
+
print(step_id)
|
|
84
|
+
return
|
|
85
|
+
if not steps:
|
|
86
|
+
print("steps: one scope — no plan-file steps")
|
|
87
|
+
return
|
|
88
|
+
for s in steps:
|
|
89
|
+
print("%s · %s · after %s · %s" % (s["id"], s["status"], ", ".join(s.get("after") or []) or "-",
|
|
90
|
+
"ready" if s["id"] in ready else "waiting" if s["status"] != "done" else "-"))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def cmd_batch(args, root, rt=rr_runtime.Runtime()):
|
|
94
|
+
"""The batch loop's switch during a task (R9). `off` raises the rigor — the
|
|
95
|
+
standard budgets from here, counting what was spent — and is anyone's with
|
|
96
|
+
--by and --why. `on` lowers it, so it takes the human route or exits 5."""
|
|
97
|
+
state = task.load_v7(root, rt=rt)
|
|
98
|
+
want = args.action == "on"
|
|
99
|
+
if not args.by:
|
|
100
|
+
fs.die("batch %s needs --by \"<who>\"" % args.action)
|
|
101
|
+
if want == rr_policy.batch_on(state):
|
|
102
|
+
print("batch is already %s" % args.action)
|
|
103
|
+
return
|
|
104
|
+
via = None
|
|
105
|
+
if want:
|
|
106
|
+
via, why = dialog.human_route(root, rt=rt)
|
|
107
|
+
if via is None:
|
|
108
|
+
fs.die("APPROVAL_REFUSED — turning the batch loop on during a task lowers the rigor, so it is the "
|
|
109
|
+
"human's. In their own terminal:\n python3 %s --root %s batch on --by \"<name>\"\nRefused "
|
|
110
|
+
"because %s." % (shlex.quote(fs.ENTRY), shlex.quote(root), why), 5)
|
|
111
|
+
elif not (args.why or "").strip():
|
|
112
|
+
fs.die("batch off needs --why \"<why the standard loop from here>\"")
|
|
113
|
+
policy = rr_policy.weight_policy(root)
|
|
114
|
+
if want:
|
|
115
|
+
rr_policy.batch_policy(policy, strict=True) # a malformed section is refused before the loop starts
|
|
116
|
+
state["batch"] = {"on": want, "by": args.by, "why": args.why or None, "since": fs.now()}
|
|
117
|
+
if state.get("stage") in ("build", "verify", "review"):
|
|
118
|
+
# At the gate (stage ship) the next action is the human's and stays so.
|
|
119
|
+
state["next_action"] = rr_policy.review_next_action(state)
|
|
120
|
+
journal.emit(root, state, "batch_set", "%s by %s%s" % (args.action, args.by, " via %s" % via if via else ""),
|
|
121
|
+
{"on": want, "by": args.by, "why": args.why or None, "via": via,
|
|
122
|
+
"review_passes": state["review"]["passes"], "suite_runs": state["verify"]["suite_runs"]},
|
|
123
|
+
actor="human" if via == "terminal" else "agent", rt=rt)
|
|
124
|
+
handoff.set_resume_point(state, rt=rt)
|
|
125
|
+
handoff.write_handoff(root, state, "batch", rt=rt)
|
|
126
|
+
task.save(root, state)
|
|
127
|
+
report.write_task_md(root, state)
|
|
128
|
+
print("batch %s by %s · review %d/%d · suite %d/%d" % (
|
|
129
|
+
args.action, args.by, state["review"]["passes"], rr_policy.budget(policy, state, "review_rounds_max")[0],
|
|
130
|
+
state["verify"]["suite_runs"], rr_policy.budget(policy, state, "max_suite_runs")[0]))
|
|
131
|
+
print("next: %s" % state["next_action"])
|