@arbiterforge/ca-pi 0.15.1 → 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 +9 -0
- package/plugins/ca-pi/hooks/_artifactauthoritylib.py +236 -27
- package/plugins/ca-pi/hooks/_host.py +1 -1
- package/plugins/ca-pi/hooks/hostapi.py +1 -1
- package/plugins/ca-pi/includes/artifacts.md +20 -1
- package/plugins/ca-pi/package.json +1 -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,15 @@ 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
|
+
|
|
7
16
|
## [0.15.1] - 2026-09-26
|
|
8
17
|
|
|
9
18
|
### Fixed
|
|
@@ -26,6 +26,8 @@ than one SubagentStop; only the first is a review result.
|
|
|
26
26
|
|
|
27
27
|
from __future__ import annotations
|
|
28
28
|
|
|
29
|
+
from contextlib import contextmanager
|
|
30
|
+
import errno
|
|
29
31
|
import hashlib
|
|
30
32
|
import json
|
|
31
33
|
import os
|
|
@@ -63,6 +65,9 @@ REQUEST_RE = re.compile(r"[0-9a-f]{64}")
|
|
|
63
65
|
TOKEN_RE = re.compile(r"[A-Za-z0-9_-]{12,128}")
|
|
64
66
|
HOST_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}")
|
|
65
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
|
|
66
71
|
MAX_OUTPUT = 8 << 20
|
|
67
72
|
MAX_DECISION = 65536
|
|
68
73
|
REGISTRY_PARENT = Path(tempfile.gettempdir())
|
|
@@ -253,27 +258,129 @@ def _spool_root(root: Path) -> Path:
|
|
|
253
258
|
raise AuthorityError("AUTHORITY_BUSY", "resolved git-common authority spool is unavailable") from exc
|
|
254
259
|
|
|
255
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
|
+
|
|
256
332
|
def _save(root: Path, value: dict[str, Any]) -> None:
|
|
257
|
-
|
|
258
|
-
|
|
333
|
+
expected = value.get("integrity_sha256")
|
|
334
|
+
updated = dict(value)
|
|
335
|
+
updated["integrity_sha256"] = _integrity(updated)
|
|
259
336
|
try:
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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)
|
|
263
363
|
except OSError as exc:
|
|
264
364
|
raise AuthorityError("AUTHORITY_BUSY", "request state could not be written") from exc
|
|
265
365
|
|
|
266
366
|
|
|
267
|
-
def _load(
|
|
367
|
+
def _load(
|
|
368
|
+
root: Path, request_id: str, *, recover_armed_review: bool = False
|
|
369
|
+
) -> dict[str, Any]:
|
|
268
370
|
path = _spool_root(root) / _request_path(request_id)
|
|
371
|
+
limit = MAX_RECOVERY_STATE if recover_armed_review else MAX_STATE
|
|
269
372
|
try:
|
|
270
373
|
info = path.lstat()
|
|
271
374
|
reparse = getattr(info, "st_file_attributes", 0) & 0x400
|
|
272
375
|
if stat.S_ISLNK(info.st_mode) or reparse or not stat.S_ISREG(info.st_mode):
|
|
273
376
|
raise OSError("not regular")
|
|
274
|
-
if info.st_size >
|
|
377
|
+
if info.st_size > limit:
|
|
275
378
|
raise OSError("oversized")
|
|
276
|
-
|
|
379
|
+
with path.open("rb") as stream:
|
|
380
|
+
raw = stream.read(limit + 1)
|
|
381
|
+
if len(raw) > limit:
|
|
382
|
+
raise OSError("oversized")
|
|
383
|
+
value = json.loads(raw.decode("utf-8"))
|
|
277
384
|
except (OSError, UnicodeError, ValueError) as exc:
|
|
278
385
|
raise AuthorityError("INVALID_AUTHORITY_STATE", "request state is unreadable") from exc
|
|
279
386
|
allowed = {
|
|
@@ -303,6 +410,11 @@ def _load(root: Path, request_id: str) -> dict[str, Any]:
|
|
|
303
410
|
or SHA256_RE.fullmatch(value.get("context_sha256", "")) is None
|
|
304
411
|
):
|
|
305
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
|
|
306
418
|
return value
|
|
307
419
|
|
|
308
420
|
|
|
@@ -312,6 +424,27 @@ def _validate_hash(value: Any, field: str) -> str:
|
|
|
312
424
|
return value
|
|
313
425
|
|
|
314
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
|
+
|
|
315
448
|
def _context(
|
|
316
449
|
root: Path, client: Any, artifact_id: str, record_id: str, activity: str
|
|
317
450
|
) -> tuple[dict[str, Any], str, str]:
|
|
@@ -322,16 +455,14 @@ def _context(
|
|
|
322
455
|
raise AuthorityError("INVALID_EVIDENCE_CONTEXT", "engine context result is malformed")
|
|
323
456
|
context_hash = _validate_hash(result.get("context_sha256"), "context_sha256")
|
|
324
457
|
context_ref = result.get("context_ref")
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
if _digest(raw) != context_hash or raw != _canonical(context):
|
|
334
|
-
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:
|
|
335
466
|
subject = context.get("subject") if isinstance(context, dict) else None
|
|
336
467
|
if (
|
|
337
468
|
not isinstance(context, dict)
|
|
@@ -339,7 +470,9 @@ def _context(
|
|
|
339
470
|
or not isinstance(subject, dict)
|
|
340
471
|
or subject.get("artifact_id") != artifact_id
|
|
341
472
|
or subject.get("record_id") != record_id
|
|
342
|
-
or context
|
|
473
|
+
or context.get("activity") != activity
|
|
474
|
+
or not isinstance(artifact_id, str)
|
|
475
|
+
or not isinstance(record_id, str)
|
|
343
476
|
or not ID_RE.fullmatch(artifact_id)
|
|
344
477
|
or not ID_RE.fullmatch(record_id)
|
|
345
478
|
):
|
|
@@ -380,7 +513,6 @@ def _context(
|
|
|
380
513
|
_validate_hash(context.get("base_input_sha256"), "base_input_sha256")
|
|
381
514
|
if not isinstance(context.get("task_hashes"), dict) or not isinstance(context.get("tasks"), list):
|
|
382
515
|
raise AuthorityError("INVALID_EVIDENCE_CONTEXT", "quality-review scope is incomplete")
|
|
383
|
-
return context, context_ref, context_hash
|
|
384
516
|
|
|
385
517
|
|
|
386
518
|
def _review_binding(context: dict[str, Any]) -> tuple[str, list[str]]:
|
|
@@ -403,6 +535,60 @@ def _review_binding(context: dict[str, Any]) -> tuple[str, list[str]]:
|
|
|
403
535
|
return _digest(_canonical(contract)), coverage
|
|
404
536
|
|
|
405
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
|
+
|
|
406
592
|
def _repository_identity(root: Path) -> dict[str, str]:
|
|
407
593
|
info = os.stat(root)
|
|
408
594
|
return {
|
|
@@ -895,15 +1081,28 @@ def _workspace_snapshots(bindings: list[dict[str, Any]]) -> list[dict[str, str]]
|
|
|
895
1081
|
return [unique[key] for key in sorted(unique)]
|
|
896
1082
|
|
|
897
1083
|
|
|
898
|
-
def _dispatch_prompt(request: dict[str, Any]) -> str:
|
|
1084
|
+
def _dispatch_prompt(request: dict[str, Any], *, legacy_inline: bool = False) -> str:
|
|
899
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
|
+
)
|
|
900
1100
|
return (
|
|
901
1101
|
f"[CODEARBITER_AUTHORITY_REQUEST:{request['request_id']}]\n"
|
|
902
1102
|
f"Target repository: {request['repository']['path']}\n"
|
|
903
1103
|
f"Frozen context: {request['context_ref']} sha256={request['context_sha256']}\n"
|
|
904
|
-
|
|
905
|
-
+
|
|
906
|
-
"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 "
|
|
907
1106
|
"delegate. Review only the frozen target and return exactly one JSON object using "
|
|
908
1107
|
f"format {DECISION_FORMAT}. Bind request_id={request['request_id']}, "
|
|
909
1108
|
f"target_sha256={context['input_sha256']}, "
|
|
@@ -1912,6 +2111,7 @@ def observe_codex_hook(root: str | Path, event: dict[str, Any]) -> dict[str, Any
|
|
|
1912
2111
|
raise AuthorityError("UNSUPPORTED_HOST_SEAM", "review request is not launchable")
|
|
1913
2112
|
if tool_input != request.get("launch_envelope"):
|
|
1914
2113
|
raise AuthorityError("UNSUPPORTED_HOST_SEAM", "review launch envelope was changed")
|
|
2114
|
+
_require_frozen_context(root, request)
|
|
1915
2115
|
if _ordinary_spawn_marker(session_id, turn_id).exists():
|
|
1916
2116
|
request["state"] = "REJECTED"
|
|
1917
2117
|
request["recovery"] = {
|
|
@@ -2116,6 +2316,7 @@ def observe_claude_hook(root: str | Path, event: dict[str, Any]) -> dict[str, An
|
|
|
2116
2316
|
if tool_input != request.get("launch_envelope"):
|
|
2117
2317
|
_reject(root, request, "changed-launch-envelope")
|
|
2118
2318
|
raise AuthorityError("UNSUPPORTED_HOST_SEAM", "review launch envelope was changed")
|
|
2319
|
+
_require_frozen_context(root, request)
|
|
2119
2320
|
try:
|
|
2120
2321
|
_refuse_shadowed_reviewer(root, session_root)
|
|
2121
2322
|
except AuthorityError:
|
|
@@ -2317,6 +2518,10 @@ def publish_request(
|
|
|
2317
2518
|
wrapper = request.get("wrapper")
|
|
2318
2519
|
if not isinstance(wrapper, dict) or wrapper.get("state") != "CORROBORATED":
|
|
2319
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)
|
|
2320
2525
|
current, current_ref, current_hash = _context(
|
|
2321
2526
|
root, client, request["context"]["subject"]["artifact_id"],
|
|
2322
2527
|
request["context"]["subject"]["record_id"],
|
|
@@ -2354,11 +2559,11 @@ def publish_request(
|
|
|
2354
2559
|
|
|
2355
2560
|
|
|
2356
2561
|
def recover_request(root: str | Path, request_id: str, disposition: str) -> dict[str, Any]:
|
|
2357
|
-
"""Retain an uncertain attempt
|
|
2562
|
+
"""Retain an uncertain attempt or abandon a review that never launched."""
|
|
2358
2563
|
root = _real_root(root)
|
|
2359
2564
|
if disposition not in {"failed", "abandoned"}:
|
|
2360
2565
|
raise AuthorityError("INVALID_RECOVERY", "recovery disposition must be failed or abandoned")
|
|
2361
|
-
request = _load(root, request_id)
|
|
2566
|
+
request = _load(root, request_id, recover_armed_review=disposition == "abandoned")
|
|
2362
2567
|
recoverable_completed = (
|
|
2363
2568
|
request["state"] == "COMPLETED"
|
|
2364
2569
|
and request["activity"] == "verification"
|
|
@@ -2371,7 +2576,11 @@ def recover_request(root: str | Path, request_id: str, disposition: str) -> dict
|
|
|
2371
2576
|
and isinstance(request.get("wrapper"), dict)
|
|
2372
2577
|
and request["wrapper"].get("state") in {"AUTHORIZED", "FAILED"}
|
|
2373
2578
|
)
|
|
2374
|
-
|
|
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
|
+
):
|
|
2375
2584
|
raise AuthorityError("INVALID_RECOVERY", "only an interrupted active attempt can be recovered")
|
|
2376
2585
|
request["state"] = disposition.upper()
|
|
2377
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"
|
|
@@ -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,
|
|
@@ -278,7 +278,13 @@ separate and cannot be relabelled as qualified launch evidence.
|
|
|
278
278
|
|
|
279
279
|
For `spec_review` or `quality_review`, arm the corresponding activity without
|
|
280
280
|
workspace mappings and dispatch the returned `launch_envelope` unchanged to one
|
|
281
|
-
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
|
|
282
288
|
the child's first stop; a pasted or coordinator-authored decision is not review
|
|
283
289
|
authority.
|
|
284
290
|
Publish the completed request through the same `publish` command. `task-review`
|
|
@@ -287,6 +293,19 @@ the scope reaches `REVIEW`, reverify against one current source snapshot, run th
|
|
|
287
293
|
combined quality review, and use `accept-scope` once. Do not accept tasks
|
|
288
294
|
individually.
|
|
289
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
|
+
|
|
290
309
|
An interrupted or `BLOCKED` HTML task never resumes by editing its status. Arm
|
|
291
310
|
the installed reconciliation adapter for the exact task or scope, present its
|
|
292
311
|
returned `short_reply` (`reconcile <STATE> <code>` for a task, `reconcile <code>`
|