diff --git a/misterdev/agent.py b/misterdev/agent.py index d693ae2..e7c2ccb 100644 --- a/misterdev/agent.py +++ b/misterdev/agent.py @@ -651,6 +651,7 @@ def build( args: str = "", reference_dir: str | None = None, progress_cb: Optional[Callable[..., None]] = None, + spec_text: str = "", ) -> str: project = self._get_or_register(project_path) if not project: @@ -719,14 +720,20 @@ def build( # exhausted by pre-execution analysis+probes+spec and crashed the run.) report = None try: - # Phase 1: Analysis - assessment = self._analyze(project, env_activate) + # Phase 1: Analysis — skipped when the caller supplies a spec directly + # (spec_text path: Claude already analysed the codebase; use a zero-cost + # stub so decomposition still has a well-typed object to read). + if spec_text: + assessment = ProjectAssessment() + else: + assessment = self._analyze(project, env_activate) report = BuildReport(mode, project.name, assessment, start_time) report.health_before = assessment.health.model_copy() - _warn_if_baseline_broken(assessment, report) - _warn_if_no_test_gate(assessment, project, report) - _warn_if_test_gate_is_noop(assessment, report) + if not spec_text: + _warn_if_baseline_broken(assessment, report) + _warn_if_no_test_gate(assessment, project, report) + _warn_if_test_gate_is_noop(assessment, report) return self._run_pipeline( project, @@ -738,6 +745,7 @@ def build( report, reference_digest=reference_digest, progress_cb=progress_cb, + spec_text=spec_text, ) except BudgetExceededError as e: return self._halt_on_budget(project, report, e) @@ -1246,6 +1254,7 @@ def _run_pipeline( confirm_plan: bool = False, reference_digest: str = "", progress_cb: Optional[Callable[..., None]] = None, + spec_text: str = "", ) -> str: """Phases 1.5-6: probes, spec, decompose, (confirm), execute, validate. @@ -1323,9 +1332,13 @@ def _run_pipeline( # conservative: only claims the verifier refutes with evidence are dropped. self._verify_completeness_claims(project, assessment, report) - # Phase 2: Generate Spec - spec = self._generate_spec( - mode, prompt, assessment, project, facts=verified_facts + # Phase 2: Generate Spec (skip when caller supplies one directly) + spec = ( + spec_text + if spec_text + else self._generate_spec( + mode, prompt, assessment, project, facts=verified_facts + ) ) # Prepend the reference-implementation digest (when porting from one) so diff --git a/misterdev/cli.py b/misterdev/cli.py index 7bb82fe..06f871f 100644 --- a/misterdev/cli.py +++ b/misterdev/cli.py @@ -374,15 +374,25 @@ def main(): _print_doctor(result) sys.exit(result.get("exit_code", 1)) elif args.command == "run": + from pathlib import Path as _Path + + run_path = args.project_path + # If project_path isn't a directory the user likely typed a goal + # (e.g. `misterdev run "add feature X"`); route to build instead. + if run_path != "." and not _Path(run_path).is_dir() and not args.task: + from misterdev.nl_cli import route as _nl_route + + sys.exit(_nl_route(run_path, orchestrator)) + if args.task: - logger.info( - f"Running specific task {args.task} in project {args.project_path}" - ) - orchestrator.run_task(args.project_path, args.task) + logger.info(f"Running specific task {args.task} in project {run_path}") + orchestrator.run_task(run_path, args.task) else: - logger.info(f"Running pending tasks for project {args.project_path}") + status_before = orchestrator.get_project_status(run_path) + has_tasks = bool(status_before.get("tasks")) + logger.info(f"Running pending tasks for project {run_path}") orchestrator.run_project( - args.project_path, + run_path, dry_run=args.dry_run, skip_preflight=args.skip_preflight, force=args.force, @@ -391,8 +401,24 @@ def main(): budget=args.budget, proceed=args.proceed, ) + if not has_tasks and not args.tasks: + console.print( + "[dim]No planned tasks found.[/] " + "Use [bold]misterdev build [goal][/bold] to autonomously plan and execute work." + ) elif args.command == "build": - build_args = list(args.prompt) + from pathlib import Path as _Path + + project_path = args.project_path + prompt_words = list(args.prompt) + # If project_path doesn't exist as a directory the user wrote something + # like `misterdev build "add caching"` — treat the value as the first + # word(s) of the goal and default the path to the current directory. + if project_path != "." and not _Path(project_path).is_dir(): + prompt_words = [project_path] + prompt_words + project_path = "." + + build_args = prompt_words if args.budget != 100.0: build_args.extend(["--budget", str(args.budget)]) if args.commit: @@ -416,9 +442,21 @@ def main(): if args.max_tasks is not None: build_args.extend(["--max-tasks", str(args.max_tasks)]) - report = orchestrator.build( - args.project_path, " ".join(build_args), reference_dir=args.reference - ) + from rich.live import Live + from rich.spinner import Spinner + + _spinner = Spinner("dots", text="Analyzing…") + + def _on_progress(done, total, phase): + _spinner.text = f"{phase} [{done}/{total}]" if total else phase + + with Live(_spinner, console=console, transient=True, refresh_per_second=10): + report = orchestrator.build( + project_path, + " ".join(build_args), + reference_dir=args.reference, + progress_cb=_on_progress, + ) console.print("\n") if orchestrator.last_build_succeeded: console.print( diff --git a/misterdev/core/execution/registry.py b/misterdev/core/execution/registry.py index 18e234d..ffd1fbf 100644 --- a/misterdev/core/execution/registry.py +++ b/misterdev/core/execution/registry.py @@ -89,6 +89,14 @@ def register_project(self, project_path: str | Path, save: bool = True) -> Proje logger.debug(f"Project already registered: {project_id}") return self.projects[project_id] + yaml_path = path / "project.yaml" + if not yaml_path.exists(): + try: + yaml_path.write_text(f"name: {path.name}\n", encoding="utf-8") + logger.info(f"Created minimal project.yaml at {yaml_path}") + except OSError as e: + logger.warning(f"Could not write project.yaml at {yaml_path}: {e}") + config = self.config_manager.load_project_config(path) project = Project(path, config) self.projects[project_id] = project diff --git a/misterdev/mcp_server.py b/misterdev/mcp_server.py index da3effc..0889a61 100644 --- a/misterdev/mcp_server.py +++ b/misterdev/mcp_server.py @@ -272,6 +272,19 @@ def build( examples=["/Users/me/code/donor-impl"], ), ] = None, + spec_text: Annotated[ + Optional[str], + Field( + description=( + "A complete implementation spec in markdown — supply this when you " + "have already analysed the codebase and written the spec yourself " + "(e.g. from a Claude conversation). misterdev will skip its own " + "analysis and spec-generation phases and go straight to " + "decompose → execute → verify using your spec. Omit to let " + "misterdev analyse and generate the spec from ``goal``." + ), + ), + ] = None, ) -> str: """Autonomously plan AND execute a goal in a project, from scratch. @@ -283,6 +296,9 @@ def build( Related: ``run`` (execute an existing plan), ``status`` (inspect tasks). Pass ``reference_dir`` to port from an existing implementation: its module/symbol map is extracted read-only and guides the plan. + Pass ``spec_text`` when you have already written the implementation spec + (e.g. in a Claude conversation) — misterdev skips its own planning phase + and executes your spec directly, making it Claude's execution backend. DESTRUCTIVE side effects: edits files and makes git commits, and calls an external LLM provider (open-world, non-idempotent). It refuses to run on a @@ -299,7 +315,12 @@ def build( parts.append("--parallel") if max_tasks is not None: parts += ["--max-tasks", str(max_tasks)] - report = orch.build(path, " ".join(parts), reference_dir=reference_dir) + report = orch.build( + path, + " ".join(parts), + reference_dir=reference_dir, + spec_text=spec_text or "", + ) outcome = "succeeded" if orch.last_build_succeeded else "did not fully succeed" return f"Build {outcome}.\n\n{report}" diff --git a/misterdev/nl_cli.py b/misterdev/nl_cli.py index d9a13b3..2aec09c 100644 --- a/misterdev/nl_cli.py +++ b/misterdev/nl_cli.py @@ -89,12 +89,31 @@ def preview(intent: Dict[str, Any]) -> str: def _dispatch(intent: Dict[str, Any], orchestrator) -> int: + from rich.live import Live + from rich.markdown import Markdown + from rich.panel import Panel + from rich.spinner import Spinner + cmd = intent.get("command") path = intent.get("path") or "." if cmd == "build": - report = orchestrator.build(path, _build_args(intent)) - console.print(report) - return 0 if orchestrator.last_build_succeeded else 1 + spinner = Spinner("dots", text="Building…") + + def _on_progress(done, total, phase): + spinner.text = f"{phase} [{done}/{total}]" if total else phase + + with Live(spinner, console=console, transient=True, refresh_per_second=10): + report = orchestrator.build( + path, _build_args(intent), progress_cb=_on_progress + ) + succeeded = orchestrator.last_build_succeeded + title = ( + "[bold green]Build Complete[/bold green]" + if succeeded + else "[bold red]Build Failed Validation[/bold red]" + ) + console.print(Panel(Markdown(report), title=title, expand=False)) + return 0 if succeeded else 1 if cmd == "run": orchestrator.run_project(path, dry_run=bool(intent.get("dry_run"))) return 0 @@ -117,22 +136,60 @@ def _dispatch(intent: Dict[str, Any], orchestrator) -> int: return 1 +_MANAGEMENT_WORDS = {"scan", "list", "status", "report", "run", "plan"} +_QUERY_WORDS = { + "what", + "how", + "why", + "show", + "get", + "find", + "check", + "is", + "are", + "does", + "do", + "did", + "has", + "have", + "which", + "where", + "when", + "who", +} +_DESTRUCTIVE_VERBS = {"delete", "remove", "drop", "destroy", "wipe", "erase", "purge"} + + +def _fast_route(request: str) -> Dict[str, Any] | None: + """Return a build intent without an LLM call when the request clearly + describes coding work (first word is not a management or query word).""" + first = request.strip().split()[0].lower() if request.strip() else "" + if first and first not in _MANAGEMENT_WORDS and first not in _QUERY_WORDS: + return {"command": "build", "path": ".", "goal": request.strip()} + return None + + def route(request: str, orchestrator, confirm=input) -> int: """Resolve a plain-English request to an action and run it. Returns a process exit code. ``confirm`` is injectable for testing. """ - cfg = ConfigManager().load_project_config(".") - try: - client = create_llm_client(cfg) - except Exception as e: - console.print( - "[yellow]Natural-language mode needs an LLM configured " - f"(model + API key). {e}[/]\nRun `misterdev --help` for the flag-based CLI." - ) - return 1 + intent = _fast_route(request) + first_word = request.strip().split()[0].lower() if request.strip() else "" + # Skip confirm for fast-routed requests unless the verb is destructive. + needs_confirm = intent is None or first_word in _DESTRUCTIVE_VERBS + if intent is None: + cfg = ConfigManager().load_project_config(".") + try: + client = create_llm_client(cfg) + except Exception as e: + console.print( + "[yellow]Natural-language mode needs an LLM configured " + f"(model + API key). {e}[/]\nRun `misterdev --help` for the flag-based CLI." + ) + return 1 + intent = parse_intent(request, client) - intent = parse_intent(request, client) cmd = intent.get("command") if cmd not in KNOWN_COMMANDS: console.print( @@ -140,8 +197,9 @@ def route(request: str, orchestrator, confirm=input) -> int: ) return 1 - console.print(f"[dim]→ I'll run:[/] misterdev {preview(intent)}") - if cmd in _MUTATING: + if needs_confirm: + console.print(f"[dim]→ I'll run:[/] misterdev {preview(intent)}") + if needs_confirm and cmd in _MUTATING: answer = confirm("proceed? [Y/n] ").strip().lower() if answer and answer not in ("y", "yes"): console.print("Cancelled.") diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 91913fd..c1368ae 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -11,7 +11,7 @@ class _FakeOrch: last_build_succeeded = True calls: dict = {} - def build(self, path, args, reference_dir=None, progress_cb=None): + def build(self, path, args, reference_dir=None, progress_cb=None, spec_text=""): _FakeOrch.calls["build"] = (path, args, reference_dir) if progress_cb is not None: progress_cb(done=1, total=1, phase="building") diff --git a/tests/test_nl_cli.py b/tests/test_nl_cli.py index 5f74af7..d8b1913 100644 --- a/tests/test_nl_cli.py +++ b/tests/test_nl_cli.py @@ -15,7 +15,7 @@ class _FakeOrch: last_build_succeeded = True calls: dict = {} - def build(self, path, args): + def build(self, path, args, progress_cb=None, **kwargs): _FakeOrch.calls["build"] = (path, args) return "REPORT" @@ -80,8 +80,10 @@ def test_route_confirms_then_runs_mutating(monkeypatch): def test_route_cancel_skips_execution(monkeypatch): + # Use a query-word prefix so the request falls through to the LLM and the + # confirm prompt fires; the fast-path skips confirm by design. _stub_client(monkeypatch, '{"command": "build", "path": ".", "goal": "x"}') - rc = nl_cli.route("x", _FakeOrch(), confirm=lambda _p: "n") + rc = nl_cli.route("do build x", _FakeOrch(), confirm=lambda _p: "n") assert rc == 0 assert "build" not in _FakeOrch.calls @@ -98,5 +100,45 @@ def _no_confirm(_p): def test_route_unmappable_request(monkeypatch): _stub_client(monkeypatch, '{"command": "nonsense"}') - rc = nl_cli.route("blah blah", _FakeOrch(), confirm=lambda _p: "y") + # "what" is a query word so it falls through to the LLM, which returns an + # unknown command — the router should return 1. + rc = nl_cli.route("what is blah blah", _FakeOrch(), confirm=lambda _p: "y") assert rc == 1 + + +def test_fast_route_skips_llm_for_build_requests(monkeypatch): + called = [] + monkeypatch.setattr( + nl_cli, "parse_intent", lambda req, client: called.append(1) or {} + ) + orch = _FakeOrch() + _FakeOrch.calls = {} + nl_cli.route("add caching to the API", orch, confirm=lambda _p: "y") + assert not called, "LLM should not be called for an obvious build request" + assert "build" in _FakeOrch.calls + + +def test_fast_route_falls_back_to_llm_for_queries(monkeypatch): + called = [] + _stub_client(monkeypatch, '{"command": "status", "path": "."}') + orig = nl_cli.parse_intent + + def spy(req, client): + called.append(req) + return orig(req, client) + + monkeypatch.setattr(nl_cli, "parse_intent", spy) + nl_cli.route("what is the current status", _FakeOrch(), confirm=lambda _p: "n") + assert called, "LLM should be called for a query-word request" + + +def test_destructive_verb_still_confirms(monkeypatch): + monkeypatch.setattr(nl_cli, "parse_intent", lambda req, client: {}) + confirmed = [] + orch = _FakeOrch() + _FakeOrch.calls = {} + nl_cli.route( + "delete the auth module", orch, confirm=lambda _p: confirmed.append(1) or "n" + ) + assert confirmed, "destructive verb must prompt for confirmation even on fast path" + assert "build" not in _FakeOrch.calls