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_term.py ADDED
@@ -0,0 +1,493 @@
1
+ """A full-window terminal dashboard as a list of text lines. frame() is a pure function of the data dict made by
2
+ dashboard.dashboard_data: no I/O, no clock, no terminal query. Every returned line has exactly `cols` visible cells
3
+ (every printed character counts as one cell: wide characters in data become '?'). Cutting and padding happen on plain
4
+ text first; colour wraps whole cells afterwards, so colour never changes a width."""
5
+ from __future__ import annotations
6
+
7
+ import textwrap
8
+ import unicodedata
9
+
10
+ from .plain import paint
11
+
12
+ MIN_COLS, MIN_ROWS = 100, 24
13
+ LEFT_W, RIGHT_W = 36, 30
14
+ STATUS = {"done": ("✓", "Complete", "green"), "running": ("▶", "In progress", "cyan"),
15
+ "blocked": ("✗", "Blocked", "red"), "pending": ("○", "Pending", "dim")}
16
+ KIND = {"heading": "value", "goal": "dim", "good": "green", "bad": "red", "note": "yellow", "wait": "dim"}
17
+ # J13/9: one colour, one meaning, on every screen. "bold" is only the Now headline; "value" is bright white, not bold.
18
+ COLOUR_MEANING = {"green": "done or ok", "cyan": "running now", "yellow": "warning", "red": "blocked or failed",
19
+ "magenta": "cost and usage", "blue": "model name", "dim": "label, border, pending or zero",
20
+ "value": "a value the eye lands on", "bold": "the one headline"}
21
+ # J13/17: every (colour, use) pair of the screen. A colour has one meaning; a plain fact stays white (no colour).
22
+ _USES = {
23
+ "green": ["Login Pro/Max", "Status Complete", "Progress bar fill", "Files Added", "Lines +", "Git and Checks OK",
24
+ "Size This run", "Phase ✓", "Live: the task is complete"],
25
+ "cyan": ["Status In progress", "Phase ▶", "Live: test run", "State word", "Spinner turning"],
26
+ "yellow": ["Login not checked", "Time over 2x the median", "Risk high", "Tries 2 or more", "Try on the last try",
27
+ "Questions", "Warnings", "Retries", "Last commit age when stalled", "Usage bar over 75%",
28
+ "Data stale", "No change for minutes", "Spinner no change", "Live: note", "Live: a wait over 60 s (dim)"],
29
+ "red": ["Login failed", "Status Blocked", "Risk crit", "Error text", "Blocked", "Files Deleted", "Lines -",
30
+ "Git and Checks fail", "Usage bar over 90%", "Phase ✗", "Live: an error line", "Spinner stopped"],
31
+ "magenta": ["Resets", "Usage bars", "Cost"],
32
+ "blue": ["Model", "Detail model", "Usage model names"],
33
+ "dim": ["Labels and borders", "Status Pending", "Phase ○", "Risk low", "Waiting", "Error empty", "Zero counts",
34
+ "Live: step verbs"],
35
+ "value": ["Running task row", "Counts the eye lands on", "Next step"],
36
+ "bold": ["Now headline"],
37
+ }
38
+ COLOUR_USES = [(c, COLOUR_MEANING[c], u) for c, uses in _USES.items() for u in uses]
39
+ RISK = {"critical": ("crit", "red"), "high": ("high", "yellow"), "medium": ("med", None), "low": ("low", "dim")}
40
+ SPIN_STYLE = {"run": "cyan", "stale": "yellow", "stopped": "red"}
41
+
42
+
43
+ def clean(x) -> str:
44
+ """A data value as safe one-cell-wide text: no control characters, no wide or combining characters."""
45
+ return "".join("?" if unicodedata.east_asian_width(c) in "WF" or unicodedata.combining(c) else c
46
+ for c in str(x if x is not None else "") if c.isprintable())
47
+
48
+
49
+ def cut(s: str, w: int) -> str:
50
+ return s if len(s) <= w else s[:w - 3] + "..." if w >= 3 else s[:max(w, 0)]
51
+
52
+
53
+ def fit(s: str, w: int) -> str:
54
+ """Plain text cut to w cells ('...' at the end) and padded to w."""
55
+ return cut(s, w).ljust(max(w, 0))
56
+
57
+
58
+ def segs(line) -> list:
59
+ """A line is a str or a list of str / (text, style) pieces."""
60
+ return [(line, None)] if isinstance(line, str) else [(p, None) if isinstance(p, str) else p for p in line]
61
+
62
+
63
+ def fit_segs(line, w: int) -> list:
64
+ parts = segs(line)
65
+ if sum(len(t) for t, _ in parts) > w:
66
+ keep, out = max(w - 3, 0), []
67
+ for t, st in parts:
68
+ if keep <= 0:
69
+ break
70
+ out.append((t[:keep], st))
71
+ keep -= len(t[:keep])
72
+ out.append(("..."[:w], parts[-1][1]))
73
+ parts = out
74
+ return parts + [(" " * (w - sum(len(t) for t, _ in parts)), None)]
75
+
76
+
77
+ def render(parts, color: bool) -> str:
78
+ """Paint whole cells; a style may be several names ('bold yellow')."""
79
+ out = []
80
+ for t, st in parts:
81
+ for name in (st or "").split() if color else ():
82
+ t = paint(t, name, True)
83
+ out.append(t)
84
+ return "".join(out)
85
+
86
+
87
+ def box(title: str, lines: list, w: int, h: int, color: bool, note=None) -> list[str]:
88
+ """A box of exactly w cells by h rows; lines that do not fit are cut off at the bottom. note = (text, style)
89
+ sits at the right end of the title bar when it fits."""
90
+ title = cut(title, w - 6)
91
+ if note and note[0] and w - 7 - len(title) - len(note[0]) >= 1:
92
+ top = [("┌─ ", "dim"), (title, None), (" " + "─" * (w - 7 - len(title) - len(note[0])) + " ", "dim"),
93
+ note, (" ┐", "dim")]
94
+ else:
95
+ top = [("┌─ ", "dim"), (title, None), (" " + "─" * (w - 5 - len(title)) + "┐", "dim")]
96
+ out = [render(top, color)]
97
+ for line in (list(lines) + [""] * h)[:h - 2]:
98
+ out.append(render([("│ ", "dim"), *fit_segs(line, w - 4), (" │", "dim")], color))
99
+ return out + [render([("└" + "─" * (w - 2) + "┘", "dim")], color)]
100
+
101
+
102
+ def num(x):
103
+ """A share clamped to 0..1, or None."""
104
+ try:
105
+ return min(max(float(x), 0.0), 1.0) if x is not None else None
106
+ except (TypeError, ValueError):
107
+ return None
108
+
109
+
110
+ def count(x) -> int:
111
+ try:
112
+ return int(x)
113
+ except (TypeError, ValueError):
114
+ return 0
115
+
116
+
117
+ def bar(share, n: int = 10) -> str:
118
+ s = num(share) or 0.0
119
+ k = n if s >= 1 else min(n - 1, int(s * n)) # a full bar only at 100%
120
+ return "█" * k + "░" * (n - k) + f" {round(s * 100)}%"
121
+
122
+
123
+ def level_bar(share, n: int = 10) -> list:
124
+ """A usage bar as segments; the filled blocks are magenta, yellow over 75%, red over 90%."""
125
+ s = num(share) or 0.0
126
+ k, pct = max(round(s * n), 1 if s > 0 else 0), round(s * 100) # a small value still shows one block
127
+ return [("█" * k, "red" if pct > 90 else "yellow" if pct > 75 else "magenta"), ("░" * (n - k) + f" {pct}%", None)]
128
+
129
+
130
+ def bar_segs(share, n: int = 10) -> list:
131
+ """The progress bar (same text as bar()) as segments; the fill is green."""
132
+ t = bar(share, n)
133
+ k = t.count("█")
134
+ return [(t[:k], "green"), (t[k:], None)]
135
+
136
+
137
+ def alert(n: int, style: str):
138
+ return str(n), (style if n > 0 else "dim")
139
+
140
+
141
+ def wrap2(text: str, w: int) -> list[str]:
142
+ """At most two lines; the second ends with '...' when there is more."""
143
+ lines = textwrap.wrap(text, max(w, 1))
144
+ return lines if len(lines) <= 2 else [lines[0], lines[1] + "..."]
145
+
146
+
147
+ TONE = {"good": "green", "wait": "yellow", "bad": "red"}
148
+
149
+
150
+ def try_style(tries: int, last: int):
151
+ """White on the first tries, yellow on the last one."""
152
+ return "yellow" if last and tries >= last else None
153
+
154
+
155
+ def login_cell(v: str):
156
+ """('not checked', yellow) until the check ran; green for a paid plan; red when the check failed."""
157
+ if v in ("", "—", "not checked"):
158
+ return "not checked", "yellow"
159
+ return v, "green" if v.lower().startswith(("pro", "max")) else "red"
160
+
161
+
162
+ def spin_cell(d: dict):
163
+ """The spinner text and its colour, or None."""
164
+ sp = d.get("spin")
165
+ return (sp["text"], SPIN_STYLE[sp["kind"]]) if sp and sp["text"] and sp["kind"] in SPIN_STYLE else None
166
+
167
+
168
+ def ticking(sec) -> str:
169
+ sec = max(0, int(sec))
170
+ return f"{sec // 3600}h{sec % 3600 // 60:02d}m" if sec >= 3600 else f"{sec // 60}m{sec % 60:02d}s"
171
+
172
+
173
+ def asof_note(d: dict, first: bool):
174
+ """J13/11: the note in a panel title. A stopped run shows 'stopped 12:50' on every panel; a live run shows the age
175
+ of the data in the first panel only, yellow with 'stale' over 30 s; no source shows a dash. No key: no note."""
176
+ a = d["asof"]
177
+ if a is None:
178
+ return None
179
+ if a["stopped"]:
180
+ return (f"stopped {a['stopped']}", "dim")
181
+ if not first:
182
+ return None
183
+ if a["age"] is None:
184
+ return ("data —", "dim")
185
+ age = f"{a['age']}s" if a["age"] < 60 else f"{a['age'] // 60}m"
186
+ return (f"data {age} old stale", "yellow") if a["age"] > 30 else (f"data {age} old", "dim")
187
+
188
+
189
+ def left_panel(d: dict, w: int, h: int, color: bool) -> list[str]:
190
+ p, pr, ny, now, size = d["project"], d["progress"], d["needs_you"], d["now"], d["size"]
191
+
192
+ def row(label, value, style=None):
193
+ return [[f"{label:<9}: ", (value, style)]] if value != "" else []
194
+
195
+ def text(t, style=None, indent=""):
196
+ return [[(indent + x, style)] for x in wrap2(t, w - 4 - len(indent))]
197
+
198
+ def rule(title, style=None, spin=None):
199
+ """The first line of a titled group: the title in a line of the full inner width. spin = (text, style) before it."""
200
+ pre = [(spin[0], spin[1]), (" ", None)] if spin else []
201
+ return [("── ", "dim"), *pre, (title, style), (" " + "─" * max(w - 8 - len(title) - (len(spin[0]) + 1 if spin else 0), 0), "dim")]
202
+ status = row("Project", p["name"]) + row("Branch", p["branch"]) # the state lives in the title and the bar
203
+ if p["stale_min"] > 0:
204
+ status.append([(f"No change for {p['stale_min']} min.", {"warn": "yellow", "bad": "red"}.get(p["stale"], "dim"))])
205
+ groups = [status + row("Started", p["started"][:16]) + row("Elapsed", p["elapsed"])]
206
+ if ny:
207
+ n = max(count(pr.get("questions")), len(ny))
208
+ groups.append([rule("! Questions for you", "yellow"),
209
+ f"{n} questions wait for you." if n > 1 else "1 question waits for you.",
210
+ *text(ny[0]["title"]), [("Run: ", None), ("ap answer", "value")]])
211
+ if now and (now["title"] or now["model"] or count(now["n"])):
212
+ n, total, tries = count(now["n"]), count(pr.get("total")), count(now["attempt"])
213
+ g = [rule("Now", spin=spin_cell(d))]
214
+ # J13/10: the one bold headline of the screen: 'Task 7 of 17: title'
215
+ head = (f"Task {n} of {total}" if total else f"Task {n}") if n else ""
216
+ g += [[(x, "bold")] for x in wrap2(f"{head}: {now['title']}" if head and now["title"] else head or now["title"],
217
+ w - 4)]
218
+ if n and tries:
219
+ g.append([(f"Try {tries}" + (f" of {now['max_attempts']}" if count(now["max_attempts"]) else ""),
220
+ try_style(tries, count(now["max_attempts"])))])
221
+ tt = []
222
+ if n: # J13/18: the task time ticks from the local clock; 'data Ns old' is the truth check
223
+ sp, since = d.get("spin"), now.get("since")
224
+ t = ticking(sp["clock"] - float(since)) if sp and since else now["time"]
225
+ if t:
226
+ tt = [[f"{'Task time':<9}: ", t, *([(f" data {sp['age']}s old", "yellow" if sp["age"] > 30 else "dim")]
227
+ if sp and sp["age"] is not None and sp["kind"] != "stopped" else [])]]
228
+ groups.append(g + tt + row("Model", now["model"], "blue"))
229
+ if p["next"]:
230
+ groups.append([rule("Next"), *text(p["next"])])
231
+ if size:
232
+ g = [rule("Size")]
233
+ if size.get("code") is not None:
234
+ g += row("Code", f"{count(size['code']):,} lines")
235
+ if size.get("tests") is not None:
236
+ lines_, cases = f"{count(size['tests']):,} lines", f"{count(size.get('test_count')):,} tests"
237
+ fits = len(lines_) + len(cases) + 13 <= w - 4
238
+ g += row("Tests", f"{lines_}, {cases}" if fits else lines_) + ([] if fits else [" " * 11 + cases])
239
+ if size.get("docs") is not None:
240
+ g += row("Docs", f"{count(size['docs']):,} lines")
241
+ if size.get("run_lines") is not None:
242
+ g += row("This run", f"+{count(size['run_lines']):,} lines", "green") + [" " * 11 + f"{count(size.get('run_files'))} files"]
243
+ groups.append(g)
244
+ if p["last_commit"]:
245
+ groups.append([rule("Last commit"), *text(p["last_commit"]),
246
+ *([[(f"{p['last_commit_age']} ago", "yellow" if p["commit_stalled"] else "dim")]]
247
+ if p["last_commit_age"] else [])])
248
+ login, err, resets = login_cell(p["login"]), p["last_error"] or "—", p["resets"] or "—"
249
+ g = [rule("Agent")] + row("Runner", p["runner"] or "—") + row("Login", *login)
250
+ groups.append(g + row("Resets", resets, "magenta" if resets != "—" else "dim")
251
+ + row("Error", err, "dim" if err == "—" else "red"))
252
+ rows = []
253
+ for g in groups:
254
+ if rows and len(rows) + len(g) > h - 2:
255
+ continue # a group that does not fit does not stop the smaller groups after it
256
+ rows += g
257
+ ph = d["phases"]
258
+ room = h - 2 - len(rows) - 1 # the last group takes the rows that are left, after its rule line
259
+ if ph and room >= min(len(ph), 2): # all phases when they fit, else a last row '… N more'
260
+ k = len(ph) if room >= len(ph) else room - 1
261
+ at = next((i for i, x in enumerate(ph) if x["status"] == "running"),
262
+ next((i for i, x in enumerate(ph) if x["status"] != "done"), len(ph) - 1))
263
+ start = min(max(at - 2, 0), len(ph) - k)
264
+ g = [rule("Phases")]
265
+ for x in ph[start:start + k]:
266
+ sym, _, st = STATUS.get(x["status"], STATUS["pending"])
267
+ g.append([(sym, st), f" {x['id']} ", (f"{x['done']}/{x['total']}", "value" if x["done"] else "dim")])
268
+ if k < len(ph):
269
+ g.append([(f"… {len(ph) - k} more", "dim")])
270
+ rows += g
271
+ return box("Project", rows, w, h, color, asof_note(d, True))
272
+
273
+
274
+ def cnt(n: int, style=None):
275
+ """A number: its style when above 0, dim at 0."""
276
+ return str(n), (style if n > 0 else "dim")
277
+
278
+
279
+ def right_column(d: dict, w: int, h: int, color: bool) -> list[str]:
280
+ pr, u, f, why = d["progress"], d["usage"], d["files"], d["blocked_why"]
281
+ prog = [bar_segs(d["share"]),
282
+ [cnt(count(pr.get("done")), "value"), " done · ", cnt(count(pr.get("working")), "value"), " working"],
283
+ [alert(count(pr.get("blocked")), "red"), " blocked · ", cnt(count(pr.get("waiting")), "dim"), " waiting"],
284
+ [(f"{count(pr.get('phases_done'))} of {count(pr.get('phases_total'))}", "value"), " phases"]]
285
+ use = []
286
+ for label, key in (("5h", "five"), ("Week", "week"), ("Context", "context")):
287
+ if num(u.get(key)) is not None:
288
+ use.append([f"{label:<8}", *level_bar(u.get(key))])
289
+ if key == "five" and u["reset_in"]:
290
+ use.append([(f"Resets in {u['reset_in']}", "dim")])
291
+ if u["tokens_text"]:
292
+ use.append(f"Tokens {u['tokens_text']}")
293
+ use += [[(f"{m['name']:<9}", "blue"), f"{m['tokens_text']:>6} {round((num(m['share']) or 0) * 100):>3}%"]
294
+ for m in u["models"]]
295
+ if u["cost_text"]:
296
+ use.append(["Cost ", (u["cost_text"], "magenta")])
297
+ use.append(f"Rate {u['rate'] or '—'}")
298
+ issues = [] # the counts of blocked and questions live in Progress and in the question group
299
+ if why:
300
+ issues += [[(x, "red")] for x in wrap2(f"{why['id']}: {why['why']}", w - 4)]
301
+ issues += [["Warnings ", alert(count(pr.get("warnings")), "yellow")]]
302
+ issues += [" " + cut(f"{x['id']} {x['why']}", w - 6) for x in pr["warned_why"][:3]] # the reason of each warning
303
+ if not pr["warned_why"]:
304
+ issues += [" " + x for x in wrap2(" ".join(pr["warned"]), w - 6)] # the tasks that have a warning
305
+ issues += [["Retries ", cnt(count(pr.get("retries")), "yellow")]]
306
+ issues += [[f"{k:<10}", (v, "green" if v == "OK" else "red")] for k, v in d["health"].items() if k != "words" and v]
307
+ issues += [[(x, "red")] for x in wrap2(d["health"]["words"], w - 4)]
308
+ boxes = [("Progress", prog), ("Usage", use)]
309
+ if f is not None:
310
+ boxes.append(("Files", [["Added ", cnt(count(f.get("added")), "green")],
311
+ ["Modified ", cnt(count(f.get("modified")))],
312
+ ["Deleted ", cnt(count(f.get("deleted")), "red")],
313
+ *([["Lines + ", cnt(count(f["plus"]), "green")], ["Lines - ", cnt(count(f["minus"]), "red")],
314
+ ["Tests + ", cnt(count(f["tests"]))]] if f.get("plus") is not None else [])]))
315
+ boxes.append(("Issues", issues))
316
+ tip = d["next"][0][0] if d["next"] else ""
317
+ out, left = [], h
318
+ for i, (title, lines) in enumerate(boxes):
319
+ if left < 3:
320
+ break
321
+ need = min(len(lines) + 2, left)
322
+ if i == len(boxes) - 1 or left - need < 3: # the last box that fits takes the spare rows
323
+ need = left
324
+ if title == "Issues" and tip and len(lines) < need - 2: # the next step in the last row, only with a free row
325
+ lines = lines + [""] * (need - 3 - len(lines)) + [[("Run: ", None), (tip, "value")]]
326
+ out += box(title, lines, w, need, color, asof_note(d, False))
327
+ left -= need
328
+ return out + [" " * w] * left
329
+
330
+
331
+ def tasks_panel(d: dict, w: int, h: int, color: bool) -> list[str]:
332
+ iw, tasks = w - 4, d["tasks"]
333
+ rt = iw - 37 >= 13 # J13/4: Risk and Tries columns when the title keeps 13 cells
334
+ detail = rt and iw - 56 >= 13 or not rt and iw - 44 >= 14
335
+ tw = iw - 25 - (12 if rt else 0) - (19 if detail else 0)
336
+
337
+ def line(n, title, status, time, det, style=None, sstyle=None, risk="", tries="", tstyle=None, rstyle=None,
338
+ ystyle=None, model=0):
339
+ parts = [(n.rjust(3), style), (" ", None), (fit(title, tw), style), (" ", None), (fit(status, 13), sstyle),
340
+ (" ", None), (fit(time, 6), tstyle or style)]
341
+ parts += [(" ", None), (fit(risk, 5), rstyle or style), (" ", None), (tries.rjust(5), ystyle or style)] if rt else []
342
+ if detail: # the model name is blue, the rest of the cell keeps the row style
343
+ cell = fit(det, 18)
344
+ parts += [(" ", None), (cell[:model], "blue"), (cell[model:], style)]
345
+ return parts
346
+ rows = [line("#", "Task", "Status", "Time", "Detail", "dim", "dim", "Risk", "Tries")]
347
+ other = d["project"]["other"] # J13/15: the time in no task, so the tasks and this row add up to Elapsed
348
+ room = h - 3 - (1 if other else 0)
349
+ focus = next((i for i, t in enumerate(tasks) if t["status"] == "running"),
350
+ next((i for i, t in enumerate(tasks) if t["status"] != "done"), len(tasks) - 1))
351
+ start = min(max(focus - room // 2, 0), max(len(tasks) - room, 0))
352
+ for t in tasks[start:start + room]:
353
+ sym, word, st = STATUS.get(t["status"], ("○", t["status"] or "Pending", "dim"))
354
+ risk, rstyle = RISK.get(t["risk"], (t["risk"][:4] or "—", None))
355
+ rows.append(line(t["n"], t["title"], f"{sym} {word}", t["time"], t["detail"],
356
+ "value" if t["status"] == "running" else None, st, risk, t["tries"] or "—",
357
+ "yellow" if t["slow"] else None, rstyle, "yellow" if count(t["tries"]) >= 2 else None,
358
+ 0 if t["detail"].endswith(" tries") and " · " not in t["detail"]
359
+ else len(t["detail"].partition(" · ")[0])))
360
+ if other:
361
+ rows.append(line("", "Other: decide, research, docs, audit, waits", "", other, "", "dim", "dim"))
362
+ return box("Tasks", rows, w, h, color, asof_note(d, False))
363
+
364
+
365
+ def live_panel(d: dict, w: int, h: int, color: bool) -> list[str]:
366
+ rows = []
367
+ for e in d["live"][-(h - 2):]:
368
+ if e["kind"] == "blank":
369
+ rows.append("")
370
+ elif e["kind"] in ("heading", "goal"):
371
+ rows.append([(e["text"], KIND[e["kind"]])])
372
+ else:
373
+ took = e.get("took")
374
+ text = fit(e["text"], w - 4 - 7 - 7) if took else e["text"]
375
+ if e["kind"] == "step" and "test" in text.lower(): # a test run is cyan
376
+ body = [(text, "cyan")]
377
+ elif e["kind"] == "step": # the verb is grey, what it works on stays white
378
+ k = len(text.split(" ", 1)[0]) + 1
379
+ body = [(text[:k], "dim"), (text[k:], None)]
380
+ else:
381
+ body = [(text, "dim yellow" if e["kind"] == "wait" else KIND.get(e["kind"]))]
382
+ rows.append([(fit(e["at"], 5), "dim"), " ", *body, *([(" " + took.rjust(6), "dim")] if took else [])])
383
+ return box("Live output", rows or [[("No output yet.", "dim")]], w, h, color, asof_note(d, False))
384
+
385
+
386
+ def status_bar(d: dict, cols: int, color: bool) -> str:
387
+ """J13/13: project │ branch │ state │ task │ progress │ left │ 5h │ model │ blocked │ tok │ cost │ age. Under 100
388
+ columns only the core items stay; then items drop from the right, the data age last. Wide cells are never wrapped."""
389
+ p, pr, u, now, a = d["project"], d["progress"], d["usage"], d["now"] or {}, d["asof"]
390
+ wide = cols >= MIN_COLS
391
+ total, active, blocked, five = count(pr.get("total")), count(pr.get("active")), count(pr.get("blocked")), num(u.get("five"))
392
+ task = f"Task {active}/{total}" if active > 0 else f"Done {count(pr.get('done'))}/{total}"
393
+ if wide and active > 0 and count(now.get("attempt")) and count(now.get("max_attempts")):
394
+ task += f" Try {count(now['attempt'])}/{count(now['max_attempts'])}"
395
+ st = p["state"]
396
+ state = ("Stopped", "dim") if p["status"] and p["status"] != "running" or a and a["stopped"] else \
397
+ (({"Commit": "Merge", "Block": "Wait"}.get(st, st)), "cyan")
398
+ sp = spin_cell(d) # J13/18: the spinner sits before the state word; a stopped run says so instead of the word
399
+ state_cells = [(sp[0], sp[1]), (" ", None), state] if sp and d["spin"]["kind"] != "stopped" else \
400
+ [sp] if sp else [state]
401
+ age = (f"{a['age']}s" if a["age"] < 60 else f"{a['age'] // 60}m", "yellow" if a["age"] > 30 else "dim") \
402
+ if a and a["age"] is not None and not a["stopped"] else ("", None)
403
+ q = count(pr.get("questions"))
404
+ blk = [(f"Blocked {blocked}", "red" if blocked > 0 else "dim")] + ([(" ", None), (f"Q {q}", "red" if q > 0 else "dim")] if wide else [])
405
+ # (cells, core): core items stay under 100 columns
406
+ items = [([(p["name"], None)], True), ([(p["branch"], None)], False), (state_cells, True),
407
+ ([(task, "value")] if total else [], True), (bar_segs(d["share"]) if pr else [], True),
408
+ ([(f"Left{':' if p['eta'].startswith('after') else ''} {p['eta']}", None)] if p["eta"] else [], True),
409
+ ([(f"5h {round(five * 100)}%", "yellow" if five >= 0.75 else None)] if five is not None else [], False),
410
+ ([(now.get("model") or p["model"], "blue")], False), (blk if pr else [], True),
411
+ ([(f"Tok {u['tokens_text']}", None)] if u["tokens_text"] else [], False),
412
+ ([(u["cost_text"], "magenta")], False), ([age], True)]
413
+ items = [(c, core) for c, core in items if (core or wide) and any(t for t, _ in c)]
414
+ tail = [items.pop()] if age[0] else [] # the data age drops last
415
+ while sum(sum(len(t) for t, _ in c) + 3 for c, _ in items + tail) - 2 > cols and len(items) > 1:
416
+ # from the right; the items that stay under 100 columns go last
417
+ items.pop(next((i for i in range(len(items) - 1, 0, -1) if not items[i][1]), len(items) - 1))
418
+ line = [(" ", None)]
419
+ for c, _ in items + tail:
420
+ line += ([(" │ ", "dim")] if len(line) > 1 else []) + c
421
+ return render(fit_segs(line, cols), color)
422
+
423
+
424
+ def normalize(data) -> dict:
425
+ """Every key present, every string clean, odd types replaced: the panels need no checks."""
426
+ data = data if isinstance(data, dict) else {}
427
+
428
+ def sub(k):
429
+ return data[k] if isinstance(data.get(k), dict) else {}
430
+
431
+ def listed(k):
432
+ return data[k] if isinstance(data.get(k), (list, tuple)) else []
433
+
434
+ def rows(k, keys):
435
+ return [{key: clean(r.get(key)) for key in keys} for r in data.get(k) or [] if isinstance(r, dict)]
436
+ p, u, pr = sub("project"), sub("usage"), sub("progress")
437
+ try:
438
+ cost_text = f"${float(u.get('cost')):.2f}" if u.get("cost") else ""
439
+ except (TypeError, ValueError):
440
+ cost_text = ""
441
+ total = count(pr.get("total"))
442
+ pct = pr.get("pct")
443
+ share = pct / 100 if isinstance(pct, (int, float)) else count(pr.get("done")) / total if total else 0
444
+ models = [m for m in u.get("models") or [] if isinstance(m, dict)]
445
+ def one(x, keys):
446
+ return {k: clean(x.get(k)) for k in keys} if isinstance(x, dict) else None
447
+ now = one(sub("project").get("now"), ("n", "title", "attempt", "max_attempts", "time", "model", "since"))
448
+ sp = sub("spin")
449
+ spin = ({"kind": clean(sp.get("kind")), "text": clean(sp.get("text")), "age": count(sp["age"]) if isinstance(
450
+ sp.get("age"), (int, float)) else None, "clock": float(sp["clock"]) if isinstance(sp.get("clock"), (int, float))
451
+ else 0.0} if sp else None)
452
+ size = data.get("size") if isinstance(data.get("size"), dict) else None
453
+ return {
454
+ "project": {**{k: clean(p.get(k)) for k in ("name", "branch", "model", "started", "status", "tone", "stale",
455
+ "state_words", "state", "elapsed", "eta", "next", "last_commit",
456
+ "last_commit_age", "other", "runner", "login", "resets",
457
+ "last_error")},
458
+ "stale_min": count(p.get("stale_min")), "commit_stalled": bool(p.get("commit_stalled"))},
459
+ "spin": spin,
460
+ "progress": {**pr, "warned": [clean(x) for x in (pr.get("warned") if isinstance(pr.get("warned"), list) else [])][:6],
461
+ "warned_why": [x for x in (one(r, ("id", "why")) for r in pr.get("warned_why") or []) if x]},
462
+ "share": num(share) or 0.0, "now": now, "size": size,
463
+ "phases": [{"id": clean(r.get("id")), "status": clean(r.get("status")), "done": count(r.get("done")),
464
+ "total": count(r.get("total"))} for r in listed("phases") if isinstance(r, dict)],
465
+ "next": [(clean(t[0]), clean(t[1]) if len(t) > 1 else "") for t in listed("next")
466
+ if isinstance(t, (list, tuple)) and t],
467
+ "needs_you": [x for x in (one(r, ("id", "title")) for r in data.get("needs_you") or []) if x],
468
+ "blocked_why": one(data.get("blocked_why"), ("id", "why")),
469
+ "tasks": [{**r, "slow": bool(o.get("slow"))} for r, o in zip(
470
+ rows("tasks", ("n", "id", "title", "status", "time", "detail", "risk", "tries")),
471
+ [x for x in data.get("tasks") or [] if isinstance(x, dict)])],
472
+ "live": rows("live", ("at", "text", "kind", "took")),
473
+ "usage": {**u, "tokens_text": clean(u.get("tokens_text")), "cost_text": cost_text,
474
+ "reset_in": clean(u.get("reset_in")), "rate": clean(u.get("rate")),
475
+ "models": [{"name": clean(m.get("name"))[:9], "tokens_text": clean(m.get("tokens_text")),
476
+ "share": m.get("share")} for m in models]},
477
+ "asof": ({"age": count(a["age"]) if isinstance(a.get("age"), (int, float)) else None,
478
+ "stopped": clean(a.get("stopped"))} if (a := sub("asof")) else None),
479
+ "files": data["files"] if isinstance(data.get("files"), dict) else None,
480
+ "health": {"Git": clean(sub("health").get("git")), "Checks": clean(sub("health").get("checks")),
481
+ "words": clean(sub("health").get("words"))},
482
+ }
483
+
484
+
485
+ def frame(data: dict, cols: int, rows: int, color: bool = False) -> list[str]:
486
+ if cols < MIN_COLS or rows < MIN_ROWS:
487
+ raise ValueError(f"the window must be at least {MIN_COLS}x{MIN_ROWS}")
488
+ d = normalize(data)
489
+ body = rows - 1
490
+ top = max(8, round(body * 0.45))
491
+ mid = tasks_panel(d, cols - LEFT_W - RIGHT_W, top, color) + live_panel(d, cols - LEFT_W - RIGHT_W, body - top, color)
492
+ return [a + b + c for a, b, c in zip(left_panel(d, LEFT_W, body, color), mid,
493
+ right_column(d, RIGHT_W, body, color))] + [status_bar(d, cols, color)]