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.
Files changed (58) 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 +11 -11
  5. package/hooks/AGENTS.md +0 -6
  6. package/hooks/CLAUDE.md +0 -6
  7. package/hooks/guard/paths.py +48 -1406
  8. package/hooks/guard/paths_base.py +59 -0
  9. package/hooks/guard/paths_bash.py +176 -0
  10. package/hooks/guard/paths_classify.py +205 -0
  11. package/hooks/guard/paths_fs.py +137 -0
  12. package/hooks/guard/paths_human.py +105 -0
  13. package/hooks/guard/paths_interp.py +162 -0
  14. package/hooks/guard/paths_movers.py +393 -0
  15. package/hooks/guard/paths_shell.py +288 -0
  16. package/package.json +1 -1
  17. package/skills/AGENTS.md +0 -6
  18. package/skills/CLAUDE.md +0 -6
  19. package/skills/ai-init/templates/gitignore.snippet +2 -0
  20. package/skills/ai-task/memory_cleanup.py +213 -0
  21. package/skills/ai-task/memory_compact.py +25 -0
  22. package/skills/ai-task/memory_config.py +120 -0
  23. package/skills/ai-task/memory_export.py +246 -0
  24. package/skills/ai-task/memory_format.py +7 -3
  25. package/skills/ai-task/memory_hook.py +172 -0
  26. package/skills/ai-task/memory_io.py +122 -0
  27. package/skills/ai-task/memory_migrate.py +187 -0
  28. package/skills/ai-task/memory_packages.py +83 -0
  29. package/skills/ai-task/memory_risks.py +84 -0
  30. package/skills/ai-task/memory_status.py +76 -0
  31. package/skills/ai-task/memory_sync.py +20 -1481
  32. package/skills/ai-task/memory_verify.py +316 -0
  33. package/skills/ai-task/rr_state/cli.py +1 -0
  34. package/skills/ai-task/rr_state/close.py +2 -2
  35. package/skills/ai-task/rr_state/memory_cmd.py +84 -64
  36. package/skills/project-update/history/blobs/fa0cb370b024e1a30e1355912032657b08f609e47773a603c193b1e609b0f997 +8 -0
  37. package/skills/project-update/history/index.json +2 -1
  38. package/skills/project-update/rr_update/__init__.py +1 -0
  39. package/skills/project-update/rr_update/apply.py +188 -0
  40. package/skills/project-update/rr_update/blocks.py +38 -0
  41. package/skills/project-update/rr_update/build.py +96 -0
  42. package/skills/project-update/rr_update/cleanup.py +194 -0
  43. package/skills/project-update/rr_update/cli.py +102 -0
  44. package/skills/project-update/rr_update/files.py +106 -0
  45. package/skills/project-update/rr_update/gate.py +60 -0
  46. package/skills/project-update/rr_update/hints.py +128 -0
  47. package/skills/project-update/rr_update/history.py +117 -0
  48. package/skills/project-update/rr_update/kept.py +120 -0
  49. package/skills/project-update/rr_update/paths.py +108 -0
  50. package/skills/project-update/rr_update/plan.py +123 -0
  51. package/skills/project-update/rr_update/report.py +51 -0
  52. package/skills/project-update/rr_update/rules.py +178 -0
  53. package/skills/project-update/rr_update/shared.py +74 -0
  54. package/skills/project-update/rr_update/siblings.py +37 -0
  55. package/skills/project-update/rr_update/weights.py +171 -0
  56. package/skills/project-update/update.py +8 -1682
  57. package/skills/rr-setup/SKILL.md +4 -2
  58. 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 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,4 +1,4 @@
1
- """memory_cmd: the `memory` and `recall` commands over the memory_store and memory_sync modules -- the actions, the human's route, the risk record.
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
- """memory_sync and memory_store, imported only by the commands that use them."""
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 memory_sync # pylint: disable=import-outside-toplevel
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
- return memory_sync, memory_store
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(sync, root):
47
+ def memory_config(mem, root):
35
48
  try:
36
- return sync.load_config(root)
37
- except sync.MemoryError_ as exc:
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
- sync, store = memory_modules()
66
- cfg = memory_config(sync, root)
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
- sync, store = memory_modules()
98
+ mem, store = memory_modules()
86
99
  action = args.memory_action
87
100
  try:
88
- MEMORY_ACTIONS[action](args, root, sync, store, rt=rt)
89
- except sync.MemoryError_ as exc:
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, sync, _store, rt=rr_runtime.Runtime()):
94
- lines = sync.status_lines(root)
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, sync, _store, rt=rr_runtime.Runtime()):
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 = sync.enable(root, args.author, args.period, args.index)
106
- print("memory: packages mode on (%s); every teammate's next close exports" % sync.POLICY_REL)
107
- print("pre-push: %s" % hook)
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", sync.snippet_text()))
111
- print("\n".join(sync.status_lines(root)))
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, sync, _store, rt=rr_runtime.Runtime()):
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" % sync.disable(root))
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, sync, _store, rt=rr_runtime.Runtime()):
120
- cfg = memory_config(sync, root)
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 = sync.export(root, ids, cfg)
123
- for line in sync.record_lines(result):
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, sync, _store, rt=rr_runtime.Runtime()):
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 = sync.validate_range(root, sync.rev(root, old), sync.rev(root, new) or new, local=False)
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
- findings = sync.verify_fresh_clone(root)
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 = sync.verify(root, "all", [task.valid_task_id(t, "ID") for t in args.ids] or None)
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(sync.format_finding(f))
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, sync, _store, rt=rr_runtime.Runtime()):
157
- print("\n".join(sync.show(root, task.valid_task_id(args.id, "ID"), "json" if args.json else "md", args.entry)))
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, sync, store, rt=rr_runtime.Runtime()):
161
- cfg = memory_config(sync, root)
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, sync, store, rt=rr_runtime.Runtime()):
191
- cfg = memory_config(sync, root)
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 = sync.risk_add_files(root, args.title, where, args.label, args.what, args.likely, args.fix,
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 = sync.risk_data(args.title, where, args.label, args.what, args.likely, args.fix, args.status)
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
- sync.risk_close_files(root, args.rkey, args.status, args.why)
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 = sync.risk_data(last["title"], last.get("where"), last.get("label"), last.get("what"),
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 = sync.standalone_risk(root, cfg, data, rt.name)
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, sync, store, rt=rr_runtime.Runtime()):
233
- cfg = memory_config(sync, root)
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, sync, _store, rt=rr_runtime.Runtime()):
257
- cfg = memory_config(sync, root)
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 = sync.move_logs(root)
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(sync.migrate_apply(root, risks=args.risks)))
286
+ print("\n".join(mem.migrate.migrate_apply(root, risks=args.risks)))
267
287
  return
268
- report = sync.migrate_report(root)
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(sync.LOCAL_REL, "migration-report.md"), report.count("\n")))
290
+ % (os.path.join(mem.io.LOCAL_REL, "migration-report.md"), report.count("\n")))
271
291
 
272
292
 
273
- def memory_rebuild(_args, root, sync, store, rt=rr_runtime.Runtime()):
274
- cfg = memory_config(sync, root)
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, sync, _store, rt=rr_runtime.Runtime()):
279
- cfg = memory_config(sync, root)
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(sync.cleanup(root, "all" if args.all else [task.valid_task_id(t, "ID") for t in args.ids])))
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, sync, _store, rt=rr_runtime.Runtime()):
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(sync.restore(root, "all" if args.all else [task.valid_task_id(t, "ID") for t in args.ids])))
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, sync, _store, rt=rr_runtime.Runtime()):
294
- cfg = memory_config(sync, root)
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 = sync.pack(root, args.period)
298
- print("pack: %s (%d records; %d batch(es) removed)" % (os.path.join(sync.MEMORY_REL, target), count,
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, sync, _store, rt=rr_runtime.Runtime()):
322
+ def memory_redact(args, root, mem, _store, rt=rr_runtime.Runtime()):
303
323
  via = memory_human(root, "redact", rt=rt)
304
- rid, name = sync.redact(root, args.rid, args.field, args.why, by="%s via %s" % (os.environ.get("USER") or
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(sync.MEMORY_REL, name), rid))
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,