codeframe-ai 0.9.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.
- codeframe/__init__.py +11 -0
- codeframe/__main__.py +20 -0
- codeframe/adapters/__init__.py +5 -0
- codeframe/adapters/e2b/__init__.py +13 -0
- codeframe/adapters/e2b/adapter.py +342 -0
- codeframe/adapters/e2b/budget.py +71 -0
- codeframe/adapters/e2b/credential_scanner.py +134 -0
- codeframe/adapters/llm/__init__.py +92 -0
- codeframe/adapters/llm/anthropic.py +414 -0
- codeframe/adapters/llm/base.py +444 -0
- codeframe/adapters/llm/mock.py +281 -0
- codeframe/adapters/llm/openai.py +483 -0
- codeframe/agents/__init__.py +8 -0
- codeframe/agents/dependency_resolver.py +714 -0
- codeframe/auth/__init__.py +16 -0
- codeframe/auth/api_key_router.py +238 -0
- codeframe/auth/api_keys.py +156 -0
- codeframe/auth/dependencies.py +358 -0
- codeframe/auth/manager.py +178 -0
- codeframe/auth/models.py +30 -0
- codeframe/auth/router.py +93 -0
- codeframe/auth/schemas.py +15 -0
- codeframe/auth/scopes.py +53 -0
- codeframe/cli/__init__.py +12 -0
- codeframe/cli/__main__.py +20 -0
- codeframe/cli/api_client.py +275 -0
- codeframe/cli/app.py +5688 -0
- codeframe/cli/auth.py +122 -0
- codeframe/cli/auth_commands.py +958 -0
- codeframe/cli/commands/__init__.py +5 -0
- codeframe/cli/config_commands.py +79 -0
- codeframe/cli/dashboard_commands.py +67 -0
- codeframe/cli/engines_commands.py +205 -0
- codeframe/cli/env_commands.py +409 -0
- codeframe/cli/helpers.py +56 -0
- codeframe/cli/hooks_commands.py +208 -0
- codeframe/cli/import_commands.py +129 -0
- codeframe/cli/pr_commands.py +549 -0
- codeframe/cli/proof_commands.py +415 -0
- codeframe/cli/stats_commands.py +311 -0
- codeframe/cli/telemetry_runtime.py +153 -0
- codeframe/cli/validators.py +123 -0
- codeframe/config/rate_limits.py +165 -0
- codeframe/core/__init__.py +15 -0
- codeframe/core/adapters/__init__.py +43 -0
- codeframe/core/adapters/agent_adapter.py +114 -0
- codeframe/core/adapters/builtin.py +326 -0
- codeframe/core/adapters/claude_code.py +62 -0
- codeframe/core/adapters/codex.py +393 -0
- codeframe/core/adapters/git_utils.py +40 -0
- codeframe/core/adapters/kilocode.py +126 -0
- codeframe/core/adapters/opencode.py +48 -0
- codeframe/core/adapters/streaming_chat.py +483 -0
- codeframe/core/adapters/subprocess_adapter.py +213 -0
- codeframe/core/adapters/verification_wrapper.py +269 -0
- codeframe/core/agent.py +2183 -0
- codeframe/core/agents_config.py +569 -0
- codeframe/core/api_key_service.py +211 -0
- codeframe/core/artifacts.py +428 -0
- codeframe/core/blocker_detection.py +218 -0
- codeframe/core/blockers.py +433 -0
- codeframe/core/checkpoints.py +481 -0
- codeframe/core/conductor.py +2255 -0
- codeframe/core/config.py +827 -0
- codeframe/core/config_watcher.py +268 -0
- codeframe/core/context.py +542 -0
- codeframe/core/context_packager.py +234 -0
- codeframe/core/credentials.py +735 -0
- codeframe/core/dependency_analyzer.py +229 -0
- codeframe/core/dependency_graph.py +290 -0
- codeframe/core/diagnostic_agent.py +712 -0
- codeframe/core/diagnostics.py +616 -0
- codeframe/core/editor.py +556 -0
- codeframe/core/engine_registry.py +256 -0
- codeframe/core/engine_stats.py +231 -0
- codeframe/core/environment.py +697 -0
- codeframe/core/events.py +375 -0
- codeframe/core/executor.py +1005 -0
- codeframe/core/fix_tracker.py +480 -0
- codeframe/core/gates.py +1322 -0
- codeframe/core/git.py +477 -0
- codeframe/core/github_connect_service.py +178 -0
- codeframe/core/github_integration_config.py +118 -0
- codeframe/core/github_issues_service.py +449 -0
- codeframe/core/hooks.py +184 -0
- codeframe/core/importers/__init__.py +1 -0
- codeframe/core/importers/ralph.py +540 -0
- codeframe/core/installer.py +650 -0
- codeframe/core/models.py +1026 -0
- codeframe/core/notifications_config.py +183 -0
- codeframe/core/planner.py +437 -0
- codeframe/core/prd.py +670 -0
- codeframe/core/prd_discovery.py +1118 -0
- codeframe/core/prd_stress_test.py +499 -0
- codeframe/core/progress.py +126 -0
- codeframe/core/proof/__init__.py +34 -0
- codeframe/core/proof/capture.py +79 -0
- codeframe/core/proof/evidence.py +56 -0
- codeframe/core/proof/ledger.py +574 -0
- codeframe/core/proof/models.py +162 -0
- codeframe/core/proof/obligations.py +103 -0
- codeframe/core/proof/runner.py +233 -0
- codeframe/core/proof/scope.py +81 -0
- codeframe/core/proof/stubs.py +156 -0
- codeframe/core/quick_fixes.py +558 -0
- codeframe/core/react_agent.py +1650 -0
- codeframe/core/reconciliation.py +183 -0
- codeframe/core/replay.py +788 -0
- codeframe/core/review.py +285 -0
- codeframe/core/runtime.py +1134 -0
- codeframe/core/sandbox/__init__.py +27 -0
- codeframe/core/sandbox/context.py +98 -0
- codeframe/core/sandbox/worktree.py +20 -0
- codeframe/core/schedule.py +396 -0
- codeframe/core/stall_detector.py +71 -0
- codeframe/core/stall_monitor.py +134 -0
- codeframe/core/state_machine.py +121 -0
- codeframe/core/streaming.py +502 -0
- codeframe/core/task_tree.py +400 -0
- codeframe/core/tasks.py +1022 -0
- codeframe/core/telemetry.py +232 -0
- codeframe/core/templates.py +221 -0
- codeframe/core/tools.py +942 -0
- codeframe/core/workspace.py +887 -0
- codeframe/core/worktrees.py +276 -0
- codeframe/git/__init__.py +5 -0
- codeframe/git/github_integration.py +505 -0
- codeframe/lib/__init__.py +0 -0
- codeframe/lib/audit_logger.py +248 -0
- codeframe/lib/metrics_tracker.py +800 -0
- codeframe/lib/quality/__init__.py +7 -0
- codeframe/lib/quality/complexity_analyzer.py +316 -0
- codeframe/lib/quality/owasp_patterns.py +284 -0
- codeframe/lib/quality/security_scanner.py +250 -0
- codeframe/lib/rate_limiter.py +312 -0
- codeframe/notifications/__init__.py +0 -0
- codeframe/notifications/webhook.py +380 -0
- codeframe/planning/__init__.py +30 -0
- codeframe/planning/issue_generator.py +219 -0
- codeframe/planning/prd_template_functions.py +137 -0
- codeframe/planning/prd_templates.py +975 -0
- codeframe/planning/task_scheduler.py +511 -0
- codeframe/planning/task_templates.py +533 -0
- codeframe/platform_store/__init__.py +5 -0
- codeframe/platform_store/database.py +277 -0
- codeframe/platform_store/repositories/__init__.py +24 -0
- codeframe/platform_store/repositories/api_key_repository.py +245 -0
- codeframe/platform_store/repositories/audit_repository.py +67 -0
- codeframe/platform_store/repositories/base.py +295 -0
- codeframe/platform_store/repositories/interactive_sessions.py +165 -0
- codeframe/platform_store/repositories/token_repository.py +598 -0
- codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
- codeframe/platform_store/schema_manager.py +321 -0
- codeframe/templates/AGENTS.md.default +94 -0
- codeframe/tui/__init__.py +5 -0
- codeframe/tui/app.py +256 -0
- codeframe/tui/data_service.py +103 -0
- codeframe/ui/__init__.py +0 -0
- codeframe/ui/dependencies.py +103 -0
- codeframe/ui/models.py +999 -0
- codeframe/ui/response_models.py +201 -0
- codeframe/ui/routers/__init__.py +5 -0
- codeframe/ui/routers/_helpers.py +29 -0
- codeframe/ui/routers/batches_v2.py +315 -0
- codeframe/ui/routers/blockers_v2.py +320 -0
- codeframe/ui/routers/checkpoints_v2.py +310 -0
- codeframe/ui/routers/costs_v2.py +322 -0
- codeframe/ui/routers/diagnose_v2.py +225 -0
- codeframe/ui/routers/discovery_v2.py +417 -0
- codeframe/ui/routers/environment_v2.py +284 -0
- codeframe/ui/routers/events_v2.py +75 -0
- codeframe/ui/routers/gates_v2.py +166 -0
- codeframe/ui/routers/git_v2.py +284 -0
- codeframe/ui/routers/github_integrations_v2.py +532 -0
- codeframe/ui/routers/interactive_sessions_v2.py +238 -0
- codeframe/ui/routers/pr_v2.py +709 -0
- codeframe/ui/routers/prd_v2.py +695 -0
- codeframe/ui/routers/proof_v2.py +755 -0
- codeframe/ui/routers/review_v2.py +360 -0
- codeframe/ui/routers/schedule_v2.py +214 -0
- codeframe/ui/routers/session_chat_ws.py +354 -0
- codeframe/ui/routers/settings_v2.py +562 -0
- codeframe/ui/routers/streaming_v2.py +155 -0
- codeframe/ui/routers/tasks_v2.py +1098 -0
- codeframe/ui/routers/templates_v2.py +232 -0
- codeframe/ui/routers/terminal_ws.py +267 -0
- codeframe/ui/routers/workspace_v2.py +527 -0
- codeframe/ui/server.py +568 -0
- codeframe/ui/shared.py +241 -0
- codeframe/workspace/__init__.py +5 -0
- codeframe/workspace/manager.py +249 -0
- codeframe_ai-0.9.0.dist-info/METADATA +517 -0
- codeframe_ai-0.9.0.dist-info/RECORD +197 -0
- codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
- codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
- codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
- codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Per-workspace outbound webhook notification config (issue #560).
|
|
2
|
+
|
|
3
|
+
Stored alongside other workspace UI settings under ``.codeframe/`` as a JSON
|
|
4
|
+
file. Headless — no FastAPI or HTTP imports.
|
|
5
|
+
|
|
6
|
+
Schema (``.codeframe/notifications_config.json``):
|
|
7
|
+
|
|
8
|
+
{
|
|
9
|
+
"webhook_url": "https://hooks.example.com/...", // or null
|
|
10
|
+
"webhook_enabled": true
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
Defense-in-depth: ``is_webhook_active`` re-validates the URL scheme/host
|
|
14
|
+
because the JSON file can be hand-edited or migrated from an older
|
|
15
|
+
deployment that didn't enforce validation at write time. The router PUT
|
|
16
|
+
endpoint validates too — never trust just one layer.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import tempfile
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Optional, TypedDict
|
|
27
|
+
|
|
28
|
+
from codeframe.core.workspace import Workspace
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
NOTIFICATIONS_CONFIG_FILENAME = "notifications_config.json"
|
|
33
|
+
|
|
34
|
+
# Intentionally duplicated with codeframe/ui/routers/settings_v2.py's
|
|
35
|
+
# ``_ALLOWED_WEBHOOK_SCHEMES``: core cannot import from the UI layer
|
|
36
|
+
# (architecture rule #1 — core must be headless). Keep both values in
|
|
37
|
+
# sync if extended.
|
|
38
|
+
_ALLOWED_SCHEMES = frozenset({"http", "https"})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class NotificationsConfig(TypedDict):
|
|
42
|
+
webhook_url: Optional[str]
|
|
43
|
+
webhook_enabled: bool
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_DEFAULT: NotificationsConfig = {"webhook_url": None, "webhook_enabled": False}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _config_path(workspace: Workspace) -> Path:
|
|
50
|
+
return workspace.state_dir / NOTIFICATIONS_CONFIG_FILENAME
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _is_safe_webhook_url(url: str) -> bool:
|
|
54
|
+
"""Defence-in-depth: scheme/host check on a stored URL before we POST.
|
|
55
|
+
|
|
56
|
+
The router PUT validates too, but a hand-edited or pre-migration JSON
|
|
57
|
+
file could carry a ``file://`` or schemeless URL. Used by
|
|
58
|
+
``is_webhook_active`` to fail-safe to ``None``.
|
|
59
|
+
|
|
60
|
+
TODO: This does NOT block RFC-1918 (10/8, 172.16/12, 192.168/16) or
|
|
61
|
+
loopback (127/8) addresses. For a self-hosted single-user tool that is
|
|
62
|
+
intentional — users want to point at local receivers. If CodeFRAME ever
|
|
63
|
+
runs as a shared / multi-tenant service, add a socket-level check here
|
|
64
|
+
that resolves the host and rejects private/loopback ranges.
|
|
65
|
+
"""
|
|
66
|
+
from urllib.parse import urlparse
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
parsed = urlparse(url)
|
|
70
|
+
except (TypeError, ValueError):
|
|
71
|
+
return False
|
|
72
|
+
return parsed.scheme.lower() in _ALLOWED_SCHEMES and bool(parsed.netloc)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def load_notifications_config(workspace: Workspace) -> NotificationsConfig:
|
|
76
|
+
"""Read notifications config, returning defaults on missing/corrupt file.
|
|
77
|
+
|
|
78
|
+
Never raises — a broken config should not break the triggering operation.
|
|
79
|
+
Non-object JSON (``[]``, ``null``, a bare integer) is treated as corrupt
|
|
80
|
+
and falls back to defaults rather than raising ``AttributeError`` from
|
|
81
|
+
a downstream ``.get()`` call.
|
|
82
|
+
"""
|
|
83
|
+
path = _config_path(workspace)
|
|
84
|
+
if not path.exists():
|
|
85
|
+
return dict(_DEFAULT) # type: ignore[return-value]
|
|
86
|
+
try:
|
|
87
|
+
data = json.loads(path.read_text())
|
|
88
|
+
if not isinstance(data, dict):
|
|
89
|
+
raise ValueError(
|
|
90
|
+
f"Expected JSON object, got {type(data).__name__}"
|
|
91
|
+
)
|
|
92
|
+
return {
|
|
93
|
+
"webhook_url": data.get("webhook_url") or None,
|
|
94
|
+
"webhook_enabled": bool(data.get("webhook_enabled", False)),
|
|
95
|
+
}
|
|
96
|
+
except (OSError, json.JSONDecodeError, ValueError) as e:
|
|
97
|
+
logger.warning(
|
|
98
|
+
"Invalid notifications_config.json — falling back to defaults: %s", e
|
|
99
|
+
)
|
|
100
|
+
return dict(_DEFAULT) # type: ignore[return-value]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def save_notifications_config(
|
|
104
|
+
workspace: Workspace, config: NotificationsConfig
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Atomically persist notifications config to disk."""
|
|
107
|
+
path = _config_path(workspace)
|
|
108
|
+
payload = {
|
|
109
|
+
"webhook_url": config.get("webhook_url") or None,
|
|
110
|
+
"webhook_enabled": bool(config.get("webhook_enabled", False)),
|
|
111
|
+
}
|
|
112
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
114
|
+
prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
|
|
115
|
+
)
|
|
116
|
+
try:
|
|
117
|
+
with os.fdopen(fd, "w") as f:
|
|
118
|
+
f.write(json.dumps(payload, indent=2))
|
|
119
|
+
os.replace(tmp_name, path)
|
|
120
|
+
except Exception:
|
|
121
|
+
try:
|
|
122
|
+
os.unlink(tmp_name)
|
|
123
|
+
except OSError:
|
|
124
|
+
pass
|
|
125
|
+
raise
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def is_webhook_active(workspace: Workspace) -> Optional[str]:
|
|
129
|
+
"""Return the webhook URL if URL is set, enabled flag is on, AND the URL
|
|
130
|
+
passes basic safety checks (``http(s)`` scheme + non-empty host).
|
|
131
|
+
|
|
132
|
+
Returns ``None`` otherwise — callers should short-circuit on ``None`` to
|
|
133
|
+
avoid instantiating the webhook service for nothing. The safety check is
|
|
134
|
+
intentionally redundant with the PUT-endpoint validation: it protects
|
|
135
|
+
against hand-edited config files and pre-migration data.
|
|
136
|
+
"""
|
|
137
|
+
cfg = load_notifications_config(workspace)
|
|
138
|
+
url = (cfg["webhook_url"] or "").strip()
|
|
139
|
+
if not url or not cfg["webhook_enabled"]:
|
|
140
|
+
return None
|
|
141
|
+
if not _is_safe_webhook_url(url):
|
|
142
|
+
logger.warning(
|
|
143
|
+
"Refusing to dispatch webhook to unsafe URL: %s",
|
|
144
|
+
_redact_url_for_log(url),
|
|
145
|
+
)
|
|
146
|
+
return None
|
|
147
|
+
return url
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _redact_url_for_log(url: str) -> str:
|
|
151
|
+
"""Return a logging-safe representation of a webhook URL.
|
|
152
|
+
|
|
153
|
+
Slack/Discord/GitHub-style webhook URLs commonly embed secrets in:
|
|
154
|
+
|
|
155
|
+
* the path or query (Slack's ``T*/B*/...`` token, signed Zapier hooks)
|
|
156
|
+
* basic-auth credentials in ``user:password@host`` form
|
|
157
|
+
|
|
158
|
+
``parsed.netloc`` preserves the userinfo segment, so we use
|
|
159
|
+
``parsed.hostname`` (which strips auth and port) and re-attach the
|
|
160
|
+
port explicitly. Returns ``scheme://host[:port]`` when parsable, else
|
|
161
|
+
``"<unparseable>"``.
|
|
162
|
+
"""
|
|
163
|
+
from urllib.parse import urlparse
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
parsed = urlparse(url)
|
|
167
|
+
except (TypeError, ValueError):
|
|
168
|
+
return "<unparseable>"
|
|
169
|
+
if not parsed.scheme or not parsed.hostname:
|
|
170
|
+
return "<unparseable>"
|
|
171
|
+
host = parsed.hostname
|
|
172
|
+
# ``parsed.port`` raises ValueError for malformed ports (e.g.
|
|
173
|
+
# ``file://host:abc/x``). Without this guard the "fail-safe" branch
|
|
174
|
+
# in ``is_webhook_active`` could end up raising instead of returning
|
|
175
|
+
# None, which would bubble through every dispatch site's broad
|
|
176
|
+
# ``except Exception``.
|
|
177
|
+
try:
|
|
178
|
+
port = parsed.port
|
|
179
|
+
except ValueError:
|
|
180
|
+
return "<unparseable>"
|
|
181
|
+
if port is not None:
|
|
182
|
+
host = f"{host}:{port}"
|
|
183
|
+
return f"{parsed.scheme}://{host}"
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
"""Agent planning module for CodeFRAME v2.
|
|
2
|
+
|
|
3
|
+
Transforms task context into an executable implementation plan.
|
|
4
|
+
Uses LLM to analyze requirements and decompose into actionable steps.
|
|
5
|
+
|
|
6
|
+
This module is headless - no FastAPI or HTTP dependencies.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from enum import Enum
|
|
13
|
+
|
|
14
|
+
from codeframe.core.context import TaskContext
|
|
15
|
+
from codeframe.adapters.llm import LLMProvider, Purpose
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StepType(str, Enum):
|
|
19
|
+
"""Type of implementation step."""
|
|
20
|
+
|
|
21
|
+
FILE_CREATE = "file_create" # Create a new file
|
|
22
|
+
FILE_EDIT = "file_edit" # Edit an existing file
|
|
23
|
+
FILE_DELETE = "file_delete" # Delete a file
|
|
24
|
+
SHELL_COMMAND = "shell_command" # Run a shell command
|
|
25
|
+
VERIFICATION = "verification" # Run tests/linting
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Complexity(str, Enum):
|
|
29
|
+
"""Estimated task complexity."""
|
|
30
|
+
|
|
31
|
+
LOW = "low" # Simple change, < 50 lines
|
|
32
|
+
MEDIUM = "medium" # Moderate change, 50-200 lines
|
|
33
|
+
HIGH = "high" # Complex change, > 200 lines or architectural
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class PlanStep:
|
|
38
|
+
"""A single step in an implementation plan.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
index: Step number (1-based)
|
|
42
|
+
type: Type of operation
|
|
43
|
+
description: What this step accomplishes
|
|
44
|
+
target: File path or command target
|
|
45
|
+
details: Additional details (e.g., what to change)
|
|
46
|
+
depends_on: Indices of steps this depends on
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
index: int
|
|
50
|
+
type: StepType
|
|
51
|
+
description: str
|
|
52
|
+
target: str
|
|
53
|
+
details: str = ""
|
|
54
|
+
depends_on: list[int] = field(default_factory=list)
|
|
55
|
+
|
|
56
|
+
def to_dict(self) -> dict:
|
|
57
|
+
"""Convert to dictionary."""
|
|
58
|
+
return {
|
|
59
|
+
"index": self.index,
|
|
60
|
+
"type": self.type.value,
|
|
61
|
+
"description": self.description,
|
|
62
|
+
"target": self.target,
|
|
63
|
+
"details": self.details,
|
|
64
|
+
"depends_on": self.depends_on,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class ImplementationPlan:
|
|
70
|
+
"""Complete plan for implementing a task.
|
|
71
|
+
|
|
72
|
+
Attributes:
|
|
73
|
+
task_id: ID of the task this plan is for
|
|
74
|
+
summary: Brief summary of the approach
|
|
75
|
+
steps: Ordered list of implementation steps
|
|
76
|
+
files_to_create: New files that will be created
|
|
77
|
+
files_to_modify: Existing files that will be changed
|
|
78
|
+
estimated_complexity: Overall complexity estimate
|
|
79
|
+
considerations: Important notes or warnings
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
task_id: str
|
|
83
|
+
summary: str
|
|
84
|
+
steps: list[PlanStep]
|
|
85
|
+
files_to_create: list[str] = field(default_factory=list)
|
|
86
|
+
files_to_modify: list[str] = field(default_factory=list)
|
|
87
|
+
estimated_complexity: Complexity = Complexity.MEDIUM
|
|
88
|
+
considerations: list[str] = field(default_factory=list)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def total_steps(self) -> int:
|
|
92
|
+
"""Total number of steps."""
|
|
93
|
+
return len(self.steps)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def file_operations(self) -> list[PlanStep]:
|
|
97
|
+
"""Steps that involve file operations."""
|
|
98
|
+
return [
|
|
99
|
+
s for s in self.steps
|
|
100
|
+
if s.type in {StepType.FILE_CREATE, StepType.FILE_EDIT, StepType.FILE_DELETE}
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def commands(self) -> list[PlanStep]:
|
|
105
|
+
"""Steps that involve shell commands."""
|
|
106
|
+
return [s for s in self.steps if s.type == StepType.SHELL_COMMAND]
|
|
107
|
+
|
|
108
|
+
def to_dict(self) -> dict:
|
|
109
|
+
"""Convert to dictionary for serialization."""
|
|
110
|
+
return {
|
|
111
|
+
"task_id": self.task_id,
|
|
112
|
+
"summary": self.summary,
|
|
113
|
+
"steps": [s.to_dict() for s in self.steps],
|
|
114
|
+
"files_to_create": self.files_to_create,
|
|
115
|
+
"files_to_modify": self.files_to_modify,
|
|
116
|
+
"estimated_complexity": self.estimated_complexity.value,
|
|
117
|
+
"considerations": self.considerations,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
def to_markdown(self) -> str:
|
|
121
|
+
"""Convert to markdown format for display."""
|
|
122
|
+
lines = [
|
|
123
|
+
"# Implementation Plan",
|
|
124
|
+
"",
|
|
125
|
+
f"**Task:** {self.task_id}",
|
|
126
|
+
f"**Complexity:** {self.estimated_complexity.value}",
|
|
127
|
+
"",
|
|
128
|
+
"## Summary",
|
|
129
|
+
f"{self.summary}",
|
|
130
|
+
"",
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
if self.files_to_create:
|
|
134
|
+
lines.append("## Files to Create")
|
|
135
|
+
for f in self.files_to_create:
|
|
136
|
+
lines.append(f"- `{f}`")
|
|
137
|
+
lines.append("")
|
|
138
|
+
|
|
139
|
+
if self.files_to_modify:
|
|
140
|
+
lines.append("## Files to Modify")
|
|
141
|
+
for f in self.files_to_modify:
|
|
142
|
+
lines.append(f"- `{f}`")
|
|
143
|
+
lines.append("")
|
|
144
|
+
|
|
145
|
+
lines.append("## Steps")
|
|
146
|
+
for step in self.steps:
|
|
147
|
+
deps = f" (depends on: {step.depends_on})" if step.depends_on else ""
|
|
148
|
+
lines.append(f"{step.index}. **[{step.type.value}]** {step.description}{deps}")
|
|
149
|
+
lines.append(f" - Target: `{step.target}`")
|
|
150
|
+
if step.details:
|
|
151
|
+
lines.append(f" - Details: {step.details[:200]}")
|
|
152
|
+
lines.append("")
|
|
153
|
+
|
|
154
|
+
if self.considerations:
|
|
155
|
+
lines.append("## Considerations")
|
|
156
|
+
for c in self.considerations:
|
|
157
|
+
lines.append(f"- {c}")
|
|
158
|
+
|
|
159
|
+
return "\n".join(lines)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
PLANNING_SYSTEM_PROMPT = """You are an expert software implementation planner. Your job is to analyze a task and its context, then create a detailed implementation plan.
|
|
163
|
+
|
|
164
|
+
## CRITICAL: Decision-Making Autonomy
|
|
165
|
+
|
|
166
|
+
You are an agent - keep going until the task is completely resolved. Make tactical decisions independently:
|
|
167
|
+
|
|
168
|
+
ALWAYS decide autonomously (without asking):
|
|
169
|
+
- Choose between equivalent implementation approaches
|
|
170
|
+
- Decide file organization and naming conventions
|
|
171
|
+
- Select library versions (prefer latest stable)
|
|
172
|
+
- Handle existing files (overwrite, merge, or extend as appropriate)
|
|
173
|
+
- Choose test frameworks and configurations
|
|
174
|
+
- Make code style decisions following existing patterns
|
|
175
|
+
- Install dependencies using the project's package manager
|
|
176
|
+
- Create directories and files as needed
|
|
177
|
+
- Fix linting errors automatically
|
|
178
|
+
|
|
179
|
+
ONLY flag as a blocker when:
|
|
180
|
+
- Requirements genuinely conflict or are underspecified
|
|
181
|
+
- Security policies need clarification
|
|
182
|
+
- Business logic requires domain expertise the context doesn't provide
|
|
183
|
+
- Access credentials are missing
|
|
184
|
+
|
|
185
|
+
NEVER stop to ask about:
|
|
186
|
+
- Tooling choices (use project preferences or best practices)
|
|
187
|
+
- File handling (existing files should be updated, not blocked on)
|
|
188
|
+
- Configuration details (use sensible defaults)
|
|
189
|
+
- Minor implementation decisions
|
|
190
|
+
- "Which approach should I use?" - pick the best one and proceed
|
|
191
|
+
|
|
192
|
+
When multiple valid options exist, choose the simpler approach. Trust your expertise.
|
|
193
|
+
|
|
194
|
+
## Output Format
|
|
195
|
+
|
|
196
|
+
You must return a valid JSON object with this structure:
|
|
197
|
+
{
|
|
198
|
+
"summary": "Brief description of the implementation approach",
|
|
199
|
+
"steps": [
|
|
200
|
+
{
|
|
201
|
+
"index": 1,
|
|
202
|
+
"type": "file_create|file_edit|file_delete|shell_command|verification",
|
|
203
|
+
"description": "What this step accomplishes",
|
|
204
|
+
"target": "see target rules below",
|
|
205
|
+
"details": "Specific changes or command arguments",
|
|
206
|
+
"depends_on": []
|
|
207
|
+
}
|
|
208
|
+
],
|
|
209
|
+
"files_to_create": ["path/to/new/file.py"],
|
|
210
|
+
"files_to_modify": ["path/to/existing/file.py"],
|
|
211
|
+
"estimated_complexity": "low|medium|high",
|
|
212
|
+
"considerations": ["Important note or warning"]
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
TARGET RULES (critical - follow exactly):
|
|
216
|
+
- file_create: target = file path to create (e.g., "src/utils.py")
|
|
217
|
+
- file_edit: target = file path to edit (e.g., "main.py")
|
|
218
|
+
- file_delete: target = file path to delete
|
|
219
|
+
- shell_command: target = the actual command to run (e.g., "python main.py --help", "pytest tests/")
|
|
220
|
+
- verification: target = the actual command to run (e.g., "python script.py --help", "pytest -v")
|
|
221
|
+
DO NOT put "shell_command" as the target. Put the actual command string.
|
|
222
|
+
|
|
223
|
+
## Planning Guidelines
|
|
224
|
+
|
|
225
|
+
1. Break work into small, focused steps (each step should do ONE thing)
|
|
226
|
+
2. Order steps by dependency (later steps can depend on earlier ones)
|
|
227
|
+
3. Include verification steps after significant changes
|
|
228
|
+
4. Be specific about what files to modify and what changes to make
|
|
229
|
+
5. Consider edge cases and potential issues
|
|
230
|
+
6. Keep the plan achievable - don't over-engineer
|
|
231
|
+
7. IMPORTANT: Before choosing file_create, verify the file does not already exist in the repository structure. If it exists, use file_edit instead. Never use file_create for files listed in the Repository Structure section.
|
|
232
|
+
8. Run tests after implementation to verify correctness
|
|
233
|
+
|
|
234
|
+
Return ONLY the JSON object, no additional text."""
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class Planner:
|
|
238
|
+
"""Creates implementation plans from task context.
|
|
239
|
+
|
|
240
|
+
Uses LLM to analyze requirements and generate structured plans.
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
def __init__(self, llm_provider: LLMProvider):
|
|
244
|
+
"""Initialize the planner.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
llm_provider: LLM provider for generating plans
|
|
248
|
+
"""
|
|
249
|
+
self.llm = llm_provider
|
|
250
|
+
|
|
251
|
+
def create_plan(self, context: TaskContext) -> ImplementationPlan:
|
|
252
|
+
"""Create an implementation plan from task context.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
context: Loaded task context
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
ImplementationPlan with steps to execute
|
|
259
|
+
|
|
260
|
+
Raises:
|
|
261
|
+
ValueError: If plan generation fails
|
|
262
|
+
"""
|
|
263
|
+
# Build the planning prompt
|
|
264
|
+
prompt = self._build_prompt(context)
|
|
265
|
+
|
|
266
|
+
# Call LLM with planning purpose (uses stronger model)
|
|
267
|
+
response = self.llm.complete(
|
|
268
|
+
messages=[{"role": "user", "content": prompt}],
|
|
269
|
+
purpose=Purpose.PLANNING,
|
|
270
|
+
system=PLANNING_SYSTEM_PROMPT,
|
|
271
|
+
max_tokens=4096,
|
|
272
|
+
temperature=0.0,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
# Parse the response into a plan
|
|
276
|
+
return self._parse_plan(response.content, context.task.id)
|
|
277
|
+
|
|
278
|
+
def _build_prompt(self, context: TaskContext) -> str:
|
|
279
|
+
"""Build the planning prompt from context.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
context: Task context
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
Formatted prompt string
|
|
286
|
+
"""
|
|
287
|
+
sections = []
|
|
288
|
+
|
|
289
|
+
# Task information
|
|
290
|
+
sections.append("## Task to Implement")
|
|
291
|
+
sections.append(f"Title: {context.task.title}")
|
|
292
|
+
if context.task.description:
|
|
293
|
+
sections.append(f"Description: {context.task.description}")
|
|
294
|
+
sections.append("")
|
|
295
|
+
|
|
296
|
+
# Project preferences from AGENTS.md/CLAUDE.md
|
|
297
|
+
if context.preferences and context.preferences.has_preferences():
|
|
298
|
+
pref_section = context.preferences.to_prompt_section()
|
|
299
|
+
if pref_section:
|
|
300
|
+
sections.append(pref_section)
|
|
301
|
+
sections.append("")
|
|
302
|
+
|
|
303
|
+
# Tech stack configuration
|
|
304
|
+
if context.has_tech_stack:
|
|
305
|
+
sections.append("## Project Tech Stack")
|
|
306
|
+
sections.append(f"**Technology:** {context.tech_stack}")
|
|
307
|
+
sections.append("")
|
|
308
|
+
sections.append("Use appropriate commands and patterns for this technology stack.")
|
|
309
|
+
sections.append("When generating shell_command steps, use the correct tools for this stack.")
|
|
310
|
+
sections.append("")
|
|
311
|
+
|
|
312
|
+
# PRD if available
|
|
313
|
+
if context.prd:
|
|
314
|
+
sections.append("## Product Requirements")
|
|
315
|
+
# Limit PRD content to avoid overwhelming the plan
|
|
316
|
+
prd_content = context.prd.content[:5000]
|
|
317
|
+
sections.append(prd_content)
|
|
318
|
+
sections.append("")
|
|
319
|
+
|
|
320
|
+
# Previous clarifications
|
|
321
|
+
if context.answered_blockers:
|
|
322
|
+
sections.append("## Clarifications")
|
|
323
|
+
for b in context.answered_blockers:
|
|
324
|
+
sections.append(f"Q: {b.question}")
|
|
325
|
+
sections.append(f"A: {b.answer}")
|
|
326
|
+
sections.append("")
|
|
327
|
+
|
|
328
|
+
# Repository structure
|
|
329
|
+
if context.file_tree:
|
|
330
|
+
sections.append("## Repository Structure")
|
|
331
|
+
sections.append(f"Total files: {len(context.file_tree)}")
|
|
332
|
+
# Show top relevant files
|
|
333
|
+
for f in context.relevant_files[:20]:
|
|
334
|
+
sections.append(f" - {f.path}")
|
|
335
|
+
sections.append("")
|
|
336
|
+
|
|
337
|
+
# Existing files warning for planner
|
|
338
|
+
if context.file_tree:
|
|
339
|
+
sections.append("## Existing Files Warning")
|
|
340
|
+
sections.append(
|
|
341
|
+
"The following files already exist in the workspace. "
|
|
342
|
+
"Use file_edit (NOT file_create) for these:"
|
|
343
|
+
)
|
|
344
|
+
for f_info in context.file_tree[:50]:
|
|
345
|
+
sections.append(f" - {f_info.path}")
|
|
346
|
+
sections.append("")
|
|
347
|
+
|
|
348
|
+
# Loaded file contents
|
|
349
|
+
if context.loaded_files:
|
|
350
|
+
sections.append("## Relevant Source Files")
|
|
351
|
+
for f in context.loaded_files[:5]: # Limit to top 5
|
|
352
|
+
sections.append(f"### {f.path}")
|
|
353
|
+
sections.append("```")
|
|
354
|
+
# Truncate large files in prompt
|
|
355
|
+
content = f.content[:3000]
|
|
356
|
+
sections.append(content)
|
|
357
|
+
if len(f.content) > 3000:
|
|
358
|
+
sections.append("... (truncated)")
|
|
359
|
+
sections.append("```")
|
|
360
|
+
sections.append("")
|
|
361
|
+
|
|
362
|
+
sections.append("## Instructions")
|
|
363
|
+
sections.append("Create an implementation plan for this task.")
|
|
364
|
+
sections.append("Return a JSON object with the plan structure.")
|
|
365
|
+
|
|
366
|
+
return "\n".join(sections)
|
|
367
|
+
|
|
368
|
+
def _parse_plan(self, response_text: str, task_id: str) -> ImplementationPlan:
|
|
369
|
+
"""Parse LLM response into an ImplementationPlan.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
response_text: Raw LLM response
|
|
373
|
+
task_id: Task ID for the plan
|
|
374
|
+
|
|
375
|
+
Returns:
|
|
376
|
+
Parsed ImplementationPlan
|
|
377
|
+
|
|
378
|
+
Raises:
|
|
379
|
+
ValueError: If parsing fails
|
|
380
|
+
"""
|
|
381
|
+
# Try to extract JSON from response
|
|
382
|
+
try:
|
|
383
|
+
# Find JSON object in response
|
|
384
|
+
json_match = re.search(r"\{[\s\S]*\}", response_text)
|
|
385
|
+
if not json_match:
|
|
386
|
+
raise ValueError("No JSON object found in response")
|
|
387
|
+
|
|
388
|
+
data = json.loads(json_match.group())
|
|
389
|
+
except json.JSONDecodeError as e:
|
|
390
|
+
raise ValueError(f"Failed to parse plan JSON: {e}")
|
|
391
|
+
|
|
392
|
+
# Build plan from parsed data
|
|
393
|
+
steps = []
|
|
394
|
+
for step_data in data.get("steps", []):
|
|
395
|
+
step_type = self._parse_step_type(step_data.get("type", "file_edit"))
|
|
396
|
+
steps.append(PlanStep(
|
|
397
|
+
index=step_data.get("index", len(steps) + 1),
|
|
398
|
+
type=step_type,
|
|
399
|
+
description=step_data.get("description", ""),
|
|
400
|
+
target=step_data.get("target", ""),
|
|
401
|
+
details=step_data.get("details", ""),
|
|
402
|
+
depends_on=step_data.get("depends_on", []),
|
|
403
|
+
))
|
|
404
|
+
|
|
405
|
+
complexity = self._parse_complexity(
|
|
406
|
+
data.get("estimated_complexity", "medium")
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
return ImplementationPlan(
|
|
410
|
+
task_id=task_id,
|
|
411
|
+
summary=data.get("summary", "No summary provided"),
|
|
412
|
+
steps=steps,
|
|
413
|
+
files_to_create=data.get("files_to_create", []),
|
|
414
|
+
files_to_modify=data.get("files_to_modify", []),
|
|
415
|
+
estimated_complexity=complexity,
|
|
416
|
+
considerations=data.get("considerations", []),
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
def _parse_step_type(self, type_str: str) -> StepType:
|
|
420
|
+
"""Parse step type string to enum."""
|
|
421
|
+
type_map = {
|
|
422
|
+
"file_create": StepType.FILE_CREATE,
|
|
423
|
+
"file_edit": StepType.FILE_EDIT,
|
|
424
|
+
"file_delete": StepType.FILE_DELETE,
|
|
425
|
+
"shell_command": StepType.SHELL_COMMAND,
|
|
426
|
+
"verification": StepType.VERIFICATION,
|
|
427
|
+
}
|
|
428
|
+
return type_map.get(type_str.lower(), StepType.FILE_EDIT)
|
|
429
|
+
|
|
430
|
+
def _parse_complexity(self, complexity_str: str) -> Complexity:
|
|
431
|
+
"""Parse complexity string to enum."""
|
|
432
|
+
complexity_map = {
|
|
433
|
+
"low": Complexity.LOW,
|
|
434
|
+
"medium": Complexity.MEDIUM,
|
|
435
|
+
"high": Complexity.HIGH,
|
|
436
|
+
}
|
|
437
|
+
return complexity_map.get(complexity_str.lower(), Complexity.MEDIUM)
|