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,214 @@
|
|
|
1
|
+
"""Check the public Plan/Review handoff and the role catalog, not per-line editorial rules."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import unittest
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
EVALS = Path(__file__).resolve().parents[1]
|
|
13
|
+
REPO = EVALS.parents[1]
|
|
14
|
+
sys.path.insert(0, str(EVALS))
|
|
15
|
+
from layer_record import LAYER_USAGES # noqa: E402
|
|
16
|
+
from validate_layer_artifact import CATALOG, _scalar, load_roles # noqa: E402
|
|
17
|
+
|
|
18
|
+
ROLES = """### Role catalog
|
|
19
|
+
|
|
20
|
+
| ID | Expertise | Used in |
|
|
21
|
+
| --- | --- | --- |
|
|
22
|
+
| `business-analyst` | Business logic | plan, review |
|
|
23
|
+
| `code-analyst` | Flow tracing | plan, verify, review |
|
|
24
|
+
| `backend-developer` | Writes source | implement |
|
|
25
|
+
| `code-reviewer` | Challenges the diff | verify, review |
|
|
26
|
+
| `process-auditor` | Self-evaluation | audit |
|
|
27
|
+
|
|
28
|
+
### Other section
|
|
29
|
+
"""
|
|
30
|
+
PLAN = """---
|
|
31
|
+
schema: pirl-plan/v1
|
|
32
|
+
content_language: en
|
|
33
|
+
plan_set: request_20260909-090000
|
|
34
|
+
task_id: T01
|
|
35
|
+
title: Short API task
|
|
36
|
+
layer: implement
|
|
37
|
+
status: planned
|
|
38
|
+
depends_on: []
|
|
39
|
+
created_at: "2026-09-09T09:00:00+07:00"
|
|
40
|
+
target_repo: .
|
|
41
|
+
source_request: Review API DTO ownership
|
|
42
|
+
planning_roles: [code-analyst]
|
|
43
|
+
implementation_roles: [backend-developer]
|
|
44
|
+
verification_roles: [code-reviewer]
|
|
45
|
+
---
|
|
46
|
+
# API task
|
|
47
|
+
## What to do
|
|
48
|
+
API DTO HTTP response contract.
|
|
49
|
+
## How to do it
|
|
50
|
+
Read the input and return the requested result.
|
|
51
|
+
## Participating roles
|
|
52
|
+
Use the roles assigned above.
|
|
53
|
+
## Required quality gates
|
|
54
|
+
The caller receives the requested data.
|
|
55
|
+
"""
|
|
56
|
+
REVIEW = """---
|
|
57
|
+
schema: pirl-review/v1
|
|
58
|
+
content_language: en
|
|
59
|
+
plan_file: "request_20260909-090000/task.md"
|
|
60
|
+
task_id: T01
|
|
61
|
+
review_round: 1
|
|
62
|
+
prior_review: null
|
|
63
|
+
review_roles: [code-analyst, code-reviewer, business-analyst]
|
|
64
|
+
repair_roles: []
|
|
65
|
+
business_verdict: PASS
|
|
66
|
+
verdict: PASS
|
|
67
|
+
status: completed
|
|
68
|
+
reviewed_at: "2026-09-09T10:00:00+07:00"
|
|
69
|
+
---
|
|
70
|
+
## Business
|
|
71
|
+
The selected result satisfies the request.
|
|
72
|
+
## Evaluations
|
|
73
|
+
Roles and supporting sources were reviewed.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class HandoffIOTests(unittest.TestCase):
|
|
78
|
+
def setUp(self):
|
|
79
|
+
temporary = tempfile.TemporaryDirectory()
|
|
80
|
+
self.addCleanup(temporary.cleanup)
|
|
81
|
+
self.root = Path(temporary.name)
|
|
82
|
+
(self.root / CATALOG).parent.mkdir(parents=True)
|
|
83
|
+
(self.root / CATALOG).write_text(ROLES, encoding="utf-8")
|
|
84
|
+
# With no location named by the user, Plan packages and reports sit under the Hub root.
|
|
85
|
+
self.plan = self.root / "request_20260909-090000/task.md"
|
|
86
|
+
self.plan.parent.mkdir(parents=True)
|
|
87
|
+
self.plan.write_text(PLAN, encoding="utf-8")
|
|
88
|
+
|
|
89
|
+
def check(self, kind, path):
|
|
90
|
+
result = subprocess.run(
|
|
91
|
+
[sys.executable, "-X", "utf8", "-B", str(EVALS / "validate_layer_artifact.py"),
|
|
92
|
+
"--root", str(self.root), "--kind", kind, "--path", str(path)],
|
|
93
|
+
capture_output=True, text=True, encoding="utf-8",
|
|
94
|
+
)
|
|
95
|
+
return result.returncode, json.loads(result.stdout)
|
|
96
|
+
|
|
97
|
+
def write_plan(self, text):
|
|
98
|
+
self.plan.write_text(text, encoding="utf-8")
|
|
99
|
+
return self.check("plan", self.plan)
|
|
100
|
+
|
|
101
|
+
def test_plan_and_review_accept_complete_handoffs_without_scoring_prose(self):
|
|
102
|
+
code, result = self.check("plan", self.plan)
|
|
103
|
+
self.assertEqual(code, 0, result)
|
|
104
|
+
report = self.root / "request_20260909-090000/t01-api/round-01.md"
|
|
105
|
+
report.parent.mkdir(parents=True)
|
|
106
|
+
report.write_text(REVIEW, encoding="utf-8")
|
|
107
|
+
code, result = self.check("review", report)
|
|
108
|
+
self.assertEqual(code, 0, result)
|
|
109
|
+
for invalid in (
|
|
110
|
+
REVIEW.replace("task.md", "missing.md"),
|
|
111
|
+
REVIEW.replace("review_round: 1", "review_round: 2"),
|
|
112
|
+
REVIEW.replace("verdict: PASS\nstatus", "verdict: FAIL\nstatus"),
|
|
113
|
+
REVIEW.replace("review_roles: [code-analyst, code-reviewer, business-analyst]", "review_roles: [backend-developer]"),
|
|
114
|
+
REVIEW.replace("repair_roles: []", "repair_roles: [business-analyst]"),
|
|
115
|
+
):
|
|
116
|
+
with self.subTest(invalid=invalid):
|
|
117
|
+
report.write_text(invalid, encoding="utf-8")
|
|
118
|
+
code, result = self.check("review", report)
|
|
119
|
+
self.assertNotEqual(code, 0)
|
|
120
|
+
self.assertFalse(result["passed"])
|
|
121
|
+
|
|
122
|
+
def test_plan_roles_come_from_the_catalog_and_verifier_differs_from_implementation_role(self):
|
|
123
|
+
for invalid, field in (
|
|
124
|
+
(PLAN.replace("verification_roles: [code-reviewer]", "verification_roles: []"), "verification_roles"),
|
|
125
|
+
(PLAN.replace("verification_roles: [code-reviewer]", "verification_roles: [unknown-role]"), "verification_roles"),
|
|
126
|
+
(PLAN.replace("verification_roles: [code-reviewer]", "verification_roles: [business-analyst]"), "verification_roles"),
|
|
127
|
+
(PLAN.replace("implementation_roles: [backend-developer]", "implementation_roles: [code-reviewer]"), "implementation_roles"),
|
|
128
|
+
(PLAN.replace("verification_roles: [code-reviewer]", "verification_roles: [process-auditor]"), "verification_roles"),
|
|
129
|
+
(PLAN.replace("planning_roles: [code-analyst]", "planning_roles: [backend-developer]"), "planning_roles"),
|
|
130
|
+
):
|
|
131
|
+
with self.subTest(field=field, invalid=invalid):
|
|
132
|
+
code, result = self.write_plan(invalid)
|
|
133
|
+
self.assertNotEqual(code, 0)
|
|
134
|
+
self.assertTrue(any(field in error for error in result["errors"]), result)
|
|
135
|
+
|
|
136
|
+
def test_quoted_values_keep_windows_backslashes(self):
|
|
137
|
+
# target_repo is usually an absolute path; on Windows it holds backslashes that are not escapes.
|
|
138
|
+
self.assertEqual(_scalar('"D:\\new-app\\shop"', "plan", 1), "D:\\new-app\\shop")
|
|
139
|
+
self.assertEqual(_scalar('"C:\\Users\\dev"', "plan", 1), "C:\\Users\\dev")
|
|
140
|
+
self.assertEqual(_scalar("'it''s'", "plan", 1), "it's")
|
|
141
|
+
self.assertEqual(_scalar('"say \\"hi\\""', "plan", 1), 'say "hi"')
|
|
142
|
+
|
|
143
|
+
def test_target_repo_must_be_an_existing_directory(self):
|
|
144
|
+
outside = tempfile.TemporaryDirectory()
|
|
145
|
+
self.addCleanup(outside.cleanup)
|
|
146
|
+
(self.root / "shop-web").mkdir()
|
|
147
|
+
for target in ("shop-web", outside.name):
|
|
148
|
+
with self.subTest(target=target):
|
|
149
|
+
self.assertEqual(self.write_plan(PLAN.replace("target_repo: .", f'target_repo: "{target}"'))[0], 0)
|
|
150
|
+
code, result = self.write_plan(PLAN.replace("target_repo: .", 'target_repo: "missing-repo"'))
|
|
151
|
+
self.assertNotEqual(code, 0)
|
|
152
|
+
self.assertTrue(any("target_repo" in error for error in result["errors"]), result)
|
|
153
|
+
|
|
154
|
+
def test_status_beyond_planned_requires_a_matching_implementation_record(self):
|
|
155
|
+
implemented = PLAN.replace("status: planned", "status: implemented")
|
|
156
|
+
self.assertNotEqual(self.write_plan(implemented)[0], 0)
|
|
157
|
+
record = '\n## Implementation Record\n- status: implemented\n- updated_at: "2026-09-09T11:00:00+07:00"\n'
|
|
158
|
+
self.assertEqual(self.write_plan(implemented + record)[0], 0)
|
|
159
|
+
self.assertNotEqual(self.write_plan(implemented + record.replace("status: implemented", "status: failed"))[0], 0)
|
|
160
|
+
|
|
161
|
+
def test_invalid_dependencies_and_metadata_return_errors_for_file_and_package(self):
|
|
162
|
+
for invalid in ("depends_on: [[]]", "depends_on: [T99]", "depends_on: [T01]"):
|
|
163
|
+
self.plan.write_text(PLAN.replace("depends_on: []", invalid), encoding="utf-8")
|
|
164
|
+
for path in (self.plan, self.plan.parent):
|
|
165
|
+
with self.subTest(invalid=invalid, path=path):
|
|
166
|
+
code, result = self.check("plan", path)
|
|
167
|
+
self.assertNotEqual(code, 0)
|
|
168
|
+
self.assertTrue(result["errors"])
|
|
169
|
+
self.assertNotEqual(self.write_plan(PLAN.replace("2026-09-09T09", "2026-99-99T09"))[0], 0)
|
|
170
|
+
|
|
171
|
+
def test_timestamps_accept_any_utc_offset_but_require_one(self):
|
|
172
|
+
for offset in ("+00:00", "-05:00", "+09:30"):
|
|
173
|
+
with self.subTest(offset=offset):
|
|
174
|
+
self.assertEqual(self.write_plan(PLAN.replace("T09:00:00+07:00", f"T09:00:00{offset}"))[0], 0)
|
|
175
|
+
code, result = self.write_plan(PLAN.replace("T09:00:00+07:00", "T09:00:00"))
|
|
176
|
+
self.assertNotEqual(code, 0)
|
|
177
|
+
self.assertTrue(any("UTC offset" in error for error in result["errors"]), result)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class RoleCatalogTests(unittest.TestCase):
|
|
181
|
+
"""Layer role tables must use IDs that the role catalog registers for that layer."""
|
|
182
|
+
|
|
183
|
+
# A table row starts with the role ID, plain or linked to its role file.
|
|
184
|
+
ROW = re.compile(r"(?m)^\| (?:\[`([a-z0-9-]+)`\]\(([^)]+)\)|`([a-z0-9-]+)`) \|")
|
|
185
|
+
|
|
186
|
+
def test_layer_role_tables_match_the_agents_catalog(self):
|
|
187
|
+
catalog = load_roles(REPO)
|
|
188
|
+
for layer, usages in LAYER_USAGES.items():
|
|
189
|
+
path = REPO / f".agents/instructions/{layer}.instructions.md"
|
|
190
|
+
section = re.search(rf"(?ms)^## AI Agent roles at the {layer.capitalize()} layer\s*$(.*?)(?=^## )", path.read_text(encoding="utf-8"))
|
|
191
|
+
self.assertIsNotNone(section, layer)
|
|
192
|
+
rows = self.ROW.findall(section.group(1))
|
|
193
|
+
self.assertTrue(rows, layer)
|
|
194
|
+
for linked, target, plain in rows:
|
|
195
|
+
role = linked or plain
|
|
196
|
+
with self.subTest(layer=layer, role=role):
|
|
197
|
+
self.assertIn(role, catalog)
|
|
198
|
+
self.assertTrue(catalog[role] & usages, f"{role} is not registered for {layer}")
|
|
199
|
+
if target:
|
|
200
|
+
self.assertEqual((path.parent / target).resolve(), (REPO / f".agents/roles/{role}.role.md").resolve())
|
|
201
|
+
|
|
202
|
+
def test_every_catalog_role_has_exactly_one_role_file(self):
|
|
203
|
+
catalog = load_roles(REPO)
|
|
204
|
+
files = {p.name.removesuffix(".role.md"): p for p in (REPO / ".agents/roles").glob("*.role.md")}
|
|
205
|
+
self.assertEqual(set(files), set(catalog))
|
|
206
|
+
for role, path in files.items():
|
|
207
|
+
with self.subTest(role=role):
|
|
208
|
+
header = path.read_text(encoding="utf-8").split("---")[1]
|
|
209
|
+
self.assertIn(f"id: .agents/roles/{role}\n", header)
|
|
210
|
+
self.assertRegex(header, r"(?m)^version: \d+\.\d+\.\d+$")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
if __name__ == "__main__":
|
|
214
|
+
unittest.main()
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Check Plan/Review handoff inputs and references, without grading their prose."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import ast
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
from collections import deque
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path, PureWindowsPath
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
PLAN_SCHEMA = "pirl-plan/v1"
|
|
16
|
+
REVIEW_SCHEMA = "pirl-review/v1"
|
|
17
|
+
PLAN_HEADINGS = ("What to do", "How to do it", "Participating roles", "Required quality gates")
|
|
18
|
+
REVIEW_HEADINGS = ("Business", "Evaluations")
|
|
19
|
+
# Value lists owned here and imported by layer_record.py, so the two tools cannot drift apart.
|
|
20
|
+
IMPLEMENT_STATUSES = ("implemented", "awaiting_confirmation", "blocked", "failed")
|
|
21
|
+
PLAN_STATUSES = ("planned", *IMPLEMENT_STATUSES)
|
|
22
|
+
REVIEW_STATUSES = ("completed", "blocked")
|
|
23
|
+
VERDICTS = ("PASS", "FAIL", "BLOCKED")
|
|
24
|
+
TASK_PATTERN = re.compile(r"T\d{2,}")
|
|
25
|
+
ROLE_USAGES = ("plan", "implement", "verify", "review", "audit")
|
|
26
|
+
CATALOG = ".agents/instructions/standalone-and-maintenance.instructions.md"
|
|
27
|
+
ROLE_SECTION = re.compile(r"(?ms)^### Role catalog\s*$(.*?)(?=^#{1,3} |\Z)")
|
|
28
|
+
ROLE_ROW = re.compile(r"^\|\s*`([a-z0-9]+(?:-[a-z0-9]+)*)`\s*\|(.*)\|\s*$")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class LayerArtifactError(ValueError):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------- constrained YAML subset (frontmatter) ----------
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _scalar(raw: str, source: str, number: int) -> Any:
|
|
39
|
+
value = raw.strip()
|
|
40
|
+
if not value:
|
|
41
|
+
raise LayerArtifactError(f"{source}:{number}: expected a scalar value")
|
|
42
|
+
lowered = value.lower()
|
|
43
|
+
if lowered in ("true", "false"):
|
|
44
|
+
return lowered == "true"
|
|
45
|
+
if lowered in ("null", "~"):
|
|
46
|
+
return None
|
|
47
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
|
|
48
|
+
# A quoted string is taken as written: Windows paths such as "D:\new-app" keep their backslashes.
|
|
49
|
+
# Only an escaped quote or backslash in double quotes, and a doubled quote in single quotes, are decoded.
|
|
50
|
+
inner = value[1:-1]
|
|
51
|
+
if value[0] == "'":
|
|
52
|
+
return inner.replace("''", "'")
|
|
53
|
+
return re.sub(r'\\(["\\])', r"\1", inner)
|
|
54
|
+
if value.startswith(("[", "{", "'", '"')):
|
|
55
|
+
try:
|
|
56
|
+
return ast.literal_eval(value)
|
|
57
|
+
except (SyntaxError, ValueError) as exc:
|
|
58
|
+
if value.startswith("[") and value.endswith("]"):
|
|
59
|
+
inner = value[1:-1].strip()
|
|
60
|
+
parts = [part.strip() for part in inner.split(",")] if inner else []
|
|
61
|
+
if all(parts):
|
|
62
|
+
return [_scalar(part, source, number) for part in parts]
|
|
63
|
+
raise LayerArtifactError(f"{source}:{number}: invalid quoted or inline value: {value}") from exc
|
|
64
|
+
try:
|
|
65
|
+
return int(value)
|
|
66
|
+
except ValueError:
|
|
67
|
+
return value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parse_yaml_subset(text: str, source: str) -> dict[str, Any]:
|
|
71
|
+
"""Parse mappings, scalar lists and inline scalar collections; reject anything else."""
|
|
72
|
+
|
|
73
|
+
tokens: list[tuple[int, str, int]] = []
|
|
74
|
+
for number, raw_line in enumerate(text.splitlines(), 1):
|
|
75
|
+
if not raw_line.strip() or raw_line.lstrip().startswith("#"):
|
|
76
|
+
continue
|
|
77
|
+
lead = raw_line[: len(raw_line) - len(raw_line.lstrip())]
|
|
78
|
+
if "\t" in lead:
|
|
79
|
+
raise LayerArtifactError(f"{source}:{number}: tabs are not valid indentation")
|
|
80
|
+
indent = len(raw_line) - len(raw_line.lstrip(" "))
|
|
81
|
+
if indent % 2:
|
|
82
|
+
raise LayerArtifactError(f"{source}:{number}: indentation must use two spaces")
|
|
83
|
+
tokens.append((indent, raw_line.strip(), number))
|
|
84
|
+
if not tokens:
|
|
85
|
+
return {}
|
|
86
|
+
if tokens[0][0] != 0:
|
|
87
|
+
raise LayerArtifactError(f"{source}:{tokens[0][2]}: top-level key must be unindented")
|
|
88
|
+
|
|
89
|
+
def block(index: int, indent: int) -> tuple[Any, int]:
|
|
90
|
+
if index >= len(tokens) or tokens[index][0] != indent:
|
|
91
|
+
line = tokens[index][2] if index < len(tokens) else len(text.splitlines())
|
|
92
|
+
raise LayerArtifactError(f"{source}:{line}: malformed indentation")
|
|
93
|
+
is_list = tokens[index][1].startswith("- ")
|
|
94
|
+
container: Any = [] if is_list else {}
|
|
95
|
+
while index < len(tokens):
|
|
96
|
+
current, content, number = tokens[index]
|
|
97
|
+
if current < indent:
|
|
98
|
+
break
|
|
99
|
+
if current > indent:
|
|
100
|
+
raise LayerArtifactError(f"{source}:{number}: unexpected indentation")
|
|
101
|
+
if is_list:
|
|
102
|
+
if not content.startswith("- "):
|
|
103
|
+
raise LayerArtifactError(f"{source}:{number}: mixed list and mapping block")
|
|
104
|
+
item = content[2:].strip()
|
|
105
|
+
if not item or (":" in item and not item.startswith(("'", '"'))):
|
|
106
|
+
raise LayerArtifactError(f"{source}:{number}: only scalar list items are supported")
|
|
107
|
+
container.append(_scalar(item, source, number))
|
|
108
|
+
index += 1
|
|
109
|
+
continue
|
|
110
|
+
if content.startswith("- ") or ":" not in content:
|
|
111
|
+
raise LayerArtifactError(f"{source}:{number}: expected a mapping entry")
|
|
112
|
+
key, raw_value = content.split(":", 1)
|
|
113
|
+
key = key.strip()
|
|
114
|
+
if not key:
|
|
115
|
+
raise LayerArtifactError(f"{source}:{number}: mapping key is empty")
|
|
116
|
+
if key in container:
|
|
117
|
+
raise LayerArtifactError(f"{source}:{number}: duplicate key {key!r}")
|
|
118
|
+
raw_value = raw_value.strip()
|
|
119
|
+
index += 1
|
|
120
|
+
if raw_value:
|
|
121
|
+
container[key] = _scalar(raw_value, source, number)
|
|
122
|
+
elif index < len(tokens) and tokens[index][0] > indent:
|
|
123
|
+
if tokens[index][0] != indent + 2:
|
|
124
|
+
raise LayerArtifactError(f"{source}:{tokens[index][2]}: nested blocks must indent two spaces")
|
|
125
|
+
container[key], index = block(index, indent + 2)
|
|
126
|
+
else:
|
|
127
|
+
container[key] = {}
|
|
128
|
+
return container, index
|
|
129
|
+
|
|
130
|
+
parsed, end = block(0, 0)
|
|
131
|
+
if end != len(tokens) or not isinstance(parsed, dict):
|
|
132
|
+
raise LayerArtifactError(f"{source}: root must be a mapping")
|
|
133
|
+
return parsed
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def read_frontmatter(path: Path) -> tuple[dict[str, Any], str]:
|
|
137
|
+
"""Return the parsed frontmatter and the body of a Plan/Review Markdown file."""
|
|
138
|
+
|
|
139
|
+
return _frontmatter_and_body(path)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _frontmatter_and_body(path: Path) -> tuple[dict[str, Any], str]:
|
|
143
|
+
try:
|
|
144
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
145
|
+
if not lines or lines[0].strip() != "---":
|
|
146
|
+
raise LayerArtifactError("missing frontmatter")
|
|
147
|
+
end = next(i for i in range(1, len(lines)) if lines[i].strip() == "---")
|
|
148
|
+
return parse_yaml_subset("\n".join(lines[1:end]), path.as_posix()), "\n".join(lines[end + 1:])
|
|
149
|
+
except (OSError, UnicodeError, StopIteration, RecursionError) as exc:
|
|
150
|
+
raise LayerArtifactError(f"{path}: cannot read metadata: {exc}") from exc
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ---------- role catalog ----------
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def load_roles(root: Path) -> dict[str, set[str]]:
|
|
157
|
+
"""Return role ID -> usages from the role catalog table (CATALOG, section "Role catalog")."""
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
text = (root / CATALOG).read_text(encoding="utf-8")
|
|
161
|
+
except (OSError, UnicodeError) as exc:
|
|
162
|
+
raise LayerArtifactError(f"{CATALOG}: cannot read role catalog: {exc}") from exc
|
|
163
|
+
section = ROLE_SECTION.search(text)
|
|
164
|
+
if section is None:
|
|
165
|
+
raise LayerArtifactError(f"{CATALOG}: missing '### Role catalog' section")
|
|
166
|
+
roles: dict[str, set[str]] = {}
|
|
167
|
+
for line in section.group(1).splitlines():
|
|
168
|
+
row = ROLE_ROW.match(line.strip())
|
|
169
|
+
if row is None:
|
|
170
|
+
continue
|
|
171
|
+
usages = {item.strip() for item in row.group(2).rsplit("|", 1)[-1].split(",") if item.strip()}
|
|
172
|
+
if not usages or not usages <= set(ROLE_USAGES):
|
|
173
|
+
raise LayerArtifactError(f"{CATALOG}: role {row.group(1)} has invalid usage {sorted(usages)}")
|
|
174
|
+
roles[row.group(1)] = usages
|
|
175
|
+
if not roles:
|
|
176
|
+
raise LayerArtifactError(f"{CATALOG}: role catalog has no roles")
|
|
177
|
+
return roles
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def resolve_path(value: Any, root: Path) -> Path:
|
|
181
|
+
"""Resolve a path written in a Plan, report or record: relative to the Hub root, or absolute."""
|
|
182
|
+
|
|
183
|
+
if not isinstance(value, str) or not value.strip():
|
|
184
|
+
raise LayerArtifactError("expected a path relative to the Hub root or an absolute path")
|
|
185
|
+
path = Path(value.strip())
|
|
186
|
+
return (path if path.is_absolute() or PureWindowsPath(value.strip()).drive else root / path).resolve()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ---------- field helpers ----------
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _timestamp(value: Any, label: str, errors: list[str]) -> None:
|
|
193
|
+
try:
|
|
194
|
+
parsed = datetime.fromisoformat(value) if isinstance(value, str) else None
|
|
195
|
+
if parsed is None or parsed.utcoffset() is None:
|
|
196
|
+
raise ValueError("expected ISO-8601 with a UTC offset, for example +07:00")
|
|
197
|
+
except ValueError as exc:
|
|
198
|
+
errors.append(f"{label}: invalid timestamp ({exc})")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _strings(value: Any, label: str, errors: list[str], required: bool = False) -> list[str]:
|
|
202
|
+
if not isinstance(value, list) or not all(isinstance(x, str) and x.strip() for x in value):
|
|
203
|
+
errors.append(f"{label}: expected an array of non-empty strings")
|
|
204
|
+
return []
|
|
205
|
+
if required and not value:
|
|
206
|
+
errors.append(f"{label}: at least one entry is required")
|
|
207
|
+
if len(set(value)) != len(value):
|
|
208
|
+
errors.append(f"{label}: duplicate entries")
|
|
209
|
+
return value
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _roles(
|
|
213
|
+
value: Any,
|
|
214
|
+
label: str,
|
|
215
|
+
usage: str,
|
|
216
|
+
catalog: dict[str, set[str]],
|
|
217
|
+
errors: list[str],
|
|
218
|
+
required: bool,
|
|
219
|
+
) -> list[str]:
|
|
220
|
+
names = _strings(value, label, errors, required)
|
|
221
|
+
for name in names:
|
|
222
|
+
if name not in catalog:
|
|
223
|
+
errors.append(f"{label}: unknown role {name}")
|
|
224
|
+
elif usage not in catalog[name]:
|
|
225
|
+
errors.append(f"{label}: role {name} is not registered for {usage}")
|
|
226
|
+
return names
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _reference_path(value: Any, root: Path, label: str) -> Path:
|
|
230
|
+
path = resolve_path(value, root)
|
|
231
|
+
if not path.is_file():
|
|
232
|
+
raise LayerArtifactError(f"{label}: {value} is not an existing file")
|
|
233
|
+
return path
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _basic(path: Path, root: Path, kind: str) -> tuple[list[str], dict[str, Any], str]:
|
|
237
|
+
errors: list[str] = []
|
|
238
|
+
try:
|
|
239
|
+
if path.suffix != ".md":
|
|
240
|
+
raise LayerArtifactError("expected a Markdown file")
|
|
241
|
+
metadata, body = _frontmatter_and_body(path)
|
|
242
|
+
except LayerArtifactError as exc:
|
|
243
|
+
return [str(exc)], {}, ""
|
|
244
|
+
expected = PLAN_SCHEMA if kind == "plan" else REVIEW_SCHEMA
|
|
245
|
+
if metadata.get("schema") != expected:
|
|
246
|
+
errors.append(f"schema must be {expected}")
|
|
247
|
+
task = metadata.get("task_id")
|
|
248
|
+
if not isinstance(task, str) or not TASK_PATTERN.fullmatch(task):
|
|
249
|
+
errors.append("task_id must identify a task, for example T01")
|
|
250
|
+
if metadata.get("content_language") != "en":
|
|
251
|
+
errors.append("content_language must be en")
|
|
252
|
+
for heading in PLAN_HEADINGS if kind == "plan" else REVIEW_HEADINGS:
|
|
253
|
+
if not re.search(rf"(?m)^## {re.escape(heading)}\s*$", body):
|
|
254
|
+
errors.append(f"missing handoff section: {heading}")
|
|
255
|
+
_timestamp(metadata.get("created_at" if kind == "plan" else "reviewed_at"), "timestamp", errors)
|
|
256
|
+
return errors, metadata, body
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ---------- Plan ----------
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _plan_file(path: Path, root: Path) -> tuple[list[str], dict[str, Any]]:
|
|
263
|
+
errors, metadata, body = _basic(path, root, "plan")
|
|
264
|
+
if not metadata:
|
|
265
|
+
return errors, metadata
|
|
266
|
+
for key in ("plan_set", "title", "target_repo", "source_request"):
|
|
267
|
+
if not isinstance(metadata.get(key), str) or not metadata[key].strip():
|
|
268
|
+
errors.append(f"{key}: non-empty text is required")
|
|
269
|
+
if metadata.get("plan_set") != path.parent.name:
|
|
270
|
+
errors.append("plan_set must identify the containing package")
|
|
271
|
+
if metadata.get("layer") != "implement" or metadata.get("status") not in PLAN_STATUSES:
|
|
272
|
+
errors.append("invalid Plan layer/status")
|
|
273
|
+
# The target repository is any existing directory the user named (AGENTS.md, Target repository).
|
|
274
|
+
target = metadata.get("target_repo")
|
|
275
|
+
if isinstance(target, str) and target.strip() and not resolve_path(target, root).is_dir():
|
|
276
|
+
errors.append(f"target_repo: {target} is not an existing directory (relative to the Hub root or absolute)")
|
|
277
|
+
try:
|
|
278
|
+
catalog = load_roles(root)
|
|
279
|
+
except LayerArtifactError as exc:
|
|
280
|
+
errors.append(str(exc))
|
|
281
|
+
else:
|
|
282
|
+
_roles(metadata.get("planning_roles"), "planning_roles", "plan", catalog, errors, True)
|
|
283
|
+
implementers = _roles(metadata.get("implementation_roles"), "implementation_roles", "implement", catalog, errors, True)
|
|
284
|
+
verifiers = _roles(metadata.get("verification_roles"), "verification_roles", "verify", catalog, errors, True)
|
|
285
|
+
if set(implementers) & set(verifiers):
|
|
286
|
+
errors.append("verification_roles: a role cannot verify its own implementation")
|
|
287
|
+
deps = _strings(metadata.get("depends_on"), "depends_on", errors)
|
|
288
|
+
if any(not TASK_PATTERN.fullmatch(dep) for dep in deps):
|
|
289
|
+
errors.append("depends_on: each entry must be a task ID")
|
|
290
|
+
if metadata.get("status") != "planned":
|
|
291
|
+
record = re.search(r"(?ms)^## Implementation Record\s*\n(.*)", body)
|
|
292
|
+
if record is None:
|
|
293
|
+
errors.append("missing Implementation Record")
|
|
294
|
+
else:
|
|
295
|
+
fields = dict(re.findall(r"(?m)^- ([A-Za-z_]+):\s*(.*)$", record[1]))
|
|
296
|
+
if fields.get("status") != metadata.get("status"):
|
|
297
|
+
errors.append("Implementation Record status differs from Plan status")
|
|
298
|
+
_timestamp(fields.get("updated_at", "").strip('"'), "updated_at", errors)
|
|
299
|
+
return errors, metadata
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _dependencies(records: list[tuple[Path, dict[str, Any]]]) -> list[str]:
|
|
303
|
+
errors: list[str] = []
|
|
304
|
+
tasks: dict[str, list[str]] = {}
|
|
305
|
+
for path, data in records:
|
|
306
|
+
task = data.get("task_id")
|
|
307
|
+
if not isinstance(task, str) or not TASK_PATTERN.fullmatch(task):
|
|
308
|
+
errors.append(f"{path}: invalid task_id")
|
|
309
|
+
continue
|
|
310
|
+
if task in tasks:
|
|
311
|
+
errors.append(f"duplicate task_id: {task}")
|
|
312
|
+
deps = _strings(data.get("depends_on"), f"{path}: depends_on", errors)
|
|
313
|
+
tasks[task] = [dep for dep in deps if TASK_PATTERN.fullmatch(dep)]
|
|
314
|
+
if len(tasks[task]) != len(deps):
|
|
315
|
+
errors.append(f"{path}: invalid dependency ID")
|
|
316
|
+
indegree = {task: 0 for task in tasks}
|
|
317
|
+
children: dict[str, list[str]] = {task: [] for task in tasks}
|
|
318
|
+
for task, deps in tasks.items():
|
|
319
|
+
for dep in deps:
|
|
320
|
+
if dep not in tasks:
|
|
321
|
+
errors.append(f"{task}: dependency {dep} is not in this package")
|
|
322
|
+
else:
|
|
323
|
+
children[dep].append(task)
|
|
324
|
+
indegree[task] += 1
|
|
325
|
+
ready = deque(task for task, count in indegree.items() if count == 0)
|
|
326
|
+
visited = 0
|
|
327
|
+
while ready:
|
|
328
|
+
task = ready.popleft()
|
|
329
|
+
visited += 1
|
|
330
|
+
for child in children[task]:
|
|
331
|
+
indegree[child] -= 1
|
|
332
|
+
if indegree[child] == 0:
|
|
333
|
+
ready.append(child)
|
|
334
|
+
if visited != len(tasks):
|
|
335
|
+
errors.append("dependency cycle in this package")
|
|
336
|
+
return errors
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def validate_plan_path(path: Path, root: Path) -> list[str]:
|
|
340
|
+
path, root = path.resolve(), root.resolve()
|
|
341
|
+
if not path.exists():
|
|
342
|
+
return ["expected an existing Plan file or package directory"]
|
|
343
|
+
package = path.parent if path.is_file() else path
|
|
344
|
+
selected = [path] if path.is_file() else sorted(path.glob("*.md"))
|
|
345
|
+
if not selected:
|
|
346
|
+
return ["Plan package has no Markdown files"]
|
|
347
|
+
errors: list[str] = []
|
|
348
|
+
records = []
|
|
349
|
+
for item in sorted(package.glob("*.md")):
|
|
350
|
+
try:
|
|
351
|
+
records.append((item, _frontmatter_and_body(item)[0]))
|
|
352
|
+
except LayerArtifactError as exc:
|
|
353
|
+
errors.append(str(exc))
|
|
354
|
+
errors.extend(_dependencies(records))
|
|
355
|
+
for item in selected:
|
|
356
|
+
errors.extend(f"{item}: {error}" for error in _plan_file(item, root)[0])
|
|
357
|
+
return errors
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# ---------- Review ----------
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def validate_review_file(path: Path, root: Path) -> list[str]:
|
|
364
|
+
root, path = root.resolve(), path.resolve()
|
|
365
|
+
errors, metadata, _ = _basic(path, root, "review")
|
|
366
|
+
if not metadata:
|
|
367
|
+
return errors
|
|
368
|
+
if metadata.get("status") not in REVIEW_STATUSES:
|
|
369
|
+
errors.append("status must be completed or blocked")
|
|
370
|
+
if metadata.get("business_verdict") not in VERDICTS or metadata.get("verdict") != metadata.get("business_verdict"):
|
|
371
|
+
errors.append("verdict must match business_verdict: PASS, FAIL or BLOCKED")
|
|
372
|
+
round_number = metadata.get("review_round")
|
|
373
|
+
if type(round_number) is not int or round_number < 1:
|
|
374
|
+
errors.append("review_round must be a positive integer")
|
|
375
|
+
try:
|
|
376
|
+
plan = _reference_path(metadata.get("plan_file"), root, "plan_file")
|
|
377
|
+
plan_metadata, _ = _frontmatter_and_body(plan)
|
|
378
|
+
if plan_metadata.get("schema") != PLAN_SCHEMA or plan_metadata.get("task_id") != metadata.get("task_id"):
|
|
379
|
+
errors.append("plan_file does not identify the reviewed Plan/task")
|
|
380
|
+
prior = metadata.get("prior_review")
|
|
381
|
+
if prior is not None:
|
|
382
|
+
previous, _ = _frontmatter_and_body(_reference_path(prior, root, "prior_review"))
|
|
383
|
+
same_plan = resolve_path(previous.get("plan_file"), root) == plan if previous.get("plan_file") else False
|
|
384
|
+
if not same_plan or previous.get("task_id") != metadata.get("task_id"):
|
|
385
|
+
errors.append("prior_review belongs to another Plan/task")
|
|
386
|
+
if type(round_number) is int and previous.get("review_round") != round_number - 1:
|
|
387
|
+
errors.append("prior_review must identify the preceding round")
|
|
388
|
+
elif type(round_number) is int and round_number > 1:
|
|
389
|
+
errors.append("prior_review is required after round 1")
|
|
390
|
+
catalog = load_roles(root)
|
|
391
|
+
_roles(metadata.get("review_roles"), "review_roles", "review", catalog, errors, True)
|
|
392
|
+
_roles(metadata.get("repair_roles"), "repair_roles", "implement", catalog, errors, False)
|
|
393
|
+
except (OSError, UnicodeError, LayerArtifactError, RecursionError) as exc:
|
|
394
|
+
errors.append(str(exc))
|
|
395
|
+
return errors
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def repository_root() -> Path:
|
|
399
|
+
return Path(__file__).resolve().parents[2]
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def main() -> int:
|
|
403
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
404
|
+
parser.add_argument("--kind", choices=("plan", "review"), required=True)
|
|
405
|
+
parser.add_argument("--path", type=Path, required=True)
|
|
406
|
+
parser.add_argument("--root", type=Path, default=repository_root())
|
|
407
|
+
args = parser.parse_args()
|
|
408
|
+
validator = validate_plan_path if args.kind == "plan" else validate_review_file
|
|
409
|
+
errors = validator(args.path, args.root)
|
|
410
|
+
print(json.dumps({"passed": not errors, "kind": args.kind, "path": args.path.as_posix(), "errors": errors}, ensure_ascii=False, indent=2))
|
|
411
|
+
return 0 if not errors else 1
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
if __name__ == "__main__":
|
|
415
|
+
raise SystemExit(main())
|