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.
Files changed (197) hide show
  1. codeframe/__init__.py +11 -0
  2. codeframe/__main__.py +20 -0
  3. codeframe/adapters/__init__.py +5 -0
  4. codeframe/adapters/e2b/__init__.py +13 -0
  5. codeframe/adapters/e2b/adapter.py +342 -0
  6. codeframe/adapters/e2b/budget.py +71 -0
  7. codeframe/adapters/e2b/credential_scanner.py +134 -0
  8. codeframe/adapters/llm/__init__.py +92 -0
  9. codeframe/adapters/llm/anthropic.py +414 -0
  10. codeframe/adapters/llm/base.py +444 -0
  11. codeframe/adapters/llm/mock.py +281 -0
  12. codeframe/adapters/llm/openai.py +483 -0
  13. codeframe/agents/__init__.py +8 -0
  14. codeframe/agents/dependency_resolver.py +714 -0
  15. codeframe/auth/__init__.py +16 -0
  16. codeframe/auth/api_key_router.py +238 -0
  17. codeframe/auth/api_keys.py +156 -0
  18. codeframe/auth/dependencies.py +358 -0
  19. codeframe/auth/manager.py +178 -0
  20. codeframe/auth/models.py +30 -0
  21. codeframe/auth/router.py +93 -0
  22. codeframe/auth/schemas.py +15 -0
  23. codeframe/auth/scopes.py +53 -0
  24. codeframe/cli/__init__.py +12 -0
  25. codeframe/cli/__main__.py +20 -0
  26. codeframe/cli/api_client.py +275 -0
  27. codeframe/cli/app.py +5688 -0
  28. codeframe/cli/auth.py +122 -0
  29. codeframe/cli/auth_commands.py +958 -0
  30. codeframe/cli/commands/__init__.py +5 -0
  31. codeframe/cli/config_commands.py +79 -0
  32. codeframe/cli/dashboard_commands.py +67 -0
  33. codeframe/cli/engines_commands.py +205 -0
  34. codeframe/cli/env_commands.py +409 -0
  35. codeframe/cli/helpers.py +56 -0
  36. codeframe/cli/hooks_commands.py +208 -0
  37. codeframe/cli/import_commands.py +129 -0
  38. codeframe/cli/pr_commands.py +549 -0
  39. codeframe/cli/proof_commands.py +415 -0
  40. codeframe/cli/stats_commands.py +311 -0
  41. codeframe/cli/telemetry_runtime.py +153 -0
  42. codeframe/cli/validators.py +123 -0
  43. codeframe/config/rate_limits.py +165 -0
  44. codeframe/core/__init__.py +15 -0
  45. codeframe/core/adapters/__init__.py +43 -0
  46. codeframe/core/adapters/agent_adapter.py +114 -0
  47. codeframe/core/adapters/builtin.py +326 -0
  48. codeframe/core/adapters/claude_code.py +62 -0
  49. codeframe/core/adapters/codex.py +393 -0
  50. codeframe/core/adapters/git_utils.py +40 -0
  51. codeframe/core/adapters/kilocode.py +126 -0
  52. codeframe/core/adapters/opencode.py +48 -0
  53. codeframe/core/adapters/streaming_chat.py +483 -0
  54. codeframe/core/adapters/subprocess_adapter.py +213 -0
  55. codeframe/core/adapters/verification_wrapper.py +269 -0
  56. codeframe/core/agent.py +2183 -0
  57. codeframe/core/agents_config.py +569 -0
  58. codeframe/core/api_key_service.py +211 -0
  59. codeframe/core/artifacts.py +428 -0
  60. codeframe/core/blocker_detection.py +218 -0
  61. codeframe/core/blockers.py +433 -0
  62. codeframe/core/checkpoints.py +481 -0
  63. codeframe/core/conductor.py +2255 -0
  64. codeframe/core/config.py +827 -0
  65. codeframe/core/config_watcher.py +268 -0
  66. codeframe/core/context.py +542 -0
  67. codeframe/core/context_packager.py +234 -0
  68. codeframe/core/credentials.py +735 -0
  69. codeframe/core/dependency_analyzer.py +229 -0
  70. codeframe/core/dependency_graph.py +290 -0
  71. codeframe/core/diagnostic_agent.py +712 -0
  72. codeframe/core/diagnostics.py +616 -0
  73. codeframe/core/editor.py +556 -0
  74. codeframe/core/engine_registry.py +256 -0
  75. codeframe/core/engine_stats.py +231 -0
  76. codeframe/core/environment.py +697 -0
  77. codeframe/core/events.py +375 -0
  78. codeframe/core/executor.py +1005 -0
  79. codeframe/core/fix_tracker.py +480 -0
  80. codeframe/core/gates.py +1322 -0
  81. codeframe/core/git.py +477 -0
  82. codeframe/core/github_connect_service.py +178 -0
  83. codeframe/core/github_integration_config.py +118 -0
  84. codeframe/core/github_issues_service.py +449 -0
  85. codeframe/core/hooks.py +184 -0
  86. codeframe/core/importers/__init__.py +1 -0
  87. codeframe/core/importers/ralph.py +540 -0
  88. codeframe/core/installer.py +650 -0
  89. codeframe/core/models.py +1026 -0
  90. codeframe/core/notifications_config.py +183 -0
  91. codeframe/core/planner.py +437 -0
  92. codeframe/core/prd.py +670 -0
  93. codeframe/core/prd_discovery.py +1118 -0
  94. codeframe/core/prd_stress_test.py +499 -0
  95. codeframe/core/progress.py +126 -0
  96. codeframe/core/proof/__init__.py +34 -0
  97. codeframe/core/proof/capture.py +79 -0
  98. codeframe/core/proof/evidence.py +56 -0
  99. codeframe/core/proof/ledger.py +574 -0
  100. codeframe/core/proof/models.py +162 -0
  101. codeframe/core/proof/obligations.py +103 -0
  102. codeframe/core/proof/runner.py +233 -0
  103. codeframe/core/proof/scope.py +81 -0
  104. codeframe/core/proof/stubs.py +156 -0
  105. codeframe/core/quick_fixes.py +558 -0
  106. codeframe/core/react_agent.py +1650 -0
  107. codeframe/core/reconciliation.py +183 -0
  108. codeframe/core/replay.py +788 -0
  109. codeframe/core/review.py +285 -0
  110. codeframe/core/runtime.py +1134 -0
  111. codeframe/core/sandbox/__init__.py +27 -0
  112. codeframe/core/sandbox/context.py +98 -0
  113. codeframe/core/sandbox/worktree.py +20 -0
  114. codeframe/core/schedule.py +396 -0
  115. codeframe/core/stall_detector.py +71 -0
  116. codeframe/core/stall_monitor.py +134 -0
  117. codeframe/core/state_machine.py +121 -0
  118. codeframe/core/streaming.py +502 -0
  119. codeframe/core/task_tree.py +400 -0
  120. codeframe/core/tasks.py +1022 -0
  121. codeframe/core/telemetry.py +232 -0
  122. codeframe/core/templates.py +221 -0
  123. codeframe/core/tools.py +942 -0
  124. codeframe/core/workspace.py +887 -0
  125. codeframe/core/worktrees.py +276 -0
  126. codeframe/git/__init__.py +5 -0
  127. codeframe/git/github_integration.py +505 -0
  128. codeframe/lib/__init__.py +0 -0
  129. codeframe/lib/audit_logger.py +248 -0
  130. codeframe/lib/metrics_tracker.py +800 -0
  131. codeframe/lib/quality/__init__.py +7 -0
  132. codeframe/lib/quality/complexity_analyzer.py +316 -0
  133. codeframe/lib/quality/owasp_patterns.py +284 -0
  134. codeframe/lib/quality/security_scanner.py +250 -0
  135. codeframe/lib/rate_limiter.py +312 -0
  136. codeframe/notifications/__init__.py +0 -0
  137. codeframe/notifications/webhook.py +380 -0
  138. codeframe/planning/__init__.py +30 -0
  139. codeframe/planning/issue_generator.py +219 -0
  140. codeframe/planning/prd_template_functions.py +137 -0
  141. codeframe/planning/prd_templates.py +975 -0
  142. codeframe/planning/task_scheduler.py +511 -0
  143. codeframe/planning/task_templates.py +533 -0
  144. codeframe/platform_store/__init__.py +5 -0
  145. codeframe/platform_store/database.py +277 -0
  146. codeframe/platform_store/repositories/__init__.py +24 -0
  147. codeframe/platform_store/repositories/api_key_repository.py +245 -0
  148. codeframe/platform_store/repositories/audit_repository.py +67 -0
  149. codeframe/platform_store/repositories/base.py +295 -0
  150. codeframe/platform_store/repositories/interactive_sessions.py +165 -0
  151. codeframe/platform_store/repositories/token_repository.py +598 -0
  152. codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
  153. codeframe/platform_store/schema_manager.py +321 -0
  154. codeframe/templates/AGENTS.md.default +94 -0
  155. codeframe/tui/__init__.py +5 -0
  156. codeframe/tui/app.py +256 -0
  157. codeframe/tui/data_service.py +103 -0
  158. codeframe/ui/__init__.py +0 -0
  159. codeframe/ui/dependencies.py +103 -0
  160. codeframe/ui/models.py +999 -0
  161. codeframe/ui/response_models.py +201 -0
  162. codeframe/ui/routers/__init__.py +5 -0
  163. codeframe/ui/routers/_helpers.py +29 -0
  164. codeframe/ui/routers/batches_v2.py +315 -0
  165. codeframe/ui/routers/blockers_v2.py +320 -0
  166. codeframe/ui/routers/checkpoints_v2.py +310 -0
  167. codeframe/ui/routers/costs_v2.py +322 -0
  168. codeframe/ui/routers/diagnose_v2.py +225 -0
  169. codeframe/ui/routers/discovery_v2.py +417 -0
  170. codeframe/ui/routers/environment_v2.py +284 -0
  171. codeframe/ui/routers/events_v2.py +75 -0
  172. codeframe/ui/routers/gates_v2.py +166 -0
  173. codeframe/ui/routers/git_v2.py +284 -0
  174. codeframe/ui/routers/github_integrations_v2.py +532 -0
  175. codeframe/ui/routers/interactive_sessions_v2.py +238 -0
  176. codeframe/ui/routers/pr_v2.py +709 -0
  177. codeframe/ui/routers/prd_v2.py +695 -0
  178. codeframe/ui/routers/proof_v2.py +755 -0
  179. codeframe/ui/routers/review_v2.py +360 -0
  180. codeframe/ui/routers/schedule_v2.py +214 -0
  181. codeframe/ui/routers/session_chat_ws.py +354 -0
  182. codeframe/ui/routers/settings_v2.py +562 -0
  183. codeframe/ui/routers/streaming_v2.py +155 -0
  184. codeframe/ui/routers/tasks_v2.py +1098 -0
  185. codeframe/ui/routers/templates_v2.py +232 -0
  186. codeframe/ui/routers/terminal_ws.py +267 -0
  187. codeframe/ui/routers/workspace_v2.py +527 -0
  188. codeframe/ui/server.py +568 -0
  189. codeframe/ui/shared.py +241 -0
  190. codeframe/workspace/__init__.py +5 -0
  191. codeframe/workspace/manager.py +249 -0
  192. codeframe_ai-0.9.0.dist-info/METADATA +517 -0
  193. codeframe_ai-0.9.0.dist-info/RECORD +197 -0
  194. codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
  195. codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
  196. codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
  197. 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