riskrails 3.0.0 → 3.0.1

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 (81) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/README.md +246 -812
  5. package/agents/ai-reviewer.md.tmpl +6 -4
  6. package/ai/policies/review-economy.md +7 -2
  7. package/ai/using-riskrails.md +15 -0
  8. package/bin/claude-1m +15 -14
  9. package/bin/riskrails.js +108 -32
  10. package/codex/hooks.json +25 -2
  11. package/hooks/AGENTS.md +2 -2
  12. package/hooks/CLAUDE.md +2 -2
  13. package/hooks/ai-guard.py +29 -8
  14. package/hooks/ai-path-guard-defaults.json +3 -1
  15. package/hooks/codex-hooks.json +25 -2
  16. package/hooks/context-guard.py +251 -35
  17. package/hooks/dialog-gate.py +105 -0
  18. package/hooks/guard/common.py +5 -0
  19. package/hooks/guard/decision.py +123 -0
  20. package/hooks/guard/dialog.py +598 -0
  21. package/hooks/guard/git.py +49 -3
  22. package/hooks/guard/paths.py +62 -3
  23. package/hooks/hooks.json +37 -2
  24. package/hooks/memory-pre-push.py +28 -0
  25. package/hooks/runtime-gate.py +59 -12
  26. package/hooks/update_notice.py +135 -0
  27. package/instructions/routing.md +4 -4
  28. package/instructions/stub.md +2 -2
  29. package/package.json +1 -1
  30. package/profiles/max.json +3 -1
  31. package/profiles/pro.json +3 -1
  32. package/scripts/merge-codex-config.py +134 -0
  33. package/scripts/resolve-profile.py +15 -3
  34. package/settings.common.json +39 -2
  35. package/skills/AGENTS.md +2 -2
  36. package/skills/CLAUDE.md +2 -2
  37. package/skills/ai-init/templates/.ai/AGENTS.md +2 -2
  38. package/skills/ai-init/templates/.ai/VERSION +1 -1
  39. package/skills/ai-init/templates/.ai/policies/dialog.json +1 -0
  40. package/skills/ai-init/templates/.ai/policies/safety.md +22 -2
  41. package/skills/ai-init/templates/.ai/policies/weight.json +1 -0
  42. package/skills/ai-init/templates/.ai/policies/weight.md +2 -2
  43. package/skills/ai-init/templates/.ai/state/README.md +9 -6
  44. package/skills/ai-init/templates/AGENTS.block.md +1 -1
  45. package/skills/ai-init/templates/CLAUDE.block.md +1 -1
  46. package/skills/ai-init/templates/GEMINI.block.md +1 -1
  47. package/skills/ai-init/templates/junie-guidelines.block.md +1 -1
  48. package/skills/ai-task/memory_compact.py +968 -0
  49. package/skills/ai-task/memory_format.py +775 -0
  50. package/skills/ai-task/memory_store.py +528 -0
  51. package/skills/ai-task/memory_sync.py +1571 -0
  52. package/skills/ai-task/sensors.py +2 -2
  53. package/skills/ai-task/state.py +967 -70
  54. package/skills/ai-task/weight.py +3 -3
  55. package/skills/project-init/templates/constitution.md +1 -1
  56. package/skills/project-update/history/blobs/164cd34c182de88c33de7df35beb29200d792419e1c13a5f0b2104f944e57be7 +1 -0
  57. package/skills/project-update/history/blobs/1d1d907449ec74594d0ee923ac142ae746b9aedf0a1eda9469f075a44d23e3d7 +13 -0
  58. package/skills/project-update/history/blobs/23a42febbacaa7b52a802d428033a99c0694391c81d51fe3b6e1dff608a641d5 +67 -0
  59. package/skills/project-update/history/blobs/5353ca529c46f2443e12947ec205a6887fb594a4de1f87f0eb1c0e6c0f826133 +49 -0
  60. package/skills/project-update/history/blobs/5f65bc7ab3fc3c4e75ae05d49eaf4331fafc398c661abe33c69f7380a720344c +32 -0
  61. package/skills/project-update/history/blobs/7be527cd648b27cca6e267de30f8e33a2ce17d5631a82694f23ae5c0623efe1f +15 -0
  62. package/skills/project-update/history/blobs/7ee99edafe7cec45ff577376c0d0178fdb2690134d4656488f46e129aba3cc5a +67 -0
  63. package/skills/project-update/history/blobs/8b3ef9586491a426767c01da5d01bdd6b0655c576c68c4cc6dfb093367817dc0 +67 -0
  64. package/skills/project-update/history/blobs/917df3320d778ddbaa5c5c7742bc4046bf803c36ed2b050f30844ed206783469 +1 -0
  65. package/skills/project-update/history/blobs/935af1019931058ec3a0b834fe230e127ec914f75edcc4a6c589cfe733b0b979 +13 -0
  66. package/skills/project-update/history/blobs/ae5ce131d6bae401199ff32d8c3dc4d8aec188010a9111624ac64506e2116ff4 +84 -0
  67. package/skills/project-update/history/blobs/ca4499d829268c8766fb3b825a515e88dc55c237e9d202816b50959b4dac031f +13 -0
  68. package/skills/project-update/history/blobs/e9adb7a9905eb719ac78ab96671f055da53e5001810f5ad6d6c94306b0323fe7 +74 -0
  69. package/skills/project-update/history/blobs/fae3064e648cdc55be4aeb1177525b302a5e34ebd752e4a904f47dc0f4557b96 +71 -0
  70. package/skills/project-update/history/index.json +32 -13
  71. package/skills/project-update/migrations/0009_state_file.py +25 -0
  72. package/skills/project-update/migrations/0010_dialog_switch.py +30 -0
  73. package/skills/project-update/migrations/README.md +2 -1
  74. package/skills/project-update/update.py +12 -3
  75. package/skills/rr-build/SKILL.md +14 -5
  76. package/skills/rr-build-all/SKILL.md +190 -0
  77. package/skills/rr-plan/SKILL.md +13 -3
  78. package/skills/rr-review/SKILL.md +18 -8
  79. package/skills/rr-ship/SKILL.md +16 -8
  80. package/skills/rr-status/SKILL.md +2 -2
  81. package/skills/usage-report/usage-report.py +41 -8
@@ -0,0 +1,775 @@
1
+ """The compact project record, format 1 — pure functions, no I/O.
2
+
3
+ Spec: docs/sdlc/specs/compact-project-memory.md §D2, §D3, §D8, §I1, §I2. The
4
+ pre-push validator, the compaction, both History backends and state.py import
5
+ this one copy, so the canonical form, the rid, the ranker and the matcher are
6
+ the same everywhere (R11).
7
+ """
8
+ import fnmatch
9
+ import hashlib
10
+ import json
11
+ import math
12
+ import re
13
+ import unicodedata
14
+
15
+ FORMAT = 1
16
+ PKG = "riskrails-memory"
17
+ KEEP_VERSION = 1
18
+ KINDS = ("task", "weight", "override", "scope", "question", "approval", "verify", "review", "note",
19
+ "handoff", "risk", "conflict", "redaction")
20
+ ACTORS = ("human", "agent", "hook", "migration", "unknown")
21
+ RUNTIMES = ("claude", "codex", "unknown")
22
+ LABELS = ("KNOWN FACT", "INFERENCE", "UNKNOWN", "RISK")
23
+ RISK_STATUS = ("open", "ticketed", "accepted", "fixed")
24
+ SEVERITIES = ("BLOCKER", "HIGH", "MEDIUM", "LOW", "INFO")
25
+ ENVELOPE = ("v", "rid", "task", "inst", "seq", "kind", "ts", "actor", "by", "runtime", "src")
26
+ REQUIRED = {
27
+ "task": ("result", "goal", "workflow", "mode", "w", "axes", "started_at", "closed_at", "files", "steps",
28
+ "stages", "changes", "code", "facts", "verify_runs", "counts", "people", "runtimes", "entries",
29
+ "digest", "source", "schema", "version"),
30
+ "weight": ("change", "w", "mode", "axes", "from", "to", "lines"),
31
+ "override": ("what", "from", "to", "via", "why"),
32
+ "scope": ("change", "added", "why"),
33
+ "question": ("qid", "stage", "question", "context", "options", "recommended", "answer"),
34
+ "approval": ("gate_id", "requested_at", "decision", "at", "via", "unattended", "tty", "note", "why",
35
+ "tree", "confirm", "operations"),
36
+ "verify": ("n", "scope", "exit", "tree", "command", "duration_s", "expected_fail", "after_fix", "why",
37
+ "excerpt", "log"),
38
+ "review": ("pass", "tier", "focus", "verdict", "findings", "closed", "report"),
39
+ "note": ("note_kind", "text", "why", "error"),
40
+ "handoff": ("from", "to", "via", "reason"),
41
+ "risk": ("rkey", "title", "where", "label", "what", "likelihood", "fix", "status", "status_why",
42
+ "found_by", "text"),
43
+ "conflict": ("key", "rids", "reason"),
44
+ "redaction": ("target", "field", "why"),
45
+ }
46
+ AGENT_TEXT_MAX = 2000
47
+ LINE_MAX_BYTES = 65536
48
+ RECALL_LINE_MAX = 300
49
+ RECALL_MAX_BUDGET = 8000
50
+
51
+ # KEEP_RULES v1 (§D3): what each journal event becomes. "seal" and "summary"
52
+ # lines are summarised into the seal's arrays; "drop" lines are counted in
53
+ # counts.dropped; an event no rule names is kept as note/unknown-event.
54
+ KEEP_RULES = {1: {
55
+ "task_started": "seal", "task_closed": "seal",
56
+ "weight_set": "weight", "weight_raised": "weight", "tier_raised": "weight",
57
+ "mode_set": "override", "human_set": "override",
58
+ "plan_registered": "scope", "scope_change": "scope",
59
+ "question_asked": "question", "question_answered": "question",
60
+ "gate_requested": "approval", "gate_approved": "approval", "gate_rejected": "approval",
61
+ "review_verdict": "review", "findings_closed": "review",
62
+ "verify_run": "verify",
63
+ "note": "note", "field_set": "note", "plan_drift": "note", "review_budget_refused": "note",
64
+ "legacy": "note",
65
+ "runtime_handoff": "handoff",
66
+ "risk_recorded": "risk",
67
+ "stage_started": "summary", "step_started": "summary", "step_done": "summary",
68
+ "handoff_written": "drop", "schema_migrated": "drop", "model_fallback": "drop",
69
+ "missed_reroute": "drop",
70
+ }}
71
+ DROP_PREFIXES = ("decision_", "consent_")
72
+
73
+ TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") # state.py's, every id it accepts
74
+ LEGACY_ID_RE = re.compile(r"^T-\d{4}-\d{2}-\d{2}-\d{3}$")
75
+ NEW_ID_RE = re.compile(r"^T-\d{4}-\d{2}-\d{2}-\d{4}-(?=[a-z0-9-]{3,24}$)[a-z0-9]+(?:-[a-z0-9]+)*$")
76
+ SLUG_RE = re.compile(r"^(?=.{3,24}$)[a-z0-9]+(?:-[a-z0-9]+)*$")
77
+ RID_RE = re.compile(r"^r1-[0-9a-f]{24}$")
78
+ AUTHOR_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,31}$")
79
+ PACKAGE_RE = re.compile(r"^(?P<period>\d{4}(?:-\d{2}|-Q[1-4])?)\.(?P<author>[a-z0-9][a-z0-9-]{0,31})"
80
+ r"(?:\.(?P<batch>[0-9a-hjkmnp-tv-z]{4}))?\.jsonl$")
81
+ TS_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$")
82
+ PERIODS = ("month", "quarter", "year")
83
+ GLOB_CHARS = "*?["
84
+
85
+ # A fixed table (§D2), Bulgarian first; NFKD and the [a-z0-9] filter handle the rest.
86
+ TRANSLIT = {
87
+ "а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ж": "zh", "з": "z", "и": "i", "й": "y",
88
+ "к": "k", "л": "l", "м": "m", "н": "n", "о": "o", "п": "p", "р": "r", "с": "s", "т": "t", "у": "u",
89
+ "ф": "f", "х": "h", "ц": "ts", "ч": "ch", "ш": "sh", "щ": "sht", "ъ": "a", "ь": "y", "ю": "yu",
90
+ "я": "ya", "ё": "yo", "э": "e", "ы": "y", "і": "i", "ї": "yi", "є": "ye", "ґ": "g", "ў": "u",
91
+ "ђ": "dj", "ј": "j", "љ": "lj", "њ": "nj", "ћ": "c", "џ": "dz", "ѓ": "gj", "ќ": "kj", "ѕ": "dz",
92
+ }
93
+
94
+ # The redaction set (R5, risk 11): applied to every log excerpt before it is kept.
95
+ REDACTIONS = [
96
+ re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?(-----END [A-Z ]*PRIVATE KEY-----|$)", re.S),
97
+ re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"),
98
+ re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b"),
99
+ re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,}\b"),
100
+ re.compile(r"\bxox[abposr]-[A-Za-z0-9-]{10,}\b"),
101
+ re.compile(r"\bsk-(?:ant-)?[A-Za-z0-9_-]{16,}\b"),
102
+ re.compile(r"\bnpm_[A-Za-z0-9]{20,}\b"),
103
+ re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b"),
104
+ re.compile(r"(?i)\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{12,}"),
105
+ re.compile(r"(?i)\b([a-z0-9_]*(?:password|passwd|secret|token|api[_-]?key|access[_-]?key)[a-z0-9_]*)"
106
+ r"(\s*[:=]\s*)(\"[^\"]*\"|'[^']*'|\S+)"),
107
+ re.compile(r"(?i)\b[a-z][a-z0-9+.-]*://[^/\s:@]+:[^/\s@]+@"),
108
+ ]
109
+
110
+
111
+ # ------------------------------------------------------------------ ids (§D2, R1)
112
+ def slug_of(goal):
113
+ """Casefold, transliterate, keep [a-z0-9] words, whole words while they fit
114
+ in 24 characters; 'task' when nothing is left."""
115
+ text = "".join(TRANSLIT.get(ch, ch) for ch in (goal or "").casefold())
116
+ text = "".join(ch for ch in unicodedata.normalize("NFKD", text) if not unicodedata.combining(ch))
117
+ words = re.findall(r"[a-z0-9]+", text)
118
+ out = []
119
+ for word in words:
120
+ if len("-".join(out + [word])) > 24:
121
+ break
122
+ out.append(word)
123
+ if not out and words:
124
+ out = [words[0][:24]]
125
+ slug = "-".join(out)
126
+ if len(slug) < 3:
127
+ slug = (slug + "-task") if slug else "task"
128
+ return slug
129
+
130
+
131
+ def with_suffix(slug, n):
132
+ """The local disambiguator, counted inside the 24 characters."""
133
+ if n <= 1:
134
+ return slug
135
+ tail = "-%d" % n
136
+ return slug[:24 - len(tail)].rstrip("-") + tail
137
+
138
+
139
+ def new_task_id(now, slug):
140
+ """`now` is an aware UTC datetime; the id is T-YYYY-MM-DD-HHMM-<slug>."""
141
+ if not SLUG_RE.match(slug or ""):
142
+ raise ValueError("slug must be 3-24 characters of [a-z0-9] words joined by '-': %r" % slug)
143
+ return "T-%s-%s" % (now.strftime("%Y-%m-%d-%H%M"), slug)
144
+
145
+
146
+ def is_task_id(task_id):
147
+ return bool(task_id) and bool(TASK_ID_RE.match(task_id))
148
+
149
+
150
+ # ------------------------------------------------------------------ the journal rules
151
+ def classify(event, schema=None, version=KEEP_VERSION):
152
+ """The KEEP_RULES class of one journal event type."""
153
+ if event == "tier_set":
154
+ # schema 7 writes a compat tier_set beside weight_set (state.py cmd_start).
155
+ return "weight" if (schema or 0) < 7 else "summary"
156
+ rule = KEEP_RULES[version].get(event)
157
+ if rule:
158
+ return rule
159
+ if isinstance(event, str) and event.startswith(DROP_PREFIXES):
160
+ return "drop"
161
+ return "unknown"
162
+
163
+
164
+ # ------------------------------------------------------------------ canonical form (§D3)
165
+ def canonical(obj):
166
+ """The one serialisation: sorted keys, no spaces, UTF-8, no trailing LF."""
167
+ return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
168
+
169
+
170
+ def make_rid(entry):
171
+ body = {k: v for k, v in entry.items() if k != "rid"}
172
+ return "r1-" + hashlib.sha256(canonical(body)).hexdigest()[:24]
173
+
174
+
175
+ def with_rid(entry):
176
+ entry = dict(entry)
177
+ entry.pop("rid", None)
178
+ entry["rid"] = make_rid(entry)
179
+ return entry
180
+
181
+
182
+ def seal_digest(rids):
183
+ return "sha256:" + hashlib.sha256("\n".join(sorted(rids)).encode("utf-8")).hexdigest()
184
+
185
+
186
+ def key_of(entry):
187
+ return "%s#%s" % (entry.get("task"), entry.get("inst"))
188
+
189
+
190
+ def cap(text, limit=AGENT_TEXT_MAX):
191
+ """(text, cut): agent text capped at 2 000 characters (§D3)."""
192
+ if isinstance(text, str) and len(text) > limit:
193
+ return text[:limit], True
194
+ return text, False
195
+
196
+
197
+ def validate(entry):
198
+ """Every problem with one entry, as short strings; [] when it is valid."""
199
+ if not isinstance(entry, dict):
200
+ return ["not an object"]
201
+ errors = ["missing %s" % k for k in ENVELOPE if k not in entry]
202
+ if errors:
203
+ return errors
204
+ kind = entry["kind"]
205
+ if entry["v"] != FORMAT:
206
+ errors.append("v is %r, not %d" % (entry["v"], FORMAT))
207
+ if kind not in KINDS:
208
+ return errors + ["unknown kind %r" % kind]
209
+ if not isinstance(entry["rid"], str) or not RID_RE.match(entry["rid"]):
210
+ errors.append("rid %r is not r1-<24 hex>" % entry["rid"])
211
+ elif entry["rid"] != make_rid(entry):
212
+ errors.append("rid is not the hash of the entry")
213
+ if entry["task"] is None:
214
+ if kind not in ("risk", "redaction", "conflict"):
215
+ errors.append("task is null for kind %s" % kind)
216
+ elif not isinstance(entry["task"], str) or not TASK_ID_RE.match(entry["task"]):
217
+ errors.append("task %r is not a task id" % entry["task"])
218
+ if not isinstance(entry["inst"], int) or isinstance(entry["inst"], bool) or entry["inst"] < 1:
219
+ errors.append("inst must be an integer >= 1")
220
+ if not isinstance(entry["seq"], int) or isinstance(entry["seq"], bool) or entry["seq"] < 0:
221
+ errors.append("seq must be an integer >= 0")
222
+ if not isinstance(entry["ts"], str) or not TS_RE.match(entry["ts"]):
223
+ errors.append("ts %r is not ISO-8601 UTC" % entry["ts"])
224
+ if entry["actor"] not in ACTORS:
225
+ errors.append("actor %r" % entry["actor"])
226
+ if entry["by"] is not None and not isinstance(entry["by"], str):
227
+ errors.append("by must be a string or null")
228
+ if entry["runtime"] not in RUNTIMES:
229
+ errors.append("runtime %r" % entry["runtime"])
230
+ if not isinstance(entry["src"], list) or not entry["src"] or \
231
+ not all(isinstance(s, str) for s in entry["src"]):
232
+ errors.append("src must be a non-empty list of strings")
233
+ for opt in ("unrecorded", "truncated"):
234
+ if opt in entry and not (isinstance(entry[opt], list) and all(isinstance(s, str) for s in entry[opt])):
235
+ errors.append("%s must be a list of strings" % opt)
236
+ if "supersedes" in entry and not (isinstance(entry["supersedes"], str) and RID_RE.match(entry["supersedes"])):
237
+ errors.append("supersedes must be a rid")
238
+ errors += ["%s: missing %s" % (kind, k) for k in REQUIRED[kind] if k not in entry]
239
+ if kind == "risk" and "label" in entry and entry["label"] not in LABELS:
240
+ errors.append("risk label %r" % entry["label"])
241
+ if kind == "risk" and "status" in entry and entry["status"] not in RISK_STATUS:
242
+ errors.append("risk status %r" % entry["status"])
243
+ return errors
244
+
245
+
246
+ # ------------------------------------------------------------------ packages (§D4, §I2)
247
+ def package_name(period, author, batch=None):
248
+ name = "%s.%s.%s.jsonl" % (period, author, batch) if batch else "%s.%s.jsonl" % (period, author)
249
+ if not PACKAGE_RE.match(name):
250
+ raise ValueError("not a package name: %r" % name)
251
+ return name
252
+
253
+
254
+ def parse_package_name(name):
255
+ """{period, author, batch} or None."""
256
+ match = PACKAGE_RE.match(name or "")
257
+ return match.groupdict() if match else None
258
+
259
+
260
+ def package_header(period, author, batch, created):
261
+ return {"author": author, "batch": batch, "created": created, "format": FORMAT, "period": period,
262
+ "pkg": PKG}
263
+
264
+
265
+ def render_package(header, entries):
266
+ """Bytes of a package: the header, then the entries by key and seq."""
267
+ lines = [canonical(header)] + [canonical(e) for e in sort_entries(entries)]
268
+ return b"\n".join(lines) + b"\n"
269
+
270
+
271
+ def sort_entries(entries):
272
+ return sorted(entries, key=lambda e: (str(e.get("task")), e.get("inst") or 0, e.get("seq") or 0,
273
+ e.get("rid") or ""))
274
+
275
+
276
+ def parse_package(data, name=None):
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."""
279
+ errors, entries, header = [], [], None
280
+ if isinstance(data, str):
281
+ data = data.encode("utf-8")
282
+ if data.startswith(b"\xef\xbb\xbf"):
283
+ errors.append((1, "starts with a UTF-8 BOM"))
284
+ data = data[3:]
285
+ if data and not data.endswith(b"\n"):
286
+ errors.append((data.count(b"\n") + 1, "no LF after the last line"))
287
+ raw_lines = data.split(b"\n")
288
+ if raw_lines and raw_lines[-1] == b"":
289
+ raw_lines.pop()
290
+ for number, raw in enumerate(raw_lines, 1):
291
+ if raw.endswith(b"\r"):
292
+ errors.append((number, "CRLF line ending"))
293
+ raw = raw[:-1]
294
+ if len(raw) > LINE_MAX_BYTES:
295
+ errors.append((number, "line is %d bytes, over %d" % (len(raw), LINE_MAX_BYTES)))
296
+ continue
297
+ try:
298
+ obj = json.loads(raw.decode("utf-8"))
299
+ except (UnicodeDecodeError, ValueError) as exc:
300
+ errors.append((number, "not JSON (%s)" % str(exc).split(":")[0]))
301
+ continue
302
+ if canonical(obj) != raw:
303
+ errors.append((number, "not in canonical form (sorted keys, no spaces)"))
304
+ if number == 1:
305
+ header = obj
306
+ if not isinstance(obj, dict) or obj.get("pkg") != PKG or obj.get("format") != FORMAT:
307
+ errors.append((1, "header is not a %s format %d header" % (PKG, FORMAT)))
308
+ elif name:
309
+ parts = parse_package_name(name)
310
+ if not parts:
311
+ errors.append((1, "file name is not <period>.<author>[.<batch>].jsonl"))
312
+ elif (parts["period"], parts["author"], parts["batch"]) != \
313
+ (obj.get("period"), obj.get("author"), obj.get("batch")):
314
+ errors.append((1, "file name does not match its header"))
315
+ continue
316
+ problems = validate(obj)
317
+ if problems:
318
+ errors.append((number, "; ".join(problems)))
319
+ continue
320
+ obj["_line"] = number
321
+ entries.append(obj)
322
+ if header is None and not errors:
323
+ errors.append((1, "empty package"))
324
+ return header, entries, errors
325
+
326
+
327
+ def strip_private(entry):
328
+ return {k: v for k, v in entry.items() if not k.startswith("_")}
329
+
330
+
331
+ def period_of(ts, period):
332
+ year, month = int(ts[0:4]), int(ts[5:7])
333
+ if period == "month":
334
+ return "%04d-%02d" % (year, month)
335
+ if period == "quarter":
336
+ return "%04d-Q%d" % (year, (month - 1) // 3 + 1)
337
+ if period == "year":
338
+ return "%04d" % year
339
+ raise ValueError("period must be one of %s" % ", ".join(PERIODS))
340
+
341
+
342
+ def est_tokens(text):
343
+ if isinstance(text, str):
344
+ text = text.encode("utf-8")
345
+ return int(math.ceil(len(text) / 3.5))
346
+
347
+
348
+ def redact(text):
349
+ if not text:
350
+ return text
351
+ for pattern in REDACTIONS:
352
+ if pattern.groups >= 3:
353
+ text = pattern.sub(lambda m: m.group(1) + m.group(2) + "[redacted]", text)
354
+ else:
355
+ text = pattern.sub("[redacted]", text)
356
+ return text
357
+
358
+
359
+ # ------------------------------------------------------------------ paths (§D8)
360
+ def strip_line(path):
361
+ return re.sub(r":\d+(?:[-,]\d+)*$", "", (path or "").strip().strip("`"))
362
+
363
+
364
+ def path_matches(a, b):
365
+ """Equality, fnmatch either way when either side has a glob, or a directory
366
+ prefix either way."""
367
+ a, b = strip_line(a), strip_line(b)
368
+ if not a or not b:
369
+ return False
370
+ if a == b:
371
+ return True
372
+ if any(c in a for c in GLOB_CHARS) and fnmatch.fnmatchcase(b, a):
373
+ return True
374
+ if any(c in b for c in GLOB_CHARS) and fnmatch.fnmatchcase(a, b):
375
+ return True
376
+ da, db = a.rstrip("/") + "/", b.rstrip("/") + "/"
377
+ return b.startswith(da) or a.startswith(db)
378
+
379
+
380
+ def touches(paths, files):
381
+ return any(path_matches(p, f) for p in paths for f in files)
382
+
383
+
384
+ def seal_paths(seal):
385
+ if not seal:
386
+ return []
387
+ out = list(seal.get("files") or [])
388
+ out += [c.get("path") for c in seal.get("changes") or [] if isinstance(c, dict) and c.get("path")]
389
+ return sorted(set(p for p in out if isinstance(p, str)))
390
+
391
+
392
+ def where_list(value):
393
+ if isinstance(value, list):
394
+ return [v for v in value if isinstance(v, str)]
395
+ if isinstance(value, str) and value:
396
+ return [v.strip() for v in value.split(",") if v.strip()]
397
+ return []
398
+
399
+
400
+ def own_paths(entry):
401
+ kind = entry.get("kind")
402
+ if kind == "scope":
403
+ return [p for p in entry.get("added") or [] if isinstance(p, str)]
404
+ if kind == "risk":
405
+ return where_list(entry.get("where"))
406
+ if kind == "task":
407
+ return seal_paths(entry)
408
+ return []
409
+
410
+
411
+ # ------------------------------------------------------------------ recall (§D8, R10, R11)
412
+ def one_line(text, limit=RECALL_LINE_MAX):
413
+ text = re.sub(r"\s+", " ", str(text if text is not None else "")).strip()
414
+ return text if len(text) <= limit else text[:limit - 1] + "…"
415
+
416
+
417
+ def fmt_n(n):
418
+ return "{:,}".format(n).replace(",", " ")
419
+
420
+
421
+ def day(ts):
422
+ return (ts or "")[:10]
423
+
424
+
425
+ def is_human_by(by):
426
+ return bool(by) and by not in ("agent", "hook", "unknown", "migration")
427
+
428
+
429
+ def candidate_items(entries, seals, superseded=frozenset(), all_risks=False):
430
+ """(items, excluded): every recall item the entries give, unranked. A
431
+ review gives one item per finding; a seal its task one-liner. Built from
432
+ fields every source provides, so file and index backends agree (R11)."""
433
+ items, excluded = [], 0
434
+ closed_labels, last_pass, last_risk = {}, {}, {}
435
+ for e in entries:
436
+ if e.get("kind") == "review":
437
+ for c in e.get("closed") or []:
438
+ closed_labels.setdefault(key_of(e), set()).update(c.get("labels") or [])
439
+ if isinstance(e.get("pass"), int):
440
+ last_pass[key_of(e)] = max(last_pass.get(key_of(e), 0), e["pass"])
441
+ elif e.get("kind") == "risk" and e.get("rid") not in superseded:
442
+ have = last_risk.get(e.get("rkey"))
443
+ if have is None or (e.get("ts") or "", e["rid"]) > (have.get("ts") or "", have["rid"]):
444
+ last_risk[e.get("rkey")] = e
445
+ for e in entries:
446
+ kind, rid = e.get("kind"), e.get("rid")
447
+ if rid in superseded:
448
+ excluded += 1
449
+ continue
450
+ if kind == "risk" and last_risk.get(e.get("rkey")) is not e:
451
+ excluded += 1 # an older state of a risk recorded again later
452
+ continue
453
+ if kind == "review" and isinstance(e.get("pass"), int) and e["pass"] < last_pass.get(key_of(e), 0):
454
+ excluded += 1 # a later pass of the same task is the standing review
455
+ continue
456
+ seal = seals.get(key_of(e)) if e.get("task") else None
457
+ inherited = seal_paths(seal)
458
+ task, ts = e.get("task") or "-", e.get("ts") or ""
459
+ base = {"rid": rid, "kind": kind, "task": e.get("task"), "ts": ts, "src": list(e.get("src") or [])}
460
+
461
+ def add(tier, line, paths, order=0, base=base): # pylint: disable=dangerous-default-value
462
+ items.append(dict(base, tier=tier, order=order, line=one_line(line), paths=paths))
463
+ if kind == "risk":
464
+ status = e.get("status")
465
+ if status == "fixed" and not all_risks:
466
+ excluded += 1
467
+ continue
468
+ where = where_list(e.get("where"))
469
+ tier = 0 if status == "open" else 6
470
+ add(tier, "[risk %s %s] %s — %s (%s; found %s)" % (
471
+ status, e.get("label"), where[0] if where else "-", e.get("title"), e.get("rkey"),
472
+ e.get("found_by") or "-"), where)
473
+ elif kind == "review":
474
+ closed = closed_labels.get(key_of(e), set())
475
+ for f in e.get("findings") or []:
476
+ if f.get("label") in closed or f.get("severity") not in SEVERITIES:
477
+ continue
478
+ where = where_list(f.get("where"))
479
+ add(1, "[finding %s residual] %s — %s (%s %s, %s, %s)" % (
480
+ f.get("severity"), where[0] if where else "-", f.get("text"), task, f.get("label"),
481
+ e.get("tier"), day(ts)), where or inherited, SEVERITIES.index(f["severity"]))
482
+ elif kind == "question":
483
+ answer = e.get("answer") or {}
484
+ if not answer:
485
+ continue
486
+ who = "human" if answer.get("actor") == "human" or is_human_by(answer.get("by")) else "agent"
487
+ option = next((o.get("text") for o in e.get("options") or []
488
+ if o.get("key") == answer.get("choice")), None)
489
+ add(2 if who == "human" else 3, '[answer %s] %s/%s — "%s" → %s%s by %s via %s, %s' % (
490
+ who, task, e.get("qid"), e.get("question") or "?", answer.get("choice") or "-",
491
+ ' "%s"' % option if option else (' "%s"' % answer["text"] if answer.get("text") else ""),
492
+ answer.get("by") or "-", answer.get("via") or "-", day(answer.get("at") or ts)), inherited)
493
+ elif kind == "override":
494
+ add(2, "[override %s] %s — %s %s → %s by %s%s" % (
495
+ e.get("actor"), task, e.get("what"), e.get("from"), e.get("to"), e.get("by") or "-",
496
+ " — %s" % e["why"] if e.get("why") else ""), inherited)
497
+ elif kind == "approval":
498
+ if e.get("decision") == "pending":
499
+ continue
500
+ text = e.get("note") if e.get("decision") == "approved" else e.get("why")
501
+ add(2, "[%s %s] %s/%s — by %s via %s, %s%s (historical — authorises nothing now)" % (
502
+ "approval" if e.get("decision") == "approved" else "rejection", e.get("actor"), task,
503
+ e.get("gate_id"), e.get("by") or "-", e.get("via") or "-", day(e.get("at") or ts),
504
+ " — %s" % text if text else ""), inherited)
505
+ elif kind == "note" and e.get("note_kind") in ("decision", "rejected", "failed"):
506
+ add(3, "[%s %s] %s — %s%s" % (
507
+ e.get("note_kind"), e.get("actor"), task, e.get("text") or e.get("error") or "-",
508
+ " — because %s" % e["why"] if e.get("why") else ""), inherited)
509
+ elif kind == "scope" and e.get("why"):
510
+ added = [p for p in e.get("added") or [] if isinstance(p, str)]
511
+ add(4, "[scope] %s — +%s — %s" % (task, ", ".join(added) or "-", e.get("why")), added or inherited)
512
+ elif kind == "task":
513
+ add(5, "[task] %s · %s · %s · %s — %s (%d files)" % (
514
+ task, day(e.get("closed_at") or ts), e.get("mode") or "-", e.get("result") or "-",
515
+ e.get("goal") or "-", len(seal_paths(e))), seal_paths(e))
516
+ return items, excluded
517
+
518
+
519
+ def item_order(item):
520
+ return (item["tier"], item["order"], desc_key(item["ts"]), item["rid"] or "", item["line"])
521
+
522
+
523
+ def desc_key(text):
524
+ """A sort key that orders strings descending inside an ascending sort."""
525
+ return tuple(-ord(c) for c in text or "") + (1,)
526
+
527
+
528
+ def recall_header(nfiles, shown, total, used, budget, source, excluded_task):
529
+ parts = ["# Recall — %s files" % fmt_n(nfiles), "%s of %s items" % (fmt_n(shown), fmt_n(total)),
530
+ "~%s/%s tokens" % (fmt_n(used), fmt_n(budget)),
531
+ "packages %s + legacy %s" % (fmt_n(source.get("packages", 0)), fmt_n(source.get("legacy", 0))),
532
+ "index %s%s" % (source.get("index", "none"), " (fallback: files)" if source.get("fallback") else "")]
533
+ if excluded_task:
534
+ parts.append("current task %s excluded" % excluded_task)
535
+ return " · ".join(parts)
536
+
537
+
538
+ def more_line(more, files, offset):
539
+ return '… %s more: state.py recall --files "%s" --offset %d' % (fmt_n(more), ",".join(files), offset)
540
+
541
+
542
+ def rank_recall(candidates, files, budget, max_items, offset=0, source=None, excluded_task=None):
543
+ """(text, result): the one ranker every backend calls (R11). The output,
544
+ header and continuation line included, stays within budget × 3.5 bytes
545
+ unless the header alone does not fit."""
546
+ source = source or {}
547
+ budget = max(1, min(int(budget), RECALL_MAX_BUDGET))
548
+ matched = sorted((c for c in candidates if touches(c["paths"], files)), key=item_order)
549
+ total = len(matched)
550
+ if not total:
551
+ text = "# Recall — %s files · nothing recorded for these files" % fmt_n(len(files))
552
+ if excluded_task:
553
+ text += " · current task %s excluded" % excluded_task
554
+ return text + "\n", {"items": [], "more": 0, "used": est_tokens(text + "\n"), "next": None}
555
+ rest = matched[offset:]
556
+ limit = budget * 3.5
557
+ # The longest header any backend prints, so every backend packs the same items (R11).
558
+ widest = dict(source, index="sqlite", fallback=True)
559
+ reserve = len(recall_header(len(files), total, total, budget, budget, widest, excluded_task).encode()) + 1
560
+ reserve += len(more_line(total, files, total).encode()) + 1
561
+ chosen, size = [], reserve
562
+ for item in rest:
563
+ line = "- %s\n" % item["line"]
564
+ if len(chosen) >= max_items or size + len(line.encode()) > limit:
565
+ break
566
+ chosen.append(item)
567
+ size += len(line.encode())
568
+ more = len(rest) - len(chosen)
569
+ body = "".join("- %s\n" % i["line"] for i in chosen)
570
+ tail = (more_line(more, files, offset + len(chosen)) + "\n") if more else ""
571
+ # The items and the continuation line, not the header: the header names
572
+ # the backend, and the count must not depend on it (R11).
573
+ used = est_tokens(body + tail)
574
+ header = recall_header(len(files), len(chosen), total, used, budget, source, excluded_task)
575
+ nxt = ('state.py recall --files "%s" --offset %d' % (",".join(files), offset + len(chosen))) if more else None
576
+ return header + "\n" + body + tail, {"items": chosen, "more": more, "used": used, "next": nxt}
577
+
578
+
579
+ # ------------------------------------------------------------------ search (§D8, R13)
580
+ SEARCH_SKIP = {"rid", "src", "digest", "v", "log", "tree", "base_tree", "base_commit", "final_tree",
581
+ "supersedes", "_line", "_package"}
582
+
583
+
584
+ def tokenize(text):
585
+ return re.findall(r"[^\W_]+", (text or "").casefold())
586
+
587
+
588
+ def search_text(entry):
589
+ out = []
590
+
591
+ def walk(value, key=None):
592
+ if key in SEARCH_SKIP:
593
+ return
594
+ if isinstance(value, str):
595
+ out.append(value)
596
+ elif isinstance(value, dict):
597
+ for k in sorted(value):
598
+ walk(value[k], k)
599
+ elif isinstance(value, list):
600
+ for v in value:
601
+ walk(v)
602
+ walk(entry)
603
+ return " ".join(out)
604
+
605
+
606
+ def match_search(entry, tokens, filters, seal=None):
607
+ """Every query token a prefix of one of the entry's tokens, and every filter."""
608
+ filters = filters or {}
609
+ if filters.get("kind") and entry.get("kind") != filters["kind"]:
610
+ return False
611
+ if filters.get("by"):
612
+ names = {entry.get("by"), (entry.get("answer") or {}).get("by") if entry.get("kind") == "question" else None}
613
+ if filters["by"] not in names:
614
+ return False
615
+ if filters.get("task") and entry.get("task") != filters["task"]:
616
+ return False
617
+ ts = entry.get("ts") or ""
618
+ if filters.get("since") and ts[:len(filters["since"])] < filters["since"]:
619
+ return False
620
+ if filters.get("until") and ts[:len(filters["until"])] > filters["until"]:
621
+ return False
622
+ if filters.get("files"):
623
+ paths = own_paths(entry) or seal_paths(seal)
624
+ if entry.get("kind") == "review":
625
+ paths = paths + [w for f in entry.get("findings") or [] for w in where_list(f.get("where"))]
626
+ if not touches(paths, filters["files"]):
627
+ return False
628
+ if not tokens:
629
+ return True
630
+ have = tokenize(search_text(entry))
631
+ return all(any(h.startswith(t) for h in have) for t in tokens)
632
+
633
+
634
+ def search_line(entry, seal=None):
635
+ kind = entry.get("kind")
636
+ text = ""
637
+ if kind == "task":
638
+ text = "%s · %s — %s" % (entry.get("mode"), entry.get("result"), entry.get("goal"))
639
+ elif kind == "question":
640
+ answer = entry.get("answer") or {}
641
+ text = '%s "%s" → %s' % (entry.get("qid"), entry.get("question"), answer.get("choice") or "pending")
642
+ elif kind == "note":
643
+ text = "%s: %s" % (entry.get("note_kind"), entry.get("text") or entry.get("error"))
644
+ elif kind == "risk":
645
+ text = "%s %s %s — %s" % (entry.get("rkey"), entry.get("status"), entry.get("label"), entry.get("title"))
646
+ elif kind == "review":
647
+ text = "pass %s %s %s · %d findings" % (entry.get("pass"), entry.get("tier"), entry.get("verdict"),
648
+ len(entry.get("findings") or []))
649
+ elif kind == "approval":
650
+ text = "%s %s by %s — %s" % (entry.get("gate_id"), entry.get("decision"), entry.get("by"),
651
+ entry.get("note") or entry.get("why") or "")
652
+ elif kind == "scope":
653
+ text = "%s +%s — %s" % (entry.get("change"), ", ".join(entry.get("added") or []), entry.get("why"))
654
+ elif kind == "override":
655
+ text = "%s %s → %s — %s" % (entry.get("what"), entry.get("from"), entry.get("to"), entry.get("why"))
656
+ elif kind == "verify":
657
+ text = "run %s %s exit %s" % (entry.get("n"), entry.get("scope"), entry.get("exit"))
658
+ elif kind == "weight":
659
+ text = "%s W %s → %s" % (entry.get("change"), entry.get("w"), entry.get("mode"))
660
+ else:
661
+ text = search_text(entry)
662
+ del seal
663
+ return one_line("%s %s %s %s — %s" % (day(entry.get("ts")), kind, entry.get("task") or "-",
664
+ entry.get("rid"), text))
665
+
666
+
667
+ def search_order(entry):
668
+ return (desc_key(entry.get("ts") or ""), entry.get("rid") or "")
669
+
670
+
671
+ # ------------------------------------------------------------------ show (§D8)
672
+ def render_task_md(entries):
673
+ """One key's entries as Markdown lines: the `memory show` view and the
674
+ task.md a restore writes. A pure function of the entries."""
675
+ entries = sort_entries(entries)
676
+ seals = [e for e in entries if e.get("kind") == "task"]
677
+ seal = seals[-1] if seals else {}
678
+ task = seal.get("task") or (entries[0].get("task") if entries else "?")
679
+ out = ["# %s — %s" % (task, one_line(seal.get("goal") or "-", 10000)), ""]
680
+ if seal:
681
+ counts = seal.get("counts") or {}
682
+ out += ["%s · %s · W %s · %s · %s → %s · %s" % (
683
+ seal.get("workflow"), seal.get("mode"), seal.get("w"), seal.get("result"), seal.get("started_at"),
684
+ seal.get("closed_at"), ", ".join(seal.get("runtimes") or []) or "unknown"),
685
+ "source: %s · format %d · keep rules %s · %s journal lines, %s skipped · %d entries · %s" % (
686
+ seal.get("source"), FORMAT, seal.get("version"), counts.get("lines"), counts.get("skipped"),
687
+ seal.get("entries") or 0, seal.get("digest")), ""]
688
+ facts = seal.get("facts") or {}
689
+ if facts:
690
+ out += ["facts: (a) %s · (b) %s" % tuple(_fact(facts.get(k)) for k in ("a", "b")), ""]
691
+ files = seal.get("changes") or []
692
+ out += ["## Files", ""]
693
+ if files:
694
+ out += ["- %s (%s, %s)" % (c.get("path"), "binary" if c.get("binary") else "+%s −%s" % (
695
+ c.get("added"), c.get("deleted")), c.get("status")) for c in files]
696
+ else:
697
+ out += ["- %s" % f for f in seal.get("files") or []] or ["none recorded"]
698
+ out.append("")
699
+ if len(seals) > 1:
700
+ out += ["## Conflict", ""] + ["- version %s: %s (%s)" % (chr(97 + i), s.get("rid"), s.get("digest"))
701
+ for i, s in enumerate(seals)] + [""]
702
+ sections = [("question", "Questions"), ("approval", "Approvals"), ("override", "Overrides"),
703
+ ("scope", "Scope"), ("weight", "Weight"), ("verify", "Verify"), ("review", "Review"),
704
+ ("note", "Notes"), ("handoff", "Handoffs"), ("risk", "Risks"), ("conflict", "Conflicts"),
705
+ ("redaction", "Redactions")]
706
+ for kind, title in sections:
707
+ group = [e for e in entries if e.get("kind") == kind]
708
+ if not group:
709
+ continue
710
+ out += ["## %s" % title, ""]
711
+ for e in group:
712
+ out += _render_entry(e)
713
+ out.append("")
714
+ return out
715
+
716
+
717
+ def _fact(fact):
718
+ if isinstance(fact, list) and len(fact) == 2:
719
+ return "%s — %s" % ("green" if fact[0] else "RED", fact[1])
720
+ return "not measured"
721
+
722
+
723
+ def _render_entry(e):
724
+ kind, ref = e.get("kind"), "(%s, %s)" % (e.get("rid"), ", ".join(e.get("src") or []))
725
+ if kind == "question":
726
+ out = ["- %s (%s): %s %s" % (e.get("qid"), e.get("stage"), e.get("question"), ref)]
727
+ out += [" - %s. %s%s" % (o.get("key"), o.get("text"), " (recommended)"
728
+ if o.get("key") == e.get("recommended") else "") for o in e.get("options") or []]
729
+ if e.get("context"):
730
+ out.append(" - context: %s" % e["context"])
731
+ a = e.get("answer")
732
+ out.append(" - answer: %s" % ("%s%s — by %s via %s at %s (%s)" % (
733
+ a.get("choice"), ": %s" % a["text"] if a.get("text") else "", a.get("by"), a.get("via"), a.get("at"),
734
+ a.get("actor")) if a else "pending"))
735
+ return out
736
+ if kind == "approval":
737
+ return ["- %s %s by %s via %s at %s%s%s (historical — authorises nothing now) %s" % (
738
+ e.get("gate_id"), e.get("decision"), e.get("by"), e.get("via"), e.get("at"),
739
+ " — note: %s" % e["note"] if e.get("note") else "", " — why: %s" % e["why"] if e.get("why") else "",
740
+ ref)]
741
+ if kind == "override":
742
+ return ["- %s %s → %s by %s via %s%s %s" % (e.get("what"), e.get("from"), e.get("to"), e.get("by"),
743
+ e.get("via"), " — %s" % e["why"] if e.get("why") else "", ref)]
744
+ if kind == "scope":
745
+ return ["- %s +%s%s %s" % (e.get("change"), ", ".join(e.get("added") or []),
746
+ " — %s" % e["why"] if e.get("why") else "", ref)]
747
+ if kind == "weight":
748
+ return ["- %s W %s → %s%s %s" % (e.get("change"), e.get("w"), e.get("mode"),
749
+ " (%s → %s)" % (e.get("from"), e.get("to")) if e.get("from") or e.get("to")
750
+ else "", ref)]
751
+ if kind == "verify":
752
+ out = ["- run %s %s exit %s%s%s %s" % (e.get("n"), e.get("scope"), e.get("exit"),
753
+ " expected_fail" if e.get("expected_fail") else "",
754
+ " — %s" % e["why"] if e.get("why") else "", ref)]
755
+ if e.get("excerpt"):
756
+ out += [" " + line for line in e["excerpt"].split("\n")]
757
+ return out
758
+ if kind == "review":
759
+ out = ["- pass %s %s %s%s %s" % (e.get("pass"), e.get("tier"), e.get("verdict"),
760
+ " (focus %s)" % e["focus"] if e.get("focus") else "", ref)]
761
+ out += [" - [%s] %s %s — %s" % (f.get("severity"), f.get("label"), f.get("where") or "-", f.get("text"))
762
+ for f in e.get("findings") or []]
763
+ out += [" - closed: %s by run %s" % (", ".join(c.get("labels") or []), c.get("by_run"))
764
+ for c in e.get("closed") or []]
765
+ return out
766
+ if kind == "note":
767
+ return ["- %s: %s%s%s %s" % (e.get("note_kind"), e.get("text"), " — why: %s" % e["why"] if e.get("why")
768
+ else "", " — error: %s" % e["error"] if e.get("error") else "", ref)]
769
+ if kind == "handoff":
770
+ return ["- %s → %s via %s %s" % (e.get("from"), e.get("to"), e.get("via"), ref)]
771
+ if kind == "risk":
772
+ return ["- %s %s [%s] %s — %s %s" % (e.get("rkey"), e.get("status"), e.get("label"), e.get("title"),
773
+ e.get("what"), ref)]
774
+ return ["- %s %s" % (json.dumps({k: v for k, v in e.items() if k not in ENVELOPE}, ensure_ascii=False,
775
+ sort_keys=True), ref)]