okstra 0.221.0 → 0.223.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/storage-model.md +1 -1
- package/docs/architecture.md +5 -5
- package/docs/cli.md +7 -7
- package/docs/project-structure-overview.md +4 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/lead/convergence.md +3 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
- package/runtime/prompts/lead/phase-routing.md +1 -1
- package/runtime/python/okstra_ctl/analysis_packet.py +8 -6
- package/runtime/python/okstra_ctl/business_flow/engine.py +1 -0
- package/runtime/python/okstra_ctl/convergence.py +155 -19
- package/runtime/python/okstra_ctl/convergence_critic_prompt.py +3 -2
- package/runtime/python/okstra_ctl/convergence_migration.py +10 -1
- package/runtime/python/okstra_ctl/convergence_provenance.py +69 -24
- package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +4 -1
- package/runtime/python/okstra_ctl/correction_cli.py +7 -7
- package/runtime/python/okstra_ctl/correction_round.py +40 -0
- package/runtime/python/okstra_ctl/fix_cycles.py +14 -1
- package/runtime/python/okstra_ctl/git_reconcile.py +62 -8
- package/runtime/python/okstra_ctl/next_phase.py +22 -9
- package/runtime/python/okstra_ctl/phases/final_verification/target.py +4 -2
- package/runtime/python/okstra_ctl/phases/implementation/entry.py +6 -2
- package/runtime/python/okstra_ctl/phases/implementation/host-rules.md +1 -0
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +2 -2
- package/runtime/python/okstra_ctl/phases/release_handoff/entry.py +42 -1
- package/runtime/python/okstra_ctl/phases/release_handoff/operations.py +52 -0
- package/runtime/python/okstra_ctl/phases/release_handoff/profile.md +3 -2
- package/runtime/python/okstra_ctl/phases/release_handoff/report_assets/release-handoff-input.template.md +3 -1
- package/runtime/python/okstra_ctl/phases/release_handoff/spec.md +1 -1
- package/runtime/python/okstra_ctl/process_group.py +4 -0
- package/runtime/python/okstra_ctl/report_assembly.py +50 -13
- package/runtime/python/okstra_ctl/report_html/common.py +3 -1
- package/runtime/python/okstra_ctl/report_projections.py +46 -4
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +22 -1
- package/runtime/python/okstra_ctl/run.py +8 -5
- package/runtime/python/okstra_ctl/stage_reconcile.py +49 -1
- package/runtime/python/okstra_ctl/wizard/steps_plan.py +3 -3
- package/runtime/python/okstra_ctl/worktree/decisions.py +42 -5
- package/runtime/python/okstra_ctl/worktree/provision.py +49 -2
- package/runtime/schemas/final-report-v2.0.schema.json +7 -2
- package/runtime/schemas/final-report-v3.0.schema.json +43 -2
- package/runtime/skills/okstra-run/SKILL.md +1 -0
- package/runtime/templates/reports/html/base.template.html +6 -1
- package/runtime/templates/reports/html/i18n/en.json +1 -0
- package/runtime/templates/reports/html/i18n/ko.json +1 -0
- package/runtime/validators/validate-run.py +10 -6
- package/runtime/validators/validate_session_conformance.py +19 -1
|
@@ -21,7 +21,6 @@ from pathlib import Path
|
|
|
21
21
|
from typing import Mapping
|
|
22
22
|
|
|
23
23
|
from okstra_ctl.convergence_engine import grouped_input_digest
|
|
24
|
-
from okstra_ctl.correction_round import correction_round
|
|
25
24
|
from okstra_ctl.execution_identity import Attempt, ExecutionManifest
|
|
26
25
|
from okstra_ctl.execution_manifest import read_execution_manifest_view
|
|
27
26
|
from okstra_ctl.json_boundary import (
|
|
@@ -30,10 +29,16 @@ from okstra_ctl.json_boundary import (
|
|
|
30
29
|
from okstra_ctl.worker_prompt_policy import CENSUS_GAPFILL_DISPATCH_KIND
|
|
31
30
|
|
|
32
31
|
GROUPS_BASENAME_RE = re.compile(
|
|
33
|
-
r"^convergence-groups-(?P<
|
|
32
|
+
r"^convergence-groups-(?:(?P<scope>correction-r[1-9][0-9]*)-)?"
|
|
33
|
+
r"(?P<suffix>[a-z][a-z-]*?-\d{3})\.json$"
|
|
34
34
|
)
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
def scoped_suffix(suffix: str, scope: str | None) -> str:
|
|
38
|
+
"""`implementation-001` in the initial namespace, `correction-r1-implementation-001` in round 1's."""
|
|
39
|
+
return f"{scope}-{suffix}" if scope else suffix
|
|
40
|
+
|
|
41
|
+
|
|
37
42
|
def _nonempty_string(value: object) -> bool:
|
|
38
43
|
return isinstance(value, str) and bool(value.strip())
|
|
39
44
|
|
|
@@ -81,7 +86,9 @@ def group_claims(document: object) -> list[tuple[str, str, str]] | None:
|
|
|
81
86
|
return claims
|
|
82
87
|
|
|
83
88
|
|
|
84
|
-
def groups_digest_ok(
|
|
89
|
+
def groups_digest_ok(
|
|
90
|
+
state_dir: Path, suffix: str, document: dict, scope: str | None = None,
|
|
91
|
+
) -> bool:
|
|
85
92
|
"""False only when a recorded groupsDigest exists and no longer matches.
|
|
86
93
|
|
|
87
94
|
``okstra convergence seed`` pins ``grouped_input_digest`` of the grouping
|
|
@@ -90,7 +97,7 @@ def groups_digest_ok(state_dir: Path, suffix: str, document: dict) -> bool:
|
|
|
90
97
|
evidence of fabrication — so the caller refuses to judge it. A missing
|
|
91
98
|
working state or missing digest means judge the file as parsed.
|
|
92
99
|
"""
|
|
93
|
-
work_path = state_dir / f"convergence-work-{suffix}.json"
|
|
100
|
+
work_path = state_dir / f"convergence-work-{scoped_suffix(suffix, scope)}.json"
|
|
94
101
|
try:
|
|
95
102
|
work = load_owned_object(work_path, artifact="convergence work state")
|
|
96
103
|
except (OSError, ValueError):
|
|
@@ -101,11 +108,14 @@ def groups_digest_ok(state_dir: Path, suffix: str, document: dict) -> bool:
|
|
|
101
108
|
return grouped_input_digest(document) == recorded
|
|
102
109
|
|
|
103
110
|
|
|
104
|
-
def discovery_attempts(
|
|
105
|
-
|
|
111
|
+
def discovery_attempts(
|
|
112
|
+
manifest: ExecutionManifest, scope: str | None = None,
|
|
113
|
+
) -> dict[str, list[Attempt]]:
|
|
114
|
+
"""한 수렴 범위의 발견 결과. 교정 실행은 초기 결과를 대체하지만 재검증 투표는 발견 출처가 아니다.
|
|
106
115
|
|
|
107
|
-
수정 라운드(`correction-r<N>`)
|
|
108
|
-
|
|
116
|
+
수정 라운드 N 의 재수렴(`scope` = `correction-r<N>`)은 그 라운드의 재검증 결과만 읽고, 초기
|
|
117
|
+
수렴은 초기 결과만 읽는다. 하나로 섞으면 이미 끝난 초기 grouping 의 출처가 라운드 결과로
|
|
118
|
+
바뀌어 그 grouping 이 출처 검사에서 깨진다.
|
|
109
119
|
"""
|
|
110
120
|
latest: dict[str, Attempt] = {}
|
|
111
121
|
for attempt in manifest.attempts:
|
|
@@ -114,8 +124,8 @@ def discovery_attempts(manifest: ExecutionManifest) -> dict[str, list[Attempt]]:
|
|
|
114
124
|
latest[attempt.invocation_ref] = attempt
|
|
115
125
|
by_role: dict[str, list[Attempt]] = {}
|
|
116
126
|
for invocation in manifest.invocations:
|
|
117
|
-
|
|
118
|
-
|
|
127
|
+
kind = invocation.dispatch_kind
|
|
128
|
+
if (kind != scope if scope else kind not in {"initial", "correction"}):
|
|
119
129
|
continue
|
|
120
130
|
attempt = latest.get(invocation.invocation_ref)
|
|
121
131
|
if attempt is not None:
|
|
@@ -124,13 +134,13 @@ def discovery_attempts(manifest: ExecutionManifest) -> dict[str, list[Attempt]]:
|
|
|
124
134
|
|
|
125
135
|
|
|
126
136
|
def worker_result_paths(
|
|
127
|
-
run_dir: Path, document: Mapping, suffix: str
|
|
137
|
+
run_dir: Path, document: Mapping, suffix: str, scope: str | None = None,
|
|
128
138
|
) -> dict[str, Path]:
|
|
129
139
|
"""실행 원장의 채택된 발견 결과를 고른다. 구형 원장은 파일명 규칙을 쓴다."""
|
|
130
140
|
results_dir = run_dir / "worker-results"
|
|
131
141
|
workers = document.get("workers") or []
|
|
132
142
|
paths = {
|
|
133
|
-
row["workerId"]: results_dir / f"{row['workerId']}-worker-{suffix}.md"
|
|
143
|
+
row["workerId"]: results_dir / f"{row['workerId']}-worker-{scoped_suffix(suffix, scope)}.md"
|
|
134
144
|
for row in workers if isinstance(row, Mapping) and row.get("workerId")
|
|
135
145
|
}
|
|
136
146
|
if document.get("schemaVersion") != "2.0":
|
|
@@ -142,7 +152,7 @@ def worker_result_paths(
|
|
|
142
152
|
manifest, authority = read_execution_manifest_view(manifest_path)
|
|
143
153
|
if manifest.legacy:
|
|
144
154
|
return paths
|
|
145
|
-
attempts = discovery_attempts(manifest)
|
|
155
|
+
attempts = discovery_attempts(manifest, scope)
|
|
146
156
|
for row in workers:
|
|
147
157
|
if not isinstance(row, Mapping) or not row.get("workerId"):
|
|
148
158
|
continue
|
|
@@ -208,6 +218,7 @@ def provenance_errors(
|
|
|
208
218
|
worker_results_dir: Path,
|
|
209
219
|
suffix: str,
|
|
210
220
|
groups_label: str,
|
|
221
|
+
scope: str | None = None,
|
|
211
222
|
) -> list[str]:
|
|
212
223
|
"""Every claim in ``document`` whose cited ID is absent from its worker's
|
|
213
224
|
result file. An unjudgeable document yields no errors — a caller that
|
|
@@ -215,22 +226,23 @@ def provenance_errors(
|
|
|
215
226
|
claims = group_claims(document)
|
|
216
227
|
if claims is None:
|
|
217
228
|
return []
|
|
218
|
-
paths = worker_result_paths(worker_results_dir.parent, document, suffix)
|
|
229
|
+
paths = worker_result_paths(worker_results_dir.parent, document, suffix, scope)
|
|
230
|
+
result_suffix = scoped_suffix(suffix, scope)
|
|
219
231
|
contents: dict[str, str | None] = {}
|
|
220
232
|
errors: list[str] = []
|
|
221
233
|
for finding_id, worker, item_id in claims:
|
|
222
234
|
if worker not in contents:
|
|
223
235
|
candidate = paths.get(worker)
|
|
224
|
-
if candidate is not None and candidate.name != f"{worker}-worker-{
|
|
236
|
+
if candidate is not None and candidate.name != f"{worker}-worker-{result_suffix}.md":
|
|
225
237
|
contents[worker] = candidate.read_text(encoding="utf-8")
|
|
226
238
|
else:
|
|
227
239
|
contents[worker] = read_canonical_worker_result(
|
|
228
|
-
worker_results_dir, worker,
|
|
240
|
+
worker_results_dir, worker, result_suffix
|
|
229
241
|
)
|
|
230
242
|
text = contents[worker]
|
|
231
243
|
if text is None or id_occurs_wordbounded(text, item_id):
|
|
232
244
|
continue
|
|
233
|
-
gapfill = read_canonical_worker_result(
|
|
245
|
+
gapfill = None if scope else read_canonical_worker_result(
|
|
234
246
|
worker_results_dir, worker, f"{CENSUS_GAPFILL_DISPATCH_KIND}-{suffix}"
|
|
235
247
|
)
|
|
236
248
|
# A finding the census gap-fill raised lives in that worker's gap-fill
|
|
@@ -240,7 +252,7 @@ def provenance_errors(
|
|
|
240
252
|
errors.append(
|
|
241
253
|
f"convergence groups `{groups_label}` group {finding_id} "
|
|
242
254
|
f"cites source item `{worker}:{item_id}`, but that ID does not "
|
|
243
|
-
f"occur in the worker's result file `{paths.get(worker, Path(f'{worker}-worker-{
|
|
255
|
+
f"occur in the worker's result file `{paths.get(worker, Path(f'{worker}-worker-{result_suffix}.md')).name}` — a "
|
|
244
256
|
"grouping may not invent a provenance link to an item the "
|
|
245
257
|
"worker never reported."
|
|
246
258
|
)
|
|
@@ -297,7 +309,7 @@ def run_dir_provenance_errors(run_dir: Path) -> list[str]:
|
|
|
297
309
|
match = GROUPS_BASENAME_RE.match(groups_path.name)
|
|
298
310
|
if match is None:
|
|
299
311
|
continue
|
|
300
|
-
suffix = match.group("suffix")
|
|
312
|
+
suffix, scope = match.group("suffix"), match.group("scope")
|
|
301
313
|
try:
|
|
302
314
|
document = load_owned_object(
|
|
303
315
|
groups_path, artifact="convergence groups input"
|
|
@@ -306,7 +318,7 @@ def run_dir_provenance_errors(run_dir: Path) -> list[str]:
|
|
|
306
318
|
continue
|
|
307
319
|
if not isinstance(document, dict):
|
|
308
320
|
continue
|
|
309
|
-
if not groups_digest_ok(state_dir, suffix, document):
|
|
321
|
+
if not groups_digest_ok(state_dir, suffix, document, scope):
|
|
310
322
|
continue
|
|
311
323
|
result_suffix = worker_result_suffix(Path(run_dir), document)
|
|
312
324
|
if result_suffix is None:
|
|
@@ -317,6 +329,7 @@ def run_dir_provenance_errors(run_dir: Path) -> list[str]:
|
|
|
317
329
|
worker_results_dir=worker_results_dir,
|
|
318
330
|
suffix=result_suffix,
|
|
319
331
|
groups_label=groups_path.name,
|
|
332
|
+
scope=scope,
|
|
320
333
|
)
|
|
321
334
|
)
|
|
322
335
|
return errors
|
|
@@ -329,7 +342,9 @@ DISCARDED_ATTEMPT_STATUSES = frozenset(
|
|
|
329
342
|
)
|
|
330
343
|
|
|
331
344
|
|
|
332
|
-
def discarded_workers(
|
|
345
|
+
def discarded_workers(
|
|
346
|
+
document: object, execution_manifest: object, scope: str | None = None,
|
|
347
|
+
) -> dict[str, str]:
|
|
333
348
|
"""``{workerId: "<invocationRef>#<attempt> <status>"}`` — 발견 attempt 가 전부
|
|
334
349
|
폐기돼 채택할 결과가 없는 분석 워커.
|
|
335
350
|
|
|
@@ -341,7 +356,7 @@ def discarded_workers(document: object, execution_manifest: object) -> dict[str,
|
|
|
341
356
|
return {}
|
|
342
357
|
if execution_manifest is None or getattr(execution_manifest, "legacy", False):
|
|
343
358
|
return {}
|
|
344
|
-
attempts = discovery_attempts(execution_manifest)
|
|
359
|
+
attempts = discovery_attempts(execution_manifest, scope)
|
|
345
360
|
result: dict[str, str] = {}
|
|
346
361
|
for row in document["workers"]:
|
|
347
362
|
if not isinstance(row, dict):
|
|
@@ -360,7 +375,9 @@ def discarded_workers(document: object, execution_manifest: object) -> dict[str,
|
|
|
360
375
|
return result
|
|
361
376
|
|
|
362
377
|
|
|
363
|
-
def discarded_worker_errors(
|
|
378
|
+
def discarded_worker_errors(
|
|
379
|
+
document: object, execution_manifest: object, scope: str | None = None,
|
|
380
|
+
) -> list[str]:
|
|
364
381
|
"""Groups that cite a worker whose only initial attempts were discarded.
|
|
365
382
|
|
|
366
383
|
The mutation audit discards a result by setting the attempt terminal status
|
|
@@ -371,7 +388,7 @@ def discarded_worker_errors(document: object, execution_manifest: object) -> lis
|
|
|
371
388
|
3 named it ``originWorker`` — the ledger said "discarded" while convergence
|
|
372
389
|
said "adopted".
|
|
373
390
|
"""
|
|
374
|
-
discarded = discarded_workers(document, execution_manifest)
|
|
391
|
+
discarded = discarded_workers(document, execution_manifest, scope)
|
|
375
392
|
if not discarded or not isinstance(document, dict):
|
|
376
393
|
return []
|
|
377
394
|
errors: list[str] = []
|
|
@@ -393,3 +410,31 @@ def discarded_worker_errors(document: object, execution_manifest: object) -> lis
|
|
|
393
410
|
"as a new invocation and cite that result"
|
|
394
411
|
)
|
|
395
412
|
return errors
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def missing_scope_results(
|
|
416
|
+
document: object, execution_manifest: object, scope: str | None,
|
|
417
|
+
) -> list[str]:
|
|
418
|
+
"""Analysis workers of a correction round's grouping with no accepted result from that round.
|
|
419
|
+
|
|
420
|
+
Without one the provenance check would read nothing and pass, and the round would converge
|
|
421
|
+
on items the worker never re-verified.
|
|
422
|
+
"""
|
|
423
|
+
if scope is None or execution_manifest is None or getattr(execution_manifest, "legacy", False):
|
|
424
|
+
return []
|
|
425
|
+
if not isinstance(document, dict):
|
|
426
|
+
return []
|
|
427
|
+
attempts = discovery_attempts(execution_manifest, scope)
|
|
428
|
+
missing = sorted({
|
|
429
|
+
str(row.get("workerId"))
|
|
430
|
+
for row in document.get("workers") or []
|
|
431
|
+
if isinstance(row, dict) and row.get("audience") == "analysis"
|
|
432
|
+
and not any(
|
|
433
|
+
item.status == "ok" for item in attempts.get(row.get("sourceRoleExecutionRef"), [])
|
|
434
|
+
)
|
|
435
|
+
})
|
|
436
|
+
return [
|
|
437
|
+
f"`{worker}` has no accepted `{scope}` result; await that round's verifiers "
|
|
438
|
+
"before converging it"
|
|
439
|
+
for worker in missing
|
|
440
|
+
]
|
|
@@ -136,6 +136,7 @@ def reverify_findings(
|
|
|
136
136
|
*,
|
|
137
137
|
project_root: Path,
|
|
138
138
|
run_dir: Path,
|
|
139
|
+
scope: str | None = None,
|
|
139
140
|
) -> list[ReverifyFinding]:
|
|
140
141
|
"""계획 행의 finding 을 계획 순서대로, 원 워커의 실물 인용 위치와 함께."""
|
|
141
142
|
row = plan_row_for_worker(plan, worker_id)
|
|
@@ -146,7 +147,9 @@ def reverify_findings(
|
|
|
146
147
|
}
|
|
147
148
|
result_paths = {
|
|
148
149
|
result.worker_id: result.result_path
|
|
149
|
-
for result in analyser_results(
|
|
150
|
+
for result in analyser_results(
|
|
151
|
+
groups, project_root=project_root, run_dir=run_dir, scope=scope,
|
|
152
|
+
)
|
|
150
153
|
}
|
|
151
154
|
findings: list[ReverifyFinding] = []
|
|
152
155
|
for finding_id in row["findingIds"]:
|
|
@@ -6,8 +6,8 @@ import subprocess
|
|
|
6
6
|
import sys
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
|
-
from .convergence import
|
|
10
|
-
from .correction_round import CorrectionRoundError, correction_instruction
|
|
9
|
+
from .convergence import scoped_convergence_state, validated_run_authority
|
|
10
|
+
from .correction_round import CorrectionRoundError, convergence_scope, correction_instruction
|
|
11
11
|
from .file_size_cap import FILE_LINE_LIMIT, FILE_LINE_TARGET, file_size_violations
|
|
12
12
|
|
|
13
13
|
|
|
@@ -18,7 +18,9 @@ _CLI_EPILOG = r"""Usage:
|
|
|
18
18
|
|
|
19
19
|
`prompt` prints the instruction body for one correction-round prompt; pass it to
|
|
20
20
|
`okstra agent-prompt materialize --instruction` with --dispatch-kind correction-r<N>.
|
|
21
|
-
Findings are read from the
|
|
21
|
+
Findings are read from the convergence record that preceded the round: the run's
|
|
22
|
+
initial one for round 1, round N-1's re-convergence (`convergence-correction-r<N-1>-…`)
|
|
23
|
+
for round N; --stage-base adds the file-size
|
|
22
24
|
findings of `<stage-base>..<base>` (the 400-line limit only). Exit 1 on a refusal.
|
|
23
25
|
|
|
24
26
|
`file-size` prints one `FILE-SIZE:<path>:401` line per file the stage diff leaves over
|
|
@@ -72,10 +74,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
72
74
|
authority = validated_run_authority(args.run_manifest)
|
|
73
75
|
if authority.task_type != "implementation":
|
|
74
76
|
raise CorrectionRoundError("correction rounds exist only in implementation runs")
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
label="convergence state",
|
|
78
|
-
)
|
|
77
|
+
previous = args.round - 1 if args.round > 1 else None
|
|
78
|
+
convergence_path = scoped_convergence_state(authority, convergence_scope(previous))
|
|
79
79
|
size_violations = [
|
|
80
80
|
v for v in file_size_violations(Path.cwd(), args.stage_base, args.base)
|
|
81
81
|
if v.blocking
|
|
@@ -32,6 +32,46 @@ def correction_round(dispatch_kind: str) -> int | None:
|
|
|
32
32
|
return int(match.group(1)) if match else None
|
|
33
33
|
|
|
34
34
|
|
|
35
|
+
def convergence_scope(round_number: int | None) -> str | None:
|
|
36
|
+
"""The convergence namespace of correction round N (`correction-r<N>`); None is the initial one.
|
|
37
|
+
|
|
38
|
+
Re-converging a round's re-verification reuses no initial-round artifact: its groups, working
|
|
39
|
+
and final state, round plans and reverify invocations carry this token, so the initial final
|
|
40
|
+
stays as written and its provenance keeps reading the initial results.
|
|
41
|
+
"""
|
|
42
|
+
if round_number is None:
|
|
43
|
+
return None
|
|
44
|
+
if not 1 <= round_number <= MAX_CORRECTION_ROUNDS:
|
|
45
|
+
raise CorrectionRoundError(f"--correction-round must be 1..{MAX_CORRECTION_ROUNDS}")
|
|
46
|
+
return f"{CORRECTION_DISPATCH_KIND_PREFIX}{round_number}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def correction_convergence_states(initial_final: Path) -> list[tuple[str, Path]]:
|
|
50
|
+
"""Each correction round's re-convergence final written beside the run's initial one."""
|
|
51
|
+
prefix = "convergence-"
|
|
52
|
+
if not initial_final.name.startswith(prefix):
|
|
53
|
+
return []
|
|
54
|
+
rest = initial_final.name[len(prefix):]
|
|
55
|
+
rows = []
|
|
56
|
+
for round_number in range(1, MAX_CORRECTION_ROUNDS + 1):
|
|
57
|
+
scope = convergence_scope(round_number)
|
|
58
|
+
path = initial_final.with_name(f"{prefix}{scope}-{rest}")
|
|
59
|
+
if path.is_file():
|
|
60
|
+
rows.append((scope, path))
|
|
61
|
+
return rows
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def reverify_invocation_in_scope(invocation_ref: str, scope: str | None, round_number: int) -> bool:
|
|
65
|
+
"""Whether a `reverify-r<N>` invocation belongs to this convergence namespace.
|
|
66
|
+
|
|
67
|
+
Both namespaces dispatch kind `reverify-r<N>`, so the invocation id tells them apart: a
|
|
68
|
+
correction re-convergence names it `…-correction-r<M>-reverify-r<N>-<worker>`.
|
|
69
|
+
"""
|
|
70
|
+
if scope is None:
|
|
71
|
+
return f"-{CORRECTION_DISPATCH_KIND_PREFIX}" not in invocation_ref
|
|
72
|
+
return f"-{scope}-reverify-r{round_number}-" in invocation_ref
|
|
73
|
+
|
|
74
|
+
|
|
35
75
|
def validate_correction_dispatch(
|
|
36
76
|
manifest: ExecutionManifest, jobs: Sequence[object],
|
|
37
77
|
) -> None:
|
|
@@ -17,7 +17,9 @@ from __future__ import annotations
|
|
|
17
17
|
import json
|
|
18
18
|
import re
|
|
19
19
|
from pathlib import Path
|
|
20
|
-
from typing import Any, Dict, List, Optional
|
|
20
|
+
from typing import Any, Dict, List, Mapping, Optional
|
|
21
|
+
|
|
22
|
+
from okstra_project.phase_pointer import promote
|
|
21
23
|
|
|
22
24
|
from .run_context import dir_flock
|
|
23
25
|
from .jsonl import append_jsonl
|
|
@@ -33,6 +35,17 @@ FIX_CYCLE_ENTRY_PHASES = (
|
|
|
33
35
|
)
|
|
34
36
|
|
|
35
37
|
|
|
38
|
+
def delivery_completed(workflow: Mapping[str, Any]) -> bool:
|
|
39
|
+
"""release-handoff 가 전달까지 끝냈는가. fix cycle 을 열고 닫는 조건이다.
|
|
40
|
+
|
|
41
|
+
skip·미전달 stage 가 남은 handoff 는 다음 phase 포인터를 다시
|
|
42
|
+
release-handoff 로 둔다(`next_phase.project`).
|
|
43
|
+
"""
|
|
44
|
+
if workflow.get("lastCompletedPhase") != "release-handoff":
|
|
45
|
+
return False
|
|
46
|
+
return promote(workflow.get("nextRecommendedPhase"))["phase"] != "release-handoff"
|
|
47
|
+
|
|
48
|
+
|
|
36
49
|
def fix_cycles_path(task_root: Path) -> Path:
|
|
37
50
|
return Path(task_root) / "history" / FIX_CYCLES_FILENAME
|
|
38
51
|
|
|
@@ -165,13 +165,20 @@ class ReconcileError(Exception):
|
|
|
165
165
|
pass
|
|
166
166
|
|
|
167
167
|
|
|
168
|
+
# 최신 done 행이 last-wins 라, 보정 행이 이 필드를 안 옮기면 stage 의 보고서·carry 가 사라진다.
|
|
169
|
+
_COMPLETION_FIELDS = ("report_path", "carry_path", "completed_at")
|
|
170
|
+
|
|
171
|
+
|
|
168
172
|
def _record_reconciled(plan_run_root: Path, *, impl_task_key: str, stage: int,
|
|
169
173
|
new_commit: str, replaced: str, reason: str) -> None:
|
|
170
|
-
from .consumers import append_consumer
|
|
174
|
+
from .consumers import append_consumer, latest_done_by_stage, read_consumers
|
|
175
|
+
previous = latest_done_by_stage(read_consumers(plan_run_root)).get(stage) or {}
|
|
176
|
+
completion = {k: previous[k] for k in _COMPLETION_FIELDS if previous.get(k)}
|
|
171
177
|
append_consumer(
|
|
172
178
|
plan_run_root, impl_task_key=impl_task_key, stage=stage,
|
|
173
179
|
status="done", force_reappend=True, head_commit=new_commit,
|
|
174
180
|
reconciled=True, reconcile_reason=reason, replaced_commit=replaced,
|
|
181
|
+
**completion,
|
|
175
182
|
)
|
|
176
183
|
|
|
177
184
|
|
|
@@ -195,12 +202,19 @@ def auto_reconcile(*, project_root: Path, plan_run_root: Path,
|
|
|
195
202
|
return applied
|
|
196
203
|
|
|
197
204
|
|
|
198
|
-
def
|
|
205
|
+
def _command(*, plan_run_root: Path, project_id: str, task_group: str,
|
|
199
206
|
task_id: str, work_category: str) -> str:
|
|
200
|
-
"
|
|
201
|
-
base = (f"okstra git-reconcile --plan-run-root {plan_run_root} "
|
|
207
|
+
return (f"okstra git-reconcile --plan-run-root {plan_run_root} "
|
|
202
208
|
f"--project-id {project_id} --task-group {task_group} "
|
|
203
209
|
f"--task-id {task_id} --work-category {work_category}")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def guidance(*, plan_run_root: Path, project_id: str, task_group: str,
|
|
213
|
+
task_id: str, work_category: str) -> str:
|
|
214
|
+
"""PrepareError 에 첨부하는 회복 안내 (명령 예시 포함)."""
|
|
215
|
+
base = _command(plan_run_root=plan_run_root, project_id=project_id,
|
|
216
|
+
task_group=task_group, task_id=task_id,
|
|
217
|
+
work_category=work_category)
|
|
204
218
|
return ("Recorded stage SHAs no longer match the git history "
|
|
205
219
|
"(external rebase/squash/amend?). Inspect and reconcile:\n"
|
|
206
220
|
f" {base} --check --text\n"
|
|
@@ -224,6 +238,32 @@ def _apply_user_ref(project_root: Path, plan_run_root: Path,
|
|
|
224
238
|
return {"stage": stage, "new_commit": sha}
|
|
225
239
|
|
|
226
240
|
|
|
241
|
+
def _anchor_warning(*, project_root: Path, plan_run_root: Path, project_id: str,
|
|
242
|
+
task_group: str, task_id: str, work_category: str,
|
|
243
|
+
stage: int, new_commit: str) -> str:
|
|
244
|
+
"""보정한 head 가 anchor 를 담지 않으면 whole-task diff 의 base 가 틀린다.
|
|
245
|
+
anchor 는 --reset-anchor 로만 옮기므로 경고와 명령만 낸다."""
|
|
246
|
+
from . import worktree_registry as _reg
|
|
247
|
+
|
|
248
|
+
anchor = _reg.get_implementation_base(project_id, task_group, task_id) or ""
|
|
249
|
+
if not _resolve_commit_sha(project_root, anchor):
|
|
250
|
+
return ""
|
|
251
|
+
if _git(project_root, "merge-base", "--is-ancestor",
|
|
252
|
+
anchor, new_commit).returncode == 0:
|
|
253
|
+
return ""
|
|
254
|
+
entry = _reg.lookup(project_id, task_group, task_id)
|
|
255
|
+
base_ref = entry.base_ref if entry else ""
|
|
256
|
+
new_base = (_git(project_root, "merge-base", base_ref, new_commit).stdout.strip()
|
|
257
|
+
if base_ref else "")
|
|
258
|
+
command = _command(plan_run_root=plan_run_root, project_id=project_id,
|
|
259
|
+
task_group=task_group, task_id=task_id,
|
|
260
|
+
work_category=work_category)
|
|
261
|
+
return (f"stage {stage} head {new_commit} does not contain the implementation "
|
|
262
|
+
f"anchor {anchor}; whole-task final-verification would diff from the "
|
|
263
|
+
f"old base. Move the anchor after confirming the new base:\n"
|
|
264
|
+
f" {command} --apply --reset-anchor {new_base or '<new-base-commit>'}")
|
|
265
|
+
|
|
266
|
+
|
|
227
267
|
def apply_reconcile(*, project_root: Path, plan_run_root: Path,
|
|
228
268
|
project_id: str, task_group: str, task_id: str,
|
|
229
269
|
work_category: str, stage: Optional[int] = None,
|
|
@@ -239,6 +279,7 @@ def apply_reconcile(*, project_root: Path, plan_run_root: Path,
|
|
|
239
279
|
from .consumers import read_consumers, latest_done_by_stage
|
|
240
280
|
|
|
241
281
|
applied: List[dict] = []
|
|
282
|
+
warnings: List[str] = []
|
|
242
283
|
if reset_anchor:
|
|
243
284
|
sha = _resolve_commit_sha(project_root, reset_anchor)
|
|
244
285
|
if not sha:
|
|
@@ -247,8 +288,16 @@ def apply_reconcile(*, project_root: Path, plan_run_root: Path,
|
|
|
247
288
|
applied.append({"anchor": sha})
|
|
248
289
|
if use_ref:
|
|
249
290
|
latest = latest_done_by_stage(read_consumers(plan_run_root))
|
|
250
|
-
|
|
251
|
-
project_root, plan_run_root, latest, stage, use_ref)
|
|
291
|
+
recorded = _apply_user_ref(
|
|
292
|
+
project_root, plan_run_root, latest, stage, use_ref)
|
|
293
|
+
applied.append(recorded)
|
|
294
|
+
warning = _anchor_warning(
|
|
295
|
+
project_root=project_root, plan_run_root=plan_run_root,
|
|
296
|
+
project_id=project_id, task_group=task_group, task_id=task_id,
|
|
297
|
+
work_category=work_category, stage=stage,
|
|
298
|
+
new_commit=recorded["new_commit"])
|
|
299
|
+
if warning:
|
|
300
|
+
warnings.append(warning)
|
|
252
301
|
items = classify_task(
|
|
253
302
|
project_root=project_root, plan_run_root=plan_run_root,
|
|
254
303
|
project_id=project_id, task_group=task_group, task_id=task_id,
|
|
@@ -264,7 +313,8 @@ def apply_reconcile(*, project_root: Path, plan_run_root: Path,
|
|
|
264
313
|
applied.append({"stage": item.stage, "new_commit": item.suggested_commit})
|
|
265
314
|
remaining = [i for i in items if i.classification == "confirm"
|
|
266
315
|
and not (use_ref and i.stage == stage)]
|
|
267
|
-
return {"applied": applied, "remaining_confirm": remaining
|
|
316
|
+
return {"applied": applied, "remaining_confirm": remaining,
|
|
317
|
+
"warnings": warnings}
|
|
268
318
|
|
|
269
319
|
|
|
270
320
|
def _items_as_json(items: List[StaleItem]) -> list:
|
|
@@ -345,6 +395,9 @@ def render_git_reconcile_text(payload: dict) -> str:
|
|
|
345
395
|
rows.append(line(f"Item {index} kind", item.get("kind")))
|
|
346
396
|
rows.append(line(f"Item {index} classification", item.get("classification")))
|
|
347
397
|
rows.append(line(f"Item {index} reason", item.get("reason")))
|
|
398
|
+
warnings = payload.get("warnings") if isinstance(payload.get("warnings"), list) else []
|
|
399
|
+
for index, warning in enumerate(warnings, 1):
|
|
400
|
+
rows.append(line(f"Warning {index}", warning))
|
|
348
401
|
if "error" in payload:
|
|
349
402
|
rows.append(line("Failure reason", payload.get("error")))
|
|
350
403
|
return "".join(rows)
|
|
@@ -362,7 +415,8 @@ def main(argv: Optional[list] = None) -> int:
|
|
|
362
415
|
result = apply_reconcile(**kw, stage=a.stage, use_ref=a.use_ref,
|
|
363
416
|
reset_anchor=a.reset_anchor)
|
|
364
417
|
out = {"applied": result["applied"],
|
|
365
|
-
"remaining_confirm": _items_as_json(result["remaining_confirm"])
|
|
418
|
+
"remaining_confirm": _items_as_json(result["remaining_confirm"]),
|
|
419
|
+
"warnings": result["warnings"]}
|
|
366
420
|
_emit_result(out, as_json=a.json, as_text=a.text)
|
|
367
421
|
return 2 if result["remaining_confirm"] else 0
|
|
368
422
|
items = classify_task(**kw)
|
|
@@ -35,7 +35,7 @@ from okstra_project.phase_pointer import ( # noqa: F401 — 의도적 재노출
|
|
|
35
35
|
promote,
|
|
36
36
|
)
|
|
37
37
|
|
|
38
|
-
# 라우팅 필드가
|
|
38
|
+
# 라우팅 필드가 자유 문자열이라 전달을 마치면 terminal 인 task-type.
|
|
39
39
|
_TERMINAL_TASK_TYPES = frozenset({"release-handoff"})
|
|
40
40
|
|
|
41
41
|
# 라우팅 필드가 없어 항상 pending 인 사이드트랙 task-type.
|
|
@@ -120,14 +120,13 @@ def project(report_data: Mapping[str, Any]) -> dict[str, str]:
|
|
|
120
120
|
|
|
121
121
|
if task_type in _TERMINAL_TASK_TYPES:
|
|
122
122
|
# release-handoff 의 라우팅 칸은 자유 문자열이다. phase 로 삼을 값은
|
|
123
|
-
# 없지만
|
|
124
|
-
#
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
)
|
|
123
|
+
# 없지만 그 문장은 태스크가 왜 끝났는지(또는 무엇이 남았는지) 말하므로
|
|
124
|
+
# 근거 자리에 싣는다.
|
|
125
|
+
handoff = _block(report_data, "releaseHandoff")
|
|
126
|
+
rationale = _text(handoff.get("routingRecommendation"))
|
|
127
|
+
if _handoff_left_stages_undelivered(handoff):
|
|
128
|
+
return make(phase="release-handoff", status=STATUS_READY, rationale=rationale)
|
|
129
|
+
return make(status=STATUS_TERMINAL, rationale=rationale)
|
|
131
130
|
if task_type in _SIDETRACK_TASK_TYPES:
|
|
132
131
|
return make(status=STATUS_PENDING)
|
|
133
132
|
|
|
@@ -148,6 +147,20 @@ def project(report_data: Mapping[str, Any]) -> dict[str, str]:
|
|
|
148
147
|
return make(status=STATUS_PENDING)
|
|
149
148
|
|
|
150
149
|
|
|
150
|
+
def _handoff_left_stages_undelivered(handoff: Mapping[str, Any]) -> bool:
|
|
151
|
+
"""skip 이거나 PR 을 건너뛴 stage 가 있으면 release-handoff 재진입이 남는다.
|
|
152
|
+
|
|
153
|
+
`prompts/lead/phase-routing.md` `## release-handoff` 의 라우팅 규칙이다.
|
|
154
|
+
"""
|
|
155
|
+
selections = handoff.get("userSelections")
|
|
156
|
+
if isinstance(selections, Mapping) and selections.get("h1") == "skip":
|
|
157
|
+
return True
|
|
158
|
+
outcomes = handoff.get("pullRequestOutcomes")
|
|
159
|
+
return isinstance(outcomes, list) and any(
|
|
160
|
+
isinstance(row, Mapping) and row.get("kind") == "skipped" for row in outcomes
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
151
164
|
def _block(report_data: Mapping[str, Any], key: str) -> Mapping[str, Any]:
|
|
152
165
|
block = report_data.get(key)
|
|
153
166
|
return block if isinstance(block, Mapping) else {}
|
|
@@ -25,7 +25,7 @@ from okstra_ctl.plan_run_root import plan_run_root_from_approved_plan
|
|
|
25
25
|
from okstra_ctl.prepare_error import PrepareError
|
|
26
26
|
from okstra_ctl.stage_integrate import IntegrateResult
|
|
27
27
|
from okstra_ctl.stage_map import active_stage_records
|
|
28
|
-
from okstra_ctl.stage_reconcile import
|
|
28
|
+
from okstra_ctl.stage_reconcile import require_reconciled_stages
|
|
29
29
|
from okstra_ctl.stage_targets import (
|
|
30
30
|
FinalVerificationTarget,
|
|
31
31
|
StageTargetError,
|
|
@@ -106,7 +106,8 @@ def _read_final_verification_done_rows(
|
|
|
106
106
|
plan_run_root = plan_run_root_from_approved_plan(request.approved_plan_path)
|
|
107
107
|
backfill_done_from_carry(plan_run_root)
|
|
108
108
|
project_id, task_group, task_id = registry_coordinates
|
|
109
|
-
|
|
109
|
+
# 복원·재배치·통합보다 먼저 거부해야 거부된 prepare 가 git 을 건드리지 않는다.
|
|
110
|
+
require_reconciled_stages(
|
|
110
111
|
replace(
|
|
111
112
|
request,
|
|
112
113
|
project_id=project_id,
|
|
@@ -114,6 +115,7 @@ def _read_final_verification_done_rows(
|
|
|
114
115
|
task_id=task_id,
|
|
115
116
|
),
|
|
116
117
|
plan_run_root,
|
|
118
|
+
stage=request.stage,
|
|
117
119
|
)
|
|
118
120
|
from okstra_ctl.stage_rebase import synchronize_stage_stack
|
|
119
121
|
from okstra_ctl.stage_targets import StageWorktreeIdentity
|
|
@@ -17,7 +17,7 @@ from typing import Any
|
|
|
17
17
|
from okstra_ctl import stage_targets
|
|
18
18
|
from okstra_ctl.design_prep import DesignPrepDecision, DesignPrepError, resolve_design_prep
|
|
19
19
|
from okstra_ctl.final_report_paths import final_report_data_path
|
|
20
|
-
from okstra_ctl.stage_reconcile import
|
|
20
|
+
from okstra_ctl.stage_reconcile import StaleStageRecordsError, require_reconciled_stages
|
|
21
21
|
from okstra_ctl.worker_prompt_policy import IMPLEMENTATION_STAGE_HEADER
|
|
22
22
|
|
|
23
23
|
|
|
@@ -94,7 +94,11 @@ def claim_implementation_stage_run(
|
|
|
94
94
|
|
|
95
95
|
plan_run_root = Path(inp.approved_plan_path).resolve().parents[1]
|
|
96
96
|
backfill_done_from_carry(plan_run_root)
|
|
97
|
-
|
|
97
|
+
# stage 재배치·base 계산보다 먼저 거부해야 거부된 prepare 가 git 을 건드리지 않는다.
|
|
98
|
+
try:
|
|
99
|
+
require_reconciled_stages(inp, plan_run_root)
|
|
100
|
+
except StaleStageRecordsError as exc:
|
|
101
|
+
raise ImplementationStageError(str(exc)) from exc
|
|
98
102
|
from okstra_ctl.stage_rebase import synchronize_stage_stack
|
|
99
103
|
|
|
100
104
|
try:
|
|
@@ -47,6 +47,7 @@ If `render-bundle` fails with a `PrepareError` containing `Recorded stage SHAs n
|
|
|
47
47
|
- **Enter a different ref directly** — the user names a commit/branch/tag.
|
|
48
48
|
- **Abort** — stop the run without recovering.
|
|
49
49
|
3. Run `okstra git-reconcile … --apply --stage <N> --use-ref <ref>` with the chosen ref, then retry the failed `render-bundle` with the same arguments.
|
|
50
|
+
If that output carries a `Warning` naming the implementation anchor, the chosen head no longer contains the anchor: show the warning to the user and run its printed `--reset-anchor` command only after they confirm the new base, before the retry.
|
|
50
51
|
|
|
51
52
|
If the anchor (`implementation_base_commit`) is reported unresolvable, run the same command's `--reset-anchor <ref>` after user confirmation. Correcting a confirm item without the picker is forbidden — the runtime also rejects a confirm correction without `--use-ref`.
|
|
52
53
|
|
package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md
CHANGED
|
@@ -88,10 +88,10 @@ Changing a test's expected value is allowed here when the finding shows the orac
|
|
|
88
88
|
2. Write the executor instruction with `okstra correction prompt --run-manifest <path> --round <N> --role executor --base <base> --stage-base <stage base> --finding <F-id> [--finding <F-id> ...]` into a file under the run's `state/`, then materialize it with `okstra agent-prompt materialize` — `--audience implementation-executor`, `--assignment-ref initial/<executor worker id>`, `--dispatch-kind correction-r<N>`, a new `--invocation-id` ending `-correction-r<N>-<executor worker id>`, and a result path `<executor worker id>-worker-correction-r<N>-implementation-<seq>.md`. Build a jobs file with `okstra agent-prompt jobs --dispatch-kind correction-r<N>`, dispatch it alone with `okstra team dispatch --jobs-file <path> --dispatch-kind correction-r<N>`, and settle it with `okstra team await`.
|
|
89
89
|
`--stage-base` (the carry's `stageCommitRange.base`) adds a `FILE-SIZE:<path>` finding for each file the stage diff leaves over the 400-line limit, computed by `okstra correction file-size`; a `FILE-SIZE-ADVISORY:` line (the 300-line target) never opens or joins a round; a round may hold only those findings, and `findingIds` records them by that id. Leave out a convergence finding id that restates a `FILE-SIZE:` line, or the executor reads it twice.
|
|
90
90
|
3. Do the same for every verifier with `--role verifier`, `--audience implementation-verifier`, its own `initial/<verifier worker id>` assignment, and result `<verifier worker id>-worker-correction-r<N>-implementation-<seq>.md`; dispatch the verifiers in one batch without the executor.
|
|
91
|
-
4. Converge the re-verification results
|
|
91
|
+
4. Converge the re-verification results in round `<N>`'s own namespace, leaving the initial convergence as written. Run Phase 5.5 again with `correction-r<N>` before `<task-type>` in every state name: `okstra convergence prepare-groups --correction-round <N>` writes `convergence-groups-correction-r<N>-<task-type>-<seq>.json`; seed it with `--work-state …/convergence-work-correction-r<N>-<task-type>-<seq>.json` and `--final-state …/convergence-correction-r<N>-<task-type>-<seq>.json`; name round plans and results `convergence-round-<k>-plan-correction-r<N>-<task-type>-<seq>.json`. A reverify round in it passes `--correction-round <N>` to `reverify-prompt` and `collect-results`, materializes with `--invocation-id` ending `-correction-r<N>-reverify-r<k>-<worker id>`, and names its result `<worker id>-worker-correction-r<N>-reverify-r<k>-<task-type>-<seq>.md`. Never reseed the initial paths: a terminal final there is reused, and `--restart-from-round0` refuses it. Round `<N+1>`'s `okstra correction prompt` reads its findings from this round's final, and the report writer's synthesis packet carries it as `Convergence state (correction-r<N>)` beside the initial `Convergence state` (`report_synthesis_packet._source_specs`); report assembly projects it into `crossVerification.correctionRounds[]` and appends the evidence it promotes to `evidence.primary` with source `convergence correction-r<N>` (`report_assembly._correction_round_convergence`).
|
|
92
92
|
5. No re-verification verdict is `FAIL`: the round `result` is `pass` and the run continues to persistence with the post-fix head. Otherwise `result: fail`; start round `<N+1>` if the cap allows and the entry rules still hold, or end the run `FAIL`.
|
|
93
93
|
|
|
94
|
-
**Enforced:** `scripts/okstra_ctl/dispatch_core.py` `_validate_correction_rounds` (via `correction_round.validate_correction_dispatch`) refuses a round past the cap, a verifier dispatch before that round's executor returned an `ok` result, a second executor fix in the same round, and round `<N+1>` before round `<N>` was re-verified. `validators/validate-run.py` `_validate_correction_rounds` (blocking) fails a report whose `correctionRounds` differ from the correction rounds the run dispatched, whose rounds are not 1, 2 in order, whose `result` contradicts its verdicts, or whose `verifierResults` are not the last round's verdicts. The entry rules are instructions only. `okstra stage-done` (`scripts/okstra_ctl/stage_done.py` `_check_file_size`) refuses to settle a stage whose carry range breaks the 400-line file-size limit and names the `--stage-base` to pass, so a size violation no verifier reported still reaches a correction round, or ends the run `FAIL` when no round is left.
|
|
94
|
+
**Enforced:** `okstra convergence seed` (`scripts/okstra_ctl/convergence.py` `_seed`) accepts state paths only in the initial or a `correction-r<N>` namespace, refuses a round grouping before every grouped verifier returned an `ok` round `<N>` result (`convergence_provenance.missing_scope_results`), and checks its cited items against that round's results (`discovery_attempts` per namespace); `convergence_migration.decide_seed_action` refuses `--restart-from-round0` without an invalid working state; `plan-round` refuses to overwrite a round plan whose namespace already dispatched that round (`_refuse_dispatched_plan_overwrite`); `collect-results` reads only the namespace's reverify invocations (`correction_round.reverify_invocation_in_scope`). `scripts/okstra_ctl/dispatch_core.py` `_validate_correction_rounds` (via `correction_round.validate_correction_dispatch`) refuses a round past the cap, a verifier dispatch before that round's executor returned an `ok` result, a second executor fix in the same round, and round `<N+1>` before round `<N>` was re-verified. `validators/validate-run.py` `_validate_correction_rounds` (blocking) fails a report whose `correctionRounds` differ from the correction rounds the run dispatched, whose rounds are not 1, 2 in order, whose `result` contradicts its verdicts, or whose `verifierResults` are not the last round's verdicts. The entry rules are instructions only. `okstra stage-done` (`scripts/okstra_ctl/stage_done.py` `_check_file_size`) refuses to settle a stage whose carry range breaks the 400-line file-size limit and names the `--stage-base` to pass, so a size violation no verifier reported still reaches a correction round, or ends the run `FAIL` when no round is left.
|
|
95
95
|
|
|
96
96
|
## Self-review pass before finalising the report (the Okstra lead runs this; do not delegate it)
|
|
97
97
|
|