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.
- package/CHANGELOG.md +50 -0
- package/LICENSE +21 -0
- package/README.md +265 -2
- package/bundled-framework/files/.agents/evals/layer_record.py +438 -0
- package/bundled-framework/files/.agents/evals/tests/test_layer_record.py +264 -0
- package/bundled-framework/files/.agents/evals/tests/test_skills.py +210 -0
- package/bundled-framework/files/.agents/evals/tests/test_validate_layer_artifact.py +214 -0
- package/bundled-framework/files/.agents/evals/validate_layer_artifact.py +415 -0
- package/bundled-framework/files/.agents/instructions/implement.instructions.md +343 -0
- package/bundled-framework/files/.agents/instructions/layered-task-execution.instructions.md +370 -0
- package/bundled-framework/files/.agents/instructions/layout-verification.instructions.md +107 -0
- package/bundled-framework/files/.agents/instructions/plan.instructions.md +292 -0
- package/bundled-framework/files/.agents/instructions/review.instructions.md +236 -0
- package/bundled-framework/files/.agents/instructions/self-evaluation.instructions.md +146 -0
- package/bundled-framework/files/.agents/instructions/standalone-and-maintenance.instructions.md +230 -0
- package/bundled-framework/files/.agents/roles/backend-developer.role.md +45 -0
- package/bundled-framework/files/.agents/roles/business-analyst.role.md +55 -0
- package/bundled-framework/files/.agents/roles/code-analyst.role.md +63 -0
- package/bundled-framework/files/.agents/roles/code-reviewer.role.md +46 -0
- package/bundled-framework/files/.agents/roles/frontend-developer.role.md +51 -0
- package/bundled-framework/files/.agents/roles/process-auditor.role.md +62 -0
- package/bundled-framework/files/.agents/roles/solution-architect.role.md +53 -0
- package/bundled-framework/files/.agents/roles/technical-writer.role.md +68 -0
- package/bundled-framework/files/.agents/roles/ux-designer.role.md +46 -0
- package/bundled-framework/files/.skills/pirl-implement/SKILL.md +115 -0
- package/bundled-framework/files/.skills/pirl-implement/assets/implementation-record-template.md +22 -0
- package/bundled-framework/files/.skills/pirl-implement/references/implementation-record-format.md +41 -0
- package/bundled-framework/files/.skills/pirl-implement/scripts/add_implementation_record.py +58 -0
- package/bundled-framework/files/.skills/pirl-implement/scripts/new_layout_run.py +51 -0
- package/bundled-framework/files/.skills/pirl-plan/SKILL.md +100 -0
- package/bundled-framework/files/.skills/pirl-plan/assets/plan-template.md +48 -0
- package/bundled-framework/files/.skills/pirl-plan/references/plan-format.md +65 -0
- package/bundled-framework/files/.skills/pirl-plan/scripts/new_plan.py +93 -0
- package/bundled-framework/files/.skills/pirl-review/SKILL.md +106 -0
- package/bundled-framework/files/.skills/pirl-review/assets/review-template.md +92 -0
- package/bundled-framework/files/.skills/pirl-review/references/review-format.md +70 -0
- package/bundled-framework/files/.skills/pirl-review/scripts/new_review.py +83 -0
- package/bundled-framework/files/AGENTS.md +303 -0
- package/bundled-framework/files/CONTEXT.md +187 -0
- package/bundled-framework/files/LICENSE +21 -0
- package/bundled-framework/files/README.md +170 -0
- package/bundled-framework/files/gitignore.pirl-bundled +12 -0
- package/bundled-framework/manifest.json +45 -0
- package/dist/bin/main.js +128 -0
- package/dist/bin/pirl.js +6 -0
- package/dist/commands/dispatch.js +57 -0
- package/dist/commands/registry.js +39 -0
- package/dist/core/clipboard.js +70 -0
- package/dist/core/config.js +73 -0
- package/dist/core/exec.js +32 -0
- package/dist/core/paths.js +34 -0
- package/dist/core/targetRepo.js +22 -0
- package/dist/doctor/doctor.js +95 -0
- package/dist/engine/agentMerge.js +71 -0
- package/dist/engine/agents.js +67 -0
- package/dist/engine/attachments.js +251 -0
- package/dist/engine/claudeQuota.js +62 -0
- package/dist/engine/claudeRunner.js +118 -0
- package/dist/engine/claudeStream.js +205 -0
- package/dist/engine/hubData.js +176 -0
- package/dist/engine/permissions.js +67 -0
- package/dist/engine/pricing.js +24 -0
- package/dist/engine/protocol.js +98 -0
- package/dist/engine/run.js +527 -0
- package/dist/engine/runStore.js +67 -0
- package/dist/engine/types.js +3 -0
- package/dist/hub/framework.js +13 -0
- package/dist/hub/hub.js +125 -0
- package/dist/hub/services.js +38 -0
- package/dist/hub/update.js +261 -0
- package/dist/providers/claude.js +62 -0
- package/dist/providers/index.js +17 -0
- package/dist/providers/types.js +1 -0
- package/dist/ui/App.js +849 -0
- package/dist/ui/browse.js +144 -0
- package/dist/ui/components/Header.js +54 -0
- package/dist/ui/components/Input.js +76 -0
- package/dist/ui/components/Lines.js +50 -0
- package/dist/ui/components/Panels.js +73 -0
- package/dist/ui/content.js +225 -0
- package/dist/ui/frameworkConsole.js +541 -0
- package/dist/ui/inputEditor.js +86 -0
- package/dist/ui/layout.js +36 -0
- package/dist/ui/lines.js +15 -0
- package/dist/ui/listOverlay.js +97 -0
- package/dist/ui/runView.js +305 -0
- package/dist/ui/session.js +1 -0
- package/dist/ui/terminalInput.js +45 -0
- package/dist/ui/text.js +58 -0
- package/dist/ui/theme.js +139 -0
- package/dist/ui/ticker.js +48 -0
- 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())
|