@arbiterforge/ca-pi 0.14.17 → 0.15.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.
@@ -0,0 +1,470 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter — forgiving, low-friction capture of authority replies.
3
+ """Canonicalize typed authority replies and expand short reply codes.
4
+
5
+ Authority still comes only from the host's own user-input seam; this module
6
+ never confers it. It removes what hosts and keyboards add around a reply
7
+ (spacing, invisible characters, code formatting, quotes, a closing period)
8
+ and lets the user type a four-character code instead of a long token. Every
9
+ result is either the exact reply that was armed or text that cannot match
10
+ one, so the adapters, route registry and engine see nothing new.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ import os
18
+ from pathlib import Path
19
+ import re
20
+ import secrets
21
+ import stat
22
+ import tempfile
23
+ import time
24
+ import unicodedata
25
+
26
+ REGISTRY_PARENT = Path(tempfile.gettempdir())
27
+ FORMAT = "codearbiter.reply-code/0.1.0"
28
+ CODE_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
29
+ CODE_RE = re.compile(r"[23456789ABCDEFGHJKMNPQRSTUVWXYZ]{4}")
30
+ CODE_TTL_SECONDS = 24 * 60 * 60
31
+ MAX_INPUT = 4096
32
+ ROUTES = frozenset({"approval", "prerequisite", "reconciliation"})
33
+ VERBS = frozenset({
34
+ "approve", "approve-sprint", "satisfy-prerequisite",
35
+ "reconcile", "reconcile-task", "reconcile-scope",
36
+ })
37
+ MODES = {"delegate": "delegate", "delegate-methods": "delegate", "approve-only": "approve-only"}
38
+ STATES = frozenset({"PENDING", "REVIEW", "BLOCKED"})
39
+ ID_RE = re.compile(r"[A-Za-z][A-Za-z0-9_-]{0,127}")
40
+ DASHES = dict.fromkeys(map(ord, "‐‑‒–—―−﹘﹣"), "-")
41
+ QUOTE_PAIRS = (("`", "`"), ('"', '"'), ("'", "'"), ("“", "”"), ("‘", "’"),
42
+ ("«", "»"))
43
+ # Word positions per verb and word count: I = identifier, M = sprint mode,
44
+ # S = reconciliation state, X = secret (4-char code or long token).
45
+ GRAMMAR = {
46
+ ("approve", 2): "X", ("approve", 3): "IX",
47
+ ("approve-sprint", 3): "MX", ("approve-sprint", 5): "IIMX",
48
+ ("satisfy-prerequisite", 2): "X", ("satisfy-prerequisite", 4): "IIX",
49
+ ("reconcile", 2): "X", ("reconcile", 3): "SX",
50
+ ("reconcile-task", 5): "IISX", ("reconcile-scope", 4): "IIX",
51
+ }
52
+ SHORT_ROUTE = {"approve": "approval", "approve-sprint": "approval",
53
+ "satisfy-prerequisite": "prerequisite", "reconcile": "reconciliation",
54
+ "reconcile-task": "reconciliation", "reconcile-scope": "reconciliation"}
55
+
56
+
57
+ class ReplyCodeError(RuntimeError):
58
+ pass
59
+
60
+
61
+ def _clean(raw: str) -> str:
62
+ text = unicodedata.normalize("NFKC", raw)
63
+ text = "".join(ch for ch in text if ch != "­" and unicodedata.category(ch) != "Cf")
64
+ text = " ".join(text.translate(DASHES).split())
65
+ for _ in range(2):
66
+ if text.startswith("```") and text.endswith("```") and len(text) >= 6:
67
+ inner = text[3:-3].strip()
68
+ words = inner.split(" ")
69
+ if len(words) > 1 and words[0].lower() not in VERBS and words[1].lower() in VERBS:
70
+ inner = " ".join(words[1:]) # a fence language tag
71
+ text = inner.strip()
72
+ continue
73
+ for opening, closing in QUOTE_PAIRS:
74
+ if len(text) >= 2 and text.startswith(opening) and text.endswith(closing):
75
+ text = text[len(opening):-len(closing)].strip()
76
+ break
77
+ else:
78
+ break
79
+ if text.endswith((".", "!")):
80
+ text = text[:-1].rstrip()
81
+ return text
82
+
83
+
84
+ def canonicalize(raw):
85
+ """Return the canonical reply for a reply-shaped prompt, else ``raw`` itself."""
86
+ if not isinstance(raw, str) or not raw or len(raw) > MAX_INPUT:
87
+ return raw
88
+ text = _clean(raw)
89
+ words = text.split(" ")
90
+ verb = words[0].lower() if words and words[0] else ""
91
+ if verb not in VERBS:
92
+ return raw
93
+ words[0] = verb
94
+ shape = GRAMMAR.get((verb, len(words)))
95
+ if shape is None:
96
+ return raw
97
+ for index, kind in enumerate(shape, start=1):
98
+ word = words[index]
99
+ if kind == "I" and word.isascii() and ID_RE.fullmatch(word):
100
+ words[index] = word.upper()
101
+ elif kind == "M":
102
+ words[index] = MODES.get(word.lower(), word.lower()) if len(words) == 3 else word.lower()
103
+ elif kind == "S" and word.isascii():
104
+ words[index] = word.upper()
105
+ elif kind == "X" and len(word) == 4 and word.isascii() and CODE_RE.fullmatch(word.upper()):
106
+ words[index] = word.upper()
107
+ return " ".join(words)
108
+
109
+
110
+ def looks_like_reply(raw) -> bool:
111
+ """Whether a prompt starts with a reply verb once cleaned (a near-miss candidate)."""
112
+ if not isinstance(raw, str) or not raw or len(raw) > MAX_INPUT:
113
+ return False
114
+ first = _clean(raw).split(" ", 1)[0].lower()
115
+ return first in VERBS
116
+
117
+
118
+ def describe(raw) -> str:
119
+ """A bounded, escaped description of a prompt for the near-miss log."""
120
+ if not isinstance(raw, str):
121
+ return "non-string prompt"
122
+ digest = hashlib.sha256(raw.encode("utf-8", "surrogatepass")).hexdigest()
123
+ head = raw[:200].encode("unicode_escape").decode("ascii")[:800]
124
+ unusual = [f"{i}:U+{ord(ch):04X}" for i, ch in enumerate(raw[:MAX_INPUT])
125
+ if not (32 <= ord(ch) < 127)][:40]
126
+ return f"len={len(raw)} sha256={digest} head={head!r} nonascii={','.join(unusual)}"[:1200]
127
+
128
+
129
+ def _canonical(value) -> bytes:
130
+ return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode()
131
+
132
+
133
+ def _digest(value: bytes) -> str:
134
+ return hashlib.sha256(value).hexdigest()
135
+
136
+
137
+ def _identity(root: Path) -> dict[str, str]:
138
+ root = Path(root).resolve(strict=True)
139
+ info = root.lstat()
140
+ reparse = getattr(info, "st_file_attributes", 0) & 0x400
141
+ if stat.S_ISLNK(info.st_mode) or reparse or not stat.S_ISDIR(info.st_mode):
142
+ raise ReplyCodeError("reply code target is not a real directory")
143
+ return {"path": str(root),
144
+ "filesystem_id": f"{int(getattr(info, 'st_dev', 0))}:{int(getattr(info, 'st_ino', 0))}"}
145
+
146
+
147
+ def _registry() -> Path:
148
+ namespace = f"uid-{os.getuid()}" if hasattr(os, "getuid") else "current-user"
149
+ root = REGISTRY_PARENT.resolve(strict=True) / f"codearbiter-reply-codes-{namespace}"
150
+ root.mkdir(mode=0o700, exist_ok=True)
151
+ info = root.lstat()
152
+ reparse = getattr(info, "st_file_attributes", 0) & 0x400
153
+ if stat.S_ISLNK(info.st_mode) or reparse or not stat.S_ISDIR(info.st_mode):
154
+ raise ReplyCodeError("reply code registry is unsafe")
155
+ if hasattr(os, "getuid"):
156
+ if info.st_uid != os.getuid():
157
+ raise ReplyCodeError("reply code registry has another owner")
158
+ os.chmod(root, 0o700)
159
+ return root
160
+
161
+
162
+ def _key(route: str, artifact_id: str, repository: dict[str, str]) -> str:
163
+ return _digest(_canonical({"route": route, "artifact_id": artifact_id, "repository": repository}))
164
+
165
+
166
+ def _entries(now: int | None = None):
167
+ """Yield (path, value, live) for every intact entry."""
168
+ now = int(time.time()) if now is None else now
169
+ for path in _registry().glob("*.json"):
170
+ try:
171
+ raw = path.read_bytes()
172
+ if len(raw) > 16384:
173
+ continue
174
+ value = json.loads(raw.decode("utf-8"))
175
+ unsigned = {k: v for k, v in value.items() if k != "integrity_sha256"}
176
+ if (value.get("format") != FORMAT
177
+ or value.get("integrity_sha256") != _digest(_canonical(unsigned))
178
+ or not isinstance(value.get("repository"), dict)):
179
+ continue
180
+ if _identity(Path(value["repository"].get("path", ""))) != value["repository"]:
181
+ continue
182
+ yield path, value, int(value.get("expires_at", 0)) > now
183
+ except (OSError, UnicodeError, ValueError, TypeError, ReplyCodeError):
184
+ continue
185
+
186
+
187
+ def _write(path: Path, value: dict) -> None:
188
+ data = _canonical(value)
189
+ fd, temporary = tempfile.mkstemp(prefix=f".{path.stem}.", suffix=".tmp", dir=path.parent)
190
+ try:
191
+ try:
192
+ view = memoryview(data)
193
+ while view:
194
+ written = os.write(fd, view)
195
+ if written <= 0:
196
+ raise OSError("short reply-code write")
197
+ view = view[written:]
198
+ os.fsync(fd)
199
+ finally:
200
+ os.close(fd)
201
+ os.replace(temporary, path)
202
+ finally:
203
+ try:
204
+ os.unlink(temporary)
205
+ except FileNotFoundError:
206
+ pass
207
+
208
+
209
+ def issue_code(root, route: str, artifact_id: str, full_reply: str, *,
210
+ names: list[str], short_prefix: list[str]) -> dict[str, str]:
211
+ """Issue a unique four-character code expanding to ``full_reply``."""
212
+ if route not in ROUTES or not isinstance(full_reply, str) or canonicalize(full_reply) != full_reply:
213
+ raise ReplyCodeError("reply code request is invalid")
214
+ repository = _identity(Path(root))
215
+ path = _registry() / f"{_key(route, artifact_id, repository)}.json"
216
+ live = {value.get("short_reply") for other, value, alive in _entries() if alive and other != path}
217
+ for _ in range(64):
218
+ code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(4))
219
+ short = " ".join([*short_prefix, code])
220
+ if short not in live and canonicalize(short) == short:
221
+ break
222
+ else:
223
+ raise ReplyCodeError("no free reply code")
224
+ value = {"format": FORMAT, "route": route, "artifact_id": artifact_id, "repository": repository,
225
+ "short_reply": short, "full_reply": full_reply, "names": list(names),
226
+ "expires_at": int(time.time()) + CODE_TTL_SECONDS}
227
+ value["integrity_sha256"] = _digest(_canonical(value))
228
+ _write(path, value)
229
+ return {"code": code, "short_reply": short}
230
+
231
+
232
+ def offer_code(root, route: str, artifact_id: str, full_reply: str, *,
233
+ names: list[str], short_prefix: list[str]) -> dict[str, str]:
234
+ """Arm-result fields for a short code; empty when one cannot be issued.
235
+
236
+ The code is a convenience: failing to issue one never blocks arming, and
237
+ the full reply always remains accepted."""
238
+ try:
239
+ issued = issue_code(root, route, artifact_id, full_reply, names=names, short_prefix=short_prefix)
240
+ except (ReplyCodeError, OSError):
241
+ return {}
242
+ return {"code": issued["code"], "short_reply": issued["short_reply"]}
243
+
244
+
245
+ def retire_code(root, route: str, artifact_id: str) -> None:
246
+ try:
247
+ repository = _identity(Path(root))
248
+ key = _key(route, artifact_id, repository)
249
+ except (ReplyCodeError, OSError):
250
+ return
251
+ for name in (f"{key}.json", f"ask-{key}.ask"):
252
+ try:
253
+ (_registry() / name).unlink()
254
+ except (FileNotFoundError, ReplyCodeError, OSError):
255
+ pass
256
+
257
+
258
+ # ---------------------------------------------------------------------------
259
+ # Click-to-approve through the host's own question dialog (Claude Code).
260
+ #
261
+ # The model passes an armed envelope to the host question tool unchanged. The
262
+ # host renders it and returns the user's selection; the model cannot author
263
+ # that answer. PreToolUse proves the call carried no pre-filled answer, and
264
+ # PostToolUse accepts only the exact armed Approve option for that same call.
265
+ # The result is the request's short reply, applied through the normal adapter.
266
+ # ---------------------------------------------------------------------------
267
+ ASK_FORMAT = "codearbiter.reply-ask/0.1.0"
268
+ ASK_TOOL = "AskUserQuestion"
269
+ ASK_CLEAN_TTL_SECONDS = 60 * 60
270
+ NOT_YET = "Not yet"
271
+
272
+
273
+ def _questions_sha256(questions) -> str:
274
+ return _digest(_canonical(questions))
275
+
276
+
277
+ def ask_envelope(root, route: str, artifact_id: str, *, question: str, header: str,
278
+ description: str, code_fields: dict[str, str]) -> dict:
279
+ """Arm a question-dialog envelope for an already issued short code."""
280
+ if not code_fields.get("short_reply"):
281
+ return {}
282
+ label = f"Approve {code_fields['code']}"
283
+ questions = [{
284
+ "question": question, "header": header[:12], "multiSelect": False,
285
+ "options": [
286
+ {"label": label, "description": description},
287
+ {"label": NOT_YET, "description": "Records nothing. You can still type the reply later."},
288
+ ],
289
+ }]
290
+ try:
291
+ repository = _identity(Path(root))
292
+ value = {"format": ASK_FORMAT, "route": route, "artifact_id": artifact_id,
293
+ "repository": repository, "questions_sha256": _questions_sha256(questions),
294
+ "question": question, "label": label, "short_reply": code_fields["short_reply"],
295
+ "expires_at": int(time.time()) + CODE_TTL_SECONDS}
296
+ value["integrity_sha256"] = _digest(_canonical(value))
297
+ _write(_registry() / f"ask-{_key(route, artifact_id, repository)}.ask", value)
298
+ except (ReplyCodeError, OSError):
299
+ return {}
300
+ return {"questions": questions}
301
+
302
+
303
+ def _ask_entries():
304
+ now = int(time.time())
305
+ for path in _registry().glob("ask-*.ask"):
306
+ try:
307
+ raw = path.read_bytes()
308
+ if len(raw) > 16384:
309
+ continue
310
+ value = json.loads(raw.decode("utf-8"))
311
+ unsigned = {k: v for k, v in value.items() if k != "integrity_sha256"}
312
+ if (value.get("format") != ASK_FORMAT
313
+ or value.get("integrity_sha256") != _digest(_canonical(unsigned))
314
+ or int(value.get("expires_at", 0)) <= now
315
+ or _identity(Path(value["repository"]["path"])) != value["repository"]):
316
+ continue
317
+ yield path, value
318
+ except (OSError, UnicodeError, ValueError, TypeError, KeyError, ReplyCodeError):
319
+ continue
320
+
321
+
322
+ def _armed_ask(tool_input):
323
+ questions = tool_input.get("questions") if isinstance(tool_input, dict) else None
324
+ if not isinstance(questions, list):
325
+ return None, None
326
+ digest = _questions_sha256(questions)
327
+ for path, value in _ask_entries():
328
+ if value["questions_sha256"] == digest:
329
+ return path, value
330
+ return None, None
331
+
332
+
333
+ def _call_key(payload) -> str | None:
334
+ session = payload.get("session_id")
335
+ call = payload.get("tool_use_id")
336
+ if not (isinstance(session, str) and session and isinstance(call, str) and call):
337
+ return None
338
+ return _digest(_canonical({"session": session, "call": call}))
339
+
340
+
341
+ def observe_ask_pre(payload) -> None:
342
+ """PreToolUse: refuse an armed envelope that carries a pre-filled answer."""
343
+ path, value = _armed_ask(payload.get("tool_input"))
344
+ if value is None:
345
+ return
346
+ tool_input = payload["tool_input"]
347
+ key = _call_key(payload)
348
+ if key is None or set(tool_input) - {"questions", "metadata"}:
349
+ # Pre-filled answers or unexpected fields: this request can never be
350
+ # approved by click. The typed code still works.
351
+ path.unlink(missing_ok=True)
352
+ raise ReplyCodeError("an armed approval question must be passed unchanged, with no answers")
353
+ marker = {"format": ASK_FORMAT, "call": key, "questions_sha256": value["questions_sha256"],
354
+ "expires_at": int(time.time()) + ASK_CLEAN_TTL_SECONDS}
355
+ marker["integrity_sha256"] = _digest(_canonical(marker))
356
+ _write(_registry() / f"call-{key}.clean", marker)
357
+
358
+
359
+ def observe_ask_post(payload) -> dict | None:
360
+ """PostToolUse: the armed short reply and its repository when the user chose Approve."""
361
+ path, value = _armed_ask(payload.get("tool_input"))
362
+ if value is None:
363
+ return None
364
+ key = _call_key(payload)
365
+ if key is None:
366
+ return None
367
+ marker_path = _registry() / f"call-{key}.clean"
368
+ try:
369
+ marker = json.loads(marker_path.read_bytes().decode("utf-8"))
370
+ except (OSError, UnicodeError, ValueError):
371
+ return None
372
+ finally:
373
+ marker_path.unlink(missing_ok=True)
374
+ unsigned = {k: v for k, v in marker.items() if k != "integrity_sha256"}
375
+ if (marker.get("integrity_sha256") != _digest(_canonical(unsigned))
376
+ or marker.get("questions_sha256") != value["questions_sha256"]
377
+ or int(marker.get("expires_at", 0)) <= int(time.time())):
378
+ return None
379
+ response = payload.get("tool_response")
380
+ answers = response.get("answers") if isinstance(response, dict) else None
381
+ if not isinstance(answers, dict) or answers.get(value["question"]) != value["label"]:
382
+ return None
383
+ path.unlink(missing_ok=True)
384
+ return {"root": value["repository"]["path"], "route": value["route"], "prompt": value["short_reply"]}
385
+
386
+
387
+ def _short_form(canonical: str):
388
+ """Split a canonical short reply into (lookup key, named IDs, stated mode/state) or None."""
389
+ words = canonical.split(" ")
390
+ shape = GRAMMAR.get((words[0], len(words))) if words else None
391
+ if not shape or not CODE_RE.fullmatch(words[-1]):
392
+ return None
393
+ verb = words[0]
394
+ names = [w for w, kind in zip(words[1:], shape) if kind == "I"]
395
+ stated = [w for w, kind in zip(words[1:], shape) if kind in "MS"]
396
+ if verb == "approve-sprint" and len(words) == 5:
397
+ stated = [MODES.get(stated[0], stated[0])]
398
+ prefix = {"reconcile-task": "reconcile", "reconcile-scope": "reconcile"}.get(verb, verb)
399
+ return " ".join([prefix, *stated, words[-1]]), names, stated
400
+
401
+
402
+ DIAGNOSTICS = Path(".codearbiter/.markers/reply-diagnostics.log")
403
+ DIAGNOSTICS_CAP = 256 * 1024
404
+
405
+
406
+ def prepare(raw, route: str) -> dict[str, object]:
407
+ """The text an adapter should match, plus a notice owned by ``route``.
408
+
409
+ ``attempted`` is true when the prompt is reply-shaped for this route, so
410
+ the adapter reports a miss instead of staying silent.
411
+ """
412
+ canonical = canonicalize(raw)
413
+ first = canonical.split(" ", 1)[0] if isinstance(canonical, str) and canonical is not raw else ""
414
+ if not first and looks_like_reply(raw):
415
+ first = _clean(raw).split(" ", 1)[0].lower()
416
+ mine = SHORT_ROUTE.get(first) == route
417
+ expanded = expand(canonical) if mine else {"text": canonical, "notice": ""}
418
+ return {"text": expanded["text"], "notice": expanded["notice"] if mine else "", "attempted": mine}
419
+
420
+
421
+ def miss_notice(example: str) -> str:
422
+ return (f"codeArbiter: that reply did not match an armed request. Send only the reply line, "
423
+ f"for example `{example}`; nothing else in the message.")
424
+
425
+
426
+ def record_near_miss(root, raw, reason: str) -> None:
427
+ """Append one bounded, escaped line for a reply-shaped prompt that missed."""
428
+ try:
429
+ state = Path(root) / ".codearbiter"
430
+ if not (state / "CONTEXT.md").is_file():
431
+ return
432
+ markers = state / ".markers"
433
+ markers.mkdir(exist_ok=True)
434
+ path = markers / DIAGNOSTICS.name
435
+ if path.is_symlink() or (path.exists() and path.stat().st_size > DIAGNOSTICS_CAP):
436
+ return
437
+ line = f"{int(time.time())} {reason[:120]} {describe(raw)}\n"
438
+ with open(path, "a", encoding="ascii", errors="backslashreplace") as stream:
439
+ stream.write(line)
440
+ except OSError:
441
+ pass
442
+
443
+
444
+ def expand(canonical: str) -> dict[str, str]:
445
+ """Expand a canonical short reply to its exact armed full reply."""
446
+ if not isinstance(canonical, str):
447
+ return {"text": canonical, "notice": ""}
448
+ parsed = _short_form(canonical)
449
+ if parsed is None:
450
+ return {"text": canonical, "notice": ""}
451
+ key, names, _stated = parsed
452
+ code = key.rsplit(" ", 1)[-1]
453
+ candidates = [(value, alive) for _path, value, alive in _entries()
454
+ if value.get("short_reply", "").rsplit(" ", 1)[-1] == code
455
+ and value.get("route") == SHORT_ROUTE[canonical.split(" ")[0]]]
456
+ if not candidates:
457
+ return {"text": canonical, "notice": f"codeArbiter: reply code {code} matches no armed request."}
458
+ exact = [(value, alive) for value, alive in candidates if value.get("short_reply") == key]
459
+ if not exact:
460
+ expected = candidates[0][0].get("short_reply")
461
+ return {"text": canonical,
462
+ "notice": f"codeArbiter: reply code {code} was armed as `{expected}`; resend exactly that."}
463
+ value, alive = exact[0]
464
+ if not alive:
465
+ return {"text": canonical, "notice": f"codeArbiter: reply code {code} has expired; ask for a new one."}
466
+ if names and names != value.get("names", [])[:len(names)] and names != value.get("names", []):
467
+ return {"text": canonical,
468
+ "notice": (f"codeArbiter: reply code {code} belongs to {' '.join(value.get('names', []))}, "
469
+ f"not {' '.join(names)}.")}
470
+ return {"text": value["full_reply"], "notice": ""}
@@ -21,6 +21,7 @@ import _approvallib as approval
21
21
  import _artifactlib
22
22
  import _artifactauthoritylib
23
23
  import _artifactpromptlib
24
+ import _replylib
24
25
 
25
26
  FORMAT = "codearbiter.pending-user-approval/0.2.0"
26
27
  SUBMISSION_FORMAT = "codearbiter.sprint-submission/0.1.0"
@@ -120,6 +121,7 @@ def arm(root: str | Path, client: Any, spec_id: str, plan_id: str, *, delegate_m
120
121
  reply = f"approve-sprint {spec_id} {plan_id} {mode} {token}"
121
122
  ph = hashlib.sha256(reply.encode("utf-8")).hexdigest()
122
123
  context = client.call("sprint-approval-context", {"artifact_id": plan_id, "spec_id": spec_id, "delegate_methods": delegate_methods, "prompt_sha256": ph})
124
+ approval._preflight_plan_verification(client, context["pair"]["plan"])
123
125
  pending = {"format": FORMAT, "artifact_id": plan_id, "kind": "sprint-pair", "pair": context["pair"], "spec_context": context["spec_context"], "plan_context": context["plan_context"], "prompt_sha256": ph, "token_sha256": hashlib.sha256(token.encode("ascii")).hexdigest()}
124
126
  pending["request_key"] = _hash({"pair": pending["pair"], "prompt_sha256": ph})
125
127
  validate_pending(pending)
@@ -132,7 +134,9 @@ def arm(root: str | Path, client: Any, spec_id: str, plan_id: str, *, delegate_m
132
134
  except _artifactpromptlib.PromptRouteError:
133
135
  (root / approval.PENDING).unlink()
134
136
  raise
135
- return {"spec_artifact_id": spec_id, "artifact_id": plan_id, "pair": pending["pair"], "reply": reply, "delegation": context.get("scope"), "approved": False}
137
+ code = _replylib.offer_code(root, "approval", plan_id, reply, names=[spec_id, plan_id],
138
+ short_prefix=["approve-sprint", "delegate" if delegate_methods else "approve-only"])
139
+ return {"spec_artifact_id": spec_id, "artifact_id": plan_id, "pair": pending["pair"], "reply": reply, **code, "delegation": context.get("scope"), "approved": False}
136
140
 
137
141
 
138
142
  def _submission_path(pending: dict[str, Any]) -> Path:
@@ -164,7 +168,7 @@ def _submission(root: Path, pending: dict[str, Any]) -> dict[str, Any] | None:
164
168
  return request
165
169
 
166
170
 
167
- def _source(root: Path, pending: dict[str, Any], name: str, prompt: str, host: str, session_id: str) -> dict[str, str]:
171
+ def _source(root: Path, pending: dict[str, Any], name: str, prompt: str, host: str, session_id: str, seam: str = "UserPromptSubmit") -> dict[str, str]:
168
172
  locator = pending[name + "_context"]
169
173
  context = _read(root, Path(locator["context_ref"]))
170
174
  if (_hash(context) != locator["context_sha256"] or context.get("subject") != locator["subject"]
@@ -177,7 +181,7 @@ def _source(root: Path, pending: dict[str, Any], name: str, prompt: str, host: s
177
181
  oh = _hash(observed)
178
182
  observation_ref = Path(".codearbiter/.artifacts/observations") / f"{oh}.json"
179
183
  _artifactauthoritylib._publish_immutable(root, observation_ref, approval._canonical(observed))
180
- event = {"format": "codearbiter.workflow-event/0.2.0", "kind": "approval", "authority_kind": "user_workflow", "subject": context["subject"], "actor": "interactive repository user", "origin": f"{host}:UserPromptSubmit:{session_id}", "verdict": "approved", "payload": payload, "source_text": prompt, "observation_ref": observation_ref.as_posix(), "observation_sha256": oh}
184
+ event = {"format": "codearbiter.workflow-event/0.2.0", "kind": "approval", "authority_kind": "user_workflow", "subject": context["subject"], "actor": "interactive repository user", "origin": f"{host}:{seam}:{session_id}", "verdict": "approved", "payload": payload, "source_text": prompt, "observation_ref": observation_ref.as_posix(), "observation_sha256": oh}
181
185
  eh = _hash(event)
182
186
  source_ref = Path(".codearbiter/.artifacts/authority-sources") / f"{eh}.json"
183
187
  _artifactauthoritylib._publish_immutable(root, source_ref, approval._canonical(event))
@@ -210,7 +214,9 @@ def _execute(root: Path, client: Any, pending: dict[str, Any], request: dict[str
210
214
 
211
215
 
212
216
  @_serialized
213
- def consume(root: Path, client: Any, pending: dict[str, Any], prompt: str, *, host: str, session_id: str) -> dict[str, Any]:
217
+ def consume(root: Path, client: Any, pending: dict[str, Any], prompt: str, *, host: str, session_id: str, seam: str = "UserPromptSubmit") -> dict[str, Any]:
218
+ if seam not in {"UserPromptSubmit", "AskUserQuestion"}:
219
+ raise approval.ApprovalError("INVALID_HOST_CONTEXT", "approval seam is unsupported")
214
220
  validate_pending(pending)
215
221
  if approval._load_pending(root) != pending:
216
222
  return {"matched": False, "approved": False}
@@ -232,8 +238,8 @@ def consume(root: Path, client: Any, pending: dict[str, Any], prompt: str, *, ho
232
238
  if any(current.get(k) != v for k, v in pair[name].items()):
233
239
  raise approval.ApprovalError("STALE_APPROVAL", "a paired artifact changed after review was requested")
234
240
  request = _base_request(pending)
235
- request["spec_source"] = _source(root, pending, "spec", prompt, host, session_id)
236
- request["plan_source"] = _source(root, pending, "plan", prompt, host, session_id)
241
+ request["spec_source"] = _source(root, pending, "spec", prompt, host, session_id, seam)
242
+ request["plan_source"] = _source(root, pending, "plan", prompt, host, session_id, seam)
237
243
  record = {"format": SUBMISSION_FORMAT, "request_key": pending["request_key"], "request": request}
238
244
  try:
239
245
  approval._write_new(root, _submission_path(pending), approval._canonical(record))
@@ -8,8 +8,10 @@ import sys
8
8
 
9
9
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
10
10
  import hostapi # noqa: E402
11
+ import _approvallib # noqa: E402
11
12
  import _artifactauthoritylib # noqa: E402
12
13
  import _hooklib # noqa: E402
14
+ import _replylib # noqa: E402
13
15
 
14
16
 
15
17
  def main() -> int:
@@ -71,6 +73,8 @@ def claude_main() -> int:
71
73
  return 0
72
74
  event = payload.get("hook_event_name")
73
75
  tool = payload.get("tool_name")
76
+ if tool == _replylib.ASK_TOOL and event in {"PreToolUse", "PostToolUse"}:
77
+ return _claude_ask(event, payload)
74
78
  root = _hooklib.project_root(payload)
75
79
  try:
76
80
  if event in {"PreToolUse", "PostToolUse", "PostToolUseFailure"} and tool == "Bash":
@@ -96,6 +100,41 @@ def claude_main() -> int:
96
100
  return 0
97
101
 
98
102
 
103
+ def _claude_ask(event: str, payload: dict) -> int:
104
+ """Click-to-approve through Claude Code's own question dialog.
105
+
106
+ An ordinary question is never touched. An armed approval question with a
107
+ pre-filled answer is denied; the user's Approve selection on a clean call
108
+ is applied through the normal approval adapter."""
109
+ if event == "PreToolUse":
110
+ try:
111
+ _replylib.observe_ask_pre(payload)
112
+ except (_replylib.ReplyCodeError, OSError) as exc:
113
+ print(json.dumps({"hookSpecificOutput": {
114
+ "hookEventName": "PreToolUse",
115
+ "permissionDecision": "deny",
116
+ "permissionDecisionReason": f"codeArbiter approval question refused: {exc}",
117
+ }}))
118
+ return 0
119
+ try:
120
+ chosen = _replylib.observe_ask_post(payload)
121
+ except (_replylib.ReplyCodeError, OSError) as exc:
122
+ sys.stderr.write(f"codeArbiter approval question not recorded: {exc}\n")
123
+ return 0
124
+ if chosen is None or chosen["route"] != "approval":
125
+ return 0
126
+ session = payload.get("session_id") if isinstance(payload.get("session_id"), str) else ""
127
+ text = _approvallib.consume_from_hook(
128
+ root=chosen["root"], plugin_root=_hooklib.get_host().plugin_root(),
129
+ prompt=chosen["prompt"], host="claude", session_id=session, seam="AskUserQuestion",
130
+ )
131
+ if text:
132
+ print(json.dumps({"hookSpecificOutput": {
133
+ "hookEventName": "PostToolUse", "additionalContext": text,
134
+ }}))
135
+ return 0
136
+
137
+
99
138
  def _authority_call(payload: dict) -> bool:
100
139
  """Whether an unexpected failure must deny: an ordinary tool call is never
101
140
  refused, but a SendMessage could steer a running review and an authority
@@ -313,7 +313,7 @@ class Host:
313
313
 
314
314
  name = "claude"
315
315
  adapter_name = "ca"
316
- adapter_version = "2.21.17"
316
+ adapter_version = "2.22.1"
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,