okstra 0.179.2 → 0.180.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 +1 -1
- package/dist/cli-registry.mjs +14 -0
- 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/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/docs/architecture/storage-model.md +1 -1
- package/docs/architecture.md +10 -10
- package/docs/cli.md +11 -8
- package/docs/project-structure-overview.md +15 -6
- package/docs/task-process/implementation-planning.md +2 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +15 -164
- package/runtime/prompts/launch.template.md +6 -5
- package/runtime/prompts/lead/adapters/cmux.md +1 -1
- package/runtime/prompts/lead/convergence.md +2 -2
- package/runtime/prompts/lead/okstra-lead-contract.md +19 -18
- package/runtime/prompts/lead/plan-body-verification.md +39 -18
- package/runtime/prompts/lead/report-writer.md +64 -423
- package/runtime/prompts/lead/team-contract.md +1 -1
- package/runtime/prompts/profiles/_clarification-recommendation.md +5 -4
- package/runtime/prompts/profiles/_common-contract.md +3 -3
- package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
- 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/implementation-planning.md +13 -11
- package/runtime/prompts/profiles/improvement-discovery.md +1 -1
- package/runtime/prompts/profiles/project-analysis.md +1 -1
- package/runtime/prompts/profiles/requirements-discovery.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +2 -1
- package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +1 -1
- package/runtime/python/okstra_ctl/agent_activity.py +23 -3
- package/runtime/python/okstra_ctl/agent_prompt_cli.py +6 -6
- package/runtime/python/okstra_ctl/analysis_packet.py +43 -2
- package/runtime/python/okstra_ctl/approval_decisions.py +327 -0
- package/runtime/python/okstra_ctl/design_snapshot.py +134 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +62 -4
- package/runtime/python/okstra_ctl/dispatch_state.py +29 -4
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +6 -2
- package/runtime/python/okstra_ctl/final_report_schema.py +24 -15
- package/runtime/python/okstra_ctl/incremental_carry.py +128 -16
- package/runtime/python/okstra_ctl/incremental_scope.py +4 -1
- package/runtime/python/okstra_ctl/path_hints.py +12 -0
- package/runtime/python/okstra_ctl/paths.py +12 -0
- package/runtime/python/okstra_ctl/plan_items_cli.py +113 -16
- package/runtime/python/okstra_ctl/ports/worker_dispatch.py +2 -1
- package/runtime/python/okstra_ctl/render.py +48 -1
- package/runtime/python/okstra_ctl/render_final_report.py +7 -6
- package/runtime/python/okstra_ctl/report_assembly.py +354 -0
- package/runtime/python/okstra_ctl/report_contract.py +2 -1
- package/runtime/python/okstra_ctl/report_finalize.py +60 -22
- package/runtime/python/okstra_ctl/report_inputs.py +72 -0
- package/runtime/python/okstra_ctl/report_markdown.py +69 -8
- package/runtime/python/okstra_ctl/report_narrative.py +319 -0
- package/runtime/python/okstra_ctl/report_projections.py +265 -0
- package/runtime/python/okstra_ctl/run.py +25 -9
- package/runtime/python/okstra_ctl/schema_excerpt.py +11 -6
- package/runtime/python/okstra_ctl/stage_fix_carry.py +4 -4
- package/runtime/python/okstra_ctl/stage_ledger.py +132 -18
- package/runtime/python/okstra_ctl/stage_map.py +70 -22
- package/runtime/python/okstra_ctl/team.py +1 -1
- package/runtime/python/okstra_ctl/worker_dispatch.py +5 -2
- package/runtime/python/okstra_ctl/worker_prompt_body.py +35 -0
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +31 -3
- package/runtime/schemas/final-report-v3.0.schema.json +10210 -0
- package/runtime/schemas/report-narrative-v3.0.schema.json +30 -0
- package/runtime/templates/report-writer-prompt-preamble.md +15 -21
- package/runtime/templates/reports/html/macros/forms.html +6 -4
- package/runtime/validators/validate-run.py +258 -10
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://okstra.dev/schemas/report-narrative-v3.0.json",
|
|
4
|
+
"title": "OKSTRA Report Writer Narrative Input (v3.0)",
|
|
5
|
+
"description": "Parsed report-writer Markdown. Only report-writer-owned judgments, plans, summaries, and user explanations are accepted.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"humanSummary": {"type": "object"},
|
|
10
|
+
"verdictCard": {"type": "object"},
|
|
11
|
+
"rationale": {"type": "object"},
|
|
12
|
+
"summary": {"type": "array"},
|
|
13
|
+
"ticketCoverage": {"type": "object"},
|
|
14
|
+
"finalVerdict": {"type": "object"},
|
|
15
|
+
"requirementsDiscovery": {"type": "object"},
|
|
16
|
+
"improvementDiscovery": {"type": "object"},
|
|
17
|
+
"errorAnalysis": {"type": "object"},
|
|
18
|
+
"analysisCommon": {"type": "object"},
|
|
19
|
+
"projectAnalysis": {"type": "object"},
|
|
20
|
+
"featureAnalysis": {"type": "object"},
|
|
21
|
+
"changeImpactAnalysis": {"type": "object"},
|
|
22
|
+
"implementationOptionSelection": {"type": "object"},
|
|
23
|
+
"implementationPlanning": {"type": "object"},
|
|
24
|
+
"releaseHandoff": {"type": "object"},
|
|
25
|
+
"implementation": {"type": "object"},
|
|
26
|
+
"finalVerification": {"type": "object"},
|
|
27
|
+
"recommendedNextSteps": {"type": "array"},
|
|
28
|
+
"followUpTasks": {"type": "array"}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -1,37 +1,31 @@
|
|
|
1
1
|
# Report Writer Prompt Preamble (canonical)
|
|
2
2
|
|
|
3
|
-
This file is the audience-specific contract for `report-writer`. Read it
|
|
3
|
+
This file is the audience-specific contract for `report-writer`. Read it and the shared Worker Error Contract end-to-end.
|
|
4
4
|
|
|
5
|
-
##
|
|
6
|
-
|
|
7
|
-
Read every input enumerated by the Phase 6 dispatch end-to-end: task/analysis inputs, worker results, convergence state, the instruction-set-local `final-report-template.md`, and the task-type excerpt `final-report-schema.json`. Do not pull the full repository template or schema when the scoped instruction-set copies are provided.
|
|
5
|
+
## Output ownership
|
|
8
6
|
|
|
9
|
-
Write
|
|
7
|
+
Write only the report narrative Markdown at `**Result Path:**`, the pointer record at `**Worker Result Path:**`, and the reading audit at `**Audit sidecar path:**`.
|
|
10
8
|
|
|
11
|
-
|
|
9
|
+
The report narrative Markdown contains the task judgment, plan, summaries, and user explanation. It must not write or patch `final-report-*.data.json`, the approval decision ledger, activity ledger, team state, convergence state, or design-preparation input.
|
|
12
10
|
|
|
13
|
-
|
|
11
|
+
The following fields belong to other owners and must not appear in the narrative: `designPreparation`, `designSurfaceCoverage`, `executionStatus`, `executionRoles`, `tokenUsage`, `crossVerification`, `approvalContext`, `clarificationItems`, `agentActivity`, and `planBodyVerification`.
|
|
14
12
|
|
|
15
|
-
-
|
|
16
|
-
- Follow the task-type schema excerpt and Phase 6 report-writer contract. Do not perform independent analysis, edit source code, or load implementation coding-preflight resources.
|
|
17
|
-
- Do not invoke `okstra render-final-report`. The full reading copy is rendered on demand.
|
|
18
|
-
- Preserve source item IDs, convergence classifications, round history, and unresolved dissent; do not recompute them from intuition.
|
|
19
|
-
- Every `clarificationItems[]` row you leave `status: open` with `blocks: approval` carries `origin` and `userConfirmation`. `origin` is `worker-finding` only when an analyser or verifier reached it on its own evidence — when the lead's dispatch prompt told you to raise it, it is `lead-directed`, whatever the workers then agreed. `userConfirmation` is what the lead reports having done about it (`asked-and-answered` / `asked-awaiting` / `deferred-no-interactive-session`); if the dispatch does not say, write `asked-awaiting` and name the gap in your return message rather than guessing. An open approval blocker stops the whole task, so a row that misreports where it came from costs a re-run. **Enforced:** `validators/validate-run.py` `_validate_open_approval_blocker_provenance`.
|
|
13
|
+
Do not pre-fill a future round, future gate, usage value, activity identifier, or resolution. Report assembly derives those values after their owner input exists.
|
|
20
14
|
|
|
21
|
-
##
|
|
15
|
+
## Required reading
|
|
22
16
|
|
|
23
|
-
|
|
17
|
+
Read every path listed under `## Inputs` end-to-end. Full context is available for synthesis, but read access does not transfer write ownership. Preserve supplied technical meaning and do not invent missing evidence.
|
|
24
18
|
|
|
25
|
-
|
|
19
|
+
Write the audit sidecar before synthesis with one `- PROGRESS: <stage> <ISO-8601-UTC>` line and the required reading confirmation. Valid stages are `started`, `required-reading-complete`, `synthesis-start`, `narrative-write-start`, and `write-result-start`.
|
|
26
20
|
|
|
27
|
-
##
|
|
21
|
+
## Narrative format
|
|
28
22
|
|
|
29
|
-
|
|
23
|
+
Start with `# OKSTRA Report Narrative`. Encode fields as nested Markdown list entries using `- **Humanised Field Name**`; use `- Item N` for array entries and `> value` for scalar or multiline values. Follow the task-specific schema order and write the prose in English. JSON, YAML, JSON Pointer, and fenced JSON are not narrative syntax.
|
|
30
24
|
|
|
31
|
-
##
|
|
25
|
+
## Pointer record
|
|
32
26
|
|
|
33
|
-
|
|
27
|
+
The pointer record names the project-relative narrative path and audit sidecar path. It does not contain the narrative, worker result corpus, or any machine-owned ledger.
|
|
34
28
|
|
|
35
|
-
|
|
29
|
+
## Failure handling
|
|
36
30
|
|
|
37
|
-
|
|
31
|
+
If a required narrative fact is missing or contradictory, record the report-writer error and stop. If report assembly later reports another owner, do not edit that owner's input; return the failure to the named owner.
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
{% for row in items %}
|
|
64
64
|
{% set is_closed = row.status in ['resolved', 'obsolete'] %}
|
|
65
65
|
{% set approval_context = row.approvalContext | default(None) %}
|
|
66
|
+
{% set resolution = row.resolution | default(None) %}
|
|
66
67
|
{% set options = row.options | default([]) %}
|
|
67
68
|
<article class="clarification-item" id="id-{{ row.id }}" data-response-id="{{ row.id }}" data-kind="{{ row.kind }}" data-status="{{ row.status }}">
|
|
68
69
|
<p class="eyebrow">{{ row.id }} · {{ row.kind }}</p>
|
|
@@ -72,9 +73,10 @@
|
|
|
72
73
|
<div class="approval-context" data-approval-classification="{{ approval_context.classification }}">
|
|
73
74
|
<p class="eyebrow">{{ approval_context.classification }}</p>
|
|
74
75
|
<p><strong>{{ t('tasks.implementation-planning.unblock-condition') }}</strong> {{ approval_context.unblockCondition | inline_code }}</p>
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
{% set check_refs = resolution.checkRefs if resolution else approval_context.activityIds | default([]) %}
|
|
77
|
+
{% if check_refs %}<p><strong>{{ t('tasks.implementation-planning.agent-evidence') }}</strong>
|
|
78
|
+
{% for activity_id in check_refs %}<a href="#id-{{ activity_id }}">{{ activity_id }}</a>{% if not loop.last %}, {% endif %}{% endfor %}
|
|
79
|
+
</p>{% endif %}
|
|
78
80
|
</div>
|
|
79
81
|
{% endif %}
|
|
80
82
|
{% if options %}
|
|
@@ -85,7 +87,7 @@
|
|
|
85
87
|
{% if option.disposition | default(None) %}<p class="clarification-option-disposition"><code>{{ option.disposition }}</code></p>{% endif %}
|
|
86
88
|
<p class="clarification-option-rationale">{{ option.rationale }}</p>
|
|
87
89
|
<dl class="clarification-option-impact">
|
|
88
|
-
<dt>{{ t('macros.forms.scope-impact') }}</dt><dd>{{ option.scopeImpact | join(', ') }}</dd>
|
|
90
|
+
<dt>{{ t('macros.forms.scope-impact') }}</dt><dd>{% if option.reach | default(None) %}{{ option.reach }}{% if option.scopeEffects | default([]) %}, {{ option.scopeEffects | join(', ') }}{% endif %}{% else %}{{ option.scopeImpact | join(', ') }}{% endif %}</dd>
|
|
89
91
|
<dt>{{ t('macros.forms.added-work') }}</dt><dd>{{ option.addedWork }}</dd>
|
|
90
92
|
<dt>{{ t('macros.forms.direction-change') }}</dt><dd>{{ option.directionChange }}</dd>
|
|
91
93
|
</dl>
|
|
@@ -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
|
|
|
@@ -3848,9 +3848,58 @@ def _classify_plan_item_gate(item: dict) -> str:
|
|
|
3848
3848
|
# made the gate stricter than a healthy roster would.
|
|
3849
3849
|
if len(non_error) >= 2 and len(blocking_disagree) > len(agree):
|
|
3850
3850
|
return "majority-disagree"
|
|
3851
|
+
# A tie is not consensus, and until now it read as one. The majority test is
|
|
3852
|
+
# strict, so an even panel splitting 1-AGREE / 1-DISAGREE on a blocking kind
|
|
3853
|
+
# fell through to `has-dissent` and the gate passed — the dissent recorded
|
|
3854
|
+
# and never acted on. An even panel is not only the two-analyser roster: one
|
|
3855
|
+
# UNVERIFIABLE or one lost dispatch turns any roster even for that item.
|
|
3856
|
+
# Send the split back for a round; if it survives a round that judged the
|
|
3857
|
+
# rewritten text, nothing further is going to settle it and the user decides.
|
|
3858
|
+
# `_validate_unresolved_tie_was_reverified` is what makes the first branch
|
|
3859
|
+
# more than a label — `needs-reverify` folds into `passed-with-dissent`.
|
|
3860
|
+
if len(non_error) >= 2 and len(blocking_disagree) == len(agree):
|
|
3861
|
+
if _max_verdict_round(item) >= _TIE_SETTLED_ROUND:
|
|
3862
|
+
return "majority-disagree"
|
|
3863
|
+
return "needs-reverify"
|
|
3851
3864
|
return "has-dissent"
|
|
3852
3865
|
|
|
3853
3866
|
|
|
3867
|
+
# 동수를 한 번 재검증한 뒤에도 갈리면 그때는 사용자가 판단한다. 초기 검증이
|
|
3868
|
+
# 라운드 1이고 자가수정 뒤의 표적 재검증이 라운드 2이므로, 라운드 2 이상의
|
|
3869
|
+
# 판정이 붙은 동수는 이미 한 번 돌아온 것이다.
|
|
3870
|
+
_TIE_SETTLED_ROUND = 2
|
|
3871
|
+
|
|
3872
|
+
|
|
3873
|
+
def _max_verdict_round(item: dict) -> int:
|
|
3874
|
+
"""이 항목의 판정이 붙은 가장 늦은 라운드. 스탬프가 없으면 1.
|
|
3875
|
+
|
|
3876
|
+
`apply-verdicts --round <N>` 이 각 행을 찍는다. 스탬프가 없는 행은 자가수정이
|
|
3877
|
+
한 번도 없었던 run 에서만 나오고, 그때는 라운드가 하나뿐이다.
|
|
3878
|
+
"""
|
|
3879
|
+
rounds = [
|
|
3880
|
+
verdict["round"]
|
|
3881
|
+
for verdict in (item.get("verdicts") or [])
|
|
3882
|
+
if isinstance(verdict, dict)
|
|
3883
|
+
and isinstance(verdict.get("round"), int)
|
|
3884
|
+
and not isinstance(verdict.get("round"), bool)
|
|
3885
|
+
]
|
|
3886
|
+
return max(rounds, default=1)
|
|
3887
|
+
|
|
3888
|
+
|
|
3889
|
+
def _is_unsettled_tie(item: dict) -> bool:
|
|
3890
|
+
"""아직 재검증되지 않은 동수 항목."""
|
|
3891
|
+
return (
|
|
3892
|
+
_classify_plan_item_gate(item) == "needs-reverify"
|
|
3893
|
+
and _max_verdict_round(item) < _TIE_SETTLED_ROUND
|
|
3894
|
+
and len([
|
|
3895
|
+
verdict for verdict in (item.get("verdicts") or [])
|
|
3896
|
+
if isinstance(verdict, dict)
|
|
3897
|
+
and str(verdict.get("verdict") or "").strip().upper()
|
|
3898
|
+
not in ("", "VERIFICATION-ERROR")
|
|
3899
|
+
]) >= 2
|
|
3900
|
+
)
|
|
3901
|
+
|
|
3902
|
+
|
|
3854
3903
|
def _disagree_breakage_kinds(item: dict) -> set[str]:
|
|
3855
3904
|
return {
|
|
3856
3905
|
str(v.get("breakageKind") or "").strip().lower()
|
|
@@ -4536,6 +4585,8 @@ def _validate_approval_dispositions(
|
|
|
4536
4585
|
row: dict,
|
|
4537
4586
|
context: dict,
|
|
4538
4587
|
failures: list[str],
|
|
4588
|
+
*,
|
|
4589
|
+
schema_version: str = "2.0",
|
|
4539
4590
|
) -> None:
|
|
4540
4591
|
row_id = str(row.get("id") or "<unknown>")
|
|
4541
4592
|
classification = str(context.get("classification") or "")
|
|
@@ -4546,7 +4597,11 @@ def _validate_approval_dispositions(
|
|
|
4546
4597
|
for index, option in enumerate(row.get("options") or [])
|
|
4547
4598
|
if isinstance(option, dict)
|
|
4548
4599
|
)
|
|
4549
|
-
resolution =
|
|
4600
|
+
resolution = (
|
|
4601
|
+
row.get("resolution")
|
|
4602
|
+
if schema_version == "3.0"
|
|
4603
|
+
else context.get("resolution")
|
|
4604
|
+
)
|
|
4550
4605
|
if isinstance(resolution, dict):
|
|
4551
4606
|
candidates.append(("resolution.disposition", resolution.get("disposition")))
|
|
4552
4607
|
for field, disposition in candidates:
|
|
@@ -4562,11 +4617,17 @@ def _validate_resolved_approval(
|
|
|
4562
4617
|
row: dict,
|
|
4563
4618
|
context: dict,
|
|
4564
4619
|
failures: list[str],
|
|
4620
|
+
*,
|
|
4621
|
+
schema_version: str = "2.0",
|
|
4565
4622
|
) -> None:
|
|
4566
4623
|
if row.get("status") != "resolved":
|
|
4567
4624
|
return
|
|
4568
4625
|
row_id = str(row.get("id") or "<unknown>")
|
|
4569
|
-
resolution =
|
|
4626
|
+
resolution = (
|
|
4627
|
+
row.get("resolution")
|
|
4628
|
+
if schema_version == "3.0"
|
|
4629
|
+
else context.get("resolution")
|
|
4630
|
+
)
|
|
4570
4631
|
if not isinstance(resolution, dict):
|
|
4571
4632
|
failures.append(
|
|
4572
4633
|
f"final-report data.json: resolved approval clarification `{row_id}` "
|
|
@@ -4882,6 +4943,9 @@ def _validate_approval_context(
|
|
|
4882
4943
|
) -> None:
|
|
4883
4944
|
if not _is_activity_contract_v1_planning(run_manifest):
|
|
4884
4945
|
return
|
|
4946
|
+
if data.get("schemaVersion") == "3.0":
|
|
4947
|
+
_validate_v3_approval_context(data, failures)
|
|
4948
|
+
return
|
|
4885
4949
|
ip = data.get("implementationPlanning") or {}
|
|
4886
4950
|
pbv = ip.get("planBodyVerification") or {}
|
|
4887
4951
|
plan_items_by_id = {
|
|
@@ -4991,6 +5055,81 @@ def _validate_approval_context(
|
|
|
4991
5055
|
)
|
|
4992
5056
|
|
|
4993
5057
|
|
|
5058
|
+
def _v3_expected_plan_backlinks(
|
|
5059
|
+
activities: Mapping[str, dict],
|
|
5060
|
+
) -> dict[str, set[str]]:
|
|
5061
|
+
expected: dict[str, set[str]] = {}
|
|
5062
|
+
for activity in activities.values():
|
|
5063
|
+
refs = {str(value) for value in activity.get("clarificationRefs") or []}
|
|
5064
|
+
for item_id in activity.get("planItemIds") or []:
|
|
5065
|
+
expected.setdefault(str(item_id), set()).update(refs)
|
|
5066
|
+
return expected
|
|
5067
|
+
|
|
5068
|
+
|
|
5069
|
+
def _validate_v3_plan_backlinks(
|
|
5070
|
+
data: dict, activities: Mapping[str, dict], failures: list[str],
|
|
5071
|
+
) -> None:
|
|
5072
|
+
expected = _v3_expected_plan_backlinks(activities)
|
|
5073
|
+
planning = data.get("implementationPlanning") or {}
|
|
5074
|
+
verification = planning.get("planBodyVerification") or {}
|
|
5075
|
+
for item in verification.get("planItems") or []:
|
|
5076
|
+
if not isinstance(item, dict):
|
|
5077
|
+
continue
|
|
5078
|
+
item_id = str(item.get("id") or "")
|
|
5079
|
+
actual = {str(value) for value in item.get("clarificationRefs") or []}
|
|
5080
|
+
if actual != expected.get(item_id, set()):
|
|
5081
|
+
failures.append(
|
|
5082
|
+
f"final-report data.json: plan item `{item_id}` clarificationRefs "
|
|
5083
|
+
"do not match activity-ledger backlinks."
|
|
5084
|
+
)
|
|
5085
|
+
|
|
5086
|
+
|
|
5087
|
+
def _validate_v3_resolution_links(
|
|
5088
|
+
row: dict, activities: Mapping[str, dict], failures: list[str],
|
|
5089
|
+
) -> None:
|
|
5090
|
+
resolution = row.get("resolution")
|
|
5091
|
+
if not isinstance(resolution, dict):
|
|
5092
|
+
return
|
|
5093
|
+
row_id = str(row.get("id") or "<unknown>")
|
|
5094
|
+
for activity_id in resolution.get("checkRefs") or []:
|
|
5095
|
+
activity = activities.get(activity_id)
|
|
5096
|
+
if activity is None:
|
|
5097
|
+
failures.append(
|
|
5098
|
+
f"final-report data.json: clarification `{row_id}` references "
|
|
5099
|
+
f"unknown activity `{activity_id}`."
|
|
5100
|
+
)
|
|
5101
|
+
elif row_id not in (activity.get("clarificationRefs") or []):
|
|
5102
|
+
failures.append(
|
|
5103
|
+
f"final-report data.json: activity `{activity_id}` does not "
|
|
5104
|
+
f"link back to clarification `{row_id}`."
|
|
5105
|
+
)
|
|
5106
|
+
|
|
5107
|
+
|
|
5108
|
+
def _validate_v3_approval_context(data: dict, failures: list[str]) -> None:
|
|
5109
|
+
"""검증 가능한 원장 참조만으로 v3 승인 역추적을 다시 계산한다."""
|
|
5110
|
+
activities = _approval_activities_by_id(data)
|
|
5111
|
+
_validate_v3_plan_backlinks(data, activities, failures)
|
|
5112
|
+
approved = (data.get("frontmatter") or {}).get("approved") is True
|
|
5113
|
+
for row in data.get("clarificationItems") or []:
|
|
5114
|
+
if not isinstance(row, dict) or row.get("blocks") != "approval":
|
|
5115
|
+
continue
|
|
5116
|
+
context = row.get("approvalContext")
|
|
5117
|
+
if not isinstance(context, dict):
|
|
5118
|
+
continue
|
|
5119
|
+
_validate_approval_dispositions(
|
|
5120
|
+
row, context, failures, schema_version="3.0"
|
|
5121
|
+
)
|
|
5122
|
+
_validate_resolved_approval(
|
|
5123
|
+
row, context, failures, schema_version="3.0"
|
|
5124
|
+
)
|
|
5125
|
+
_validate_v3_resolution_links(row, activities, failures)
|
|
5126
|
+
if approved and row.get("status") in {"open", "answered"}:
|
|
5127
|
+
failures.append(
|
|
5128
|
+
f"final-report data.json: approval is true while clarification "
|
|
5129
|
+
f"`{row.get('id')}` remains `{row.get('status')}`."
|
|
5130
|
+
)
|
|
5131
|
+
|
|
5132
|
+
|
|
4994
5133
|
def _validate_activity_contract_plan_limits(
|
|
4995
5134
|
data: dict,
|
|
4996
5135
|
run_manifest: dict,
|
|
@@ -5435,7 +5574,7 @@ def _validate_clarification_evidence_note(data: dict, failures: list[str]) -> No
|
|
|
5435
5574
|
)
|
|
5436
5575
|
|
|
5437
5576
|
|
|
5438
|
-
|
|
5577
|
+
_CLARIFICATION_OPTION_SCHEMA_VERSIONS = frozenset({"2.0", "3.0"})
|
|
5439
5578
|
# The four profiles that read `_clarification-recommendation.md`. Unlike the
|
|
5440
5579
|
# evidence-note gate above — which is called from inside the
|
|
5441
5580
|
# `implementation-planning` branch — this one is called phase-agnostically, so
|
|
@@ -5508,7 +5647,7 @@ def _validate_clarification_options(data: dict, failures: list[str]) -> None:
|
|
|
5508
5647
|
demanding one would fail every v1 `decision` row for a structure the format
|
|
5509
5648
|
has no place to hold.
|
|
5510
5649
|
"""
|
|
5511
|
-
if data.get("schemaVersion")
|
|
5650
|
+
if data.get("schemaVersion") not in _CLARIFICATION_OPTION_SCHEMA_VERSIONS:
|
|
5512
5651
|
return
|
|
5513
5652
|
task_type = (data.get("header") or {}).get("taskType")
|
|
5514
5653
|
if task_type not in _CLARIFICATION_OPTION_TASK_TYPES:
|
|
@@ -5517,7 +5656,10 @@ def _validate_clarification_options(data: dict, failures: list[str]) -> None:
|
|
|
5517
5656
|
if not isinstance(row, dict) or row.get("kind") != "decision":
|
|
5518
5657
|
continue
|
|
5519
5658
|
row_id = str(row.get("id") or "<unknown>")
|
|
5520
|
-
_validate_option_set(
|
|
5659
|
+
_validate_option_set(
|
|
5660
|
+
row.get("options"), row_id, failures,
|
|
5661
|
+
schema_version=str(data.get("schemaVersion") or ""),
|
|
5662
|
+
)
|
|
5521
5663
|
if _LEGACY_EXPECTED_FORM_RE.search(str(row.get("expectedForm") or "")):
|
|
5522
5664
|
failures.append(
|
|
5523
5665
|
f"final-report data.json: clarification `{row_id}` still encodes "
|
|
@@ -5529,7 +5671,7 @@ def _validate_clarification_options(data: dict, failures: list[str]) -> None:
|
|
|
5529
5671
|
|
|
5530
5672
|
|
|
5531
5673
|
def _validate_option_set(
|
|
5532
|
-
options: object, row_id: str, failures: list[str]
|
|
5674
|
+
options: object, row_id: str, failures: list[str], *, schema_version: str = "2.0"
|
|
5533
5675
|
) -> None:
|
|
5534
5676
|
"""Check one `decision` row's `options[]` for pickability and reach."""
|
|
5535
5677
|
if not isinstance(options, list) or len(options) < 2:
|
|
@@ -5550,7 +5692,9 @@ def _validate_option_set(
|
|
|
5550
5692
|
for index, option in enumerate(entries):
|
|
5551
5693
|
tokens = option.get("scopeImpact")
|
|
5552
5694
|
reach = (
|
|
5553
|
-
[
|
|
5695
|
+
[str(option.get("reach"))]
|
|
5696
|
+
if schema_version == "3.0" and option.get("reach") in _REACH_TOKENS
|
|
5697
|
+
else [token for token in tokens if token in _REACH_TOKENS]
|
|
5554
5698
|
if isinstance(tokens, list)
|
|
5555
5699
|
else []
|
|
5556
5700
|
)
|
|
@@ -6697,6 +6841,53 @@ def _validate_round_recorded_verdicts(data: dict, failures: list[str]) -> None:
|
|
|
6697
6841
|
)
|
|
6698
6842
|
|
|
6699
6843
|
|
|
6844
|
+
def _validate_unresolved_tie_was_reverified(
|
|
6845
|
+
data: dict,
|
|
6846
|
+
failures: list[str],
|
|
6847
|
+
) -> None:
|
|
6848
|
+
"""A split panel is sent back once before the gate is declared.
|
|
6849
|
+
|
|
6850
|
+
The gate needs a strict majority to block, so a panel splitting evenly on a
|
|
6851
|
+
blocking kind reaches neither consensus nor `majority-disagree`. That state
|
|
6852
|
+
is classified `needs-reverify`, and `needs-reverify` folds into
|
|
6853
|
+
`passed-with-dissent` — which is correct for the shape it was built for (a
|
|
6854
|
+
peer that returned nothing) and wrong for this one: nothing failed here, two
|
|
6855
|
+
verifiers read the same plan and disagreed, and passing on that records a
|
|
6856
|
+
dissent nobody acted on.
|
|
6857
|
+
|
|
6858
|
+
So the round is not optional. Re-dispatch those items and record the votes
|
|
6859
|
+
with `--round 2`; a split that survives becomes `majority-disagree` and the
|
|
6860
|
+
user decides. This is satisfiable with the machinery the contract already
|
|
6861
|
+
defines — it is the same targeted re-verification step 7 runs after a
|
|
6862
|
+
self-fix, with the tied items added to that queue.
|
|
6863
|
+
"""
|
|
6864
|
+
ip = data.get("implementationPlanning")
|
|
6865
|
+
if not isinstance(ip, dict):
|
|
6866
|
+
return
|
|
6867
|
+
pbv = ip.get("planBodyVerification")
|
|
6868
|
+
if not isinstance(pbv, dict):
|
|
6869
|
+
return
|
|
6870
|
+
unsettled = sorted({
|
|
6871
|
+
str(item.get("id") or "").strip()
|
|
6872
|
+
for item in pbv.get("planItems") or []
|
|
6873
|
+
if isinstance(item, dict)
|
|
6874
|
+
and not item.get("carriedForwardFromSeq")
|
|
6875
|
+
and _is_unsettled_tie(item)
|
|
6876
|
+
})
|
|
6877
|
+
if not unsettled:
|
|
6878
|
+
return
|
|
6879
|
+
failures.append(
|
|
6880
|
+
f"final-report data.json: plan item(s) {unsettled} carry an even split "
|
|
6881
|
+
"on a blocking breakage kind and were never re-verified. A tie is not "
|
|
6882
|
+
"consensus: the gate's majority test is strict, so this split neither "
|
|
6883
|
+
"blocks nor resolves, and declaring the gate on it passes a dissent "
|
|
6884
|
+
"nobody settled. Re-dispatch those items in a plan-body round and "
|
|
6885
|
+
"record the votes with `okstra plan-items apply-verdicts --data "
|
|
6886
|
+
"<data.json> --verdicts <verdicts.json> --round 2`. A split that "
|
|
6887
|
+
"survives that round becomes `majority-disagree` and goes to the user."
|
|
6888
|
+
)
|
|
6889
|
+
|
|
6890
|
+
|
|
6700
6891
|
def _validate_verdict_rounds_outlive_self_fix(
|
|
6701
6892
|
data: dict,
|
|
6702
6893
|
failures: list[str],
|
|
@@ -7794,6 +7985,7 @@ def validate_plan_body_section(
|
|
|
7794
7985
|
_validate_round_recorded_verdicts(data, failures)
|
|
7795
7986
|
_validate_verdicts_match_current_subjects(data, failures)
|
|
7796
7987
|
_validate_verdict_rounds_outlive_self_fix(data, failures)
|
|
7988
|
+
_validate_unresolved_tie_was_reverified(data, failures)
|
|
7797
7989
|
_validate_plan_item_extraction_completeness(data, failures)
|
|
7798
7990
|
_validate_plan_item_subject_substance(data, failures)
|
|
7799
7991
|
_validate_plan_body_clarification_matching(data, failures, accepted_item_ids)
|
|
@@ -9272,6 +9464,52 @@ def run_plan_body_section(report_path: Path) -> int:
|
|
|
9272
9464
|
return 0 if not failures else 2
|
|
9273
9465
|
|
|
9274
9466
|
|
|
9467
|
+
def run_plan_body_inputs(narrative_path: Path, state_path: Path) -> int:
|
|
9468
|
+
"""게시 전 서사와 수렴 소유 상태에서 같은 계획 게이트를 채점한다."""
|
|
9469
|
+
from okstra_ctl.report_narrative import parse_narrative
|
|
9470
|
+
from okstra_ctl.final_report_schema import load_schema_version
|
|
9471
|
+
|
|
9472
|
+
try:
|
|
9473
|
+
narrative = parse_narrative(
|
|
9474
|
+
narrative_path.read_text(encoding="utf-8"),
|
|
9475
|
+
load_schema_version("3.0"),
|
|
9476
|
+
)
|
|
9477
|
+
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
9478
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
9479
|
+
print(f"validate-run: plan-body inputs are invalid ({exc})", file=sys.stderr)
|
|
9480
|
+
return 2
|
|
9481
|
+
state_owner = state.get("owner") if isinstance(state, dict) else None
|
|
9482
|
+
verification = state.get("planBodyVerification") if isinstance(state, dict) else None
|
|
9483
|
+
planning = narrative.get("implementationPlanning")
|
|
9484
|
+
if state_owner != "convergence" or not isinstance(verification, dict):
|
|
9485
|
+
print("validate-run: plan-body state must be convergence-owned", file=sys.stderr)
|
|
9486
|
+
return 2
|
|
9487
|
+
if not isinstance(planning, dict):
|
|
9488
|
+
print("validate-run: narrative has no implementationPlanning", file=sys.stderr)
|
|
9489
|
+
return 2
|
|
9490
|
+
data = {**narrative, "schemaVersion": "3.0"}
|
|
9491
|
+
data["implementationPlanning"] = {**planning, "planBodyVerification": verification}
|
|
9492
|
+
failures: list[str] = []
|
|
9493
|
+
warnings = validate_plan_body_section(data, _report_path_for_state(state_path), failures)
|
|
9494
|
+
payload = {
|
|
9495
|
+
"ok": not failures,
|
|
9496
|
+
"section": SECTION_PLAN_BODY,
|
|
9497
|
+
"gate": plan_body_gate_summary(data),
|
|
9498
|
+
"failures": failures,
|
|
9499
|
+
"warnings": warnings,
|
|
9500
|
+
}
|
|
9501
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
9502
|
+
return 0 if not failures else 2
|
|
9503
|
+
|
|
9504
|
+
|
|
9505
|
+
def _report_path_for_state(state_path: Path) -> Path:
|
|
9506
|
+
match = re.search(r"-(\d{3})\.json$", state_path.name)
|
|
9507
|
+
seq = match.group(1) if match else "001"
|
|
9508
|
+
return state_path.parent.parent / "reports" / (
|
|
9509
|
+
f"final-report-implementation-planning-{seq}.data.json"
|
|
9510
|
+
)
|
|
9511
|
+
|
|
9512
|
+
|
|
9275
9513
|
def _data_schema_failures(data: dict) -> list[str]:
|
|
9276
9514
|
"""Schema errors in the round's data.json, as failures.
|
|
9277
9515
|
|
|
@@ -9315,9 +9553,11 @@ def main() -> int:
|
|
|
9315
9553
|
)
|
|
9316
9554
|
parser.add_argument(
|
|
9317
9555
|
"--report",
|
|
9318
|
-
required=
|
|
9556
|
+
required=False,
|
|
9319
9557
|
help="Project-relative or absolute path to the report record (.data.json). Schema-v1 reports still use the Markdown file.",
|
|
9320
9558
|
)
|
|
9559
|
+
parser.add_argument("--narrative", required=False)
|
|
9560
|
+
parser.add_argument("--state", required=False)
|
|
9321
9561
|
parser.add_argument(
|
|
9322
9562
|
"--run-manifest",
|
|
9323
9563
|
required=False,
|
|
@@ -9343,7 +9583,15 @@ def main() -> int:
|
|
|
9343
9583
|
args = parser.parse_args()
|
|
9344
9584
|
|
|
9345
9585
|
if args.section == SECTION_PLAN_BODY:
|
|
9346
|
-
|
|
9586
|
+
if args.narrative and args.state and not args.report:
|
|
9587
|
+
return run_plan_body_inputs(
|
|
9588
|
+
Path(args.narrative).resolve(), Path(args.state).resolve()
|
|
9589
|
+
)
|
|
9590
|
+
if args.report and not args.narrative and not args.state:
|
|
9591
|
+
return run_plan_body_section(Path(args.report).resolve())
|
|
9592
|
+
parser.error(
|
|
9593
|
+
"--section plan-body requires either --report or both --narrative and --state"
|
|
9594
|
+
)
|
|
9347
9595
|
|
|
9348
9596
|
missing = [
|
|
9349
9597
|
f"--{flag.replace('_', '-')}"
|