riskrails 4.1.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) 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 +25 -15
  5. package/agents/ai-reviewer.md.tmpl +14 -1
  6. package/ai/agents/implementer.md +6 -0
  7. package/ai/agents/reviewer.md +8 -1
  8. package/bin/ansi.js +12 -0
  9. package/bin/menu.js +71 -12
  10. package/bin/riskrails.js +240 -14
  11. package/bin/uninstall.js +115 -0
  12. package/bin/wizard.js +72 -0
  13. package/hooks/AGENTS.md +0 -6
  14. package/hooks/CLAUDE.md +0 -6
  15. package/hooks/context-guard.py +11 -1
  16. package/hooks/dialog-gate.py +3 -2
  17. package/hooks/guard/decision.py +4 -3
  18. package/hooks/guard/dialog.py +12 -2
  19. package/hooks/guard/ere.py +29 -10
  20. package/hooks/guard/git.py +2 -1
  21. package/hooks/guard/paths.py +48 -1406
  22. package/hooks/guard/paths_base.py +66 -0
  23. package/hooks/guard/paths_bash.py +176 -0
  24. package/hooks/guard/paths_classify.py +205 -0
  25. package/hooks/guard/paths_fs.py +137 -0
  26. package/hooks/guard/paths_human.py +105 -0
  27. package/hooks/guard/paths_interp.py +162 -0
  28. package/hooks/guard/paths_movers.py +393 -0
  29. package/hooks/guard/paths_shell.py +288 -0
  30. package/hooks/runtime-gate.py +87 -0
  31. package/hooks/update_notice.py +23 -8
  32. package/package.json +1 -1
  33. package/skills/AGENTS.md +0 -6
  34. package/skills/CLAUDE.md +0 -6
  35. package/skills/ai-init/templates/.ai/policies/coding.md +9 -0
  36. package/skills/ai-init/templates/gitignore.snippet +2 -0
  37. package/skills/ai-task/memory_cleanup.py +213 -0
  38. package/skills/ai-task/memory_compact.py +25 -0
  39. package/skills/ai-task/memory_config.py +120 -0
  40. package/skills/ai-task/memory_export.py +246 -0
  41. package/skills/ai-task/memory_format.py +7 -3
  42. package/skills/ai-task/memory_hook.py +172 -0
  43. package/skills/ai-task/memory_io.py +122 -0
  44. package/skills/ai-task/memory_migrate.py +187 -0
  45. package/skills/ai-task/memory_packages.py +83 -0
  46. package/skills/ai-task/memory_risks.py +84 -0
  47. package/skills/ai-task/memory_status.py +76 -0
  48. package/skills/ai-task/memory_sync.py +20 -1481
  49. package/skills/ai-task/memory_verify.py +316 -0
  50. package/skills/ai-task/rr_state/cli.py +1 -0
  51. package/skills/ai-task/rr_state/close.py +2 -2
  52. package/skills/ai-task/rr_state/dialog.py +32 -7
  53. package/skills/ai-task/rr_state/dialog_cmd.py +38 -5
  54. package/skills/ai-task/rr_state/gate.py +10 -4
  55. package/skills/ai-task/rr_state/labels.py +33 -4
  56. package/skills/ai-task/rr_state/memory_cmd.py +84 -64
  57. package/skills/ai-task/rr_state/questions_cmd.py +5 -4
  58. package/skills/ai-task/rr_state/report.py +73 -10
  59. package/skills/ai-task/rr_state/report_cmd.py +2 -0
  60. package/skills/ai-task/rr_state/review.py +18 -14
  61. package/skills/ai-task/rr_state/start.py +12 -9
  62. package/skills/ai-task/rr_state/steps.py +16 -7
  63. package/skills/ai-task/rr_state/verify.py +17 -12
  64. package/skills/project-update/history/blobs/dac90781793b8a1c8e5f3e0307665b6394d922140c3831c5508d019f79e1ed0a +41 -0
  65. package/skills/project-update/history/blobs/fa0cb370b024e1a30e1355912032657b08f609e47773a603c193b1e609b0f997 +8 -0
  66. package/skills/project-update/history/index.json +8 -4
  67. package/skills/project-update/rr_update/__init__.py +1 -0
  68. package/skills/project-update/rr_update/apply.py +188 -0
  69. package/skills/project-update/rr_update/blocks.py +38 -0
  70. package/skills/project-update/rr_update/build.py +96 -0
  71. package/skills/project-update/rr_update/cleanup.py +194 -0
  72. package/skills/project-update/rr_update/cli.py +102 -0
  73. package/skills/project-update/rr_update/files.py +106 -0
  74. package/skills/project-update/rr_update/gate.py +60 -0
  75. package/skills/project-update/rr_update/hints.py +128 -0
  76. package/skills/project-update/rr_update/history.py +117 -0
  77. package/skills/project-update/rr_update/kept.py +120 -0
  78. package/skills/project-update/rr_update/paths.py +108 -0
  79. package/skills/project-update/rr_update/plan.py +123 -0
  80. package/skills/project-update/rr_update/report.py +51 -0
  81. package/skills/project-update/rr_update/rules.py +178 -0
  82. package/skills/project-update/rr_update/shared.py +74 -0
  83. package/skills/project-update/rr_update/siblings.py +37 -0
  84. package/skills/project-update/rr_update/weights.py +171 -0
  85. package/skills/project-update/update.py +8 -1682
  86. package/skills/rr-build/SKILL.md +5 -2
  87. package/skills/rr-setup/SKILL.md +4 -2
  88. package/skills/rr-setup/setup.py +13 -12
  89. package/skills/rr-ship/SKILL.md +2 -2
  90. package/skills/rr-spec/SKILL.md +6 -0
  91. package/skills/rr-status/SKILL.md +2 -0
  92. package/skills/rr-status/status.py +5 -3
@@ -0,0 +1,316 @@
1
+ """Verification (§D6, §D10): the findings of the package and journal checks, verify and its fresh-clone form,
2
+ the git tree reader and the range check the pre-push validator runs."""
3
+ import collections
4
+ import json
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import tempfile
9
+ import time
10
+
11
+ import memory_compact as mc
12
+ import memory_config as mcfg
13
+ import memory_format as mf
14
+ import memory_io as mio
15
+ import memory_packages as mpkg
16
+
17
+
18
+ # ------------------------------------------------------------------ verify (§D6)
19
+ Finding = collections.namedtuple("Finding", "check path line what fix")
20
+
21
+
22
+ def kept_lines(events, schema=None):
23
+ """The journal lines V5 requires to be the src of an entry."""
24
+ out = []
25
+ for number, event in events:
26
+ if not event:
27
+ continue
28
+ if event.get("event") == "task_started":
29
+ schema = (event.get("data") or {}).get("schema", schema)
30
+ rule = mf.classify(event.get("event"), schema)
31
+ if rule not in ("seal", "summary", "drop", "verify"):
32
+ out.append(number)
33
+ return out
34
+
35
+
36
+ def check_packages(packages, max_bytes, sizes=None, changed=None, ref_name=None):
37
+ """V1, V3, V4, V8 over one tree's packages."""
38
+ findings = []
39
+ sizes = sizes or {}
40
+ for name, (_, kept, errors) in sorted(packages.items()):
41
+ if changed is not None and name not in changed:
42
+ continue
43
+ over = {e["_line"]: e for e in kept if "_over" in e}
44
+ for line, what in errors:
45
+ fix = ("restore the line from git ('git checkout -- %s') or 'state.py memory verify'"
46
+ % os.path.join(mio.MEMORY_REL, name))
47
+ if line in over and what.startswith("line is "):
48
+ entry = over[line]
49
+ sizes_of = {k: len(mf.canonical(v)) for k, v in entry.items()
50
+ if k not in mf.ENVELOPE and not k.startswith("_")}
51
+ field = max(sizes_of, key=sizes_of.get, default="changes")
52
+ fix = ("shorten it: 'state.py memory redact %s --field %s --why \"…\"' (the human's route)"
53
+ % (entry["rid"], field))
54
+ findings.append(Finding("V1", os.path.join(mio.MEMORY_REL, name), line, what, fix))
55
+ by_key = collections.defaultdict(list)
56
+ conflicts = set()
57
+ redacted = {}
58
+ for name, (_, entries, _) in packages.items():
59
+ for e in entries:
60
+ by_key[mf.key_of(e)].append((name, e))
61
+ if e.get("kind") == "conflict":
62
+ conflicts.add((e.get("key"), tuple(e.get("rids") or [])))
63
+ if e.get("kind") == "redaction" and e.get("replacement"):
64
+ redacted[e["replacement"]] = e.get("target")
65
+ for key, items in sorted(by_key.items()):
66
+ seals = [(n, e) for n, e in items if e.get("kind") == "task"]
67
+ unique = {e["rid"]: (n, e) for n, e in seals}
68
+ chained = {e.get("supersedes") for _, e in unique.values() if e.get("supersedes")}
69
+ live = [rid for rid in unique if rid not in chained]
70
+ if len(live) > 1:
71
+ task = key.rsplit("#", 1)[0]
72
+ recorded = any(k == key and set(live) <= set(r) for k, r in conflicts)
73
+ if mf.NEW_ID_RE.match(task) and not recorded:
74
+ findings.append(Finding("V3", os.path.join(mio.MEMORY_REL, unique[live[0]][0]), None,
75
+ "%s has %d seals not chained by supersedes" % (key, len(live)),
76
+ "'state.py memory export %s' records the two as a conflict" % task))
77
+ elif not recorded:
78
+ findings.append(Finding("V3", os.path.join(mio.MEMORY_REL, unique[live[0]][0]), None,
79
+ "legacy %s has %d seals and no conflict entry" % (key, len(live)),
80
+ "'state.py memory export %s' records the conflict" % task))
81
+ rids = {e["rid"] for _, e in items if e.get("kind") not in ("task", "conflict", "redaction")}
82
+ if len(live) > 1:
83
+ continue # a recorded conflict: which entries are whose is not knowable
84
+ for rid in live:
85
+ name, seal = unique[rid]
86
+ own = sorted({redacted.get(r, r) for r in rids if _belongs(items, r, seal)})
87
+ if len(own) != seal.get("entries") or mf.seal_digest(own) != seal.get("digest"):
88
+ findings.append(Finding("V4", os.path.join(mio.MEMORY_REL, name), seal.get("_line"),
89
+ "%s: the seal names %s entries (%s), the package has %d" % (
90
+ key, seal.get("entries"), (seal.get("digest") or "")[:19], len(own)),
91
+ "'state.py memory export %s' re-exports it" % seal.get("task")))
92
+ for name, size in sorted(sizes.items()):
93
+ if size > 2 * max_bytes:
94
+ findings.append(Finding("V8", os.path.join(mio.MEMORY_REL, name), None,
95
+ "%d bytes, over twice package_max_bytes (%d)" % (size, max_bytes),
96
+ "a new batch opens on the next close; 'state.py memory pack' folds old ones"))
97
+ elif size > max_bytes:
98
+ findings.append(Finding("V8-warn", os.path.join(mio.MEMORY_REL, name), None,
99
+ "%d bytes, over package_max_bytes (%d)" % (size, max_bytes),
100
+ "nothing to do now; the next close opens a new batch"))
101
+ del ref_name
102
+ return findings
103
+
104
+
105
+ def _belongs(items, rid, seal):
106
+ """Entries of a key that came before its seal (seq below the seal's)."""
107
+ for _, e in items:
108
+ if e["rid"] == rid:
109
+ return (e.get("seq") or 0) < (seal.get("seq") or 0)
110
+ return False
111
+
112
+
113
+ def check_journals(read, paths, packages, cleanup_on=False):
114
+ """V5 (and V7 with cleanup) against the committed journals."""
115
+ findings = []
116
+ seals = mpkg.seals_by_key(packages)
117
+ sealed_tasks = {k.rsplit("#", 1)[0] for k in seals}
118
+ srcs = collections.defaultdict(set)
119
+ for _, entries, _ in packages.values():
120
+ for e in entries:
121
+ for s in e.get("src") or []:
122
+ srcs[mf.key_of(e)].add(s)
123
+ journals = sorted(p for p in paths if re.match(r"^\.ai/reports/[^/]+/events\.jsonl$", p))
124
+ for path in journals:
125
+ task = path.split("/")[2]
126
+ data = read(path) or b""
127
+ if b'"task_closed"' not in data:
128
+ continue
129
+ if not (mf.NEW_ID_RE.match(task) or task in sealed_tasks):
130
+ continue # an unmigrated legacy task: 'memory status' lists it
131
+ events = mc.parse_journal(data)
132
+ instances = mc.split_instances(events)
133
+ for inst, lines in enumerate(instances, 1):
134
+ key = "%s#%d" % (task, inst)
135
+ closed = any(e and e.get("event") == "task_closed" for _, e in lines)
136
+ if not closed and inst == len(instances):
137
+ continue
138
+ if key not in seals:
139
+ findings.append(Finding("V5", path, None, "%s is closed but has no seal" % key,
140
+ "state.py memory export %s" % task))
141
+ continue
142
+ missing = [n for n in kept_lines(lines) if "events.jsonl:%d" % n not in srcs[key]]
143
+ if missing:
144
+ findings.append(Finding("V5", path, missing[0], "%s: %d kept line(s) are no entry's src" % (
145
+ key, len(missing)), "state.py memory export %s" % task))
146
+ if cleanup_on and task in sealed_tasks:
147
+ findings.append(Finding("V7", "/".join(path.split("/")[:3]) + "/", None,
148
+ "cleanup is on and %s is sealed" % task, "state.py memory cleanup %s" % task))
149
+ return findings
150
+
151
+
152
+ def verify(root, scope="all", ids=None):
153
+ """Findings for --all or ids over the working tree; [] is green."""
154
+ cfg = mcfg.load_config(root)
155
+ packages = mpkg.load_packages(root)
156
+ sizes = {n: os.path.getsize(os.path.join(mio.memory_dir(root), n)) for n in packages}
157
+ findings = check_packages(packages, cfg.package_max_bytes, sizes)
158
+ reports = os.path.join(root, ".ai", "reports")
159
+ paths = []
160
+ for name in sorted(os.listdir(reports)) if os.path.isdir(reports) else []:
161
+ if ids and name not in ids:
162
+ continue
163
+ if os.path.exists(os.path.join(reports, name, "events.jsonl")):
164
+ paths.append(".ai/reports/%s/events.jsonl" % name)
165
+ findings += check_journals(lambda rel: mio.read_bytes(os.path.join(root, rel)), paths, packages, False)
166
+ if ids:
167
+ findings = [f for f in findings if any(i in (f.path or "") or i in f.what for i in ids)]
168
+ del scope
169
+ return findings
170
+
171
+
172
+ def verify_fresh_clone(root):
173
+ """§D6: a local clone of HEAD, rebuilt from packages alone, shows every
174
+ sealed key as this clone does."""
175
+ import memory_store # pylint: disable=import-outside-toplevel
176
+ findings = []
177
+ here = memory_store.FileHistory(root, mcfg.load_config(root))
178
+ keys = sorted({mf.key_of(e) for _, entries, _ in mpkg.load_packages(root).values() for e in entries
179
+ if e.get("kind") == "task"})
180
+ with tempfile.TemporaryDirectory(prefix="ai-memory-clone-") as tmp:
181
+ clone = os.path.join(tmp, "clone")
182
+ code, _, err = mio.git(root, "clone", "-q", "--no-hardlinks", root, clone)
183
+ if code != 0:
184
+ raise mio.MemoryError_("git clone failed: %s" % err.strip(), 3)
185
+ there = memory_store.FileHistory(clone, mcfg.load_config(clone))
186
+ for key in keys:
187
+ a = "\n".join(mf.render_task_md(here.entries(key)))
188
+ b = "\n".join(mf.render_task_md(there.entries(key)))
189
+ if a != b:
190
+ findings.append(Finding("fresh-clone", key, None, "renders differently in a fresh clone",
191
+ "commit .ai/memory/ (the clone sees HEAD only)"))
192
+ return findings
193
+
194
+
195
+ def format_finding(f, ref=None):
196
+ where = f.path + (":%s" % f.line if f.line else "")
197
+ return "%s%s: %s: %s — fix: %s" % ("%s: " % ref if ref else "", f.check, where, f.what, f.fix)
198
+
199
+
200
+ # ------------------------------------------------------------------ git trees (§D10)
201
+ class GitTreeReader:
202
+ """Blobs of one commit, through one `git cat-file --batch`."""
203
+
204
+ def __init__(self, repo, sha):
205
+ self.repo, self.sha = repo, sha
206
+ self._cache = {}
207
+ self._paths = None
208
+
209
+ def paths(self):
210
+ if self._paths is None:
211
+ code, out, _ = mio.git(self.repo, "ls-tree", "-r", "-z", "--name-only", self.sha, binary=True)
212
+ self._paths = [p.decode("utf-8", "replace") for p in out.split(b"\0") if p] if code == 0 else []
213
+ return self._paths
214
+
215
+ def read_many(self, rels):
216
+ wanted = [r for r in rels if r not in self._cache]
217
+ if wanted:
218
+ stdin = "".join("%s:%s\n" % (self.sha, r) for r in wanted).encode("utf-8")
219
+ done = subprocess.run(["git", "-C", self.repo, "cat-file", "--batch"], input=stdin,
220
+ capture_output=True, check=False)
221
+ data, pos = done.stdout, 0
222
+ for rel in wanted:
223
+ end = data.find(b"\n", pos)
224
+ head = data[pos:end].split(b" ")
225
+ pos = end + 1
226
+ if len(head) == 2 and head[1] == b"missing" or len(head) < 3:
227
+ self._cache[rel] = None
228
+ continue
229
+ size = int(head[2])
230
+ self._cache[rel] = data[pos:pos + size]
231
+ pos += size + 1
232
+ return {r: self._cache.get(r) for r in rels}
233
+
234
+ def read(self, rel):
235
+ return self.read_many([rel])[rel]
236
+
237
+ def listdir(self, rel):
238
+ prefix = rel.rstrip("/") + "/"
239
+ return sorted({p[len(prefix):].split("/")[0] for p in self.paths() if p.startswith(prefix)})
240
+
241
+
242
+ def rev(repo, name):
243
+ code, out, _ = mio.git(repo, "rev-parse", "--verify", "-q", name + "^{commit}")
244
+ return out.strip() if code == 0 and out.strip() else None
245
+
246
+
247
+ def policy_at(repo, sha):
248
+ code, out, _ = mio.git(repo, "show", "%s:%s" % (sha, mio.POLICY_REL.replace(os.sep, "/")))
249
+ if code != 0:
250
+ return None
251
+ try:
252
+ data = json.loads(out)
253
+ except ValueError:
254
+ return {"mode": "invalid"}
255
+ return data if isinstance(data, dict) else {"mode": "invalid"}
256
+
257
+
258
+ def validate_range(repo, old, new, local=False):
259
+ """(findings, stats) for the commits O..T (§D10). O may be None: then only
260
+ the structure of T is checked."""
261
+ started = time.time()
262
+ policy = policy_at(repo, new)
263
+ if policy is None or policy.get("mode", "files") == "files":
264
+ return None, {}
265
+ if policy.get("mode") == "invalid":
266
+ return [Finding("V1", mio.POLICY_REL, None, "memory.json at %s is not valid JSON" % new[:9],
267
+ "fix the file, or 'state.py memory disable'")], {}
268
+ max_bytes = int(policy.get("package_max_bytes", mcfg.DEFAULTS["package_max_bytes"]))
269
+ tree = GitTreeReader(repo, new)
270
+ paths = tree.paths()
271
+ pkg_paths = [p for p in paths if p.startswith(".ai/memory/") and p.endswith(".jsonl")]
272
+ blobs = tree.read_many(pkg_paths)
273
+ packages = {p.split("/", 2)[2]: mf.parse_package(blobs[p] or b"", p.split("/", 2)[2]) for p in pkg_paths}
274
+ sizes = {p.split("/", 2)[2]: len(blobs[p] or b"") for p in pkg_paths}
275
+ changed, added = None, []
276
+ findings = []
277
+ if old:
278
+ code, out, _ = mio.git(repo, "diff", "--name-status", "--no-renames", old, new, "--", ".ai")
279
+ rows = [line.split("\t") for line in out.splitlines() if "\t" in line] if code == 0 else []
280
+ changed = {r[1].split("/", 2)[2] for r in rows if r[1].startswith(".ai/memory/")}
281
+ added = [r[1] for r in rows if r[0].startswith("A")]
282
+ before = GitTreeReader(repo, old)
283
+ old_pkgs = [p for p in before.paths() if p.startswith(".ai/memory/") and p.endswith(".jsonl")]
284
+ old_blobs = before.read_many(old_pkgs)
285
+ old_rids = set()
286
+ for p in old_pkgs:
287
+ _, entries, _ = mf.parse_package(old_blobs[p] or b"")
288
+ old_rids |= {e["rid"] for e in entries}
289
+ new_rids = mpkg.all_rids(packages)
290
+ redacted = {e.get("target") for _, entries, _ in packages.values() for e in entries
291
+ if e.get("kind") == "redaction"}
292
+ lost = sorted(old_rids - new_rids - redacted)
293
+ if lost:
294
+ findings.append(Finding("V2", ".ai/memory/", None, "%d record(s) of %s are gone (%s…)" % (
295
+ len(lost), old[:9], lost[0]), "git checkout %s -- .ai/memory/ and merge the lines back" % old[:9]))
296
+ findings = check_packages(packages, max_bytes, sizes, changed) + findings
297
+ journals = [p for p in paths if re.match(r"^\.ai/reports/[^/]+/events\.jsonl$", p)]
298
+ journal_blobs = tree.read_many(journals) # one cat-file --batch for every journal (§D10)
299
+ findings += check_journals(journal_blobs.get, journals, packages, bool(policy.get("cleanup")))
300
+ for path in added:
301
+ if path.startswith(".ai/reports/") and path.endswith(".log"):
302
+ findings.append(Finding("V6", path, None, "a log is committed under .ai/reports/",
303
+ "git rm --cached %s (logs live in .ai/state/logs/)" % path))
304
+ for path in paths:
305
+ # The index lives under .ai/local/; a project's own SQLite file elsewhere is not ours.
306
+ if path.startswith(".ai/local/") or path.startswith(".ai/state/logs/") or \
307
+ (path.startswith(".ai/") and ".sqlite3" in path):
308
+ findings.append(Finding("V6", path, None, "local data is committed",
309
+ "git rm --cached %s (.ai/local/ and .ai/state/logs/ stay local)" % path))
310
+ if local:
311
+ outbox = mio.local_path(repo, "outbox")
312
+ if os.path.isdir(outbox) and any(n.endswith(".jsonl") for n in os.listdir(outbox)):
313
+ findings.append(Finding("V9", os.path.join(mio.LOCAL_REL, "outbox"), None, "an export is pending",
314
+ "state.py memory export --pending, commit, push again"))
315
+ stats = {"packages": len(packages), "tasks": len(journals), "ms": int((time.time() - started) * 1000)}
316
+ return findings, stats
@@ -34,6 +34,7 @@ def memory_parsers(sub):
34
34
  m = msub.add_parser("status"); m.add_argument("--json", action="store_true")
35
35
  m = msub.add_parser("enable"); m.add_argument("--author"); m.add_argument("--period", choices=["month", "quarter", "year"])
36
36
  m.add_argument("--index", choices=["none", "sqlite"])
37
+ m.add_argument("--share", choices=["git", "local"])
37
38
  msub.add_parser("disable")
38
39
  m = msub.add_parser("export"); m.add_argument("ids", nargs="*"); m.add_argument("--pending", action="store_true")
39
40
  m = msub.add_parser("verify"); m.add_argument("ids", nargs="*"); m.add_argument("--all", action="store_true")
@@ -165,7 +165,7 @@ def memory_after_close(root, task_id):
165
165
  if not os.path.exists(os.path.join(root, ".ai", "policies", "memory.json")):
166
166
  return
167
167
  try:
168
- import memory_sync # pylint: disable=import-outside-toplevel
168
+ import memory_export # pylint: disable=import-outside-toplevel
169
169
  except ImportError:
170
170
  return
171
- memory_sync.after_close(root, task_id)
171
+ memory_export.after_close(root, task_id)
@@ -1,7 +1,7 @@
1
1
  """dialog: the human decision dialog and the authority that tells a human from the agent -- decisions, consent labels, picks, the approval offer.
2
2
 
3
- shell: human_route, human_turn, refuse_approval, DIALOG_PICK, dialog_policy, dialog_tool_verified, dialog_on, open_decision, print_dialog, offer_decision, settle_decision, settle_written, prune_decisions, mirror_ledger, sync_dialog, dialog_pick, valid_decisions, retire_approvals, task_facts, audit_pick, approve_command, offer_approval
4
- core: DIALOG_TOOL, SESSION_ENV, DECISIONS_MAX, QUESTION_MAX, HEADER_MAX, JSON_DEPTH_MAX, json_loads_capped, dialog_questions, option_description, part_question, dialog_payload, MIRROR, pick_data
3
+ shell: human_route, human_turn, open_approval_id, refuse_approval, DIALOG_PICK, dialog_policy, dialog_tool_verified, dialog_on, open_decision, print_dialog, offer_decision, settle_decision, settle_written, prune_decisions, mirror_ledger, sync_dialog, dialog_pick, valid_decisions, retire_approvals, task_facts, audit_pick, approve_command, offer_approval
4
+ core: DIALOG_TOOL, SESSION_ENV, DECISIONS_MAX, QUESTION_MAX, HEADER_MAX, JSON_DEPTH_MAX, json_loads_capped, dialog_questions, option_description, human_refusal, part_question, dialog_payload, MIRROR, pick_data
5
5
  """
6
6
  import json
7
7
  import os
@@ -77,16 +77,29 @@ def human_turn(root, state):
77
77
  return True, ""
78
78
 
79
79
 
80
+ def open_approval_id(root, rt=rr_runtime.Runtime()):
81
+ """The id of the open approval decision, or None with the dialog off or none open: the refusals name the dialog then (R7)."""
82
+ if not dialog_on(root, runtime=rt.name):
83
+ return None
84
+ try:
85
+ with open(fs.decision_path(root), encoding="utf-8") as fh:
86
+ return next((d["id"] for d in json.load(fh)["open"] if d.get("kind") == "approval"), None)
87
+ except (OSError, ValueError, KeyError, TypeError, AttributeError, RecursionError):
88
+ return None # an unreadable decision.json names no decision: the refusal stays today's
89
+
90
+
80
91
  def refuse_approval(root, state, gate_id, by, why):
81
92
  # Quoted: this project's own path has a space in it, and the command the
82
93
  # refusal prints is the only route a human without the flag has.
83
- fs.die("APPROVAL_REFUSED — approval happens outside the agent. Run in your own terminal:\n"
84
- " python3 %s --root %s approve --by %s\n"
94
+ cmd = "python3 %s --root %s approve --by %s" % (shlex.quote(fs.ENTRY), shlex.quote(root), shlex.quote(by))
95
+ asked = None if state["mode"] == "full_irreversible" else human_refusal("'state.py approve'", cmd, why, open_approval_id(root))
96
+ questions = os.path.relpath(fs.questions_path(root, state, None), root)
97
+ if asked:
98
+ fs.die("%s Or set [Answer]: A on %s in %s and tell the session to sync." % (asked, gate_id, questions), 5)
99
+ fs.die("APPROVAL_REFUSED — approval happens outside the agent. Run in your own terminal:\n %s\n"
85
100
  "or set [Answer]: A on %s in %s and tell the session to sync. An unattended run exports "
86
101
  "AI_UNATTENDED=1 in the launcher's environment; the journal then records the approval as "
87
- "unattended.\nRefused because %s."
88
- % (shlex.quote(fs.ENTRY), shlex.quote(root), shlex.quote(by), gate_id,
89
- os.path.relpath(fs.questions_path(root, state, None), root), why), 5)
102
+ "unattended.\nRefused because %s." % (cmd, gate_id, questions, why), 5)
90
103
 
91
104
 
92
105
  # ---------------------------------------------- the dialog (R36, step 7c)
@@ -254,6 +267,9 @@ def open_decision(root, state, kind, header, question, options, opened_by, multi
254
267
  "question": (question or "").strip()[:QUESTION_MAX],
255
268
  "options": [dict(option, n=n) for n, option in enumerate(options, 1)],
256
269
  }, **extra)
270
+ for option in decision["options"]:
271
+ if option["kind"] == "command" and not option.get("description"):
272
+ option["description"] = labels.describe(shlex.split(option["label"]))
257
273
  data["open"].append(decision)
258
274
  rr_ledger.write_decisions(root, data)
259
275
  if state and state.get("task_id"):
@@ -270,6 +286,15 @@ def option_description(option):
270
286
  return "the session runs exactly this, once" if option["kind"] == "command" else "nothing runs"
271
287
 
272
288
 
289
+ def human_refusal(what, cmd, why, decision):
290
+ """The refusal text that names the dialog first, or None without a decision: the site prints its own text then (R7)."""
291
+ if not decision:
292
+ return None
293
+ return ("APPROVAL_REFUSED — %s is the human's. Ask them in the dialog: send DIALOG_PENDING %s (printed above), "
294
+ "then run the command they pick, verbatim, once. Or they run, in their own terminal:\n %s\n"
295
+ "Refused because %s." % (what, decision, cmd, why))
296
+
297
+
273
298
  def part_question(question, i, n):
274
299
  """The question text of part i of n: a split decision's parts must differ,
275
300
  Claude keys tool_response.answers by text; cut so the whole fits QUESTION_MAX."""
@@ -1,12 +1,13 @@
1
1
  """dialog_cmd: the consent and decision commands -- decision open/show/settle/fallback, the pick applied, and the read-only consent view.
2
2
 
3
- core: SETTLE_EFFECT, SETUP_VALUES, SETUP_VALUE_MAX, SETUP_SPEC_MAX, setup_option_why, setup_decision_why, setup_spec_why
4
- shell: cmd_decision, decision_open, read_setup_spec, setup_command_why, check_setup_labels, setup_open, decision_settle, apply_choice, cmd_consent
3
+ core: SETTLE_EFFECT, INSTALL_RE, SETUP_VALUES, SETUP_VALUE_MAX, SETUP_SPEC_MAX, setup_option_why, setup_decision_why, setup_spec_why
4
+ shell: cmd_decision, decision_open, install_open, read_setup_spec, setup_command_why, check_setup_labels, setup_open, decision_settle, apply_choice, cmd_consent
5
5
  """
6
6
  # pylint: disable=unused-argument # uniform cli dispatch: cmd_consent takes rt like every command
7
7
  import argparse
8
8
  import json
9
9
  import os
10
+ import re
10
11
  import shlex
11
12
  import sys
12
13
 
@@ -22,6 +23,7 @@ SETUP_VALUES = {"memory": ("off", "keep"), "runtime": ("auto", "claude", "codex"
22
23
  "survey": ("light", "full", "keep"), "verify": ("", "other", "keep", "edit")}
23
24
  SETUP_VALUE_MAX = 200
24
25
  SETUP_SPEC_MAX = 262144
26
+ INSTALL_RE = re.compile(r"^npx riskrails@(\d+\.\d+\.\d+) --yes$") # the one command an install decision offers
25
27
 
26
28
 
27
29
  def cmd_decision(args, root, rt=rr_runtime.Runtime()):
@@ -61,13 +63,17 @@ def cmd_decision(args, root, rt=rr_runtime.Runtime()):
61
63
 
62
64
 
63
65
  def decision_open(args, root, rt=rr_runtime.Runtime()):
64
- """The openers the model may call: --kind commit (git commit and git push) and --kind setup (the /rr-setup
65
- questions: choices that set a value, and this project's `memory enable`); nothing else."""
66
+ """The openers the model may call: --kind commit (git commit and git push), --kind setup (the /rr-setup
67
+ questions: choices that set a value, and this project's `memory enable`) and --kind install (one
68
+ `npx riskrails@X.Y.Z --yes`); nothing else."""
66
69
  if args.kind == "setup":
67
70
  setup_open(args, root, rt=rt)
68
71
  return
72
+ if args.kind == "install":
73
+ install_open(args, root, rt=rt)
74
+ return
69
75
  if args.kind != "commit":
70
- fs.die("decision open takes --kind commit or --kind setup; every other decision is opened by the "
76
+ fs.die("decision open takes --kind commit or --kind setup (or --kind install, one npx riskrails@X.Y.Z --yes); every other decision is opened by the "
71
77
  "command that needs it (close, ask, the budgets, start --mode)")
72
78
  if args.spec is not None:
73
79
  fs.die("decision open --kind commit takes --command, not --spec", 2)
@@ -97,6 +103,33 @@ def decision_open(args, root, rt=rr_runtime.Runtime()):
97
103
  dialog.print_dialog([decision], rt=rt)
98
104
 
99
105
 
106
+ def install_open(args, root, rt=rr_runtime.Runtime()):
107
+ """decision open --kind install --command "npx riskrails@X.Y.Z --yes": the update offered in the dialog (R4).
108
+ The command is exactly that, once; no task may be in flight, because the install rewrites the hooks."""
109
+ match = INSTALL_RE.match(args.commands[0]) if len(args.commands) == 1 else None
110
+ if args.spec is not None or not match:
111
+ fs.die("CONSENT_LABEL_INVALID — decision open --kind install takes one --command, exactly "
112
+ "\"npx riskrails@X.Y.Z --yes\" (X.Y.Z a plain version); found: %s"
113
+ % (" | ".join(args.commands)[:120] or "none"), 6)
114
+ if not dialog.dialog_on(root, runtime=rt.name):
115
+ fs.die("the dialog is off here (.ai/policies/dialog.json, the runtime's dialog tool, a session id): "
116
+ "suggest the command; the human runs it outside the agent")
117
+ state = task.in_flight(root, rt=rt)
118
+ if state:
119
+ fs.die("INSTALL_DURING_TASK — an install rewrites the hooks and skills this task runs on, and task %s is in "
120
+ "flight; finish or abandon it, then open the install decision again" % state["task_id"], 6)
121
+ runtime = rr_runtime.own_runtime(runtime=rt.name)
122
+ have = profile.read_profile(runtime, rt=rt).get("plugin_version") or "unknown"
123
+ question = ("Install RiskRails %s (installed %s)? It rewrites the hooks, skills and settings under %s; "
124
+ "restart the session afterwards." % (match.group(1), have, rr_runtime.runtime_home(runtime)))
125
+ options = [{"kind": "choice", "effect": "defer", "label": "Not now — keep %s" % have},
126
+ {"kind": "command", "label": args.commands[0]}]
127
+ decision, why = dialog.open_decision(root, None, "install", "Install", question, options, "decision open", rt=rt)
128
+ if decision is None:
129
+ fs.die(why, 6)
130
+ dialog.print_dialog([decision], rt=rt)
131
+
132
+
100
133
  def read_setup_spec(path):
101
134
  """--spec FILE|- as JSON: a spec that cannot be read, is longer than SETUP_SPEC_MAX characters, is not JSON or
102
135
  nests deeper than the dialog's own cap is a bad spec (exit 2)."""
@@ -5,8 +5,9 @@ shell: guard_pending, request_gate, close_gate
5
5
  """
6
6
  import os
7
7
  import re
8
+ import shlex
8
9
 
9
- from rr_state import fs
10
+ from rr_state import dialog, fs
10
11
  from rr_state import journal
11
12
  from rr_state import locks
12
13
  from rr_state import questions as questions_mod # the bodies keep a local `questions`; the module is aliased
@@ -36,12 +37,17 @@ def guard_pending(root, command, rt=rr_runtime.Runtime()):
36
37
  if any(q["kind"] == "G" for q in blocking):
37
38
  # `answer` refuses a G id, so the general text would send the human to a
38
39
  # command that cannot work. A gate is closed from a terminal.
40
+ gates = ", ".join(q["id"] for q in blocking if q["kind"] == "G")
41
+ cmd = "python3 %s --root %s approve --by \"<name>\"" % (shlex.quote(fs.ENTRY), shlex.quote(root))
42
+ asked = dialog.human_refusal("the approval gate", cmd, "approve and reject are the human's, and %s is open" % gates,
43
+ dialog.open_approval_id(root, rt=rt))
44
+ if asked:
45
+ fs.die("QUESTIONS_PENDING — %d unanswered in %s: %s. Stage stays at %s.\n%s"
46
+ % (len(blocking), os.path.relpath(path, root), ", ".join(ids), state.get("current_stage"), asked), 4)
39
47
  fs.die("QUESTIONS_PENDING — %d unanswered in %s: %s. %s is the approval gate: run "
40
48
  "'state.py approve --by \"<name>\"' or 'state.py reject --by \"<name>\" --why \"<text>\"' "
41
49
  "in your own terminal. Stage stays at %s."
42
- % (len(blocking), os.path.relpath(path, root), ", ".join(ids),
43
- ", ".join(q["id"] for q in blocking if q["kind"] == "G"),
44
- state.get("current_stage")), 4)
50
+ % (len(blocking), os.path.relpath(path, root), ", ".join(ids), gates, state.get("current_stage")), 4)
45
51
  fs.die("QUESTIONS_PENDING — %d unanswered in %s: %s. Answer with 'state.py answer %s' or fill "
46
52
  "the [Answer]: lines and run 'state.py questions --sync'. Stage stays at %s."
47
53
  % (len(blocking), os.path.relpath(path, root), ", ".join(ids),
@@ -1,7 +1,7 @@
1
1
  """labels: the consent labels a decision may offer as commands -- the one-simple-command rule, the state.py command a label runs, and the absolute command the agent and the human share.
2
2
 
3
3
  shell: validate_label, human_name, state_label
4
- core: LABEL_MAX, LABEL_FORBIDDEN, INTERPRETER_RE, PUSH_REF_RE, INTERPRETER_FLAG_RE, NPX_PACKAGE_RE, label_argv, subcommand, state_label_ok, subcommand_prefix
4
+ core: BATCH_ON, DESCRIPTIONS, describe, LABEL_MAX, LABEL_FORBIDDEN, INTERPRETER_RE, PUSH_REF_RE, INTERPRETER_FLAG_RE, NPX_PACKAGE_RE, label_argv, subcommand, state_label_ok, subcommand_prefix
5
5
  """
6
6
 
7
7
  import os
@@ -16,6 +16,17 @@ INTERPRETER_RE = re.compile(r"^python3?$") # a bare name: a path could be any
16
16
  PUSH_REF_RE = re.compile(r"^[A-Za-z0-9._/-]+$") # no '+' (a force push), no ':' (a refspec), no option
17
17
  INTERPRETER_FLAG_RE = re.compile(r"^-[IS]+$")
18
18
  NPX_PACKAGE_RE = re.compile(r"^riskrails@[0-9A-Za-z.+-]+$")
19
+ BATCH_ON = {"--by": True, "--why": False} # `batch on` options, each at most once; True = required. hooks/guard/dialog.py keeps the same table
20
+ DESCRIPTIONS = { # what each offered command does, in words a person without a terminal reads
21
+ ("approve",): "Approve the task's result as it is now, so it can be closed.",
22
+ ("reject",): "Reject the task's result and send it back for changes.",
23
+ ("profile", "set"): "Change the plan profile, which sets the models the work runs on.",
24
+ ("verify",): "Run the test suite once more, past the number of runs this task allows.",
25
+ ("review",): "Record the next review verdict as yours, past the review passes this task allows.",
26
+ ("start",): "Start the task in the mode the command names instead of the one its weight gives.",
27
+ ("memory", "enable"): "Turn on the project's compact record of tasks.",
28
+ ("batch", "on"): "Turn the batch loop on for this task: more test runs and review passes before you are asked.",
29
+ }
19
30
 
20
31
 
21
32
  def label_argv(label):
@@ -44,10 +55,15 @@ def subcommand(argv):
44
55
 
45
56
  def state_label_ok(argv):
46
57
  """approve | reject | profile set | verify --by | review --by | start --mode … --by |
47
- memory enable [--author S] [--period month|quarter|year] [--index none|sqlite]: each option at most
58
+ batch on --by S [--why S] | memory enable [--author S] [--period month|quarter|year] [--index none|sqlite]: each option at most
48
59
  once, as two words, in any order, and no other memory verb. hooks/guard/dialog.py keeps the same
49
60
  rule; tests/test_dialog_contract.py pins the two equal."""
50
61
  rest = subcommand(argv)
62
+ if rest[:2] == ["batch", "on"]:
63
+ names, values = rest[2::2], rest[3::2]
64
+ return (len(names) == len(values) == len(set(names)) and set(names) <= set(BATCH_ON)
65
+ and all(n in names for n, need in BATCH_ON.items() if need)
66
+ and all(v[:1] not in ("", "-") for v in values))
51
67
  if rest[:1] in (["approve"], ["reject"]) or rest[:2] == ["profile", "set"]:
52
68
  return True
53
69
  if rest[:1] in (["verify"], ["review"]):
@@ -89,8 +105,8 @@ def validate_label(label):
89
105
  and state_label_ok(script[1]):
90
106
  return None
91
107
  return ("a label starts with git commit, git push <remote> <branch>, state.py approve|reject|"
92
- "profile set|verify --by|review --by|start --mode --by|memory enable [--author S] "
93
- "[--period P] [--index none|sqlite], or npx riskrails@<v> … --yes")
108
+ "profile set|verify --by|review --by|start --mode --by|batch on --by S [--why S]|memory enable "
109
+ "[--author S] [--period P] [--index none|sqlite], or npx riskrails@<v> … --yes")
94
110
 
95
111
 
96
112
  def human_name(root):
@@ -109,3 +125,16 @@ def state_label(root, argv):
109
125
  def subcommand_prefix(argv):
110
126
  """The global options in front of the subcommand."""
111
127
  return list(argv)[:len(argv) - len(subcommand(argv))]
128
+
129
+
130
+ def describe(argv):
131
+ """One plain-words line for the command a label runs; argv is the label split into words."""
132
+ if argv[:2] == ["git", "commit"]:
133
+ return "Save the finished work as a commit in git."
134
+ if argv[:2] == ["git", "push"]:
135
+ return "Send the committed work to the remote repository."
136
+ if argv[:1] == ["npx"]:
137
+ return "Install this RiskRails version into your runtime's home; start a new session afterwards."
138
+ script = label_argv(shlex.join(argv))
139
+ rest = subcommand(script[1]) if script else []
140
+ return DESCRIPTIONS.get(tuple(rest[:2])) or DESCRIPTIONS.get(tuple(rest[:1])) or "Run a command you have read."