loopcaptain 0.3.1__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.
- loopcaptain/__init__.py +0 -0
- loopcaptain/agents.py +162 -0
- loopcaptain/board/AGENTS.md +39 -0
- loopcaptain/board/__init__.py +0 -0
- loopcaptain/board/app.py +430 -0
- loopcaptain/board/discover.py +249 -0
- loopcaptain/board/files.py +94 -0
- loopcaptain/board/filters.py +48 -0
- loopcaptain/board/graph.py +139 -0
- loopcaptain/board/overview.py +59 -0
- loopcaptain/board/static/board.js +41 -0
- loopcaptain/board/static/fonts/OFL.txt +94 -0
- loopcaptain/board/static/fonts/inter-latin-wght-normal.woff2 +0 -0
- loopcaptain/board/static/fonts/inter-tight-latin-wght-normal.woff2 +0 -0
- loopcaptain/board/static/mark.svg +5 -0
- loopcaptain/board/static/style.css +425 -0
- loopcaptain/board/steer.py +207 -0
- loopcaptain/board/templates/base.html +27 -0
- loopcaptain/board/templates/connect.html +35 -0
- loopcaptain/board/templates/file.html +9 -0
- loopcaptain/board/templates/loop.html +357 -0
- loopcaptain/board/templates/overview.html +108 -0
- loopcaptain/board/templates/unreadable.html +17 -0
- loopcaptain/board/trend.py +185 -0
- loopcaptain/cli.py +204 -0
- loopcaptain/demo/__init__.py +59 -0
- loopcaptain/demo/loop.py +311 -0
- loopcaptain/demo/loops.py +76 -0
- loopcaptain/mcp_server/AGENTS.md +23 -0
- loopcaptain/mcp_server/__init__.py +0 -0
- loopcaptain/mcp_server/server.py +181 -0
- loopcaptain/prompt_texts/build-a-loop.md +6 -0
- loopcaptain/prompt_texts/watch-my-loops.md +6 -0
- loopcaptain/prompts.py +27 -0
- loopcaptain/protocol/AGENTS.md +24 -0
- loopcaptain/protocol/LOOPS.md +402 -0
- loopcaptain/protocol/__init__.py +17 -0
- loopcaptain/protocol/doctor.py +79 -0
- loopcaptain/protocol/models.py +496 -0
- loopcaptain/protocol/signing.py +105 -0
- loopcaptain/protocol/state.py +595 -0
- loopcaptain/protocol/store.py +275 -0
- loopcaptain/protocol/validate.py +199 -0
- loopcaptain-0.3.1.dist-info/METADATA +220 -0
- loopcaptain-0.3.1.dist-info/RECORD +50 -0
- loopcaptain-0.3.1.dist-info/WHEEL +4 -0
- loopcaptain-0.3.1.dist-info/entry_points.txt +2 -0
- loopcaptain-0.3.1.dist-info/licenses/LICENSE +202 -0
- loopcaptain-0.3.1.dist-info/licenses/NOTICE +11 -0
- loopcaptain-0.3.1.dist-info/licenses/src/loopcaptain/board/static/fonts/OFL.txt +94 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Small charts of a loop's rounds, drawn as SVG on the server: is it converging?
|
|
2
|
+
|
|
3
|
+
No script and no chart library: the content policy allows neither, and a line of a dozen points
|
|
4
|
+
needs neither. Each chart is one series over the rounds, with the loop's target where it gave one.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from loopcaptain.protocol.state import LoopState
|
|
12
|
+
|
|
13
|
+
WIDTH = 240
|
|
14
|
+
HEIGHT = 64
|
|
15
|
+
PAD = 6
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Series:
|
|
20
|
+
name: str
|
|
21
|
+
values: tuple[tuple[int, float], ...] # (round number, value), rounds without a value left out
|
|
22
|
+
unit: str = ""
|
|
23
|
+
target: float | None = None
|
|
24
|
+
better: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Dot:
|
|
29
|
+
x: float
|
|
30
|
+
y: float
|
|
31
|
+
round: int
|
|
32
|
+
value: float
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Chart:
|
|
37
|
+
series: Series
|
|
38
|
+
path: str
|
|
39
|
+
dots: tuple[Dot, ...]
|
|
40
|
+
target_y: float | None
|
|
41
|
+
width: int = WIDTH
|
|
42
|
+
height: int = HEIGHT
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def last(self) -> float:
|
|
46
|
+
return self.series.values[-1][1]
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def first(self) -> float:
|
|
50
|
+
return self.series.values[0][1]
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def improving(self) -> bool | None:
|
|
54
|
+
"""Whether the last value is better than the first, where "better" is known."""
|
|
55
|
+
if self.series.better is None or len(self.series.values) < 2 or self.last == self.first:
|
|
56
|
+
return None
|
|
57
|
+
return (self.last < self.first) if self.series.better == "lower" else (self.last > self.first)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def series_of(state: LoopState) -> list[Series]:
|
|
61
|
+
"""What is worth a line: open material findings, cost (or tokens without a price), every metric."""
|
|
62
|
+
finished = [r for r in state.rounds if not r.running]
|
|
63
|
+
found: list[Series] = []
|
|
64
|
+
if any(run.findings for r in finished for run in r.runs):
|
|
65
|
+
found.append(
|
|
66
|
+
Series(
|
|
67
|
+
"open material findings",
|
|
68
|
+
tuple((r.number, float(sum(run.material_open for run in r.runs))) for r in finished),
|
|
69
|
+
better="lower",
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
if any(r.cost_usd for r in finished):
|
|
73
|
+
found.append(Series("cost", tuple((r.number, r.cost_usd) for r in finished), unit="$"))
|
|
74
|
+
elif any(r.tokens for r in finished):
|
|
75
|
+
found.append(Series("tokens", tuple((r.number, float(r.tokens)) for r in finished)))
|
|
76
|
+
names: list[str] = []
|
|
77
|
+
for r in finished:
|
|
78
|
+
names += [metric.name for metric in r.all_metrics if metric.name not in names]
|
|
79
|
+
for name in names:
|
|
80
|
+
points = [(r.number, m) for r in finished for m in r.all_metrics if m.name == name]
|
|
81
|
+
last = points[-1][1]
|
|
82
|
+
found.append(
|
|
83
|
+
Series(name, tuple((n, m.value) for n, m in points), last.unit, last.target, last.better)
|
|
84
|
+
)
|
|
85
|
+
return [series for series in found if series.values]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def chart(series: Series, width: int = WIDTH, height: int = HEIGHT) -> Chart:
|
|
89
|
+
values = [value for _, value in series.values]
|
|
90
|
+
low = min([*values, *([series.target] if series.target is not None else [])])
|
|
91
|
+
high = max([*values, *([series.target] if series.target is not None else [])])
|
|
92
|
+
span = (high - low) or 1.0
|
|
93
|
+
count = len(series.values)
|
|
94
|
+
|
|
95
|
+
def x(index: int) -> float:
|
|
96
|
+
return PAD + (width - 2 * PAD) * (index / (count - 1) if count > 1 else 0.5)
|
|
97
|
+
|
|
98
|
+
def y(value: float) -> float:
|
|
99
|
+
return round(height - PAD - (height - 2 * PAD) * (value - low) / span, 1)
|
|
100
|
+
|
|
101
|
+
dots = tuple(
|
|
102
|
+
Dot(round(x(i), 1), y(value), number, value) for i, (number, value) in enumerate(series.values)
|
|
103
|
+
)
|
|
104
|
+
path = " ".join(f"{'M' if i == 0 else 'L'}{dot.x} {dot.y}" for i, dot in enumerate(dots))
|
|
105
|
+
target_y = y(series.target) if series.target is not None else None
|
|
106
|
+
return Chart(series, path, dots, target_y, width, height)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def charts_of(state: LoopState) -> list[Chart]:
|
|
110
|
+
return [chart(series) for series in series_of(state)]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
#: From this share of a cap on, its bar warns.
|
|
114
|
+
WARN_SHARE = 0.8
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class CapBar:
|
|
119
|
+
name: str
|
|
120
|
+
used: str
|
|
121
|
+
cap: str
|
|
122
|
+
share: float # 0..1, clipped
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def warns(self) -> bool:
|
|
126
|
+
return self.share >= WARN_SHARE
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def caps_of(state: LoopState) -> list[CapBar]:
|
|
130
|
+
"""Rounds, runtime and cost against the loop's own caps, for the caps it declares."""
|
|
131
|
+
manifest = state.manifest
|
|
132
|
+
bars = []
|
|
133
|
+
if manifest.round_cap:
|
|
134
|
+
used = state.rounds[-1].number if state.rounds else 0
|
|
135
|
+
bars.append(CapBar("rounds", str(used), str(manifest.round_cap), min(1.0, used / manifest.round_cap)))
|
|
136
|
+
if manifest.runtime_cap_minutes:
|
|
137
|
+
share = state.minutes / manifest.runtime_cap_minutes
|
|
138
|
+
bars.append(
|
|
139
|
+
CapBar("runtime", f"{state.minutes} min", f"{manifest.runtime_cap_minutes} min", min(1.0, share))
|
|
140
|
+
)
|
|
141
|
+
if manifest.cost_cap_usd:
|
|
142
|
+
share = state.cost_usd / manifest.cost_cap_usd
|
|
143
|
+
bars.append(
|
|
144
|
+
CapBar("cost", f"${state.cost_usd:,.2f}", f"${manifest.cost_cap_usd:,.2f}", min(1.0, share))
|
|
145
|
+
)
|
|
146
|
+
return bars
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
#: How many of a loop's latest rounds the overview draws beside its count.
|
|
150
|
+
TRAIL_ROUNDS = 12
|
|
151
|
+
#: The darkest shade stands for this many open findings or more.
|
|
152
|
+
HEAT_LEVELS = 4
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass(frozen=True)
|
|
156
|
+
class TrailCell:
|
|
157
|
+
"""One round on the overview: `live` while it runs, `clear` with nothing open, else `h1` to `h4`."""
|
|
158
|
+
|
|
159
|
+
number: int
|
|
160
|
+
look: str
|
|
161
|
+
label: str
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def trail_of(state: LoopState) -> list[TrailCell]:
|
|
165
|
+
"""The loop's latest rounds, oldest first, each shaded by the findings it left open."""
|
|
166
|
+
cells = []
|
|
167
|
+
for round_ in state.rounds[-TRAIL_ROUNDS:]:
|
|
168
|
+
left_open = round_.findings_open
|
|
169
|
+
if round_.running:
|
|
170
|
+
look, what = "live", "under way"
|
|
171
|
+
elif left_open == 0:
|
|
172
|
+
look, what = "clear", "nothing open"
|
|
173
|
+
else:
|
|
174
|
+
look = f"h{min(left_open, HEAT_LEVELS)}"
|
|
175
|
+
what = f"{left_open} open finding{'s' if left_open != 1 else ''}"
|
|
176
|
+
cells.append(TrailCell(round_.number, look, f"round {round_.number}: {what}"))
|
|
177
|
+
return cells
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def figure(value: float, unit: str = "") -> str:
|
|
181
|
+
"""A value as a person reads it: whole numbers without decimals, money with two."""
|
|
182
|
+
if unit == "$":
|
|
183
|
+
return f"${value:,.2f}"
|
|
184
|
+
text = f"{value:,.0f}" if float(value).is_integer() else f"{value:,.2f}"
|
|
185
|
+
return f"{text} {unit}".strip()
|
loopcaptain/cli.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""The `loopcaptain` command: serve the board, check a folder, emit an event, run the MCP server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from importlib.metadata import version as package_version
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from pydantic import TypeAdapter, ValidationError
|
|
12
|
+
|
|
13
|
+
from loopcaptain.agents import agent, agents, checkout_of, markdown
|
|
14
|
+
from loopcaptain.board.discover import as_written, edit_config
|
|
15
|
+
from loopcaptain.protocol import spec_text, store
|
|
16
|
+
from loopcaptain.protocol.doctor import examine
|
|
17
|
+
from loopcaptain.protocol.models import EVENT_NAMES, AnyEvent, InboxEntry, Manifest, now
|
|
18
|
+
from loopcaptain.protocol.store import StoreError
|
|
19
|
+
from loopcaptain.protocol.validate import validate_report
|
|
20
|
+
|
|
21
|
+
#: This repository's folder, for the MCP command a checkout prints; an installed package has none.
|
|
22
|
+
CHECKOUT = Path(__file__).resolve().parents[2]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def write_schemas(target: Path) -> list[Path]:
|
|
26
|
+
"""The protocol as JSON Schema, one file per file of a loop's folder."""
|
|
27
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
schemas = {
|
|
29
|
+
"loop.schema.json": Manifest.model_json_schema(),
|
|
30
|
+
"event.schema.json": TypeAdapter(AnyEvent).json_schema(),
|
|
31
|
+
"inbox.schema.json": InboxEntry.model_json_schema(),
|
|
32
|
+
}
|
|
33
|
+
written = []
|
|
34
|
+
for name, schema in schemas.items():
|
|
35
|
+
path = target / name
|
|
36
|
+
path.write_text(json.dumps(schema, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
37
|
+
written.append(path)
|
|
38
|
+
return written
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def emit(repo: Path, loop_id: str, event: str, fields: list[str]) -> None:
|
|
42
|
+
"""Append one event from `key=value` pairs; a value that parses as JSON is taken as JSON."""
|
|
43
|
+
data: dict[str, object] = {"ts": now().isoformat(), "event": event}
|
|
44
|
+
for pair in fields:
|
|
45
|
+
key, sep, raw = pair.partition("=")
|
|
46
|
+
if not sep:
|
|
47
|
+
raise StoreError(f"{pair!r}: expected key=value")
|
|
48
|
+
try:
|
|
49
|
+
data[key] = json.loads(raw)
|
|
50
|
+
except json.JSONDecodeError:
|
|
51
|
+
data[key] = raw
|
|
52
|
+
try:
|
|
53
|
+
parsed = store.parse_event(data)
|
|
54
|
+
except ValidationError as error:
|
|
55
|
+
raise StoreError(store.short_error(error)) from error
|
|
56
|
+
store.read_manifest(repo, loop_id)
|
|
57
|
+
store.append_event(repo, loop_id, parsed)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
61
|
+
parser = argparse.ArgumentParser(prog="loopcaptain", description="One board for every agent loop.")
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--version", action="version", version=f"loopcaptain {package_version('loopcaptain')}"
|
|
64
|
+
)
|
|
65
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
66
|
+
|
|
67
|
+
serve = commands.add_parser("serve", help="the board on 127.0.0.1")
|
|
68
|
+
serve.add_argument("--port", type=int, default=8484, help="the port on 127.0.0.1, default 8484")
|
|
69
|
+
|
|
70
|
+
validate = commands.add_parser("validate", help="check a repository's .loops folders")
|
|
71
|
+
validate.add_argument("repo", type=Path)
|
|
72
|
+
validate.add_argument("--strict", action="store_true", help="count warnings as problems")
|
|
73
|
+
|
|
74
|
+
emit_cmd = commands.add_parser(
|
|
75
|
+
"emit", help="append one event: loopcaptain emit <loop> <event> key=value ..."
|
|
76
|
+
)
|
|
77
|
+
emit_cmd.add_argument("loop")
|
|
78
|
+
emit_cmd.add_argument("event", choices=EVENT_NAMES)
|
|
79
|
+
emit_cmd.add_argument("fields", nargs="*")
|
|
80
|
+
emit_cmd.add_argument("--repo", type=Path, default=Path("."))
|
|
81
|
+
|
|
82
|
+
schema = commands.add_parser("schema", help="write the protocol as JSON Schema")
|
|
83
|
+
schema.add_argument("target", type=Path)
|
|
84
|
+
|
|
85
|
+
doctor = commands.add_parser("doctor", help="send a loop a stop and check that it takes it and ends")
|
|
86
|
+
doctor.add_argument("repo", type=Path)
|
|
87
|
+
doctor.add_argument("loop")
|
|
88
|
+
doctor.add_argument("--timeout", type=float, default=600, help="seconds to wait, default 600")
|
|
89
|
+
|
|
90
|
+
watch = commands.add_parser(
|
|
91
|
+
"watch", help="add a repository, or with --root a folder of them, to the board"
|
|
92
|
+
)
|
|
93
|
+
watch.add_argument("path", type=Path)
|
|
94
|
+
watch.add_argument("--root", action="store_true", help="every sub-folder with .loops/ is a repository")
|
|
95
|
+
unwatch = commands.add_parser("unwatch", help="take a folder off the board")
|
|
96
|
+
unwatch.add_argument("path", type=Path)
|
|
97
|
+
|
|
98
|
+
demo = commands.add_parser(
|
|
99
|
+
"demo", help="write the example loop into a folder and watch it; costs nothing"
|
|
100
|
+
)
|
|
101
|
+
demo.add_argument("--target", type=Path, default=Path("~/.local/share/loopcaptain/demo"))
|
|
102
|
+
demo.add_argument("--no-watch", action="store_true", help="write it, but leave the config alone")
|
|
103
|
+
|
|
104
|
+
mcp_config = commands.add_parser("mcp-config", help="how one coding agent adds the MCP server")
|
|
105
|
+
mcp_config.add_argument("agent", nargs="?", default="", help="claude-code, codex, gemini, cursor, ...")
|
|
106
|
+
mcp_config.add_argument(
|
|
107
|
+
"--checkout", default=checkout_of(CHECKOUT), help="the folder of this repository, if run from one"
|
|
108
|
+
)
|
|
109
|
+
mcp_config.add_argument("--markdown", action="store_true", help="print the page for every agent")
|
|
110
|
+
|
|
111
|
+
commands.add_parser("spec", help="print LOOPS.md, the protocol to paste into a loop")
|
|
112
|
+
commands.add_parser("mcp", help="run the MCP server on stdio")
|
|
113
|
+
return parser
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def main(argv: list[str] | None = None) -> int:
|
|
117
|
+
args = build_parser().parse_args(argv)
|
|
118
|
+
try:
|
|
119
|
+
return run(args)
|
|
120
|
+
except StoreError as error:
|
|
121
|
+
sys.stderr.write(f"loopcaptain: {error}\n")
|
|
122
|
+
return 1
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def run(args: argparse.Namespace) -> int:
|
|
126
|
+
if args.command == "validate":
|
|
127
|
+
report = validate_report(args.repo.expanduser().resolve())
|
|
128
|
+
problems = report.problems + (report.warnings if args.strict else [])
|
|
129
|
+
warnings = [] if args.strict else report.warnings
|
|
130
|
+
for line in problems:
|
|
131
|
+
sys.stdout.write(f"problem: {line}\n")
|
|
132
|
+
for line in warnings:
|
|
133
|
+
sys.stdout.write(f"warning: {line}\n")
|
|
134
|
+
if problems:
|
|
135
|
+
sys.stdout.write(f"{len(problems)} problem(s), {len(warnings)} warning(s)\n")
|
|
136
|
+
else:
|
|
137
|
+
sys.stdout.write(f"ok, {len(warnings)} warning(s)\n" if warnings else "ok\n")
|
|
138
|
+
return 1 if problems else 0
|
|
139
|
+
if args.command == "emit":
|
|
140
|
+
emit(args.repo.expanduser().resolve(), args.loop, args.event, args.fields)
|
|
141
|
+
return 0
|
|
142
|
+
if args.command == "schema":
|
|
143
|
+
for path in write_schemas(args.target):
|
|
144
|
+
sys.stdout.write(f"{path}\n")
|
|
145
|
+
return 0
|
|
146
|
+
if args.command == "doctor":
|
|
147
|
+
sys.stdout.write("waiting for the loop to take a stop; start it now if it is not running\n")
|
|
148
|
+
checkup = examine(args.repo.expanduser().resolve(), args.loop, args.timeout)
|
|
149
|
+
sys.stdout.write("".join(f"{line}\n" for line in checkup.lines()))
|
|
150
|
+
return 0 if checkup.passed else 1
|
|
151
|
+
if args.command in ("watch", "unwatch"):
|
|
152
|
+
entry = as_written(args.path)
|
|
153
|
+
if args.command == "watch":
|
|
154
|
+
if not args.path.expanduser().is_dir():
|
|
155
|
+
raise StoreError(f"{args.path} is not a folder")
|
|
156
|
+
changes = edit_config(args.path, add=("roots" if args.root else "repos", entry))
|
|
157
|
+
else:
|
|
158
|
+
changes = edit_config(args.path, remove=entry)
|
|
159
|
+
sys.stdout.write("".join(f"{line}\n" for line in changes))
|
|
160
|
+
return 0
|
|
161
|
+
if args.command == "demo":
|
|
162
|
+
from loopcaptain.demo import DemoError, write_demo
|
|
163
|
+
|
|
164
|
+
target = args.target.expanduser()
|
|
165
|
+
try:
|
|
166
|
+
write_demo(target)
|
|
167
|
+
except DemoError as error:
|
|
168
|
+
raise StoreError(str(error)) from error
|
|
169
|
+
sys.stdout.write(f"the example loop is in {target}\n")
|
|
170
|
+
if not args.no_watch:
|
|
171
|
+
sys.stdout.write(
|
|
172
|
+
"".join(f"{line}\n" for line in edit_config(target, add=("repos", as_written(target))))
|
|
173
|
+
)
|
|
174
|
+
sys.stdout.write("start the board with `loopcaptain serve` and open http://127.0.0.1:8484\n")
|
|
175
|
+
return 0
|
|
176
|
+
if args.command == "mcp-config":
|
|
177
|
+
if args.markdown:
|
|
178
|
+
sys.stdout.write(markdown())
|
|
179
|
+
return 0
|
|
180
|
+
if not args.agent:
|
|
181
|
+
sys.stdout.write("".join(f"{item.key:12} {item.name}\n" for item in agents(args.checkout)))
|
|
182
|
+
return 0
|
|
183
|
+
try:
|
|
184
|
+
item = agent(args.agent, args.checkout)
|
|
185
|
+
except KeyError as error:
|
|
186
|
+
raise StoreError(str(error.args[0])) from error
|
|
187
|
+
sys.stdout.write(f"{item.name}\n\n{item.add}\n\nunattended: {item.headless}\n")
|
|
188
|
+
return 0
|
|
189
|
+
if args.command == "spec":
|
|
190
|
+
sys.stdout.write(spec_text())
|
|
191
|
+
return 0
|
|
192
|
+
if args.command == "mcp":
|
|
193
|
+
from loopcaptain.mcp_server.server import serve_stdio
|
|
194
|
+
|
|
195
|
+
serve_stdio()
|
|
196
|
+
return 0
|
|
197
|
+
from loopcaptain.board.app import serve
|
|
198
|
+
|
|
199
|
+
serve(args.port)
|
|
200
|
+
return 0
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
if __name__ == "__main__":
|
|
204
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""`loopcaptain demo`: the example loop, written into a folder of its own with its first rounds.
|
|
2
|
+
|
|
3
|
+
The folder gets `loop.py`, the example loop, and `loops.py`, the helper as LOOPS.md prints it: the
|
|
4
|
+
same two files any loop needs. Its history is made by running the loop itself with past times.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from importlib import resources
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
HERE = Path(__file__).resolve().parent
|
|
16
|
+
CHECKOUT_HELPER = HERE.parents[2] / "standard" / "loops.py"
|
|
17
|
+
SEEDED_ROUNDS = 2
|
|
18
|
+
SEED_TIMEOUT_S = 60
|
|
19
|
+
#: What the demo writes into its folder; only these are replaced on a second run.
|
|
20
|
+
WRITTEN = (".loops", "work", "out")
|
|
21
|
+
#: The file that marks a folder as the demo's own: only such a folder is ever cleared.
|
|
22
|
+
MARKER = ".loopcaptain-demo"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DemoError(Exception):
|
|
26
|
+
"""The target is not a folder the demo may write into."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def helper_source() -> str:
|
|
30
|
+
"""The helper: packaged beside this file in a build (force-include), in standard/ in a checkout."""
|
|
31
|
+
packaged = resources.files(__name__).joinpath("loops.py")
|
|
32
|
+
if packaged.is_file():
|
|
33
|
+
return packaged.read_text(encoding="utf-8")
|
|
34
|
+
return CHECKOUT_HELPER.read_text(encoding="utf-8")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def write_demo(target: Path) -> Path:
|
|
38
|
+
"""Write the example loop into `target`, replacing what an earlier demo wrote there, and seed it.
|
|
39
|
+
|
|
40
|
+
A folder that holds anything and was not made by the demo is refused: it may be a real repository.
|
|
41
|
+
"""
|
|
42
|
+
ours = (target / MARKER).is_file()
|
|
43
|
+
if target.exists() and any(target.iterdir()) and not ours:
|
|
44
|
+
raise DemoError(f"{target} is not empty and was not made by the demo; pick a new folder")
|
|
45
|
+
if ours:
|
|
46
|
+
for name in WRITTEN:
|
|
47
|
+
if (target / name).is_dir() and not (target / name).is_symlink():
|
|
48
|
+
shutil.rmtree(target / name)
|
|
49
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
(target / MARKER).write_text("Written by `loopcaptain demo`; it may clear .loops, work and out here.\n")
|
|
51
|
+
(target / "loops.py").write_text(helper_source(), encoding="utf-8")
|
|
52
|
+
(target / "loop.py").write_text((HERE / "loop.py").read_text(encoding="utf-8"), encoding="utf-8")
|
|
53
|
+
subprocess.run(
|
|
54
|
+
[sys.executable, "loop.py", "--seed", str(SEEDED_ROUNDS)],
|
|
55
|
+
cwd=target,
|
|
56
|
+
check=True,
|
|
57
|
+
timeout=SEED_TIMEOUT_S,
|
|
58
|
+
)
|
|
59
|
+
return target
|