riskrails 3.0.0 → 3.1.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 (112) 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 +259 -812
  5. package/agents/ai-reviewer.md.tmpl +6 -4
  6. package/ai/policies/model-routing.md +7 -7
  7. package/ai/policies/production.md +3 -3
  8. package/ai/policies/release.md +2 -2
  9. package/ai/policies/review-economy.md +34 -32
  10. package/ai/using-riskrails.md +15 -0
  11. package/bin/claude-1m +15 -14
  12. package/bin/riskrails.js +113 -37
  13. package/codex/hooks.json +25 -2
  14. package/hooks/AGENTS.md +2 -2
  15. package/hooks/CLAUDE.md +2 -2
  16. package/hooks/ai-guard.py +29 -8
  17. package/hooks/ai-path-guard-defaults.json +3 -1
  18. package/hooks/codex-hooks.json +25 -2
  19. package/hooks/context-guard.py +252 -36
  20. package/hooks/dialog-gate.py +105 -0
  21. package/hooks/guard/common.py +7 -0
  22. package/hooks/guard/decision.py +123 -0
  23. package/hooks/guard/dialog.py +598 -0
  24. package/hooks/guard/git.py +49 -3
  25. package/hooks/guard/paths.py +89 -19
  26. package/hooks/hooks.json +37 -2
  27. package/hooks/memory-pre-push.py +28 -0
  28. package/hooks/project-scaffold.py +1 -5
  29. package/hooks/runtime-gate.py +65 -18
  30. package/hooks/update_notice.py +141 -0
  31. package/instructions/routing.md +4 -4
  32. package/instructions/stub.md +3 -3
  33. package/package.json +1 -1
  34. package/profiles/max.json +3 -1
  35. package/profiles/pro.json +3 -1
  36. package/scripts/merge-codex-config.py +134 -0
  37. package/scripts/resolve-profile.py +15 -3
  38. package/settings.common.json +39 -2
  39. package/skills/AGENTS.md +3 -3
  40. package/skills/CLAUDE.md +3 -3
  41. package/skills/ai-init/templates/.ai/AGENTS.md +3 -3
  42. package/skills/ai-init/templates/.ai/VERSION +1 -1
  43. package/skills/ai-init/templates/.ai/policies/dialog.json +1 -0
  44. package/skills/ai-init/templates/.ai/policies/safety.md +22 -2
  45. package/skills/ai-init/templates/.ai/policies/security.md +7 -1
  46. package/skills/ai-init/templates/.ai/policies/testing.md +19 -6
  47. package/skills/ai-init/templates/.ai/policies/weight.json +1 -0
  48. package/skills/ai-init/templates/.ai/policies/weight.md +2 -2
  49. package/skills/ai-init/templates/.ai/state/README.md +9 -6
  50. package/skills/ai-init/templates/AGENTS.block.md +2 -2
  51. package/skills/ai-init/templates/CLAUDE.block.md +2 -2
  52. package/skills/ai-init/templates/GEMINI.block.md +2 -2
  53. package/skills/ai-init/templates/gitignore.snippet +1 -0
  54. package/skills/ai-init/templates/junie-guidelines.block.md +2 -2
  55. package/skills/ai-task/memory_compact.py +968 -0
  56. package/skills/ai-task/memory_format.py +775 -0
  57. package/skills/ai-task/memory_store.py +528 -0
  58. package/skills/ai-task/memory_sync.py +1571 -0
  59. package/skills/ai-task/sensors.py +25 -5
  60. package/skills/ai-task/state.py +999 -79
  61. package/skills/ai-task/weight.py +3 -3
  62. package/skills/project-init/templates/constitution.md +1 -1
  63. package/skills/project-init/templates/sdlc-README.md +20 -14
  64. package/skills/project-update/history/blobs/00289f98434fed230eebe768dfb3425f83d737866a3aebc97aa95167c4fa0c6e +131 -0
  65. package/skills/project-update/history/blobs/079a91f976ad69b5cf49ccb9a28849576e4735ddd9485fc26599e528a69fd2f4 +30 -0
  66. package/skills/project-update/history/blobs/07ff7fa6f93d471ff533f553b4e523e4105ccde1f3dab70132c5237728f68422 +13 -0
  67. package/skills/project-update/history/blobs/0ef4a360c76da290173dea0f8073949cb0f8113710a38df95acae5d96bdd1286 +123 -0
  68. package/skills/project-update/history/blobs/164cd34c182de88c33de7df35beb29200d792419e1c13a5f0b2104f944e57be7 +1 -0
  69. package/skills/project-update/history/blobs/18c7a2753ce9cb6f6d3d1e9d5d1125d75950b879d56b7325225b200869631b29 +15 -0
  70. package/skills/project-update/history/blobs/1d1d907449ec74594d0ee923ac142ae746b9aedf0a1eda9469f075a44d23e3d7 +13 -0
  71. package/skills/project-update/history/blobs/23a42febbacaa7b52a802d428033a99c0694391c81d51fe3b6e1dff608a641d5 +67 -0
  72. package/skills/project-update/history/blobs/25d4f2a86deb5e2574bb3210b67bb24fcc4afb19f93a7b65a057daa874a9d18e +1 -0
  73. package/skills/project-update/history/blobs/48d9950f16e31d55475f566a80dc95f64b35f1c804d2f51729e313a4f29bac8d +13 -0
  74. package/skills/project-update/history/blobs/5353ca529c46f2443e12947ec205a6887fb594a4de1f87f0eb1c0e6c0f826133 +49 -0
  75. package/skills/project-update/history/blobs/5a93f78b4ed4d416c171139b2ce610a117f9c1dd46fbb56ca86729cb4c53d10c +6 -0
  76. package/skills/project-update/history/blobs/5f65bc7ab3fc3c4e75ae05d49eaf4331fafc398c661abe33c69f7380a720344c +32 -0
  77. package/skills/project-update/history/blobs/6a3be0e624e5ad91e44d08857b7183ee53b7903e1e8d56ff196dbc9dde271e4a +75 -0
  78. package/skills/project-update/history/blobs/7170553410bba904ffb7edb4b231e3b3579b85542d84de717299980938f82c9d +13 -0
  79. package/skills/project-update/history/blobs/774b1e3a04ef0d49d33c44a2cfecd92eb6f6b738a12b6a681103ba7fc13e7b86 +67 -0
  80. package/skills/project-update/history/blobs/7be527cd648b27cca6e267de30f8e33a2ce17d5631a82694f23ae5c0623efe1f +15 -0
  81. package/skills/project-update/history/blobs/7ee99edafe7cec45ff577376c0d0178fdb2690134d4656488f46e129aba3cc5a +67 -0
  82. package/skills/project-update/history/blobs/7f063e79195a5c85c37b7260dd20c184d3b9bea85e37202e0bbca73ad84bba49 +13 -0
  83. package/skills/project-update/history/blobs/8b3ef9586491a426767c01da5d01bdd6b0655c576c68c4cc6dfb093367817dc0 +67 -0
  84. package/skills/project-update/history/blobs/917df3320d778ddbaa5c5c7742bc4046bf803c36ed2b050f30844ed206783469 +1 -0
  85. package/skills/project-update/history/blobs/935af1019931058ec3a0b834fe230e127ec914f75edcc4a6c589cfe733b0b979 +13 -0
  86. package/skills/project-update/history/blobs/ae5ce131d6bae401199ff32d8c3dc4d8aec188010a9111624ac64506e2116ff4 +84 -0
  87. package/skills/project-update/history/blobs/c8573ca1f810ce6c4ac49456a4c1725b93e0d3b74c54ce95d46d8747777895f0 +13 -0
  88. package/skills/project-update/history/blobs/ca4499d829268c8766fb3b825a515e88dc55c237e9d202816b50959b4dac031f +13 -0
  89. package/skills/project-update/history/blobs/d6e5b34d475e7187d2a8cb2aac7e7f1a427142baf6461bda387d70ceb4d8a8c1 +30 -0
  90. package/skills/project-update/history/blobs/e120ff4e32b2e950fc94ada4e787d57dc9c790da37223e5a202d74f7e3aadf80 +13 -0
  91. package/skills/project-update/history/blobs/e9adb7a9905eb719ac78ab96671f055da53e5001810f5ad6d6c94306b0323fe7 +74 -0
  92. package/skills/project-update/history/blobs/fae3064e648cdc55be4aeb1177525b302a5e34ebd752e4a904f47dc0f4557b96 +71 -0
  93. package/skills/project-update/history/index.json +60 -20
  94. package/skills/project-update/migrations/0009_state_file.py +25 -0
  95. package/skills/project-update/migrations/0010_dialog_switch.py +30 -0
  96. package/skills/project-update/migrations/0011_retire_risk_tiers.py +37 -0
  97. package/skills/project-update/migrations/README.md +2 -1
  98. package/skills/project-update/migrations/__init__.py +6 -1
  99. package/skills/project-update/render_instructions.py +1 -1
  100. package/skills/project-update/update.py +385 -89
  101. package/skills/rr-build/SKILL.md +14 -5
  102. package/skills/rr-build-all/SKILL.md +190 -0
  103. package/skills/rr-plan/SKILL.md +16 -4
  104. package/skills/rr-review/SKILL.md +24 -9
  105. package/skills/rr-setup/SKILL.md +15 -2
  106. package/skills/rr-setup/setup.py +7 -1
  107. package/skills/rr-ship/SKILL.md +16 -8
  108. package/skills/rr-spec/SKILL.md +4 -2
  109. package/skills/rr-status/SKILL.md +2 -2
  110. package/skills/usage-report/usage-report.py +45 -10
  111. package/skills/ai-init/templates/.ai/policies/risk-tiers.json +0 -628
  112. package/skills/ai-init/templates/.ai/policies/risk-tiers.md +0 -180
@@ -0,0 +1,968 @@
1
+ """Close-time and migration-time extraction of the compact record (spec §D3).
2
+
3
+ compact_task reads one task's sources through a reader — FsReader over the
4
+ working tree, GitTreeReader over a commit — and returns its entries and seal.
5
+ Deterministic: the same sources give the same rids. Every journal line is
6
+ accounted for exactly once (R3): as the src of an entry, summarised into the
7
+ seal, or counted as dropped; a line no rule knows is kept as a note.
8
+ """
9
+ import collections
10
+ import json
11
+ import os
12
+ import re
13
+ from datetime import datetime, timezone
14
+
15
+ import memory_format as mf
16
+
17
+ Compaction = collections.namedtuple("Compaction", "entries seal coverage problems")
18
+
19
+ # The question grammar of state.py (HEADING_RE … TRAILER_RE), read-only here:
20
+ # the record must not import state.py, because the pre-push hook imports it.
21
+ HEADING_RE = re.compile(r"^## (Q|G)(\d+)\. (.+)$")
22
+ OPTION_RE = re.compile(r"^([A-W])\. (.+?)( \(recommended\))?$")
23
+ OTHER_RE = re.compile(r"^X\. Other")
24
+ ANSWER_RE = re.compile(r"^\[Answer\]:\s*(.*)$")
25
+ META_RE = re.compile(r"^asked: (\S+) · stage: (\S+)(?: · by: (.+))?$")
26
+ CONTEXT_RE = re.compile(r"^context: (.+)$")
27
+ TRAILER_RE = re.compile(r"\s+—\s+by ([^—]+?) via (picker|prose|file|terminal|unattended|dialog) at "
28
+ r"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)\s*$")
29
+ CHOICE_TEXT_RE = re.compile(r"^([A-W])\s*[—:-]\s*(.+)$")
30
+ OTHER_TEXT_RE = re.compile(r"^X\s*:\s*(.+)$")
31
+ PASS_RE = re.compile(r"^### Pass (\d+) — (\w+), (\w+)(?: \(focus (\w+)\))?(?: by (.+))?$")
32
+ FINDING_RE = re.compile(r"^\s*(?:[-*]\s+)?\[(BLOCKER|HIGH|MEDIUM|LOW|INFO)\]\s+([A-Z][0-9]+)\b[.:]?\s*(.*)$")
33
+ IN_SCOPE_RE = re.compile(r"^\s*in_scope:\s*(yes|no)\b", re.I)
34
+ WHERE_RE = re.compile(r"`([A-Za-z0-9_.~/-]*[/.][A-Za-z0-9_.~/-]*(?::\d+(?:[-,]\d+)*)?)`")
35
+ CHANGE_ROW_RE = re.compile(r"^\| (.+) \| (binary|\+(\d+) −(\d+)) \| (New|Edit|Delete) \|$")
36
+ FACTS_RE = re.compile(r"^facts: \(a\) (.+) · \(b\) (.+)$")
37
+ AMENDED_RE = re.compile(r"^amended (\S+): \+(.*?) — (.*)$")
38
+ ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
39
+ FAIL_LINE_RE = re.compile(r"^(FAIL|ERROR):")
40
+ EVIDENCE_LIMIT_LINES = 12
41
+ EVIDENCE_FAIL_LINES = 10
42
+
43
+
44
+ class FsReader:
45
+ """The working tree. `tracked` sources read only .ai/reports/<id>/, which
46
+ is what every clone has; close sources also read the local raw store."""
47
+
48
+ def __init__(self, root):
49
+ self.root = root
50
+
51
+ def read(self, rel):
52
+ try:
53
+ with open(os.path.join(self.root, rel), "rb") as fh:
54
+ return fh.read()
55
+ except OSError:
56
+ return None
57
+
58
+ def listdir(self, rel):
59
+ try:
60
+ return sorted(os.listdir(os.path.join(self.root, rel)))
61
+ except OSError:
62
+ return []
63
+
64
+
65
+ def report_rel(task_id, name):
66
+ return ".ai/reports/%s/%s" % (task_id, name)
67
+
68
+
69
+ def text_of(data):
70
+ if data is None:
71
+ return None
72
+ return data.decode("utf-8", errors="replace")
73
+
74
+
75
+ # ------------------------------------------------------------------ task.md
76
+ def parse_questions_lines(lines, src_name):
77
+ questions, current = {}, None
78
+ for line in lines:
79
+ heading = HEADING_RE.match(line)
80
+ if heading:
81
+ qid = heading.group(1) + heading.group(2)
82
+ current = {"id": qid, "question": heading.group(3), "asked": None, "stage": None, "by": None,
83
+ "context": None, "options": [], "recommend": None, "answered": False,
84
+ "choice": None, "text": None, "answered_by": None, "via": None, "answered_at": None,
85
+ "src": "%s#%s" % (src_name, qid)}
86
+ questions[qid] = current
87
+ continue
88
+ if current is None:
89
+ continue
90
+ meta = META_RE.match(line)
91
+ if meta:
92
+ current["asked"], current["stage"], current["by"] = meta.groups()
93
+ continue
94
+ context = CONTEXT_RE.match(line)
95
+ if context:
96
+ current["context"] = context.group(1)
97
+ continue
98
+ answer = ANSWER_RE.match(line)
99
+ if answer:
100
+ parse_answer(current, answer.group(1))
101
+ current = None
102
+ continue
103
+ if OTHER_RE.match(line):
104
+ continue
105
+ option = OPTION_RE.match(line)
106
+ if option:
107
+ current["options"].append({"key": option.group(1), "text": option.group(2)})
108
+ if option.group(3):
109
+ current["recommend"] = option.group(1)
110
+ return questions
111
+
112
+
113
+ def parse_answer(question, body):
114
+ trailer = TRAILER_RE.search(body)
115
+ if trailer:
116
+ question["answered_by"], question["via"], question["answered_at"] = trailer.groups()
117
+ body = body[:trailer.start()]
118
+ body = body.strip()
119
+ if not body:
120
+ return
121
+ question["answered"] = True
122
+ other, pair = OTHER_TEXT_RE.match(body), CHOICE_TEXT_RE.match(body)
123
+ if other:
124
+ question["choice"], question["text"] = "X", other.group(1).strip()
125
+ elif pair:
126
+ question["choice"], question["text"] = pair.group(1), pair.group(2).strip()
127
+ elif re.match(r"^[A-Z]$", body):
128
+ question["choice"] = body
129
+ else:
130
+ question["choice"], question["text"] = "X", body
131
+
132
+
133
+ def sections(lines):
134
+ """{heading: [lines]} for the `## ` sections of task.md; a question's own
135
+ `## Q1.` heading stays inside §Questions."""
136
+ out, name, fence = {}, None, None
137
+ for line in lines:
138
+ run = re.match(r"^`{3,}", line)
139
+ if run and (fence is None or run.group(0) == fence):
140
+ # A review report quotes its own `## FINDINGS`: a fence is opaque.
141
+ fence = run.group(0) if fence is None else None
142
+ elif fence is None and line.startswith("## ") and not HEADING_RE.match(line):
143
+ name = line[3:].strip()
144
+ out[name] = []
145
+ continue
146
+ if name is not None:
147
+ out[name].append(line)
148
+ return out
149
+
150
+
151
+ def parse_task_md(text, questions_md=None):
152
+ """What the record takes from task.md: questions, gates, review passes,
153
+ the plan's files, the change table, the facts and the gate line."""
154
+ lines = (text or "").split("\n")
155
+ parts = sections(lines)
156
+ out = {"goal": None, "weight_lines": None, "files": None, "questions": {}, "reviews": {},
157
+ "changes": None, "facts": None, "confirm": None}
158
+ if lines and lines[0].startswith("# ") and " — " in lines[0]:
159
+ out["goal"] = lines[0].split(" — ", 1)[1]
160
+ weight = parts.get("Weight")
161
+ if weight and "```" in weight:
162
+ start = weight.index("```")
163
+ end = weight.index("```", start + 1) if "```" in weight[start + 1:] else len(weight)
164
+ out["weight_lines"] = weight[start + 1:end]
165
+ out["amended"] = []
166
+ for line in parts.get("Plan", []):
167
+ if line.startswith("files: "):
168
+ body = line[len("files: "):]
169
+ out["files"] = [] if body == "none" else [f for f in body.split(", ") if f]
170
+ amended = AMENDED_RE.match(line)
171
+ if amended:
172
+ out["amended"].append({"ts": amended.group(1), "added": amended.group(2).split(", "),
173
+ "why": amended.group(3)})
174
+ out["questions"] = parse_questions_lines(parts.get("Questions", []), "task.md")
175
+ if questions_md is not None:
176
+ for qid, q in parse_questions_lines(questions_md.split("\n"), "questions.md").items():
177
+ out["questions"].setdefault(qid, q)
178
+ review = parts.get("Review", [])
179
+ i = 0
180
+ while i < len(review):
181
+ head = PASS_RE.match(review[i])
182
+ if not head:
183
+ i += 1
184
+ continue
185
+ n, tier, verdict, focus, by = head.groups()
186
+ body, j = [], i + 1
187
+ while j < len(review) and not review[j].startswith("`"):
188
+ if PASS_RE.match(review[j]):
189
+ break
190
+ j += 1
191
+ if j < len(review) and review[j].startswith("`"):
192
+ fence = re.match(r"^`+", review[j]).group(0)
193
+ j += 1
194
+ while j < len(review) and review[j] != fence:
195
+ body.append(review[j])
196
+ j += 1
197
+ out["reviews"][int(n)] = {"tier": tier, "verdict": verdict, "focus": focus, "by": by,
198
+ "text": "\n".join(body)}
199
+ i = j + 1
200
+ changes = []
201
+ for line in parts.get("Changes", []):
202
+ row = CHANGE_ROW_RE.match(line)
203
+ if row:
204
+ path = row.group(1).replace("\\|", "|")
205
+ binary = row.group(2) == "binary"
206
+ changes.append({"path": path, "status": {"New": "A", "Delete": "D"}.get(row.group(5), "M"),
207
+ "added": 0 if binary else int(row.group(3)),
208
+ "deleted": 0 if binary else int(row.group(4)), "binary": binary})
209
+ if changes or any(line == "| File | Change | New/Edit/Delete |" for line in parts.get("Changes", [])):
210
+ out["changes"] = changes
211
+ for line in parts.get("Verdict", []):
212
+ facts = FACTS_RE.match(line)
213
+ if facts:
214
+ parsed = {}
215
+ for key, value in zip(("a", "b"), facts.groups()):
216
+ if value != "not measured" and " — " in value:
217
+ state, detail = value.split(" — ", 1)
218
+ parsed[key] = [state == "green", detail]
219
+ out["facts"] = parsed or None
220
+ if line.startswith("confirm: "):
221
+ value = line[len("confirm: "):]
222
+ out["confirm"] = None if value == "none" else value
223
+ return out
224
+
225
+
226
+ def parse_findings(text):
227
+ """[{label, severity, where, text, in_scope}] from a FINDINGS block."""
228
+ findings, current = [], None
229
+ for line in (text or "").split("\n"):
230
+ match = FINDING_RE.match(line)
231
+ if match:
232
+ severity, label, rest = match.groups()
233
+ where = WHERE_RE.search(rest)
234
+ current = {"label": label, "severity": severity, "where": where.group(1) if where else None,
235
+ "text": rest.strip(), "in_scope": None}
236
+ findings.append(current)
237
+ continue
238
+ if current is None:
239
+ continue
240
+ scope = IN_SCOPE_RE.match(line)
241
+ if scope:
242
+ current["in_scope"] = scope.group(1).lower() == "yes"
243
+ continue
244
+ if not line.strip() or line.startswith("#") or re.match(r"^(VERDICT|verdict:)", line.strip()):
245
+ current = None
246
+ continue
247
+ current["text"] += "\n" + line.strip()
248
+ return findings
249
+
250
+
251
+ def excerpt(log_text, expected_fail, limit):
252
+ """At most `limit` bytes: the last 12 non-empty lines, ANSI stripped, with
253
+ up to 10 FAIL:/ERROR: lines first for an expected_fail run, redacted."""
254
+ if not log_text or limit <= 0:
255
+ return None
256
+ lines = [ANSI_RE.sub("", line).rstrip() for line in log_text.replace("\r\n", "\n").split("\n")]
257
+ lines = [line for line in lines if line.strip()]
258
+ tail = lines[-EVIDENCE_LIMIT_LINES:]
259
+ head = [line for line in lines if FAIL_LINE_RE.match(line)][:EVIDENCE_FAIL_LINES] if expected_fail else []
260
+ picked = head + [line for line in tail if line not in head]
261
+ text = mf.redact("\n".join(picked))
262
+ data = text.encode("utf-8")
263
+ if len(data) > limit:
264
+ text = data[-limit:].decode("utf-8", errors="ignore")
265
+ return text
266
+
267
+
268
+ # ------------------------------------------------------------------ the journal
269
+ def parse_journal(data):
270
+ """[(line_no, event or None)] — None is a torn line."""
271
+ out = []
272
+ if not data:
273
+ return out
274
+ for number, raw in enumerate(data.split(b"\n"), 1):
275
+ if not raw.strip():
276
+ continue
277
+ try:
278
+ event = json.loads(raw.decode("utf-8"))
279
+ out.append((number, event if isinstance(event, dict) else None))
280
+ except (ValueError, UnicodeDecodeError):
281
+ out.append((number, None))
282
+ return out
283
+
284
+
285
+ def split_instances(events):
286
+ """One list per task_started segment; lines before the first belong to
287
+ instance 1 (a backfilled journal starts mid-task)."""
288
+ instances = [[]]
289
+ for number, event in events:
290
+ if event and event.get("event") == "task_started" and any(e for _, e in instances[-1]
291
+ if e and e.get("event") == "task_started"):
292
+ instances.append([])
293
+ instances[-1].append((number, event))
294
+ return [inst for inst in instances if inst] or [[]]
295
+
296
+
297
+ def actor_of(event):
298
+ actor = (event or {}).get("actor")
299
+ return actor if actor in mf.ACTORS else "unknown"
300
+
301
+
302
+ def runtime_of(event):
303
+ runtime = (event or {}).get("runtime")
304
+ return runtime if runtime in mf.RUNTIMES else "unknown"
305
+
306
+
307
+ class Builder:
308
+ """One instance's entries, in journal order; the seal comes last."""
309
+
310
+ def __init__(self, task_id, inst):
311
+ self.task_id, self.inst = task_id, inst
312
+ self.entries = []
313
+
314
+ def add(self, kind, ts, actor, by, runtime, src, fields, truncated=(), unrecorded=()):
315
+ entry = {"v": mf.FORMAT, "task": self.task_id, "inst": self.inst, "kind": kind, "ts": ts,
316
+ "actor": actor, "by": by, "runtime": runtime, "src": list(src)}
317
+ entry.update(fields)
318
+ if truncated:
319
+ entry["truncated"] = sorted(set(truncated))
320
+ if unrecorded:
321
+ entry["unrecorded"] = sorted(set(unrecorded))
322
+ self.entries.append(entry)
323
+ return entry
324
+
325
+
326
+ def capped(fields, names, truncated, prefix=""):
327
+ for name in names:
328
+ value, cut = mf.cap(fields.get(name))
329
+ fields[name] = value
330
+ if cut:
331
+ truncated.append(prefix + name)
332
+
333
+
334
+ def ts_ok(ts, fallback="1970-01-01T00:00:00Z"):
335
+ return ts if isinstance(ts, str) and mf.TS_RE.match(ts) else fallback
336
+
337
+
338
+ def compact_task(reader, task_id, sources="close", inst=None, excerpt_max=1024):
339
+ """One instance's Compaction (the last one by default)."""
340
+ found = compact_instances(reader, task_id, sources, excerpt_max)
341
+ if not found:
342
+ return Compaction([], None, {"lines": 0, "accounted": 0, "skipped": 0}, ["no sources for %s" % task_id])
343
+ if inst is None:
344
+ return found[-1]
345
+ for c in found:
346
+ if c.seal and c.seal["inst"] == inst:
347
+ return c
348
+ return Compaction([], None, {"lines": 0, "accounted": 0, "skipped": 0}, ["no instance %s" % inst])
349
+
350
+
351
+ def compact_instances(reader, task_id, sources="close", excerpt_max=1024):
352
+ """Every instance of one task id, in order."""
353
+ journal = reader.read(report_rel(task_id, "events.jsonl"))
354
+ task_md_raw = reader.read(report_rel(task_id, "task.md"))
355
+ questions_raw = reader.read(report_rel(task_id, "questions.md"))
356
+ state_rel = (".ai/state/logs/%s/state.json" % task_id) if sources == "close" else report_rel(task_id, "state.json")
357
+ state_raw = reader.read(state_rel)
358
+ if state_raw is None and sources == "close":
359
+ state_rel = report_rel(task_id, "state.json")
360
+ state_raw = reader.read(state_rel)
361
+ problems, state = [], None
362
+ if state_raw is not None:
363
+ try:
364
+ state = json.loads(state_raw.decode("utf-8"))
365
+ if not isinstance(state, dict):
366
+ raise ValueError("not an object")
367
+ except (ValueError, UnicodeDecodeError):
368
+ problems.append("%s is not valid JSON — the task is not exported (known risk: a torn archive)"
369
+ % state_rel)
370
+ state = None
371
+ md = parse_task_md(text_of(task_md_raw), text_of(questions_raw))
372
+ events = parse_journal(journal)
373
+ if not events and state is None:
374
+ if md["goal"] is None:
375
+ return []
376
+ ctx = {"reader": reader, "task_id": task_id, "sources": sources, "excerpt_max": excerpt_max,
377
+ "state": state, "state_rel": os.path.basename(state_rel), "md": md}
378
+ instances = split_instances(events) if events else [[]]
379
+ out = []
380
+ for number, lines in enumerate(instances, 1):
381
+ last = number == len(instances)
382
+ out.append(_compact_one(ctx, number, lines, last, list(problems)))
383
+ return out
384
+
385
+
386
+ def _compact_one(ctx, inst, lines, last, problems):
387
+ task_id, md = ctx["task_id"], ctx["md"]
388
+ state = ctx["state"] if last else None
389
+ b = Builder(task_id, inst)
390
+ accounted, dropped, skipped = set(), collections.Counter(), 0
391
+ seal_src, summary = [], {"steps": [], "stages": [], "verify_runs": [], "runtimes": set()}
392
+ started = closed = None
393
+ schema = None
394
+ weights, questions, gates, reviews, verifies = [], {}, {}, [], []
395
+ tier_last = None
396
+ prev_stage = None
397
+ open_gate = None
398
+ for number, event in lines:
399
+ if event is None:
400
+ skipped += 1
401
+ continue
402
+ name = event.get("event")
403
+ data = event.get("data") if isinstance(event.get("data"), dict) else {}
404
+ ts = ts_ok(event.get("ts"))
405
+ src = "events.jsonl:%d" % number
406
+ summary["runtimes"].add(runtime_of(event))
407
+ stage = event.get("stage")
408
+ if stage and stage != prev_stage:
409
+ summary["stages"].append([stage, ts])
410
+ prev_stage = stage
411
+ if name == "task_started":
412
+ schema = data.get("schema", schema)
413
+ rule = mf.classify(name, schema)
414
+ accounted.add(number)
415
+ if rule == "seal":
416
+ seal_src.append(src)
417
+ if name == "task_started":
418
+ started = (event, data)
419
+ else:
420
+ closed = (event, data)
421
+ elif rule == "summary":
422
+ if name in ("step_started", "step_done"):
423
+ step = data.get("step_id")
424
+ rows = [r for r in summary["steps"] if r[0] == step]
425
+ if name == "step_started" and not rows:
426
+ summary["steps"].append([step, "started"])
427
+ elif name == "step_done":
428
+ if rows:
429
+ rows[0][1] = "done"
430
+ else:
431
+ summary["steps"].append([step, "done"])
432
+ elif name == "tier_set":
433
+ tier_last = data.get("tier")
434
+ elif rule == "drop":
435
+ dropped[name] += 1
436
+ elif rule == "weight":
437
+ weights.append((number, event, data))
438
+ elif rule == "override":
439
+ unrecorded = []
440
+ if cut_line(data) and name == "mode_set":
441
+ # The 4 KB cap dropped the payload: the live state keeps the override.
442
+ data = {k: (state or {}).get("mode_override", {}).get(k) for k in ("from", "to", "by", "why")} \
443
+ if (state or {}).get("mode_override") else {}
444
+ if cut_line(data) or not data:
445
+ unrecorded = ["from", "to", "why"]
446
+ fields = {"what": "mode" if name == "mode_set" else data.get("field"), "from": data.get("from"),
447
+ "to": data.get("to") if name == "mode_set" else data.get("value"),
448
+ "via": data.get("via"), "why": data.get("why")}
449
+ if data.get("pick"):
450
+ fields["pick"] = data["pick"]
451
+ b.add("override", ts, actor_of(event), data.get("by"), runtime_of(event), [src], fields,
452
+ unrecorded=unrecorded)
453
+ elif rule == "scope":
454
+ truncated, unrecorded = [], []
455
+ if cut_line(data):
456
+ data = amendment_at(state, md if last else {}, ts)
457
+ unrecorded = [] if data else ["added", "why"]
458
+ fields = {"change": "plan" if name == "plan_registered" else (data.get("kind") or "amend"),
459
+ "added": list(data.get("added") or data.get("files") or []), "why": data.get("why")}
460
+ capped(fields, ("why",), truncated)
461
+ if name == "plan_registered":
462
+ fields["steps"] = data.get("steps")
463
+ b.add("scope", ts, actor_of(event), data.get("by"), runtime_of(event), [src], fields, truncated,
464
+ unrecorded)
465
+ elif rule == "question":
466
+ qid = data.get("id") or id_from_detail(event, r"[QG]\d+") or ""
467
+ if qid.startswith("G"):
468
+ gates.setdefault(qid, []).append((number, event, data))
469
+ else:
470
+ questions.setdefault(qid, []).append((number, event, data))
471
+ elif rule == "approval":
472
+ gate_id = data.get("gate_id") or id_from_detail(event, r"G\d+") or open_gate or "G?"
473
+ if name == "gate_requested":
474
+ open_gate = gate_id
475
+ gates.setdefault(gate_id, []).append((number, event, data))
476
+ elif rule == "review":
477
+ reviews.append((number, event, data))
478
+ elif rule == "verify":
479
+ summary["verify_runs"].append([data.get("n"), data.get("scope"), data.get("exit"), data.get("tree"),
480
+ bool(data.get("expected_fail"))])
481
+ verifies.append((number, event, data)) # evidence runs also become entries
482
+ elif rule == "note":
483
+ truncated = []
484
+ if name == "note":
485
+ fields = {"note_kind": data.get("kind"), "text": data.get("text"), "why": data.get("why"),
486
+ "error": data.get("error")}
487
+ if fields["text"] is None and event.get("detail"):
488
+ # `event note --detail …` without --data, or a payload the 4 KB cap dropped:
489
+ # the detail (at most 500 characters) is all the journal kept.
490
+ fields["text"] = event["detail"]
491
+ if cut_line(data) or len(event["detail"]) >= DETAIL_MAX:
492
+ truncated.append("text")
493
+ elif name == "field_set":
494
+ fields = {"note_kind": "field", "text": event.get("detail"), "why": None, "error": None,
495
+ "field": data.get("field"), "raw": data}
496
+ else:
497
+ fields = {"note_kind": name, "text": event.get("detail"), "why": data.get("why"), "error": None,
498
+ "raw": data}
499
+ capped(fields, ("text", "why", "error"), truncated)
500
+ # A payload the cap dropped is said, never guessed (review M1, pass 2).
501
+ unrecorded = [k for k in ("note_kind", "text", "why") if fields.get(k) is None] if cut_line(data) else []
502
+ b.add("note", ts, actor_of(event), data.get("by"), runtime_of(event), [src], fields, truncated,
503
+ unrecorded)
504
+ elif rule == "handoff":
505
+ b.add("handoff", ts, actor_of(event), data.get("by"), runtime_of(event), [src],
506
+ {"from": data.get("from"), "to": data.get("to"), "via": data.get("via"),
507
+ "reason": data.get("reason")})
508
+ elif rule == "risk":
509
+ fields = {k: data.get(k) for k in ("rkey", "title", "where", "label", "what", "likelihood", "fix",
510
+ "status", "status_why")}
511
+ fields.update(found_by=task_id, text=data.get("text"))
512
+ if data.get("supersedes"):
513
+ fields["supersedes"] = data["supersedes"]
514
+ if data.get("label_derived"):
515
+ fields["label_derived"] = True
516
+ b.add("risk", ts, actor_of(event), data.get("by"), runtime_of(event), [src], fields)
517
+ else:
518
+ b.add("note", ts, actor_of(event), data.get("by"), runtime_of(event), [src],
519
+ {"note_kind": "unknown-event", "text": event.get("detail"), "why": None, "error": None,
520
+ "raw": event})
521
+ _weights(b, weights, md)
522
+ _questions(b, questions, md if last else {"questions": {}})
523
+ _gates(b, gates, md if last else {"questions": {}}, state, md.get("confirm") if last else None)
524
+ _reviews(b, reviews, md if last else {"reviews": {}})
525
+ _verifies(ctx, b, verifies, state)
526
+ if not lines and state is not None:
527
+ _history(b, state)
528
+ b.entries.sort(key=_entry_order)
529
+ for seq, entry in enumerate(b.entries):
530
+ entry["seq"] = seq
531
+ entries = relink_risks([mf.with_rid(e) for e in b.entries])
532
+ seal = _seal(ctx, entries, inst, last, state, started, closed, seal_src, summary, dropped, skipped,
533
+ len(lines), schema, tier_last)
534
+ coverage = {"lines": len(lines), "accounted": len(accounted), "skipped": skipped}
535
+ if skipped:
536
+ problems.append("%d torn journal line(s) — cleanup stays blocked for this task" % skipped)
537
+ return Compaction(entries, seal, coverage, problems)
538
+
539
+
540
+ DETAIL_MAX = 500 # state.py's cap on a journal line's detail
541
+
542
+
543
+ def relink_risks(entries):
544
+ """A risk closed in the task that recorded it superseded the live rid
545
+ (`memory risks` before close); the record points at the exported one."""
546
+ out, last = [], {}
547
+ for e in entries:
548
+ if e.get("kind") == "risk":
549
+ prev = last.get(e.get("rkey"))
550
+ if e.get("supersedes") and prev and e["supersedes"] != prev:
551
+ e = mf.with_rid(dict(e, supersedes=prev))
552
+ last[e.get("rkey")] = e["rid"]
553
+ out.append(e)
554
+ return out
555
+
556
+
557
+ def cut_line(data):
558
+ """A line over JOURNAL_MAX_BYTES keeps its existence and loses its payload."""
559
+ return data == {"truncated": True}
560
+
561
+
562
+ def id_from_detail(event, pattern):
563
+ """A question or gate id from the detail's head (`Q1 = A`, `G1: human_approval`)
564
+ when the payload is gone; the id only, never the text."""
565
+ match = re.match(r"^(%s)\b" % pattern, event.get("detail") or "")
566
+ return match.group(1) if match else None
567
+
568
+
569
+ def epoch(ts):
570
+ try:
571
+ return datetime.strptime((ts or "")[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc).timestamp()
572
+ except ValueError:
573
+ return None
574
+
575
+
576
+ def amendment_at(state, md, ts):
577
+ """A scope change the cap cut, from the archived state's amendments (exact)
578
+ or task.md's `amended` line (whitespace folded): the one recorded nearest
579
+ the journal line, within two seconds (state.py stamps both in one call)."""
580
+ when = epoch(ts)
581
+ if when is None:
582
+ return {}
583
+ for source in ((state or {}).get("amendments") or [], md.get("amended") or []):
584
+ near = [(abs(epoch(a.get("ts")) - when), i, a) for i, a in enumerate(source)
585
+ if isinstance(a, dict) and epoch(a.get("ts")) is not None and abs(epoch(a.get("ts")) - when) <= 2]
586
+ if near:
587
+ amendment = min(near, key=lambda n: (n[0], n[1]))[2]
588
+ return {"kind": "amend", "added": list(amendment.get("added") or []), "why": amendment.get("why")}
589
+ return {}
590
+
591
+
592
+ def _entry_order(entry):
593
+ first = entry["src"][0] if entry["src"] else ""
594
+ match = re.match(r"^events\.jsonl:(\d+)$", first)
595
+ return (0, int(match.group(1)), "") if match else (1, 0, first)
596
+
597
+
598
+ def _weights(b, weights, md):
599
+ first = True
600
+ for number, event, data in weights:
601
+ name = event.get("event")
602
+ if name == "weight_set":
603
+ fields = {"change": "set", "w": data.get("w"), "mode": data.get("mode"), "axes": data.get("axes"),
604
+ "from": None, "to": None, "lines": md.get("weight_lines") if first else None}
605
+ first = False
606
+ elif name == "weight_raised":
607
+ fields = {"change": "raised", "w": data.get("w"), "mode": data.get("mode"), "axes": data.get("axes"),
608
+ "from": data.get("from"), "to": data.get("to"), "lines": None}
609
+ elif name == "tier_raised":
610
+ fields = {"change": "tier_raised", "w": None, "mode": None, "axes": None, "from": data.get("from"),
611
+ "to": data.get("to"), "lines": None}
612
+ else:
613
+ fields = {"change": "legacy_tier", "w": data.get("w"), "mode": data.get("mode"), "axes": None,
614
+ "from": data.get("from"), "to": data.get("tier"), "lines": None}
615
+ b.add("weight", ts_ok(event.get("ts")), actor_of(event), data.get("by"), runtime_of(event),
616
+ ["events.jsonl:%d" % number], fields)
617
+
618
+
619
+ def _questions(b, questions, md):
620
+ from_md = md.get("questions") or {}
621
+ ids = list(questions) + [q for q in from_md if q.startswith("Q") and q not in questions]
622
+ for qid in ids:
623
+ lines = questions.get(qid, [])
624
+ q = from_md.get(qid)
625
+ asked = next(((n, e, d) for n, e, d in lines if e.get("event") == "question_asked"), None)
626
+ answers = [(n, e, d) for n, e, d in lines if e.get("event") == "question_answered"]
627
+ src = ["events.jsonl:%d" % n for n, _, _ in lines] + ([q["src"]] if q else [])
628
+ unrecorded = []
629
+ options = list(q["options"]) if q else None
630
+ if options is None:
631
+ keys = (asked[2].get("options") if asked else None) or []
632
+ options = [{"key": k, "text": None} for k in keys]
633
+ unrecorded.append("options")
634
+ fields = {"qid": qid, "stage": (q or {}).get("stage") or (asked[1].get("stage") if asked else None),
635
+ "question": q["question"] if q else None, "context": (q or {}).get("context"),
636
+ "options": options,
637
+ "recommended": (asked[2].get("recommended") if asked else None) or (q or {}).get("recommend"),
638
+ "answer": None}
639
+ if fields["question"] is None:
640
+ unrecorded.append("question")
641
+ if answers or (q and q.get("answered")):
642
+ _, ev, data = answers[-1] if answers else (None, None, {})
643
+ whole = bool(answers) and not data.get("truncated")
644
+ text = data.get("text") if whole else None
645
+ if text is None and q and q.get("answered"):
646
+ text = q.get("text")
647
+ fields["answer"] = {
648
+ "choice": data.get("choice") if whole else (q or {}).get("choice"),
649
+ "text": text, "by": data.get("by") or (q or {}).get("answered_by"),
650
+ "via": data.get("via") or (q or {}).get("via"),
651
+ "at": (q or {}).get("answered_at") or (ts_ok(ev.get("ts")) if ev else None),
652
+ "actor": actor_of(ev) if ev else "unknown"}
653
+ head = asked or (lines[0] if lines else None)
654
+ ts = ts_ok(head[1].get("ts")) if head else ts_ok((q or {}).get("asked"))
655
+ b.add("question", ts, actor_of(head[1]) if head else "unknown", (q or {}).get("by"),
656
+ runtime_of(head[1]) if head else "unknown", src, fields, unrecorded=unrecorded)
657
+
658
+
659
+ def _gates(b, gates, md, state, confirm):
660
+ from_md = md.get("questions") or {}
661
+ gate_state = (state or {}).get("gate") or {}
662
+ ids = list(gates) + [g for g in from_md if g.startswith("G") and g not in gates]
663
+ for gid in ids:
664
+ lines = gates.get(gid, [])
665
+ q = from_md.get(gid)
666
+ request = next(((n, e, d) for n, e, d in lines if e.get("event") == "gate_requested"), None)
667
+ decided = [(n, e, d) for n, e, d in lines if e.get("event") in ("gate_approved", "gate_rejected")]
668
+ answered = [(n, e, d) for n, e, d in lines if e.get("event") == "question_answered"]
669
+ last = decided[-1] if decided else (answered[-1] if answered else None)
670
+ decision = "pending"
671
+ if decided:
672
+ decision = "approved" if decided[-1][1].get("event") == "gate_approved" else "rejected"
673
+ elif answered:
674
+ decision = "approved" if answered[-1][2].get("choice") == "A" else "rejected"
675
+ elif q and q.get("answered"):
676
+ decision = "approved" if q.get("choice") == "A" else "rejected"
677
+ data = last[2] if last else {}
678
+ text = q.get("text") if q else None
679
+ same = gate_state.get("gate_id") == gid
680
+ fields = {"gate_id": gid,
681
+ "requested_at": (request[2].get("requested_at") if request else None) or (q or {}).get("asked"),
682
+ "decision": decision,
683
+ "at": (q or {}).get("answered_at") or (ts_ok(last[1].get("ts")) if last else None),
684
+ "via": data.get("via") or (q or {}).get("via"), "unattended": data.get("unattended"),
685
+ "tty": data.get("tty"), "note": text if decision == "approved" else None,
686
+ "why": (data.get("why") or text) if decision == "rejected" else None,
687
+ "tree": gate_state.get("tree") if same else None,
688
+ "confirm": (gate_state.get("confirm") if same else None) or (confirm if decision == "approved" else None),
689
+ "operations": list(gate_state.get("operations") or []) if same else []}
690
+ if data.get("pick"):
691
+ fields["pick"] = data["pick"]
692
+ src = ["events.jsonl:%d" % n for n, _, _ in lines] + ([q["src"]] if q else [])
693
+ head = last or request or (lines[0] if lines else None)
694
+ unrecorded = [] if q else ["note"]
695
+ if not same:
696
+ unrecorded.append("tree")
697
+ b.add("approval", ts_ok(head[1].get("ts")) if head else ts_ok((q or {}).get("asked")),
698
+ actor_of(head[1]) if head else "unknown", data.get("by") or (q or {}).get("answered_by"),
699
+ runtime_of(head[1]) if head else "unknown", src, fields, unrecorded=unrecorded)
700
+
701
+
702
+ def _reviews(b, reviews, md):
703
+ passes = md.get("reviews") or {}
704
+ entries = {}
705
+ for number, event, data in reviews:
706
+ if event.get("event") == "review_verdict":
707
+ n = data.get("pass_n")
708
+ text = (passes.get(n) or {}).get("text")
709
+ findings = parse_findings(text)
710
+ truncated = []
711
+ for i, f in enumerate(findings):
712
+ f["text"], cut = mf.cap(f["text"])
713
+ if cut:
714
+ truncated.append("findings[%d].text" % i)
715
+ fields = {"pass": n, "tier": data.get("tier"), "focus": data.get("focus"),
716
+ "verdict": data.get("verdict"), "findings": findings, "closed": [],
717
+ "report": "task.md#Review-pass-%s" % n}
718
+ entries[n] = b.add("review", ts_ok(event.get("ts")), actor_of(event), (passes.get(n) or {}).get("by"),
719
+ runtime_of(event), ["events.jsonl:%d" % number] + (["task.md#Review-pass-%s" % n]
720
+ if text is not None else []),
721
+ fields, truncated, [] if text is not None else ["findings"])
722
+ else:
723
+ target = entries[max(entries)] if entries else None
724
+ if target is None:
725
+ b.add("note", ts_ok(event.get("ts")), actor_of(event), None, runtime_of(event),
726
+ ["events.jsonl:%d" % number],
727
+ {"note_kind": "findings_closed", "text": event.get("detail"), "why": None, "error": None,
728
+ "raw": data})
729
+ continue
730
+ target.setdefault("closed", []).append({"labels": list(data.get("labels") or []),
731
+ "by_run": data.get("run")})
732
+ target.setdefault("src", []).append("events.jsonl:%d" % number)
733
+
734
+
735
+ def _verifies(ctx, b, verifies, state):
736
+ runs = {r.get("n"): r for r in ((state or {}).get("verify") or {}).get("runs") or [] if isinstance(r, dict)}
737
+ evidence = set()
738
+ for number, _, data in verifies:
739
+ if data.get("expected_fail"):
740
+ evidence.add(number)
741
+ for scope in ("suite", "e2e"):
742
+ last = [number for number, _, data in verifies if data.get("scope") == scope]
743
+ if last:
744
+ evidence.add(last[-1])
745
+ for number, event, data in verifies:
746
+ if number not in evidence:
747
+ continue
748
+ run = runs.get(data.get("n")) or {}
749
+ log = data.get("log")
750
+ text = None
751
+ if log and (ctx["sources"] == "close" or log.startswith(".ai/reports/")):
752
+ text = text_of(ctx["reader"].read(log))
753
+ fields = {"n": data.get("n"), "scope": data.get("scope"), "exit": data.get("exit"),
754
+ "tree": data.get("tree"), "command": run.get("command"), "duration_s": run.get("duration_s"),
755
+ "expected_fail": bool(data.get("expected_fail")), "after_fix": run.get("after_fix"),
756
+ "why": data.get("why"), "excerpt": excerpt(text, data.get("expected_fail"), ctx["excerpt_max"]),
757
+ "log": ("log:%s" % log) if log else None}
758
+ truncated = []
759
+ capped(fields, ("why", "after_fix"), truncated)
760
+ b.add("verify", ts_ok(event.get("ts")), actor_of(event), None, runtime_of(event),
761
+ ["events.jsonl:%d" % number], fields, truncated)
762
+
763
+
764
+ def _history(b, state):
765
+ """A state-only legacy task (pre-WP2): its history[] as notes, by migration."""
766
+ for i, item in enumerate(state.get("history") or []):
767
+ if not isinstance(item, dict):
768
+ continue
769
+ fields = {"note_kind": "history:%s" % item.get("event"), "text": item.get("detail"), "why": None,
770
+ "error": None}
771
+ truncated = []
772
+ capped(fields, ("text",), truncated)
773
+ b.add("note", ts_ok(item.get("at")), "migration", None, "unknown", ["state.json#history[%d]" % i], fields,
774
+ truncated)
775
+
776
+
777
+ def _seal(ctx, entries, inst, last, state, started, closed, seal_src, summary, dropped, skipped, nlines,
778
+ schema, tier_last):
779
+ md = ctx["md"] if last else {}
780
+ sdata = started[1] if started else {}
781
+ cdata = closed[1] if closed else {}
782
+ state = state or {}
783
+ unrecorded, truncated = [], []
784
+ goal = sdata.get("goal") or state.get("goal") or md.get("goal")
785
+ goal, cut = mf.cap(goal)
786
+ if cut:
787
+ truncated.append("goal")
788
+ weights = [e for e in entries if e["kind"] == "weight" and e.get("axes")]
789
+ modes = [e for e in entries if e["kind"] == "override" and e.get("what") == "mode"]
790
+ if closed:
791
+ result = "abandoned" if cdata.get("abandoned") else "closed"
792
+ elif not last:
793
+ result = "restarted"
794
+ else:
795
+ result = state.get("result") or ("closed" if state.get("current_stage") == "done" else "open")
796
+ if ctx["sources"] == "close" and state.get("files") is not None:
797
+ files = list(state.get("files") or [])
798
+ else:
799
+ files = md.get("files")
800
+ if files is None and isinstance(state.get("approved_plan"), dict):
801
+ # A legacy state.json (tracked): the plan's steps name their files.
802
+ plan_files = []
803
+ for step in state["approved_plan"].get("steps") or []:
804
+ for path in (step.get("allowed_files") or []) if isinstance(step, dict) else []:
805
+ if isinstance(path, str) and path not in plan_files:
806
+ plan_files.append(path)
807
+ files = plan_files or None
808
+ if files is None:
809
+ files = sorted({p for e in entries if e["kind"] == "scope" for p in e.get("added") or []})
810
+ unrecorded.append("files")
811
+ changes = state.get("changes") if ctx["sources"] == "close" and state.get("changes") is not None \
812
+ else md.get("changes")
813
+ if changes is None:
814
+ changes = []
815
+ unrecorded.append("changes")
816
+ diff, facts_state = state.get("diff") or {}, state.get("facts") or {}
817
+ if diff and (ctx["sources"] == "close" or diff.get("base_tree")):
818
+ code = {"base_commit": diff.get("base_commit"), "base_tree": diff.get("base_tree"),
819
+ "final_tree": facts_state.get("tree") or ((diff.get("task") or {}).get("tree")
820
+ if isinstance(diff.get("task"), dict) else None)}
821
+ else:
822
+ code = {"base_commit": None, "base_tree": None, "final_tree": None}
823
+ unrecorded.append("code")
824
+ if ctx["sources"] == "close" and facts_state.get("a"):
825
+ facts = {k: list(facts_state[k]) for k in ("a", "b") if facts_state.get(k)}
826
+ else:
827
+ facts = md.get("facts")
828
+ if facts is None:
829
+ facts = {}
830
+ unrecorded.append("facts")
831
+ humans = set()
832
+ for e in entries:
833
+ if e["kind"] == "question" and e.get("answer") and (e["answer"].get("actor") == "human"
834
+ or e["answer"].get("via") in ("file", "terminal")):
835
+ humans.add(e["answer"].get("by"))
836
+ elif e["actor"] == "human" and e.get("by"):
837
+ humans.add(e["by"])
838
+ elif e["kind"] in ("approval",) and e.get("by") and e.get("decision") != "pending":
839
+ humans.add(e["by"])
840
+ humans.discard(None)
841
+ rids = [e["rid"] for e in entries]
842
+ unrecorded.append("people.started_by")
843
+ when = (closed[0].get("ts") if closed else None) or (started[0].get("ts") if started else None) \
844
+ or state.get("closed_at") or state.get("updated_at") or state.get("created_at") or state.get("created")
845
+ seal = {"v": mf.FORMAT, "task": ctx["task_id"], "inst": inst, "seq": len(entries), "kind": "task",
846
+ "ts": ts_ok(when),
847
+ "actor": actor_of(closed[0]) if closed else ("migration" if not started else "agent"),
848
+ "by": None, "runtime": runtime_of(closed[0]) if closed else "unknown",
849
+ "src": seal_src + ([ctx["state_rel"]] if state else []) or ["task.md"],
850
+ "result": result, "goal": goal,
851
+ "workflow": sdata.get("workflow") or state.get("workflow"),
852
+ "mode": cdata.get("mode") or (modes[-1].get("to") if modes else None) or sdata.get("mode")
853
+ or state.get("mode"),
854
+ "w": cdata.get("w") if cdata.get("w") is not None else (weights[-1].get("w") if weights else None),
855
+ "axes": weights[-1].get("axes") if weights else None,
856
+ "started_at": ts_ok(started[0].get("ts")) if started else (state.get("created_at") or state.get("created")),
857
+ "closed_at": ts_ok(closed[0].get("ts")) if closed else state.get("closed_at"),
858
+ "files": files, "steps": summary["steps"], "stages": summary["stages"], "changes": changes,
859
+ "code": code, "facts": facts, "verify_runs": summary["verify_runs"],
860
+ "counts": {"lines": nlines, "skipped": skipped, "dropped": dict(sorted(dropped.items()))},
861
+ "people": {"started_by": None, "humans": sorted(humans)},
862
+ "runtimes": sorted(summary["runtimes"] - {"unknown"}) or ["unknown"],
863
+ "entries": len(entries), "digest": mf.seal_digest(rids),
864
+ "source": "close" if ctx["sources"] == "close" else "migration",
865
+ "schema": schema if schema is not None else state.get("schema"), "version": mf.KEEP_VERSION}
866
+ if tier_last and (seal["schema"] or 0) < 7:
867
+ seal["legacy_tier"] = tier_last
868
+ elif state.get("risk_tier") and not started:
869
+ seal["legacy_tier"] = state.get("risk_tier")
870
+ if not when:
871
+ unrecorded.append("ts")
872
+ if truncated:
873
+ seal["truncated"] = truncated
874
+ seal["unrecorded"] = sorted(set(unrecorded))
875
+ return mf.with_rid(seal)
876
+
877
+
878
+ # ------------------------------------------------------------------ known-risks.md (§D9, R24)
879
+ RISK_HEAD_RE = re.compile(r"^## (.+)$")
880
+ RISK_FIELD_RE = re.compile(r"^- \*\*([^*]+)\*\*:\s?(.*)$")
881
+ LABEL_IN_TEXT_RE = re.compile(r"\b(KNOWN FACT|INFERENCE|UNKNOWN)\b")
882
+ TASK_REF_RE = re.compile(r"\bT-[0-9A-Za-z][0-9A-Za-z._-]*[0-9A-Za-z]")
883
+
884
+
885
+ def rkey_of(title):
886
+ """R-<slug(title)[:40]>-<sha256(title)[:4]> (§D9)."""
887
+ import hashlib # pylint: disable=import-outside-toplevel
888
+ text = "".join(mf.TRANSLIT.get(c, c) for c in (title or "").casefold())
889
+ words = re.findall(r"[a-z0-9]+", text)
890
+ slug = "-".join(words)[:40].strip("-") or "risk"
891
+ return "R-%s-%s" % (slug, hashlib.sha256((title or "").encode("utf-8")).hexdigest()[:4])
892
+
893
+
894
+ def risk_status(text):
895
+ text = (text or "").strip().lower()
896
+ for status in mf.RISK_STATUS:
897
+ if text.startswith(status):
898
+ return status
899
+ if re.match(r"^(closed|done|resolved)", text):
900
+ return "fixed"
901
+ return "open"
902
+
903
+
904
+ def parse_known_risks(text):
905
+ """(risks, leftovers): one risk per `## ` section outside an HTML comment,
906
+ each with its exact text, so render_known_risks reproduces the file byte
907
+ for byte (A12). leftovers are the lines before the first section."""
908
+ raw = text or ""
909
+ trailing_newline = raw.endswith("\n")
910
+ lines = raw.split("\n")
911
+ if trailing_newline:
912
+ lines = lines[:-1]
913
+ first, in_comment = len(lines), False
914
+ for i, line in enumerate(lines):
915
+ if not in_comment and RISK_HEAD_RE.match(line):
916
+ first = i
917
+ break
918
+ if "<!--" in line and "-->" not in line.split("<!--", 1)[1]:
919
+ in_comment = True
920
+ elif "-->" in line:
921
+ in_comment = False
922
+ blocks, current = [], None
923
+ for line in lines[first:]:
924
+ if RISK_HEAD_RE.match(line):
925
+ current = [line]
926
+ blocks.append(current)
927
+ else:
928
+ current.append(line)
929
+ risks = []
930
+ for section in blocks:
931
+ title = RISK_HEAD_RE.match(section[0]).group(1)
932
+ fields = {}
933
+ for line in section[1:]:
934
+ match = RISK_FIELD_RE.match(line)
935
+ if match and match.group(1) not in fields:
936
+ fields[match.group(1)] = match.group(2)
937
+ label, derived = fields.get("Label"), False
938
+ if label not in mf.LABELS:
939
+ found = LABEL_IN_TEXT_RE.search(fields.get("What could go wrong") or "")
940
+ label, derived = (found.group(1) if found else "RISK"), True
941
+ found_text = fields.get("Found") or fields.get("Found by")
942
+ ref = TASK_REF_RE.search(found_text or "")
943
+ where = fields.get("Where") or ""
944
+ risks.append({"rkey": rkey_of(title), "title": title,
945
+ "where": [w.group(1) for w in re.finditer(r"`([^`]+)`", where)] or
946
+ ([where.strip()] if where.strip() else []),
947
+ "label": label, "label_derived": derived, "what": fields.get("What could go wrong"),
948
+ "likelihood": fields.get("How likely / how bad"),
949
+ "fix": fields.get("Suggested fix") or fields.get("Fix"),
950
+ "status": risk_status(fields.get("Status")), "status_why": fields.get("Status"),
951
+ "found_by": ref.group(0) if ref else found_text, "text": "\n".join(section)})
952
+ return risks, {"lines": lines[:first], "trailing_newline": trailing_newline}
953
+
954
+
955
+ def render_known_risks(risks, leftovers):
956
+ """The inverse of parse_known_risks, from each risk's stored text."""
957
+ lines = list(leftovers["lines"])
958
+ for risk in risks:
959
+ lines += risk["text"].split("\n")
960
+ return "\n".join(lines) + ("\n" if leftovers["trailing_newline"] else "")
961
+
962
+
963
+ def risk_section(title, where, label, what, likely, fix, status, found):
964
+ """A new known-risks.md section: the file's template plus a Label line."""
965
+ return ["## %s" % title, "", "- **Found**: %s" % found,
966
+ "- **Where**: %s" % ", ".join("`%s`" % w for w in where),
967
+ "- **What could go wrong**: %s" % (what or ""), "- **How likely / how bad**: %s" % (likely or ""),
968
+ "- **Suggested fix**: %s" % (fix or ""), "- **Status**: %s" % status, "- **Label**: %s" % label]