@arbiterforge/ca-pi 0.15.0 → 0.15.2
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/README.md +1 -1
- package/package.json +1 -1
- package/plugins/ca-pi/CHANGELOG.md +18 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-amd64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-arm64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-amd64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-arm64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-amd64.exe +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-arm64.exe +0 -0
- package/plugins/ca-pi/helpers/artifacts/release.json +6 -6
- package/plugins/ca-pi/hooks/_approvallib.py +98 -1
- package/plugins/ca-pi/hooks/_artifactauthoritylib.py +522 -65
- package/plugins/ca-pi/hooks/_host.py +1 -1
- package/plugins/ca-pi/hooks/_sprintapprovallib.py +1 -0
- package/plugins/ca-pi/hooks/hostapi.py +1 -1
- package/plugins/ca-pi/includes/artifacts.md +66 -1
- package/plugins/ca-pi/package.json +1 -1
- package/plugins/ca-pi/routines/writing-plans/SKILL.md +12 -1
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ project context. You decide. codeArbiter enforces.
|
|
|
10
10
|
<img alt="Claude Code plugin" src="https://img.shields.io/badge/Claude_Code-plugin-d97757">
|
|
11
11
|
<img alt="Codex plugin" src="https://img.shields.io/badge/OpenAI_Codex-plugin-10a37f">
|
|
12
12
|
<img alt="Pi Feature Forge preview" src="https://img.shields.io/badge/ca--pi-Feature_Forge_preview-d97757">
|
|
13
|
-
<img alt="version 2.22.
|
|
13
|
+
<img alt="version 2.22.2" src="https://img.shields.io/badge/version-2.22.2-2b7489">
|
|
14
14
|
<img alt="core lanes" src="https://img.shields.io/badge/core_lanes-18-555">
|
|
15
15
|
<img alt="skills" src="https://img.shields.io/badge/skills-23-555">
|
|
16
16
|
<img alt="agents" src="https://img.shields.io/badge/agents-20-555">
|
package/package.json
CHANGED
|
@@ -4,6 +4,24 @@ All notable changes to `ca-pi` are documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.15.2] - 2026-09-26
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Update the shared artifact adapter and guidance for bounded review prompts
|
|
12
|
+
and safe abandonment of never-launched review requests on supported hosts.
|
|
13
|
+
Pi's native verification and review authority remain unavailable.
|
|
14
|
+
- Shared authority request writes reject stale state during concurrent operations.
|
|
15
|
+
|
|
16
|
+
## [0.15.1] - 2026-09-26
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Ship the shared whole-plan verification preflight, exact named-result collectors,
|
|
21
|
+
and pinned native Windows npm runner contract.
|
|
22
|
+
- Reject ineffective snapshot exclusions, prevent explicit roots from bypassing
|
|
23
|
+
exclusions, and retain actionable oversized-input diagnostics.
|
|
24
|
+
|
|
7
25
|
## [0.15.0] - 2026-09-26
|
|
8
26
|
|
|
9
27
|
### Added
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -6,32 +6,32 @@
|
|
|
6
6
|
"binaries": {
|
|
7
7
|
"darwin/amd64": {
|
|
8
8
|
"file": "ca-artifact-darwin-amd64",
|
|
9
|
-
"sha256": "
|
|
9
|
+
"sha256": "3c8d8deb48d85ba1c9a6cfaf1286543cc8193fc8db307fe35446f29e53d3ca7a",
|
|
10
10
|
"native_tested": true
|
|
11
11
|
},
|
|
12
12
|
"darwin/arm64": {
|
|
13
13
|
"file": "ca-artifact-darwin-arm64",
|
|
14
|
-
"sha256": "
|
|
14
|
+
"sha256": "af3a36a6926532c6e0b4da10397aae17a472e300f50d5c0dc7886aa722046d28",
|
|
15
15
|
"native_tested": true
|
|
16
16
|
},
|
|
17
17
|
"linux/amd64": {
|
|
18
18
|
"file": "ca-artifact-linux-amd64",
|
|
19
|
-
"sha256": "
|
|
19
|
+
"sha256": "83300e32dd6514b4716707091d900611f42454097b177443c9c47f464cad5065",
|
|
20
20
|
"native_tested": true
|
|
21
21
|
},
|
|
22
22
|
"linux/arm64": {
|
|
23
23
|
"file": "ca-artifact-linux-arm64",
|
|
24
|
-
"sha256": "
|
|
24
|
+
"sha256": "47f1e529140642c7574f29664bb9adacc2755458a1cd9a30c6fdc0f9df447f24",
|
|
25
25
|
"native_tested": true
|
|
26
26
|
},
|
|
27
27
|
"windows/amd64": {
|
|
28
28
|
"file": "ca-artifact-windows-amd64.exe",
|
|
29
|
-
"sha256": "
|
|
29
|
+
"sha256": "fa1a11489cc4521c945a59d2dce9f270ba9fcf6fcdc220011579326d5e97569a",
|
|
30
30
|
"native_tested": true
|
|
31
31
|
},
|
|
32
32
|
"windows/arm64": {
|
|
33
33
|
"file": "ca-artifact-windows-arm64.exe",
|
|
34
|
-
"sha256": "
|
|
34
|
+
"sha256": "88e0e9c2cf7d5861ff1365d8aa4d9376f6fa1ff8cdf0eb708be78be88af45468",
|
|
35
35
|
"native_tested": true
|
|
36
36
|
}
|
|
37
37
|
}
|
|
@@ -71,7 +71,102 @@ def _ready(client: Any, identity: dict[str, Any]) -> None:
|
|
|
71
71
|
or result.get("model_sha256") != identity["model_sha256"]
|
|
72
72
|
or result.get("normative_sha256") != identity["normative_sha256"]
|
|
73
73
|
):
|
|
74
|
-
|
|
74
|
+
details = "; ".join(
|
|
75
|
+
f"{item.get('code', 'NOT_READY')} {item.get('symbol', '')} {item.get('field', '')}: {item.get('message', '')}"
|
|
76
|
+
for item in result.get("diagnostics", []) if isinstance(item, dict)
|
|
77
|
+
)
|
|
78
|
+
raise ApprovalError("NOT_READY", "only the exact current ready artifact can be armed" + (": " + details if details else ""))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _preflight_plan_verification(client: Any, identity: dict[str, Any]) -> dict[str, Any]:
|
|
82
|
+
"""Inspect the whole engine-owned plan before requesting approval.
|
|
83
|
+
|
|
84
|
+
This private admission check runs no declared command or test discovery.
|
|
85
|
+
Proposed tests may be absent. Existing input roots must already be readable;
|
|
86
|
+
a containing directory records future file creation in the input snapshot.
|
|
87
|
+
Runtime binding, host authorization and fresh evidence remain separate.
|
|
88
|
+
"""
|
|
89
|
+
if identity.get("kind") != "plan":
|
|
90
|
+
raise _artifactlib.ArtifactError("WRONG_KIND", "verification preflight requires a plan")
|
|
91
|
+
keys = ("artifact_id", "revision", "model_sha256", "normative_sha256")
|
|
92
|
+
artifact_id = identity["artifact_id"]
|
|
93
|
+
|
|
94
|
+
def same_identity(value: dict[str, Any]) -> None:
|
|
95
|
+
if not isinstance(value, dict) or any(value.get(key) != identity.get(key) for key in keys):
|
|
96
|
+
raise _artifactlib.ArtifactError("STALE_PLAN_PREFLIGHT", "plan identity changed during verification preflight; reread the complete current plan")
|
|
97
|
+
|
|
98
|
+
same_identity(client.call("identity", {"artifact_id": artifact_id}))
|
|
99
|
+
root = _artifactlib._trusted_directory(client.root, "UNSAFE_ROOT")
|
|
100
|
+
diagnostics: list[str] = []
|
|
101
|
+
seen: set[str] = set()
|
|
102
|
+
command_count = 0
|
|
103
|
+
offset, total = 0, None
|
|
104
|
+
for _ in range(8193):
|
|
105
|
+
request = {"artifact_id": artifact_id, "offset": offset, "budget": 16384}
|
|
106
|
+
if offset:
|
|
107
|
+
request["model_sha256"] = identity["model_sha256"]
|
|
108
|
+
page = client.call("outline", request)
|
|
109
|
+
same_identity(page)
|
|
110
|
+
_artifactlib.ArtifactClient._check_page(page, offset, "records")
|
|
111
|
+
if (page.get("offset") != offset or type(page.get("total")) is not int
|
|
112
|
+
or page["total"] < offset + len(page["records"])
|
|
113
|
+
or total is not None and page["total"] != total):
|
|
114
|
+
raise _artifactlib.ArtifactError("INVALID_RESPONSE", "plan outline count or offset changed")
|
|
115
|
+
total = page["total"]
|
|
116
|
+
for row in page["records"]:
|
|
117
|
+
if (not isinstance(row, dict) or not isinstance(row.get("id"), str)
|
|
118
|
+
or row["id"] in seen or type(row.get("retired")) is not bool):
|
|
119
|
+
raise _artifactlib.ArtifactError("INVALID_RESPONSE", "plan outline contains an invalid or duplicate record")
|
|
120
|
+
seen.add(row["id"])
|
|
121
|
+
if row.get("kind") != "tasks" or row["retired"]:
|
|
122
|
+
continue
|
|
123
|
+
exact = client.call("read", {"artifact_id": artifact_id, "symbol": row["id"], "mode": "exact", "budget": 16384})
|
|
124
|
+
same_identity(exact)
|
|
125
|
+
task = exact.get("record")
|
|
126
|
+
if (not isinstance(task, dict) or task.get("id") != row["id"]
|
|
127
|
+
or not isinstance(task.get("verification"), list) or not task["verification"]):
|
|
128
|
+
raise _artifactlib.ArtifactError("INVALID_RESPONSE", "plan task lacks complete verification definitions")
|
|
129
|
+
for number, definition in enumerate(task["verification"], 1):
|
|
130
|
+
command_count += 1
|
|
131
|
+
try:
|
|
132
|
+
if not isinstance(definition, dict):
|
|
133
|
+
raise _artifactlib.ArtifactError("INVALID_RESPONSE", "verification definition is not an object")
|
|
134
|
+
label = definition.get("cwd")
|
|
135
|
+
cwd = None
|
|
136
|
+
if label in {".", "current worktree", "candidate worktree"}:
|
|
137
|
+
cwd = root
|
|
138
|
+
elif isinstance(label, str):
|
|
139
|
+
relative = Path(label)
|
|
140
|
+
if relative.is_absolute() or ".." in relative.parts:
|
|
141
|
+
raise _artifactlib.ArtifactError("UNSUPPORTED_WORKSPACE", "declared cwd must be a contained repository path or mapped worktree label")
|
|
142
|
+
candidate = root / relative
|
|
143
|
+
if candidate.exists():
|
|
144
|
+
cwd = _artifactlib._trusted_directory(candidate, "UNSUPPORTED_WORKSPACE")
|
|
145
|
+
_artifactauthoritylib.validate_command_definition(definition, cwd=cwd)
|
|
146
|
+
except (_artifactlib.ArtifactError, _artifactauthoritylib.AuthorityError) as exc:
|
|
147
|
+
argv = definition.get("argv") if isinstance(definition, dict) else None
|
|
148
|
+
diagnostics.append(f"{row['id']} verification[{number}] {json.dumps(argv, ensure_ascii=True)}: {exc}")
|
|
149
|
+
next_offset = page["next_offset"]
|
|
150
|
+
expected = offset + len(page["records"])
|
|
151
|
+
if next_offset is None:
|
|
152
|
+
if expected != total:
|
|
153
|
+
raise _artifactlib.ArtifactError("INVALID_RESPONSE", "plan outline ended before every record was inspected")
|
|
154
|
+
break
|
|
155
|
+
if next_offset != expected:
|
|
156
|
+
raise _artifactlib.ArtifactError("INVALID_RESPONSE", "plan outline skipped records")
|
|
157
|
+
offset = next_offset
|
|
158
|
+
else:
|
|
159
|
+
raise _artifactlib.ArtifactError("INVALID_RESPONSE", "plan outline did not terminate")
|
|
160
|
+
try:
|
|
161
|
+
snapshot = client.call("snapshot", {"artifact_id": artifact_id})
|
|
162
|
+
except _artifactlib.ArtifactError as exc:
|
|
163
|
+
diagnostics.append(f"verification_inputs: {exc}")
|
|
164
|
+
snapshot = None
|
|
165
|
+
same_identity(client.call("identity", {"artifact_id": artifact_id}))
|
|
166
|
+
if diagnostics:
|
|
167
|
+
raise _artifactlib.ArtifactError("PLAN_VERIFICATION_UNAVAILABLE", "; ".join(diagnostics))
|
|
168
|
+
return {"artifact_id": artifact_id, "model_sha256": identity["model_sha256"],
|
|
169
|
+
"command_count": command_count, "snapshot": snapshot}
|
|
75
170
|
|
|
76
171
|
|
|
77
172
|
def _safe_directory(root: Path, relative: Path, *, create: bool) -> Path:
|
|
@@ -135,6 +230,8 @@ def arm_user_approval(
|
|
|
135
230
|
root = Path(root).resolve(strict=True)
|
|
136
231
|
identity = _identity(client, artifact_id)
|
|
137
232
|
_ready(client, identity)
|
|
233
|
+
if identity["kind"] == "plan":
|
|
234
|
+
_preflight_plan_verification(client, identity)
|
|
138
235
|
token = token or secrets.token_urlsafe(24)
|
|
139
236
|
if not TOKEN_RE.fullmatch(token):
|
|
140
237
|
raise ApprovalError("INVALID_TOKEN", "approval token has an invalid shape")
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
"""Supervise verification and correlate independent Codex or Claude review evidence.
|
|
4
4
|
|
|
5
5
|
arm_request(...) -> dict
|
|
6
|
+
validate_command_definition(definition, cwd=None) -> str
|
|
6
7
|
run_verification(...) -> dict
|
|
7
8
|
observe_codex_hook(...) -> dict
|
|
8
9
|
observe_claude_hook(...) -> dict
|
|
@@ -25,10 +26,12 @@ than one SubagentStop; only the first is a review result.
|
|
|
25
26
|
|
|
26
27
|
from __future__ import annotations
|
|
27
28
|
|
|
29
|
+
from contextlib import contextmanager
|
|
30
|
+
import errno
|
|
28
31
|
import hashlib
|
|
29
32
|
import json
|
|
30
33
|
import os
|
|
31
|
-
from pathlib import Path
|
|
34
|
+
from pathlib import Path, PureWindowsPath
|
|
32
35
|
import re
|
|
33
36
|
import secrets
|
|
34
37
|
import shlex
|
|
@@ -62,6 +65,9 @@ REQUEST_RE = re.compile(r"[0-9a-f]{64}")
|
|
|
62
65
|
TOKEN_RE = re.compile(r"[A-Za-z0-9_-]{12,128}")
|
|
63
66
|
HOST_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}")
|
|
64
67
|
MAX_STATE = 1 << 20
|
|
68
|
+
# Only terminal abandonment can read a legacy inline-context review this large.
|
|
69
|
+
MAX_RECOVERY_STATE = 2 << 20
|
|
70
|
+
REQUEST_LOCK_WAIT_SECONDS = 5.0
|
|
65
71
|
MAX_OUTPUT = 8 << 20
|
|
66
72
|
MAX_DECISION = 65536
|
|
67
73
|
REGISTRY_PARENT = Path(tempfile.gettempdir())
|
|
@@ -252,27 +258,129 @@ def _spool_root(root: Path) -> Path:
|
|
|
252
258
|
raise AuthorityError("AUTHORITY_BUSY", "resolved git-common authority spool is unavailable") from exc
|
|
253
259
|
|
|
254
260
|
|
|
261
|
+
@contextmanager
|
|
262
|
+
def _request_lock(spool: Path, relative: Path):
|
|
263
|
+
"""Keep one OS-owned lock inode for all writers of this request."""
|
|
264
|
+
path = spool / f"{relative.name}.lock"
|
|
265
|
+
fd = None
|
|
266
|
+
|
|
267
|
+
def check_path() -> None:
|
|
268
|
+
info = path.lstat()
|
|
269
|
+
if (
|
|
270
|
+
stat.S_ISLNK(info.st_mode)
|
|
271
|
+
or getattr(info, "st_file_attributes", 0) & 0x400
|
|
272
|
+
or not stat.S_ISREG(info.st_mode)
|
|
273
|
+
):
|
|
274
|
+
raise OSError("request lock path is unsafe")
|
|
275
|
+
if fd is not None:
|
|
276
|
+
opened = os.fstat(fd)
|
|
277
|
+
if (info.st_dev, info.st_ino) != (opened.st_dev, opened.st_ino):
|
|
278
|
+
raise OSError("request lock path changed")
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
check_path()
|
|
282
|
+
except FileNotFoundError:
|
|
283
|
+
pass
|
|
284
|
+
flags = (
|
|
285
|
+
os.O_RDWR | os.O_CREAT | getattr(os, "O_BINARY", 0)
|
|
286
|
+
| getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
|
|
287
|
+
)
|
|
288
|
+
fd = os.open(path, flags, 0o600)
|
|
289
|
+
locked = False
|
|
290
|
+
try:
|
|
291
|
+
check_path()
|
|
292
|
+
deadline = time.monotonic() + REQUEST_LOCK_WAIT_SECONDS
|
|
293
|
+
if os.name == "nt":
|
|
294
|
+
import msvcrt
|
|
295
|
+
else:
|
|
296
|
+
import fcntl
|
|
297
|
+
while True:
|
|
298
|
+
try:
|
|
299
|
+
if os.name == "nt":
|
|
300
|
+
# Windows permits a byte-range lock beyond EOF. Never seed
|
|
301
|
+
# or truncate the lock file, including on an unsafe path.
|
|
302
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
303
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
304
|
+
else:
|
|
305
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
306
|
+
locked = True
|
|
307
|
+
break
|
|
308
|
+
except OSError as exc:
|
|
309
|
+
if (
|
|
310
|
+
exc.errno not in {errno.EACCES, errno.EAGAIN, errno.EDEADLK, 36}
|
|
311
|
+
and getattr(exc, "winerror", None) not in {32, 33}
|
|
312
|
+
):
|
|
313
|
+
raise
|
|
314
|
+
if time.monotonic() >= deadline:
|
|
315
|
+
raise AuthorityError("AUTHORITY_BUSY", "request transition lock timed out") from exc
|
|
316
|
+
time.sleep(0.01)
|
|
317
|
+
check_path()
|
|
318
|
+
yield
|
|
319
|
+
finally:
|
|
320
|
+
try:
|
|
321
|
+
if locked:
|
|
322
|
+
if os.name == "nt":
|
|
323
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
324
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
325
|
+
else:
|
|
326
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
327
|
+
finally:
|
|
328
|
+
os.close(fd)
|
|
329
|
+
# Do not unlink: a peer may already be waiting on this same inode.
|
|
330
|
+
|
|
331
|
+
|
|
255
332
|
def _save(root: Path, value: dict[str, Any]) -> None:
|
|
256
|
-
|
|
257
|
-
|
|
333
|
+
expected = value.get("integrity_sha256")
|
|
334
|
+
updated = dict(value)
|
|
335
|
+
updated["integrity_sha256"] = _integrity(updated)
|
|
258
336
|
try:
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
337
|
+
spool = _spool_root(root)
|
|
338
|
+
relative = _request_path(value["request_id"])
|
|
339
|
+
with _request_lock(spool, relative):
|
|
340
|
+
try:
|
|
341
|
+
(spool / relative).lstat()
|
|
342
|
+
except FileNotFoundError:
|
|
343
|
+
if expected is not None:
|
|
344
|
+
raise AuthorityError("STALE_AUTHORITY_REQUEST", "loaded request state disappeared")
|
|
345
|
+
else:
|
|
346
|
+
if expected is None:
|
|
347
|
+
raise AuthorityError("STALE_AUTHORITY_REQUEST", "request state already exists")
|
|
348
|
+
legacy_abandonment = (
|
|
349
|
+
value["state"] == "ABANDONED"
|
|
350
|
+
and value["activity"] in REVIEW_ACTIVITIES
|
|
351
|
+
and value.get("recovery") == {
|
|
352
|
+
"disposition": "abandoned", "previous_attempt": None, "rerun_permitted": False,
|
|
353
|
+
}
|
|
354
|
+
)
|
|
355
|
+
current = _load(root, value["request_id"], recover_armed_review=legacy_abandonment)
|
|
356
|
+
if current["integrity_sha256"] != expected:
|
|
357
|
+
raise AuthorityError("STALE_AUTHORITY_REQUEST", "request state changed since it was loaded")
|
|
358
|
+
_atomic_replace(spool, relative, _canonical(updated))
|
|
359
|
+
# A live operation can save several times without loading again.
|
|
360
|
+
value["integrity_sha256"] = updated["integrity_sha256"]
|
|
361
|
+
if value["state"] in TERMINAL_STATES:
|
|
362
|
+
(_registry_root() / f"{value['request_id']}.json").unlink(missing_ok=True)
|
|
262
363
|
except OSError as exc:
|
|
263
364
|
raise AuthorityError("AUTHORITY_BUSY", "request state could not be written") from exc
|
|
264
365
|
|
|
265
366
|
|
|
266
|
-
def _load(
|
|
367
|
+
def _load(
|
|
368
|
+
root: Path, request_id: str, *, recover_armed_review: bool = False
|
|
369
|
+
) -> dict[str, Any]:
|
|
267
370
|
path = _spool_root(root) / _request_path(request_id)
|
|
371
|
+
limit = MAX_RECOVERY_STATE if recover_armed_review else MAX_STATE
|
|
268
372
|
try:
|
|
269
373
|
info = path.lstat()
|
|
270
374
|
reparse = getattr(info, "st_file_attributes", 0) & 0x400
|
|
271
375
|
if stat.S_ISLNK(info.st_mode) or reparse or not stat.S_ISREG(info.st_mode):
|
|
272
376
|
raise OSError("not regular")
|
|
273
|
-
if info.st_size >
|
|
377
|
+
if info.st_size > limit:
|
|
378
|
+
raise OSError("oversized")
|
|
379
|
+
with path.open("rb") as stream:
|
|
380
|
+
raw = stream.read(limit + 1)
|
|
381
|
+
if len(raw) > limit:
|
|
274
382
|
raise OSError("oversized")
|
|
275
|
-
value = json.loads(
|
|
383
|
+
value = json.loads(raw.decode("utf-8"))
|
|
276
384
|
except (OSError, UnicodeError, ValueError) as exc:
|
|
277
385
|
raise AuthorityError("INVALID_AUTHORITY_STATE", "request state is unreadable") from exc
|
|
278
386
|
allowed = {
|
|
@@ -302,6 +410,11 @@ def _load(root: Path, request_id: str) -> dict[str, Any]:
|
|
|
302
410
|
or SHA256_RE.fullmatch(value.get("context_sha256", "")) is None
|
|
303
411
|
):
|
|
304
412
|
raise AuthorityError("INVALID_AUTHORITY_STATE", "request state failed validation")
|
|
413
|
+
if len(raw) > MAX_STATE:
|
|
414
|
+
try:
|
|
415
|
+
_validate_legacy_review_recovery(root, value)
|
|
416
|
+
except (AuthorityError, KeyError, TypeError, ValueError) as exc:
|
|
417
|
+
raise AuthorityError("INVALID_AUTHORITY_STATE", "legacy review recovery binding failed") from exc
|
|
305
418
|
return value
|
|
306
419
|
|
|
307
420
|
|
|
@@ -311,6 +424,27 @@ def _validate_hash(value: Any, field: str) -> str:
|
|
|
311
424
|
return value
|
|
312
425
|
|
|
313
426
|
|
|
427
|
+
def _read_context(root: Path, context_ref: str, context_hash: str) -> dict[str, Any]:
|
|
428
|
+
_validate_hash(context_hash, "context_sha256")
|
|
429
|
+
expected_ref = f".codearbiter/.artifacts/evidence-contexts/{context_hash}.json"
|
|
430
|
+
if context_ref != expected_ref:
|
|
431
|
+
raise AuthorityError("INVALID_EVIDENCE_CONTEXT", "context locator is not content addressed")
|
|
432
|
+
try:
|
|
433
|
+
raw = (root / expected_ref).read_bytes()
|
|
434
|
+
context = json.loads(raw.decode("utf-8"))
|
|
435
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
436
|
+
raise AuthorityError("INVALID_EVIDENCE_CONTEXT", "context bytes are unavailable") from exc
|
|
437
|
+
if not isinstance(context, dict) or _digest(raw) != context_hash or raw != _canonical(context):
|
|
438
|
+
raise AuthorityError("INVALID_EVIDENCE_CONTEXT", "context bytes changed or are not canonical")
|
|
439
|
+
return context
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _require_frozen_context(root: Path, request: dict[str, Any]) -> None:
|
|
443
|
+
context = _read_context(root, request["context_ref"], request["context_sha256"])
|
|
444
|
+
if context != request["context"]:
|
|
445
|
+
raise AuthorityError("INVALID_EVIDENCE_CONTEXT", "frozen context differs from the request")
|
|
446
|
+
|
|
447
|
+
|
|
314
448
|
def _context(
|
|
315
449
|
root: Path, client: Any, artifact_id: str, record_id: str, activity: str
|
|
316
450
|
) -> tuple[dict[str, Any], str, str]:
|
|
@@ -321,16 +455,14 @@ def _context(
|
|
|
321
455
|
raise AuthorityError("INVALID_EVIDENCE_CONTEXT", "engine context result is malformed")
|
|
322
456
|
context_hash = _validate_hash(result.get("context_sha256"), "context_sha256")
|
|
323
457
|
context_ref = result.get("context_ref")
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
if _digest(raw) != context_hash or raw != _canonical(context):
|
|
333
|
-
raise AuthorityError("INVALID_EVIDENCE_CONTEXT", "context bytes changed or are not canonical")
|
|
458
|
+
context = _read_context(root, context_ref, context_hash)
|
|
459
|
+
_validate_context(context, artifact_id, record_id, activity)
|
|
460
|
+
return context, context_ref, context_hash
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _validate_context(
|
|
464
|
+
context: dict[str, Any], artifact_id: str, record_id: str, activity: str
|
|
465
|
+
) -> None:
|
|
334
466
|
subject = context.get("subject") if isinstance(context, dict) else None
|
|
335
467
|
if (
|
|
336
468
|
not isinstance(context, dict)
|
|
@@ -338,7 +470,9 @@ def _context(
|
|
|
338
470
|
or not isinstance(subject, dict)
|
|
339
471
|
or subject.get("artifact_id") != artifact_id
|
|
340
472
|
or subject.get("record_id") != record_id
|
|
341
|
-
or context
|
|
473
|
+
or context.get("activity") != activity
|
|
474
|
+
or not isinstance(artifact_id, str)
|
|
475
|
+
or not isinstance(record_id, str)
|
|
342
476
|
or not ID_RE.fullmatch(artifact_id)
|
|
343
477
|
or not ID_RE.fullmatch(record_id)
|
|
344
478
|
):
|
|
@@ -379,7 +513,6 @@ def _context(
|
|
|
379
513
|
_validate_hash(context.get("base_input_sha256"), "base_input_sha256")
|
|
380
514
|
if not isinstance(context.get("task_hashes"), dict) or not isinstance(context.get("tasks"), list):
|
|
381
515
|
raise AuthorityError("INVALID_EVIDENCE_CONTEXT", "quality-review scope is incomplete")
|
|
382
|
-
return context, context_ref, context_hash
|
|
383
516
|
|
|
384
517
|
|
|
385
518
|
def _review_binding(context: dict[str, Any]) -> tuple[str, list[str]]:
|
|
@@ -402,6 +535,60 @@ def _review_binding(context: dict[str, Any]) -> tuple[str, list[str]]:
|
|
|
402
535
|
return _digest(_canonical(contract)), coverage
|
|
403
536
|
|
|
404
537
|
|
|
538
|
+
def _never_launched_review(request: dict[str, Any]) -> bool:
|
|
539
|
+
return (
|
|
540
|
+
request["state"] == "ARMED"
|
|
541
|
+
and request["activity"] in REVIEW_ACTIVITIES
|
|
542
|
+
and all(field in request and request[field] is None for field in (
|
|
543
|
+
"attempt", "launch", "observation_ref", "observation_sha256", "receipt", "wrapper", "recovery",
|
|
544
|
+
))
|
|
545
|
+
and "payload" not in request
|
|
546
|
+
and "authority_source" not in request
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _validate_legacy_review_recovery(root: Path, request: dict[str, Any]) -> None:
|
|
551
|
+
"""Admit only the old duplicate-context shape, solely to retain and abandon it."""
|
|
552
|
+
if not _never_launched_review(request) or request["repository"] != _repository_identity(root):
|
|
553
|
+
raise AuthorityError("INVALID_AUTHORITY_STATE", "legacy request is not an unlaunched review")
|
|
554
|
+
context = request["context"]
|
|
555
|
+
subject = context["subject"]
|
|
556
|
+
_validate_context(context, subject["artifact_id"], subject["record_id"], request["activity"])
|
|
557
|
+
context_hash = _digest(_canonical(context))
|
|
558
|
+
records = [context["task"]] if request["activity"] == "spec_review" else context["tasks"]
|
|
559
|
+
if any(not isinstance(record, dict) for record in records):
|
|
560
|
+
raise AuthorityError("INVALID_AUTHORITY_STATE", "legacy review records are malformed")
|
|
561
|
+
contract_hash, coverage = _review_binding(context)
|
|
562
|
+
if (
|
|
563
|
+
request["context_sha256"] != context_hash
|
|
564
|
+
or request["context_ref"] != f".codearbiter/.artifacts/evidence-contexts/{context_hash}.json"
|
|
565
|
+
or request.get("review_contract_sha256") != contract_hash
|
|
566
|
+
or request.get("required_coverage") != coverage
|
|
567
|
+
or request.get("command_bindings") != []
|
|
568
|
+
or request.get("workspace_roots") != {}
|
|
569
|
+
):
|
|
570
|
+
raise AuthorityError("INVALID_AUTHORITY_STATE", "legacy review context binding differs")
|
|
571
|
+
# Recovery validates the retained snapshot; it neither recreates absent
|
|
572
|
+
# evidence files nor upgrades the legacy prompt into launchable authority.
|
|
573
|
+
prompt = _dispatch_prompt(request, legacy_inline=True)
|
|
574
|
+
if not isinstance(request.get("launch_envelope"), dict):
|
|
575
|
+
raise AuthorityError("INVALID_AUTHORITY_STATE", "legacy launch envelope is malformed")
|
|
576
|
+
if request.get("host", "codex") == "claude":
|
|
577
|
+
model = request["launch_envelope"].get("model")
|
|
578
|
+
if model not in CLAUDE_REVIEWER_MODELS:
|
|
579
|
+
raise AuthorityError("INVALID_AUTHORITY_STATE", "legacy reviewer model is unsupported")
|
|
580
|
+
envelope = {
|
|
581
|
+
"description": f"codeArbiter {request['activity']} {request['request_id'][:12]}",
|
|
582
|
+
"prompt": prompt, "subagent_type": CLAUDE_REVIEWER, "model": model,
|
|
583
|
+
}
|
|
584
|
+
else:
|
|
585
|
+
envelope = {
|
|
586
|
+
"message": prompt, "task_name": f"authority_{request['request_id'][:12]}", "fork_turns": "none",
|
|
587
|
+
}
|
|
588
|
+
if request.get("dispatch_prompt") != prompt or request.get("launch_envelope") != envelope:
|
|
589
|
+
raise AuthorityError("INVALID_AUTHORITY_STATE", "legacy launch envelope differs")
|
|
590
|
+
|
|
591
|
+
|
|
405
592
|
def _repository_identity(root: Path) -> dict[str, str]:
|
|
406
593
|
info = os.stat(root)
|
|
407
594
|
return {
|
|
@@ -706,21 +893,89 @@ def _bind_commands(
|
|
|
706
893
|
for command in context["commands"]:
|
|
707
894
|
definition = command["definition"]
|
|
708
895
|
cwd, worktree, common = _workspace_for(root, definition["cwd"], workspace_roots)
|
|
896
|
+
collector = validate_command_definition(definition, cwd=cwd)
|
|
709
897
|
executable = _resolve_executable(definition["argv"][0], cwd)
|
|
898
|
+
argv, launch_files = _native_launch(executable, definition["argv"][1:])
|
|
899
|
+
if (_command_name(definition["argv"][0]) in {"node", "node.exe"}
|
|
900
|
+
and len(definition["argv"]) > 1
|
|
901
|
+
and Path(definition["argv"][1]).suffix.casefold() in {".js", ".mjs", ".cjs"}):
|
|
902
|
+
# Match Node's lexical entrypoint normalization and the Go contract;
|
|
903
|
+
# retain this lookup path so linked-parent retargeting stays visible.
|
|
904
|
+
script = Path(os.path.normpath(cwd / definition["argv"][1]))
|
|
905
|
+
try:
|
|
906
|
+
_path_identity(script)
|
|
907
|
+
if not script.is_file():
|
|
908
|
+
raise OSError("runner entrypoint is not a file")
|
|
909
|
+
except OSError as exc:
|
|
910
|
+
raise AuthorityError("UNSUPPORTED_EXECUTABLE", "declared Node runner entrypoint is unavailable") from exc
|
|
911
|
+
launch_files.append(_launch_file(script, "runner-entrypoint"))
|
|
912
|
+
if definition["required_tests"] and _command_name(definition["argv"][0]) in {"npm", "npm.cmd"}:
|
|
913
|
+
_runner, manifest = _npm_runner(definition["argv"], cwd)
|
|
914
|
+
launch_files.append(_launch_file(manifest, "npm-manifest"))
|
|
710
915
|
bindings.append({
|
|
711
916
|
"definition_sha256": command["definition_sha256"],
|
|
712
|
-
"argv":
|
|
917
|
+
"argv": argv,
|
|
918
|
+
"collector_profile": collector,
|
|
919
|
+
"launch_files": launch_files,
|
|
713
920
|
"cwd": str(cwd),
|
|
714
921
|
"cwd_filesystem_id": _path_identity(cwd),
|
|
715
922
|
"workspace_root": str(worktree),
|
|
716
923
|
"workspace_filesystem_id": _path_identity(worktree),
|
|
717
924
|
"git_common_dir": str(common),
|
|
718
925
|
"git_common_filesystem_id": _path_identity(common),
|
|
719
|
-
"executable_sha256": _file_sha256(
|
|
926
|
+
"executable_sha256": _file_sha256(Path(argv[0])),
|
|
720
927
|
})
|
|
721
928
|
return bindings
|
|
722
929
|
|
|
723
930
|
|
|
931
|
+
def _launch_file(path: Path, role: str) -> dict[str, str]:
|
|
932
|
+
return {"path": str(path), "sha256": _file_sha256(path),
|
|
933
|
+
"filesystem_id": _path_identity(path), "role": role}
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def _native_launch(executable: Path, arguments: list[str]) -> tuple[list[str], list[dict[str, str]]]:
|
|
937
|
+
files = [_launch_file(executable, "declared-executable")]
|
|
938
|
+
if executable.suffix.casefold() not in {".cmd", ".bat"}:
|
|
939
|
+
return [str(executable), *arguments], files
|
|
940
|
+
if os.name != "nt" or executable.name.casefold() != "npm.cmd":
|
|
941
|
+
raise AuthorityError("UNSUPPORTED_EXECUTABLE", "batch executables are unsupported; declare a native executable")
|
|
942
|
+
# CreateProcess may send a .cmd file through cmd.exe despite shell=False.
|
|
943
|
+
# Support only npm's standard colocated installation; never execute the shim.
|
|
944
|
+
# This adapter deliberately selects the sibling CLI, not npm.cmd's optional
|
|
945
|
+
# global-prefix redirect. The wrapper, native runtime, and CLI are all pinned.
|
|
946
|
+
standard_wrapper = r''':: Created by npm, please don't edit manually.
|
|
947
|
+
@ECHO OFF
|
|
948
|
+
SETLOCAL
|
|
949
|
+
SET "NODE_EXE=%~dp0\node.exe"
|
|
950
|
+
IF NOT EXIST "%NODE_EXE%" (
|
|
951
|
+
SET "NODE_EXE=node"
|
|
952
|
+
)
|
|
953
|
+
SET "NPM_PREFIX_JS=%~dp0\node_modules\npm\bin\npm-prefix.js"
|
|
954
|
+
SET "NPM_CLI_JS=%~dp0\node_modules\npm\bin\npm-cli.js"
|
|
955
|
+
FOR /F "delims=" %%F IN ('CALL "%NODE_EXE%" "%NPM_PREFIX_JS%"') DO (
|
|
956
|
+
SET "NPM_PREFIX_NPM_CLI_JS=%%F\node_modules\npm\bin\npm-cli.js"
|
|
957
|
+
)
|
|
958
|
+
IF EXIST "%NPM_PREFIX_NPM_CLI_JS%" (
|
|
959
|
+
SET "NPM_CLI_JS=%NPM_PREFIX_NPM_CLI_JS%"
|
|
960
|
+
)
|
|
961
|
+
"%NODE_EXE%" "%NPM_CLI_JS%" %*'''
|
|
962
|
+
try:
|
|
963
|
+
actual = executable.read_text("utf-8")
|
|
964
|
+
except (OSError, UnicodeError) as exc:
|
|
965
|
+
raise AuthorityError("UNSUPPORTED_EXECUTABLE", "npm wrapper is unreadable") from exc
|
|
966
|
+
if [line for line in actual.splitlines() if line] != standard_wrapper.splitlines():
|
|
967
|
+
raise AuthorityError("UNSUPPORTED_EXECUTABLE", "npm wrapper is not a qualified standard npm.cmd; declare node.exe and npm-cli.js explicitly")
|
|
968
|
+
node = executable.parent / "node.exe"
|
|
969
|
+
cli = executable.parent / "node_modules" / "npm" / "bin" / "npm-cli.js"
|
|
970
|
+
if not node.is_file() or not cli.is_file():
|
|
971
|
+
raise AuthorityError("UNSUPPORTED_EXECUTABLE", "npm batch adapter requires sibling node.exe and node_modules/npm/bin/npm-cli.js")
|
|
972
|
+
for path, role in ((node, "node-runtime"), (cli, "npm-cli")):
|
|
973
|
+
files.append(_launch_file(path, role))
|
|
974
|
+
for directory in (cli.parent, cli.parent.parent, cli.parent.parent.parent):
|
|
975
|
+
_path_identity(directory)
|
|
976
|
+
return [str(node), str(cli), *arguments], files
|
|
977
|
+
|
|
978
|
+
|
|
724
979
|
def _workspace_snapshots(bindings: list[dict[str, Any]]) -> list[dict[str, str]]:
|
|
725
980
|
unique: dict[str, dict[str, str]] = {}
|
|
726
981
|
for binding in bindings:
|
|
@@ -734,6 +989,10 @@ def _workspace_snapshots(bindings: list[dict[str, Any]]) -> list[dict[str, str]]
|
|
|
734
989
|
or _file_sha256(Path(binding["argv"][0])) != binding["executable_sha256"]
|
|
735
990
|
):
|
|
736
991
|
raise AuthorityError("WORKSPACE_DRIFT", "workspace or executable identity changed")
|
|
992
|
+
for item in binding.get("launch_files", []):
|
|
993
|
+
path = Path(item["path"])
|
|
994
|
+
if _launch_file(path, item["role"]) != item:
|
|
995
|
+
raise AuthorityError("WORKSPACE_DRIFT", "runner, npm wrapper, or script manifest identity changed")
|
|
737
996
|
status = _git_text(root, "status", "--porcelain=v2", "-z", "--untracked-files=all")
|
|
738
997
|
index = _git_text(root, "ls-files", "--stage", "-z")
|
|
739
998
|
untracked = _git_text(root, "ls-files", "--others", "--exclude-standard", "-z")
|
|
@@ -822,15 +1081,28 @@ def _workspace_snapshots(bindings: list[dict[str, Any]]) -> list[dict[str, str]]
|
|
|
822
1081
|
return [unique[key] for key in sorted(unique)]
|
|
823
1082
|
|
|
824
1083
|
|
|
825
|
-
def _dispatch_prompt(request: dict[str, Any]) -> str:
|
|
1084
|
+
def _dispatch_prompt(request: dict[str, Any], *, legacy_inline: bool = False) -> str:
|
|
826
1085
|
context = request["context"]
|
|
1086
|
+
if legacy_inline:
|
|
1087
|
+
# Used only to validate an oversized historical request for abandonment.
|
|
1088
|
+
target = "Frozen target data follows as canonical JSON:\n" + _canonical(context).decode("ascii") + "\n"
|
|
1089
|
+
else:
|
|
1090
|
+
target = (
|
|
1091
|
+
f"Review activity: {request['activity']}; subject: "
|
|
1092
|
+
f"{context['subject']['artifact_id']}/{context['subject']['record_id']}\n"
|
|
1093
|
+
"Read the frozen context from that repository-relative path before reviewing. "
|
|
1094
|
+
"The adapter verifies its exact digest before launch and publication. Inspect its "
|
|
1095
|
+
"activity, subject, input_sha256, task definitions and criterion coverage against "
|
|
1096
|
+
"these bindings, then inspect the input manifest and referenced target files. "
|
|
1097
|
+
"Do not pass if the evidence is missing, unreadable or inconsistent. The locator "
|
|
1098
|
+
"is a reference to the complete immutable evidence, not replacement authority.\n"
|
|
1099
|
+
)
|
|
827
1100
|
return (
|
|
828
1101
|
f"[CODEARBITER_AUTHORITY_REQUEST:{request['request_id']}]\n"
|
|
829
1102
|
f"Target repository: {request['repository']['path']}\n"
|
|
830
1103
|
f"Frozen context: {request['context_ref']} sha256={request['context_sha256']}\n"
|
|
831
|
-
|
|
832
|
-
+
|
|
833
|
-
"Act as a fresh read-only independent codeArbiter reviewer. Do not edit files or "
|
|
1104
|
+
+ target
|
|
1105
|
+
+ "Act as a fresh read-only independent codeArbiter reviewer. Do not edit files or "
|
|
834
1106
|
"delegate. Review only the frozen target and return exactly one JSON object using "
|
|
835
1107
|
f"format {DECISION_FORMAT}. Bind request_id={request['request_id']}, "
|
|
836
1108
|
f"target_sha256={context['input_sha256']}, "
|
|
@@ -958,12 +1230,14 @@ def _unittest_collector(stdout: bytes, stderr: bytes, required: list[str]) -> li
|
|
|
958
1230
|
text = (stdout + b"\n" + stderr).decode("utf-8", "replace")
|
|
959
1231
|
results = []
|
|
960
1232
|
for name in required:
|
|
961
|
-
pattern = re.compile(r"(?m)^" + re.escape(name) + r"(
|
|
1233
|
+
pattern = re.compile(r"(?m)^" + re.escape(name) + r"(?:[ \t]+\([^\r\n]*\))?[ \t]+\.\.\.[ \t]+([^\r\n]+)\r?$")
|
|
962
1234
|
matches = pattern.findall(text)
|
|
963
1235
|
if not matches:
|
|
964
1236
|
raise AuthorityError("MISSING_TEST_RESULT", f"required test did not pass: {name}")
|
|
965
1237
|
if len(matches) != 1:
|
|
966
1238
|
raise AuthorityError("DUPLICATE_TEST_RESULT", f"required test appeared more than once: {name}")
|
|
1239
|
+
if matches[0].strip() != "ok":
|
|
1240
|
+
raise AuthorityError("MISSING_TEST_RESULT", f"required test did not pass: {name}")
|
|
967
1241
|
results.append({"name": name, "status": "pass"})
|
|
968
1242
|
return results
|
|
969
1243
|
|
|
@@ -973,16 +1247,16 @@ def _named_line_collector(stdout: bytes, stderr: bytes, required: list[str]) ->
|
|
|
973
1247
|
# fall-damage harnesses emit umbrella summaries for some plan aggregates.
|
|
974
1248
|
# Those summaries are not semantic evidence; only exact PASS names qualify.
|
|
975
1249
|
text = (stdout + b"\n" + stderr).decode("utf-8", "replace")
|
|
976
|
-
|
|
1250
|
+
observed: dict[str, list[bool]] = {}
|
|
977
1251
|
for line in text.splitlines():
|
|
978
1252
|
stripped = line.strip()
|
|
979
|
-
if stripped.startswith("PASS: "):
|
|
980
|
-
name = stripped[6:]
|
|
981
|
-
|
|
982
|
-
duplicates = [name for name in required if
|
|
1253
|
+
if stripped.startswith(("PASS: ", "FAIL: ", "SKIP: ")):
|
|
1254
|
+
name = stripped[6:]
|
|
1255
|
+
observed.setdefault(name, []).append(stripped.startswith("PASS: "))
|
|
1256
|
+
duplicates = [name for name in required if len(observed.get(name, [])) > 1]
|
|
983
1257
|
if duplicates:
|
|
984
1258
|
raise AuthorityError("DUPLICATE_TEST_RESULT", "required PASS lines are duplicated: " + ", ".join(duplicates))
|
|
985
|
-
missing = [name for name in required if
|
|
1259
|
+
missing = [name for name in required if observed.get(name) != [True]]
|
|
986
1260
|
if missing:
|
|
987
1261
|
raise AuthorityError(
|
|
988
1262
|
"MISSING_TEST_RESULT", "required explicit PASS lines are absent: " + ", ".join(missing)
|
|
@@ -992,17 +1266,23 @@ def _named_line_collector(stdout: bytes, stderr: bytes, required: list[str]) ->
|
|
|
992
1266
|
|
|
993
1267
|
def _vitest_verbose_collector(stdout: bytes, stderr: bytes, required: list[str]) -> list[dict[str, str]]:
|
|
994
1268
|
text = (stdout + b"\n" + stderr).decode("utf-8", "replace")
|
|
995
|
-
|
|
1269
|
+
text = re.sub(r"\x1b\[[0-9;]*m", "", text)
|
|
1270
|
+
observed: dict[str, list[bool]] = {}
|
|
996
1271
|
for line in text.splitlines():
|
|
997
1272
|
stripped = line.strip()
|
|
998
|
-
if stripped.startswith(("✓ ", "√ ")):
|
|
999
|
-
|
|
1273
|
+
if stripped.startswith(("✓ ", "√ ", "↓ ", "× ", "✗ ", "- ")) and " > " in stripped:
|
|
1274
|
+
# The full rendered identity after the filename retains every suite
|
|
1275
|
+
# and title segment. A short suffix cannot prove the exact test.
|
|
1276
|
+
name = stripped[2:].split(" > ", 1)[1]
|
|
1277
|
+
annotated = re.search(r" \((?:retry|repeat) x\d+\)| \d+ MB heap used| \[[^\r\n]*\]$", name)
|
|
1278
|
+
if annotated:
|
|
1279
|
+
name = name[:annotated.start()]
|
|
1000
1280
|
name = re.sub(r"\s+\d+(?:\.\d+)?m?s$", "", name)
|
|
1001
|
-
|
|
1002
|
-
duplicates = [name for name in required if
|
|
1281
|
+
observed.setdefault(name, []).append(stripped[0] in {"✓", "√"} and not annotated)
|
|
1282
|
+
duplicates = [name for name in required if len(observed.get(name, [])) > 1]
|
|
1003
1283
|
if duplicates:
|
|
1004
1284
|
raise AuthorityError("DUPLICATE_TEST_RESULT", "required Vitest cases are duplicated: " + ", ".join(duplicates))
|
|
1005
|
-
missing = [name for name in required if
|
|
1285
|
+
missing = [name for name in required if observed.get(name) != [True]]
|
|
1006
1286
|
if missing:
|
|
1007
1287
|
raise AuthorityError(
|
|
1008
1288
|
"MISSING_TEST_RESULT", "required exact Vitest case names are absent: " + ", ".join(missing)
|
|
@@ -1010,6 +1290,68 @@ def _vitest_verbose_collector(stdout: bytes, stderr: bytes, required: list[str])
|
|
|
1010
1290
|
return [{"name": name, "status": "pass"} for name in required]
|
|
1011
1291
|
|
|
1012
1292
|
|
|
1293
|
+
def _unique_json_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
1294
|
+
value: dict[str, Any] = {}
|
|
1295
|
+
for key, item in pairs:
|
|
1296
|
+
if key in value:
|
|
1297
|
+
raise ValueError("duplicate JSON key")
|
|
1298
|
+
value[key] = item
|
|
1299
|
+
return value
|
|
1300
|
+
|
|
1301
|
+
|
|
1302
|
+
def _invalid_json_constant(_value: str) -> Any:
|
|
1303
|
+
raise ValueError("nonfinite JSON")
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
def _playwright_json_collector(stdout: bytes, _stderr: bytes, required: list[str]) -> list[dict[str, str]]:
|
|
1307
|
+
# Native JSONReporter reports spec.title separately from suites/projects and
|
|
1308
|
+
# preserves every retry. Never infer a case from a summary or title prefix.
|
|
1309
|
+
try:
|
|
1310
|
+
text = stdout.decode("utf-8")
|
|
1311
|
+
# npm run's two-line command banner precedes the native JSON report.
|
|
1312
|
+
text = re.sub(r"\A\s*>[^\r\n]*\r?\n>[^\r\n]*\r?\n\s*(?=\{)", "", text)
|
|
1313
|
+
report = json.loads(text, object_pairs_hook=_unique_json_pairs,
|
|
1314
|
+
parse_constant=_invalid_json_constant)
|
|
1315
|
+
except (ValueError, UnicodeError, RecursionError) as exc:
|
|
1316
|
+
raise AuthorityError("INVALID_TEST_RESULT", "Playwright requires one complete JSON reporter document") from exc
|
|
1317
|
+
if not isinstance(report, dict) or not isinstance(report.get("suites"), list) or report.get("errors") != []:
|
|
1318
|
+
raise AuthorityError("INVALID_TEST_RESULT", "Playwright report is malformed or contains global errors")
|
|
1319
|
+
observed: dict[str, list[dict[str, Any]]] = {}
|
|
1320
|
+
pending = list(report["suites"])
|
|
1321
|
+
while pending:
|
|
1322
|
+
suite = pending.pop()
|
|
1323
|
+
if not isinstance(suite, dict) or not isinstance(suite.get("specs"), list) or not isinstance(suite.get("suites", []), list):
|
|
1324
|
+
raise AuthorityError("INVALID_TEST_RESULT", "Playwright suite is malformed")
|
|
1325
|
+
pending.extend(suite.get("suites", []))
|
|
1326
|
+
for spec in suite["specs"]:
|
|
1327
|
+
if (not isinstance(spec, dict) or not isinstance(spec.get("title"), str)
|
|
1328
|
+
or not isinstance(spec.get("tests"), list) or not spec["tests"]):
|
|
1329
|
+
raise AuthorityError("INVALID_TEST_RESULT", "Playwright spec is malformed")
|
|
1330
|
+
if spec["title"] in required and spec.get("ok") is not True:
|
|
1331
|
+
raise AuthorityError("INVALID_TEST_RESULT", "required Playwright spec verdict is inconsistent")
|
|
1332
|
+
for test in spec["tests"]:
|
|
1333
|
+
if not isinstance(test, dict):
|
|
1334
|
+
raise AuthorityError("INVALID_TEST_RESULT", "Playwright test is malformed")
|
|
1335
|
+
observed.setdefault(spec["title"], []).append(test)
|
|
1336
|
+
for name in required:
|
|
1337
|
+
tests = observed.get(name, [])
|
|
1338
|
+
if not tests:
|
|
1339
|
+
raise AuthorityError("MISSING_TEST_RESULT", f"required exact Playwright title is absent: {name}")
|
|
1340
|
+
if len(tests) != 1:
|
|
1341
|
+
raise AuthorityError("DUPLICATE_TEST_RESULT", f"required Playwright title is ambiguous: {name}")
|
|
1342
|
+
test = tests[0]
|
|
1343
|
+
results = test.get("results")
|
|
1344
|
+
if (test.get("expectedStatus") != "passed" or test.get("status") != "expected"
|
|
1345
|
+
or not isinstance(results, list) or len(results) != 1):
|
|
1346
|
+
raise AuthorityError("FAILED_TEST_RESULT", f"required Playwright test has no single clean pass: {name}")
|
|
1347
|
+
result = results[0]
|
|
1348
|
+
if (not isinstance(result, dict) or result.get("status") != "passed"
|
|
1349
|
+
or type(result.get("retry")) is not int or result["retry"] != 0
|
|
1350
|
+
or result.get("errors") != [] or result.get("error") is not None):
|
|
1351
|
+
raise AuthorityError("FAILED_TEST_RESULT", f"required Playwright test did not pass without retry or error: {name}")
|
|
1352
|
+
return [{"name": name, "status": "pass"} for name in required]
|
|
1353
|
+
|
|
1354
|
+
|
|
1013
1355
|
def _exit_only_collector(_stdout: bytes, _stderr: bytes, required: list[str]) -> list[dict[str, str]]:
|
|
1014
1356
|
if required:
|
|
1015
1357
|
raise AuthorityError("UNSUPPORTED_COLLECTOR", "exit-only profile cannot attest named tests")
|
|
@@ -1020,26 +1362,135 @@ COLLECTORS: dict[str, Callable[[bytes, bytes, list[str]], list[dict[str, str]]]]
|
|
|
1020
1362
|
"python-unittest-text/0.1.0": _unittest_collector,
|
|
1021
1363
|
"codearbiter-named-lines/0.1.0": _named_line_collector,
|
|
1022
1364
|
"vitest-verbose/0.1.0": _vitest_verbose_collector,
|
|
1365
|
+
"playwright-json/0.1.0": _playwright_json_collector,
|
|
1023
1366
|
"exit-only/0.1.0": _exit_only_collector,
|
|
1024
1367
|
}
|
|
1025
1368
|
|
|
1026
1369
|
|
|
1370
|
+
def _command_name(value: str) -> str:
|
|
1371
|
+
return value.replace("\\", "/").rsplit("/", 1)[-1].casefold()
|
|
1372
|
+
|
|
1373
|
+
|
|
1374
|
+
def _command_tokens(command: str) -> list[str]:
|
|
1375
|
+
lexer = shlex.shlex(command, posix=True)
|
|
1376
|
+
lexer.whitespace_split = True
|
|
1377
|
+
lexer.commenters = ""
|
|
1378
|
+
lexer.escape = "" # Native Windows paths retain backslashes inside both quotes.
|
|
1379
|
+
return list(lexer)
|
|
1380
|
+
|
|
1381
|
+
|
|
1382
|
+
def _npm_runner(argv: list[str], cwd: Path | None) -> tuple[list[str], Path]:
|
|
1383
|
+
message = "declare a direct test runner with an explicit supported reporter; npm scripts must be a single runner command"
|
|
1384
|
+
if cwd is None:
|
|
1385
|
+
raise AuthorityError("UNSUPPORTED_COLLECTOR", "npm runner requires an inspectable package.json; " + message)
|
|
1386
|
+
rest = list(argv[1:])
|
|
1387
|
+
prefix = "."
|
|
1388
|
+
while rest and rest[0].startswith("-"):
|
|
1389
|
+
option = rest.pop(0)
|
|
1390
|
+
if option == "--prefix" and rest:
|
|
1391
|
+
prefix = rest.pop(0)
|
|
1392
|
+
elif option.startswith("--prefix="):
|
|
1393
|
+
prefix = option.split("=", 1)[1]
|
|
1394
|
+
elif option not in {"--silent", "-s"}:
|
|
1395
|
+
raise AuthorityError("UNSUPPORTED_COLLECTOR", "unsupported npm runner option; " + message)
|
|
1396
|
+
if len(rest) >= 2 and rest[0] in {"run", "run-script"}:
|
|
1397
|
+
script_name, forwarded = rest[1], rest[2:]
|
|
1398
|
+
elif rest and rest[0] in {"test", "t", "tst"}:
|
|
1399
|
+
script_name, forwarded = "test", rest[1:]
|
|
1400
|
+
else:
|
|
1401
|
+
raise AuthorityError("UNSUPPORTED_COLLECTOR", "unsupported npm runner shape; " + message)
|
|
1402
|
+
if forwarded:
|
|
1403
|
+
if forwarded[0] != "--":
|
|
1404
|
+
raise AuthorityError("UNSUPPORTED_COLLECTOR", "npm runner arguments must follow --; " + message)
|
|
1405
|
+
forwarded = forwarded[1:]
|
|
1406
|
+
relative = Path(prefix)
|
|
1407
|
+
if (not prefix or relative.anchor or PureWindowsPath(prefix).anchor
|
|
1408
|
+
or ".." in relative.parts or ".." in PureWindowsPath(prefix).parts):
|
|
1409
|
+
raise AuthorityError("UNSUPPORTED_COLLECTOR", "npm --prefix must stay within the declared cwd; " + message)
|
|
1410
|
+
manifest = cwd / relative / "package.json"
|
|
1411
|
+
try:
|
|
1412
|
+
directory = cwd
|
|
1413
|
+
for part in relative.parts:
|
|
1414
|
+
directory /= part
|
|
1415
|
+
_path_identity(directory)
|
|
1416
|
+
manifest.parent.resolve(strict=True).relative_to(cwd.resolve(strict=True))
|
|
1417
|
+
_path_identity(manifest)
|
|
1418
|
+
with manifest.open("rb") as stream:
|
|
1419
|
+
raw = stream.read(MAX_STATE + 1)
|
|
1420
|
+
if len(raw) > MAX_STATE:
|
|
1421
|
+
raise ValueError("oversized manifest")
|
|
1422
|
+
package = json.loads(raw.decode("utf-8"), object_pairs_hook=_unique_json_pairs,
|
|
1423
|
+
parse_constant=_invalid_json_constant)
|
|
1424
|
+
scripts = package["scripts"]
|
|
1425
|
+
script = scripts[script_name]
|
|
1426
|
+
if not isinstance(script, str) or any(marker in script for marker in ("\n", "\r", ";", "&", "|", ">", "<", "`", "$")):
|
|
1427
|
+
raise ValueError("compound script")
|
|
1428
|
+
if scripts.get("pre" + script_name) or scripts.get("post" + script_name):
|
|
1429
|
+
raise ValueError("lifecycle scripts")
|
|
1430
|
+
tokens = _command_tokens(script)
|
|
1431
|
+
if not tokens or _command_name(tokens[0]) in {"npm", "npm.cmd"}:
|
|
1432
|
+
raise ValueError("nested npm script")
|
|
1433
|
+
except (OSError, ValueError, KeyError, TypeError, RecursionError, AuthorityError) as exc:
|
|
1434
|
+
raise AuthorityError("UNSUPPORTED_COLLECTOR", "npm package script is unavailable or compound; " + message) from exc
|
|
1435
|
+
return [*tokens, *forwarded], manifest
|
|
1436
|
+
|
|
1437
|
+
|
|
1438
|
+
def validate_command_definition(definition: dict[str, Any], *, cwd: Path | None = None) -> str:
|
|
1439
|
+
"""Inspect declared runner grammar without executing or discovering tools."""
|
|
1440
|
+
argv, required = definition.get("argv"), definition.get("required_tests")
|
|
1441
|
+
if (not isinstance(argv, list) or not argv or any(not isinstance(item, str) or not item or "\0" in item for item in argv)
|
|
1442
|
+
or not isinstance(required, list) or any(not isinstance(item, str) or not item for item in required)
|
|
1443
|
+
or len(set(required)) != len(required)):
|
|
1444
|
+
raise AuthorityError("UNSUPPORTED_COLLECTOR", "runner argv and required_tests must be nonempty strings without duplicate tests")
|
|
1445
|
+
name = _command_name(argv[0])
|
|
1446
|
+
if name.endswith((".cmd", ".bat")) and name != "npm.cmd":
|
|
1447
|
+
raise AuthorityError("UNSUPPORTED_EXECUTABLE", "batch executables are unsupported; declare a native executable")
|
|
1448
|
+
if required and name in {"npm", "npm.cmd"}:
|
|
1449
|
+
argv, _manifest = _npm_runner(argv, cwd)
|
|
1450
|
+
return _collector_profile(argv, required)
|
|
1451
|
+
|
|
1452
|
+
|
|
1027
1453
|
def _collector_profile(argv: list[str], required: list[str]) -> str:
|
|
1028
1454
|
if not required:
|
|
1029
1455
|
return "exit-only/0.1.0"
|
|
1030
|
-
|
|
1031
|
-
|
|
1456
|
+
name = _command_name(argv[0])
|
|
1457
|
+
arguments = argv[1:]
|
|
1458
|
+
if re.fullmatch(r"(?:python(?:\d+(?:\.\d+)*)?|py)(?:\.exe)?", name) and arguments[:2] == ["-m", "unittest"]:
|
|
1459
|
+
if not any(item in {"-v", "--verbose"} for item in arguments[2:]):
|
|
1460
|
+
raise AuthorityError("UNSUPPORTED_COLLECTOR", "unittest named results require -v or --verbose")
|
|
1032
1461
|
return "python-unittest-text/0.1.0"
|
|
1033
|
-
if
|
|
1034
|
-
|
|
1035
|
-
|
|
1462
|
+
if name in {"node", "node.exe"} and arguments:
|
|
1463
|
+
script = arguments[0].replace("\\", "/")
|
|
1464
|
+
for suffix, runner in (("node_modules/playwright/cli.js", "playwright"),
|
|
1465
|
+
("node_modules/@playwright/test/cli.js", "playwright"),
|
|
1466
|
+
("node_modules/vitest/vitest.mjs", "vitest"),
|
|
1467
|
+
("node_modules/tsx/dist/cli.mjs", "tsx")):
|
|
1468
|
+
if script == suffix or script.endswith("/" + suffix):
|
|
1469
|
+
name, arguments = runner, arguments[1:]
|
|
1470
|
+
break
|
|
1471
|
+
if name in {"playwright", "vitest"}:
|
|
1472
|
+
expected = "json" if name == "playwright" else "verbose"
|
|
1473
|
+
reporters = []
|
|
1474
|
+
for index, value in enumerate(arguments):
|
|
1475
|
+
if value.startswith("--reporter="):
|
|
1476
|
+
reporters.append(value.split("=", 1)[1])
|
|
1477
|
+
elif value == "--reporter":
|
|
1478
|
+
reporters.append(arguments[index + 1] if index + 1 < len(arguments) else "")
|
|
1479
|
+
mode = "test" if name == "playwright" else "run"
|
|
1480
|
+
if (not arguments or arguments[0] != mode or reporters != [expected]
|
|
1481
|
+
or "--" in arguments or "--list" in arguments
|
|
1482
|
+
or any(item.startswith("--outputFile") for item in arguments)):
|
|
1483
|
+
raise AuthorityError("UNSUPPORTED_COLLECTOR", f"declare {name} {mode} with exactly --reporter={expected} and stdout output")
|
|
1484
|
+
return "playwright-json/0.1.0" if name == "playwright" else "vitest-verbose/0.1.0"
|
|
1485
|
+
if name == "tsx" and arguments:
|
|
1036
1486
|
return "codearbiter-named-lines/0.1.0"
|
|
1037
|
-
raise AuthorityError("UNSUPPORTED_COLLECTOR", "declared runner has no qualified collector")
|
|
1487
|
+
raise AuthorityError("UNSUPPORTED_COLLECTOR", "declared runner has no qualified collector; declare a direct supported runner")
|
|
1038
1488
|
|
|
1039
1489
|
|
|
1040
1490
|
def _minimal_environment() -> tuple[dict[str, str], str]:
|
|
1041
1491
|
names = (
|
|
1042
1492
|
"PATH", "PATHEXT", "SystemRoot", "WINDIR", "COMSPEC", "TEMP", "TMP",
|
|
1493
|
+
"ProgramFiles", "ProgramFiles(x86)", "ProgramW6432", "SystemDrive",
|
|
1043
1494
|
"HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "LANG", "LC_ALL",
|
|
1044
1495
|
)
|
|
1045
1496
|
environment = {name: os.environ[name] for name in names if name in os.environ}
|
|
@@ -1064,6 +1515,8 @@ def _run_contained(
|
|
|
1064
1515
|
argv: list[str], *, cwd: str, env: dict[str, str], timeout_seconds: float = 1800.0
|
|
1065
1516
|
) -> subprocess.CompletedProcess:
|
|
1066
1517
|
"""Bound output and descendants with POSIX sessions or a Windows Job Object."""
|
|
1518
|
+
if argv and _command_name(argv[0]).endswith((".cmd", ".bat")):
|
|
1519
|
+
raise AuthorityError("UNSUPPORTED_EXECUTABLE", "batch execution requires a bound native adapter")
|
|
1067
1520
|
if not isinstance(timeout_seconds, (int, float)) or timeout_seconds <= 0:
|
|
1068
1521
|
raise AuthorityError("INVALID_PROCESS_TIMEOUT", "process timeout must be positive")
|
|
1069
1522
|
job = None
|
|
@@ -1339,7 +1792,7 @@ def run_verification(
|
|
|
1339
1792
|
commands = []
|
|
1340
1793
|
for command, binding in zip(request["context"]["commands"], request["command_bindings"]):
|
|
1341
1794
|
definition = command["definition"]
|
|
1342
|
-
collector = COLLECTORS[
|
|
1795
|
+
collector = COLLECTORS[binding["collector_profile"]]
|
|
1343
1796
|
cwd = _resolved_directory(binding["cwd"])
|
|
1344
1797
|
try:
|
|
1345
1798
|
completed = _run_contained(list(binding["argv"]), cwd=str(cwd), env=environment)
|
|
@@ -1384,7 +1837,7 @@ def run_verification(
|
|
|
1384
1837
|
"commands": commands,
|
|
1385
1838
|
}
|
|
1386
1839
|
observation = _closed_observation(
|
|
1387
|
-
request, payload, "declared-command/0.
|
|
1840
|
+
request, payload, "declared-command/0.2.0", request["attempt"],
|
|
1388
1841
|
producer_result,
|
|
1389
1842
|
)
|
|
1390
1843
|
_observation(root, request, observation)
|
|
@@ -1423,37 +1876,34 @@ def _verification_selector(event: dict[str, Any]) -> tuple[str, Path, str] | Non
|
|
|
1423
1876
|
raise AuthorityError("UNSUPPORTED_HOST_SEAM", "exec command is absent")
|
|
1424
1877
|
compound = any(marker in command for marker in ("\n", "\r", ";", "&", "|", ">", "<", "`", "$"))
|
|
1425
1878
|
try:
|
|
1426
|
-
tokens =
|
|
1879
|
+
tokens = _command_tokens(command)
|
|
1427
1880
|
except ValueError as exc:
|
|
1428
1881
|
# An ordinary command with unbalanced quoting is not ours to deny.
|
|
1429
1882
|
if "artifact-authority" not in command.casefold():
|
|
1430
1883
|
return None
|
|
1431
1884
|
raise AuthorityError("UNSUPPORTED_HOST_SEAM", "verifier wrapper command is malformed") from exc
|
|
1432
1885
|
wrapper_start = (
|
|
1433
|
-
len(tokens) >=
|
|
1434
|
-
and
|
|
1886
|
+
len(tokens) >= 2
|
|
1887
|
+
and _command_name(tokens[0]) in {
|
|
1435
1888
|
"python", "python.exe", "python3", "python3.exe", "py", "py.exe",
|
|
1436
1889
|
}
|
|
1437
|
-
and
|
|
1438
|
-
and tokens[2] == "verify"
|
|
1890
|
+
and _command_name(tokens[1]) == "artifact-authority.py"
|
|
1891
|
+
and (len(tokens) == 2 or tokens[2] == "verify")
|
|
1439
1892
|
)
|
|
1440
1893
|
if compound:
|
|
1441
1894
|
if wrapper_start:
|
|
1442
1895
|
raise AuthorityError("UNSUPPORTED_HOST_SEAM", "verifier wrapper command is compound")
|
|
1443
1896
|
return None
|
|
1444
|
-
if
|
|
1897
|
+
if not wrapper_start:
|
|
1445
1898
|
return None
|
|
1446
1899
|
if (
|
|
1447
|
-
|
|
1448
|
-
"python", "python.exe", "python3", "python3.exe", "py", "py.exe",
|
|
1449
|
-
}
|
|
1450
|
-
or Path(tokens[1]).name.lower() != "artifact-authority.py"
|
|
1900
|
+
len(tokens) != 7
|
|
1451
1901
|
or tokens[2] != "verify"
|
|
1452
1902
|
or tokens[3] != "--root"
|
|
1453
1903
|
or tokens[5] != "--request-id"
|
|
1454
1904
|
or REQUEST_RE.fullmatch(tokens[6]) is None
|
|
1455
1905
|
):
|
|
1456
|
-
|
|
1906
|
+
raise AuthorityError("UNSUPPORTED_HOST_SEAM", "verifier wrapper selector is malformed")
|
|
1457
1907
|
return tokens[6], _real_root(tokens[4]), tokens[1]
|
|
1458
1908
|
|
|
1459
1909
|
|
|
@@ -1506,9 +1956,6 @@ def observe_verifier_hook(
|
|
|
1506
1956
|
current_bindings = _bind_commands(root, request["context"], request["workspace_roots"])
|
|
1507
1957
|
if current_bindings != request["command_bindings"]:
|
|
1508
1958
|
raise AuthorityError("WORKSPACE_DRIFT", "effective child argv or cwd changed")
|
|
1509
|
-
for command in request["context"]["commands"]:
|
|
1510
|
-
definition = command["definition"]
|
|
1511
|
-
_collector_profile(definition["argv"], definition["required_tests"])
|
|
1512
1959
|
workspace_before = _workspace_snapshots(current_bindings)
|
|
1513
1960
|
request["wrapper"] = {
|
|
1514
1961
|
"state": "AUTHORIZED", "session_id": session_id,
|
|
@@ -1664,6 +2111,7 @@ def observe_codex_hook(root: str | Path, event: dict[str, Any]) -> dict[str, Any
|
|
|
1664
2111
|
raise AuthorityError("UNSUPPORTED_HOST_SEAM", "review request is not launchable")
|
|
1665
2112
|
if tool_input != request.get("launch_envelope"):
|
|
1666
2113
|
raise AuthorityError("UNSUPPORTED_HOST_SEAM", "review launch envelope was changed")
|
|
2114
|
+
_require_frozen_context(root, request)
|
|
1667
2115
|
if _ordinary_spawn_marker(session_id, turn_id).exists():
|
|
1668
2116
|
request["state"] = "REJECTED"
|
|
1669
2117
|
request["recovery"] = {
|
|
@@ -1868,6 +2316,7 @@ def observe_claude_hook(root: str | Path, event: dict[str, Any]) -> dict[str, An
|
|
|
1868
2316
|
if tool_input != request.get("launch_envelope"):
|
|
1869
2317
|
_reject(root, request, "changed-launch-envelope")
|
|
1870
2318
|
raise AuthorityError("UNSUPPORTED_HOST_SEAM", "review launch envelope was changed")
|
|
2319
|
+
_require_frozen_context(root, request)
|
|
1871
2320
|
try:
|
|
1872
2321
|
_refuse_shadowed_reviewer(root, session_root)
|
|
1873
2322
|
except AuthorityError:
|
|
@@ -2069,6 +2518,10 @@ def publish_request(
|
|
|
2069
2518
|
wrapper = request.get("wrapper")
|
|
2070
2519
|
if not isinstance(wrapper, dict) or wrapper.get("state") != "CORROBORATED":
|
|
2071
2520
|
raise AuthorityError("AUTHORITY_NOT_COMPLETE", "verifier wrapper completion is uncorroborated")
|
|
2521
|
+
else:
|
|
2522
|
+
# Check the original reference before the engine can recreate missing
|
|
2523
|
+
# derived bytes while computing the current context.
|
|
2524
|
+
_require_frozen_context(root, request)
|
|
2072
2525
|
current, current_ref, current_hash = _context(
|
|
2073
2526
|
root, client, request["context"]["subject"]["artifact_id"],
|
|
2074
2527
|
request["context"]["subject"]["record_id"],
|
|
@@ -2106,11 +2559,11 @@ def publish_request(
|
|
|
2106
2559
|
|
|
2107
2560
|
|
|
2108
2561
|
def recover_request(root: str | Path, request_id: str, disposition: str) -> dict[str, Any]:
|
|
2109
|
-
"""Retain an uncertain attempt
|
|
2562
|
+
"""Retain an uncertain attempt or abandon a review that never launched."""
|
|
2110
2563
|
root = _real_root(root)
|
|
2111
2564
|
if disposition not in {"failed", "abandoned"}:
|
|
2112
2565
|
raise AuthorityError("INVALID_RECOVERY", "recovery disposition must be failed or abandoned")
|
|
2113
|
-
request = _load(root, request_id)
|
|
2566
|
+
request = _load(root, request_id, recover_armed_review=disposition == "abandoned")
|
|
2114
2567
|
recoverable_completed = (
|
|
2115
2568
|
request["state"] == "COMPLETED"
|
|
2116
2569
|
and request["activity"] == "verification"
|
|
@@ -2123,7 +2576,11 @@ def recover_request(root: str | Path, request_id: str, disposition: str) -> dict
|
|
|
2123
2576
|
and isinstance(request.get("wrapper"), dict)
|
|
2124
2577
|
and request["wrapper"].get("state") in {"AUTHORIZED", "FAILED"}
|
|
2125
2578
|
)
|
|
2126
|
-
|
|
2579
|
+
abandonable_review = disposition == "abandoned" and _never_launched_review(request)
|
|
2580
|
+
if (
|
|
2581
|
+
request["state"] not in {"RUNNING", "LAUNCHING"}
|
|
2582
|
+
and not recoverable_completed and not recoverable_authorized and not abandonable_review
|
|
2583
|
+
):
|
|
2127
2584
|
raise AuthorityError("INVALID_RECOVERY", "only an interrupted active attempt can be recovered")
|
|
2128
2585
|
request["state"] = disposition.upper()
|
|
2129
2586
|
request["recovery"] = {
|
|
@@ -11,7 +11,7 @@ import hostapi # noqa: E402
|
|
|
11
11
|
class PiHost(hostapi.Host):
|
|
12
12
|
name = "pi"
|
|
13
13
|
adapter_name = "@arbiterforge/ca-pi"
|
|
14
|
-
adapter_version = "0.15.
|
|
14
|
+
adapter_version = "0.15.2"
|
|
15
15
|
update_target = "ca-pi"
|
|
16
16
|
update_tag_prefix = "ca-pi-v"
|
|
17
17
|
update_command = "pi update npm:@arbiterforge/ca-pi"
|
|
@@ -121,6 +121,7 @@ def arm(root: str | Path, client: Any, spec_id: str, plan_id: str, *, delegate_m
|
|
|
121
121
|
reply = f"approve-sprint {spec_id} {plan_id} {mode} {token}"
|
|
122
122
|
ph = hashlib.sha256(reply.encode("utf-8")).hexdigest()
|
|
123
123
|
context = client.call("sprint-approval-context", {"artifact_id": plan_id, "spec_id": spec_id, "delegate_methods": delegate_methods, "prompt_sha256": ph})
|
|
124
|
+
approval._preflight_plan_verification(client, context["pair"]["plan"])
|
|
124
125
|
pending = {"format": FORMAT, "artifact_id": plan_id, "kind": "sprint-pair", "pair": context["pair"], "spec_context": context["spec_context"], "plan_context": context["plan_context"], "prompt_sha256": ph, "token_sha256": hashlib.sha256(token.encode("ascii")).hexdigest()}
|
|
125
126
|
pending["request_key"] = _hash({"pair": pending["pair"], "prompt_sha256": ph})
|
|
126
127
|
validate_pending(pending)
|
|
@@ -313,7 +313,7 @@ class Host:
|
|
|
313
313
|
|
|
314
314
|
name = "claude"
|
|
315
315
|
adapter_name = "ca"
|
|
316
|
-
adapter_version = "2.22.
|
|
316
|
+
adapter_version = "2.22.2"
|
|
317
317
|
|
|
318
318
|
# Update-notifier descriptor. Each independently versioned host overrides
|
|
319
319
|
# these three values in its per-plugin _host.py. Keeping the target,
|
|
@@ -72,6 +72,30 @@ ADR compatibility review, open-question handling, harvest and user/SMARTS gates.
|
|
|
72
72
|
One criterion may need multiple tests. Never invent a condition, oracle, source,
|
|
73
73
|
command, result, approval or reviewer to satisfy a required field.
|
|
74
74
|
|
|
75
|
+
Before requesting review or approval of a plan, use the private
|
|
76
|
+
`_approvallib._preflight_plan_verification(client, identity)` with its current engine identity.
|
|
77
|
+
It checks all task verification definitions through complete bounded engine reads
|
|
78
|
+
and collects the current source-input snapshot, reporting every unsupported
|
|
79
|
+
command and any input-policy failure together. Both ordinary and initial paired
|
|
80
|
+
approval arming enforce this check. No declared command, test discovery, or test
|
|
81
|
+
module is executed or imported. Proposed test files and names may be absent;
|
|
82
|
+
the installed collector still has to support the proposed runner's output format.
|
|
83
|
+
Missing package-script definitions cannot establish a runner contract: declare a
|
|
84
|
+
supported direct runner or supply the reviewed script definition first. Runtime
|
|
85
|
+
executable/workspace binding and fresh named-result evidence remain required.
|
|
86
|
+
|
|
87
|
+
`verification_inputs.roots` and `exclude_directories` use exact slash-separated
|
|
88
|
+
repository-relative paths. Exclusions are directory paths, not globs or basename
|
|
89
|
+
matches and not relative to an individual root. For root `site`, a generated
|
|
90
|
+
dependency exclusion is `site/node_modules`, not `node_modules`. Review each
|
|
91
|
+
excluded output directory explicitly; `.gitignore` does not define this policy.
|
|
92
|
+
An exclusion outside all selected roots is rejected as ineffective. Overlapping
|
|
93
|
+
roots cannot reinclude an excluded directory. Roots must exist before execution;
|
|
94
|
+
use an existing containing directory for a planned new file so its creation
|
|
95
|
+
changes the snapshot. An oversized included file is reported with its path and
|
|
96
|
+
the 32 MiB per-file limit; revise the explicit policy rather than increasing the
|
|
97
|
+
limit or excluding source/test/governance inputs needed for the task.
|
|
98
|
+
|
|
75
99
|
The existing user, SMARTS, reviewer, or verification boundary must first persist
|
|
76
100
|
its actual policy-owned workflow event as canonical JSON in the reserved
|
|
77
101
|
content-addressed authority-source store. Interactive prompt approval currently
|
|
@@ -230,9 +254,37 @@ workspace or executable drift. If an attempt is interrupted, use the adapter's
|
|
|
230
254
|
`recover` operation to retain it as failed or abandoned; never rerun an uncertain
|
|
231
255
|
attempt under the same request.
|
|
232
256
|
|
|
257
|
+
Named Playwright commands require `test --reporter=json`; each required name must
|
|
258
|
+
equal a unique native `spec.title`. Named Vitest commands require
|
|
259
|
+
`run --reporter=verbose`; use the complete rendered `suite > case` identity after
|
|
260
|
+
the filename, not a leaf-name alias. Named unittest commands require `-v` or
|
|
261
|
+
`--verbose`. A passing row cannot conceal a duplicate, skip, failure or retry.
|
|
262
|
+
Plain exit checks cannot attest named tests.
|
|
263
|
+
|
|
264
|
+
Named npm commands support inspectable single-runner scripts, a contained
|
|
265
|
+
repository-relative `--prefix`, and arguments forwarded after `--`. Compound
|
|
266
|
+
scripts and pre/post lifecycle scripts require separately declared direct-runner
|
|
267
|
+
commands; retain every check from the original suite when splitting commands.
|
|
268
|
+
On Windows, only the qualified standard `npm.cmd` layout is adapted: the runner
|
|
269
|
+
pins the wrapper, sibling `node.exe`, sibling `node_modules/npm/bin/npm-cli.js`,
|
|
270
|
+
and script manifest. It deliberately selects that sibling CLI rather than the
|
|
271
|
+
shim's optional global-prefix redirection. Unknown batch wrappers fail closed.
|
|
272
|
+
Direct Node script entrypoints are also pinned. These launch-file bindings do
|
|
273
|
+
not establish transitive dependency closure for deliberately excluded inputs.
|
|
274
|
+
|
|
275
|
+
New observations use the closed `declared-command/0.2.0` producer profile to
|
|
276
|
+
retain collector and launch-file identities. Historical `0.1.0` bindings remain
|
|
277
|
+
separate and cannot be relabelled as qualified launch evidence.
|
|
278
|
+
|
|
233
279
|
For `spec_review` or `quality_review`, arm the corresponding activity without
|
|
234
280
|
workspace mappings and dispatch the returned `launch_envelope` unchanged to one
|
|
235
|
-
fresh host subagent. The
|
|
281
|
+
fresh host subagent. The bounded prompt names the retained, content-addressed
|
|
282
|
+
evidence context instead of embedding its full input manifest. The reviewer
|
|
283
|
+
must read that context and inspect its subject, coverage and source inputs.
|
|
284
|
+
The adapter verifies the context's canonical bytes and digest before launch and
|
|
285
|
+
publication; a missing or changed context blocks authority. Never shorten or
|
|
286
|
+
rewrite the returned envelope yourself.
|
|
287
|
+
The host hooks bind the exact launch call, child start and
|
|
236
288
|
the child's first stop; a pasted or coordinator-authored decision is not review
|
|
237
289
|
authority.
|
|
238
290
|
Publish the completed request through the same `publish` command. `task-review`
|
|
@@ -241,6 +293,19 @@ the scope reaches `REVIEW`, reverify against one current source snapshot, run th
|
|
|
241
293
|
combined quality review, and use `accept-scope` once. Do not accept tasks
|
|
242
294
|
individually.
|
|
243
295
|
|
|
296
|
+
An ARMED review that never launched can be abandoned through the adapter:
|
|
297
|
+
|
|
298
|
+
```sh
|
|
299
|
+
python "<plugin-root>/hooks/artifact-authority.py" recover --root "<project-root>" --request-id <request-id> --disposition abandoned
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
The adapter requires no attempt, launch, observation, receipt or prior recovery.
|
|
303
|
+
It retains the request and its evidence, creates no receipt, and prohibits
|
|
304
|
+
dispatching that request again. This also covers bounded legacy requests whose
|
|
305
|
+
inline manifests made them too large for normal dispatch. Launched or captured
|
|
306
|
+
requests remain subject to their existing recovery rules. Arm a new request
|
|
307
|
+
only after the retained request is terminal.
|
|
308
|
+
|
|
244
309
|
An interrupted or `BLOCKED` HTML task never resumes by editing its status. Arm
|
|
245
310
|
the installed reconciliation adapter for the exact task or scope, present its
|
|
246
311
|
returned `short_reply` (`reconcile <STATE> <code>` for a task, `reconcile <code>`
|
|
@@ -118,7 +118,18 @@ returned same-slug `.codearbiter/plans/<slug>.html` target may be created. Creat
|
|
|
118
118
|
that plan through the installed structured-artifact engine as `draft_preview`,
|
|
119
119
|
using the source spec's exact artifact ID and normative digest. For ordinary sequential planning,
|
|
120
120
|
this must be the approved spec's exact artifact ID and normative digest. Populate it only with typed operations
|
|
121
|
-
and validate it at the ready gate.
|
|
121
|
+
and validate it at the ready gate. Before presenting either ordinary or combined review, call
|
|
122
|
+
the private `_approvallib._preflight_plan_verification(client, identity)` with the current engine identity.
|
|
123
|
+
It reads every task through bounded engine pages, checks each declared runner's supported
|
|
124
|
+
named-outcome format, snapshots current inputs, and rejects identity drift. Resolve the complete
|
|
125
|
+
reported set of command/input diagnostics before asking for approval. It does not run commands,
|
|
126
|
+
discover or import tests, or require proposed test files and test names to exist. Input roots must
|
|
127
|
+
already exist: choose an existing containing directory for files the plan will create. Roots and
|
|
128
|
+
excluded directories are exact repository-relative paths, not basenames, globs, or paths relative
|
|
129
|
+
to each root; for root `site`, use `site/node_modules`, `site/dist`, and other reviewed output paths.
|
|
130
|
+
The ordinary and paired approval adapters repeat this preflight before arming their user reply.
|
|
131
|
+
This admission check is not verification evidence; execution still binds actual commands and
|
|
132
|
+
requires current named outcomes. For initial combined review, retain `draft_preview` and return
|
|
122
133
|
both ready identities to the caller: `sprint-approve` owns the binding and both approvals after one
|
|
123
134
|
actual host-observed reply. Do not separately `plan-bind` or approve that pair. On ordinary approved-spec
|
|
124
135
|
planning, use `plan-bind` to bind it to the approved spec before plan approval. This path must not create `.codearbiter/plans/<slug>.md`;
|