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.
Files changed (99) 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 +29 -15
  5. package/ai/policies/model-routing.md +7 -7
  6. package/ai/policies/production.md +3 -3
  7. package/ai/policies/release.md +2 -2
  8. package/ai/policies/review-economy.md +27 -30
  9. package/bin/riskrails.js +114 -23
  10. package/hooks/ai-path-guard-defaults.json +3 -2
  11. package/hooks/context-guard.py +15 -12
  12. package/hooks/dialog-gate.py +1 -1
  13. package/hooks/guard/common.py +2 -0
  14. package/hooks/guard/dialog.py +107 -27
  15. package/hooks/guard/git.py +12 -3
  16. package/hooks/guard/paths.py +150 -37
  17. package/hooks/project-scaffold.py +3 -6
  18. package/hooks/runtime-gate.py +134 -33
  19. package/hooks/update_notice.py +22 -16
  20. package/instructions/routing.md +1 -1
  21. package/instructions/stub.md +3 -3
  22. package/package.json +1 -1
  23. package/skills/AGENTS.md +1 -1
  24. package/skills/CLAUDE.md +1 -1
  25. package/skills/ai-init/templates/.ai/AGENTS.md +1 -1
  26. package/skills/ai-init/templates/.ai/VERSION +1 -1
  27. package/skills/ai-init/templates/.ai/policies/security.md +7 -1
  28. package/skills/ai-init/templates/.ai/policies/testing.md +19 -6
  29. package/skills/ai-init/templates/.ai/state/README.md +4 -4
  30. package/skills/ai-init/templates/AGENTS.block.md +2 -2
  31. package/skills/ai-init/templates/CLAUDE.block.md +2 -2
  32. package/skills/ai-init/templates/GEMINI.block.md +2 -2
  33. package/skills/ai-init/templates/gitignore.snippet +1 -0
  34. package/skills/ai-init/templates/junie-guidelines.block.md +2 -2
  35. package/skills/ai-task/rr_state/__init__.py +1 -0
  36. package/skills/ai-task/rr_state/cli.py +210 -0
  37. package/skills/ai-task/rr_state/close.py +171 -0
  38. package/skills/ai-task/rr_state/dialog.py +541 -0
  39. package/skills/ai-task/rr_state/dialog_cmd.py +164 -0
  40. package/skills/ai-task/rr_state/fs.py +220 -0
  41. package/skills/ai-task/rr_state/gate.py +125 -0
  42. package/skills/ai-task/rr_state/gate_cmd.py +128 -0
  43. package/skills/ai-task/rr_state/handoff.py +226 -0
  44. package/skills/ai-task/rr_state/journal.py +126 -0
  45. package/skills/ai-task/rr_state/labels.py +99 -0
  46. package/skills/ai-task/rr_state/ledger.py +145 -0
  47. package/skills/ai-task/rr_state/locks.py +84 -0
  48. package/skills/ai-task/rr_state/memory_cmd.py +310 -0
  49. package/skills/ai-task/rr_state/policy.py +378 -0
  50. package/skills/ai-task/rr_state/profile.py +373 -0
  51. package/skills/ai-task/rr_state/questions.py +336 -0
  52. package/skills/ai-task/rr_state/questions_cmd.py +321 -0
  53. package/skills/ai-task/rr_state/report.py +271 -0
  54. package/skills/ai-task/rr_state/report_cmd.py +230 -0
  55. package/skills/ai-task/rr_state/review.py +165 -0
  56. package/skills/ai-task/rr_state/runtime.py +80 -0
  57. package/skills/ai-task/rr_state/start.py +257 -0
  58. package/skills/ai-task/rr_state/steps.py +131 -0
  59. package/skills/ai-task/rr_state/task.py +396 -0
  60. package/skills/ai-task/rr_state/verify.py +231 -0
  61. package/skills/ai-task/sensors.py +37 -4
  62. package/skills/ai-task/state.py +24 -5138
  63. package/skills/project-init/templates/constitution.md +1 -1
  64. package/skills/project-init/templates/sdlc-README.md +20 -14
  65. package/skills/project-update/adopt.py +38 -24
  66. package/skills/project-update/history/blobs/00289f98434fed230eebe768dfb3425f83d737866a3aebc97aa95167c4fa0c6e +131 -0
  67. package/skills/project-update/history/blobs/079a91f976ad69b5cf49ccb9a28849576e4735ddd9485fc26599e528a69fd2f4 +30 -0
  68. package/skills/project-update/history/blobs/07ff7fa6f93d471ff533f553b4e523e4105ccde1f3dab70132c5237728f68422 +13 -0
  69. package/skills/project-update/history/blobs/0ef4a360c76da290173dea0f8073949cb0f8113710a38df95acae5d96bdd1286 +123 -0
  70. package/skills/project-update/history/blobs/18c7a2753ce9cb6f6d3d1e9d5d1125d75950b879d56b7325225b200869631b29 +15 -0
  71. package/skills/project-update/history/blobs/25d4f2a86deb5e2574bb3210b67bb24fcc4afb19f93a7b65a057daa874a9d18e +1 -0
  72. package/skills/project-update/history/blobs/48d9950f16e31d55475f566a80dc95f64b35f1c804d2f51729e313a4f29bac8d +13 -0
  73. package/skills/project-update/history/blobs/5a93f78b4ed4d416c171139b2ce610a117f9c1dd46fbb56ca86729cb4c53d10c +6 -0
  74. package/skills/project-update/history/blobs/6a3be0e624e5ad91e44d08857b7183ee53b7903e1e8d56ff196dbc9dde271e4a +75 -0
  75. package/skills/project-update/history/blobs/7170553410bba904ffb7edb4b231e3b3579b85542d84de717299980938f82c9d +13 -0
  76. package/skills/project-update/history/blobs/774b1e3a04ef0d49d33c44a2cfecd92eb6f6b738a12b6a681103ba7fc13e7b86 +67 -0
  77. package/skills/project-update/history/blobs/7f063e79195a5c85c37b7260dd20c184d3b9bea85e37202e0bbca73ad84bba49 +13 -0
  78. package/skills/project-update/history/blobs/a51517e13e57420d2d88a6bcfb4055d762d4a0d10a8f88450929c5c4572213a9 +67 -0
  79. package/skills/project-update/history/blobs/c8573ca1f810ce6c4ac49456a4c1725b93e0d3b74c54ce95d46d8747777895f0 +13 -0
  80. package/skills/project-update/history/blobs/d6e5b34d475e7187d2a8cb2aac7e7f1a427142baf6461bda387d70ceb4d8a8c1 +30 -0
  81. package/skills/project-update/history/blobs/e120ff4e32b2e950fc94ada4e787d57dc9c790da37223e5a202d74f7e3aadf80 +13 -0
  82. package/skills/project-update/history/index.json +38 -16
  83. package/skills/project-update/migrations/0003_context_diet.py +1 -1
  84. package/skills/project-update/migrations/0007_weight_model.py +130 -23
  85. package/skills/project-update/migrations/0011_retire_risk_tiers.py +37 -0
  86. package/skills/project-update/migrations/README.md +16 -1
  87. package/skills/project-update/migrations/__init__.py +6 -1
  88. package/skills/project-update/render_instructions.py +1 -1
  89. package/skills/project-update/update.py +569 -106
  90. package/skills/rr-build-all/SKILL.md +2 -2
  91. package/skills/rr-plan/SKILL.md +3 -1
  92. package/skills/rr-review/SKILL.md +13 -8
  93. package/skills/rr-setup/SKILL.md +24 -4
  94. package/skills/rr-setup/setup.py +7 -1
  95. package/skills/rr-spec/SKILL.md +4 -2
  96. package/skills/rr-status/status.py +36 -9
  97. package/skills/usage-report/usage-report.py +4 -2
  98. package/skills/ai-init/templates/.ai/policies/risk-tiers.json +0 -628
  99. package/skills/ai-init/templates/.ai/policies/risk-tiers.md +0 -180
@@ -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"])