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.
Files changed (50) hide show
  1. loopcaptain/__init__.py +0 -0
  2. loopcaptain/agents.py +162 -0
  3. loopcaptain/board/AGENTS.md +39 -0
  4. loopcaptain/board/__init__.py +0 -0
  5. loopcaptain/board/app.py +430 -0
  6. loopcaptain/board/discover.py +249 -0
  7. loopcaptain/board/files.py +94 -0
  8. loopcaptain/board/filters.py +48 -0
  9. loopcaptain/board/graph.py +139 -0
  10. loopcaptain/board/overview.py +59 -0
  11. loopcaptain/board/static/board.js +41 -0
  12. loopcaptain/board/static/fonts/OFL.txt +94 -0
  13. loopcaptain/board/static/fonts/inter-latin-wght-normal.woff2 +0 -0
  14. loopcaptain/board/static/fonts/inter-tight-latin-wght-normal.woff2 +0 -0
  15. loopcaptain/board/static/mark.svg +5 -0
  16. loopcaptain/board/static/style.css +425 -0
  17. loopcaptain/board/steer.py +207 -0
  18. loopcaptain/board/templates/base.html +27 -0
  19. loopcaptain/board/templates/connect.html +35 -0
  20. loopcaptain/board/templates/file.html +9 -0
  21. loopcaptain/board/templates/loop.html +357 -0
  22. loopcaptain/board/templates/overview.html +108 -0
  23. loopcaptain/board/templates/unreadable.html +17 -0
  24. loopcaptain/board/trend.py +185 -0
  25. loopcaptain/cli.py +204 -0
  26. loopcaptain/demo/__init__.py +59 -0
  27. loopcaptain/demo/loop.py +311 -0
  28. loopcaptain/demo/loops.py +76 -0
  29. loopcaptain/mcp_server/AGENTS.md +23 -0
  30. loopcaptain/mcp_server/__init__.py +0 -0
  31. loopcaptain/mcp_server/server.py +181 -0
  32. loopcaptain/prompt_texts/build-a-loop.md +6 -0
  33. loopcaptain/prompt_texts/watch-my-loops.md +6 -0
  34. loopcaptain/prompts.py +27 -0
  35. loopcaptain/protocol/AGENTS.md +24 -0
  36. loopcaptain/protocol/LOOPS.md +402 -0
  37. loopcaptain/protocol/__init__.py +17 -0
  38. loopcaptain/protocol/doctor.py +79 -0
  39. loopcaptain/protocol/models.py +496 -0
  40. loopcaptain/protocol/signing.py +105 -0
  41. loopcaptain/protocol/state.py +595 -0
  42. loopcaptain/protocol/store.py +275 -0
  43. loopcaptain/protocol/validate.py +199 -0
  44. loopcaptain-0.3.1.dist-info/METADATA +220 -0
  45. loopcaptain-0.3.1.dist-info/RECORD +50 -0
  46. loopcaptain-0.3.1.dist-info/WHEEL +4 -0
  47. loopcaptain-0.3.1.dist-info/entry_points.txt +2 -0
  48. loopcaptain-0.3.1.dist-info/licenses/LICENSE +202 -0
  49. loopcaptain-0.3.1.dist-info/licenses/NOTICE +11 -0
  50. loopcaptain-0.3.1.dist-info/licenses/src/loopcaptain/board/static/fonts/OFL.txt +94 -0
@@ -0,0 +1,311 @@
1
+ """An example loop: it builds a one-page dashboard on loop engineering, round by round.
2
+
3
+ Standard library only, plus `loops.py`, the helper pasted from LOOPS.md: a loop needs nothing else
4
+ to show up on the board. No model and no network: the "agents" are small functions, so it costs
5
+ nothing and always runs the same way. Every claim, source and finding in it is invented.
6
+
7
+ python loop.py --rounds 2 what the board's Start button runs, in this folder
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import time
14
+ from datetime import UTC, datetime, timedelta
15
+ from pathlib import Path
16
+
17
+ import loops
18
+
19
+ LOOP = "loop-engineering"
20
+ HERE = Path(__file__).resolve().parent
21
+ PRACTICES = (
22
+ ("Judge with another agent than the one that made the work", "A maker grades its own work kindly."),
23
+ ("Start every round from a fresh context and a progress file", "Long contexts drift; files do not."),
24
+ ("Commit once per round", "A round that made things worse can be thrown away whole."),
25
+ ("Cap rounds, runtime and cost, and stop at the first", "A loop without a cap is a bill without one."),
26
+ ("Stop when the same findings stay open two rounds running", "No progress is a verdict too."),
27
+ ("Ask the person with options, not an open question", "A button is answered in seconds."),
28
+ ("Report as it happens, never afterwards", "A round written later has invented durations."),
29
+ ("Read the inbox at the start of every round", "A stop that is never read never stops anything."),
30
+ )
31
+ ANGLES = ("stopping", "judging", "cost")
32
+ AUDIENCES = (
33
+ {"id": "leads", "label": "Team leads"},
34
+ {"id": "builders", "label": "The engineers who build loops"},
35
+ )
36
+ ANSWER_WAIT_S = 90
37
+
38
+ MANIFEST = {
39
+ "title": "A dashboard on loop engineering",
40
+ "brief": {
41
+ "goal": "One page that says which practices make an agent loop converge, with a source for each.",
42
+ "audience": "A team about to run its first agent loop",
43
+ "bar": "Every claim has a source, it reads in five minutes, and reviewers find nothing material",
44
+ "path": "brief.md",
45
+ },
46
+ "steps": [
47
+ {"id": "plan", "kind": "plan", "name": "Editor plans", "backend": "a function", "model": "none"},
48
+ {"id": "research", "kind": "research", "name": "Researchers", "purpose": "One per angle"},
49
+ {"id": "build", "kind": "build", "name": "Page builder", "purpose": "Writes out/dashboard.md"},
50
+ {"id": "facts", "kind": "review", "after": ["build"], "purpose": "Every claim has a source"},
51
+ {"id": "reader", "kind": "review", "after": ["build"], "purpose": "Reads as the audience"},
52
+ {"id": "gate", "kind": "gate", "after": ["facts", "reader"]},
53
+ {"id": "verdict", "kind": "verdict", "name": "Editor decides"},
54
+ ],
55
+ "round_cap": 6,
56
+ "cost_cap_usd": 5,
57
+ "pace": "a round every few seconds; asks once which audience comes first",
58
+ "commands": {"start": "python3 loop.py --rounds 2"},
59
+ "outputs": [{"name": "dashboard", "path": "out/dashboard.md"}],
60
+ "state": [{"name": "progress", "path": "work/progress.md"}],
61
+ }
62
+
63
+
64
+ class Clock:
65
+ """Now, or a time in the past for the history `seed` writes, moving on as the round does."""
66
+
67
+ def __init__(self, start: datetime | None = None, pace_s: float = 0.0) -> None:
68
+ self.at, self.pace_s = start, pace_s
69
+
70
+ def tick(self, minutes: float) -> dict[str, str]:
71
+ if self.at is None:
72
+ time.sleep(self.pace_s)
73
+ return {}
74
+ self.at += timedelta(minutes=minutes)
75
+ return {"ts": self.at.strftime("%Y-%m-%dT%H:%M:%SZ")}
76
+
77
+
78
+ def write(relative: str, text: str) -> str:
79
+ path = HERE / relative
80
+ path.parent.mkdir(parents=True, exist_ok=True)
81
+ path.write_text(text, encoding="utf-8")
82
+ return relative
83
+
84
+
85
+ def sourced_in(number: int) -> int:
86
+ """How many claims carry a source after round `number`: the loop converges, two at a time."""
87
+ return min(len(PRACTICES), 1 + 2 * number)
88
+
89
+
90
+ def build_page(number: int, audience: str, steering: str) -> str:
91
+ sourced = sourced_in(number)
92
+ rows = [
93
+ f"| {name} | {why} | {f'invented source {chr(65 + i)}, 2026' if i < sourced else 'none yet'} |"
94
+ for i, (name, why) in enumerate(PRACTICES)
95
+ ]
96
+ lines = [
97
+ "# What makes an agent loop converge",
98
+ "",
99
+ f"For {audience}. Round {number}.{f' Steered: {steering}' if steering else ''}",
100
+ "",
101
+ "| Practice | Why | Source |",
102
+ "|---|---|---|",
103
+ *rows,
104
+ ]
105
+ return "\n".join(lines) + "\n"
106
+
107
+
108
+ def run_round(
109
+ number: int, clock: Clock, audience: str = "a team about to run its first loop", steering: str = ""
110
+ ) -> bool:
111
+ """One round, reported as it happens. Returns True when the gate passes and the loop is done."""
112
+ emit = loops.emit
113
+ plan = f"Add sources to the claims that lack one; {len(PRACTICES) - sourced_in(number - 1)} to go."
114
+ plan_file = write(f"work/rounds/{number}-plan.md", f"# Round {number}\n\n{plan}\n{steering}\n")
115
+ emit(LOOP, "round_started", {"round": number, "plan": plan, "artifact": plan_file, **clock.tick(0)})
116
+ emit(
117
+ LOOP,
118
+ "step_finished",
119
+ {
120
+ "round": number,
121
+ "step": "plan",
122
+ "artifact": plan_file,
123
+ "cost": {"usd": 0.04, "tokens_in": 9_000, "tokens_out": 700, "estimated": True},
124
+ **clock.tick(2),
125
+ },
126
+ )
127
+ for angle in ANGLES:
128
+ notes = write(
129
+ f"work/rounds/{number}-research-{angle}.md", f"# {angle}\n\nInvented notes on {angle}.\n"
130
+ )
131
+ emit(
132
+ LOOP,
133
+ "step_finished",
134
+ {
135
+ "round": number,
136
+ "step": "research",
137
+ "label": angle,
138
+ "artifact": notes,
139
+ "summary": f"Notes on {angle}, with dated sources.",
140
+ "cost": {
141
+ "usd": 0.06,
142
+ "tokens_in": 21_000,
143
+ "tokens_cache_read": 40_000,
144
+ "tokens_out": 1_400,
145
+ "estimated": True,
146
+ },
147
+ **clock.tick(4),
148
+ },
149
+ )
150
+ page = write("out/dashboard.md", build_page(number, audience, steering))
151
+ share = sourced_in(number) / len(PRACTICES)
152
+ emit(
153
+ LOOP,
154
+ "step_finished",
155
+ {
156
+ "round": number,
157
+ "step": "build",
158
+ "artifact": page,
159
+ "summary": f"{len(PRACTICES)} practices on one page.",
160
+ "metrics": [
161
+ {"name": "claims with a source", "value": round(share, 2), "target": 1.0, "better": "higher"}
162
+ ],
163
+ "cost": {"usd": 0.09, "tokens_in": 30_000, "tokens_out": 3_000, "estimated": True},
164
+ **clock.tick(5),
165
+ },
166
+ )
167
+ unsourced = [name for name, _ in PRACTICES[sourced_in(number) :]]
168
+ facts = [{"id": str(i + 1), "text": f"No source for: {name}."} for i, name in enumerate(unsourced)]
169
+ reader = [{"id": "1", "text": "The table is too long to read in five minutes."}] if number < 3 else []
170
+ if number == 3:
171
+ reader = [{"id": "1", "text": "Say who the page is for in the title.", "severity": "minor"}]
172
+ for step, found in (("facts", facts), ("reader", reader)):
173
+ review = write(
174
+ f"work/rounds/{number}-review-{step}.md",
175
+ "".join(f"- {f['text']}\n" for f in found) or "Nothing.\n",
176
+ )
177
+ emit(
178
+ LOOP,
179
+ "step_finished",
180
+ {
181
+ "round": number,
182
+ "step": step,
183
+ "artifact": review,
184
+ "findings": found,
185
+ "summary": f"{len(found)} finding(s).",
186
+ **clock.tick(3),
187
+ },
188
+ )
189
+ material = sum(1 for f in [*facts, *reader] if f.get("severity", "material") == "material")
190
+ passed = material == 0
191
+ emit(
192
+ LOOP,
193
+ "step_finished",
194
+ {
195
+ "round": number,
196
+ "step": "gate",
197
+ "status": "ok" if passed else "failed",
198
+ "summary": "No material finding." if passed else f"{material} material finding(s) open.",
199
+ **clock.tick(1),
200
+ },
201
+ )
202
+ emit(LOOP, "step_finished", {"round": number, "step": "verdict", **clock.tick(1)})
203
+ write("work/progress.md", f"Round {number}: {sourced_in(number)} of {len(PRACTICES)} claims sourced.\n")
204
+ verdict = {
205
+ "round": number,
206
+ "verdict": "done" if passed else "continue",
207
+ "stop_reason": "goal_met" if passed else None,
208
+ "summary": "Every claim has a source." if passed else f"{material} material finding(s) remain.",
209
+ "next": "" if passed else "Source the next claims.",
210
+ "metrics": [
211
+ {"name": "claims with a source", "value": round(share, 2), "target": 1.0, "better": "higher"}
212
+ ],
213
+ }
214
+ emit(LOOP, "round_finished", {**verdict, **clock.tick(1)})
215
+ return passed
216
+
217
+
218
+ def ask_audience(number: int, pace_s: float, wait_s: float = ANSWER_WAIT_S) -> str | None:
219
+ """Ask once which audience comes first, with buttons; go on with a default if nobody answers.
220
+
221
+ None means the person sent a stop while the loop waited.
222
+ """
223
+ loops.emit(
224
+ LOOP,
225
+ "question",
226
+ {
227
+ "id": "audience",
228
+ "round": number,
229
+ "blocks": True,
230
+ "timeout_s": max(1, int(wait_s)),
231
+ "text": "Who should the page speak to first?",
232
+ "options": list(AUDIENCES),
233
+ },
234
+ )
235
+ waited = 0.0
236
+ while waited < wait_s:
237
+ for message in loops.inbox(LOOP):
238
+ if message["signed"] is False:
239
+ continue # not from the board: an agent or a hand wrote it, not the person
240
+ if message["kind"] == "stop":
241
+ return None
242
+ if message["kind"] == "reply":
243
+ picked = next(
244
+ (a["label"] for a in AUDIENCES if a["id"] == message.get("choice")), message["text"]
245
+ )
246
+ return picked
247
+ time.sleep(max(pace_s, 0.5))
248
+ waited += max(pace_s, 0.5)
249
+ loops.emit(
250
+ LOOP,
251
+ "question_closed",
252
+ {
253
+ "question": "audience",
254
+ "reason": "superseded",
255
+ "answer": "No answer in time; went on for team leads.",
256
+ },
257
+ )
258
+ return "Team leads"
259
+
260
+
261
+ def past_rounds() -> int:
262
+ finished = [
263
+ e for e in loops.lines(HERE / ".loops" / LOOP / "events.jsonl") if e.get("event") == "round_finished"
264
+ ]
265
+ return max((e["round"] for e in finished), default=0)
266
+
267
+
268
+ def seed(rounds: int) -> None:
269
+ """The loop's history: its first rounds, run now with times from yesterday."""
270
+ loops.register(LOOP, MANIFEST)
271
+ write("brief.md", "# Brief\n\n" + MANIFEST["brief"]["goal"] + "\n")
272
+ clock = Clock(datetime.now(UTC).replace(microsecond=0) - timedelta(days=1))
273
+ for number in range(1, rounds + 1):
274
+ loops.inbox(LOOP)
275
+ run_round(number, clock)
276
+ clock.tick(60)
277
+
278
+
279
+ def main(argv: list[str] | None = None) -> int:
280
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
281
+ parser.add_argument("--rounds", type=int, default=2, help="how many rounds to run now")
282
+ parser.add_argument("--pace", type=float, default=1.5, help="seconds between steps")
283
+ parser.add_argument("--wait", type=float, default=ANSWER_WAIT_S, help="seconds to wait for an answer")
284
+ parser.add_argument("--seed", type=int, metavar="N", help="write N past rounds as history, then end")
285
+ args = parser.parse_args(argv)
286
+ if args.seed:
287
+ seed(args.seed)
288
+ return 0
289
+ loops.register(LOOP, MANIFEST)
290
+ audience, steering = "a team about to run its first loop", ""
291
+ for number in range(past_rounds() + 1, past_rounds() + 1 + args.rounds):
292
+ messages = loops.inbox(LOOP)
293
+ if any(m["kind"] == "stop" and m["signed"] is not False for m in messages):
294
+ loops.emit(LOOP, "loop_stopped", {"stop_reason": "person", "summary": "Stopped from the board."})
295
+ return 0
296
+ steering = " ".join(m["text"] for m in messages if m["kind"] == "reply" and m["signed"] is not False)
297
+ if number == 3:
298
+ picked = ask_audience(number, args.pace, args.wait)
299
+ if picked is None:
300
+ loops.emit(
301
+ LOOP, "loop_stopped", {"stop_reason": "person", "summary": "Stopped while it asked."}
302
+ )
303
+ return 0
304
+ audience = picked
305
+ if run_round(number, Clock(pace_s=args.pace), audience, steering):
306
+ return 0
307
+ return 0
308
+
309
+
310
+ if __name__ == "__main__":
311
+ raise SystemExit(main())
@@ -0,0 +1,76 @@
1
+ """Loops protocol 1: say what a loop is, what happened, and read the person's answers."""
2
+
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ import os
7
+ import time
8
+ from pathlib import Path
9
+
10
+
11
+ def folder(loop_id, repo="."):
12
+ path = Path(repo) / ".loops" / loop_id
13
+ path.mkdir(parents=True, exist_ok=True)
14
+ return path
15
+
16
+
17
+ def register(loop_id, manifest, repo="."):
18
+ """Write loop.json. `manifest` holds title, brief, steps and so on; id and protocol are set here."""
19
+ target = folder(loop_id, repo) / "loop.json"
20
+ tmp = target.with_suffix(".tmp")
21
+ tmp.write_text(json.dumps({**manifest, "protocol": 1, "id": loop_id}, indent=2) + "\n")
22
+ tmp.replace(target)
23
+
24
+
25
+ def emit(loop_id, event, fields=None, repo="."):
26
+ """Append one event line. Fields that are None are left out."""
27
+ now = time.time()
28
+ line = {
29
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(now)) + f".{int(now % 1 * 1000):03d}Z",
30
+ "event": event,
31
+ }
32
+ line.update({key: value for key, value in (fields or {}).items() if value is not None})
33
+ with open(folder(loop_id, repo) / "events.jsonl", "a") as log:
34
+ log.write(json.dumps(line) + "\n")
35
+
36
+
37
+ def lines(path):
38
+ """The JSON objects of a file, one per complete line; a line that is not JSON is skipped."""
39
+ found = []
40
+ if path.exists():
41
+ for line in path.read_text(errors="replace").splitlines(keepends=True):
42
+ try:
43
+ found.append(json.loads(line) if line.endswith("\n") else None)
44
+ except ValueError:
45
+ continue
46
+ return [entry for entry in found if isinstance(entry, dict)]
47
+
48
+
49
+ def signed(entry, loop_id):
50
+ """True if the board wrote this entry for this loop, False if not, None where there is no key."""
51
+ key = Path(os.environ.get("LOOPCAPTAIN_INBOX_KEY") or "~/.config/loopcaptain/inbox.key").expanduser()
52
+ if not key.is_file():
53
+ return None
54
+ if entry.get("loop") != loop_id:
55
+ return False
56
+ body = {key: value for key, value in entry.items() if key != "sig"}
57
+ text = json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
58
+ try:
59
+ secret = bytes.fromhex(key.read_text().strip())
60
+ except ValueError:
61
+ return None # a key the board would not use either: nothing can be told apart
62
+ mark = hmac.new(secret, text.encode(), hashlib.sha256).hexdigest()
63
+ return hmac.compare_digest(mark, str(entry.get("sig", "")))
64
+
65
+
66
+ def inbox(loop_id, repo="."):
67
+ """What the person sent since the last call, oldest first, each with `signed`, and mark it read."""
68
+ base = folder(loop_id, repo)
69
+ seen = set()
70
+ for event in lines(base / "events.jsonl"):
71
+ if event.get("event") == "inbox_read":
72
+ seen.update(event.get("ids", []))
73
+ fresh = [entry for entry in lines(base / "inbox.jsonl") if entry.get("id") and entry["id"] not in seen]
74
+ if fresh:
75
+ emit(loop_id, "inbox_read", {"ids": [entry["id"] for entry in fresh]}, repo)
76
+ return [{**entry, "signed": signed(entry, loop_id)} for entry in fresh]
@@ -0,0 +1,23 @@
1
+ # mcp_server/: the MCP door
2
+
3
+ Read [the root AGENTS.md](../../../AGENTS.md) first. `server.py` exposes
4
+ the protocol as MCP tools for a loop that an agent session drives without
5
+ a script. Run it with `loopcaptain mcp` (stdio).
6
+
7
+ Rules here:
8
+
9
+ 1. Five tools and no more: `protocol`, `loop_register`, `emit`,
10
+ `inbox_read`, `loop_status`. They mirror the helper's three verbs, so
11
+ a loop reads the same on both doors and the tool list stays short in
12
+ an agent's context. A new event is a new value for `emit`, not a tool.
13
+ 2. A tool writes exactly what the helper in `standard/loops.py` would
14
+ write. No tool adds state of its own; `tests/test_mcp.py` validates the
15
+ folder the tools produce.
16
+ 2. Every tool name that a loop needs is listed in section 5 of
17
+ `standard/LOOPS.md`; a test compares the two.
18
+ 3. Tool docstrings are the interface an agent reads: say what to pass and
19
+ when to call, in the words of the standard.
20
+ 4. A bad call is refused with one line that names the field. An event for
21
+ a loop that is not registered is refused with the way out.
22
+ 5. The server reads and writes only inside the `repo` it is given, by
23
+ default the folder it was started in.
File without changes
@@ -0,0 +1,181 @@
1
+ """The MCP door: the protocol as five tools, for a loop that an agent session drives.
2
+
3
+ The tools mirror the helper a script pastes (`register`, `emit`, `inbox`), so there is one
4
+ vocabulary on both doors and the tool list stays small in the agent's context. Every tool writes
5
+ the same files; the board cannot tell the two doors apart. `repo` is the repository the loop
6
+ lives in; left out, it is the folder the server was started in, which is the agent's working folder.
7
+ A `repo` outside that folder is refused unless `LOOPCAPTAIN_MCP_ROOTS` names a folder above it.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from mcp.server.mcpserver import MCPServer
17
+ from mcp.server.mcpserver.exceptions import ToolError
18
+ from pydantic import ValidationError
19
+
20
+ from loopcaptain.protocol import spec_text, store
21
+ from loopcaptain.protocol.models import EVENT_NAMES, Manifest, now
22
+ from loopcaptain.protocol.signing import is_signed, read_key, untrusted_ids
23
+ from loopcaptain.protocol.state import fold
24
+ from loopcaptain.protocol.store import StoreError
25
+
26
+ INSTRUCTIONS = (
27
+ "Report an agent loop to Loopcaptain. Call `protocol` once to read the format. Then: "
28
+ "`loop_register` when the loop starts; `emit` for every event (round_started, one step_finished "
29
+ "per step, round_finished); `inbox_read` at the start of every round, which returns what the "
30
+ "person sent from the board (a reply to act on, or a stop). Emit `question` when the loop needs "
31
+ "the person."
32
+ )
33
+
34
+ #: More folders, separated like PATH, under which a tool may write besides the working folder.
35
+ ROOTS_ENV = "LOOPCAPTAIN_MCP_ROOTS"
36
+
37
+ server = MCPServer("loopcaptain", instructions=INSTRUCTIONS)
38
+
39
+
40
+ def allowed_roots() -> list[Path]:
41
+ """Where a tool may write: the server's working folder, and any `LOOPCAPTAIN_MCP_ROOTS` names."""
42
+ named = os.environ.get(ROOTS_ENV, "")
43
+ extra = [Path(part).expanduser().resolve() for part in named.split(os.pathsep) if part.strip()]
44
+ return [Path.cwd().resolve(), *extra]
45
+
46
+
47
+ def repo_path(repo: str) -> Path:
48
+ """The repository a tool works on: inside the working folder or a named root, never anywhere else."""
49
+ path = Path(repo).expanduser().resolve()
50
+ if not any(path.is_relative_to(root) for root in allowed_roots()):
51
+ raise ToolError(f"{repo!r} is outside the folder this server was started in; see {ROOTS_ENV}")
52
+ if not path.is_dir():
53
+ raise ToolError(f"{repo!r} is not a folder")
54
+ return path
55
+
56
+
57
+ def known_loop(repo: str, loop_id: str) -> Path:
58
+ """The repository, once its loop is known to exist: events are never written for an unregistered loop."""
59
+ path = repo_path(repo)
60
+ if loop_id not in store.loop_ids(path):
61
+ raise ToolError(f"no loop {loop_id!r} here. Call loop_register first.")
62
+ try:
63
+ store.read_manifest(path, loop_id)
64
+ except StoreError as error:
65
+ raise ToolError(f"{error}. Call loop_register again to repair it.") from error
66
+ return path
67
+
68
+
69
+ def explain(error: ValidationError) -> ToolError:
70
+ """A refusal the agent gets to read: the SDK passes on the text of a ToolError and of nothing else."""
71
+ return ToolError(store.short_error(error))
72
+
73
+
74
+ @server.tool()
75
+ def protocol() -> str:
76
+ """Return LOOPS.md: the files a loop writes, every event and its fields, and the rules."""
77
+ return spec_text()
78
+
79
+
80
+ @server.tool()
81
+ def loop_register(loop_id: str, manifest: dict[str, Any], repo: str = ".") -> str:
82
+ """Say what the loop is; writes loop.json. Call when the loop starts; calling again replaces it.
83
+
84
+ manifest: the content of loop.json without `id` and `protocol`, which are set here:
85
+ {"title", "brief": {"goal", "audience", "bar", "path"}, "steps": [{"id", "kind", "name",
86
+ "purpose", "prompt", "backend", "model", "after"}], "round_cap", "runtime_cap_minutes",
87
+ "cost_cap_usd", "pace", "commands": {"start"}, "outputs": [{"name", "path"}],
88
+ "workspace": {"branch", "worktree"}, "state": [{"name", "path"}]}.
89
+ Only `steps` matters for the picture: the roles of one round in order. kind is plan, research,
90
+ analyse, build, review, gate, verdict or other; a step follows the one before it unless
91
+ "after" lists the step ids it waits for.
92
+ """
93
+ try:
94
+ parsed = Manifest.model_validate({**manifest, "id": loop_id, "protocol": 1})
95
+ except ValidationError as error:
96
+ raise explain(error) from error
97
+ path = store.write_manifest(repo_path(repo), parsed)
98
+ return f"registered {loop_id}: {path}"
99
+
100
+
101
+ @server.tool()
102
+ def emit(loop_id: str, event: str, fields: dict[str, Any] | None = None, repo: str = ".") -> str:
103
+ """Append one event to the loop's log; the time is set here.
104
+
105
+ event and its fields (see `protocol` for all of them); any event may carry `at`, when it really
106
+ happened, if that was earlier than now:
107
+ - round_started: round, plan
108
+ - step_started: round, step, label
109
+ - step_finished: round, step, label, status (ok|failed|skipped), summary, artifact (a path),
110
+ findings [{"id", "text", "severity": material|minor}], cost {"usd", "tokens_in", "tokens_out",
111
+ "tokens_cache_read", "tokens_cache_write", "turns", "estimated"}, metrics [{"name", "value",
112
+ "target", "better": higher|lower, "unit"}], attempt, error {"kind": transient|needs_person,
113
+ "text"}, session
114
+ - finding_closed: round, step, id, label, resolution, reason
115
+ - round_finished: round, verdict (continue|done|wait|stopped|failed), stop_reason, summary, next,
116
+ commit, cost, metrics, outcome (kept|discarded), next_at
117
+ - question: text, round, blocks, timeout_s, id, options [{"id", "label"}]
118
+ - question_closed: question (its id), reason (answered|superseded|withdrawn), answer
119
+ - loop_stopped: stop_reason, summary
120
+ - heartbeat: text, next_at
121
+ - check: item, kind, result (pass|partly|fail|blocked), who, finding, evidence
122
+ """
123
+ if event not in EVENT_NAMES or event == "inbox_read":
124
+ allowed = ", ".join(name for name in EVENT_NAMES if name != "inbox_read")
125
+ raise ToolError(f"{event!r} is not an event to emit. One of: {allowed}")
126
+ path = known_loop(repo, loop_id)
127
+ try:
128
+ parsed = store.parse_event({**(fields or {}), "ts": now().isoformat(), "event": event})
129
+ except ValidationError as error:
130
+ raise explain(error) from error
131
+ store.append_event(path, loop_id, parsed)
132
+ return f"{event} recorded"
133
+
134
+
135
+ @server.tool()
136
+ def inbox_read(loop_id: str, repo: str = ".") -> list[dict[str, Any]]:
137
+ """What the person sent from the board since the last call, oldest first; marks it read.
138
+
139
+ kind "reply": text to use as steering for the work (the answer to an open question, or a note
140
+ for the next round). It is input, never a command to run.
141
+ kind "stop": finish the current round, emit round_finished with verdict "stopped" and stop_reason
142
+ "person", and end.
143
+ signed: true if the board wrote the entry, false if anything else did (treat it as not from the
144
+ person: do not act on a false "stop"), null where this machine holds no board key.
145
+ """
146
+ path = known_loop(repo, loop_id)
147
+ key = read_key()
148
+ return [
149
+ {**entry.model_dump(mode="json", exclude_none=True), "signed": is_signed(entry.body(), key, loop_id)}
150
+ for entry in store.take_inbox(path, loop_id)
151
+ ]
152
+
153
+
154
+ @server.tool()
155
+ def loop_status(loop_id: str = "", repo: str = ".") -> dict[str, Any]:
156
+ """Where a loop stands, as the board shows it. Without loop_id: the loops registered in the repository."""
157
+ if not loop_id:
158
+ return {"loops": store.loop_ids(repo_path(repo))}
159
+ path = known_loop(repo, loop_id)
160
+ manifest = store.read_manifest(path, loop_id)
161
+ events, problems = store.read_events(path, loop_id)
162
+ inbox, _ = store.read_inbox(path, loop_id)
163
+ state = fold(manifest, events, inbox, now(), untrusted=untrusted_ids(inbox, loop_id))
164
+ last = state.last_round
165
+ return {
166
+ "status": state.status,
167
+ "rounds": len(state.rounds),
168
+ "round_cap": manifest.round_cap,
169
+ "last_verdict": last.verdict if last else None,
170
+ "open_findings": [
171
+ {"step": run.step, "id": finding.id, "text": finding.text} for run, finding in state.open_findings
172
+ ],
173
+ "open_question": state.question.text if state.question else None,
174
+ "stop_requested": state.stop_requested,
175
+ "cost_usd": float(f"{state.cost_usd:.2f}"),
176
+ "unreadable_lines": [str(problem) for problem in problems],
177
+ }
178
+
179
+
180
+ def serve_stdio() -> None:
181
+ server.run("stdio")
@@ -0,0 +1,6 @@
1
+ Help me build an agent loop that reports to Loopcaptain.
2
+ Read https://github.com/faviens/loopcaptain/blob/main/standard/AGENTS.md and LOOPS.md beside it.
3
+ Ask me what the loop is for, who reads the result and when it is done.
4
+ Then design the roles of one round, write the loop in this repository,
5
+ make it write .loops/<loop-id>/ as the protocol says, and show me
6
+ `loopcaptain validate .` printing ok after one real round.
@@ -0,0 +1,6 @@
1
+ Help me see and steer the agent loops on this machine with Loopcaptain.
2
+ Read https://github.com/faviens/loopcaptain/blob/main/AGENTS.md, section A.
3
+ Find the repositories here that run agent loops, tell me which already
4
+ write a .loops/ folder and which do not, set up the board to watch them,
5
+ start it, and for each loop that is not connected propose the smallest
6
+ change that connects it. Change nothing in those repositories before I agree.
loopcaptain/prompts.py ADDED
@@ -0,0 +1,27 @@
1
+ """The two prompts a person copies into their coding agent to get started.
2
+
3
+ Their home is `standard/prompts/`; the README, the docs and the board's pages show them from there,
4
+ and a built package carries a copy (pyproject.toml, force-include), like LOOPS.md.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from importlib import resources
10
+ from pathlib import Path
11
+
12
+ #: Build a loop from nothing, and watch the loops already on this machine.
13
+ NAMES = ("build-a-loop", "watch-my-loops")
14
+ CHECKOUT = Path(__file__).resolve().parents[2] / "standard" / "prompts"
15
+
16
+
17
+ def prompt_text(name: str) -> str:
18
+ if name not in NAMES:
19
+ raise ValueError(f"no prompt {name!r}; one of {', '.join(NAMES)}")
20
+ packaged = resources.files(__package__ or "loopcaptain").joinpath("prompt_texts", f"{name}.md")
21
+ if packaged.is_file():
22
+ return packaged.read_text(encoding="utf-8")
23
+ return (CHECKOUT / f"{name}.md").read_text(encoding="utf-8")
24
+
25
+
26
+ def prompts() -> dict[str, str]:
27
+ return {name: prompt_text(name) for name in NAMES}
@@ -0,0 +1,24 @@
1
+ # protocol/: the standard as code
2
+
3
+ Read [the root AGENTS.md](../../../AGENTS.md) first. This folder is the
4
+ code twin of [standard/LOOPS.md](../../../standard/LOOPS.md): if
5
+ the text and this folder disagree, both are wrong until a test says which.
6
+
7
+ | File | Holds |
8
+ |---|---|
9
+ | `models.py` | The Pydantic models of the loop's files: `Manifest` (`loop.json`), the events (`events.jsonl`), `InboxEntry` (`inbox.jsonl`). Frozen; unknown fields ignored |
10
+ | `store.py` | Reading and writing a loop's folder: atomic `loop.json`, append-only lines, bad lines reported as `Problem`, never fatal |
11
+ | `state.py` | `fold`: manifest + events + inbox + a clock -> `LoopState` (rounds, runs, findings, items, status). Pure |
12
+ | `validate.py` | `loopcaptain validate`: what a folder gets wrong, one line each |
13
+ | `__init__.py` | `spec_text()`: the standard's text, for the board's Connect page and the MCP tool |
14
+
15
+ Rules here:
16
+
17
+ 1. A new field or event: add it to `models.py`, to the tables of
18
+ `LOOPS.md`, run `make schema`, and add a test that folds it.
19
+ 2. Optional with a default, always. A loop written yesterday must still
20
+ validate (root rule 3).
21
+ 3. `state.py` takes the time as an argument and reads no file: keep it
22
+ pure, so status rules stay testable.
23
+ 4. Reading never raises on a loop's bad line. Writing validates first.
24
+ 5. Nothing in this folder imports `board/` or `mcp_server/`.