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
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""The awk segmenter of the retired shell guard: shell_segments cuts a command into its segments,
|
|
2
|
+
words and redirections, with the keyword, flag and redirection tables it reads.
|
|
3
|
+
|
|
4
|
+
R16: every name here is core (no file, clock or subprocess access).
|
|
5
|
+
Moved from paths.py in WP5 (small-oop-modules), unchanged.
|
|
6
|
+
"""
|
|
7
|
+
import re
|
|
8
|
+
from guard.common import chomp_all
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_KEYWORDS = {"if", "then", "else", "elif", "do", "while", "until", "!", "{", "}", "time", "exec",
|
|
12
|
+
"command", "nohup", "builtin", "env", "sudo"}
|
|
13
|
+
_FLAGGED = {"time", "exec", "command", "env", "sudo"}
|
|
14
|
+
_ASSIGN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\+?=")
|
|
15
|
+
_SPECIAL_OUT = re.compile(r"""['$"#<(\n`)&;|\\]""")
|
|
16
|
+
_SPECIAL_D = re.compile(r'["$`\n\\]')
|
|
17
|
+
_SPECIAL_A = re.compile(r"['\n\\]")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def shell_segments(cmd):
|
|
21
|
+
"""The awk segmenter of shell_segments, character for character: the
|
|
22
|
+
simple commands of a shell line, verb first, one per output line."""
|
|
23
|
+
src = cmd + "\n"
|
|
24
|
+
n = len(src)
|
|
25
|
+
out = []
|
|
26
|
+
cur = []
|
|
27
|
+
stk, qst = [], []
|
|
28
|
+
q = ""
|
|
29
|
+
nh = 0
|
|
30
|
+
hd, hs = [], []
|
|
31
|
+
i = 0
|
|
32
|
+
|
|
33
|
+
def emit():
|
|
34
|
+
s = "".join(cur)
|
|
35
|
+
cur.clear()
|
|
36
|
+
while True:
|
|
37
|
+
s = s.lstrip(" \t")
|
|
38
|
+
if s == "":
|
|
39
|
+
return
|
|
40
|
+
qq = ""
|
|
41
|
+
k = 0
|
|
42
|
+
ln = len(s)
|
|
43
|
+
while k < ln:
|
|
44
|
+
c = s[k]
|
|
45
|
+
if qq:
|
|
46
|
+
if c == qq:
|
|
47
|
+
qq = ""
|
|
48
|
+
elif c in "\"'":
|
|
49
|
+
qq = c
|
|
50
|
+
elif c in " \t":
|
|
51
|
+
break
|
|
52
|
+
k += 1
|
|
53
|
+
w = s[:k]
|
|
54
|
+
if w in _KEYWORDS:
|
|
55
|
+
s = s[k:]
|
|
56
|
+
if w in _FLAGGED:
|
|
57
|
+
while True:
|
|
58
|
+
s = s.lstrip(" \t")
|
|
59
|
+
if not s.startswith("-"):
|
|
60
|
+
break
|
|
61
|
+
j = 0
|
|
62
|
+
while j < len(s) and s[j] not in " \t":
|
|
63
|
+
j += 1
|
|
64
|
+
s = s[j:]
|
|
65
|
+
continue
|
|
66
|
+
if _ASSIGN.match(w):
|
|
67
|
+
s = s[k:]
|
|
68
|
+
continue
|
|
69
|
+
break
|
|
70
|
+
out.append(s)
|
|
71
|
+
|
|
72
|
+
def cur_ends(chars):
|
|
73
|
+
return bool(cur) and cur[-1][-1:] in chars and cur[-1][-1:] != ""
|
|
74
|
+
|
|
75
|
+
while i < n:
|
|
76
|
+
c = src[i]
|
|
77
|
+
d = src[i + 1] if i + 1 < n else ""
|
|
78
|
+
if q == "s":
|
|
79
|
+
j = src.find("'", i)
|
|
80
|
+
k = src.find("\n", i)
|
|
81
|
+
if j < 0:
|
|
82
|
+
j = n
|
|
83
|
+
if 0 <= k < j:
|
|
84
|
+
cur.append(src[i:k] + " ")
|
|
85
|
+
i = k + 1
|
|
86
|
+
continue
|
|
87
|
+
cur.append(src[i:j + 1])
|
|
88
|
+
i = j + 1
|
|
89
|
+
if j < n:
|
|
90
|
+
q = ""
|
|
91
|
+
continue
|
|
92
|
+
if q == "a":
|
|
93
|
+
m = _SPECIAL_A.search(src, i)
|
|
94
|
+
j = m.start() if m else n
|
|
95
|
+
if j > i:
|
|
96
|
+
cur.append(src[i:j])
|
|
97
|
+
i = j
|
|
98
|
+
continue
|
|
99
|
+
if c == "\\":
|
|
100
|
+
cur.append(c + d)
|
|
101
|
+
i += 2
|
|
102
|
+
continue
|
|
103
|
+
if c == "'":
|
|
104
|
+
q = ""
|
|
105
|
+
cur.append(" " if c == "\n" else c)
|
|
106
|
+
i += 1
|
|
107
|
+
continue
|
|
108
|
+
if c == "\\":
|
|
109
|
+
if d == "\n":
|
|
110
|
+
i += 2
|
|
111
|
+
continue
|
|
112
|
+
cur.append(c + d)
|
|
113
|
+
i += 2
|
|
114
|
+
continue
|
|
115
|
+
if q == "d":
|
|
116
|
+
m = _SPECIAL_D.search(src, i)
|
|
117
|
+
j = m.start() if m else n
|
|
118
|
+
if j > i:
|
|
119
|
+
cur.append(src[i:j])
|
|
120
|
+
i = j
|
|
121
|
+
continue
|
|
122
|
+
if c == '"':
|
|
123
|
+
q = ""
|
|
124
|
+
cur.append(c)
|
|
125
|
+
i += 1
|
|
126
|
+
continue
|
|
127
|
+
if c == "$" and d == "(":
|
|
128
|
+
stk.append("p")
|
|
129
|
+
qst.append(q)
|
|
130
|
+
q = ""
|
|
131
|
+
emit()
|
|
132
|
+
i += 2
|
|
133
|
+
continue
|
|
134
|
+
if c == "`":
|
|
135
|
+
stk.append("b")
|
|
136
|
+
qst.append(q)
|
|
137
|
+
q = ""
|
|
138
|
+
emit()
|
|
139
|
+
i += 1
|
|
140
|
+
continue
|
|
141
|
+
if c == "$":
|
|
142
|
+
cur.append(c)
|
|
143
|
+
i += 1
|
|
144
|
+
continue
|
|
145
|
+
cur.append(" " if c == "\n" else c)
|
|
146
|
+
i += 1
|
|
147
|
+
continue
|
|
148
|
+
# outside quotes
|
|
149
|
+
m = _SPECIAL_OUT.search(src, i)
|
|
150
|
+
j = m.start() if m else n
|
|
151
|
+
if j > i:
|
|
152
|
+
cur.append(src[i:j])
|
|
153
|
+
i = j
|
|
154
|
+
continue
|
|
155
|
+
if c == "'":
|
|
156
|
+
q = "s"
|
|
157
|
+
cur.append(c)
|
|
158
|
+
i += 1
|
|
159
|
+
continue
|
|
160
|
+
if c == "$" and d == "'":
|
|
161
|
+
q = "a"
|
|
162
|
+
cur.append(c + d)
|
|
163
|
+
i += 2
|
|
164
|
+
continue
|
|
165
|
+
if c == '"':
|
|
166
|
+
q = "d"
|
|
167
|
+
cur.append(c)
|
|
168
|
+
i += 1
|
|
169
|
+
continue
|
|
170
|
+
if c == "#" and (not cur or "".join(cur) == "" or cur_ends(" \t")):
|
|
171
|
+
k = src.find("\n", i)
|
|
172
|
+
i = k if k >= 0 else n
|
|
173
|
+
continue
|
|
174
|
+
if c == "<" and d == "<" and src[i + 2:i + 3] == "<":
|
|
175
|
+
cur.append("<<<")
|
|
176
|
+
i += 3
|
|
177
|
+
continue
|
|
178
|
+
if (c == "$" and d == "(" and src[i + 2:i + 3] == "(") or (c == "(" and d == "("):
|
|
179
|
+
j = i + (3 if c == "$" else 2)
|
|
180
|
+
depth = 2
|
|
181
|
+
while j < n and depth > 0:
|
|
182
|
+
e = src[j]
|
|
183
|
+
if e == "(":
|
|
184
|
+
depth += 1
|
|
185
|
+
elif e == ")":
|
|
186
|
+
depth -= 1
|
|
187
|
+
j += 1
|
|
188
|
+
cur.append(src[i:j])
|
|
189
|
+
i = j
|
|
190
|
+
continue
|
|
191
|
+
if c == "<" and d == "<":
|
|
192
|
+
j = i + 2
|
|
193
|
+
strip = 0
|
|
194
|
+
if src[j:j + 1] == "-":
|
|
195
|
+
strip = 1
|
|
196
|
+
j += 1
|
|
197
|
+
while src[j:j + 1] in (" ", "\t") and j < n:
|
|
198
|
+
j += 1
|
|
199
|
+
w = []
|
|
200
|
+
wq = ""
|
|
201
|
+
while j < n:
|
|
202
|
+
e = src[j]
|
|
203
|
+
if wq:
|
|
204
|
+
if e == wq:
|
|
205
|
+
wq = ""
|
|
206
|
+
else:
|
|
207
|
+
w.append(e)
|
|
208
|
+
j += 1
|
|
209
|
+
continue
|
|
210
|
+
if e in "'\"":
|
|
211
|
+
wq = e
|
|
212
|
+
j += 1
|
|
213
|
+
continue
|
|
214
|
+
if e == "\\":
|
|
215
|
+
w.append(src[j + 1:j + 2])
|
|
216
|
+
j += 2
|
|
217
|
+
continue
|
|
218
|
+
if e in " \t\n;&|<>()":
|
|
219
|
+
break
|
|
220
|
+
w.append(e)
|
|
221
|
+
j += 1
|
|
222
|
+
word = "".join(w)
|
|
223
|
+
if word:
|
|
224
|
+
nh += 1
|
|
225
|
+
hd.append(word)
|
|
226
|
+
hs.append(strip)
|
|
227
|
+
i = j
|
|
228
|
+
continue
|
|
229
|
+
if c == "\n":
|
|
230
|
+
emit()
|
|
231
|
+
i += 1
|
|
232
|
+
for h in range(nh):
|
|
233
|
+
while i < n:
|
|
234
|
+
e = src.find("\n", i)
|
|
235
|
+
line = src[i:e] if e >= 0 else src[i:]
|
|
236
|
+
i = e + 1 if e >= 0 else n
|
|
237
|
+
if hs[h]:
|
|
238
|
+
line = line.lstrip("\t")
|
|
239
|
+
if line == hd[h]:
|
|
240
|
+
break
|
|
241
|
+
nh = 0
|
|
242
|
+
hd, hs = [], []
|
|
243
|
+
continue
|
|
244
|
+
if c == "$" and d == "(":
|
|
245
|
+
stk.append("p")
|
|
246
|
+
qst.append(q)
|
|
247
|
+
emit()
|
|
248
|
+
i += 2
|
|
249
|
+
continue
|
|
250
|
+
if c == "$":
|
|
251
|
+
cur.append(c)
|
|
252
|
+
i += 1
|
|
253
|
+
continue
|
|
254
|
+
if c == "`":
|
|
255
|
+
if stk and stk[-1] == "b":
|
|
256
|
+
stk.pop()
|
|
257
|
+
q = qst.pop()
|
|
258
|
+
else:
|
|
259
|
+
stk.append("b")
|
|
260
|
+
qst.append(q)
|
|
261
|
+
emit()
|
|
262
|
+
i += 1
|
|
263
|
+
continue
|
|
264
|
+
if c == "(":
|
|
265
|
+
stk.append("p")
|
|
266
|
+
qst.append(q)
|
|
267
|
+
emit()
|
|
268
|
+
i += 1
|
|
269
|
+
continue
|
|
270
|
+
if c == ")":
|
|
271
|
+
if stk and stk[-1] == "p":
|
|
272
|
+
stk.pop()
|
|
273
|
+
q = qst.pop()
|
|
274
|
+
emit()
|
|
275
|
+
i += 1
|
|
276
|
+
continue
|
|
277
|
+
if c == "&" and (cur_ends("<>") or d == ">"):
|
|
278
|
+
cur.append(c)
|
|
279
|
+
i += 1
|
|
280
|
+
continue
|
|
281
|
+
if c in ";|&":
|
|
282
|
+
emit()
|
|
283
|
+
i += 1
|
|
284
|
+
continue
|
|
285
|
+
cur.append(c)
|
|
286
|
+
i += 1
|
|
287
|
+
emit()
|
|
288
|
+
return chomp_all("".join(s + "\n" for s in out))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "riskrails",
|
|
3
|
-
"version": "4.0
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"description": "RiskRails: a weight-based engineering pipeline for Claude Code and Codex — scoped steps, enforcement hooks, adversarial review, model routing.",
|
|
5
5
|
"keywords": ["claude-code", "codex", "sdlc", "agents", "guardrails", "code-review"],
|
|
6
6
|
"homepage": "https://github.com/vanssata/claude-agentic-sdlc-module#readme",
|
package/skills/AGENTS.md
CHANGED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
<!-- riskrails:rule:skills:start -->
|
|
2
|
-
# Skills rules
|
|
3
|
-
- Weigh a change by what it does to a user project's decisions, never by the skill being a process (constitution C6). The mode is what W gives (`state.py weight`); the rows below set the behaviour axis.
|
|
4
|
-
- behaviour 0: skill or agent text, `usage-report`, no `state.py` refusal moves. behaviour 1: adds a `state.py` check, a sensor or a refusal. behaviour 2: weakens a refusal, changes the approval path (`approve`, `questions --sync`, `AI_UNATTENDED`, the review verdict transitions) or changes install and migration (the `project-update` merge; a migration also raises reversibility through its hint) — declare it, `--declare behaviour=2:"<the effect>"`. Start such a task with `start --mode full --by <name>`: its weight lands in guarded, and these effects keep a STRONG review and the gate.
|
|
5
|
-
- A change to a shipped template or to `ai/` runs `tools/build-template-history.py` in the same task, or `tests/test_project_update.py` (`test_history_covers_every_template`) goes red.
|
|
6
|
-
<!-- riskrails:rule:skills:end -->
|
package/skills/CLAUDE.md
CHANGED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
<!-- riskrails:rule:skills:start -->
|
|
2
|
-
# Skills rules
|
|
3
|
-
- Weigh a change by what it does to a user project's decisions, never by the skill being a process (constitution C6). The mode is what W gives (`state.py weight`); the rows below set the behaviour axis.
|
|
4
|
-
- behaviour 0: skill or agent text, `usage-report`, no `state.py` refusal moves. behaviour 1: adds a `state.py` check, a sensor or a refusal. behaviour 2: weakens a refusal, changes the approval path (`approve`, `questions --sync`, `AI_UNATTENDED`, the review verdict transitions) or changes install and migration (the `project-update` merge; a migration also raises reversibility through its hint) — declare it, `--declare behaviour=2:"<the effect>"`. Start such a task with `start --mode full --by <name>`: its weight lands in guarded, and these effects keep a STRONG review and the gate.
|
|
5
|
-
- A change to a shipped template or to `ai/` runs `tools/build-template-history.py` in the same task, or `tests/test_project_update.py` (`test_history_covers_every_template`) goes red.
|
|
6
|
-
<!-- riskrails:rule:skills:end -->
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# Idempotent .ai/ scaffold for RiskRails.
|
|
3
|
-
# scaffold-ai.sh [project-dir] [--runtime auto|claude|codex|both] (default: $PWD, auto)
|
|
3
|
+
# scaffold-ai.sh [project-dir] [--runtime auto|claude|codex|both|none] (default: $PWD, auto)
|
|
4
|
+
# none: .ai/ and .gitignore only, no instruction file; a whole value, never part of a comma list
|
|
4
5
|
#
|
|
5
6
|
# A shim: the scaffold lives in skills/rr-setup/scaffold_ai.py and reads its
|
|
6
7
|
# templates from this directory. This file keeps every caller's command working.
|
|
@@ -29,9 +29,10 @@ section, and the human decides whether they become their own task.
|
|
|
29
29
|
|
|
30
30
|
The compact record is the human's to switch: `state.py memory enable`, `disable`,
|
|
31
31
|
`cleanup`, `pack`, `redact` and `migrate --move-logs` take the human route, as
|
|
32
|
-
`approve` does
|
|
33
|
-
`
|
|
34
|
-
|
|
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`).
|
|
35
36
|
|
|
36
37
|
## Scope
|
|
37
38
|
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Cleanup, restore, redaction (§D6, §D13) and pack (§D4, R7): the writers that remove, bring back, blank
|
|
2
|
+
or archive what a package holds."""
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
|
|
8
|
+
import memory_config as mcfg
|
|
9
|
+
import memory_format as mf
|
|
10
|
+
import memory_io as mio
|
|
11
|
+
import memory_packages as mpkg
|
|
12
|
+
import memory_verify as mver
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ------------------------------------------------------------------ cleanup, restore, redact (§D6, §D13)
|
|
16
|
+
def cleanup_ready(root, task_id, cfg, packages=None):
|
|
17
|
+
"""The R22 conditions for one task, or the first that fails."""
|
|
18
|
+
packages = packages if packages is not None else mpkg.load_packages(root)
|
|
19
|
+
seals = [(n, e) for n, (_, entries, _) in packages.items() for e in entries
|
|
20
|
+
if e.get("kind") == "task" and e.get("task") == task_id]
|
|
21
|
+
if not cfg.cleanup:
|
|
22
|
+
return "cleanup is off in %s" % mio.POLICY_REL
|
|
23
|
+
if not seals:
|
|
24
|
+
return "no seal for %s in a package in the working tree" % task_id
|
|
25
|
+
findings = mver.check_packages({n: packages[n] for n, _ in seals}, cfg.package_max_bytes)
|
|
26
|
+
if findings:
|
|
27
|
+
return "its package does not verify (%s)" % findings[0].what
|
|
28
|
+
for _, seal in seals:
|
|
29
|
+
if (seal.get("counts") or {}).get("skipped"):
|
|
30
|
+
return "%s has %s torn journal line(s)" % (mf.key_of(seal), seal["counts"]["skipped"])
|
|
31
|
+
journal = mio.read_bytes(os.path.join(root, ".ai", "reports", task_id, "events.jsonl"))
|
|
32
|
+
if journal is not None:
|
|
33
|
+
found = mver.check_journals(lambda rel: journal if rel.endswith("events.jsonl") else None,
|
|
34
|
+
[".ai/reports/%s/events.jsonl" % task_id], packages)
|
|
35
|
+
found = [f for f in found if f.check == "V5"]
|
|
36
|
+
if found:
|
|
37
|
+
return "coverage is incomplete (%s)" % found[0].what
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def cleanup_task(root, task_id, cfg):
|
|
42
|
+
"""B6: .ai/reports/<id> → .ai/state/logs/<id>/report; copy, verify,
|
|
43
|
+
remove when the two are on different filesystems."""
|
|
44
|
+
why = cleanup_ready(root, task_id, cfg)
|
|
45
|
+
if why:
|
|
46
|
+
raise mio.MemoryError_(why, 1)
|
|
47
|
+
src = os.path.join(root, ".ai", "reports", task_id)
|
|
48
|
+
dest = os.path.join(root, ".ai", "state", "logs", task_id, "report")
|
|
49
|
+
if not os.path.isdir(src):
|
|
50
|
+
return "%s already cleaned up" % task_id
|
|
51
|
+
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
52
|
+
if os.path.exists(dest):
|
|
53
|
+
raise mio.MemoryError_("%s exists: 'state.py memory restore %s' first" % (os.path.relpath(dest, root), task_id), 3)
|
|
54
|
+
try:
|
|
55
|
+
if os.environ.get("AI_MEMORY_FORCE_EXDEV"):
|
|
56
|
+
raise OSError(18, "Invalid cross-device link (forced)")
|
|
57
|
+
os.replace(src, dest)
|
|
58
|
+
except OSError as exc:
|
|
59
|
+
if exc.errno != 18:
|
|
60
|
+
raise
|
|
61
|
+
shutil.copytree(src, dest)
|
|
62
|
+
if tree_digest(src) != tree_digest(dest):
|
|
63
|
+
shutil.rmtree(dest, ignore_errors=True)
|
|
64
|
+
raise mio.MemoryError_("the copy of %s does not match its source — nothing removed" % task_id, 3) \
|
|
65
|
+
from exc
|
|
66
|
+
shutil.rmtree(src)
|
|
67
|
+
return "%s → %s" % (os.path.relpath(src, root), os.path.relpath(dest, root))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def tree_digest(path):
|
|
71
|
+
import hashlib # pylint: disable=import-outside-toplevel
|
|
72
|
+
h = hashlib.sha256()
|
|
73
|
+
for dirpath, dirs, names in os.walk(path):
|
|
74
|
+
dirs.sort()
|
|
75
|
+
for name in sorted(names):
|
|
76
|
+
full = os.path.join(dirpath, name)
|
|
77
|
+
h.update(os.path.relpath(full, path).encode() + b"\0" + (mio.read_bytes(full) or b"") + b"\0")
|
|
78
|
+
return h.hexdigest()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def cleanup(root, ids):
|
|
82
|
+
cfg = mcfg.load_config(root)
|
|
83
|
+
if not cfg.packages:
|
|
84
|
+
raise mio.MemoryError_("files mode: cleanup needs packages mode", 2)
|
|
85
|
+
if not cfg.cleanup:
|
|
86
|
+
raise mio.MemoryError_("cleanup is off: set \"cleanup\": true in %s once 'memory verify --all' and "
|
|
87
|
+
"'--fresh-clone' are green (§D6)" % mio.POLICY_REL, 2)
|
|
88
|
+
findings = [f for f in mver.verify(root) if not f.check.endswith("-warn")]
|
|
89
|
+
if findings:
|
|
90
|
+
raise mio.MemoryError_("'memory verify --all' is not green: %s" % mver.format_finding(findings[0]), 1)
|
|
91
|
+
packages = mpkg.load_packages(root)
|
|
92
|
+
if ids == "all":
|
|
93
|
+
ids = sorted(mpkg.sealed_task_ids(packages) & set(os.listdir(os.path.join(root, ".ai", "reports"))))
|
|
94
|
+
out = []
|
|
95
|
+
for task_id in ids:
|
|
96
|
+
try:
|
|
97
|
+
out.append(cleanup_task(root, task_id, cfg))
|
|
98
|
+
except mio.MemoryError_ as exc:
|
|
99
|
+
out.append("%s kept: %s" % (task_id, exc))
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def restore(root, ids):
|
|
104
|
+
import memory_store # pylint: disable=import-outside-toplevel
|
|
105
|
+
logs = os.path.join(root, ".ai", "state", "logs")
|
|
106
|
+
if ids == "all":
|
|
107
|
+
ids = sorted(n for n in os.listdir(logs) if os.path.isdir(os.path.join(logs, n, "report"))) \
|
|
108
|
+
if os.path.isdir(logs) else []
|
|
109
|
+
history = memory_store.FileHistory(root, mcfg.load_config(root))
|
|
110
|
+
ids = sorted(set(ids) | {s.get("task") for s in history.seals().values()
|
|
111
|
+
if not os.path.isdir(os.path.join(root, ".ai", "reports", s.get("task") or "-"))
|
|
112
|
+
and mf.NEW_ID_RE.match(s.get("task") or "")})
|
|
113
|
+
out = []
|
|
114
|
+
history = None
|
|
115
|
+
for task_id in ids:
|
|
116
|
+
dest = os.path.join(root, ".ai", "reports", task_id)
|
|
117
|
+
raw = os.path.join(logs, task_id, "report")
|
|
118
|
+
if os.path.isdir(dest):
|
|
119
|
+
out.append("%s is in .ai/reports/ already" % task_id)
|
|
120
|
+
continue
|
|
121
|
+
if os.path.isdir(raw):
|
|
122
|
+
os.replace(raw, dest)
|
|
123
|
+
out.append("%s restored from %s" % (task_id, os.path.relpath(raw, root)))
|
|
124
|
+
continue
|
|
125
|
+
history = history or memory_store.FileHistory(root, mcfg.load_config(root))
|
|
126
|
+
entries = history.entries(task_id)
|
|
127
|
+
if not entries:
|
|
128
|
+
out.append("%s: nothing recorded" % task_id)
|
|
129
|
+
continue
|
|
130
|
+
os.makedirs(dest)
|
|
131
|
+
md = ["<!-- restored from record %s — a reconstruction, not the original task.md -->" % mio.now()]
|
|
132
|
+
md += mf.render_task_md(entries)
|
|
133
|
+
mio.atomic_write(os.path.join(dest, "task.md"), "\n".join(md) + "\n")
|
|
134
|
+
lines = []
|
|
135
|
+
for e in entries:
|
|
136
|
+
data = {k: v for k, v in e.items() if k not in mf.ENVELOPE}
|
|
137
|
+
data["restored"] = True
|
|
138
|
+
data["rid"] = e["rid"]
|
|
139
|
+
lines.append(json.dumps({"ts": e["ts"], "task": task_id, "event": "restored_%s" % e["kind"],
|
|
140
|
+
"actor": e["actor"], "runtime": e["runtime"], "stage": "",
|
|
141
|
+
"detail": "restored from record", "data": data}, ensure_ascii=False))
|
|
142
|
+
mio.atomic_write(os.path.join(dest, "events.jsonl"), "\n".join(lines) + "\n")
|
|
143
|
+
out.append("%s restored from record (task.md marked, events.jsonl with data.restored)" % task_id)
|
|
144
|
+
return out
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def redact(root, rid, field, why, by=None):
|
|
148
|
+
"""The one exception to append-only (R8): the field becomes "[redacted]",
|
|
149
|
+
the line gets a new rid, a redaction entry names the old one."""
|
|
150
|
+
cfg = mcfg.load_config(root)
|
|
151
|
+
with mio.locked(root):
|
|
152
|
+
packages = mpkg.load_packages(root)
|
|
153
|
+
for name, (header, entries, _) in packages.items():
|
|
154
|
+
hit = [e for e in entries if e["rid"] == rid]
|
|
155
|
+
if not hit:
|
|
156
|
+
continue
|
|
157
|
+
entry = mf.strip_private(hit[0])
|
|
158
|
+
if field in mf.ENVELOPE or field not in entry:
|
|
159
|
+
raise mio.MemoryError_("%s has no redactable field %r" % (rid, field), 2)
|
|
160
|
+
new = dict(entry, **{field: "[redacted]"})
|
|
161
|
+
new = mf.with_rid(new)
|
|
162
|
+
note = mf.with_rid({"v": mf.FORMAT, "task": entry.get("task"), "inst": entry.get("inst") or 1,
|
|
163
|
+
"seq": entry.get("seq") or 0, "kind": "redaction", "ts": mio.now(), "actor": "human",
|
|
164
|
+
"by": by, "runtime": "unknown", "src": ["state.py memory redact"],
|
|
165
|
+
"target": rid, "field": field, "why": why, "replacement": new["rid"]})
|
|
166
|
+
rest = [mf.strip_private(e) for e in entries if e["rid"] != rid]
|
|
167
|
+
mio.atomic_write(os.path.join(mio.memory_dir(root), name), mf.render_package(header, rest + [new, note]))
|
|
168
|
+
del cfg
|
|
169
|
+
return new["rid"], name
|
|
170
|
+
raise mio.MemoryError_("no entry %s in .ai/memory/" % rid, 1)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ------------------------------------------------------------------ pack (§D4, R7)
|
|
174
|
+
def pack(root, period):
|
|
175
|
+
cfg = mcfg.load_config(root)
|
|
176
|
+
if not re.match(r"^\d{4}(?:-\d{2}|-Q[1-4])?$", period or ""):
|
|
177
|
+
raise mio.MemoryError_("--period takes YYYY-MM, YYYY-Qn or YYYY", 2)
|
|
178
|
+
grain = "quarter" if "-Q" in period else ("month" if "-" in period else "year")
|
|
179
|
+
current = mf.period_of(mio.now(), grain)
|
|
180
|
+
if period >= current:
|
|
181
|
+
raise mio.MemoryError_("period %s has not ended (now %s)" % (period, current), 2)
|
|
182
|
+
with mio.locked(root):
|
|
183
|
+
packages = mpkg.load_packages(root)
|
|
184
|
+
mine = [n for n in packages if (mf.parse_package_name(n) or {}).get("period") == period
|
|
185
|
+
and (mf.parse_package_name(n) or {}).get("author") == cfg.author]
|
|
186
|
+
if not mine:
|
|
187
|
+
raise mio.MemoryError_("no package of %s for %s" % (cfg.author, period), 1)
|
|
188
|
+
target = mf.package_name(period, cfg.author)
|
|
189
|
+
before = set()
|
|
190
|
+
entries = {}
|
|
191
|
+
for name in mine:
|
|
192
|
+
for e in packages[name][1]:
|
|
193
|
+
before.add(e["rid"])
|
|
194
|
+
entries[e["rid"]] = mf.strip_private(e)
|
|
195
|
+
seals = {mf.key_of(e): e for e in entries.values() if e.get("kind") == "task"}
|
|
196
|
+
|
|
197
|
+
def order(e):
|
|
198
|
+
seal = seals.get(mf.key_of(e)) or {}
|
|
199
|
+
return (seal.get("closed_at") or e.get("ts") or "", mf.key_of(e), e.get("seq") or 0, e["rid"])
|
|
200
|
+
ordered = sorted(entries.values(), key=order)
|
|
201
|
+
header = mf.package_header(period, cfg.author, None, mio.now())
|
|
202
|
+
data = b"\n".join([mf.canonical(header)] + [mf.canonical(e) for e in ordered]) + b"\n"
|
|
203
|
+
mio.atomic_write(os.path.join(mio.memory_dir(root), target), data)
|
|
204
|
+
_, check, errors = mf.parse_package(data, target)
|
|
205
|
+
if errors or {e["rid"] for e in check} != before:
|
|
206
|
+
raise mio.MemoryError_("the pack does not hold the batches' rid set — batches kept", 3)
|
|
207
|
+
removed = [n for n in mine if n != target]
|
|
208
|
+
for name in removed:
|
|
209
|
+
os.remove(os.path.join(mio.memory_dir(root), name))
|
|
210
|
+
books = mpkg.read_batches(root)
|
|
211
|
+
books["open"] = {b: n for b, n in books["open"].items() if n not in removed}
|
|
212
|
+
mpkg.write_batches(root, books)
|
|
213
|
+
return target, removed, len(before)
|
|
@@ -872,9 +872,34 @@ def _seal(ctx, entries, inst, last, state, started, closed, seal_src, summary, d
|
|
|
872
872
|
if truncated:
|
|
873
873
|
seal["truncated"] = truncated
|
|
874
874
|
seal["unrecorded"] = sorted(set(unrecorded))
|
|
875
|
+
_fit_changes(seal)
|
|
875
876
|
return mf.with_rid(seal)
|
|
876
877
|
|
|
877
878
|
|
|
879
|
+
def _fit_changes(seal):
|
|
880
|
+
"""GitHub #74: a seal line over mf.LINE_MAX_BYTES (a task that changed ~1200 files) is unreadable to
|
|
881
|
+
parse_package. Keep the longest prefix of `changes` whose line fits, say so in `truncated` and
|
|
882
|
+
`changes_truncated`. A seal that fits is untouched; the result depends only on the seal's own fields."""
|
|
883
|
+
rows = seal.get("changes")
|
|
884
|
+
if not isinstance(rows, list) or not rows or len(mf.canonical(mf.with_rid(seal))) <= mf.LINE_MAX_BYTES:
|
|
885
|
+
return
|
|
886
|
+
total = {k: sum(r[k] for r in rows if isinstance(r, dict) and isinstance(r.get(k), int)
|
|
887
|
+
and not isinstance(r.get(k), bool)) for k in ("added", "deleted")}
|
|
888
|
+
seal["changes_truncated"] = {"rows": len(rows), "added": total["added"], "deleted": total["deleted"]}
|
|
889
|
+
seal["truncated"] = sorted(set(seal.get("truncated") or []) | {"changes"})
|
|
890
|
+
seal["changes"] = []
|
|
891
|
+
room = mf.LINE_MAX_BYTES - len(mf.canonical(mf.with_rid(seal)))
|
|
892
|
+
keep = 0
|
|
893
|
+
for row in rows:
|
|
894
|
+
room -= len(mf.canonical(row)) + (1 if keep else 0)
|
|
895
|
+
if room < 0:
|
|
896
|
+
break
|
|
897
|
+
keep += 1
|
|
898
|
+
seal["changes"] = rows[:keep]
|
|
899
|
+
while seal["changes"] and len(mf.canonical(mf.with_rid(seal))) > mf.LINE_MAX_BYTES:
|
|
900
|
+
seal["changes"] = seal["changes"][:-1]
|
|
901
|
+
|
|
902
|
+
|
|
878
903
|
# ------------------------------------------------------------------ known-risks.md (§D9, R24)
|
|
879
904
|
RISK_HEAD_RE = re.compile(r"^## (.+)$")
|
|
880
905
|
RISK_FIELD_RE = re.compile(r"^- \*\*([^*]+)\*\*:\s?(.*)$")
|