devrites 3.0.6 → 3.1.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 (472) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +67 -45
  3. package/SECURITY.md +31 -29
  4. package/docs/adr/0001-go-engine-as-control-plane.md +1 -1
  5. package/docs/adr/0002-dual-host-harness.md +1 -1
  6. package/docs/adr/0004-state-schema-phases-sections.md +1 -2
  7. package/docs/adr/0006-clock-seam-and-engine-ci-gates.md +3 -4
  8. package/docs/adr/0009-prebuild-decision-coverage-and-readiness.md +67 -0
  9. package/docs/adr/0010-agent-first-fresh-context-orchestration.md +71 -0
  10. package/docs/adr/0011-define-plan-transition-rights.md +28 -0
  11. package/docs/adr/README.md +3 -0
  12. package/docs/agents/triage-labels.md +7 -7
  13. package/docs/architecture.md +157 -140
  14. package/docs/cli.md +41 -12
  15. package/docs/command-map.md +49 -35
  16. package/docs/engine/agent-contract.md +92 -15
  17. package/docs/engine/commands.md +242 -72
  18. package/docs/engine/state-schema.md +52 -23
  19. package/docs/engine/workspace-schema.md +94 -15
  20. package/docs/extensions.md +1 -1
  21. package/docs/flow.md +80 -50
  22. package/docs/harness-compliance.md +29 -5
  23. package/docs/orchestration.md +107 -79
  24. package/docs/quick-reference.md +7 -3
  25. package/docs/release.md +4 -3
  26. package/docs/skills.md +64 -38
  27. package/docs/usage.md +57 -38
  28. package/engine/cmd/releasepack/main.go +219 -0
  29. package/engine/cmd/releasepack/main_test.go +170 -0
  30. package/engine/commands.go +170 -23
  31. package/engine/git_guard.go +187 -0
  32. package/engine/git_guard_test.go +283 -0
  33. package/engine/hookpolicy.go +53 -55
  34. package/engine/hookpolicy_test.go +91 -1
  35. package/engine/hooks.go +296 -75
  36. package/engine/hooks_events_test.go +276 -6
  37. package/engine/hooks_workspace.go +640 -159
  38. package/engine/internal/devritespaths/paths.go +65 -10
  39. package/engine/internal/devritespaths/paths_test.go +110 -0
  40. package/engine/internal/doctor/doctor.go +153 -23
  41. package/engine/internal/doctor/doctor_test.go +74 -0
  42. package/engine/internal/forge/forge.go +940 -0
  43. package/engine/internal/forge/forge_test.go +576 -0
  44. package/engine/internal/forge/git.go +245 -0
  45. package/engine/internal/forge/liveness_unix.go +48 -0
  46. package/engine/internal/forge/liveness_windows.go +67 -0
  47. package/engine/internal/forge/manifest.go +402 -0
  48. package/engine/internal/gate/gate.go +90 -71
  49. package/engine/internal/gate/gate_test.go +71 -2
  50. package/engine/internal/harness/compliance.go +18 -24
  51. package/engine/internal/harness/harness.go +37 -44
  52. package/engine/internal/harness/harness_test.go +21 -5
  53. package/engine/internal/install/install.go +575 -58
  54. package/engine/internal/install/install_test.go +490 -9
  55. package/engine/internal/iohooks/iohooks.go +350 -59
  56. package/engine/internal/iohooks/iohooks_test.go +421 -1
  57. package/engine/internal/lib/buildreadiness.go +35 -18
  58. package/engine/internal/lib/clarifyreturn.go +91 -0
  59. package/engine/internal/lib/clarifyreturn_test.go +123 -0
  60. package/engine/internal/lib/context.go +54 -15
  61. package/engine/internal/lib/cursor_compat_test.go +8 -0
  62. package/engine/internal/lib/extensions.go +2 -3
  63. package/engine/internal/lib/gitauthority.go +601 -0
  64. package/engine/internal/lib/gitauthority_test.go +346 -0
  65. package/engine/internal/lib/jsonout.go +25 -11
  66. package/engine/internal/lib/jsonout_test.go +16 -0
  67. package/engine/internal/lib/lanes.go +14 -8
  68. package/engine/internal/lib/observability_test.go +358 -0
  69. package/engine/internal/lib/packageexistence.go +151 -35
  70. package/engine/internal/lib/packageexistence_test.go +163 -0
  71. package/engine/internal/lib/progress.go +11 -9
  72. package/engine/internal/lib/provenance.go +462 -0
  73. package/engine/internal/lib/provenance_test.go +154 -0
  74. package/engine/internal/lib/readiness_contract.json +111 -0
  75. package/engine/internal/lib/readinessartifact.go +510 -0
  76. package/engine/internal/lib/readinessartifact_test.go +321 -0
  77. package/engine/internal/lib/reconcile.go +712 -88
  78. package/engine/internal/lib/reconcile_test.go +335 -16
  79. package/engine/internal/lib/recoveryattempts.go +298 -0
  80. package/engine/internal/lib/recoveryattempts_test.go +215 -0
  81. package/engine/internal/lib/resolve.go +23 -19
  82. package/engine/internal/lib/runbook_context_test.go +20 -0
  83. package/engine/internal/lib/session.go +701 -9
  84. package/engine/internal/lib/session_test.go +80 -13
  85. package/engine/internal/lib/testintegrity.go +33 -37
  86. package/engine/internal/lib/testintegrity_test.go +63 -1
  87. package/engine/internal/migrate/migrate.go +81 -1
  88. package/engine/internal/migrate/migrate_test.go +22 -1
  89. package/engine/internal/reason/reason.go +180 -0
  90. package/engine/internal/reason/reason_test.go +35 -0
  91. package/engine/internal/rootfacts/facts.go +466 -0
  92. package/engine/internal/rootfacts/facts_test.go +306 -0
  93. package/engine/internal/safepath/safepath.go +55 -0
  94. package/engine/internal/safepath/safepath_test.go +69 -0
  95. package/engine/internal/safepath/safepath_windows_test.go +26 -0
  96. package/engine/internal/state/clarify_transition.go +165 -0
  97. package/engine/internal/state/clarify_transition_test.go +130 -0
  98. package/engine/internal/state/cmd/workflowmanifest/main.go +12 -8
  99. package/engine/internal/state/cursor.go +58 -0
  100. package/engine/internal/state/cursor_test.go +42 -1
  101. package/engine/internal/state/feature.go +35 -48
  102. package/engine/internal/state/schema.go +90 -37
  103. package/engine/internal/state/snapshot.go +18 -2
  104. package/engine/internal/state/state_test.go +187 -7
  105. package/engine/internal/state/status.go +48 -11
  106. package/engine/internal/state/workflow_manifest.json +231 -50
  107. package/engine/internal/toolpolicy/classifier.go +533 -0
  108. package/engine/internal/toolpolicy/classifier_test.go +424 -0
  109. package/engine/internal/toolpolicy/git.go +616 -0
  110. package/engine/internal/toolpolicy/scanner.go +382 -0
  111. package/engine/main.go +160 -77
  112. package/engine/observability_cli_test.go +52 -0
  113. package/engine/root_routing_test.go +277 -0
  114. package/engine/testdata/golden/TestParityBuildReadiness/arg=approved.golden +1 -1
  115. package/engine/testdata/golden/TestParityBuildReadiness/arg=emptystatus.golden +1 -1
  116. package/engine/testdata/golden/TestParityBuildReadiness/arg=noclarify.golden +1 -0
  117. package/engine/testdata/golden/TestParityBuildReadiness/arg=novet.golden +1 -0
  118. package/engine/testdata/golden/TestParityBuildReadiness/arg=stalevet.golden +1 -0
  119. package/engine/testdata/golden/TestParityBuildReadiness/arg=trailhash.golden +1 -1
  120. package/engine/testdata/golden/TestParityBuildReadiness/arg=trailpipe.golden +1 -1
  121. package/engine/testdata/golden/TestParityBuildReadiness/arg=vetnotready.golden +1 -0
  122. package/engine/testdata/golden/TestParityProgress/arg=allbuilt.golden +1 -1
  123. package/engine/testdata/golden/TestParityProgress/arg=done.golden +1 -1
  124. package/engine/testdata/golden/TestParityProgress/arg=mid.golden +1 -1
  125. package/engine/testdata/golden/TestParityProgress/arg=nophase.golden +1 -1
  126. package/engine/testdata/golden/TestParityProgress/arg=noslice.golden +1 -1
  127. package/engine/testdata/golden/TestParityProgress/arg=plan.golden +1 -1
  128. package/engine/testdata/golden/TestParityProgress/arg=seal.golden +1 -1
  129. package/engine/testdata/golden/TestParityReconcile/check-clean.golden +1 -1
  130. package/engine/testdata/golden/TestParityReconcile/inline-fallback.golden +1 -0
  131. package/engine/testdata/golden/TestParityReconcile/snapshot-no-allowlist.golden +1 -0
  132. package/engine/testdata/golden/TestParityWrightScope/devrites-edit-denied.golden +2 -0
  133. package/engine/testdata/golden/TestParityWrightScope/out-of-scope-enforce-denies.golden +1 -1
  134. package/engine/tests/adr_0011_define_plan_test.go +30 -0
  135. package/engine/tests/budget_test.go +19 -8
  136. package/engine/tests/concurrency_cli_test.go +7 -1
  137. package/engine/tests/doctor_cli_test.go +148 -2
  138. package/engine/tests/forge_cli_test.go +463 -0
  139. package/engine/tests/gate_test.go +63 -8
  140. package/engine/tests/hook_test.go +260 -8
  141. package/engine/tests/hooks_io_test.go +94 -3
  142. package/engine/tests/json_contract_test.go +127 -2
  143. package/engine/tests/migrate_cli_test.go +2 -2
  144. package/engine/tests/parity_buildreadiness_test.go +141 -10
  145. package/engine/tests/parity_learnings_test.go +16 -17
  146. package/engine/tests/parity_reconcile_test.go +20 -17
  147. package/engine/tests/parity_resolve_test.go +12 -11
  148. package/engine/tests/parity_test.go +21 -17
  149. package/pack/.claude/agents/devrites-code-reviewer.md +62 -48
  150. package/pack/.claude/agents/devrites-devex-reviewer.md +69 -53
  151. package/pack/.claude/agents/devrites-doubt-reviewer.md +29 -22
  152. package/pack/.claude/agents/devrites-evidence-scout.md +69 -0
  153. package/pack/.claude/agents/devrites-forge-judge.md +74 -61
  154. package/pack/.claude/agents/devrites-frontend-reviewer.md +48 -39
  155. package/pack/.claude/agents/devrites-performance-reviewer.md +49 -40
  156. package/pack/.claude/agents/devrites-plan-drafter.md +71 -0
  157. package/pack/.claude/agents/devrites-plan-reviewer.md +80 -47
  158. package/pack/.claude/agents/devrites-proof-runner.md +74 -0
  159. package/pack/.claude/agents/devrites-retrospector.md +48 -45
  160. package/pack/.claude/agents/devrites-security-auditor.md +46 -36
  161. package/pack/.claude/agents/devrites-simplifier-reviewer.md +50 -46
  162. package/pack/.claude/agents/devrites-slice-wright.md +153 -165
  163. package/pack/.claude/agents/devrites-spec-reviewer.md +34 -32
  164. package/pack/.claude/agents/devrites-strategy-reviewer.md +63 -34
  165. package/pack/.claude/agents/devrites-test-analyst.md +40 -30
  166. package/pack/.claude/settings.json +2 -1
  167. package/pack/.claude/skills/devrites-audit/SKILL.md +40 -61
  168. package/pack/.claude/skills/devrites-debug-recovery/SKILL.md +24 -13
  169. package/pack/.claude/skills/devrites-debug-recovery/reference/build-the-loop.md +24 -21
  170. package/pack/.claude/skills/devrites-debug-recovery/reference/cleanup-and-classify.md +34 -6
  171. package/pack/.claude/skills/devrites-debug-recovery/reference/instrumentation.md +2 -2
  172. package/pack/.claude/skills/devrites-doubt/SKILL.md +25 -17
  173. package/pack/.claude/skills/devrites-interview/SKILL.md +53 -52
  174. package/pack/.claude/skills/devrites-lib/SKILL.md +11 -9
  175. package/pack/.claude/skills/devrites-lib/reference/intent-map.md +3 -2
  176. package/pack/.claude/skills/devrites-lib/reference/parallel-dispatch.md +82 -137
  177. package/pack/.claude/skills/devrites-lib/reference/reply-contract.md +8 -1
  178. package/pack/.claude/skills/devrites-lib/reference/standards/README.md +38 -55
  179. package/pack/.claude/skills/devrites-lib/reference/standards/afk-hitl.md +53 -27
  180. package/pack/.claude/skills/devrites-lib/reference/standards/agents.md +198 -190
  181. package/pack/.claude/skills/devrites-lib/reference/standards/anti-patterns.md +5 -15
  182. package/pack/.claude/skills/devrites-lib/reference/standards/ci-cd.md +27 -58
  183. package/pack/.claude/skills/devrites-lib/reference/standards/code-review.md +16 -52
  184. package/pack/.claude/skills/devrites-lib/reference/standards/coding-style.md +2 -10
  185. package/pack/.claude/skills/devrites-lib/reference/standards/context-hygiene.md +18 -61
  186. package/pack/.claude/skills/devrites-lib/reference/standards/core.md +23 -18
  187. package/pack/.claude/skills/devrites-lib/reference/standards/deprecation.md +17 -57
  188. package/pack/.claude/skills/devrites-lib/reference/standards/development-workflow.md +4 -33
  189. package/pack/.claude/skills/devrites-lib/reference/standards/git-workflow.md +4 -20
  190. package/pack/.claude/skills/devrites-lib/reference/standards/hooks.md +3 -14
  191. package/pack/.claude/skills/devrites-lib/reference/standards/patterns.md +9 -25
  192. package/pack/.claude/skills/devrites-lib/reference/standards/performance.md +0 -9
  193. package/pack/.claude/skills/devrites-lib/reference/standards/principles.md +1 -3
  194. package/pack/.claude/skills/devrites-lib/reference/standards/security.md +17 -7
  195. package/pack/.claude/skills/devrites-lib/reference/standards/testing.md +1 -27
  196. package/pack/.claude/skills/devrites-lib/reference/standards/tooling.md +46 -50
  197. package/pack/.claude/skills/devrites-lib/reference/workspace-artifact-schema.md +23 -10
  198. package/pack/.claude/skills/devrites-source-driven/SKILL.md +23 -22
  199. package/pack/.claude/skills/rite/SKILL.md +8 -6
  200. package/pack/.claude/skills/rite/reference/menu.md +8 -6
  201. package/pack/.claude/skills/rite-adopt/SKILL.md +33 -37
  202. package/pack/.claude/skills/rite-autocomplete/SKILL.md +33 -28
  203. package/pack/.claude/skills/rite-autocomplete/reference/loop.md +21 -21
  204. package/pack/.claude/skills/rite-autocomplete/reference/stop-conditions.md +15 -6
  205. package/pack/.claude/skills/rite-build/SKILL.md +35 -30
  206. package/pack/.claude/skills/rite-build/reference/afk-discipline.md +38 -38
  207. package/pack/.claude/skills/rite-build/reference/evidence-standard.md +10 -0
  208. package/pack/.claude/skills/rite-build/reference/forge.md +186 -156
  209. package/pack/.claude/skills/rite-build/reference/one-slice-cycle.md +4 -3
  210. package/pack/.claude/skills/rite-build/reference/phase-contract.md +96 -175
  211. package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +129 -134
  212. package/pack/.claude/skills/rite-clarify/SKILL.md +89 -0
  213. package/pack/.claude/skills/rite-clarify/reference/decision-coverage.md +59 -0
  214. package/pack/.claude/skills/rite-converge/SKILL.md +35 -25
  215. package/pack/.claude/skills/rite-define/SKILL.md +57 -32
  216. package/pack/.claude/skills/rite-define/reference/gates.md +16 -15
  217. package/pack/.claude/skills/rite-define/reference/plan-template.md +16 -8
  218. package/pack/.claude/skills/rite-frame/reference/failure-modes.md +22 -24
  219. package/pack/.claude/skills/rite-plan/SKILL.md +44 -16
  220. package/pack/.claude/skills/rite-plan/reference/task-breakdown.md +4 -0
  221. package/pack/.claude/skills/rite-polish/SKILL.md +27 -23
  222. package/pack/.claude/skills/rite-prototype/SKILL.md +25 -26
  223. package/pack/.claude/skills/rite-prove/SKILL.md +27 -17
  224. package/pack/.claude/skills/rite-resolve/SKILL.md +12 -11
  225. package/pack/.claude/skills/rite-resolve/reference/answer-protocol.md +3 -0
  226. package/pack/.claude/skills/rite-review/SKILL.md +37 -28
  227. package/pack/.claude/skills/rite-seal/reference/phase-contract.md +27 -92
  228. package/pack/.claude/skills/rite-seal/reference/risk-and-rollback.md +13 -0
  229. package/pack/.claude/skills/rite-ship/reference/design-memory.md +31 -36
  230. package/pack/.claude/skills/rite-spec/SKILL.md +84 -135
  231. package/pack/.claude/skills/rite-spec/reference/investigation.md +37 -33
  232. package/pack/.claude/skills/rite-spec/reference/question-protocol.md +6 -2
  233. package/pack/.claude/skills/rite-spec/reference/spec-checklists.md +25 -24
  234. package/pack/.claude/skills/rite-spec/reference/spec-template.md +9 -2
  235. package/pack/.claude/skills/rite-spec/reference/state-workspace.md +13 -3
  236. package/pack/.claude/skills/rite-temper/SKILL.md +62 -47
  237. package/pack/.claude/skills/rite-temper/reference/review-dimensions.md +24 -22
  238. package/pack/.claude/skills/rite-vet/SKILL.md +110 -113
  239. package/pack/.claude/skills/rite-vet/reference/artifacts.md +42 -9
  240. package/pack/.claude/skills/rite-vet/reference/review-axes.md +39 -37
  241. package/pack/generated/claude/agents/devrites-code-reviewer.md +62 -48
  242. package/pack/generated/claude/agents/devrites-devex-reviewer.md +69 -53
  243. package/pack/generated/claude/agents/devrites-doubt-reviewer.md +29 -22
  244. package/pack/generated/claude/agents/devrites-evidence-scout.md +69 -0
  245. package/pack/generated/claude/agents/devrites-forge-judge.md +74 -61
  246. package/pack/generated/claude/agents/devrites-frontend-reviewer.md +48 -39
  247. package/pack/generated/claude/agents/devrites-performance-reviewer.md +49 -40
  248. package/pack/generated/claude/agents/devrites-plan-drafter.md +71 -0
  249. package/pack/generated/claude/agents/devrites-plan-reviewer.md +80 -47
  250. package/pack/generated/claude/agents/devrites-proof-runner.md +74 -0
  251. package/pack/generated/claude/agents/devrites-retrospector.md +48 -45
  252. package/pack/generated/claude/agents/devrites-security-auditor.md +46 -36
  253. package/pack/generated/claude/agents/devrites-simplifier-reviewer.md +50 -46
  254. package/pack/generated/claude/agents/devrites-slice-wright.md +153 -165
  255. package/pack/generated/claude/agents/devrites-spec-reviewer.md +34 -32
  256. package/pack/generated/claude/agents/devrites-strategy-reviewer.md +63 -34
  257. package/pack/generated/claude/agents/devrites-test-analyst.md +40 -30
  258. package/pack/generated/claude/settings.json +2 -1
  259. package/pack/generated/claude/skills/devrites-audit/SKILL.md +40 -61
  260. package/pack/generated/claude/skills/devrites-debug-recovery/SKILL.md +24 -13
  261. package/pack/generated/claude/skills/devrites-debug-recovery/reference/build-the-loop.md +24 -21
  262. package/pack/generated/claude/skills/devrites-debug-recovery/reference/cleanup-and-classify.md +34 -6
  263. package/pack/generated/claude/skills/devrites-debug-recovery/reference/instrumentation.md +2 -2
  264. package/pack/generated/claude/skills/devrites-doubt/SKILL.md +25 -17
  265. package/pack/generated/claude/skills/devrites-interview/SKILL.md +53 -52
  266. package/pack/generated/claude/skills/devrites-lib/SKILL.md +11 -9
  267. package/pack/generated/claude/skills/devrites-lib/reference/intent-map.md +3 -2
  268. package/pack/generated/claude/skills/devrites-lib/reference/parallel-dispatch.md +82 -137
  269. package/pack/generated/claude/skills/devrites-lib/reference/reply-contract.md +8 -1
  270. package/pack/generated/claude/skills/devrites-lib/reference/standards/README.md +38 -55
  271. package/pack/generated/claude/skills/devrites-lib/reference/standards/afk-hitl.md +53 -27
  272. package/pack/generated/claude/skills/devrites-lib/reference/standards/agents.md +198 -190
  273. package/pack/generated/claude/skills/devrites-lib/reference/standards/anti-patterns.md +5 -15
  274. package/pack/generated/claude/skills/devrites-lib/reference/standards/ci-cd.md +27 -58
  275. package/pack/generated/claude/skills/devrites-lib/reference/standards/code-review.md +16 -52
  276. package/pack/generated/claude/skills/devrites-lib/reference/standards/coding-style.md +2 -10
  277. package/pack/generated/claude/skills/devrites-lib/reference/standards/context-hygiene.md +18 -61
  278. package/pack/generated/claude/skills/devrites-lib/reference/standards/core.md +23 -18
  279. package/pack/generated/claude/skills/devrites-lib/reference/standards/deprecation.md +17 -57
  280. package/pack/generated/claude/skills/devrites-lib/reference/standards/development-workflow.md +4 -33
  281. package/pack/generated/claude/skills/devrites-lib/reference/standards/git-workflow.md +4 -20
  282. package/pack/generated/claude/skills/devrites-lib/reference/standards/hooks.md +3 -14
  283. package/pack/generated/claude/skills/devrites-lib/reference/standards/patterns.md +9 -25
  284. package/pack/generated/claude/skills/devrites-lib/reference/standards/performance.md +0 -9
  285. package/pack/generated/claude/skills/devrites-lib/reference/standards/principles.md +1 -3
  286. package/pack/generated/claude/skills/devrites-lib/reference/standards/security.md +17 -7
  287. package/pack/generated/claude/skills/devrites-lib/reference/standards/testing.md +1 -27
  288. package/pack/generated/claude/skills/devrites-lib/reference/standards/tooling.md +46 -50
  289. package/pack/generated/claude/skills/devrites-lib/reference/workspace-artifact-schema.md +23 -10
  290. package/pack/generated/claude/skills/devrites-source-driven/SKILL.md +23 -22
  291. package/pack/generated/claude/skills/rite/SKILL.md +8 -6
  292. package/pack/generated/claude/skills/rite/reference/menu.md +8 -6
  293. package/pack/generated/claude/skills/rite-adopt/SKILL.md +33 -37
  294. package/pack/generated/claude/skills/rite-autocomplete/SKILL.md +33 -28
  295. package/pack/generated/claude/skills/rite-autocomplete/reference/loop.md +21 -21
  296. package/pack/generated/claude/skills/rite-autocomplete/reference/stop-conditions.md +15 -6
  297. package/pack/generated/claude/skills/rite-build/SKILL.md +35 -30
  298. package/pack/generated/claude/skills/rite-build/reference/afk-discipline.md +38 -38
  299. package/pack/generated/claude/skills/rite-build/reference/evidence-standard.md +10 -0
  300. package/pack/generated/claude/skills/rite-build/reference/forge.md +186 -156
  301. package/pack/generated/claude/skills/rite-build/reference/one-slice-cycle.md +4 -3
  302. package/pack/generated/claude/skills/rite-build/reference/phase-contract.md +96 -175
  303. package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +129 -134
  304. package/pack/generated/claude/skills/rite-clarify/SKILL.md +89 -0
  305. package/pack/generated/claude/skills/rite-clarify/reference/decision-coverage.md +59 -0
  306. package/pack/generated/claude/skills/rite-converge/SKILL.md +35 -25
  307. package/pack/generated/claude/skills/rite-define/SKILL.md +57 -32
  308. package/pack/generated/claude/skills/rite-define/reference/gates.md +16 -15
  309. package/pack/generated/claude/skills/rite-define/reference/plan-template.md +16 -8
  310. package/pack/generated/claude/skills/rite-frame/reference/failure-modes.md +22 -24
  311. package/pack/generated/claude/skills/rite-plan/SKILL.md +44 -16
  312. package/pack/generated/claude/skills/rite-plan/reference/task-breakdown.md +4 -0
  313. package/pack/generated/claude/skills/rite-polish/SKILL.md +27 -23
  314. package/pack/generated/claude/skills/rite-prototype/SKILL.md +25 -26
  315. package/pack/generated/claude/skills/rite-prove/SKILL.md +27 -17
  316. package/pack/generated/claude/skills/rite-resolve/SKILL.md +12 -11
  317. package/pack/generated/claude/skills/rite-resolve/reference/answer-protocol.md +3 -0
  318. package/pack/generated/claude/skills/rite-review/SKILL.md +37 -28
  319. package/pack/generated/claude/skills/rite-seal/reference/phase-contract.md +27 -92
  320. package/pack/generated/claude/skills/rite-seal/reference/risk-and-rollback.md +13 -0
  321. package/pack/generated/claude/skills/rite-ship/reference/design-memory.md +31 -36
  322. package/pack/generated/claude/skills/rite-spec/SKILL.md +84 -135
  323. package/pack/generated/claude/skills/rite-spec/reference/investigation.md +37 -33
  324. package/pack/generated/claude/skills/rite-spec/reference/question-protocol.md +6 -2
  325. package/pack/generated/claude/skills/rite-spec/reference/spec-checklists.md +25 -24
  326. package/pack/generated/claude/skills/rite-spec/reference/spec-template.md +9 -2
  327. package/pack/generated/claude/skills/rite-spec/reference/state-workspace.md +13 -3
  328. package/pack/generated/claude/skills/rite-temper/SKILL.md +62 -47
  329. package/pack/generated/claude/skills/rite-temper/reference/review-dimensions.md +24 -22
  330. package/pack/generated/claude/skills/rite-vet/SKILL.md +110 -113
  331. package/pack/generated/claude/skills/rite-vet/reference/artifacts.md +42 -9
  332. package/pack/generated/claude/skills/rite-vet/reference/review-axes.md +39 -37
  333. package/pack/generated/codex/AGENTS.md +5 -2
  334. package/pack/generated/codex/agents/devrites-code-reviewer.toml +69 -47
  335. package/pack/generated/codex/agents/devrites-devex-reviewer.toml +75 -51
  336. package/pack/generated/codex/agents/devrites-doubt-reviewer.toml +35 -20
  337. package/pack/generated/codex/agents/devrites-evidence-scout.toml +75 -0
  338. package/pack/generated/codex/agents/devrites-forge-judge.toml +80 -59
  339. package/pack/generated/codex/agents/devrites-frontend-reviewer.toml +54 -37
  340. package/pack/generated/codex/agents/devrites-performance-reviewer.toml +55 -38
  341. package/pack/generated/codex/agents/devrites-plan-drafter.toml +77 -0
  342. package/pack/generated/codex/agents/devrites-plan-reviewer.toml +82 -47
  343. package/pack/generated/codex/agents/devrites-proof-runner.toml +80 -0
  344. package/pack/generated/codex/agents/devrites-retrospector.toml +54 -43
  345. package/pack/generated/codex/agents/devrites-security-auditor.toml +53 -35
  346. package/pack/generated/codex/agents/devrites-simplifier-reviewer.toml +56 -44
  347. package/pack/generated/codex/agents/devrites-slice-wright.toml +159 -163
  348. package/pack/generated/codex/agents/devrites-spec-reviewer.toml +40 -30
  349. package/pack/generated/codex/agents/devrites-strategy-reviewer.toml +65 -34
  350. package/pack/generated/codex/agents/devrites-test-analyst.toml +46 -28
  351. package/pack/generated/codex/hooks.json +4 -14
  352. package/pack/generated/codex/skills/devrites-api-interface/SKILL.md +7 -3
  353. package/pack/generated/codex/skills/devrites-audit/SKILL.md +47 -64
  354. package/pack/generated/codex/skills/devrites-browser-proof/SKILL.md +7 -3
  355. package/pack/generated/codex/skills/devrites-debug-recovery/SKILL.md +31 -16
  356. package/pack/generated/codex/skills/devrites-debug-recovery/reference/build-the-loop.md +24 -21
  357. package/pack/generated/codex/skills/devrites-debug-recovery/reference/cleanup-and-classify.md +34 -6
  358. package/pack/generated/codex/skills/devrites-debug-recovery/reference/instrumentation.md +2 -2
  359. package/pack/generated/codex/skills/devrites-doubt/SKILL.md +32 -20
  360. package/pack/generated/codex/skills/devrites-frontend-craft/SKILL.md +7 -3
  361. package/pack/generated/codex/skills/devrites-interview/SKILL.md +60 -55
  362. package/pack/generated/codex/skills/devrites-lib/SKILL.md +18 -12
  363. package/pack/generated/codex/skills/devrites-lib/reference/intent-map.md +3 -2
  364. package/pack/generated/codex/skills/devrites-lib/reference/parallel-dispatch.md +82 -137
  365. package/pack/generated/codex/skills/devrites-lib/reference/reply-contract.md +8 -1
  366. package/pack/generated/codex/skills/devrites-lib/reference/standards/README.md +38 -55
  367. package/pack/generated/codex/skills/devrites-lib/reference/standards/afk-hitl.md +53 -27
  368. package/pack/generated/codex/skills/devrites-lib/reference/standards/agents.md +198 -190
  369. package/pack/generated/codex/skills/devrites-lib/reference/standards/anti-patterns.md +5 -15
  370. package/pack/generated/codex/skills/devrites-lib/reference/standards/ci-cd.md +27 -58
  371. package/pack/generated/codex/skills/devrites-lib/reference/standards/code-review.md +16 -52
  372. package/pack/generated/codex/skills/devrites-lib/reference/standards/coding-style.md +2 -10
  373. package/pack/generated/codex/skills/devrites-lib/reference/standards/context-hygiene.md +18 -61
  374. package/pack/generated/codex/skills/devrites-lib/reference/standards/core.md +23 -18
  375. package/pack/generated/codex/skills/devrites-lib/reference/standards/deprecation.md +17 -57
  376. package/pack/generated/codex/skills/devrites-lib/reference/standards/development-workflow.md +4 -33
  377. package/pack/generated/codex/skills/devrites-lib/reference/standards/git-workflow.md +4 -20
  378. package/pack/generated/codex/skills/devrites-lib/reference/standards/hooks.md +3 -14
  379. package/pack/generated/codex/skills/devrites-lib/reference/standards/patterns.md +9 -25
  380. package/pack/generated/codex/skills/devrites-lib/reference/standards/performance.md +0 -9
  381. package/pack/generated/codex/skills/devrites-lib/reference/standards/principles.md +1 -3
  382. package/pack/generated/codex/skills/devrites-lib/reference/standards/security.md +17 -7
  383. package/pack/generated/codex/skills/devrites-lib/reference/standards/testing.md +1 -27
  384. package/pack/generated/codex/skills/devrites-lib/reference/standards/tooling.md +46 -50
  385. package/pack/generated/codex/skills/devrites-lib/reference/workspace-artifact-schema.md +23 -10
  386. package/pack/generated/codex/skills/devrites-prose-craft/SKILL.md +7 -3
  387. package/pack/generated/codex/skills/devrites-refresh-indexes/SKILL.md +7 -3
  388. package/pack/generated/codex/skills/devrites-source-driven/SKILL.md +29 -24
  389. package/pack/generated/codex/skills/devrites-ux-shape/SKILL.md +7 -3
  390. package/pack/generated/codex/skills/rite/SKILL.md +19 -13
  391. package/pack/generated/codex/skills/rite/reference/menu.md +8 -6
  392. package/pack/generated/codex/skills/rite-adopt/SKILL.md +40 -40
  393. package/pack/generated/codex/skills/rite-autocomplete/SKILL.md +40 -31
  394. package/pack/generated/codex/skills/rite-autocomplete/reference/loop.md +21 -21
  395. package/pack/generated/codex/skills/rite-autocomplete/reference/stop-conditions.md +15 -6
  396. package/pack/generated/codex/skills/rite-build/SKILL.md +42 -33
  397. package/pack/generated/codex/skills/rite-build/reference/afk-discipline.md +38 -38
  398. package/pack/generated/codex/skills/rite-build/reference/evidence-standard.md +10 -0
  399. package/pack/generated/codex/skills/rite-build/reference/forge.md +186 -156
  400. package/pack/generated/codex/skills/rite-build/reference/one-slice-cycle.md +4 -3
  401. package/pack/generated/codex/skills/rite-build/reference/phase-contract.md +96 -175
  402. package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +129 -134
  403. package/pack/generated/codex/skills/rite-clarify/SKILL.md +105 -0
  404. package/pack/generated/codex/skills/rite-clarify/reference/decision-coverage.md +59 -0
  405. package/pack/generated/codex/skills/rite-converge/SKILL.md +42 -28
  406. package/pack/generated/codex/skills/rite-customize/SKILL.md +7 -3
  407. package/pack/generated/codex/skills/rite-define/SKILL.md +64 -35
  408. package/pack/generated/codex/skills/rite-define/reference/gates.md +16 -15
  409. package/pack/generated/codex/skills/rite-define/reference/plan-template.md +16 -8
  410. package/pack/generated/codex/skills/rite-doctor/SKILL.md +7 -3
  411. package/pack/generated/codex/skills/rite-dogfood/SKILL.md +7 -3
  412. package/pack/generated/codex/skills/rite-explain/SKILL.md +7 -3
  413. package/pack/generated/codex/skills/rite-frame/SKILL.md +7 -3
  414. package/pack/generated/codex/skills/rite-frame/reference/failure-modes.md +22 -24
  415. package/pack/generated/codex/skills/rite-handoff/SKILL.md +7 -3
  416. package/pack/generated/codex/skills/rite-learn/SKILL.md +7 -3
  417. package/pack/generated/codex/skills/rite-plan/SKILL.md +51 -19
  418. package/pack/generated/codex/skills/rite-plan/reference/task-breakdown.md +5 -1
  419. package/pack/generated/codex/skills/rite-polish/SKILL.md +34 -26
  420. package/pack/generated/codex/skills/rite-pov/SKILL.md +7 -3
  421. package/pack/generated/codex/skills/rite-pr-feedback/SKILL.md +7 -3
  422. package/pack/generated/codex/skills/rite-pressure-test/SKILL.md +7 -3
  423. package/pack/generated/codex/skills/rite-prototype/SKILL.md +32 -29
  424. package/pack/generated/codex/skills/rite-prove/SKILL.md +34 -20
  425. package/pack/generated/codex/skills/rite-quick/SKILL.md +7 -3
  426. package/pack/generated/codex/skills/rite-resolve/SKILL.md +19 -14
  427. package/pack/generated/codex/skills/rite-resolve/reference/answer-protocol.md +3 -0
  428. package/pack/generated/codex/skills/rite-review/SKILL.md +44 -31
  429. package/pack/generated/codex/skills/rite-seal/SKILL.md +7 -3
  430. package/pack/generated/codex/skills/rite-seal/reference/phase-contract.md +27 -92
  431. package/pack/generated/codex/skills/rite-seal/reference/risk-and-rollback.md +13 -0
  432. package/pack/generated/codex/skills/rite-ship/SKILL.md +7 -3
  433. package/pack/generated/codex/skills/rite-ship/reference/design-memory.md +31 -36
  434. package/pack/generated/codex/skills/rite-spec/SKILL.md +91 -138
  435. package/pack/generated/codex/skills/rite-spec/reference/investigation.md +37 -33
  436. package/pack/generated/codex/skills/rite-spec/reference/question-protocol.md +6 -2
  437. package/pack/generated/codex/skills/rite-spec/reference/spec-checklists.md +25 -24
  438. package/pack/generated/codex/skills/rite-spec/reference/spec-template.md +9 -2
  439. package/pack/generated/codex/skills/rite-spec/reference/state-workspace.md +13 -3
  440. package/pack/generated/codex/skills/rite-status/SKILL.md +7 -3
  441. package/pack/generated/codex/skills/rite-temper/SKILL.md +69 -50
  442. package/pack/generated/codex/skills/rite-temper/reference/review-dimensions.md +24 -22
  443. package/pack/generated/codex/skills/rite-vet/SKILL.md +117 -116
  444. package/pack/generated/codex/skills/rite-vet/reference/artifacts.md +42 -9
  445. package/pack/generated/codex/skills/rite-vet/reference/review-axes.md +40 -38
  446. package/pack/generated/codex/skills/rite-zoom-out/SKILL.md +7 -3
  447. package/package.json +1 -1
  448. package/scripts/build-release-tarball.sh +32 -15
  449. package/scripts/check-authority-drift.py +125 -0
  450. package/scripts/check-instruction-size-baseline.mjs +19 -11
  451. package/scripts/check-invocation-integrity.py +2 -0
  452. package/scripts/codex-generate.sh +69 -33
  453. package/scripts/grade-feature.sh +121 -40
  454. package/scripts/live-hosts/agent-result.schema.json +230 -0
  455. package/scripts/live-hosts/claude.sh +87 -0
  456. package/scripts/live-hosts/codex.sh +81 -0
  457. package/scripts/live-hosts/common.sh +113 -0
  458. package/scripts/live-hosts/fake-host.py +264 -0
  459. package/scripts/live-hosts/host-transport.py +287 -0
  460. package/scripts/release-check.sh +5 -1
  461. package/scripts/run-agent-contract-evals.py +1380 -0
  462. package/scripts/run-behavioral-evals.sh +24 -30
  463. package/scripts/run-evals.sh +1 -5
  464. package/scripts/run-live-behavioral-evals.py +1274 -144
  465. package/scripts/run-outcome-evals.sh +414 -88
  466. package/scripts/run-tests.mjs +30 -2
  467. package/scripts/skills-inventory.mjs +1 -1
  468. package/scripts/validate-workflow-security.py +39 -20
  469. package/scripts/validate-workspace-schema.py +362 -10
  470. package/scripts/validate.sh +21 -15
  471. package/engine/testdata/golden/TestParityWrightScope/devrites-edit-allowed.golden +0 -1
  472. /package/engine/testdata/golden/{TestParityReconcile/check-no-claimed.golden → TestParityBuildReadiness/arg=clarifyopen.golden} +0 -0
@@ -0,0 +1,1380 @@
1
+ #!/usr/bin/env python3
2
+ """Run the hermetic Claude/Codex agent-contract matrix.
3
+
4
+ Fake mode is deterministic and network-free. Live mode is manual, paid, and
5
+ fails closed unless host pins, distinct scoped credentials, artifact input,
6
+ and both cost ceilings pass preflight.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import re
16
+ import shutil
17
+ import signal
18
+ import stat
19
+ import subprocess
20
+ import sys
21
+ import tempfile
22
+ import time
23
+ from collections import Counter
24
+ from datetime import datetime, timezone
25
+ from decimal import Decimal, InvalidOperation
26
+ from pathlib import Path, PurePosixPath
27
+ from typing import Any
28
+
29
+
30
+ ROOT = Path(__file__).resolve().parent.parent
31
+ DEFAULT_MATRIX = ROOT / "evals" / "agent-contract" / "contract-matrix.json"
32
+ FAKE_HOST = ROOT / "scripts" / "live-hosts" / "fake-host.py"
33
+ LIVE_CONFIRMATION = "RUN-PAID-HOST-CONTRACTS"
34
+ SUMMARY_SCHEMA = "devrites-agent-contract-summary/v1"
35
+ PREFLIGHT_SCHEMA = "devrites-agent-contract-preflight/v1"
36
+ BUDGET_SCHEMA = "devrites-agent-contract-budget/v1"
37
+ LIVE_RUNTIME_COST_CAPS = {"claude": "cli-max-budget-usd"}
38
+ RUN_ID_RE = re.compile(r"^drv-run-v1:[0-9a-f]{32}$")
39
+ REASON_RE = re.compile(r'\bID\s*=\s*"([A-Z0-9-]+)"')
40
+ SAFE_TOKEN_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
41
+ SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
42
+ SENSITIVE_ASSIGNMENT_RE = re.compile(
43
+ r"(?i)(?:api[_-]?key|authorization|bearer|credential|password|secret|token)\s*[:=]\s*\S+"
44
+ )
45
+ SECRET_TOKEN_RE = re.compile(
46
+ r"(?i)\b(?:sk|rk|ghp|github_pat|xox[baprs])[-_][a-z0-9_-]{8,}\b"
47
+ )
48
+ TEXT_PAYLOAD_RE = re.compile(r"(?i)(?:^|[\s,{])(prompt|raw|source|trace)\s*[:=]")
49
+ MANDATORY_SIGNALS = {
50
+ "auth_preflight",
51
+ "budget_bound",
52
+ "cleanup_ready",
53
+ "environment_allowlisted",
54
+ "mcp_absent",
55
+ "plugins_absent",
56
+ "roots_isolated",
57
+ "shell_startup_ignored",
58
+ "version_pinned",
59
+ }
60
+ RESULT_KEYS = {
61
+ "result_version",
62
+ "run_id",
63
+ "cell_id",
64
+ "host",
65
+ "role",
66
+ "status",
67
+ "baseline_id",
68
+ "scope_id",
69
+ "budget",
70
+ "side_effects",
71
+ "payload_type",
72
+ "execution",
73
+ "signals",
74
+ "usage",
75
+ "dispatch_reason_id",
76
+ "result_reason_id",
77
+ "outcome",
78
+ "terminal_sentinel",
79
+ "host_version",
80
+ "model",
81
+ }
82
+ FORBIDDEN_RESULT_KEYS = {
83
+ "absolute_path",
84
+ "auth",
85
+ "config",
86
+ "home",
87
+ "prompt",
88
+ "raw",
89
+ "scratch_root",
90
+ "source",
91
+ "trace",
92
+ }
93
+
94
+
95
+ class ContractError(Exception):
96
+ def __init__(self, message: str, reason_id: str = "DRV-AGENT-RESULT-MALFORMED"):
97
+ super().__init__(message)
98
+ self.reason_id = reason_id
99
+
100
+
101
+ def canonical_json(value: Any) -> bytes:
102
+ return json.dumps(
103
+ value, sort_keys=True, separators=(",", ":"), ensure_ascii=True
104
+ ).encode()
105
+
106
+
107
+ def sha256_bytes(value: bytes) -> str:
108
+ return hashlib.sha256(value).hexdigest()
109
+
110
+
111
+ def load_json(path: Path) -> Any:
112
+ try:
113
+ return json.loads(path.read_text(encoding="utf-8"))
114
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
115
+ raise ContractError("JSON-compatible YAML is required") from exc
116
+
117
+
118
+ def exact_keys(value: dict[str, Any], required: set[str], where: str) -> None:
119
+ keys = set(value)
120
+ if keys != required:
121
+ raise ContractError(f"{where} keys do not match the frozen schema")
122
+
123
+
124
+ def safe_relative(value: Any, where: str) -> str:
125
+ if not isinstance(value, str) or not value:
126
+ raise ContractError(f"{where} must be a non-empty relative path")
127
+ path = PurePosixPath(value)
128
+ if path.is_absolute() or ".." in path.parts or str(path) != value or "\\" in value:
129
+ raise ContractError(f"{where} is not a confined portable path")
130
+ return value
131
+
132
+
133
+ def safe_id(value: Any, where: str) -> str:
134
+ if (
135
+ not isinstance(value, str)
136
+ or not 1 <= len(value) <= 128
137
+ or value.startswith("/")
138
+ or any(ord(character) < 32 or ord(character) == 127 for character in value)
139
+ ):
140
+ raise ContractError(
141
+ f"{where} is not a safe identifier", "DRV-AGENT-UNAVAILABLE"
142
+ )
143
+ return value
144
+
145
+
146
+ def positive_int(value: Any, where: str) -> int:
147
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
148
+ raise ContractError(f"{where} must be a positive integer")
149
+ return value
150
+
151
+
152
+ def money(value: Any, where: str, *, allow_zero: bool = False) -> Decimal:
153
+ try:
154
+ parsed = Decimal(str(value))
155
+ except (InvalidOperation, ValueError) as exc:
156
+ raise ContractError(f"{where} must be a decimal amount") from exc
157
+ if not parsed.is_finite() or parsed < 0 or (not allow_zero and parsed == 0):
158
+ raise ContractError(
159
+ f"{where} must be {'non-negative' if allow_zero else 'positive'}"
160
+ )
161
+ return parsed
162
+
163
+
164
+ def private_string_fault(value: str) -> str | None:
165
+ if (
166
+ value.startswith(("/", "~", "file://"))
167
+ or re.match(r"^[A-Za-z]:[\\/]", value)
168
+ or "\x00" in value
169
+ ):
170
+ return "path"
171
+ if len(value) > 256 or any(character in value for character in ("\n", "\r", "```")):
172
+ return "text payload"
173
+ if (
174
+ SENSITIVE_ASSIGNMENT_RE.search(value)
175
+ or SECRET_TOKEN_RE.search(value)
176
+ or TEXT_PAYLOAD_RE.search(value)
177
+ ):
178
+ return "sensitive payload"
179
+ return None
180
+
181
+
182
+ def frozen_reasons() -> set[str]:
183
+ path = ROOT / "engine" / "internal" / "reason" / "reason.go"
184
+ try:
185
+ reasons = set(REASON_RE.findall(path.read_text(encoding="utf-8")))
186
+ except OSError as exc:
187
+ raise ContractError("frozen reason catalog is unavailable") from exc
188
+ if not reasons:
189
+ raise ContractError("frozen reason catalog is empty")
190
+ return reasons
191
+
192
+
193
+ def validate_matrix(data: Any) -> dict[str, Any]:
194
+ if not isinstance(data, dict):
195
+ raise ContractError("matrix must be an object")
196
+ exact_keys(
197
+ data,
198
+ {"schema", "packet_schema", "result_schema", "hosts", "budget", "scenarios"},
199
+ "matrix",
200
+ )
201
+ if data["schema"] != "devrites-agent-contract-matrix/v1":
202
+ raise ContractError("matrix schema is unsupported")
203
+ if (
204
+ data["packet_schema"] != "agent-packet/v1"
205
+ or data["result_schema"] != "agent-result/v1"
206
+ ):
207
+ raise ContractError("transport schema is unsupported")
208
+
209
+ reasons = frozen_reasons()
210
+ hosts = data["hosts"]
211
+ if not isinstance(hosts, list) or len(hosts) != 2:
212
+ raise ContractError("matrix must define exactly two hosts")
213
+ host_ids: set[str] = set()
214
+ for host in hosts:
215
+ if not isinstance(host, dict):
216
+ raise ContractError("host row must be an object")
217
+ exact_keys(host, {"id", "launcher", "fake_version"}, "host")
218
+ host_id = host["id"]
219
+ if host_id not in {"claude", "codex"} or host_id in host_ids:
220
+ raise ContractError("host IDs must be unique claude and codex")
221
+ host_ids.add(host_id)
222
+ safe_relative(host["launcher"], "host.launcher")
223
+ if not isinstance(host["fake_version"], str) or not host["fake_version"]:
224
+ raise ContractError("host.fake_version must be non-empty")
225
+ launcher = (ROOT / host["launcher"]).resolve()
226
+ if ROOT.resolve() not in launcher.parents:
227
+ raise ContractError("host launcher escapes the repository")
228
+
229
+ budget = data["budget"]
230
+ if not isinstance(budget, dict):
231
+ raise ContractError("budget must be an object")
232
+ exact_keys(
233
+ budget,
234
+ {
235
+ "timeout_seconds",
236
+ "estimated_max_cost_usd_per_cell",
237
+ "max_files",
238
+ "max_loaded_lines",
239
+ "max_result_lines",
240
+ },
241
+ "budget",
242
+ )
243
+ for key in ("timeout_seconds", "max_files", "max_loaded_lines", "max_result_lines"):
244
+ positive_int(budget[key], f"budget.{key}")
245
+ money(
246
+ budget["estimated_max_cost_usd_per_cell"],
247
+ "budget.estimated_max_cost_usd_per_cell",
248
+ )
249
+
250
+ scenarios = data["scenarios"]
251
+ if not isinstance(scenarios, list) or not scenarios:
252
+ raise ContractError("matrix scenarios must be non-empty")
253
+ scenario_ids: set[str] = set()
254
+ for scenario in scenarios:
255
+ if not isinstance(scenario, dict):
256
+ raise ContractError("scenario row must be an object")
257
+ exact_keys(
258
+ scenario,
259
+ {"id", "role", "role_class", "simulate", "allowed_repo_writes", "expect"},
260
+ "scenario",
261
+ )
262
+ scenario_id = scenario["id"]
263
+ if (
264
+ not isinstance(scenario_id, str)
265
+ or not SAFE_TOKEN_RE.fullmatch(scenario_id)
266
+ or scenario_id in scenario_ids
267
+ ):
268
+ raise ContractError("scenario IDs must be unique safe tokens")
269
+ scenario_ids.add(scenario_id)
270
+ if not isinstance(scenario["role"], str) or not scenario["role"]:
271
+ raise ContractError("scenario.role must be non-empty")
272
+ if scenario["role_class"] not in {"reviewer", "wright"}:
273
+ raise ContractError("scenario.role_class is unsupported")
274
+ if scenario["simulate"] not in {
275
+ "accepted",
276
+ "generic-fallback",
277
+ "inline-fallback",
278
+ "interrupted",
279
+ "malformed",
280
+ "missing",
281
+ "post-compaction",
282
+ "reviewer-denied",
283
+ "session-start",
284
+ "stale",
285
+ "wright-denied",
286
+ "write-allowed",
287
+ }:
288
+ raise ContractError("scenario.simulate is unsupported")
289
+ writes = scenario["allowed_repo_writes"]
290
+ if not isinstance(writes, list) or len(writes) != len(set(writes)):
291
+ raise ContractError("allowed writes must be a unique list")
292
+ for index, path in enumerate(writes):
293
+ safe_relative(path, f"allowed_repo_writes[{index}]")
294
+ if scenario["role_class"] == "reviewer" and writes:
295
+ raise ContractError("reviewers cannot receive repo writes")
296
+
297
+ expect = scenario["expect"]
298
+ if not isinstance(expect, dict):
299
+ raise ContractError("scenario.expect must be an object")
300
+ exact_keys(
301
+ expect,
302
+ {
303
+ "execution_mode",
304
+ "guard_strength",
305
+ "independence",
306
+ "dispatch_reason_id",
307
+ "result_reason_id",
308
+ "status",
309
+ "outcome",
310
+ "terminal_sentinel",
311
+ "host_exit",
312
+ "required_signals",
313
+ },
314
+ "scenario.expect",
315
+ )
316
+ if expect["execution_mode"] not in {"named", "generic", "inline"}:
317
+ raise ContractError("execution mode is unsupported")
318
+ if expect["guard_strength"] not in {
319
+ "enforced",
320
+ "observed",
321
+ "unavailable",
322
+ "bypassed",
323
+ }:
324
+ raise ContractError("guard strength is unsupported")
325
+ if not isinstance(expect["independence"], bool):
326
+ raise ContractError("independence must be boolean")
327
+ for key in ("dispatch_reason_id", "result_reason_id"):
328
+ if expect[key] not in reasons:
329
+ raise ContractError(f"{key} is not in the frozen reason catalog")
330
+ if expect["status"] not in {"complete", "partial", "blocked", "failed"}:
331
+ raise ContractError("expected status is unsupported")
332
+ if expect["outcome"] not in {"passed", "denied", "unavailable"}:
333
+ raise ContractError("expected outcome is unsupported")
334
+ if expect["terminal_sentinel"] not in {
335
+ "complete",
336
+ "interrupted",
337
+ "malformed",
338
+ "missing",
339
+ "stale",
340
+ }:
341
+ raise ContractError("terminal sentinel is unsupported")
342
+ if isinstance(expect["host_exit"], bool) or expect["host_exit"] not in {0, 130}:
343
+ raise ContractError("expected host exit is unsupported")
344
+ required_signals = expect["required_signals"]
345
+ if (
346
+ not isinstance(required_signals, list)
347
+ or len(required_signals) != len(set(required_signals))
348
+ or not all(
349
+ isinstance(item, str)
350
+ and SAFE_TOKEN_RE.fullmatch(item.replace("_", "-"))
351
+ for item in required_signals
352
+ )
353
+ ):
354
+ raise ContractError("required signals must be unique safe tokens")
355
+ return data
356
+
357
+
358
+ def tree_snapshot(root: Path) -> dict[str, dict[str, Any]]:
359
+ out: dict[str, dict[str, Any]] = {}
360
+ if not root.exists():
361
+ return out
362
+ for path in sorted(root.rglob("*")):
363
+ rel = path.relative_to(root).as_posix()
364
+ mode = stat.S_IMODE(path.lstat().st_mode)
365
+ if path.is_symlink():
366
+ out[rel] = {
367
+ "kind": "symlink",
368
+ "mode": mode,
369
+ "sha256": sha256_bytes(os.readlink(path).encode()),
370
+ }
371
+ elif path.is_file():
372
+ out[rel] = {
373
+ "kind": "file",
374
+ "mode": mode,
375
+ "sha256": sha256_bytes(path.read_bytes()),
376
+ }
377
+ return out
378
+
379
+
380
+ def tree_delta(
381
+ before: dict[str, dict[str, Any]], after: dict[str, dict[str, Any]]
382
+ ) -> list[dict[str, Any]]:
383
+ delta: list[dict[str, Any]] = []
384
+ for path in sorted(set(before) | set(after)):
385
+ if before.get(path) == after.get(path):
386
+ continue
387
+ if path not in after:
388
+ delta.append(
389
+ {"path": path, "kind": "deleted", "sha256": sha256_bytes(b"deleted")}
390
+ )
391
+ else:
392
+ delta.append(
393
+ {
394
+ "path": path,
395
+ "kind": after[path]["kind"],
396
+ "sha256": after[path]["sha256"],
397
+ }
398
+ )
399
+ return delta
400
+
401
+
402
+ def parse_assignments(values: list[str], hosts: set[str], label: str) -> dict[str, str]:
403
+ out: dict[str, str] = {}
404
+ for value in values:
405
+ if "=" not in value:
406
+ raise ContractError(f"{label} must use HOST=VALUE", "DRV-AGENT-UNAVAILABLE")
407
+ host, item = value.split("=", 1)
408
+ if host not in hosts or not item or host in out:
409
+ raise ContractError(
410
+ f"{label} has an invalid or duplicate host", "DRV-AGENT-UNAVAILABLE"
411
+ )
412
+ out[host] = item
413
+ return out
414
+
415
+
416
+ def secure_auth_file(path_value: str) -> Path:
417
+ path = Path(path_value)
418
+ try:
419
+ info = path.lstat()
420
+ except OSError as exc:
421
+ raise ContractError(
422
+ "scoped auth file is unavailable", "DRV-AGENT-UNAVAILABLE"
423
+ ) from exc
424
+ if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
425
+ raise ContractError(
426
+ "scoped auth file must be a regular non-symlink", "DRV-AGENT-UNAVAILABLE"
427
+ )
428
+ if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o600:
429
+ raise ContractError(
430
+ "scoped auth file must be owner-only mode 0600", "DRV-AGENT-UNAVAILABLE"
431
+ )
432
+ if info.st_size <= 0 or info.st_size > 64 * 1024:
433
+ raise ContractError("scoped auth file size is invalid", "DRV-AGENT-UNAVAILABLE")
434
+ return path.resolve()
435
+
436
+
437
+ def launcher_version(
438
+ host: dict[str, Any], fake: bool, fake_fault: str | None = None
439
+ ) -> str:
440
+ launcher = ROOT / host["launcher"]
441
+ env = {
442
+ "PATH": os.environ.get("PATH", "/usr/bin:/bin"),
443
+ "LANG": "C",
444
+ "LC_ALL": "C",
445
+ }
446
+ if fake:
447
+ env["DEVRITES_CONTRACT_FAKE_HOST"] = str(FAKE_HOST)
448
+ env["DEVRITES_CONTRACT_FAKE_VERSION"] = host["fake_version"]
449
+ env["DEVRITES_CONTRACT_HOST"] = host["id"]
450
+ if fake_fault == "version-mismatch":
451
+ env["DEVRITES_CONTRACT_FAKE_VERSION"] += "-mismatch"
452
+ try:
453
+ proc = subprocess.run(
454
+ [str(launcher), "version"],
455
+ env=env,
456
+ text=True,
457
+ stdout=subprocess.PIPE,
458
+ stderr=subprocess.DEVNULL,
459
+ timeout=15,
460
+ check=False,
461
+ )
462
+ except (OSError, subprocess.TimeoutExpired) as exc:
463
+ raise ContractError(
464
+ "host version preflight failed", "DRV-AGENT-UNAVAILABLE"
465
+ ) from exc
466
+ version = proc.stdout.strip()
467
+ if proc.returncode != 0 or not version or "\n" in version:
468
+ raise ContractError("host version preflight failed", "DRV-AGENT-UNAVAILABLE")
469
+ return version
470
+
471
+
472
+ def load_budget_ledger(path: Path) -> dict[str, Any]:
473
+ data = load_json(path)
474
+ if not isinstance(data, dict):
475
+ raise ContractError("budget ledger must be an object", "DRV-AGENT-UNAVAILABLE")
476
+ exact_keys(data, {"schema", "month", "spent_usd"}, "budget ledger")
477
+ if data["schema"] != BUDGET_SCHEMA:
478
+ raise ContractError(
479
+ "budget ledger schema is unsupported", "DRV-AGENT-UNAVAILABLE"
480
+ )
481
+ if data["month"] != datetime.now(timezone.utc).strftime("%Y-%m"):
482
+ raise ContractError(
483
+ "budget ledger month is not current", "DRV-AGENT-UNAVAILABLE"
484
+ )
485
+ money(data["spent_usd"], "budget ledger spent_usd", allow_zero=True)
486
+ return data
487
+
488
+
489
+ def live_preflight(
490
+ args: argparse.Namespace, matrix: dict[str, Any]
491
+ ) -> tuple[dict[str, dict[str, str]], Decimal, Decimal, Path]:
492
+ hosts = {row["id"]: row for row in matrix["hosts"]}
493
+ host_ids = set(hosts)
494
+ if args.confirm_live != LIVE_CONFIRMATION:
495
+ raise ContractError("live confirmation is missing", "DRV-AGENT-UNAVAILABLE")
496
+ versions = parse_assignments(args.host_version, host_ids, "--host-version")
497
+ models = parse_assignments(args.host_model, host_ids, "--host-model")
498
+ versions = {
499
+ host: safe_id(value, f"--host-version {host}")
500
+ for host, value in versions.items()
501
+ }
502
+ models = {
503
+ host: safe_id(value, f"--host-model {host}")
504
+ for host, value in models.items()
505
+ }
506
+ auth_values = parse_assignments(args.auth_file, host_ids, "--auth-file")
507
+ if (
508
+ set(versions) != host_ids
509
+ or set(models) != host_ids
510
+ or set(auth_values) != host_ids
511
+ ):
512
+ raise ContractError(
513
+ "every host needs a version, model, and auth file", "DRV-AGENT-UNAVAILABLE"
514
+ )
515
+ auth = {host: secure_auth_file(auth_values[host]) for host in host_ids}
516
+ auth_identities = {
517
+ (path.stat().st_dev, path.stat().st_ino) for path in auth.values()
518
+ }
519
+ if len(set(auth.values())) != len(auth) or len(auth_identities) != len(auth):
520
+ raise ContractError(
521
+ "host credentials must use separate scoped files", "DRV-AGENT-UNAVAILABLE"
522
+ )
523
+ actual_versions: dict[str, dict[str, str]] = {}
524
+ for host_id, host in hosts.items():
525
+ actual = launcher_version(host, False)
526
+ if actual != versions[host_id]:
527
+ raise ContractError(
528
+ "host version does not match its exact pin", "DRV-AGENT-UNAVAILABLE"
529
+ )
530
+ actual_versions[host_id] = {
531
+ "version": actual,
532
+ "model": models[host_id],
533
+ "auth_file": str(auth[host_id]),
534
+ }
535
+
536
+ if args.host_artifacts is None or not args.host_artifacts.is_dir():
537
+ raise ContractError(
538
+ "live mode requires a prepared host artifact directory",
539
+ "DRV-AGENT-UNAVAILABLE",
540
+ )
541
+ per_run = money(args.max_cost_per_run_usd, "--max-cost-per-run-usd")
542
+ monthly = money(args.max_monthly_cost_usd, "--max-monthly-cost-usd")
543
+ if args.budget_ledger is None:
544
+ raise ContractError(
545
+ "live mode requires a monthly budget ledger", "DRV-AGENT-UNAVAILABLE"
546
+ )
547
+ ledger = load_budget_ledger(args.budget_ledger)
548
+ spent = money(ledger["spent_usd"], "budget ledger spent_usd", allow_zero=True)
549
+ projected = per_run * Decimal(len(matrix["hosts"]) * len(matrix["scenarios"]))
550
+ if spent + projected > monthly:
551
+ raise ContractError(
552
+ "the full matrix exceeds the monthly ceiling", "DRV-AGENT-UNAVAILABLE"
553
+ )
554
+ uncapped = sorted(host_ids - LIVE_RUNTIME_COST_CAPS.keys())
555
+ if uncapped:
556
+ raise ContractError(
557
+ "paid live host lacks an enforced runtime/provider cost cap: "
558
+ + ", ".join(uncapped),
559
+ "DRV-AGENT-UNAVAILABLE",
560
+ )
561
+ return actual_versions, per_run, monthly, args.budget_ledger
562
+
563
+
564
+ def seed_project(
565
+ project: Path, live: bool, host_artifacts: Path | None, env: dict[str, str]
566
+ ) -> None:
567
+ project.mkdir(parents=True)
568
+ (project / "README.md").write_text(
569
+ "# Synthetic agent contract fixture\n", encoding="utf-8"
570
+ )
571
+ (project / "src").mkdir()
572
+ if not live:
573
+ return
574
+ install_env = {
575
+ "PATH": env["PATH"],
576
+ "HOME": env["HOME"],
577
+ "TMPDIR": env["TMPDIR"],
578
+ "LANG": "C",
579
+ "LC_ALL": "C",
580
+ "DEVRITES_HOST_ARTIFACT_DIR": str(host_artifacts),
581
+ }
582
+ proc = subprocess.run(
583
+ ["bash", str(ROOT / "install.sh"), "--target", str(project)],
584
+ env=install_env,
585
+ stdout=subprocess.DEVNULL,
586
+ stderr=subprocess.DEVNULL,
587
+ timeout=180,
588
+ check=False,
589
+ )
590
+ if proc.returncode != 0:
591
+ raise ContractError(
592
+ "isolated host-pack install failed", "DRV-AGENT-UNAVAILABLE"
593
+ )
594
+ fixture = ROOT / "evals" / "golden" / "shippable-feature"
595
+ workspace = project / ".devrites" / "work" / "shippable-feature"
596
+ workspace.parent.mkdir(parents=True, exist_ok=True)
597
+ shutil.copytree(fixture, workspace)
598
+ (project / ".devrites" / "ACTIVE").write_text(
599
+ "shippable-feature\n", encoding="utf-8"
600
+ )
601
+
602
+
603
+ def make_packet(
604
+ matrix: dict[str, Any],
605
+ host: dict[str, Any],
606
+ scenario: dict[str, Any],
607
+ project: Path,
608
+ scratch: Path,
609
+ ) -> dict[str, Any]:
610
+ baseline_tree = tree_snapshot(project)
611
+ diff_hash = sha256_bytes(canonical_json(baseline_tree))
612
+ touched_hash = sha256_bytes(canonical_json(sorted(baseline_tree)))
613
+ run_id = "drv-run-v1:" + os.urandom(16).hex()
614
+ baseline_id = f"no-git:{diff_hash}:{touched_hash}"
615
+ budget = matrix["budget"]
616
+ return {
617
+ "packet_version": matrix["packet_schema"],
618
+ "run_id": run_id,
619
+ "cell_id": f"{host['id']}:{scenario['id']}",
620
+ "host": host["id"],
621
+ "role": scenario["role"],
622
+ "role_class": scenario["role_class"],
623
+ "phase": "eval",
624
+ "attempt": 1,
625
+ "baseline": {
626
+ "id": baseline_id,
627
+ "head": "no-git",
628
+ "diff_sha256": diff_hash,
629
+ "touched_sha256": touched_hash,
630
+ },
631
+ "objective_id": scenario["id"],
632
+ "inputs": [{"path": "README.md", "purpose": "synthetic-contract-fixture"}],
633
+ "scope": {
634
+ "id": sha256_bytes(canonical_json(scenario["allowed_repo_writes"])),
635
+ "in": ["README.md"],
636
+ "out": ["repository-history", "external-systems"],
637
+ "allowed_repo_writes": scenario["allowed_repo_writes"],
638
+ },
639
+ "budgets": {
640
+ "max_files": budget["max_files"],
641
+ "max_loaded_lines": budget["max_loaded_lines"],
642
+ "max_result_lines": budget["max_result_lines"],
643
+ },
644
+ "scratch_root": str(scratch),
645
+ "simulate": scenario["simulate"],
646
+ "expected_execution": scenario["expect"]["execution_mode"],
647
+ "expected_guard": scenario["expect"]["guard_strength"],
648
+ "allowed_signals": sorted(
649
+ MANDATORY_SIGNALS | set(scenario["expect"]["required_signals"])
650
+ ),
651
+ "result_schema": matrix["result_schema"],
652
+ }
653
+
654
+
655
+ def write_transport(path: Path, value: Any) -> None:
656
+ path.write_bytes(canonical_json(value) + b"\n")
657
+ path.chmod(0o600)
658
+
659
+
660
+ def synthetic_reconcile(
661
+ host: dict[str, Any],
662
+ scenario: dict[str, Any],
663
+ exit_code: int,
664
+ reason_id: str,
665
+ sentinel: str,
666
+ host_version: str,
667
+ model: str,
668
+ *,
669
+ live: bool,
670
+ identity_matched: bool = True,
671
+ baseline_matched: bool = True,
672
+ ) -> dict[str, Any]:
673
+ expect = scenario["expect"]
674
+ return {
675
+ "cell_id": f"{host['id']}:{scenario['id']}",
676
+ "host": host["id"],
677
+ "scenario": scenario["id"],
678
+ "role": scenario["role"],
679
+ "passed": False,
680
+ "status": "partial" if sentinel == "interrupted" else "failed",
681
+ "outcome": "unavailable",
682
+ "execution_mode": expect["execution_mode"],
683
+ "guard_strength": expect["guard_strength"],
684
+ "independence": expect["independence"],
685
+ "dispatch_reason_id": expect["dispatch_reason_id"],
686
+ "result_reason_id": reason_id,
687
+ "terminal_sentinel": sentinel,
688
+ "host_exit": exit_code,
689
+ "host_version": host_version,
690
+ "model": model,
691
+ "identity_matched": identity_matched,
692
+ "baseline_matched": baseline_matched,
693
+ "repo_writes": [],
694
+ "signals": sorted(MANDATORY_SIGNALS if sentinel == "interrupted" else []),
695
+ "usage": {
696
+ "input_tokens": 0,
697
+ "output_tokens": 0,
698
+ "cost_usd": None if live else "0",
699
+ "cost_provenance": "unavailable" if live else "synthetic",
700
+ },
701
+ }
702
+
703
+
704
+ def declared_writes(value: Any) -> list[dict[str, Any]]:
705
+ if not isinstance(value, list):
706
+ raise ContractError("repo_writes must be a list")
707
+ out: list[dict[str, Any]] = []
708
+ for item in value:
709
+ if not isinstance(item, dict):
710
+ raise ContractError("repo write must be an object")
711
+ exact_keys(item, {"path", "kind", "sha256"}, "repo write")
712
+ safe_relative(item["path"], "repo write path")
713
+ if item["kind"] not in {
714
+ "file",
715
+ "symlink",
716
+ "deleted",
717
+ } or not SHA256_RE.fullmatch(str(item["sha256"])):
718
+ raise ContractError("repo write metadata is invalid")
719
+ out.append(item)
720
+ if len({item["path"] for item in out}) != len(out):
721
+ raise ContractError("repo writes contain duplicates")
722
+ return sorted(out, key=lambda item: item["path"])
723
+
724
+
725
+ def validate_result(
726
+ raw: Any,
727
+ packet: dict[str, Any],
728
+ host: dict[str, Any],
729
+ scenario: dict[str, Any],
730
+ exit_code: int,
731
+ actual_delta: list[dict[str, Any]],
732
+ host_version: str,
733
+ model: str,
734
+ reasons: set[str],
735
+ live: bool,
736
+ ) -> dict[str, Any]:
737
+ if not isinstance(raw, dict) or set(raw) != RESULT_KEYS:
738
+ raise ContractError("result object does not match the frozen schema")
739
+ if any(key in FORBIDDEN_RESULT_KEYS for key in raw):
740
+ raise ContractError("result contains a forbidden retention key")
741
+ if raw["result_version"] != "agent-result/v1":
742
+ raise ContractError("result version is unsupported")
743
+ identity_fields = {
744
+ "run_id": packet["run_id"],
745
+ "cell_id": packet["cell_id"],
746
+ "host": packet["host"],
747
+ "role": packet["role"],
748
+ }
749
+ if any(raw.get(key) != value for key, value in identity_fields.items()):
750
+ raise ContractError(
751
+ "result identity mismatch", "DRV-AGENT-RESULT-IDENTITY-MISMATCH"
752
+ )
753
+ if not RUN_ID_RE.fullmatch(raw["run_id"]):
754
+ raise ContractError("result run ID is malformed")
755
+ if raw["baseline_id"] != packet["baseline"]["id"]:
756
+ raise ContractError("result baseline is stale", "DRV-AGENT-RESULT-STALE")
757
+ if raw["scope_id"] != packet["scope"]["id"]:
758
+ raise ContractError(
759
+ "result scope identity mismatch", "DRV-AGENT-RESULT-IDENTITY-MISMATCH"
760
+ )
761
+ if raw["host_version"] != host_version or raw["model"] != model:
762
+ raise ContractError(
763
+ "result host pin identity mismatch", "DRV-AGENT-RESULT-IDENTITY-MISMATCH"
764
+ )
765
+
766
+ if raw["status"] not in {"complete", "partial", "blocked", "failed"}:
767
+ raise ContractError("result status is unsupported")
768
+ if raw["outcome"] not in {"passed", "denied", "unavailable"}:
769
+ raise ContractError("result outcome is unsupported")
770
+ if raw["terminal_sentinel"] not in {"complete", "interrupted"}:
771
+ raise ContractError("result terminal sentinel is unsupported")
772
+ if raw["payload_type"] not in {"review-findings", "wright-report"}:
773
+ raise ContractError("result payload type is unsupported")
774
+
775
+ execution = raw["execution"]
776
+ if not isinstance(execution, dict):
777
+ raise ContractError("result execution must be an object")
778
+ exact_keys(
779
+ execution, {"mode", "guard_strength", "independence"}, "result execution"
780
+ )
781
+ if execution["mode"] not in {"named", "generic", "inline"}:
782
+ raise ContractError("result execution mode is unsupported")
783
+ if execution["guard_strength"] not in {
784
+ "enforced",
785
+ "observed",
786
+ "unavailable",
787
+ "bypassed",
788
+ }:
789
+ raise ContractError("result guard strength is unsupported")
790
+ if not isinstance(execution["independence"], bool):
791
+ raise ContractError("result independence must be boolean")
792
+
793
+ for key in ("dispatch_reason_id", "result_reason_id"):
794
+ if raw[key] not in reasons:
795
+ raise ContractError("result reason is not frozen")
796
+ budget = raw["budget"]
797
+ if not isinstance(budget, dict):
798
+ raise ContractError("result budget must be an object")
799
+ exact_keys(
800
+ budget,
801
+ {"files_used", "loaded_lines_used", "result_lines_used"},
802
+ "result budget",
803
+ )
804
+ packet_limits = packet["budgets"]
805
+ for used_key, limit_key in (
806
+ ("files_used", "max_files"),
807
+ ("loaded_lines_used", "max_loaded_lines"),
808
+ ("result_lines_used", "max_result_lines"),
809
+ ):
810
+ used = budget[used_key]
811
+ if (
812
+ isinstance(used, bool)
813
+ or not isinstance(used, int)
814
+ or used < 0
815
+ or used > packet_limits[limit_key]
816
+ ):
817
+ raise ContractError("result exceeded its packet budget")
818
+
819
+ side_effects = raw["side_effects"]
820
+ if not isinstance(side_effects, dict):
821
+ raise ContractError("result side_effects must be an object")
822
+ exact_keys(
823
+ side_effects, {"repo_writes", "scratch_write_count"}, "result side_effects"
824
+ )
825
+ writes = declared_writes(side_effects["repo_writes"])
826
+ if (
827
+ isinstance(side_effects["scratch_write_count"], bool)
828
+ or not isinstance(side_effects["scratch_write_count"], int)
829
+ or side_effects["scratch_write_count"] < 0
830
+ ):
831
+ raise ContractError("scratch write count is invalid")
832
+ if writes != actual_delta:
833
+ raise ContractError(
834
+ "declared and observed repo effects differ", "DRV-AGENT-RESULT-OUT-OF-SCOPE"
835
+ )
836
+ allowed = set(packet["scope"]["allowed_repo_writes"])
837
+ if any(item["path"] not in allowed for item in actual_delta):
838
+ raise ContractError(
839
+ "repo effect escaped the exact allowlist", "DRV-AGENT-RESULT-OUT-OF-SCOPE"
840
+ )
841
+
842
+ signals = raw["signals"]
843
+ if (
844
+ not isinstance(signals, list)
845
+ or len(signals) != len(set(signals))
846
+ or not all(isinstance(item, str) and item for item in signals)
847
+ ):
848
+ raise ContractError("result signals are invalid")
849
+ if not MANDATORY_SIGNALS.issubset(signals):
850
+ raise ContractError("result is missing hermetic preflight signals")
851
+ allowed_signals = MANDATORY_SIGNALS | set(scenario["expect"]["required_signals"])
852
+ if not set(signals).issubset(allowed_signals):
853
+ raise ContractError("result contains a signal outside the scenario vocabulary")
854
+ usage = raw["usage"]
855
+ if not isinstance(usage, dict):
856
+ raise ContractError("result usage must be an object")
857
+ usage = dict(usage)
858
+ if not live and set(usage) == {"input_tokens", "output_tokens", "cost_usd"}:
859
+ usage["cost_provenance"] = "synthetic"
860
+ exact_keys(
861
+ usage,
862
+ {"input_tokens", "output_tokens", "cost_usd", "cost_provenance"},
863
+ "result usage",
864
+ )
865
+ for key in ("input_tokens", "output_tokens"):
866
+ if (
867
+ isinstance(usage[key], bool)
868
+ or not isinstance(usage[key], int)
869
+ or usage[key] < 0
870
+ ):
871
+ raise ContractError("result token count is invalid")
872
+ provenance = usage["cost_provenance"]
873
+ if provenance == "unavailable":
874
+ if not live or usage["cost_usd"] is not None:
875
+ raise ContractError("unavailable cost provenance is inconsistent")
876
+ elif provenance == "provider-reported":
877
+ if not live or usage["cost_usd"] is None:
878
+ raise ContractError("provider cost provenance is inconsistent")
879
+ usage["cost_usd"] = str(
880
+ money(usage["cost_usd"], "result cost", allow_zero=True)
881
+ )
882
+ elif provenance == "synthetic":
883
+ if live or usage["cost_usd"] is None:
884
+ raise ContractError("synthetic cost provenance is inconsistent")
885
+ usage["cost_usd"] = str(
886
+ money(usage["cost_usd"], "result cost", allow_zero=True)
887
+ )
888
+ else:
889
+ raise ContractError("result cost provenance is unsupported")
890
+
891
+ return {
892
+ "cell_id": packet["cell_id"],
893
+ "host": host["id"],
894
+ "scenario": scenario["id"],
895
+ "role": scenario["role"],
896
+ "passed": False,
897
+ "status": raw["status"],
898
+ "outcome": raw["outcome"],
899
+ "execution_mode": execution["mode"],
900
+ "guard_strength": execution["guard_strength"],
901
+ "independence": execution["independence"],
902
+ "dispatch_reason_id": raw["dispatch_reason_id"],
903
+ "result_reason_id": raw["result_reason_id"],
904
+ "terminal_sentinel": raw["terminal_sentinel"],
905
+ "host_exit": exit_code,
906
+ "host_version": raw["host_version"],
907
+ "model": raw["model"],
908
+ "identity_matched": True,
909
+ "baseline_matched": True,
910
+ "repo_writes": writes,
911
+ "signals": sorted(signals),
912
+ "usage": usage,
913
+ }
914
+
915
+
916
+ def usage_cost(usage: dict[str, Any]) -> Decimal | None:
917
+ if usage["cost_provenance"] == "unavailable":
918
+ return None
919
+ return money(usage["cost_usd"], "result cost", allow_zero=True)
920
+
921
+
922
+ def result_matches_expected(result: dict[str, Any], scenario: dict[str, Any]) -> bool:
923
+ expect = scenario["expect"]
924
+ pairs = {
925
+ "status": expect["status"],
926
+ "outcome": expect["outcome"],
927
+ "execution_mode": expect["execution_mode"],
928
+ "guard_strength": expect["guard_strength"],
929
+ "independence": expect["independence"],
930
+ "dispatch_reason_id": expect["dispatch_reason_id"],
931
+ "result_reason_id": expect["result_reason_id"],
932
+ "terminal_sentinel": expect["terminal_sentinel"],
933
+ "host_exit": expect["host_exit"],
934
+ }
935
+ if any(result.get(key) != value for key, value in pairs.items()):
936
+ return False
937
+ if not set(expect["required_signals"]).issubset(result.get("signals", [])):
938
+ return False
939
+ if scenario["simulate"] == "write-allowed":
940
+ if [item["path"] for item in result["repo_writes"]] != scenario[
941
+ "allowed_repo_writes"
942
+ ]:
943
+ return False
944
+ elif result["repo_writes"]:
945
+ return False
946
+ return True
947
+
948
+
949
+ def run_cell(
950
+ matrix: dict[str, Any],
951
+ host: dict[str, Any],
952
+ scenario: dict[str, Any],
953
+ *,
954
+ live: bool,
955
+ host_version: str,
956
+ model: str,
957
+ auth_file: Path | None,
958
+ host_artifacts: Path | None,
959
+ per_run_cost: Decimal,
960
+ fake_fault: str | None,
961
+ ) -> dict[str, Any]:
962
+ reasons = frozen_reasons()
963
+ run_root = Path(tempfile.mkdtemp(prefix="devrites-agent-contract-")).resolve()
964
+ project = run_root / "project"
965
+ config = run_root / "config"
966
+ state = run_root / "state"
967
+ home = run_root / "home"
968
+ scratch = run_root / "scratch"
969
+ tmp = run_root / "tmp"
970
+ guard = run_root / "guard"
971
+ for path in (config, state, home, scratch, tmp, guard):
972
+ path.mkdir(mode=0o700)
973
+ (guard / "sentinel").write_text("unchanged\n", encoding="utf-8")
974
+
975
+ if auth_file is None:
976
+ auth_file = state / f"{host['id']}.auth"
977
+ auth_file.write_text("fake-scoped-credential\n", encoding="utf-8")
978
+ auth_file.chmod(0o600)
979
+ base_env = {
980
+ "PATH": os.environ.get("PATH", "/usr/bin:/bin"),
981
+ "LANG": "C",
982
+ "LC_ALL": "C",
983
+ "HOME": str(home),
984
+ "TMPDIR": str(tmp),
985
+ "XDG_CONFIG_HOME": str(config),
986
+ "XDG_STATE_HOME": str(state),
987
+ }
988
+ try:
989
+ seed_project(project, live, host_artifacts, base_env)
990
+ packet = make_packet(matrix, host, scenario, project, scratch)
991
+ packet_path = scratch / "agent-packet.yaml"
992
+ result_path = scratch / "agent-result.yaml"
993
+ write_transport(packet_path, packet)
994
+
995
+ before_project = tree_snapshot(project)
996
+ before_guard = tree_snapshot(guard)
997
+ env = {
998
+ **base_env,
999
+ "DEVRITES_ROOT": str(project),
1000
+ "DEVRITES_AGENT_SCRATCH": str(scratch),
1001
+ "DEVRITES_RUN_ID": packet["run_id"],
1002
+ "DEVRITES_CONTRACT_RUN_ROOT": str(run_root),
1003
+ "DEVRITES_CONTRACT_PACKET": str(packet_path),
1004
+ "DEVRITES_CONTRACT_RESULT": str(result_path),
1005
+ "DEVRITES_CONTRACT_HOST": host["id"],
1006
+ "DEVRITES_CONTRACT_HOST_VERSION_PIN": host_version,
1007
+ "DEVRITES_CONTRACT_MODEL": model,
1008
+ "DEVRITES_CONTRACT_AUTH_FILE": str(auth_file),
1009
+ "DEVRITES_CONTRACT_MAX_COST_USD": str(per_run_cost),
1010
+ }
1011
+ if not live:
1012
+ env["DEVRITES_CONTRACT_FAKE_HOST"] = str(FAKE_HOST)
1013
+ env["DEVRITES_CONTRACT_FAKE_VERSION"] = host["fake_version"]
1014
+ if fake_fault and fake_fault.startswith(packet["cell_id"] + ":"):
1015
+ env["DEVRITES_CONTRACT_FAKE_FAULT"] = fake_fault.rsplit(":", 1)[1]
1016
+
1017
+ launcher = ROOT / host["launcher"]
1018
+ if live and scenario["simulate"] == "interrupted":
1019
+ proc_live = subprocess.Popen(
1020
+ [str(launcher), "run"],
1021
+ env=env,
1022
+ stdout=subprocess.DEVNULL,
1023
+ stderr=subprocess.DEVNULL,
1024
+ start_new_session=True,
1025
+ )
1026
+ time.sleep(2)
1027
+ if proc_live.poll() is None:
1028
+ os.killpg(proc_live.pid, signal.SIGINT)
1029
+ try:
1030
+ exit_code = proc_live.wait(timeout=15)
1031
+ except subprocess.TimeoutExpired:
1032
+ os.killpg(proc_live.pid, signal.SIGKILL)
1033
+ proc_live.wait()
1034
+ exit_code = 130
1035
+ else:
1036
+ try:
1037
+ proc = subprocess.run(
1038
+ [str(launcher), "run"],
1039
+ env=env,
1040
+ stdout=subprocess.DEVNULL,
1041
+ stderr=subprocess.DEVNULL,
1042
+ timeout=matrix["budget"]["timeout_seconds"],
1043
+ check=False,
1044
+ start_new_session=True,
1045
+ )
1046
+ exit_code = proc.returncode
1047
+ except subprocess.TimeoutExpired:
1048
+ exit_code = 130
1049
+
1050
+ after_project = tree_snapshot(project)
1051
+ actual_delta = tree_delta(before_project, after_project)
1052
+ if before_guard != tree_snapshot(guard):
1053
+ result = synthetic_reconcile(
1054
+ host,
1055
+ scenario,
1056
+ exit_code,
1057
+ "DRV-AGENT-RESULT-OUT-OF-SCOPE",
1058
+ "complete",
1059
+ host_version,
1060
+ model,
1061
+ live=live,
1062
+ )
1063
+ elif not result_path.exists():
1064
+ result = synthetic_reconcile(
1065
+ host,
1066
+ scenario,
1067
+ exit_code,
1068
+ "DRV-AGENT-UNAVAILABLE",
1069
+ "missing",
1070
+ host_version,
1071
+ model,
1072
+ live=live,
1073
+ )
1074
+ else:
1075
+ try:
1076
+ raw = load_json(result_path)
1077
+ result = validate_result(
1078
+ raw,
1079
+ packet,
1080
+ host,
1081
+ scenario,
1082
+ exit_code,
1083
+ actual_delta,
1084
+ host_version,
1085
+ model,
1086
+ reasons,
1087
+ live,
1088
+ )
1089
+ except ContractError as exc:
1090
+ sentinel = {
1091
+ "DRV-AGENT-RESULT-STALE": "stale",
1092
+ "DRV-AGENT-RESULT-MALFORMED": "malformed",
1093
+ "DRV-AGENT-RESULT-IDENTITY-MISMATCH": "malformed",
1094
+ "DRV-AGENT-RESULT-OUT-OF-SCOPE": "malformed",
1095
+ }.get(exc.reason_id, "malformed")
1096
+ result = synthetic_reconcile(
1097
+ host,
1098
+ scenario,
1099
+ exit_code,
1100
+ exc.reason_id,
1101
+ sentinel,
1102
+ host_version,
1103
+ model,
1104
+ live=live,
1105
+ identity_matched=exc.reason_id
1106
+ != "DRV-AGENT-RESULT-IDENTITY-MISMATCH",
1107
+ baseline_matched=exc.reason_id != "DRV-AGENT-RESULT-STALE",
1108
+ )
1109
+ if (
1110
+ scenario["simulate"] == "interrupted"
1111
+ and result["result_reason_id"] == "DRV-AGENT-UNAVAILABLE"
1112
+ ):
1113
+ result["status"] = "partial"
1114
+ result["terminal_sentinel"] = "interrupted"
1115
+ result["signals"] = sorted(
1116
+ MANDATORY_SIGNALS | {"progress_recorded", "partial_metadata"}
1117
+ )
1118
+ cost = usage_cost(result["usage"])
1119
+ if cost is not None and cost > per_run_cost:
1120
+ result["passed"] = False
1121
+ result["result_reason_id"] = "DRV-AGENT-UNAVAILABLE"
1122
+ return result
1123
+ result["passed"] = result_matches_expected(result, scenario)
1124
+ return result
1125
+ finally:
1126
+ shutil.rmtree(run_root, ignore_errors=True)
1127
+
1128
+
1129
+ def assert_private_metadata(value: Any) -> None:
1130
+ def walk(item: Any) -> None:
1131
+ if isinstance(item, dict):
1132
+ if FORBIDDEN_RESULT_KEYS.intersection(item):
1133
+ raise ContractError("summary contains forbidden retention fields")
1134
+ for key, child in item.items():
1135
+ if isinstance(key, str):
1136
+ if key.lower() in FORBIDDEN_RESULT_KEYS:
1137
+ raise ContractError(
1138
+ "summary contains a forbidden retention key"
1139
+ )
1140
+ if private_string_fault(key):
1141
+ raise ContractError("summary contains a private metadata key")
1142
+ walk(child)
1143
+ elif isinstance(item, list):
1144
+ for child in item:
1145
+ walk(child)
1146
+ elif isinstance(item, str):
1147
+ fault = private_string_fault(item)
1148
+ if fault:
1149
+ raise ContractError(f"summary contains a private {fault}")
1150
+
1151
+ walk(value)
1152
+
1153
+
1154
+ def write_summary(results_dir: Path | None, summary: dict[str, Any]) -> None:
1155
+ assert_private_metadata(summary)
1156
+ encoded = json.dumps(summary, sort_keys=True, indent=2) + "\n"
1157
+ if results_dir is None:
1158
+ sys.stdout.write(encoded)
1159
+ return
1160
+ results_dir.mkdir(parents=True, exist_ok=True)
1161
+ target = results_dir / "summary.json"
1162
+ temp = results_dir / ".summary.json.tmp"
1163
+ temp.write_text(encoded, encoding="utf-8")
1164
+ temp.chmod(0o600)
1165
+ os.replace(temp, target)
1166
+ sys.stdout.write(encoded)
1167
+
1168
+
1169
+ def write_ledger(path: Path, month: str, spent: Decimal) -> None:
1170
+ updated = {"schema": BUDGET_SCHEMA, "month": month, "spent_usd": str(spent)}
1171
+ temp = path.with_name(path.name + ".tmp")
1172
+ temp.write_bytes(canonical_json(updated) + b"\n")
1173
+ temp.chmod(0o600)
1174
+ os.replace(temp, path)
1175
+
1176
+
1177
+ def locked_ledger(path: Path) -> tuple[int, Path, dict[str, Any]]:
1178
+ lock = path.with_name(path.name + ".lock")
1179
+ try:
1180
+ descriptor = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
1181
+ except FileExistsError as exc:
1182
+ raise ContractError(
1183
+ "budget ledger is locked by another run", "DRV-AGENT-UNAVAILABLE"
1184
+ ) from exc
1185
+ try:
1186
+ return descriptor, lock, load_budget_ledger(path)
1187
+ except Exception:
1188
+ os.close(descriptor)
1189
+ lock.unlink(missing_ok=True)
1190
+ raise
1191
+
1192
+
1193
+ def reserve_budget(path: Path, monthly: Decimal, amount: Decimal) -> None:
1194
+ descriptor, lock, ledger = locked_ledger(path)
1195
+ try:
1196
+ spent = money(ledger["spent_usd"], "ledger spent", allow_zero=True)
1197
+ if spent + amount > monthly:
1198
+ raise ContractError(
1199
+ "the full matrix exceeds the monthly ceiling", "DRV-AGENT-UNAVAILABLE"
1200
+ )
1201
+ write_ledger(path, ledger["month"], spent + amount)
1202
+ finally:
1203
+ os.close(descriptor)
1204
+ lock.unlink(missing_ok=True)
1205
+
1206
+
1207
+ def settle_budget(path: Path, reserved: Decimal, actual: Decimal | None) -> None:
1208
+ descriptor, lock, ledger = locked_ledger(path)
1209
+ try:
1210
+ spent = money(ledger["spent_usd"], "ledger spent", allow_zero=True)
1211
+ settled = reserved if actual is None else actual
1212
+ write_ledger(
1213
+ path, ledger["month"], max(Decimal("0"), spent - reserved + settled)
1214
+ )
1215
+ finally:
1216
+ os.close(descriptor)
1217
+ lock.unlink(missing_ok=True)
1218
+
1219
+
1220
+ def parse_args() -> argparse.Namespace:
1221
+ parser = argparse.ArgumentParser(description=__doc__)
1222
+ mode = parser.add_mutually_exclusive_group()
1223
+ mode.add_argument(
1224
+ "--fake",
1225
+ action="store_true",
1226
+ help="run the deterministic fake-host matrix (default)",
1227
+ )
1228
+ mode.add_argument(
1229
+ "--live",
1230
+ action="store_true",
1231
+ help="run paid host CLIs after strict manual preflight",
1232
+ )
1233
+ parser.add_argument("--matrix", type=Path, default=DEFAULT_MATRIX)
1234
+ parser.add_argument("--results-dir", type=Path)
1235
+ parser.add_argument("--preflight-only", action="store_true")
1236
+ parser.add_argument("--confirm-live")
1237
+ parser.add_argument(
1238
+ "--host-version", action="append", default=[], metavar="HOST=VERSION"
1239
+ )
1240
+ parser.add_argument(
1241
+ "--host-model", action="append", default=[], metavar="HOST=MODEL"
1242
+ )
1243
+ parser.add_argument("--auth-file", action="append", default=[], metavar="HOST=PATH")
1244
+ parser.add_argument("--host-artifacts", type=Path)
1245
+ parser.add_argument("--max-cost-per-run-usd")
1246
+ parser.add_argument("--max-monthly-cost-usd")
1247
+ parser.add_argument("--budget-ledger", type=Path)
1248
+ parser.add_argument(
1249
+ "--fake-fault",
1250
+ help="fake-only fault injection as HOST:SCENARIO:identity-mismatch|escape-write|privacy-leak|cost-overrun",
1251
+ )
1252
+ return parser.parse_args()
1253
+
1254
+
1255
+ def main() -> int:
1256
+ args = parse_args()
1257
+ try:
1258
+ matrix = validate_matrix(load_json(args.matrix))
1259
+ matrix_digest = sha256_bytes(canonical_json(matrix))
1260
+ host_rows = {row["id"]: row for row in matrix["hosts"]}
1261
+ cells = len(matrix["hosts"]) * len(matrix["scenarios"])
1262
+ live = bool(args.live)
1263
+ live_info: dict[str, dict[str, str]] = {}
1264
+ ledger_path: Path | None = None
1265
+ monthly = Decimal("0")
1266
+ if live:
1267
+ if args.results_dir is None:
1268
+ raise ContractError(
1269
+ "live mode requires --results-dir", "DRV-AGENT-UNAVAILABLE"
1270
+ )
1271
+ live_info, per_run, monthly, ledger_path = live_preflight(args, matrix)
1272
+ else:
1273
+ if any(
1274
+ [
1275
+ args.confirm_live,
1276
+ args.host_version,
1277
+ args.host_model,
1278
+ args.auth_file,
1279
+ args.host_artifacts,
1280
+ args.max_cost_per_run_usd,
1281
+ args.max_monthly_cost_usd,
1282
+ args.budget_ledger,
1283
+ ]
1284
+ ):
1285
+ raise ContractError("live-only options cannot be used in fake mode")
1286
+ per_run = money(
1287
+ matrix["budget"]["estimated_max_cost_usd_per_cell"],
1288
+ "matrix estimated cost",
1289
+ )
1290
+ for host_id, row in host_rows.items():
1291
+ actual = launcher_version(
1292
+ row,
1293
+ True,
1294
+ "version-mismatch"
1295
+ if args.fake_fault == f"{host_id}:version:version-mismatch"
1296
+ else None,
1297
+ )
1298
+ if actual != row["fake_version"]:
1299
+ raise ContractError(
1300
+ "fake host version pin mismatch", "DRV-AGENT-UNAVAILABLE"
1301
+ )
1302
+ live_info[host_id] = {
1303
+ "version": actual,
1304
+ "model": f"{host_id}-contract-fake",
1305
+ "auth_file": "",
1306
+ }
1307
+
1308
+ if args.preflight_only:
1309
+ summary = {
1310
+ "schema": PREFLIGHT_SCHEMA,
1311
+ "mode": "live" if live else "fake",
1312
+ "matrix_digest": matrix_digest,
1313
+ "cells": cells,
1314
+ "hosts": [
1315
+ {
1316
+ "host": host_id,
1317
+ "version": live_info[host_id]["version"],
1318
+ "model": live_info[host_id]["model"],
1319
+ }
1320
+ for host_id in sorted(host_rows)
1321
+ ],
1322
+ "max_live_cost_usd": str(per_run * Decimal(cells)),
1323
+ "monthly_ceiling_usd": str(monthly) if live else "0",
1324
+ "ready": True,
1325
+ }
1326
+ write_summary(args.results_dir, summary)
1327
+ return 0
1328
+
1329
+ results: list[dict[str, Any]] = []
1330
+ reservation = per_run * Decimal(cells)
1331
+ if live and ledger_path is not None:
1332
+ reserve_budget(ledger_path, monthly, reservation)
1333
+ for host in matrix["hosts"]:
1334
+ info = live_info[host["id"]]
1335
+ auth = Path(info["auth_file"]) if live else None
1336
+ for scenario in matrix["scenarios"]:
1337
+ result = run_cell(
1338
+ matrix,
1339
+ host,
1340
+ scenario,
1341
+ live=live,
1342
+ host_version=info["version"],
1343
+ model=info["model"],
1344
+ auth_file=auth,
1345
+ host_artifacts=args.host_artifacts,
1346
+ per_run_cost=per_run,
1347
+ fake_fault=args.fake_fault,
1348
+ )
1349
+ results.append(result)
1350
+
1351
+ observed_costs = [usage_cost(row["usage"]) for row in results]
1352
+ total_cost = (
1353
+ sum((cost for cost in observed_costs if cost is not None), Decimal("0"))
1354
+ if all(cost is not None for cost in observed_costs)
1355
+ else None
1356
+ )
1357
+ summary = {
1358
+ "schema": SUMMARY_SCHEMA,
1359
+ "mode": "live" if live else "fake",
1360
+ "matrix_digest": matrix_digest,
1361
+ "cells_total": len(results),
1362
+ "cells_passed": sum(1 for row in results if row["passed"]),
1363
+ "cells_failed": sum(1 for row in results if not row["passed"]),
1364
+ "cost_usd": str(total_cost) if total_cost is not None else None,
1365
+ "reason_counts": dict(
1366
+ sorted(Counter(row["result_reason_id"] for row in results).items())
1367
+ ),
1368
+ "results": results,
1369
+ }
1370
+ write_summary(args.results_dir, summary)
1371
+ if live and ledger_path is not None:
1372
+ settle_budget(ledger_path, reservation, total_cost)
1373
+ return 0 if summary["cells_failed"] == 0 else 1
1374
+ except ContractError as exc:
1375
+ sys.stderr.write(f"agent-contract preflight failed: {exc.reason_id}: {exc}\n")
1376
+ return 2
1377
+
1378
+
1379
+ if __name__ == "__main__":
1380
+ sys.exit(main())