riskrails 4.0.1 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/README.md +22 -13
  5. package/bin/menu.js +116 -0
  6. package/bin/riskrails.js +119 -20
  7. package/hooks/AGENTS.md +0 -6
  8. package/hooks/CLAUDE.md +0 -6
  9. package/hooks/guard/dialog.py +14 -2
  10. package/hooks/guard/paths.py +48 -1406
  11. package/hooks/guard/paths_base.py +59 -0
  12. package/hooks/guard/paths_bash.py +176 -0
  13. package/hooks/guard/paths_classify.py +205 -0
  14. package/hooks/guard/paths_fs.py +137 -0
  15. package/hooks/guard/paths_human.py +105 -0
  16. package/hooks/guard/paths_interp.py +162 -0
  17. package/hooks/guard/paths_movers.py +393 -0
  18. package/hooks/guard/paths_shell.py +288 -0
  19. package/package.json +1 -1
  20. package/skills/AGENTS.md +0 -6
  21. package/skills/CLAUDE.md +0 -6
  22. package/skills/ai-init/scaffold-ai.sh +2 -1
  23. package/skills/ai-init/templates/.ai/policies/safety.md +4 -3
  24. package/skills/ai-init/templates/gitignore.snippet +2 -0
  25. package/skills/ai-task/memory_cleanup.py +213 -0
  26. package/skills/ai-task/memory_compact.py +25 -0
  27. package/skills/ai-task/memory_config.py +120 -0
  28. package/skills/ai-task/memory_export.py +246 -0
  29. package/skills/ai-task/memory_format.py +7 -3
  30. package/skills/ai-task/memory_hook.py +172 -0
  31. package/skills/ai-task/memory_io.py +122 -0
  32. package/skills/ai-task/memory_migrate.py +187 -0
  33. package/skills/ai-task/memory_packages.py +83 -0
  34. package/skills/ai-task/memory_risks.py +84 -0
  35. package/skills/ai-task/memory_status.py +76 -0
  36. package/skills/ai-task/memory_sync.py +20 -1481
  37. package/skills/ai-task/memory_verify.py +316 -0
  38. package/skills/ai-task/rr_state/cli.py +2 -1
  39. package/skills/ai-task/rr_state/close.py +2 -2
  40. package/skills/ai-task/rr_state/dialog_cmd.py +183 -6
  41. package/skills/ai-task/rr_state/labels.py +14 -2
  42. package/skills/ai-task/rr_state/memory_cmd.py +91 -68
  43. package/skills/project-update/history/blobs/8ec171f0fbdf38cc6996d496f01491fd1f21f04a435a118ad979b127a8be06f1 +85 -0
  44. package/skills/project-update/history/blobs/fa0cb370b024e1a30e1355912032657b08f609e47773a603c193b1e609b0f997 +8 -0
  45. package/skills/project-update/history/index.json +4 -2
  46. package/skills/project-update/rr_update/__init__.py +1 -0
  47. package/skills/project-update/rr_update/apply.py +188 -0
  48. package/skills/project-update/rr_update/blocks.py +38 -0
  49. package/skills/project-update/rr_update/build.py +96 -0
  50. package/skills/project-update/rr_update/cleanup.py +194 -0
  51. package/skills/project-update/rr_update/cli.py +102 -0
  52. package/skills/project-update/rr_update/files.py +106 -0
  53. package/skills/project-update/rr_update/gate.py +60 -0
  54. package/skills/project-update/rr_update/hints.py +128 -0
  55. package/skills/project-update/rr_update/history.py +117 -0
  56. package/skills/project-update/rr_update/kept.py +120 -0
  57. package/skills/project-update/rr_update/paths.py +108 -0
  58. package/skills/project-update/rr_update/plan.py +123 -0
  59. package/skills/project-update/rr_update/report.py +51 -0
  60. package/skills/project-update/rr_update/rules.py +178 -0
  61. package/skills/project-update/rr_update/shared.py +74 -0
  62. package/skills/project-update/rr_update/siblings.py +37 -0
  63. package/skills/project-update/rr_update/weights.py +171 -0
  64. package/skills/project-update/update.py +8 -1682
  65. package/skills/rr-setup/SKILL.md +79 -21
  66. package/skills/rr-setup/scaffold_ai.py +8 -2
  67. package/skills/rr-setup/setup.py +371 -4
@@ -0,0 +1,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.1",
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, and `.ai/memory/` and `.ai/local/memory/` are written by
33
- `state.py` alone — an agent never edits them (`state.py memory --help`; the
34
- plugin's `docs/local-memory.md`).
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
 
@@ -4,3 +4,5 @@
4
4
  .ai/state/*.tmp
5
5
  .ai/state/*.jsonl
6
6
  .ai/local/
7
+ .ai/memory/
8
+ .ai/reports/
@@ -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?(.*)$")