okstra 0.206.1 → 0.207.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/README.md +1 -1
- package/dist/cli-registry.mjs +7 -1
- package/dist/cli-registry.mjs.map +1 -1
- package/docs/architecture/storage-model.md +1 -0
- package/docs/architecture.md +28 -4
- package/docs/cli.md +13 -11
- package/docs/project-structure-overview.md +4 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/operations/code-review.json +1 -1
- package/runtime/bin/lib/okstra/usage.sh +3 -3
- package/runtime/bin/okstra-compact-reminder.sh +1 -1
- package/runtime/prompts/duties/direction-selection-worker.json +1 -1
- package/runtime/prompts/launch.template.md +1 -1
- package/runtime/prompts/lead/adapters/cmux.md +4 -3
- package/runtime/prompts/lead/convergence.md +41 -9
- package/runtime/prompts/lead/okstra-lead-contract.md +31 -19
- package/runtime/prompts/lead/report-writer.md +8 -6
- package/runtime/prompts/profiles/_clarification-recommendation.md +4 -4
- package/runtime/prompts/profiles/_common-contract.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +2 -1
- package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +5 -5
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +3 -2
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +17 -26
- package/runtime/python/okstra_ctl/agent/prompt_cli/batch.py +183 -0
- package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +60 -10
- package/runtime/python/okstra_ctl/agent/prompt_cli/jobs.py +21 -4
- package/runtime/python/okstra_ctl/approval_decisions.py +32 -2
- package/runtime/python/okstra_ctl/assignment_resolver.py +8 -0
- package/runtime/python/okstra_ctl/blocking_checks.py +7 -0
- package/runtime/python/okstra_ctl/code_review_target.py +92 -6
- package/runtime/python/okstra_ctl/dispatch_checkpoints.py +121 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +54 -32
- package/runtime/python/okstra_ctl/dispatch_state.py +12 -5
- package/runtime/python/okstra_ctl/domain/provider.py +5 -0
- package/runtime/python/okstra_ctl/domain/worker_presentation.py +21 -2
- package/runtime/python/okstra_ctl/domain/write_policy.py +2 -1
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +19 -8
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +5 -0
- package/runtime/python/okstra_ctl/lead_progress.py +33 -1
- package/runtime/python/okstra_ctl/manager_view.py +34 -21
- package/runtime/python/okstra_ctl/model_io/lines.py +21 -4
- package/runtime/python/okstra_ctl/models.py +4 -1
- package/runtime/python/okstra_ctl/operation_invocation.py +11 -2
- package/runtime/python/okstra_ctl/phases/final_verification/profile.md +1 -1
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-executor.md +1 -1
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-verifier.md +14 -3
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/profile.md +1 -1
- package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +1 -1
- package/runtime/python/okstra_ctl/phases/technical_verification/profile.md +1 -1
- package/runtime/python/okstra_ctl/process_group.py +118 -0
- package/runtime/python/okstra_ctl/render.py +6 -2
- package/runtime/python/okstra_ctl/report_assembly.py +17 -2
- package/runtime/python/okstra_ctl/report_finalize.py +106 -2
- package/runtime/python/okstra_ctl/run.py +1 -1
- package/runtime/python/okstra_ctl/run_artifact_prune.py +200 -0
- package/runtime/python/okstra_ctl/team.py +108 -9
- package/runtime/python/okstra_ctl/wizard/steps_options.py +8 -0
- package/runtime/python/okstra_ctl/worker_dispatch.py +44 -3
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +19 -0
- package/runtime/python/okstra_ctl/worker_runner.py +21 -3
- package/runtime/python/okstra_ctl/write_policy.py +57 -7
- package/runtime/python/okstra_project/dirs.py +14 -0
- package/runtime/python/okstra_project/resolver.py +2 -1
- package/runtime/schemas/execution-manifest-v2.schema.json +2 -1
- package/runtime/skills/okstra-code-review/SKILL.md +70 -32
- package/runtime/skills/okstra-code-review/references/review-calibration.md +26 -6
- package/runtime/skills/okstra-run/SKILL.md +2 -2
- package/runtime/templates/manager/view.template.html +21 -1
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""dispatch 배치 하나를 한 번의 호출로 materialize 하고, 원하면 jobs 파일까지 쓴다.
|
|
2
|
+
|
|
3
|
+
항목마다 단일 `materialize` 호출과 같은 파서와 `_materialize` 를 거치므로, 항목은
|
|
4
|
+
그 호출이 낼 오류와 똑같이 실패한다. 여기에는 반복과 jobs 넘기기만 있다.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Callable
|
|
11
|
+
|
|
12
|
+
from ..invocation import AgentInvocationError
|
|
13
|
+
from ...dispatch_state import DispatchError
|
|
14
|
+
from ...json_boundary import JsonBoundaryError, load_owned_object
|
|
15
|
+
from ...worker_prompt_headers import WorkerPromptHeaderError
|
|
16
|
+
from .emit import _prepared_payload
|
|
17
|
+
from .inputs import AgentPromptCliError, _project_input, _project_root
|
|
18
|
+
from .jobs import check_jobs_target, generate_jobs
|
|
19
|
+
from .materialize import _materialize
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_ENTRY_FAILURES = (
|
|
23
|
+
AgentPromptCliError, AgentInvocationError, DispatchError,
|
|
24
|
+
WorkerPromptHeaderError, OSError, ValueError,
|
|
25
|
+
)
|
|
26
|
+
_REQUIRED_FLAGS = ("invocation-id", "audience", "instruction", "prompt")
|
|
27
|
+
_STANDALONE_FLAGS = frozenset({
|
|
28
|
+
"host-runtime", "terminal-backend", "provider", "model-role", "model", "purpose",
|
|
29
|
+
})
|
|
30
|
+
_ENTRY_FLAGS = frozenset({
|
|
31
|
+
"invocation-id", "audience", "instruction", "prompt", "worker-id",
|
|
32
|
+
"dispatch-kind", "assignment-ref", "source-role-execution-ref", "result",
|
|
33
|
+
"corrections", "audit-source", "replace-undispatched",
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def materialize_batch(
|
|
38
|
+
args: argparse.Namespace,
|
|
39
|
+
parser_factory: Callable[[], argparse.ArgumentParser],
|
|
40
|
+
) -> dict[str, object]:
|
|
41
|
+
entries, dispatch_kind = _validated_batch(args, parser_factory)
|
|
42
|
+
prepared = _publish(entries)
|
|
43
|
+
jobs_path = write_jobs(args, prepared, dispatch_kind) if args.jobs_out else None
|
|
44
|
+
return {"ok": True, "invocations": prepared, "jobsPath": jobs_path}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _validated_batch(
|
|
48
|
+
args: argparse.Namespace,
|
|
49
|
+
parser_factory: Callable[[], argparse.ArgumentParser],
|
|
50
|
+
) -> tuple[list[argparse.Namespace], str]:
|
|
51
|
+
"""배치 전체를 발행 전에 검사한다 — 어느 항목도 쓰기 전에 거부할 수 있는 것은 전부 여기서."""
|
|
52
|
+
if not args.run_manifest:
|
|
53
|
+
raise AgentPromptCliError("materialize --batch requires --run-manifest")
|
|
54
|
+
stray = sorted(
|
|
55
|
+
"--" + flag for flag in _ENTRY_FLAGS | _STANDALONE_FLAGS
|
|
56
|
+
if getattr(args, flag.replace("-", "_"))
|
|
57
|
+
)
|
|
58
|
+
if stray:
|
|
59
|
+
raise AgentPromptCliError(
|
|
60
|
+
"with --batch pass only --project-root, --run-manifest, --jobs-out "
|
|
61
|
+
"and --json; per-invocation flags belong in the batch entries: "
|
|
62
|
+
+ ", ".join(stray)
|
|
63
|
+
)
|
|
64
|
+
project = _project_root(args.project_root)
|
|
65
|
+
entries = [
|
|
66
|
+
_entry_args(args, index, entry, parser_factory)
|
|
67
|
+
for index, entry in enumerate(_load_entries(project, args.batch), start=1)
|
|
68
|
+
]
|
|
69
|
+
ids = [entry.invocation_id for entry in entries]
|
|
70
|
+
if len(set(ids)) != len(ids):
|
|
71
|
+
raise AgentPromptCliError("batch repeats an invocation id")
|
|
72
|
+
kinds = {entry.dispatch_kind for entry in entries}
|
|
73
|
+
if args.jobs_out and len(kinds) != 1:
|
|
74
|
+
raise AgentPromptCliError(
|
|
75
|
+
"--jobs-out needs one dispatch kind; this batch has "
|
|
76
|
+
+ ", ".join(sorted(str(kind) for kind in kinds))
|
|
77
|
+
)
|
|
78
|
+
if args.jobs_out:
|
|
79
|
+
check_jobs_target(args.project_root, args.run_manifest, args.jobs_out)
|
|
80
|
+
return entries, next(iter(kinds))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _publish(entries: list[argparse.Namespace]) -> list[dict[str, object]]:
|
|
84
|
+
prepared: list[dict[str, object]] = []
|
|
85
|
+
for index, entry in enumerate(entries, start=1):
|
|
86
|
+
try:
|
|
87
|
+
prepared.append(_prepared_payload(_materialize(entry), entry.audience))
|
|
88
|
+
except _ENTRY_FAILURES as exc:
|
|
89
|
+
raise AgentPromptCliError(
|
|
90
|
+
f"batch entry {index} ({entry.invocation_id}): {exc}; "
|
|
91
|
+
+ _published_note(prepared)
|
|
92
|
+
) from exc
|
|
93
|
+
return prepared
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def write_jobs(
|
|
97
|
+
args: argparse.Namespace,
|
|
98
|
+
prepared: list[dict[str, object]],
|
|
99
|
+
dispatch_kind: str,
|
|
100
|
+
) -> str:
|
|
101
|
+
"""materialize 된 호출들을 그대로 `agent-prompt jobs` 에 넘긴다."""
|
|
102
|
+
try:
|
|
103
|
+
return str(generate_jobs(argparse.Namespace(
|
|
104
|
+
project_root=args.project_root,
|
|
105
|
+
run_manifest=args.run_manifest,
|
|
106
|
+
dispatch_kind=dispatch_kind,
|
|
107
|
+
metadata=[str(row["metadataPath"]) for row in prepared],
|
|
108
|
+
out=args.jobs_out,
|
|
109
|
+
))["jobsPath"])
|
|
110
|
+
except _ENTRY_FAILURES as exc:
|
|
111
|
+
raise AgentPromptCliError(
|
|
112
|
+
f"jobs file not written: {exc}; " + _published_note(prepared)
|
|
113
|
+
) from exc
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _published_note(prepared: list[dict[str, object]]) -> str:
|
|
117
|
+
if not prepared:
|
|
118
|
+
return "no entry was materialized"
|
|
119
|
+
# 발행된 항목은 발행된 채 남는다. 같은 배치를 다시 돌리면 새로 쓰지 않고
|
|
120
|
+
# 동일한 프롬프트·메타데이터·예약을 재사용한다.
|
|
121
|
+
return (
|
|
122
|
+
"already materialized and reused unchanged by an identical rerun: "
|
|
123
|
+
+ ", ".join(str(row["invocationId"]) for row in prepared)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _load_entries(project: Path, raw: str) -> list[object]:
|
|
128
|
+
path = _project_input(project, raw, "batch")
|
|
129
|
+
try:
|
|
130
|
+
payload = load_owned_object(path, artifact="agent prompt batch")
|
|
131
|
+
except JsonBoundaryError as exc:
|
|
132
|
+
raise AgentPromptCliError(f"batch file is invalid: {exc}") from exc
|
|
133
|
+
entries = payload.get("invocations")
|
|
134
|
+
if set(payload) != {"invocations"} or not isinstance(entries, list) or not entries:
|
|
135
|
+
raise AgentPromptCliError(
|
|
136
|
+
'batch file must be {"invocations": [...]} with at least one entry'
|
|
137
|
+
)
|
|
138
|
+
return entries
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _entry_args(
|
|
142
|
+
args: argparse.Namespace,
|
|
143
|
+
index: int,
|
|
144
|
+
entry: object,
|
|
145
|
+
parser_factory: Callable[[], argparse.ArgumentParser],
|
|
146
|
+
) -> argparse.Namespace:
|
|
147
|
+
if not isinstance(entry, dict):
|
|
148
|
+
raise AgentPromptCliError(f"batch entry {index} must be an object")
|
|
149
|
+
unknown = sorted(set(entry) - _ENTRY_FLAGS)
|
|
150
|
+
if unknown:
|
|
151
|
+
raise AgentPromptCliError(
|
|
152
|
+
f"batch entry {index} has keys outside the materialize flags: "
|
|
153
|
+
+ ", ".join(unknown)
|
|
154
|
+
)
|
|
155
|
+
argv = [
|
|
156
|
+
"materialize",
|
|
157
|
+
"--project-root", str(args.project_root),
|
|
158
|
+
"--run-manifest", str(args.run_manifest),
|
|
159
|
+
]
|
|
160
|
+
for key, value in entry.items():
|
|
161
|
+
if value is True and key == "replace-undispatched":
|
|
162
|
+
argv.append(f"--{key}")
|
|
163
|
+
elif isinstance(value, str) and key != "replace-undispatched":
|
|
164
|
+
argv.extend([f"--{key}", value])
|
|
165
|
+
else:
|
|
166
|
+
raise AgentPromptCliError(f"batch entry {index}: invalid value for {key}")
|
|
167
|
+
try:
|
|
168
|
+
parsed = parser_factory().parse_args(argv)
|
|
169
|
+
except SystemExit as exc:
|
|
170
|
+
raise AgentPromptCliError(
|
|
171
|
+
f"batch entry {index}: invalid materialize arguments"
|
|
172
|
+
) from exc
|
|
173
|
+
require_invocation_flags(parsed, f"batch entry {index}")
|
|
174
|
+
return parsed
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def require_invocation_flags(args: argparse.Namespace, label: str) -> None:
|
|
178
|
+
missing = [
|
|
179
|
+
"--" + flag for flag in _REQUIRED_FLAGS
|
|
180
|
+
if not getattr(args, flag.replace("-", "_"))
|
|
181
|
+
]
|
|
182
|
+
if missing:
|
|
183
|
+
raise AgentPromptCliError(f"{label} requires: " + ", ".join(missing))
|
|
@@ -16,6 +16,8 @@ from ...error_log_write import record_runtime_failure
|
|
|
16
16
|
from ...dispatch_state import BACKEND_CLI_WRAPPER, BACKEND_CMUX_PANE, DispatchError
|
|
17
17
|
from .emit import _emit, _prepared_payload
|
|
18
18
|
from .inputs import AgentPromptCliError
|
|
19
|
+
from .batch import materialize_batch, require_invocation_flags, write_jobs
|
|
20
|
+
from .jobs import check_jobs_target
|
|
19
21
|
from .materialize import _apply_corrections, _check_corrections, _materialize
|
|
20
22
|
from .jobs import generate_jobs
|
|
21
23
|
from .results import (
|
|
@@ -31,7 +33,8 @@ from .results import (
|
|
|
31
33
|
|
|
32
34
|
|
|
33
35
|
_CLI_EPILOG = r"""Usage:
|
|
34
|
-
okstra agent-prompt materialize [options]
|
|
36
|
+
okstra agent-prompt materialize [options] [--jobs-out <path>]
|
|
37
|
+
okstra agent-prompt materialize --project-root <dir> --run-manifest <path> --batch <file> [--jobs-out <path>]
|
|
35
38
|
okstra agent-prompt jobs --project-root <dir> --run-manifest <path> --dispatch-kind <kind> --metadata <path> [--metadata <path>] --out <path>
|
|
36
39
|
okstra agent-prompt check-corrections --project-root <dir> --run-manifest <path> --corrections <file>
|
|
37
40
|
okstra agent-prompt apply-corrections --project-root <dir> --run-manifest <path> --corrections <file>
|
|
@@ -63,6 +66,10 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
63
66
|
help="resolve the role, worker count and models an operation requires",
|
|
64
67
|
)
|
|
65
68
|
operation.add_argument("--operation", required=True)
|
|
69
|
+
operation.add_argument(
|
|
70
|
+
"--count", type=int, default=None,
|
|
71
|
+
help="slots to open, from 1 to the operation contract's count (default: the contract's count)",
|
|
72
|
+
)
|
|
66
73
|
operation.add_argument("--json", action="store_true")
|
|
67
74
|
jobs = commands.add_parser("jobs", help="generate a verified jobs file without dispatching")
|
|
68
75
|
_common_paths(jobs)
|
|
@@ -77,10 +84,11 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
77
84
|
def _add_materialize_parser(commands: argparse._SubParsersAction) -> None:
|
|
78
85
|
materialize = commands.add_parser("materialize")
|
|
79
86
|
_common_paths(materialize)
|
|
80
|
-
|
|
81
|
-
materialize.add_argument("--
|
|
82
|
-
materialize.add_argument("--
|
|
83
|
-
materialize.add_argument("--
|
|
87
|
+
single_only = "required unless --batch is given"
|
|
88
|
+
materialize.add_argument("--invocation-id", help=single_only)
|
|
89
|
+
materialize.add_argument("--audience", help=single_only)
|
|
90
|
+
materialize.add_argument("--instruction", help=single_only)
|
|
91
|
+
materialize.add_argument("--prompt", help=single_only)
|
|
84
92
|
# 두 갈래의 인자를 한 파서가 받는다. 어느 갈래의 것인지 help 에 적지 않으면
|
|
85
93
|
# `--help` 가 조건 없이 나열한 인자를 run 갈래가 거절해 놀라게 된다.
|
|
86
94
|
run_only = "run mode only (with --run-manifest)"
|
|
@@ -130,6 +138,20 @@ def _add_materialize_parser(commands: argparse._SubParsersAction) -> None:
|
|
|
130
138
|
"reservations are append-only, so a rewrite needs a fresh "
|
|
131
139
|
"invocation id and prompt path",
|
|
132
140
|
)
|
|
141
|
+
materialize.add_argument(
|
|
142
|
+
"--batch",
|
|
143
|
+
help="run mode only: a JSON file {\"invocations\": [...]} whose "
|
|
144
|
+
"entries map the per-invocation flags above (without the leading "
|
|
145
|
+
"--) to values, one entry per worker of the batch; each entry is "
|
|
146
|
+
"materialized exactly as a single call would be",
|
|
147
|
+
)
|
|
148
|
+
materialize.add_argument(
|
|
149
|
+
"--jobs-out",
|
|
150
|
+
help="run mode only: also write the verified jobs file for "
|
|
151
|
+
"`okstra team dispatch --jobs-file` from the materialized "
|
|
152
|
+
"invocations, as `okstra agent-prompt jobs` would; every batch "
|
|
153
|
+
"entry must share one --dispatch-kind",
|
|
154
|
+
)
|
|
133
155
|
materialize.add_argument("--json", action="store_true")
|
|
134
156
|
|
|
135
157
|
check = commands.add_parser(
|
|
@@ -277,7 +299,7 @@ def _refreeze_contracts(args: argparse.Namespace) -> dict[str, object]:
|
|
|
277
299
|
}
|
|
278
300
|
|
|
279
301
|
|
|
280
|
-
def _resolve_operation(operation_id: str) -> dict[str, object]:
|
|
302
|
+
def _resolve_operation(operation_id: str, count: int | None) -> dict[str, object]:
|
|
281
303
|
"""이 작업이 열 칸과 각 칸의 모델. 기술은 작업 이름만 알면 된다."""
|
|
282
304
|
from ...model_pool import ModelPool
|
|
283
305
|
from ...operation_invocation import (
|
|
@@ -298,6 +320,7 @@ def _resolve_operation(operation_id: str) -> dict[str, object]:
|
|
|
298
320
|
operation_id,
|
|
299
321
|
contract_root=root,
|
|
300
322
|
pool=ModelPool.from_registry(default_provider_registry()),
|
|
323
|
+
count=count,
|
|
301
324
|
)
|
|
302
325
|
except OperationPreparationError as exc:
|
|
303
326
|
raise AgentPromptCliError(str(exc)) from exc
|
|
@@ -318,11 +341,38 @@ def _resolve_operation(operation_id: str) -> dict[str, object]:
|
|
|
318
341
|
}
|
|
319
342
|
|
|
320
343
|
|
|
344
|
+
def _materialize_batch_command(args: argparse.Namespace) -> int:
|
|
345
|
+
payload = materialize_batch(args, _parser)
|
|
346
|
+
if args.json:
|
|
347
|
+
_emit(payload, True)
|
|
348
|
+
return 0
|
|
349
|
+
for row in payload["invocations"]:
|
|
350
|
+
print(row["promptPath"])
|
|
351
|
+
if payload["jobsPath"]:
|
|
352
|
+
print(payload["jobsPath"])
|
|
353
|
+
return 0
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _materialize_single_command(args: argparse.Namespace) -> int:
|
|
357
|
+
require_invocation_flags(args, "materialize")
|
|
358
|
+
if args.jobs_out:
|
|
359
|
+
if not args.run_manifest:
|
|
360
|
+
raise AgentPromptCliError("--jobs-out requires --run-manifest")
|
|
361
|
+
check_jobs_target(args.project_root, args.run_manifest, args.jobs_out)
|
|
362
|
+
payload = _prepared_payload(_materialize(args), args.audience)
|
|
363
|
+
if args.jobs_out:
|
|
364
|
+
payload["jobsPath"] = write_jobs(args, [payload], args.dispatch_kind)
|
|
365
|
+
_emit(payload, args.json)
|
|
366
|
+
if args.jobs_out and not args.json:
|
|
367
|
+
print(payload["jobsPath"])
|
|
368
|
+
return 0
|
|
369
|
+
|
|
370
|
+
|
|
321
371
|
def main(argv: list[str] | None = None) -> int:
|
|
322
372
|
try:
|
|
323
373
|
args = _parser().parse_args(argv)
|
|
324
374
|
if args.command == "resolve-operation":
|
|
325
|
-
payload = _resolve_operation(args.operation)
|
|
375
|
+
payload = _resolve_operation(args.operation, args.count)
|
|
326
376
|
if args.json:
|
|
327
377
|
_emit(payload, True)
|
|
328
378
|
else:
|
|
@@ -346,9 +396,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
346
396
|
print(payload["jobsPath"])
|
|
347
397
|
return 0
|
|
348
398
|
if args.command == "materialize":
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
return
|
|
399
|
+
if args.batch:
|
|
400
|
+
return _materialize_batch_command(args)
|
|
401
|
+
return _materialize_single_command(args)
|
|
352
402
|
if args.command == "check-corrections":
|
|
353
403
|
payload = _check_corrections(args)
|
|
354
404
|
if args.json:
|
|
@@ -19,13 +19,30 @@ from .inputs import (
|
|
|
19
19
|
)
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
def
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
def check_jobs_target(project_root: str, run_manifest: str, out: str) -> None:
|
|
23
|
+
"""jobs 파일을 쓸 수 있는 run 인지 — `materialize --jobs-out` 이 발행 전에 부른다.
|
|
24
|
+
|
|
25
|
+
발행 뒤에 이 검사에서 거부되면 프롬프트만 발행된 채 남는다.
|
|
26
|
+
"""
|
|
27
|
+
_jobs_target(project_root, run_manifest, out)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _jobs_target(
|
|
31
|
+
project_root: str, run_manifest: str, out: str,
|
|
32
|
+
) -> tuple[Path, Path, dict, Path]:
|
|
33
|
+
project = _project_root(project_root)
|
|
34
|
+
manifest_path = _project_input(project, run_manifest, "run manifest")
|
|
25
35
|
manifest = _read_json_object(manifest_path, "run manifest")
|
|
26
36
|
if manifest.get("executionIdentityVersion") != 2:
|
|
27
37
|
raise AgentPromptCliError("agent-prompt jobs requires a v2 run manifest")
|
|
28
|
-
output = _authorized_path(project,
|
|
38
|
+
output = _authorized_path(project, out, [manifest["runDirectoryPath"]], "jobs", must_exist=False)
|
|
39
|
+
return project, manifest_path, manifest, output
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def generate_jobs(args: argparse.Namespace) -> dict[str, object]:
|
|
43
|
+
project, manifest_path, manifest, output = _jobs_target(
|
|
44
|
+
args.project_root, args.run_manifest, args.out,
|
|
45
|
+
)
|
|
29
46
|
paths = [_project_input(project, value, "metadata") for value in args.metadata]
|
|
30
47
|
rows = [_metadata_job(project, manifest_path, manifest, path, args.dispatch_kind) for path in paths]
|
|
31
48
|
refs = [(row["invocationRef"], row["attempt"]) for row in rows]
|
|
@@ -222,7 +222,7 @@ def open_decision(
|
|
|
222
222
|
clarification_id: str, ticket_id: str, statement: str, expected_form: str,
|
|
223
223
|
classification: str, origin: str, user_confirmation: str,
|
|
224
224
|
unblock_condition: str, recommended_disposition: str,
|
|
225
|
-
options: Sequence[DecisionOption],
|
|
225
|
+
options: Sequence[DecisionOption], supersedes: Sequence[str] = (),
|
|
226
226
|
) -> None:
|
|
227
227
|
row = _decision_row(
|
|
228
228
|
clarification_id=clarification_id, ticket_id=ticket_id,
|
|
@@ -237,10 +237,30 @@ def open_decision(
|
|
|
237
237
|
raise ApprovalDecisionError("activeClarifications must be an array")
|
|
238
238
|
if any(item.get("id") == clarification_id for item in active if isinstance(item, Mapping)):
|
|
239
239
|
raise ApprovalDecisionError(f"duplicate active clarification: {clarification_id}")
|
|
240
|
+
if supersedes:
|
|
241
|
+
row["supersedes"] = _superseded_carried_ids(ledger, supersedes)
|
|
240
242
|
active.append(row)
|
|
241
243
|
write_json_atomic(ledger_path, ledger)
|
|
242
244
|
|
|
243
245
|
|
|
246
|
+
def _superseded_carried_ids(
|
|
247
|
+
ledger: Mapping[str, Any], supersedes: Sequence[str],
|
|
248
|
+
) -> list[str]:
|
|
249
|
+
carried = ledger.get("carriedDecisions")
|
|
250
|
+
carried_ids = {
|
|
251
|
+
entry["decision"].get("id")
|
|
252
|
+
for entry in (carried if isinstance(carried, list) else [])
|
|
253
|
+
if isinstance(entry, Mapping) and isinstance(entry.get("decision"), Mapping)
|
|
254
|
+
}
|
|
255
|
+
ids = list(dict.fromkeys(_required(value, "supersedes") for value in supersedes))
|
|
256
|
+
unknown = [value for value in ids if value not in carried_ids]
|
|
257
|
+
if unknown:
|
|
258
|
+
raise ApprovalDecisionError(
|
|
259
|
+
f"supersedes must name carried decisions of this ledger: {unknown}"
|
|
260
|
+
)
|
|
261
|
+
return ids
|
|
262
|
+
|
|
263
|
+
|
|
244
264
|
def resolve_decision(
|
|
245
265
|
ledger_path: Path, clarification_id: str, *, disposition: str,
|
|
246
266
|
user_text: str, user_response_ref: str, check_refs: Sequence[str],
|
|
@@ -412,10 +432,20 @@ def _add_open_arguments(parser: argparse.ArgumentParser) -> None:
|
|
|
412
432
|
)
|
|
413
433
|
for flag in repeated:
|
|
414
434
|
parser.add_argument(f"--option-{flag}", action="append", default=[])
|
|
435
|
+
parser.add_argument(
|
|
436
|
+
"--supersedes",
|
|
437
|
+
action="append",
|
|
438
|
+
default=[],
|
|
439
|
+
help=(
|
|
440
|
+
"a carried decision this question replaces; once this row is "
|
|
441
|
+
"resolved, report assembly publishes that carried row as obsolete. "
|
|
442
|
+
"Repeat for several ids."
|
|
443
|
+
),
|
|
444
|
+
)
|
|
415
445
|
|
|
416
446
|
|
|
417
447
|
_CLI_EPILOG = r"""Usage:
|
|
418
|
-
okstra approval-decision open --ledger <path> [decision fields]
|
|
448
|
+
okstra approval-decision open --ledger <path> [decision fields] [--supersedes <C-NNN> ...]
|
|
419
449
|
okstra approval-decision resolve --ledger <path> --clarification-id <C-NNN> [resolution fields]
|
|
420
450
|
okstra approval-decision carry --ledger <path> --from-responses <clarification-response.md> --clarification-id <C-NNN> [--clarification-id <C-NNN> ...]
|
|
421
451
|
okstra approval-decision carry --ledger <path> --source-ledger <path> --source-run-ref <ref> --clarification-id <C-NNN>
|
|
@@ -327,6 +327,14 @@ def resolve_model_selection(
|
|
|
327
327
|
raw_model: str,
|
|
328
328
|
) -> ResolvedModel:
|
|
329
329
|
"""Resolve one explicit or provider-default catalog model without binding."""
|
|
330
|
+
if raw_model and "/" in raw_model:
|
|
331
|
+
# `agent-prompt resolve-operation` prints slots as model refs.
|
|
332
|
+
selected = pool.resolve(raw_model)
|
|
333
|
+
if selected.provider_id != provider.strip().lower():
|
|
334
|
+
raise AssignmentResolutionError(
|
|
335
|
+
f"model {raw_model!r} does not belong to provider {provider!r}"
|
|
336
|
+
)
|
|
337
|
+
return selected
|
|
330
338
|
if raw_model:
|
|
331
339
|
return pool.resolve_alias(provider, raw_model)
|
|
332
340
|
selected = pool.default_candidate(provider, role)
|
|
@@ -239,6 +239,13 @@ _BLOCKING: tuple[tuple[str, str], ...] = (
|
|
|
239
239
|
"같은 이유 — 검증 범위가 열거값 밖이거나 단일 stage 검증이 전체 "
|
|
240
240
|
"release-handoff 를 요구한다.",
|
|
241
241
|
),
|
|
242
|
+
(
|
|
243
|
+
"for release but `runs/implementation-planning/consumers.jsonl` carries no",
|
|
244
|
+
"통과 판정이 stage 에 `verified` 행을 남기지 않았다 — handoff.py 의 "
|
|
245
|
+
"compute_eligibility 가 그 행으로만 PR 대상을 고르므로 통과한 stage 가 "
|
|
246
|
+
"release-handoff 에서 영영 제외된다. report-finalize 의 record-verified "
|
|
247
|
+
"단계가 이 검증 직전에 행을 쓰므로 정상 run 은 항상 만족한다.",
|
|
248
|
+
),
|
|
242
249
|
)
|
|
243
250
|
|
|
244
251
|
# 2026-08-28 에 제거한 조각과 근거 (감사 보고서 §2):
|
|
@@ -27,7 +27,8 @@ from . import code_review_paths, stage_targets, worktree_registry
|
|
|
27
27
|
from .code_review_paths import branch_review_dir, next_branch_review
|
|
28
28
|
from .paths import RunRef
|
|
29
29
|
from .stage_map import StageMapError, load_latest_plan_stage_map
|
|
30
|
-
from .fixed_text import line
|
|
30
|
+
from .fixed_text import line, scalar
|
|
31
|
+
from .report_language import ReportLanguageError, resolve_report_language
|
|
31
32
|
|
|
32
33
|
DEFAULT_BRANCH_CANDIDATES = ("main", "master")
|
|
33
34
|
|
|
@@ -101,9 +102,12 @@ def _branch_snapshot(repo: Path, branch: str, base: str, review_date: str) -> di
|
|
|
101
102
|
_CLI_EPILOG = r"""Usage:
|
|
102
103
|
okstra code-review target --task-key <k> --stage <N> [--base <ref>] [--project-root <dir>] [--cwd <dir>] --json
|
|
103
104
|
okstra code-review target --branch <name> [--base <ref>] [--date <YYYY-MM-DD>] [--project-root <dir>] --json
|
|
105
|
+
okstra code-review check-lines --base <ref> --head <ref> --cite <path:line> [--cite ...] [--project-root <dir>]
|
|
104
106
|
|
|
105
107
|
Output: JSON { ok, mode, worktreePath, branch, baseCommit, headCommit,
|
|
106
|
-
reviewPath, round }.
|
|
108
|
+
reviewPath, round, reportLanguage }. `check-lines` prints `ok`, `not-changed`
|
|
109
|
+
(with the file's changed lines) or `not-in-diff` per citation and exits 1 when
|
|
110
|
+
any citation is not a line the range changed. `--base` sets the base in either mode. Without it, stage
|
|
107
111
|
mode reads the base commit from the stage registry row and branch mode falls
|
|
108
112
|
back to the merge-base with the default branch. An empty worktreePath means the review reads the branch ref
|
|
109
113
|
instead of a checked-out tree. This is read-only — it never creates
|
|
@@ -116,11 +120,9 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
116
120
|
epilog=_CLI_EPILOG,
|
|
117
121
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
118
122
|
prog="okstra code-review",
|
|
119
|
-
description="Resolve a code review's diff range and result file path."
|
|
123
|
+
description="Resolve a code review's diff range and result file path, or check finding citations."
|
|
120
124
|
)
|
|
121
|
-
|
|
122
|
-
# 그 토큰을 파서가 직접 받는다.
|
|
123
|
-
parser.add_argument("command", choices=("target",))
|
|
125
|
+
parser.add_argument("command", choices=("target", "check-lines"))
|
|
124
126
|
parser.add_argument("--task-key", default="", help="project-id:task-group:task-id")
|
|
125
127
|
parser.add_argument("--stage", type=int, default=0, help="stage number to review")
|
|
126
128
|
parser.add_argument("--branch", default="", help="branch to review")
|
|
@@ -128,6 +130,11 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
128
130
|
"--base", default="",
|
|
129
131
|
help="explicit base ref; overrides the stage registry base or the branch merge-base",
|
|
130
132
|
)
|
|
133
|
+
parser.add_argument("--head", default="", help="check-lines: head ref of the reviewed range")
|
|
134
|
+
parser.add_argument(
|
|
135
|
+
"--cite", action="append", default=[],
|
|
136
|
+
help="check-lines: a finding's `path:line` (repeatable)",
|
|
137
|
+
)
|
|
131
138
|
parser.add_argument("--date", default="", help="YYYY-MM-DD for branch result naming")
|
|
132
139
|
parser.add_argument("--project-root", default="", help="explicit project root")
|
|
133
140
|
parser.add_argument("--cwd", default=".", help="cwd for project root resolution")
|
|
@@ -160,6 +167,7 @@ def render_code_review_target_text(payload: dict) -> str:
|
|
|
160
167
|
("Worktree path", "worktreePath"), ("Branch", "branch"),
|
|
161
168
|
("Base commit", "baseCommit"), ("Head commit", "headCommit"),
|
|
162
169
|
("Review path", "reviewPath"), ("Round", "round"),
|
|
170
|
+
("Report language", "reportLanguage"),
|
|
163
171
|
("Task key", "taskKey"), ("Task root", "taskRoot"), ("Stage", "stage"),
|
|
164
172
|
("Failure stage", "stage"), ("Failure reason", "reason"),
|
|
165
173
|
)
|
|
@@ -170,6 +178,77 @@ def render_code_review_target_text(payload: dict) -> str:
|
|
|
170
178
|
return "".join(rows)
|
|
171
179
|
|
|
172
180
|
|
|
181
|
+
_HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def changed_lines(repo: Path, base: str, head: str) -> dict[str, set[int]]:
|
|
185
|
+
"""Head-side line numbers each file's diff touched.
|
|
186
|
+
|
|
187
|
+
A pure deletion changes no head line, so it anchors on the line before the
|
|
188
|
+
cut — the line a reviewer quotes when it points at what was removed.
|
|
189
|
+
"""
|
|
190
|
+
diff = _git_out(repo, "diff", "-U0", "--no-color", f"{base}..{head}")
|
|
191
|
+
lines: dict[str, set[int]] = {}
|
|
192
|
+
current: set[int] | None = None
|
|
193
|
+
for row in diff.splitlines():
|
|
194
|
+
if row.startswith("+++ "):
|
|
195
|
+
path = row[4:]
|
|
196
|
+
current = None if path == "/dev/null" else lines.setdefault(path[2:], set())
|
|
197
|
+
continue
|
|
198
|
+
match = _HUNK_RE.match(row)
|
|
199
|
+
if match and current is not None:
|
|
200
|
+
start = int(match[1])
|
|
201
|
+
count = 1 if match[2] is None else int(match[2])
|
|
202
|
+
current.update(range(start, start + count) if count else {max(start, 1)})
|
|
203
|
+
return lines
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def render_check_lines(changed: dict[str, set[int]], cites: list[str]) -> tuple[str, bool]:
|
|
207
|
+
rows = ["Okstra code review citation check\n"]
|
|
208
|
+
all_ok = True
|
|
209
|
+
for cite in cites:
|
|
210
|
+
path, _, number = cite.rpartition(":")
|
|
211
|
+
touched = changed.get(path)
|
|
212
|
+
label = f"- `{scalar(cite)}`: "
|
|
213
|
+
if touched is None:
|
|
214
|
+
all_ok = False
|
|
215
|
+
rows.append(label + "`not-in-diff`\n")
|
|
216
|
+
elif number.isdigit() and int(number) in touched:
|
|
217
|
+
rows.append(label + "`ok`\n")
|
|
218
|
+
else:
|
|
219
|
+
all_ok = False
|
|
220
|
+
rows.append(
|
|
221
|
+
label + "`not-changed` — changed lines "
|
|
222
|
+
f"{_line_ranges(sorted(touched))}\n"
|
|
223
|
+
)
|
|
224
|
+
return "".join(rows), all_ok
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _line_ranges(numbers: list[int]) -> str:
|
|
228
|
+
ranges: list[str] = []
|
|
229
|
+
for number in numbers:
|
|
230
|
+
if ranges and number == int(ranges[-1].split("-")[-1]) + 1:
|
|
231
|
+
ranges[-1] = f"{ranges[-1].split('-')[0]}-{number}"
|
|
232
|
+
else:
|
|
233
|
+
ranges.append(str(number))
|
|
234
|
+
return ", ".join(ranges)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _check_lines(args: argparse.Namespace, project_root: Path) -> int:
|
|
238
|
+
if not (args.base and args.head and args.cite):
|
|
239
|
+
print("okstra code-review check-lines: pass --base, --head and at least one --cite",
|
|
240
|
+
file=sys.stderr)
|
|
241
|
+
return 2
|
|
242
|
+
try:
|
|
243
|
+
changed = changed_lines(project_root, args.base, args.head)
|
|
244
|
+
except StateError as exc:
|
|
245
|
+
print(f"okstra code-review check-lines: {exc}", file=sys.stderr)
|
|
246
|
+
return 2
|
|
247
|
+
text, all_ok = render_check_lines(changed, args.cite)
|
|
248
|
+
print(text, end="")
|
|
249
|
+
return 0 if all_ok else 1
|
|
250
|
+
|
|
251
|
+
|
|
173
252
|
def main(argv: list[str] | None = None) -> int:
|
|
174
253
|
args = _build_parser().parse_args(argv)
|
|
175
254
|
try:
|
|
@@ -181,8 +260,15 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
181
260
|
print(render_code_review_target_text(payload) if args.text else json.dumps(payload))
|
|
182
261
|
return 2
|
|
183
262
|
|
|
263
|
+
if args.command == "check-lines":
|
|
264
|
+
return _check_lines(args, Path(project_root))
|
|
184
265
|
try:
|
|
185
266
|
snapshot = _resolve(args, Path(project_root))
|
|
267
|
+
snapshot["reportLanguage"] = resolve_report_language(Path(project_root), {}, {})
|
|
268
|
+
except ReportLanguageError as exc:
|
|
269
|
+
payload = {"ok": False, "stage": "report-language", "reason": str(exc)}
|
|
270
|
+
print(render_code_review_target_text(payload) if args.text else json.dumps(payload))
|
|
271
|
+
return 1
|
|
186
272
|
except StateError as exc:
|
|
187
273
|
payload = {
|
|
188
274
|
"ok": False,
|