okstra 0.163.2 → 0.165.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 (167) hide show
  1. package/README.md +8 -6
  2. package/docs/architecture.md +24 -15
  3. package/docs/cli.md +15 -8
  4. package/docs/for-ai/README.md +2 -2
  5. package/docs/for-ai/skills/okstra-inspect.md +2 -2
  6. package/docs/for-ai/skills/okstra-user-response.md +2 -2
  7. package/docs/project-structure-overview.md +22 -14
  8. package/package.json +1 -1
  9. package/runtime/BUILD.json +2 -2
  10. package/runtime/agents/workers/antigravity-worker.md +9 -7
  11. package/runtime/agents/workers/claude-worker.md +1 -0
  12. package/runtime/agents/workers/codex-worker.md +9 -7
  13. package/runtime/agents/workers/grok-worker.md +6 -4
  14. package/runtime/agents/workers/kimi-worker.md +6 -4
  15. package/runtime/bin/lib/okstra/cli.sh +5 -0
  16. package/runtime/bin/lib/okstra/globals.sh +2 -0
  17. package/runtime/bin/lib/okstra/usage.sh +5 -5
  18. package/runtime/bin/okstra-antigravity-exec.sh +1 -340
  19. package/runtime/bin/okstra-claude-exec.sh +1 -178
  20. package/runtime/bin/okstra-codex-exec.sh +1 -467
  21. package/runtime/bin/okstra-provider-exec.py +165 -190
  22. package/runtime/bin/okstra-trace-cleanup.sh +14 -7
  23. package/runtime/bin/okstra-wrapper-status.py +26 -19
  24. package/runtime/bin/okstra.sh +87 -91
  25. package/runtime/prompts/lead/adapters/cmux.md +2 -2
  26. package/runtime/prompts/lead/convergence.md +36 -8
  27. package/runtime/prompts/lead/okstra-lead-contract.md +24 -1
  28. package/runtime/prompts/lead/plan-body-verification.md +9 -1
  29. package/runtime/prompts/lead/report-writer.md +1 -0
  30. package/runtime/prompts/lead/team-contract.md +3 -3
  31. package/runtime/prompts/profiles/_common-contract.md +9 -1
  32. package/runtime/prompts/profiles/_coverage-critic.md +1 -1
  33. package/runtime/prompts/profiles/_implementation-diff-review.md +3 -1
  34. package/runtime/prompts/profiles/_implementation-self-check.md +1 -1
  35. package/runtime/prompts/profiles/_implementation-verifier.md +3 -1
  36. package/runtime/prompts/profiles/implementation-planning.md +6 -4
  37. package/runtime/python/okstra_ctl/adapters/accounting/__init__.py +11 -0
  38. package/runtime/python/okstra_ctl/adapters/accounting/claude_jsonl.py +17 -0
  39. package/runtime/python/okstra_ctl/adapters/accounting/cli_artifact.py +17 -0
  40. package/runtime/python/okstra_ctl/adapters/accounting/unavailable.py +19 -0
  41. package/runtime/python/okstra_ctl/adapters/dispatch/__init__.py +92 -0
  42. package/runtime/python/okstra_ctl/adapters/dispatch/cli_wrapper.py +54 -0
  43. package/runtime/python/okstra_ctl/adapters/dispatch/cmux.py +68 -0
  44. package/runtime/python/okstra_ctl/adapters/dispatch/native_team.py +13 -0
  45. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/adapter.py +60 -0
  46. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/manifest.json +1 -0
  47. package/runtime/{prompts/lead/adapters/antigravity.md → python/okstra_ctl/adapters/hosts/antigravity/relay.md} +52 -0
  48. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +292 -0
  49. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +120 -0
  50. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/manifest.json +1 -0
  51. package/runtime/{prompts/lead/adapters/claude-code.md → python/okstra_ctl/adapters/hosts/claude-code/relay.md} +112 -1
  52. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +60 -0
  53. package/runtime/python/okstra_ctl/adapters/hosts/codex/manifest.json +1 -0
  54. package/runtime/{prompts/lead/adapters/codex.md → python/okstra_ctl/adapters/hosts/codex/relay.md} +52 -0
  55. package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +72 -0
  56. package/runtime/python/okstra_ctl/adapters/hosts/external/manifest.json +1 -0
  57. package/runtime/{prompts/lead/adapters/external.md → python/okstra_ctl/adapters/hosts/external/relay.md} +53 -1
  58. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +63 -0
  59. package/runtime/python/okstra_ctl/adapters/hosts/grok/manifest.json +1 -0
  60. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +90 -0
  61. package/runtime/python/okstra_ctl/adapters/hosts/kimi/adapter.py +63 -0
  62. package/runtime/python/okstra_ctl/adapters/hosts/kimi/manifest.json +1 -0
  63. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +90 -0
  64. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +183 -0
  65. package/runtime/python/okstra_ctl/adapters/providers/antigravity/manifest.json +1 -0
  66. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +110 -0
  67. package/runtime/python/okstra_ctl/adapters/providers/claude/manifest.json +1 -0
  68. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +84 -0
  69. package/runtime/python/okstra_ctl/adapters/providers/codex/manifest.json +1 -0
  70. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +76 -0
  71. package/runtime/python/okstra_ctl/adapters/providers/grok/manifest.json +1 -0
  72. package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +80 -0
  73. package/runtime/python/okstra_ctl/adapters/providers/kimi/manifest.json +1 -0
  74. package/runtime/python/okstra_ctl/application/__init__.py +1 -0
  75. package/runtime/python/okstra_ctl/application/advance_wizard.py +25 -0
  76. package/runtime/python/okstra_ctl/application/collect_usage.py +15 -0
  77. package/runtime/python/okstra_ctl/application/dispatch_assignments.py +15 -0
  78. package/runtime/python/okstra_ctl/application/resolve_assignment.py +93 -0
  79. package/runtime/python/okstra_ctl/application/resume_run.py +21 -0
  80. package/runtime/python/okstra_ctl/application/start_run.py +21 -0
  81. package/runtime/python/okstra_ctl/codex_dispatch.py +49 -826
  82. package/runtime/python/okstra_ctl/dispatch_core.py +244 -29
  83. package/runtime/python/okstra_ctl/dispatch_state.py +27 -0
  84. package/runtime/python/okstra_ctl/domain/__init__.py +34 -0
  85. package/runtime/python/okstra_ctl/domain/host.py +100 -0
  86. package/runtime/python/okstra_ctl/domain/provider.py +70 -0
  87. package/runtime/python/okstra_ctl/domain/wizard/__init__.py +19 -0
  88. package/runtime/python/okstra_ctl/domain/wizard/interaction.py +140 -0
  89. package/runtime/python/okstra_ctl/domain/worker_exec.py +102 -0
  90. package/runtime/python/okstra_ctl/domain/worker_role.py +34 -0
  91. package/runtime/python/okstra_ctl/domain/worker_stream.py +261 -0
  92. package/runtime/python/okstra_ctl/entrypoints/__init__.py +1 -0
  93. package/runtime/python/okstra_ctl/entrypoints/hosts.py +334 -0
  94. package/runtime/python/okstra_ctl/incremental_scope.py +16 -4
  95. package/runtime/python/okstra_ctl/models.py +54 -269
  96. package/runtime/python/okstra_ctl/ports/__init__.py +15 -0
  97. package/runtime/python/okstra_ctl/ports/host.py +32 -0
  98. package/runtime/python/okstra_ctl/ports/interaction.py +15 -0
  99. package/runtime/python/okstra_ctl/ports/lead_session.py +25 -0
  100. package/runtime/python/okstra_ctl/ports/usage_accounting.py +23 -0
  101. package/runtime/python/okstra_ctl/ports/worker_dispatch.py +32 -0
  102. package/runtime/python/okstra_ctl/registry/__init__.py +13 -0
  103. package/runtime/python/okstra_ctl/registry/factory_loader.py +32 -0
  104. package/runtime/python/okstra_ctl/registry/host_discovery.py +124 -0
  105. package/runtime/python/okstra_ctl/registry/host_registry.py +365 -0
  106. package/runtime/python/okstra_ctl/registry/provider_registry.py +149 -0
  107. package/runtime/python/okstra_ctl/render.py +145 -47
  108. package/runtime/python/okstra_ctl/report_html/common.py +71 -25
  109. package/runtime/python/okstra_ctl/report_html/models.py +5 -0
  110. package/runtime/python/okstra_ctl/report_html/render.py +1 -1
  111. package/runtime/python/okstra_ctl/report_html/run_usage.py +19 -0
  112. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +14 -0
  113. package/runtime/python/okstra_ctl/report_views.py +44 -16
  114. package/runtime/python/okstra_ctl/run.py +80 -58
  115. package/runtime/python/okstra_ctl/session.py +1 -1
  116. package/runtime/python/okstra_ctl/stage_citations.py +52 -15
  117. package/runtime/python/okstra_ctl/team.py +44 -32
  118. package/runtime/python/okstra_ctl/user_response.py +45 -29
  119. package/runtime/python/okstra_ctl/wizard.py +175 -73
  120. package/runtime/python/okstra_ctl/worker_audit_ledger.py +29 -4
  121. package/runtime/python/okstra_ctl/worker_prompt_policy.py +10 -3
  122. package/runtime/python/okstra_ctl/worker_request.py +140 -0
  123. package/runtime/python/okstra_ctl/worker_runner.py +622 -0
  124. package/runtime/python/okstra_token_usage/collect.py +42 -7
  125. package/runtime/python/okstra_token_usage/report.py +42 -0
  126. package/runtime/python/okstra_token_usage/task_totals.py +88 -0
  127. package/runtime/schemas/final-report-v1.0.schema.json +4040 -1066
  128. package/runtime/schemas/final-report-v2.0.schema.json +5673 -1412
  129. package/runtime/skills/okstra-inspect/SKILL.md +1 -2
  130. package/runtime/skills/okstra-inspect/facets/logs.md +5 -5
  131. package/runtime/skills/okstra-inspect/facets/run-audit.md +3 -3
  132. package/runtime/skills/okstra-run/SKILL.md +74 -29
  133. package/runtime/skills/okstra-user-response/SKILL.md +15 -5
  134. package/runtime/templates/implementation-worker-preamble.md +1 -1
  135. package/runtime/templates/report-writer-prompt-preamble.md +1 -0
  136. package/runtime/templates/reports/final-report.template.md +3 -3
  137. package/runtime/templates/reports/html/assets/base.css +8 -4
  138. package/runtime/templates/reports/html/base.template.html +12 -6
  139. package/runtime/templates/reports/html/i18n/en.json +32 -7
  140. package/runtime/templates/reports/html/i18n/ko.json +32 -7
  141. package/runtime/templates/reports/html/macros/forms.html +9 -3
  142. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +14 -19
  143. package/runtime/templates/reports/report.js +59 -26
  144. package/runtime/templates/reports/user-response.template.md +12 -8
  145. package/runtime/templates/worker-prompt-preamble.md +1 -1
  146. package/runtime/validators/validate-implementation-plan-stages.py +17 -22
  147. package/runtime/validators/validate-report-views.py +0 -39
  148. package/runtime/validators/validate-run.py +96 -14
  149. package/runtime/validators/validate_session_conformance.py +69 -3
  150. package/src/cli-registry.mjs +0 -7
  151. package/src/commands/execute/render-bundle.mjs +4 -4
  152. package/src/commands/execute/run.mjs +8 -25
  153. package/src/commands/execute/wizard.mjs +33 -13
  154. package/src/commands/lifecycle/doctor.mjs +10 -10
  155. package/src/commands/lifecycle/install.mjs +53 -30
  156. package/src/commands/lifecycle/preflight.mjs +14 -4
  157. package/src/lib/host-registry-client.mjs +176 -0
  158. package/src/lib/runtime-manifest.mjs +6 -8
  159. package/runtime/bin/okstra-wrapper-agy-stream.py +0 -61
  160. package/runtime/python/okstra_ctl/error_issue.py +0 -640
  161. package/runtime/python/okstra_ctl/issue_signals.py +0 -186
  162. package/runtime/python/okstra_ctl/lead_runtime.py +0 -115
  163. package/runtime/python/okstra_ctl/runner_resolution.py +0 -103
  164. package/runtime/skills/okstra-inspect/facets/error-issue.md +0 -77
  165. package/src/commands/inspect/error-issue.mjs +0 -27
  166. package/src/lib/runtime-readiness.mjs +0 -90
  167. package/src/lib/runtime-resolver.mjs +0 -123
@@ -0,0 +1,70 @@
1
+ """Provider and model value objects shared by the registry and adapters."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from typing import TYPE_CHECKING, Mapping, Optional
6
+
7
+ if TYPE_CHECKING:
8
+ from okstra_ctl.domain.worker_exec import ExecutionStrategy
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class ModelSpec:
13
+ display: str
14
+ execution: str
15
+ pricing: Optional[tuple[float, float, float]] = None
16
+ in_picker: bool = False
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class LeadLaunchSpec:
21
+ """How to start a provider CLI as an interactive lead session."""
22
+
23
+ executable: str
24
+ model_flag: str
25
+ prompt_flag: str = ""
26
+ start_session_id_flag: str = ""
27
+ resume_session_id_flag: str = ""
28
+ sandbox_waiver: tuple[str, ...] = ()
29
+ sandbox_waiver_note: str = ""
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class ProviderSpec:
34
+ provider: str
35
+ display_label: str
36
+ models: Mapping[str, ModelSpec]
37
+ default_models: Mapping[str, str]
38
+ wrapper: str
39
+ supported_roles: frozenset[str]
40
+ lead_launch: Optional[LeadLaunchSpec] = None
41
+ exec_strategy: Optional["ExecutionStrategy"] = None
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class RunnerResolution:
46
+ runner: str
47
+ wrapper: str
48
+ reason: str
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class LeadProviderAssignment:
53
+ provider: str
54
+ runner: str
55
+ wrapper: str
56
+ reason: str
57
+
58
+
59
+ @dataclass
60
+ class ModelAssignment:
61
+ display: str
62
+ execution: str
63
+
64
+
65
+ class UnknownModelError(ValueError):
66
+ """Raised when a provider has no model alias matching a raw value."""
67
+
68
+
69
+ class UnknownProviderError(ValueError):
70
+ """Raised when a requested provider is absent from the registry."""
@@ -0,0 +1,19 @@
1
+ """Wizard interaction domain values and answer normalization."""
2
+
3
+ from .interaction import (
4
+ AnswerProtocol,
5
+ InteractionPlan,
6
+ WizardAnswerError,
7
+ WizardOption,
8
+ WizardPrompt,
9
+ normalize_planned_answer,
10
+ )
11
+
12
+ __all__ = [
13
+ "AnswerProtocol",
14
+ "InteractionPlan",
15
+ "WizardAnswerError",
16
+ "WizardOption",
17
+ "WizardPrompt",
18
+ "normalize_planned_answer",
19
+ ]
@@ -0,0 +1,140 @@
1
+ """Host-neutral wizard prompts, interaction plans, and answer protocols."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from dataclasses import dataclass
6
+ from typing import Literal
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class WizardOption:
11
+ value: str
12
+ label: str
13
+ description: str = ""
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class WizardPrompt:
18
+ step: str
19
+ kind: Literal["pick", "text", "pick_group", "done", "aborted"]
20
+ label: str = ""
21
+ options: tuple[WizardOption, ...] = ()
22
+ help: str = ""
23
+ echo_template: str = ""
24
+ multi: bool = False
25
+ questions: tuple["WizardPrompt", ...] = ()
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class AnswerProtocol:
30
+ kind: Literal["exact-value", "numbered", "group-json", "numbered-group-json"]
31
+ multi: bool = False
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class InteractionPlan:
36
+ kind: Literal[
37
+ "native-single",
38
+ "native-multi",
39
+ "native-group",
40
+ "numbered-single",
41
+ "numbered-multi",
42
+ "sequential-group",
43
+ "plain-text",
44
+ ]
45
+ answer_protocol: AnswerProtocol
46
+
47
+
48
+ class WizardAnswerError(ValueError):
49
+ """Raised when an answer violates the interaction plan's protocol."""
50
+
51
+
52
+ def _normalize_numbered_item(prompt: WizardPrompt, answer: str) -> str:
53
+ candidate = (answer or "").strip()
54
+ exact_values = tuple(
55
+ option.value for option in prompt.options if option.value == candidate
56
+ )
57
+ if exact_values:
58
+ return exact_values[0]
59
+ if candidate.isdecimal():
60
+ number = int(candidate)
61
+ if 1 <= number <= len(prompt.options):
62
+ return prompt.options[number - 1].value
63
+ raise WizardAnswerError(f"numbered-text answer is out of range: {candidate}")
64
+ label_values = tuple(
65
+ option.value for option in prompt.options if option.label == candidate
66
+ )
67
+ if len(label_values) == 1:
68
+ return label_values[0]
69
+ if len(label_values) > 1:
70
+ raise WizardAnswerError(
71
+ f"numbered-text answer label is ambiguous: {candidate}"
72
+ )
73
+ raise WizardAnswerError(f"numbered-text answer is not an option: {candidate}")
74
+
75
+
76
+ def _normalize_numbered_answer(
77
+ prompt: WizardPrompt,
78
+ answer: str,
79
+ *,
80
+ multi: bool,
81
+ ) -> str:
82
+ if not multi:
83
+ return _normalize_numbered_item(prompt, answer)
84
+ if not (answer or "").strip():
85
+ return ""
86
+ return ",".join(
87
+ _normalize_numbered_item(prompt, item)
88
+ for item in answer.split(",")
89
+ )
90
+
91
+
92
+ def _group_answers(answer: str) -> dict[str, object]:
93
+ try:
94
+ answers = json.loads(answer or "{}")
95
+ except json.JSONDecodeError as exc:
96
+ raise WizardAnswerError(f"group answer must be a JSON object: {exc}") from exc
97
+ if not isinstance(answers, dict):
98
+ raise WizardAnswerError("group answer must be a JSON object")
99
+ return answers
100
+
101
+
102
+ def _normalize_group_answer(
103
+ prompt: WizardPrompt,
104
+ answer: str,
105
+ *,
106
+ numbered: bool,
107
+ ) -> str:
108
+ answers = _group_answers(answer)
109
+ if not numbered:
110
+ return json.dumps(answers, ensure_ascii=False, separators=(",", ":"))
111
+ questions = {question.step: question for question in prompt.questions}
112
+ normalized = dict(answers)
113
+ for step, raw_answer in answers.items():
114
+ question = questions.get(step)
115
+ if question is None:
116
+ continue
117
+ normalized[step] = _normalize_numbered_answer(
118
+ question,
119
+ str(raw_answer or ""),
120
+ multi=question.multi,
121
+ )
122
+ return json.dumps(normalized, ensure_ascii=False, separators=(",", ":"))
123
+
124
+
125
+ def normalize_planned_answer(
126
+ prompt: WizardPrompt,
127
+ protocol: AnswerProtocol,
128
+ answer: str,
129
+ ) -> str:
130
+ if protocol.kind == "exact-value":
131
+ return answer
132
+ if protocol.kind == "numbered":
133
+ return _normalize_numbered_answer(prompt, answer, multi=protocol.multi)
134
+ if protocol.kind in {"group-json", "numbered-group-json"}:
135
+ return _normalize_group_answer(
136
+ prompt,
137
+ answer,
138
+ numbered=protocol.kind == "numbered-group-json",
139
+ )
140
+ raise WizardAnswerError(f"unsupported answer protocol: {protocol.kind}")
@@ -0,0 +1,102 @@
1
+ """Provider-neutral execution contracts shared by the dispatcher and workers.
2
+
3
+ A worker run is decided along three axes: which terminal surface it lands on,
4
+ which provider CLI runs it, and which role it plays. This module owns the
5
+ provider axis' vocabulary plus the one rule that is identical for every worker
6
+ regardless of provider — see ``ExecutionPolicy``.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Protocol, runtime_checkable
13
+
14
+ from .worker_stream import Normalise, no_events
15
+
16
+ # What ``ExecCommand.stream_format`` may hold. The value decides whether the
17
+ # runner pipes the CLI's output through the stream formatter or forwards it
18
+ # unchanged, so it is part of the contract rather than a display hint.
19
+ STREAM_JSON = "stream-json"
20
+ TEXT = "text"
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ExecutionPolicy:
25
+ """How a non-interactive worker is allowed to act.
26
+
27
+ Identical for every worker: nobody is at the keyboard to answer an approval
28
+ prompt, so the gate has to be open and the boundary has to come from a
29
+ sandbox instead. Declared once here rather than per provider, because a
30
+ policy that lives in one adapter file per provider drifts into one policy
31
+ per provider.
32
+ """
33
+
34
+ auto_approve: bool
35
+ write_scope: tuple[Path, ...]
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class WorkerExecRequest:
40
+ """One worker dispatch, before any provider has looked at it.
41
+
42
+ ``worktree_path`` is the stage tree this run was given, or None for a
43
+ dispatch that works in the project itself. It is kept apart from
44
+ ``policy.write_scope`` because the two answer different questions: the scope
45
+ is what may be written, while this is where the run belongs. Providers
46
+ disagree on that second answer, so the request carries both and each
47
+ strategy decides.
48
+ """
49
+
50
+ prompt_text: str
51
+ model: str
52
+ project_root: Path
53
+ worktree_path: Path | None
54
+ policy: ExecutionPolicy
55
+ idle_timeout_seconds: int
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class ExecCommand:
60
+ """One provider invocation, fully resolved.
61
+
62
+ ``stdin_text`` is None when the provider takes its prompt as an argument
63
+ rather than on stdin.
64
+
65
+ ``cwd`` is part of the contract because it is not derivable from the argv:
66
+ some CLIs name their working directory in a flag, others inherit the
67
+ process's, and the two groups do not choose the same directory. A runner
68
+ that had to re-derive it would be guessing at what the strategy already
69
+ decided.
70
+
71
+ ``normalise`` is the companion of ``stream_format``: declaring a JSON stream
72
+ without saying how to read it is what left one provider's pane blank for a
73
+ whole run. The two are named together so a provider whose events are shaped
74
+ differently cannot be silently handed a formatter that cannot see them.
75
+ """
76
+
77
+ argv: tuple[str, ...]
78
+ stdin_text: str | None
79
+ stream_format: str
80
+ cwd: Path
81
+ normalise: Normalise = field(default=no_events)
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class PolicySupport:
86
+ """Whether a provider CLI can express ``ExecutionPolicy`` at all.
87
+
88
+ A provider that cannot must say so rather than quietly accepting the policy
89
+ and running without it. ``note`` carries the reason and is what the contract
90
+ test asserts on.
91
+ """
92
+
93
+ can_auto_approve: bool
94
+ can_bound_write_scope: bool
95
+ note: str = ""
96
+
97
+
98
+ @runtime_checkable
99
+ class ExecutionStrategy(Protocol):
100
+ def build_command(self, request: WorkerExecRequest) -> ExecCommand: ...
101
+
102
+ def policy_support(self) -> PolicySupport: ...
@@ -0,0 +1,34 @@
1
+ """Per-role execution parameters.
2
+
3
+ Roles differ by value, not by algorithm, so this is a value object rather than
4
+ a strategy. Owning the values here takes the final say back from the shell: the
5
+ dispatcher passed a global idle budget while each wrapper quietly overrode it
6
+ with its own `case "$role"` block.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+ # Implementation executor / verifier dispatches run whole build + test suites
13
+ # that legitimately emit nothing for minutes; the 600s cap reaped them mid-suite
14
+ # (observed: a silent jest + tsc build TERM'd at ~929s). This stays below the
15
+ # 1800s wall-clock polling cap so a genuine hang is still reaped first.
16
+ _BUILD_RUNNING_IDLE_SECONDS = 1500
17
+ _DEFAULT_IDLE_SECONDS = 600
18
+
19
+ _BUILD_RUNNING_ROLES = frozenset({"executor", "verifier"})
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class WorkerRoleSpec:
24
+ role: str
25
+ idle_timeout_seconds: int
26
+
27
+
28
+ def role_spec(role: str) -> WorkerRoleSpec:
29
+ seconds = (
30
+ _BUILD_RUNNING_IDLE_SECONDS
31
+ if role in _BUILD_RUNNING_ROLES
32
+ else _DEFAULT_IDLE_SECONDS
33
+ )
34
+ return WorkerRoleSpec(role=role, idle_timeout_seconds=seconds)
@@ -0,0 +1,261 @@
1
+ """Turn a worker's progress into lines a person can read.
2
+
3
+ Pure transformation: no files, no sockets, no clock. The runner decides where
4
+ the lines go; this module decides what they say.
5
+
6
+ Providers do not agree on a wire format, and this layer may not name one. So the
7
+ formatter never reads a provider's JSON: it reads the normalised events below,
8
+ and each provider adapter supplies the function that produces them from its own
9
+ wire shape (``ExecCommand.normalise``). One wire shape is shared widely enough
10
+ to be worth a default here — ``content_block_events``, whose events are keyed on
11
+ ``type`` and carry ``message.content`` blocks — but it is a schema, not a
12
+ provider, and an adapter that speaks something else says so in its own file.
13
+
14
+ Screen output folds what the log keeps in full — a tool call becomes one line, a
15
+ tool result becomes an outcome and a size. Thinking is dropped at normalisation,
16
+ which is safe only because worker liveness is measured from stream arrival
17
+ rather than from log writes (see the spec's 7.2).
18
+
19
+ Three projections of the same stream: ``format_live`` for the pane,
20
+ ``format_log`` for the archive, and ``final_text`` for the caller that must
21
+ receive the closing message without the progress that produced it.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass
26
+ from typing import Any, Callable, Mapping
27
+
28
+ # Worker panes sit in a two-column grid whose narrowest allowed pane is the
29
+ # floor named by the placement module (60 columns at the time of writing).
30
+ # Holding a summary to two such lines keeps one tool call from pushing the
31
+ # previous one off the top of a short pane.
32
+ _MAX_SUMMARY = 120
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Text:
37
+ """Prose the worker addressed to whoever is reading."""
38
+
39
+ body: str
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class ToolCall:
44
+ """A tool the worker invoked, and the argument worth naming on one row."""
45
+
46
+ name: str
47
+ detail: str = ""
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class ToolResult:
52
+ """What a tool returned.
53
+
54
+ ``size_bytes`` is carried rather than derived from ``body`` because the two
55
+ answer different questions: the size is how much the tool produced, while
56
+ the body is what the archive is willing to keep of it.
57
+
58
+ ``failed`` is None when the stream closed the tool step without reporting an
59
+ outcome at all. Reading that absence as success would put a confident "ok"
60
+ next to a command that failed.
61
+ """
62
+
63
+ body: str
64
+ size_bytes: int
65
+ failed: bool | None = None
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class Denial:
70
+ """A tool call the provider refused before the worker could make it."""
71
+
72
+ tool: str
73
+ reason: str
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class Result:
78
+ """The worker's closing message. At most one per run."""
79
+
80
+ text: str
81
+
82
+
83
+ StreamEvent = Text | ToolCall | ToolResult | Denial | Result
84
+
85
+ # What an adapter hands the runner alongside its stream format: one line of the
86
+ # CLI's output, already parsed, turned into however many normalised events it
87
+ # carries. Returning nothing is how an event is dropped.
88
+ Normalise = Callable[[Mapping[str, Any]], tuple[StreamEvent, ...]]
89
+
90
+
91
+ def no_events(event: Mapping[str, Any]) -> tuple[StreamEvent, ...]:
92
+ """The normaliser for a CLI that emits no events at all.
93
+
94
+ A text CLI's output is already what a person reads, so the runner forwards
95
+ it whole and never calls this. It exists so ``ExecCommand`` can default to
96
+ a truthful "there is nothing here to normalise" instead of to some
97
+ provider's schema.
98
+ """
99
+ return ()
100
+
101
+
102
+ def content_block_events(event: Mapping[str, Any]) -> tuple[StreamEvent, ...]:
103
+ """Normalise the stream shape keyed on ``type`` with ``message.content``.
104
+
105
+ Assistant events carry the worker's prose and its tool calls in the same
106
+ content list, so one event becomes several normalised ones. Thinking blocks
107
+ and everything unrecognised are dropped here rather than in the formatter:
108
+ what is worth reading is a property of the stream, not of the projection.
109
+ """
110
+ kind = event.get("type")
111
+ if kind == "assistant":
112
+ return tuple(
113
+ item
114
+ for block in _content_blocks(event)
115
+ if (item := _assistant_event(block)) is not None
116
+ )
117
+ if kind == "user":
118
+ return tuple(
119
+ _tool_result(block)
120
+ for block in _content_blocks(event)
121
+ if block.get("type") == "tool_result"
122
+ )
123
+ if kind == "system" and event.get("subtype") == "permission_denied":
124
+ return (
125
+ Denial(
126
+ tool=str(event.get("tool_name", "?")),
127
+ reason=str(event.get("decision_reason", "")).strip(),
128
+ ),
129
+ )
130
+ if kind == "result" and isinstance(event.get("result"), str):
131
+ return (Result(text=str(event["result"])),)
132
+ return ()
133
+
134
+
135
+ def format_live(event: StreamEvent) -> list[str]:
136
+ return _rows(event, limit=_MAX_SUMMARY, include_body=False)
137
+
138
+
139
+ def format_log(event: StreamEvent) -> list[str]:
140
+ """What the archive keeps: everything the screen shows, plus the bodies.
141
+
142
+ The log is no longer a live window — the worker pane is. It is read after
143
+ the fact, by a person reconstructing why a worker failed and by
144
+ ``okstra log-report``.
145
+ """
146
+ return _rows(event, limit=None, include_body=True)
147
+
148
+
149
+ def final_text(event: StreamEvent) -> str | None:
150
+ """The worker's closing message, or None for any other event."""
151
+ return event.text if isinstance(event, Result) else None
152
+
153
+
154
+ def _rows(event: StreamEvent, *, limit: int | None, include_body: bool) -> list[str]:
155
+ if isinstance(event, Text):
156
+ return _body_rows(event.body)
157
+ if isinstance(event, ToolCall):
158
+ head = f"→ {event.name}: {event.detail}" if event.detail else f"→ {event.name}"
159
+ return [_truncate(head, limit)]
160
+ if isinstance(event, ToolResult):
161
+ rows = [f" ← {_outcome(event.failed)} ({event.size_bytes} bytes)"]
162
+ if include_body:
163
+ rows.extend(_body_rows(event.body))
164
+ return rows
165
+ if isinstance(event, Denial):
166
+ return [_truncate(f"!! PERMISSION DENIED — {event.tool}: {event.reason}", limit)]
167
+ # A Result is printed by the runner at the point the run ends, not woven
168
+ # into the progress it interrupts.
169
+ return []
170
+
171
+
172
+ def _outcome(failed: bool | None) -> str:
173
+ if failed is None:
174
+ return "done"
175
+ return "error" if failed else "ok"
176
+
177
+
178
+ def _body_rows(text: str) -> list[str]:
179
+ """One screen row per line, with every blank row dropped.
180
+
181
+ Blank rows are dropped wherever they sit, not only at the end: a tool that
182
+ double-spaces its output would otherwise take twice the pane height it
183
+ earns, and a body that ends in newlines would pad the archive.
184
+ """
185
+ return [part for part in text.splitlines() if part.strip()]
186
+
187
+
188
+ def _assistant_event(block: Mapping[str, Any]) -> StreamEvent | None:
189
+ if block.get("type") == "text":
190
+ return Text(body=str(block.get("text", "")))
191
+ if block.get("type") == "tool_use":
192
+ return ToolCall(
193
+ name=str(block.get("name", "tool")), detail=_tool_detail(block.get("input"))
194
+ )
195
+ return None
196
+
197
+
198
+ def _tool_detail(payload: Any) -> str:
199
+ if not isinstance(payload, Mapping):
200
+ return ""
201
+ for key in ("command", "file_path", "path", "pattern", "query"):
202
+ value = payload.get(key)
203
+ if value:
204
+ return str(value)
205
+ return ""
206
+
207
+
208
+ def _tool_result(block: Mapping[str, Any]) -> ToolResult:
209
+ body = block.get("content")
210
+ return ToolResult(
211
+ body=_body_text(body),
212
+ size_bytes=_body_size(body),
213
+ failed=bool(block.get("is_error")),
214
+ )
215
+
216
+
217
+ def _body_text(body: Any) -> str:
218
+ """The tool's own output as one string, whatever shape it arrived in."""
219
+ if isinstance(body, str):
220
+ return body
221
+ if isinstance(body, list):
222
+ return "\n".join(
223
+ str(block.get("text", "")) for block in body if isinstance(block, Mapping)
224
+ )
225
+ return ""
226
+
227
+
228
+ def _body_size(body: Any) -> int:
229
+ """How much a tool returned, in bytes.
230
+
231
+ The content arrives either as a plain string or as a list of content
232
+ blocks. Counting only the string form reports a five-figure result as
233
+ zero — a confident wrong number, which is worse on a screen than no
234
+ number. Bytes rather than characters because the figure exists to be
235
+ compared against the log this run leaves on disk.
236
+ """
237
+ if isinstance(body, str):
238
+ return len(body.encode("utf-8"))
239
+ if isinstance(body, list):
240
+ return sum(
241
+ len(str(block.get("text", "")).encode("utf-8"))
242
+ for block in body
243
+ if isinstance(block, Mapping)
244
+ )
245
+ return 0
246
+
247
+
248
+ def _content_blocks(event: Mapping[str, Any]) -> list[Mapping[str, Any]]:
249
+ message = event.get("message")
250
+ if not isinstance(message, Mapping):
251
+ return []
252
+ content = message.get("content")
253
+ if not isinstance(content, list):
254
+ return []
255
+ return [block for block in content if isinstance(block, Mapping)]
256
+
257
+
258
+ def _truncate(line: str, limit: int | None) -> str:
259
+ if limit is None or len(line) <= limit:
260
+ return line
261
+ return line[: limit - 1] + "…"
@@ -0,0 +1 @@
1
+ """Internal command entrypoints used by Okstra host integrations."""