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,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)
|