okstra 0.189.3 โ†’ 0.190.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 (178) hide show
  1. package/README.md +1 -1
  2. package/dist/cli-registry.mjs +9 -0
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/chat/chat.mjs +54 -4
  5. package/dist/commands/chat/chat.mjs.map +1 -1
  6. package/dist/commands/lifecycle/install.mjs +12 -5
  7. package/dist/commands/lifecycle/install.mjs.map +1 -1
  8. package/dist/commands/lifecycle/uninstall.mjs +2 -1
  9. package/dist/commands/lifecycle/uninstall.mjs.map +1 -1
  10. package/dist/lib/host-config.d.mts +5 -3
  11. package/dist/lib/host-config.mjs +89 -31
  12. package/dist/lib/host-config.mjs.map +1 -1
  13. package/docs/architecture/storage-model.md +4 -1
  14. package/docs/architecture.md +3 -3
  15. package/docs/cli.md +11 -9
  16. package/docs/for-ai/skills/okstra-brief-gen.md +1 -1
  17. package/docs/for-ai/skills/okstra-chat.md +3 -3
  18. package/docs/for-ai/skills/okstra-inspect.md +11 -1
  19. package/docs/for-ai/skills/okstra-rollup.md +1 -0
  20. package/docs/for-ai/skills/okstra-run.md +5 -4
  21. package/docs/for-ai/skills/okstra-user-response.md +1 -1
  22. package/docs/project-structure-overview.md +17 -5
  23. package/docs/task-process/README.md +1 -1
  24. package/docs/task-process/error-analysis.md +2 -2
  25. package/docs/task-process/final-verification.md +1 -1
  26. package/docs/task-process/implementation.md +1 -1
  27. package/docs/task-process/release-handoff.md +8 -6
  28. package/docs/task-process/requirements-discovery.md +1 -1
  29. package/package.json +1 -1
  30. package/runtime/BUILD.json +2 -2
  31. package/runtime/agents/workers/claude-worker.md +4 -0
  32. package/runtime/bin/okstra-report-translate.py +10 -4
  33. package/runtime/prompts/launch.template.md +11 -5
  34. package/runtime/prompts/lead/adapters/cmux.md +4 -7
  35. package/runtime/prompts/lead/context-loader.md +2 -0
  36. package/runtime/prompts/lead/convergence.md +20 -83
  37. package/runtime/prompts/lead/okstra-lead-contract.md +24 -6
  38. package/runtime/prompts/lead/plan-body-verification.md +10 -3
  39. package/runtime/prompts/lead/report-writer.md +15 -5
  40. package/runtime/prompts/lead/team-contract.md +15 -9
  41. package/runtime/prompts/profiles/_common-contract.md +2 -1
  42. package/runtime/prompts/profiles/_implementation-deliverable.md +2 -0
  43. package/runtime/prompts/profiles/_implementation-diff-review.md +4 -0
  44. package/runtime/prompts/profiles/_implementation-executor.md +9 -5
  45. package/runtime/prompts/profiles/_implementation-self-check.md +2 -0
  46. package/runtime/prompts/profiles/_implementation-verifier.md +3 -4
  47. package/runtime/prompts/profiles/_stage-discipline.md +12 -5
  48. package/runtime/prompts/profiles/final-verification.md +1 -1
  49. package/runtime/prompts/profiles/implementation-planning.md +1 -1
  50. package/runtime/prompts/profiles/implementation.md +2 -0
  51. package/runtime/prompts/profiles/release-handoff.md +4 -3
  52. package/runtime/prompts/wizard/prompts.ko.json +2 -1
  53. package/runtime/python/okstra_ctl/adapters/dispatch/cmux.py +6 -0
  54. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/adapter.py +1 -0
  55. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +2 -2
  56. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +9 -0
  57. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +1 -0
  58. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +3 -3
  59. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +1 -0
  60. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +17 -3
  61. package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +1 -0
  62. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +3 -3
  63. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +1 -0
  64. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +2 -2
  65. package/runtime/python/okstra_ctl/adapters/hosts/kimi/adapter.py +1 -0
  66. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +2 -2
  67. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +3 -3
  68. package/runtime/python/okstra_ctl/agent/invocation.py +107 -5
  69. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +16 -0
  70. package/runtime/python/okstra_ctl/agent/prompt_cli/dynamic_verifier.py +3 -2
  71. package/runtime/python/okstra_ctl/agent/prompt_cli/jobs.py +87 -0
  72. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +72 -3
  73. package/runtime/python/okstra_ctl/analysis_packet.py +47 -6
  74. package/runtime/python/okstra_ctl/cmux.py +34 -13
  75. package/runtime/python/okstra_ctl/consumers.py +30 -7
  76. package/runtime/python/okstra_ctl/convergence.py +110 -21
  77. package/runtime/python/okstra_ctl/convergence_provenance.py +83 -0
  78. package/runtime/python/okstra_ctl/dispatch_core.py +127 -5
  79. package/runtime/python/okstra_ctl/dispatch_state.py +17 -9
  80. package/runtime/python/okstra_ctl/domain/host.py +5 -0
  81. package/runtime/python/okstra_ctl/domain/wizard/interaction.py +1 -0
  82. package/runtime/python/okstra_ctl/error_log_write.py +44 -4
  83. package/runtime/python/okstra_ctl/execution_mutation_audit.py +84 -7
  84. package/runtime/python/okstra_ctl/fixed_text.py +25 -7
  85. package/runtime/python/okstra_ctl/group_context.py +475 -10
  86. package/runtime/python/okstra_ctl/handoff.py +109 -32
  87. package/runtime/python/okstra_ctl/handoff_verification.py +80 -0
  88. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +52 -10
  89. package/runtime/python/okstra_ctl/lead_progress.py +291 -0
  90. package/runtime/python/okstra_ctl/model_io/renderers.py +60 -1
  91. package/runtime/python/okstra_ctl/model_io_cli.py +6 -1
  92. package/runtime/python/okstra_ctl/ports/worker_dispatch.py +4 -0
  93. package/runtime/python/okstra_ctl/recap.py +179 -17
  94. package/runtime/python/okstra_ctl/reconcile.py +55 -4
  95. package/runtime/python/okstra_ctl/render.py +34 -8
  96. package/runtime/python/okstra_ctl/report_assembly.py +17 -10
  97. package/runtime/python/okstra_ctl/report_finalize.py +204 -11
  98. package/runtime/python/okstra_ctl/report_html/common.py +241 -113
  99. package/runtime/python/okstra_ctl/report_html/context_links.py +121 -0
  100. package/runtime/python/okstra_ctl/report_html/models.py +11 -5
  101. package/runtime/python/okstra_ctl/report_html/render.py +69 -17
  102. package/runtime/python/okstra_ctl/report_html/view_models/change_impact_analysis.py +8 -1
  103. package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +8 -1
  104. package/runtime/python/okstra_ctl/report_html/view_models/feature_analysis.py +11 -1
  105. package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +13 -1
  106. package/runtime/python/okstra_ctl/report_html/view_models/implementation.py +9 -1
  107. package/runtime/python/okstra_ctl/report_html/view_models/implementation_option_selection.py +29 -2
  108. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +14 -9
  109. package/runtime/python/okstra_ctl/report_html/view_models/improvement_discovery.py +8 -1
  110. package/runtime/python/okstra_ctl/report_html/view_models/project_analysis.py +15 -1
  111. package/runtime/python/okstra_ctl/report_html/view_models/release_handoff.py +7 -1
  112. package/runtime/python/okstra_ctl/report_html/view_models/requirements_discovery.py +9 -1
  113. package/runtime/python/okstra_ctl/report_translation.py +58 -1
  114. package/runtime/python/okstra_ctl/run.py +54 -14
  115. package/runtime/python/okstra_ctl/stage_targets.py +66 -0
  116. package/runtime/python/okstra_ctl/verdict_blocks.py +39 -12
  117. package/runtime/python/okstra_ctl/wizard/__init__.py +148 -0
  118. package/runtime/python/okstra_ctl/wizard/__main__.py +13 -0
  119. package/runtime/python/okstra_ctl/wizard/cli.py +176 -0
  120. package/runtime/python/okstra_ctl/wizard/confirmation.py +331 -0
  121. package/runtime/python/okstra_ctl/wizard/engine.py +363 -0
  122. package/runtime/python/okstra_ctl/wizard/ids.py +411 -0
  123. package/runtime/python/okstra_ctl/wizard/prompts.py +202 -0
  124. package/runtime/python/okstra_ctl/wizard/registry.py +836 -0
  125. package/runtime/python/okstra_ctl/wizard/render.py +189 -0
  126. package/runtime/python/okstra_ctl/wizard/roles.py +737 -0
  127. package/runtime/python/okstra_ctl/wizard/sources.py +703 -0
  128. package/runtime/python/okstra_ctl/wizard/state.py +689 -0
  129. package/runtime/python/okstra_ctl/wizard/statefile.py +145 -0
  130. package/runtime/python/okstra_ctl/wizard/steps_analysis.py +558 -0
  131. package/runtime/python/okstra_ctl/wizard/steps_identity.py +828 -0
  132. package/runtime/python/okstra_ctl/wizard/steps_options.py +340 -0
  133. package/runtime/python/okstra_ctl/wizard/steps_plan.py +959 -0
  134. package/runtime/python/okstra_ctl/wizard/steps_roles.py +672 -0
  135. package/runtime/python/okstra_ctl/worker_prompt_contract.py +43 -3
  136. package/runtime/python/okstra_ctl/worker_prompt_headers.py +14 -0
  137. package/runtime/python/okstra_ctl/workflow.py +2 -2
  138. package/runtime/python/okstra_ctl/worktree/__init__.py +2 -0
  139. package/runtime/python/okstra_ctl/worktree/cleanliness.py +7 -4
  140. package/runtime/python/okstra_ctl/worktree/git_ops.py +7 -0
  141. package/runtime/python/okstra_ctl/worktree/naming.py +15 -6
  142. package/runtime/python/okstra_ctl/worktree/provision.py +2 -1
  143. package/runtime/python/okstra_ctl/worktree_registry.py +27 -0
  144. package/runtime/python/okstra_ctl/wrapper_status.py +4 -0
  145. package/runtime/schemas/final-report-v2.0.schema.json +4 -0
  146. package/runtime/schemas/final-report-v3.0.schema.json +4 -0
  147. package/runtime/skills/okstra-brief-gen/SKILL.md +22 -3
  148. package/runtime/skills/okstra-chat/SKILL.md +8 -6
  149. package/runtime/skills/okstra-inspect/SKILL.md +2 -2
  150. package/runtime/skills/okstra-inspect/facets/recap.md +52 -4
  151. package/runtime/skills/okstra-rollup/SKILL.md +1 -1
  152. package/runtime/skills/okstra-run/SKILL.md +17 -7
  153. package/runtime/skills/okstra-schedule-gen/SKILL.md +2 -0
  154. package/runtime/skills/okstra-user-response/SKILL.md +1 -1
  155. package/runtime/templates/reports/brief.template.md +15 -7
  156. package/runtime/templates/reports/final-verification-input.template.md +2 -0
  157. package/runtime/templates/reports/group-context.template.md +7 -0
  158. package/runtime/templates/reports/html/base.template.html +13 -3
  159. package/runtime/templates/reports/html/i18n/en.json +43 -5
  160. package/runtime/templates/reports/html/i18n/ko.json +43 -5
  161. package/runtime/templates/reports/html/macros/forms.html +4 -4
  162. package/runtime/templates/reports/html/tasks/final-verification.template.html +11 -1
  163. package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +16 -15
  164. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +5 -5
  165. package/runtime/templates/reports/html/tasks/release-handoff.template.html +2 -2
  166. package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +1 -1
  167. package/runtime/templates/reports/implementation-planning-input.template.md +3 -0
  168. package/runtime/templates/reports/improvement-discovery-input.template.md +3 -0
  169. package/runtime/templates/reports/release-handoff-input.template.md +5 -3
  170. package/runtime/templates/reports/schedule.template.md +13 -4
  171. package/runtime/templates/reports/task-brief.template.md +1 -1
  172. package/runtime/templates/reverify-output-contract.md +5 -0
  173. package/runtime/templates/worker-error-contract.md +2 -0
  174. package/runtime/templates/worker-prompt-preamble.md +1 -1
  175. package/runtime/validators/validate-report-views.py +30 -1
  176. package/runtime/validators/validate-run.py +194 -25
  177. package/runtime/validators/validate_session_conformance.py +102 -2
  178. package/runtime/python/okstra_ctl/wizard.py +0 -7168
@@ -117,6 +117,14 @@ _NESTED_KEYS = {
117
117
  "promptDigest",
118
118
  },
119
119
  }
120
+ EXECUTION_DELIVERY_CONTRACT = "execution-identity-v1"
121
+ EXECUTION_DELIVERY_PREFIXES = (
122
+ "**Execution delivery contract:**", "**Invocation metadata path:**",
123
+ *(f"**{field}:**" for field in (
124
+ "participantRef", "roleExecutionRef", "executionLabel", "invocationRef",
125
+ "dutyId", "attempt",
126
+ )),
127
+ )
120
128
 
121
129
 
122
130
  class AgentInvocationError(RuntimeError):
@@ -586,6 +594,12 @@ def materialize_retry_invocation(
586
594
  manifest_path = _project_path(
587
595
  project_root, source.get("runManifestPath"), must_exist=True
588
596
  )
597
+ errors = verify_agent_invocation(
598
+ metadata_path, project_root=project_root,
599
+ expected_run_manifest_path=manifest_path,
600
+ )
601
+ if errors:
602
+ raise AgentInvocationError("cannot retry invalid source invocation: " + "; ".join(errors))
589
603
  prompt_path = _project_path(
590
604
  project_root, prompt_spec.get("path"), must_exist=True
591
605
  )
@@ -600,10 +614,14 @@ def materialize_retry_invocation(
600
614
  retry_metadata["invocationId"] = retry_invocation_id
601
615
  retry_metadata["attempt"] = attempt
602
616
  retry_metadata["prompt"] = {
617
+ **prompt_spec,
603
618
  "path": _project_relative(
604
619
  retry_prompt_path, project_root, must_exist=False
605
620
  )
606
621
  }
622
+ retry_body = _retry_prompt_bytes(
623
+ prompt_path.read_bytes(), retry_metadata, retry_prompt_path, retry_metadata_path,
624
+ )
607
625
  manifest = _load_json_object(manifest_path, "run manifest")
608
626
  contract = manifest.get("agentContract")
609
627
  if not isinstance(contract, dict):
@@ -636,7 +654,7 @@ def materialize_retry_invocation(
636
654
  _pretty_json(reservation),
637
655
  )
638
656
  with _exclusive_lock(_prompt_lock_path(retry_prompt_path)):
639
- _publish_or_reuse_exact(retry_prompt_path, prompt_path.read_bytes())
657
+ _publish_or_reuse_exact(retry_prompt_path, retry_body)
640
658
  _publish_or_reuse_exact(
641
659
  retry_metadata_path, _pretty_json(retry_metadata)
642
660
  )
@@ -665,6 +683,34 @@ def materialize_retry_invocation(
665
683
  )
666
684
 
667
685
 
686
+ def _retry_prompt_bytes(
687
+ body: bytes, metadata: dict[str, object], prompt_path: Path, metadata_path: Path,
688
+ ) -> bytes:
689
+ if metadata["prompt"].get("deliveryContract") != EXECUTION_DELIVERY_CONTRACT:
690
+ return body
691
+ prefix, remainder = body.decode("utf-8").split("\n\n## Duty Contract\n\n", 1)
692
+ paths = {
693
+ "**Prompt History Path:**": metadata["prompt"]["path"],
694
+ "Assigned worker prompt history path:": str(prompt_path),
695
+ }
696
+ lines = [line for line in prefix.splitlines() if not line.startswith(EXECUTION_DELIVERY_PREFIXES)]
697
+ lines = [next((f"{label} {path}" for label, path in paths.items() if line.startswith(label)), line) for line in lines]
698
+ lines.extend(_execution_delivery_lines(metadata, metadata_path))
699
+ text = "\n".join(lines) + "\n\n## Duty Contract\n\n" + remainder
700
+ prefix, task = _split_prompt(text)
701
+ instruction = {
702
+ "anchorLines": _extract_anchor_lines(prefix),
703
+ "body": task.rstrip("\n"),
704
+ "sourcePaths": metadata["instruction"]["sourcePaths"],
705
+ }
706
+ metadata["digests"] = {
707
+ **metadata["digests"],
708
+ "instructionDigest": _sha256(_canonical_json(instruction)),
709
+ "promptDigest": _sha256(text.encode("utf-8")),
710
+ }
711
+ return text.encode("utf-8")
712
+
713
+
668
714
  def compose_agent_prompt(request: AgentInvocationRequest) -> str:
669
715
  """Render and validate a prompt candidate without publishing artifacts."""
670
716
  _validate_request(request)
@@ -995,9 +1041,11 @@ def _materialize(request: AgentInvocationRequest) -> _MaterializedInvocation:
995
1041
  duty = catalog[request.audience]
996
1042
  if _request_identity_version(request) == 2 and request.duty_id != duty.id:
997
1043
  raise AgentInvocationError("v2 execution identity duty does not match audience")
998
- prompt_bytes = _render_prompt(request, common, duty).encode("utf-8")
1044
+ metadata = _metadata_payload(request, duty, {})
1045
+ delivery = _execution_delivery_lines(metadata, request.metadata_path)
1046
+ prompt_bytes = _render_prompt(request, common, duty, delivery).encode("utf-8")
999
1047
  digests = _invocation_digests(request, duty, prompt_bytes)
1000
- metadata = _metadata_payload(request, duty, digests)
1048
+ metadata["digests"] = digests
1001
1049
  prepared = PreparedAgentInvocation(
1002
1050
  invocation_id=request.invocation_id,
1003
1051
  worker_id=request.worker_id,
@@ -1212,6 +1260,7 @@ def _render_prompt(
1212
1260
  request: AgentInvocationRequest,
1213
1261
  common: DutyContract,
1214
1262
  duty: DutyContract,
1263
+ delivery: tuple[str, ...] = (),
1215
1264
  ) -> str:
1216
1265
  assignment = request.assignment
1217
1266
  task_body = request.instruction.body.rstrip("\n")
@@ -1255,6 +1304,7 @@ def _render_prompt(
1255
1304
  header.append(f"{delivery_mode} eager-include")
1256
1305
  if assignment.host_model_value is not None:
1257
1306
  header.append(f"**Host model value:** {assignment.host_model_value}")
1307
+ header.extend(delivery)
1258
1308
  duty_body = f"{common.body.rstrip()}\n\n{duty.body.rstrip()}"
1259
1309
  return (
1260
1310
  "\n".join(header)
@@ -1367,6 +1417,7 @@ def _metadata_payload(
1367
1417
  "attempt": request.attempt,
1368
1418
  })
1369
1419
  payload.pop("workerId")
1420
+ payload["prompt"]["deliveryContract"] = EXECUTION_DELIVERY_CONTRACT
1370
1421
  return payload
1371
1422
 
1372
1423
 
@@ -1591,7 +1642,7 @@ def _replace_undispatched(
1591
1642
  """
1592
1643
  if not request.replace_undispatched:
1593
1644
  raise _existing_conflict(f"existing prompt differs ({difference})")
1594
- dispatched = _recorded_dispatch_ids(request)
1645
+ dispatched = invocation_dispatch_ids(request)
1595
1646
  if dispatched:
1596
1647
  raise _existing_conflict(
1597
1648
  "existing prompt differs and this invocation was already "
@@ -1607,7 +1658,7 @@ def _replace_undispatched(
1607
1658
  )
1608
1659
 
1609
1660
 
1610
- def _recorded_dispatch_ids(request: AgentInvocationRequest) -> set[str]:
1661
+ def invocation_dispatch_ids(request: AgentInvocationRequest) -> set[str]:
1611
1662
  """Dispatch rows in team-state that already reference this invocation id.
1612
1663
 
1613
1664
  Both ledgers are read: `agentDispatches` is written by the host-native
@@ -1746,9 +1797,56 @@ def _verify_prompt_file(
1746
1797
  errors = []
1747
1798
  errors.extend(_verify_instruction_digest(metadata, body))
1748
1799
  errors.extend(_verify_model_header(metadata, body))
1800
+ errors.extend(_verify_execution_delivery(metadata, path, body))
1749
1801
  return errors
1750
1802
 
1751
1803
 
1804
+ def _execution_delivery_lines(
1805
+ metadata: Mapping[str, object], metadata_path: Path,
1806
+ ) -> tuple[str, ...]:
1807
+ if metadata.get("executionIdentityVersion") != 2:
1808
+ return ()
1809
+ values = (
1810
+ EXECUTION_DELIVERY_CONTRACT, str(metadata_path.resolve()),
1811
+ *(str(metadata[key]) for key in (
1812
+ "participantRef", "roleExecutionRef", "executionLabel", "invocationRef",
1813
+ "dutyId", "attempt",
1814
+ )),
1815
+ )
1816
+ return tuple(f"{prefix} {value}" for prefix, value in zip(EXECUTION_DELIVERY_PREFIXES, values))
1817
+
1818
+
1819
+ def _verify_execution_delivery(
1820
+ metadata: Mapping[str, object], prompt_path: Path, body: bytes,
1821
+ ) -> list[str]:
1822
+ if metadata["prompt"].get("deliveryContract") != EXECUTION_DELIVERY_CONTRACT:
1823
+ return []
1824
+ metadata_path = prompt_path.with_name(prompt_path.name + ".meta.json")
1825
+ expected = _execution_delivery_lines(metadata, metadata_path)
1826
+ prefix = body.decode("utf-8").split("\n\n## Duty Contract\n\n", 1)[0]
1827
+ actual = tuple(line for line in prefix.splitlines() if line.startswith(EXECUTION_DELIVERY_PREFIXES))
1828
+ if actual != expected:
1829
+ return ["prompt execution identity or metadata path does not match invocation metadata"]
1830
+ return []
1831
+
1832
+
1833
+ def agent_prompt_task_bytes(prompt_bytes: bytes) -> bytes:
1834
+ """์‹œ๋„๋ณ„ ์ „๋‹ฌ๊ฐ’์„ ์ œ์™ธํ•œ ๋…ผ๋ฆฌ ์ž‘์—… ์ž…๋ ฅ. ์˜ˆ์•ฝ๊ณผ ๋‘ ๋ฐฐ์น˜ ๊ฒฝ๊ณ„๊ฐ€ ๊ณต์œ ํ•œ๋‹ค."""
1835
+ prefix, remainder = prompt_bytes.decode("utf-8").split("\n\n## Duty Contract\n\n", 1)
1836
+ history_prefixes = ("**Prompt History Path:**", "Assigned worker prompt history path:")
1837
+ lines = [line for line in prefix.splitlines() if not line.startswith(EXECUTION_DELIVERY_PREFIXES)]
1838
+ lines = [next((label for label in history_prefixes if line.startswith(label)), line) for line in lines]
1839
+ return ("\n".join(lines) + "\n\n## Duty Contract\n\n" + remainder).encode("utf-8")
1840
+
1841
+
1842
+ def invocation_input_digest(metadata: Mapping[str, object], project_root: Path) -> str:
1843
+ """๊ตฌํ˜• ์˜ˆ์•ฝ์˜ ์ž…๋ ฅ ํ•ด์‹œ๋ฅผ ์œ ์ง€ํ•˜๊ณ  ์ƒˆ ์ „๋‹ฌ ๊ณ„์•ฝ์€ ๋…ผ๋ฆฌ ์ž‘์—…์„ ํ•ด์‹œํ•œ๋‹ค."""
1844
+ if metadata["prompt"].get("deliveryContract") != EXECUTION_DELIVERY_CONTRACT:
1845
+ return str(metadata["digests"]["promptDigest"])
1846
+ path = _project_path(project_root, metadata["prompt"]["path"], must_exist=True)
1847
+ return _sha256(agent_prompt_task_bytes(path.read_bytes()))
1848
+
1849
+
1752
1850
  def _verify_instruction_digest(
1753
1851
  metadata: Mapping[str, object],
1754
1852
  prompt_bytes: bytes,
@@ -2137,6 +2235,10 @@ def _metadata_schema_is_exact(metadata: Mapping[str, object]) -> bool:
2137
2235
  return False
2138
2236
  for key, expected_keys in _NESTED_KEYS.items():
2139
2237
  value = metadata.get(key)
2238
+ if key == "prompt" and isinstance(value, dict) and "deliveryContract" in value:
2239
+ if schema != "2.0" or value["deliveryContract"] != EXECUTION_DELIVERY_CONTRACT:
2240
+ return False
2241
+ expected_keys = expected_keys | {"deliveryContract"}
2140
2242
  if not isinstance(value, dict) or set(value) != expected_keys:
2141
2243
  return False
2142
2244
  sources = metadata["instruction"]["sourcePaths"]
@@ -16,6 +16,7 @@ from ...dispatch_state import BACKEND_CLI_WRAPPER, BACKEND_CMUX_PANE, DispatchEr
16
16
  from .emit import _emit, _prepared_payload
17
17
  from .inputs import AgentPromptCliError
18
18
  from .materialize import _apply_corrections, _check_corrections, _materialize
19
+ from .jobs import generate_jobs
19
20
  from .results import (
20
21
  _complete,
21
22
  _link_result,
@@ -30,6 +31,7 @@ from .results import (
30
31
 
31
32
  _CLI_EPILOG = r"""Usage:
32
33
  okstra agent-prompt materialize [options]
34
+ okstra agent-prompt jobs --project-root <dir> --run-manifest <path> --dispatch-kind <kind> --metadata <path> [--metadata <path>] --out <path>
33
35
  okstra agent-prompt check-corrections --project-root <dir> --run-manifest <path> --corrections <file>
34
36
  okstra agent-prompt apply-corrections --project-root <dir> --run-manifest <path> --corrections <file>
35
37
  okstra agent-prompt verify [options]
@@ -54,6 +56,13 @@ def _parser() -> argparse.ArgumentParser:
54
56
  _add_materialize_parser(commands)
55
57
  _add_completion_parsers(commands)
56
58
  _add_dispatch_parsers(commands)
59
+ jobs = commands.add_parser("jobs", help="generate a verified jobs file without dispatching")
60
+ _common_paths(jobs)
61
+ jobs.add_argument("--run-manifest", required=True)
62
+ jobs.add_argument("--dispatch-kind", required=True)
63
+ jobs.add_argument("--metadata", action="append", required=True)
64
+ jobs.add_argument("--out", required=True)
65
+ jobs.add_argument("--json", action="store_true")
57
66
  return parser
58
67
 
59
68
 
@@ -221,6 +230,13 @@ def _standalone_completion_args(parser: argparse.ArgumentParser) -> None:
221
230
  def main(argv: list[str] | None = None) -> int:
222
231
  try:
223
232
  args = _parser().parse_args(argv)
233
+ if args.command == "jobs":
234
+ payload = generate_jobs(args)
235
+ if args.json:
236
+ _emit(payload, True)
237
+ else:
238
+ print(payload["jobsPath"])
239
+ return 0
224
240
  if args.command == "materialize":
225
241
  prepared = _materialize(args)
226
242
  _emit(_prepared_payload(prepared, args.audience), args.json)
@@ -18,6 +18,7 @@ from ..invocation import (
18
18
  AgentModelAssignment,
19
19
  compose_agent_prompt,
20
20
  compose_unbound_run_prompt,
21
+ agent_prompt_task_bytes,
21
22
  v2_role_assignment_authority_errors,
22
23
  )
23
24
  from ...convergence_store import (
@@ -47,7 +48,7 @@ def _reserve_dynamic_verifier_request(
47
48
  duty_id=args.audience,
48
49
  round_number=_reverify_round(args.dispatch_kind),
49
50
  task_key=_required_manifest_string(manifest, "taskKey"),
50
- input_digest="sha256:" + hashlib.sha256(prompt_bytes).hexdigest(),
51
+ input_digest="sha256:" + hashlib.sha256(agent_prompt_task_bytes(prompt_bytes)).hexdigest(),
51
52
  invocation_ref=args.invocation_id,
52
53
  )
53
54
  bound = replace(
@@ -58,7 +59,7 @@ def _reserve_dynamic_verifier_request(
58
59
  duty_id=args.audience,
59
60
  invocation_ref=invocation.invocation_ref,
60
61
  )
61
- if compose_agent_prompt(bound).encode("utf-8") != prompt_bytes:
62
+ if agent_prompt_task_bytes(compose_agent_prompt(bound).encode("utf-8")) != agent_prompt_task_bytes(prompt_bytes):
62
63
  raise AgentPromptCliError(
63
64
  "dynamic verifier identity changed immutable prompt bytes"
64
65
  )
@@ -0,0 +1,87 @@
1
+ """๊ฒ€์ฆ๋œ ํ˜ธ์ถœ์—์„œ ๋ฐฐ์น˜ ์ž…๋ ฅ์„ ์ƒ์„ฑํ•˜๊ณ  ์ „์ฒด ๊ฒ€์‚ฌ ๋’ค ๋ถˆ๋ณ€ ๋ฐœํ–‰ํ•œ๋‹ค."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any, Mapping
8
+
9
+ from ..invocation import (
10
+ _exclusive_lock, _publish_or_reuse_exact, verify_agent_invocation,
11
+ )
12
+ from ...dispatch_state import worker_jobs_from_payload, validate_dispatch_prompts, prompt_anchor_values
13
+ from ...models import provider_wrappers
14
+ from ...path_hints import hydrate_active_run_context
15
+ from ...paths import okstra_home
16
+ from .inputs import (
17
+ AgentPromptCliError, _authorized_path, _project_input, _project_root,
18
+ _read_json_object,
19
+ )
20
+
21
+
22
+ def generate_jobs(args: argparse.Namespace) -> dict[str, object]:
23
+ project = _project_root(args.project_root)
24
+ manifest_path = _project_input(project, args.run_manifest, "run manifest")
25
+ manifest = _read_json_object(manifest_path, "run manifest")
26
+ if manifest.get("executionIdentityVersion") != 2:
27
+ raise AgentPromptCliError("agent-prompt jobs requires a v2 run manifest")
28
+ output = _authorized_path(project, args.out, [manifest["runDirectoryPath"]], "jobs", must_exist=False)
29
+ paths = [_project_input(project, value, "metadata") for value in args.metadata]
30
+ rows = [_metadata_job(project, manifest_path, manifest, path, args.dispatch_kind) for path in paths]
31
+ refs = [(row["invocationRef"], row["attempt"]) for row in rows]
32
+ if len(set(refs)) != len(refs):
33
+ raise AgentPromptCliError("jobs metadata contains duplicate invocation attempts")
34
+ context_path = _project_input(project, manifest["activeRunContextPath"], "active run context")
35
+ context = hydrate_active_run_context(_read_json_object(context_path, "active run context"))
36
+ payload = {"dispatchKind": args.dispatch_kind, "workers": rows}
37
+ wrappers = provider_wrappers("analyser")
38
+ jobs = worker_jobs_from_payload(
39
+ project, payload, manifest=manifest, active_context=context,
40
+ backend="cli-wrapper", idle_timeout_seconds=None,
41
+ default_dispatch_kind=args.dispatch_kind,
42
+ resolve_wrapper=lambda provider: okstra_home() / "bin" / wrappers[provider],
43
+ default_provider=lambda worker_id: worker_id,
44
+ )
45
+ validate_dispatch_prompts(manifest, context, jobs)
46
+ body = (json.dumps(payload, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
47
+ with _exclusive_lock(output.with_name(f".{output.name}.lock")):
48
+ if output.exists() and output.read_bytes() != body:
49
+ raise AgentPromptCliError(f"jobs output differs; existing file preserved: {output}; choose a new --out path")
50
+ _publish_or_reuse_exact(output, body)
51
+ return {"ok": True, "jobsPath": str(output), "workers": len(rows)}
52
+
53
+
54
+ def _metadata_job(
55
+ project: Path, manifest_path: Path, manifest: Mapping[str, Any],
56
+ metadata_path: Path, dispatch_kind: str,
57
+ ) -> dict[str, object]:
58
+ errors = verify_agent_invocation(
59
+ metadata_path, project_root=project, expected_run_manifest_path=manifest_path,
60
+ )
61
+ if errors:
62
+ raise AgentPromptCliError(f"{metadata_path}: " + "; ".join(errors))
63
+ metadata = _read_json_object(metadata_path, "agent metadata")
64
+ if metadata.get("executionIdentityVersion") != 2 or metadata["dispatchKind"] != dispatch_kind:
65
+ raise AgentPromptCliError(f"{metadata_path}: expected v2 dispatchKind {dispatch_kind!r}")
66
+ execution = next(row for row in manifest["roleExecutions"] if row["roleExecutionRef"] == metadata["roleExecutionRef"])
67
+ if execution["role"] == "translator":
68
+ raise AgentPromptCliError("translator uses canonical reservation dispatch: worker-dispatch --workers translator")
69
+ prompt = _project_input(project, metadata["prompt"]["path"], "prompt")
70
+ anchors = prompt_anchor_values(prompt, ("Result Path", "Worker Result Path"))
71
+ result = anchors.get("Worker Result Path") or anchors.get("Result Path")
72
+ if not result:
73
+ raise AgentPromptCliError(f"prompt has no result anchor: {prompt}")
74
+ assignment = metadata["modelAssignment"]
75
+ if assignment["provider"] not in provider_wrappers("analyser"):
76
+ raise AgentPromptCliError(f"unsupported worker provider: {assignment['provider']}")
77
+ return {
78
+ **{key: metadata[key] for key in (
79
+ "schemaVersion", "executionIdentityVersion", "participantRef", "roleExecutionRef",
80
+ "executionLabel", "dutyId", "invocationRef", "attempt", "invocationId",
81
+ "audience", "assignmentRef", "digests",
82
+ )},
83
+ "provider": assignment["provider"], "modelExecutionValue": assignment["modelExecutionValue"],
84
+ "hostModelValue": assignment["hostModelValue"], "role": execution["role"],
85
+ "promptPath": str(prompt), "promptMetadataPath": str(metadata_path),
86
+ "workerResultPath": result, "enforcementMode": "core-pre-dispatch",
87
+ }
@@ -26,11 +26,15 @@ from ..invocation import (
26
26
  digest_duty_catalog,
27
27
  invocation_execution_identity_from_manifest,
28
28
  prepare_agent_invocation,
29
+ compose_agent_prompt,
30
+ compose_unbound_run_prompt,
29
31
  )
30
32
  from ...assignment_environment import load_assignment_context
31
33
  from ...assignment_resolver import AssignmentContext, resolve_dispatch_assignment
32
34
  from ...path_hints import hydrate_active_run_context
33
35
  from ...worker_prompt_headers import worker_prompt_headers
36
+ from ...worker_prompt_contract import complete_reverify_instruction, validate_reverify_prompt
37
+ from ...paths import okstra_home
34
38
  from ...final_report_paths import final_report_data_path
35
39
  from ...final_report_schema import load_schema_version
36
40
  from ...report_inputs import report_narrative_path, uses_report_contract_v3
@@ -207,8 +211,31 @@ def _materialize_run(
207
211
  result_path=result_path,
208
212
  audit_source_path=audit_source_path,
209
213
  )
210
- elif audit_source_path is None:
211
- audit_source_path = result_path
214
+ elif (
215
+ audit_source_path is None
216
+ or _normalized(audit_source_path) == _normalized(result_path)
217
+ ):
218
+ # translator ๋‹ค. report-writer ์™€ ๊ฐ™์€ ๋‘-๊ฒฝ๋กœ audience ์ธ๋ฐ ์ข…์ „์—” ๋น ์ง„
219
+ # `--audit-source` ๋ฅผ `--result` ๋กœ ์กฐ์šฉํžˆ ์ ‘์—ˆ๋‹ค. ๊ทธ๋Ÿฌ๋ฉด ํ”„๋กฌํ”„ํŠธ์—
220
+ # `**Worker Result Path:**` ์•ต์ปค๊ฐ€ ์•ˆ ์ƒ๊ธฐ๋Š”๋ฐ, `worker-dispatch` ๋Š” ๊ทธ
221
+ # ์•ต์ปค๊ฐ€ `**Result Path:**` ์™€ ๋‹ค๋ฅผ ๊ฒƒ์„ ์š”๊ตฌํ•˜๋ฏ€๋กœ ๋ฐœํ–‰์€ ๋˜์ง€๋งŒ ์–ด๋–ค
222
+ # ๋””์ŠคํŒจ์น˜๋„ ๋ฐ›์ง€ ๋ชปํ•˜๋Š” ์˜ˆ์•ฝ์ด ๋‚จ๋Š”๋‹ค. ์˜ˆ์•ฝ์€ ํšŒ์ˆ˜ ๋ช…๋ น์ด ์—†์–ด ๋ฆฌ๋“œ๊ฐ€
223
+ # ๋‘ ๋ฒˆ์งธ id ๋กœ ๋‹ค์‹œ ๋งŒ๋“œ๋Š” ์ˆœ๊ฐ„ "exactly one reservation" ์ด ์˜์˜
224
+ # ๊นจ์กŒ๋‹ค(2026-09-08 ์‹ค์ธก, fontsninja-v3-site dev-10627-2
225
+ # requirements-discovery). ๋ฐœํ–‰ ์‹œ์ ์— ๊ฑฐ์ ˆํ•œ๋‹ค.
226
+ raise AgentPromptCliError(
227
+ "translator materialization requires --audit-source distinct from "
228
+ "--result: --result is the translator's own report "
229
+ "(e.g. worker-results/translator-translations-<task-type>-<seq>.md) "
230
+ "and --audit-source the worker-result .md the audit sidecar is "
231
+ "derived from (worker-results/translator-worker-<task-type>-<seq>.md). "
232
+ "`worker-dispatch --workers translator` reads both from the prompt's "
233
+ "**Result Path:** and **Worker Result Path:** anchors and refuses a "
234
+ "prompt without the second. To fix a prompt already published "
235
+ "without it, re-run this command with the same --invocation-id and "
236
+ "--prompt plus --replace-undispatched; a new invocation id would "
237
+ "leave two reservations and block the translator for this run"
238
+ )
212
239
  _validate_run_identity(
213
240
  manifest,
214
241
  worker_id=args.worker_id,
@@ -272,6 +299,9 @@ def _materialize_run(
272
299
  active_context=active_context,
273
300
  ))
274
301
  body = instruction_path.read_text(encoding="utf-8")
302
+ is_reverify = args.dispatch_kind.startswith("reverify-r")
303
+ if is_reverify:
304
+ body = _complete_run_reverify_body(args, manifest, active_context, assignment, body, instruction_path)
275
305
  if args.audience == "report-writer":
276
306
  body = _with_inputs_section(
277
307
  body,
@@ -315,7 +345,9 @@ def _materialize_run(
315
345
  source_paths=(AgentInstructionSource(
316
346
  kind="project",
317
347
  path=_relative(project_root, instruction_path),
318
- ),),
348
+ ),) + ((AgentInstructionSource(
349
+ kind="runtime", path="templates/reverify-output-contract.md",
350
+ ),) if is_reverify else ()),
319
351
  ),
320
352
  project_root=project_root,
321
353
  run_manifest_path=manifest_path,
@@ -332,6 +364,15 @@ def _materialize_run(
332
364
  attempt=1,
333
365
  replace_undispatched=args.replace_undispatched,
334
366
  )
367
+ if is_reverify:
368
+ candidate = compose_unbound_run_prompt(request).decode("utf-8") if dynamic_source else compose_agent_prompt(request)
369
+ errors = validate_reverify_prompt(
370
+ candidate, task_type=str(manifest["taskType"]),
371
+ forbidden_actions=str(active_context["workflow"]["forbiddenActions"]),
372
+ expected_model=assignment.model_execution_value,
373
+ )
374
+ if errors:
375
+ raise AgentPromptCliError("; ".join(errors))
335
376
  if dynamic_source is not None:
336
377
  request = _reserve_dynamic_verifier_request(
337
378
  request,
@@ -343,6 +384,34 @@ def _materialize_run(
343
384
  return prepare_agent_invocation(request)
344
385
 
345
386
 
387
+ def _complete_run_reverify_body(
388
+ args: argparse.Namespace, manifest: Mapping[str, Any],
389
+ active_context: Mapping[str, Any], assignment: AgentModelAssignment,
390
+ body: str, instruction_path: Path,
391
+ ) -> str:
392
+ workflow = _mapping(active_context.get("workflow"), "active workflow")
393
+ forbidden = workflow.get("forbiddenActions")
394
+ if not isinstance(forbidden, str) or not forbidden.strip():
395
+ raise AgentPromptCliError("active workflow.forbiddenActions is missing or invalid")
396
+ resources = active_context.get("runtimeResources") or {}
397
+ error_contract = resources.get("workerErrorContractPath")
398
+ template = Path(
399
+ error_contract or okstra_home() / "templates/worker-error-contract.md"
400
+ ).with_name("reverify-output-contract.md")
401
+ try:
402
+ return complete_reverify_instruction(
403
+ body, task_type=str(manifest["taskType"]), forbidden_actions=forbidden,
404
+ model=assignment.model_execution_value,
405
+ output_contract=template.read_text(encoding="utf-8"),
406
+ )
407
+ except ValueError as exc:
408
+ raise AgentPromptCliError(
409
+ f"{exc}; correct {instruction_path} and rerun agent-prompt materialize "
410
+ f"with --invocation-id {args.invocation_id} --prompt {args.prompt} "
411
+ "(use a fresh ID and path if already dispatched or dynamically reserved)"
412
+ ) from exc
413
+
414
+
346
415
  def _normalized(path: Path) -> Path:
347
416
  return Path(os.path.normpath(path))
348
417
 
@@ -4,6 +4,8 @@ from __future__ import annotations
4
4
  import re
5
5
  from pathlib import Path
6
6
 
7
+ from . import group_context
8
+
7
9
 
8
10
  BRIEF_SECTIONS = (
9
11
  "Identity",
@@ -136,10 +138,15 @@ def build_analysis_packet(
136
138
  bool(group_context_text),
137
139
  )
138
140
  )
139
- # ๊ทธ๋ฃน ๋งฅ๋ฝ์€ ๋ธŒ๋ฆฌํ”„ ๋ฐœ์ทŒ๋ณด๋‹ค ์•ž์— ๋‘”๋‹ค: ์งง๊ณ  "์™œ" ๋ฅผ ๋‹ด์œผ๋ฉฐ, 46KB ์—์„œ ์ž˜๋ฆฌ๋Š”
140
- # ์ฝ๊ธฐ(agy view_file)์—์„œ๋„ ๋„์ฐฉํ•ด์•ผ ํ•˜๋Š” ์ ˆ์ด๋‹ค.
141
- parts.extend(_group_context_block(group_context_text))
141
+ # ๊ทธ๋ฃน ๋ฌธ์„œ๋Š” ์‚ฌ๋žŒ ์ ˆ๊ณผ okstra ๋ฉ”๋ชจ๋ฆฌ ์˜์—ญ์œผ๋กœ ๊ฐˆ๋ผ ์‹ฃ๋Š”๋‹ค. ์‚ฌ๋žŒ ์ ˆ("์™œ")์€
142
+ # ๋ธŒ๋ฆฌํ”„ ๋ฐœ์ทŒ๋ณด๋‹ค ์•ž โ€” ์งง๊ณ , 46KB ์—์„œ ์ž˜๋ฆฌ๋Š” ์ฝ๊ธฐ(agy view_file)์—์„œ๋„
143
+ # ๋„์ฐฉํ•ด์•ผ ํ•œ๋‹ค. ํ˜•์ œ task ์˜ ๊ฒฐ๋ก ์€ ๋ธŒ๋ฆฌํ”„ ๋’ค โ€” ๋ธŒ๋ฆฌํ”„๋ฅผ ๋ฐ€์–ด๋‚ด๋ฉด ์•ˆ ๋œ๋‹ค.
144
+ group_human, group_memory, _ = group_context.split_memory_region(
145
+ _strip_frontmatter(group_context_text)
146
+ )
147
+ parts.extend(_group_context_block(group_human))
142
148
  parts.extend(_brief_block(brief_text))
149
+ parts.extend(_group_memory_block(group_memory, _own_task_segment(task_key)))
143
150
  parts.extend(_profile_block(task_type, profile_text))
144
151
  parts.extend(_reference_block(reference_text))
145
152
  parts.extend(_fix_history_block(fix_history_text))
@@ -296,8 +303,20 @@ GROUP_CONTEXT_PREFACE = (
296
303
  )
297
304
 
298
305
 
299
- def _group_context_block(group_context_text: str) -> list[str]:
300
- if not group_context_text.strip():
306
+ GROUP_MEMORY_PREFACE = (
307
+ "What the other tasks of this task-group concluded in their latest run, "
308
+ "written by okstra at each run's report-finalize into the group's "
309
+ "`group-context.md`. A headline is a claim; its evidence is the record at "
310
+ "`record`. This section is not this task's requirement ledger, and a sibling's "
311
+ "decision does not bind this run: where a sibling's conclusion conflicts with "
312
+ "the `## Task-Specific Brief Extract`, raise a `Clarification Items` row rather "
313
+ "than re-deriving what the sibling already settled or silently overriding it."
314
+ )
315
+
316
+
317
+ def _group_context_block(human_text: str) -> list[str]:
318
+ """์‚ฌ๋žŒ ์ ˆ๋งŒ ์‹ฃ๋Š”๋‹ค. ๋ฉ”๋ชจ๋ฆฌ ์ „์šฉ ๋ฌธ์„œ(์‚ฌ๋žŒ `## ` ์ ˆ ์—†์Œ)๋Š” ์ ˆ์„ ๋งŒ๋“ค์ง€ ์•Š๋Š”๋‹ค."""
319
+ if not any(line.startswith("## ") for line in human_text.splitlines()):
301
320
  return []
302
321
  return [
303
322
  "",
@@ -305,7 +324,29 @@ def _group_context_block(group_context_text: str) -> list[str]:
305
324
  "",
306
325
  GROUP_CONTEXT_PREFACE,
307
326
  "",
308
- _strip_frontmatter(group_context_text).strip(),
327
+ human_text.strip(),
328
+ ]
329
+
330
+
331
+ def _own_task_segment(task_key: str) -> str:
332
+ return group_context.slugify_task_segment(re.split(r"[:/]", task_key)[-1])
333
+
334
+
335
+ def _group_memory_block(region: str, own_task_segment: str) -> list[str]:
336
+ """ํ˜•์ œ task ์˜ ํ•ญ๋ชฉ๋งŒ ์‹ฃ๋Š”๋‹ค. ์ž๊ธฐ ํ•ญ๋ชฉ์€ timelineยทprior-planning ์ด ์ด๋ฏธ ๋‚˜๋ฅธ๋‹ค."""
337
+ entries = [
338
+ entry for entry in group_context.parse_memory_entries(region)
339
+ if entry.task_id != own_task_segment
340
+ ]
341
+ if not entries:
342
+ return []
343
+ return [
344
+ "",
345
+ "## Task-Group Memory",
346
+ "",
347
+ GROUP_MEMORY_PREFACE,
348
+ "",
349
+ group_context.render_memory_entries(entries).strip(),
309
350
  ]
310
351
 
311
352
 
@@ -225,6 +225,13 @@ def plan_worker_placement(
225
225
  return _extend_the_worker_grid(workers)
226
226
 
227
227
 
228
+ def _overlaps_vertically(first: PaneGeometry, second: PaneGeometry) -> bool:
229
+ """๊ฒฝ๊ณ„๋งŒ ๋งž๋‹ฟ์€ ์œ„์•„๋ž˜ ์„ธ์…˜์€ ๊ฐ€๋กœ ๊ณต๊ฐ„์„ ๊ณต์œ ํ•˜์ง€ ์•Š๋Š”๋‹ค."""
230
+ return max(first.y, second.y) < min(
231
+ first.y + first.height_points, second.y + second.height_points,
232
+ )
233
+
234
+
228
235
  def lead_target_width(
229
236
  panes: Sequence[PaneGeometry],
230
237
  *,
@@ -238,20 +245,29 @@ def lead_target_width(
238
245
  fourth worker is on screen, all of it when they are gone. The share is
239
246
  taken of what okstra may actually place into, not of the window: a
240
247
  workspace can hold panes okstra never opened, and handing the lead the
241
- whole container would shove those off their own width. Their width is
242
- subtracted first and the share applies to the remainder.
248
+ whole container would shove those off their own width.
249
+
250
+ ๋ฆฌ๋“œ์™€ ์„ธ๋กœ ์˜์—ญ์ด ๊ฒน์น˜๋Š” ๋‹ค๋ฅธ ์ฐฝ์˜ ๊ฐ€๋กœ ๊ตฌ๊ฐ„๋งŒ ์ฐจ๊ฐํ•œ๋‹ค. ์œ„์•„๋ž˜๋กœ
251
+ ์Œ“์ธ ์ฐฝ๋“ค์ด ๊ฐ™์€ ๊ฐ€๋กœ ๊ตฌ๊ฐ„์„ ์ฐจ์ง€ํ•˜๋ฉด ํ•ด๋‹น ๊ตฌ๊ฐ„์€ ํ•œ ๋ฒˆ๋งŒ ์ฐจ๊ฐํ•œ๋‹ค.
243
252
 
244
253
  Deriving the target from the container rather than from a fixed column count
245
254
  is what keeps the split honest at any window size โ€” a hardcoded 80 columns
246
255
  is two fifths of one particular display and an arbitrary slice of every
247
256
  other.
248
257
  """
249
- strangers = sum(
250
- pane.width_points
258
+ lead = _pane_by_id(panes, lead_pane_id)
259
+ intervals = sorted(
260
+ (pane.x, pane.x + pane.width_points)
251
261
  for pane in panes
252
262
  if pane.pane_id != lead_pane_id
253
263
  and not _holds_an_okstra_surface(pane, owned_surface_ids)
264
+ and _overlaps_vertically(lead, pane)
254
265
  )
266
+ strangers = 0.0
267
+ right_edge = float("-inf")
268
+ for left, right in intervals:
269
+ strangers += max(0.0, right - max(left, right_edge))
270
+ right_edge = max(right_edge, right)
255
271
  usable = container_width_points - strangers
256
272
  if usable <= 0:
257
273
  return 0.0
@@ -707,18 +723,23 @@ def _size_lead_pane(workspace: str, owned_surface_ids: Collection[str]) -> None:
707
723
  return
708
724
  panes = _panes_from(payload)
709
725
  lead = _lead_pane(panes)
710
- offset = lead_resize_points(
711
- lead,
712
- target_width_points=lead_target_width(
713
- panes,
714
- lead_pane_id=lead.pane_id,
715
- owned_surface_ids=owned_surface_ids,
716
- container_width_points=container_width,
717
- ),
726
+ target = lead_target_width(
727
+ panes,
728
+ lead_pane_id=lead.pane_id,
729
+ owned_surface_ids=owned_surface_ids,
730
+ container_width_points=container_width,
718
731
  )
732
+ if target <= 0:
733
+ # ์ž˜๋ชป๋˜๊ฑฐ๋‚˜ ๊ณต๊ฐ„์ด ์—†๋Š” ๋ฐฐ์น˜๋ฅผ ์ตœ์†Œ ํญ๊นŒ์ง€ ์ถ•์†Œํ•˜๋Š” ์š”์ฒญ์œผ๋กœ ๋ฐ”๊พธ์ง€ ์•Š๋Š”๋‹ค.
734
+ return
735
+ offset = lead_resize_points(lead, target_width_points=target)
719
736
  if offset == 0:
720
737
  return
721
- neighbours = [pane for pane in panes if pane.x > lead.x]
738
+ neighbours = [
739
+ pane for pane in panes
740
+ if abs(pane.x - (lead.x + lead.width_points)) <= 1
741
+ and _overlaps_vertically(lead, pane)
742
+ ]
722
743
  if not neighbours:
723
744
  return
724
745
  narrowing = offset > 0