@arbiterforge/ca-pi 0.14.9 → 0.14.12
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 +7 -2
- package/package.json +1 -1
- package/plugins/ca-pi/CHANGELOG.md +39 -0
- package/plugins/ca-pi/SKILLS.md +2 -2
- package/plugins/ca-pi/SPRINT.md +47 -0
- package/plugins/ca-pi/generated/command-catalog.json +2 -2
- 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 +6 -6
- package/plugins/ca-pi/hooks/_approvallib.py +58 -22
- package/plugins/ca-pi/hooks/_artifactauthoritylib.py +1686 -0
- package/plugins/ca-pi/hooks/_artifactlib.py +14 -4
- package/plugins/ca-pi/hooks/_artifactpromptlib.py +177 -0
- package/plugins/ca-pi/hooks/_githooks.py +368 -39
- package/plugins/ca-pi/hooks/_host.py +1 -1
- package/plugins/ca-pi/hooks/_overrideappendlib.py +5 -1
- package/plugins/ca-pi/hooks/_prerequisitelib.py +44 -22
- package/plugins/ca-pi/hooks/_reconciliationlib.py +472 -0
- package/plugins/ca-pi/hooks/_sprintapprovallib.py +302 -0
- package/plugins/ca-pi/hooks/artifact-authority-hook.py +72 -0
- package/plugins/ca-pi/hooks/artifact-authority.py +80 -0
- package/plugins/ca-pi/hooks/artifact-reconcile.py +57 -0
- package/plugins/ca-pi/hooks/doctor.py +3 -1
- package/plugins/ca-pi/hooks/hostapi.py +1 -1
- package/plugins/ca-pi/hooks/prompt-submit.py +20 -3
- package/plugins/ca-pi/includes/artifacts.md +99 -12
- package/plugins/ca-pi/includes/sprint-authority.md +69 -0
- package/plugins/ca-pi/package.json +1 -1
- package/plugins/ca-pi/routines/INDEX.md +7 -4
- package/plugins/ca-pi/routines/brainstorming/SKILL.md +97 -29
- package/plugins/ca-pi/routines/commit-gate/SKILL.md +7 -1
- package/plugins/ca-pi/routines/executing-plans/SKILL.md +32 -15
- package/plugins/ca-pi/routines/skill-author/SKILL.md +26 -5
- package/plugins/ca-pi/routines/writing-plans/SKILL.md +22 -10
- package/plugins/ca-pi/skills/ca-commit/SKILL.md +170 -16
- package/plugins/ca-pi/skills/ca-new-skill/SKILL.md +116 -21
|
@@ -38,6 +38,8 @@ import time
|
|
|
38
38
|
from typing import Any
|
|
39
39
|
|
|
40
40
|
import _artifactlib
|
|
41
|
+
import _artifactauthoritylib
|
|
42
|
+
import _artifactpromptlib
|
|
41
43
|
from _hooklib import acquire_lock, release_lock
|
|
42
44
|
|
|
43
45
|
|
|
@@ -393,7 +395,7 @@ def _validate_record(value: Any, prerequisite_id: str) -> bool:
|
|
|
393
395
|
)
|
|
394
396
|
|
|
395
397
|
|
|
396
|
-
def _validate_confirmed_state(value: dict[str, Any]) -> None:
|
|
398
|
+
def _validate_confirmed_state(value: dict[str, Any], *, check_source: bool = True) -> None:
|
|
397
399
|
event = value["event"]
|
|
398
400
|
expected_subject = {
|
|
399
401
|
"artifact_id": value["artifact_id"],
|
|
@@ -444,7 +446,7 @@ def _validate_confirmed_state(value: dict[str, Any]) -> None:
|
|
|
444
446
|
expected_source = (
|
|
445
447
|
SOURCE_DIR / f"{_digest(_canonical(event))}.json"
|
|
446
448
|
).as_posix()
|
|
447
|
-
if value["authority_source"] != expected_source:
|
|
449
|
+
if check_source and value["authority_source"] != expected_source:
|
|
448
450
|
raise PrerequisiteError(
|
|
449
451
|
"INVALID_PENDING_PREREQUISITE",
|
|
450
452
|
"confirmed authority source does not match the event",
|
|
@@ -535,7 +537,7 @@ def _load_pending(root: Path, artifact_id: str) -> dict[str, Any] | None:
|
|
|
535
537
|
"INVALID_PENDING_PREREQUISITE", "captured state is incomplete"
|
|
536
538
|
)
|
|
537
539
|
else:
|
|
538
|
-
_validate_confirmed_state(value)
|
|
540
|
+
_validate_confirmed_state(value, check_source=False)
|
|
539
541
|
if value["mutation_request"] != _expected_mutation_request(value):
|
|
540
542
|
raise PrerequisiteError(
|
|
541
543
|
"INVALID_PENDING_PREREQUISITE",
|
|
@@ -601,16 +603,22 @@ def arm_user_prerequisite(
|
|
|
601
603
|
"PENDING_PREREQUISITE",
|
|
602
604
|
"resolve or cancel this plan's existing prerequisite request first",
|
|
603
605
|
) from exc
|
|
606
|
+
reply = (
|
|
607
|
+
f"satisfy-prerequisite {artifact_id} {prerequisite_id} "
|
|
608
|
+
f"{confirmation_nonce}"
|
|
609
|
+
)
|
|
610
|
+
try:
|
|
611
|
+
_artifactpromptlib.register(root, "prerequisite", artifact_id, reply)
|
|
612
|
+
except _artifactpromptlib.PromptRouteError:
|
|
613
|
+
(root / relative).unlink()
|
|
614
|
+
raise
|
|
604
615
|
return {
|
|
605
616
|
"artifact_id": artifact_id,
|
|
606
617
|
"prerequisite_id": prerequisite_id,
|
|
607
618
|
"title": record["title"],
|
|
608
619
|
"requirement": record["requirement"],
|
|
609
620
|
"review_packet_sha256": packet_sha256,
|
|
610
|
-
"reply":
|
|
611
|
-
f"satisfy-prerequisite {artifact_id} {prerequisite_id} "
|
|
612
|
-
f"{confirmation_nonce}"
|
|
613
|
-
),
|
|
621
|
+
"reply": reply,
|
|
614
622
|
"pending": relative.as_posix(),
|
|
615
623
|
}
|
|
616
624
|
|
|
@@ -752,12 +760,17 @@ def consume_user_prerequisite(
|
|
|
752
760
|
session_id=session_id,
|
|
753
761
|
now=observed_at,
|
|
754
762
|
)
|
|
755
|
-
source_ref, source_sha256 = _publish_event(root, pending)
|
|
756
763
|
if pending["state"] == "CONFIRMED":
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
764
|
+
observed_host, separator, observed_session = pending["event"]["origin"].partition(":UserPromptSubmit:")
|
|
765
|
+
if not separator:
|
|
766
|
+
raise PrerequisiteError("INVALID_PENDING_PREREQUISITE", "confirmed prompt origin is malformed")
|
|
767
|
+
try:
|
|
768
|
+
captured = _artifactauthoritylib.capture_user_prompt(
|
|
769
|
+
root, client, pending["event"], host=observed_host,
|
|
770
|
+
session_id=observed_session,
|
|
771
|
+
)
|
|
772
|
+
except _artifactauthoritylib.AuthorityError as exc:
|
|
773
|
+
raise PrerequisiteError("INVALID_AUTHORITY_SOURCE", str(exc)) from exc
|
|
761
774
|
receipt = captured.get("receipt")
|
|
762
775
|
if not isinstance(receipt, str) or RECEIPT_RE.fullmatch(receipt) is None:
|
|
763
776
|
raise PrerequisiteError("INVALID_RECEIPT", "capture returned no valid receipt")
|
|
@@ -774,18 +787,20 @@ def consume_user_prerequisite(
|
|
|
774
787
|
}
|
|
775
788
|
captured_state = dict(pending)
|
|
776
789
|
captured_state.update(
|
|
777
|
-
state="CAPTURED", receipt=receipt, mutation_request=mutation_request
|
|
790
|
+
state="CAPTURED", receipt=receipt, mutation_request=mutation_request,
|
|
791
|
+
authority_source=captured["authority_source"],
|
|
778
792
|
)
|
|
779
793
|
_replace_state(root, _pending_relative(artifact_id), captured_state)
|
|
780
794
|
pending = captured_state
|
|
781
795
|
result = client.call("prerequisite", pending["mutation_request"])
|
|
782
796
|
(root / _pending_relative(artifact_id)).unlink()
|
|
797
|
+
_artifactpromptlib.unregister(root, "prerequisite", artifact_id)
|
|
783
798
|
return {
|
|
784
799
|
"matched": True,
|
|
785
800
|
"satisfied": True,
|
|
786
801
|
"artifact_id": artifact_id,
|
|
787
802
|
"prerequisite_id": prerequisite_id,
|
|
788
|
-
"authority_source":
|
|
803
|
+
"authority_source": pending["authority_source"],
|
|
789
804
|
"receipt": pending["receipt"],
|
|
790
805
|
"revision": result.get("revision"),
|
|
791
806
|
"replayed": bool((result.get("transaction") or {}).get("replayed")),
|
|
@@ -817,6 +832,7 @@ def cancel_user_prerequisite(
|
|
|
817
832
|
"a confirmed prerequisite decision must be recovered, not cancelled",
|
|
818
833
|
)
|
|
819
834
|
(root / _pending_relative(artifact_id)).unlink()
|
|
835
|
+
_artifactpromptlib.unregister(root, "prerequisite", artifact_id)
|
|
820
836
|
return {
|
|
821
837
|
"artifact_id": artifact_id,
|
|
822
838
|
"prerequisite_id": prerequisite_id,
|
|
@@ -868,6 +884,7 @@ def supersede_user_prerequisite(
|
|
|
868
884
|
raise
|
|
869
885
|
else:
|
|
870
886
|
(root / _pending_relative(artifact_id)).unlink()
|
|
887
|
+
_artifactpromptlib.unregister(root, "prerequisite", artifact_id)
|
|
871
888
|
return {
|
|
872
889
|
"artifact_id": artifact_id,
|
|
873
890
|
"prerequisite_id": prerequisite_id,
|
|
@@ -878,6 +895,7 @@ def supersede_user_prerequisite(
|
|
|
878
895
|
"replayed": bool((result.get("transaction") or {}).get("replayed")),
|
|
879
896
|
}
|
|
880
897
|
(root / _pending_relative(artifact_id)).unlink()
|
|
898
|
+
_artifactpromptlib.unregister(root, "prerequisite", artifact_id)
|
|
881
899
|
return {
|
|
882
900
|
"artifact_id": artifact_id,
|
|
883
901
|
"prerequisite_id": prerequisite_id,
|
|
@@ -904,20 +922,23 @@ def consume_from_hook(
|
|
|
904
922
|
if attempted
|
|
905
923
|
else ""
|
|
906
924
|
)
|
|
907
|
-
if not (Path(root) / _pending_relative(match[0])).exists():
|
|
908
|
-
return (
|
|
909
|
-
"codeArbiter: prerequisite capture failed: no matching armed request"
|
|
910
|
-
if attempted
|
|
911
|
-
else ""
|
|
912
|
-
)
|
|
913
925
|
try:
|
|
926
|
+
routed = _artifactpromptlib.resolve("prerequisite", prompt)
|
|
927
|
+
root = routed or Path(root)
|
|
928
|
+
if not (Path(root) / _pending_relative(match[0])).exists():
|
|
929
|
+
return (
|
|
930
|
+
"codeArbiter: prerequisite capture failed: no matching armed request"
|
|
931
|
+
if attempted
|
|
932
|
+
else ""
|
|
933
|
+
)
|
|
914
934
|
client = _artifactlib.ArtifactClient(
|
|
915
935
|
root, Path(plugin_root) / "helpers" / "artifacts"
|
|
916
936
|
)
|
|
917
937
|
result = consume_user_prerequisite(
|
|
918
938
|
root, client, prompt, host=host, session_id=session_id
|
|
919
939
|
)
|
|
920
|
-
except (PrerequisiteError, _artifactlib.ArtifactError,
|
|
940
|
+
except (PrerequisiteError, _artifactlib.ArtifactError,
|
|
941
|
+
_artifactpromptlib.PromptRouteError, OSError) as exc:
|
|
921
942
|
return f"codeArbiter: prerequisite capture failed: {exc}"
|
|
922
943
|
if not result.get("satisfied"):
|
|
923
944
|
return "codeArbiter: prerequisite capture failed: confirmation did not match"
|
|
@@ -966,6 +987,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
966
987
|
if __name__ == "__main__":
|
|
967
988
|
try:
|
|
968
989
|
raise SystemExit(main())
|
|
969
|
-
except (PrerequisiteError, _artifactlib.ArtifactError
|
|
990
|
+
except (PrerequisiteError, _artifactlib.ArtifactError,
|
|
991
|
+
_artifactpromptlib.PromptRouteError) as exc:
|
|
970
992
|
sys.stderr.write(str(exc) + "\n")
|
|
971
993
|
raise SystemExit(1)
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Exact-prompt user authority for task/scope reconciliation mutations."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from contextlib import contextmanager
|
|
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 time
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import _approvallib
|
|
18
|
+
import _artifactauthoritylib
|
|
19
|
+
import _artifactlib
|
|
20
|
+
import _artifactpromptlib
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
ID_RE = re.compile(r"[A-Z][A-Z0-9_-]{0,127}")
|
|
24
|
+
TOKEN_RE = re.compile(r"[A-Za-z0-9_-]{12,128}")
|
|
25
|
+
PENDING_DIR = Path(".codearbiter/.markers/reconciliations")
|
|
26
|
+
ATTEMPT_DIR = Path(".codearbiter/.markers/reconciliation-attempts")
|
|
27
|
+
SOURCE_DIR = Path(".codearbiter/.artifacts/authority-sources")
|
|
28
|
+
PROVEN_UNCOMMITTED = frozenset({"REVISION_CONFLICT", "OPERATION_ROLLED_BACK"})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ReconciliationError(RuntimeError):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _canonical(value: Any) -> bytes:
|
|
36
|
+
return json.dumps(value, ensure_ascii=True, allow_nan=False, sort_keys=True, separators=(",", ":")).encode()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _digest(value: bytes) -> str:
|
|
40
|
+
return hashlib.sha256(value).hexdigest()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _pending(artifact_id: str) -> Path:
|
|
44
|
+
return PENDING_DIR / f"{artifact_id}.json"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _operation_id(pending: dict[str, Any]) -> str:
|
|
48
|
+
return f"host-user-reconcile-{_digest(_canonical(pending))[:24]}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _attempt(pending: dict[str, Any]) -> Path:
|
|
52
|
+
return ATTEMPT_DIR / f"{_operation_id(pending)}.json"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _mutation_request(pending: dict[str, Any], receipt: str) -> dict[str, Any]:
|
|
56
|
+
identity = pending["identity"]
|
|
57
|
+
request = {
|
|
58
|
+
"artifact_id": pending["artifact_id"], "operation_id": _operation_id(pending),
|
|
59
|
+
"expected": {"revision": identity["revision"], "model_sha256": identity["model_sha256"]},
|
|
60
|
+
"receipt": receipt,
|
|
61
|
+
}
|
|
62
|
+
if pending["operation"] == "task-reconcile":
|
|
63
|
+
request.update(task=pending["target_id"], state=pending["target_state"], reason=pending["reason"])
|
|
64
|
+
else:
|
|
65
|
+
request.update(scope=pending["target_id"])
|
|
66
|
+
return request
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@contextmanager
|
|
70
|
+
def _reconciliation_lock(root: Path, artifact_id: str):
|
|
71
|
+
"""Serialize all transitions for one artifact, including authority revocation."""
|
|
72
|
+
if ID_RE.fullmatch(artifact_id) is None:
|
|
73
|
+
raise ReconciliationError("reconciliation ID is invalid")
|
|
74
|
+
parent = _approvallib._safe_directory(root, PENDING_DIR, create=True)
|
|
75
|
+
path = parent / f"{artifact_id}.lock"
|
|
76
|
+
flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
77
|
+
fd = os.open(path, flags, 0o600)
|
|
78
|
+
try:
|
|
79
|
+
info = path.lstat()
|
|
80
|
+
opened = os.fstat(fd)
|
|
81
|
+
reparse = getattr(info, "st_file_attributes", 0) & 0x400
|
|
82
|
+
if (
|
|
83
|
+
stat.S_ISLNK(info.st_mode) or reparse or not stat.S_ISREG(info.st_mode)
|
|
84
|
+
or (info.st_dev, info.st_ino) != (opened.st_dev, opened.st_ino)
|
|
85
|
+
):
|
|
86
|
+
raise ReconciliationError("reconciliation lock path is unsafe")
|
|
87
|
+
deadline = time.monotonic() + 30
|
|
88
|
+
if os.name == "nt":
|
|
89
|
+
import msvcrt
|
|
90
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
91
|
+
while True:
|
|
92
|
+
try:
|
|
93
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
94
|
+
break
|
|
95
|
+
except OSError:
|
|
96
|
+
if time.monotonic() >= deadline:
|
|
97
|
+
raise ReconciliationError("reconciliation transition lock timed out")
|
|
98
|
+
time.sleep(0.05)
|
|
99
|
+
else:
|
|
100
|
+
import fcntl
|
|
101
|
+
while True:
|
|
102
|
+
try:
|
|
103
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
104
|
+
break
|
|
105
|
+
except BlockingIOError:
|
|
106
|
+
if time.monotonic() >= deadline:
|
|
107
|
+
raise ReconciliationError("reconciliation transition lock timed out")
|
|
108
|
+
time.sleep(0.05)
|
|
109
|
+
try:
|
|
110
|
+
yield
|
|
111
|
+
finally:
|
|
112
|
+
if os.name == "nt":
|
|
113
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
114
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
115
|
+
else:
|
|
116
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
117
|
+
finally:
|
|
118
|
+
os.close(fd)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _write_atomic_new(root: Path, relative: Path, payload: bytes) -> None:
|
|
122
|
+
"""Publish complete bytes without exposing a partial or replacing a peer."""
|
|
123
|
+
parent = _approvallib._safe_directory(root, relative.parent, create=True)
|
|
124
|
+
target = parent / relative.name
|
|
125
|
+
temporary = parent / f".{target.name}.{secrets.token_hex(8)}.tmp"
|
|
126
|
+
try:
|
|
127
|
+
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0), 0o600)
|
|
128
|
+
try:
|
|
129
|
+
data = memoryview(payload)
|
|
130
|
+
while data:
|
|
131
|
+
written = os.write(fd, data)
|
|
132
|
+
if written <= 0:
|
|
133
|
+
raise OSError("short reconciliation state write")
|
|
134
|
+
data = data[written:]
|
|
135
|
+
os.fsync(fd)
|
|
136
|
+
finally:
|
|
137
|
+
os.close(fd)
|
|
138
|
+
os.link(temporary, target)
|
|
139
|
+
finally:
|
|
140
|
+
temporary.unlink(missing_ok=True)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _write_attempt(root: Path, pending: dict[str, Any], request: dict[str, Any]) -> None:
|
|
144
|
+
"""Durably bind the exact idempotent mutation request before dispatch."""
|
|
145
|
+
record = {
|
|
146
|
+
"format": "codearbiter.reconciliation-attempt/0.1.0",
|
|
147
|
+
"pending_sha256": _digest(_canonical(pending)), "request": request,
|
|
148
|
+
}
|
|
149
|
+
_write_atomic_new(root, _attempt(pending), _canonical(record))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _load_attempt(root: Path, pending: dict[str, Any]) -> dict[str, Any] | None:
|
|
153
|
+
path = root / _attempt(pending)
|
|
154
|
+
try:
|
|
155
|
+
info = path.lstat()
|
|
156
|
+
except FileNotFoundError:
|
|
157
|
+
return None
|
|
158
|
+
reparse = getattr(info, "st_file_attributes", 0) & 0x400
|
|
159
|
+
if stat.S_ISLNK(info.st_mode) or reparse or not stat.S_ISREG(info.st_mode) or info.st_size > 16384:
|
|
160
|
+
raise ReconciliationError("in-flight reconciliation record is unsafe")
|
|
161
|
+
try:
|
|
162
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
163
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
164
|
+
raise ReconciliationError("in-flight reconciliation record is unreadable") from exc
|
|
165
|
+
if (
|
|
166
|
+
not isinstance(value, dict) or set(value) != {"format", "pending_sha256", "request"}
|
|
167
|
+
or value["format"] != "codearbiter.reconciliation-attempt/0.1.0"
|
|
168
|
+
or value["pending_sha256"] != _digest(_canonical(pending))
|
|
169
|
+
or not isinstance(value["request"], dict)
|
|
170
|
+
or not isinstance(value["request"].get("receipt"), str)
|
|
171
|
+
or not value["request"]["receipt"].startswith(".codearbiter/.artifacts/receipts/")
|
|
172
|
+
or value["request"] != _mutation_request(pending, value["request"]["receipt"])
|
|
173
|
+
):
|
|
174
|
+
raise ReconciliationError("in-flight reconciliation record is malformed")
|
|
175
|
+
return value["request"]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _current(client: Any, artifact_id: str, target_id: str) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
|
|
179
|
+
identity = client.call("identity", {"artifact_id": artifact_id})
|
|
180
|
+
snapshot = client.call("snapshot", {"artifact_id": artifact_id})
|
|
181
|
+
record = client.call("read", {"artifact_id": artifact_id, "symbol": target_id, "mode": "exact"})
|
|
182
|
+
record = record.get("record") if isinstance(record, dict) else None
|
|
183
|
+
if (
|
|
184
|
+
not isinstance(identity, dict) or identity.get("artifact_id") != artifact_id
|
|
185
|
+
or not isinstance(identity.get("revision"), int)
|
|
186
|
+
or not isinstance(identity.get("model_sha256"), str)
|
|
187
|
+
or not isinstance(identity.get("normative_sha256"), str)
|
|
188
|
+
or not isinstance(snapshot, dict) or not isinstance(snapshot.get("sha256"), str)
|
|
189
|
+
or not isinstance(record, dict) or record.get("id") != target_id
|
|
190
|
+
):
|
|
191
|
+
raise ReconciliationError("engine reconciliation target is malformed")
|
|
192
|
+
return identity, snapshot, record
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def arm_reconciliation(
|
|
196
|
+
root: str | Path, client: Any, artifact_id: str, target_id: str,
|
|
197
|
+
operation: str, *, target_state: str | None = None, reason: str = "",
|
|
198
|
+
assessment: str, token: str | None = None,
|
|
199
|
+
) -> dict[str, Any]:
|
|
200
|
+
resolved = Path(root).resolve(strict=True)
|
|
201
|
+
with _reconciliation_lock(resolved, artifact_id):
|
|
202
|
+
return _arm_reconciliation_unlocked(
|
|
203
|
+
resolved, client, artifact_id, target_id, operation,
|
|
204
|
+
target_state=target_state, reason=reason, assessment=assessment, token=token,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _arm_reconciliation_unlocked(
|
|
209
|
+
root: Path, client: Any, artifact_id: str, target_id: str,
|
|
210
|
+
operation: str, *, target_state: str | None = None, reason: str = "",
|
|
211
|
+
assessment: str, token: str | None = None,
|
|
212
|
+
) -> dict[str, Any]:
|
|
213
|
+
root = Path(root).resolve(strict=True)
|
|
214
|
+
if operation not in {"task-reconcile", "scope-reconcile"}:
|
|
215
|
+
raise ReconciliationError("reconciliation operation is unsupported")
|
|
216
|
+
if ID_RE.fullmatch(artifact_id) is None or ID_RE.fullmatch(target_id) is None:
|
|
217
|
+
raise ReconciliationError("reconciliation IDs are invalid")
|
|
218
|
+
if operation == "task-reconcile" and target_state not in {"PENDING", "REVIEW", "BLOCKED"}:
|
|
219
|
+
raise ReconciliationError("task reconciliation state is invalid")
|
|
220
|
+
if operation == "scope-reconcile" and target_state is not None:
|
|
221
|
+
raise ReconciliationError("scope reconciliation has no target state")
|
|
222
|
+
if not isinstance(assessment, str) or not assessment.strip() or not isinstance(reason, str):
|
|
223
|
+
raise ReconciliationError("reconciliation assessment/reason is invalid")
|
|
224
|
+
token = token or secrets.token_urlsafe(24)
|
|
225
|
+
if TOKEN_RE.fullmatch(token) is None:
|
|
226
|
+
raise ReconciliationError("reconciliation token is invalid")
|
|
227
|
+
identity, snapshot, record = _current(client, artifact_id, target_id)
|
|
228
|
+
reply = (
|
|
229
|
+
f"reconcile-task {artifact_id} {target_id} {target_state} {token}"
|
|
230
|
+
if operation == "task-reconcile"
|
|
231
|
+
else f"reconcile-scope {artifact_id} {target_id} {token}"
|
|
232
|
+
)
|
|
233
|
+
pending = {
|
|
234
|
+
"format": "codearbiter.pending-reconciliation/0.1.0",
|
|
235
|
+
"operation": operation, "artifact_id": artifact_id, "target_id": target_id,
|
|
236
|
+
"target_state": target_state, "reason": reason, "assessment": assessment,
|
|
237
|
+
"identity": identity, "input_sha256": snapshot["sha256"],
|
|
238
|
+
"record": record, "record_sha256": _digest(_canonical(record)),
|
|
239
|
+
"prompt_sha256": _digest(reply.encode()),
|
|
240
|
+
}
|
|
241
|
+
relative = _pending(artifact_id)
|
|
242
|
+
created = False
|
|
243
|
+
try:
|
|
244
|
+
pending_bytes = _canonical(pending)
|
|
245
|
+
_write_atomic_new(root, relative, pending_bytes)
|
|
246
|
+
created = True
|
|
247
|
+
_artifactpromptlib.register(
|
|
248
|
+
root, "reconciliation", artifact_id, reply,
|
|
249
|
+
binding_sha256=_digest(pending_bytes),
|
|
250
|
+
)
|
|
251
|
+
except FileExistsError as exc:
|
|
252
|
+
raise ReconciliationError("PENDING_RECONCILIATION: reconciliation is already armed") from exc
|
|
253
|
+
except Exception:
|
|
254
|
+
if created:
|
|
255
|
+
try:
|
|
256
|
+
(root / relative).unlink()
|
|
257
|
+
except OSError:
|
|
258
|
+
pass
|
|
259
|
+
raise
|
|
260
|
+
return {"artifact_id": artifact_id, "target_id": target_id, "operation": operation, "reply": reply}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _load(root: Path, artifact_id: str) -> dict[str, Any]:
|
|
264
|
+
try:
|
|
265
|
+
path = root / _pending(artifact_id)
|
|
266
|
+
info = path.lstat()
|
|
267
|
+
reparse = getattr(info, "st_file_attributes", 0) & 0x400
|
|
268
|
+
if stat.S_ISLNK(info.st_mode) or reparse or not stat.S_ISREG(info.st_mode) or info.st_size > 131072:
|
|
269
|
+
raise OSError("unsafe pending reconciliation")
|
|
270
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
271
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
272
|
+
raise ReconciliationError("pending reconciliation is unreadable") from exc
|
|
273
|
+
expected = {
|
|
274
|
+
"format", "operation", "artifact_id", "target_id", "target_state",
|
|
275
|
+
"reason", "assessment", "identity", "input_sha256", "record",
|
|
276
|
+
"record_sha256", "prompt_sha256",
|
|
277
|
+
}
|
|
278
|
+
if (
|
|
279
|
+
not isinstance(value, dict) or set(value) != expected
|
|
280
|
+
or value.get("format") != "codearbiter.pending-reconciliation/0.1.0"
|
|
281
|
+
or value.get("artifact_id") != artifact_id
|
|
282
|
+
or ID_RE.fullmatch(value.get("artifact_id", "")) is None
|
|
283
|
+
or ID_RE.fullmatch(value.get("target_id", "")) is None
|
|
284
|
+
or value.get("operation") not in {"task-reconcile", "scope-reconcile"}
|
|
285
|
+
or not isinstance(value.get("reason"), str)
|
|
286
|
+
or not isinstance(value.get("assessment"), str) or not value["assessment"].strip()
|
|
287
|
+
or not isinstance(value.get("identity"), dict)
|
|
288
|
+
or not isinstance(value.get("record"), dict)
|
|
289
|
+
or not isinstance(value.get("input_sha256"), str)
|
|
290
|
+
or not isinstance(value.get("record_sha256"), str)
|
|
291
|
+
or not isinstance(value.get("prompt_sha256"), str)
|
|
292
|
+
):
|
|
293
|
+
raise ReconciliationError("pending reconciliation is malformed")
|
|
294
|
+
if value["operation"] == "task-reconcile":
|
|
295
|
+
if value["target_state"] not in {"PENDING", "REVIEW", "BLOCKED"}:
|
|
296
|
+
raise ReconciliationError("pending reconciliation is malformed")
|
|
297
|
+
else:
|
|
298
|
+
if value["target_state"] is not None:
|
|
299
|
+
raise ReconciliationError("pending reconciliation is malformed")
|
|
300
|
+
if _digest(_canonical(value["record"])) != value["record_sha256"]:
|
|
301
|
+
raise ReconciliationError("pending reconciliation is malformed")
|
|
302
|
+
return value
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def cancel_reconciliation(root: str | Path, artifact_id: str, prompt: str) -> dict[str, Any]:
|
|
306
|
+
"""Cancel only an exact armed prompt with no durable mutation attempt."""
|
|
307
|
+
root = Path(root).resolve(strict=True)
|
|
308
|
+
with _reconciliation_lock(root, artifact_id):
|
|
309
|
+
return _cancel_reconciliation_unlocked(root, artifact_id, prompt)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _cancel_reconciliation_unlocked(root: Path, artifact_id: str, prompt: str) -> dict[str, Any]:
|
|
313
|
+
if ID_RE.fullmatch(artifact_id) is None or not isinstance(prompt, str):
|
|
314
|
+
raise ReconciliationError("cancellation target is invalid")
|
|
315
|
+
pending = _load(root, artifact_id)
|
|
316
|
+
if _digest(prompt.encode()) != pending["prompt_sha256"]:
|
|
317
|
+
raise ReconciliationError("cancellation prompt does not match the armed request")
|
|
318
|
+
if _load_attempt(root, pending) is not None:
|
|
319
|
+
raise ReconciliationError("in-flight reconciliation must be retried or recovered, not cancelled")
|
|
320
|
+
try:
|
|
321
|
+
binding = _artifactpromptlib.binding(root, "reconciliation", artifact_id)
|
|
322
|
+
except _artifactpromptlib.PromptRouteError as exc:
|
|
323
|
+
raise ReconciliationError("reconciliation authority binding is unavailable") from exc
|
|
324
|
+
if binding != _digest(_canonical(pending)):
|
|
325
|
+
raise ReconciliationError("pending reconciliation changed after arming")
|
|
326
|
+
(root / _pending(artifact_id)).unlink()
|
|
327
|
+
_artifactpromptlib.unregister(root, "reconciliation", artifact_id)
|
|
328
|
+
return {"artifact_id": artifact_id, "cancelled": True}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def cancel_orphaned_reconciliation(root: str | Path, artifact_id: str) -> dict[str, Any]:
|
|
332
|
+
"""Recover a complete pending marker stranded before route registration."""
|
|
333
|
+
root = Path(root).resolve(strict=True)
|
|
334
|
+
with _reconciliation_lock(root, artifact_id):
|
|
335
|
+
pending = _load(root, artifact_id)
|
|
336
|
+
if _load_attempt(root, pending) is not None:
|
|
337
|
+
raise ReconciliationError("in-flight reconciliation must be retried or recovered, not cancelled")
|
|
338
|
+
try:
|
|
339
|
+
binding = _artifactpromptlib.binding(root, "reconciliation", artifact_id)
|
|
340
|
+
except _artifactpromptlib.PromptRouteError as exc:
|
|
341
|
+
if isinstance(exc.__cause__, FileNotFoundError):
|
|
342
|
+
binding = None
|
|
343
|
+
else:
|
|
344
|
+
raise ReconciliationError("reconciliation authority binding is unreadable") from exc
|
|
345
|
+
if binding == _digest(_canonical(pending)):
|
|
346
|
+
raise ReconciliationError("reconciliation route is active; cancel with its exact prompt")
|
|
347
|
+
(root / _pending(artifact_id)).unlink()
|
|
348
|
+
if binding is not None:
|
|
349
|
+
_artifactpromptlib.unregister(root, "reconciliation", artifact_id)
|
|
350
|
+
return {"artifact_id": artifact_id, "cancelled": True, "orphaned": True}
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def recover_reconciliation(root: str | Path, client: Any, artifact_id: str) -> dict[str, Any]:
|
|
354
|
+
"""Replay a durable attempt; clear it only after a proven commit or non-commit."""
|
|
355
|
+
root = Path(root).resolve(strict=True)
|
|
356
|
+
with _reconciliation_lock(root, artifact_id):
|
|
357
|
+
pending = _load(root, artifact_id)
|
|
358
|
+
request = _load_attempt(root, pending)
|
|
359
|
+
if request is None:
|
|
360
|
+
raise ReconciliationError("no in-flight reconciliation to recover")
|
|
361
|
+
try:
|
|
362
|
+
result = client.call(pending["operation"], request)
|
|
363
|
+
transaction = result.get("transaction") if isinstance(result, dict) else None
|
|
364
|
+
if not isinstance(result, dict) or not (
|
|
365
|
+
type(result.get("revision")) is int or result.get("refresh_required") is True
|
|
366
|
+
) or not isinstance(transaction, dict) or (
|
|
367
|
+
transaction.get("operation_id") != request["operation_id"]
|
|
368
|
+
or transaction.get("state") != "committed"
|
|
369
|
+
):
|
|
370
|
+
raise ReconciliationError("reconciliation replay response is malformed")
|
|
371
|
+
committed = True
|
|
372
|
+
except _artifactlib.ArtifactError as exc:
|
|
373
|
+
if exc.code not in PROVEN_UNCOMMITTED:
|
|
374
|
+
raise
|
|
375
|
+
result = None
|
|
376
|
+
committed = False
|
|
377
|
+
(root / _pending(artifact_id)).unlink()
|
|
378
|
+
_artifactpromptlib.unregister(root, "reconciliation", artifact_id)
|
|
379
|
+
(root / _attempt(pending)).unlink()
|
|
380
|
+
return {
|
|
381
|
+
"artifact_id": artifact_id, "committed": committed,
|
|
382
|
+
"revision": result.get("revision") if committed else None,
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def consume_reconciliation(
|
|
387
|
+
root: str | Path, client: Any, prompt: str, *, host: str, session_id: str,
|
|
388
|
+
) -> dict[str, Any]:
|
|
389
|
+
root = Path(root).resolve(strict=True)
|
|
390
|
+
parts = prompt.split(" ") if isinstance(prompt, str) else []
|
|
391
|
+
if len(parts) not in {4, 5} or parts[0] not in {"reconcile-task", "reconcile-scope"}:
|
|
392
|
+
return {"matched": False, "reconciled": False}
|
|
393
|
+
if ID_RE.fullmatch(parts[1]) is None or ID_RE.fullmatch(parts[2]) is None:
|
|
394
|
+
return {"matched": False, "reconciled": False}
|
|
395
|
+
with _reconciliation_lock(root, parts[1]):
|
|
396
|
+
return _consume_reconciliation_unlocked(root, client, prompt, host=host, session_id=session_id)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _consume_reconciliation_unlocked(
|
|
400
|
+
root: Path, client: Any, prompt: str, *, host: str, session_id: str,
|
|
401
|
+
) -> dict[str, Any]:
|
|
402
|
+
parts = prompt.split(" ")
|
|
403
|
+
artifact_id, target_id = parts[1], parts[2]
|
|
404
|
+
pending = _load(root, artifact_id)
|
|
405
|
+
if not isinstance(host, str) or not host or not isinstance(session_id, str) or not session_id:
|
|
406
|
+
raise ReconciliationError("reconciliation host context is invalid")
|
|
407
|
+
try:
|
|
408
|
+
binding = _artifactpromptlib.binding(root, "reconciliation", artifact_id)
|
|
409
|
+
except _artifactpromptlib.PromptRouteError as exc:
|
|
410
|
+
raise ReconciliationError("reconciliation authority binding is unavailable") from exc
|
|
411
|
+
if binding != _digest(_canonical(pending)):
|
|
412
|
+
raise ReconciliationError("pending reconciliation changed after arming")
|
|
413
|
+
expected_prompt = (
|
|
414
|
+
f"reconcile-task {pending['artifact_id']} {pending['target_id']} {pending['target_state']} {parts[-1]}"
|
|
415
|
+
if pending["operation"] == "task-reconcile"
|
|
416
|
+
else f"reconcile-scope {pending['artifact_id']} {pending['target_id']} {parts[-1]}"
|
|
417
|
+
)
|
|
418
|
+
if prompt != expected_prompt or _digest(prompt.encode()) != pending["prompt_sha256"]:
|
|
419
|
+
return {"matched": False, "reconciled": False}
|
|
420
|
+
if artifact_id != pending["artifact_id"] or target_id != pending["target_id"]:
|
|
421
|
+
return {"matched": False, "reconciled": False}
|
|
422
|
+
request = _load_attempt(root, pending)
|
|
423
|
+
if request is None:
|
|
424
|
+
identity, snapshot, record = _current(client, artifact_id, target_id)
|
|
425
|
+
if identity != pending["identity"] or snapshot["sha256"] != pending["input_sha256"] or record != pending["record"]:
|
|
426
|
+
raise ReconciliationError("reconciliation target changed after arming")
|
|
427
|
+
payload = {"input_sha256": pending["input_sha256"], "assessment": pending["assessment"]}
|
|
428
|
+
if pending["operation"] == "task-reconcile":
|
|
429
|
+
payload.update(task_sha256=pending["record_sha256"], target_state=pending["target_state"])
|
|
430
|
+
event = {
|
|
431
|
+
"kind": "reconciliation",
|
|
432
|
+
"authority_kind": "user_workflow",
|
|
433
|
+
"subject": {"artifact_id": artifact_id, "normative_sha256": identity["normative_sha256"], "record_id": target_id},
|
|
434
|
+
"actor": "interactive repository user", "origin": f"{host}:UserPromptSubmit:{session_id}",
|
|
435
|
+
"verdict": "reconciled", "payload": payload, "source_text": prompt,
|
|
436
|
+
}
|
|
437
|
+
try:
|
|
438
|
+
captured = _artifactauthoritylib.capture_user_prompt(
|
|
439
|
+
root, client, event, host=host, session_id=session_id,
|
|
440
|
+
)
|
|
441
|
+
except _artifactauthoritylib.AuthorityError as exc:
|
|
442
|
+
raise ReconciliationError(str(exc)) from exc
|
|
443
|
+
receipt = captured.get("receipt")
|
|
444
|
+
if not isinstance(receipt, str) or not receipt.startswith(".codearbiter/.artifacts/receipts/"):
|
|
445
|
+
raise ReconciliationError("capture returned no valid reconciliation receipt")
|
|
446
|
+
request = _mutation_request(pending, receipt)
|
|
447
|
+
try:
|
|
448
|
+
_write_attempt(root, pending, request)
|
|
449
|
+
except FileExistsError:
|
|
450
|
+
request = _load_attempt(root, pending)
|
|
451
|
+
if request is None:
|
|
452
|
+
raise ReconciliationError("in-flight reconciliation record disappeared")
|
|
453
|
+
result = client.call(pending["operation"], request)
|
|
454
|
+
(root / _pending(artifact_id)).unlink()
|
|
455
|
+
_artifactpromptlib.unregister(root, "reconciliation", artifact_id)
|
|
456
|
+
(root / _attempt(pending)).unlink()
|
|
457
|
+
return {"matched": True, "reconciled": True, "artifact_id": artifact_id, "target_id": target_id, "receipt": request["receipt"], "revision": result.get("revision")}
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
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:
|
|
463
|
+
return ""
|
|
464
|
+
try:
|
|
465
|
+
routed = _artifactpromptlib.resolve("reconciliation", prompt)
|
|
466
|
+
if routed is None:
|
|
467
|
+
raise ReconciliationError("no matching armed reconciliation")
|
|
468
|
+
client = _artifactlib.ArtifactClient(routed, Path(plugin_root) / "helpers" / "artifacts")
|
|
469
|
+
result = consume_reconciliation(routed, client, prompt, host=host, session_id=session_id)
|
|
470
|
+
except (ReconciliationError, _artifactlib.ArtifactError, _artifactpromptlib.PromptRouteError, OSError) as exc:
|
|
471
|
+
return f"codeArbiter: reconciliation capture failed: {exc}"
|
|
472
|
+
return f"codeArbiter: workflow reconciliation recorded for {result['artifact_id']}#{result['target_id']} (receipt {result['receipt']})."
|