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
@@ -24,9 +24,11 @@ from .convergence_engine import (
24
24
  validate_final_state,
25
25
  validate_working_state,
26
26
  )
27
- from .worker_prompt_policy import critic_assignment_ref
27
+ from .worker_prompt_policy import critic_assignment_ref, critic_duty_for_task_type
28
28
  from .convergence_critic_verify_prompt import (
29
29
  CriticVerifyPromptError,
30
+ acceptance_candidates,
31
+ acceptance_verify_prompt_body,
30
32
  critic_result_paths,
31
33
  critic_verify_gaps,
32
34
  critic_verify_prompt_body,
@@ -37,7 +39,9 @@ from .convergence_reverify_prompt import (
37
39
  reverify_prompt_body,
38
40
  )
39
41
  from .convergence_critic_prompt import (
42
+ acceptance_critic_prompt_body,
40
43
  analyser_results,
44
+ analysis_roster,
41
45
  covered_index,
42
46
  critic_prompt_body,
43
47
  )
@@ -73,6 +77,7 @@ from .execution_identity import ExecutionManifest
73
77
  from .execution_manifest import read_execution_manifest_view
74
78
  from .final_report_schema import load_named_schema, validate as validate_schema
75
79
  from .paths import RunRef
80
+ from .plan_items import CRITIC_WORKER_ID
76
81
  from .report_finalize import task_manifest_path
77
82
  from .verdict_blocks import FindingVote, VerdictBlockError, parse_finding_votes
78
83
  from .fixed_text import line
@@ -181,10 +186,10 @@ _EXAMPLES: dict[str, dict[str, Any]] = {
181
186
  "taskKey": "example/group/task",
182
187
  "config": {
183
188
  "enabled": True,
184
- "adversarial": False,
189
+ "adversarial": True,
185
190
  "maxRounds": 2,
186
191
  "effectiveMaxRounds": 2,
187
- "verificationMode": "lightweight",
192
+ "verificationMode": "full-reanalysis",
188
193
  },
189
194
  "workers": [
190
195
  {"workerId": "claude-worker", "audience": "analysis"},
@@ -412,10 +417,6 @@ def _parser() -> argparse.ArgumentParser:
412
417
  help="read one round's worker responses into the apply-round input",
413
418
  )
414
419
  collect.add_argument("--plan", type=Path, required=True)
415
- collect.add_argument("--mode", choices=("adversarial", "collaborative"),
416
- required=True,
417
- help="which reverify prompt this round used; the two "
418
- "carry different verdict vocabularies")
419
420
  collect.add_argument("--result", action="append", default=[],
420
421
  metavar="<worker>=<path>",
421
422
  help="one worker's reverify result file (repeatable)")
@@ -452,9 +453,8 @@ def _parser() -> argparse.ArgumentParser:
452
453
  description=(
453
454
  "Print the reverify instruction body for one `dispatches[]` row of "
454
455
  "a round plan. The lead writes it verbatim to the file the prompt "
455
- "materializer's `--instruction` takes. It carries the round's "
456
- "mandate (adversarial or collaborative, from the grouping's "
457
- "config), every planned finding with its summary, origin worker, "
456
+ "materializer's `--instruction` takes. It carries the adversarial "
457
+ "mandate, every planned finding with its summary, origin worker, "
458
458
  "cited-evidence line, the origin worker's result file and item id, "
459
459
  "and the origin audit sidecar the verifier may open, plus the "
460
460
  "response format the collector parses. Nothing here is hand-written."
@@ -485,7 +485,9 @@ def _parser() -> argparse.ArgumentParser:
485
485
  critic_verify_prompt.add_argument("--run-manifest", type=Path, required=True)
486
486
  critic_verify_prompt.add_argument(
487
487
  "--gaps", type=Path, required=True,
488
- help="coverage batch JSON (schemaVersion 1.0, mode coverage, gaps[])",
488
+ help="critic batch JSON: mode coverage with gaps[], or mode "
489
+ "acceptance-devils-advocate with candidates[] (every analyser "
490
+ "verifies every candidate)",
489
491
  )
490
492
  critic_verify_prompt.add_argument("--worker", required=True)
491
493
 
@@ -556,6 +558,28 @@ def _parser() -> argparse.ArgumentParser:
556
558
  apply_acceptance.add_argument("--work-state", type=Path, required=True)
557
559
  apply_acceptance.add_argument("--results", type=Path, required=True)
558
560
 
561
+ collect_acceptance = subparsers.add_parser(
562
+ "collect-acceptance-votes",
563
+ help="derive confirmed/downgraded from the analysers' acceptance verdicts",
564
+ description=(
565
+ "Read every analyser's `critic-verify` response to the acceptance "
566
+ "candidates and write the `apply-acceptance-critic --results` batch: "
567
+ "a candidate any analyser answers SURVIVES or SURVIVES-WITH-CAVEAT is "
568
+ "confirmed, every other candidate is downgraded to a Residual Risk. "
569
+ "Each analyser must answer every candidate."
570
+ ),
571
+ )
572
+ collect_acceptance.add_argument("--run-manifest", type=Path, required=True)
573
+ collect_acceptance.add_argument(
574
+ "--candidates", type=Path, required=True,
575
+ help="the acceptance batch given to critic-verify-prompt (no verdicts yet)",
576
+ )
577
+ collect_acceptance.add_argument(
578
+ "--result", action="append", default=[], metavar="<worker>=<path>",
579
+ help="one analyser's critic-verify result file (repeatable)",
580
+ )
581
+ collect_acceptance.add_argument("--output", type=Path, required=True)
582
+
559
583
  finalize = subparsers.add_parser("finalize", help="write the public final state")
560
584
  finalize.add_argument("--work-state", type=Path, required=True)
561
585
  finalize.add_argument("--output", type=Path, required=True)
@@ -1371,7 +1395,7 @@ def _planned_finding_ids(plan: dict[str, Any]) -> dict[str, list[str]]:
1371
1395
 
1372
1396
 
1373
1397
  def _worker_votes(
1374
- raw_results: list[str], planned: dict[str, list[str]], adversarial: bool
1398
+ raw_results: list[str], planned: dict[str, list[str]]
1375
1399
  ) -> dict[str, dict[str, FindingVote]]:
1376
1400
  votes: dict[str, dict[str, FindingVote]] = {}
1377
1401
  for raw in raw_results:
@@ -1380,9 +1404,7 @@ def _worker_votes(
1380
1404
  raise ConvergenceContractError(
1381
1405
  f"`{worker}` returned a result but the round plan did not dispatch it"
1382
1406
  )
1383
- parsed = parse_finding_votes(
1384
- Path(path).read_text(encoding="utf-8"), adversarial=adversarial
1385
- )
1407
+ parsed = parse_finding_votes(Path(path).read_text(encoding="utf-8"))
1386
1408
  assigned = set(planned[worker])
1387
1409
  missing = sorted(assigned - set(parsed))
1388
1410
  if missing:
@@ -1411,7 +1433,7 @@ def _collect_results(args: argparse.Namespace) -> tuple[str, Path]:
1411
1433
  "a legacy v1 roster has no attempt ledger to read"
1412
1434
  )
1413
1435
  dispatches = _dispatch_rows_from_manifest(plan, manifest, args.run_manifest)
1414
- votes = _worker_votes(args.result, planned, args.mode == "adversarial")
1436
+ votes = _worker_votes(args.result, planned)
1415
1437
  returned_without_completion = sorted(
1416
1438
  worker for worker in votes
1417
1439
  if next((row["status"] for row in dispatches if row["worker"] == worker), None)
@@ -1606,15 +1628,26 @@ def _group_worker(manifest: Mapping[str, Any], worker_id: str) -> dict[str, str]
1606
1628
  "sourceRoleExecutionRef": str(execution["roleExecutionRef"])}
1607
1629
 
1608
1630
 
1631
+ def _critic_tie_breaker(manifest: Mapping[str, Any]) -> list[dict[str, str]]:
1632
+ """로스터에 크리틱이 있으면 동률 finding 을 판정할 `critic` 워커 행 하나."""
1633
+ executions = manifest.get("roleExecutions") if isinstance(manifest.get("roleExecutions"), list) else []
1634
+ critics = [row for row in executions
1635
+ if isinstance(row, Mapping) and row.get("role") == "critic"]
1636
+ if not critics:
1637
+ return []
1638
+ if len(critics) != 1 or not critics[0].get("participantRef") or not critics[0].get("roleExecutionRef"):
1639
+ raise ConvergenceContractError("no single canonical critic role execution")
1640
+ return [{"workerId": CRITIC_WORKER_ID, "audience": "critic",
1641
+ "participantRef": str(critics[0]["participantRef"]),
1642
+ "sourceRoleExecutionRef": str(critics[0]["roleExecutionRef"])}]
1643
+
1644
+
1609
1645
  def _convergence_config(authority: RunArtifactAuthority) -> Mapping[str, Any]:
1610
1646
  """convergence 설정의 정본은 task-manifest.json 이다.
1611
1647
 
1612
1648
  `render.render_task_manifest` 가 `_build_convergence_block` 결과를 거기에만
1613
- 쓴다. run manifest 에는 `convergenceStatePath` 뿐이라, 예전처럼 run manifest 를
1614
- 읽으면 키가 없어 `adversarial=False` / `verificationMode="lightweight"` 기본값으로
1615
- 조용히 떨어졌다. 그 강등은 무증상이다 — `convergence_engine._parse_groups` 가
1616
- 적대 모드가 꺼진 다중 출처 그룹을 큐에 넣지 않고 즉시 `full-consensus` 로
1617
- 확정하므로, 세 워커의 처방이 서로 달라도 합의로 기록된다.
1649
+ 쓴다. run manifest 에는 `convergenceStatePath` 뿐이라 거기서는 라운드 상한을
1650
+ 읽을 수 없다.
1618
1651
 
1619
1652
  설정이 없으면 기본값으로 떨어지지 않고 실패한다. 여기서 조용히 넘어가면
1620
1653
  검증 자체가 사라지고 산출물은 정상으로 읽힌다.
@@ -1641,6 +1674,18 @@ def _critic_prompt(args: argparse.Namespace) -> str:
1641
1674
  f"`okstra convergence prepare-groups` first: {groups_path}"
1642
1675
  )
1643
1676
  groups = load_owned_json_object(groups_path)
1677
+ if critic_duty_for_task_type(authority.task_type) == "acceptance-critic":
1678
+ return acceptance_critic_prompt_body(
1679
+ groups,
1680
+ analysis_packet_path=_manifest_authority_string(
1681
+ authority.payload, "analysisPacketPath"
1682
+ ),
1683
+ results=analyser_results(
1684
+ groups,
1685
+ project_root=authority.project_root,
1686
+ run_dir=authority.run_dir,
1687
+ ),
1688
+ )
1644
1689
  return critic_prompt_body(
1645
1690
  groups,
1646
1691
  analysis_packet_path=_manifest_authority_string(
@@ -1667,16 +1712,17 @@ def _prepare_groups(args: argparse.Namespace) -> tuple[str, Path]:
1667
1712
  )
1668
1713
  config = _convergence_config(authority)
1669
1714
  workers = [_group_worker(manifest, worker) for worker in worker_ids]
1715
+ workers.extend(_critic_tie_breaker(manifest))
1670
1716
  source_refs = [worker["sourceRoleExecutionRef"] for worker in workers]
1671
1717
  if len(source_refs) != len(set(source_refs)):
1672
1718
  raise ConvergenceContractError("duplicate sourceRoleExecutionRef")
1673
1719
  payload = {"schemaVersion": "2.0", "executionIdentityVersion": 2,
1674
1720
  "taskKey": manifest.get("taskKey"), "runManifestPath": manifest.get("runManifestPath"),
1675
1721
  "config": {"enabled": bool(config.get("enabled", True)),
1676
- "adversarial": bool(config.get("adversarial", False)),
1722
+ "adversarial": True,
1677
1723
  "maxRounds": int(config.get("maxRounds", 2)),
1678
1724
  "effectiveMaxRounds": int(config.get("effectiveMaxRounds", config.get("maxRounds", 2))),
1679
- "verificationMode": str(config.get("verificationMode", "lightweight"))},
1725
+ "verificationMode": "full-reanalysis"},
1680
1726
  "workers": workers, "groups": groups}
1681
1727
  schema_errors = validate_schema(
1682
1728
  payload, load_named_schema("convergence-groups-v2.0.schema.json")
@@ -1705,7 +1751,6 @@ def _reverify_prompt(args: argparse.Namespace) -> str:
1705
1751
  )
1706
1752
  groups = load_owned_json_object(groups_path)
1707
1753
  plan = load_owned_json_object(args.plan)
1708
- config = groups.get("config") if isinstance(groups.get("config"), Mapping) else {}
1709
1754
  round_number = plan.get("round")
1710
1755
  if not isinstance(round_number, int) or round_number < 1:
1711
1756
  raise ConvergenceContractError("round plan has no positive `round`")
@@ -1717,7 +1762,6 @@ def _reverify_prompt(args: argparse.Namespace) -> str:
1717
1762
  return reverify_prompt_body(
1718
1763
  task_key=_manifest_authority_string(authority.payload, "taskKey"),
1719
1764
  round_number=round_number,
1720
- adversarial=bool(config.get("adversarial", False)),
1721
1765
  findings=reverify_findings(
1722
1766
  groups, plan, args.worker,
1723
1767
  project_root=authority.project_root, run_dir=authority.run_dir,
@@ -1740,11 +1784,11 @@ def _critic_verify_prompt(args: argparse.Namespace) -> str:
1740
1784
  batch = load_owned_json_object(args.gaps)
1741
1785
  task_key = _manifest_authority_string(authority.payload, "taskKey")
1742
1786
  if batch.get("taskKey") != task_key:
1743
- raise ConvergenceContractError("coverage batch taskKey does not match the run manifest")
1744
- if batch.get("mode") != "coverage":
1787
+ raise ConvergenceContractError("critic batch taskKey does not match the run manifest")
1788
+ mode = batch.get("mode")
1789
+ if mode not in {"coverage", "acceptance-devils-advocate"}:
1745
1790
  raise ConvergenceContractError(
1746
- "critic-verify-prompt accepts coverage mode only; acceptance candidates "
1747
- "use confirm-or-downgrade"
1791
+ f"critic batch mode must be coverage or acceptance-devils-advocate, got {mode!r}"
1748
1792
  )
1749
1793
  critic_ref = critic_assignment_ref(authority.task_type)
1750
1794
  assignments = authority.payload.get("invocationAssignments")
@@ -1759,6 +1803,14 @@ def _critic_verify_prompt(args: argparse.Namespace) -> str:
1759
1803
  project_root=authority.project_root, run_dir=authority.run_dir,
1760
1804
  recorded_result=_settled_critic_result(authority.payload),
1761
1805
  )
1806
+ if mode == "acceptance-devils-advocate":
1807
+ return acceptance_verify_prompt_body(
1808
+ task_key=task_key,
1809
+ critic_worker=f"{provider}-critic",
1810
+ critic_result_path=result_path,
1811
+ critic_audit_path=audit_path,
1812
+ candidates=acceptance_candidates(batch, groups, args.worker),
1813
+ )
1762
1814
  return critic_verify_prompt_body(
1763
1815
  task_key=task_key,
1764
1816
  critic_worker=f"{provider}-critic",
@@ -1768,6 +1820,67 @@ def _critic_verify_prompt(args: argparse.Namespace) -> str:
1768
1820
  )
1769
1821
 
1770
1822
 
1823
+ def _collect_acceptance_votes(args: argparse.Namespace) -> tuple[str, Path]:
1824
+ """분석자 응답에서 후보마다 confirmed / downgraded 를 정해 반영 배치를 쓴다.
1825
+
1826
+ 한 분석자라도 SURVIVES(또는 SURVIVES-WITH-CAVEAT)면 확인된 blocker 다
1827
+ (convergence.md §"Verification — confirm-or-downgrade"). 아무도 살리지
1828
+ 않으면 Residual Risk 로 내린다 — 후보는 사라지지 않는다.
1829
+ """
1830
+ authority = validated_run_authority(args.run_manifest)
1831
+ groups_path = authority.run_dir / "state" / _canonical_run_artifact_name(
1832
+ "convergence-groups", authority.task_type, authority.state_sequence
1833
+ )
1834
+ groups = load_owned_json_object(groups_path)
1835
+ batch = load_owned_json_object(args.candidates)
1836
+ if batch.get("taskKey") != _manifest_authority_string(authority.payload, "taskKey"):
1837
+ raise ConvergenceContractError("acceptance batch taskKey does not match the run manifest")
1838
+ if batch.get("mode") != "acceptance-devils-advocate":
1839
+ raise ConvergenceContractError("acceptance batch mode must be acceptance-devils-advocate")
1840
+ if not args.result:
1841
+ raise ConvergenceContractError("pass at least one analyser --result")
1842
+ roster = analysis_roster(groups)
1843
+ votes: dict[str, dict[str, FindingVote]] = {}
1844
+ for raw in args.result:
1845
+ worker, path = _split_pair(raw, "--result")
1846
+ if worker.removesuffix("-worker") in roster and worker not in roster:
1847
+ worker = worker.removesuffix("-worker")
1848
+ if worker not in roster:
1849
+ raise ConvergenceContractError(
1850
+ f"`{worker}` is not an analysis worker of this run; roster: {', '.join(roster)}"
1851
+ )
1852
+ votes[worker] = parse_finding_votes(Path(path).read_text(encoding="utf-8"))
1853
+ candidates: list[dict[str, Any]] = []
1854
+ for candidate in acceptance_candidates(batch, groups, next(iter(votes))):
1855
+ row: dict[str, Any] = {}
1856
+ for worker, parsed in votes.items():
1857
+ vote = parsed.get(candidate.candidate_id)
1858
+ if vote is None:
1859
+ raise ConvergenceContractError(
1860
+ f"`{worker}` returned no verdict for {candidate.candidate_id}"
1861
+ )
1862
+ row[worker] = {"verdict": vote.verdict, "explanation": vote.explanation}
1863
+ confirmed = any(v["verdict"] in {"agree", "supplement"} for v in row.values())
1864
+ candidates.append({
1865
+ "candidateId": candidate.candidate_id,
1866
+ "statement": candidate.statement,
1867
+ **({"severity": candidate.severity} if candidate.severity else {}),
1868
+ "originEvidence": candidate.origin_evidence,
1869
+ "verdict": "confirmed" if confirmed else "downgraded",
1870
+ "votes": row,
1871
+ })
1872
+ write_json_atomic(args.output, {
1873
+ "schemaVersion": "1.0",
1874
+ "taskKey": batch["taskKey"],
1875
+ "mode": "acceptance-devils-advocate",
1876
+ "provider": batch.get("provider"),
1877
+ "modelExecutionValue": batch.get("modelExecutionValue"),
1878
+ "candidates": candidates,
1879
+ })
1880
+ load_acceptance_critic_batch(args.output)
1881
+ return "collected", args.output
1882
+
1883
+
1771
1884
  def _worker_paths(raw_pairs: list[str], label: str) -> dict[str, Path]:
1772
1885
  paths: dict[str, Path] = {}
1773
1886
  for raw in raw_pairs:
@@ -1880,6 +1993,7 @@ def _execute(args: argparse.Namespace) -> tuple[Any, ...]:
1880
1993
  "apply-round": _apply_round,
1881
1994
  "apply-critic-gaps": _apply_critic_gaps,
1882
1995
  "apply-acceptance-critic": _apply_acceptance_critic,
1996
+ "collect-acceptance-votes": _collect_acceptance_votes,
1883
1997
  "finalize": _finalize,
1884
1998
  "skip": _skip,
1885
1999
  "validate": _validate,
@@ -288,6 +288,63 @@ def critic_prompt_body(
288
288
  return "".join(rows)
289
289
 
290
290
 
291
+ _ACCEPTANCE_MANDATE = """
292
+ The delivered work is about to be judged for acceptance. Your ONLY job is to find
293
+ reasons it should NOT be accepted — surface candidate acceptance BLOCKERS the
294
+ verifiers may have missed:
295
+ - requirements / acceptance points with no covering evidence,
296
+ - DB / IO / SQL changes lacking real-execution evidence,
297
+ - regressions or broken error paths,
298
+ - scope / contract violations.
299
+ A finding that would not stop the release is not a blocker — say so, and it is
300
+ recorded as a conditional-acceptance condition instead. Do NOT restate an existing
301
+ Acceptance Blocker. If you find none, write `No candidate blockers.`
302
+
303
+ Write each candidate as its own section, numbered from AC-001:
304
+
305
+ ### [AC-001] <one-line statement>
306
+ **Severity**: critical | major
307
+ **Evidence**: <file:line / log / test output you executed or read>
308
+ """.strip()
309
+
310
+
311
+ def acceptance_critic_prompt_body(
312
+ groups: Mapping[str, Any],
313
+ *,
314
+ analysis_packet_path: str,
315
+ results: Sequence[AnalyserResult],
316
+ ) -> str:
317
+ """final-verification acceptance critic 지시문 본문. 같은 입력이면 같은 바이트.
318
+
319
+ final-verification 프롬프트 본문은 96줄 상한을 받으므로 Round 0 finding 목록은
320
+ 싣지 않는다 — 분석자 결과 파일 경로가 그 자리를 대신한다.
321
+ """
322
+ task_key = _nonempty_string(groups.get("taskKey"))
323
+ if not task_key:
324
+ raise CriticPromptError("convergence groups carry no taskKey")
325
+ packet = _nonempty_string(analysis_packet_path)
326
+ if not packet.endswith("analysis-packet.md"):
327
+ raise CriticPromptError(
328
+ "run manifest analysisPacketPath must name analysis-packet.md; "
329
+ f"found `{analysis_packet_path}`"
330
+ )
331
+ rows: list[str] = [
332
+ f"**Prompt Delivery Mode:** {PROMPT_DELIVERY_MODE}\n",
333
+ "\n",
334
+ f"# Acceptance critic — {task_key}\n",
335
+ "\n",
336
+ "## Inputs\n",
337
+ f"- Primary analysis packet: `{packet}`\n",
338
+ "\n",
339
+ "## Mandate\n",
340
+ "\n",
341
+ _ACCEPTANCE_MANDATE,
342
+ "\n\n",
343
+ ]
344
+ rows.extend(_analyser_rows(results))
345
+ return "".join(rows)
346
+
347
+
291
348
  def _round0_rows(groups: Mapping[str, Any]) -> list[str]:
292
349
  findings = [
293
350
  group for group in groups.get("groups") or [] if isinstance(group, Mapping)
@@ -237,3 +237,109 @@ def critic_verify_prompt_body(
237
237
  if len(gaps) > 1:
238
238
  rows.append(f"\n### {gaps[1].gap_id}\n**Verdict**: ...\n")
239
239
  return "".join(rows)
240
+
241
+
242
+ @dataclass(frozen=True)
243
+ class AcceptanceCandidate:
244
+ """acceptance critic 이 낸 후보 blocker 하나."""
245
+
246
+ candidate_id: str
247
+ statement: str
248
+ severity: str
249
+ origin_evidence: str
250
+
251
+
252
+ def acceptance_candidates(
253
+ batch: Mapping[str, Any],
254
+ groups: Mapping[str, Any],
255
+ worker_id: str,
256
+ ) -> list[AcceptanceCandidate]:
257
+ """분석자 한 명이 검증할 후보 — 계약상 모든 분석자가 모든 후보를 본다."""
258
+ roster = analysis_roster(groups)
259
+ if worker_id not in roster and worker_id.removesuffix("-worker") in roster:
260
+ worker_id = worker_id.removesuffix("-worker")
261
+ if worker_id not in roster:
262
+ raise CriticVerifyPromptError(
263
+ f"`{worker_id}` is not an analysis worker of this run; roster: "
264
+ f"{', '.join(roster) or 'none'}"
265
+ )
266
+ raw = batch.get("candidates")
267
+ if not isinstance(raw, list) or not raw:
268
+ raise CriticVerifyPromptError("acceptance batch has no candidates array")
269
+ candidates: list[AcceptanceCandidate] = []
270
+ seen: set[str] = set()
271
+ for row in raw:
272
+ if not isinstance(row, Mapping):
273
+ raise CriticVerifyPromptError("acceptance candidate must be an object")
274
+ candidate_id = _nonempty_string(row.get("candidateId"))
275
+ if not candidate_id:
276
+ raise CriticVerifyPromptError("acceptance candidate has no candidateId")
277
+ if candidate_id in seen:
278
+ raise CriticVerifyPromptError(f"duplicate candidateId: {candidate_id}")
279
+ seen.add(candidate_id)
280
+ candidates.append(AcceptanceCandidate(
281
+ candidate_id=candidate_id,
282
+ statement=_nonempty_string(row.get("statement")),
283
+ severity=_nonempty_string(row.get("severity")),
284
+ origin_evidence=_nonempty_string(row.get("originEvidence")),
285
+ ))
286
+ return candidates
287
+
288
+
289
+ _ACCEPTANCE_EVIDENCE_ACCESS = """Each candidate claims the delivered work should NOT be accepted. The complete
290
+ claim is the critic's own item: before judging, open the `**Origin item**` file at
291
+ the section whose heading names the candidate id, and the `**Origin audit sidecar**`, and
292
+ re-run or re-read what they cite. SURVIVES means you reproduced the problem or
293
+ found evidence that supports it; the candidate then becomes an Acceptance Blocker.
294
+ A candidate no analyser lets survive is downgraded to a Residual Risk, never
295
+ dropped."""
296
+
297
+
298
+ def acceptance_verify_prompt_body(
299
+ *,
300
+ task_key: str,
301
+ critic_worker: str,
302
+ critic_result_path: str,
303
+ critic_audit_path: str,
304
+ candidates: Sequence[AcceptanceCandidate],
305
+ ) -> str:
306
+ """acceptance 후보 확인·강등 지시문 본문. 같은 입력이면 같은 바이트를 낸다."""
307
+ if not _nonempty_string(task_key):
308
+ raise CriticVerifyPromptError("run manifest carries no taskKey")
309
+ if not candidates:
310
+ raise CriticVerifyPromptError("no acceptance candidates to verify")
311
+ rows = [
312
+ "## Instructions\n\n",
313
+ f"{RENDERED_BY_LINE}\n\n",
314
+ f"Perform ADVERSARIAL acceptance-candidate verification for {task_key} "
315
+ f"(dispatch kind `{CRITIC_VERIFY_DISPATCH_KIND}`, one round).\n\n",
316
+ ADVERSARIAL_MANDATE, "\n\n",
317
+ _ACCEPTANCE_EVIDENCE_ACCESS, "\n\n",
318
+ "## Findings to verify\n",
319
+ ]
320
+ for candidate in candidates:
321
+ rows.append(
322
+ f"\n### {candidate.candidate_id}: {candidate.statement or '(no statement)'}\n"
323
+ )
324
+ rows.append(f"**Origin**: {critic_worker}\n")
325
+ rows.append(f"**Severity**: {candidate.severity or '(none recorded)'}\n")
326
+ rows.append(
327
+ f"**Cited evidence**: {candidate.origin_evidence or '(none recorded)'}\n"
328
+ )
329
+ # 헤딩 모양은 critic 이 정한다(`### [AC-001]`, `### AC-001 — …`); id 만 가리킨다.
330
+ rows.append(
331
+ f"**Origin item**: `{critic_result_path}` — the section headed "
332
+ f"`{candidate.candidate_id}`\n"
333
+ )
334
+ rows.append(f"**Origin audit sidecar**: `{critic_audit_path}`\n")
335
+ rows.append("\n## Response format\n\n")
336
+ rows.append(
337
+ "One block per candidate, headed by the candidate id at exactly three hashes. "
338
+ "Field labels are bold with the colon outside (`**Verdict**: …`); the "
339
+ "collector also reads `**Verdict:** …` and `- Verdict: …` as the same field.\n\n"
340
+ )
341
+ rows.append(ADVERSARIAL_RESPONSE.replace("<finding-id>", candidates[0].candidate_id))
342
+ rows.append("\n")
343
+ if len(candidates) > 1:
344
+ rows.append(f"\n### {candidates[1].candidate_id}\n**Verdict**: ...\n")
345
+ return "".join(rows)