riskrails 4.0.1 → 4.2.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 (67) 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 +22 -13
  5. package/bin/menu.js +116 -0
  6. package/bin/riskrails.js +119 -20
  7. package/hooks/AGENTS.md +0 -6
  8. package/hooks/CLAUDE.md +0 -6
  9. package/hooks/guard/dialog.py +14 -2
  10. package/hooks/guard/paths.py +48 -1406
  11. package/hooks/guard/paths_base.py +59 -0
  12. package/hooks/guard/paths_bash.py +176 -0
  13. package/hooks/guard/paths_classify.py +205 -0
  14. package/hooks/guard/paths_fs.py +137 -0
  15. package/hooks/guard/paths_human.py +105 -0
  16. package/hooks/guard/paths_interp.py +162 -0
  17. package/hooks/guard/paths_movers.py +393 -0
  18. package/hooks/guard/paths_shell.py +288 -0
  19. package/package.json +1 -1
  20. package/skills/AGENTS.md +0 -6
  21. package/skills/CLAUDE.md +0 -6
  22. package/skills/ai-init/scaffold-ai.sh +2 -1
  23. package/skills/ai-init/templates/.ai/policies/safety.md +4 -3
  24. package/skills/ai-init/templates/gitignore.snippet +2 -0
  25. package/skills/ai-task/memory_cleanup.py +213 -0
  26. package/skills/ai-task/memory_compact.py +25 -0
  27. package/skills/ai-task/memory_config.py +120 -0
  28. package/skills/ai-task/memory_export.py +246 -0
  29. package/skills/ai-task/memory_format.py +7 -3
  30. package/skills/ai-task/memory_hook.py +172 -0
  31. package/skills/ai-task/memory_io.py +122 -0
  32. package/skills/ai-task/memory_migrate.py +187 -0
  33. package/skills/ai-task/memory_packages.py +83 -0
  34. package/skills/ai-task/memory_risks.py +84 -0
  35. package/skills/ai-task/memory_status.py +76 -0
  36. package/skills/ai-task/memory_sync.py +20 -1481
  37. package/skills/ai-task/memory_verify.py +316 -0
  38. package/skills/ai-task/rr_state/cli.py +2 -1
  39. package/skills/ai-task/rr_state/close.py +2 -2
  40. package/skills/ai-task/rr_state/dialog_cmd.py +183 -6
  41. package/skills/ai-task/rr_state/labels.py +14 -2
  42. package/skills/ai-task/rr_state/memory_cmd.py +91 -68
  43. package/skills/project-update/history/blobs/8ec171f0fbdf38cc6996d496f01491fd1f21f04a435a118ad979b127a8be06f1 +85 -0
  44. package/skills/project-update/history/blobs/fa0cb370b024e1a30e1355912032657b08f609e47773a603c193b1e609b0f997 +8 -0
  45. package/skills/project-update/history/index.json +4 -2
  46. package/skills/project-update/rr_update/__init__.py +1 -0
  47. package/skills/project-update/rr_update/apply.py +188 -0
  48. package/skills/project-update/rr_update/blocks.py +38 -0
  49. package/skills/project-update/rr_update/build.py +96 -0
  50. package/skills/project-update/rr_update/cleanup.py +194 -0
  51. package/skills/project-update/rr_update/cli.py +102 -0
  52. package/skills/project-update/rr_update/files.py +106 -0
  53. package/skills/project-update/rr_update/gate.py +60 -0
  54. package/skills/project-update/rr_update/hints.py +128 -0
  55. package/skills/project-update/rr_update/history.py +117 -0
  56. package/skills/project-update/rr_update/kept.py +120 -0
  57. package/skills/project-update/rr_update/paths.py +108 -0
  58. package/skills/project-update/rr_update/plan.py +123 -0
  59. package/skills/project-update/rr_update/report.py +51 -0
  60. package/skills/project-update/rr_update/rules.py +178 -0
  61. package/skills/project-update/rr_update/shared.py +74 -0
  62. package/skills/project-update/rr_update/siblings.py +37 -0
  63. package/skills/project-update/rr_update/weights.py +171 -0
  64. package/skills/project-update/update.py +8 -1682
  65. package/skills/rr-setup/SKILL.md +79 -21
  66. package/skills/rr-setup/scaffold_ai.py +8 -2
  67. package/skills/rr-setup/setup.py +371 -4
@@ -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")
@@ -179,7 +180,7 @@ def main():
179
180
 
180
181
  p = sub.add_parser("decision"); p.add_argument("action", choices=["show", "open", "settle", "fallback"])
181
182
  p.add_argument("--id"); p.add_argument("--kind"); p.add_argument("--command", dest="commands", action="append", default=[])
182
- p.add_argument("--pick", type=int); p.add_argument("--why", default="")
183
+ p.add_argument("--spec"); p.add_argument("--pick", type=int); p.add_argument("--why", default="")
183
184
  p.add_argument("--json", action="store_true"); p.set_defaults(func=dialog_cmd.cmd_decision)
184
185
 
185
186
  p = sub.add_parser("consent"); p.add_argument("action", nargs="?", choices=["audit"])
@@ -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,19 +1,27 @@
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
4
- shell: cmd_decision, decision_open, decision_settle, apply_choice, cmd_consent
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
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 shlex
10
11
  import sys
11
12
 
12
13
  from rr_state import dialog, fs, labels, profile, questions_cmd, task
13
14
  from rr_state import ledger as rr_ledger
14
15
  from rr_state import runtime as rr_runtime
15
16
 
16
- SETTLE_EFFECT = {"defer": "defer", "keep": "ack", "ack": "ack", "answer": "answered"}
17
+ SETTLE_EFFECT = {"defer": "defer", "keep": "ack", "ack": "ack", "answer": "answered", "set": "set"}
18
+ # What `decision open --kind setup` accepts (R11): the questions /rr-setup asks, each with the words a choice may set
19
+ # (what the skill applies is what the human read: one of these words, or, for verify only, a command shown verbatim
20
+ # in its own label), the longest value, and the most characters of a spec it reads.
21
+ SETUP_VALUES = {"memory": ("off", "keep"), "runtime": ("auto", "claude", "codex", "both"),
22
+ "survey": ("light", "full", "keep"), "verify": ("", "other", "keep", "edit")}
23
+ SETUP_VALUE_MAX = 200
24
+ SETUP_SPEC_MAX = 262144
17
25
 
18
26
 
19
27
  def cmd_decision(args, root, rt=rr_runtime.Runtime()):
@@ -53,10 +61,16 @@ def cmd_decision(args, root, rt=rr_runtime.Runtime()):
53
61
 
54
62
 
55
63
  def decision_open(args, root, rt=rr_runtime.Runtime()):
56
- """The one opener the model may call: commit and push, nothing else."""
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
+ if args.kind == "setup":
67
+ setup_open(args, root, rt=rt)
68
+ return
57
69
  if args.kind != "commit":
58
- fs.die("decision open takes --kind commit; every other decision is opened by the command that "
59
- "needs it (close, ask, the budgets, start --mode)")
70
+ fs.die("decision open takes --kind commit or --kind setup; every other decision is opened by the "
71
+ "command that needs it (close, ask, the budgets, start --mode)")
72
+ if args.spec is not None:
73
+ fs.die("decision open --kind commit takes --command, not --spec", 2)
60
74
  if not args.commands:
61
75
  fs.die("decision open --kind commit needs --command \"git commit …\" [--command \"git push <remote> <branch>\"]")
62
76
  for command in args.commands:
@@ -83,6 +97,165 @@ def decision_open(args, root, rt=rr_runtime.Runtime()):
83
97
  dialog.print_dialog([decision], rt=rt)
84
98
 
85
99
 
100
+ def read_setup_spec(path):
101
+ """--spec FILE|- as JSON: a spec that cannot be read, is longer than SETUP_SPEC_MAX characters, is not JSON or
102
+ nests deeper than the dialog's own cap is a bad spec (exit 2)."""
103
+ try:
104
+ if path == "-":
105
+ text = sys.stdin.read(SETUP_SPEC_MAX + 1)
106
+ else:
107
+ with open(path, encoding="utf-8") as fh:
108
+ text = fh.read(SETUP_SPEC_MAX + 1)
109
+ if len(text) > SETUP_SPEC_MAX:
110
+ fs.die("bad spec — more than %d characters" % SETUP_SPEC_MAX, 2)
111
+ spec = dialog.json_loads_capped(text)
112
+ # A lone surrogate ("\ud800") parses, yet no file can hold it: refused here, not after a first decision is written.
113
+ json.dumps(spec, ensure_ascii=False).encode("utf-8")
114
+ return spec
115
+ except (OSError, ValueError, RecursionError) as exc:
116
+ fs.die("bad spec — %s" % exc, 2)
117
+
118
+
119
+ def setup_option_why(option, key):
120
+ """Why one option of the decision `key` is not {kind: choice, label, value[, description]} or {kind: command,
121
+ label[, description]}, else None. A choice sets one of its key's words (SETUP_VALUES); a verify choice may also
122
+ set a command, and check_setup_labels (exit 6) makes its label show it, like every rule of the labels."""
123
+ if not isinstance(option, dict) or option.get("kind") not in ("choice", "command"):
124
+ return "an option is an object whose kind is choice or command"
125
+ need = {"kind", "label", "value"} if option["kind"] == "choice" else {"kind", "label"}
126
+ if not need <= set(option) <= need | {"description"}:
127
+ return "a %s option holds %s, and a description if it has one" % (option["kind"], ", ".join(sorted(need)))
128
+ text, value = option.get("description", ""), option.get("value", "")
129
+ if not isinstance(option["label"], str) or not isinstance(text, str) or len(text) > dialog.QUESTION_MAX:
130
+ return "a label and a description are text, a description at most %d characters" % dialog.QUESTION_MAX
131
+ if not isinstance(value, str) or len(value) > SETUP_VALUE_MAX or not value.isprintable():
132
+ return "a choice value is at most %d printable characters (empty is allowed)" % SETUP_VALUE_MAX
133
+ if option["kind"] == "choice" and key != "verify" and value not in SETUP_VALUES[key]:
134
+ return "a %s choice sets one of: %s" % (key, ", ".join(SETUP_VALUES[key]))
135
+ return None
136
+
137
+
138
+ def setup_decision_why(item):
139
+ """Why one decision of a spec is not {key, header, question, options}, else None."""
140
+ if not isinstance(item, dict) or set(item) != {"key", "header", "question", "options"}:
141
+ return "a decision is an object holding exactly key, header, question and options"
142
+ if not isinstance(item["key"], str) or item["key"] not in SETUP_VALUES:
143
+ return "a decision key is one of %s" % ", ".join(SETUP_VALUES)
144
+ if not all(isinstance(item[name], str) and item[name].strip() for name in ("header", "question")):
145
+ return "a header and a question are text and not empty"
146
+ if not isinstance(item["options"], list) or not 2 <= len(item["options"]) <= 4:
147
+ return "a decision has 2 to 4 options"
148
+ for number, option in enumerate(item["options"], 1):
149
+ why = setup_option_why(option, item["key"])
150
+ if why:
151
+ return "option %d: %s" % (number, why)
152
+ return None
153
+
154
+
155
+ def setup_spec_why(spec):
156
+ """Why a parsed spec is not {"schema": 1, "decisions": [...]} with 1 to DECISIONS_MAX decisions, each key
157
+ once, else None (R11)."""
158
+ if not isinstance(spec, dict) or set(spec) != {"schema", "decisions"}:
159
+ return "a spec is an object holding exactly schema and decisions"
160
+ schema, decisions = spec["schema"], spec["decisions"]
161
+ if not isinstance(schema, int) or isinstance(schema, bool) or schema != 1:
162
+ return "schema is 1"
163
+ if not isinstance(decisions, list) or not 1 <= len(decisions) <= dialog.DECISIONS_MAX:
164
+ return "decisions is a list of 1 to %d decisions" % dialog.DECISIONS_MAX
165
+ for number, item in enumerate(decisions, 1):
166
+ why = setup_decision_why(item)
167
+ if why:
168
+ return "decision %d: %s" % (number, why)
169
+ keys = [item["key"] for item in decisions]
170
+ return None if len(set(keys)) == len(keys) else "a decision key is used once"
171
+
172
+
173
+ def setup_command_why(root, label):
174
+ """Why `label` is not exactly `python3 <this state.py> --root <this project> memory enable [options]`, else
175
+ None. validate_label alone also passes another --root, a --runtime, interpreter flags, approve and git commit
176
+ (R11, B1); the paths must be absolute, as the labels state_label writes."""
177
+ why = labels.validate_label(label)
178
+ if why:
179
+ return why
180
+ words = shlex.split(label)
181
+ if len(words) >= 6 and words[0] == "python3" and words[2] == "--root" and words[4:6] == ["memory", "enable"] \
182
+ and os.path.isabs(words[1]) and os.path.realpath(words[1]) == os.path.realpath(fs.ENTRY) \
183
+ and os.path.isabs(words[3]) and os.path.realpath(words[3]) == os.path.realpath(root):
184
+ return None
185
+ return ("a setup command is exactly python3 <this state.py> --root <this project> memory enable "
186
+ "[--author S] [--period P] [--index none|sqlite]")
187
+
188
+
189
+ def check_setup_labels(root, decisions):
190
+ """Every label of the spec, before anything is written (exit 6): only the memory decision offers a command, and
191
+ that is setup_command_why's; a choice never reads as a command, because the gate passes a Bash command equal
192
+ to any pick and dialog_pick runs a pick that is a state.py label; and a verify choice that sets a command
193
+ shows it, whole, as one `span` of its label, because the skill applies the value and the human reads the
194
+ label (R11, B1)."""
195
+ for item in decisions:
196
+ options = item["options"]
197
+ if len({option["label"] for option in options}) != len(options):
198
+ fs.die("CONSENT_LABEL_INVALID — two options of the %s decision carry the same label" % item["key"], 6)
199
+ for option in options:
200
+ label = option["label"]
201
+ if option["kind"] == "command":
202
+ why = setup_command_why(root, label) if item["key"] == "memory" else \
203
+ "only the memory decision offers a command"
204
+ elif not 0 < len(label) <= labels.LABEL_MAX or not label.isprintable():
205
+ why = "a choice label is 1 to %d printable characters" % labels.LABEL_MAX
206
+ elif label.startswith(("python3", "git", "npx")) or labels.label_argv(label) \
207
+ or labels.validate_label(label) is None:
208
+ why = "a choice label never reads as a command"
209
+ elif item["key"] == "verify" and option["value"] not in SETUP_VALUES["verify"] \
210
+ and option["value"] not in label.split("`")[1:-1:2]:
211
+ why = "a verify command is shown verbatim in its label, inside backticks"
212
+ else:
213
+ why = None
214
+ if why:
215
+ fs.die("CONSENT_LABEL_INVALID — %s: %s" % (why, label[:120]), 6)
216
+
217
+
218
+ def setup_open(args, root, rt=rr_runtime.Runtime()):
219
+ """decision open --kind setup --spec FILE|-: every check runs for every decision of the spec before the first
220
+ write, then all are opened in the spec's order, or none (R11). The caller sends the one dialog."""
221
+ if args.commands or args.spec is None:
222
+ fs.die("decision open --kind setup takes --spec FILE|- and no --command", 2)
223
+ spec = read_setup_spec(args.spec)
224
+ why = setup_spec_why(spec)
225
+ if why:
226
+ fs.die("bad spec — %s" % why, 2)
227
+ decisions = spec["decisions"]
228
+ check_setup_labels(root, decisions)
229
+ if not dialog.dialog_on(root, runtime=rt.name):
230
+ fs.die("the dialog is off here (.ai/policies/dialog.json, the runtime's dialog tool, a session id): "
231
+ "suggest the commands; the human runs them outside the agent")
232
+ data = rr_ledger.read_decisions(root)
233
+ questions = [item["question"].strip()[:dialog.QUESTION_MAX] for item in decisions] # as open_decision stores them
234
+ if len(set(questions)) != len(questions):
235
+ fs.die("DECISION_DUPLICATE — two decisions of the spec carry one question text", 6)
236
+ twin = next((d for d in data["open"] if d.get("question") in questions), None)
237
+ if twin:
238
+ fs.die("DECISION_DUPLICATE — %s is open with this very question; settle it, or 'state.py decision fallback "
239
+ "--id %s --why …', before opening it again" % (twin.get("id"), twin.get("id")), 6)
240
+ open_questions = sum(len(dialog.dialog_questions(d)) for d in data["open"])
241
+ if open_questions + len(decisions) > dialog.DECISIONS_MAX:
242
+ fs.die("DECISION_LIMIT — %d questions are open and the spec adds %d; one dialog call carries %d"
243
+ % (open_questions, len(decisions), dialog.DECISIONS_MAX), 6)
244
+ state = task.load(root, required=False, claim=False, rt=rt)
245
+ state = state if state and task.is_v7(state) else None
246
+ opened = []
247
+ for item in decisions:
248
+ options = [dict(o, effect="set") if o["kind"] == "choice" else o for o in item["options"]]
249
+ decision, why = dialog.open_decision(root, state, "setup", item["header"], item["question"], options,
250
+ "decision open --kind setup", rt=rt, setup_key=item["key"])
251
+ if decision is None:
252
+ for done in opened: # a race since the checks above: none of this call stays open
253
+ dialog.settle_decision(root, done["id"], "withdrawn")
254
+ fs.die(why, 6)
255
+ opened.append(decision)
256
+ dialog.print_dialog(opened, rt=rt)
257
+
258
+
86
259
  def decision_settle(args, root, decision, rt=rr_runtime.Runtime()):
87
260
  """(b) apply what the recorder wrote — a choice pick or a decline; (c) a
88
261
  human in a terminal picks an option by number (R36.14)."""
@@ -112,6 +285,8 @@ def decision_settle(args, root, decision, rt=rr_runtime.Runtime()):
112
285
  dialog.settle_decision(root, decision["id"], "declined")
113
286
  print("%s settled: declined in the dialog (%s); nothing runs"
114
287
  % (decision["id"], mine[-1].get("why") or "no answer"))
288
+ if decision.get("kind") == "setup":
289
+ print("SETUP_PICK %s=" % decision.get("setup_key"))
115
290
  return
116
291
  live = [p for p in rr_ledger.live_picks(ledger, (dialog.dialog_on(root, runtime=rt.name) or {}).get("session"))
117
292
  if p.get("decision") == decision["id"]]
@@ -132,6 +307,8 @@ def apply_choice(root, decision, option, pick_id, by, via, rt=rr_runtime.Runtime
132
307
  root, rt=rt)
133
308
  dialog.settle_decision(root, decision["id"], effect, pick_id)
134
309
  print("%s settled: %s (%s)" % (decision["id"], option["label"], effect))
310
+ if decision.get("kind") == "setup":
311
+ print("SETUP_PICK %s=%s" % (decision.get("setup_key"), option.get("value", "")))
135
312
 
136
313
 
137
314
  def cmd_consent(args, root, rt=rr_runtime.Runtime()):
@@ -43,12 +43,23 @@ def subcommand(argv):
43
43
 
44
44
 
45
45
  def state_label_ok(argv):
46
- """approve | reject | profile set | verify --by | review --by | start --mode … --by."""
46
+ """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
48
+ once, as two words, in any order, and no other memory verb. hooks/guard/dialog.py keeps the same
49
+ rule; tests/test_dialog_contract.py pins the two equal."""
47
50
  rest = subcommand(argv)
48
51
  if rest[:1] in (["approve"], ["reject"]) or rest[:2] == ["profile", "set"]:
49
52
  return True
50
53
  if rest[:1] in (["verify"], ["review"]):
51
54
  return "--by" in rest
55
+ if rest[:2] == ["memory", "enable"]:
56
+ # The pick is the exact command the human read: no `--opt=value`, no abbreviation argparse would
57
+ # take, no positional, an option once, a value of its own kind (an author never starts with '-').
58
+ names, values = rest[2::2], rest[3::2]
59
+ kinds = {"--author": None, "--period": ("month", "quarter", "year"), "--index": ("none", "sqlite")}
60
+ return (len(names) == len(values) == len(set(names)) and set(names) <= set(kinds)
61
+ and all((v[:1] not in ("", "-")) if kinds[n] is None else v in kinds[n]
62
+ for n, v in zip(names, values)))
52
63
  return rest[:1] == ["start"] and "--mode" in rest and "--by" in rest
53
64
 
54
65
 
@@ -78,7 +89,8 @@ def validate_label(label):
78
89
  and state_label_ok(script[1]):
79
90
  return None
80
91
  return ("a label starts with git commit, git push <remote> <branch>, state.py approve|reject|"
81
- "profile set|verify --by|review --by|start --mode --by, or npx riskrails@<v> … --yes")
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")
82
94
 
83
95
 
84
96
  def human_name(root):