okstra 0.130.4 → 0.131.1

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 +2019 -96
  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,12 +70,18 @@ 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,
75
80
  )
76
- from okstra_ctl.convergence_engine import validate_final_state # noqa: E402
81
+ from okstra_ctl.convergence_engine import ( # noqa: E402
82
+ grouped_input_digest,
83
+ validate_final_state,
84
+ )
77
85
 
78
86
  TERMINAL_STATUSES = {"completed", "timeout", "error", "not-run"}
79
87
  ATTEMPTED_STATUSES = {"completed", "timeout", "error"}
@@ -82,6 +90,33 @@ WORKER_DISPATCH_MODES = {"cli-wrapper", "mixed", "tmux-pane"}
82
90
 
83
91
  def utc_now() -> str:
84
92
  return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
93
+ # Providers dispatched through a CLI wrapper. Their token accounting lives in
94
+ # that CLI, never in the lead's session JSONL, so `usage.source: unavailable`
95
+ # is structural rather than a collection failure.
96
+ _CLI_WRAPPER_AGENTS = frozenset({"codex", "antigravity"})
97
+
98
+
99
+ def _cli_wrapper_worker_labels(team_state: dict) -> frozenset[str]:
100
+ """Token-table row labels whose `--` cells are honest.
101
+
102
+ Mirrors `scripts/okstra_token_usage/report.py` `_worker_detail_label`
103
+ (`"{role} ({agent}, {status})"`) so the match is on the rendered label the
104
+ scanner actually sees.
105
+ """
106
+ labels = set()
107
+ for worker in team_state.get("workers") or []:
108
+ if not isinstance(worker, dict):
109
+ continue
110
+ agent = str(worker.get("agent") or "").strip()
111
+ if agent not in _CLI_WRAPPER_AGENTS:
112
+ continue
113
+ role = str(worker.get("role") or worker.get("workerId") or "Worker").strip()
114
+ status = str(worker.get("status") or "").strip()
115
+ suffix = ", ".join(v for v in (agent, status) if v)
116
+ labels.add(f"{role} ({suffix})" if suffix else role)
117
+ return frozenset(labels)
118
+
119
+
85
120
  def _session_accounting(team_state: dict) -> str:
86
121
  adapter = (
87
122
  team_state.get("leadAdapter") if isinstance(team_state.get("leadAdapter"), dict) else {}
@@ -847,6 +882,7 @@ def _scan_token_usage_summary(
847
882
  failures: list[str],
848
883
  *,
849
884
  allow_unavailable_tokens: bool = False,
885
+ unavailable_ok_labels: frozenset[str] = frozenset(),
850
886
  ) -> None:
851
887
  """Reject sentinel / zero values that workers typed into the Token
852
888
  Usage Summary table instead of leaving the `{{...}}` placeholders
@@ -895,13 +931,27 @@ def _scan_token_usage_summary(
895
931
  # The CLI row's label always contains the word "CLI" — matching
896
932
  # `Codex/Antigravity CLI 추가 비용` regardless of formatting variations.
897
933
  is_cli_row = "CLI" in label_cell
934
+ # A CLI-wrapper worker's tokens are accounted inside its own CLI, not
935
+ # in the lead's session JSONL, so `--` on that row is the honest
936
+ # value — not a skipped collection. Gating it made every roster
937
+ # containing such a worker unable to pass, leaving the lead a choice
938
+ # between fabricating numbers and shipping `contract-violated`.
939
+ row_label = label_cell.lstrip("- ").strip()
940
+ unavailable_ok = allow_unavailable_tokens or row_label in unavailable_ok_labels
941
+ # One row's three cells carry the same defect, so reporting per cell
942
+ # turned two broken workers into six failures and made the real count
943
+ # unreadable. Collapse to one line per (row, sentinel).
944
+ reported_sentinels: set[str] = set()
898
945
  for raw_cell in cells[1:]:
899
946
  for value in _TOKEN_USAGE_BACKTICK_CELL_RE.findall(raw_cell):
900
947
  stripped = value.strip()
901
948
  lowered = stripped.lower()
902
- if allow_unavailable_tokens and stripped == "--":
949
+ if unavailable_ok and stripped == "--":
950
+ continue
951
+ if lowered in reported_sentinels:
903
952
  continue
904
953
  if lowered in _TOKEN_USAGE_SENTINEL_VALUES:
954
+ reported_sentinels.add(lowered)
905
955
  failures.append(
906
956
  "Token Usage Summary cell contains sentinel value "
907
957
  f"`{stripped}` on row labelled `{label_cell or '<unlabeled>'}` — "
@@ -911,6 +961,7 @@ def _scan_token_usage_summary(
911
961
  )
912
962
  continue
913
963
  if stripped in _TOKEN_USAGE_ZERO_VALUES and not is_cli_row:
964
+ reported_sentinels.add(lowered)
914
965
  failures.append(
915
966
  f"Token Usage Summary row `{label_cell or '<unlabeled>'}` has "
916
967
  f"a zero value `{stripped}` — no okstra run consumes zero "
@@ -1020,8 +1071,10 @@ _DEPRECATED_FINAL_REPORT_PATTERNS: tuple[tuple[re.Pattern, str], ...] = (
1020
1071
 
1021
1072
 
1022
1073
  def _load_conformance_results(qa_dir: Path, manifest: dict) -> dict:
1023
- """매니페스트 entry 에 대응하는 `result-<stageKey>.json` 을 로드.
1024
- 파일 부재/파손은 키를 비워 둬 미실행(None→BLOCKING)으로 흐르게 한다."""
1074
+ """Load each entry's `result-<stageKey>.json` sidecar.
1075
+
1076
+ Missing or malformed files leave the key absent so gate evaluation receives None.
1077
+ """
1025
1078
  results: dict = {}
1026
1079
  for entry in manifest.get("entries", []):
1027
1080
  key = entry.get("stageKey") if isinstance(entry, dict) else None
@@ -1033,7 +1086,7 @@ def _load_conformance_results(qa_dir: Path, manifest: dict) -> dict:
1033
1086
  try:
1034
1087
  results[key] = qa_result_from_dict(json.loads(sidecar.read_text()))
1035
1088
  except (OSError, json.JSONDecodeError):
1036
- results[key] = qa_result_from_dict(None) # → MISSING → BLOCKING
1089
+ results[key] = qa_result_from_dict(None) # → MISSING → conformance verdict
1037
1090
  return results
1038
1091
 
1039
1092
 
@@ -1052,11 +1105,12 @@ def _parse_diff_summary_files(content: str) -> list[str]:
1052
1105
  _STAGE_RUN_DIR_RE = re.compile(r"^stage-\d+$")
1053
1106
 
1054
1107
 
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):
1108
+ def _stage_isolated_name(run_dir: Path) -> str | None:
1109
+ """Return stage-<N> for implementation or final-verification stage runs."""
1110
+ if (
1111
+ run_dir.parent.name in ("implementation", "final-verification")
1112
+ and _STAGE_RUN_DIR_RE.match(run_dir.name)
1113
+ ):
1060
1114
  return run_dir.name
1061
1115
  return None
1062
1116
 
@@ -1093,6 +1147,308 @@ def _task_root_from_run_dir(run_dir: Path) -> Path:
1093
1147
  return run_dir.parent.parent
1094
1148
 
1095
1149
 
1150
+ def _read_run_inputs_payload(
1151
+ inputs_path: Path,
1152
+ failures: list[str],
1153
+ ) -> dict | None:
1154
+ """Read the exact run-inputs payload, failing closed on malformed evidence."""
1155
+ if not inputs_path.is_file():
1156
+ failures.append(
1157
+ f"conformance gate BLOCKING: approved plan evidence run inputs missing "
1158
+ f"at {inputs_path}"
1159
+ )
1160
+ return None
1161
+ try:
1162
+ payload = json.loads(inputs_path.read_text(encoding="utf-8"))
1163
+ except (OSError, json.JSONDecodeError) as exc:
1164
+ failures.append(
1165
+ f"conformance gate BLOCKING: approved plan evidence run inputs "
1166
+ f"unreadable at {inputs_path}: {exc}"
1167
+ )
1168
+ return None
1169
+ if not isinstance(payload, dict):
1170
+ failures.append(
1171
+ "conformance gate BLOCKING: approved plan evidence run inputs "
1172
+ f"payload malformed at {inputs_path}"
1173
+ )
1174
+ return None
1175
+ return payload
1176
+
1177
+
1178
+ def _approved_plan_path_from_run_inputs(
1179
+ run_manifest_path: Path,
1180
+ failures: list[str],
1181
+ ) -> Path | None:
1182
+ """Load an absolute approvedPlanPath from the exact run-inputs sibling."""
1183
+ name = run_manifest_path.name
1184
+ if not name.startswith("run-manifest-") or not name.endswith(".json"):
1185
+ failures.append(
1186
+ "conformance gate BLOCKING: approved plan evidence cannot derive "
1187
+ f"run-inputs sibling from {run_manifest_path}"
1188
+ )
1189
+ return None
1190
+ inputs_path = run_manifest_path.with_name(
1191
+ name.replace("run-manifest-", "run-inputs-", 1)
1192
+ )
1193
+ payload = _read_run_inputs_payload(inputs_path, failures)
1194
+ if payload is None:
1195
+ return None
1196
+ inputs = payload.get("inputs")
1197
+ if not isinstance(inputs, dict):
1198
+ failures.append(
1199
+ "conformance gate BLOCKING: approved plan evidence has malformed "
1200
+ f"inputs object at {inputs_path}"
1201
+ )
1202
+ return None
1203
+ raw_path = inputs.get("approvedPlanPath")
1204
+ if not isinstance(raw_path, str) or not raw_path.strip():
1205
+ failures.append(
1206
+ "conformance gate BLOCKING: approved plan evidence approvedPlanPath "
1207
+ f"missing or malformed at {inputs_path}"
1208
+ )
1209
+ return None
1210
+ path = Path(raw_path)
1211
+ if not path.is_absolute():
1212
+ failures.append(
1213
+ "conformance gate BLOCKING: approved plan evidence approvedPlanPath "
1214
+ f"must be absolute at {inputs_path}: {raw_path}"
1215
+ )
1216
+ return None
1217
+ try:
1218
+ return path.resolve()
1219
+ except (OSError, ValueError) as exc:
1220
+ failures.append(
1221
+ "conformance gate BLOCKING: approved plan evidence approvedPlanPath "
1222
+ f"malformed at {inputs_path}: {exc}"
1223
+ )
1224
+ return None
1225
+
1226
+
1227
+ def _approved_plan_stage_entries(
1228
+ stages: object,
1229
+ data_path: Path,
1230
+ failures: list[str],
1231
+ ) -> list[dict] | None:
1232
+ """Validate approved-plan stages and return conformance declarations."""
1233
+ if not isinstance(stages, list) or not stages:
1234
+ _record_malformed_plan_stages(data_path, failures)
1235
+ return None
1236
+ entries: list[dict] = []
1237
+ seen_stage_numbers: set[int] = set()
1238
+ for stage in stages:
1239
+ valid, entry = _approved_plan_stage_entry(
1240
+ stage,
1241
+ seen_stage_numbers,
1242
+ data_path,
1243
+ failures,
1244
+ )
1245
+ if not valid:
1246
+ return None
1247
+ if entry is not None:
1248
+ entries.append(entry)
1249
+ return entries
1250
+
1251
+
1252
+ def _record_malformed_plan_stages(data_path: Path, failures: list[str]) -> None:
1253
+ failures.append(
1254
+ f"conformance gate BLOCKING: approved plan evidence has malformed stages at {data_path}"
1255
+ )
1256
+
1257
+
1258
+ def _approved_plan_stage_entry(
1259
+ stage: object,
1260
+ seen_stage_numbers: set[int],
1261
+ data_path: Path,
1262
+ failures: list[str],
1263
+ ) -> tuple[bool, dict | None]:
1264
+ if not isinstance(stage, dict):
1265
+ _record_malformed_plan_stages(data_path, failures)
1266
+ return False, None
1267
+ stage_number = stage.get("stage")
1268
+ tests = stage.get("conformanceTests")
1269
+ exemption = stage.get("conformanceExemption")
1270
+ tests_present = "conformanceTests" in stage
1271
+ exemption_present = "conformanceExemption" in stage
1272
+ fields_are_strings = all(
1273
+ value is None or isinstance(value, str) for value in (tests, exemption)
1274
+ )
1275
+ has_tests = isinstance(tests, str) and bool(tests.strip())
1276
+ has_exemption = isinstance(exemption, str) and bool(exemption.strip())
1277
+ choice_is_valid = (
1278
+ tests_present != exemption_present
1279
+ and (has_tests if tests_present else has_exemption)
1280
+ )
1281
+ stage_is_valid = (
1282
+ isinstance(stage_number, int)
1283
+ and not isinstance(stage_number, bool)
1284
+ and stage_number >= 1
1285
+ and stage_number not in seen_stage_numbers
1286
+ )
1287
+ if not fields_are_strings or not stage_is_valid or not choice_is_valid:
1288
+ _record_malformed_plan_stages(data_path, failures)
1289
+ return False, None
1290
+ seen_stage_numbers.add(stage_number)
1291
+ if not has_tests:
1292
+ return True, None
1293
+ declaration = _parse_conformance_tests(tests)
1294
+ if declaration is None:
1295
+ failures.append(
1296
+ "conformance gate BLOCKING: approved plan evidence has malformed "
1297
+ f"conformanceTests for stage {stage_number} at {data_path}"
1298
+ )
1299
+ return False, None
1300
+ script, requires = declaration
1301
+ return True, {
1302
+ "stageKey": f"approved-plan-stage-{stage_number}",
1303
+ "script": script,
1304
+ "requires": sorted(requires),
1305
+ }
1306
+
1307
+
1308
+ _CONFORMANCE_TESTS_RE = re.compile(
1309
+ r"^(?P<script>\S+)\s+\(requires=\[(?P<requires>[^\]]+)\]\)$"
1310
+ )
1311
+
1312
+
1313
+ def _normalize_conformance_script(script: str) -> str:
1314
+ prefix = "<task_root>/"
1315
+ return script[len(prefix):] if script.startswith(prefix) else script
1316
+
1317
+
1318
+ def _parse_conformance_tests(value: object) -> tuple[str, frozenset[str]] | None:
1319
+ """Parse `<script> (requires=[cap,...])` from a stage declaration."""
1320
+ if not isinstance(value, str):
1321
+ return None
1322
+ match = _CONFORMANCE_TESTS_RE.fullmatch(value.strip())
1323
+ if match is None:
1324
+ return None
1325
+ script = _normalize_conformance_script(match.group("script"))
1326
+ capabilities = [part.strip() for part in match.group("requires").split(",")]
1327
+ if (
1328
+ not script
1329
+ or any(not capability for capability in capabilities)
1330
+ or len(set(capabilities)) != len(capabilities)
1331
+ or any(capability not in CAPABILITY_WHITELIST for capability in capabilities)
1332
+ ):
1333
+ return None
1334
+ return script, frozenset(capabilities)
1335
+
1336
+
1337
+ def _approved_plan_conformance_manifest(
1338
+ approved_plan_path: Path,
1339
+ task_root: Path,
1340
+ failures: list[str],
1341
+ ) -> dict | None:
1342
+ """Represent approved-plan conformance declarations as stage-key entries."""
1343
+ if not approved_plan_path.is_file():
1344
+ failures.append(
1345
+ f"conformance gate BLOCKING: approved plan evidence missing at {approved_plan_path}"
1346
+ )
1347
+ return None
1348
+ data_path = _data_path_for(approved_plan_path)
1349
+ if not data_path.is_file():
1350
+ failures.append(
1351
+ f"conformance gate BLOCKING: approved plan evidence missing at {data_path}"
1352
+ )
1353
+ return None
1354
+ resolved_data_path = data_path.resolve()
1355
+ if not resolved_data_path.is_relative_to(task_root.resolve()):
1356
+ failures.append(
1357
+ "conformance gate BLOCKING: approved plan data evidence resolves "
1358
+ f"outside current task root {task_root}: {resolved_data_path}"
1359
+ )
1360
+ return None
1361
+ data_path = resolved_data_path
1362
+ try:
1363
+ data = json.loads(data_path.read_text(encoding="utf-8"))
1364
+ except (OSError, json.JSONDecodeError) as exc:
1365
+ failures.append(
1366
+ f"conformance gate BLOCKING: approved plan evidence unreadable at {data_path}: {exc}"
1367
+ )
1368
+ return None
1369
+ planning = data.get("implementationPlanning") if isinstance(data, dict) else None
1370
+ stages = planning.get("stages") if isinstance(planning, dict) else None
1371
+ entries = _approved_plan_stage_entries(stages, data_path, failures)
1372
+ return {"entries": entries} if entries is not None else None
1373
+
1374
+
1375
+ def _declared_conformance_errors(
1376
+ declared_manifest: dict,
1377
+ actual_manifest: dict,
1378
+ stage_name: str | None,
1379
+ ) -> list[str]:
1380
+ """Compare scoped plan declarations with their one actual manifest entry."""
1381
+ declared = _scope_manifest_entries(declared_manifest, stage_name).get("entries", [])
1382
+ actual = _scope_manifest_entries(actual_manifest, stage_name).get("entries", [])
1383
+ errors: list[str] = []
1384
+ declared_stage_numbers = {
1385
+ str(entry.get("stageKey") or "").rsplit("-stage-", 1)[-1]
1386
+ for entry in declared
1387
+ if isinstance(entry, dict)
1388
+ }
1389
+ for declaration in declared:
1390
+ stage_number = str(declaration.get("stageKey") or "").rsplit("-stage-", 1)[-1]
1391
+ matches = [
1392
+ entry for entry in actual
1393
+ if isinstance(entry, dict)
1394
+ and str(entry.get("stageKey") or "").endswith(f"-stage-{stage_number}")
1395
+ ]
1396
+ if not matches:
1397
+ errors.append(f"stage {stage_number} has no matching entry")
1398
+ continue
1399
+ if len(matches) > 1:
1400
+ errors.append(f"stage {stage_number} has multiple matching entries")
1401
+ continue
1402
+ actual_entry = matches[0]
1403
+ actual_script = _normalize_conformance_script(str(actual_entry.get("script") or ""))
1404
+ if actual_script != declaration.get("script"):
1405
+ errors.append(f"stage {stage_number} script mismatch")
1406
+ actual_requires = actual_entry.get("requires")
1407
+ actual_capabilities = (
1408
+ frozenset(actual_requires)
1409
+ if isinstance(actual_requires, list)
1410
+ and all(isinstance(value, str) for value in actual_requires)
1411
+ else None
1412
+ )
1413
+ if actual_capabilities != frozenset(declaration.get("requires") or []):
1414
+ errors.append(f"stage {stage_number} requires mismatch")
1415
+ for actual_entry in actual:
1416
+ if not isinstance(actual_entry, dict):
1417
+ continue
1418
+ stage_number = str(actual_entry.get("stageKey") or "").rsplit("-stage-", 1)[-1]
1419
+ if stage_number not in declared_stage_numbers:
1420
+ errors.append(
1421
+ f"stage {stage_number} actual manifest entry is not declared by approved plan"
1422
+ )
1423
+ return errors
1424
+
1425
+
1426
+ def _planning_conformance_declarations(
1427
+ stages: object,
1428
+ failures: list[str],
1429
+ ) -> list[dict]:
1430
+ declarations: list[dict] = []
1431
+ for stage in stages if isinstance(stages, list) else []:
1432
+ if not isinstance(stage, dict) or not str(stage.get("conformanceTests") or "").strip():
1433
+ continue
1434
+ parsed = _parse_conformance_tests(stage.get("conformanceTests"))
1435
+ if parsed is None:
1436
+ failures.append(
1437
+ "final-report data.json: stage "
1438
+ f"{stage.get('stage')} has malformed conformanceTests declaration."
1439
+ )
1440
+ continue
1441
+ script, requires = parsed
1442
+ declarations.append(
1443
+ {
1444
+ "stageKey": f"approved-plan-stage-{stage.get('stage')}",
1445
+ "script": script,
1446
+ "requires": sorted(requires),
1447
+ }
1448
+ )
1449
+ return declarations
1450
+
1451
+
1096
1452
  def _validate_planning_conformance_declared(report_path: Path, failures: list[str]) -> None:
1097
1453
  """H4-c — at planning time, every stage that DECLARES `Conformance tests:`
1098
1454
  must already carry a matching entry in the shared task-level
@@ -1113,52 +1469,72 @@ def _validate_planning_conformance_declared(report_path: Path, failures: list[st
1113
1469
  ip = data.get("implementationPlanning")
1114
1470
  if not isinstance(ip, dict):
1115
1471
  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:
1472
+ declarations = _planning_conformance_declarations(ip.get("stages"), failures)
1473
+ if not declarations:
1122
1474
  return
1123
1475
  task_root = _task_root_from_run_dir(report_path.parent.parent)
1124
1476
  manifest_path = task_root / "qa" / "conformance-manifest.json"
1125
1477
  entries = []
1126
1478
  if manifest_path.is_file():
1127
1479
  try:
1128
- entries = json.loads(manifest_path.read_text()).get("entries") or []
1480
+ manifest = json.loads(manifest_path.read_text())
1481
+ entries = manifest.get("entries") or [] if isinstance(manifest, dict) else []
1129
1482
  except (OSError, json.JSONDecodeError):
1130
1483
  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
1484
+ for error in _declared_conformance_errors(
1485
+ {"entries": declarations},
1486
+ {"entries": entries},
1487
+ None,
1488
+ ):
1489
+ failures.append(
1490
+ f"final-report data.json: conformance declaration {error} in "
1491
+ f"{manifest_path}. Emitting `Conformance tests:` MUST also write "
1492
+ "one matching manifest entry (implementation-planning.md §Conformance)."
1493
+ )
1494
+
1495
+
1496
+ def _validate_conformance_surfaces(
1497
+ report_path: Path,
1498
+ scoped_manifest: dict,
1499
+ surface_patterns: object,
1500
+ failures: list[str],
1501
+ ) -> None:
1502
+ changed_files = _parse_diff_summary_files(report_path.read_text(encoding="utf-8"))
1503
+ if not changed_files:
1504
+ return
1505
+ uncovered = (
1506
+ detect_surfaces(changed_files, surface_patterns)
1507
+ - manifest_required_surfaces(scoped_manifest)
1508
+ )
1509
+ if uncovered:
1510
+ failures.append(
1511
+ "conformance gate BLOCKING: implementation diff touches undeclared "
1512
+ f"surface(s) {sorted(uncovered)} — no in-scope stage declares "
1513
+ "`requires` for them. Declare a conformance entry (requires=[...]) "
1514
+ "for the touching stage. "
1515
+ "(silent mock-green 방지 — DEV-9184)"
1136
1516
  )
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
1517
 
1146
1518
 
1147
- def _validate_conformance(report_path: Path, failures: list[str],
1148
- surface_patterns: object = None) -> None:
1519
+ def _validate_conformance(
1520
+ report_path: Path,
1521
+ failures: list[str],
1522
+ surface_patterns: object = None,
1523
+ approved_plan_path: Path | None = None,
1524
+ ) -> list[str]:
1149
1525
  """Tier 3 conformance 게이트(implementation / final-verification).
1150
1526
 
1151
- `<task_root>/qa/conformance-manifest.json` 없으면 inert(선언된 conformance
1152
- 없다는 선언을 강제하는 것은 planning 계약(Phase 4)의 몫). 매니페스트가
1153
- 있으면 결과 사이드카와 함께 evaluate_conformance 판정하고 BLOCKING verdict
1154
- run 검증 실패로 승격한다. WAIVED(conditional)/EXEMPT 통과시킨다.
1527
+ A missing task-level manifest is inert only without approved-plan evidence.
1528
+ When a recorded approved plan declares conformance for the in-scope stage,
1529
+ disappearance of that manifest is a blocking structural failure. Otherwise,
1530
+ evaluate the manifest and result sidecars normally: BLOCKING verdicts fail
1531
+ validation, ADVISORY verdicts become user follow-up warnings, and permitted
1532
+ WAIVED/EXEMPT verdicts pass.
1155
1533
 
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 의 스코프 계약과 동형.
1534
+ Stage-isolated implementation and final-verification runs evaluate only their
1535
+ own stage suffix. Whole-task runs evaluate every entry.
1161
1536
  """
1537
+ warnings: list[str] = []
1162
1538
  # conformance 산출물은 task-level(<task_root>/qa)에 있어 planning/
1163
1539
  # implementation/final-verification 가 공유한다. report_path 는
1164
1540
  # task_root/runs/<task-type>/reports/final-report.md (implementation 은
@@ -1169,38 +1545,106 @@ def _validate_conformance(report_path: Path, failures: list[str],
1169
1545
  task_root = _task_root_from_run_dir(run_dir)
1170
1546
  qa_dir = task_root / "qa"
1171
1547
  manifest_path = qa_dir / "conformance-manifest.json"
1548
+ stage_name = _stage_isolated_name(run_dir)
1549
+ declared_manifest = None
1550
+ if approved_plan_path is not None:
1551
+ resolved_plan_path = approved_plan_path.resolve()
1552
+ if not resolved_plan_path.is_relative_to(task_root.resolve()):
1553
+ failures.append(
1554
+ "conformance gate BLOCKING: approved plan evidence resolves "
1555
+ f"outside current task root {task_root}: {resolved_plan_path}"
1556
+ )
1557
+ return warnings
1558
+ declared_manifest = _approved_plan_conformance_manifest(
1559
+ resolved_plan_path,
1560
+ task_root,
1561
+ failures,
1562
+ )
1563
+ if declared_manifest is None:
1564
+ return warnings
1172
1565
  if not manifest_path.is_file():
1173
- return
1566
+ empty_scoped_manifest = {"entries": []}
1567
+ if declared_manifest is not None:
1568
+ for error in _declared_conformance_errors(
1569
+ declared_manifest,
1570
+ empty_scoped_manifest,
1571
+ stage_name,
1572
+ ):
1573
+ failures.append(
1574
+ f"conformance gate BLOCKING: approved plan {error}; "
1575
+ f"{manifest_path} is absent"
1576
+ )
1577
+ _validate_conformance_surfaces(
1578
+ report_path,
1579
+ empty_scoped_manifest,
1580
+ surface_patterns,
1581
+ failures,
1582
+ )
1583
+ return warnings
1174
1584
  try:
1175
1585
  manifest = json.loads(manifest_path.read_text())
1176
1586
  except (OSError, json.JSONDecodeError) as exc:
1177
1587
  failures.append(f"conformance manifest unreadable at {manifest_path}: {exc}")
1178
- return
1588
+ return warnings
1179
1589
  schema_errors = validate_conformance_manifest(manifest)
1180
1590
  if schema_errors:
1181
1591
  failures.extend(f"conformance manifest: {e}" for e in schema_errors)
1182
- return
1183
- scoped = _scope_manifest_entries(manifest, _implementation_stage_name(run_dir))
1592
+ return warnings
1593
+ if declared_manifest is not None:
1594
+ for error in _declared_conformance_errors(
1595
+ declared_manifest,
1596
+ manifest,
1597
+ stage_name,
1598
+ ):
1599
+ failures.append(
1600
+ f"conformance gate BLOCKING: approved plan {error} "
1601
+ f"in {manifest_path}"
1602
+ )
1603
+ scoped = _scope_manifest_entries(manifest, stage_name)
1604
+ entries_by_key = {
1605
+ entry.get("stageKey"): entry
1606
+ for entry in scoped.get("entries", [])
1607
+ if isinstance(entry, dict) and isinstance(entry.get("stageKey"), str)
1608
+ }
1609
+ for stage_key, entry in entries_by_key.items():
1610
+ if entry.get("requires") == [] and entry.get("waiver"):
1611
+ failures.append(
1612
+ f"conformance gate BLOCKING for stage {stage_key}: "
1613
+ "requires=[] is declaration/contract trouble and cannot be waived"
1614
+ )
1615
+ if entry.get("requires") and entry.get("exemption"):
1616
+ failures.append(
1617
+ f"conformance gate BLOCKING for stage {stage_key}: "
1618
+ f"a stage that declares a gated surface (requires="
1619
+ f"{entry.get('requires')}) cannot be exempted — submit a real "
1620
+ "conformance result or record a user-acknowledged waiver. See "
1621
+ "docs/superpowers/specs/2026-06-07-stage-conformance-qa-design.md §7.1."
1622
+ )
1184
1623
  results = _load_conformance_results(qa_dir, scoped)
1185
1624
  for verdict in evaluate_conformance(scoped, results):
1186
- if not verdict.ok:
1625
+ if verdict.status == "ADVISORY":
1626
+ entry = entries_by_key.get(verdict.stage_key, {})
1627
+ warnings.append(
1628
+ f"conformance advisory for stage {verdict.stage_key}: "
1629
+ f"{verdict.message}; requires={entry.get('requires') or []}; "
1630
+ f"user-owned follow-up command: "
1631
+ f"{entry.get('runCommand') or '(missing)'}; "
1632
+ f"manifest: {manifest_path}"
1633
+ )
1634
+ elif not verdict.ok:
1187
1635
  failures.append(
1188
1636
  f"conformance gate BLOCKING for stage {verdict.stage_key}: "
1189
1637
  f"{verdict.message}. Run the stage's conformance script (or declare "
1190
1638
  f"an exemption / user waiver) — see "
1191
1639
  f"docs/superpowers/specs/2026-06-07-stage-conformance-qa-design.md."
1192
1640
  )
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
- )
1641
+ _validate_conformance_surfaces(
1642
+ report_path,
1643
+ scoped,
1644
+ surface_patterns,
1645
+ failures,
1646
+ )
1647
+ return warnings
1204
1648
 
1205
1649
 
1206
1650
  def _check_incremental_audit_block(
@@ -1247,6 +1691,7 @@ def validate_report(
1247
1691
  failures: list[str],
1248
1692
  *,
1249
1693
  allow_unavailable_token_usage: bool = False,
1694
+ unavailable_ok_labels: frozenset[str] = frozenset(),
1250
1695
  ) -> None:
1251
1696
  if not report_path.exists():
1252
1697
  failures.append(f"final report is missing: {report_path}")
@@ -1272,6 +1717,7 @@ def validate_report(
1272
1717
  content,
1273
1718
  failures,
1274
1719
  allow_unavailable_tokens=allow_unavailable_token_usage,
1720
+ unavailable_ok_labels=unavailable_ok_labels,
1275
1721
  )
1276
1722
 
1277
1723
  # Verdict Card is mandatory in every final-report (introduced with the
@@ -1355,14 +1801,24 @@ _WORKER_RESULT_BASENAME_RE = re.compile(
1355
1801
  r"^(?P<worker>[a-z][a-z0-9-]*-worker)-(?P<task_type>[a-z][a-z-]*?)-(?P<seq>\d{3})\.md$"
1356
1802
  )
1357
1803
 
1804
+ _REPORT_BASENAME_SEQ_RE = re.compile(r"-(?P<seq>\d{3})(?:\.data)?\.(?:md|json)$")
1805
+
1806
+
1807
+ def _report_run_seq(report_path: Path) -> str | None:
1808
+ """This run's seq, read off `final-report-<task-type>-<seq>.md`. ``None``
1809
+ when the name does not carry one, so callers fall back to not filtering
1810
+ rather than silently checking nothing."""
1811
+ match = _REPORT_BASENAME_SEQ_RE.search(report_path.name)
1812
+ return match.group("seq") if match else None
1813
+
1358
1814
 
1359
1815
  def validate_worker_results_audit(
1360
1816
  report_path: Path, task_type: str, failures: list[str]
1361
1817
  ) -> None:
1362
1818
  """Enforce the worker audit sidecar contract.
1363
1819
 
1364
- For every `worker-results/<worker>-<task-type>-<seq>.md` produced by a
1365
- worker (skipping the audit sidecar itself), the validator checks:
1820
+ For every `worker-results/<worker>-<task-type>-<seq>.md` **this run**
1821
+ produced (skipping the audit sidecar itself), the validator checks:
1366
1822
 
1367
1823
  1. The main worker-results file does NOT contain a `## 0. Reading
1368
1824
  Confirmation` heading. That block moved to the audit sidecar with
@@ -1370,6 +1826,13 @@ def validate_worker_results_audit(
1370
1826
  2. The matching audit sidecar exists at
1371
1827
  `<worker>-audit-<task-type>-<seq>.md`. Missing sidecar means the
1372
1828
  worker silently skipped the reading-confirmation step.
1829
+
1830
+ Scoped to this run's seq. `worker-results/` accumulates every run's
1831
+ artifacts, so scanning the whole directory judged a run by files it did
1832
+ not produce: a task that once opted a worker in and later dropped it from
1833
+ the roster failed forever on that worker's old sidecar, with no legitimate
1834
+ remedy — the lead can neither fabricate a sidecar for a worker it never
1835
+ dispatched nor delete a prior run's audit record.
1373
1836
  """
1374
1837
  # `report_path` is `runs/<task-type>/reports/final-report-...md`; the
1375
1838
  # sibling `worker-results/` directory holds every worker artifact.
@@ -1379,6 +1842,8 @@ def validate_worker_results_audit(
1379
1842
  # `release-handoff` which is single-lead). Nothing to enforce.
1380
1843
  return
1381
1844
 
1845
+ run_seq = _report_run_seq(report_path)
1846
+
1382
1847
  for path in sorted(worker_results_dir.glob("*.md")):
1383
1848
  name = path.name
1384
1849
  if "-audit-" in name:
@@ -1392,6 +1857,9 @@ def validate_worker_results_audit(
1392
1857
  # Cross-phase artifacts shouldn't appear here; skip rather
1393
1858
  # than fail to keep the check focused on the current phase.
1394
1859
  continue
1860
+ if run_seq is not None and match.group("seq") != run_seq:
1861
+ # A prior run's artifact. Its contract was judged when it ran.
1862
+ continue
1395
1863
 
1396
1864
  worker_role = match.group("worker")
1397
1865
  seq = match.group("seq")
@@ -1847,13 +2315,25 @@ def validate_final_report_data(
1847
2315
  _validate_unverified_critic_gaps_recorded(data, failures)
1848
2316
 
1849
2317
  task_type = (data.get("header") or {}).get("taskType")
2318
+ _validate_verifier_fail_blocks_verdict(data, failures)
2319
+ if task_type == "implementation":
2320
+ _validate_stage_carry_sidecar_exists(data, report_path, failures)
1850
2321
  if task_type == "final-verification":
1851
2322
  _validate_final_verification_consistency(data, failures)
2323
+ _validate_verified_row_recorded(data, report_path, failures)
1852
2324
  elif task_type == "implementation-planning":
1853
2325
  active_report_contracts = report_contracts or set()
1854
2326
  _validate_implementation_planning_cross_project(data, failures)
1855
2327
  _validate_implementation_planning_decision_drafts(data, failures)
1856
2328
  _validate_plan_body_gate_recompute(data, failures)
2329
+ _validate_gate_blocked_by(data, failures)
2330
+ _validate_supersession_ledger(data, failures)
2331
+ _validate_self_fix_grouping(data, failures)
2332
+ _validate_clarification_evidence_note(data, failures)
2333
+ _validate_plan_body_verdict_provenance(data, report_path, failures)
2334
+ _validate_aborted_gate_has_clarification(data, failures)
2335
+ _validate_round_recorded_verdicts(data, failures)
2336
+ _validate_verdicts_match_current_subjects(data, failures)
1857
2337
  _validate_plan_item_extraction_completeness(data, failures)
1858
2338
  _validate_plan_item_subject_substance(data, failures)
1859
2339
  _validate_plan_body_clarification_matching(data, failures)
@@ -2041,6 +2521,11 @@ _PLAN_GATE_RANK = {
2041
2521
  # is prone to planning-vs-implementation environment false positives.
2042
2522
  _SINGLE_VOTE_BLOCKING_KINDS = {"a", "d"}
2043
2523
 
2524
+ # Stop reasons that justify promoting a still-broken planner-fixable item to the
2525
+ # user: the self-fix budget ran out, or a round produced no net resolution so
2526
+ # further rounds would repeat themselves.
2527
+ _SELF_FIX_EXHAUSTED_REASONS = frozenset({"max-rounds-reached", "no-progress"})
2528
+
2044
2529
 
2045
2530
  def _classify_plan_item_gate(item: dict) -> str:
2046
2531
  """Recompute one plan item's gate class from its per-worker verdicts,
@@ -2089,12 +2574,74 @@ def _classify_plan_item_gate(item: dict) -> str:
2089
2574
  return "has-dissent"
2090
2575
 
2091
2576
 
2577
+ def _disagree_breakage_kinds(item: dict) -> set[str]:
2578
+ return {
2579
+ str(v.get("breakageKind") or "").strip().lower()
2580
+ for v in (item.get("verdicts") or [])
2581
+ if isinstance(v, dict)
2582
+ and str(v.get("verdict") or "").strip().upper() == "DISAGREE"
2583
+ and str(v.get("breakageKind") or "").strip()
2584
+ }
2585
+
2586
+
2587
+ def _has_planner_fixable_majority(item: dict) -> bool:
2588
+ disagrees = [
2589
+ v
2590
+ for v in (item.get("verdicts") or [])
2591
+ if isinstance(v, dict) and str(v.get("verdict") or "").upper() == "DISAGREE"
2592
+ ]
2593
+ fixable = [v for v in disagrees if v.get("fixability") == "planner-fixable"]
2594
+ return bool(disagrees) and len(fixable) * 2 > len(disagrees)
2595
+
2596
+
2597
+ def _is_correctness_critical(item: dict) -> bool:
2598
+ """Whether this item's defect would make `implementation` produce wrong or
2599
+ unsafe code — the single-vote-blocking kinds `a` (cited path/symbol
2600
+ mismatch) and `d` (rollback violates commit order) on any item, or `f`
2601
+ (requirement-coverage mismatch) on a `P-Req-*` item. Kinds `b`/`c`/`e` are
2602
+ plan-prose defects: they degrade the document, not the resulting code.
2603
+ """
2604
+ kinds = _disagree_breakage_kinds(item)
2605
+ is_req = str(item.get("id") or "").upper().startswith("P-REQ")
2606
+ return bool(kinds & _SINGLE_VOTE_BLOCKING_KINDS) or (is_req and "f" in kinds)
2607
+
2608
+
2609
+ def _self_fix_budget_exhausted(pbv: dict) -> bool:
2610
+ rounds_applied = pbv.get("selfFixRoundsApplied")
2611
+ return (
2612
+ isinstance(rounds_applied, int)
2613
+ and rounds_applied >= 1
2614
+ and pbv.get("selfFixStopReason") in _SELF_FIX_EXHAUSTED_REASONS
2615
+ )
2616
+
2617
+
2618
+ def _is_dissent_downgraded(item: dict, pbv: dict) -> bool:
2619
+ """Whether a surviving `majority-disagree` item stops blocking approval.
2620
+
2621
+ A planner-fixable defect the planner failed to fix is still a *planner*
2622
+ defect; promoting it to a `Blocks=approval` row asks the user to proofread
2623
+ the plan document. Once the self-fix budget is exhausted, such an item is
2624
+ recorded as a Working Assumption in `## 5. Missing Information and Risks`
2625
+ instead of blocking the gate. Defects that would make the implementation
2626
+ itself wrong or unsafe (`_is_correctness_critical`) are excluded and keep
2627
+ blocking, so correctness never trades away for throughput.
2628
+ """
2629
+ return (
2630
+ _classify_plan_item_gate(item) == "majority-disagree"
2631
+ and not _is_correctness_critical(item)
2632
+ and _has_planner_fixable_majority(item)
2633
+ and _self_fix_budget_exhausted(pbv)
2634
+ )
2635
+
2636
+
2092
2637
  def _recompute_plan_body_gate(pbv: dict) -> str | None:
2093
2638
  """Recompute the whole §5.5.9 gate value from ``planItems[].verdicts``.
2094
2639
  Returns a value in ``PLAN_VERIFY_GATE_VALUES`` or ``None`` when there are
2095
2640
  no plan items to judge (disabled / empty round)."""
2096
2641
  classes = [
2097
- _classify_plan_item_gate(it)
2642
+ "has-dissent"
2643
+ if _is_dissent_downgraded(it, pbv)
2644
+ else _classify_plan_item_gate(it)
2098
2645
  for it in (pbv.get("planItems") or [])
2099
2646
  if isinstance(it, dict)
2100
2647
  ]
@@ -2137,11 +2684,80 @@ def _validate_plan_body_gate_recompute(data: dict, failures: list[str]) -> None:
2137
2684
  )
2138
2685
 
2139
2686
 
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."""
2687
+ def _cited_clarification_id(row: dict) -> str | None:
2688
+ """The `C-NNN` a coverage row's `status` / `approvalDisposition` cites."""
2689
+ for field in ("status", "approvalDisposition"):
2690
+ value = str(row.get(field) or "").strip()
2691
+ if value.startswith("blocked "):
2692
+ return value.split(" ", 1)[1].strip()
2693
+ return None
2694
+
2695
+
2696
+ def _blocks_approval(row: dict) -> bool:
2697
+ """Whether one Requirement Coverage row blocks approval on its face, per
2698
+ `prompts/profiles/implementation-planning.md` §"Requirement Coverage": a
2699
+ `gap`, a plain `blocked C-NNN`, or a deviation whose approval disposition
2700
+ is blocked.
2701
+ """
2702
+ status = str(row.get("status") or "").strip()
2703
+ if status == "gap" or status.startswith("blocked C-"):
2704
+ return True
2705
+ disposition = str(row.get("approvalDisposition") or "").strip()
2706
+ return status == "documented-deviation" and disposition.startswith("blocked C-")
2707
+
2708
+
2709
+ def _plan_body_promoted_clarification_ids(pbv: dict) -> set[str]:
2710
+ """`C-NNN` ids this run's own plan-body round created by promoting a
2711
+ majority-disagree item (step 8). Used to break the Requirement Coverage
2712
+ ↔ Clarification cycle: a coverage row citing one of these echoes a blocker
2713
+ the gate already counted, rather than contributing an independent one.
2714
+ """
2715
+ return {
2716
+ item["clarificationId"].strip()
2717
+ for item in (pbv.get("planItems") or [])
2718
+ if isinstance(item, dict)
2719
+ and isinstance(item.get("clarificationId"), str)
2720
+ and item["clarificationId"].strip()
2721
+ }
2722
+
2723
+
2724
+ def _independent_coverage_blockers(ip: dict, pbv: dict) -> list[str]:
2725
+ """Coverage rows that block the gate on their own — excluding rows whose
2726
+ blocker is a `C-NNN` this same run's plan-body round promoted."""
2727
+ promoted = _plan_body_promoted_clarification_ids(pbv)
2728
+ return [
2729
+ str(row.get("id") or "<unknown>")
2730
+ for row in (ip.get("requirementCoverage") or [])
2731
+ if isinstance(row, dict)
2732
+ and _blocks_approval(row)
2733
+ and _cited_clarification_id(row) not in promoted
2734
+ ]
2735
+
2736
+
2737
+ def _gate_blocking_causes(pbv: dict, coverage_blockers: list[str]) -> set[str]:
2738
+ """Which inputs actually block approval, as `gateBlockedBy` enum values."""
2739
+ causes = set()
2740
+ recomputed = _recompute_plan_body_gate(pbv)
2741
+ if recomputed == "blocked-by-disagreement":
2742
+ causes.add("majority-disagree")
2743
+ elif recomputed == "aborted-non-result":
2744
+ causes.add("non-result")
2745
+ if coverage_blockers:
2746
+ causes.add("coverage-gap")
2747
+ return causes
2748
+
2749
+
2750
+ def _validate_gate_blocked_by(data: dict, failures: list[str]) -> None:
2751
+ """The gate value names an *outcome*; `gateBlockedBy` names the *cause*.
2752
+
2753
+ Two independent inputs can block approval — a `majority-disagree` plan item
2754
+ and a Requirement Coverage `gap` / `blocked C-NNN` row — and before this
2755
+ check both surfaced as the single value `blocked-by-disagreement`. A
2756
+ coverage-only block therefore asserted a worker disagreement that never
2757
+ happened, sending the reader hunting for a dissent that does not exist.
2758
+ `gateBlockedBy` records which input actually fired, and this check makes the
2759
+ coverage rule enforceable rather than prose-only.
2760
+ """
2145
2761
  ip = data.get("implementationPlanning")
2146
2762
  if not isinstance(ip, dict):
2147
2763
  return
@@ -2151,20 +2767,975 @@ def _validate_plan_item_extraction_completeness(
2151
2767
  round_count = pbv.get("roundCount")
2152
2768
  if not isinstance(round_count, int) or round_count < 1:
2153
2769
  return
2154
- try:
2155
- expected_sequence = expected_plan_item_ids(ip)
2156
- except Exception as exc: # noqa: BLE001
2770
+
2771
+ declared_gate = str(pbv.get("gateResult") or "").strip().lower()
2772
+ declared_causes = {
2773
+ str(c).strip()
2774
+ for c in (pbv.get("gateBlockedBy") or [])
2775
+ if isinstance(c, str) and str(c).strip()
2776
+ }
2777
+ coverage_blockers = _independent_coverage_blockers(ip, pbv)
2778
+ actual_causes = _gate_blocking_causes(pbv, coverage_blockers)
2779
+
2780
+ if actual_causes and declared_gate in ("passed", "passed-with-dissent"):
2157
2781
  failures.append(
2158
- "final-report data.json: deterministic plan-item extraction failed: "
2159
- f"{exc}"
2782
+ "final-report data.json: implementationPlanning.planBodyVerification "
2783
+ f"`gateResult` is `{declared_gate}` but "
2784
+ f"{sorted(actual_causes)} blocks approval "
2785
+ f"(coverage rows: {coverage_blockers or 'none'}). A Requirement "
2786
+ "Coverage `gap` / `blocked C-NNN` row blocks the gate independently "
2787
+ "of the worker verdicts (implementation-planning.md "
2788
+ '§"Requirement Coverage").'
2160
2789
  )
2161
2790
  return
2162
2791
 
2163
- actual_sequence = [
2164
- str(item.get("id") or "").strip()
2165
- for item in (pbv.get("planItems") or [])
2166
- if isinstance(item, dict)
2167
- ]
2792
+ if declared_causes != actual_causes:
2793
+ failures.append(
2794
+ "final-report data.json: implementationPlanning.planBodyVerification "
2795
+ f"`gateBlockedBy` is {sorted(declared_causes)} but the recorded "
2796
+ f"inputs support {sorted(actual_causes)} (coverage rows blocking "
2797
+ f"independently: {coverage_blockers or 'none'}). Every blocking "
2798
+ "input must be named so the reader is not sent looking for a "
2799
+ "worker disagreement that never happened "
2800
+ '(plan-body-verification.md §"Round protocol" step 5).'
2801
+ )
2802
+
2803
+
2804
+ _EVIDENCE_NOTE_RE = re.compile(r"Evidence checked:\s*(?P<body>.+?)(?:\.\s|\.$|$)", re.S)
2805
+ _EVIDENCE_NONE_RE = re.compile(r"^none\s*[—-]\s*\S")
2806
+ # `path/to/file.ext:123`, the form the codebase-first rule asks the row to cite.
2807
+ _EVIDENCE_PATH_LINE_RE = re.compile(r"\S+\.\w+:\d+")
2808
+
2809
+
2810
+ def _validate_clarification_evidence_note(data: dict, failures: list[str]) -> None:
2811
+ """Every clarification row must show its codebase-first work.
2812
+
2813
+ The profile requires any ambiguity answerable by `Read` / `Grep` to be
2814
+ resolved that way, and calls a row for something the code already answers a
2815
+ defect of the phase — but nothing checked it, so rows the codebase could
2816
+ have settled reached the user as approval blockers. Requiring the
2817
+ `Evidence checked:` note to exist and be well-formed does not prove the
2818
+ lookup happened; it makes its absence a failure and a false `none` an
2819
+ attributable claim rather than a silent omission.
2820
+ """
2821
+ if (data.get("header") or {}).get("taskType") != "implementation-planning":
2822
+ return
2823
+ for row in data.get("clarificationItems") or []:
2824
+ if not isinstance(row, dict):
2825
+ continue
2826
+ row_id = str(row.get("id") or "<unknown>")
2827
+ statement = str(row.get("statement") or "")
2828
+ match = _EVIDENCE_NOTE_RE.search(statement)
2829
+ if not match:
2830
+ failures.append(
2831
+ f"final-report data.json: clarification `{row_id}` has no "
2832
+ "`Evidence checked:` note in its `statement`. Every row must "
2833
+ "record the codebase lookup that failed to settle it — either "
2834
+ "`Evidence checked: <path:line>` or `Evidence checked: none — "
2835
+ "<human-only reason>` (implementation-planning.md "
2836
+ '§"Evidence note required inside `Statement`").'
2837
+ )
2838
+ continue
2839
+ body = match.group("body").strip()
2840
+ if _EVIDENCE_NONE_RE.match(body) or _EVIDENCE_PATH_LINE_RE.search(body):
2841
+ continue
2842
+ failures.append(
2843
+ f"final-report data.json: clarification `{row_id}` has a malformed "
2844
+ f"`Evidence checked:` note ({body[:60]!r}). It must cite a concrete "
2845
+ "`<path>:<line>` or use the form `none — <human-only reason>`; a "
2846
+ "bare assertion records no lookup."
2847
+ )
2848
+
2849
+
2850
+ def _validate_self_fix_grouping(data: dict, failures: list[str]) -> None:
2851
+ """A self-fix round must be instructed by cause, not as a flat item list.
2852
+
2853
+ Blocked items are usually several derivatives of one defect. Instructed
2854
+ item-by-item, each patch corrects its own section and leaves the sibling
2855
+ sections still asserting the old value, so the next round re-finds the same
2856
+ family and the budget drains without converging. Recording the grouping
2857
+ makes the lead commit to a diagnosis and makes a one-group-per-item
2858
+ non-diagnosis visible in the artifact rather than invisible in a prompt.
2859
+
2860
+ Recording rounds here also ties `selfFixRoundsApplied` to work that exists
2861
+ in the data: it was a free-floating self-reported integer, yet
2862
+ `_validate_self_fix_before_clarification` gates promotion on its value.
2863
+ """
2864
+ ip = data.get("implementationPlanning")
2865
+ if not isinstance(ip, dict):
2866
+ return
2867
+ pbv = ip.get("planBodyVerification")
2868
+ if not isinstance(pbv, dict):
2869
+ return
2870
+ rounds_applied = pbv.get("selfFixRoundsApplied")
2871
+ if not isinstance(rounds_applied, int) or rounds_applied < 1:
2872
+ return
2873
+
2874
+ groups = [g for g in (pbv.get("selfFixGroups") or []) if isinstance(g, dict)]
2875
+ if not groups:
2876
+ failures.append(
2877
+ "final-report data.json: planBodyVerification declares "
2878
+ f"`selfFixRoundsApplied`={rounds_applied} but records no "
2879
+ "`selfFixGroups`. Each round's targets MUST be grouped by common "
2880
+ "cause before being handed to report-writer — a flat item list "
2881
+ "makes every patch leave its siblings' contradictions standing "
2882
+ '(plan-body-verification.md §"Round protocol" step 7).'
2883
+ )
2884
+ return
2885
+
2886
+ rounds = [g.get("round") for g in groups if isinstance(g.get("round"), int)]
2887
+ if rounds and max(rounds) != rounds_applied:
2888
+ failures.append(
2889
+ "final-report data.json: planBodyVerification "
2890
+ f"`selfFixRoundsApplied`={rounds_applied} does not match the highest "
2891
+ f"round recorded in `selfFixGroups` ({max(rounds)}). The round count "
2892
+ "must be derivable from recorded work, not asserted independently of "
2893
+ "it — promotion eligibility is gated on this number."
2894
+ )
2895
+
2896
+ known_ids = {
2897
+ str(item.get("id")).strip()
2898
+ for item in (pbv.get("planItems") or [])
2899
+ if isinstance(item, dict) and str(item.get("id") or "").strip()
2900
+ }
2901
+ grouped_ids = [
2902
+ str(item_id).strip()
2903
+ for group in groups
2904
+ for item_id in (group.get("itemIds") or [])
2905
+ if str(item_id or "").strip()
2906
+ ]
2907
+ unknown = sorted({i for i in grouped_ids if i not in known_ids})
2908
+ if unknown:
2909
+ failures.append(
2910
+ "final-report data.json: planBodyVerification.selfFixGroups targets "
2911
+ f"plan item(s) {unknown} that do not exist in `planItems`."
2912
+ )
2913
+
2914
+ fixed_ids = {
2915
+ str(item.get("id")).strip()
2916
+ for item in (pbv.get("planItems") or [])
2917
+ if isinstance(item, dict)
2918
+ and str(item.get("selfFixNote") or "").strip()
2919
+ and str(item.get("id") or "").strip()
2920
+ }
2921
+ ungrouped = sorted(fixed_ids - set(grouped_ids))
2922
+ if ungrouped:
2923
+ failures.append(
2924
+ "final-report data.json: plan item(s) "
2925
+ f"{ungrouped} carry a `selfFixNote` but appear in no "
2926
+ "`selfFixGroups` entry. Every item a round corrected must be "
2927
+ "attributable to the cause group it was instructed under."
2928
+ )
2929
+
2930
+
2931
+ _ANSWERED_CLARIFICATION_STATUSES = frozenset({"answered", "resolved"})
2932
+
2933
+
2934
+ def _answered_clarification_ids(data: dict) -> list[str]:
2935
+ """Clarifications this run incorporated an answer for — the rows whose
2936
+ answers can invalidate statements the previous run wrote."""
2937
+ return [
2938
+ str(row.get("id")).strip()
2939
+ for row in (data.get("clarificationItems") or [])
2940
+ if isinstance(row, dict)
2941
+ and str(row.get("status") or "").strip() in _ANSWERED_CLARIFICATION_STATUSES
2942
+ and str(row.get("userInput") or "").strip()
2943
+ and str(row.get("id") or "").strip()
2944
+ ]
2945
+
2946
+
2947
+ def _validate_supersession_ledger(data: dict, failures: list[str]) -> None:
2948
+ """Incorporating an answer means retiring what it invalidates, not only
2949
+ adding what it decides.
2950
+
2951
+ A re-run reconciles each `C-*` row's `Status` and writes the new decision
2952
+ into the plan, but nothing required it to remove the sentences the answer
2953
+ made false. The result is one plan carrying two opposite instructions for
2954
+ the same symbol — the implementer then has to guess which one is live, and
2955
+ the §5.5.9 round correctly blocks on it. This check makes the writer state,
2956
+ per answered clarification, what it retired or why nothing was contingent
2957
+ on that answer. The claim's *truth* is what the §5.5.9 adversarial round
2958
+ tests; this only forces the claim to exist and be attributable.
2959
+ """
2960
+ ip = data.get("implementationPlanning")
2961
+ if not isinstance(ip, dict):
2962
+ return
2963
+ answered = _answered_clarification_ids(data)
2964
+ if not answered:
2965
+ return
2966
+ ledger = [e for e in (ip.get("supersessionLedger") or []) if isinstance(e, dict)]
2967
+ covered = {
2968
+ str(entry.get("clarificationId") or "").strip()
2969
+ for entry in ledger
2970
+ if str(entry.get("clarificationId") or "").strip()
2971
+ }
2972
+ missing = [cid for cid in answered if cid not in covered]
2973
+ if missing:
2974
+ failures.append(
2975
+ "final-report data.json: implementationPlanning.supersessionLedger has "
2976
+ f"no entry for answered clarification(s) {sorted(missing)}. Every "
2977
+ "answer this run incorporated MUST record what it superseded "
2978
+ "(`disposition: superseded` with the retired statement and the "
2979
+ "sections revised) or state that no plan statement was contingent "
2980
+ "on it (`disposition: no-dependent-statement` with a rationale). "
2981
+ "Adding the new decision while leaving the contradicting sentence "
2982
+ "in place is what puts two opposite instructions in one plan "
2983
+ '(_common-contract.md §"clarification response carry-in").'
2984
+ )
2985
+ stale = sorted(covered - set(answered))
2986
+ if stale:
2987
+ failures.append(
2988
+ "final-report data.json: implementationPlanning.supersessionLedger "
2989
+ f"cites {stale}, which this run did not answer. A ledger entry must "
2990
+ "correspond 1:1 to a clarification whose answer this run "
2991
+ "incorporated."
2992
+ )
2993
+
2994
+
2995
+ def _safe_resolve(path: Path) -> Path | None:
2996
+ """``path.resolve()`` that returns None instead of raising, so a
2997
+ malformed recorded path degrades to "no match" rather than a crash."""
2998
+ try:
2999
+ return path.resolve()
3000
+ except (OSError, ValueError):
3001
+ return None
3002
+
3003
+
3004
+ def _carry_in_source_for_run(run_manifest_path: Path) -> str:
3005
+ """The carry-in source path THIS run was launched with, read off the
3006
+ per-run ``run-inputs-<task-type>-<seq>.json`` sibling. Empty string when
3007
+ the run had no carry-in or the evidence is unreadable.
3008
+
3009
+ File existence is NOT a usable signal here: ``instruction-set/`` lives at
3010
+ the task root and is shared by every run of the task-key, and
3011
+ ``run.py:_write_instruction_set_sources`` only mkdirs and overwrites — it
3012
+ never clears the directory. So a ``clarification-response.md`` on disk may
3013
+ have been staged by an earlier run, and keying off the file alone would
3014
+ fail every later run of a task that ever used ``--clarification-response``.
3015
+ The run-inputs record is the only per-run evidence.
3016
+ """
3017
+ name = run_manifest_path.name
3018
+ if not name.startswith("run-manifest-") or not name.endswith(".json"):
3019
+ return ""
3020
+ inputs_path = run_manifest_path.with_name(
3021
+ name.replace("run-manifest-", "run-inputs-", 1)
3022
+ )
3023
+ # Absent/malformed run-inputs has its own reporting path
3024
+ # (`_approved_plan_path_from_run_inputs`); discard the reader's
3025
+ # conformance-gate failures so this check stays silent on that input.
3026
+ payload = _read_run_inputs_payload(inputs_path, [])
3027
+ if payload is None:
3028
+ return ""
3029
+ inputs = payload.get("inputs")
3030
+ if not isinstance(inputs, dict):
3031
+ return ""
3032
+ raw = inputs.get("clarificationResponsePath")
3033
+ return raw.strip() if isinstance(raw, str) else ""
3034
+
3035
+
3036
+ def _validate_clarification_carry_in_recorded(
3037
+ data: dict,
3038
+ report_path: Path,
3039
+ run_manifest_path: Path,
3040
+ project_root: Path,
3041
+ failures: list[str],
3042
+ ) -> None:
3043
+ """A clarification response carried into this run must be recorded in §0.
3044
+
3045
+ Enforcement ran in one direction only: `_EMPTY_CARRY_IN_RE` fails a report
3046
+ that emits an empty `## 0.` stub when nothing was carried in. Nothing
3047
+ failed the opposite — a run launched with `--clarification-response` whose
3048
+ report never records `clarificationCarryIn.sourceFile`. The template gates
3049
+ the whole `## 0.` section on that field, so an unrecorded carry-in makes
3050
+ the section vanish and the user's answers leave no audit trace at all.
3051
+ `_validate_supersession_ledger` covers a slice of this but returns unless
3052
+ `implementationPlanning` is a dict, so it is inert for `implementation`
3053
+ and every other non-planning phase.
3054
+
3055
+ Two spellings of `sourceFile` are accepted because two shipped contracts
3056
+ disagree: `_implementation-deliverable.md` and `launch.template.md` name
3057
+ the staged copy, while `report-writer.md` and the schema description name
3058
+ the source the run was launched with. Both identify a real file this run
3059
+ consumed, so failing either one would be a false accusation.
3060
+ """
3061
+ source = _carry_in_source_for_run(run_manifest_path)
3062
+ if not source:
3063
+ return
3064
+ task_root = _task_root_from_run_dir(report_path.parent.parent)
3065
+ staged = task_root / "instruction-set" / "clarification-response.md"
3066
+ if not staged.is_file():
3067
+ return
3068
+
3069
+ carry_in = data.get("clarificationCarryIn")
3070
+ recorded = (
3071
+ str(carry_in.get("sourceFile") or "").strip()
3072
+ if isinstance(carry_in, dict)
3073
+ else ""
3074
+ )
3075
+ if not recorded:
3076
+ failures.append(
3077
+ "final report does not record the clarification response this run "
3078
+ f"was launched with (`{source}`, staged at `{staged}`). "
3079
+ "`clarificationCarryIn.sourceFile` is missing from the data.json, "
3080
+ "so the renderer omits `## 0. Clarification Response Carried In "
3081
+ "From Previous Run` entirely and nothing in the report shows which "
3082
+ "user answers it incorporated. Set `clarificationCarryIn."
3083
+ "sourceFile` to the staged carry-in path."
3084
+ )
3085
+ return
3086
+
3087
+ accepted = {p for p in (_safe_resolve(staged),) if p is not None}
3088
+ source_path = Path(source)
3089
+ if source_path.is_absolute():
3090
+ resolved_source = _safe_resolve(source_path)
3091
+ if resolved_source is not None:
3092
+ accepted.add(resolved_source)
3093
+
3094
+ recorded_path = Path(recorded)
3095
+ # A recorded relative value may be project-root-relative (schema wording)
3096
+ # or task-root-relative (`instruction-set/clarification-response.md`, the
3097
+ # deliverable profile's wording); accept whichever lands on a real target.
3098
+ bases = (
3099
+ (recorded_path,)
3100
+ if recorded_path.is_absolute()
3101
+ else (project_root / recorded_path, task_root / recorded_path)
3102
+ )
3103
+ candidates = {p for p in (_safe_resolve(b) for b in bases) if p is not None}
3104
+ if candidates & accepted:
3105
+ return
3106
+
3107
+ failures.append(
3108
+ f"final report records `clarificationCarryIn.sourceFile` as "
3109
+ f"`{recorded}`, which resolves to neither the carry-in this run staged "
3110
+ f"(`{staged}`) nor the source it was launched with (`{source}`). "
3111
+ "`## 0.` must name the file whose answers this run actually "
3112
+ "incorporated — pointing it at some other file makes the section "
3113
+ "unauditable."
3114
+ )
3115
+
3116
+
3117
+ _PASSING_VERDICT_TOKENS = frozenset({"accepted", "conditional-accept"})
3118
+
3119
+
3120
+ def _consumers_rows(report_path: Path) -> list[dict] | None:
3121
+ """`runs/implementation-planning/consumers.jsonl` rows for this task.
3122
+
3123
+ The final-verification report sits at `runs/final-verification/[stage-N/]
3124
+ reports/...`, so walk up to the `runs/` directory rather than guessing a
3125
+ fixed depth. ``None`` when the file is absent, so the caller does not turn
3126
+ a missing artifact into a false accusation.
3127
+ """
3128
+ runs_dir = None
3129
+ for parent in report_path.parents:
3130
+ if parent.name == "runs":
3131
+ runs_dir = parent
3132
+ break
3133
+ if runs_dir is None:
3134
+ return None
3135
+ path = runs_dir / "implementation-planning" / "consumers.jsonl"
3136
+ if not path.is_file():
3137
+ return None
3138
+ rows = []
3139
+ try:
3140
+ for line in path.read_text(encoding="utf-8").splitlines():
3141
+ line = line.strip()
3142
+ if line:
3143
+ rows.append(json.loads(line))
3144
+ except (OSError, json.JSONDecodeError):
3145
+ return None
3146
+ return rows
3147
+
3148
+
3149
+ _TARGET_FIELD_RES = {
3150
+ "scope": re.compile(r"\*\*Verification scope:\*\*\s*`([^`]*)`"),
3151
+ "worktree": re.compile(r"\*\*Worktree:\*\*\s*`([^`]*)`"),
3152
+ "base": re.compile(r"\*\*Verification base ref:\*\*\s*`([^`]*)`"),
3153
+ "head": re.compile(r"\*\*Verification head ref:\*\*\s*`([^`]*)`"),
3154
+ }
3155
+ _TARGET_STAGES_RE = re.compile(r"\*\*Stages under verification:\*\*\s*\[([^\]]*)\]")
3156
+ # Anchored at the line start so the cut excludes the `- ` list marker; the
3157
+ # digest covers the snapshot body only, so leaving the marker in shifts the
3158
+ # hash and makes every well-formed target look tampered with.
3159
+ _TARGET_DIGEST_RE = re.compile(
3160
+ r"^- \*\*Verification target digest:\*\*\s*`([^`]*)`", re.M
3161
+ )
3162
+
3163
+
3164
+ def _read_verification_target(project_root: Path, relative: str) -> dict | None:
3165
+ """The prepared target snapshot, but only when its digest still checks out.
3166
+
3167
+ The digest covers the snapshot body (everything before the digest line, as
3168
+ `write_verification_target_snapshot` normalizes it). A file that no longer
3169
+ matches its own digest is not evidence of anything, so return ``None``
3170
+ rather than compare against text someone edited after prep.
3171
+ """
3172
+ path = project_root / relative
3173
+ if not path.is_file():
3174
+ return None
3175
+ try:
3176
+ content = path.read_text(encoding="utf-8")
3177
+ except OSError:
3178
+ return None
3179
+ digest_match = _TARGET_DIGEST_RE.search(content)
3180
+ if digest_match is None:
3181
+ return None
3182
+ body = content[: digest_match.start()]
3183
+ body = body.replace("\r\n", "\n").replace("\r", "\n").rstrip() + "\n"
3184
+ recomputed = "sha256:" + hashlib.sha256(body.encode("utf-8")).hexdigest()
3185
+ if recomputed != digest_match.group(1).strip():
3186
+ return None
3187
+ parsed = {
3188
+ key: (match.group(1).strip() if (match := pattern.search(body)) else "")
3189
+ for key, pattern in _TARGET_FIELD_RES.items()
3190
+ }
3191
+ stages_match = _TARGET_STAGES_RE.search(body)
3192
+ parsed["stages"] = (
3193
+ {int(v) for v in re.findall(r"\d+", stages_match.group(1))}
3194
+ if stages_match
3195
+ else set()
3196
+ )
3197
+ return parsed
3198
+
3199
+
3200
+ def _validate_plan_body_state_file(
3201
+ data: dict,
3202
+ report_path: Path,
3203
+ failures: list[str],
3204
+ ) -> None:
3205
+ """The per-round state file must exist once a round has run.
3206
+
3207
+ Nothing read this file, so its documented schema was dead contract — yet
3208
+ it is the only record of *superseded* rounds. `planItems[].verdicts` in
3209
+ data.json is overwritten by each self-fix re-verification, so after the
3210
+ loop the report shows the final votes and no trace of what the earlier
3211
+ rounds found. Both defect investigations of this phase depended on the
3212
+ sidecar to recover that history.
3213
+
3214
+ Deliberately does NOT cross-check `gateResult` against data.json: the two
3215
+ are different views by design (per-round history vs. final state), and
3216
+ demanding equality would fail every run whose self-fix loop worked.
3217
+ """
3218
+ ip = data.get("implementationPlanning")
3219
+ if not isinstance(ip, dict):
3220
+ return
3221
+ pbv = ip.get("planBodyVerification")
3222
+ if not isinstance(pbv, dict):
3223
+ return
3224
+ round_count = pbv.get("roundCount")
3225
+ if not isinstance(round_count, int) or round_count < 1:
3226
+ return
3227
+ seq = _report_run_seq(report_path)
3228
+ if seq is None:
3229
+ return
3230
+ state_dir = report_path.parent.parent / "state"
3231
+ expected = state_dir / f"plan-body-verification-implementation-planning-{seq}.json"
3232
+ if not expected.is_file():
3233
+ failures.append(
3234
+ f"plan-body verification ran ({round_count} round(s)) but "
3235
+ f"`state/{expected.name}` was not written. It is the only record "
3236
+ "of superseded rounds — data.json keeps just the final verdicts, "
3237
+ "so without it a self-fixed run leaves no trace of what the "
3238
+ 'earlier rounds found (plan-body-verification.md §"schema").'
3239
+ )
3240
+ return
3241
+ try:
3242
+ state = json.loads(expected.read_text(encoding="utf-8"))
3243
+ except (OSError, json.JSONDecodeError) as exc:
3244
+ failures.append(f"plan-body verification state file is unreadable: {exc}")
3245
+ return
3246
+ for key in ("schemaVersion", "gateResult", "planItems"):
3247
+ if key not in state:
3248
+ failures.append(
3249
+ f"plan-body verification state file `{expected.name}` is "
3250
+ f"missing required key `{key}`."
3251
+ )
3252
+
3253
+
3254
+ _QA_NOT_CONFIGURED_TEMPLATE = "qa-command not configured: {category}"
3255
+
3256
+
3257
+ def _validate_missing_qa_categories_recorded(
3258
+ data: dict,
3259
+ project_root: Path,
3260
+ failures: list[str],
3261
+ ) -> None:
3262
+ """"lint is clean" and "lint never ran" must not read the same.
3263
+
3264
+ The verifier contract requires one `qa-command not configured: <category>`
3265
+ line per category absent from `project.json.qaCommands`, but the literal
3266
+ appeared nowhere in the codebase — so a category that was never executed
3267
+ was indistinguishable in the report from one that passed. That reads as
3268
+ verification coverage the run does not have.
3269
+ """
3270
+ implementation = data.get("implementation")
3271
+ if not isinstance(implementation, dict):
3272
+ return
3273
+ results = [r for r in (implementation.get("verifierResults") or []) if isinstance(r, dict)]
3274
+ if not results:
3275
+ return
3276
+ # `db-test` is deliberately excluded: the contract requires its note only
3277
+ # when the diff touches DB/IO/SQL, and that condition is not decidable
3278
+ # here. Demanding it unconditionally would fail every project that has no
3279
+ # database — a false positive, which in a blocking validator is worse than
3280
+ # the gap it closes. The DB case keeps its own gate (the blocking finding
3281
+ # `db-test not configured — DB change unverified`).
3282
+ unconditional_categories = ("lint", "format", "typecheck", "test")
3283
+ path = project_json_path(project_root)
3284
+ if not path.is_file():
3285
+ return
3286
+ try:
3287
+ configured = json.loads(path.read_text(encoding="utf-8")).get("qaCommands") or {}
3288
+ except (OSError, json.JSONDecodeError):
3289
+ return
3290
+ if not isinstance(configured, dict):
3291
+ return
3292
+
3293
+ recorded = "\n".join(
3294
+ str(value)
3295
+ for row in results
3296
+ for value in row.values()
3297
+ if isinstance(value, str)
3298
+ )
3299
+ missing = [
3300
+ category
3301
+ for category in unconditional_categories
3302
+ if not configured.get(category)
3303
+ and _QA_NOT_CONFIGURED_TEMPLATE.format(category=category) not in recorded
3304
+ ]
3305
+ if missing:
3306
+ failures.append(
3307
+ "final-report data.json: `project.json.qaCommands` declares no "
3308
+ f"command for {missing}, but no verifier result records "
3309
+ f"`{_QA_NOT_CONFIGURED_TEMPLATE.format(category='<category>')}` for "
3310
+ "them. An unrun category MUST be stated as unrun — otherwise the "
3311
+ "report reads as though it passed "
3312
+ '(_implementation-verifier.md §"Missing-tier handling").'
3313
+ )
3314
+
3315
+
3316
+ def _load_report_data(report_path: Path) -> dict | None:
3317
+ """The report's data.json, or ``None`` when it is missing or malformed.
3318
+ Both cases are already reported by `validate_final_report_data`; callers
3319
+ here only need to skip rather than re-report."""
3320
+ data_path = _data_path_for(report_path)
3321
+ if not data_path.is_file():
3322
+ return None
3323
+ try:
3324
+ loaded = json.loads(data_path.read_text(encoding="utf-8"))
3325
+ except (OSError, json.JSONDecodeError):
3326
+ return None
3327
+ return loaded if isinstance(loaded, dict) else None
3328
+
3329
+
3330
+ def _validate_verification_target_match(
3331
+ data: dict,
3332
+ run_manifest: dict,
3333
+ project_root: Path,
3334
+ failures: list[str],
3335
+ ) -> None:
3336
+ """The verification report must mirror the target it was prepared against.
3337
+
3338
+ `verificationScope`, the worktree, and the base/head refs were entirely
3339
+ self-declared: the schema required the fields to exist but nothing compared
3340
+ them to the digest-verified snapshot written at prep time. That matters
3341
+ because both `handoff.compute_eligibility` and the `release-handoff`
3342
+ routing check read `verificationScope` — a single-stage run that writes
3343
+ `whole-task` passes both, and an `accepted` verdict can be rendered against
3344
+ a worktree or head nobody verified.
3345
+ """
3346
+ instruction_set = run_manifest.get("instructionSet")
3347
+ if not isinstance(instruction_set, dict):
3348
+ return
3349
+ relative = str(instruction_set.get("verificationTargetPath") or "").strip()
3350
+ if not relative:
3351
+ return
3352
+ target = _read_verification_target(project_root, relative)
3353
+ if target is None:
3354
+ return
3355
+
3356
+ source = (data.get("finalVerification") or {}).get("sourceImplementationReport") or {}
3357
+ declared = {
3358
+ "scope": str(data.get("verificationScope") or "").strip(),
3359
+ "worktree": str(source.get("worktreePath") or "").strip(),
3360
+ "base": str(source.get("implementationBaseRef") or "").strip(),
3361
+ "head": str(source.get("capturedHeadSha") or "").strip(),
3362
+ }
3363
+ for key, expected in ((k, target[k]) for k in _TARGET_FIELD_RES):
3364
+ actual = declared[key]
3365
+ if expected and actual and actual != expected:
3366
+ failures.append(
3367
+ f"final-verification report declares {key} `{actual}` but the "
3368
+ f"prepared verification target says `{expected}` "
3369
+ f"(`{relative}`). The report must mirror the target it was "
3370
+ "prepared against — `verificationScope` in particular gates "
3371
+ "both stage-group eligibility and release-handoff routing, so "
3372
+ "a self-declared value lets a run be judged as something it "
3373
+ "was not."
3374
+ )
3375
+
3376
+ declared_stages = {
3377
+ row.get("stage")
3378
+ for row in ((data.get("finalVerification") or {}).get("stageReports") or [])
3379
+ if isinstance(row, dict) and isinstance(row.get("stage"), int)
3380
+ }
3381
+ if target["stages"] and declared_stages and declared_stages != target["stages"]:
3382
+ failures.append(
3383
+ f"final-verification report covers stages {sorted(declared_stages)} "
3384
+ f"but the prepared target names {sorted(target['stages'])} "
3385
+ f"(`{relative}`). A verdict must not be rendered for a stage set "
3386
+ "nobody prepared evidence for."
3387
+ )
3388
+
3389
+
3390
+ def _validate_verified_row_recorded(
3391
+ data: dict,
3392
+ report_path: Path,
3393
+ failures: list[str],
3394
+ ) -> None:
3395
+ """An accepted single-stage verification must leave its `verified` row.
3396
+
3397
+ `okstra handoff record-verified` validates its own inputs, but nothing
3398
+ checked that it ever ran. Skipping it leaves the report saying `accepted`
3399
+ while `consumers.jsonl` says the stage was never verified, so
3400
+ `handoff.compute_eligibility` never offers it for a stage-group PR — and
3401
+ the only recoveries are re-running an expensive phase or hand-editing the
3402
+ registry.
3403
+ """
3404
+ if str(data.get("verificationScope") or "") != "single-stage":
3405
+ return
3406
+ token = str((data.get("finalVerdict") or {}).get("verdictToken") or "").strip()
3407
+ if token.lower() != "accepted":
3408
+ return
3409
+ stages = {
3410
+ row.get("stage")
3411
+ for row in ((data.get("finalVerification") or {}).get("stageReports") or [])
3412
+ if isinstance(row, dict) and isinstance(row.get("stage"), int)
3413
+ }
3414
+ if not stages:
3415
+ return
3416
+ rows = _consumers_rows(report_path)
3417
+ if rows is None:
3418
+ return
3419
+ verified = {
3420
+ row.get("stage")
3421
+ for row in rows
3422
+ if isinstance(row, dict) and row.get("status") == "verified"
3423
+ }
3424
+ missing = sorted(s for s in stages if s not in verified)
3425
+ if missing:
3426
+ failures.append(
3427
+ f"final-verification accepted stage(s) {missing} but "
3428
+ "`runs/implementation-planning/consumers.jsonl` carries no "
3429
+ "`verified` row for them. Run `okstra handoff record-verified` "
3430
+ "before finishing — without that row the report says accepted "
3431
+ "while the registry says unverified, and the stage is never "
3432
+ "offered for a stage-group PR "
3433
+ '(final-verification.md §"Verified-row recording").'
3434
+ )
3435
+
3436
+
3437
+ def _validate_verifier_fail_blocks_verdict(data: dict, failures: list[str]) -> None:
3438
+ """A verifier FAIL cannot be dropped during synthesis.
3439
+
3440
+ `implementation.verifierResults[]` was written, read by the stage-fix carry
3441
+ helper, and by nothing else — no check compared a recorded `FAIL` against
3442
+ the verdict the lead published. A FAIL lost in synthesis lets
3443
+ `final-verification` reach `accepted` and `release-handoff` push work a
3444
+ verifier rejected.
3445
+ """
3446
+ implementation = data.get("implementation")
3447
+ if not isinstance(implementation, dict):
3448
+ return
3449
+ failed = sorted({
3450
+ str(row.get("verifier") or "<unknown>")
3451
+ for row in (implementation.get("verifierResults") or [])
3452
+ if isinstance(row, dict) and str(row.get("verdict") or "").strip() == "FAIL"
3453
+ })
3454
+ if not failed:
3455
+ return
3456
+ token = str((data.get("verdictCard") or {}).get("verdictToken") or "").strip()
3457
+ if token in _PASSING_VERDICT_TOKENS:
3458
+ failures.append(
3459
+ f"final-report data.json: verifier(s) {failed} recorded "
3460
+ f"`verdict: FAIL` but `verdictCard.verdictToken` is `{token}`. A "
3461
+ "verifier rejection MUST survive into the published verdict — "
3462
+ "dropping it during synthesis is how rejected work reaches "
3463
+ "`release-handoff`. Either carry the FAIL into a blocking verdict "
3464
+ "or record why the verifier's finding was withdrawn."
3465
+ )
3466
+
3467
+
3468
+ def _validate_stage_carry_sidecar_exists(
3469
+ data: dict,
3470
+ report_path: Path,
3471
+ failures: list[str],
3472
+ ) -> None:
3473
+ """The stage carry sidecar must exist on disk, not only be transcribed.
3474
+
3475
+ `implementation.stageSidecarEvidence` is prose the report quotes, so a
3476
+ report can describe a sidecar that was never written. `consumers` treats
3477
+ the carry file as the source of truth for marking a stage `done`, so a
3478
+ missing file leaves the stage permanently un-done and blocks every
3479
+ dependent stage with a `PrepareError` — while the run that caused it
3480
+ finished reporting success.
3481
+ """
3482
+ implementation = data.get("implementation")
3483
+ if not isinstance(implementation, dict):
3484
+ return
3485
+ evidence = implementation.get("stageSidecarEvidence")
3486
+ if not isinstance(evidence, dict):
3487
+ return
3488
+ stage = evidence.get("stageNumber")
3489
+ if not isinstance(stage, int):
3490
+ return
3491
+ # `report_path` is `runs/implementation/reports/final-report-...md`.
3492
+ carry_path = report_path.parent.parent / "carry" / f"stage-{stage}.json"
3493
+ if not carry_path.exists():
3494
+ failures.append(
3495
+ f"implementation run declares stage-{stage} sidecar evidence but "
3496
+ f"`{carry_path.parent.name}/{carry_path.name}` does not exist. The "
3497
+ "carry file is what marks the stage `done` for dependent stages; "
3498
+ "without it this stage never completes and every successor fails "
3499
+ "to prepare, even though this run reported success "
3500
+ '(_implementation-executor.md §"Sidecar evidence writer").'
3501
+ )
3502
+
3503
+
3504
+ def _validate_round_recorded_verdicts(data: dict, failures: list[str]) -> None:
3505
+ """A round that ran must leave the votes it ran on.
3506
+
3507
+ The gate is re-derived from `planItems[].verdicts[]`, so an empty table
3508
+ removes the very evidence the recompute judges. A *healthier* declared gate
3509
+ is already caught — empty verdicts recompute to `aborted-non-result`, which
3510
+ every passing value outranks. What slipped through was the conservative
3511
+ declaration: a lead writing `aborted-non-result` over an empty table
3512
+ produces a gate nothing can audit, indistinguishable from a round that was
3513
+ dispatched and whose results were never transcribed.
3514
+ """
3515
+ ip = data.get("implementationPlanning")
3516
+ if not isinstance(ip, dict):
3517
+ return
3518
+ pbv = ip.get("planBodyVerification")
3519
+ if not isinstance(pbv, dict):
3520
+ return
3521
+ round_count = pbv.get("roundCount")
3522
+ if not isinstance(round_count, int) or round_count < 1:
3523
+ return
3524
+ items = [it for it in (pbv.get("planItems") or []) if isinstance(it, dict)]
3525
+ if not items:
3526
+ return
3527
+ if any(it.get("verdicts") for it in items):
3528
+ return
3529
+ failures.append(
3530
+ "final-report data.json: planBodyVerification declares "
3531
+ f"`roundCount`={round_count} but every one of the {len(items)} "
3532
+ "`planItems[]` carries an empty `verdicts[]`. A round that ran MUST "
3533
+ "record the votes it produced — the gate is re-derived from this "
3534
+ "table, so an empty one leaves the declared `gateResult` unauditable. "
3535
+ "A dispatch that returned nothing is recorded as `verification-error`, "
3536
+ 'not omitted (plan-body-verification.md §"Round protocol" step 4).'
3537
+ )
3538
+
3539
+
3540
+ def _validate_verdicts_match_current_subjects(
3541
+ data: dict,
3542
+ failures: list[str],
3543
+ ) -> None:
3544
+ """A verdict must still be attached to the element it was cast on.
3545
+
3546
+ `P-*` ids are positional (`plan_items.py` numbers rows by array index), so
3547
+ when a self-fix round deletes a plan element every later row shifts up one.
3548
+ A dangling id at the tail is already caught by
3549
+ `_validate_plan_item_extraction_completeness`, but the shift itself is not:
3550
+ the id set still matches while each surviving verdict now points at its
3551
+ neighbour. The recorded `subject` is what makes the shift visible — it is a
3552
+ snapshot of the row the worker actually judged.
3553
+ """
3554
+ ip = data.get("implementationPlanning")
3555
+ if not isinstance(ip, dict):
3556
+ return
3557
+ pbv = ip.get("planBodyVerification")
3558
+ if not isinstance(pbv, dict):
3559
+ return
3560
+ round_count = pbv.get("roundCount")
3561
+ if not isinstance(round_count, int) or round_count < 1:
3562
+ return
3563
+ try:
3564
+ current = {
3565
+ str(item["id"]): str(item.get("subject") or "")
3566
+ for item in extract_plan_items(ip)
3567
+ }
3568
+ except Exception: # noqa: BLE001
3569
+ # Extraction failure is already reported by the completeness check;
3570
+ # do not double-report it here as a spurious subject mismatch.
3571
+ return
3572
+
3573
+ drifted = []
3574
+ for item in pbv.get("planItems") or []:
3575
+ if not isinstance(item, dict):
3576
+ continue
3577
+ item_id = str(item.get("id") or "").strip()
3578
+ recorded = str(item.get("subject") or "").strip()
3579
+ expected = current.get(item_id)
3580
+ if expected is None or not recorded:
3581
+ continue
3582
+ if recorded != expected.strip():
3583
+ drifted.append(item_id)
3584
+ if drifted:
3585
+ failures.append(
3586
+ f"final-report data.json: plan item(s) {sorted(drifted)} carry a "
3587
+ "`subject` that no longer matches the plan element at that "
3588
+ "position. `P-*` ids are positional, so deleting an element during "
3589
+ "self-fix shifts every later row and silently re-points its "
3590
+ "verdicts at a different element — a recorded blocker then refers "
3591
+ "to something the reader cannot find. Re-extract the plan items "
3592
+ "and re-verify the shifted ones instead of carrying the old votes "
3593
+ 'forward (plan-body-verification.md §"Round protocol" step 7).'
3594
+ )
3595
+
3596
+
3597
+ def _validate_aborted_gate_has_clarification(data: dict, failures: list[str]) -> None:
3598
+ """A gate nobody can act on is a stalled task.
3599
+
3600
+ `aborted-non-result` correctly refuses approval and run-prep fail-closes
3601
+ the `implementation` entry, but the clarification matcher only walks
3602
+ `majority-disagree` items — and an aborted round has none. So the report
3603
+ stated no blocker, `okstra-user-response` had nothing to present, and the
3604
+ run stalled with no remedy until someone read the gate value by hand.
3605
+ """
3606
+ ip = data.get("implementationPlanning")
3607
+ if not isinstance(ip, dict):
3608
+ return
3609
+ pbv = ip.get("planBodyVerification")
3610
+ if not isinstance(pbv, dict):
3611
+ return
3612
+ if str(pbv.get("gateResult") or "").strip() != "aborted-non-result":
3613
+ return
3614
+ has_open_blocker = any(
3615
+ isinstance(row, dict)
3616
+ and row.get("blocks") == "approval"
3617
+ and str(row.get("status") or "").strip() == "open"
3618
+ for row in (data.get("clarificationItems") or [])
3619
+ )
3620
+ if not has_open_blocker:
3621
+ failures.append(
3622
+ "final-report data.json: planBodyVerification `gateResult` is "
3623
+ "`aborted-non-result` but no open `Blocks=approval` clarification "
3624
+ "row explains it. An aborted round blocks approval without "
3625
+ "producing any majority-disagree item, so without this row the "
3626
+ "report names no blocker, `okstra-user-response` has nothing to "
3627
+ "present, and the task stalls with no stated remedy. Add a row "
3628
+ "naming which dispatches returned no result and what re-running "
3629
+ 'them requires (plan-body-verification.md §"Round protocol").'
3630
+ )
3631
+
3632
+
3633
+ def _plan_verify_result_workers(report_path: Path, task_type: str) -> set[str] | None:
3634
+ """Worker roles that actually returned a plan-body reverify result.
3635
+
3636
+ Result files are named
3637
+ ``<role-slug>-plan-verify-r<N>-<task-type>-<seq>.md`` per
3638
+ `plan-body-verification.md` §"Round protocol" step 3, and the role slug is
3639
+ ``<role>-plan-verify-r<N>``. Returns ``None`` when the directory is absent
3640
+ so the caller can distinguish "no artifacts to check against" from "nobody
3641
+ voted".
3642
+ """
3643
+ worker_results_dir = report_path.parent.parent / "worker-results"
3644
+ if not worker_results_dir.is_dir():
3645
+ return None
3646
+ # Scoped to this run's seq for the same reason the audit check is: the
3647
+ # directory accumulates every run, so an unscoped glob would let a prior
3648
+ # run's result file vouch for a vote this run never collected.
3649
+ seq = _report_run_seq(report_path)
3650
+ pattern = f"*-plan-verify-r*-{task_type}-{seq or '*'}.md"
3651
+ workers = set()
3652
+ for path in worker_results_dir.glob(pattern):
3653
+ role = path.name.split("-plan-verify-r", 1)[0]
3654
+ if role:
3655
+ workers.add(role)
3656
+ return workers
3657
+
3658
+
3659
+ def _validate_plan_body_verdict_provenance(
3660
+ data: dict,
3661
+ report_path: Path,
3662
+ failures: list[str],
3663
+ ) -> None:
3664
+ """A recorded verdict must trace back to a worker that was actually asked.
3665
+
3666
+ Every §5.5.9 gate computation reads `planItems[].verdicts[]` out of the
3667
+ data.json the lead authored, and nothing tied a vote to a dispatch. A lead
3668
+ that skipped the round entirely and wrote `AGREE` for two workers produced
3669
+ `gateResult: passed`, a flippable `approved:`, and a clean validator run —
3670
+ the same self-report weakness `selfFixRoundsApplied` had, but on the votes
3671
+ the whole gate is computed from.
3672
+ """
3673
+ ip = data.get("implementationPlanning")
3674
+ if not isinstance(ip, dict):
3675
+ return
3676
+ pbv = ip.get("planBodyVerification")
3677
+ if not isinstance(pbv, dict):
3678
+ return
3679
+ round_count = pbv.get("roundCount")
3680
+ if not isinstance(round_count, int) or round_count < 1:
3681
+ return
3682
+
3683
+ voters = {
3684
+ str(v.get("worker") or "").strip()
3685
+ for item in (pbv.get("planItems") or [])
3686
+ if isinstance(item, dict)
3687
+ for v in (item.get("verdicts") or [])
3688
+ if isinstance(v, dict) and str(v.get("worker") or "").strip()
3689
+ }
3690
+ if not voters:
3691
+ return
3692
+
3693
+ task_type = str((data.get("header") or {}).get("taskType") or "")
3694
+ dispatched = _plan_verify_result_workers(report_path, task_type)
3695
+ if dispatched is None:
3696
+ return
3697
+ unbacked = sorted(voters - dispatched)
3698
+ if unbacked:
3699
+ failures.append(
3700
+ "final-report data.json: planBodyVerification records verdicts from "
3701
+ f"{unbacked} but no matching plan-body reverify result file exists "
3702
+ f"under `runs/{task_type}/worker-results/` "
3703
+ f"(expected `<worker>-plan-verify-r<N>-{task_type}-<seq>.md`). "
3704
+ "A vote the gate is computed from MUST trace back to a dispatch "
3705
+ "that actually returned — otherwise the round can be skipped and "
3706
+ 'the gate still read `passed` (plan-body-verification.md §"Round '
3707
+ 'protocol" step 3).'
3708
+ )
3709
+
3710
+
3711
+ def _validate_plan_item_extraction_completeness(
3712
+ data: dict,
3713
+ failures: list[str],
3714
+ ) -> None:
3715
+ """Require the exact deterministic P-* extraction when a round ran."""
3716
+ ip = data.get("implementationPlanning")
3717
+ if not isinstance(ip, dict):
3718
+ return
3719
+ pbv = ip.get("planBodyVerification")
3720
+ if not isinstance(pbv, dict):
3721
+ return
3722
+ round_count = pbv.get("roundCount")
3723
+ if not isinstance(round_count, int) or round_count < 1:
3724
+ return
3725
+ try:
3726
+ expected_sequence = expected_plan_item_ids(ip)
3727
+ except Exception as exc: # noqa: BLE001
3728
+ failures.append(
3729
+ "final-report data.json: deterministic plan-item extraction failed: "
3730
+ f"{exc}"
3731
+ )
3732
+ return
3733
+
3734
+ actual_sequence = [
3735
+ str(item.get("id") or "").strip()
3736
+ for item in (pbv.get("planItems") or [])
3737
+ if isinstance(item, dict)
3738
+ ]
2168
3739
  expected_ids = set(expected_sequence)
2169
3740
  actual_ids = set(actual_sequence)
2170
3741
  missing = expected_ids - actual_ids
@@ -2575,6 +4146,8 @@ def _validate_plan_body_clarification_matching(data: dict, failures: list[str])
2575
4146
  continue
2576
4147
  if _classify_plan_item_gate(item) != "majority-disagree":
2577
4148
  continue
4149
+ if _is_dissent_downgraded(item, pbv):
4150
+ continue
2578
4151
  item_id = item.get("id") or "<unknown>"
2579
4152
  cid = item.get("clarificationId")
2580
4153
  if not cid:
@@ -2597,12 +4170,6 @@ def _validate_plan_body_clarification_matching(data: dict, failures: list[str])
2597
4170
  )
2598
4171
 
2599
4172
 
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
4173
  def _validate_self_fix_before_clarification(data: dict, failures: list[str]) -> None:
2607
4174
  """A planner-fixable defect MUST exhaust the self-fix budget before it is
2608
4175
  promoted to a `## 1. Clarification Items` row. Closes the hole where the
@@ -2619,26 +4186,16 @@ def _validate_self_fix_before_clarification(data: dict, failures: list[str]) ->
2619
4186
  round_count = pbv.get("roundCount")
2620
4187
  if not isinstance(round_count, int) or round_count < 1:
2621
4188
  return
4189
+ if _self_fix_budget_exhausted(pbv):
4190
+ return
2622
4191
  rounds_applied = pbv.get("selfFixRoundsApplied")
2623
4192
  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
4193
  for item in pbv.get("planItems") or []:
2632
4194
  if not isinstance(item, dict):
2633
4195
  continue
2634
4196
  if _classify_plan_item_gate(item) != "majority-disagree":
2635
4197
  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):
4198
+ if _has_planner_fixable_majority(item):
2642
4199
  allowed = " / ".join(sorted(_SELF_FIX_EXHAUSTED_REASONS))
2643
4200
  failures.append(
2644
4201
  "final-report data.json: plan item "
@@ -3471,6 +5028,47 @@ _CONVERGENCE_INTERMEDIATE_PREFIXES = (
3471
5028
  )
3472
5029
 
3473
5030
 
5031
+ def _validate_convergence_rounds_match_manifest(
5032
+ run_dir,
5033
+ run_manifest: dict,
5034
+ failures: list[str],
5035
+ ) -> None:
5036
+ """The round budget must be the one the run was configured with.
5037
+
5038
+ The engine only checks `effectiveMaxRounds <= maxRounds` *within* the
5039
+ artifact, and the `okstra convergence` CLI never reads the manifest — so
5040
+ both numbers were lead-authored and unlinked to the `convergence.maxRounds`
5041
+ the run was prepared with. Writing `maxRounds: 1` into the artifact when
5042
+ the manifest says 2 legally short-circuits Round 2 via
5043
+ `round2SkippedReason: max-rounds-1` and halves cross-verification depth,
5044
+ with every self-consistency check satisfied.
5045
+ """
5046
+ from pathlib import Path as _Path
5047
+
5048
+ configured = (run_manifest.get("convergence") or {}).get("maxRounds")
5049
+ if not isinstance(configured, int):
5050
+ return
5051
+ state_dir = _Path(run_dir) / "state"
5052
+ if not state_dir.is_dir():
5053
+ return
5054
+ for state_path in sorted(state_dir.glob("convergence-*.json")):
5055
+ if state_path.name.startswith(_CONVERGENCE_INTERMEDIATE_PREFIXES):
5056
+ continue
5057
+ try:
5058
+ state = json.loads(state_path.read_text(encoding="utf-8"))
5059
+ except (OSError, json.JSONDecodeError):
5060
+ continue
5061
+ declared = (state.get("config") or {}).get("maxRounds")
5062
+ if isinstance(declared, int) and declared != configured:
5063
+ failures.append(
5064
+ f"convergence state `{state_path.name}` declares "
5065
+ f"`config.maxRounds`={declared} but the run manifest configured "
5066
+ f"{configured}. The round budget is not the lead's to restate — "
5067
+ "lowering it in the artifact silently halves cross-verification "
5068
+ "depth while every self-consistency check still passes."
5069
+ )
5070
+
5071
+
3474
5072
  def _validate_convergence_states(run_dir, failures) -> None:
3475
5073
  """Replay shared engine invariants for every public final state artifact."""
3476
5074
  from pathlib import Path as _Path
@@ -3494,6 +5092,292 @@ def _validate_convergence_states(run_dir, failures) -> None:
3494
5092
  )
3495
5093
 
3496
5094
 
5095
+ def _nonempty_string(value) -> bool:
5096
+ """True for a non-blank string, matching the schema's `\\S` pattern."""
5097
+ return isinstance(value, str) and value.strip() != ""
5098
+
5099
+
5100
+ # Round-0 grouping filename: `convergence-groups-<task-type>-<seq>.json`. The
5101
+ # `<task-type>-<seq>` suffix is shared verbatim with the worker-result and
5102
+ # working-state basenames, so capture it as one token to reconstruct both.
5103
+ _CONVERGENCE_GROUPS_BASENAME_RE = re.compile(
5104
+ r"^convergence-groups-(?P<suffix>[a-z][a-z-]*?-\d{3})\.json$"
5105
+ )
5106
+
5107
+
5108
+ def _convergence_group_claims(document) -> list[tuple[str, str, str]] | None:
5109
+ """Extract `(findingId, worker, itemId)` provenance claims from a grouping.
5110
+
5111
+ Returns ``None`` when the document does not match the convergence-groups
5112
+ schema shape closely enough to read its provenance fields safely — the
5113
+ caller refuses to judge such a file. Otherwise returns the deduplicated
5114
+ claims drawn from every group's ``sourceItems[]`` and ``discoveredBy`` map.
5115
+ """
5116
+ if not isinstance(document, dict) or not isinstance(document.get("groups"), list):
5117
+ return None
5118
+ claims: list[tuple[str, str, str]] = []
5119
+ seen: set[tuple[str, str, str]] = set()
5120
+ for group in document["groups"]:
5121
+ if not isinstance(group, dict):
5122
+ return None
5123
+ finding_id = group.get("findingId")
5124
+ source_items = group.get("sourceItems")
5125
+ discovered_by = group.get("discoveredBy")
5126
+ if (
5127
+ not _nonempty_string(finding_id)
5128
+ or not isinstance(source_items, list)
5129
+ or not isinstance(discovered_by, dict)
5130
+ ):
5131
+ return None
5132
+ pairs: list[tuple[object, object]] = []
5133
+ for item in source_items:
5134
+ if not isinstance(item, dict):
5135
+ return None
5136
+ pairs.append((item.get("worker"), item.get("itemId")))
5137
+ for worker, discovery in discovered_by.items():
5138
+ if not isinstance(discovery, dict):
5139
+ return None
5140
+ pairs.append((worker, discovery.get("itemId")))
5141
+ for worker, item_id in pairs:
5142
+ if not _nonempty_string(worker) or not _nonempty_string(item_id):
5143
+ return None
5144
+ claim = (finding_id, worker, item_id)
5145
+ if claim not in seen:
5146
+ seen.add(claim)
5147
+ claims.append(claim)
5148
+ return claims
5149
+
5150
+
5151
+ def _convergence_groups_digest_ok(state_dir, suffix: str, document: dict) -> bool:
5152
+ """False only when a recorded groupsDigest exists and no longer matches.
5153
+
5154
+ ``okstra convergence seed`` pins ``grouped_input_digest`` of the grouping
5155
+ into the working state. When that record is present and disagrees with the
5156
+ on-disk grouping, the file was tampered with or written out of band — not
5157
+ evidence of fabrication — so the caller refuses to judge it. A missing
5158
+ working state or missing digest means judge the file as parsed.
5159
+ """
5160
+ work_path = state_dir / f"convergence-work-{suffix}.json"
5161
+ try:
5162
+ work = json.loads(work_path.read_text(encoding="utf-8"))
5163
+ except (OSError, ValueError):
5164
+ return True
5165
+ recorded = work.get("groupsDigest") if isinstance(work, dict) else None
5166
+ if not _nonempty_string(recorded):
5167
+ return True
5168
+ return grouped_input_digest(document) == recorded
5169
+
5170
+
5171
+ def _read_canonical_worker_result(worker_results_dir, worker: str, suffix: str):
5172
+ """Return the `<worker>-<suffix>.md` text, or ``None`` when it is not a
5173
+ resolvable file directly inside ``worker-results/`` (missing, unreadable,
5174
+ or a worker slug that is not a single path component)."""
5175
+ candidate = worker_results_dir / f"{worker}-{suffix}.md"
5176
+ try:
5177
+ if candidate.resolve().parent != worker_results_dir.resolve():
5178
+ return None
5179
+ return candidate.read_text(encoding="utf-8")
5180
+ except (OSError, ValueError):
5181
+ return None
5182
+
5183
+
5184
+ def _id_occurs_wordbounded(text: str, item_id: str) -> bool:
5185
+ """True when `item_id` occurs as a literal not flanked by another
5186
+ identifier char, so `F-7` matches neither `F-70` nor `xF-7`."""
5187
+ pattern = r"(?<![0-9A-Za-z_-])" + re.escape(item_id) + r"(?![0-9A-Za-z_-])"
5188
+ return re.search(pattern, text) is not None
5189
+
5190
+
5191
+ def _validate_convergence_group_provenance(run_dir, failures) -> None:
5192
+ """Every source item a round-0 grouping cites must exist in the worker file.
5193
+
5194
+ The lead mints its own finding IDs, but each ``sourceItems[].itemId`` and
5195
+ ``discoveredBy.<worker>.itemId`` is copied verbatim from a worker's own
5196
+ result. A grouping that cites ``codex-worker:F-007`` when codex never wrote
5197
+ F-007 is a fabricated provenance link the final report cannot expose. This
5198
+ replays that one claim: the cited item ID must occur, word-boundary
5199
+ anchored, in the worker's canonical result file.
5200
+
5201
+ A validator failure blocks user approval, so unjudgeable input is skipped
5202
+ silently: no groups file, unreadable/malformed JSON, a groups file that
5203
+ fails the schema shape, a missing worker result file (provider-unavailable
5204
+ substitution is legitimate), or a recorded groupsDigest that no longer
5205
+ matches.
5206
+ """
5207
+ from pathlib import Path as _Path
5208
+
5209
+ state_dir = _Path(run_dir) / "state"
5210
+ worker_results_dir = _Path(run_dir) / "worker-results"
5211
+ if not state_dir.is_dir() or not worker_results_dir.is_dir():
5212
+ return
5213
+ for groups_path in sorted(state_dir.glob("convergence-groups-*.json")):
5214
+ match = _CONVERGENCE_GROUPS_BASENAME_RE.match(groups_path.name)
5215
+ if match is None:
5216
+ continue
5217
+ suffix = match.group("suffix")
5218
+ try:
5219
+ document = json.loads(groups_path.read_text(encoding="utf-8"))
5220
+ except (OSError, ValueError):
5221
+ continue
5222
+ claims = _convergence_group_claims(document)
5223
+ if claims is None:
5224
+ continue
5225
+ if not _convergence_groups_digest_ok(state_dir, suffix, document):
5226
+ continue
5227
+ contents: dict[str, str | None] = {}
5228
+ for finding_id, worker, item_id in claims:
5229
+ if worker not in contents:
5230
+ contents[worker] = _read_canonical_worker_result(
5231
+ worker_results_dir, worker, suffix
5232
+ )
5233
+ text = contents[worker]
5234
+ if text is None or _id_occurs_wordbounded(text, item_id):
5235
+ continue
5236
+ failures.append(
5237
+ f"convergence groups `{groups_path.name}` group {finding_id} "
5238
+ f"cites source item `{worker}:{item_id}`, but that ID does not "
5239
+ f"occur in the worker's result file `{worker}-{suffix}.md` — a "
5240
+ "grouping may not invent a provenance link to an item the "
5241
+ "worker never reported."
5242
+ )
5243
+
5244
+
5245
+ # Reverify-prompt basename: `<role-slug>-reverify-r<N>-<task-type>-<seq>.md`.
5246
+ # The role slug ends in `-worker` like the worker-result files, but the round
5247
+ # plan's `dispatches[].worker` may carry either that full slug or the bare
5248
+ # provider token (`claude` vs `claude-worker`), depending on how the roster was
5249
+ # seeded — worker resolution below accepts both. `-reverify-r` never occurs
5250
+ # inside a slug, so the non-greedy slug capture stops at that literal anchor.
5251
+ _REVERIFY_PROMPT_BASENAME_RE = re.compile(
5252
+ r"^(?P<slug>[a-z][a-z0-9-]*?)-reverify-r(?P<round>\d+)"
5253
+ r"-(?P<task_type>[a-z][a-z-]*?)-(?P<seq>\d{3})\.md$"
5254
+ )
5255
+
5256
+ # The reverify prompt's finding identifier is the lead-minted `### F-NNN` /
5257
+ # `### C-NNN` H3 heading inside the `## Findings to verify` block. The older
5258
+ # `### VF-N (origin ...)` / `G-NNN` heading shape this token regex deliberately
5259
+ # does not match, so historical prompts parse zero ids and are skipped.
5260
+ _FINDINGS_TO_VERIFY_HEADING_RE = re.compile(r"^##\s+Findings to verify\s*$")
5261
+ _TWO_HASH_HEADING_RE = re.compile(r"^##\s")
5262
+ _FINDING_HEADING_TOKEN_RE = re.compile(r"^###\s+(F-\d+|C-\d+)")
5263
+
5264
+
5265
+ def _reverify_prompt_finding_ids(content: str) -> set[str] | None:
5266
+ """Distinct `### F-NNN`/`### C-NNN` tokens in the `## Findings to verify`
5267
+ block, or ``None`` when the prompt has no such block.
5268
+
5269
+ The `## Response format` section re-echoes the same headings, so the block
5270
+ is bounded at the next `## ` heading and the tokens are collected into a
5271
+ set — order and re-echoed duplicates are irrelevant because votes are keyed
5272
+ by findingId. An empty set means the block exists but yielded no recognized
5273
+ heading (the historical `VF-N`/`G-NNN` shape); the caller skips both.
5274
+ """
5275
+ lines = content.splitlines()
5276
+ start = None
5277
+ for index, line in enumerate(lines):
5278
+ if _FINDINGS_TO_VERIFY_HEADING_RE.match(line):
5279
+ start = index + 1
5280
+ break
5281
+ if start is None:
5282
+ return None
5283
+ ids: set[str] = set()
5284
+ for line in lines[start:]:
5285
+ if _TWO_HASH_HEADING_RE.match(line):
5286
+ break # next `## ` section (e.g. `## Response format`) ends the block
5287
+ match = _FINDING_HEADING_TOKEN_RE.match(line)
5288
+ if match:
5289
+ ids.add(match.group(1))
5290
+ return ids
5291
+
5292
+
5293
+ def _plan_dispatch_finding_ids(plan, prompt_slug: str) -> set[str] | None:
5294
+ """The `findingIds` set of the single `dispatches[]` row the prompt slug
5295
+ resolves to, or ``None`` when the plan shape is malformed or the slug does
5296
+ not resolve to exactly one row (refuse to judge either way).
5297
+
5298
+ A prompt slug `claude-worker` matches a row whose `worker` is `claude-worker`
5299
+ (exact) or `claude` (bare token, `worker + "-worker" == slug`). The round
5300
+ plan has no JSON schema, so the `dispatches[]` shape is validated defensively.
5301
+ """
5302
+ if not isinstance(plan, dict):
5303
+ return None
5304
+ dispatches = plan.get("dispatches")
5305
+ if not isinstance(dispatches, list):
5306
+ return None
5307
+ matched: list[set[str]] = []
5308
+ for row in dispatches:
5309
+ if not isinstance(row, dict):
5310
+ return None
5311
+ worker = row.get("worker")
5312
+ finding_ids = row.get("findingIds")
5313
+ if not _nonempty_string(worker) or not isinstance(finding_ids, list):
5314
+ return None
5315
+ if any(not _nonempty_string(fid) for fid in finding_ids):
5316
+ return None
5317
+ if worker == prompt_slug or f"{worker}-worker" == prompt_slug:
5318
+ matched.append(set(finding_ids))
5319
+ if len(matched) != 1:
5320
+ return None
5321
+ return matched[0]
5322
+
5323
+
5324
+ def _validate_reverify_prompt_matches_plan(run_dir, failures) -> None:
5325
+ """Each reverify prompt must show exactly the findings its plan row assigned.
5326
+
5327
+ `okstra convergence apply-round` checks *who* voted (`votes ⊆ plan`), but
5328
+ nothing checks that the reverify *prompt* carried the findings the plan
5329
+ assigned. A prompt-generation bug or a tampered prompt that drops or adds a
5330
+ `### F-NNN` heading relative to `dispatches[].findingIds` goes undetected,
5331
+ and a vote can be attributed to a finding the worker was never shown. This
5332
+ replays `plan == prompt`, which with the engine's check yields
5333
+ `votes ⊆ what-was-shown`. It stays independent of the engine's own check.
5334
+
5335
+ A validator failure blocks user approval, so any unjudgeable input is
5336
+ skipped silently: no paired plan file for the prompt's (round, task-type,
5337
+ seq) — which also excludes a superseded plan whose seq/task-type differs;
5338
+ no recognizable `## Findings to verify` block or zero headings (guards the
5339
+ historical `VF-N`/`G-NNN` formats); a worker slug that resolves to other
5340
+ than exactly one `dispatches[]` row; and an unreadable/malformed plan.
5341
+ """
5342
+ from pathlib import Path as _Path
5343
+
5344
+ run_dir = _Path(run_dir)
5345
+ prompts_dir = run_dir / "prompts"
5346
+ state_dir = run_dir / "state"
5347
+ if not prompts_dir.is_dir() or not state_dir.is_dir():
5348
+ return
5349
+ for prompt_path in sorted(prompts_dir.glob("*-reverify-r*.md")):
5350
+ match = _REVERIFY_PROMPT_BASENAME_RE.match(prompt_path.name)
5351
+ if match is None:
5352
+ continue
5353
+ round_n = match.group("round")
5354
+ plan_path = state_dir / (
5355
+ f"convergence-round-{round_n}-plan-"
5356
+ f"{match.group('task_type')}-{match.group('seq')}.json"
5357
+ )
5358
+ if not plan_path.is_file():
5359
+ continue # no paired plan (~96% of artifacts) — never infer from the prompt
5360
+ try:
5361
+ content = prompt_path.read_text(encoding="utf-8")
5362
+ plan = json.loads(plan_path.read_text(encoding="utf-8"))
5363
+ except (OSError, ValueError):
5364
+ continue
5365
+ prompt_ids = _reverify_prompt_finding_ids(content)
5366
+ if not prompt_ids: # None (no block) or empty set (no F-/C- headings)
5367
+ continue
5368
+ plan_ids = _plan_dispatch_finding_ids(plan, match.group("slug"))
5369
+ if plan_ids is None or prompt_ids == plan_ids:
5370
+ continue
5371
+ failures.append(
5372
+ f"reverify prompt `{prompt_path.name}` (round {round_n}, worker "
5373
+ f"`{match.group('slug')}`) does not show the findings its plan row "
5374
+ f"assigned: in the plan but not the prompt {sorted(plan_ids - prompt_ids)}; "
5375
+ f"in the prompt but not the plan {sorted(prompt_ids - plan_ids)}. The "
5376
+ f"prompt must carry exactly `dispatches[].findingIds` so a vote cannot "
5377
+ f"be attributed to a finding the worker was never shown."
5378
+ )
5379
+
5380
+
3497
5381
  def _validate_requirements_discovery_fanout(run_dir, failures, brief_path=None) -> None:
3498
5382
  """requirements-discovery run 에 fan-out/ 이 있으면 packet+index 를 검증해
3499
5383
  실패를 ``requirements-discovery: `` 접두로 folding 한다. fan-out 이 없으면 no-op.
@@ -3894,6 +5778,7 @@ def main() -> int:
3894
5778
  contract["required_agent_status_entries"],
3895
5779
  failures,
3896
5780
  allow_unavailable_token_usage=_session_accounting(team_state) == "artifact-only",
5781
+ unavailable_ok_labels=_cli_wrapper_worker_labels(team_state),
3897
5782
  )
3898
5783
  validate_team_state_usage(team_state, failures)
3899
5784
 
@@ -3925,7 +5810,17 @@ def main() -> int:
3925
5810
  _sp = (json.loads(_pj.read_text()).get("qaEnv") or {}).get("surfacePatterns")
3926
5811
  except (OSError, json.JSONDecodeError):
3927
5812
  _sp = None
3928
- _validate_conformance(report_path, failures, surface_patterns=_sp)
5813
+ conformance_warnings = _validate_conformance(
5814
+ report_path,
5815
+ failures,
5816
+ surface_patterns=_sp,
5817
+ approved_plan_path=_approved_plan_path_from_run_inputs(
5818
+ run_manifest_path,
5819
+ failures,
5820
+ ),
5821
+ )
5822
+ for warning in conformance_warnings:
5823
+ print(f"validate-run: warning: {warning}", file=sys.stderr)
3929
5824
  if task_type == "implementation-planning":
3930
5825
  _validate_planning_conformance_declared(report_path, failures)
3931
5826
  brief_relative = str(task_manifest.get("taskBriefPath") or "").strip()
@@ -3957,7 +5852,35 @@ def main() -> int:
3957
5852
  _validate_requirements_discovery_fanout(run_dir, failures, brief_path)
3958
5853
  # Phase-agnostic: convergence runs in every finding-producing phase.
3959
5854
  _validate_convergence_states(report_path.parent.parent, failures)
5855
+ _validate_convergence_rounds_match_manifest(
5856
+ report_path.parent.parent, run_manifest, failures
5857
+ )
5858
+ _validate_convergence_group_provenance(report_path.parent.parent, failures)
5859
+ _validate_reverify_prompt_matches_plan(report_path.parent.parent, failures)
3960
5860
  validate_report_views(report_path, failures)
5861
+ if task_type == "implementation":
5862
+ _validate_missing_qa_categories_recorded(
5863
+ _load_report_data(report_path) or {}, project_root, failures
5864
+ )
5865
+ if task_type == "implementation-planning":
5866
+ _validate_plan_body_state_file(
5867
+ _load_report_data(report_path) or {}, report_path, failures
5868
+ )
5869
+ if task_type == "final-verification":
5870
+ _validate_verification_target_match(
5871
+ _load_report_data(report_path) or {},
5872
+ run_manifest,
5873
+ project_root,
5874
+ failures,
5875
+ )
5876
+ # Phase-agnostic: any task-type can be launched with a carry-in.
5877
+ _validate_clarification_carry_in_recorded(
5878
+ _load_report_data(report_path) or {},
5879
+ report_path,
5880
+ run_manifest_path,
5881
+ project_root,
5882
+ failures,
5883
+ )
3961
5884
 
3962
5885
  validation_status = "passed" if not failures else "failed"
3963
5886
  update_validation_metadata(