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