autodevloop 0.1.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.
@@ -0,0 +1,5 @@
1
+ """AutoDevLoop: AI-driven autonomous software iteration loop."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,6 @@
1
+ """Allow `python -m autodevloop ...`."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
autodevloop/cli.py ADDED
@@ -0,0 +1,233 @@
1
+ """Command-line interface for AutoDevLoop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from . import __version__
11
+ from .config import deep_get, deep_merge, load_config, save_config
12
+ from .engine import AutoDevLoop
13
+ from .util import APP_DIR, STATE_FILE, STOP_FILE, load_json, now_text, write_text
14
+
15
+
16
+ def resolve_project_dir(raw: str | None, base_dir: Path | None = None) -> Path:
17
+ base = (base_dir or Path.cwd()).resolve()
18
+ if not raw:
19
+ return base
20
+ path = Path(raw).expanduser()
21
+ return path.resolve() if path.is_absolute() else (base / path).resolve()
22
+
23
+
24
+ def _prompt(label: str, default: str = "") -> str:
25
+ suffix = f" [{default}]" if default else ""
26
+ return input(f"{label}{suffix}: ").strip() or default
27
+
28
+
29
+ def _prompt_int(label: str, default: int, minimum: int = 1) -> int:
30
+ while True:
31
+ raw = _prompt(label, str(default))
32
+ try:
33
+ value = int(raw)
34
+ except ValueError:
35
+ print(f"Please enter an integer >= {minimum}.")
36
+ continue
37
+ if value >= minimum:
38
+ return value
39
+ print(f"Please enter an integer >= {minimum}.")
40
+
41
+
42
+ def _prompt_multiline(label: str) -> str:
43
+ print(label)
44
+ print("Enter the requirement text. Finish with a single line containing only END.")
45
+ lines: list[str] = []
46
+ while True:
47
+ line = input()
48
+ if line.strip().upper() == "END":
49
+ break
50
+ lines.append(line)
51
+ return "\n".join(lines).strip()
52
+
53
+
54
+ def _overrides_from_args(args: argparse.Namespace) -> dict[str, Any]:
55
+ """Build a config-shaped override dict from any explicitly-set CLI flags."""
56
+ o: dict[str, Any] = {"project": {}, "provider": {}, "pipeline": {}, "agents": {}, "review": {}, "fix": {}, "tests": {}, "vcs": {}}
57
+ if getattr(args, "project_name", ""):
58
+ o["project"]["name"] = args.project_name
59
+ if getattr(args, "arch_hint", ""):
60
+ o["project"]["arch_hint"] = args.arch_hint
61
+ if getattr(args, "mode", ""):
62
+ o["pipeline"]["mode"] = args.mode
63
+ if getattr(args, "provider", ""):
64
+ o["provider"]["name"] = args.provider
65
+ if getattr(args, "provider_command", ""):
66
+ o["provider"]["command"] = args.provider_command
67
+ if getattr(args, "model", ""):
68
+ o["provider"]["model"] = args.model
69
+ if getattr(args, "agent_timeout", None):
70
+ o["agents"]["timeout"] = args.agent_timeout
71
+ if getattr(args, "max_parallel_agents", None):
72
+ o["agents"]["max_parallel"] = args.max_parallel_agents
73
+ if getattr(args, "no_parallel", False):
74
+ o["agents"]["allow_parallel"] = False
75
+ if getattr(args, "review_threshold", None):
76
+ o["review"]["threshold"] = args.review_threshold
77
+ if getattr(args, "fix_retries", None) is not None:
78
+ o["fix"]["retries"] = args.fix_retries
79
+ if getattr(args, "test_command", ""):
80
+ o["tests"]["command"] = args.test_command
81
+ if getattr(args, "test_timeout", None):
82
+ o["tests"]["timeout"] = args.test_timeout
83
+ if getattr(args, "no_git", False):
84
+ o["vcs"]["git"] = False
85
+ return {k: v for k, v in o.items() if v}
86
+
87
+
88
+ def cmd_run(args: argparse.Namespace) -> None:
89
+ execution_dir = Path.cwd().resolve()
90
+ if not args.non_interactive and not args.project_dir:
91
+ print("[AutoDevLoop] Project directory setup.")
92
+ print(f"Absolute example: {execution_dir}")
93
+ print("Relative example: . or demo-project")
94
+ args.project_dir = _prompt("Project directory", str(execution_dir))
95
+
96
+ root = resolve_project_dir(args.project_dir, execution_dir)
97
+ config = deep_merge(load_config(root), _overrides_from_args(args))
98
+
99
+ goal = args.goal or deep_get(config, "project.goal", "")
100
+ project_name = deep_get(config, "project.name", "") or root.name
101
+ max_versions = args.max_versions or int(deep_get(config, "project.max_versions", 5))
102
+
103
+ if not args.non_interactive:
104
+ print("[AutoDevLoop] Interactive setup. Press Enter to accept defaults.")
105
+ if not project_name:
106
+ project_name = _prompt("Project name", root.name)
107
+ if not goal:
108
+ goal = _prompt_multiline("Goal / user requirement")
109
+ max_versions = _prompt_int("Max versions", max_versions, minimum=1)
110
+ mode = _prompt("Mode (simple/advanced)", deep_get(config, "pipeline.mode", "advanced"))
111
+ config = deep_merge(config, {"pipeline": {"mode": mode}, "project": {"name": project_name, "max_versions": max_versions}})
112
+
113
+ if not goal and not (root / APP_DIR / STATE_FILE).exists():
114
+ raise SystemExit("A goal is required. Use --goal \"...\" or run interactively.")
115
+
116
+ # Persist resolved config so the web UI and resumes see the same settings.
117
+ save_config(root, deep_merge(config, {"project": {"name": project_name, "goal": goal, "max_versions": max_versions}}))
118
+
119
+ print(f"[AutoDevLoop] Stop with: autodevloop stop --project-dir {root}")
120
+ AutoDevLoop(root, config).run(
121
+ reset=args.reset, goal=goal, project_name=project_name, max_versions=max_versions,
122
+ )
123
+
124
+
125
+ def cmd_stop(args: argparse.Namespace) -> None:
126
+ root = resolve_project_dir(args.project_dir)
127
+ app_dir = root / APP_DIR
128
+ app_dir.mkdir(parents=True, exist_ok=True)
129
+ write_text(app_dir / STOP_FILE, f"stop requested at {now_text()}\n")
130
+ print(f"[AutoDevLoop] Stop requested: {app_dir / STOP_FILE}")
131
+
132
+
133
+ def cmd_status(args: argparse.Namespace) -> None:
134
+ root = resolve_project_dir(args.project_dir)
135
+ state = load_json(root / APP_DIR / STATE_FILE, {})
136
+ if not state:
137
+ print("[AutoDevLoop] No state found. Run `autodevloop run` first.")
138
+ return
139
+ versions = state.get("versions", [])
140
+ scores = [v.get("review_score", 0) for v in versions if isinstance(v.get("review_score", 0), int)]
141
+ cost = state.get("cost", {})
142
+ print()
143
+ print(f" Project : {state.get('project_name')}")
144
+ print(f" Status : {state.get('status')} (phase: {state.get('phase')})")
145
+ print(f" Version : v{state.get('current_version')} / {state.get('max_versions')}")
146
+ print(f" Goal : {state.get('goal_progress', 0)}% met"
147
+ + (f" (completed at v{state.get('goal_completed_version')})" if state.get('goal_completed_version') else ""))
148
+ if scores:
149
+ print(f" Scores : {scores} (avg {round(sum(scores)/len(scores))})")
150
+ print(f" Cost : ${cost.get('cost_usd_total', 0):.4f} | "
151
+ f"in {cost.get('input_tokens', 0)} / out {cost.get('output_tokens', 0)} tokens")
152
+ print(f" Updated : {state.get('updated_at')}")
153
+ print()
154
+
155
+
156
+ def cmd_web(args: argparse.Namespace) -> None:
157
+ from .webapp import serve
158
+ serve(host=args.host, port=args.port)
159
+
160
+
161
+ def cmd_version(_args: argparse.Namespace) -> None:
162
+ print(f"AutoDevLoop {__version__}")
163
+
164
+
165
+ def _add_project_dir(parser: argparse.ArgumentParser) -> None:
166
+ parser.add_argument("--project-dir", default=None, help="Project directory (default: current dir).")
167
+
168
+
169
+ def build_parser() -> argparse.ArgumentParser:
170
+ parser = argparse.ArgumentParser(prog="autodevloop", description="AI-driven autonomous development iteration loop.")
171
+ parser.add_argument("--version", action="version", version=f"AutoDevLoop {__version__}")
172
+ _add_project_dir(parser)
173
+ sub = parser.add_subparsers(dest="command", required=True)
174
+
175
+ run = sub.add_parser("run", help="Start or resume an autonomous run.")
176
+ _add_project_dir(run)
177
+ run.add_argument("--project-name", default="")
178
+ run.add_argument("--goal", default="", help="User requirement / project goal.")
179
+ run.add_argument("--max-versions", type=int, default=0, help="Number of versions to generate.")
180
+ run.add_argument("--mode", choices=["simple", "advanced"], default="", help="Pipeline mode.")
181
+ run.add_argument("--arch-hint", default="", help="Optional architecture / stack hint for AgentARCH.")
182
+ run.add_argument("--provider", choices=["claude", "codex", "gemini"], default="", help="Provider profile.")
183
+ run.add_argument("--provider-command", default="", help="Override the CLI command (e.g. a wrapper).")
184
+ run.add_argument("--model", default="", help="Optional model alias/name passed to the provider.")
185
+ run.add_argument("--test-command", default="", help="Override test command (otherwise auto-detected).")
186
+ run.add_argument("--test-timeout", type=int, default=0)
187
+ run.add_argument("--agent-timeout", type=int, default=0)
188
+ run.add_argument("--review-threshold", type=int, default=0)
189
+ run.add_argument("--fix-retries", type=int, default=None)
190
+ run.add_argument("--max-parallel-agents", type=int, default=0)
191
+ run.add_argument("--no-parallel", action="store_true")
192
+ run.add_argument("--no-git", action="store_true", help="Disable git commits/tags in current/.")
193
+ run.add_argument("--reset", action="store_true", help="Start fresh (wipe .autodev, versions, current).")
194
+ run.add_argument("--non-interactive", action="store_true", help="Never prompt; fail if goal missing.")
195
+ run.set_defaults(func=cmd_run)
196
+
197
+ stop = sub.add_parser("stop", help="Request a running loop to stop.")
198
+ _add_project_dir(stop)
199
+ stop.set_defaults(func=cmd_stop)
200
+
201
+ status = sub.add_parser("status", help="Print run state summary.")
202
+ _add_project_dir(status)
203
+ status.set_defaults(func=cmd_status)
204
+
205
+ web = sub.add_parser("web", help="Launch the local web dashboard.")
206
+ web.add_argument("--host", default="127.0.0.1")
207
+ web.add_argument("--port", type=int, default=8787)
208
+ web.set_defaults(func=cmd_web)
209
+
210
+ ver = sub.add_parser("version", help="Print version.")
211
+ ver.set_defaults(func=cmd_version)
212
+ return parser
213
+
214
+
215
+ def _make_stdout_safe() -> None:
216
+ """Avoid UnicodeEncodeError on legacy Windows code pages (cp932/gbk/etc.)."""
217
+ for stream in (sys.stdout, sys.stderr):
218
+ try:
219
+ stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
220
+ except (AttributeError, ValueError):
221
+ pass
222
+
223
+
224
+ def main(argv: list[str] | None = None) -> int:
225
+ _make_stdout_safe()
226
+ parser = build_parser()
227
+ args = parser.parse_args(argv)
228
+ args.func(args)
229
+ return 0
230
+
231
+
232
+ if __name__ == "__main__":
233
+ raise SystemExit(main())
autodevloop/config.py ADDED
@@ -0,0 +1,165 @@
1
+ """Configuration defaults, loading, merging, and persistence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from . import yaml_compat
10
+ from .util import CONFIG_FILE, read_text, write_text
11
+
12
+ DEFAULT_AGENT_TIMEOUT = 1800
13
+ DEFAULT_MAX_VERSIONS = 5
14
+ DEFAULT_REVIEW_THRESHOLD = 80
15
+ DEFAULT_FIX_RETRIES = 2
16
+ DEFAULT_MAX_PARALLEL_AGENTS = 3
17
+ DEFAULT_TEST_TIMEOUT = 120
18
+ DEFAULT_VALUE_THRESHOLD = 65
19
+
20
+ # Provider profiles. Only the CLI *command* (and optional args/model) differ;
21
+ # no API keys are ever requested. Users pre-configure their CLI locally.
22
+ PROVIDER_PROFILES: dict[str, dict[str, Any]] = {
23
+ "claude": {
24
+ "command": "claude",
25
+ "args": [
26
+ "--print", "--input-format", "text", "--output-format", "json",
27
+ "--no-session-persistence", "--permission-mode", "acceptEdits",
28
+ "--allowedTools", "Read,Write,Edit,MultiEdit,LS,Glob,Grep",
29
+ ],
30
+ "model_flag": "--model",
31
+ "prompt_via": "stdin",
32
+ "output": "claude-json",
33
+ },
34
+ "codex": {
35
+ "command": "codex",
36
+ "args": ["exec", "--skip-git-repo-check", "--dangerously-bypass-approvals-and-sandbox"],
37
+ "model_flag": "--model",
38
+ "prompt_via": "stdin",
39
+ "output": "text",
40
+ },
41
+ "gemini": {
42
+ "command": "gemini",
43
+ "args": ["-y"],
44
+ "model_flag": "--model",
45
+ "prompt_via": "stdin",
46
+ "output": "text",
47
+ },
48
+ }
49
+
50
+ # Steps enabled per mode. Web/CLI may override individual steps.
51
+ SIMPLE_STEPS = {
52
+ "arch": True,
53
+ "goal_check": False, # folded into review in simple mode
54
+ "test_agent": False, # built-in test detection only (no extra LLM call)
55
+ "doc": False,
56
+ "scout": False, # no周边-feature scouting
57
+ "evaluate": False,
58
+ "features_doc": True,
59
+ }
60
+ ADVANCED_STEPS = {
61
+ "arch": True,
62
+ "goal_check": True,
63
+ "test_agent": True,
64
+ "doc": True,
65
+ "scout": True,
66
+ "evaluate": True,
67
+ "features_doc": True,
68
+ }
69
+
70
+
71
+ def default_config() -> dict[str, Any]:
72
+ return {
73
+ "project": {
74
+ "name": "",
75
+ "goal": "",
76
+ "max_versions": DEFAULT_MAX_VERSIONS,
77
+ "arch_hint": "",
78
+ },
79
+ "provider": {
80
+ "name": "claude",
81
+ "command": "", # blank -> use profile command
82
+ "model": "",
83
+ "extra_args": [],
84
+ },
85
+ "pipeline": {
86
+ "mode": "advanced", # "simple" | "advanced"
87
+ "steps": {}, # per-step overrides on top of mode defaults
88
+ },
89
+ "agents": {
90
+ "timeout": DEFAULT_AGENT_TIMEOUT,
91
+ "allow_parallel": True,
92
+ "max_parallel": DEFAULT_MAX_PARALLEL_AGENTS,
93
+ "retries": 3,
94
+ "backoff_seconds": 5,
95
+ },
96
+ "review": {
97
+ "threshold": DEFAULT_REVIEW_THRESHOLD,
98
+ },
99
+ "value": {
100
+ "threshold": DEFAULT_VALUE_THRESHOLD,
101
+ },
102
+ "fix": {
103
+ "retries": DEFAULT_FIX_RETRIES,
104
+ },
105
+ "tests": {
106
+ "timeout": DEFAULT_TEST_TIMEOUT,
107
+ "command": "",
108
+ },
109
+ "vcs": {
110
+ "git": True,
111
+ },
112
+ }
113
+
114
+
115
+ def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
116
+ result = copy.deepcopy(base)
117
+ for key, value in (override or {}).items():
118
+ if isinstance(value, dict) and isinstance(result.get(key), dict):
119
+ result[key] = deep_merge(result[key], value)
120
+ else:
121
+ result[key] = copy.deepcopy(value)
122
+ return result
123
+
124
+
125
+ def load_config(root: Path) -> dict[str, Any]:
126
+ raw = yaml_compat.load(read_text(root / CONFIG_FILE))
127
+ return deep_merge(default_config(), raw if isinstance(raw, dict) else {})
128
+
129
+
130
+ def save_config(root: Path, config: dict[str, Any]) -> None:
131
+ merged = deep_merge(default_config(), config)
132
+ write_text(root / CONFIG_FILE, yaml_compat.dump(merged))
133
+
134
+
135
+ def resolved_steps(config: dict[str, Any]) -> dict[str, bool]:
136
+ mode = str(deep_get(config, "pipeline.mode", "advanced")).lower()
137
+ base = ADVANCED_STEPS if mode == "advanced" else SIMPLE_STEPS
138
+ steps = dict(base)
139
+ overrides = deep_get(config, "pipeline.steps", {}) or {}
140
+ for key, value in overrides.items():
141
+ if key in steps and isinstance(value, bool):
142
+ steps[key] = value
143
+ return steps
144
+
145
+
146
+ def provider_invocation(config: dict[str, Any]) -> dict[str, Any]:
147
+ name = str(deep_get(config, "provider.name", "claude")).lower()
148
+ profile = copy.deepcopy(PROVIDER_PROFILES.get(name, PROVIDER_PROFILES["claude"]))
149
+ command = deep_get(config, "provider.command", "") or profile["command"]
150
+ extra = deep_get(config, "provider.extra_args", []) or []
151
+ model = deep_get(config, "provider.model", "")
152
+ profile["command"] = command
153
+ profile["extra_args"] = list(extra)
154
+ profile["model"] = model
155
+ profile["name"] = name
156
+ return profile
157
+
158
+
159
+ def deep_get(data: dict[str, Any], dotted: str, default: Any = None) -> Any:
160
+ current: Any = data
161
+ for part in dotted.split("."):
162
+ if not isinstance(current, dict) or part not in current:
163
+ return default
164
+ current = current[part]
165
+ return current