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
@@ -11,7 +11,12 @@ from dataclasses import dataclass
11
11
  from pathlib import Path
12
12
 
13
13
  from .. import worktree_registry
14
- from .git_ops import _is_inside_non_main_worktree, is_git_work_tree
14
+ from .git_ops import (
15
+ _branch_checkout_path,
16
+ _branch_exists,
17
+ _is_inside_non_main_worktree,
18
+ is_git_work_tree,
19
+ )
15
20
  from .naming import _safe_segment, compute_branch_name, compute_worktree_path
16
21
 
17
22
 
@@ -24,11 +29,19 @@ class WorktreeDecision:
24
29
  - "reused": registry already has this task-key; existing path/branch returned
25
30
  - "skipped-in-worktree": project_root is itself a non-main worktree
26
31
  - "skipped-not-git": project_root has no .git
32
+
33
+ recovery (only when the active registry row no longer matches git):
34
+ - "restore-from-branch": status "reused"; the recorded branch survives but
35
+ is not checked out at the recorded path, so provision re-adds it there
36
+ - "replace-stale-row": status "new"; the recorded branch is gone, so
37
+ provision releases the row and creates a fresh worktree from the
38
+ requested base, or the recorded base when none is requested
27
39
  """
28
40
  status: str
29
41
  path: str # worktree path (new: prospective; reuse: existing; skip: project_root)
30
42
  branch: str = "" # new: prospective branch; reused: existing branch
31
43
  base_ref: str = "" # new: requested base_ref; reused: existing base
44
+ recovery: str = ""
32
45
 
33
46
 
34
47
  def preview_worktree_decision(
@@ -54,11 +67,21 @@ def preview_worktree_decision(
54
67
  safe_group = _safe_segment(task_group_segment)
55
68
  safe_task = _safe_segment(task_id_segment)
56
69
  existing = worktree_registry.lookup(safe_project, safe_group, safe_task)
70
+ recovery = ""
57
71
  if existing is not None and existing.status == "active":
58
- return WorktreeDecision(
59
- status="reused", path=existing.worktree_path,
60
- branch=existing.branch, base_ref=existing.base_ref,
61
- )
72
+ if _is_checked_out_at(project_root, existing.branch, existing.worktree_path):
73
+ return WorktreeDecision(
74
+ status="reused", path=existing.worktree_path,
75
+ branch=existing.branch, base_ref=existing.base_ref,
76
+ )
77
+ if _branch_exists(project_root, existing.branch):
78
+ return WorktreeDecision(
79
+ status="reused", path=existing.worktree_path,
80
+ branch=existing.branch, base_ref=existing.base_ref,
81
+ recovery="restore-from-branch",
82
+ )
83
+ recovery = "replace-stale-row"
84
+ base_ref = base_ref or existing.base_ref
62
85
  return WorktreeDecision(
63
86
  status="new",
64
87
  path=str(compute_worktree_path(
@@ -66,9 +89,23 @@ def preview_worktree_decision(
66
89
  task_id_segment=safe_task)),
67
90
  branch=compute_branch_name(work_category=work_category, task_id_segment=safe_task),
68
91
  base_ref=base_ref,
92
+ recovery=recovery,
69
93
  )
70
94
 
71
95
 
96
+ def _is_checked_out_at(project_root: Path, branch: str, worktree_path: str) -> bool:
97
+ """Whether git itself has `branch` checked out at `worktree_path`.
98
+
99
+ An `active` registry row only records what okstra reserved; the directory
100
+ can be removed by hand and the branch deleted after a merge without the
101
+ registry hearing about it.
102
+ """
103
+ checkout = _branch_checkout_path(project_root, branch)
104
+ if not checkout or not Path(worktree_path).is_dir():
105
+ return False
106
+ return Path(checkout).resolve() == Path(worktree_path).resolve()
107
+
108
+
72
109
  @dataclass
73
110
  class StageWorktreeDecision:
74
111
  """Side-effect-free decision for one concrete implementation stage."""
@@ -141,6 +141,10 @@ def provision_task_worktree(
141
141
  safe_task = _safe_segment(task_id_segment)
142
142
 
143
143
  if decision.status == "reused":
144
+ restored = ""
145
+ if decision.recovery == "restore-from-branch":
146
+ _restore_task_worktree(project_root, Path(decision.path), decision.branch)
147
+ restored = "; restored from its branch (directory was not a worktree)"
144
148
  worktree_registry.touch_phase(safe_project, safe_group, safe_task, task_type)
145
149
  _seed_worktree_settings_symlink(Path(decision.path))
146
150
  drift = _sync_key_drift_note(
@@ -155,7 +159,7 @@ def provision_task_worktree(
155
159
  note=(
156
160
  f"task worktree reused at {decision.path} on branch "
157
161
  f"{decision.branch} (base {decision.base_ref[:12]}); phase {task_type}"
158
- f"{drift}"
162
+ f"{restored}{drift}"
159
163
  ),
160
164
  )
161
165
 
@@ -163,6 +167,8 @@ def provision_task_worktree(
163
167
  worktree_path = Path(decision.path)
164
168
  branch = decision.branch
165
169
 
170
+ if decision.recovery == "replace-stale-row":
171
+ _remove_settings_seed_shell(worktree_path)
166
172
  if worktree_path.exists():
167
173
  raise RuntimeError(
168
174
  f"task worktree path already exists but is not in the registry: "
@@ -173,7 +179,7 @@ def provision_task_worktree(
173
179
  raise RuntimeError(_branch_exists_message(project_root, branch, "task"))
174
180
 
175
181
  main_root = main_worktree_path(project_root)
176
- requested_base = (base_ref or "").strip()
182
+ requested_base = (decision.base_ref or "").strip()
177
183
  if not requested_base and require_base_ref:
178
184
  raise RuntimeError(
179
185
  "first-phase task worktree requires an explicit base ref; "
@@ -221,8 +227,15 @@ def provision_task_worktree(
221
227
  linked_parts.append(f"linked-files {', '.join(linked_files)}")
222
228
  if snapshot_files:
223
229
  linked_parts.append(f"snapshot {', '.join(snapshot_files)}")
230
+ if decision.recovery == "replace-stale-row":
231
+ linked_parts.append(
232
+ "replaced a stale registry row whose recorded branch no longer exists")
224
233
  linked_suffix = ("; " + "; ".join(linked_parts)) if linked_parts else ""
225
234
 
235
+ if decision.recovery == "replace-stale-row":
236
+ # The recorded branch is already deleted, so freeing its slot keeps the
237
+ # registry's "a free branch name is physically deleted" invariant.
238
+ worktree_registry.release(safe_project, safe_group, safe_task)
226
239
  try:
227
240
  worktree_registry.reserve(
228
241
  project_id=safe_project,
@@ -260,6 +273,40 @@ def provision_task_worktree(
260
273
  )
261
274
 
262
275
 
276
+ def _remove_settings_seed_shell(path: Path) -> None:
277
+ """Remove a directory that holds nothing but okstra's settings seed.
278
+
279
+ Earlier prepares re-seeded `.claude/settings.local.json` into the recorded
280
+ path of a stale registry row, which left exactly this shell behind and made
281
+ every later prepare refuse the path. Anything else in the directory is
282
+ left alone.
283
+ """
284
+ seed = path / ".claude" / "settings.local.json"
285
+ if not path.is_dir() or not (seed.is_symlink() or seed.is_file()):
286
+ return
287
+ if [p.name for p in path.iterdir()] != [".claude"]:
288
+ return
289
+ if [p.name for p in (path / ".claude").iterdir()] != ["settings.local.json"]:
290
+ return
291
+ seed.unlink()
292
+ (path / ".claude").rmdir()
293
+ path.rmdir()
294
+
295
+
296
+ def _restore_task_worktree(project_root: Path, path: Path, branch: str) -> None:
297
+ _remove_settings_seed_shell(path)
298
+ if path.exists():
299
+ raise RuntimeError(
300
+ f"task worktree path {path} exists but is not a worktree of branch "
301
+ f"{branch}; move it aside so okstra can re-add the worktree from "
302
+ "that branch."
303
+ )
304
+ # A directory deleted by hand stays registered in git until pruned, and
305
+ # `git worktree add` refuses a missing-but-registered path.
306
+ _git(main_worktree_path(project_root), "worktree", "prune")
307
+ restore_stage_worktree(project_root, str(path), branch)
308
+
309
+
263
310
  def restore_stage_worktree(project_root: Path, path: str, branch: str) -> None:
264
311
  target = Path(path)
265
312
  if target.exists():
@@ -481,11 +481,12 @@
481
481
  },
482
482
  "criticGaps": {
483
483
  "type": "object",
484
- "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\").",
484
+ "description": "Coverage-critic gap accounting. `proposed - merged - unverified - duplicate` is the count the voters judged and rejected, so a gap that vanished shows up as a mismatch instead of as nothing. `duplicate` counts gaps the critic declared duplicates of an existing finding; they are never verified. Every `unverified` gap needs a matching `missingInformation` row with source `critic-unverified` (prompts/lead/convergence.md \"Gap verification\").",
485
485
  "required": [
486
486
  "proposed",
487
487
  "merged",
488
- "unverified"
488
+ "unverified",
489
+ "duplicate"
489
490
  ],
490
491
  "additionalProperties": false,
491
492
  "properties": {
@@ -500,6 +501,10 @@
500
501
  "unverified": {
501
502
  "type": "integer",
502
503
  "minimum": 0
504
+ },
505
+ "duplicate": {
506
+ "type": "integer",
507
+ "minimum": 0
503
508
  }
504
509
  }
505
510
  },
@@ -480,13 +480,50 @@
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
- "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\").",
521
+ "description": "Coverage-critic gap accounting. `proposed - merged - unverified - duplicate` is the count the voters judged and rejected, so a gap that vanished shows up as a mismatch instead of as nothing. `duplicate` counts gaps the critic declared duplicates of an existing finding; they are never verified. Every `unverified` gap needs a matching `missingInformation` row with source `critic-unverified` (prompts/lead/convergence.md \"Gap verification\").",
486
522
  "required": [
487
523
  "proposed",
488
524
  "merged",
489
- "unverified"
525
+ "unverified",
526
+ "duplicate"
490
527
  ],
491
528
  "additionalProperties": false,
492
529
  "properties": {
@@ -501,6 +538,10 @@
501
538
  "unverified": {
502
539
  "type": "integer",
503
540
  "minimum": 0
541
+ },
542
+ "duplicate": {
543
+ "type": "integer",
544
+ "minimum": 0
504
545
  }
505
546
  }
506
547
  },
@@ -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": "내 답변 내보내기",
@@ -4233,12 +4233,13 @@ def _validate_unverified_critic_gaps_recorded(data: dict, failures: list[str]) -
4233
4233
 
4234
4234
  proposed = critic.get("proposed")
4235
4235
  merged = critic.get("merged")
4236
- if isinstance(proposed, int) and isinstance(merged, int):
4237
- if merged + unverified > proposed:
4236
+ duplicate = critic.get("duplicate")
4237
+ if isinstance(proposed, int) and isinstance(merged, int) and isinstance(duplicate, int):
4238
+ if merged + unverified + duplicate > proposed:
4238
4239
  failures.append(
4239
4240
  "final-report data.json: crossVerification.criticGaps has "
4240
- f"merged({merged}) + unverified({unverified}) > proposed({proposed}) "
4241
- "— the accounting does not describe a real set of gaps."
4241
+ f"merged({merged}) + unverified({unverified}) + duplicate({duplicate}) "
4242
+ f"> proposed({proposed}) — the accounting does not describe a real set of gaps."
4242
4243
  )
4243
4244
 
4244
4245
 
@@ -4854,8 +4855,10 @@ def _validate_convergence_group_provenance(run_dir, failures, suffix=None) -> No
4854
4855
  # provider token (`claude` vs `claude-worker`), depending on how the roster was
4855
4856
  # seeded — worker resolution below accepts both. `-reverify-r` never occurs
4856
4857
  # inside a slug, so the non-greedy slug capture stops at that literal anchor.
4858
+ # A correction round's re-convergence puts `correction-r<N>-` before
4859
+ # `reverify-r`, and its plans carry the same token before `<task-type>`.
4857
4860
  _REVERIFY_PROMPT_BASENAME_RE = re.compile(
4858
- r"^(?P<slug>[a-z][a-z0-9-]*?)-reverify-r(?P<round>\d+)"
4861
+ r"^(?P<slug>[a-z][a-z0-9-]*?)(?:-(?P<scope>correction-r\d+))?-reverify-r(?P<round>\d+)"
4859
4862
  r"-(?P<task_type>[a-z][a-z-]*?)-(?P<seq>\d{3})\.md$"
4860
4863
  )
4861
4864
 
@@ -5318,8 +5321,9 @@ def _validate_reverify_prompt_matches_plan(run_dir, failures, suffix=None) -> No
5318
5321
  if match is None:
5319
5322
  continue
5320
5323
  round_n = match.group("round")
5324
+ scope = f"{match.group('scope')}-" if match.group("scope") else ""
5321
5325
  plan_path = state_dir / (
5322
- f"convergence-round-{round_n}-plan-"
5326
+ f"convergence-round-{round_n}-plan-{scope}"
5323
5327
  f"{match.group('task_type')}-{match.group('seq')}.json"
5324
5328
  )
5325
5329
  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",