@arbiterforge/ca-pi 0.10.12 → 0.14.6
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 +6 -5
- package/package.json +2 -1
- package/plugins/ca-pi/CHANGELOG.md +165 -0
- package/plugins/ca-pi/SKILLS.md +1 -1
- package/plugins/ca-pi/SPRINT.md +40 -10
- package/plugins/ca-pi/agents/backend-author.md +1 -1
- package/plugins/ca-pi/agents/frontend-author.md +1 -1
- package/plugins/ca-pi/generated/command-catalog.json +1 -1
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-amd64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-arm64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-amd64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-arm64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-amd64.exe +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-arm64.exe +0 -0
- package/plugins/ca-pi/helpers/artifacts/release.json +38 -0
- package/plugins/ca-pi/hooks/_approvallib.py +355 -0
- package/plugins/ca-pi/hooks/_artifactlib.py +855 -0
- package/plugins/ca-pi/hooks/_cleanuplib.py +1553 -0
- package/plugins/ca-pi/hooks/_hooklib.py +37 -13
- package/plugins/ca-pi/hooks/_host.py +1 -1
- package/plugins/ca-pi/hooks/_intentlib.py +31 -0
- package/plugins/ca-pi/hooks/_planfilelib.py +34 -1
- package/plugins/ca-pi/hooks/_protectedstatelib.py +11 -1
- package/plugins/ca-pi/hooks/_readinjectlib.py +120 -0
- package/plugins/ca-pi/hooks/_releaselib.py +933 -85
- package/plugins/ca-pi/hooks/_standuplib.py +38 -10
- package/plugins/ca-pi/hooks/doctor.py +103 -0
- package/plugins/ca-pi/hooks/hostapi.py +1 -1
- package/plugins/ca-pi/hooks/pi-bridge.py +5 -1
- package/plugins/ca-pi/hooks/prompt-submit.py +33 -10
- package/plugins/ca-pi/hooks/releasehash.py +46 -32
- package/plugins/ca-pi/hooks/session-start.py +5 -4
- package/plugins/ca-pi/includes/artifacts.md +166 -0
- package/plugins/ca-pi/includes/author-tdd-workflow.md +2 -1
- package/plugins/ca-pi/includes/farm.md +27 -4
- package/plugins/ca-pi/includes/routing-table.md +2 -2
- package/plugins/ca-pi/includes/verification-boundary.md +27 -0
- package/plugins/ca-pi/package.json +18 -0
- package/plugins/ca-pi/routines/INDEX.md +4 -4
- package/plugins/ca-pi/routines/brainstorming/SKILL.md +23 -4
- package/plugins/ca-pi/routines/commit-gate/SKILL.md +30 -5
- package/plugins/ca-pi/routines/executing-plans/SKILL.md +52 -7
- package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +25 -3
- package/plugins/ca-pi/routines/refactor/SKILL.md +3 -2
- package/plugins/ca-pi/routines/release/SKILL.md +150 -44
- package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +53 -9
- package/plugins/ca-pi/routines/subagent-driven-development/references/farm-dispatch.md +20 -3
- package/plugins/ca-pi/routines/tdd/SKILL.md +27 -5
- package/plugins/ca-pi/routines/using-git-worktrees/SKILL.md +11 -2
- package/plugins/ca-pi/routines/writing-plans/SKILL.md +39 -10
- package/plugins/ca-pi/routines/writing-plans/references/farm-plan.md +34 -13
- package/plugins/ca-pi/skills/ca-chore/SKILL.md +7 -3
- package/plugins/ca-pi/skills/ca-commit/SKILL.md +3 -2
- package/plugins/ca-pi/skills/ca-doctor/SKILL.md +14 -0
- package/plugins/ca-pi/skills/ca-feature/SKILL.md +42 -8
- package/plugins/ca-pi/skills/ca-refactor/SKILL.md +4 -2
- package/plugins/ca-pi/skills/ca-release/SKILL.md +7 -6
- package/plugins/ca-pi/skills/ca-standup/SKILL.md +36 -8
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Host-owned approval capture for structured artifacts."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import re
|
|
12
|
+
import secrets
|
|
13
|
+
import stat
|
|
14
|
+
import sys
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import _artifactlib
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
PENDING = Path(".codearbiter/.markers/pending-user-approval.json")
|
|
21
|
+
SOURCE_DIR = Path(".codearbiter/.artifacts/authority-sources")
|
|
22
|
+
TOKEN_RE = re.compile(r"[A-Za-z0-9_-]{12,128}")
|
|
23
|
+
HOST_RE = re.compile(r"[A-Za-z0-9_-]{1,32}")
|
|
24
|
+
SESSION_RE = re.compile(r"[A-Za-z0-9._:-]{1,256}")
|
|
25
|
+
SHA256_RE = re.compile(r"[0-9a-f]{64}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ApprovalError(RuntimeError):
|
|
29
|
+
def __init__(self, code: str, message: str):
|
|
30
|
+
self.code = code
|
|
31
|
+
super().__init__(f"{code}: {message}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _canonical(value: Any) -> bytes:
|
|
35
|
+
return json.dumps(
|
|
36
|
+
value, ensure_ascii=True, allow_nan=False, sort_keys=True, separators=(",", ":")
|
|
37
|
+
).encode("utf-8")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _identity(client: Any, artifact_id: str) -> dict[str, Any]:
|
|
41
|
+
identity = client.call("identity", {"artifact_id": artifact_id})
|
|
42
|
+
required = {
|
|
43
|
+
"artifact_id": str,
|
|
44
|
+
"kind": str,
|
|
45
|
+
"revision": int,
|
|
46
|
+
"model_sha256": str,
|
|
47
|
+
"normative_sha256": str,
|
|
48
|
+
}
|
|
49
|
+
if any(type(identity.get(key)) is not kind for key, kind in required.items()):
|
|
50
|
+
raise ApprovalError("INVALID_IDENTITY", "artifact identity is incomplete")
|
|
51
|
+
if identity["artifact_id"] != artifact_id:
|
|
52
|
+
raise ApprovalError("INVALID_IDENTITY", "engine returned another artifact")
|
|
53
|
+
for key in ("model_sha256", "normative_sha256"):
|
|
54
|
+
if not SHA256_RE.fullmatch(identity[key]):
|
|
55
|
+
raise ApprovalError("INVALID_IDENTITY", f"{key} is malformed")
|
|
56
|
+
return identity
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _ready(client: Any, identity: dict[str, Any]) -> None:
|
|
60
|
+
result = client.call(
|
|
61
|
+
"validate",
|
|
62
|
+
{"artifact_id": identity["artifact_id"], "gate": "ready"},
|
|
63
|
+
permit_invalid=True,
|
|
64
|
+
)
|
|
65
|
+
if (
|
|
66
|
+
result.get("valid") is not True
|
|
67
|
+
or result.get("artifact_id") != identity["artifact_id"]
|
|
68
|
+
or result.get("model_sha256") != identity["model_sha256"]
|
|
69
|
+
or result.get("normative_sha256") != identity["normative_sha256"]
|
|
70
|
+
):
|
|
71
|
+
raise ApprovalError("NOT_READY", "only the exact current ready artifact can be armed")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _safe_directory(root: Path, relative: Path, *, create: bool) -> Path:
|
|
75
|
+
current = root
|
|
76
|
+
for part in relative.parts:
|
|
77
|
+
current = current / part
|
|
78
|
+
try:
|
|
79
|
+
info = current.lstat()
|
|
80
|
+
except FileNotFoundError:
|
|
81
|
+
if not create:
|
|
82
|
+
raise
|
|
83
|
+
current.mkdir()
|
|
84
|
+
info = current.lstat()
|
|
85
|
+
reparse = getattr(info, "st_file_attributes", 0) & 0x400
|
|
86
|
+
if stat.S_ISLNK(info.st_mode) or reparse or not stat.S_ISDIR(info.st_mode):
|
|
87
|
+
raise ApprovalError(
|
|
88
|
+
"UNSAFE_APPROVAL_PATH", "approval state directories must be real directories"
|
|
89
|
+
)
|
|
90
|
+
return current
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _write_new(root: Path, relative: Path, data: bytes) -> None:
|
|
94
|
+
parent = _safe_directory(root, relative.parent, create=True)
|
|
95
|
+
path = parent / relative.name
|
|
96
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
97
|
+
if hasattr(os, "O_BINARY"):
|
|
98
|
+
flags |= os.O_BINARY
|
|
99
|
+
fd = os.open(path, flags, 0o600)
|
|
100
|
+
completed = False
|
|
101
|
+
try:
|
|
102
|
+
view = memoryview(data)
|
|
103
|
+
while view:
|
|
104
|
+
written = os.write(fd, view)
|
|
105
|
+
if written <= 0:
|
|
106
|
+
raise OSError("short write")
|
|
107
|
+
view = view[written:]
|
|
108
|
+
os.fsync(fd)
|
|
109
|
+
os.close(fd)
|
|
110
|
+
fd = None
|
|
111
|
+
completed = True
|
|
112
|
+
finally:
|
|
113
|
+
if fd is not None:
|
|
114
|
+
try:
|
|
115
|
+
os.close(fd)
|
|
116
|
+
except OSError:
|
|
117
|
+
pass
|
|
118
|
+
if not completed:
|
|
119
|
+
try:
|
|
120
|
+
path.unlink()
|
|
121
|
+
except OSError:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def arm_user_approval(
|
|
126
|
+
root: str | Path,
|
|
127
|
+
client: Any,
|
|
128
|
+
artifact_id: str,
|
|
129
|
+
*,
|
|
130
|
+
token: str | None = None,
|
|
131
|
+
) -> dict[str, Any]:
|
|
132
|
+
root = Path(root).resolve(strict=True)
|
|
133
|
+
identity = _identity(client, artifact_id)
|
|
134
|
+
_ready(client, identity)
|
|
135
|
+
token = token or secrets.token_urlsafe(24)
|
|
136
|
+
if not TOKEN_RE.fullmatch(token):
|
|
137
|
+
raise ApprovalError("INVALID_TOKEN", "approval token has an invalid shape")
|
|
138
|
+
pending = {
|
|
139
|
+
"format": "codearbiter.pending-user-approval/0.1.0",
|
|
140
|
+
"artifact_id": identity["artifact_id"],
|
|
141
|
+
"kind": identity["kind"],
|
|
142
|
+
"revision": identity["revision"],
|
|
143
|
+
"model_sha256": identity["model_sha256"],
|
|
144
|
+
"normative_sha256": identity["normative_sha256"],
|
|
145
|
+
"token_sha256": hashlib.sha256(token.encode("ascii")).hexdigest(),
|
|
146
|
+
}
|
|
147
|
+
try:
|
|
148
|
+
_write_new(root, PENDING, _canonical(pending))
|
|
149
|
+
except FileExistsError as exc:
|
|
150
|
+
raise ApprovalError(
|
|
151
|
+
"PENDING_APPROVAL", "resolve or clear the existing approval request first"
|
|
152
|
+
) from exc
|
|
153
|
+
return {
|
|
154
|
+
"artifact_id": artifact_id,
|
|
155
|
+
"reply": f"approve {artifact_id} {token}",
|
|
156
|
+
"model_sha256": identity["model_sha256"],
|
|
157
|
+
"normative_sha256": identity["normative_sha256"],
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _load_pending(root: Path) -> dict[str, Any] | None:
|
|
162
|
+
try:
|
|
163
|
+
parent = _safe_directory(root, PENDING.parent, create=False)
|
|
164
|
+
except FileNotFoundError:
|
|
165
|
+
return None
|
|
166
|
+
path = parent / PENDING.name
|
|
167
|
+
try:
|
|
168
|
+
info = path.lstat()
|
|
169
|
+
reparse = getattr(info, "st_file_attributes", 0) & 0x400
|
|
170
|
+
if (
|
|
171
|
+
stat.S_ISLNK(info.st_mode)
|
|
172
|
+
or reparse
|
|
173
|
+
or not stat.S_ISREG(info.st_mode)
|
|
174
|
+
or info.st_size > 16384
|
|
175
|
+
):
|
|
176
|
+
raise ApprovalError(
|
|
177
|
+
"INVALID_PENDING_APPROVAL",
|
|
178
|
+
"pending approval is not bounded regular data",
|
|
179
|
+
)
|
|
180
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
181
|
+
except FileNotFoundError:
|
|
182
|
+
return None
|
|
183
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
184
|
+
raise ApprovalError("INVALID_PENDING_APPROVAL", "pending approval is unreadable") from exc
|
|
185
|
+
expected = {
|
|
186
|
+
"format", "artifact_id", "kind", "revision", "model_sha256",
|
|
187
|
+
"normative_sha256", "token_sha256",
|
|
188
|
+
}
|
|
189
|
+
if not isinstance(value, dict) or set(value) != expected:
|
|
190
|
+
raise ApprovalError("INVALID_PENDING_APPROVAL", "pending approval shape is invalid")
|
|
191
|
+
if (
|
|
192
|
+
value["format"] != "codearbiter.pending-user-approval/0.1.0"
|
|
193
|
+
or not isinstance(value["artifact_id"], str)
|
|
194
|
+
or not value["artifact_id"]
|
|
195
|
+
or not isinstance(value["kind"], str)
|
|
196
|
+
or not value["kind"]
|
|
197
|
+
or type(value["revision"]) is not int
|
|
198
|
+
or not isinstance(value["model_sha256"], str)
|
|
199
|
+
or SHA256_RE.fullmatch(value["model_sha256"]) is None
|
|
200
|
+
or not isinstance(value["normative_sha256"], str)
|
|
201
|
+
or SHA256_RE.fullmatch(value["normative_sha256"]) is None
|
|
202
|
+
or not isinstance(value["token_sha256"], str)
|
|
203
|
+
or SHA256_RE.fullmatch(value["token_sha256"]) is None
|
|
204
|
+
):
|
|
205
|
+
raise ApprovalError("INVALID_PENDING_APPROVAL", "pending approval values are invalid")
|
|
206
|
+
return value
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def cancel_user_approval(root: str | Path, artifact_id: str) -> dict[str, Any]:
|
|
210
|
+
root = Path(root).resolve(strict=True)
|
|
211
|
+
pending = _load_pending(root)
|
|
212
|
+
if pending is None:
|
|
213
|
+
raise ApprovalError("NO_PENDING_APPROVAL", "there is no approval request to cancel")
|
|
214
|
+
if pending["artifact_id"] != artifact_id:
|
|
215
|
+
raise ApprovalError(
|
|
216
|
+
"PENDING_APPROVAL_MISMATCH",
|
|
217
|
+
"the pending approval request belongs to another artifact",
|
|
218
|
+
)
|
|
219
|
+
(root / PENDING).unlink()
|
|
220
|
+
return {"artifact_id": artifact_id, "cancelled": True}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def consume_user_approval(
|
|
224
|
+
root: str | Path,
|
|
225
|
+
client: Any,
|
|
226
|
+
prompt: str,
|
|
227
|
+
*,
|
|
228
|
+
host: str,
|
|
229
|
+
session_id: str,
|
|
230
|
+
) -> dict[str, Any]:
|
|
231
|
+
root = Path(root).resolve(strict=True)
|
|
232
|
+
pending = _load_pending(root)
|
|
233
|
+
if pending is None or not isinstance(prompt, str):
|
|
234
|
+
return {"matched": False, "approved": False}
|
|
235
|
+
parts = prompt.split(" ")
|
|
236
|
+
if len(parts) != 3 or parts[0] != "approve" or parts[1] != pending["artifact_id"]:
|
|
237
|
+
return {"matched": False, "approved": False}
|
|
238
|
+
token = parts[2]
|
|
239
|
+
if not TOKEN_RE.fullmatch(token) or not secrets.compare_digest(
|
|
240
|
+
hashlib.sha256(token.encode("ascii")).hexdigest(), pending["token_sha256"]
|
|
241
|
+
):
|
|
242
|
+
return {"matched": False, "approved": False}
|
|
243
|
+
if not HOST_RE.fullmatch(host) or not SESSION_RE.fullmatch(session_id):
|
|
244
|
+
raise ApprovalError("INVALID_HOST_CONTEXT", "host approval context is malformed")
|
|
245
|
+
|
|
246
|
+
current = _identity(client, pending["artifact_id"])
|
|
247
|
+
for field in ("kind", "revision", "model_sha256", "normative_sha256"):
|
|
248
|
+
if current[field] != pending[field]:
|
|
249
|
+
raise ApprovalError("STALE_APPROVAL", "artifact changed after approval was requested")
|
|
250
|
+
_ready(client, current)
|
|
251
|
+
|
|
252
|
+
event = {
|
|
253
|
+
"format": "codearbiter.workflow-event/0.1.0",
|
|
254
|
+
"kind": "approval",
|
|
255
|
+
"authority_kind": "user_workflow",
|
|
256
|
+
"subject": {
|
|
257
|
+
"artifact_id": current["artifact_id"],
|
|
258
|
+
"normative_sha256": current["normative_sha256"],
|
|
259
|
+
"record_id": current["artifact_id"],
|
|
260
|
+
},
|
|
261
|
+
"actor": "interactive repository user",
|
|
262
|
+
"origin": f"{host}:UserPromptSubmit:{session_id}",
|
|
263
|
+
"verdict": "approved",
|
|
264
|
+
"payload": {},
|
|
265
|
+
"source_text": prompt,
|
|
266
|
+
}
|
|
267
|
+
event_bytes = _canonical(event)
|
|
268
|
+
digest = hashlib.sha256(event_bytes).hexdigest()
|
|
269
|
+
source_rel = SOURCE_DIR / f"{digest}.json"
|
|
270
|
+
source = root / source_rel
|
|
271
|
+
try:
|
|
272
|
+
_write_new(root, source_rel, event_bytes)
|
|
273
|
+
except FileExistsError:
|
|
274
|
+
if source.read_bytes() != event_bytes:
|
|
275
|
+
raise ApprovalError("INVALID_AUTHORITY_SOURCE", "content-addressed source differs")
|
|
276
|
+
|
|
277
|
+
captured = client.call(
|
|
278
|
+
"capture",
|
|
279
|
+
{"source_ref": source_rel.as_posix(), "source_sha256": digest},
|
|
280
|
+
)
|
|
281
|
+
receipt = captured.get("receipt")
|
|
282
|
+
if not isinstance(receipt, str) or not receipt:
|
|
283
|
+
raise ApprovalError("INVALID_RECEIPT", "capture returned no receipt")
|
|
284
|
+
approved = client.call(
|
|
285
|
+
"approve",
|
|
286
|
+
{
|
|
287
|
+
"artifact_id": current["artifact_id"],
|
|
288
|
+
"operation_id": f"host-user-approval-{digest[:24]}",
|
|
289
|
+
"expected": {
|
|
290
|
+
"revision": current["revision"],
|
|
291
|
+
"model_sha256": current["model_sha256"],
|
|
292
|
+
},
|
|
293
|
+
"receipt": receipt,
|
|
294
|
+
},
|
|
295
|
+
)
|
|
296
|
+
(root / PENDING).unlink()
|
|
297
|
+
return {
|
|
298
|
+
"matched": True,
|
|
299
|
+
"approved": True,
|
|
300
|
+
"artifact_id": current["artifact_id"],
|
|
301
|
+
"authority_source": source_rel.as_posix(),
|
|
302
|
+
"receipt": receipt,
|
|
303
|
+
"revision": approved.get("revision"),
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def consume_from_hook(*, root: str | Path, plugin_root: str | Path, prompt: str,
|
|
308
|
+
host: str, session_id: str) -> str:
|
|
309
|
+
if not (Path(root) / PENDING).exists():
|
|
310
|
+
return ""
|
|
311
|
+
try:
|
|
312
|
+
client = _artifactlib.ArtifactClient(
|
|
313
|
+
root, Path(plugin_root) / "helpers" / "artifacts"
|
|
314
|
+
)
|
|
315
|
+
result = consume_user_approval(
|
|
316
|
+
root, client, prompt, host=host, session_id=session_id
|
|
317
|
+
)
|
|
318
|
+
except (ApprovalError, _artifactlib.ArtifactError, OSError) as exc:
|
|
319
|
+
if isinstance(prompt, str) and prompt.startswith("approve "):
|
|
320
|
+
return f"codeArbiter: approval capture failed: {exc}"
|
|
321
|
+
return ""
|
|
322
|
+
if not result.get("approved"):
|
|
323
|
+
return ""
|
|
324
|
+
return (
|
|
325
|
+
"codeArbiter: workflow approval recorded for "
|
|
326
|
+
f"{result['artifact_id']} (receipt {result['receipt']})."
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def main(argv: list[str] | None = None) -> int:
|
|
331
|
+
parser = argparse.ArgumentParser()
|
|
332
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
333
|
+
arm = sub.add_parser("arm")
|
|
334
|
+
arm.add_argument("--root", required=True)
|
|
335
|
+
arm.add_argument("--artifact-id", required=True)
|
|
336
|
+
cancel = sub.add_parser("cancel")
|
|
337
|
+
cancel.add_argument("--root", required=True)
|
|
338
|
+
cancel.add_argument("--artifact-id", required=True)
|
|
339
|
+
args = parser.parse_args(argv)
|
|
340
|
+
if args.command == "cancel":
|
|
341
|
+
print(json.dumps(cancel_user_approval(args.root, args.artifact_id)))
|
|
342
|
+
return 0
|
|
343
|
+
client = _artifactlib.ArtifactClient(
|
|
344
|
+
args.root, _artifactlib.helper_installation(__file__)
|
|
345
|
+
)
|
|
346
|
+
print(json.dumps(arm_user_approval(args.root, client, args.artifact_id)))
|
|
347
|
+
return 0
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
if __name__ == "__main__":
|
|
351
|
+
try:
|
|
352
|
+
raise SystemExit(main())
|
|
353
|
+
except (ApprovalError, _artifactlib.ArtifactError) as exc:
|
|
354
|
+
sys.stderr.write(str(exc) + "\n")
|
|
355
|
+
raise SystemExit(1)
|