okstra 0.214.1 → 0.216.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 (118) hide show
  1. package/README.md +2 -1
  2. package/dist/cli-registry.d.mts +14 -0
  3. package/dist/cli-registry.mjs +12 -0
  4. package/dist/cli-registry.mjs.map +1 -1
  5. package/dist/commands/chat/chat.mjs +2 -56
  6. package/dist/commands/chat/chat.mjs.map +1 -1
  7. package/dist/commands/execute/render-bundle.mjs +5 -2
  8. package/dist/commands/execute/render-bundle.mjs.map +1 -1
  9. package/dist/commands/execute/wizard.mjs +6 -0
  10. package/dist/commands/execute/wizard.mjs.map +1 -1
  11. package/dist/commands/memory/memory.mjs +57 -6
  12. package/dist/commands/memory/memory.mjs.map +1 -1
  13. package/dist/lib/file-lock.d.mts +1 -0
  14. package/dist/lib/file-lock.mjs +60 -0
  15. package/dist/lib/file-lock.mjs.map +1 -0
  16. package/dist/lib/python-command.mjs +11 -1
  17. package/dist/lib/python-command.mjs.map +1 -1
  18. package/dist/lib/skill-catalog.mjs +1 -0
  19. package/dist/lib/skill-catalog.mjs.map +1 -1
  20. package/dist/lib/types.d.mts +1 -1
  21. package/docs/architecture.md +2 -2
  22. package/docs/cli.md +36 -14
  23. package/docs/project-structure-overview.md +6 -3
  24. package/package.json +1 -1
  25. package/runtime/BUILD.json +2 -2
  26. package/runtime/bin/lib/okstra/cli.sh +9 -2
  27. package/runtime/bin/lib/okstra/globals.sh +2 -0
  28. package/runtime/bin/lib/okstra/interactive.sh +8 -2
  29. package/runtime/bin/lib/okstra/usage.sh +3 -1
  30. package/runtime/bin/okstra.sh +7 -1
  31. package/runtime/prompts/duties/discussion-worker.json +32 -0
  32. package/runtime/prompts/lead/convergence.md +58 -136
  33. package/runtime/prompts/lead/okstra-lead-contract.md +45 -12
  34. package/runtime/prompts/profiles/_common-contract.md +1 -1
  35. package/runtime/prompts/wizard/prompts.ko.json +14 -1
  36. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +4 -0
  37. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +4 -11
  38. package/runtime/python/okstra_ctl/agent/invocation.py +1 -0
  39. package/runtime/python/okstra_ctl/agent/prompt_cli/dynamic_verifier.py +2 -2
  40. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +4 -4
  41. package/runtime/python/okstra_ctl/agent/prompt_cli/run_identity.py +2 -2
  42. package/runtime/python/okstra_ctl/analysis_packet.py +3 -0
  43. package/runtime/python/okstra_ctl/consumers.py +8 -2
  44. package/runtime/python/okstra_ctl/convergence.py +143 -29
  45. package/runtime/python/okstra_ctl/convergence_critic_prompt.py +57 -0
  46. package/runtime/python/okstra_ctl/convergence_critic_verify_prompt.py +106 -0
  47. package/runtime/python/okstra_ctl/convergence_engine.py +140 -95
  48. package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +3 -27
  49. package/runtime/python/okstra_ctl/convergence_store.py +11 -4
  50. package/runtime/python/okstra_ctl/dispatch_core.py +4 -1
  51. package/runtime/python/okstra_ctl/domain/role.py +1 -0
  52. package/runtime/python/okstra_ctl/manager_cli.py +27 -1
  53. package/runtime/python/okstra_ctl/manager_launch.py +44 -4
  54. package/runtime/python/okstra_ctl/manager_sync.py +14 -1
  55. package/runtime/python/okstra_ctl/manager_view.py +53 -1
  56. package/runtime/python/okstra_ctl/next_phase.py +1 -0
  57. package/runtime/python/okstra_ctl/phases/catalog.py +2 -1
  58. package/runtime/python/okstra_ctl/phases/discussion/__init__.py +1 -0
  59. package/runtime/python/okstra_ctl/phases/discussion/boundary.json +11 -0
  60. package/runtime/python/okstra_ctl/phases/discussion/cli.py +512 -0
  61. package/runtime/python/okstra_ctl/phases/discussion/entry.py +116 -0
  62. package/runtime/python/okstra_ctl/phases/discussion/followup.py +876 -0
  63. package/runtime/python/okstra_ctl/phases/discussion/memory.py +294 -0
  64. package/runtime/python/okstra_ctl/phases/discussion/overview.py +85 -0
  65. package/runtime/python/okstra_ctl/phases/discussion/profile.json +31 -0
  66. package/runtime/python/okstra_ctl/phases/discussion/profile.md +48 -0
  67. package/runtime/python/okstra_ctl/phases/discussion/report.py +73 -0
  68. package/runtime/python/okstra_ctl/phases/discussion/report_assets/discussion.template.html +15 -0
  69. package/runtime/python/okstra_ctl/phases/discussion/report_assets/discussion.template.md +6 -0
  70. package/runtime/python/okstra_ctl/phases/discussion/spec.md +197 -0
  71. package/runtime/python/okstra_ctl/phases/discussion/store.py +1143 -0
  72. package/runtime/python/okstra_ctl/phases/discussion/validation.py +111 -0
  73. package/runtime/python/okstra_ctl/phases/discussion/wizard.py +90 -0
  74. package/runtime/python/okstra_ctl/phases/discussion/workflow.py +667 -0
  75. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +1 -1
  76. package/runtime/python/okstra_ctl/phases/implementation_planning/host-rules.md +4 -1
  77. package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +1 -1
  78. package/runtime/python/okstra_ctl/phases/requirements_discovery/spec.md +1 -1
  79. package/runtime/python/okstra_ctl/plan_run_root.py +8 -0
  80. package/runtime/python/okstra_ctl/render.py +7 -20
  81. package/runtime/python/okstra_ctl/render_final_report.py +2 -1
  82. package/runtime/python/okstra_ctl/report_assembly.py +22 -0
  83. package/runtime/python/okstra_ctl/report_contract.py +2 -0
  84. package/runtime/python/okstra_ctl/report_narrative.py +13 -4
  85. package/runtime/python/okstra_ctl/report_synthesis_packet.py +10 -0
  86. package/runtime/python/okstra_ctl/run.py +40 -7
  87. package/runtime/python/okstra_ctl/stage_close.py +46 -11
  88. package/runtime/python/okstra_ctl/stage_reopen.py +151 -0
  89. package/runtime/python/okstra_ctl/verdict_blocks.py +11 -35
  90. package/runtime/python/okstra_ctl/wizard/cli.py +8 -0
  91. package/runtime/python/okstra_ctl/wizard/confirmation.py +4 -1
  92. package/runtime/python/okstra_ctl/wizard/engine.py +8 -0
  93. package/runtime/python/okstra_ctl/wizard/ids.py +12 -0
  94. package/runtime/python/okstra_ctl/wizard/registry.py +35 -3
  95. package/runtime/python/okstra_ctl/wizard/render.py +4 -1
  96. package/runtime/python/okstra_ctl/wizard/sources.py +2 -2
  97. package/runtime/python/okstra_ctl/wizard/state.py +11 -1
  98. package/runtime/python/okstra_ctl/wizard/steps_identity.py +4 -4
  99. package/runtime/python/okstra_ctl/worker_prompt_policy.py +24 -6
  100. package/runtime/python/okstra_token_usage/pricing.py +2 -0
  101. package/runtime/schemas/convergence-groups-v1.0.schema.json +1 -1
  102. package/runtime/schemas/convergence-groups-v2.0.schema.json +1 -1
  103. package/runtime/schemas/final-report-v3.0.schema.json +90 -1
  104. package/runtime/skills/_fragments/wizard-bash-invocation-rule.md +1 -0
  105. package/runtime/skills/_fragments/wizard-confirm-step.md +7 -0
  106. package/runtime/skills/_fragments/wizard-lead-takeover.md +8 -0
  107. package/runtime/skills/_fragments/wizard-preflight.md +75 -0
  108. package/runtime/skills/_fragments/wizard-prompt-loop.md +22 -0
  109. package/runtime/skills/_fragments/wizard-relay-protocol.md +40 -0
  110. package/runtime/skills/_fragments/wizard-render-bundle.md +74 -0
  111. package/runtime/skills/okstra-discussion/SKILL.md +347 -0
  112. package/runtime/skills/okstra-pr-gen/SKILL.md +7 -0
  113. package/runtime/skills/okstra-run/SKILL.md +22 -2
  114. package/runtime/templates/manager/view.template.html +3 -0
  115. package/runtime/templates/reports/html/i18n/en.json +5 -0
  116. package/runtime/templates/reports/html/i18n/ko.json +5 -0
  117. package/runtime/validators/validate-brief.py +3 -2
  118. package/runtime/validators/validate-run.py +4 -6
@@ -13,7 +13,8 @@ WORKING_SCHEMA_VERSION = "1.0"
13
13
  V2_WORKING_SCHEMA_VERSION = "2.0"
14
14
  EXECUTION_IDENTITY_VERSION = 2
15
15
  FINAL_SCHEMA_VERSION = "1.4"
16
- _AUDIENCES = {"analysis", "lead", "report-writer"}
16
+ # `critic` 은 발견을 내지 않고, 분석 워커 표가 원저자 표와 동률일 때만 투표한다.
17
+ _AUDIENCES = {"analysis", "lead", "report-writer", "critic"}
17
18
  # 발견을 낼 수 있는 audience. 리드는 계약상 Phase 6 자체 리뷰를 수행하므로
18
19
  # 출처가 될 수 있지만, 교차검증 표는 아니다 — 합의 수는 아래에서 분석 워커만
19
20
  # 센다. 리드를 분석 워커로 위장 선언하는 것을 계약이 금지하는 이상, 리드의
@@ -23,6 +24,7 @@ _AUDIENCE_SOURCE_ROLES = {
23
24
  "analysis": frozenset({"analyser", "designer", "planner", "verifier"}),
24
25
  "lead": frozenset({"leader"}),
25
26
  "report-writer": frozenset({"report-writer"}),
27
+ "critic": frozenset({"critic"}),
26
28
  }
27
29
  _VERIFICATION_MODES = {"lightweight", "full-reanalysis"}
28
30
  # `unverified` 가 없던 시절의 산출물. `reuse-final` 이 그대로 읽으므로 그
@@ -73,6 +75,16 @@ FINALIZE_PLAN_NOTE = (
73
75
  )
74
76
 
75
77
 
78
+ # 원저자 표를 더한 다수결이 갈리지 않은 라운드. 분류 값이 아니라 크리틱에게
79
+ # 넘길지 정하는 내부 표지다.
80
+ TIE = "tie"
81
+
82
+ _COLLABORATIVE_REMOVED = (
83
+ "config.adversarial is false: collaborative convergence was removed and every "
84
+ "phase verifies adversarially; regroup with `okstra convergence prepare-groups`"
85
+ )
86
+
87
+
76
88
  class ConvergenceContractError(ValueError):
77
89
  """Raised when convergence input violates the persisted-state contract."""
78
90
 
@@ -112,6 +124,12 @@ def seed_working_state(
112
124
  for worker in workers
113
125
  if worker["audience"] in _FINDING_SOURCE_AUDIENCES
114
126
  ]
127
+ critics = [worker["workerId"] for worker in workers if worker["audience"] == "critic"]
128
+ if len(critics) > 1:
129
+ raise ConvergenceContractError(
130
+ f"at most one critic worker may break ties: {', '.join(critics)}"
131
+ )
132
+ config["tieBreaker"] = critics[0] if critics else None
115
133
  state = {
116
134
  "schemaVersion": source["schemaVersion"],
117
135
  "taskKey": task_key,
@@ -201,6 +219,12 @@ def plan_next_round(state: Mapping[str, Any]) -> dict[str, Any]:
201
219
  workers = current.get("workers")
202
220
  if not isinstance(workers, list):
203
221
  raise ConvergenceContractError("working state.workers must be an array")
222
+ tie_breaker = config.get("tieBreaker")
223
+ awaiting_tie_break = [
224
+ finding_id
225
+ for finding_id in queue
226
+ if _finding_outcome(findings[finding_id].get("rounds") or [], tie_breaker)[1]
227
+ ]
204
228
  dispatches: list[dict[str, Any]] = []
205
229
  skipped: list[dict[str, str]] = []
206
230
  for worker in workers:
@@ -210,7 +234,8 @@ def plan_next_round(state: Mapping[str, Any]) -> dict[str, Any]:
210
234
  eligible = [
211
235
  finding_id
212
236
  for finding_id in queue
213
- if _worker_is_independent_from_finding(
237
+ if finding_id not in awaiting_tie_break
238
+ and _worker_is_independent_from_finding(
214
239
  worker, findings[finding_id], workers
215
240
  )
216
241
  ]
@@ -228,6 +253,17 @@ def plan_next_round(state: Mapping[str, Any]) -> dict[str, Any]:
228
253
  "reason": "no items to verify",
229
254
  }
230
255
  )
256
+ if awaiting_tie_break:
257
+ critic = next(
258
+ worker
259
+ for worker in workers
260
+ if isinstance(worker, Mapping) and worker.get("workerId") == tie_breaker
261
+ )
262
+ dispatches.append({
263
+ **_worker_execution_refs(critic),
264
+ "worker": tie_breaker,
265
+ "findingIds": awaiting_tie_break,
266
+ })
231
267
  return {
232
268
  "schemaVersion": WORKING_SCHEMA_VERSION,
233
269
  "action": "dispatch",
@@ -292,25 +328,6 @@ def _worker_execution_refs(worker: Mapping[str, Any]) -> dict[str, str]:
292
328
  }
293
329
 
294
330
 
295
- def classify_collaborative_round(
296
- votes: Mapping[str, Mapping[str, Any]],
297
- ) -> str | None:
298
- """Classify one collaborative round using non-error votes only."""
299
- parsed = _parsed_votes(votes, adversarial=False)
300
- usable = [vote for vote in parsed.values() if vote["verdict"] != "verification-error"]
301
- if not usable:
302
- return None
303
- agreeing = sum(vote["verdict"] in {"agree", "supplement"} for vote in usable)
304
- disagreeing = sum(vote["verdict"] == "disagree" for vote in usable)
305
- if agreeing == len(usable):
306
- return "full-consensus"
307
- if disagreeing == len(usable):
308
- return "worker-unique"
309
- if agreeing > len(usable) / 2:
310
- return "partial-consensus"
311
- return None
312
-
313
-
314
331
  def classify_adversarial_round(
315
332
  votes: Mapping[str, Mapping[str, Any]],
316
333
  ) -> str | None:
@@ -343,6 +360,65 @@ def classify_adversarial_round(
343
360
  return "partial-consensus"
344
361
 
345
362
 
363
+ def classify_finding_round(
364
+ votes: Mapping[str, Mapping[str, Any]],
365
+ ) -> str | None:
366
+ """원저자를 SURVIVES 1표로 세어 finding 의 한 라운드를 분류한다.
367
+
368
+ 검증자 표만 세면 검증자 둘이 1:1 로 갈릴 때 과반이 없다. 원저자는 자기 발견을
369
+ 유지하는 쪽이므로 그 표를 더해 다수결을 낸다. 동률은 `TIE` 다.
370
+ """
371
+ parsed = _parsed_votes(votes, adversarial=True)
372
+ usable = [vote for vote in parsed.values() if vote["verdict"] != "verification-error"]
373
+ if not usable:
374
+ return None
375
+ disagrees = [vote for vote in usable if vote["verdict"] == "disagree"]
376
+ if any(vote["disagreeBasis"] == "counter-evidence" for vote in disagrees):
377
+ return "contested"
378
+ survives = 1 + len(usable) - len(disagrees)
379
+ if survives > len(disagrees):
380
+ if disagrees:
381
+ return "partial-consensus"
382
+ caveats = sum(vote["verdict"] == "supplement" for vote in usable)
383
+ return "partial-consensus" if caveats > len(usable) / 2 else "full-consensus"
384
+ if len(disagrees) > survives:
385
+ return "worker-unique"
386
+ return TIE
387
+
388
+
389
+ def _finding_outcome(
390
+ rounds: list[Any],
391
+ tie_breaker: str | None,
392
+ ) -> tuple[str | None, bool]:
393
+ """finding 의 라운드 기록이 확정한 분류와, 크리틱의 동률 판정을 기다리는지.
394
+
395
+ 동률 라운드의 표는 다음 라운드(크리틱 한 표)와 합쳐 다시 센다. 크리틱이
396
+ 로스터에 없으면 동률은 그 라운드에서 `contested` 로 끝난다 — 같은 분석
397
+ 워커에게 다시 물어도 새 정보가 없다.
398
+ """
399
+ rows = [
400
+ row
401
+ for row in rounds
402
+ if isinstance(row, Mapping) and isinstance(row.get("votes"), Mapping)
403
+ ]
404
+ pending: dict[str, Any] = {}
405
+ for index, row in enumerate(rows):
406
+ votes = {**pending, **row["votes"]}
407
+ classification = classify_finding_round(votes)
408
+ if classification is None:
409
+ continue
410
+ if classification == TIE:
411
+ if tie_breaker is None:
412
+ classification = "contested"
413
+ else:
414
+ pending = votes
415
+ continue
416
+ if classification == "contested" and index < len(rows) - 1:
417
+ return _legacy_adversarial_history(rows), False
418
+ return classification, False
419
+ return None, bool(pending)
420
+
421
+
346
422
  def _round_has_counter_evidence(
347
423
  votes: Mapping[str, Mapping[str, Any]],
348
424
  ) -> bool:
@@ -356,12 +432,12 @@ def _round_has_counter_evidence(
356
432
 
357
433
  def _classify_adversarial_history(
358
434
  rounds: list[Mapping[str, Any]],
435
+ tie_breaker: str | None = None,
359
436
  ) -> str | None:
360
437
  """적대적 라운드 기록을 읽어 확정된 분류를 돌려준다.
361
438
 
362
- 분류를 낸 첫 라운드에서 멈춘다. `counter-evidence` 반증이 만드는
363
- `contested` 도 그 라운드에서 종결이다 — 반증이 영구적이므로 다음 라운드는
364
- 같은 표를 다시 받을 뿐이다.
439
+ 분류를 낸 첫 라운드에서 멈춘다. `counter-evidence` 반증이 만드는 `contested`
440
+ 도 그 라운드에서 종결이다 — 같은 검증자에게 다시 물어도 새 정보가 없다.
365
441
 
366
442
  예외는 구 엔진이 남긴 상태 하나다. 구 엔진은 counter-evidence 를 마지막
367
443
  라운드까지 끌고 갔고, 그 뒤로는 만장일치 반대(`worker-unique`)만 인정했다.
@@ -369,19 +445,7 @@ def _classify_adversarial_history(
369
445
  지문이다 — 현 엔진에서는 거기서 큐를 떠나므로 뒤 라운드가 생기지 않는다.
370
446
  그때만 구 규칙으로 이어 읽어, 이미 기록된 세션을 소급해 실패시키지 않는다.
371
447
  """
372
- rows = [
373
- row
374
- for row in rounds
375
- if isinstance(row, Mapping) and isinstance(row.get("votes"), Mapping)
376
- ]
377
- for index, row in enumerate(rows):
378
- classification = classify_adversarial_round(row["votes"])
379
- if classification is None:
380
- continue
381
- if classification == "contested" and index < len(rows) - 1:
382
- return _legacy_adversarial_history(rows)
383
- return classification
384
- return None
448
+ return _finding_outcome(rounds, tie_breaker)[0]
385
449
 
386
450
 
387
451
  def _legacy_adversarial_history(
@@ -449,7 +513,6 @@ def apply_round_results(
449
513
  updated = deepcopy(dict(current))
450
514
  queue = list(updated["queueFindingIds"])
451
515
  findings = {row["findingId"]: row for row in updated["findings"]}
452
- adversarial = bool(updated["config"].get("adversarial"))
453
516
  resolved: list[str] = []
454
517
  for finding_id in queue:
455
518
  finding = findings[finding_id]
@@ -458,15 +521,13 @@ def apply_round_results(
458
521
  planned_by_finding,
459
522
  statuses,
460
523
  votes_by_finding,
461
- adversarial,
524
+ True,
462
525
  )
463
526
  finding["rounds"].append(
464
527
  {"round": expected_plan["round"], "votes": round_votes}
465
528
  )
466
- classification = (
467
- _classify_adversarial_history(finding["rounds"])
468
- if adversarial
469
- else classify_collaborative_round(round_votes)
529
+ classification = _classify_adversarial_history(
530
+ finding["rounds"], updated["config"].get("tieBreaker")
470
531
  )
471
532
  if classification is not None:
472
533
  finding["classification"] = classification
@@ -659,17 +720,11 @@ def finalize_working_state(state: Mapping[str, Any]) -> dict[str, Any]:
659
720
  config = deepcopy(current["config"])
660
721
  findings = deepcopy(current["findings"])
661
722
  queue = set(current["queueFindingIds"])
662
- adversarial = bool(config.get("adversarial"))
663
723
  for finding in findings:
664
724
  if finding["findingId"] not in queue:
665
725
  continue
666
- if _received_no_verdict(finding):
667
- finding["classification"] = "unverified"
668
- continue
669
726
  finding["classification"] = (
670
- "contested"
671
- if adversarial
672
- else _final_collaborative_classification(finding)
727
+ "unverified" if _received_no_verdict(finding) else "contested"
673
728
  )
674
729
  stop_reason = current.get("stopReason")
675
730
  history = deepcopy(current["roundHistory"])
@@ -923,6 +978,12 @@ def validate_final_state(state: Mapping[str, Any]) -> list[str]:
923
978
  history = []
924
979
  errors.extend(_validate_round_history(history))
925
980
  adversarial = bool(config.get("adversarial"))
981
+ if not adversarial and history:
982
+ errors.append(_COLLABORATIVE_REMOVED)
983
+ tie_breaker = config.get("tieBreaker")
984
+ if tie_breaker is not None and not _nonempty_string(tie_breaker):
985
+ errors.append("config.tieBreaker must be a worker ID or null")
986
+ tie_breaker = None
926
987
  historical = version in _HISTORICAL_FINAL_VERSIONS
927
988
  allowed_classifications = set(_CLASSIFICATION_COUNT_KEYS)
928
989
  if historical:
@@ -949,23 +1010,25 @@ def validate_final_state(state: Mapping[str, Any]) -> list[str]:
949
1010
  # 라운드 안에서 확정된 `contested`(증거 기반 반증)는 다르다 — 그때 큐를
950
1011
  # 떠나므로 뒤 라운드 기록이 없는 것이 정상이다.
951
1012
  settled_in_round = (
952
- adversarial and _round_settled_classification(rounds) is not None
1013
+ _round_settled_classification(rounds, tie_breaker) is not None
953
1014
  )
954
1015
  unresolved = classification in {"contested", "unverified"}
955
1016
  if unresolved and rounds and not settled_in_round:
956
1017
  if rounds[-1].get("round") != len(history):
957
1018
  errors.append(f"finding {finding_id} contested before the final round")
958
1019
  if not unresolved:
959
- errors.extend(_validate_no_reappearance_after_resolution(finding, adversarial))
1020
+ errors.extend(
1021
+ _validate_no_reappearance_after_resolution(finding, tie_breaker)
1022
+ )
960
1023
  expected = _expected_final_classification(
961
- finding, adversarial, historical=historical
1024
+ finding, tie_breaker, historical=historical
962
1025
  )
963
1026
  if expected is not None and classification != expected:
964
1027
  errors.append(
965
1028
  f"finding {finding_id} classification {classification} "
966
1029
  f"does not match replayed classification {expected}"
967
1030
  )
968
- errors.extend(_validate_round_ledger_counts(findings, history, adversarial))
1031
+ errors.extend(_validate_round_ledger_counts(findings, history, tie_breaker))
969
1032
  total_rounds = state.get("totalRounds")
970
1033
  if total_rounds != len(history):
971
1034
  errors.append("totalRounds does not match roundHistory length")
@@ -1852,22 +1915,15 @@ def _received_no_verdict(finding: Mapping[str, Any]) -> bool:
1852
1915
  return seen
1853
1916
 
1854
1917
 
1855
- def _final_collaborative_classification(finding: Mapping[str, Any]) -> str:
1856
- usable: list[Mapping[str, Any]] = []
1857
- for round_row in finding.get("rounds", []):
1858
- for vote in round_row.get("votes", {}).values():
1859
- if vote.get("verdict") != "verification-error":
1860
- usable.append(vote)
1861
- agreeing = sum(vote.get("verdict") in {"agree", "supplement"} for vote in usable)
1862
- return "partial-consensus" if usable and agreeing > len(usable) / 2 else "contested"
1863
-
1864
-
1865
- def _round_settled_classification(rounds: list[Any]) -> str | None:
1918
+ def _round_settled_classification(
1919
+ rounds: list[Any],
1920
+ tie_breaker: str | None,
1921
+ ) -> str | None:
1866
1922
  """라운드 기록만으로 확정된 적대적 분류. finalize 가 찍은 값과 구별한다."""
1867
1923
  if not isinstance(rounds, list) or not rounds:
1868
1924
  return None
1869
1925
  try:
1870
- return _classify_adversarial_history(rounds)
1926
+ return _classify_adversarial_history(rounds, tie_breaker)
1871
1927
  except ConvergenceContractError:
1872
1928
  # 표 자체가 계약 위반이면 그 사실은 `_validate_finding_ledger` 가 이미
1873
1929
  # 별도 오류로 낸다. 여기서는 "라운드 안에서 확정되지 않았다"로만 읽는다.
@@ -1876,7 +1932,7 @@ def _round_settled_classification(rounds: list[Any]) -> str | None:
1876
1932
 
1877
1933
  def _expected_final_classification(
1878
1934
  finding: Mapping[str, Any],
1879
- adversarial: bool,
1935
+ tie_breaker: str | None,
1880
1936
  *,
1881
1937
  historical: bool = False,
1882
1938
  ) -> str | None:
@@ -1885,27 +1941,16 @@ def _expected_final_classification(
1885
1941
  return "unverified"
1886
1942
  if not isinstance(rounds, list) or not rounds:
1887
1943
  return None
1888
- if adversarial:
1889
- try:
1890
- return _classify_adversarial_history(rounds) or "contested"
1891
- except ConvergenceContractError:
1892
- return "contested"
1893
- for row in rounds:
1894
- if not isinstance(row, Mapping) or not isinstance(row.get("votes"), Mapping):
1895
- continue
1896
- try:
1897
- resolved = classify_collaborative_round(row["votes"])
1898
- except ConvergenceContractError:
1899
- continue
1900
- if resolved is not None:
1901
- return resolved
1902
- return _final_collaborative_classification(finding)
1944
+ try:
1945
+ return _classify_adversarial_history(rounds, tie_breaker) or "contested"
1946
+ except ConvergenceContractError:
1947
+ return "contested"
1903
1948
 
1904
1949
 
1905
1950
  def _validate_round_ledger_counts(
1906
1951
  findings: list[Any],
1907
1952
  history: list[Any],
1908
- adversarial: bool,
1953
+ tie_breaker: str | None,
1909
1954
  ) -> list[str]:
1910
1955
  errors: list[str] = []
1911
1956
  for round_number, history_row in enumerate(history, start=1):
@@ -1919,9 +1964,9 @@ def _validate_round_ledger_counts(
1919
1964
  accepted = {
1920
1965
  (len(ledgers), resolved, len(ledgers) - resolved)
1921
1966
  for resolved in {
1922
- _resolved_ledger_count(findings, round_number, adversarial),
1967
+ _resolved_ledger_count(findings, round_number, tie_breaker),
1923
1968
  _resolved_ledger_count(
1924
- findings, round_number, adversarial, legacy=True
1969
+ findings, round_number, tie_breaker, legacy=True
1925
1970
  ),
1926
1971
  }
1927
1972
  }
@@ -1960,7 +2005,7 @@ def _round_ledgers(
1960
2005
  def _resolved_ledger_count(
1961
2006
  findings: list[Any],
1962
2007
  round_number: int,
1963
- adversarial: bool,
2008
+ tie_breaker: str | None,
1964
2009
  *,
1965
2010
  legacy: bool = False,
1966
2011
  ) -> int:
@@ -1985,12 +2030,12 @@ def _resolved_ledger_count(
1985
2030
  if not isinstance(votes, Mapping):
1986
2031
  continue
1987
2032
  try:
1988
- if not adversarial:
1989
- classification = classify_collaborative_round(votes)
1990
- elif legacy:
2033
+ if legacy:
1991
2034
  classification = _legacy_adversarial_history(current_rounds)
1992
2035
  else:
1993
- classification = _classify_adversarial_history(current_rounds)
2036
+ classification = _classify_adversarial_history(
2037
+ current_rounds, tie_breaker
2038
+ )
1994
2039
  except ConvergenceContractError:
1995
2040
  continue
1996
2041
  resolved += classification is not None
@@ -2071,7 +2116,7 @@ def _validate_finding_ledger(
2071
2116
 
2072
2117
  def _validate_no_reappearance_after_resolution(
2073
2118
  finding: Mapping[str, Any],
2074
- adversarial: bool,
2119
+ tie_breaker: str | None,
2075
2120
  ) -> list[str]:
2076
2121
  rounds = finding.get("rounds")
2077
2122
  if not isinstance(rounds, list):
@@ -2081,10 +2126,8 @@ def _validate_no_reappearance_after_resolution(
2081
2126
  if not isinstance(votes, Mapping):
2082
2127
  continue
2083
2128
  try:
2084
- classification = (
2085
- _classify_adversarial_history(rounds[: index + 1])
2086
- if adversarial
2087
- else classify_collaborative_round(votes)
2129
+ classification = _classify_adversarial_history(
2130
+ rounds[: index + 1], tie_breaker
2088
2131
  )
2089
2132
  except ConvergenceContractError:
2090
2133
  continue
@@ -2438,6 +2481,8 @@ def _parse_config(value: Any) -> dict[str, Any]:
2438
2481
  mode = _required_string(config, "verificationMode", "config")
2439
2482
  if mode not in _VERIFICATION_MODES:
2440
2483
  raise ConvergenceContractError("config.verificationMode is unsupported")
2484
+ if enabled and not adversarial:
2485
+ raise ConvergenceContractError(_COLLABORATIVE_REMOVED)
2441
2486
  if adversarial and mode != "full-reanalysis":
2442
2487
  raise ConvergenceContractError(
2443
2488
  "adversarial convergence requires full-reanalysis verificationMode"
@@ -78,21 +78,6 @@ uncertain, your verdict is REFUTED with basis = burden-not-met.
78
78
  Inspect ONLY the evidence each finding cites and its immediate surroundings. Do NOT
79
79
  re-read the task brief, instruction-set, or report template."""
80
80
 
81
- _COLLABORATIVE_MANDATE = """Review the following findings discovered by other workers.
82
- For EACH finding, respond with exactly one verdict:
83
-
84
- - **AGREE**: The finding is valid based on the evidence presented
85
- - **DISAGREE**: The finding is incorrect or unsupported (explain briefly why)
86
- - **SUPPLEMENT**: The finding is valid AND you have additional supporting evidence or context
87
- - **UNVERIFIABLE**: Capability, credential, network, or service state prevents you
88
- from checking this finding. Explain the unavailable capability; do not substitute DISAGREE.
89
-
90
- Every verdict carries an `**Explanation**`, AGREE included — name the evidence you
91
- checked. A block with a verdict and no explanation is not collected and the whole
92
- response is refused.
93
-
94
- Do NOT re-analyze the original source materials. Judge based on the evidence provided."""
95
-
96
81
  # 근거 접근 규칙. `**Cited evidence**` 는 리드의 요약이고, 완전한 인용은 원 워커의
97
82
  # 결과 항목이다. 검증자가 요약만 열고 "이 근거로는 입증되지 않는다" 고 답하던
98
83
  # 자리를 막는다.
@@ -111,11 +96,6 @@ ADVERSARIAL_RESPONSE = """### <finding-id>
111
96
  SURVIVES say what you attacked and why the attack failed; for counter-evidence include
112
97
  the file:line you found>"""
113
98
 
114
- _COLLABORATIVE_RESPONSE = """### <finding-id>
115
- **Verdict**: AGREE | DISAGREE | SUPPLEMENT | UNVERIFIABLE
116
- **Explanation** (required for every verdict, AGREE included): <2-3 sentences>"""
117
-
118
-
119
99
  def _nonempty_string(value: Any) -> str:
120
100
  return value if isinstance(value, str) and value.strip() else ""
121
101
 
@@ -208,7 +188,6 @@ def reverify_prompt_body(
208
188
  *,
209
189
  task_key: str,
210
190
  round_number: int,
211
- adversarial: bool,
212
191
  findings: Sequence[ReverifyFinding],
213
192
  ) -> str:
214
193
  """reverify 지시문 파일 본문. 같은 입력이면 같은 바이트를 낸다."""
@@ -216,14 +195,11 @@ def reverify_prompt_body(
216
195
  raise ReverifyPromptError("convergence groups carry no taskKey")
217
196
  if not findings:
218
197
  raise ReverifyPromptError("no findings to verify")
219
- mode = "ADVERSARIAL re-verification" if adversarial else "re-verification"
220
- mandate = ADVERSARIAL_MANDATE if adversarial else _COLLABORATIVE_MANDATE
221
- response = ADVERSARIAL_RESPONSE if adversarial else _COLLABORATIVE_RESPONSE
222
198
  rows = [
223
199
  "## Instructions\n\n",
224
200
  f"{RENDERED_BY_LINE}\n\n",
225
- f"Perform {mode} for {task_key} (round {round_number}).\n\n",
226
- mandate, "\n\n",
201
+ f"Perform ADVERSARIAL re-verification for {task_key} (round {round_number}).\n\n",
202
+ ADVERSARIAL_MANDATE, "\n\n",
227
203
  _EVIDENCE_ACCESS, "\n\n",
228
204
  "## Findings to verify\n",
229
205
  ]
@@ -242,7 +218,7 @@ def reverify_prompt_body(
242
218
  "Field labels are bold with the colon outside (`**Verdict**: …`); the "
243
219
  "collector also reads `**Verdict:** …` and `- Verdict: …` as the same field.\n\n"
244
220
  )
245
- rows.append(response.replace("<finding-id>", findings[0].finding_id))
221
+ rows.append(ADVERSARIAL_RESPONSE.replace("<finding-id>", findings[0].finding_id))
246
222
  rows.append("\n")
247
223
  if len(findings) > 1:
248
224
  rows.append(f"\n### {findings[1].finding_id}\n**Verdict**: ...\n")
@@ -68,6 +68,9 @@ _ACCEPTANCE_BATCH_SCHEMA = {
68
68
  "candidateId": {"type": "string", "pattern": "\\S"},
69
69
  "verdict": {"enum": ["confirmed", "downgraded"]},
70
70
  "statement": {"type": "string"},
71
+ "severity": {"enum": ["critical", "major"]},
72
+ "originEvidence": {"type": "string"},
73
+ "votes": {"type": "object"},
71
74
  },
72
75
  },
73
76
  },
@@ -226,7 +229,10 @@ def _validate_dynamic_verifier_request(
226
229
  task_type: str,
227
230
  dispatch_kind: str,
228
231
  ) -> None:
229
- from .worker_prompt_policy import is_plan_verify_dispatch_kind
232
+ from .worker_prompt_policy import (
233
+ is_plan_verify_dispatch_kind,
234
+ is_reverify_dispatch_kind,
235
+ )
230
236
 
231
237
  try:
232
238
  duty_role = role_for_duty(duty_id)
@@ -247,11 +253,12 @@ def _validate_dynamic_verifier_request(
247
253
  f"dynamic verifier source role is not eligible: {source.role}"
248
254
  )
249
255
  if source is not None and source.role == "critic" and not (
250
- task_type == "implementation-planning"
251
- and is_plan_verify_dispatch_kind(dispatch_kind)
256
+ (task_type == "implementation-planning" and is_plan_verify_dispatch_kind(dispatch_kind))
257
+ or is_reverify_dispatch_kind(dispatch_kind)
252
258
  ):
253
259
  raise ExecutionManifestError(
254
- "critic source role is eligible only for planning verification"
260
+ "critic source role is eligible only for a plan-body or finding "
261
+ "re-verification round"
255
262
  )
256
263
 
257
264
 
@@ -144,6 +144,9 @@ from .write_policy import (
144
144
 
145
145
 
146
146
  MAX_WORKER_ATTEMPTS = 2
147
+ # 배치 시작 실패가 그 배치의 모든 디스패치 행에 남기는 사유의 머리. 토론 단계가 이
148
+ # 값으로 시작 실패와 재시도 루프 뒤 실패를 가른다.
149
+ CLI_BATCH_LAUNCH_FAILURE = "concurrent CLI batch launch failed"
147
150
  TERMINAL_DISPATCH_STATUSES = {"completed", "timeout", "error", "not-run"}
148
151
  # What the error log records for a wrapper the dispatcher timed out, matching
149
152
  # the value `team-contract` prescribes for a polling-cap termination.
@@ -770,7 +773,7 @@ def _abort_cli_batch_start(
770
773
  ) -> None:
771
774
  from .adapters.runtime.assembly import port_for
772
775
 
773
- reason = f"concurrent CLI batch launch failed: {error}"
776
+ reason = f"{CLI_BATCH_LAUNCH_FAILURE}: {error}"
774
777
  for handle, _attempt in active:
775
778
  runtime = handle.runtime_handle
776
779
  if runtime is None:
@@ -48,6 +48,7 @@ DUTY_ROLE_IDS = {
48
48
  "discovery-worker": "analyser",
49
49
  "diagnosis-worker": "analyser",
50
50
  "technical-verification-worker": "analyser",
51
+ "discussion-worker": "analyser",
51
52
  "scope-critic": "critic",
52
53
  "acceptance-critic": "critic",
53
54
  "direction-selection-worker": "designer",
@@ -146,7 +146,7 @@ def _render_manager_status(payload: dict) -> list[str]:
146
146
  ("Task ID", "taskId"), ("Progress mode", "progressMode")):
147
147
  rows.append(line(label, manifest.get(key)))
148
148
  summary = payload.get("summary") if isinstance(payload.get("summary"), dict) else {}
149
- for key in ("planned", "missing", "running", "done", "blocked"):
149
+ for key in ("planned", "missing", "running", "done", "blocked", "discussion"):
150
150
  rows.append(line(f"Summary {key}", summary.get(key)))
151
151
  children = payload.get("children") if isinstance(payload.get("children"), list) else []
152
152
  for index, child in enumerate(children, 1):
@@ -160,6 +160,32 @@ def _render_manager_status(payload: dict) -> list[str]:
160
160
  if key in child:
161
161
  value = child.get(key)
162
162
  rows.append(line(f"Child {index} {label}", ("yes" if value else "no") if isinstance(value, bool) else value))
163
+ if isinstance(child.get("discussion"), dict):
164
+ rows.extend(_render_discussion(index, child["discussion"]))
165
+ return rows
166
+
167
+
168
+ def _render_discussion(index: int, discussion: dict) -> list[str]:
169
+ prefix = f"Child {index} discussion"
170
+ rows = [line(f"{prefix} state", discussion.get("state"))]
171
+ if discussion.get("state") == "unknown":
172
+ return rows + [line(f"{prefix} reason", discussion.get("reason"))]
173
+ rows += [line(f"{prefix} phase", discussion.get("phase")),
174
+ line(f"{prefix} completed revision", discussion.get("completedRevision") or "none")]
175
+ questions = discussion.get("pendingQuestions") or {}
176
+ for audience, key in (("user", "forUser"), ("participant", "forParticipants")):
177
+ for number, row in enumerate(questions.get(key) or [], 1):
178
+ rows.append(line(f"{prefix} pending {audience} question {number}",
179
+ f"{row.get('recordId')}: {row.get('text')}"))
180
+ for number, row in enumerate(discussion.get("revisions") or [], 1):
181
+ rows.append(line(f"{prefix} revision {number}",
182
+ f"{row.get('revisionId')} — {row.get('changeReason') or row.get('reason') or 'first revision'}"))
183
+ follow_ups = discussion.get("followUps") or {}
184
+ for number, row in enumerate(follow_ups.get("items") or [], 1):
185
+ rows.append(line(f"{prefix} follow-up {number}",
186
+ f"{row.get('targetTaskKey')} {row.get('state')} uses {row.get('pinnedRevision') or '-'}"))
187
+ if follow_ups.get("state") == "unknown":
188
+ rows.append(line(f"{prefix} follow-ups", f"unknown — {follow_ups.get('reason')}"))
163
189
  return rows
164
190
 
165
191