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.
- package/docs/architecture.md +2 -2
- package/docs/cli.md +3 -2
- package/docs/project-structure-overview.md +3 -1
- package/docs/task-process/README.md +2 -2
- package/docs/task-process/common-flow.md +4 -5
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/translator-worker.md +1 -1
- package/runtime/prompts/launch.template.md +1 -1
- package/runtime/prompts/lead/convergence.md +13 -3
- package/runtime/prompts/lead/okstra-lead-contract.md +2 -2
- package/runtime/prompts/lead/plan-body-verification.md +1 -1
- package/runtime/prompts/lead/report-writer.md +11 -8
- package/runtime/prompts/profiles/_implementation-executor.md +1 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +52 -23
- package/runtime/python/okstra_ctl/conformance.py +74 -0
- package/runtime/python/okstra_ctl/convergence.py +63 -1
- package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +238 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +42 -22
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +96 -8
- package/runtime/python/okstra_ctl/next_phase.py +18 -8
- package/runtime/python/okstra_ctl/plan_items.py +6 -4
- package/runtime/python/okstra_ctl/plan_items_cli.py +91 -6
- package/runtime/python/okstra_ctl/report_finalize.py +57 -10
- package/runtime/python/okstra_ctl/report_translation_dispatch.py +300 -0
- package/runtime/python/okstra_ctl/verdict_blocks.py +37 -7
- package/runtime/python/okstra_ctl/wizard/engine.py +16 -2
- package/runtime/python/okstra_ctl/wizard/registry.py +11 -2
- package/runtime/python/okstra_ctl/wizard/roles.py +364 -361
- package/runtime/python/okstra_ctl/wizard/state.py +39 -27
- package/runtime/python/okstra_ctl/wizard/steps_identity.py +50 -8
- package/runtime/python/okstra_ctl/wizard/steps_roles.py +1 -0
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +11 -0
- package/runtime/skills/okstra-run/SKILL.md +2 -2
- 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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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]:
|