riskrails 4.0.1 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/README.md +22 -13
  5. package/bin/menu.js +116 -0
  6. package/bin/riskrails.js +119 -20
  7. package/hooks/AGENTS.md +0 -6
  8. package/hooks/CLAUDE.md +0 -6
  9. package/hooks/guard/dialog.py +14 -2
  10. package/hooks/guard/paths.py +48 -1406
  11. package/hooks/guard/paths_base.py +59 -0
  12. package/hooks/guard/paths_bash.py +176 -0
  13. package/hooks/guard/paths_classify.py +205 -0
  14. package/hooks/guard/paths_fs.py +137 -0
  15. package/hooks/guard/paths_human.py +105 -0
  16. package/hooks/guard/paths_interp.py +162 -0
  17. package/hooks/guard/paths_movers.py +393 -0
  18. package/hooks/guard/paths_shell.py +288 -0
  19. package/package.json +1 -1
  20. package/skills/AGENTS.md +0 -6
  21. package/skills/CLAUDE.md +0 -6
  22. package/skills/ai-init/scaffold-ai.sh +2 -1
  23. package/skills/ai-init/templates/.ai/policies/safety.md +4 -3
  24. package/skills/ai-init/templates/gitignore.snippet +2 -0
  25. package/skills/ai-task/memory_cleanup.py +213 -0
  26. package/skills/ai-task/memory_compact.py +25 -0
  27. package/skills/ai-task/memory_config.py +120 -0
  28. package/skills/ai-task/memory_export.py +246 -0
  29. package/skills/ai-task/memory_format.py +7 -3
  30. package/skills/ai-task/memory_hook.py +172 -0
  31. package/skills/ai-task/memory_io.py +122 -0
  32. package/skills/ai-task/memory_migrate.py +187 -0
  33. package/skills/ai-task/memory_packages.py +83 -0
  34. package/skills/ai-task/memory_risks.py +84 -0
  35. package/skills/ai-task/memory_status.py +76 -0
  36. package/skills/ai-task/memory_sync.py +20 -1481
  37. package/skills/ai-task/memory_verify.py +316 -0
  38. package/skills/ai-task/rr_state/cli.py +2 -1
  39. package/skills/ai-task/rr_state/close.py +2 -2
  40. package/skills/ai-task/rr_state/dialog_cmd.py +183 -6
  41. package/skills/ai-task/rr_state/labels.py +14 -2
  42. package/skills/ai-task/rr_state/memory_cmd.py +91 -68
  43. package/skills/project-update/history/blobs/8ec171f0fbdf38cc6996d496f01491fd1f21f04a435a118ad979b127a8be06f1 +85 -0
  44. package/skills/project-update/history/blobs/fa0cb370b024e1a30e1355912032657b08f609e47773a603c193b1e609b0f997 +8 -0
  45. package/skills/project-update/history/index.json +4 -2
  46. package/skills/project-update/rr_update/__init__.py +1 -0
  47. package/skills/project-update/rr_update/apply.py +188 -0
  48. package/skills/project-update/rr_update/blocks.py +38 -0
  49. package/skills/project-update/rr_update/build.py +96 -0
  50. package/skills/project-update/rr_update/cleanup.py +194 -0
  51. package/skills/project-update/rr_update/cli.py +102 -0
  52. package/skills/project-update/rr_update/files.py +106 -0
  53. package/skills/project-update/rr_update/gate.py +60 -0
  54. package/skills/project-update/rr_update/hints.py +128 -0
  55. package/skills/project-update/rr_update/history.py +117 -0
  56. package/skills/project-update/rr_update/kept.py +120 -0
  57. package/skills/project-update/rr_update/paths.py +108 -0
  58. package/skills/project-update/rr_update/plan.py +123 -0
  59. package/skills/project-update/rr_update/report.py +51 -0
  60. package/skills/project-update/rr_update/rules.py +178 -0
  61. package/skills/project-update/rr_update/shared.py +74 -0
  62. package/skills/project-update/rr_update/siblings.py +37 -0
  63. package/skills/project-update/rr_update/weights.py +171 -0
  64. package/skills/project-update/update.py +8 -1682
  65. package/skills/rr-setup/SKILL.md +79 -21
  66. package/skills/rr-setup/scaffold_ai.py +8 -2
  67. package/skills/rr-setup/setup.py +371 -4
@@ -0,0 +1,120 @@
1
+ """The configuration of packages mode: the policy file's schema and defaults, MemoryConfig, loading and
2
+ checking .ai/policies/memory.json, and the mode a project runs in."""
3
+ import contextlib
4
+ import os
5
+ import re
6
+
7
+ import memory_format as mf
8
+ import memory_io as mio
9
+
10
+
11
+ SCHEMA = 1
12
+ DEFAULTS = {"schema": 1, "mode": "files", "period": "quarter", "package_max_bytes": 1048576, "cleanup": False,
13
+ "excerpt_max_bytes": 1024, "keep_rules": 1, "recall": {"budget_tokens": 1500, "max_items": 40}}
14
+
15
+
16
+ # ------------------------------------------------------------------ configuration (§D11, §I6)
17
+ def author_slug(name):
18
+ slug = re.sub(r"[^a-z0-9-]+", "-", (name or "").casefold()).strip("-")[:32].strip("-")
19
+ return slug if slug and mf.AUTHOR_RE.match(slug) else "unknown"
20
+
21
+
22
+ class MemoryConfig:
23
+ """The project policy (.ai/policies/memory.json) and the developer's own
24
+ choices (.ai/local/memory/config.json)."""
25
+
26
+ def __init__(self, root, policy, local, present):
27
+ self.root, self.policy, self.local, self.present = root, policy, local, present
28
+
29
+ def __getattr__(self, name):
30
+ if name in ("mode", "period", "package_max_bytes", "cleanup", "excerpt_max_bytes", "keep_rules"):
31
+ return self.policy.get(name, DEFAULTS[name])
32
+ raise AttributeError(name)
33
+
34
+ @property
35
+ def recall_budget(self):
36
+ return int((self.policy.get("recall") or {}).get("budget_tokens", 1500))
37
+
38
+ @property
39
+ def recall_max_items(self):
40
+ return int((self.policy.get("recall") or {}).get("max_items", 40))
41
+
42
+ @property
43
+ def author(self):
44
+ return self.local.get("author") or author_slug(os.environ.get("USER") or os.environ.get("USERNAME"))
45
+
46
+ @property
47
+ def index(self):
48
+ return self.local.get("index") or "none"
49
+
50
+ @property
51
+ def packages(self):
52
+ return self.mode == "packages"
53
+
54
+
55
+ def load_config(root):
56
+ path = os.path.join(root, mio.POLICY_REL)
57
+ present = os.path.exists(path)
58
+ policy = dict(DEFAULTS)
59
+ if present:
60
+ try:
61
+ loaded = mio.read_json(path)
62
+ except (OSError, ValueError) as exc:
63
+ raise mio.MemoryError_("%s is not valid JSON (%s): fix it, or 'state.py memory disable'"
64
+ % (mio.POLICY_REL, exc)) from exc
65
+ if not isinstance(loaded, dict):
66
+ raise mio.MemoryError_("%s must be a JSON object" % mio.POLICY_REL)
67
+ schema = loaded.get("schema", 1)
68
+ if isinstance(schema, int) and not isinstance(schema, bool) and schema > SCHEMA:
69
+ raise mio.MemoryError_("%s has schema %s; this state.py knows %d — update the plugin"
70
+ % (mio.POLICY_REL, schema, SCHEMA))
71
+ policy.update(loaded)
72
+ check_policy(policy)
73
+ local = {}
74
+ with contextlib.suppress(OSError, ValueError):
75
+ found = mio.read_json(os.path.join(root, mio.LOCAL_REL, "config.json"))
76
+ if isinstance(found, dict):
77
+ local = found
78
+ where = os.path.join(mio.LOCAL_REL, "config.json")
79
+ if local.get("author") is not None and not (isinstance(local["author"], str)
80
+ and mf.AUTHOR_RE.match(local["author"])):
81
+ raise mio.MemoryError_("%s: author %r is not a slug of [a-z0-9-]" % (where, local["author"]))
82
+ if local.get("index", "none") not in ("none", "sqlite"):
83
+ raise mio.MemoryError_("%s: index takes none or sqlite" % where)
84
+ return MemoryConfig(root, policy, local, present)
85
+
86
+
87
+ def check_policy(policy):
88
+ """Every key of memory.json with its type (§I6): a wrong one is a refusal
89
+ naming the key, never a traceback inside close."""
90
+ def is_int(value, low):
91
+ return isinstance(value, int) and not isinstance(value, bool) and value >= low
92
+ problems = []
93
+ if not is_int(policy.get("schema"), 1):
94
+ problems.append("schema must be an integer (1)")
95
+ if policy.get("mode") not in ("files", "packages"):
96
+ problems.append('mode must be "files" or "packages"')
97
+ if policy.get("period") not in mf.PERIODS:
98
+ problems.append("period must be month, quarter or year")
99
+ if not is_int(policy.get("package_max_bytes"), 1024):
100
+ problems.append("package_max_bytes must be an integer of at least 1024")
101
+ if not isinstance(policy.get("cleanup"), bool):
102
+ problems.append("cleanup must be true or false")
103
+ if not is_int(policy.get("excerpt_max_bytes"), 0):
104
+ problems.append("excerpt_max_bytes must be an integer >= 0")
105
+ if policy.get("keep_rules") != 1:
106
+ problems.append("keep_rules must be 1")
107
+ recall = policy.get("recall")
108
+ if not (isinstance(recall, dict) and is_int(recall.get("budget_tokens", 1500), 1)
109
+ and is_int(recall.get("max_items", 40), 1)):
110
+ problems.append('recall must be {"budget_tokens": N, "max_items": N}')
111
+ if problems:
112
+ raise mio.MemoryError_("%s: %s" % (mio.POLICY_REL, "; ".join(problems)))
113
+
114
+
115
+ def mode_of(root):
116
+ """'files' or 'packages' without raising: close must archive whatever happens."""
117
+ try:
118
+ return load_config(root).mode
119
+ except mio.MemoryError_:
120
+ return "files"
@@ -0,0 +1,246 @@
1
+ """Export at close (§D5): the outbox, the compaction of a task into a package, the export itself, the
2
+ lines it reports, the pending ids and the close hook."""
3
+ import collections
4
+ import contextlib
5
+ import json
6
+ import os
7
+
8
+ import memory_cleanup as mclean
9
+ import memory_compact as mc
10
+ import memory_config as mcfg
11
+ import memory_format as mf
12
+ import memory_io as mio
13
+ import memory_packages as mpkg
14
+
15
+
16
+ # ------------------------------------------------------------------ export (§D5)
17
+ def outbox_path(root, key):
18
+ task, inst = key.rsplit("#", 1)
19
+ return mio.local_path(root, "outbox", "%s~%s.jsonl" % (task, inst))
20
+
21
+
22
+ def exportable(compactions):
23
+ return [c for c in compactions if c.seal and c.seal["result"] in ("closed", "abandoned", "restarted")]
24
+
25
+
26
+ ExportResult = collections.namedtuple("ExportResult",
27
+ "package added sealed coverage failures conflicts already added_by_key")
28
+
29
+
30
+ def compact_for_export(root, task_id, cfg):
31
+ found = mc.compact_instances(mc.FsReader(root), task_id, "close", cfg.excerpt_max_bytes)
32
+ problems = [p for c in found for p in c.problems if "torn journal" not in p]
33
+ if problems:
34
+ raise mio.MemoryError_(problems[0], 3)
35
+ ready = exportable(found)
36
+ if not ready:
37
+ raise mio.MemoryError_("%s has no closed instance to export" % task_id, 3)
38
+ return ready
39
+
40
+
41
+ def write_outbox(root, compaction):
42
+ key = mf.key_of(compaction.seal)
43
+ lines = [mf.canonical(e) for e in compaction.entries + [compaction.seal]]
44
+ mio.atomic_write(outbox_path(root, key), b"\n".join(lines) + b"\n")
45
+ return key
46
+
47
+
48
+ def read_outbox(root):
49
+ base = mio.local_path(root, "outbox")
50
+ out = {}
51
+ for name in sorted(os.listdir(base)) if os.path.isdir(base) else []:
52
+ if not name.endswith(".jsonl"):
53
+ continue
54
+ entries = []
55
+ for raw in (mio.read_bytes(os.path.join(base, name)) or b"").split(b"\n"):
56
+ if raw.strip():
57
+ with contextlib.suppress(ValueError, UnicodeDecodeError):
58
+ entries.append(json.loads(raw.decode("utf-8")))
59
+ seals = [e for e in entries if e.get("kind") == "task"]
60
+ if seals:
61
+ out[mf.key_of(seals[-1])] = entries
62
+ return out
63
+
64
+
65
+ def export(root, ids, cfg=None, stamp=None):
66
+ """B2–B4 for every id (or the pending ones); returns ExportResult. A
67
+ failure of one task keeps its outbox and is reported, never raised."""
68
+ cfg = cfg or mcfg.load_config(root)
69
+ if not cfg.packages:
70
+ raise mio.MemoryError_("files mode: nothing is exported ('state.py memory enable' turns packages on)")
71
+ failures, coverage, sealed, already, added_by_key = [], {}, [], [], {}
72
+ if ids == "pending":
73
+ ids = pending_ids(root, cfg)
74
+ ids = list(ids)
75
+ # A key with a seal in the tree is never sealed again from other sources: a
76
+ # second compaction of changed local sources (a restarted id, a cleaned-up
77
+ # directory) would add a degraded seal to an append-only record (R8, R9).
78
+ # The same seal is a repair: the rids it lacks come back, nothing else (V4, V5).
79
+ sealed_here = mpkg.seals_by_key(mpkg.load_packages(root))
80
+ for task_id in ids:
81
+ try:
82
+ for compaction in compact_for_export(root, task_id, cfg):
83
+ key = mf.key_of(compaction.seal)
84
+ coverage[key] = compaction.coverage
85
+ stored = {s["rid"] for _, s in sealed_here.get(key, [])}
86
+ if stored and compaction.seal["rid"] not in stored:
87
+ already.append(key)
88
+ continue
89
+ write_outbox(root, compaction)
90
+ except mio.MemoryError_ as exc:
91
+ failures.append((task_id, str(exc)))
92
+ except OSError as exc:
93
+ failures.append((task_id, "the outbox could not be written (%s)" % exc))
94
+ mio.crash("B2")
95
+ added, conflicts, name = 0, [], None
96
+ try:
97
+ with mio.locked(root):
98
+ outbox = read_outbox(root) # under the lock: another close may have taken it
99
+ packages = mpkg.load_packages(root)
100
+ have = mpkg.all_rids(packages)
101
+ seals = mpkg.seals_by_key(packages)
102
+ recorded = {e.get("key"): set(e.get("rids") or []) for _, entries, _ in packages.values()
103
+ for e in entries if e.get("kind") == "conflict"}
104
+ new, keep = [], {}
105
+ for key, entries in outbox.items():
106
+ seal_rid = [e for e in entries if e.get("kind") == "task"][-1]["rid"]
107
+ stored = {s["rid"] for _, s in seals.get(key, [])}
108
+ missing = [e for e in entries if e["rid"] not in have]
109
+ if (stored and seal_rid not in stored) or not missing:
110
+ # Sealed meanwhile from other sources, or nothing lacking (a re-run, B4).
111
+ os.remove(outbox_path(root, key))
112
+ if key not in already:
113
+ already.append(key)
114
+ continue
115
+ new += missing
116
+ added_by_key[key] = len(missing)
117
+ keep[key] = entries
118
+ for key in coverage:
119
+ if key in seals and key not in keep and key not in already:
120
+ already.append(key) # another close took this outbox and sealed it (review L3)
121
+ for key, found in sorted(seals.items()):
122
+ # Two seals that arrived by a merge (one id closed in two clones):
123
+ # recorded side by side, never merged (R9).
124
+ rids = sorted({s["rid"] for _, s in found} - {s.get("supersedes") for _, s in found})
125
+ if len(rids) < 2 or key.rsplit("#", 1)[0] not in ids or set(rids) <= recorded.get(key, set()):
126
+ continue
127
+ seal = found[-1][1]
128
+ conflict = mf.with_rid({
129
+ "v": mf.FORMAT, "task": seal["task"], "inst": seal["inst"], "seq": seal["seq"] + 1,
130
+ "kind": "conflict", "ts": max(s["ts"] for _, s in found), "actor": "agent", "by": None,
131
+ "runtime": seal["runtime"], "src": ["export"], "key": key, "rids": rids,
132
+ "reason": "two different seals for one key — kept side by side, never merged"})
133
+ if conflict["rid"] not in have:
134
+ new.append(conflict)
135
+ conflicts.append(key)
136
+ if not new:
137
+ return ExportResult(None, 0, sealed, coverage, failures, conflicts, already, added_by_key)
138
+ name, branch, books = mpkg.open_batch(root, cfg, stamp)
139
+ path = os.path.join(mio.memory_dir(root), name)
140
+ header, current, _ = packages.get(name, (None, [], []))
141
+ parts = mf.parse_package_name(name)
142
+ header = header or mf.package_header(parts["period"], cfg.author, parts["batch"], stamp or mio.now())
143
+ current = [mf.strip_private(e) for e in current]
144
+ outbox = keep
145
+ if new:
146
+ data = mf.render_package(header, current + new)
147
+ mio.crash("B3")
148
+ mio.atomic_write(path, data)
149
+ added = len(new)
150
+ books["open"][branch] = name
151
+ mpkg.write_batches(root, books)
152
+ mio.crash("B4")
153
+ check = mpkg.load_packages(root)
154
+ present = mpkg.all_rids(check)
155
+ for key, entries in outbox.items():
156
+ seal = [e for e in entries if e.get("kind") == "task"][-1]
157
+ if all(e["rid"] in present for e in entries):
158
+ os.remove(outbox_path(root, key))
159
+ sealed.append(key)
160
+ else:
161
+ failures.append((seal["task"], "read-back found %d of %d entries" % (
162
+ sum(e["rid"] in present for e in entries), len(entries))))
163
+ except OSError as exc:
164
+ failures.append((",".join(sorted(coverage)) or "the outbox",
165
+ "%s (%s)" % (exc.strerror or exc, getattr(exc, "filename", ""))))
166
+ if added and cfg.index == "sqlite":
167
+ mio.crash("B5")
168
+ index_import(root, cfg)
169
+ return ExportResult(name, added, sealed, coverage, failures, conflicts, already, added_by_key)
170
+
171
+
172
+ def record_lines(result):
173
+ """What close and `memory export` print: each key newly sealed with what it
174
+ added, each key that was already sealed, each conflict recorded (review L5)."""
175
+ out = []
176
+ for key in result.sealed:
177
+ cov = result.coverage.get(key) or {}
178
+ out.append("record: %s (+%d entries, %s sealed, coverage %s/%s lines)" % (
179
+ os.path.join(mio.MEMORY_REL, result.package or "-"), result.added_by_key.get(key, 0),
180
+ key.rsplit("#", 1)[0], cov.get("accounted", "?"), cov.get("lines", "?")))
181
+ for key in result.already:
182
+ out.append("record: %s already sealed in %s — nothing added" % (key, mio.MEMORY_REL))
183
+ for key in result.conflicts:
184
+ out.append("conflict: %s has two different seals — recorded side by side, never merged "
185
+ "('state.py memory show %s')" % (key, key.rsplit("#", 1)[0]))
186
+ return out
187
+
188
+
189
+ def index_import(root, cfg):
190
+ """B5: the index learns the new package; any failure marks it stale."""
191
+ try:
192
+ import memory_store # pylint: disable=import-outside-toplevel
193
+ memory_store.SqliteHistory(root, cfg).refresh()
194
+ except Exception: # pylint: disable=broad-except # a stale index is rebuilt by the next read
195
+ with contextlib.suppress(Exception):
196
+ import memory_store # pylint: disable=import-outside-toplevel
197
+ memory_store.mark_stale(root)
198
+
199
+
200
+ def pending_ids(root, cfg=None):
201
+ """Closed new-shape tasks (and anything in the outbox) without a seal:
202
+ B1 left them closed but not exported. Legacy ids wait for the migration."""
203
+ sealed = mpkg.sealed_task_ids(mpkg.load_packages(root))
204
+ found = set(k.rsplit("#", 1)[0] for k in read_outbox(root))
205
+ logs = os.path.join(root, ".ai", "state", "logs")
206
+ for name in os.listdir(logs) if os.path.isdir(logs) else []:
207
+ if not mf.NEW_ID_RE.match(name) or name in sealed:
208
+ continue
209
+ with contextlib.suppress(OSError, ValueError):
210
+ state = mio.read_json(os.path.join(logs, name, "state.json"))
211
+ if state.get("result") in ("closed", "abandoned"):
212
+ found.add(name)
213
+ del cfg
214
+ return sorted(found - sealed)
215
+
216
+
217
+ def after_close(root, task_id, out=print):
218
+ """The one call close makes after archive_v7. Packages mode only; every
219
+ failure is one printed line and close still exits 0 (B4)."""
220
+ try:
221
+ cfg = mcfg.load_config(root)
222
+ except mio.MemoryError_ as exc:
223
+ out("memory: %s — the task is closed in files mode" % exc)
224
+ return None
225
+ if not cfg.packages:
226
+ return None
227
+ try:
228
+ pending = [t for t in pending_ids(root, cfg) if t != task_id][:mio.PENDING_MAX]
229
+ result = export(root, [task_id] + pending, cfg)
230
+ except (mio.MemoryError_, ValueError, OSError) as exc:
231
+ out("memory: export of %s failed (%s) — the task is closed; 'state.py memory export %s' retries"
232
+ % (task_id, exc, task_id))
233
+ return None
234
+ for failed, why in result.failures:
235
+ out("memory: export of %s failed (%s) — the task is closed; 'state.py memory export %s' retries"
236
+ % (failed, why, failed))
237
+ for line in record_lines(result):
238
+ out(line)
239
+ if cfg.cleanup and any(k.rsplit("#", 1)[0] == task_id for k in result.sealed + result.already):
240
+ mio.crash("B6")
241
+ try:
242
+ done = mclean.cleanup_task(root, task_id, cfg)
243
+ out("cleanup: %s" % done)
244
+ except mio.MemoryError_ as exc:
245
+ out("cleanup: %s kept in .ai/reports/ (%s)" % (task_id, exc))
246
+ return result
@@ -275,7 +275,9 @@ def sort_entries(entries):
275
275
 
276
276
  def parse_package(data, name=None):
277
277
  """(header, entries, errors): errors are (line, what) pairs; a line that
278
- fails is left out of entries, so a reader still sees the rest."""
278
+ fails is left out of entries, so a reader still sees the rest. A line over
279
+ LINE_MAX_BYTES is an error, and a valid entry on it is still kept, with its
280
+ byte length in `_over` (GitHub #74)."""
279
281
  errors, entries, header = [], [], None
280
282
  if isinstance(data, str):
281
283
  data = data.encode("utf-8")
@@ -293,7 +295,6 @@ def parse_package(data, name=None):
293
295
  raw = raw[:-1]
294
296
  if len(raw) > LINE_MAX_BYTES:
295
297
  errors.append((number, "line is %d bytes, over %d" % (len(raw), LINE_MAX_BYTES)))
296
- continue
297
298
  try:
298
299
  obj = json.loads(raw.decode("utf-8"))
299
300
  except (UnicodeDecodeError, ValueError) as exc:
@@ -318,6 +319,8 @@ def parse_package(data, name=None):
318
319
  errors.append((number, "; ".join(problems)))
319
320
  continue
320
321
  obj["_line"] = number
322
+ if len(raw) > LINE_MAX_BYTES:
323
+ obj["_over"] = len(raw)
321
324
  entries.append(obj)
322
325
  if header is None and not errors:
323
326
  errors.append((1, "empty package"))
@@ -688,7 +691,8 @@ def render_task_md(entries):
688
691
  facts = seal.get("facts") or {}
689
692
  if facts:
690
693
  out += ["facts: (a) %s · (b) %s" % tuple(_fact(facts.get(k)) for k in ("a", "b")), ""]
691
- files = seal.get("changes") or []
694
+ files = seal.get("changes")
695
+ files = files if isinstance(files, list) else []
692
696
  out += ["## Files", ""]
693
697
  if files:
694
698
  out += ["- %s (%s, %s)" % (c.get("path"), "binary" if c.get("binary") else "+%s −%s" % (
@@ -0,0 +1,172 @@
1
+ """The git hook (R17, §D10): the installed shim and where it points, the policy file, and the enable and
2
+ disable commands that write and remove them."""
3
+ import json
4
+ import os
5
+ import re
6
+
7
+ import memory_config as mcfg
8
+ import memory_format as mf
9
+ import memory_io as mio
10
+
11
+
12
+ # ------------------------------------------------------------------ status, show
13
+ def hook_state(root):
14
+ code, out, _ = mio.git(root, "config", "--get", "core.hooksPath")
15
+ if code == 0 and out.strip():
16
+ return "hookspath"
17
+ path = hooks_path(root)
18
+ if not path:
19
+ return "absent"
20
+ hook = os.path.join(path, "pre-push")
21
+ text = mio.read_bytes(hook)
22
+ if text is None:
23
+ return "absent"
24
+ if mio.HOOK_MARKER.encode() in text:
25
+ decoded = text.decode("utf-8", "replace")
26
+ if not any(os.path.isfile(p) for p in shim_paths(decoded)):
27
+ return "installed, validator missing — pushes are NOT checked ('state.py memory enable' rewrites it)"
28
+ return "current" if decoded == shim_text() else "installed (stale)"
29
+ return "foreign"
30
+
31
+
32
+ # ------------------------------------------------------------------ the git hook (R17, §D10)
33
+ def hooks_path(root):
34
+ code, out, _ = mio.git(root, "rev-parse", "--git-path", "hooks")
35
+ if code != 0 or not out.strip():
36
+ return None
37
+ path = out.strip()
38
+ return path if os.path.isabs(path) else os.path.join(root, path)
39
+
40
+
41
+ def shim_candidates(validator=None):
42
+ """Where the shim looks for the validator, in order: the hook beside this
43
+ module, the hook in either runtime home, then this module itself — a Codex
44
+ install copies skills/ai-task/ but not every hook."""
45
+ return [validator or mio.VALIDATOR, "$HOME/.claude/hooks/memory-pre-push.py",
46
+ "${CODEX_HOME:-$HOME/.codex}/hooks/memory-pre-push.py", mio.SELF,
47
+ "$HOME/.claude/skills/ai-task/memory_sync.py",
48
+ "${CODEX_HOME:-$HOME/.codex}/skills/ai-task/memory_sync.py"]
49
+
50
+
51
+ def shim_text(validator=None):
52
+ return ("#!/bin/sh\n%s — written by 'state.py memory enable'; 'state.py memory disable' removes it\n"
53
+ "for v in %s; do\n"
54
+ " [ -f \"$v\" ] && exec python3 -I \"$v\" \"$@\"\n"
55
+ "done\n"
56
+ "echo \"riskrails memory: validator not found — this push is not checked\" >&2; exit 0\n"
57
+ % (mio.HOOK_MARKER, " ".join('"%s"' % c for c in shim_candidates(validator))))
58
+
59
+
60
+ def shim_paths(text):
61
+ """The shim's candidates as paths on this machine."""
62
+ match = re.search(r"^for v in (.+); do$", text, re.M)
63
+ home = os.path.expanduser("~")
64
+ codex = os.environ.get("CODEX_HOME") or os.path.join(home, ".codex")
65
+ return [c.replace("${CODEX_HOME:-$HOME/.codex}", codex).replace("$HOME", home)
66
+ for c in re.findall(r'"([^"]+)"', match.group(1) if match else "")]
67
+
68
+
69
+ def snippet_text(validator=None):
70
+ validator = validator or mio.VALIDATOR
71
+ return ("# riskrails memory: add to your own pre-push hook (stdin can be read only once)\n"
72
+ "rr_in=$(mktemp) && cat > \"$rr_in\"\n"
73
+ "python3 -I \"%s\" \"$@\" < \"$rr_in\" || { rr=$?; rm -f \"$rr_in\"; exit $rr; }\n"
74
+ "# … then your hook, reading \"$rr_in\" where it read stdin …\n"
75
+ "rm -f \"$rr_in\"\n" % validator)
76
+
77
+
78
+ def install_hook(root):
79
+ """installed | current | foreign | hookspath (R17)."""
80
+ code, out, _ = mio.git(root, "config", "--get", "core.hooksPath")
81
+ if code == 0 and out.strip():
82
+ return "hookspath"
83
+ path = hooks_path(root)
84
+ if not path:
85
+ raise mio.MemoryError_("not a git repository: no hooks path", 2)
86
+ hook = os.path.join(path, "pre-push")
87
+ text = mio.read_bytes(hook)
88
+ if text is not None and mio.HOOK_MARKER.encode() not in text:
89
+ return "foreign"
90
+ if text is not None and text.decode("utf-8", "replace") == shim_text():
91
+ return "current"
92
+ os.makedirs(path, exist_ok=True)
93
+ mio.atomic_write(hook, shim_text())
94
+ os.chmod(hook, 0o755)
95
+ return "installed"
96
+
97
+
98
+ def remove_hook(root):
99
+ path = hooks_path(root)
100
+ hook = os.path.join(path, "pre-push") if path else None
101
+ text = mio.read_bytes(hook) if hook else None
102
+ if text is not None and mio.HOOK_MARKER.encode() in text:
103
+ os.remove(hook)
104
+ return "removed"
105
+ return "foreign" if text is not None else "absent"
106
+
107
+
108
+ def write_policy(root, policy):
109
+ mio.atomic_write(os.path.join(root, mio.POLICY_REL), json.dumps(policy, indent=2, ensure_ascii=False) + "\n")
110
+
111
+
112
+ GITIGNORE_LOCAL = (".ai/memory/", ".ai/reports/")
113
+ GITIGNORE_SHARED = ("!.ai/memory/", "!.ai/reports/")
114
+
115
+
116
+ def append_gitignore(root, lines):
117
+ """Append `lines` to .gitignore (created when absent), after a newline if the file lacks one; never rewrites."""
118
+ path = os.path.join(root, ".gitignore")
119
+ current = mio.read_bytes(path) or b""
120
+ sep = b"\n" if current and not current.endswith(b"\n") else b""
121
+ with open(path, "ab") as fh:
122
+ fh.write(sep + "".join(line + "\n" for line in lines).encode())
123
+
124
+
125
+ def apply_share(root, share):
126
+ """--share git: the negations (the record is shared through git); --share local: the ignore lines. Append only."""
127
+ if share == "git":
128
+ have = (mio.read_bytes(os.path.join(root, ".gitignore")) or b"").decode("utf-8", "replace").splitlines()
129
+ add = [line for line in GITIGNORE_SHARED if line not in have]
130
+ if not add and not mio.shared(root):
131
+ add = list(GITIGNORE_SHARED)
132
+ if add:
133
+ append_gitignore(root, add)
134
+ elif share == "local" and mio.shared(root):
135
+ append_gitignore(root, GITIGNORE_LOCAL)
136
+
137
+
138
+ def enable(root, author=None, period=None, index=None, share=None):
139
+ """Packages mode on. The hook is installed only while the record is shared (.ai/memory/ not git-ignored);
140
+ a local record has no hook of ours and the state is reported as `local`."""
141
+ cfg = mcfg.load_config(root)
142
+ policy = dict(mcfg.DEFAULTS)
143
+ policy.update(cfg.policy if cfg.present else {})
144
+ policy["mode"] = "packages"
145
+ if period:
146
+ if period not in mf.PERIODS:
147
+ raise mio.MemoryError_("--period takes one of %s" % ", ".join(mf.PERIODS))
148
+ policy["period"] = period
149
+ write_policy(root, policy)
150
+ local = dict(cfg.local)
151
+ local["schema"] = 1
152
+ local["author"] = mcfg.author_slug(author) if author else cfg.author
153
+ if index:
154
+ if index not in ("none", "sqlite"):
155
+ raise mio.MemoryError_("--index takes none or sqlite")
156
+ local["index"] = index
157
+ local.setdefault("index", "none")
158
+ mio.atomic_write(mio.local_path(root, "config.json"), json.dumps(local, sort_keys=True) + "\n")
159
+ if share:
160
+ apply_share(root, share)
161
+ if not mio.shared(root):
162
+ remove_hook(root)
163
+ return "local"
164
+ return install_hook(root)
165
+
166
+
167
+ def disable(root):
168
+ cfg = mcfg.load_config(root)
169
+ policy = dict(cfg.policy)
170
+ policy["mode"] = "files"
171
+ write_policy(root, policy)
172
+ return remove_hook(root)