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
@@ -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,26 +23,39 @@ 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
 
41
54
 
42
- def memory_human(root, what, rt=rr_runtime.Runtime()):
43
- """R16: enable, disable, cleanup, pack, redact and move-logs take the human's route, as approve does."""
44
- via, why = rr_dialog.human_route(root, rt=rt)
55
+ def memory_human(root, what, allow_dialog=False, rt=rr_runtime.Runtime()):
56
+ """R16: enable, disable, cleanup, pack, redact and move-logs take the human's route, as approve does. Only
57
+ enable passes allow_dialog: a live pick of this session that is this very command is the human's answer."""
58
+ via, why = rr_dialog.human_route(root, allow_dialog=allow_dialog, rt=rt)
45
59
  if via is None:
46
60
  fs.die("APPROVAL_REFUSED — 'memory %s' is the human's, from their own terminal:\n python3 %s --root %s "
47
61
  "%s\nRefused because %s." % (what, shlex.quote(fs.ENTRY), shlex.quote(root),
@@ -61,8 +75,8 @@ def memory_confirm(cfg, name, what):
61
75
  def cmd_recall(args, root, rt=rr_runtime.Runtime()):
62
76
  """R10: the open risks, residual findings, decisions, scope changes and task
63
77
  one-liners that touch these files, within the budget. Read-only."""
64
- sync, store = memory_modules()
65
- cfg = memory_config(sync, root)
78
+ mem, store = memory_modules()
79
+ cfg = memory_config(mem, root)
66
80
  files = fs._split_files(args.files)
67
81
  if not files:
68
82
  fs.die("recall needs --files \"a,b,dir/**\"", 2)
@@ -81,43 +95,49 @@ def cmd_recall(args, root, rt=rr_runtime.Runtime()):
81
95
 
82
96
 
83
97
  def cmd_memory(args, root, rt=rr_runtime.Runtime()):
84
- sync, store = memory_modules()
98
+ mem, store = memory_modules()
85
99
  action = args.memory_action
86
100
  try:
87
- MEMORY_ACTIONS[action](args, root, sync, store, rt=rt)
88
- except sync.MemoryError_ as exc:
101
+ MEMORY_ACTIONS[action](args, root, mem, store, rt=rt)
102
+ except mem.io.MemoryError_ as exc:
89
103
  fs.die(str(exc), exc.code)
90
104
 
91
105
 
92
- def memory_status(args, root, sync, _store, rt=rr_runtime.Runtime()):
93
- lines = sync.status_lines(root)
106
+ def memory_status(args, root, mem, _store, rt=rr_runtime.Runtime()):
107
+ lines = mem.status.status_lines(root)
94
108
  if args.json:
95
109
  print(json.dumps(dict(line.split(": ", 1) for line in lines), ensure_ascii=False, sort_keys=True))
96
110
  return
97
111
  print("\n".join(lines))
98
112
 
99
113
 
100
- def memory_enable(args, root, sync, _store, rt=rr_runtime.Runtime()):
101
- memory_human(root, "enable", rt=rt)
102
- hook = sync.enable(root, args.author, args.period, args.index)
103
- print("memory: packages mode on (%s); every teammate's next close exports" % sync.POLICY_REL)
104
- print("pre-push: %s" % hook)
114
+ def memory_enable(args, root, mem, _store, rt=rr_runtime.Runtime()):
115
+ if memory_human(root, "enable", allow_dialog=True, rt=rt) == "dialog":
116
+ pick = rr_dialog.pick_data(rr_dialog.DIALOG_PICK[0])
117
+ print("memory: via dialog (consent %s, decision %s)" % (pick["consent"], pick["decision"]))
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)
105
125
  if hook in ("foreign", "hookspath"):
106
126
  print("not written — %s. Add these lines to your own pre-push hook:\n%s" % (
107
- "core.hooksPath is set" if hook == "hookspath" else "a pre-push hook exists", sync.snippet_text()))
108
- 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)))
109
129
 
110
130
 
111
- def memory_disable(_args, root, sync, _store, rt=rr_runtime.Runtime()):
131
+ def memory_disable(_args, root, mem, _store, rt=rr_runtime.Runtime()):
112
132
  memory_human(root, "disable", rt=rt)
113
- 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))
114
134
 
115
135
 
116
- def memory_export(args, root, sync, _store, rt=rr_runtime.Runtime()):
117
- cfg = memory_config(sync, root)
136
+ def memory_export(args, root, mem, _store, rt=rr_runtime.Runtime()):
137
+ cfg = memory_config(mem, root)
118
138
  ids = "pending" if args.pending or not args.ids else [task.valid_task_id(t, "ID") for t in args.ids]
119
- result = sync.export(root, ids, cfg)
120
- for line in sync.record_lines(result):
139
+ result = mem.export.export(root, ids, cfg)
140
+ for line in mem.export.record_lines(result):
121
141
  print(line)
122
142
  print("exported: +%d entries%s" % (result.added, "" if result.added else " — nothing pending"))
123
143
  for task_id, why in result.failures:
@@ -126,36 +146,39 @@ def memory_export(args, root, sync, _store, rt=rr_runtime.Runtime()):
126
146
  sys.exit(1)
127
147
 
128
148
 
129
- def memory_verify(args, root, sync, _store, rt=rr_runtime.Runtime()):
149
+ def memory_verify(args, root, mem, _store, rt=rr_runtime.Runtime()):
130
150
  stats = None
131
151
  if args.range:
132
152
  if ".." not in args.range:
133
153
  fs.die("--range takes OLD..NEW", 2)
134
154
  old, new = args.range.split("..", 1)
135
- 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)
136
156
  findings = findings or []
137
157
  elif args.fresh_clone:
138
- 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)
139
162
  else:
140
- 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)
141
164
  failing = [f for f in findings if not f.check.endswith("-warn")]
142
165
  if args.json:
143
166
  print(json.dumps({"findings": [f._asdict() for f in findings], "ok": not failing, "stats": stats},
144
167
  ensure_ascii=False, sort_keys=True))
145
168
  else:
146
169
  for f in findings:
147
- print(sync.format_finding(f))
170
+ print(mem.verify.format_finding(f))
148
171
  print("memory verify: %s" % ("ok" if not failing else "%d finding(s)" % len(failing)))
149
172
  if failing:
150
173
  sys.exit(1)
151
174
 
152
175
 
153
- def memory_show(args, root, sync, _store, rt=rr_runtime.Runtime()):
154
- 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)))
155
178
 
156
179
 
157
- def memory_search(args, root, sync, store, rt=rr_runtime.Runtime()):
158
- cfg = memory_config(sync, root)
180
+ def memory_search(args, root, mem, store, rt=rr_runtime.Runtime()):
181
+ cfg = memory_config(mem, root)
159
182
  filters = {"kind": args.kind, "by": args.by, "task": args.task, "since": args.since, "until": args.until,
160
183
  "files": fs._split_files(args.file) or None}
161
184
  history = store.open_history(root, cfg)
@@ -184,8 +207,8 @@ def risk_scope(state):
184
207
  6)
185
208
 
186
209
 
187
- def memory_risk(args, root, sync, store, rt=rr_runtime.Runtime()):
188
- cfg = memory_config(sync, root)
210
+ def memory_risk(args, root, mem, store, rt=rr_runtime.Runtime()):
211
+ cfg = memory_config(mem, root)
189
212
  state = task.in_flight(root, rt=rt)
190
213
  if args.risk_action == "add":
191
214
  where = fs._split_files(args.where)
@@ -196,17 +219,17 @@ def memory_risk(args, root, sync, store, rt=rr_runtime.Runtime()):
196
219
  if not cfg.packages:
197
220
  risk_scope(state)
198
221
  found = "%s, by %s" % (fs.now()[:10], state["task_id"] if state else "no task")
199
- 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,
200
223
  args.status, found)
201
224
  print("risk: %s added to .ai/project/known-risks.md" % rkey)
202
225
  return
203
- 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)
204
227
  else:
205
228
  if args.status not in ("fixed", "accepted", "ticketed"):
206
229
  fs.die("--status takes fixed, accepted or ticketed", 2)
207
230
  if not cfg.packages:
208
231
  risk_scope(state)
209
- sync.risk_close_files(root, args.rkey, args.status, args.why)
232
+ mem.risks.risk_close_files(root, args.rkey, args.status, args.why)
210
233
  print("risk: %s is %s" % (args.rkey, args.status))
211
234
  return
212
235
  history = store.FileHistory(root, cfg)
@@ -214,7 +237,7 @@ def memory_risk(args, root, sync, store, rt=rr_runtime.Runtime()):
214
237
  if not found:
215
238
  fs.die("no risk %s ('state.py memory risks --status all')" % args.rkey, 1)
216
239
  last = found[0]
217
- 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"),
218
241
  last.get("likelihood"), last.get("fix"), args.status, args.why, last["rid"])
219
242
  if state:
220
243
  state = task.load(root, rt=rt)
@@ -222,12 +245,12 @@ def memory_risk(args, root, sync, store, rt=rr_runtime.Runtime()):
222
245
  task.save(root, state)
223
246
  print("risk: %s recorded in %s's journal (exported at close)" % (data["rkey"], state["task_id"]))
224
247
  return
225
- entry = sync.standalone_risk(root, cfg, data, rt.name)
248
+ entry = mem.risks.standalone_risk(root, cfg, data, rt.name)
226
249
  print("risk: %s recorded (%s)" % (data["rkey"], entry["rid"]))
227
250
 
228
251
 
229
- def memory_risks(args, root, sync, store, rt=rr_runtime.Runtime()):
230
- cfg = memory_config(sync, root)
252
+ def memory_risks(args, root, mem, store, rt=rr_runtime.Runtime()):
253
+ cfg = memory_config(mem, root)
231
254
  risks = store.open_history(root, cfg).risks(args.status)
232
255
  if args.json:
233
256
  for e in risks:
@@ -250,57 +273,57 @@ def memory_risks(args, root, sync, store, rt=rr_runtime.Runtime()):
250
273
  e.get("title"), e.get("rkey")))
251
274
 
252
275
 
253
- def memory_migrate(args, root, sync, _store, rt=rr_runtime.Runtime()):
254
- cfg = memory_config(sync, root)
276
+ def memory_migrate(args, root, mem, _store, rt=rr_runtime.Runtime()):
277
+ cfg = memory_config(mem, root)
255
278
  if args.move_logs:
256
279
  memory_human(root, "migrate --move-logs", rt=rt)
257
280
  memory_confirm(cfg, args.confirm_delete, "migrate --move-logs")
258
- moved, command = sync.move_logs(root)
281
+ moved, command = mem.migrate.move_logs(root)
259
282
  print("moved %d committed log(s) into .ai/state/logs/<id>/legacy/; now, as its own commit (C3):\n %s"
260
283
  % (len(moved), command))
261
284
  return
262
285
  if args.apply:
263
- print("\n".join(sync.migrate_apply(root, risks=args.risks)))
286
+ print("\n".join(mem.migrate.migrate_apply(root, risks=args.risks)))
264
287
  return
265
- report = sync.migrate_report(root)
288
+ report = mem.migrate.migrate_report(root)
266
289
  print("report: %s (%d lines) — read it, then 'state.py memory migrate --apply'"
267
- % (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")))
268
291
 
269
292
 
270
- def memory_rebuild(_args, root, sync, store, rt=rr_runtime.Runtime()):
271
- cfg = memory_config(sync, root)
293
+ def memory_rebuild(_args, root, mem, store, rt=rr_runtime.Runtime()):
294
+ cfg = memory_config(mem, root)
272
295
  print("index: rebuilt from %d sources (%s)" % (store.SqliteHistory(root, cfg).rebuild(), store.INDEX_REL))
273
296
 
274
297
 
275
- def memory_cleanup(args, root, sync, _store, rt=rr_runtime.Runtime()):
276
- cfg = memory_config(sync, root)
298
+ def memory_cleanup(args, root, mem, _store, rt=rr_runtime.Runtime()):
299
+ cfg = memory_config(mem, root)
277
300
  memory_human(root, "cleanup", rt=rt)
278
301
  memory_confirm(cfg, args.confirm_delete, "cleanup")
279
302
  if not args.all and not args.ids:
280
303
  fs.die("memory cleanup takes IDs or --all", 2)
281
- 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])))
282
305
 
283
306
 
284
- def memory_restore(args, root, sync, _store, rt=rr_runtime.Runtime()):
307
+ def memory_restore(args, root, mem, _store, rt=rr_runtime.Runtime()):
285
308
  if not args.all and not args.ids:
286
309
  fs.die("memory restore takes IDs or --all", 2)
287
- 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])))
288
311
 
289
312
 
290
- def memory_pack(args, root, sync, _store, rt=rr_runtime.Runtime()):
291
- cfg = memory_config(sync, root)
313
+ def memory_pack(args, root, mem, _store, rt=rr_runtime.Runtime()):
314
+ cfg = memory_config(mem, root)
292
315
  memory_human(root, "pack", rt=rt)
293
316
  memory_confirm(cfg, args.confirm_delete, "pack")
294
- target, removed, count = sync.pack(root, args.period)
295
- 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,
296
319
  len(removed)))
297
320
 
298
321
 
299
- def memory_redact(args, root, sync, _store, rt=rr_runtime.Runtime()):
322
+ def memory_redact(args, root, mem, _store, rt=rr_runtime.Runtime()):
300
323
  via = memory_human(root, "redact", rt=rt)
301
- 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
302
325
  "unknown", via))
303
- 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))
304
327
 
305
328
 
306
329
  MEMORY_ACTIONS = {"status": memory_status, "enable": memory_enable, "disable": memory_disable,
@@ -0,0 +1,85 @@
1
+ # Safety policy
2
+
3
+ This is the file every agent reads first. It is short on purpose. It binds every
4
+ agent in every runtime — Claude Code and Codex alike.
5
+
6
+ ## The one rule
7
+
8
+ **Production behaviour is the source of truth until proven otherwise.** Code that
9
+ looks wrong, dead, or badly written may be load-bearing. The repository's history
10
+ is evidence; your intuition about how the code "should" look is not.
11
+
12
+ ## What an agent must never do
13
+
14
+ - Change application code that the current task did not ask about.
15
+ - Rename, move, reorganise or "clean up" anything as a side effect.
16
+ - Remove code because it looks unused. Find the callers first; if there are none,
17
+ say so in the report and leave the code alone.
18
+ - Combine a refactoring with a feature change in the same step.
19
+ - Modify database schemas, dependencies or framework versions outside a task that
20
+ is explicitly about that.
21
+ - Weaken a test, a linter rule or a static-analysis level to make a check pass.
22
+ - Commit, push, merge or deploy on its own initiative.
23
+ - Read production secrets or customer data into the model context.
24
+
25
+ If you find a problem outside the current task: **write it down, do not fix it.**
26
+ Unrelated findings are recorded with `state.py memory risk add` (in files mode it
27
+ writes `.ai/project/known-risks.md`) or go into the report's "observations"
28
+ section, and the human decides whether they become their own task.
29
+
30
+ The compact record is the human's to switch: `state.py memory enable`, `disable`,
31
+ `cleanup`, `pack`, `redact` and `migrate --move-logs` take the human route, as
32
+ `approve` does. `enable` may also run as the command the human picked in the
33
+ `/rr-setup` dialog; the rest have no dialog route. `.ai/memory/` and
34
+ `.ai/local/memory/` are written by `state.py` alone — an agent never edits them
35
+ (`state.py memory --help`; the plugin's `docs/local-memory.md`).
36
+
37
+ ## Scope
38
+
39
+ Every implementation step names the files it may touch. Editing anything else is
40
+ refused by `ai-scope-guard` and must be answered with `SCOPE_CHANGE_REQUIRED`:
41
+ stop, say which file you need and why, and let the plan be amended. A step whose
42
+ scope keeps growing is a step that was planned wrong.
43
+
44
+ `ai-git-guard`, `ai-path-guard` and `ai-scope-guard` all run in both runtimes. A
45
+ multi-file edit in one call — Codex's `apply_patch` — is checked path by path, so
46
+ a single out-of-scope or protected file rejects the whole patch. What a guard
47
+ refuses is not a hint: do not reword the command to get past it.
48
+
49
+ A guard is a backstop, not the policy. Some checks exist in only one runtime —
50
+ Codex has no hookable read tool, so the large-read cap is Claude-only there — and
51
+ an agent that behaves correctly only because a hook is watching is not behaving
52
+ correctly. The rules above hold whether or not a hook enforces them.
53
+
54
+ **The guards' threat model.** The guards defend against an agent's mistakes — a
55
+ careless `rm`, a write to a frozen policy during a task, a commit with a
56
+ secret — not against an agent that constructs a command to exhaust them. A
57
+ command built to time the guard out is visible in the transcript and in the
58
+ journal, and the human gate in full and full_irreversible mode is the boundary
59
+ for anything that matters.
60
+ Approval rests on a guard plus a ledger: a command the agent constructs to pass
61
+ for the human's pick is outside the threat model, and `state.py consent audit`
62
+ detects it afterwards.
63
+
64
+ ## Evidence labels
65
+
66
+ Anything an agent writes into `.ai/project/` carries one of four labels:
67
+
68
+ - **KNOWN FACT** — read from the code, a config file, a migration or a test.
69
+ Cite `file:line`.
70
+ - **INFERENCE** — a conclusion drawn from facts. Say what it is drawn from.
71
+ - **UNKNOWN** — something that matters and could not be determined. Say what
72
+ would answer it.
73
+ - **RISK** — something that looks dangerous. Say what could go wrong.
74
+
75
+ Never present an inference as a fact. When documentation and code disagree,
76
+ record both under "Documented behaviour" and "Observed behaviour", with the
77
+ evidence for each, and let the human decide which is the bug.
78
+
79
+ ## When in doubt
80
+
81
+ Stop and ask. An agent that pauses costs a message; an agent that guesses on a
82
+ payment path costs an incident.
83
+
84
+ Doubt on reversibility or domain is a blocker; other doubt is written to
85
+ `task.md` and the work continues.
@@ -0,0 +1,8 @@
1
+ # claude-agentic (.ai)
2
+ .ai/state/*.json
3
+ .ai/state/handoff.md
4
+ .ai/state/*.tmp
5
+ .ai/state/*.jsonl
6
+ .ai/local/
7
+ .ai/memory/
8
+ .ai/reports/
@@ -171,7 +171,8 @@
171
171
  "ae9a22907ba9acbb7e80fac1ba4e3d1ff09e5f1df5c4456afb9099d7090fcaa6",
172
172
  "fae3064e648cdc55be4aeb1177525b302a5e34ebd752e4a904f47dc0f4557b96",
173
173
  "e9adb7a9905eb719ac78ab96671f055da53e5001810f5ad6d6c94306b0323fe7",
174
- "ae5ce131d6bae401199ff32d8c3dc4d8aec188010a9111624ac64506e2116ff4"
174
+ "ae5ce131d6bae401199ff32d8c3dc4d8aec188010a9111624ac64506e2116ff4",
175
+ "8ec171f0fbdf38cc6996d496f01491fd1f21f04a435a118ad979b127a8be06f1"
175
176
  ],
176
177
  "ai-init/.ai/policies/security.md": [
177
178
  "cc808d5b940109f1703abfb3ca0334a3fcfd34267feefb2323627d0decc8b89a",
@@ -394,7 +395,8 @@
394
395
  "ai-init/gitignore.snippet": [
395
396
  "5c0619c1ee059477c9d92cf70b72da5d238d9176fc27ab0d38104ecba795624c",
396
397
  "a00db51859bb7943174017a34b4a100f98e53d6c3b32f73d8f87d6072016a709",
397
- "5a93f78b4ed4d416c171139b2ce610a117f9c1dd46fbb56ca86729cb4c53d10c"
398
+ "5a93f78b4ed4d416c171139b2ce610a117f9c1dd46fbb56ca86729cb4c53d10c",
399
+ "fa0cb370b024e1a30e1355912032657b08f609e47773a603c193b1e609b0f997"
398
400
  ],
399
401
  "ai-init/junie-guidelines.block.md": [
400
402
  "e8df28f07cb784d768bcafbced70fd15771afa61dbec7dc4c1c3f858f9002fec",
@@ -0,0 +1 @@
1
+ """rr_update: the pieces of skills/project-update/update.py, one module per concern, lower layers first."""
@@ -0,0 +1,188 @@
1
+ """Writing the plan: the preconditions, the atomic write, the record of what was taken away, and the abort.
2
+
3
+ shell: current_sha, check, default_mode, write_file, keep_original, record_run, apply, apply_item, prune_empty
4
+ core: Abort
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import tempfile
10
+ from collections import OrderedDict
11
+
12
+ from rr_update import files
13
+ from rr_update import paths
14
+
15
+
16
+ class Abort(Exception):
17
+ """A precondition failed: the project is not what the dry run described."""
18
+
19
+ def __init__(self, item, why):
20
+ super().__init__(why)
21
+ self.item, self.why = item, why
22
+
23
+
24
+ def current_sha(root, target):
25
+ """The sha of a target now, or None when it is absent. A path that is not a
26
+ regular file has no content this tool may replace."""
27
+ path = os.path.join(root, target)
28
+ if os.path.islink(path) or (os.path.exists(path) and not os.path.isfile(path)):
29
+ raise Abort(None, "%s is not a regular file" % target)
30
+ return files.sha(files.read(path)) if os.path.isfile(path) else None
31
+
32
+
33
+ def check(plan, item, target, expect, written):
34
+ """The target must still be what the dry run read, or what this run wrote."""
35
+ try:
36
+ now_sha = current_sha(plan.root, target)
37
+ except Abort as exc:
38
+ raise Abort(item, exc.why) from exc
39
+ want = written.get(target, expect)
40
+ if now_sha != want:
41
+ raise Abort(item, "%s changed since the dry run read it" % target)
42
+
43
+
44
+ def default_mode():
45
+ mask = os.umask(0)
46
+ os.umask(mask)
47
+ return 0o666 & ~mask
48
+
49
+
50
+ def write_file(root, target, content, mode=None):
51
+ """Atomic, like state.py: a crash leaves the old file or the new one, never
52
+ half of one. The temp file is created with mkstemp in the target's directory,
53
+ so a planted name cannot redirect the write, and a symlinked target is
54
+ refused rather than followed. The target keeps the mode it had. A symlinked
55
+ *directory* on the way to it is still followed, as every writer here always
56
+ has."""
57
+ path = os.path.join(root, target)
58
+ if os.path.islink(path) or (os.path.exists(path) and not os.path.isfile(path)):
59
+ raise Abort(None, "%s is not a regular file" % target)
60
+ parent = os.path.dirname(path) or "."
61
+ os.makedirs(parent, exist_ok=True)
62
+ if mode is None:
63
+ mode = os.stat(path).st_mode & 0o7777 if os.path.isfile(path) else default_mode()
64
+ fd, tmp = tempfile.mkstemp(dir=parent, prefix=os.path.basename(path) + ".", suffix=".tmp")
65
+ try:
66
+ with os.fdopen(fd, "wb") as fh:
67
+ fh.write(content)
68
+ os.chmod(tmp, mode)
69
+ os.replace(tmp, path)
70
+ except BaseException:
71
+ if os.path.exists(tmp):
72
+ os.unlink(tmp)
73
+ raise
74
+
75
+
76
+ def keep_original(plan, target):
77
+ """A faithful copy of what a move or a delete takes away, mode included.
78
+ Nothing else is ever lost, so nothing else is copied."""
79
+ path = os.path.join(plan.root, target)
80
+ write_file(plan.root, os.path.join(plan.report_dir, "original", target),
81
+ files.read(path), mode=os.stat(path).st_mode & 0o7777)
82
+
83
+
84
+
85
+ def record_run(plan, ops, deletions):
86
+ """The record of what was taken away or overwritten, rewritten after every such operation:
87
+ an abort must not lose the note of a change a human approved. A second run on the same
88
+ day appends to the record instead of replacing it."""
89
+ path = os.path.join(plan.report_dir, "migration.json")
90
+ existing = {}
91
+ full = os.path.join(plan.root, path)
92
+ if os.path.isfile(full):
93
+ try:
94
+ existing = json.loads(files.read(full), object_pairs_hook=OrderedDict)
95
+ except (ValueError, RecursionError): # too deep reads as malformed
96
+ existing = {}
97
+ if not isinstance(existing, dict): # hand-edited into another shape
98
+ existing = {}
99
+ record = OrderedDict()
100
+ record["from"] = existing.get("from", plan.version_from)
101
+ # The version the project is actually at now, read from the disk: this record is
102
+ # written before the files change, and an aborted run never reaches the template's.
103
+ version_path = os.path.join(plan.root, paths.VERSION_FILE)
104
+ on_disk = files.read(version_path).decode().strip() if os.path.isfile(version_path) else ""
105
+ record["to"] = int(on_disk) if on_disk.isdigit() else record["from"]
106
+ record["applied_at"] = files.utc_now()
107
+ # A recovery re-run replays the same operation: record it once.
108
+ record["ops"] = list(existing.get("ops", []))
109
+ record["ops"] += [o for o in ops if o not in record["ops"]]
110
+ record["deletions"] = list(existing.get("deletions", []))
111
+ record["deletions"] += [d for d in deletions
112
+ if not any(e.get("path") == d["path"] for e in record["deletions"])]
113
+ if existing and all(existing.get(k) == record[k] for k in ("from", "to", "ops", "deletions")):
114
+ return # a run that changed nothing leaves the record as it was
115
+ write_file(plan.root, path, files.dump_json(record))
116
+
117
+
118
+ def apply(plan):
119
+ """Write the plan, in order. Every item checks its target first; a violation
120
+ or a failed write aborts before the next item, leaving everything after it
121
+ unwritten. Every operation is idempotent, so the re-run is the recovery."""
122
+ written, recorded = {}, []
123
+ for item in plan.items:
124
+ try:
125
+ apply_item(plan, item, written, recorded)
126
+ except Abort as exc:
127
+ raise Abort(exc.item or item, exc.why) from exc
128
+ except OSError as exc:
129
+ raise Abort(item, str(exc)) from exc
130
+ # The run finished: say which version it actually reached.
131
+ try:
132
+ if recorded or os.path.isfile(os.path.join(plan.root, plan.report_dir, "migration.json")):
133
+ record_run(plan, [], [])
134
+ except OSError as exc:
135
+ raise Abort(None, str(exc)) from exc
136
+
137
+
138
+ def apply_item(plan, item, written, recorded):
139
+ target, action = item["target"], item["action"]
140
+ reason = item.get("reason") or ""
141
+ cleanup = reason.startswith("[cleanup]")
142
+ if action in ("delete?", "regenerate?"): # listed only, until a human confirms it
143
+ return
144
+ by = dict(confirmed_by=plan.confirm_cleanup if cleanup else plan.confirm_delete,
145
+ via=plan.cleanup_via if cleanup else plan.confirm_via, at=files.utc_now())
146
+ if action == "delete":
147
+ # the plan-time symlink check is repeated: a path swapped since is refused
148
+ why = files.unsafe_path(plan.root, target)
149
+ if why:
150
+ raise Abort(item, why)
151
+ check(plan, item, target, item["expect"], written)
152
+ # git-ignored task state: a [cleanup] deletion of it is NOT recoverable through git, and
153
+ # copying what it removes would defeat the cleanup, so no original/ copy is kept. A report
154
+ # file git tracks, or should, is kept like any other deletion.
155
+ if not target.startswith(".ai/state/") and (not cleanup or target in plan.git_kept):
156
+ keep_original(plan, target)
157
+ recorded.append(target)
158
+ # Recorded before the file is gone: an abort later must not lose the note
159
+ # of what was taken away, or of who approved it.
160
+ record_run(plan, [dict(action=action, path=target)], [dict(path=target, **by)])
161
+ os.remove(os.path.join(plan.root, target))
162
+ written[target] = None
163
+ if reason.startswith(("[cleanup]", "[local]")):
164
+ prune_empty(plan.root, target)
165
+ return
166
+ check(plan, item, target, item["expect"], written)
167
+ if item["keep"] and item["expect"] is not None and target not in written:
168
+ # A change a human confirmed overwrites what they wrote: keep it, so it can be read back.
169
+ if not os.path.exists(os.path.join(plan.root, plan.original_of(target))):
170
+ keep_original(plan, target)
171
+ recorded.append(target)
172
+ record_run(plan, [dict(action=action, path=target, **by)], [])
173
+ write_file(plan.root, target, item["content"])
174
+ written[target] = files.sha(item["content"])
175
+
176
+
177
+ def prune_empty(root, target):
178
+ """The directories a cleanup or cache deletion left empty, up to (not including)
179
+ .ai/reports, .ai/state/logs and .ai/local; a directory with anything in it stays."""
180
+ top = ".ai/state/logs" if target.startswith(".ai/state/logs/") else \
181
+ ".ai/local" if target.startswith(paths.LOCAL_CACHE + "/") else ".ai/reports"
182
+ here = os.path.dirname(target)
183
+ while here.startswith(top + "/"):
184
+ try:
185
+ os.rmdir(os.path.join(root, here))
186
+ except OSError:
187
+ return
188
+ here = os.path.dirname(here)