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/dashboard.py ADDED
@@ -0,0 +1,410 @@
1
+ """J9: the data of the dashboard, one plain dict for both views (the browser page in dash_web.py and the terminal
2
+ window in dash_term.py). It only reads. Every value is a str, int, float, bool, None, list or dict, so a view never
3
+ needs the plan, the state or the log."""
4
+ from __future__ import annotations
5
+
6
+ import datetime as dt
7
+ import re
8
+ import subprocess
9
+ import time
10
+
11
+ from .decisions import problems
12
+ from .plain import SimpleView, ending
13
+ from .report import (MODEL_NAMES, STATE_WORDS, _n, _runtime, _shares, next_steps, open_window, status_line,
14
+ warned_tasks)
15
+
16
+ CODE = {".py", ".js", ".jsx", ".ts", ".tsx", ".go", ".rs", ".java", ".kt", ".swift", ".c", ".h", ".cpp", ".hpp", ".cs",
17
+ ".rb", ".php", ".html", ".css", ".scss", ".vue", ".svelte", ".sh", ".ps1", ".sql"}
18
+ DOCS = {".md", ".rst", ".txt"}
19
+ TEST_FILE = re.compile(r"(^|/)(tests?|__tests__|spec)/|(^|/)test_[^/]*$|_test\.[a-z]+$|\.(test|spec)\.[a-z]+$")
20
+ TEST_CASE = re.compile(r"^\s*(?:async\s+)?def test_|^\s*(?:it|test)\(|^\s*#\[test\]|^func Test", re.M)
21
+ _cache: dict = {} # (root, commit) -> the facts that only change with a commit
22
+
23
+
24
+ def _git(root, *args) -> str:
25
+ try:
26
+ out = subprocess.run(["git", *args], cwd=str(root), capture_output=True, text=True, timeout=5, # noqa: S603, S607
27
+ encoding="utf-8", errors="replace")
28
+ return out.stdout.strip() if out.returncode == 0 else ""
29
+ except (OSError, subprocess.SubprocessError):
30
+ return ""
31
+
32
+
33
+ def _span(start, end) -> str:
34
+ """'4m' between two ISO times, '' when one is missing or the clock went back."""
35
+ try:
36
+ secs = (dt.datetime.fromisoformat(end) - dt.datetime.fromisoformat(start)).total_seconds()
37
+ except (TypeError, ValueError):
38
+ return ""
39
+ return "" if secs < 0 else _dur(secs)
40
+
41
+
42
+ def _dur(secs: float) -> str:
43
+ if secs >= 86400:
44
+ return f"{int(secs // 86400)}d"
45
+ return f"{int(secs // 3600)}h{int(secs % 3600 // 60):02d}m" if secs >= 3600 else f"{max(1, round(secs / 60))}m"
46
+
47
+
48
+ def project_size(root) -> dict:
49
+ """Lines of code, tests and docs in the files that git tracks, and the number of test cases (a count of test
50
+ functions, not a result). Lock files, images and files over 1 MB are not counted, and .agent/ is not the product.
51
+ ponytail: the split is by file type and folder name; an unusual layout can put a file in the wrong group."""
52
+ size = {"code": 0, "tests": 0, "docs": 0, "test_count": 0}
53
+ for rel in _git(root, "ls-files").splitlines()[:5000]:
54
+ ext = "." + rel.rsplit(".", 1)[-1].lower() if "." in rel.rsplit("/", 1)[-1] else ""
55
+ if rel.startswith(".agent/") or ext not in CODE | DOCS:
56
+ continue
57
+ try:
58
+ path = root / rel
59
+ if path.stat().st_size > 1_000_000:
60
+ continue
61
+ text = path.read_text(encoding="utf-8", errors="replace")
62
+ except OSError:
63
+ continue
64
+ lines = text.count("\n") + (1 if text and not text.endswith("\n") else 0)
65
+ if ext in DOCS:
66
+ size["docs"] += lines
67
+ elif TEST_FILE.search(rel.lower()):
68
+ size["tests"] += lines
69
+ size["test_count"] += len(TEST_CASE.findall(text))
70
+ else:
71
+ size["code"] += lines
72
+ return size
73
+
74
+
75
+ def by_commit(root, main: str, started: str) -> dict:
76
+ """The facts that change only with a commit on main: the last commit, the size of the project, what this run
77
+ changed. One git call for each refresh finds the commit; the rest is read again only for a new commit."""
78
+ head = _git(root, "log", "-1", "--format=%h%x09%ct%x09%s", main) or _git(root, "log", "-1", "--format=%h%x09%ct%x09%s")
79
+ sha, _, rest = head.partition("\t")
80
+ stamp, _, subject = rest.partition("\t")
81
+ key = (str(root), sha, started)
82
+ if key not in _cache:
83
+ _cache.clear() # one project, one commit: nothing old is of use
84
+ facts = {"commit": sha, "subject": subject, "stamp": int(stamp) if stamp.isdigit() else 0,
85
+ "size": project_size(root) if sha else None, "files": None, "run": None}
86
+ if sha and started:
87
+ marks = re.findall(r"^([AMD])\t", _git(root, "log", f"--since={started}", "--name-status",
88
+ "--pretty=format:", main), re.M)
89
+ facts["files"] = {"added": marks.count("A"), "modified": marks.count("M"), "deleted": marks.count("D")}
90
+ base = _git(root, "rev-list", "-1", f"--before={started}", main)
91
+ stat = _git(root, "diff", "--shortstat", base, main) if base else ""
92
+ files, plus = re.search(r"(\d+) files? changed", stat), re.search(r"(\d+) insertions?", stat)
93
+ if files:
94
+ gone = re.search(r"(\d+) deletions?", stat)
95
+ diff = _git(root, "diff", "-U0", base, main) # J13/6: tests added = new test functions in the diff
96
+ new = "\n".join(x[1:] for x in diff.splitlines() if x.startswith("+") and not x.startswith("+++"))
97
+ facts["run"] = {"files": int(files.group(1)), "lines": int(plus.group(1)) if plus else 0,
98
+ "removed": int(gone.group(1)) if gone else 0, "tests": len(TEST_CASE.findall(new))}
99
+ facts["files"].update(plus=facts["run"]["lines"], minus=facts["run"]["removed"], tests=facts["run"]["tests"])
100
+ _cache[key] = facts
101
+ return _cache[key]
102
+
103
+
104
+ def live_rows(root, plan, lines: int = 40, live: bool = False) -> list[dict]:
105
+ """The last lines of the simple view as {at, text, kind}; kind: heading, goal, step, good, bad, note, blank."""
106
+ from . import AGENT_DIR
107
+ log = root / AGENT_DIR / "logs" / "autopilot.log"
108
+ ids = plan.all_tasks()
109
+ view = SimpleView({t.id: (i + 1, len(ids), t.title, t.description) for i, t in enumerate(ids)})
110
+ # ponytail: reads the end of the log on each call; read by offset if a log grows to many MB
111
+ raw = log.read_text(encoding="utf-8", errors="replace").splitlines()[-600:] if log.exists() else []
112
+ # J13/8: the time of a step = the session clock of the line that ends it minus the clock of the line that began it
113
+ clock = lambda row: (lambda m: int(m.group(1)) * 60 + int(m.group(2)) if m else None)( # noqa: E731
114
+ re.search(r"· (\d+)m(\d\d)s\s*$", row))
115
+ outs, begun, last = [], None, None
116
+ for row in raw:
117
+ g0, now_s = view.group, clock(row)
118
+ got = view.feed(row)
119
+ if now_s is not None:
120
+ last = now_s
121
+ if g0 and view.group is not g0 and got: # the group g0 ended with the first line
122
+ took = now_s - begun if now_s is not None and begun is not None and now_s >= begun else None
123
+ outs.append((got[0], took))
124
+ got = got[1:]
125
+ if view.group is not g0 and view.group:
126
+ begun = now_s
127
+ outs += [(x, None) for x in got]
128
+ tail = view.close()
129
+ if tail:
130
+ outs.append((tail[0], last - begun if last is not None and begun is not None and last >= begun else None))
131
+ rows = []
132
+ for text, took in outs[-lines:]:
133
+ m = re.match(r"(\d\d:\d\d) (.*)$", text)
134
+ at, body = (m.group(1), m.group(2)) if m else ("", text)
135
+ kind = ("blank" if not text else "goal" if text.startswith("Goal: ") else "heading" if not m else
136
+ "good" if " is complete." in body else
137
+ "bad" if body.startswith(("The checks failed.", "The build stopped.")) else
138
+ "note" if not re.match(r"(Reads|Writes|Changes|Checks|Runs|Searches|Installs|Uses) ", body) else "step")
139
+ rows.append({"at": at, "text": body, "kind": kind,
140
+ **({"took": f"{took // 60}m{took % 60:02d}s"} if took is not None and kind == "step" else {})})
141
+ try: # a tool that runs for over 60 s writes nothing: say so, dim
142
+ quiet = int(time.time() - log.stat().st_mtime)
143
+ except OSError:
144
+ quiet = 0
145
+ if live and raw and quiet > 60 and "▸" in raw[-1]:
146
+ rows.append({"at": "", "text": f"A tool runs for {_dur(quiet)}.", "kind": "wait"})
147
+ return rows
148
+
149
+
150
+ def _first(text, limit: int = 90) -> str:
151
+ line = re.sub(r"^[A-Z]+:\s*", "", " ".join(str(text or "").split())) # e.g. the gate's "PROBLEM: " prefix
152
+ line = re.split(r"(?<=[.!?:])\s", line)[0].rstrip(":")
153
+ return line if len(line) <= limit else line[:limit - 4].rsplit(" ", 1)[0] + " ..."
154
+
155
+
156
+ def data_mtime(root, run: dict) -> float | None:
157
+ """The time of the newest sign of life: the run journal, the state file (and its WAL), the log. None when none has one."""
158
+ from . import AGENT_DIR
159
+ stamps = []
160
+ try:
161
+ stamps.append(dt.datetime.fromisoformat(run["updated_at"]).timestamp())
162
+ except (KeyError, TypeError, ValueError):
163
+ pass
164
+ for rel in ("state.db", "state.db-wal", "logs/autopilot.log"):
165
+ try:
166
+ stamps.append((root / AGENT_DIR / rel).stat().st_mtime)
167
+ except OSError:
168
+ pass
169
+ return max(stamps) if stamps else None
170
+
171
+
172
+ def data_age(root, run: dict) -> int | None:
173
+ """J13/11: seconds since data_mtime. None when there is no time (the view shows a dash, never a guess)."""
174
+ m = data_mtime(root, run)
175
+ return max(0, int(time.time() - m)) if m is not None else None
176
+
177
+
178
+ SPIN = {True: ("⠋⠙⠹⠸⠼⠴⠦⠧", "⏸", "✗"), False: ("|/-\\", "||", "x")} # braille dots, or ASCII when the console is not UTF-8
179
+
180
+
181
+ def is_utf(stream) -> bool:
182
+ """True when the console encoding is UTF-8 (braille spinner); else the ASCII spinner."""
183
+ return "utf" in (getattr(stream, "encoding", "") or "").lower()
184
+
185
+
186
+ def spinner(d: dict, mtime, now: float, prev: dict | None = None, utf: bool = True) -> dict:
187
+ """J13/18: the one running indicator, for both views. The frame moves only when the data file changed since the
188
+ last call (prev = the dict this function returned last time). kind: run (turns), stale (no change for over 30 s:
189
+ frozen, yellow), stopped (the run ended: red, with the end time), or '' (nothing to show)."""
190
+ frames, pause, stop = SPIN[utf]
191
+ a, prev = d.get("asof") or {}, prev or {}
192
+ frame = int(prev.get("frame", 0)) + (1 if prev and mtime != prev.get("mtime") else 0)
193
+ age = max(0, int(now - mtime)) if mtime is not None else a.get("age")
194
+ out = {"frame": frame, "mtime": mtime, "clock": now, "age": age}
195
+ if not a.get("live", True):
196
+ stopped = a.get("stopped") or ""
197
+ return {**out, "kind": "stopped" if stopped else "", "text": f"{stop} stopped {stopped}" if stopped else ""}
198
+ if isinstance(age, (int, float)) and age > 30:
199
+ return {**out, "kind": "stale", "text": f"{pause} no change {age}s" if age < 60 else f"{pause} no change {age // 60}m"}
200
+ return {**out, "kind": "run", "text": frames[frame % len(frames)]}
201
+
202
+
203
+ def dashboard_data(cfg, plan, state, run: dict) -> dict:
204
+ root = cfg.root
205
+ status, tasks = state.status_map(), plan.all_tasks()
206
+ done = sum(1 for t in tasks if status.get(t.id) == "done")
207
+ blocked = sum(1 for t in tasks if status.get(t.id) == "blocked")
208
+ live = run.get("status") == "running"
209
+ finished = run.get("status") == "finished"
210
+ rows = {r["id"]: r for r in state.tasks()}
211
+ now = dt.datetime.now().isoformat(timespec="seconds")
212
+ name = lambda m: next((s for k, s in MODEL_NAMES.items() if k in str(m).lower()), str(m or "")) # noqa: E731
213
+
214
+ # J13/15: a task's time = its first session to its merge (every try), not the last attempt only
215
+ first = {r[0]: r[1] for r in state.db.execute(
216
+ "SELECT task_id, MIN(started_at) FROM sessions WHERE kind IN ('task','review','unstick') AND task_id IS NOT NULL "
217
+ "GROUP BY task_id")}
218
+
219
+ def secs(a, b):
220
+ try:
221
+ return max(0.0, (dt.datetime.fromisoformat(b) - dt.datetime.fromisoformat(a)).total_seconds())
222
+ except (TypeError, ValueError):
223
+ return None
224
+
225
+ def span(r): # (start, end) of a task: first session to the merge, or to now while it works
226
+ start = first.get(r["id"]) or r["started_at"]
227
+ return (start, r["finished_at"] if r["status"] == "done" else now if r["status"] == "running" else None)
228
+
229
+ def one(i, t):
230
+ r = rows.get(t.id)
231
+ a, b = span(r) if r else (None, None)
232
+ st = "running" if live and run.get("task") == t.id else status.get(t.id, "pending")
233
+ st = st if st in ("done", "running", "blocked") else "pending"
234
+ tries = int(r["attempts"] or 0) if r else 0
235
+ if st == "running": # the session in work, not the last attempt in the database
236
+ tries, model = int(run.get("attempt") or 1), name(run.get("model"))
237
+ else:
238
+ model = name(r["model"]) if r else ""
239
+ return {"n": i + 1, "id": t.id, "title": t.title, "phase": t.phase_id, "status": st,
240
+ "risk": t.risk, "tries": tries, "secs": (secs(a, b) or 0) if r and a and b else None,
241
+ "time": (_span(a, b) if st == "done" else _span(a, now) if st == "running" else "") if r else "",
242
+ "detail": " · ".join(x for x in (model, f"{tries} tries" if tries > 1 else "") if x)}
243
+
244
+ table = [one(i, t) for i, t in enumerate(tasks)]
245
+ gone = sorted(t["secs"] for t in table if t["status"] == "done" and t["secs"] is not None)
246
+ med = (gone[len(gone) // 2] if len(gone) % 2 else (gone[len(gone) // 2 - 1] + gone[len(gone) // 2]) / 2) if gone else 0
247
+ for t in table: # J13/17: a time over 2x the median task time shows yellow
248
+ t["slow"] = bool(med and t["secs"] and t["secs"] > 2 * med)
249
+ working = [t for t in table if t["status"] == "running"]
250
+ here = [r for r in state.tasks("done") if (r["finished_at"] or "") >= (run.get("started_at") or "")]
251
+ left = sum(1 for t in tasks if status.get(t.id, "pending") in ("pending", "running"))
252
+ # J13/15: tasks + other = elapsed. The tasks' time is the union of their spans inside the run, so a parallel run
253
+ # never counts one minute twice. Elapsed ends now (live) or at the last journal write.
254
+ begin, end = run.get("started_at"), now if live else run.get("updated_at")
255
+ elapsed = secs(begin, end) if begin and end else None
256
+ time_secs = None
257
+ if elapsed is not None:
258
+ cut = sorted((max(s, 0.0), min(e, elapsed)) for s, e in (
259
+ (secs(begin, a), secs(begin, b)) for a, b in (span(r) for r in rows.values() if r["id"] in first or r["started_at"])
260
+ if a and b) if s is not None and e is not None and min(e, elapsed) > max(s, 0.0))
261
+ used, edge = 0.0, 0.0
262
+ for s, e in cut:
263
+ used += max(0.0, e - max(s, edge))
264
+ edge = max(edge, e)
265
+ time_secs = {"tasks": round(used), "other": round(elapsed) - round(used), "elapsed": round(elapsed)}
266
+ # J13/16: ETA = (queued / parallel) x median task time of this run + what the running tasks still need
267
+ eta, mid = "", None
268
+ if live and left:
269
+ spent = sorted(s for s in (secs(*span(r)) for r in here if r["id"] in rows) if s is not None)
270
+ if len(spent) < 3:
271
+ eta = f"after 3 tasks ({len(spent)} done)"
272
+ else:
273
+ mid = spent[len(spent) // 2] if len(spent) % 2 else (spent[len(spent) // 2 - 1] + spent[len(spent) // 2]) / 2
274
+ par = max(1, int(cfg.get("scheduling.parallel", 1) or 1))
275
+ run_rows = [rows[t["id"]] for t in table if t["status"] == "running"]
276
+ queued = left - len(run_rows)
277
+ rest = sum(max(0.0, mid - (secs(*span(r)) or 0)) for r in run_rows)
278
+ total = queued * mid / par + rest / par
279
+ hours = f"{total / 3600:.1f}h" if total >= 5400 else f"{round(total / 60)}m"
280
+ basis = (f"{queued} × {_dur(mid)}" if queued else f"{len(run_rows)} running, {_dur(mid)} each") + \
281
+ (f" + {_dur(rest)} now" if queued and rest else "")
282
+ eta = f"~{hours} ({basis})"
283
+ total, models, _, _ = _shares(state, 0)
284
+ five, week = open_window(state), (state.get_meta("window_seen", {}) or {}).get("seven_day") or {}
285
+ used = int(run.get("context_tokens") or 0) if live else 0
286
+ main = str(cfg.get("main_branch", "main"))
287
+ facts = by_commit(root, main, str(run.get("started_at") or ""))
288
+
289
+ # J10: what the build does now, what is next, and what waits for the person
290
+ doing = None
291
+ if live and working: # one task in work; a parallel run has no single task in the journal
292
+ t = working[0]
293
+ doing = {"n": t["n"], "title": t["title"], "attempt": int(run.get("attempt") or 1),
294
+ "max_attempts": int(run.get("max_attempts") or 0), "time": t["time"], "model": name(run.get("model")),
295
+ "since": round(dt.datetime.fromisoformat(first.get(t["id"]) or rows[t["id"]]["started_at"]).timestamp())
296
+ if t["id"] in rows and (first.get(t["id"]) or rows[t["id"]]["started_at"]) else None}
297
+ elif live and run.get("state") not in ("Wait", "Block"):
298
+ many = sum(1 for t in tasks if status.get(t.id) == "running")
299
+ doing = {"n": 0, "title": f"{many} tasks in work" if many > 1 else str(run.get("current") or ""),
300
+ "attempt": 1, "max_attempts": 0, "time": "", "model": name(run.get("model"))}
301
+ try:
302
+ nxt = plan.next_ready({**status, **({run["task"]: "running"} if live and run.get("task") else {})},
303
+ cfg.get("scheduling.phase_dependency", "soft"))
304
+ except Exception: # noqa: BLE001 — a plan in the middle of a change
305
+ nxt = None
306
+ asked = state.decisions("OPEN")
307
+ stuck = next((r for r in state.tasks("blocked")), None)
308
+ stale = 0
309
+ if live:
310
+ try:
311
+ stale = int((dt.datetime.fromisoformat(now) - dt.datetime.fromisoformat(run["updated_at"])).total_seconds() // 60)
312
+ except (KeyError, TypeError, ValueError):
313
+ stale = 0
314
+ git, checks = (str(run.get("git") or ""), str(run.get("build") or "")) if live else ("", "")
315
+ warned = [tid for p in plan.phases for tid in warned_tasks(cfg, p)]
316
+ why_warned = [] # J13/7: the first gate warning of each warned task, from its history file
317
+ hist = root / cfg.get("docs.history_dir", ".agent/history")
318
+ for p in plan.phases:
319
+ for tid in warned_tasks(cfg, p):
320
+ try:
321
+ first_line = (hist / p.id / f"{tid}.md").read_text(encoding="utf-8").rpartition("## Gate warnings\n")[2].splitlines()[0]
322
+ except (OSError, IndexError):
323
+ continue
324
+ why_warned.append({"id": tid, "why": first_line.lstrip("- ").replace("outside files_in_scope", "out of scope")[:70]})
325
+ # J13/3: the last error still open (a task that is not done); J13/5: minutes per done task, from the task spans
326
+ broken = max((r for r in rows.values() if r["last_error"] and r["status"] != "done"),
327
+ key=lambda r: r["updated_at"] or "", default=None)
328
+ kept = [secs(*span(r)) for r in rows.values() if r["status"] == "done"]
329
+ kept = [x for x in kept if x is not None]
330
+ login = (state.get_meta("window_seen", {}) or {}).get("plan_tier")
331
+ backend = str(cfg.get("agent.backend", "claude_cli"))
332
+ runner = ("claude" if backend == "claude_cli" else
333
+ str(cfg.get("agent.preset") or (cfg.get("agent.command") or "command").split()[0]))
334
+ ended = sorted((r for r in rows.values() if r["status"] == "done" and r["finished_at"]),
335
+ key=lambda r: r["finished_at"], reverse=True)[:3]
336
+ number = {t["id"]: t for t in table}
337
+ return {
338
+ # the age of the data; a run that is not live shows when it stopped
339
+ "asof": {"age": data_age(root, run), "live": live, "mtime": data_mtime(root, run),
340
+ "stopped": "" if live else str(run.get("updated_at") or "")[11:16]},
341
+ "project": {
342
+ "name": str(cfg.get("name", root.name)), "branch": main, "commit": facts["commit"],
343
+ "last_commit": _first(facts["subject"], 60),
344
+ "last_commit_age": _dur(time.time() - facts["stamp"]) if 0 < facts["stamp"] <= time.time() else "",
345
+ # J13/17: no commit for over 10 minutes while a task works is a stall
346
+ "commit_stalled": bool(live and working and 0 < facts["stamp"] <= time.time() - 600),
347
+ "model": name(run.get("model")) if live else "", "state": str(run.get("state") or "") if live else "",
348
+ "runner": runner, "login": str(login or "not checked"), # J13/17: the plan is stored by no check yet: no call
349
+ "resets": f"{dt.datetime.fromtimestamp(float(five['reset'])):%H:%M}" if five else "—",
350
+ "last_error": _first(broken["last_error"], 70) if broken else "—",
351
+ "run_id": str(run.get("run_id") or ""), "started": str(run.get("started_at") or "").replace("T", " "),
352
+ "status": "running" if live else "finished" if finished else "idle",
353
+ # how the status word looks: good, wait (it needs the person or a usage window), bad, or '' (neutral)
354
+ "tone": ("wait" if live and run.get("state") in ("Wait", "Block") else "good" if live else
355
+ "good" if finished and done == len(tasks) else "bad" if finished else ""),
356
+ "state_words": (STATE_WORDS.get(str(run.get("state") or ""), "Works.") if live else
357
+ ending(run.get("outcome")) if finished else "No build is active."),
358
+ "elapsed": _runtime(run.get("started_at")) if live else "", "eta": eta,
359
+ "other": _dur(time_secs["other"]) if time_secs and time_secs["other"] > 0 else "", "time_secs": time_secs,
360
+ "attempt": int(run.get("attempt") or 1) if live else 0,
361
+ "now": doing, "next": nxt.title if nxt else "",
362
+ # a build that writes nothing for a long time can be in one long command, or stuck
363
+ "stale_min": stale if stale >= 5 else 0, "stale": "bad" if stale >= 30 else "warn" if stale >= 10 else "",
364
+ },
365
+ "progress": {
366
+ "done": done, "total": len(tasks), "pct": 100 * done // len(tasks) if tasks else 0,
367
+ "phases_done": sum(1 for p in plan.phases if p.tasks and all(status.get(t.id) == "done" for t in p.tasks)),
368
+ "phases_total": len(plan.phases), "blocked": blocked, "questions": len(asked),
369
+ "warnings": len(warned), "warned": warned[:6], "warned_why": why_warned[:6],
370
+ "retries": sum(max(0, int(r["attempts"] or 0) - 1) for r in rows.values()),
371
+ "working": len(working) if live else 0, "waiting": max(0, len(tasks) - done - blocked - (len(working) if live else 0)),
372
+ "active": working[0]["n"] if live and working else 0,
373
+ },
374
+ "needs_you": [{"id": str(d["id"]), "title": _first(d["title"] or d["question"], 70)} for d in asked[:2]],
375
+ # the same simple sentence as docs/NEEDS-YOU.md gives for this problem
376
+ "blocked_why": ({"id": stuck["id"], "why": (problems(stuck["last_error"] or stuck["note"])
377
+ or ["No reason is recorded."])[0][:160]} if stuck else None),
378
+ # J11: each phase in one line, and the tasks that ended last
379
+ "phases": [{"id": p.id, "title": p.title, "total": len(p.tasks),
380
+ "done": sum(1 for t in p.tasks if status.get(t.id) == "done"),
381
+ "status": ("done" if p.tasks and all(status.get(t.id) == "done" for t in p.tasks) else
382
+ "blocked" if any(status.get(t.id) == "blocked" for t in p.tasks) else
383
+ "running" if any(status.get(t.id) == "done" for t in p.tasks)
384
+ or (live and any(run.get("task") == t.id for t in p.tasks)) else "pending")}
385
+ for p in plan.phases],
386
+ "recent": [{"n": number[r["id"]]["n"], "title": number[r["id"]]["title"], "time": number[r["id"]]["time"]}
387
+ for r in ended if r["id"] in number],
388
+ "tasks": table,
389
+ "live": live_rows(root, plan, live=live),
390
+ "usage": {
391
+ "five": float(five["pct"]) if five else None,
392
+ "week": float(week["pct"]) if five and "pct" in week else None,
393
+ "reset_in": _dur(float(five["reset"]) - time.time()) if five and float(five.get("reset") or 0) > time.time() else "",
394
+ "context": (used / (200_000 if used <= 200_000 else 1_000_000)) if used else None,
395
+ "tokens_text": _n(total), "cost": round(state.cost(), 2),
396
+ "rate": f"{round(sum(kept) / len(kept) / 60)} min per task" if kept else "—",
397
+ "models": [{"name": name(m), "tokens_text": _n(v["total"]), "share": v["total"] / total}
398
+ for m, v in sorted(models, key=lambda x: -x[1]["total"]) if total and v["total"]],
399
+ },
400
+ "size": ({**facts["size"], "run_lines": facts["run"]["lines"] if facts["run"] else None,
401
+ "run_files": facts["run"]["files"] if facts["run"] else None} if facts["size"] else None),
402
+ "files": facts["files"],
403
+ "health": {"git": git, "checks": checks,
404
+ "words": " ".join(x for x in ("The checks fail on main." if checks == "Red" else "",
405
+ "Git has no connection." if git == "Off" else "",
406
+ "The push to GitHub fails." if git == "Push!" else "") if x)},
407
+ "next": [tuple(re.split(r"\s{2,}", tip, maxsplit=1)) if re.search(r"\s{2,}", tip) else (tip, "")
408
+ for tip in next_steps(root, live)],
409
+ "line": status_line(cfg, plan, state, run),
410
+ }
autopilot/decisions.py ADDED
@@ -0,0 +1,85 @@
1
+ """docs/NEEDS-YOU.md: the questions for the owner in simple words (ASD-STE100), rendered from the decisions table.
2
+ The text says what happened, what the problem is, what the agent asks, what waits, and how to answer."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+
8
+ HEAD = re.compile(r"^## (D-\d+) · .* · (OPEN|ANSWERED|DONE)[ \t]*$", re.M)
9
+ LABEL = {"OPEN": "OPEN", "ANSWERED": "ANSWERED", "APPLIED": "DONE"}
10
+ STATUS = {"OPEN": "Open. This question waits for your answer.",
11
+ "ANSWERED": "Answered. Autopilot uses your answer when it starts this task again.",
12
+ "APPLIED": "Complete. Autopilot used your answer."}
13
+ QUESTION = "The agent could not complete this task. How must it continue?"
14
+ OLD_QUESTION = "I could not finish this feature and cannot decide how to proceed on my own. What should I do?"
15
+ INTRO = [
16
+ "# Questions for you", "",
17
+ "Autopilot cannot decide these points alone. It continues with all other tasks while the questions wait.", "",
18
+ "## How to answer", "",
19
+ "1. Open a terminal in the project folder.",
20
+ "2. Run `ap answer`.",
21
+ "3. Type 1, 2 or 3 for each question. Press Enter alone to pick 1, the suggestion of the agent.", "",
22
+ "Write the answer in your own words, as an instruction for the agent. Example: `Try again. Keep all the tests.`", "",
23
+ "To answer one question in one command, run `ap answer D-001 \"your answer\"`.",
24
+ "If no build is active, you can also write the answer after \"Your answer:\" in this file. Then commit the file.", ""]
25
+
26
+
27
+ def _line(v) -> str:
28
+ return " ".join(str(v or "").split())
29
+
30
+
31
+ def problems(why) -> list[str]:
32
+ """The reasons as a list, one sentence each. The gate writes 'PROBLEM: ...' for each one."""
33
+ out = []
34
+ for part in (p.strip() for p in re.split(r"\bPROBLEM:\s*", _line(why))):
35
+ m = re.match(r"(test|assertion) count dropped in (\S+): (\d+) -> (\d+)$", part)
36
+ if m:
37
+ part = f"The number of {m.group(1)}s in `{m.group(2)}` decreased from {m.group(3)} to {m.group(4)}."
38
+ if part:
39
+ out.append(part)
40
+ return out
41
+
42
+
43
+ def explain(d) -> list[str]:
44
+ """One question as markdown list items in simple words. An empty field gives no line."""
45
+ done = re.match(r"(\d+) attempts by the agent$", _line(d["checked"]))
46
+ happened = f"The agent made {done.group(1)} attempts. The checks did not pass." if done else \
47
+ " ".join(re.split(r"(?<=[.!?]) ", _line(d["checked"]))[:3]) # at most 3 sentences
48
+ question = _line(d["question"])
49
+ why, blocks = problems(d["why"]), json.loads(d["blocks"] or "[]")
50
+ out = [f"- **What happened:** {happened}"] if happened else []
51
+ if len(why) == 1:
52
+ out.append(f"- **The problem:** {why[0]}")
53
+ elif why:
54
+ out += ["- **The problem:**"] + [f" - {p}" for p in why[:2]] + ([" - More problems are in the log."] if len(why) > 2 else [])
55
+ out.append(f"- **The question:** {QUESTION if question == OLD_QUESTION else question}")
56
+ if _line(d["suggestion"]):
57
+ out.append(f"- **Suggestion from the agent:** {_line(d['suggestion'])}")
58
+ out.append(f"- **What waits:** {', '.join(blocks)}. All other tasks continue." if blocks else
59
+ "- **What waits:** No task waits.")
60
+ return out
61
+
62
+
63
+ CHOICES = "1 = do that (Enter) · 2 = skip this task · 3 = your own words"
64
+ SKIP = "skip this task"
65
+ LOG = "../.agent/logs/autopilot.log"
66
+
67
+
68
+ def render(state) -> str:
69
+ out = list(INTRO)
70
+ for d in state.decisions():
71
+ out += [f"## {d['id']} · {_line(d['title'])} · {LABEL.get(d['status'], d['status'])}", "",
72
+ f"- **Status:** {STATUS.get(d['status'], d['status'])}", *explain(d),
73
+ *([f"- **Choices:** {CHOICES}. Full details: [log]({LOG})"] if d["status"] == "OPEN" else []),
74
+ f"- Your answer: {(d['answer'] or '').strip()}".rstrip(), ""]
75
+ return "\n".join(out)
76
+
77
+
78
+ def parse_answers(text: str) -> dict[str, str]:
79
+ parts = HEAD.split(text) # [preamble, id, status, body, id, status, body, ...]
80
+ out = {}
81
+ for did, status, body in zip(parts[1::3], parts[2::3], parts[3::3]):
82
+ m = re.search(r"^- Your answer:[ \t]*(.*?)\s*(?=^## |\Z)", body, re.M | re.S)
83
+ if status == "OPEN" and m and m.group(1).strip():
84
+ out[did] = m.group(1).strip()
85
+ return out
autopilot/deploy.py ADDED
@@ -0,0 +1,67 @@
1
+ """Phase deployments with health checks, smoke tests and automatic rollback."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import time
6
+ import urllib.request
7
+ from dataclasses import dataclass
8
+
9
+ from .gate import run_commands
10
+ from .log_scrub import scrub_secrets
11
+
12
+ # masked in every failure text; more (a zone ID, another token) via deploy.secret_env
13
+ _SECRET_ENV = ("CLOUDFLARE_TOKEN", "EMAIL_API_TOKEN", "DEPLOY_SSH_KEY", "DEPLOY_SSH_HOST", "DEPLOY_SSH_KEY_PATH")
14
+
15
+
16
+ @dataclass
17
+ class DeployResult:
18
+ ok: bool
19
+ detail: str
20
+ rolled_back: bool = False
21
+
22
+
23
+ def health_check(url: str, timeout: int) -> bool:
24
+ if not url:
25
+ return True
26
+ deadline = time.monotonic() + timeout
27
+ while time.monotonic() < deadline:
28
+ try:
29
+ with urllib.request.urlopen(url, timeout=10) as r:
30
+ if 200 <= r.status < 400:
31
+ return True
32
+ except Exception: # noqa: BLE001
33
+ pass
34
+ time.sleep(5)
35
+ return False
36
+
37
+
38
+ def deploy(cfg, env_name: str, ref: str, phase_id: str, prev_ref: str | None) -> DeployResult:
39
+ c = f"deploy.{env_name}"
40
+ env = {"AUTOPILOT_ENV": env_name, "AUTOPILOT_REF": ref, "AUTOPILOT_PHASE": phase_id,
41
+ "AUTOPILOT_PREV_REF": prev_ref or "", "AUTOPILOT_URL": cfg.get(f"{c}.health_url", "")}
42
+ env.update({"AUTODEV_" + k[10:]: v for k, v in list(env.items())})
43
+ timeout = int(cfg.get("verify_timeout_sec", 1200))
44
+
45
+ secrets = {k: os.environ[k] for k in _SECRET_ENV + tuple(cfg.get("deploy.secret_env", []) or []) if os.environ.get(k)}
46
+ res = run_commands([cfg.get(f"{c}.cmd")], cfg.root, timeout, env) # output holds stdout + stderr
47
+ failure = None
48
+ if res[-1].rc != 0:
49
+ failure = f"deploy command failed:\n{scrub_secrets(res[-1].output[-1500:], secrets)}"
50
+ elif not health_check(cfg.get(f"{c}.health_url", ""), int(cfg.get(f"{c}.health_timeout_sec", 180))):
51
+ failure = f"health check failed: {cfg.get(f'{c}.health_url')}"
52
+ else:
53
+ smoke = run_commands(cfg.commands("smoke"), cfg.root, timeout, env)
54
+ bad = [r for r in smoke if r.rc != 0]
55
+ if bad:
56
+ failure = f"smoke test failed: {bad[0].cmd}\n{scrub_secrets(bad[0].output[-1500:], secrets)}"
57
+ if not failure:
58
+ return DeployResult(True, f"{env_name} deployed {ref[:10]}")
59
+ failure = scrub_secrets(failure, secrets) # health URL may carry a token
60
+
61
+ rolled = False
62
+ rb = cfg.get(f"{c}.rollback_cmd")
63
+ if rb and prev_ref:
64
+ rb_res = run_commands([rb], cfg.root, timeout, env)
65
+ rolled = rb_res[-1].rc == 0
66
+ failure += f"\nrollback to {prev_ref[:10]}: {'ok' if rolled else 'FAILED'}"
67
+ return DeployResult(False, failure, rolled_back=rolled)