okstra 0.130.4 → 0.131.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 (365) hide show
  1. package/README.md +0 -1
  2. package/docs/architecture.md +2 -2
  3. package/docs/cli.md +1 -1
  4. package/docs/for-ai/README.md +0 -2
  5. package/docs/for-ai/skills/okstra-run.md +13 -2
  6. package/docs/for-ai/skills/okstra-setup.md +4 -1
  7. package/docs/project-structure-overview.md +5 -4
  8. package/docs/task-process/implementation-planning.md +13 -0
  9. package/docs/task-process/implementation.md +17 -2
  10. package/package.json +1 -1
  11. package/runtime/BUILD.json +2 -2
  12. package/runtime/agents/workers/report-writer-worker.md +7 -0
  13. package/runtime/bin/lib/okstra/usage.sh +4 -4
  14. package/runtime/prompts/lead/okstra-lead-contract.md +2 -2
  15. package/runtime/prompts/lead/plan-body-verification.md +26 -5
  16. package/runtime/prompts/lead/report-writer.md +6 -2
  17. package/runtime/prompts/profiles/_common-contract.md +2 -1
  18. package/runtime/prompts/profiles/_implementation-deliverable.md +6 -1
  19. package/runtime/prompts/profiles/_implementation-executor.md +2 -2
  20. package/runtime/prompts/profiles/_implementation-verifier.md +33 -10
  21. package/runtime/prompts/profiles/final-verification.md +21 -6
  22. package/runtime/prompts/profiles/implementation-planning.md +16 -4
  23. package/runtime/python/okstra_ctl/codex_dispatch.py +31 -0
  24. package/runtime/python/okstra_ctl/conformance.py +49 -11
  25. package/runtime/python/okstra_ctl/report_finalize.py +52 -1
  26. package/runtime/python/okstra_ctl/run.py +15 -3
  27. package/runtime/python/okstra_ctl/worker_prompt_contract.py +14 -6
  28. package/runtime/python/okstra_ctl/worker_prompt_policy.py +19 -1
  29. package/runtime/python/okstra_vendor/__init__.py +0 -44
  30. package/runtime/schemas/final-report-v1.0.schema.json +99 -5
  31. package/runtime/skills/okstra-run/SKILL.md +11 -4
  32. package/runtime/skills/okstra-setup/SKILL.md +3 -2
  33. package/runtime/skills/okstra-setup/references/project-config.md +16 -7
  34. package/runtime/templates/reports/final-report.template.md +15 -9
  35. package/runtime/templates/reports/i18n/en.json +1 -0
  36. package/runtime/templates/reports/i18n/ko.json +1 -0
  37. package/runtime/validators/lib/fixtures.sh +6 -0
  38. package/runtime/validators/validate-run.py +1722 -98
  39. package/src/cli-registry.mjs +0 -7
  40. package/src/commands/lifecycle/doctor.mjs +0 -9
  41. package/src/lib/skill-catalog.mjs +0 -1
  42. package/docs/for-ai/skills/okstra-graphify.md +0 -64
  43. package/runtime/python/okstra_ctl/graphify_cmd.py +0 -225
  44. package/runtime/python/okstra_vendor/graphify/.vendored-version +0 -1
  45. package/runtime/python/okstra_vendor/graphify/__init__.py +0 -28
  46. package/runtime/python/okstra_vendor/graphify/__main__.py +0 -1371
  47. package/runtime/python/okstra_vendor/graphify/analyze.py +0 -540
  48. package/runtime/python/okstra_vendor/graphify/benchmark.py +0 -129
  49. package/runtime/python/okstra_vendor/graphify/build.py +0 -107
  50. package/runtime/python/okstra_vendor/graphify/cache.py +0 -169
  51. package/runtime/python/okstra_vendor/graphify/cluster.py +0 -137
  52. package/runtime/python/okstra_vendor/graphify/detect.py +0 -510
  53. package/runtime/python/okstra_vendor/graphify/export.py +0 -1014
  54. package/runtime/python/okstra_vendor/graphify/extract.py +0 -3277
  55. package/runtime/python/okstra_vendor/graphify/hooks.py +0 -220
  56. package/runtime/python/okstra_vendor/graphify/ingest.py +0 -297
  57. package/runtime/python/okstra_vendor/graphify/manifest.py +0 -4
  58. package/runtime/python/okstra_vendor/graphify/report.py +0 -175
  59. package/runtime/python/okstra_vendor/graphify/security.py +0 -203
  60. package/runtime/python/okstra_vendor/graphify/serve.py +0 -373
  61. package/runtime/python/okstra_vendor/graphify/skill-aider.md +0 -1184
  62. package/runtime/python/okstra_vendor/graphify/skill-claw.md +0 -1184
  63. package/runtime/python/okstra_vendor/graphify/skill-codex.md +0 -1242
  64. package/runtime/python/okstra_vendor/graphify/skill-copilot.md +0 -1268
  65. package/runtime/python/okstra_vendor/graphify/skill-droid.md +0 -1239
  66. package/runtime/python/okstra_vendor/graphify/skill-kiro.md +0 -1183
  67. package/runtime/python/okstra_vendor/graphify/skill-opencode.md +0 -1238
  68. package/runtime/python/okstra_vendor/graphify/skill-trae.md +0 -1208
  69. package/runtime/python/okstra_vendor/graphify/skill-vscode.md +0 -253
  70. package/runtime/python/okstra_vendor/graphify/skill-windows.md +0 -1245
  71. package/runtime/python/okstra_vendor/graphify/skill.md +0 -1319
  72. package/runtime/python/okstra_vendor/graphify/transcribe.py +0 -182
  73. package/runtime/python/okstra_vendor/graphify/validate.py +0 -72
  74. package/runtime/python/okstra_vendor/graphify/watch.py +0 -188
  75. package/runtime/python/okstra_vendor/graphify/wiki.py +0 -214
  76. package/runtime/python/okstra_vendor/networkx/__init__.py +0 -62
  77. package/runtime/python/okstra_vendor/networkx/algorithms/__init__.py +0 -134
  78. package/runtime/python/okstra_vendor/networkx/algorithms/approximation/__init__.py +0 -26
  79. package/runtime/python/okstra_vendor/networkx/algorithms/approximation/clique.py +0 -259
  80. package/runtime/python/okstra_vendor/networkx/algorithms/approximation/clustering_coefficient.py +0 -71
  81. package/runtime/python/okstra_vendor/networkx/algorithms/approximation/connectivity.py +0 -412
  82. package/runtime/python/okstra_vendor/networkx/algorithms/approximation/density.py +0 -396
  83. package/runtime/python/okstra_vendor/networkx/algorithms/approximation/distance_measures.py +0 -150
  84. package/runtime/python/okstra_vendor/networkx/algorithms/approximation/dominating_set.py +0 -149
  85. package/runtime/python/okstra_vendor/networkx/algorithms/approximation/kcomponents.py +0 -369
  86. package/runtime/python/okstra_vendor/networkx/algorithms/approximation/matching.py +0 -44
  87. package/runtime/python/okstra_vendor/networkx/algorithms/approximation/maxcut.py +0 -143
  88. package/runtime/python/okstra_vendor/networkx/algorithms/approximation/ramsey.py +0 -53
  89. package/runtime/python/okstra_vendor/networkx/algorithms/approximation/steinertree.py +0 -265
  90. package/runtime/python/okstra_vendor/networkx/algorithms/approximation/traveling_salesman.py +0 -1508
  91. package/runtime/python/okstra_vendor/networkx/algorithms/approximation/treewidth.py +0 -255
  92. package/runtime/python/okstra_vendor/networkx/algorithms/approximation/vertex_cover.py +0 -83
  93. package/runtime/python/okstra_vendor/networkx/algorithms/assortativity/__init__.py +0 -5
  94. package/runtime/python/okstra_vendor/networkx/algorithms/assortativity/connectivity.py +0 -122
  95. package/runtime/python/okstra_vendor/networkx/algorithms/assortativity/correlation.py +0 -302
  96. package/runtime/python/okstra_vendor/networkx/algorithms/assortativity/mixing.py +0 -255
  97. package/runtime/python/okstra_vendor/networkx/algorithms/assortativity/neighbor_degree.py +0 -160
  98. package/runtime/python/okstra_vendor/networkx/algorithms/assortativity/pairs.py +0 -127
  99. package/runtime/python/okstra_vendor/networkx/algorithms/asteroidal.py +0 -164
  100. package/runtime/python/okstra_vendor/networkx/algorithms/bipartite/__init__.py +0 -88
  101. package/runtime/python/okstra_vendor/networkx/algorithms/bipartite/basic.py +0 -322
  102. package/runtime/python/okstra_vendor/networkx/algorithms/bipartite/centrality.py +0 -290
  103. package/runtime/python/okstra_vendor/networkx/algorithms/bipartite/cluster.py +0 -289
  104. package/runtime/python/okstra_vendor/networkx/algorithms/bipartite/covering.py +0 -57
  105. package/runtime/python/okstra_vendor/networkx/algorithms/bipartite/edgelist.py +0 -360
  106. package/runtime/python/okstra_vendor/networkx/algorithms/bipartite/extendability.py +0 -105
  107. package/runtime/python/okstra_vendor/networkx/algorithms/bipartite/generators.py +0 -603
  108. package/runtime/python/okstra_vendor/networkx/algorithms/bipartite/link_analysis.py +0 -316
  109. package/runtime/python/okstra_vendor/networkx/algorithms/bipartite/matching.py +0 -590
  110. package/runtime/python/okstra_vendor/networkx/algorithms/bipartite/matrix.py +0 -232
  111. package/runtime/python/okstra_vendor/networkx/algorithms/bipartite/projection.py +0 -526
  112. package/runtime/python/okstra_vendor/networkx/algorithms/bipartite/redundancy.py +0 -112
  113. package/runtime/python/okstra_vendor/networkx/algorithms/bipartite/spectral.py +0 -69
  114. package/runtime/python/okstra_vendor/networkx/algorithms/boundary.py +0 -168
  115. package/runtime/python/okstra_vendor/networkx/algorithms/bridges.py +0 -205
  116. package/runtime/python/okstra_vendor/networkx/algorithms/broadcasting.py +0 -164
  117. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/__init__.py +0 -20
  118. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/betweenness.py +0 -591
  119. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/betweenness_subset.py +0 -236
  120. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/closeness.py +0 -282
  121. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/current_flow_betweenness.py +0 -364
  122. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/current_flow_betweenness_subset.py +0 -227
  123. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/current_flow_closeness.py +0 -96
  124. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/degree_alg.py +0 -150
  125. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/dispersion.py +0 -107
  126. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/eigenvector.py +0 -357
  127. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/flow_matrix.py +0 -130
  128. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/group.py +0 -787
  129. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/harmonic.py +0 -88
  130. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/katz.py +0 -331
  131. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/laplacian.py +0 -150
  132. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/load.py +0 -200
  133. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/percolation.py +0 -128
  134. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/reaching.py +0 -209
  135. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/second_order.py +0 -141
  136. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/subgraph_alg.py +0 -361
  137. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/trophic.py +0 -181
  138. package/runtime/python/okstra_vendor/networkx/algorithms/centrality/voterank_alg.py +0 -95
  139. package/runtime/python/okstra_vendor/networkx/algorithms/chains.py +0 -172
  140. package/runtime/python/okstra_vendor/networkx/algorithms/chordal.py +0 -443
  141. package/runtime/python/okstra_vendor/networkx/algorithms/clique.py +0 -818
  142. package/runtime/python/okstra_vendor/networkx/algorithms/cluster.py +0 -732
  143. package/runtime/python/okstra_vendor/networkx/algorithms/coloring/__init__.py +0 -4
  144. package/runtime/python/okstra_vendor/networkx/algorithms/coloring/equitable_coloring.py +0 -505
  145. package/runtime/python/okstra_vendor/networkx/algorithms/coloring/greedy_coloring.py +0 -565
  146. package/runtime/python/okstra_vendor/networkx/algorithms/communicability_alg.py +0 -163
  147. package/runtime/python/okstra_vendor/networkx/algorithms/community/__init__.py +0 -28
  148. package/runtime/python/okstra_vendor/networkx/algorithms/community/asyn_fluid.py +0 -153
  149. package/runtime/python/okstra_vendor/networkx/algorithms/community/bipartitions.py +0 -354
  150. package/runtime/python/okstra_vendor/networkx/algorithms/community/centrality.py +0 -171
  151. package/runtime/python/okstra_vendor/networkx/algorithms/community/community_utils.py +0 -30
  152. package/runtime/python/okstra_vendor/networkx/algorithms/community/divisive.py +0 -216
  153. package/runtime/python/okstra_vendor/networkx/algorithms/community/kclique.py +0 -79
  154. package/runtime/python/okstra_vendor/networkx/algorithms/community/label_propagation.py +0 -338
  155. package/runtime/python/okstra_vendor/networkx/algorithms/community/leiden.py +0 -162
  156. package/runtime/python/okstra_vendor/networkx/algorithms/community/local.py +0 -220
  157. package/runtime/python/okstra_vendor/networkx/algorithms/community/louvain.py +0 -384
  158. package/runtime/python/okstra_vendor/networkx/algorithms/community/lukes.py +0 -227
  159. package/runtime/python/okstra_vendor/networkx/algorithms/community/modularity_max.py +0 -452
  160. package/runtime/python/okstra_vendor/networkx/algorithms/community/quality.py +0 -347
  161. package/runtime/python/okstra_vendor/networkx/algorithms/components/__init__.py +0 -6
  162. package/runtime/python/okstra_vendor/networkx/algorithms/components/attracting.py +0 -115
  163. package/runtime/python/okstra_vendor/networkx/algorithms/components/biconnected.py +0 -394
  164. package/runtime/python/okstra_vendor/networkx/algorithms/components/connected.py +0 -282
  165. package/runtime/python/okstra_vendor/networkx/algorithms/components/semiconnected.py +0 -71
  166. package/runtime/python/okstra_vendor/networkx/algorithms/components/strongly_connected.py +0 -359
  167. package/runtime/python/okstra_vendor/networkx/algorithms/components/weakly_connected.py +0 -196
  168. package/runtime/python/okstra_vendor/networkx/algorithms/connectivity/__init__.py +0 -11
  169. package/runtime/python/okstra_vendor/networkx/algorithms/connectivity/connectivity.py +0 -811
  170. package/runtime/python/okstra_vendor/networkx/algorithms/connectivity/cuts.py +0 -616
  171. package/runtime/python/okstra_vendor/networkx/algorithms/connectivity/disjoint_paths.py +0 -408
  172. package/runtime/python/okstra_vendor/networkx/algorithms/connectivity/edge_augmentation.py +0 -1270
  173. package/runtime/python/okstra_vendor/networkx/algorithms/connectivity/edge_kcomponents.py +0 -592
  174. package/runtime/python/okstra_vendor/networkx/algorithms/connectivity/kcomponents.py +0 -220
  175. package/runtime/python/okstra_vendor/networkx/algorithms/connectivity/kcutsets.py +0 -235
  176. package/runtime/python/okstra_vendor/networkx/algorithms/connectivity/stoerwagner.py +0 -152
  177. package/runtime/python/okstra_vendor/networkx/algorithms/connectivity/utils.py +0 -88
  178. package/runtime/python/okstra_vendor/networkx/algorithms/core.py +0 -588
  179. package/runtime/python/okstra_vendor/networkx/algorithms/covering.py +0 -142
  180. package/runtime/python/okstra_vendor/networkx/algorithms/cuts.py +0 -416
  181. package/runtime/python/okstra_vendor/networkx/algorithms/cycles.py +0 -1234
  182. package/runtime/python/okstra_vendor/networkx/algorithms/d_separation.py +0 -677
  183. package/runtime/python/okstra_vendor/networkx/algorithms/dag.py +0 -1392
  184. package/runtime/python/okstra_vendor/networkx/algorithms/distance_measures.py +0 -1095
  185. package/runtime/python/okstra_vendor/networkx/algorithms/distance_regular.py +0 -272
  186. package/runtime/python/okstra_vendor/networkx/algorithms/dominance.py +0 -142
  187. package/runtime/python/okstra_vendor/networkx/algorithms/dominating.py +0 -268
  188. package/runtime/python/okstra_vendor/networkx/algorithms/efficiency_measures.py +0 -167
  189. package/runtime/python/okstra_vendor/networkx/algorithms/euler.py +0 -470
  190. package/runtime/python/okstra_vendor/networkx/algorithms/flow/__init__.py +0 -11
  191. package/runtime/python/okstra_vendor/networkx/algorithms/flow/boykovkolmogorov.py +0 -370
  192. package/runtime/python/okstra_vendor/networkx/algorithms/flow/capacityscaling.py +0 -407
  193. package/runtime/python/okstra_vendor/networkx/algorithms/flow/dinitz_alg.py +0 -238
  194. package/runtime/python/okstra_vendor/networkx/algorithms/flow/edmondskarp.py +0 -241
  195. package/runtime/python/okstra_vendor/networkx/algorithms/flow/gomory_hu.py +0 -178
  196. package/runtime/python/okstra_vendor/networkx/algorithms/flow/maxflow.py +0 -611
  197. package/runtime/python/okstra_vendor/networkx/algorithms/flow/mincost.py +0 -356
  198. package/runtime/python/okstra_vendor/networkx/algorithms/flow/networksimplex.py +0 -662
  199. package/runtime/python/okstra_vendor/networkx/algorithms/flow/preflowpush.py +0 -425
  200. package/runtime/python/okstra_vendor/networkx/algorithms/flow/shortestaugmentingpath.py +0 -300
  201. package/runtime/python/okstra_vendor/networkx/algorithms/flow/utils.py +0 -194
  202. package/runtime/python/okstra_vendor/networkx/algorithms/graph_hashing.py +0 -435
  203. package/runtime/python/okstra_vendor/networkx/algorithms/graphical.py +0 -483
  204. package/runtime/python/okstra_vendor/networkx/algorithms/hierarchy.py +0 -57
  205. package/runtime/python/okstra_vendor/networkx/algorithms/hybrid.py +0 -196
  206. package/runtime/python/okstra_vendor/networkx/algorithms/isolate.py +0 -107
  207. package/runtime/python/okstra_vendor/networkx/algorithms/isomorphism/__init__.py +0 -7
  208. package/runtime/python/okstra_vendor/networkx/algorithms/isomorphism/ismags.py +0 -1306
  209. package/runtime/python/okstra_vendor/networkx/algorithms/isomorphism/isomorph.py +0 -336
  210. package/runtime/python/okstra_vendor/networkx/algorithms/isomorphism/isomorphvf2.py +0 -1262
  211. package/runtime/python/okstra_vendor/networkx/algorithms/isomorphism/matchhelpers.py +0 -352
  212. package/runtime/python/okstra_vendor/networkx/algorithms/isomorphism/temporalisomorphvf2.py +0 -308
  213. package/runtime/python/okstra_vendor/networkx/algorithms/isomorphism/tree_isomorphism.py +0 -264
  214. package/runtime/python/okstra_vendor/networkx/algorithms/isomorphism/vf2pp.py +0 -1102
  215. package/runtime/python/okstra_vendor/networkx/algorithms/isomorphism/vf2userfunc.py +0 -192
  216. package/runtime/python/okstra_vendor/networkx/algorithms/link_analysis/__init__.py +0 -2
  217. package/runtime/python/okstra_vendor/networkx/algorithms/link_analysis/hits_alg.py +0 -337
  218. package/runtime/python/okstra_vendor/networkx/algorithms/link_analysis/pagerank_alg.py +0 -498
  219. package/runtime/python/okstra_vendor/networkx/algorithms/link_prediction.py +0 -687
  220. package/runtime/python/okstra_vendor/networkx/algorithms/lowest_common_ancestors.py +0 -280
  221. package/runtime/python/okstra_vendor/networkx/algorithms/matching.py +0 -1148
  222. package/runtime/python/okstra_vendor/networkx/algorithms/minors/__init__.py +0 -27
  223. package/runtime/python/okstra_vendor/networkx/algorithms/minors/contraction.py +0 -738
  224. package/runtime/python/okstra_vendor/networkx/algorithms/mis.py +0 -78
  225. package/runtime/python/okstra_vendor/networkx/algorithms/moral.py +0 -59
  226. package/runtime/python/okstra_vendor/networkx/algorithms/node_classification.py +0 -219
  227. package/runtime/python/okstra_vendor/networkx/algorithms/non_randomness.py +0 -155
  228. package/runtime/python/okstra_vendor/networkx/algorithms/operators/__init__.py +0 -4
  229. package/runtime/python/okstra_vendor/networkx/algorithms/operators/all.py +0 -324
  230. package/runtime/python/okstra_vendor/networkx/algorithms/operators/binary.py +0 -468
  231. package/runtime/python/okstra_vendor/networkx/algorithms/operators/product.py +0 -633
  232. package/runtime/python/okstra_vendor/networkx/algorithms/operators/unary.py +0 -77
  233. package/runtime/python/okstra_vendor/networkx/algorithms/perfect_graph.py +0 -73
  234. package/runtime/python/okstra_vendor/networkx/algorithms/planar_drawing.py +0 -464
  235. package/runtime/python/okstra_vendor/networkx/algorithms/planarity.py +0 -1463
  236. package/runtime/python/okstra_vendor/networkx/algorithms/polynomials.py +0 -306
  237. package/runtime/python/okstra_vendor/networkx/algorithms/reciprocity.py +0 -98
  238. package/runtime/python/okstra_vendor/networkx/algorithms/regular.py +0 -167
  239. package/runtime/python/okstra_vendor/networkx/algorithms/richclub.py +0 -138
  240. package/runtime/python/okstra_vendor/networkx/algorithms/shortest_paths/__init__.py +0 -5
  241. package/runtime/python/okstra_vendor/networkx/algorithms/shortest_paths/astar.py +0 -239
  242. package/runtime/python/okstra_vendor/networkx/algorithms/shortest_paths/dense.py +0 -264
  243. package/runtime/python/okstra_vendor/networkx/algorithms/shortest_paths/generic.py +0 -716
  244. package/runtime/python/okstra_vendor/networkx/algorithms/shortest_paths/unweighted.py +0 -625
  245. package/runtime/python/okstra_vendor/networkx/algorithms/shortest_paths/weighted.py +0 -2542
  246. package/runtime/python/okstra_vendor/networkx/algorithms/similarity.py +0 -2107
  247. package/runtime/python/okstra_vendor/networkx/algorithms/simple_paths.py +0 -966
  248. package/runtime/python/okstra_vendor/networkx/algorithms/smallworld.py +0 -404
  249. package/runtime/python/okstra_vendor/networkx/algorithms/smetric.py +0 -30
  250. package/runtime/python/okstra_vendor/networkx/algorithms/sparsifiers.py +0 -296
  251. package/runtime/python/okstra_vendor/networkx/algorithms/structuralholes.py +0 -374
  252. package/runtime/python/okstra_vendor/networkx/algorithms/summarization.py +0 -564
  253. package/runtime/python/okstra_vendor/networkx/algorithms/swap.py +0 -406
  254. package/runtime/python/okstra_vendor/networkx/algorithms/threshold.py +0 -981
  255. package/runtime/python/okstra_vendor/networkx/algorithms/time_dependent.py +0 -142
  256. package/runtime/python/okstra_vendor/networkx/algorithms/tournament.py +0 -406
  257. package/runtime/python/okstra_vendor/networkx/algorithms/traversal/__init__.py +0 -5
  258. package/runtime/python/okstra_vendor/networkx/algorithms/traversal/beamsearch.py +0 -90
  259. package/runtime/python/okstra_vendor/networkx/algorithms/traversal/breadth_first_search.py +0 -576
  260. package/runtime/python/okstra_vendor/networkx/algorithms/traversal/depth_first_search.py +0 -529
  261. package/runtime/python/okstra_vendor/networkx/algorithms/traversal/edgebfs.py +0 -185
  262. package/runtime/python/okstra_vendor/networkx/algorithms/traversal/edgedfs.py +0 -182
  263. package/runtime/python/okstra_vendor/networkx/algorithms/tree/__init__.py +0 -7
  264. package/runtime/python/okstra_vendor/networkx/algorithms/tree/branchings.py +0 -1042
  265. package/runtime/python/okstra_vendor/networkx/algorithms/tree/coding.py +0 -413
  266. package/runtime/python/okstra_vendor/networkx/algorithms/tree/decomposition.py +0 -88
  267. package/runtime/python/okstra_vendor/networkx/algorithms/tree/distance_measures.py +0 -219
  268. package/runtime/python/okstra_vendor/networkx/algorithms/tree/mst.py +0 -1281
  269. package/runtime/python/okstra_vendor/networkx/algorithms/tree/operations.py +0 -106
  270. package/runtime/python/okstra_vendor/networkx/algorithms/tree/recognition.py +0 -273
  271. package/runtime/python/okstra_vendor/networkx/algorithms/triads.py +0 -500
  272. package/runtime/python/okstra_vendor/networkx/algorithms/vitality.py +0 -76
  273. package/runtime/python/okstra_vendor/networkx/algorithms/voronoi.py +0 -86
  274. package/runtime/python/okstra_vendor/networkx/algorithms/walks.py +0 -77
  275. package/runtime/python/okstra_vendor/networkx/algorithms/wiener.py +0 -278
  276. package/runtime/python/okstra_vendor/networkx/classes/__init__.py +0 -13
  277. package/runtime/python/okstra_vendor/networkx/classes/coreviews.py +0 -435
  278. package/runtime/python/okstra_vendor/networkx/classes/digraph.py +0 -1363
  279. package/runtime/python/okstra_vendor/networkx/classes/filters.py +0 -95
  280. package/runtime/python/okstra_vendor/networkx/classes/function.py +0 -1549
  281. package/runtime/python/okstra_vendor/networkx/classes/graph.py +0 -2082
  282. package/runtime/python/okstra_vendor/networkx/classes/graphviews.py +0 -269
  283. package/runtime/python/okstra_vendor/networkx/classes/multidigraph.py +0 -977
  284. package/runtime/python/okstra_vendor/networkx/classes/multigraph.py +0 -1294
  285. package/runtime/python/okstra_vendor/networkx/classes/reportviews.py +0 -1447
  286. package/runtime/python/okstra_vendor/networkx/convert.py +0 -502
  287. package/runtime/python/okstra_vendor/networkx/convert_matrix.py +0 -1314
  288. package/runtime/python/okstra_vendor/networkx/drawing/__init__.py +0 -7
  289. package/runtime/python/okstra_vendor/networkx/drawing/layout.py +0 -2036
  290. package/runtime/python/okstra_vendor/networkx/drawing/nx_agraph.py +0 -470
  291. package/runtime/python/okstra_vendor/networkx/drawing/nx_latex.py +0 -570
  292. package/runtime/python/okstra_vendor/networkx/drawing/nx_pydot.py +0 -361
  293. package/runtime/python/okstra_vendor/networkx/drawing/nx_pylab.py +0 -2978
  294. package/runtime/python/okstra_vendor/networkx/exception.py +0 -131
  295. package/runtime/python/okstra_vendor/networkx/generators/__init__.py +0 -34
  296. package/runtime/python/okstra_vendor/networkx/generators/atlas.dat.gz +0 -0
  297. package/runtime/python/okstra_vendor/networkx/generators/atlas.py +0 -227
  298. package/runtime/python/okstra_vendor/networkx/generators/classic.py +0 -1091
  299. package/runtime/python/okstra_vendor/networkx/generators/cographs.py +0 -68
  300. package/runtime/python/okstra_vendor/networkx/generators/community.py +0 -1070
  301. package/runtime/python/okstra_vendor/networkx/generators/degree_seq.py +0 -886
  302. package/runtime/python/okstra_vendor/networkx/generators/directed.py +0 -572
  303. package/runtime/python/okstra_vendor/networkx/generators/duplication.py +0 -174
  304. package/runtime/python/okstra_vendor/networkx/generators/ego.py +0 -66
  305. package/runtime/python/okstra_vendor/networkx/generators/expanders.py +0 -499
  306. package/runtime/python/okstra_vendor/networkx/generators/geometric.py +0 -1037
  307. package/runtime/python/okstra_vendor/networkx/generators/harary_graph.py +0 -163
  308. package/runtime/python/okstra_vendor/networkx/generators/internet_as_graphs.py +0 -443
  309. package/runtime/python/okstra_vendor/networkx/generators/intersection.py +0 -125
  310. package/runtime/python/okstra_vendor/networkx/generators/interval_graph.py +0 -70
  311. package/runtime/python/okstra_vendor/networkx/generators/joint_degree_seq.py +0 -664
  312. package/runtime/python/okstra_vendor/networkx/generators/lattice.py +0 -405
  313. package/runtime/python/okstra_vendor/networkx/generators/line.py +0 -501
  314. package/runtime/python/okstra_vendor/networkx/generators/mycielski.py +0 -110
  315. package/runtime/python/okstra_vendor/networkx/generators/nonisomorphic_trees.py +0 -259
  316. package/runtime/python/okstra_vendor/networkx/generators/random_clustered.py +0 -117
  317. package/runtime/python/okstra_vendor/networkx/generators/random_graphs.py +0 -1416
  318. package/runtime/python/okstra_vendor/networkx/generators/small.py +0 -1070
  319. package/runtime/python/okstra_vendor/networkx/generators/social.py +0 -554
  320. package/runtime/python/okstra_vendor/networkx/generators/spectral_graph_forge.py +0 -120
  321. package/runtime/python/okstra_vendor/networkx/generators/stochastic.py +0 -54
  322. package/runtime/python/okstra_vendor/networkx/generators/sudoku.py +0 -131
  323. package/runtime/python/okstra_vendor/networkx/generators/time_series.py +0 -74
  324. package/runtime/python/okstra_vendor/networkx/generators/trees.py +0 -1070
  325. package/runtime/python/okstra_vendor/networkx/generators/triads.py +0 -94
  326. package/runtime/python/okstra_vendor/networkx/lazy_imports.py +0 -188
  327. package/runtime/python/okstra_vendor/networkx/linalg/__init__.py +0 -13
  328. package/runtime/python/okstra_vendor/networkx/linalg/algebraicconnectivity.py +0 -650
  329. package/runtime/python/okstra_vendor/networkx/linalg/attrmatrix.py +0 -466
  330. package/runtime/python/okstra_vendor/networkx/linalg/bethehessianmatrix.py +0 -77
  331. package/runtime/python/okstra_vendor/networkx/linalg/graphmatrix.py +0 -168
  332. package/runtime/python/okstra_vendor/networkx/linalg/laplacianmatrix.py +0 -512
  333. package/runtime/python/okstra_vendor/networkx/linalg/modularitymatrix.py +0 -166
  334. package/runtime/python/okstra_vendor/networkx/linalg/spectrum.py +0 -186
  335. package/runtime/python/okstra_vendor/networkx/readwrite/__init__.py +0 -17
  336. package/runtime/python/okstra_vendor/networkx/readwrite/adjlist.py +0 -330
  337. package/runtime/python/okstra_vendor/networkx/readwrite/edgelist.py +0 -489
  338. package/runtime/python/okstra_vendor/networkx/readwrite/gexf.py +0 -1084
  339. package/runtime/python/okstra_vendor/networkx/readwrite/gml.py +0 -879
  340. package/runtime/python/okstra_vendor/networkx/readwrite/graph6.py +0 -427
  341. package/runtime/python/okstra_vendor/networkx/readwrite/graphml.py +0 -1053
  342. package/runtime/python/okstra_vendor/networkx/readwrite/json_graph/__init__.py +0 -19
  343. package/runtime/python/okstra_vendor/networkx/readwrite/json_graph/adjacency.py +0 -156
  344. package/runtime/python/okstra_vendor/networkx/readwrite/json_graph/cytoscape.py +0 -190
  345. package/runtime/python/okstra_vendor/networkx/readwrite/json_graph/node_link.py +0 -261
  346. package/runtime/python/okstra_vendor/networkx/readwrite/json_graph/tree.py +0 -137
  347. package/runtime/python/okstra_vendor/networkx/readwrite/leda.py +0 -108
  348. package/runtime/python/okstra_vendor/networkx/readwrite/multiline_adjlist.py +0 -393
  349. package/runtime/python/okstra_vendor/networkx/readwrite/p2g.py +0 -113
  350. package/runtime/python/okstra_vendor/networkx/readwrite/pajek.py +0 -286
  351. package/runtime/python/okstra_vendor/networkx/readwrite/sparse6.py +0 -379
  352. package/runtime/python/okstra_vendor/networkx/readwrite/text.py +0 -851
  353. package/runtime/python/okstra_vendor/networkx/relabel.py +0 -285
  354. package/runtime/python/okstra_vendor/networkx/utils/__init__.py +0 -8
  355. package/runtime/python/okstra_vendor/networkx/utils/backends.py +0 -2171
  356. package/runtime/python/okstra_vendor/networkx/utils/configs.py +0 -396
  357. package/runtime/python/okstra_vendor/networkx/utils/decorators.py +0 -1233
  358. package/runtime/python/okstra_vendor/networkx/utils/heaps.py +0 -338
  359. package/runtime/python/okstra_vendor/networkx/utils/mapped_queue.py +0 -297
  360. package/runtime/python/okstra_vendor/networkx/utils/misc.py +0 -703
  361. package/runtime/python/okstra_vendor/networkx/utils/random_sequence.py +0 -198
  362. package/runtime/python/okstra_vendor/networkx/utils/rcm.py +0 -159
  363. package/runtime/python/okstra_vendor/networkx/utils/union_find.py +0 -106
  364. package/runtime/skills/okstra-graphify/SKILL.md +0 -169
  365. package/src/commands/graphify.mjs +0 -32
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
+ import hashlib
6
7
  import importlib.util
7
8
  import json
8
9
  import os
@@ -35,6 +36,7 @@ from okstra_project import project_json_path # noqa: E402
35
36
  from okstra_project.dirs import tasks_root as _okstra_tasks_root # noqa: E402
36
37
 
37
38
  from okstra_ctl.conformance import ( # noqa: E402
39
+ CAPABILITY_WHITELIST,
38
40
  detect_surfaces,
39
41
  evaluate_conformance,
40
42
  manifest_required_surfaces,
@@ -68,7 +70,10 @@ from okstra_ctl.design_surfaces import ( # noqa: E402
68
70
  detect_design_surfaces,
69
71
  expected_prep_plan_item_id,
70
72
  )
71
- from okstra_ctl.plan_items import expected_plan_item_ids # noqa: E402
73
+ from okstra_ctl.plan_items import ( # noqa: E402
74
+ expected_plan_item_ids,
75
+ extract_plan_items,
76
+ )
72
77
  from okstra_ctl.worker_prompt_contract import ( # noqa: E402
73
78
  PromptRecord,
74
79
  validate_initial_prompt_records,
@@ -82,6 +87,33 @@ WORKER_DISPATCH_MODES = {"cli-wrapper", "mixed", "tmux-pane"}
82
87
 
83
88
  def utc_now() -> str:
84
89
  return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
90
+ # Providers dispatched through a CLI wrapper. Their token accounting lives in
91
+ # that CLI, never in the lead's session JSONL, so `usage.source: unavailable`
92
+ # is structural rather than a collection failure.
93
+ _CLI_WRAPPER_AGENTS = frozenset({"codex", "antigravity"})
94
+
95
+
96
+ def _cli_wrapper_worker_labels(team_state: dict) -> frozenset[str]:
97
+ """Token-table row labels whose `--` cells are honest.
98
+
99
+ Mirrors `scripts/okstra_token_usage/report.py` `_worker_detail_label`
100
+ (`"{role} ({agent}, {status})"`) so the match is on the rendered label the
101
+ scanner actually sees.
102
+ """
103
+ labels = set()
104
+ for worker in team_state.get("workers") or []:
105
+ if not isinstance(worker, dict):
106
+ continue
107
+ agent = str(worker.get("agent") or "").strip()
108
+ if agent not in _CLI_WRAPPER_AGENTS:
109
+ continue
110
+ role = str(worker.get("role") or worker.get("workerId") or "Worker").strip()
111
+ status = str(worker.get("status") or "").strip()
112
+ suffix = ", ".join(v for v in (agent, status) if v)
113
+ labels.add(f"{role} ({suffix})" if suffix else role)
114
+ return frozenset(labels)
115
+
116
+
85
117
  def _session_accounting(team_state: dict) -> str:
86
118
  adapter = (
87
119
  team_state.get("leadAdapter") if isinstance(team_state.get("leadAdapter"), dict) else {}
@@ -847,6 +879,7 @@ def _scan_token_usage_summary(
847
879
  failures: list[str],
848
880
  *,
849
881
  allow_unavailable_tokens: bool = False,
882
+ unavailable_ok_labels: frozenset[str] = frozenset(),
850
883
  ) -> None:
851
884
  """Reject sentinel / zero values that workers typed into the Token
852
885
  Usage Summary table instead of leaving the `{{...}}` placeholders
@@ -895,13 +928,27 @@ def _scan_token_usage_summary(
895
928
  # The CLI row's label always contains the word "CLI" — matching
896
929
  # `Codex/Antigravity CLI 추가 비용` regardless of formatting variations.
897
930
  is_cli_row = "CLI" in label_cell
931
+ # A CLI-wrapper worker's tokens are accounted inside its own CLI, not
932
+ # in the lead's session JSONL, so `--` on that row is the honest
933
+ # value — not a skipped collection. Gating it made every roster
934
+ # containing such a worker unable to pass, leaving the lead a choice
935
+ # between fabricating numbers and shipping `contract-violated`.
936
+ row_label = label_cell.lstrip("- ").strip()
937
+ unavailable_ok = allow_unavailable_tokens or row_label in unavailable_ok_labels
938
+ # One row's three cells carry the same defect, so reporting per cell
939
+ # turned two broken workers into six failures and made the real count
940
+ # unreadable. Collapse to one line per (row, sentinel).
941
+ reported_sentinels: set[str] = set()
898
942
  for raw_cell in cells[1:]:
899
943
  for value in _TOKEN_USAGE_BACKTICK_CELL_RE.findall(raw_cell):
900
944
  stripped = value.strip()
901
945
  lowered = stripped.lower()
902
- if allow_unavailable_tokens and stripped == "--":
946
+ if unavailable_ok and stripped == "--":
947
+ continue
948
+ if lowered in reported_sentinels:
903
949
  continue
904
950
  if lowered in _TOKEN_USAGE_SENTINEL_VALUES:
951
+ reported_sentinels.add(lowered)
905
952
  failures.append(
906
953
  "Token Usage Summary cell contains sentinel value "
907
954
  f"`{stripped}` on row labelled `{label_cell or '<unlabeled>'}` — "
@@ -911,6 +958,7 @@ def _scan_token_usage_summary(
911
958
  )
912
959
  continue
913
960
  if stripped in _TOKEN_USAGE_ZERO_VALUES and not is_cli_row:
961
+ reported_sentinels.add(lowered)
914
962
  failures.append(
915
963
  f"Token Usage Summary row `{label_cell or '<unlabeled>'}` has "
916
964
  f"a zero value `{stripped}` — no okstra run consumes zero "
@@ -1020,8 +1068,10 @@ _DEPRECATED_FINAL_REPORT_PATTERNS: tuple[tuple[re.Pattern, str], ...] = (
1020
1068
 
1021
1069
 
1022
1070
  def _load_conformance_results(qa_dir: Path, manifest: dict) -> dict:
1023
- """매니페스트 entry 에 대응하는 `result-<stageKey>.json` 을 로드.
1024
- 파일 부재/파손은 키를 비워 둬 미실행(None→BLOCKING)으로 흐르게 한다."""
1071
+ """Load each entry's `result-<stageKey>.json` sidecar.
1072
+
1073
+ Missing or malformed files leave the key absent so gate evaluation receives None.
1074
+ """
1025
1075
  results: dict = {}
1026
1076
  for entry in manifest.get("entries", []):
1027
1077
  key = entry.get("stageKey") if isinstance(entry, dict) else None
@@ -1033,7 +1083,7 @@ def _load_conformance_results(qa_dir: Path, manifest: dict) -> dict:
1033
1083
  try:
1034
1084
  results[key] = qa_result_from_dict(json.loads(sidecar.read_text()))
1035
1085
  except (OSError, json.JSONDecodeError):
1036
- results[key] = qa_result_from_dict(None) # → MISSING → BLOCKING
1086
+ results[key] = qa_result_from_dict(None) # → MISSING → conformance verdict
1037
1087
  return results
1038
1088
 
1039
1089
 
@@ -1052,11 +1102,12 @@ def _parse_diff_summary_files(content: str) -> list[str]:
1052
1102
  _STAGE_RUN_DIR_RE = re.compile(r"^stage-\d+$")
1053
1103
 
1054
1104
 
1055
- def _implementation_stage_name(run_dir: Path) -> str | None:
1056
- """implementation stage 격리 run(`runs/implementation/stage-<N>`)이면
1057
- `stage-<N>` 을 반환. 그 외(final-verification 등 task-type 레벨 run)는
1058
- None whole-task 스코프."""
1059
- if run_dir.parent.name == "implementation" and _STAGE_RUN_DIR_RE.match(run_dir.name):
1105
+ def _stage_isolated_name(run_dir: Path) -> str | None:
1106
+ """Return stage-<N> for implementation or final-verification stage runs."""
1107
+ if (
1108
+ run_dir.parent.name in ("implementation", "final-verification")
1109
+ and _STAGE_RUN_DIR_RE.match(run_dir.name)
1110
+ ):
1060
1111
  return run_dir.name
1061
1112
  return None
1062
1113
 
@@ -1093,6 +1144,308 @@ def _task_root_from_run_dir(run_dir: Path) -> Path:
1093
1144
  return run_dir.parent.parent
1094
1145
 
1095
1146
 
1147
+ def _read_run_inputs_payload(
1148
+ inputs_path: Path,
1149
+ failures: list[str],
1150
+ ) -> dict | None:
1151
+ """Read the exact run-inputs payload, failing closed on malformed evidence."""
1152
+ if not inputs_path.is_file():
1153
+ failures.append(
1154
+ f"conformance gate BLOCKING: approved plan evidence run inputs missing "
1155
+ f"at {inputs_path}"
1156
+ )
1157
+ return None
1158
+ try:
1159
+ payload = json.loads(inputs_path.read_text(encoding="utf-8"))
1160
+ except (OSError, json.JSONDecodeError) as exc:
1161
+ failures.append(
1162
+ f"conformance gate BLOCKING: approved plan evidence run inputs "
1163
+ f"unreadable at {inputs_path}: {exc}"
1164
+ )
1165
+ return None
1166
+ if not isinstance(payload, dict):
1167
+ failures.append(
1168
+ "conformance gate BLOCKING: approved plan evidence run inputs "
1169
+ f"payload malformed at {inputs_path}"
1170
+ )
1171
+ return None
1172
+ return payload
1173
+
1174
+
1175
+ def _approved_plan_path_from_run_inputs(
1176
+ run_manifest_path: Path,
1177
+ failures: list[str],
1178
+ ) -> Path | None:
1179
+ """Load an absolute approvedPlanPath from the exact run-inputs sibling."""
1180
+ name = run_manifest_path.name
1181
+ if not name.startswith("run-manifest-") or not name.endswith(".json"):
1182
+ failures.append(
1183
+ "conformance gate BLOCKING: approved plan evidence cannot derive "
1184
+ f"run-inputs sibling from {run_manifest_path}"
1185
+ )
1186
+ return None
1187
+ inputs_path = run_manifest_path.with_name(
1188
+ name.replace("run-manifest-", "run-inputs-", 1)
1189
+ )
1190
+ payload = _read_run_inputs_payload(inputs_path, failures)
1191
+ if payload is None:
1192
+ return None
1193
+ inputs = payload.get("inputs")
1194
+ if not isinstance(inputs, dict):
1195
+ failures.append(
1196
+ "conformance gate BLOCKING: approved plan evidence has malformed "
1197
+ f"inputs object at {inputs_path}"
1198
+ )
1199
+ return None
1200
+ raw_path = inputs.get("approvedPlanPath")
1201
+ if not isinstance(raw_path, str) or not raw_path.strip():
1202
+ failures.append(
1203
+ "conformance gate BLOCKING: approved plan evidence approvedPlanPath "
1204
+ f"missing or malformed at {inputs_path}"
1205
+ )
1206
+ return None
1207
+ path = Path(raw_path)
1208
+ if not path.is_absolute():
1209
+ failures.append(
1210
+ "conformance gate BLOCKING: approved plan evidence approvedPlanPath "
1211
+ f"must be absolute at {inputs_path}: {raw_path}"
1212
+ )
1213
+ return None
1214
+ try:
1215
+ return path.resolve()
1216
+ except (OSError, ValueError) as exc:
1217
+ failures.append(
1218
+ "conformance gate BLOCKING: approved plan evidence approvedPlanPath "
1219
+ f"malformed at {inputs_path}: {exc}"
1220
+ )
1221
+ return None
1222
+
1223
+
1224
+ def _approved_plan_stage_entries(
1225
+ stages: object,
1226
+ data_path: Path,
1227
+ failures: list[str],
1228
+ ) -> list[dict] | None:
1229
+ """Validate approved-plan stages and return conformance declarations."""
1230
+ if not isinstance(stages, list) or not stages:
1231
+ _record_malformed_plan_stages(data_path, failures)
1232
+ return None
1233
+ entries: list[dict] = []
1234
+ seen_stage_numbers: set[int] = set()
1235
+ for stage in stages:
1236
+ valid, entry = _approved_plan_stage_entry(
1237
+ stage,
1238
+ seen_stage_numbers,
1239
+ data_path,
1240
+ failures,
1241
+ )
1242
+ if not valid:
1243
+ return None
1244
+ if entry is not None:
1245
+ entries.append(entry)
1246
+ return entries
1247
+
1248
+
1249
+ def _record_malformed_plan_stages(data_path: Path, failures: list[str]) -> None:
1250
+ failures.append(
1251
+ f"conformance gate BLOCKING: approved plan evidence has malformed stages at {data_path}"
1252
+ )
1253
+
1254
+
1255
+ def _approved_plan_stage_entry(
1256
+ stage: object,
1257
+ seen_stage_numbers: set[int],
1258
+ data_path: Path,
1259
+ failures: list[str],
1260
+ ) -> tuple[bool, dict | None]:
1261
+ if not isinstance(stage, dict):
1262
+ _record_malformed_plan_stages(data_path, failures)
1263
+ return False, None
1264
+ stage_number = stage.get("stage")
1265
+ tests = stage.get("conformanceTests")
1266
+ exemption = stage.get("conformanceExemption")
1267
+ tests_present = "conformanceTests" in stage
1268
+ exemption_present = "conformanceExemption" in stage
1269
+ fields_are_strings = all(
1270
+ value is None or isinstance(value, str) for value in (tests, exemption)
1271
+ )
1272
+ has_tests = isinstance(tests, str) and bool(tests.strip())
1273
+ has_exemption = isinstance(exemption, str) and bool(exemption.strip())
1274
+ choice_is_valid = (
1275
+ tests_present != exemption_present
1276
+ and (has_tests if tests_present else has_exemption)
1277
+ )
1278
+ stage_is_valid = (
1279
+ isinstance(stage_number, int)
1280
+ and not isinstance(stage_number, bool)
1281
+ and stage_number >= 1
1282
+ and stage_number not in seen_stage_numbers
1283
+ )
1284
+ if not fields_are_strings or not stage_is_valid or not choice_is_valid:
1285
+ _record_malformed_plan_stages(data_path, failures)
1286
+ return False, None
1287
+ seen_stage_numbers.add(stage_number)
1288
+ if not has_tests:
1289
+ return True, None
1290
+ declaration = _parse_conformance_tests(tests)
1291
+ if declaration is None:
1292
+ failures.append(
1293
+ "conformance gate BLOCKING: approved plan evidence has malformed "
1294
+ f"conformanceTests for stage {stage_number} at {data_path}"
1295
+ )
1296
+ return False, None
1297
+ script, requires = declaration
1298
+ return True, {
1299
+ "stageKey": f"approved-plan-stage-{stage_number}",
1300
+ "script": script,
1301
+ "requires": sorted(requires),
1302
+ }
1303
+
1304
+
1305
+ _CONFORMANCE_TESTS_RE = re.compile(
1306
+ r"^(?P<script>\S+)\s+\(requires=\[(?P<requires>[^\]]+)\]\)$"
1307
+ )
1308
+
1309
+
1310
+ def _normalize_conformance_script(script: str) -> str:
1311
+ prefix = "<task_root>/"
1312
+ return script[len(prefix):] if script.startswith(prefix) else script
1313
+
1314
+
1315
+ def _parse_conformance_tests(value: object) -> tuple[str, frozenset[str]] | None:
1316
+ """Parse `<script> (requires=[cap,...])` from a stage declaration."""
1317
+ if not isinstance(value, str):
1318
+ return None
1319
+ match = _CONFORMANCE_TESTS_RE.fullmatch(value.strip())
1320
+ if match is None:
1321
+ return None
1322
+ script = _normalize_conformance_script(match.group("script"))
1323
+ capabilities = [part.strip() for part in match.group("requires").split(",")]
1324
+ if (
1325
+ not script
1326
+ or any(not capability for capability in capabilities)
1327
+ or len(set(capabilities)) != len(capabilities)
1328
+ or any(capability not in CAPABILITY_WHITELIST for capability in capabilities)
1329
+ ):
1330
+ return None
1331
+ return script, frozenset(capabilities)
1332
+
1333
+
1334
+ def _approved_plan_conformance_manifest(
1335
+ approved_plan_path: Path,
1336
+ task_root: Path,
1337
+ failures: list[str],
1338
+ ) -> dict | None:
1339
+ """Represent approved-plan conformance declarations as stage-key entries."""
1340
+ if not approved_plan_path.is_file():
1341
+ failures.append(
1342
+ f"conformance gate BLOCKING: approved plan evidence missing at {approved_plan_path}"
1343
+ )
1344
+ return None
1345
+ data_path = _data_path_for(approved_plan_path)
1346
+ if not data_path.is_file():
1347
+ failures.append(
1348
+ f"conformance gate BLOCKING: approved plan evidence missing at {data_path}"
1349
+ )
1350
+ return None
1351
+ resolved_data_path = data_path.resolve()
1352
+ if not resolved_data_path.is_relative_to(task_root.resolve()):
1353
+ failures.append(
1354
+ "conformance gate BLOCKING: approved plan data evidence resolves "
1355
+ f"outside current task root {task_root}: {resolved_data_path}"
1356
+ )
1357
+ return None
1358
+ data_path = resolved_data_path
1359
+ try:
1360
+ data = json.loads(data_path.read_text(encoding="utf-8"))
1361
+ except (OSError, json.JSONDecodeError) as exc:
1362
+ failures.append(
1363
+ f"conformance gate BLOCKING: approved plan evidence unreadable at {data_path}: {exc}"
1364
+ )
1365
+ return None
1366
+ planning = data.get("implementationPlanning") if isinstance(data, dict) else None
1367
+ stages = planning.get("stages") if isinstance(planning, dict) else None
1368
+ entries = _approved_plan_stage_entries(stages, data_path, failures)
1369
+ return {"entries": entries} if entries is not None else None
1370
+
1371
+
1372
+ def _declared_conformance_errors(
1373
+ declared_manifest: dict,
1374
+ actual_manifest: dict,
1375
+ stage_name: str | None,
1376
+ ) -> list[str]:
1377
+ """Compare scoped plan declarations with their one actual manifest entry."""
1378
+ declared = _scope_manifest_entries(declared_manifest, stage_name).get("entries", [])
1379
+ actual = _scope_manifest_entries(actual_manifest, stage_name).get("entries", [])
1380
+ errors: list[str] = []
1381
+ declared_stage_numbers = {
1382
+ str(entry.get("stageKey") or "").rsplit("-stage-", 1)[-1]
1383
+ for entry in declared
1384
+ if isinstance(entry, dict)
1385
+ }
1386
+ for declaration in declared:
1387
+ stage_number = str(declaration.get("stageKey") or "").rsplit("-stage-", 1)[-1]
1388
+ matches = [
1389
+ entry for entry in actual
1390
+ if isinstance(entry, dict)
1391
+ and str(entry.get("stageKey") or "").endswith(f"-stage-{stage_number}")
1392
+ ]
1393
+ if not matches:
1394
+ errors.append(f"stage {stage_number} has no matching entry")
1395
+ continue
1396
+ if len(matches) > 1:
1397
+ errors.append(f"stage {stage_number} has multiple matching entries")
1398
+ continue
1399
+ actual_entry = matches[0]
1400
+ actual_script = _normalize_conformance_script(str(actual_entry.get("script") or ""))
1401
+ if actual_script != declaration.get("script"):
1402
+ errors.append(f"stage {stage_number} script mismatch")
1403
+ actual_requires = actual_entry.get("requires")
1404
+ actual_capabilities = (
1405
+ frozenset(actual_requires)
1406
+ if isinstance(actual_requires, list)
1407
+ and all(isinstance(value, str) for value in actual_requires)
1408
+ else None
1409
+ )
1410
+ if actual_capabilities != frozenset(declaration.get("requires") or []):
1411
+ errors.append(f"stage {stage_number} requires mismatch")
1412
+ for actual_entry in actual:
1413
+ if not isinstance(actual_entry, dict):
1414
+ continue
1415
+ stage_number = str(actual_entry.get("stageKey") or "").rsplit("-stage-", 1)[-1]
1416
+ if stage_number not in declared_stage_numbers:
1417
+ errors.append(
1418
+ f"stage {stage_number} actual manifest entry is not declared by approved plan"
1419
+ )
1420
+ return errors
1421
+
1422
+
1423
+ def _planning_conformance_declarations(
1424
+ stages: object,
1425
+ failures: list[str],
1426
+ ) -> list[dict]:
1427
+ declarations: list[dict] = []
1428
+ for stage in stages if isinstance(stages, list) else []:
1429
+ if not isinstance(stage, dict) or not str(stage.get("conformanceTests") or "").strip():
1430
+ continue
1431
+ parsed = _parse_conformance_tests(stage.get("conformanceTests"))
1432
+ if parsed is None:
1433
+ failures.append(
1434
+ "final-report data.json: stage "
1435
+ f"{stage.get('stage')} has malformed conformanceTests declaration."
1436
+ )
1437
+ continue
1438
+ script, requires = parsed
1439
+ declarations.append(
1440
+ {
1441
+ "stageKey": f"approved-plan-stage-{stage.get('stage')}",
1442
+ "script": script,
1443
+ "requires": sorted(requires),
1444
+ }
1445
+ )
1446
+ return declarations
1447
+
1448
+
1096
1449
  def _validate_planning_conformance_declared(report_path: Path, failures: list[str]) -> None:
1097
1450
  """H4-c — at planning time, every stage that DECLARES `Conformance tests:`
1098
1451
  must already carry a matching entry in the shared task-level
@@ -1113,52 +1466,72 @@ def _validate_planning_conformance_declared(report_path: Path, failures: list[st
1113
1466
  ip = data.get("implementationPlanning")
1114
1467
  if not isinstance(ip, dict):
1115
1468
  return
1116
- declaring_stages = [
1117
- s.get("stage")
1118
- for s in (ip.get("stages") or [])
1119
- if isinstance(s, dict) and str(s.get("conformanceTests") or "").strip()
1120
- ]
1121
- if not declaring_stages:
1469
+ declarations = _planning_conformance_declarations(ip.get("stages"), failures)
1470
+ if not declarations:
1122
1471
  return
1123
1472
  task_root = _task_root_from_run_dir(report_path.parent.parent)
1124
1473
  manifest_path = task_root / "qa" / "conformance-manifest.json"
1125
1474
  entries = []
1126
1475
  if manifest_path.is_file():
1127
1476
  try:
1128
- entries = json.loads(manifest_path.read_text()).get("entries") or []
1477
+ manifest = json.loads(manifest_path.read_text())
1478
+ entries = manifest.get("entries") or [] if isinstance(manifest, dict) else []
1129
1479
  except (OSError, json.JSONDecodeError):
1130
1480
  entries = []
1131
- for stage_number in declaring_stages:
1132
- suffix = f"-stage-{stage_number}"
1133
- has_entry = any(
1134
- isinstance(e, dict) and str(e.get("stageKey") or "").endswith(suffix)
1135
- for e in entries
1481
+ for error in _declared_conformance_errors(
1482
+ {"entries": declarations},
1483
+ {"entries": entries},
1484
+ None,
1485
+ ):
1486
+ failures.append(
1487
+ f"final-report data.json: conformance declaration {error} in "
1488
+ f"{manifest_path}. Emitting `Conformance tests:` MUST also write "
1489
+ "one matching manifest entry (implementation-planning.md §Conformance)."
1136
1490
  )
1137
- if not has_entry:
1138
- failures.append(
1139
- f"final-report data.json: stage {stage_number} declares "
1140
- "`Conformance tests:` but no matching entry exists in "
1141
- f"{manifest_path} (stageKey ending `{suffix}`). Emitting the "
1142
- "declaration MUST also write the manifest entry + script "
1143
- "(implementation-planning.md §Conformance)."
1144
- )
1145
1491
 
1146
1492
 
1147
- def _validate_conformance(report_path: Path, failures: list[str],
1148
- surface_patterns: object = None) -> None:
1493
+ def _validate_conformance_surfaces(
1494
+ report_path: Path,
1495
+ scoped_manifest: dict,
1496
+ surface_patterns: object,
1497
+ failures: list[str],
1498
+ ) -> None:
1499
+ changed_files = _parse_diff_summary_files(report_path.read_text(encoding="utf-8"))
1500
+ if not changed_files:
1501
+ return
1502
+ uncovered = (
1503
+ detect_surfaces(changed_files, surface_patterns)
1504
+ - manifest_required_surfaces(scoped_manifest)
1505
+ )
1506
+ if uncovered:
1507
+ failures.append(
1508
+ "conformance gate BLOCKING: implementation diff touches undeclared "
1509
+ f"surface(s) {sorted(uncovered)} — no in-scope stage declares "
1510
+ "`requires` for them. Declare a conformance entry (requires=[...]) "
1511
+ "for the touching stage. "
1512
+ "(silent mock-green 방지 — DEV-9184)"
1513
+ )
1514
+
1515
+
1516
+ def _validate_conformance(
1517
+ report_path: Path,
1518
+ failures: list[str],
1519
+ surface_patterns: object = None,
1520
+ approved_plan_path: Path | None = None,
1521
+ ) -> list[str]:
1149
1522
  """Tier 3 conformance 게이트(implementation / final-verification).
1150
1523
 
1151
- `<task_root>/qa/conformance-manifest.json` 없으면 inert(선언된 conformance
1152
- 없다는 선언을 강제하는 것은 planning 계약(Phase 4)의 몫). 매니페스트가
1153
- 있으면 결과 사이드카와 함께 evaluate_conformance 판정하고 BLOCKING verdict
1154
- run 검증 실패로 승격한다. WAIVED(conditional)/EXEMPT 통과시킨다.
1524
+ A missing task-level manifest is inert only without approved-plan evidence.
1525
+ When a recorded approved plan declares conformance for the in-scope stage,
1526
+ disappearance of that manifest is a blocking structural failure. Otherwise,
1527
+ evaluate the manifest and result sidecars normally: BLOCKING verdicts fail
1528
+ validation, ADVISORY verdicts become user follow-up warnings, and permitted
1529
+ WAIVED/EXEMPT verdicts pass.
1155
1530
 
1156
- 게이트 스코프: implementation stage 격리 run 자기 stage entry 만(결과
1157
- 게이트·diff-surface 교차검증 모두 미래 stage 미실행이 현재 run 을
1158
- 막으면 안 된다), final-verification 등 task-type 레벨 run 은 전 entry
1159
- (whole-task). prompts/profiles/_implementation-verifier.md §Tier 3 /
1160
- final-verification.md 의 스코프 계약과 동형.
1531
+ Stage-isolated implementation and final-verification runs evaluate only their
1532
+ own stage suffix. Whole-task runs evaluate every entry.
1161
1533
  """
1534
+ warnings: list[str] = []
1162
1535
  # conformance 산출물은 task-level(<task_root>/qa)에 있어 planning/
1163
1536
  # implementation/final-verification 가 공유한다. report_path 는
1164
1537
  # task_root/runs/<task-type>/reports/final-report.md (implementation 은
@@ -1169,38 +1542,98 @@ def _validate_conformance(report_path: Path, failures: list[str],
1169
1542
  task_root = _task_root_from_run_dir(run_dir)
1170
1543
  qa_dir = task_root / "qa"
1171
1544
  manifest_path = qa_dir / "conformance-manifest.json"
1545
+ stage_name = _stage_isolated_name(run_dir)
1546
+ declared_manifest = None
1547
+ if approved_plan_path is not None:
1548
+ resolved_plan_path = approved_plan_path.resolve()
1549
+ if not resolved_plan_path.is_relative_to(task_root.resolve()):
1550
+ failures.append(
1551
+ "conformance gate BLOCKING: approved plan evidence resolves "
1552
+ f"outside current task root {task_root}: {resolved_plan_path}"
1553
+ )
1554
+ return warnings
1555
+ declared_manifest = _approved_plan_conformance_manifest(
1556
+ resolved_plan_path,
1557
+ task_root,
1558
+ failures,
1559
+ )
1560
+ if declared_manifest is None:
1561
+ return warnings
1172
1562
  if not manifest_path.is_file():
1173
- return
1563
+ empty_scoped_manifest = {"entries": []}
1564
+ if declared_manifest is not None:
1565
+ for error in _declared_conformance_errors(
1566
+ declared_manifest,
1567
+ empty_scoped_manifest,
1568
+ stage_name,
1569
+ ):
1570
+ failures.append(
1571
+ f"conformance gate BLOCKING: approved plan {error}; "
1572
+ f"{manifest_path} is absent"
1573
+ )
1574
+ _validate_conformance_surfaces(
1575
+ report_path,
1576
+ empty_scoped_manifest,
1577
+ surface_patterns,
1578
+ failures,
1579
+ )
1580
+ return warnings
1174
1581
  try:
1175
1582
  manifest = json.loads(manifest_path.read_text())
1176
1583
  except (OSError, json.JSONDecodeError) as exc:
1177
1584
  failures.append(f"conformance manifest unreadable at {manifest_path}: {exc}")
1178
- return
1585
+ return warnings
1179
1586
  schema_errors = validate_conformance_manifest(manifest)
1180
1587
  if schema_errors:
1181
1588
  failures.extend(f"conformance manifest: {e}" for e in schema_errors)
1182
- return
1183
- scoped = _scope_manifest_entries(manifest, _implementation_stage_name(run_dir))
1589
+ return warnings
1590
+ if declared_manifest is not None:
1591
+ for error in _declared_conformance_errors(
1592
+ declared_manifest,
1593
+ manifest,
1594
+ stage_name,
1595
+ ):
1596
+ failures.append(
1597
+ f"conformance gate BLOCKING: approved plan {error} "
1598
+ f"in {manifest_path}"
1599
+ )
1600
+ scoped = _scope_manifest_entries(manifest, stage_name)
1601
+ entries_by_key = {
1602
+ entry.get("stageKey"): entry
1603
+ for entry in scoped.get("entries", [])
1604
+ if isinstance(entry, dict) and isinstance(entry.get("stageKey"), str)
1605
+ }
1606
+ for stage_key, entry in entries_by_key.items():
1607
+ if entry.get("requires") == [] and entry.get("waiver"):
1608
+ failures.append(
1609
+ f"conformance gate BLOCKING for stage {stage_key}: "
1610
+ "requires=[] is declaration/contract trouble and cannot be waived"
1611
+ )
1184
1612
  results = _load_conformance_results(qa_dir, scoped)
1185
1613
  for verdict in evaluate_conformance(scoped, results):
1186
- if not verdict.ok:
1614
+ if verdict.status == "ADVISORY":
1615
+ entry = entries_by_key.get(verdict.stage_key, {})
1616
+ warnings.append(
1617
+ f"conformance advisory for stage {verdict.stage_key}: "
1618
+ f"{verdict.message}; requires={entry.get('requires') or []}; "
1619
+ f"user-owned follow-up command: "
1620
+ f"{entry.get('runCommand') or '(missing)'}; "
1621
+ f"manifest: {manifest_path}"
1622
+ )
1623
+ elif not verdict.ok:
1187
1624
  failures.append(
1188
1625
  f"conformance gate BLOCKING for stage {verdict.stage_key}: "
1189
1626
  f"{verdict.message}. Run the stage's conformance script (or declare "
1190
1627
  f"an exemption / user waiver) — see "
1191
1628
  f"docs/superpowers/specs/2026-06-07-stage-conformance-qa-design.md."
1192
1629
  )
1193
- changed_files = _parse_diff_summary_files(report_path.read_text(encoding="utf-8"))
1194
- if changed_files:
1195
- uncovered = detect_surfaces(changed_files, surface_patterns) - manifest_required_surfaces(scoped)
1196
- if uncovered:
1197
- failures.append(
1198
- "conformance gate BLOCKING: implementation diff touches undeclared "
1199
- f"surface(s) {sorted(uncovered)} — no in-scope stage declares "
1200
- "`requires` for them. Declare a conformance entry (requires=[...]) "
1201
- "for the touching stage, or an explicit exemption. "
1202
- "(silent mock-green 방지 — DEV-9184)"
1203
- )
1630
+ _validate_conformance_surfaces(
1631
+ report_path,
1632
+ scoped,
1633
+ surface_patterns,
1634
+ failures,
1635
+ )
1636
+ return warnings
1204
1637
 
1205
1638
 
1206
1639
  def _check_incremental_audit_block(
@@ -1247,6 +1680,7 @@ def validate_report(
1247
1680
  failures: list[str],
1248
1681
  *,
1249
1682
  allow_unavailable_token_usage: bool = False,
1683
+ unavailable_ok_labels: frozenset[str] = frozenset(),
1250
1684
  ) -> None:
1251
1685
  if not report_path.exists():
1252
1686
  failures.append(f"final report is missing: {report_path}")
@@ -1272,6 +1706,7 @@ def validate_report(
1272
1706
  content,
1273
1707
  failures,
1274
1708
  allow_unavailable_tokens=allow_unavailable_token_usage,
1709
+ unavailable_ok_labels=unavailable_ok_labels,
1275
1710
  )
1276
1711
 
1277
1712
  # Verdict Card is mandatory in every final-report (introduced with the
@@ -1355,14 +1790,24 @@ _WORKER_RESULT_BASENAME_RE = re.compile(
1355
1790
  r"^(?P<worker>[a-z][a-z0-9-]*-worker)-(?P<task_type>[a-z][a-z-]*?)-(?P<seq>\d{3})\.md$"
1356
1791
  )
1357
1792
 
1793
+ _REPORT_BASENAME_SEQ_RE = re.compile(r"-(?P<seq>\d{3})(?:\.data)?\.(?:md|json)$")
1794
+
1795
+
1796
+ def _report_run_seq(report_path: Path) -> str | None:
1797
+ """This run's seq, read off `final-report-<task-type>-<seq>.md`. ``None``
1798
+ when the name does not carry one, so callers fall back to not filtering
1799
+ rather than silently checking nothing."""
1800
+ match = _REPORT_BASENAME_SEQ_RE.search(report_path.name)
1801
+ return match.group("seq") if match else None
1802
+
1358
1803
 
1359
1804
  def validate_worker_results_audit(
1360
1805
  report_path: Path, task_type: str, failures: list[str]
1361
1806
  ) -> None:
1362
1807
  """Enforce the worker audit sidecar contract.
1363
1808
 
1364
- For every `worker-results/<worker>-<task-type>-<seq>.md` produced by a
1365
- worker (skipping the audit sidecar itself), the validator checks:
1809
+ For every `worker-results/<worker>-<task-type>-<seq>.md` **this run**
1810
+ produced (skipping the audit sidecar itself), the validator checks:
1366
1811
 
1367
1812
  1. The main worker-results file does NOT contain a `## 0. Reading
1368
1813
  Confirmation` heading. That block moved to the audit sidecar with
@@ -1370,6 +1815,13 @@ def validate_worker_results_audit(
1370
1815
  2. The matching audit sidecar exists at
1371
1816
  `<worker>-audit-<task-type>-<seq>.md`. Missing sidecar means the
1372
1817
  worker silently skipped the reading-confirmation step.
1818
+
1819
+ Scoped to this run's seq. `worker-results/` accumulates every run's
1820
+ artifacts, so scanning the whole directory judged a run by files it did
1821
+ not produce: a task that once opted a worker in and later dropped it from
1822
+ the roster failed forever on that worker's old sidecar, with no legitimate
1823
+ remedy — the lead can neither fabricate a sidecar for a worker it never
1824
+ dispatched nor delete a prior run's audit record.
1373
1825
  """
1374
1826
  # `report_path` is `runs/<task-type>/reports/final-report-...md`; the
1375
1827
  # sibling `worker-results/` directory holds every worker artifact.
@@ -1379,6 +1831,8 @@ def validate_worker_results_audit(
1379
1831
  # `release-handoff` which is single-lead). Nothing to enforce.
1380
1832
  return
1381
1833
 
1834
+ run_seq = _report_run_seq(report_path)
1835
+
1382
1836
  for path in sorted(worker_results_dir.glob("*.md")):
1383
1837
  name = path.name
1384
1838
  if "-audit-" in name:
@@ -1392,6 +1846,9 @@ def validate_worker_results_audit(
1392
1846
  # Cross-phase artifacts shouldn't appear here; skip rather
1393
1847
  # than fail to keep the check focused on the current phase.
1394
1848
  continue
1849
+ if run_seq is not None and match.group("seq") != run_seq:
1850
+ # A prior run's artifact. Its contract was judged when it ran.
1851
+ continue
1395
1852
 
1396
1853
  worker_role = match.group("worker")
1397
1854
  seq = match.group("seq")
@@ -1847,13 +2304,25 @@ def validate_final_report_data(
1847
2304
  _validate_unverified_critic_gaps_recorded(data, failures)
1848
2305
 
1849
2306
  task_type = (data.get("header") or {}).get("taskType")
2307
+ _validate_verifier_fail_blocks_verdict(data, failures)
2308
+ if task_type == "implementation":
2309
+ _validate_stage_carry_sidecar_exists(data, report_path, failures)
1850
2310
  if task_type == "final-verification":
1851
2311
  _validate_final_verification_consistency(data, failures)
2312
+ _validate_verified_row_recorded(data, report_path, failures)
1852
2313
  elif task_type == "implementation-planning":
1853
2314
  active_report_contracts = report_contracts or set()
1854
2315
  _validate_implementation_planning_cross_project(data, failures)
1855
2316
  _validate_implementation_planning_decision_drafts(data, failures)
1856
2317
  _validate_plan_body_gate_recompute(data, failures)
2318
+ _validate_gate_blocked_by(data, failures)
2319
+ _validate_supersession_ledger(data, failures)
2320
+ _validate_self_fix_grouping(data, failures)
2321
+ _validate_clarification_evidence_note(data, failures)
2322
+ _validate_plan_body_verdict_provenance(data, report_path, failures)
2323
+ _validate_aborted_gate_has_clarification(data, failures)
2324
+ _validate_round_recorded_verdicts(data, failures)
2325
+ _validate_verdicts_match_current_subjects(data, failures)
1857
2326
  _validate_plan_item_extraction_completeness(data, failures)
1858
2327
  _validate_plan_item_subject_substance(data, failures)
1859
2328
  _validate_plan_body_clarification_matching(data, failures)
@@ -2041,6 +2510,11 @@ _PLAN_GATE_RANK = {
2041
2510
  # is prone to planning-vs-implementation environment false positives.
2042
2511
  _SINGLE_VOTE_BLOCKING_KINDS = {"a", "d"}
2043
2512
 
2513
+ # Stop reasons that justify promoting a still-broken planner-fixable item to the
2514
+ # user: the self-fix budget ran out, or a round produced no net resolution so
2515
+ # further rounds would repeat themselves.
2516
+ _SELF_FIX_EXHAUSTED_REASONS = frozenset({"max-rounds-reached", "no-progress"})
2517
+
2044
2518
 
2045
2519
  def _classify_plan_item_gate(item: dict) -> str:
2046
2520
  """Recompute one plan item's gate class from its per-worker verdicts,
@@ -2089,12 +2563,74 @@ def _classify_plan_item_gate(item: dict) -> str:
2089
2563
  return "has-dissent"
2090
2564
 
2091
2565
 
2566
+ def _disagree_breakage_kinds(item: dict) -> set[str]:
2567
+ return {
2568
+ str(v.get("breakageKind") or "").strip().lower()
2569
+ for v in (item.get("verdicts") or [])
2570
+ if isinstance(v, dict)
2571
+ and str(v.get("verdict") or "").strip().upper() == "DISAGREE"
2572
+ and str(v.get("breakageKind") or "").strip()
2573
+ }
2574
+
2575
+
2576
+ def _has_planner_fixable_majority(item: dict) -> bool:
2577
+ disagrees = [
2578
+ v
2579
+ for v in (item.get("verdicts") or [])
2580
+ if isinstance(v, dict) and str(v.get("verdict") or "").upper() == "DISAGREE"
2581
+ ]
2582
+ fixable = [v for v in disagrees if v.get("fixability") == "planner-fixable"]
2583
+ return bool(disagrees) and len(fixable) * 2 > len(disagrees)
2584
+
2585
+
2586
+ def _is_correctness_critical(item: dict) -> bool:
2587
+ """Whether this item's defect would make `implementation` produce wrong or
2588
+ unsafe code — the single-vote-blocking kinds `a` (cited path/symbol
2589
+ mismatch) and `d` (rollback violates commit order) on any item, or `f`
2590
+ (requirement-coverage mismatch) on a `P-Req-*` item. Kinds `b`/`c`/`e` are
2591
+ plan-prose defects: they degrade the document, not the resulting code.
2592
+ """
2593
+ kinds = _disagree_breakage_kinds(item)
2594
+ is_req = str(item.get("id") or "").upper().startswith("P-REQ")
2595
+ return bool(kinds & _SINGLE_VOTE_BLOCKING_KINDS) or (is_req and "f" in kinds)
2596
+
2597
+
2598
+ def _self_fix_budget_exhausted(pbv: dict) -> bool:
2599
+ rounds_applied = pbv.get("selfFixRoundsApplied")
2600
+ return (
2601
+ isinstance(rounds_applied, int)
2602
+ and rounds_applied >= 1
2603
+ and pbv.get("selfFixStopReason") in _SELF_FIX_EXHAUSTED_REASONS
2604
+ )
2605
+
2606
+
2607
+ def _is_dissent_downgraded(item: dict, pbv: dict) -> bool:
2608
+ """Whether a surviving `majority-disagree` item stops blocking approval.
2609
+
2610
+ A planner-fixable defect the planner failed to fix is still a *planner*
2611
+ defect; promoting it to a `Blocks=approval` row asks the user to proofread
2612
+ the plan document. Once the self-fix budget is exhausted, such an item is
2613
+ recorded as a Working Assumption in `## 5. Missing Information and Risks`
2614
+ instead of blocking the gate. Defects that would make the implementation
2615
+ itself wrong or unsafe (`_is_correctness_critical`) are excluded and keep
2616
+ blocking, so correctness never trades away for throughput.
2617
+ """
2618
+ return (
2619
+ _classify_plan_item_gate(item) == "majority-disagree"
2620
+ and not _is_correctness_critical(item)
2621
+ and _has_planner_fixable_majority(item)
2622
+ and _self_fix_budget_exhausted(pbv)
2623
+ )
2624
+
2625
+
2092
2626
  def _recompute_plan_body_gate(pbv: dict) -> str | None:
2093
2627
  """Recompute the whole §5.5.9 gate value from ``planItems[].verdicts``.
2094
2628
  Returns a value in ``PLAN_VERIFY_GATE_VALUES`` or ``None`` when there are
2095
2629
  no plan items to judge (disabled / empty round)."""
2096
2630
  classes = [
2097
- _classify_plan_item_gate(it)
2631
+ "has-dissent"
2632
+ if _is_dissent_downgraded(it, pbv)
2633
+ else _classify_plan_item_gate(it)
2098
2634
  for it in (pbv.get("planItems") or [])
2099
2635
  if isinstance(it, dict)
2100
2636
  ]
@@ -2137,11 +2673,80 @@ def _validate_plan_body_gate_recompute(data: dict, failures: list[str]) -> None:
2137
2673
  )
2138
2674
 
2139
2675
 
2140
- def _validate_plan_item_extraction_completeness(
2141
- data: dict,
2142
- failures: list[str],
2143
- ) -> None:
2144
- """Require the exact deterministic P-* extraction when a round ran."""
2676
+ def _cited_clarification_id(row: dict) -> str | None:
2677
+ """The `C-NNN` a coverage row's `status` / `approvalDisposition` cites."""
2678
+ for field in ("status", "approvalDisposition"):
2679
+ value = str(row.get(field) or "").strip()
2680
+ if value.startswith("blocked "):
2681
+ return value.split(" ", 1)[1].strip()
2682
+ return None
2683
+
2684
+
2685
+ def _blocks_approval(row: dict) -> bool:
2686
+ """Whether one Requirement Coverage row blocks approval on its face, per
2687
+ `prompts/profiles/implementation-planning.md` §"Requirement Coverage": a
2688
+ `gap`, a plain `blocked C-NNN`, or a deviation whose approval disposition
2689
+ is blocked.
2690
+ """
2691
+ status = str(row.get("status") or "").strip()
2692
+ if status == "gap" or status.startswith("blocked C-"):
2693
+ return True
2694
+ disposition = str(row.get("approvalDisposition") or "").strip()
2695
+ return status == "documented-deviation" and disposition.startswith("blocked C-")
2696
+
2697
+
2698
+ def _plan_body_promoted_clarification_ids(pbv: dict) -> set[str]:
2699
+ """`C-NNN` ids this run's own plan-body round created by promoting a
2700
+ majority-disagree item (step 8). Used to break the Requirement Coverage
2701
+ ↔ Clarification cycle: a coverage row citing one of these echoes a blocker
2702
+ the gate already counted, rather than contributing an independent one.
2703
+ """
2704
+ return {
2705
+ item["clarificationId"].strip()
2706
+ for item in (pbv.get("planItems") or [])
2707
+ if isinstance(item, dict)
2708
+ and isinstance(item.get("clarificationId"), str)
2709
+ and item["clarificationId"].strip()
2710
+ }
2711
+
2712
+
2713
+ def _independent_coverage_blockers(ip: dict, pbv: dict) -> list[str]:
2714
+ """Coverage rows that block the gate on their own — excluding rows whose
2715
+ blocker is a `C-NNN` this same run's plan-body round promoted."""
2716
+ promoted = _plan_body_promoted_clarification_ids(pbv)
2717
+ return [
2718
+ str(row.get("id") or "<unknown>")
2719
+ for row in (ip.get("requirementCoverage") or [])
2720
+ if isinstance(row, dict)
2721
+ and _blocks_approval(row)
2722
+ and _cited_clarification_id(row) not in promoted
2723
+ ]
2724
+
2725
+
2726
+ def _gate_blocking_causes(pbv: dict, coverage_blockers: list[str]) -> set[str]:
2727
+ """Which inputs actually block approval, as `gateBlockedBy` enum values."""
2728
+ causes = set()
2729
+ recomputed = _recompute_plan_body_gate(pbv)
2730
+ if recomputed == "blocked-by-disagreement":
2731
+ causes.add("majority-disagree")
2732
+ elif recomputed == "aborted-non-result":
2733
+ causes.add("non-result")
2734
+ if coverage_blockers:
2735
+ causes.add("coverage-gap")
2736
+ return causes
2737
+
2738
+
2739
+ def _validate_gate_blocked_by(data: dict, failures: list[str]) -> None:
2740
+ """The gate value names an *outcome*; `gateBlockedBy` names the *cause*.
2741
+
2742
+ Two independent inputs can block approval — a `majority-disagree` plan item
2743
+ and a Requirement Coverage `gap` / `blocked C-NNN` row — and before this
2744
+ check both surfaced as the single value `blocked-by-disagreement`. A
2745
+ coverage-only block therefore asserted a worker disagreement that never
2746
+ happened, sending the reader hunting for a dissent that does not exist.
2747
+ `gateBlockedBy` records which input actually fired, and this check makes the
2748
+ coverage rule enforceable rather than prose-only.
2749
+ """
2145
2750
  ip = data.get("implementationPlanning")
2146
2751
  if not isinstance(ip, dict):
2147
2752
  return
@@ -2151,23 +2756,978 @@ def _validate_plan_item_extraction_completeness(
2151
2756
  round_count = pbv.get("roundCount")
2152
2757
  if not isinstance(round_count, int) or round_count < 1:
2153
2758
  return
2154
- try:
2155
- expected_sequence = expected_plan_item_ids(ip)
2156
- except Exception as exc: # noqa: BLE001
2759
+
2760
+ declared_gate = str(pbv.get("gateResult") or "").strip().lower()
2761
+ declared_causes = {
2762
+ str(c).strip()
2763
+ for c in (pbv.get("gateBlockedBy") or [])
2764
+ if isinstance(c, str) and str(c).strip()
2765
+ }
2766
+ coverage_blockers = _independent_coverage_blockers(ip, pbv)
2767
+ actual_causes = _gate_blocking_causes(pbv, coverage_blockers)
2768
+
2769
+ if actual_causes and declared_gate in ("passed", "passed-with-dissent"):
2157
2770
  failures.append(
2158
- "final-report data.json: deterministic plan-item extraction failed: "
2159
- f"{exc}"
2771
+ "final-report data.json: implementationPlanning.planBodyVerification "
2772
+ f"`gateResult` is `{declared_gate}` but "
2773
+ f"{sorted(actual_causes)} blocks approval "
2774
+ f"(coverage rows: {coverage_blockers or 'none'}). A Requirement "
2775
+ "Coverage `gap` / `blocked C-NNN` row blocks the gate independently "
2776
+ "of the worker verdicts (implementation-planning.md "
2777
+ '§"Requirement Coverage").'
2160
2778
  )
2161
2779
  return
2162
2780
 
2163
- actual_sequence = [
2164
- str(item.get("id") or "").strip()
2165
- for item in (pbv.get("planItems") or [])
2166
- if isinstance(item, dict)
2167
- ]
2168
- expected_ids = set(expected_sequence)
2169
- actual_ids = set(actual_sequence)
2170
- missing = expected_ids - actual_ids
2781
+ if declared_causes != actual_causes:
2782
+ failures.append(
2783
+ "final-report data.json: implementationPlanning.planBodyVerification "
2784
+ f"`gateBlockedBy` is {sorted(declared_causes)} but the recorded "
2785
+ f"inputs support {sorted(actual_causes)} (coverage rows blocking "
2786
+ f"independently: {coverage_blockers or 'none'}). Every blocking "
2787
+ "input must be named so the reader is not sent looking for a "
2788
+ "worker disagreement that never happened "
2789
+ '(plan-body-verification.md §"Round protocol" step 5).'
2790
+ )
2791
+
2792
+
2793
+ _EVIDENCE_NOTE_RE = re.compile(r"Evidence checked:\s*(?P<body>.+?)(?:\.\s|\.$|$)", re.S)
2794
+ _EVIDENCE_NONE_RE = re.compile(r"^none\s*[—-]\s*\S")
2795
+ # `path/to/file.ext:123`, the form the codebase-first rule asks the row to cite.
2796
+ _EVIDENCE_PATH_LINE_RE = re.compile(r"\S+\.\w+:\d+")
2797
+
2798
+
2799
+ def _validate_clarification_evidence_note(data: dict, failures: list[str]) -> None:
2800
+ """Every clarification row must show its codebase-first work.
2801
+
2802
+ The profile requires any ambiguity answerable by `Read` / `Grep` to be
2803
+ resolved that way, and calls a row for something the code already answers a
2804
+ defect of the phase — but nothing checked it, so rows the codebase could
2805
+ have settled reached the user as approval blockers. Requiring the
2806
+ `Evidence checked:` note to exist and be well-formed does not prove the
2807
+ lookup happened; it makes its absence a failure and a false `none` an
2808
+ attributable claim rather than a silent omission.
2809
+ """
2810
+ if (data.get("header") or {}).get("taskType") != "implementation-planning":
2811
+ return
2812
+ for row in data.get("clarificationItems") or []:
2813
+ if not isinstance(row, dict):
2814
+ continue
2815
+ row_id = str(row.get("id") or "<unknown>")
2816
+ statement = str(row.get("statement") or "")
2817
+ match = _EVIDENCE_NOTE_RE.search(statement)
2818
+ if not match:
2819
+ failures.append(
2820
+ f"final-report data.json: clarification `{row_id}` has no "
2821
+ "`Evidence checked:` note in its `statement`. Every row must "
2822
+ "record the codebase lookup that failed to settle it — either "
2823
+ "`Evidence checked: <path:line>` or `Evidence checked: none — "
2824
+ "<human-only reason>` (implementation-planning.md "
2825
+ '§"Evidence note required inside `Statement`").'
2826
+ )
2827
+ continue
2828
+ body = match.group("body").strip()
2829
+ if _EVIDENCE_NONE_RE.match(body) or _EVIDENCE_PATH_LINE_RE.search(body):
2830
+ continue
2831
+ failures.append(
2832
+ f"final-report data.json: clarification `{row_id}` has a malformed "
2833
+ f"`Evidence checked:` note ({body[:60]!r}). It must cite a concrete "
2834
+ "`<path>:<line>` or use the form `none — <human-only reason>`; a "
2835
+ "bare assertion records no lookup."
2836
+ )
2837
+
2838
+
2839
+ def _validate_self_fix_grouping(data: dict, failures: list[str]) -> None:
2840
+ """A self-fix round must be instructed by cause, not as a flat item list.
2841
+
2842
+ Blocked items are usually several derivatives of one defect. Instructed
2843
+ item-by-item, each patch corrects its own section and leaves the sibling
2844
+ sections still asserting the old value, so the next round re-finds the same
2845
+ family and the budget drains without converging. Recording the grouping
2846
+ makes the lead commit to a diagnosis and makes a one-group-per-item
2847
+ non-diagnosis visible in the artifact rather than invisible in a prompt.
2848
+
2849
+ Recording rounds here also ties `selfFixRoundsApplied` to work that exists
2850
+ in the data: it was a free-floating self-reported integer, yet
2851
+ `_validate_self_fix_before_clarification` gates promotion on its value.
2852
+ """
2853
+ ip = data.get("implementationPlanning")
2854
+ if not isinstance(ip, dict):
2855
+ return
2856
+ pbv = ip.get("planBodyVerification")
2857
+ if not isinstance(pbv, dict):
2858
+ return
2859
+ rounds_applied = pbv.get("selfFixRoundsApplied")
2860
+ if not isinstance(rounds_applied, int) or rounds_applied < 1:
2861
+ return
2862
+
2863
+ groups = [g for g in (pbv.get("selfFixGroups") or []) if isinstance(g, dict)]
2864
+ if not groups:
2865
+ failures.append(
2866
+ "final-report data.json: planBodyVerification declares "
2867
+ f"`selfFixRoundsApplied`={rounds_applied} but records no "
2868
+ "`selfFixGroups`. Each round's targets MUST be grouped by common "
2869
+ "cause before being handed to report-writer — a flat item list "
2870
+ "makes every patch leave its siblings' contradictions standing "
2871
+ '(plan-body-verification.md §"Round protocol" step 7).'
2872
+ )
2873
+ return
2874
+
2875
+ rounds = [g.get("round") for g in groups if isinstance(g.get("round"), int)]
2876
+ if rounds and max(rounds) != rounds_applied:
2877
+ failures.append(
2878
+ "final-report data.json: planBodyVerification "
2879
+ f"`selfFixRoundsApplied`={rounds_applied} does not match the highest "
2880
+ f"round recorded in `selfFixGroups` ({max(rounds)}). The round count "
2881
+ "must be derivable from recorded work, not asserted independently of "
2882
+ "it — promotion eligibility is gated on this number."
2883
+ )
2884
+
2885
+ known_ids = {
2886
+ str(item.get("id")).strip()
2887
+ for item in (pbv.get("planItems") or [])
2888
+ if isinstance(item, dict) and str(item.get("id") or "").strip()
2889
+ }
2890
+ grouped_ids = [
2891
+ str(item_id).strip()
2892
+ for group in groups
2893
+ for item_id in (group.get("itemIds") or [])
2894
+ if str(item_id or "").strip()
2895
+ ]
2896
+ unknown = sorted({i for i in grouped_ids if i not in known_ids})
2897
+ if unknown:
2898
+ failures.append(
2899
+ "final-report data.json: planBodyVerification.selfFixGroups targets "
2900
+ f"plan item(s) {unknown} that do not exist in `planItems`."
2901
+ )
2902
+
2903
+ fixed_ids = {
2904
+ str(item.get("id")).strip()
2905
+ for item in (pbv.get("planItems") or [])
2906
+ if isinstance(item, dict)
2907
+ and str(item.get("selfFixNote") or "").strip()
2908
+ and str(item.get("id") or "").strip()
2909
+ }
2910
+ ungrouped = sorted(fixed_ids - set(grouped_ids))
2911
+ if ungrouped:
2912
+ failures.append(
2913
+ "final-report data.json: plan item(s) "
2914
+ f"{ungrouped} carry a `selfFixNote` but appear in no "
2915
+ "`selfFixGroups` entry. Every item a round corrected must be "
2916
+ "attributable to the cause group it was instructed under."
2917
+ )
2918
+
2919
+
2920
+ _ANSWERED_CLARIFICATION_STATUSES = frozenset({"answered", "resolved"})
2921
+
2922
+
2923
+ def _answered_clarification_ids(data: dict) -> list[str]:
2924
+ """Clarifications this run incorporated an answer for — the rows whose
2925
+ answers can invalidate statements the previous run wrote."""
2926
+ return [
2927
+ str(row.get("id")).strip()
2928
+ for row in (data.get("clarificationItems") or [])
2929
+ if isinstance(row, dict)
2930
+ and str(row.get("status") or "").strip() in _ANSWERED_CLARIFICATION_STATUSES
2931
+ and str(row.get("userInput") or "").strip()
2932
+ and str(row.get("id") or "").strip()
2933
+ ]
2934
+
2935
+
2936
+ def _validate_supersession_ledger(data: dict, failures: list[str]) -> None:
2937
+ """Incorporating an answer means retiring what it invalidates, not only
2938
+ adding what it decides.
2939
+
2940
+ A re-run reconciles each `C-*` row's `Status` and writes the new decision
2941
+ into the plan, but nothing required it to remove the sentences the answer
2942
+ made false. The result is one plan carrying two opposite instructions for
2943
+ the same symbol — the implementer then has to guess which one is live, and
2944
+ the §5.5.9 round correctly blocks on it. This check makes the writer state,
2945
+ per answered clarification, what it retired or why nothing was contingent
2946
+ on that answer. The claim's *truth* is what the §5.5.9 adversarial round
2947
+ tests; this only forces the claim to exist and be attributable.
2948
+ """
2949
+ ip = data.get("implementationPlanning")
2950
+ if not isinstance(ip, dict):
2951
+ return
2952
+ answered = _answered_clarification_ids(data)
2953
+ if not answered:
2954
+ return
2955
+ ledger = [e for e in (ip.get("supersessionLedger") or []) if isinstance(e, dict)]
2956
+ covered = {
2957
+ str(entry.get("clarificationId") or "").strip()
2958
+ for entry in ledger
2959
+ if str(entry.get("clarificationId") or "").strip()
2960
+ }
2961
+ missing = [cid for cid in answered if cid not in covered]
2962
+ if missing:
2963
+ failures.append(
2964
+ "final-report data.json: implementationPlanning.supersessionLedger has "
2965
+ f"no entry for answered clarification(s) {sorted(missing)}. Every "
2966
+ "answer this run incorporated MUST record what it superseded "
2967
+ "(`disposition: superseded` with the retired statement and the "
2968
+ "sections revised) or state that no plan statement was contingent "
2969
+ "on it (`disposition: no-dependent-statement` with a rationale). "
2970
+ "Adding the new decision while leaving the contradicting sentence "
2971
+ "in place is what puts two opposite instructions in one plan "
2972
+ '(_common-contract.md §"clarification response carry-in").'
2973
+ )
2974
+ stale = sorted(covered - set(answered))
2975
+ if stale:
2976
+ failures.append(
2977
+ "final-report data.json: implementationPlanning.supersessionLedger "
2978
+ f"cites {stale}, which this run did not answer. A ledger entry must "
2979
+ "correspond 1:1 to a clarification whose answer this run "
2980
+ "incorporated."
2981
+ )
2982
+
2983
+
2984
+ def _safe_resolve(path: Path) -> Path | None:
2985
+ """``path.resolve()`` that returns None instead of raising, so a
2986
+ malformed recorded path degrades to "no match" rather than a crash."""
2987
+ try:
2988
+ return path.resolve()
2989
+ except (OSError, ValueError):
2990
+ return None
2991
+
2992
+
2993
+ def _carry_in_source_for_run(run_manifest_path: Path) -> str:
2994
+ """The carry-in source path THIS run was launched with, read off the
2995
+ per-run ``run-inputs-<task-type>-<seq>.json`` sibling. Empty string when
2996
+ the run had no carry-in or the evidence is unreadable.
2997
+
2998
+ File existence is NOT a usable signal here: ``instruction-set/`` lives at
2999
+ the task root and is shared by every run of the task-key, and
3000
+ ``run.py:_write_instruction_set_sources`` only mkdirs and overwrites — it
3001
+ never clears the directory. So a ``clarification-response.md`` on disk may
3002
+ have been staged by an earlier run, and keying off the file alone would
3003
+ fail every later run of a task that ever used ``--clarification-response``.
3004
+ The run-inputs record is the only per-run evidence.
3005
+ """
3006
+ name = run_manifest_path.name
3007
+ if not name.startswith("run-manifest-") or not name.endswith(".json"):
3008
+ return ""
3009
+ inputs_path = run_manifest_path.with_name(
3010
+ name.replace("run-manifest-", "run-inputs-", 1)
3011
+ )
3012
+ # Absent/malformed run-inputs has its own reporting path
3013
+ # (`_approved_plan_path_from_run_inputs`); discard the reader's
3014
+ # conformance-gate failures so this check stays silent on that input.
3015
+ payload = _read_run_inputs_payload(inputs_path, [])
3016
+ if payload is None:
3017
+ return ""
3018
+ inputs = payload.get("inputs")
3019
+ if not isinstance(inputs, dict):
3020
+ return ""
3021
+ raw = inputs.get("clarificationResponsePath")
3022
+ return raw.strip() if isinstance(raw, str) else ""
3023
+
3024
+
3025
+ def _validate_clarification_carry_in_recorded(
3026
+ data: dict,
3027
+ report_path: Path,
3028
+ run_manifest_path: Path,
3029
+ project_root: Path,
3030
+ failures: list[str],
3031
+ ) -> None:
3032
+ """A clarification response carried into this run must be recorded in §0.
3033
+
3034
+ Enforcement ran in one direction only: `_EMPTY_CARRY_IN_RE` fails a report
3035
+ that emits an empty `## 0.` stub when nothing was carried in. Nothing
3036
+ failed the opposite — a run launched with `--clarification-response` whose
3037
+ report never records `clarificationCarryIn.sourceFile`. The template gates
3038
+ the whole `## 0.` section on that field, so an unrecorded carry-in makes
3039
+ the section vanish and the user's answers leave no audit trace at all.
3040
+ `_validate_supersession_ledger` covers a slice of this but returns unless
3041
+ `implementationPlanning` is a dict, so it is inert for `implementation`
3042
+ and every other non-planning phase.
3043
+
3044
+ Two spellings of `sourceFile` are accepted because two shipped contracts
3045
+ disagree: `_implementation-deliverable.md` and `launch.template.md` name
3046
+ the staged copy, while `report-writer.md` and the schema description name
3047
+ the source the run was launched with. Both identify a real file this run
3048
+ consumed, so failing either one would be a false accusation.
3049
+ """
3050
+ source = _carry_in_source_for_run(run_manifest_path)
3051
+ if not source:
3052
+ return
3053
+ task_root = _task_root_from_run_dir(report_path.parent.parent)
3054
+ staged = task_root / "instruction-set" / "clarification-response.md"
3055
+ if not staged.is_file():
3056
+ return
3057
+
3058
+ carry_in = data.get("clarificationCarryIn")
3059
+ recorded = (
3060
+ str(carry_in.get("sourceFile") or "").strip()
3061
+ if isinstance(carry_in, dict)
3062
+ else ""
3063
+ )
3064
+ if not recorded:
3065
+ failures.append(
3066
+ "final report does not record the clarification response this run "
3067
+ f"was launched with (`{source}`, staged at `{staged}`). "
3068
+ "`clarificationCarryIn.sourceFile` is missing from the data.json, "
3069
+ "so the renderer omits `## 0. Clarification Response Carried In "
3070
+ "From Previous Run` entirely and nothing in the report shows which "
3071
+ "user answers it incorporated. Set `clarificationCarryIn."
3072
+ "sourceFile` to the staged carry-in path."
3073
+ )
3074
+ return
3075
+
3076
+ accepted = {p for p in (_safe_resolve(staged),) if p is not None}
3077
+ source_path = Path(source)
3078
+ if source_path.is_absolute():
3079
+ resolved_source = _safe_resolve(source_path)
3080
+ if resolved_source is not None:
3081
+ accepted.add(resolved_source)
3082
+
3083
+ recorded_path = Path(recorded)
3084
+ # A recorded relative value may be project-root-relative (schema wording)
3085
+ # or task-root-relative (`instruction-set/clarification-response.md`, the
3086
+ # deliverable profile's wording); accept whichever lands on a real target.
3087
+ bases = (
3088
+ (recorded_path,)
3089
+ if recorded_path.is_absolute()
3090
+ else (project_root / recorded_path, task_root / recorded_path)
3091
+ )
3092
+ candidates = {p for p in (_safe_resolve(b) for b in bases) if p is not None}
3093
+ if candidates & accepted:
3094
+ return
3095
+
3096
+ failures.append(
3097
+ f"final report records `clarificationCarryIn.sourceFile` as "
3098
+ f"`{recorded}`, which resolves to neither the carry-in this run staged "
3099
+ f"(`{staged}`) nor the source it was launched with (`{source}`). "
3100
+ "`## 0.` must name the file whose answers this run actually "
3101
+ "incorporated — pointing it at some other file makes the section "
3102
+ "unauditable."
3103
+ )
3104
+
3105
+
3106
+ _PASSING_VERDICT_TOKENS = frozenset({"accepted", "conditional-accept"})
3107
+
3108
+
3109
+ def _consumers_rows(report_path: Path) -> list[dict] | None:
3110
+ """`runs/implementation-planning/consumers.jsonl` rows for this task.
3111
+
3112
+ The final-verification report sits at `runs/final-verification/[stage-N/]
3113
+ reports/...`, so walk up to the `runs/` directory rather than guessing a
3114
+ fixed depth. ``None`` when the file is absent, so the caller does not turn
3115
+ a missing artifact into a false accusation.
3116
+ """
3117
+ runs_dir = None
3118
+ for parent in report_path.parents:
3119
+ if parent.name == "runs":
3120
+ runs_dir = parent
3121
+ break
3122
+ if runs_dir is None:
3123
+ return None
3124
+ path = runs_dir / "implementation-planning" / "consumers.jsonl"
3125
+ if not path.is_file():
3126
+ return None
3127
+ rows = []
3128
+ try:
3129
+ for line in path.read_text(encoding="utf-8").splitlines():
3130
+ line = line.strip()
3131
+ if line:
3132
+ rows.append(json.loads(line))
3133
+ except (OSError, json.JSONDecodeError):
3134
+ return None
3135
+ return rows
3136
+
3137
+
3138
+ _TARGET_FIELD_RES = {
3139
+ "scope": re.compile(r"\*\*Verification scope:\*\*\s*`([^`]*)`"),
3140
+ "worktree": re.compile(r"\*\*Worktree:\*\*\s*`([^`]*)`"),
3141
+ "base": re.compile(r"\*\*Verification base ref:\*\*\s*`([^`]*)`"),
3142
+ "head": re.compile(r"\*\*Verification head ref:\*\*\s*`([^`]*)`"),
3143
+ }
3144
+ _TARGET_STAGES_RE = re.compile(r"\*\*Stages under verification:\*\*\s*\[([^\]]*)\]")
3145
+ # Anchored at the line start so the cut excludes the `- ` list marker; the
3146
+ # digest covers the snapshot body only, so leaving the marker in shifts the
3147
+ # hash and makes every well-formed target look tampered with.
3148
+ _TARGET_DIGEST_RE = re.compile(
3149
+ r"^- \*\*Verification target digest:\*\*\s*`([^`]*)`", re.M
3150
+ )
3151
+
3152
+
3153
+ def _read_verification_target(project_root: Path, relative: str) -> dict | None:
3154
+ """The prepared target snapshot, but only when its digest still checks out.
3155
+
3156
+ The digest covers the snapshot body (everything before the digest line, as
3157
+ `write_verification_target_snapshot` normalizes it). A file that no longer
3158
+ matches its own digest is not evidence of anything, so return ``None``
3159
+ rather than compare against text someone edited after prep.
3160
+ """
3161
+ path = project_root / relative
3162
+ if not path.is_file():
3163
+ return None
3164
+ try:
3165
+ content = path.read_text(encoding="utf-8")
3166
+ except OSError:
3167
+ return None
3168
+ digest_match = _TARGET_DIGEST_RE.search(content)
3169
+ if digest_match is None:
3170
+ return None
3171
+ body = content[: digest_match.start()]
3172
+ body = body.replace("\r\n", "\n").replace("\r", "\n").rstrip() + "\n"
3173
+ recomputed = "sha256:" + hashlib.sha256(body.encode("utf-8")).hexdigest()
3174
+ if recomputed != digest_match.group(1).strip():
3175
+ return None
3176
+ parsed = {
3177
+ key: (match.group(1).strip() if (match := pattern.search(body)) else "")
3178
+ for key, pattern in _TARGET_FIELD_RES.items()
3179
+ }
3180
+ stages_match = _TARGET_STAGES_RE.search(body)
3181
+ parsed["stages"] = (
3182
+ {int(v) for v in re.findall(r"\d+", stages_match.group(1))}
3183
+ if stages_match
3184
+ else set()
3185
+ )
3186
+ return parsed
3187
+
3188
+
3189
+ def _validate_plan_body_state_file(
3190
+ data: dict,
3191
+ report_path: Path,
3192
+ failures: list[str],
3193
+ ) -> None:
3194
+ """The per-round state file must exist once a round has run.
3195
+
3196
+ Nothing read this file, so its documented schema was dead contract — yet
3197
+ it is the only record of *superseded* rounds. `planItems[].verdicts` in
3198
+ data.json is overwritten by each self-fix re-verification, so after the
3199
+ loop the report shows the final votes and no trace of what the earlier
3200
+ rounds found. Both defect investigations of this phase depended on the
3201
+ sidecar to recover that history.
3202
+
3203
+ Deliberately does NOT cross-check `gateResult` against data.json: the two
3204
+ are different views by design (per-round history vs. final state), and
3205
+ demanding equality would fail every run whose self-fix loop worked.
3206
+ """
3207
+ ip = data.get("implementationPlanning")
3208
+ if not isinstance(ip, dict):
3209
+ return
3210
+ pbv = ip.get("planBodyVerification")
3211
+ if not isinstance(pbv, dict):
3212
+ return
3213
+ round_count = pbv.get("roundCount")
3214
+ if not isinstance(round_count, int) or round_count < 1:
3215
+ return
3216
+ seq = _report_run_seq(report_path)
3217
+ if seq is None:
3218
+ return
3219
+ state_dir = report_path.parent.parent / "state"
3220
+ expected = state_dir / f"plan-body-verification-implementation-planning-{seq}.json"
3221
+ if not expected.is_file():
3222
+ failures.append(
3223
+ f"plan-body verification ran ({round_count} round(s)) but "
3224
+ f"`state/{expected.name}` was not written. It is the only record "
3225
+ "of superseded rounds — data.json keeps just the final verdicts, "
3226
+ "so without it a self-fixed run leaves no trace of what the "
3227
+ 'earlier rounds found (plan-body-verification.md §"schema").'
3228
+ )
3229
+ return
3230
+ try:
3231
+ state = json.loads(expected.read_text(encoding="utf-8"))
3232
+ except (OSError, json.JSONDecodeError) as exc:
3233
+ failures.append(f"plan-body verification state file is unreadable: {exc}")
3234
+ return
3235
+ for key in ("schemaVersion", "gateResult", "planItems"):
3236
+ if key not in state:
3237
+ failures.append(
3238
+ f"plan-body verification state file `{expected.name}` is "
3239
+ f"missing required key `{key}`."
3240
+ )
3241
+
3242
+
3243
+ _QA_NOT_CONFIGURED_TEMPLATE = "qa-command not configured: {category}"
3244
+
3245
+
3246
+ def _validate_missing_qa_categories_recorded(
3247
+ data: dict,
3248
+ project_root: Path,
3249
+ failures: list[str],
3250
+ ) -> None:
3251
+ """"lint is clean" and "lint never ran" must not read the same.
3252
+
3253
+ The verifier contract requires one `qa-command not configured: <category>`
3254
+ line per category absent from `project.json.qaCommands`, but the literal
3255
+ appeared nowhere in the codebase — so a category that was never executed
3256
+ was indistinguishable in the report from one that passed. That reads as
3257
+ verification coverage the run does not have.
3258
+ """
3259
+ implementation = data.get("implementation")
3260
+ if not isinstance(implementation, dict):
3261
+ return
3262
+ results = [r for r in (implementation.get("verifierResults") or []) if isinstance(r, dict)]
3263
+ if not results:
3264
+ return
3265
+ # `db-test` is deliberately excluded: the contract requires its note only
3266
+ # when the diff touches DB/IO/SQL, and that condition is not decidable
3267
+ # here. Demanding it unconditionally would fail every project that has no
3268
+ # database — a false positive, which in a blocking validator is worse than
3269
+ # the gap it closes. The DB case keeps its own gate (the blocking finding
3270
+ # `db-test not configured — DB change unverified`).
3271
+ unconditional_categories = ("lint", "format", "typecheck", "test")
3272
+ path = project_json_path(project_root)
3273
+ if not path.is_file():
3274
+ return
3275
+ try:
3276
+ configured = json.loads(path.read_text(encoding="utf-8")).get("qaCommands") or {}
3277
+ except (OSError, json.JSONDecodeError):
3278
+ return
3279
+ if not isinstance(configured, dict):
3280
+ return
3281
+
3282
+ recorded = "\n".join(
3283
+ str(value)
3284
+ for row in results
3285
+ for value in row.values()
3286
+ if isinstance(value, str)
3287
+ )
3288
+ missing = [
3289
+ category
3290
+ for category in unconditional_categories
3291
+ if not configured.get(category)
3292
+ and _QA_NOT_CONFIGURED_TEMPLATE.format(category=category) not in recorded
3293
+ ]
3294
+ if missing:
3295
+ failures.append(
3296
+ "final-report data.json: `project.json.qaCommands` declares no "
3297
+ f"command for {missing}, but no verifier result records "
3298
+ f"`{_QA_NOT_CONFIGURED_TEMPLATE.format(category='<category>')}` for "
3299
+ "them. An unrun category MUST be stated as unrun — otherwise the "
3300
+ "report reads as though it passed "
3301
+ '(_implementation-verifier.md §"Missing-tier handling").'
3302
+ )
3303
+
3304
+
3305
+ def _load_report_data(report_path: Path) -> dict | None:
3306
+ """The report's data.json, or ``None`` when it is missing or malformed.
3307
+ Both cases are already reported by `validate_final_report_data`; callers
3308
+ here only need to skip rather than re-report."""
3309
+ data_path = _data_path_for(report_path)
3310
+ if not data_path.is_file():
3311
+ return None
3312
+ try:
3313
+ loaded = json.loads(data_path.read_text(encoding="utf-8"))
3314
+ except (OSError, json.JSONDecodeError):
3315
+ return None
3316
+ return loaded if isinstance(loaded, dict) else None
3317
+
3318
+
3319
+ def _validate_verification_target_match(
3320
+ data: dict,
3321
+ run_manifest: dict,
3322
+ project_root: Path,
3323
+ failures: list[str],
3324
+ ) -> None:
3325
+ """The verification report must mirror the target it was prepared against.
3326
+
3327
+ `verificationScope`, the worktree, and the base/head refs were entirely
3328
+ self-declared: the schema required the fields to exist but nothing compared
3329
+ them to the digest-verified snapshot written at prep time. That matters
3330
+ because both `handoff.compute_eligibility` and the `release-handoff`
3331
+ routing check read `verificationScope` — a single-stage run that writes
3332
+ `whole-task` passes both, and an `accepted` verdict can be rendered against
3333
+ a worktree or head nobody verified.
3334
+ """
3335
+ instruction_set = run_manifest.get("instructionSet")
3336
+ if not isinstance(instruction_set, dict):
3337
+ return
3338
+ relative = str(instruction_set.get("verificationTargetPath") or "").strip()
3339
+ if not relative:
3340
+ return
3341
+ target = _read_verification_target(project_root, relative)
3342
+ if target is None:
3343
+ return
3344
+
3345
+ source = (data.get("finalVerification") or {}).get("sourceImplementationReport") or {}
3346
+ declared = {
3347
+ "scope": str(data.get("verificationScope") or "").strip(),
3348
+ "worktree": str(source.get("worktreePath") or "").strip(),
3349
+ "base": str(source.get("implementationBaseRef") or "").strip(),
3350
+ "head": str(source.get("capturedHeadSha") or "").strip(),
3351
+ }
3352
+ for key, expected in ((k, target[k]) for k in _TARGET_FIELD_RES):
3353
+ actual = declared[key]
3354
+ if expected and actual and actual != expected:
3355
+ failures.append(
3356
+ f"final-verification report declares {key} `{actual}` but the "
3357
+ f"prepared verification target says `{expected}` "
3358
+ f"(`{relative}`). The report must mirror the target it was "
3359
+ "prepared against — `verificationScope` in particular gates "
3360
+ "both stage-group eligibility and release-handoff routing, so "
3361
+ "a self-declared value lets a run be judged as something it "
3362
+ "was not."
3363
+ )
3364
+
3365
+ declared_stages = {
3366
+ row.get("stage")
3367
+ for row in ((data.get("finalVerification") or {}).get("stageReports") or [])
3368
+ if isinstance(row, dict) and isinstance(row.get("stage"), int)
3369
+ }
3370
+ if target["stages"] and declared_stages and declared_stages != target["stages"]:
3371
+ failures.append(
3372
+ f"final-verification report covers stages {sorted(declared_stages)} "
3373
+ f"but the prepared target names {sorted(target['stages'])} "
3374
+ f"(`{relative}`). A verdict must not be rendered for a stage set "
3375
+ "nobody prepared evidence for."
3376
+ )
3377
+
3378
+
3379
+ def _validate_verified_row_recorded(
3380
+ data: dict,
3381
+ report_path: Path,
3382
+ failures: list[str],
3383
+ ) -> None:
3384
+ """An accepted single-stage verification must leave its `verified` row.
3385
+
3386
+ `okstra handoff record-verified` validates its own inputs, but nothing
3387
+ checked that it ever ran. Skipping it leaves the report saying `accepted`
3388
+ while `consumers.jsonl` says the stage was never verified, so
3389
+ `handoff.compute_eligibility` never offers it for a stage-group PR — and
3390
+ the only recoveries are re-running an expensive phase or hand-editing the
3391
+ registry.
3392
+ """
3393
+ if str(data.get("verificationScope") or "") != "single-stage":
3394
+ return
3395
+ token = str((data.get("finalVerdict") or {}).get("verdictToken") or "").strip()
3396
+ if token.lower() != "accepted":
3397
+ return
3398
+ stages = {
3399
+ row.get("stage")
3400
+ for row in ((data.get("finalVerification") or {}).get("stageReports") or [])
3401
+ if isinstance(row, dict) and isinstance(row.get("stage"), int)
3402
+ }
3403
+ if not stages:
3404
+ return
3405
+ rows = _consumers_rows(report_path)
3406
+ if rows is None:
3407
+ return
3408
+ verified = {
3409
+ row.get("stage")
3410
+ for row in rows
3411
+ if isinstance(row, dict) and row.get("status") == "verified"
3412
+ }
3413
+ missing = sorted(s for s in stages if s not in verified)
3414
+ if missing:
3415
+ failures.append(
3416
+ f"final-verification accepted stage(s) {missing} but "
3417
+ "`runs/implementation-planning/consumers.jsonl` carries no "
3418
+ "`verified` row for them. Run `okstra handoff record-verified` "
3419
+ "before finishing — without that row the report says accepted "
3420
+ "while the registry says unverified, and the stage is never "
3421
+ "offered for a stage-group PR "
3422
+ '(final-verification.md §"Verified-row recording").'
3423
+ )
3424
+
3425
+
3426
+ def _validate_verifier_fail_blocks_verdict(data: dict, failures: list[str]) -> None:
3427
+ """A verifier FAIL cannot be dropped during synthesis.
3428
+
3429
+ `implementation.verifierResults[]` was written, read by the stage-fix carry
3430
+ helper, and by nothing else — no check compared a recorded `FAIL` against
3431
+ the verdict the lead published. A FAIL lost in synthesis lets
3432
+ `final-verification` reach `accepted` and `release-handoff` push work a
3433
+ verifier rejected.
3434
+ """
3435
+ implementation = data.get("implementation")
3436
+ if not isinstance(implementation, dict):
3437
+ return
3438
+ failed = sorted({
3439
+ str(row.get("verifier") or "<unknown>")
3440
+ for row in (implementation.get("verifierResults") or [])
3441
+ if isinstance(row, dict) and str(row.get("verdict") or "").strip() == "FAIL"
3442
+ })
3443
+ if not failed:
3444
+ return
3445
+ token = str((data.get("verdictCard") or {}).get("verdictToken") or "").strip()
3446
+ if token in _PASSING_VERDICT_TOKENS:
3447
+ failures.append(
3448
+ f"final-report data.json: verifier(s) {failed} recorded "
3449
+ f"`verdict: FAIL` but `verdictCard.verdictToken` is `{token}`. A "
3450
+ "verifier rejection MUST survive into the published verdict — "
3451
+ "dropping it during synthesis is how rejected work reaches "
3452
+ "`release-handoff`. Either carry the FAIL into a blocking verdict "
3453
+ "or record why the verifier's finding was withdrawn."
3454
+ )
3455
+
3456
+
3457
+ def _validate_stage_carry_sidecar_exists(
3458
+ data: dict,
3459
+ report_path: Path,
3460
+ failures: list[str],
3461
+ ) -> None:
3462
+ """The stage carry sidecar must exist on disk, not only be transcribed.
3463
+
3464
+ `implementation.stageSidecarEvidence` is prose the report quotes, so a
3465
+ report can describe a sidecar that was never written. `consumers` treats
3466
+ the carry file as the source of truth for marking a stage `done`, so a
3467
+ missing file leaves the stage permanently un-done and blocks every
3468
+ dependent stage with a `PrepareError` — while the run that caused it
3469
+ finished reporting success.
3470
+ """
3471
+ implementation = data.get("implementation")
3472
+ if not isinstance(implementation, dict):
3473
+ return
3474
+ evidence = implementation.get("stageSidecarEvidence")
3475
+ if not isinstance(evidence, dict):
3476
+ return
3477
+ stage = evidence.get("stageNumber")
3478
+ if not isinstance(stage, int):
3479
+ return
3480
+ # `report_path` is `runs/implementation/reports/final-report-...md`.
3481
+ carry_path = report_path.parent.parent / "carry" / f"stage-{stage}.json"
3482
+ if not carry_path.exists():
3483
+ failures.append(
3484
+ f"implementation run declares stage-{stage} sidecar evidence but "
3485
+ f"`{carry_path.parent.name}/{carry_path.name}` does not exist. The "
3486
+ "carry file is what marks the stage `done` for dependent stages; "
3487
+ "without it this stage never completes and every successor fails "
3488
+ "to prepare, even though this run reported success "
3489
+ '(_implementation-executor.md §"Sidecar evidence writer").'
3490
+ )
3491
+
3492
+
3493
+ def _validate_round_recorded_verdicts(data: dict, failures: list[str]) -> None:
3494
+ """A round that ran must leave the votes it ran on.
3495
+
3496
+ The gate is re-derived from `planItems[].verdicts[]`, so an empty table
3497
+ removes the very evidence the recompute judges. A *healthier* declared gate
3498
+ is already caught — empty verdicts recompute to `aborted-non-result`, which
3499
+ every passing value outranks. What slipped through was the conservative
3500
+ declaration: a lead writing `aborted-non-result` over an empty table
3501
+ produces a gate nothing can audit, indistinguishable from a round that was
3502
+ dispatched and whose results were never transcribed.
3503
+ """
3504
+ ip = data.get("implementationPlanning")
3505
+ if not isinstance(ip, dict):
3506
+ return
3507
+ pbv = ip.get("planBodyVerification")
3508
+ if not isinstance(pbv, dict):
3509
+ return
3510
+ round_count = pbv.get("roundCount")
3511
+ if not isinstance(round_count, int) or round_count < 1:
3512
+ return
3513
+ items = [it for it in (pbv.get("planItems") or []) if isinstance(it, dict)]
3514
+ if not items:
3515
+ return
3516
+ if any(it.get("verdicts") for it in items):
3517
+ return
3518
+ failures.append(
3519
+ "final-report data.json: planBodyVerification declares "
3520
+ f"`roundCount`={round_count} but every one of the {len(items)} "
3521
+ "`planItems[]` carries an empty `verdicts[]`. A round that ran MUST "
3522
+ "record the votes it produced — the gate is re-derived from this "
3523
+ "table, so an empty one leaves the declared `gateResult` unauditable. "
3524
+ "A dispatch that returned nothing is recorded as `verification-error`, "
3525
+ 'not omitted (plan-body-verification.md §"Round protocol" step 4).'
3526
+ )
3527
+
3528
+
3529
+ def _validate_verdicts_match_current_subjects(
3530
+ data: dict,
3531
+ failures: list[str],
3532
+ ) -> None:
3533
+ """A verdict must still be attached to the element it was cast on.
3534
+
3535
+ `P-*` ids are positional (`plan_items.py` numbers rows by array index), so
3536
+ when a self-fix round deletes a plan element every later row shifts up one.
3537
+ A dangling id at the tail is already caught by
3538
+ `_validate_plan_item_extraction_completeness`, but the shift itself is not:
3539
+ the id set still matches while each surviving verdict now points at its
3540
+ neighbour. The recorded `subject` is what makes the shift visible — it is a
3541
+ snapshot of the row the worker actually judged.
3542
+ """
3543
+ ip = data.get("implementationPlanning")
3544
+ if not isinstance(ip, dict):
3545
+ return
3546
+ pbv = ip.get("planBodyVerification")
3547
+ if not isinstance(pbv, dict):
3548
+ return
3549
+ round_count = pbv.get("roundCount")
3550
+ if not isinstance(round_count, int) or round_count < 1:
3551
+ return
3552
+ try:
3553
+ current = {
3554
+ str(item["id"]): str(item.get("subject") or "")
3555
+ for item in extract_plan_items(ip)
3556
+ }
3557
+ except Exception: # noqa: BLE001
3558
+ # Extraction failure is already reported by the completeness check;
3559
+ # do not double-report it here as a spurious subject mismatch.
3560
+ return
3561
+
3562
+ drifted = []
3563
+ for item in pbv.get("planItems") or []:
3564
+ if not isinstance(item, dict):
3565
+ continue
3566
+ item_id = str(item.get("id") or "").strip()
3567
+ recorded = str(item.get("subject") or "").strip()
3568
+ expected = current.get(item_id)
3569
+ if expected is None or not recorded:
3570
+ continue
3571
+ if recorded != expected.strip():
3572
+ drifted.append(item_id)
3573
+ if drifted:
3574
+ failures.append(
3575
+ f"final-report data.json: plan item(s) {sorted(drifted)} carry a "
3576
+ "`subject` that no longer matches the plan element at that "
3577
+ "position. `P-*` ids are positional, so deleting an element during "
3578
+ "self-fix shifts every later row and silently re-points its "
3579
+ "verdicts at a different element — a recorded blocker then refers "
3580
+ "to something the reader cannot find. Re-extract the plan items "
3581
+ "and re-verify the shifted ones instead of carrying the old votes "
3582
+ 'forward (plan-body-verification.md §"Round protocol" step 7).'
3583
+ )
3584
+
3585
+
3586
+ def _validate_aborted_gate_has_clarification(data: dict, failures: list[str]) -> None:
3587
+ """A gate nobody can act on is a stalled task.
3588
+
3589
+ `aborted-non-result` correctly refuses approval and run-prep fail-closes
3590
+ the `implementation` entry, but the clarification matcher only walks
3591
+ `majority-disagree` items — and an aborted round has none. So the report
3592
+ stated no blocker, `okstra-user-response` had nothing to present, and the
3593
+ run stalled with no remedy until someone read the gate value by hand.
3594
+ """
3595
+ ip = data.get("implementationPlanning")
3596
+ if not isinstance(ip, dict):
3597
+ return
3598
+ pbv = ip.get("planBodyVerification")
3599
+ if not isinstance(pbv, dict):
3600
+ return
3601
+ if str(pbv.get("gateResult") or "").strip() != "aborted-non-result":
3602
+ return
3603
+ has_open_blocker = any(
3604
+ isinstance(row, dict)
3605
+ and row.get("blocks") == "approval"
3606
+ and str(row.get("status") or "").strip() == "open"
3607
+ for row in (data.get("clarificationItems") or [])
3608
+ )
3609
+ if not has_open_blocker:
3610
+ failures.append(
3611
+ "final-report data.json: planBodyVerification `gateResult` is "
3612
+ "`aborted-non-result` but no open `Blocks=approval` clarification "
3613
+ "row explains it. An aborted round blocks approval without "
3614
+ "producing any majority-disagree item, so without this row the "
3615
+ "report names no blocker, `okstra-user-response` has nothing to "
3616
+ "present, and the task stalls with no stated remedy. Add a row "
3617
+ "naming which dispatches returned no result and what re-running "
3618
+ 'them requires (plan-body-verification.md §"Round protocol").'
3619
+ )
3620
+
3621
+
3622
+ def _plan_verify_result_workers(report_path: Path, task_type: str) -> set[str] | None:
3623
+ """Worker roles that actually returned a plan-body reverify result.
3624
+
3625
+ Result files are named
3626
+ ``<role-slug>-plan-verify-r<N>-<task-type>-<seq>.md`` per
3627
+ `plan-body-verification.md` §"Round protocol" step 3, and the role slug is
3628
+ ``<role>-plan-verify-r<N>``. Returns ``None`` when the directory is absent
3629
+ so the caller can distinguish "no artifacts to check against" from "nobody
3630
+ voted".
3631
+ """
3632
+ worker_results_dir = report_path.parent.parent / "worker-results"
3633
+ if not worker_results_dir.is_dir():
3634
+ return None
3635
+ # Scoped to this run's seq for the same reason the audit check is: the
3636
+ # directory accumulates every run, so an unscoped glob would let a prior
3637
+ # run's result file vouch for a vote this run never collected.
3638
+ seq = _report_run_seq(report_path)
3639
+ pattern = f"*-plan-verify-r*-{task_type}-{seq or '*'}.md"
3640
+ workers = set()
3641
+ for path in worker_results_dir.glob(pattern):
3642
+ role = path.name.split("-plan-verify-r", 1)[0]
3643
+ if role:
3644
+ workers.add(role)
3645
+ return workers
3646
+
3647
+
3648
+ def _validate_plan_body_verdict_provenance(
3649
+ data: dict,
3650
+ report_path: Path,
3651
+ failures: list[str],
3652
+ ) -> None:
3653
+ """A recorded verdict must trace back to a worker that was actually asked.
3654
+
3655
+ Every §5.5.9 gate computation reads `planItems[].verdicts[]` out of the
3656
+ data.json the lead authored, and nothing tied a vote to a dispatch. A lead
3657
+ that skipped the round entirely and wrote `AGREE` for two workers produced
3658
+ `gateResult: passed`, a flippable `approved:`, and a clean validator run —
3659
+ the same self-report weakness `selfFixRoundsApplied` had, but on the votes
3660
+ the whole gate is computed from.
3661
+ """
3662
+ ip = data.get("implementationPlanning")
3663
+ if not isinstance(ip, dict):
3664
+ return
3665
+ pbv = ip.get("planBodyVerification")
3666
+ if not isinstance(pbv, dict):
3667
+ return
3668
+ round_count = pbv.get("roundCount")
3669
+ if not isinstance(round_count, int) or round_count < 1:
3670
+ return
3671
+
3672
+ voters = {
3673
+ str(v.get("worker") or "").strip()
3674
+ for item in (pbv.get("planItems") or [])
3675
+ if isinstance(item, dict)
3676
+ for v in (item.get("verdicts") or [])
3677
+ if isinstance(v, dict) and str(v.get("worker") or "").strip()
3678
+ }
3679
+ if not voters:
3680
+ return
3681
+
3682
+ task_type = str((data.get("header") or {}).get("taskType") or "")
3683
+ dispatched = _plan_verify_result_workers(report_path, task_type)
3684
+ if dispatched is None:
3685
+ return
3686
+ unbacked = sorted(voters - dispatched)
3687
+ if unbacked:
3688
+ failures.append(
3689
+ "final-report data.json: planBodyVerification records verdicts from "
3690
+ f"{unbacked} but no matching plan-body reverify result file exists "
3691
+ f"under `runs/{task_type}/worker-results/` "
3692
+ f"(expected `<worker>-plan-verify-r<N>-{task_type}-<seq>.md`). "
3693
+ "A vote the gate is computed from MUST trace back to a dispatch "
3694
+ "that actually returned — otherwise the round can be skipped and "
3695
+ 'the gate still read `passed` (plan-body-verification.md §"Round '
3696
+ 'protocol" step 3).'
3697
+ )
3698
+
3699
+
3700
+ def _validate_plan_item_extraction_completeness(
3701
+ data: dict,
3702
+ failures: list[str],
3703
+ ) -> None:
3704
+ """Require the exact deterministic P-* extraction when a round ran."""
3705
+ ip = data.get("implementationPlanning")
3706
+ if not isinstance(ip, dict):
3707
+ return
3708
+ pbv = ip.get("planBodyVerification")
3709
+ if not isinstance(pbv, dict):
3710
+ return
3711
+ round_count = pbv.get("roundCount")
3712
+ if not isinstance(round_count, int) or round_count < 1:
3713
+ return
3714
+ try:
3715
+ expected_sequence = expected_plan_item_ids(ip)
3716
+ except Exception as exc: # noqa: BLE001
3717
+ failures.append(
3718
+ "final-report data.json: deterministic plan-item extraction failed: "
3719
+ f"{exc}"
3720
+ )
3721
+ return
3722
+
3723
+ actual_sequence = [
3724
+ str(item.get("id") or "").strip()
3725
+ for item in (pbv.get("planItems") or [])
3726
+ if isinstance(item, dict)
3727
+ ]
3728
+ expected_ids = set(expected_sequence)
3729
+ actual_ids = set(actual_sequence)
3730
+ missing = expected_ids - actual_ids
2171
3731
  unexpected = actual_ids - expected_ids
2172
3732
  duplicate_ids = {
2173
3733
  item_id for item_id in actual_ids if actual_sequence.count(item_id) > 1
@@ -2575,6 +4135,8 @@ def _validate_plan_body_clarification_matching(data: dict, failures: list[str])
2575
4135
  continue
2576
4136
  if _classify_plan_item_gate(item) != "majority-disagree":
2577
4137
  continue
4138
+ if _is_dissent_downgraded(item, pbv):
4139
+ continue
2578
4140
  item_id = item.get("id") or "<unknown>"
2579
4141
  cid = item.get("clarificationId")
2580
4142
  if not cid:
@@ -2597,12 +4159,6 @@ def _validate_plan_body_clarification_matching(data: dict, failures: list[str])
2597
4159
  )
2598
4160
 
2599
4161
 
2600
- # Stop reasons that justify promoting a still-broken planner-fixable item to the
2601
- # user: the self-fix budget ran out, or a round produced no net resolution so
2602
- # further rounds would repeat themselves.
2603
- _SELF_FIX_EXHAUSTED_REASONS = frozenset({"max-rounds-reached", "no-progress"})
2604
-
2605
-
2606
4162
  def _validate_self_fix_before_clarification(data: dict, failures: list[str]) -> None:
2607
4163
  """A planner-fixable defect MUST exhaust the self-fix budget before it is
2608
4164
  promoted to a `## 1. Clarification Items` row. Closes the hole where the
@@ -2619,26 +4175,16 @@ def _validate_self_fix_before_clarification(data: dict, failures: list[str]) ->
2619
4175
  round_count = pbv.get("roundCount")
2620
4176
  if not isinstance(round_count, int) or round_count < 1:
2621
4177
  return
4178
+ if _self_fix_budget_exhausted(pbv):
4179
+ return
2622
4180
  rounds_applied = pbv.get("selfFixRoundsApplied")
2623
4181
  stop_reason = pbv.get("selfFixStopReason")
2624
- budget_exhausted = (
2625
- isinstance(rounds_applied, int)
2626
- and rounds_applied >= 1
2627
- and stop_reason in _SELF_FIX_EXHAUSTED_REASONS
2628
- )
2629
- if budget_exhausted:
2630
- return
2631
4182
  for item in pbv.get("planItems") or []:
2632
4183
  if not isinstance(item, dict):
2633
4184
  continue
2634
4185
  if _classify_plan_item_gate(item) != "majority-disagree":
2635
4186
  continue
2636
- disagrees = [
2637
- v for v in (item.get("verdicts") or [])
2638
- if isinstance(v, dict) and str(v.get("verdict") or "").upper() == "DISAGREE"
2639
- ]
2640
- fixable = [v for v in disagrees if v.get("fixability") == "planner-fixable"]
2641
- if disagrees and len(fixable) * 2 > len(disagrees):
4187
+ if _has_planner_fixable_majority(item):
2642
4188
  allowed = " / ".join(sorted(_SELF_FIX_EXHAUSTED_REASONS))
2643
4189
  failures.append(
2644
4190
  "final-report data.json: plan item "
@@ -3471,6 +5017,47 @@ _CONVERGENCE_INTERMEDIATE_PREFIXES = (
3471
5017
  )
3472
5018
 
3473
5019
 
5020
+ def _validate_convergence_rounds_match_manifest(
5021
+ run_dir,
5022
+ run_manifest: dict,
5023
+ failures: list[str],
5024
+ ) -> None:
5025
+ """The round budget must be the one the run was configured with.
5026
+
5027
+ The engine only checks `effectiveMaxRounds <= maxRounds` *within* the
5028
+ artifact, and the `okstra convergence` CLI never reads the manifest — so
5029
+ both numbers were lead-authored and unlinked to the `convergence.maxRounds`
5030
+ the run was prepared with. Writing `maxRounds: 1` into the artifact when
5031
+ the manifest says 2 legally short-circuits Round 2 via
5032
+ `round2SkippedReason: max-rounds-1` and halves cross-verification depth,
5033
+ with every self-consistency check satisfied.
5034
+ """
5035
+ from pathlib import Path as _Path
5036
+
5037
+ configured = (run_manifest.get("convergence") or {}).get("maxRounds")
5038
+ if not isinstance(configured, int):
5039
+ return
5040
+ state_dir = _Path(run_dir) / "state"
5041
+ if not state_dir.is_dir():
5042
+ return
5043
+ for state_path in sorted(state_dir.glob("convergence-*.json")):
5044
+ if state_path.name.startswith(_CONVERGENCE_INTERMEDIATE_PREFIXES):
5045
+ continue
5046
+ try:
5047
+ state = json.loads(state_path.read_text(encoding="utf-8"))
5048
+ except (OSError, json.JSONDecodeError):
5049
+ continue
5050
+ declared = (state.get("config") or {}).get("maxRounds")
5051
+ if isinstance(declared, int) and declared != configured:
5052
+ failures.append(
5053
+ f"convergence state `{state_path.name}` declares "
5054
+ f"`config.maxRounds`={declared} but the run manifest configured "
5055
+ f"{configured}. The round budget is not the lead's to restate — "
5056
+ "lowering it in the artifact silently halves cross-verification "
5057
+ "depth while every self-consistency check still passes."
5058
+ )
5059
+
5060
+
3474
5061
  def _validate_convergence_states(run_dir, failures) -> None:
3475
5062
  """Replay shared engine invariants for every public final state artifact."""
3476
5063
  from pathlib import Path as _Path
@@ -3894,6 +5481,7 @@ def main() -> int:
3894
5481
  contract["required_agent_status_entries"],
3895
5482
  failures,
3896
5483
  allow_unavailable_token_usage=_session_accounting(team_state) == "artifact-only",
5484
+ unavailable_ok_labels=_cli_wrapper_worker_labels(team_state),
3897
5485
  )
3898
5486
  validate_team_state_usage(team_state, failures)
3899
5487
 
@@ -3925,7 +5513,17 @@ def main() -> int:
3925
5513
  _sp = (json.loads(_pj.read_text()).get("qaEnv") or {}).get("surfacePatterns")
3926
5514
  except (OSError, json.JSONDecodeError):
3927
5515
  _sp = None
3928
- _validate_conformance(report_path, failures, surface_patterns=_sp)
5516
+ conformance_warnings = _validate_conformance(
5517
+ report_path,
5518
+ failures,
5519
+ surface_patterns=_sp,
5520
+ approved_plan_path=_approved_plan_path_from_run_inputs(
5521
+ run_manifest_path,
5522
+ failures,
5523
+ ),
5524
+ )
5525
+ for warning in conformance_warnings:
5526
+ print(f"validate-run: warning: {warning}", file=sys.stderr)
3929
5527
  if task_type == "implementation-planning":
3930
5528
  _validate_planning_conformance_declared(report_path, failures)
3931
5529
  brief_relative = str(task_manifest.get("taskBriefPath") or "").strip()
@@ -3957,7 +5555,33 @@ def main() -> int:
3957
5555
  _validate_requirements_discovery_fanout(run_dir, failures, brief_path)
3958
5556
  # Phase-agnostic: convergence runs in every finding-producing phase.
3959
5557
  _validate_convergence_states(report_path.parent.parent, failures)
5558
+ _validate_convergence_rounds_match_manifest(
5559
+ report_path.parent.parent, run_manifest, failures
5560
+ )
3960
5561
  validate_report_views(report_path, failures)
5562
+ if task_type == "implementation":
5563
+ _validate_missing_qa_categories_recorded(
5564
+ _load_report_data(report_path) or {}, project_root, failures
5565
+ )
5566
+ if task_type == "implementation-planning":
5567
+ _validate_plan_body_state_file(
5568
+ _load_report_data(report_path) or {}, report_path, failures
5569
+ )
5570
+ if task_type == "final-verification":
5571
+ _validate_verification_target_match(
5572
+ _load_report_data(report_path) or {},
5573
+ run_manifest,
5574
+ project_root,
5575
+ failures,
5576
+ )
5577
+ # Phase-agnostic: any task-type can be launched with a carry-in.
5578
+ _validate_clarification_carry_in_recorded(
5579
+ _load_report_data(report_path) or {},
5580
+ report_path,
5581
+ run_manifest_path,
5582
+ project_root,
5583
+ failures,
5584
+ )
3961
5585
 
3962
5586
  validation_status = "passed" if not failures else "failed"
3963
5587
  update_validation_metadata(