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,357 @@
1
+ {% extends "base.html" %}
2
+ {% block title %}{{ view.title }} · Loopcaptain{% endblock %}
3
+ {% block where %}{{ view.repo.key }} / {{ view.loop_id }}{% endblock %}
4
+ {% block body_attrs %} data-pulse-url="{{ url_for('loop_pulse', repo_key=view.repo.key, loop_id=view.loop_id) }}" data-pulse="{{ pulse }}"{% endblock %}
5
+ {% set here = url_for('loop_page', repo_key=view.repo.key, loop_id=view.loop_id) %}
6
+ {% macro file_link(path, text) %}{% if path %}<a href="{{ url_for('loop_file', repo_key=view.repo.key, loop_id=view.loop_id) }}?path={{ path|urlencode }}">{{ text or path }}</a>{% endif %}{% endmacro %}
7
+ {% block main %}
8
+ <p class="crumb"><a href="{{ url_for('overview') }}">Loops</a> / {{ view.repo.key }} / {{ view.loop_id }}</p>
9
+ <h1>{{ view.title }}</h1>
10
+ <p class="meta"><span class="pill {{ state.status }}">{{ state.status }}</span><span>{{ status_text[state.status] }}</span>{% if state.last_event %}<span>last line {{ state.last_event|ago }}</span>{% endif %}{% if branch %}<span>branch <code>{{ branch }}</code></span>{% endif %}{% if view.repo.others %}<span title="{% for other in view.repo.others %}{{ other|place }}{% if not loop.last %}, {% endif %}{% endfor %}">written from <code>{{ view.repo.path|place }}</code>, the latest of {{ view.repo.others|length + 1 }} worktrees</span>{% endif %}{% if manifest.pace %}<span>{{ manifest.pace }}</span>{% endif %}{% if state.status == 'sleeping' and state.expected %}<span>back at {{ state.expected|stamp }}</span>{% endif %}{% if state.ended %}<span>ended between rounds: {{ state.ended.stop_reason|replace('_', ' ') }}{% if state.ended.summary %}, {{ state.ended.summary }}{% endif %}</span>{% endif %}</p>
11
+ {% if note %}<p class="flash" role="status">{{ note }}</p>{% endif %}
12
+ {% if error %}<p class="flash bad" role="alert">{{ error }}</p>{% endif %}
13
+
14
+ {% if state.question %}
15
+ {% set q = state.question %}
16
+ <section class="ask" aria-label="{{ 'A step needs you' if q.needs_person else 'The loop' ~ "'s question" }}">
17
+ <p class="eyebrow">{{ 'A step needs you' if q.needs_person else 'The loop asks' }}{% if q.round %}, round {{ q.round }}{% endif %} · {{ q.asked|ago }}{% if q.blocks %} · it stands still until you answer{% if q.gives_up %}, at most until {{ q.gives_up|stamp }}{% endif %}{% endif %}</p>
18
+ <p class="q">{{ q.text }}</p>
19
+ {% if q.options %}
20
+ <form class="choices" method="post" action="{{ url_for('loop_reply', repo_key=view.repo.key, loop_id=view.loop_id) }}">
21
+ <input type="hidden" name="token" value="{{ token }}">
22
+ {% for option in q.options %}<button type="submit" name="choice" value="{{ option.id }}">{{ option.label }}</button>{% endfor %}
23
+ </form>
24
+ {% endif %}
25
+ <form class="say" method="post" action="{{ url_for('loop_reply', repo_key=view.repo.key, loop_id=view.loop_id) }}">
26
+ <input type="hidden" name="token" value="{{ token }}">
27
+ <textarea name="text" required aria-label="Your answer" placeholder="{{ 'Or answer in your own words.' if q.options else ('What you did, or what the loop should do instead.' if q.needs_person else 'Your answer. The loop reads it at its next round.') }}"></textarea>
28
+ <button type="submit">Send answer</button>
29
+ </form>
30
+ </section>
31
+ {% endif %}
32
+
33
+ {% set open_count = state.open_findings|length %}
34
+ <nav class="tabs" aria-label="Parts of this loop">
35
+ <a href="{{ here }}"{% if tab == 'overview' %} aria-current="page"{% endif %}>Overview</a>
36
+ <a href="{{ here }}?tab=rounds"{% if tab == 'rounds' %} aria-current="page"{% endif %}>Rounds<span class="n">{{ state.rounds|length }}</span></a>
37
+ <a href="{{ here }}?tab=findings"{% if tab == 'findings' %} aria-current="page"{% endif %}>Open findings<span class="n">{{ open_count }}</span></a>
38
+ {% if state.items %}<a href="{{ here }}?tab=items"{% if tab == 'items' %} aria-current="page"{% endif %}>Items<span class="n">{{ state.items|length }}</span></a>{% endif %}
39
+ <a href="{{ here }}?tab=inbox"{% if tab == 'inbox' %} aria-current="page"{% endif %}>What you sent<span class="n">{{ state.inbox|length }}</span></a>
40
+ </nav>
41
+
42
+ {% if tab == 'rounds' %}
43
+ {% if not state.rounds %}<p class="empty tabbed">No round has run yet.</p>{% endif %}
44
+ {% if state.rounds %}
45
+ <h2>Every round<small>a number is that step's open material findings, darker for more; pick a round to read it</small></h2>
46
+ <div class="split{{ ' stacked' if state.rounds|length > 8 }}">
47
+ <div class="pick card flush fit">
48
+ <table class="grid">
49
+ <thead><tr>
50
+ <th scope="col" class="step">Step</th>
51
+ {% for r in state.rounds %}<th scope="col" class="rnd"><a href="{{ here }}?tab=rounds&amp;round={{ r.number }}#round" aria-label="Round {{ r.number }}"{% if shown and r.number == shown.number %} aria-current="true"{% endif %}>{{ r.number }}</a></th>{% endfor %}
52
+ </tr></thead>
53
+ <tbody>
54
+ {% for step_id in state.step_ids %}
55
+ {% set step = manifest.step(step_id) %}
56
+ <tr>
57
+ <td class="step"><b>{{ (step.name if step else '') or step_id }}</b><span>{{ step.kind if step else 'not in loop.json' }}</span></td>
58
+ {% for r in state.rounds %}
59
+ {% set runs = r.runs_of(step_id) %}
60
+ {% set on = ' on' if shown and r.number == shown.number else '' %}
61
+ {% if not runs %}<td class="{{ on }}"><span class="cell" title="did not run"><i class="mark"></i><span class="sr">did not run</span></span></td>
62
+ {% else %}
63
+ {% set finds = runs|sum(attribute='material_open') %}
64
+ {% set st = 'running' if runs|selectattr('status', 'equalto', 'running')|list else ('failed' if runs|selectattr('status', 'equalto', 'failed')|list else ('ok' if runs|selectattr('status', 'equalto', 'ok')|list else 'skipped')) %}
65
+ <td class="{{ on }}">{% if finds %}<span class="cell h{{ 1 if finds == 1 else (2 if finds == 2 else (3 if finds <= 4 else 4)) }}" title="{{ finds }} open material finding{{ 's' if finds != 1 }}{% if st == 'failed' %}, failed{% endif %}">{{ finds }}</span>{% else %}<span class="cell" title="{{ st }}{% if runs|length > 1 %}, {{ runs|length }} runs{% endif %}"><i class="mark {{ st }}"></i><span class="sr">{{ st }}</span></span>{% endif %}</td>
66
+ {% endif %}
67
+ {% endfor %}
68
+ </tr>
69
+ {% endfor %}
70
+ <tr class="sum first"><td class="step">Verdict</td>{% for r in state.rounds %}<td class="{{ 'on' if shown and r.number == shown.number }}"><span class="v {{ r.verdict or 'live' }}">{{ (r.verdict or 'live')|replace('continue', 'go on') }}</span></td>{% endfor %}</tr>
71
+ <tr class="sum"><td class="step">Minutes</td>{% for r in state.rounds %}<td class="{{ 'on' if shown and r.number == shown.number }}">{{ r.minutes if r.minutes is not none else '' }}</td>{% endfor %}</tr>
72
+ <tr class="sum"><td class="step">Cost</td>{% for r in state.rounds %}<td class="{{ 'on' if shown and r.number == shown.number }}">{{ r.cost_usd|usd }}</td>{% endfor %}</tr>
73
+ </tbody>
74
+ </table>
75
+ </div>
76
+ <div class="detail">
77
+ {% if shown %}
78
+ <div class="round-head" id="round">
79
+ <h2>Round {{ shown.number }}</h2>
80
+ <span class="facts">{{ shown.started|stamp }}{% if shown.minutes is not none %} · {{ shown.minutes }} min{% endif %}{% if shown.cost_usd %} · {{ shown.cost_usd|usd }}{% if shown.round_cost %} for the round{% endif %}{% elif shown.tokens %} · {{ shown.tokens|tokens }} tokens{% endif %}{% if shown.outcome == 'discarded' %} · <span class="v discarded">discarded</span>{% endif %} · {% if shown.verdict %}<span class="v {{ shown.verdict }}">{{ shown.verdict }}{% if shown.stop_reason %}: {{ shown.stop_reason|replace('_', ' ') }}{% endif %}</span>{% else %}<span class="v live">under way</span>{% endif %}{% if shown.commit %} · commit <code>{{ shown.commit[:10] }}</code>{% endif %} · {{ shown.findings_open }} open, {{ shown.findings_closed }} closed</span>
81
+ </div>
82
+ {% if shown.all_metrics %}
83
+ <p class="metrics">{% for m in shown.all_metrics %}<span class="{{ 'met' if m.met is true else ('short' if m.met is false else '') }}"><b>{{ m.value|figure(m.unit) }}</b> {{ m.name }}{% if m.target is not none %} <span class="grey">target {{ m.target|figure(m.unit) }}</span>{% endif %}</span>{% endfor %}</p>
84
+ {% endif %}
85
+ {% if shown.plan or shown.plan_artifact %}
86
+ <div class="plan"><p class="eyebrow">Plan{% if shown.plan_artifact %} · {{ file_link(shown.plan_artifact, 'the whole plan') }}{% endif %}</p>{% if shown.plan %}<p>{{ shown.plan }}</p>{% endif %}</div>
87
+ {% endif %}
88
+ {% if shown.runs %}
89
+ <div class="card flush">
90
+ <table class="runs">
91
+ <thead><tr><th scope="col">Step</th><th scope="col">State</th><th scope="col">What it did and found</th><th scope="col" class="r">Tokens</th><th scope="col" class="r">Cost</th><th scope="col">File</th></tr></thead>
92
+ <tbody>
93
+ {% for run in shown.runs %}
94
+ {% set step = manifest.step(run.step) %}
95
+ <tr>
96
+ <td class="what"><b>{{ (step.name if step else '') or run.step }}</b>{% if run.label %}<span>{{ run.label }}</span>{% endif %}<span>{{ step.kind if step else '' }}{% if step and step.backend %} · {{ step.backend }}{% endif %}{% if step and step.model %} · {{ step.model }}{% endif %}</span></td>
97
+ <td><span class="st {{ run.status }}"><i class="mark {{ run.status }}"></i>{{ run.status }}</span>{% if run.attempt and run.attempt > 1 %}<span class="grey"> · try {{ run.attempt }}</span>{% endif %}</td>
98
+ <td class="sum">{% if run.error %}<p class="err{{ ' person' if run.error.kind == 'needs_person' }}">{{ 'Needs you: ' if run.error.kind == 'needs_person' else 'Error: ' }}{{ run.error.text }}</p>{% endif %}{{ run.summary }}{% if run.metrics %}<span class="grey"> · {% for m in run.metrics %}{{ m.name }} {{ m.value|figure(m.unit) }}{{ ', ' if not loop.last }}{% endfor %}</span>{% endif %}{% if run.model %}<span class="grey"> · model <code>{{ run.model }}</code></span>{% endif %}{% if run.session %}<span class="grey"> · session <code>{{ run.session }}</code></span>{% endif %}{% if run.findings %}
99
+ <ol>{% for f in run.findings %}<li data-id="{{ f.id }}" class="{{ 'closed' if f.closed }} {{ 'minor' if f.severity == 'minor' }}">{% if f.closed %}<s>{{ f.text }}</s> <em>{{ (f.resolution or 'closed')|replace('_', ' ') }}{% if f.reason %}: {{ f.reason }}{% endif %}</em>{% else %}{{ f.text }}{% if f.severity == 'minor' %} <span class="grey">(minor)</span>{% endif %}{% endif %}</li>{% endfor %}</ol>
100
+ {% endif %}</td>
101
+ <td class="r num">{% if run.cost %}{{ run.cost.tokens|tokens }}{% if run.cost.estimated %}<span class="grey" title="estimated by the loop">~</span>{% endif %}{% endif %}</td>
102
+ <td class="r num">{% if run.cost %}{{ run.cost.usd|usd }}{% endif %}</td>
103
+ <td>{{ file_link(run.artifact, 'open') }}</td>
104
+ </tr>
105
+ {% endfor %}
106
+ </tbody>
107
+ </table>
108
+ </div>
109
+ {% elif shown.running %}<p class="note">No step has reported yet.</p>{% endif %}
110
+ {% if shown.summary or shown.next or shown.questions %}
111
+ <div class="outcome">
112
+ {% if shown.summary %}<div class="card"><p class="eyebrow">Verdict{% if shown.verdict_artifact %} · {{ file_link(shown.verdict_artifact, 'all of it') }}{% endif %}</p><p>{{ shown.summary }}</p></div>{% endif %}
113
+ {% if shown.next %}<div class="card"><p class="eyebrow">Next round</p><p>{{ shown.next }}</p></div>{% endif %}
114
+ {% if shown.questions %}<div class="card"><p class="eyebrow">Asked you</p>{% for q in shown.questions %}<p>{{ q }}</p>{% endfor %}</div>{% endif %}
115
+ </div>
116
+ {% endif %}
117
+ {% endif %}
118
+ </div>
119
+ </div>
120
+ {% endif %}
121
+ {% elif tab == 'findings' %}
122
+ {% set reviewed = state.open_findings[0][0] if state.open_findings else none %}
123
+ <h2>Open now<small>what the last reviewed round found and nobody closed; reviews are fresh each round</small></h2>
124
+ {% if state.open_findings %}
125
+ <div class="card flush">
126
+ <table class="runs">
127
+ <thead><tr><th scope="col">Found by</th><th scope="col">Finding</th><th scope="col">Weight</th><th scope="col">File</th></tr></thead>
128
+ <tbody>
129
+ {% for run, finding in state.open_findings %}
130
+ {% set step = manifest.step(run.step) %}
131
+ <tr>
132
+ <td class="what"><b>{{ (step.name if step else '') or run.step }}</b>{% if run.label %}<span>{{ run.label }}</span>{% endif %}<span>{{ step.kind if step else '' }} · no. {{ finding.id }}</span></td>
133
+ <td class="sum">{{ finding.text }}</td>
134
+ <td><span class="st {{ 'skipped' if finding.severity == 'minor' else '' }}">{{ finding.severity }}</span></td>
135
+ <td>{{ file_link(run.artifact, 'open') }}</td>
136
+ </tr>
137
+ {% endfor %}
138
+ </tbody>
139
+ </table>
140
+ </div>
141
+ {% else %}
142
+ <p class="empty">Nothing is open{% if not state.rounds %}: no round has run yet{% endif %}.</p>
143
+ {% endif %}
144
+ {% elif tab == 'inbox' %}
145
+ <h2>What you sent<small>every line of the inbox, newest first, and whether the loop has taken it</small></h2>
146
+ {% if state.inbox %}
147
+ <div class="card flush">
148
+ <table class="runs inbox">
149
+ <thead><tr><th scope="col">Sent</th><th scope="col">Kind</th><th scope="col">What</th><th scope="col">Taken by the loop</th><th scope="col">From</th></tr></thead>
150
+ <tbody>
151
+ {% for item in state.inbox|reverse %}
152
+ {% set mark = signed[item.entry.id] %}
153
+ <tr{% if mark is false %} class="forged"{% endif %}>
154
+ <td class="num">{{ item.entry.ts|stamp }}<span class="grey"> · {{ item.entry.ts|ago }}</span></td>
155
+ <td><span class="st">{{ item.entry.kind }}</span></td>
156
+ <td class="sum">{{ item.entry.text or 'Stop after the current round.' }}{% if item.entry.choice %} <span class="grey">(picked option {{ item.entry.choice }})</span>{% endif %}{% if item.entry.round %} <span class="grey">· round {{ item.entry.round }}</span>{% endif %}</td>
157
+ <td>{% if item.read %}<span class="st ok"><i class="mark ok"></i>taken</span>{% else %}<span class="st"><i class="mark"></i>not yet</span>{% endif %}</td>
158
+ <td>{% if mark is true %}this board{% elif mark is false %}<b class="bad">not from the board</b>{% else %}<span class="grey">unknown</span>{% endif %}</td>
159
+ </tr>
160
+ {% endfor %}
161
+ </tbody>
162
+ </table>
163
+ </div>
164
+ <p class="note">A line marked "not from the board" was written into <code>inbox.jsonl</code> by something else, an agent of the loop perhaps. A loop that checks <code>signed</code> ignores it.</p>
165
+ {% else %}
166
+ <p class="empty">Nothing sent yet. An answer, a note or a stop from this page shows here.</p>
167
+ {% endif %}
168
+ {% elif tab == 'items' %}
169
+ {% if not state.items %}<p class="empty tabbed">This loop reports no items. A loop that evaluates a list of things sends a <code>check</code> event per check.</p>{% endif %}
170
+ {% if state.items %}
171
+ <h2>What the loop evaluates<small>each cell is the latest check of that kind; a hatched cell means none has run</small></h2>
172
+ <div class="card flush">
173
+ <table class="items">
174
+ <thead><tr><th scope="col" class="item">Item</th>{% for kind in state.check_kinds %}<th scope="col">{{ kind }}</th>{% endfor %}</tr></thead>
175
+ <tbody>
176
+ {% for item in state.items %}
177
+ <tr>
178
+ <td class="item">{{ item.name }}</td>
179
+ {% for kind in state.check_kinds %}
180
+ {% set latest = item.latest(kind) %}
181
+ {% if latest %}
182
+ <td><details><summary><span class="res {{ latest.result }}">{{ latest.result }}</span><span class="when">{{ latest.at|stamp }}{% if latest.who %} · {{ latest.who }}{% endif %}</span></summary>
183
+ <ul>{% for check in item.history(kind)|reverse %}<li>{{ check.at|stamp }} · {{ check.result }}{% if check.who %} · {{ check.who }}{% endif %}{% if check.finding %}: {{ check.finding }}{% endif %} {{ file_link(check.evidence, 'evidence') }}</li>{% endfor %}</ul>
184
+ </details></td>
185
+ {% else %}<td class="void" title="no {{ kind }} has run for this item"><span class="sr">not run</span></td>{% endif %}
186
+ {% endfor %}
187
+ </tr>
188
+ {% endfor %}
189
+ </tbody>
190
+ </table>
191
+ </div>
192
+ {% endif %}
193
+
194
+ {% else %}
195
+ <section class="kpis" aria-label="Totals">
196
+ <div class="kpi"><b>{{ state.rounds|length }}{% if manifest.round_cap %}<sup>/ {{ manifest.round_cap }}</sup>{% endif %}</b><span>rounds{% if manifest.round_cap %}, of the cap{% endif %}</span></div>
197
+ <div class="kpi"><b>{{ state.open_findings|length }}</b><span>findings open now</span></div>
198
+ <div class="kpi"><b>{{ state.minutes|minutes or '0 min' }}</b><span>in finished rounds{% if manifest.runtime_cap_minutes %}, cap {{ manifest.runtime_cap_minutes|minutes }}{% endif %}</span></div>
199
+ <div class="kpi"><b>{{ state.cost_usd|usd or '$0' }}</b><span>reported cost{% if manifest.cost_cap_usd %}, cap {{ manifest.cost_cap_usd|usd }}{% endif %}{% if state.tokens %}, {{ state.tokens|tokens }} tokens{% endif %}</span></div>
200
+ <div class="kpi"><b>{{ manifest.steps|length }}</b><span>steps in a round, {{ manifest.steps|selectattr('kind', 'equalto', 'review')|list|length }} of them reviewers</span></div>
201
+ </section>
202
+
203
+ {% if caps %}
204
+ <section class="caps card" aria-label="How far along its own caps">
205
+ {% for bar in caps %}
206
+ <div class="cap{{ ' warn' if bar.warns }}">
207
+ <p><span class="eyebrow">{{ bar.name }}</span><span class="num">{{ bar.used }} of {{ bar.cap }}</span></p>
208
+ <svg viewBox="0 0 100 6" preserveAspectRatio="none" role="img" aria-label="{{ bar.name }}: {{ bar.used }} of {{ bar.cap }}"><rect class="track" width="100" height="6" rx="3"/><rect class="fill" width="{{ (bar.share * 100)|round(1) }}" height="6" rx="3"/></svg>
209
+ </div>
210
+ {% endfor %}
211
+ </section>
212
+ {% endif %}
213
+
214
+ {% if stuck %}
215
+ <section class="card stalled" aria-label="Signs of no progress">
216
+ <p class="eyebrow">No progress?</p>
217
+ <ul class="plain">{% for sign in stuck %}<li><span class="body">{{ sign|capitalize }}.</span></li>{% endfor %}</ul>
218
+ <p class="grey">A loop that goes round without getting closer costs rounds and money. Read the last review, then send a note that changes the approach, or stop it.</p>
219
+ </section>
220
+ {% endif %}
221
+
222
+ {% if charts %}
223
+ <h2>Is it converging<small>one line per measure, a dot per finished round{% if charts|selectattr('target_y', 'ne', none)|list %}; the dashed line is the loop's target{% endif %}</small></h2>
224
+ <section class="trends">
225
+ {% for c in charts %}
226
+ <figure class="card trend{{ ' better' if c.improving is true else (' worse' if c.improving is false else '') }}">
227
+ <figcaption><span class="eyebrow">{{ c.series.name }}</span><b>{{ c.last|figure(c.series.unit) }}</b>{% if c.series.values|length > 1 %}<span class="grey">from {{ c.first|figure(c.series.unit) }} in round {{ c.series.values[0][0] }}</span>{% endif %}</figcaption>
228
+ <svg viewBox="0 0 {{ c.width }} {{ c.height }}" role="img" aria-label="{{ c.series.name }} per round: {% for d in c.dots %}round {{ d.round }} {{ d.value|figure(c.series.unit) }}{{ ', ' if not loop.last }}{% endfor %}">
229
+ {% if c.target_y is not none %}<line class="target" x1="0" x2="{{ c.width }}" y1="{{ c.target_y }}" y2="{{ c.target_y }}"/>{% endif %}
230
+ <path class="line" d="{{ c.path }}"/>
231
+ {% for d in c.dots %}<circle cx="{{ d.x }}" cy="{{ d.y }}" r="{{ 3.5 if loop.last else 2.5 }}"><title>round {{ d.round }}: {{ d.value|figure(c.series.unit) }}</title></circle>{% endfor %}
232
+ </svg>
233
+ </figure>
234
+ {% endfor %}
235
+ </section>
236
+ {% endif %}
237
+
238
+
239
+ {% if state.status == 'stalled' %}
240
+ <section class="card stalled" aria-label="Why this loop looks stalled">
241
+ <p class="eyebrow">Silent since {{ state.last_event|stamp }}, {{ state.last_event|ago }}</p>
242
+ <p>The last line was <b>{{ state.last_line }}</b>.{% if state.expected %} The loop said it would be back by {{ state.expected|stamp }}.{% endif %} No line since, and {% if process %}the process the board started {{ 'still runs' if process.alive else 'has ended' }}{% else %}the board did not start it{% endif %}.</p>
243
+ <ol class="steps">
244
+ {% if process and not process.alive %}<li>Read the last lines it printed, below, for why it ended.</li>{% endif %}
245
+ <li>Look where the loop runs{% if manifest.workspace and manifest.workspace.worktree %} (<code>{{ manifest.workspace.worktree }}</code>){% endif %}: is its process alive, is it waiting on something outside?</li>
246
+ <li>If it died, start it again{% if manifest.commands.get('start') %} with Start below{% endif %}; a round that starts again starts clean.</li>
247
+ <li>If it should end, send a stop: the loop takes it at its next round.</li>
248
+ </ol>
249
+ </section>
250
+ {% endif %}
251
+
252
+ <section class="steer" aria-label="Steer the loop">
253
+ {% if not state.question or state.inbox %}
254
+ <div class="card">
255
+ <p class="eyebrow">{{ 'What you sent' if state.question else 'Tell the loop' }}</p>
256
+ {% if not state.question %}
257
+ <form class="say" method="post" action="{{ url_for('loop_reply', repo_key=view.repo.key, loop_id=view.loop_id) }}">
258
+ <input type="hidden" name="token" value="{{ token }}">
259
+ <textarea name="text" required aria-label="A note for the loop" placeholder="A note for the next round: what to change, drop or look at."></textarea>
260
+ <button type="submit">Send</button>
261
+ </form>
262
+ {% endif %}
263
+ {% if state.inbox %}
264
+ <ul class="plain">
265
+ {% for item in state.inbox|reverse %}{% if loop.index <= 4 %}
266
+ <li><span class="tag">{{ item.entry.kind }} · {{ 'read' if item.read else 'not read yet' }}</span><span class="body">{{ item.entry.text or 'Stop after the current round.' }} <span class="grey">{{ item.entry.ts|ago }}</span></span></li>
267
+ {% endif %}{% endfor %}
268
+ </ul>
269
+ {% endif %}
270
+ </div>
271
+ {% endif %}
272
+ <div class="card">
273
+ <p class="eyebrow">Run</p>
274
+ <div class="row">
275
+ {% if manifest.commands.get('start') %}
276
+ {% if process and process.alive %}
277
+ <span>Running since {{ process.started|stamp }}, pid {{ process.pid }}</span>
278
+ {% else %}
279
+ <form class="start" method="post" action="{{ url_for('loop_start', repo_key=view.repo.key, loop_id=view.loop_id) }}">
280
+ <input type="hidden" name="token" value="{{ token }}">
281
+ <input type="hidden" name="command" value="{{ manifest.commands.get('start') }}">
282
+ <button type="submit">Start</button>
283
+ <code>{{ manifest.commands.get('start') }}</code>
284
+ <p class="grey">This command comes from this repository's <code>loop.json</code>, not from the board. It runs here with your rights.</p>
285
+ {% if not confirmed %}<label class="tick"><input type="checkbox" name="confirm" value="yes" required> I have read the command and want it run</label>{% endif %}
286
+ </form>
287
+ {% endif %}
288
+ {% else %}
289
+ <span class="grey">This loop names no command the board could start. Start it where it lives, from its own repository.</span>
290
+ {% endif %}
291
+ </div>
292
+ <div class="row">
293
+ {% if state.status in stoppable or state.stop_requested %}
294
+ <form method="post" action="{{ url_for('loop_stop', repo_key=view.repo.key, loop_id=view.loop_id) }}">
295
+ <input type="hidden" name="token" value="{{ token }}">
296
+ <button type="submit" class="quiet"{% if state.stop_requested %} disabled{% endif %}>{{ 'Stop is waiting for the loop' if state.stop_requested else ('Stop after this round' if state.status in ('running', 'stalled', 'waiting') else 'Stop before the next round') }}</button>
297
+ </form>
298
+ {% endif %}
299
+ {% if process and process.alive %}
300
+ <form method="post" action="{{ url_for('loop_kill', repo_key=view.repo.key, loop_id=view.loop_id) }}">
301
+ <input type="hidden" name="token" value="{{ token }}">
302
+ <button type="submit" class="danger">End the process now</button>
303
+ </form>
304
+ {% endif %}
305
+ </div>
306
+ {% if log_tail %}<pre class="log" tabindex="0" aria-label="Last lines the process printed">{{ log_tail }}</pre>{% endif %}
307
+ </div>
308
+ </section>
309
+
310
+ {% set brief = manifest.brief %}
311
+ {% if brief.goal or brief.audience or brief.bar or brief.path %}
312
+ <h2>The brief{% if brief.path %}<small>{{ file_link(brief.path, 'read all of it') }}</small>{% endif %}</h2>
313
+ <section class="brief card">
314
+ {% if brief.goal %}<div><p class="eyebrow">Goal</p><p>{{ brief.goal }}</p></div>{% endif %}
315
+ {% if brief.audience %}<div><p class="eyebrow">For</p><p>{{ brief.audience }}</p></div>{% endif %}
316
+ {% if brief.bar %}<div><p class="eyebrow">Done when</p><p>{{ brief.bar }}</p></div>{% endif %}
317
+ </section>
318
+ {% endif %}
319
+ {% if manifest.outputs %}
320
+ <p class="note">Makes: {% for output in manifest.outputs %}{{ file_link(output.path, output.name) }}{{ ', ' if not loop.last }}{% endfor %}</p>
321
+ {% endif %}
322
+ {% if manifest.workspace or manifest.state %}
323
+ <p class="note">{% if manifest.workspace %}Runs on {% if manifest.workspace.branch %}branch <code>{{ manifest.workspace.branch }}</code>{% endif %}{% if manifest.workspace.worktree %} in <code>{{ manifest.workspace.worktree }}</code>{% endif %}. {% endif %}{% if manifest.state %}Hands from round to round: {% for item in manifest.state %}{{ file_link(item.path, item.name) }}{{ ', ' if not loop.last }}{% endfor %}.{% endif %}</p>
324
+ {% endif %}
325
+
326
+ {% if graph %}
327
+ <h2>How a round runs<small>{% if shown %}coloured as round {{ shown.number }} stands{% else %}no round yet{% endif %}</small></h2>
328
+ <div class="card">
329
+ <div class="scroll"><svg class="round" viewBox="0 0 {{ graph.width }} {{ graph.height }}" role="img" aria-label="The steps of one round, left to right: {{ graph.nodes|map(attribute='name')|join(', ') }}">
330
+ <defs><marker id="tip" viewBox="0 0 8 8" refX="6" refY="4" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="tip" d="M0 0 L8 4 L0 8 z"/></marker></defs>
331
+ {% for edge in graph.edges %}<path class="edge" d="{{ edge }}" marker-end="url(#tip)"/>{% endfor %}
332
+ {% if graph.back %}<path class="back" d="{{ graph.back }}" marker-end="url(#tip)"/><text class="backlabel" x="{{ graph.width // 2 }}" y="{{ graph.height - 16 }}">next round, until the verdict says done</text>{% endif %}
333
+ {% for node in graph.nodes %}
334
+ <g class="node {{ node.state }}">
335
+ <title>{{ node.name }}: {{ node.state }}{% if node.runs > 1 %}, {{ node.runs }} runs{% endif %}{% if node.findings %}, {{ node.findings }} open finding(s){% endif %}</title>
336
+ <rect class="box" x="{{ node.x }}" y="{{ node.y }}" width="{{ graph.node_w }}" height="{{ graph.node_h }}" rx="10"/>
337
+ <rect class="bar" x="{{ node.x + 7 }}" y="{{ node.y + 9 }}" width="3" height="{{ graph.node_h - 18 }}" rx="1.5"/>
338
+ <text class="kind" x="{{ node.x + 18 }}" y="{{ node.y + 18 }}">{{ node.kind }}</text>
339
+ <text class="name" x="{{ node.x + 18 }}" y="{{ node.y + 35 }}">{{ node.name|truncate(18, True, '…', 0) }}</text>
340
+ {% if node.runs > 1 %}<text class="count" x="{{ node.x + graph.node_w - 12 }}" y="{{ node.y + 18 }}">×{{ node.runs }}</text>{% endif %}
341
+ {% if node.findings %}<g class="badge"><circle cx="{{ node.x + graph.node_w - 5 }}" cy="{{ node.y + 5 }}" r="11"/><text x="{{ node.x + graph.node_w - 5 }}" y="{{ node.y + 9 }}">{{ node.findings }}</text></g>{% endif %}
342
+ </g>
343
+ {% endfor %}
344
+ </svg></div>
345
+ <p class="legend"><span><i class="mark ok"></i>finished</span><span><i class="mark running"></i>running</span><span><i class="mark failed"></i>failed</span><span><i class="mark skipped"></i>skipped</span><span><i class="mark"></i>not run</span><span><i class="mark find">3</i>open material findings</span></p>
346
+ </div>
347
+ {% endif %}
348
+
349
+ {% if report.problems or report.warnings %}
350
+ <h2>What the protocol check finds<small>the same as <code>loopcaptain validate</code> in the loop's repository</small></h2>
351
+ <div class="card"><ul class="plain">
352
+ {% for line in report.problems %}<li><span class="tag bad">problem</span><span class="body">{{ line }}</span></li>{% endfor %}
353
+ {% for line in report.warnings %}<li><span class="tag">warning</span><span class="body">{{ line }}</span></li>{% endfor %}
354
+ </ul></div>
355
+ {% endif %}
356
+ {% endif %}
357
+ {% endblock %}
@@ -0,0 +1,108 @@
1
+ {% extends "base.html" %}
2
+ {% set page = 'overview' %}
3
+ {% block title %}{% if waiting %}({{ waiting|length }}) {% endif %}Loops · Loopcaptain{% endblock %}
4
+ {% block body_attrs %} data-pulse-url="{{ url_for('overview_pulse') }}" data-pulse="{{ pulse }}"{% endblock %}
5
+ {% block main %}
6
+ <p class="eyebrow">Where every loop stands</p>
7
+ <h1>Loops</h1>
8
+ {% if error %}<p class="flash bad" role="alert">{{ error }}</p>{% endif %}
9
+
10
+ {% if views %}
11
+ {% set running = views|selectattr('state')|selectattr('state.status', 'equalto', 'running')|list %}
12
+ {% set troubled = views|selectattr('state')|selectattr('state.status', 'in', ['stalled', 'failed'])|list %}
13
+ {% set here = url_for('overview') %}
14
+ <section class="kpis" aria-label="Totals">
15
+ <div class="kpi"><b>{{ views|length }}</b><span>loop{{ 's' if views|length != 1 }} in {{ project_count }} project{{ 's' if project_count != 1 }}</span></div>
16
+ <div class="kpi{{ ' hot' if waiting }}"><b>{{ waiting|length }}</b><span>wait{{ 's' if waiting|length == 1 }} for your answer</span></div>
17
+ <div class="kpi{{ ' live' if running }}"><b>{{ running|length }}</b><span>running now</span></div>
18
+ <div class="kpi{{ ' alarm' if troubled }}"><b>{{ troubled|length }}</b><span>stalled or failed</span></div>
19
+ <div class="kpi"><b>{{ total_usd|usd or '$0' }}</b><span>reported cost of all rounds</span></div>
20
+ </section>
21
+
22
+ <nav class="tabs" aria-label="Parts of this page">
23
+ <a href="{{ here }}"{% if tab == 'loops' %} aria-current="page"{% endif %}>All loops<span class="n">{{ views|length }}</span></a>
24
+ <a href="{{ here }}?tab=waiting"{% if tab == 'waiting' %} aria-current="page"{% endif %}>Waiting for you<span class="n{{ ' hot' if waiting }}">{{ waiting|length }}</span></a>
25
+ </nav>
26
+
27
+ {% if tab == 'waiting' %}
28
+ {% if not waiting %}<p class="empty tabbed">Nothing waits for you. A loop that needs you shows here with its question.</p>{% endif %}
29
+ {% for view in waiting %}
30
+ <section class="ask" aria-label="Question from {{ view.title }}">
31
+ <dl class="from">
32
+ <div><dt>Project</dt><dd>{{ view.repo.key }}</dd></div>
33
+ <div><dt>Loop</dt><dd><a href="{{ url_for('loop_page', repo_key=view.repo.key, loop_id=view.loop_id) }}">{{ view.title }}</a></dd></div>
34
+ <div><dt>Asked</dt><dd>{% if view.state.question.round %}round {{ view.state.question.round }} · {% endif %}{{ view.state.question.asked|ago }}{% if view.state.question.blocks %} · it stands still until you answer{% endif %}</dd></div>
35
+ </dl>
36
+ <p class="q">{% if view.state.question.needs_person %}<b>A step needs you.</b> {% endif %}{{ view.state.question.text }}</p>
37
+ {% if view.state.question.options %}
38
+ <form class="choices" method="post" action="{{ url_for('loop_reply', repo_key=view.repo.key, loop_id=view.loop_id) }}">
39
+ <input type="hidden" name="token" value="{{ token }}">
40
+ {% for option in view.state.question.options %}<button type="submit" name="choice" value="{{ option.id }}">{{ option.label }}</button>{% endfor %}
41
+ </form>
42
+ {% endif %}
43
+ <form class="say" method="post" action="{{ url_for('loop_reply', repo_key=view.repo.key, loop_id=view.loop_id) }}">
44
+ <input type="hidden" name="token" value="{{ token }}">
45
+ <textarea name="text" required aria-label="Your answer to {{ view.title }}" placeholder="Your answer. The loop reads it at its next round."></textarea>
46
+ <button type="submit">Send answer</button>
47
+ </form>
48
+ </section>
49
+ {% endfor %}
50
+ {% else %}
51
+ <form class="filters" method="get" action="{{ here }}" role="search" aria-label="Filter the loops">
52
+ <label><span class="sr">Find</span><input type="search" name="q" value="{{ filters.q }}" placeholder="Find a loop, a project, a goal"></label>
53
+ <label><span class="sr">Status</span><select name="status">
54
+ <option value="">every status</option>
55
+ {% for name in statuses %}<option value="{{ name }}"{% if filters.status == name %} selected{% endif %}>{{ name }}</option>{% endfor %}
56
+ </select></label>
57
+ <label class="tick"><input type="checkbox" name="finished" value="1"{% if filters.finished %} checked{% endif %}> show finished loops</label>
58
+ <button type="submit" class="quiet">Filter</button>
59
+ {% if filters.q or filters.status or filters.finished %}<a href="{{ here }}">clear</a>{% endif %}
60
+ </form>
61
+ {% if not projects %}<p class="empty tabbed">No loop matches{% if hidden %}; {{ hidden }} finished loop{{ 's' if hidden != 1 }} folded away{% endif %}.</p>{% else %}
62
+ <div class="card flush tabbed">
63
+ <table class="loops">
64
+ <thead><tr>
65
+ <th scope="col">Loop</th><th scope="col">Status</th><th scope="col">What it is for</th><th scope="col" class="r" title="A square per round, darker for more findings left open; a green dot for none; a ring while it runs">Rounds</th><th scope="col" class="r">Open findings</th><th scope="col" class="r">Time</th><th scope="col" class="r">Cost</th><th scope="col">Last sign of life</th><th scope="col"><span class="sr">Open</span></th>
66
+ </tr></thead>
67
+ {% for project in projects %}
68
+ <tbody>
69
+ <tr class="project">
70
+ <th scope="rowgroup" colspan="9"><span class="eyebrow">Project</span><b>{{ project.repo.key }}</b><span class="where">{{ project.place }}{% if project.branch %} · branch {{ project.branch }}{% endif %} · {{ project.views|length }} loop{{ 's' if project.views|length != 1 }}</span>{% if project.waiting %}<a class="waits" href="{{ here }}?tab=waiting">{{ project.waiting }} wait{{ 's' if project.waiting == 1 }} for you</a>{% endif %}</th>
71
+ </tr>
72
+ {% for view in project.views %}
73
+ <tr class="is-{{ view.state.status if view.state else 'failed' }}">
74
+ <td class="name"><a href="{{ url_for('loop_page', repo_key=view.repo.key, loop_id=view.loop_id) }}">{{ view.title }}</a><span class="id">{{ view.loop_id }}</span></td>
75
+ {% if view.state %}
76
+ {% set s = view.state %}
77
+ <td><span class="pill {{ s.status }}" title="{{ status_text[s.status] }}">{{ s.status }}</span>{% if s.question and s.status != 'waiting' %}<span class="asks" title="it has a question for you">asks</span>{% endif %}{% set signs = no_progress(s) %}{% if signs %}<span class="stuck" title="{{ signs|join('; ') }}">no progress?</span>{% endif %}</td>
78
+ <td class="goal"><span>{{ s.manifest.brief.goal|truncate(150) }}</span></td>
79
+ <td class="r num">{{ s.rounds|length }}{% if s.manifest.round_cap %}<span class="grey"> / {{ s.manifest.round_cap }}</span>{% endif %}{% set trail = trail_of(s) %}{% if trail %}<span class="trail" role="img" aria-label="{{ trail|map(attribute='label')|join('; ') }}">{% for cell in trail %}<i class="{{ cell.look }}" title="{{ cell.label }}"></i>{% endfor %}</span>{% endif %}</td>
80
+ <td class="r num">{{ s.open_findings|length or '' }}</td>
81
+ <td class="r num">{{ s.minutes|minutes }}</td>
82
+ <td class="r num">{{ s.cost_usd|usd }}</td>
83
+ <td class="grey">{{ s.last_event|ago }}{% if view.problems %} · {{ view.problems|length }} unreadable line{{ 's' if view.problems|length != 1 }}{% endif %}</td>
84
+ {% else %}
85
+ <td><span class="pill failed">unreadable</span></td>
86
+ <td class="goal" colspan="6">{{ view.error }}</td>
87
+ {% endif %}
88
+ <td class="go" aria-hidden="true">Open<i></i></td>
89
+ </tr>
90
+ {% endfor %}
91
+ </tbody>
92
+ {% endfor %}
93
+ </table>
94
+ </div>
95
+ {% if hidden %}<p class="note">{{ hidden }} finished loop{{ 's' if hidden != 1 }} folded away. <a href="{{ here }}?finished=1{% if filters.q %}&amp;q={{ filters.q|urlencode }}{% endif %}">Show {{ 'them' if hidden != 1 else 'it' }}</a>.</p>{% endif %}
96
+ {% endif %}
97
+ {% endif %}
98
+ {% else %}
99
+ <section class="start-here" aria-label="Get started">
100
+ <p class="lead">{% if not config.source %}No loop to show yet, and no list of repositories to watch.{% else %}None of the repositories in <code>{{ config.source.name }}</code> has a <code>.loops/</code> folder yet.{% endif %} Copy one of these into your coding agent:</p>
101
+ <div class="prompts">
102
+ <div class="card"><p class="eyebrow">Build a loop</p><pre class="prompt" tabindex="0">{{ prompts['build-a-loop'] }}</pre></div>
103
+ <div class="card"><p class="eyebrow">Watch the loops on this machine</p><pre class="prompt" tabindex="0">{{ prompts['watch-my-loops'] }}</pre></div>
104
+ </div>
105
+ <p class="note">Or see it first with an invented loop that costs nothing: <code>loopcaptain demo</code>, then reload. To watch your own repositories, list them under <code>roots:</code> or <code>repos:</code> in <code>~/.config/loopcaptain/config.yaml</code>, or run <code>loopcaptain watch &lt;folder&gt;</code>. <a href="{{ url_for('connect') }}">How a loop connects</a>.</p>
106
+ </section>
107
+ {% endif %}
108
+ {% endblock %}
@@ -0,0 +1,17 @@
1
+ {% extends "base.html" %}
2
+ {% block title %}{{ loop_id }} cannot be read · Loopcaptain{% endblock %}
3
+ {% block where %}{{ repo.key }} / {{ loop_id }}{% endblock %}
4
+ {% block main %}
5
+ <p class="crumb"><a href="{{ url_for('overview') }}">Loops</a> / {{ repo.key }} / {{ loop_id }}</p>
6
+ <h1>{{ loop_id }} cannot be read</h1>
7
+ <p class="meta"><span class="pill failed">unreadable</span><span>the board shows nothing it cannot read; this is what is wrong</span></p>
8
+ <div class="card">
9
+ <ul class="plain">{% for problem in problems %}<li><span class="body">{{ problem }}</span></li>{% endfor %}</ul>
10
+ </div>
11
+ <h2>How to fix it</h2>
12
+ <ol class="steps">
13
+ <li>Open <code>.loops/{{ loop_id }}/loop.json</code> in the loop's repository and correct the field named above. The loop writes this file: fix the code that writes it, not only the file.</li>
14
+ <li>Check the folder: <code>loopcaptain validate .</code> in that repository lists every problem and warning.</li>
15
+ <li>Reload this page. It reads the files again on every request.</li>
16
+ </ol>
17
+ {% endblock %}