okstra 0.191.2 → 0.193.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/docs/architecture.md +2 -2
  2. package/docs/cli.md +3 -2
  3. package/docs/project-structure-overview.md +3 -1
  4. package/docs/task-process/README.md +2 -2
  5. package/docs/task-process/common-flow.md +4 -5
  6. package/package.json +1 -1
  7. package/runtime/BUILD.json +2 -2
  8. package/runtime/agents/workers/translator-worker.md +1 -1
  9. package/runtime/prompts/launch.template.md +1 -1
  10. package/runtime/prompts/lead/convergence.md +13 -3
  11. package/runtime/prompts/lead/okstra-lead-contract.md +2 -2
  12. package/runtime/prompts/lead/plan-body-verification.md +1 -1
  13. package/runtime/prompts/lead/report-writer.md +11 -8
  14. package/runtime/prompts/profiles/_implementation-executor.md +1 -1
  15. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  16. package/runtime/prompts/profiles/implementation-planning.md +1 -1
  17. package/runtime/prompts/wizard/prompts.ko.json +52 -23
  18. package/runtime/python/okstra_ctl/conformance.py +74 -0
  19. package/runtime/python/okstra_ctl/convergence.py +63 -1
  20. package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +238 -0
  21. package/runtime/python/okstra_ctl/dispatch_core.py +42 -22
  22. package/runtime/python/okstra_ctl/execution_mutation_audit.py +96 -8
  23. package/runtime/python/okstra_ctl/next_phase.py +18 -8
  24. package/runtime/python/okstra_ctl/plan_items.py +6 -4
  25. package/runtime/python/okstra_ctl/plan_items_cli.py +91 -6
  26. package/runtime/python/okstra_ctl/report_finalize.py +57 -10
  27. package/runtime/python/okstra_ctl/report_translation_dispatch.py +300 -0
  28. package/runtime/python/okstra_ctl/verdict_blocks.py +37 -7
  29. package/runtime/python/okstra_ctl/wizard/engine.py +16 -2
  30. package/runtime/python/okstra_ctl/wizard/registry.py +11 -2
  31. package/runtime/python/okstra_ctl/wizard/roles.py +364 -361
  32. package/runtime/python/okstra_ctl/wizard/state.py +39 -27
  33. package/runtime/python/okstra_ctl/wizard/steps_identity.py +50 -8
  34. package/runtime/python/okstra_ctl/wizard/steps_roles.py +1 -0
  35. package/runtime/python/okstra_ctl/worker_prompt_contract.py +11 -0
  36. package/runtime/skills/okstra-run/SKILL.md +2 -2
  37. package/runtime/validators/validate-run.py +78 -16
@@ -152,6 +152,80 @@ def malformed_conformance_stages(data: object) -> list[int]:
152
152
  return bad
153
153
 
154
154
 
155
+ def exempt_stage_surface_conflicts(
156
+ data: object, patterns: object = None,
157
+ ) -> list[dict[str, object]]:
158
+ """`Conformance exemption:` 을 선언했는데 계획된 경로가 capability 표면을 건드리는 stage.
159
+
160
+ 면제 규칙(prompts/profiles/implementation-planning.md "Per-stage conformance
161
+ declaration")은 "db/io/http/external 표면을 건드리지 않는 stage" 에만 허용하고,
162
+ 구현 diff 가 그 표면을 건드리면 validate-run 의 diff-surface 대조가 막는다고
163
+ 적는다. 그 대조는 구현이 끝난 뒤에만 돌았고, 승인된 계획은 불변이라 그때는
164
+ 고칠 수 없었다(실측 2026-09-09, dev-10784 Stage 2: plannedPaths 에 ORM
165
+ repository 를 넣고 면제 선언 → 구현 완료 후 `contract-violated`). 같은 모순은
166
+ 계획의 `stepwiseExecution[].plannedPaths` 로 승인 전에 판별된다 — 이 함수가
167
+ 그 판별이고, 구현 게이트와 같은 `detect_surfaces` 를 쓴다.
168
+
169
+ 반환 행: `{"stage": <int>, "surfaces": [..], "paths": [..]}`. `plannedPaths`
170
+ 가 없는 step 은 `files` 문자열(쉼표 구분)을 대신 읽는다.
171
+ """
172
+ planning = data.get("implementationPlanning") if isinstance(data, dict) else None
173
+ stages = planning.get("stages") if isinstance(planning, dict) else None
174
+ conflicts: list[dict[str, object]] = []
175
+ for stage in stages if isinstance(stages, list) else []:
176
+ if not isinstance(stage, dict):
177
+ continue
178
+ exemption = stage.get("conformanceExemption")
179
+ if not isinstance(exemption, str) or not exemption.strip():
180
+ continue
181
+ number = stage.get("stage")
182
+ if not isinstance(number, int) or isinstance(number, bool) or number < 1:
183
+ continue
184
+ paths = _stage_planned_paths(stage)
185
+ touching = sorted(
186
+ path for path in paths if detect_surfaces([path], patterns)
187
+ )
188
+ surfaces = detect_surfaces(touching, patterns)
189
+ if surfaces:
190
+ conflicts.append(
191
+ {"stage": number, "surfaces": sorted(surfaces), "paths": touching}
192
+ )
193
+ return conflicts
194
+
195
+
196
+ def _stage_planned_paths(stage: dict) -> list[str]:
197
+ """stage 의 step 들이 계획한 경로 — 경로 모양인 문자열만.
198
+
199
+ plannedPaths 에는 `(none — read-only repository command)` 같은 산문
200
+ 자리표시자도 들어온다(실측 2026-09-09 dev-10627). 공백이 든 문자열을 표면
201
+ 패턴에 대면 `*repository*` 가 그 산문에 걸려 거짓 양성이 된다. 실제 경로는
202
+ 공백이 없고 `/` 나 `.` 을 품는다."""
203
+ paths: list[str] = []
204
+ for step in stage.get("stepwiseExecution") or []:
205
+ if not isinstance(step, dict):
206
+ continue
207
+ planned = step.get("plannedPaths")
208
+ candidates: list[str] = []
209
+ if isinstance(planned, list):
210
+ candidates = [p for p in planned if isinstance(p, str)]
211
+ elif isinstance(step.get("files"), str):
212
+ candidates = step["files"].split(",")
213
+ paths.extend(c.strip() for c in candidates if _looks_like_path(c.strip()))
214
+ return paths
215
+
216
+
217
+ def _looks_like_path(value: str) -> bool:
218
+ if not value or any(ch.isspace() for ch in value):
219
+ return False
220
+ if value.startswith(".okstra/"):
221
+ # task 산출물(qa 스크립트·fixture·decision 기록)은 코드 표면이 아니다 —
222
+ # 구현 diff 는 워크트리에서 나오므로 그 경로는 구현 게이트에도 닿지 않는다.
223
+ # 실측(84개 계획 sweep): `.okstra/**` 를 세면 `*migration*` 이 decision
224
+ # 파일명에 걸려 거짓 양성이 14건 늘었다.
225
+ return False
226
+ return "/" in value or "." in value
227
+
228
+
155
229
  def is_advisory_conformance_entry(entry: object) -> bool:
156
230
  """Return whether one entry depends on user-owned external QA."""
157
231
  if not isinstance(entry, dict):
@@ -22,6 +22,11 @@ from .convergence_engine import (
22
22
  validate_final_state,
23
23
  validate_working_state,
24
24
  )
25
+ from .convergence_reverify_prompt import (
26
+ ReverifyPromptError,
27
+ reverify_findings,
28
+ reverify_prompt_body,
29
+ )
25
30
  from .convergence_critic_prompt import (
26
31
  analyser_results,
27
32
  covered_index,
@@ -285,6 +290,8 @@ _CLI_EPILOG = r"""Usage:
285
290
  okstra convergence apply-round --work-state <path> --plan <path> \
286
291
  --results <path>
287
292
  okstra convergence critic-prompt --run-manifest <path>
293
+ okstra convergence reverify-prompt --run-manifest <path> --plan <path> \
294
+ --worker <worker-id>
288
295
  okstra convergence apply-critic-gaps --work-state <path> --results <path>
289
296
  okstra convergence finalize --work-state <path> --output <path>
290
297
  okstra convergence validate --state <path> --kind <working|final>
@@ -388,6 +395,25 @@ def _parser() -> argparse.ArgumentParser:
388
395
  )
389
396
  critic_prompt.add_argument("--run-manifest", type=Path, required=True)
390
397
 
398
+ reverify_prompt = subparsers.add_parser(
399
+ "reverify-prompt",
400
+ help="render one worker's reverify task instructions to stdout",
401
+ description=(
402
+ "Print the reverify instruction body for one `dispatches[]` row of "
403
+ "a round plan. The lead writes it verbatim to the file the prompt "
404
+ "materializer's `--instruction` takes. It carries the round's "
405
+ "mandate (adversarial or collaborative, from the grouping's "
406
+ "config), every planned finding with its summary, origin worker, "
407
+ "cited-evidence line, the origin worker's result file and item id, "
408
+ "and the origin audit sidecar the verifier may open, plus the "
409
+ "response format the collector parses. Nothing here is hand-written."
410
+ ),
411
+ formatter_class=argparse.RawDescriptionHelpFormatter,
412
+ )
413
+ reverify_prompt.add_argument("--run-manifest", type=Path, required=True)
414
+ reverify_prompt.add_argument("--plan", type=Path, required=True)
415
+ reverify_prompt.add_argument("--worker", required=True)
416
+
391
417
  apply_critic = subparsers.add_parser(
392
418
  "apply-critic-gaps",
393
419
  help="apply one coverage-critic verification batch",
@@ -1403,6 +1429,39 @@ def _prepare_groups(args: argparse.Namespace) -> tuple[str, Path]:
1403
1429
  return "prepared", output
1404
1430
 
1405
1431
 
1432
+ def _reverify_prompt(args: argparse.Namespace) -> str:
1433
+ """한 워커의 reverify 지시문 본문. 입력은 매니페스트·라운드 계획·워커 id."""
1434
+ authority = validated_run_authority(args.run_manifest)
1435
+ groups_path = authority.run_dir / "state" / _canonical_run_artifact_name(
1436
+ "convergence-groups", authority.task_type, authority.state_sequence
1437
+ )
1438
+ if not groups_path.is_file():
1439
+ raise ConvergenceContractError(
1440
+ "the reverify prompt needs the Round 0 grouping; run "
1441
+ f"`okstra convergence prepare-groups` first: {groups_path}"
1442
+ )
1443
+ groups = load_owned_json_object(groups_path)
1444
+ plan = load_owned_json_object(args.plan)
1445
+ config = groups.get("config") if isinstance(groups.get("config"), Mapping) else {}
1446
+ round_number = plan.get("round")
1447
+ if not isinstance(round_number, int) or round_number < 1:
1448
+ raise ConvergenceContractError("round plan has no positive `round`")
1449
+ if plan.get("action") != "dispatch":
1450
+ raise ConvergenceContractError(
1451
+ f"round plan action is {plan.get('action')!r}, not `dispatch`; "
1452
+ "there is nothing to verify this round"
1453
+ )
1454
+ return reverify_prompt_body(
1455
+ task_key=_manifest_authority_string(authority.payload, "taskKey"),
1456
+ round_number=round_number,
1457
+ adversarial=bool(config.get("adversarial", False)),
1458
+ findings=reverify_findings(
1459
+ groups, plan, args.worker,
1460
+ project_root=authority.project_root, run_dir=authority.run_dir,
1461
+ ),
1462
+ )
1463
+
1464
+
1406
1465
  def _execute(args: argparse.Namespace) -> tuple[str, Path]:
1407
1466
  operations: dict[str, Any] = {
1408
1467
  "prepare-groups": _prepare_groups,
@@ -1433,8 +1492,11 @@ def main(argv: list[str] | None = None) -> int:
1433
1492
  if args.operation == "critic-prompt":
1434
1493
  print(_critic_prompt(args), end="")
1435
1494
  return 0
1495
+ if args.operation == "reverify-prompt":
1496
+ print(_reverify_prompt(args), end="")
1497
+ return 0
1436
1498
  action, path = _execute(args)
1437
- except (ConvergenceContractError, VerdictBlockError,
1499
+ except (ConvergenceContractError, VerdictBlockError, ReverifyPromptError,
1438
1500
  json.JSONDecodeError, ValueError) as exc:
1439
1501
  print(f"error: {exc}", file=sys.stderr)
1440
1502
  return 2
@@ -0,0 +1,238 @@
1
+ """Reverify 지시문을 수렴 상태와 라운드 계획에서 결정적으로 렌더한다.
2
+
3
+ Phase 5.5 의 재검증 워커는 리드가 손으로 쓴 지시문을 받아 왔다. 그 손에서 두
4
+ 가지가 반복해서 어긋났다(실측 2026-09-09, 다른 세션의 첫 reverify 보고).
5
+
6
+ - 응답 형식 — 리드가 `- Verdict:` 불릿을 적었고 워커는 시킨 대로 썼다. 파서는
7
+ 이제 그 모양도 읽지만(`verdict_blocks._field_match`), 형식 블록을 리드가 매
8
+ 라운드 다시 쓰는 구조 자체가 결함이다.
9
+ - 근거 축약 — 리드가 `**Cited evidence**` 줄에 원 워커가 인용한 근거의 일부만
10
+ 옮겨 적었다. 검증자는 계약대로 그 줄만 열었고, `burden-not-met` 5건은 주장을
11
+ 검증한 것이 아니라 리드의 전사(轉寫)를 검증한 것이었다.
12
+
13
+ 이 모듈은 그 지시문을 코드로 만든다. finding 마다 그룹의 요약·원 워커·인용
14
+ 근거 줄에 더해, **원 워커의 결과 파일과 그 안의 항목 id, 그리고 원 워커의 감사
15
+ 사이드카**(실행한 읽기 전용 명령과 출력이 기록된 파일)를 싣고, 그 둘을 열어도
16
+ 된다고 명시한다. 검증자는 리드의 요약이 아니라 원 워커가 실제로 인용한 것을
17
+ 판단한다. 응답 형식은 `verdict_blocks` 가 읽는 정본 모양 그대로다.
18
+
19
+ 산출물은 프롬프트 materializer 의 `--instruction` 이 받는 본문이다. `## Instructions`
20
+ 로 시작하므로 `complete_reverify_instruction` 이 모델·task type·금지 목록을 그
21
+ 앞에 붙이고, 출력 계약(`templates/reverify-output-contract.md`)을 뒤에 덧붙인다.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+ from typing import Any, Mapping, Sequence
28
+
29
+ from .convergence_critic_prompt import analyser_results
30
+ from .worker_artifact_paths import WorkerArtifactPathError, audit_sidecar_rel
31
+
32
+
33
+ class ReverifyPromptError(ValueError):
34
+ """지시문을 결정적으로 만들 수 없다."""
35
+
36
+
37
+ # 렌더된 지시문의 서명. 리드가 손으로 쓴 지시문은 이 줄이 없어 materialize 의
38
+ # `validate_reverify_prompt` 에서 거절된다 — 저작(convergence.md "write its output
39
+ # verbatim")만 있고 집행이 없으면 리드는 다시 손으로 쓴다.
40
+ RENDERED_BY_LINE = "**Rendered by:** okstra convergence reverify-prompt"
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class ReverifyFinding:
45
+ """검증 큐의 finding 하나와, 그 원 워커의 실물 인용 위치."""
46
+
47
+ finding_id: str
48
+ summary: str
49
+ origin_worker: str
50
+ origin_item_id: str
51
+ origin_evidence: str
52
+ origin_result_path: str
53
+ origin_audit_path: str
54
+
55
+
56
+ _ADVERSARIAL_MANDATE = """Your job is to BREAK each finding below, not to confirm it. For EACH finding,
57
+ open the cited evidence directly and actively search for evidence that the claim
58
+ is wrong, overstated, or unproven. Then respond with exactly one verdict:
59
+
60
+ - **REFUTED**: You broke the claim. State the basis:
61
+ - counter-evidence — you found contradicting evidence (give file:line or log line), OR
62
+ - burden-not-met — you re-inspected the cited evidence and could neither confirm
63
+ nor refute it (the claim has not proven itself).
64
+ - **SURVIVES**: You actively tried to refute it and failed — the claim withstood the attack.
65
+ - **SURVIVES-WITH-CAVEAT**: It holds, but a scope limit / extra condition / missing
66
+ precondition exists (state it).
67
+ - **UNVERIFIABLE**: Capability, credential, network, or service state prevents you
68
+ from opening or reproducing the cited evidence. Do not use REFUTED as a substitute.
69
+
70
+ The burden of proof is on the claim. If after inspecting the cited evidence you remain
71
+ uncertain, your verdict is REFUTED with basis = burden-not-met.
72
+
73
+ Inspect ONLY the evidence each finding cites and its immediate surroundings. Do NOT
74
+ re-read the task brief, instruction-set, or report template."""
75
+
76
+ _COLLABORATIVE_MANDATE = """Review the following findings discovered by other workers.
77
+ For EACH finding, respond with exactly one verdict:
78
+
79
+ - **AGREE**: The finding is valid based on the evidence presented
80
+ - **DISAGREE**: The finding is incorrect or unsupported (explain briefly why)
81
+ - **SUPPLEMENT**: The finding is valid AND you have additional supporting evidence or context
82
+ - **UNVERIFIABLE**: Capability, credential, network, or service state prevents you
83
+ from checking this finding. Explain the unavailable capability; do not substitute DISAGREE.
84
+
85
+ Do NOT re-analyze the original source materials. Judge based on the evidence provided."""
86
+
87
+ # 근거 접근 규칙. `**Cited evidence**` 는 리드의 요약이고, 완전한 인용은 원 워커의
88
+ # 결과 항목이다. 검증자가 요약만 열고 "이 근거로는 입증되지 않는다" 고 답하던
89
+ # 자리를 막는다.
90
+ _EVIDENCE_ACCESS = """The `**Cited evidence**` line is the lead's summary of what the origin worker cited.
91
+ The complete citation is the origin worker's own item: before judging, open the
92
+ `**Origin item**` file at the named `### <item-id>` section and read every path, line,
93
+ command, and quote it cites. The `**Origin audit sidecar**` records the read-only
94
+ commands that worker ran and their output; it counts as cited evidence and you may
95
+ open it. Judge the claim against what the origin worker actually cited, never against
96
+ the summary line alone."""
97
+
98
+ _ADVERSARIAL_RESPONSE = """### <finding-id>
99
+ **Verdict**: REFUTED | SURVIVES | SURVIVES-WITH-CAVEAT | UNVERIFIABLE
100
+ **Basis** (only if REFUTED): counter-evidence | burden-not-met
101
+ **Explanation**: <2-3 sentences; for counter-evidence include the file:line you found>"""
102
+
103
+ _COLLABORATIVE_RESPONSE = """### <finding-id>
104
+ **Verdict**: AGREE | DISAGREE | SUPPLEMENT | UNVERIFIABLE
105
+ **Explanation**: <2-3 sentences>"""
106
+
107
+
108
+ def _nonempty_string(value: Any) -> str:
109
+ return value if isinstance(value, str) and value.strip() else ""
110
+
111
+
112
+ def plan_row_for_worker(plan: Mapping[str, Any], worker_id: str) -> Mapping[str, Any]:
113
+ """계획의 `dispatches[]` 에서 이 워커의 행 하나. 검증기와 같은 규칙으로 맞춘다.
114
+
115
+ `validators/validate-run.py` `_plan_dispatch_finding_ids` 처럼 `worker` 가
116
+ 그대로 같거나 `<worker>-worker` 가 같으면 그 행이다.
117
+ """
118
+ dispatches = plan.get("dispatches")
119
+ if not isinstance(dispatches, list):
120
+ raise ReverifyPromptError("round plan has no dispatches array")
121
+ matched = [
122
+ row for row in dispatches
123
+ if isinstance(row, Mapping)
124
+ and (row.get("worker") == worker_id or f"{row.get('worker')}-worker" == worker_id)
125
+ ]
126
+ if len(matched) != 1:
127
+ planned = ", ".join(
128
+ _nonempty_string(row.get("worker")) or "?" for row in dispatches
129
+ if isinstance(row, Mapping)
130
+ )
131
+ raise ReverifyPromptError(
132
+ f"round plan dispatches nothing to `{worker_id}`; planned workers: "
133
+ f"{planned or 'none'}"
134
+ )
135
+ finding_ids = matched[0].get("findingIds")
136
+ if not isinstance(finding_ids, list) or not finding_ids:
137
+ raise ReverifyPromptError(f"round plan row for `{worker_id}` has no findingIds")
138
+ return matched[0]
139
+
140
+
141
+ def reverify_findings(
142
+ groups: Mapping[str, Any],
143
+ plan: Mapping[str, Any],
144
+ worker_id: str,
145
+ *,
146
+ project_root: Path,
147
+ run_dir: Path,
148
+ ) -> list[ReverifyFinding]:
149
+ """계획 행의 finding 을 계획 순서대로, 원 워커의 실물 인용 위치와 함께."""
150
+ row = plan_row_for_worker(plan, worker_id)
151
+ by_id = {
152
+ _nonempty_string(group.get("findingId")): group
153
+ for group in groups.get("groups") or []
154
+ if isinstance(group, Mapping) and _nonempty_string(group.get("findingId"))
155
+ }
156
+ result_paths = {
157
+ result.worker_id: result.result_path
158
+ for result in analyser_results(groups, project_root=project_root, run_dir=run_dir)
159
+ }
160
+ findings: list[ReverifyFinding] = []
161
+ for finding_id in row["findingIds"]:
162
+ group = by_id.get(str(finding_id))
163
+ if group is None:
164
+ raise ReverifyPromptError(
165
+ f"round plan names `{finding_id}`, which the grouping does not carry"
166
+ )
167
+ origin = _nonempty_string(group.get("originWorker"))
168
+ discovered = group.get("discoveredBy")
169
+ origin_item = (
170
+ _nonempty_string((discovered.get(origin) or {}).get("itemId"))
171
+ if isinstance(discovered, Mapping) and isinstance(discovered.get(origin), Mapping)
172
+ else ""
173
+ )
174
+ result_path = result_paths.get(origin, "")
175
+ if not origin or not origin_item or not result_path:
176
+ raise ReverifyPromptError(
177
+ f"finding `{finding_id}` has no resolvable origin item "
178
+ f"(origin worker `{origin or '?'}`, item `{origin_item or '?'}`)"
179
+ )
180
+ try:
181
+ audit_path = audit_sidecar_rel(result_path)
182
+ except WorkerArtifactPathError as exc:
183
+ raise ReverifyPromptError(str(exc)) from exc
184
+ findings.append(ReverifyFinding(
185
+ finding_id=str(finding_id),
186
+ summary=_nonempty_string(group.get("summary")),
187
+ origin_worker=origin,
188
+ origin_item_id=origin_item,
189
+ origin_evidence=_nonempty_string(group.get("originEvidence")),
190
+ origin_result_path=result_path,
191
+ origin_audit_path=audit_path,
192
+ ))
193
+ return findings
194
+
195
+
196
+ def reverify_prompt_body(
197
+ *,
198
+ task_key: str,
199
+ round_number: int,
200
+ adversarial: bool,
201
+ findings: Sequence[ReverifyFinding],
202
+ ) -> str:
203
+ """reverify 지시문 파일 본문. 같은 입력이면 같은 바이트를 낸다."""
204
+ if not _nonempty_string(task_key):
205
+ raise ReverifyPromptError("convergence groups carry no taskKey")
206
+ if not findings:
207
+ raise ReverifyPromptError("no findings to verify")
208
+ mode = "ADVERSARIAL re-verification" if adversarial else "re-verification"
209
+ mandate = _ADVERSARIAL_MANDATE if adversarial else _COLLABORATIVE_MANDATE
210
+ response = _ADVERSARIAL_RESPONSE if adversarial else _COLLABORATIVE_RESPONSE
211
+ rows = [
212
+ "## Instructions\n\n",
213
+ f"{RENDERED_BY_LINE}\n\n",
214
+ f"Perform {mode} for {task_key} (round {round_number}).\n\n",
215
+ mandate, "\n\n",
216
+ _EVIDENCE_ACCESS, "\n\n",
217
+ "## Findings to verify\n",
218
+ ]
219
+ for finding in findings:
220
+ rows.append(f"\n### {finding.finding_id}: {finding.summary or '(no summary)'}\n")
221
+ rows.append(f"**Origin**: {finding.origin_worker}\n")
222
+ rows.append(f"**Cited evidence**: {finding.origin_evidence or '(none recorded)'}\n")
223
+ rows.append(
224
+ f"**Origin item**: `{finding.origin_result_path}` — section "
225
+ f"`### {finding.origin_item_id}`\n"
226
+ )
227
+ rows.append(f"**Origin audit sidecar**: `{finding.origin_audit_path}`\n")
228
+ rows.append("\n## Response format\n\n")
229
+ rows.append(
230
+ "One block per finding, headed by the finding id at exactly three hashes. "
231
+ "Field labels are bold with the colon outside (`**Verdict**: …`); the "
232
+ "collector also reads `**Verdict:** …` and `- Verdict: …` as the same field.\n\n"
233
+ )
234
+ rows.append(response.replace("<finding-id>", findings[0].finding_id))
235
+ rows.append("\n")
236
+ if len(findings) > 1:
237
+ rows.append(f"\n### {findings[1].finding_id}\n**Verdict**: ...\n")
238
+ return "".join(rows)
@@ -1032,29 +1032,8 @@ def _translator_job_from_reservation(
1032
1032
  raise DispatchError(
1033
1033
  "translator dispatch requires a canonical translator role execution"
1034
1034
  )
1035
- contract = manifest.get("agentContract")
1036
- if not isinstance(contract, Mapping):
1037
- raise DispatchError("translator dispatch requires an agent contract")
1038
- reservation_root = _resolve_required_path(
1039
- project_root, contract, "invocationReservationRootPath"
1040
- )
1041
- dispatched_ids = {
1042
- str(row.get("dispatchId") or "")
1043
- for collection in (
1044
- team_state.get("workerDispatches") or [],
1045
- team_state.get("agentDispatches") or [],
1046
- )
1047
- for row in collection
1048
- if isinstance(row, Mapping)
1049
- }
1035
+ candidates, seen = translator_reservations(project_root, manifest, team_state)
1050
1036
  run_manifest_rel = _string_value(manifest.get("runManifestPath"))
1051
- candidates, seen = _translator_reservation_candidates(
1052
- project_root,
1053
- reservation_root,
1054
- execution=execution,
1055
- dispatched_ids=dispatched_ids,
1056
- run_manifest_rel=run_manifest_rel,
1057
- )
1058
1037
  if len(candidates) != 1:
1059
1038
  run_label = run_manifest_rel or "run manifest path unknown"
1060
1039
  raise DispatchError(
@@ -1120,6 +1099,47 @@ def _translator_job_from_reservation(
1120
1099
  )
1121
1100
 
1122
1101
 
1102
+ def translator_reservations(
1103
+ project_root: Path,
1104
+ manifest: Mapping[str, Any],
1105
+ team_state: Mapping[str, Any],
1106
+ ) -> tuple[list[Mapping[str, Any]], list[str]]:
1107
+ """이 run 이 지금 디스패치할 수 있는 translator 예약과, 본 예약 전부의 요약.
1108
+
1109
+ 두 소비자가 같은 답을 읽는다: `_translator_job_from_reservation` 은 그 하나를
1110
+ 골라 띄우고, `report-finalize` 의 `translate` 단계
1111
+ (`okstra_ctl.report_translation_dispatch`) 는 0건이면 예약을 만든다. 후보를
1112
+ 세는 규칙이 두 곳이면 한쪽만 고쳐져 "만들었는데 못 고른다" 가 나온다.
1113
+ """
1114
+ execution = _canonical_translator_execution(manifest)
1115
+ if execution is None:
1116
+ raise DispatchError(
1117
+ "translator dispatch requires a canonical translator role execution"
1118
+ )
1119
+ contract = manifest.get("agentContract")
1120
+ if not isinstance(contract, Mapping):
1121
+ raise DispatchError("translator dispatch requires an agent contract")
1122
+ reservation_root = _resolve_required_path(
1123
+ project_root, contract, "invocationReservationRootPath"
1124
+ )
1125
+ dispatched_ids = {
1126
+ str(row.get("dispatchId") or "")
1127
+ for collection in (
1128
+ team_state.get("workerDispatches") or [],
1129
+ team_state.get("agentDispatches") or [],
1130
+ )
1131
+ for row in collection
1132
+ if isinstance(row, Mapping)
1133
+ }
1134
+ return _translator_reservation_candidates(
1135
+ project_root,
1136
+ reservation_root,
1137
+ execution=execution,
1138
+ dispatched_ids=dispatched_ids,
1139
+ run_manifest_rel=_string_value(manifest.get("runManifestPath")),
1140
+ )
1141
+
1142
+
1123
1143
  def _translator_reservation_candidates(
1124
1144
  project_root: Path,
1125
1145
  reservation_root: Path,
@@ -38,6 +38,12 @@ class MutationSnapshot:
38
38
  # 때문이다(다른 라운드의 재시도 프롬프트, 로그, 상태 사이드카가 그 창에
39
39
  # 들어온다).
40
40
  orchestrator_paths: tuple[str, ...] = ()
41
+ # artifact root(프로젝트 루트)의 HEAD. 소스 root 가 워크트리라 `git_projection`
42
+ # 은 워크트리를 보고, 프로젝트 루트에서 사람이 브랜치를 바꾸면 그 사실이 어디에도
43
+ # 남지 않았다. 다이제스트에는 넣지 않는다 — 이 필드가 없던 시절의 `before`
44
+ # 스냅샷이 아직 실행 중인 디스패치에 남아 있고, 그것을 못 읽으면 그 워커가
45
+ # 통째로 error 가 된다. 두 루트가 같으면 None.
46
+ artifact_git_head: str | None = None
41
47
 
42
48
  def to_payload(self) -> dict[str, Any]:
43
49
  return {
@@ -50,6 +56,7 @@ class MutationSnapshot:
50
56
  "gitProjection": dict(self.git_projection),
51
57
  "digest": self.digest,
52
58
  "orchestratorPaths": list(self.orchestrator_paths),
59
+ "artifactGitHead": self.artifact_git_head,
53
60
  }
54
61
 
55
62
  @classmethod
@@ -64,6 +71,10 @@ class MutationSnapshot:
64
71
  git_projection=dict(payload["gitProjection"]),
65
72
  digest=str(payload["digest"]),
66
73
  orchestrator_paths=tuple(payload.get("orchestratorPaths", ())),
74
+ artifact_git_head=(
75
+ str(payload["artifactGitHead"])
76
+ if payload.get("artifactGitHead") else None
77
+ ),
67
78
  )
68
79
  expected = _snapshot_digest(
69
80
  snapshot.root,
@@ -168,6 +179,9 @@ class ExecutionMutationAudit:
168
179
  git_projection=git_projection,
169
180
  digest=digest,
170
181
  orchestrator_paths=orchestrator,
182
+ artifact_git_head=(
183
+ None if artifact_root == root else _git_head(artifact_root)
184
+ ),
171
185
  )
172
186
 
173
187
  def compare(
@@ -206,8 +220,8 @@ class ExecutionMutationAudit:
206
220
  source_changes,
207
221
  out_of_plan_edits,
208
222
  )
209
- artifact_failures, untracked_artifact_changes = _artifact_policy_failures(
210
- before, rows, artifact_changed
223
+ artifact_failures, untracked_artifact_changes, switched = (
224
+ _artifact_policy_failures(before, rows, artifact_changed, after=after)
211
225
  )
212
226
  violations.extend(artifact_failures)
213
227
  status = _terminal_status(
@@ -234,7 +248,8 @@ class ExecutionMutationAudit:
234
248
  after_digest=after.digest,
235
249
  git_projection=after.git_projection,
236
250
  untracked_artifact_paths=tuple(sorted(untracked_artifact_changes)),
237
- warnings=_audit_warnings(untracked_artifact_changes),
251
+ warnings=_audit_warnings(untracked_artifact_changes)
252
+ + _branch_switch_warnings(before, after, switched),
238
253
  )
239
254
 
240
255
 
@@ -250,6 +265,24 @@ def _audit_warnings(untracked_artifact_changes: set[str]) -> tuple[str, ...]:
250
265
  )
251
266
 
252
267
 
268
+ def _branch_switch_warnings(
269
+ before: MutationSnapshot, after: MutationSnapshot, switched: set[str],
270
+ ) -> tuple[str, ...]:
271
+ if not switched:
272
+ return ()
273
+ shown = sorted(switched)[:5]
274
+ more = len(switched) - len(shown)
275
+ tail = f" (+{more} more)" if more else ""
276
+ return (
277
+ "artifact root HEAD moved "
278
+ f"{(before.artifact_git_head or '?')[:12]} → "
279
+ f"{(after.artifact_git_head or '?')[:12]} during the dispatch; "
280
+ f"{len(switched)} tracked path(s) that differ between those commits are "
281
+ "attributed to that switch, not to the worker: "
282
+ + ", ".join(shown) + tail,
283
+ )
284
+
285
+
253
286
  def _validate_batch(policies: tuple[WritePolicy, ...]) -> tuple[Path, Path]:
254
287
  if not policies:
255
288
  raise MutationAuditError("mutation audit requires at least one policy")
@@ -611,14 +644,24 @@ def _artifact_policy_failures(
611
644
  snapshot: MutationSnapshot,
612
645
  policies: Sequence[WritePolicy],
613
646
  changed: set[str],
614
- ) -> tuple[list[str], set[str]]:
615
- """``(위반 목록, 위반이 아닌 비추적 신규 경로)``.
647
+ *,
648
+ after: MutationSnapshot | None = None,
649
+ ) -> tuple[list[str], set[str], set[str]]:
650
+ """``(위반 목록, 위반이 아닌 비추적 신규 경로, 브랜치 전환으로 설명되는 경로)``.
616
651
 
617
652
  artifact root 의 변경 중 위반으로 남는 것은 두 부류다 — okstra 산출물
618
653
  서브트리(`.okstra/`) 안의 허용 밖 쓰기와, git 이 추적하는 파일의 변경.
619
654
  그 밖의 비추적 신규 파일은 워커의 도구가 남긴 로그·캐시이므로 소스 root 의
620
655
  `_split_tracked` 와 같은 이유로 기록만 한다. artifact root 가 git 레포가
621
656
  아니면 추적 여부를 알 수 없으므로 종전대로 전부 위반으로 본다.
657
+
658
+ 추적 파일의 변경 중 **artifact root 의 HEAD 가 실행 창 안에서 옮겨졌고 그
659
+ 두 커밋 사이에서 실제로 달라지는 경로**는 워커의 쓰기가 아니라 사람의 브랜치
660
+ 전환이다. 실측(2026-09-09, `fontsninja-v3-site` dev-10627 reverify r1b): 워커가
661
+ 워크트리에서 읽기만 하는 8분 동안 프로젝트 루트에서 `checkout preprod →
662
+ rebase` 가 있었고, run 브랜치에만 있는 `CardHero.{tsx,styled.ts}` 가 사라져
663
+ 완주한 결과가 `contract-failed-unattributed` 로 폐기됐다. 그 경로는 위반에서
664
+ 빼고 경고로 남긴다. 전환으로 설명되지 않는 추적 파일 변경은 그대로 위반이다.
622
665
  """
623
666
  allowed = _allowed_artifact_paths(policies)
624
667
  orchestrator = {
@@ -632,7 +675,7 @@ def _artifact_policy_failures(
632
675
  and not any(_is_within(path, item) for item in orchestrator)
633
676
  }
634
677
  if snapshot.artifact_root == snapshot.root:
635
- return [], set()
678
+ return [], set(), set()
636
679
  tracked = _tracked_paths(snapshot.artifact_root)
637
680
  if tracked is None:
638
681
  untracked_outside: set[str] = set()
@@ -642,9 +685,54 @@ def _artifact_policy_failures(
642
685
  if path not in tracked
643
686
  and not _is_within(path, _OKSTRA_ARTIFACT_SUBTREE)
644
687
  }
645
- violating = unauthorized - untracked_outside
688
+ # 전환으로 설명되는 경로는 사라진 쪽(after 에서 비추적)과 나타난 쪽(after 에서
689
+ # 추적) 양쪽에 걸친다 — 둘 다 워커의 흔적이 아니므로 두 집합에서 함께 뺀다.
690
+ switched = {
691
+ path for path in _branch_switch_paths(snapshot, after)
692
+ if path in unauthorized and not _is_within(path, _OKSTRA_ARTIFACT_SUBTREE)
693
+ }
694
+ untracked_outside -= switched
695
+ violating = unauthorized - untracked_outside - switched
646
696
  failures = ["artifact-root change exceeds batch policy union"] if violating else []
647
- return failures, untracked_outside
697
+ return failures, untracked_outside, switched
698
+
699
+
700
+ def _branch_switch_paths(
701
+ before: MutationSnapshot, after: MutationSnapshot | None,
702
+ ) -> set[str]:
703
+ """artifact root 의 HEAD 가 before→after 사이에 옮겨졌을 때 두 커밋 간 달라지는 경로."""
704
+ if after is None:
705
+ return set()
706
+ old_head, new_head = before.artifact_git_head, after.artifact_git_head
707
+ if not old_head or not new_head or old_head == new_head:
708
+ return set()
709
+ listing = subprocess.run(
710
+ ["git", "-C", str(before.artifact_root), "diff", "--name-only", old_head, new_head],
711
+ capture_output=True,
712
+ text=True,
713
+ check=False,
714
+ )
715
+ if listing.returncode != 0:
716
+ return set()
717
+ return {line.strip() for line in listing.stdout.splitlines() if line.strip()}
718
+
719
+
720
+ def _git_head(root: Path) -> str | None:
721
+ probe = subprocess.run(
722
+ ["git", "-C", str(root), "rev-parse", "--is-inside-work-tree"],
723
+ capture_output=True,
724
+ text=True,
725
+ check=False,
726
+ )
727
+ if probe.returncode != 0:
728
+ return None
729
+ head = subprocess.run(
730
+ ["git", "-C", str(root), "rev-parse", "HEAD"],
731
+ capture_output=True,
732
+ text=True,
733
+ check=False,
734
+ )
735
+ return head.stdout.strip() or None if head.returncode == 0 else None
648
736
 
649
737
 
650
738
  def _allowed_artifact_paths(policies: Sequence[WritePolicy]) -> set[str]: