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
|
@@ -7,15 +7,28 @@ 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
|
|
30
|
+
from .final_report_schema import load_schema_version
|
|
31
|
+
from .report_narrative import parse_narrative
|
|
19
32
|
from .user_response import parse_user_response_entries
|
|
20
33
|
from .verdict_blocks import (
|
|
21
34
|
PLAN_ITEM_VERDICTS,
|
|
@@ -23,16 +36,14 @@ from .verdict_blocks import (
|
|
|
23
36
|
VerdictBlockError,
|
|
24
37
|
parse_verdict_blocks,
|
|
25
38
|
)
|
|
39
|
+
from .fixed_text import line, scalar
|
|
26
40
|
|
|
27
41
|
|
|
28
42
|
def _load_json_object(path: Path) -> dict[str, Any]:
|
|
29
43
|
try:
|
|
30
|
-
|
|
31
|
-
except
|
|
32
|
-
raise PlanItemContractError(
|
|
33
|
-
if not isinstance(value, dict):
|
|
34
|
-
raise PlanItemContractError(f"JSON input must be an object: {path}")
|
|
35
|
-
return value
|
|
44
|
+
return load_owned_object(path, artifact="plan-items artifact")
|
|
45
|
+
except JsonBoundaryError as exc:
|
|
46
|
+
raise PlanItemContractError(str(exc)) from exc
|
|
36
47
|
|
|
37
48
|
|
|
38
49
|
def _planning(data: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
@@ -57,11 +68,19 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
57
68
|
)
|
|
58
69
|
commands = parser.add_subparsers(dest="command", required=True)
|
|
59
70
|
extract = commands.add_parser("extract")
|
|
60
|
-
extract
|
|
71
|
+
_add_plan_source(extract)
|
|
61
72
|
extract.add_argument("--output", type=Path, required=True)
|
|
62
73
|
validate = commands.add_parser("validate")
|
|
63
|
-
validate
|
|
74
|
+
_add_plan_source(validate)
|
|
64
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)
|
|
65
84
|
collect = commands.add_parser(
|
|
66
85
|
"collect-verdicts",
|
|
67
86
|
help="read this round's worker responses into a verdicts envelope",
|
|
@@ -84,35 +103,286 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
84
103
|
"seed",
|
|
85
104
|
help="create the planBodyVerification.planItems[] rows a round lands in",
|
|
86
105
|
)
|
|
87
|
-
seed
|
|
106
|
+
_add_plan_source(seed)
|
|
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
|
+
)
|
|
88
113
|
apply_verdicts = commands.add_parser(
|
|
89
114
|
"apply-verdicts",
|
|
90
|
-
help="overwrite planBodyVerification.planItems[].verdicts in
|
|
115
|
+
help="overwrite planBodyVerification.planItems[].verdicts in owned state",
|
|
116
|
+
)
|
|
117
|
+
target = apply_verdicts.add_mutually_exclusive_group(required=True)
|
|
118
|
+
target.add_argument("--data", type=Path)
|
|
119
|
+
target.add_argument("--state", type=Path)
|
|
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",
|
|
91
135
|
)
|
|
92
|
-
apply_verdicts.add_argument("--data", type=Path, required=True)
|
|
93
|
-
apply_verdicts.add_argument("--verdicts", type=Path, required=True)
|
|
94
136
|
apply_verdicts.add_argument(
|
|
95
137
|
"--round", type=int, required=True, dest="round_number",
|
|
96
138
|
help="the verification round these verdicts were cast in; stamped on "
|
|
97
139
|
"every row so a later self-fix can be told from a current judgement",
|
|
98
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"))
|
|
99
151
|
return parser
|
|
100
152
|
|
|
101
153
|
|
|
154
|
+
def _add_plan_source(parser: argparse.ArgumentParser) -> None:
|
|
155
|
+
source = parser.add_mutually_exclusive_group(required=True)
|
|
156
|
+
source.add_argument("--data", type=Path)
|
|
157
|
+
source.add_argument("--narrative", type=Path)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _plan_source(args: argparse.Namespace) -> dict[str, Any]:
|
|
161
|
+
if args.data is not None:
|
|
162
|
+
return _load_json_object(args.data)
|
|
163
|
+
try:
|
|
164
|
+
markdown = args.narrative.read_text(encoding="utf-8")
|
|
165
|
+
except (OSError, UnicodeError) as exc:
|
|
166
|
+
raise PlanItemContractError(
|
|
167
|
+
f"cannot read report narrative {args.narrative}: {exc}"
|
|
168
|
+
) from exc
|
|
169
|
+
try:
|
|
170
|
+
return parse_narrative(markdown, load_schema_version("3.0"))
|
|
171
|
+
except ValueError as exc:
|
|
172
|
+
raise PlanItemContractError(f"invalid report narrative: {exc}") from exc
|
|
173
|
+
|
|
174
|
+
|
|
102
175
|
def _extract(args: argparse.Namespace) -> dict[str, Any]:
|
|
103
|
-
envelope = _envelope(
|
|
176
|
+
envelope = _envelope(_plan_source(args))
|
|
104
177
|
write_json_atomic(args.output, envelope)
|
|
105
178
|
return {"ok": True, "operation": "extract", "path": str(args.output)}
|
|
106
179
|
|
|
107
180
|
|
|
108
181
|
def _validate(args: argparse.Namespace) -> dict[str, Any]:
|
|
109
|
-
expected = _envelope(
|
|
182
|
+
expected = _envelope(_plan_source(args))
|
|
110
183
|
actual = _load_json_object(args.items)
|
|
111
184
|
if actual != expected:
|
|
112
185
|
raise PlanItemContractError("plan items envelope does not match deterministic extraction")
|
|
113
186
|
return {"ok": True, "operation": "validate", "path": str(args.items)}
|
|
114
187
|
|
|
115
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
|
+
|
|
116
386
|
def _split_result_arg(raw: str) -> tuple[str, Path]:
|
|
117
387
|
worker, separator, path = raw.partition("=")
|
|
118
388
|
if not separator or not worker.strip() or not path.strip():
|
|
@@ -169,8 +439,15 @@ def _worker_blocks(
|
|
|
169
439
|
parser; each is a round scored on a table that does not match the queue.
|
|
170
440
|
"""
|
|
171
441
|
collected: list[tuple[str, dict[str, VerdictBlock]]] = []
|
|
442
|
+
seen_workers: set[str] = set()
|
|
172
443
|
for raw in raw_results:
|
|
173
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)
|
|
174
451
|
try:
|
|
175
452
|
blocks = parse_verdict_blocks(path.read_text(encoding="utf-8"))
|
|
176
453
|
except (OSError, UnicodeError) as exc:
|
|
@@ -229,6 +506,45 @@ def _plan_body_items(data: dict[str, Any], data_path: Path) -> list[dict[str, An
|
|
|
229
506
|
return items
|
|
230
507
|
|
|
231
508
|
|
|
509
|
+
def _state_plan_body_items(
|
|
510
|
+
state: dict[str, Any], state_path: Path,
|
|
511
|
+
) -> list[dict[str, Any]]:
|
|
512
|
+
if state.get("owner") != "convergence":
|
|
513
|
+
raise PlanItemContractError(
|
|
514
|
+
f"plan-body state owner must be convergence: {state_path}"
|
|
515
|
+
)
|
|
516
|
+
verification = state.get("planBodyVerification")
|
|
517
|
+
if not isinstance(verification, Mapping):
|
|
518
|
+
raise PlanItemContractError(
|
|
519
|
+
f"planBodyVerification must be an object: {state_path}"
|
|
520
|
+
)
|
|
521
|
+
items = verification.get("planItems")
|
|
522
|
+
if not isinstance(items, list):
|
|
523
|
+
raise PlanItemContractError(
|
|
524
|
+
f"planBodyVerification.planItems must be an array: {state_path}"
|
|
525
|
+
)
|
|
526
|
+
return items
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _new_v3_state() -> dict[str, Any]:
|
|
530
|
+
return {
|
|
531
|
+
"schemaVersion": "1.1",
|
|
532
|
+
"owner": "convergence",
|
|
533
|
+
"planItems": [],
|
|
534
|
+
"roundHistory": [],
|
|
535
|
+
"selfFixRoundsApplied": 0,
|
|
536
|
+
"planBodyVerification": {
|
|
537
|
+
"roundCount": 0,
|
|
538
|
+
"gateResult": "passed",
|
|
539
|
+
"gateBlockedBy": [],
|
|
540
|
+
"selfFixRoundsApplied": 0,
|
|
541
|
+
"selfFixStopReason": "not-attempted",
|
|
542
|
+
"planItems": [],
|
|
543
|
+
"dissentLog": [],
|
|
544
|
+
},
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
|
|
232
548
|
def _derivations(args: argparse.Namespace) -> dict[str, Any]:
|
|
233
549
|
"""Candidate statements each answered clarification may have falsified.
|
|
234
550
|
|
|
@@ -269,6 +585,37 @@ def _derivations(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
269
585
|
}
|
|
270
586
|
|
|
271
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
|
+
|
|
272
619
|
def _seed(args: argparse.Namespace) -> dict[str, Any]:
|
|
273
620
|
"""Create the `planBodyVerification.planItems[]` rows a round lands in.
|
|
274
621
|
|
|
@@ -283,9 +630,17 @@ def _seed(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
283
630
|
already applied, `carriedForwardFromSeq`, `selfFixNote` — because a re-seed
|
|
284
631
|
between rounds must not erase the round before it.
|
|
285
632
|
"""
|
|
286
|
-
extracted = _envelope(
|
|
287
|
-
|
|
288
|
-
|
|
633
|
+
extracted = _envelope(_plan_source(args))["items"]
|
|
634
|
+
if args.state is not None:
|
|
635
|
+
data = _load_json_object(args.state) if args.state.is_file() else _new_v3_state()
|
|
636
|
+
recorded = _state_plan_body_items(data, args.state)
|
|
637
|
+
target = args.state
|
|
638
|
+
else:
|
|
639
|
+
if args.data is None:
|
|
640
|
+
raise PlanItemContractError("--narrative requires --state")
|
|
641
|
+
data = _load_json_object(args.data)
|
|
642
|
+
recorded = _plan_body_items(data, args.data)
|
|
643
|
+
target = args.data
|
|
289
644
|
known = {
|
|
290
645
|
item.get("id")
|
|
291
646
|
for item in recorded
|
|
@@ -301,33 +656,94 @@ def _seed(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
301
656
|
key: item[key]
|
|
302
657
|
for key in ("id", "subject", "sourceSection")
|
|
303
658
|
if key in item
|
|
304
|
-
} |
|
|
659
|
+
} | (
|
|
660
|
+
{"stageScope": item["stageScope"]}
|
|
661
|
+
if item.get("stageScope") else {}
|
|
662
|
+
) | (
|
|
663
|
+
{"block": item["block"]} if item.get("block") else {}
|
|
664
|
+
) | {"verdicts": []}
|
|
305
665
|
for item in extracted
|
|
306
666
|
if item["id"] not in known
|
|
307
667
|
]
|
|
308
668
|
recorded.extend(added)
|
|
309
|
-
|
|
669
|
+
if args.state is not None:
|
|
670
|
+
audit_rows = data.get("planItems")
|
|
671
|
+
if not isinstance(audit_rows, list):
|
|
672
|
+
raise PlanItemContractError("state planItems must be an array")
|
|
673
|
+
audited = {
|
|
674
|
+
item.get("id") for item in audit_rows if isinstance(item, Mapping)
|
|
675
|
+
}
|
|
676
|
+
audit_rows.extend(
|
|
677
|
+
{
|
|
678
|
+
"id": item["id"],
|
|
679
|
+
"subject": item["subject"],
|
|
680
|
+
"sourceSection": item["sourceSection"],
|
|
681
|
+
"ticketId": item.get("ticketId") or "unknown",
|
|
682
|
+
"rounds": [],
|
|
683
|
+
"clarificationId": None,
|
|
684
|
+
}
|
|
685
|
+
for item in extracted
|
|
686
|
+
if item["id"] not in audited
|
|
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
|
|
695
|
+
write_json_atomic(target, data)
|
|
310
696
|
return {
|
|
311
697
|
"ok": True,
|
|
312
698
|
"operation": "seed",
|
|
313
|
-
"path": str(
|
|
699
|
+
"path": str(target),
|
|
314
700
|
"seeded": len(added),
|
|
315
701
|
"existing": len(known),
|
|
316
702
|
}
|
|
317
703
|
|
|
318
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
|
+
|
|
319
737
|
def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
if isinstance(item, Mapping) and isinstance(item.get("id"), str)
|
|
328
|
-
}
|
|
329
|
-
recorded = _plan_body_items(data, args.data)
|
|
738
|
+
target = args.state or args.data
|
|
739
|
+
data = _load_json_object(target)
|
|
740
|
+
recorded = (
|
|
741
|
+
_state_plan_body_items(data, target)
|
|
742
|
+
if args.state is not None
|
|
743
|
+
else _plan_body_items(data, target)
|
|
744
|
+
)
|
|
330
745
|
known = {item.get("id") for item in recorded if isinstance(item, Mapping)}
|
|
746
|
+
rows = _incoming_verdict_rows(args, known)
|
|
331
747
|
missing = sorted(item_id for item_id in rows if item_id not in known)
|
|
332
748
|
if missing:
|
|
333
749
|
raise PlanItemContractError(
|
|
@@ -337,6 +753,7 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
337
753
|
)
|
|
338
754
|
if args.round_number < 1:
|
|
339
755
|
raise PlanItemContractError("--round must be 1 or greater")
|
|
756
|
+
project_root = _probe_project_root(getattr(args, "run_manifest", None))
|
|
340
757
|
for item in recorded:
|
|
341
758
|
if isinstance(item, Mapping) and item.get("id") in rows:
|
|
342
759
|
# Overwrite, never merge: the contract records one round at a time,
|
|
@@ -349,19 +766,189 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
349
766
|
# row's vintage. Stamping it here is what lets the validator tell a
|
|
350
767
|
# current judgement from one two rewrites old.
|
|
351
768
|
item["verdicts"] = [
|
|
352
|
-
{**row, "round": args.round_number}
|
|
769
|
+
{**_judged_row(row, project_root), "round": args.round_number}
|
|
770
|
+
for row in rows[item["id"]]
|
|
353
771
|
]
|
|
354
|
-
write_json_atomic(
|
|
355
|
-
return {"ok": True, "operation": "apply-verdicts", "path": str(
|
|
772
|
+
write_json_atomic(target, data)
|
|
773
|
+
return {"ok": True, "operation": "apply-verdicts", "path": str(target)}
|
|
774
|
+
|
|
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
|
+
}
|
|
356
939
|
|
|
357
940
|
|
|
358
941
|
_HANDLERS = {
|
|
359
942
|
"extract": _extract,
|
|
360
943
|
"validate": _validate,
|
|
944
|
+
"prepare": _prepare,
|
|
945
|
+
"prompt": _prompt,
|
|
946
|
+
"validate-prepared": _validate_prepared,
|
|
361
947
|
"collect-verdicts": _collect_verdicts,
|
|
362
948
|
"derivations": _derivations,
|
|
363
949
|
"seed": _seed,
|
|
364
950
|
"apply-verdicts": _apply_verdicts,
|
|
951
|
+
"complete-round": _complete_round,
|
|
365
952
|
}
|
|
366
953
|
|
|
367
954
|
|
|
@@ -372,7 +959,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
372
959
|
except (PlanItemContractError, VerdictBlockError, OSError, ValueError) as exc:
|
|
373
960
|
print(f"plan-items: {exc}", file=sys.stderr)
|
|
374
961
|
return 2
|
|
375
|
-
|
|
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))
|
|
376
968
|
return 0
|
|
377
969
|
|
|
378
970
|
|