okstra 0.221.0 → 0.222.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 (39) hide show
  1. package/docs/architecture/storage-model.md +1 -1
  2. package/docs/architecture.md +4 -4
  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/business_flow/engine.py +1 -0
  11. package/runtime/python/okstra_ctl/convergence.py +155 -19
  12. package/runtime/python/okstra_ctl/convergence_critic_prompt.py +3 -2
  13. package/runtime/python/okstra_ctl/convergence_migration.py +10 -1
  14. package/runtime/python/okstra_ctl/convergence_provenance.py +69 -24
  15. package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +4 -1
  16. package/runtime/python/okstra_ctl/correction_cli.py +7 -7
  17. package/runtime/python/okstra_ctl/correction_round.py +40 -0
  18. package/runtime/python/okstra_ctl/fix_cycles.py +14 -1
  19. package/runtime/python/okstra_ctl/git_reconcile.py +62 -8
  20. package/runtime/python/okstra_ctl/next_phase.py +22 -9
  21. package/runtime/python/okstra_ctl/phases/final_verification/target.py +4 -2
  22. package/runtime/python/okstra_ctl/phases/implementation/entry.py +6 -2
  23. package/runtime/python/okstra_ctl/phases/implementation/host-rules.md +1 -0
  24. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +2 -2
  25. package/runtime/python/okstra_ctl/process_group.py +4 -0
  26. package/runtime/python/okstra_ctl/report_assembly.py +50 -13
  27. package/runtime/python/okstra_ctl/report_html/common.py +3 -1
  28. package/runtime/python/okstra_ctl/report_projections.py +45 -4
  29. package/runtime/python/okstra_ctl/report_synthesis_packet.py +22 -1
  30. package/runtime/python/okstra_ctl/run.py +7 -5
  31. package/runtime/python/okstra_ctl/stage_reconcile.py +49 -1
  32. package/runtime/python/okstra_ctl/wizard/steps_plan.py +3 -3
  33. package/runtime/schemas/final-report-v3.0.schema.json +36 -0
  34. package/runtime/skills/okstra-run/SKILL.md +1 -0
  35. package/runtime/templates/reports/html/base.template.html +6 -1
  36. package/runtime/templates/reports/html/i18n/en.json +1 -0
  37. package/runtime/templates/reports/html/i18n/ko.json +1 -0
  38. package/runtime/validators/validate-run.py +5 -2
  39. package/runtime/validators/validate_session_conformance.py +19 -1
@@ -18,6 +18,7 @@ from .clarification_items import (
18
18
  )
19
19
  from .final_report_schema import load_schema_version, validate
20
20
  from .report_inputs import ReportInputPath, report_input_paths, uses_report_contract_v3
21
+ from .correction_round import correction_convergence_states, correction_round
21
22
  from .json_boundary import JsonBoundaryError, load_owned_object, serialize_owned_object
22
23
  from .report_narrative import parse_narrative, task_narrative_errors
23
24
  from .report_synthesis_packet import (
@@ -98,6 +99,32 @@ def _read_json(row: ReportInputPath) -> dict[str, Any]:
98
99
  return value
99
100
 
100
101
 
102
+ def _correction_round_convergence(
103
+ initial: ReportInputPath, cross: Mapping[str, Any],
104
+ ) -> tuple[list[dict[str, Any]], list[tuple[str, Mapping[str, Any]]]]:
105
+ """Each correction round's re-convergence, projected like the initial one, and the
106
+ evidence it promotes labelled `convergence <scope>`."""
107
+ rows: list[dict[str, Any]] = []
108
+ promoted: list[tuple[str, Mapping[str, Any]]] = []
109
+ consensus = len(cross["consensus"])
110
+ differences = len(cross["differences"])
111
+ for scope, path in correction_convergence_states(initial.path):
112
+ row = ReportInputPath(initial.key, initial.owner, path)
113
+ try:
114
+ result = project_convergence(
115
+ _read_json(row),
116
+ first_consensus=consensus + 1, first_difference=differences + 1,
117
+ )
118
+ except ReportProjectionError as exc:
119
+ _fail(row.owner, path, "$", exc)
120
+ projected = result["crossVerification"]
121
+ consensus += len(projected["consensus"])
122
+ differences += len(projected["differences"])
123
+ rows.append({"round": correction_round(scope), **projected})
124
+ promoted.extend((f"convergence {scope}", item) for item in result["promotedEvidence"])
125
+ return rows, promoted
126
+
127
+
101
128
  def _input_map(project_root: Path, manifest: Mapping[str, Any]) -> dict[str, ReportInputPath]:
102
129
  try:
103
130
  return {row.key: row for row in report_input_paths(project_root, manifest)}
@@ -488,6 +515,11 @@ def _compose(
488
515
  data = {"schemaVersion": "3.0", **narrative, **execution}
489
516
  data["tokenUsage"] = usage
490
517
  data["crossVerification"] = convergence_data["crossVerification"]
518
+ rounds, round_evidence = _correction_round_convergence(
519
+ inputs["convergence"], data["crossVerification"]
520
+ )
521
+ if rounds:
522
+ data["crossVerification"]["correctionRounds"] = rounds
491
523
  data["clarificationItems"] = _clarifications(ledger, activities, inputs["approval-decisions"].path)
492
524
  # 작성자의 사실 분류와 리드 원장의 질문 연결은 양쪽 입력을 합친 뒤 검사한다.
493
525
  for reason in validate_blocked_answer_channel(data):
@@ -504,8 +536,13 @@ def _compose(
504
536
  if scope:
505
537
  data["verificationScope"] = scope
506
538
  data["agentActivity"] = activities
507
- data["evidence"] = {"primary": _promoted_evidence(convergence_data)}
508
- data["missingInformation"] = _census_missing_information(project_root, manifest)
539
+ data["evidence"] = {"primary": _promoted_evidence([
540
+ *(("convergence", item) for item in convergence_data["promotedEvidence"]),
541
+ *round_evidence,
542
+ ])}
543
+ data["missingInformation"] = _missing_information(
544
+ project_root, manifest, convergence_data["criticUnverified"],
545
+ )
509
546
  # 스냅샷 복사가 _attach_metadata 보다 먼저다 — frontmatter 가
510
547
  # selectedDirectionRef.snapshotPath 를 읽는다.
511
548
  _apply_selected_direction_snapshot(data, project_root, manifest)
@@ -529,16 +566,16 @@ def _attach_discussion(data: dict[str, Any], project_root: Path, manifest: Mappi
529
566
  _fail("discussion-record", store.records_path, "discussion", exc)
530
567
 
531
568
 
532
- def _census_missing_information(
533
- project_root: Path, manifest: Mapping[str, Any],
569
+ def _missing_information(
570
+ project_root: Path, manifest: Mapping[str, Any], critic_unverified: list[dict[str, Any]],
534
571
  ) -> list[dict[str, Any]]:
535
- """Coverage-census warnings are the only rows this field carries under the
536
- v3 contract, so they number from `R-001`."""
537
- state = read_census_state(project_root, manifest)
538
- if state is None:
539
- return []
572
+ """Coverage-census warnings and unjudged critic gaps are the only rows this
573
+ field carries under the v3 contract, so they number from `R-001`."""
540
574
  _project_id, _task_group, task_id = _identity(manifest)
541
- rows = census_warning_rows(state, ticket_id=task_id or "unknown")
575
+ ticket_id = task_id or "unknown"
576
+ state = read_census_state(project_root, manifest)
577
+ rows = [] if state is None else census_warning_rows(state, ticket_id=ticket_id)
578
+ rows += [{**row, "ticketId": row["ticketId"] or ticket_id} for row in critic_unverified]
542
579
  return [{"id": f"R-{index:03d}", **row} for index, row in enumerate(rows, start=1)]
543
580
 
544
581
 
@@ -717,16 +754,16 @@ def _attach_metadata(data: dict[str, Any], manifest: Mapping[str, Any]) -> None:
717
754
  }
718
755
 
719
756
 
720
- def _promoted_evidence(convergence: Mapping[str, Any]) -> list[dict[str, Any]]:
757
+ def _promoted_evidence(rows: list[tuple[str, Mapping[str, Any]]]) -> list[dict[str, Any]]:
721
758
  return [
722
759
  {
723
760
  "id": f"E-{index:03d}",
724
761
  "ticketId": row["ticketId"],
725
762
  "evidence": row["evidence"],
726
763
  "sourceItems": row["sourceItems"],
727
- "source": "convergence",
764
+ "source": source,
728
765
  }
729
- for index, row in enumerate(convergence.get("promotedEvidence") or [], start=1)
766
+ for index, (source, row) in enumerate(rows, start=1)
730
767
  ]
731
768
 
732
769
 
@@ -403,8 +403,10 @@ def anchor_index(
403
403
  for row_id in sorted(found)
404
404
  if isinstance(row_id, str) and _anchorable(row_id)
405
405
  }
406
+ cross = data.get("crossVerification") if isinstance(data.get("crossVerification"), dict) else {}
407
+ rounds = [cross, *(r for r in cross.get("correctionRounds") or [] if isinstance(r, dict))]
406
408
  for block in ("consensus", "differences"):
407
- for row in _dig(data, ("crossVerification", block)):
409
+ for row in (row for scope in rounds for row in scope.get(block) or []):
408
410
  row_id = row.get("id") if isinstance(row, dict) else None
409
411
  if isinstance(row_id, str) and _anchorable(row_id) and row_id not in index:
410
412
  index[row_id] = f"id-xv-{row_id}"
@@ -261,8 +261,14 @@ def _source_items(finding: Mapping[str, Any]) -> list[str]:
261
261
  return [f"{_text(worker, 'worker').removesuffix('-worker')}:{finding_id}" for worker in workers]
262
262
 
263
263
 
264
- def project_convergence(state: Mapping[str, Any]) -> dict[str, Any]:
265
- """수렴 상태에서 독자용 합의·이견과 공개 근거만 승격한다."""
264
+ def project_convergence(
265
+ state: Mapping[str, Any], *, first_consensus: int = 1, first_difference: int = 1,
266
+ ) -> dict[str, Any]:
267
+ """수렴 상태에서 독자용 합의·이견과 공개 근거만 승격한다.
268
+
269
+ 정정 라운드 재수렴은 초기 행 다음 번호부터 매긴다 — 행 id 가 리포트 앵커라 한 리포트
270
+ 안에서 겹치면 안 된다.
271
+ """
266
272
  consensus = []
267
273
  differences = []
268
274
  promoted = []
@@ -274,7 +280,7 @@ def project_convergence(state: Mapping[str, Any]) -> dict[str, Any]:
274
280
  ticket_id = _text(ticket_ids[0] if ticket_ids else "unknown", "unknown")
275
281
  if finding.get("classification") in {"full-consensus", "partial-consensus"}:
276
282
  row = {
277
- "id": f"CV-{len(consensus) + 1:03d}",
283
+ "id": f"CV-{len(consensus) + first_consensus:03d}",
278
284
  "ticketId": ticket_id,
279
285
  "statement": _text(finding.get("summary"), "Converged finding"),
280
286
  "sourceItems": _source_items(finding),
@@ -298,7 +304,7 @@ def project_convergence(state: Mapping[str, Any]) -> dict[str, Any]:
298
304
  "position": "origin" if worker == finding.get("originWorker") else "dissent",
299
305
  })
300
306
  differences.append({
301
- "id": f"D-{len(differences) + 1:03d}",
307
+ "id": f"D-{len(differences) + first_difference:03d}",
302
308
  "ticketId": ticket_id,
303
309
  "disagreement": _text(finding.get("summary"), "Unresolved difference"),
304
310
  "workersPosition": positions or [{"worker": "unknown", "itemId": "", "position": "unresolved"}],
@@ -311,5 +317,40 @@ def project_convergence(state: Mapping[str, Any]) -> dict[str, Any]:
311
317
  "differences": differences,
312
318
  },
313
319
  "promotedEvidence": promoted,
320
+ "criticUnverified": _critic_unverified(state),
314
321
  }
322
+ critic = (state.get("config") or {}).get("critic")
323
+ if isinstance(critic, Mapping):
324
+ result["crossVerification"]["criticGaps"] = {
325
+ "proposed": critic.get("gapsProposed", 0),
326
+ "merged": critic.get("gapsMerged", 0),
327
+ "unverified": critic.get("gapsUnverified", 0),
328
+ }
315
329
  return result
330
+
331
+
332
+ def _critic_unverified(state: Mapping[str, Any]) -> list[dict[str, Any]]:
333
+ """미판정 gap 하나당 Missing Information 행 하나 — id 와 기본 ticket 은 조립이 채운다."""
334
+ ledger = (state.get("criticVerification") or {}).get("gaps") or []
335
+ tickets = {
336
+ gap.get("gapId"): (gap.get("ticketIds") or [None])[0]
337
+ for gap in ledger
338
+ if isinstance(gap, Mapping)
339
+ }
340
+ rows = []
341
+ for gap in state.get("unverifiedGaps") or []:
342
+ if not isinstance(gap, Mapping):
343
+ continue
344
+ gap_id = _text(gap.get("gapId"), "unknown")
345
+ rows.append({
346
+ "ticketId": tickets.get(gap_id),
347
+ "item": f"Coverage critic gap `{gap_id}` was never verified: "
348
+ f"{_text(gap.get('summary'), 'no summary')}",
349
+ "risk": (
350
+ f"Verification did not complete ({_text(gap.get('reason'), 'unknown reason')}), "
351
+ "so no analyser has judged this gap; it is neither a finding nor a rejection."
352
+ ),
353
+ "owner": "Okstra lead",
354
+ "source": "critic-unverified",
355
+ })
356
+ return rows
@@ -14,6 +14,7 @@ from typing import Any, Mapping
14
14
  from okstra_token_usage.blocks import accounting_workers
15
15
 
16
16
  from .analysis_packet import reference_source_extracts
17
+ from .correction_round import correction_convergence_states
17
18
  from .json_boundary import JsonBoundaryError, load_owned_object, write_owned_object_atomic
18
19
  from .phases.implementation_option_selection.authoring import (
19
20
  requirement_instructions as selection_requirement_instructions,
@@ -251,6 +252,17 @@ class ReportSynthesisPacket:
251
252
  ]
252
253
  if self.task_type == "implementation-planning":
253
254
  return self._planning_requirement_instructions()
255
+ if self.task_type == "implementation" and any(
256
+ source.label.startswith(f"{CONVERGENCE_STATE_LABEL} (correction-r")
257
+ for source in self.sources
258
+ ):
259
+ return [
260
+ "Each `Convergence state (correction-r<N>)` source is correction round N's "
261
+ "re-convergence of the verifiers' re-verification; the plain `Convergence "
262
+ "state` is the initial round's. Describe the stage's open findings from the "
263
+ "last round's re-convergence, not the initial one; report what each round "
264
+ "fixed through `implementation.correctionRounds`.",
265
+ ]
254
266
  if self.task_type != "implementation-option-selection":
255
267
  return []
256
268
  return selection_requirement_instructions(
@@ -371,6 +383,7 @@ _SOURCE_FIELDS = (
371
383
  ("Final report schema", "orchestrator", "instructionSet", "finalReportSchemaPath", True),
372
384
  ("Convergence state", "convergence", "run", "convergenceStatePath", True),
373
385
  )
386
+ CONVERGENCE_STATE_LABEL = "Convergence state"
374
387
 
375
388
 
376
389
  _READ_CHUNK_BYTES = 16_000
@@ -514,7 +527,7 @@ def _source_markdown(
514
527
  brief = next((item.content.strip() for item in sources if item.label == "Task brief"), "")
515
528
  if len(brief.encode("utf-8")) >= _SHARED_TEXT_MIN_BYTES:
516
529
  content = content.replace(brief, 'Read the complete "Source: Task brief" above.')
517
- if source.label == "Convergence state":
530
+ if source.label.startswith(CONVERGENCE_STATE_LABEL):
518
531
  content = _convergence_view(content)
519
532
  if content != source.content.rstrip("\n"):
520
533
  view = [
@@ -815,6 +828,14 @@ def _source_specs(
815
828
  path = str(Path(instruction_set) / "final-report-schema.json")
816
829
  if path or required:
817
830
  specs.append((label, owner, path, required))
831
+ if label == CONVERGENCE_STATE_LABEL and path:
832
+ specs.extend(
833
+ (f"{CONVERGENCE_STATE_LABEL} ({scope})", owner,
834
+ _relative(project_root, round_final), True)
835
+ for scope, round_final in correction_convergence_states(
836
+ _resolve(project_root, path)
837
+ )
838
+ )
818
839
  if carried_source:
819
840
  specs.append((CARRIED_STAGES_LABEL, "orchestrator", carried_source, True))
820
841
  specs.extend(_user_response_specs(project_root, narrative_path))
@@ -3370,10 +3370,11 @@ def _maybe_open_fix_cycle(inp: PrepareInputs, task_root: Path, existing: dict,
3370
3370
  f"{', '.join(fix_cycles.FIX_CYCLE_ENTRY_PHASES)})에서만 허용됩니다: "
3371
3371
  f"{inp.task_type}")
3372
3372
  workflow = existing.get("workflow") or {}
3373
- if workflow.get("lastCompletedPhase") != "release-handoff":
3373
+ if not fix_cycles.delivery_completed(workflow):
3374
3374
  raise PrepareError(
3375
- "--fix-cycle yes 는 release-handoff 까지 완료된 task 에만 허용됩니다 "
3376
- "(workflow.lastCompletedPhase 확인)")
3375
+ "--fix-cycle yes 는 release-handoff 로 전달까지 끝난 task 에만 허용됩니다 "
3376
+ "(workflow.lastCompletedPhase 가 release-handoff 이고, skip·미전달 stage 로 "
3377
+ "다음 phase 가 release-handoff 로 남아 있지 않아야 함)")
3377
3378
  brief_text = Path(inp.brief_path).read_text(encoding="utf-8")
3378
3379
  fix_cycles.append_opened(
3379
3380
  task_root, target_report=str(existing.get("latestReportRecordPath", "")),
@@ -3385,7 +3386,8 @@ def _record_fix_cycle_events(inp: PrepareInputs, ctx: dict) -> str:
3385
3386
  """fix-cycles.jsonl 의 lazy-close → opened → run 기록. cycle id 반환.
3386
3387
 
3387
3388
  - lazy-close: open cycle 에 release-handoff run 이 부착됐고 디스크 manifest 의
3388
- lastCompletedPhase 가 release-handoff 면 닫는다. manifest 는 prepare 가
3389
+ lastCompletedPhase 가 release-handoff 면 닫는다. 다음 phase 포인터가 다시
3390
+ release-handoff 를 가리키면(skip·미전달 stage) 닫지 않는다. manifest 는 prepare 가
3389
3391
  이번 run 으로 재작성하기 *전* 값이어야 하므로 finalize 직전에 호출한다.
3390
3392
  - opened: --fix-cycle yes 일 때만. 완료 task + entry phase 미충족이면
3391
3393
  PrepareError.
@@ -3398,7 +3400,7 @@ def _record_fix_cycle_events(inp: PrepareInputs, ctx: dict) -> str:
3398
3400
 
3399
3401
  open_c = fix_cycles.open_cycle(fix_cycles.read_rows(task_root))
3400
3402
 
3401
- if open_c and workflow.get("lastCompletedPhase") == "release-handoff":
3403
+ if open_c and fix_cycles.delivery_completed(workflow):
3402
3404
  rows = fix_cycles.read_rows(task_root)
3403
3405
  handoff_attached = any(
3404
3406
  r.get("event") == "run" and r.get("cycle") == open_c["cycle"]
@@ -1,4 +1,4 @@
1
- """Best-effort git reconciliation shared by stage prepare flows."""
1
+ """Git reconciliation shared by stage prepare flows."""
2
2
  from __future__ import annotations
3
3
 
4
4
  import sys
@@ -31,3 +31,51 @@ def auto_reconcile_best_effort(inp: Any, plan_run_root: Path) -> None:
31
31
  )
32
32
  except Exception as exc:
33
33
  print(f"git-reconcile skipped: {exc}", file=sys.stderr)
34
+
35
+
36
+ class StaleStageRecordsError(RuntimeError):
37
+ """A recorded stage SHA no longer matches git and only the user can say which commit counts."""
38
+
39
+
40
+ def require_reconciled_stages(
41
+ inp: Any, plan_run_root: Path, *, stage: int | None = None,
42
+ ) -> None:
43
+ """Apply auto items, then refuse while a confirm item remains.
44
+
45
+ A prepare that restores, rebases or merges stage branches calls this first:
46
+ building on a recorded commit the branch no longer carries integrates work
47
+ nobody verified. ``stage`` narrows the check to that stage's done row.
48
+ """
49
+ from .git_reconcile import _git, classify_task, guidance
50
+
51
+ auto_reconcile_best_effort(inp, plan_run_root)
52
+ # A project prepared without git (worktree status skipped-not-git) has no
53
+ # history the recorded SHAs could disagree with.
54
+ if _git(Path(inp.project_root), "rev-parse", "--git-dir").returncode != 0:
55
+ return
56
+ coordinates = dict(
57
+ project_id=inp.project_id,
58
+ task_group=inp.task_group,
59
+ task_id=inp.task_id,
60
+ work_category=inp.work_category,
61
+ )
62
+ pending = [
63
+ item
64
+ for item in classify_task(
65
+ project_root=Path(inp.project_root),
66
+ plan_run_root=plan_run_root,
67
+ **coordinates,
68
+ )
69
+ if item.classification == "confirm"
70
+ and (stage is None or item.stage == stage)
71
+ ]
72
+ if not pending:
73
+ return
74
+ lines = "\n".join(
75
+ f" {f'stage {item.stage}' if item.kind == 'done' else 'anchor'}: "
76
+ f"{item.reason} (recorded {item.recorded})"
77
+ for item in pending
78
+ )
79
+ raise StaleStageRecordsError(
80
+ f"{lines}\n{guidance(plan_run_root=plan_run_root, **coordinates)}"
81
+ )
@@ -215,14 +215,14 @@ def _stage_plan_for_confirmation(
215
215
 
216
216
 
217
217
  def _fix_cycle_confirm_required(state: WizardState) -> bool:
218
- """완료(release-handoff) task 에 entry phase 로 재진입하고, 아직 열린 fix
219
- cycle 이 없을 때만 묻는다."""
218
+ """전달까지 끝난(release-handoff) task 에 entry phase 로 재진입하고, 아직
219
+ 열린 fix cycle 이 없을 때만 묻는다."""
220
220
  if state.task_type not in fix_cycles.FIX_CYCLE_ENTRY_PHASES:
221
221
  return False
222
222
  task_root = task_dir(Path(state.project_root),
223
223
  state.task_group, state.task_id)
224
224
  workflow = (read_task_manifest(task_root) or {}).get("workflow") or {}
225
- if workflow.get("lastCompletedPhase") != "release-handoff":
225
+ if not fix_cycles.delivery_completed(workflow):
226
226
  return False
227
227
  return fix_cycles.open_cycle(fix_cycles.read_rows(task_root)) is None
228
228
 
@@ -480,6 +480,42 @@
480
480
  }
481
481
  ]
482
482
  },
483
+ "correctionRounds": {
484
+ "type": "array",
485
+ "maxItems": 2,
486
+ "description": "Each implementation correction round's re-convergence of the verifiers' re-verification, projected from `convergence-correction-r<N>-<task-type>-<seq>.json`. Row ids continue the initial round's numbering so every row keeps its own anchor.",
487
+ "items": {
488
+ "type": "object",
489
+ "required": [
490
+ "round",
491
+ "consensus",
492
+ "differences"
493
+ ],
494
+ "additionalProperties": false,
495
+ "properties": {
496
+ "round": {
497
+ "type": "integer",
498
+ "minimum": 1,
499
+ "maximum": 2
500
+ },
501
+ "roundHistory": {
502
+ "$ref": "#/properties/crossVerification/properties/roundHistory"
503
+ },
504
+ "consensus": {
505
+ "type": "array",
506
+ "items": {
507
+ "$ref": "#/$defs/ConsensusRow"
508
+ }
509
+ },
510
+ "differences": {
511
+ "type": "array",
512
+ "items": {
513
+ "$ref": "#/$defs/DifferenceRow"
514
+ }
515
+ }
516
+ }
517
+ }
518
+ },
483
519
  "criticGaps": {
484
520
  "type": "object",
485
521
  "description": "Coverage-critic gap accounting. `proposed - merged - unverified` is the count the voters judged and rejected, so a gap that vanished shows up as a mismatch instead of as nothing. Every `unverified` gap needs a matching `missingInformation` row with source `critic-unverified` (prompts/lead/convergence.md \"Gap verification\").",
@@ -439,6 +439,7 @@ If `render-bundle` fails with a `PrepareError` containing `Recorded stage SHAs n
439
439
  - **Enter a different ref directly** — the user names a commit/branch/tag.
440
440
  - **Abort** — stop the run without recovering.
441
441
  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.
442
+ 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.
442
443
 
443
444
  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`.
444
445
 
@@ -70,7 +70,7 @@
70
70
  </section>
71
71
  {% endif %}
72
72
  {% set no_cross_model_check = taskType != "release-handoff" and (crossVerification.get("roundHistory") or {}).get("round2SkippedReason") == "single-analyser-only" %}
73
- {% if crossVerification.get("consensus") or crossVerification.get("differences") or no_cross_model_check %}
73
+ {% if crossVerification.get("consensus") or crossVerification.get("differences") or crossVerification.get("correctionRounds") or no_cross_model_check %}
74
74
  <section data-report-section="cross-check">
75
75
  <h2>{{ t('base.cross-check') }}</h2>
76
76
  {% if no_cross_model_check %}<p class="section-lede">{{ t('base.no-cross-model-check') }}</p>{% endif %}
@@ -82,6 +82,11 @@
82
82
  <h3>{{ t('base.workers-disagreed') }}</h3>
83
83
  <div class="summary-grid">{% for row in crossVerification.differences %}<article class="summary-card tone-important" id="id-xv-{{ row.id }}"><p class="eyebrow">{{ row.id | inline_code }}</p><h3>{{ row.disagreement | inline_code }}</h3><p>{% for pos in row.get("workersPosition", []) %}{{ pos.worker | inline_code }} · {{ pos.position | inline_code }}{% if not loop.last %} · {% endif %}{% endfor %}</p><p class="evidence-refs">{{ t('macros.layout.evidence') }} {{ row.evidence | inline_code }}</p></article>{% endfor %}</div>
84
84
  {% endif %}
85
+ {% for correction in crossVerification.get("correctionRounds", []) %}
86
+ <h3>{{ t('base.cross-check-correction-round') | replace('{round}', correction.round | string) }}</h3>
87
+ {% if correction.consensus %}<div class="summary-grid">{% for row in correction.consensus %}<article class="summary-card" id="id-xv-{{ row.id }}"><p class="eyebrow">{{ row.id | inline_code }} · {{ t('base.agreed-across-workers') }}</p><h3>{{ row.statement | inline_code }}</h3><p class="evidence-refs">{{ t('macros.layout.evidence') }} {{ row.evidence | inline_code }}</p></article>{% endfor %}</div>{% endif %}
88
+ {% if correction.differences %}<div class="summary-grid">{% for row in correction.differences %}<article class="summary-card tone-important" id="id-xv-{{ row.id }}"><p class="eyebrow">{{ row.id | inline_code }} · {{ t('base.workers-disagreed') }}</p><h3>{{ row.disagreement | inline_code }}</h3><p>{% for pos in row.get("workersPosition", []) %}{{ pos.worker | inline_code }} · {{ pos.position | inline_code }}{% if not loop.last %} · {% endif %}{% endfor %}</p><p class="evidence-refs">{{ t('macros.layout.evidence') }} {{ row.evidence | inline_code }}</p></article>{% endfor %}</div>{% endif %}
89
+ {% endfor %}
85
90
  </section>
86
91
  {% endif %}
87
92
  {% if agentActivity %}
@@ -242,6 +242,7 @@
242
242
  "no-cross-model-check": "No cross-model check ran: fewer than two analysis workers were selected, so no other model verified these findings.",
243
243
  "agreed-across-workers": "Agreed across workers",
244
244
  "workers-disagreed": "Workers disagreed",
245
+ "cross-check-correction-round": "Re-converged after correction round {round}",
245
246
  "source": "Source",
246
247
  "confidence": "Confidence",
247
248
  "export-my-answers": "Export my answers",
@@ -242,6 +242,7 @@
242
242
  "no-cross-model-check": "교차 모델 검증을 하지 않았습니다. 분석 작업자를 두 명 미만으로 골라 다른 모델이 이 결과를 검증하지 않았습니다.",
243
243
  "agreed-across-workers": "작업자 간 합의",
244
244
  "workers-disagreed": "작업자 간 이견",
245
+ "cross-check-correction-round": "수정 라운드 {round} 뒤 재수렴",
245
246
  "source": "출처",
246
247
  "confidence": "확신도",
247
248
  "export-my-answers": "내 답변 내보내기",
@@ -4854,8 +4854,10 @@ def _validate_convergence_group_provenance(run_dir, failures, suffix=None) -> No
4854
4854
  # provider token (`claude` vs `claude-worker`), depending on how the roster was
4855
4855
  # seeded — worker resolution below accepts both. `-reverify-r` never occurs
4856
4856
  # inside a slug, so the non-greedy slug capture stops at that literal anchor.
4857
+ # A correction round's re-convergence puts `correction-r<N>-` before
4858
+ # `reverify-r`, and its plans carry the same token before `<task-type>`.
4857
4859
  _REVERIFY_PROMPT_BASENAME_RE = re.compile(
4858
- r"^(?P<slug>[a-z][a-z0-9-]*?)-reverify-r(?P<round>\d+)"
4860
+ r"^(?P<slug>[a-z][a-z0-9-]*?)(?:-(?P<scope>correction-r\d+))?-reverify-r(?P<round>\d+)"
4859
4861
  r"-(?P<task_type>[a-z][a-z-]*?)-(?P<seq>\d{3})\.md$"
4860
4862
  )
4861
4863
 
@@ -5318,8 +5320,9 @@ def _validate_reverify_prompt_matches_plan(run_dir, failures, suffix=None) -> No
5318
5320
  if match is None:
5319
5321
  continue
5320
5322
  round_n = match.group("round")
5323
+ scope = f"{match.group('scope')}-" if match.group("scope") else ""
5321
5324
  plan_path = state_dir / (
5322
- f"convergence-round-{round_n}-plan-"
5325
+ f"convergence-round-{round_n}-plan-{scope}"
5323
5326
  f"{match.group('task_type')}-{match.group('seq')}.json"
5324
5327
  )
5325
5328
  if not plan_path.is_file():
@@ -298,6 +298,18 @@ def _analysis_workers(team_state: Mapping[str, Any]) -> list[dict]:
298
298
  return [worker for worker in workers if not _is_report_writer(worker)]
299
299
 
300
300
 
301
+ def _critic_worker_ids(team_state: Mapping[str, Any]) -> set[str]:
302
+ """`critic/*` 배정이 로스터에 남기는 행의 workerId (`render._critic_role_rows`)."""
303
+ assignments = team_state.get("invocationAssignments")
304
+ if not isinstance(assignments, Mapping):
305
+ return set()
306
+ return {
307
+ str(ref).rsplit("/", 1)[-1]
308
+ for ref in assignments
309
+ if str(ref).startswith("critic/")
310
+ }
311
+
312
+
301
313
  def _scan_one_jsonl(
302
314
  path: Path, since: str | None, until: str | None
303
315
  ) -> tuple[list[tuple[str, str, str]], dict[str, list[str]], str | None]:
@@ -1686,7 +1698,13 @@ def _check_progress_checkpoints(
1686
1698
  "`using implicit team` line, or the "
1687
1699
  "`skipped (concurrent-run)` variant in the concurrent-run path",
1688
1700
  )
1689
- _check_worker_checkpoint_lines(by_phase, analysis_workers, narration)
1701
+ # critic 은 `critic` dispatch 로만 뜨고 `phase-5.6-critic` 으로 알린다.
1702
+ critic_ids = _critic_worker_ids(team_state)
1703
+ _check_worker_checkpoint_lines(
1704
+ by_phase,
1705
+ [w for w in analysis_workers if str(w.get("workerId", "")) not in critic_ids],
1706
+ narration,
1707
+ )
1690
1708
  convergence_ran = _convergence_rounds_ran(run_dir, suffix)
1691
1709
  require(
1692
1710
  "phase-5.5-convergence",