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/cli.py
ADDED
|
@@ -0,0 +1,1614 @@
|
|
|
1
|
+
"""autopilot CLI."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from . import AGENT_DIR, __version__
|
|
16
|
+
|
|
17
|
+
# J15: the same plain-words opening as the README; `ap --help` and the bare `autopilot` banner show it first.
|
|
18
|
+
DESCRIPTION = ("Autopilot builds your software project while you are away. You write what you want in plain words "
|
|
19
|
+
"and start it once. You get working software, tested, with a record of every step. "
|
|
20
|
+
"`ap` is the short name of `autopilot`.")
|
|
21
|
+
from .config import TEMPLATES, Config, load_yaml
|
|
22
|
+
from .plan import Plan, PlanError
|
|
23
|
+
from .proc import exclusive_lock
|
|
24
|
+
|
|
25
|
+
AGENT_FILES = ["project.yaml", "plan.yaml", "BRAIN.md", "DECISIONS.md", "HANDOFF.md"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def setup_logging(root: Path, verbose: bool):
|
|
29
|
+
logdir = root / AGENT_DIR / "logs"
|
|
30
|
+
logdir.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
handlers = [logging.StreamHandler(sys.stdout), logging.FileHandler(logdir / "autopilot.log", encoding="utf-8")]
|
|
32
|
+
logging.basicConfig(level=logging.DEBUG if verbose else logging.INFO, handlers=handlers,
|
|
33
|
+
format="%(asctime)s %(levelname)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def detect_stack(root: Path) -> str:
|
|
37
|
+
order = ["node", "python", "go", "rust", "java", "docker", "static"]
|
|
38
|
+
for name in order:
|
|
39
|
+
preset = load_yaml(TEMPLATES / "presets" / f"{name}.yaml")
|
|
40
|
+
if any((root / f).exists() for f in preset.get("detect", [])):
|
|
41
|
+
return name
|
|
42
|
+
return "generic"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------- commands
|
|
46
|
+
def cmd_init(args):
|
|
47
|
+
root = Path(args.path).resolve()
|
|
48
|
+
ad = root / AGENT_DIR
|
|
49
|
+
ad.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
stack = args.stack or detect_stack(root)
|
|
51
|
+
for name in AGENT_FILES:
|
|
52
|
+
dst = ad / name
|
|
53
|
+
if dst.exists() and not args.force:
|
|
54
|
+
print(f"keep {dst.relative_to(root)}")
|
|
55
|
+
continue
|
|
56
|
+
shutil.copy(TEMPLATES / "agent" / name, dst)
|
|
57
|
+
print(f"create {dst.relative_to(root)}")
|
|
58
|
+
# apply stack preset to project.yaml (text edit keeps the template's comments)
|
|
59
|
+
proj_path = ad / "project.yaml"
|
|
60
|
+
proj = load_yaml(proj_path)
|
|
61
|
+
if args.force or proj.get("stack") in (None, "generic"):
|
|
62
|
+
import re
|
|
63
|
+
preset = load_yaml(TEMPLATES / "presets" / f"{stack}.yaml")
|
|
64
|
+
name = args.name or (proj.get("name") if proj.get("name") not in (None, "my-project") else root.name)
|
|
65
|
+
commands = {**(proj.get("commands") or {}), **preset.get("commands", {})}
|
|
66
|
+
if stack == "python" and not shutil.which("ruff"): # O2: one linter, decided here, never `ruff || flake8`
|
|
67
|
+
commands["lint"] = ["python -m flake8 ."] if shutil.which("flake8") else []
|
|
68
|
+
print("lint: " + (commands["lint"][0] if commands["lint"] else "none (install ruff for a lint gate)"))
|
|
69
|
+
cmd_block = yaml.safe_dump({"commands": commands}, sort_keys=False, width=200).rstrip() + "\n"
|
|
70
|
+
text = proj_path.read_text(encoding="utf-8")
|
|
71
|
+
text = re.sub(r"(?m)^name:.*$", f"name: {name}", text, count=1)
|
|
72
|
+
text = re.sub(r"(?m)^stack:[^#\n]*", f"stack: {stack} ", text, count=1)
|
|
73
|
+
text = re.sub(r"(?m)^commands:[ \t]*\n(?:[ \t]+[^\n]*\n)*", lambda m: cmd_block, text, count=1)
|
|
74
|
+
proj_path.write_text(text, encoding="utf-8")
|
|
75
|
+
# gitignore + CLAUDE.md pointer
|
|
76
|
+
gi = root / ".gitignore"
|
|
77
|
+
snippet = (TEMPLATES / "agent" / "gitignore").read_text(encoding="utf-8")
|
|
78
|
+
if not gi.exists() or ".agent/state.db" not in gi.read_text(encoding="utf-8"):
|
|
79
|
+
with open(gi, "a", encoding="utf-8") as fh:
|
|
80
|
+
fh.write(snippet)
|
|
81
|
+
cm = root / "CLAUDE.md"
|
|
82
|
+
cm_snip = (TEMPLATES / "agent" / "CLAUDE.md.snippet").read_text(encoding="utf-8")
|
|
83
|
+
if not cm.exists():
|
|
84
|
+
with open(cm, "a", encoding="utf-8") as fh:
|
|
85
|
+
fh.write(cm_snip)
|
|
86
|
+
elif "## Autopilot (autonomous sessions)" not in (t := cm.read_text(encoding="utf-8")) and "## AutoDev (autonomous sessions)" not in t:
|
|
87
|
+
with open(cm, "a", encoding="utf-8") as fh:
|
|
88
|
+
fh.write(cm_snip)
|
|
89
|
+
elif "ap status" not in t: # P08-T02: an older Autopilot section gets only the two command lines, once
|
|
90
|
+
lines = cm_snip.rstrip("\n").splitlines()[-2:]
|
|
91
|
+
with open(cm, "a", encoding="utf-8") as fh:
|
|
92
|
+
fh.write(("" if t.endswith("\n") or not t else "\n") + "\n".join(lines) + "\n")
|
|
93
|
+
write_ide_rules(root) # N6
|
|
94
|
+
from .mcp import register # N9
|
|
95
|
+
register(root)
|
|
96
|
+
from .gate import write_baseline # O2: file sizes on day one, so only new growth fails the gate
|
|
97
|
+
if not (ad / "quality_baseline.json").exists():
|
|
98
|
+
print(f"create {AGENT_DIR}/quality_baseline.json ({write_baseline(root)} files)")
|
|
99
|
+
print(f"\nstack: {stack}")
|
|
100
|
+
if not getattr(args, "quiet", False): # quickstart does these steps itself
|
|
101
|
+
print("next: autopilot onboard --plan-doc <your plan.md> (or edit .agent/plan.yaml)\n"
|
|
102
|
+
" autopilot validate && autopilot run")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
IDE_BEGIN = "<!-- autopilot:begin -->"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def write_ide_rules(root):
|
|
109
|
+
"""N6: one marked Autopilot block in the rule file of each IDE present; user text is never changed."""
|
|
110
|
+
block = (TEMPLATES / "agent" / "ide_rules.md").read_text(encoding="utf-8").replace("\r\n", "\n")
|
|
111
|
+
targets = [] # (path, front matter for a new file)
|
|
112
|
+
if (root / ".cursor").is_dir():
|
|
113
|
+
targets.append((root / ".cursor" / "rules" / "autopilot.mdc", "---\ndescription: Autopilot project rules\nalwaysApply: true\n---\n"))
|
|
114
|
+
if (root / ".kiro").is_dir():
|
|
115
|
+
targets.append((root / ".kiro" / "steering" / "autopilot.md", "---\ninclusion: always\n---\n"))
|
|
116
|
+
if (root / ".vscode").is_dir() and (root / ".github").is_dir(): # P10-T06: VS Code alone is not Copilot
|
|
117
|
+
targets.append((root / ".github" / "copilot-instructions.md", ""))
|
|
118
|
+
for name in ("GEMINI.md", "AGENTS.md"):
|
|
119
|
+
if (root / name).is_file():
|
|
120
|
+
targets.append((root / name, ""))
|
|
121
|
+
for path, front in targets:
|
|
122
|
+
old = path.read_bytes() if path.exists() else b""
|
|
123
|
+
rel = path.relative_to(root).as_posix()
|
|
124
|
+
if IDE_BEGIN.encode() in old:
|
|
125
|
+
print(f"keep {rel}")
|
|
126
|
+
continue
|
|
127
|
+
nl = "\r\n" if b"\r\n" in old else "\n"
|
|
128
|
+
add = (("" if old.endswith(b"\n") else nl) + nl + block.replace("\n", nl)) if old else front + block
|
|
129
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
with open(path, "ab") as fh:
|
|
131
|
+
fh.write(add.encode("utf-8"))
|
|
132
|
+
print(f"{'append' if old else 'create'} {rel}")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def cmd_onboard(args):
|
|
136
|
+
from .backends import SessionRequest, get_backend
|
|
137
|
+
from .context import discovery_pack, render
|
|
138
|
+
|
|
139
|
+
root = Path(args.path).resolve()
|
|
140
|
+
if not (root / AGENT_DIR / "project.yaml").exists():
|
|
141
|
+
cmd_init(argparse.Namespace(path=str(root), stack=None, name=None, force=False, quiet=True))
|
|
142
|
+
setup_logging(root, False) # the session's live activity lines
|
|
143
|
+
cfg = Config.load(root)
|
|
144
|
+
plan_doc = Path(args.plan_doc).read_text(encoding="utf-8") if args.plan_doc else \
|
|
145
|
+
"(no plan document — derive a sensible plan from the repository and README)"
|
|
146
|
+
if len(plan_doc) > 150_000:
|
|
147
|
+
plan_doc = plan_doc[:150_000] + "\n…(truncated)…"
|
|
148
|
+
prompt = render("onboard.md", plan_doc=plan_doc, discovery=discovery_pack(root, cfg)) # N1: read before write
|
|
149
|
+
before = untracked_files(root)
|
|
150
|
+
system = (Path(__file__).parent / "prompts" / "system.md").read_text(encoding="utf-8")
|
|
151
|
+
system += "\nFor THIS onboarding session you ARE allowed to edit the .agent files listed in the assignment."
|
|
152
|
+
backend = get_backend(cfg)
|
|
153
|
+
plan_journal(root, current="makes the task list")
|
|
154
|
+
print(f"running onboarding session ({cfg.get('models.onboard', 'opus')}) …")
|
|
155
|
+
res = backend.run(SessionRequest(prompt=prompt, model=cfg.get("models.onboard", "opus"), cwd=str(root),
|
|
156
|
+
timeout_sec=int(cfg.get("agent.session_timeout_sec", 3600)),
|
|
157
|
+
budget_usd=float(args.budget), system_append=system,
|
|
158
|
+
effort=cfg.get("models.effort.onboard", ""), label="onboarding",
|
|
159
|
+
log_path=str(root / AGENT_DIR / "logs" / "onboard.log")))
|
|
160
|
+
print(res.report.get("summary") or res.text[-1500:] or res.error)
|
|
161
|
+
new = sorted(untracked_files(root) - before) # N1: no new document outside .agent/ and CLAUDE.md
|
|
162
|
+
if new:
|
|
163
|
+
print("WARN onboarding created new files outside .agent/ and CLAUDE.md (kept, check them): " + ", ".join(new))
|
|
164
|
+
if args.plan_doc: # N13: later runs read this file; a change in it reaches the plan with no command
|
|
165
|
+
remember_plan_source(root, Path(args.plan_doc))
|
|
166
|
+
return cmd_validate(args)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def remember_plan_source(root: Path, doc: Path):
|
|
170
|
+
"""N13: `plan_source:` in plan.yaml names the plan document; .agent/plan_source.txt keeps the text last planned from."""
|
|
171
|
+
plan = root / AGENT_DIR / "plan.yaml"
|
|
172
|
+
try:
|
|
173
|
+
rel = doc.resolve().relative_to(root).as_posix()
|
|
174
|
+
except ValueError:
|
|
175
|
+
rel = str(doc.resolve())
|
|
176
|
+
text = plan.read_text(encoding="utf-8") if plan.exists() else ""
|
|
177
|
+
if text and not any(ln.startswith("plan_source:") for ln in text.splitlines()):
|
|
178
|
+
plan.write_text(text.rstrip("\n") + f"\nplan_source: {json.dumps(rel)}\n", encoding="utf-8")
|
|
179
|
+
(root / AGENT_DIR / "plan_source.txt").write_text(doc.read_text(encoding="utf-8"), encoding="utf-8")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def untracked_files(root: Path) -> set:
|
|
183
|
+
"""N1: untracked files (git status) outside .agent/ and CLAUDE.md; empty when this is not a git repository."""
|
|
184
|
+
from .proc import run_proc
|
|
185
|
+
try:
|
|
186
|
+
p = run_proc(["git", "status", "--porcelain", "-uall"], cwd=str(root), timeout=60)
|
|
187
|
+
except (OSError, ValueError):
|
|
188
|
+
return set()
|
|
189
|
+
if p.rc != 0:
|
|
190
|
+
return set()
|
|
191
|
+
files = {ln[3:].strip().strip('"') for ln in p.stdout.splitlines() if ln.startswith("??")}
|
|
192
|
+
return {f for f in files if not f.startswith(AGENT_DIR + "/") and f != "CLAUDE.md"}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def plan_journal(root: Path, **kw):
|
|
196
|
+
"""J4: while quickstart writes the plan and the tasks, .agent/run.json says so, and every view shows
|
|
197
|
+
"ON · Plan" instead of "Idle". Only while the journal is in the Plan state: a run owns it afterwards."""
|
|
198
|
+
from .state import now
|
|
199
|
+
path = root / AGENT_DIR / "run.json"
|
|
200
|
+
try:
|
|
201
|
+
old = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
|
|
202
|
+
if kw.get("status") != "running" and old.get("state") != "Plan":
|
|
203
|
+
return
|
|
204
|
+
path.write_text(json.dumps({**old, **kw, "updated_at": now()}, indent=1), encoding="utf-8")
|
|
205
|
+
except (OSError, ValueError):
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def cmd_quickstart(args):
|
|
210
|
+
"""M5: init → environment fixes → (claude CLI check) → onboard → doctor → optionally run, in one command."""
|
|
211
|
+
from .state import now
|
|
212
|
+
root = Path(args.path).resolve()
|
|
213
|
+
if not (root / AGENT_DIR / "project.yaml").exists():
|
|
214
|
+
cmd_init(argparse.Namespace(path=str(root), stack=None, name=None, force=False, quiet=True))
|
|
215
|
+
if not preflight(Config.load(root)): # don't start onboarding on a CLI that can't run a session
|
|
216
|
+
return 1
|
|
217
|
+
plan_journal(root, status="running", state="Plan", current="makes the plan", started_at=now(), outcome="",
|
|
218
|
+
task="", pid=os.getpid())
|
|
219
|
+
try:
|
|
220
|
+
return _quickstart(args, root)
|
|
221
|
+
finally: # no run started (or it failed before one): the status must not say ON for ever
|
|
222
|
+
plan_journal(root, status="finished", state="Done", current="", outcome="plan ready")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _quickstart(args, root: Path):
|
|
226
|
+
if args.plan_doc and not Path(args.plan_doc).is_file():
|
|
227
|
+
print(f"plan document not found: {args.plan_doc}")
|
|
228
|
+
return 1
|
|
229
|
+
if not args.plan_doc: # no plan given: use the project's own, or have Claude write one from the owner's idea
|
|
230
|
+
found = next((root / p for p in ("PLAN.md", "docs/PLAN.md") if (root / p).is_file()), None)
|
|
231
|
+
if found:
|
|
232
|
+
print(f"using {found.relative_to(root)}")
|
|
233
|
+
else:
|
|
234
|
+
idea = getattr(args, "idea", None) or ask_idea()
|
|
235
|
+
if idea and Path(idea).is_file():
|
|
236
|
+
idea = Path(idea).read_text(encoding="utf-8")
|
|
237
|
+
if not (idea or "").strip():
|
|
238
|
+
print("no PLAN.md here and no idea given: describe what to build with --idea \"...\" (or a file)")
|
|
239
|
+
return 1
|
|
240
|
+
found = write_plan(root, idea.strip(), args.budget)
|
|
241
|
+
if not found:
|
|
242
|
+
return 1
|
|
243
|
+
args.plan_doc = str(found)
|
|
244
|
+
if cmd_onboard(args): # onboard ends with validate: 1 = the plan or config it wrote is invalid
|
|
245
|
+
print("onboarding left an invalid plan or config: fix it (see above), then `autopilot doctor`")
|
|
246
|
+
return 1
|
|
247
|
+
print("\n--- doctor")
|
|
248
|
+
if cmd_doctor(args):
|
|
249
|
+
print("fix the FAIL lines, then `autopilot run`")
|
|
250
|
+
return 1
|
|
251
|
+
if not args.run:
|
|
252
|
+
from .report import next_steps_text
|
|
253
|
+
print("review .agent/plan.yaml and .agent/BRAIN.md (the cheapest moment to change the plan)")
|
|
254
|
+
print(next_steps_text(root, running=False))
|
|
255
|
+
return 0
|
|
256
|
+
return cmd_run(argparse.Namespace(path=str(root), verbose=False, clear_stop=False, max_sessions=None))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def ask_idea(intro: str = "No PLAN.md found. Describe what you want to build: the product, who it is for, the main "
|
|
260
|
+
"features, any\ntech you want. Claude turns it into a full plan. Finish with an empty line.") -> str:
|
|
261
|
+
"""Ask the owner in the terminal what to build (several lines, an empty line ends). '' when not interactive."""
|
|
262
|
+
if not sys.stdin.isatty():
|
|
263
|
+
return ""
|
|
264
|
+
print(intro)
|
|
265
|
+
lines = []
|
|
266
|
+
while True:
|
|
267
|
+
try:
|
|
268
|
+
line = input("> " if not lines else " ")
|
|
269
|
+
except EOFError:
|
|
270
|
+
break
|
|
271
|
+
if not line.strip():
|
|
272
|
+
break
|
|
273
|
+
lines.append(line)
|
|
274
|
+
return "\n".join(lines)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def write_plan(root: Path, idea: str, budget: str) -> Path | None:
|
|
278
|
+
"""One Claude Code session turns the owner's idea into a detailed PLAN.md (architecture + phased build plan)."""
|
|
279
|
+
from .backends import SessionRequest, get_backend
|
|
280
|
+
from .context import render
|
|
281
|
+
setup_logging(root, False)
|
|
282
|
+
cfg = Config.load(root)
|
|
283
|
+
model = cfg.get("models.plan_writer", "") or cfg.get("models.onboard", "opus")
|
|
284
|
+
print(f"writing PLAN.md from your idea ({model}) …")
|
|
285
|
+
res = get_backend(cfg).run(SessionRequest(
|
|
286
|
+
prompt=render("plan_writer.md", idea=idea), model=model, cwd=str(root), label="writing PLAN.md",
|
|
287
|
+
timeout_sec=int(cfg.get("agent.session_timeout_sec", 3600)), budget_usd=float(budget),
|
|
288
|
+
effort=cfg.get("models.effort.onboard", ""), log_path=str(root / AGENT_DIR / "logs" / "plan_writer.log")))
|
|
289
|
+
plan = root / "PLAN.md"
|
|
290
|
+
if not plan.is_file() or len(plan.read_text(encoding="utf-8", errors="replace")) < 500:
|
|
291
|
+
print(f"the plan session did not write a usable PLAN.md: {res.error or res.text[-500:]}")
|
|
292
|
+
return None
|
|
293
|
+
print(res.report.get("summary") or "PLAN.md written")
|
|
294
|
+
return plan
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def preflight(cfg) -> bool:
|
|
298
|
+
"""Before quickstart or a run: fix the machine where possible (PATH, the claude CLI), then make sure the agent CLI
|
|
299
|
+
can start. False = it can't, and the reason is printed."""
|
|
300
|
+
from .doctor import FAIL, autofix, check_claude, check_path, check_runner_login
|
|
301
|
+
if cfg is not None and not runner_set(cfg.root):
|
|
302
|
+
if not pick_runner(cfg.root):
|
|
303
|
+
return False
|
|
304
|
+
cfg = Config.load(cfg.root)
|
|
305
|
+
cli = cfg is None or cfg.get("agent.backend", "claude_cli") == "claude_cli" # no project yet (H5): the default
|
|
306
|
+
rows = [check_path()] + (check_claude(cfg) if cli else [])
|
|
307
|
+
for line in autofix(rows):
|
|
308
|
+
print(f"fix {line}")
|
|
309
|
+
if cli:
|
|
310
|
+
bad = [r for r in check_claude(cfg) if r[0] == FAIL]
|
|
311
|
+
if bad and bad[0][1] == "claude login" and login_now(): # H2: only here, at the start; never during a run
|
|
312
|
+
bad = [r for r in check_claude(cfg) if r[0] == FAIL]
|
|
313
|
+
if bad:
|
|
314
|
+
print(f"FAIL {bad[0][1]}: {bad[0][2]}")
|
|
315
|
+
return False
|
|
316
|
+
elif cfg is not None: # N10: the other runner's own subscription login, before the first session
|
|
317
|
+
level, name, detail = check_runner_login(cfg)
|
|
318
|
+
print(f"{level:<5} {name}: {detail}")
|
|
319
|
+
if level == FAIL:
|
|
320
|
+
return False
|
|
321
|
+
return install_deps(cfg) if cfg is not None else True
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def install_deps(cfg) -> bool:
|
|
325
|
+
"""N2: project dependencies install without a question; system tools never (the exact command is printed and the
|
|
326
|
+
run stops). Uses commands.setup when set; when empty, runs the standard command and writes it into commands.setup."""
|
|
327
|
+
from .doctor import missing_deps, system_tool_hint
|
|
328
|
+
from .gate import run_commands
|
|
329
|
+
miss = missing_deps(cfg.root)
|
|
330
|
+
if not miss:
|
|
331
|
+
return True
|
|
332
|
+
for m in miss:
|
|
333
|
+
tool = m["tool"]
|
|
334
|
+
if not (shutil.which(tool) or (tool == "python" and (shutil.which("python") or shutil.which("python3")))):
|
|
335
|
+
from .rights import install_system_tool # N8: through the rights the installer took; never a question
|
|
336
|
+
if install_system_tool(tool, system_tool_hint(tool)):
|
|
337
|
+
print(f"ok {tool} installed")
|
|
338
|
+
continue
|
|
339
|
+
print(f"FAIL {tool} is not installed. Install it with: {system_tool_hint(tool)}")
|
|
340
|
+
return False
|
|
341
|
+
setup = cfg.commands("setup")
|
|
342
|
+
cmds = setup or list(dict.fromkeys(c for m in miss for c in venv_commands(cfg.root, m["command"])))
|
|
343
|
+
note = logging.getLogger("autopilot.preflight")
|
|
344
|
+
for cmd in cmds:
|
|
345
|
+
print(f"setup {cmd}")
|
|
346
|
+
note.info("preflight: installing project dependencies: %s", cmd)
|
|
347
|
+
r = run_commands([cmd], cfg.root, int(cfg.get("verify_timeout_sec", 1200)))[0]
|
|
348
|
+
note.info("preflight: %s -> exit %s", cmd, r.rc)
|
|
349
|
+
if r.rc != 0:
|
|
350
|
+
print(f"FAIL {cmd} failed (exit {r.rc}):\n{r.output[-1500:]}")
|
|
351
|
+
return False
|
|
352
|
+
if not setup:
|
|
353
|
+
remember_setup(cfg.root / AGENT_DIR / "project.yaml", cmds)
|
|
354
|
+
return True
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def venv_commands(root, cmd: str) -> list[str]:
|
|
358
|
+
"""A bare `pip install ...` never runs on the system Python: use the active venv, a project venv, or make .venv."""
|
|
359
|
+
if not cmd.startswith("pip install "):
|
|
360
|
+
return [cmd]
|
|
361
|
+
pip = f"-m pip {cmd[4:]}"
|
|
362
|
+
py = "python" if shutil.which("python") else "python3"
|
|
363
|
+
if os.environ.get("VIRTUAL_ENV"):
|
|
364
|
+
return [f"{py} {pip}"]
|
|
365
|
+
sub = "Scripts\\python.exe" if os.name == "nt" else "bin/python"
|
|
366
|
+
for n in (".venv", "venv", "env"):
|
|
367
|
+
if (Path(root) / n / "pyvenv.cfg").is_file():
|
|
368
|
+
return [f"{n}\\{sub} {pip}" if os.name == "nt" else f"{n}/{sub} {pip}"]
|
|
369
|
+
return [f"{py} -m venv .venv", f".venv\\{sub} {pip}" if os.name == "nt" else f".venv/{sub} {pip}"]
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def remember_setup(proj_path: Path, cmds: list[str]) -> None:
|
|
373
|
+
"""N2: write `commands.setup` as a text edit, so the comments of project.yaml stay (same approach as cmd_init)."""
|
|
374
|
+
import re
|
|
375
|
+
text = proj_path.read_text(encoding="utf-8")
|
|
376
|
+
value = json.dumps(cmds)
|
|
377
|
+
new, n = re.subn(r"(?m)^([ \t]+setup:)[ \t]*\[\]", lambda m: f"{m.group(1)} {value}", text, count=1)
|
|
378
|
+
if not n:
|
|
379
|
+
new, n = re.subn(r"(?m)^(commands:[ \t]*(?:#.*)?)$", lambda m: f"{m.group(1)}\n setup: {value}", text, count=1)
|
|
380
|
+
if n:
|
|
381
|
+
proj_path.write_text(new, encoding="utf-8")
|
|
382
|
+
print(f"setup saved in commands.setup of {proj_path.name}")
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def runner_set(root: Path) -> bool:
|
|
386
|
+
"""N3: does the project's own project.yaml choose the agent CLI? Read raw: Config merges the claude_cli default.
|
|
387
|
+
An unusual `agent:` value (not a mapping) counts as set, so the file is left alone."""
|
|
388
|
+
path = Path(root) / AGENT_DIR / "project.yaml"
|
|
389
|
+
try:
|
|
390
|
+
agent = (load_yaml(path) if path.exists() else {}).get("agent") or {}
|
|
391
|
+
except (yaml.YAMLError, AttributeError):
|
|
392
|
+
return True # a file preflight cannot read is never edited; validate reports it
|
|
393
|
+
return not isinstance(agent, dict) or any(agent.get(k) for k in ("backend", "command", "preset"))
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def pick_runner(root: Path) -> bool:
|
|
397
|
+
"""N3: no runner set -> write the first installed, logged-in agent CLI on PATH. False = none (install hint printed).
|
|
398
|
+
A claude that is installed but logged out stays the default with no write, so the H2 login offer still runs."""
|
|
399
|
+
from .backends.claude_cli import ClaudeCLIBackend
|
|
400
|
+
from .doctor import INSTALL, RUNNERS, detect_runner
|
|
401
|
+
if os.environ.get("AUTOPILOT_CLAUDE_BIN") or os.environ.get("AUTODEV_CLAUDE_BIN"):
|
|
402
|
+
return True # an explicit claude binary is a choice: keep the claude_cli default
|
|
403
|
+
choice = detect_runner()
|
|
404
|
+
if choice:
|
|
405
|
+
if not remember_runner(Path(root) / AGENT_DIR / "project.yaml", choice):
|
|
406
|
+
return False
|
|
407
|
+
print(f"runner: {next(b for b, be, *_ in RUNNERS if be == choice)} (found on PATH)")
|
|
408
|
+
return True
|
|
409
|
+
if shutil.which(ClaudeCLIBackend.resolve_binary()):
|
|
410
|
+
return True
|
|
411
|
+
print(f"FAIL no agent CLI found. Install Claude Code with: {INSTALL['native'][1]}\n"
|
|
412
|
+
f" supported: {', '.join(b for b, *_ in RUNNERS)}")
|
|
413
|
+
return False
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def remember_runner(proj_path: Path, choice: str) -> bool:
|
|
417
|
+
"""N3: write agent.backend (and agent.preset) as a text edit after the `agent:` line, so comments stay. The new
|
|
418
|
+
text is parsed first: when it would not give exactly the wanted values, nothing is written."""
|
|
419
|
+
import re
|
|
420
|
+
want = {"backend": "claude_cli"} if choice == "claude_cli" else {"backend": "command", "preset": choice}
|
|
421
|
+
text = proj_path.read_text(encoding="utf-8") if proj_path.exists() else ""
|
|
422
|
+
m = re.search(r"(?m)^agent:[ \t]*(?:#.*)?$", text)
|
|
423
|
+
if m:
|
|
424
|
+
nxt = re.search(r"(?m)^([ \t]+)\S", text[m.end():])
|
|
425
|
+
ind = nxt.group(1) if nxt else " "
|
|
426
|
+
new = text[:m.end()] + "".join(f"\n{ind}{k}: {v}" for k, v in want.items()) + text[m.end():]
|
|
427
|
+
else:
|
|
428
|
+
sep = "" if not text or text.endswith("\n") else "\n"
|
|
429
|
+
new = text + sep + "agent:" + "".join(f"\n {k}: {v}" for k, v in want.items()) + "\n"
|
|
430
|
+
try:
|
|
431
|
+
old_d, new_d = (yaml.safe_load(text) or {}), (yaml.safe_load(new) or {})
|
|
432
|
+
old_a = old_d.get("agent") or {}
|
|
433
|
+
ok = (isinstance(new_d.get("agent"), dict) and new_d["agent"] == {**old_a, **want}
|
|
434
|
+
and {k: v for k, v in new_d.items() if k != "agent"} == {k: v for k, v in old_d.items() if k != "agent"})
|
|
435
|
+
except (yaml.YAMLError, AttributeError, TypeError):
|
|
436
|
+
ok = False
|
|
437
|
+
if not ok:
|
|
438
|
+
print("FAIL set agent.backend in .agent/project.yaml (see REFERENCE.md)")
|
|
439
|
+
return False
|
|
440
|
+
proj_path.parent.mkdir(parents=True, exist_ok=True)
|
|
441
|
+
proj_path.write_text(new, encoding="utf-8")
|
|
442
|
+
return True
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def login_now() -> bool:
|
|
446
|
+
"""H2: with a terminal, offer the Claude login and run it. False = no terminal (never ask), or the owner stops."""
|
|
447
|
+
if not (sys.stdin.isatty() and sys.stdout.isatty()) or os.environ.get("SSH_CONNECTION"):
|
|
448
|
+
return False
|
|
449
|
+
# N5.4: no question. The login opens in the browser; the user types nothing here (a paid Claude plan is necessary).
|
|
450
|
+
print("Claude Code is not logged in. The login opens in your browser now (Ctrl+C stops).")
|
|
451
|
+
try:
|
|
452
|
+
import subprocess
|
|
453
|
+
|
|
454
|
+
from .backends.claude_cli import ClaudeCLIBackend
|
|
455
|
+
subprocess.run([ClaudeCLIBackend.resolve_binary(), "auth", "login"], check=False)
|
|
456
|
+
except (EOFError, KeyboardInterrupt, OSError):
|
|
457
|
+
return False
|
|
458
|
+
return True
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def safe_folder(p: Path) -> str:
|
|
462
|
+
"""H5: why a folder must not hold a new project ('' = fine). Sessions run with no prompts, so they get the folder."""
|
|
463
|
+
home = Path.home().resolve()
|
|
464
|
+
if p == home:
|
|
465
|
+
return "this is your home folder"
|
|
466
|
+
if p.parent == p or home.is_relative_to(p):
|
|
467
|
+
return "this folder contains your home folder, or is a drive or filesystem root"
|
|
468
|
+
return ""
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def ask_folder(offer: Path) -> Path:
|
|
472
|
+
"""H5: ask where the project goes until the answer is safe. Enter takes `offer`."""
|
|
473
|
+
while True:
|
|
474
|
+
raw = input(f"Folder for the project (Enter = {offer}): ").strip().strip('"')
|
|
475
|
+
p = (Path.cwd() / Path(raw).expanduser()).resolve() if raw else offer.resolve()
|
|
476
|
+
if why := safe_folder(p):
|
|
477
|
+
print(f"not this one: {why}. Choose another.")
|
|
478
|
+
elif p.is_file():
|
|
479
|
+
print("not this one: it is a file. Choose another.")
|
|
480
|
+
elif (p / AGENT_DIR / "project.yaml").exists():
|
|
481
|
+
print(f"{p} already has an Autopilot project: the start continues it.")
|
|
482
|
+
return p
|
|
483
|
+
elif p.is_dir() and (items := sorted(x.name for x in p.iterdir())):
|
|
484
|
+
print(f"{p} is not empty ({len(items)} entries): {', '.join(items[:10])}{' …' if len(items) > 10 else ''}")
|
|
485
|
+
print(" 1 use this folder\n 2 choose a different folder")
|
|
486
|
+
if input("> ").strip() == "1":
|
|
487
|
+
return p
|
|
488
|
+
else:
|
|
489
|
+
return p
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
CODE_SUFFIXES = {".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".rs", ".java", ".c", ".cpp", ".h", ".cs", ".rb", ".php",
|
|
493
|
+
".sh", ".html", ".css", ".toml", ".json", ".yaml", ".yml"}
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def idea_file(root: Path) -> str:
|
|
497
|
+
"""N5.5: the idea from a file: IDEA.md, or the one .md file (under 2000 words) in a folder with no code files
|
|
498
|
+
and no subfolders. '' = none."""
|
|
499
|
+
try:
|
|
500
|
+
seen = [x for x in root.iterdir() if not x.name.startswith(".")]
|
|
501
|
+
md = [x for x in seen if x.is_file() and x.suffix.lower() == ".md"]
|
|
502
|
+
pick = next((x for x in md if x.name.lower() == "idea.md"), None)
|
|
503
|
+
if pick is None and len(md) == 1 and not any(x.is_dir() or x.suffix.lower() in CODE_SUFFIXES for x in seen):
|
|
504
|
+
pick = md[0]
|
|
505
|
+
text = pick.read_text(encoding="utf-8", errors="replace").strip() if pick else ""
|
|
506
|
+
except OSError:
|
|
507
|
+
return ""
|
|
508
|
+
return text if pick is not None and (pick.name.lower() == "idea.md" or len(text.split()) < 2000) else ""
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def needs_you(plan: Plan) -> bool:
|
|
512
|
+
"""N5.5: does the plan hold a task for the owner or a critical one? Then the start keeps its question."""
|
|
513
|
+
return any(t.owner_only or t.risk == "critical" for t in plan.all_tasks())
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def three_lines(plan: Plan) -> str:
|
|
517
|
+
"""N5.5: done / open / next in three lines."""
|
|
518
|
+
tasks = plan.all_tasks()
|
|
519
|
+
done = sum(1 for t in tasks if t.status == "done")
|
|
520
|
+
nxt = plan.next_ready({})
|
|
521
|
+
return f"Done: {done} of {len(tasks)} tasks.\nOpen: {len(tasks) - done}.\n" + (
|
|
522
|
+
f"Next: {nxt.id} {nxt.title}" if nxt else "Next: none yet.")
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def guided_start(ask: bool = False) -> int:
|
|
526
|
+
"""H5: plain `autopilot` in a terminal, no project here: ask the idea and the folder, then quickstart and run.
|
|
527
|
+
N5.5: the idea may come from a file; a plan with no owner or critical task starts at once, unless `ask`."""
|
|
528
|
+
from .docs import slug
|
|
529
|
+
try:
|
|
530
|
+
idea = idea_file(Path.cwd())
|
|
531
|
+
if idea:
|
|
532
|
+
root = Path.cwd().resolve()
|
|
533
|
+
if why := safe_folder(root):
|
|
534
|
+
print(f"not building in this folder: {why}.")
|
|
535
|
+
return -1
|
|
536
|
+
else:
|
|
537
|
+
idea = ask_idea("What do you want to build? The product, who it is for, the main features, any tech you "
|
|
538
|
+
"want.\nFinish with an empty line.").strip()
|
|
539
|
+
if not idea:
|
|
540
|
+
return -1
|
|
541
|
+
root = ask_folder(Path.cwd() / slug((idea.splitlines()[0]), 30, "myapp"))
|
|
542
|
+
if not preflight(None): # the login is checked before anything is created
|
|
543
|
+
return 1
|
|
544
|
+
rc = cmd_quickstart(argparse.Namespace(path=str(root), plan_doc=None, budget="15", run=False, idea=idea))
|
|
545
|
+
if rc:
|
|
546
|
+
return rc
|
|
547
|
+
plan = Plan.load(root / AGENT_DIR / "plan.yaml")
|
|
548
|
+
print("\nThe plan:")
|
|
549
|
+
for ph in plan.phases:
|
|
550
|
+
print(f" {ph.id} {ph.title} ({len(ph.tasks)} tasks)")
|
|
551
|
+
print(f" {len(plan.phases)} phases, {len(plan.all_tasks())} tasks")
|
|
552
|
+
print("The build runs for hours. It uses your Claude plan.\nIt works without asking you.")
|
|
553
|
+
if not ask and not needs_you(plan): # N5.5
|
|
554
|
+
print("Starting. Stop with ap stop.\n" + three_lines(plan))
|
|
555
|
+
return cmd_run(argparse.Namespace(path=str(root), verbose=False, clear_stop=False, max_sessions=None))
|
|
556
|
+
print(" 1 start now\n 2 not now")
|
|
557
|
+
if input("> ").strip() == "1":
|
|
558
|
+
return cmd_run(argparse.Namespace(path=str(root), verbose=False, clear_stop=False, max_sessions=None))
|
|
559
|
+
print(f"Start later with: autopilot run -C {root}")
|
|
560
|
+
return 0
|
|
561
|
+
except KeyboardInterrupt:
|
|
562
|
+
print()
|
|
563
|
+
return 130
|
|
564
|
+
except EOFError:
|
|
565
|
+
print()
|
|
566
|
+
return 0
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def folder_case(root: Path) -> str:
|
|
570
|
+
"""N4: what plain `ap` finds here: "agent" (.agent/project.yaml), "empty" (no entries but dotfiles, e.g. a bare
|
|
571
|
+
git repo) or "code" (files or documents, no project)."""
|
|
572
|
+
if (root / AGENT_DIR / "project.yaml").exists():
|
|
573
|
+
return "agent"
|
|
574
|
+
try:
|
|
575
|
+
return "code" if any(not x.name.startswith(".") for x in root.iterdir()) else "empty"
|
|
576
|
+
except OSError:
|
|
577
|
+
return "empty"
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def adopt_start(root: Path) -> int:
|
|
581
|
+
"""N4: plain `autopilot` in a terminal, code and no .agent/: init, read-first onboarding, one-screen summary, start?"""
|
|
582
|
+
from .report import adopt_summary
|
|
583
|
+
if why := safe_folder(root):
|
|
584
|
+
print(f"not adopting this folder: {why}.")
|
|
585
|
+
return -1
|
|
586
|
+
try:
|
|
587
|
+
if not preflight(None): # the login is checked before anything is created
|
|
588
|
+
return 1
|
|
589
|
+
print(f"Found a project without Autopilot in {root}. Reading it …")
|
|
590
|
+
cmd_init(argparse.Namespace(path=str(root), stack=None, name=None, force=False, quiet=True))
|
|
591
|
+
rc = cmd_onboard(argparse.Namespace(path=str(root), plan_doc=None, budget="15", run=False, idea=None))
|
|
592
|
+
if rc:
|
|
593
|
+
return rc
|
|
594
|
+
print("\n" + adopt_summary(root))
|
|
595
|
+
print("The build runs for hours. It uses your Claude plan.\nIt works without asking you.")
|
|
596
|
+
print(" 1 start now\n 2 not now")
|
|
597
|
+
if input("> ").strip() == "1":
|
|
598
|
+
return cmd_run(argparse.Namespace(path=str(root), verbose=False, clear_stop=False, max_sessions=None))
|
|
599
|
+
print(f"Start later with: autopilot run -C {root}")
|
|
600
|
+
return 0
|
|
601
|
+
except KeyboardInterrupt:
|
|
602
|
+
print()
|
|
603
|
+
return 130
|
|
604
|
+
except EOFError:
|
|
605
|
+
print()
|
|
606
|
+
return 0
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def cmd_validate(args):
|
|
610
|
+
root = Path(args.path).resolve()
|
|
611
|
+
cfg = Config.load(root)
|
|
612
|
+
ok = True
|
|
613
|
+
blocking = cfg.blocking_errors()
|
|
614
|
+
for e in blocking:
|
|
615
|
+
ok = False
|
|
616
|
+
print(f"config ERROR {e}")
|
|
617
|
+
for e in cfg.validate():
|
|
618
|
+
if e not in blocking:
|
|
619
|
+
print(f"config WARN {e}")
|
|
620
|
+
try:
|
|
621
|
+
plan = Plan.load(root / AGENT_DIR / "plan.yaml")
|
|
622
|
+
tasks = plan.all_tasks()
|
|
623
|
+
print(f"plan OK {len(plan.phases)} phases, {len(tasks)} tasks")
|
|
624
|
+
weak = [t.id for t in tasks if len(t.acceptance_criteria) < 1]
|
|
625
|
+
if weak:
|
|
626
|
+
print(f"plan WARN {len(weak)} tasks without acceptance criteria: {', '.join(weak[:10])}"
|
|
627
|
+
f"{' …' if len(weak) > 10 else ''}")
|
|
628
|
+
wide = [t.id for t in tasks if not t.files_in_scope and not t.owner_only and t.status != "done"]
|
|
629
|
+
if wide: # O1: an empty scope means the whole repo, so the task runs alone and nothing is gated
|
|
630
|
+
print(f"plan WARN {len(wide)} tasks without files_in_scope (whole repo, run alone): "
|
|
631
|
+
f"{', '.join(wide[:10])}{' …' if len(wide) > 10 else ''}")
|
|
632
|
+
except PlanError as e:
|
|
633
|
+
ok = False
|
|
634
|
+
for err in e.errors:
|
|
635
|
+
print(f"plan ERROR {err}")
|
|
636
|
+
except FileNotFoundError:
|
|
637
|
+
ok = False
|
|
638
|
+
print("plan ERROR .agent/plan.yaml not found — run `autopilot init`")
|
|
639
|
+
return 0 if ok else 1
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def enable_vt() -> bool:
|
|
643
|
+
"""Can this terminal take escape codes? Windows consoles need virtual terminal mode switched on first."""
|
|
644
|
+
if os.name != "nt":
|
|
645
|
+
return True
|
|
646
|
+
try:
|
|
647
|
+
import ctypes
|
|
648
|
+
kernel, mode = ctypes.windll.kernel32, ctypes.c_uint()
|
|
649
|
+
handle = kernel.GetStdHandle(-11) # stdout
|
|
650
|
+
return bool(kernel.GetConsoleMode(handle, ctypes.byref(mode)) and kernel.SetConsoleMode(handle, mode.value | 0x0004))
|
|
651
|
+
except Exception: # noqa: BLE001
|
|
652
|
+
return False
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
class StatusBar:
|
|
656
|
+
"""G8: keeps one line on the terminal's bottom row and current, while normal output scrolls above it. The same
|
|
657
|
+
line goes into the window title. Only on a real terminal: a pipe, a log file or AUTOPILOT_PLAIN=1 gets nothing,
|
|
658
|
+
so no escape code ever lands in a file. `line(width)` gives the text; a failing `line` is skipped."""
|
|
659
|
+
|
|
660
|
+
def __init__(self, line, out=None, every: float = 1.0, bold: bool = False, height: int = 1, title=None):
|
|
661
|
+
self.line, self.out, self.every, self.bold = line, out or sys.stdout, every, bold
|
|
662
|
+
self.height, self.title = height, title # J5: `line` may give `height` rows (one for each "\n")
|
|
663
|
+
self.on, self.rows, self.lock, self.done = False, 0, threading.Lock(), threading.Event()
|
|
664
|
+
|
|
665
|
+
def _write(self, text: str):
|
|
666
|
+
with self.lock:
|
|
667
|
+
try:
|
|
668
|
+
self.out.write(text)
|
|
669
|
+
self.out.flush()
|
|
670
|
+
except Exception: # noqa: BLE001 — a status line must never break a run
|
|
671
|
+
pass
|
|
672
|
+
|
|
673
|
+
def start(self) -> bool:
|
|
674
|
+
if os.environ.get("AUTOPILOT_PLAIN") or not self.out.isatty() or not enable_vt():
|
|
675
|
+
return False
|
|
676
|
+
self.on, self.rows = True, shutil.get_terminal_size().lines
|
|
677
|
+
if self.rows < self.height + 6: # a very small window: one row only
|
|
678
|
+
self.height = 1
|
|
679
|
+
h = self.height
|
|
680
|
+
# fresh lines, then rows 1..n-h scroll and the cursor waits on the last of them; the rows below are the line's
|
|
681
|
+
self._write("\n" * (h + 1) + f"\x1b[1;{self.rows - h}r\x1b[{self.rows - h};1H")
|
|
682
|
+
if self.every:
|
|
683
|
+
threading.Thread(target=self._loop, daemon=True).start()
|
|
684
|
+
return True
|
|
685
|
+
|
|
686
|
+
def _loop(self):
|
|
687
|
+
while not self.done.wait(self.every):
|
|
688
|
+
self.draw()
|
|
689
|
+
|
|
690
|
+
def draw(self):
|
|
691
|
+
if not self.on:
|
|
692
|
+
return
|
|
693
|
+
try:
|
|
694
|
+
size = shutil.get_terminal_size()
|
|
695
|
+
# only printable characters reach the terminal: a control code in the text must not act on it
|
|
696
|
+
clean = lambda s: "".join(c for c in str(s) if c.isprintable())[:size.columns - 1] # noqa: E731
|
|
697
|
+
raw, h = str(self.line(size.columns - 1)), self.height
|
|
698
|
+
texts = [clean(s) for s in (raw.split("\n") + [""] * h)[:h]] if h > 1 else [clean(raw)]
|
|
699
|
+
title = clean(self.title()) if self.title else texts[0]
|
|
700
|
+
except Exception: # noqa: BLE001 — e.g. the plan is being rewritten right now: keep the last line
|
|
701
|
+
return
|
|
702
|
+
refit = ""
|
|
703
|
+
if size.lines != self.rows: # the window was resized: move the reserved rows
|
|
704
|
+
# the old rows are now inside the part that scrolls: clear them, or their text stays behind new lines
|
|
705
|
+
old = "".join(f"\x1b[{self.rows - i};1H\x1b[2K" for i in range(h))
|
|
706
|
+
self.rows, refit = size.lines, f"\x1b7{old}\x1b[1;{size.lines - h}r\x1b8"
|
|
707
|
+
# save the cursor, draw the bottom rows, put the cursor back; then the window title
|
|
708
|
+
from .plain import tint
|
|
709
|
+
# J3: each fact in a row has its color; the title stays plain
|
|
710
|
+
rows = "".join(f"\x1b[{self.rows - h + 1 + i};1H\x1b[2K{tint(t, self.bold)}" for i, t in enumerate(texts))
|
|
711
|
+
self._write(f"{refit}\x1b7{rows}\x1b8\x1b]0;{title}\x07")
|
|
712
|
+
|
|
713
|
+
def stop(self):
|
|
714
|
+
if not self.on:
|
|
715
|
+
return
|
|
716
|
+
self.done.set()
|
|
717
|
+
self.on = False
|
|
718
|
+
# whole-screen scrolling again, the rows cleared from the bottom up
|
|
719
|
+
self._write("\x1b[r" + "".join(f"\x1b[{self.rows - i};1H\x1b[2K" for i in range(self.height)))
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def status_reader(root: Path, words: bool = False):
|
|
723
|
+
"""line(width) for the project at `root`: the status line, read fresh from run.json and state.db on each call.
|
|
724
|
+
It only reads, and keeps its own database connection, so use one reader per thread. `words`: in plain words."""
|
|
725
|
+
from .report import run_journal, status_line, status_panel, status_words
|
|
726
|
+
from .state import State
|
|
727
|
+
box: dict = {}
|
|
728
|
+
|
|
729
|
+
def line(width=None):
|
|
730
|
+
plan_file = root / AGENT_DIR / "plan.yaml"
|
|
731
|
+
stamp = plan_file.stat().st_mtime
|
|
732
|
+
if box.get("stamp") != stamp: # first call, or the plan gained tasks
|
|
733
|
+
if "state" in box:
|
|
734
|
+
box["state"].db.close()
|
|
735
|
+
box.update(cfg=Config.load(root), plan=Plan.load(plan_file), state=State(root / AGENT_DIR / "state.db"),
|
|
736
|
+
stamp=stamp)
|
|
737
|
+
if words == "panel":
|
|
738
|
+
return "\n".join(status_panel(box["cfg"], box["plan"], box["state"], run_journal(root), width))
|
|
739
|
+
if words:
|
|
740
|
+
return status_words(box["cfg"], box["plan"], box["state"], run_journal(root))
|
|
741
|
+
return status_line(box["cfg"], box["plan"], box["state"], run_journal(root), width)
|
|
742
|
+
return line
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def hints(root: Path, running: bool | None, color: bool) -> str:
|
|
746
|
+
"""The "What you can do next" text, with each command in color on a terminal (J3)."""
|
|
747
|
+
from .plain import paint
|
|
748
|
+
from .report import next_steps_text
|
|
749
|
+
out = []
|
|
750
|
+
for row in next_steps_text(root, running).split("\n"):
|
|
751
|
+
cmd, gap, rest = row.strip().partition(" ")
|
|
752
|
+
out.append(f" {paint(cmd, 'cyan', color)}{gap}{rest}" if row.startswith(" ap ") else row)
|
|
753
|
+
return "\n".join(out)
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def cmd_preflight(args):
|
|
757
|
+
"""Phase 0: credentials the plan needs are set and work, else exit 1 (also called by `run`)."""
|
|
758
|
+
from .commands.preflight import report_text, run_preflight
|
|
759
|
+
root = Path(args.path).resolve()
|
|
760
|
+
try:
|
|
761
|
+
plan = Plan.load(root / AGENT_DIR / "plan.yaml")
|
|
762
|
+
except (PlanError, FileNotFoundError) as e:
|
|
763
|
+
print(f"preflight ERROR plan.yaml: {e}")
|
|
764
|
+
return 1
|
|
765
|
+
rows = run_preflight(plan, Config.load(root))
|
|
766
|
+
print(report_text(rows))
|
|
767
|
+
return 1 if any(r["status"] != "ok" for r in rows) else 0
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def headless() -> bool:
|
|
771
|
+
"""N5.5: no screen to open a page on: SSH, no terminal, or Linux with no display."""
|
|
772
|
+
return bool(os.environ.get("SSH_CONNECTION")) or not sys.stdout.isatty() or (
|
|
773
|
+
sys.platform.startswith("linux") and not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")))
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def open_page_once(root: Path, url: str):
|
|
777
|
+
"""N5.5: the first run in a project opens the status page; later runs and headless sessions do not."""
|
|
778
|
+
marker = root / AGENT_DIR / "page_opened"
|
|
779
|
+
if marker.exists() or headless():
|
|
780
|
+
return
|
|
781
|
+
import webbrowser
|
|
782
|
+
try: # on a machine without a browser this does nothing
|
|
783
|
+
marker.write_text(url, encoding="utf-8")
|
|
784
|
+
webbrowser.open(url, new=2)
|
|
785
|
+
except Exception: # noqa: BLE001
|
|
786
|
+
pass
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def cmd_run(args):
|
|
790
|
+
from .orchestrator import Orchestrator, run_supervised
|
|
791
|
+
|
|
792
|
+
root = Path(args.path).resolve()
|
|
793
|
+
setup_logging(root, args.verbose)
|
|
794
|
+
stop = root / AGENT_DIR / "STOP"
|
|
795
|
+
if stop.exists() and args.clear_stop:
|
|
796
|
+
stop.unlink()
|
|
797
|
+
if run_active(root): # a second `autopilot run` while one is going: show that run instead of failing
|
|
798
|
+
print("a run is already active for this project; following it (Ctrl+C stops watching, not the run)\n")
|
|
799
|
+
return cmd_watch(argparse.Namespace(path=str(root), lines=20))
|
|
800
|
+
if not preflight(Config.load(root)): # every session would fail: stop before the first one
|
|
801
|
+
return 1
|
|
802
|
+
if cmd_preflight(args): # missing or broken credentials: stop before any work
|
|
803
|
+
return 1
|
|
804
|
+
url = "" if getattr(args, "no_page", False) else start_status_page(root)
|
|
805
|
+
if url and not getattr(args, "no_browser", False) and not os.environ.get("AUTOPILOT_NO_BROWSER"):
|
|
806
|
+
open_page_once(root, url)
|
|
807
|
+
if not sys.stdout.isatty() and not getattr(args, "no_watch", False) and not os.environ.get("AUTOPILOT_NO_BROWSER"):
|
|
808
|
+
how = open_watch_window(root) # no terminal of our own: put `autopilot watch` on screen
|
|
809
|
+
if how:
|
|
810
|
+
print(f"opened a {how} with `ap watch`", flush=True)
|
|
811
|
+
print(f"\nFollow this run: {url + ' in a browser, or ' if url else ''}`ap watch` in another terminal "
|
|
812
|
+
f"(log: {root / AGENT_DIR / 'logs' / 'autopilot.log'})\n", flush=True)
|
|
813
|
+
bar = StatusBar(status_reader(root)) # G8: the status line stays on the bottom row while the run prints above it
|
|
814
|
+
bar.start()
|
|
815
|
+
try:
|
|
816
|
+
outcome = run_supervised(root, lambda: Orchestrator(root, max_sessions=args.max_sessions,
|
|
817
|
+
no_prod=getattr(args, "no_prod", False)))
|
|
818
|
+
finally:
|
|
819
|
+
bar.stop()
|
|
820
|
+
print(f"\nrun finished: {outcome}\nreport: {root / AGENT_DIR / 'REPORT.md'}")
|
|
821
|
+
from .report import next_steps_text
|
|
822
|
+
print(next_steps_text(root, running=False))
|
|
823
|
+
return 0
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def _open_state(root: Path):
|
|
827
|
+
from .state import State
|
|
828
|
+
cfg = Config.load(root)
|
|
829
|
+
plan = Plan.load(root / AGENT_DIR / "plan.yaml")
|
|
830
|
+
state = State(root / AGENT_DIR / "state.db")
|
|
831
|
+
state.sync_plan(plan)
|
|
832
|
+
return cfg, plan, state
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def cmd_status(args):
|
|
836
|
+
from .report import build_report, run_journal, status_line
|
|
837
|
+
cfg, plan, state = _open_state(Path(args.path).resolve())
|
|
838
|
+
print(status_line(cfg, plan, state, run_journal(cfg.root),
|
|
839
|
+
shutil.get_terminal_size().columns - 1 if sys.stdout.isatty() else None) + "\n")
|
|
840
|
+
print(build_report(cfg, plan, state))
|
|
841
|
+
from .plain import colors_on
|
|
842
|
+
print(hints(Path(args.path).resolve(), None, colors_on(sys.stdout)))
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def cmd_stats(args):
|
|
846
|
+
from .report import proof_stats
|
|
847
|
+
cfg, plan, state = _open_state(Path(args.path).resolve())
|
|
848
|
+
print(proof_stats(cfg, plan, state))
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def watch_end(root: Path, run: dict, color: bool, hold: bool):
|
|
852
|
+
"""J8: the end of the simple view and of the dashboard: the end line, the status in short, the next steps.
|
|
853
|
+
A window that the build opened (`hold`) stays until the person closes it."""
|
|
854
|
+
from .plain import ending, paint, tint
|
|
855
|
+
print("\n" + paint(ending(run.get("outcome")), "bold", color))
|
|
856
|
+
try:
|
|
857
|
+
for row in status_reader(root, words="panel")().split("\n"):
|
|
858
|
+
print(tint(row, color))
|
|
859
|
+
except Exception: # noqa: BLE001 — no plan: the end line is enough
|
|
860
|
+
pass
|
|
861
|
+
print(hints(root, False, color))
|
|
862
|
+
if hold and sys.stdin and sys.stdin.isatty():
|
|
863
|
+
try:
|
|
864
|
+
input(paint("\nPress Enter to close this window.", "dim", color))
|
|
865
|
+
except (EOFError, KeyboardInterrupt):
|
|
866
|
+
pass
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def restarting(root: Path, wait: float = 90, sleep=None, clock=None) -> bool:
|
|
870
|
+
"""J14.9: a run that ended `interrupted` may be restarting itself after a crash. True when run.json turns
|
|
871
|
+
`running` again within `wait` seconds."""
|
|
872
|
+
import time
|
|
873
|
+
|
|
874
|
+
from .report import run_journal
|
|
875
|
+
sleep, clock = sleep or time.sleep, clock or time.monotonic
|
|
876
|
+
if run_journal(root).get("outcome") != "interrupted":
|
|
877
|
+
return False
|
|
878
|
+
end = clock() + wait
|
|
879
|
+
while clock() < end:
|
|
880
|
+
sleep(1)
|
|
881
|
+
if run_journal(root).get("status") == "running":
|
|
882
|
+
return True
|
|
883
|
+
return False
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def watch_dashboard(root: Path, color: bool, hold: bool, tick: float = 1.0):
|
|
887
|
+
"""J9: the full-window dashboard (dash_term.frame) on the terminal's second screen, drawn again each second.
|
|
888
|
+
Returns None when this terminal cannot show it (too small, no plan yet): the caller uses the scrolling view.
|
|
889
|
+
A window made too small later shows one line that says so. Ctrl+C and the end of the build give the first
|
|
890
|
+
screen back, with the end message on it."""
|
|
891
|
+
import time
|
|
892
|
+
|
|
893
|
+
from .dash_term import MIN_COLS, MIN_ROWS, frame
|
|
894
|
+
from .dashboard import dashboard_data, is_utf, spinner
|
|
895
|
+
from .report import run_journal
|
|
896
|
+
from .state import State
|
|
897
|
+
utf = is_utf(sys.stdout) # J13/18: ASCII spinner on other consoles
|
|
898
|
+
spin = None
|
|
899
|
+
size = shutil.get_terminal_size()
|
|
900
|
+
if size.columns < MIN_COLS or size.lines < MIN_ROWS:
|
|
901
|
+
return None
|
|
902
|
+
box: dict = {}
|
|
903
|
+
|
|
904
|
+
def data() -> dict:
|
|
905
|
+
plan_file = root / AGENT_DIR / "plan.yaml"
|
|
906
|
+
stamp = plan_file.stat().st_mtime
|
|
907
|
+
if box.get("stamp") != stamp: # first call, or the plan gained tasks
|
|
908
|
+
if "state" in box:
|
|
909
|
+
box["state"].db.close()
|
|
910
|
+
box.update(cfg=Config.load(root), plan=Plan.load(plan_file), state=State(root / AGENT_DIR / "state.db"),
|
|
911
|
+
stamp=stamp)
|
|
912
|
+
return dashboard_data(box["cfg"], box["plan"], box["state"], run_journal(root))
|
|
913
|
+
try:
|
|
914
|
+
data()
|
|
915
|
+
except Exception: # noqa: BLE001 — no plan yet
|
|
916
|
+
return None
|
|
917
|
+
out, done, ended = sys.stdout, 0, False
|
|
918
|
+
out.write("\x1b[?1049h\x1b[?25l\x1b[?7l") # the second screen, no cursor, no line wrap
|
|
919
|
+
try:
|
|
920
|
+
while True:
|
|
921
|
+
size = shutil.get_terminal_size()
|
|
922
|
+
try:
|
|
923
|
+
d = data()
|
|
924
|
+
spin = d["spin"] = spinner(d, d["asof"]["mtime"], time.time(), spin, utf)
|
|
925
|
+
rows = frame(d, size.columns, size.lines, color)
|
|
926
|
+
title = "".join(c for c in f"{spin['text']} {d.get('line', '')}".strip() if c.isprintable())
|
|
927
|
+
out.write("\x1b[H" + "\n".join(rows) + f"\x1b]0;{title}\x07")
|
|
928
|
+
except ValueError: # the window is too small now
|
|
929
|
+
out.write("\x1b[2J\x1b[HThe window is too small for the dashboard. Make it larger, or run: ap watch --scroll")
|
|
930
|
+
except Exception: # noqa: BLE001 — e.g. the plan is being rewritten right now: keep the last picture
|
|
931
|
+
pass
|
|
932
|
+
out.flush()
|
|
933
|
+
done = done + 1 if run_journal(root).get("status") == "finished" else 0
|
|
934
|
+
if done >= 3 and restarting(root):
|
|
935
|
+
done = 0
|
|
936
|
+
if done >= 3:
|
|
937
|
+
ended = True
|
|
938
|
+
break
|
|
939
|
+
time.sleep(tick)
|
|
940
|
+
except KeyboardInterrupt:
|
|
941
|
+
pass
|
|
942
|
+
finally:
|
|
943
|
+
out.write("\x1b[?7h\x1b[?25h\x1b[?1049l")
|
|
944
|
+
out.flush()
|
|
945
|
+
if "state" in box:
|
|
946
|
+
box["state"].db.close()
|
|
947
|
+
if ended:
|
|
948
|
+
watch_end(root, run_journal(root), color, hold)
|
|
949
|
+
return 0
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def cmd_watch(args):
|
|
953
|
+
"""Follow a run from any terminal: progress, current step, then every live line until the run finishes."""
|
|
954
|
+
import time
|
|
955
|
+
|
|
956
|
+
from .plain import SimpleView, colors_on, paint
|
|
957
|
+
from .report import next_steps_text, run_journal
|
|
958
|
+
root = Path(args.path).resolve()
|
|
959
|
+
log = root / AGENT_DIR / "logs" / "autopilot.log"
|
|
960
|
+
# J1: plain words on a terminal; the technical lines with --detail, and when the output is not a terminal
|
|
961
|
+
simple = bool(getattr(args, "simple", False)) or (sys.stdout.isatty() and not getattr(args, "detail", False))
|
|
962
|
+
color = colors_on(sys.stdout)
|
|
963
|
+
if (simple and color and sys.stdout.isatty() and not getattr(args, "scroll", False)
|
|
964
|
+
and not getattr(args, "simple", False)): # J9: the dashboard, when the window is large enough
|
|
965
|
+
rc = watch_dashboard(root, color, bool(getattr(args, "hold", False)))
|
|
966
|
+
if rc is not None:
|
|
967
|
+
return rc
|
|
968
|
+
names = {}
|
|
969
|
+
try:
|
|
970
|
+
cfg, plan, state = _open_state(root)
|
|
971
|
+
from .context import progress_line
|
|
972
|
+
ids = plan.all_tasks()
|
|
973
|
+
names = {t.id: (i + 1, len(ids), t.title, t.description) for i, t in enumerate(ids)}
|
|
974
|
+
print(f"{cfg.get('name', root.name)}: {progress_line(plan, state.status_map())}")
|
|
975
|
+
except Exception: # noqa: BLE001 — a project without a plan yet still has a log to show
|
|
976
|
+
pass
|
|
977
|
+
view = SimpleView(names, color)
|
|
978
|
+
|
|
979
|
+
asked = [False]
|
|
980
|
+
live, drawn = [False], [None] # live: the pinned panel is on, so the terminal takes escape codes
|
|
981
|
+
|
|
982
|
+
def show(text: str, final: bool = False):
|
|
983
|
+
"""J6: the lines that are final, then the group in work. A terminal draws that group again in place."""
|
|
984
|
+
if not simple:
|
|
985
|
+
print(text, end="" if text.endswith("\n") else "\n", flush=True)
|
|
986
|
+
return
|
|
987
|
+
if live[0]:
|
|
988
|
+
view.width = shutil.get_terminal_size().columns - 1
|
|
989
|
+
rows = [out for row in text.splitlines() for out in view.feed(row)]
|
|
990
|
+
if final or not live[0]:
|
|
991
|
+
rows += view.close() if final else []
|
|
992
|
+
now = None
|
|
993
|
+
else:
|
|
994
|
+
now = view.open_line()
|
|
995
|
+
if not rows and now == drawn[0]:
|
|
996
|
+
return
|
|
997
|
+
erase = "\x1b[1A\x1b[2K" if drawn[0] else "" # the cursor is below the group line: go up, clear, write again
|
|
998
|
+
drawn[0] = now
|
|
999
|
+
if rows or now or erase:
|
|
1000
|
+
print(erase + "\n".join(rows + ([now] if now else [])), flush=True)
|
|
1001
|
+
run = run_journal(root)
|
|
1002
|
+
if simple:
|
|
1003
|
+
print(paint("Ctrl+C closes this view. The build continues.", "dim", color))
|
|
1004
|
+
print(hints(root, None, color))
|
|
1005
|
+
else:
|
|
1006
|
+
print(f"run: {run.get('status', 'no run yet')} · now: {run.get('current') or '-'} (Ctrl+C stops watching, "
|
|
1007
|
+
"not the run)")
|
|
1008
|
+
print(next_steps_text(root))
|
|
1009
|
+
pos = 0
|
|
1010
|
+
if log.exists():
|
|
1011
|
+
lines = log.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
1012
|
+
show("\n".join(lines[-(args.lines * 4 if simple else args.lines):]), final=True)
|
|
1013
|
+
pos = log.stat().st_size
|
|
1014
|
+
quiet = 0.0
|
|
1015
|
+
line = status_reader(root, words=simple)
|
|
1016
|
+
# G8: a terminal gets the status pinned to its bottom; J5: the simple view has four rows, one for each category
|
|
1017
|
+
panel = status_reader(root, words="panel")
|
|
1018
|
+
# a wide window needs fewer rows. A reader of its own: the bar reads on its thread, and a database connection
|
|
1019
|
+
# stays on one thread. ponytail: the height is set once; a window made narrow later cuts rows
|
|
1020
|
+
try:
|
|
1021
|
+
height = len(status_reader(root, words="panel")(shutil.get_terminal_size().columns - 1).split("\n"))
|
|
1022
|
+
except Exception: # noqa: BLE001 — no plan yet
|
|
1023
|
+
height = 4
|
|
1024
|
+
bar = StatusBar(panel, bold=color, height=height, title=line) if simple else StatusBar(line)
|
|
1025
|
+
pinned, shown = bar.start(), [None]
|
|
1026
|
+
live[0] = pinned and simple
|
|
1027
|
+
|
|
1028
|
+
def moved(): # not a terminal: print the line when the run moves on, and never an escape code
|
|
1029
|
+
now = run_journal(root)
|
|
1030
|
+
key = (now.get("status"), now.get("state"), now.get("task"))
|
|
1031
|
+
if not pinned and key != shown[0]:
|
|
1032
|
+
shown[0] = key
|
|
1033
|
+
try:
|
|
1034
|
+
print(line(), flush=True)
|
|
1035
|
+
except Exception: # noqa: BLE001 — no plan yet: the log is still worth following
|
|
1036
|
+
pass
|
|
1037
|
+
moved()
|
|
1038
|
+
try:
|
|
1039
|
+
while True:
|
|
1040
|
+
time.sleep(1)
|
|
1041
|
+
moved()
|
|
1042
|
+
if log.exists() and log.stat().st_size > pos:
|
|
1043
|
+
with open(log, encoding="utf-8", errors="replace") as fh:
|
|
1044
|
+
fh.seek(pos)
|
|
1045
|
+
chunk = fh.read()
|
|
1046
|
+
pos = fh.tell()
|
|
1047
|
+
show(chunk)
|
|
1048
|
+
quiet = 0
|
|
1049
|
+
continue
|
|
1050
|
+
quiet += 1
|
|
1051
|
+
run = run_journal(root)
|
|
1052
|
+
if simple and not asked[0] and run.get("status") == "running" and (root / AGENT_DIR / "STOP").exists():
|
|
1053
|
+
asked[0], drawn[0] = True, None # the line in work stays as it is; the notice goes below it
|
|
1054
|
+
print(paint("You asked for a stop. The build stops after the current session.", "yellow", color),
|
|
1055
|
+
flush=True)
|
|
1056
|
+
if run.get("status") == "finished" and quiet >= 3 and restarting(root):
|
|
1057
|
+
quiet = 0
|
|
1058
|
+
continue
|
|
1059
|
+
if run.get("status") == "finished" and quiet >= 3:
|
|
1060
|
+
if simple:
|
|
1061
|
+
show("", final=True)
|
|
1062
|
+
bar.stop() # J8: the panel goes; its facts stay on the screen as normal lines
|
|
1063
|
+
watch_end(root, run, color, bool(getattr(args, "hold", False)))
|
|
1064
|
+
else:
|
|
1065
|
+
print(f"\nrun finished: {run.get('outcome', '?')}")
|
|
1066
|
+
print(next_steps_text(root, running=False))
|
|
1067
|
+
return 0
|
|
1068
|
+
except KeyboardInterrupt:
|
|
1069
|
+
return 0
|
|
1070
|
+
finally:
|
|
1071
|
+
bar.stop()
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def open_watch_window(root: Path) -> str:
|
|
1075
|
+
"""A run without its own terminal (started in the background, by a tool or another program) opens a separate
|
|
1076
|
+
terminal window running `autopilot watch`, so the live view is always on screen. Returns how, or '' when there is
|
|
1077
|
+
no desktop to open one on (a server, CI)."""
|
|
1078
|
+
import subprocess
|
|
1079
|
+
cmd = [sys.executable, "-m", "autopilot", "watch", "-C", str(root), "--hold"]
|
|
1080
|
+
# Claude Code sets NO_COLOR for its tools; that is not the user's wish for a new window of their own
|
|
1081
|
+
env = {k: v for k, v in os.environ.items() if not (k == "NO_COLOR" and os.environ.get("CLAUDECODE"))}
|
|
1082
|
+
try:
|
|
1083
|
+
if os.name == "nt":
|
|
1084
|
+
subprocess.Popen(cmd, creationflags=subprocess.CREATE_NEW_CONSOLE, env=env) # noqa: S603
|
|
1085
|
+
return "new console window"
|
|
1086
|
+
if sys.platform == "darwin":
|
|
1087
|
+
script = " ".join(f"'{c}'" for c in cmd)
|
|
1088
|
+
subprocess.Popen(["osascript", "-e", f'tell application "Terminal" to do script "{script}"'])
|
|
1089
|
+
return "Terminal window"
|
|
1090
|
+
term = shutil.which("x-terminal-emulator") or shutil.which("gnome-terminal") or shutil.which("xterm")
|
|
1091
|
+
if term and (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")):
|
|
1092
|
+
subprocess.Popen([term, "--", *cmd] if term.endswith("gnome-terminal") else [term, "-e", *cmd], env=env)
|
|
1093
|
+
return "terminal window"
|
|
1094
|
+
except OSError:
|
|
1095
|
+
pass
|
|
1096
|
+
return ""
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def start_detached(root: Path, argv: list[str]) -> int:
|
|
1100
|
+
"""I4/H6: start this command again as its own process, then return. The run outlives the terminal or the Claude
|
|
1101
|
+
Code chat that started it (a background command in a chat stops after 2 hours at most)."""
|
|
1102
|
+
import subprocess
|
|
1103
|
+
if run_active(root):
|
|
1104
|
+
print(f"a run is already active for {root}; follow it with `ap watch`")
|
|
1105
|
+
return 0
|
|
1106
|
+
log = root / AGENT_DIR / "logs" / "detached.log"
|
|
1107
|
+
log.parent.mkdir(parents=True, exist_ok=True)
|
|
1108
|
+
cmd = [sys.executable, "-m", "autopilot", *argv]
|
|
1109
|
+
out = open(log, "a", encoding="utf-8") # noqa: SIM115 — the child keeps it open
|
|
1110
|
+
# PYTHONSAFEPATH: `-m` puts the cwd first on sys.path, so a project that has an `autopilot/` folder of its own (a
|
|
1111
|
+
# self-build clone) would be imported instead of the installed Autopilot (RCA 2026-10-11)
|
|
1112
|
+
kw = dict(stdin=subprocess.DEVNULL, stdout=out, stderr=subprocess.STDOUT, cwd=str(Path.cwd()),
|
|
1113
|
+
env={**os.environ, "PYTHONSAFEPATH": "1"})
|
|
1114
|
+
if os.name == "nt": # a hidden console of its own (git and claude get no window each), out of the caller's job
|
|
1115
|
+
flags = subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP
|
|
1116
|
+
try:
|
|
1117
|
+
p = subprocess.Popen(cmd, creationflags=flags | subprocess.CREATE_BREAKAWAY_FROM_JOB, **kw) # noqa: S603
|
|
1118
|
+
except OSError: # the caller's job forbids breakaway
|
|
1119
|
+
p = subprocess.Popen(cmd, creationflags=flags, **kw) # noqa: S603
|
|
1120
|
+
else: # a new session: no hangup signal when the terminal or the chat closes
|
|
1121
|
+
p = subprocess.Popen(cmd, start_new_session=True, **kw) # noqa: S603
|
|
1122
|
+
out.close()
|
|
1123
|
+
print(f"started in the background (process {p.pid}); it continues when this terminal or chat closes.\n"
|
|
1124
|
+
f" follow: ap watch\n stop: ap stop\n start again: ap start\n output: {log}")
|
|
1125
|
+
return 0
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def run_active(root: Path) -> bool:
|
|
1129
|
+
"""Is an `autopilot run` going on for this project right now? (It holds .agent/run.lock.)"""
|
|
1130
|
+
lock = root / AGENT_DIR / "run.lock"
|
|
1131
|
+
if not lock.exists():
|
|
1132
|
+
return False
|
|
1133
|
+
try:
|
|
1134
|
+
with exclusive_lock(lock):
|
|
1135
|
+
return False
|
|
1136
|
+
except BlockingIOError:
|
|
1137
|
+
return True
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def start_status_page(root: Path, port: int = 8765) -> str:
|
|
1141
|
+
"""Start the read-only status page in the background for this process (loopback only). Returns its URL, or ''
|
|
1142
|
+
when no port is free. ponytail: tries 10 ports, then gives up quietly."""
|
|
1143
|
+
import threading
|
|
1144
|
+
|
|
1145
|
+
from .report import status_server
|
|
1146
|
+
for p in range(port, port + 10):
|
|
1147
|
+
try:
|
|
1148
|
+
srv = status_server(root, "127.0.0.1", p, "", 10)
|
|
1149
|
+
except OSError:
|
|
1150
|
+
continue
|
|
1151
|
+
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
|
1152
|
+
return f"http://127.0.0.1:{p}/"
|
|
1153
|
+
return ""
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
def cmd_serve(args):
|
|
1157
|
+
import os
|
|
1158
|
+
|
|
1159
|
+
from .report import status_server
|
|
1160
|
+
try:
|
|
1161
|
+
srv = status_server(Path(args.path).resolve(), args.host, args.port,
|
|
1162
|
+
os.environ.get("AUTOPILOT_STATUS_TOKEN", ""), args.refresh)
|
|
1163
|
+
except (ValueError, OSError) as exc:
|
|
1164
|
+
print(f"cannot serve: {exc}")
|
|
1165
|
+
return 1
|
|
1166
|
+
print(f"status page: http://{args.host}:{srv.server_address[1]}/ (refreshes every {args.refresh}s; Ctrl+C stops)")
|
|
1167
|
+
try:
|
|
1168
|
+
srv.serve_forever()
|
|
1169
|
+
except KeyboardInterrupt:
|
|
1170
|
+
pass
|
|
1171
|
+
finally:
|
|
1172
|
+
srv.server_close()
|
|
1173
|
+
return 0
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def cmd_doctor(args):
|
|
1177
|
+
from .doctor import FAIL, autofix, checks
|
|
1178
|
+
root = Path(args.path).resolve()
|
|
1179
|
+
if not (root / AGENT_DIR / "project.yaml").exists():
|
|
1180
|
+
print(f"FAIL project no {AGENT_DIR}/project.yaml in {root}: run `autopilot init` first")
|
|
1181
|
+
return 1
|
|
1182
|
+
cfg = Config.load(root)
|
|
1183
|
+
rows = checks(cfg)
|
|
1184
|
+
if getattr(args, "fix", False):
|
|
1185
|
+
done = autofix(rows)
|
|
1186
|
+
for line in done:
|
|
1187
|
+
print(f"fix {line}")
|
|
1188
|
+
if done:
|
|
1189
|
+
rows = checks(cfg)
|
|
1190
|
+
print()
|
|
1191
|
+
for level, name, detail in rows:
|
|
1192
|
+
print(f"{level:5s} {name:14s} {detail}")
|
|
1193
|
+
bad = sum(r[0] == FAIL for r in rows)
|
|
1194
|
+
print(f"\n{bad} problem(s) to fix before `autopilot run`" if bad else "\nready for `autopilot run`")
|
|
1195
|
+
return 1 if bad else 0
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
def cmd_next(args):
|
|
1199
|
+
cfg, plan, state = _open_state(Path(args.path).resolve())
|
|
1200
|
+
status = dict(state.status_map())
|
|
1201
|
+
policy = cfg.get("scheduling.phase_dependency", "soft")
|
|
1202
|
+
for i in range(args.n):
|
|
1203
|
+
t = plan.next_ready(status, policy)
|
|
1204
|
+
if not t:
|
|
1205
|
+
break
|
|
1206
|
+
ladder = cfg.get(f"models.ladder.{t.risk}")
|
|
1207
|
+
print(f"{i + 1:3d}. {t.id:14s} [{t.risk:8s} → {ladder[0]}] {t.title}")
|
|
1208
|
+
status[t.id] = "done"
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def cmd_unblock(args):
|
|
1212
|
+
_, _, state = _open_state(Path(args.path).resolve())
|
|
1213
|
+
for tid in args.task_ids:
|
|
1214
|
+
if not state.task(tid):
|
|
1215
|
+
print(f"unknown task {tid}")
|
|
1216
|
+
continue
|
|
1217
|
+
state.set_task(tid, status="pending", attempts=0, last_error=None, note=args.note or "unblocked by user")
|
|
1218
|
+
state.set_meta(f"unstick:{tid}", False)
|
|
1219
|
+
print(f"{tid} -> pending")
|
|
1220
|
+
if args.note:
|
|
1221
|
+
print("note: add hints for the agent to the task description in plan.yaml or to .agent/BRAIN.md")
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
def _tty() -> bool:
|
|
1225
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
def _ask(prompt: str) -> str | None:
|
|
1229
|
+
"""One line from the owner; None on Ctrl+C or end of input, so callers stop cleanly."""
|
|
1230
|
+
try:
|
|
1231
|
+
return input(prompt).strip()
|
|
1232
|
+
except (EOFError, KeyboardInterrupt):
|
|
1233
|
+
print()
|
|
1234
|
+
return None
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
def _store_answer(state, did: str, text: str) -> int:
|
|
1238
|
+
if state.answer_decision(did, text):
|
|
1239
|
+
print(f"{did} answered; the run applies it on its next loop (or at the next `autopilot run`)")
|
|
1240
|
+
return 0
|
|
1241
|
+
row = state.decision(did)
|
|
1242
|
+
print(f"unknown decision {did}" if not row else f"{did} is not open (status {row['status']})")
|
|
1243
|
+
return 1
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def cmd_answer(args):
|
|
1247
|
+
from .state import State
|
|
1248
|
+
state = State(Path(args.path).resolve() / AGENT_DIR / "state.db") # no run lock: works while a run is active
|
|
1249
|
+
if args.decision_id and args.text is not None:
|
|
1250
|
+
return _store_answer(state, args.decision_id, args.text)
|
|
1251
|
+
usage = 'ap answer <id> "text"'
|
|
1252
|
+
if args.decision_id:
|
|
1253
|
+
if not _tty():
|
|
1254
|
+
print(f"usage: {usage}")
|
|
1255
|
+
return 1
|
|
1256
|
+
text = _ask(f"Your answer for {args.decision_id}: ")
|
|
1257
|
+
return _store_answer(state, args.decision_id, text) if text else 1
|
|
1258
|
+
rows = state.decisions("OPEN")[::-1]
|
|
1259
|
+
if not rows:
|
|
1260
|
+
print("No question waits for you.")
|
|
1261
|
+
return 0
|
|
1262
|
+
from .decisions import explain
|
|
1263
|
+
for r in rows: # the same simple words as docs/NEEDS-YOU.md
|
|
1264
|
+
print(f"\n{r['id']} {r['title']}")
|
|
1265
|
+
for line in explain(r):
|
|
1266
|
+
print(" " + line.replace("**", "").replace("`", ""))
|
|
1267
|
+
print("\nWrite each answer in your own words, as an instruction for the agent.")
|
|
1268
|
+
if not _tty():
|
|
1269
|
+
print(f"usage: {usage}")
|
|
1270
|
+
return 1
|
|
1271
|
+
from .decisions import CHOICES, SKIP
|
|
1272
|
+
print(f"Choices: {CHOICES}")
|
|
1273
|
+
for r in rows:
|
|
1274
|
+
sug = " ".join(str(r["suggestion"] or "").split())
|
|
1275
|
+
pick = _ask(f"{r['id']} choice (Enter = 1{': ' + sug if sug else ''}): ")
|
|
1276
|
+
if pick is None:
|
|
1277
|
+
break
|
|
1278
|
+
pick = pick.strip()
|
|
1279
|
+
text = sug if pick in ("", "1") else SKIP if pick == "2" else \
|
|
1280
|
+
(_ask(f"Your answer for {r['id']}: ") or "") if pick == "3" else pick # other text = your own words
|
|
1281
|
+
if text:
|
|
1282
|
+
_store_answer(state, r["id"], text)
|
|
1283
|
+
return 0
|
|
1284
|
+
|
|
1285
|
+
|
|
1286
|
+
def _plan_view(plan) -> dict:
|
|
1287
|
+
return {t.id: (t.title, t.description, list(t.acceptance_criteria)) for t in plan.all_tasks()}
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
def _change(orch, args, model) -> int:
|
|
1291
|
+
print(f"A plan change costs one replan session ({model}).")
|
|
1292
|
+
if not args.yes:
|
|
1293
|
+
if not _tty():
|
|
1294
|
+
print("no terminal: add --yes to start it")
|
|
1295
|
+
return 1
|
|
1296
|
+
if _ask("1 start, 2 stop> ") != "1":
|
|
1297
|
+
print("stopped; nothing changed")
|
|
1298
|
+
return 0
|
|
1299
|
+
orch.git.ensure_repo(orch.main)
|
|
1300
|
+
orch._prepare_repo()
|
|
1301
|
+
orch.reload_plan()
|
|
1302
|
+
before, head0, idea = _plan_view(orch.plan), orch.git.head(), " ".join(args.text.split())
|
|
1303
|
+
orch.docs.backlog_add(idea)
|
|
1304
|
+
orch._commit_main("[autopilot] backlog: plan change") # committed first: the replan session starts with a git discard
|
|
1305
|
+
|
|
1306
|
+
def undo(): # revert commits, not a reset: they may already be pushed. The idea leaves the backlog with them.
|
|
1307
|
+
if orch.git.head() != head0:
|
|
1308
|
+
orch.git.run("revert", "--no-edit", f"{head0}..HEAD")
|
|
1309
|
+
orch._push()
|
|
1310
|
+
orch.reload_plan()
|
|
1311
|
+
if not orch.replan("change", backlog=[idea]):
|
|
1312
|
+
undo()
|
|
1313
|
+
print("replan rejected or failed; nothing changed")
|
|
1314
|
+
return 1
|
|
1315
|
+
after = _plan_view(orch.plan)
|
|
1316
|
+
added, gone = [i for i in after if i not in before], [i for i in before if i not in after]
|
|
1317
|
+
changed = [i for i in after if i in before and after[i] != before[i]]
|
|
1318
|
+
for label, ids, view in (("added", added, after), ("changed", changed, after), ("removed", gone, before)):
|
|
1319
|
+
for i in ids:
|
|
1320
|
+
print(f"{label}: {i} {view[i][0]}")
|
|
1321
|
+
if not (added or changed or gone):
|
|
1322
|
+
print("no change")
|
|
1323
|
+
if not args.yes and _ask("1 keep, 2 undo> ") == "2":
|
|
1324
|
+
undo()
|
|
1325
|
+
for i in gone: # sync_plan skipped the tasks the replan removed; they are back in the plan now
|
|
1326
|
+
orch.state.set_task(i, status="pending", note="plan change undone")
|
|
1327
|
+
print("undone; the plan is as it was before")
|
|
1328
|
+
return 0
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
def cmd_change(args):
|
|
1332
|
+
from .orchestrator import Orchestrator
|
|
1333
|
+
root = Path(args.path).resolve()
|
|
1334
|
+
setup_logging(root, False)
|
|
1335
|
+
orch = Orchestrator(root)
|
|
1336
|
+
try:
|
|
1337
|
+
with exclusive_lock(root / AGENT_DIR / "run.lock"):
|
|
1338
|
+
return _change(orch, args, orch.cfg.get("models.replanner", "opus"))
|
|
1339
|
+
except BlockingIOError:
|
|
1340
|
+
orch.docs.backlog_add(args.text)
|
|
1341
|
+
print("a run is active; the idea is in docs/BACKLOG.md and the run takes it at its next replan")
|
|
1342
|
+
return 0
|
|
1343
|
+
|
|
1344
|
+
|
|
1345
|
+
def cmd_hint(args):
|
|
1346
|
+
_, plan, state = _open_state(Path(args.path).resolve())
|
|
1347
|
+
ok, msg = state.add_hint(plan, args.task_id, args.text)
|
|
1348
|
+
print(msg)
|
|
1349
|
+
return 0 if ok else 1
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
def cmd_skip(args):
|
|
1353
|
+
_, _, state = _open_state(Path(args.path).resolve())
|
|
1354
|
+
for tid in args.task_ids:
|
|
1355
|
+
state.set_task(tid, status="skipped", note="skipped by user")
|
|
1356
|
+
print(f"{tid} -> skipped")
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
def cmd_approve(args):
|
|
1360
|
+
import logging as _l
|
|
1361
|
+
from .orchestrator import Orchestrator
|
|
1362
|
+
root = Path(args.path).resolve()
|
|
1363
|
+
setup_logging(root, False)
|
|
1364
|
+
orch = Orchestrator(root)
|
|
1365
|
+
row = orch.state.phase(args.phase)
|
|
1366
|
+
if not row or row["prod_status"] != "awaiting_approval":
|
|
1367
|
+
print(f"phase {args.phase} is not awaiting approval")
|
|
1368
|
+
return 1
|
|
1369
|
+
try:
|
|
1370
|
+
with exclusive_lock(root / AGENT_DIR / "run.lock"):
|
|
1371
|
+
orch.reload_plan()
|
|
1372
|
+
print(orch.deploy_prod(args.phase, row["prod_ref"]))
|
|
1373
|
+
except BlockingIOError:
|
|
1374
|
+
d = root / AGENT_DIR / "approvals"
|
|
1375
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
1376
|
+
(d / args.phase).write_text("approved\n")
|
|
1377
|
+
print(f"a run is active; it will deploy {args.phase} to prod before its next task")
|
|
1378
|
+
_l.shutdown()
|
|
1379
|
+
return 0
|
|
1380
|
+
|
|
1381
|
+
|
|
1382
|
+
def cmd_uninstall(args):
|
|
1383
|
+
"""N8: `ap uninstall --rights` removes the scheduled task / the sudoers file the installer created."""
|
|
1384
|
+
from .rights import remove_rights
|
|
1385
|
+
if not getattr(args, "rights", False):
|
|
1386
|
+
print("ap uninstall --rights removes the rights the installer took (scheduled task / sudoers file)\n"
|
|
1387
|
+
"uv tool uninstall dev-autopilot removes Autopilot itself")
|
|
1388
|
+
return 0
|
|
1389
|
+
for line in remove_rights():
|
|
1390
|
+
print(line)
|
|
1391
|
+
return 0
|
|
1392
|
+
|
|
1393
|
+
|
|
1394
|
+
def cmd_mcp(args):
|
|
1395
|
+
from .mcp import serve
|
|
1396
|
+
serve(Path(args.path).resolve())
|
|
1397
|
+
|
|
1398
|
+
|
|
1399
|
+
def cmd_stop(args):
|
|
1400
|
+
p = Path(args.path).resolve() / AGENT_DIR / "STOP"
|
|
1401
|
+
p.write_text("stop requested\n")
|
|
1402
|
+
print("the run will stop before its next session (remove with `autopilot resume`)")
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
def cmd_resume(args):
|
|
1406
|
+
p = Path(args.path).resolve() / AGENT_DIR / "STOP"
|
|
1407
|
+
if p.exists():
|
|
1408
|
+
p.unlink()
|
|
1409
|
+
print("stop flag cleared — start again with `autopilot run`")
|
|
1410
|
+
|
|
1411
|
+
|
|
1412
|
+
def cmd_review(args):
|
|
1413
|
+
"""Manual trigger for an audit or replan outside the normal cadence."""
|
|
1414
|
+
from .orchestrator import Orchestrator
|
|
1415
|
+
root = Path(args.path).resolve()
|
|
1416
|
+
setup_logging(root, args.verbose)
|
|
1417
|
+
orch = Orchestrator(root)
|
|
1418
|
+
orch.git.ensure_repo(orch.main)
|
|
1419
|
+
orch._prepare_repo()
|
|
1420
|
+
orch.reload_plan()
|
|
1421
|
+
if args.kind == "replan":
|
|
1422
|
+
print("replan accepted" if orch.replan("manual") else "replan rejected or failed")
|
|
1423
|
+
else:
|
|
1424
|
+
rep = orch.audit(args.kind)
|
|
1425
|
+
print(rep and {k: rep.get(k) for k in ("complete", "completion_pct", "summary", "_new_phase")})
|
|
1426
|
+
orch.write_report()
|
|
1427
|
+
|
|
1428
|
+
|
|
1429
|
+
def cmd_report(args):
|
|
1430
|
+
from .report import build_report, tokens_report
|
|
1431
|
+
cfg, plan, state = _open_state(Path(args.path).resolve())
|
|
1432
|
+
print(tokens_report(state) if args.tokens else build_report(cfg, plan, state))
|
|
1433
|
+
|
|
1434
|
+
|
|
1435
|
+
NEW_PROJECT = ("init", "onboard", "quickstart", "mcp") # these make a project: with no -C they use this folder
|
|
1436
|
+
|
|
1437
|
+
|
|
1438
|
+
def _recent_file() -> Path:
|
|
1439
|
+
return Path(os.environ.get("AUTOPILOT_HOME") or Path.home() / ".autopilot") / "recent"
|
|
1440
|
+
|
|
1441
|
+
|
|
1442
|
+
def recent_projects() -> list[Path]:
|
|
1443
|
+
"""Projects used on this PC, newest first; only folders that still hold a project."""
|
|
1444
|
+
try:
|
|
1445
|
+
rows = _recent_file().read_text(encoding="utf-8").splitlines()
|
|
1446
|
+
except OSError:
|
|
1447
|
+
return []
|
|
1448
|
+
return [Path(r) for r in rows if r and (Path(r) / AGENT_DIR / "project.yaml").exists()]
|
|
1449
|
+
|
|
1450
|
+
|
|
1451
|
+
def remember(root: Path):
|
|
1452
|
+
"""J2: note this project as the last one used, so `ap status` works from any folder."""
|
|
1453
|
+
if not (root / AGENT_DIR / "project.yaml").exists():
|
|
1454
|
+
return
|
|
1455
|
+
try:
|
|
1456
|
+
rows = [str(root)] + [str(p) for p in recent_projects() if p != root]
|
|
1457
|
+
_recent_file().parent.mkdir(parents=True, exist_ok=True)
|
|
1458
|
+
_recent_file().write_text("\n".join(rows[:20]) + "\n", encoding="utf-8")
|
|
1459
|
+
except OSError:
|
|
1460
|
+
pass
|
|
1461
|
+
|
|
1462
|
+
|
|
1463
|
+
def pick_project() -> str | None:
|
|
1464
|
+
"""J2: no -C and this folder is not a project. One active build: that one. Several: ask (or list them when
|
|
1465
|
+
there is no terminal; never guess). None active: the last project used. Says which project it took."""
|
|
1466
|
+
known = recent_projects()
|
|
1467
|
+
active = [p for p in known if run_active(p)]
|
|
1468
|
+
if len(active) > 1:
|
|
1469
|
+
print("More than one build is active:")
|
|
1470
|
+
for i, p in enumerate(active, 1):
|
|
1471
|
+
print(f" {i}. {p}")
|
|
1472
|
+
pick = _ask("Which one? Type its number: ") if _tty() else None
|
|
1473
|
+
if not (pick or "").isdigit() or not 1 <= int(pick) <= len(active):
|
|
1474
|
+
print("Name the project with -C <folder>.")
|
|
1475
|
+
return None
|
|
1476
|
+
active = [active[int(pick) - 1]]
|
|
1477
|
+
chosen = active[0] if active else known[0] if known else None
|
|
1478
|
+
if chosen is None:
|
|
1479
|
+
print("This folder is no Autopilot project, and no other project is known on this PC.\n"
|
|
1480
|
+
"Go to the project folder, or name it with -C <folder>. To start a new one: autopilot quickstart")
|
|
1481
|
+
return None
|
|
1482
|
+
print(f"project: {chosen}")
|
|
1483
|
+
return str(chosen)
|
|
1484
|
+
|
|
1485
|
+
|
|
1486
|
+
def _version_line():
|
|
1487
|
+
"""Version plus short commit (N5.1); 'installed' outside a git checkout."""
|
|
1488
|
+
import subprocess
|
|
1489
|
+
try:
|
|
1490
|
+
sha = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=Path(__file__).parent,
|
|
1491
|
+
capture_output=True, text=True, timeout=5).stdout.strip() or "installed"
|
|
1492
|
+
except Exception:
|
|
1493
|
+
sha = "installed"
|
|
1494
|
+
return f"{__version__} ({sha})"
|
|
1495
|
+
|
|
1496
|
+
|
|
1497
|
+
def main(argv=None):
|
|
1498
|
+
for s in (sys.stdout, sys.stderr):
|
|
1499
|
+
if hasattr(s, "reconfigure"):
|
|
1500
|
+
s.reconfigure(encoding="utf-8", errors="replace")
|
|
1501
|
+
called = Path(sys.argv[0]).stem.lower() if argv is None else ""
|
|
1502
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
1503
|
+
if argv[:1] == ["start"]: # J2: one plain word to start or continue the build in the background
|
|
1504
|
+
argv[:1] = ["run", "--clear-stop", "--detach"]
|
|
1505
|
+
ap = argparse.ArgumentParser(prog="ap" if called == "ap" else "autopilot",
|
|
1506
|
+
description=DESCRIPTION)
|
|
1507
|
+
if argv[:1] in (["--help"], ["-h"]): # J15: the plain-words opening is the first line of the help
|
|
1508
|
+
print(DESCRIPTION.rsplit(". ", 1)[0] + ".\n")
|
|
1509
|
+
if argv[:1] == ["--version"]: # N5.1: git only runs here, never on a normal command
|
|
1510
|
+
print(f"{called or 'autopilot'} {_version_line()}")
|
|
1511
|
+
return 0
|
|
1512
|
+
ap.add_argument("--version", action="version", version=__version__)
|
|
1513
|
+
ap.add_argument("--ask", action="store_true", help="ask before a new build starts (N5.5)")
|
|
1514
|
+
sub = ap.add_subparsers(dest="cmd")
|
|
1515
|
+
|
|
1516
|
+
def add(name, fn, help_):
|
|
1517
|
+
p = sub.add_parser(name, help=help_)
|
|
1518
|
+
p.add_argument("-C", "--path", default=None,
|
|
1519
|
+
help="project folder (default: this folder, or else the active or last project)")
|
|
1520
|
+
p.set_defaults(fn=fn)
|
|
1521
|
+
return p
|
|
1522
|
+
|
|
1523
|
+
p = add("init", cmd_init, "create .agent/ contract (auto-detects stack)")
|
|
1524
|
+
p.add_argument("--stack"); p.add_argument("--name"); p.add_argument("--force", action="store_true")
|
|
1525
|
+
p = add("onboard", cmd_onboard, "AI session: write BRAIN.md, commands and plan.yaml from a plan doc")
|
|
1526
|
+
p.add_argument("--plan-doc"); p.add_argument("--budget", default="15")
|
|
1527
|
+
p = add("quickstart", cmd_quickstart, "one command: init, onboard from a plan doc, doctor, then optionally run")
|
|
1528
|
+
p.add_argument("--plan-doc"); p.add_argument("--budget", default="15")
|
|
1529
|
+
p.add_argument("--run", action="store_true", help="start the run when the doctor finds no problems")
|
|
1530
|
+
p.add_argument("--idea", help="no PLAN.md yet: what to build (text or a file); Claude writes PLAN.md from it")
|
|
1531
|
+
p.add_argument("--detach", action="store_true", help="start as a separate process that outlives this terminal")
|
|
1532
|
+
p.add_argument("--ask", action="store_true", help="keep the question before the build starts (N5.5)")
|
|
1533
|
+
add("preflight", cmd_preflight, "phase 0: check the credentials the plan needs (read-only); blocks the run on a problem")
|
|
1534
|
+
add("validate", cmd_validate, "validate project.yaml and plan.yaml")
|
|
1535
|
+
p = add("doctor", cmd_doctor, "check the claude CLI, git, gh, sandbox tools, notifications and config")
|
|
1536
|
+
p.add_argument("--fix", action="store_true", help="also fix what needs no person: PATH, reinstall the claude CLI")
|
|
1537
|
+
p = add("run", cmd_run, "run autonomously until the app is complete")
|
|
1538
|
+
p.add_argument("--max-sessions", type=int); p.add_argument("--clear-stop", action="store_true")
|
|
1539
|
+
p.add_argument("--no-page", action="store_true", help="don't start the live status page")
|
|
1540
|
+
p.add_argument("--no-browser", action="store_true", help="start the live page but don't open the browser")
|
|
1541
|
+
p.add_argument("--no-prod", action="store_true", help="run everything except the deploy to the production host (K)")
|
|
1542
|
+
p.add_argument("--no-watch", action="store_true",
|
|
1543
|
+
help="when run without a terminal, don't open a window with `autopilot watch`")
|
|
1544
|
+
p.add_argument("--detach", action="store_true", help="start as a separate process that outlives this terminal")
|
|
1545
|
+
p.add_argument("-v", "--verbose", action="store_true")
|
|
1546
|
+
add("start", cmd_run, "start or continue the build in the background (= run --clear-stop --detach)")
|
|
1547
|
+
add("status", cmd_status, "progress, cost, blocked tasks, approvals")
|
|
1548
|
+
add("stats", cmd_stats, "the numbers of a run worth publishing (markdown table)")
|
|
1549
|
+
p = add("watch", cmd_watch, "follow a run live from any terminal, in plain words")
|
|
1550
|
+
p.add_argument("-n", "--lines", type=int, default=20, help="recent lines to show first")
|
|
1551
|
+
p.add_argument("--detail", action="store_true", help="the technical view: every log line as it is")
|
|
1552
|
+
p.add_argument("--simple", action="store_true", help="plain words, also when the output is not a terminal")
|
|
1553
|
+
p.add_argument("--hold", action="store_true", help="at the end of the build, wait for Enter (a window of its own)")
|
|
1554
|
+
p.add_argument("--scroll", action="store_true", help="the scrolling view in plain words, not the dashboard")
|
|
1555
|
+
p = add("serve", cmd_serve, "live read-only status page in the browser")
|
|
1556
|
+
p.add_argument("--host", default="127.0.0.1"); p.add_argument("--port", type=int, default=8765)
|
|
1557
|
+
p.add_argument("--refresh", type=int, default=30, help="seconds between page reloads")
|
|
1558
|
+
p = add("next", cmd_next, "preview the next tasks in execution order"); p.add_argument("-n", type=int, default=15)
|
|
1559
|
+
p = add("unblock", cmd_unblock, "reset blocked task(s) to pending")
|
|
1560
|
+
p.add_argument("task_ids", nargs="+"); p.add_argument("--note")
|
|
1561
|
+
p = add("answer", cmd_answer, "answer an open decision from docs/NEEDS-YOU.md")
|
|
1562
|
+
p.add_argument("decision_id", nargs="?"); p.add_argument("text", nargs="?")
|
|
1563
|
+
p = add("change", cmd_change, "change the plan in plain words (one replan session; you can undo it)")
|
|
1564
|
+
p.add_argument("text"); p.add_argument("--yes", action="store_true", help="no questions: start and keep the result")
|
|
1565
|
+
p = add("hint", cmd_hint, "give a task advice for its next session (works while a run is active)")
|
|
1566
|
+
p.add_argument("task_id"); p.add_argument("text")
|
|
1567
|
+
p = add("skip", cmd_skip, "skip task(s)"); p.add_argument("task_ids", nargs="+")
|
|
1568
|
+
p = add("approve", cmd_approve, "deploy an approved phase to prod"); p.add_argument("phase")
|
|
1569
|
+
add("mcp", cmd_mcp, "MCP server on stdin/stdout for IDE chats: ap_status, ap_next, ap_start, ap_stop, ap_answer")
|
|
1570
|
+
p = add("uninstall", cmd_uninstall, "--rights: remove the scheduled task / sudoers file the installer created")
|
|
1571
|
+
p.add_argument("--rights", action="store_true")
|
|
1572
|
+
from .upgrade import cmd_install, cmd_upgrade
|
|
1573
|
+
p = add("upgrade", cmd_upgrade, "get the newest Autopilot; --check only prints")
|
|
1574
|
+
p.add_argument("--check", action="store_true"); p.add_argument("--ref"); p.add_argument("--daily", action="store_true", help=argparse.SUPPRESS)
|
|
1575
|
+
p = add("install", cmd_install, "install or repair Autopilot for this user; --rights = the one rights step")
|
|
1576
|
+
p.add_argument("--rights", action="store_true")
|
|
1577
|
+
add("stop", cmd_stop, "graceful stop before the next session")
|
|
1578
|
+
add("resume", cmd_resume, "clear the stop flag")
|
|
1579
|
+
p = add("report", cmd_report, "the run report; --tokens = tokens and cost per step type")
|
|
1580
|
+
p.add_argument("--tokens", action="store_true", help="tokens and cost per step: build, retry, review, verify, audit, ...")
|
|
1581
|
+
p = add("audit", cmd_review, "run the completion audit now (the lean profile never runs it by itself)")
|
|
1582
|
+
p.set_defaults(kind="completion")
|
|
1583
|
+
p = add("review", cmd_review, "run an audit / completion audit / replan now")
|
|
1584
|
+
p.add_argument("--kind", choices=["periodic", "completion", "replan"], default="periodic")
|
|
1585
|
+
p.add_argument("-v", "--verbose", action="store_true")
|
|
1586
|
+
|
|
1587
|
+
args = ap.parse_args(argv)
|
|
1588
|
+
if args.cmd is None: # plain `autopilot` in a project folder: follow its run when one is going
|
|
1589
|
+
if run_active(Path.cwd()):
|
|
1590
|
+
print("a run is active here; following it (Ctrl+C stops watching, not the run)\n")
|
|
1591
|
+
return cmd_watch(argparse.Namespace(path=".", lines=20))
|
|
1592
|
+
from .report import next_steps_text
|
|
1593
|
+
if sys.stdin.isatty() and sys.stdout.isatty() and (case := folder_case(Path.cwd())) != "agent":
|
|
1594
|
+
adopt = case == "code" and not idea_file(Path.cwd()) # N5.5: a folder with just the idea is a new project
|
|
1595
|
+
if (rc := adopt_start(Path.cwd().resolve()) if adopt else guided_start(args.ask)) >= 0: # -1: the normal banner
|
|
1596
|
+
return rc
|
|
1597
|
+
print(f"autopilot {__version__}: {DESCRIPTION.split('. ')[0]}.")
|
|
1598
|
+
print(next_steps_text(Path.cwd()))
|
|
1599
|
+
return 0
|
|
1600
|
+
if args.cmd in ("upgrade", "install"): # no project: nothing to find or remember
|
|
1601
|
+
return args.fn(args) or 0
|
|
1602
|
+
if args.path is None: # J2: no -C
|
|
1603
|
+
found = "." if args.cmd in NEW_PROJECT or (Path.cwd() / AGENT_DIR / "project.yaml").exists() else pick_project()
|
|
1604
|
+
if found is None:
|
|
1605
|
+
return 1
|
|
1606
|
+
args.path = found
|
|
1607
|
+
remember(Path(args.path).resolve())
|
|
1608
|
+
if getattr(args, "detach", False):
|
|
1609
|
+
return start_detached(Path(args.path).resolve(), [a for a in (argv or sys.argv[1:]) if a != "--detach"])
|
|
1610
|
+
return args.fn(args) or 0
|
|
1611
|
+
|
|
1612
|
+
|
|
1613
|
+
if __name__ == "__main__":
|
|
1614
|
+
sys.exit(main())
|