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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/README.md +246 -812
- package/agents/ai-reviewer.md.tmpl +6 -4
- package/ai/policies/review-economy.md +7 -2
- package/ai/using-riskrails.md +15 -0
- package/bin/claude-1m +15 -14
- package/bin/riskrails.js +108 -32
- package/codex/hooks.json +25 -2
- package/hooks/AGENTS.md +2 -2
- package/hooks/CLAUDE.md +2 -2
- package/hooks/ai-guard.py +29 -8
- package/hooks/ai-path-guard-defaults.json +3 -1
- package/hooks/codex-hooks.json +25 -2
- package/hooks/context-guard.py +251 -35
- package/hooks/dialog-gate.py +105 -0
- package/hooks/guard/common.py +5 -0
- package/hooks/guard/decision.py +123 -0
- package/hooks/guard/dialog.py +598 -0
- package/hooks/guard/git.py +49 -3
- package/hooks/guard/paths.py +62 -3
- package/hooks/hooks.json +37 -2
- package/hooks/memory-pre-push.py +28 -0
- package/hooks/runtime-gate.py +59 -12
- package/hooks/update_notice.py +135 -0
- package/instructions/routing.md +4 -4
- package/instructions/stub.md +2 -2
- package/package.json +1 -1
- package/profiles/max.json +3 -1
- package/profiles/pro.json +3 -1
- package/scripts/merge-codex-config.py +134 -0
- package/scripts/resolve-profile.py +15 -3
- package/settings.common.json +39 -2
- package/skills/AGENTS.md +2 -2
- package/skills/CLAUDE.md +2 -2
- package/skills/ai-init/templates/.ai/AGENTS.md +2 -2
- package/skills/ai-init/templates/.ai/VERSION +1 -1
- package/skills/ai-init/templates/.ai/policies/dialog.json +1 -0
- package/skills/ai-init/templates/.ai/policies/safety.md +22 -2
- package/skills/ai-init/templates/.ai/policies/weight.json +1 -0
- package/skills/ai-init/templates/.ai/policies/weight.md +2 -2
- package/skills/ai-init/templates/.ai/state/README.md +9 -6
- package/skills/ai-init/templates/AGENTS.block.md +1 -1
- package/skills/ai-init/templates/CLAUDE.block.md +1 -1
- package/skills/ai-init/templates/GEMINI.block.md +1 -1
- package/skills/ai-init/templates/junie-guidelines.block.md +1 -1
- package/skills/ai-task/memory_compact.py +968 -0
- package/skills/ai-task/memory_format.py +775 -0
- package/skills/ai-task/memory_store.py +528 -0
- package/skills/ai-task/memory_sync.py +1571 -0
- package/skills/ai-task/sensors.py +2 -2
- package/skills/ai-task/state.py +967 -70
- package/skills/ai-task/weight.py +3 -3
- package/skills/project-init/templates/constitution.md +1 -1
- package/skills/project-update/history/blobs/164cd34c182de88c33de7df35beb29200d792419e1c13a5f0b2104f944e57be7 +1 -0
- package/skills/project-update/history/blobs/1d1d907449ec74594d0ee923ac142ae746b9aedf0a1eda9469f075a44d23e3d7 +13 -0
- package/skills/project-update/history/blobs/23a42febbacaa7b52a802d428033a99c0694391c81d51fe3b6e1dff608a641d5 +67 -0
- package/skills/project-update/history/blobs/5353ca529c46f2443e12947ec205a6887fb594a4de1f87f0eb1c0e6c0f826133 +49 -0
- package/skills/project-update/history/blobs/5f65bc7ab3fc3c4e75ae05d49eaf4331fafc398c661abe33c69f7380a720344c +32 -0
- package/skills/project-update/history/blobs/7be527cd648b27cca6e267de30f8e33a2ce17d5631a82694f23ae5c0623efe1f +15 -0
- package/skills/project-update/history/blobs/7ee99edafe7cec45ff577376c0d0178fdb2690134d4656488f46e129aba3cc5a +67 -0
- package/skills/project-update/history/blobs/8b3ef9586491a426767c01da5d01bdd6b0655c576c68c4cc6dfb093367817dc0 +67 -0
- package/skills/project-update/history/blobs/917df3320d778ddbaa5c5c7742bc4046bf803c36ed2b050f30844ed206783469 +1 -0
- package/skills/project-update/history/blobs/935af1019931058ec3a0b834fe230e127ec914f75edcc4a6c589cfe733b0b979 +13 -0
- package/skills/project-update/history/blobs/ae5ce131d6bae401199ff32d8c3dc4d8aec188010a9111624ac64506e2116ff4 +84 -0
- package/skills/project-update/history/blobs/ca4499d829268c8766fb3b825a515e88dc55c237e9d202816b50959b4dac031f +13 -0
- package/skills/project-update/history/blobs/e9adb7a9905eb719ac78ab96671f055da53e5001810f5ad6d6c94306b0323fe7 +74 -0
- package/skills/project-update/history/blobs/fae3064e648cdc55be4aeb1177525b302a5e34ebd752e4a904f47dc0f4557b96 +71 -0
- package/skills/project-update/history/index.json +32 -13
- package/skills/project-update/migrations/0009_state_file.py +25 -0
- package/skills/project-update/migrations/0010_dialog_switch.py +30 -0
- package/skills/project-update/migrations/README.md +2 -1
- package/skills/project-update/update.py +12 -3
- package/skills/rr-build/SKILL.md +14 -5
- package/skills/rr-build-all/SKILL.md +190 -0
- package/skills/rr-plan/SKILL.md +13 -3
- package/skills/rr-review/SKILL.md +18 -8
- package/skills/rr-ship/SKILL.md +16 -8
- package/skills/rr-status/SKILL.md +2 -2
- package/skills/usage-report/usage-report.py +41 -8
|
@@ -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]
|