okstra 0.224.0 → 0.225.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.
Files changed (65) hide show
  1. package/dist/commands/execute/render-bundle.mjs +7 -5
  2. package/dist/commands/execute/render-bundle.mjs.map +1 -1
  3. package/docs/cli.md +10 -2
  4. package/package.json +1 -1
  5. package/runtime/BUILD.json +2 -2
  6. package/runtime/bin/lib/okstra/cli.sh +5 -1
  7. package/runtime/bin/lib/okstra/globals.sh +1 -0
  8. package/runtime/bin/lib/okstra/usage.sh +4 -1
  9. package/runtime/bin/okstra-provider-exec.py +32 -16
  10. package/runtime/bin/okstra.sh +1 -0
  11. package/runtime/prompts/duties/code-reviewer.json +5 -3
  12. package/runtime/prompts/wizard/prompts.ko.json +10 -0
  13. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +2 -0
  14. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +3 -1
  15. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +6 -0
  16. package/runtime/python/okstra_ctl/adapters/providers/zai/adapter.py +5 -1
  17. package/runtime/python/okstra_ctl/agent/prompt_cli/results.py +25 -0
  18. package/runtime/python/okstra_ctl/code_review_coverage.py +213 -0
  19. package/runtime/python/okstra_ctl/code_review_target.py +31 -2
  20. package/runtime/python/okstra_ctl/coverage_census.py +2 -2
  21. package/runtime/python/okstra_ctl/dispatch_core.py +15 -0
  22. package/runtime/python/okstra_ctl/domain/worker_exec.py +6 -0
  23. package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +66 -0
  24. package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +5 -1
  25. package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +1 -0
  26. package/runtime/python/okstra_ctl/phases/implementation_planning/round_completion.py +177 -0
  27. package/runtime/python/okstra_ctl/render.py +2 -0
  28. package/runtime/python/okstra_ctl/run.py +23 -2
  29. package/runtime/python/okstra_ctl/team.py +12 -18
  30. package/runtime/python/okstra_ctl/wizard/__init__.py +1 -0
  31. package/runtime/python/okstra_ctl/wizard/confirmation.py +2 -0
  32. package/runtime/python/okstra_ctl/wizard/ids.py +3 -0
  33. package/runtime/python/okstra_ctl/wizard/registry.py +11 -0
  34. package/runtime/python/okstra_ctl/wizard/render.py +1 -0
  35. package/runtime/python/okstra_ctl/wizard/state.py +3 -0
  36. package/runtime/python/okstra_ctl/wizard/steps_options.py +41 -1
  37. package/runtime/python/okstra_ctl/worker_dispatch.py +9 -0
  38. package/runtime/python/okstra_ctl/worker_request.py +12 -1
  39. package/runtime/schemas/execution-manifest-v2.schema.json +1 -0
  40. package/runtime/skills/_fragments/bash-invocation-rule.md +1 -1
  41. package/runtime/skills/_fragments/host-question-interface.md +1 -0
  42. package/runtime/skills/_fragments/host-runtime-selection.md +1 -0
  43. package/runtime/skills/_fragments/wizard-bash-invocation-rule.md +1 -1
  44. package/runtime/skills/_fragments/wizard-preflight.md +1 -1
  45. package/runtime/skills/_fragments/wizard-render-bundle.md +4 -4
  46. package/runtime/skills/okstra-brief-gen/SKILL.md +45 -47
  47. package/runtime/skills/okstra-brief-gen/references/reporter-confirmations.md +3 -3
  48. package/runtime/skills/okstra-brief-gen/references/tracker-recursion.md +1 -1
  49. package/runtime/skills/okstra-chat/SKILL.md +1 -1
  50. package/runtime/skills/okstra-code-review/SKILL.md +26 -15
  51. package/runtime/skills/okstra-code-review/references/census-rules.md +38 -2
  52. package/runtime/skills/okstra-code-review/references/review-calibration.md +39 -15
  53. package/runtime/skills/okstra-container-build/SKILL.md +6 -2
  54. package/runtime/skills/okstra-discussion/SKILL.md +6 -6
  55. package/runtime/skills/okstra-inspect/SKILL.md +5 -3
  56. package/runtime/skills/okstra-manager/SKILL.md +8 -4
  57. package/runtime/skills/okstra-memory/SKILL.md +1 -1
  58. package/runtime/skills/okstra-pr-gen/SKILL.md +10 -6
  59. package/runtime/skills/okstra-rollup/SKILL.md +6 -2
  60. package/runtime/skills/okstra-run/SKILL.md +8 -7
  61. package/runtime/skills/okstra-schedule-gen/SKILL.md +13 -6
  62. package/runtime/skills/okstra-setup/SKILL.md +8 -4
  63. package/runtime/skills/okstra-setup/references/project-config.md +3 -3
  64. package/runtime/skills/okstra-usage/SKILL.md +5 -1
  65. package/runtime/skills/okstra-user-response/SKILL.md +7 -3
@@ -0,0 +1,213 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Sequence
5
+ from dataclasses import dataclass
6
+ from pathlib import Path, PurePosixPath
7
+
8
+ from .agent.invocation import AgentInvocationError, verify_standalone_completion
9
+ from .convergence_provenance import id_occurs_wordbounded
10
+ from .coverage_census import CellVerdicts, split_coverage_verdict_section
11
+ from .json_boundary import load_owned_object
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class ReviewCell:
16
+ target: str
17
+ group: str
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class ReviewCensus:
22
+ rules: dict[str, tuple[str, ...]]
23
+ cells: dict[str, ReviewCell]
24
+
25
+
26
+ def _table_fields(line: str) -> list[str]:
27
+ return [field.strip() for field in line.strip().strip("|").split("|")]
28
+
29
+
30
+ def read_review_census(path: Path) -> ReviewCensus:
31
+ rules: dict[str, list[str]] = {}
32
+ cells: dict[str, ReviewCell] = {}
33
+ rule_ids: set[str] = set()
34
+ pairs: set[tuple[str, str]] = set()
35
+ references: set[tuple[str, str]] = set()
36
+ section = ""
37
+ for line in path.read_text(encoding="utf-8").splitlines():
38
+ if line.startswith("## "):
39
+ section = line.strip()
40
+ if section not in ("## Rules", "## Cells") or not line.strip().startswith("|"):
41
+ continue
42
+ fields = _table_fields(line)
43
+ header = fields[0] in ("Rule", "Cell")
44
+ if header or all(re.fullmatch(r"[-:]+", f) for f in fields):
45
+ continue
46
+ if len(fields) != 3 or not all(fields):
47
+ raise ValueError("malformed grouped census row")
48
+ identity, value, reference = fields
49
+ if section == "## Rules":
50
+ if not re.fullmatch(r"[A-Z][A-Z0-9]*", identity) or identity in rule_ids:
51
+ raise ValueError("invalid or duplicate rule ID")
52
+ if (value, reference) in references:
53
+ raise ValueError("duplicate group and rule reference")
54
+ if value not in ("structural", "semantic", "state-and-tests", "general"):
55
+ raise ValueError("unknown rule group")
56
+ rule_ids.add(identity)
57
+ references.add((value, reference))
58
+ rules.setdefault(value, []).append(identity)
59
+ else:
60
+ if not re.fullmatch(r"C-CR-[0-9]+", identity) or identity in cells:
61
+ raise ValueError("invalid or duplicate cell ID")
62
+ if (value, reference) in pairs:
63
+ raise ValueError("duplicate target and group")
64
+ target = PurePosixPath(value.partition(":")[0])
65
+ if target.is_absolute() or ".." in target.parts:
66
+ raise ValueError("target must be project-relative")
67
+ pairs.add((value, reference))
68
+ cells[identity] = ReviewCell(value, reference)
69
+ if not rules or not cells:
70
+ raise ValueError("census needs Rules and Cells tables")
71
+ if any(cell.group not in rules for cell in cells.values()):
72
+ raise ValueError("cell names an unknown rule group")
73
+ return ReviewCensus({group: tuple(ids) for group, ids in rules.items()}, cells)
74
+
75
+
76
+ def _keyed_values(text: str) -> dict[str, str]:
77
+ values: dict[str, str] = {}
78
+ if text == "-":
79
+ return values
80
+ for entry in text.split(";"):
81
+ key, separator, value = entry.partition("=")
82
+ key, value = key.strip(), value.strip()
83
+ if not separator or not key or not value or key in values:
84
+ raise ValueError("empty or duplicate rule assignment")
85
+ values[key] = value
86
+ return values
87
+
88
+
89
+ def _source_evidence(project_root: Path, cell: ReviewCell, evidence: str) -> str:
90
+ match = re.fullmatch(r"(.+):([1-9][0-9]*)", evidence.strip("`"))
91
+ target = cell.target.partition(":")[0]
92
+ if match is None or match[1] != target:
93
+ raise ValueError("evidence must cite the target source path:line")
94
+ relative = PurePosixPath(target)
95
+ source = (project_root / target).resolve()
96
+ if (relative.is_absolute() or ".." in relative.parts
97
+ or not source.is_relative_to(project_root.resolve())):
98
+ raise ValueError("source evidence escapes project")
99
+ if not source.is_file():
100
+ raise ValueError("source evidence file does not exist")
101
+ if int(match[2]) > len(source.read_text(encoding="utf-8").splitlines()):
102
+ raise ValueError("source evidence line does not exist")
103
+ return evidence
104
+
105
+
106
+ def _group_verdict(
107
+ fields: Sequence[str], expected: Sequence[str], cell: ReviewCell,
108
+ project_root: Path, references: Sequence[str],
109
+ ) -> tuple[str, str]:
110
+ if len(fields) != 5:
111
+ raise ValueError("expected Cell, Checked, N/A, Findings, Evidence columns")
112
+ checked = [] if fields[1] == "-" else [rule.strip() for rule in fields[1].split(",")]
113
+ excluded = _keyed_values(fields[2])
114
+ findings = _keyed_values(fields[3])
115
+ assigned = checked + list(excluded) + list(findings)
116
+ if len(set(assigned)) != len(assigned):
117
+ raise ValueError("duplicate or conflicting rule verdicts")
118
+ unknown = set(assigned) - set(expected)
119
+ if unknown:
120
+ raise ValueError("unknown rules: " + ", ".join(sorted(unknown)))
121
+ missing = set(expected) - set(assigned)
122
+ if missing:
123
+ raise ValueError("unchecked rules: " + ", ".join(sorted(missing)))
124
+ for reason in excluded.values():
125
+ if re.fullmatch(r"(?:[A-Z][0-9]+|[a-z]+:[0-9]+)", reason):
126
+ raise ValueError("n/a needs an inline applicability reason, not an alias")
127
+ for ids in findings.values():
128
+ for item in ids.split(","):
129
+ item = item.strip()
130
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", item):
131
+ raise ValueError("invalid finding ID")
132
+ if not any(id_occurs_wordbounded(body, item) for body in references):
133
+ raise ValueError(f"finding {item} has no finding body")
134
+ evidence = _source_evidence(project_root, cell, fields[4])
135
+ if findings:
136
+ return "finding", "; ".join(findings.values())
137
+ if checked:
138
+ return "clean", evidence
139
+ return "n/a", "; ".join(excluded.values())
140
+
141
+
142
+ def _group_results(
143
+ body: str, census: ReviewCensus, project_root: Path, references: Sequence[str],
144
+ ) -> CellVerdicts:
145
+ section, _ = split_coverage_verdict_section(body)
146
+ verdicts: dict[str, tuple[str, str]] = {}
147
+ reasons: dict[str, str] = {}
148
+ seen: set[str] = set()
149
+ unknown: list[str] = []
150
+ for line in section:
151
+ if not line.strip().startswith("|"):
152
+ continue
153
+ fields = _table_fields(line)
154
+ identity = fields[0]
155
+ if identity not in census.cells:
156
+ if identity.startswith("C-") and identity not in unknown:
157
+ unknown.append(identity)
158
+ continue
159
+ if identity in seen:
160
+ verdicts.pop(identity, None)
161
+ reasons[identity] = "duplicate cell row"
162
+ continue
163
+ seen.add(identity)
164
+ cell = census.cells[identity]
165
+ try:
166
+ verdicts[identity] = _group_verdict(
167
+ fields, census.rules[cell.group], cell, project_root, references,
168
+ )
169
+ except (OSError, ValueError) as exc:
170
+ reasons[identity] = str(exc)
171
+ return CellVerdicts(
172
+ verdicts,
173
+ {key: reasons.get(key, "no grouped verdict row") for key in census.cells if key not in verdicts},
174
+ tuple(unknown),
175
+ )
176
+
177
+
178
+ def check_review_coverage(
179
+ project_root: Path, census_path: Path, completions: Sequence[Path],
180
+ ) -> CellVerdicts:
181
+ census = read_review_census(census_path)
182
+ if not completions or len(set(completions)) != len(completions):
183
+ raise ValueError("pass distinct completion paths for one reviewer")
184
+ bodies: list[str] = []
185
+ assignment: object = None
186
+ for path in completions:
187
+ verified = verify_standalone_completion(
188
+ path, project_root=project_root, expected_purpose="code-review",
189
+ )
190
+ metadata = load_owned_object(
191
+ verified.prompt_metadata_path, artifact="agent invocation metadata",
192
+ )
193
+ if metadata["audience"] != "code-reviewer":
194
+ raise AgentInvocationError("completion is not a code reviewer")
195
+ current = metadata["modelAssignment"]
196
+ if assignment is not None and current != assignment:
197
+ raise AgentInvocationError("coverage cannot mix reviewer model assignments")
198
+ assignment = current
199
+ bodies.append(verified.returned_body)
200
+ verdicts: dict[str, tuple[str, str]] = {}
201
+ missing: dict[str, str] = {}
202
+ unknown: list[str] = []
203
+ references = [split_coverage_verdict_section(body)[1] for body in bodies]
204
+ for body in bodies:
205
+ parsed = _group_results(body, census, project_root, references)
206
+ unknown.extend(key for key in parsed.unknown if key not in unknown)
207
+ for key, verdict in parsed.verdicts.items():
208
+ verdicts.setdefault(key, verdict)
209
+ missing.update(parsed.missing)
210
+ return CellVerdicts(
211
+ verdicts, {key: missing[key] for key in census.cells if key not in verdicts},
212
+ tuple(unknown),
213
+ )
@@ -102,6 +102,7 @@ def _branch_snapshot(repo: Path, branch: str, base: str, review_date: str) -> di
102
102
  _CLI_EPILOG = r"""Usage:
103
103
  okstra code-review target --task-key <k> --stage <N> [--base <ref>] [--project-root <dir>] [--cwd <dir>] --json
104
104
  okstra code-review target --branch <name> [--base <ref>] [--date <YYYY-MM-DD>] [--project-root <dir>] --json
105
+ okstra code-review check-coverage --census <table-path> --completion <completion-path> [--completion ...] [--project-root <dir>]
105
106
  okstra code-review check-lines --base <ref> --head <ref> --cite <path:line> [--cite ...] [--project-root <dir>]
106
107
 
107
108
  Output: JSON { ok, mode, worktreePath, branch, baseCommit, headCommit,
@@ -111,7 +112,8 @@ any citation is not a line the range changed. `--base` sets the base in either m
111
112
  mode reads the base commit from the stage registry row and branch mode falls
112
113
  back to the merge-base with the default branch. An empty worktreePath means the review reads the branch ref
113
114
  instead of a checked-out tree. This is read-only — it never creates
114
- directories or files.
115
+ directories or files. `check-coverage` prints covered cells, missing rules and unknown cells;
116
+ it exits 1 for incomplete coverage and 2 for invalid input or completion evidence.
115
117
  """
116
118
 
117
119
 
@@ -122,7 +124,7 @@ def _build_parser() -> argparse.ArgumentParser:
122
124
  prog="okstra code-review",
123
125
  description="Resolve a code review's diff range and result file path, or check finding citations."
124
126
  )
125
- parser.add_argument("command", choices=("target", "check-lines"))
127
+ parser.add_argument("command", choices=("target", "check-lines", "check-coverage"))
126
128
  parser.add_argument("--task-key", default="", help="project-id:task-group:task-id")
127
129
  parser.add_argument("--stage", type=int, default=0, help="stage number to review")
128
130
  parser.add_argument("--branch", default="", help="branch to review")
@@ -135,6 +137,9 @@ def _build_parser() -> argparse.ArgumentParser:
135
137
  "--cite", action="append", default=[],
136
138
  help="check-lines: a finding's `path:line` (repeatable)",
137
139
  )
140
+ parser.add_argument("--census", default="", help="check-coverage: shared Rules and grouped Cells tables")
141
+ parser.add_argument("--completion", action="append", default=[],
142
+ help="check-coverage: verified reviewer completion (repeat for gap-fills)")
138
143
  parser.add_argument("--date", default="", help="YYYY-MM-DD for branch result naming")
139
144
  parser.add_argument("--project-root", default="", help="explicit project root")
140
145
  parser.add_argument("--cwd", default=".", help="cwd for project root resolution")
@@ -249,6 +254,28 @@ def _check_lines(args: argparse.Namespace, project_root: Path) -> int:
249
254
  return 0 if all_ok else 1
250
255
 
251
256
 
257
+ def _check_coverage(args: argparse.Namespace, project_root: Path) -> int:
258
+ from .agent.invocation import AgentInvocationError
259
+ from .code_review_coverage import check_review_coverage
260
+
261
+ if not args.census or not args.completion:
262
+ print("check-coverage: pass --census and at least one --completion", file=sys.stderr)
263
+ return 2
264
+ try:
265
+ result = check_review_coverage(
266
+ project_root, Path(args.census), [Path(path) for path in args.completion],
267
+ )
268
+ except (OSError, ValueError, AgentInvocationError) as exc:
269
+ print(f"check-coverage: {exc}", file=sys.stderr)
270
+ return 2
271
+ print(f"Covered cells: {len(result.verdicts)}")
272
+ for key, reason in result.missing.items():
273
+ print(f"Missing: {key} — {reason}")
274
+ for key in result.unknown:
275
+ print(f"Unknown: {key}")
276
+ return 1 if result.missing or result.unknown else 0
277
+
278
+
252
279
  def main(argv: list[str] | None = None) -> int:
253
280
  args = _build_parser().parse_args(argv)
254
281
  try:
@@ -260,6 +287,8 @@ def main(argv: list[str] | None = None) -> int:
260
287
  print(render_code_review_target_text(payload) if args.text else json.dumps(payload))
261
288
  return 2
262
289
 
290
+ if args.command == "check-coverage":
291
+ return _check_coverage(args, Path(project_root))
263
292
  if args.command == "check-lines":
264
293
  return _check_lines(args, Path(project_root))
265
294
  try:
@@ -233,7 +233,7 @@ class CellVerdicts:
233
233
  unknown: tuple[str, ...]
234
234
 
235
235
 
236
- def _split_verdict_section(text: str) -> tuple[list[str], str]:
236
+ def split_coverage_verdict_section(text: str) -> tuple[list[str], str]:
237
237
  """The Coverage Verdicts lines, and the rest of the result."""
238
238
  lines = text.splitlines()
239
239
  start = next(
@@ -288,7 +288,7 @@ def parse_cell_verdicts(
288
288
  gap-fill result can cite an item its worker's first result already raised.
289
289
  The first usable line for a cell wins.
290
290
  """
291
- section, body = _split_verdict_section(result_text)
291
+ section, body = split_coverage_verdict_section(result_text)
292
292
  wanted = list(dict.fromkeys(cell_ids))
293
293
  known = set(wanted)
294
294
  verdicts: dict[str, tuple[str, str]] = {}
@@ -335,6 +335,21 @@ class DispatchPlan:
335
335
  return payload
336
336
 
337
337
 
338
+ def existing_dispatch_plan(
339
+ project_root: Path, workspace_root: Path, run_manifest_path: Path,
340
+ manifest: Mapping[str, Any],
341
+ ) -> DispatchPlan:
342
+ root = project_root.resolve()
343
+ return DispatchPlan(
344
+ project_root=root, workspace_root=workspace_root.resolve(),
345
+ manifest_path=_resolve_project_path(root, str(run_manifest_path)),
346
+ team_state_path=_resolve_required_path(root, manifest, "teamStatePath"),
347
+ lead_events_path=_resolve_required_path(root, manifest, "leadEventsPath"),
348
+ manifest=manifest, jobs=(),
349
+ default_backend=str(manifest.get("terminalBackend") or BACKEND_CLI_WRAPPER),
350
+ )
351
+
352
+
338
353
  @dataclass(frozen=True)
339
354
  class _BuildOptions:
340
355
  okstra_bin: Path | None
@@ -18,6 +18,9 @@ SERVED_MODEL_MISMATCH_EXIT_CODE = 78
18
18
  # JSON 이벤트 스트림이 result 이벤트 없이 깨끗하게(exit 0) 끝난 run.
19
19
  # CLI 가 성공을 주장했지만 답을 내지 않았다 — sysexits 의 EX_PROTOCOL.
20
20
  NO_RESULT_EXIT_CODE = 76
21
+ # run 하나에 한 단계, okstra-run 위저드에서 고른다. 강도를 받는 CLI 들이 이 넷을
22
+ # 공통으로 받으므로 각 전략은 철자만 옮긴다.
23
+ REASONING_EFFORTS = ("low", "medium", "high", "xhigh")
21
24
 
22
25
 
23
26
  @dataclass(frozen=True)
@@ -62,6 +65,9 @@ class WorkerExecRequest:
62
65
  # issue its own, so the recorded id and the session's own jsonl are
63
66
  # guaranteed to name the same session.
64
67
  session_id: str = ""
68
+ # `REASONING_EFFORTS` 중 하나. run 이 고르지 않았으면 빈 값이고, 그때 CLI 는
69
+ # 자기 설정을 따른다.
70
+ reasoning_effort: str = ""
65
71
 
66
72
 
67
73
  @dataclass(frozen=True)
@@ -11,8 +11,10 @@ from . import plan_body
11
11
 
12
12
  import argparse
13
13
  import copy
14
+ from dataclasses import dataclass
14
15
  import json
15
16
  import re
17
+ import subprocess
16
18
  from collections.abc import Mapping, Sequence
17
19
  from datetime import datetime, timezone
18
20
  from pathlib import Path
@@ -25,6 +27,7 @@ from okstra_ctl.convergence import (
25
27
  validated_run_authority,
26
28
  )
27
29
  from okstra_ctl.convergence_store import write_json_atomic
30
+ from okstra_ctl.paths import RUN_VALIDATOR_RELATIVE, find_asset_root
28
31
  from okstra_ctl.incremental_carry import sync_prepared_dispatch_queue
29
32
  from okstra_ctl.json_boundary import JsonBoundaryError, load_owned_object
30
33
  from okstra_ctl.report_finalize import task_manifest_path
@@ -1146,6 +1149,69 @@ def _validate_prepared(args: argparse.Namespace) -> dict[str, Any]:
1146
1149
  return {"ok": True, "operation": "validate-prepared", "path": str(path)}
1147
1150
 
1148
1151
 
1152
+ @dataclass(frozen=True)
1153
+ class PreparedPlanReview:
1154
+ manifest: Path
1155
+ state: Path
1156
+ items: Path
1157
+ narrative: Path
1158
+
1159
+
1160
+ def validate_current_review(run_manifest: Path) -> PreparedPlanReview:
1161
+ authority = validated_run_authority(run_manifest)
1162
+ state = canonical_run_state_artifact(
1163
+ authority, manifest_field="planBodyVerificationPath",
1164
+ prefix="plan-body-verification", label="plan body verification path",
1165
+ )
1166
+ name = authority.payload.get("reportNarrativePath")
1167
+ if not isinstance(name, str) or not name:
1168
+ raise PlanItemContractError("reportNarrativePath is missing")
1169
+ narrative = (authority.project_root / name).resolve(strict=True)
1170
+ if not narrative.is_relative_to(authority.run_dir):
1171
+ raise PlanItemContractError("plan narrative must stay inside the current run")
1172
+ items_path = _prepared_items_path(run_manifest, require_regular=True)
1173
+ prepared = _load_json_object(items_path)
1174
+ source = _plan_source(argparse.Namespace(data=None, narrative=narrative))
1175
+ items = _envelope(source)["items"]
1176
+ if prepared.get("items") != items:
1177
+ raise PlanItemContractError("prepared plan items do not match the current narrative")
1178
+ inputs = _checked_review_inputs(run_manifest)
1179
+ current = _review_inputs(items, _planning(source), run_manifest, [])
1180
+ if inputs.get("context") != current.get("context"):
1181
+ raise PlanItemContractError("prepared review context changed; prepare and verify again")
1182
+ return PreparedPlanReview(run_manifest, state, items_path, narrative)
1183
+
1184
+
1185
+ def finish_verification_round(
1186
+ review: PreparedPlanReview, results: Sequence[str], round_number: int,
1187
+ ) -> dict[str, Any]:
1188
+ common = ["--state", str(review.state), "--run-manifest", str(review.manifest),
1189
+ "--items", str(review.items), "--round", str(round_number)]
1190
+ apply_args = ["apply-verdicts", *common]
1191
+ for result in results:
1192
+ apply_args.extend(["--result", result])
1193
+ if results and all(is_critic_worker(value.split("=", 1)[0]) for value in results):
1194
+ apply_args.append("--append")
1195
+ _apply_verdicts(_parser().parse_args(apply_args))
1196
+ assets = find_asset_root(RUN_VALIDATOR_RELATIVE)
1197
+ if assets is None:
1198
+ raise PlanItemContractError("plan-body validator is unavailable")
1199
+ verified = subprocess.run(
1200
+ [sys.executable, str(assets.joinpath(*RUN_VALIDATOR_RELATIVE)),
1201
+ "--section", "plan-body", "--narrative", str(review.narrative),
1202
+ "--state", str(review.state)],
1203
+ capture_output=True, text=True, check=False, timeout=60,
1204
+ )
1205
+ if verified.returncode:
1206
+ raise PlanItemContractError(
1207
+ f"plan-verify failed: {verified.stdout.strip()} {verified.stderr.strip()}"
1208
+ )
1209
+ validation = json.loads(verified.stdout)
1210
+ validate_current_review(review.manifest)
1211
+ completed = _complete_round(_parser().parse_args(["complete-round", *common]))
1212
+ return {**completed, "verification": validation}
1213
+
1214
+
1149
1215
  def _split_result_arg(raw: str) -> tuple[str, Path]:
1150
1216
  worker, separator, path = raw.partition("=")
1151
1217
  if not separator or not worker.strip() or not path.strip():
@@ -424,7 +424,11 @@ For contract 3.0, `prepare` checks the selected-direction draft with the same se
424
424
  3. Dispatch uses the same wrapper infrastructure as finding convergence, so the `--role-slug` is the same canonical `<role>-worker` that convergence uses — not a round-specific slug. Result file path: `runs/<task-type>/worker-results/<role>-worker-plan-verify-r<N>-implementation-planning-<seq>.md` (e.g. `codex-worker-plan-verify-r1-implementation-planning-003.md`). **`<seq>` is the report's sequence** — the one in this run's `final-report-<task-type>-<seq>` filename, NOT the `workerResults` sequence the initial analysis results carry. The two are equal in most runs and diverge in some (`reports: 004` alongside `workerResults: 005` is a real case). Provenance no longer globs by either seq: it resolves the expected filenames from this run's team-state `workerDispatches[]` rows — the paths the dispatches actually recorded — and falls back to the seq glob only when no team-state is readable, saying so in its finding. The `-worker-` token is load-bearing twice over: §"Plan-body reverify prompt" requires the same anchor headers as convergence, whose `**Audit sidecar path:**` is derived by `okstra_ctl.worker_artifact_paths.audit_sidecar_rel()` inserting `-audit-` after that token — a slug without it makes the header underivable and the helper raises. Record each `planItems[].verdicts[].worker` as the same `<role>-worker` string, because provenance compares it to this filename's prefix. **Enforced:** `tests/contract/test_reverify_dispatch_anchors.py` derives the sidecar from the documented name and re-extracts the prefix the provenance resolver uses.
425
425
  **Verdict provenance.** Every verdict recorded in `planItems[].verdicts[]` MUST trace back to a dispatch that actually returned a result file. The whole gate — classification, self-fix eligibility, promotion, `gateBlockedBy` — is computed from these votes, so an unbacked vote lets the round be skipped while the gate still reads `passed`. **Enforced (advisory):** `scripts/okstra_ctl/phases/implementation_planning/plan_body.py` `_validate_plan_body_verdict_provenance` reports any `verdicts[].worker` with no recorded reverify dispatch whose result file exists; the finding is not in the blocking allowlist, so it surfaces as an advisory rather than failing the run — record it, never dismiss it. Recording a `verification-error` for a dispatch that produced no result is the correct way to represent a failed worker — inventing an `AGREE` is a contract violation, and renaming a result file to make provenance match destroys the link the check reads.
426
426
 
427
- 4. After all dispatches return, lead aggregates verdicts per `P-*` item across workers and classifies each:
427
+ 4. After all dispatches return, first read `planVerification` in the `worker-dispatch`, `team await --json`, or `agent-prompt link-result --json` response. When its `status` is `completed`, the runtime has already parsed all accepted verifier results, run the plan-body contract validator, and recorded the round. Use its `gateResult`, `verification` (including advisories), and `nextDispatch`; skip the separate collect/apply/plan-verify/complete/next-dispatch calls and continue with the decisions and self-fix procedure below. `pending` means the complete prepared verifier roster has not returned. `failed` stops automatic completion and returns the error; preserve the result files and repair the named defect. `needs-lead`, or a response without this field, uses the manual procedure below. In particular, self-fix re-verification retains the lead-owned cause groups and completion metadata. **Enforced:** `phases/implementation_planning/round_completion.py` and the automatic-round and execution-boundary tests in `tests/run/test_plan_items.py`.
428
+
429
+ A critic correction sharing the analysers' round also returns `needs-lead`; retain the original round queue and use the manual `--append` procedure. The automatic-round tests in `tests/run/test_plan_items.py` enforce this boundary.
430
+
431
+ In the manual procedure, lead aggregates verdicts per `P-*` item across workers and classifies each:
428
432
 
429
433
  **Every in-scope item carries at least one verdict row (BLOCKING).** Aggregation covers the dispatch queue, not deferred or observed stages. An in-scope item left with an empty `verdicts[]` is not a weak signal the gate can discount — it classifies `all-non-result`, states as `needs-reverify`, and folds into `passed-with-dissent` next to items two verifiers actually agreed on, so a plan item nobody judged reads as a passing one. This is the shape a self-fix round produces when the planner adds an in-scope item and the targeted round-N queue never picks it up. A worker that returned nothing is a `verification-error` row (step 3), not a missing row; if an in-scope item was never dispatched, dispatch it before scoring the round. **Enforced:** `scripts/okstra_ctl/phases/implementation_planning/plan_body.py` `_validate_round_recorded_verdicts` fails any run whose `roundCount` ≥ 1 leaves an in-scope item with no verdict row.
430
434
 
@@ -292,6 +292,7 @@ For a new `implementation-planning` run, the fixed order is initial verification
292
292
 
293
293
  An `AGREE` response records the considered counterexample and exclusion reason in its note; unverified external material is `verification-error`, not `DISAGREE`.
294
294
  2. Dispatch a single plan-body reverify round to every analyser worker in the run's roster. `Report writer worker` is NOT a participant in this round.
295
+ Read `planVerification` in the dispatch, await, or native result-link response. `status=completed` means the runtime has already applied the accepted results, checked the plan-body contract, and completed the round. Use its gate, advisories, and next dispatch and skip steps 3–4. `pending` waits for the remaining verifiers; `failed` stops for the returned defect; `needs-lead` retains the manual path, including self-fix cause-group recording. Enforcement: `phases/implementation_planning/round_completion.py` and automatic-round integration tests in `tests/run/test_plan_items.py`.
295
296
  3. Record each verifier Markdown result through `okstra plan-items apply-verdicts --state <plan-body-verification.json> --result <worker-id>=<result.md> --round <N>`. Python validates every submitted `P-*` identifier against the current convergence state and overwrites only that round's verdicts. Then resolve the gate result to one of `passed` / `passed-with-dissent` / `blocked-by-disagreement` / `aborted-non-result`.
296
297
  4. After `okstra plan-verify` succeeds, run `okstra plan-items complete-round --state <plan-body-verification.json> --run-manifest <current-run-manifest.json> --round <N>`. Python reads the current worker assignments, atomically appends the convergence-owned history, and updates its nested final projection. This state is the only plan-verification input report assembly reads.
297
298
  5. Record every *in-scope execution* `majority-disagree` decision through `okstra approval-decision`; record its plan and clarification links only on activities. Do not promote `observed` / `deferred` / `record` items, and do not append `clarificationItems[]` directly.
@@ -0,0 +1,177 @@
1
+ """채택된 계획 검증 배치의 결정적 후처리."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import subprocess
7
+ from collections.abc import Mapping
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from okstra_ctl.convergence import validated_run_authority
12
+ from okstra_ctl.convergence_store import load_owned_json_object, write_json_atomic
13
+ from okstra_ctl.dispatch_state import dispatch_worker_key
14
+ from okstra_ctl.error_log_write import record_runtime_failure
15
+ from okstra_ctl.plan_items import PlanItemContractError, analyser_key, critic_is_rostered
16
+ from okstra_ctl.run_context import dir_flock
17
+ from okstra_ctl.worker_prompt_policy import (
18
+ is_plan_verify_dispatch_kind, verification_dispatch_round,
19
+ )
20
+ from .authoring import finish_verification_round, validate_current_review
21
+
22
+ if TYPE_CHECKING:
23
+ from okstra_ctl.dispatch_core import DispatchPlan
24
+
25
+
26
+ def _verifier_key(row: Mapping[str, Any]) -> str:
27
+ if row.get("assignmentRef") == "critic/scope":
28
+ return "critic"
29
+ return analyser_key(dispatch_worker_key(row))
30
+
31
+
32
+ def _round_dispatches(plan: DispatchPlan, team: Mapping[str, Any]) -> tuple[int, list[dict]] | None:
33
+ accepted = {row.get("dispatchId") for row in team.get("agentResultLinks", [])
34
+ if isinstance(row, Mapping) and not row.get("supersededBy")}
35
+ native = [{**row, "kind": row.get("dispatchKind", ""),
36
+ "status": "completed" if row.get("status") == "dispatched" and row.get("dispatchId") in accepted else row.get("status"),
37
+ "workerResultPath": row.get("workerResultPath") or next(
38
+ (link.get("resultPath") for link in team.get("agentResultLinks", [])
39
+ if link.get("dispatchId") == row.get("dispatchId") and not link.get("supersededBy")), "")}
40
+ for row in team.get("agentDispatches", []) if isinstance(row, dict)]
41
+ tails = [rows[-1] for rows in (team.get("workerDispatches", []), native) if rows]
42
+ if not plan.jobs and tails and all(
43
+ not is_plan_verify_dispatch_kind(str(row.get("kind", ""))) for row in tails
44
+ ):
45
+ return None
46
+ rows = [row for row in [*team.get("workerDispatches", []), *native] if isinstance(row, dict)
47
+ and is_plan_verify_dispatch_kind(str(row.get("kind", "")))]
48
+ targets = {verification_dispatch_round(job.dispatch_kind) for job in plan.jobs
49
+ if is_plan_verify_dispatch_kind(job.dispatch_kind)}
50
+ if plan.jobs and not targets:
51
+ return None
52
+ if not rows:
53
+ return None
54
+ if len(targets) > 1:
55
+ raise PlanItemContractError("one plan-verification batch cannot span multiple rounds")
56
+ number = next(iter(targets)) if targets else max(
57
+ verification_dispatch_round(row["kind"]) for row in rows
58
+ )
59
+ superseded = {row.get("dispatchId") for row in team.get("agentResultLinks", [])
60
+ if isinstance(row, Mapping) and row.get("supersededBy")}
61
+ latest: dict[str, dict] = {}
62
+ for row in rows:
63
+ if verification_dispatch_round(row["kind"]) != number or row.get("dispatchId") in superseded:
64
+ continue
65
+ key = _verifier_key(row)
66
+ previous = latest.get(key)
67
+ identity = row.get("invocationRef") or row.get("invocationId")
68
+ if previous and (not identity or identity != (previous.get("invocationRef") or previous.get("invocationId"))):
69
+ raise PlanItemContractError(f"ambiguous plan-verification invocations for {key}")
70
+ if previous is None or row.get("attempt", 1) > previous.get("attempt", 1):
71
+ latest[key] = row
72
+ return int(number), list(latest.values())
73
+
74
+
75
+ def _accepted_results(plan: DispatchPlan, team: Mapping[str, Any], rows: list[dict]) -> list[str]:
76
+ authority = validated_run_authority(plan.manifest_path)
77
+ execution = authority.execution_manifest
78
+ results = []
79
+ for row in rows:
80
+ declared_path = plan.project_root / str(row.get("workerResultPath", ""))
81
+ path = declared_path.resolve(strict=True)
82
+ if declared_path.is_symlink():
83
+ raise PlanItemContractError("plan-verification result must not be a symlink")
84
+ if not path.is_relative_to(authority.run_dir / "worker-results") or not path.is_file():
85
+ raise PlanItemContractError("plan-verification result must stay inside this run's worker-results")
86
+ links = [link for link in team.get("agentResultLinks", []) if isinstance(link, Mapping)
87
+ and link.get("dispatchId") == row.get("dispatchId") and not link.get("supersededBy")]
88
+ if len(links) != 1 or (plan.project_root / str(links[0].get("resultPath", ""))).resolve() != path:
89
+ raise PlanItemContractError("plan-verification result has no unique accepted result link")
90
+ if execution is not None:
91
+ attempts = [attempt for attempt in execution.attempts
92
+ if attempt.invocation_ref == row.get("invocationRef")
93
+ and attempt.attempt == row.get("attempt")]
94
+ if len(attempts) != 1 or attempts[0].status != "ok":
95
+ raise PlanItemContractError("plan-verification attempt is not accepted")
96
+ if (plan.project_root / str(attempts[0].result_path or "")).resolve() != path:
97
+ raise PlanItemContractError("accepted attempt result path does not match the dispatch")
98
+ token = f'-plan-verify-r{verification_dispatch_round(row["kind"])}-'
99
+ if token not in path.name:
100
+ raise PlanItemContractError("plan-verification result filename has the wrong round")
101
+ worker = path.name.split(token, 1)[0]
102
+ if analyser_key(worker) != _verifier_key(row):
103
+ raise PlanItemContractError("plan-verification result has the wrong worker")
104
+ results.append(f"{worker}={path}")
105
+ return sorted(results)
106
+
107
+
108
+ def _completion_receipt(plan: DispatchPlan, number: int, results: list[str]) -> dict[str, Any]:
109
+ review = validate_current_review(plan.manifest_path)
110
+ digest = hashlib.sha256()
111
+ for path in [review.items, review.narrative, *(Path(value.split("=", 1)[1]) for value in results)]:
112
+ digest.update(str(path).encode())
113
+ digest.update(path.read_bytes())
114
+ digest.update(json.dumps(results).encode())
115
+ fingerprint = digest.hexdigest()
116
+ receipt = review.state.with_name(f"plan-round-completion-r{number}-{review.state.name.removeprefix('plan-body-verification-')}")
117
+ state = load_owned_json_object(review.state)
118
+ if receipt.is_file():
119
+ recorded = load_owned_json_object(receipt)
120
+ if recorded.get("inputDigest") == fingerprint and recorded.get("stateDigest") == hashlib.sha256(review.state.read_bytes()).hexdigest():
121
+ return recorded
122
+ if any(row.get("round") == number for row in state.get("roundHistory", [])):
123
+ return {"status": "needs-lead", "round": number,
124
+ "reason": "round already closed; automatic completion will not replace its recorded inputs"}
125
+ prepared = load_owned_json_object(review.items)
126
+ if prepared.get("dispatchKind") == "reverify":
127
+ return {"status": "needs-lead", "round": number,
128
+ "reason": "self-fix re-verification needs the lead's cause groups and completion metadata"}
129
+ completed = finish_verification_round(review, results, number)
130
+ output = {**completed, "status": "completed", "round": number,
131
+ "inputDigest": fingerprint, "stateDigest": hashlib.sha256(review.state.read_bytes()).hexdigest(),
132
+ "receiptPath": str(receipt)}
133
+ write_json_atomic(receipt, output)
134
+ return output
135
+
136
+
137
+ def complete_ready_plan_round(plan: DispatchPlan) -> dict[str, Any] | None:
138
+ from okstra_ctl.dispatch_core import TERMINAL_DISPATCH_STATUSES
139
+
140
+ if plan.manifest.get("taskType") != "implementation-planning":
141
+ return None
142
+ number = 0
143
+ try:
144
+ with dir_flock(plan.team_state_path.parent, ".plan-round-completion.lock"):
145
+ team = load_owned_json_object(plan.team_state_path)
146
+ batch = _round_dispatches(plan, team)
147
+ if batch is None:
148
+ return None
149
+ number, rows = batch
150
+ review = validate_current_review(plan.manifest_path)
151
+ prepared = load_owned_json_object(review.items)
152
+ workers = {analyser_key(worker) for worker in prepared.get("reviewInputs", {}).get("workers", [])}
153
+ actual = {_verifier_key(row) for row in rows}
154
+ if prepared.get("dispatchKind") == "critic-tie":
155
+ if not critic_is_rostered(plan.manifest):
156
+ return {"status": "needs-lead", "round": number,
157
+ "reason": "the run has no rostered critic to settle this tie"}
158
+ if actual & workers and not actual - (workers | {"critic"}):
159
+ return {"status": "needs-lead", "round": number,
160
+ "reason": "critic shares an analyser round; preserve its queue through manual append"}
161
+ workers = {"critic"}
162
+ if actual - workers:
163
+ raise PlanItemContractError("plan-verification dispatch includes an unassigned verifier")
164
+ if not workers or actual != workers:
165
+ return {"status": "pending", "round": number, "reason": "waiting for the complete prepared verifier roster"}
166
+ if any(row.get("status") not in TERMINAL_DISPATCH_STATUSES for row in rows):
167
+ return {"status": "pending", "round": number, "reason": "waiting for every verifier to settle"}
168
+ if any(row.get("status") != "completed" for row in rows):
169
+ raise PlanItemContractError("one or more plan verifiers failed; preserve their results for lead recovery")
170
+ return _completion_receipt(plan, number, _accepted_results(plan, team, rows))
171
+ except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired) as exc:
172
+ output = {"status": "failed", "round": number, "reason": str(exc)}
173
+ logged = record_runtime_failure(plan.manifest_path, command="plan-round automatic completion",
174
+ exit_code=2, detail=str(exc))
175
+ if not logged["ok"]:
176
+ output["errorLogFailure"] = logged["reason"]
177
+ return output
@@ -1761,6 +1761,8 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1761
1761
  Path(ctx.get("PROJECT_ROOT", "")), task_manifest, {}
1762
1762
  ),
1763
1763
  "leadModel": ctx.get("LEAD_MODEL", ""),
1764
+ # `okstra-provider-exec.py` 가 워커를 띄울 때마다 읽는다.
1765
+ "reasoningEffort": ctx.get("REASONING_EFFORT", ""),
1764
1766
  # Every other path in this manifest is project-relative; this is the
1765
1767
  # anchor they resolve against. Convergence reads it to place run
1766
1768
  # artifacts, so a manifest without it cannot seed a round.