okstra 0.224.0 → 0.225.1

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 (68) 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/dist/commands/pr/pr.mjs +42 -13
  4. package/dist/commands/pr/pr.mjs.map +1 -1
  5. package/docs/cli.md +11 -3
  6. package/package.json +1 -1
  7. package/runtime/BUILD.json +2 -2
  8. package/runtime/bin/lib/okstra/cli.sh +5 -1
  9. package/runtime/bin/lib/okstra/globals.sh +1 -0
  10. package/runtime/bin/lib/okstra/usage.sh +4 -1
  11. package/runtime/bin/okstra-provider-exec.py +32 -16
  12. package/runtime/bin/okstra.sh +1 -0
  13. package/runtime/prompts/duties/code-reviewer.json +10 -5
  14. package/runtime/prompts/wizard/prompts.ko.json +10 -0
  15. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +2 -0
  16. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +3 -1
  17. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +6 -0
  18. package/runtime/python/okstra_ctl/adapters/providers/zai/adapter.py +5 -1
  19. package/runtime/python/okstra_ctl/agent/prompt_cli/results.py +25 -0
  20. package/runtime/python/okstra_ctl/cmux.py +4 -2
  21. package/runtime/python/okstra_ctl/code_review_coverage.py +237 -0
  22. package/runtime/python/okstra_ctl/code_review_target.py +31 -2
  23. package/runtime/python/okstra_ctl/coverage_census.py +2 -2
  24. package/runtime/python/okstra_ctl/dispatch_core.py +15 -0
  25. package/runtime/python/okstra_ctl/domain/worker_exec.py +6 -0
  26. package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +66 -0
  27. package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +5 -1
  28. package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +1 -0
  29. package/runtime/python/okstra_ctl/phases/implementation_planning/round_completion.py +177 -0
  30. package/runtime/python/okstra_ctl/render.py +2 -0
  31. package/runtime/python/okstra_ctl/run.py +23 -2
  32. package/runtime/python/okstra_ctl/team.py +12 -18
  33. package/runtime/python/okstra_ctl/wizard/__init__.py +1 -0
  34. package/runtime/python/okstra_ctl/wizard/confirmation.py +2 -0
  35. package/runtime/python/okstra_ctl/wizard/ids.py +3 -0
  36. package/runtime/python/okstra_ctl/wizard/registry.py +11 -0
  37. package/runtime/python/okstra_ctl/wizard/render.py +1 -0
  38. package/runtime/python/okstra_ctl/wizard/state.py +3 -0
  39. package/runtime/python/okstra_ctl/wizard/steps_options.py +41 -1
  40. package/runtime/python/okstra_ctl/worker_dispatch.py +9 -0
  41. package/runtime/python/okstra_ctl/worker_request.py +12 -1
  42. package/runtime/schemas/execution-manifest-v2.schema.json +1 -0
  43. package/runtime/skills/_fragments/bash-invocation-rule.md +1 -1
  44. package/runtime/skills/_fragments/host-question-interface.md +1 -0
  45. package/runtime/skills/_fragments/host-runtime-selection.md +1 -0
  46. package/runtime/skills/_fragments/wizard-bash-invocation-rule.md +1 -1
  47. package/runtime/skills/_fragments/wizard-preflight.md +1 -1
  48. package/runtime/skills/_fragments/wizard-render-bundle.md +4 -4
  49. package/runtime/skills/okstra-brief-gen/SKILL.md +45 -47
  50. package/runtime/skills/okstra-brief-gen/references/reporter-confirmations.md +3 -3
  51. package/runtime/skills/okstra-brief-gen/references/tracker-recursion.md +1 -1
  52. package/runtime/skills/okstra-chat/SKILL.md +1 -1
  53. package/runtime/skills/okstra-code-review/SKILL.md +28 -15
  54. package/runtime/skills/okstra-code-review/references/census-rules.md +52 -2
  55. package/runtime/skills/okstra-code-review/references/review-calibration.md +45 -15
  56. package/runtime/skills/okstra-container-build/SKILL.md +6 -2
  57. package/runtime/skills/okstra-discussion/SKILL.md +6 -6
  58. package/runtime/skills/okstra-inspect/SKILL.md +5 -3
  59. package/runtime/skills/okstra-manager/SKILL.md +8 -4
  60. package/runtime/skills/okstra-memory/SKILL.md +1 -1
  61. package/runtime/skills/okstra-pr-gen/SKILL.md +25 -7
  62. package/runtime/skills/okstra-rollup/SKILL.md +6 -2
  63. package/runtime/skills/okstra-run/SKILL.md +8 -7
  64. package/runtime/skills/okstra-schedule-gen/SKILL.md +13 -6
  65. package/runtime/skills/okstra-setup/SKILL.md +8 -4
  66. package/runtime/skills/okstra-setup/references/project-config.md +3 -3
  67. package/runtime/skills/okstra-usage/SKILL.md +5 -1
  68. package/runtime/skills/okstra-user-response/SKILL.md +7 -3
@@ -263,6 +263,11 @@ class GrokExecution:
263
263
  cwd = request.worktree_path or request.project_root
264
264
  # `-p` 기본 `plain` 은 종료 시에만 stdout 에 쓴다. 러너는 파이프라
265
265
  # TUI 도 열리지 않고, `--no-alt-screen` 도 그 침묵을 바꾸지 않는다.
266
+ effort = (
267
+ ("--reasoning-effort", request.reasoning_effort)
268
+ if request.reasoning_effort
269
+ else ()
270
+ )
266
271
  return ExecCommand(
267
272
  argv=(
268
273
  "grok",
@@ -270,6 +275,7 @@ class GrokExecution:
270
275
  request.prompt_text,
271
276
  "-m",
272
277
  request.model,
278
+ *effort,
273
279
  "--cwd",
274
280
  str(cwd),
275
281
  "--output-format",
@@ -83,7 +83,11 @@ def _read_zai_api_key() -> str:
83
83
  class ZaiExecution:
84
84
  def build_command(self, request: WorkerExecRequest) -> ExecCommand:
85
85
  api_key = _read_zai_api_key()
86
- command = ClaudeExecution().build_command(request)
86
+ # z.ai 의 Anthropic 호환 엔드포인트가 Claude Code 의 effort 를 받는지
87
+ # 확인되지 않았으므로 run 의 강도를 넘기지 않는다.
88
+ command = ClaudeExecution().build_command(
89
+ replace(request, reasoning_effort="")
90
+ )
87
91
  return replace(
88
92
  command,
89
93
  # 설정 파일의 env 가 자식 환경을 다시 덮어쓰지 않게 한다.
@@ -7,6 +7,7 @@ materialize 가 만든 명세에 대해, 이 층은 그 뒤에 오는 것들을
7
7
  from __future__ import annotations
8
8
 
9
9
  import argparse
10
+ import json
10
11
  from datetime import datetime, timezone
11
12
  from pathlib import Path
12
13
 
@@ -87,6 +88,30 @@ def _link_result(args: argparse.Namespace) -> None:
87
88
  dispatch_id=args.dispatch_id,
88
89
  result_path=_project_input(project_root, args.result, "agent result"),
89
90
  )
91
+ from ...dispatch_core import existing_dispatch_plan
92
+ from ...paths import RUN_VALIDATOR_RELATIVE, find_asset_root
93
+ from ...phases.implementation_planning.round_completion import complete_ready_plan_round
94
+ from ...worker_prompt_policy import is_plan_verify_dispatch_kind
95
+
96
+ manifest_path = _project_input(project_root, args.run_manifest, "run manifest")
97
+ manifest = _read_json_object(manifest_path, "run manifest")
98
+ if manifest.get("taskType") == "implementation-planning":
99
+ team = _read_json_object(project_root / manifest["teamStatePath"], "team-state")
100
+ records = [row for collection in ("workerDispatches", "agentDispatches")
101
+ for row in team.get(collection, []) if row.get("dispatchId") == args.dispatch_id]
102
+ if len(records) == 1 and is_plan_verify_dispatch_kind(str(records[0].get("kind") or records[0].get("dispatchKind", ""))):
103
+ assets = find_asset_root(RUN_VALIDATOR_RELATIVE)
104
+ if assets is None:
105
+ raise AgentPromptCliError("plan-body validator is unavailable")
106
+ completion = complete_ready_plan_round(existing_dispatch_plan(
107
+ project_root, assets, manifest_path, manifest,
108
+ ))
109
+ if completion:
110
+ link["planVerification"] = completion
111
+ if completion["status"] == "failed":
112
+ raise AgentPromptCliError(completion["reason"])
113
+ if not args.json:
114
+ print(json.dumps({"planVerification": completion}, ensure_ascii=False))
90
115
  _emit(link, args.json)
91
116
 
92
117
 
@@ -539,7 +539,7 @@ def spawn_worker_surface(
539
539
  # 호출이 반환되기 전에는 명부에 창 식별자가 없다. 여기서 정리하지
540
540
  # 않으면 대체 실행으로 전환된 뒤에도 회수할 수 없는 창이 남는다.
541
541
  try:
542
- closed = run_cmux(["close-surface", "--surface", surface_uuid])
542
+ closed = run_cmux(["close-surface", "--surface", surface_uuid, "--force"])
543
543
  if closed.returncode != 0:
544
544
  raise RuntimeError(closed.stderr.strip() or "cmux close failed")
545
545
  except (RuntimeError, OSError, subprocess.SubprocessError) as cleanup_error:
@@ -610,8 +610,10 @@ def rename_lead_surface(title: str) -> str:
610
610
 
611
611
  def close_surface(surface_uuid: str) -> None:
612
612
  """Close an okstra-created surface, killing whatever still runs inside it."""
613
+ # cmux 는 살아 있는 프로세스가 있는 창을 `--force` 없이 닫지 않는다
614
+ # (`confirmation_required`). 막 분할된 창의 로그인 셸도 그 프로세스로 센다.
613
615
  try:
614
- run_cmux(["close-surface", "--surface", surface_uuid])
616
+ run_cmux(["close-surface", "--surface", surface_uuid, "--force"])
615
617
  except (OSError, subprocess.SubprocessError):
616
618
  return
617
619
 
@@ -0,0 +1,237 @@
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 _checked_rule_evidence(
107
+ project_root: Path, cell: ReviewCell, checked: Sequence[str], text: str,
108
+ ) -> str:
109
+ anchor, _, raw = text.partition(" :: ")
110
+ _source_evidence(project_root, cell, anchor)
111
+ observations = _keyed_values(raw.strip() or "-")
112
+ required = set(checked) & {"G2", "G3", "G5"} if cell.group == "general" else set()
113
+ missing = required - observations.keys()
114
+ if missing:
115
+ raise ValueError("missing clean check evidence: " + ", ".join(sorted(missing)))
116
+ if observations.keys() - set(checked):
117
+ raise ValueError("clean check evidence names an unchecked rule")
118
+ for observation in observations.values():
119
+ match = re.fullmatch(r"(`?.+?:[1-9][0-9]*`?) (.+)", observation)
120
+ if match is None:
121
+ raise ValueError("clean check evidence needs source path:line and an observation")
122
+ if re.fullmatch(r"(?:checked|clean|no issues?|none|n/?a)", match[2], re.I):
123
+ raise ValueError("clean check evidence needs an observation, not a verdict label")
124
+ citation = match[1].strip("`")
125
+ source_cell = ReviewCell(citation.partition(":")[0], cell.group)
126
+ _source_evidence(project_root, source_cell, citation)
127
+ return text
128
+
129
+
130
+ def _group_verdict(
131
+ fields: Sequence[str], expected: Sequence[str], cell: ReviewCell,
132
+ project_root: Path, references: Sequence[str],
133
+ ) -> tuple[str, str]:
134
+ if len(fields) != 5:
135
+ raise ValueError("expected Cell, Checked, N/A, Findings, Evidence columns")
136
+ checked = [] if fields[1] == "-" else [rule.strip() for rule in fields[1].split(",")]
137
+ excluded = _keyed_values(fields[2])
138
+ findings = _keyed_values(fields[3])
139
+ assigned = checked + list(excluded) + list(findings)
140
+ if len(set(assigned)) != len(assigned):
141
+ raise ValueError("duplicate or conflicting rule verdicts")
142
+ unknown = set(assigned) - set(expected)
143
+ if unknown:
144
+ raise ValueError("unknown rules: " + ", ".join(sorted(unknown)))
145
+ missing = set(expected) - set(assigned)
146
+ if missing:
147
+ raise ValueError("unchecked rules: " + ", ".join(sorted(missing)))
148
+ for reason in excluded.values():
149
+ if re.fullmatch(r"(?:[A-Z][0-9]+|[a-z]+:[0-9]+)", reason):
150
+ raise ValueError("n/a needs an inline applicability reason, not an alias")
151
+ for ids in findings.values():
152
+ for item in ids.split(","):
153
+ item = item.strip()
154
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", item):
155
+ raise ValueError("invalid finding ID")
156
+ if not any(id_occurs_wordbounded(body, item) for body in references):
157
+ raise ValueError(f"finding {item} has no finding body")
158
+ evidence = _checked_rule_evidence(project_root, cell, checked, fields[4])
159
+ if findings:
160
+ return "finding", "; ".join(findings.values())
161
+ if checked:
162
+ return "clean", evidence
163
+ return "n/a", "; ".join(excluded.values())
164
+
165
+
166
+ def _group_results(
167
+ body: str, census: ReviewCensus, project_root: Path, references: Sequence[str],
168
+ ) -> CellVerdicts:
169
+ section, _ = split_coverage_verdict_section(body)
170
+ verdicts: dict[str, tuple[str, str]] = {}
171
+ reasons: dict[str, str] = {}
172
+ seen: set[str] = set()
173
+ unknown: list[str] = []
174
+ for line in section:
175
+ if not line.strip().startswith("|"):
176
+ continue
177
+ fields = _table_fields(line)
178
+ identity = fields[0]
179
+ if identity not in census.cells:
180
+ if identity.startswith("C-") and identity not in unknown:
181
+ unknown.append(identity)
182
+ continue
183
+ if identity in seen:
184
+ verdicts.pop(identity, None)
185
+ reasons[identity] = "duplicate cell row"
186
+ continue
187
+ seen.add(identity)
188
+ cell = census.cells[identity]
189
+ try:
190
+ verdicts[identity] = _group_verdict(
191
+ fields, census.rules[cell.group], cell, project_root, references,
192
+ )
193
+ except (OSError, ValueError) as exc:
194
+ reasons[identity] = str(exc)
195
+ return CellVerdicts(
196
+ verdicts,
197
+ {key: reasons.get(key, "no grouped verdict row") for key in census.cells if key not in verdicts},
198
+ tuple(unknown),
199
+ )
200
+
201
+
202
+ def check_review_coverage(
203
+ project_root: Path, census_path: Path, completions: Sequence[Path],
204
+ ) -> CellVerdicts:
205
+ census = read_review_census(census_path)
206
+ if not completions or len(set(completions)) != len(completions):
207
+ raise ValueError("pass distinct completion paths for one reviewer")
208
+ bodies: list[str] = []
209
+ assignment: object = None
210
+ for path in completions:
211
+ verified = verify_standalone_completion(
212
+ path, project_root=project_root, expected_purpose="code-review",
213
+ )
214
+ metadata = load_owned_object(
215
+ verified.prompt_metadata_path, artifact="agent invocation metadata",
216
+ )
217
+ if metadata["audience"] != "code-reviewer":
218
+ raise AgentInvocationError("completion is not a code reviewer")
219
+ current = metadata["modelAssignment"]
220
+ if assignment is not None and current != assignment:
221
+ raise AgentInvocationError("coverage cannot mix reviewer model assignments")
222
+ assignment = current
223
+ bodies.append(verified.returned_body)
224
+ verdicts: dict[str, tuple[str, str]] = {}
225
+ missing: dict[str, str] = {}
226
+ unknown: list[str] = []
227
+ references = [split_coverage_verdict_section(body)[1] for body in bodies]
228
+ for body in bodies:
229
+ parsed = _group_results(body, census, project_root, references)
230
+ unknown.extend(key for key in parsed.unknown if key not in unknown)
231
+ for key, verdict in parsed.verdicts.items():
232
+ verdicts.setdefault(key, verdict)
233
+ missing.update(parsed.missing)
234
+ return CellVerdicts(
235
+ verdicts, {key: missing[key] for key in census.cells if key not in verdicts},
236
+ tuple(unknown),
237
+ )
@@ -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.