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,402 @@
1
+ # Loops protocol, version 1
2
+
3
+ How an agent loop says what it is and what happens in it, so that a board
4
+ can show it and a person can steer it. This file is the whole interface:
5
+ paste it into the repository of a loop, or hand it to the agent that
6
+ writes the loop. Nothing else is needed.
7
+
8
+ A loop keeps one small folder in its own repository. It says once what it
9
+ is, then appends one line for each thing that happens. A board reads that
10
+ folder and writes the person's answers into it. No server, no dependency, no
11
+ network: a loop in any language that can write a file can do this. The
12
+ protocol names no tool, vendor or model.
13
+
14
+ ```
15
+ <repository>/.loops/<loop-id>/
16
+ loop.json what the loop is written by the loop
17
+ events.jsonl what happened, line by line appended by the loop
18
+ inbox.jsonl what the person sends appended by the board
19
+ ```
20
+
21
+ `<loop-id>` is lowercase letters, digits, `.`, `-`, `_`. A repository may
22
+ hold several loops, one folder each. A run that counts its rounds from 1
23
+ again (a new branch, a fresh start) is a loop of its own, with its own id
24
+ such as `review-run-3`, so the rounds of two runs never mix. Whether `.loops/` is committed or
25
+ ignored is the repository's choice.
26
+
27
+ ## 1. `loop.json`: what the loop is
28
+
29
+ Written when the loop starts; writing it again replaces it.
30
+
31
+ ```json
32
+ {
33
+ "protocol": 1,
34
+ "id": "market-scan",
35
+ "title": "Market scan of field service tools",
36
+ "brief": {
37
+ "goal": "Which three tools should the client try first?",
38
+ "audience": "The head of operations",
39
+ "bar": "She can name the three and say why",
40
+ "path": "brief/brief.md"
41
+ },
42
+ "steps": [
43
+ {"id": "plan", "kind": "plan", "name": "Editor plans", "backend": "agent-cli", "model": "large"},
44
+ {"id": "research", "kind": "research", "purpose": "One agent per angle", "backend": "another-agent-cli"},
45
+ {"id": "build", "kind": "build", "prompt": "prompts/build.md"},
46
+ {"id": "facts", "kind": "review", "after": ["build"], "purpose": "Every number has a source"},
47
+ {"id": "reader", "kind": "review", "after": ["build"], "purpose": "Reads as the audience"},
48
+ {"id": "gate", "kind": "gate", "after": ["facts", "reader"]},
49
+ {"id": "verdict", "kind": "verdict"}
50
+ ],
51
+ "round_cap": 12,
52
+ "cost_cap_usd": 60,
53
+ "pace": "stops when it needs the person",
54
+ "commands": {"start": "make loop ROUNDS=3"},
55
+ "outputs": [{"name": "memo", "path": "out/memo.md"}],
56
+ "workspace": {"branch": "loop/market-scan", "worktree": "../market-scan-loop"},
57
+ "state": [{"name": "progress", "path": "work/progress.md"}]
58
+ }
59
+ ```
60
+
61
+ Only `protocol` and `id` are required. The rest:
62
+
63
+ 1. `brief`: the goal, the audience and the bar for done in a line each, and
64
+ the path of the full brief.
65
+ 2. `steps`: the roles of one round, in order. This is the graph the board
66
+ draws. `kind` is one of `plan`, `research`, `analyse`, `build`, `review`,
67
+ `gate`, `verdict`, `other`. A step follows the one listed before it;
68
+ give `after` only where that is not so (two reviewers that both follow
69
+ `build`, a gate that waits for both). `name`, `purpose`, `prompt` (a
70
+ path), `backend` and `model` are shown as given. `backend` is whatever
71
+ runs the step: the name of an agent's command line, a script, a
72
+ person. The protocol knows no vendor.
73
+ 3. `round_cap`, `runtime_cap_minutes`, `cost_cap_usd`: the loop's own
74
+ limits. It stops at whichever comes first; the board shows them.
75
+ 4. `commands.start`: the one command the board may run, in the repository
76
+ root, when the person presses Start. Leave it out and the board has no
77
+ Start button for this loop.
78
+ 5. `outputs`: what the loop makes, by path.
79
+ 6. `workspace`: the branch and worktree the loop runs in, and `state`:
80
+ the files one round hands to the next (a progress note, a feature
81
+ list). Both are shown as text to copy, so a person can pick the loop up
82
+ by hand; the board runs neither.
83
+
84
+ Every path in the protocol is relative to the repository root.
85
+
86
+ ## 2. `events.jsonl`: what happened
87
+
88
+ One JSON object per line, appended, never rewritten. Every line has `ts`
89
+ (UTC, `2026-11-14T21:03:00Z`, fractions of a second allowed:
90
+ `2026-11-14T21:03:00.250Z`), the time the line was written, and
91
+ `event`. Any line may carry `at`: the time the thing really happened, for
92
+ a loop that reports later than it works. Durations are measured on `at`
93
+ where it is given, so a round written after the fact keeps its real
94
+ times.
95
+
96
+ | `event` | Fields | When |
97
+ |---|---|---|
98
+ | `round_started` | `round`, `plan`, `artifact` | A round begins. `plan` is a few lines. |
99
+ | `step_started` | `round`, `step`, `label` | Optional: shows the step as running. |
100
+ | `step_finished` | `round`, `step`, `label`, `status`, `summary`, `artifact`, `findings`, `cost`, `metrics`, `attempt`, `error`, `session`, `model` | A step ends. |
101
+ | `finding_closed` | `round`, `step`, `id`, `label`, `resolution`, `reason` | A finding of this round is settled. |
102
+ | `round_finished` | `round`, `verdict`, `stop_reason`, `summary`, `next`, `commit`, `artifact`, `cost`, `metrics`, `outcome`, `next_at` | A round ends. |
103
+ | `question` | `text`, `round`, `blocks`, `timeout_s`, `id`, `options` | The loop needs the person. |
104
+ | `question_closed` | `question`, `reason`, `answer` | The loop ends its own question. |
105
+ | `loop_stopped` | `stop_reason`, `summary` | The loop ends between rounds. |
106
+ | `heartbeat` | `text`, `next_at` | Alive; when to expect the next line. |
107
+ | `check` | `item`, `kind`, `result`, `who`, `finding`, `evidence`, `round` | One check of one thing the loop evaluates. |
108
+ | `inbox_read` | `ids` | The loop has taken these inbox entries. |
109
+
110
+ 1. `round` counts from 1. `step` is a step id from `loop.json`. A
111
+ `round_started` for a round that already ran starts that round again,
112
+ clean.
113
+ 2. `label` tells runs of one step apart within a round: a research step
114
+ with eight agents sends eight `step_finished`, each with its angle as
115
+ `label`.
116
+ 3. `status`: `ok`, `failed` or `skipped`. Left out, it is `ok`.
117
+ 4. `summary`: one or two sentences. `artifact`: the path of the file the
118
+ step wrote (the plan, the review, the gate report). The board links to it
119
+ and never copies it.
120
+ 5. `findings`: what a reviewer or a gate found, each
121
+ `{"id": "1", "text": "...", "severity": "material"}`; `severity` is
122
+ `material` or `minor`. Reviews are fresh each round: what is open is what
123
+ the last round's reviews found and nobody closed. A step that reports
124
+ again in the same round replaces what it reported before. In
125
+ `finding_closed`, `label` names the run of a fan-out step; left out,
126
+ the finding is closed in every run of the step. `resolution` says how
127
+ it was settled: `fixed`, `accepted` (it stands, on purpose),
128
+ `wontfix`, `false_positive` or `duplicate`; `reason` is a sentence.
129
+ 6. `cost`: `{"usd": 0.34, "tokens_in": 162113, "tokens_out": 4784}`, any
130
+ part of it, and `tokens_cache_read`, `tokens_cache_write`, `turns`, and
131
+ `estimated: true` where the figures are the loop's guess. `usd` is
132
+ optional: an agent on a subscription reports none, and the board then
133
+ shows tokens. A `cost` on `round_finished` is the round's whole cost,
134
+ for a loop that knows only the sum; the steps' costs are then not added.
135
+ 7. `verdict`: what happens next. `continue` (another round follows),
136
+ `done` (the goal is met), `wait` (the loop needs the person before it
137
+ goes on), `stopped` (it ended without meeting the goal) or `failed`
138
+ (the round broke). `stop_reason` says why where the verdict does not:
139
+ `goal_met`, `round_cap`, `cost_cap`, `runtime_cap`, `person`,
140
+ `no_progress`, `error` or `impossible` (the goal cannot be met as
141
+ asked). `next` is the brief for the following round, `commit` the
142
+ commit of this one. `outcome` is `kept` or `discarded`: a round whose
143
+ work was thrown away, an experiment that made things worse. `next_at`
144
+ is when the next round starts, for a loop that sleeps between rounds:
145
+ the board shows it as sleeping, not stalled, until then, and at most a
146
+ week ahead. A new round spends the promise.
147
+ 8. `question`: `blocks` is `true` when the loop stands still until the
148
+ answer comes, `timeout_s` how long it waits before it goes on without
149
+ one. Left out, the loop goes on and reads the answer at its next round.
150
+ A question is open until the person replies. A blocking question is
151
+ also over once its `timeout_s` has passed or the loop starts another
152
+ round: it went on. `id` names the question; `options`,
153
+ `[{"id": "a", "label": "Forty people"}]`, become buttons, and the reply
154
+ carries `question` and `choice`. `question_closed` ends a question the
155
+ loop settled itself: `reason` is `answered` (elsewhere, `answer` says
156
+ how), `superseded` (it went on with a default) or `withdrawn`;
157
+ `question` is the id, left out the last open question.
158
+ 9. `check`: for loops that evaluate a list of things (use cases, skills,
159
+ features). `kind` is free text such as `desk research`, `own trial`,
160
+ `workshop`, `automated test`; `result` is `pass`, `partly`, `fail` or
161
+ `blocked`. A kind nobody ran for an item shows as an empty cell.
162
+ 10. `metrics`: the numbers a loop optimises, on a step or a round,
163
+ `[{"name": "pass rate", "value": 0.82, "target": 0.95, "better":
164
+ "higher", "unit": ""}]`. The board draws each per round.
165
+ 11. `attempt` counts tries of a step within a round from 1; `error`,
166
+ `{"kind": "transient" | "needs_person", "text": "..."}`, says why it
167
+ did not succeed. `needs_person` waits for the person like a blocking
168
+ question, until they reply. `session` is the agent session the step
169
+ ran in, shown as text so a person can resume it by hand. `model` is
170
+ the model the step really ran on, where it is not the one `loop.json`
171
+ names for the step: a fallback after the first one refused.
172
+ 12. `loop_stopped`: the loop ended between rounds, say after a stop that
173
+ came when no round was open. The last round's verdict stays as it was.
174
+ 13. `heartbeat`: a loop that works quietly or sleeps says it is alive and,
175
+ with `next_at`, when its next line comes. Until then it is not
176
+ stalled.
177
+
178
+ A round of the example above, as lines:
179
+
180
+ ```json
181
+ {"ts": "2026-11-14T21:00:00Z", "event": "round_started", "round": 3, "plan": "Close the two fact findings; add pricing."}
182
+ {"ts": "2026-11-14T21:04:10Z", "event": "step_finished", "round": 3, "step": "plan", "artifact": "work/rounds/3-plan.md", "cost": {"usd": 0.31}}
183
+ {"ts": "2026-11-14T21:09:40Z", "event": "step_finished", "round": 3, "step": "research", "label": "pricing", "summary": "List prices for five of six tools."}
184
+ {"ts": "2026-11-14T21:15:02Z", "event": "step_finished", "round": 3, "step": "facts", "artifact": "work/rounds/3-review-facts.md", "findings": [{"id": "1", "text": "The 40 percent figure has no source."}]}
185
+ {"ts": "2026-11-14T21:16:30Z", "event": "question", "round": 3, "text": "Is the budget per seat or per site?"}
186
+ {"ts": "2026-11-14T21:17:00Z", "event": "round_finished", "round": 3, "verdict": "continue", "summary": "Pricing is in; one fact is open.", "commit": "3fa3487"}
187
+ ```
188
+
189
+ ## 3. `inbox.jsonl`: what the person sends
190
+
191
+ The board appends; the loop only reads. Read it at the start of every round
192
+ and wherever the loop waits for the person.
193
+
194
+ ```json
195
+ {"id": "20261114T211900-a3f1", "ts": "2026-11-14T21:19:00Z", "kind": "reply", "text": "Per seat.", "round": 3, "sig": "9c1e..."}
196
+ {"id": "20261114T213000-09bc", "ts": "2026-11-14T21:30:00Z", "kind": "stop"}
197
+ ```
198
+
199
+ 1. `reply`: text for the loop. It answers the open question if there is
200
+ one; without one it is steering for the next round. A reply to a
201
+ question with an `id` carries `question`, and `choice`, the id of the
202
+ option the person picked, where the question offered options.
203
+ 2. `stop`: end. A stop read during a round: finish the round and send
204
+ `round_finished` with its real verdict, `done` if the goal was met,
205
+ else `stopped` with `stop_reason` `person`. A stop read between rounds:
206
+ send `loop_stopped` and start no round.
207
+
208
+ After acting on entries, append `inbox_read` with their ids so the board
209
+ shows them as read.
210
+
211
+ Every line the board writes carries `sig`, an HMAC-SHA256 in hex over the
212
+ line without `sig` (keys sorted, no spaces, as `json.dumps(...,
213
+ sort_keys=True, separators=(",", ":"), ensure_ascii=False)` writes it).
214
+ The line names its loop in `loop`, signed with the rest, so a line copied
215
+ into another loop's inbox does not verify there. The key is in the
216
+ board's config folder, `~/.config/loopcaptain/inbox.key`
217
+ or the file `LOOPCAPTAIN_INBOX_KEY` names, outside every repository. The
218
+ helper and the MCP tool add `signed` to each entry: `true` if the board
219
+ wrote it, `false` if anything else did, `null` where the machine has no
220
+ key. A loop whose agents can write files SHOULD NOT act on an entry whose
221
+ `signed` is `false`: it is not from the person.
222
+
223
+ ## 4. The helper to paste
224
+
225
+ Standard library only. Copy it into the loop as `loops.py`; the same
226
+ text is the file `loops.py` beside this one.
227
+
228
+ ```python
229
+ """Loops protocol 1: say what a loop is, what happened, and read the person's answers."""
230
+
231
+ import hashlib
232
+ import hmac
233
+ import json
234
+ import os
235
+ import time
236
+ from pathlib import Path
237
+
238
+
239
+ def folder(loop_id, repo="."):
240
+ path = Path(repo) / ".loops" / loop_id
241
+ path.mkdir(parents=True, exist_ok=True)
242
+ return path
243
+
244
+
245
+ def register(loop_id, manifest, repo="."):
246
+ """Write loop.json. `manifest` holds title, brief, steps and so on; id and protocol are set here."""
247
+ target = folder(loop_id, repo) / "loop.json"
248
+ tmp = target.with_suffix(".tmp")
249
+ tmp.write_text(json.dumps({**manifest, "protocol": 1, "id": loop_id}, indent=2) + "\n")
250
+ tmp.replace(target)
251
+
252
+
253
+ def emit(loop_id, event, fields=None, repo="."):
254
+ """Append one event line. Fields that are None are left out."""
255
+ now = time.time()
256
+ line = {
257
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(now)) + f".{int(now % 1 * 1000):03d}Z",
258
+ "event": event,
259
+ }
260
+ line.update({key: value for key, value in (fields or {}).items() if value is not None})
261
+ with open(folder(loop_id, repo) / "events.jsonl", "a") as log:
262
+ log.write(json.dumps(line) + "\n")
263
+
264
+
265
+ def lines(path):
266
+ """The JSON objects of a file, one per complete line; a line that is not JSON is skipped."""
267
+ found = []
268
+ if path.exists():
269
+ for line in path.read_text(errors="replace").splitlines(keepends=True):
270
+ try:
271
+ found.append(json.loads(line) if line.endswith("\n") else None)
272
+ except ValueError:
273
+ continue
274
+ return [entry for entry in found if isinstance(entry, dict)]
275
+
276
+
277
+ def signed(entry, loop_id):
278
+ """True if the board wrote this entry for this loop, False if not, None where there is no key."""
279
+ key = Path(os.environ.get("LOOPCAPTAIN_INBOX_KEY") or "~/.config/loopcaptain/inbox.key").expanduser()
280
+ if not key.is_file():
281
+ return None
282
+ if entry.get("loop") != loop_id:
283
+ return False
284
+ body = {key: value for key, value in entry.items() if key != "sig"}
285
+ text = json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
286
+ try:
287
+ secret = bytes.fromhex(key.read_text().strip())
288
+ except ValueError:
289
+ return None # a key the board would not use either: nothing can be told apart
290
+ mark = hmac.new(secret, text.encode(), hashlib.sha256).hexdigest()
291
+ return hmac.compare_digest(mark, str(entry.get("sig", "")))
292
+
293
+
294
+ def inbox(loop_id, repo="."):
295
+ """What the person sent since the last call, oldest first, each with `signed`, and mark it read."""
296
+ base = folder(loop_id, repo)
297
+ seen = set()
298
+ for event in lines(base / "events.jsonl"):
299
+ if event.get("event") == "inbox_read":
300
+ seen.update(event.get("ids", []))
301
+ fresh = [entry for entry in lines(base / "inbox.jsonl") if entry.get("id") and entry["id"] not in seen]
302
+ if fresh:
303
+ emit(loop_id, "inbox_read", {"ids": [entry["id"] for entry in fresh]}, repo)
304
+ return [{**entry, "signed": signed(entry, loop_id)} for entry in fresh]
305
+ ```
306
+
307
+ A loop then needs about one call per step:
308
+
309
+ ```python
310
+ import loops as board
311
+
312
+ board.register("market-scan", {"title": "Market scan", "steps": STEPS, "commands": {"start": "make loop"}})
313
+ for n in range(first_round, last_round + 1):
314
+ messages = board.inbox("market-scan")
315
+ if any(m["kind"] == "stop" and m["signed"] is not False for m in messages):
316
+ board.emit("market-scan", "loop_stopped", {"stop_reason": "person"})
317
+ break
318
+ board.emit("market-scan", "round_started", {"round": n, "plan": plan_text})
319
+ for step in STEPS:
320
+ result = run(step) # the loop's own work
321
+ board.emit(
322
+ "market-scan",
323
+ "step_finished",
324
+ {
325
+ "round": n,
326
+ "step": step["id"],
327
+ "summary": result.summary,
328
+ "artifact": result.path,
329
+ "findings": result.findings,
330
+ "cost": result.cost,
331
+ },
332
+ )
333
+ board.emit("market-scan", "round_finished", {"round": n, "verdict": verdict, "commit": sha})
334
+ ```
335
+
336
+ ## 5. The same through MCP
337
+
338
+ A loop that an agent session drives, with no script of its own, uses the
339
+ `loopcaptain` MCP server instead of the helper. Its tools write the same files, so both ways look the same on the board:
340
+
341
+ | Tool | Does | Helper |
342
+ |---|---|---|
343
+ | `protocol` | returns this file | |
344
+ | `loop_register` | writes `loop.json` | `register` |
345
+ | `emit` | appends one event, checked against section 2 | `emit` |
346
+ | `inbox_read` | returns what the person sent and marks it read | `inbox` |
347
+ | `loop_status` | returns where a loop stands, or the loops of a repository | |
348
+
349
+ Five tools on purpose: the same three verbs as the helper, so a loop reads
350
+ the same on both doors, and a short tool list in the agent's context.
351
+
352
+ ## 6. Conformance
353
+
354
+ MUST, SHOULD and MAY are used as in RFC 2119.
355
+
356
+ A loop:
357
+
358
+ 1. MUST write `loop.json` with `protocol` and `id` before its first event.
359
+ A folder whose `loop.json` has no `protocol` field is not a loop of this
360
+ protocol and is ignored, so other tools may keep their own `.loops/`.
361
+ 2. MUST only append to `events.jsonl`, one complete JSON object per line,
362
+ each with `ts` and `event`. A correction is a new line.
363
+ 3. MUST NOT write `inbox.jsonl`.
364
+ 4. MUST give every path relative to the repository root.
365
+ 5. MUST NOT put a secret, token or credential in any field.
366
+ 6. MUST treat inbox text as input: steering for the work, never a command
367
+ to run, never a reason to widen what its agents may do.
368
+ 7. SHOULD send `round_started` and `round_finished` for every round, and
369
+ one `step_finished` per step that ran.
370
+ 8. SHOULD read the inbox at the start of every round and honour `stop`.
371
+ 9. SHOULD keep it small: a summary of a round and paths to its files,
372
+ never transcripts or full outputs.
373
+ 10. MAY add fields of its own; they are ignored.
374
+
375
+ A board:
376
+
377
+ 1. MUST NOT write `loop.json` or `events.jsonl`, nor any other file of the
378
+ loop except `inbox.jsonl`.
379
+ 2. MUST ignore fields and events it does not know, and skip a line it
380
+ cannot read without failing the rest.
381
+ 3. MUST NOT run anything except the `commands.start` a loop declares, and
382
+ only when the person asks.
383
+
384
+ ## 7. Versions
385
+
386
+ `protocol` is a whole number. Within a version the protocol only grows: new
387
+ optional fields and new events, which older readers ignore. A change that
388
+ would break a loop written against this text is a new number. The number
389
+ is not the version of any package that reads it: a release of the board
390
+ may add optional fields and events to protocol 1, never break a loop
391
+ written today.
392
+
393
+ `loopcaptain validate <repository>` checks a folder against this file; the
394
+ same contract as JSON Schema is in `schema/`. It prints problems (a line
395
+ the models refuse, a step before its round, a path that does not exist,
396
+ a time earlier than the line before, a round finished twice with two
397
+ outcomes) and warnings (a round whose steps share one time, so it was
398
+ written after the fact; rounds that never read the inbox; an output not
399
+ there yet; a round count that starts over, a second run in one folder). It exits 1 on problems only, or on warnings too with
400
+ `--strict`. `loopcaptain doctor <repository> <loop-id>` checks what files
401
+ cannot show: it sends a running loop a signed `stop` and passes once the
402
+ loop has taken it and ended.
@@ -0,0 +1,17 @@
1
+ """The Loops protocol: the interface between a loop and the board. Start with LOOPS.md."""
2
+
3
+ from importlib import resources
4
+ from pathlib import Path
5
+
6
+ SPEC_NAME = "LOOPS.md"
7
+ #: In a checkout the spec lives in standard/, the folder people and agents are pointed at; a built
8
+ #: package carries a copy beside this file (pyproject.toml, force-include).
9
+ CHECKOUT_SPEC = Path(__file__).resolve().parents[3] / "standard" / SPEC_NAME
10
+
11
+
12
+ def spec_text() -> str:
13
+ """LOOPS.md, the protocol as loop authors read it."""
14
+ packaged = resources.files(__name__).joinpath(SPEC_NAME)
15
+ if packaged.is_file():
16
+ return packaged.read_text(encoding="utf-8")
17
+ return CHECKOUT_SPEC.read_text(encoding="utf-8")
@@ -0,0 +1,79 @@
1
+ """`loopcaptain doctor`: does a loop listen? Send it a stop and watch whether it takes it and ends.
2
+
3
+ `validate` checks the files; only a run shows that the inbox is read. The doctor appends a signed
4
+ `stop` to the loop's inbox, then waits for the loop to mark it read (`inbox_read`) and to end (a
5
+ `round_finished` with verdict `stopped`, or `loop_stopped`). The loop must be running, or be started
6
+ while the doctor waits.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from collections.abc import Callable
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ from loopcaptain.protocol import store
17
+ from loopcaptain.protocol.models import InboxRead, LoopStopped, RoundFinished
18
+ from loopcaptain.protocol.signing import board_key
19
+
20
+ POLL_S = 1.0
21
+ #: A round that took the stop ends with `stopped`, or with `done` where it met the goal anyway.
22
+ ENDING_VERDICTS = frozenset({"stopped", "done"})
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Checkup:
27
+ """What the doctor saw, step by step; `passed` only if the loop took the stop and ended."""
28
+
29
+ sent: str
30
+ taken: bool
31
+ ended: bool
32
+ waited_s: float
33
+
34
+ @property
35
+ def passed(self) -> bool:
36
+ return self.taken and self.ended
37
+
38
+ def lines(self) -> list[str]:
39
+ return [
40
+ f"sent a stop ({self.sent})",
41
+ "the loop took it (inbox_read)"
42
+ if self.taken
43
+ else "the loop never took it: does it read its inbox?",
44
+ "the loop ended as asked" if self.ended else "the loop did not end after taking it",
45
+ *(
46
+ []
47
+ if self.taken
48
+ else ["the stop stays in the inbox: the loop takes it at its next round, whenever that is"]
49
+ ),
50
+ f"{'passed' if self.passed else 'failed'} after {self.waited_s:.0f} s",
51
+ ]
52
+
53
+
54
+ def examine(
55
+ repo: Path,
56
+ loop_id: str,
57
+ timeout_s: float,
58
+ sleep: Callable[[float], None] = time.sleep,
59
+ clock: Callable[[], float] = time.monotonic,
60
+ ) -> Checkup:
61
+ store.read_manifest(repo, loop_id) # refuses a loop that is not there before writing anything
62
+ before = len(store.read_events(repo, loop_id)[0])
63
+ entry = store.append_inbox(repo, loop_id, "stop", key=board_key())
64
+ started = clock()
65
+ taken = ended = False
66
+ while True:
67
+ events = store.read_events(repo, loop_id)[0][before:]
68
+ took = next((i for i, e in enumerate(events) if isinstance(e, InboxRead) and entry.id in e.ids), None)
69
+ taken = took is not None
70
+ # Only an end after the loop took this stop counts: a round that stopped for its own reasons
71
+ # while the doctor waited says nothing about whether the loop listens.
72
+ ended = taken and any(
73
+ isinstance(e, LoopStopped) or (isinstance(e, RoundFinished) and e.verdict in ENDING_VERDICTS)
74
+ for e in events[(took or 0) + 1 :]
75
+ )
76
+ waited = clock() - started
77
+ if (taken and ended) or waited >= timeout_s:
78
+ return Checkup(entry.id, taken, taken and ended, waited)
79
+ sleep(POLL_S)