@arbiterforge/ca-pi 0.14.6 → 0.14.7

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.21.6" src="https://img.shields.io/badge/version-2.21.6-2b7489">
13
+ <img alt="version 2.21.7" src="https://img.shields.io/badge/version-2.21.7-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-19-555">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arbiterforge/ca-pi",
3
- "version": "0.14.6",
3
+ "version": "0.14.7",
4
4
  "license": "AGPL-3.0-only",
5
5
  "repository": {
6
6
  "type": "git",
@@ -4,6 +4,14 @@ All notable changes to `ca-pi` are documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.14.7] - 2026-09-20
8
+
9
+ ### Fixed
10
+
11
+ - Package the shared prerequisite adapter while keeping prompt-backed
12
+ prerequisite authority explicitly unavailable on Pi until a native prompt
13
+ boundary exists.
14
+
7
15
  ## [0.14.6] - 2026-09-20
8
16
 
9
17
  ### Fixed
@@ -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.14.6"
14
+ adapter_version = "0.14.7"
15
15
  update_target = "ca-pi"
16
16
  update_tag_prefix = "ca-pi-v"
17
17
  update_command = "pi update npm:@arbiterforge/ca-pi"
@@ -0,0 +1,971 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter — host-owned prerequisite confirmation for structured plans.
3
+ """Capture an exact host-observed user decision and satisfy one prerequisite.
4
+
5
+ arm_user_prerequisite(...) -> dict
6
+ consume_user_prerequisite(...) -> dict
7
+ cancel_user_prerequisite(...) -> dict
8
+ supersede_user_prerequisite(...) -> dict
9
+ consume_from_hook(...) -> str
10
+
11
+ The adapter fixes the authority kind, verdict, actor, event shape, and engine
12
+ operation. Callers select only an approved plan and one of its exact current
13
+ prerequisite records. Confirmation is persisted before authority publication;
14
+ retries reuse the same event, receipt, operation ID, and mutation request.
15
+
16
+ The user-visible confirmation nonce is deliberately non-secret correlation
17
+ data, not a bearer credential. Possession never confers authority: the adapter
18
+ accepts it only in an exact prompt observed by the trusted UserPromptSubmit
19
+ boundary while the reviewed plan, approval, and prerequisite remain current.
20
+ The observed prompt is retained verbatim as the authority source because it is
21
+ the decision evidence; nonce unpredictability is not a security boundary.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ from contextlib import contextmanager
28
+ import hashlib
29
+ import json
30
+ import os
31
+ from pathlib import Path
32
+ import re
33
+ import secrets
34
+ import stat
35
+ import sys
36
+ import tempfile
37
+ import time
38
+ from typing import Any
39
+
40
+ import _artifactlib
41
+ from _hooklib import acquire_lock, release_lock
42
+
43
+
44
+ PENDING_DIR = Path(".codearbiter/.markers/pending-prerequisites")
45
+ SOURCE_DIR = Path(".codearbiter/.artifacts/authority-sources")
46
+ FORMAT = "codearbiter.pending-user-prerequisite/0.1.0"
47
+ POLICY = "explicit-user-prerequisite-confirmation/0.1.0"
48
+ CONFIRMATION_LIFETIME_SECONDS = 15 * 60
49
+ CONFIRMATION_RE = re.compile(r"[A-Za-z0-9_-]{12,128}")
50
+ ID_RE = re.compile(r"[A-Z][A-Z0-9_-]{0,127}")
51
+ HOST_RE = re.compile(r"[A-Za-z0-9_-]{1,32}")
52
+ SESSION_RE = re.compile(r"[A-Za-z0-9._:-]{1,256}")
53
+ SHA256_RE = re.compile(r"[0-9a-f]{64}")
54
+ RECEIPT_RE = re.compile(
55
+ r"\.codearbiter/\.artifacts/receipts/[0-9a-f]{64}\.json"
56
+ )
57
+ STATES = frozenset({"ARMED", "CONFIRMED", "CAPTURED"})
58
+ STATE_FIELDS = {
59
+ "format", "state", "request_id", "artifact_id", "prerequisite_id",
60
+ "kind", "revision", "model_sha256", "normative_sha256", "record",
61
+ "review_packet_sha256", "confirmation_sha256", "issued_at", "expires_at",
62
+ "event", "authority_source", "receipt", "mutation_request",
63
+ }
64
+
65
+
66
+ class PrerequisiteError(RuntimeError):
67
+ def __init__(self, code: str, message: str):
68
+ self.code = code
69
+ super().__init__(f"{code}: {message}")
70
+
71
+
72
+ def _object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
73
+ value: dict[str, Any] = {}
74
+ for key, item in pairs:
75
+ if key in value:
76
+ raise ValueError("duplicate JSON key")
77
+ value[key] = item
78
+ return value
79
+
80
+
81
+ def _canonical(value: Any) -> bytes:
82
+ return json.dumps(
83
+ value, ensure_ascii=True, allow_nan=False, sort_keys=True, separators=(",", ":")
84
+ ).encode("utf-8")
85
+
86
+
87
+ def _digest(data: bytes) -> str:
88
+ return hashlib.sha256(data).hexdigest()
89
+
90
+
91
+ def _pending_relative(artifact_id: str) -> Path:
92
+ return PENDING_DIR / f"{_digest(artifact_id.encode('utf-8'))}.json"
93
+
94
+
95
+ def _prerequisite_busy() -> PrerequisiteError:
96
+ return PrerequisiteError(
97
+ "PREREQUISITE_BUSY",
98
+ "another session is resolving this plan's prerequisite request",
99
+ )
100
+
101
+
102
+ def _repository_lock_key(root: Path) -> bytes:
103
+ """Return one key for every path alias to the same repository directory."""
104
+ info = os.stat(root)
105
+ device = int(getattr(info, "st_dev", 0))
106
+ inode = int(getattr(info, "st_ino", 0))
107
+ if device or inode:
108
+ return f"stat:{device}:{inode}".encode("ascii")
109
+
110
+ # Some Windows filesystems report no stable inode. Their path comparison is
111
+ # case-insensitive, so casefold closes aliases that normcase alone may miss
112
+ # on a non-native test host.
113
+ resolved = str(root.resolve(strict=True))
114
+ return b"path:" + os.path.normcase(resolved).casefold().encode("utf-8")
115
+
116
+
117
+ def _pending_lock_directory_name(temporary: Path | None = None) -> str:
118
+ if hasattr(os, "getuid"):
119
+ namespace = f"uid-{os.getuid()}"
120
+ else:
121
+ if temporary is None:
122
+ temporary = Path(tempfile.gettempdir()).resolve(strict=True)
123
+ temp_identity = (
124
+ os.path.normcase(str(temporary)).casefold().encode("utf-8")
125
+ )
126
+ namespace = f"temp-{_digest(temp_identity)[:24]}"
127
+ return f"codearbiter-prerequisite-locks-{namespace}"
128
+
129
+
130
+ def _pending_lock_root() -> Path:
131
+ """Create and validate the private cross-process lock namespace."""
132
+ try:
133
+ temporary = Path(tempfile.gettempdir()).resolve(strict=True)
134
+ lock_root = temporary / _pending_lock_directory_name(temporary)
135
+ lock_root.mkdir(mode=0o700, exist_ok=True)
136
+ info = lock_root.lstat()
137
+ reparse = getattr(info, "st_file_attributes", 0) & 0x400
138
+ if stat.S_ISLNK(info.st_mode) or reparse or not stat.S_ISDIR(info.st_mode):
139
+ raise OSError("prerequisite lock root is not a real directory")
140
+ if hasattr(os, "getuid"):
141
+ if info.st_uid != os.getuid():
142
+ raise OSError("prerequisite lock root has the wrong owner")
143
+ os.chmod(lock_root, 0o700)
144
+ info = lock_root.lstat()
145
+ if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o077:
146
+ raise OSError("prerequisite lock root is not private")
147
+ return lock_root
148
+ except OSError as error:
149
+ raise _prerequisite_busy() from error
150
+
151
+
152
+ @contextmanager
153
+ def _pending_transition_lock(root: Path, artifact_id: str):
154
+ """Serialize one artifact's load-to-cleanup prerequisite transition."""
155
+ # Keep the live OS lock outside the repository. Verification snapshots
156
+ # recursively read ordinary marker files, and Windows denies that read
157
+ # while acquire_lock holds its byte-range lock. The resolved repository
158
+ # filesystem identity makes path aliases share a key, while the private
159
+ # per-user namespace prevents another local account from redirecting or
160
+ # pre-seeding the lock sidecar.
161
+ repository_key = _repository_lock_key(root)
162
+ lock_name = _digest(repository_key + b"\0" + artifact_id.encode("utf-8"))
163
+ lock_root = _pending_lock_root()
164
+ handle = acquire_lock(lock_root / lock_name)
165
+ if handle is None:
166
+ raise _prerequisite_busy()
167
+ try:
168
+ yield
169
+ finally:
170
+ release_lock(handle)
171
+
172
+
173
+ def _safe_directory(root: Path, relative: Path, *, create: bool) -> Path:
174
+ current = root
175
+ for part in relative.parts:
176
+ current = current / part
177
+ try:
178
+ info = current.lstat()
179
+ except FileNotFoundError:
180
+ if not create:
181
+ raise
182
+ current.mkdir()
183
+ info = current.lstat()
184
+ reparse = getattr(info, "st_file_attributes", 0) & 0x400
185
+ if stat.S_ISLNK(info.st_mode) or reparse or not stat.S_ISDIR(info.st_mode):
186
+ raise PrerequisiteError(
187
+ "UNSAFE_PREREQUISITE_PATH",
188
+ "prerequisite state directories must be real directories",
189
+ )
190
+ return current
191
+
192
+
193
+ def _write_new(root: Path, relative: Path, data: bytes) -> None:
194
+ parent = _safe_directory(root, relative.parent, create=True)
195
+ path = parent / relative.name
196
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
197
+ if hasattr(os, "O_BINARY"):
198
+ flags |= os.O_BINARY
199
+ fd = os.open(path, flags, 0o600)
200
+ completed = False
201
+ try:
202
+ view = memoryview(data)
203
+ while view:
204
+ written = os.write(fd, view)
205
+ if written <= 0:
206
+ raise OSError("short write")
207
+ view = view[written:]
208
+ os.fsync(fd)
209
+ os.close(fd)
210
+ fd = None
211
+ completed = True
212
+ finally:
213
+ if fd is not None:
214
+ try:
215
+ os.close(fd)
216
+ except OSError:
217
+ pass
218
+ if not completed:
219
+ try:
220
+ path.unlink()
221
+ except OSError:
222
+ pass
223
+
224
+
225
+ def _replace_state(root: Path, relative: Path, value: dict[str, Any]) -> None:
226
+ suffix = secrets.token_hex(8)
227
+ temporary = relative.with_name(f".{relative.name}.{suffix}.tmp")
228
+ _write_new(root, temporary, _canonical(value))
229
+ source = root / temporary
230
+ target = root / relative
231
+ try:
232
+ os.replace(source, target)
233
+ if os.name != "nt":
234
+ directory = os.open(target.parent, os.O_RDONLY)
235
+ try:
236
+ os.fsync(directory)
237
+ finally:
238
+ os.close(directory)
239
+ finally:
240
+ try:
241
+ source.unlink()
242
+ except FileNotFoundError:
243
+ pass
244
+
245
+
246
+ def _publish_new(root: Path, relative: Path, data: bytes) -> None:
247
+ """Publish complete bytes atomically without replacing an existing name."""
248
+ suffix = secrets.token_hex(8)
249
+ temporary = relative.with_name(f".{relative.name}.{suffix}.tmp")
250
+ _write_new(root, temporary, data)
251
+ source = root / temporary
252
+ target = root / relative
253
+ linked = False
254
+ try:
255
+ os.link(source, target)
256
+ linked = True
257
+ if os.name != "nt":
258
+ directory = os.open(target.parent, os.O_RDONLY)
259
+ try:
260
+ os.fsync(directory)
261
+ finally:
262
+ os.close(directory)
263
+ finally:
264
+ try:
265
+ source.unlink()
266
+ except FileNotFoundError:
267
+ pass
268
+ if not linked:
269
+ raise OSError("content-addressed publication did not complete")
270
+
271
+
272
+ def _read_bounded_regular(root: Path, relative: Path, *, limit: int) -> bytes:
273
+ parent = _safe_directory(root, relative.parent, create=False)
274
+ path = parent / relative.name
275
+ info = path.lstat()
276
+ reparse = getattr(info, "st_file_attributes", 0) & 0x400
277
+ if (
278
+ stat.S_ISLNK(info.st_mode)
279
+ or reparse
280
+ or not stat.S_ISREG(info.st_mode)
281
+ or info.st_size > limit
282
+ ):
283
+ raise PrerequisiteError(
284
+ "INVALID_PENDING_PREREQUISITE",
285
+ "prerequisite state is not bounded regular data",
286
+ )
287
+ data = path.read_bytes()
288
+ if len(data) > limit:
289
+ raise PrerequisiteError(
290
+ "INVALID_PENDING_PREREQUISITE", "prerequisite state exceeds its bound"
291
+ )
292
+ return data
293
+
294
+
295
+ def _identity(client: Any, artifact_id: str) -> dict[str, Any]:
296
+ identity = client.call("identity", {"artifact_id": artifact_id})
297
+ required = {
298
+ "artifact_id": str,
299
+ "kind": str,
300
+ "revision": int,
301
+ "model_sha256": str,
302
+ "normative_sha256": str,
303
+ }
304
+ if any(type(identity.get(key)) is not kind for key, kind in required.items()):
305
+ raise PrerequisiteError("INVALID_IDENTITY", "artifact identity is incomplete")
306
+ if identity["artifact_id"] != artifact_id or identity["kind"] != "plan":
307
+ raise PrerequisiteError("INVALID_IDENTITY", "prerequisites require the exact plan")
308
+ for key in ("model_sha256", "normative_sha256"):
309
+ if SHA256_RE.fullmatch(identity[key]) is None:
310
+ raise PrerequisiteError("INVALID_IDENTITY", f"{key} is malformed")
311
+ return identity
312
+
313
+
314
+ def _approved(client: Any, identity: dict[str, Any]) -> None:
315
+ result = client.call(
316
+ "validate",
317
+ {"artifact_id": identity["artifact_id"], "gate": "approved"},
318
+ permit_invalid=True,
319
+ )
320
+ authority = result.get("authority")
321
+ if (
322
+ result.get("valid") is not True
323
+ or result.get("artifact_id") != identity["artifact_id"]
324
+ or result.get("model_sha256") != identity["model_sha256"]
325
+ or result.get("normative_sha256") != identity["normative_sha256"]
326
+ or not isinstance(authority, dict)
327
+ or authority.get("state") != "approved"
328
+ or authority.get("authority_verified") is not True
329
+ ):
330
+ raise PrerequisiteError(
331
+ "AUTHORITY_UNVERIFIED",
332
+ "only the exact current approved plan can arm a prerequisite",
333
+ )
334
+
335
+
336
+ def _prerequisite_record(
337
+ client: Any, identity: dict[str, Any], prerequisite_id: str
338
+ ) -> dict[str, Any]:
339
+ result = client.call(
340
+ "read",
341
+ {
342
+ "artifact_id": identity["artifact_id"],
343
+ "symbol": prerequisite_id,
344
+ "mode": "exact",
345
+ },
346
+ )
347
+ if any(result.get(field) != identity[field] for field in (
348
+ "artifact_id", "revision", "model_sha256", "normative_sha256"
349
+ )):
350
+ raise PrerequisiteError("STALE_PREREQUISITE", "prerequisite read changed identity")
351
+ record = result.get("record")
352
+ if (
353
+ not isinstance(record, dict)
354
+ or set(record) != {"id", "title", "requirement", "source_refs"}
355
+ or record.get("id") != prerequisite_id
356
+ or not isinstance(record.get("title"), str)
357
+ or not record["title"]
358
+ or not isinstance(record.get("requirement"), str)
359
+ or not record["requirement"]
360
+ or not isinstance(record.get("source_refs"), list)
361
+ or any(not isinstance(value, str) for value in record["source_refs"])
362
+ ):
363
+ raise PrerequisiteError(
364
+ "UNKNOWN_PREREQUISITE", "symbol is not an exact prerequisite record"
365
+ )
366
+ return record
367
+
368
+
369
+ def _review_packet(identity: dict[str, Any], record: dict[str, Any]) -> dict[str, Any]:
370
+ return {
371
+ "policy": POLICY,
372
+ "artifact": {
373
+ key: identity[key]
374
+ for key in (
375
+ "artifact_id", "kind", "revision", "model_sha256", "normative_sha256"
376
+ )
377
+ },
378
+ "prerequisite": record,
379
+ }
380
+
381
+
382
+ def _validate_record(value: Any, prerequisite_id: str) -> bool:
383
+ return (
384
+ isinstance(value, dict)
385
+ and set(value) == {"id", "title", "requirement", "source_refs"}
386
+ and value.get("id") == prerequisite_id
387
+ and isinstance(value.get("title"), str)
388
+ and bool(value["title"])
389
+ and isinstance(value.get("requirement"), str)
390
+ and bool(value["requirement"])
391
+ and isinstance(value.get("source_refs"), list)
392
+ and all(isinstance(item, str) for item in value["source_refs"])
393
+ )
394
+
395
+
396
+ def _validate_confirmed_state(value: dict[str, Any]) -> None:
397
+ event = value["event"]
398
+ expected_subject = {
399
+ "artifact_id": value["artifact_id"],
400
+ "normative_sha256": value["normative_sha256"],
401
+ "record_id": value["prerequisite_id"],
402
+ }
403
+ expected_payload = {
404
+ "adapter_policy": POLICY,
405
+ "request_id": value["request_id"],
406
+ "review_packet_sha256": value["review_packet_sha256"],
407
+ "prerequisite": value["record"],
408
+ }
409
+ if (
410
+ not isinstance(event, dict)
411
+ or set(event) != {
412
+ "format", "kind", "authority_kind", "subject", "actor", "origin",
413
+ "verdict", "payload", "source_text",
414
+ }
415
+ or event.get("format") != "codearbiter.workflow-event/0.1.0"
416
+ or event.get("kind") != "prerequisite"
417
+ or event.get("authority_kind") != "user_workflow"
418
+ or event.get("subject") != expected_subject
419
+ or event.get("actor") != "interactive repository user"
420
+ or not isinstance(event.get("origin"), str)
421
+ or re.fullmatch(
422
+ r"[A-Za-z0-9_-]{1,32}:UserPromptSubmit:[A-Za-z0-9._:-]{1,256}",
423
+ event["origin"],
424
+ ) is None
425
+ or event.get("verdict") != "satisfied"
426
+ or event.get("payload") != expected_payload
427
+ ):
428
+ raise PrerequisiteError(
429
+ "INVALID_PENDING_PREREQUISITE",
430
+ "confirmed event does not match the fixed prerequisite policy",
431
+ )
432
+ match = _match_prompt(event.get("source_text"))
433
+ if (
434
+ match is None
435
+ or match[:2] != (value["artifact_id"], value["prerequisite_id"])
436
+ or not secrets.compare_digest(
437
+ _digest(match[2].encode("ascii")), value["confirmation_sha256"]
438
+ )
439
+ ):
440
+ raise PrerequisiteError(
441
+ "INVALID_PENDING_PREREQUISITE",
442
+ "confirmed source text does not match the armed request",
443
+ )
444
+ expected_source = (
445
+ SOURCE_DIR / f"{_digest(_canonical(event))}.json"
446
+ ).as_posix()
447
+ if value["authority_source"] != expected_source:
448
+ raise PrerequisiteError(
449
+ "INVALID_PENDING_PREREQUISITE",
450
+ "confirmed authority source does not match the event",
451
+ )
452
+
453
+
454
+ def _expected_mutation_request(value: dict[str, Any]) -> dict[str, Any]:
455
+ operation_suffix = _digest(value["request_id"].encode("ascii"))[:24]
456
+ return {
457
+ "artifact_id": value["artifact_id"],
458
+ "operation_id": f"host-user-prerequisite-{operation_suffix}",
459
+ "expected": {
460
+ "revision": value["revision"],
461
+ "model_sha256": value["model_sha256"],
462
+ },
463
+ "prerequisite": value["prerequisite_id"],
464
+ "receipt": value["receipt"],
465
+ }
466
+
467
+
468
+ def _load_pending(root: Path, artifact_id: str) -> dict[str, Any] | None:
469
+ relative = _pending_relative(artifact_id)
470
+ try:
471
+ raw = _read_bounded_regular(root, relative, limit=65536)
472
+ except FileNotFoundError:
473
+ return None
474
+ except (OSError, UnicodeError) as exc:
475
+ raise PrerequisiteError(
476
+ "INVALID_PENDING_PREREQUISITE", "prerequisite state is unreadable"
477
+ ) from exc
478
+ try:
479
+ value = json.loads(raw.decode("utf-8", "strict"), object_pairs_hook=_object)
480
+ except (ValueError, UnicodeError, RecursionError) as exc:
481
+ raise PrerequisiteError(
482
+ "INVALID_PENDING_PREREQUISITE", "prerequisite state is invalid JSON"
483
+ ) from exc
484
+ if not isinstance(value, dict) or set(value) != STATE_FIELDS:
485
+ raise PrerequisiteError(
486
+ "INVALID_PENDING_PREREQUISITE", "prerequisite state shape is invalid"
487
+ )
488
+ if (
489
+ value["format"] != FORMAT
490
+ or value["state"] not in STATES
491
+ or value["artifact_id"] != artifact_id
492
+ or any(not isinstance(value[key], str) or not value[key] for key in (
493
+ "request_id", "artifact_id", "prerequisite_id", "kind",
494
+ "model_sha256", "normative_sha256", "review_packet_sha256",
495
+ "confirmation_sha256",
496
+ ))
497
+ or type(value["revision"]) is not int
498
+ or type(value["issued_at"]) is not int
499
+ or type(value["expires_at"]) is not int
500
+ or value["expires_at"] <= value["issued_at"]
501
+ or any(SHA256_RE.fullmatch(value[key]) is None for key in (
502
+ "model_sha256", "normative_sha256", "review_packet_sha256", "confirmation_sha256"
503
+ ))
504
+ or not _validate_record(value["record"], value["prerequisite_id"])
505
+ ):
506
+ raise PrerequisiteError(
507
+ "INVALID_PENDING_PREREQUISITE", "prerequisite state values are invalid"
508
+ )
509
+ if value["state"] == "ARMED":
510
+ if any(value[key] is not None for key in (
511
+ "event", "authority_source", "receipt", "mutation_request"
512
+ )):
513
+ raise PrerequisiteError(
514
+ "INVALID_PENDING_PREREQUISITE", "armed state contains later-phase data"
515
+ )
516
+ elif value["state"] == "CONFIRMED":
517
+ if (
518
+ not isinstance(value["event"], dict)
519
+ or not isinstance(value["authority_source"], str)
520
+ or value["receipt"] is not None
521
+ or value["mutation_request"] is not None
522
+ ):
523
+ raise PrerequisiteError(
524
+ "INVALID_PENDING_PREREQUISITE", "confirmed state is incomplete"
525
+ )
526
+ _validate_confirmed_state(value)
527
+ elif (
528
+ not isinstance(value["event"], dict)
529
+ or not isinstance(value["authority_source"], str)
530
+ or not isinstance(value["receipt"], str)
531
+ or RECEIPT_RE.fullmatch(value["receipt"]) is None
532
+ or not isinstance(value["mutation_request"], dict)
533
+ ):
534
+ raise PrerequisiteError(
535
+ "INVALID_PENDING_PREREQUISITE", "captured state is incomplete"
536
+ )
537
+ else:
538
+ _validate_confirmed_state(value)
539
+ if value["mutation_request"] != _expected_mutation_request(value):
540
+ raise PrerequisiteError(
541
+ "INVALID_PENDING_PREREQUISITE",
542
+ "captured mutation request does not match the confirmed decision",
543
+ )
544
+ return value
545
+
546
+
547
+ def arm_user_prerequisite(
548
+ root: str | Path,
549
+ client: Any,
550
+ artifact_id: str,
551
+ prerequisite_id: str,
552
+ *,
553
+ confirmation_nonce: str | None = None,
554
+ now: int | None = None,
555
+ ) -> dict[str, Any]:
556
+ root = Path(root).resolve(strict=True)
557
+ if ID_RE.fullmatch(artifact_id) is None or ID_RE.fullmatch(prerequisite_id) is None:
558
+ raise PrerequisiteError("INVALID_PREREQUISITE", "artifact and prerequisite IDs are invalid")
559
+ identity = _identity(client, artifact_id)
560
+ _approved(client, identity)
561
+ record = _prerequisite_record(client, identity, prerequisite_id)
562
+ final_identity = _identity(client, artifact_id)
563
+ if final_identity != identity:
564
+ raise PrerequisiteError("STALE_PREREQUISITE", "plan changed while arming")
565
+ _approved(client, final_identity)
566
+ packet_sha256 = _digest(_canonical(_review_packet(identity, record)))
567
+ confirmation_nonce = confirmation_nonce or secrets.token_urlsafe(32)
568
+ if CONFIRMATION_RE.fullmatch(confirmation_nonce) is None:
569
+ raise PrerequisiteError(
570
+ "INVALID_CONFIRMATION", "prerequisite confirmation nonce has an invalid shape"
571
+ )
572
+ issued_at = int(time.time()) if now is None else now
573
+ if type(issued_at) is not int or issued_at < 0:
574
+ raise PrerequisiteError("INVALID_TIME", "prerequisite issue time is invalid")
575
+ request_id = secrets.token_urlsafe(18)
576
+ pending = {
577
+ "format": FORMAT,
578
+ "state": "ARMED",
579
+ "request_id": request_id,
580
+ "artifact_id": artifact_id,
581
+ "prerequisite_id": prerequisite_id,
582
+ "kind": identity["kind"],
583
+ "revision": identity["revision"],
584
+ "model_sha256": identity["model_sha256"],
585
+ "normative_sha256": identity["normative_sha256"],
586
+ "record": record,
587
+ "review_packet_sha256": packet_sha256,
588
+ "confirmation_sha256": _digest(confirmation_nonce.encode("ascii")),
589
+ "issued_at": issued_at,
590
+ "expires_at": issued_at + CONFIRMATION_LIFETIME_SECONDS,
591
+ "event": None,
592
+ "authority_source": None,
593
+ "receipt": None,
594
+ "mutation_request": None,
595
+ }
596
+ relative = _pending_relative(artifact_id)
597
+ try:
598
+ _write_new(root, relative, _canonical(pending))
599
+ except FileExistsError as exc:
600
+ raise PrerequisiteError(
601
+ "PENDING_PREREQUISITE",
602
+ "resolve or cancel this plan's existing prerequisite request first",
603
+ ) from exc
604
+ return {
605
+ "artifact_id": artifact_id,
606
+ "prerequisite_id": prerequisite_id,
607
+ "title": record["title"],
608
+ "requirement": record["requirement"],
609
+ "review_packet_sha256": packet_sha256,
610
+ "reply": (
611
+ f"satisfy-prerequisite {artifact_id} {prerequisite_id} "
612
+ f"{confirmation_nonce}"
613
+ ),
614
+ "pending": relative.as_posix(),
615
+ }
616
+
617
+
618
+ def _match_prompt(prompt: Any) -> tuple[str, str, str] | None:
619
+ if not isinstance(prompt, str):
620
+ return None
621
+ parts = prompt.split(" ")
622
+ if (
623
+ len(parts) != 4
624
+ or parts[0] != "satisfy-prerequisite"
625
+ or ID_RE.fullmatch(parts[1]) is None
626
+ or ID_RE.fullmatch(parts[2]) is None
627
+ or CONFIRMATION_RE.fullmatch(parts[3]) is None
628
+ ):
629
+ return None
630
+ return parts[1], parts[2], parts[3]
631
+
632
+
633
+ def _confirm(
634
+ root: Path,
635
+ client: Any,
636
+ pending: dict[str, Any],
637
+ prompt: str,
638
+ *,
639
+ host: str,
640
+ session_id: str,
641
+ now: int,
642
+ ) -> dict[str, Any]:
643
+ if now > pending["expires_at"]:
644
+ raise PrerequisiteError(
645
+ "EXPIRED_PREREQUISITE", "the prerequisite confirmation request expired"
646
+ )
647
+ if not HOST_RE.fullmatch(host) or not SESSION_RE.fullmatch(session_id):
648
+ raise PrerequisiteError("INVALID_HOST_CONTEXT", "host prerequisite context is malformed")
649
+ current = _identity(client, pending["artifact_id"])
650
+ for field in ("kind", "revision", "model_sha256", "normative_sha256"):
651
+ if current[field] != pending[field]:
652
+ raise PrerequisiteError(
653
+ "STALE_PREREQUISITE", "plan changed after prerequisite review was armed"
654
+ )
655
+ _approved(client, current)
656
+ record = _prerequisite_record(client, current, pending["prerequisite_id"])
657
+ packet_sha256 = _digest(_canonical(_review_packet(current, record)))
658
+ if record != pending["record"] or packet_sha256 != pending["review_packet_sha256"]:
659
+ raise PrerequisiteError(
660
+ "STALE_PREREQUISITE", "prerequisite definition changed after review was armed"
661
+ )
662
+ event = {
663
+ "format": "codearbiter.workflow-event/0.1.0",
664
+ "kind": "prerequisite",
665
+ "authority_kind": "user_workflow",
666
+ "subject": {
667
+ "artifact_id": current["artifact_id"],
668
+ "normative_sha256": current["normative_sha256"],
669
+ "record_id": pending["prerequisite_id"],
670
+ },
671
+ "actor": "interactive repository user",
672
+ "origin": f"{host}:UserPromptSubmit:{session_id}",
673
+ "verdict": "satisfied",
674
+ "payload": {
675
+ "adapter_policy": POLICY,
676
+ "request_id": pending["request_id"],
677
+ "review_packet_sha256": pending["review_packet_sha256"],
678
+ "prerequisite": pending["record"],
679
+ },
680
+ "source_text": prompt,
681
+ }
682
+ event_bytes = _canonical(event)
683
+ source_rel = SOURCE_DIR / f"{_digest(event_bytes)}.json"
684
+ confirmed = dict(pending)
685
+ confirmed.update(
686
+ state="CONFIRMED",
687
+ event=event,
688
+ authority_source=source_rel.as_posix(),
689
+ )
690
+ _replace_state(root, _pending_relative(pending["artifact_id"]), confirmed)
691
+ return confirmed
692
+
693
+
694
+ def _publish_event(root: Path, pending: dict[str, Any]) -> tuple[str, str]:
695
+ event_bytes = _canonical(pending["event"])
696
+ digest = _digest(event_bytes)
697
+ source_rel = SOURCE_DIR / f"{digest}.json"
698
+ if pending["authority_source"] != source_rel.as_posix():
699
+ raise PrerequisiteError(
700
+ "INVALID_PENDING_PREREQUISITE", "authority source does not match the event"
701
+ )
702
+ try:
703
+ _publish_new(root, source_rel, event_bytes)
704
+ except FileExistsError:
705
+ try:
706
+ existing = _read_bounded_regular(root, source_rel, limit=65536)
707
+ except (FileNotFoundError, OSError) as exc:
708
+ raise PrerequisiteError(
709
+ "INVALID_AUTHORITY_SOURCE", "authority source is unreadable"
710
+ ) from exc
711
+ if existing != event_bytes:
712
+ raise PrerequisiteError(
713
+ "INVALID_AUTHORITY_SOURCE", "content-addressed source differs"
714
+ )
715
+ return source_rel.as_posix(), digest
716
+
717
+
718
+ def consume_user_prerequisite(
719
+ root: str | Path,
720
+ client: Any,
721
+ prompt: str,
722
+ *,
723
+ host: str,
724
+ session_id: str,
725
+ now: int | None = None,
726
+ ) -> dict[str, Any]:
727
+ root = Path(root).resolve(strict=True)
728
+ match = _match_prompt(prompt)
729
+ if match is None:
730
+ return {"matched": False, "satisfied": False}
731
+ artifact_id, prerequisite_id, confirmation_nonce = match
732
+ if not (root / _pending_relative(artifact_id)).exists():
733
+ return {"matched": False, "satisfied": False}
734
+ with _pending_transition_lock(root, artifact_id):
735
+ pending = _load_pending(root, artifact_id)
736
+ if pending is None or pending["prerequisite_id"] != prerequisite_id:
737
+ return {"matched": False, "satisfied": False}
738
+ if not secrets.compare_digest(
739
+ _digest(confirmation_nonce.encode("ascii")), pending["confirmation_sha256"]
740
+ ):
741
+ return {"matched": False, "satisfied": False}
742
+ observed_at = int(time.time()) if now is None else now
743
+ if type(observed_at) is not int or observed_at < 0:
744
+ raise PrerequisiteError("INVALID_TIME", "prerequisite confirmation time is invalid")
745
+ if pending["state"] == "ARMED":
746
+ pending = _confirm(
747
+ root,
748
+ client,
749
+ pending,
750
+ prompt,
751
+ host=host,
752
+ session_id=session_id,
753
+ now=observed_at,
754
+ )
755
+ source_ref, source_sha256 = _publish_event(root, pending)
756
+ if pending["state"] == "CONFIRMED":
757
+ captured = client.call(
758
+ "capture",
759
+ {"source_ref": source_ref, "source_sha256": source_sha256},
760
+ )
761
+ receipt = captured.get("receipt")
762
+ if not isinstance(receipt, str) or RECEIPT_RE.fullmatch(receipt) is None:
763
+ raise PrerequisiteError("INVALID_RECEIPT", "capture returned no valid receipt")
764
+ operation_suffix = _digest(pending["request_id"].encode("ascii"))[:24]
765
+ mutation_request = {
766
+ "artifact_id": pending["artifact_id"],
767
+ "operation_id": f"host-user-prerequisite-{operation_suffix}",
768
+ "expected": {
769
+ "revision": pending["revision"],
770
+ "model_sha256": pending["model_sha256"],
771
+ },
772
+ "prerequisite": pending["prerequisite_id"],
773
+ "receipt": receipt,
774
+ }
775
+ captured_state = dict(pending)
776
+ captured_state.update(
777
+ state="CAPTURED", receipt=receipt, mutation_request=mutation_request
778
+ )
779
+ _replace_state(root, _pending_relative(artifact_id), captured_state)
780
+ pending = captured_state
781
+ result = client.call("prerequisite", pending["mutation_request"])
782
+ (root / _pending_relative(artifact_id)).unlink()
783
+ return {
784
+ "matched": True,
785
+ "satisfied": True,
786
+ "artifact_id": artifact_id,
787
+ "prerequisite_id": prerequisite_id,
788
+ "authority_source": source_ref,
789
+ "receipt": pending["receipt"],
790
+ "revision": result.get("revision"),
791
+ "replayed": bool((result.get("transaction") or {}).get("replayed")),
792
+ }
793
+
794
+
795
+ def cancel_user_prerequisite(
796
+ root: str | Path, artifact_id: str, prerequisite_id: str
797
+ ) -> dict[str, Any]:
798
+ root = Path(root).resolve(strict=True)
799
+ if not (root / _pending_relative(artifact_id)).exists():
800
+ raise PrerequisiteError(
801
+ "NO_PENDING_PREREQUISITE", "there is no prerequisite request to cancel"
802
+ )
803
+ with _pending_transition_lock(root, artifact_id):
804
+ pending = _load_pending(root, artifact_id)
805
+ if pending is None:
806
+ raise PrerequisiteError(
807
+ "NO_PENDING_PREREQUISITE", "there is no prerequisite request to cancel"
808
+ )
809
+ if pending["prerequisite_id"] != prerequisite_id:
810
+ raise PrerequisiteError(
811
+ "PENDING_PREREQUISITE_MISMATCH",
812
+ "the pending request belongs to another prerequisite",
813
+ )
814
+ if pending["state"] != "ARMED":
815
+ raise PrerequisiteError(
816
+ "PREREQUISITE_RECOVERY_REQUIRED",
817
+ "a confirmed prerequisite decision must be recovered, not cancelled",
818
+ )
819
+ (root / _pending_relative(artifact_id)).unlink()
820
+ return {
821
+ "artifact_id": artifact_id,
822
+ "prerequisite_id": prerequisite_id,
823
+ "cancelled": True,
824
+ }
825
+
826
+
827
+ def supersede_user_prerequisite(
828
+ root: str | Path,
829
+ client: Any,
830
+ artifact_id: str,
831
+ prerequisite_id: str,
832
+ ) -> dict[str, Any]:
833
+ """Clear a confirmed stale request after excluding a committed replay."""
834
+ root = Path(root).resolve(strict=True)
835
+ if not (root / _pending_relative(artifact_id)).exists():
836
+ raise PrerequisiteError(
837
+ "NO_PENDING_PREREQUISITE", "there is no prerequisite request to supersede"
838
+ )
839
+ with _pending_transition_lock(root, artifact_id):
840
+ pending = _load_pending(root, artifact_id)
841
+ if pending is None:
842
+ raise PrerequisiteError(
843
+ "NO_PENDING_PREREQUISITE", "there is no prerequisite request to supersede"
844
+ )
845
+ if pending["prerequisite_id"] != prerequisite_id:
846
+ raise PrerequisiteError(
847
+ "PENDING_PREREQUISITE_MISMATCH",
848
+ "the pending request belongs to another prerequisite",
849
+ )
850
+ if pending["state"] == "ARMED":
851
+ raise PrerequisiteError(
852
+ "PREREQUISITE_NOT_CONFIRMED",
853
+ "an unconfirmed request should be cancelled, not superseded",
854
+ )
855
+ current = _identity(client, artifact_id)
856
+ if all(current[field] == pending[field] for field in (
857
+ "kind", "revision", "model_sha256", "normative_sha256"
858
+ )):
859
+ raise PrerequisiteError(
860
+ "PREREQUISITE_STILL_CURRENT",
861
+ "the confirmed request still matches the current plan identity",
862
+ )
863
+ if pending["state"] == "CAPTURED":
864
+ try:
865
+ result = client.call("prerequisite", pending["mutation_request"])
866
+ except _artifactlib.ArtifactError as exc:
867
+ if exc.code != "REVISION_CONFLICT":
868
+ raise
869
+ else:
870
+ (root / _pending_relative(artifact_id)).unlink()
871
+ return {
872
+ "artifact_id": artifact_id,
873
+ "prerequisite_id": prerequisite_id,
874
+ "superseded": False,
875
+ "satisfied": True,
876
+ "receipt": pending["receipt"],
877
+ "revision": result.get("revision"),
878
+ "replayed": bool((result.get("transaction") or {}).get("replayed")),
879
+ }
880
+ (root / _pending_relative(artifact_id)).unlink()
881
+ return {
882
+ "artifact_id": artifact_id,
883
+ "prerequisite_id": prerequisite_id,
884
+ "superseded": True,
885
+ "satisfied": False,
886
+ "authority_source": pending["authority_source"],
887
+ "receipt": pending["receipt"],
888
+ }
889
+
890
+
891
+ def consume_from_hook(
892
+ *,
893
+ root: str | Path,
894
+ plugin_root: str | Path,
895
+ prompt: str,
896
+ host: str,
897
+ session_id: str,
898
+ ) -> str:
899
+ attempted = isinstance(prompt, str) and prompt.startswith("satisfy-prerequisite")
900
+ match = _match_prompt(prompt)
901
+ if match is None:
902
+ return (
903
+ "codeArbiter: prerequisite capture failed: exact confirmation syntax required"
904
+ if attempted
905
+ else ""
906
+ )
907
+ if not (Path(root) / _pending_relative(match[0])).exists():
908
+ return (
909
+ "codeArbiter: prerequisite capture failed: no matching armed request"
910
+ if attempted
911
+ else ""
912
+ )
913
+ try:
914
+ client = _artifactlib.ArtifactClient(
915
+ root, Path(plugin_root) / "helpers" / "artifacts"
916
+ )
917
+ result = consume_user_prerequisite(
918
+ root, client, prompt, host=host, session_id=session_id
919
+ )
920
+ except (PrerequisiteError, _artifactlib.ArtifactError, OSError) as exc:
921
+ return f"codeArbiter: prerequisite capture failed: {exc}"
922
+ if not result.get("satisfied"):
923
+ return "codeArbiter: prerequisite capture failed: confirmation did not match"
924
+ return (
925
+ "codeArbiter: workflow prerequisite recorded for "
926
+ f"{result['artifact_id']}#{result['prerequisite_id']} "
927
+ f"(receipt {result['receipt']})."
928
+ )
929
+
930
+
931
+ def main(argv: list[str] | None = None) -> int:
932
+ parser = argparse.ArgumentParser()
933
+ sub = parser.add_subparsers(dest="command", required=True)
934
+ arm = sub.add_parser("arm")
935
+ arm.add_argument("--root", required=True)
936
+ arm.add_argument("--artifact-id", required=True)
937
+ arm.add_argument("--prerequisite-id", required=True)
938
+ cancel = sub.add_parser("cancel")
939
+ cancel.add_argument("--root", required=True)
940
+ cancel.add_argument("--artifact-id", required=True)
941
+ cancel.add_argument("--prerequisite-id", required=True)
942
+ supersede = sub.add_parser("supersede")
943
+ supersede.add_argument("--root", required=True)
944
+ supersede.add_argument("--artifact-id", required=True)
945
+ supersede.add_argument("--prerequisite-id", required=True)
946
+ args = parser.parse_args(argv)
947
+ if args.command == "cancel":
948
+ print(json.dumps(cancel_user_prerequisite(
949
+ args.root, args.artifact_id, args.prerequisite_id
950
+ )))
951
+ return 0
952
+ client = _artifactlib.ArtifactClient(
953
+ args.root, _artifactlib.helper_installation(__file__)
954
+ )
955
+ if args.command == "supersede":
956
+ print(json.dumps(supersede_user_prerequisite(
957
+ args.root, client, args.artifact_id, args.prerequisite_id
958
+ )))
959
+ return 0
960
+ print(json.dumps(arm_user_prerequisite(
961
+ args.root, client, args.artifact_id, args.prerequisite_id
962
+ )))
963
+ return 0
964
+
965
+
966
+ if __name__ == "__main__":
967
+ try:
968
+ raise SystemExit(main())
969
+ except (PrerequisiteError, _artifactlib.ArtifactError) as exc:
970
+ sys.stderr.write(str(exc) + "\n")
971
+ raise SystemExit(1)
@@ -313,7 +313,7 @@ class Host:
313
313
 
314
314
  name = "claude"
315
315
  adapter_name = "ca"
316
- adapter_version = "2.21.6"
316
+ adapter_version = "2.21.7"
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,
@@ -67,6 +67,7 @@ import hostapi # noqa: E402 — host seam (ADR-0011)
67
67
  import _approvallib # noqa: E402 — host-owned artifact approval capture
68
68
  import _hooklib # noqa: E402
69
69
  import _modelib # noqa: E402 — mode plane core (T-06..T-16, Lane A)
70
+ import _prerequisitelib # noqa: E402 — host-owned prerequisite capture
70
71
  import _readinjectlib # noqa: E402 — marker_path(prefix=) (T-30)
71
72
 
72
73
 
@@ -456,13 +457,26 @@ def _handle_user_prompt_submit(payload, host):
456
457
  token = _modelib.match_mode_token(prompt)
457
458
  approval_context = ""
458
459
  if token is None:
459
- approval_context = _approvallib.consume_from_hook(
460
+ contexts = []
461
+ approval = _approvallib.consume_from_hook(
460
462
  root=root,
461
463
  plugin_root=host.plugin_root(),
462
464
  prompt=prompt,
463
465
  host=host.name,
464
466
  session_id=session_id,
465
467
  )
468
+ if approval:
469
+ contexts.append(approval)
470
+ prerequisite = _prerequisitelib.consume_from_hook(
471
+ root=root,
472
+ plugin_root=host.plugin_root(),
473
+ prompt=prompt,
474
+ host=host.name,
475
+ session_id=session_id,
476
+ )
477
+ if prerequisite:
478
+ contexts.append(prerequisite)
479
+ approval_context = "\n".join(contexts)
466
480
  if host.name == "codex":
467
481
  return _handle_codex(
468
482
  payload, host, state_root, session_id, token, approval_context
@@ -109,6 +109,50 @@ delivery receipts. They are not authentication secrets and do not claim
109
109
  resistance to an unrestricted same-user filesystem writer. A digest without its
110
110
  corresponding engine-written receipt is rejected.
111
111
 
112
+ Before task selection, inspect the plan's exact prerequisite records. If one is
113
+ unsatisfied, collect and present the facts needed to judge its requirement; a
114
+ verification runner may supply facts but cannot confer prerequisite authority.
115
+ On Claude Code or Codex, arm a genuine user-workflow decision for the exact
116
+ current approved plan and prerequisite:
117
+
118
+ ```sh
119
+ python "<plugin-root>/hooks/_prerequisitelib.py" arm --root "<project-root>" --artifact-id <artifact-id> --prerequisite-id <prerequisite-id>
120
+ ```
121
+
122
+ Show the returned title, requirement, and review-packet digest, then require the
123
+ returned `satisfy-prerequisite ...` reply verbatim. The `UserPromptSubmit` hook
124
+ durably records that observed decision before publishing its authority source,
125
+ captures a prerequisite receipt, and applies the existing prerequisite CAS
126
+ operation. Its final nonce is non-secret freshness/correlation data, not a
127
+ credential: possession alone confers no authority, and only the exact prompt
128
+ observed at the trusted host boundary while the reviewed plan remains current
129
+ can satisfy the prerequisite. Wrong nonces or records, stale plan content or
130
+ approval, and expired requests confer no authority. A retry after confirmation reuses the same event,
131
+ receipt, operation ID, and mutation request; it never reconstructs a decision.
132
+ Only an unconfirmed request can be cancelled:
133
+
134
+ ```sh
135
+ python "<plugin-root>/hooks/_prerequisitelib.py" cancel --root "<project-root>" --artifact-id <artifact-id> --prerequisite-id <prerequisite-id>
136
+ ```
137
+
138
+ If the plan changes after confirmation, recover the obsolete request instead of
139
+ deleting its marker. `supersede` first proves the current identity differs. For
140
+ a captured request it also retries the exact operation ID: a committed replay is
141
+ accepted, while only an engine `REVISION_CONFLICT` proves the obsolete mutation
142
+ did not commit and permits the marker to clear. Published source and receipt
143
+ files remain as evidence:
144
+
145
+ ```sh
146
+ python "<plugin-root>/hooks/_prerequisitelib.py" supersede --root "<project-root>" --artifact-id <artifact-id> --prerequisite-id <prerequisite-id>
147
+ ```
148
+
149
+ The adapter fixes `authority_kind: user_workflow` and `verdict: satisfied`;
150
+ there is no caller-selected authority or bulk-satisfaction path. SMARTS remains
151
+ unsupported until a real policy-owned SMARTS decision producer exists. Pi has no
152
+ qualified pre-model prompt seam and must remain blocked. Do not infer eligibility from one satisfied prerequisite:
153
+ rerun `eligible`, and resolve each remaining record independently against the
154
+ new current plan identity.
155
+
112
156
  `eligible` is authoritative for task selection on the HTML path.
113
157
  `task-start` checks current spec/plan approval, binding, prerequisites, source
114
158
  snapshot, dependency evidence and the complete context ticket. Within a checkpoint,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arbiterforge/ca-pi",
3
- "version": "0.14.6",
3
+ "version": "0.14.7",
4
4
  "private": true,
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",