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.
Files changed (48) hide show
  1. package/docs/architecture/storage-model.md +1 -1
  2. package/docs/architecture.md +5 -5
  3. package/docs/cli.md +7 -7
  4. package/docs/project-structure-overview.md +4 -0
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/prompts/lead/convergence.md +3 -1
  8. package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
  9. package/runtime/prompts/lead/phase-routing.md +1 -1
  10. package/runtime/python/okstra_ctl/analysis_packet.py +8 -6
  11. package/runtime/python/okstra_ctl/business_flow/engine.py +1 -0
  12. package/runtime/python/okstra_ctl/convergence.py +155 -19
  13. package/runtime/python/okstra_ctl/convergence_critic_prompt.py +3 -2
  14. package/runtime/python/okstra_ctl/convergence_migration.py +10 -1
  15. package/runtime/python/okstra_ctl/convergence_provenance.py +69 -24
  16. package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +4 -1
  17. package/runtime/python/okstra_ctl/correction_cli.py +7 -7
  18. package/runtime/python/okstra_ctl/correction_round.py +40 -0
  19. package/runtime/python/okstra_ctl/fix_cycles.py +14 -1
  20. package/runtime/python/okstra_ctl/git_reconcile.py +62 -8
  21. package/runtime/python/okstra_ctl/next_phase.py +22 -9
  22. package/runtime/python/okstra_ctl/phases/final_verification/target.py +4 -2
  23. package/runtime/python/okstra_ctl/phases/implementation/entry.py +6 -2
  24. package/runtime/python/okstra_ctl/phases/implementation/host-rules.md +1 -0
  25. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +2 -2
  26. package/runtime/python/okstra_ctl/phases/release_handoff/entry.py +42 -1
  27. package/runtime/python/okstra_ctl/phases/release_handoff/operations.py +52 -0
  28. package/runtime/python/okstra_ctl/phases/release_handoff/profile.md +3 -2
  29. package/runtime/python/okstra_ctl/phases/release_handoff/report_assets/release-handoff-input.template.md +3 -1
  30. package/runtime/python/okstra_ctl/phases/release_handoff/spec.md +1 -1
  31. package/runtime/python/okstra_ctl/process_group.py +4 -0
  32. package/runtime/python/okstra_ctl/report_assembly.py +50 -13
  33. package/runtime/python/okstra_ctl/report_html/common.py +3 -1
  34. package/runtime/python/okstra_ctl/report_projections.py +46 -4
  35. package/runtime/python/okstra_ctl/report_synthesis_packet.py +22 -1
  36. package/runtime/python/okstra_ctl/run.py +8 -5
  37. package/runtime/python/okstra_ctl/stage_reconcile.py +49 -1
  38. package/runtime/python/okstra_ctl/wizard/steps_plan.py +3 -3
  39. package/runtime/python/okstra_ctl/worktree/decisions.py +42 -5
  40. package/runtime/python/okstra_ctl/worktree/provision.py +49 -2
  41. package/runtime/schemas/final-report-v2.0.schema.json +7 -2
  42. package/runtime/schemas/final-report-v3.0.schema.json +43 -2
  43. package/runtime/skills/okstra-run/SKILL.md +1 -0
  44. package/runtime/templates/reports/html/base.template.html +6 -1
  45. package/runtime/templates/reports/html/i18n/en.json +1 -0
  46. package/runtime/templates/reports/html/i18n/ko.json +1 -0
  47. package/runtime/validators/validate-run.py +10 -6
  48. 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<suffix>[a-z][a-z-]*?-\d{3})\.json$"
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(state_dir: Path, suffix: str, document: dict) -> bool:
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(manifest: ExecutionManifest) -> dict[str, list[Attempt]]:
105
- """교정 실행과 수정 라운드 재검증은 발견 결과를 대체하지만 재검증 투표는 발견 출처가 아니다.
111
+ def discovery_attempts(
112
+ manifest: ExecutionManifest, scope: str | None = None,
113
+ ) -> dict[str, list[Attempt]]:
114
+ """한 수렴 범위의 발견 결과. 교정 실행은 초기 결과를 대체하지만 재검증 투표는 발견 출처가 아니다.
106
115
 
107
- 수정 라운드(`correction-r<N>`) 뒤 수렴을 다시 시작하면 마지막 라운드의 verifier 결과가
108
- 출처여야 한다. 빠지면 고치기 전 FAIL 결과로 다시 수렴한다.
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
- if (invocation.dispatch_kind not in {"initial", "correction"}
118
- and correction_round(invocation.dispatch_kind) is None):
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-{suffix}.md":
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, suffix
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-{suffix}.md')).name}` — a "
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(document: object, execution_manifest: object) -> dict[str, str]:
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(document: object, execution_manifest: object) -> list[str]:
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(groups, project_root=project_root, run_dir=run_dir)
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 canonical_run_state_artifact, validated_run_authority
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 run's convergence record; --stage-base adds the file-size
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
- convergence_path = canonical_run_state_artifact(
76
- authority, manifest_field="convergenceStatePath", prefix="convergence",
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 guidance(*, plan_run_root: Path, project_id: str, task_group: str,
205
+ def _command(*, plan_run_root: Path, project_id: str, task_group: str,
199
206
  task_id: str, work_category: str) -> str:
200
- """PrepareError 에 첨부하는 회복 안내 (명령 예시 포함)."""
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
- applied.append(_apply_user_ref(
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
- # 라우팅 필드가 없어 항상 terminal 인 task-type.
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
- return make(
126
- status=STATUS_TERMINAL,
127
- rationale=_text(
128
- _block(report_data, "releaseHandoff").get("routingRecommendation")
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 auto_reconcile_best_effort
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
- auto_reconcile_best_effort(
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 auto_reconcile_best_effort
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
- auto_reconcile_best_effort(inp, plan_run_root)
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
 
@@ -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 the way the initial round was converged, restarting Phase 5.5 with `okstra convergence seed ... --restart-from-round0`.
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