@arbiterforge/ca-pi 0.18.4 → 0.19.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 +1 -1
- package/package.json +1 -1
- package/plugins/ca-pi/CHANGELOG.md +54 -0
- package/plugins/ca-pi/COMMANDS.md +2 -2
- package/plugins/ca-pi/SKILLS.md +5 -5
- package/plugins/ca-pi/SPRINT.md +43 -230
- package/plugins/ca-pi/agents/backend-author.md +5 -5
- package/plugins/ca-pi/agents/checkpoint-aggregator.md +7 -0
- package/plugins/ca-pi/agents/frontend-author.md +5 -5
- package/plugins/ca-pi/agents/infra-author.md +6 -7
- package/plugins/ca-pi/agents/scout.md +25 -24
- package/plugins/ca-pi/arbiter.md +6 -4
- package/plugins/ca-pi/generated/command-catalog.json +5 -5
- 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 +37 -37
- package/plugins/ca-pi/hooks/_approvallib.py +41 -0
- package/plugins/ca-pi/hooks/_artifactlib.py +61 -115
- package/plugins/ca-pi/hooks/_artifactpromptlib.py +12 -12
- package/plugins/ca-pi/hooks/_bashguardlib.py +66 -4
- package/plugins/ca-pi/hooks/_contextselectlib.py +38 -35
- package/plugins/ca-pi/hooks/_contextwriterlib.py +114 -0
- package/plugins/ca-pi/hooks/_host.py +1 -1
- package/plugins/ca-pi/hooks/_prerequisitelib.py +35 -0
- package/plugins/ca-pi/hooks/_provenancelib.py +107 -1
- package/plugins/ca-pi/hooks/_readinjectlib.py +21 -7
- package/plugins/ca-pi/hooks/_reconciliationlib.py +167 -2
- package/plugins/ca-pi/hooks/_releaselib.py +6 -6
- package/plugins/ca-pi/hooks/_sprintapprovallib.py +52 -2
- package/plugins/ca-pi/hooks/artifact-reconcile.py +13 -4
- package/plugins/ca-pi/hooks/hostapi.py +1 -1
- package/plugins/ca-pi/hooks/init-codearbiter.py +34 -5
- package/plugins/ca-pi/includes/artifacts-attestation.md +434 -0
- package/plugins/ca-pi/includes/artifacts.md +133 -492
- package/plugins/ca-pi/includes/author-tdd-workflow.md +32 -21
- package/plugins/ca-pi/includes/fresh-verification.md +14 -12
- package/plugins/ca-pi/includes/harvest.md +3 -3
- package/plugins/ca-pi/includes/reference-map.md +1 -1
- package/plugins/ca-pi/includes/routing-table.md +3 -3
- package/plugins/ca-pi/includes/sprint-authority.md +6 -1
- package/plugins/ca-pi/includes/verification-boundary.md +11 -2
- package/plugins/ca-pi/package.json +1 -1
- package/plugins/ca-pi/routines/INDEX.md +8 -8
- package/plugins/ca-pi/routines/brainstorming/SKILL.md +65 -202
- package/plugins/ca-pi/routines/commit-gate/SKILL.md +60 -25
- package/plugins/ca-pi/routines/context-check/SKILL.md +89 -53
- package/plugins/ca-pi/routines/context-creation/SKILL.md +114 -220
- package/plugins/ca-pi/routines/decompose/SKILL.md +139 -167
- package/plugins/ca-pi/routines/executing-plans/SKILL.md +38 -136
- package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +45 -55
- package/plugins/ca-pi/routines/refactor/SKILL.md +27 -12
- package/plugins/ca-pi/routines/release/SKILL.md +1 -1
- package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +80 -230
- package/plugins/ca-pi/routines/tdd/SKILL.md +115 -228
- package/plugins/ca-pi/routines/writing-plans/SKILL.md +104 -174
- package/plugins/ca-pi/skills/ca-audit/SKILL.md +2 -2
- package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +1 -1
- package/plugins/ca-pi/skills/ca-commit/SKILL.md +60 -25
- package/plugins/ca-pi/skills/ca-context-check/SKILL.md +89 -53
- package/plugins/ca-pi/skills/ca-create-context/SKILL.md +114 -220
- package/plugins/ca-pi/skills/ca-decompose/SKILL.md +139 -167
- package/plugins/ca-pi/skills/ca-feature/SKILL.md +21 -38
- package/plugins/ca-pi/skills/ca-fix/SKILL.md +15 -9
- package/plugins/ca-pi/skills/ca-init/SKILL.md +18 -40
- package/plugins/ca-pi/skills/ca-pr/SKILL.md +45 -55
- package/plugins/ca-pi/skills/ca-refactor/SKILL.md +27 -12
- package/plugins/ca-pi/skills/ca-release/SKILL.md +1 -1
- package/plugins/ca-pi/skills/ca-review/SKILL.md +5 -8
- package/plugins/ca-pi/skills/ca-sprint/SKILL.md +11 -25
- package/plugins/ca-pi/skills/ca-status/SKILL.md +15 -6
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Prepare explicit whole-document context previews from bounded source evidence.
|
|
2
|
+
|
|
3
|
+
This convenience layer writes nothing. The native writer owns validation,
|
|
4
|
+
preview retention, compare-and-swap, paired writes and recovery. Content hashes
|
|
5
|
+
record what was inspected; they never attest a semantic review or user approval.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import datetime
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import stat
|
|
15
|
+
|
|
16
|
+
import _artifactlib
|
|
17
|
+
import _contextsnapshotlib
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _read_file(path: Path, limit: int) -> bytes:
|
|
21
|
+
try:
|
|
22
|
+
fd = _artifactlib._open_pinned_regular(path)
|
|
23
|
+
try:
|
|
24
|
+
info = os.fstat(fd)
|
|
25
|
+
if not stat.S_ISREG(info.st_mode) or info.st_size > limit:
|
|
26
|
+
raise _artifactlib.ArtifactError("INVALID_CONTEXT_REQUEST", "context input is not a bounded regular file")
|
|
27
|
+
raw = os.read(fd, limit + 1)
|
|
28
|
+
finally:
|
|
29
|
+
os.close(fd)
|
|
30
|
+
if len(raw) > limit or len(raw) != info.st_size:
|
|
31
|
+
raise _artifactlib.ArtifactError("INVALID_CONTEXT_REQUEST", "context input is oversized or changed during acquisition")
|
|
32
|
+
return raw
|
|
33
|
+
except OSError as exc:
|
|
34
|
+
raise _artifactlib.ArtifactError("INVALID_CONTEXT_REQUEST", "context input is unavailable or unsafe") from exc
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def prepare_document_preview(root: str | Path, document_id: str,
|
|
38
|
+
draft_file: str | Path, source_paths: list[str]) -> dict:
|
|
39
|
+
"""Select the entire named document and preserve exact current identities."""
|
|
40
|
+
root = _artifactlib._trusted_directory(root, "UNSAFE_ROOT")
|
|
41
|
+
target = _artifactlib._CONTEXT_TARGETS.get(document_id)
|
|
42
|
+
if target is None or not source_paths or len(source_paths) > 64:
|
|
43
|
+
raise _artifactlib.ArtifactError("INVALID_CONTEXT_REQUEST", "select one context document and 1-64 source paths")
|
|
44
|
+
provenance_path = f".codearbiter/.provenance/{document_id}.json"
|
|
45
|
+
if any(path in {target, provenance_path} for path in source_paths):
|
|
46
|
+
raise _artifactlib.ArtifactError("INVALID_CONTEXT_REQUEST", "context cannot use its own output as source evidence")
|
|
47
|
+
draft = Path(draft_file)
|
|
48
|
+
if not draft.is_absolute():
|
|
49
|
+
draft = root / draft
|
|
50
|
+
raw = _read_file(draft, 128 * 1024)
|
|
51
|
+
try:
|
|
52
|
+
markdown = raw.decode("utf-8", "strict")
|
|
53
|
+
except UnicodeError as exc:
|
|
54
|
+
raise _artifactlib.ArtifactError("INVALID_CONTEXT_REQUEST", "context draft must be UTF-8") from exc
|
|
55
|
+
evidence = []
|
|
56
|
+
for path in sorted(set(source_paths)):
|
|
57
|
+
try:
|
|
58
|
+
acquired = _contextsnapshotlib.digest_file(root, path)
|
|
59
|
+
except _contextsnapshotlib.DigestError as exc:
|
|
60
|
+
raise _artifactlib.ArtifactError("INVALID_CONTEXT_SOURCE", str(exc)) from exc
|
|
61
|
+
evidence.append({"kind": "content", **{key: acquired[key] for key in
|
|
62
|
+
("path", "digest_method", "digest")}})
|
|
63
|
+
existing = _read_file(root / target, 128 * 1024) if os.path.lexists(root / target) else None
|
|
64
|
+
prior = _read_file(root / provenance_path, 1 << 20) if os.path.lexists(root / provenance_path) else None
|
|
65
|
+
mode = "create" if existing is None else "adopt"
|
|
66
|
+
if prior is not None:
|
|
67
|
+
try:
|
|
68
|
+
record = _artifactlib._decode(prior, max_bytes=1 << 20)
|
|
69
|
+
except _artifactlib.ArtifactError as exc:
|
|
70
|
+
raise _artifactlib.ArtifactError("INVALID_CONTEXT_REQUEST", "preserve malformed existing provenance for review") from exc
|
|
71
|
+
if not isinstance(record, dict) or record.get("doc") != document_id or record.get("schema") not in (1, 2):
|
|
72
|
+
raise _artifactlib.ArtifactError("INVALID_CONTEXT_REQUEST", "preserve unsupported existing provenance for review")
|
|
73
|
+
if existing is None:
|
|
74
|
+
raise _artifactlib.ArtifactError("INVALID_CONTEXT_REQUEST", "existing provenance has no document; inspect recovery before creating")
|
|
75
|
+
if record["schema"] == 2:
|
|
76
|
+
mode = "update"
|
|
77
|
+
digest = lambda value: hashlib.sha256(value).hexdigest() if value is not None else None
|
|
78
|
+
request = {
|
|
79
|
+
"mode": mode, "document_id": document_id, "target_path": target,
|
|
80
|
+
"typed": {"format": "codearbiter.repository-context/0.1.0", "document_id": document_id,
|
|
81
|
+
"entries": [{"id": "FIELD-DOCUMENT", "kind": "document", "markdown": markdown,
|
|
82
|
+
"evidence_refs": ["CLAIM-DOCUMENT"]}]},
|
|
83
|
+
"selections": [{"entry_id": "FIELD-DOCUMENT", "anchor": "document"}],
|
|
84
|
+
"expected_document_sha256": digest(existing), "expected_provenance_sha256": digest(prior),
|
|
85
|
+
"proposed_provenance": {
|
|
86
|
+
"schema": 2, "doc": document_id, "created": datetime.date.today().isoformat(),
|
|
87
|
+
"document": {"path": target, "digest_method": "sha256-raw", "digest": digest(raw)},
|
|
88
|
+
"fields": [{"id": "FIELD-DOCUMENT", "owner_ref": "document", "claims": [{
|
|
89
|
+
"id": "CLAIM-DOCUMENT", "semantic_review": {"state": "unreviewed", "reference": None},
|
|
90
|
+
"evidence": evidence, "effective_authority_refs": [],
|
|
91
|
+
}]}],
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
identity = hashlib.sha256(json.dumps(request, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest()
|
|
95
|
+
request["operation_id"] = "context-refresh-" + identity[:32]
|
|
96
|
+
return request
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def prepare_finalization_preview(root: str | Path) -> dict:
|
|
100
|
+
"""Bind initialization to the exact required outputs without changing them."""
|
|
101
|
+
root = _artifactlib._trusted_directory(root, "UNSAFE_ROOT")
|
|
102
|
+
expected = {}
|
|
103
|
+
for path in sorted(_artifactlib._CONTEXT_FINALIZE_PATHS):
|
|
104
|
+
try:
|
|
105
|
+
expected[path] = _contextsnapshotlib.digest_file(root, path)["digest"]
|
|
106
|
+
except _contextsnapshotlib.DigestError as exc:
|
|
107
|
+
raise _artifactlib.ArtifactError(
|
|
108
|
+
"INVALID_CONTEXT_FINALIZATION", f"required output unavailable or unsafe: {path} ({exc})") from exc
|
|
109
|
+
identity = hashlib.sha256(json.dumps(expected, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
|
110
|
+
return {
|
|
111
|
+
"operation_id": "context-finalize-" + identity[:32], "mode": "finalize",
|
|
112
|
+
"document_id": "CONTEXT", "target_path": ".codearbiter/CONTEXT.md",
|
|
113
|
+
"expected": expected,
|
|
114
|
+
}
|
|
@@ -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
|
+
adapter_version = "0.19.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"
|
|
@@ -847,6 +847,31 @@ def cancel_user_prerequisite(
|
|
|
847
847
|
}
|
|
848
848
|
|
|
849
849
|
|
|
850
|
+
def retire_unanswered_user_prerequisite(
|
|
851
|
+
root: str | Path, artifact_id: str, prerequisite_id: str, *, reason: str,
|
|
852
|
+
) -> dict[str, Any]:
|
|
853
|
+
"""Retire only an ARMED question, never an observed user decision.
|
|
854
|
+
|
|
855
|
+
Reuse the existing bounded pending reader, per-plan transition lock,
|
|
856
|
+
and the proven ARMED-only cancellation. CONFIRMED or CAPTURED must
|
|
857
|
+
follow their existing exact recovery paths; no receipt is synthesized.
|
|
858
|
+
"""
|
|
859
|
+
if (not isinstance(reason, str) or not reason.strip()
|
|
860
|
+
or len(reason.encode("utf-8")) > 2048
|
|
861
|
+
or not isinstance(artifact_id, str) or ID_RE.fullmatch(artifact_id) is None
|
|
862
|
+
or not isinstance(prerequisite_id, str) or ID_RE.fullmatch(prerequisite_id) is None):
|
|
863
|
+
raise PrerequisiteError("INVALID_RETIREMENT", "retirement requires valid IDs and a bounded nonblank reason")
|
|
864
|
+
cancelled = cancel_user_prerequisite(root, artifact_id, prerequisite_id)
|
|
865
|
+
if cancelled.get("cancelled") is not True:
|
|
866
|
+
raise PrerequisiteError("INVALID_RETIREMENT", "the unanswered prerequisite was not cancelled")
|
|
867
|
+
return {
|
|
868
|
+
"artifact_id": artifact_id, "prerequisite_id": prerequisite_id,
|
|
869
|
+
"disposition": "unanswered_prompt_superseded", "reason": reason.strip(),
|
|
870
|
+
"authority_granted": False, "mutation_attempted": False,
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
|
|
850
875
|
def supersede_user_prerequisite(
|
|
851
876
|
root: str | Path,
|
|
852
877
|
client: Any,
|
|
@@ -976,12 +1001,22 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
976
1001
|
supersede.add_argument("--root", required=True)
|
|
977
1002
|
supersede.add_argument("--artifact-id", required=True)
|
|
978
1003
|
supersede.add_argument("--prerequisite-id", required=True)
|
|
1004
|
+
retire = sub.add_parser("retire")
|
|
1005
|
+
retire.add_argument("--root", required=True)
|
|
1006
|
+
retire.add_argument("--artifact-id", required=True)
|
|
1007
|
+
retire.add_argument("--prerequisite-id", required=True)
|
|
1008
|
+
retire.add_argument("--reason", required=True)
|
|
979
1009
|
args = parser.parse_args(argv)
|
|
980
1010
|
if args.command == "cancel":
|
|
981
1011
|
print(json.dumps(cancel_user_prerequisite(
|
|
982
1012
|
args.root, args.artifact_id, args.prerequisite_id
|
|
983
1013
|
)))
|
|
984
1014
|
return 0
|
|
1015
|
+
if args.command == "retire":
|
|
1016
|
+
print(json.dumps(retire_unanswered_user_prerequisite(
|
|
1017
|
+
args.root, args.artifact_id, args.prerequisite_id, reason=args.reason,
|
|
1018
|
+
)))
|
|
1019
|
+
return 0
|
|
985
1020
|
client = _artifactlib.ArtifactClient(
|
|
986
1021
|
args.root, _artifactlib.helper_installation(__file__)
|
|
987
1022
|
)
|
|
@@ -650,7 +650,7 @@ def assess_context_provenance(provenance_dir, expected_docs, snapshot,
|
|
|
650
650
|
"""
|
|
651
651
|
result = {"coverage": "incomplete", "identity": "unknown",
|
|
652
652
|
"semantic": "unverified", "verified_fresh": False,
|
|
653
|
-
"documents": {}, "first_input": {}}
|
|
653
|
+
"documents": {}, "first_input": {}, "snapshot_current": {}}
|
|
654
654
|
if (not isinstance(provenance_dir, (str, os.PathLike)) or
|
|
655
655
|
type(expected_docs) not in (list, tuple) or
|
|
656
656
|
not expected_docs):
|
|
@@ -725,6 +725,8 @@ def assess_context_provenance(provenance_dir, expected_docs, snapshot,
|
|
|
725
725
|
except Exception:
|
|
726
726
|
return result
|
|
727
727
|
probes = {doc: probe for doc in eligible}
|
|
728
|
+
result["snapshot_current"] = {
|
|
729
|
+
doc: probe["source_current"] and probe["targets_current"] for doc in eligible}
|
|
728
730
|
if not probe["source_current"] or not probe["targets_current"]:
|
|
729
731
|
if store_complete and len(eligible) == len(expected_docs):
|
|
730
732
|
result["coverage"] = "complete"
|
|
@@ -732,6 +734,9 @@ def assess_context_provenance(provenance_dir, expected_docs, snapshot,
|
|
|
732
734
|
for doc in eligible:
|
|
733
735
|
result["documents"][doc] = "v2_stale"
|
|
734
736
|
return result
|
|
737
|
+
result["snapshot_current"] = {
|
|
738
|
+
doc: probe.get("source_current") is True and probe.get("targets_current") is True
|
|
739
|
+
for doc, probe in probes.items()}
|
|
735
740
|
try:
|
|
736
741
|
files = snapshot["source"]["files"]
|
|
737
742
|
membership = snapshot["source"]["membership"]
|
|
@@ -1111,6 +1116,107 @@ def rebaseline(provenance, current_hashes):
|
|
|
1111
1116
|
# ---------------------------------------------------------------------------
|
|
1112
1117
|
|
|
1113
1118
|
|
|
1119
|
+
def v2_heal_worklist(staged_paths, provenance, snapshot, assessment):
|
|
1120
|
+
"""Select changed v2 claims intersecting staged sources; never authorize writes.
|
|
1121
|
+
|
|
1122
|
+
Use the existing assessor's dependency probe and snapshot. Unknown evidence
|
|
1123
|
+
suspends automatic maintenance for that document, not the commit. Selected
|
|
1124
|
+
claims still need content review and the native paired writer with CAS;
|
|
1125
|
+
replacing their digests through the legacy rebaseline path is unsupported.
|
|
1126
|
+
"""
|
|
1127
|
+
result = {"claims": [], "unknown": [], "authorizes_write": False}
|
|
1128
|
+
if (type(staged_paths) not in (list, tuple) or
|
|
1129
|
+
any(not _v2_path(path) for path in staged_paths) or
|
|
1130
|
+
type(provenance) is not dict or type(assessment) is not dict):
|
|
1131
|
+
result["unknown"].append({"document_id": None, "reason": "invalid_inputs"})
|
|
1132
|
+
return result
|
|
1133
|
+
paths = list(dict.fromkeys(staged_paths))
|
|
1134
|
+
if not paths:
|
|
1135
|
+
return result
|
|
1136
|
+
try:
|
|
1137
|
+
from _contextsnapshotlib import _membership_match
|
|
1138
|
+
|
|
1139
|
+
files, members = snapshot["source"]["files"], snapshot["source"]["membership"]
|
|
1140
|
+
targets = snapshot["targets"]
|
|
1141
|
+
states, probes = assessment["documents"], assessment["snapshot_current"]
|
|
1142
|
+
if (any(type(value) is not dict for value in (files, targets, states, probes)) or
|
|
1143
|
+
type(members) is not list):
|
|
1144
|
+
raise ValueError
|
|
1145
|
+
membership = {}
|
|
1146
|
+
for member in members:
|
|
1147
|
+
key = (member["predicate"], tuple(member["scope_paths"]))
|
|
1148
|
+
if key in membership:
|
|
1149
|
+
raise ValueError
|
|
1150
|
+
membership[key] = member
|
|
1151
|
+
except (KeyError, TypeError, ValueError):
|
|
1152
|
+
result["unknown"].append({"document_id": None, "reason": "invalid_snapshot_or_assessment"})
|
|
1153
|
+
return result
|
|
1154
|
+
|
|
1155
|
+
def intersects(evidence, path):
|
|
1156
|
+
if evidence["kind"] == "content":
|
|
1157
|
+
return path == evidence["path"]
|
|
1158
|
+
if not any(scope == "." or path == scope or path.startswith(scope + "/")
|
|
1159
|
+
for scope in evidence["scope_paths"]):
|
|
1160
|
+
return False
|
|
1161
|
+
predicate = evidence["predicate"]
|
|
1162
|
+
parents = path.split("/")[:-1]
|
|
1163
|
+
return (_membership_match(predicate, path, False) or
|
|
1164
|
+
any(_membership_match(predicate, parent, True) for parent in parents))
|
|
1165
|
+
|
|
1166
|
+
for doc in sorted(set(provenance) | set(states)):
|
|
1167
|
+
record = provenance.get(doc)
|
|
1168
|
+
if (type(record) is dict and record.get("schema") == SCHEMA_VERSION and
|
|
1169
|
+
valid_provenance_record(record)):
|
|
1170
|
+
continue
|
|
1171
|
+
if (not valid_provenance_record(record) or record.get("schema") != V2_SCHEMA_VERSION or
|
|
1172
|
+
record.get("doc") != doc):
|
|
1173
|
+
result["unknown"].append({"document_id": doc, "reason": "record_unknown"})
|
|
1174
|
+
continue
|
|
1175
|
+
candidates = []
|
|
1176
|
+
for field in record["fields"]:
|
|
1177
|
+
for claim in field["claims"]:
|
|
1178
|
+
affected = [path for path in paths
|
|
1179
|
+
if any(intersects(item, path) for item in claim["evidence"])]
|
|
1180
|
+
if affected:
|
|
1181
|
+
candidates.append((field, claim, affected))
|
|
1182
|
+
if not candidates:
|
|
1183
|
+
continue
|
|
1184
|
+
if states.get(doc) not in ("v2_current", "v2_stale") or probes.get(doc) is not True:
|
|
1185
|
+
result["unknown"].append({"document_id": doc, "reason": "snapshot_not_current"})
|
|
1186
|
+
continue
|
|
1187
|
+
document = record["document"]
|
|
1188
|
+
target = targets.get(document["path"])
|
|
1189
|
+
if (type(target) is not dict or target.get("status") != "file" or
|
|
1190
|
+
any(target.get(key) != document[key] for key in ("digest_method", "digest"))):
|
|
1191
|
+
result["unknown"].append({"document_id": doc, "reason": "document_preimage_changed_or_unknown"})
|
|
1192
|
+
continue
|
|
1193
|
+
selected, unknown = [], []
|
|
1194
|
+
for field, claim, affected in candidates:
|
|
1195
|
+
changed = []
|
|
1196
|
+
for evidence in claim["evidence"]:
|
|
1197
|
+
current = (files.get(evidence["path"]) if evidence["kind"] == "content" else
|
|
1198
|
+
membership.get((evidence["predicate"], tuple(evidence["scope_paths"]))))
|
|
1199
|
+
if (type(current) is not dict or
|
|
1200
|
+
current.get("status", "file") != (
|
|
1201
|
+
"file" if evidence["kind"] == "content" else "complete") or
|
|
1202
|
+
current.get("digest_method") != evidence["digest_method"] or
|
|
1203
|
+
not _sha256(current.get("digest"))):
|
|
1204
|
+
unknown.append({"document_id": doc, "field_id": field["id"],
|
|
1205
|
+
"claim_id": claim["id"], "paths": affected,
|
|
1206
|
+
"reason": "evidence_unknown"})
|
|
1207
|
+
break
|
|
1208
|
+
if current["digest"] != evidence["digest"]:
|
|
1209
|
+
changed.extend(path for path in affected if intersects(evidence, path))
|
|
1210
|
+
if changed:
|
|
1211
|
+
selected.append({"document_id": doc, "field_id": field["id"],
|
|
1212
|
+
"claim_id": claim["id"],
|
|
1213
|
+
"paths": [path for path in affected if path in changed]})
|
|
1214
|
+
result["unknown"].extend(unknown)
|
|
1215
|
+
if not unknown:
|
|
1216
|
+
result["claims"].extend(selected)
|
|
1217
|
+
return result
|
|
1218
|
+
|
|
1219
|
+
|
|
1114
1220
|
def heal_worklist(staged_paths, provenance, current_hashes):
|
|
1115
1221
|
"""Return staged paths that are drift_trigger:true entries with diverged/absent hashes.
|
|
1116
1222
|
|
|
@@ -1128,13 +1128,14 @@ def build_index(root):
|
|
|
1128
1128
|
extra = []
|
|
1129
1129
|
client = ArtifactClient(root, helper_installation(__file__))
|
|
1130
1130
|
for entry in client.index("spec"):
|
|
1131
|
-
|
|
1132
|
-
if
|
|
1131
|
+
state = entry["authority"].get("state")
|
|
1132
|
+
if state in ("draft", "ready", "approved") and entry.get("governs"):
|
|
1133
1133
|
extra.append({
|
|
1134
1134
|
"spec": entry["artifact_id"],
|
|
1135
1135
|
"path": entry["path"],
|
|
1136
1136
|
"globs": entry["governs"],
|
|
1137
1137
|
"artifact_html": True,
|
|
1138
|
+
"governance_state": state,
|
|
1138
1139
|
"revision": entry.get("revision"),
|
|
1139
1140
|
"model_sha256": entry.get("model_sha256"),
|
|
1140
1141
|
"normative_sha256": entry.get("normative_sha256"),
|
|
@@ -1165,22 +1166,35 @@ def spec_pointers(rel, index):
|
|
|
1165
1166
|
if not isinstance(index, list):
|
|
1166
1167
|
return _artifact_legacy_spec_pointers(rel, index)
|
|
1167
1168
|
warnings = [item for item in index if isinstance(item, dict) and item.get("artifact_diagnostic")]
|
|
1168
|
-
normal = [item for item in index if not isinstance(item, dict) or
|
|
1169
|
+
normal = [item for item in index if not isinstance(item, dict) or
|
|
1170
|
+
not item.get("artifact_html") and not item.get("artifact_diagnostic")]
|
|
1169
1171
|
result = _artifact_legacy_spec_pointers(rel, normal)
|
|
1172
|
+
for item in index:
|
|
1173
|
+
if (not isinstance(item, dict) or not item.get("artifact_html") or
|
|
1174
|
+
item.get("governance_state") not in ("draft", "ready", "approved")):
|
|
1175
|
+
continue
|
|
1176
|
+
if isinstance(rel, str) and any(fnmatch.fnmatch(rel, glob) for glob in item.get("globs", [])):
|
|
1177
|
+
result.append({"tier": "specs", "text": (
|
|
1178
|
+
"spec {} ({}) — advisory context for this file; read {}. "
|
|
1179
|
+
"This pointer grants no approval or execution authority."
|
|
1180
|
+
).format(item["spec"], item["governance_state"], item["path"])})
|
|
1170
1181
|
if warnings:
|
|
1171
1182
|
warning = warnings[0]
|
|
1172
1183
|
code = warning["artifact_diagnostic"]
|
|
1173
1184
|
if warning.get("artifact_diagnostic_kind") == "capability":
|
|
1174
1185
|
text = (
|
|
1175
1186
|
"HTML artifact capability unavailable (" + code + "). Repair or reinstall "
|
|
1176
|
-
"the pinned artifact payload.
|
|
1177
|
-
"
|
|
1187
|
+
"the pinned artifact payload. Stop only operations that require this capability. "
|
|
1188
|
+
"Independent authorized work may continue. "
|
|
1189
|
+
"Do not bypass the error with raw artifact writes."
|
|
1178
1190
|
)
|
|
1179
1191
|
else:
|
|
1180
1192
|
text = (
|
|
1181
1193
|
"HTML specification is invalid or unreadable (" + code + "). Inspect the "
|
|
1182
|
-
"engine diagnostic and repair-preview before
|
|
1183
|
-
"
|
|
1194
|
+
"engine diagnostic and repair-preview before repairing the affected artifact. "
|
|
1195
|
+
"Stop only operations that depend on the affected artifact. "
|
|
1196
|
+
"Independent authorized work may continue. "
|
|
1197
|
+
"Do not bypass the error with raw artifact writes."
|
|
1184
1198
|
)
|
|
1185
1199
|
result.append({"tier": "artifact-diagnostic", "text": text})
|
|
1186
1200
|
return result
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
|
|
6
6
|
from contextlib import contextmanager
|
|
7
7
|
import hashlib
|
|
8
|
+
from itertools import islice
|
|
8
9
|
import json
|
|
9
10
|
import os
|
|
10
11
|
from pathlib import Path
|
|
@@ -237,6 +238,107 @@ def _load_attempt(root: Path, pending: dict[str, Any]) -> dict[str, Any] | None:
|
|
|
237
238
|
return value["request"]
|
|
238
239
|
|
|
239
240
|
|
|
241
|
+
def _history_records(root: Path, relative: Path):
|
|
242
|
+
"""Read a bounded immutable history directory without following links."""
|
|
243
|
+
try:
|
|
244
|
+
directory = _artifactauthoritylib._safe_directory(root, relative, create=False)
|
|
245
|
+
except FileNotFoundError:
|
|
246
|
+
return
|
|
247
|
+
except (OSError, _artifactauthoritylib.AuthorityError) as exc:
|
|
248
|
+
raise ReconciliationError(f"reconciliation {relative.name} storage is unavailable or unsafe") from exc
|
|
249
|
+
try:
|
|
250
|
+
entries = list(islice(directory.iterdir(), 513))
|
|
251
|
+
if len(entries) > 512:
|
|
252
|
+
raise ReconciliationError(f"too many historical {relative.name} to inspect reconciliation safely")
|
|
253
|
+
for entry in entries:
|
|
254
|
+
if not _approvallib.SHA256_RE.fullmatch(entry.stem) or entry.suffix != ".json":
|
|
255
|
+
raise ReconciliationError(f"{relative.name} storage contains an unrecognized entry; inspect before retiring")
|
|
256
|
+
info = entry.lstat()
|
|
257
|
+
if (not stat.S_ISREG(info.st_mode) or getattr(info, "st_file_attributes", 0) & 0x400
|
|
258
|
+
or info.st_size > 65536):
|
|
259
|
+
raise ReconciliationError(f"reconciliation {relative.name} is not bounded regular data")
|
|
260
|
+
fd = _artifactlib._open_pinned_regular(entry)
|
|
261
|
+
with os.fdopen(fd, "rb") as stream:
|
|
262
|
+
opened = os.fstat(stream.fileno())
|
|
263
|
+
if (not stat.S_ISREG(opened.st_mode) or opened.st_size > 65536
|
|
264
|
+
or (info.st_dev, info.st_ino) != (opened.st_dev, opened.st_ino)):
|
|
265
|
+
raise ReconciliationError(f"reconciliation {relative.name} changed while opening")
|
|
266
|
+
raw = stream.read(65537)
|
|
267
|
+
if len(raw) > 65536:
|
|
268
|
+
raise ReconciliationError(f"reconciliation {relative.name} exceeds its bound")
|
|
269
|
+
record = _artifactlib._decode(raw)
|
|
270
|
+
if not isinstance(record, dict) or _digest(_canonical(record)) != entry.stem:
|
|
271
|
+
raise ReconciliationError(f"stored {relative.name} changed; preserve the pending reconciliation")
|
|
272
|
+
yield relative / entry.name, record
|
|
273
|
+
except (OSError, ValueError, _artifactlib.ArtifactError) as exc:
|
|
274
|
+
raise ReconciliationError(f"reconciliation {relative.name} storage is unreadable or unsafe") from exc
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _reply_observation_matches(observed: dict[str, Any], pending: dict[str, Any]) -> bool:
|
|
278
|
+
return (observed.get("format") == _artifactauthoritylib.OBSERVATION_FORMAT
|
|
279
|
+
and observed.get("kind") == "reconciliation"
|
|
280
|
+
and observed.get("producer_profile") == "host-user-prompt/0.1.0"
|
|
281
|
+
and observed.get("subject") == {
|
|
282
|
+
"artifact_id": pending["artifact_id"], "record_id": pending["target_id"],
|
|
283
|
+
"normative_sha256": pending["identity"].get("normative_sha256"),
|
|
284
|
+
}
|
|
285
|
+
and isinstance(observed.get("producer_result"), dict)
|
|
286
|
+
and observed["producer_result"].get("prompt_sha256") == pending["prompt_sha256"])
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _published_reply_observed(root: Path, pending: dict[str, Any]) -> bool:
|
|
290
|
+
"""A captured reply is not unanswered even if its attempt was not saved."""
|
|
291
|
+
return any(_reply_observation_matches(record, pending)
|
|
292
|
+
for _, record in _history_records(root, _artifactauthoritylib.OBSERVATION_DIR))
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _recover_captured_request(root: Path, client: Any, pending: dict[str, Any]) -> dict[str, Any]:
|
|
296
|
+
"""Recover only the original immutable user event; never manufacture one."""
|
|
297
|
+
try:
|
|
298
|
+
binding = _artifactpromptlib.binding(root, "reconciliation", pending["artifact_id"])
|
|
299
|
+
except _artifactpromptlib.PromptRouteError as exc:
|
|
300
|
+
raise ReconciliationError("captured reconciliation route is unavailable or unsafe") from exc
|
|
301
|
+
if binding != _digest(_canonical(pending)):
|
|
302
|
+
raise ReconciliationError("pending reconciliation changed after arming")
|
|
303
|
+
observations = {
|
|
304
|
+
path.as_posix(): record
|
|
305
|
+
for path, record in _history_records(root, _artifactauthoritylib.OBSERVATION_DIR)
|
|
306
|
+
if _reply_observation_matches(record, pending)
|
|
307
|
+
}
|
|
308
|
+
if not observations:
|
|
309
|
+
raise ReconciliationError("no in-flight reconciliation or observed reply to recover")
|
|
310
|
+
payload = {"input_sha256": pending["input_sha256"], "assessment": pending["assessment"]}
|
|
311
|
+
if pending["operation"] == "task-reconcile":
|
|
312
|
+
payload.update(task_sha256=_artifactauthoritylib._engine_digest(pending["record"]),
|
|
313
|
+
target_state=pending["target_state"])
|
|
314
|
+
sources = []
|
|
315
|
+
for path, event in _history_records(root, SOURCE_DIR):
|
|
316
|
+
reference = event.get("observation_ref")
|
|
317
|
+
observed = observations.get(reference) if isinstance(reference, str) else None
|
|
318
|
+
if (observed is not None
|
|
319
|
+
and event.get("format") == _artifactauthoritylib.EVENT_FORMAT
|
|
320
|
+
and event.get("kind") == "reconciliation"
|
|
321
|
+
and event.get("authority_kind") == "user_workflow"
|
|
322
|
+
and event.get("verdict") == "reconciled"
|
|
323
|
+
and event.get("subject") == observed["subject"]
|
|
324
|
+
and event.get("observation_sha256") == Path(event["observation_ref"]).stem
|
|
325
|
+
and event.get("payload") == payload
|
|
326
|
+
and isinstance(event.get("source_text"), str)
|
|
327
|
+
and _digest(event["source_text"].encode("utf-8")) == pending["prompt_sha256"]):
|
|
328
|
+
sources.append({"source_ref": path.as_posix(), "source_sha256": path.stem})
|
|
329
|
+
if len(sources) != 1:
|
|
330
|
+
raise ReconciliationError("captured reconciliation source is missing or ambiguous; preserve pending state")
|
|
331
|
+
# The native reader validates the original source/observation/context and
|
|
332
|
+
# reuses its content-addressed receipt. No host event or source is created.
|
|
333
|
+
captured = client.call("capture-observation", sources[0])
|
|
334
|
+
receipt = captured.get("receipt") if isinstance(captured, dict) else None
|
|
335
|
+
if not isinstance(receipt, str) or not receipt.startswith(".codearbiter/.artifacts/receipts/"):
|
|
336
|
+
raise ReconciliationError("capture returned no valid reconciliation receipt")
|
|
337
|
+
request = _mutation_request(pending, receipt)
|
|
338
|
+
_write_attempt(root, pending, request)
|
|
339
|
+
return request
|
|
340
|
+
|
|
341
|
+
|
|
240
342
|
def _current(client: Any, artifact_id: str, target_id: str) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
|
|
241
343
|
identity = client.call("identity", {"artifact_id": artifact_id})
|
|
242
344
|
snapshot = client.call("snapshot", {"artifact_id": artifact_id})
|
|
@@ -386,6 +488,8 @@ def _cancel_reconciliation_unlocked(root: Path, artifact_id: str, prompt: str) -
|
|
|
386
488
|
raise ReconciliationError("cancellation prompt does not match the armed request")
|
|
387
489
|
if _load_attempt(root, pending) is not None:
|
|
388
490
|
raise ReconciliationError("in-flight reconciliation must be retried or recovered, not cancelled")
|
|
491
|
+
if _published_reply_observed(root, pending):
|
|
492
|
+
raise ReconciliationError("observed user reconciliation must be retried or recovered, not cancelled")
|
|
389
493
|
try:
|
|
390
494
|
binding = _artifactpromptlib.binding(root, "reconciliation", artifact_id)
|
|
391
495
|
except _artifactpromptlib.PromptRouteError as exc:
|
|
@@ -397,6 +501,65 @@ def _cancel_reconciliation_unlocked(root: Path, artifact_id: str, prompt: str) -
|
|
|
397
501
|
return {"artifact_id": artifact_id, "cancelled": True}
|
|
398
502
|
|
|
399
503
|
|
|
504
|
+
def supersede_unanswered_reconciliation(
|
|
505
|
+
root: str | Path, artifact_id: str, *, reason: str,
|
|
506
|
+
) -> dict[str, Any]:
|
|
507
|
+
"""Retire an unanswered legacy prompt without a user approval code.
|
|
508
|
+
|
|
509
|
+
This only discards an unanswered armed question. A published reply or a
|
|
510
|
+
durable execution attempt must remain available for retry/recovery: never
|
|
511
|
+
turn uncertain work into an uncommitted result by deleting its marker.
|
|
512
|
+
Real task BLOCKED state, original receipts and actual user decisions are
|
|
513
|
+
not changed by this call.
|
|
514
|
+
"""
|
|
515
|
+
if (
|
|
516
|
+
not isinstance(reason, str) or not reason.strip()
|
|
517
|
+
or len(reason.encode("utf-8")) > 2048
|
|
518
|
+
or not isinstance(artifact_id, str) or ID_RE.fullmatch(artifact_id) is None
|
|
519
|
+
):
|
|
520
|
+
raise ReconciliationError("a valid artifact and bounded nonblank supersession reason are required")
|
|
521
|
+
# Match existing reconciliation entrypoints: macOS /var may resolve through
|
|
522
|
+
# the system /private/var alias. Inspect the canonical path with the shared
|
|
523
|
+
# trusted-directory validator rather than rejecting that platform alias.
|
|
524
|
+
# This still rejects an unsafe final directory, unreadable path or reparse
|
|
525
|
+
# point, and all pending/attempt/route checks remain unchanged.
|
|
526
|
+
requested = Path(root).absolute()
|
|
527
|
+
try:
|
|
528
|
+
info = requested.lstat()
|
|
529
|
+
except OSError as exc:
|
|
530
|
+
raise ReconciliationError("reconciliation root is unavailable or unsafe") from exc
|
|
531
|
+
# Allow macOS's *parent* /var -> /private/var alias after canonicalization,
|
|
532
|
+
# but never accept a symlink or Windows reparse point as the selected repo
|
|
533
|
+
# itself. This is stricter than the older legacy arm/cancel entrypoints.
|
|
534
|
+
if stat.S_ISLNK(info.st_mode) or getattr(info, "st_file_attributes", 0) & 0x400:
|
|
535
|
+
raise ReconciliationError("reconciliation root cannot be a linked or reparse directory")
|
|
536
|
+
root = _artifactlib._trusted_directory(requested.resolve(strict=True), "UNSAFE_ROOT")
|
|
537
|
+
with _reconciliation_lock(root, artifact_id):
|
|
538
|
+
pending = _load(root, artifact_id)
|
|
539
|
+
if _load_attempt(root, pending) is not None:
|
|
540
|
+
raise ReconciliationError("in-flight reconciliation must be retried or recovered, not superseded")
|
|
541
|
+
if _published_reply_observed(root, pending):
|
|
542
|
+
raise ReconciliationError("observed user reconciliation must be retried or recovered, not superseded")
|
|
543
|
+
try:
|
|
544
|
+
binding = _artifactpromptlib.binding(root, "reconciliation", artifact_id)
|
|
545
|
+
except _artifactpromptlib.PromptRouteError as exc:
|
|
546
|
+
raise ReconciliationError("reconciliation route is unavailable or unsafe; inspect before retiring") from exc
|
|
547
|
+
if binding != _digest(_canonical(pending)):
|
|
548
|
+
raise ReconciliationError("pending reconciliation and registered prompt binding differ")
|
|
549
|
+
# The validated marker represents an unanswered question; no captured
|
|
550
|
+
# reply or attempt exists. This does not certify a decision or change state.
|
|
551
|
+
# Receipt, observation and source history remain in their owning stores.
|
|
552
|
+
_pending_path(root, artifact_id).unlink()
|
|
553
|
+
_artifactpromptlib.unregister(root, "reconciliation", artifact_id)
|
|
554
|
+
return {
|
|
555
|
+
"artifact_id": artifact_id, "target_id": pending["target_id"],
|
|
556
|
+
"original_operation": pending["operation"],
|
|
557
|
+
"superseded": True, "disposition": "unanswered_prompt_superseded",
|
|
558
|
+
"reason": reason.strip(), "mutation_attempted": False,
|
|
559
|
+
"authority_granted": False,
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
|
|
400
563
|
def cancel_orphaned_reconciliation(root: str | Path, artifact_id: str) -> dict[str, Any]:
|
|
401
564
|
"""Recover a complete pending marker stranded before route registration."""
|
|
402
565
|
root = Path(root).resolve(strict=True)
|
|
@@ -404,6 +567,8 @@ def cancel_orphaned_reconciliation(root: str | Path, artifact_id: str) -> dict[s
|
|
|
404
567
|
pending = _load(root, artifact_id)
|
|
405
568
|
if _load_attempt(root, pending) is not None:
|
|
406
569
|
raise ReconciliationError("in-flight reconciliation must be retried or recovered, not cancelled")
|
|
570
|
+
if _published_reply_observed(root, pending):
|
|
571
|
+
raise ReconciliationError("observed user reconciliation must be retried or recovered, not cancelled")
|
|
407
572
|
try:
|
|
408
573
|
binding = _artifactpromptlib.binding(root, "reconciliation", artifact_id)
|
|
409
574
|
except _artifactpromptlib.PromptRouteError as exc:
|
|
@@ -420,13 +585,13 @@ def cancel_orphaned_reconciliation(root: str | Path, artifact_id: str) -> dict[s
|
|
|
420
585
|
|
|
421
586
|
|
|
422
587
|
def recover_reconciliation(root: str | Path, client: Any, artifact_id: str) -> dict[str, Any]:
|
|
423
|
-
"""
|
|
588
|
+
"""Recover a captured reply/attempt; clear only a proven commit or non-commit."""
|
|
424
589
|
root = Path(root).resolve(strict=True)
|
|
425
590
|
with _reconciliation_lock(root, artifact_id):
|
|
426
591
|
pending = _load(root, artifact_id)
|
|
427
592
|
request = _load_attempt(root, pending)
|
|
428
593
|
if request is None:
|
|
429
|
-
|
|
594
|
+
request = _recover_captured_request(root, client, pending)
|
|
430
595
|
try:
|
|
431
596
|
result = client.call(pending["operation"], request)
|
|
432
597
|
transaction = result.get("transaction") if isinstance(result, dict) else None
|
|
@@ -2834,12 +2834,12 @@ def _targets_error_exit_code(exc):
|
|
|
2834
2834
|
skill's "Targets" section and must never be mistaken for
|
|
2835
2835
|
"safe to back-fill".
|
|
2836
2836
|
|
|
2837
|
-
Exit code 2 is deliberately NOT reused for either state
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2837
|
+
Exit code 2 is deliberately NOT reused for either declared-file state.
|
|
2838
|
+
This module's dispatcher uses 2 for bad CLI invocation or an unrecognised
|
|
2839
|
+
target name; releasehash.py instead reserves 2 for "never confirmed"
|
|
2840
|
+
and uses 64/65 for usage/unknown-target errors. Neither caller may
|
|
2841
|
+
collapse its own exit-2 meaning into the distinction between an absent
|
|
2842
|
+
declared file and an existing but broken one."""
|
|
2843
2843
|
return 3 if isinstance(exc, AbsentBlockError) else 4
|
|
2844
2844
|
|
|
2845
2845
|
|