okstra 0.167.0 → 0.169.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 (102) hide show
  1. package/README.md +6 -5
  2. package/docs/architecture/storage-model.md +57 -1
  3. package/docs/architecture.md +70 -2
  4. package/docs/cli.md +8 -4
  5. package/docs/for-ai/skills/okstra-code-review.md +3 -2
  6. package/docs/for-ai/skills/okstra-schedule-gen.md +3 -1
  7. package/docs/pr-template-usage.md +10 -6
  8. package/docs/project-structure-overview.md +14 -11
  9. package/package.json +1 -1
  10. package/runtime/BUILD.json +2 -2
  11. package/runtime/agents/workers/claude-worker.md +6 -5
  12. package/runtime/agents/workers/report-writer-worker.md +9 -4
  13. package/runtime/agents/workers/translator-worker.md +6 -4
  14. package/runtime/prompts/coding-preflight/architectures/hexagonal.md +27 -1
  15. package/runtime/prompts/coding-preflight/clean-code.md +13 -0
  16. package/runtime/prompts/duties/acceptance-critic.md +24 -0
  17. package/runtime/prompts/duties/acceptance-verifier.md +24 -0
  18. package/runtime/prompts/duties/analysis-worker.md +24 -0
  19. package/runtime/prompts/duties/code-reviewer.md +24 -0
  20. package/runtime/prompts/duties/common.md +35 -0
  21. package/runtime/prompts/duties/implementation-executor.md +24 -0
  22. package/runtime/prompts/duties/implementation-verifier.md +24 -0
  23. package/runtime/prompts/duties/lead.md +24 -0
  24. package/runtime/prompts/duties/report-writer.md +24 -0
  25. package/runtime/prompts/duties/reverification-worker.md +24 -0
  26. package/runtime/prompts/duties/schedule-verifier.md +24 -0
  27. package/runtime/prompts/duties/scope-critic.md +24 -0
  28. package/runtime/prompts/duties/translator.md +24 -0
  29. package/runtime/prompts/lead/convergence.md +51 -7
  30. package/runtime/prompts/lead/okstra-lead-contract.md +10 -20
  31. package/runtime/prompts/lead/plan-body-verification.md +16 -1
  32. package/runtime/prompts/lead/report-writer.md +20 -5
  33. package/runtime/prompts/lead/team-contract.md +13 -13
  34. package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -1
  35. package/runtime/prompts/profiles/_implementation-diff-review.md +3 -1
  36. package/runtime/prompts/profiles/_implementation-executor.md +1 -1
  37. package/runtime/prompts/profiles/_implementation-verifier.md +3 -1
  38. package/runtime/prompts/profiles/implementation.md +4 -2
  39. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/adapter.py +6 -0
  40. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +3 -2
  41. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +8 -0
  42. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +33 -0
  43. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +13 -12
  44. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +6 -0
  45. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +3 -2
  46. package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +2 -0
  47. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +3 -3
  48. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +6 -0
  49. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +2 -1
  50. package/runtime/python/okstra_ctl/adapters/hosts/kimi/adapter.py +6 -0
  51. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +2 -1
  52. package/runtime/python/okstra_ctl/agent_invocation.py +1502 -0
  53. package/runtime/python/okstra_ctl/agent_prompt_cli.py +788 -0
  54. package/runtime/python/okstra_ctl/codex_dispatch.py +2 -107
  55. package/runtime/python/okstra_ctl/context_cost.py +46 -5
  56. package/runtime/python/okstra_ctl/dispatch_core.py +312 -37
  57. package/runtime/python/okstra_ctl/dispatch_state.py +461 -36
  58. package/runtime/python/okstra_ctl/doctor.py +150 -16
  59. package/runtime/python/okstra_ctl/entrypoints/hosts.py +87 -9
  60. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +214 -23
  61. package/runtime/python/okstra_ctl/path_hints.py +26 -0
  62. package/runtime/python/okstra_ctl/paths.py +20 -0
  63. package/runtime/python/okstra_ctl/ports/__init__.py +8 -0
  64. package/runtime/python/okstra_ctl/ports/host.py +3 -0
  65. package/runtime/python/okstra_ctl/ports/host_model.py +60 -0
  66. package/runtime/python/okstra_ctl/pr_template.py +3 -6
  67. package/runtime/python/okstra_ctl/registry/host_registry.py +5 -0
  68. package/runtime/python/okstra_ctl/render.py +217 -12
  69. package/runtime/python/okstra_ctl/report_finalize.py +44 -0
  70. package/runtime/python/okstra_ctl/run.py +368 -51
  71. package/runtime/python/okstra_ctl/session.py +16 -12
  72. package/runtime/python/okstra_ctl/team.py +11 -11
  73. package/runtime/python/okstra_ctl/worker_dispatch.py +104 -0
  74. package/runtime/python/okstra_ctl/worker_prompt_body.py +5 -38
  75. package/runtime/python/okstra_ctl/worker_prompt_contract.py +32 -2
  76. package/runtime/python/okstra_ctl/worker_prompt_policy.py +38 -1
  77. package/runtime/skills/okstra-code-review/SKILL.md +22 -3
  78. package/runtime/skills/okstra-run/SKILL.md +16 -1
  79. package/runtime/skills/okstra-schedule-gen/SKILL.md +15 -1
  80. package/runtime/templates/implementation-worker-preamble.md +0 -10
  81. package/runtime/templates/report-writer-prompt-preamble.md +0 -9
  82. package/runtime/templates/reports/settings.template.json +0 -11
  83. package/runtime/templates/worker-prompt-preamble.md +0 -10
  84. package/runtime/validators/lib/fixtures.sh +93 -0
  85. package/runtime/validators/lib/validate-assets.sh +0 -8
  86. package/runtime/validators/validate-run.py +182 -0
  87. package/src/cli-registry.mjs +14 -0
  88. package/src/commands/execute/agent-prompt.mjs +25 -0
  89. package/src/commands/execute/codex-dispatch.mjs +6 -63
  90. package/src/commands/execute/worker-dispatch.mjs +76 -0
  91. package/src/commands/lifecycle/doctor.mjs +18 -3
  92. package/src/commands/lifecycle/install.mjs +33 -15
  93. package/src/commands/lifecycle/uninstall.mjs +4 -3
  94. package/src/lib/install-assets.mjs +9 -0
  95. package/runtime/agents/workers/antigravity-worker.md +0 -259
  96. package/runtime/agents/workers/codex-worker.md +0 -259
  97. package/runtime/agents/workers/grok-worker.md +0 -259
  98. package/runtime/agents/workers/kimi-worker.md +0 -259
  99. package/runtime/prompts/coding-preflight/scripts/preedit-check.sh +0 -79
  100. package/runtime/templates/operating-standard.md +0 -22
  101. package/src/lib/worker-agent-render.mjs +0 -50
  102. /package/runtime/templates/{prd → pr}/pr-body.template.md +0 -0
@@ -22,6 +22,7 @@ import re
22
22
  import shutil
23
23
  import subprocess as _subprocess
24
24
  import sys
25
+ import tempfile
25
26
  from dataclasses import dataclass, field
26
27
  from datetime import datetime, timezone
27
28
  from pathlib import Path
@@ -82,6 +83,18 @@ from .models import (
82
83
  from .schema_excerpt import build_schema_excerpt
83
84
  from .registry.host_registry import default_host_registry
84
85
  from .registry.provider_registry import default_provider_registry
86
+ from .ports.host_model import HostModelBindingError, HostModelBindingRequest
87
+ from .agent_invocation import (
88
+ AgentInstruction,
89
+ AgentInstructionSource,
90
+ AgentInvocationRequest,
91
+ AgentModelAssignment,
92
+ AgentInvocationError,
93
+ agent_model_assignment_from_payload,
94
+ digest_duty_catalog,
95
+ prepare_agent_invocation,
96
+ verify_agent_invocation,
97
+ )
85
98
  from .report_contract import CURRENT_REPORT_SCHEMA_VERSION
86
99
  from .path_resolve import relative_to_project_root, resolve_user_file
87
100
  from .render import (
@@ -1355,15 +1368,24 @@ class RoleAssignment:
1355
1368
  model_display: str
1356
1369
  model_execution_value: str
1357
1370
  runner: str
1371
+ host_runtime: str
1372
+ host_model_value: str | None
1358
1373
  worker_id: str = ""
1359
1374
 
1360
- def to_payload(self) -> dict[str, str]:
1361
- payload = {
1362
- "role": self.role,
1375
+ def to_model_payload(self) -> dict[str, object]:
1376
+ return {
1363
1377
  "provider": self.provider,
1364
1378
  "model": self.model_display,
1365
1379
  "modelExecutionValue": self.model_execution_value,
1366
1380
  "runner": self.runner,
1381
+ "hostRuntime": self.host_runtime,
1382
+ "hostModelValue": self.host_model_value,
1383
+ }
1384
+
1385
+ def to_payload(self) -> dict[str, object]:
1386
+ payload = {
1387
+ "role": self.role,
1388
+ **self.to_model_payload(),
1367
1389
  }
1368
1390
  if self.worker_id:
1369
1391
  payload["workerId"] = self.worker_id
@@ -1386,15 +1408,13 @@ class _ModelBindings:
1386
1408
  rw: ModelAssignment
1387
1409
  critic_choice: str
1388
1410
  critic_model_execution: str
1389
- executor_provider: str
1390
1411
  executor_display_name: str
1391
- executor_worker_agent: str
1392
- executor_model_meta: ModelAssignment
1393
1412
  codex_worker_execution: str
1394
1413
  antigravity_worker_execution: str
1395
- executor_execution: str
1414
+ executor_assignment: RoleAssignment
1396
1415
  lead_assignment: RoleAssignment
1397
1416
  worker_assignments: tuple[RoleAssignment, ...]
1417
+ invocation_assignments: dict[str, dict[str, object]]
1398
1418
 
1399
1419
 
1400
1420
  def recommended_role_models() -> dict[str, str]:
@@ -1536,6 +1556,7 @@ def _resolve_model_bindings(inp: PrepareInputs, workers: list[str]) -> _ModelBin
1536
1556
  f"(got: {critic_choice!r})"
1537
1557
  )
1538
1558
  critic_model_execution = ""
1559
+ critic_meta = None
1539
1560
  if critic_choice in provider_ids("critic"):
1540
1561
  critic_meta = _model_for_role(
1541
1562
  critic_choice,
@@ -1561,7 +1582,6 @@ def _resolve_model_bindings(inp: PrepareInputs, workers: list[str]) -> _ModelBin
1561
1582
  )
1562
1583
  model_meta = m["providers"][executor_provider]
1563
1584
  display_name = f"{provider_spec(executor_provider).display_label} executor"
1564
- worker_agent = f"{executor_provider}-worker"
1565
1585
  lead_provider = m["lead_provider"]
1566
1586
  lead_execution = m["lead"].execution
1567
1587
  lead_runner = _assignment_runner(inp.lead_runtime, lead_provider, "lead")
@@ -1569,29 +1589,69 @@ def _resolve_model_bindings(inp: PrepareInputs, workers: list[str]) -> _ModelBin
1569
1589
  lead_execution = _normalized_execution(
1570
1590
  m["lead"], provider=lead_provider, role="lead",
1571
1591
  )
1592
+ lead_assignment = _role_assignment(
1593
+ host_runtime=inp.lead_runtime,
1594
+ role="lead",
1595
+ provider=lead_provider,
1596
+ model=m["lead"],
1597
+ execution=lead_execution,
1598
+ runner_role="lead",
1599
+ )
1572
1600
  worker_assignments = _build_worker_assignments(inp, workers, m)
1601
+ critic_assignment = (
1602
+ _role_assignment(
1603
+ host_runtime=inp.lead_runtime,
1604
+ role="critic",
1605
+ provider=critic_choice,
1606
+ model=critic_meta,
1607
+ execution=critic_model_execution,
1608
+ runner_role="critic",
1609
+ )
1610
+ if critic_meta is not None
1611
+ else None
1612
+ )
1613
+ translator_assignment = _role_assignment(
1614
+ host_runtime=inp.lead_runtime,
1615
+ role="translator",
1616
+ provider=m["report_writer_provider"],
1617
+ model=m["rw"],
1618
+ execution=_normalized_execution(
1619
+ m["rw"],
1620
+ provider=m["report_writer_provider"],
1621
+ role="report-writer",
1622
+ ),
1623
+ runner_role="report-writer",
1624
+ )
1625
+ executor_assignment = _role_assignment(
1626
+ host_runtime=inp.lead_runtime,
1627
+ role="executor",
1628
+ provider=executor_provider,
1629
+ model=model_meta,
1630
+ execution=_normalized_execution(
1631
+ model_meta, provider=executor_provider, role="executor"
1632
+ ),
1633
+ runner_role="executor",
1634
+ worker_id=executor_provider,
1635
+ )
1636
+ invocation_assignments = _build_invocation_assignments(
1637
+ lead_assignment,
1638
+ worker_assignments,
1639
+ critic_assignment,
1640
+ translator_assignment,
1641
+ )
1573
1642
  return _ModelBindings(
1574
1643
  lead=m["lead"], cw=m["cw"], co=m["co"], ge=m["ge"], rw=m["rw"],
1575
1644
  critic_choice=critic_choice,
1576
1645
  critic_model_execution=critic_model_execution,
1577
- executor_provider=executor_provider,
1578
1646
  executor_display_name=display_name,
1579
- executor_worker_agent=worker_agent,
1580
- executor_model_meta=model_meta,
1581
1647
  codex_worker_execution=_worker_execution_value(
1582
1648
  m["co"], "codex", "worker", workers),
1583
1649
  antigravity_worker_execution=_worker_execution_value(
1584
1650
  m["ge"], "antigravity", "worker", workers),
1585
- executor_execution=_normalized_execution(
1586
- model_meta, provider=executor_provider, role="executor"),
1587
- lead_assignment=RoleAssignment(
1588
- role="lead",
1589
- provider=lead_provider,
1590
- model_display=m["lead"].display,
1591
- model_execution_value=lead_execution,
1592
- runner=lead_runner,
1593
- ),
1651
+ executor_assignment=executor_assignment,
1652
+ lead_assignment=lead_assignment,
1594
1653
  worker_assignments=worker_assignments,
1654
+ invocation_assignments=invocation_assignments,
1595
1655
  )
1596
1656
 
1597
1657
 
@@ -1609,17 +1669,90 @@ def _build_worker_assignments(
1609
1669
  execution = _worker_execution_value(
1610
1670
  meta, provider, "worker", [provider],
1611
1671
  )
1612
- assignments.append(RoleAssignment(
1672
+ assignments.append(_role_assignment(
1673
+ host_runtime=inp.lead_runtime,
1613
1674
  role=role,
1614
1675
  provider=provider,
1615
- model_display=meta.display,
1616
- model_execution_value=execution,
1617
- runner=_assignment_runner(inp.lead_runtime, provider, role),
1676
+ model=meta,
1677
+ execution=execution,
1678
+ runner_role=role,
1618
1679
  worker_id=worker_id,
1619
1680
  ))
1620
1681
  return tuple(assignments)
1621
1682
 
1622
1683
 
1684
+ def _role_assignment(
1685
+ *,
1686
+ host_runtime: str,
1687
+ role: str,
1688
+ provider: str,
1689
+ model: ModelAssignment,
1690
+ execution: str,
1691
+ runner_role: str,
1692
+ worker_id: str = "",
1693
+ ) -> RoleAssignment:
1694
+ runner = _assignment_runner(host_runtime, provider, runner_role)
1695
+ host_model_value = _resolve_host_model_value(
1696
+ host_runtime=host_runtime,
1697
+ provider=provider,
1698
+ model=model,
1699
+ execution=execution,
1700
+ runner=runner,
1701
+ )
1702
+ return RoleAssignment(
1703
+ role=role,
1704
+ provider=provider,
1705
+ model_display=model.display,
1706
+ model_execution_value=execution,
1707
+ runner=runner,
1708
+ host_runtime=host_runtime,
1709
+ host_model_value=host_model_value,
1710
+ worker_id=worker_id,
1711
+ )
1712
+
1713
+
1714
+ def _resolve_host_model_value(
1715
+ *,
1716
+ host_runtime: str,
1717
+ provider: str,
1718
+ model: ModelAssignment,
1719
+ execution: str,
1720
+ runner: str,
1721
+ ) -> str | None:
1722
+ try:
1723
+ port = default_host_registry().resolve(host_runtime).host_model()
1724
+ return port.resolve(HostModelBindingRequest(
1725
+ host_runtime=host_runtime,
1726
+ provider=provider,
1727
+ model=model.display,
1728
+ model_execution_value=execution,
1729
+ runner=runner,
1730
+ ))
1731
+ except (HostModelBindingError, HostNotRegistered) as exc:
1732
+ raise PrepareError(str(exc)) from exc
1733
+
1734
+
1735
+ def _build_invocation_assignments(
1736
+ lead: RoleAssignment,
1737
+ workers: tuple[RoleAssignment, ...],
1738
+ critic: RoleAssignment | None,
1739
+ translator: RoleAssignment,
1740
+ ) -> dict[str, dict[str, object]]:
1741
+ assignments = {"lead": lead.to_model_payload()}
1742
+ for assignment in workers:
1743
+ assignments[f"initial/{assignment.worker_id}"] = (
1744
+ assignment.to_model_payload()
1745
+ )
1746
+ assignments[f"reverify/{assignment.worker_id}"] = (
1747
+ assignment.to_model_payload()
1748
+ )
1749
+ if critic is not None:
1750
+ assignments["critic/scope"] = critic.to_model_payload()
1751
+ assignments["critic/acceptance"] = critic.to_model_payload()
1752
+ assignments["translator"] = translator.to_model_payload()
1753
+ return assignments
1754
+
1755
+
1623
1756
  def _normalized_execution(meta: ModelAssignment, provider: str, role: str) -> str:
1624
1757
  """Route a role's catalog execution value through the CLI-identity normalizer
1625
1758
  so the manifest carries the dispatch-correct spelling. SSOT for the mapping
@@ -1853,11 +1986,14 @@ def _write_instruction_set_sources(
1853
1986
  if inp.task_type == "implementation":
1854
1987
  profile_rendered += "\n\n{{DESIGN_PREP_CONTEXT}}\n\n{{FIX_RUN_CONTEXT}}"
1855
1988
  profile_tokens = (
1989
+ "EXECUTOR_WORKER_ID",
1856
1990
  "EXECUTOR_PROVIDER",
1857
1991
  "EXECUTOR_DISPLAY_NAME",
1858
- "EXECUTOR_WORKER_AGENT",
1859
1992
  "EXECUTOR_MODEL_DISPLAY",
1860
1993
  "EXECUTOR_MODEL_EXECUTION_VALUE",
1994
+ "EXECUTOR_HOST_MODEL_VALUE",
1995
+ "EXECUTOR_RUNNER",
1996
+ "EXECUTOR_DISPATCH_MODE",
1861
1997
  "EXECUTOR_WORKTREE_PATH",
1862
1998
  "EXECUTOR_WORKTREE_BRANCH",
1863
1999
  "EXECUTOR_WORKTREE_BASE_REF",
@@ -1980,8 +2116,7 @@ def _render_lead_prompt_and_snapshot(
1980
2116
  final_report_template: Path,
1981
2117
  prompt_template: Path,
1982
2118
  ) -> str:
1983
- """compute/default 토큰을 ctx 주입한 final-report 템플릿 사본과 lead 실행
1984
- 프롬프트를 렌더하고, 프롬프트 스냅샷을 기록한 후 prompt_text 를 돌려준다."""
2119
+ """Render lead instructions, then publish one verified lead invocation."""
1985
2120
  # inject populates ctx with compute + default tokens consumed by the lead
1986
2121
  # prompt render below (lead-execution-prompt.md). The final-report
1987
2122
  # template render is effectively a copy (Jinja2 `{{ var }}` syntax does
@@ -2016,14 +2151,48 @@ def _render_lead_prompt_and_snapshot(
2016
2151
  )
2017
2152
  except Exception: # noqa: BLE001 — advisory artifact; never fail prep over it
2018
2153
  pass
2019
- lead_prompt_path = instruction_set / "lead-execution-prompt.md"
2020
- legacy_claude_prompt_path = instruction_set / "claude-execution-prompt.md"
2021
- render_template_with_ctx(str(prompt_template), str(lead_prompt_path), ctx)
2022
- prompt_text = lead_prompt_path.read_text(encoding="utf-8")
2023
- legacy_claude_prompt_path.write_text(prompt_text, encoding="utf-8")
2024
- Path(ctx["RUN_PROMPT_SNAPSHOT_FILE"]).parent.mkdir(parents=True, exist_ok=True)
2025
- Path(ctx["RUN_PROMPT_SNAPSHOT_FILE"]).write_text(prompt_text, encoding="utf-8")
2026
- return prompt_text
2154
+ instructions_path = Path(ctx["LEAD_INSTRUCTIONS_PATH"])
2155
+ render_template_with_ctx(str(prompt_template), str(instructions_path), ctx)
2156
+ assignment = _agent_model_assignment(
2157
+ json.loads(ctx["INVOCATION_ASSIGNMENTS_JSON"])["lead"]
2158
+ )
2159
+ prepared = prepare_agent_invocation(AgentInvocationRequest(
2160
+ invocation_id=(
2161
+ f"{ctx['TASK_TYPE_SEGMENT']}-{ctx['RUN_PROMPTS_SEQ']}-lead"
2162
+ ),
2163
+ worker_id="lead",
2164
+ audience="lead",
2165
+ assignment_ref="lead",
2166
+ purpose=None,
2167
+ assignment=assignment,
2168
+ instruction=AgentInstruction(
2169
+ anchor_lines=(),
2170
+ body=instructions_path.read_text(encoding="utf-8"),
2171
+ source_paths=(
2172
+ AgentInstructionSource(
2173
+ "project", ctx["LEAD_INSTRUCTIONS_RELATIVE_PATH"]
2174
+ ),
2175
+ AgentInstructionSource("runtime", "prompts/launch.template.md"),
2176
+ AgentInstructionSource(
2177
+ "runtime", "prompts/lead/okstra-lead-contract.md"
2178
+ ),
2179
+ ),
2180
+ ),
2181
+ project_root=Path(ctx["PROJECT_ROOT"]),
2182
+ run_manifest_path=Path(ctx["RUN_MANIFEST_PATH"]),
2183
+ duty_root=Path(ctx["DUTY_CONTRACT_ROOT"]),
2184
+ prompt_path=Path(ctx["RUN_PROMPT_SNAPSHOT_FILE"]),
2185
+ metadata_path=Path(ctx["LEAD_PROMPT_METADATA_PATH"]),
2186
+ dispatch_kind="lead",
2187
+ ))
2188
+ return prepared.prompt_path.read_text(encoding="utf-8")
2189
+
2190
+
2191
+ def _agent_model_assignment(payload: object) -> AgentModelAssignment:
2192
+ try:
2193
+ return agent_model_assignment_from_payload(payload)
2194
+ except AgentInvocationError as exc:
2195
+ raise PrepareError(str(exc)) from exc
2027
2196
 
2028
2197
 
2029
2198
  def _persist_run_inputs(
@@ -2066,7 +2235,7 @@ def _persist_run_inputs(
2066
2235
  inp.report_writer_provider or "claude",
2067
2236
  ),
2068
2237
  "reportWriterModel": models.rw.display,
2069
- "executor": models.executor_provider,
2238
+ "executor": models.executor_assignment.provider,
2070
2239
  "relatedTasks": inp.related_tasks_raw,
2071
2240
  "approvedPlanPath": approved_plan_path,
2072
2241
  "clarificationResponsePath": inp.clarification_response_path,
@@ -2083,6 +2252,19 @@ def _persist_run_inputs(
2083
2252
  )
2084
2253
 
2085
2254
 
2255
+ def _persist_pre_dispatch_run_manifest(ctx: dict) -> None:
2256
+ """Publish and re-read the complete invocation authority before prompts."""
2257
+ render_run_manifest(ctx["RUN_MANIFEST_PATH"], ctx)
2258
+ manifest = _read_existing_manifest(Path(ctx["RUN_MANIFEST_PATH"]))
2259
+ expected_contract = json.loads(ctx["AGENT_CONTRACT_JSON"])
2260
+ expected_assignments = json.loads(ctx["INVOCATION_ASSIGNMENTS_JSON"])
2261
+ if (
2262
+ manifest.get("agentContract") != expected_contract
2263
+ or manifest.get("invocationAssignments") != expected_assignments
2264
+ ):
2265
+ raise PrepareError("pre-dispatch run manifest is incomplete")
2266
+
2267
+
2086
2268
  def _read_existing_manifest(manifest_path: Path) -> dict:
2087
2269
  if not manifest_path.exists():
2088
2270
  return {}
@@ -2255,6 +2437,98 @@ def _write_bundle_artifacts(
2255
2437
  ctx["FIX_CYCLE_ID"] = _record_fix_cycle_events(inp, ctx)
2256
2438
 
2257
2439
 
2440
+ def _prepare_agent_contract(
2441
+ inp: PrepareInputs,
2442
+ ctx: dict,
2443
+ workspace_root: Path,
2444
+ models: _ModelBindings,
2445
+ ) -> None:
2446
+ source = _duty_catalog_source(workspace_root)
2447
+ destination = Path(ctx["DUTY_CONTRACT_ROOT"])
2448
+ _snapshot_duty_catalog(source, destination)
2449
+ digest = digest_duty_catalog(destination)
2450
+ allowed_audiences = _allowed_agent_audiences(inp, models)
2451
+ contract = {
2452
+ "schemaVersion": 1,
2453
+ "dutyRootPath": ctx["DUTY_CONTRACT_ROOT_RELATIVE_PATH"],
2454
+ "catalogDigest": digest,
2455
+ "invocationReservationRootPath": (
2456
+ ctx["INVOCATION_RESERVATION_ROOT_RELATIVE_PATH"]
2457
+ ),
2458
+ "allowedAudiences": allowed_audiences,
2459
+ "authorizedPaths": {
2460
+ "instructionRoots": [
2461
+ ctx["RUN_PROMPTS_RELATIVE_PATH"],
2462
+ ctx["RUN_STATE_RELATIVE_PATH"],
2463
+ ],
2464
+ "promptRoots": [ctx["RUN_PROMPTS_RELATIVE_PATH"]],
2465
+ "resultRoots": [
2466
+ ctx["WORKER_RESULTS_RELATIVE_PATH"],
2467
+ ctx["RUN_REPORTS_RELATIVE_PATH"],
2468
+ ],
2469
+ },
2470
+ }
2471
+ ctx["DUTY_CATALOG_DIGEST"] = digest
2472
+ ctx["AGENT_CONTRACT_JSON"] = json.dumps(contract, ensure_ascii=False)
2473
+ ctx["INVOCATION_ASSIGNMENTS_JSON"] = json.dumps(
2474
+ models.invocation_assignments,
2475
+ ensure_ascii=False,
2476
+ )
2477
+
2478
+
2479
+ def _duty_catalog_source(workspace_root: Path) -> Path:
2480
+ candidates = (
2481
+ workspace_root / "prompts" / "duties",
2482
+ okstra_home() / "prompts" / "duties",
2483
+ )
2484
+ for candidate in candidates:
2485
+ if (candidate / "common.md").is_file():
2486
+ return candidate
2487
+ raise PrepareError("agent duty catalog is missing from the runtime")
2488
+
2489
+
2490
+ def _snapshot_duty_catalog(source: Path, destination: Path) -> None:
2491
+ if destination.exists():
2492
+ if digest_duty_catalog(destination) != digest_duty_catalog(source):
2493
+ raise PrepareError("existing run duty snapshot conflicts with runtime catalog")
2494
+ return
2495
+ destination.parent.mkdir(parents=True, exist_ok=True)
2496
+ temp_dir = Path(tempfile.mkdtemp(
2497
+ dir=destination.parent,
2498
+ prefix=f".{destination.name}.",
2499
+ suffix=".tmp",
2500
+ ))
2501
+ try:
2502
+ shutil.copytree(source, temp_dir, dirs_exist_ok=True)
2503
+ os.rename(temp_dir, destination)
2504
+ except FileExistsError as exc:
2505
+ if digest_duty_catalog(destination) != digest_duty_catalog(source):
2506
+ raise PrepareError("run duty snapshot publication conflict") from exc
2507
+ finally:
2508
+ if temp_dir.exists():
2509
+ shutil.rmtree(temp_dir)
2510
+
2511
+
2512
+ def _allowed_agent_audiences(
2513
+ inp: PrepareInputs,
2514
+ models: _ModelBindings,
2515
+ ) -> list[str]:
2516
+ audiences = {"lead", "translator"}
2517
+ if models.worker_assignments:
2518
+ audiences.add("reverification-worker")
2519
+ if any(item.worker_id == "report-writer" for item in models.worker_assignments):
2520
+ audiences.add("report-writer")
2521
+ if inp.task_type == "implementation":
2522
+ audiences.update({"implementation-executor", "implementation-verifier"})
2523
+ elif inp.task_type == "final-verification":
2524
+ audiences.add("acceptance-verifier")
2525
+ else:
2526
+ audiences.add("analysis-worker")
2527
+ if models.critic_choice not in {"", "off"}:
2528
+ audiences.update({"scope-critic", "acceptance-critic"})
2529
+ return sorted(audiences)
2530
+
2531
+
2258
2532
  def _record_run_in_central_index(
2259
2533
  inp: PrepareInputs,
2260
2534
  ctx: dict,
@@ -2351,6 +2625,10 @@ def _model_ctx(models: "_ModelBindings") -> dict[str, str]:
2351
2625
  [assignment.to_payload() for assignment in models.worker_assignments],
2352
2626
  ensure_ascii=False,
2353
2627
  ),
2628
+ "INVOCATION_ASSIGNMENTS_JSON": json.dumps(
2629
+ models.invocation_assignments,
2630
+ ensure_ascii=False,
2631
+ ),
2354
2632
  "CLAUDE_WORKER_MODEL": models.cw.display,
2355
2633
  "CLAUDE_WORKER_MODEL_EXECUTION_VALUE": models.cw.execution,
2356
2634
  "CODEX_WORKER_MODEL": models.co.display,
@@ -2366,11 +2644,22 @@ def _model_ctx(models: "_ModelBindings") -> dict[str, str]:
2366
2644
  ),
2367
2645
  "",
2368
2646
  ),
2369
- "EXECUTOR_PROVIDER": models.executor_provider,
2647
+ "EXECUTOR_WORKER_ID": models.executor_assignment.worker_id,
2648
+ "EXECUTOR_PROVIDER": models.executor_assignment.provider,
2370
2649
  "EXECUTOR_DISPLAY_NAME": models.executor_display_name,
2371
- "EXECUTOR_WORKER_AGENT": models.executor_worker_agent,
2372
- "EXECUTOR_MODEL_DISPLAY": models.executor_model_meta.display,
2373
- "EXECUTOR_MODEL_EXECUTION_VALUE": models.executor_execution,
2650
+ "EXECUTOR_MODEL_DISPLAY": models.executor_assignment.model_display,
2651
+ "EXECUTOR_MODEL_EXECUTION_VALUE": (
2652
+ models.executor_assignment.model_execution_value
2653
+ ),
2654
+ "EXECUTOR_HOST_MODEL_VALUE": (
2655
+ models.executor_assignment.host_model_value or ""
2656
+ ),
2657
+ "EXECUTOR_RUNNER": models.executor_assignment.runner,
2658
+ "EXECUTOR_DISPATCH_MODE": (
2659
+ "host-native"
2660
+ if models.executor_assignment.runner == "native-session"
2661
+ else "worker-dispatch"
2662
+ ),
2374
2663
  "CRITIC_CHOICE": models.critic_choice,
2375
2664
  "CRITIC_MODEL_EXECUTION_VALUE": models.critic_model_execution,
2376
2665
  }
@@ -2667,18 +2956,21 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2667
2956
  _write_bundle_artifacts(
2668
2957
  inp, ctx, project_root, lead_runtime, claude_session_id,
2669
2958
  )
2959
+ _prepare_agent_contract(inp, ctx, workspace_root, models)
2670
2960
 
2671
- # ---- write instruction-set scaffolding + lead prompt ----
2961
+ # ---- write instruction-set scaffolding ----
2672
2962
  instruction_set = _write_instruction_set_sources(
2673
2963
  inp, ctx, profile_content, review_material, assets.host_rules_file
2674
2964
  )
2965
+ # ---- run-inputs persistence ----
2966
+ _persist_run_inputs(inp, ctx, models, selected_reviewers, brief_relative)
2967
+ _persist_pre_dispatch_run_manifest(ctx)
2968
+
2969
+ # ---- lead prompt publication (manifest is now the external authority) ----
2675
2970
  prompt_text = _render_lead_prompt_and_snapshot(
2676
2971
  inp, ctx, instruction_set, final_report_template, prompt_template
2677
2972
  )
2678
2973
 
2679
- # ---- run-inputs persistence ----
2680
- _persist_run_inputs(inp, ctx, models, selected_reviewers, brief_relative)
2681
-
2682
2974
  # ---- final status + manifest/discovery renders ----
2683
2975
  _finalize_status_and_render_manifests(
2684
2976
  inp, ctx, task_index_template, forbidden_by_phase
@@ -2997,14 +3289,37 @@ def main(argv: list[str]) -> int:
2997
3289
 
2998
3290
  def _lead_launch_payload(ctx: dict[str, object]) -> dict[str, object]:
2999
3291
  lead_runtime = str(ctx.get("LEAD_RUNTIME", "claude-code"))
3000
- lead_provider = str(ctx.get("LEAD_PROVIDER", ""))
3001
- launch = lead_launch_spec(lead_provider)
3002
- prompt_file = (
3003
- Path(str(ctx["INSTRUCTION_SET_PATH"])) / "lead-execution-prompt.md"
3292
+ project_root = Path(str(ctx["PROJECT_ROOT"]))
3293
+ run_manifest_path = Path(str(ctx["RUN_MANIFEST_PATH"]))
3294
+ try:
3295
+ manifest = json.loads(run_manifest_path.read_text(encoding="utf-8"))
3296
+ resources = manifest["resources"]
3297
+ assignment = agent_model_assignment_from_payload(
3298
+ manifest["invocationAssignments"]["lead"]
3299
+ )
3300
+ prompt_file = project_root / resources["leadExecutionPromptPath"]
3301
+ metadata_path = project_root / resources["leadPromptMetadataPath"]
3302
+ metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
3303
+ if metadata["prompt"]["path"] != resources["leadExecutionPromptPath"]:
3304
+ raise KeyError("lead prompt resource does not match metadata")
3305
+ except (OSError, json.JSONDecodeError, KeyError, TypeError, AgentInvocationError) as exc:
3306
+ raise PrepareError("lead invocation authority is incomplete") from exc
3307
+ errors = verify_agent_invocation(
3308
+ metadata_path,
3309
+ project_root=project_root,
3310
+ expected_run_manifest_path=run_manifest_path,
3311
+ expected_assignment=assignment,
3312
+ expected_worker_id="lead",
3313
+ expected_assignment_ref="lead",
3314
+ expected_audience="lead",
3004
3315
  )
3316
+ if errors:
3317
+ raise PrepareError("lead invocation verification failed: " + "; ".join(errors))
3318
+ lead_provider = assignment.provider
3319
+ launch = lead_launch_spec(lead_provider)
3005
3320
  launch_argv = lead_launch_argv(
3006
3321
  lead_provider,
3007
- model=str(ctx["LEAD_MODEL_EXECUTION_VALUE"]),
3322
+ model=assignment.model_execution_value,
3008
3323
  session_id=str(ctx["CLAUDE_SESSION_ID"]),
3009
3324
  prompt=prompt_file.read_text(encoding="utf-8"),
3010
3325
  )
@@ -3014,9 +3329,11 @@ def _lead_launch_payload(ctx: dict[str, object]) -> dict[str, object]:
3014
3329
  "leadProvider": lead_provider,
3015
3330
  "leadExecutable": launch.executable,
3016
3331
  "leadSessionId": ctx["CLAUDE_SESSION_ID"],
3017
- "leadModelExecutionValue": ctx["LEAD_MODEL_EXECUTION_VALUE"],
3018
- "projectRoot": ctx["PROJECT_ROOT"],
3332
+ "leadModelExecutionValue": assignment.model_execution_value,
3333
+ "projectRoot": str(project_root),
3019
3334
  "promptFile": str(prompt_file),
3335
+ "runManifestPath": str(run_manifest_path),
3336
+ "leadPromptMetadataPath": str(metadata_path),
3020
3337
  "sandboxWaiverNote": launch.sandbox_waiver_note,
3021
3338
  "launchArgv": launch_argv,
3022
3339
  "launchRequestArgv": launch_argv[launch_prefix_size:],
@@ -10,6 +10,8 @@ import os
10
10
  import uuid
11
11
  from pathlib import Path
12
12
 
13
+ from .dispatch_state import DispatchError, mutate_team_state
14
+
13
15
 
14
16
  def generate_claude_session_id() -> str:
15
17
  """UUIDv4 문자열."""
@@ -105,20 +107,22 @@ def record_observed_lead_session(project_root: Path, team_state_path: Path) -> s
105
107
  sid = resolve_lead_session_id_for_run(project_root, team_state_path.parent.parent)
106
108
  if not sid:
107
109
  return ""
110
+ def add_observed_session(state: dict) -> bool:
111
+ lead_ids = state.setdefault("leadSessionIds", [])
112
+ if sid in lead_ids:
113
+ return False
114
+ lead_ids.append(sid)
115
+ team = f"session-{sid[:8]}"
116
+ observed = state.setdefault("observedTeamNames", [])
117
+ if team not in observed:
118
+ observed.append(team)
119
+ return True
120
+
108
121
  try:
109
- state = json.loads(team_state_path.read_text(encoding="utf-8"))
110
- except (OSError, json.JSONDecodeError):
111
- return ""
112
- lead_ids = state.setdefault("leadSessionIds", [])
113
- if sid in lead_ids:
122
+ changed = mutate_team_state(team_state_path, add_observed_session)
123
+ except (DispatchError, OSError):
114
124
  return ""
115
- lead_ids.append(sid)
116
- team = f"session-{sid[:8]}"
117
- observed = state.setdefault("observedTeamNames", [])
118
- if team not in observed:
119
- observed.append(team)
120
- team_state_path.write_text(json.dumps(state, indent=2), encoding="utf-8")
121
- return sid
125
+ return sid if changed else ""
122
126
 
123
127
 
124
128
  def write_claude_resume_command_file(
@@ -19,7 +19,7 @@ from . import tmux
19
19
  from .adapters.dispatch import provider_worker_wrappers
20
20
  from .adapters.dispatch.cmux import dispatch_port_for_terminal_backend
21
21
  from .application.dispatch_assignments import dispatch_assignments
22
- from .dispatch_state import TEARDOWN_BEFORE_TERMINAL_REASON
22
+ from .dispatch_state import TEARDOWN_BEFORE_TERMINAL_REASON, mutate_team_state
23
23
  from .dispatch_core import (
24
24
  BACKEND_CMUX_PANE,
25
25
  BACKEND_TMUX_PANE,
@@ -253,12 +253,16 @@ def _append_pane(panes: list[dict[str, str]], seen: set[str], pane_id: str, kind
253
253
 
254
254
 
255
255
  def _mark_teardown_errors(team_state_path: Path) -> None:
256
- payload = _load_json(team_state_path, "team-state")
257
- for record in payload.get("workerDispatches", []):
258
- if isinstance(record, dict) and record.get("status") not in _TERMINAL_STATUSES:
259
- record["status"] = "error"
260
- record["reason"] = TEARDOWN_BEFORE_TERMINAL_REASON
261
- _write_json(team_state_path, payload)
256
+ def mark(payload: dict[str, Any]) -> bool:
257
+ changed = False
258
+ for record in payload.get("workerDispatches", []):
259
+ if isinstance(record, dict) and record.get("status") not in _TERMINAL_STATUSES:
260
+ record["status"] = "error"
261
+ record["reason"] = TEARDOWN_BEFORE_TERMINAL_REASON
262
+ changed = True
263
+ return changed
264
+
265
+ mutate_team_state(team_state_path, mark)
262
266
 
263
267
 
264
268
  def _emit_teardown(as_json: bool, panes: list[dict[str, str]]) -> None:
@@ -310,10 +314,6 @@ def _load_json(path: Path, label: str) -> dict[str, Any]:
310
314
  return payload
311
315
 
312
316
 
313
- def _write_json(path: Path, payload: Mapping[str, Any]) -> None:
314
- path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
315
-
316
-
317
317
  def _resolve_project_path(project_root: Path, value: str | Path) -> Path:
318
318
  path = Path(value)
319
319
  return path if path.is_absolute() else project_root / path