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
File without changes
@@ -0,0 +1,131 @@
1
+ """`ap preflight`: phase 0 check. Every credential the plan needs is present and works (read-only calls), or
2
+ the run does not start. Missing items come out as ONE batch for the owner."""
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ import urllib.error
8
+ import urllib.request
9
+
10
+ from ..log_scrub import scrub_secrets
11
+
12
+ # type -> env var that must be set; the check is read-only
13
+ CRED = {"deploy": "DEPLOY_SSH_KEY", "cloudflare": "CLOUDFLARE_TOKEN", "email": "EMAIL_API_TOKEN"}
14
+ CF_VERIFY = "https://api.cloudflare.com/client/v4/user/tokens/verify"
15
+
16
+
17
+ def http_get(url: str, token: str) -> int: # the one network call; tests replace it
18
+ req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
19
+ try:
20
+ with urllib.request.urlopen(req, timeout=15) as r:
21
+ return r.status
22
+ except urllib.error.HTTPError as e:
23
+ return e.code
24
+
25
+
26
+ def run_cmd(cmd: list[str] | str, shell: bool = False) -> tuple[int, str]: # tests replace it too
27
+ try:
28
+ p = subprocess.run(cmd, shell=shell, capture_output=True, text=True, timeout=30)
29
+ return p.returncode, (p.stderr or p.stdout).strip()[:200]
30
+ except (OSError, subprocess.TimeoutExpired) as e:
31
+ return 1, str(e)[:200]
32
+
33
+
34
+ def _check(step: dict, val: str, secrets: dict[str, str]) -> tuple[str, str]:
35
+ """-> (ok|error, detail) for one present credential. Text from a command is scrubbed: it can hold a token or host."""
36
+ st, detail = _probe(step, val)
37
+ return st, scrub_secrets(detail, secrets) if st == "error" else detail
38
+
39
+
40
+ def _probe(step: dict, val: str) -> tuple[str, str]:
41
+ t = step["type"]
42
+ if t == "cloudflare":
43
+ code = http_get(CF_VERIFY, val)
44
+ return ("ok", "token valid") if code == 200 else ("error", f"Cloudflare answered HTTP {code}")
45
+ if t == "deploy":
46
+ if not os.path.isfile(val):
47
+ return "error", "DEPLOY_SSH_KEY must be the path of a key file"
48
+ if step["host"]: # read-only: log in and run `true`
49
+ rc, out = run_cmd(["ssh", "-i", val, "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", step["host"], "true"])
50
+ return ("ok", "ssh login works") if rc == 0 else ("error", f"ssh failed: {out}")
51
+ return "ok", "key file found (no `host:` set, login not tested)"
52
+ if step["verify"]: # email / custom: the plan's own read-only verify command
53
+ rc, out = run_cmd(step["verify"], shell=True)
54
+ return ("ok", "verify command passed") if rc == 0 else ("error", f"verify failed: {out}")
55
+ return "ok", "set (no verify command, not tested)" # ponytail: email has no default provider endpoint
56
+
57
+
58
+ def check_steps(steps: list[dict], env) -> tuple[list[dict], set]:
59
+ """The credential rows of these infra steps, and the env names they use. Used at the run start and again before
60
+ each deploy (a token can expire during a long run)."""
61
+ rows, seen = [], set()
62
+ secrets = {n: env[n] for s in steps for n in [*CRED.values(), *s["env"]] if env.get(n)}
63
+ secrets.update({f"host:{s['id']}": s["host"] for s in steps if s["host"]})
64
+ for s in steps:
65
+ for name in ([CRED[s["type"]]] if s["type"] in CRED else []) + s["env"]:
66
+ if name not in env or not env[name]:
67
+ rows.append({"item": f"{name} ({s['id']})", "status": "missing",
68
+ "detail": s["description"] or f"needed by {s['type']} step {s['id']}"})
69
+ elif name == CRED.get(s["type"]) or s["verify"]:
70
+ st, d = _check(s, env[name], secrets)
71
+ rows.append({"item": f"{name} ({s['id']})", "status": st, "detail": d})
72
+ seen.add(name)
73
+ return rows, seen
74
+
75
+
76
+ def recheck(phase, env=None) -> str:
77
+ """'' when the credentials of this phase still work; else one plain line that names them (never a value)."""
78
+ rows, _ = check_steps(phase.infra.get("steps", []), os.environ if env is None else env)
79
+ bad = [f"{r['item']} is {r['status']}" for r in rows if r["status"] != "ok"]
80
+ return f"credential check failed before the deploy: {'; '.join(bad)}" if bad else ""
81
+
82
+
83
+ def run_preflight(plan, cfg, env=None) -> list[dict]:
84
+ """Report rows {item, status: ok|missing|error, detail}. Any row that is not ok blocks the run."""
85
+ env = os.environ if env is None else env
86
+ rows, seen = check_steps([s for ph in plan.phases for s in ph.infra.get("steps", [])], env)
87
+ for name in cfg.get("agent.env_passthrough", []) or []:
88
+ if name not in seen:
89
+ rows.append({"item": name, "status": "ok" if env.get(name) else "missing",
90
+ "detail": "agent.env_passthrough" if env.get(name) else "listed in agent.env_passthrough"})
91
+ for t in plan.all_tasks():
92
+ if t.owner_only: # the loop skips owner tasks (plan.py), so they inform, they do not block (RCA 2026-10-11)
93
+ rows.append({"item": f"{t.id} {t.title}", "status": "ok", "detail": "owner_only: left for you; the run skips it"})
94
+ return rows
95
+
96
+
97
+ def report_text(rows: list[dict]) -> str:
98
+ bad = [r for r in rows if r["status"] != "ok"]
99
+ lines = [f"{r['status'].upper():8}{r['item']} {r['detail']}" for r in rows]
100
+ if bad:
101
+ lines += ["", f"NEEDS YOU: {len(bad)} item(s) block the run. Fix them in one go, then run `ap preflight` again."]
102
+ else:
103
+ lines.append("preflight OK" if rows else "preflight OK (nothing to check)")
104
+ return "\n".join(lines)
105
+
106
+
107
+ def approval_setup() -> list[str]:
108
+ """Advice for manual setup to bypass interactive prompts. Does NOT modify global config (owner's standing rule).
109
+ Returns a list of recommendations only (no automatic actions)."""
110
+ actions = []
111
+
112
+ # 1. SSH agent (info only)
113
+ ssh_key = os.environ.get("DEPLOY_SSH_KEY")
114
+ if ssh_key and os.path.isfile(ssh_key):
115
+ rc, _ = run_cmd(["ssh-add", "-l"])
116
+ if rc == 0:
117
+ actions.append("SSH agent running; load key with: ssh-add " + ssh_key)
118
+ else:
119
+ actions.append("SSH agent not running; start with: eval $(ssh-agent -s)")
120
+
121
+ # 2. GitHub gh CLI (info only)
122
+ rc, _ = run_cmd(["gh", "auth", "status"])
123
+ if rc != 0:
124
+ actions.append("GitHub CLI not authenticated; run: gh auth login")
125
+
126
+ # 3. Info for other setup (owner does manually)
127
+ import platform
128
+ if platform.system() == "Windows":
129
+ actions.append("PowerShell: run as Administrator to avoid execution policy prompts")
130
+
131
+ return actions
autopilot/config.py ADDED
@@ -0,0 +1,155 @@
1
+ """Project contract loading. Defaults come from the shipped template, so there's one source of truth."""
2
+ from __future__ import annotations
3
+
4
+ import copy
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import yaml
10
+
11
+ from . import AGENT_DIR
12
+
13
+ TEMPLATES = Path(__file__).parent / "templates"
14
+ RISKS = ["low", "medium", "high", "critical"]
15
+ NATIVE_WINDOWS = os.name == "nt"
16
+ # CLI flags that could replace or widen the sandbox settings Autopilot passes (P5 egress allowlist)
17
+ SANDBOX_UNSAFE_ARGS = ("--settings", "--setting-sources", "--mcp-config", "--dangerously-skip-permissions",
18
+ "--allowedTools", "--allowed-tools")
19
+
20
+
21
+ def deep_merge(base: dict, override: dict) -> dict:
22
+ out = copy.deepcopy(base)
23
+ for k, v in (override or {}).items():
24
+ if isinstance(v, dict) and isinstance(out.get(k), dict):
25
+ out[k] = deep_merge(out[k], v)
26
+ else:
27
+ out[k] = copy.deepcopy(v)
28
+ return out
29
+
30
+
31
+ def load_yaml(path: Path) -> dict:
32
+ with open(path, encoding="utf-8") as fh:
33
+ return yaml.safe_load(fh) or {}
34
+
35
+
36
+ def defaults() -> dict:
37
+ d = load_yaml(TEMPLATES / "agent" / "project.yaml")
38
+ d.setdefault("agent", {}).setdefault("backend", "claude_cli") # N3: the template leaves it unset (preflight detects)
39
+ d["profile"] = "full" # O10: an old project.yaml without the key stays full; the template gives new projects lean
40
+ # (clean measure 2026-10-12: -39.5 percent tokens per task, 11 of 11 done; the owner accepted it as the 40 percent rule)
41
+ d.setdefault("gate", {})["new_file_reasons"] = False # N7: on in the template (new projects) only; a missing key = off
42
+ d["gate"]["file_growth"] = False # O2: same rule: the template turns it on, an old project.yaml keeps it off
43
+ return d
44
+
45
+
46
+ class Config:
47
+ """Wraps the merged project.yaml with dotted-path access."""
48
+
49
+ def __init__(self, root: Path, data: dict):
50
+ self.root = Path(root).resolve()
51
+ self.data = data
52
+
53
+ @classmethod
54
+ def load(cls, root: Path | str) -> "Config":
55
+ root = Path(root).resolve()
56
+ path = root / AGENT_DIR / "project.yaml"
57
+ user = load_yaml(path) if path.exists() else {}
58
+ cfg = cls(root, deep_merge(defaults(), user))
59
+ if cfg.lean and "max_attempts_per_task" not in (user.get("retries") or {}):
60
+ cfg.data["retries"]["max_attempts_per_task"] = 2 # O10: one retry under lean, unless the project sets it
61
+ return cfg
62
+
63
+ @property
64
+ def lean(self) -> bool:
65
+ return self.get("profile", "full") == "lean"
66
+
67
+ def get(self, dotted: str, default: Any = None) -> Any:
68
+ cur: Any = self.data
69
+ for part in dotted.split("."):
70
+ if not isinstance(cur, dict) or part not in cur:
71
+ return default
72
+ cur = cur[part]
73
+ return default if cur is None else cur
74
+
75
+ def commands(self, *groups: str) -> list[str]:
76
+ out: list[str] = []
77
+ for g in groups:
78
+ val = self.get(f"commands.{g}", []) or []
79
+ out.extend([val] if isinstance(val, str) else list(val))
80
+ return [c for c in out if c and str(c).strip()]
81
+
82
+ def parallel_size(self) -> int:
83
+ """N12: `scheduling.parallel`; unset = 3 with a test command in direct git mode, else 1."""
84
+ v = self.get("scheduling.parallel")
85
+ if v is None:
86
+ return 3 if self.commands("test") and self.get("git.mode", "direct") == "direct" else 1
87
+ return max(1, int(v or 1))
88
+
89
+ @property
90
+ def agent_dir(self) -> Path:
91
+ return self.root / AGENT_DIR
92
+
93
+ @property
94
+ def main_branch(self) -> str:
95
+ return self.get("main_branch", "main")
96
+
97
+ def validate(self) -> list[str]:
98
+ errs = []
99
+ ladder = self.get("models.ladder", {})
100
+ for r in RISKS:
101
+ if not ladder.get(r):
102
+ errs.append(f"models.ladder.{r} is empty")
103
+ backend = self.get("agent.backend")
104
+ if backend not in ("claude_cli", "command"):
105
+ errs.append(f"agent.backend must be claude_cli or command, got {backend!r}")
106
+ if backend == "command":
107
+ from .backends.command import PRESETS, SAFE_MODEL
108
+ preset = self.get("agent.preset")
109
+ names = [str(v) for v in (self.get("agent.model_map", {}) or {}).values()]
110
+ if preset and any(not SAFE_MODEL.fullmatch(n) for n in names):
111
+ errs.append("agent.model_map names may use only letters, digits and . _ : / @ -")
112
+ if preset and preset not in PRESETS:
113
+ errs.append(f"agent.preset must be one of {', '.join(PRESETS)}, got {preset!r}")
114
+ elif not (self.get("agent.command") or preset):
115
+ errs.append(f"agent.backend=command needs agent.command or agent.preset ({', '.join(PRESETS)})") # P08-T02
116
+ for env in ("staging", "prod"):
117
+ if self.get(f"deploy.{env}.enabled") and not self.get(f"deploy.{env}.cmd"):
118
+ errs.append(f"deploy.{env}.enabled but deploy.{env}.cmd is empty")
119
+ if self.get("deploy.prod.max_auto_risk") not in RISKS:
120
+ errs.append("deploy.prod.max_auto_risk must be one of " + ", ".join(RISKS))
121
+ if not self.commands("build", "lint", "typecheck", "test"):
122
+ errs.append("no verify commands (build/lint/typecheck/test) — the gate would pass anything")
123
+ if self.get("usage.billing", "subscription") not in ("subscription", "api"):
124
+ errs.append("usage.billing must be subscription or api")
125
+ return errs + self._git_errors() + self._sandbox_errors()
126
+
127
+ def _sandbox_errors(self) -> list[str]:
128
+ if not self.get("sandbox.enabled"):
129
+ return []
130
+ if self.get("agent.backend") != "claude_cli":
131
+ return ["sandbox.enabled needs agent.backend: claude_cli (it is Claude Code's own sandbox)"]
132
+ if NATIVE_WINDOWS: # Claude Code's sandbox runs on Linux, WSL2 and macOS only
133
+ return ["sandbox.enabled is not supported on native Windows: run Autopilot in WSL2 or the Docker image"]
134
+ if not isinstance(self.get("sandbox.allowed_domains", []), list):
135
+ return ["sandbox.allowed_domains must be a list of host names"]
136
+ unsafe = [a for a in self.get("agent.extra_args", []) if str(a).split("=")[0] in SANDBOX_UNSAFE_ARGS]
137
+ if unsafe:
138
+ return [f"agent.extra_args {unsafe} would override the sandbox; remove them or set sandbox.enabled: false"]
139
+ return []
140
+
141
+ def _git_errors(self) -> list[str]:
142
+ mode = self.get("git.mode", "direct")
143
+ if mode not in ("direct", "pr"):
144
+ return [f"git.mode must be direct or pr, got {mode!r}"]
145
+ if mode == "pr" and not self.get("git.push"): # else local bookkeeping commits make main diverge from GitHub
146
+ return ["git.mode: pr needs git.push: true (Autopilot's own plan and docs commits go straight to main)"]
147
+ return []
148
+
149
+ def blocking_errors(self) -> list[str]:
150
+ """The subset of validate() that must stop a run."""
151
+ errs = [f"deploy.{e}.enabled but deploy.{e}.cmd is empty" for e in ("staging", "prod")
152
+ if self.get(f"deploy.{e}.enabled") and not self.get(f"deploy.{e}.cmd")]
153
+ if not self.get("gate.allow_no_checks") and not self.commands("build", "lint", "typecheck", "test"):
154
+ errs.append("no verify commands (build/lint/typecheck/test) — the gate would pass anything")
155
+ return errs + self._git_errors() + self._sandbox_errors()