okstra 0.171.0 → 0.173.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -6
- package/docs/architecture/storage-model.md +11 -0
- package/docs/architecture.md +29 -14
- package/docs/cli.md +40 -7
- package/docs/for-ai/skills/okstra-user-response.md +2 -2
- package/docs/performance-improvement-plan-v2.md +6 -5
- package/docs/project-structure-overview.md +24 -14
- package/docs/task-process/README.md +5 -3
- package/docs/task-process/error-analysis.md +2 -2
- package/docs/task-process/final-verification.md +2 -2
- package/docs/task-process/implementation-option-selection.md +70 -0
- package/docs/task-process/implementation-planning.md +23 -15
- package/docs/task-process/requirements-discovery.md +2 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +30 -6
- package/runtime/bin/lib/okstra/cli.sh +5 -1
- package/runtime/bin/lib/okstra/globals.sh +1 -0
- package/runtime/bin/lib/okstra/usage.sh +3 -0
- package/runtime/bin/okstra.sh +2 -0
- package/runtime/prompts/duties/direction-selection-worker.md +44 -0
- package/runtime/prompts/duties/planning-worker.md +12 -4
- package/runtime/prompts/launch.template.md +4 -0
- package/runtime/prompts/lead/adapters/cmux.md +1 -1
- package/runtime/prompts/lead/context-loader.md +1 -1
- package/runtime/prompts/lead/convergence.md +5 -5
- package/runtime/prompts/lead/okstra-lead-contract.md +42 -17
- package/runtime/prompts/lead/plan-body-verification.md +42 -14
- package/runtime/prompts/lead/report-writer.md +38 -15
- package/runtime/prompts/lead/team-contract.md +2 -0
- package/runtime/prompts/profiles/_clarification-recommendation.md +3 -1
- package/runtime/prompts/profiles/_common-contract.md +3 -2
- package/runtime/prompts/profiles/_implementation-deliverable.md +2 -2
- package/runtime/prompts/profiles/error-analysis.md +3 -3
- package/runtime/prompts/profiles/final-verification.md +3 -3
- package/runtime/prompts/profiles/forbidden-actions.json +7 -0
- package/runtime/prompts/profiles/implementation-option-selection.md +35 -0
- package/runtime/prompts/profiles/implementation-planning.md +56 -37
- package/runtime/prompts/profiles/implementation.md +2 -1
- package/runtime/prompts/profiles/improvement-discovery.md +1 -1
- package/runtime/prompts/profiles/requirements-discovery.md +3 -3
- package/runtime/prompts/wizard/prompts.ko.json +9 -1
- package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +1 -1
- package/runtime/python/okstra_ctl/agent_activity.py +306 -0
- package/runtime/python/okstra_ctl/agent_invocation.py +1 -0
- package/runtime/python/okstra_ctl/analysis_packet.py +6 -0
- package/runtime/python/okstra_ctl/clarification_items.py +37 -20
- package/runtime/python/okstra_ctl/exact_coverage.py +128 -0
- package/runtime/python/okstra_ctl/fix_cycles.py +3 -1
- package/runtime/python/okstra_ctl/implementation_direction.py +836 -0
- package/runtime/python/okstra_ctl/implementation_options.py +479 -0
- package/runtime/python/okstra_ctl/lead_events.py +47 -4
- package/runtime/python/okstra_ctl/plan_items.py +51 -3
- package/runtime/python/okstra_ctl/render.py +12 -3
- package/runtime/python/okstra_ctl/render_final_report.py +1 -0
- package/runtime/python/okstra_ctl/report_contract.py +45 -13
- package/runtime/python/okstra_ctl/report_finalize.py +51 -14
- package/runtime/python/okstra_ctl/report_html/common.py +5 -3
- package/runtime/python/okstra_ctl/report_html/render.py +4 -2
- package/runtime/python/okstra_ctl/report_html/router.py +4 -0
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_option_selection.py +32 -0
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +42 -11
- package/runtime/python/okstra_ctl/report_translation.py +14 -0
- package/runtime/python/okstra_ctl/report_views.py +148 -12
- package/runtime/python/okstra_ctl/run.py +350 -2
- package/runtime/python/okstra_ctl/scope_provenance.py +15 -9
- package/runtime/python/okstra_ctl/user_response.py +75 -0
- package/runtime/python/okstra_ctl/wizard.py +144 -0
- package/runtime/python/okstra_ctl/worker_audit_ledger.py +150 -0
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +2 -0
- package/runtime/python/okstra_ctl/workflow.py +29 -7
- package/runtime/schemas/final-report-v2.0.schema.json +1623 -143
- package/runtime/skills/okstra-user-response/SKILL.md +2 -2
- package/runtime/templates/reports/final-report-v2.template.md +12 -0
- package/runtime/templates/reports/final-verification-input.template.md +1 -1
- package/runtime/templates/reports/html/assets/base.css +7 -0
- package/runtime/templates/reports/html/base.template.html +3 -2
- package/runtime/templates/reports/html/i18n/en.json +27 -2
- package/runtime/templates/reports/html/i18n/ko.json +27 -2
- package/runtime/templates/reports/html/macros/forms.html +42 -4
- package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +49 -0
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +61 -2
- package/runtime/templates/reports/i18n/en.json +17 -0
- package/runtime/templates/reports/implementation-input.template.md +4 -2
- package/runtime/templates/reports/implementation-planning-input.template.md +18 -4
- package/runtime/templates/reports/improvement-discovery-input.template.md +1 -1
- package/runtime/templates/reports/md/tasks/implementation-option-selection.template.md +13 -0
- package/runtime/templates/reports/md/tasks/implementation-planning.template.md +17 -0
- package/runtime/templates/reports/report.js +137 -21
- package/runtime/templates/reports/task-brief.template.md +9 -3
- package/runtime/templates/reports/user-response.template.md +28 -5
- package/runtime/templates/worker-prompt-preamble.md +16 -0
- package/runtime/validators/validate-implementation-plan-stages.py +106 -1
- package/runtime/validators/validate-report-views.py +2 -2
- package/runtime/validators/validate-run.py +1124 -54
- package/runtime/validators/validate_improvement_report.py +5 -1
- package/runtime/validators/validate_session_conformance.py +523 -35
- package/src/cli-registry.mjs +7 -0
- package/src/commands/execute/codex-run.mjs +1 -0
- package/src/commands/execute/render-bundle.mjs +1 -0
- package/src/commands/report/agent-activity.mjs +21 -0
|
@@ -71,7 +71,12 @@ from okstra_ctl.incremental_scope import ( # noqa: E402
|
|
|
71
71
|
coverage_row_blocked_on,
|
|
72
72
|
stages_for_clarification,
|
|
73
73
|
)
|
|
74
|
-
from okstra_ctl.workflow import
|
|
74
|
+
from okstra_ctl.workflow import ( # noqa: E402
|
|
75
|
+
DEFAULT_NEXT_PHASE,
|
|
76
|
+
ERROR_ANALYSIS_ROUTING_DIRECTIONS,
|
|
77
|
+
PHASE_SEQUENCE,
|
|
78
|
+
REQUIREMENTS_DISCOVERY_ROUTING_TARGETS,
|
|
79
|
+
)
|
|
75
80
|
from okstra_ctl.md_table import ( # noqa: E402
|
|
76
81
|
is_separator_row as _is_markdown_separator,
|
|
77
82
|
split_pipe_row as _split_pipe_row,
|
|
@@ -80,9 +85,16 @@ from okstra_ctl.final_report_paths import final_report_data_path as _data_path_f
|
|
|
80
85
|
from okstra_ctl.improvement_assignment import ( # noqa: E402
|
|
81
86
|
validate_primary_lens_assignments,
|
|
82
87
|
)
|
|
88
|
+
from okstra_ctl.implementation_options import ( # noqa: E402
|
|
89
|
+
validate_implementation_option_selection,
|
|
90
|
+
)
|
|
91
|
+
from okstra_ctl.implementation_direction import ( # noqa: E402
|
|
92
|
+
validate_selected_direction_plan,
|
|
93
|
+
)
|
|
83
94
|
from okstra_ctl.worker_prompt_policy import GRILLING_LOG_HEADER # noqa: E402
|
|
84
95
|
from okstra_ctl.scope_provenance import ( # noqa: E402
|
|
85
96
|
brief_citation_problem,
|
|
97
|
+
brief_end_state_id_sequence,
|
|
86
98
|
brief_end_state_ids,
|
|
87
99
|
brief_headings,
|
|
88
100
|
parse_source,
|
|
@@ -113,6 +125,7 @@ from okstra_ctl.agent_invocation import ( # noqa: E402
|
|
|
113
125
|
agent_model_assignment_from_payload,
|
|
114
126
|
verify_agent_invocation,
|
|
115
127
|
)
|
|
128
|
+
from okstra_ctl.lead_events import LeadEventParseError, read_lead_events # noqa: E402
|
|
116
129
|
from okstra_ctl.worker_audit_ledger import ( # noqa: E402
|
|
117
130
|
READING_CONFIRMATION_HEADING_RE,
|
|
118
131
|
check_worker_results_audit,
|
|
@@ -405,7 +418,28 @@ def _error_analysis_next_phase(data: Mapping[str, Any]) -> str | None:
|
|
|
405
418
|
if not isinstance(routing, Mapping):
|
|
406
419
|
return None
|
|
407
420
|
target = routing.get("nextTaskType")
|
|
408
|
-
if target in
|
|
421
|
+
if target in ERROR_ANALYSIS_ROUTING_DIRECTIONS:
|
|
422
|
+
return str(target)
|
|
423
|
+
return None
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _requirements_discovery_next_phase(data: Mapping[str, Any]) -> str | None:
|
|
427
|
+
if not isinstance(data, Mapping):
|
|
428
|
+
return None
|
|
429
|
+
header = data.get("header")
|
|
430
|
+
if (
|
|
431
|
+
not isinstance(header, Mapping)
|
|
432
|
+
or header.get("taskType") != "requirements-discovery"
|
|
433
|
+
):
|
|
434
|
+
return None
|
|
435
|
+
requirements = data.get("requirementsDiscovery")
|
|
436
|
+
if not isinstance(requirements, Mapping):
|
|
437
|
+
return None
|
|
438
|
+
routing = requirements.get("routing")
|
|
439
|
+
if not isinstance(routing, Mapping):
|
|
440
|
+
return None
|
|
441
|
+
target = routing.get("nextTaskType")
|
|
442
|
+
if target in REQUIREMENTS_DISCOVERY_ROUTING_TARGETS:
|
|
409
443
|
return str(target)
|
|
410
444
|
return None
|
|
411
445
|
|
|
@@ -444,11 +478,12 @@ def update_workflow_metadata(
|
|
|
444
478
|
# Validation just passed → actively advance to the next phase in
|
|
445
479
|
# the sequence rather than preserving a stale value that may equal
|
|
446
480
|
# current_phase (which would cause the lifecycle pointer to stall).
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
481
|
+
if current_phase == "requirements-discovery":
|
|
482
|
+
report_next_phase = _requirements_discovery_next_phase(report_data or {})
|
|
483
|
+
elif current_phase == "error-analysis":
|
|
484
|
+
report_next_phase = _error_analysis_next_phase(report_data or {})
|
|
485
|
+
else:
|
|
486
|
+
report_next_phase = None
|
|
452
487
|
next_recommended_phase = report_next_phase or advance_next_phase(
|
|
453
488
|
current_phase, phase_sequence
|
|
454
489
|
)
|
|
@@ -3141,10 +3176,15 @@ def _validate_error_analysis_consistency(
|
|
|
3141
3176
|
target = routing.get("nextTaskType")
|
|
3142
3177
|
leading_cause_id = routing.get("leadingCauseId")
|
|
3143
3178
|
candidate_id_set = set(candidate_ids)
|
|
3144
|
-
if target
|
|
3179
|
+
if isinstance(target, str) and target not in ERROR_ANALYSIS_ROUTING_DIRECTIONS:
|
|
3180
|
+
failures.append(
|
|
3181
|
+
"final-report data.json: errorAnalysis.routing has unsupported "
|
|
3182
|
+
f"routing target `{target}`."
|
|
3183
|
+
)
|
|
3184
|
+
if target == "implementation-option-selection":
|
|
3145
3185
|
if not candidates:
|
|
3146
3186
|
failures.append(
|
|
3147
|
-
"final-report data.json: implementation-
|
|
3187
|
+
"final-report data.json: implementation-option-selection routing requires "
|
|
3148
3188
|
"at least one cause candidate."
|
|
3149
3189
|
)
|
|
3150
3190
|
if (
|
|
@@ -3152,7 +3192,7 @@ def _validate_error_analysis_consistency(
|
|
|
3152
3192
|
or leading_cause_id not in candidate_id_set
|
|
3153
3193
|
):
|
|
3154
3194
|
failures.append(
|
|
3155
|
-
"final-report data.json: implementation-
|
|
3195
|
+
"final-report data.json: implementation-option-selection routing "
|
|
3156
3196
|
"leadingCauseId must reference a cause candidate."
|
|
3157
3197
|
)
|
|
3158
3198
|
elif target == "error-analysis" and (
|
|
@@ -3164,10 +3204,7 @@ def _validate_error_analysis_consistency(
|
|
|
3164
3204
|
"empty or reference a cause candidate."
|
|
3165
3205
|
)
|
|
3166
3206
|
|
|
3167
|
-
expected_direction =
|
|
3168
|
-
"implementation-planning": "begin-planning",
|
|
3169
|
-
"error-analysis": "continue-investigation",
|
|
3170
|
-
}.get(target)
|
|
3207
|
+
expected_direction = ERROR_ANALYSIS_ROUTING_DIRECTIONS.get(target)
|
|
3171
3208
|
verdict_card_value = data.get("verdictCard")
|
|
3172
3209
|
verdict_card = (
|
|
3173
3210
|
verdict_card_value if isinstance(verdict_card_value, Mapping) else {}
|
|
@@ -3229,7 +3266,7 @@ def _validate_error_analysis_consistency(
|
|
|
3229
3266
|
|
|
3230
3267
|
if isinstance(target, str) and target in {
|
|
3231
3268
|
"error-analysis",
|
|
3232
|
-
"implementation-
|
|
3269
|
+
"implementation-option-selection",
|
|
3233
3270
|
}:
|
|
3234
3271
|
for field_name, value in (
|
|
3235
3272
|
("verdictCard.nextStep", verdict_card.get("nextStep")),
|
|
@@ -3343,11 +3380,15 @@ def validate_final_report_data(
|
|
|
3343
3380
|
if errors:
|
|
3344
3381
|
return data
|
|
3345
3382
|
|
|
3383
|
+
manifest = run_manifest or {}
|
|
3384
|
+
_validate_approval_context(data, manifest, failures, report_path)
|
|
3385
|
+
_validate_activity_contract_plan_limits(data, manifest, failures)
|
|
3386
|
+
|
|
3346
3387
|
analysis_result = validate_analysis_report(
|
|
3347
3388
|
data=data,
|
|
3348
3389
|
report_path=report_path,
|
|
3349
3390
|
project_root=project_root or report_path.parent,
|
|
3350
|
-
run_manifest=
|
|
3391
|
+
run_manifest=manifest,
|
|
3351
3392
|
clarification_text=clarification_text,
|
|
3352
3393
|
)
|
|
3353
3394
|
manifest_task_type = str((run_manifest or {}).get("taskType") or "")
|
|
@@ -3368,7 +3409,25 @@ def validate_final_report_data(
|
|
|
3368
3409
|
|
|
3369
3410
|
task_type = (data.get("header") or {}).get("taskType")
|
|
3370
3411
|
_validate_verifier_fail_blocks_verdict(data, failures)
|
|
3371
|
-
if task_type == "implementation":
|
|
3412
|
+
if task_type == "implementation-option-selection":
|
|
3413
|
+
selection = data.get("implementationOptionSelection") or {}
|
|
3414
|
+
validation_root = project_root or report_path.parent
|
|
3415
|
+
original_ids = brief_end_state_id_sequence(
|
|
3416
|
+
_brief_path_from_manifest(manifest, validation_root)
|
|
3417
|
+
)
|
|
3418
|
+
roster = manifest.get("recommendedWorkers") or ()
|
|
3419
|
+
participating_analysers = tuple(
|
|
3420
|
+
worker for worker in roster if worker != "report-writer"
|
|
3421
|
+
)
|
|
3422
|
+
failures.extend(
|
|
3423
|
+
f"implementation-option-selection: {error}"
|
|
3424
|
+
for error in validate_implementation_option_selection(
|
|
3425
|
+
selection,
|
|
3426
|
+
original_ids,
|
|
3427
|
+
participating_analysers,
|
|
3428
|
+
)
|
|
3429
|
+
)
|
|
3430
|
+
elif task_type == "implementation":
|
|
3372
3431
|
_validate_stage_carry_sidecar_exists(data, report_path, failures)
|
|
3373
3432
|
if task_type == "error-analysis":
|
|
3374
3433
|
_validate_error_analysis_consistency(data, failures)
|
|
@@ -3377,6 +3436,31 @@ def validate_final_report_data(
|
|
|
3377
3436
|
_validate_verified_row_recorded(data, report_path, failures)
|
|
3378
3437
|
elif task_type == "implementation-planning":
|
|
3379
3438
|
active_report_contracts = report_contracts or set()
|
|
3439
|
+
planning = data.get("implementationPlanning") or {}
|
|
3440
|
+
selected_direction_contract = (
|
|
3441
|
+
isinstance(planning, Mapping)
|
|
3442
|
+
and planning.get("planningContract") == "selected-direction"
|
|
3443
|
+
)
|
|
3444
|
+
if selected_direction_contract:
|
|
3445
|
+
validation_root = project_root or report_path.parent
|
|
3446
|
+
try:
|
|
3447
|
+
brief_path = _brief_path_from_manifest(manifest, validation_root)
|
|
3448
|
+
except (OSError, ValueError) as exc:
|
|
3449
|
+
failures.append(
|
|
3450
|
+
"implementation-planning selected-direction: run manifest "
|
|
3451
|
+
f"taskBriefPath is malformed: {exc}"
|
|
3452
|
+
)
|
|
3453
|
+
brief_path = validation_root / "__invalid-brief__"
|
|
3454
|
+
task_root = _task_root_from_run_dir(report_path.parent.parent)
|
|
3455
|
+
snapshot_path = task_root / "instruction-set" / "selected-direction.json"
|
|
3456
|
+
failures.extend(
|
|
3457
|
+
f"implementation-planning selected-direction: {error}"
|
|
3458
|
+
for error in validate_selected_direction_plan(
|
|
3459
|
+
data, brief_path, snapshot_path
|
|
3460
|
+
)
|
|
3461
|
+
)
|
|
3462
|
+
if planning.get("outcome") == "direction-invalidated":
|
|
3463
|
+
return data
|
|
3380
3464
|
_validate_implementation_planning_cross_project(data, failures)
|
|
3381
3465
|
_validate_implementation_planning_decision_drafts(data, failures)
|
|
3382
3466
|
for warning in validate_plan_body_section(data, report_path, failures):
|
|
@@ -3396,8 +3480,9 @@ def validate_final_report_data(
|
|
|
3396
3480
|
resolve_architecture(_project_root_from_report(report_path)),
|
|
3397
3481
|
failures,
|
|
3398
3482
|
)
|
|
3399
|
-
|
|
3400
|
-
|
|
3483
|
+
if not selected_direction_contract:
|
|
3484
|
+
_validate_requirement_deviations(data, failures)
|
|
3485
|
+
_validate_requirement_coverage_covered_by(data, failures)
|
|
3401
3486
|
warnings = _validate_design_prep_contract(
|
|
3402
3487
|
data,
|
|
3403
3488
|
report_path,
|
|
@@ -3750,32 +3835,66 @@ def _state_classification(item: dict, gate_class: str) -> str:
|
|
|
3750
3835
|
return "dissent-isolated" if dissenting == 1 else "partial-consensus"
|
|
3751
3836
|
|
|
3752
3837
|
|
|
3753
|
-
def
|
|
3838
|
+
def _resolved_noncritical_dissent_ids(data: dict) -> set[str]:
|
|
3839
|
+
"""Plan items whose remaining dissent the user explicitly accepted."""
|
|
3840
|
+
accepted: set[str] = set()
|
|
3841
|
+
for row in data.get("clarificationItems") or []:
|
|
3842
|
+
if not isinstance(row, dict) or row.get("blocks") != "approval":
|
|
3843
|
+
continue
|
|
3844
|
+
context = row.get("approvalContext")
|
|
3845
|
+
if not isinstance(context, dict):
|
|
3846
|
+
continue
|
|
3847
|
+
resolution = context.get("resolution")
|
|
3848
|
+
if (
|
|
3849
|
+
row.get("status") == "resolved"
|
|
3850
|
+
and context.get("classification") == "noncritical-dissent"
|
|
3851
|
+
and isinstance(resolution, dict)
|
|
3852
|
+
and resolution.get("disposition") == "accept-risk"
|
|
3853
|
+
and str(resolution.get("userText") or "").strip()
|
|
3854
|
+
and _approval_context_activity_refs_exist(
|
|
3855
|
+
data, str(row.get("id") or ""), context, resolution
|
|
3856
|
+
)
|
|
3857
|
+
):
|
|
3858
|
+
accepted.update(
|
|
3859
|
+
item_id
|
|
3860
|
+
for item_id in context.get("planItemIds") or []
|
|
3861
|
+
if isinstance(item_id, str)
|
|
3862
|
+
)
|
|
3863
|
+
return accepted
|
|
3864
|
+
|
|
3865
|
+
|
|
3866
|
+
def _is_dissent_downgraded(
|
|
3867
|
+
item: dict,
|
|
3868
|
+
pbv: dict,
|
|
3869
|
+
accepted_item_ids: set[str],
|
|
3870
|
+
) -> bool:
|
|
3754
3871
|
"""Whether a surviving `majority-disagree` item stops blocking approval.
|
|
3755
3872
|
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
instead of blocking the gate. Defects that would make the implementation
|
|
3761
|
-
itself wrong or unsafe (`_is_correctness_critical`) are excluded and keep
|
|
3762
|
-
blocking, so correctness never trades away for throughput.
|
|
3873
|
+
Exhausting the automatic self-fix budget records the unresolved dissent but
|
|
3874
|
+
does not accept it. Only an explicit, resolved noncritical risk-acceptance
|
|
3875
|
+
row can lower the item to `has-dissent`. Correctness-critical defects remain
|
|
3876
|
+
blocking regardless of the user's selected disposition.
|
|
3763
3877
|
"""
|
|
3764
3878
|
return (
|
|
3765
3879
|
_classify_plan_item_gate(item) == "majority-disagree"
|
|
3766
3880
|
and not _is_correctness_critical(item)
|
|
3767
3881
|
and _has_planner_fixable_majority(item)
|
|
3768
3882
|
and _self_fix_budget_exhausted(pbv)
|
|
3883
|
+
and str(item.get("id") or "") in accepted_item_ids
|
|
3769
3884
|
)
|
|
3770
3885
|
|
|
3771
3886
|
|
|
3772
|
-
def _recompute_plan_body_gate(
|
|
3887
|
+
def _recompute_plan_body_gate(
|
|
3888
|
+
pbv: dict,
|
|
3889
|
+
accepted_item_ids: set[str] | None = None,
|
|
3890
|
+
) -> str | None:
|
|
3773
3891
|
"""Recompute the whole §5.5.9 gate value from ``planItems[].verdicts``.
|
|
3774
3892
|
Returns a value in ``PLAN_VERIFY_GATE_VALUES`` or ``None`` when there are
|
|
3775
3893
|
no plan items to judge (disabled / empty round)."""
|
|
3894
|
+
accepted = accepted_item_ids or set()
|
|
3776
3895
|
classes = [
|
|
3777
3896
|
"has-dissent"
|
|
3778
|
-
if _is_dissent_downgraded(it, pbv)
|
|
3897
|
+
if _is_dissent_downgraded(it, pbv, accepted)
|
|
3779
3898
|
else _classify_plan_item_gate(it)
|
|
3780
3899
|
for it in (pbv.get("planItems") or [])
|
|
3781
3900
|
if isinstance(it, dict)
|
|
@@ -3791,7 +3910,11 @@ def _recompute_plan_body_gate(pbv: dict) -> str | None:
|
|
|
3791
3910
|
return "passed"
|
|
3792
3911
|
|
|
3793
3912
|
|
|
3794
|
-
def _validate_plan_body_gate_recompute(
|
|
3913
|
+
def _validate_plan_body_gate_recompute(
|
|
3914
|
+
data: dict,
|
|
3915
|
+
failures: list[str],
|
|
3916
|
+
accepted_item_ids: set[str] | None = None,
|
|
3917
|
+
) -> None:
|
|
3795
3918
|
"""H1 — the declared `Gate result` must not claim a healthier outcome than
|
|
3796
3919
|
the recorded per-worker verdicts support. Closes the forgery hole where a
|
|
3797
3920
|
lead writes `gateResult: passed` while workers actually voted DISAGREE:
|
|
@@ -3805,7 +3928,12 @@ def _validate_plan_body_gate_recompute(data: dict, failures: list[str]) -> None:
|
|
|
3805
3928
|
if not isinstance(pbv, dict):
|
|
3806
3929
|
return
|
|
3807
3930
|
declared = str(pbv.get("gateResult") or "").strip().lower()
|
|
3808
|
-
|
|
3931
|
+
accepted = (
|
|
3932
|
+
_resolved_noncritical_dissent_ids(data)
|
|
3933
|
+
if accepted_item_ids is None
|
|
3934
|
+
else accepted_item_ids
|
|
3935
|
+
)
|
|
3936
|
+
recomputed = _recompute_plan_body_gate(pbv, accepted)
|
|
3809
3937
|
if recomputed is None or declared not in _PLAN_GATE_RANK:
|
|
3810
3938
|
return
|
|
3811
3939
|
if _PLAN_GATE_RANK[declared] > _PLAN_GATE_RANK[recomputed]:
|
|
@@ -3869,10 +3997,14 @@ def _independent_coverage_blockers(ip: dict, pbv: dict) -> list[str]:
|
|
|
3869
3997
|
]
|
|
3870
3998
|
|
|
3871
3999
|
|
|
3872
|
-
def _gate_blocking_causes(
|
|
4000
|
+
def _gate_blocking_causes(
|
|
4001
|
+
pbv: dict,
|
|
4002
|
+
coverage_blockers: list[str],
|
|
4003
|
+
accepted_item_ids: set[str] | None = None,
|
|
4004
|
+
) -> set[str]:
|
|
3873
4005
|
"""Which inputs actually block approval, as `gateBlockedBy` enum values."""
|
|
3874
4006
|
causes = set()
|
|
3875
|
-
recomputed = _recompute_plan_body_gate(pbv)
|
|
4007
|
+
recomputed = _recompute_plan_body_gate(pbv, accepted_item_ids)
|
|
3876
4008
|
if recomputed == "blocked-by-disagreement":
|
|
3877
4009
|
causes.add("majority-disagree")
|
|
3878
4010
|
elif recomputed == "aborted-non-result":
|
|
@@ -3882,6 +4014,885 @@ def _gate_blocking_causes(pbv: dict, coverage_blockers: list[str]) -> set[str]:
|
|
|
3882
4014
|
return causes
|
|
3883
4015
|
|
|
3884
4016
|
|
|
4017
|
+
_APPROVAL_DISPOSITIONS_BY_CLASSIFICATION = {
|
|
4018
|
+
"user-decision": frozenset({"select", "request-revision", "reject"}),
|
|
4019
|
+
"noncritical-dissent": frozenset(
|
|
4020
|
+
{"accept-risk", "request-revision", "reject"}
|
|
4021
|
+
),
|
|
4022
|
+
"correctness-critical": frozenset({"request-revision", "reject"}),
|
|
4023
|
+
}
|
|
4024
|
+
|
|
4025
|
+
|
|
4026
|
+
def _is_activity_contract_v1_planning(run_manifest: dict) -> bool:
|
|
4027
|
+
return (
|
|
4028
|
+
run_manifest.get("activityContractVersion") == 1
|
|
4029
|
+
and run_manifest.get("taskType") == "implementation-planning"
|
|
4030
|
+
)
|
|
4031
|
+
|
|
4032
|
+
|
|
4033
|
+
def _independent_coverage_clarification_ids(ip: dict, pbv: dict) -> set[str]:
|
|
4034
|
+
promoted = _plan_body_promoted_clarification_ids(pbv)
|
|
4035
|
+
return {
|
|
4036
|
+
clarification_id
|
|
4037
|
+
for row in (ip.get("requirementCoverage") or [])
|
|
4038
|
+
if isinstance(row, dict) and _blocks_approval(row)
|
|
4039
|
+
for clarification_id in [_cited_clarification_id(row)]
|
|
4040
|
+
if clarification_id and clarification_id not in promoted
|
|
4041
|
+
}
|
|
4042
|
+
|
|
4043
|
+
|
|
4044
|
+
def _expected_approval_classification(
|
|
4045
|
+
row: dict,
|
|
4046
|
+
plan_items_by_id: dict[str, dict],
|
|
4047
|
+
independent_coverage_clarification_ids: set[str],
|
|
4048
|
+
) -> str:
|
|
4049
|
+
linked = [
|
|
4050
|
+
plan_items_by_id[item_id]
|
|
4051
|
+
for item_id in (row.get("approvalContext") or {}).get("planItemIds") or []
|
|
4052
|
+
if item_id in plan_items_by_id
|
|
4053
|
+
]
|
|
4054
|
+
if any(_is_correctness_critical(item) for item in linked):
|
|
4055
|
+
return "correctness-critical"
|
|
4056
|
+
if str(row.get("id") or "") in independent_coverage_clarification_ids:
|
|
4057
|
+
return "correctness-critical"
|
|
4058
|
+
for item in linked:
|
|
4059
|
+
disagree_votes = [
|
|
4060
|
+
verdict
|
|
4061
|
+
for verdict in (item.get("verdicts") or [])
|
|
4062
|
+
if isinstance(verdict, dict)
|
|
4063
|
+
and str(verdict.get("verdict") or "").upper() == "DISAGREE"
|
|
4064
|
+
]
|
|
4065
|
+
needs_user_input = sum(
|
|
4066
|
+
verdict.get("fixability") == "needs-user-input"
|
|
4067
|
+
for verdict in disagree_votes
|
|
4068
|
+
)
|
|
4069
|
+
if disagree_votes and needs_user_input * 2 > len(disagree_votes):
|
|
4070
|
+
return "user-decision"
|
|
4071
|
+
if any(_classify_plan_item_gate(item) == "majority-disagree" for item in linked):
|
|
4072
|
+
return "noncritical-dissent"
|
|
4073
|
+
return "user-decision"
|
|
4074
|
+
|
|
4075
|
+
|
|
4076
|
+
_STATE_DISAGREE_VOTE_RE = re.compile(r"^DISAGREE\(([a-f])\)$")
|
|
4077
|
+
_APPROVAL_CLARIFICATION_ID_RE = re.compile(r"^C-\d{3,}$")
|
|
4078
|
+
|
|
4079
|
+
|
|
4080
|
+
def _state_round_as_plan_item(item_id: str, round_row: dict) -> dict:
|
|
4081
|
+
verdicts = []
|
|
4082
|
+
votes = round_row.get("votes")
|
|
4083
|
+
for worker, raw_vote in (votes.items() if isinstance(votes, dict) else ()):
|
|
4084
|
+
vote = str(raw_vote or "").strip()
|
|
4085
|
+
match = _STATE_DISAGREE_VOTE_RE.fullmatch(vote)
|
|
4086
|
+
if match:
|
|
4087
|
+
verdicts.append(
|
|
4088
|
+
{"worker": worker, "verdict": "DISAGREE", "breakageKind": match.group(1)}
|
|
4089
|
+
)
|
|
4090
|
+
elif vote in {"AGREE", "SUPPLEMENT", "verification-error"}:
|
|
4091
|
+
verdicts.append({"worker": worker, "verdict": vote})
|
|
4092
|
+
return {"id": item_id, "verdicts": verdicts}
|
|
4093
|
+
|
|
4094
|
+
|
|
4095
|
+
def _historical_plan_item_evidence(state: dict) -> tuple[dict[str, str], set[str]]:
|
|
4096
|
+
classifications: dict[str, str] = {}
|
|
4097
|
+
item_ids: set[str] = set()
|
|
4098
|
+
for item in state.get("planItems") or []:
|
|
4099
|
+
if not isinstance(item, dict):
|
|
4100
|
+
continue
|
|
4101
|
+
item_id = str(item.get("id") or "").strip()
|
|
4102
|
+
if not item_id:
|
|
4103
|
+
continue
|
|
4104
|
+
item_ids.add(item_id)
|
|
4105
|
+
for round_row in item.get("rounds") or []:
|
|
4106
|
+
if not isinstance(round_row, dict):
|
|
4107
|
+
continue
|
|
4108
|
+
historical = _state_round_as_plan_item(item_id, round_row)
|
|
4109
|
+
if _is_correctness_critical(historical):
|
|
4110
|
+
classifications[item_id] = "correctness-critical"
|
|
4111
|
+
break
|
|
4112
|
+
if _classify_plan_item_gate(historical) == "majority-disagree":
|
|
4113
|
+
classifications.setdefault(item_id, "noncritical-dissent")
|
|
4114
|
+
return classifications, item_ids
|
|
4115
|
+
|
|
4116
|
+
|
|
4117
|
+
def _historical_coverage_clarification_ids(
|
|
4118
|
+
state: dict,
|
|
4119
|
+
plan_classifications: dict[str, str],
|
|
4120
|
+
) -> set[str]:
|
|
4121
|
+
coverage_gap_rounds = {
|
|
4122
|
+
round_row.get("round")
|
|
4123
|
+
for round_row in (state.get("roundHistory") or [])
|
|
4124
|
+
if isinstance(round_row, dict)
|
|
4125
|
+
and isinstance(round_row.get("round"), int)
|
|
4126
|
+
and "coverage-gap" in (round_row.get("gateBlockedBy") or [])
|
|
4127
|
+
}
|
|
4128
|
+
return {
|
|
4129
|
+
clarification_id
|
|
4130
|
+
for item in (state.get("planItems") or [])
|
|
4131
|
+
if isinstance(item, dict)
|
|
4132
|
+
for item_id in [str(item.get("id") or "").strip()]
|
|
4133
|
+
for clarification_id in [str(item.get("clarificationId") or "").strip()]
|
|
4134
|
+
if item_id
|
|
4135
|
+
and item_id not in plan_classifications
|
|
4136
|
+
and _APPROVAL_CLARIFICATION_ID_RE.fullmatch(clarification_id)
|
|
4137
|
+
and any(
|
|
4138
|
+
isinstance(round_row, dict)
|
|
4139
|
+
and round_row.get("round") in coverage_gap_rounds
|
|
4140
|
+
for round_row in (item.get("rounds") or [])
|
|
4141
|
+
)
|
|
4142
|
+
}
|
|
4143
|
+
|
|
4144
|
+
|
|
4145
|
+
def _read_approval_history(
|
|
4146
|
+
report_path: Path | None,
|
|
4147
|
+
) -> tuple[dict[str, str], set[str], set[str], dict, Path | None]:
|
|
4148
|
+
if report_path is None or (seq := _report_run_seq(report_path)) is None:
|
|
4149
|
+
return {}, set(), set(), {}, None
|
|
4150
|
+
state_path = (
|
|
4151
|
+
report_path.parent.parent
|
|
4152
|
+
/ "state"
|
|
4153
|
+
/ f"plan-body-verification-implementation-planning-{seq}.json"
|
|
4154
|
+
)
|
|
4155
|
+
try:
|
|
4156
|
+
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
4157
|
+
except (OSError, json.JSONDecodeError):
|
|
4158
|
+
return {}, set(), set(), {}, None
|
|
4159
|
+
if not isinstance(state, dict):
|
|
4160
|
+
return {}, set(), set(), {}, None
|
|
4161
|
+
classifications, item_ids = _historical_plan_item_evidence(state)
|
|
4162
|
+
coverage_ids = _historical_coverage_clarification_ids(
|
|
4163
|
+
state,
|
|
4164
|
+
classifications,
|
|
4165
|
+
)
|
|
4166
|
+
return classifications, item_ids, coverage_ids, state, state_path
|
|
4167
|
+
|
|
4168
|
+
|
|
4169
|
+
def _nonblocking_coverage_clarification_ids(ip: dict) -> set[str]:
|
|
4170
|
+
return {
|
|
4171
|
+
ref
|
|
4172
|
+
for row in (ip.get("requirementCoverage") or [])
|
|
4173
|
+
if isinstance(row, dict) and not _blocks_approval(row)
|
|
4174
|
+
for ref in (row.get("decisionRefs") or [])
|
|
4175
|
+
if isinstance(ref, str) and _APPROVAL_CLARIFICATION_ID_RE.fullmatch(ref)
|
|
4176
|
+
}
|
|
4177
|
+
|
|
4178
|
+
|
|
4179
|
+
def _historical_approval_classification(
|
|
4180
|
+
row_id: str,
|
|
4181
|
+
linked_ids: list[str],
|
|
4182
|
+
historical_plan_classifications: dict[str, str],
|
|
4183
|
+
historical_coverage_ids: set[str],
|
|
4184
|
+
) -> str | None:
|
|
4185
|
+
if row_id in historical_coverage_ids:
|
|
4186
|
+
return "correctness-critical"
|
|
4187
|
+
classes = {
|
|
4188
|
+
historical_plan_classifications[item_id]
|
|
4189
|
+
for item_id in linked_ids
|
|
4190
|
+
if item_id in historical_plan_classifications
|
|
4191
|
+
}
|
|
4192
|
+
if "correctness-critical" in classes:
|
|
4193
|
+
return "correctness-critical"
|
|
4194
|
+
if "noncritical-dissent" in classes:
|
|
4195
|
+
return "noncritical-dissent"
|
|
4196
|
+
return None
|
|
4197
|
+
|
|
4198
|
+
|
|
4199
|
+
def _approval_activities_by_id(data: dict) -> dict[str, dict]:
|
|
4200
|
+
return {
|
|
4201
|
+
activity_id: activity
|
|
4202
|
+
for activity in (data.get("agentActivity") or [])
|
|
4203
|
+
if isinstance(activity, dict)
|
|
4204
|
+
for activity_id in [activity.get("activityId")]
|
|
4205
|
+
if isinstance(activity_id, str) and activity_id
|
|
4206
|
+
}
|
|
4207
|
+
|
|
4208
|
+
|
|
4209
|
+
def _canonical_activity_timestamps(
|
|
4210
|
+
run_manifest: Mapping[str, Any],
|
|
4211
|
+
report_path: Path | None,
|
|
4212
|
+
) -> dict[str, str]:
|
|
4213
|
+
raw_path = run_manifest.get("leadEventsPath")
|
|
4214
|
+
if not isinstance(raw_path, str) or not raw_path.strip():
|
|
4215
|
+
return {}
|
|
4216
|
+
path = Path(raw_path)
|
|
4217
|
+
if not path.is_absolute() and report_path is not None:
|
|
4218
|
+
path = _project_root_from_report(report_path) / path
|
|
4219
|
+
try:
|
|
4220
|
+
events = read_lead_events(path)
|
|
4221
|
+
except (LeadEventParseError, OSError):
|
|
4222
|
+
return {}
|
|
4223
|
+
return {
|
|
4224
|
+
str(event.details.get("activityId")): event.timestamp
|
|
4225
|
+
for event in events
|
|
4226
|
+
if event.event_type == "activity"
|
|
4227
|
+
and isinstance(event.details.get("activityId"), str)
|
|
4228
|
+
}
|
|
4229
|
+
|
|
4230
|
+
|
|
4231
|
+
def _is_decision_required_activity(activity: dict | None) -> bool:
|
|
4232
|
+
return bool(
|
|
4233
|
+
activity
|
|
4234
|
+
and activity.get("kind") == "user-decision-required"
|
|
4235
|
+
and activity.get("outcome") == "blocked"
|
|
4236
|
+
)
|
|
4237
|
+
|
|
4238
|
+
|
|
4239
|
+
def _is_applied_decision_check(activity: dict | None) -> bool:
|
|
4240
|
+
commands = (activity or {}).get("commands")
|
|
4241
|
+
return bool(
|
|
4242
|
+
activity
|
|
4243
|
+
and activity.get("kind") == "user-decision-evaluated"
|
|
4244
|
+
and activity.get("outcome") == "resolved"
|
|
4245
|
+
and str(activity.get("resultPath") or "").strip()
|
|
4246
|
+
and isinstance(commands, list)
|
|
4247
|
+
and bool(commands)
|
|
4248
|
+
and all(
|
|
4249
|
+
isinstance(command, dict) and command.get("exitCode") == 0
|
|
4250
|
+
for command in commands
|
|
4251
|
+
)
|
|
4252
|
+
)
|
|
4253
|
+
|
|
4254
|
+
|
|
4255
|
+
def _activity_matches_approval_context(
|
|
4256
|
+
activity: dict | None,
|
|
4257
|
+
row_id: str,
|
|
4258
|
+
context: dict,
|
|
4259
|
+
) -> bool:
|
|
4260
|
+
if not activity:
|
|
4261
|
+
return False
|
|
4262
|
+
evidence_refs = {
|
|
4263
|
+
ref
|
|
4264
|
+
for ref in (activity.get("evidenceRefs") or [])
|
|
4265
|
+
if isinstance(ref, str)
|
|
4266
|
+
}
|
|
4267
|
+
activity_item_ids = {
|
|
4268
|
+
item_id
|
|
4269
|
+
for item_id in (activity.get("planItemIds") or [])
|
|
4270
|
+
if isinstance(item_id, str)
|
|
4271
|
+
}
|
|
4272
|
+
context_item_ids = {
|
|
4273
|
+
item_id
|
|
4274
|
+
for item_id in (context.get("planItemIds") or [])
|
|
4275
|
+
if isinstance(item_id, str)
|
|
4276
|
+
}
|
|
4277
|
+
clarification_refs = {
|
|
4278
|
+
ref for ref in evidence_refs if _APPROVAL_CLARIFICATION_ID_RE.fullmatch(ref)
|
|
4279
|
+
}
|
|
4280
|
+
return clarification_refs == {row_id} and context_item_ids == activity_item_ids
|
|
4281
|
+
|
|
4282
|
+
|
|
4283
|
+
_TARGETED_REVERIFICATION_REF_RE = re.compile(
|
|
4284
|
+
r"^plan-body-verification:round-(?P<round>\d+)$"
|
|
4285
|
+
)
|
|
4286
|
+
|
|
4287
|
+
|
|
4288
|
+
def _targeted_reverification_round(activity: dict | None) -> int | None:
|
|
4289
|
+
rounds = {
|
|
4290
|
+
int(match.group("round"))
|
|
4291
|
+
for ref in ((activity or {}).get("evidenceRefs") or [])
|
|
4292
|
+
if isinstance(ref, str)
|
|
4293
|
+
for match in [_TARGETED_REVERIFICATION_REF_RE.fullmatch(ref)]
|
|
4294
|
+
if match is not None
|
|
4295
|
+
}
|
|
4296
|
+
if len(rounds) != 1:
|
|
4297
|
+
return None
|
|
4298
|
+
return next(iter(rounds))
|
|
4299
|
+
|
|
4300
|
+
|
|
4301
|
+
def _approval_context_activity_refs_exist(
|
|
4302
|
+
data: dict,
|
|
4303
|
+
row_id: str,
|
|
4304
|
+
context: dict,
|
|
4305
|
+
resolution: dict,
|
|
4306
|
+
) -> bool:
|
|
4307
|
+
activities = _approval_activities_by_id(data)
|
|
4308
|
+
activity_ids = {
|
|
4309
|
+
value for value in (context.get("activityIds") or []) if isinstance(value, str)
|
|
4310
|
+
}
|
|
4311
|
+
check_refs = {
|
|
4312
|
+
value for value in (resolution.get("checkRefs") or []) if isinstance(value, str)
|
|
4313
|
+
}
|
|
4314
|
+
activity_order = {
|
|
4315
|
+
activity.get("activityId"): index
|
|
4316
|
+
for index, activity in enumerate(data.get("agentActivity") or [])
|
|
4317
|
+
if isinstance(activity, dict)
|
|
4318
|
+
}
|
|
4319
|
+
ordered = bool(activity_ids and check_refs) and max(
|
|
4320
|
+
activity_order.get(ref, -1) for ref in activity_ids
|
|
4321
|
+
) < min(activity_order.get(ref, -1) for ref in check_refs)
|
|
4322
|
+
return (
|
|
4323
|
+
bool(activity_ids)
|
|
4324
|
+
and bool(check_refs)
|
|
4325
|
+
and all(
|
|
4326
|
+
_is_decision_required_activity(activities.get(ref))
|
|
4327
|
+
and _activity_matches_approval_context(
|
|
4328
|
+
activities.get(ref), row_id, context
|
|
4329
|
+
)
|
|
4330
|
+
for ref in activity_ids
|
|
4331
|
+
)
|
|
4332
|
+
and all(
|
|
4333
|
+
_is_applied_decision_check(activities.get(ref))
|
|
4334
|
+
and _targeted_reverification_round(activities.get(ref)) is not None
|
|
4335
|
+
and _activity_matches_approval_context(
|
|
4336
|
+
activities.get(ref), row_id, context
|
|
4337
|
+
)
|
|
4338
|
+
for ref in check_refs
|
|
4339
|
+
)
|
|
4340
|
+
and ordered
|
|
4341
|
+
)
|
|
4342
|
+
|
|
4343
|
+
|
|
4344
|
+
def _validate_approval_activity_refs(
|
|
4345
|
+
row_id: str,
|
|
4346
|
+
context: dict,
|
|
4347
|
+
activities: dict[str, dict],
|
|
4348
|
+
failures: list[str],
|
|
4349
|
+
) -> None:
|
|
4350
|
+
activity_ids = {
|
|
4351
|
+
value for value in (context.get("activityIds") or []) if isinstance(value, str)
|
|
4352
|
+
}
|
|
4353
|
+
unknown_activity_ids = sorted(activity_ids - set(activities))
|
|
4354
|
+
if not activity_ids or unknown_activity_ids:
|
|
4355
|
+
failures.append(
|
|
4356
|
+
f"final-report data.json: approval clarification `{row_id}` activityIds "
|
|
4357
|
+
f"must reference agentActivity[].activityId values; unknown="
|
|
4358
|
+
f"{unknown_activity_ids or 'none'}, recorded={sorted(activity_ids)}."
|
|
4359
|
+
)
|
|
4360
|
+
elif not all(
|
|
4361
|
+
_is_decision_required_activity(activities.get(ref)) for ref in activity_ids
|
|
4362
|
+
):
|
|
4363
|
+
failures.append(
|
|
4364
|
+
f"final-report data.json: approval clarification `{row_id}` activityIds "
|
|
4365
|
+
"must reference blocked user-decision-required activities."
|
|
4366
|
+
)
|
|
4367
|
+
elif not all(
|
|
4368
|
+
_activity_matches_approval_context(activities.get(ref), row_id, context)
|
|
4369
|
+
for ref in activity_ids
|
|
4370
|
+
):
|
|
4371
|
+
failures.append(
|
|
4372
|
+
f"final-report data.json: approval clarification `{row_id}` activityIds "
|
|
4373
|
+
f"must cite exactly one clarification (`{row_id}`) in evidenceRefs "
|
|
4374
|
+
"and exactly match approvalContext.planItemIds."
|
|
4375
|
+
)
|
|
4376
|
+
resolution = context.get("resolution")
|
|
4377
|
+
if not isinstance(resolution, dict):
|
|
4378
|
+
return
|
|
4379
|
+
check_refs = {
|
|
4380
|
+
value for value in (resolution.get("checkRefs") or []) if isinstance(value, str)
|
|
4381
|
+
}
|
|
4382
|
+
unknown_check_refs = sorted(check_refs - set(activities))
|
|
4383
|
+
if check_refs and unknown_check_refs:
|
|
4384
|
+
failures.append(
|
|
4385
|
+
f"final-report data.json: approval clarification `{row_id}` resolution."
|
|
4386
|
+
f"checkRefs must reference agentActivity[].activityId values; unknown="
|
|
4387
|
+
f"{unknown_check_refs}."
|
|
4388
|
+
)
|
|
4389
|
+
elif check_refs and not all(
|
|
4390
|
+
_is_applied_decision_check(activities.get(ref))
|
|
4391
|
+
and _targeted_reverification_round(activities.get(ref)) is not None
|
|
4392
|
+
for ref in check_refs
|
|
4393
|
+
):
|
|
4394
|
+
failures.append(
|
|
4395
|
+
f"final-report data.json: approval clarification `{row_id}` resolution."
|
|
4396
|
+
"checkRefs must reference resolved user-decision-evaluated activities "
|
|
4397
|
+
"with successful check evidence and one "
|
|
4398
|
+
"`plan-body-verification:round-N` evidenceRef."
|
|
4399
|
+
)
|
|
4400
|
+
elif check_refs and not all(
|
|
4401
|
+
_activity_matches_approval_context(activities.get(ref), row_id, context)
|
|
4402
|
+
for ref in check_refs
|
|
4403
|
+
):
|
|
4404
|
+
failures.append(
|
|
4405
|
+
f"final-report data.json: approval clarification `{row_id}` resolution."
|
|
4406
|
+
f"checkRefs must cite exactly one clarification (`{row_id}`) in "
|
|
4407
|
+
"evidenceRefs and exactly match approvalContext.planItemIds."
|
|
4408
|
+
)
|
|
4409
|
+
elif check_refs:
|
|
4410
|
+
order = {
|
|
4411
|
+
activity.get("activityId"): index
|
|
4412
|
+
for index, activity in enumerate(activities.values())
|
|
4413
|
+
}
|
|
4414
|
+
if activity_ids and max(order.get(ref, -1) for ref in activity_ids) >= min(
|
|
4415
|
+
order.get(ref, -1) for ref in check_refs
|
|
4416
|
+
):
|
|
4417
|
+
failures.append(
|
|
4418
|
+
f"final-report data.json: approval clarification `{row_id}` "
|
|
4419
|
+
"user-decision-evaluated activity must occur after every "
|
|
4420
|
+
"user-decision-required activity."
|
|
4421
|
+
)
|
|
4422
|
+
|
|
4423
|
+
|
|
4424
|
+
def _validate_approval_dispositions(
|
|
4425
|
+
row: dict,
|
|
4426
|
+
context: dict,
|
|
4427
|
+
failures: list[str],
|
|
4428
|
+
) -> None:
|
|
4429
|
+
row_id = str(row.get("id") or "<unknown>")
|
|
4430
|
+
classification = str(context.get("classification") or "")
|
|
4431
|
+
allowed = _APPROVAL_DISPOSITIONS_BY_CLASSIFICATION.get(classification, frozenset())
|
|
4432
|
+
candidates = [("recommendedDisposition", context.get("recommendedDisposition"))]
|
|
4433
|
+
candidates.extend(
|
|
4434
|
+
(f"options[{index}].disposition", option.get("disposition"))
|
|
4435
|
+
for index, option in enumerate(row.get("options") or [])
|
|
4436
|
+
if isinstance(option, dict)
|
|
4437
|
+
)
|
|
4438
|
+
resolution = context.get("resolution")
|
|
4439
|
+
if isinstance(resolution, dict):
|
|
4440
|
+
candidates.append(("resolution.disposition", resolution.get("disposition")))
|
|
4441
|
+
for field, disposition in candidates:
|
|
4442
|
+
if disposition not in allowed:
|
|
4443
|
+
failures.append(
|
|
4444
|
+
f"final-report data.json: approval clarification `{row_id}` "
|
|
4445
|
+
f"classification `{classification}` does not allow `{disposition}` "
|
|
4446
|
+
f"in {field}; allowed dispositions are {sorted(allowed)}."
|
|
4447
|
+
)
|
|
4448
|
+
|
|
4449
|
+
|
|
4450
|
+
def _validate_resolved_approval(
|
|
4451
|
+
row: dict,
|
|
4452
|
+
context: dict,
|
|
4453
|
+
failures: list[str],
|
|
4454
|
+
) -> None:
|
|
4455
|
+
if row.get("status") != "resolved":
|
|
4456
|
+
return
|
|
4457
|
+
row_id = str(row.get("id") or "<unknown>")
|
|
4458
|
+
resolution = context.get("resolution")
|
|
4459
|
+
if not isinstance(resolution, dict):
|
|
4460
|
+
failures.append(
|
|
4461
|
+
f"final-report data.json: resolved approval clarification `{row_id}` "
|
|
4462
|
+
"requires resolution.userText and non-empty resolution.checkRefs."
|
|
4463
|
+
)
|
|
4464
|
+
return
|
|
4465
|
+
if not str(resolution.get("userText") or "").strip():
|
|
4466
|
+
failures.append(
|
|
4467
|
+
f"final-report data.json: resolved approval clarification `{row_id}` "
|
|
4468
|
+
"requires non-empty resolution.userText."
|
|
4469
|
+
)
|
|
4470
|
+
check_refs = resolution.get("checkRefs")
|
|
4471
|
+
if not isinstance(check_refs, list) or not any(
|
|
4472
|
+
isinstance(value, str) and value for value in check_refs
|
|
4473
|
+
):
|
|
4474
|
+
failures.append(
|
|
4475
|
+
f"final-report data.json: resolved approval clarification `{row_id}` "
|
|
4476
|
+
"requires non-empty resolution.checkRefs."
|
|
4477
|
+
)
|
|
4478
|
+
if (
|
|
4479
|
+
context.get("classification") == "noncritical-dissent"
|
|
4480
|
+
and resolution.get("disposition") != "accept-risk"
|
|
4481
|
+
):
|
|
4482
|
+
failures.append(
|
|
4483
|
+
f"final-report data.json: resolved noncritical-dissent `{row_id}` "
|
|
4484
|
+
"requires an explicit accept-risk disposition."
|
|
4485
|
+
)
|
|
4486
|
+
|
|
4487
|
+
|
|
4488
|
+
def _has_successful_targeted_reverification(item: dict) -> bool:
|
|
4489
|
+
verdicts = [
|
|
4490
|
+
str(verdict.get("verdict") or "").strip().upper()
|
|
4491
|
+
for verdict in (item.get("verdicts") or [])
|
|
4492
|
+
if isinstance(verdict, dict)
|
|
4493
|
+
]
|
|
4494
|
+
return bool(verdicts) and all(
|
|
4495
|
+
verdict in {"AGREE", "SUPPLEMENT"} for verdict in verdicts
|
|
4496
|
+
)
|
|
4497
|
+
|
|
4498
|
+
|
|
4499
|
+
def _parse_approval_timestamp(value: Any) -> datetime | None:
|
|
4500
|
+
if not isinstance(value, str) or not value.strip():
|
|
4501
|
+
return None
|
|
4502
|
+
try:
|
|
4503
|
+
parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
|
4504
|
+
except ValueError:
|
|
4505
|
+
return None
|
|
4506
|
+
if parsed.tzinfo is None or parsed.utcoffset() != timezone.utc.utcoffset(parsed):
|
|
4507
|
+
return None
|
|
4508
|
+
return parsed
|
|
4509
|
+
|
|
4510
|
+
|
|
4511
|
+
def _referenced_approval_timestamps(
|
|
4512
|
+
refs: Any,
|
|
4513
|
+
activity_timestamps: dict[str, str],
|
|
4514
|
+
) -> list[datetime | None]:
|
|
4515
|
+
return [
|
|
4516
|
+
_parse_approval_timestamp(activity_timestamps.get(ref))
|
|
4517
|
+
for ref in (refs or [])
|
|
4518
|
+
if isinstance(ref, str)
|
|
4519
|
+
]
|
|
4520
|
+
|
|
4521
|
+
|
|
4522
|
+
def _target_round_completed_at(
|
|
4523
|
+
approval_state: dict,
|
|
4524
|
+
target_round: int,
|
|
4525
|
+
) -> datetime | None:
|
|
4526
|
+
matching_rounds = [
|
|
4527
|
+
row
|
|
4528
|
+
for row in (approval_state.get("roundHistory") or [])
|
|
4529
|
+
if isinstance(row, dict) and row.get("round") == target_round
|
|
4530
|
+
]
|
|
4531
|
+
if len(matching_rounds) != 1:
|
|
4532
|
+
return None
|
|
4533
|
+
return _parse_approval_timestamp(matching_rounds[0].get("completedAt"))
|
|
4534
|
+
|
|
4535
|
+
|
|
4536
|
+
def _validate_target_round_causality(
|
|
4537
|
+
row_id: str,
|
|
4538
|
+
target_round: int,
|
|
4539
|
+
approval_state: dict,
|
|
4540
|
+
context: dict,
|
|
4541
|
+
resolution: dict,
|
|
4542
|
+
activity_timestamps: dict[str, str],
|
|
4543
|
+
failures: list[str],
|
|
4544
|
+
) -> None:
|
|
4545
|
+
completed_at = _target_round_completed_at(approval_state, target_round)
|
|
4546
|
+
required_at = _referenced_approval_timestamps(
|
|
4547
|
+
context.get("activityIds"),
|
|
4548
|
+
activity_timestamps,
|
|
4549
|
+
)
|
|
4550
|
+
evaluated_at = _referenced_approval_timestamps(
|
|
4551
|
+
resolution.get("checkRefs"),
|
|
4552
|
+
activity_timestamps,
|
|
4553
|
+
)
|
|
4554
|
+
if (
|
|
4555
|
+
completed_at is None
|
|
4556
|
+
or not required_at
|
|
4557
|
+
or not evaluated_at
|
|
4558
|
+
or None in required_at
|
|
4559
|
+
or None in evaluated_at
|
|
4560
|
+
):
|
|
4561
|
+
failures.append(
|
|
4562
|
+
f"final-report data.json: correctness-critical clarification `{row_id}` "
|
|
4563
|
+
f"state round {target_round} requires one UTC completedAt plus canonical "
|
|
4564
|
+
"timestamps for every required and evaluated activity."
|
|
4565
|
+
)
|
|
4566
|
+
return
|
|
4567
|
+
if completed_at <= max(required_at):
|
|
4568
|
+
failures.append(
|
|
4569
|
+
f"final-report data.json: correctness-critical clarification `{row_id}` "
|
|
4570
|
+
f"state round {target_round} completedAt must be after every referenced "
|
|
4571
|
+
"user-decision-required activity."
|
|
4572
|
+
)
|
|
4573
|
+
if completed_at > min(evaluated_at):
|
|
4574
|
+
failures.append(
|
|
4575
|
+
f"final-report data.json: correctness-critical clarification `{row_id}` "
|
|
4576
|
+
f"state round {target_round} completedAt must be no later than every "
|
|
4577
|
+
"referenced user-decision-evaluated activity."
|
|
4578
|
+
)
|
|
4579
|
+
|
|
4580
|
+
|
|
4581
|
+
def _required_activity_plan_item_ids(
|
|
4582
|
+
context: dict,
|
|
4583
|
+
activities: dict[str, dict],
|
|
4584
|
+
) -> set[str]:
|
|
4585
|
+
return {
|
|
4586
|
+
item_id
|
|
4587
|
+
for ref in (context.get("activityIds") or [])
|
|
4588
|
+
if isinstance(ref, str) and _is_decision_required_activity(activities.get(ref))
|
|
4589
|
+
for item_id in (activities[ref].get("planItemIds") or [])
|
|
4590
|
+
if isinstance(item_id, str)
|
|
4591
|
+
}
|
|
4592
|
+
|
|
4593
|
+
|
|
4594
|
+
def _terminal_unknown_plan_items_are_historical(
|
|
4595
|
+
row: dict,
|
|
4596
|
+
context: dict,
|
|
4597
|
+
unknown_ids: set[str],
|
|
4598
|
+
historical_item_ids: set[str],
|
|
4599
|
+
historical_coverage_ids: set[str],
|
|
4600
|
+
activities: dict[str, dict],
|
|
4601
|
+
) -> bool:
|
|
4602
|
+
status = row.get("status")
|
|
4603
|
+
if status not in {"resolved", "obsolete"}:
|
|
4604
|
+
return False
|
|
4605
|
+
if status == "resolved" and str(row.get("id") or "") not in historical_coverage_ids:
|
|
4606
|
+
return False
|
|
4607
|
+
required_item_ids = _required_activity_plan_item_ids(context, activities)
|
|
4608
|
+
return bool(unknown_ids) and unknown_ids <= historical_item_ids & required_item_ids
|
|
4609
|
+
|
|
4610
|
+
|
|
4611
|
+
def _validate_correctness_resolution(
|
|
4612
|
+
row: dict,
|
|
4613
|
+
linked_ids: list[str],
|
|
4614
|
+
linked_items: list[dict],
|
|
4615
|
+
independent_coverage_clarification_ids: set[str],
|
|
4616
|
+
activities: dict[str, dict],
|
|
4617
|
+
approval_state: dict,
|
|
4618
|
+
approval_state_path: Path | None,
|
|
4619
|
+
activity_timestamps: dict[str, str],
|
|
4620
|
+
failures: list[str],
|
|
4621
|
+
) -> None:
|
|
4622
|
+
context = row.get("approvalContext") or {}
|
|
4623
|
+
if (
|
|
4624
|
+
context.get("classification") != "correctness-critical"
|
|
4625
|
+
or row.get("status") != "resolved"
|
|
4626
|
+
):
|
|
4627
|
+
return
|
|
4628
|
+
row_id = str(row.get("id") or "<unknown>")
|
|
4629
|
+
resolution = context.get("resolution") or {}
|
|
4630
|
+
target_rounds = {
|
|
4631
|
+
round_number
|
|
4632
|
+
for ref in (resolution.get("checkRefs") or [])
|
|
4633
|
+
if isinstance(ref, str)
|
|
4634
|
+
for round_number in [_targeted_reverification_round(activities.get(ref))]
|
|
4635
|
+
if round_number is not None
|
|
4636
|
+
}
|
|
4637
|
+
if len(target_rounds) != 1:
|
|
4638
|
+
failures.append(
|
|
4639
|
+
f"final-report data.json: correctness-critical clarification `{row_id}` "
|
|
4640
|
+
"requires exactly one evidenced targeted reverification state round."
|
|
4641
|
+
)
|
|
4642
|
+
return
|
|
4643
|
+
target_round = next(iter(target_rounds))
|
|
4644
|
+
_validate_target_round_causality(
|
|
4645
|
+
row_id,
|
|
4646
|
+
target_round,
|
|
4647
|
+
approval_state,
|
|
4648
|
+
context,
|
|
4649
|
+
resolution,
|
|
4650
|
+
activity_timestamps,
|
|
4651
|
+
failures,
|
|
4652
|
+
)
|
|
4653
|
+
state_name = approval_state_path.name if approval_state_path is not None else ""
|
|
4654
|
+
result_paths_match = bool(state_name) and all(
|
|
4655
|
+
tuple(
|
|
4656
|
+
Path(str(activities[ref].get("resultPath") or "")).parts[-2:]
|
|
4657
|
+
) == ("state", state_name)
|
|
4658
|
+
for ref in (resolution.get("checkRefs") or [])
|
|
4659
|
+
if isinstance(ref, str) and ref in activities
|
|
4660
|
+
)
|
|
4661
|
+
if not result_paths_match:
|
|
4662
|
+
failures.append(
|
|
4663
|
+
f"final-report data.json: correctness-critical clarification `{row_id}` "
|
|
4664
|
+
"evaluation resultPath must reference the matching plan-body "
|
|
4665
|
+
"verification state artifact."
|
|
4666
|
+
)
|
|
4667
|
+
state_items = {
|
|
4668
|
+
str(item.get("id") or ""): item
|
|
4669
|
+
for item in (approval_state.get("planItems") or [])
|
|
4670
|
+
if isinstance(item, dict) and str(item.get("id") or "")
|
|
4671
|
+
}
|
|
4672
|
+
state_failures: dict[str, str] = {}
|
|
4673
|
+
current_by_id = {str(item.get("id") or ""): item for item in linked_items}
|
|
4674
|
+
for item_id in linked_ids:
|
|
4675
|
+
state_item = state_items.get(item_id)
|
|
4676
|
+
rounds = state_item.get("rounds") if isinstance(state_item, dict) else None
|
|
4677
|
+
target_state_round = next(
|
|
4678
|
+
(
|
|
4679
|
+
round_row
|
|
4680
|
+
for round_row in (rounds or [])
|
|
4681
|
+
if isinstance(round_row, dict)
|
|
4682
|
+
and round_row.get("round") == target_round
|
|
4683
|
+
),
|
|
4684
|
+
None,
|
|
4685
|
+
)
|
|
4686
|
+
blocking_rounds = [
|
|
4687
|
+
round_row.get("round")
|
|
4688
|
+
for round_row in (rounds or [])
|
|
4689
|
+
if isinstance(round_row, dict)
|
|
4690
|
+
and isinstance(round_row.get("round"), int)
|
|
4691
|
+
and round_row.get("round") < target_round
|
|
4692
|
+
and (
|
|
4693
|
+
_is_correctness_critical(
|
|
4694
|
+
_state_round_as_plan_item(item_id, round_row)
|
|
4695
|
+
)
|
|
4696
|
+
or _classify_plan_item_gate(
|
|
4697
|
+
_state_round_as_plan_item(item_id, round_row)
|
|
4698
|
+
)
|
|
4699
|
+
== "majority-disagree"
|
|
4700
|
+
)
|
|
4701
|
+
]
|
|
4702
|
+
if (
|
|
4703
|
+
isinstance(state_item, dict)
|
|
4704
|
+
and state_item.get("clarificationId") == row_id
|
|
4705
|
+
):
|
|
4706
|
+
blocking_rounds.extend(
|
|
4707
|
+
round_row.get("round")
|
|
4708
|
+
for round_row in (approval_state.get("roundHistory") or [])
|
|
4709
|
+
if isinstance(round_row, dict)
|
|
4710
|
+
and isinstance(round_row.get("round"), int)
|
|
4711
|
+
and round_row.get("round") < target_round
|
|
4712
|
+
and "coverage-gap" in (round_row.get("gateBlockedBy") or [])
|
|
4713
|
+
)
|
|
4714
|
+
target_votes = (
|
|
4715
|
+
target_state_round.get("votes")
|
|
4716
|
+
if isinstance(target_state_round, dict)
|
|
4717
|
+
else None
|
|
4718
|
+
)
|
|
4719
|
+
successful = bool(target_votes) and all(
|
|
4720
|
+
vote in {"AGREE", "SUPPLEMENT"} for vote in target_votes.values()
|
|
4721
|
+
)
|
|
4722
|
+
if not blocking_rounds or not successful:
|
|
4723
|
+
state_failures[item_id] = (
|
|
4724
|
+
f"state round {target_round} is not a successful post-blocker round"
|
|
4725
|
+
)
|
|
4726
|
+
continue
|
|
4727
|
+
current = current_by_id.get(item_id)
|
|
4728
|
+
if current is not None:
|
|
4729
|
+
report_votes = {
|
|
4730
|
+
str(verdict.get("worker") or ""): str(verdict.get("verdict") or "")
|
|
4731
|
+
for verdict in (current.get("verdicts") or [])
|
|
4732
|
+
if isinstance(verdict, dict)
|
|
4733
|
+
}
|
|
4734
|
+
if report_votes != target_votes:
|
|
4735
|
+
state_failures[item_id] = (
|
|
4736
|
+
f"state round {target_round} votes do not match final report verdicts"
|
|
4737
|
+
)
|
|
4738
|
+
if state_failures:
|
|
4739
|
+
failures.append(
|
|
4740
|
+
f"final-report data.json: correctness-critical clarification `{row_id}` "
|
|
4741
|
+
f"targeted reverification state round {target_round} is not bound to "
|
|
4742
|
+
f"the resolved evidence; failures={state_failures}."
|
|
4743
|
+
)
|
|
4744
|
+
unresolved = {
|
|
4745
|
+
str(item.get("id") or "<unknown>"): [
|
|
4746
|
+
str(verdict.get("verdict") or "")
|
|
4747
|
+
for verdict in (item.get("verdicts") or [])
|
|
4748
|
+
if isinstance(verdict, dict)
|
|
4749
|
+
]
|
|
4750
|
+
for item in linked_items
|
|
4751
|
+
if not _has_successful_targeted_reverification(item)
|
|
4752
|
+
}
|
|
4753
|
+
if unresolved:
|
|
4754
|
+
failures.append(
|
|
4755
|
+
f"final-report data.json: correctness-critical clarification `{row_id}` "
|
|
4756
|
+
"cannot resolve until targeted reverification records only AGREE or "
|
|
4757
|
+
f"acceptable SUPPLEMENT verdicts; unresolved plan items={unresolved}."
|
|
4758
|
+
)
|
|
4759
|
+
if row_id in independent_coverage_clarification_ids:
|
|
4760
|
+
failures.append(
|
|
4761
|
+
f"final-report data.json: correctness-critical clarification `{row_id}` "
|
|
4762
|
+
"cannot resolve while an independent requirement coverage blocker remains."
|
|
4763
|
+
)
|
|
4764
|
+
|
|
4765
|
+
|
|
4766
|
+
def _validate_approval_context(
|
|
4767
|
+
data: dict,
|
|
4768
|
+
run_manifest: dict,
|
|
4769
|
+
failures: list[str],
|
|
4770
|
+
report_path: Path | None = None,
|
|
4771
|
+
) -> None:
|
|
4772
|
+
if not _is_activity_contract_v1_planning(run_manifest):
|
|
4773
|
+
return
|
|
4774
|
+
ip = data.get("implementationPlanning") or {}
|
|
4775
|
+
pbv = ip.get("planBodyVerification") or {}
|
|
4776
|
+
plan_items_by_id = {
|
|
4777
|
+
str(item.get("id")): item
|
|
4778
|
+
for item in (pbv.get("planItems") or [])
|
|
4779
|
+
if isinstance(item, dict) and str(item.get("id") or "")
|
|
4780
|
+
}
|
|
4781
|
+
coverage_ids = _independent_coverage_clarification_ids(ip, pbv)
|
|
4782
|
+
(
|
|
4783
|
+
historical_classes,
|
|
4784
|
+
historical_item_ids,
|
|
4785
|
+
historical_coverage_ids,
|
|
4786
|
+
approval_state,
|
|
4787
|
+
approval_state_path,
|
|
4788
|
+
) = _read_approval_history(report_path)
|
|
4789
|
+
historical_coverage_ids &= _nonblocking_coverage_clarification_ids(
|
|
4790
|
+
ip
|
|
4791
|
+
)
|
|
4792
|
+
activities = _approval_activities_by_id(data)
|
|
4793
|
+
activity_timestamps = _canonical_activity_timestamps(run_manifest, report_path)
|
|
4794
|
+
report_approved = (data.get("frontmatter") or {}).get("approved") is True
|
|
4795
|
+
for row in data.get("clarificationItems") or []:
|
|
4796
|
+
if not isinstance(row, dict) or row.get("blocks") != "approval":
|
|
4797
|
+
continue
|
|
4798
|
+
row_id = str(row.get("id") or "<unknown>")
|
|
4799
|
+
context = row.get("approvalContext")
|
|
4800
|
+
if not isinstance(context, dict):
|
|
4801
|
+
failures.append(
|
|
4802
|
+
f"final-report data.json: approval clarification `{row_id}` requires "
|
|
4803
|
+
"approvalContext under activity contract v1."
|
|
4804
|
+
)
|
|
4805
|
+
continue
|
|
4806
|
+
linked_ids = [
|
|
4807
|
+
value
|
|
4808
|
+
for value in context.get("planItemIds") or []
|
|
4809
|
+
if isinstance(value, str)
|
|
4810
|
+
]
|
|
4811
|
+
unknown_ids = set(linked_ids) - set(plan_items_by_id)
|
|
4812
|
+
if unknown_ids and not _terminal_unknown_plan_items_are_historical(
|
|
4813
|
+
row,
|
|
4814
|
+
context,
|
|
4815
|
+
unknown_ids,
|
|
4816
|
+
historical_item_ids,
|
|
4817
|
+
historical_coverage_ids,
|
|
4818
|
+
activities,
|
|
4819
|
+
):
|
|
4820
|
+
failures.append(
|
|
4821
|
+
f"final-report data.json: approval clarification `{row_id}` planItemIds "
|
|
4822
|
+
f"reference unknown plan items {sorted(unknown_ids)}."
|
|
4823
|
+
)
|
|
4824
|
+
linked_items = [
|
|
4825
|
+
plan_items_by_id[item_id]
|
|
4826
|
+
for item_id in linked_ids
|
|
4827
|
+
if item_id in plan_items_by_id
|
|
4828
|
+
]
|
|
4829
|
+
current_expected = _expected_approval_classification(
|
|
4830
|
+
row, plan_items_by_id, coverage_ids
|
|
4831
|
+
)
|
|
4832
|
+
historical_expected = _historical_approval_classification(
|
|
4833
|
+
row_id, linked_ids, historical_classes, historical_coverage_ids
|
|
4834
|
+
)
|
|
4835
|
+
expected = (
|
|
4836
|
+
historical_expected
|
|
4837
|
+
if row.get("status") in {"resolved", "obsolete"} and historical_expected
|
|
4838
|
+
else current_expected
|
|
4839
|
+
)
|
|
4840
|
+
if context.get("classification") != expected:
|
|
4841
|
+
failures.append(
|
|
4842
|
+
f"final-report data.json: approval clarification `{row_id}` classification "
|
|
4843
|
+
f"is `{context.get('classification')}` but plan evidence requires `{expected}`."
|
|
4844
|
+
)
|
|
4845
|
+
obsolete_has_active_cause = current_expected != "user-decision" or any(
|
|
4846
|
+
item_id in plan_items_by_id for item_id in linked_ids
|
|
4847
|
+
)
|
|
4848
|
+
if row.get("status") == "obsolete" and obsolete_has_active_cause:
|
|
4849
|
+
failures.append(
|
|
4850
|
+
f"final-report data.json: obsolete approval clarification `{row_id}` "
|
|
4851
|
+
f"still has an active `{current_expected}` cause in the current plan."
|
|
4852
|
+
)
|
|
4853
|
+
_validate_approval_activity_refs(row_id, context, activities, failures)
|
|
4854
|
+
_validate_approval_dispositions(row, context, failures)
|
|
4855
|
+
_validate_resolved_approval(row, context, failures)
|
|
4856
|
+
_validate_correctness_resolution(
|
|
4857
|
+
row,
|
|
4858
|
+
linked_ids,
|
|
4859
|
+
linked_items,
|
|
4860
|
+
coverage_ids,
|
|
4861
|
+
activities,
|
|
4862
|
+
approval_state,
|
|
4863
|
+
approval_state_path,
|
|
4864
|
+
activity_timestamps,
|
|
4865
|
+
failures,
|
|
4866
|
+
)
|
|
4867
|
+
if report_approved and row.get("status") in {"open", "answered"}:
|
|
4868
|
+
failures.append(
|
|
4869
|
+
f"final-report data.json: approval is true while clarification `{row_id}` "
|
|
4870
|
+
f"has status `{row.get('status')}`; open and answered approval "
|
|
4871
|
+
"rows remain blocking."
|
|
4872
|
+
)
|
|
4873
|
+
|
|
4874
|
+
|
|
4875
|
+
def _validate_activity_contract_plan_limits(
|
|
4876
|
+
data: dict,
|
|
4877
|
+
run_manifest: dict,
|
|
4878
|
+
failures: list[str],
|
|
4879
|
+
) -> None:
|
|
4880
|
+
if not _is_activity_contract_v1_planning(run_manifest):
|
|
4881
|
+
return
|
|
4882
|
+
pbv = (data.get("implementationPlanning") or {}).get("planBodyVerification") or {}
|
|
4883
|
+
rounds_applied = pbv.get("selfFixRoundsApplied", 0)
|
|
4884
|
+
if isinstance(rounds_applied, int) and rounds_applied > 1:
|
|
4885
|
+
failures.append(
|
|
4886
|
+
"final-report data.json: activity contract v1 selfFixRoundsApplied "
|
|
4887
|
+
"must be at most one automatic self-fix round"
|
|
4888
|
+
)
|
|
4889
|
+
if pbv.get("selfFixStopReason") == "cause-group-recurrence":
|
|
4890
|
+
failures.append(
|
|
4891
|
+
"final-report data.json: activity contract v1 cannot newly emit "
|
|
4892
|
+
"cause-group-recurrence"
|
|
4893
|
+
)
|
|
4894
|
+
|
|
4895
|
+
|
|
3885
4896
|
_CHECKLIST_REF_RE = re.compile(r"VC-\d+")
|
|
3886
4897
|
|
|
3887
4898
|
|
|
@@ -4172,7 +5183,11 @@ def _validate_participating_analysers(data: dict, failures: list[str]) -> None:
|
|
|
4172
5183
|
)
|
|
4173
5184
|
|
|
4174
5185
|
|
|
4175
|
-
def _validate_gate_blocked_by(
|
|
5186
|
+
def _validate_gate_blocked_by(
|
|
5187
|
+
data: dict,
|
|
5188
|
+
failures: list[str],
|
|
5189
|
+
accepted_item_ids: set[str] | None = None,
|
|
5190
|
+
) -> None:
|
|
4176
5191
|
"""The gate value names an *outcome*; `gateBlockedBy` names the *cause*.
|
|
4177
5192
|
|
|
4178
5193
|
Two independent inputs can block approval — a `majority-disagree` plan item
|
|
@@ -4200,7 +5215,12 @@ def _validate_gate_blocked_by(data: dict, failures: list[str]) -> None:
|
|
|
4200
5215
|
if isinstance(c, str) and str(c).strip()
|
|
4201
5216
|
}
|
|
4202
5217
|
coverage_blockers = _independent_coverage_blockers(ip, pbv)
|
|
4203
|
-
|
|
5218
|
+
accepted = (
|
|
5219
|
+
_resolved_noncritical_dissent_ids(data)
|
|
5220
|
+
if accepted_item_ids is None
|
|
5221
|
+
else accepted_item_ids
|
|
5222
|
+
)
|
|
5223
|
+
actual_causes = _gate_blocking_causes(pbv, coverage_blockers, accepted)
|
|
4204
5224
|
|
|
4205
5225
|
if actual_causes and declared_gate in ("passed", "passed-with-dissent"):
|
|
4206
5226
|
failures.append(
|
|
@@ -6227,7 +7247,11 @@ def _validate_plan_item_subject_substance(data: dict, failures: list[str]) -> No
|
|
|
6227
7247
|
)
|
|
6228
7248
|
|
|
6229
7249
|
|
|
6230
|
-
def _validate_plan_body_clarification_matching(
|
|
7250
|
+
def _validate_plan_body_clarification_matching(
|
|
7251
|
+
data: dict,
|
|
7252
|
+
failures: list[str],
|
|
7253
|
+
accepted_item_ids: set[str] | None = None,
|
|
7254
|
+
) -> None:
|
|
6231
7255
|
"""H5 — every plan item that the recorded verdicts make `majority-disagree`
|
|
6232
7256
|
must point (via `clarificationId`) at an existing `blocks: approval`
|
|
6233
7257
|
clarification row. Closes the hole where a majority-disagree item blocks the
|
|
@@ -6243,6 +7267,11 @@ def _validate_plan_body_clarification_matching(data: dict, failures: list[str])
|
|
|
6243
7267
|
round_count = pbv.get("roundCount")
|
|
6244
7268
|
if not isinstance(round_count, int) or round_count < 1:
|
|
6245
7269
|
return
|
|
7270
|
+
accepted = (
|
|
7271
|
+
_resolved_noncritical_dissent_ids(data)
|
|
7272
|
+
if accepted_item_ids is None
|
|
7273
|
+
else accepted_item_ids
|
|
7274
|
+
)
|
|
6246
7275
|
clar_rows = [r for r in (data.get("clarificationItems") or []) if isinstance(r, dict)]
|
|
6247
7276
|
all_ids = {r.get("id") for r in clar_rows if r.get("id")}
|
|
6248
7277
|
approval_ids = {r.get("id") for r in clar_rows if r.get("blocks") == "approval" and r.get("id")}
|
|
@@ -6251,7 +7280,7 @@ def _validate_plan_body_clarification_matching(data: dict, failures: list[str])
|
|
|
6251
7280
|
continue
|
|
6252
7281
|
if _classify_plan_item_gate(item) != "majority-disagree":
|
|
6253
7282
|
continue
|
|
6254
|
-
if _is_dissent_downgraded(item, pbv):
|
|
7283
|
+
if _is_dissent_downgraded(item, pbv, accepted):
|
|
6255
7284
|
continue
|
|
6256
7285
|
item_id = item.get("id") or "<unknown>"
|
|
6257
7286
|
cid = item.get("clarificationId")
|
|
@@ -6427,8 +7456,9 @@ def validate_plan_body_section(
|
|
|
6427
7456
|
spent against a mis-scored gate.
|
|
6428
7457
|
"""
|
|
6429
7458
|
pbv = (data.get("implementationPlanning") or {}).get("planBodyVerification") or {}
|
|
6430
|
-
|
|
6431
|
-
|
|
7459
|
+
accepted_item_ids = _resolved_noncritical_dissent_ids(data)
|
|
7460
|
+
_validate_plan_body_gate_recompute(data, failures, accepted_item_ids)
|
|
7461
|
+
_validate_gate_blocked_by(data, failures, accepted_item_ids)
|
|
6432
7462
|
_validate_participating_analysers(data, failures)
|
|
6433
7463
|
_validate_self_fix_rewrite_scope(data, failures)
|
|
6434
7464
|
_validate_self_fix_grouping(data, failures)
|
|
@@ -6440,17 +7470,21 @@ def validate_plan_body_section(
|
|
|
6440
7470
|
_validate_verdict_rounds_outlive_self_fix(data, failures)
|
|
6441
7471
|
_validate_plan_item_extraction_completeness(data, failures)
|
|
6442
7472
|
_validate_plan_item_subject_substance(data, failures)
|
|
6443
|
-
_validate_plan_body_clarification_matching(data, failures)
|
|
7473
|
+
_validate_plan_body_clarification_matching(data, failures, accepted_item_ids)
|
|
6444
7474
|
_validate_disagree_has_fixability(data, failures)
|
|
6445
7475
|
_validate_self_fix_before_clarification(data, failures)
|
|
6446
7476
|
return [*_detect_self_fix_recurrence(pbv), *_detect_uniform_verifier(pbv)]
|
|
6447
7477
|
|
|
6448
7478
|
|
|
6449
|
-
def _gate_summary_item(
|
|
7479
|
+
def _gate_summary_item(
|
|
7480
|
+
item: dict,
|
|
7481
|
+
pbv: dict,
|
|
7482
|
+
accepted_item_ids: set[str],
|
|
7483
|
+
) -> dict:
|
|
6450
7484
|
"""One `gate.items[]` row: the gate class plus its state-file counterpart."""
|
|
6451
7485
|
classification = (
|
|
6452
7486
|
"has-dissent"
|
|
6453
|
-
if _is_dissent_downgraded(item, pbv)
|
|
7487
|
+
if _is_dissent_downgraded(item, pbv, accepted_item_ids)
|
|
6454
7488
|
else _classify_plan_item_gate(item)
|
|
6455
7489
|
)
|
|
6456
7490
|
return {
|
|
@@ -6478,11 +7512,12 @@ def plan_body_gate_summary(data: dict) -> dict | None:
|
|
|
6478
7512
|
pbv = ip.get("planBodyVerification")
|
|
6479
7513
|
if not isinstance(pbv, dict):
|
|
6480
7514
|
return None
|
|
6481
|
-
|
|
7515
|
+
accepted_item_ids = _resolved_noncritical_dissent_ids(data)
|
|
7516
|
+
recomputed = _recompute_plan_body_gate(pbv, accepted_item_ids)
|
|
6482
7517
|
if recomputed is None:
|
|
6483
7518
|
return None
|
|
6484
7519
|
items = [
|
|
6485
|
-
_gate_summary_item(item, pbv)
|
|
7520
|
+
_gate_summary_item(item, pbv, accepted_item_ids)
|
|
6486
7521
|
for item in (pbv.get("planItems") or [])
|
|
6487
7522
|
if isinstance(item, dict)
|
|
6488
7523
|
]
|
|
@@ -6493,7 +7528,9 @@ def plan_body_gate_summary(data: dict) -> dict | None:
|
|
|
6493
7528
|
"declaredBlockedBy": sorted(
|
|
6494
7529
|
str(c) for c in (pbv.get("gateBlockedBy") or []) if isinstance(c, str)
|
|
6495
7530
|
),
|
|
6496
|
-
"blockedBy": sorted(
|
|
7531
|
+
"blockedBy": sorted(
|
|
7532
|
+
_gate_blocking_causes(pbv, coverage_blockers, accepted_item_ids)
|
|
7533
|
+
),
|
|
6497
7534
|
"coverageBlockers": coverage_blockers,
|
|
6498
7535
|
"blockingItems": [
|
|
6499
7536
|
item["id"] for item in items if item["classification"] == "majority-disagree"
|
|
@@ -6846,6 +7883,13 @@ def _validate_stage_has_requirement(data: dict, failures: list[str]) -> None:
|
|
|
6846
7883
|
)
|
|
6847
7884
|
|
|
6848
7885
|
|
|
7886
|
+
_FINAL_VERIFICATION_ROUTING_TOKEN_RE = re.compile(
|
|
7887
|
+
r"(?<![A-Za-z-])(?:release-handoff\(stage-group\)|release-handoff|done|"
|
|
7888
|
+
r"implementation|error-analysis|implementation-option-selection|"
|
|
7889
|
+
r"implementation-planning)(?![A-Za-z-])"
|
|
7890
|
+
)
|
|
7891
|
+
|
|
7892
|
+
|
|
6849
7893
|
def _validate_final_verification_consistency(data: dict, failures: list[str]) -> None:
|
|
6850
7894
|
"""Enforce verdict ↔ blocker/condition/routing consistency on the
|
|
6851
7895
|
final-verification data.json (SSOT). The schema guarantees field SHAPE;
|
|
@@ -6860,7 +7904,16 @@ def _validate_final_verification_consistency(data: dict, failures: list[str]) ->
|
|
|
6860
7904
|
fv = data.get("finalVerification") or {}
|
|
6861
7905
|
blockers = fv.get("acceptanceBlockers") or []
|
|
6862
7906
|
conditions = verdict.get("conditionalAcceptanceConditions") or []
|
|
6863
|
-
|
|
7907
|
+
routing_value = fv.get("routingRecommendation")
|
|
7908
|
+
routing = routing_value if isinstance(routing_value, str) else ""
|
|
7909
|
+
routing_tokens = _FINAL_VERIFICATION_ROUTING_TOKEN_RE.findall(routing)
|
|
7910
|
+
routing_token = routing_tokens[0] if len(routing_tokens) == 1 else None
|
|
7911
|
+
|
|
7912
|
+
if routing_token is None:
|
|
7913
|
+
failures.append(
|
|
7914
|
+
"final-verification: routingRecommendation must contain exactly one "
|
|
7915
|
+
"supported routing token."
|
|
7916
|
+
)
|
|
6864
7917
|
|
|
6865
7918
|
if token == "accepted" and blockers:
|
|
6866
7919
|
failures.append(
|
|
@@ -6877,7 +7930,10 @@ def _validate_final_verification_consistency(data: dict, failures: list[str]) ->
|
|
|
6877
7930
|
"final-verification: verdict `conditional-accept` but "
|
|
6878
7931
|
"conditionalAcceptanceConditions is empty — list every condition."
|
|
6879
7932
|
)
|
|
6880
|
-
if
|
|
7933
|
+
if (
|
|
7934
|
+
routing_token in {"release-handoff", "release-handoff(stage-group)"}
|
|
7935
|
+
and token != "accepted"
|
|
7936
|
+
):
|
|
6881
7937
|
failures.append(
|
|
6882
7938
|
f"final-verification: routingRecommendation cites `release-handoff` "
|
|
6883
7939
|
f"but verdict is `{token}` — release-handoff routing is allowed only "
|
|
@@ -6890,8 +7946,7 @@ def _validate_final_verification_consistency(data: dict, failures: list[str]) ->
|
|
|
6890
7946
|
f"final-verification: verificationScope must be `whole-task` or "
|
|
6891
7947
|
f"`single-stage`, got {scope!r}."
|
|
6892
7948
|
)
|
|
6893
|
-
if
|
|
6894
|
-
and "release-handoff(stage-group)" not in routing):
|
|
7949
|
+
if scope == "single-stage" and routing_token == "release-handoff":
|
|
6895
7950
|
failures.append(
|
|
6896
7951
|
"final-verification: verificationScope `single-stage` cannot recommend "
|
|
6897
7952
|
"plain release-handoff routing — a single-stage accepted verdict may "
|
|
@@ -7290,14 +8345,15 @@ def _validate_fix_cycle(run_manifest: dict, data: dict, failures: list[str]) ->
|
|
|
7290
8345
|
def _validate_session_conformance(
|
|
7291
8346
|
team_state: dict,
|
|
7292
8347
|
team_state_path: Path,
|
|
8348
|
+
run_manifest: Mapping[str, Any],
|
|
7293
8349
|
project_root: Path,
|
|
7294
8350
|
report_path: Path,
|
|
7295
8351
|
task_type: str,
|
|
7296
8352
|
claude_projects_dir: str | None,
|
|
7297
8353
|
failures: list[str],
|
|
7298
8354
|
) -> None:
|
|
7299
|
-
"""prompts/lead/okstra-lead-contract.md
|
|
7300
|
-
|
|
8355
|
+
"""prompts/lead/okstra-lead-contract.md의 PROGRESS / activity / heartbeat /
|
|
8356
|
+
implementation entry guard 사후 검사를 위임하고 실패를
|
|
7301
8357
|
``session-conformance: `` 접두로 folding 한다. 설계:
|
|
7302
8358
|
docs/superpowers/specs/2026-06-10-blocking-contract-posthoc-conformance-design.md
|
|
7303
8359
|
"""
|
|
@@ -7314,6 +8370,7 @@ def _validate_session_conformance(
|
|
|
7314
8370
|
result = validate_session_conformance(
|
|
7315
8371
|
team_state=team_state,
|
|
7316
8372
|
team_state_path=team_state_path,
|
|
8373
|
+
run_manifest=run_manifest,
|
|
7317
8374
|
project_root=project_root,
|
|
7318
8375
|
report_path=report_path,
|
|
7319
8376
|
task_type=task_type,
|
|
@@ -8149,6 +9206,7 @@ def main() -> int:
|
|
|
8149
9206
|
_validate_session_conformance(
|
|
8150
9207
|
team_state,
|
|
8151
9208
|
team_state_path,
|
|
9209
|
+
run_manifest,
|
|
8152
9210
|
project_root,
|
|
8153
9211
|
report_path,
|
|
8154
9212
|
task_type,
|
|
@@ -8184,16 +9242,28 @@ def main() -> int:
|
|
|
8184
9242
|
for warning in conformance_warnings:
|
|
8185
9243
|
print(f"validate-run: warning: {warning}", file=sys.stderr)
|
|
8186
9244
|
if task_type in _BRIEF_DERIVED_PHASES:
|
|
8187
|
-
|
|
9245
|
+
planning = validation_data.get("implementationPlanning")
|
|
9246
|
+
selected_direction_plan = (
|
|
9247
|
+
task_type == "implementation-planning"
|
|
9248
|
+
and isinstance(planning, Mapping)
|
|
9249
|
+
and planning.get("planningContract") == "selected-direction"
|
|
9250
|
+
)
|
|
9251
|
+
brief_path = (
|
|
9252
|
+
project_root / "__selected-direction-brief-validated-from-run-manifest__"
|
|
9253
|
+
if selected_direction_plan
|
|
9254
|
+
else _brief_path_from_manifest(task_manifest, project_root)
|
|
9255
|
+
)
|
|
8188
9256
|
if task_type in _END_STATE_PHASES:
|
|
8189
9257
|
if task_type == "implementation-planning":
|
|
8190
9258
|
_validate_planning_conformance_declared(report_path, failures)
|
|
8191
|
-
|
|
8192
|
-
|
|
9259
|
+
if not selected_direction_plan:
|
|
9260
|
+
_validate_end_state_coverage(validation_data, brief_path, failures)
|
|
9261
|
+
if task_type == "implementation-planning" and not selected_direction_plan:
|
|
8193
9262
|
_validate_requirement_provenance(
|
|
8194
9263
|
validation_data, brief_path, failures
|
|
8195
9264
|
)
|
|
8196
9265
|
_validate_stage_has_requirement(validation_data, failures)
|
|
9266
|
+
if task_type == "implementation-planning":
|
|
8197
9267
|
_append_stage_data_failures(validation_data, failures)
|
|
8198
9268
|
if task_type == "improvement-discovery":
|
|
8199
9269
|
run_dir = report_path.parent.parent
|