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 +13 -0
- foreman/acceptance.py +140 -0
- foreman/cli.py +292 -0
- foreman/config.py +175 -0
- foreman/contract.py +234 -0
- foreman/errors.py +51 -0
- foreman/gate.py +202 -0
- foreman/ledger.py +238 -0
- foreman/mcp_server.py +233 -0
- foreman/runner.py +398 -0
- foreman_plane-0.1.0.dist-info/METADATA +253 -0
- foreman_plane-0.1.0.dist-info/RECORD +16 -0
- foreman_plane-0.1.0.dist-info/WHEEL +5 -0
- foreman_plane-0.1.0.dist-info/entry_points.txt +2 -0
- foreman_plane-0.1.0.dist-info/licenses/LICENSE +21 -0
- foreman_plane-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
]
|