@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.
Files changed (64) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/plugins/ca-pi/CHANGELOG.md +33 -0
  4. package/plugins/ca-pi/SKILLS.md +1 -1
  5. package/plugins/ca-pi/agents/infra-author.md +7 -1
  6. package/plugins/ca-pi/agents/scout.md +40 -8
  7. package/plugins/ca-pi/generated/command-catalog.json +1 -1
  8. package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-amd64 +0 -0
  9. package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-arm64 +0 -0
  10. package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-amd64 +0 -0
  11. package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-arm64 +0 -0
  12. package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-amd64.exe +0 -0
  13. package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-arm64.exe +0 -0
  14. package/plugins/ca-pi/helpers/artifacts/release.json +104 -6
  15. package/plugins/ca-pi/hooks/_activationlib.py +97 -13
  16. package/plugins/ca-pi/hooks/_arbiterstatelib.py +7 -2
  17. package/plugins/ca-pi/hooks/_artifactauthoritylib.py +313 -76
  18. package/plugins/ca-pi/hooks/_artifactlib.py +166 -2
  19. package/plugins/ca-pi/hooks/_artifactpromptlib.py +106 -2
  20. package/plugins/ca-pi/hooks/_bashguardlib.py +14 -0
  21. package/plugins/ca-pi/hooks/_contextreportlib.py +564 -6
  22. package/plugins/ca-pi/hooks/_contextselectlib.py +464 -0
  23. package/plugins/ca-pi/hooks/_contextsnapshotlib.py +999 -0
  24. package/plugins/ca-pi/hooks/_debughandofflib.py +767 -0
  25. package/plugins/ca-pi/hooks/_hooklib.py +1 -0
  26. package/plugins/ca-pi/hooks/_host.py +1 -1
  27. package/plugins/ca-pi/hooks/_intentlib.py +5 -5
  28. package/plugins/ca-pi/hooks/_protectedlib.py +3 -3
  29. package/plugins/ca-pi/hooks/_protectedstatelib.py +152 -2
  30. package/plugins/ca-pi/hooks/_provenancelib.py +604 -34
  31. package/plugins/ca-pi/hooks/_readinjectlib.py +131 -1
  32. package/plugins/ca-pi/hooks/artifact-authority.py +20 -1
  33. package/plugins/ca-pi/hooks/debug-handoff.py +85 -0
  34. package/plugins/ca-pi/hooks/doctor.py +4 -2
  35. package/plugins/ca-pi/hooks/hostapi.py +1 -1
  36. package/plugins/ca-pi/hooks/init-codearbiter.py +143 -15
  37. package/plugins/ca-pi/hooks/prompt-submit.py +14 -0
  38. package/plugins/ca-pi/hooks/session-start.py +26 -7
  39. package/plugins/ca-pi/includes/author-tdd-workflow.md +16 -4
  40. package/plugins/ca-pi/includes/helper-invocation.md +128 -0
  41. package/plugins/ca-pi/includes/pi-host-notes.md +13 -0
  42. package/plugins/ca-pi/includes/routing-table.md +1 -1
  43. package/plugins/ca-pi/package.json +1 -1
  44. package/plugins/ca-pi/routines/INDEX.md +1 -1
  45. package/plugins/ca-pi/routines/commit-gate/SKILL.md +5 -3
  46. package/plugins/ca-pi/routines/context-check/SKILL.md +103 -17
  47. package/plugins/ca-pi/routines/context-creation/SKILL.md +290 -54
  48. package/plugins/ca-pi/routines/debug/SKILL.md +303 -55
  49. package/plugins/ca-pi/routines/debug/references/debug-handoff.schema.json +879 -0
  50. package/plugins/ca-pi/routines/debug/references/handoff.md +155 -0
  51. package/plugins/ca-pi/routines/decompose/SKILL.md +13 -0
  52. package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +34 -0
  53. package/plugins/ca-pi/routines/tdd/SKILL.md +91 -17
  54. package/plugins/ca-pi/skills/ca-commit/SKILL.md +5 -3
  55. package/plugins/ca-pi/skills/ca-context-check/SKILL.md +103 -17
  56. package/plugins/ca-pi/skills/ca-create-context/SKILL.md +290 -54
  57. package/plugins/ca-pi/skills/ca-debug/SKILL.md +303 -55
  58. package/plugins/ca-pi/skills/ca-decompose/SKILL.md +13 -0
  59. package/plugins/ca-pi/skills/ca-feature/SKILL.md +1 -1
  60. package/plugins/ca-pi/skills/ca-fix/SKILL.md +36 -3
  61. package/plugins/ca-pi/skills/ca-init/SKILL.md +19 -0
  62. package/plugins/ca-pi/skills/ca-review/SKILL.md +33 -4
  63. package/plugins/ca-pi/skills/ca-status/SKILL.md +5 -0
  64. 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 != _canonical(context):
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 != _canonical(context)
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
- session_id = event.get("session_id")
3075
- tool_use_id = event.get("tool_use_id")
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
- agent_id = _host_id(event.get("agent_id"), "agent_id")
3106
- agent_type = _host_id(event.get("agent_type"), "agent_type")
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
- # A start marker written late in the launch/start race.
3176
- _claude_bind_child(root, request)
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
- # A start marker written late in the launch/start race.
3214
- _claude_bind_child(root, request)
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: