whiletrue 0.1.0__py3-none-any.whl

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 (98) hide show
  1. whiletrue/__init__.py +0 -0
  2. whiletrue/__main__.py +3 -0
  3. whiletrue/abort.py +79 -0
  4. whiletrue/basics/__init__.py +99 -0
  5. whiletrue/basics/agent.py +249 -0
  6. whiletrue/basics/comments.py +113 -0
  7. whiletrue/basics/diff_size.py +80 -0
  8. whiletrue/basics/exec_.py +84 -0
  9. whiletrue/basics/history.py +117 -0
  10. whiletrue/basics/human.py +201 -0
  11. whiletrue/basics/json_source.py +35 -0
  12. whiletrue/basics/markdown_source.py +55 -0
  13. whiletrue/basics/process.py +99 -0
  14. whiletrue/basics/sessions.py +62 -0
  15. whiletrue/basics/set_.py +29 -0
  16. whiletrue/basics/shell.py +98 -0
  17. whiletrue/basics/wake.py +27 -0
  18. whiletrue/claude_code/__init__.py +58 -0
  19. whiletrue/claude_code/endpoint.py +143 -0
  20. whiletrue/claude_code/headless.py +181 -0
  21. whiletrue/claude_code/interactive.py +344 -0
  22. whiletrue/claude_code/learnings.py +32 -0
  23. whiletrue/claude_code/panes.py +164 -0
  24. whiletrue/claude_code/plugin/.claude-plugin/plugin.json +5 -0
  25. whiletrue/claude_code/plugin/hook.py +44 -0
  26. whiletrue/claude_code/plugin/hooks/hooks.json +81 -0
  27. whiletrue/claude_code/plugin/mcp_server.py +98 -0
  28. whiletrue/claude_code/session.py +345 -0
  29. whiletrue/claude_code/transcript.py +123 -0
  30. whiletrue/cli/__init__.py +15 -0
  31. whiletrue/cli/__main__.py +3 -0
  32. whiletrue/cli/app.py +550 -0
  33. whiletrue/cli/common.py +36 -0
  34. whiletrue/cli/gates.py +156 -0
  35. whiletrue/cli/ops.py +339 -0
  36. whiletrue/cli/panel.py +198 -0
  37. whiletrue/cli/registry.py +143 -0
  38. whiletrue/cli/templates.py +24 -0
  39. whiletrue/codex/__init__.py +14 -0
  40. whiletrue/codex/runner.py +243 -0
  41. whiletrue/control/__init__.py +38 -0
  42. whiletrue/control/chain.py +61 -0
  43. whiletrue/control/loop.py +139 -0
  44. whiletrue/control/map_.py +101 -0
  45. whiletrue/control/parallel.py +32 -0
  46. whiletrue/control/retry.py +100 -0
  47. whiletrue/control/sequence.py +40 -0
  48. whiletrue/control/switch.py +67 -0
  49. whiletrue/core/__init__.py +260 -0
  50. whiletrue/core/caps.py +139 -0
  51. whiletrue/core/comments.py +80 -0
  52. whiletrue/core/composite.py +140 -0
  53. whiletrue/core/config.py +461 -0
  54. whiletrue/core/context.py +177 -0
  55. whiletrue/core/duration.py +19 -0
  56. whiletrue/core/events.py +94 -0
  57. whiletrue/core/expr.py +85 -0
  58. whiletrue/core/history.py +83 -0
  59. whiletrue/core/lock.py +86 -0
  60. whiletrue/core/memory_store.py +352 -0
  61. whiletrue/core/module.py +146 -0
  62. whiletrue/core/outcomes.py +82 -0
  63. whiletrue/core/registry.py +129 -0
  64. whiletrue/core/replay.py +58 -0
  65. whiletrue/core/runner.py +98 -0
  66. whiletrue/core/runtime.py +538 -0
  67. whiletrue/core/source.py +45 -0
  68. whiletrue/core/store.py +207 -0
  69. whiletrue/core/template.py +60 -0
  70. whiletrue/core/validate.py +142 -0
  71. whiletrue/core/wake.py +117 -0
  72. whiletrue/core/workspace.py +55 -0
  73. whiletrue/git/__init__.py +31 -0
  74. whiletrue/git/modules.py +128 -0
  75. whiletrue/git/workspaces.py +77 -0
  76. whiletrue/github/__init__.py +30 -0
  77. whiletrue/github/client.py +269 -0
  78. whiletrue/github/filebacked.py +87 -0
  79. whiletrue/github/memory.py +96 -0
  80. whiletrue/github/plugin.py +343 -0
  81. whiletrue/github/specs.py +39 -0
  82. whiletrue/opencode/__init__.py +25 -0
  83. whiletrue/opencode/runner.py +186 -0
  84. whiletrue/py.typed +0 -0
  85. whiletrue/recipes/issue-loop.yaml +59 -0
  86. whiletrue/recipes/ralph.yaml +14 -0
  87. whiletrue/recipes/review-pass.yaml +27 -0
  88. whiletrue/recipes/review-respond.yaml +22 -0
  89. whiletrue/store_sqlite/__init__.py +6 -0
  90. whiletrue/store_sqlite/migrations.py +132 -0
  91. whiletrue/store_sqlite/sqlite_store.py +537 -0
  92. whiletrue/tmux/__init__.py +11 -0
  93. whiletrue/tmux/controller.py +197 -0
  94. whiletrue-0.1.0.dist-info/METADATA +48 -0
  95. whiletrue-0.1.0.dist-info/RECORD +98 -0
  96. whiletrue-0.1.0.dist-info/WHEEL +4 -0
  97. whiletrue-0.1.0.dist-info/entry_points.txt +2 -0
  98. whiletrue-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Awaitable, Callable
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING, Any, Literal
7
+
8
+ from pydantic import BaseModel
9
+
10
+ if TYPE_CHECKING:
11
+ from whiletrue.core.config import PipelineNode
12
+ from whiletrue.core.context import CompositeContext, StepContext
13
+ from whiletrue.core.registry import Registry
14
+
15
+ #: How a module may touch the world. Drives the safety rules (see architecture.md).
16
+ EFFECTS = ("pure", "deterministic", "agentic", "side-effect")
17
+ Effect = Literal["pure", "deterministic", "agentic", "side-effect"]
18
+
19
+ #: Values arriving on (or leaving from) a module's ports, keyed by port name.
20
+ PortValues = dict[str, Any]
21
+
22
+
23
+ def is_effect(value: object) -> bool:
24
+ return isinstance(value, str) and value in EFFECTS
25
+
26
+
27
+ class EmptyConfig(BaseModel):
28
+ """Config for modules that take none."""
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class PortSpec:
33
+ """A named, typed connection point. `cap` names a schema (Issue, Diff, Text, Any, ...)."""
34
+
35
+ name: str
36
+ cap: str
37
+ optional: bool = False
38
+
39
+
40
+ @dataclass
41
+ class Ports:
42
+ inputs: list[PortSpec] = field(default_factory=lambda: [])
43
+ outputs: list[PortSpec] = field(default_factory=lambda: [])
44
+
45
+
46
+ @dataclass
47
+ class Emit:
48
+ """Which port fires, and with what. `value=None` means no value."""
49
+
50
+ port: str
51
+ value: Any = None
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class ConfigIssue:
56
+ """A static-validation finding about a config (never raised at run time)."""
57
+
58
+ severity: Literal["error", "warning"]
59
+ message: str
60
+
61
+
62
+ @dataclass
63
+ class ValidateContext:
64
+ """What static checks may look at (e.g. an agent step loading its prompt file)."""
65
+
66
+ cwd: str
67
+ registry: Registry
68
+
69
+
70
+ @dataclass
71
+ class ActionModule[Cfg: BaseModel]:
72
+ id: str # e.g. 'gate.tests'
73
+ config: type[Cfg]
74
+ ports: Ports
75
+ effects: Effect
76
+ run: Callable[[PortValues, StepContext[Cfg]], Awaitable[Emit]]
77
+ kind: Literal["action"] = "action"
78
+ capabilities: list[str] = field(default_factory=lambda: [])
79
+ #: Whether results of `pure`/`deterministic` runs may be cached by input hash. Modules that
80
+ #: read the workspace (gates, shell) must set this to False.
81
+ cacheable: bool = True
82
+ #: Extra static checks beyond the config schema (e.g. agent limits).
83
+ validate: Callable[[Cfg, ValidateContext], list[ConfigIssue]] | None = None
84
+ #: Required for `side-effect` modules: a deterministic key so resume never repeats the effect.
85
+ idempotency_key: Callable[[PortValues, StepContext[Cfg]], str] | None = None
86
+ #: Record the result under `idempotency_key` so a repeat (resume, a second run) reuses it
87
+ #: instead of repeating the effect. The key must name the *effect*, not the run.
88
+ journal: bool = False
89
+
90
+
91
+ @dataclass
92
+ class Strategy[Cfg: BaseModel]:
93
+ id: str # e.g. 'loop'
94
+ config: type[Cfg]
95
+ run: Callable[[list[PipelineNode], PortValues, CompositeContext[Cfg]], Awaitable[Emit]]
96
+ #: Children are chained: each child's `out` feeds the next child's `in`.
97
+ chain: bool = False
98
+ #: Extra static checks beyond the config schema (e.g. loops must be bounded).
99
+ validate: Callable[[Cfg, ValidateContext], list[ConfigIssue]] | None = None
100
+ #: Static checks that need the step's children (e.g. `switch` case targets).
101
+ validate_node: Callable[[Cfg, PipelineNode, ValidateContext], list[ConfigIssue]] | None = None
102
+
103
+
104
+ class ModuleDefinitionError(Exception):
105
+ def __init__(self, module_id: str, problem: str) -> None:
106
+ super().__init__(f'Invalid module "{module_id}": {problem}')
107
+
108
+
109
+ _ID = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$")
110
+
111
+
112
+ def _check_id(module_id: str) -> None:
113
+ if not _ID.match(module_id):
114
+ raise ModuleDefinitionError(
115
+ module_id, "id must be lowercase dotted segments, e.g. 'gate.tests'"
116
+ )
117
+
118
+
119
+ def _check_ports(module_id: str, direction: str, ports: list[PortSpec]) -> None:
120
+ seen: set[str] = set()
121
+ for port in ports:
122
+ if port.name in seen:
123
+ raise ModuleDefinitionError(module_id, f'duplicate {direction} port "{port.name}"')
124
+ seen.add(port.name)
125
+
126
+
127
+ def define_action[Cfg: BaseModel](mod: ActionModule[Cfg]) -> ActionModule[Cfg]:
128
+ """Validates an action module's static shape at definition time and returns it unchanged."""
129
+ _check_id(mod.id)
130
+ if str(mod.kind) != "action":
131
+ raise ModuleDefinitionError(mod.id, f"kind must be 'action', got '{mod.kind}'")
132
+ if not is_effect(mod.effects):
133
+ raise ModuleDefinitionError(mod.id, f"unknown effect '{mod.effects}'")
134
+ if mod.id.startswith("gate.") and mod.effects != "deterministic":
135
+ raise ModuleDefinitionError(
136
+ mod.id, "gates must be deterministic: an agent can never grade itself"
137
+ )
138
+ _check_ports(mod.id, "in", mod.ports.inputs)
139
+ _check_ports(mod.id, "out", mod.ports.outputs)
140
+ return mod
141
+
142
+
143
+ def define_strategy[Cfg: BaseModel](strategy: Strategy[Cfg]) -> Strategy[Cfg]:
144
+ """Validates a strategy's static shape at definition time and returns it unchanged."""
145
+ _check_id(strategy.id)
146
+ return strategy
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Literal
5
+
6
+ #: Every module has these outcome ports in addition to its data ports.
7
+ OUTCOME_PORTS = ("ok", "fail", "needs_human", "skip", "rejected")
8
+
9
+ ItemState = Literal["pending", "ready", "running", "parked", "done", "failed", "cancelled"]
10
+
11
+ #: What a composite does with an unwired `fail`.
12
+ OnFail = Literal["park", "drop", "propagate"]
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class OutcomePolicy:
17
+ on_fail: OnFail = "park"
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Resolution:
22
+ action: Literal["continue", "route", "propagate", "exit"]
23
+ target: str | None = None
24
+ port: str | None = None
25
+ state: ItemState | None = None
26
+
27
+
28
+ def is_outcome_port(port: str) -> bool:
29
+ return port in OUTCOME_PORTS
30
+
31
+
32
+ def item_state_for(port: str, on_fail: OnFail) -> ItemState:
33
+ """Terminal item state for an outcome that leaves the pipeline."""
34
+ match port:
35
+ case "ok" | "skip":
36
+ return "done"
37
+ case "fail":
38
+ return "parked" if on_fail == "park" else "failed"
39
+ case _:
40
+ return "parked"
41
+
42
+
43
+ def resolve_outcome(port: str, wires: dict[str, str], policy: OutcomePolicy) -> Resolution:
44
+ """Decide what happens when a child fires `port`. A wired port always routes to its target.
45
+
46
+ Unwired: ok/skip and custom data ports continue; fail/needs_human/rejected follow the policy.
47
+ """
48
+ target = wires.get(port)
49
+ if target is not None:
50
+ return Resolution("route", target=target)
51
+ if not is_outcome_port(port) or port in ("ok", "skip"):
52
+ return Resolution("continue")
53
+ if policy.on_fail == "propagate":
54
+ return Resolution("propagate", port=port)
55
+ return Resolution("exit", port=port, state=item_state_for(port, policy.on_fail))
56
+
57
+
58
+ class InvalidTransition(Exception):
59
+ pass
60
+
61
+
62
+ #: Where an item may go next. `done` is final; `failed` and `cancelled` can be rerun.
63
+ _TRANSITIONS: dict[ItemState, frozenset[ItemState]] = {
64
+ "pending": frozenset({"ready", "cancelled"}),
65
+ "ready": frozenset({"running", "pending", "cancelled"}),
66
+ "running": frozenset({"parked", "done", "failed", "cancelled"}),
67
+ "parked": frozenset({"running", "ready", "cancelled"}),
68
+ "done": frozenset(),
69
+ "failed": frozenset({"ready", "cancelled"}),
70
+ "cancelled": frozenset({"ready"}),
71
+ }
72
+
73
+
74
+ def can_transition(old: ItemState, new: ItemState) -> bool:
75
+ return new in _TRANSITIONS[old]
76
+
77
+
78
+ def transition(old: ItemState, new: ItemState) -> ItemState:
79
+ """`new` if the move is allowed, else InvalidTransition."""
80
+ if not can_transition(old, new):
81
+ raise InvalidTransition(f"Invalid item transition {old} -> {new}")
82
+ return new
@@ -0,0 +1,129 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from typing import Any, Literal, Protocol, runtime_checkable
6
+
7
+ from pydantic import BaseModel
8
+
9
+ from whiletrue.core.caps import CapRegistry, create_builtin_caps
10
+ from whiletrue.core.module import ActionModule, Strategy
11
+ from whiletrue.core.runner import Runner
12
+
13
+ ProviderKind = Literal["source", "store", "workspace", "knowledge", "comments", "wake"]
14
+
15
+
16
+ class RegistryError(Exception):
17
+ pass
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Resolved:
22
+ kind: Literal["action", "strategy"]
23
+ module: Any # ActionModule[Any] | Strategy[Any]; narrowed by `kind`
24
+
25
+
26
+ class PluginApi(Protocol):
27
+ """The public plugin surface. Built-in modules use exactly this API."""
28
+
29
+ def register_action[Cfg: BaseModel](self, module: ActionModule[Cfg]) -> None: ...
30
+ def register_strategy[Cfg: BaseModel](self, strategy: Strategy[Cfg]) -> None: ...
31
+ def register_runner(self, runner: Runner) -> None: ...
32
+ def register_provider(self, kind: ProviderKind, name: str, provider: Any) -> None: ...
33
+ def register_cap(self, name: str, schema: Any) -> None: ...
34
+
35
+
36
+ @runtime_checkable
37
+ class Plugin(Protocol):
38
+ @property
39
+ def name(self) -> str: ...
40
+
41
+ def register(self, api: PluginApi, /) -> None: ...
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class SimplePlugin:
46
+ """A plugin made of a name and a registration function."""
47
+
48
+ name: str
49
+ register: Callable[[PluginApi], None]
50
+
51
+
52
+ class Registry:
53
+ def __init__(self, caps: CapRegistry | None = None) -> None:
54
+ self.caps = caps if caps is not None else create_builtin_caps()
55
+ self._actions: dict[str, ActionModule[Any]] = {}
56
+ self._strategies: dict[str, Strategy[Any]] = {}
57
+ self._runners: dict[str, Runner] = {}
58
+ self._providers: dict[str, Any] = {}
59
+ self._plugins: list[str] = []
60
+ self._plugin_versions: dict[str, str] = {}
61
+
62
+ def _assert_free_id(self, module_id: str) -> None:
63
+ if module_id in self._actions or module_id in self._strategies:
64
+ raise RegistryError(f'Module or strategy "{module_id}" is already registered')
65
+
66
+ def register_action[Cfg: BaseModel](self, module: ActionModule[Cfg]) -> None:
67
+ self._assert_free_id(module.id)
68
+ self._actions[module.id] = module
69
+
70
+ def register_strategy[Cfg: BaseModel](self, strategy: Strategy[Cfg]) -> None:
71
+ self._assert_free_id(strategy.id)
72
+ self._strategies[strategy.id] = strategy
73
+
74
+ def register_runner(self, runner: Runner) -> None:
75
+ if runner.id in self._runners:
76
+ raise RegistryError(f'Runner "{runner.id}" is already registered')
77
+ self._runners[runner.id] = runner
78
+
79
+ def register_provider(self, kind: ProviderKind, name: str, provider: Any) -> None:
80
+ key = f"{kind}:{name}"
81
+ if key in self._providers:
82
+ raise RegistryError(f'Provider "{key}" is already registered')
83
+ self._providers[key] = provider
84
+
85
+ def register_cap(self, name: str, schema: Any) -> None:
86
+ self.caps.register(name, schema)
87
+
88
+ def resolve(self, module_id: str) -> Resolved | None:
89
+ action = self._actions.get(module_id)
90
+ if action is not None:
91
+ return Resolved("action", action)
92
+ strategy = self._strategies.get(module_id)
93
+ if strategy is not None:
94
+ return Resolved("strategy", strategy)
95
+ return None
96
+
97
+ def get_runner(self, runner_id: str) -> Runner | None:
98
+ return self._runners.get(runner_id)
99
+
100
+ def get_provider(self, kind: ProviderKind, name: str) -> Any:
101
+ return self._providers.get(f"{kind}:{name}")
102
+
103
+ def providers(self, kind: ProviderKind) -> list[tuple[str, Any]]:
104
+ """All providers of a kind as (name, provider), in registration order."""
105
+ prefix = f"{kind}:"
106
+ return [
107
+ (key[len(prefix) :], provider)
108
+ for key, provider in self._providers.items()
109
+ if key.startswith(prefix)
110
+ ]
111
+
112
+ def ids(self) -> list[str]:
113
+ return sorted([*self._actions, *self._strategies])
114
+
115
+ def use(self, plugin: Plugin, version: str | None = None) -> None:
116
+ """Installs a plugin. `version` (else its `version` attribute) is what the lock pins."""
117
+ if plugin.name in self._plugins:
118
+ raise RegistryError(f'Plugin "{plugin.name}" is already installed')
119
+ plugin.register(self)
120
+ self._plugins.append(plugin.name)
121
+ self._plugin_versions[plugin.name] = version or str(
122
+ getattr(plugin, "version", "unversioned")
123
+ )
124
+
125
+ def plugin_versions(self) -> dict[str, str]:
126
+ return dict(self._plugin_versions)
127
+
128
+ def plugins(self) -> list[str]:
129
+ return list(self._plugins)
@@ -0,0 +1,58 @@
1
+ """Replay: re-run a recorded run's logic without running any module that has effects.
2
+
3
+ Every step's port and output is already recorded. A replay serves those recordings for all
4
+ modules except `pure` ones, so control flow, expressions and wiring run for real while agents,
5
+ shells and gates are not executed again.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ from whiletrue.core.runtime import RunItem
14
+ from whiletrue.core.store import StateStore
15
+
16
+ #: (item id, node path, attempt) -> recorded (port, output)
17
+ ReplayData = dict[tuple[str, str, int], tuple[str, Any]]
18
+
19
+
20
+ @dataclass
21
+ class Recording:
22
+ run_id: str
23
+ status: str
24
+ pipeline_hash: str
25
+ items: list[RunItem]
26
+ #: Recorded terminal state per item id.
27
+ item_states: dict[str, str]
28
+ steps: ReplayData
29
+ #: The chain of resumes this run belongs to (its own id for a fresh run).
30
+ lineage: str = ""
31
+
32
+
33
+ class ReplayError(Exception):
34
+ pass
35
+
36
+
37
+ def load_recording(store: StateStore, run_id: str) -> Recording:
38
+ run = store.get_run(run_id)
39
+ if run is None:
40
+ raise ReplayError(f'No run "{run_id}"')
41
+ records = store.list_items(run_id)
42
+ steps: ReplayData = {
43
+ (s.item_id, s.node_path, s.attempt): (s.port, s.output)
44
+ for s in store.list_steps(run_id)
45
+ # A step that parked for a person must run again when its run is resumed.
46
+ if s.port is not None and s.port != "needs_human"
47
+ }
48
+ started = store.list_events(run_id, type="run.started")
49
+ lineage = str(started[0].data.get("lineage", run_id)) if started else run_id
50
+ return Recording(
51
+ run_id=run.id,
52
+ status=run.status,
53
+ pipeline_hash=run.pipeline_hash,
54
+ items=[RunItem(i.id, i.payload) for i in records],
55
+ item_states={i.id: i.state for i in records},
56
+ steps=steps,
57
+ lineage=lineage,
58
+ )
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from typing import Any, Literal, Protocol
6
+
7
+ from whiletrue.abort import AbortSignal
8
+ from whiletrue.core.caps import CapValidation
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class RunnerCapabilities:
13
+ """What a runner can do; `validate` rejects steps that ask for more."""
14
+
15
+ interactive: bool
16
+ structured_output: bool
17
+ resume: bool
18
+ fork: bool
19
+ budget: bool
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class AgentLimits:
24
+ #: Max spend in USD (hard where the runner supports it, an estimate otherwise).
25
+ budget_usd: float | None = None
26
+ turns: int | None = None
27
+ #: Wall-clock limit in milliseconds.
28
+ wall_ms: int | None = None
29
+
30
+
31
+ @dataclass
32
+ class AgentRequest:
33
+ prompt: str
34
+ cwd: str
35
+ model: str | None = None
36
+ tools: list[str] | None = None
37
+ allowed_tools: list[str] | None = None
38
+ disallowed_tools: list[str] | None = None
39
+ permission_mode: str | None = None
40
+ system_prompt_append: str | None = None
41
+ #: JSON Schema the final structured output must satisfy.
42
+ output_schema: dict[str, Any] | None = None
43
+ limits: AgentLimits | None = None
44
+ #: New session id to use (fresh sessions).
45
+ session_id: str | None = None
46
+ #: Existing session to resume.
47
+ resume: str | None = None
48
+ #: With `resume`: branch into a new session instead of continuing the old one.
49
+ fork: bool = False
50
+ add_dirs: list[str] | None = None
51
+ append_system_prompt_file: str | None = None
52
+ mcp_config: list[str] | None = None
53
+ #: Reproducible mode: skip hooks, plugins, memory and auto-discovery (`claude --bare`).
54
+ bare: bool = False
55
+ #: Interactive runners: what happens to the pane when the step ends.
56
+ on_done: Literal["close", "keep"] = "close"
57
+ #: Identity of the step execution, for tying a live session back to it.
58
+ step_key: str | None = None
59
+ #: A short title for the pane, e.g. `#7 implement`.
60
+ label: str | None = None
61
+ #: The item's payload (what `whiletrue_context` returns to the agent).
62
+ item: dict[str, Any] | None = None
63
+ #: Name of the output cap and a validator for it (interactive runners check `whiletrue_submit`).
64
+ output_cap: str | None = None
65
+ validate_output: Callable[[Any], CapValidation] | None = None
66
+
67
+
68
+ @dataclass
69
+ class AgentResult:
70
+ ok: bool
71
+ #: Final text answer.
72
+ text: str
73
+ #: Structured output when the request carried an output schema.
74
+ output: Any = None
75
+ session_id: str | None = None
76
+ cost_usd: float | None = None
77
+ turns: int | None = None
78
+ error: str | None = None
79
+ #: Why the step needs a person instead of failing: `needs_human`, `budget_exceeded`, ...
80
+ park: str | None = None
81
+ #: A person typed into the session, which lifts the step's limits.
82
+ steered: bool = False
83
+ #: The part of `cost_usd` spent after the steer.
84
+ steered_cost_usd: float | None = None
85
+
86
+
87
+ @dataclass
88
+ class RunContext:
89
+ signal: AbortSignal
90
+ #: Raw runner events (tool calls, messages) for the event log.
91
+ on_event: Callable[[dict[str, Any]], None] | None = None
92
+
93
+
94
+ class Runner(Protocol):
95
+ id: str
96
+ capabilities: RunnerCapabilities
97
+
98
+ async def run(self, request: AgentRequest, ctx: RunContext) -> AgentResult: ...