Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 21 additions & 8 deletions misterdev/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
58 changes: 48 additions & 10 deletions misterdev/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -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(
Expand Down
8 changes: 8 additions & 0 deletions misterdev/core/execution/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 22 additions & 1 deletion misterdev/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand All @@ -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}"

Expand Down
88 changes: 73 additions & 15 deletions misterdev/nl_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -117,31 +136,70 @@ 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(
"[yellow]Couldn't map that to an action.[/] Try `misterdev --help`."
)
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.")
Expand Down
2 changes: 1 addition & 1 deletion tests/test_mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading