@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 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.1" src="https://img.shields.io/badge/version-2.22.1-2b7489">
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arbiterforge/ca-pi",
3
- "version": "0.15.1",
3
+ "version": "0.15.2",
4
4
  "license": "AGPL-3.0-only",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- value = dict(value)
258
- value["integrity_sha256"] = _integrity(value)
333
+ expected = value.get("integrity_sha256")
334
+ updated = dict(value)
335
+ updated["integrity_sha256"] = _integrity(updated)
259
336
  try:
260
- _atomic_replace(_spool_root(root), _request_path(value["request_id"]), _canonical(value))
261
- if value["state"] in TERMINAL_STATES:
262
- (_registry_root() / f"{value['request_id']}.json").unlink(missing_ok=True)
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(root: Path, request_id: str) -> dict[str, Any]:
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 > MAX_STATE:
377
+ if info.st_size > limit:
275
378
  raise OSError("oversized")
276
- value = json.loads(path.read_text(encoding="utf-8"))
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
- expected_ref = f".codearbiter/.artifacts/evidence-contexts/{context_hash}.json"
326
- if context_ref != expected_ref:
327
- raise AuthorityError("INVALID_EVIDENCE_CONTEXT", "context locator is not content addressed")
328
- try:
329
- raw = (root / expected_ref).read_bytes()
330
- context = json.loads(raw.decode("utf-8"))
331
- except (OSError, UnicodeError, ValueError) as exc:
332
- raise AuthorityError("INVALID_EVIDENCE_CONTEXT", "context bytes are unavailable") from exc
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["activity"] != activity
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
- "Frozen target data follows as canonical JSON:\n"
905
- + _canonical(context).decode("ascii") + "\n"
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 and close it without ever rerunning commands."""
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
- if request["state"] not in {"RUNNING", "LAUNCHING"} and not recoverable_completed and not recoverable_authorized:
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.1"
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.1"
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 host hooks bind the exact launch call, child start and
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>`
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arbiterforge/ca-pi",
3
- "version": "0.15.1",
3
+ "version": "0.15.2",
4
4
  "private": true,
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",