@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.
Files changed (58) hide show
  1. package/README.md +6 -5
  2. package/package.json +2 -1
  3. package/plugins/ca-pi/CHANGELOG.md +165 -0
  4. package/plugins/ca-pi/SKILLS.md +1 -1
  5. package/plugins/ca-pi/SPRINT.md +40 -10
  6. package/plugins/ca-pi/agents/backend-author.md +1 -1
  7. package/plugins/ca-pi/agents/frontend-author.md +1 -1
  8. package/plugins/ca-pi/generated/command-catalog.json +1 -1
  9. package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-amd64 +0 -0
  10. package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-arm64 +0 -0
  11. package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-amd64 +0 -0
  12. package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-arm64 +0 -0
  13. package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-amd64.exe +0 -0
  14. package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-arm64.exe +0 -0
  15. package/plugins/ca-pi/helpers/artifacts/release.json +38 -0
  16. package/plugins/ca-pi/hooks/_approvallib.py +355 -0
  17. package/plugins/ca-pi/hooks/_artifactlib.py +855 -0
  18. package/plugins/ca-pi/hooks/_cleanuplib.py +1553 -0
  19. package/plugins/ca-pi/hooks/_hooklib.py +37 -13
  20. package/plugins/ca-pi/hooks/_host.py +1 -1
  21. package/plugins/ca-pi/hooks/_intentlib.py +31 -0
  22. package/plugins/ca-pi/hooks/_planfilelib.py +34 -1
  23. package/plugins/ca-pi/hooks/_protectedstatelib.py +11 -1
  24. package/plugins/ca-pi/hooks/_readinjectlib.py +120 -0
  25. package/plugins/ca-pi/hooks/_releaselib.py +933 -85
  26. package/plugins/ca-pi/hooks/_standuplib.py +38 -10
  27. package/plugins/ca-pi/hooks/doctor.py +103 -0
  28. package/plugins/ca-pi/hooks/hostapi.py +1 -1
  29. package/plugins/ca-pi/hooks/pi-bridge.py +5 -1
  30. package/plugins/ca-pi/hooks/prompt-submit.py +33 -10
  31. package/plugins/ca-pi/hooks/releasehash.py +46 -32
  32. package/plugins/ca-pi/hooks/session-start.py +5 -4
  33. package/plugins/ca-pi/includes/artifacts.md +166 -0
  34. package/plugins/ca-pi/includes/author-tdd-workflow.md +2 -1
  35. package/plugins/ca-pi/includes/farm.md +27 -4
  36. package/plugins/ca-pi/includes/routing-table.md +2 -2
  37. package/plugins/ca-pi/includes/verification-boundary.md +27 -0
  38. package/plugins/ca-pi/package.json +18 -0
  39. package/plugins/ca-pi/routines/INDEX.md +4 -4
  40. package/plugins/ca-pi/routines/brainstorming/SKILL.md +23 -4
  41. package/plugins/ca-pi/routines/commit-gate/SKILL.md +30 -5
  42. package/plugins/ca-pi/routines/executing-plans/SKILL.md +52 -7
  43. package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +25 -3
  44. package/plugins/ca-pi/routines/refactor/SKILL.md +3 -2
  45. package/plugins/ca-pi/routines/release/SKILL.md +150 -44
  46. package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +53 -9
  47. package/plugins/ca-pi/routines/subagent-driven-development/references/farm-dispatch.md +20 -3
  48. package/plugins/ca-pi/routines/tdd/SKILL.md +27 -5
  49. package/plugins/ca-pi/routines/using-git-worktrees/SKILL.md +11 -2
  50. package/plugins/ca-pi/routines/writing-plans/SKILL.md +39 -10
  51. package/plugins/ca-pi/routines/writing-plans/references/farm-plan.md +34 -13
  52. package/plugins/ca-pi/skills/ca-chore/SKILL.md +7 -3
  53. package/plugins/ca-pi/skills/ca-commit/SKILL.md +3 -2
  54. package/plugins/ca-pi/skills/ca-doctor/SKILL.md +14 -0
  55. package/plugins/ca-pi/skills/ca-feature/SKILL.md +42 -8
  56. package/plugins/ca-pi/skills/ca-refactor/SKILL.md +4 -2
  57. package/plugins/ca-pi/skills/ca-release/SKILL.md +7 -6
  58. 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)