@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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/plugins/ca-pi/CHANGELOG.md +25 -0
- 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 +6 -6
- package/plugins/ca-pi/hooks/_approvallib.py +134 -9
- package/plugins/ca-pi/hooks/_artifactauthoritylib.py +286 -38
- package/plugins/ca-pi/hooks/_artifactlib.py +1 -1
- package/plugins/ca-pi/hooks/_artifactpromptlib.py +4 -0
- package/plugins/ca-pi/hooks/_host.py +1 -1
- package/plugins/ca-pi/hooks/_prerequisitelib.py +15 -6
- package/plugins/ca-pi/hooks/_reconciliationlib.py +15 -4
- package/plugins/ca-pi/hooks/_replylib.py +470 -0
- package/plugins/ca-pi/hooks/_sprintapprovallib.py +12 -6
- package/plugins/ca-pi/hooks/artifact-authority-hook.py +39 -0
- package/plugins/ca-pi/hooks/hostapi.py +1 -1
- package/plugins/ca-pi/includes/artifacts.md +63 -7
- package/plugins/ca-pi/includes/sprint-authority.md +4 -2
- package/plugins/ca-pi/package.json +1 -1
- package/plugins/ca-pi/routines/writing-plans/SKILL.md +12 -1
|
@@ -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
|
-
|
|
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}:
|
|
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.
|
|
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,
|