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