dev-autopilot 0.4.0__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 (79) hide show
  1. autopilot/__init__.py +4 -0
  2. autopilot/__main__.py +6 -0
  3. autopilot/backends/__init__.py +159 -0
  4. autopilot/backends/claude_cli.py +368 -0
  5. autopilot/backends/command.py +90 -0
  6. autopilot/cli.py +1614 -0
  7. autopilot/commands/__init__.py +0 -0
  8. autopilot/commands/preflight.py +131 -0
  9. autopilot/config.py +155 -0
  10. autopilot/context.py +414 -0
  11. autopilot/dash_term.py +493 -0
  12. autopilot/dash_web.py +347 -0
  13. autopilot/dashboard.py +410 -0
  14. autopilot/decisions.py +85 -0
  15. autopilot/deploy.py +67 -0
  16. autopilot/docs.py +301 -0
  17. autopilot/doctor.py +478 -0
  18. autopilot/gate.py +383 -0
  19. autopilot/github_util.py +42 -0
  20. autopilot/gitops.py +251 -0
  21. autopilot/growth.py +85 -0
  22. autopilot/handoff.py +27 -0
  23. autopilot/layers.py +134 -0
  24. autopilot/log_scrub.py +11 -0
  25. autopilot/mcp.py +104 -0
  26. autopilot/notify.py +98 -0
  27. autopilot/orchestrator.py +2214 -0
  28. autopilot/plain.py +238 -0
  29. autopilot/plan.py +320 -0
  30. autopilot/proc.py +176 -0
  31. autopilot/prompts/auditor.md +39 -0
  32. autopilot/prompts/decide.md +38 -0
  33. autopilot/prompts/docs.md +48 -0
  34. autopilot/prompts/fixer.md +26 -0
  35. autopilot/prompts/onboard.md +43 -0
  36. autopilot/prompts/plan_writer.md +40 -0
  37. autopilot/prompts/replanner.md +42 -0
  38. autopilot/prompts/research.md +25 -0
  39. autopilot/prompts/resume.md +10 -0
  40. autopilot/prompts/retry.md +8 -0
  41. autopilot/prompts/review.md +40 -0
  42. autopilot/prompts/system.md +16 -0
  43. autopilot/prompts/task.md +78 -0
  44. autopilot/prompts/triage.md +30 -0
  45. autopilot/prompts/unstick.md +37 -0
  46. autopilot/prompts/verify.md +32 -0
  47. autopilot/repomap.py +71 -0
  48. autopilot/report.py +633 -0
  49. autopilot/rights.py +184 -0
  50. autopilot/schemas.py +42 -0
  51. autopilot/state.py +298 -0
  52. autopilot/templates/__pycache__/cloudflare_apply.cpython-313.pyc +0 -0
  53. autopilot/templates/agent/BRAIN.md +21 -0
  54. autopilot/templates/agent/CLAUDE.md.snippet +10 -0
  55. autopilot/templates/agent/DECISIONS.md +5 -0
  56. autopilot/templates/agent/HANDOFF.md +3 -0
  57. autopilot/templates/agent/gitignore +10 -0
  58. autopilot/templates/agent/ide_rules.md +8 -0
  59. autopilot/templates/agent/plan.yaml +48 -0
  60. autopilot/templates/agent/project.yaml +259 -0
  61. autopilot/templates/cloudflare_apply.py +211 -0
  62. autopilot/templates/deploy.sh +72 -0
  63. autopilot/templates/email_verify.py +144 -0
  64. autopilot/templates/infra/cloudflare.yaml +11 -0
  65. autopilot/templates/presets/docker.yaml +4 -0
  66. autopilot/templates/presets/generic.yaml +3 -0
  67. autopilot/templates/presets/go.yaml +6 -0
  68. autopilot/templates/presets/java.yaml +4 -0
  69. autopilot/templates/presets/node.yaml +7 -0
  70. autopilot/templates/presets/python.yaml +6 -0
  71. autopilot/templates/presets/rust.yaml +5 -0
  72. autopilot/templates/presets/static.yaml +3 -0
  73. autopilot/upgrade.py +192 -0
  74. dev_autopilot-0.4.0.dist-info/METADATA +148 -0
  75. dev_autopilot-0.4.0.dist-info/RECORD +79 -0
  76. dev_autopilot-0.4.0.dist-info/WHEEL +5 -0
  77. dev_autopilot-0.4.0.dist-info/entry_points.txt +3 -0
  78. dev_autopilot-0.4.0.dist-info/licenses/LICENSE +21 -0
  79. dev_autopilot-0.4.0.dist-info/top_level.txt +1 -0
autopilot/dash_web.py ADDED
@@ -0,0 +1,347 @@
1
+ """J9: the browser page of the dashboard. One self-contained HTML string from the plain dict of
2
+ dashboard.py::dashboard_data. No external resource and no library. Every dynamic value goes through html.escape."""
3
+ from __future__ import annotations
4
+
5
+ from html import escape as e
6
+
7
+ CSS = """
8
+ :root{color-scheme:light dark;--bg:#f3f4f6;--panel:#fff;--panel-2:#e9ecf0;--line:#b9c0ca;--text:#0b0f16;--muted:#3d4654;
9
+ --good:#0f5c24;--bad:#a01b14;--warn:#7a4700;--count:#075a72;--use:#8a1f7a;--model:#1d4fa8;--hl:#dcedf4;--row:3px;--cell:6px;
10
+ --s1:4px;--s2:8px;--s3:12px;--s4:16px;--r1:6px;--r2:10px;
11
+ --t11:11px;--t12:12px;--t13:13px;--t14:14px;--t16:16px;--t18:18px;--t24:24px;
12
+ --font:ui-monospace,Consolas,"Cascadia Mono",monospace}
13
+ @media (prefers-color-scheme:dark){:root{--bg:#0b0e13;--panel:#141922;--panel-2:#1b212c;--line:#3a4454;
14
+ --text:#f4f7fa;--muted:#b6c0ce;--good:#62d67f;--bad:#ff8e86;--warn:#f2c754;--count:#72deee;--use:#e58ae0;--model:#8ab4ff;--hl:#173440}}
15
+ *{box-sizing:border-box}
16
+ body{margin:0;background:var(--bg);color:var(--text);font:var(--t13)/1.4 var(--font);font-variant-numeric:tabular-nums}
17
+ main{padding:var(--s4) var(--s4) 64px}
18
+ #off{margin:0;padding:var(--s2) var(--s4);background:var(--warn);color:var(--bg);font-size:var(--t13);font-weight:600}
19
+ h1{font-size:var(--t18);margin:0 0 var(--s4);overflow-wrap:anywhere}
20
+ h2{font-size:var(--t11);text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin:0 0 var(--s2);font-weight:600}
21
+ .grid{display:grid;gap:var(--s4);grid-template-columns:minmax(0,1fr)}
22
+ .grid section,.next{background:var(--panel);border:1px solid var(--line);border-radius:var(--r2);padding:var(--s4);min-width:0}
23
+ .col{display:contents}
24
+ @media (max-width:699px){#tasks th:nth-child(n+4),#tasks td:nth-child(n+4){display:none}}
25
+ #progress{order:1}#project{order:2}#tasks{order:3}#live{order:4}#usage{order:5}#files{order:6}#issues{order:7}
26
+ @media (min-width:700px){.grid{grid-template-columns:repeat(2,minmax(0,1fr))}#tasks,#live{grid-column:1/-1}}
27
+ @media (min-width:1100px){.grid{grid-template-columns:minmax(260px,1fr) minmax(0,3.2fr) minmax(280px,1.1fr);align-items:start}
28
+ .col{display:grid;gap:var(--s4);min-width:0;grid-template-columns:minmax(0,1fr)}#tasks,#live{grid-column:auto}}
29
+ dl{margin:0}
30
+ dl div{display:flex;justify-content:space-between;gap:var(--s3);padding:var(--row) 0}
31
+ dt{color:var(--muted)}dd{margin:0;font-weight:600;text-align:right;overflow-wrap:anywhere;min-width:0}
32
+ .big{font-size:var(--t24);font-weight:700;line-height:1.2}
33
+ .row{display:flex;justify-content:space-between;gap:var(--s3);padding:var(--row) 0}
34
+ .bar{height:8px;background:var(--panel-2);border:1px solid var(--line);border-radius:var(--r1);overflow:hidden;margin:var(--s1) 0 var(--s2)}
35
+ .bar i{display:block;height:100%;background:var(--use)}
36
+ .bar.ok i{background:var(--good)}.bar.warn i{background:var(--warn)}.bar.bad i{background:var(--bad)}
37
+ h3{font-size:var(--t11);text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin:var(--s3) 0 var(--s1);font-weight:600}
38
+ .w{color:var(--warn);font-weight:600}.z{color:var(--muted)}.t-good{color:var(--good)}.t-wait{color:var(--warn)}.t-bad{color:var(--bad)}
39
+ #needs{background:var(--panel);border:2px solid var(--warn);border-radius:var(--r2);padding:var(--s4);margin-bottom:var(--s4)}
40
+ #needs p{margin:0 0 var(--s2)}#needs ul{margin:0 0 var(--s2);padding-left:var(--s4)}
41
+ .box{overflow:auto;max-height:max(320px,calc(48vh - 110px))}
42
+ table{width:100%;border-collapse:collapse;font-size:var(--t13)}
43
+ th{font-size:var(--t11);text-transform:uppercase;letter-spacing:.08em;color:var(--muted);text-align:left;font-weight:600;
44
+ padding:var(--cell) var(--s2);border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--panel)}
45
+ td:first-child{white-space:nowrap}
46
+ td{padding:var(--cell) var(--s2);border-bottom:1px solid var(--line);vertical-align:top;overflow-wrap:anywhere}
47
+ tr[aria-current=true]{background:var(--hl)}
48
+ .done{color:var(--good)}.running{color:var(--count)}.blocked{color:var(--bad)}.pending{color:var(--muted)}
49
+ .st{white-space:nowrap;font-weight:600}
50
+ #live .box{max-height:max(240px,calc(40vh - 110px))}
51
+ .l{display:flex;gap:var(--s3)}
52
+ .l time{color:var(--muted);flex:none;min-width:5ch}
53
+ .l span{min-width:0;overflow-wrap:anywhere}
54
+ .l.heading span{font-weight:700}.l.goal span{color:var(--muted)}.l.wait span{color:var(--warn);opacity:.6}.l.test span{color:var(--count)}.l .v{color:var(--muted);font-weight:400}.l .took{margin-left:auto;color:var(--muted)}.l.good span{color:var(--good)}
55
+ .l.bad span{color:var(--bad)}.l.note span{color:var(--warn)}.l.blank{height:var(--s2)}
56
+ .mg{color:var(--use);font-weight:600}.bl{color:var(--model)}.bad{color:var(--bad);font-weight:600}.good{color:var(--good);font-weight:600}
57
+ .next{margin-top:var(--s4)}.next ul{margin:0;padding-left:var(--s4)}.next li{padding:var(--row) 0}
58
+ code{color:var(--count);font-weight:600}
59
+ details{margin-top:var(--s4)}summary{cursor:pointer;color:var(--muted);font-size:var(--t12)}
60
+ pre{white-space:pre-wrap;overflow-wrap:anywhere;background:var(--panel);border:1px solid var(--line);
61
+ border-radius:var(--r2);padding:var(--s4);font:var(--t13)/1.5 var(--font)}
62
+ footer{position:fixed;left:0;right:0;bottom:0;display:flex;flex-wrap:wrap;gap:var(--s1) var(--s4);align-items:center;
63
+ background:var(--panel);border-top:1px solid var(--line);padding:var(--s2) var(--s4);font-size:var(--t12);font-weight:600}
64
+ footer .bar{width:96px;margin:0;display:inline-block;vertical-align:middle}
65
+ footer .m{color:var(--muted);font-weight:400}
66
+ .spin{display:inline-block;width:.9em;height:.9em;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;
67
+ vertical-align:-.1em;margin-right:var(--s2);animation:sp .8s steps(8) infinite}
68
+ .spin.run{color:var(--count)}.spin.stale{color:var(--warn)}.spin.stopped{color:var(--bad)}.spin.paused{animation-play-state:paused}
69
+ @keyframes sp{to{transform:rotate(360deg)}}
70
+ @media (prefers-reduced-motion:reduce){.spin{animation:none}}
71
+ """
72
+
73
+ SCRIPT = ("<script type=module>const end=x=>x.scrollHeight-x.scrollTop-x.clientHeight<8,"
74
+ "see=()=>{const r=document.querySelector('tr[aria-current]'),b=r&&r.closest('[data-keep]');"
75
+ "if(b)b.scrollTop=r.offsetTop-b.clientHeight/2;const l=document.querySelector('#live [data-keep]');"
76
+ "if(l)l.scrollTop=l.scrollHeight};see();"
77
+ "setInterval(async()=>{const off=document.getElementById('off');try{const r=await "
78
+ "fetch(location.href,{cache:'no-store'});if(!r.ok)throw 0;const d=new DOMParser().parseFromString("
79
+ "await r.text(),'text/html');const o=document.querySelector('details')?.open,m0=document.querySelector('.spin')?.dataset.m,"
80
+ "s=[...document.querySelectorAll('[data-keep]')].map(x=>x.id=='out'&&end(x)?1e9:x.scrollTop);"
81
+ "document.querySelector('main').replaceWith(d.querySelector('main'));"
82
+ "const sp=document.querySelector('.spin');if(sp&&sp.dataset.m==m0)sp.classList.add('paused');"
83
+ "const n=document.querySelector('details');if(n&&o)n.open=true;"
84
+ "document.querySelectorAll('[data-keep]').forEach((x,i)=>{x.scrollTop=s[i]||0});"
85
+ "document.title=d.title;off.hidden=true}catch(x){off.hidden=false}},REFRESH_MS)</script>")
86
+
87
+ RISK = {"critical": "bad", "high": "w", "medium": "", "low": "z"} # J13/17: crit red, high yellow, med white, low grey
88
+ PAGE_TITLE = "Autopilot builds your software project while you are away" # J15: the plain-words opening
89
+ WORDS = {"done": ("Complete", "✓"), "running": ("In progress", "▶"), "blocked": ("Blocked", "✗"),
90
+ "pending": ("Pending", "○")}
91
+
92
+
93
+ def _v(v) -> float:
94
+ try:
95
+ return min(1.0, max(0.0, float(v)))
96
+ except (TypeError, ValueError):
97
+ return 0.0
98
+
99
+
100
+ def _bar(v, label: str, cls: str = "") -> str:
101
+ p = round(_v(v) * 100)
102
+ return f'<div class="bar {cls}" role="img" aria-label="{e(label)} {p} percent"><i style="width:{p}%"></i></div>'
103
+
104
+
105
+ def _dl(rows) -> str:
106
+ """rows of (label, value) or (label, value, class); a class colours the value."""
107
+ out = []
108
+ for k, v, *c in rows:
109
+ if str(v or "") != "":
110
+ cls = f' class="{c[0]}"' if c and c[0] else ""
111
+ out.append(f"<div><dt>{e(k)}</dt><dd{cls}>{e(str(v))}</dd></div>")
112
+ return "<dl>" + "".join(out) + "</dl>"
113
+
114
+
115
+ def _sec(i: str, title: str, body: str) -> str:
116
+ return f'<section id="{i}" aria-labelledby="{i}-h"><h2 id="{i}-h">{e(title)}</h2>{body}</section>'
117
+
118
+
119
+ def _tasks(tasks, other: str = "") -> str:
120
+ rows = []
121
+ for t in tasks:
122
+ st = t.get("status") or "pending"
123
+ word, sym = WORDS.get(st, WORDS["pending"])
124
+ cur = ' aria-current="true"' if st == "running" else ""
125
+ risk, tries = str(t.get("risk") or "—"), t.get("tries") if t.get("tries") not in (None, "") else "—"
126
+ model, sep, rest = str(t.get("detail", "")).partition(" · ")
127
+ if not sep and model.endswith(" tries"):
128
+ model, rest = "", model
129
+ rows.append(f'<tr{cur}><td>{e(str(t.get("n", "")))}</td><td>{e(str(t.get("title", "")))}</td>'
130
+ f'<td class="st {e(st if st in WORDS else "pending")}">{sym} {word}</td>'
131
+ f'<td class="{"w" if t.get("slow") else ""}">{e(str(t.get("time", "")))}</td>'
132
+ f'<td class="{RISK.get(risk, "")}">{e(risk)}</td>'
133
+ f'<td class="{"w" if _i(tries) >= 2 else ""}">{e(str(tries))}</td>'
134
+ f'<td><span class="bl">{e(model)}</span>{" · " if sep else ""}{e(rest)}</td></tr>')
135
+ if other: # J13/15: tasks + this row = Elapsed
136
+ rows.append(f'<tr><td></td><td>Other: decide, research, docs, audit, waits</td><td></td><td>{e(other)}</td><td></td><td></td><td></td></tr>')
137
+ head = "".join(f"<th scope=col>{h}</th>" for h in ("#", "Task", "Status", "Time", "Risk", "Tries", "Detail"))
138
+ return f'<div class="box" data-keep tabindex="0"><table><thead><tr>{head}</tr></thead><tbody>{"".join(rows)}</tbody></table></div>'
139
+
140
+
141
+ def _live(rows) -> str:
142
+ if not rows:
143
+ return "<p>No output yet.</p>"
144
+ out = []
145
+ for r in rows:
146
+ k = r.get("kind") or "step"
147
+ k = k if k in ("heading", "goal", "step", "good", "bad", "note", "blank", "wait") else "step"
148
+ text = str(r.get("text", ""))
149
+ body = e(text)
150
+ if k == "step": # J13/17: a test run is cyan; otherwise the verb is grey and what it works on stays white
151
+ if "test" in text.lower():
152
+ k = "step test"
153
+ else:
154
+ verb, sep, rest = text.partition(" ")
155
+ body = f'<b class="v">{e(verb)}</b>{sep}{e(rest)}'
156
+ out.append(f'<div class="l {k}"><time>{e(str(r.get("at", "")))}</time><span>{body}</span>'
157
+ + (f'<span class="took">{e(str(r["took"]))}</span>' if r.get("took") else "") + "</div>"
158
+ if k != "blank" else '<div class="l blank"></div>')
159
+ return f'<div class="box" id="out" data-keep tabindex="0">{"".join(out)}</div>'
160
+
161
+
162
+ def _lvl(v) -> str:
163
+ """A usage bar: magenta, yellow over 75 percent, red over 90 percent."""
164
+ return "bad" if _v(v) > 0.9 else "warn" if _v(v) > 0.75 else "use"
165
+
166
+
167
+ def _i(x) -> int:
168
+ try:
169
+ return int(x or 0)
170
+ except (TypeError, ValueError):
171
+ return 0
172
+
173
+
174
+ def _count(label: str, v, cls: str) -> str:
175
+ """One row with a count; cls is the colour class for a value above 0 (muted at 0)."""
176
+ c = cls if _i(v) > 0 else "z"
177
+ return f'<div class="row"><span>{label}</span><span class="{c}">{_i(v)}</span></div>'
178
+
179
+
180
+ def _group(title: str, body: str) -> str:
181
+ return f"<h3>{e(title)}</h3>{body}" if body else ""
182
+
183
+
184
+ def _asof(a) -> str:
185
+ """J13/11: how old the data is; yellow with 'stale' over 30 s; a stopped run says when; no source is a dash."""
186
+ if not a:
187
+ return ""
188
+ if a.get("stopped"):
189
+ return f'<p class="z">stopped {e(str(a["stopped"]))}</p>'
190
+ age = a.get("age")
191
+ if not isinstance(age, (int, float)):
192
+ return '<p class="z">data —</p>'
193
+ t = f"{int(age)}s" if age < 60 else f"{int(age) // 60}m"
194
+ return f'<p class="w">data {t} old stale</p>' if age > 30 else f'<p class="z">data {t} old</p>'
195
+
196
+
197
+ def _spin(sp) -> str:
198
+ """J13/18: the one running indicator. A ring that turns while the data changes, frozen when stale or stopped;
199
+ data-m is the data time, so the fetch loop can pause the ring when two fetches show the same time."""
200
+ if not sp or sp.get("kind") not in ("run", "stale", "stopped"):
201
+ return ""
202
+ k, text = sp["kind"], str(sp.get("text") or "")
203
+ ring = (f'<span class="spin {k}{"" if k == "run" else " paused"}" data-m="{e(str(sp.get("mtime")))}" role=img '
204
+ f'aria-label="{e(text if k != "run" else "running")}"></span>')
205
+ return ring + (f'<span class="{"w" if k == "stale" else "bad"}">{e(text)} </span>' if k != "run" else "")
206
+
207
+
208
+ def _project(p: dict, total: int, sz, asof=None, spin=None) -> str:
209
+ tone = p.get("tone") if p.get("tone") in ("good", "wait", "bad") else ""
210
+ st = e(str(p.get("status") or ""))
211
+ ring = _spin(spin)
212
+ status = (f'<div class="row"><span class="z">Status</span><span>{ring}<strong class="t-{tone}">{st}</strong></span></div>'
213
+ if tone else f'<div class="row"><span class="z">Status</span><span>{ring}<strong>{st}</strong></span></div>'
214
+ if st else "")
215
+ out = (_asof(asof) + _dl([("Branch", p.get("branch"))]) + status # 'Time left' lives in the bar
216
+ + ("" if p.get("status") == "running" else f'<p>{e(str(p.get("state_words") or ""))}</p>') # not "Writes the code."
217
+ + _dl([("Start", p.get("started")), ("Elapsed", p.get("elapsed"))]))
218
+ if _i(p.get("stale_min")) > 0:
219
+ c = {"warn": "w", "bad": "bad"}.get(p.get("stale"), "z")
220
+ out += f'<p class="{c}">No change for {_i(p.get("stale_min"))} min.</p>'
221
+ nw, body = p.get("now") or {}, ""
222
+ if nw.get("n"):
223
+ body += f'<p>Task {_i(nw["n"])} of {total}</p>'
224
+ if nw.get("title"):
225
+ body += f'<p><strong>{e(str(nw["title"]))}</strong></p>'
226
+ if nw.get("n") and _i(nw.get("attempt")) > 0:
227
+ a, m = _i(nw["attempt"]), _i(nw.get("max_attempts"))
228
+ c = "w" if m and a >= m else "" # white, yellow on the last try
229
+ body += f'<p class="{c}">Try {a}{f" of {m}" if m else ""}</p>'
230
+ if nw.get("n"):
231
+ body += _dl([("Task time", nw.get("time"))])
232
+ body += _dl([("Model", nw.get("model"))])
233
+ out += _group("Now", body if nw else "")
234
+ out += _group("Next", f'<p>{e(str(p["next"]))}</p>' if p.get("next") else "")
235
+ login = str(p.get("login") or "")
236
+ login = "not checked" if login in ("", "—") else login
237
+ err, resets = p.get("last_error") or "—", p.get("resets") or "—"
238
+ out += _group("Agent", _dl([("Runner", p.get("runner") or "—"),
239
+ ("Login", login, "w" if login == "not checked" else
240
+ "good" if login.lower().startswith(("pro", "max")) else "bad"),
241
+ ("Resets", resets, "z" if resets == "—" else "mg"),
242
+ ("Error", err, "z" if err == "—" else "bad")]))
243
+ if sz:
244
+ rows = [("Code", f'{_i(sz.get("code")):,} lines'),
245
+ ("Tests", f'{_i(sz.get("tests")):,} lines, {_i(sz.get("test_count")):,} tests'),
246
+ ("Docs", f'{_i(sz.get("docs")):,} lines')]
247
+ if sz.get("run_lines") is not None:
248
+ rows.append(("This run", f'+{_i(sz["run_lines"]):,} lines', "good"))
249
+ rows.append(("", f'{_i(sz.get("run_files")):,} files'))
250
+ out += _group("Size", _dl(rows))
251
+ if p.get("last_commit"):
252
+ age = (f' <span class="{"w" if p.get("commit_stalled") else "z"}">{e(str(p["last_commit_age"]))} ago</span>'
253
+ if p.get("last_commit_age") else "")
254
+ out += _group("Last commit", f'<p>{e(str(p["last_commit"]))}{age}</p>')
255
+ return out
256
+
257
+
258
+ def render(data: dict, refresh: int = 30, report_md: str = "") -> str:
259
+ p, g, u = data.get("project") or {}, data.get("progress") or {}, data.get("usage") or {}
260
+ tasks, health, files = data.get("tasks") or [], data.get("health") or {}, data.get("files")
261
+ done, total, pct = _i(g.get("done")), _i(g.get("total")), _i(g.get("pct"))
262
+ eta, active = p.get("eta") or "", _i(g.get("active"))
263
+ proj = _project(p, total, data.get("size"), data.get("asof"), data.get("spin"))
264
+
265
+ prog = (f'<div class="big">{pct}%</div>{_bar(pct / 100, "Progress", "ok")}'
266
+ f'<div class="row"><span>{done} done · {_i(g.get("working"))} working</span></div>'
267
+ f'<div class="row"><span><span class="{"bad" if _i(g.get("blocked")) > 0 else ""}">{_i(g.get("blocked"))} blocked</span>'
268
+ f' · <span class="z">{_i(g.get("waiting"))} waiting</span></span></div>'
269
+ f'<div class="row"><span>{_i(g.get("phases_done"))} of {_i(g.get("phases_total"))} phases</span></div>')
270
+
271
+ bars = ""
272
+ for k, lab in (("five", "5-hour window"), ("week", "Week"), ("context", "Context")):
273
+ if u.get(k) is not None:
274
+ bars += (f'<div class="row"><span>{lab}</span><span class="mg">{round(_v(u[k]) * 100)}%</span></div>'
275
+ f'{_bar(u[k], lab, _lvl(u[k]))}')
276
+ if k == "five" and u.get("reset_in"):
277
+ bars += f'<div class="z">Resets in {e(str(u["reset_in"]))}</div>'
278
+ models = "".join(f'<div class="row"><span class="bl">{e(str(m.get("name", "")))}</span>'
279
+ f'<span>{e(str(m.get("tokens_text", "")))} {round(_v(m.get("share")) * 100)}%</span></div>'
280
+ f'{_bar(m.get("share"), str(m.get("name", "")))}' for m in u.get("models") or [])
281
+ usage = (bars + f'<div class="row"><span>Tokens</span><strong>{e(str(u.get("tokens_text") or "0"))}</strong></div>'
282
+ + models + f'<div class="row"><span>Cost</span><strong class="mg">${float(u.get("cost") or 0):.2f}</strong></div>'
283
+ + f'<div class="row"><span>Rate</span><span>{e(str(u.get("rate") or "—"))}</span></div>')
284
+
285
+ bw = data.get("blocked_why") or {}
286
+ issues = _count("Blocked tasks", g.get("blocked"), "bad")
287
+ if bw:
288
+ issues += f'<div class="bad">{e(str(bw.get("id", "")))}: {e(str(bw.get("why", "")))}</div>'
289
+ issues += _count("Questions for you", g.get("questions"), "w") + _count("Gate warnings", g.get("warnings"), "w")
290
+ issues += "".join(f'<div class="w">{e(str(x.get("id", "")))} {e(str(x.get("why", "")))}</div>'
291
+ for x in (g.get("warned_why") or [])[:3])
292
+ issues += _count("Retries", g.get("retries"), "w")
293
+ for lab, k in (("Git", "git"), ("Checks", "checks")):
294
+ if health.get(k):
295
+ issues += (f'<div class="row"><span>{lab}</span><span class="{"good" if health[k] == "OK" else "bad"}">'
296
+ f'{e(str(health[k]))}</span></div>')
297
+ if health.get("words"):
298
+ issues += f'<p class="bad">{e(str(health["words"]))}</p>'
299
+
300
+ def fv(k, cls):
301
+ v = _i((files or {}).get(k))
302
+ return (f'<div class="row"><span class="z">{k.capitalize()}</span>'
303
+ f'<strong class="{cls if v > 0 and cls else "" if v > 0 else "z"}">{v}</strong></div>')
304
+
305
+ # three columns on a wide window; on a narrow one the columns dissolve and the `order` of each panel decides
306
+ right = [_sec("progress", "Progress", prog), _sec("usage", "Usage", usage)]
307
+ if files is not None:
308
+ more = (fv("plus", "good").replace(">Plus<", ">Lines +<") + fv("minus", "bad").replace(">Minus<", ">Lines -<")
309
+ + fv("tests", "").replace(">Tests<", ">Tests +<")) if files.get("plus") is not None else ""
310
+ right.append(_sec("files", "Files", fv("added", "good") + fv("modified", "") + fv("deleted", "bad") + more))
311
+ right.append(_sec("issues", "Issues", issues))
312
+ cols = ([_sec("project", "Project", proj)],
313
+ [_sec("tasks", "Tasks", _tasks(tasks, str(p.get("other") or ""))), _sec("live", "Live output", _live(data.get("live") or []))], right)
314
+ cells = [f'<div class="col">{"".join(c)}</div>' for c in cols]
315
+
316
+ need, needs = data.get("needs_you") or [], ""
317
+ if need:
318
+ k = len(need)
319
+ li = "".join(f"<li>{e(str(x.get('title', '')))}</li>" for x in need)
320
+ needs = (f'<section id="needs" role="status" aria-labelledby="needs-h"><h2 id="needs-h">Questions for you</h2>'
321
+ f'<p><strong>{k} question{" waits" if k == 1 else "s wait"} for you.</strong></p><ul>{li}</ul>'
322
+ f'<p>Run: <code>ap answer</code></p></section>')
323
+
324
+ nxt = "".join(f"<li><code>{e(str(r[0]))}</code> {e(str(r[1]) if len(r) > 1 else '')}</li>"
325
+ for r in data.get("next") or [] if r)
326
+ nxt = f'<section class="next" aria-labelledby="next-h"><h2 id="next-h">What you can do next</h2><ul>{nxt}</ul></section>' if nxt else ""
327
+
328
+ nb = _i(g.get("blocked"))
329
+ five = u.get("five")
330
+ bar = (f'<footer><span>{e(str(p.get("name") or ""))}</span>'
331
+ + (f"<span>Task {active}/{total}</span>" if active > 0 else f"<span>Done {done}/{total}</span>")
332
+ + f'<span>{_bar(pct / 100, "Progress", "ok")}{pct}%</span>'
333
+ + (f"<span>Left{':' if str(eta).startswith('after') else ''} {e(str(eta))}</span>" if eta else "")
334
+ + f'<span class="{"bad" if nb else "z"}">Blocked {nb}</span><span>Tok {e(str(u.get("tokens_text") or "0"))}</span>'
335
+ f'<span>${float(u.get("cost") or 0):.2f}</span>'
336
+ + (f"<span>5h {round(float(five) * 100)}%</span>" if isinstance(five, (int, float)) else "") + "</footer>")
337
+
338
+ rep = f"<details><summary>Full report</summary><pre>{e(report_md)}</pre></details>" if report_md else ""
339
+ ms = int(refresh) * 1000
340
+ return ("<!doctype html><html lang=en><head><meta charset=utf-8>"
341
+ "<meta name=viewport content=\"width=device-width,initial-scale=1\">"
342
+ f"<title>{e(str(data.get('line') or ''))} · {PAGE_TITLE}</title><style>{CSS}</style>"
343
+ f"<noscript><meta http-equiv=refresh content={int(refresh)}></noscript></head><body>"
344
+ "<p id=off hidden>The build stopped, or the status server is off. This page shows the last status. "
345
+ "For the status now, run: ap status</p>"
346
+ f'<main><h1>{e(str(p.get("name") or "Autopilot"))}</h1>{needs}<div class="grid">{"".join(cells)}</div>'
347
+ f"{nxt}{bar}{rep}</main>{SCRIPT.replace('REFRESH_MS', str(ms))}</body></html>")