@arbiterforge/ca-pi 0.14.17 → 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 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.21.17" src="https://img.shields.io/badge/version-2.21.17-2b7489">
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arbiterforge/ca-pi",
3
- "version": "0.14.17",
3
+ "version": "0.15.0",
4
4
  "license": "AGPL-3.0-only",
5
5
  "repository": {
6
6
  "type": "git",
@@ -4,6 +4,22 @@ 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
+
7
23
  ## [0.14.17] - 2026-09-25
8
24
 
9
25
  ### Fixed
@@ -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": f"approve {artifact_id} {token}",
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}:UserPromptSubmit:{session_id}",
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 isinstance(prompt, str) and prompt.startswith(("approve ", "approve-sprint ")):
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.17"
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"
@@ -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
- attempted = isinstance(prompt, str) and prompt.startswith("satisfy-prerequisite")
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
- return (
921
- "codeArbiter: prerequisite capture failed: exact confirmation syntax required"
922
- if attempted
923
- else ""
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
- return {"artifact_id": artifact_id, "target_id": target_id, "operation": operation, "reply": reply}
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
- attempted = isinstance(prompt, str) and prompt.startswith(("reconcile-task ", "reconcile-scope "))
462
- if not attempted:
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
- raise ReconciliationError("no matching armed reconciliation")
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
- return {"spec_artifact_id": spec_id, "artifact_id": plan_id, "pair": pending["pair"], "reply": reply, "delegation": context.get("scope"), "approved": False}
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}:UserPromptSubmit:{session_id}", "verdict": "approved", "payload": payload, "source_text": prompt, "observation_ref": observation_ref.as_posix(), "observation_sha256": oh}
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.21.17"
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
- Present the returned `reply` value verbatim and require that exact reply. The
89
- `UserPromptSubmit` hook rechecks the armed identity, records the host-observed
90
- prompt as the policy-owned source, captures its receipt, and applies approval.
91
- An ordinary `yes`, a changed artifact, a wrong token, or a model-authored event
92
- does not confer authority. On the next turn, verify `validate` at the `approved`
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 ...` reply verbatim. The `UserPromptSubmit` hook
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 reply verbatim, and let `UserPromptSubmit` capture and apply the observed
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
- `reply` verbatim. It explicitly names both IDs, `delegate-methods` or `approve-only`, and the nonce.
21
- An ordinary yes, wrong nonce/mode or changed member does not approve either. `sprint-approve`
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arbiterforge/ca-pi",
3
- "version": "0.14.17",
3
+ "version": "0.15.0",
4
4
  "private": true,
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",