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.
- autopilot/__init__.py +4 -0
- autopilot/__main__.py +6 -0
- autopilot/backends/__init__.py +159 -0
- autopilot/backends/claude_cli.py +368 -0
- autopilot/backends/command.py +90 -0
- autopilot/cli.py +1614 -0
- autopilot/commands/__init__.py +0 -0
- autopilot/commands/preflight.py +131 -0
- autopilot/config.py +155 -0
- autopilot/context.py +414 -0
- autopilot/dash_term.py +493 -0
- autopilot/dash_web.py +347 -0
- autopilot/dashboard.py +410 -0
- autopilot/decisions.py +85 -0
- autopilot/deploy.py +67 -0
- autopilot/docs.py +301 -0
- autopilot/doctor.py +478 -0
- autopilot/gate.py +383 -0
- autopilot/github_util.py +42 -0
- autopilot/gitops.py +251 -0
- autopilot/growth.py +85 -0
- autopilot/handoff.py +27 -0
- autopilot/layers.py +134 -0
- autopilot/log_scrub.py +11 -0
- autopilot/mcp.py +104 -0
- autopilot/notify.py +98 -0
- autopilot/orchestrator.py +2214 -0
- autopilot/plain.py +238 -0
- autopilot/plan.py +320 -0
- autopilot/proc.py +176 -0
- autopilot/prompts/auditor.md +39 -0
- autopilot/prompts/decide.md +38 -0
- autopilot/prompts/docs.md +48 -0
- autopilot/prompts/fixer.md +26 -0
- autopilot/prompts/onboard.md +43 -0
- autopilot/prompts/plan_writer.md +40 -0
- autopilot/prompts/replanner.md +42 -0
- autopilot/prompts/research.md +25 -0
- autopilot/prompts/resume.md +10 -0
- autopilot/prompts/retry.md +8 -0
- autopilot/prompts/review.md +40 -0
- autopilot/prompts/system.md +16 -0
- autopilot/prompts/task.md +78 -0
- autopilot/prompts/triage.md +30 -0
- autopilot/prompts/unstick.md +37 -0
- autopilot/prompts/verify.md +32 -0
- autopilot/repomap.py +71 -0
- autopilot/report.py +633 -0
- autopilot/rights.py +184 -0
- autopilot/schemas.py +42 -0
- autopilot/state.py +298 -0
- autopilot/templates/__pycache__/cloudflare_apply.cpython-313.pyc +0 -0
- autopilot/templates/agent/BRAIN.md +21 -0
- autopilot/templates/agent/CLAUDE.md.snippet +10 -0
- autopilot/templates/agent/DECISIONS.md +5 -0
- autopilot/templates/agent/HANDOFF.md +3 -0
- autopilot/templates/agent/gitignore +10 -0
- autopilot/templates/agent/ide_rules.md +8 -0
- autopilot/templates/agent/plan.yaml +48 -0
- autopilot/templates/agent/project.yaml +259 -0
- autopilot/templates/cloudflare_apply.py +211 -0
- autopilot/templates/deploy.sh +72 -0
- autopilot/templates/email_verify.py +144 -0
- autopilot/templates/infra/cloudflare.yaml +11 -0
- autopilot/templates/presets/docker.yaml +4 -0
- autopilot/templates/presets/generic.yaml +3 -0
- autopilot/templates/presets/go.yaml +6 -0
- autopilot/templates/presets/java.yaml +4 -0
- autopilot/templates/presets/node.yaml +7 -0
- autopilot/templates/presets/python.yaml +6 -0
- autopilot/templates/presets/rust.yaml +5 -0
- autopilot/templates/presets/static.yaml +3 -0
- autopilot/upgrade.py +192 -0
- dev_autopilot-0.4.0.dist-info/METADATA +148 -0
- dev_autopilot-0.4.0.dist-info/RECORD +79 -0
- dev_autopilot-0.4.0.dist-info/WHEEL +5 -0
- dev_autopilot-0.4.0.dist-info/entry_points.txt +3 -0
- dev_autopilot-0.4.0.dist-info/licenses/LICENSE +21 -0
- 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)]
|