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
|
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()
|