riskrails 4.1.0 → 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 +11 -11
- package/hooks/AGENTS.md +0 -6
- package/hooks/CLAUDE.md +0 -6
- 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/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 +1 -0
- package/skills/ai-task/rr_state/close.py +2 -2
- package/skills/ai-task/rr_state/memory_cmd.py +84 -64
- package/skills/project-update/history/blobs/fa0cb370b024e1a30e1355912032657b08f609e47773a603c193b1e609b0f997 +8 -0
- package/skills/project-update/history/index.json +2 -1
- 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 +4 -2
- package/skills/rr-setup/setup.py +13 -12
|
@@ -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
|
|
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,4 +1,4 @@
|
|
|
1
|
-
"""memory_cmd: the `memory` and `recall` commands over the memory_store and
|
|
1
|
+
"""memory_cmd: the `memory` and `recall` commands over the memory_store module and the owner modules memory_io, memory_config, memory_verify, memory_hook, memory_risks, memory_cleanup, memory_migrate, memory_export and memory_status -- the actions, the human's route, the risk record.
|
|
2
2
|
|
|
3
3
|
shell: memory_modules, memory_config, memory_human, memory_confirm, cmd_recall, cmd_memory, memory_status, memory_enable, memory_disable, memory_export, memory_verify, memory_show, memory_search, risk_scope, memory_risk, memory_risks, memory_migrate, memory_rebuild, memory_cleanup, memory_restore, memory_pack, memory_redact
|
|
4
4
|
core: MEMORY_ACTIONS
|
|
@@ -9,6 +9,7 @@ import json
|
|
|
9
9
|
import os
|
|
10
10
|
import shlex
|
|
11
11
|
import sys
|
|
12
|
+
import types
|
|
12
13
|
|
|
13
14
|
from rr_state import fs, journal, task
|
|
14
15
|
from rr_state import dialog as rr_dialog
|
|
@@ -22,19 +23,31 @@ except ImportError: # an old install that shipped state.py
|
|
|
22
23
|
|
|
23
24
|
|
|
24
25
|
def memory_modules():
|
|
25
|
-
"""
|
|
26
|
+
"""The owner modules of the memory commands as one namespace (mem.io, mem.config, mem.verify, mem.hook,
|
|
27
|
+
mem.risks, mem.cleanup, mem.migrate, mem.export, mem.status -- each name is read on its owner at call time)
|
|
28
|
+
and memory_store, imported only by the commands that use them."""
|
|
26
29
|
try:
|
|
27
30
|
import memory_store # pylint: disable=import-outside-toplevel
|
|
28
|
-
import
|
|
31
|
+
import memory_cleanup as cleanup # pylint: disable=import-outside-toplevel
|
|
32
|
+
import memory_config as config # pylint: disable=import-outside-toplevel
|
|
33
|
+
import memory_export as export # pylint: disable=import-outside-toplevel
|
|
34
|
+
import memory_hook as hook # pylint: disable=import-outside-toplevel
|
|
35
|
+
import memory_io as io # pylint: disable=import-outside-toplevel
|
|
36
|
+
import memory_migrate as migrate # pylint: disable=import-outside-toplevel
|
|
37
|
+
import memory_risks as risks # pylint: disable=import-outside-toplevel
|
|
38
|
+
import memory_status as status # pylint: disable=import-outside-toplevel
|
|
39
|
+
import memory_verify as verify # pylint: disable=import-outside-toplevel
|
|
29
40
|
except ImportError as exc:
|
|
30
41
|
fs.die("the memory modules are not installed beside state.py (%s)" % exc, 2)
|
|
31
|
-
|
|
42
|
+
mem = types.SimpleNamespace(cleanup=cleanup, config=config, export=export, hook=hook, io=io, migrate=migrate,
|
|
43
|
+
risks=risks, status=status, verify=verify)
|
|
44
|
+
return mem, memory_store
|
|
32
45
|
|
|
33
46
|
|
|
34
|
-
def memory_config(
|
|
47
|
+
def memory_config(mem, root):
|
|
35
48
|
try:
|
|
36
|
-
return
|
|
37
|
-
except
|
|
49
|
+
return mem.config.load_config(root)
|
|
50
|
+
except mem.io.MemoryError_ as exc:
|
|
38
51
|
fs.die(str(exc), exc.code)
|
|
39
52
|
return None
|
|
40
53
|
|
|
@@ -62,8 +75,8 @@ def memory_confirm(cfg, name, what):
|
|
|
62
75
|
def cmd_recall(args, root, rt=rr_runtime.Runtime()):
|
|
63
76
|
"""R10: the open risks, residual findings, decisions, scope changes and task
|
|
64
77
|
one-liners that touch these files, within the budget. Read-only."""
|
|
65
|
-
|
|
66
|
-
cfg = memory_config(
|
|
78
|
+
mem, store = memory_modules()
|
|
79
|
+
cfg = memory_config(mem, root)
|
|
67
80
|
files = fs._split_files(args.files)
|
|
68
81
|
if not files:
|
|
69
82
|
fs.die("recall needs --files \"a,b,dir/**\"", 2)
|
|
@@ -82,45 +95,49 @@ def cmd_recall(args, root, rt=rr_runtime.Runtime()):
|
|
|
82
95
|
|
|
83
96
|
|
|
84
97
|
def cmd_memory(args, root, rt=rr_runtime.Runtime()):
|
|
85
|
-
|
|
98
|
+
mem, store = memory_modules()
|
|
86
99
|
action = args.memory_action
|
|
87
100
|
try:
|
|
88
|
-
MEMORY_ACTIONS[action](args, root,
|
|
89
|
-
except
|
|
101
|
+
MEMORY_ACTIONS[action](args, root, mem, store, rt=rt)
|
|
102
|
+
except mem.io.MemoryError_ as exc:
|
|
90
103
|
fs.die(str(exc), exc.code)
|
|
91
104
|
|
|
92
105
|
|
|
93
|
-
def memory_status(args, root,
|
|
94
|
-
lines =
|
|
106
|
+
def memory_status(args, root, mem, _store, rt=rr_runtime.Runtime()):
|
|
107
|
+
lines = mem.status.status_lines(root)
|
|
95
108
|
if args.json:
|
|
96
109
|
print(json.dumps(dict(line.split(": ", 1) for line in lines), ensure_ascii=False, sort_keys=True))
|
|
97
110
|
return
|
|
98
111
|
print("\n".join(lines))
|
|
99
112
|
|
|
100
113
|
|
|
101
|
-
def memory_enable(args, root,
|
|
114
|
+
def memory_enable(args, root, mem, _store, rt=rr_runtime.Runtime()):
|
|
102
115
|
if memory_human(root, "enable", allow_dialog=True, rt=rt) == "dialog":
|
|
103
116
|
pick = rr_dialog.pick_data(rr_dialog.DIALOG_PICK[0])
|
|
104
117
|
print("memory: via dialog (consent %s, decision %s)" % (pick["consent"], pick["decision"]))
|
|
105
|
-
hook =
|
|
106
|
-
print("memory: packages mode on (%s); every teammate's next close exports" %
|
|
107
|
-
|
|
118
|
+
hook = mem.hook.enable(root, args.author, args.period, args.index, args.share)
|
|
119
|
+
print("memory: packages mode on (%s); every teammate's next close exports" % mem.io.POLICY_REL)
|
|
120
|
+
if hook == "local":
|
|
121
|
+
print("record: local — .ai/memory/ and .ai/reports/ are git-ignored; 'state.py memory enable --share git' "
|
|
122
|
+
"in your own terminal shares them through git and checks pushes")
|
|
123
|
+
else:
|
|
124
|
+
print("pre-push: %s" % hook)
|
|
108
125
|
if hook in ("foreign", "hookspath"):
|
|
109
126
|
print("not written — %s. Add these lines to your own pre-push hook:\n%s" % (
|
|
110
|
-
"core.hooksPath is set" if hook == "hookspath" else "a pre-push hook exists",
|
|
111
|
-
print("\n".join(
|
|
127
|
+
"core.hooksPath is set" if hook == "hookspath" else "a pre-push hook exists", mem.hook.snippet_text()))
|
|
128
|
+
print("\n".join(mem.status.status_lines(root)))
|
|
112
129
|
|
|
113
130
|
|
|
114
|
-
def memory_disable(_args, root,
|
|
131
|
+
def memory_disable(_args, root, mem, _store, rt=rr_runtime.Runtime()):
|
|
115
132
|
memory_human(root, "disable", rt=rt)
|
|
116
|
-
print("memory: files mode; packages and local data kept; pre-push: %s" %
|
|
133
|
+
print("memory: files mode; packages and local data kept; pre-push: %s" % mem.hook.disable(root))
|
|
117
134
|
|
|
118
135
|
|
|
119
|
-
def memory_export(args, root,
|
|
120
|
-
cfg = memory_config(
|
|
136
|
+
def memory_export(args, root, mem, _store, rt=rr_runtime.Runtime()):
|
|
137
|
+
cfg = memory_config(mem, root)
|
|
121
138
|
ids = "pending" if args.pending or not args.ids else [task.valid_task_id(t, "ID") for t in args.ids]
|
|
122
|
-
result =
|
|
123
|
-
for line in
|
|
139
|
+
result = mem.export.export(root, ids, cfg)
|
|
140
|
+
for line in mem.export.record_lines(result):
|
|
124
141
|
print(line)
|
|
125
142
|
print("exported: +%d entries%s" % (result.added, "" if result.added else " — nothing pending"))
|
|
126
143
|
for task_id, why in result.failures:
|
|
@@ -129,36 +146,39 @@ def memory_export(args, root, sync, _store, rt=rr_runtime.Runtime()):
|
|
|
129
146
|
sys.exit(1)
|
|
130
147
|
|
|
131
148
|
|
|
132
|
-
def memory_verify(args, root,
|
|
149
|
+
def memory_verify(args, root, mem, _store, rt=rr_runtime.Runtime()):
|
|
133
150
|
stats = None
|
|
134
151
|
if args.range:
|
|
135
152
|
if ".." not in args.range:
|
|
136
153
|
fs.die("--range takes OLD..NEW", 2)
|
|
137
154
|
old, new = args.range.split("..", 1)
|
|
138
|
-
findings, stats =
|
|
155
|
+
findings, stats = mem.verify.validate_range(root, mem.verify.rev(root, old), mem.verify.rev(root, new) or new, local=False)
|
|
139
156
|
findings = findings or []
|
|
140
157
|
elif args.fresh_clone:
|
|
141
|
-
|
|
158
|
+
if not mem.io.shared(root):
|
|
159
|
+
fs.die("the record is local (.ai/memory/ is git-ignored): a fresh clone has none — "
|
|
160
|
+
"'state.py memory enable --share git' shares it", 2)
|
|
161
|
+
findings = mem.verify.verify_fresh_clone(root)
|
|
142
162
|
else:
|
|
143
|
-
findings =
|
|
163
|
+
findings = mem.verify.verify(root, "all", [task.valid_task_id(t, "ID") for t in args.ids] or None)
|
|
144
164
|
failing = [f for f in findings if not f.check.endswith("-warn")]
|
|
145
165
|
if args.json:
|
|
146
166
|
print(json.dumps({"findings": [f._asdict() for f in findings], "ok": not failing, "stats": stats},
|
|
147
167
|
ensure_ascii=False, sort_keys=True))
|
|
148
168
|
else:
|
|
149
169
|
for f in findings:
|
|
150
|
-
print(
|
|
170
|
+
print(mem.verify.format_finding(f))
|
|
151
171
|
print("memory verify: %s" % ("ok" if not failing else "%d finding(s)" % len(failing)))
|
|
152
172
|
if failing:
|
|
153
173
|
sys.exit(1)
|
|
154
174
|
|
|
155
175
|
|
|
156
|
-
def memory_show(args, root,
|
|
157
|
-
print("\n".join(
|
|
176
|
+
def memory_show(args, root, mem, _store, rt=rr_runtime.Runtime()):
|
|
177
|
+
print("\n".join(mem.status.show(root, task.valid_task_id(args.id, "ID"), "json" if args.json else "md", args.entry)))
|
|
158
178
|
|
|
159
179
|
|
|
160
|
-
def memory_search(args, root,
|
|
161
|
-
cfg = memory_config(
|
|
180
|
+
def memory_search(args, root, mem, store, rt=rr_runtime.Runtime()):
|
|
181
|
+
cfg = memory_config(mem, root)
|
|
162
182
|
filters = {"kind": args.kind, "by": args.by, "task": args.task, "since": args.since, "until": args.until,
|
|
163
183
|
"files": fs._split_files(args.file) or None}
|
|
164
184
|
history = store.open_history(root, cfg)
|
|
@@ -187,8 +207,8 @@ def risk_scope(state):
|
|
|
187
207
|
6)
|
|
188
208
|
|
|
189
209
|
|
|
190
|
-
def memory_risk(args, root,
|
|
191
|
-
cfg = memory_config(
|
|
210
|
+
def memory_risk(args, root, mem, store, rt=rr_runtime.Runtime()):
|
|
211
|
+
cfg = memory_config(mem, root)
|
|
192
212
|
state = task.in_flight(root, rt=rt)
|
|
193
213
|
if args.risk_action == "add":
|
|
194
214
|
where = fs._split_files(args.where)
|
|
@@ -199,17 +219,17 @@ def memory_risk(args, root, sync, store, rt=rr_runtime.Runtime()):
|
|
|
199
219
|
if not cfg.packages:
|
|
200
220
|
risk_scope(state)
|
|
201
221
|
found = "%s, by %s" % (fs.now()[:10], state["task_id"] if state else "no task")
|
|
202
|
-
rkey =
|
|
222
|
+
rkey = mem.risks.risk_add_files(root, args.title, where, args.label, args.what, args.likely, args.fix,
|
|
203
223
|
args.status, found)
|
|
204
224
|
print("risk: %s added to .ai/project/known-risks.md" % rkey)
|
|
205
225
|
return
|
|
206
|
-
data =
|
|
226
|
+
data = mem.risks.risk_data(args.title, where, args.label, args.what, args.likely, args.fix, args.status)
|
|
207
227
|
else:
|
|
208
228
|
if args.status not in ("fixed", "accepted", "ticketed"):
|
|
209
229
|
fs.die("--status takes fixed, accepted or ticketed", 2)
|
|
210
230
|
if not cfg.packages:
|
|
211
231
|
risk_scope(state)
|
|
212
|
-
|
|
232
|
+
mem.risks.risk_close_files(root, args.rkey, args.status, args.why)
|
|
213
233
|
print("risk: %s is %s" % (args.rkey, args.status))
|
|
214
234
|
return
|
|
215
235
|
history = store.FileHistory(root, cfg)
|
|
@@ -217,7 +237,7 @@ def memory_risk(args, root, sync, store, rt=rr_runtime.Runtime()):
|
|
|
217
237
|
if not found:
|
|
218
238
|
fs.die("no risk %s ('state.py memory risks --status all')" % args.rkey, 1)
|
|
219
239
|
last = found[0]
|
|
220
|
-
data =
|
|
240
|
+
data = mem.risks.risk_data(last["title"], last.get("where"), last.get("label"), last.get("what"),
|
|
221
241
|
last.get("likelihood"), last.get("fix"), args.status, args.why, last["rid"])
|
|
222
242
|
if state:
|
|
223
243
|
state = task.load(root, rt=rt)
|
|
@@ -225,12 +245,12 @@ def memory_risk(args, root, sync, store, rt=rr_runtime.Runtime()):
|
|
|
225
245
|
task.save(root, state)
|
|
226
246
|
print("risk: %s recorded in %s's journal (exported at close)" % (data["rkey"], state["task_id"]))
|
|
227
247
|
return
|
|
228
|
-
entry =
|
|
248
|
+
entry = mem.risks.standalone_risk(root, cfg, data, rt.name)
|
|
229
249
|
print("risk: %s recorded (%s)" % (data["rkey"], entry["rid"]))
|
|
230
250
|
|
|
231
251
|
|
|
232
|
-
def memory_risks(args, root,
|
|
233
|
-
cfg = memory_config(
|
|
252
|
+
def memory_risks(args, root, mem, store, rt=rr_runtime.Runtime()):
|
|
253
|
+
cfg = memory_config(mem, root)
|
|
234
254
|
risks = store.open_history(root, cfg).risks(args.status)
|
|
235
255
|
if args.json:
|
|
236
256
|
for e in risks:
|
|
@@ -253,57 +273,57 @@ def memory_risks(args, root, sync, store, rt=rr_runtime.Runtime()):
|
|
|
253
273
|
e.get("title"), e.get("rkey")))
|
|
254
274
|
|
|
255
275
|
|
|
256
|
-
def memory_migrate(args, root,
|
|
257
|
-
cfg = memory_config(
|
|
276
|
+
def memory_migrate(args, root, mem, _store, rt=rr_runtime.Runtime()):
|
|
277
|
+
cfg = memory_config(mem, root)
|
|
258
278
|
if args.move_logs:
|
|
259
279
|
memory_human(root, "migrate --move-logs", rt=rt)
|
|
260
280
|
memory_confirm(cfg, args.confirm_delete, "migrate --move-logs")
|
|
261
|
-
moved, command =
|
|
281
|
+
moved, command = mem.migrate.move_logs(root)
|
|
262
282
|
print("moved %d committed log(s) into .ai/state/logs/<id>/legacy/; now, as its own commit (C3):\n %s"
|
|
263
283
|
% (len(moved), command))
|
|
264
284
|
return
|
|
265
285
|
if args.apply:
|
|
266
|
-
print("\n".join(
|
|
286
|
+
print("\n".join(mem.migrate.migrate_apply(root, risks=args.risks)))
|
|
267
287
|
return
|
|
268
|
-
report =
|
|
288
|
+
report = mem.migrate.migrate_report(root)
|
|
269
289
|
print("report: %s (%d lines) — read it, then 'state.py memory migrate --apply'"
|
|
270
|
-
% (os.path.join(
|
|
290
|
+
% (os.path.join(mem.io.LOCAL_REL, "migration-report.md"), report.count("\n")))
|
|
271
291
|
|
|
272
292
|
|
|
273
|
-
def memory_rebuild(_args, root,
|
|
274
|
-
cfg = memory_config(
|
|
293
|
+
def memory_rebuild(_args, root, mem, store, rt=rr_runtime.Runtime()):
|
|
294
|
+
cfg = memory_config(mem, root)
|
|
275
295
|
print("index: rebuilt from %d sources (%s)" % (store.SqliteHistory(root, cfg).rebuild(), store.INDEX_REL))
|
|
276
296
|
|
|
277
297
|
|
|
278
|
-
def memory_cleanup(args, root,
|
|
279
|
-
cfg = memory_config(
|
|
298
|
+
def memory_cleanup(args, root, mem, _store, rt=rr_runtime.Runtime()):
|
|
299
|
+
cfg = memory_config(mem, root)
|
|
280
300
|
memory_human(root, "cleanup", rt=rt)
|
|
281
301
|
memory_confirm(cfg, args.confirm_delete, "cleanup")
|
|
282
302
|
if not args.all and not args.ids:
|
|
283
303
|
fs.die("memory cleanup takes IDs or --all", 2)
|
|
284
|
-
print("\n".join(
|
|
304
|
+
print("\n".join(mem.cleanup.cleanup(root, "all" if args.all else [task.valid_task_id(t, "ID") for t in args.ids])))
|
|
285
305
|
|
|
286
306
|
|
|
287
|
-
def memory_restore(args, root,
|
|
307
|
+
def memory_restore(args, root, mem, _store, rt=rr_runtime.Runtime()):
|
|
288
308
|
if not args.all and not args.ids:
|
|
289
309
|
fs.die("memory restore takes IDs or --all", 2)
|
|
290
|
-
print("\n".join(
|
|
310
|
+
print("\n".join(mem.cleanup.restore(root, "all" if args.all else [task.valid_task_id(t, "ID") for t in args.ids])))
|
|
291
311
|
|
|
292
312
|
|
|
293
|
-
def memory_pack(args, root,
|
|
294
|
-
cfg = memory_config(
|
|
313
|
+
def memory_pack(args, root, mem, _store, rt=rr_runtime.Runtime()):
|
|
314
|
+
cfg = memory_config(mem, root)
|
|
295
315
|
memory_human(root, "pack", rt=rt)
|
|
296
316
|
memory_confirm(cfg, args.confirm_delete, "pack")
|
|
297
|
-
target, removed, count =
|
|
298
|
-
print("pack: %s (%d records; %d batch(es) removed)" % (os.path.join(
|
|
317
|
+
target, removed, count = mem.cleanup.pack(root, args.period)
|
|
318
|
+
print("pack: %s (%d records; %d batch(es) removed)" % (os.path.join(mem.io.MEMORY_REL, target), count,
|
|
299
319
|
len(removed)))
|
|
300
320
|
|
|
301
321
|
|
|
302
|
-
def memory_redact(args, root,
|
|
322
|
+
def memory_redact(args, root, mem, _store, rt=rr_runtime.Runtime()):
|
|
303
323
|
via = memory_human(root, "redact", rt=rt)
|
|
304
|
-
rid, name =
|
|
324
|
+
rid, name = mem.cleanup.redact(root, args.rid, args.field, args.why, by="%s via %s" % (os.environ.get("USER") or
|
|
305
325
|
"unknown", via))
|
|
306
|
-
print("redacted: %s in %s (new rid %s)" % (args.field, os.path.join(
|
|
326
|
+
print("redacted: %s in %s (new rid %s)" % (args.field, os.path.join(mem.io.MEMORY_REL, name), rid))
|
|
307
327
|
|
|
308
328
|
|
|
309
329
|
MEMORY_ACTIONS = {"status": memory_status, "enable": memory_enable, "disable": memory_disable,
|