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