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,540 @@
|
|
|
1
|
+
"""ralph-claude-code project importer (issue #615).
|
|
2
|
+
|
|
3
|
+
Reads a ralph project directory and maps it onto CodeFRAME concepts:
|
|
4
|
+
|
|
5
|
+
- ``.ralph/fix_plan.md`` -> tasks (items under optional sections -> BACKLOG)
|
|
6
|
+
- ``.ralph/PROMPT.md`` -> PRD seed content
|
|
7
|
+
- ``.ralph/specs/*.md`` -> PRD seed content (appended with attribution)
|
|
8
|
+
- ``.ralph/AGENT.md`` -> AGENTS.md "Commands" section
|
|
9
|
+
- ``.ralphrc`` -> config hints (OPTIONAL_SECTIONS, ALLOWED_TOOLS)
|
|
10
|
+
|
|
11
|
+
Ralph runtime state files (``.ralph/status.json``, ``.ralph/.call_count``,
|
|
12
|
+
logs, ...) are never read; they are only listed in the import report as
|
|
13
|
+
ignored.
|
|
14
|
+
|
|
15
|
+
This module is headless - no FastAPI or UI imports.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import re
|
|
20
|
+
import sqlite3
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
from codeframe.core.state_machine import TaskStatus
|
|
26
|
+
|
|
27
|
+
# Section headings in fix_plan.md whose unchecked items do not block ralph's
|
|
28
|
+
# exit; they import as BACKLOG instead of READY. Overridable per project via
|
|
29
|
+
# OPTIONAL_SECTIONS in .ralphrc (comma-separated).
|
|
30
|
+
DEFAULT_OPTIONAL_SECTIONS = [
|
|
31
|
+
"Optional",
|
|
32
|
+
"Future",
|
|
33
|
+
"Nice to Have",
|
|
34
|
+
"Backlog",
|
|
35
|
+
"Later",
|
|
36
|
+
"Someday",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
# Files inside .ralph/ that the importer reads; everything else is runtime
|
|
40
|
+
# state and is reported as ignored.
|
|
41
|
+
_RALPH_SOURCE_ENTRIES = {"fix_plan.md", "PROMPT.md", "AGENT.md", "specs"}
|
|
42
|
+
|
|
43
|
+
_KEY_VALUE_RE = re.compile(r"^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
|
|
44
|
+
# ${VAR} and ${VAR:-default} forms found in generated .ralphrc files
|
|
45
|
+
_SHELL_EXPANSION_RE = re.compile(r"\$\{[A-Za-z_][A-Za-z0-9_]*(?::-([^}]*))?\}")
|
|
46
|
+
_HEADING_RE = re.compile(r"^(#+)\s+(.+?)\s*$")
|
|
47
|
+
_CHECKBOX_RE = re.compile(r"^\s*[-*]\s*\[([ xX])\]\s+(.+?)\s*$")
|
|
48
|
+
|
|
49
|
+
# AGENT.md heading keywords -> AGENTS.md command keys. Ordered: first match
|
|
50
|
+
# wins ("Running Tests" must map to test, not dev, despite containing "run").
|
|
51
|
+
_COMMAND_SECTION_KEYS = [
|
|
52
|
+
("test", ("test",)),
|
|
53
|
+
("build", ("build",)),
|
|
54
|
+
("install", ("setup", "install")),
|
|
55
|
+
("dev", ("server", "dev", "run")),
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class RalphProjectNotFoundError(Exception):
|
|
60
|
+
"""Raised when the given path does not contain an importable ralph project."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class FixPlanItem:
|
|
65
|
+
"""One checkbox item from .ralph/fix_plan.md."""
|
|
66
|
+
|
|
67
|
+
title: str
|
|
68
|
+
section: str
|
|
69
|
+
checked: bool
|
|
70
|
+
line: int
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class RalphProject:
|
|
75
|
+
"""Parsed intermediate representation of a ralph project directory."""
|
|
76
|
+
|
|
77
|
+
root: Path
|
|
78
|
+
ralphrc: dict[str, str]
|
|
79
|
+
fix_plan_items: list[FixPlanItem]
|
|
80
|
+
prompt: Optional[str]
|
|
81
|
+
agent_md: Optional[str]
|
|
82
|
+
specs: list[tuple[str, str]]
|
|
83
|
+
state_files_ignored: list[str]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class RalphImportReport:
|
|
88
|
+
"""Outcome (or dry-run preview) of importing a ralph project."""
|
|
89
|
+
|
|
90
|
+
workspace_path: Path
|
|
91
|
+
dry_run: bool
|
|
92
|
+
tasks_created: list[dict] = field(default_factory=list)
|
|
93
|
+
tasks_skipped: list[dict] = field(default_factory=list)
|
|
94
|
+
prd_action: str = "none" # created | new_version | skipped_identical | none
|
|
95
|
+
prd_title: Optional[str] = None
|
|
96
|
+
agents_md_action: str = "none" # written | skipped_exists | none
|
|
97
|
+
state_files_ignored: list[str] = field(default_factory=list)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# =============================================================================
|
|
101
|
+
# Parsers
|
|
102
|
+
# =============================================================================
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def parse_ralphrc(path: Path) -> dict[str, str]:
|
|
106
|
+
"""Parse a shell-style .ralphrc file into a flat string dict.
|
|
107
|
+
|
|
108
|
+
Handles comments, blank lines, single/double quoting, unquoted trailing
|
|
109
|
+
comments, and ``${VAR:-default}`` expansions (resolved to the default
|
|
110
|
+
literal - the importer never reads the caller's environment).
|
|
111
|
+
"""
|
|
112
|
+
if not path.is_file():
|
|
113
|
+
return {}
|
|
114
|
+
|
|
115
|
+
config: dict[str, str] = {}
|
|
116
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
117
|
+
stripped = line.strip()
|
|
118
|
+
if not stripped or stripped.startswith("#"):
|
|
119
|
+
continue
|
|
120
|
+
match = _KEY_VALUE_RE.match(stripped)
|
|
121
|
+
if not match:
|
|
122
|
+
continue
|
|
123
|
+
key, raw = match.group(1), match.group(2).strip()
|
|
124
|
+
if raw[:1] in ("'", '"'):
|
|
125
|
+
quote = raw[0]
|
|
126
|
+
closing = raw.find(quote, 1)
|
|
127
|
+
value = raw[1:closing] if closing != -1 else raw[1:]
|
|
128
|
+
else:
|
|
129
|
+
value = raw.split(" #", 1)[0].strip()
|
|
130
|
+
config[key] = _SHELL_EXPANSION_RE.sub(lambda m: m.group(1) or "", value)
|
|
131
|
+
return config
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def parse_fix_plan(path: Path) -> list[FixPlanItem]:
|
|
135
|
+
"""Extract checkbox items from fix_plan.md, tracking section headings."""
|
|
136
|
+
if not path.is_file():
|
|
137
|
+
return []
|
|
138
|
+
|
|
139
|
+
items: list[FixPlanItem] = []
|
|
140
|
+
section = ""
|
|
141
|
+
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
|
142
|
+
heading = _HEADING_RE.match(line)
|
|
143
|
+
if heading:
|
|
144
|
+
section = heading.group(2)
|
|
145
|
+
continue
|
|
146
|
+
checkbox = _CHECKBOX_RE.match(line)
|
|
147
|
+
if checkbox:
|
|
148
|
+
items.append(
|
|
149
|
+
FixPlanItem(
|
|
150
|
+
title=checkbox.group(2),
|
|
151
|
+
section=section,
|
|
152
|
+
checked=checkbox.group(1).lower() == "x",
|
|
153
|
+
line=lineno,
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
return items
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def parse_prompt_md(path: Path) -> Optional[str]:
|
|
160
|
+
"""Read PROMPT.md content, or None if absent."""
|
|
161
|
+
return path.read_text(encoding="utf-8") if path.is_file() else None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def parse_agent_md(path: Path) -> Optional[str]:
|
|
165
|
+
"""Read AGENT.md content, or None if absent."""
|
|
166
|
+
return path.read_text(encoding="utf-8") if path.is_file() else None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def collect_specs(specs_dir: Path) -> list[tuple[str, str]]:
|
|
170
|
+
"""Gather (filename, content) for spec markdown files, sorted by name."""
|
|
171
|
+
if not specs_dir.is_dir():
|
|
172
|
+
return []
|
|
173
|
+
return [
|
|
174
|
+
(spec.name, spec.read_text(encoding="utf-8"))
|
|
175
|
+
for spec in sorted(specs_dir.glob("*.md"))
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def load_ralph_project(path: Path) -> RalphProject:
|
|
180
|
+
"""Load and validate a ralph project directory.
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
RalphProjectNotFoundError: if ``.ralph/`` is missing, or it contains
|
|
184
|
+
neither fix_plan.md nor PROMPT.md.
|
|
185
|
+
"""
|
|
186
|
+
root = Path(path).resolve()
|
|
187
|
+
ralph_dir = root / ".ralph"
|
|
188
|
+
if not ralph_dir.is_dir():
|
|
189
|
+
raise RalphProjectNotFoundError(
|
|
190
|
+
f"No ralph project found: {root} has no .ralph/ directory"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
fix_plan_path = ralph_dir / "fix_plan.md"
|
|
194
|
+
prompt_path = ralph_dir / "PROMPT.md"
|
|
195
|
+
if not fix_plan_path.is_file() and not prompt_path.is_file():
|
|
196
|
+
raise RalphProjectNotFoundError(
|
|
197
|
+
f"{ralph_dir} contains neither fix_plan.md nor PROMPT.md; "
|
|
198
|
+
"nothing to import"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
state_files = sorted(
|
|
202
|
+
entry.name
|
|
203
|
+
for entry in ralph_dir.iterdir()
|
|
204
|
+
if entry.name not in _RALPH_SOURCE_ENTRIES
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
return RalphProject(
|
|
208
|
+
root=root,
|
|
209
|
+
ralphrc=parse_ralphrc(root / ".ralphrc"),
|
|
210
|
+
fix_plan_items=parse_fix_plan(fix_plan_path),
|
|
211
|
+
prompt=parse_prompt_md(prompt_path),
|
|
212
|
+
agent_md=parse_agent_md(ralph_dir / "AGENT.md"),
|
|
213
|
+
specs=collect_specs(ralph_dir / "specs"),
|
|
214
|
+
state_files_ignored=state_files,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# =============================================================================
|
|
219
|
+
# Mappers
|
|
220
|
+
# =============================================================================
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _optional_sections(ralphrc: dict[str, str]) -> list[str]:
|
|
224
|
+
raw = ralphrc.get("OPTIONAL_SECTIONS", "").strip()
|
|
225
|
+
if raw:
|
|
226
|
+
return [name.strip() for name in raw.split(",") if name.strip()]
|
|
227
|
+
return DEFAULT_OPTIONAL_SECTIONS
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _is_optional_section(section: str, optional_sections: list[str]) -> bool:
|
|
231
|
+
# Keyword containment so "Future Enhancements" matches "Future",
|
|
232
|
+
# mirroring ralph's own optional-section semantics (ralph issue #239).
|
|
233
|
+
lowered = section.lower()
|
|
234
|
+
return any(name.lower() in lowered for name in optional_sections)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _external_url(section: str, title: str, seen: set[str]) -> str:
|
|
238
|
+
"""Stable idempotency key for a fix_plan item.
|
|
239
|
+
|
|
240
|
+
Hashes section + title (not the item's position) so re-imports stay
|
|
241
|
+
idempotent when unrelated items are inserted or removed. Duplicate
|
|
242
|
+
section/title pairs get an ordinal suffix.
|
|
243
|
+
"""
|
|
244
|
+
digest = hashlib.sha1(f"{section}|{title}".encode("utf-8")).hexdigest()[:16]
|
|
245
|
+
url = f"ralph://fix_plan.md#{digest}"
|
|
246
|
+
ordinal = 1
|
|
247
|
+
candidate = url
|
|
248
|
+
while candidate in seen:
|
|
249
|
+
ordinal += 1
|
|
250
|
+
candidate = f"{url}-{ordinal}"
|
|
251
|
+
seen.add(candidate)
|
|
252
|
+
return candidate
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def map_tasks(project: RalphProject) -> tuple[list[dict], list[dict]]:
|
|
256
|
+
"""Map fix_plan items to task specs ready for ``tasks.create()``.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
(mapped, skipped) - mapped task dicts in file order, and checked
|
|
260
|
+
items skipped with a reason.
|
|
261
|
+
"""
|
|
262
|
+
optional_sections = _optional_sections(project.ralphrc)
|
|
263
|
+
mapped: list[dict] = []
|
|
264
|
+
skipped: list[dict] = []
|
|
265
|
+
seen_urls: set[str] = set()
|
|
266
|
+
|
|
267
|
+
for item in project.fix_plan_items:
|
|
268
|
+
if item.checked:
|
|
269
|
+
skipped.append(
|
|
270
|
+
{
|
|
271
|
+
"title": item.title,
|
|
272
|
+
"section": item.section,
|
|
273
|
+
"reason": "already completed in fix_plan.md",
|
|
274
|
+
}
|
|
275
|
+
)
|
|
276
|
+
continue
|
|
277
|
+
|
|
278
|
+
status = (
|
|
279
|
+
TaskStatus.BACKLOG
|
|
280
|
+
if _is_optional_section(item.section, optional_sections)
|
|
281
|
+
else TaskStatus.READY
|
|
282
|
+
)
|
|
283
|
+
mapped.append(
|
|
284
|
+
{
|
|
285
|
+
"title": item.title,
|
|
286
|
+
"description": (
|
|
287
|
+
f"Imported from .ralph/fix_plan.md "
|
|
288
|
+
f"(section: {item.section or 'top level'}, line {item.line})."
|
|
289
|
+
),
|
|
290
|
+
"status": status,
|
|
291
|
+
"priority": len(mapped),
|
|
292
|
+
"external_url": _external_url(item.section, item.title, seen_urls),
|
|
293
|
+
"section": item.section,
|
|
294
|
+
}
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
return mapped, skipped
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def map_prd_content(project: RalphProject) -> Optional[dict]:
|
|
301
|
+
"""Combine PROMPT.md and specs into PRD content with source attribution.
|
|
302
|
+
|
|
303
|
+
Returns None when the project has neither.
|
|
304
|
+
"""
|
|
305
|
+
if not project.prompt and not project.specs:
|
|
306
|
+
return None
|
|
307
|
+
|
|
308
|
+
project_name = project.ralphrc.get("PROJECT_NAME") or project.root.name
|
|
309
|
+
title = f"{project_name} (imported from ralph)"
|
|
310
|
+
sections = [f"# {title}"]
|
|
311
|
+
sources: list[str] = []
|
|
312
|
+
|
|
313
|
+
if project.prompt:
|
|
314
|
+
sections.append(f"## Source: .ralph/PROMPT.md\n\n{project.prompt.strip()}")
|
|
315
|
+
sources.append(".ralph/PROMPT.md")
|
|
316
|
+
for name, content in project.specs:
|
|
317
|
+
sections.append(f"## Source: .ralph/specs/{name}\n\n{content.strip()}")
|
|
318
|
+
sources.append(f".ralph/specs/{name}")
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
"title": title,
|
|
322
|
+
"content": "\n\n".join(sections) + "\n",
|
|
323
|
+
"metadata": {"ralph_import": True, "sources": sources},
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _extract_agent_commands(agent_md: str) -> dict[str, str]:
|
|
328
|
+
"""Pull one representative command per known AGENT.md section.
|
|
329
|
+
|
|
330
|
+
Takes the first non-comment line inside a code fence under each
|
|
331
|
+
recognized heading (Setup/Tests/Build/Server).
|
|
332
|
+
"""
|
|
333
|
+
commands: dict[str, str] = {}
|
|
334
|
+
current_key: Optional[str] = None
|
|
335
|
+
in_fence = False
|
|
336
|
+
|
|
337
|
+
for line in agent_md.splitlines():
|
|
338
|
+
if line.strip().startswith("```"):
|
|
339
|
+
in_fence = not in_fence
|
|
340
|
+
continue
|
|
341
|
+
if in_fence:
|
|
342
|
+
# Comment lines inside fences are bash comments, not headings.
|
|
343
|
+
if current_key and current_key not in commands:
|
|
344
|
+
candidate = line.strip()
|
|
345
|
+
if candidate and not candidate.startswith("#"):
|
|
346
|
+
commands[current_key] = candidate
|
|
347
|
+
continue
|
|
348
|
+
heading = _HEADING_RE.match(line)
|
|
349
|
+
if heading:
|
|
350
|
+
text = heading.group(2).lower()
|
|
351
|
+
current_key = None
|
|
352
|
+
for key, keywords in _COMMAND_SECTION_KEYS:
|
|
353
|
+
if any(keyword in text for keyword in keywords):
|
|
354
|
+
current_key = key
|
|
355
|
+
break
|
|
356
|
+
|
|
357
|
+
return commands
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def map_agent_preferences(project: RalphProject) -> Optional[dict]:
|
|
361
|
+
"""Build AGENTS.md content from AGENT.md commands and ALLOWED_TOOLS.
|
|
362
|
+
|
|
363
|
+
The output uses the standard section format parsed by
|
|
364
|
+
``codeframe.core.agents_config.load_preferences()``. Returns None when
|
|
365
|
+
the project has neither source.
|
|
366
|
+
"""
|
|
367
|
+
commands = _extract_agent_commands(project.agent_md) if project.agent_md else {}
|
|
368
|
+
allowed_tools = project.ralphrc.get("ALLOWED_TOOLS", "").strip()
|
|
369
|
+
if not commands and not allowed_tools:
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
lines = ["# Agent Preferences (imported from ralph)", ""]
|
|
373
|
+
if commands:
|
|
374
|
+
lines += ["## Commands", ""]
|
|
375
|
+
lines += [f"- **{key}**: {value}" for key, value in commands.items()]
|
|
376
|
+
lines.append("")
|
|
377
|
+
if allowed_tools:
|
|
378
|
+
lines += [
|
|
379
|
+
"## Always Do",
|
|
380
|
+
"",
|
|
381
|
+
f"- Use the tools ralph permitted (ALLOWED_TOOLS): {allowed_tools}",
|
|
382
|
+
"",
|
|
383
|
+
]
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
"title": "Agent Preferences (imported from ralph)",
|
|
387
|
+
"content": "\n".join(lines),
|
|
388
|
+
"metadata": {"ralph_import": True},
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# =============================================================================
|
|
393
|
+
# Import orchestration
|
|
394
|
+
# =============================================================================
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _find_ralph_prd(workspace, prd_module):
|
|
398
|
+
"""Find the most recent PRD previously imported from ralph, if any."""
|
|
399
|
+
# list_all is ordered newest-first, so the first ralph_import match is
|
|
400
|
+
# the latest version (create_new_version copies parent metadata).
|
|
401
|
+
for record in prd_module.list_all(workspace):
|
|
402
|
+
if record.metadata.get("ralph_import"):
|
|
403
|
+
return record
|
|
404
|
+
return None
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def import_ralph_project(
|
|
408
|
+
ralph_path: Path,
|
|
409
|
+
workspace_path: Optional[Path] = None,
|
|
410
|
+
dry_run: bool = False,
|
|
411
|
+
) -> RalphImportReport:
|
|
412
|
+
"""Import a ralph project into a CodeFRAME workspace.
|
|
413
|
+
|
|
414
|
+
Idempotent: re-runs skip tasks already imported (keyed on
|
|
415
|
+
``external_url``), skip the PRD when its content is unchanged (new
|
|
416
|
+
version when it changed), and never overwrite an existing AGENTS.md.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
ralph_path: Root of the ralph project (contains ``.ralph/``).
|
|
420
|
+
workspace_path: Target CodeFRAME workspace root. Defaults to the
|
|
421
|
+
ralph project root (import in place).
|
|
422
|
+
dry_run: When True, compute the full mapping report without
|
|
423
|
+
creating the workspace or writing anything.
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
RalphImportReport describing what was created, skipped, and ignored.
|
|
427
|
+
|
|
428
|
+
Raises:
|
|
429
|
+
RalphProjectNotFoundError: if ``ralph_path`` is not a ralph project.
|
|
430
|
+
"""
|
|
431
|
+
from codeframe.core import prd, tasks
|
|
432
|
+
from codeframe.core.workspace import (
|
|
433
|
+
create_or_load_workspace,
|
|
434
|
+
get_workspace,
|
|
435
|
+
workspace_exists,
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
project = load_ralph_project(Path(ralph_path))
|
|
439
|
+
target = Path(workspace_path).resolve() if workspace_path else project.root
|
|
440
|
+
|
|
441
|
+
mapped_tasks, mapping_skipped = map_tasks(project)
|
|
442
|
+
prd_mapping = map_prd_content(project)
|
|
443
|
+
agents_mapping = map_agent_preferences(project)
|
|
444
|
+
|
|
445
|
+
report = RalphImportReport(
|
|
446
|
+
workspace_path=target,
|
|
447
|
+
dry_run=dry_run,
|
|
448
|
+
tasks_skipped=list(mapping_skipped),
|
|
449
|
+
state_files_ignored=list(project.state_files_ignored),
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
workspace = None
|
|
453
|
+
if dry_run:
|
|
454
|
+
if workspace_exists(target):
|
|
455
|
+
workspace = get_workspace(target)
|
|
456
|
+
else:
|
|
457
|
+
workspace = create_or_load_workspace(target)
|
|
458
|
+
|
|
459
|
+
# PRD first so created tasks can link to it via prd_id.
|
|
460
|
+
prd_id: Optional[str] = None
|
|
461
|
+
if prd_mapping is not None:
|
|
462
|
+
existing = _find_ralph_prd(workspace, prd) if workspace else None
|
|
463
|
+
if existing is None:
|
|
464
|
+
report.prd_action = "created"
|
|
465
|
+
report.prd_title = prd_mapping["title"]
|
|
466
|
+
if not dry_run:
|
|
467
|
+
record = prd.store(
|
|
468
|
+
workspace,
|
|
469
|
+
prd_mapping["content"],
|
|
470
|
+
title=prd_mapping["title"],
|
|
471
|
+
metadata=prd_mapping["metadata"],
|
|
472
|
+
)
|
|
473
|
+
prd_id = record.id
|
|
474
|
+
elif existing.content == prd_mapping["content"]:
|
|
475
|
+
report.prd_action = "skipped_identical"
|
|
476
|
+
report.prd_title = existing.title
|
|
477
|
+
prd_id = existing.id
|
|
478
|
+
else:
|
|
479
|
+
report.prd_action = "new_version"
|
|
480
|
+
report.prd_title = existing.title
|
|
481
|
+
if not dry_run:
|
|
482
|
+
record = prd.create_new_version(
|
|
483
|
+
workspace,
|
|
484
|
+
existing.id,
|
|
485
|
+
prd_mapping["content"],
|
|
486
|
+
change_summary="Re-imported from ralph (source files changed)",
|
|
487
|
+
)
|
|
488
|
+
prd_id = record.id if record else existing.id
|
|
489
|
+
|
|
490
|
+
for spec in mapped_tasks:
|
|
491
|
+
already = (
|
|
492
|
+
tasks.get_by_external_url(workspace, spec["external_url"])
|
|
493
|
+
if workspace
|
|
494
|
+
else None
|
|
495
|
+
)
|
|
496
|
+
if already is not None:
|
|
497
|
+
report.tasks_skipped.append(
|
|
498
|
+
{
|
|
499
|
+
"title": spec["title"],
|
|
500
|
+
"section": spec["section"],
|
|
501
|
+
"reason": "already imported",
|
|
502
|
+
}
|
|
503
|
+
)
|
|
504
|
+
continue
|
|
505
|
+
if not dry_run:
|
|
506
|
+
try:
|
|
507
|
+
tasks.create(
|
|
508
|
+
workspace,
|
|
509
|
+
title=spec["title"],
|
|
510
|
+
description=spec["description"],
|
|
511
|
+
status=spec["status"],
|
|
512
|
+
priority=spec["priority"],
|
|
513
|
+
prd_id=prd_id,
|
|
514
|
+
external_url=spec["external_url"],
|
|
515
|
+
)
|
|
516
|
+
except sqlite3.IntegrityError:
|
|
517
|
+
# Lost a race with a concurrent import of the same item; the
|
|
518
|
+
# UNIQUE(workspace_id, external_url) index makes this safe.
|
|
519
|
+
report.tasks_skipped.append(
|
|
520
|
+
{
|
|
521
|
+
"title": spec["title"],
|
|
522
|
+
"section": spec["section"],
|
|
523
|
+
"reason": "already imported",
|
|
524
|
+
}
|
|
525
|
+
)
|
|
526
|
+
continue
|
|
527
|
+
report.tasks_created.append(spec)
|
|
528
|
+
|
|
529
|
+
if agents_mapping is not None:
|
|
530
|
+
agents_path = target / "AGENTS.md"
|
|
531
|
+
if agents_path.exists():
|
|
532
|
+
report.agents_md_action = "skipped_exists"
|
|
533
|
+
else:
|
|
534
|
+
report.agents_md_action = "written"
|
|
535
|
+
if not dry_run:
|
|
536
|
+
agents_path.write_text(
|
|
537
|
+
agents_mapping["content"], encoding="utf-8"
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
return report
|