@arbiterforge/ca-pi 0.18.3 → 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.
Files changed (74) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/plugins/ca-pi/CHANGELOG.md +60 -0
  4. package/plugins/ca-pi/COMMANDS.md +2 -2
  5. package/plugins/ca-pi/SKILLS.md +5 -5
  6. package/plugins/ca-pi/SPRINT.md +43 -230
  7. package/plugins/ca-pi/agents/backend-author.md +5 -5
  8. package/plugins/ca-pi/agents/checkpoint-aggregator.md +7 -0
  9. package/plugins/ca-pi/agents/frontend-author.md +5 -5
  10. package/plugins/ca-pi/agents/infra-author.md +6 -7
  11. package/plugins/ca-pi/agents/scout.md +25 -24
  12. package/plugins/ca-pi/arbiter.md +6 -4
  13. package/plugins/ca-pi/generated/command-catalog.json +5 -5
  14. package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-amd64 +0 -0
  15. package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-arm64 +0 -0
  16. package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-amd64 +0 -0
  17. package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-arm64 +0 -0
  18. package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-amd64.exe +0 -0
  19. package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-arm64.exe +0 -0
  20. package/plugins/ca-pi/helpers/artifacts/release.json +37 -37
  21. package/plugins/ca-pi/hooks/_approvallib.py +41 -0
  22. package/plugins/ca-pi/hooks/_artifactlib.py +61 -115
  23. package/plugins/ca-pi/hooks/_artifactpromptlib.py +12 -12
  24. package/plugins/ca-pi/hooks/_bashguardlib.py +66 -4
  25. package/plugins/ca-pi/hooks/_contextselectlib.py +38 -35
  26. package/plugins/ca-pi/hooks/_contextwriterlib.py +114 -0
  27. package/plugins/ca-pi/hooks/_host.py +1 -1
  28. package/plugins/ca-pi/hooks/_prerequisitelib.py +35 -0
  29. package/plugins/ca-pi/hooks/_provenancelib.py +107 -1
  30. package/plugins/ca-pi/hooks/_readinjectlib.py +21 -7
  31. package/plugins/ca-pi/hooks/_reconciliationlib.py +170 -3
  32. package/plugins/ca-pi/hooks/_releaselib.py +6 -6
  33. package/plugins/ca-pi/hooks/_sprintapprovallib.py +52 -2
  34. package/plugins/ca-pi/hooks/artifact-reconcile.py +13 -4
  35. package/plugins/ca-pi/hooks/hostapi.py +1 -1
  36. package/plugins/ca-pi/hooks/init-codearbiter.py +34 -5
  37. package/plugins/ca-pi/includes/artifacts-attestation.md +434 -0
  38. package/plugins/ca-pi/includes/artifacts.md +133 -492
  39. package/plugins/ca-pi/includes/author-tdd-workflow.md +32 -21
  40. package/plugins/ca-pi/includes/fresh-verification.md +14 -12
  41. package/plugins/ca-pi/includes/harvest.md +3 -3
  42. package/plugins/ca-pi/includes/reference-map.md +1 -1
  43. package/plugins/ca-pi/includes/routing-table.md +3 -3
  44. package/plugins/ca-pi/includes/sprint-authority.md +6 -1
  45. package/plugins/ca-pi/includes/verification-boundary.md +11 -2
  46. package/plugins/ca-pi/package.json +1 -1
  47. package/plugins/ca-pi/routines/INDEX.md +8 -8
  48. package/plugins/ca-pi/routines/brainstorming/SKILL.md +65 -202
  49. package/plugins/ca-pi/routines/commit-gate/SKILL.md +60 -25
  50. package/plugins/ca-pi/routines/context-check/SKILL.md +89 -53
  51. package/plugins/ca-pi/routines/context-creation/SKILL.md +114 -220
  52. package/plugins/ca-pi/routines/decompose/SKILL.md +139 -167
  53. package/plugins/ca-pi/routines/executing-plans/SKILL.md +38 -136
  54. package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +45 -55
  55. package/plugins/ca-pi/routines/refactor/SKILL.md +27 -12
  56. package/plugins/ca-pi/routines/release/SKILL.md +1 -1
  57. package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +80 -230
  58. package/plugins/ca-pi/routines/tdd/SKILL.md +115 -228
  59. package/plugins/ca-pi/routines/writing-plans/SKILL.md +104 -174
  60. package/plugins/ca-pi/skills/ca-audit/SKILL.md +2 -2
  61. package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +1 -1
  62. package/plugins/ca-pi/skills/ca-commit/SKILL.md +60 -25
  63. package/plugins/ca-pi/skills/ca-context-check/SKILL.md +89 -53
  64. package/plugins/ca-pi/skills/ca-create-context/SKILL.md +114 -220
  65. package/plugins/ca-pi/skills/ca-decompose/SKILL.md +139 -167
  66. package/plugins/ca-pi/skills/ca-feature/SKILL.md +21 -38
  67. package/plugins/ca-pi/skills/ca-fix/SKILL.md +15 -9
  68. package/plugins/ca-pi/skills/ca-init/SKILL.md +18 -40
  69. package/plugins/ca-pi/skills/ca-pr/SKILL.md +45 -55
  70. package/plugins/ca-pi/skills/ca-refactor/SKILL.md +27 -12
  71. package/plugins/ca-pi/skills/ca-release/SKILL.md +1 -1
  72. package/plugins/ca-pi/skills/ca-review/SKILL.md +5 -8
  73. package/plugins/ca-pi/skills/ca-sprint/SKILL.md +11 -25
  74. 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.18.3"
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
- authority = entry["authority"]
1132
- if authority["authority_verified"] and entry.get("governs"):
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 not item.get("artifact_diagnostic")]
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. Source inspection and unrelated legacy Markdown "
1177
- "may continue; HTML planning/execution must stop until resolved."
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 any repair. Source inspection may "
1183
- "continue; HTML planning/execution must stop until resolved."
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
- """Replay a durable attempt; clear it only after a proven commit or non-commit."""
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
- raise ReconciliationError("no in-flight reconciliation to recover")
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
@@ -498,7 +663,9 @@ def _consume_reconciliation_unlocked(
498
663
  raise ReconciliationError("reconciliation target changed after arming")
499
664
  payload = {"input_sha256": pending["input_sha256"], "assessment": pending["assessment"]}
500
665
  if pending["operation"] == "task-reconcile":
501
- payload.update(task_sha256=pending["record_sha256"], target_state=pending["target_state"])
666
+ # Marker/route integrity retains its legacy serialization; authority
667
+ # binds the rechecked record to the engine's canonical JSON digest.
668
+ payload.update(task_sha256=_artifactauthoritylib._engine_digest(record), target_state=pending["target_state"])
502
669
  event = {
503
670
  "kind": "reconciliation",
504
671
  "authority_kind": "user_workflow",
@@ -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 here: it already
2838
- means "bad CLI invocation" or "unrecognised target name" elsewhere in
2839
- this dispatcher, neither of which is a statement about the declared
2840
- file's own state -- collapsing them together is exactly how the
2841
- original defect made "absent" and "exists but broken" indistinguishable
2842
- from the CLI."""
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