pirl-framework-cli 0.0.0-stage → 1.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/CHANGELOG.md +78 -0
- package/LICENSE +21 -0
- package/README.md +88 -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,264 @@
|
|
|
1
|
+
"""Check the record writer's public contract: accepted records, actionable rejections and stored lines."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import copy
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import unittest
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
EVALS = Path(__file__).resolve().parents[1]
|
|
15
|
+
TOOL = EVALS / "layer_record.py"
|
|
16
|
+
sys.path.insert(0, str(EVALS))
|
|
17
|
+
from validate_layer_artifact import CATALOG # noqa: E402
|
|
18
|
+
|
|
19
|
+
ROLES = """### Role catalog
|
|
20
|
+
|
|
21
|
+
| ID | Expertise | Used in |
|
|
22
|
+
| --- | --- | --- |
|
|
23
|
+
| `business-analyst` | Business logic | plan, review |
|
|
24
|
+
| `code-analyst` | Flow tracing | plan, verify, review |
|
|
25
|
+
| `backend-developer` | Non-UI code | implement |
|
|
26
|
+
| `code-reviewer` | Challenges the diff | verify, review |
|
|
27
|
+
| `process-auditor` | Self-evaluation | audit |
|
|
28
|
+
|
|
29
|
+
### Other section
|
|
30
|
+
"""
|
|
31
|
+
# Records are stored as UTF-8 without escaping; this sample checks that non-ASCII text survives.
|
|
32
|
+
NON_ASCII = "Không quét tài liệu ngoài đường dẫn Plan"
|
|
33
|
+
REF = {"commit": "none", "files": {"AGENTS.md": "0123456789ab"}}
|
|
34
|
+
IMPLEMENT = {
|
|
35
|
+
"layer": "implement",
|
|
36
|
+
"framework_ref": REF,
|
|
37
|
+
"task": {"plan_set": "sign-in_20261014-090000", "task_ids": ["T01"]},
|
|
38
|
+
"target_repo": ".",
|
|
39
|
+
"outcome": {"status": "implemented", "summary": "Added a check for expired OTP codes"},
|
|
40
|
+
"roles": [
|
|
41
|
+
{"role": "backend-developer", "mode": "agent", "assignment": "Fix OTP verification", "result": "PASS"},
|
|
42
|
+
{"role": "code-reviewer", "mode": "agent", "assignment": "Conclude QG-01", "result": "PASS"},
|
|
43
|
+
],
|
|
44
|
+
"audit": {"role": "process-auditor", "mode": "agent", "result": "issues"},
|
|
45
|
+
"issues": [{
|
|
46
|
+
"id": "ISSUE-01", "issue_key": "skip-consistency-sweep", "role": "backend-developer", "record": None,
|
|
47
|
+
"cause": "instruction", "decision": "Did not sweep documentation outside the Plan paths",
|
|
48
|
+
"rule": ".agents/instructions/implement.instructions.md#implementing-and-repairing-per-review",
|
|
49
|
+
"evidence": "The API index still describes the old behavior", "prior_fix": None,
|
|
50
|
+
}],
|
|
51
|
+
"fixes": [{
|
|
52
|
+
"id": "FIX-01", "issues": ["ISSUE-01"], "status": "applied",
|
|
53
|
+
"files": [{"path": ".agents/instructions/implement.instructions.md", "section": "Implementing and repairing per Review"}],
|
|
54
|
+
"summary": "State that indexes and status tables must be swept", "checks": ["python -B -m unittest discover"],
|
|
55
|
+
}],
|
|
56
|
+
"evaluates": None,
|
|
57
|
+
"supersedes": None,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class LayerRecordTests(unittest.TestCase):
|
|
62
|
+
def setUp(self):
|
|
63
|
+
temporary = tempfile.TemporaryDirectory()
|
|
64
|
+
self.addCleanup(temporary.cleanup)
|
|
65
|
+
self.root = Path(temporary.name)
|
|
66
|
+
(self.root / "AGENTS.md").write_text("# Start here\n", encoding="utf-8")
|
|
67
|
+
(self.root / CATALOG).parent.mkdir(parents=True)
|
|
68
|
+
(self.root / CATALOG).write_text(ROLES, encoding="utf-8")
|
|
69
|
+
# Reports live wherever the user puts them; by default under the Hub root.
|
|
70
|
+
self.report = self.root / "sign-in_20261014-090000/t01-otp/round-01.md"
|
|
71
|
+
self.report.parent.mkdir(parents=True)
|
|
72
|
+
self.report.write_text("---\n---\n", encoding="utf-8")
|
|
73
|
+
|
|
74
|
+
def run_tool(self, *args, payload=None, raw=None):
|
|
75
|
+
stdin = raw if raw is not None else (json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else b"")
|
|
76
|
+
result = subprocess.run([sys.executable, "-B", str(TOOL), "--root", str(self.root), *args], input=stdin, capture_output=True)
|
|
77
|
+
return result.returncode, json.loads(result.stdout.decode("utf-8"))
|
|
78
|
+
|
|
79
|
+
def append(self, payload):
|
|
80
|
+
return self.run_tool("append", payload=payload)
|
|
81
|
+
|
|
82
|
+
def stored(self):
|
|
83
|
+
return [json.loads(line) for path in sorted((self.root / ".agents/evals/records").glob("*/*.jsonl"))
|
|
84
|
+
for line in path.read_text(encoding="utf-8").splitlines()]
|
|
85
|
+
|
|
86
|
+
def test_append_stores_one_utf8_line_with_tool_fields(self):
|
|
87
|
+
payload = copy.deepcopy(IMPLEMENT)
|
|
88
|
+
payload["outcome"]["summary"] = NON_ASCII
|
|
89
|
+
code, result = self.append(payload)
|
|
90
|
+
self.assertEqual(code, 0, result)
|
|
91
|
+
self.assertTrue(result["record_id"].startswith("implement-") and result["record_id"].endswith("-T01"))
|
|
92
|
+
self.assertTrue(result["path"].startswith(".agents/evals/records/implement/"))
|
|
93
|
+
[record] = self.stored()
|
|
94
|
+
self.assertEqual(record["schema"], "pirl-record/v1")
|
|
95
|
+
self.assertEqual(record["outcome"]["summary"], NON_ASCII)
|
|
96
|
+
self.assertIsNotNone(datetime.fromisoformat(record["recorded_at"]).utcoffset())
|
|
97
|
+
code, again = self.append(IMPLEMENT)
|
|
98
|
+
self.assertEqual(code, 0, again)
|
|
99
|
+
self.assertNotEqual(again["record_id"], result["record_id"])
|
|
100
|
+
|
|
101
|
+
def test_input_file_with_bom_keeps_non_ascii_text(self):
|
|
102
|
+
payload = copy.deepcopy(IMPLEMENT)
|
|
103
|
+
payload["issues"][0]["decision"] = NON_ASCII
|
|
104
|
+
source = self.root / "record.json"
|
|
105
|
+
source.write_bytes(b"\xef\xbb\xbf" + json.dumps(payload, ensure_ascii=False).encode("utf-8"))
|
|
106
|
+
code, result = self.run_tool("append", "--input", str(source))
|
|
107
|
+
self.assertEqual(code, 0, result)
|
|
108
|
+
self.assertEqual(self.stored()[0]["issues"][0]["decision"], NON_ASCII)
|
|
109
|
+
code, result = self.run_tool("append", raw="not JSON".encode("utf-8"))
|
|
110
|
+
self.assertNotEqual(code, 0)
|
|
111
|
+
|
|
112
|
+
def test_rejections_name_the_broken_rule(self):
|
|
113
|
+
def variant(change):
|
|
114
|
+
data = copy.deepcopy(IMPLEMENT)
|
|
115
|
+
change(data)
|
|
116
|
+
return data
|
|
117
|
+
|
|
118
|
+
cases = {
|
|
119
|
+
"self-evaluation cannot apply": variant(lambda d: d["audit"].update(mode="self")),
|
|
120
|
+
"has no fix": variant(lambda d: d.update(fixes=[])),
|
|
121
|
+
"ineffective_fix must name an earlier fix": variant(lambda d: d["issues"][0].update(cause="ineffective_fix")),
|
|
122
|
+
"unknown role": variant(lambda d: d["roles"][0].update(role="implementer")),
|
|
123
|
+
"not registered for this layer": variant(lambda d: d["roles"][0].update(role="business-analyst")),
|
|
124
|
+
"did not take part": variant(lambda d: d["issues"][0].update(role="code-analyst")),
|
|
125
|
+
"set by the tool": variant(lambda d: d.update(record_id="x")),
|
|
126
|
+
"mode_self is only valid": variant(lambda d: d["fixes"][0].update(status="pending_user", reason="mode_self")),
|
|
127
|
+
"audit.result": variant(lambda d: d["audit"].update(result="clean")),
|
|
128
|
+
}
|
|
129
|
+
for message, payload in cases.items():
|
|
130
|
+
with self.subTest(message=message):
|
|
131
|
+
code, result = self.append(payload)
|
|
132
|
+
self.assertNotEqual(code, 0)
|
|
133
|
+
self.assertIn(message, " ".join(result["errors"]))
|
|
134
|
+
self.assertEqual(self.stored(), [])
|
|
135
|
+
|
|
136
|
+
def test_pending_fixes_follow_mode_and_safety(self):
|
|
137
|
+
self_run = copy.deepcopy(IMPLEMENT)
|
|
138
|
+
self_run["audit"]["mode"] = "self"
|
|
139
|
+
self_run["fixes"][0].update(status="pending_user", reason="mode_self")
|
|
140
|
+
self.assertEqual(self.append(self_run)[0], 0)
|
|
141
|
+
safety = copy.deepcopy(IMPLEMENT)
|
|
142
|
+
safety["fixes"][0].update(status="pending_user", reason="safety")
|
|
143
|
+
self.assertEqual(self.append(safety)[0], 0)
|
|
144
|
+
repository = copy.deepcopy(IMPLEMENT)
|
|
145
|
+
repository["issues"][0]["cause"] = "input"
|
|
146
|
+
repository["fixes"][0].update(status="pending_user", reason="repository",
|
|
147
|
+
files=[{"path": "sign-in_20261014-090000", "section": "Target repository context documentation"}])
|
|
148
|
+
self.assertEqual(self.append(repository)[0], 0)
|
|
149
|
+
unknown = copy.deepcopy(IMPLEMENT)
|
|
150
|
+
unknown["fixes"][0].update(status="pending_user", reason="later")
|
|
151
|
+
self.assertNotEqual(self.append(unknown)[0], 0)
|
|
152
|
+
|
|
153
|
+
def test_ineffective_fix_and_review_link_earlier_records(self):
|
|
154
|
+
_, first = self.append(IMPLEMENT)
|
|
155
|
+
repeat = copy.deepcopy(IMPLEMENT)
|
|
156
|
+
repeat["issues"][0].update(cause="ineffective_fix", prior_fix=f"{first['record_id']}#FIX-01")
|
|
157
|
+
code, second = self.append(repeat)
|
|
158
|
+
self.assertEqual(code, 0, second)
|
|
159
|
+
review = {
|
|
160
|
+
**copy.deepcopy(IMPLEMENT),
|
|
161
|
+
"layer": "review",
|
|
162
|
+
"task": {"plan_set": "sign-in_20261014-090000", "task_ids": ["T01"],
|
|
163
|
+
"report_file": self.report.relative_to(self.root).as_posix()},
|
|
164
|
+
"outcome": {"status": "completed", "verdict": "FAIL", "summary": "FIND-01 expired OTP"},
|
|
165
|
+
"roles": [{"role": "code-reviewer", "mode": "agent", "assignment": "Challenge the diff", "result": "PASS"}],
|
|
166
|
+
"evaluates": second["record_id"],
|
|
167
|
+
}
|
|
168
|
+
review["issues"][0].update(role="code-reviewer", record=second["record_id"], cause="execution",
|
|
169
|
+
decision="Missed the expired OTP branch")
|
|
170
|
+
code, result = self.append(review)
|
|
171
|
+
self.assertEqual(code, 0, result)
|
|
172
|
+
absolute = copy.deepcopy(review)
|
|
173
|
+
absolute["task"]["report_file"] = str(self.report)
|
|
174
|
+
self.assertEqual(self.append(absolute)[0], 0)
|
|
175
|
+
missing = copy.deepcopy(review)
|
|
176
|
+
missing["evaluates"] = "implement-20000101-000000-T01"
|
|
177
|
+
self.assertNotEqual(self.append(missing)[0], 0)
|
|
178
|
+
found = self.run_tool("query", "--issue-key", "skip-consistency-sweep")[1]
|
|
179
|
+
self.assertEqual([r["layer"] for r in found], ["implement", "implement", "review", "review"])
|
|
180
|
+
self.assertEqual(found[0]["fixes"][0]["id"], "FIX-01")
|
|
181
|
+
self.assertEqual(len(self.run_tool("query", "--layer", "review", "--task", "T01")[1]), 2)
|
|
182
|
+
|
|
183
|
+
def test_check_resolves_references_by_time_not_by_layer_folder(self):
|
|
184
|
+
_, first = self.append(IMPLEMENT)
|
|
185
|
+
review = {
|
|
186
|
+
**copy.deepcopy(IMPLEMENT),
|
|
187
|
+
"layer": "review",
|
|
188
|
+
"task": {"plan_set": "sign-in_20261014-090000", "task_ids": ["T01"],
|
|
189
|
+
"report_file": self.report.relative_to(self.root).as_posix()},
|
|
190
|
+
"outcome": {"status": "completed", "verdict": "FAIL", "summary": "FIND-01 expired OTP"},
|
|
191
|
+
"roles": [{"role": "code-reviewer", "mode": "agent", "assignment": "Challenge the diff", "result": "PASS"}],
|
|
192
|
+
"evaluates": first["record_id"],
|
|
193
|
+
}
|
|
194
|
+
review["issues"][0].update(role="code-reviewer", record=first["record_id"], cause="execution")
|
|
195
|
+
_, judged = self.append(review)
|
|
196
|
+
# The review folder sorts after the implement folder, yet this later implement record names the review's fix.
|
|
197
|
+
repeat = copy.deepcopy(IMPLEMENT)
|
|
198
|
+
repeat["issues"][0].update(cause="ineffective_fix", prior_fix=f"{judged['record_id']}#FIX-01")
|
|
199
|
+
code, second = self.append(repeat)
|
|
200
|
+
self.assertEqual(code, 0, second)
|
|
201
|
+
self.assertEqual(self.run_tool("check")[0], 0)
|
|
202
|
+
# A reference to a record written later than the referrer is still an error.
|
|
203
|
+
path = self.root / judged["path"]
|
|
204
|
+
text = path.read_text(encoding="utf-8")
|
|
205
|
+
later = text.replace('"recorded_at":"20', '"recorded_at":"29', 1)
|
|
206
|
+
self.assertNotEqual(later, text)
|
|
207
|
+
path.write_text(later, encoding="utf-8")
|
|
208
|
+
code, result = self.run_tool("check")
|
|
209
|
+
self.assertNotEqual(code, 0)
|
|
210
|
+
self.assertTrue(any("prior_fix" in error for error in result["errors"]), result)
|
|
211
|
+
|
|
212
|
+
def test_check_orders_records_by_instant_across_utc_offsets(self):
|
|
213
|
+
_, first = self.append(IMPLEMENT)
|
|
214
|
+
repeat = copy.deepcopy(IMPLEMENT)
|
|
215
|
+
repeat["issues"][0].update(cause="ineffective_fix", prior_fix=f"{first['record_id']}#FIX-01")
|
|
216
|
+
self.assertEqual(self.append(repeat)[0], 0)
|
|
217
|
+
path = self.root / first["path"]
|
|
218
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
219
|
+
# 10:00+07:00 is 03:00Z and comes before 04:00Z, although it sorts after "2026-10-14T04" as text.
|
|
220
|
+
stamps = ["2026-10-14T10:00:00+07:00", "2026-10-14T04:00:00+00:00"]
|
|
221
|
+
records = [json.loads(line) for line in lines]
|
|
222
|
+
for record, stamp in zip(records, stamps):
|
|
223
|
+
record["recorded_at"] = stamp
|
|
224
|
+
path.write_text("".join(json.dumps(r, ensure_ascii=False, separators=(",", ":")) + "\n" for r in records), encoding="utf-8")
|
|
225
|
+
self.assertEqual(self.run_tool("check")[0], 0)
|
|
226
|
+
records[0]["recorded_at"], records[1]["recorded_at"] = "2026-10-14T05:00:00+00:00", "2026-10-14T10:00:00+07:00"
|
|
227
|
+
path.write_text("".join(json.dumps(r, ensure_ascii=False, separators=(",", ":")) + "\n" for r in records), encoding="utf-8")
|
|
228
|
+
code, result = self.run_tool("check")
|
|
229
|
+
self.assertNotEqual(code, 0)
|
|
230
|
+
self.assertTrue(any("prior_fix" in error for error in result["errors"]), result)
|
|
231
|
+
|
|
232
|
+
def test_check_reads_stored_v1_lines_and_reports_corruption(self):
|
|
233
|
+
self.assertEqual(self.append(IMPLEMENT)[0], 0)
|
|
234
|
+
self.assertEqual(self.run_tool("check")[0], 0)
|
|
235
|
+
[path] = (self.root / ".agents/evals/records").glob("*/*.jsonl")
|
|
236
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
237
|
+
handle.write("{broken\n")
|
|
238
|
+
code, result = self.run_tool("check")
|
|
239
|
+
self.assertNotEqual(code, 0)
|
|
240
|
+
self.assertTrue(any("invalid JSON" in error for error in result["errors"]))
|
|
241
|
+
self.assertNotEqual(self.append(IMPLEMENT)[0], 0)
|
|
242
|
+
|
|
243
|
+
@unittest.skipUnless(shutil.which("git"), "git is required")
|
|
244
|
+
def test_start_pins_commit_and_changed_framework_files(self):
|
|
245
|
+
self.assertEqual(self.append(IMPLEMENT)[0], 0)
|
|
246
|
+
skill = self.root / ".skills/pirl-plan/SKILL.md"
|
|
247
|
+
skill.parent.mkdir(parents=True)
|
|
248
|
+
skill.write_text("---\nname: plan\n---\n", encoding="utf-8")
|
|
249
|
+
ref = self.run_tool("start")[1]["framework_ref"]
|
|
250
|
+
self.assertEqual(ref["commit"], "none")
|
|
251
|
+
# Skills are framework files; records are local output, not framework content.
|
|
252
|
+
self.assertEqual(sorted(ref["files"]), [CATALOG, ".skills/pirl-plan/SKILL.md", "AGENTS.md"])
|
|
253
|
+
git = ["git", "-C", str(self.root), "-c", "user.email=t@t", "-c", "user.name=t"]
|
|
254
|
+
subprocess.run([*git, "init", "-q"], check=True)
|
|
255
|
+
subprocess.run([*git, "add", "-A"], check=True)
|
|
256
|
+
subprocess.run([*git, "commit", "-qm", "init"], check=True)
|
|
257
|
+
(self.root / "AGENTS.md").write_text("# Start here\nchanged\n", encoding="utf-8")
|
|
258
|
+
ref = self.run_tool("start")[1]["framework_ref"]
|
|
259
|
+
self.assertRegex(ref["commit"], r"^[0-9a-f]{40}$")
|
|
260
|
+
self.assertEqual(list(ref["files"]), ["AGENTS.md"])
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
if __name__ == "__main__":
|
|
264
|
+
unittest.main()
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Check the layer skills as packages (frontmatter, resources, helper scripts) and the links between framework files."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import unittest
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
EVALS = Path(__file__).resolve().parents[1]
|
|
14
|
+
REPO = EVALS.parents[1]
|
|
15
|
+
SKILLS = REPO / ".skills"
|
|
16
|
+
sys.path.insert(0, str(EVALS))
|
|
17
|
+
from layer_record import FRAMEWORK_PATHS, LAYERS, NOT_FRAMEWORK, NOT_FRAMEWORK_TRACKED, RECORDS # noqa: E402
|
|
18
|
+
from validate_layer_artifact import CATALOG, parse_yaml_subset # noqa: E402
|
|
19
|
+
|
|
20
|
+
LINK = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)")
|
|
21
|
+
FENCE = re.compile(r"(?ms)^(`{3,})[^\n]*\n.*?^\1\s*$")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def git(*args: str) -> subprocess.CompletedProcess:
|
|
25
|
+
return subprocess.run(["git", "-C", str(REPO), "-c", "core.quotepath=off", *args], capture_output=True, text=True)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def repository_files(pattern: str = "*") -> list[str]:
|
|
29
|
+
"""Every file Git tracks or would track (ignored outputs excluded), so no hand-kept list decides what is checked."""
|
|
30
|
+
|
|
31
|
+
return sorted(set(git("ls-files", "--cached", "--others", "--exclude-standard", "--", pattern).stdout.splitlines()))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
HAS_GIT = shutil.which("git") is not None and git("rev-parse", "--git-dir").returncode == 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def anchors(path: Path) -> set[str]:
|
|
38
|
+
"""GitHub heading anchors: lowercase, punctuation dropped, spaces turned into hyphens."""
|
|
39
|
+
|
|
40
|
+
text = FENCE.sub("", path.read_text(encoding="utf-8"))
|
|
41
|
+
return {re.sub(r"[^\w\- ]", "", heading.strip().lower()).replace(" ", "-")
|
|
42
|
+
for heading in re.findall(r"(?m)^#{1,6} (.+)$", text)}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SkillPackageTests(unittest.TestCase):
|
|
46
|
+
def test_each_layer_has_a_complete_skill(self):
|
|
47
|
+
for layer in LAYERS:
|
|
48
|
+
skill = SKILLS / f"pirl-{layer}"
|
|
49
|
+
with self.subTest(skill=layer):
|
|
50
|
+
text = (skill / "SKILL.md").read_text(encoding="utf-8")
|
|
51
|
+
header = text.split("---")[1]
|
|
52
|
+
meta = parse_yaml_subset(header, f"{skill.name}/SKILL.md")
|
|
53
|
+
self.assertEqual(meta.get("name"), skill.name)
|
|
54
|
+
self.assertTrue(isinstance(meta.get("description"), str) and 0 < len(meta["description"]) <= 1024)
|
|
55
|
+
self.assertEqual(meta.get("metadata", {}).get("layer"), layer)
|
|
56
|
+
self.assertRegex(str(meta.get("metadata", {}).get("version")), r"^\d+\.\d+\.\d+$")
|
|
57
|
+
self.assertTrue((REPO / f".agents/instructions/{layer}.instructions.md").is_file())
|
|
58
|
+
for folder in ("assets", "references", "scripts"):
|
|
59
|
+
self.assertTrue([p for p in (skill / folder).glob("*") if p.is_file()], f"{skill.name}/{folder} is empty")
|
|
60
|
+
self.assertEqual(set(SKILLS.iterdir()), {SKILLS / f"pirl-{name}" for name in LAYERS})
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@unittest.skipUnless(HAS_GIT, "a git checkout is required")
|
|
64
|
+
class FrameworkLinkTests(unittest.TestCase):
|
|
65
|
+
def test_relative_links_and_anchors_resolve(self):
|
|
66
|
+
for source in (REPO / name for name in repository_files("*.md")):
|
|
67
|
+
text = FENCE.sub("", source.read_text(encoding="utf-8"))
|
|
68
|
+
for target in LINK.findall(text):
|
|
69
|
+
if re.match(r"[a-z]+:", target):
|
|
70
|
+
continue
|
|
71
|
+
file_part, _, anchor = target.partition("#")
|
|
72
|
+
resolved = (source.parent / file_part).resolve() if file_part else source
|
|
73
|
+
with self.subTest(source=source.relative_to(REPO).as_posix(), target=target):
|
|
74
|
+
self.assertTrue(resolved.exists(), "link target does not exist")
|
|
75
|
+
if anchor and resolved.suffix == ".md":
|
|
76
|
+
self.assertIn(anchor, anchors(resolved))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@unittest.skipUnless(HAS_GIT, "a git checkout is required")
|
|
80
|
+
class PrivateOutputTests(unittest.TestCase):
|
|
81
|
+
"""Plan packages, their reports and records describe the user's projects and must stay out of Git.
|
|
82
|
+
|
|
83
|
+
The paths come from the scripts and constants that produce them, so a change in how outputs are named is
|
|
84
|
+
checked against .gitignore instead of a hand-written sample that would keep passing.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def ignored(self, path):
|
|
88
|
+
return git("check-ignore", "--no-index", "-q", path).returncode == 0
|
|
89
|
+
|
|
90
|
+
def test_produced_outputs_are_ignored(self):
|
|
91
|
+
new_plan = SKILLS / "pirl-plan/scripts/new_plan.py"
|
|
92
|
+
new_review = SKILLS / "pirl-review/scripts/new_review.py"
|
|
93
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
94
|
+
root = Path(temporary)
|
|
95
|
+
produced = []
|
|
96
|
+
for extra in ([], ["--out-dir", "plans"]):
|
|
97
|
+
result = subprocess.run([sys.executable, "-X", "utf8", "-B", str(new_plan), "--root", str(root),
|
|
98
|
+
"--request-slug", "order-email", "--todo-slug", "send-email", *extra],
|
|
99
|
+
capture_output=True, text=True, encoding="utf-8")
|
|
100
|
+
plan = json.loads(result.stdout)
|
|
101
|
+
self.assertEqual(result.returncode, 0, plan)
|
|
102
|
+
result = subprocess.run([sys.executable, "-X", "utf8", "-B", str(new_review), "--root", str(root),
|
|
103
|
+
"--plan", plan["path"], "--todo-slug", "send-email", *extra],
|
|
104
|
+
capture_output=True, text=True, encoding="utf-8")
|
|
105
|
+
review = json.loads(result.stdout)
|
|
106
|
+
self.assertEqual(result.returncode, 0, review)
|
|
107
|
+
produced += [plan["path"], review["path"]]
|
|
108
|
+
produced.append(f"{RECORDS}/plan/2026-10-23.jsonl")
|
|
109
|
+
for path in produced:
|
|
110
|
+
with self.subTest(path=path):
|
|
111
|
+
self.assertTrue(self.ignored(path))
|
|
112
|
+
|
|
113
|
+
def test_no_tracked_file_is_ignored(self):
|
|
114
|
+
self.assertEqual(git("ls-files", "--cached", "--ignored", "--exclude-standard").stdout.splitlines(), [])
|
|
115
|
+
|
|
116
|
+
def test_every_tracked_file_is_classified(self):
|
|
117
|
+
def framework(path):
|
|
118
|
+
return any(path == base or path.startswith(base + "/") for base in FRAMEWORK_PATHS)
|
|
119
|
+
|
|
120
|
+
for path in repository_files():
|
|
121
|
+
if any(prefix in path for prefix in NOT_FRAMEWORK):
|
|
122
|
+
continue
|
|
123
|
+
with self.subTest(path=path):
|
|
124
|
+
self.assertTrue(framework(path) or path in NOT_FRAMEWORK_TRACKED,
|
|
125
|
+
"add the path to FRAMEWORK_PATHS or NOT_FRAMEWORK_TRACKED in layer_record.py")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class SkillScriptTests(unittest.TestCase):
|
|
129
|
+
"""Run the helper scripts against a temporary Hub and check their output with the real validator."""
|
|
130
|
+
|
|
131
|
+
def setUp(self):
|
|
132
|
+
temporary = tempfile.TemporaryDirectory()
|
|
133
|
+
self.addCleanup(temporary.cleanup)
|
|
134
|
+
self.root = Path(temporary.name)
|
|
135
|
+
(self.root / CATALOG).parent.mkdir(parents=True)
|
|
136
|
+
shutil.copy(REPO / CATALOG, self.root / CATALOG)
|
|
137
|
+
|
|
138
|
+
def run_script(self, script, *args):
|
|
139
|
+
result = subprocess.run([sys.executable, "-X", "utf8", "-B", str(script), "--root", str(self.root), *args],
|
|
140
|
+
capture_output=True, text=True, encoding="utf-8")
|
|
141
|
+
return result.returncode, json.loads(result.stdout)
|
|
142
|
+
|
|
143
|
+
def validate(self, kind, path):
|
|
144
|
+
result = subprocess.run([sys.executable, "-X", "utf8", "-B", str(EVALS / "validate_layer_artifact.py"),
|
|
145
|
+
"--root", str(self.root), "--kind", kind, "--path", str(path)],
|
|
146
|
+
capture_output=True, text=True, encoding="utf-8")
|
|
147
|
+
return result.returncode, json.loads(result.stdout)
|
|
148
|
+
|
|
149
|
+
def new_plan(self, *args):
|
|
150
|
+
return self.run_script(SKILLS / "pirl-plan/scripts/new_plan.py", *args)
|
|
151
|
+
|
|
152
|
+
def fill_plan(self, path):
|
|
153
|
+
text = path.read_text(encoding="utf-8")
|
|
154
|
+
for placeholder, value in (("[<planning-role>]", "[code-analyst]"), ("[<implementation-role>]", "[backend-developer]"),
|
|
155
|
+
("[<verification-role>]", "[code-reviewer]")):
|
|
156
|
+
text = text.replace(placeholder, value)
|
|
157
|
+
text = re.sub(r'(?m)^target_repo: ".*"$', 'target_repo: "."', text)
|
|
158
|
+
path.write_text(text, encoding="utf-8")
|
|
159
|
+
|
|
160
|
+
def test_plan_package_review_rounds_and_record_pass_the_validator(self):
|
|
161
|
+
code, first = self.new_plan("--request-slug", "order-email", "--todo-slug", "send-email")
|
|
162
|
+
self.assertEqual(code, 0, first)
|
|
163
|
+
self.assertEqual(first["task_id"], "T01")
|
|
164
|
+
self.assertRegex(first["path"], r"^order-email_\d{8}-\d{6}/01-plan-send-email-\d{8}-\d{6}\.md$")
|
|
165
|
+
code, second = self.new_plan("--plan-set", first["plan_set"], "--todo-slug", "log-email")
|
|
166
|
+
self.assertEqual(code, 0, second)
|
|
167
|
+
self.assertEqual(second["task_id"], "T02")
|
|
168
|
+
self.assertTrue(second["path"].endswith(first["plan_set"].split("_")[1] + ".md"))
|
|
169
|
+
self.assertNotEqual(self.new_plan("--request-slug", "Bad Slug", "--todo-slug", "x")[0], 0)
|
|
170
|
+
plan = self.root / first["path"]
|
|
171
|
+
for path in (plan, self.root / second["path"]):
|
|
172
|
+
self.fill_plan(path)
|
|
173
|
+
code, result = self.validate("plan", plan.parent)
|
|
174
|
+
self.assertEqual(code, 0, result)
|
|
175
|
+
|
|
176
|
+
review = SKILLS / "pirl-review/scripts/new_review.py"
|
|
177
|
+
code, round_one = self.run_script(review, "--plan", first["path"], "--todo-slug", "send-email")
|
|
178
|
+
self.assertEqual(code, 0, round_one)
|
|
179
|
+
self.assertEqual((round_one["review_round"], round_one["prior_review"]), (1, None))
|
|
180
|
+
self.assertTrue(round_one["path"].startswith(f"{first['plan_set']}/t01-send-email/round-01-review-send-email-"))
|
|
181
|
+
self.assertEqual(self.validate("review", self.root / round_one["path"])[0], 0)
|
|
182
|
+
code, round_two = self.run_script(review, "--plan", first["path"], "--todo-slug", "send-email")
|
|
183
|
+
self.assertEqual(code, 0, round_two)
|
|
184
|
+
self.assertEqual((round_two["review_round"], round_two["prior_review"]), (2, round_one["path"]))
|
|
185
|
+
code, result = self.validate("review", self.root / round_two["path"])
|
|
186
|
+
self.assertEqual(code, 0, result)
|
|
187
|
+
|
|
188
|
+
record = SKILLS / "pirl-implement/scripts/add_implementation_record.py"
|
|
189
|
+
self.assertEqual(self.run_script(record, "--plan", first["path"])[0], 0)
|
|
190
|
+
self.assertNotEqual(self.run_script(record, "--plan", first["path"])[0], 0)
|
|
191
|
+
text = plan.read_text(encoding="utf-8").replace("status: planned", "status: implemented", 1)
|
|
192
|
+
text = text.replace("- status: awaiting_confirmation | implemented | blocked | failed", "- status: implemented")
|
|
193
|
+
plan.write_text(text, encoding="utf-8")
|
|
194
|
+
code, result = self.validate("plan", plan)
|
|
195
|
+
self.assertEqual(code, 0, result)
|
|
196
|
+
|
|
197
|
+
def test_plan_and_layout_run_outside_the_default_location(self):
|
|
198
|
+
outside = tempfile.TemporaryDirectory()
|
|
199
|
+
self.addCleanup(outside.cleanup)
|
|
200
|
+
code, plan = self.new_plan("--request-slug", "cart", "--todo-slug", "badge", "--out-dir", outside.name)
|
|
201
|
+
self.assertEqual(code, 0, plan)
|
|
202
|
+
self.assertTrue(Path(plan["path"]).is_absolute() and Path(plan["path"]).is_file())
|
|
203
|
+
code, run = self.run_script(SKILLS / "pirl-implement/scripts/new_layout_run.py", "--plan", plan["path"])
|
|
204
|
+
self.assertEqual(code, 0, run)
|
|
205
|
+
self.assertRegex(run["path"], rf"^\.tmp/layout-snapshots/{plan['plan_set']}/T01/\d{{8}}-\d{{6}}$")
|
|
206
|
+
self.assertTrue((self.root / run["path"]).is_dir())
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
if __name__ == "__main__":
|
|
210
|
+
unittest.main()
|