ralphx 0.2.2__py3-none-any.whl → 0.3.5__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 (45) hide show
  1. ralphx/__init__.py +1 -1
  2. ralphx/api/main.py +9 -1
  3. ralphx/api/routes/auth.py +730 -65
  4. ralphx/api/routes/config.py +3 -56
  5. ralphx/api/routes/export_import.py +795 -0
  6. ralphx/api/routes/loops.py +4 -4
  7. ralphx/api/routes/planning.py +19 -5
  8. ralphx/api/routes/projects.py +84 -2
  9. ralphx/api/routes/templates.py +115 -2
  10. ralphx/api/routes/workflows.py +22 -22
  11. ralphx/cli.py +21 -6
  12. ralphx/core/auth.py +346 -171
  13. ralphx/core/database.py +615 -167
  14. ralphx/core/executor.py +0 -3
  15. ralphx/core/loop.py +15 -2
  16. ralphx/core/loop_templates.py +69 -3
  17. ralphx/core/planning_service.py +109 -21
  18. ralphx/core/preview.py +9 -25
  19. ralphx/core/project_db.py +175 -75
  20. ralphx/core/project_export.py +469 -0
  21. ralphx/core/project_import.py +670 -0
  22. ralphx/core/sample_project.py +430 -0
  23. ralphx/core/templates.py +46 -9
  24. ralphx/core/workflow_executor.py +35 -5
  25. ralphx/core/workflow_export.py +606 -0
  26. ralphx/core/workflow_import.py +1149 -0
  27. ralphx/examples/sample_project/DESIGN.md +345 -0
  28. ralphx/examples/sample_project/README.md +37 -0
  29. ralphx/examples/sample_project/guardrails.md +57 -0
  30. ralphx/examples/sample_project/stories.jsonl +10 -0
  31. ralphx/mcp/__init__.py +6 -2
  32. ralphx/mcp/registry.py +3 -3
  33. ralphx/mcp/server.py +99 -29
  34. ralphx/mcp/tools/__init__.py +4 -0
  35. ralphx/mcp/tools/help.py +204 -0
  36. ralphx/mcp/tools/workflows.py +114 -32
  37. ralphx/mcp_server.py +6 -2
  38. ralphx/static/assets/index-0ovNnfOq.css +1 -0
  39. ralphx/static/assets/index-CY9s08ZB.js +251 -0
  40. ralphx/static/assets/index-CY9s08ZB.js.map +1 -0
  41. ralphx/static/index.html +14 -0
  42. {ralphx-0.2.2.dist-info → ralphx-0.3.5.dist-info}/METADATA +34 -12
  43. {ralphx-0.2.2.dist-info → ralphx-0.3.5.dist-info}/RECORD +45 -30
  44. {ralphx-0.2.2.dist-info → ralphx-0.3.5.dist-info}/WHEEL +0 -0
  45. {ralphx-0.2.2.dist-info → ralphx-0.3.5.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,430 @@
1
+ """Sample project creation for RalphX first-run experience.
2
+
3
+ Creates a sample "Excuse Generator" project when users first install RalphX,
4
+ demonstrating how workflows work with design documents, stories, and guardrails.
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ import shutil
10
+ import subprocess
11
+ import uuid
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ from ralphx.core.workspace import get_workspace_path
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # Sample project name and directory
20
+ SAMPLE_PROJECT_NAME = "Excuse-Generator"
21
+ SAMPLE_PROJECT_DIR_NAME = "excuse-generator"
22
+
23
+
24
+ def get_sample_project_source_path() -> Path:
25
+ """Get path to bundled sample project files.
26
+
27
+ Returns:
28
+ Path to ralphx/examples/sample_project/ directory.
29
+ """
30
+ return Path(__file__).parent.parent / "examples" / "sample_project"
31
+
32
+
33
+ def get_samples_directory() -> Path:
34
+ """Get the samples directory within the RalphX workspace.
35
+
36
+ Returns:
37
+ Path to ~/.ralphx/samples/
38
+ """
39
+ samples_dir = get_workspace_path() / "samples"
40
+ samples_dir.mkdir(parents=True, exist_ok=True)
41
+ return samples_dir
42
+
43
+
44
+ def get_sample_project_target_path() -> Path:
45
+ """Get target path for sample project.
46
+
47
+ Returns:
48
+ Path to ~/.ralphx/samples/excuse-generator/
49
+ """
50
+ return get_samples_directory() / SAMPLE_PROJECT_DIR_NAME
51
+
52
+
53
+ def _init_git_repo(project_path: Path) -> bool:
54
+ """Initialize a git repository in the project directory.
55
+
56
+ Args:
57
+ project_path: Path to the project directory.
58
+
59
+ Returns:
60
+ True if git repo was initialized, False if git not available.
61
+ """
62
+ try:
63
+ # Check if git is available
64
+ result = subprocess.run(
65
+ ["git", "--version"],
66
+ capture_output=True,
67
+ text=True,
68
+ timeout=5,
69
+ )
70
+ if result.returncode != 0:
71
+ return False
72
+
73
+ # Initialize git repo
74
+ subprocess.run(
75
+ ["git", "init"],
76
+ cwd=project_path,
77
+ capture_output=True,
78
+ timeout=10,
79
+ )
80
+
81
+ # Create initial commit with sample files
82
+ subprocess.run(
83
+ ["git", "add", "."],
84
+ cwd=project_path,
85
+ capture_output=True,
86
+ timeout=10,
87
+ )
88
+ subprocess.run(
89
+ ["git", "commit", "-m", "Initial commit: RalphX sample project"],
90
+ cwd=project_path,
91
+ capture_output=True,
92
+ timeout=10,
93
+ )
94
+
95
+ return True
96
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
97
+ return False
98
+
99
+
100
+ def _create_workflow_with_stories(
101
+ project_db,
102
+ workflow_id: str,
103
+ template_id: str,
104
+ stories_path: Path,
105
+ ) -> Optional[str]:
106
+ """Create a workflow from template and populate with sample stories.
107
+
108
+ Args:
109
+ project_db: ProjectDatabase instance.
110
+ workflow_id: ID for the new workflow.
111
+ template_id: Template ID to base workflow on.
112
+ stories_path: Path to stories.jsonl file.
113
+
114
+ Returns:
115
+ Workflow ID if created, None on error.
116
+ """
117
+ try:
118
+ # Seed templates if empty
119
+ project_db.seed_workflow_templates_if_empty()
120
+
121
+ # Get template to create steps from
122
+ template = project_db.get_workflow_template(template_id)
123
+ if not template:
124
+ logger.warning(f"Template '{template_id}' not found")
125
+ return None
126
+
127
+ # Create workflow (namespace parameter removed in schema v16)
128
+ workflow = project_db.create_workflow(
129
+ id=workflow_id,
130
+ name="Build Excuse Generator",
131
+ template_id=template_id,
132
+ status="draft",
133
+ )
134
+
135
+ # Create steps from template phases
136
+ created_steps = []
137
+ for phase in template.get("phases", []):
138
+ step = project_db.create_workflow_step(
139
+ workflow_id=workflow_id,
140
+ step_number=phase["number"],
141
+ name=phase["name"],
142
+ step_type=phase["type"],
143
+ config={
144
+ "description": phase.get("description"),
145
+ "loopType": phase.get("loopType"),
146
+ "inputs": phase.get("inputs", []),
147
+ "outputs": phase.get("outputs", []),
148
+ "skippable": phase.get("skippable", False),
149
+ "skipCondition": phase.get("skipCondition"),
150
+ },
151
+ status="pending",
152
+ )
153
+ created_steps.append(step)
154
+
155
+ # Find the Story Generation step (step 1 in from-design-doc template)
156
+ story_step = None
157
+ for step in created_steps:
158
+ if step["step_number"] == 1:
159
+ story_step = step
160
+ break
161
+
162
+ if not story_step:
163
+ logger.warning("Could not find Story Generation step")
164
+ return workflow_id
165
+
166
+ # Import stories from JSONL file
167
+ if stories_path.exists():
168
+ story_count = _import_stories(
169
+ project_db,
170
+ workflow_id=workflow_id,
171
+ source_step_id=story_step["id"],
172
+ stories_path=stories_path,
173
+ )
174
+
175
+ # Create a completed run record to track the imported stories
176
+ if story_count > 0:
177
+ run_id = f"run-sample-{uuid.uuid4().hex[:8]}"
178
+ project_db.create_run(
179
+ id=run_id,
180
+ loop_name="sample-import",
181
+ workflow_id=workflow_id,
182
+ step_id=story_step["id"],
183
+ )
184
+ # Mark run as completed with story count
185
+ from datetime import datetime
186
+ project_db.update_run(
187
+ run_id,
188
+ status="completed",
189
+ completed_at=datetime.utcnow().isoformat(),
190
+ iterations_completed=1,
191
+ items_generated=story_count,
192
+ )
193
+
194
+ # Mark Story Generation step as completed since stories are pre-populated
195
+ project_db.update_workflow_step(
196
+ story_step["id"],
197
+ status="completed",
198
+ )
199
+
200
+ return workflow_id
201
+
202
+ except Exception as e:
203
+ logger.error(f"Failed to create workflow: {e}")
204
+ return None
205
+
206
+
207
+ def _import_stories(
208
+ project_db,
209
+ workflow_id: str,
210
+ source_step_id: int,
211
+ stories_path: Path,
212
+ ) -> int:
213
+ """Import stories from JSONL file as work items.
214
+
215
+ Args:
216
+ project_db: ProjectDatabase instance.
217
+ workflow_id: Parent workflow ID.
218
+ source_step_id: Step ID that "created" these items.
219
+ stories_path: Path to stories.jsonl file.
220
+
221
+ Returns:
222
+ Number of stories imported.
223
+ """
224
+ count = 0
225
+ try:
226
+ with open(stories_path, "r") as f:
227
+ for line in f:
228
+ line = line.strip()
229
+ if not line:
230
+ continue
231
+
232
+ try:
233
+ story = json.loads(line)
234
+
235
+ # Build content from story data
236
+ content_parts = [story.get("description", "")]
237
+
238
+ # Add acceptance criteria if present
239
+ criteria = story.get("acceptance_criteria", [])
240
+ if criteria:
241
+ content_parts.append("\n\n## Acceptance Criteria")
242
+ for criterion in criteria:
243
+ content_parts.append(f"- {criterion}")
244
+
245
+ content = "\n".join(content_parts)
246
+
247
+ # Determine priority (convert string to int if needed)
248
+ priority = story.get("priority")
249
+ if priority == "high":
250
+ priority = 1
251
+ elif priority == "medium":
252
+ priority = 2
253
+ elif priority == "low":
254
+ priority = 3
255
+ elif isinstance(priority, str):
256
+ priority = 2 # default
257
+
258
+ # Create work item with status "completed" so it's ready for Implementation
259
+ project_db.create_work_item(
260
+ id=story.get("id", f"story-{uuid.uuid4().hex[:8]}"),
261
+ workflow_id=workflow_id,
262
+ source_step_id=source_step_id,
263
+ content=content,
264
+ title=story.get("title"),
265
+ priority=priority,
266
+ category=story.get("category"),
267
+ item_type="story",
268
+ metadata={
269
+ "acceptance_criteria": story.get("acceptance_criteria", []),
270
+ "dependencies": story.get("dependencies", []),
271
+ },
272
+ status="completed", # Ready for next step to consume
273
+ )
274
+ count += 1
275
+
276
+ except json.JSONDecodeError as e:
277
+ logger.warning(f"Failed to parse story line: {e}")
278
+ continue
279
+
280
+ except Exception as e:
281
+ logger.error(f"Failed to import stories: {e}")
282
+
283
+ return count
284
+
285
+
286
+ def create_sample_project() -> Optional[str]:
287
+ """Create the sample project for first-run experience.
288
+
289
+ This function:
290
+ 1. Creates ~/RalphX-Sample-Project/ directory
291
+ 2. Copies sample files (README, DESIGN.md, guardrails, stories)
292
+ 3. Initializes git repository (if git available)
293
+ 4. Registers project with RalphX
294
+ 5. Creates a workflow from "from-design-doc" template
295
+ 6. Pre-populates with sample stories
296
+
297
+ Returns:
298
+ Project slug if created successfully, None on error.
299
+ """
300
+ from ralphx.core.project import ProjectManager
301
+ from ralphx.core.project_db import ProjectDatabase
302
+
303
+ source_path = get_sample_project_source_path()
304
+ target_path = get_sample_project_target_path()
305
+
306
+ # Check if source files exist
307
+ if not source_path.exists():
308
+ logger.warning(f"Sample project source not found: {source_path}")
309
+ return None
310
+
311
+ # Check if target already exists
312
+ if target_path.exists():
313
+ logger.info(f"Sample project directory already exists: {target_path}")
314
+ return None
315
+
316
+ try:
317
+ # Create target directory
318
+ target_path.mkdir(parents=True, exist_ok=True)
319
+
320
+ # Copy sample files
321
+ for source_file in source_path.iterdir():
322
+ if source_file.is_file():
323
+ shutil.copy2(source_file, target_path / source_file.name)
324
+
325
+ logger.info(f"Created sample project at: {target_path}")
326
+
327
+ # Initialize git repo
328
+ git_initialized = _init_git_repo(target_path)
329
+ if git_initialized:
330
+ logger.info("Initialized git repository")
331
+ else:
332
+ logger.info("Git not available, skipping repository initialization")
333
+
334
+ # Register project with RalphX
335
+ pm = ProjectManager()
336
+
337
+ try:
338
+ project = pm.add_project(
339
+ path=target_path,
340
+ name=SAMPLE_PROJECT_NAME,
341
+ design_doc="DESIGN.md",
342
+ slug="excuse-generator",
343
+ )
344
+ logger.info(f"Registered sample project: {project.slug}")
345
+
346
+ # Create workflow with pre-populated stories
347
+ project_db = pm.get_project_db(target_path)
348
+ workflow_id = f"wf-sample-{uuid.uuid4().hex[:8]}"
349
+
350
+ _create_workflow_with_stories(
351
+ project_db,
352
+ workflow_id=workflow_id,
353
+ template_id="from-design-doc",
354
+ stories_path=target_path / "stories.jsonl",
355
+ )
356
+ logger.info("Created sample workflow with pre-populated stories")
357
+
358
+ # Add design doc as workflow resource
359
+ try:
360
+ design_doc_path = target_path / "DESIGN.md"
361
+ if design_doc_path.exists():
362
+ project_db.create_workflow_resource(
363
+ workflow_id=workflow_id,
364
+ resource_type="design_doc",
365
+ name="Excuse Generator Design",
366
+ content=design_doc_path.read_text(),
367
+ source="manual",
368
+ enabled=True,
369
+ )
370
+
371
+ # Add guardrails as workflow resource
372
+ guardrails_path = target_path / "guardrails.md"
373
+ if guardrails_path.exists():
374
+ project_db.create_workflow_resource(
375
+ workflow_id=workflow_id,
376
+ resource_type="guardrails",
377
+ name="Project Guardrails",
378
+ content=guardrails_path.read_text(),
379
+ source="manual",
380
+ enabled=True,
381
+ )
382
+ except Exception as e:
383
+ logger.warning(f"Failed to add workflow resources: {e}")
384
+
385
+ return project.slug
386
+
387
+ except FileExistsError:
388
+ logger.info("Sample project already registered")
389
+ return "excuse-generator"
390
+
391
+ except Exception as e:
392
+ logger.error(f"Failed to create sample project: {e}")
393
+ # Clean up on failure
394
+ if target_path.exists():
395
+ try:
396
+ shutil.rmtree(target_path)
397
+ except Exception:
398
+ pass
399
+ return None
400
+
401
+
402
+ def ensure_sample_project_created() -> bool:
403
+ """Ensure sample project is created on first run.
404
+
405
+ Uses global database settings to track if sample was already created.
406
+
407
+ Returns:
408
+ True if sample project was created or already exists, False on error.
409
+ """
410
+ from ralphx.core.global_db import GlobalDatabase
411
+
412
+ global_db = GlobalDatabase()
413
+
414
+ # Check if already created
415
+ if global_db.get_setting("sample_project_created") is not None:
416
+ return True
417
+
418
+ # Create sample project
419
+ result = create_sample_project()
420
+
421
+ if result:
422
+ # Mark as created
423
+ global_db.set_setting("sample_project_created", "true")
424
+ logger.info("Sample project setup complete")
425
+ return True
426
+
427
+ # Even if creation failed (e.g., directory exists), mark as attempted
428
+ # to avoid repeated attempts
429
+ global_db.set_setting("sample_project_created", "attempted")
430
+ return False
ralphx/core/templates.py CHANGED
@@ -9,15 +9,15 @@ from typing import Optional
9
9
 
10
10
  # Base loop templates
11
11
  TEMPLATES: dict[str, dict] = {
12
- "research": {
13
- "name": "research",
14
- "display_name": "Research Loop",
12
+ "extractgen_requirements": {
13
+ "name": "extractgen_requirements",
14
+ "display_name": "Extract Requirements Loop",
15
15
  "description": "Discover and document user stories from design documents or web research",
16
16
  "type": "generator",
17
17
  "category": "discovery",
18
18
  "config": {
19
- "name": "research",
20
- "display_name": "Research Loop",
19
+ "name": "extractgen_requirements",
20
+ "display_name": "Extract Requirements Loop",
21
21
  "type": "generator",
22
22
  "description": "Discover and document user stories from design documents",
23
23
  "item_types": {
@@ -34,7 +34,7 @@ TEMPLATES: dict[str, dict] = {
34
34
  "model": "claude-sonnet-4-20250514",
35
35
  "timeout": 180,
36
36
  "tools": [],
37
- "prompt_template": "prompts/research_turbo.md",
37
+ "prompt_template": "prompts/extractgen_requirements_turbo.md",
38
38
  },
39
39
  {
40
40
  "name": "deep",
@@ -42,7 +42,7 @@ TEMPLATES: dict[str, dict] = {
42
42
  "model": "claude-sonnet-4-20250514",
43
43
  "timeout": 900,
44
44
  "tools": ["web_search"],
45
- "prompt_template": "prompts/research_deep.md",
45
+ "prompt_template": "prompts/extractgen_requirements_deep.md",
46
46
  },
47
47
  ],
48
48
  "mode_selection": {
@@ -57,6 +57,43 @@ TEMPLATES: dict[str, dict] = {
57
57
  },
58
58
  },
59
59
  },
60
+ "webgen_requirements": {
61
+ "name": "webgen_requirements",
62
+ "display_name": "Web-Generated Requirements",
63
+ "description": "Discover missing requirements through web research on domain best practices",
64
+ "type": "generator",
65
+ "category": "discovery",
66
+ "config": {
67
+ "name": "webgen_requirements",
68
+ "display_name": "Web-Generated Requirements",
69
+ "type": "generator",
70
+ "description": "Research industry best practices to find requirements NOT in the design doc",
71
+ "item_types": {
72
+ "output": {
73
+ "singular": "story",
74
+ "plural": "stories",
75
+ "description": "User stories discovered through domain research",
76
+ }
77
+ },
78
+ "modes": [
79
+ {
80
+ "name": "research",
81
+ "description": "Web research for best practices and gaps",
82
+ "model": "claude-sonnet-4-20250514",
83
+ "timeout": 900,
84
+ "tools": ["web_search"],
85
+ "prompt_template": "prompts/webgen_requirements.md",
86
+ }
87
+ ],
88
+ "mode_selection": {"strategy": "fixed", "fixed_mode": "research"},
89
+ "limits": {
90
+ "max_iterations": 15,
91
+ "max_runtime_seconds": 14400,
92
+ "max_consecutive_errors": 3,
93
+ "cooldown_between_iterations": 15,
94
+ },
95
+ },
96
+ },
60
97
  "implementation": {
61
98
  "name": "implementation",
62
99
  "display_name": "Implementation Loop",
@@ -72,7 +109,7 @@ TEMPLATES: dict[str, dict] = {
72
109
  "input": {
73
110
  "singular": "story",
74
111
  "plural": "stories",
75
- "source": "research",
112
+ "source": "extractgen_requirements",
76
113
  "description": "Stories to implement",
77
114
  },
78
115
  "output": {
@@ -182,7 +219,7 @@ def get_template(name: str) -> Optional[dict]:
182
219
  """Get a template by name.
183
220
 
184
221
  Args:
185
- name: Template name (e.g., 'research', 'implementation')
222
+ name: Template name (e.g., 'extractgen_requirements', 'implementation')
186
223
 
187
224
  Returns:
188
225
  Template dict or None if not found
@@ -305,7 +305,7 @@ class WorkflowExecutor:
305
305
  config_path.write_text(config_yaml)
306
306
 
307
307
  # Create prompt files (required by the loop config)
308
- self._create_loop_prompt_files(loop_name, loop_type)
308
+ self._create_loop_prompt_files(loop_name, loop_type, step)
309
309
 
310
310
  # Load and return config
311
311
  loader = LoopLoader(db=self.db)
@@ -323,31 +323,61 @@ class WorkflowExecutor:
323
323
  loader = LoopLoader(db=self.db)
324
324
  return loader.get_loop(loop_record["name"])
325
325
 
326
- def _create_loop_prompt_files(self, loop_name: str, loop_type: str) -> None:
326
+ # Maximum allowed size for custom prompts (50KB)
327
+ MAX_CUSTOM_PROMPT_LENGTH = 50000
328
+
329
+ def _create_loop_prompt_files(
330
+ self, loop_name: str, loop_type: str, step: dict
331
+ ) -> None:
327
332
  """Create prompt template files for an auto-generated loop.
328
333
 
329
334
  The simple config generators reference prompt files that don't exist.
330
- This method creates those files with default content.
335
+ This method creates those files with default content, or uses a custom
336
+ prompt if specified in the step config.
331
337
 
332
338
  Args:
333
339
  loop_name: Name of the loop.
334
340
  loop_type: Type of loop (generator, consumer).
341
+ step: The step dict containing config (may include customPrompt or template).
335
342
  """
336
343
  from ralphx.core.loop_templates import (
337
344
  PLANNING_EXTRACT_PROMPT,
338
345
  IMPLEMENTATION_IMPLEMENT_PROMPT,
346
+ WEBGEN_REQUIREMENTS_PROMPT,
339
347
  )
340
348
 
341
349
  loop_dir = Path(self.project.path) / ".ralphx" / "loops" / loop_name
342
350
  prompts_dir = loop_dir / "prompts"
343
351
  prompts_dir.mkdir(parents=True, exist_ok=True)
344
352
 
353
+ # Check for custom prompt in step config
354
+ step_config = step.get("config", {})
355
+ custom_prompt = step_config.get("customPrompt")
356
+
357
+ # Validate custom prompt size if provided
358
+ if custom_prompt:
359
+ if len(custom_prompt) > self.MAX_CUSTOM_PROMPT_LENGTH:
360
+ raise ValueError(
361
+ f"Custom prompt exceeds maximum length of {self.MAX_CUSTOM_PROMPT_LENGTH} characters"
362
+ )
363
+ # Treat empty/whitespace-only as no custom prompt
364
+ if not custom_prompt.strip():
365
+ custom_prompt = None
366
+
367
+ # Determine prompt content and file based on template or loop_type
368
+ template = step_config.get("template")
369
+
345
370
  if loop_type == "generator":
346
371
  prompt_file = prompts_dir / "planning.md"
347
- prompt_content = PLANNING_EXTRACT_PROMPT
372
+ if custom_prompt:
373
+ prompt_content = custom_prompt
374
+ elif template == "webgen_requirements":
375
+ prompt_content = WEBGEN_REQUIREMENTS_PROMPT
376
+ else:
377
+ prompt_content = PLANNING_EXTRACT_PROMPT
348
378
  else:
349
379
  prompt_file = prompts_dir / "implement.md"
350
- prompt_content = IMPLEMENTATION_IMPLEMENT_PROMPT
380
+ prompt_content = custom_prompt or IMPLEMENTATION_IMPLEMENT_PROMPT
351
381
 
352
382
  prompt_file.write_text(prompt_content.strip())
353
383