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
@@ -11,6 +11,16 @@ from pathlib import Path
11
11
  from typing import Any, Callable, Literal, Mapping, Sequence
12
12
 
13
13
  from .final_report_paths import final_report_data_path
14
+ from .agent_invocation import (
15
+ AgentInstruction,
16
+ AgentInstructionSource,
17
+ AgentInvocationError,
18
+ AgentInvocationRequest,
19
+ agent_model_assignment_from_payload,
20
+ compose_agent_prompt,
21
+ prepare_agent_invocation,
22
+ verify_agent_invocation,
23
+ )
14
24
  from .path_hints import hydrate_active_run_context
15
25
  from .report_language import resolve_report_language
16
26
  from .worker_prompt_body import (
@@ -101,6 +111,30 @@ _CLARIFICATION_AUTHORITY_INTRO = (
101
111
  _CLARIFICATION_SOURCE_PREFIX = "Source:"
102
112
  _REQUIRED_PROMPT_RESOURCES_HEADING = "## Required prompt resources"
103
113
  _HTML_COMMENTS = re.compile(r"<!--.*?-->\n?", re.DOTALL)
114
+ _AGENT_CONTRACT_KEYS = {
115
+ "schemaVersion",
116
+ "dutyRootPath",
117
+ "catalogDigest",
118
+ "invocationReservationRootPath",
119
+ "allowedAudiences",
120
+ "authorizedPaths",
121
+ }
122
+ _MODEL_ASSIGNMENT_KEYS = {
123
+ "provider",
124
+ "model",
125
+ "modelExecutionValue",
126
+ "runner",
127
+ "hostRuntime",
128
+ "hostModelValue",
129
+ }
130
+ _RUN_INVOCATION_PATH_KEYS = {
131
+ "runManifestPath",
132
+ "workerPromptsDirectoryPath",
133
+ "leadInstructionsPath",
134
+ "leadExecutionPromptPath",
135
+ "leadPromptMetadataPath",
136
+ "invocationReservationRootPath",
137
+ }
104
138
 
105
139
 
106
140
  @dataclass(frozen=True)
@@ -198,6 +232,7 @@ def _load_materialization_context(
198
232
  )
199
233
  manifest_path = _resolve_input_path(project_root, request.run_manifest_path)
200
234
  manifest = _load_json_object(manifest_path, "run manifest")
235
+ _validate_invocation_manifest_authority(manifest)
201
236
  team_state = _load_referenced_json(project_root, manifest, "teamStatePath")
202
237
  active_context = hydrate_active_run_context(
203
238
  _load_referenced_json(
@@ -215,6 +250,39 @@ def _load_materialization_context(
215
250
  )
216
251
 
217
252
 
253
+ def _validate_invocation_manifest_authority(
254
+ manifest: Mapping[str, Any],
255
+ ) -> None:
256
+ has_contract = "agentContract" in manifest
257
+ has_assignments = "invocationAssignments" in manifest
258
+ if not has_contract and not has_assignments:
259
+ return
260
+ contract = manifest.get("agentContract")
261
+ assignments = manifest.get("invocationAssignments")
262
+ complete = (
263
+ isinstance(contract, Mapping)
264
+ and set(contract) == _AGENT_CONTRACT_KEYS
265
+ and contract.get("schemaVersion") == 1
266
+ and isinstance(assignments, Mapping)
267
+ and bool(assignments)
268
+ and all(
269
+ isinstance(item, Mapping)
270
+ and set(item) == _MODEL_ASSIGNMENT_KEYS
271
+ for item in assignments.values()
272
+ )
273
+ and all(
274
+ isinstance(manifest.get(key), str)
275
+ and bool(manifest.get(key))
276
+ for key in _RUN_INVOCATION_PATH_KEYS
277
+ )
278
+ )
279
+ if not complete:
280
+ raise InitialPromptMaterializationError(
281
+ "required_input_missing",
282
+ "new invocation manifest is incomplete",
283
+ )
284
+
285
+
218
286
  def _resolve_prompt_item(
219
287
  context: _MaterializationContext,
220
288
  worker: InitialPromptWorkerRequest,
@@ -312,12 +380,23 @@ def _render_prompt(
312
380
  context: _MaterializationContext,
313
381
  item: _PromptItem,
314
382
  ) -> str:
383
+ request = _agent_invocation_request(context, item)
384
+ if request is not None:
385
+ return compose_agent_prompt(request)
386
+ anchors, body = _render_prompt_parts(context, item)
387
+ return "\n".join([*anchors, *body]).rstrip() + "\n"
388
+
389
+
390
+ def _render_prompt_parts(
391
+ context: _MaterializationContext,
392
+ item: _PromptItem,
393
+ ) -> tuple[list[str], list[str]]:
315
394
  worker = item.worker
316
395
  state = _worker_state(context.team_state, worker.worker_id)
317
396
  prompt_rel = _project_relative(context.project_root, item.final_path)
318
397
  result_rel, audit_source_rel = _result_paths(context, state, worker.worker_id)
319
398
  try:
320
- lines = worker_prompt_headers(
399
+ anchors = worker_prompt_headers(
321
400
  project_root=context.project_root,
322
401
  prompt_rel=prompt_rel,
323
402
  result_rel=result_rel,
@@ -327,12 +406,12 @@ def _render_prompt(
327
406
  manifest=context.manifest,
328
407
  active_context=context.active_context,
329
408
  )
330
- lines.append(
409
+ anchors.append(
331
410
  f"**Prompt Delivery Mode:** {context.request.delivery_mode.value}"
332
411
  )
333
- lines.extend(_implementation_anchor_lines(context, item.plan))
334
- lines.extend(_prompt_body(context, state, worker, item.plan))
335
- lines.extend(_resource_lines(context, item))
412
+ anchors.extend(_implementation_anchor_lines(context, item.plan))
413
+ body = _prompt_body(context, state, worker, item.plan)
414
+ body.extend(_resource_lines(context, item))
336
415
  except InitialPromptMaterializationError:
337
416
  raise
338
417
  except Exception as exc:
@@ -340,7 +419,62 @@ def _render_prompt(
340
419
  "render_failed",
341
420
  f"cannot compose initial prompt for {worker.worker_id}: {exc}",
342
421
  ) from exc
343
- return "\n".join(lines).rstrip() + "\n"
422
+ return anchors, body
423
+
424
+
425
+ def _agent_invocation_request(
426
+ context: _MaterializationContext,
427
+ item: _PromptItem,
428
+ ) -> AgentInvocationRequest | None:
429
+ contract = context.manifest.get("agentContract")
430
+ if not isinstance(contract, Mapping):
431
+ return None
432
+ assignment_ref = f"initial/{item.worker.worker_id}"
433
+ assignments = context.manifest.get("invocationAssignments")
434
+ payload = assignments.get(assignment_ref) if isinstance(assignments, Mapping) else None
435
+ try:
436
+ assignment = agent_model_assignment_from_payload(payload)
437
+ except AgentInvocationError as exc:
438
+ raise InitialPromptMaterializationError(
439
+ "required_input_missing", str(exc)
440
+ ) from exc
441
+ if assignment.model_execution_value != item.worker.model:
442
+ raise InitialPromptMaterializationError(
443
+ "required_input_missing",
444
+ f"requested model does not match {assignment_ref}",
445
+ )
446
+ anchors, body = _render_prompt_parts(context, item)
447
+ source_paths = (
448
+ AgentInstructionSource(
449
+ "project",
450
+ _project_relative(context.project_root, context.request.run_manifest_path),
451
+ ),
452
+ AgentInstructionSource(
453
+ "project", _required_string(context.manifest, "activeRunContextPath")
454
+ ),
455
+ AgentInstructionSource(
456
+ "project", _required_string(context.manifest, "teamStatePath")
457
+ ),
458
+ )
459
+ return AgentInvocationRequest(
460
+ invocation_id=f"initial-{item.worker.worker_id}",
461
+ worker_id=item.worker.worker_id,
462
+ audience=item.plan.duty_audience,
463
+ assignment_ref=assignment_ref,
464
+ purpose=None,
465
+ assignment=assignment,
466
+ instruction=AgentInstruction(
467
+ anchor_lines=tuple(anchors),
468
+ body="\n".join(body).rstrip() + "\n",
469
+ source_paths=source_paths,
470
+ ),
471
+ project_root=context.project_root,
472
+ run_manifest_path=context.request.run_manifest_path,
473
+ duty_root=context.project_root / _required_string(contract, "dutyRootPath"),
474
+ prompt_path=item.final_path,
475
+ metadata_path=item.final_path.with_name(item.final_path.name + ".meta.json"),
476
+ dispatch_kind="initial",
477
+ )
344
478
 
345
479
 
346
480
  def _prompt_body(
@@ -366,7 +500,6 @@ def _prompt_body(
366
500
  context.active_context,
367
501
  worker.worker_id,
368
502
  worker.model,
369
- _worker_role(plan),
370
503
  plan,
371
504
  )
372
505
 
@@ -607,15 +740,30 @@ def _publish_or_reuse(
607
740
  ) -> _PublishedPrompt:
608
741
  if item.existed:
609
742
  return _published_prompt(item, existed=True)
610
- temp_path = _required_temp_path(item)
743
+ invocation = _agent_invocation_request(context, item)
744
+ if invocation is None:
745
+ temp_path = _required_temp_path(item)
746
+ try:
747
+ os.link(temp_path, item.final_path)
748
+ except FileExistsError:
749
+ _validate_existing_prompt(context, item)
750
+ return _published_prompt(item, existed=True)
751
+ except OSError as exc:
752
+ raise InitialPromptMaterializationError(
753
+ "publication_failed",
754
+ f"cannot publish initial prompt {item.final_path}: {exc}",
755
+ ) from exc
756
+ return _published_prompt(item, existed=False)
611
757
  try:
612
- os.link(temp_path, item.final_path)
613
- except FileExistsError:
614
- _validate_existing_prompt(context, item)
615
- return _published_prompt(item, existed=True)
616
- except OSError as exc:
758
+ prepare_agent_invocation(invocation)
759
+ except AgentInvocationError as exc:
760
+ reason: MaterializationReason = (
761
+ "existing_prompt_conflict"
762
+ if exc.reason == "existing_invocation_conflict"
763
+ else "publication_failed"
764
+ )
617
765
  raise InitialPromptMaterializationError(
618
- "publication_failed",
766
+ reason,
619
767
  f"cannot publish initial prompt {item.final_path}: {exc}",
620
768
  ) from exc
621
769
  return _published_prompt(item, existed=False)
@@ -625,6 +773,7 @@ def _validate_existing_prompt(
625
773
  context: _MaterializationContext,
626
774
  item: _PromptItem,
627
775
  ) -> None:
776
+ expects_invocation = isinstance(context.manifest.get("agentContract"), Mapping)
628
777
  text = _require_readable_prompt(
629
778
  item.final_path,
630
779
  item.worker.worker_id,
@@ -636,6 +785,10 @@ def _validate_existing_prompt(
636
785
  item.worker.worker_id,
637
786
  "initial",
638
787
  item.final_path,
788
+ metadata_path=_metadata_path(item.final_path) if expects_invocation else None,
789
+ expected_duty_audience=(
790
+ item.plan.duty_audience if expects_invocation else None
791
+ ),
639
792
  )
640
793
  basic_errors = validate_initial_prompt_records(
641
794
  manifest=context.manifest,
@@ -653,6 +806,10 @@ def _validate_existing_prompt(
653
806
  item.final_path,
654
807
  expected_model=item.worker.model,
655
808
  expected_delivery_mode=context.request.delivery_mode.value,
809
+ metadata_path=_metadata_path(item.final_path) if expects_invocation else None,
810
+ expected_duty_audience=(
811
+ item.plan.duty_audience if expects_invocation else None
812
+ ),
656
813
  )
657
814
  expected_errors = validate_initial_prompt_records(
658
815
  manifest=context.manifest,
@@ -665,6 +822,23 @@ def _validate_existing_prompt(
665
822
  reason,
666
823
  "existing initial prompt metadata: " + "; ".join(expected_errors),
667
824
  )
825
+ invocation = _agent_invocation_request(context, item)
826
+ if invocation is not None:
827
+ errors = verify_agent_invocation(
828
+ invocation.metadata_path,
829
+ project_root=context.project_root,
830
+ expected_run_manifest_path=context.request.run_manifest_path,
831
+ expected_assignment=invocation.assignment,
832
+ expected_invocation_id=invocation.invocation_id,
833
+ expected_worker_id=invocation.worker_id,
834
+ expected_assignment_ref=invocation.assignment_ref,
835
+ expected_audience=invocation.audience,
836
+ )
837
+ if errors:
838
+ raise InitialPromptMaterializationError(
839
+ "existing_prompt_invalid",
840
+ "existing initial prompt invocation: " + "; ".join(errors),
841
+ )
668
842
 
669
843
 
670
844
  def _validate_existing_clarification_metadata(
@@ -854,12 +1028,20 @@ def _item_record(
854
1028
  item: _PromptItem,
855
1029
  ) -> PromptRecord:
856
1030
  path = item.final_path if item.existed else _required_temp_path(item)
1031
+ expects_invocation = (
1032
+ item.existed
1033
+ and isinstance(context.manifest.get("agentContract"), Mapping)
1034
+ )
857
1035
  return PromptRecord(
858
1036
  item.worker.worker_id,
859
1037
  "initial",
860
1038
  path,
861
1039
  expected_model=item.worker.model,
862
1040
  expected_delivery_mode=context.request.delivery_mode.value,
1041
+ metadata_path=_metadata_path(item.final_path) if expects_invocation else None,
1042
+ expected_duty_audience=(
1043
+ item.plan.duty_audience if expects_invocation else None
1044
+ ),
863
1045
  )
864
1046
 
865
1047
 
@@ -867,15 +1049,24 @@ def _published_record(
867
1049
  context: _MaterializationContext,
868
1050
  prompt: _PublishedPrompt,
869
1051
  ) -> PromptRecord:
1052
+ expects_invocation = isinstance(context.manifest.get("agentContract"), Mapping)
870
1053
  return PromptRecord(
871
1054
  prompt.worker.worker_id,
872
1055
  "initial",
873
1056
  prompt.path,
874
1057
  expected_model=prompt.worker.model,
875
1058
  expected_delivery_mode=context.request.delivery_mode.value,
1059
+ metadata_path=_metadata_path(prompt.path) if expects_invocation else None,
1060
+ expected_duty_audience=(
1061
+ prompt.plan.duty_audience if expects_invocation else None
1062
+ ),
876
1063
  )
877
1064
 
878
1065
 
1066
+ def _metadata_path(prompt_path: Path) -> Path:
1067
+ return prompt_path.with_name(prompt_path.name + ".meta.json")
1068
+
1069
+
879
1070
  def _worker_prompt_path(
880
1071
  context: _MaterializationContext,
881
1072
  worker_id: str,
@@ -1094,14 +1285,6 @@ def _validate_worker_request(worker: InitialPromptWorkerRequest) -> None:
1094
1285
  )
1095
1286
 
1096
1287
 
1097
- def _worker_role(plan: PromptPlan) -> str:
1098
- if plan.audience == "implementation-executor":
1099
- return "executor"
1100
- if plan.audience == "implementation-verifier":
1101
- return "verifier"
1102
- return "worker"
1103
-
1104
-
1105
1288
  def _existing_metadata_reason(
1106
1289
  errors: Sequence[str],
1107
1290
  ) -> MaterializationReason:
@@ -1164,7 +1347,15 @@ def _required_temp_path(item: _PromptItem) -> Path:
1164
1347
 
1165
1348
 
1166
1349
  def _project_relative(project_root: Path, path: Path) -> str:
1167
- return path.relative_to(project_root).as_posix()
1350
+ # macOS commonly exposes the same temporary directory as both `/var/...`
1351
+ # and `/private/var/...`; project-root resolution already canonicalizes the
1352
+ # former. Resolve the candidate too so provenance does not reject an alias
1353
+ # for the same directory. `strict=False` is required for a prompt that has
1354
+ # not been published yet, while still resolving every existing parent and
1355
+ # therefore preserving the symlink-escape check in `relative_to`.
1356
+ return path.resolve(strict=False).relative_to(
1357
+ project_root.resolve(strict=True)
1358
+ ).as_posix()
1168
1359
 
1169
1360
 
1170
1361
  def _resolve_input_path(project_root: Path, path: Path) -> Path:
@@ -60,6 +60,7 @@ def compact_active_run_context(
60
60
  "stage": ctx.get("RUN_STAGE", ""),
61
61
  "fixRunCarry": ctx.get("FIX_RUN_CONTEXT", ""),
62
62
  },
63
+ "agentContract": dict(_mapping(payload.get("agentContract"))),
63
64
  "inputs": _compact_active_inputs(payload),
64
65
  "workers": _compact_active_workers(payload),
65
66
  "executorWorktree": dict(_mapping(payload.get("executorWorktree"))),
@@ -97,6 +98,7 @@ def hydrate_active_run_context(payload: Mapping[str, Any]) -> dict[str, Any]:
97
98
  "task": _hydrate_active_task(payload, ctx),
98
99
  "workflow": dict(_mapping(payload.get("workflow"))),
99
100
  "run": _hydrate_active_run(payload, ctx),
101
+ "agentContract": dict(_mapping(payload.get("agentContract"))),
100
102
  "instructionSet": _hydrate_active_instruction_set(payload, ctx),
101
103
  "workers": _hydrate_active_workers(payload, ctx),
102
104
  "errorLogs": _hydrate_active_error_logs(ctx),
@@ -416,6 +418,12 @@ def _run_files(
416
418
  "run_context_file": run_manifests / f"run-context-{task_type_segment}-{sequences['manifests']}.json",
417
419
  "run_inputs_file": run_manifests / f"run-inputs-{task_type_segment}-{sequences['manifests']}.json",
418
420
  "run_prompt_snapshot": run_prompts / f"lead-execution-prompt{suffixes['prompts']}.md",
421
+ "duty_contract_root": run_prompts / f"duty-contracts{suffixes['prompts']}",
422
+ "lead_instructions": run_prompts / f"lead-instructions{suffixes['prompts']}.md",
423
+ "lead_prompt_metadata": (
424
+ run_prompts / f"lead-execution-prompt{suffixes['prompts']}.md.meta.json"
425
+ ),
426
+ "invocation_reservation_root": run_prompts / ".agent-invocations",
419
427
  "claude_worker_prompt": run_prompts / f"claude-worker-prompt{suffixes['prompts']}.md",
420
428
  "codex_worker_prompt": run_prompts / f"codex-worker-prompt{suffixes['prompts']}.md",
421
429
  "antigravity_worker_prompt": run_prompts / f"antigravity-worker-prompt{suffixes['prompts']}.md",
@@ -535,6 +543,10 @@ def _absolute_run_fields(paths: Mapping[str, Any]) -> dict[str, str]:
535
543
  "RUN_MANIFEST_PATH": str(paths["run_manifest_file"]),
536
544
  "RUN_CONTEXT_FILE": str(paths["run_context_file"]),
537
545
  "RUN_PROMPT_SNAPSHOT_FILE": str(paths["run_prompt_snapshot"]),
546
+ "DUTY_CONTRACT_ROOT": str(paths["duty_contract_root"]),
547
+ "LEAD_INSTRUCTIONS_PATH": str(paths["lead_instructions"]),
548
+ "LEAD_PROMPT_METADATA_PATH": str(paths["lead_prompt_metadata"]),
549
+ "INVOCATION_RESERVATION_ROOT": str(paths["invocation_reservation_root"]),
538
550
  "FINAL_REPORT_PATH": str(paths["final_report"]),
539
551
  "CONVERGENCE_STATE_PATH": str(paths["convergence_state"]),
540
552
  "FINAL_STATUS_PATH": str(paths["final_status"]),
@@ -569,6 +581,8 @@ def _filename_fields(paths: Mapping[str, Any]) -> dict[str, str]:
569
581
  return {
570
582
  "RUN_MANIFEST_FILENAME": Path(paths["run_manifest_file"]).name,
571
583
  "RUN_PROMPT_SNAPSHOT_FILENAME": Path(paths["run_prompt_snapshot"]).name,
584
+ "LEAD_INSTRUCTIONS_FILENAME": Path(paths["lead_instructions"]).name,
585
+ "LEAD_PROMPT_METADATA_FILENAME": Path(paths["lead_prompt_metadata"]).name,
572
586
  "FINAL_REPORT_FILENAME": Path(paths["final_report"]).name,
573
587
  "FINAL_STATUS_FILENAME": Path(paths["final_status"]).name,
574
588
  "CLAUDE_RESUME_COMMAND_FILENAME": Path(paths["claude_resume_command"]).name,
@@ -621,6 +635,18 @@ def _relative_file_fields(project_root: Path, paths: Mapping[str, Any]) -> dict[
621
635
  "RUN_CONTEXT_RELATIVE_PATH": _rel(project_root, paths["run_context_file"]),
622
636
  "RUN_INPUTS_RELATIVE_PATH": _rel(project_root, paths["run_inputs_file"]),
623
637
  "RUN_PROMPT_SNAPSHOT_RELATIVE_PATH": _rel(project_root, paths["run_prompt_snapshot"]),
638
+ "DUTY_CONTRACT_ROOT_RELATIVE_PATH": _rel(
639
+ project_root, paths["duty_contract_root"]
640
+ ),
641
+ "LEAD_INSTRUCTIONS_RELATIVE_PATH": _rel(
642
+ project_root, paths["lead_instructions"]
643
+ ),
644
+ "LEAD_PROMPT_METADATA_RELATIVE_PATH": _rel(
645
+ project_root, paths["lead_prompt_metadata"]
646
+ ),
647
+ "INVOCATION_RESERVATION_ROOT_RELATIVE_PATH": _rel(
648
+ project_root, paths["invocation_reservation_root"]
649
+ ),
624
650
  "CLAUDE_WORKER_PROMPT_RELATIVE_PATH": _rel(project_root, paths["claude_worker_prompt"]),
625
651
  "CODEX_WORKER_PROMPT_RELATIVE_PATH": _rel(project_root, paths["codex_worker_prompt"]),
626
652
  "ANTIGRAVITY_WORKER_PROMPT_RELATIVE_PATH": _rel(project_root, paths["antigravity_worker_prompt"]),
@@ -524,6 +524,16 @@ def compute_run_paths(
524
524
 
525
525
  run_manifest_file = run_manifests / f"run-manifest{suffixes['manifests']}.json"
526
526
  run_prompt_snapshot = run_prompts / f"lead-execution-prompt{suffixes['prompts']}.md"
527
+ duty_contract_root = (
528
+ run_prompts / f"duty-contracts{suffixes['prompts']}"
529
+ )
530
+ lead_instructions = (
531
+ run_prompts / f"lead-instructions{suffixes['prompts']}.md"
532
+ )
533
+ lead_prompt_metadata = run_prompt_snapshot.with_name(
534
+ run_prompt_snapshot.name + ".meta.json"
535
+ )
536
+ invocation_reservation_root = run_prompts / ".agent-invocations"
527
537
  claude_worker_prompt = run_prompts / f"claude-worker-prompt{suffixes['prompts']}.md"
528
538
  codex_worker_prompt = run_prompts / f"codex-worker-prompt{suffixes['prompts']}.md"
529
539
  antigravity_worker_prompt = run_prompts / f"antigravity-worker-prompt{suffixes['prompts']}.md"
@@ -625,6 +635,10 @@ def compute_run_paths(
625
635
  "RUN_CARRY_PATH": str(run_carry),
626
636
  "RUN_MANIFEST_PATH": str(run_manifest_file),
627
637
  "RUN_PROMPT_SNAPSHOT_FILE": str(run_prompt_snapshot),
638
+ "DUTY_CONTRACT_ROOT": str(duty_contract_root),
639
+ "LEAD_INSTRUCTIONS_PATH": str(lead_instructions),
640
+ "LEAD_PROMPT_METADATA_PATH": str(lead_prompt_metadata),
641
+ "INVOCATION_RESERVATION_ROOT": str(invocation_reservation_root),
628
642
  "CLAUDE_WORKER_PROMPT_FILE": str(claude_worker_prompt),
629
643
  "CODEX_WORKER_PROMPT_FILE": str(codex_worker_prompt),
630
644
  "ANTIGRAVITY_WORKER_PROMPT_FILE": str(antigravity_worker_prompt),
@@ -652,6 +666,8 @@ def compute_run_paths(
652
666
  "RUN_VALIDATOR_PATH": str(run_validator_script),
653
667
  "RUN_MANIFEST_FILENAME": run_manifest_file.name,
654
668
  "RUN_PROMPT_SNAPSHOT_FILENAME": run_prompt_snapshot.name,
669
+ "LEAD_INSTRUCTIONS_FILENAME": lead_instructions.name,
670
+ "LEAD_PROMPT_METADATA_FILENAME": lead_prompt_metadata.name,
655
671
  "FINAL_REPORT_FILENAME": final_report.name,
656
672
  "FINAL_STATUS_FILENAME": final_status.name,
657
673
  "CLAUDE_RESUME_COMMAND_FILENAME": claude_resume_command.name,
@@ -688,6 +704,10 @@ def compute_run_paths(
688
704
  ("RUN_SESSIONS_RELATIVE_PATH", run_sessions),
689
705
  ("RUN_MANIFEST_RELATIVE_PATH", run_manifest_file),
690
706
  ("RUN_PROMPT_SNAPSHOT_RELATIVE_PATH", run_prompt_snapshot),
707
+ ("DUTY_CONTRACT_ROOT_RELATIVE_PATH", duty_contract_root),
708
+ ("LEAD_INSTRUCTIONS_RELATIVE_PATH", lead_instructions),
709
+ ("LEAD_PROMPT_METADATA_RELATIVE_PATH", lead_prompt_metadata),
710
+ ("INVOCATION_RESERVATION_ROOT_RELATIVE_PATH", invocation_reservation_root),
691
711
  ("CLAUDE_WORKER_PROMPT_RELATIVE_PATH", claude_worker_prompt),
692
712
  ("CODEX_WORKER_PROMPT_RELATIVE_PATH", codex_worker_prompt),
693
713
  ("ANTIGRAVITY_WORKER_PROMPT_RELATIVE_PATH", antigravity_worker_prompt),
@@ -1,6 +1,11 @@
1
1
  """Host-facing ports consumed by application use cases."""
2
2
 
3
3
  from .host import HostAdapter
4
+ from .host_model import (
5
+ HostModelBindingError,
6
+ HostModelBindingPort,
7
+ HostModelBindingRequest,
8
+ )
4
9
  from .interaction import InteractionPort
5
10
  from .lead_session import LeadSessionPort
6
11
  from .usage_accounting import UsageAccountingPort
@@ -8,6 +13,9 @@ from .worker_dispatch import WorkerDispatchPort
8
13
 
9
14
  __all__ = (
10
15
  "HostAdapter",
16
+ "HostModelBindingError",
17
+ "HostModelBindingPort",
18
+ "HostModelBindingRequest",
11
19
  "InteractionPort",
12
20
  "LeadSessionPort",
13
21
  "UsageAccountingPort",
@@ -11,6 +11,7 @@ from ..domain.host import (
11
11
  HostSessionContext,
12
12
  )
13
13
  from .interaction import InteractionPort
14
+ from .host_model import HostModelBindingPort
14
15
  from .lead_session import LeadSessionPort
15
16
  from .usage_accounting import UsageAccountingPort
16
17
  from .worker_dispatch import WorkerDispatchPort
@@ -25,6 +26,8 @@ class HostAdapter(Protocol):
25
26
 
26
27
  def interaction(self) -> InteractionPort: ...
27
28
 
29
+ def host_model(self) -> HostModelBindingPort: ...
30
+
28
31
  def lead_session(self) -> LeadSessionPort: ...
29
32
 
30
33
  def worker_dispatch(self) -> WorkerDispatchPort: ...
@@ -0,0 +1,60 @@
1
+ """Host-owned binding from provider model values to native call arguments."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from typing import Protocol
6
+
7
+
8
+ class HostModelBindingError(ValueError):
9
+ """Raised when a host has no explicit native model mapping."""
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class HostModelBindingRequest:
14
+ host_runtime: str
15
+ provider: str
16
+ model: str
17
+ model_execution_value: str
18
+ runner: str
19
+
20
+
21
+ class HostModelBindingPort(Protocol):
22
+ def resolve(self, request: HostModelBindingRequest) -> str | None: ...
23
+
24
+
25
+ class FailClosedHostModelBindingPort:
26
+ def __init__(self, host_runtime: str) -> None:
27
+ self._host_runtime = host_runtime
28
+
29
+ def resolve(self, request: HostModelBindingRequest) -> str | None:
30
+ if request.runner == "cli-wrapper":
31
+ return None
32
+ raise HostModelBindingError(
33
+ "unsupported host model: "
34
+ f"host={self._host_runtime!r}, provider={request.provider!r}, "
35
+ f"model={request.model_execution_value!r}"
36
+ )
37
+
38
+
39
+ class NativeExecutionValueHostModelBindingPort:
40
+ """Explicit policy for hosts whose native API accepts provider CLI values."""
41
+
42
+ def __init__(self, host_runtime: str, native_provider: str) -> None:
43
+ self._host_runtime = host_runtime
44
+ self._native_provider = native_provider
45
+
46
+ def resolve(self, request: HostModelBindingRequest) -> str | None:
47
+ if request.runner == "cli-wrapper":
48
+ return None
49
+ if (
50
+ request.runner != "native-session"
51
+ or request.host_runtime != self._host_runtime
52
+ or request.provider != self._native_provider
53
+ or not request.model_execution_value.strip()
54
+ ):
55
+ raise HostModelBindingError(
56
+ "unsupported host model: "
57
+ f"host={request.host_runtime!r}, provider={request.provider!r}, "
58
+ f"model={request.model_execution_value!r}"
59
+ )
60
+ return request.model_execution_value
@@ -6,8 +6,8 @@ release-handoff 단계에서 lead 가 PR 본문을 작성할 때 사용하는
6
6
  1. per-run override (okstra-run Step 6 에서 입력)
7
7
  2. project: <project_root>/.okstra/project.json 의 ``prTemplatePath``
8
8
  3. global: ~/.okstra/config.json 의 ``prTemplatePath``
9
- 4. default: ``$OKSTRA_HOME/templates/pr/pr-body.template.md`` (구버전
10
- ``~/.claude/skills/...`` 후보는 backward-compat 유지)
9
+ 4. default: ``$OKSTRA_SKILLS_DIR/okstra-run/templates/pr-body.template.md``
10
+ (env 설정된 경우에만) → ``$OKSTRA_HOME/templates/pr/pr-body.template.md``
11
11
 
12
12
  경로는 절대경로 또는 ``~`` 시작 경로를 권장한다. 상대경로일 경우 project
13
13
  스코프는 ``project_root`` 기준, override 는 호출자 cwd 기준으로 해석한다.
@@ -41,10 +41,7 @@ def _default_template_candidates() -> list[Path]:
41
41
  env_dir = os.environ.get("OKSTRA_SKILLS_DIR", "").strip()
42
42
  if env_dir:
43
43
  out.append(Path(env_dir) / "okstra-run" / "templates" / _DEFAULT_FILENAME)
44
- out.append(okstra_home() / "templates" / "prd" / _DEFAULT_FILENAME)
45
- out.append(
46
- Path.home() / ".claude" / "skills" / "okstra-run" / "templates" / _DEFAULT_FILENAME
47
- )
44
+ out.append(okstra_home() / "templates" / "pr" / _DEFAULT_FILENAME)
48
45
  return out
49
46
 
50
47
 
@@ -26,12 +26,14 @@ _ADAPTER_METHODS = (
26
26
  "detect",
27
27
  "probe",
28
28
  "interaction",
29
+ "host_model",
29
30
  "lead_session",
30
31
  "worker_dispatch",
31
32
  "usage_accounting",
32
33
  )
33
34
  _PORT_METHODS = {
34
35
  "interaction": ("plan",),
36
+ "host_model": ("resolve",),
35
37
  "lead_session": ("build_start", "build_resume"),
36
38
  "worker_dispatch": ("build_plan",),
37
39
  "usage_accounting": ("collect",),
@@ -52,6 +54,9 @@ class _RegisteredHostAdapter:
52
54
  def interaction(self):
53
55
  return self._adapter.interaction()
54
56
 
57
+ def host_model(self):
58
+ return self._adapter.host_model()
59
+
55
60
  def lead_session(self):
56
61
  return self._adapter.lead_session()
57
62