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.
- package/dist/commands/execute/render-bundle.mjs +7 -5
- package/dist/commands/execute/render-bundle.mjs.map +1 -1
- package/dist/commands/pr/pr.mjs +42 -13
- package/dist/commands/pr/pr.mjs.map +1 -1
- package/docs/cli.md +11 -3
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/lib/okstra/cli.sh +5 -1
- package/runtime/bin/lib/okstra/globals.sh +1 -0
- package/runtime/bin/lib/okstra/usage.sh +4 -1
- package/runtime/bin/okstra-provider-exec.py +32 -16
- package/runtime/bin/okstra.sh +1 -0
- package/runtime/prompts/duties/code-reviewer.json +10 -5
- package/runtime/prompts/wizard/prompts.ko.json +10 -0
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +2 -0
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +3 -1
- package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +6 -0
- package/runtime/python/okstra_ctl/adapters/providers/zai/adapter.py +5 -1
- package/runtime/python/okstra_ctl/agent/prompt_cli/results.py +25 -0
- package/runtime/python/okstra_ctl/cmux.py +4 -2
- package/runtime/python/okstra_ctl/code_review_coverage.py +237 -0
- package/runtime/python/okstra_ctl/code_review_target.py +31 -2
- package/runtime/python/okstra_ctl/coverage_census.py +2 -2
- package/runtime/python/okstra_ctl/dispatch_core.py +15 -0
- package/runtime/python/okstra_ctl/domain/worker_exec.py +6 -0
- package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +66 -0
- package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +5 -1
- package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +1 -0
- package/runtime/python/okstra_ctl/phases/implementation_planning/round_completion.py +177 -0
- package/runtime/python/okstra_ctl/render.py +2 -0
- package/runtime/python/okstra_ctl/run.py +23 -2
- package/runtime/python/okstra_ctl/team.py +12 -18
- package/runtime/python/okstra_ctl/wizard/__init__.py +1 -0
- package/runtime/python/okstra_ctl/wizard/confirmation.py +2 -0
- package/runtime/python/okstra_ctl/wizard/ids.py +3 -0
- package/runtime/python/okstra_ctl/wizard/registry.py +11 -0
- package/runtime/python/okstra_ctl/wizard/render.py +1 -0
- package/runtime/python/okstra_ctl/wizard/state.py +3 -0
- package/runtime/python/okstra_ctl/wizard/steps_options.py +41 -1
- package/runtime/python/okstra_ctl/worker_dispatch.py +9 -0
- package/runtime/python/okstra_ctl/worker_request.py +12 -1
- package/runtime/schemas/execution-manifest-v2.schema.json +1 -0
- package/runtime/skills/_fragments/bash-invocation-rule.md +1 -1
- package/runtime/skills/_fragments/host-question-interface.md +1 -0
- package/runtime/skills/_fragments/host-runtime-selection.md +1 -0
- package/runtime/skills/_fragments/wizard-bash-invocation-rule.md +1 -1
- package/runtime/skills/_fragments/wizard-preflight.md +1 -1
- package/runtime/skills/_fragments/wizard-render-bundle.md +4 -4
- package/runtime/skills/okstra-brief-gen/SKILL.md +45 -47
- package/runtime/skills/okstra-brief-gen/references/reporter-confirmations.md +3 -3
- package/runtime/skills/okstra-brief-gen/references/tracker-recursion.md +1 -1
- package/runtime/skills/okstra-chat/SKILL.md +1 -1
- package/runtime/skills/okstra-code-review/SKILL.md +28 -15
- package/runtime/skills/okstra-code-review/references/census-rules.md +52 -2
- package/runtime/skills/okstra-code-review/references/review-calibration.md +45 -15
- package/runtime/skills/okstra-container-build/SKILL.md +6 -2
- package/runtime/skills/okstra-discussion/SKILL.md +6 -6
- package/runtime/skills/okstra-inspect/SKILL.md +5 -3
- package/runtime/skills/okstra-manager/SKILL.md +8 -4
- package/runtime/skills/okstra-memory/SKILL.md +1 -1
- package/runtime/skills/okstra-pr-gen/SKILL.md +25 -7
- package/runtime/skills/okstra-rollup/SKILL.md +6 -2
- package/runtime/skills/okstra-run/SKILL.md +8 -7
- package/runtime/skills/okstra-schedule-gen/SKILL.md +13 -6
- package/runtime/skills/okstra-setup/SKILL.md +8 -4
- package/runtime/skills/okstra-setup/references/project-config.md +3 -3
- package/runtime/skills/okstra-usage/SKILL.md +5 -1
- 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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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.
|