@arbiterforge/ca-pi 0.15.5 → 0.15.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 +1 -1
- package/package.json +1 -1
- package/plugins/ca-pi/CHANGELOG.md +7 -0
- package/plugins/ca-pi/hooks/_host.py +1 -1
- package/plugins/ca-pi/hooks/_reconciliationlib.py +82 -14
- package/plugins/ca-pi/hooks/artifact-reconcile.py +5 -1
- package/plugins/ca-pi/hooks/hostapi.py +1 -1
- 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.22.
|
|
13
|
+
<img alt="version 2.22.6" src="https://img.shields.io/badge/version-2.22.6-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-22-555">
|
|
16
16
|
<img alt="agents" src="https://img.shields.io/badge/agents-20-555">
|
package/package.json
CHANGED
|
@@ -4,6 +4,13 @@ All notable changes to `ca-pi` are documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.15.6] - 2026-09-27
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Keep reconciliation bookkeeping outside verification inputs so an unchanged
|
|
12
|
+
workspace can be reconciled without locked-file failures or stale approvals.
|
|
13
|
+
|
|
7
14
|
## [0.15.5] - 2026-09-26
|
|
8
15
|
|
|
9
16
|
### Fixed
|
|
@@ -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.15.
|
|
14
|
+
adapter_version = "0.15.6"
|
|
15
15
|
update_target = "ca-pi"
|
|
16
16
|
update_tag_prefix = "ca-pi-v"
|
|
17
17
|
update_command = "pi update npm:@arbiterforge/ca-pi"
|
|
@@ -23,8 +23,10 @@ import _replylib
|
|
|
23
23
|
|
|
24
24
|
ID_RE = re.compile(r"[A-Z][A-Z0-9_-]{0,127}")
|
|
25
25
|
TOKEN_RE = re.compile(r"[A-Za-z0-9_-]{12,128}")
|
|
26
|
-
PENDING_DIR = Path("
|
|
27
|
-
ATTEMPT_DIR = Path("
|
|
26
|
+
PENDING_DIR = Path("pending")
|
|
27
|
+
ATTEMPT_DIR = Path("attempts")
|
|
28
|
+
LEGACY_PENDING_DIR = Path(".codearbiter/.markers/reconciliations")
|
|
29
|
+
LEGACY_ATTEMPT_DIR = Path(".codearbiter/.markers/reconciliation-attempts")
|
|
28
30
|
SOURCE_DIR = Path(".codearbiter/.artifacts/authority-sources")
|
|
29
31
|
PROVEN_UNCOMMITTED = frozenset({"REVISION_CONFLICT", "OPERATION_ROLLED_BACK"})
|
|
30
32
|
|
|
@@ -53,6 +55,65 @@ def _attempt(pending: dict[str, Any]) -> Path:
|
|
|
53
55
|
return ATTEMPT_DIR / f"{_operation_id(pending)}.json"
|
|
54
56
|
|
|
55
57
|
|
|
58
|
+
def _state_root(root: Path) -> Path:
|
|
59
|
+
"""Keep operational state outside every repository input root."""
|
|
60
|
+
try:
|
|
61
|
+
spool = _artifactauthoritylib._spool_root(root)
|
|
62
|
+
return _artifactauthoritylib._safe_directory(spool, Path("reconciliations"), create=True)
|
|
63
|
+
except _artifactauthoritylib.AuthorityError as exc:
|
|
64
|
+
raise ReconciliationError("Git admin reconciliation storage is unavailable or unsafe") from exc
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _legacy_pending(root: Path, artifact_id: str) -> Path:
|
|
68
|
+
return _legacy_file(root, LEGACY_PENDING_DIR, f"{artifact_id}.json")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _legacy_file(root: Path, directory: Path, filename: str) -> Path:
|
|
72
|
+
try:
|
|
73
|
+
parent = _approvallib._safe_directory(root, directory, create=False)
|
|
74
|
+
except FileNotFoundError:
|
|
75
|
+
return root / directory / filename
|
|
76
|
+
except _approvallib.ApprovalError as exc:
|
|
77
|
+
raise ReconciliationError("legacy reconciliation directory is unsafe") from exc
|
|
78
|
+
return parent / filename
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _state_file(root: Path, directory: Path, filename: str) -> Path:
|
|
82
|
+
try:
|
|
83
|
+
parent = _artifactauthoritylib._safe_directory(_state_root(root), directory, create=True)
|
|
84
|
+
except _artifactauthoritylib.AuthorityError as exc:
|
|
85
|
+
raise ReconciliationError("Git admin reconciliation directory is unsafe") from exc
|
|
86
|
+
return parent / filename
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _present(path: Path) -> bool:
|
|
90
|
+
try:
|
|
91
|
+
path.lstat()
|
|
92
|
+
except FileNotFoundError:
|
|
93
|
+
return False
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _pending_path(root: Path, artifact_id: str) -> Path:
|
|
98
|
+
current = _state_file(root, PENDING_DIR, f"{artifact_id}.json")
|
|
99
|
+
legacy = _legacy_pending(root, artifact_id)
|
|
100
|
+
if _present(current) and _present(legacy):
|
|
101
|
+
raise ReconciliationError("ambiguous reconciliation state in repository and Git admin storage")
|
|
102
|
+
return legacy if _present(legacy) else current
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _attempt_path(root: Path, pending: dict[str, Any]) -> Path:
|
|
106
|
+
filename = f"{_operation_id(pending)}.json"
|
|
107
|
+
legacy = _legacy_file(root, LEGACY_ATTEMPT_DIR, filename)
|
|
108
|
+
current = _state_file(root, ATTEMPT_DIR, filename)
|
|
109
|
+
legacy_pending = _present(_legacy_pending(root, pending["artifact_id"]))
|
|
110
|
+
if (_present(legacy) and _present(current)) or (
|
|
111
|
+
legacy_pending and _present(current)
|
|
112
|
+
) or (not legacy_pending and _present(legacy)):
|
|
113
|
+
raise ReconciliationError("mixed reconciliation state requires explicit repair")
|
|
114
|
+
return legacy if legacy_pending else current
|
|
115
|
+
|
|
116
|
+
|
|
56
117
|
def _mutation_request(pending: dict[str, Any], receipt: str) -> dict[str, Any]:
|
|
57
118
|
identity = pending["identity"]
|
|
58
119
|
request = {
|
|
@@ -72,7 +133,7 @@ def _reconciliation_lock(root: Path, artifact_id: str):
|
|
|
72
133
|
"""Serialize all transitions for one artifact, including authority revocation."""
|
|
73
134
|
if ID_RE.fullmatch(artifact_id) is None:
|
|
74
135
|
raise ReconciliationError("reconciliation ID is invalid")
|
|
75
|
-
parent =
|
|
136
|
+
parent = _state_file(root, PENDING_DIR, artifact_id).parent
|
|
76
137
|
path = parent / f"{artifact_id}.lock"
|
|
77
138
|
flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
78
139
|
fd = os.open(path, flags, 0o600)
|
|
@@ -147,11 +208,11 @@ def _write_attempt(root: Path, pending: dict[str, Any], request: dict[str, Any])
|
|
|
147
208
|
"format": "codearbiter.reconciliation-attempt/0.1.0",
|
|
148
209
|
"pending_sha256": _digest(_canonical(pending)), "request": request,
|
|
149
210
|
}
|
|
150
|
-
_write_atomic_new(root, _attempt(pending), _canonical(record))
|
|
211
|
+
_write_atomic_new(_state_root(root), _attempt(pending), _canonical(record))
|
|
151
212
|
|
|
152
213
|
|
|
153
214
|
def _load_attempt(root: Path, pending: dict[str, Any]) -> dict[str, Any] | None:
|
|
154
|
-
path = root
|
|
215
|
+
path = _attempt_path(root, pending)
|
|
155
216
|
try:
|
|
156
217
|
info = path.lstat()
|
|
157
218
|
except FileNotFoundError:
|
|
@@ -225,6 +286,8 @@ def _arm_reconciliation_unlocked(
|
|
|
225
286
|
token = token or secrets.token_urlsafe(24)
|
|
226
287
|
if TOKEN_RE.fullmatch(token) is None:
|
|
227
288
|
raise ReconciliationError("reconciliation token is invalid")
|
|
289
|
+
if _present(_legacy_pending(root, artifact_id)):
|
|
290
|
+
raise ReconciliationError("PENDING_RECONCILIATION: legacy request must be cancelled or recovered")
|
|
228
291
|
identity, snapshot, record = _current(client, artifact_id, target_id)
|
|
229
292
|
reply = (
|
|
230
293
|
f"reconcile-task {artifact_id} {target_id} {target_state} {token}"
|
|
@@ -240,10 +303,11 @@ def _arm_reconciliation_unlocked(
|
|
|
240
303
|
"prompt_sha256": _digest(reply.encode()),
|
|
241
304
|
}
|
|
242
305
|
relative = _pending(artifact_id)
|
|
306
|
+
state = _state_root(root)
|
|
243
307
|
created = False
|
|
244
308
|
try:
|
|
245
309
|
pending_bytes = _canonical(pending)
|
|
246
|
-
_write_atomic_new(
|
|
310
|
+
_write_atomic_new(state, relative, pending_bytes)
|
|
247
311
|
created = True
|
|
248
312
|
_artifactpromptlib.register(
|
|
249
313
|
root, "reconciliation", artifact_id, reply,
|
|
@@ -254,7 +318,7 @@ def _arm_reconciliation_unlocked(
|
|
|
254
318
|
except Exception:
|
|
255
319
|
if created:
|
|
256
320
|
try:
|
|
257
|
-
(
|
|
321
|
+
(state / relative).unlink()
|
|
258
322
|
except OSError:
|
|
259
323
|
pass
|
|
260
324
|
raise
|
|
@@ -267,7 +331,7 @@ def _arm_reconciliation_unlocked(
|
|
|
267
331
|
|
|
268
332
|
def _load(root: Path, artifact_id: str) -> dict[str, Any]:
|
|
269
333
|
try:
|
|
270
|
-
path = root
|
|
334
|
+
path = _pending_path(root, artifact_id)
|
|
271
335
|
info = path.lstat()
|
|
272
336
|
reparse = getattr(info, "st_file_attributes", 0) & 0x400
|
|
273
337
|
if stat.S_ISLNK(info.st_mode) or reparse or not stat.S_ISREG(info.st_mode) or info.st_size > 131072:
|
|
@@ -328,7 +392,7 @@ def _cancel_reconciliation_unlocked(root: Path, artifact_id: str, prompt: str) -
|
|
|
328
392
|
raise ReconciliationError("reconciliation authority binding is unavailable") from exc
|
|
329
393
|
if binding != _digest(_canonical(pending)):
|
|
330
394
|
raise ReconciliationError("pending reconciliation changed after arming")
|
|
331
|
-
(root
|
|
395
|
+
_pending_path(root, artifact_id).unlink()
|
|
332
396
|
_artifactpromptlib.unregister(root, "reconciliation", artifact_id)
|
|
333
397
|
return {"artifact_id": artifact_id, "cancelled": True}
|
|
334
398
|
|
|
@@ -349,7 +413,7 @@ def cancel_orphaned_reconciliation(root: str | Path, artifact_id: str) -> dict[s
|
|
|
349
413
|
raise ReconciliationError("reconciliation authority binding is unreadable") from exc
|
|
350
414
|
if binding == _digest(_canonical(pending)):
|
|
351
415
|
raise ReconciliationError("reconciliation route is active; cancel with its exact prompt")
|
|
352
|
-
(root
|
|
416
|
+
_pending_path(root, artifact_id).unlink()
|
|
353
417
|
if binding is not None:
|
|
354
418
|
_artifactpromptlib.unregister(root, "reconciliation", artifact_id)
|
|
355
419
|
return {"artifact_id": artifact_id, "cancelled": True, "orphaned": True}
|
|
@@ -379,9 +443,10 @@ def recover_reconciliation(root: str | Path, client: Any, artifact_id: str) -> d
|
|
|
379
443
|
raise
|
|
380
444
|
result = None
|
|
381
445
|
committed = False
|
|
382
|
-
(root
|
|
446
|
+
attempt_path = _attempt_path(root, pending)
|
|
447
|
+
_pending_path(root, artifact_id).unlink()
|
|
383
448
|
_artifactpromptlib.unregister(root, "reconciliation", artifact_id)
|
|
384
|
-
|
|
449
|
+
attempt_path.unlink()
|
|
385
450
|
return {
|
|
386
451
|
"artifact_id": artifact_id, "committed": committed,
|
|
387
452
|
"revision": result.get("revision") if committed else None,
|
|
@@ -407,6 +472,8 @@ def _consume_reconciliation_unlocked(
|
|
|
407
472
|
parts = prompt.split(" ")
|
|
408
473
|
artifact_id, target_id = parts[1], parts[2]
|
|
409
474
|
pending = _load(root, artifact_id)
|
|
475
|
+
if _present(_legacy_pending(root, artifact_id)):
|
|
476
|
+
raise ReconciliationError("legacy reconciliation must be cancelled or recovered before consumption")
|
|
410
477
|
if not isinstance(host, str) or not host or not isinstance(session_id, str) or not session_id:
|
|
411
478
|
raise ReconciliationError("reconciliation host context is invalid")
|
|
412
479
|
try:
|
|
@@ -456,9 +523,10 @@ def _consume_reconciliation_unlocked(
|
|
|
456
523
|
if request is None:
|
|
457
524
|
raise ReconciliationError("in-flight reconciliation record disappeared")
|
|
458
525
|
result = client.call(pending["operation"], request)
|
|
459
|
-
(root
|
|
526
|
+
attempt_path = _attempt_path(root, pending)
|
|
527
|
+
_pending_path(root, artifact_id).unlink()
|
|
460
528
|
_artifactpromptlib.unregister(root, "reconciliation", artifact_id)
|
|
461
|
-
|
|
529
|
+
attempt_path.unlink()
|
|
462
530
|
return {"matched": True, "reconciled": True, "artifact_id": artifact_id, "target_id": target_id, "receipt": request["receipt"], "revision": result.get("revision")}
|
|
463
531
|
|
|
464
532
|
|
|
@@ -54,4 +54,8 @@ def main(argv=None):
|
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
if __name__ == "__main__":
|
|
57
|
-
|
|
57
|
+
try:
|
|
58
|
+
raise SystemExit(main())
|
|
59
|
+
except (_reconciliationlib.ReconciliationError, _artifactlib.ArtifactError, OSError) as exc:
|
|
60
|
+
print(f"codeArbiter: reconciliation failed: {exc}", file=sys.stderr)
|
|
61
|
+
raise SystemExit(1) from exc
|
|
@@ -313,7 +313,7 @@ class Host:
|
|
|
313
313
|
|
|
314
314
|
name = "claude"
|
|
315
315
|
adapter_name = "ca"
|
|
316
|
-
adapter_version = "2.22.
|
|
316
|
+
adapter_version = "2.22.6"
|
|
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,
|