dev-autopilot 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. autopilot/__init__.py +4 -0
  2. autopilot/__main__.py +6 -0
  3. autopilot/backends/__init__.py +159 -0
  4. autopilot/backends/claude_cli.py +368 -0
  5. autopilot/backends/command.py +90 -0
  6. autopilot/cli.py +1614 -0
  7. autopilot/commands/__init__.py +0 -0
  8. autopilot/commands/preflight.py +131 -0
  9. autopilot/config.py +155 -0
  10. autopilot/context.py +414 -0
  11. autopilot/dash_term.py +493 -0
  12. autopilot/dash_web.py +347 -0
  13. autopilot/dashboard.py +410 -0
  14. autopilot/decisions.py +85 -0
  15. autopilot/deploy.py +67 -0
  16. autopilot/docs.py +301 -0
  17. autopilot/doctor.py +478 -0
  18. autopilot/gate.py +383 -0
  19. autopilot/github_util.py +42 -0
  20. autopilot/gitops.py +251 -0
  21. autopilot/growth.py +85 -0
  22. autopilot/handoff.py +27 -0
  23. autopilot/layers.py +134 -0
  24. autopilot/log_scrub.py +11 -0
  25. autopilot/mcp.py +104 -0
  26. autopilot/notify.py +98 -0
  27. autopilot/orchestrator.py +2214 -0
  28. autopilot/plain.py +238 -0
  29. autopilot/plan.py +320 -0
  30. autopilot/proc.py +176 -0
  31. autopilot/prompts/auditor.md +39 -0
  32. autopilot/prompts/decide.md +38 -0
  33. autopilot/prompts/docs.md +48 -0
  34. autopilot/prompts/fixer.md +26 -0
  35. autopilot/prompts/onboard.md +43 -0
  36. autopilot/prompts/plan_writer.md +40 -0
  37. autopilot/prompts/replanner.md +42 -0
  38. autopilot/prompts/research.md +25 -0
  39. autopilot/prompts/resume.md +10 -0
  40. autopilot/prompts/retry.md +8 -0
  41. autopilot/prompts/review.md +40 -0
  42. autopilot/prompts/system.md +16 -0
  43. autopilot/prompts/task.md +78 -0
  44. autopilot/prompts/triage.md +30 -0
  45. autopilot/prompts/unstick.md +37 -0
  46. autopilot/prompts/verify.md +32 -0
  47. autopilot/repomap.py +71 -0
  48. autopilot/report.py +633 -0
  49. autopilot/rights.py +184 -0
  50. autopilot/schemas.py +42 -0
  51. autopilot/state.py +298 -0
  52. autopilot/templates/__pycache__/cloudflare_apply.cpython-313.pyc +0 -0
  53. autopilot/templates/agent/BRAIN.md +21 -0
  54. autopilot/templates/agent/CLAUDE.md.snippet +10 -0
  55. autopilot/templates/agent/DECISIONS.md +5 -0
  56. autopilot/templates/agent/HANDOFF.md +3 -0
  57. autopilot/templates/agent/gitignore +10 -0
  58. autopilot/templates/agent/ide_rules.md +8 -0
  59. autopilot/templates/agent/plan.yaml +48 -0
  60. autopilot/templates/agent/project.yaml +259 -0
  61. autopilot/templates/cloudflare_apply.py +211 -0
  62. autopilot/templates/deploy.sh +72 -0
  63. autopilot/templates/email_verify.py +144 -0
  64. autopilot/templates/infra/cloudflare.yaml +11 -0
  65. autopilot/templates/presets/docker.yaml +4 -0
  66. autopilot/templates/presets/generic.yaml +3 -0
  67. autopilot/templates/presets/go.yaml +6 -0
  68. autopilot/templates/presets/java.yaml +4 -0
  69. autopilot/templates/presets/node.yaml +7 -0
  70. autopilot/templates/presets/python.yaml +6 -0
  71. autopilot/templates/presets/rust.yaml +5 -0
  72. autopilot/templates/presets/static.yaml +3 -0
  73. autopilot/upgrade.py +192 -0
  74. dev_autopilot-0.4.0.dist-info/METADATA +148 -0
  75. dev_autopilot-0.4.0.dist-info/RECORD +79 -0
  76. dev_autopilot-0.4.0.dist-info/WHEEL +5 -0
  77. dev_autopilot-0.4.0.dist-info/entry_points.txt +3 -0
  78. dev_autopilot-0.4.0.dist-info/licenses/LICENSE +21 -0
  79. dev_autopilot-0.4.0.dist-info/top_level.txt +1 -0
autopilot/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())