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,496 @@
|
|
|
1
|
+
"""The Loops protocol, version 1: what a loop writes and what the board writes back.
|
|
2
|
+
|
|
3
|
+
`LOOPS.md` beside this file is the text a loop author reads; these models are the same
|
|
4
|
+
contract as code. A loop needs neither: it writes JSON by hand. The board and `loopcaptain validate`
|
|
5
|
+
read through these models, and unknown fields are ignored so a loop may carry more than the board
|
|
6
|
+
shows.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from collections import Counter
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from typing import Annotated, Literal
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, field_validator, model_validator
|
|
18
|
+
|
|
19
|
+
PROTOCOL = 1
|
|
20
|
+
#: The folder a loop keeps in its repository, one sub-folder per loop.
|
|
21
|
+
FOLDER = ".loops"
|
|
22
|
+
MANIFEST_FILE = "loop.json"
|
|
23
|
+
EVENTS_FILE = "events.jsonl"
|
|
24
|
+
INBOX_FILE = "inbox.jsonl"
|
|
25
|
+
|
|
26
|
+
#: Loop and step ids are folder names and URL parts: lowercase, digits, dot, dash, underscore.
|
|
27
|
+
SLUG = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
|
|
28
|
+
|
|
29
|
+
StepKind = Literal["plan", "research", "analyse", "build", "review", "gate", "verdict", "other"]
|
|
30
|
+
StepStatus = Literal["ok", "failed", "skipped"]
|
|
31
|
+
Severity = Literal["material", "minor"]
|
|
32
|
+
Verdict = Literal["continue", "done", "wait", "stopped", "failed"]
|
|
33
|
+
#: Why a round ended the way it did, where the verdict alone does not say.
|
|
34
|
+
StopReason = Literal[
|
|
35
|
+
"goal_met", "round_cap", "cost_cap", "runtime_cap", "person", "no_progress", "error", "impossible"
|
|
36
|
+
]
|
|
37
|
+
#: Whether a round's work was kept or thrown away (an experiment that made things worse).
|
|
38
|
+
Outcome = Literal["kept", "discarded"]
|
|
39
|
+
#: An error a retry may cure, or one only the person can.
|
|
40
|
+
ErrorKind = Literal["transient", "needs_person"]
|
|
41
|
+
#: How a question ended without a reply on the board.
|
|
42
|
+
QuestionEnd = Literal["answered", "superseded", "withdrawn"]
|
|
43
|
+
Better = Literal["higher", "lower"]
|
|
44
|
+
#: How a finding was settled.
|
|
45
|
+
Resolution = Literal["fixed", "accepted", "wontfix", "false_positive", "duplicate"]
|
|
46
|
+
CheckResult = Literal["pass", "partly", "fail", "blocked"]
|
|
47
|
+
InboxKind = Literal["reply", "stop"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def is_slug(value: str) -> bool:
|
|
51
|
+
return bool(SLUG.match(value))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def as_slug(value: str) -> str:
|
|
55
|
+
if not is_slug(value):
|
|
56
|
+
raise ValueError(f"{value!r} is not an id: lowercase letters, digits, '.', '-', '_', at most 64")
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def relative_path(value: str | None) -> str | None:
|
|
61
|
+
"""Paths in the protocol are relative to the repository root, so an export moves with its clone."""
|
|
62
|
+
if value is None:
|
|
63
|
+
return None
|
|
64
|
+
if value.startswith(("/", "~")) or ".." in value.split("/"):
|
|
65
|
+
raise ValueError(f"{value!r} must be a path inside the repository, relative to its root")
|
|
66
|
+
return value
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Record(BaseModel):
|
|
70
|
+
model_config = ConfigDict(frozen=True, extra="ignore")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---- loop.json: what the loop is ----
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Brief(Record):
|
|
77
|
+
"""What the loop is for, in the person's words. `path` points to the full brief."""
|
|
78
|
+
|
|
79
|
+
goal: str = ""
|
|
80
|
+
audience: str = ""
|
|
81
|
+
bar: str = ""
|
|
82
|
+
path: str | None = None
|
|
83
|
+
|
|
84
|
+
_path = field_validator("path")(relative_path)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Step(Record):
|
|
88
|
+
"""One role of a round: a planner, a researcher, a reviewer, a gate.
|
|
89
|
+
|
|
90
|
+
`after` names the steps this one waits for. Left out, the step follows the one listed before
|
|
91
|
+
it, so a plain sequence needs no `after` at all.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
id: str
|
|
95
|
+
kind: StepKind = "other"
|
|
96
|
+
name: str = ""
|
|
97
|
+
purpose: str = ""
|
|
98
|
+
prompt: str | None = None
|
|
99
|
+
#: Plain data, shown as given: which agent runs the step (an agent's command line, a script, a
|
|
100
|
+
#: person) and on which model. The protocol knows no vendor.
|
|
101
|
+
backend: str | None = None
|
|
102
|
+
model: str | None = None
|
|
103
|
+
after: list[str] | None = None
|
|
104
|
+
|
|
105
|
+
_id = field_validator("id")(as_slug)
|
|
106
|
+
_prompt = field_validator("prompt")(relative_path)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Workspace(Record):
|
|
110
|
+
"""Where the loop runs, so a person can pick it up: shown as text to copy, never run."""
|
|
111
|
+
|
|
112
|
+
branch: str = ""
|
|
113
|
+
worktree: str = ""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class StateFile(Record):
|
|
117
|
+
"""A file one round hands to the next: a progress note, a feature list."""
|
|
118
|
+
|
|
119
|
+
name: str
|
|
120
|
+
path: str
|
|
121
|
+
|
|
122
|
+
_path = field_validator("path")(relative_path)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class Output(Record):
|
|
126
|
+
"""Something the loop makes: a deck, a dashboard, a memo."""
|
|
127
|
+
|
|
128
|
+
name: str
|
|
129
|
+
path: str
|
|
130
|
+
|
|
131
|
+
_path = field_validator("path")(relative_path)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class Manifest(Record):
|
|
135
|
+
protocol: int = PROTOCOL
|
|
136
|
+
id: str
|
|
137
|
+
title: str = ""
|
|
138
|
+
brief: Brief = Brief()
|
|
139
|
+
steps: list[Step] = Field(default_factory=list)
|
|
140
|
+
#: The loop's own limits; it stops at whichever comes first. The board shows them and marks
|
|
141
|
+
#: an idle loop that has reached one as capped; enforcing them is the loop's job.
|
|
142
|
+
round_cap: int | None = Field(default=None, ge=1)
|
|
143
|
+
runtime_cap_minutes: int | None = Field(default=None, ge=1)
|
|
144
|
+
cost_cap_usd: float | None = Field(default=None, gt=0)
|
|
145
|
+
pace: str = ""
|
|
146
|
+
#: Commands the board may run in the repository, by name. Only "start" has a button.
|
|
147
|
+
commands: dict[str, str] = Field(default_factory=dict)
|
|
148
|
+
outputs: list[Output] = Field(default_factory=list)
|
|
149
|
+
workspace: Workspace | None = None
|
|
150
|
+
state: list[StateFile] = Field(default_factory=list)
|
|
151
|
+
|
|
152
|
+
_id = field_validator("id")(as_slug)
|
|
153
|
+
|
|
154
|
+
@field_validator("protocol")
|
|
155
|
+
@classmethod
|
|
156
|
+
def _known_protocol(cls, value: int) -> int:
|
|
157
|
+
if value != PROTOCOL:
|
|
158
|
+
raise ValueError(f"protocol {value} is not supported; this board reads protocol {PROTOCOL}")
|
|
159
|
+
return value
|
|
160
|
+
|
|
161
|
+
@model_validator(mode="after")
|
|
162
|
+
def _steps_form_a_graph(self) -> Manifest:
|
|
163
|
+
ids = [step.id for step in self.steps]
|
|
164
|
+
doubles = sorted(name for name, count in Counter(ids).items() if count > 1)
|
|
165
|
+
if doubles:
|
|
166
|
+
raise ValueError(f"step ids must be unique: {', '.join(doubles)}")
|
|
167
|
+
for step in self.steps:
|
|
168
|
+
unknown = [name for name in step.after or [] if name not in ids]
|
|
169
|
+
if unknown:
|
|
170
|
+
raise ValueError(f"step {step.id!r} comes after unknown step(s): {', '.join(unknown)}")
|
|
171
|
+
cycle = first_cycle(self.edges())
|
|
172
|
+
if cycle:
|
|
173
|
+
raise ValueError(f"steps wait for each other in a circle: {' -> '.join(cycle)}")
|
|
174
|
+
return self
|
|
175
|
+
|
|
176
|
+
def edges(self) -> list[tuple[str, str]]:
|
|
177
|
+
"""Every (before, after) pair. A step without `after` follows the step listed before it."""
|
|
178
|
+
pairs: list[tuple[str, str]] = []
|
|
179
|
+
for index, step in enumerate(self.steps):
|
|
180
|
+
before = step.after if step.after is not None else ([self.steps[index - 1].id] if index else [])
|
|
181
|
+
pairs.extend((name, step.id) for name in before)
|
|
182
|
+
return pairs
|
|
183
|
+
|
|
184
|
+
def step(self, step_id: str) -> Step | None:
|
|
185
|
+
return next((step for step in self.steps if step.id == step_id), None)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def first_cycle(edges: list[tuple[str, str]]) -> list[str]:
|
|
189
|
+
"""A circle of steps, start repeated at the end, or [] if there is none.
|
|
190
|
+
|
|
191
|
+
Depth first with its own stack, not recursion: a loop.json with thousands of chained steps must
|
|
192
|
+
be refused or read, never crash the reader.
|
|
193
|
+
"""
|
|
194
|
+
following: dict[str, list[str]] = {}
|
|
195
|
+
for before, after in edges:
|
|
196
|
+
following.setdefault(before, []).append(after)
|
|
197
|
+
done: set[str] = set()
|
|
198
|
+
for start in list(following):
|
|
199
|
+
if start in done:
|
|
200
|
+
continue
|
|
201
|
+
path: list[str] = [start]
|
|
202
|
+
on_path = {start}
|
|
203
|
+
pending: list[list[str]] = [list(following.get(start, []))]
|
|
204
|
+
while path:
|
|
205
|
+
if not pending[-1]:
|
|
206
|
+
finished = path.pop()
|
|
207
|
+
on_path.discard(finished)
|
|
208
|
+
done.add(finished)
|
|
209
|
+
pending.pop()
|
|
210
|
+
continue
|
|
211
|
+
nxt = pending[-1].pop(0)
|
|
212
|
+
if nxt in on_path:
|
|
213
|
+
return [*path[path.index(nxt) :], nxt]
|
|
214
|
+
if nxt not in done:
|
|
215
|
+
path.append(nxt)
|
|
216
|
+
on_path.add(nxt)
|
|
217
|
+
pending.append(list(following.get(nxt, [])))
|
|
218
|
+
return []
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ---- events.jsonl: what happened, one line each ----
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class Finding(Record):
|
|
225
|
+
"""Something a step found wrong. `id` is unique within its step and round."""
|
|
226
|
+
|
|
227
|
+
id: str
|
|
228
|
+
text: str
|
|
229
|
+
severity: Severity = "material"
|
|
230
|
+
|
|
231
|
+
@field_validator("id", mode="before")
|
|
232
|
+
@classmethod
|
|
233
|
+
def _id_as_text(cls, value: object) -> object:
|
|
234
|
+
return str(value) if isinstance(value, int) else value
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class Cost(Record):
|
|
238
|
+
"""What a step or round cost. `usd` is optional: an agent on a subscription reports none."""
|
|
239
|
+
|
|
240
|
+
usd: float = Field(default=0.0, ge=0)
|
|
241
|
+
tokens_in: int = Field(default=0, ge=0)
|
|
242
|
+
tokens_out: int = Field(default=0, ge=0)
|
|
243
|
+
tokens_cache_read: int = Field(default=0, ge=0)
|
|
244
|
+
tokens_cache_write: int = Field(default=0, ge=0)
|
|
245
|
+
turns: int = Field(default=0, ge=0)
|
|
246
|
+
#: True where the figures are the loop's estimate, not what the agent reported.
|
|
247
|
+
estimated: bool = False
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def tokens(self) -> int:
|
|
251
|
+
return self.tokens_in + self.tokens_out + self.tokens_cache_read + self.tokens_cache_write
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class Metric(Record):
|
|
255
|
+
"""A number a loop optimises, per step or round: a score, a test pass rate, a word count."""
|
|
256
|
+
|
|
257
|
+
name: str
|
|
258
|
+
value: float
|
|
259
|
+
target: float | None = None
|
|
260
|
+
better: Better | None = None
|
|
261
|
+
unit: str = ""
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def met(self) -> bool | None:
|
|
265
|
+
if self.target is None or self.better is None:
|
|
266
|
+
return None
|
|
267
|
+
return self.value >= self.target if self.better == "higher" else self.value <= self.target
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class StepError(Record):
|
|
271
|
+
kind: ErrorKind = "transient"
|
|
272
|
+
text: str = ""
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class Option(Record):
|
|
276
|
+
"""One answer a question offers; the board draws a button for it."""
|
|
277
|
+
|
|
278
|
+
id: str
|
|
279
|
+
label: str
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class Event(Record):
|
|
283
|
+
"""`ts` is when the line was written. `at`, optional, is when the thing really happened, for a loop
|
|
284
|
+
that reports later than it works: durations are measured on `at` where it is given."""
|
|
285
|
+
|
|
286
|
+
ts: datetime
|
|
287
|
+
at: datetime | None = None
|
|
288
|
+
|
|
289
|
+
@field_validator("ts", "at")
|
|
290
|
+
@classmethod
|
|
291
|
+
def _aware(cls, value: datetime | None) -> datetime | None:
|
|
292
|
+
return value if value is None or value.tzinfo else value.replace(tzinfo=UTC)
|
|
293
|
+
|
|
294
|
+
@property
|
|
295
|
+
def when(self) -> datetime:
|
|
296
|
+
return self.at or self.ts
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class RoundStarted(Event):
|
|
300
|
+
event: Literal["round_started"] = "round_started"
|
|
301
|
+
round: int = Field(ge=1)
|
|
302
|
+
plan: str = ""
|
|
303
|
+
artifact: str | None = None
|
|
304
|
+
|
|
305
|
+
_artifact = field_validator("artifact")(relative_path)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class StepStarted(Event):
|
|
309
|
+
event: Literal["step_started"] = "step_started"
|
|
310
|
+
round: int = Field(ge=1)
|
|
311
|
+
step: str
|
|
312
|
+
#: Tells runs of the same step apart within a round: one research agent per angle.
|
|
313
|
+
label: str = ""
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class StepFinished(Event):
|
|
317
|
+
event: Literal["step_finished"] = "step_finished"
|
|
318
|
+
round: int = Field(ge=1)
|
|
319
|
+
step: str
|
|
320
|
+
label: str = ""
|
|
321
|
+
status: StepStatus = "ok"
|
|
322
|
+
summary: str = ""
|
|
323
|
+
artifact: str | None = None
|
|
324
|
+
findings: list[Finding] = Field(default_factory=list)
|
|
325
|
+
cost: Cost | None = None
|
|
326
|
+
metrics: list[Metric] = Field(default_factory=list)
|
|
327
|
+
#: Which try of this step in this round, from 1; with `error`, why it did not succeed.
|
|
328
|
+
attempt: int | None = Field(default=None, ge=1)
|
|
329
|
+
error: StepError | None = None
|
|
330
|
+
#: The agent session the step ran in, to resume it by hand: shown as text, never run.
|
|
331
|
+
session: str | None = None
|
|
332
|
+
#: The model this run really used, where it differs from the step's in loop.json (a fallback).
|
|
333
|
+
model: str | None = None
|
|
334
|
+
|
|
335
|
+
_artifact = field_validator("artifact")(relative_path)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class FindingClosed(Event):
|
|
339
|
+
event: Literal["finding_closed"] = "finding_closed"
|
|
340
|
+
round: int = Field(ge=1)
|
|
341
|
+
step: str
|
|
342
|
+
id: str
|
|
343
|
+
#: Which run of a fan-out step; left out, the finding is closed in every run of the step.
|
|
344
|
+
label: str = ""
|
|
345
|
+
resolution: Resolution | None = None
|
|
346
|
+
reason: str = ""
|
|
347
|
+
|
|
348
|
+
@field_validator("id", mode="before")
|
|
349
|
+
@classmethod
|
|
350
|
+
def _id_as_text(cls, value: object) -> object:
|
|
351
|
+
return str(value) if isinstance(value, int) else value
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
class RoundFinished(Event):
|
|
355
|
+
event: Literal["round_finished"] = "round_finished"
|
|
356
|
+
round: int = Field(ge=1)
|
|
357
|
+
verdict: Verdict
|
|
358
|
+
stop_reason: StopReason | None = None
|
|
359
|
+
summary: str = ""
|
|
360
|
+
next: str = ""
|
|
361
|
+
commit: str | None = None
|
|
362
|
+
artifact: str | None = None
|
|
363
|
+
#: The round's whole cost, for a loop that knows only the sum; step costs are then not added.
|
|
364
|
+
cost: Cost | None = None
|
|
365
|
+
metrics: list[Metric] = Field(default_factory=list)
|
|
366
|
+
outcome: Outcome | None = None
|
|
367
|
+
#: When the next round starts, for a loop that sleeps between rounds: not stalled until then.
|
|
368
|
+
next_at: datetime | None = None
|
|
369
|
+
|
|
370
|
+
_artifact = field_validator("artifact")(relative_path)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class LoopStopped(Event):
|
|
374
|
+
"""The loop ended between rounds: a stop that came after the last round finished, a cap."""
|
|
375
|
+
|
|
376
|
+
event: Literal["loop_stopped"] = "loop_stopped"
|
|
377
|
+
stop_reason: StopReason = "person"
|
|
378
|
+
summary: str = ""
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
class Heartbeat(Event):
|
|
382
|
+
"""Alive and between signs: a loop that sleeps or works quietly says when to expect it next."""
|
|
383
|
+
|
|
384
|
+
event: Literal["heartbeat"] = "heartbeat"
|
|
385
|
+
text: str = ""
|
|
386
|
+
next_at: datetime | None = None
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
class Question(Event):
|
|
390
|
+
"""The loop needs the person. Open until the person replies through the inbox."""
|
|
391
|
+
|
|
392
|
+
event: Literal["question"] = "question"
|
|
393
|
+
text: str
|
|
394
|
+
round: int | None = Field(default=None, ge=1)
|
|
395
|
+
#: True when the loop stands still until the answer comes, and for how long it waits at most.
|
|
396
|
+
blocks: bool = False
|
|
397
|
+
timeout_s: int | None = Field(default=None, ge=1)
|
|
398
|
+
#: Lets a reply or a `question_closed` name this question; `options` become buttons.
|
|
399
|
+
id: str | None = None
|
|
400
|
+
options: list[Option] = Field(default_factory=list)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
class QuestionClosed(Event):
|
|
404
|
+
"""The loop ends its own question: answered elsewhere, overtaken by a default, or withdrawn."""
|
|
405
|
+
|
|
406
|
+
event: Literal["question_closed"] = "question_closed"
|
|
407
|
+
#: The question's id; left out, the loop's last open question.
|
|
408
|
+
question: str | None = None
|
|
409
|
+
reason: QuestionEnd = "answered"
|
|
410
|
+
answer: str = ""
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
class Check(Event):
|
|
414
|
+
"""One check of one item the loop evaluates or builds: a use case, a skill, a feature."""
|
|
415
|
+
|
|
416
|
+
event: Literal["check"] = "check"
|
|
417
|
+
item: str
|
|
418
|
+
kind: str
|
|
419
|
+
result: CheckResult
|
|
420
|
+
who: str = ""
|
|
421
|
+
finding: str = ""
|
|
422
|
+
evidence: str | None = None
|
|
423
|
+
round: int | None = Field(default=None, ge=1)
|
|
424
|
+
|
|
425
|
+
_evidence = field_validator("evidence")(relative_path)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
class InboxRead(Event):
|
|
429
|
+
"""The loop has taken these inbox entries, so the board can show them as read."""
|
|
430
|
+
|
|
431
|
+
event: Literal["inbox_read"] = "inbox_read"
|
|
432
|
+
ids: list[str]
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
AnyEvent = Annotated[
|
|
436
|
+
RoundStarted
|
|
437
|
+
| StepStarted
|
|
438
|
+
| StepFinished
|
|
439
|
+
| FindingClosed
|
|
440
|
+
| RoundFinished
|
|
441
|
+
| Question
|
|
442
|
+
| QuestionClosed
|
|
443
|
+
| Check
|
|
444
|
+
| InboxRead
|
|
445
|
+
| LoopStopped
|
|
446
|
+
| Heartbeat,
|
|
447
|
+
Field(discriminator="event"),
|
|
448
|
+
]
|
|
449
|
+
EVENT: TypeAdapter[AnyEvent] = TypeAdapter(AnyEvent)
|
|
450
|
+
EVENT_NAMES = (
|
|
451
|
+
"round_started",
|
|
452
|
+
"step_started",
|
|
453
|
+
"step_finished",
|
|
454
|
+
"finding_closed",
|
|
455
|
+
"round_finished",
|
|
456
|
+
"question",
|
|
457
|
+
"question_closed",
|
|
458
|
+
"check",
|
|
459
|
+
"inbox_read",
|
|
460
|
+
"loop_stopped",
|
|
461
|
+
"heartbeat",
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
# ---- inbox.jsonl: what the person sends, written by the board ----
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
class InboxEntry(Record):
|
|
469
|
+
id: str
|
|
470
|
+
ts: datetime
|
|
471
|
+
kind: InboxKind
|
|
472
|
+
text: str = ""
|
|
473
|
+
round: int | None = None
|
|
474
|
+
#: The loop this line is for; it is signed with the rest, so a line copied elsewhere fails.
|
|
475
|
+
loop: str | None = None
|
|
476
|
+
#: A reply to a question with an id names it; `choice` is the id of the option picked.
|
|
477
|
+
question: str | None = None
|
|
478
|
+
choice: str | None = None
|
|
479
|
+
#: HMAC-SHA256 the board puts on every line it writes; a line without it is not from the board.
|
|
480
|
+
sig: str | None = None
|
|
481
|
+
|
|
482
|
+
def body(self) -> dict[str, object]:
|
|
483
|
+
"""The line as written, for checking its `sig`."""
|
|
484
|
+
return dict(json.loads(self.model_dump_json(exclude_none=True)))
|
|
485
|
+
|
|
486
|
+
@field_validator("ts")
|
|
487
|
+
@classmethod
|
|
488
|
+
def _aware(cls, value: datetime) -> datetime:
|
|
489
|
+
return value if value.tzinfo else value.replace(tzinfo=UTC)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def now() -> datetime:
|
|
493
|
+
"""The time to the millisecond, as the helper writes it: an answer in the same second as its
|
|
494
|
+
question still comes after it."""
|
|
495
|
+
moment = datetime.now(UTC)
|
|
496
|
+
return moment.replace(microsecond=moment.microsecond // 1000 * 1000)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""The board's mark on the inbox: an HMAC over each line it writes, with a key no repository holds.
|
|
2
|
+
|
|
3
|
+
The inbox is a plain file in the loop's repository, so an agent that can write files there could
|
|
4
|
+
add a `reply` that reads as the person or a `stop` that ends the run. The board signs every line
|
|
5
|
+
with a key kept in the user's config folder, outside every watched repository; a line without a
|
|
6
|
+
valid `sig` is "not from the board". This protects against agents confined to their repository,
|
|
7
|
+
not against a process with the user's full file access, which can read the key too.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import hmac
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import secrets
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from loopcaptain.protocol.models import InboxEntry
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger("loopcaptain.signing")
|
|
25
|
+
|
|
26
|
+
KEY_ENV = "LOOPCAPTAIN_INBOX_KEY"
|
|
27
|
+
DEFAULT_KEY = Path("~/.config/loopcaptain/inbox.key")
|
|
28
|
+
KEY_BYTES = 32
|
|
29
|
+
PRIVATE_MODE = 0o600
|
|
30
|
+
PRIVATE_DIR_MODE = 0o700
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def key_path() -> Path:
|
|
34
|
+
return Path(os.environ.get(KEY_ENV) or DEFAULT_KEY).expanduser()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def read_key() -> bytes | None:
|
|
38
|
+
"""The board's key, or None where this machine has none yet or it cannot be read."""
|
|
39
|
+
try:
|
|
40
|
+
key = bytes.fromhex(key_path().read_text(encoding="utf-8").strip())
|
|
41
|
+
except FileNotFoundError:
|
|
42
|
+
return None
|
|
43
|
+
except (OSError, ValueError) as error:
|
|
44
|
+
log.warning("the inbox key at %s cannot be read: %s", key_path(), error)
|
|
45
|
+
return None
|
|
46
|
+
if len(key) < KEY_BYTES:
|
|
47
|
+
log.warning("the inbox key at %s is too short; it is not used", key_path())
|
|
48
|
+
return None
|
|
49
|
+
return key
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def board_key() -> bytes:
|
|
53
|
+
"""The board's key, created readable by this user only on first use."""
|
|
54
|
+
found = read_key()
|
|
55
|
+
if found:
|
|
56
|
+
return found
|
|
57
|
+
path = key_path()
|
|
58
|
+
path.parent.mkdir(mode=PRIVATE_DIR_MODE, parents=True, exist_ok=True)
|
|
59
|
+
key = secrets.token_bytes(KEY_BYTES)
|
|
60
|
+
# Written whole beside it, then linked into place: a reader never sees half a key, and of two
|
|
61
|
+
# boards starting at once the second finds the first one's key instead of writing its own.
|
|
62
|
+
draft = path.with_name(f"{path.name}.{secrets.token_hex(4)}.tmp")
|
|
63
|
+
descriptor = os.open(draft, os.O_WRONLY | os.O_CREAT | os.O_EXCL, PRIVATE_MODE)
|
|
64
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
65
|
+
handle.write(key.hex() + "\n")
|
|
66
|
+
try:
|
|
67
|
+
os.link(draft, path)
|
|
68
|
+
except FileExistsError:
|
|
69
|
+
existing = read_key()
|
|
70
|
+
if existing is None:
|
|
71
|
+
raise
|
|
72
|
+
return existing
|
|
73
|
+
finally:
|
|
74
|
+
draft.unlink(missing_ok=True)
|
|
75
|
+
log.info("created the inbox key at %s", path)
|
|
76
|
+
return key
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def canonical(entry: dict[str, Any]) -> bytes:
|
|
80
|
+
"""The bytes that are signed: the line without `sig`, keys sorted, no spaces."""
|
|
81
|
+
body = {key: value for key, value in entry.items() if key != "sig"}
|
|
82
|
+
return json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def sign(entry: dict[str, Any], key: bytes) -> str:
|
|
86
|
+
return hmac.new(key, canonical(entry), hashlib.sha256).hexdigest()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def is_signed(entry: dict[str, Any], key: bytes | None, loop_id: str | None = None) -> bool | None:
|
|
90
|
+
"""True if the board wrote this line for this loop, False if not, None where there is no key.
|
|
91
|
+
|
|
92
|
+
The line names its loop, so a signed line copied into another loop's inbox is not signed there.
|
|
93
|
+
"""
|
|
94
|
+
if key is None:
|
|
95
|
+
return None
|
|
96
|
+
given = entry.get("sig")
|
|
97
|
+
if loop_id is not None and entry.get("loop") != loop_id:
|
|
98
|
+
return False
|
|
99
|
+
return isinstance(given, str) and hmac.compare_digest(given, sign(entry, key))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def untrusted_ids(inbox: list[InboxEntry], loop_id: str) -> frozenset[str]:
|
|
103
|
+
"""The inbox entries whose signature fails. Without a key on this machine nothing can be told apart."""
|
|
104
|
+
key = read_key()
|
|
105
|
+
return frozenset(entry.id for entry in inbox if is_signed(entry.body(), key, loop_id) is False)
|