riskrails 4.0.1 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +22 -13
- package/bin/menu.js +116 -0
- package/bin/riskrails.js +119 -20
- package/hooks/AGENTS.md +0 -6
- package/hooks/CLAUDE.md +0 -6
- package/hooks/guard/dialog.py +14 -2
- package/hooks/guard/paths.py +48 -1406
- package/hooks/guard/paths_base.py +59 -0
- package/hooks/guard/paths_bash.py +176 -0
- package/hooks/guard/paths_classify.py +205 -0
- package/hooks/guard/paths_fs.py +137 -0
- package/hooks/guard/paths_human.py +105 -0
- package/hooks/guard/paths_interp.py +162 -0
- package/hooks/guard/paths_movers.py +393 -0
- package/hooks/guard/paths_shell.py +288 -0
- package/package.json +1 -1
- package/skills/AGENTS.md +0 -6
- package/skills/CLAUDE.md +0 -6
- package/skills/ai-init/scaffold-ai.sh +2 -1
- package/skills/ai-init/templates/.ai/policies/safety.md +4 -3
- package/skills/ai-init/templates/gitignore.snippet +2 -0
- package/skills/ai-task/memory_cleanup.py +213 -0
- package/skills/ai-task/memory_compact.py +25 -0
- package/skills/ai-task/memory_config.py +120 -0
- package/skills/ai-task/memory_export.py +246 -0
- package/skills/ai-task/memory_format.py +7 -3
- package/skills/ai-task/memory_hook.py +172 -0
- package/skills/ai-task/memory_io.py +122 -0
- package/skills/ai-task/memory_migrate.py +187 -0
- package/skills/ai-task/memory_packages.py +83 -0
- package/skills/ai-task/memory_risks.py +84 -0
- package/skills/ai-task/memory_status.py +76 -0
- package/skills/ai-task/memory_sync.py +20 -1481
- package/skills/ai-task/memory_verify.py +316 -0
- package/skills/ai-task/rr_state/cli.py +2 -1
- package/skills/ai-task/rr_state/close.py +2 -2
- package/skills/ai-task/rr_state/dialog_cmd.py +183 -6
- package/skills/ai-task/rr_state/labels.py +14 -2
- package/skills/ai-task/rr_state/memory_cmd.py +91 -68
- package/skills/project-update/history/blobs/8ec171f0fbdf38cc6996d496f01491fd1f21f04a435a118ad979b127a8be06f1 +85 -0
- package/skills/project-update/history/blobs/fa0cb370b024e1a30e1355912032657b08f609e47773a603c193b1e609b0f997 +8 -0
- package/skills/project-update/history/index.json +4 -2
- package/skills/project-update/rr_update/__init__.py +1 -0
- package/skills/project-update/rr_update/apply.py +188 -0
- package/skills/project-update/rr_update/blocks.py +38 -0
- package/skills/project-update/rr_update/build.py +96 -0
- package/skills/project-update/rr_update/cleanup.py +194 -0
- package/skills/project-update/rr_update/cli.py +102 -0
- package/skills/project-update/rr_update/files.py +106 -0
- package/skills/project-update/rr_update/gate.py +60 -0
- package/skills/project-update/rr_update/hints.py +128 -0
- package/skills/project-update/rr_update/history.py +117 -0
- package/skills/project-update/rr_update/kept.py +120 -0
- package/skills/project-update/rr_update/paths.py +108 -0
- package/skills/project-update/rr_update/plan.py +123 -0
- package/skills/project-update/rr_update/report.py +51 -0
- package/skills/project-update/rr_update/rules.py +178 -0
- package/skills/project-update/rr_update/shared.py +74 -0
- package/skills/project-update/rr_update/siblings.py +37 -0
- package/skills/project-update/rr_update/weights.py +171 -0
- package/skills/project-update/update.py +8 -1682
- package/skills/rr-setup/SKILL.md +79 -21
- package/skills/rr-setup/scaffold_ai.py +8 -2
- package/skills/rr-setup/setup.py +371 -4
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""memory_cmd: the `memory` and `recall` commands over the memory_store and
|
|
1
|
+
"""memory_cmd: the `memory` and `recall` commands over the memory_store module and the owner modules memory_io, memory_config, memory_verify, memory_hook, memory_risks, memory_cleanup, memory_migrate, memory_export and memory_status -- the actions, the human's route, the risk record.
|
|
2
2
|
|
|
3
3
|
shell: memory_modules, memory_config, memory_human, memory_confirm, cmd_recall, cmd_memory, memory_status, memory_enable, memory_disable, memory_export, memory_verify, memory_show, memory_search, risk_scope, memory_risk, memory_risks, memory_migrate, memory_rebuild, memory_cleanup, memory_restore, memory_pack, memory_redact
|
|
4
4
|
core: MEMORY_ACTIONS
|
|
@@ -9,6 +9,7 @@ import json
|
|
|
9
9
|
import os
|
|
10
10
|
import shlex
|
|
11
11
|
import sys
|
|
12
|
+
import types
|
|
12
13
|
|
|
13
14
|
from rr_state import fs, journal, task
|
|
14
15
|
from rr_state import dialog as rr_dialog
|
|
@@ -22,26 +23,39 @@ except ImportError: # an old install that shipped state.py
|
|
|
22
23
|
|
|
23
24
|
|
|
24
25
|
def memory_modules():
|
|
25
|
-
"""
|
|
26
|
+
"""The owner modules of the memory commands as one namespace (mem.io, mem.config, mem.verify, mem.hook,
|
|
27
|
+
mem.risks, mem.cleanup, mem.migrate, mem.export, mem.status -- each name is read on its owner at call time)
|
|
28
|
+
and memory_store, imported only by the commands that use them."""
|
|
26
29
|
try:
|
|
27
30
|
import memory_store # pylint: disable=import-outside-toplevel
|
|
28
|
-
import
|
|
31
|
+
import memory_cleanup as cleanup # pylint: disable=import-outside-toplevel
|
|
32
|
+
import memory_config as config # pylint: disable=import-outside-toplevel
|
|
33
|
+
import memory_export as export # pylint: disable=import-outside-toplevel
|
|
34
|
+
import memory_hook as hook # pylint: disable=import-outside-toplevel
|
|
35
|
+
import memory_io as io # pylint: disable=import-outside-toplevel
|
|
36
|
+
import memory_migrate as migrate # pylint: disable=import-outside-toplevel
|
|
37
|
+
import memory_risks as risks # pylint: disable=import-outside-toplevel
|
|
38
|
+
import memory_status as status # pylint: disable=import-outside-toplevel
|
|
39
|
+
import memory_verify as verify # pylint: disable=import-outside-toplevel
|
|
29
40
|
except ImportError as exc:
|
|
30
41
|
fs.die("the memory modules are not installed beside state.py (%s)" % exc, 2)
|
|
31
|
-
|
|
42
|
+
mem = types.SimpleNamespace(cleanup=cleanup, config=config, export=export, hook=hook, io=io, migrate=migrate,
|
|
43
|
+
risks=risks, status=status, verify=verify)
|
|
44
|
+
return mem, memory_store
|
|
32
45
|
|
|
33
46
|
|
|
34
|
-
def memory_config(
|
|
47
|
+
def memory_config(mem, root):
|
|
35
48
|
try:
|
|
36
|
-
return
|
|
37
|
-
except
|
|
49
|
+
return mem.config.load_config(root)
|
|
50
|
+
except mem.io.MemoryError_ as exc:
|
|
38
51
|
fs.die(str(exc), exc.code)
|
|
39
52
|
return None
|
|
40
53
|
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
cfg = memory_config(
|
|
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
|
-
|
|
98
|
+
mem, store = memory_modules()
|
|
85
99
|
action = args.memory_action
|
|
86
100
|
try:
|
|
87
|
-
MEMORY_ACTIONS[action](args, root,
|
|
88
|
-
except
|
|
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,
|
|
93
|
-
lines =
|
|
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,
|
|
101
|
-
memory_human(root, "enable", rt=rt)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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",
|
|
108
|
-
print("\n".join(
|
|
127
|
+
"core.hooksPath is set" if hook == "hookspath" else "a pre-push hook exists", mem.hook.snippet_text()))
|
|
128
|
+
print("\n".join(mem.status.status_lines(root)))
|
|
109
129
|
|
|
110
130
|
|
|
111
|
-
def memory_disable(_args, root,
|
|
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" %
|
|
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,
|
|
117
|
-
cfg = memory_config(
|
|
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 =
|
|
120
|
-
for line in
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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,
|
|
154
|
-
print("\n".join(
|
|
176
|
+
def memory_show(args, root, mem, _store, rt=rr_runtime.Runtime()):
|
|
177
|
+
print("\n".join(mem.status.show(root, task.valid_task_id(args.id, "ID"), "json" if args.json else "md", args.entry)))
|
|
155
178
|
|
|
156
179
|
|
|
157
|
-
def memory_search(args, root,
|
|
158
|
-
cfg = memory_config(
|
|
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,
|
|
188
|
-
cfg = memory_config(
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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,
|
|
230
|
-
cfg = memory_config(
|
|
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,
|
|
254
|
-
cfg = memory_config(
|
|
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 =
|
|
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(
|
|
286
|
+
print("\n".join(mem.migrate.migrate_apply(root, risks=args.risks)))
|
|
264
287
|
return
|
|
265
|
-
report =
|
|
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(
|
|
290
|
+
% (os.path.join(mem.io.LOCAL_REL, "migration-report.md"), report.count("\n")))
|
|
268
291
|
|
|
269
292
|
|
|
270
|
-
def memory_rebuild(_args, root,
|
|
271
|
-
cfg = memory_config(
|
|
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,
|
|
276
|
-
cfg = memory_config(
|
|
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(
|
|
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,
|
|
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(
|
|
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,
|
|
291
|
-
cfg = memory_config(
|
|
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 =
|
|
295
|
-
print("pack: %s (%d records; %d batch(es) removed)" % (os.path.join(
|
|
317
|
+
target, removed, count = mem.cleanup.pack(root, args.period)
|
|
318
|
+
print("pack: %s (%d records; %d batch(es) removed)" % (os.path.join(mem.io.MEMORY_REL, target), count,
|
|
296
319
|
len(removed)))
|
|
297
320
|
|
|
298
321
|
|
|
299
|
-
def memory_redact(args, root,
|
|
322
|
+
def memory_redact(args, root, mem, _store, rt=rr_runtime.Runtime()):
|
|
300
323
|
via = memory_human(root, "redact", rt=rt)
|
|
301
|
-
rid, name =
|
|
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(
|
|
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.
|
|
@@ -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)
|