@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 +1 -1
- package/package.json +1 -1
- package/plugins/ca-pi/CHANGELOG.md +8 -0
- package/plugins/ca-pi/hooks/_host.py +1 -1
- package/plugins/ca-pi/hooks/_prerequisitelib.py +971 -0
- package/plugins/ca-pi/hooks/hostapi.py +1 -1
- package/plugins/ca-pi/hooks/prompt-submit.py +15 -1
- package/plugins/ca-pi/includes/artifacts.md +44 -0
- 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.21.
|
|
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
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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,
|