okstra 0.179.2 → 0.183.2
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 +1 -1
- package/dist/cli-registry.mjs +30 -2
- package/dist/cli-registry.mjs.map +1 -1
- package/dist/commands/execute/incremental-carry.mjs +9 -8
- package/dist/commands/execute/incremental-carry.mjs.map +1 -1
- package/dist/commands/execute/plan-verify.mjs +3 -1
- package/dist/commands/execute/plan-verify.mjs.map +1 -1
- package/dist/commands/execute/render-bundle.d.mts +4 -2
- package/dist/commands/execute/render-bundle.mjs +46 -5
- package/dist/commands/execute/render-bundle.mjs.map +1 -1
- package/dist/commands/execute/run.mjs +11 -3
- package/dist/commands/execute/run.mjs.map +1 -1
- package/dist/commands/inspect/model-io.d.mts +1 -0
- package/dist/commands/inspect/model-io.mjs +25 -0
- package/dist/commands/inspect/model-io.mjs.map +1 -0
- package/dist/commands/inspect/stage-map.mjs +29 -8
- package/dist/commands/inspect/stage-map.mjs.map +1 -1
- package/dist/commands/inspect/task-list.mjs +52 -6
- package/dist/commands/inspect/task-list.mjs.map +1 -1
- package/dist/commands/inspect/user-response.mjs +14 -4
- package/dist/commands/inspect/user-response.mjs.map +1 -1
- package/dist/commands/lifecycle/check-project.d.mts +1 -0
- package/dist/commands/lifecycle/check-project.mjs +69 -50
- package/dist/commands/lifecycle/check-project.mjs.map +1 -1
- package/dist/commands/lifecycle/contract-check.d.mts +1 -0
- package/dist/commands/lifecycle/contract-check.mjs +18 -0
- package/dist/commands/lifecycle/contract-check.mjs.map +1 -0
- package/dist/commands/lifecycle/preflight.mjs +154 -51
- package/dist/commands/lifecycle/preflight.mjs.map +1 -1
- package/dist/commands/pr/pr.d.mts +1 -0
- package/dist/commands/pr/pr.mjs +19 -1
- package/dist/commands/pr/pr.mjs.map +1 -1
- package/dist/commands/report/agent-activity.mjs +2 -2
- package/dist/commands/report/approval-decision.d.mts +1 -0
- package/dist/commands/report/approval-decision.mjs +21 -0
- package/dist/commands/report/approval-decision.mjs.map +1 -0
- package/dist/commands/report/design-snapshot.d.mts +1 -0
- package/dist/commands/report/design-snapshot.mjs +19 -0
- package/dist/commands/report/design-snapshot.mjs.map +1 -0
- package/dist/commands/report/translate.mjs +3 -0
- package/dist/commands/report/translate.mjs.map +1 -1
- package/dist/lib/host-registry-client.mjs +13 -9
- package/dist/lib/host-registry-client.mjs.map +1 -1
- package/docs/architecture/storage-model.md +1 -1
- package/docs/architecture.md +21 -10
- package/docs/cli.md +36 -20
- package/docs/container.md +6 -4
- package/docs/contributor-change-matrix.md +1 -1
- package/docs/for-ai/README.md +2 -2
- package/docs/for-ai/skills/okstra-brief-gen.md +5 -3
- package/docs/for-ai/skills/okstra-code-review.md +4 -4
- package/docs/for-ai/skills/okstra-container-build.md +20 -17
- package/docs/for-ai/skills/okstra-inspect.md +20 -23
- package/docs/for-ai/skills/okstra-manager.md +19 -18
- package/docs/for-ai/skills/okstra-memory.md +2 -2
- package/docs/for-ai/skills/okstra-pr-gen.md +3 -3
- package/docs/for-ai/skills/okstra-rollup.md +14 -13
- package/docs/for-ai/skills/okstra-run.md +7 -3
- package/docs/for-ai/skills/okstra-schedule-gen.md +15 -18
- package/docs/for-ai/skills/okstra-setup.md +7 -7
- package/docs/for-ai/skills/okstra-usage.md +5 -4
- package/docs/for-ai/skills/okstra-user-response.md +50 -32
- package/docs/project-structure-overview.md +43 -31
- package/docs/task-process/implementation-planning.md +2 -2
- package/docs/task-process/implementation.md +7 -4
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/claude-worker.md +4 -4
- package/runtime/agents/workers/report-writer-worker.md +15 -164
- package/runtime/agents/workers/translator-worker.md +5 -13
- package/runtime/bin/okstra-error-log.py +51 -11
- package/runtime/bin/okstra-report-translate.py +210 -23
- package/runtime/prompts/host-orchestration/implementation.md +1 -1
- package/runtime/prompts/launch.template.md +9 -12
- package/runtime/prompts/lead/adapters/cmux.md +1 -1
- package/runtime/prompts/lead/context-loader.md +41 -141
- package/runtime/prompts/lead/convergence.md +10 -8
- package/runtime/prompts/lead/okstra-lead-contract.md +40 -49
- package/runtime/prompts/lead/plan-body-verification.md +217 -23
- package/runtime/prompts/lead/report-writer.md +81 -421
- package/runtime/prompts/lead/team-contract.md +9 -54
- package/runtime/prompts/profiles/_clarification-recommendation.md +5 -4
- package/runtime/prompts/profiles/_coding-conventions-preflight.md +3 -2
- package/runtime/prompts/profiles/_common-contract.md +4 -4
- package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
- package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +3 -3
- package/runtime/prompts/profiles/change-impact-analysis.md +1 -1
- package/runtime/prompts/profiles/error-analysis.md +1 -1
- package/runtime/prompts/profiles/feature-analysis.md +1 -1
- package/runtime/prompts/profiles/final-verification.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +20 -12
- package/runtime/prompts/profiles/improvement-discovery.md +1 -1
- package/runtime/prompts/profiles/project-analysis.md +1 -1
- package/runtime/prompts/profiles/release-handoff.md +0 -1
- package/runtime/prompts/profiles/requirements-discovery.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +0 -1
- package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +69 -17
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +13 -4
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +8 -2
- package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +2 -2
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +50 -5
- package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +2 -2
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +66 -5
- package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +70 -2
- package/runtime/python/okstra_ctl/agent_activity.py +141 -38
- package/runtime/python/okstra_ctl/agent_invocation.py +19 -6
- package/runtime/python/okstra_ctl/agent_prompt_cli.py +70 -23
- package/runtime/python/okstra_ctl/analysis_inputs.py +5 -4
- package/runtime/python/okstra_ctl/analysis_packet.py +124 -3
- package/runtime/python/okstra_ctl/approval_decisions.py +328 -0
- package/runtime/python/okstra_ctl/attempt_evidence.py +2 -2
- package/runtime/python/okstra_ctl/backfill.py +13 -10
- package/runtime/python/okstra_ctl/batch.py +2 -4
- package/runtime/python/okstra_ctl/build_tools.py +6 -3
- package/runtime/python/okstra_ctl/claim_reproduction.py +101 -0
- package/runtime/python/okstra_ctl/clarification_items.py +27 -13
- package/runtime/python/okstra_ctl/cmux.py +130 -52
- package/runtime/python/okstra_ctl/code_review_target.py +34 -8
- package/runtime/python/okstra_ctl/consumers.py +5 -4
- package/runtime/python/okstra_ctl/container.py +103 -8
- package/runtime/python/okstra_ctl/context_cost.py +2 -1
- package/runtime/python/okstra_ctl/contract_graph.py +497 -0
- package/runtime/python/okstra_ctl/contract_graph_cli.py +62 -0
- package/runtime/python/okstra_ctl/convergence.py +338 -17
- package/runtime/python/okstra_ctl/convergence_engine.py +10 -18
- package/runtime/python/okstra_ctl/convergence_provenance.py +58 -8
- package/runtime/python/okstra_ctl/convergence_store.py +55 -34
- package/runtime/python/okstra_ctl/design_prep.py +7 -4
- package/runtime/python/okstra_ctl/design_snapshot.py +134 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +97 -69
- package/runtime/python/okstra_ctl/dispatch_state.py +163 -63
- package/runtime/python/okstra_ctl/doctor.py +6 -3
- package/runtime/python/okstra_ctl/domain/worker_presentation.py +70 -9
- package/runtime/python/okstra_ctl/entrypoints/hosts.py +16 -30
- package/runtime/python/okstra_ctl/error_log_write.py +35 -30
- package/runtime/python/okstra_ctl/error_report.py +26 -1
- package/runtime/python/okstra_ctl/error_zip.py +27 -5
- package/runtime/python/okstra_ctl/execution_identity.py +3 -2
- package/runtime/python/okstra_ctl/execution_manifest.py +7 -4
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +6 -2
- package/runtime/python/okstra_ctl/final_report_schema.py +26 -17
- package/runtime/python/okstra_ctl/fix_cycles.py +2 -2
- package/runtime/python/okstra_ctl/fixed_text.py +39 -0
- package/runtime/python/okstra_ctl/git_reconcile.py +41 -9
- package/runtime/python/okstra_ctl/handoff.py +5 -4
- package/runtime/python/okstra_ctl/i18n.py +4 -2
- package/runtime/python/okstra_ctl/implementation_direction.py +22 -14
- package/runtime/python/okstra_ctl/implementation_outcome.py +4 -7
- package/runtime/python/okstra_ctl/incremental_carry.py +130 -17
- package/runtime/python/okstra_ctl/incremental_scope.py +12 -4
- package/runtime/python/okstra_ctl/index.py +8 -11
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +79 -7
- package/runtime/python/okstra_ctl/invocation.py +3 -6
- package/runtime/python/okstra_ctl/json_boundary.py +366 -0
- package/runtime/python/okstra_ctl/json_registry.py +10 -12
- package/runtime/python/okstra_ctl/jsonl.py +19 -2
- package/runtime/python/okstra_ctl/lead_events.py +33 -1
- package/runtime/python/okstra_ctl/listing.py +3 -3
- package/runtime/python/okstra_ctl/log_report.py +24 -2
- package/runtime/python/okstra_ctl/manager_cli.py +92 -7
- package/runtime/python/okstra_ctl/manager_store.py +12 -10
- package/runtime/python/okstra_ctl/material.py +5 -1
- package/runtime/python/okstra_ctl/migrate.py +29 -25
- package/runtime/python/okstra_ctl/model_cli.py +3 -15
- package/runtime/python/okstra_ctl/model_io_cli.py +1051 -0
- package/runtime/python/okstra_ctl/mutation_probe.py +13 -4
- package/runtime/python/okstra_ctl/pane_reclaim.py +3 -2
- package/runtime/python/okstra_ctl/path_hints.py +12 -0
- package/runtime/python/okstra_ctl/paths.py +21 -0
- package/runtime/python/okstra_ctl/plan_items.py +115 -4
- package/runtime/python/okstra_ctl/plan_items_cli.py +626 -34
- package/runtime/python/okstra_ctl/ports/worker_dispatch.py +2 -1
- package/runtime/python/okstra_ctl/pr_template.py +3 -2
- package/runtime/python/okstra_ctl/project_meta.py +5 -7
- package/runtime/python/okstra_ctl/recap.py +5 -4
- package/runtime/python/okstra_ctl/reconcile.py +21 -27
- package/runtime/python/okstra_ctl/registry/host_discovery.py +3 -2
- package/runtime/python/okstra_ctl/registry/provider_registry.py +3 -2
- package/runtime/python/okstra_ctl/render.py +74 -16
- package/runtime/python/okstra_ctl/render_final_report.py +10 -8
- package/runtime/python/okstra_ctl/report_assembly.py +509 -0
- package/runtime/python/okstra_ctl/report_contract.py +2 -1
- package/runtime/python/okstra_ctl/report_finalize.py +67 -32
- package/runtime/python/okstra_ctl/report_html/render.py +3 -2
- package/runtime/python/okstra_ctl/report_inputs.py +72 -0
- package/runtime/python/okstra_ctl/report_language.py +3 -2
- package/runtime/python/okstra_ctl/report_markdown.py +81 -8
- package/runtime/python/okstra_ctl/report_narrative.py +351 -0
- package/runtime/python/okstra_ctl/report_projections.py +265 -0
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +518 -0
- package/runtime/python/okstra_ctl/report_views.py +3 -2
- package/runtime/python/okstra_ctl/rollup.py +65 -4
- package/runtime/python/okstra_ctl/run.py +184 -65
- package/runtime/python/okstra_ctl/run_audit.py +3 -2
- package/runtime/python/okstra_ctl/run_context.py +6 -9
- package/runtime/python/okstra_ctl/run_index_row.py +2 -8
- package/runtime/python/okstra_ctl/schedule_semantics.py +5 -2
- package/runtime/python/okstra_ctl/schema_excerpt.py +15 -8
- package/runtime/python/okstra_ctl/session_transcript.py +27 -1
- package/runtime/python/okstra_ctl/set_work_status.py +64 -38
- package/runtime/python/okstra_ctl/stage_fix_carry.py +8 -6
- package/runtime/python/okstra_ctl/stage_ledger.py +132 -18
- package/runtime/python/okstra_ctl/stage_map.py +96 -28
- package/runtime/python/okstra_ctl/stage_targets.py +3 -4
- package/runtime/python/okstra_ctl/team.py +3 -2
- package/runtime/python/okstra_ctl/team_reconcile.py +11 -2
- package/runtime/python/okstra_ctl/time_report.py +51 -4
- package/runtime/python/okstra_ctl/usage_identity.py +2 -1
- package/runtime/python/okstra_ctl/usage_report.py +58 -4
- package/runtime/python/okstra_ctl/user_response.py +1431 -66
- package/runtime/python/okstra_ctl/wizard.py +11 -44
- package/runtime/python/okstra_ctl/work_categories.py +3 -2
- package/runtime/python/okstra_ctl/worker_dispatch.py +5 -2
- package/runtime/python/okstra_ctl/worker_prompt_body.py +49 -3
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +3 -2
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +31 -3
- package/runtime/python/okstra_ctl/worker_runner.py +14 -12
- package/runtime/python/okstra_ctl/workflow.py +2 -1
- package/runtime/python/okstra_ctl/worktree.py +3 -2
- package/runtime/python/okstra_ctl/wrapper_status.py +4 -2
- package/runtime/python/okstra_ctl/write_policy.py +4 -2
- package/runtime/python/okstra_token_usage/antigravity.py +39 -12
- package/runtime/python/okstra_token_usage/collect.py +90 -38
- package/runtime/python/okstra_token_usage/grok.py +127 -0
- package/runtime/schemas/final-report-v2.0.schema.json +9 -0
- package/runtime/schemas/final-report-v3.0.schema.json +10219 -0
- package/runtime/schemas/report-narrative-v3.0.schema.json +30 -0
- package/runtime/schemas/report-synthesis-packet-v1.0.schema.json +140 -0
- package/runtime/skills/okstra-brief-gen/SKILL.md +9 -7
- package/runtime/skills/okstra-code-review/SKILL.md +21 -11
- package/runtime/skills/okstra-container-build/SKILL.md +18 -18
- package/runtime/skills/okstra-inspect/SKILL.md +12 -11
- package/runtime/skills/okstra-inspect/facets/error-zip.md +8 -8
- package/runtime/skills/okstra-inspect/facets/errors.md +2 -2
- package/runtime/skills/okstra-inspect/facets/history.md +9 -14
- package/runtime/skills/okstra-inspect/facets/logs.md +2 -2
- package/runtime/skills/okstra-inspect/facets/recap.md +5 -5
- package/runtime/skills/okstra-inspect/facets/report.md +6 -10
- package/runtime/skills/okstra-inspect/facets/status.md +9 -8
- package/runtime/skills/okstra-inspect/facets/time.md +3 -3
- package/runtime/skills/okstra-manager/SKILL.md +16 -14
- package/runtime/skills/okstra-memory/SKILL.md +3 -3
- package/runtime/skills/okstra-pr-gen/SKILL.md +5 -4
- package/runtime/skills/okstra-rollup/SKILL.md +6 -16
- package/runtime/skills/okstra-run/SKILL.md +7 -7
- package/runtime/skills/okstra-schedule-gen/SKILL.md +21 -17
- package/runtime/skills/okstra-setup/SKILL.md +21 -13
- package/runtime/skills/okstra-setup/references/project-config.md +2 -2
- package/runtime/skills/okstra-usage/SKILL.md +10 -10
- package/runtime/skills/okstra-user-response/SKILL.md +78 -107
- package/runtime/templates/report-writer-prompt-preamble.md +29 -19
- package/runtime/templates/reports/html/macros/forms.html +6 -4
- package/runtime/templates/reports/schedule.template.md +4 -4
- package/runtime/templates/worker-error-contract.md +17 -29
- package/runtime/validators/validate-run.py +636 -72
- package/runtime/validators/validate_session_conformance.py +43 -7
|
@@ -2890,7 +2890,7 @@ def validate_report(
|
|
|
2890
2890
|
report_data: Mapping[str, Any] | None = None,
|
|
2891
2891
|
team_state: Mapping[str, Any] | None = None,
|
|
2892
2892
|
) -> None:
|
|
2893
|
-
if (report_data or {}).get("schemaVersion")
|
|
2893
|
+
if (report_data or {}).get("schemaVersion") in {"2.0", "3.0"}:
|
|
2894
2894
|
_validate_v2_report(report_data or {}, failures, team_state=team_state)
|
|
2895
2895
|
return
|
|
2896
2896
|
|
|
@@ -2997,6 +2997,9 @@ def validate_report(
|
|
|
2997
2997
|
|
|
2998
2998
|
|
|
2999
2999
|
_REPORT_BASENAME_SEQ_RE = re.compile(r"-(?P<seq>\d{3})(?:\.data)?\.(?:md|json)$")
|
|
3000
|
+
_REPORT_BASENAME_TASK_TYPE_RE = re.compile(
|
|
3001
|
+
r"^final-report-(?P<task_type>[a-z][a-z-]*?)-\d{3}(?:\.data)?\.(?:md|json)$"
|
|
3002
|
+
)
|
|
3000
3003
|
|
|
3001
3004
|
|
|
3002
3005
|
def _report_run_seq(report_path: Path) -> str | None:
|
|
@@ -3007,6 +3010,25 @@ def _report_run_seq(report_path: Path) -> str | None:
|
|
|
3007
3010
|
return match.group("seq") if match else None
|
|
3008
3011
|
|
|
3009
3012
|
|
|
3013
|
+
def _report_task_type(report_path: Path) -> str:
|
|
3014
|
+
"""This run's task type, read off `final-report-<task-type>-<seq>`.
|
|
3015
|
+
|
|
3016
|
+
The report's own `header.taskType` is the first source, but it is not
|
|
3017
|
+
always reachable. `report_narrative._allowed_top_level()` has no `header` —
|
|
3018
|
+
it is not a writer-owned block — so a gate scored from a narrative, which is
|
|
3019
|
+
the only input a report-contract-3.0 run has before assembly, carries no
|
|
3020
|
+
task type at all. Globbing with an empty one matched nothing and reported
|
|
3021
|
+
every verdict as unbacked under a `runs//worker-results/` path.
|
|
3022
|
+
|
|
3023
|
+
The filename carries it in every caller: the full-run path passes the report
|
|
3024
|
+
itself, and `_report_path_for_state` builds the same canonical name from the
|
|
3025
|
+
state file. Returns `""` when the name does not carry one, so a caller can
|
|
3026
|
+
tell "not resolvable" from a real task type.
|
|
3027
|
+
"""
|
|
3028
|
+
match = _REPORT_BASENAME_TASK_TYPE_RE.match(report_path.name)
|
|
3029
|
+
return match.group("task_type") if match else ""
|
|
3030
|
+
|
|
3031
|
+
|
|
3010
3032
|
def validate_worker_results_audit(
|
|
3011
3033
|
report_path: Path,
|
|
3012
3034
|
task_type: str,
|
|
@@ -3090,29 +3112,21 @@ PLAN_VERIFY_GATE_VALUES = (
|
|
|
3090
3112
|
# the approved-frontmatter gate (the `.match` would otherwise return None).
|
|
3091
3113
|
_FRONTMATTER_BLOCK_RE = re.compile(r"\A\ufeff?\s*---\n(.*?)\n---\n", re.DOTALL)
|
|
3092
3114
|
def _validate_verdict_card_fields(data: dict, failures: list[str]) -> None:
|
|
3093
|
-
"""
|
|
3094
|
-
|
|
3095
|
-
The markdown check above compares only the Verdict Token, so a Card whose
|
|
3096
|
-
Direction contradicted §7 shipped silently. `nextStep` is deliberately NOT
|
|
3097
|
-
compared: every shipped fixture authors the Card's cell as the actionable
|
|
3098
|
-
command (`/okstra-run task-type=release-handoff`) while §7 states the same
|
|
3099
|
-
action as prose ("Proceed to release-handoff."), so a byte-match rule there
|
|
3100
|
-
would reject the reference reports.
|
|
3101
|
-
"""
|
|
3115
|
+
"""Verdict Card의 정본 표시 값이 §7의 값과 같은지 모두 검사한다."""
|
|
3102
3116
|
card = data.get("verdictCard")
|
|
3103
3117
|
final = data.get("finalVerdict")
|
|
3104
3118
|
if not isinstance(card, dict) or not isinstance(final, dict):
|
|
3105
3119
|
return
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3120
|
+
for field in ("finalConclusion", "direction", "nextStep"):
|
|
3121
|
+
card_value = str(card.get(field) or "").strip()
|
|
3122
|
+
final_value = str(final.get(field) or "").strip()
|
|
3123
|
+
if not card_value or not final_value or card_value == final_value:
|
|
3124
|
+
continue
|
|
3125
|
+
failures.append(
|
|
3126
|
+
f"final-report data.json: verdictCard.{field} value `{card_value}` "
|
|
3127
|
+
f"does not match finalVerdict.{field} value `{final_value}` — the "
|
|
3128
|
+
"Card is a non-authoritative index and must preserve the §7 value."
|
|
3129
|
+
)
|
|
3116
3130
|
|
|
3117
3131
|
|
|
3118
3132
|
def _route_target_matches(value: Any, target: str, *, command: bool) -> bool:
|
|
@@ -3585,8 +3599,12 @@ def validate_final_report_data(
|
|
|
3585
3599
|
# namespaced audit ref (claude:F-005), or a §section reference (§5.4) — any one
|
|
3586
3600
|
# satisfies "this claim is anchored". A bare brief/worker ID (RC-*/RF-*/F-*) is
|
|
3587
3601
|
# handled separately by `_validate_no_opaque_id_references`.
|
|
3588
|
-
|
|
3589
|
-
|
|
3602
|
+
_PATH_LINE_EVIDENCE_TOKEN = re.compile(r"[\w./-]+\.\w+:\d+")
|
|
3603
|
+
_SECTION_EVIDENCE_TOKEN = re.compile(r"§\s*\d")
|
|
3604
|
+
_IN_REPORT_ID_TOKEN = re.compile(r"\b[A-Z]{1,3}(?:-[a-z]+)?-\d+\b")
|
|
3605
|
+
_NAMESPACED_EVIDENCE_TOKEN = re.compile(
|
|
3606
|
+
r"(?<![\w-])[a-z][a-z0-9-]*:[A-Z]{1,3}(?:-[a-z]+)?-\d+(?![\w-])"
|
|
3607
|
+
)
|
|
3590
3608
|
# Explicit "I don't know" escapes — anti-fabrication's other valid answer.
|
|
3591
3609
|
_INSUFFICIENCY_MARKERS = (
|
|
3592
3610
|
"근거 불충분", "근거가 불충분", "증거 불충분", "증거가 불충분",
|
|
@@ -3596,6 +3614,44 @@ _INSUFFICIENCY_MARKERS = (
|
|
|
3596
3614
|
_RATIONALE_FIELDS = ("motivation", "problem", "approach", "justification")
|
|
3597
3615
|
|
|
3598
3616
|
|
|
3617
|
+
def _report_reference_ids(data: Mapping[str, Any]) -> set[str]:
|
|
3618
|
+
references: set[str] = set()
|
|
3619
|
+
|
|
3620
|
+
def visit(value: Any, key: str = "") -> None:
|
|
3621
|
+
if isinstance(value, Mapping):
|
|
3622
|
+
for child_key, child_value in value.items():
|
|
3623
|
+
visit(child_value, str(child_key))
|
|
3624
|
+
return
|
|
3625
|
+
if isinstance(value, list):
|
|
3626
|
+
for child in value:
|
|
3627
|
+
visit(child, key)
|
|
3628
|
+
return
|
|
3629
|
+
if not isinstance(value, str):
|
|
3630
|
+
return
|
|
3631
|
+
if (key == "id" or key.endswith("Id")) and _IN_REPORT_ID_TOKEN.fullmatch(value):
|
|
3632
|
+
references.add(value)
|
|
3633
|
+
if key == "sourceItems":
|
|
3634
|
+
references.update(_NAMESPACED_EVIDENCE_TOKEN.findall(value))
|
|
3635
|
+
|
|
3636
|
+
visit(data)
|
|
3637
|
+
return references
|
|
3638
|
+
|
|
3639
|
+
|
|
3640
|
+
def _rationale_reference_status(
|
|
3641
|
+
text: str,
|
|
3642
|
+
known_references: set[str],
|
|
3643
|
+
) -> tuple[bool, tuple[str, ...]]:
|
|
3644
|
+
if _PATH_LINE_EVIDENCE_TOKEN.search(text) or _SECTION_EVIDENCE_TOKEN.search(text):
|
|
3645
|
+
return True, ()
|
|
3646
|
+
namespaced = tuple(_NAMESPACED_EVIDENCE_TOKEN.findall(text))
|
|
3647
|
+
without_namespaced = _NAMESPACED_EVIDENCE_TOKEN.sub("", text)
|
|
3648
|
+
report_ids = tuple(_IN_REPORT_ID_TOKEN.findall(without_namespaced))
|
|
3649
|
+
candidates = (*namespaced, *report_ids)
|
|
3650
|
+
if any(candidate in known_references for candidate in candidates):
|
|
3651
|
+
return True, ()
|
|
3652
|
+
return False, tuple(dict.fromkeys(candidates))
|
|
3653
|
+
|
|
3654
|
+
|
|
3599
3655
|
def _validate_rationale_evidence(data: dict, failures: list[str]) -> None:
|
|
3600
3656
|
"""Every `## 작업 배경과 근거` field must anchor its claim: carry at least
|
|
3601
3657
|
one evidence reference (path:line, report ID, §section) OR an explicit
|
|
@@ -3605,14 +3661,25 @@ def _validate_rationale_evidence(data: dict, failures: list[str]) -> None:
|
|
|
3605
3661
|
rationale = data.get("rationale")
|
|
3606
3662
|
if not isinstance(rationale, dict):
|
|
3607
3663
|
return # absence/shape is the schema's job; don't double-report.
|
|
3664
|
+
known_references = _report_reference_ids(data)
|
|
3608
3665
|
for field in _RATIONALE_FIELDS:
|
|
3609
3666
|
text = rationale.get(field)
|
|
3610
3667
|
if not isinstance(text, str):
|
|
3611
3668
|
continue
|
|
3612
|
-
|
|
3669
|
+
grounded, unknown_references = _rationale_reference_status(
|
|
3670
|
+
text,
|
|
3671
|
+
known_references,
|
|
3672
|
+
)
|
|
3673
|
+
if grounded:
|
|
3613
3674
|
continue
|
|
3614
3675
|
if any(m in text.lower() for m in _INSUFFICIENCY_MARKERS):
|
|
3615
3676
|
continue
|
|
3677
|
+
if unknown_references:
|
|
3678
|
+
failures.append(
|
|
3679
|
+
f"final-report data.json: rationale.{field} cites unknown "
|
|
3680
|
+
"in-report reference(s): " + ", ".join(unknown_references) + "."
|
|
3681
|
+
)
|
|
3682
|
+
continue
|
|
3616
3683
|
failures.append(
|
|
3617
3684
|
f"final-report data.json: rationale.{field} cites no evidence "
|
|
3618
3685
|
f"(expected a path:line, an in-report ID like C-001, or a §"
|
|
@@ -3787,6 +3854,42 @@ def _is_variation_point_item(item: dict) -> bool:
|
|
|
3787
3854
|
return str(item.get("id") or "").upper().startswith("P-VAR")
|
|
3788
3855
|
|
|
3789
3856
|
|
|
3857
|
+
def _single_vote_dissents(item: dict, kinds: set[str]) -> list[dict]:
|
|
3858
|
+
"""이 항목에서 1표 차단을 주장하는 DISAGREE 행들."""
|
|
3859
|
+
return [
|
|
3860
|
+
row for row in (item.get("verdicts") or [])
|
|
3861
|
+
if isinstance(row, dict)
|
|
3862
|
+
and str(row.get("verdict") or "").strip().upper() == "DISAGREE"
|
|
3863
|
+
and str(row.get("breakageKind") or "").strip().lower() in kinds
|
|
3864
|
+
]
|
|
3865
|
+
|
|
3866
|
+
|
|
3867
|
+
def _single_vote_block_survives(item: dict, kinds: set[str]) -> bool:
|
|
3868
|
+
"""1표 차단이 성립하는지.
|
|
3869
|
+
|
|
3870
|
+
1표 차단에는 근거가 있다 — 명시된 두 인용이 서로 모순이라는 것은 한 명이
|
|
3871
|
+
실측으로 확정할 수 있는 사실이고, 사실을 다수결로 기각하면 안 된다. 문제는
|
|
3872
|
+
1표라는 것이 아니라 **1표에 재현 요구가 없었다**는 것이다. "이 경로는 존재하지
|
|
3873
|
+
않는다" 라고 쓰기만 하면 그대로 차단이 됐다.
|
|
3874
|
+
|
|
3875
|
+
이제 주장이 스스로 `fact` 를 선언하고 okstra 가 그것을 재현했을 때만 1표로
|
|
3876
|
+
막는다. 선언했는데 재현되지 않았거나 `judgement` 였다면 정족수로 내려간다.
|
|
3877
|
+
|
|
3878
|
+
아무 행도 `claimKind` 를 선언하지 않았으면 종전대로 막는다. 그 필드를 실을 수
|
|
3879
|
+
없던 시절의 판정을 뒤에서 뒤집지 않기 위해서다 — 도입은 완화 방향으로만
|
|
3880
|
+
작동하고, 선언한 주장만 재현을 요구받는다.
|
|
3881
|
+
"""
|
|
3882
|
+
dissents = _single_vote_dissents(item, kinds)
|
|
3883
|
+
declared = [row for row in dissents if row.get("claimKind")]
|
|
3884
|
+
if not declared:
|
|
3885
|
+
return bool(dissents)
|
|
3886
|
+
return any(
|
|
3887
|
+
str(row.get("claimKind") or "") == "fact"
|
|
3888
|
+
and str(row.get("reproductionResult") or "") == "reproduced"
|
|
3889
|
+
for row in declared
|
|
3890
|
+
)
|
|
3891
|
+
|
|
3892
|
+
|
|
3790
3893
|
def _classify_plan_item_gate(item: dict) -> str:
|
|
3791
3894
|
"""Recompute one plan item's gate class from its per-worker verdicts,
|
|
3792
3895
|
per `prompts/lead/plan-body-verification.md` "Round protocol". Returns one of
|
|
@@ -3831,8 +3934,11 @@ def _classify_plan_item_gate(item: dict) -> str:
|
|
|
3831
3934
|
# `a` for any item except `P-Var-*` (majority-gated, see
|
|
3832
3935
|
# `_is_variation_point_item`); `f` only for P-Req items (requirement coverage).
|
|
3833
3936
|
is_req = str(item.get("id") or "").upper().startswith("P-REQ")
|
|
3834
|
-
|
|
3835
|
-
|
|
3937
|
+
single_vote_kinds = set(_SINGLE_VOTE_BLOCKING_KINDS) | ({"f"} if is_req else set())
|
|
3938
|
+
if (
|
|
3939
|
+
not _is_variation_point_item(item)
|
|
3940
|
+
and blocking_kinds & single_vote_kinds
|
|
3941
|
+
and _single_vote_block_survives(item, single_vote_kinds)
|
|
3836
3942
|
):
|
|
3837
3943
|
# "One confirmed DISAGREE" presupposes the item was actually
|
|
3838
3944
|
# cross-verified. When the peer returned a non-result nothing confirmed
|
|
@@ -3848,9 +3954,58 @@ def _classify_plan_item_gate(item: dict) -> str:
|
|
|
3848
3954
|
# made the gate stricter than a healthy roster would.
|
|
3849
3955
|
if len(non_error) >= 2 and len(blocking_disagree) > len(agree):
|
|
3850
3956
|
return "majority-disagree"
|
|
3957
|
+
# A tie is not consensus, and until now it read as one. The majority test is
|
|
3958
|
+
# strict, so an even panel splitting 1-AGREE / 1-DISAGREE on a blocking kind
|
|
3959
|
+
# fell through to `has-dissent` and the gate passed — the dissent recorded
|
|
3960
|
+
# and never acted on. An even panel is not only the two-analyser roster: one
|
|
3961
|
+
# UNVERIFIABLE or one lost dispatch turns any roster even for that item.
|
|
3962
|
+
# Send the split back for a round; if it survives a round that judged the
|
|
3963
|
+
# rewritten text, nothing further is going to settle it and the user decides.
|
|
3964
|
+
# `_validate_unresolved_tie_was_reverified` is what makes the first branch
|
|
3965
|
+
# more than a label — `needs-reverify` folds into `passed-with-dissent`.
|
|
3966
|
+
if len(non_error) >= 2 and len(blocking_disagree) == len(agree):
|
|
3967
|
+
if _max_verdict_round(item) >= _TIE_SETTLED_ROUND:
|
|
3968
|
+
return "majority-disagree"
|
|
3969
|
+
return "needs-reverify"
|
|
3851
3970
|
return "has-dissent"
|
|
3852
3971
|
|
|
3853
3972
|
|
|
3973
|
+
# 동수를 한 번 재검증한 뒤에도 갈리면 그때는 사용자가 판단한다. 초기 검증이
|
|
3974
|
+
# 라운드 1이고 자가수정 뒤의 표적 재검증이 라운드 2이므로, 라운드 2 이상의
|
|
3975
|
+
# 판정이 붙은 동수는 이미 한 번 돌아온 것이다.
|
|
3976
|
+
_TIE_SETTLED_ROUND = 2
|
|
3977
|
+
|
|
3978
|
+
|
|
3979
|
+
def _max_verdict_round(item: dict) -> int:
|
|
3980
|
+
"""이 항목의 판정이 붙은 가장 늦은 라운드. 스탬프가 없으면 1.
|
|
3981
|
+
|
|
3982
|
+
`apply-verdicts --round <N>` 이 각 행을 찍는다. 스탬프가 없는 행은 자가수정이
|
|
3983
|
+
한 번도 없었던 run 에서만 나오고, 그때는 라운드가 하나뿐이다.
|
|
3984
|
+
"""
|
|
3985
|
+
rounds = [
|
|
3986
|
+
verdict["round"]
|
|
3987
|
+
for verdict in (item.get("verdicts") or [])
|
|
3988
|
+
if isinstance(verdict, dict)
|
|
3989
|
+
and isinstance(verdict.get("round"), int)
|
|
3990
|
+
and not isinstance(verdict.get("round"), bool)
|
|
3991
|
+
]
|
|
3992
|
+
return max(rounds, default=1)
|
|
3993
|
+
|
|
3994
|
+
|
|
3995
|
+
def _is_unsettled_tie(item: dict) -> bool:
|
|
3996
|
+
"""아직 재검증되지 않은 동수 항목."""
|
|
3997
|
+
return (
|
|
3998
|
+
_classify_plan_item_gate(item) == "needs-reverify"
|
|
3999
|
+
and _max_verdict_round(item) < _TIE_SETTLED_ROUND
|
|
4000
|
+
and len([
|
|
4001
|
+
verdict for verdict in (item.get("verdicts") or [])
|
|
4002
|
+
if isinstance(verdict, dict)
|
|
4003
|
+
and str(verdict.get("verdict") or "").strip().upper()
|
|
4004
|
+
not in ("", "VERIFICATION-ERROR")
|
|
4005
|
+
]) >= 2
|
|
4006
|
+
)
|
|
4007
|
+
|
|
4008
|
+
|
|
3854
4009
|
def _disagree_breakage_kinds(item: dict) -> set[str]:
|
|
3855
4010
|
return {
|
|
3856
4011
|
str(v.get("breakageKind") or "").strip().lower()
|
|
@@ -3980,6 +4135,103 @@ def _is_dissent_downgraded(
|
|
|
3980
4135
|
)
|
|
3981
4136
|
|
|
3982
4137
|
|
|
4138
|
+
_STARTABLE_STAGE_STATUSES = frozenset({"ready", "active"})
|
|
4139
|
+
|
|
4140
|
+
|
|
4141
|
+
def _stage_scope_bucket(item: dict, pbv: dict) -> str:
|
|
4142
|
+
"""Whether this item has standing to block the stage about to start.
|
|
4143
|
+
|
|
4144
|
+
The plan covers every stage; implementation runs one at a time. Judging all
|
|
4145
|
+
of them at once means a defect in a stage nobody has reached, or in one
|
|
4146
|
+
already frozen, stops the next stage from starting — and a frozen stage's
|
|
4147
|
+
item cannot be fixed at all, because the Stage Ledger forbids editing its
|
|
4148
|
+
commands. Measured on one run, 9 of 13 blockers were that shape, 6 of them
|
|
4149
|
+
frozen.
|
|
4150
|
+
|
|
4151
|
+
Returns `in-scope` (may block), `observed` (only frozen stages), or
|
|
4152
|
+
`deferred` (only stages not yet startable). Anything unresolvable is
|
|
4153
|
+
`in-scope`: an absent ledger is no basis to narrow, and an item with no
|
|
4154
|
+
`stageScope` belongs to the plan as a whole — `P-Opt-*` and `P-Var-*` live
|
|
4155
|
+
there permanently, and scoping them out would stop an unrequested-work
|
|
4156
|
+
verdict from blocking a start.
|
|
4157
|
+
"""
|
|
4158
|
+
ledger = pbv.get("stageLedger")
|
|
4159
|
+
if not isinstance(ledger, dict) or not ledger:
|
|
4160
|
+
return "in-scope"
|
|
4161
|
+
scope = item.get("stageScope")
|
|
4162
|
+
stages = [
|
|
4163
|
+
value for value in scope
|
|
4164
|
+
if isinstance(value, int) and not isinstance(value, bool)
|
|
4165
|
+
] if isinstance(scope, list) else []
|
|
4166
|
+
if not stages:
|
|
4167
|
+
return "in-scope"
|
|
4168
|
+
statuses = {str(ledger.get(str(stage)) or "") for stage in stages}
|
|
4169
|
+
if statuses & _STARTABLE_STAGE_STATUSES:
|
|
4170
|
+
return "in-scope"
|
|
4171
|
+
return "observed" if "done" in statuses else "deferred"
|
|
4172
|
+
|
|
4173
|
+
|
|
4174
|
+
def _set_aside_reason(item: dict, pbv: dict, accepted_item_ids: set[str]) -> str | None:
|
|
4175
|
+
"""Why this item stopped blocking, or ``None`` if it never did.
|
|
4176
|
+
|
|
4177
|
+
A gate that passes while defects were set aside has to say which ones and on
|
|
4178
|
+
what grounds. Without that the two halves of the acceptance condition — the
|
|
4179
|
+
next stage can start, and the known risks are written down — collapse into
|
|
4180
|
+
the first, and a defect deferred for a good reason is indistinguishable in
|
|
4181
|
+
the record from one nobody found.
|
|
4182
|
+
"""
|
|
4183
|
+
raw = (
|
|
4184
|
+
"has-dissent"
|
|
4185
|
+
if _is_dissent_downgraded(item, pbv, accepted_item_ids)
|
|
4186
|
+
else _classify_plan_item_gate(item)
|
|
4187
|
+
)
|
|
4188
|
+
if raw != "majority-disagree":
|
|
4189
|
+
return None
|
|
4190
|
+
bucket = _stage_scope_bucket(item, pbv)
|
|
4191
|
+
if bucket != "in-scope":
|
|
4192
|
+
return bucket
|
|
4193
|
+
return "record" if str(item.get("block") or "") == "record" else None
|
|
4194
|
+
|
|
4195
|
+
|
|
4196
|
+
def _set_aside_register(pbv: dict, accepted_item_ids: set[str]) -> list[dict]:
|
|
4197
|
+
"""Every set-aside item, in id order, as the gate records them."""
|
|
4198
|
+
register = [
|
|
4199
|
+
{"id": str(item.get("id") or ""), "reason": reason}
|
|
4200
|
+
for item in (pbv.get("planItems") or [])
|
|
4201
|
+
if isinstance(item, dict)
|
|
4202
|
+
for reason in [_set_aside_reason(item, pbv, accepted_item_ids)]
|
|
4203
|
+
if reason is not None
|
|
4204
|
+
]
|
|
4205
|
+
return sorted(register, key=lambda row: row["id"])
|
|
4206
|
+
|
|
4207
|
+
|
|
4208
|
+
def _plan_item_gate_class(
|
|
4209
|
+
item: dict, pbv: dict, accepted_item_ids: set[str],
|
|
4210
|
+
) -> str:
|
|
4211
|
+
"""The gate class for one item, after stage scope is applied.
|
|
4212
|
+
|
|
4213
|
+
An out-of-scope blocker is not dropped — it lands on `has-dissent`, so the
|
|
4214
|
+
gate still reads `passed-with-dissent` rather than `passed` and the record
|
|
4215
|
+
says something is outstanding. Silently scoring it `passed` would hide the
|
|
4216
|
+
defect instead of deferring it.
|
|
4217
|
+
"""
|
|
4218
|
+
classification = (
|
|
4219
|
+
"has-dissent"
|
|
4220
|
+
if _is_dissent_downgraded(item, pbv, accepted_item_ids)
|
|
4221
|
+
else _classify_plan_item_gate(item)
|
|
4222
|
+
)
|
|
4223
|
+
if classification != "majority-disagree":
|
|
4224
|
+
return classification
|
|
4225
|
+
if _stage_scope_bucket(item, pbv) != "in-scope":
|
|
4226
|
+
return "has-dissent"
|
|
4227
|
+
if str(item.get("block") or "") == "record":
|
|
4228
|
+
# 자기 기록의 부정확은 기록되고 다음 run 의 입력이 되지, 구현 착수를 막지
|
|
4229
|
+
# 않는다. 요구사항이 실제로 안 만들어지는 경우는 이 경로가 아니라
|
|
4230
|
+
# `_independent_coverage_blockers` 의 `coverage-gap` 이 계속 막는다.
|
|
4231
|
+
return "has-dissent"
|
|
4232
|
+
return classification
|
|
4233
|
+
|
|
4234
|
+
|
|
3983
4235
|
def _recompute_plan_body_gate(
|
|
3984
4236
|
pbv: dict,
|
|
3985
4237
|
accepted_item_ids: set[str] | None = None,
|
|
@@ -3989,9 +4241,7 @@ def _recompute_plan_body_gate(
|
|
|
3989
4241
|
no plan items to judge (disabled / empty round)."""
|
|
3990
4242
|
accepted = accepted_item_ids or set()
|
|
3991
4243
|
classes = [
|
|
3992
|
-
|
|
3993
|
-
if _is_dissent_downgraded(it, pbv, accepted)
|
|
3994
|
-
else _classify_plan_item_gate(it)
|
|
4244
|
+
_plan_item_gate_class(it, pbv, accepted)
|
|
3995
4245
|
for it in (pbv.get("planItems") or [])
|
|
3996
4246
|
if isinstance(it, dict)
|
|
3997
4247
|
]
|
|
@@ -4013,6 +4263,60 @@ def _recompute_plan_body_gate(
|
|
|
4013
4263
|
return "passed"
|
|
4014
4264
|
|
|
4015
4265
|
|
|
4266
|
+
def _validate_set_aside_register(
|
|
4267
|
+
data: dict, failures: list[str], accepted_item_ids: set[str] | None = None,
|
|
4268
|
+
) -> None:
|
|
4269
|
+
"""A gate that set defects aside must say which ones and why.
|
|
4270
|
+
|
|
4271
|
+
The gate stops blocking on a defect that belongs to a frozen or unreached
|
|
4272
|
+
stage, or to the plan's own record. That is the point — but a reader of the
|
|
4273
|
+
report cannot tell such a defect from one that was never raised unless the
|
|
4274
|
+
run writes the register down. `gateBlockedBy` names what blocked; this names
|
|
4275
|
+
what did not, and why.
|
|
4276
|
+
|
|
4277
|
+
Declared against recomputed, the same shape as `_validate_gate_blocked_by`:
|
|
4278
|
+
a hand-written register drifts from the verdicts it claims to summarise, and
|
|
4279
|
+
the drift is invisible precisely because nothing else reads it.
|
|
4280
|
+
"""
|
|
4281
|
+
ip = data.get("implementationPlanning")
|
|
4282
|
+
if not isinstance(ip, dict):
|
|
4283
|
+
return
|
|
4284
|
+
pbv = ip.get("planBodyVerification")
|
|
4285
|
+
if not isinstance(pbv, dict):
|
|
4286
|
+
return
|
|
4287
|
+
round_count = pbv.get("roundCount")
|
|
4288
|
+
if not isinstance(round_count, int) or round_count < 1:
|
|
4289
|
+
return
|
|
4290
|
+
accepted = (
|
|
4291
|
+
_resolved_noncritical_dissent_ids(data)
|
|
4292
|
+
if accepted_item_ids is None
|
|
4293
|
+
else accepted_item_ids
|
|
4294
|
+
)
|
|
4295
|
+
expected = _set_aside_register(pbv, accepted)
|
|
4296
|
+
declared_raw = pbv.get("setAside")
|
|
4297
|
+
declared = sorted(
|
|
4298
|
+
(
|
|
4299
|
+
{"id": str(row.get("id") or ""), "reason": str(row.get("reason") or "")}
|
|
4300
|
+
for row in declared_raw
|
|
4301
|
+
if isinstance(row, dict)
|
|
4302
|
+
),
|
|
4303
|
+
key=lambda row: row["id"],
|
|
4304
|
+
) if isinstance(declared_raw, list) else None
|
|
4305
|
+
if declared == expected:
|
|
4306
|
+
return
|
|
4307
|
+
if declared is None and not expected:
|
|
4308
|
+
return
|
|
4309
|
+
failures.append(
|
|
4310
|
+
"final-report data.json: planBodyVerification.setAside is "
|
|
4311
|
+
f"{declared!r} but the recorded verdicts set aside {expected!r}. The "
|
|
4312
|
+
"gate stopped blocking on those items — a frozen or unreached stage, or "
|
|
4313
|
+
"the plan's own record — and a run that passes without listing them "
|
|
4314
|
+
"leaves a deferred defect indistinguishable from one nobody raised. "
|
|
4315
|
+
"Re-record the round with `okstra plan-items complete-round`, which "
|
|
4316
|
+
"writes this register from the same computation."
|
|
4317
|
+
)
|
|
4318
|
+
|
|
4319
|
+
|
|
4016
4320
|
def _validate_plan_body_gate_recompute(
|
|
4017
4321
|
data: dict,
|
|
4018
4322
|
failures: list[str],
|
|
@@ -4072,6 +4376,29 @@ def _blocks_approval(row: dict) -> bool:
|
|
|
4072
4376
|
return status == "documented-deviation" and disposition.startswith("blocked C-")
|
|
4073
4377
|
|
|
4074
4378
|
|
|
4379
|
+
def _plan_item_clarification_ids(item: object) -> set[str]:
|
|
4380
|
+
"""이 plan item 이 가리키는 `C-NNN` 들.
|
|
4381
|
+
|
|
4382
|
+
계약 v3 에서 리포트 정본의 이 링크는 복수형 `clarificationRefs[]` 다 —
|
|
4383
|
+
`report_assembly` 가 활동 원장의 `clarificationRefs[]` + `planItemIds[]` 에서
|
|
4384
|
+
유도해 쓰고, v3.0 스키마의 `planItems[]` 는 `additionalProperties: false` 아래
|
|
4385
|
+
그 이름만 허용한다. 단수형 `clarificationId` 는 lead 가 쓰는 plan-body 상태
|
|
4386
|
+
파일과 v2 리포트에 남아 있으므로 읽을 때는 둘 다 받는다
|
|
4387
|
+
(`incremental_scope` 가 이미 그렇게 한다).
|
|
4388
|
+
"""
|
|
4389
|
+
if not isinstance(item, dict):
|
|
4390
|
+
return set()
|
|
4391
|
+
ids = {
|
|
4392
|
+
str(ref).strip()
|
|
4393
|
+
for ref in (item.get("clarificationRefs") or [])
|
|
4394
|
+
if str(ref).strip()
|
|
4395
|
+
}
|
|
4396
|
+
single = item.get("clarificationId")
|
|
4397
|
+
if isinstance(single, str) and single.strip():
|
|
4398
|
+
ids.add(single.strip())
|
|
4399
|
+
return ids
|
|
4400
|
+
|
|
4401
|
+
|
|
4075
4402
|
def _plan_body_promoted_clarification_ids(pbv: dict) -> set[str]:
|
|
4076
4403
|
"""`C-NNN` ids this run's own plan-body round created by promoting a
|
|
4077
4404
|
majority-disagree item (step 8). Used to break the Requirement Coverage
|
|
@@ -4079,11 +4406,9 @@ def _plan_body_promoted_clarification_ids(pbv: dict) -> set[str]:
|
|
|
4079
4406
|
the gate already counted, rather than contributing an independent one.
|
|
4080
4407
|
"""
|
|
4081
4408
|
return {
|
|
4082
|
-
|
|
4409
|
+
clarification_id
|
|
4083
4410
|
for item in (pbv.get("planItems") or [])
|
|
4084
|
-
|
|
4085
|
-
and isinstance(item.get("clarificationId"), str)
|
|
4086
|
-
and item["clarificationId"].strip()
|
|
4411
|
+
for clarification_id in _plan_item_clarification_ids(item)
|
|
4087
4412
|
}
|
|
4088
4413
|
|
|
4089
4414
|
|
|
@@ -4536,6 +4861,8 @@ def _validate_approval_dispositions(
|
|
|
4536
4861
|
row: dict,
|
|
4537
4862
|
context: dict,
|
|
4538
4863
|
failures: list[str],
|
|
4864
|
+
*,
|
|
4865
|
+
schema_version: str = "2.0",
|
|
4539
4866
|
) -> None:
|
|
4540
4867
|
row_id = str(row.get("id") or "<unknown>")
|
|
4541
4868
|
classification = str(context.get("classification") or "")
|
|
@@ -4546,7 +4873,11 @@ def _validate_approval_dispositions(
|
|
|
4546
4873
|
for index, option in enumerate(row.get("options") or [])
|
|
4547
4874
|
if isinstance(option, dict)
|
|
4548
4875
|
)
|
|
4549
|
-
resolution =
|
|
4876
|
+
resolution = (
|
|
4877
|
+
row.get("resolution")
|
|
4878
|
+
if schema_version == "3.0"
|
|
4879
|
+
else context.get("resolution")
|
|
4880
|
+
)
|
|
4550
4881
|
if isinstance(resolution, dict):
|
|
4551
4882
|
candidates.append(("resolution.disposition", resolution.get("disposition")))
|
|
4552
4883
|
for field, disposition in candidates:
|
|
@@ -4562,11 +4893,17 @@ def _validate_resolved_approval(
|
|
|
4562
4893
|
row: dict,
|
|
4563
4894
|
context: dict,
|
|
4564
4895
|
failures: list[str],
|
|
4896
|
+
*,
|
|
4897
|
+
schema_version: str = "2.0",
|
|
4565
4898
|
) -> None:
|
|
4566
4899
|
if row.get("status") != "resolved":
|
|
4567
4900
|
return
|
|
4568
4901
|
row_id = str(row.get("id") or "<unknown>")
|
|
4569
|
-
resolution =
|
|
4902
|
+
resolution = (
|
|
4903
|
+
row.get("resolution")
|
|
4904
|
+
if schema_version == "3.0"
|
|
4905
|
+
else context.get("resolution")
|
|
4906
|
+
)
|
|
4570
4907
|
if not isinstance(resolution, dict):
|
|
4571
4908
|
failures.append(
|
|
4572
4909
|
f"final-report data.json: resolved approval clarification `{row_id}` "
|
|
@@ -4882,6 +5219,9 @@ def _validate_approval_context(
|
|
|
4882
5219
|
) -> None:
|
|
4883
5220
|
if not _is_activity_contract_v1_planning(run_manifest):
|
|
4884
5221
|
return
|
|
5222
|
+
if data.get("schemaVersion") == "3.0":
|
|
5223
|
+
_validate_v3_approval_context(data, failures)
|
|
5224
|
+
return
|
|
4885
5225
|
ip = data.get("implementationPlanning") or {}
|
|
4886
5226
|
pbv = ip.get("planBodyVerification") or {}
|
|
4887
5227
|
plan_items_by_id = {
|
|
@@ -4991,6 +5331,81 @@ def _validate_approval_context(
|
|
|
4991
5331
|
)
|
|
4992
5332
|
|
|
4993
5333
|
|
|
5334
|
+
def _v3_expected_plan_backlinks(
|
|
5335
|
+
activities: Mapping[str, dict],
|
|
5336
|
+
) -> dict[str, set[str]]:
|
|
5337
|
+
expected: dict[str, set[str]] = {}
|
|
5338
|
+
for activity in activities.values():
|
|
5339
|
+
refs = {str(value) for value in activity.get("clarificationRefs") or []}
|
|
5340
|
+
for item_id in activity.get("planItemIds") or []:
|
|
5341
|
+
expected.setdefault(str(item_id), set()).update(refs)
|
|
5342
|
+
return expected
|
|
5343
|
+
|
|
5344
|
+
|
|
5345
|
+
def _validate_v3_plan_backlinks(
|
|
5346
|
+
data: dict, activities: Mapping[str, dict], failures: list[str],
|
|
5347
|
+
) -> None:
|
|
5348
|
+
expected = _v3_expected_plan_backlinks(activities)
|
|
5349
|
+
planning = data.get("implementationPlanning") or {}
|
|
5350
|
+
verification = planning.get("planBodyVerification") or {}
|
|
5351
|
+
for item in verification.get("planItems") or []:
|
|
5352
|
+
if not isinstance(item, dict):
|
|
5353
|
+
continue
|
|
5354
|
+
item_id = str(item.get("id") or "")
|
|
5355
|
+
actual = {str(value) for value in item.get("clarificationRefs") or []}
|
|
5356
|
+
if actual != expected.get(item_id, set()):
|
|
5357
|
+
failures.append(
|
|
5358
|
+
f"final-report data.json: plan item `{item_id}` clarificationRefs "
|
|
5359
|
+
"do not match activity-ledger backlinks."
|
|
5360
|
+
)
|
|
5361
|
+
|
|
5362
|
+
|
|
5363
|
+
def _validate_v3_resolution_links(
|
|
5364
|
+
row: dict, activities: Mapping[str, dict], failures: list[str],
|
|
5365
|
+
) -> None:
|
|
5366
|
+
resolution = row.get("resolution")
|
|
5367
|
+
if not isinstance(resolution, dict):
|
|
5368
|
+
return
|
|
5369
|
+
row_id = str(row.get("id") or "<unknown>")
|
|
5370
|
+
for activity_id in resolution.get("checkRefs") or []:
|
|
5371
|
+
activity = activities.get(activity_id)
|
|
5372
|
+
if activity is None:
|
|
5373
|
+
failures.append(
|
|
5374
|
+
f"final-report data.json: clarification `{row_id}` references "
|
|
5375
|
+
f"unknown activity `{activity_id}`."
|
|
5376
|
+
)
|
|
5377
|
+
elif row_id not in (activity.get("clarificationRefs") or []):
|
|
5378
|
+
failures.append(
|
|
5379
|
+
f"final-report data.json: activity `{activity_id}` does not "
|
|
5380
|
+
f"link back to clarification `{row_id}`."
|
|
5381
|
+
)
|
|
5382
|
+
|
|
5383
|
+
|
|
5384
|
+
def _validate_v3_approval_context(data: dict, failures: list[str]) -> None:
|
|
5385
|
+
"""검증 가능한 원장 참조만으로 v3 승인 역추적을 다시 계산한다."""
|
|
5386
|
+
activities = _approval_activities_by_id(data)
|
|
5387
|
+
_validate_v3_plan_backlinks(data, activities, failures)
|
|
5388
|
+
approved = (data.get("frontmatter") or {}).get("approved") is True
|
|
5389
|
+
for row in data.get("clarificationItems") or []:
|
|
5390
|
+
if not isinstance(row, dict) or row.get("blocks") != "approval":
|
|
5391
|
+
continue
|
|
5392
|
+
context = row.get("approvalContext")
|
|
5393
|
+
if not isinstance(context, dict):
|
|
5394
|
+
continue
|
|
5395
|
+
_validate_approval_dispositions(
|
|
5396
|
+
row, context, failures, schema_version="3.0"
|
|
5397
|
+
)
|
|
5398
|
+
_validate_resolved_approval(
|
|
5399
|
+
row, context, failures, schema_version="3.0"
|
|
5400
|
+
)
|
|
5401
|
+
_validate_v3_resolution_links(row, activities, failures)
|
|
5402
|
+
if approved and row.get("status") in {"open", "answered"}:
|
|
5403
|
+
failures.append(
|
|
5404
|
+
f"final-report data.json: approval is true while clarification "
|
|
5405
|
+
f"`{row.get('id')}` remains `{row.get('status')}`."
|
|
5406
|
+
)
|
|
5407
|
+
|
|
5408
|
+
|
|
4994
5409
|
def _validate_activity_contract_plan_limits(
|
|
4995
5410
|
data: dict,
|
|
4996
5411
|
run_manifest: dict,
|
|
@@ -5435,7 +5850,7 @@ def _validate_clarification_evidence_note(data: dict, failures: list[str]) -> No
|
|
|
5435
5850
|
)
|
|
5436
5851
|
|
|
5437
5852
|
|
|
5438
|
-
|
|
5853
|
+
_CLARIFICATION_OPTION_SCHEMA_VERSIONS = frozenset({"2.0", "3.0"})
|
|
5439
5854
|
# The four profiles that read `_clarification-recommendation.md`. Unlike the
|
|
5440
5855
|
# evidence-note gate above — which is called from inside the
|
|
5441
5856
|
# `implementation-planning` branch — this one is called phase-agnostically, so
|
|
@@ -5508,7 +5923,7 @@ def _validate_clarification_options(data: dict, failures: list[str]) -> None:
|
|
|
5508
5923
|
demanding one would fail every v1 `decision` row for a structure the format
|
|
5509
5924
|
has no place to hold.
|
|
5510
5925
|
"""
|
|
5511
|
-
if data.get("schemaVersion")
|
|
5926
|
+
if data.get("schemaVersion") not in _CLARIFICATION_OPTION_SCHEMA_VERSIONS:
|
|
5512
5927
|
return
|
|
5513
5928
|
task_type = (data.get("header") or {}).get("taskType")
|
|
5514
5929
|
if task_type not in _CLARIFICATION_OPTION_TASK_TYPES:
|
|
@@ -5517,7 +5932,10 @@ def _validate_clarification_options(data: dict, failures: list[str]) -> None:
|
|
|
5517
5932
|
if not isinstance(row, dict) or row.get("kind") != "decision":
|
|
5518
5933
|
continue
|
|
5519
5934
|
row_id = str(row.get("id") or "<unknown>")
|
|
5520
|
-
_validate_option_set(
|
|
5935
|
+
_validate_option_set(
|
|
5936
|
+
row.get("options"), row_id, failures,
|
|
5937
|
+
schema_version=str(data.get("schemaVersion") or ""),
|
|
5938
|
+
)
|
|
5521
5939
|
if _LEGACY_EXPECTED_FORM_RE.search(str(row.get("expectedForm") or "")):
|
|
5522
5940
|
failures.append(
|
|
5523
5941
|
f"final-report data.json: clarification `{row_id}` still encodes "
|
|
@@ -5529,7 +5947,7 @@ def _validate_clarification_options(data: dict, failures: list[str]) -> None:
|
|
|
5529
5947
|
|
|
5530
5948
|
|
|
5531
5949
|
def _validate_option_set(
|
|
5532
|
-
options: object, row_id: str, failures: list[str]
|
|
5950
|
+
options: object, row_id: str, failures: list[str], *, schema_version: str = "2.0"
|
|
5533
5951
|
) -> None:
|
|
5534
5952
|
"""Check one `decision` row's `options[]` for pickability and reach."""
|
|
5535
5953
|
if not isinstance(options, list) or len(options) < 2:
|
|
@@ -5550,7 +5968,9 @@ def _validate_option_set(
|
|
|
5550
5968
|
for index, option in enumerate(entries):
|
|
5551
5969
|
tokens = option.get("scopeImpact")
|
|
5552
5970
|
reach = (
|
|
5553
|
-
[
|
|
5971
|
+
[str(option.get("reach"))]
|
|
5972
|
+
if schema_version == "3.0" and option.get("reach") in _REACH_TOKENS
|
|
5973
|
+
else [token for token in tokens if token in _REACH_TOKENS]
|
|
5554
5974
|
if isinstance(tokens, list)
|
|
5555
5975
|
else []
|
|
5556
5976
|
)
|
|
@@ -5649,8 +6069,7 @@ def _has_clarification_backtrace(
|
|
|
5649
6069
|
resolver cannot disagree about what counts as a link.
|
|
5650
6070
|
"""
|
|
5651
6071
|
if isinstance(plan_items, list) and any(
|
|
5652
|
-
|
|
5653
|
-
for item in plan_items
|
|
6072
|
+
row_id in _plan_item_clarification_ids(item) for item in plan_items
|
|
5654
6073
|
):
|
|
5655
6074
|
return True
|
|
5656
6075
|
return isinstance(coverage, list) and any(
|
|
@@ -6134,6 +6553,7 @@ def _validate_plan_body_state_file(
|
|
|
6134
6553
|
data: dict,
|
|
6135
6554
|
report_path: Path,
|
|
6136
6555
|
failures: list[str],
|
|
6556
|
+
state_path: Path | None = None,
|
|
6137
6557
|
) -> None:
|
|
6138
6558
|
"""The per-round state file must exist once a round has run.
|
|
6139
6559
|
|
|
@@ -6157,20 +6577,34 @@ def _validate_plan_body_state_file(
|
|
|
6157
6577
|
round_count = pbv.get("roundCount")
|
|
6158
6578
|
if not isinstance(round_count, int) or round_count < 1:
|
|
6159
6579
|
return
|
|
6160
|
-
seq = _report_run_seq(report_path)
|
|
6161
|
-
if seq is None:
|
|
6162
|
-
return
|
|
6163
6580
|
state_dir = report_path.parent.parent / "state"
|
|
6164
|
-
|
|
6165
|
-
|
|
6166
|
-
|
|
6167
|
-
|
|
6168
|
-
|
|
6169
|
-
|
|
6170
|
-
|
|
6171
|
-
|
|
6172
|
-
|
|
6173
|
-
|
|
6581
|
+
if state_path is not None:
|
|
6582
|
+
# 호출자가 경로를 넘겼으면 그걸 본다. 리드는 launch 프롬프트의
|
|
6583
|
+
# `Run Paths` 에서 정본 경로를 받으므로, 여기서 이름을 다시 만들면
|
|
6584
|
+
# 그 정본과 어긋날 수 있다 — 실제로 그랬다.
|
|
6585
|
+
written = [state_path] if state_path.is_file() else []
|
|
6586
|
+
else:
|
|
6587
|
+
# 이름을 유도할 근거가 없다. run 은 seq 계열을 둘 갖고(`state` /
|
|
6588
|
+
# `reports`) 리포트 정본은 자기 run 의 state seq 를 담지 않으므로,
|
|
6589
|
+
# 리포트 seq 로 만든 이름은 추측이다. 이 검사가 묻는 것은 "덮어써진
|
|
6590
|
+
# 라운드의 기록이 남았는가" 이지 파일 이름이 아니므로, 이 run 의 상태
|
|
6591
|
+
# 디렉터리에 사이드카가 있는지만 본다. 이름의 정본은 `paths.py` 다.
|
|
6592
|
+
written = sorted(
|
|
6593
|
+
state_dir.glob("plan-body-verification-implementation-planning-*.json")
|
|
6594
|
+
)
|
|
6595
|
+
if not written:
|
|
6596
|
+
failures.append(
|
|
6597
|
+
f"plan-body verification ran ({round_count} round(s)) but no "
|
|
6598
|
+
f"`state/plan-body-verification-*.json` was written. It is the only "
|
|
6599
|
+
"record of superseded rounds — data.json keeps just the final "
|
|
6600
|
+
"verdicts, so without it a self-fixed run leaves no trace of what "
|
|
6601
|
+
'the earlier rounds found (plan-body-verification.md §"schema"). '
|
|
6602
|
+
"The path is rendered into the launch prompt's `Run Paths` block; "
|
|
6603
|
+
"write it there rather than deriving a name."
|
|
6604
|
+
)
|
|
6605
|
+
return
|
|
6606
|
+
# 여럿이면 가장 최신(seq 가 큰) 것이 이 run 의 것이다.
|
|
6607
|
+
expected = written[-1]
|
|
6174
6608
|
try:
|
|
6175
6609
|
state = json.loads(expected.read_text(encoding="utf-8"))
|
|
6176
6610
|
except (OSError, json.JSONDecodeError) as exc:
|
|
@@ -6697,6 +7131,53 @@ def _validate_round_recorded_verdicts(data: dict, failures: list[str]) -> None:
|
|
|
6697
7131
|
)
|
|
6698
7132
|
|
|
6699
7133
|
|
|
7134
|
+
def _validate_unresolved_tie_was_reverified(
|
|
7135
|
+
data: dict,
|
|
7136
|
+
failures: list[str],
|
|
7137
|
+
) -> None:
|
|
7138
|
+
"""A split panel is sent back once before the gate is declared.
|
|
7139
|
+
|
|
7140
|
+
The gate needs a strict majority to block, so a panel splitting evenly on a
|
|
7141
|
+
blocking kind reaches neither consensus nor `majority-disagree`. That state
|
|
7142
|
+
is classified `needs-reverify`, and `needs-reverify` folds into
|
|
7143
|
+
`passed-with-dissent` — which is correct for the shape it was built for (a
|
|
7144
|
+
peer that returned nothing) and wrong for this one: nothing failed here, two
|
|
7145
|
+
verifiers read the same plan and disagreed, and passing on that records a
|
|
7146
|
+
dissent nobody acted on.
|
|
7147
|
+
|
|
7148
|
+
So the round is not optional. Re-dispatch those items and record the votes
|
|
7149
|
+
with `--round 2`; a split that survives becomes `majority-disagree` and the
|
|
7150
|
+
user decides. This is satisfiable with the machinery the contract already
|
|
7151
|
+
defines — it is the same targeted re-verification step 7 runs after a
|
|
7152
|
+
self-fix, with the tied items added to that queue.
|
|
7153
|
+
"""
|
|
7154
|
+
ip = data.get("implementationPlanning")
|
|
7155
|
+
if not isinstance(ip, dict):
|
|
7156
|
+
return
|
|
7157
|
+
pbv = ip.get("planBodyVerification")
|
|
7158
|
+
if not isinstance(pbv, dict):
|
|
7159
|
+
return
|
|
7160
|
+
unsettled = sorted({
|
|
7161
|
+
str(item.get("id") or "").strip()
|
|
7162
|
+
for item in pbv.get("planItems") or []
|
|
7163
|
+
if isinstance(item, dict)
|
|
7164
|
+
and not item.get("carriedForwardFromSeq")
|
|
7165
|
+
and _is_unsettled_tie(item)
|
|
7166
|
+
})
|
|
7167
|
+
if not unsettled:
|
|
7168
|
+
return
|
|
7169
|
+
failures.append(
|
|
7170
|
+
f"final-report data.json: plan item(s) {unsettled} carry an even split "
|
|
7171
|
+
"on a blocking breakage kind and were never re-verified. A tie is not "
|
|
7172
|
+
"consensus: the gate's majority test is strict, so this split neither "
|
|
7173
|
+
"blocks nor resolves, and declaring the gate on it passes a dissent "
|
|
7174
|
+
"nobody settled. Re-dispatch those items in a plan-body round and "
|
|
7175
|
+
"record the votes with `okstra plan-items apply-verdicts --data "
|
|
7176
|
+
"<data.json> --verdicts <verdicts.json> --round 2`. A split that "
|
|
7177
|
+
"survives that round becomes `majority-disagree` and goes to the user."
|
|
7178
|
+
)
|
|
7179
|
+
|
|
7180
|
+
|
|
6700
7181
|
def _validate_verdict_rounds_outlive_self_fix(
|
|
6701
7182
|
data: dict,
|
|
6702
7183
|
failures: list[str],
|
|
@@ -6941,7 +7422,15 @@ def _validate_plan_body_verdict_provenance(
|
|
|
6941
7422
|
if not voters:
|
|
6942
7423
|
return
|
|
6943
7424
|
|
|
6944
|
-
task_type =
|
|
7425
|
+
task_type = (
|
|
7426
|
+
str((data.get("header") or {}).get("taskType") or "")
|
|
7427
|
+
or _report_task_type(report_path)
|
|
7428
|
+
)
|
|
7429
|
+
if not task_type:
|
|
7430
|
+
# Neither source names it, so the glob below would be built from an
|
|
7431
|
+
# empty segment and match nothing — reporting every verdict as unbacked
|
|
7432
|
+
# on the strength of a path this check could not construct.
|
|
7433
|
+
return
|
|
6945
7434
|
dispatched = _plan_verify_result_workers(report_path, task_type)
|
|
6946
7435
|
if dispatched is None:
|
|
6947
7436
|
return
|
|
@@ -7609,15 +8098,20 @@ def _validate_plan_body_clarification_matching(
|
|
|
7609
8098
|
if _is_dissent_downgraded(item, pbv, accepted):
|
|
7610
8099
|
continue
|
|
7611
8100
|
item_id = item.get("id") or "<unknown>"
|
|
7612
|
-
|
|
7613
|
-
if not
|
|
8101
|
+
cids = _plan_item_clarification_ids(item)
|
|
8102
|
+
if not cids:
|
|
7614
8103
|
failures.append(
|
|
7615
8104
|
f"final-report data.json: plan item `{item_id}` is majority-disagree "
|
|
7616
|
-
"but carries no `
|
|
8105
|
+
"but carries no `clarificationRefs`. A blocking disagreement MUST "
|
|
7617
8106
|
"surface as a `## 1. Clarification Items` row (blocks=approval) so "
|
|
7618
|
-
"the user sees the blocker (implementation-planning.md self-review
|
|
8107
|
+
"the user sees the blocker (implementation-planning.md self-review "
|
|
8108
|
+
"step 12). Report assembly derives this link from the activity "
|
|
8109
|
+
"ledger's `clarificationRefs[]` + `planItemIds[]`, so record the "
|
|
8110
|
+
"decision through `okstra approval-decision` rather than editing "
|
|
8111
|
+
"the report."
|
|
7619
8112
|
)
|
|
7620
|
-
|
|
8113
|
+
continue
|
|
8114
|
+
for cid in sorted(cids - approval_ids):
|
|
7621
8115
|
reason = (
|
|
7622
8116
|
"references a non-existent §1 row"
|
|
7623
8117
|
if cid not in all_ids
|
|
@@ -7625,8 +8119,9 @@ def _validate_plan_body_clarification_matching(
|
|
|
7625
8119
|
)
|
|
7626
8120
|
failures.append(
|
|
7627
8121
|
f"final-report data.json: plan item `{item_id}` (majority-disagree) "
|
|
7628
|
-
f"has
|
|
7629
|
-
"item MUST
|
|
8122
|
+
f"has clarificationRefs entry `{cid}` which {reason}. Every "
|
|
8123
|
+
"majority-disagree item MUST reach a `blocks: approval` "
|
|
8124
|
+
"Clarification row."
|
|
7630
8125
|
)
|
|
7631
8126
|
|
|
7632
8127
|
|
|
@@ -7793,7 +8288,9 @@ def validate_plan_body_section(
|
|
|
7793
8288
|
_validate_aborted_gate_has_clarification(data, failures)
|
|
7794
8289
|
_validate_round_recorded_verdicts(data, failures)
|
|
7795
8290
|
_validate_verdicts_match_current_subjects(data, failures)
|
|
8291
|
+
_validate_set_aside_register(data, failures, accepted_item_ids)
|
|
7796
8292
|
_validate_verdict_rounds_outlive_self_fix(data, failures)
|
|
8293
|
+
_validate_unresolved_tie_was_reverified(data, failures)
|
|
7797
8294
|
_validate_plan_item_extraction_completeness(data, failures)
|
|
7798
8295
|
_validate_plan_item_subject_substance(data, failures)
|
|
7799
8296
|
_validate_plan_body_clarification_matching(data, failures, accepted_item_ids)
|
|
@@ -7808,16 +8305,16 @@ def _gate_summary_item(
|
|
|
7808
8305
|
accepted_item_ids: set[str],
|
|
7809
8306
|
) -> dict:
|
|
7810
8307
|
"""One `gate.items[]` row: the gate class plus its state-file counterpart."""
|
|
7811
|
-
classification = (
|
|
7812
|
-
"has-dissent"
|
|
7813
|
-
if _is_dissent_downgraded(item, pbv, accepted_item_ids)
|
|
7814
|
-
else _classify_plan_item_gate(item)
|
|
7815
|
-
)
|
|
8308
|
+
classification = _plan_item_gate_class(item, pbv, accepted_item_ids)
|
|
7816
8309
|
return {
|
|
7817
8310
|
"id": item.get("id"),
|
|
7818
8311
|
"classification": classification,
|
|
7819
8312
|
"stateClassification": _state_classification(item, classification),
|
|
7820
8313
|
"correctnessCritical": _is_correctness_critical(item),
|
|
8314
|
+
# 왜 안 막는지가 기록에 남아야 한다. 이 값이 없으면 범위 밖 강등과
|
|
8315
|
+
# 실제 합의가 산출물에서 같은 모양으로 읽힌다.
|
|
8316
|
+
"stageScope": _stage_scope_bucket(item, pbv),
|
|
8317
|
+
"block": str(item.get("block") or "execution"),
|
|
7821
8318
|
}
|
|
7822
8319
|
|
|
7823
8320
|
|
|
@@ -7858,6 +8355,7 @@ def plan_body_gate_summary(data: dict) -> dict | None:
|
|
|
7858
8355
|
_gate_blocking_causes(pbv, coverage_blockers, accepted_item_ids)
|
|
7859
8356
|
),
|
|
7860
8357
|
"coverageBlockers": coverage_blockers,
|
|
8358
|
+
"setAside": _set_aside_register(pbv, accepted_item_ids),
|
|
7861
8359
|
"blockingItems": [
|
|
7862
8360
|
item["id"] for item in items if item["classification"] == "majority-disagree"
|
|
7863
8361
|
],
|
|
@@ -9233,7 +9731,9 @@ SECTION_PLAN_BODY = "plan-body"
|
|
|
9233
9731
|
_FULL_ONLY_REQUIRED_FLAGS = ("team_state", "run_manifest", "task_manifest")
|
|
9234
9732
|
|
|
9235
9733
|
|
|
9236
|
-
def run_plan_body_section(
|
|
9734
|
+
def run_plan_body_section(
|
|
9735
|
+
report_path: Path, state_path: Path | None = None
|
|
9736
|
+
) -> int:
|
|
9237
9737
|
"""`--section plan-body` — the §5.5.9 checks a self-fix round can run on
|
|
9238
9738
|
its own, plus the recomputed gate the lead records for that round.
|
|
9239
9739
|
|
|
@@ -9260,7 +9760,45 @@ def run_plan_body_section(report_path: Path) -> int:
|
|
|
9260
9760
|
return 2
|
|
9261
9761
|
failures = _data_schema_failures(data)
|
|
9262
9762
|
warnings = validate_plan_body_section(data, report_path, failures)
|
|
9263
|
-
_validate_plan_body_state_file(data, report_path, failures)
|
|
9763
|
+
_validate_plan_body_state_file(data, report_path, failures, state_path)
|
|
9764
|
+
payload = {
|
|
9765
|
+
"ok": not failures,
|
|
9766
|
+
"section": SECTION_PLAN_BODY,
|
|
9767
|
+
"gate": plan_body_gate_summary(data),
|
|
9768
|
+
"failures": failures,
|
|
9769
|
+
"warnings": warnings,
|
|
9770
|
+
}
|
|
9771
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
9772
|
+
return 0 if not failures else 2
|
|
9773
|
+
|
|
9774
|
+
|
|
9775
|
+
def run_plan_body_inputs(narrative_path: Path, state_path: Path) -> int:
|
|
9776
|
+
"""게시 전 서사와 수렴 소유 상태에서 같은 계획 게이트를 채점한다."""
|
|
9777
|
+
from okstra_ctl.report_narrative import parse_narrative
|
|
9778
|
+
from okstra_ctl.final_report_schema import load_schema_version
|
|
9779
|
+
|
|
9780
|
+
try:
|
|
9781
|
+
narrative = parse_narrative(
|
|
9782
|
+
narrative_path.read_text(encoding="utf-8"),
|
|
9783
|
+
load_schema_version("3.0"),
|
|
9784
|
+
)
|
|
9785
|
+
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
9786
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
9787
|
+
print(f"validate-run: plan-body inputs are invalid ({exc})", file=sys.stderr)
|
|
9788
|
+
return 2
|
|
9789
|
+
state_owner = state.get("owner") if isinstance(state, dict) else None
|
|
9790
|
+
verification = state.get("planBodyVerification") if isinstance(state, dict) else None
|
|
9791
|
+
planning = narrative.get("implementationPlanning")
|
|
9792
|
+
if state_owner != "convergence" or not isinstance(verification, dict):
|
|
9793
|
+
print("validate-run: plan-body state must be convergence-owned", file=sys.stderr)
|
|
9794
|
+
return 2
|
|
9795
|
+
if not isinstance(planning, dict):
|
|
9796
|
+
print("validate-run: narrative has no implementationPlanning", file=sys.stderr)
|
|
9797
|
+
return 2
|
|
9798
|
+
data = {**narrative, "schemaVersion": "3.0"}
|
|
9799
|
+
data["implementationPlanning"] = {**planning, "planBodyVerification": verification}
|
|
9800
|
+
failures: list[str] = []
|
|
9801
|
+
warnings = validate_plan_body_section(data, _report_path_for_state(state_path), failures)
|
|
9264
9802
|
payload = {
|
|
9265
9803
|
"ok": not failures,
|
|
9266
9804
|
"section": SECTION_PLAN_BODY,
|
|
@@ -9272,6 +9810,14 @@ def run_plan_body_section(report_path: Path) -> int:
|
|
|
9272
9810
|
return 0 if not failures else 2
|
|
9273
9811
|
|
|
9274
9812
|
|
|
9813
|
+
def _report_path_for_state(state_path: Path) -> Path:
|
|
9814
|
+
match = re.search(r"-(\d{3})\.json$", state_path.name)
|
|
9815
|
+
seq = match.group(1) if match else "001"
|
|
9816
|
+
return state_path.parent.parent / "reports" / (
|
|
9817
|
+
f"final-report-implementation-planning-{seq}.data.json"
|
|
9818
|
+
)
|
|
9819
|
+
|
|
9820
|
+
|
|
9275
9821
|
def _data_schema_failures(data: dict) -> list[str]:
|
|
9276
9822
|
"""Schema errors in the round's data.json, as failures.
|
|
9277
9823
|
|
|
@@ -9315,9 +9861,11 @@ def main() -> int:
|
|
|
9315
9861
|
)
|
|
9316
9862
|
parser.add_argument(
|
|
9317
9863
|
"--report",
|
|
9318
|
-
required=
|
|
9864
|
+
required=False,
|
|
9319
9865
|
help="Project-relative or absolute path to the report record (.data.json). Schema-v1 reports still use the Markdown file.",
|
|
9320
9866
|
)
|
|
9867
|
+
parser.add_argument("--narrative", required=False)
|
|
9868
|
+
parser.add_argument("--state", required=False)
|
|
9321
9869
|
parser.add_argument(
|
|
9322
9870
|
"--run-manifest",
|
|
9323
9871
|
required=False,
|
|
@@ -9343,7 +9891,18 @@ def main() -> int:
|
|
|
9343
9891
|
args = parser.parse_args()
|
|
9344
9892
|
|
|
9345
9893
|
if args.section == SECTION_PLAN_BODY:
|
|
9346
|
-
|
|
9894
|
+
if args.narrative and args.state and not args.report:
|
|
9895
|
+
return run_plan_body_inputs(
|
|
9896
|
+
Path(args.narrative).resolve(), Path(args.state).resolve()
|
|
9897
|
+
)
|
|
9898
|
+
if args.report and not args.narrative and not args.state:
|
|
9899
|
+
return run_plan_body_section(
|
|
9900
|
+
Path(args.report).resolve(),
|
|
9901
|
+
Path(args.state).resolve() if args.state else None,
|
|
9902
|
+
)
|
|
9903
|
+
parser.error(
|
|
9904
|
+
"--section plan-body requires either --report or both --narrative and --state"
|
|
9905
|
+
)
|
|
9347
9906
|
|
|
9348
9907
|
missing = [
|
|
9349
9908
|
f"--{flag.replace('_', '-')}"
|
|
@@ -9550,7 +10109,12 @@ def main() -> int:
|
|
|
9550
10109
|
validation_data, project_root, failures
|
|
9551
10110
|
)
|
|
9552
10111
|
if task_type == "implementation-planning":
|
|
9553
|
-
_validate_plan_body_state_file(
|
|
10112
|
+
_validate_plan_body_state_file(
|
|
10113
|
+
validation_data,
|
|
10114
|
+
report_path,
|
|
10115
|
+
failures,
|
|
10116
|
+
Path(args.state).resolve() if args.state else None,
|
|
10117
|
+
)
|
|
9554
10118
|
if task_type == "final-verification":
|
|
9555
10119
|
_validate_verification_target_match(
|
|
9556
10120
|
validation_data,
|