solo-builder-core 0.4.0__py3-none-any.whl

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 (285) hide show
  1. solo_builder/__init__.py +5 -0
  2. solo_builder/_canonical.py +59 -0
  3. solo_builder/_signing.py +88 -0
  4. solo_builder/acceptance_report.py +445 -0
  5. solo_builder/action_scanner.py +2229 -0
  6. solo_builder/action_tokens.py +309 -0
  7. solo_builder/actor_attestation.py +775 -0
  8. solo_builder/adapters/__init__.py +40 -0
  9. solo_builder/adapters/_common.py +145 -0
  10. solo_builder/adapters/anthropic.py +91 -0
  11. solo_builder/adapters/openai.py +85 -0
  12. solo_builder/adapters/stripe.py +96 -0
  13. solo_builder/adversarial_demo.py +449 -0
  14. solo_builder/agent.py +1913 -0
  15. solo_builder/agent_registry.py +278 -0
  16. solo_builder/ai.py +353 -0
  17. solo_builder/analyzers/go/go.mod +3 -0
  18. solo_builder/analyzers/go/main.go +269 -0
  19. solo_builder/analyzers/rust/.gitignore +1 -0
  20. solo_builder/analyzers/rust/Cargo.lock +120 -0
  21. solo_builder/analyzers/rust/Cargo.toml +18 -0
  22. solo_builder/analyzers/rust/src/main.rs +428 -0
  23. solo_builder/anomaly_engine.py +389 -0
  24. solo_builder/approvals.py +231 -0
  25. solo_builder/audit.py +164 -0
  26. solo_builder/authority_redemption.py +334 -0
  27. solo_builder/authority_resolution_log.py +110 -0
  28. solo_builder/autonomy.py +121 -0
  29. solo_builder/autonomy_envelope.py +1240 -0
  30. solo_builder/bootstrap.py +141 -0
  31. solo_builder/bridge_server.py +932 -0
  32. solo_builder/budget.py +268 -0
  33. solo_builder/build_event.py +641 -0
  34. solo_builder/build_genome.py +468 -0
  35. solo_builder/buildpack.py +203 -0
  36. solo_builder/bundle.py +388 -0
  37. solo_builder/bundle_verifier.py +566 -0
  38. solo_builder/capabilities.py +322 -0
  39. solo_builder/claim_discipline.py +193 -0
  40. solo_builder/cli.py +208 -0
  41. solo_builder/codebase_index.py +405 -0
  42. solo_builder/commands/__init__.py +1 -0
  43. solo_builder/commands/_shared.py +63 -0
  44. solo_builder/commands/account.py +154 -0
  45. solo_builder/commands/agent.py +144 -0
  46. solo_builder/commands/ai.py +159 -0
  47. solo_builder/commands/audit.py +50 -0
  48. solo_builder/commands/bridge.py +272 -0
  49. solo_builder/commands/compliance.py +765 -0
  50. solo_builder/commands/daemon.py +654 -0
  51. solo_builder/commands/demo.py +359 -0
  52. solo_builder/commands/distillation.py +338 -0
  53. solo_builder/commands/doctor.py +232 -0
  54. solo_builder/commands/eval.py +271 -0
  55. solo_builder/commands/evidence.py +350 -0
  56. solo_builder/commands/export.py +378 -0
  57. solo_builder/commands/forecast.py +107 -0
  58. solo_builder/commands/gate.py +149 -0
  59. solo_builder/commands/govern.py +1679 -0
  60. solo_builder/commands/governed_email.py +128 -0
  61. solo_builder/commands/init.py +67 -0
  62. solo_builder/commands/kernel.py +455 -0
  63. solo_builder/commands/linkedin.py +1994 -0
  64. solo_builder/commands/mcp_serve.py +129 -0
  65. solo_builder/commands/memory.py +144 -0
  66. solo_builder/commands/metrics.py +28 -0
  67. solo_builder/commands/mission_control.py +338 -0
  68. solo_builder/commands/onboard.py +530 -0
  69. solo_builder/commands/persona.py +64 -0
  70. solo_builder/commands/prompt.py +1040 -0
  71. solo_builder/commands/proof.py +791 -0
  72. solo_builder/commands/rcm.py +300 -0
  73. solo_builder/commands/registry.py +226 -0
  74. solo_builder/commands/reliance.py +385 -0
  75. solo_builder/commands/replay.py +103 -0
  76. solo_builder/commands/report.py +295 -0
  77. solo_builder/commands/resume.py +41 -0
  78. solo_builder/commands/serve.py +327 -0
  79. solo_builder/commands/ship.py +6361 -0
  80. solo_builder/commands/status.py +153 -0
  81. solo_builder/commands/strix_wire.py +1508 -0
  82. solo_builder/commands/sync.py +41 -0
  83. solo_builder/commands/ticket.py +60 -0
  84. solo_builder/commands/tool.py +162 -0
  85. solo_builder/commands/watch.py +52 -0
  86. solo_builder/compare.py +489 -0
  87. solo_builder/compliance_profiles.py +1138 -0
  88. solo_builder/compliance_schemas/ai_act_annex_iv_v1.schema.json +102 -0
  89. solo_builder/compliance_schemas/insurance_risk_v1.schema.json +91 -0
  90. solo_builder/compliance_schemas/iso_42001_clause_8_v1.schema.json +96 -0
  91. solo_builder/config.py +510 -0
  92. solo_builder/connected.py +135 -0
  93. solo_builder/credential_class_matrix.snapshot.json +24 -0
  94. solo_builder/csv_export.py +227 -0
  95. solo_builder/custody.py +545 -0
  96. solo_builder/daemon_proc.py +221 -0
  97. solo_builder/demo.py +240 -0
  98. solo_builder/deployers/__init__.py +67 -0
  99. solo_builder/deployers/_process.py +38 -0
  100. solo_builder/deployers/base.py +111 -0
  101. solo_builder/deployers/cloudflare.py +65 -0
  102. solo_builder/deployers/expo.py +43 -0
  103. solo_builder/deployers/manual.py +16 -0
  104. solo_builder/deployers/pages.py +173 -0
  105. solo_builder/deployers/railway.py +66 -0
  106. solo_builder/deployers/vercel.py +120 -0
  107. solo_builder/device_attestation.py +771 -0
  108. solo_builder/device_class_matrix.snapshot.json +16 -0
  109. solo_builder/distillation_boundary.py +397 -0
  110. solo_builder/distillation_copilot.py +393 -0
  111. solo_builder/distillation_demo.py +303 -0
  112. solo_builder/distillation_governance.py +611 -0
  113. solo_builder/entitlements.py +778 -0
  114. solo_builder/evals.py +655 -0
  115. solo_builder/events.py +190 -0
  116. solo_builder/evidence_store.py +338 -0
  117. solo_builder/evidence_stream.py +318 -0
  118. solo_builder/evolve.py +170 -0
  119. solo_builder/executor.py +670 -0
  120. solo_builder/executor_key_enrollment.py +430 -0
  121. solo_builder/external_verification_receipt.py +997 -0
  122. solo_builder/fleet.py +513 -0
  123. solo_builder/functional_gate.py +188 -0
  124. solo_builder/git_ops.py +212 -0
  125. solo_builder/github_client.py +154 -0
  126. solo_builder/governance_coverage.py +289 -0
  127. solo_builder/governance_trend.py +230 -0
  128. solo_builder/governed_email_revalidation.py +574 -0
  129. solo_builder/governed_outbound/__init__.py +32 -0
  130. solo_builder/governed_outbound/api.py +217 -0
  131. solo_builder/governed_outbound/models.py +225 -0
  132. solo_builder/governed_outbound/outbox_worker.py +136 -0
  133. solo_builder/governed_outbound/policy.py +52 -0
  134. solo_builder/governed_outbound/postgres_suppression.py +740 -0
  135. solo_builder/governed_outbound/release.py +373 -0
  136. solo_builder/governed_outbound/release_recheck.py +66 -0
  137. solo_builder/governed_outbound/repository.py +149 -0
  138. solo_builder/governed_outbound/service.py +299 -0
  139. solo_builder/governed_outbound/suppression.py +535 -0
  140. solo_builder/governed_outbound/webhook_sync.py +184 -0
  141. solo_builder/http.py +39 -0
  142. solo_builder/interactive_gate.py +55 -0
  143. solo_builder/jason_handoff.py +851 -0
  144. solo_builder/jason_handoff_execute.py +1174 -0
  145. solo_builder/jason_handoff_ingest.py +235 -0
  146. solo_builder/jason_plan.py +174 -0
  147. solo_builder/jwks.py +337 -0
  148. solo_builder/kernel_client.py +436 -0
  149. solo_builder/kernel_decision.py +90 -0
  150. solo_builder/linkedin_accounts.py +132 -0
  151. solo_builder/linkedin_authorization.py +397 -0
  152. solo_builder/linkedin_capabilities.py +669 -0
  153. solo_builder/linkedin_console.py +645 -0
  154. solo_builder/linkedin_content.py +505 -0
  155. solo_builder/linkedin_drafting.py +523 -0
  156. solo_builder/linkedin_email.py +331 -0
  157. solo_builder/linkedin_enrich.py +251 -0
  158. solo_builder/linkedin_evidence.py +231 -0
  159. solo_builder/linkedin_gate.py +189 -0
  160. solo_builder/linkedin_history.py +206 -0
  161. solo_builder/linkedin_ingest.py +533 -0
  162. solo_builder/linkedin_kernel_bridge.py +766 -0
  163. solo_builder/linkedin_mc1.py +1006 -0
  164. solo_builder/linkedin_mcp.py +126 -0
  165. solo_builder/linkedin_memory.py +138 -0
  166. solo_builder/linkedin_pass.py +922 -0
  167. solo_builder/linkedin_pipeline.py +254 -0
  168. solo_builder/linkedin_qualify.py +404 -0
  169. solo_builder/linkedin_queue.py +133 -0
  170. solo_builder/linkedin_scoring.py +141 -0
  171. solo_builder/linkedin_signals.py +465 -0
  172. solo_builder/llm.py +324 -0
  173. solo_builder/marketplace.py +334 -0
  174. solo_builder/mc_1.py +1184 -0
  175. solo_builder/mcp_adapter.py +484 -0
  176. solo_builder/mcp_auth.py +224 -0
  177. solo_builder/mcp_governance.py +544 -0
  178. solo_builder/mcp_http.py +312 -0
  179. solo_builder/mcp_proof.py +1369 -0
  180. solo_builder/memory.py +258 -0
  181. solo_builder/metrics.py +152 -0
  182. solo_builder/mission_control.py +1315 -0
  183. solo_builder/mission_control_svg.py +289 -0
  184. solo_builder/network_signatures.py +326 -0
  185. solo_builder/notion_client.py +67 -0
  186. solo_builder/objective_graph.py +623 -0
  187. solo_builder/outcome_swarm.py +170 -0
  188. solo_builder/output.py +73 -0
  189. solo_builder/parallel.py +278 -0
  190. solo_builder/pattern_catalog.py +1996 -0
  191. solo_builder/personas.py +229 -0
  192. solo_builder/plan.py +325 -0
  193. solo_builder/planner.py +141 -0
  194. solo_builder/policy_export.py +294 -0
  195. solo_builder/policy_lint.py +295 -0
  196. solo_builder/policy_replay.py +238 -0
  197. solo_builder/process_launch_census.py +859 -0
  198. solo_builder/production_authority.py +404 -0
  199. solo_builder/production_profile.py +162 -0
  200. solo_builder/prompts.py +1151 -0
  201. solo_builder/proof.py +198 -0
  202. solo_builder/proof_browser.py +104 -0
  203. solo_builder/proof_receipt.py +601 -0
  204. solo_builder/provenance.py +502 -0
  205. solo_builder/public_verify.py +312 -0
  206. solo_builder/py.typed +0 -0
  207. solo_builder/race.py +159 -0
  208. solo_builder/rcm_csv_adapter.py +1108 -0
  209. solo_builder/rcm_signed_evidence.py +370 -0
  210. solo_builder/rcm_solution_pack.py +345 -0
  211. solo_builder/registry.py +549 -0
  212. solo_builder/release_authority.py +528 -0
  213. solo_builder/release_evidence.py +659 -0
  214. solo_builder/release_evidence_log.py +269 -0
  215. solo_builder/release_identity.py +368 -0
  216. solo_builder/release_population.py +546 -0
  217. solo_builder/release_proof_bundle.py +1290 -0
  218. solo_builder/reliance.py +462 -0
  219. solo_builder/replay.py +418 -0
  220. solo_builder/right_to_contact.py +532 -0
  221. solo_builder/right_to_contact_api.py +206 -0
  222. solo_builder/roots/strix-root-v1.pem +3 -0
  223. solo_builder/sandbox_cgroup.py +316 -0
  224. solo_builder/sandbox_ns.py +474 -0
  225. solo_builder/sarif_policy.py +207 -0
  226. solo_builder/scaffold.py +38 -0
  227. solo_builder/session_audit_demo.py +839 -0
  228. solo_builder/ship_attestation.py +602 -0
  229. solo_builder/ship_attestation_emit.py +476 -0
  230. solo_builder/siem_export.py +274 -0
  231. solo_builder/slsa_export.py +237 -0
  232. solo_builder/slug.py +27 -0
  233. solo_builder/solo_execution_receipt.py +554 -0
  234. solo_builder/sonarqube_export.py +261 -0
  235. solo_builder/state.py +121 -0
  236. solo_builder/strix_demo.py +892 -0
  237. solo_builder/strix_domains.py +72 -0
  238. solo_builder/strix_personal/__init__.py +92 -0
  239. solo_builder/strix_personal/lifecycle.py +544 -0
  240. solo_builder/strix_personal/patcher.py +394 -0
  241. solo_builder/strix_personal/signals.py +571 -0
  242. solo_builder/strix_redemption.py +599 -0
  243. solo_builder/strix_wire_analyzer_common.py +97 -0
  244. solo_builder/strix_wire_broker.py +280 -0
  245. solo_builder/strix_wire_candidate.py +410 -0
  246. solo_builder/strix_wire_compile.py +495 -0
  247. solo_builder/strix_wire_dryrun.py +558 -0
  248. solo_builder/strix_wire_go_adapter.py +104 -0
  249. solo_builder/strix_wire_hosted.py +364 -0
  250. solo_builder/strix_wire_langdetect.py +402 -0
  251. solo_builder/strix_wire_local.py +629 -0
  252. solo_builder/strix_wire_local_attestation.py +501 -0
  253. solo_builder/strix_wire_local_chain.py +416 -0
  254. solo_builder/strix_wire_local_dag.py +539 -0
  255. solo_builder/strix_wire_local_key.py +291 -0
  256. solo_builder/strix_wire_local_receipt.py +1055 -0
  257. solo_builder/strix_wire_local_reliance.py +1464 -0
  258. solo_builder/strix_wire_outcome.py +346 -0
  259. solo_builder/strix_wire_patch.py +272 -0
  260. solo_builder/strix_wire_rust_adapter.py +115 -0
  261. solo_builder/suppression_quality.py +139 -0
  262. solo_builder/swarm.py +315 -0
  263. solo_builder/sync.py +123 -0
  264. solo_builder/three_way_identity.py +223 -0
  265. solo_builder/ticket.py +295 -0
  266. solo_builder/ticket_integrity.py +354 -0
  267. solo_builder/tool_policy.py +302 -0
  268. solo_builder/tools.py +747 -0
  269. solo_builder/transaction_proof.py +1234 -0
  270. solo_builder/trust.py +266 -0
  271. solo_builder/trust_mark.py +974 -0
  272. solo_builder/upstream_requester_provenance.py +596 -0
  273. solo_builder/verification_ladder.py +436 -0
  274. solo_builder/watch.py +156 -0
  275. solo_builder/web.py +275 -0
  276. solo_builder/webhook.py +478 -0
  277. solo_builder/worklog.py +122 -0
  278. solo_builder/wrap_patterns.py +231 -0
  279. solo_builder_core-0.4.0.dist-info/METADATA +297 -0
  280. solo_builder_core-0.4.0.dist-info/RECORD +285 -0
  281. solo_builder_core-0.4.0.dist-info/WHEEL +5 -0
  282. solo_builder_core-0.4.0.dist-info/entry_points.txt +2 -0
  283. solo_builder_core-0.4.0.dist-info/licenses/LICENSE +21 -0
  284. solo_builder_core-0.4.0.dist-info/licenses/NOTICE.md +39 -0
  285. solo_builder_core-0.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,5 @@
1
+ """Solo Builder: the Strix Labs Shipping Factory."""
2
+
3
+ __version__ = "0.4.0"
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,59 @@
1
+ """Shared canonical-byte primitives — the single source of truth.
2
+
3
+ Every hashed object in the Strix stack — ``record_hash`` (ADR-006),
4
+ ``signed_envelope`` payload hashes (ADR-005), Kafka wire bytes
5
+ (ADR-007), CSV-export metadata (ADR-008), ``plan_hash`` (ADR-012),
6
+ webhook signature payloads (ADR-013), AA-1 attestation payload bytes
7
+ (ADR-015) — reaches through this module. One canonicalization
8
+ contract; zero parallel serialization shapes.
9
+
10
+ The byte contract is locked by ADR-005 §4:
11
+
12
+ - JSON serialization.
13
+ - Object keys sorted lexicographically.
14
+ - No whitespace (separators ``,`` and ``:``).
15
+ - ``ensure_ascii=False`` so Unicode survives byte-for-byte.
16
+ - ``allow_nan=False`` — NaN and Infinity are forbidden because they
17
+ break determinism across implementations.
18
+
19
+ Implementations in other languages (TypeScript ``@strixgov/sdk``)
20
+ MUST produce byte-identical output for the same logical input.
21
+ Cross-SDK byte-equivalence is asserted in every module's test suite
22
+ that touches hashing, so divergence is caught immediately if it ever
23
+ re-introduces itself.
24
+
25
+ For module-level naming compatibility, ``evidence_store`` re-exports
26
+ ``hash_canonical`` as ``hash_payload`` so callers that import
27
+ ``from .evidence_store import hash_payload`` continue to work
28
+ unchanged.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import hashlib
34
+ import json
35
+ from typing import Any
36
+
37
+
38
+ def canonicalize(obj: Any) -> bytes:
39
+ """Deterministic JSON bytes used for hashing and signing.
40
+
41
+ The single byte-contract chokepoint for the entire stack.
42
+ """
43
+
44
+ return json.dumps(
45
+ obj,
46
+ sort_keys=True,
47
+ separators=(",", ":"),
48
+ ensure_ascii=False,
49
+ allow_nan=False,
50
+ ).encode("utf-8")
51
+
52
+
53
+ def hash_canonical(obj: Any) -> str:
54
+ """Lowercase SHA-256 hex digest of :func:`canonicalize` output."""
55
+
56
+ return hashlib.sha256(canonicalize(obj)).hexdigest()
57
+
58
+
59
+ __all__ = ["canonicalize", "hash_canonical"]
@@ -0,0 +1,88 @@
1
+ """Shared Ed25519 attestation-signing policy for the local proof artifacts.
2
+
3
+ The provenance chain, proof bundle, replay pack, and LinkedIn decision chain
4
+ all sign with the same ``SOLO_ATTESTATION_PRIVATE_KEY``. They historically each
5
+ inlined the same ``except Exception: return None`` block, which had one correct
6
+ case and one latent gap:
7
+
8
+ * **No key configured** → return ``None`` (best-effort unsigned). Correct: the
9
+ hash chain still verifies; the artifact is simply not authorship-bound.
10
+
11
+ * **Key configured but signing/loading FAILS** (bad hex, wrong length, broken
12
+ crypto backend) → the old code *also* returned ``None``, silently producing
13
+ an unsigned artifact indistinguishable from "intentionally unsigned". A
14
+ downstream consumer could mistake a misconfigured-key run for a deliberate
15
+ best-effort one. That is the gap this module closes.
16
+
17
+ Policy (single source of truth):
18
+
19
+ ============================ ================= ========================
20
+ key state strict mode off strict mode on
21
+ ============================ ================= ========================
22
+ absent return None raise SigningRequiredError
23
+ present but invalid/broken raise SigningError raise SigningError
24
+ present and valid return signature return signature
25
+ ============================ ================= ========================
26
+
27
+ Strict mode is opt-in via ``SOLO_REQUIRE_SIGNING`` ∈ {1,true,yes,on}. Trust
28
+ surfaces that must never emit an unsigned artifact set it; local development
29
+ leaves it unset and keeps the best-effort behavior.
30
+
31
+ A configured-but-broken key ALWAYS raises, in both modes — a broken key is a
32
+ fault, never a silent downgrade.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import os
38
+
39
+ _ENV_KEY = "SOLO_ATTESTATION_PRIVATE_KEY"
40
+ _ENV_REQUIRE = "SOLO_REQUIRE_SIGNING"
41
+ _TRUTHY = {"1", "true", "yes", "on"}
42
+
43
+
44
+ class SigningError(RuntimeError):
45
+ """A signing key was configured but signing could not be performed."""
46
+
47
+
48
+ class SigningRequiredError(SigningError):
49
+ """Strict mode is on but no signing key is configured."""
50
+
51
+
52
+ def signing_required() -> bool:
53
+ """True when ``SOLO_REQUIRE_SIGNING`` opts into fail-closed signing."""
54
+ return os.environ.get(_ENV_REQUIRE, "").strip().lower() in _TRUTHY
55
+
56
+
57
+ def sign_attestation(payload: bytes, *, require: bool | None = None) -> str | None:
58
+ """Sign ``payload`` (raw bytes) with the configured Ed25519 key.
59
+
60
+ Returns the hex signature, or ``None`` when no key is configured and strict
61
+ mode is off. Raises :class:`SigningRequiredError` when no key is configured
62
+ and strict mode is on, and :class:`SigningError` when a key IS configured
63
+ but loading or signing fails. Callers pass pre-encoded bytes so the exact
64
+ signed-byte contract (e.g. ``head_hash.encode("ascii")``) stays unchanged.
65
+ """
66
+ strict = signing_required() if require is None else require
67
+ raw = os.environ.get(_ENV_KEY)
68
+
69
+ if not raw:
70
+ if strict:
71
+ raise SigningRequiredError(f"{_ENV_KEY} is not set but {_ENV_REQUIRE} requires a signed artifact")
72
+ return None
73
+
74
+ # A key is configured. From here, any failure is a real fault — never a
75
+ # silent unsigned artifact.
76
+ try:
77
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
78
+ Ed25519PrivateKey,
79
+ )
80
+
81
+ key = Ed25519PrivateKey.from_private_bytes(bytes.fromhex(raw))
82
+ except Exception as exc: # noqa: BLE001 — surface the misconfiguration loudly
83
+ raise SigningError(f"{_ENV_KEY} is set but is not a valid Ed25519 private key (hex): {exc}") from exc
84
+
85
+ try:
86
+ return key.sign(payload).hex()
87
+ except Exception as exc: # noqa: BLE001 — a configured key that cannot sign is a fault
88
+ raise SigningError(f"attestation signing failed: {exc}") from exc
@@ -0,0 +1,445 @@
1
+ """Report-only acceptance-criterion results for an autonomous ship run.
2
+
3
+ A ticket carries 3-7 acceptance tests (``ticket.py``). Until this module,
4
+ nothing in the agent pipeline assessed them: ``review_code`` never saw them,
5
+ and the execution loop runs the repo's own tests, which answer "does the
6
+ generated code run?" and not "was the ticket delivered?". Those are different
7
+ questions, and a run could finish green having answered only the first.
8
+
9
+ This module reports, per criterion, what the review stage ASSERTED — and is
10
+ deliberately incapable of being more than that:
11
+
12
+ * **Report-only.** There is no ``ok`` / ``passed`` / ``all_met`` on
13
+ ``AcceptanceReport``, on purpose: a single green bit is exactly the collapse
14
+ this codebase's ProofState and verification ladder exist to prevent. The
15
+ report never feeds ``AgentResult.ok``, the trust engine, budget, deploy, or
16
+ any exit code; ``tests/test_acceptance_report.py`` pins that.
17
+ * **Model assertion is not evidence.** Every asserted verdict carries
18
+ ``evidence_class="MODEL_ASSERTION"`` and an empty ``deterministic_evidence``.
19
+ ``MET`` here means "the reviewing model said so about code it was shown",
20
+ never "verified". A verdict may only be promoted by tying the criterion to
21
+ deterministic evidence (a named test's recorded result) or to a named human
22
+ decision (``human_decision``); neither exists yet, so both fields are carried
23
+ empty and named rather than omitted. Promotion is NOT built, and the claim
24
+ ceiling is structural: ``claim_ceiling == "MODEL_ASSERTION_ONLY"``.
25
+ * **Absence is recorded, never fabricated.** A criterion the model did not
26
+ address, or addressed in a form this parser does not recognize, is
27
+ ``UNASSESSED`` — not ``UNCLEAR`` (the model said it could not tell) and never
28
+ a quiet pass. A run with no acceptance criteria, or no review output,
29
+ produces no report at all (``None``) rather than an empty green one.
30
+ * **Never raises.** Parsing model prose is a best-effort read; a malformed
31
+ review degrades to ``UNASSESSED`` rows. It must not break a ship.
32
+
33
+ Gate H: ``AcceptanceReport`` is type-disjoint from ``EvidenceRecord`` (no
34
+ ``record_hash`` / ``chain_seq`` / ``prev_hash`` / ``signature``); it is an
35
+ unsigned measurement, never proof.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import dataclasses
41
+ import re
42
+ from collections.abc import Mapping, Sequence
43
+ from dataclasses import dataclass, field
44
+ from typing import Any
45
+
46
+ from ._canonical import hash_canonical
47
+
48
+ SCHEMA = "acceptance_report_v1"
49
+ SOURCE_STAGE = "review_code"
50
+ GATING = "REPORT_ONLY"
51
+ CLAIM_CEILING = "MODEL_ASSERTION_ONLY"
52
+
53
+ ASSERTED_MET = "ASSERTED_MET"
54
+ ASSERTED_NOT_MET = "ASSERTED_NOT_MET"
55
+ ASSERTED_UNCLEAR = "ASSERTED_UNCLEAR"
56
+ UNASSESSED = "UNASSESSED"
57
+ #: A criterion BOUND to a named test whose recorded result decided it.
58
+ EVIDENCE_PASSED = "EVIDENCE_PASSED"
59
+ EVIDENCE_FAILED = "EVIDENCE_FAILED"
60
+ #: A named person decided it, with a reason, on the record.
61
+ HUMAN_MET = "HUMAN_MET"
62
+ HUMAN_NOT_MET = "HUMAN_NOT_MET"
63
+
64
+ STATES: tuple[str, ...] = (
65
+ ASSERTED_MET,
66
+ ASSERTED_NOT_MET,
67
+ ASSERTED_UNCLEAR,
68
+ UNASSESSED,
69
+ EVIDENCE_PASSED,
70
+ EVIDENCE_FAILED,
71
+ HUMAN_MET,
72
+ HUMAN_NOT_MET,
73
+ )
74
+
75
+ EVIDENCE_MODEL_ASSERTION = "MODEL_ASSERTION"
76
+ EVIDENCE_NONE = "NONE"
77
+ EVIDENCE_DETERMINISTIC_TEST = "DETERMINISTIC_TEST"
78
+ EVIDENCE_HUMAN_DECISION = "HUMAN_DECISION"
79
+
80
+ #: Strict order of evidence strength. A stronger class REPLACES a weaker verdict
81
+ #: on the same criterion; a weaker one never overwrites a stronger one.
82
+ EVIDENCE_RANK: dict[str, int] = {
83
+ EVIDENCE_NONE: 0,
84
+ EVIDENCE_MODEL_ASSERTION: 1,
85
+ EVIDENCE_DETERMINISTIC_TEST: 2,
86
+ EVIDENCE_HUMAN_DECISION: 3,
87
+ }
88
+ #: The report-level claim ceiling is the STRONGEST evidence class any row
89
+ #: carries — it names what the report can at most be read as, never more.
90
+ CLAIM_CEILING_BY_EVIDENCE: dict[str, str] = {
91
+ EVIDENCE_NONE: "MODEL_ASSERTION_ONLY",
92
+ EVIDENCE_MODEL_ASSERTION: "MODEL_ASSERTION_ONLY",
93
+ EVIDENCE_DETERMINISTIC_TEST: "DETERMINISTIC_TEST",
94
+ EVIDENCE_HUMAN_DECISION: "HUMAN_DECISION",
95
+ }
96
+
97
+ #: ``[test: tests/test_api.py::test_returns_200]`` or ``[test: test_returns_200]``
98
+ #: inside a criterion's text binds it to a named test. Several bindings are
99
+ #: allowed; ALL must pass for EVIDENCE_PASSED, ANY failure is EVIDENCE_FAILED.
100
+ _BINDING = re.compile(r"\[\s*tests?\s*:\s*([^\]]+?)\s*\]", re.IGNORECASE)
101
+ #: pytest ``-rA`` short-summary lines: ``PASSED tests/test_x.py::test_a``.
102
+ _PYTEST_RESULT = re.compile(r"^(PASSED|FAILED|ERROR|XPASS|XFAIL|SKIPPED)\s+(\S+?)(?:\s+-\s.*)?$")
103
+
104
+ DISCLAIMER = (
105
+ "Unsigned measurement, never proof. Verdicts are the reviewing model's opinion about code it was "
106
+ "shown; none is backed by deterministic evidence or a human decision, and none affects whether the "
107
+ "run succeeded."
108
+ )
109
+
110
+ #: Max characters of the model's stated basis kept per criterion. The report is
111
+ #: surfaced in terminals, audit lines and JSON; an unbounded model paragraph
112
+ #: does not belong in any of them.
113
+ _BASIS_MAX = 300
114
+
115
+ _HEADING = re.compile(r"^\W*acceptance\s+criteri(?:a|on)\s+assessment\b", re.IGNORECASE)
116
+ #: ``1. MET — basis`` / ``2) NOT_MET: basis`` / ``- 3. UNCLEAR - basis``,
117
+ #: tolerating markdown emphasis around the verdict word.
118
+ _ROW = re.compile(
119
+ r"^\s*(?:[-*]\s*)?(?P<n>\d{1,3})\s*[.)]\s*[*_`]*\s*"
120
+ r"(?P<verdict>NOT[\s_-]*MET|UNCLEAR|MET)\b[*_`]*\s*[—–:\-]*\s*(?P<basis>.*)$",
121
+ re.IGNORECASE,
122
+ )
123
+ _NEXT_HEADING = re.compile(r"^\s*#{1,6}\s+\S")
124
+
125
+ _VERDICT_STATE = {"MET": ASSERTED_MET, "NOT_MET": ASSERTED_NOT_MET, "UNCLEAR": ASSERTED_UNCLEAR}
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class CriterionResult:
130
+ index: int
131
+ text: str
132
+ state: str
133
+ basis: str = ""
134
+ evidence_class: str = EVIDENCE_NONE
135
+ #: Deterministic evidence ties (e.g. a named test's recorded result). Always
136
+ #: empty in v1 — carried so the absence is a stated fact, not an omission.
137
+ deterministic_evidence: tuple[str, ...] = ()
138
+ #: A named human decision on this criterion. None until one is recorded.
139
+ human_decision: str | None = None
140
+
141
+ def to_dict(self) -> dict[str, Any]:
142
+ return {
143
+ "index": self.index,
144
+ "text": self.text,
145
+ "state": self.state,
146
+ "basis": self.basis,
147
+ "evidence_class": self.evidence_class,
148
+ "deterministic_evidence": list(self.deterministic_evidence),
149
+ "human_decision": self.human_decision,
150
+ }
151
+
152
+
153
+ @dataclass(frozen=True)
154
+ class AcceptanceReport:
155
+ criteria: tuple[CriterionResult, ...] = field(default_factory=tuple)
156
+ source_stage: str = SOURCE_STAGE
157
+ gating: str = GATING
158
+ schema: str = SCHEMA
159
+ disclaimer: str = DISCLAIMER
160
+
161
+ @property
162
+ def claim_ceiling(self) -> str:
163
+ """Derived from the rows, never stored: the strongest evidence present."""
164
+ strongest = max(
165
+ (c.evidence_class for c in self.criteria),
166
+ key=lambda e: EVIDENCE_RANK.get(e, 0),
167
+ default=EVIDENCE_NONE,
168
+ )
169
+ return CLAIM_CEILING_BY_EVIDENCE.get(strongest, CLAIM_CEILING)
170
+
171
+ @property
172
+ def counts(self) -> dict[str, int]:
173
+ """Per-state tallies. Deliberately NOT collapsed into one verdict."""
174
+ tally = dict.fromkeys(STATES, 0)
175
+ for c in self.criteria:
176
+ tally[c.state] += 1
177
+ return tally
178
+
179
+ def to_dict(self) -> dict[str, Any]:
180
+ return {
181
+ "schema": self.schema,
182
+ "gating": self.gating,
183
+ "claim_ceiling": self.claim_ceiling,
184
+ "source_stage": self.source_stage,
185
+ "counts": self.counts,
186
+ "criteria": [c.to_dict() for c in self.criteria],
187
+ "disclaimer": self.disclaimer,
188
+ }
189
+
190
+ def format_text(self) -> str:
191
+ icons = {
192
+ ASSERTED_MET: "◻︎✓",
193
+ ASSERTED_NOT_MET: "◻︎✗",
194
+ ASSERTED_UNCLEAR: "◻︎?",
195
+ UNASSESSED: "◻︎–",
196
+ EVIDENCE_PASSED: "■✓",
197
+ EVIDENCE_FAILED: "■✗",
198
+ HUMAN_MET: "👤✓",
199
+ HUMAN_NOT_MET: "👤✗",
200
+ }
201
+ lines = [
202
+ "Acceptance criteria (report only; does not affect the run) — "
203
+ "◻︎ model-asserted, NOT verified · ■ bound to a recorded test result · 👤 named human decision:"
204
+ ]
205
+ for c in self.criteria:
206
+ tail = f" — {c.basis}" if c.basis else ""
207
+ if c.deterministic_evidence:
208
+ tail += f" [{', '.join(c.deterministic_evidence)}]"
209
+ if c.human_decision:
210
+ tail += f" [{c.human_decision}]"
211
+ lines.append(f" {icons[c.state]} {c.index}. {c.state}: {c.text}{tail}")
212
+ n = self.counts
213
+ lines.append(
214
+ f" asserted met {n[ASSERTED_MET]} · asserted not met {n[ASSERTED_NOT_MET]} · "
215
+ f"unclear {n[ASSERTED_UNCLEAR]} · unassessed {n[UNASSESSED]} · "
216
+ f"evidence passed {n[EVIDENCE_PASSED]} · evidence failed {n[EVIDENCE_FAILED]} · "
217
+ f"human met {n[HUMAN_MET]} · human not met {n[HUMAN_NOT_MET]}"
218
+ )
219
+ lines.append(f" claim ceiling: {self.claim_ceiling}")
220
+ return "\n".join(lines)
221
+
222
+
223
+ def _clean_basis(raw: str) -> str:
224
+ text = re.sub(r"[*_`]+", "", raw).strip(" \t-—–:")
225
+ text = re.sub(r"\s+", " ", text)
226
+ return text[: _BASIS_MAX - 1] + "…" if len(text) > _BASIS_MAX else text
227
+
228
+
229
+ def parse_review_assessment(review_text: str, count: int) -> dict[int, tuple[str, str]]:
230
+ """Read ``{criterion index: (state, basis)}`` from a review's assessment section.
231
+
232
+ Only rows inside the "Acceptance Criteria Assessment" section count, indices
233
+ must be within ``1..count`` (anything else is ignored, never remapped), and
234
+ the FIRST row for an index wins — a later contradictory row must not
235
+ silently overwrite an earlier verdict. Unrecognized shapes yield nothing.
236
+ """
237
+ found: dict[int, tuple[str, str]] = {}
238
+ in_section = False
239
+ for line in review_text.splitlines():
240
+ if not in_section:
241
+ if _HEADING.match(line.strip().lstrip("#").strip()):
242
+ in_section = True
243
+ continue
244
+ if _NEXT_HEADING.match(line) and not _HEADING.match(line.strip().lstrip("#").strip()):
245
+ break
246
+ m = _ROW.match(line)
247
+ if not m:
248
+ continue
249
+ index = int(m.group("n"))
250
+ if not 1 <= index <= count or index in found:
251
+ continue
252
+ verdict = re.sub(r"[\s-]+", "_", m.group("verdict").upper())
253
+ state = _VERDICT_STATE.get(verdict)
254
+ if state is None:
255
+ continue
256
+ found[index] = (state, _clean_basis(m.group("basis")))
257
+ return found
258
+
259
+
260
+ def build_acceptance_report(criteria: Sequence[str], review_text: str | None) -> AcceptanceReport | None:
261
+ """The report for a run, or None when there is nothing honest to report.
262
+
263
+ None when the ticket has no criteria or the review stage produced no
264
+ output: an empty report would read as "nothing failed".
265
+ """
266
+ texts = [c.strip() for c in criteria if c and c.strip()]
267
+ if not texts or not review_text or not review_text.strip():
268
+ return None
269
+ try:
270
+ found = parse_review_assessment(review_text, len(texts))
271
+ except Exception: # noqa: BLE001 — a parse defect must degrade to UNASSESSED, never break a ship
272
+ found = {}
273
+ rows: list[CriterionResult] = []
274
+ for i, text in enumerate(texts, start=1):
275
+ if i in found:
276
+ state, basis = found[i]
277
+ rows.append(
278
+ CriterionResult(
279
+ index=i, text=text, state=state, basis=basis, evidence_class=EVIDENCE_MODEL_ASSERTION
280
+ )
281
+ )
282
+ else:
283
+ rows.append(CriterionResult(index=i, text=text, state=UNASSESSED))
284
+ return AcceptanceReport(criteria=tuple(rows))
285
+
286
+
287
+ # ---------------------------------------------------------------------------
288
+ # Promotion beyond a model assertion: deterministic test evidence, human decision
289
+ # ---------------------------------------------------------------------------
290
+
291
+
292
+ def bound_tests(criterion_text: str) -> tuple[str, ...]:
293
+ """The test ids a criterion names with ``[test: ...]``, in order, de-duplicated."""
294
+ return tuple(dict.fromkeys(m.strip() for m in _BINDING.findall(criterion_text) if m.strip()))
295
+
296
+
297
+ def parse_pytest_results(stdout: str) -> dict[str, str]:
298
+ """``{nodeid: PASSED|FAILED|...}`` from pytest ``-rA`` short-summary lines.
299
+
300
+ Only lines in that exact shape count; a FAILED anywhere else in the output
301
+ (a traceback header, a docstring) is not a result. The LAST line for a
302
+ nodeid wins, since a re-run inside one capture reports the final state.
303
+ """
304
+ results: dict[str, str] = {}
305
+ for line in stdout.splitlines():
306
+ m = _PYTEST_RESULT.match(line.strip())
307
+ if m:
308
+ results[m.group(2)] = m.group(1)
309
+ return results
310
+
311
+
312
+ def _match_binding(binding: str, results: Mapping[str, str]) -> list[str]:
313
+ """Nodeids a binding names: exact, ``::``-suffix, or bare function name."""
314
+ exact = [n for n in results if n == binding]
315
+ if exact:
316
+ return exact
317
+ suffix = [n for n in results if n.endswith("::" + binding) or n.endswith("/" + binding)]
318
+ if suffix:
319
+ return suffix
320
+ return [n for n in results if n.split("::")[-1] == binding]
321
+
322
+
323
+ def bind_deterministic_evidence(report: AcceptanceReport, results: Mapping[str, str]) -> AcceptanceReport:
324
+ """Replace model assertions with the recorded result of each bound test.
325
+
326
+ A criterion with no binding is untouched. A binding that names no test in
327
+ ``results`` is recorded in the basis as unresolved and the row keeps its
328
+ weaker verdict — a missing test is never read as a pass. A human decision
329
+ already on the row outranks test evidence and is left alone.
330
+ """
331
+ rows: list[CriterionResult] = []
332
+ for c in report.criteria:
333
+ bindings = bound_tests(c.text)
334
+ if not bindings or c.evidence_class == EVIDENCE_HUMAN_DECISION:
335
+ rows.append(c)
336
+ continue
337
+ evidence: list[str] = []
338
+ unresolved: list[str] = []
339
+ statuses: list[str] = []
340
+ for b in bindings:
341
+ matched = _match_binding(b, results)
342
+ if not matched:
343
+ unresolved.append(b)
344
+ continue
345
+ for nodeid in matched:
346
+ statuses.append(results[nodeid])
347
+ evidence.append(f"pytest:{nodeid}:{results[nodeid]}")
348
+ if not evidence:
349
+ rows.append(
350
+ dataclasses.replace(
351
+ c,
352
+ basis=(c.basis + " · " if c.basis else "")
353
+ + f"bound test(s) not found in results: {', '.join(unresolved)}",
354
+ )
355
+ )
356
+ continue
357
+ failed = any(st in ("FAILED", "ERROR") for st in statuses)
358
+ passed = all(st in ("PASSED", "XPASS") for st in statuses)
359
+ if failed:
360
+ state = EVIDENCE_FAILED
361
+ elif passed and not unresolved:
362
+ state = EVIDENCE_PASSED
363
+ else:
364
+ # Skipped/xfail or a partly-unresolved binding set: evidence exists but
365
+ # does not establish the criterion either way. Keep the weaker verdict.
366
+ rows.append(
367
+ dataclasses.replace(
368
+ c,
369
+ basis=(c.basis + " · " if c.basis else "")
370
+ + "bound tests inconclusive: "
371
+ + ", ".join(evidence + [f"{u}:NOT_FOUND" for u in unresolved]),
372
+ )
373
+ )
374
+ continue
375
+ rows.append(
376
+ dataclasses.replace(
377
+ c,
378
+ state=state,
379
+ evidence_class=EVIDENCE_DETERMINISTIC_TEST,
380
+ deterministic_evidence=tuple(evidence),
381
+ basis=(c.basis + " · " if c.basis else "") + "decided by recorded test result",
382
+ )
383
+ )
384
+ return dataclasses.replace(report, criteria=tuple(rows))
385
+
386
+
387
+ def apply_human_decision(
388
+ report: AcceptanceReport,
389
+ index: int,
390
+ *,
391
+ met: bool,
392
+ decided_by: str,
393
+ reason: str,
394
+ decided_at: str,
395
+ ) -> AcceptanceReport:
396
+ """Record a NAMED person's decision on one criterion. Outranks every other
397
+ evidence class; refuses an anonymous or unexplained decision outright."""
398
+ if not decided_by.strip() or not reason.strip():
399
+ raise ValueError("a human decision needs both a named decider and a reason")
400
+ if not any(c.index == index for c in report.criteria):
401
+ raise ValueError(f"no criterion {index}; the report has {len(report.criteria)}")
402
+ rows = [
403
+ dataclasses.replace(
404
+ c,
405
+ state=HUMAN_MET if met else HUMAN_NOT_MET,
406
+ evidence_class=EVIDENCE_HUMAN_DECISION,
407
+ human_decision=f"{decided_by.strip()} @ {decided_at}: {reason.strip()}",
408
+ )
409
+ if c.index == index
410
+ else c
411
+ for c in report.criteria
412
+ ]
413
+ return dataclasses.replace(report, criteria=tuple(rows))
414
+
415
+
416
+ def report_digest(report: AcceptanceReport) -> str:
417
+ """Content address of the report: sha256 over its canonical document.
418
+
419
+ Computed from ``to_dict()``, i.e. from the ROWS — ``claim_ceiling`` and
420
+ ``counts`` in the document are derived, so a planted ceiling in a stored
421
+ file cannot change the digest of the report it was loaded into. Used by
422
+ ``ship_attestation`` to bind WHICH report accompanied a ship; it says
423
+ nothing about whether the report is right."""
424
+
425
+ return hash_canonical(report.to_dict())
426
+
427
+
428
+ def report_from_dict(data: Mapping[str, Any]) -> AcceptanceReport:
429
+ """Rebuild a report from ``to_dict()`` output; a stored ``claim_ceiling`` is
430
+ ignored and re-derived from the rows (a planted ceiling is never trusted)."""
431
+ if data.get("schema") != SCHEMA:
432
+ raise ValueError(f"not an {SCHEMA} document")
433
+ rows = tuple(
434
+ CriterionResult(
435
+ index=int(c["index"]),
436
+ text=str(c["text"]),
437
+ state=str(c["state"]) if c.get("state") in STATES else UNASSESSED,
438
+ basis=str(c.get("basis", "")),
439
+ evidence_class=str(c.get("evidence_class", EVIDENCE_NONE)),
440
+ deterministic_evidence=tuple(str(x) for x in c.get("deterministic_evidence", ())),
441
+ human_decision=(str(c["human_decision"]) if c.get("human_decision") else None),
442
+ )
443
+ for c in data.get("criteria", [])
444
+ )
445
+ return AcceptanceReport(criteria=rows, source_stage=str(data.get("source_stage", SOURCE_STAGE)))