@tyroneross/build-loop 0.36.0 → 0.43.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 (270) hide show
  1. package/.agents/plugins/marketplace.json +2 -2
  2. package/.claude-plugin/marketplace.json +3 -3
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +1 -1
  5. package/.cursor/rules/build-loop-surface.mdc +12 -11
  6. package/.cursor/rules/skill-index.mdc +33 -0
  7. package/AGENTS.md +213 -34
  8. package/README.md +99 -31
  9. package/agents/advisor.md +4 -4
  10. package/agents/alignment-checker.md +2 -2
  11. package/agents/architecture-scout.md +4 -4
  12. package/agents/build-orchestrator.md +38 -36
  13. package/agents/database-assessor.md +11 -5
  14. package/agents/design-contract-specialist.md +8 -8
  15. package/agents/fact-checker.md +13 -3
  16. package/agents/fix-critique.md +2 -2
  17. package/agents/independent-auditor.md +60 -7
  18. package/agents/leak-scanner.md +82 -0
  19. package/agents/overfitting-reviewer.md +2 -2
  20. package/agents/plan-critic.md +1 -1
  21. package/agents/promotion-reviewer.md +5 -5
  22. package/agents/retrospective-synthesizer.md +138 -35
  23. package/agents/scope-auditor.md +82 -11
  24. package/agents/security-reviewer.md +56 -2
  25. package/agents/self-improvement-architect.md +17 -3
  26. package/agents/transcript-pattern-miner.md +5 -5
  27. package/agents/ui-validator.md +1 -1
  28. package/bin/build-loop-debugger.js +143 -0
  29. package/bin/build-loop-install.js +1 -4
  30. package/bin/build-loop-load-probe.js +345 -0
  31. package/codex-skills/build-loop/SKILL.md +28 -6
  32. package/commands/feedback.md +37 -0
  33. package/dist/src/interactive-verifier.d.ts +1 -14
  34. package/dist/src/interactive-verifier.d.ts.map +1 -1
  35. package/dist/src/interactive-verifier.js +6 -113
  36. package/dist/src/interactive-verifier.js.map +1 -1
  37. package/dist/src/quality.d.ts +5 -0
  38. package/dist/src/quality.d.ts.map +1 -0
  39. package/dist/src/quality.js +81 -0
  40. package/dist/src/quality.js.map +1 -0
  41. package/dist/src/storage.d.ts.map +1 -1
  42. package/dist/src/storage.js +37 -3
  43. package/dist/src/storage.js.map +1 -1
  44. package/docs/agent-surface-policy.md +35 -31
  45. package/docs/memory-setup.md +19 -0
  46. package/hooks/git/pre-push +65 -4
  47. package/hooks/hooks.json +95 -38
  48. package/hooks/pre-commit +20 -1
  49. package/hooks/pre-edit-rally-point.sh +10 -3
  50. package/hooks/session-start-codex-hook-trust.sh +30 -0
  51. package/hooks/session-start-git-hooks.sh +3 -1
  52. package/hooks/session-start-rally-point.sh +52 -4
  53. package/hooks/session-start-worktree-gc.sh +47 -94
  54. package/hooks/stop-transcript-sweep.sh +173 -0
  55. package/hooks/test_closeout.sh +14 -2
  56. package/package.json +8 -7
  57. package/scripts/README.md +1 -1
  58. package/scripts/_paths.py +65 -0
  59. package/scripts/groundwork_exchange.py +1012 -0
  60. package/scripts/install_memory.py +33 -1
  61. package/scripts/lessons_index/ingest.py +13 -2
  62. package/scripts/lessons_index/query.py +36 -13
  63. package/scripts/memory_context/__init__.py +108 -14
  64. package/scripts/memory_graph/__init__.py +5 -1
  65. package/scripts/project_resolver.py +42 -36
  66. package/scripts/sync_plugin_cache.py +37 -2
  67. package/skills/agent-rally-point/SKILL.md +46 -0
  68. package/skills/api-registry-bridge/SKILL.md +1 -1
  69. package/skills/architecture/dead/SKILL.md +1 -1
  70. package/skills/architecture/impact/SKILL.md +1 -1
  71. package/skills/architecture/review/SKILL.md +1 -1
  72. package/skills/architecture/rules/SKILL.md +3 -3
  73. package/skills/architecture/scan/SKILL.md +1 -1
  74. package/skills/architecture/trace/SKILL.md +1 -1
  75. package/skills/attribution-standard/SKILL.md +6 -6
  76. package/skills/auto-decision-capture/SKILL.md +31 -2
  77. package/skills/auto-finding-capture/SKILL.md +28 -1
  78. package/skills/build-loop/SKILL.md +131 -23
  79. package/skills/build-loop/fallbacks.md +16 -21
  80. package/skills/build-loop/phases/ui-validation.md +2 -2
  81. package/skills/build-loop/references/advisor-dispatch-ladder.md +1 -1
  82. package/skills/build-loop/references/apple-native-planning.md +1 -1
  83. package/skills/build-loop/references/autonomous-and-per-commit-modes.md +11 -5
  84. package/skills/build-loop/references/autonomy-dashboard.md +115 -0
  85. package/skills/build-loop/references/capability-routing.md +24 -2
  86. package/skills/build-loop/references/coordination.md +24 -6
  87. package/skills/build-loop/references/experiment-results-template.md +15 -3
  88. package/skills/build-loop/references/leadership.md +1 -1
  89. package/skills/build-loop/references/memory.md +14 -3
  90. package/skills/build-loop/references/modular-systems-pack.md +8 -0
  91. package/skills/build-loop/references/output-style.md +86 -0
  92. package/skills/build-loop/references/phase-1-assess.md +102 -2
  93. package/skills/build-loop/references/phase-2-plan.md +9 -1
  94. package/skills/build-loop/references/phase-3-execute.md +5 -2
  95. package/skills/build-loop/references/phase-4-review.md +85 -8
  96. package/skills/build-loop/references/phase-5-iterate.md +76 -8
  97. package/skills/build-loop/references/phase-6-learn.md +10 -17
  98. package/skills/build-loop/references/privileged-request-broker.md +254 -0
  99. package/skills/build-loop/references/resource-aware-execution.md +183 -0
  100. package/skills/build-loop/references/self-recursive-dev.md +2 -2
  101. package/skills/build-loop/references/status-output-format.md +207 -0
  102. package/skills/build-loop/references/verify-dispatch.md +56 -2
  103. package/skills/building-with-deepagents/SKILL.md +1 -1
  104. package/skills/claim-scope/SKILL.md +185 -0
  105. package/skills/color-engine/SKILL.md +103 -0
  106. package/skills/color-engine/_core.py +464 -0
  107. package/skills/color-engine/color_engine.py +175 -0
  108. package/skills/cost-rca/SKILL.md +61 -0
  109. package/skills/data-plane-worktrees/SKILL.md +139 -0
  110. package/skills/data-plane-worktrees/agents/openai.yaml +4 -0
  111. package/skills/database-practice/SKILL.md +200 -0
  112. package/skills/database-practice/references/diagnostic-queries.sql +126 -0
  113. package/skills/database-practice/references/vector-and-graph-tuning.md +208 -0
  114. package/skills/database-practice/scripts/db_table_map.py +1244 -0
  115. package/skills/database-practice/scripts/test_db_table_map.py +514 -0
  116. package/skills/debug-loop/SKILL.md +36 -6
  117. package/skills/debugging-memory/SKILL.md +32 -430
  118. package/skills/debugging-memory/references/pattern-extraction.md +4 -4
  119. package/skills/debugging-memory/references/search.md +32 -120
  120. package/skills/debugging-memory/references/store.md +32 -126
  121. package/skills/debugging-memory/references/subagent-integration.md +1 -1
  122. package/skills/decision-queue/SKILL.md +251 -0
  123. package/skills/decision-queue/assets/template.html +1242 -0
  124. package/skills/decision-queue/references/example-large-queue-batching.md +164 -0
  125. package/skills/decision-queue/scripts/regen_template_constants.py +160 -0
  126. package/skills/defenseclaw-bridge/SKILL.md +2 -2
  127. package/skills/defenseclaw-bridge/references/dc-config-mapping.md +2 -9
  128. package/skills/drain-proposals/SKILL.md +53 -0
  129. package/skills/focused-loop-builder/SKILL.md +31 -0
  130. package/skills/focused-loop-builder/references/spec-format.md +27 -0
  131. package/skills/handoff/SKILL.md +169 -8
  132. package/skills/ibr-bridge/SKILL.md +4 -1
  133. package/skills/knowledge/SKILL.md +26 -14
  134. package/skills/knowledge/references/review-mode.md +2 -3
  135. package/skills/knowledge/templates/madr-minimal.md +1 -1
  136. package/skills/mcp-builder/SKILL.md +1 -1
  137. package/skills/model-bakeoff/SKILL.md +48 -10
  138. package/skills/model-tiering/SKILL.md +92 -31
  139. package/skills/native-ax-driver/SKILL.md +38 -5
  140. package/skills/native-ax-driver/scripts/native_driver.py +278 -22
  141. package/skills/native-ax-driver/scripts/test_native_driver.py +227 -0
  142. package/skills/optimize/SKILL.md +1 -1
  143. package/skills/plugin-builder/SKILL.md +48 -1
  144. package/skills/plugin-builder/references/build-loop-phase-guidance.md +3 -4
  145. package/skills/plugin-builder/references/distribution.md +13 -2
  146. package/skills/plugin-builder/references/plugin-hygiene-lessons.md +2 -2
  147. package/skills/plugin-tests/SKILL.md +2 -2
  148. package/skills/recursive-retrospective/SKILL.md +1 -1
  149. package/skills/repo-closeout/SKILL.md +17 -0
  150. package/skills/repo-closeout/agents/openai.yaml +4 -0
  151. package/skills/repo-maintenance/SKILL.md +179 -0
  152. package/skills/repo-maintenance/agents/openai.yaml +4 -0
  153. package/skills/repo-maintenance/references/pre-public-hygiene.md +134 -0
  154. package/skills/repo-maintenance/references/repository-taxonomy.md +161 -0
  155. package/skills/repo-maintenance/references/safety-protocol.md +106 -0
  156. package/skills/repo-maintenance/references/stack-profiles.md +138 -0
  157. package/skills/repo-maintenance/scripts/audit_repo_maintenance.py +1198 -0
  158. package/skills/repo-maintenance/scripts/test_audit_repo_maintenance.py +506 -0
  159. package/skills/repository-intelligence/SKILL.md +189 -0
  160. package/skills/repository-intelligence/agents/openai.yaml +4 -0
  161. package/skills/repository-intelligence/references/assessment-rubric.md +88 -0
  162. package/skills/repository-intelligence/scripts/repository_inventory.py +347 -0
  163. package/skills/research/SKILL.md +12 -2
  164. package/skills/root-cause-analysis/SKILL.md +1 -1
  165. package/skills/runtime-parity-verification/SKILL.md +36 -1
  166. package/skills/security-methodology/SKILL.md +23 -10
  167. package/skills/security-methodology/references/agentic-handoff-templates.md +220 -0
  168. package/skills/security-methodology/references/cross-source-matrix.md +1 -1
  169. package/skills/security-methodology/references/owasp-agentic-top-10.md +1 -1
  170. package/skills/security-scan/SKILL.md +55 -15
  171. package/skills/self-improve/SKILL.md +70 -50
  172. package/skills/silent-assumptions/SKILL.md +341 -0
  173. package/skills/silent-assumptions/references/elicitation-detectors.md +342 -0
  174. package/skills/spec-writing/SKILL.md +128 -24
  175. package/skills/spec-writing/scripts/check_checklist.py +114 -15
  176. package/skills/ui-design/SKILL.md +6 -4
  177. package/skills/ui-design/references/color-engine.md +132 -0
  178. package/skills/ui-design/references/design-preferences-from-owned-apps.md +8 -8
  179. package/skills/ui-design/references/ui-guidance-sources.md +1 -1
  180. package/skills/ui-design/references/universal-design-principles.alt.md +2 -2
  181. package/plugin-artifacts/codex/.codex-plugin/plugin.json +0 -41
  182. package/plugin-artifacts/codex/AGENTS.md +0 -560
  183. package/plugin-artifacts/codex/BUILD-ARTIFACT.md +0 -5
  184. package/plugin-artifacts/codex/LICENSE +0 -202
  185. package/plugin-artifacts/codex/README.md +0 -313
  186. package/plugin-artifacts/codex/assets/build-loop-plugin-icon.png +0 -0
  187. package/plugin-artifacts/codex/docs/agent-surface-policy.md +0 -63
  188. package/plugin-artifacts/codex/references/advisor-dispatch-ladder.md +0 -62
  189. package/plugin-artifacts/codex/references/agent-role-taxonomy.md +0 -135
  190. package/plugin-artifacts/codex/references/autonomous-and-per-commit-modes.md +0 -161
  191. package/plugin-artifacts/codex/references/autonomy-config.md +0 -231
  192. package/plugin-artifacts/codex/references/backlog-system.md +0 -285
  193. package/plugin-artifacts/codex/references/capability-routing.md +0 -231
  194. package/plugin-artifacts/codex/references/codex-subagents.md +0 -106
  195. package/plugin-artifacts/codex/references/coordination-file-template.md +0 -181
  196. package/plugin-artifacts/codex/references/coordination-rules.md +0 -552
  197. package/plugin-artifacts/codex/references/dogfood-reload-checkpoint.md +0 -112
  198. package/plugin-artifacts/codex/references/halt-and-ask-protocol.md +0 -102
  199. package/plugin-artifacts/codex/references/implementer-envelope-schema.md +0 -302
  200. package/plugin-artifacts/codex/references/intent-capability-pack.md +0 -257
  201. package/plugin-artifacts/codex/references/intent-exploration-prompts.md +0 -96
  202. package/plugin-artifacts/codex/references/leadership.md +0 -72
  203. package/plugin-artifacts/codex/references/memory-systems.md +0 -261
  204. package/plugin-artifacts/codex/references/memory.md +0 -313
  205. package/plugin-artifacts/codex/references/model-tier-mapping.md +0 -296
  206. package/plugin-artifacts/codex/references/modular-systems-pack.md +0 -96
  207. package/plugin-artifacts/codex/references/phase-1-assess.md +0 -249
  208. package/plugin-artifacts/codex/references/phase-2-plan.md +0 -86
  209. package/plugin-artifacts/codex/references/phase-3-execute.md +0 -49
  210. package/plugin-artifacts/codex/references/phase-4-review.md +0 -341
  211. package/plugin-artifacts/codex/references/phase-5-iterate.md +0 -72
  212. package/plugin-artifacts/codex/references/phase-6-learn.md +0 -58
  213. package/plugin-artifacts/codex/references/recent-design-structures.md +0 -274
  214. package/plugin-artifacts/codex/references/research-trigger-policy.md +0 -140
  215. package/plugin-artifacts/codex/references/runtime-smoke-triggers.md +0 -42
  216. package/plugin-artifacts/codex/references/self-review.md +0 -234
  217. package/plugin-artifacts/codex/references/single-writer-commit-protocol.md +0 -90
  218. package/plugin-artifacts/codex/references/task-capture-policy.md +0 -68
  219. package/plugin-artifacts/codex/references/ui-io-contract.md +0 -116
  220. package/plugin-artifacts/codex/references/ui-spotcheck-protocol.md +0 -65
  221. package/plugin-artifacts/codex/references/verify-dispatch.md +0 -85
  222. package/plugin-artifacts/codex/skills/build-loop/SKILL.md +0 -381
  223. package/plugin-artifacts/codex/skills/build-loop/detect-plugins.mjs +0 -82
  224. package/plugin-artifacts/codex/skills/build-loop/eval-guide.md +0 -65
  225. package/plugin-artifacts/codex/skills/build-loop/fallbacks.md +0 -549
  226. package/plugin-artifacts/codex/skills/build-loop/phases/fact-check.md +0 -42
  227. package/plugin-artifacts/codex/skills/build-loop/phases/ui-validation.md +0 -267
  228. package/plugin-artifacts/codex/skills/build-loop/references/advisor-dispatch-ladder.md +0 -62
  229. package/plugin-artifacts/codex/skills/build-loop/references/apple-native-planning.md +0 -439
  230. package/plugin-artifacts/codex/skills/build-loop/references/autonomous-and-per-commit-modes.md +0 -161
  231. package/plugin-artifacts/codex/skills/build-loop/references/capability-routing.md +0 -231
  232. package/plugin-artifacts/codex/skills/build-loop/references/codex-subagents.md +0 -106
  233. package/plugin-artifacts/codex/skills/build-loop/references/coordination.md +0 -161
  234. package/plugin-artifacts/codex/skills/build-loop/references/correction-aware-capture.md +0 -177
  235. package/plugin-artifacts/codex/skills/build-loop/references/experiment-results-template.md +0 -101
  236. package/plugin-artifacts/codex/skills/build-loop/references/independent-auditor.md +0 -72
  237. package/plugin-artifacts/codex/skills/build-loop/references/intent-capability-pack.md +0 -257
  238. package/plugin-artifacts/codex/skills/build-loop/references/intent-exploration-prompts.md +0 -96
  239. package/plugin-artifacts/codex/skills/build-loop/references/leadership.md +0 -72
  240. package/plugin-artifacts/codex/skills/build-loop/references/memory.md +0 -313
  241. package/plugin-artifacts/codex/skills/build-loop/references/modular-systems-pack.md +0 -96
  242. package/plugin-artifacts/codex/skills/build-loop/references/output-style.md +0 -222
  243. package/plugin-artifacts/codex/skills/build-loop/references/pay-it-forward-arch.md +0 -98
  244. package/plugin-artifacts/codex/skills/build-loop/references/phase-1-assess.md +0 -249
  245. package/plugin-artifacts/codex/skills/build-loop/references/phase-2-plan.md +0 -86
  246. package/plugin-artifacts/codex/skills/build-loop/references/phase-3-execute.md +0 -49
  247. package/plugin-artifacts/codex/skills/build-loop/references/phase-4-review.md +0 -341
  248. package/plugin-artifacts/codex/skills/build-loop/references/phase-5-iterate.md +0 -72
  249. package/plugin-artifacts/codex/skills/build-loop/references/phase-6-learn.md +0 -58
  250. package/plugin-artifacts/codex/skills/build-loop/references/recent-design-structures.md +0 -274
  251. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/ASSESSMENT.md +0 -85
  252. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/STANDALONE_TEST_RUN.md +0 -149
  253. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/01-simple-bugfix.md +0 -32
  254. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/02-ui-build-with-iteration.md +0 -48
  255. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/03-multi-failure-escalation.md +0 -60
  256. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/04-ui-build-ibr-absent.md +0 -51
  257. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/05-refactor-navgator-absent.md +0 -71
  258. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/06-recurring-bug-debugger-absent.md +0 -52
  259. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/trace-comparison.md +0 -202
  260. package/plugin-artifacts/codex/skills/build-loop/references/self-recursive-dev.md +0 -77
  261. package/plugin-artifacts/codex/skills/build-loop/references/self-review.md +0 -234
  262. package/plugin-artifacts/codex/skills/build-loop/references/ui-io-contract.md +0 -116
  263. package/plugin-artifacts/codex/skills/build-loop/references/verify-dispatch.md +0 -85
  264. package/plugin-artifacts/codex/skills/build-loop/scanners/audit-design-rules.mjs +0 -476
  265. package/plugin-artifacts/codex/skills/build-loop/scanners/require-visual-evidence.mjs +0 -239
  266. package/plugin-artifacts/codex/skills/build-loop/templates/backlog-item.md +0 -35
  267. package/plugin-artifacts/codex/skills/build-loop/templates/codex-worker-prompt.md +0 -100
  268. package/plugin-artifacts/codex/skills/build-loop/templates/ui-subagent-prompt.md +0 -179
  269. package/plugin-artifacts/codex/skills/build-loop/templates/ux-fix-plan.md +0 -40
  270. package/scripts/build_codex_plugin_artifact.py +0 -321
@@ -0,0 +1,1012 @@
1
+ #!/usr/bin/env python3
2
+ """Validate Groundwork build requests and emit Build Loop implementation maps.
3
+
4
+ The adapter is intentionally stdlib-only. Groundwork remains authoritative for
5
+ desired state and convergence; this script validates the immutable request,
6
+ verifies repository-local delivery evidence, and returns a digest-bound map.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import hashlib
12
+ import json
13
+ import math
14
+ import os
15
+ import re
16
+ import shlex
17
+ import subprocess
18
+ import sys
19
+ import tempfile
20
+ from datetime import datetime, timezone
21
+ from decimal import Decimal
22
+ from pathlib import Path
23
+ from typing import Any, NoReturn
24
+ from urllib.parse import parse_qsl, unquote, urlsplit
25
+
26
+
27
+ BUILD_REQUEST_CONTRACT = "groundwork.build-request/v1"
28
+ IMPLEMENTATION_MAP_CONTRACT = "build-loop.implementation-map/v1"
29
+ CONVERGENCE_CONTRACT = "groundwork.convergence/v1"
30
+ DIGEST_RE = re.compile(r"^sha256:[a-f0-9]{64}$")
31
+ COMMIT_RE = re.compile(r"^[0-9a-fA-F]{7,64}$")
32
+ ARCHITECTURE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$")
33
+ KINDS = {"task", "component", "contract", "requirement"}
34
+ STATUSES = {"not-started", "implemented", "verified", "blocked", "manual", "diverged"}
35
+ EVIDENCE_KINDS = {"test", "runtime", "inspection"}
36
+ EVIDENCE_OUTCOMES = {"passed", "failed", "blocked", "manual"}
37
+ IMPACTS = {"none", "low", "medium", "high", "blocking"}
38
+ PLATFORMS = {"web", "vite-spa", "ios", "macos", "watchos", "tvos", "visionos", "android", "claude-plugin", "agent-system", "api", "service", "other"}
39
+ SURFACE_ROLES = {"primary", "companion", "admin", "extension", "service"}
40
+ PROVENANCE = {"observed", "decided", "assumed", "derived"}
41
+ PRIVATE_SEGMENTS = {".git", ".ssh", ".aws", ".gnupg", ".env", "secrets", "credentials"}
42
+ PRIVATE_NAME_RE = re.compile(r"^(?:credentials(?:\.[^.]+)?|secrets?(?:\.[^.]+)?|id_(?:rsa|dsa|ecdsa|ed25519))$", re.I)
43
+ URL_RE = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://[^\s\"'<>]+")
44
+ PRIVATE_PATH_RE = re.compile(r"(?:^|[\s\"'(])(?:/(?:Users|home|root|var/folders)/[^\s\"')]+|[A-Za-z]:\\Users\\[^\s\"')]+)", re.I)
45
+ DATETIME_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$")
46
+ SECRET_ASSIGNMENT_RE = re.compile(r"\b(?:api[-_ ]?key|access[-_ ]?token|refresh[-_ ]?token|client[-_ ]?secret|password|passwd|private[-_ ]?key|secret|credential|authorization)\b\s*(?:=|:|\bis\b)", re.I)
47
+ SECRET_VALUE_RES = (
48
+ re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----", re.I),
49
+ re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
50
+ re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b"),
51
+ re.compile(r"\bsk-[A-Za-z0-9_-]{16,}\b"),
52
+ re.compile(r"\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{12,}\b"),
53
+ re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{12,}\b"),
54
+ re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b"),
55
+ )
56
+
57
+
58
+ class ExchangeError(ValueError):
59
+ """Fail-closed contract or evidence validation error."""
60
+
61
+
62
+ def _fail(message: str) -> NoReturn:
63
+ raise ExchangeError(message)
64
+
65
+
66
+ def _js_number(value: int | float) -> str:
67
+ if isinstance(value, bool):
68
+ return "true" if value else "false"
69
+ if isinstance(value, int):
70
+ if abs(value) <= 9_007_199_254_740_991:
71
+ return str(value)
72
+ try:
73
+ value = float(value)
74
+ except OverflowError:
75
+ _fail("JSON numbers must be finite")
76
+ if not math.isfinite(value):
77
+ _fail("JSON numbers must be finite")
78
+ if value == 0:
79
+ return "0"
80
+ absolute = abs(value)
81
+ shortest = repr(value).lower()
82
+ if 1e-6 <= absolute < 1e21:
83
+ if "e" in shortest:
84
+ return format(Decimal(shortest), "f")
85
+ if shortest.endswith(".0"):
86
+ return shortest[:-2]
87
+ return shortest
88
+ if "e" not in shortest:
89
+ coefficient, exponent = f"{value:.15e}".split("e")
90
+ coefficient = coefficient.rstrip("0").rstrip(".")
91
+ else:
92
+ coefficient, exponent = shortest.split("e")
93
+ exponent_number = int(exponent)
94
+ sign = "+" if exponent_number >= 0 else "-"
95
+ return f"{coefficient}e{sign}{abs(exponent_number)}"
96
+
97
+
98
+ def _quote_json_string(value: str) -> str:
99
+ quoted = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
100
+ return re.sub(
101
+ r"[\ud800-\udfff]",
102
+ lambda match: f"\\u{ord(match.group(0)):04x}",
103
+ quoted,
104
+ )
105
+
106
+
107
+ def _utf16_sort_key(value: str) -> bytes:
108
+ return value.encode("utf-16-be", errors="surrogatepass")
109
+
110
+
111
+ def normalize_json(value: Any) -> str:
112
+ """Match Groundwork's recursively sorted compact UTF-8 JSON projection."""
113
+ if value is None:
114
+ return "null"
115
+ if value is True:
116
+ return "true"
117
+ if value is False:
118
+ return "false"
119
+ if isinstance(value, (int, float)):
120
+ return _js_number(value)
121
+ if isinstance(value, str):
122
+ return _quote_json_string(value)
123
+ if isinstance(value, list):
124
+ return "[" + ",".join(normalize_json(item) for item in value) + "]"
125
+ if isinstance(value, dict):
126
+ if not all(isinstance(key, str) for key in value):
127
+ _fail("JSON object keys must be strings")
128
+ return "{" + ",".join(
129
+ f"{_quote_json_string(key)}:{normalize_json(value[key])}"
130
+ for key in sorted(value, key=_utf16_sort_key)
131
+ ) + "}"
132
+ _fail(f"unsupported JSON value: {type(value).__name__}")
133
+
134
+
135
+ def digest_normalized(value: Any) -> str:
136
+ return "sha256:" + hashlib.sha256(normalize_json(value).encode("utf-8")).hexdigest()
137
+
138
+
139
+ def digest_file(path: Path) -> str:
140
+ digest = hashlib.sha256()
141
+ with path.open("rb") as handle:
142
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
143
+ digest.update(chunk)
144
+ return "sha256:" + digest.hexdigest()
145
+
146
+
147
+ def _load_json(path: Path) -> Any:
148
+ try:
149
+ return json.loads(path.read_text(encoding="utf-8"), parse_constant=lambda token: _fail(f"non-finite number {token}"))
150
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
151
+ _fail(f"cannot read JSON {path}: {exc}")
152
+
153
+
154
+ def _require_object(value: Any, label: str) -> dict[str, Any]:
155
+ if not isinstance(value, dict):
156
+ _fail(f"{label} must be an object")
157
+ return value
158
+
159
+
160
+ def _strict_keys(value: dict[str, Any], required: set[str], optional: set[str], label: str) -> None:
161
+ missing = sorted(required - value.keys())
162
+ extra = sorted(value.keys() - required - optional)
163
+ if missing:
164
+ _fail(f"{label} missing required fields: {', '.join(missing)}")
165
+ if extra:
166
+ _fail(f"{label} contains unsupported fields: {', '.join(extra)}")
167
+
168
+
169
+ def _nonempty(value: Any, label: str) -> str:
170
+ if not isinstance(value, str) or not value:
171
+ _fail(f"{label} must be a non-empty string")
172
+ return value
173
+
174
+
175
+ def _list(value: Any, label: str) -> list[Any]:
176
+ if not isinstance(value, list):
177
+ _fail(f"{label} must be an array")
178
+ return value
179
+
180
+
181
+ def _digest(value: Any, label: str) -> str:
182
+ text = _nonempty(value, label)
183
+ if not DIGEST_RE.fullmatch(text):
184
+ _fail(f"{label} must be a lowercase sha256 digest")
185
+ return text
186
+
187
+
188
+ def _timestamp(value: Any, label: str) -> datetime:
189
+ text = _nonempty(value, label)
190
+ if not DATETIME_RE.fullmatch(text):
191
+ _fail(f"{label} must use RFC 3339 date-time syntax with seconds and an offset")
192
+ try:
193
+ parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
194
+ except ValueError as exc:
195
+ _fail(f"{label} must be an ISO-8601 timestamp: {exc}")
196
+ if parsed.tzinfo is None:
197
+ _fail(f"{label} must include a timezone offset")
198
+ return parsed.astimezone(timezone.utc)
199
+
200
+
201
+ def _unique(values: list[str], label: str) -> None:
202
+ if len(values) != len(set(values)):
203
+ _fail(f"{label} contains duplicate values")
204
+
205
+
206
+ def _enum(value: Any, allowed: set[str], label: str) -> str:
207
+ if not isinstance(value, str) or value not in allowed:
208
+ _fail(f"{label} is unsupported")
209
+ return value
210
+
211
+
212
+ def _architecture_id(value: Any, label: str) -> str:
213
+ text = _nonempty(value, label)
214
+ if not ARCHITECTURE_ID_RE.fullmatch(text):
215
+ _fail(f"{label} must use Groundwork architecture ID syntax")
216
+ return text
217
+
218
+
219
+ def _safe_repo_path(value: Any, label: str) -> str:
220
+ text = _nonempty(value, label)
221
+ if "\0" in text or text.startswith(("/", "~", "\\\\")) or re.match(r"^[A-Za-z]:[\\/]", text):
222
+ _fail(f"{label} must be repository-relative")
223
+ if "\\" in text or re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", text):
224
+ _fail(f"{label} must use a repository-relative forward-slash path")
225
+ if re.search(r"%(?![0-9A-Fa-f]{2})", text):
226
+ _fail(f"{label} contains invalid percent encoding")
227
+ try:
228
+ decoded = unquote(text, errors="strict")
229
+ except UnicodeDecodeError:
230
+ _fail(f"{label} contains invalid UTF-8 percent encoding")
231
+ segments = decoded.split("/")
232
+ if any(not segment or segment in {".", ".."} for segment in segments):
233
+ _fail(f"{label} contains an unsafe path segment")
234
+ for segment in segments:
235
+ lower = segment.lower()
236
+ if lower in PRIVATE_SEGMENTS or (lower.startswith(".env.") and lower != ".env.example") or PRIVATE_NAME_RE.fullmatch(segment):
237
+ _fail(f"{label} points to a private or credential-bearing location")
238
+ return text
239
+
240
+
241
+ def _owned_files(raw: Any, label: str) -> list[str]:
242
+ values = [
243
+ _safe_repo_path(item, f"{label}[{index}]")
244
+ for index, item in enumerate(_list(raw, label))
245
+ ]
246
+ if not values:
247
+ _fail(f"{label} must contain at least 1 value(s)")
248
+ _unique(values, label)
249
+ return values
250
+
251
+
252
+ def _safe_text(value: str, label: str) -> None:
253
+ if PRIVATE_PATH_RE.search(value):
254
+ _fail(f"{label} contains an absolute private path")
255
+ for candidate in URL_RE.findall(value):
256
+ cleaned = candidate.rstrip("),.;!?")
257
+ try:
258
+ parsed = urlsplit(cleaned)
259
+ except ValueError:
260
+ _fail(f"{label} contains a malformed URL")
261
+ if parsed.username or parsed.password:
262
+ _fail(f"{label} contains a credential-bearing URL")
263
+ for key, _ in parse_qsl(parsed.query, keep_blank_values=True):
264
+ if re.search(r"(?:token|secret|password|passwd|api[-_]?key|credential|signature|access[-_]?key)", key, re.I):
265
+ _fail(f"{label} contains a credential-bearing URL")
266
+
267
+
268
+ def _safe_manual_text(value: str, label: str) -> None:
269
+ _safe_text(value, label)
270
+ if SECRET_ASSIGNMENT_RE.search(value) or any(pattern.search(value) for pattern in SECRET_VALUE_RES):
271
+ _fail(f"{label} contains a secret or credential assignment")
272
+
273
+
274
+ def _string_list(raw: Any, label: str, *, minimum: int = 0, unique: bool = False) -> list[str]:
275
+ values = [_nonempty(item, label) for item in _list(raw, label)]
276
+ if len(values) < minimum:
277
+ _fail(f"{label} must contain at least {minimum} value(s)")
278
+ if unique:
279
+ _unique(values, label)
280
+ return values
281
+
282
+
283
+ def _validate_qualified_ref(raw: Any, label: str) -> dict[str, Any]:
284
+ value = _require_object(raw, label)
285
+ _strict_keys(value, {"specId", "kind", "id"}, set(), label)
286
+ _architecture_id(value["specId"], f"{label}.specId")
287
+ _architecture_id(value["id"], f"{label}.id")
288
+ _enum(value["kind"], {"component", "contract", "feature", "screen", "element", "requirement", "flow", "task"}, f"{label}.kind")
289
+ return value
290
+
291
+
292
+ def _validate_architecture(
293
+ architecture: dict[str, Any],
294
+ *,
295
+ spec_id: str,
296
+ local_refs: set[str],
297
+ screen_states: dict[str, set[str]],
298
+ ) -> tuple[set[str], set[str], set[str]]:
299
+ refs: list[tuple[dict[str, Any], str, str | None]] = []
300
+ component_ids: list[str] = []
301
+ for index, raw in enumerate(architecture["components"]):
302
+ label = f"architecture.components[{index}]"
303
+ item = _require_object(raw, label)
304
+ _strict_keys(item, {"id", "name", "kind", "featureIds", "owner"}, {"description", "provenance", "ownedFiles"}, label)
305
+ component_ids.append(_architecture_id(item["id"], f"{label}.id"))
306
+ _nonempty(item["name"], f"{label}.name")
307
+ _nonempty(item["owner"], f"{label}.owner")
308
+ _enum(item["kind"], {"ui", "service", "data", "integration", "agent", "library", "platform"}, f"{label}.kind")
309
+ for feature_index, feature_id in enumerate(_string_list(item["featureIds"], f"{label}.featureIds", unique=True)):
310
+ _architecture_id(feature_id, f"{label}.featureIds[{feature_index}]")
311
+ refs.append((
312
+ {"specId": spec_id, "kind": "feature", "id": feature_id},
313
+ f"{label}.featureIds[{feature_index}]",
314
+ "feature",
315
+ ))
316
+ if "description" in item and not isinstance(item["description"], str):
317
+ _fail(f"{label}.description must be a string")
318
+ if "ownedFiles" in item:
319
+ _owned_files(item["ownedFiles"], f"{label}.ownedFiles")
320
+ if "provenance" in item:
321
+ _enum(item["provenance"], PROVENANCE, f"{label}.provenance")
322
+ _unique(component_ids, "architecture component ids")
323
+
324
+ contract_ids: list[str] = []
325
+ for index, raw in enumerate(architecture["contracts"]):
326
+ label = f"architecture.contracts[{index}]"
327
+ item = _require_object(raw, label)
328
+ _strict_keys(item, {"id", "name", "provider", "consumers", "ports", "transport", "failureModes", "securityNotes"}, {"provenance", "ownedFiles"}, label)
329
+ contract_ids.append(_architecture_id(item["id"], f"{label}.id"))
330
+ _nonempty(item["name"], f"{label}.name")
331
+ refs.append((_validate_qualified_ref(item["provider"], f"{label}.provider"), f"{label}.provider", "component"))
332
+ consumers = _list(item["consumers"], f"{label}.consumers")
333
+ if not consumers:
334
+ _fail(f"{label}.consumers must not be empty")
335
+ consumer_ids: list[str] = []
336
+ for ref_index, ref in enumerate(consumers):
337
+ parsed_ref = _validate_qualified_ref(ref, f"{label}.consumers[{ref_index}]")
338
+ refs.append((parsed_ref, f"{label}.consumers[{ref_index}]", "component"))
339
+ consumer_ids.append(f"{parsed_ref['specId']}:{parsed_ref['kind']}:{parsed_ref['id']}")
340
+ _unique(consumer_ids, f"{label}.consumers")
341
+ ports = _list(item["ports"], f"{label}.ports")
342
+ if not ports:
343
+ _fail(f"{label}.ports must not be empty")
344
+ port_ids: list[str] = []
345
+ for port_index, raw_port in enumerate(ports):
346
+ port_label = f"{label}.ports[{port_index}]"
347
+ port = _require_object(raw_port, port_label)
348
+ _strict_keys(port, {"id", "name", "type", "direction"}, {"required"}, port_label)
349
+ port_ids.append(_architecture_id(port["id"], f"{port_label}.id"))
350
+ _nonempty(port["name"], f"{port_label}.name")
351
+ _nonempty(port["type"], f"{port_label}.type")
352
+ _enum(port["direction"], {"input", "output", "bidirectional"}, f"{port_label}.direction")
353
+ if "required" in port and not isinstance(port["required"], bool):
354
+ _fail(f"{port_label}.required must be boolean")
355
+ _unique(port_ids, f"{label} port ids")
356
+ _nonempty(item["transport"], f"{label}.transport")
357
+ _string_list(item["failureModes"], f"{label}.failureModes", minimum=1)
358
+ _string_list(item["securityNotes"], f"{label}.securityNotes", minimum=1)
359
+ if "ownedFiles" in item:
360
+ _owned_files(item["ownedFiles"], f"{label}.ownedFiles")
361
+ if "provenance" in item:
362
+ _enum(item["provenance"], PROVENANCE, f"{label}.provenance")
363
+ _unique(contract_ids, "architecture contract ids")
364
+
365
+ relationship_ids: list[str] = []
366
+ for index, raw in enumerate(architecture["relationships"]):
367
+ label = f"architecture.relationships[{index}]"
368
+ item = _require_object(raw, label)
369
+ _strict_keys(item, {"id", "from", "to", "direction", "criticality", "optional", "rationale"}, {"contractRef", "provenance"}, label)
370
+ relationship_ids.append(_architecture_id(item["id"], f"{label}.id"))
371
+ refs.append((_validate_qualified_ref(item["from"], f"{label}.from"), f"{label}.from", "component"))
372
+ refs.append((_validate_qualified_ref(item["to"], f"{label}.to"), f"{label}.to", "component"))
373
+ if "contractRef" in item:
374
+ refs.append((_validate_qualified_ref(item["contractRef"], f"{label}.contractRef"), f"{label}.contractRef", "contract"))
375
+ _enum(item["direction"], {"unidirectional", "bidirectional", "event"}, f"{label}.direction")
376
+ _enum(item["criticality"], {"hard", "soft", "informational"}, f"{label}.criticality")
377
+ if not isinstance(item["optional"], bool):
378
+ _fail(f"{label}.optional must be boolean")
379
+ _nonempty(item["rationale"], f"{label}.rationale")
380
+ if "provenance" in item:
381
+ _enum(item["provenance"], PROVENANCE, f"{label}.provenance")
382
+ _unique(relationship_ids, "architecture relationship ids")
383
+
384
+ flow_ids: list[str] = []
385
+ for index, raw in enumerate(architecture["flows"]):
386
+ label = f"architecture.flows[{index}]"
387
+ item = _require_object(raw, label)
388
+ _strict_keys(item, {"id", "name", "trigger", "exchanges"}, {"provenance"}, label)
389
+ flow_ids.append(_architecture_id(item["id"], f"{label}.id"))
390
+ _nonempty(item["name"], f"{label}.name")
391
+ _nonempty(item["trigger"], f"{label}.trigger")
392
+ exchanges = _list(item["exchanges"], f"{label}.exchanges")
393
+ if not exchanges:
394
+ _fail(f"{label}.exchanges must not be empty")
395
+ exchange_ids: list[str] = []
396
+ orders: list[int] = []
397
+ for exchange_index, raw_exchange in enumerate(exchanges):
398
+ exchange_label = f"{label}.exchanges[{exchange_index}]"
399
+ exchange = _require_object(raw_exchange, exchange_label)
400
+ _strict_keys(
401
+ exchange,
402
+ {"id", "order", "from", "to", "contractRef", "inputRefs", "outputRefs", "failurePaths"},
403
+ {"stateRefs"},
404
+ exchange_label,
405
+ )
406
+ exchange_ids.append(_architecture_id(exchange["id"], f"{exchange_label}.id"))
407
+ if not isinstance(exchange["order"], int) or isinstance(exchange["order"], bool) or exchange["order"] < 1:
408
+ _fail(f"{exchange_label}.order must be a positive integer")
409
+ orders.append(exchange["order"])
410
+ for key, expected_kind in (("from", "component"), ("to", "component"), ("contractRef", "contract")):
411
+ refs.append((
412
+ _validate_qualified_ref(exchange[key], f"{exchange_label}.{key}"),
413
+ f"{exchange_label}.{key}",
414
+ expected_kind,
415
+ ))
416
+ for key in ("inputRefs", "outputRefs"):
417
+ for ref_index, ref in enumerate(_list(exchange[key], f"{exchange_label}.{key}")):
418
+ ref_label = f"{exchange_label}.{key}[{ref_index}]"
419
+ refs.append((_validate_qualified_ref(ref, ref_label), ref_label, None))
420
+ if "stateRefs" in exchange:
421
+ for state_ref_index, raw_state_ref in enumerate(
422
+ _list(exchange["stateRefs"], f"{exchange_label}.stateRefs")
423
+ ):
424
+ state_ref_label = f"{exchange_label}.stateRefs[{state_ref_index}]"
425
+ state_ref = _require_object(raw_state_ref, state_ref_label)
426
+ _strict_keys(state_ref, {"screenId", "state"}, set(), state_ref_label)
427
+ screen_id = _architecture_id(state_ref["screenId"], f"{state_ref_label}.screenId")
428
+ state = _nonempty(state_ref["state"], f"{state_ref_label}.state")
429
+ states = screen_states.get(screen_id)
430
+ if states is None:
431
+ _fail(f"{state_ref_label}.screenId references unknown screen {screen_id}")
432
+ if state not in states:
433
+ _fail(f"{state_ref_label}.state references unknown state {state} on screen {screen_id}")
434
+ _string_list(exchange["failurePaths"], f"{exchange_label}.failurePaths", minimum=1)
435
+ _unique(exchange_ids, f"{label} exchange ids")
436
+ if len(orders) != len(set(orders)):
437
+ _fail(f"{label} exchange orders must be unique")
438
+ if "provenance" in item:
439
+ _enum(item["provenance"], PROVENANCE, f"{label}.provenance")
440
+ _unique(flow_ids, "architecture flow ids")
441
+
442
+ dependency_ids: list[str] = []
443
+ dependency_specs: list[str] = []
444
+ for index, raw in enumerate(architecture["specDependencies"]):
445
+ label = f"architecture.specDependencies[{index}]"
446
+ item = _require_object(raw, label)
447
+ _strict_keys(item, {"id", "specId", "location", "schemaVersion", "revision", "digest", "relationship"}, {"optional"}, label)
448
+ dependency_ids.append(_architecture_id(item["id"], f"{label}.id"))
449
+ dependency_specs.append(_architecture_id(item["specId"], f"{label}.specId"))
450
+ location = _require_object(item["location"], f"{label}.location")
451
+ if location.get("kind") == "local":
452
+ _strict_keys(location, {"kind", "path"}, set(), f"{label}.location")
453
+ _safe_repo_path(location["path"], f"{label}.location.path")
454
+ elif location.get("kind") == "uri":
455
+ _strict_keys(location, {"kind", "uri"}, set(), f"{label}.location")
456
+ uri = _nonempty(location["uri"], f"{label}.location.uri")
457
+ _safe_text(uri, f"{label}.location.uri")
458
+ if not URL_RE.fullmatch(uri):
459
+ _fail(f"{label}.location.uri must be an absolute URI")
460
+ else:
461
+ _fail(f"{label}.location.kind is unsupported")
462
+ if not isinstance(item["schemaVersion"], int) or isinstance(item["schemaVersion"], bool) or item["schemaVersion"] < 1:
463
+ _fail(f"{label}.schemaVersion must be a positive integer")
464
+ _nonempty(item["revision"], f"{label}.revision")
465
+ _digest(item["digest"], f"{label}.digest")
466
+ _enum(item["relationship"], {"uses", "extends", "implements", "companion"}, f"{label}.relationship")
467
+ if "optional" in item and not isinstance(item["optional"], bool):
468
+ _fail(f"{label}.optional must be boolean")
469
+ _unique(dependency_ids, "architecture dependency ids")
470
+ _unique(dependency_specs, "architecture dependency specIds")
471
+ known_local = set(local_refs)
472
+ known_local.update(f"{spec_id}:component:{item}" for item in component_ids)
473
+ known_local.update(f"{spec_id}:contract:{item}" for item in contract_ids)
474
+ known_local.update(f"{spec_id}:flow:{item}" for item in flow_ids)
475
+ dependency_spec_set = set(dependency_specs)
476
+ for ref, label, expected_kind in refs:
477
+ if expected_kind and ref["kind"] != expected_kind:
478
+ _fail(f"{label}.kind must be {expected_kind}")
479
+ identity = f"{ref['specId']}:{ref['kind']}:{ref['id']}"
480
+ if ref["specId"] == spec_id and identity not in known_local:
481
+ _fail(f"{label} references unknown local {ref['kind']} {ref['id']}")
482
+ if ref["specId"] != spec_id and ref["specId"] not in dependency_spec_set:
483
+ _fail(f"{label} has no declared Spec dependency for {ref['specId']}")
484
+ return set(component_ids), set(contract_ids), set(dependency_specs)
485
+
486
+
487
+ def _request_projection(request: dict[str, Any]) -> dict[str, Any]:
488
+ return {key: value for key, value in request.items() if key != "requestDigest"}
489
+
490
+
491
+ def _validate_platform_surfaces(raw: Any) -> list[dict[str, Any]]:
492
+ surfaces = _list(raw, "platformSurfaces")
493
+ if not surfaces:
494
+ _fail("platformSurfaces must not be empty")
495
+ ids: list[str] = []
496
+ for index, entry in enumerate(surfaces):
497
+ item = _require_object(entry, f"platformSurfaces[{index}]")
498
+ _strict_keys(item, {"id", "platform", "role", "name", "interactionModes", "featureIds"}, {"provenance"}, f"platformSurfaces[{index}]")
499
+ ids.append(_architecture_id(item["id"], f"platformSurfaces[{index}].id"))
500
+ _nonempty(item["name"], f"platformSurfaces[{index}].name")
501
+ if not isinstance(item["platform"], str) or not isinstance(item["role"], str) or item["platform"] not in PLATFORMS or item["role"] not in SURFACE_ROLES:
502
+ _fail(f"platformSurfaces[{index}] has an unsupported platform or role")
503
+ for key in ("interactionModes", "featureIds"):
504
+ values = [_nonempty(value, f"platformSurfaces[{index}].{key}") for value in _list(item[key], f"platformSurfaces[{index}].{key}")]
505
+ _unique(values, f"platformSurfaces[{index}].{key}")
506
+ if key == "featureIds":
507
+ for value_index, entry in enumerate(values):
508
+ _architecture_id(entry, f"platformSurfaces[{index}].featureIds[{value_index}]")
509
+ if "provenance" in item and (not isinstance(item["provenance"], str) or item["provenance"] not in PROVENANCE):
510
+ _fail(f"platformSurfaces[{index}].provenance is unsupported")
511
+ _unique(ids, "platform surface ids")
512
+ return surfaces
513
+
514
+
515
+ def _validate_object_ids(raw: Any, label: str, id_key: str = "id") -> set[str]:
516
+ ids: list[str] = []
517
+ for index, entry in enumerate(_list(raw, label)):
518
+ item = _require_object(entry, f"{label}[{index}]")
519
+ ids.append(_nonempty(item.get(id_key), f"{label}[{index}].{id_key}"))
520
+ _unique(ids, f"{label} ids")
521
+ return set(ids)
522
+
523
+
524
+ def _canonical_local_refs(canonical: dict[str, Any], spec_id: str) -> set[str]:
525
+ refs: set[str] = set()
526
+ for field, kind in (("needs", "requirement"), ("features", "feature"), ("screens", "screen")):
527
+ if field not in canonical:
528
+ continue
529
+ for index, raw in enumerate(_list(canonical[field], f"canonical Spec.{field}")):
530
+ item = _require_object(raw, f"canonical Spec.{field}[{index}]")
531
+ item_id = _architecture_id(item.get("id"), f"canonical Spec.{field}[{index}].id")
532
+ refs.add(f"{spec_id}:{kind}:{item_id}")
533
+ if field == "screens":
534
+ for element_index, raw_element in enumerate(_list(item.get("elements", []), f"canonical Spec.{field}[{index}].elements")):
535
+ element = _require_object(raw_element, f"canonical Spec.{field}[{index}].elements[{element_index}]")
536
+ element_id = _architecture_id(element.get("id"), f"canonical Spec.{field}[{index}].elements[{element_index}].id")
537
+ refs.add(f"{spec_id}:element:{element_id}")
538
+ return refs
539
+
540
+
541
+ def _canonical_screen_states(canonical: dict[str, Any]) -> dict[str, set[str]]:
542
+ screen_states: dict[str, set[str]] = {}
543
+ for index, raw in enumerate(_list(canonical.get("screens", []), "canonical Spec.screens")):
544
+ label = f"canonical Spec.screens[{index}]"
545
+ screen = _require_object(raw, label)
546
+ screen_id = _architecture_id(screen.get("id"), f"{label}.id")
547
+ states = set(_string_list(screen.get("states", []), f"{label}.states"))
548
+ screen_states[screen_id] = states
549
+ return screen_states
550
+
551
+
552
+ def validate_request(request: Any, canonical_spec: Any) -> dict[str, Any]:
553
+ value = _require_object(request, "build request")
554
+ _strict_keys(value, {
555
+ "contract", "runId", "specId", "specDigest", "taskDigest", "platformSurfaces",
556
+ "architecture", "tasks", "acceptanceCriteria", "manualActions", "returnVersions",
557
+ "requestDigest", "createdAt",
558
+ }, set(), "build request")
559
+ if value["contract"] != BUILD_REQUEST_CONTRACT:
560
+ _fail(f"unsupported build request contract: {value['contract']!r}")
561
+ _nonempty(value["runId"], "runId")
562
+ spec_id = _nonempty(value["specId"], "specId")
563
+ _digest(value["specDigest"], "specDigest")
564
+ _digest(value["taskDigest"], "taskDigest")
565
+ _digest(value["requestDigest"], "requestDigest")
566
+ _timestamp(value["createdAt"], "createdAt")
567
+ canonical = _require_object(canonical_spec, "canonical Spec")
568
+ if _nonempty(canonical.get("id"), "canonical Spec id") != spec_id:
569
+ _fail("specId does not match the canonical Spec id")
570
+ surfaces = _validate_platform_surfaces(value["platformSurfaces"])
571
+ architecture = _require_object(value["architecture"], "architecture")
572
+ _strict_keys(architecture, {"components", "contracts", "relationships", "flows", "specDependencies"}, set(), "architecture")
573
+ for key in ("components", "contracts", "relationships", "flows", "specDependencies"):
574
+ _list(architecture[key], f"architecture.{key}")
575
+ components, contracts, dependency_specs = _validate_architecture(
576
+ architecture,
577
+ spec_id=spec_id,
578
+ local_refs=_canonical_local_refs(canonical, spec_id),
579
+ screen_states=_canonical_screen_states(canonical),
580
+ )
581
+
582
+ if "platformSurfaces" not in canonical or normalize_json(surfaces) != normalize_json(canonical["platformSurfaces"]):
583
+ _fail("platformSurfaces do not match the canonical Spec")
584
+ if "architecture" not in canonical or normalize_json(architecture) != normalize_json(canonical["architecture"]):
585
+ _fail("architecture does not match the canonical Spec")
586
+
587
+ tasks = _list(value["tasks"], "tasks")
588
+ if not tasks:
589
+ _fail("tasks must not be empty")
590
+ task_ids: list[str] = []
591
+ acceptance_ids_list: list[str] = []
592
+ for index, raw in enumerate(_list(value["acceptanceCriteria"], "acceptanceCriteria")):
593
+ criterion = _require_object(raw, f"acceptanceCriteria[{index}]")
594
+ _strict_keys(criterion, {"id", "statement"}, {"testHint"}, f"acceptanceCriteria[{index}]")
595
+ acceptance_ids_list.append(_nonempty(criterion["id"], f"acceptanceCriteria[{index}].id"))
596
+ _safe_text(_nonempty(criterion["statement"], f"acceptanceCriteria[{index}].statement"), f"acceptanceCriteria[{index}].statement")
597
+ if "testHint" in criterion:
598
+ if not isinstance(criterion["testHint"], str):
599
+ _fail(f"acceptanceCriteria[{index}].testHint must be a string")
600
+ _safe_text(criterion["testHint"], f"acceptanceCriteria[{index}].testHint")
601
+ _unique(acceptance_ids_list, "acceptance criterion ids")
602
+ acceptance_ids = set(acceptance_ids_list)
603
+
604
+ manual_ids: list[str] = []
605
+ for index, raw in enumerate(_list(value["manualActions"], "manualActions")):
606
+ action = _require_object(raw, f"manualActions[{index}]")
607
+ _strict_keys(action, {"id", "location", "action", "requiredValueName", "destination", "verification"}, set(), f"manualActions[{index}]")
608
+ manual_id = _nonempty(action["id"], f"manualActions[{index}].id")
609
+ _safe_manual_text(manual_id, f"manualActions[{index}].id")
610
+ manual_ids.append(manual_id)
611
+ for key in ("location", "action", "requiredValueName", "destination", "verification"):
612
+ text = _nonempty(action[key], f"manualActions[{index}].{key}")
613
+ _safe_manual_text(text, f"manualActions[{index}].{key}")
614
+ _unique(manual_ids, "manual action ids")
615
+ for index, raw in enumerate(tasks):
616
+ task = _require_object(raw, f"tasks[{index}]")
617
+ _strict_keys(
618
+ task,
619
+ {"id", "title", "componentRefs", "contractRefs", "requirementIds", "dependsOn", "acceptanceCriterionIds"},
620
+ {"ownedFiles"},
621
+ f"tasks[{index}]",
622
+ )
623
+ task_ids.append(_nonempty(task["id"], f"tasks[{index}].id"))
624
+ _nonempty(task["title"], f"tasks[{index}].title")
625
+ if "ownedFiles" in task:
626
+ _owned_files(task["ownedFiles"], f"tasks[{index}].ownedFiles")
627
+ for list_key in ("requirementIds", "dependsOn", "acceptanceCriterionIds"):
628
+ entries = [_nonempty(item, f"tasks[{index}].{list_key}") for item in _list(task[list_key], f"tasks[{index}].{list_key}")]
629
+ _unique(entries, f"tasks[{index}].{list_key}")
630
+ for ref_key, expected_kind, local_ids in (("componentRefs", "component", components), ("contractRefs", "contract", contracts)):
631
+ identities: list[str] = []
632
+ for ref_index, raw_ref in enumerate(_list(task[ref_key], f"tasks[{index}].{ref_key}")):
633
+ ref = _require_object(raw_ref, f"tasks[{index}].{ref_key}[{ref_index}]")
634
+ _strict_keys(ref, {"specId", "kind", "id"}, set(), f"tasks[{index}].{ref_key}[{ref_index}]")
635
+ ref_spec = _nonempty(ref["specId"], "qualified ref specId")
636
+ ref_id = _nonempty(ref["id"], "qualified ref id")
637
+ if ref["kind"] != expected_kind:
638
+ _fail(f"tasks[{index}].{ref_key}[{ref_index}] must be a {expected_kind} reference")
639
+ if ref_spec == spec_id and ref_id not in local_ids:
640
+ _fail(f"tasks[{index}].{ref_key}[{ref_index}] references unknown local {expected_kind} {ref_id}")
641
+ if ref_spec != spec_id and ref_spec not in dependency_specs:
642
+ _fail(f"tasks[{index}].{ref_key}[{ref_index}] has no declared Spec dependency")
643
+ identities.append(f"{ref_spec}:{expected_kind}:{ref_id}")
644
+ _unique(identities, f"tasks[{index}].{ref_key}")
645
+ _unique(task_ids, "task ids")
646
+ task_id_set = set(task_ids)
647
+ requirement_ids = {item for task in tasks for item in task["requirementIds"]}
648
+ target_owners: dict[str, list[str]] = {}
649
+ for kind, targets in {
650
+ "task": task_id_set,
651
+ "component": components,
652
+ "contract": contracts,
653
+ "requirement": requirement_ids,
654
+ }.items():
655
+ for target in targets:
656
+ target_owners.setdefault(target, []).append(kind)
657
+ ambiguous_targets = {target: kinds for target, kinds in target_owners.items() if len(kinds) > 1}
658
+ if ambiguous_targets:
659
+ rendered = ", ".join(
660
+ f"{target} ({'/'.join(sorted(kinds))})"
661
+ for target, kinds in sorted(ambiguous_targets.items())
662
+ )
663
+ _fail(f"v1 target ids must be globally unique across mapping kinds: {rendered}")
664
+ task_positions = {task_id: index for index, task_id in enumerate(task_ids)}
665
+ for index, task in enumerate(tasks):
666
+ for dependency in task["dependsOn"]:
667
+ if dependency not in task_id_set:
668
+ _fail(f"tasks[{index}].dependsOn references unknown task {dependency}")
669
+ if task_positions[dependency] >= index:
670
+ _fail(f"tasks[{index}].dependsOn must reference an earlier task in the final order")
671
+ for criterion in task["acceptanceCriterionIds"]:
672
+ if criterion not in acceptance_ids:
673
+ _fail(f"tasks[{index}].acceptanceCriterionIds references unknown criterion {criterion}")
674
+
675
+ versions = _require_object(value["returnVersions"], "returnVersions")
676
+ _strict_keys(versions, {"implementationMap", "convergence"}, set(), "returnVersions")
677
+ implementation_versions = _list(versions["implementationMap"], "returnVersions.implementationMap")
678
+ convergence_versions = _list(versions["convergence"], "returnVersions.convergence")
679
+ if not implementation_versions or any(item != IMPLEMENTATION_MAP_CONTRACT for item in implementation_versions):
680
+ _fail("unsupported implementation-map return version")
681
+ if not convergence_versions or any(item != CONVERGENCE_CONTRACT for item in convergence_versions):
682
+ _fail("unsupported convergence return version")
683
+ if value["taskDigest"] != digest_normalized(tasks):
684
+ _fail("taskDigest does not match the final ordered task list")
685
+ if value["requestDigest"] != digest_normalized(_request_projection(value)):
686
+ _fail("requestDigest does not match its normalized self-digest projection")
687
+ if value["specDigest"] != digest_normalized(canonical_spec):
688
+ _fail("specDigest does not match the normalized canonical Spec")
689
+ return value
690
+
691
+
692
+ def _intended_targets(request: dict[str, Any]) -> dict[str, set[str]]:
693
+ return {
694
+ "task": {task["id"] for task in request["tasks"]},
695
+ "component": {item["id"] for item in request["architecture"]["components"]},
696
+ "contract": {item["id"] for item in request["architecture"]["contracts"]},
697
+ "requirement": {item for task in request["tasks"] for item in task["requirementIds"]},
698
+ }
699
+
700
+
701
+ def _resolve_repo_file(workdir: Path, raw: Any, label: str, *, allow_internal: bool = False) -> tuple[str, Path]:
702
+ if allow_internal:
703
+ text = _safe_repo_path(raw, label)
704
+ if not text.startswith(".build-loop/evidence/"):
705
+ _fail(f"{label} must be under .build-loop/evidence/")
706
+ else:
707
+ text = _safe_repo_path(raw, label)
708
+ root = workdir.resolve()
709
+ joined = root / text
710
+ cursor = root
711
+ for segment in text.split("/"):
712
+ cursor = cursor / segment
713
+ if cursor.is_symlink():
714
+ _fail(f"{label} must not traverse a symlink")
715
+ candidate = joined.resolve()
716
+ if candidate == root or root not in candidate.parents:
717
+ _fail(f"{label} escapes the repository")
718
+ if not candidate.is_file():
719
+ _fail(f"{label} must name an existing non-symlink file")
720
+ return text, candidate
721
+
722
+
723
+ def _verify_commit(workdir: Path, commit: str, label: str) -> str:
724
+ if not COMMIT_RE.fullmatch(commit):
725
+ _fail(f"{label} must be a 7-64 character hexadecimal commit")
726
+ result = subprocess.run(["git", "cat-file", "-e", f"{commit}^{{commit}}"], cwd=workdir, capture_output=True, text=True, check=False)
727
+ if result.returncode != 0:
728
+ _fail(f"{label} does not resolve to a repository commit")
729
+ ancestor = subprocess.run(["git", "merge-base", "--is-ancestor", commit, "HEAD"], cwd=workdir, capture_output=True, text=True, check=False)
730
+ if ancestor.returncode != 0:
731
+ _fail(f"{label} is not reachable from the current repository HEAD")
732
+ resolved = subprocess.run(["git", "rev-parse", f"{commit}^{{commit}}"], cwd=workdir, capture_output=True, text=True, check=False)
733
+ if resolved.returncode != 0:
734
+ _fail(f"{label} cannot be canonicalized")
735
+ return resolved.stdout.strip()
736
+
737
+
738
+ def _verify_file_at_commit(workdir: Path, file_ref: str, commits: list[str], label: str) -> None:
739
+ current = (workdir / file_ref).read_bytes()
740
+ for commit in commits:
741
+ result = subprocess.run(["git", "show", f"{commit}:{file_ref}"], cwd=workdir, capture_output=True, check=False)
742
+ if result.returncode == 0 and result.stdout == current:
743
+ return
744
+ _fail(f"{label} is not byte-identical in any cited commit")
745
+
746
+
747
+ def build_implementation_map(
748
+ request: dict[str, Any],
749
+ draft_input: Any,
750
+ *,
751
+ workdir: Path,
752
+ producer_version: str,
753
+ producer_commit: str | None,
754
+ created_at: str,
755
+ verified_evidence_ids: set[str] | None = None,
756
+ ) -> dict[str, Any]:
757
+ draft = _require_object(draft_input, "evidence draft")
758
+ _strict_keys(draft, {"mappings", "evidence", "deviations"}, set(), "evidence draft")
759
+ created = _timestamp(created_at, "createdAt")
760
+ if created < _timestamp(request["createdAt"], "request.createdAt"):
761
+ _fail("implementation map cannot predate the build request")
762
+ _nonempty(producer_version, "producer version")
763
+ trusted_evidence = verified_evidence_ids or set()
764
+ if producer_commit:
765
+ producer_root = Path(__file__).resolve().parent.parent
766
+ producer_commit = _verify_commit(producer_root, producer_commit, "producer commit")
767
+ _verify_file_at_commit(
768
+ producer_root,
769
+ "scripts/groundwork_exchange.py",
770
+ [producer_commit],
771
+ "producer adapter",
772
+ )
773
+
774
+ intended = _intended_targets(request)
775
+ owners: dict[str, list[str]] = {}
776
+ for kind, targets in intended.items():
777
+ for target in targets:
778
+ owners.setdefault(target, []).append(kind)
779
+ ambiguous = {target: kinds for target, kinds in owners.items() if len(kinds) > 1}
780
+ if ambiguous:
781
+ rendered = ", ".join(f"{target} ({'/'.join(sorted(kinds))})" for target, kinds in sorted(ambiguous.items()))
782
+ _fail(f"v1 target ids must be globally unique across mapping kinds: {rendered}")
783
+
784
+ evidence_output: list[dict[str, Any]] = []
785
+ evidence_by_id: dict[str, dict[str, Any]] = {}
786
+ for index, raw in enumerate(_list(draft["evidence"], "evidence")):
787
+ item = _require_object(raw, f"evidence[{index}]")
788
+ _strict_keys(item, {"id", "kind", "command", "outcome", "recordedAt"}, {"summary", "artifactPath"}, f"evidence[{index}]")
789
+ evidence_id = _nonempty(item["id"], f"evidence[{index}].id")
790
+ if evidence_id in evidence_by_id:
791
+ _fail(f"duplicate evidence id {evidence_id}")
792
+ _enum(item["kind"], EVIDENCE_KINDS, f"evidence[{index}].kind")
793
+ _enum(item["outcome"], EVIDENCE_OUTCOMES, f"evidence[{index}].outcome")
794
+ recorded = _timestamp(item["recordedAt"], f"evidence[{index}].recordedAt")
795
+ if recorded < _timestamp(request["createdAt"], "request.createdAt") or recorded > created:
796
+ _fail(f"evidence[{index}].recordedAt falls outside the request/map window")
797
+ command = _nonempty(item["command"], f"evidence[{index}].command")
798
+ _safe_manual_text(command, f"evidence[{index}].command")
799
+ output = {key: item[key] for key in ("id", "kind", "command", "outcome")}
800
+ if "summary" in item:
801
+ if not isinstance(item["summary"], str):
802
+ _fail(f"evidence[{index}].summary must be a string")
803
+ _safe_manual_text(item["summary"], f"evidence[{index}].summary")
804
+ output["summary"] = item["summary"]
805
+ if item["outcome"] == "passed" and "artifactPath" not in item:
806
+ _fail(f"evidence[{index}] passed evidence requires artifactPath")
807
+ if item["outcome"] == "passed" and evidence_id not in trusted_evidence:
808
+ _fail(f"evidence[{index}] passed outcome requires --verified-evidence-id from Review-B")
809
+ if "artifactPath" in item:
810
+ _, artifact = _resolve_repo_file(workdir, item["artifactPath"], f"evidence[{index}].artifactPath", allow_internal=True)
811
+ output["artifactDigest"] = digest_file(artifact)
812
+ output["recordedAt"] = item["recordedAt"]
813
+ evidence_output.append(output)
814
+ evidence_by_id[evidence_id] = output
815
+ missing_trusted = sorted(trusted_evidence - evidence_by_id.keys())
816
+ if missing_trusted:
817
+ _fail(f"verified evidence ids are missing from the draft: {', '.join(missing_trusted)}")
818
+
819
+ deviations_output: list[dict[str, Any]] = []
820
+ deviations_by_id: dict[str, dict[str, Any]] = {}
821
+ all_targets = set().union(*intended.values())
822
+ for index, raw in enumerate(_list(draft["deviations"], "deviations")):
823
+ item = _require_object(raw, f"deviations[{index}]")
824
+ _strict_keys(item, {"id", "targetId", "summary", "impact"}, set(), f"deviations[{index}]")
825
+ deviation_id = _nonempty(item["id"], f"deviations[{index}].id")
826
+ if deviation_id in deviations_by_id:
827
+ _fail(f"duplicate deviation id {deviation_id}")
828
+ target = _nonempty(item["targetId"], f"deviations[{index}].targetId")
829
+ if target not in all_targets:
830
+ _fail(f"deviations[{index}] targets unknown intent or has unsupported impact")
831
+ _enum(item["impact"], IMPACTS, f"deviations[{index}].impact")
832
+ _safe_manual_text(_nonempty(item["summary"], f"deviations[{index}].summary"), f"deviations[{index}].summary")
833
+ deviations_output.append(dict(item))
834
+ deviations_by_id[deviation_id] = item
835
+
836
+ mappings_output: list[dict[str, Any]] = []
837
+ mapping_ids: set[str] = set()
838
+ for index, raw in enumerate(_list(draft["mappings"], "mappings")):
839
+ item = _require_object(raw, f"mappings[{index}]")
840
+ _strict_keys(item, {"id", "kind", "targetId", "status", "fileRefs", "symbolRefs", "commitRefs", "testEvidenceIds", "runtimeEvidenceIds"}, {"deviationIds"}, f"mappings[{index}]")
841
+ mapping_id = _nonempty(item["id"], f"mappings[{index}].id")
842
+ if mapping_id in mapping_ids:
843
+ _fail(f"duplicate mapping id {mapping_id}")
844
+ mapping_ids.add(mapping_id)
845
+ kind = item["kind"]
846
+ target = _nonempty(item["targetId"], f"mappings[{index}].targetId")
847
+ status = item["status"]
848
+ kind = _enum(kind, KINDS, f"mappings[{index}].kind")
849
+ status = _enum(status, STATUSES, f"mappings[{index}].status")
850
+ if target not in intended[kind]:
851
+ _fail(f"mappings[{index}] has unsupported kind/status or unknown target")
852
+ file_refs = [_safe_repo_path(value, f"mappings[{index}].fileRefs") for value in _list(item["fileRefs"], f"mappings[{index}].fileRefs")]
853
+ _unique(file_refs, f"mappings[{index}].fileRefs")
854
+ for file_ref in file_refs:
855
+ _resolve_repo_file(workdir, file_ref, f"mappings[{index}].fileRefs")
856
+ symbols = [_nonempty(value, f"mappings[{index}].symbolRefs") for value in _list(item["symbolRefs"], f"mappings[{index}].symbolRefs")]
857
+ for symbol_index, symbol in enumerate(symbols):
858
+ _safe_manual_text(symbol, f"mappings[{index}].symbolRefs[{symbol_index}]")
859
+ commits = [_nonempty(value, f"mappings[{index}].commitRefs") for value in _list(item["commitRefs"], f"mappings[{index}].commitRefs")]
860
+ tests = [_nonempty(value, f"mappings[{index}].testEvidenceIds") for value in _list(item["testEvidenceIds"], f"mappings[{index}].testEvidenceIds")]
861
+ runtimes = [_nonempty(value, f"mappings[{index}].runtimeEvidenceIds") for value in _list(item["runtimeEvidenceIds"], f"mappings[{index}].runtimeEvidenceIds")]
862
+ deviations = [_nonempty(value, f"mappings[{index}].deviationIds") for value in _list(item.get("deviationIds", []), f"mappings[{index}].deviationIds")]
863
+ for values, label in ((symbols, "symbolRefs"), (commits, "commitRefs"), (tests, "testEvidenceIds"), (runtimes, "runtimeEvidenceIds"), (deviations, "deviationIds")):
864
+ _unique(values, f"mappings[{index}].{label}")
865
+ commits = [_verify_commit(workdir, commit, f"mappings[{index}].commitRefs") for commit in commits]
866
+ referenced: list[dict[str, Any]] = []
867
+ for evidence_id in tests:
868
+ evidence = evidence_by_id.get(evidence_id)
869
+ if not evidence or evidence["kind"] != "test":
870
+ _fail(f"mappings[{index}] references missing or non-test evidence {evidence_id}")
871
+ referenced.append(evidence)
872
+ for evidence_id in runtimes:
873
+ evidence = evidence_by_id.get(evidence_id)
874
+ if not evidence or evidence["kind"] != "runtime":
875
+ _fail(f"mappings[{index}] references missing or non-runtime evidence {evidence_id}")
876
+ referenced.append(evidence)
877
+ for deviation_id in deviations:
878
+ deviation = deviations_by_id.get(deviation_id)
879
+ if not deviation or deviation["targetId"] != target:
880
+ _fail(f"mappings[{index}] references an invalid deviation {deviation_id}")
881
+ if status in {"implemented", "verified"} and (not file_refs or not commits):
882
+ _fail(f"mappings[{index}] {status} requires both repository fileRefs and commitRefs")
883
+ for file_ref in file_refs:
884
+ _verify_file_at_commit(workdir, file_ref, commits, f"mappings[{index}].fileRefs {file_ref}")
885
+ if status == "verified" and (
886
+ not referenced or any(evidence["outcome"] != "passed" for evidence in referenced)
887
+ ):
888
+ _fail(f"mappings[{index}] verified requires all referenced test and runtime evidence to pass")
889
+ if status == "implemented" and any(evidence["outcome"] == "passed" for evidence in referenced):
890
+ _fail(f"mappings[{index}] implemented contradicts passing verification evidence")
891
+ if status == "not-started" and (file_refs or symbols or commits or referenced or deviations):
892
+ _fail(f"mappings[{index}] not-started cannot carry implementation evidence or deviations")
893
+ if status == "diverged" and not deviations:
894
+ _fail(f"mappings[{index}] diverged requires a declared deviation")
895
+ output = dict(item)
896
+ output["commitRefs"] = commits
897
+ output["deviationIds"] = deviations
898
+ mappings_output.append(output)
899
+
900
+ producer: dict[str, Any] = {"name": "build-loop", "version": producer_version}
901
+ if producer_commit:
902
+ producer["commit"] = producer_commit
903
+ result: dict[str, Any] = {
904
+ "contract": IMPLEMENTATION_MAP_CONTRACT,
905
+ "runId": request["runId"],
906
+ "buildRequestDigest": request["requestDigest"],
907
+ "specDigest": request["specDigest"],
908
+ "taskDigest": request["taskDigest"],
909
+ "producer": producer,
910
+ "mappings": mappings_output,
911
+ "evidence": evidence_output,
912
+ "deviations": deviations_output,
913
+ "implementationMapDigest": "sha256:" + "0" * 64,
914
+ "createdAt": created_at,
915
+ }
916
+ result["implementationMapDigest"] = digest_normalized({key: value for key, value in result.items() if key != "implementationMapDigest"})
917
+ return result
918
+
919
+
920
+ def _atomic_write_json(path: Path, value: Any) -> None:
921
+ path.parent.mkdir(parents=True, exist_ok=True)
922
+ payload = json.dumps(value, ensure_ascii=False, indent=2) + "\n"
923
+ fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
924
+ try:
925
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
926
+ handle.write(payload)
927
+ handle.flush()
928
+ os.fsync(handle.fileno())
929
+ os.replace(tmp_name, path)
930
+ finally:
931
+ if os.path.exists(tmp_name):
932
+ os.unlink(tmp_name)
933
+
934
+
935
+ def _resolve_producer_version(value: str) -> str:
936
+ if value != "auto":
937
+ return _nonempty(value, "producer version")
938
+ root = Path(__file__).resolve().parent.parent
939
+ for relative in (Path(".claude-plugin/plugin.json"), Path(".codex-plugin/plugin.json"), Path("package.json")):
940
+ candidate = root / relative
941
+ if candidate.is_file():
942
+ parsed = _require_object(_load_json(candidate), str(relative))
943
+ version = parsed.get("version")
944
+ if isinstance(version, str) and version:
945
+ return version
946
+ _fail("producer version auto-discovery found no versioned plugin manifest")
947
+
948
+
949
+ def _parser() -> argparse.ArgumentParser:
950
+ parser = argparse.ArgumentParser(description=__doc__)
951
+ sub = parser.add_subparsers(dest="command", required=True)
952
+ validate = sub.add_parser("validate-request", help="validate a Groundwork BuildRequest v1")
953
+ validate.add_argument("--request", required=True)
954
+ validate.add_argument("--spec", required=True)
955
+ validate.add_argument("--output", help="atomically write the validated request summary")
956
+ emit = sub.add_parser("emit-map", help="verify evidence and emit ImplementationMap v1")
957
+ emit.add_argument("--request", required=True)
958
+ emit.add_argument("--spec", required=True)
959
+ emit.add_argument("--evidence", required=True)
960
+ emit.add_argument("--workdir", default=".")
961
+ emit.add_argument("--output", required=True)
962
+ emit.add_argument("--producer-version", required=True)
963
+ emit.add_argument("--producer-commit")
964
+ emit.add_argument("--created-at", required=True)
965
+ emit.add_argument(
966
+ "--verified-evidence-id",
967
+ action="append",
968
+ default=[],
969
+ help="evidence id whose command Review-B actually executed successfully; repeat per passed receipt",
970
+ )
971
+ return parser
972
+
973
+
974
+ def main(argv: list[str] | None = None) -> int:
975
+ args = _parser().parse_args(argv)
976
+ try:
977
+ request = validate_request(_load_json(Path(args.request)), _load_json(Path(args.spec)))
978
+ if args.command == "validate-request":
979
+ result = {
980
+ "ok": True,
981
+ "contract": request["contract"],
982
+ "runId": request["runId"],
983
+ "requestDigest": request["requestDigest"],
984
+ "taskDigest": request["taskDigest"],
985
+ "taskIds": [task["id"] for task in request["tasks"]],
986
+ }
987
+ if args.output:
988
+ _atomic_write_json(Path(args.output), result)
989
+ else:
990
+ result = build_implementation_map(
991
+ request,
992
+ _load_json(Path(args.evidence)),
993
+ workdir=Path(args.workdir).resolve(),
994
+ producer_version=_resolve_producer_version(args.producer_version),
995
+ producer_commit=args.producer_commit,
996
+ created_at=args.created_at,
997
+ verified_evidence_ids=set(args.verified_evidence_id),
998
+ )
999
+ _atomic_write_json(Path(args.output), result)
1000
+ print(json.dumps(result, ensure_ascii=False, sort_keys=True))
1001
+ return 0
1002
+ except ExchangeError as exc:
1003
+ print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False), file=sys.stderr)
1004
+ return 2
1005
+ except (OSError, subprocess.SubprocessError) as exc:
1006
+ message = getattr(exc, "strerror", None) or exc.__class__.__name__
1007
+ print(json.dumps({"ok": False, "error": f"operational failure: {message}"}, ensure_ascii=False), file=sys.stderr)
1008
+ return 2
1009
+
1010
+
1011
+ if __name__ == "__main__":
1012
+ raise SystemExit(main())