@arbiterforge/ca-pi 0.14.16 → 0.15.0
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 +23 -0
- package/plugins/ca-pi/hooks/_approvallib.py +36 -8
- 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/_intentlib.py +6 -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 +11 -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 +17 -7
- package/plugins/ca-pi/includes/sprint-authority.md +4 -2
- package/plugins/ca-pi/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ project context. You decide. codeArbiter enforces.
|
|
|
10
10
|
<img alt="Claude Code plugin" src="https://img.shields.io/badge/Claude_Code-plugin-d97757">
|
|
11
11
|
<img alt="Codex plugin" src="https://img.shields.io/badge/OpenAI_Codex-plugin-10a37f">
|
|
12
12
|
<img alt="Pi Feature Forge preview" src="https://img.shields.io/badge/ca--pi-Feature_Forge_preview-d97757">
|
|
13
|
-
<img alt="version 2.
|
|
13
|
+
<img alt="version 2.22.0" src="https://img.shields.io/badge/version-2.22.0-2b7489">
|
|
14
14
|
<img alt="core lanes" src="https://img.shields.io/badge/core_lanes-18-555">
|
|
15
15
|
<img alt="skills" src="https://img.shields.io/badge/skills-23-555">
|
|
16
16
|
<img alt="agents" src="https://img.shields.io/badge/agents-20-555">
|
package/package.json
CHANGED
|
@@ -4,6 +4,29 @@ All notable changes to `ca-pi` are documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.15.0] - 2026-09-26
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Approve a structured artifact by typing a four-character code, for example
|
|
12
|
+
`approve K7MQ`, instead of pasting a long token. The hook expands it to the
|
|
13
|
+
exact armed reply, so the engine and recorded evidence are unchanged.
|
|
14
|
+
- On Claude Code, approve by clicking an Approve option in the host's own
|
|
15
|
+
question dialog; the recorded origin names that seam.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Replies with extra spacing, invisible characters, backticks, quotes or a
|
|
20
|
+
closing period are no longer ignored; a reply that still cannot match now
|
|
21
|
+
says why instead of failing silently.
|
|
22
|
+
|
|
23
|
+
## [0.14.17] - 2026-09-25
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- Accept null diagnostics from successful HTML intent validation without bypassing
|
|
28
|
+
invalid-spec, stale-read or issue-coverage checks; reject malformed collections.
|
|
29
|
+
|
|
7
30
|
## [0.14.16] - 2026-09-25
|
|
8
31
|
|
|
9
32
|
### Changed
|
|
@@ -17,6 +17,7 @@ from typing import Any
|
|
|
17
17
|
import _artifactlib
|
|
18
18
|
import _artifactauthoritylib
|
|
19
19
|
import _artifactpromptlib
|
|
20
|
+
import _replylib
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
PENDING = Path(".codearbiter/.markers/pending-user-approval.json")
|
|
@@ -159,9 +160,21 @@ def arm_user_approval(
|
|
|
159
160
|
except _artifactpromptlib.PromptRouteError:
|
|
160
161
|
(root / PENDING).unlink()
|
|
161
162
|
raise
|
|
163
|
+
reply = f"approve {artifact_id} {token}"
|
|
164
|
+
code = _replylib.offer_code(root, "approval", artifact_id, reply,
|
|
165
|
+
names=[artifact_id], short_prefix=["approve"])
|
|
166
|
+
envelope = _replylib.ask_envelope(
|
|
167
|
+
root, "approval", artifact_id, code_fields=code,
|
|
168
|
+
question=(f"Approve {identity['kind']} {artifact_id} revision {identity['revision']} "
|
|
169
|
+
f"as you reviewed it? (normative {identity['normative_sha256'][:12]})"),
|
|
170
|
+
header=f"Approve {code.get('code', '')}",
|
|
171
|
+
description=f"Records your approval of this exact revision. Same as typing {code.get('short_reply')}.",
|
|
172
|
+
)
|
|
162
173
|
return {
|
|
163
174
|
"artifact_id": artifact_id,
|
|
164
|
-
"reply":
|
|
175
|
+
"reply": reply,
|
|
176
|
+
**code,
|
|
177
|
+
**({"ask_envelope": envelope} if envelope else {}),
|
|
165
178
|
"model_sha256": identity["model_sha256"],
|
|
166
179
|
"normative_sha256": identity["normative_sha256"],
|
|
167
180
|
}
|
|
@@ -243,14 +256,17 @@ def consume_user_approval(
|
|
|
243
256
|
*,
|
|
244
257
|
host: str,
|
|
245
258
|
session_id: str,
|
|
259
|
+
seam: str = "UserPromptSubmit",
|
|
246
260
|
) -> dict[str, Any]:
|
|
247
261
|
root = Path(root).resolve(strict=True)
|
|
262
|
+
if seam not in {"UserPromptSubmit", "AskUserQuestion"}:
|
|
263
|
+
raise ApprovalError("INVALID_HOST_CONTEXT", "approval seam is unsupported")
|
|
248
264
|
pending = _load_pending(root)
|
|
249
265
|
if pending is None or not isinstance(prompt, str):
|
|
250
266
|
return {"matched": False, "approved": False}
|
|
251
267
|
if pending["format"] == "codearbiter.pending-user-approval/0.2.0":
|
|
252
268
|
import _sprintapprovallib
|
|
253
|
-
return _sprintapprovallib.consume(root, client, pending, prompt, host=host, session_id=session_id)
|
|
269
|
+
return _sprintapprovallib.consume(root, client, pending, prompt, host=host, session_id=session_id, seam=seam)
|
|
254
270
|
parts = prompt.split(" ")
|
|
255
271
|
if len(parts) != 3 or parts[0] != "approve" or parts[1] != pending["artifact_id"]:
|
|
256
272
|
return {"matched": False, "approved": False}
|
|
@@ -277,7 +293,7 @@ def consume_user_approval(
|
|
|
277
293
|
"record_id": current["artifact_id"],
|
|
278
294
|
},
|
|
279
295
|
"actor": "interactive repository user",
|
|
280
|
-
"origin": f"{host}:
|
|
296
|
+
"origin": f"{host}:{seam}:{session_id}",
|
|
281
297
|
"verdict": "approved",
|
|
282
298
|
"payload": {},
|
|
283
299
|
"source_text": prompt,
|
|
@@ -316,25 +332,37 @@ def consume_user_approval(
|
|
|
316
332
|
|
|
317
333
|
|
|
318
334
|
def consume_from_hook(*, root: str | Path, plugin_root: str | Path, prompt: str,
|
|
319
|
-
host: str, session_id: str) -> str:
|
|
335
|
+
host: str, session_id: str, seam: str = "UserPromptSubmit") -> str:
|
|
336
|
+
raw = prompt
|
|
337
|
+
prepared = _replylib.prepare(raw, "approval")
|
|
338
|
+
prompt = prepared["text"]
|
|
339
|
+
attempted = prepared["attempted"]
|
|
340
|
+
|
|
341
|
+
def missed(reason: str) -> str:
|
|
342
|
+
_replylib.record_near_miss(root, raw, reason)
|
|
343
|
+
return prepared["notice"] or _replylib.miss_notice("approve K7MQ")
|
|
344
|
+
|
|
345
|
+
if prepared["notice"]:
|
|
346
|
+
return missed("code")
|
|
320
347
|
try:
|
|
321
348
|
routed = _artifactpromptlib.resolve("approval", prompt)
|
|
322
349
|
root = routed or Path(root)
|
|
323
350
|
if not (Path(root) / PENDING).exists():
|
|
324
|
-
return ""
|
|
351
|
+
return missed("no-pending") if attempted else ""
|
|
325
352
|
client = _artifactlib.ArtifactClient(
|
|
326
353
|
root, Path(plugin_root) / "helpers" / "artifacts"
|
|
327
354
|
)
|
|
328
355
|
result = consume_user_approval(
|
|
329
|
-
root, client, prompt, host=host, session_id=session_id
|
|
356
|
+
root, client, prompt, host=host, session_id=session_id, seam=seam
|
|
330
357
|
)
|
|
331
358
|
except (ApprovalError, _artifactlib.ArtifactError,
|
|
332
359
|
_artifactpromptlib.PromptRouteError, OSError) as exc:
|
|
333
|
-
if
|
|
360
|
+
if attempted:
|
|
361
|
+
_replylib.record_near_miss(root, raw, "capture-failed")
|
|
334
362
|
return f"codeArbiter: approval capture failed: {exc}"
|
|
335
363
|
return ""
|
|
336
364
|
if not result.get("approved"):
|
|
337
|
-
return ""
|
|
365
|
+
return missed("mismatch") if attempted else ""
|
|
338
366
|
return (
|
|
339
367
|
"codeArbiter: workflow approval recorded for "
|
|
340
368
|
f"{result['artifact_id']} (receipt {result['receipt']})."
|
|
@@ -304,7 +304,7 @@ def _installed_workflow_preflight(installation: Path) -> dict[str, object]:
|
|
|
304
304
|
return result
|
|
305
305
|
|
|
306
306
|
for filename in ("_approvallib.py", "_prerequisitelib.py", "_sprintapprovallib.py",
|
|
307
|
-
"_reconciliationlib.py", "_artifactauthoritylib.py", "_gitexec.py",
|
|
307
|
+
"_reconciliationlib.py", "_artifactauthoritylib.py", "_gitexec.py", "_replylib.py",
|
|
308
308
|
"artifact-authority.py", "artifact-authority-hook.py", "prompt-submit.py"):
|
|
309
309
|
resource("hooks/" + filename)
|
|
310
310
|
if host == "claude":
|
|
@@ -11,6 +11,8 @@ from pathlib import Path
|
|
|
11
11
|
import stat
|
|
12
12
|
import tempfile
|
|
13
13
|
|
|
14
|
+
import _replylib
|
|
15
|
+
|
|
14
16
|
|
|
15
17
|
REGISTRY_PARENT = Path(tempfile.gettempdir())
|
|
16
18
|
FORMAT = "codearbiter.artifact-prompt-route/0.1.0"
|
|
@@ -147,6 +149,8 @@ def unregister(root: str | Path, route: str, artifact_id: str) -> None:
|
|
|
147
149
|
path.unlink()
|
|
148
150
|
except FileNotFoundError:
|
|
149
151
|
pass
|
|
152
|
+
# A request's short reply code dies with its route.
|
|
153
|
+
_replylib.retire_code(root, route, artifact_id)
|
|
150
154
|
|
|
151
155
|
|
|
152
156
|
def binding(root: str | Path, route: str, artifact_id: str) -> str | None:
|
|
@@ -11,7 +11,7 @@ import hostapi # noqa: E402
|
|
|
11
11
|
class PiHost(hostapi.Host):
|
|
12
12
|
name = "pi"
|
|
13
13
|
adapter_name = "@arbiterforge/ca-pi"
|
|
14
|
-
adapter_version = "0.
|
|
14
|
+
adapter_version = "0.15.0"
|
|
15
15
|
update_target = "ca-pi"
|
|
16
16
|
update_tag_prefix = "ca-pi-v"
|
|
17
17
|
update_command = "pi update npm:@arbiterforge/ca-pi"
|
|
@@ -239,8 +239,13 @@ def main(argv):
|
|
|
239
239
|
from _artifactlib import resolve_spec_file, helper_installation
|
|
240
240
|
client, entry = resolve_spec_file(positional[0], helper_installation(__file__))
|
|
241
241
|
result = client.call("validate", {"artifact_id": entry["artifact_id"], "gate": "ready"}, permit_invalid=True)
|
|
242
|
+
diagnostics = result.get("diagnostics")
|
|
243
|
+
if diagnostics is None:
|
|
244
|
+
diagnostics = [] # The native engine serializes no findings as null.
|
|
245
|
+
if not isinstance(diagnostics, list):
|
|
246
|
+
raise ValueError("HTML validation diagnostics must be a list or null")
|
|
242
247
|
findings = ["[INVALID-HTML-SPEC] " + item["code"] + ": " + item["message"]
|
|
243
|
-
for item in
|
|
248
|
+
for item in diagnostics]
|
|
244
249
|
# Explicit source->criterion mappings replace fuzzy scope matching.
|
|
245
250
|
# The legacy issue-checkbox heuristic is retained ONLY for external
|
|
246
251
|
# issue text until issue-source records receive their own schema.
|
|
@@ -40,6 +40,7 @@ from typing import Any
|
|
|
40
40
|
import _artifactlib
|
|
41
41
|
import _artifactauthoritylib
|
|
42
42
|
import _artifactpromptlib
|
|
43
|
+
import _replylib
|
|
43
44
|
from _hooklib import acquire_lock, release_lock
|
|
44
45
|
|
|
45
46
|
|
|
@@ -619,6 +620,9 @@ def arm_user_prerequisite(
|
|
|
619
620
|
"requirement": record["requirement"],
|
|
620
621
|
"review_packet_sha256": packet_sha256,
|
|
621
622
|
"reply": reply,
|
|
623
|
+
**_replylib.offer_code(root, "prerequisite", artifact_id, reply,
|
|
624
|
+
names=[artifact_id, prerequisite_id],
|
|
625
|
+
short_prefix=["satisfy-prerequisite"]),
|
|
622
626
|
"pending": relative.as_posix(),
|
|
623
627
|
}
|
|
624
628
|
|
|
@@ -914,14 +918,19 @@ def consume_from_hook(
|
|
|
914
918
|
host: str,
|
|
915
919
|
session_id: str,
|
|
916
920
|
) -> str:
|
|
917
|
-
|
|
921
|
+
raw = prompt
|
|
922
|
+
prepared = _replylib.prepare(raw, "prerequisite")
|
|
923
|
+
prompt = prepared["text"]
|
|
924
|
+
attempted = prepared["attempted"]
|
|
925
|
+
if prepared["notice"]:
|
|
926
|
+
_replylib.record_near_miss(root, raw, "code")
|
|
927
|
+
return prepared["notice"]
|
|
918
928
|
match = _match_prompt(prompt)
|
|
919
929
|
if match is None:
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
)
|
|
930
|
+
if attempted:
|
|
931
|
+
_replylib.record_near_miss(root, raw, "syntax")
|
|
932
|
+
return _replylib.miss_notice("satisfy-prerequisite K7MQ")
|
|
933
|
+
return ""
|
|
925
934
|
try:
|
|
926
935
|
routed = _artifactpromptlib.resolve("prerequisite", prompt)
|
|
927
936
|
root = routed or Path(root)
|
|
@@ -18,6 +18,7 @@ import _approvallib
|
|
|
18
18
|
import _artifactauthoritylib
|
|
19
19
|
import _artifactlib
|
|
20
20
|
import _artifactpromptlib
|
|
21
|
+
import _replylib
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
ID_RE = re.compile(r"[A-Z][A-Z0-9_-]{0,127}")
|
|
@@ -257,7 +258,11 @@ def _arm_reconciliation_unlocked(
|
|
|
257
258
|
except OSError:
|
|
258
259
|
pass
|
|
259
260
|
raise
|
|
260
|
-
|
|
261
|
+
code = _replylib.offer_code(
|
|
262
|
+
root, "reconciliation", artifact_id, reply, names=[artifact_id, target_id],
|
|
263
|
+
short_prefix=["reconcile", target_state] if operation == "task-reconcile" else ["reconcile"],
|
|
264
|
+
)
|
|
265
|
+
return {"artifact_id": artifact_id, "target_id": target_id, "operation": operation, "reply": reply, **code}
|
|
261
266
|
|
|
262
267
|
|
|
263
268
|
def _load(root: Path, artifact_id: str) -> dict[str, Any]:
|
|
@@ -458,13 +463,19 @@ def _consume_reconciliation_unlocked(
|
|
|
458
463
|
|
|
459
464
|
|
|
460
465
|
def consume_from_hook(*, root: str | Path, plugin_root: str | Path, prompt: str, host: str, session_id: str) -> str:
|
|
461
|
-
|
|
462
|
-
|
|
466
|
+
raw = prompt
|
|
467
|
+
prepared = _replylib.prepare(raw, "reconciliation")
|
|
468
|
+
prompt = prepared["text"]
|
|
469
|
+
if not prepared["attempted"]:
|
|
463
470
|
return ""
|
|
471
|
+
if prepared["notice"]:
|
|
472
|
+
_replylib.record_near_miss(root, raw, "code")
|
|
473
|
+
return prepared["notice"]
|
|
464
474
|
try:
|
|
465
475
|
routed = _artifactpromptlib.resolve("reconciliation", prompt)
|
|
466
476
|
if routed is None:
|
|
467
|
-
|
|
477
|
+
_replylib.record_near_miss(root, raw, "no-route")
|
|
478
|
+
raise ReconciliationError("no matching armed reconciliation; send only the reply line")
|
|
468
479
|
client = _artifactlib.ArtifactClient(routed, Path(plugin_root) / "helpers" / "artifacts")
|
|
469
480
|
result = consume_reconciliation(routed, client, prompt, host=host, session_id=session_id)
|
|
470
481
|
except (ReconciliationError, _artifactlib.ArtifactError, _artifactpromptlib.PromptRouteError, OSError) as exc:
|
|
@@ -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"
|
|
@@ -132,7 +133,9 @@ def arm(root: str | Path, client: Any, spec_id: str, plan_id: str, *, delegate_m
|
|
|
132
133
|
except _artifactpromptlib.PromptRouteError:
|
|
133
134
|
(root / approval.PENDING).unlink()
|
|
134
135
|
raise
|
|
135
|
-
|
|
136
|
+
code = _replylib.offer_code(root, "approval", plan_id, reply, names=[spec_id, plan_id],
|
|
137
|
+
short_prefix=["approve-sprint", "delegate" if delegate_methods else "approve-only"])
|
|
138
|
+
return {"spec_artifact_id": spec_id, "artifact_id": plan_id, "pair": pending["pair"], "reply": reply, **code, "delegation": context.get("scope"), "approved": False}
|
|
136
139
|
|
|
137
140
|
|
|
138
141
|
def _submission_path(pending: dict[str, Any]) -> Path:
|
|
@@ -164,7 +167,7 @@ def _submission(root: Path, pending: dict[str, Any]) -> dict[str, Any] | None:
|
|
|
164
167
|
return request
|
|
165
168
|
|
|
166
169
|
|
|
167
|
-
def _source(root: Path, pending: dict[str, Any], name: str, prompt: str, host: str, session_id: str) -> dict[str, str]:
|
|
170
|
+
def _source(root: Path, pending: dict[str, Any], name: str, prompt: str, host: str, session_id: str, seam: str = "UserPromptSubmit") -> dict[str, str]:
|
|
168
171
|
locator = pending[name + "_context"]
|
|
169
172
|
context = _read(root, Path(locator["context_ref"]))
|
|
170
173
|
if (_hash(context) != locator["context_sha256"] or context.get("subject") != locator["subject"]
|
|
@@ -177,7 +180,7 @@ def _source(root: Path, pending: dict[str, Any], name: str, prompt: str, host: s
|
|
|
177
180
|
oh = _hash(observed)
|
|
178
181
|
observation_ref = Path(".codearbiter/.artifacts/observations") / f"{oh}.json"
|
|
179
182
|
_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}:
|
|
183
|
+
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
184
|
eh = _hash(event)
|
|
182
185
|
source_ref = Path(".codearbiter/.artifacts/authority-sources") / f"{eh}.json"
|
|
183
186
|
_artifactauthoritylib._publish_immutable(root, source_ref, approval._canonical(event))
|
|
@@ -210,7 +213,9 @@ def _execute(root: Path, client: Any, pending: dict[str, Any], request: dict[str
|
|
|
210
213
|
|
|
211
214
|
|
|
212
215
|
@_serialized
|
|
213
|
-
def consume(root: Path, client: Any, pending: dict[str, Any], prompt: str, *, host: str, session_id: str) -> dict[str, Any]:
|
|
216
|
+
def consume(root: Path, client: Any, pending: dict[str, Any], prompt: str, *, host: str, session_id: str, seam: str = "UserPromptSubmit") -> dict[str, Any]:
|
|
217
|
+
if seam not in {"UserPromptSubmit", "AskUserQuestion"}:
|
|
218
|
+
raise approval.ApprovalError("INVALID_HOST_CONTEXT", "approval seam is unsupported")
|
|
214
219
|
validate_pending(pending)
|
|
215
220
|
if approval._load_pending(root) != pending:
|
|
216
221
|
return {"matched": False, "approved": False}
|
|
@@ -232,8 +237,8 @@ def consume(root: Path, client: Any, pending: dict[str, Any], prompt: str, *, ho
|
|
|
232
237
|
if any(current.get(k) != v for k, v in pair[name].items()):
|
|
233
238
|
raise approval.ApprovalError("STALE_APPROVAL", "a paired artifact changed after review was requested")
|
|
234
239
|
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)
|
|
240
|
+
request["spec_source"] = _source(root, pending, "spec", prompt, host, session_id, seam)
|
|
241
|
+
request["plan_source"] = _source(root, pending, "plan", prompt, host, session_id, seam)
|
|
237
242
|
record = {"format": SUBMISSION_FORMAT, "request_key": pending["request_key"], "request": request}
|
|
238
243
|
try:
|
|
239
244
|
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.0"
|
|
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,
|
|
@@ -85,11 +85,19 @@ before asking the approval question:
|
|
|
85
85
|
python "<plugin-root>/hooks/_approvallib.py" arm --root "<project-root>" --artifact-id <artifact-id>
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
88
|
+
On Claude Code, when the result carries `ask_envelope`, pass it unchanged as the
|
|
89
|
+
entire AskUserQuestion input (no `answers`, `annotations` or other field): the
|
|
90
|
+
user approves by clicking its Approve option, and the host-observed selection is
|
|
91
|
+
applied with origin `claude:AskUserQuestion:<session>`. Otherwise, or if the
|
|
92
|
+
user prefers typing, present the returned `short_reply` (a four-character code
|
|
93
|
+
such as `approve K7MQ`); the full `reply` is also accepted, and you
|
|
94
|
+
require that exact reply: the hook tolerates surrounding whitespace, invisible characters, a code
|
|
95
|
+
fence or quotes and a closing period, but nothing else in the message, and it
|
|
96
|
+
reports a mismatch instead of staying silent. The `UserPromptSubmit` hook
|
|
97
|
+
rechecks the armed identity, records the full armed reply as the host-observed
|
|
98
|
+
policy-owned source, captures its receipt, and applies approval. An ordinary
|
|
99
|
+
`yes`, a changed artifact, a wrong or expired code, a reply inside other text, or
|
|
100
|
+
a model-authored event does not confer authority. On the next turn, verify `validate` at the `approved`
|
|
93
101
|
gate before continuing. If the user declines or the artifact changes, cancel the
|
|
94
102
|
exact pending request before arming another one:
|
|
95
103
|
|
|
@@ -149,7 +157,8 @@ python "<plugin-root>/hooks/_prerequisitelib.py" arm --root "<project-root>" --a
|
|
|
149
157
|
```
|
|
150
158
|
|
|
151
159
|
Show the returned title, requirement, and review-packet digest, then require the
|
|
152
|
-
returned `satisfy-prerequisite
|
|
160
|
+
returned `short_reply` (`satisfy-prerequisite <code>`) or the full `reply`, with
|
|
161
|
+
the same tolerance and mismatch reporting as approval. The `UserPromptSubmit` hook
|
|
153
162
|
durably records that observed decision before publishing its authority source,
|
|
154
163
|
captures a prerequisite receipt, and applies the existing prerequisite CAS
|
|
155
164
|
operation. Its final nonce is non-secret freshness/correlation data, not a
|
|
@@ -234,7 +243,8 @@ individually.
|
|
|
234
243
|
|
|
235
244
|
An interrupted or `BLOCKED` HTML task never resumes by editing its status. Arm
|
|
236
245
|
the installed reconciliation adapter for the exact task or scope, present its
|
|
237
|
-
returned
|
|
246
|
+
returned `short_reply` (`reconcile <STATE> <code>` for a task, `reconcile <code>`
|
|
247
|
+
for a scope; the full `reply` also works), and let `UserPromptSubmit` capture and apply the observed
|
|
238
248
|
user decision across repositories:
|
|
239
249
|
|
|
240
250
|
```sh
|
|
@@ -17,8 +17,10 @@ python "<plugin-root>/hooks/_approvallib.py" arm-sprint --root "<project-root>"
|
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
Omit `--delegate-methods` for approval without delegated plan-method changes. Show the returned
|
|
20
|
-
`
|
|
21
|
-
|
|
20
|
+
`short_reply`, which restates the mode: `approve-sprint delegate <code>` or
|
|
21
|
+
`approve-sprint approve-only <code>`. The full `reply` naming both IDs, `delegate-methods` or
|
|
22
|
+
`approve-only`, and the nonce is also accepted, with the same tolerance as single approval.
|
|
23
|
+
An ordinary yes, wrong code/nonce/mode or changed member does not approve either. `sprint-approve`
|
|
22
24
|
recomputes both model/revision identities and the projected binding, verifies both observations
|
|
23
25
|
from the same actual reply, and commits the two approvals through the existing native journal.
|
|
24
26
|
It produces separate bound receipts and an optional `grant_receipt`. No partial approval is visible
|