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