ralphx 0.3.4__py3-none-any.whl → 0.4.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.
- ralphx/__init__.py +1 -1
- ralphx/adapters/base.py +10 -2
- ralphx/adapters/claude_cli.py +222 -82
- ralphx/api/routes/auth.py +780 -98
- ralphx/api/routes/config.py +3 -56
- ralphx/api/routes/export_import.py +6 -9
- ralphx/api/routes/loops.py +4 -4
- ralphx/api/routes/planning.py +882 -19
- ralphx/api/routes/resources.py +528 -6
- ralphx/api/routes/stream.py +58 -56
- ralphx/api/routes/templates.py +2 -2
- ralphx/api/routes/workflows.py +258 -47
- ralphx/cli.py +4 -1
- ralphx/core/auth.py +372 -172
- ralphx/core/database.py +588 -164
- ralphx/core/executor.py +170 -19
- ralphx/core/loop.py +15 -2
- ralphx/core/loop_templates.py +29 -3
- ralphx/core/planning_iteration_executor.py +633 -0
- ralphx/core/planning_service.py +119 -24
- ralphx/core/preview.py +9 -25
- ralphx/core/project_db.py +864 -121
- ralphx/core/project_export.py +1 -5
- ralphx/core/project_import.py +14 -29
- ralphx/core/resources.py +28 -2
- ralphx/core/sample_project.py +1 -5
- ralphx/core/templates.py +9 -9
- ralphx/core/workflow_executor.py +32 -3
- ralphx/core/workflow_export.py +4 -7
- ralphx/core/workflow_import.py +3 -27
- ralphx/mcp/__init__.py +6 -2
- ralphx/mcp/registry.py +3 -3
- ralphx/mcp/tools/diagnostics.py +1 -1
- ralphx/mcp/tools/monitoring.py +10 -16
- ralphx/mcp/tools/workflows.py +115 -33
- ralphx/mcp_server.py +6 -2
- ralphx/static/assets/index-BuLI7ffn.css +1 -0
- ralphx/static/assets/index-DWvlqOTb.js +264 -0
- ralphx/static/assets/index-DWvlqOTb.js.map +1 -0
- ralphx/static/index.html +2 -2
- ralphx/templates/loop_templates/consumer.md +2 -2
- {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/METADATA +33 -12
- {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/RECORD +45 -44
- ralphx/static/assets/index-CcRDyY3b.css +0 -1
- ralphx/static/assets/index-CcxfTosc.js +0 -251
- ralphx/static/assets/index-CcxfTosc.js.map +0 -1
- {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/WHEEL +0 -0
- {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/entry_points.txt +0 -0
ralphx/core/executor.py
CHANGED
|
@@ -23,7 +23,7 @@ from enum import Enum
|
|
|
23
23
|
from pathlib import Path
|
|
24
24
|
from typing import Any, AsyncIterator, Callable, Optional
|
|
25
25
|
|
|
26
|
-
from ralphx.adapters.base import ExecutionResult, LLMAdapter
|
|
26
|
+
from ralphx.adapters.base import AdapterEvent, ExecutionResult, LLMAdapter, StreamEvent
|
|
27
27
|
from ralphx.adapters.claude_cli import ClaudeCLIAdapter
|
|
28
28
|
from ralphx.core.dependencies import DependencyGraph, order_items_by_dependency
|
|
29
29
|
from ralphx.core.project_db import ProjectDatabase
|
|
@@ -47,6 +47,7 @@ class ExecutorEvent(str, Enum):
|
|
|
47
47
|
GIT_COMMIT = "git_commit" # Git commit after successful iteration
|
|
48
48
|
ERROR = "error"
|
|
49
49
|
WARNING = "warning"
|
|
50
|
+
INFO = "info"
|
|
50
51
|
HEARTBEAT = "heartbeat"
|
|
51
52
|
RUN_PAUSED = "run_paused"
|
|
52
53
|
RUN_RESUMED = "run_resumed"
|
|
@@ -123,6 +124,7 @@ class LoopExecutor:
|
|
|
123
124
|
batch_size: int = 10,
|
|
124
125
|
consume_from_step_id: Optional[int] = None,
|
|
125
126
|
architecture_first: bool = False,
|
|
127
|
+
context_from_steps: Optional[list[int]] = None,
|
|
126
128
|
):
|
|
127
129
|
"""Initialize the executor.
|
|
128
130
|
|
|
@@ -142,6 +144,8 @@ class LoopExecutor:
|
|
|
142
144
|
consume_from_step_id: For consumer loops, the step ID to consume items from.
|
|
143
145
|
architecture_first: If True, prioritize foundational stories (FND, DBM, SEC, ARC)
|
|
144
146
|
for new codebases. Batches them together for initial build.
|
|
147
|
+
context_from_steps: For generator loops, additional step IDs whose items
|
|
148
|
+
should be included as existing context (avoid duplicates).
|
|
145
149
|
"""
|
|
146
150
|
self.project = project
|
|
147
151
|
self.config = loop_config
|
|
@@ -149,6 +153,7 @@ class LoopExecutor:
|
|
|
149
153
|
self.workflow_id = workflow_id
|
|
150
154
|
self.step_id = step_id
|
|
151
155
|
self._consume_from_step_id = consume_from_step_id
|
|
156
|
+
self._context_from_steps = context_from_steps or []
|
|
152
157
|
# Create adapter with per-loop settings path for permission templates
|
|
153
158
|
# Pass project_id for credential lookup (project-scoped auth)
|
|
154
159
|
if adapter is None:
|
|
@@ -403,15 +408,24 @@ class LoopExecutor:
|
|
|
403
408
|
mode_name = list(modes.keys())[0]
|
|
404
409
|
return mode_name, modes[mode_name]
|
|
405
410
|
|
|
406
|
-
def _resolve_loop_resource_content(self, resource: dict) -> Optional[str]:
|
|
411
|
+
def _resolve_loop_resource_content(self, resource: dict, _depth: int = 0) -> Optional[str]:
|
|
407
412
|
"""Resolve content for a loop resource based on its source_type.
|
|
408
413
|
|
|
409
414
|
Args:
|
|
410
415
|
resource: Loop resource dict from database.
|
|
416
|
+
_depth: Internal recursion depth counter (max 5 to prevent cycles).
|
|
411
417
|
|
|
412
418
|
Returns:
|
|
413
419
|
Resolved content string, or None if unable to resolve.
|
|
414
420
|
"""
|
|
421
|
+
if _depth > 5:
|
|
422
|
+
import logging
|
|
423
|
+
logging.getLogger(__name__).warning(
|
|
424
|
+
f"[RESOLVE] Max recursion depth reached resolving loop resource "
|
|
425
|
+
f"(possible circular loop_ref). Resource: {resource.get('name', 'unknown')}"
|
|
426
|
+
)
|
|
427
|
+
return None
|
|
428
|
+
|
|
415
429
|
source_type = resource.get("source_type", "")
|
|
416
430
|
|
|
417
431
|
if source_type == "system":
|
|
@@ -425,22 +439,30 @@ class LoopExecutor:
|
|
|
425
439
|
return None
|
|
426
440
|
|
|
427
441
|
elif source_type == "project_file":
|
|
428
|
-
# Load from project file path
|
|
442
|
+
# Load from project file path (must stay within project directory)
|
|
429
443
|
source_path = resource.get("source_path")
|
|
430
444
|
if source_path:
|
|
431
|
-
file_path = Path(self.project.path) / source_path
|
|
445
|
+
file_path = (Path(self.project.path) / source_path).resolve()
|
|
446
|
+
project_root = Path(self.project.path).resolve()
|
|
447
|
+
# Prevent path traversal outside project directory
|
|
448
|
+
if not str(file_path).startswith(str(project_root) + "/") and file_path != project_root:
|
|
449
|
+
import logging
|
|
450
|
+
logging.getLogger(__name__).warning(
|
|
451
|
+
f"[RESOLVE] Path traversal blocked: {source_path} resolves outside project"
|
|
452
|
+
)
|
|
453
|
+
return None
|
|
432
454
|
if file_path.exists():
|
|
433
455
|
return file_path.read_text()
|
|
434
456
|
return None
|
|
435
457
|
|
|
436
458
|
elif source_type == "loop_ref":
|
|
437
|
-
# Load from another loop's resource (recursively)
|
|
459
|
+
# Load from another loop's resource (recursively, with depth limit)
|
|
438
460
|
source_loop = resource.get("source_loop")
|
|
439
461
|
source_resource_id = resource.get("source_resource_id")
|
|
440
462
|
if source_loop and source_resource_id:
|
|
441
463
|
source_resource = self.db.get_loop_resource(source_resource_id)
|
|
442
464
|
if source_resource:
|
|
443
|
-
return self._resolve_loop_resource_content(source_resource)
|
|
465
|
+
return self._resolve_loop_resource_content(source_resource, _depth=_depth + 1)
|
|
444
466
|
return None
|
|
445
467
|
|
|
446
468
|
elif source_type == "project_resource":
|
|
@@ -528,7 +550,7 @@ class LoopExecutor:
|
|
|
528
550
|
|
|
529
551
|
return "\n".join(sections)
|
|
530
552
|
|
|
531
|
-
def _load_prompt_template(self, mode: Mode) -> str:
|
|
553
|
+
def _load_prompt_template(self, mode: Mode, loop_resources: Optional[list[dict]] = None) -> str:
|
|
532
554
|
"""Load prompt template for a mode.
|
|
533
555
|
|
|
534
556
|
Priority order:
|
|
@@ -539,12 +561,14 @@ class LoopExecutor:
|
|
|
539
561
|
|
|
540
562
|
Args:
|
|
541
563
|
mode: Mode configuration.
|
|
564
|
+
loop_resources: Pre-loaded loop resources (avoids redundant DB query).
|
|
542
565
|
|
|
543
566
|
Returns:
|
|
544
567
|
Prompt template content.
|
|
545
568
|
"""
|
|
546
569
|
# Priority 1: Check for loop-level LOOP_TEMPLATE resource
|
|
547
|
-
loop_resources
|
|
570
|
+
if loop_resources is None:
|
|
571
|
+
loop_resources = self._load_loop_resources()
|
|
548
572
|
for resource in loop_resources:
|
|
549
573
|
if (resource.get("resource_type") == "loop_template" and
|
|
550
574
|
resource.get("injection_position") == "template_body"):
|
|
@@ -627,11 +651,12 @@ class LoopExecutor:
|
|
|
627
651
|
Returns:
|
|
628
652
|
Complete prompt with all resources and tracking marker.
|
|
629
653
|
"""
|
|
630
|
-
template = self._load_prompt_template(mode)
|
|
631
|
-
|
|
632
654
|
# Load loop-specific resources first (from loop_resources table)
|
|
655
|
+
# Pass to _load_prompt_template to avoid redundant DB query
|
|
633
656
|
loop_resources = self._load_loop_resources()
|
|
634
657
|
|
|
658
|
+
template = self._load_prompt_template(mode, loop_resources=loop_resources)
|
|
659
|
+
|
|
635
660
|
# Also load project-level resources as fallback
|
|
636
661
|
resource_manager = ResourceManager(self.project.path, db=self.db)
|
|
637
662
|
resource_set = resource_manager.load_for_loop(self.config, mode_name)
|
|
@@ -692,6 +717,17 @@ class LoopExecutor:
|
|
|
692
717
|
escaped_design_doc = self._escape_template_vars(design_doc_content)
|
|
693
718
|
template = template.replace("{DESIGN_DOC}", escaped_design_doc)
|
|
694
719
|
|
|
720
|
+
# Substitute {{design_doc}} (RalphX style) with actual design doc content
|
|
721
|
+
# Note: {{design_doc}} may have already been used as a position marker for
|
|
722
|
+
# after_design_doc resources above, but the marker text itself remains.
|
|
723
|
+
# Replace it with the actual design doc content (or empty string if none).
|
|
724
|
+
if "{{design_doc}}" in template:
|
|
725
|
+
if design_doc_content:
|
|
726
|
+
escaped_design_doc = self._escape_template_vars(design_doc_content)
|
|
727
|
+
template = template.replace("{{design_doc}}", escaped_design_doc)
|
|
728
|
+
else:
|
|
729
|
+
template = template.replace("{{design_doc}}", "")
|
|
730
|
+
|
|
695
731
|
# BEFORE_TASK: Insert before the main task instruction
|
|
696
732
|
# Look for {{task}} marker or insert near the end
|
|
697
733
|
if before_task:
|
|
@@ -701,10 +737,21 @@ class LoopExecutor:
|
|
|
701
737
|
# Append before the final section
|
|
702
738
|
template = template + "\n\n" + before_task
|
|
703
739
|
|
|
704
|
-
# AFTER_TASK: Append at the end
|
|
705
|
-
|
|
740
|
+
# AFTER_TASK: Append at the end, unless the template has a dedicated
|
|
741
|
+
# {{custom_context}} placeholder (which serves the same purpose)
|
|
742
|
+
if "{{custom_context}}" in template:
|
|
743
|
+
# Template has a designated place for custom context - substitute there
|
|
744
|
+
template = template.replace("{{custom_context}}", after_task or "")
|
|
745
|
+
elif after_task:
|
|
746
|
+
# No placeholder - append at end
|
|
706
747
|
template = template + "\n\n" + after_task
|
|
707
748
|
|
|
749
|
+
# Substitute {{backlog_status}} for hybrid loops
|
|
750
|
+
# Shows current item counts to help mode detection
|
|
751
|
+
if "{{backlog_status}}" in template:
|
|
752
|
+
backlog_status = self._build_backlog_status()
|
|
753
|
+
template = template.replace("{{backlog_status}}", backlog_status)
|
|
754
|
+
|
|
708
755
|
# Inject generator loop context (existing stories, category stats, inputs)
|
|
709
756
|
# This MUST happen before any variable substitution
|
|
710
757
|
if self._is_generator_loop():
|
|
@@ -763,9 +810,6 @@ class LoopExecutor:
|
|
|
763
810
|
template = template.replace("{{input_item.title}}", title)
|
|
764
811
|
template = template.replace("{{input_item}}", content) # Alias
|
|
765
812
|
template = template.replace("{{workflow_id}}", workflow_id_val)
|
|
766
|
-
# Backward compatibility for old templates using namespace/source_loop
|
|
767
|
-
template = template.replace("{{namespace}}", workflow_id_val)
|
|
768
|
-
template = template.replace("{{source_loop}}", workflow_id_val)
|
|
769
813
|
|
|
770
814
|
# hank-rcm style variables (for PROMPT_IMPL.md compatibility)
|
|
771
815
|
# Using {VAR} format to match ralph_impl.sh templates exactly
|
|
@@ -779,7 +823,7 @@ class LoopExecutor:
|
|
|
779
823
|
# Support both {{implemented_summary}} (RalphX style) and {IMPLEMENTED_SUMMARY} (hank-rcm style)
|
|
780
824
|
if self._is_consumer_loop():
|
|
781
825
|
if "{{implemented_summary}}" in template or "{IMPLEMENTED_SUMMARY}" in template:
|
|
782
|
-
impl_summary = self._build_implemented_summary()
|
|
826
|
+
impl_summary = self._escape_template_vars(self._build_implemented_summary())
|
|
783
827
|
template = template.replace("{{implemented_summary}}", impl_summary)
|
|
784
828
|
template = template.replace("{IMPLEMENTED_SUMMARY}", impl_summary)
|
|
785
829
|
|
|
@@ -810,8 +854,49 @@ class LoopExecutor:
|
|
|
810
854
|
)
|
|
811
855
|
template = template + marker
|
|
812
856
|
|
|
857
|
+
# Warn if prompt exceeds reasonable size (200K chars ~ 50K tokens)
|
|
858
|
+
prompt_size = len(template)
|
|
859
|
+
if prompt_size > 200_000:
|
|
860
|
+
self._emit_event(
|
|
861
|
+
ExecutorEvent.WARNING,
|
|
862
|
+
f"Prompt is very large ({prompt_size:,} chars, ~{prompt_size // 4:,} tokens). "
|
|
863
|
+
f"This may exceed the model's context window. Consider reducing resource sizes.",
|
|
864
|
+
)
|
|
865
|
+
|
|
813
866
|
return template
|
|
814
867
|
|
|
868
|
+
def _build_backlog_status(self) -> str:
|
|
869
|
+
"""Build backlog status summary for hybrid loop templates.
|
|
870
|
+
|
|
871
|
+
Shows the current state of work items to help the hybrid template
|
|
872
|
+
decide whether to generate new items or implement existing ones.
|
|
873
|
+
|
|
874
|
+
Returns:
|
|
875
|
+
Status string describing pending/completed item counts.
|
|
876
|
+
"""
|
|
877
|
+
try:
|
|
878
|
+
# Get all items for this workflow step
|
|
879
|
+
all_items, total = self.db.list_work_items(
|
|
880
|
+
workflow_id=self.workflow_id,
|
|
881
|
+
source_step_id=self.step_id,
|
|
882
|
+
limit=10000,
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
if total == 0:
|
|
886
|
+
return "Backlog is EMPTY. No work items exist yet."
|
|
887
|
+
|
|
888
|
+
# Count by status
|
|
889
|
+
pending = sum(1 for i in all_items if i.get("status") == "pending")
|
|
890
|
+
completed = sum(1 for i in all_items if i.get("status") in ("completed", "processed"))
|
|
891
|
+
in_progress = sum(1 for i in all_items if i.get("status") == "in_progress")
|
|
892
|
+
|
|
893
|
+
return (
|
|
894
|
+
f"Backlog has {total} items: "
|
|
895
|
+
f"{pending} pending, {in_progress} in progress, {completed} completed."
|
|
896
|
+
)
|
|
897
|
+
except Exception:
|
|
898
|
+
return "Backlog status unavailable."
|
|
899
|
+
|
|
815
900
|
def _is_consumer_loop(self) -> bool:
|
|
816
901
|
"""Check if this loop consumes items from another loop.
|
|
817
902
|
|
|
@@ -876,6 +961,15 @@ class LoopExecutor:
|
|
|
876
961
|
limit=10000, # Get all existing items
|
|
877
962
|
)
|
|
878
963
|
|
|
964
|
+
# Also include items from linked context steps (cross-step visibility)
|
|
965
|
+
for ctx_step_id in self._context_from_steps:
|
|
966
|
+
ctx_items, _ = self.db.list_work_items(
|
|
967
|
+
workflow_id=self.workflow_id,
|
|
968
|
+
source_step_id=ctx_step_id,
|
|
969
|
+
limit=10000,
|
|
970
|
+
)
|
|
971
|
+
existing_items.extend(ctx_items)
|
|
972
|
+
|
|
879
973
|
# 2. Build category stats with next available ID
|
|
880
974
|
category_stats: dict[str, dict] = {}
|
|
881
975
|
for item in existing_items:
|
|
@@ -923,10 +1017,13 @@ class LoopExecutor:
|
|
|
923
1017
|
json.dumps(category_stats, indent=2)
|
|
924
1018
|
)
|
|
925
1019
|
|
|
1020
|
+
# Escape inputs_list to prevent template injection from filenames
|
|
1021
|
+
inputs_list_escaped = self._escape_template_vars(inputs_list)
|
|
1022
|
+
|
|
926
1023
|
template = template.replace("{{existing_stories}}", existing_stories_json)
|
|
927
1024
|
template = template.replace("{{category_stats}}", category_stats_json)
|
|
928
1025
|
template = template.replace("{{total_stories}}", str(len(existing_items)))
|
|
929
|
-
template = template.replace("{{inputs_list}}",
|
|
1026
|
+
template = template.replace("{{inputs_list}}", inputs_list_escaped)
|
|
930
1027
|
|
|
931
1028
|
return template
|
|
932
1029
|
|
|
@@ -1640,9 +1737,13 @@ class LoopExecutor:
|
|
|
1640
1737
|
if self._is_consumer_loop() and not self._batch_mode:
|
|
1641
1738
|
json_schema = IMPLEMENTATION_STATUS_SCHEMA
|
|
1642
1739
|
|
|
1740
|
+
# Mutable holder for session_id (set by INIT event before other events)
|
|
1741
|
+
session_id_holder: list[Optional[str]] = [None]
|
|
1742
|
+
|
|
1643
1743
|
# Callback to register session immediately when it starts
|
|
1644
1744
|
# This enables live streaming in the UI before execution completes
|
|
1645
1745
|
def register_session_early(session_id: str) -> None:
|
|
1746
|
+
session_id_holder[0] = session_id
|
|
1646
1747
|
if self._run:
|
|
1647
1748
|
self.db.create_session(
|
|
1648
1749
|
session_id=session_id,
|
|
@@ -1652,6 +1753,55 @@ class LoopExecutor:
|
|
|
1652
1753
|
status="running",
|
|
1653
1754
|
)
|
|
1654
1755
|
|
|
1756
|
+
# Callback to persist events to DB for history/debugging
|
|
1757
|
+
def persist_event(event: StreamEvent) -> None:
|
|
1758
|
+
sid = session_id_holder[0]
|
|
1759
|
+
if not sid:
|
|
1760
|
+
return
|
|
1761
|
+
try:
|
|
1762
|
+
if event.type == AdapterEvent.INIT:
|
|
1763
|
+
self.db.add_session_event(
|
|
1764
|
+
session_id=sid,
|
|
1765
|
+
event_type="init",
|
|
1766
|
+
raw_data=event.data if event.data else None,
|
|
1767
|
+
)
|
|
1768
|
+
elif event.type == AdapterEvent.TEXT and event.text:
|
|
1769
|
+
self.db.add_session_event(
|
|
1770
|
+
session_id=sid,
|
|
1771
|
+
event_type="text",
|
|
1772
|
+
content=event.text,
|
|
1773
|
+
)
|
|
1774
|
+
elif event.type == AdapterEvent.TOOL_USE:
|
|
1775
|
+
self.db.add_session_event(
|
|
1776
|
+
session_id=sid,
|
|
1777
|
+
event_type="tool_call",
|
|
1778
|
+
tool_name=event.tool_name,
|
|
1779
|
+
tool_input=event.tool_input,
|
|
1780
|
+
)
|
|
1781
|
+
elif event.type == AdapterEvent.TOOL_RESULT:
|
|
1782
|
+
self.db.add_session_event(
|
|
1783
|
+
session_id=sid,
|
|
1784
|
+
event_type="tool_result",
|
|
1785
|
+
tool_name=event.tool_name,
|
|
1786
|
+
tool_result=event.tool_result[:1000] if event.tool_result else None,
|
|
1787
|
+
)
|
|
1788
|
+
elif event.type == AdapterEvent.ERROR:
|
|
1789
|
+
self.db.add_session_event(
|
|
1790
|
+
session_id=sid,
|
|
1791
|
+
event_type="error",
|
|
1792
|
+
error_message=event.error_message,
|
|
1793
|
+
)
|
|
1794
|
+
elif event.type == AdapterEvent.COMPLETE:
|
|
1795
|
+
self.db.add_session_event(
|
|
1796
|
+
session_id=sid,
|
|
1797
|
+
event_type="complete",
|
|
1798
|
+
)
|
|
1799
|
+
except Exception as exc:
|
|
1800
|
+
import logging
|
|
1801
|
+
logging.getLogger(__name__).debug(
|
|
1802
|
+
f"[PERSIST] Failed to persist {event.type} event for session {sid}: {exc}"
|
|
1803
|
+
)
|
|
1804
|
+
|
|
1655
1805
|
exec_result = await self.adapter.execute(
|
|
1656
1806
|
prompt=prompt,
|
|
1657
1807
|
model=mode.model,
|
|
@@ -1659,6 +1809,7 @@ class LoopExecutor:
|
|
|
1659
1809
|
timeout=mode.timeout,
|
|
1660
1810
|
json_schema=json_schema,
|
|
1661
1811
|
on_session_start=register_session_early,
|
|
1812
|
+
on_event=persist_event,
|
|
1662
1813
|
)
|
|
1663
1814
|
|
|
1664
1815
|
result.session_id = exec_result.session_id
|
|
@@ -1948,8 +2099,8 @@ class LoopExecutor:
|
|
|
1948
2099
|
generator_done = True
|
|
1949
2100
|
stop_reason = f"Generator signaled completion after {self._iteration} iterations, {self._items_generated} items"
|
|
1950
2101
|
|
|
1951
|
-
elif
|
|
1952
|
-
#
|
|
2102
|
+
elif self._no_items_streak >= 3:
|
|
2103
|
+
# 3 consecutive empty iterations — extraction is exhausted
|
|
1953
2104
|
generator_done = True
|
|
1954
2105
|
stop_reason = f"Generator exhausted (3 empty iterations), {self._items_generated} items generated"
|
|
1955
2106
|
|
ralphx/core/loop.py
CHANGED
|
@@ -188,11 +188,15 @@ class LoopLoader:
|
|
|
188
188
|
def register_loop(
|
|
189
189
|
self,
|
|
190
190
|
config: LoopConfig,
|
|
191
|
+
workflow_id: Optional[str] = None,
|
|
192
|
+
step_id: Optional[int] = None,
|
|
191
193
|
) -> str:
|
|
192
194
|
"""Register a loop configuration in the database.
|
|
193
195
|
|
|
194
196
|
Args:
|
|
195
197
|
config: Validated loop configuration.
|
|
198
|
+
workflow_id: Parent workflow ID. Required for workflow-first architecture.
|
|
199
|
+
step_id: Parent workflow step ID. Required for workflow-first architecture.
|
|
196
200
|
|
|
197
201
|
Returns:
|
|
198
202
|
The loop ID.
|
|
@@ -213,6 +217,8 @@ class LoopLoader:
|
|
|
213
217
|
id=loop_id,
|
|
214
218
|
name=config.name,
|
|
215
219
|
config_yaml=config.to_yaml(),
|
|
220
|
+
workflow_id=workflow_id,
|
|
221
|
+
step_id=step_id,
|
|
216
222
|
)
|
|
217
223
|
return loop_id
|
|
218
224
|
|
|
@@ -287,11 +293,18 @@ class LoopLoader:
|
|
|
287
293
|
|
|
288
294
|
return sorted(set(loop_files))
|
|
289
295
|
|
|
290
|
-
def sync_loops(
|
|
296
|
+
def sync_loops(
|
|
297
|
+
self,
|
|
298
|
+
project: Project,
|
|
299
|
+
workflow_id: Optional[str] = None,
|
|
300
|
+
step_id: Optional[int] = None,
|
|
301
|
+
) -> dict:
|
|
291
302
|
"""Sync loop configurations from project files to database.
|
|
292
303
|
|
|
293
304
|
Args:
|
|
294
305
|
project: Project to sync.
|
|
306
|
+
workflow_id: Parent workflow ID. Required for workflow-first architecture.
|
|
307
|
+
step_id: Parent workflow step ID. Required for workflow-first architecture.
|
|
295
308
|
|
|
296
309
|
Returns:
|
|
297
310
|
Dictionary with sync results (added, updated, removed counts).
|
|
@@ -312,7 +325,7 @@ class LoopLoader:
|
|
|
312
325
|
|
|
313
326
|
# Check if already exists
|
|
314
327
|
existing = self._require_db().get_loop(config.name)
|
|
315
|
-
self.register_loop(config)
|
|
328
|
+
self.register_loop(config, workflow_id=workflow_id, step_id=step_id)
|
|
316
329
|
|
|
317
330
|
if existing:
|
|
318
331
|
updated += 1
|
ralphx/core/loop_templates.py
CHANGED
|
@@ -178,6 +178,19 @@ Return your findings as JSON:
|
|
|
178
178
|
- Avoid duplicating stories that already exist
|
|
179
179
|
- Flag any ambiguous requirements for clarification
|
|
180
180
|
- Mark infrastructure/architecture stories with category "INFRA" or "ARCH"
|
|
181
|
+
|
|
182
|
+
## Completion Signal
|
|
183
|
+
|
|
184
|
+
When you have exhausted all stories from the documents and have nothing new to add,
|
|
185
|
+
return an empty stories array and output `[GENERATION_COMPLETE]` at the end of your response:
|
|
186
|
+
|
|
187
|
+
```json
|
|
188
|
+
{"stories": [], "notes": "All requirements have been extracted."}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
[GENERATION_COMPLETE]
|
|
192
|
+
|
|
193
|
+
Do NOT emit this signal if you are returning new stories in this response.
|
|
181
194
|
"""
|
|
182
195
|
|
|
183
196
|
PLANNING_RESEARCH_PROMPT = """# Research Mode
|
|
@@ -290,6 +303,19 @@ Total: {{total_stories}}
|
|
|
290
303
|
4. If web search returns nothing useful, return empty stories array with explanation
|
|
291
304
|
5. DO NOT duplicate existing stories - check IDs and titles carefully
|
|
292
305
|
6. Focus on GAPS - things genuinely missing, not rephrasing existing stories
|
|
306
|
+
|
|
307
|
+
## Completion Signal
|
|
308
|
+
|
|
309
|
+
When you have exhausted all gaps and have nothing new to add,
|
|
310
|
+
return an empty stories array and output `[GENERATION_COMPLETE]` at the end of your response:
|
|
311
|
+
|
|
312
|
+
```json
|
|
313
|
+
{"stories": [], "gaps_found": [], "notes": "All gaps have been addressed."}
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
[GENERATION_COMPLETE]
|
|
317
|
+
|
|
318
|
+
Do NOT emit this signal if you are returning new stories in this response.
|
|
293
319
|
"""
|
|
294
320
|
|
|
295
321
|
|
|
@@ -750,7 +776,7 @@ limits:
|
|
|
750
776
|
|
|
751
777
|
def generate_simple_implementation_config(
|
|
752
778
|
name: str,
|
|
753
|
-
|
|
779
|
+
source_loop: Optional[str] = None,
|
|
754
780
|
display_name: str = "Implementation",
|
|
755
781
|
description: str = "",
|
|
756
782
|
max_iterations: Optional[int] = None,
|
|
@@ -761,7 +787,7 @@ def generate_simple_implementation_config(
|
|
|
761
787
|
|
|
762
788
|
Args:
|
|
763
789
|
name: Unique loop ID (auto-generated, e.g., implementation-20260115_1).
|
|
764
|
-
|
|
790
|
+
source_loop: Source loop name to consume items from.
|
|
765
791
|
display_name: User-facing name (can be duplicated across loops).
|
|
766
792
|
description: Optional user-provided description.
|
|
767
793
|
max_iterations: Override for max iterations (default: 50).
|
|
@@ -771,7 +797,7 @@ def generate_simple_implementation_config(
|
|
|
771
797
|
Returns:
|
|
772
798
|
YAML configuration string.
|
|
773
799
|
"""
|
|
774
|
-
source_section = f" source: {
|
|
800
|
+
source_section = f" source: {source_loop}" if source_loop else ""
|
|
775
801
|
desc_line = description if description else "Implement user stories as working code"
|
|
776
802
|
|
|
777
803
|
# Apply defaults if not specified
|