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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [1.0.1] - 2026-10-25
|
|
8
|
+
|
|
9
|
+
Documentation and tests only: the CLI and the bundled framework (fw-1.1.0) are the same as in 1.0.0.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- A [user guide](docs/user-guide.md) with the details of using PIRL: commands, keyboard shortcuts, `@`
|
|
14
|
+
attachments, how a run works, usage limits, settings, framework updates and where data is stored.
|
|
15
|
+
- Uninstallation steps in the README.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- The README is now in English and introduces the product: highlights, requirements, installation, quick start,
|
|
20
|
+
documentation links, uninstallation and development.
|
|
21
|
+
- The changelog is now in English.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- The test suite no longer times out on slower Windows computers, where it stopped `npm test` and so
|
|
26
|
+
`npm publish`.
|
|
27
|
+
|
|
28
|
+
## [1.0.0] - 2026-10-25
|
|
29
|
+
|
|
30
|
+
First release, published on npm as `pirl-framework-cli`. Provider: Claude Code 2.1.281 or later. Bundled framework:
|
|
31
|
+
fw-1.1.0.
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- A terminal UI started with `pirl` in a Git repository, which becomes the target repository.
|
|
36
|
+
- `/pirl-plan`, `/pirl-implement` and `/pirl-review` run a PIRL layer through your Claude Code. Each role runs as
|
|
37
|
+
its own subagent, with permissions set per layer.
|
|
38
|
+
- Text typed without a `/` is a question, a standalone task or framework maintenance. When it asks for a layer,
|
|
39
|
+
the run suggests the matching command instead of running it.
|
|
40
|
+
- Questions during a run: pick an answer with the arrow keys, type its number, or write your own.
|
|
41
|
+
- `/resume` lists the runs of the repository that can continue and resumes the one you choose, even after the
|
|
42
|
+
TUI was closed.
|
|
43
|
+
- After each run, PIRL runs the framework's validator and checks the self-evaluation record. After Implement, it
|
|
44
|
+
checks that the changed files are within the Plan's allowed paths.
|
|
45
|
+
- Framework improvements made by a run are tested with the framework's own tests before PIRL offers to commit
|
|
46
|
+
them to the Hub; an improvement that fails the tests cannot be committed.
|
|
47
|
+
- `/framework`:
|
|
48
|
+
- update to the framework bundled with the CLI through a draft, with a three-way merge;
|
|
49
|
+
- resolve conflicts by keeping your version, taking the new one, letting the agent merge them, or editing by
|
|
50
|
+
hand;
|
|
51
|
+
- run the framework's tests before applying an update;
|
|
52
|
+
- undo the last update;
|
|
53
|
+
- view differences from the original, restore single files, and browse the history of improvements.
|
|
54
|
+
- `/plans` and `/records`: open a Plan, a review report or a record to see its details.
|
|
55
|
+
- Attach files and folders with `@`: paths of the repository are suggested as you type. Small text files are sent
|
|
56
|
+
with their content; large files and folders are sent as paths or file lists. On Windows, network (UNC) and
|
|
57
|
+
device paths are never read, except on the share that holds the repository or the home directory.
|
|
58
|
+
- Run progress: a spinner, a clock and what each role is doing; the full log of each run in `~/.pirl/logs/`.
|
|
59
|
+
- Token usage by kind (cache reads, cache writes, output, new input), including the roles', with its equivalent at
|
|
60
|
+
API list prices.
|
|
61
|
+
- 5-hour and weekly usage limits in the side panel, with percentage bars and reset times.
|
|
62
|
+
- Mouse wheel scrolling of the chat and panels, while dragging still selects text as usual.
|
|
63
|
+
- `/settings`: coordinator model (Sonnet by default), role model, reasoning effort for every role, parallel roles,
|
|
64
|
+
color scheme, light or dark background, glyphs, motion and mouse mode.
|
|
65
|
+
- `/doctor`, `/copy`, `/clear`, `/help` and `/exit`; paste with Ctrl+V; new lines with Shift+Enter, Ctrl+J or `\`
|
|
66
|
+
then Enter.
|
|
67
|
+
- A responsive layout from 40 columns, and support for Vietnamese input methods.
|
|
68
|
+
|
|
69
|
+
### Framework fw-1.1.0
|
|
70
|
+
|
|
71
|
+
- The details of self-evaluation moved to `self-evaluation.instructions.md`, with a JSON template for records. No
|
|
72
|
+
rule changed.
|
|
73
|
+
- Plans, reports and records use the computer's time zone instead of a fixed UTC+7.
|
|
74
|
+
- The validator reads quoted frontmatter strings literally. Before, a Windows path in `target_repo`
|
|
75
|
+
(`"C:\Users\..."`) was rejected, and the `\n` in `"D:\new-app"` became a line break.
|
|
76
|
+
|
|
77
|
+
[1.0.1]: https://github.com/tdmadc107/PIRL-CLI/releases/tag/v1.0.1
|
|
78
|
+
[1.0.0]: https://github.com/tdmadc107/PIRL-CLI/releases/tag/v1.0.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tdmadc107
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,89 @@
|
|
|
1
|
-
#
|
|
1
|
+
# PIRL CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/pirl-framework-cli)
|
|
4
|
+
[](https://github.com/tdmadc107/PIRL-CLI/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
**Plan, implement and review code changes with a team of AI agents, from your terminal.**
|
|
8
|
+
|
|
9
|
+
PIRL CLI runs the [PIRL framework](pirl-framework/README.md) through the AI agent CLI you already use. Describe
|
|
10
|
+
the work, and PIRL turns it into a Plan, implements it and reviews the result, with specialist agent roles at
|
|
11
|
+
each step and the framework's own checks after each run.
|
|
12
|
+
|
|
13
|
+
PIRL CLI has no login of its own: it uses your agent CLI under your subscription and never reads, stores or
|
|
14
|
+
forwards its credentials.
|
|
15
|
+
|
|
16
|
+
## Highlights
|
|
17
|
+
|
|
18
|
+
- **Plan → Implement → Review** as three commands, each handled by specialist agent roles.
|
|
19
|
+
- **Checked results**: every run is validated by the framework's tools before you see it as done.
|
|
20
|
+
- **Your repository stays yours**: runs never commit, push or switch branches.
|
|
21
|
+
- **A framework that improves itself**, with every change tested and left to your approval.
|
|
22
|
+
|
|
23
|
+
## Requirements
|
|
24
|
+
|
|
25
|
+
- Node.js 22 or later
|
|
26
|
+
- Git
|
|
27
|
+
- Python 3.9 or later
|
|
28
|
+
- [Claude Code](https://code.claude.com/docs) 2.1.281 or later, installed and signed in
|
|
29
|
+
|
|
30
|
+
Support for Codex CLI, Gemini CLI and Copilot CLI is planned.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npm install -g pirl-framework-cli
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Verify the installation:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pirl --version
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quick start
|
|
45
|
+
|
|
46
|
+
Start PIRL in the Git repository you want to work on:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
cd path/to/your-project
|
|
50
|
+
pirl
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Then describe the work as a Plan:
|
|
54
|
+
|
|
55
|
+
```text
|
|
56
|
+
/pirl-plan Send customers an email when their order is cancelled
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
PIRL suggests the next step when the Plan is ready. Type `/help` in PIRL for all commands and shortcuts.
|
|
60
|
+
|
|
61
|
+
## Documentation
|
|
62
|
+
|
|
63
|
+
- [User guide](docs/user-guide.md): commands, shortcuts, attaching files, settings, usage limits and framework
|
|
64
|
+
updates
|
|
65
|
+
- [PIRL framework](pirl-framework/README.md): the layers, roles and self-improvement behind PIRL
|
|
66
|
+
- [Changelog](CHANGELOG.md)
|
|
67
|
+
|
|
68
|
+
## Uninstallation
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
npm uninstall -g pirl-framework-cli
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Your Hub, settings and run history stay in `~/.pirl` (`%USERPROFILE%\.pirl` on Windows). Delete that folder to
|
|
75
|
+
remove them as well.
|
|
76
|
+
|
|
77
|
+
## Development
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
git clone https://github.com/tdmadc107/PIRL-CLI.git
|
|
81
|
+
cd PIRL-CLI
|
|
82
|
+
npm ci
|
|
83
|
+
npm run dev # run from source
|
|
84
|
+
npm test # run the tests
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
[MIT](LICENSE)
|
|
@@ -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())
|