okstra 0.183.2 → 0.185.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/cli-registry.mjs +9 -0
- package/dist/cli-registry.mjs.map +1 -1
- package/dist/commands/chat/chat.d.mts +1 -0
- package/dist/commands/chat/chat.mjs +385 -0
- package/dist/commands/chat/chat.mjs.map +1 -0
- package/dist/lib/skill-catalog.mjs +1 -0
- package/dist/lib/skill-catalog.mjs.map +1 -1
- package/docs/architecture.md +10 -8
- package/docs/cli.md +9 -5
- package/docs/for-ai/README.md +4 -2
- package/docs/for-ai/skills/okstra-chat.md +28 -0
- package/docs/for-ai/skills/okstra-inspect.md +1 -1
- package/docs/for-ai/skills/okstra-run.md +2 -2
- package/docs/for-ai/skills/okstra-user-response.md +10 -8
- package/docs/project-structure-overview.md +6 -5
- package/docs/task-process/README.md +2 -2
- package/docs/task-process/common-flow.md +2 -3
- package/docs/task-process/error-analysis.md +3 -4
- package/docs/task-process/final-verification.md +2 -3
- package/docs/task-process/implementation-planning.md +3 -4
- package/docs/task-process/implementation.md +2 -3
- package/docs/task-process/release-handoff.md +3 -4
- package/docs/task-process/requirements-discovery.md +3 -4
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/launch.template.md +8 -7
- package/runtime/prompts/lead/okstra-lead-contract.md +7 -6
- package/runtime/prompts/lead/plan-body-verification.md +27 -19
- package/runtime/prompts/lead/report-writer.md +4 -4
- package/runtime/prompts/profiles/_clarification-recommendation.md +2 -2
- package/runtime/prompts/profiles/_implementation-executor.md +1 -0
- package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +11 -12
- package/runtime/prompts/wizard/prompts.ko.json +9 -10
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +2 -2
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +1 -1
- package/runtime/python/okstra_ctl/conformance.py +37 -1
- package/runtime/python/okstra_ctl/incremental_scope.py +84 -39
- package/runtime/python/okstra_ctl/next_phase.py +67 -4
- package/runtime/python/okstra_ctl/plan_items.py +410 -1
- package/runtime/python/okstra_ctl/plan_items_cli.py +346 -31
- package/runtime/python/okstra_ctl/render.py +4 -0
- package/runtime/python/okstra_ctl/user_response.py +147 -37
- package/runtime/python/okstra_ctl/wizard.py +52 -73
- package/runtime/schemas/final-report-v2.0.schema.json +12 -0
- package/runtime/schemas/final-report-v3.0.schema.json +12 -0
- package/runtime/skills/okstra-chat/SKILL.md +104 -0
- package/runtime/skills/okstra-inspect/facets/status.md +6 -5
- package/runtime/skills/okstra-run/SKILL.md +4 -4
- package/runtime/skills/okstra-user-response/SKILL.md +50 -16
- package/runtime/validators/validate-run.py +254 -81
- package/runtime/validators/validate_session_conformance.py +24 -5
|
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
import argparse
|
|
10
10
|
import importlib.util
|
|
11
11
|
import json
|
|
12
|
-
from collections.abc import Mapping
|
|
12
|
+
from collections.abc import Mapping, Sequence
|
|
13
13
|
from datetime import datetime, timezone
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
import sys
|
|
@@ -22,8 +22,21 @@ from .convergence import (
|
|
|
22
22
|
)
|
|
23
23
|
from .convergence_store import write_json_atomic
|
|
24
24
|
from .json_boundary import JsonBoundaryError, load_owned_object
|
|
25
|
+
from .report_finalize import task_manifest_path
|
|
25
26
|
from .plan_derivations import extract_tokens, find_derivations
|
|
26
|
-
from .plan_items import
|
|
27
|
+
from .plan_items import (
|
|
28
|
+
NextDispatch,
|
|
29
|
+
PlanItemContractError,
|
|
30
|
+
advisory_plan_body_gating,
|
|
31
|
+
content_hash,
|
|
32
|
+
correction_prompt_text,
|
|
33
|
+
dispatch_item_ids,
|
|
34
|
+
extract_plan_items,
|
|
35
|
+
next_dispatch,
|
|
36
|
+
planning_stage_ledger,
|
|
37
|
+
reverify_item_ids,
|
|
38
|
+
tie_vote_item_ids,
|
|
39
|
+
)
|
|
27
40
|
from .paths import RunRef
|
|
28
41
|
from .stage_ledger import build_stage_ledger
|
|
29
42
|
from .claim_reproduction import NOT_RUNNABLE, reproduce
|
|
@@ -61,6 +74,64 @@ def _envelope(data: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
61
74
|
}
|
|
62
75
|
|
|
63
76
|
|
|
77
|
+
def _merged_ledger(
|
|
78
|
+
planning: Mapping[str, Any],
|
|
79
|
+
run_manifest: Path | None,
|
|
80
|
+
) -> dict[str, str]:
|
|
81
|
+
return planning_stage_ledger(planning, _stage_ledger_snapshot(run_manifest))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _previous_hashes(state_path: Path | None) -> dict[str, str]:
|
|
85
|
+
if state_path is None or not state_path.is_file():
|
|
86
|
+
return {}
|
|
87
|
+
rows = _state_plan_body_items(_load_json_object(state_path), state_path)
|
|
88
|
+
return {
|
|
89
|
+
str(row["id"]): str(row["verifiedContentHash"])
|
|
90
|
+
for row in rows
|
|
91
|
+
if isinstance(row, Mapping)
|
|
92
|
+
and isinstance(row.get("id"), str)
|
|
93
|
+
and isinstance(row.get("verifiedContentHash"), str)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _queue_for(
|
|
98
|
+
items: list[dict[str, Any]],
|
|
99
|
+
planning: Mapping[str, Any],
|
|
100
|
+
run_manifest: Path | None,
|
|
101
|
+
previous_hashes: Mapping[str, str] | None = None,
|
|
102
|
+
tied_ids: Sequence[str] | None = None,
|
|
103
|
+
) -> list[str]:
|
|
104
|
+
ledger = _merged_ledger(planning, run_manifest)
|
|
105
|
+
if tied_ids is not None:
|
|
106
|
+
return tie_vote_item_ids(items, ledger, tied_ids)
|
|
107
|
+
if previous_hashes:
|
|
108
|
+
return reverify_item_ids(items, previous_hashes, ledger)
|
|
109
|
+
return dispatch_item_ids(items, ledger)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _queue_kwargs(args: argparse.Namespace) -> dict[str, Any]:
|
|
113
|
+
if getattr(args, "tie_vote", False):
|
|
114
|
+
state = getattr(args, "state", None)
|
|
115
|
+
if state is None:
|
|
116
|
+
raise PlanItemContractError("--tie-vote requires --state")
|
|
117
|
+
return {"tied_ids": _tied_ids(state)}
|
|
118
|
+
return {"previous_hashes": _previous_hashes(getattr(args, "state", None))}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _tied_ids(state_path: Path) -> list[str]:
|
|
122
|
+
if not state_path.is_file():
|
|
123
|
+
raise PlanItemContractError("--tie-vote requires --state with recorded verdicts")
|
|
124
|
+
items = _state_plan_body_items(_load_json_object(state_path), state_path)
|
|
125
|
+
gate = _gate_module()
|
|
126
|
+
return [
|
|
127
|
+
str(item["id"])
|
|
128
|
+
for item in items
|
|
129
|
+
if isinstance(item, Mapping)
|
|
130
|
+
and isinstance(item.get("id"), str)
|
|
131
|
+
and gate._is_unsettled_tie(dict(item))
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
|
|
64
135
|
def _parser() -> argparse.ArgumentParser:
|
|
65
136
|
parser = argparse.ArgumentParser(
|
|
66
137
|
prog="okstra plan-items",
|
|
@@ -76,11 +147,28 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
76
147
|
prepare = commands.add_parser("prepare")
|
|
77
148
|
_add_plan_source(prepare)
|
|
78
149
|
prepare.add_argument("--run-manifest", type=Path, required=True)
|
|
150
|
+
prepare.add_argument(
|
|
151
|
+
"--state", type=Path,
|
|
152
|
+
help="previous plan-body state; when it carries verifiedContentHash "
|
|
153
|
+
"rows the dispatch queue shrinks to the self-fix reverify set",
|
|
154
|
+
)
|
|
155
|
+
prepare.add_argument(
|
|
156
|
+
"--tie-vote", action="store_true",
|
|
157
|
+
help="dispatch queue is the needs-reverify ties in --state, nothing else",
|
|
158
|
+
)
|
|
79
159
|
prompt = commands.add_parser("prompt")
|
|
80
160
|
prompt.add_argument("--run-manifest", type=Path, required=True)
|
|
81
161
|
validate_prepared = commands.add_parser("validate-prepared")
|
|
82
162
|
_add_plan_source(validate_prepared)
|
|
83
163
|
validate_prepared.add_argument("--run-manifest", type=Path, required=True)
|
|
164
|
+
validate_prepared.add_argument(
|
|
165
|
+
"--state", type=Path,
|
|
166
|
+
help="same previous state prepare used to shrink the dispatch queue",
|
|
167
|
+
)
|
|
168
|
+
validate_prepared.add_argument(
|
|
169
|
+
"--tie-vote", action="store_true",
|
|
170
|
+
help="same --tie-vote prepare used to shrink the dispatch queue",
|
|
171
|
+
)
|
|
84
172
|
collect = commands.add_parser(
|
|
85
173
|
"collect-verdicts",
|
|
86
174
|
help="read this round's worker responses into a verdicts envelope",
|
|
@@ -138,6 +226,10 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
138
226
|
help="the verification round these verdicts were cast in; stamped on "
|
|
139
227
|
"every row so a later self-fix can be told from a current judgement",
|
|
140
228
|
)
|
|
229
|
+
apply_verdicts.add_argument(
|
|
230
|
+
"--append", action="store_true",
|
|
231
|
+
help="add these votes to existing rows instead of replacing the round",
|
|
232
|
+
)
|
|
141
233
|
complete = commands.add_parser(
|
|
142
234
|
"complete-round",
|
|
143
235
|
help="derive and atomically record one verified plan-body round",
|
|
@@ -148,9 +240,26 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
148
240
|
complete.add_argument("--self-fix-note", action="append", default=[], metavar="<item-id>=<markdown-file>")
|
|
149
241
|
complete.add_argument("--self-fix-group", action="append", default=[], metavar="<cause-file>=<item-id>[,<item-id>...]")
|
|
150
242
|
complete.add_argument("--self-fix-stop-reason", choices=("all-resolved", "no-progress", "max-rounds-reached"))
|
|
243
|
+
_add_dispatch_commands(commands)
|
|
151
244
|
return parser
|
|
152
245
|
|
|
153
246
|
|
|
247
|
+
def _add_dispatch_commands(commands: Any) -> None:
|
|
248
|
+
nxt = commands.add_parser(
|
|
249
|
+
"next-dispatch",
|
|
250
|
+
help="decide whether this round opens a worker batch",
|
|
251
|
+
)
|
|
252
|
+
nxt.add_argument("--state", type=Path, required=True)
|
|
253
|
+
nxt.add_argument("--run-manifest", type=Path)
|
|
254
|
+
correction = commands.add_parser(
|
|
255
|
+
"correction-prompt",
|
|
256
|
+
help="environment-exception preamble then the assigned queue",
|
|
257
|
+
)
|
|
258
|
+
correction.add_argument("--state", type=Path, required=True)
|
|
259
|
+
correction.add_argument("--run-manifest", type=Path, required=True)
|
|
260
|
+
correction.add_argument("--worker", required=True)
|
|
261
|
+
|
|
262
|
+
|
|
154
263
|
def _add_plan_source(parser: argparse.ArgumentParser) -> None:
|
|
155
264
|
source = parser.add_mutually_exclusive_group(required=True)
|
|
156
265
|
source.add_argument("--data", type=Path)
|
|
@@ -211,11 +320,43 @@ def _prepared_items_path(
|
|
|
211
320
|
return path
|
|
212
321
|
|
|
213
322
|
|
|
323
|
+
def _sync_task_manifest_gating(run_manifest: Path, gating: bool) -> None:
|
|
324
|
+
"""준비 이후 매니페스트 ``gating`` 을 계획 사실로 맞춘다."""
|
|
325
|
+
try:
|
|
326
|
+
authority = validated_run_authority(run_manifest)
|
|
327
|
+
path = task_manifest_path(authority.project_root, authority.payload)
|
|
328
|
+
payload = load_owned_object(path, artifact="task manifest")
|
|
329
|
+
except (ConvergenceContractError, JsonBoundaryError, OSError, ValueError):
|
|
330
|
+
return
|
|
331
|
+
block = payload.get("convergence")
|
|
332
|
+
if not isinstance(block, dict):
|
|
333
|
+
return
|
|
334
|
+
pbv = block.get("planBodyVerification")
|
|
335
|
+
if not isinstance(pbv, dict):
|
|
336
|
+
return
|
|
337
|
+
pbv["gating"] = gating
|
|
338
|
+
write_json_atomic(path, payload)
|
|
339
|
+
|
|
340
|
+
|
|
214
341
|
def _prepare(args: argparse.Namespace) -> dict[str, Any]:
|
|
215
342
|
output = _prepared_items_path(args.run_manifest)
|
|
216
|
-
|
|
343
|
+
source = _plan_source(args)
|
|
344
|
+
envelope = _envelope(source)
|
|
345
|
+
envelope["dispatchQueue"] = _queue_for(
|
|
346
|
+
envelope["items"],
|
|
347
|
+
_planning(source),
|
|
348
|
+
args.run_manifest,
|
|
349
|
+
**_queue_kwargs(args),
|
|
350
|
+
)
|
|
351
|
+
gating = not advisory_plan_body_gating(_planning(source), envelope["items"])
|
|
352
|
+
_sync_task_manifest_gating(args.run_manifest, gating)
|
|
217
353
|
write_json_atomic(output, envelope)
|
|
218
|
-
return {
|
|
354
|
+
return {
|
|
355
|
+
"ok": True,
|
|
356
|
+
"operation": "prepare",
|
|
357
|
+
"path": str(output),
|
|
358
|
+
"gating": gating,
|
|
359
|
+
}
|
|
219
360
|
|
|
220
361
|
|
|
221
362
|
_PAYLOAD_FIELDS = {
|
|
@@ -364,6 +505,13 @@ def _prompt(args: argparse.Namespace) -> str:
|
|
|
364
505
|
_prepared_items_path(args.run_manifest, require_regular=True)
|
|
365
506
|
)
|
|
366
507
|
items = envelope.get("items") if isinstance(envelope.get("items"), list) else []
|
|
508
|
+
queue = envelope.get("dispatchQueue")
|
|
509
|
+
if isinstance(queue, list):
|
|
510
|
+
allowed = {item_id for item_id in queue if isinstance(item_id, str)}
|
|
511
|
+
items = [
|
|
512
|
+
item for item in items
|
|
513
|
+
if isinstance(item, Mapping) and item.get("id") in allowed
|
|
514
|
+
]
|
|
367
515
|
rows = ["# Plan verification queue\n", line("Item count", len(items))]
|
|
368
516
|
for index, item in enumerate(items, 1):
|
|
369
517
|
if not isinstance(item, Mapping):
|
|
@@ -376,10 +524,22 @@ def _prompt(args: argparse.Namespace) -> str:
|
|
|
376
524
|
|
|
377
525
|
|
|
378
526
|
def _validate_prepared(args: argparse.Namespace) -> dict[str, Any]:
|
|
379
|
-
|
|
527
|
+
source = _plan_source(args)
|
|
528
|
+
expected = _envelope(source)
|
|
380
529
|
path = _prepared_items_path(args.run_manifest, require_regular=True)
|
|
381
|
-
|
|
382
|
-
|
|
530
|
+
actual = _load_json_object(path)
|
|
531
|
+
if actual.get("items") != expected["items"]:
|
|
532
|
+
raise PlanItemContractError(
|
|
533
|
+
"prepared plan items do not match deterministic extraction"
|
|
534
|
+
)
|
|
535
|
+
expected_queue = _queue_for(
|
|
536
|
+
expected["items"],
|
|
537
|
+
_planning(source),
|
|
538
|
+
args.run_manifest,
|
|
539
|
+
**_queue_kwargs(args),
|
|
540
|
+
)
|
|
541
|
+
if actual.get("dispatchQueue") != expected_queue:
|
|
542
|
+
raise PlanItemContractError("prepared dispatch queue does not match")
|
|
383
543
|
return {"ok": True, "operation": "validate-prepared", "path": str(path)}
|
|
384
544
|
|
|
385
545
|
|
|
@@ -397,6 +557,9 @@ def _assigned_item_ids(items_path: Path) -> list[str]:
|
|
|
397
557
|
items = envelope.get("items")
|
|
398
558
|
if not isinstance(items, list):
|
|
399
559
|
raise PlanItemContractError(f"items envelope has no `items` array: {items_path}")
|
|
560
|
+
queue = envelope.get("dispatchQueue")
|
|
561
|
+
if isinstance(queue, list):
|
|
562
|
+
return [item_id for item_id in queue if isinstance(item_id, str) and item_id]
|
|
400
563
|
ids: list[str] = []
|
|
401
564
|
for item in items:
|
|
402
565
|
item_id = item.get("id") if isinstance(item, Mapping) else None
|
|
@@ -630,7 +793,9 @@ def _seed(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
630
793
|
already applied, `carriedForwardFromSeq`, `selfFixNote` — because a re-seed
|
|
631
794
|
between rounds must not erase the round before it.
|
|
632
795
|
"""
|
|
633
|
-
|
|
796
|
+
source = _plan_source(args)
|
|
797
|
+
extracted = _envelope(source)["items"]
|
|
798
|
+
hashes = {item["id"]: content_hash(item) for item in extracted}
|
|
634
799
|
if args.state is not None:
|
|
635
800
|
data = _load_json_object(args.state) if args.state.is_file() else _new_v3_state()
|
|
636
801
|
recorded = _state_plan_body_items(data, args.state)
|
|
@@ -661,11 +826,17 @@ def _seed(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
661
826
|
if item.get("stageScope") else {}
|
|
662
827
|
) | (
|
|
663
828
|
{"block": item["block"]} if item.get("block") else {}
|
|
829
|
+
) | (
|
|
830
|
+
{"contentHash": hashes[item["id"]]} if item["id"] in hashes else {}
|
|
664
831
|
) | {"verdicts": []}
|
|
665
832
|
for item in extracted
|
|
666
833
|
if item["id"] not in known
|
|
667
834
|
]
|
|
668
835
|
recorded.extend(added)
|
|
836
|
+
for row in recorded:
|
|
837
|
+
item_id = row.get("id") if isinstance(row, Mapping) else None
|
|
838
|
+
if isinstance(item_id, str) and item_id in hashes:
|
|
839
|
+
row["contentHash"] = hashes[item_id]
|
|
669
840
|
if args.state is not None:
|
|
670
841
|
audit_rows = data.get("planItems")
|
|
671
842
|
if not isinstance(audit_rows, list):
|
|
@@ -685,13 +856,26 @@ def _seed(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
685
856
|
for item in extracted
|
|
686
857
|
if item["id"] not in audited
|
|
687
858
|
)
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
859
|
+
verification = (
|
|
860
|
+
data["planBodyVerification"] if args.state is not None
|
|
861
|
+
else _planning(data)["planBodyVerification"]
|
|
862
|
+
)
|
|
863
|
+
ledger = _merged_ledger(_planning(source), getattr(args, "run_manifest", None))
|
|
864
|
+
if ledger:
|
|
694
865
|
verification["stageLedger"] = ledger
|
|
866
|
+
previous = {
|
|
867
|
+
str(row["id"]): str(row["verifiedContentHash"])
|
|
868
|
+
for row in recorded
|
|
869
|
+
if isinstance(row, Mapping)
|
|
870
|
+
and isinstance(row.get("id"), str)
|
|
871
|
+
and isinstance(row.get("verifiedContentHash"), str)
|
|
872
|
+
}
|
|
873
|
+
verification["dispatchQueue"] = _queue_for(
|
|
874
|
+
extracted, _planning(source), getattr(args, "run_manifest", None), previous,
|
|
875
|
+
)
|
|
876
|
+
verification["gating"] = not advisory_plan_body_gating(
|
|
877
|
+
_planning(source), extracted,
|
|
878
|
+
)
|
|
695
879
|
write_json_atomic(target, data)
|
|
696
880
|
return {
|
|
697
881
|
"ok": True,
|
|
@@ -743,7 +927,16 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
743
927
|
else _plan_body_items(data, target)
|
|
744
928
|
)
|
|
745
929
|
known = {item.get("id") for item in recorded if isinstance(item, Mapping)}
|
|
746
|
-
|
|
930
|
+
verification = (
|
|
931
|
+
data["planBodyVerification"] if args.state is not None
|
|
932
|
+
else _planning(data)["planBodyVerification"]
|
|
933
|
+
)
|
|
934
|
+
queue = verification.get("dispatchQueue") if isinstance(verification, Mapping) else None
|
|
935
|
+
assigned = (
|
|
936
|
+
{item_id for item_id in queue if isinstance(item_id, str)}
|
|
937
|
+
if isinstance(queue, list) else known
|
|
938
|
+
)
|
|
939
|
+
rows = _incoming_verdict_rows(args, assigned)
|
|
747
940
|
missing = sorted(item_id for item_id in rows if item_id not in known)
|
|
748
941
|
if missing:
|
|
749
942
|
raise PlanItemContractError(
|
|
@@ -754,25 +947,74 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
754
947
|
if args.round_number < 1:
|
|
755
948
|
raise PlanItemContractError("--round must be 1 or greater")
|
|
756
949
|
project_root = _probe_project_root(getattr(args, "run_manifest", None))
|
|
950
|
+
writer = (
|
|
951
|
+
_append_item_verdicts if getattr(args, "append", False)
|
|
952
|
+
else _replace_item_verdicts
|
|
953
|
+
)
|
|
757
954
|
for item in recorded:
|
|
758
955
|
if isinstance(item, Mapping) and item.get("id") in rows:
|
|
759
|
-
|
|
760
|
-
# and a merged table lets a previous round's votes keep voting.
|
|
761
|
-
#
|
|
762
|
-
# Each row carries the round it was cast in. A self-fix round
|
|
763
|
-
# rewrites the plan *after* a verification round, so an item left out
|
|
764
|
-
# of a later round keeps a verdict on text that has since changed —
|
|
765
|
-
# invisibly, because the gate reads the table without knowing any
|
|
766
|
-
# row's vintage. Stamping it here is what lets the validator tell a
|
|
767
|
-
# current judgement from one two rewrites old.
|
|
768
|
-
item["verdicts"] = [
|
|
769
|
-
{**_judged_row(row, project_root), "round": args.round_number}
|
|
770
|
-
for row in rows[item["id"]]
|
|
771
|
-
]
|
|
956
|
+
writer(item, rows[item["id"]], args.round_number, project_root)
|
|
772
957
|
write_json_atomic(target, data)
|
|
773
958
|
return {"ok": True, "operation": "apply-verdicts", "path": str(target)}
|
|
774
959
|
|
|
775
960
|
|
|
961
|
+
def _stamped_verdicts(
|
|
962
|
+
incoming: list[dict[str, Any]], round_number: int, project_root: Path | None,
|
|
963
|
+
) -> list[dict[str, Any]]:
|
|
964
|
+
return [
|
|
965
|
+
{**_judged_row(row, project_root), "round": round_number}
|
|
966
|
+
for row in incoming
|
|
967
|
+
]
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def _remember_verified_hash(item: dict[str, Any]) -> None:
|
|
971
|
+
if item.get("contentHash"):
|
|
972
|
+
item["verifiedContentHash"] = item["contentHash"]
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _replace_item_verdicts(
|
|
976
|
+
item: dict[str, Any],
|
|
977
|
+
incoming: list[dict[str, Any]],
|
|
978
|
+
round_number: int,
|
|
979
|
+
project_root: Path | None,
|
|
980
|
+
) -> None:
|
|
981
|
+
item["verdicts"] = _stamped_verdicts(incoming, round_number, project_root)
|
|
982
|
+
_remember_verified_hash(item)
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
def _append_item_verdicts(
|
|
986
|
+
item: dict[str, Any],
|
|
987
|
+
incoming: list[dict[str, Any]],
|
|
988
|
+
round_number: int,
|
|
989
|
+
project_root: Path | None,
|
|
990
|
+
) -> None:
|
|
991
|
+
"""동수 항목의 세 번째 표. 이미 투표한 워커는 거부한다."""
|
|
992
|
+
stamped = _stamped_verdicts(incoming, round_number, project_root)
|
|
993
|
+
existing = item.get("verdicts")
|
|
994
|
+
current = existing if isinstance(existing, list) else []
|
|
995
|
+
seen = {
|
|
996
|
+
row.get("worker") for row in current if isinstance(row, Mapping)
|
|
997
|
+
}
|
|
998
|
+
clash = [row.get("worker") for row in stamped if row.get("worker") in seen]
|
|
999
|
+
if clash:
|
|
1000
|
+
raise PlanItemContractError(
|
|
1001
|
+
f"plan item {item.get('id')} already has a vote from {clash} — "
|
|
1002
|
+
"the extra vote must come from a worker who has not voted on it"
|
|
1003
|
+
)
|
|
1004
|
+
item["verdicts"] = [*current, *stamped]
|
|
1005
|
+
_remember_verified_hash(item)
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def _gate_module() -> Any:
|
|
1009
|
+
path = Path(__file__).parents[2] / "validators" / "validate-run.py"
|
|
1010
|
+
spec = importlib.util.spec_from_file_location("okstra_plan_gate", path)
|
|
1011
|
+
if spec is None or spec.loader is None:
|
|
1012
|
+
raise PlanItemContractError("cannot load plan-body gate authority")
|
|
1013
|
+
module = importlib.util.module_from_spec(spec)
|
|
1014
|
+
spec.loader.exec_module(module)
|
|
1015
|
+
return module
|
|
1016
|
+
|
|
1017
|
+
|
|
776
1018
|
def _round_snapshot(item: Mapping[str, Any], round_number: int) -> dict[str, Any]:
|
|
777
1019
|
votes = {
|
|
778
1020
|
str(row["worker"]): str(row["verdict"])
|
|
@@ -852,7 +1094,15 @@ def _round_inputs(args: argparse.Namespace) -> tuple[dict[str, Any], list[dict[s
|
|
|
852
1094
|
|
|
853
1095
|
|
|
854
1096
|
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
|
-
|
|
1097
|
+
queue = verification.get("dispatchQueue")
|
|
1098
|
+
scoped = current
|
|
1099
|
+
if isinstance(queue, list):
|
|
1100
|
+
allowed = {item_id for item_id in queue if isinstance(item_id, str)}
|
|
1101
|
+
scoped = [
|
|
1102
|
+
item for item in current
|
|
1103
|
+
if isinstance(item, Mapping) and item.get("id") in allowed
|
|
1104
|
+
]
|
|
1105
|
+
snapshots = {str(item.get("id")): _round_snapshot(item, round_number) for item in scoped}
|
|
856
1106
|
if not snapshots or any(not row["votes"] for row in snapshots.values()):
|
|
857
1107
|
raise PlanItemContractError("every current plan item needs a verdict before completion")
|
|
858
1108
|
summary = _plan_gate_summary(verification)
|
|
@@ -892,6 +1142,15 @@ def _complete_round(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
892
1142
|
raise PlanItemContractError("--round must be 1 or greater")
|
|
893
1143
|
data, current, audit, history = _round_inputs(args)
|
|
894
1144
|
verification = data["planBodyVerification"]
|
|
1145
|
+
if verification.get("gating") is False:
|
|
1146
|
+
if args.round_number > 1:
|
|
1147
|
+
raise PlanItemContractError(
|
|
1148
|
+
"advisory plan-body gating allows one verification round"
|
|
1149
|
+
)
|
|
1150
|
+
if args.self_fix_group or args.self_fix_note:
|
|
1151
|
+
raise PlanItemContractError(
|
|
1152
|
+
"advisory plan-body gating forbids the self-fix loop"
|
|
1153
|
+
)
|
|
895
1154
|
snapshots, summary = _round_snapshots(current, verification, args.round_number)
|
|
896
1155
|
_record_audit_round(audit, snapshots, args.round_number)
|
|
897
1156
|
gate = str(summary["recomputed"])
|
|
@@ -915,7 +1174,54 @@ def _complete_round(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
915
1174
|
history[:] = [row for row in history if row.get("round") != args.round_number]
|
|
916
1175
|
history.append({"round": args.round_number, "completedAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "gateResult": gate, "gateBlockedBy": verification["gateBlockedBy"]})
|
|
917
1176
|
write_json_atomic(args.state, data)
|
|
918
|
-
|
|
1177
|
+
payloads = _prepared_payloads(args.run_manifest)
|
|
1178
|
+
return {
|
|
1179
|
+
"ok": True,
|
|
1180
|
+
"operation": "complete-round",
|
|
1181
|
+
"path": str(args.state),
|
|
1182
|
+
"gateResult": gate,
|
|
1183
|
+
"nextDispatch": next_dispatch(current, payloads).as_dict(),
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
def _prepared_payloads(run_manifest: Path | None) -> dict[str, dict[str, Any]]:
|
|
1188
|
+
if run_manifest is None:
|
|
1189
|
+
return {}
|
|
1190
|
+
try:
|
|
1191
|
+
envelope = _load_json_object(
|
|
1192
|
+
_prepared_items_path(run_manifest, require_regular=True)
|
|
1193
|
+
)
|
|
1194
|
+
except PlanItemContractError:
|
|
1195
|
+
return {}
|
|
1196
|
+
items = envelope.get("items")
|
|
1197
|
+
if not isinstance(items, list):
|
|
1198
|
+
return {}
|
|
1199
|
+
return {
|
|
1200
|
+
str(item["id"]): item
|
|
1201
|
+
for item in items
|
|
1202
|
+
if isinstance(item, Mapping) and isinstance(item.get("id"), str)
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
def _state_next_dispatch(args: argparse.Namespace) -> NextDispatch:
|
|
1207
|
+
_data, current, _audit, _history = _round_inputs(args)
|
|
1208
|
+
payloads = _prepared_payloads(getattr(args, "run_manifest", None))
|
|
1209
|
+
return next_dispatch(current, payloads)
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
def _next_dispatch(args: argparse.Namespace) -> dict[str, Any]:
|
|
1213
|
+
decision = _state_next_dispatch(args)
|
|
1214
|
+
return {"ok": True, "operation": "next-dispatch", **decision.as_dict()}
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
def _correction_prompt(args: argparse.Namespace) -> str:
|
|
1218
|
+
decision = _state_next_dispatch(args)
|
|
1219
|
+
if decision.kind != "worker-correction" or args.worker not in decision.workers:
|
|
1220
|
+
raise PlanItemContractError(
|
|
1221
|
+
f"worker `{args.worker}` is not a blanket-UNVERIFIABLE correction "
|
|
1222
|
+
"target — do not open a queue round"
|
|
1223
|
+
)
|
|
1224
|
+
return correction_prompt_text(_prompt(args))
|
|
919
1225
|
|
|
920
1226
|
|
|
921
1227
|
def _incoming_verdict_rows(
|
|
@@ -949,6 +1255,8 @@ _HANDLERS = {
|
|
|
949
1255
|
"seed": _seed,
|
|
950
1256
|
"apply-verdicts": _apply_verdicts,
|
|
951
1257
|
"complete-round": _complete_round,
|
|
1258
|
+
"next-dispatch": _next_dispatch,
|
|
1259
|
+
"correction-prompt": _correction_prompt,
|
|
952
1260
|
}
|
|
953
1261
|
|
|
954
1262
|
|
|
@@ -962,7 +1270,14 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
962
1270
|
if isinstance(result, str):
|
|
963
1271
|
print(result, end="")
|
|
964
1272
|
elif args.command in {"prepare", "validate-prepared"}:
|
|
965
|
-
|
|
1273
|
+
rows = (
|
|
1274
|
+
"Plan items\n"
|
|
1275
|
+
+ line("Status", "ready")
|
|
1276
|
+
+ line("Operation", result["operation"])
|
|
1277
|
+
)
|
|
1278
|
+
if "gating" in result:
|
|
1279
|
+
rows += line("Gating", result["gating"])
|
|
1280
|
+
print(rows, end="")
|
|
966
1281
|
else:
|
|
967
1282
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
968
1283
|
return 0
|
|
@@ -1584,6 +1584,10 @@ def _build_convergence_block(ctx: dict) -> dict:
|
|
|
1584
1584
|
always emitted (dead-letter on other phases) so the schema stays stable.
|
|
1585
1585
|
Its `selfFixMaxRounds` default 1 bounds the report-writer self-fix loop
|
|
1586
1586
|
that runs before a planner-fixable defect is promoted to the user.
|
|
1587
|
+
`gating` is true here because the plan does not exist yet. After the
|
|
1588
|
+
report-writer draft, `okstra plan-items prepare` flips it to false when
|
|
1589
|
+
`designPreparation.mode` is `no-design-inputs` and the Stage Map has
|
|
1590
|
+
exactly one row.
|
|
1587
1591
|
|
|
1588
1592
|
ctx knobs honoured:
|
|
1589
1593
|
- `OKSTRA_PLAN_VERIFICATION`: "true" | "false" | "" (empty → default True).
|