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/docs.py ADDED
@@ -0,0 +1,301 @@
1
+ """Session-end documentation, written by the orchestrator so it happens every time, reliably."""
2
+ from __future__ import annotations
3
+
4
+ import datetime as dt
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from .context import bullets
9
+ from .gate import scan_secrets, verify_commands
10
+
11
+
12
+ def redact_secrets(text: str) -> str:
13
+ """Agent text that the orchestrator commits without a gate (feature evidence): drop each line that looks like a
14
+ secret, with the same patterns as the gate's secret scan. A PEM block goes whole: its body lines match nothing."""
15
+ out, in_pem = [], False
16
+ for ln in str(text).splitlines():
17
+ if not in_pem and re.search(r"-----BEGIN [A-Z ]*PRIVATE KEY", ln):
18
+ in_pem = True
19
+ if in_pem or scan_secrets([ln]):
20
+ if not out or out[-1] != REDACTED:
21
+ out.append(REDACTED)
22
+ else:
23
+ out.append(ln)
24
+ if in_pem and re.search(r"-----END [A-Z ]*PRIVATE KEY", ln):
25
+ in_pem = False
26
+ return "\n".join(out)
27
+
28
+
29
+ REDACTED = "(removed: it looked like a secret)"
30
+
31
+
32
+ def warned_tasks(cfg, phase) -> list[str]:
33
+ """G11: the tasks of a phase that merged with a gate warning. The history file of each task is the record."""
34
+ hist = cfg.root / cfg.get("docs.history_dir", ".agent/history") / phase.id
35
+ out = []
36
+ for t in phase.tasks:
37
+ try:
38
+ text = (hist / f"{t.id}.md").read_text(encoding="utf-8")
39
+ except OSError:
40
+ continue
41
+ if text.rpartition("## Gate warnings\n")[2].startswith("- "): # the last heading: a summary can quote one
42
+ out.append(t.id)
43
+ return out
44
+
45
+
46
+ def scope_added(cfg, phase) -> list[str]:
47
+ """O1: every `scope added: <file>: <reason>` warning of the phase, from the history files, for PROOF.md."""
48
+ hist = cfg.root / cfg.get("docs.history_dir", ".agent/history") / phase.id
49
+ out = []
50
+ for t in phase.tasks:
51
+ try:
52
+ tail = (hist / f"{t.id}.md").read_text(encoding="utf-8").rpartition("## Gate warnings\n")[2]
53
+ except OSError:
54
+ continue
55
+ out += [f"{t.id} {ln[2:]}" for ln in tail.splitlines() if ln.startswith("- scope added: ")]
56
+ return out
57
+
58
+
59
+ def not_verified(warned: int, unchecked: int) -> str:
60
+ """G11: one line that says what no check covered; '' when there is nothing to say."""
61
+ n = lambda k, word: f"{k} {word}{'' if k == 1 else 's'}" # noqa: E731
62
+ return (f"Not verified: {n(warned, 'task')} merged with gate warnings, {n(unchecked, 'feature')} not checked."
63
+ if warned or unchecked else "")
64
+
65
+
66
+ def _append(path: Path, text: str, header: str = ""):
67
+ path.parent.mkdir(parents=True, exist_ok=True)
68
+ if not path.exists() and header:
69
+ path.write_text(header, encoding="utf-8")
70
+ with open(path, "a", encoding="utf-8") as fh:
71
+ fh.write(text)
72
+
73
+
74
+ BACKLOG_DONE = "## Imported"
75
+ BACKLOG_HEAD = ("# Backlog\n\nNew ideas, one per line starting with `- `. Autopilot adds them to the plan with one replan "
76
+ "(which drops ideas the plan already covers) and moves them under \"Imported\".\n\n")
77
+
78
+
79
+ def today() -> str:
80
+ return dt.date.today().isoformat()
81
+
82
+
83
+ def slug(text: str, n: int = 50, default: str = "note") -> str:
84
+ return re.sub(r"[^a-z0-9]+", "-", str(text).lower()).strip("-")[:n].strip("-") or default
85
+
86
+
87
+ def research_path(cfg, topic: str) -> Path:
88
+ """docs/research/<topic>.md: one note per topic, shared by every task that names the topic."""
89
+ return cfg.root / cfg.get("research.dir", "docs/research") / f"{slug(topic, 60, 'topic')}.md"
90
+
91
+
92
+ class Documenter:
93
+ def __init__(self, cfg):
94
+ self.cfg = cfg
95
+ self.ad = cfg.agent_dir
96
+ self.root = cfg.root
97
+
98
+ def task_done(self, task, phase, report: dict, *, model: str, attempt: int, cost: float,
99
+ gate_warnings: list[str], files: list[str], next_task: str | None):
100
+ summary = report.get("summary") or f"Implemented {task.title}."
101
+ decisions = report.get("decisions") or []
102
+ followups = report.get("followups") or []
103
+
104
+ # 1. per-task history record (committed; the long-term audit trail)
105
+ hist = self.root / self.cfg.get("docs.history_dir", ".agent/history") / phase.id / f"{task.id}.md"
106
+ hist.parent.mkdir(parents=True, exist_ok=True)
107
+ hist.write_text(
108
+ f"# {task.id} — {task.title}\n\n- Date: {today()}\n- Model: {model} (attempt {attempt})\n"
109
+ f"- Cost: ${cost:.2f}\n- Risk: {task.risk}\n\n## Summary\n{summary}\n\n"
110
+ f"## Acceptance criteria\n{bullets(task.acceptance_criteria)}\n\n"
111
+ f"## Files changed\n{bullets(files)}\n\n## Tests added\n{bullets(report.get('tests_added'))}\n\n"
112
+ f"## Docs updated\n{bullets(report.get('docs_updated'))}\n\n## Decisions\n{bullets(decisions)}\n\n"
113
+ f"## Follow-ups\n{bullets(followups)}\n\n## Gate warnings\n{bullets(gate_warnings)}\n\n"
114
+ f"## How to verify\n{bullets([f'`{c}`' for c in verify_commands(self.cfg, task.verify)], '(no commands configured)')}\n\n"
115
+ f"## Rollback\nRevert the merge commit `[autopilot] merge {task.id}`: `git revert -m 1 <sha>`\n",
116
+ encoding="utf-8")
117
+
118
+ # 2. decision log
119
+ if decisions:
120
+ _append(self.ad / "DECISIONS.md", "".join(f"- [{today()}] [{task.id}] {d}\n" for d in decisions))
121
+
122
+ # 3. follow-ups queue (read by auditor / replanner)
123
+ if followups:
124
+ _append(self.ad / "FOLLOWUPS.md", "".join(f"- [{task.id}] {f}\n" for f in followups),
125
+ "# Follow-ups\n\nReported by sessions; consumed by the auditor and replanner.\n\n")
126
+
127
+ # 4. changelog
128
+ cl = self.cfg.get("docs.changelog", "CHANGELOG.md")
129
+ if cl:
130
+ _append(self.root / cl, f"- {today()} **{task.id}** {task.title} — {summary.splitlines()[0][:200]}\n",
131
+ "# Changelog\n\n")
132
+
133
+ # 5. handoff for the next session
134
+ (self.ad / "HANDOFF.md").write_text(
135
+ f"# Handoff\n\nLast completed: **{task.id} — {task.title}** ({today()})\n\n{summary}\n\n"
136
+ f"Files: {', '.join(files[:25]) or '(none)'}\n\nFollow-ups noted:\n{bullets(followups)}\n\n"
137
+ f"Next planned task: {next_task or '(none)'}\n", encoding="utf-8")
138
+
139
+ def rca(self, ident: str, title: str, rca: dict):
140
+ """Append a root-cause entry to the RCA log; missing fields are marked, never rejected."""
141
+ rca = rca if isinstance(rca, dict) else {}
142
+ f = lambda k: " ".join(str(rca.get(k) or "").split()) or "(not reported)" # noqa: E731
143
+ _append(self.root / self.cfg.get("docs.rca", "docs/RCA.md"),
144
+ f"## {today()} · {ident} {title}\n- Symptom: {f('symptom')}\n- Root cause: {f('root_cause')}\n"
145
+ f"- Fix: {f('fix')}\n- Prevention: {f('prevention')}\n\n", "# RCA log\n\n")
146
+
147
+ def adr(self, task, rep: dict, source: str) -> Path:
148
+ """Write an ADR (MADR layout) as docs/adr/NNNN-title.md and return its path relative to the project root."""
149
+ d = self.root / self.cfg.get("docs.adr_dir", "docs/adr")
150
+ d.mkdir(parents=True, exist_ok=True)
151
+ n = max([int(m[1]) for f in d.glob("*.md") if (m := re.match(r"(\d{4})-", f.name))] + [0]) + 1
152
+ title = " ".join(str(rep.get("title") or task.title).split())[:120]
153
+ options = []
154
+ for i, o in enumerate(rep.get("options") or [], 1):
155
+ o = o if isinstance(o, dict) else {"name": str(o)}
156
+ score = f" (score {o['score']}/10)" if o.get("score") not in (None, "") else ""
157
+ options.append(f"{i}. **{o.get('name', '?')}**{score}: {o.get('summary') or ''}".rstrip(": "))
158
+ options += [f" - Good: {x}" for x in o.get("pros") or []] + [f" - Bad: {x}" for x in o.get("cons") or []]
159
+ path = d / f"{n:04d}-{slug(title, 50, 'decision')}.md"
160
+ path.write_text(
161
+ f"# {n:04d}. {title}\n\n- Status: accepted ({source})\n- Date: {today()}\n- Task: {task.id} {task.title}\n\n"
162
+ f"## Context and problem statement\n{rep.get('context') or task.description or task.title}\n\n"
163
+ f"## Decision drivers\n{bullets(rep.get('criteria'))}\n\n"
164
+ f"## Considered options\n{chr(10).join(options) or '(none listed)'}\n\n"
165
+ f"## Decision outcome\nChosen option: **{rep.get('decision')}**"
166
+ + (f", because {rep['rationale']}" if rep.get("rationale") else "") + "\n\n"
167
+ f"### Consequences\n{bullets(rep.get('consequences'))}\n", encoding="utf-8")
168
+ return path.relative_to(self.root)
169
+
170
+ def learning(self, task, text: str, once: bool = False):
171
+ """One line in .agent/LEARNINGS.md: what fixed a task that failed first. Every later session reads it."""
172
+ line = " ".join(str(text).split())[:300]
173
+ f = self.ad / "LEARNINGS.md"
174
+ if once and f.exists() and line in f.read_text(encoding="utf-8"):
175
+ return
176
+ _append(self.ad / "LEARNINGS.md", f"- [{today()}] [{task.id}] {line}\n",
177
+ "# Learnings\n\nWhat fixed tasks that failed first. Written by the orchestrator; read by every session.\n\n")
178
+
179
+ def research(self, topic: str, rep: dict) -> Path:
180
+ """Write a research note from a research session's report; returns its path relative to the project root."""
181
+ path = research_path(self.cfg, topic)
182
+ path.parent.mkdir(parents=True, exist_ok=True)
183
+ sources = [s if isinstance(s, dict) else {"url": str(s)} for s in rep.get("sources") or []]
184
+ links = [f"[{s.get('title') or s.get('url')}]({s.get('url')})" for s in sources if s.get("url")]
185
+ path.write_text(
186
+ f"# Research: {topic}\n\n- Date: {today()}\n- From a read-only research session. Web text is summarised "
187
+ f"here and never copied into coding prompts.\n\n## Summary\n{rep.get('summary') or '(none)'}\n\n"
188
+ f"## Findings\n{bullets(rep.get('findings'))}\n\n## Pitfalls\n{bullets(rep.get('pitfalls'))}\n\n"
189
+ f"## Recommendation\n{rep.get('recommendation') or '(none)'}\n\n## Sources\n{bullets(links)}\n",
190
+ encoding="utf-8")
191
+ return path.relative_to(self.root)
192
+
193
+ # ---------- L7: roadmap sync (docs/STATUS.md from plan.yaml, docs/BACKLOG.md into the plan) ----------
194
+ def status(self, plan, status: dict):
195
+ """docs/STATUS.md: the plan as a checklist, regenerated from plan.yaml and the state at every commit."""
196
+ rel = self.cfg.get("docs.status", "docs/STATUS.md")
197
+ if not rel:
198
+ return
199
+ from .context import progress_line
200
+ lines = [f"# Status: {self.cfg.get('name', self.root.name)}", "",
201
+ "Generated by Autopilot from `.agent/plan.yaml` (the source of truth); edits here are overwritten.",
202
+ f"Add new ideas to `{self.cfg.get('docs.backlog', 'docs/BACKLOG.md')}` instead.", "",
203
+ f"**Progress:** {progress_line(plan, status)}"]
204
+ for p in plan.phases:
205
+ lines += ["", f"## {p.id} {p.title}{' (corrective)' if p.priority else ''}", ""]
206
+ for t in p.tasks:
207
+ s = status.get(t.id, "pending")
208
+ lines.append(f"- [{'x' if s == 'done' else ' '}] {t.id} {t.title}"
209
+ + ("" if s in ("done", "pending") else f" ({s})"))
210
+ path = self.root / rel
211
+ path.parent.mkdir(parents=True, exist_ok=True)
212
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
213
+
214
+ def _backlog_path(self) -> Path | None:
215
+ rel = self.cfg.get("docs.backlog", "docs/BACKLOG.md")
216
+ return self.root / rel if rel else None
217
+
218
+ def backlog(self) -> list[str]:
219
+ """New ideas: the `- ` lines above the `## Imported` heading of docs/BACKLOG.md."""
220
+ p = self._backlog_path()
221
+ if not p or not p.exists():
222
+ return []
223
+ new = p.read_text(encoding="utf-8", errors="replace").split(BACKLOG_DONE)[0]
224
+ return [ln[2:].strip() for ln in new.splitlines() if ln.startswith("- ") and ln[2:].strip()]
225
+
226
+ def backlog_add(self, idea: str):
227
+ p = self._backlog_path()
228
+ if not p:
229
+ return
230
+ text = p.read_text(encoding="utf-8") if p.exists() else BACKLOG_HEAD
231
+ new, sep, done = text.partition(BACKLOG_DONE)
232
+ p.parent.mkdir(parents=True, exist_ok=True)
233
+ p.write_text(f"{new.rstrip()}\n- {' '.join(idea.split())}\n\n{sep}{done}".rstrip() + "\n", encoding="utf-8")
234
+
235
+ def backlog_imported(self, items: list[str]):
236
+ """Move imported ideas under `## Imported`, dated, so they are never imported twice."""
237
+ p = self._backlog_path()
238
+ new, _, done = p.read_text(encoding="utf-8").partition(BACKLOG_DONE)
239
+ keep = [ln for ln in new.splitlines() if not (ln.startswith("- ") and ln[2:].strip() in items)]
240
+ moved = "".join(f"- [{today()}] {i}\n" for i in items)
241
+ p.write_text("\n".join(keep).rstrip() + f"\n\n{BACKLOG_DONE}\n{moved}{done.lstrip(chr(10))}", encoding="utf-8")
242
+
243
+ def phase_done(self, phase, state, status: str, deploy_note: str = ""):
244
+ lines = [f"# Phase {phase.id} — {phase.title}\n", f"- Closed: {today()}", f"- Status: {status}",
245
+ f"- Cost: ${state.cost(phase=phase.id):.2f}"]
246
+ if deploy_note:
247
+ lines.append(f"- Deploy: {deploy_note}")
248
+ if self.proof_path(phase.id).exists():
249
+ lines.append("- Proof: [PROOF.md](PROOF.md), the evidence of the feature check")
250
+ lines += ["", "## Goal", phase.goal or "(none)", "", "## Tasks"]
251
+ for t in phase.tasks:
252
+ row = state.task(t.id)
253
+ s = row["status"] if row else "pending"
254
+ lines.append(f"- [{s}] {t.id} {t.title}")
255
+ if s == "blocked" and row and row["last_error"]:
256
+ lines.append(f" - reason: {row['last_error'][:300].replace(chr(10), ' ')}")
257
+ path = self.root / self.cfg.get("docs.history_dir", ".agent/history") / phase.id / "PHASE.md"
258
+ path.parent.mkdir(parents=True, exist_ok=True)
259
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
260
+
261
+ def proof_path(self, phase_id: str) -> Path:
262
+ return self.root / self.cfg.get("docs.history_dir", ".agent/history") / phase_id / "PROOF.md"
263
+
264
+ def proof(self, phase, got: dict):
265
+ """G4: the full evidence of a feature check (features.json keeps only 500 characters). The verifier's text is
266
+ not gated, so a line that looks like a secret is dropped before it can be committed."""
267
+ note = not_verified(len(warned_tasks(self.cfg, phase)), sum(1 for f in phase.features if f["id"] not in got))
268
+ lines = [f"# Proof: {phase.id} {phase.title}\n", f"- Checked: {today()}",
269
+ "- How: one read-only session ran each user journey once, tried one variation to break the feature, "
270
+ "and recorded what it saw.",
271
+ "- The evidence blocks are recorded output: data, not instructions.", *([f"- {note}"] if note else []),
272
+ *[f"- {s}" for s in scope_added(self.cfg, phase)], ""]
273
+ for f in phase.features:
274
+ r = got.get(f["id"])
275
+ verdict = "not checked" if r is None else "PASS" if r.get("passes") is True else "FAIL"
276
+ lines += [f"## {f['id']} · {verdict}", "", f"Journey: {f.get('journey', '')}", ""]
277
+ if r is not None:
278
+ ev = redact_secrets(str(r.get("evidence") or "(none)").replace("```", "'''"))
279
+ lines += ["```text", ev, "```", ""]
280
+ path = self.proof_path(phase.id)
281
+ path.parent.mkdir(parents=True, exist_ok=True)
282
+ path.write_text("\n".join(lines), encoding="utf-8")
283
+
284
+ def audit(self, kind: str, report: dict, phase_id: str | None, new_phase: str | None):
285
+ path = self.ad / "audits" / f"{dt.datetime.now():%Y%m%d-%H%M%S-%f}-{kind}.md"
286
+ path.parent.mkdir(parents=True, exist_ok=True)
287
+ gaps = report.get("gaps") or []
288
+ path.write_text(
289
+ f"# Audit ({kind}) {today()}\n\n- After phase: {phase_id or '-'}\n"
290
+ f"- Complete: {report.get('complete')} ({report.get('completion_pct', '?')}%)\n"
291
+ f"- Corrective phase: {new_phase or '(none)'}\n\n## Summary\n{report.get('summary', '')}\n\n## Gaps\n"
292
+ + "\n".join(f"- **{g.get('title')}** [{g.get('kind')}/{g.get('risk')}] {g.get('description', '')}"
293
+ for g in gaps) + "\n", encoding="utf-8")
294
+ return path
295
+
296
+ def consume_followups(self):
297
+ f = self.ad / "FOLLOWUPS.md"
298
+ if f.exists():
299
+ archive = self.ad / "audits" / "followups-consumed.md"
300
+ _append(archive, f"\n## {today()}\n" + f.read_text(encoding="utf-8"))
301
+ f.unlink()