okstra 0.180.0 → 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/dist/cli-registry.mjs +16 -2
- package/dist/cli-registry.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/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.md +11 -0
- package/docs/cli.md +26 -13
- 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 +29 -26
- 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 +3 -3
- 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 +3 -7
- package/runtime/prompts/lead/context-loader.md +41 -141
- package/runtime/prompts/lead/convergence.md +8 -6
- package/runtime/prompts/lead/okstra-lead-contract.md +25 -35
- package/runtime/prompts/lead/plan-body-verification.md +187 -14
- package/runtime/prompts/lead/report-writer.md +20 -1
- package/runtime/prompts/lead/team-contract.md +8 -53
- package/runtime/prompts/profiles/_coding-conventions-preflight.md +3 -2
- package/runtime/prompts/profiles/_common-contract.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/final-verification.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +8 -2
- package/runtime/prompts/profiles/release-handoff.md +0 -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 +6 -1
- 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/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 +118 -35
- package/runtime/python/okstra_ctl/agent_invocation.py +19 -6
- package/runtime/python/okstra_ctl/agent_prompt_cli.py +65 -18
- package/runtime/python/okstra_ctl/analysis_inputs.py +5 -4
- package/runtime/python/okstra_ctl/analysis_packet.py +81 -1
- package/runtime/python/okstra_ctl/approval_decisions.py +3 -2
- 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/dispatch_core.py +35 -65
- package/runtime/python/okstra_ctl/dispatch_state.py +134 -59
- 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/final_report_schema.py +2 -2
- 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 +2 -1
- package/runtime/python/okstra_ctl/incremental_scope.py +8 -3
- 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/paths.py +9 -0
- package/runtime/python/okstra_ctl/plan_items.py +115 -4
- package/runtime/python/okstra_ctl/plan_items_cli.py +513 -18
- 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 +26 -15
- package/runtime/python/okstra_ctl/render_final_report.py +3 -2
- package/runtime/python/okstra_ctl/report_assembly.py +172 -17
- package/runtime/python/okstra_ctl/report_finalize.py +7 -10
- package/runtime/python/okstra_ctl/report_html/render.py +3 -2
- package/runtime/python/okstra_ctl/report_language.py +3 -2
- package/runtime/python/okstra_ctl/report_markdown.py +13 -1
- package/runtime/python/okstra_ctl/report_narrative.py +40 -8
- 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 +159 -56
- 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 +4 -2
- 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 +4 -2
- package/runtime/python/okstra_ctl/stage_map.py +26 -6
- package/runtime/python/okstra_ctl/stage_targets.py +3 -4
- package/runtime/python/okstra_ctl/team.py +2 -1
- 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_prompt_body.py +18 -7
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +3 -2
- 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 +9 -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 +17 -1
- package/runtime/templates/reports/schedule.template.md +4 -4
- package/runtime/templates/worker-error-contract.md +17 -29
- package/runtime/validators/validate-run.py +379 -63
- package/runtime/validators/validate_session_conformance.py +43 -7
|
@@ -7,15 +7,26 @@ does not depend on a parser the lead re-writes each time.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import argparse
|
|
10
|
+
import importlib.util
|
|
10
11
|
import json
|
|
11
12
|
from collections.abc import Mapping
|
|
13
|
+
from datetime import datetime, timezone
|
|
12
14
|
from pathlib import Path
|
|
13
15
|
import sys
|
|
14
16
|
from typing import Any
|
|
15
17
|
|
|
18
|
+
from .convergence import (
|
|
19
|
+
ConvergenceContractError,
|
|
20
|
+
canonical_run_state_artifact,
|
|
21
|
+
validated_run_authority,
|
|
22
|
+
)
|
|
16
23
|
from .convergence_store import write_json_atomic
|
|
24
|
+
from .json_boundary import JsonBoundaryError, load_owned_object
|
|
17
25
|
from .plan_derivations import extract_tokens, find_derivations
|
|
18
26
|
from .plan_items import PlanItemContractError, extract_plan_items
|
|
27
|
+
from .paths import RunRef
|
|
28
|
+
from .stage_ledger import build_stage_ledger
|
|
29
|
+
from .claim_reproduction import NOT_RUNNABLE, reproduce
|
|
19
30
|
from .final_report_schema import load_schema_version
|
|
20
31
|
from .report_narrative import parse_narrative
|
|
21
32
|
from .user_response import parse_user_response_entries
|
|
@@ -25,16 +36,14 @@ from .verdict_blocks import (
|
|
|
25
36
|
VerdictBlockError,
|
|
26
37
|
parse_verdict_blocks,
|
|
27
38
|
)
|
|
39
|
+
from .fixed_text import line, scalar
|
|
28
40
|
|
|
29
41
|
|
|
30
42
|
def _load_json_object(path: Path) -> dict[str, Any]:
|
|
31
43
|
try:
|
|
32
|
-
|
|
33
|
-
except
|
|
34
|
-
raise PlanItemContractError(
|
|
35
|
-
if not isinstance(value, dict):
|
|
36
|
-
raise PlanItemContractError(f"JSON input must be an object: {path}")
|
|
37
|
-
return value
|
|
44
|
+
return load_owned_object(path, artifact="plan-items artifact")
|
|
45
|
+
except JsonBoundaryError as exc:
|
|
46
|
+
raise PlanItemContractError(str(exc)) from exc
|
|
38
47
|
|
|
39
48
|
|
|
40
49
|
def _planning(data: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
@@ -64,6 +73,14 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
64
73
|
validate = commands.add_parser("validate")
|
|
65
74
|
_add_plan_source(validate)
|
|
66
75
|
validate.add_argument("--items", type=Path, required=True)
|
|
76
|
+
prepare = commands.add_parser("prepare")
|
|
77
|
+
_add_plan_source(prepare)
|
|
78
|
+
prepare.add_argument("--run-manifest", type=Path, required=True)
|
|
79
|
+
prompt = commands.add_parser("prompt")
|
|
80
|
+
prompt.add_argument("--run-manifest", type=Path, required=True)
|
|
81
|
+
validate_prepared = commands.add_parser("validate-prepared")
|
|
82
|
+
_add_plan_source(validate_prepared)
|
|
83
|
+
validate_prepared.add_argument("--run-manifest", type=Path, required=True)
|
|
67
84
|
collect = commands.add_parser(
|
|
68
85
|
"collect-verdicts",
|
|
69
86
|
help="read this round's worker responses into a verdicts envelope",
|
|
@@ -88,6 +105,11 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
88
105
|
)
|
|
89
106
|
_add_plan_source(seed)
|
|
90
107
|
seed.add_argument("--state", type=Path)
|
|
108
|
+
seed.add_argument(
|
|
109
|
+
"--run-manifest", type=Path,
|
|
110
|
+
help="record the stage ledger so the gate can scope itself to the "
|
|
111
|
+
"stage about to start; without it the gate does not narrow",
|
|
112
|
+
)
|
|
91
113
|
apply_verdicts = commands.add_parser(
|
|
92
114
|
"apply-verdicts",
|
|
93
115
|
help="overwrite planBodyVerification.planItems[].verdicts in owned state",
|
|
@@ -95,12 +117,37 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
95
117
|
target = apply_verdicts.add_mutually_exclusive_group(required=True)
|
|
96
118
|
target.add_argument("--data", type=Path)
|
|
97
119
|
target.add_argument("--state", type=Path)
|
|
98
|
-
apply_verdicts.
|
|
120
|
+
incoming = apply_verdicts.add_mutually_exclusive_group(required=True)
|
|
121
|
+
incoming.add_argument(
|
|
122
|
+
"--verdicts", type=Path,
|
|
123
|
+
help="historical automation envelope; model-facing calls use --result",
|
|
124
|
+
)
|
|
125
|
+
incoming.add_argument(
|
|
126
|
+
"--result", action="append", default=[],
|
|
127
|
+
metavar="<worker-id>=<path>",
|
|
128
|
+
help="one worker's plan-verify Markdown result (repeatable)",
|
|
129
|
+
)
|
|
130
|
+
apply_verdicts.add_argument(
|
|
131
|
+
"--run-manifest", type=Path,
|
|
132
|
+
help="resolve the project a `fact` claim's probe runs against; without "
|
|
133
|
+
"it every such claim records `not-runnable` and takes the quorum "
|
|
134
|
+
"route instead of blocking on one vote",
|
|
135
|
+
)
|
|
99
136
|
apply_verdicts.add_argument(
|
|
100
137
|
"--round", type=int, required=True, dest="round_number",
|
|
101
138
|
help="the verification round these verdicts were cast in; stamped on "
|
|
102
139
|
"every row so a later self-fix can be told from a current judgement",
|
|
103
140
|
)
|
|
141
|
+
complete = commands.add_parser(
|
|
142
|
+
"complete-round",
|
|
143
|
+
help="derive and atomically record one verified plan-body round",
|
|
144
|
+
)
|
|
145
|
+
complete.add_argument("--state", type=Path, required=True)
|
|
146
|
+
complete.add_argument("--run-manifest", type=Path, required=True)
|
|
147
|
+
complete.add_argument("--round", type=int, required=True, dest="round_number")
|
|
148
|
+
complete.add_argument("--self-fix-note", action="append", default=[], metavar="<item-id>=<markdown-file>")
|
|
149
|
+
complete.add_argument("--self-fix-group", action="append", default=[], metavar="<cause-file>=<item-id>[,<item-id>...]")
|
|
150
|
+
complete.add_argument("--self-fix-stop-reason", choices=("all-resolved", "no-progress", "max-rounds-reached"))
|
|
104
151
|
return parser
|
|
105
152
|
|
|
106
153
|
|
|
@@ -139,6 +186,203 @@ def _validate(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
139
186
|
return {"ok": True, "operation": "validate", "path": str(args.items)}
|
|
140
187
|
|
|
141
188
|
|
|
189
|
+
def _prepared_items_path(
|
|
190
|
+
run_manifest: Path, *, require_regular: bool = False
|
|
191
|
+
) -> Path:
|
|
192
|
+
try:
|
|
193
|
+
authority = validated_run_authority(run_manifest)
|
|
194
|
+
state = canonical_run_state_artifact(
|
|
195
|
+
authority,
|
|
196
|
+
manifest_field="planBodyVerificationPath",
|
|
197
|
+
prefix="plan-body-verification",
|
|
198
|
+
label="plan body verification path",
|
|
199
|
+
)
|
|
200
|
+
except ConvergenceContractError as exc:
|
|
201
|
+
raise PlanItemContractError(str(exc)) from exc
|
|
202
|
+
path = state.with_name(
|
|
203
|
+
"plan-items-" + state.name.removeprefix("plan-body-verification-")
|
|
204
|
+
)
|
|
205
|
+
if path.absolute() != path.resolve(strict=False):
|
|
206
|
+
raise PlanItemContractError("plan items path does not match run authority")
|
|
207
|
+
if require_regular and not path.is_file():
|
|
208
|
+
raise PlanItemContractError("plan items path is not a regular file")
|
|
209
|
+
if path.exists() and not path.is_file():
|
|
210
|
+
raise PlanItemContractError("plan items path is not a regular file")
|
|
211
|
+
return path
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _prepare(args: argparse.Namespace) -> dict[str, Any]:
|
|
215
|
+
output = _prepared_items_path(args.run_manifest)
|
|
216
|
+
envelope = _envelope(_plan_source(args))
|
|
217
|
+
write_json_atomic(output, envelope)
|
|
218
|
+
return {"ok": True, "operation": "prepare", "path": str(output)}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
_PAYLOAD_FIELDS = {
|
|
222
|
+
"Dir": ("goal", "coreMechanism", "architectureBoundaries", "expectedChangeAreas",
|
|
223
|
+
"fileStructure", "interfaces", "blastRadius", "testSeams", "assumptions",
|
|
224
|
+
"planningInvariants", "userConstraints"),
|
|
225
|
+
"Opt": ("name", "ticketId", "fileStructure", "interfaces", "blastRadius"),
|
|
226
|
+
"Step": ("stage", "step", "ticketId", "action", "files", "plannedPaths",
|
|
227
|
+
"command", "outcome", "expectedDetail"),
|
|
228
|
+
"Dep": ("id", "ticketId", "kind", "item", "impact", "mitigation"),
|
|
229
|
+
"Val": ("id", "phase", "ticketId", "check", "commandOrObservation", "expectedOutcome",
|
|
230
|
+
"stageRefs"),
|
|
231
|
+
"Rb": ("id", "ticketId", "step", "action", "triggerSignal", "verificationMethod"),
|
|
232
|
+
# 두 계약의 합집합이다. `ImplementationRequirementCoverageRow` 는 id/source/
|
|
233
|
+
# requirement/coveredBy/status 와 사용자 처분 기록(approvalDisposition,
|
|
234
|
+
# decisionRefs)을, `SelectedDirectionRequirementCoverage` 는 originalRequirementId
|
|
235
|
+
# 와 네 개의 *Refs 를 싣는다. 어느 한쪽만 남기면 다른 계약의 계획이 렌더에서
|
|
236
|
+
# 거부되므로 줄이지 말 것.
|
|
237
|
+
"Req": ("id", "source", "requirement", "coveredBy", "originalRequirementId",
|
|
238
|
+
"stageRefs", "stepRefs", "validationRefs", "fileRefs", "ticketId", "status",
|
|
239
|
+
"approvalDisposition", "decisionRefs"),
|
|
240
|
+
"Prep": ("stage", "kind", "evidence"),
|
|
241
|
+
}
|
|
242
|
+
_VAR_ANALYSIS_FIELDS = (
|
|
243
|
+
"hasMultipleImplementations", "noVariationRationale", "points",
|
|
244
|
+
)
|
|
245
|
+
_VAR_POINT_FIELDS = (
|
|
246
|
+
"behavior", "implementations", "evidence", "extractionDecision",
|
|
247
|
+
)
|
|
248
|
+
_OBJECT_LIST_FIELDS = {
|
|
249
|
+
"fileStructure": ("id", "ticketId", "action", "path", "summary", "details"),
|
|
250
|
+
"testSeams": ("boundary", "injectedAs", "replacedInTest"),
|
|
251
|
+
"planningInvariants": ("id", "statement", "requirementIds", "evidence"),
|
|
252
|
+
"evidence": ("step", "field", "match"),
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _label(key: str) -> str:
|
|
257
|
+
return " ".join(part.capitalize() for part in key.replace("Id", " ID").split())
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _render_object_list(key: str, value: object) -> list[str]:
|
|
261
|
+
if not isinstance(value, list):
|
|
262
|
+
raise PlanItemContractError(f"plan item {key} must be an array")
|
|
263
|
+
rows = [f"- {_label(key)}:\n"]
|
|
264
|
+
allowed = _OBJECT_LIST_FIELDS[key]
|
|
265
|
+
for index, entry in enumerate(value, 1):
|
|
266
|
+
if not isinstance(entry, Mapping) or set(entry) - set(allowed):
|
|
267
|
+
raise PlanItemContractError(f"plan item {key}[{index}] has unknown fields")
|
|
268
|
+
rows.append(f" - Entry {index}:\n")
|
|
269
|
+
for field in allowed:
|
|
270
|
+
if field in entry:
|
|
271
|
+
field_path = f"{key}[{index - 1}].{field}"
|
|
272
|
+
field_value = entry[field]
|
|
273
|
+
if isinstance(field_value, list):
|
|
274
|
+
rows.append(f" - `{field_path}`:\n")
|
|
275
|
+
rows.extend(
|
|
276
|
+
f" - `{scalar(item)}`\n" for item in field_value
|
|
277
|
+
)
|
|
278
|
+
elif isinstance(field_value, Mapping):
|
|
279
|
+
raise PlanItemContractError(
|
|
280
|
+
f"plan item {field_path} must be scalar"
|
|
281
|
+
)
|
|
282
|
+
else:
|
|
283
|
+
rows.append(
|
|
284
|
+
f" - `{field_path}`: `{scalar(field_value)}`\n"
|
|
285
|
+
)
|
|
286
|
+
return rows
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _render_variation_points(value: object) -> list[str]:
|
|
290
|
+
if not isinstance(value, list):
|
|
291
|
+
raise PlanItemContractError("plan item points must be an array")
|
|
292
|
+
rows = ["- Points:\n"]
|
|
293
|
+
allowed = {"behavior", "implementations", "evidence", "extractionDecision"}
|
|
294
|
+
decision_fields = ("extract", "interfaceKind", "coveredBy", "rationale")
|
|
295
|
+
for index, point in enumerate(value, 1):
|
|
296
|
+
if not isinstance(point, Mapping) or set(point) - allowed:
|
|
297
|
+
raise PlanItemContractError(f"plan item points[{index}] has unknown fields")
|
|
298
|
+
rows.append(f" - Point {index}:\n")
|
|
299
|
+
for field in ("behavior", "implementations", "evidence"):
|
|
300
|
+
rows.extend(_render_scalar_or_list(field, point.get(field), " "))
|
|
301
|
+
decision = point.get("extractionDecision")
|
|
302
|
+
if not isinstance(decision, Mapping) or set(decision) - set(decision_fields):
|
|
303
|
+
raise PlanItemContractError("variation extractionDecision has unknown fields")
|
|
304
|
+
rows.append(" - Extraction decision:\n")
|
|
305
|
+
for field in decision_fields:
|
|
306
|
+
rows.append(" " + line(_label(field), decision.get(field)))
|
|
307
|
+
return rows
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _render_variation_point_payload(payload: Mapping[str, Any]) -> list[str]:
|
|
311
|
+
rows: list[str] = []
|
|
312
|
+
for field in ("behavior", "implementations", "evidence"):
|
|
313
|
+
rows.extend(_render_scalar_or_list(field, payload.get(field)))
|
|
314
|
+
decision = payload.get("extractionDecision")
|
|
315
|
+
decision_fields = ("extract", "interfaceKind", "coveredBy", "rationale")
|
|
316
|
+
if not isinstance(decision, Mapping) or set(decision) - set(decision_fields):
|
|
317
|
+
raise PlanItemContractError("variation extractionDecision has unknown fields")
|
|
318
|
+
rows.append("- Extraction decision:\n")
|
|
319
|
+
for field in decision_fields:
|
|
320
|
+
rows.append(" " + line(_label(field), decision.get(field)))
|
|
321
|
+
return rows
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _render_scalar_or_list(key: str, value: object, indent: str = "") -> list[str]:
|
|
325
|
+
if isinstance(value, list):
|
|
326
|
+
if any(isinstance(entry, (Mapping, list)) for entry in value):
|
|
327
|
+
raise PlanItemContractError(f"plan item {key} must contain scalars")
|
|
328
|
+
rows = [f"{indent}- {_label(key)}:\n"]
|
|
329
|
+
rows.extend(f"{indent} - `{scalar(entry)}`\n" for entry in value)
|
|
330
|
+
return rows
|
|
331
|
+
if isinstance(value, Mapping):
|
|
332
|
+
raise PlanItemContractError(f"plan item {key} must be scalar")
|
|
333
|
+
return [indent + line(_label(key), value)]
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _render_payload(item_id: str, payload: object) -> list[str]:
|
|
337
|
+
if not isinstance(payload, Mapping):
|
|
338
|
+
raise PlanItemContractError(f"plan item {item_id} payload must be an object")
|
|
339
|
+
kind = item_id.split("-", 2)[1] if item_id.startswith("P-") else ""
|
|
340
|
+
allowed = (
|
|
341
|
+
_VAR_ANALYSIS_FIELDS if item_id == "P-Var-0"
|
|
342
|
+
else _VAR_POINT_FIELDS if kind == "Var"
|
|
343
|
+
else _PAYLOAD_FIELDS.get(kind)
|
|
344
|
+
)
|
|
345
|
+
if allowed is None or set(payload) - set(allowed):
|
|
346
|
+
raise PlanItemContractError(f"plan item {item_id} has unknown payload fields")
|
|
347
|
+
if kind == "Var" and item_id != "P-Var-0":
|
|
348
|
+
return _render_variation_point_payload(payload)
|
|
349
|
+
rows: list[str] = []
|
|
350
|
+
for key in allowed:
|
|
351
|
+
if key not in payload:
|
|
352
|
+
continue
|
|
353
|
+
if key in _OBJECT_LIST_FIELDS:
|
|
354
|
+
rows.extend(_render_object_list(key, payload[key]))
|
|
355
|
+
elif key == "points":
|
|
356
|
+
rows.extend(_render_variation_points(payload[key]))
|
|
357
|
+
else:
|
|
358
|
+
rows.extend(_render_scalar_or_list(key, payload[key]))
|
|
359
|
+
return rows
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _prompt(args: argparse.Namespace) -> str:
|
|
363
|
+
envelope = _load_json_object(
|
|
364
|
+
_prepared_items_path(args.run_manifest, require_regular=True)
|
|
365
|
+
)
|
|
366
|
+
items = envelope.get("items") if isinstance(envelope.get("items"), list) else []
|
|
367
|
+
rows = ["# Plan verification queue\n", line("Item count", len(items))]
|
|
368
|
+
for index, item in enumerate(items, 1):
|
|
369
|
+
if not isinstance(item, Mapping):
|
|
370
|
+
continue
|
|
371
|
+
item_id = str(item.get("id") or "")
|
|
372
|
+
rows.extend((f"\n## Plan item {index}\n", line("Item ID", item_id),
|
|
373
|
+
line("Subject", item.get("subject"))))
|
|
374
|
+
rows.extend(_render_payload(item_id, item.get("payload")))
|
|
375
|
+
return "".join(rows)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _validate_prepared(args: argparse.Namespace) -> dict[str, Any]:
|
|
379
|
+
expected = _envelope(_plan_source(args))
|
|
380
|
+
path = _prepared_items_path(args.run_manifest, require_regular=True)
|
|
381
|
+
if _load_json_object(path) != expected:
|
|
382
|
+
raise PlanItemContractError("prepared plan items do not match deterministic extraction")
|
|
383
|
+
return {"ok": True, "operation": "validate-prepared", "path": str(path)}
|
|
384
|
+
|
|
385
|
+
|
|
142
386
|
def _split_result_arg(raw: str) -> tuple[str, Path]:
|
|
143
387
|
worker, separator, path = raw.partition("=")
|
|
144
388
|
if not separator or not worker.strip() or not path.strip():
|
|
@@ -195,8 +439,15 @@ def _worker_blocks(
|
|
|
195
439
|
parser; each is a round scored on a table that does not match the queue.
|
|
196
440
|
"""
|
|
197
441
|
collected: list[tuple[str, dict[str, VerdictBlock]]] = []
|
|
442
|
+
seen_workers: set[str] = set()
|
|
198
443
|
for raw in raw_results:
|
|
199
444
|
worker, path = _split_result_arg(raw)
|
|
445
|
+
if worker in seen_workers:
|
|
446
|
+
raise PlanItemContractError(
|
|
447
|
+
f"duplicate worker result for `{worker}` — one worker may cast "
|
|
448
|
+
"only one verdict per plan-item round"
|
|
449
|
+
)
|
|
450
|
+
seen_workers.add(worker)
|
|
200
451
|
try:
|
|
201
452
|
blocks = parse_verdict_blocks(path.read_text(encoding="utf-8"))
|
|
202
453
|
except (OSError, UnicodeError) as exc:
|
|
@@ -334,6 +585,37 @@ def _derivations(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
334
585
|
}
|
|
335
586
|
|
|
336
587
|
|
|
588
|
+
def _stage_ledger_snapshot(run_manifest: Path | None) -> dict[str, str] | None:
|
|
589
|
+
"""`{스테이지 번호: 상태}`. 원장을 못 읽으면 ``None``.
|
|
590
|
+
|
|
591
|
+
게이트가 범위를 좁히려면 어느 스테이지가 지금 시작 가능한지 알아야 한다. 그
|
|
592
|
+
사실은 Stage 원장에 있고, 상태 어휘(`done` / `active` / `ready` / `blocked`)는
|
|
593
|
+
`stage_targets.StageLifecycle.status` 의 것을 그대로 쓴다 — 원장이 자기 어휘를
|
|
594
|
+
따로 가지면 같은 stage 가 소비처마다 다르게 읽힌다.
|
|
595
|
+
|
|
596
|
+
``None`` 은 판정 근거가 없다는 뜻이고, 소비처는 좁히지 않는다. 첫 계획 run 이라
|
|
597
|
+
원장이 아직 없는 경우와 `--run-manifest` 를 안 넘긴 경우가 여기 해당한다.
|
|
598
|
+
"""
|
|
599
|
+
if run_manifest is None:
|
|
600
|
+
return None
|
|
601
|
+
try:
|
|
602
|
+
authority = validated_run_authority(run_manifest)
|
|
603
|
+
task_root = RunRef.from_run_dir(authority.run_dir).task_root
|
|
604
|
+
ledger = build_stage_ledger(task_root)
|
|
605
|
+
except (ConvergenceContractError, ValueError, OSError):
|
|
606
|
+
return None
|
|
607
|
+
if not isinstance(ledger, Mapping) or not isinstance(ledger.get("stages"), list):
|
|
608
|
+
return None
|
|
609
|
+
snapshot = {
|
|
610
|
+
str(row["stage"]): str(row["status"])
|
|
611
|
+
for row in ledger["stages"]
|
|
612
|
+
if isinstance(row, Mapping)
|
|
613
|
+
and isinstance(row.get("stage"), int)
|
|
614
|
+
and isinstance(row.get("status"), str)
|
|
615
|
+
}
|
|
616
|
+
return snapshot or None
|
|
617
|
+
|
|
618
|
+
|
|
337
619
|
def _seed(args: argparse.Namespace) -> dict[str, Any]:
|
|
338
620
|
"""Create the `planBodyVerification.planItems[]` rows a round lands in.
|
|
339
621
|
|
|
@@ -374,7 +656,12 @@ def _seed(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
374
656
|
key: item[key]
|
|
375
657
|
for key in ("id", "subject", "sourceSection")
|
|
376
658
|
if key in item
|
|
377
|
-
} |
|
|
659
|
+
} | (
|
|
660
|
+
{"stageScope": item["stageScope"]}
|
|
661
|
+
if item.get("stageScope") else {}
|
|
662
|
+
) | (
|
|
663
|
+
{"block": item["block"]} if item.get("block") else {}
|
|
664
|
+
) | {"verdicts": []}
|
|
378
665
|
for item in extracted
|
|
379
666
|
if item["id"] not in known
|
|
380
667
|
]
|
|
@@ -398,6 +685,13 @@ def _seed(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
398
685
|
for item in extracted
|
|
399
686
|
if item["id"] not in audited
|
|
400
687
|
)
|
|
688
|
+
ledger = _stage_ledger_snapshot(getattr(args, "run_manifest", None))
|
|
689
|
+
if ledger is not None:
|
|
690
|
+
verification = (
|
|
691
|
+
data["planBodyVerification"] if args.state is not None
|
|
692
|
+
else _planning(data)["planBodyVerification"]
|
|
693
|
+
)
|
|
694
|
+
verification["stageLedger"] = ledger
|
|
401
695
|
write_json_atomic(target, data)
|
|
402
696
|
return {
|
|
403
697
|
"ok": True,
|
|
@@ -408,23 +702,48 @@ def _seed(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
408
702
|
}
|
|
409
703
|
|
|
410
704
|
|
|
705
|
+
def _judged_row(row: Mapping[str, Any], project_root: Path | None) -> dict[str, Any]:
|
|
706
|
+
"""한 verdict 행에 재현 결과를 채워 돌려준다.
|
|
707
|
+
|
|
708
|
+
`reproductionResult` 는 들어온 값을 쓰지 않고 **항상 여기서 덮어쓴다.** 그
|
|
709
|
+
필드가 1표 차단의 자격을 결정하므로, 워커가 보낸 값을 그대로 실으면 검증자가
|
|
710
|
+
자기 주장의 판정을 스스로 적는 것이 된다. 계약이 "okstra 가 쓴다" 고 적은 것이
|
|
711
|
+
이 뜻이고, 그 문장은 이 덮어쓰기가 있어야 참이다.
|
|
712
|
+
|
|
713
|
+
프로젝트를 못 찾으면 `not-runnable` 이다 — 못 돌렸다는 사실이 기록되고, 그
|
|
714
|
+
주장은 정족수로 내려간다. 조용히 통과시키지도, 근거 없이 차단하지도 않는다.
|
|
715
|
+
"""
|
|
716
|
+
judged = dict(row)
|
|
717
|
+
if str(judged.get("claimKind") or "") != "fact":
|
|
718
|
+
judged.pop("reproductionResult", None)
|
|
719
|
+
return judged
|
|
720
|
+
judged["reproductionResult"] = (
|
|
721
|
+
reproduce(judged.get("reproduction"), project_root=project_root)
|
|
722
|
+
if project_root is not None
|
|
723
|
+
else NOT_RUNNABLE
|
|
724
|
+
)
|
|
725
|
+
return judged
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _probe_project_root(run_manifest: Path | None) -> Path | None:
|
|
729
|
+
if run_manifest is None:
|
|
730
|
+
return None
|
|
731
|
+
try:
|
|
732
|
+
return validated_run_authority(run_manifest).project_root
|
|
733
|
+
except ConvergenceContractError:
|
|
734
|
+
return None
|
|
735
|
+
|
|
736
|
+
|
|
411
737
|
def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
|
|
412
738
|
target = args.state or args.data
|
|
413
739
|
data = _load_json_object(target)
|
|
414
|
-
incoming = _load_json_object(args.verdicts).get("planItems")
|
|
415
|
-
if not isinstance(incoming, list):
|
|
416
|
-
raise PlanItemContractError("verdicts envelope has no `planItems` array")
|
|
417
|
-
rows = {
|
|
418
|
-
item["id"]: item.get("verdicts", [])
|
|
419
|
-
for item in incoming
|
|
420
|
-
if isinstance(item, Mapping) and isinstance(item.get("id"), str)
|
|
421
|
-
}
|
|
422
740
|
recorded = (
|
|
423
741
|
_state_plan_body_items(data, target)
|
|
424
742
|
if args.state is not None
|
|
425
743
|
else _plan_body_items(data, target)
|
|
426
744
|
)
|
|
427
745
|
known = {item.get("id") for item in recorded if isinstance(item, Mapping)}
|
|
746
|
+
rows = _incoming_verdict_rows(args, known)
|
|
428
747
|
missing = sorted(item_id for item_id in rows if item_id not in known)
|
|
429
748
|
if missing:
|
|
430
749
|
raise PlanItemContractError(
|
|
@@ -434,6 +753,7 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
434
753
|
)
|
|
435
754
|
if args.round_number < 1:
|
|
436
755
|
raise PlanItemContractError("--round must be 1 or greater")
|
|
756
|
+
project_root = _probe_project_root(getattr(args, "run_manifest", None))
|
|
437
757
|
for item in recorded:
|
|
438
758
|
if isinstance(item, Mapping) and item.get("id") in rows:
|
|
439
759
|
# Overwrite, never merge: the contract records one round at a time,
|
|
@@ -446,19 +766,189 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
446
766
|
# row's vintage. Stamping it here is what lets the validator tell a
|
|
447
767
|
# current judgement from one two rewrites old.
|
|
448
768
|
item["verdicts"] = [
|
|
449
|
-
{**row, "round": args.round_number}
|
|
769
|
+
{**_judged_row(row, project_root), "round": args.round_number}
|
|
770
|
+
for row in rows[item["id"]]
|
|
450
771
|
]
|
|
451
772
|
write_json_atomic(target, data)
|
|
452
773
|
return {"ok": True, "operation": "apply-verdicts", "path": str(target)}
|
|
453
774
|
|
|
454
775
|
|
|
776
|
+
def _round_snapshot(item: Mapping[str, Any], round_number: int) -> dict[str, Any]:
|
|
777
|
+
votes = {
|
|
778
|
+
str(row["worker"]): str(row["verdict"])
|
|
779
|
+
for row in item.get("verdicts", [])
|
|
780
|
+
if isinstance(row, Mapping) and row.get("round") == round_number
|
|
781
|
+
and isinstance(row.get("worker"), str) and isinstance(row.get("verdict"), str)
|
|
782
|
+
}
|
|
783
|
+
disagrees = sum(value.startswith("DISAGREE") for value in votes.values())
|
|
784
|
+
if votes and disagrees == 0:
|
|
785
|
+
classification = "full-consensus"
|
|
786
|
+
elif disagrees * 2 > len(votes):
|
|
787
|
+
classification = "majority-disagree"
|
|
788
|
+
else:
|
|
789
|
+
classification = "partial-consensus"
|
|
790
|
+
return {"round": round_number, "votes": votes, "classification": classification}
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _plan_gate_summary(verification: Mapping[str, Any]) -> dict[str, Any]:
|
|
794
|
+
path = Path(__file__).parents[2] / "validators" / "validate-run.py"
|
|
795
|
+
spec = importlib.util.spec_from_file_location("okstra_plan_gate", path)
|
|
796
|
+
if spec is None or spec.loader is None:
|
|
797
|
+
raise PlanItemContractError("cannot load plan-body gate authority")
|
|
798
|
+
module = importlib.util.module_from_spec(spec)
|
|
799
|
+
spec.loader.exec_module(module)
|
|
800
|
+
summary = module.plan_body_gate_summary({
|
|
801
|
+
"implementationPlanning": {"planBodyVerification": dict(verification)}
|
|
802
|
+
})
|
|
803
|
+
if not isinstance(summary, Mapping):
|
|
804
|
+
raise PlanItemContractError("plan-body gate authority returned no gate")
|
|
805
|
+
return dict(summary)
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def _record_self_fixes(args: argparse.Namespace, audit: list[object], verification: dict[str, Any]) -> None:
|
|
809
|
+
notes: dict[str, str] = {}
|
|
810
|
+
known = {item.get("id") for item in audit if isinstance(item, Mapping)}
|
|
811
|
+
if args.self_fix_stop_reason and not args.self_fix_group:
|
|
812
|
+
raise PlanItemContractError("--self-fix-stop-reason requires --self-fix-group")
|
|
813
|
+
for raw in args.self_fix_note:
|
|
814
|
+
item_id, separator, filename = raw.partition("=")
|
|
815
|
+
if not separator or not item_id or not filename:
|
|
816
|
+
raise PlanItemContractError("--self-fix-note must be <item-id>=<markdown-file>")
|
|
817
|
+
if item_id not in known:
|
|
818
|
+
raise PlanItemContractError(f"unknown self-fix item `{item_id}`")
|
|
819
|
+
try:
|
|
820
|
+
notes[item_id] = Path(filename).read_text(encoding="utf-8").strip()
|
|
821
|
+
except (OSError, UnicodeError) as exc:
|
|
822
|
+
raise PlanItemContractError(f"cannot read self-fix note {filename}: {exc}") from exc
|
|
823
|
+
for item in audit:
|
|
824
|
+
if isinstance(item, dict) and item.get("id") in notes:
|
|
825
|
+
item["selfFixNote"] = notes[item["id"]]
|
|
826
|
+
groups = []
|
|
827
|
+
for raw in args.self_fix_group:
|
|
828
|
+
filename, separator, item_ids = raw.partition("=")
|
|
829
|
+
if not separator or not filename or not item_ids:
|
|
830
|
+
raise PlanItemContractError("--self-fix-group must be <cause-file>=<item-id>[,<item-id>...]")
|
|
831
|
+
try:
|
|
832
|
+
cause = Path(filename).read_text(encoding="utf-8").strip()
|
|
833
|
+
except (OSError, UnicodeError) as exc:
|
|
834
|
+
raise PlanItemContractError(f"cannot read self-fix cause {filename}: {exc}") from exc
|
|
835
|
+
ids = [item for item in item_ids.split(",") if item]
|
|
836
|
+
unknown = sorted(set(ids) - known)
|
|
837
|
+
if unknown:
|
|
838
|
+
raise PlanItemContractError("unknown self-fix item(s): " + ", ".join(unknown))
|
|
839
|
+
groups.append({"round": args.round_number, "causeSummary": cause, "itemIds": ids})
|
|
840
|
+
if groups:
|
|
841
|
+
verification["selfFixGroups"] = groups
|
|
842
|
+
verification["selfFixRoundsApplied"] = args.round_number
|
|
843
|
+
verification["selfFixStopReason"] = args.self_fix_stop_reason or "all-resolved"
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _round_inputs(args: argparse.Namespace) -> tuple[dict[str, Any], list[dict[str, Any]], list[object], list[object]]:
|
|
847
|
+
data = _load_json_object(args.state)
|
|
848
|
+
audit, history = data.get("planItems"), data.get("roundHistory")
|
|
849
|
+
if not isinstance(audit, list) or not isinstance(history, list):
|
|
850
|
+
raise PlanItemContractError("state planItems and roundHistory must be arrays")
|
|
851
|
+
return data, _state_plan_body_items(data, args.state), audit, history
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def _round_snapshots(current: list[dict[str, Any]], verification: Mapping[str, Any], round_number: int) -> tuple[dict[str, dict[str, Any]], dict[str, Any]]:
|
|
855
|
+
snapshots = {str(item.get("id")): _round_snapshot(item, round_number) for item in current}
|
|
856
|
+
if not snapshots or any(not row["votes"] for row in snapshots.values()):
|
|
857
|
+
raise PlanItemContractError("every current plan item needs a verdict before completion")
|
|
858
|
+
summary = _plan_gate_summary(verification)
|
|
859
|
+
classes = {row["id"]: row["stateClassification"] for row in summary["items"]}
|
|
860
|
+
for item_id, snapshot in snapshots.items():
|
|
861
|
+
snapshot["classification"] = classes.get(item_id, snapshot["classification"])
|
|
862
|
+
return snapshots, summary
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _record_audit_round(audit: list[object], snapshots: Mapping[str, dict[str, Any]], round_number: int) -> None:
|
|
866
|
+
for item in audit:
|
|
867
|
+
if isinstance(item, dict) and str(item.get("id")) in snapshots:
|
|
868
|
+
rounds = item.get("rounds")
|
|
869
|
+
if not isinstance(rounds, list):
|
|
870
|
+
raise PlanItemContractError("state plan item rounds must be an array")
|
|
871
|
+
item["rounds"] = [row for row in rounds if row.get("round") != round_number]
|
|
872
|
+
item["rounds"].append(snapshots[str(item["id"])])
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def _participant_counts(args: argparse.Namespace, snapshots: Mapping[str, dict[str, Any]]) -> tuple[set[str], int, int]:
|
|
876
|
+
workers = {worker for row in snapshots.values() for worker in row["votes"]}
|
|
877
|
+
voting = {
|
|
878
|
+
worker for row in snapshots.values()
|
|
879
|
+
for worker, verdict in row["votes"].items()
|
|
880
|
+
if verdict != "verification-error"
|
|
881
|
+
}
|
|
882
|
+
assignments = _load_json_object(args.run_manifest).get("workerAssignments")
|
|
883
|
+
if not isinstance(assignments, list):
|
|
884
|
+
raise PlanItemContractError("run manifest workerAssignments must be an array")
|
|
885
|
+
rostered = sum(1 for row in assignments if isinstance(row, Mapping)
|
|
886
|
+
and isinstance(row.get("workerId"), str) and row["workerId"] != "report-writer")
|
|
887
|
+
return workers, len(voting), rostered
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def _complete_round(args: argparse.Namespace) -> dict[str, Any]:
|
|
891
|
+
if args.round_number < 1:
|
|
892
|
+
raise PlanItemContractError("--round must be 1 or greater")
|
|
893
|
+
data, current, audit, history = _round_inputs(args)
|
|
894
|
+
verification = data["planBodyVerification"]
|
|
895
|
+
snapshots, summary = _round_snapshots(current, verification, args.round_number)
|
|
896
|
+
_record_audit_round(audit, snapshots, args.round_number)
|
|
897
|
+
gate = str(summary["recomputed"])
|
|
898
|
+
workers, voting, rostered = _participant_counts(args, snapshots)
|
|
899
|
+
uniform = [
|
|
900
|
+
{"worker": worker, "verdict": values[0], "itemCount": len(values)}
|
|
901
|
+
for worker in workers
|
|
902
|
+
for values in [[row["votes"][worker] for row in snapshots.values()]]
|
|
903
|
+
if len(set(values)) == 1
|
|
904
|
+
]
|
|
905
|
+
verification.update({"roundCount": max(int(verification.get("roundCount", 0)), args.round_number),
|
|
906
|
+
"gateResult": gate, "gateBlockedBy": summary["blockedBy"],
|
|
907
|
+
# 무엇이 막았는지는 `gateBlockedBy` 가, 무엇이 막지 않았고
|
|
908
|
+
# 왜인지는 이 쪽이 적는다. 둘 다 같은 계산에서 나온다.
|
|
909
|
+
"setAside": summary["setAside"],
|
|
910
|
+
"participatingAnalysers": {"rostered": rostered, "voting": voting},
|
|
911
|
+
"uniformVerifiers": uniform})
|
|
912
|
+
_record_self_fixes(args, audit, verification)
|
|
913
|
+
if args.self_fix_group:
|
|
914
|
+
data["selfFixRoundsApplied"] = args.round_number
|
|
915
|
+
history[:] = [row for row in history if row.get("round") != args.round_number]
|
|
916
|
+
history.append({"round": args.round_number, "completedAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "gateResult": gate, "gateBlockedBy": verification["gateBlockedBy"]})
|
|
917
|
+
write_json_atomic(args.state, data)
|
|
918
|
+
return {"ok": True, "operation": "complete-round", "path": str(args.state), "gateResult": gate}
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def _incoming_verdict_rows(
|
|
922
|
+
args: argparse.Namespace, known: set[object],
|
|
923
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
924
|
+
if args.verdicts is not None:
|
|
925
|
+
incoming = _load_json_object(args.verdicts).get("planItems")
|
|
926
|
+
if not isinstance(incoming, list):
|
|
927
|
+
raise PlanItemContractError("verdicts envelope has no `planItems` array")
|
|
928
|
+
return {
|
|
929
|
+
item["id"]: item.get("verdicts", [])
|
|
930
|
+
for item in incoming
|
|
931
|
+
if isinstance(item, Mapping) and isinstance(item.get("id"), str)
|
|
932
|
+
}
|
|
933
|
+
assigned = {item_id for item_id in known if isinstance(item_id, str)}
|
|
934
|
+
collected = _worker_blocks(args.result, assigned)
|
|
935
|
+
return {
|
|
936
|
+
item_id: [_verdict_row(worker, blocks[item_id]) for worker, blocks in collected]
|
|
937
|
+
for item_id in assigned
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
|
|
455
941
|
_HANDLERS = {
|
|
456
942
|
"extract": _extract,
|
|
457
943
|
"validate": _validate,
|
|
944
|
+
"prepare": _prepare,
|
|
945
|
+
"prompt": _prompt,
|
|
946
|
+
"validate-prepared": _validate_prepared,
|
|
458
947
|
"collect-verdicts": _collect_verdicts,
|
|
459
948
|
"derivations": _derivations,
|
|
460
949
|
"seed": _seed,
|
|
461
950
|
"apply-verdicts": _apply_verdicts,
|
|
951
|
+
"complete-round": _complete_round,
|
|
462
952
|
}
|
|
463
953
|
|
|
464
954
|
|
|
@@ -469,7 +959,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
469
959
|
except (PlanItemContractError, VerdictBlockError, OSError, ValueError) as exc:
|
|
470
960
|
print(f"plan-items: {exc}", file=sys.stderr)
|
|
471
961
|
return 2
|
|
472
|
-
|
|
962
|
+
if isinstance(result, str):
|
|
963
|
+
print(result, end="")
|
|
964
|
+
elif args.command in {"prepare", "validate-prepared"}:
|
|
965
|
+
print("Plan items\n" + line("Status", "ready") + line("Operation", result["operation"]), end="")
|
|
966
|
+
else:
|
|
967
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
473
968
|
return 0
|
|
474
969
|
|
|
475
970
|
|