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