@arbiterforge/ca-pi 0.15.12 → 0.16.1
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 +33 -0
- package/plugins/ca-pi/SKILLS.md +1 -1
- package/plugins/ca-pi/agents/infra-author.md +7 -1
- package/plugins/ca-pi/agents/scout.md +40 -8
- package/plugins/ca-pi/generated/command-catalog.json +1 -1
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-amd64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-arm64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-amd64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-arm64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-amd64.exe +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-arm64.exe +0 -0
- package/plugins/ca-pi/helpers/artifacts/release.json +104 -6
- package/plugins/ca-pi/hooks/_activationlib.py +97 -13
- package/plugins/ca-pi/hooks/_arbiterstatelib.py +7 -2
- package/plugins/ca-pi/hooks/_artifactauthoritylib.py +313 -76
- package/plugins/ca-pi/hooks/_artifactlib.py +166 -2
- package/plugins/ca-pi/hooks/_artifactpromptlib.py +106 -2
- package/plugins/ca-pi/hooks/_bashguardlib.py +14 -0
- package/plugins/ca-pi/hooks/_contextreportlib.py +564 -6
- package/plugins/ca-pi/hooks/_contextselectlib.py +464 -0
- package/plugins/ca-pi/hooks/_contextsnapshotlib.py +999 -0
- package/plugins/ca-pi/hooks/_debughandofflib.py +767 -0
- package/plugins/ca-pi/hooks/_hooklib.py +1 -0
- package/plugins/ca-pi/hooks/_host.py +1 -1
- package/plugins/ca-pi/hooks/_intentlib.py +5 -5
- package/plugins/ca-pi/hooks/_protectedlib.py +3 -3
- package/plugins/ca-pi/hooks/_protectedstatelib.py +152 -2
- package/plugins/ca-pi/hooks/_provenancelib.py +604 -34
- package/plugins/ca-pi/hooks/_readinjectlib.py +131 -1
- package/plugins/ca-pi/hooks/artifact-authority.py +20 -1
- package/plugins/ca-pi/hooks/debug-handoff.py +85 -0
- package/plugins/ca-pi/hooks/doctor.py +4 -2
- package/plugins/ca-pi/hooks/hostapi.py +1 -1
- package/plugins/ca-pi/hooks/init-codearbiter.py +143 -15
- package/plugins/ca-pi/hooks/prompt-submit.py +14 -0
- package/plugins/ca-pi/hooks/session-start.py +26 -7
- package/plugins/ca-pi/includes/author-tdd-workflow.md +16 -4
- package/plugins/ca-pi/includes/helper-invocation.md +128 -0
- package/plugins/ca-pi/includes/pi-host-notes.md +13 -0
- package/plugins/ca-pi/includes/routing-table.md +1 -1
- package/plugins/ca-pi/package.json +1 -1
- package/plugins/ca-pi/routines/INDEX.md +1 -1
- package/plugins/ca-pi/routines/commit-gate/SKILL.md +5 -3
- package/plugins/ca-pi/routines/context-check/SKILL.md +103 -17
- package/plugins/ca-pi/routines/context-creation/SKILL.md +290 -54
- package/plugins/ca-pi/routines/debug/SKILL.md +303 -55
- package/plugins/ca-pi/routines/debug/references/debug-handoff.schema.json +879 -0
- package/plugins/ca-pi/routines/debug/references/handoff.md +155 -0
- package/plugins/ca-pi/routines/decompose/SKILL.md +13 -0
- package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +34 -0
- package/plugins/ca-pi/routines/tdd/SKILL.md +91 -17
- package/plugins/ca-pi/skills/ca-commit/SKILL.md +5 -3
- package/plugins/ca-pi/skills/ca-context-check/SKILL.md +103 -17
- package/plugins/ca-pi/skills/ca-create-context/SKILL.md +290 -54
- package/plugins/ca-pi/skills/ca-debug/SKILL.md +303 -55
- package/plugins/ca-pi/skills/ca-decompose/SKILL.md +13 -0
- package/plugins/ca-pi/skills/ca-feature/SKILL.md +1 -1
- package/plugins/ca-pi/skills/ca-fix/SKILL.md +36 -3
- package/plugins/ca-pi/skills/ca-init/SKILL.md +19 -0
- package/plugins/ca-pi/skills/ca-review/SKILL.md +33 -4
- package/plugins/ca-pi/skills/ca-status/SKILL.md +5 -0
- package/plugins/ca-pi/skills/ca-task/SKILL.md +55 -8
|
@@ -8,6 +8,9 @@ run_verification(...) -> dict
|
|
|
8
8
|
observe_codex_hook(...) -> dict
|
|
9
9
|
observe_claude_hook(...) -> dict
|
|
10
10
|
publish_request(...) -> dict
|
|
11
|
+
arm_context_preview(...) -> dict
|
|
12
|
+
cancel_context_preview(...) -> dict
|
|
13
|
+
capture_context_preview(...) -> dict
|
|
11
14
|
|
|
12
15
|
The artifact engine owns every target binding. Callers select only an activity
|
|
13
16
|
and record; they cannot supply an authority kind, verdict, evidence payload, or
|
|
@@ -26,7 +29,7 @@ than one SubagentStop; only the first is a review result.
|
|
|
26
29
|
|
|
27
30
|
from __future__ import annotations
|
|
28
31
|
|
|
29
|
-
from contextlib import contextmanager
|
|
32
|
+
from contextlib import ExitStack, contextmanager
|
|
30
33
|
import base64
|
|
31
34
|
import errno
|
|
32
35
|
import hashlib
|
|
@@ -45,6 +48,8 @@ import time
|
|
|
45
48
|
from typing import Any
|
|
46
49
|
|
|
47
50
|
from _gitexec import git_executable, root_bound_git_env
|
|
51
|
+
import _artifactpromptlib
|
|
52
|
+
import _artifactlib
|
|
48
53
|
|
|
49
54
|
|
|
50
55
|
OBSERVATION_DIR = Path(".codearbiter/.artifacts/observations")
|
|
@@ -72,6 +77,9 @@ MAX_STATE = 1 << 20
|
|
|
72
77
|
# Only terminal abandonment can read a legacy inline-context review this large.
|
|
73
78
|
MAX_RECOVERY_STATE = 2 << 20
|
|
74
79
|
REQUEST_LOCK_WAIT_SECONDS = 5.0
|
|
80
|
+
# The Claude join lock is per user, not per request: every SubagentStart waits
|
|
81
|
+
# on it while any review is LAUNCHING, and the holder scans the registry.
|
|
82
|
+
CLAUDE_JOIN_LOCK_WAIT_SECONDS = 30.0
|
|
75
83
|
MAX_OUTPUT = 8 << 20
|
|
76
84
|
MAX_DECISION = 65536
|
|
77
85
|
MAX_COMPLETION_FILES = 16
|
|
@@ -79,6 +87,15 @@ MAX_COMPLETION_FILE_BYTES = 64 << 10
|
|
|
79
87
|
MAX_COMPLETION_TOTAL_BYTES = 256 << 10
|
|
80
88
|
REGISTRY_PARENT = Path(tempfile.gettempdir())
|
|
81
89
|
HOSTS = frozenset({"codex", "claude"})
|
|
90
|
+
CONTEXT_ROUTE = "context_approval"
|
|
91
|
+
CONTEXT_PREVIEW_KEYS = frozenset({
|
|
92
|
+
"operation_id", "mode", "document_id", "target_path", "typed",
|
|
93
|
+
"selections", "expected_document_sha256", "expected_provenance_sha256",
|
|
94
|
+
"proposed_provenance",
|
|
95
|
+
})
|
|
96
|
+
CONTEXT_FINALIZE_KEYS = frozenset({
|
|
97
|
+
"operation_id", "mode", "document_id", "target_path", "expected",
|
|
98
|
+
})
|
|
82
99
|
CLAUDE_REVIEWER = "ca:authority-reviewer"
|
|
83
100
|
# Claude Code 2.1.286 children report through this tool; their SubagentStop
|
|
84
101
|
# then carries no last_assistant_message.
|
|
@@ -124,6 +141,40 @@ def _canonical(value: Any) -> bytes:
|
|
|
124
141
|
raise AuthorityError("INVALID_AUTHORITY_STATE", "state is not finite JSON") from exc
|
|
125
142
|
|
|
126
143
|
|
|
144
|
+
def _native_context_canonical(value: Any) -> bytes:
|
|
145
|
+
"""Match the artifact engine's safe-integer, UTF-16-key-order JSON bytes."""
|
|
146
|
+
def ordered(item: Any, depth: int) -> Any:
|
|
147
|
+
if depth > 64:
|
|
148
|
+
raise ValueError("context exceeds native depth")
|
|
149
|
+
if isinstance(item, dict):
|
|
150
|
+
if any(type(key) is not str for key in item):
|
|
151
|
+
raise ValueError("context has a non-string key")
|
|
152
|
+
return {
|
|
153
|
+
key: ordered(item[key], depth + 1)
|
|
154
|
+
for key in sorted(item, key=lambda key: key.encode("utf-16-be"))
|
|
155
|
+
}
|
|
156
|
+
if isinstance(item, list):
|
|
157
|
+
return [ordered(value, depth + 1) for value in item]
|
|
158
|
+
if type(item) is int:
|
|
159
|
+
if abs(item) > 9007199254740991:
|
|
160
|
+
raise ValueError("context integer exceeds native range")
|
|
161
|
+
return item
|
|
162
|
+
if item is None or type(item) in (str, bool):
|
|
163
|
+
return item
|
|
164
|
+
raise ValueError("context value is outside native JSON")
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
raw = json.dumps(
|
|
168
|
+
ordered(value, 0), ensure_ascii=False, allow_nan=False,
|
|
169
|
+
separators=(",", ":"),
|
|
170
|
+
).encode("utf-8")
|
|
171
|
+
if len(raw) > 8 << 20:
|
|
172
|
+
raise ValueError("context exceeds native byte limit")
|
|
173
|
+
return raw
|
|
174
|
+
except (TypeError, ValueError, UnicodeError, RecursionError) as exc:
|
|
175
|
+
raise AuthorityError("INVALID_EVIDENCE_CONTEXT", "context is outside native canonical JSON") from exc
|
|
176
|
+
|
|
177
|
+
|
|
127
178
|
def _digest(data: bytes) -> str:
|
|
128
179
|
return hashlib.sha256(data).hexdigest()
|
|
129
180
|
|
|
@@ -277,7 +328,7 @@ def _spool_root(root: Path) -> Path:
|
|
|
277
328
|
|
|
278
329
|
|
|
279
330
|
@contextmanager
|
|
280
|
-
def _request_lock(spool: Path, relative: Path):
|
|
331
|
+
def _request_lock(spool: Path, relative: Path, wait: float | None = None):
|
|
281
332
|
"""Keep one OS-owned lock inode for all writers of this request."""
|
|
282
333
|
path = spool / f"{relative.name}.lock"
|
|
283
334
|
fd = None
|
|
@@ -307,7 +358,7 @@ def _request_lock(spool: Path, relative: Path):
|
|
|
307
358
|
locked = False
|
|
308
359
|
try:
|
|
309
360
|
check_path()
|
|
310
|
-
deadline = time.monotonic() + REQUEST_LOCK_WAIT_SECONDS
|
|
361
|
+
deadline = time.monotonic() + (REQUEST_LOCK_WAIT_SECONDS if wait is None else wait)
|
|
311
362
|
if os.name == "nt":
|
|
312
363
|
import msvcrt
|
|
313
364
|
else:
|
|
@@ -479,7 +530,7 @@ def _read_context(root: Path, context_ref: str, context_hash: str) -> dict[str,
|
|
|
479
530
|
context = json.loads(raw.decode("utf-8"))
|
|
480
531
|
except (OSError, UnicodeError, ValueError) as exc:
|
|
481
532
|
raise AuthorityError("INVALID_EVIDENCE_CONTEXT", "context bytes are unavailable") from exc
|
|
482
|
-
if not isinstance(context, dict) or _digest(raw) != context_hash or raw !=
|
|
533
|
+
if not isinstance(context, dict) or _digest(raw) != context_hash or raw != _native_context_canonical(context):
|
|
483
534
|
raise AuthorityError("INVALID_EVIDENCE_CONTEXT", "context bytes changed or are not canonical")
|
|
484
535
|
return context
|
|
485
536
|
|
|
@@ -2097,7 +2148,7 @@ def capture_user_prompt(
|
|
|
2097
2148
|
raise AuthorityError("INVALID_EVIDENCE_CONTEXT", "prompt context is unavailable") from exc
|
|
2098
2149
|
if (
|
|
2099
2150
|
_digest(context_raw) != context_sha256
|
|
2100
|
-
or context_raw !=
|
|
2151
|
+
or context_raw != _native_context_canonical(context)
|
|
2101
2152
|
or context.get("activity") != event["kind"]
|
|
2102
2153
|
or context.get("subject") != subject
|
|
2103
2154
|
or context.get("prompt_sha256") != prompt_sha256
|
|
@@ -2139,6 +2190,162 @@ def capture_user_prompt(
|
|
|
2139
2190
|
}
|
|
2140
2191
|
|
|
2141
2192
|
|
|
2193
|
+
def _context_preview_context(root: Path, client: Any, preview: dict[str, Any], prompt: str):
|
|
2194
|
+
operation = ("context-finalize-evidence-context" if preview.get("mode") == "finalize"
|
|
2195
|
+
else "context-evidence-context")
|
|
2196
|
+
result = client.call(operation, {**preview, "prompt_sha256": _digest(prompt.encode("utf-8"))})
|
|
2197
|
+
if not isinstance(result, dict):
|
|
2198
|
+
raise AuthorityError("INVALID_CONTEXT_AUTHORITY", "context preview issuer returned no context")
|
|
2199
|
+
context_hash = _validate_hash(result.get("context_sha256"), "context_sha256")
|
|
2200
|
+
context_ref = f".codearbiter/.artifacts/evidence-contexts/{context_hash}.json"
|
|
2201
|
+
if result.get("context_ref") != context_ref:
|
|
2202
|
+
raise AuthorityError("INVALID_CONTEXT_AUTHORITY", "context preview locator is malformed")
|
|
2203
|
+
try:
|
|
2204
|
+
raw = (root / context_ref).read_bytes()
|
|
2205
|
+
context = json.loads(raw.decode("utf-8"))
|
|
2206
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
2207
|
+
raise AuthorityError("INVALID_CONTEXT_AUTHORITY", "context preview issuer bytes are unavailable") from exc
|
|
2208
|
+
binding = result.get("preview_binding_sha256")
|
|
2209
|
+
_validate_hash(binding, "preview_binding_sha256")
|
|
2210
|
+
subject = {"artifact_id": "CONTEXT-" + preview["document_id"].upper(),
|
|
2211
|
+
"record_id": "CONTEXT-" + preview["document_id"].upper(),
|
|
2212
|
+
"normative_sha256": binding}
|
|
2213
|
+
if (
|
|
2214
|
+
_digest(raw) != context_hash or raw != _canonical(context)
|
|
2215
|
+
or context.get("activity") != CONTEXT_ROUTE
|
|
2216
|
+
or context.get("preview") != preview
|
|
2217
|
+
or context.get("subject") != subject
|
|
2218
|
+
or context.get("record") != {"preview_binding_sha256": binding}
|
|
2219
|
+
or context.get("record_sha256") != _digest(_canonical(context.get("record")))
|
|
2220
|
+
or context.get("input_sha256") != binding
|
|
2221
|
+
or context.get("prompt_sha256") != _digest(prompt.encode("utf-8"))
|
|
2222
|
+
or result.get("subject") != subject
|
|
2223
|
+
or not isinstance(context.get("preview_document"), str)
|
|
2224
|
+
or not context["preview_document"]
|
|
2225
|
+
or _digest(context["preview_document"].encode("utf-8")) != context.get("after_document_sha256")
|
|
2226
|
+
or result.get("preview_document") != context["preview_document"]
|
|
2227
|
+
or result.get("after_document_sha256") != context["after_document_sha256"]
|
|
2228
|
+
):
|
|
2229
|
+
raise AuthorityError("INVALID_CONTEXT_AUTHORITY", "issued context does not bind the exact preview")
|
|
2230
|
+
return context, context_ref, context_hash, binding
|
|
2231
|
+
|
|
2232
|
+
|
|
2233
|
+
def arm_context_preview(root: str | Path, client: Any, preview: dict[str, Any], *, token: str | None = None) -> dict[str, Any]:
|
|
2234
|
+
"""Arm an exact preview reply; this step does not assert approval."""
|
|
2235
|
+
root = _real_root(root)
|
|
2236
|
+
valid_content = (isinstance(preview, dict) and set(preview) == CONTEXT_PREVIEW_KEYS
|
|
2237
|
+
and isinstance(preview.get("mode"), str)
|
|
2238
|
+
and preview.get("mode") in {"create", "adopt", "update"}
|
|
2239
|
+
and isinstance(preview.get("document_id"), str)
|
|
2240
|
+
and preview.get("document_id") in {"CONTEXT", "tech-stack", "coding-standards", "security-controls", "code-map"})
|
|
2241
|
+
valid_final = (isinstance(preview, dict) and set(preview) == CONTEXT_FINALIZE_KEYS
|
|
2242
|
+
and preview.get("mode") == "finalize" and preview.get("document_id") == "CONTEXT"
|
|
2243
|
+
and preview.get("target_path") == ".codearbiter/CONTEXT.md"
|
|
2244
|
+
and isinstance(preview.get("expected"), dict))
|
|
2245
|
+
if not (valid_content or valid_final):
|
|
2246
|
+
raise AuthorityError("INVALID_CONTEXT_AUTHORITY", "context preview request contains unsupported or authority fields")
|
|
2247
|
+
token = token or secrets.token_urlsafe(24)
|
|
2248
|
+
if TOKEN_RE.fullmatch(token) is None:
|
|
2249
|
+
raise AuthorityError("INVALID_CONTEXT_AUTHORITY", "context preview token is invalid")
|
|
2250
|
+
artifact_id = "CONTEXT-" + preview["document_id"].upper()
|
|
2251
|
+
prompt = f"approve-context {artifact_id} {token}"
|
|
2252
|
+
context, context_ref, context_hash, binding = _context_preview_context(root, client, preview, prompt)
|
|
2253
|
+
_artifactpromptlib.register(root, CONTEXT_ROUTE, artifact_id, prompt, binding_sha256=context_hash)
|
|
2254
|
+
return {"reply": prompt, "artifact_id": artifact_id, "preview_binding_sha256": binding,
|
|
2255
|
+
"context_ref": context_ref, "context_sha256": context_hash,
|
|
2256
|
+
"target_path": preview["target_path"], "preview_document": context["preview_document"],
|
|
2257
|
+
"after_document_sha256": context["after_document_sha256"]}
|
|
2258
|
+
|
|
2259
|
+
|
|
2260
|
+
def cancel_context_preview(root: str | Path, document_id: str) -> dict[str, Any]:
|
|
2261
|
+
"""Retire one pending context reply without altering a document or receipt."""
|
|
2262
|
+
if document_id not in {"CONTEXT", "tech-stack", "coding-standards", "security-controls", "code-map"}:
|
|
2263
|
+
raise AuthorityError("INVALID_CONTEXT_AUTHORITY", "unknown context document")
|
|
2264
|
+
artifact_id = "CONTEXT-" + document_id.upper()
|
|
2265
|
+
_artifactpromptlib.unregister(_real_root(root), CONTEXT_ROUTE, artifact_id)
|
|
2266
|
+
return {"artifact_id": artifact_id, "cancelled": True}
|
|
2267
|
+
|
|
2268
|
+
|
|
2269
|
+
def capture_context_preview(root: str | Path, client: Any, prompt: str, *, host: str, session_id: str) -> dict[str, Any]:
|
|
2270
|
+
"""Capture an exact host UserPromptSubmit reply for one armed context preview."""
|
|
2271
|
+
root = _real_root(root)
|
|
2272
|
+
if not isinstance(prompt, str):
|
|
2273
|
+
raise AuthorityError("INVALID_CONTEXT_AUTHORITY", "context preview reply is malformed")
|
|
2274
|
+
parts = prompt.split(" ")
|
|
2275
|
+
if (len(parts) != 3 or parts[0] != "approve-context" or ID_RE.fullmatch(parts[1]) is None
|
|
2276
|
+
or TOKEN_RE.fullmatch(parts[2]) is None):
|
|
2277
|
+
raise AuthorityError("INVALID_CONTEXT_AUTHORITY", "context preview reply is unrelated")
|
|
2278
|
+
artifact_id = parts[1]
|
|
2279
|
+
routed = _artifactpromptlib.resolve(CONTEXT_ROUTE, prompt)
|
|
2280
|
+
if routed != root:
|
|
2281
|
+
raise AuthorityError("INVALID_CONTEXT_AUTHORITY", "context preview reply has no armed route")
|
|
2282
|
+
context_hash = _artifactpromptlib.binding(root, CONTEXT_ROUTE, artifact_id)
|
|
2283
|
+
context_ref = f".codearbiter/.artifacts/evidence-contexts/{context_hash}.json"
|
|
2284
|
+
try:
|
|
2285
|
+
raw = (root / context_ref).read_bytes()
|
|
2286
|
+
context = json.loads(raw.decode("utf-8"))
|
|
2287
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
2288
|
+
raise AuthorityError("INVALID_CONTEXT_AUTHORITY", "armed preview context is unavailable") from exc
|
|
2289
|
+
preview = context.get("preview") if isinstance(context, dict) else None
|
|
2290
|
+
if (not isinstance(preview, dict) or set(preview) not in
|
|
2291
|
+
(CONTEXT_PREVIEW_KEYS, CONTEXT_FINALIZE_KEYS)):
|
|
2292
|
+
raise AuthorityError("INVALID_CONTEXT_AUTHORITY", "armed preview context is malformed")
|
|
2293
|
+
fresh, fresh_ref, fresh_hash, binding = _context_preview_context(root, client, preview, prompt)
|
|
2294
|
+
if fresh_ref != context_ref or fresh_hash != context_hash or fresh != context or _digest(raw) != context_hash:
|
|
2295
|
+
raise AuthorityError("STALE_CONTEXT_AUTHORITY", "context preview changed after it was armed")
|
|
2296
|
+
producer_run_id = f"{_host_id(host, 'host')}:{_host_id(session_id, 'session_id')}"
|
|
2297
|
+
producer_result = {"host": host, "session_id": session_id,
|
|
2298
|
+
"prompt_sha256": _digest(prompt.encode("utf-8"))}
|
|
2299
|
+
payload = {"preview_binding_sha256": binding}
|
|
2300
|
+
observation = {
|
|
2301
|
+
"format": OBSERVATION_FORMAT, "kind": CONTEXT_ROUTE,
|
|
2302
|
+
"subject": context["subject"], "context_ref": context_ref,
|
|
2303
|
+
"context_sha256": context_hash, "payload_sha256": _digest(_canonical(payload)),
|
|
2304
|
+
"producer_profile": "host-user-context-preview/0.1.0", "producer_run_id": producer_run_id,
|
|
2305
|
+
"producer_result": producer_result, "producer_result_sha256": _digest(_canonical(producer_result)),
|
|
2306
|
+
}
|
|
2307
|
+
observation_raw = _canonical(observation)
|
|
2308
|
+
observation_hash = _digest(observation_raw)
|
|
2309
|
+
observation_ref = OBSERVATION_DIR / f"{observation_hash}.json"
|
|
2310
|
+
_publish_immutable(root, observation_ref, observation_raw)
|
|
2311
|
+
event = {
|
|
2312
|
+
"format": EVENT_FORMAT, "kind": CONTEXT_ROUTE, "authority_kind": "user_workflow",
|
|
2313
|
+
"subject": context["subject"], "actor": "interactive repository user",
|
|
2314
|
+
"origin": f"{host}:UserPromptSubmit:{session_id}", "verdict": "approved",
|
|
2315
|
+
"payload": payload, "source_text": prompt,
|
|
2316
|
+
"observation_ref": observation_ref.as_posix(), "observation_sha256": observation_hash,
|
|
2317
|
+
}
|
|
2318
|
+
event_raw = _canonical(event)
|
|
2319
|
+
event_hash = _digest(event_raw)
|
|
2320
|
+
source_ref = SOURCE_DIR / f"{event_hash}.json"
|
|
2321
|
+
_publish_immutable(root, source_ref, event_raw)
|
|
2322
|
+
captured = client.call("capture-observation", {"source_ref": source_ref.as_posix(), "source_sha256": event_hash})
|
|
2323
|
+
if not isinstance(captured, dict) or not isinstance(captured.get("receipt"), str):
|
|
2324
|
+
raise AuthorityError("INVALID_RECEIPT", "context preview capture returned no receipt")
|
|
2325
|
+
_artifactpromptlib.unregister(root, CONTEXT_ROUTE, artifact_id)
|
|
2326
|
+
return {**captured, "preview_binding_sha256": binding,
|
|
2327
|
+
"authority_source": source_ref.as_posix(), "observation_ref": observation_ref.as_posix()}
|
|
2328
|
+
|
|
2329
|
+
|
|
2330
|
+
def capture_context_preview_from_hook(*, root: str | Path, plugin_root: str | Path,
|
|
2331
|
+
prompt: str, host: str, session_id: str) -> str:
|
|
2332
|
+
"""Route only an armed exact context reply from the host prompt seam."""
|
|
2333
|
+
if not isinstance(prompt, str) or not prompt.startswith("approve-context "):
|
|
2334
|
+
return ""
|
|
2335
|
+
try:
|
|
2336
|
+
routed = _artifactpromptlib.resolve(CONTEXT_ROUTE, prompt)
|
|
2337
|
+
if routed is None:
|
|
2338
|
+
return ""
|
|
2339
|
+
client = _artifactlib.ArtifactClient(
|
|
2340
|
+
routed, Path(plugin_root) / "helpers" / "artifacts"
|
|
2341
|
+
)
|
|
2342
|
+
result = capture_context_preview(routed, client, prompt, host=host, session_id=session_id)
|
|
2343
|
+
except (AuthorityError, _artifactpromptlib.PromptRouteError, OSError, RuntimeError) as exc:
|
|
2344
|
+
return f"codeArbiter: context preview approval capture failed: {exc}"
|
|
2345
|
+
return ("codeArbiter: context preview approval recorded for "
|
|
2346
|
+
f"{result['preview_binding_sha256']} (receipt {result['receipt']}).")
|
|
2347
|
+
|
|
2348
|
+
|
|
2142
2349
|
def run_verification(
|
|
2143
2350
|
root: str | Path,
|
|
2144
2351
|
client: Any,
|
|
@@ -3012,6 +3219,91 @@ def _claude_bind_child(root: Path, request: dict[str, Any]) -> dict[str, Any]:
|
|
|
3012
3219
|
return {"request_id": request["request_id"], "state": "RUNNING"}
|
|
3013
3220
|
|
|
3014
3221
|
|
|
3222
|
+
@contextmanager
|
|
3223
|
+
def _claude_join_lock():
|
|
3224
|
+
"""Serialize the Claude launch/child join across hook processes.
|
|
3225
|
+
|
|
3226
|
+
SubagentStart and an async Agent launch result fire back to back as
|
|
3227
|
+
separate hook processes. Each reads the request and then writes its half
|
|
3228
|
+
of the join; unserialized, either can read before the other writes and
|
|
3229
|
+
both miss, leaving the review LAUNCHING with no start marker."""
|
|
3230
|
+
with ExitStack() as stack:
|
|
3231
|
+
try:
|
|
3232
|
+
stack.enter_context(
|
|
3233
|
+
_request_lock(_registry_root(), Path("claude-join"), wait=CLAUDE_JOIN_LOCK_WAIT_SECONDS)
|
|
3234
|
+
)
|
|
3235
|
+
except OSError as exc:
|
|
3236
|
+
# Only acquisition maps here; the joined body reports its own errors.
|
|
3237
|
+
raise AuthorityError("AUTHORITY_BUSY", "Claude review join is unavailable") from exc
|
|
3238
|
+
yield
|
|
3239
|
+
|
|
3240
|
+
|
|
3241
|
+
def _claude_launch_result(event: dict[str, Any]) -> dict[str, Any] | None:
|
|
3242
|
+
session_id = event.get("session_id")
|
|
3243
|
+
tool_use_id = event.get("tool_use_id")
|
|
3244
|
+
matches = [
|
|
3245
|
+
(candidate_root, candidate)
|
|
3246
|
+
for candidate_root, candidate in _claude_requests({"LAUNCHING"})
|
|
3247
|
+
if (candidate.get("launch") or {}).get("tool_use_id") == tool_use_id
|
|
3248
|
+
and candidate["launch"].get("parent_session_id") == session_id
|
|
3249
|
+
]
|
|
3250
|
+
if not matches:
|
|
3251
|
+
return None
|
|
3252
|
+
if len(matches) != 1:
|
|
3253
|
+
raise AuthorityError("UNSUPPORTED_HOST_SEAM", "review launch correlation is ambiguous")
|
|
3254
|
+
root, request = matches[0]
|
|
3255
|
+
response = event.get("tool_response")
|
|
3256
|
+
try:
|
|
3257
|
+
prompt_id = _host_id(event.get("prompt_id"), "prompt_id")
|
|
3258
|
+
if request["launch"].get("parent_prompt_id") != prompt_id or request["launch"]["post_confirmed"]:
|
|
3259
|
+
raise AuthorityError("UNSUPPORTED_HOST_SEAM", "review launch result is out of order")
|
|
3260
|
+
if not isinstance(response, dict):
|
|
3261
|
+
raise AuthorityError("UNSUPPORTED_HOST_SEAM", "agent result is malformed")
|
|
3262
|
+
agent_id = _host_id(response.get("agentId"), "agentId")
|
|
3263
|
+
resolved = _model_id(response.get("resolvedModel"), "resolvedModel")
|
|
3264
|
+
except AuthorityError:
|
|
3265
|
+
_reject(root, request, "uncorrelated-launch-result")
|
|
3266
|
+
raise
|
|
3267
|
+
request["launch"].update(post_confirmed=True, agent_id=agent_id, resolved_model=resolved)
|
|
3268
|
+
return _claude_bind_child(root, request)
|
|
3269
|
+
|
|
3270
|
+
|
|
3271
|
+
def _claude_subagent_start(event: dict[str, Any]) -> dict[str, Any] | None:
|
|
3272
|
+
agent_id = _host_id(event.get("agent_id"), "agent_id")
|
|
3273
|
+
agent_type = _host_id(event.get("agent_type"), "agent_type")
|
|
3274
|
+
pending = _claude_requests({"LAUNCHING"})
|
|
3275
|
+
for candidate_root, candidate in pending:
|
|
3276
|
+
launch = candidate.get("launch") or {}
|
|
3277
|
+
if launch.get("agent_id") == agent_id:
|
|
3278
|
+
if agent_type != CLAUDE_REVIEWER:
|
|
3279
|
+
_reject(candidate_root, candidate, "unpinned-reviewer-agent")
|
|
3280
|
+
raise AuthorityError("UNSUPPORTED_HOST_SEAM", "subagent is not the pinned reviewer")
|
|
3281
|
+
launch["agent_type"] = agent_type
|
|
3282
|
+
candidate["state"] = "RUNNING"
|
|
3283
|
+
_save(candidate_root, candidate)
|
|
3284
|
+
return {"request_id": candidate["request_id"], "state": "RUNNING"}
|
|
3285
|
+
if any((candidate.get("launch") or {}).get("post_confirmed") is False for _root, candidate in pending):
|
|
3286
|
+
# The launch result may not have arrived yet; remember this start.
|
|
3287
|
+
marker = _claude_start_marker(agent_id)
|
|
3288
|
+
data = _canonical({"agent_id": agent_id, "agent_type": agent_type})
|
|
3289
|
+
# Written aside and linked into place: a reader must never see an
|
|
3290
|
+
# empty or partial marker.
|
|
3291
|
+
temporary = marker.with_name(f".{marker.name}.{secrets.token_hex(8)}.tmp")
|
|
3292
|
+
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
3293
|
+
try:
|
|
3294
|
+
os.write(fd, data)
|
|
3295
|
+
os.fsync(fd)
|
|
3296
|
+
finally:
|
|
3297
|
+
os.close(fd)
|
|
3298
|
+
try:
|
|
3299
|
+
os.link(temporary, marker)
|
|
3300
|
+
except FileExistsError:
|
|
3301
|
+
return None
|
|
3302
|
+
finally:
|
|
3303
|
+
temporary.unlink(missing_ok=True)
|
|
3304
|
+
return None
|
|
3305
|
+
|
|
3306
|
+
|
|
3015
3307
|
def observe_claude_hook(root: str | Path, event: dict[str, Any]) -> dict[str, Any] | None:
|
|
3016
3308
|
"""Correlate a Claude Code reviewer launch, child lifecycle, and first result.
|
|
3017
3309
|
|
|
@@ -3071,77 +3363,14 @@ def observe_claude_hook(root: str | Path, event: dict[str, Any]) -> dict[str, An
|
|
|
3071
3363
|
return {"request_id": request["request_id"], "state": "LAUNCHING"}
|
|
3072
3364
|
|
|
3073
3365
|
if name == "PostToolUse" and tool == "Agent":
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
matches = [
|
|
3077
|
-
(candidate_root, candidate)
|
|
3078
|
-
for candidate_root, candidate in _claude_requests({"LAUNCHING"})
|
|
3079
|
-
if (candidate.get("launch") or {}).get("tool_use_id") == tool_use_id
|
|
3080
|
-
and candidate["launch"].get("parent_session_id") == session_id
|
|
3081
|
-
]
|
|
3082
|
-
if not matches:
|
|
3083
|
-
return None
|
|
3084
|
-
if len(matches) != 1:
|
|
3085
|
-
raise AuthorityError("UNSUPPORTED_HOST_SEAM", "review launch correlation is ambiguous")
|
|
3086
|
-
root, request = matches[0]
|
|
3087
|
-
response = event.get("tool_response")
|
|
3088
|
-
try:
|
|
3089
|
-
prompt_id = _host_id(event.get("prompt_id"), "prompt_id")
|
|
3090
|
-
if request["launch"].get("parent_prompt_id") != prompt_id or request["launch"]["post_confirmed"]:
|
|
3091
|
-
raise AuthorityError("UNSUPPORTED_HOST_SEAM", "review launch result is out of order")
|
|
3092
|
-
if not isinstance(response, dict):
|
|
3093
|
-
raise AuthorityError("UNSUPPORTED_HOST_SEAM", "agent result is malformed")
|
|
3094
|
-
agent_id = _host_id(response.get("agentId"), "agentId")
|
|
3095
|
-
resolved = _model_id(response.get("resolvedModel"), "resolvedModel")
|
|
3096
|
-
except AuthorityError:
|
|
3097
|
-
_reject(root, request, "uncorrelated-launch-result")
|
|
3098
|
-
raise
|
|
3099
|
-
request["launch"].update(post_confirmed=True, agent_id=agent_id, resolved_model=resolved)
|
|
3100
|
-
return _claude_bind_child(root, request)
|
|
3366
|
+
with _claude_join_lock():
|
|
3367
|
+
return _claude_launch_result(event)
|
|
3101
3368
|
|
|
3102
3369
|
if name == "SubagentStart":
|
|
3103
3370
|
if not _claude_requests({"LAUNCHING"}):
|
|
3104
3371
|
return None
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
for candidate_root, candidate in _claude_requests({"LAUNCHING"}):
|
|
3108
|
-
launch = candidate.get("launch") or {}
|
|
3109
|
-
if launch.get("agent_id") == agent_id:
|
|
3110
|
-
if agent_type != CLAUDE_REVIEWER:
|
|
3111
|
-
_reject(candidate_root, candidate, "unpinned-reviewer-agent")
|
|
3112
|
-
raise AuthorityError("UNSUPPORTED_HOST_SEAM", "subagent is not the pinned reviewer")
|
|
3113
|
-
launch["agent_type"] = agent_type
|
|
3114
|
-
candidate["state"] = "RUNNING"
|
|
3115
|
-
_save(candidate_root, candidate)
|
|
3116
|
-
return {"request_id": candidate["request_id"], "state": "RUNNING"}
|
|
3117
|
-
if any(
|
|
3118
|
-
(candidate.get("launch") or {}).get("post_confirmed") is False
|
|
3119
|
-
for _root, candidate in _claude_requests({"LAUNCHING"})
|
|
3120
|
-
):
|
|
3121
|
-
# The launch result may not have arrived yet; remember this start.
|
|
3122
|
-
marker = _claude_start_marker(agent_id)
|
|
3123
|
-
data = _canonical({"agent_id": agent_id, "agent_type": agent_type})
|
|
3124
|
-
# Written aside and linked into place: a concurrent launch result
|
|
3125
|
-
# joining this agent must never read an empty or partial marker.
|
|
3126
|
-
temporary = marker.with_name(f".{marker.name}.{secrets.token_hex(8)}.tmp")
|
|
3127
|
-
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
3128
|
-
try:
|
|
3129
|
-
os.write(fd, data)
|
|
3130
|
-
os.fsync(fd)
|
|
3131
|
-
finally:
|
|
3132
|
-
os.close(fd)
|
|
3133
|
-
try:
|
|
3134
|
-
os.link(temporary, marker)
|
|
3135
|
-
except FileExistsError:
|
|
3136
|
-
return None
|
|
3137
|
-
finally:
|
|
3138
|
-
temporary.unlink(missing_ok=True)
|
|
3139
|
-
# The launch result may have bound this agent while the marker was
|
|
3140
|
-
# being written; finish the join now rather than strand it.
|
|
3141
|
-
for candidate_root, candidate in _claude_requests({"LAUNCHING"}):
|
|
3142
|
-
if (candidate.get("launch") or {}).get("agent_id") == agent_id:
|
|
3143
|
-
return _claude_bind_child(candidate_root, candidate)
|
|
3144
|
-
return None
|
|
3372
|
+
with _claude_join_lock():
|
|
3373
|
+
return _claude_subagent_start(event)
|
|
3145
3374
|
|
|
3146
3375
|
if name == "PostToolUse" and tool == CLAUDE_HANDBACK_TOOL:
|
|
3147
3376
|
# Every child's handback lands here; only the bound reviewer's counts.
|
|
@@ -3172,8 +3401,12 @@ def observe_claude_hook(root: str | Path, event: dict[str, Any]) -> dict[str, An
|
|
|
3172
3401
|
_reject(root, request, "repeated-handback")
|
|
3173
3402
|
raise AuthorityError("UNSUPPORTED_HOST_SEAM", "reviewer reported more than once")
|
|
3174
3403
|
if request["state"] == "LAUNCHING" and request["launch"].get("post_confirmed") is True:
|
|
3175
|
-
#
|
|
3176
|
-
|
|
3404
|
+
# Defensive: the join lock leaves no late-marker race in this
|
|
3405
|
+
# adapter, but bind any marker still waiting under the same lock.
|
|
3406
|
+
with _claude_join_lock():
|
|
3407
|
+
current = _load(root, request["request_id"])
|
|
3408
|
+
if current["state"] == "LAUNCHING":
|
|
3409
|
+
_claude_bind_child(root, current)
|
|
3177
3410
|
request = _load(root, request["request_id"])
|
|
3178
3411
|
candidate = {**request, "handback": record}
|
|
3179
3412
|
if (
|
|
@@ -3210,8 +3443,12 @@ def observe_claude_hook(root: str | Path, event: dict[str, Any]) -> dict[str, An
|
|
|
3210
3443
|
root, request = matches[0]
|
|
3211
3444
|
launch = request["launch"]
|
|
3212
3445
|
if request["state"] == "LAUNCHING" and launch.get("post_confirmed") is True:
|
|
3213
|
-
#
|
|
3214
|
-
|
|
3446
|
+
# Defensive: the join lock leaves no late-marker race in this
|
|
3447
|
+
# adapter, but bind any marker still waiting under the same lock.
|
|
3448
|
+
with _claude_join_lock():
|
|
3449
|
+
current = _load(root, request["request_id"])
|
|
3450
|
+
if current["state"] == "LAUNCHING":
|
|
3451
|
+
_claude_bind_child(root, current)
|
|
3215
3452
|
request = _load(root, request["request_id"])
|
|
3216
3453
|
launch = request["launch"]
|
|
3217
3454
|
try:
|