okstra 0.179.2 → 0.183.2

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 (258) hide show
  1. package/README.md +1 -1
  2. package/dist/cli-registry.mjs +30 -2
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/execute/incremental-carry.mjs +9 -8
  5. package/dist/commands/execute/incremental-carry.mjs.map +1 -1
  6. package/dist/commands/execute/plan-verify.mjs +3 -1
  7. package/dist/commands/execute/plan-verify.mjs.map +1 -1
  8. package/dist/commands/execute/render-bundle.d.mts +4 -2
  9. package/dist/commands/execute/render-bundle.mjs +46 -5
  10. package/dist/commands/execute/render-bundle.mjs.map +1 -1
  11. package/dist/commands/execute/run.mjs +11 -3
  12. package/dist/commands/execute/run.mjs.map +1 -1
  13. package/dist/commands/inspect/model-io.d.mts +1 -0
  14. package/dist/commands/inspect/model-io.mjs +25 -0
  15. package/dist/commands/inspect/model-io.mjs.map +1 -0
  16. package/dist/commands/inspect/stage-map.mjs +29 -8
  17. package/dist/commands/inspect/stage-map.mjs.map +1 -1
  18. package/dist/commands/inspect/task-list.mjs +52 -6
  19. package/dist/commands/inspect/task-list.mjs.map +1 -1
  20. package/dist/commands/inspect/user-response.mjs +14 -4
  21. package/dist/commands/inspect/user-response.mjs.map +1 -1
  22. package/dist/commands/lifecycle/check-project.d.mts +1 -0
  23. package/dist/commands/lifecycle/check-project.mjs +69 -50
  24. package/dist/commands/lifecycle/check-project.mjs.map +1 -1
  25. package/dist/commands/lifecycle/contract-check.d.mts +1 -0
  26. package/dist/commands/lifecycle/contract-check.mjs +18 -0
  27. package/dist/commands/lifecycle/contract-check.mjs.map +1 -0
  28. package/dist/commands/lifecycle/preflight.mjs +154 -51
  29. package/dist/commands/lifecycle/preflight.mjs.map +1 -1
  30. package/dist/commands/pr/pr.d.mts +1 -0
  31. package/dist/commands/pr/pr.mjs +19 -1
  32. package/dist/commands/pr/pr.mjs.map +1 -1
  33. package/dist/commands/report/agent-activity.mjs +2 -2
  34. package/dist/commands/report/approval-decision.d.mts +1 -0
  35. package/dist/commands/report/approval-decision.mjs +21 -0
  36. package/dist/commands/report/approval-decision.mjs.map +1 -0
  37. package/dist/commands/report/design-snapshot.d.mts +1 -0
  38. package/dist/commands/report/design-snapshot.mjs +19 -0
  39. package/dist/commands/report/design-snapshot.mjs.map +1 -0
  40. package/dist/commands/report/translate.mjs +3 -0
  41. package/dist/commands/report/translate.mjs.map +1 -1
  42. package/dist/lib/host-registry-client.mjs +13 -9
  43. package/dist/lib/host-registry-client.mjs.map +1 -1
  44. package/docs/architecture/storage-model.md +1 -1
  45. package/docs/architecture.md +21 -10
  46. package/docs/cli.md +36 -20
  47. package/docs/container.md +6 -4
  48. package/docs/contributor-change-matrix.md +1 -1
  49. package/docs/for-ai/README.md +2 -2
  50. package/docs/for-ai/skills/okstra-brief-gen.md +5 -3
  51. package/docs/for-ai/skills/okstra-code-review.md +4 -4
  52. package/docs/for-ai/skills/okstra-container-build.md +20 -17
  53. package/docs/for-ai/skills/okstra-inspect.md +20 -23
  54. package/docs/for-ai/skills/okstra-manager.md +19 -18
  55. package/docs/for-ai/skills/okstra-memory.md +2 -2
  56. package/docs/for-ai/skills/okstra-pr-gen.md +3 -3
  57. package/docs/for-ai/skills/okstra-rollup.md +14 -13
  58. package/docs/for-ai/skills/okstra-run.md +7 -3
  59. package/docs/for-ai/skills/okstra-schedule-gen.md +15 -18
  60. package/docs/for-ai/skills/okstra-setup.md +7 -7
  61. package/docs/for-ai/skills/okstra-usage.md +5 -4
  62. package/docs/for-ai/skills/okstra-user-response.md +50 -32
  63. package/docs/project-structure-overview.md +43 -31
  64. package/docs/task-process/implementation-planning.md +2 -2
  65. package/docs/task-process/implementation.md +7 -4
  66. package/package.json +1 -1
  67. package/runtime/BUILD.json +2 -2
  68. package/runtime/agents/workers/claude-worker.md +4 -4
  69. package/runtime/agents/workers/report-writer-worker.md +15 -164
  70. package/runtime/agents/workers/translator-worker.md +5 -13
  71. package/runtime/bin/okstra-error-log.py +51 -11
  72. package/runtime/bin/okstra-report-translate.py +210 -23
  73. package/runtime/prompts/host-orchestration/implementation.md +1 -1
  74. package/runtime/prompts/launch.template.md +9 -12
  75. package/runtime/prompts/lead/adapters/cmux.md +1 -1
  76. package/runtime/prompts/lead/context-loader.md +41 -141
  77. package/runtime/prompts/lead/convergence.md +10 -8
  78. package/runtime/prompts/lead/okstra-lead-contract.md +40 -49
  79. package/runtime/prompts/lead/plan-body-verification.md +217 -23
  80. package/runtime/prompts/lead/report-writer.md +81 -421
  81. package/runtime/prompts/lead/team-contract.md +9 -54
  82. package/runtime/prompts/profiles/_clarification-recommendation.md +5 -4
  83. package/runtime/prompts/profiles/_coding-conventions-preflight.md +3 -2
  84. package/runtime/prompts/profiles/_common-contract.md +4 -4
  85. package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
  86. package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
  87. package/runtime/prompts/profiles/_implementation-verifier.md +3 -3
  88. package/runtime/prompts/profiles/change-impact-analysis.md +1 -1
  89. package/runtime/prompts/profiles/error-analysis.md +1 -1
  90. package/runtime/prompts/profiles/feature-analysis.md +1 -1
  91. package/runtime/prompts/profiles/final-verification.md +1 -1
  92. package/runtime/prompts/profiles/implementation-planning.md +20 -12
  93. package/runtime/prompts/profiles/improvement-discovery.md +1 -1
  94. package/runtime/prompts/profiles/project-analysis.md +1 -1
  95. package/runtime/prompts/profiles/release-handoff.md +0 -1
  96. package/runtime/prompts/profiles/requirements-discovery.md +1 -1
  97. package/runtime/prompts/wizard/prompts.ko.json +0 -1
  98. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +69 -17
  99. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +13 -4
  100. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +8 -2
  101. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +2 -2
  102. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +50 -5
  103. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +1 -1
  104. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +2 -2
  105. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +66 -5
  106. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +70 -2
  107. package/runtime/python/okstra_ctl/agent_activity.py +141 -38
  108. package/runtime/python/okstra_ctl/agent_invocation.py +19 -6
  109. package/runtime/python/okstra_ctl/agent_prompt_cli.py +70 -23
  110. package/runtime/python/okstra_ctl/analysis_inputs.py +5 -4
  111. package/runtime/python/okstra_ctl/analysis_packet.py +124 -3
  112. package/runtime/python/okstra_ctl/approval_decisions.py +328 -0
  113. package/runtime/python/okstra_ctl/attempt_evidence.py +2 -2
  114. package/runtime/python/okstra_ctl/backfill.py +13 -10
  115. package/runtime/python/okstra_ctl/batch.py +2 -4
  116. package/runtime/python/okstra_ctl/build_tools.py +6 -3
  117. package/runtime/python/okstra_ctl/claim_reproduction.py +101 -0
  118. package/runtime/python/okstra_ctl/clarification_items.py +27 -13
  119. package/runtime/python/okstra_ctl/cmux.py +130 -52
  120. package/runtime/python/okstra_ctl/code_review_target.py +34 -8
  121. package/runtime/python/okstra_ctl/consumers.py +5 -4
  122. package/runtime/python/okstra_ctl/container.py +103 -8
  123. package/runtime/python/okstra_ctl/context_cost.py +2 -1
  124. package/runtime/python/okstra_ctl/contract_graph.py +497 -0
  125. package/runtime/python/okstra_ctl/contract_graph_cli.py +62 -0
  126. package/runtime/python/okstra_ctl/convergence.py +338 -17
  127. package/runtime/python/okstra_ctl/convergence_engine.py +10 -18
  128. package/runtime/python/okstra_ctl/convergence_provenance.py +58 -8
  129. package/runtime/python/okstra_ctl/convergence_store.py +55 -34
  130. package/runtime/python/okstra_ctl/design_prep.py +7 -4
  131. package/runtime/python/okstra_ctl/design_snapshot.py +134 -0
  132. package/runtime/python/okstra_ctl/dispatch_core.py +97 -69
  133. package/runtime/python/okstra_ctl/dispatch_state.py +163 -63
  134. package/runtime/python/okstra_ctl/doctor.py +6 -3
  135. package/runtime/python/okstra_ctl/domain/worker_presentation.py +70 -9
  136. package/runtime/python/okstra_ctl/entrypoints/hosts.py +16 -30
  137. package/runtime/python/okstra_ctl/error_log_write.py +35 -30
  138. package/runtime/python/okstra_ctl/error_report.py +26 -1
  139. package/runtime/python/okstra_ctl/error_zip.py +27 -5
  140. package/runtime/python/okstra_ctl/execution_identity.py +3 -2
  141. package/runtime/python/okstra_ctl/execution_manifest.py +7 -4
  142. package/runtime/python/okstra_ctl/execution_mutation_audit.py +6 -2
  143. package/runtime/python/okstra_ctl/final_report_schema.py +26 -17
  144. package/runtime/python/okstra_ctl/fix_cycles.py +2 -2
  145. package/runtime/python/okstra_ctl/fixed_text.py +39 -0
  146. package/runtime/python/okstra_ctl/git_reconcile.py +41 -9
  147. package/runtime/python/okstra_ctl/handoff.py +5 -4
  148. package/runtime/python/okstra_ctl/i18n.py +4 -2
  149. package/runtime/python/okstra_ctl/implementation_direction.py +22 -14
  150. package/runtime/python/okstra_ctl/implementation_outcome.py +4 -7
  151. package/runtime/python/okstra_ctl/incremental_carry.py +130 -17
  152. package/runtime/python/okstra_ctl/incremental_scope.py +12 -4
  153. package/runtime/python/okstra_ctl/index.py +8 -11
  154. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +79 -7
  155. package/runtime/python/okstra_ctl/invocation.py +3 -6
  156. package/runtime/python/okstra_ctl/json_boundary.py +366 -0
  157. package/runtime/python/okstra_ctl/json_registry.py +10 -12
  158. package/runtime/python/okstra_ctl/jsonl.py +19 -2
  159. package/runtime/python/okstra_ctl/lead_events.py +33 -1
  160. package/runtime/python/okstra_ctl/listing.py +3 -3
  161. package/runtime/python/okstra_ctl/log_report.py +24 -2
  162. package/runtime/python/okstra_ctl/manager_cli.py +92 -7
  163. package/runtime/python/okstra_ctl/manager_store.py +12 -10
  164. package/runtime/python/okstra_ctl/material.py +5 -1
  165. package/runtime/python/okstra_ctl/migrate.py +29 -25
  166. package/runtime/python/okstra_ctl/model_cli.py +3 -15
  167. package/runtime/python/okstra_ctl/model_io_cli.py +1051 -0
  168. package/runtime/python/okstra_ctl/mutation_probe.py +13 -4
  169. package/runtime/python/okstra_ctl/pane_reclaim.py +3 -2
  170. package/runtime/python/okstra_ctl/path_hints.py +12 -0
  171. package/runtime/python/okstra_ctl/paths.py +21 -0
  172. package/runtime/python/okstra_ctl/plan_items.py +115 -4
  173. package/runtime/python/okstra_ctl/plan_items_cli.py +626 -34
  174. package/runtime/python/okstra_ctl/ports/worker_dispatch.py +2 -1
  175. package/runtime/python/okstra_ctl/pr_template.py +3 -2
  176. package/runtime/python/okstra_ctl/project_meta.py +5 -7
  177. package/runtime/python/okstra_ctl/recap.py +5 -4
  178. package/runtime/python/okstra_ctl/reconcile.py +21 -27
  179. package/runtime/python/okstra_ctl/registry/host_discovery.py +3 -2
  180. package/runtime/python/okstra_ctl/registry/provider_registry.py +3 -2
  181. package/runtime/python/okstra_ctl/render.py +74 -16
  182. package/runtime/python/okstra_ctl/render_final_report.py +10 -8
  183. package/runtime/python/okstra_ctl/report_assembly.py +509 -0
  184. package/runtime/python/okstra_ctl/report_contract.py +2 -1
  185. package/runtime/python/okstra_ctl/report_finalize.py +67 -32
  186. package/runtime/python/okstra_ctl/report_html/render.py +3 -2
  187. package/runtime/python/okstra_ctl/report_inputs.py +72 -0
  188. package/runtime/python/okstra_ctl/report_language.py +3 -2
  189. package/runtime/python/okstra_ctl/report_markdown.py +81 -8
  190. package/runtime/python/okstra_ctl/report_narrative.py +351 -0
  191. package/runtime/python/okstra_ctl/report_projections.py +265 -0
  192. package/runtime/python/okstra_ctl/report_synthesis_packet.py +518 -0
  193. package/runtime/python/okstra_ctl/report_views.py +3 -2
  194. package/runtime/python/okstra_ctl/rollup.py +65 -4
  195. package/runtime/python/okstra_ctl/run.py +184 -65
  196. package/runtime/python/okstra_ctl/run_audit.py +3 -2
  197. package/runtime/python/okstra_ctl/run_context.py +6 -9
  198. package/runtime/python/okstra_ctl/run_index_row.py +2 -8
  199. package/runtime/python/okstra_ctl/schedule_semantics.py +5 -2
  200. package/runtime/python/okstra_ctl/schema_excerpt.py +15 -8
  201. package/runtime/python/okstra_ctl/session_transcript.py +27 -1
  202. package/runtime/python/okstra_ctl/set_work_status.py +64 -38
  203. package/runtime/python/okstra_ctl/stage_fix_carry.py +8 -6
  204. package/runtime/python/okstra_ctl/stage_ledger.py +132 -18
  205. package/runtime/python/okstra_ctl/stage_map.py +96 -28
  206. package/runtime/python/okstra_ctl/stage_targets.py +3 -4
  207. package/runtime/python/okstra_ctl/team.py +3 -2
  208. package/runtime/python/okstra_ctl/team_reconcile.py +11 -2
  209. package/runtime/python/okstra_ctl/time_report.py +51 -4
  210. package/runtime/python/okstra_ctl/usage_identity.py +2 -1
  211. package/runtime/python/okstra_ctl/usage_report.py +58 -4
  212. package/runtime/python/okstra_ctl/user_response.py +1431 -66
  213. package/runtime/python/okstra_ctl/wizard.py +11 -44
  214. package/runtime/python/okstra_ctl/work_categories.py +3 -2
  215. package/runtime/python/okstra_ctl/worker_dispatch.py +5 -2
  216. package/runtime/python/okstra_ctl/worker_prompt_body.py +49 -3
  217. package/runtime/python/okstra_ctl/worker_prompt_contract.py +3 -2
  218. package/runtime/python/okstra_ctl/worker_prompt_policy.py +31 -3
  219. package/runtime/python/okstra_ctl/worker_runner.py +14 -12
  220. package/runtime/python/okstra_ctl/workflow.py +2 -1
  221. package/runtime/python/okstra_ctl/worktree.py +3 -2
  222. package/runtime/python/okstra_ctl/wrapper_status.py +4 -2
  223. package/runtime/python/okstra_ctl/write_policy.py +4 -2
  224. package/runtime/python/okstra_token_usage/antigravity.py +39 -12
  225. package/runtime/python/okstra_token_usage/collect.py +90 -38
  226. package/runtime/python/okstra_token_usage/grok.py +127 -0
  227. package/runtime/schemas/final-report-v2.0.schema.json +9 -0
  228. package/runtime/schemas/final-report-v3.0.schema.json +10219 -0
  229. package/runtime/schemas/report-narrative-v3.0.schema.json +30 -0
  230. package/runtime/schemas/report-synthesis-packet-v1.0.schema.json +140 -0
  231. package/runtime/skills/okstra-brief-gen/SKILL.md +9 -7
  232. package/runtime/skills/okstra-code-review/SKILL.md +21 -11
  233. package/runtime/skills/okstra-container-build/SKILL.md +18 -18
  234. package/runtime/skills/okstra-inspect/SKILL.md +12 -11
  235. package/runtime/skills/okstra-inspect/facets/error-zip.md +8 -8
  236. package/runtime/skills/okstra-inspect/facets/errors.md +2 -2
  237. package/runtime/skills/okstra-inspect/facets/history.md +9 -14
  238. package/runtime/skills/okstra-inspect/facets/logs.md +2 -2
  239. package/runtime/skills/okstra-inspect/facets/recap.md +5 -5
  240. package/runtime/skills/okstra-inspect/facets/report.md +6 -10
  241. package/runtime/skills/okstra-inspect/facets/status.md +9 -8
  242. package/runtime/skills/okstra-inspect/facets/time.md +3 -3
  243. package/runtime/skills/okstra-manager/SKILL.md +16 -14
  244. package/runtime/skills/okstra-memory/SKILL.md +3 -3
  245. package/runtime/skills/okstra-pr-gen/SKILL.md +5 -4
  246. package/runtime/skills/okstra-rollup/SKILL.md +6 -16
  247. package/runtime/skills/okstra-run/SKILL.md +7 -7
  248. package/runtime/skills/okstra-schedule-gen/SKILL.md +21 -17
  249. package/runtime/skills/okstra-setup/SKILL.md +21 -13
  250. package/runtime/skills/okstra-setup/references/project-config.md +2 -2
  251. package/runtime/skills/okstra-usage/SKILL.md +10 -10
  252. package/runtime/skills/okstra-user-response/SKILL.md +78 -107
  253. package/runtime/templates/report-writer-prompt-preamble.md +29 -19
  254. package/runtime/templates/reports/html/macros/forms.html +6 -4
  255. package/runtime/templates/reports/schedule.template.md +4 -4
  256. package/runtime/templates/worker-error-contract.md +17 -29
  257. package/runtime/validators/validate-run.py +636 -72
  258. package/runtime/validators/validate_session_conformance.py +43 -7
@@ -0,0 +1,62 @@
1
+ """런타임 계약 그래프 검증 명령 진입점."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ from pathlib import Path
7
+ import sys
8
+
9
+ from .contract_graph import (
10
+ ContractFile,
11
+ ContractGraphError,
12
+ ContractGraphRoot,
13
+ ContractGraphValidator,
14
+ ContractSelection,
15
+ )
16
+
17
+
18
+ def _parser() -> argparse.ArgumentParser:
19
+ parser = argparse.ArgumentParser(
20
+ prog="okstra contract-check",
21
+ description="Validate runtime contract documents and dependencies.",
22
+ )
23
+ parser.add_argument("--root", required=True, type=Path)
24
+ selection = parser.add_mutually_exclusive_group()
25
+ selection.add_argument("--profile")
26
+ selection.add_argument("--operation")
27
+ return parser
28
+
29
+
30
+ def _record(row: ContractFile) -> dict[str, str]:
31
+ return {
32
+ "path": row.relative_path,
33
+ "schemaVersion": row.schema_version,
34
+ "sha256": row.sha256,
35
+ }
36
+
37
+
38
+ def main(argv: list[str] | None = None) -> int:
39
+ args = _parser().parse_args(argv)
40
+ selected = None
41
+ if args.profile is not None or args.operation is not None:
42
+ selected = ContractSelection(args.profile, args.operation)
43
+ try:
44
+ graph = ContractGraphValidator().validate(
45
+ ContractGraphRoot.from_base(args.root), selected=selected
46
+ )
47
+ if args.profile is not None:
48
+ rows = graph.dependencies_for_profile(args.profile)
49
+ elif args.operation is not None:
50
+ rows = graph.dependencies_for_operation(args.operation)
51
+ else:
52
+ rows = graph.files
53
+ except ContractGraphError as exc:
54
+ for error in exc.errors:
55
+ print(error, file=sys.stderr)
56
+ return 1
57
+ print(json.dumps([_record(row) for row in rows], indent=2))
58
+ return 0
59
+
60
+
61
+ if __name__ == "__main__":
62
+ raise SystemExit(main())
@@ -3,11 +3,14 @@ from __future__ import annotations
3
3
 
4
4
  import argparse
5
5
  from collections.abc import Mapping
6
+ from dataclasses import dataclass
6
7
  import json
7
8
  from pathlib import Path, PurePosixPath
8
9
  import sys
9
10
  from typing import Any
10
11
 
12
+ from okstra_project.state import StateError, parse_task_key, slugify
13
+
11
14
  from .convergence_engine import (
12
15
  ConvergenceContractError,
13
16
  apply_critic_gap_results,
@@ -27,16 +30,107 @@ from .convergence_migration import (
27
30
  from .convergence_provenance import (
28
31
  GROUPS_BASENAME_RE,
29
32
  provenance_errors,
33
+ worker_result_suffix,
30
34
  )
31
35
  from .convergence_store import (
32
- load_json_object,
36
+ load_convergence_critic_batch,
37
+ load_convergence_round_results,
38
+ load_owned_json_object,
33
39
  write_final_state_atomic,
34
40
  write_json_atomic,
35
41
  )
36
42
  from .execution_identity import ExecutionManifest
37
43
  from .execution_manifest import read_execution_manifest_view
44
+ from .final_report_schema import load_named_schema, validate as validate_schema
38
45
  from .paths import RunRef
46
+ from .report_finalize import task_manifest_path
39
47
  from .verdict_blocks import FindingVote, VerdictBlockError, parse_finding_votes
48
+ from .fixed_text import line
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class RunArtifactAuthority:
53
+ manifest_path: Path
54
+ project_root: Path
55
+ run_dir: Path
56
+ task_type: str
57
+ task_key: str
58
+ manifest_ref: str
59
+ state_sequence: str
60
+ execution_manifest: ExecutionManifest | None
61
+ payload: Mapping[str, Any]
62
+
63
+
64
+ def validated_run_authority(manifest_path: Path) -> RunArtifactAuthority:
65
+ supplied = Path(manifest_path)
66
+ if not supplied.is_absolute() or supplied.absolute() != supplied.resolve():
67
+ raise ConvergenceContractError("run manifest path is not canonical")
68
+ payload = load_owned_json_object(supplied)
69
+ execution_manifest = None
70
+ if payload.get("executionIdentityVersion") == 2:
71
+ execution_manifest, view = read_execution_manifest_view(supplied)
72
+ payload = dict(view)
73
+ project_root = _manifest_project_root(payload)
74
+ manifest_ref = _manifest_run_ref(payload, project_root, supplied)
75
+ run_dir, task_type, state_sequence = _validated_run_artifact_authority(
76
+ payload, project_root, supplied, manifest_ref
77
+ )
78
+ task_key = _manifest_authority_string(payload, "taskKey")
79
+ try:
80
+ _project_id, task_group, task_id = parse_task_key(task_key)
81
+ except StateError as exc:
82
+ raise ConvergenceContractError(str(exc)) from exc
83
+ recovered = RunRef.from_run_dir(run_dir)
84
+ if (slugify(task_group), slugify(task_id)) != (
85
+ recovered.task_group, recovered.task_id
86
+ ):
87
+ raise ConvergenceContractError(
88
+ "run manifest taskKey does not match run directory"
89
+ )
90
+ return RunArtifactAuthority(
91
+ supplied, project_root, run_dir, task_type, task_key, manifest_ref,
92
+ state_sequence, execution_manifest, payload,
93
+ )
94
+
95
+
96
+ def canonical_run_state_artifact(
97
+ authority: RunArtifactAuthority,
98
+ *,
99
+ manifest_field: str,
100
+ prefix: str,
101
+ label: str,
102
+ ) -> Path:
103
+ value = _manifest_authority_string(authority.payload, manifest_field)
104
+ supplied = _canonical_project_artifact_path(
105
+ authority.project_root, value, label
106
+ )
107
+ expected = authority.run_dir / "state" / _canonical_run_artifact_name(
108
+ prefix, authority.task_type, authority.state_sequence
109
+ )
110
+ if supplied != expected:
111
+ raise ConvergenceContractError(f"{label} does not match run authority")
112
+ return expected
113
+
114
+
115
+ def canonical_run_report_artifact(authority: RunArtifactAuthority) -> Path:
116
+ report_sequence = _manifest_sequence(authority.payload, "reports")
117
+ try:
118
+ expected = RunRef.from_run_dir(
119
+ authority.run_dir, seq=int(report_sequence)
120
+ ).report
121
+ except ValueError as exc:
122
+ raise ConvergenceContractError(
123
+ "run manifest report sequence is not canonical"
124
+ ) from exc
125
+ value = _manifest_authority_string(
126
+ authority.payload, "expectedReportRecordPath"
127
+ )
128
+ supplied = _canonical_project_artifact_path(
129
+ authority.project_root, value, "report path"
130
+ )
131
+ if supplied != expected:
132
+ raise ConvergenceContractError("report path does not match run authority")
133
+ return expected
40
134
 
41
135
 
42
136
  _EVIDENCE_ARTIFACT = {
@@ -197,6 +291,11 @@ def _parser() -> argparse.ArgumentParser:
197
291
  seed.add_argument("--final-state", type=Path, required=True)
198
292
  seed.add_argument("--migration-dir", type=Path, required=True)
199
293
  seed.add_argument("--restart-from-round0", action="store_true")
294
+ prepare = subparsers.add_parser(
295
+ "prepare-groups", help="publish grouped findings from fixed Markdown"
296
+ )
297
+ prepare.add_argument("--run-manifest", type=Path, required=True)
298
+ prepare.add_argument("--input", type=Path, required=True)
200
299
 
201
300
  plan = subparsers.add_parser("plan-round", help="write the next dispatch plan")
202
301
  plan.add_argument("--work-state", type=Path, required=True)
@@ -292,7 +391,7 @@ def _resume_after_legacy_final(
292
391
  source_path=final_state_path,
293
392
  migration_dir=migration_dir,
294
393
  )
295
- state = load_json_object(work_state_path)
394
+ state = load_owned_json_object(work_state_path)
296
395
  state["migration"] = migration_record(final_state_path, archive_path, digest)
297
396
  write_json_atomic(work_state_path, state)
298
397
 
@@ -305,16 +404,21 @@ def _seeded_groups_provenance_errors(groups_path: Path, document: dict) -> list[
305
404
  file, and the same claims are re-checked at Phase 7 by `validate-run`,
306
405
  where a bad `itemId` surfaces hours later as a blocked approval.
307
406
  """
308
- match = GROUPS_BASENAME_RE.match(groups_path.name)
309
- if match is None:
407
+ if GROUPS_BASENAME_RE.match(groups_path.name) is None:
310
408
  return []
311
- worker_results_dir = groups_path.parent.parent / "worker-results"
409
+ run_dir = groups_path.parent.parent
410
+ worker_results_dir = run_dir / "worker-results"
312
411
  if not worker_results_dir.is_dir():
313
412
  return []
413
+ # 그룹 파일명이 지닌 것은 state 시퀀스다. 워커 결과는 workerResults 시퀀스로
414
+ # 이름 붙으므로 그룹 문서가 기록한 run manifest 를 거쳐 해소한다.
415
+ suffix = worker_result_suffix(run_dir, document)
416
+ if suffix is None:
417
+ return []
314
418
  return provenance_errors(
315
419
  document,
316
420
  worker_results_dir=worker_results_dir,
317
- suffix=match.group("suffix"),
421
+ suffix=suffix,
318
422
  groups_label=groups_path.name,
319
423
  )
320
424
 
@@ -698,7 +802,7 @@ def _canonical_project_artifact_path(
698
802
 
699
803
 
700
804
  def _seed(args: argparse.Namespace) -> tuple[str, Path]:
701
- grouped_input = load_json_object(args.groups)
805
+ grouped_input = load_owned_json_object(args.groups)
702
806
  execution_manifest = _seed_execution_manifest(
703
807
  args.groups,
704
808
  grouped_input,
@@ -746,7 +850,7 @@ def _seed(args: argparse.Namespace) -> tuple[str, Path]:
746
850
 
747
851
 
748
852
  def _plan_round(args: argparse.Namespace) -> tuple[str, Path]:
749
- state = load_json_object(args.work_state)
853
+ state = load_owned_json_object(args.work_state)
750
854
  errors = validate_working_state(state)
751
855
  if errors:
752
856
  raise ConvergenceContractError("invalid working state: " + "; ".join(errors))
@@ -756,10 +860,10 @@ def _plan_round(args: argparse.Namespace) -> tuple[str, Path]:
756
860
 
757
861
 
758
862
  def _apply_round(args: argparse.Namespace) -> tuple[str, Path]:
759
- state = load_json_object(args.work_state)
863
+ state = load_owned_json_object(args.work_state)
760
864
  _validated_v2_work_authority(args.work_state, state)
761
- plan = load_json_object(args.plan)
762
- results = load_json_object(args.results)
865
+ plan = load_owned_json_object(args.plan)
866
+ results = load_convergence_round_results(args.results)
763
867
  updated = apply_round_results(state, plan, results)
764
868
  errors = validate_working_state(updated)
765
869
  if errors:
@@ -769,16 +873,16 @@ def _apply_round(args: argparse.Namespace) -> tuple[str, Path]:
769
873
 
770
874
 
771
875
  def _apply_critic_gaps(args: argparse.Namespace) -> tuple[str, Path]:
772
- state = load_json_object(args.work_state)
876
+ state = load_owned_json_object(args.work_state)
773
877
  _validated_v2_work_authority(args.work_state, state)
774
- results = load_json_object(args.results)
878
+ results = load_convergence_critic_batch(args.results)
775
879
  updated = apply_critic_gap_results(state, results)
776
880
  write_json_atomic(args.work_state, updated)
777
881
  return "applied", args.work_state
778
882
 
779
883
 
780
884
  def _finalize(args: argparse.Namespace) -> tuple[str, Path]:
781
- state = load_json_object(args.work_state)
885
+ state = load_owned_json_object(args.work_state)
782
886
  authority = _validated_v2_work_authority(args.work_state, state)
783
887
  if authority is not None:
784
888
  _manifest, run_dir, task_type, state_sequence = authority
@@ -797,7 +901,7 @@ def _finalize(args: argparse.Namespace) -> tuple[str, Path]:
797
901
 
798
902
 
799
903
  def _validate(args: argparse.Namespace) -> tuple[str, Path]:
800
- state = load_json_object(args.state)
904
+ state = load_owned_json_object(args.state)
801
905
  errors = (
802
906
  validate_working_state(state)
803
907
  if args.kind == "working"
@@ -881,7 +985,7 @@ def _worker_votes(
881
985
 
882
986
 
883
987
  def _collect_results(args: argparse.Namespace) -> tuple[str, Path]:
884
- plan = load_json_object(args.plan)
988
+ plan = load_owned_json_object(args.plan)
885
989
  planned = _planned_finding_ids(plan)
886
990
  dispatches = _dispatch_rows(args.dispatch)
887
991
  undispatched = sorted(set(planned) - {row["worker"] for row in dispatches})
@@ -909,8 +1013,222 @@ def _collect_results(args: argparse.Namespace) -> tuple[str, Path]:
909
1013
  return "collected", args.output
910
1014
 
911
1015
 
1016
+ def _parse_grouping_markdown(path: Path) -> list[dict[str, Any]]:
1017
+ groups: list[dict[str, Any]] = []
1018
+ fields: dict[str, list[str]] = {}
1019
+ finding_id = ""
1020
+ for raw in [*path.read_text(encoding="utf-8").splitlines(), "## END"]:
1021
+ if raw.startswith("## "):
1022
+ if finding_id:
1023
+ groups.append(_group_from_fields(finding_id, fields))
1024
+ finding_id = raw[3:].strip()
1025
+ fields = {}
1026
+ elif ":" in raw and finding_id:
1027
+ key, value = raw.split(":", 1)
1028
+ fields.setdefault(key.strip().lower(), []).append(value.strip())
1029
+ return groups
1030
+
1031
+
1032
+ def _parse_group_source(spec: str) -> tuple[str, str, str]:
1033
+ identity, separator, evidence = spec.partition("|")
1034
+ worker, colon, item_id = identity.strip().partition(":")
1035
+ if not separator or not colon or not all((worker, item_id, evidence.strip())):
1036
+ raise ConvergenceContractError(
1037
+ "Source must use worker:item-id | evidence"
1038
+ )
1039
+ return worker, item_id, evidence.strip()
1040
+
1041
+
1042
+ def _group_from_fields(
1043
+ finding_id: str, fields: Mapping[str, list[str]]
1044
+ ) -> dict[str, Any]:
1045
+ scalar = {
1046
+ key: values[0] if len(values) == 1 else ""
1047
+ for key, values in fields.items() if key != "source"
1048
+ }
1049
+ source_rows = fields.get("source", [])
1050
+ if source_rows:
1051
+ parsed = [_parse_group_source(spec) for spec in source_rows]
1052
+ else:
1053
+ specs = [part.strip() for part in scalar.get("sources", "").split(",") if part.strip()]
1054
+ if len(specs) != 1 or not scalar.get("evidence"):
1055
+ raise ConvergenceContractError(
1056
+ "multi-worker groups require one Source per worker"
1057
+ )
1058
+ worker, colon, item_id = specs[0].partition(":")
1059
+ if not colon or not worker or not item_id:
1060
+ raise ConvergenceContractError("Sources must use worker:item-id entries")
1061
+ parsed = [(worker, item_id, scalar["evidence"])]
1062
+ discovered, source_items = _group_source_provenance(parsed)
1063
+ origin_worker, separator, origin_item = scalar.get("origin", "").partition(":")
1064
+ if not separator or discovered.get(origin_worker, {}).get("itemId") != origin_item:
1065
+ raise ConvergenceContractError("Origin must identify one Source item")
1066
+ return {
1067
+ "findingId": finding_id, "summary": scalar.get("summary", ""),
1068
+ "category": scalar.get("category", ""),
1069
+ "ticketIds": [item.strip() for item in scalar.get("tickets", "").split(",") if item.strip()],
1070
+ "originWorker": origin_worker,
1071
+ "originEvidence": discovered[origin_worker]["evidence"],
1072
+ "discoveredBy": discovered,
1073
+ "sourceItems": source_items,
1074
+ }
1075
+
1076
+
1077
+ def _group_source_provenance(
1078
+ parsed: list[tuple[str, str, str]],
1079
+ ) -> tuple[dict[str, dict[str, str]], list[dict[str, str]]]:
1080
+ workers = [worker for worker, _item, _evidence in parsed]
1081
+ if len(workers) != len(set(workers)):
1082
+ raise ConvergenceContractError("duplicate Source worker in one finding")
1083
+ discovered = {
1084
+ worker: {"itemId": item, "evidence": evidence}
1085
+ for worker, item, evidence in parsed
1086
+ }
1087
+ sources = [
1088
+ {"worker": worker, "itemId": item}
1089
+ for worker, item, _evidence in parsed
1090
+ ]
1091
+ if any(discovered[row["worker"]]["itemId"] != row["itemId"] for row in sources):
1092
+ raise ConvergenceContractError("discoveredBy and sourceItems do not match")
1093
+ return discovered, sources
1094
+
1095
+
1096
+ def _assignment_identity(row: Mapping[str, Any]) -> tuple[str, str, str]:
1097
+ role = str(row.get("role") or "")
1098
+ provider = str(row.get("provider") or "")
1099
+ model = str(row.get("modelExecutionValue") or row.get("model") or "")
1100
+ if not all((role, provider, model)):
1101
+ raise ConvergenceContractError(
1102
+ "worker assignment requires role, provider, and model"
1103
+ )
1104
+ return role, provider, model
1105
+
1106
+
1107
+ def _execution_model(row: Mapping[str, Any]) -> str:
1108
+ binding = row.get("binding")
1109
+ if isinstance(binding, Mapping) and binding.get("resolvedExecutionValue"):
1110
+ return str(binding["resolvedExecutionValue"])
1111
+ return str(row.get("modelId") or "")
1112
+
1113
+
1114
+ def _group_assignment_identity(
1115
+ manifest: Mapping[str, Any], worker_id: str,
1116
+ ) -> tuple[str, str, str, int]:
1117
+ assignments = manifest.get("workerAssignments") if isinstance(manifest.get("workerAssignments"), list) else []
1118
+ normalized = worker_id.removesuffix("-worker")
1119
+ if normalized == "lead":
1120
+ duplicate = any(
1121
+ isinstance(row, Mapping)
1122
+ and str(row.get("workerId", "")).removesuffix("-worker") == "lead"
1123
+ for row in assignments
1124
+ )
1125
+ if duplicate:
1126
+ raise ConvergenceContractError("ambiguous lead assignment identity")
1127
+ lead = manifest.get("leadAssignment")
1128
+ if not isinstance(lead, Mapping):
1129
+ raise ConvergenceContractError("lead assignment identity is missing")
1130
+ role, provider, model = _assignment_identity(lead)
1131
+ if role != "lead":
1132
+ raise ConvergenceContractError("leadAssignment role must be lead")
1133
+ return "leader", provider, model, 1
1134
+ matches = [(index, row) for index, row in enumerate(assignments)
1135
+ if isinstance(row, Mapping)
1136
+ and str(row.get("workerId", "")).removesuffix("-worker") == normalized]
1137
+ if len(matches) != 1:
1138
+ raise ConvergenceContractError(
1139
+ f"worker assignment identity is missing or duplicate for {worker_id}"
1140
+ )
1141
+ index, assignment = matches[0]
1142
+ role, provider, model = _assignment_identity(assignment)
1143
+ ordinal = sum(
1144
+ 1 for row in assignments[:index + 1]
1145
+ if isinstance(row, Mapping) and str(row.get("role") or "") == role
1146
+ )
1147
+ return role, provider, model, ordinal
1148
+
1149
+
1150
+ def _group_worker(manifest: Mapping[str, Any], worker_id: str) -> dict[str, str]:
1151
+ role, provider, model, ordinal = _group_assignment_identity(
1152
+ manifest, worker_id
1153
+ )
1154
+ executions = manifest.get("roleExecutions") if isinstance(manifest.get("roleExecutions"), list) else []
1155
+ candidates = [row for row in executions if isinstance(row, Mapping)
1156
+ and row.get("role") == role and row.get("provider") == provider
1157
+ and _execution_model(row) == model and row.get("ordinal") == ordinal]
1158
+ if len(candidates) != 1:
1159
+ raise ConvergenceContractError(f"no canonical role execution for {worker_id}")
1160
+ execution = candidates[0]
1161
+ if not execution.get("participantRef") or not execution.get("roleExecutionRef"):
1162
+ raise ConvergenceContractError(f"no canonical role execution for {worker_id}")
1163
+ audience = "report-writer" if role == "report-writer" else "lead" if role in {"lead", "leader"} else "analysis"
1164
+ return {"workerId": worker_id, "audience": audience,
1165
+ "participantRef": str(execution["participantRef"]),
1166
+ "sourceRoleExecutionRef": str(execution["roleExecutionRef"])}
1167
+
1168
+
1169
+ def _convergence_config(authority: RunArtifactAuthority) -> Mapping[str, Any]:
1170
+ """convergence 설정의 정본은 task-manifest.json 이다.
1171
+
1172
+ `render.render_task_manifest` 가 `_build_convergence_block` 결과를 거기에만
1173
+ 쓴다. run manifest 에는 `convergenceStatePath` 뿐이라, 예전처럼 run manifest 를
1174
+ 읽으면 키가 없어 `adversarial=False` / `verificationMode="lightweight"` 기본값으로
1175
+ 조용히 떨어졌다. 그 강등은 무증상이다 — `convergence_engine._parse_groups` 가
1176
+ 적대 모드가 꺼진 다중 출처 그룹을 큐에 넣지 않고 즉시 `full-consensus` 로
1177
+ 확정하므로, 세 워커의 처방이 서로 달라도 합의로 기록된다.
1178
+
1179
+ 설정이 없으면 기본값으로 떨어지지 않고 실패한다. 여기서 조용히 넘어가면
1180
+ 검증 자체가 사라지고 산출물은 정상으로 읽힌다.
1181
+ """
1182
+ path = task_manifest_path(authority.project_root, authority.payload)
1183
+ payload = load_owned_json_object(path)
1184
+ config = payload.get("convergence")
1185
+ if not isinstance(config, Mapping):
1186
+ raise ConvergenceContractError(
1187
+ f"task manifest carries no convergence config: {path}"
1188
+ )
1189
+ return config
1190
+
1191
+
1192
+ def _prepare_groups(args: argparse.Namespace) -> tuple[str, Path]:
1193
+ authority = validated_run_authority(args.run_manifest)
1194
+ manifest = authority.payload
1195
+ groups = _parse_grouping_markdown(args.input)
1196
+ worker_ids = list(dict.fromkeys(
1197
+ source["worker"] for group in groups for source in group["sourceItems"]
1198
+ ))
1199
+ output = authority.run_dir / "state" / _canonical_run_artifact_name(
1200
+ "convergence-groups", authority.task_type, authority.state_sequence
1201
+ )
1202
+ config = _convergence_config(authority)
1203
+ workers = [_group_worker(manifest, worker) for worker in worker_ids]
1204
+ source_refs = [worker["sourceRoleExecutionRef"] for worker in workers]
1205
+ if len(source_refs) != len(set(source_refs)):
1206
+ raise ConvergenceContractError("duplicate sourceRoleExecutionRef")
1207
+ payload = {"schemaVersion": "2.0", "executionIdentityVersion": 2,
1208
+ "taskKey": manifest.get("taskKey"), "runManifestPath": manifest.get("runManifestPath"),
1209
+ "config": {"enabled": bool(config.get("enabled", True)),
1210
+ "adversarial": bool(config.get("adversarial", False)),
1211
+ "maxRounds": int(config.get("maxRounds", 2)),
1212
+ "effectiveMaxRounds": int(config.get("effectiveMaxRounds", 2)),
1213
+ "verificationMode": str(config.get("verificationMode", "lightweight"))},
1214
+ "workers": workers, "groups": groups}
1215
+ schema_errors = validate_schema(
1216
+ payload, load_named_schema("convergence-groups-v2.0.schema.json")
1217
+ )
1218
+ if schema_errors:
1219
+ raise ConvergenceContractError(
1220
+ "invalid convergence groups: " + "; ".join(schema_errors)
1221
+ )
1222
+ seed_working_state(
1223
+ payload, execution_manifest=authority.execution_manifest
1224
+ )
1225
+ write_json_atomic(output, payload)
1226
+ return "prepared", output
1227
+
1228
+
912
1229
  def _execute(args: argparse.Namespace) -> tuple[str, Path]:
913
1230
  operations: dict[str, Any] = {
1231
+ "prepare-groups": _prepare_groups,
914
1232
  "seed": _seed,
915
1233
  "plan-round": _plan_round,
916
1234
  "collect-results": _collect_results,
@@ -943,7 +1261,10 @@ def main(argv: list[str] | None = None) -> int:
943
1261
  except OSError as exc:
944
1262
  print(f"error: {exc}", file=sys.stderr)
945
1263
  return 1
946
- _emit(args.operation, action, path)
1264
+ if args.operation == "prepare-groups":
1265
+ print("Convergence groups\n" + line("Status", "ready") + line("Action", action), end="")
1266
+ else:
1267
+ _emit(args.operation, action, path)
947
1268
  return 0
948
1269
 
949
1270
 
@@ -21,6 +21,7 @@ _AUDIENCES = {"analysis", "lead", "report-writer"}
21
21
  _FINDING_SOURCE_AUDIENCES = {"analysis", "lead"}
22
22
  _AUDIENCE_SOURCE_ROLES = {
23
23
  "analysis": frozenset({"analyser", "designer", "planner", "verifier"}),
24
+ "lead": frozenset({"leader"}),
24
25
  "report-writer": frozenset({"report-writer"}),
25
26
  }
26
27
  _VERIFICATION_MODES = {"lightweight", "full-reanalysis"}
@@ -809,7 +810,7 @@ def _parse_critic_dispatches(
809
810
  worker = _required_string(row, "worker", f"critic dispatches[{index}]")
810
811
  if worker in seen:
811
812
  raise ConvergenceContractError(f"duplicate critic voter dispatch: {worker}")
812
- if worker not in roster or _is_critic_worker(worker, provider):
813
+ if worker not in roster:
813
814
  raise ConvergenceContractError(
814
815
  f"critic voter must be a non-critic analyser: {worker}"
815
816
  )
@@ -823,9 +824,7 @@ def _parse_critic_dispatches(
823
824
  )
824
825
  seen.add(worker)
825
826
  dispatches.append({"worker": worker, "status": status, "durationMs": duration})
826
- expected = {
827
- worker for worker in roster if not _is_critic_worker(worker, provider)
828
- }
827
+ expected = set(roster)
829
828
  if seen != expected:
830
829
  missing = sorted(expected - seen)
831
830
  raise ConvergenceContractError(
@@ -869,7 +868,7 @@ def _parse_critic_gap(
869
868
  votes_value = _object(gap.get("votes"), f"{label}.votes")
870
869
  votes: dict[str, dict[str, Any]] = {}
871
870
  for worker, raw_vote in votes_value.items():
872
- if worker not in roster or _is_critic_worker(worker, provider):
871
+ if worker not in roster:
873
872
  raise ConvergenceContractError(
874
873
  f"critic voter must be a non-critic analyser: {worker}"
875
874
  )
@@ -1435,15 +1434,9 @@ def _validate_critic_roster(
1435
1434
  errors.append("criticVerification.analyserRoster does not match workers")
1436
1435
  for row in dispatches if isinstance(dispatches, list) else []:
1437
1436
  worker = row.get("worker") if isinstance(row, Mapping) else None
1438
- if (
1439
- not _nonempty_string(worker)
1440
- or worker not in roster
1441
- or _is_critic_worker(worker, provider)
1442
- ):
1437
+ if not _nonempty_string(worker) or worker not in roster:
1443
1438
  errors.append(f"critic voter must be a non-critic analyser: {worker}")
1444
- expected_dispatches = {
1445
- worker for worker in roster if not _is_critic_worker(worker, provider)
1446
- }
1439
+ expected_dispatches = set(roster)
1447
1440
  actual_dispatches = {
1448
1441
  row.get("worker")
1449
1442
  for row in dispatches
@@ -1467,17 +1460,13 @@ def _validate_critic_roster(
1467
1460
  for gap in gaps if isinstance(gaps, list) else []:
1468
1461
  votes = gap.get("votes") if isinstance(gap, Mapping) else None
1469
1462
  for voter in votes if isinstance(votes, Mapping) else {}:
1470
- if voter not in roster or _is_critic_worker(voter, provider):
1463
+ if voter not in roster:
1471
1464
  errors.append(f"critic voter must be a non-critic analyser: {voter}")
1472
1465
  elif voter not in completed:
1473
1466
  errors.append(f"critic voter has no completed dispatch: {voter}")
1474
1467
  return errors
1475
1468
 
1476
1469
 
1477
- def _is_critic_worker(worker: Any, provider: Any) -> bool:
1478
- return isinstance(provider, str) and worker in {provider, f"{provider}-worker"}
1479
-
1480
-
1481
1470
  def _final_collaborative_classification(finding: Mapping[str, Any]) -> str:
1482
1471
  usable: list[Mapping[str, Any]] = []
1483
1472
  for round_row in finding.get("rounds", []):
@@ -2135,6 +2124,9 @@ def _validate_worker_execution_identity(
2135
2124
  f"{worker['audience']} audience source role is not allowed: "
2136
2125
  f"{source.role}"
2137
2126
  )
2127
+ source_refs = [worker["sourceRoleExecutionRef"] for worker in workers]
2128
+ if len(source_refs) != len(set(source_refs)):
2129
+ raise ConvergenceContractError("duplicate sourceRoleExecutionRef")
2138
2130
 
2139
2131
 
2140
2132
  def _parse_groups(