foreman-plane 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.
foreman/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """foreman-plane — a task governance plane that lives *outside* the agent harness.
2
+
3
+ The plane owns dispatch, two-level dependency gating, objective artefact
4
+ acceptance and an append-only audit ledger. Any CLI agent (Claude Code,
5
+ Codex, DeepSeek Harness, ...) is just a pluggable execution body.
6
+
7
+ Zero third-party dependencies: standard library only, Python >= 3.10.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ __version__ = "0.1.0"
13
+ __all__ = ["__version__"]
foreman/acceptance.py ADDED
@@ -0,0 +1,140 @@
1
+ """Objective artefact acceptance (SPEC §5).
2
+
3
+ The acceptance string is ``path`` or ``path::key=value::key=value``.
4
+
5
+ Supported criteria:
6
+
7
+ * ``min_bytes=N`` file size >= N
8
+ * ``sha256=<hex>`` sha256 of the file content equals the digest
9
+ * ``contains=<sub>`` file content contains the substring
10
+ * ``exit_code=N`` execution body exit code equals N
11
+
12
+ Criteria are ANDed. An unknown criterion fails closed.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import os
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+
22
+ from .contract import ACCEPT_PATH_RE, split_acceptance
23
+
24
+ __all__ = ["AcceptanceResult", "verify", "parse_acceptance"]
25
+
26
+
27
+ @dataclass
28
+ class AcceptanceResult:
29
+ """Result of one acceptance verification (SPEC §5)."""
30
+
31
+ ok: bool
32
+ path: str | None = None
33
+ checks: list[dict] = field(default_factory=list)
34
+ reason: str | None = None
35
+
36
+ def to_dict(self) -> dict:
37
+ return {
38
+ "ok": self.ok,
39
+ "path": self.path,
40
+ "checks": list(self.checks),
41
+ "reason": self.reason,
42
+ }
43
+
44
+
45
+ def parse_acceptance(value: str | None) -> tuple[str | None, list[tuple[str, str]]]:
46
+ """Public alias of the acceptance string parser."""
47
+ return split_acceptance(value)
48
+
49
+
50
+ def _as_int(value: str) -> int | None:
51
+ try:
52
+ return int(str(value).strip())
53
+ except (TypeError, ValueError):
54
+ return None
55
+
56
+
57
+ def verify(
58
+ acceptance: str | None,
59
+ exit_code: int | None = None,
60
+ base_dir: str | os.PathLike[str] | None = None,
61
+ ) -> AcceptanceResult:
62
+ """Verify an artefact against an acceptance string."""
63
+ if acceptance is None or not str(acceptance).strip():
64
+ return AcceptanceResult(ok=True, path=None, checks=[], reason=None)
65
+
66
+ raw_path, criteria = split_acceptance(acceptance)
67
+ if not raw_path or not ACCEPT_PATH_RE.match(raw_path):
68
+ return AcceptanceResult(
69
+ ok=False,
70
+ path=raw_path,
71
+ checks=[],
72
+ reason="acceptance_invalid",
73
+ )
74
+
75
+ target = Path(raw_path).expanduser()
76
+ if not target.is_absolute():
77
+ base = Path(base_dir).expanduser() if base_dir else Path.cwd()
78
+ target = base / target
79
+
80
+ checks: list[dict] = []
81
+ if not target.exists():
82
+ checks.append({"name": "exists", "expected": True, "actual": False, "ok": False})
83
+ return AcceptanceResult(ok=False, path=str(target), checks=checks, reason="artifact_missing")
84
+ checks.append({"name": "exists", "expected": True, "actual": True, "ok": True})
85
+ if not target.is_file():
86
+ return AcceptanceResult(
87
+ ok=False,
88
+ path=str(target),
89
+ checks=checks,
90
+ reason="artifact_not_a_file",
91
+ )
92
+
93
+ try:
94
+ data = target.read_bytes()
95
+ except OSError:
96
+ return AcceptanceResult(
97
+ ok=False,
98
+ path=str(target),
99
+ checks=checks,
100
+ reason="artifact_unreadable",
101
+ )
102
+ text = data.decode("utf-8", errors="replace")
103
+
104
+ ok = True
105
+ reason: str | None = None
106
+ for name, expected in criteria:
107
+ key = name.strip().lower()
108
+ if key == "min_bytes":
109
+ wanted = _as_int(expected)
110
+ actual = len(data)
111
+ passed = wanted is not None and actual >= wanted
112
+ record_expected = wanted
113
+ elif key == "sha256":
114
+ actual = hashlib.sha256(data).hexdigest()
115
+ passed = actual.lower() == str(expected).strip().lower()
116
+ record_expected = str(expected).strip().lower()
117
+ elif key == "contains":
118
+ actual = None
119
+ passed = str(expected) in text
120
+ record_expected = str(expected)
121
+ elif key == "exit_code":
122
+ wanted = _as_int(expected)
123
+ actual = exit_code
124
+ passed = wanted is not None and exit_code is not None and int(exit_code) == wanted
125
+ record_expected = wanted
126
+ else:
127
+ checks.append(
128
+ {"name": key, "expected": str(expected), "actual": None, "ok": False}
129
+ )
130
+ ok = False
131
+ reason = reason or "acceptance_unknown_criterion"
132
+ continue
133
+ checks.append(
134
+ {"name": key, "expected": record_expected, "actual": actual, "ok": bool(passed)}
135
+ )
136
+ if not passed:
137
+ ok = False
138
+ reason = reason or "acceptance_criterion_failed"
139
+
140
+ return AcceptanceResult(ok=ok, path=str(target), checks=checks, reason=reason)
foreman/cli.py ADDED
@@ -0,0 +1,292 @@
1
+ """Command line interface (SPEC §8).
2
+
3
+ ``python -m foreman.cli`` or the ``foreman`` console script.
4
+
5
+ Exit codes: 0 success, 2 blocked by the gate, 3 artefact acceptance failed,
6
+ 1 any other error.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import sys
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from .acceptance import verify
18
+ from .config import describe, load_config, resolve_default_agent
19
+ from .errors import UnknownTaskError
20
+ from .runner import Dispatcher
21
+
22
+ __all__ = ["main", "build_parser"]
23
+
24
+ EXIT_OK = 0
25
+ EXIT_ERROR = 1
26
+ EXIT_BLOCKED = 2
27
+ EXIT_ACCEPTANCE = 3
28
+
29
+ _JSON_FLAG = argparse.SUPPRESS
30
+
31
+
32
+ def _add_json(parser: argparse.ArgumentParser) -> None:
33
+ parser.add_argument(
34
+ "--json",
35
+ action="store_true",
36
+ default=_JSON_FLAG,
37
+ help="machine readable output (JSON)",
38
+ )
39
+
40
+
41
+ def build_parser() -> argparse.ArgumentParser:
42
+ """Build the argument parser."""
43
+ parser = argparse.ArgumentParser(
44
+ prog="foreman",
45
+ description="foreman-plane — task governance outside the agent harness",
46
+ )
47
+ _add_json(parser)
48
+ sub = parser.add_subparsers(dest="command")
49
+
50
+ dispatch = sub.add_parser("dispatch", help="dispatch a task card file")
51
+ dispatch.add_argument("--file", required=True, help="path to the task card")
52
+ dispatch.add_argument("--agent", help="execution body name")
53
+ dispatch.add_argument("--workdir", help="working directory")
54
+ dispatch.add_argument("--force", action="store_true", help="override the duplicate check")
55
+ _add_json(dispatch)
56
+
57
+ status = sub.add_parser("status", help="ledger status summary")
58
+ status.add_argument("--task-id", help="restrict to one task")
59
+ _add_json(status)
60
+
61
+ ledger = sub.add_parser("ledger", help="ledger operations")
62
+ ledger_sub = ledger.add_subparsers(dest="ledger_command")
63
+
64
+ listing = ledger_sub.add_parser("list", help="list all tasks")
65
+ listing.add_argument("--task-id", help="restrict to one task")
66
+ _add_json(listing)
67
+
68
+ show = ledger_sub.add_parser("show", help="show the events of one task")
69
+ show.add_argument("task_id")
70
+ _add_json(show)
71
+
72
+ ready = ledger_sub.add_parser("ready", help="tasks whose dependencies are satisfied")
73
+ _add_json(ready)
74
+
75
+ blocked = ledger_sub.add_parser("blocked", help="tasks currently blocked")
76
+ _add_json(blocked)
77
+
78
+ cancel = ledger_sub.add_parser("cancel", help="cancel a task")
79
+ cancel.add_argument("task_id")
80
+ cancel.add_argument("--reason", default="")
81
+ _add_json(cancel)
82
+
83
+ replan = ledger_sub.add_parser("replan", help="re-plan a task")
84
+ replan.add_argument("task_id")
85
+ replan.add_argument("--reason", default="")
86
+ replan.add_argument("--new-task-id", default=None)
87
+ _add_json(replan)
88
+
89
+ verify_cmd = sub.add_parser("verify", help="verify an acceptance string")
90
+ verify_cmd.add_argument("--acceptance", required=True, help="acceptance string")
91
+ verify_cmd.add_argument("--base-dir", default=None, help="base directory for relative paths")
92
+ _add_json(verify_cmd)
93
+
94
+ sub.add_parser("mcp", help="run the MCP stdio server")
95
+ sub.add_parser("config", help="print the effective configuration")
96
+ _add_json(sub.choices["mcp"])
97
+ _add_json(sub.choices["config"])
98
+
99
+ return parser
100
+
101
+
102
+ def _emit(payload: Any, as_json: bool, text: str = "") -> None:
103
+ if as_json:
104
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
105
+ else:
106
+ print(text if text else json.dumps(payload, ensure_ascii=False, indent=2))
107
+
108
+
109
+ def _cmd_dispatch(args: argparse.Namespace, dispatcher: Dispatcher) -> int:
110
+ try:
111
+ text = Path(args.file).read_text(encoding="utf-8")
112
+ except OSError as exc:
113
+ print(f"cannot read task card: {exc}", file=sys.stderr)
114
+ return EXIT_ERROR
115
+ payload = dispatcher.dispatch(
116
+ text,
117
+ agent=args.agent,
118
+ workdir=args.workdir,
119
+ force=bool(args.force),
120
+ )
121
+ if payload.get("blocked"):
122
+ _emit(payload, args.json, _dispatch_text(payload))
123
+ return EXIT_BLOCKED
124
+ if payload.get("acceptance_ok") is False:
125
+ _emit(payload, args.json, _dispatch_text(payload))
126
+ return EXIT_ACCEPTANCE
127
+ if not payload.get("ok"):
128
+ _emit(payload, args.json, _dispatch_text(payload))
129
+ return EXIT_ERROR
130
+ _emit(payload, args.json, _dispatch_text(payload))
131
+ return EXIT_OK
132
+
133
+
134
+ def _dispatch_text(payload: dict) -> str:
135
+ lines = [
136
+ f"task_id: {payload.get('task_id')}",
137
+ f"ok: {payload.get('ok')}",
138
+ ]
139
+ if payload.get("blocked"):
140
+ lines.append(f"blocked: {payload.get('reason')}")
141
+ if payload.get("missing"):
142
+ lines.append(f"missing: {', '.join(payload['missing'])}")
143
+ return "\n".join(lines)
144
+ lines.append(f"verdict: {payload.get('verdict')}")
145
+ lines.append(f"acceptance_ok: {payload.get('acceptance_ok')}")
146
+ lines.append(f"exit_code: {payload.get('exit_code')}")
147
+ if payload.get("needs_api_pending"):
148
+ lines.append("needs_api_pending: true")
149
+ return "\n".join(lines)
150
+
151
+
152
+ def _cmd_status(args: argparse.Namespace, dispatcher: Dispatcher) -> int:
153
+ payload = dispatcher.status(args.task_id)
154
+ if args.json:
155
+ _emit(payload, True)
156
+ else:
157
+ if args.task_id:
158
+ state = payload.get("task")
159
+ if not state:
160
+ print(f"unknown task: {args.task_id}")
161
+ else:
162
+ for key in ("task_id", "status", "verdict", "acceptance_ok", "exit_code", "last_ts"):
163
+ print(f"{key}: {state.get(key)}")
164
+ else:
165
+ for name, count in sorted((payload.get("counts") or {}).items()):
166
+ print(f"{name}: {count}")
167
+ return EXIT_OK
168
+
169
+
170
+ def _cmd_ledger(args: argparse.Namespace, dispatcher: Dispatcher) -> int:
171
+ command = getattr(args, "ledger_command", None)
172
+ if command is None:
173
+ print("foreman ledger: expected one of list|show|ready|blocked|cancel|replan", file=sys.stderr)
174
+ return EXIT_ERROR
175
+ if command == "list":
176
+ payload = dispatcher.status(getattr(args, "task_id", None))
177
+ tasks = payload.get("tasks") or {}
178
+ if getattr(args, "task_id", None):
179
+ tasks = {args.task_id: tasks.get(args.task_id)} if args.task_id in tasks else {}
180
+ if args.json:
181
+ _emit(tasks, True)
182
+ else:
183
+ for task_id, state in sorted(tasks.items()):
184
+ print(f"{task_id}\t{state.get('status')}\t{state.get('verdict')}")
185
+ return EXIT_OK
186
+ if command == "show":
187
+ events = dispatcher.ledger.events(args.task_id)
188
+ if args.json:
189
+ _emit(events, True)
190
+ else:
191
+ for event in events:
192
+ print(json.dumps(event, ensure_ascii=False))
193
+ return EXIT_OK
194
+ if command == "ready":
195
+ payload = dispatcher.ready_tasks()
196
+ _emit(payload, args.json, "\n".join(item["task_id"] for item in payload))
197
+ return EXIT_OK
198
+ if command == "blocked":
199
+ payload = dispatcher.blocked_tasks()
200
+ _emit(payload, args.json, "\n".join(
201
+ f"{item['task_id']}\t{item.get('reason')}" for item in payload
202
+ ))
203
+ return EXIT_OK
204
+ if command == "cancel":
205
+ try:
206
+ payload = dispatcher.cancel(args.task_id, args.reason)
207
+ except UnknownTaskError as exc:
208
+ print(str(exc), file=sys.stderr)
209
+ return EXIT_ERROR
210
+ _emit(payload, args.json, f"cancelled: {args.task_id}")
211
+ return EXIT_OK
212
+ if command == "replan":
213
+ try:
214
+ payload = dispatcher.replan(args.task_id, args.reason, args.new_task_id)
215
+ except UnknownTaskError as exc:
216
+ print(str(exc), file=sys.stderr)
217
+ return EXIT_ERROR
218
+ _emit(payload, args.json, f"replanned: {args.task_id}")
219
+ return EXIT_OK
220
+ print(f"unknown ledger command: {command}", file=sys.stderr)
221
+ return EXIT_ERROR
222
+
223
+
224
+ def _cmd_verify(args: argparse.Namespace) -> int:
225
+ result = verify(args.acceptance, base_dir=args.base_dir)
226
+ payload = result.to_dict()
227
+ if args.json:
228
+ _emit(payload, True)
229
+ else:
230
+ print(f"ok: {result.ok}")
231
+ print(f"path: {result.path}")
232
+ if result.reason:
233
+ print(f"reason: {result.reason}")
234
+ for check in result.checks:
235
+ print(f" {check['name']}: expected={check['expected']} actual={check['actual']} ok={check['ok']}")
236
+ return EXIT_OK if result.ok else EXIT_ACCEPTANCE
237
+
238
+
239
+ def _cmd_config(args: argparse.Namespace) -> int:
240
+ config = load_config()
241
+ payload = describe(config)
242
+ payload["default_agent"] = resolve_default_agent()
243
+ if args.json:
244
+ _emit(payload, True)
245
+ else:
246
+ print(f"FOREMAN_HOME: {payload['home']}")
247
+ print(f"ledger: {payload['ledger_path']}")
248
+ print(f"runs_dir: {payload['runs_dir']}")
249
+ print(f"timeout_s: {payload['timeout_s']}")
250
+ print(f"default_agent: {payload['default_agent']}")
251
+ print(f"agents: {', '.join(payload['agents'])}")
252
+ return EXIT_OK
253
+
254
+
255
+ def _cmd_mcp() -> int:
256
+ from .mcp_server import main as mcp_main
257
+
258
+ return mcp_main()
259
+
260
+
261
+ def main(argv: list[str] | None = None) -> int:
262
+ """CLI entry point."""
263
+ parser = build_parser()
264
+ args = parser.parse_args(argv)
265
+ if not hasattr(args, "json"):
266
+ args.json = False
267
+ as_json = bool(args.json)
268
+ command = getattr(args, "command", None)
269
+ if not command:
270
+ parser.print_help()
271
+ return EXIT_ERROR
272
+
273
+ if command == "mcp":
274
+ return _cmd_mcp()
275
+
276
+ dispatcher = Dispatcher(load_config())
277
+ if command == "dispatch":
278
+ return _cmd_dispatch(args, dispatcher)
279
+ if command == "status":
280
+ return _cmd_status(args, dispatcher)
281
+ if command == "ledger":
282
+ return _cmd_ledger(args, dispatcher)
283
+ if command == "verify":
284
+ return _cmd_verify(args)
285
+ if command == "config":
286
+ return _cmd_config(args)
287
+ parser.print_help()
288
+ return EXIT_ERROR
289
+
290
+
291
+ if __name__ == "__main__": # pragma: no cover
292
+ sys.exit(main())
foreman/config.py ADDED
@@ -0,0 +1,175 @@
1
+ """Configuration resolution (SPEC §9).
2
+
3
+ Every path used by foreman-plane comes from configuration:
4
+
5
+ 1. ``FOREMAN_HOME`` environment variable
6
+ 2. ``$HOME/.foreman`` (skipped when ``HOME`` is not set — never crash)
7
+ 3. ``<cwd>/.foreman``
8
+
9
+ No absolute path constant and no host module is referenced anywhere.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Mapping, MutableMapping
19
+
20
+ DEFAULT_TIMEOUT_S = 900
21
+ DEFAULT_AGENT = "generic"
22
+
23
+ ENV_HOME = "FOREMAN_HOME"
24
+ ENV_TIMEOUT = "FOREMAN_TIMEOUT"
25
+ ENV_AGENT = "FOREMAN_AGENT"
26
+ ENV_ACTOR = "FOREMAN_ACTOR"
27
+
28
+ AGENTS_FILE = "agents.json"
29
+ LEDGER_FILE = "ledger.jsonl"
30
+ RUNS_DIR = "runs"
31
+
32
+ #: Built-in execution bodies. Every one of them is a generic CLI agent
33
+ #: entry point — none of them depends on the host harness.
34
+ BUILTIN_AGENTS: dict[str, dict] = {
35
+ "claude": {"command": "claude -p {task}", "description": "Claude Code CLI"},
36
+ "codex": {"command": "codex exec {task}", "description": "Codex CLI"},
37
+ "dsh": {"command": "dsh headless {task}", "description": "DeepSeek Harness, headless"},
38
+ "generic": {"command": "", "description": "custom agent, define it in agents.json"},
39
+ }
40
+
41
+
42
+ @dataclass
43
+ class ForemanConfig:
44
+ """Effective configuration of one foreman-plane instance."""
45
+
46
+ home: Path
47
+ ledger_path: Path
48
+ runs_dir: Path
49
+ agents: dict[str, dict] = field(default_factory=dict)
50
+ timeout_s: int = DEFAULT_TIMEOUT_S
51
+
52
+
53
+ def resolve_home(
54
+ env: Mapping[str, str] | None = None,
55
+ cwd: str | os.PathLike[str] | None = None,
56
+ ) -> Path:
57
+ """Resolve ``FOREMAN_HOME`` following SPEC §3.1."""
58
+ env = os.environ if env is None else env
59
+ raw = (env.get(ENV_HOME) or "").strip()
60
+ if raw:
61
+ return Path(raw).expanduser()
62
+ for var in ("HOME", "USERPROFILE"):
63
+ home = (env.get(var) or "").strip()
64
+ if home:
65
+ return Path(home).expanduser() / ".foreman"
66
+ base = Path(cwd) if cwd else Path.cwd()
67
+ return base / ".foreman"
68
+
69
+
70
+ def load_agents(home: str | os.PathLike[str]) -> dict[str, dict]:
71
+ """Load ``${FOREMAN_HOME}/agents.json`` merged over the built-in agents.
72
+
73
+ The file is optional. A malformed file never raises: the built-in
74
+ agents are returned instead.
75
+ """
76
+ agents: MutableMapping[str, dict] = {name: dict(spec) for name, spec in BUILTIN_AGENTS.items()}
77
+ path = Path(home).expanduser() / AGENTS_FILE
78
+ try:
79
+ if not path.is_file():
80
+ return dict(agents)
81
+ data = json.loads(path.read_text(encoding="utf-8"))
82
+ except (OSError, ValueError):
83
+ return dict(agents)
84
+ if not isinstance(data, dict):
85
+ return dict(agents)
86
+ extra = data.get("agents")
87
+ if isinstance(extra, dict):
88
+ for name, spec in extra.items():
89
+ if not isinstance(spec, dict):
90
+ continue
91
+ merged = dict(agents.get(str(name), {}))
92
+ merged.update(spec)
93
+ agents[str(name)] = merged
94
+ return dict(agents)
95
+
96
+
97
+ def _resolve_timeout(env: Mapping[str, str]) -> int:
98
+ raw = (env.get(ENV_TIMEOUT) or "").strip()
99
+ if raw:
100
+ try:
101
+ value = int(raw)
102
+ except ValueError:
103
+ value = DEFAULT_TIMEOUT_S
104
+ if value > 0:
105
+ return value
106
+ return DEFAULT_TIMEOUT_S
107
+
108
+
109
+ def load_config(
110
+ env: Mapping[str, str] | None = None,
111
+ cwd: str | os.PathLike[str] | None = None,
112
+ ) -> ForemanConfig:
113
+ """Build the effective configuration."""
114
+ env = os.environ if env is None else env
115
+ home = resolve_home(env, cwd)
116
+ return ForemanConfig(
117
+ home=home,
118
+ ledger_path=home / LEDGER_FILE,
119
+ runs_dir=home / RUNS_DIR,
120
+ agents=load_agents(home),
121
+ timeout_s=_resolve_timeout(env),
122
+ )
123
+
124
+
125
+ def resolve_default_agent(env: Mapping[str, str] | None = None) -> str:
126
+ """Name of the execution body used when the caller does not pick one."""
127
+ env = os.environ if env is None else env
128
+ return (env.get(ENV_AGENT) or "").strip() or DEFAULT_AGENT
129
+
130
+
131
+ def resolve_actor(env: Mapping[str, str] | None = None) -> str:
132
+ """Name recorded in the ``actor`` field of every ledger event."""
133
+ env = os.environ if env is None else env
134
+ return (env.get(ENV_ACTOR) or "").strip() or "foreman"
135
+
136
+
137
+ def ensure_dirs(config: ForemanConfig) -> None:
138
+ """Create the home / runs directories, ignoring filesystem failures."""
139
+ for directory in (config.home, config.runs_dir):
140
+ try:
141
+ directory.mkdir(parents=True, exist_ok=True)
142
+ except OSError:
143
+ pass
144
+
145
+
146
+ def describe(config: ForemanConfig, env: Mapping[str, str] | None = None) -> dict:
147
+ """Human/machine readable snapshot of the effective configuration."""
148
+ env = os.environ if env is None else env
149
+ return {
150
+ "home": str(config.home),
151
+ "ledger_path": str(config.ledger_path),
152
+ "runs_dir": str(config.runs_dir),
153
+ "timeout_s": config.timeout_s,
154
+ "default_agent": resolve_default_agent(env),
155
+ "agents": sorted(config.agents),
156
+ }
157
+
158
+
159
+ __all__ = [
160
+ "DEFAULT_TIMEOUT_S",
161
+ "DEFAULT_AGENT",
162
+ "ENV_HOME",
163
+ "ENV_TIMEOUT",
164
+ "ENV_AGENT",
165
+ "ENV_ACTOR",
166
+ "BUILTIN_AGENTS",
167
+ "ForemanConfig",
168
+ "resolve_home",
169
+ "load_agents",
170
+ "load_config",
171
+ "resolve_default_agent",
172
+ "resolve_actor",
173
+ "ensure_dirs",
174
+ "describe",
175
+ ]