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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +22 -13
- package/bin/menu.js +116 -0
- package/bin/riskrails.js +119 -20
- package/hooks/AGENTS.md +0 -6
- package/hooks/CLAUDE.md +0 -6
- package/hooks/guard/dialog.py +14 -2
- package/hooks/guard/paths.py +48 -1406
- package/hooks/guard/paths_base.py +59 -0
- package/hooks/guard/paths_bash.py +176 -0
- package/hooks/guard/paths_classify.py +205 -0
- package/hooks/guard/paths_fs.py +137 -0
- package/hooks/guard/paths_human.py +105 -0
- package/hooks/guard/paths_interp.py +162 -0
- package/hooks/guard/paths_movers.py +393 -0
- package/hooks/guard/paths_shell.py +288 -0
- package/package.json +1 -1
- package/skills/AGENTS.md +0 -6
- package/skills/CLAUDE.md +0 -6
- package/skills/ai-init/scaffold-ai.sh +2 -1
- package/skills/ai-init/templates/.ai/policies/safety.md +4 -3
- package/skills/ai-init/templates/gitignore.snippet +2 -0
- package/skills/ai-task/memory_cleanup.py +213 -0
- package/skills/ai-task/memory_compact.py +25 -0
- package/skills/ai-task/memory_config.py +120 -0
- package/skills/ai-task/memory_export.py +246 -0
- package/skills/ai-task/memory_format.py +7 -3
- package/skills/ai-task/memory_hook.py +172 -0
- package/skills/ai-task/memory_io.py +122 -0
- package/skills/ai-task/memory_migrate.py +187 -0
- package/skills/ai-task/memory_packages.py +83 -0
- package/skills/ai-task/memory_risks.py +84 -0
- package/skills/ai-task/memory_status.py +76 -0
- package/skills/ai-task/memory_sync.py +20 -1481
- package/skills/ai-task/memory_verify.py +316 -0
- package/skills/ai-task/rr_state/cli.py +2 -1
- package/skills/ai-task/rr_state/close.py +2 -2
- package/skills/ai-task/rr_state/dialog_cmd.py +183 -6
- package/skills/ai-task/rr_state/labels.py +14 -2
- package/skills/ai-task/rr_state/memory_cmd.py +91 -68
- package/skills/project-update/history/blobs/8ec171f0fbdf38cc6996d496f01491fd1f21f04a435a118ad979b127a8be06f1 +85 -0
- package/skills/project-update/history/blobs/fa0cb370b024e1a30e1355912032657b08f609e47773a603c193b1e609b0f997 +8 -0
- package/skills/project-update/history/index.json +4 -2
- package/skills/project-update/rr_update/__init__.py +1 -0
- package/skills/project-update/rr_update/apply.py +188 -0
- package/skills/project-update/rr_update/blocks.py +38 -0
- package/skills/project-update/rr_update/build.py +96 -0
- package/skills/project-update/rr_update/cleanup.py +194 -0
- package/skills/project-update/rr_update/cli.py +102 -0
- package/skills/project-update/rr_update/files.py +106 -0
- package/skills/project-update/rr_update/gate.py +60 -0
- package/skills/project-update/rr_update/hints.py +128 -0
- package/skills/project-update/rr_update/history.py +117 -0
- package/skills/project-update/rr_update/kept.py +120 -0
- package/skills/project-update/rr_update/paths.py +108 -0
- package/skills/project-update/rr_update/plan.py +123 -0
- package/skills/project-update/rr_update/report.py +51 -0
- package/skills/project-update/rr_update/rules.py +178 -0
- package/skills/project-update/rr_update/shared.py +74 -0
- package/skills/project-update/rr_update/siblings.py +37 -0
- package/skills/project-update/rr_update/weights.py +171 -0
- package/skills/project-update/update.py +8 -1682
- package/skills/rr-setup/SKILL.md +79 -21
- package/skills/rr-setup/scaffold_ai.py +8 -2
- 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
|
|
168
|
+
import memory_export # pylint: disable=import-outside-toplevel
|
|
169
169
|
except ImportError:
|
|
170
170
|
return
|
|
171
|
-
|
|
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
|
|
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
|
|
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
|
|
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):
|