pirl-framework-cli 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/LICENSE +21 -0
  3. package/README.md +265 -2
  4. package/bundled-framework/files/.agents/evals/layer_record.py +438 -0
  5. package/bundled-framework/files/.agents/evals/tests/test_layer_record.py +264 -0
  6. package/bundled-framework/files/.agents/evals/tests/test_skills.py +210 -0
  7. package/bundled-framework/files/.agents/evals/tests/test_validate_layer_artifact.py +214 -0
  8. package/bundled-framework/files/.agents/evals/validate_layer_artifact.py +415 -0
  9. package/bundled-framework/files/.agents/instructions/implement.instructions.md +343 -0
  10. package/bundled-framework/files/.agents/instructions/layered-task-execution.instructions.md +370 -0
  11. package/bundled-framework/files/.agents/instructions/layout-verification.instructions.md +107 -0
  12. package/bundled-framework/files/.agents/instructions/plan.instructions.md +292 -0
  13. package/bundled-framework/files/.agents/instructions/review.instructions.md +236 -0
  14. package/bundled-framework/files/.agents/instructions/self-evaluation.instructions.md +146 -0
  15. package/bundled-framework/files/.agents/instructions/standalone-and-maintenance.instructions.md +230 -0
  16. package/bundled-framework/files/.agents/roles/backend-developer.role.md +45 -0
  17. package/bundled-framework/files/.agents/roles/business-analyst.role.md +55 -0
  18. package/bundled-framework/files/.agents/roles/code-analyst.role.md +63 -0
  19. package/bundled-framework/files/.agents/roles/code-reviewer.role.md +46 -0
  20. package/bundled-framework/files/.agents/roles/frontend-developer.role.md +51 -0
  21. package/bundled-framework/files/.agents/roles/process-auditor.role.md +62 -0
  22. package/bundled-framework/files/.agents/roles/solution-architect.role.md +53 -0
  23. package/bundled-framework/files/.agents/roles/technical-writer.role.md +68 -0
  24. package/bundled-framework/files/.agents/roles/ux-designer.role.md +46 -0
  25. package/bundled-framework/files/.skills/pirl-implement/SKILL.md +115 -0
  26. package/bundled-framework/files/.skills/pirl-implement/assets/implementation-record-template.md +22 -0
  27. package/bundled-framework/files/.skills/pirl-implement/references/implementation-record-format.md +41 -0
  28. package/bundled-framework/files/.skills/pirl-implement/scripts/add_implementation_record.py +58 -0
  29. package/bundled-framework/files/.skills/pirl-implement/scripts/new_layout_run.py +51 -0
  30. package/bundled-framework/files/.skills/pirl-plan/SKILL.md +100 -0
  31. package/bundled-framework/files/.skills/pirl-plan/assets/plan-template.md +48 -0
  32. package/bundled-framework/files/.skills/pirl-plan/references/plan-format.md +65 -0
  33. package/bundled-framework/files/.skills/pirl-plan/scripts/new_plan.py +93 -0
  34. package/bundled-framework/files/.skills/pirl-review/SKILL.md +106 -0
  35. package/bundled-framework/files/.skills/pirl-review/assets/review-template.md +92 -0
  36. package/bundled-framework/files/.skills/pirl-review/references/review-format.md +70 -0
  37. package/bundled-framework/files/.skills/pirl-review/scripts/new_review.py +83 -0
  38. package/bundled-framework/files/AGENTS.md +303 -0
  39. package/bundled-framework/files/CONTEXT.md +187 -0
  40. package/bundled-framework/files/LICENSE +21 -0
  41. package/bundled-framework/files/README.md +170 -0
  42. package/bundled-framework/files/gitignore.pirl-bundled +12 -0
  43. package/bundled-framework/manifest.json +45 -0
  44. package/dist/bin/main.js +128 -0
  45. package/dist/bin/pirl.js +6 -0
  46. package/dist/commands/dispatch.js +57 -0
  47. package/dist/commands/registry.js +39 -0
  48. package/dist/core/clipboard.js +70 -0
  49. package/dist/core/config.js +73 -0
  50. package/dist/core/exec.js +32 -0
  51. package/dist/core/paths.js +34 -0
  52. package/dist/core/targetRepo.js +22 -0
  53. package/dist/doctor/doctor.js +95 -0
  54. package/dist/engine/agentMerge.js +71 -0
  55. package/dist/engine/agents.js +67 -0
  56. package/dist/engine/attachments.js +251 -0
  57. package/dist/engine/claudeQuota.js +62 -0
  58. package/dist/engine/claudeRunner.js +118 -0
  59. package/dist/engine/claudeStream.js +205 -0
  60. package/dist/engine/hubData.js +176 -0
  61. package/dist/engine/permissions.js +67 -0
  62. package/dist/engine/pricing.js +24 -0
  63. package/dist/engine/protocol.js +98 -0
  64. package/dist/engine/run.js +527 -0
  65. package/dist/engine/runStore.js +67 -0
  66. package/dist/engine/types.js +3 -0
  67. package/dist/hub/framework.js +13 -0
  68. package/dist/hub/hub.js +125 -0
  69. package/dist/hub/services.js +38 -0
  70. package/dist/hub/update.js +261 -0
  71. package/dist/providers/claude.js +62 -0
  72. package/dist/providers/index.js +17 -0
  73. package/dist/providers/types.js +1 -0
  74. package/dist/ui/App.js +849 -0
  75. package/dist/ui/browse.js +144 -0
  76. package/dist/ui/components/Header.js +54 -0
  77. package/dist/ui/components/Input.js +76 -0
  78. package/dist/ui/components/Lines.js +50 -0
  79. package/dist/ui/components/Panels.js +73 -0
  80. package/dist/ui/content.js +225 -0
  81. package/dist/ui/frameworkConsole.js +541 -0
  82. package/dist/ui/inputEditor.js +86 -0
  83. package/dist/ui/layout.js +36 -0
  84. package/dist/ui/lines.js +15 -0
  85. package/dist/ui/listOverlay.js +97 -0
  86. package/dist/ui/runView.js +305 -0
  87. package/dist/ui/session.js +1 -0
  88. package/dist/ui/terminalInput.js +45 -0
  89. package/dist/ui/text.js +58 -0
  90. package/dist/ui/theme.js +139 -0
  91. package/dist/ui/ticker.js +48 -0
  92. package/package.json +67 -4
@@ -0,0 +1,438 @@
1
+ #!/usr/bin/env python3
2
+ """Start, append, check and query layer self-evaluation records.
3
+
4
+ Records live in .agents/evals/records/<layer>/<YYYY-MM-DD>.jsonl, one JSON object per line, append-only.
5
+ That directory is ignored by Git: records stay on the local machine and are still read by `query` and `check`.
6
+ The record contract (when to write, field meanings, causes, fix rules) is owned by the router,
7
+ section "Self-evaluation and improvement"; this tool only enforces the structure of that contract.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import hashlib
14
+ import json
15
+ import re
16
+ import subprocess
17
+ import sys
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path, PureWindowsPath
20
+ from typing import Any
21
+
22
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
23
+ from validate_layer_artifact import ( # noqa: E402
24
+ IMPLEMENT_STATUSES, REVIEW_STATUSES, VERDICTS, LayerArtifactError, load_roles, resolve_path,
25
+ )
26
+
27
+ # Every schema ever written stays readable; bump CURRENT when the record shape changes.
28
+ CURRENT_SCHEMA = "pirl-record/v1"
29
+ READABLE_SCHEMAS = ("pirl-record/v1",)
30
+ RECORDS = ".agents/evals/records"
31
+ LAYERS = ("plan", "implement", "review")
32
+ LAYER_USAGES = {"plan": {"plan", "audit"}, "implement": {"implement", "verify", "audit"}, "review": {"review", "audit"}}
33
+ OUTCOMES = {
34
+ "plan": ("ready", "draft", "blocked"),
35
+ "implement": IMPLEMENT_STATUSES,
36
+ "review": REVIEW_STATUSES,
37
+ }
38
+ MODES = ("agent", "self")
39
+ RESULTS = ("PASS", "FAIL", "BLOCKED")
40
+ CAUSES = ("instruction", "ineffective_fix", "input", "execution")
41
+ PENDING_REASONS = ("mode_self", "safety", "repository")
42
+ FRAMEWORK_PATHS = ("AGENTS.md", "CONTEXT.md", ".agents/instructions", ".agents/roles", ".agents/evals", ".skills")
43
+ # Tracked paths that are deliberately not framework content. Every tracked file sits under one of the two lists;
44
+ # a test fails when a new path appears in neither, so adding one is a decision, not an accident.
45
+ NOT_FRAMEWORK_TRACKED = (".gitignore", "LICENSE", "README.md")
46
+ # Inside FRAMEWORK_PATHS but not framework content: the records themselves and Python caches.
47
+ NOT_FRAMEWORK = (RECORDS + "/", "__pycache__/")
48
+ KEBAB = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
49
+ TASK = re.compile(r"T\d{2,}")
50
+ ISSUE_ID = re.compile(r"ISSUE-\d{2,}")
51
+ FIX_ID = re.compile(r"FIX-\d{2,}")
52
+ COMMIT = re.compile(r"[0-9a-f]{7,40}|none")
53
+
54
+
55
+ class RecordError(ValueError):
56
+ pass
57
+
58
+
59
+ # ---------- reading ----------
60
+
61
+
62
+ def _record_files(root: Path) -> list[Path]:
63
+ return sorted((root / RECORDS).glob("*/*.jsonl"))
64
+
65
+
66
+ def read_records(root: Path) -> tuple[list[dict[str, Any]], list[str]]:
67
+ """Return every stored record in file order, plus errors for lines that cannot be parsed."""
68
+
69
+ records: list[dict[str, Any]] = []
70
+ errors: list[str] = []
71
+ for path in _record_files(root):
72
+ rel = path.relative_to(root).as_posix()
73
+ for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
74
+ if not line.strip():
75
+ errors.append(f"{rel}:{number}: empty line")
76
+ continue
77
+ try:
78
+ data = json.loads(line)
79
+ except json.JSONDecodeError as exc:
80
+ errors.append(f"{rel}:{number}: invalid JSON ({exc.msg})")
81
+ continue
82
+ if not isinstance(data, dict):
83
+ errors.append(f"{rel}:{number}: a record must be a JSON object")
84
+ continue
85
+ data["_location"] = f"{rel}:{number}"
86
+ records.append(data)
87
+ return records, errors
88
+
89
+
90
+ # ---------- validation ----------
91
+
92
+
93
+ def _text(value: Any, label: str, errors: list[str]) -> str:
94
+ if not isinstance(value, str) or not value.strip():
95
+ errors.append(f"{label}: non-empty text is required")
96
+ return ""
97
+ return value
98
+
99
+
100
+ def _list(value: Any, label: str, errors: list[str], required: bool = True) -> list[Any]:
101
+ if not isinstance(value, list):
102
+ errors.append(f"{label}: expected an array")
103
+ return []
104
+ if required and not value:
105
+ errors.append(f"{label}: at least one entry is required")
106
+ return value
107
+
108
+
109
+ def _relative(value: Any, label: str, errors: list[str]) -> str:
110
+ text = _text(value, label, errors)
111
+ if text and (PureWindowsPath(text).drive or text.startswith(("/", "\\")) or ".." in Path(text).parts):
112
+ errors.append(f"{label}: must be a path relative to the Hub root")
113
+ return text
114
+
115
+
116
+ def validate_record(record: dict[str, Any], others: list[dict[str, Any]], root: Path, catalog: dict[str, set[str]]) -> list[str]:
117
+ """Check one record against the v1 contract; `others` are the records written at or before it."""
118
+
119
+ errors: list[str] = []
120
+ if record.get("schema") not in READABLE_SCHEMAS:
121
+ return [f"schema must be one of {', '.join(READABLE_SCHEMAS)}"]
122
+ by_id = {item.get("record_id"): item for item in others}
123
+ layer = record.get("layer")
124
+ if layer not in LAYERS:
125
+ errors.append(f"layer must be one of {', '.join(LAYERS)}")
126
+ layer = None
127
+
128
+ ref = record.get("framework_ref")
129
+ if not isinstance(ref, dict) or not isinstance(ref.get("commit"), str) or not COMMIT.fullmatch(ref["commit"]):
130
+ errors.append("framework_ref.commit: expected a git commit hash or 'none' (from `start`)")
131
+ elif not isinstance(ref.get("files"), dict) or not all(isinstance(v, str) for v in ref["files"].values()):
132
+ errors.append("framework_ref.files: expected an object of path -> content hash (from `start`)")
133
+
134
+ task = record.get("task")
135
+ if not isinstance(task, dict):
136
+ errors.append("task: expected an object")
137
+ task = {}
138
+ _text(task.get("plan_set"), "task.plan_set", errors)
139
+ ids = _list(task.get("task_ids"), "task.task_ids", errors)
140
+ if any(not isinstance(t, str) or not TASK.fullmatch(t) for t in ids):
141
+ errors.append("task.task_ids: each entry must be a task ID such as T01")
142
+ if layer == "review":
143
+ report = _text(task.get("report_file"), "task.report_file", errors)
144
+ if report and not resolve_path(report, root).is_file():
145
+ errors.append(f"task.report_file: {report} does not exist (relative to the Hub root or absolute)")
146
+ _text(record.get("target_repo"), "target_repo", errors)
147
+
148
+ outcome = record.get("outcome")
149
+ if not isinstance(outcome, dict):
150
+ errors.append("outcome: expected an object")
151
+ outcome = {}
152
+ if layer and outcome.get("status") not in OUTCOMES[layer]:
153
+ errors.append(f"outcome.status: expected one of {', '.join(OUTCOMES[layer])} for layer {layer}")
154
+ if layer == "review" and outcome.get("verdict") not in VERDICTS:
155
+ errors.append("outcome.verdict: expected PASS, FAIL or BLOCKED for a Review record")
156
+ _text(outcome.get("summary"), "outcome.summary", errors)
157
+
158
+ def check_role(name: Any, label: str, usages: set[str] | None) -> None:
159
+ if name == "coordinator":
160
+ return
161
+ if not isinstance(name, str) or name not in catalog:
162
+ errors.append(f"{label}: unknown role {name}")
163
+ elif usages and not catalog[name] & usages:
164
+ errors.append(f"{label}: role {name} is not registered for this layer")
165
+
166
+ roles: dict[str, dict[str, Any]] = {}
167
+ for index, entry in enumerate(_list(record.get("roles"), "roles", errors)):
168
+ label = f"roles[{index}]"
169
+ if not isinstance(entry, dict):
170
+ errors.append(f"{label}: expected an object")
171
+ continue
172
+ name = entry.get("role")
173
+ check_role(name, f"{label}.role", LAYER_USAGES.get(layer or "", None))
174
+ if name in roles:
175
+ errors.append(f"{label}: duplicate role {name}")
176
+ roles[str(name)] = entry
177
+ if entry.get("mode") not in MODES:
178
+ errors.append(f"{label}.mode: expected agent or self")
179
+ if entry.get("result") not in RESULTS:
180
+ errors.append(f"{label}.result: expected PASS, FAIL or BLOCKED")
181
+ _text(entry.get("assignment"), f"{label}.assignment", errors)
182
+
183
+ audit = record.get("audit")
184
+ if not isinstance(audit, dict) or audit.get("mode") not in MODES:
185
+ errors.append("audit.mode: expected agent or self")
186
+ audit = {}
187
+ if audit.get("role") != "process-auditor":
188
+ errors.append("audit.role: must be process-auditor")
189
+
190
+ for key in ("evaluates", "supersedes"):
191
+ value = record.get(key)
192
+ if value is not None and value not in by_id:
193
+ errors.append(f"{key}: record {value} does not exist")
194
+
195
+ issues = _list(record.get("issues"), "issues", errors, required=False)
196
+ fixes = _list(record.get("fixes"), "fixes", errors, required=False)
197
+ if audit and audit.get("result") != ("issues" if issues else "clean"):
198
+ errors.append("audit.result: must be 'issues' when issues exist, otherwise 'clean'")
199
+
200
+ issue_ids: set[str] = set()
201
+ for index, issue in enumerate(issues):
202
+ label = f"issues[{index}]"
203
+ if not isinstance(issue, dict):
204
+ errors.append(f"{label}: expected an object")
205
+ continue
206
+ issue_id = issue.get("id")
207
+ if not isinstance(issue_id, str) or not ISSUE_ID.fullmatch(issue_id) or issue_id in issue_ids:
208
+ errors.append(f"{label}.id: expected a unique ID such as ISSUE-01")
209
+ issue_ids.add(str(issue_id))
210
+ if not isinstance(issue.get("issue_key"), str) or not KEBAB.fullmatch(issue["issue_key"]):
211
+ errors.append(f"{label}.issue_key: expected a stable kebab-case key")
212
+ scope = issue.get("record")
213
+ if scope is None:
214
+ if issue.get("role") != "coordinator" and issue.get("role") not in roles:
215
+ errors.append(f"{label}.role: {issue.get('role')} did not take part in this run")
216
+ elif scope not in by_id:
217
+ errors.append(f"{label}.record: record {scope} does not exist")
218
+ elif scope != record.get("evaluates"):
219
+ errors.append(f"{label}.record: only the record named in `evaluates` can be judged here")
220
+ elif issue.get("role") != "coordinator" and issue.get("role") not in {r.get("role") for r in by_id[scope].get("roles", []) if isinstance(r, dict)}:
221
+ errors.append(f"{label}.role: {issue.get('role')} did not take part in {scope}")
222
+ cause = issue.get("cause")
223
+ if cause not in CAUSES:
224
+ errors.append(f"{label}.cause: expected one of {', '.join(CAUSES)}")
225
+ for key in ("decision", "rule", "evidence"):
226
+ _text(issue.get(key), f"{label}.{key}", errors)
227
+ prior = issue.get("prior_fix")
228
+ if cause == "ineffective_fix":
229
+ prior_record, _, prior_fix = str(prior).partition("#")
230
+ stored = by_id.get(prior_record)
231
+ if prior is None or stored is None or prior_fix not in {f.get("id") for f in stored.get("fixes", []) if isinstance(f, dict)}:
232
+ errors.append(f"{label}.prior_fix: an ineffective_fix must name an earlier fix as <record_id>#FIX-NN")
233
+ elif prior is not None:
234
+ errors.append(f"{label}.prior_fix: only an ineffective_fix names an earlier fix")
235
+
236
+ covered: set[str] = set()
237
+ fix_ids: set[str] = set()
238
+ for index, fix in enumerate(fixes):
239
+ label = f"fixes[{index}]"
240
+ if not isinstance(fix, dict):
241
+ errors.append(f"{label}: expected an object")
242
+ continue
243
+ fix_id = fix.get("id")
244
+ if not isinstance(fix_id, str) or not FIX_ID.fullmatch(fix_id) or fix_id in fix_ids:
245
+ errors.append(f"{label}.id: expected a unique ID such as FIX-01")
246
+ fix_ids.add(str(fix_id))
247
+ targets = _list(fix.get("issues"), f"{label}.issues", errors)
248
+ for target in targets:
249
+ if target not in issue_ids:
250
+ errors.append(f"{label}.issues: unknown issue {target}")
251
+ covered.update(t for t in targets if isinstance(t, str))
252
+ status, reason = fix.get("status"), fix.get("reason")
253
+ if status == "applied":
254
+ if audit.get("mode") == "self":
255
+ errors.append(f"{label}.status: a self-evaluation cannot apply fixes; use pending_user with reason mode_self")
256
+ elif status == "pending_user":
257
+ if reason not in PENDING_REASONS:
258
+ errors.append(f"{label}.reason: pending_user needs reason {', '.join(PENDING_REASONS)}")
259
+ elif reason == "mode_self" and audit.get("mode") != "self":
260
+ errors.append(f"{label}.reason: mode_self is only valid when audit.mode is self")
261
+ else:
262
+ errors.append(f"{label}.status: expected applied or pending_user")
263
+ for position, item in enumerate(_list(fix.get("files"), f"{label}.files", errors)):
264
+ if not isinstance(item, dict):
265
+ errors.append(f"{label}.files[{position}]: expected an object")
266
+ continue
267
+ _relative(item.get("path"), f"{label}.files[{position}].path", errors)
268
+ _text(item.get("section"), f"{label}.files[{position}].section", errors)
269
+ _text(fix.get("summary"), f"{label}.summary", errors)
270
+ if status == "applied":
271
+ _list(fix.get("checks"), f"{label}.checks", errors)
272
+ for missing in sorted(issue_ids - covered):
273
+ errors.append(f"issues: {missing} has no fix; every issue needs an improvement")
274
+ return errors
275
+
276
+
277
+ # ---------- commands ----------
278
+
279
+
280
+ def _git(root: Path, *args: str) -> str | None:
281
+ try:
282
+ result = subprocess.run(["git", "-C", str(root), *args], capture_output=True, text=True, encoding="utf-8", check=False)
283
+ except OSError:
284
+ return None
285
+ return result.stdout if result.returncode == 0 else None
286
+
287
+
288
+ def _hash(path: Path) -> str:
289
+ return hashlib.sha256(path.read_bytes()).hexdigest()[:12] if path.is_file() else "deleted"
290
+
291
+
292
+ def framework_ref(root: Path) -> dict[str, Any]:
293
+ """Pin the framework version in force: the Hub commit plus a hash of each framework file that differs from it."""
294
+
295
+ commit = (_git(root, "rev-parse", "HEAD") or "").strip()
296
+ if not commit:
297
+ files = [p for base in FRAMEWORK_PATHS for p in ([root / base] if (root / base).is_file() else sorted((root / base).rglob("*")))]
298
+ paths = {p.relative_to(root).as_posix(): p for p in files if p.is_file()}
299
+ return {"commit": "none", "files": {rel: _hash(p) for rel, p in paths.items() if not _excluded(rel)}}
300
+ status = _git(root, "-c", "core.quotepath=off", "status", "--porcelain", "--untracked-files=all", "--", *FRAMEWORK_PATHS) or ""
301
+ changed = {line[3:].split(" -> ")[-1].strip('"') for line in status.splitlines() if len(line) > 3}
302
+ return {"commit": commit, "files": {path: _hash(root / path) for path in sorted(changed) if not _excluded(path)}}
303
+
304
+
305
+ def _excluded(path: str) -> bool:
306
+ return any(path.startswith(prefix) or f"/{prefix}" in path for prefix in NOT_FRAMEWORK)
307
+
308
+
309
+ def _read_input(path: str | None) -> dict[str, Any]:
310
+ raw = Path(path).read_bytes() if path else sys.stdin.buffer.read()
311
+ try:
312
+ data = json.loads(raw.decode("utf-8-sig"))
313
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
314
+ raise RecordError(f"input is not UTF-8 JSON: {exc}") from exc
315
+ if not isinstance(data, dict):
316
+ raise RecordError("input must be a JSON object")
317
+ return data
318
+
319
+
320
+ def append(root: Path, data: dict[str, Any]) -> dict[str, Any]:
321
+ stored, broken = read_records(root)
322
+ if broken:
323
+ raise RecordError("existing records are unreadable; run `check` and repair them first: " + "; ".join(broken))
324
+ for key in ("schema", "record_id", "recorded_at"):
325
+ if key in data:
326
+ raise RecordError(f"{key} is set by the tool, not by the input")
327
+ now = datetime.now().astimezone()
328
+ record = {"schema": CURRENT_SCHEMA, "record_id": "", "recorded_at": now.isoformat(timespec="seconds"), **data}
329
+ tasks = data.get("task", {}).get("task_ids") if isinstance(data.get("task"), dict) else None
330
+ base = f"{data.get('layer')}-{now:%Y%m%d-%H%M%S}-{tasks[0] if isinstance(tasks, list) and tasks else 'T00'}"
331
+ existing = {r.get("record_id") for r in stored}
332
+ record_id, number = base, 1
333
+ while record_id in existing:
334
+ number += 1
335
+ record_id = f"{base}-{number}"
336
+ record["record_id"] = record_id
337
+ errors = validate_record(record, stored, root, load_roles(root))
338
+ if errors:
339
+ raise RecordError("; ".join(errors))
340
+ target = root / RECORDS / str(record["layer"]) / f"{now:%Y-%m-%d}.jsonl"
341
+ target.parent.mkdir(parents=True, exist_ok=True)
342
+ with target.open("a", encoding="utf-8", newline="\n") as handle:
343
+ handle.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n")
344
+ return {"record_id": record["record_id"], "path": target.relative_to(root).as_posix()}
345
+
346
+
347
+ def _instant(value: Any) -> datetime:
348
+ """Records may carry different UTC offsets (the machine's own), so order them by instant, not by text.
349
+ A missing or malformed stamp sorts first, as an empty string did."""
350
+ try:
351
+ parsed = datetime.fromisoformat(value) if isinstance(value, str) else None
352
+ except ValueError:
353
+ parsed = None
354
+ if parsed is None or parsed.utcoffset() is None:
355
+ return datetime.min.replace(tzinfo=timezone.utc)
356
+ return parsed
357
+
358
+
359
+ def check(root: Path) -> list[str]:
360
+ stored, errors = read_records(root)
361
+ catalog = load_roles(root)
362
+ located = [(record.pop("_location"), record) for record in stored]
363
+ ids: set[Any] = set()
364
+ for location, record in located:
365
+ if record.get("record_id") in ids:
366
+ errors.append(f"{location}: duplicate record_id {record.get('record_id')}")
367
+ # Layer folders sort by name, not by time, so "stored before" is judged by `recorded_at` (same instant allowed):
368
+ # a record may name any record written at or before its own time, whichever layer folder holds it.
369
+ stamp = _instant(record.get("recorded_at"))
370
+ earlier = [other for _, other in located if other is not record and _instant(other.get("recorded_at")) <= stamp]
371
+ errors.extend(f"{location}: {error}" for error in validate_record(record, earlier, root, catalog))
372
+ ids.add(record.get("record_id"))
373
+ return errors
374
+
375
+
376
+ def query(root: Path, args: argparse.Namespace) -> list[dict[str, Any]]:
377
+ stored, _ = read_records(root)
378
+ found = []
379
+ for record in stored:
380
+ record.pop("_location", None)
381
+ task = record.get("task") or {}
382
+ if args.layer and record.get("layer") != args.layer:
383
+ continue
384
+ if args.plan_set and task.get("plan_set") != args.plan_set:
385
+ continue
386
+ if args.task and args.task not in (task.get("task_ids") or []):
387
+ continue
388
+ issues = record.get("issues") or []
389
+ if args.issue_key or args.rule:
390
+ issues = [i for i in issues if (not args.issue_key or i.get("issue_key") == args.issue_key)
391
+ and (not args.rule or args.rule in str(i.get("rule", "")))]
392
+ if not issues:
393
+ continue
394
+ ids = {i.get("id") for i in issues}
395
+ fixes = [f for f in record.get("fixes") or [] if ids & set(f.get("issues") or [])]
396
+ found.append({"record_id": record.get("record_id"), "layer": record.get("layer"), "task": task,
397
+ "outcome": record.get("outcome"), "issues": issues, "fixes": fixes})
398
+ return found
399
+
400
+
401
+ def repository_root() -> Path:
402
+ return Path(__file__).resolve().parents[2]
403
+
404
+
405
+ def main() -> int:
406
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
407
+ parser.add_argument("--root", type=Path, default=repository_root())
408
+ commands = parser.add_subparsers(dest="command", required=True)
409
+ commands.add_parser("start", help="print framework_ref for the run that is starting")
410
+ adding = commands.add_parser("append", help="validate one record (JSON from --input or stdin) and append it")
411
+ adding.add_argument("--input", help="UTF-8 JSON file; safer than a pipe on Windows PowerShell")
412
+ commands.add_parser("check", help="validate every stored record")
413
+ finding = commands.add_parser("query", help="list stored records and their matching issues and fixes")
414
+ for flag in ("--layer", "--plan-set", "--task", "--issue-key", "--rule"):
415
+ finding.add_argument(flag)
416
+ args = parser.parse_args()
417
+ root = args.root.resolve()
418
+ if hasattr(sys.stdout, "reconfigure"):
419
+ sys.stdout.reconfigure(encoding="utf-8")
420
+ try:
421
+ if args.command == "start":
422
+ result: Any = {"framework_ref": framework_ref(root)}
423
+ elif args.command == "append":
424
+ result = {"passed": True, **append(root, _read_input(args.input))}
425
+ elif args.command == "check":
426
+ errors = check(root)
427
+ result = {"passed": not errors, "errors": errors}
428
+ else:
429
+ result = query(root, args)
430
+ except (RecordError, LayerArtifactError, OSError) as exc:
431
+ print(json.dumps({"passed": False, "errors": [str(exc)]}, ensure_ascii=False, indent=2))
432
+ return 1
433
+ print(json.dumps(result, ensure_ascii=False, indent=2))
434
+ return 0 if not (isinstance(result, dict) and result.get("passed") is False) else 1
435
+
436
+
437
+ if __name__ == "__main__":
438
+ raise SystemExit(main())