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.
Files changed (73) hide show
  1. package/README.md +1 -1
  2. package/dist/cli-registry.mjs +7 -1
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/docs/architecture/storage-model.md +1 -0
  5. package/docs/architecture.md +28 -4
  6. package/docs/cli.md +13 -11
  7. package/docs/project-structure-overview.md +4 -2
  8. package/package.json +1 -1
  9. package/runtime/BUILD.json +2 -2
  10. package/runtime/agents/operations/code-review.json +1 -1
  11. package/runtime/bin/lib/okstra/usage.sh +3 -3
  12. package/runtime/bin/okstra-compact-reminder.sh +1 -1
  13. package/runtime/prompts/duties/direction-selection-worker.json +1 -1
  14. package/runtime/prompts/launch.template.md +1 -1
  15. package/runtime/prompts/lead/adapters/cmux.md +4 -3
  16. package/runtime/prompts/lead/convergence.md +41 -9
  17. package/runtime/prompts/lead/okstra-lead-contract.md +31 -19
  18. package/runtime/prompts/lead/report-writer.md +8 -6
  19. package/runtime/prompts/profiles/_clarification-recommendation.md +4 -4
  20. package/runtime/prompts/profiles/_common-contract.md +1 -1
  21. package/runtime/prompts/wizard/prompts.ko.json +2 -1
  22. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +1 -1
  23. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +5 -5
  24. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
  25. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +3 -2
  26. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +1 -1
  27. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +1 -1
  28. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +17 -26
  29. package/runtime/python/okstra_ctl/agent/prompt_cli/batch.py +183 -0
  30. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +60 -10
  31. package/runtime/python/okstra_ctl/agent/prompt_cli/jobs.py +21 -4
  32. package/runtime/python/okstra_ctl/approval_decisions.py +32 -2
  33. package/runtime/python/okstra_ctl/assignment_resolver.py +8 -0
  34. package/runtime/python/okstra_ctl/blocking_checks.py +7 -0
  35. package/runtime/python/okstra_ctl/code_review_target.py +92 -6
  36. package/runtime/python/okstra_ctl/dispatch_checkpoints.py +121 -0
  37. package/runtime/python/okstra_ctl/dispatch_core.py +54 -32
  38. package/runtime/python/okstra_ctl/dispatch_state.py +12 -5
  39. package/runtime/python/okstra_ctl/domain/provider.py +5 -0
  40. package/runtime/python/okstra_ctl/domain/worker_presentation.py +21 -2
  41. package/runtime/python/okstra_ctl/domain/write_policy.py +2 -1
  42. package/runtime/python/okstra_ctl/execution_mutation_audit.py +19 -8
  43. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +5 -0
  44. package/runtime/python/okstra_ctl/lead_progress.py +33 -1
  45. package/runtime/python/okstra_ctl/manager_view.py +34 -21
  46. package/runtime/python/okstra_ctl/model_io/lines.py +21 -4
  47. package/runtime/python/okstra_ctl/models.py +4 -1
  48. package/runtime/python/okstra_ctl/operation_invocation.py +11 -2
  49. package/runtime/python/okstra_ctl/phases/final_verification/profile.md +1 -1
  50. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-executor.md +1 -1
  51. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-verifier.md +14 -3
  52. package/runtime/python/okstra_ctl/phases/implementation_option_selection/profile.md +1 -1
  53. package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +1 -1
  54. package/runtime/python/okstra_ctl/phases/technical_verification/profile.md +1 -1
  55. package/runtime/python/okstra_ctl/process_group.py +118 -0
  56. package/runtime/python/okstra_ctl/render.py +6 -2
  57. package/runtime/python/okstra_ctl/report_assembly.py +17 -2
  58. package/runtime/python/okstra_ctl/report_finalize.py +106 -2
  59. package/runtime/python/okstra_ctl/run.py +1 -1
  60. package/runtime/python/okstra_ctl/run_artifact_prune.py +200 -0
  61. package/runtime/python/okstra_ctl/team.py +108 -9
  62. package/runtime/python/okstra_ctl/wizard/steps_options.py +8 -0
  63. package/runtime/python/okstra_ctl/worker_dispatch.py +44 -3
  64. package/runtime/python/okstra_ctl/worker_prompt_policy.py +19 -0
  65. package/runtime/python/okstra_ctl/worker_runner.py +21 -3
  66. package/runtime/python/okstra_ctl/write_policy.py +57 -7
  67. package/runtime/python/okstra_project/dirs.py +14 -0
  68. package/runtime/python/okstra_project/resolver.py +2 -1
  69. package/runtime/schemas/execution-manifest-v2.schema.json +2 -1
  70. package/runtime/skills/okstra-code-review/SKILL.md +70 -32
  71. package/runtime/skills/okstra-code-review/references/review-calibration.md +26 -6
  72. package/runtime/skills/okstra-run/SKILL.md +2 -2
  73. 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
- materialize.add_argument("--invocation-id", required=True)
81
- materialize.add_argument("--audience", required=True)
82
- materialize.add_argument("--instruction", required=True)
83
- materialize.add_argument("--prompt", required=True)
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
- prepared = _materialize(args)
350
- _emit(_prepared_payload(prepared, args.audience), args.json)
351
- return 0
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 generate_jobs(args: argparse.Namespace) -> dict[str, object]:
23
- project = _project_root(args.project_root)
24
- manifest_path = _project_input(project, args.run_manifest, "run manifest")
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, args.out, [manifest["runDirectoryPath"]], "jobs", must_exist=False)
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 }. `--base` sets the base in either mode. Without it, stage
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
- # 서브커맨드는 하나뿐이지만 공개 명령이 `okstra code-review target` 이므로
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,