teddy-cli 0.1.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 (143) hide show
  1. teddy_cli-0.1.0.dist-info/LICENSE +677 -0
  2. teddy_cli-0.1.0.dist-info/METADATA +33 -0
  3. teddy_cli-0.1.0.dist-info/RECORD +143 -0
  4. teddy_cli-0.1.0.dist-info/WHEEL +4 -0
  5. teddy_cli-0.1.0.dist-info/entry_points.txt +3 -0
  6. teddy_executor/__init__.py +1 -0
  7. teddy_executor/__main__.py +335 -0
  8. teddy_executor/adapters/__init__.py +0 -0
  9. teddy_executor/adapters/inbound/__init__.py +0 -0
  10. teddy_executor/adapters/inbound/cli_formatter.py +107 -0
  11. teddy_executor/adapters/inbound/cli_helpers.py +249 -0
  12. teddy_executor/adapters/inbound/console_plan_reviewer.py +69 -0
  13. teddy_executor/adapters/inbound/session_cli_handlers.py +366 -0
  14. teddy_executor/adapters/inbound/textual_plan_reviewer.py +78 -0
  15. teddy_executor/adapters/inbound/textual_plan_reviewer_app.py +367 -0
  16. teddy_executor/adapters/inbound/textual_plan_reviewer_editor.py +281 -0
  17. teddy_executor/adapters/inbound/textual_plan_reviewer_execution.py +213 -0
  18. teddy_executor/adapters/inbound/textual_plan_reviewer_helpers.py +308 -0
  19. teddy_executor/adapters/inbound/textual_plan_reviewer_logic.py +345 -0
  20. teddy_executor/adapters/inbound/textual_plan_reviewer_previews.py +227 -0
  21. teddy_executor/adapters/inbound/textual_plan_reviewer_widgets.py +246 -0
  22. teddy_executor/adapters/outbound/__init__.py +7 -0
  23. teddy_executor/adapters/outbound/console_interactor.py +212 -0
  24. teddy_executor/adapters/outbound/console_interactor_ask_loop.py +121 -0
  25. teddy_executor/adapters/outbound/console_interactor_helpers.py +95 -0
  26. teddy_executor/adapters/outbound/console_tooling.py +62 -0
  27. teddy_executor/adapters/outbound/filesystem_helpers.py +61 -0
  28. teddy_executor/adapters/outbound/litellm_adapter.py +462 -0
  29. teddy_executor/adapters/outbound/local_file_system_adapter.py +300 -0
  30. teddy_executor/adapters/outbound/local_repo_tree_generator.py +96 -0
  31. teddy_executor/adapters/outbound/openrouter_hydrator.py +89 -0
  32. teddy_executor/adapters/outbound/shell_adapter.py +344 -0
  33. teddy_executor/adapters/outbound/shell_command_builder.py +105 -0
  34. teddy_executor/adapters/outbound/system_environment_adapter.py +62 -0
  35. teddy_executor/adapters/outbound/system_environment_inspector.py +54 -0
  36. teddy_executor/adapters/outbound/system_time_adapter.py +22 -0
  37. teddy_executor/adapters/outbound/web_scraper_adapter.py +346 -0
  38. teddy_executor/adapters/outbound/web_searcher_adapter.py +122 -0
  39. teddy_executor/adapters/outbound/yaml_config_adapter.py +105 -0
  40. teddy_executor/container.py +333 -0
  41. teddy_executor/core/__init__.py +0 -0
  42. teddy_executor/core/domain/__init__.py +0 -0
  43. teddy_executor/core/domain/models/__init__.py +44 -0
  44. teddy_executor/core/domain/models/action_ports.py +28 -0
  45. teddy_executor/core/domain/models/change_set.py +10 -0
  46. teddy_executor/core/domain/models/exceptions.py +40 -0
  47. teddy_executor/core/domain/models/execution_report.py +65 -0
  48. teddy_executor/core/domain/models/orchestrator_ports.py +26 -0
  49. teddy_executor/core/domain/models/plan.py +85 -0
  50. teddy_executor/core/domain/models/planning_ports.py +43 -0
  51. teddy_executor/core/domain/models/project_context.py +56 -0
  52. teddy_executor/core/domain/models/report_assembly_data.py +18 -0
  53. teddy_executor/core/domain/models/session.py +17 -0
  54. teddy_executor/core/domain/models/shell_output.py +12 -0
  55. teddy_executor/core/domain/models/web_search_results.py +26 -0
  56. teddy_executor/core/ports/__init__.py +0 -0
  57. teddy_executor/core/ports/inbound/__init__.py +0 -0
  58. teddy_executor/core/ports/inbound/edit_simulator.py +33 -0
  59. teddy_executor/core/ports/inbound/get_context_use_case.py +32 -0
  60. teddy_executor/core/ports/inbound/init.py +15 -0
  61. teddy_executor/core/ports/inbound/plan_parser.py +52 -0
  62. teddy_executor/core/ports/inbound/plan_reviewer.py +44 -0
  63. teddy_executor/core/ports/inbound/plan_validator.py +26 -0
  64. teddy_executor/core/ports/inbound/planning_use_case.py +30 -0
  65. teddy_executor/core/ports/inbound/run_plan_use_case.py +60 -0
  66. teddy_executor/core/ports/outbound/__init__.py +34 -0
  67. teddy_executor/core/ports/outbound/config_service.py +29 -0
  68. teddy_executor/core/ports/outbound/environment_inspector.py +30 -0
  69. teddy_executor/core/ports/outbound/execution_report_assembler.py +19 -0
  70. teddy_executor/core/ports/outbound/file_system_manager.py +131 -0
  71. teddy_executor/core/ports/outbound/llm_client.py +90 -0
  72. teddy_executor/core/ports/outbound/markdown_report_formatter.py +26 -0
  73. teddy_executor/core/ports/outbound/prompt_manager.py +55 -0
  74. teddy_executor/core/ports/outbound/repo_tree_generator.py +17 -0
  75. teddy_executor/core/ports/outbound/session_loop_guard.py +16 -0
  76. teddy_executor/core/ports/outbound/session_manager.py +97 -0
  77. teddy_executor/core/ports/outbound/session_repository.py +65 -0
  78. teddy_executor/core/ports/outbound/shell_executor.py +24 -0
  79. teddy_executor/core/ports/outbound/system_environment.py +25 -0
  80. teddy_executor/core/ports/outbound/time_service.py +28 -0
  81. teddy_executor/core/ports/outbound/user_interactor.py +126 -0
  82. teddy_executor/core/ports/outbound/web_scraper.py +24 -0
  83. teddy_executor/core/ports/outbound/web_searcher.py +25 -0
  84. teddy_executor/core/services/__init__.py +0 -0
  85. teddy_executor/core/services/action_changeset_builder.py +90 -0
  86. teddy_executor/core/services/action_diff_manager.py +110 -0
  87. teddy_executor/core/services/action_dispatcher.py +142 -0
  88. teddy_executor/core/services/action_executor.py +209 -0
  89. teddy_executor/core/services/action_factory.py +197 -0
  90. teddy_executor/core/services/action_parser_complex.py +216 -0
  91. teddy_executor/core/services/action_parser_strategies.py +84 -0
  92. teddy_executor/core/services/context_service.py +437 -0
  93. teddy_executor/core/services/edit_simulator.py +128 -0
  94. teddy_executor/core/services/execution_orchestrator.py +295 -0
  95. teddy_executor/core/services/execution_report_assembler.py +62 -0
  96. teddy_executor/core/services/init_service.py +80 -0
  97. teddy_executor/core/services/markdown_plan_parser.py +309 -0
  98. teddy_executor/core/services/markdown_report_formatter.py +143 -0
  99. teddy_executor/core/services/parser_infrastructure.py +222 -0
  100. teddy_executor/core/services/parser_metadata.py +153 -0
  101. teddy_executor/core/services/parser_reporting.py +267 -0
  102. teddy_executor/core/services/plan_validator.py +82 -0
  103. teddy_executor/core/services/planning_service.py +242 -0
  104. teddy_executor/core/services/prompt_manager.py +146 -0
  105. teddy_executor/core/services/session_lifecycle_manager.py +228 -0
  106. teddy_executor/core/services/session_loop_guard.py +46 -0
  107. teddy_executor/core/services/session_orchestrator.py +538 -0
  108. teddy_executor/core/services/session_planner.py +43 -0
  109. teddy_executor/core/services/session_pruning_service.py +438 -0
  110. teddy_executor/core/services/session_replanner.py +105 -0
  111. teddy_executor/core/services/session_repository.py +194 -0
  112. teddy_executor/core/services/session_service.py +529 -0
  113. teddy_executor/core/services/templates/execution_report.md.j2 +290 -0
  114. teddy_executor/core/services/validation_rules/__init__.py +4 -0
  115. teddy_executor/core/services/validation_rules/edit.py +207 -0
  116. teddy_executor/core/services/validation_rules/edit_matcher.py +247 -0
  117. teddy_executor/core/services/validation_rules/edit_matcher_heuristics.py +84 -0
  118. teddy_executor/core/services/validation_rules/execute.py +37 -0
  119. teddy_executor/core/services/validation_rules/filesystem.py +73 -0
  120. teddy_executor/core/services/validation_rules/helpers.py +178 -0
  121. teddy_executor/core/services/validation_rules/message.py +29 -0
  122. teddy_executor/core/utils/__init__.py +1 -0
  123. teddy_executor/core/utils/diff.py +57 -0
  124. teddy_executor/core/utils/io.py +75 -0
  125. teddy_executor/core/utils/markdown.py +131 -0
  126. teddy_executor/core/utils/serialization.py +39 -0
  127. teddy_executor/core/utils/string.py +351 -0
  128. teddy_executor/prompts.py +45 -0
  129. teddy_executor/registries/__init__.py +1 -0
  130. teddy_executor/registries/infrastructure.py +147 -0
  131. teddy_executor/registries/reviewer.py +57 -0
  132. teddy_executor/registries/validators.py +47 -0
  133. teddy_executor/resources/__init__.py +1 -0
  134. teddy_executor/resources/config/.gitignore +2 -0
  135. teddy_executor/resources/config/__init__.py +1 -0
  136. teddy_executor/resources/config/config.yaml +49 -0
  137. teddy_executor/resources/config/init.context +5 -0
  138. teddy_executor/resources/config/prompts/architect.xml +462 -0
  139. teddy_executor/resources/config/prompts/assistant.xml +336 -0
  140. teddy_executor/resources/config/prompts/debugger.xml +456 -0
  141. teddy_executor/resources/config/prompts/developer.xml +481 -0
  142. teddy_executor/resources/config/prompts/pathfinder.xml +502 -0
  143. teddy_executor/resources/config/prompts/prototyper.xml +425 -0
@@ -0,0 +1,538 @@
1
+ import logging
2
+ from pathlib import Path
3
+ from typing import Any, Optional, cast
4
+
5
+ from teddy_executor.core.domain.models.execution_report import (
6
+ ExecutionReport,
7
+ )
8
+ from teddy_executor.core.services.parser_reporting import (
9
+ format_hybrid_ast_view,
10
+ )
11
+ from teddy_executor.core.domain.models.plan import Plan
12
+ from teddy_executor.core.ports.inbound.run_plan_use_case import IRunPlanUseCase
13
+ from teddy_executor.core.ports.outbound.file_system_manager import IFileSystemManager
14
+ from teddy_executor.core.services.session_replanner import SessionReplanner
15
+ from teddy_executor.core.utils.io import Tee as _Tee
16
+ import typer
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def _extract_status_emoji(raw_status: str) -> str:
22
+ """Extract the first status emoji (🟢, 🟡, 🔴) from a status string."""
23
+ for emoji in ("🟢", "🟡", "🔴"):
24
+ if emoji in raw_status:
25
+ return emoji
26
+ return ""
27
+
28
+
29
+ def _print_initial_request(
30
+ message: Optional[str], is_session: bool, plan_path: Optional[str] = None
31
+ ) -> None:
32
+ """Print the initial user request before the turn header.
33
+
34
+ If message is not provided (None) and plan_path is set, falls back to
35
+ reading '<session_root>/initial_request.md' from the filesystem.
36
+
37
+ Only prints when is_session=True and message is non-empty.
38
+ Output::
39
+ Initial Request:
40
+ {content}
41
+ (blank line separator)
42
+ """
43
+ if not is_session:
44
+ return
45
+ # Resolve message from file if not provided
46
+ if not message or not message.strip():
47
+ if plan_path:
48
+ try:
49
+ import_path = Path(plan_path).parent / "initial_request.md"
50
+ if import_path.exists():
51
+ content = import_path.read_text(encoding="utf-8").strip()
52
+ if content:
53
+ message = content
54
+ except Exception:
55
+ pass
56
+ if not message or not message.strip():
57
+ return
58
+ typer.secho("")
59
+ typer.secho("Initial Request:")
60
+ typer.secho(message.strip())
61
+
62
+
63
+ def _print_header_bar(plan: Any, is_session: bool) -> None:
64
+ """Print the plan status emoji and title after telemetry, before actions.
65
+
66
+ Only prints when is_session=True.
67
+ Output: {emoji} {title} (no blank lines around it)
68
+ """
69
+ if not is_session:
70
+ return
71
+ # Guard against mock objects or non-standard plan-like objects
72
+ metadata = getattr(plan, "metadata", {})
73
+ if not isinstance(metadata, dict):
74
+ metadata = {}
75
+ raw_status = metadata.get("Status") or metadata.get("status") or ""
76
+ emoji = _extract_status_emoji(raw_status)
77
+ title = getattr(plan, "title", None)
78
+ if not isinstance(title, str):
79
+ title = ""
80
+ parts = [p for p in [emoji, title] if p]
81
+ if parts:
82
+ typer.secho(" ".join(parts))
83
+
84
+
85
+ def _print_user_message(
86
+ message: Optional[str], is_session: bool, plan: Optional[Any] = None
87
+ ) -> None:
88
+ """Print the user message after all actions execute.
89
+
90
+ If message is not provided (None) and plan is set, falls back to
91
+ plan.metadata.get("user_request").
92
+
93
+ Only prints when is_session=True and message is non-empty.
94
+ Output::
95
+ (blank line)
96
+ User Message:
97
+ {content}
98
+ (trailing newline)
99
+ """
100
+ if not is_session:
101
+ return
102
+ # Resolve message from plan metadata if not provided
103
+ if not message or not message.strip():
104
+ if plan:
105
+ meta_msg = getattr(plan, "metadata", {}).get("user_request") or ""
106
+ if meta_msg.strip():
107
+ message = meta_msg.strip()
108
+ if not message or not message.strip():
109
+ return
110
+ typer.secho("")
111
+ typer.secho("User Message:")
112
+ typer.secho(message.strip())
113
+
114
+
115
+ class SessionOrchestrator(IRunPlanUseCase):
116
+ """
117
+ A wrapper service implementing the 'Turn Transition Algorithm'
118
+ around the base execution logic.
119
+ """
120
+
121
+ def __init__( # noqa: PLR0913
122
+ self,
123
+ execution_orchestrator,
124
+ session_service,
125
+ file_system_manager: IFileSystemManager,
126
+ plan_validator,
127
+ plan_parser,
128
+ user_interactor,
129
+ lifecycle_manager,
130
+ replanner: SessionReplanner,
131
+ context_service,
132
+ config_service,
133
+ llm_client,
134
+ prompt_manager,
135
+ pruning_service=None,
136
+ ):
137
+ self._execution_orchestrator = execution_orchestrator
138
+ self._session_service = session_service
139
+ self._file_system_manager = file_system_manager
140
+ self._plan_validator = plan_validator
141
+ self._plan_parser = plan_parser
142
+ self._user_interactor = user_interactor
143
+ self._lifecycle_manager = lifecycle_manager
144
+ self._replanner = replanner
145
+ self._context_service = context_service
146
+ self._config_service = config_service
147
+ self._llm_client = llm_client
148
+ self._prompt_manager = prompt_manager
149
+ self._pruning_service = pruning_service
150
+
151
+ def resume(
152
+ self,
153
+ session_name: str,
154
+ interactive: bool = True,
155
+ project_context: Optional[Any] = None,
156
+ ):
157
+ """
158
+ Implements the 'resume' state machine.
159
+ """
160
+ return self._lifecycle_manager.resume(
161
+ session_name,
162
+ self,
163
+ interactive,
164
+ project_context=project_context,
165
+ )
166
+
167
+ def execute( # noqa: PLR0913, C901
168
+ self,
169
+ plan: Optional[Plan] = None,
170
+ plan_content: Optional[str] = None,
171
+ plan_path: Optional[str] = None,
172
+ interactive: bool = True,
173
+ message: Optional[str] = None,
174
+ project_context: Optional[Any] = None,
175
+ ) -> ExecutionReport:
176
+ # Empty message signals session termination (no report.md created).
177
+ if message is not None and not message.strip():
178
+ return None # type: ignore
179
+
180
+ # 0. Detect Session Mode (requires plan_path and meta.yaml)
181
+ is_session = self._is_session_mode(plan_path)
182
+
183
+ # Install Tee for history.log capture (guarded: skip if lifecycle manager already installed)
184
+ _tee = None
185
+ if is_session and plan_path and not self._lifecycle_manager.tee_active:
186
+ try:
187
+ _log_path = str(Path(plan_path).parent.parent / "history.log")
188
+ # Defensive guard: ensure history.log is never written to project root.
189
+ # If resolved path equals project root or CWD, redirect to .tmp/.
190
+ _resolved = str(Path(_log_path).resolve())
191
+ _project_root = str(Path.cwd().resolve())
192
+ if _resolved.rstrip("/") == _project_root.rstrip("/"):
193
+ _safe_dir = str(Path(plan_path).parent.parent / ".tmp")
194
+ self._file_system_manager.create_directory(_safe_dir)
195
+ _log_path = str(Path(_safe_dir) / "history.log")
196
+ _log_file = self._file_system_manager.open_file_for_append(_log_path)
197
+ _tee = _Tee(_log_file)
198
+ _tee.__enter__()
199
+ except Exception:
200
+ _tee = None
201
+
202
+ try:
203
+ # 1. Resolve Plan (Parse only)
204
+ result = self._prepare_plan_parsing(
205
+ plan, plan_content, plan_path, is_session
206
+ )
207
+ if isinstance(result, ExecutionReport):
208
+ return result
209
+ plan = result
210
+
211
+ # 2. Context Preparation (Gather, Prune, Harvest)
212
+ # We must harvest context BEFORE validation so that pruned paths persist across replans
213
+ if is_session and plan_path and not project_context:
214
+ context_files = self._session_service.resolve_context_paths(plan_path)
215
+ agent_name = (
216
+ plan.metadata.get("Agent")
217
+ or plan.metadata.get("agent")
218
+ or (
219
+ self._lifecycle_manager.get_agent_name(plan_path)
220
+ if hasattr(self._lifecycle_manager, "get_agent_name")
221
+ else "Unknown"
222
+ )
223
+ )
224
+ total_window = self._llm_client.get_context_window()
225
+
226
+ cache_dir = str(Path(plan_path).parent.parent)
227
+ # Compute system prompt token count BEFORE context construction so the
228
+ # ProjectContext DTO is born with correct data (no post-hoc patching needed).
229
+ system_prompt = self._prompt_manager.fetch_system_prompt(
230
+ agent_name, Path(plan_path).parent
231
+ )
232
+ model = str(self._config_service.get_setting("llm.model") or "")
233
+ try:
234
+ system_token_count = self._llm_client.get_text_token_count(
235
+ system_prompt, model=model
236
+ )
237
+ except Exception:
238
+ system_token_count = 0
239
+
240
+ project_context = self._context_service.get_context(
241
+ context_files=context_files,
242
+ agent_name=agent_name,
243
+ total_window=total_window,
244
+ cache_dir=cache_dir,
245
+ system_prompt_tokens=system_token_count,
246
+ )
247
+ from dataclasses import is_dataclass
248
+
249
+ if (
250
+ is_dataclass(project_context)
251
+ and agent_name != project_context.agent_name
252
+ ):
253
+ from dataclasses import replace
254
+
255
+ project_context = replace(
256
+ cast(Any, project_context),
257
+ agent_name=agent_name,
258
+ )
259
+ if self._pruning_service:
260
+ status = plan.metadata.get("Status") if plan else None
261
+ project_context = self._pruning_service.prune(
262
+ project_context, current_status=status
263
+ )
264
+
265
+ self._harvest_context(
266
+ is_session=is_session,
267
+ project_context=project_context,
268
+ plan=plan,
269
+ )
270
+
271
+ # 3. Validation (passing refined context)
272
+ result = self._validate_plan_with_context(
273
+ plan, plan_path, is_session, project_context
274
+ )
275
+ if isinstance(result, ExecutionReport):
276
+ return result
277
+
278
+ # Print header bar (emoji + title) before execution logs (only for session mode)
279
+ if is_session:
280
+ _print_header_bar(plan, is_session)
281
+
282
+ # 4. Execution
283
+ report = self._execution_orchestrator.execute(
284
+ plan=plan,
285
+ plan_content=plan_content,
286
+ plan_path=plan_path,
287
+ interactive=interactive,
288
+ message=message,
289
+ project_context=project_context,
290
+ )
291
+
292
+ # 4a. Empty user reply after communication turn → terminate session immediately (no report.md)
293
+ if report and plan.is_communication_turn():
294
+ user_reply = next(
295
+ (
296
+ log.details
297
+ for log in report.action_logs
298
+ if log.action_type == "MESSAGE"
299
+ ),
300
+ None,
301
+ )
302
+ if user_reply is not None and not user_reply.strip():
303
+ import typer
304
+
305
+ typer.secho("\nSession terminated.", fg=typer.colors.RED, err=True)
306
+ typer.secho(
307
+ "To continue the session, use `teddy resume [session_path]`.",
308
+ err=True,
309
+ )
310
+ return None # type: ignore
311
+
312
+ # Print user message after all actions executed (only for session mode)
313
+ # Uses plan.metadata["user_request"] which is populated by _dispatch_single_action
314
+ # when the user provides a reply during execution.
315
+ if is_session:
316
+ user_reply = plan.metadata.get("user_request", "") if plan else ""
317
+ _print_user_message(user_reply, is_session, plan=plan)
318
+
319
+ # 4. Turn Transition
320
+ if is_session and plan_path:
321
+ report = self._handle_aborted_session(report, plan)
322
+ if report is None:
323
+ import typer
324
+
325
+ typer.secho("\nSession terminated.", fg=typer.colors.RED, err=True)
326
+ typer.secho(
327
+ "To continue the session, use `teddy resume [session_path]`.",
328
+ err=True,
329
+ )
330
+ return None # type: ignore
331
+
332
+ self._lifecycle_manager.finalize_turn(plan_path, report, plan=plan)
333
+
334
+ return report
335
+
336
+ finally:
337
+ if _tee is not None:
338
+ try:
339
+ _tee.__exit__(None, None, None)
340
+ except Exception:
341
+ logger.exception("Failed to clean up Tee during session execute")
342
+
343
+ def _harvest_context(
344
+ self,
345
+ is_session: bool,
346
+ project_context: Optional[Any],
347
+ plan: Plan,
348
+ ) -> None:
349
+ """Harvests unselected context paths."""
350
+ if is_session and project_context:
351
+ if hasattr(project_context, "items") and project_context.items:
352
+ pruned_paths = [
353
+ item.path for item in project_context.items if not item.selected
354
+ ]
355
+ if pruned_paths:
356
+ plan.metadata["pruned_context"] = ",".join(pruned_paths)
357
+ else:
358
+ plan.metadata.pop("pruned_context", None)
359
+
360
+ def _handle_aborted_session(
361
+ self, report: ExecutionReport, plan: Optional[Plan]
362
+ ) -> ExecutionReport:
363
+ """Handles user interaction and metadata updates when a session is aborted."""
364
+ from dataclasses import replace
365
+ from teddy_executor.core.domain.models import RunStatus
366
+
367
+ if report.run_summary.status != RunStatus.ABORTED:
368
+ return report
369
+
370
+ import typer
371
+
372
+ typer.secho("Plan aborted by user.", fg=typer.colors.YELLOW, err=True)
373
+
374
+ # We always prompt for a NEW message when a plan is aborted,
375
+ # unless one was explicitly captured during the abort process itself
376
+ # (e.g. via the 'm' key in TUI). Pre-existing turn messages are ignored.
377
+ new_message = self._user_interactor.ask_question(
378
+ "Plan aborted. How do you want to proceed?"
379
+ )
380
+
381
+ # If still empty after potential prompt, return None to signal session termination.
382
+ if not new_message:
383
+ return None # type: ignore
384
+
385
+ # Update metadata (dict is mutable even in frozen dataclass)
386
+ report.metadata["user_request"] = new_message
387
+ # Replace report to include user_request so it shows in report.md header
388
+ updated_report = replace(report, user_request=new_message)
389
+ # Update plan metadata as well
390
+ if plan:
391
+ plan.metadata["user_request"] = new_message
392
+
393
+ return updated_report
394
+
395
+ def _prepare_plan_parsing(
396
+ self,
397
+ plan: Optional[Plan],
398
+ plan_content: Optional[str],
399
+ plan_path: Optional[str],
400
+ is_session: bool,
401
+ ) -> Plan | ExecutionReport:
402
+ """Handles parsing only, potentially triggering a replan on structural error."""
403
+ if plan:
404
+ plan.is_session = is_session
405
+
406
+ # Defensive check for missing plan file
407
+ if plan_path and not plan_content:
408
+ if not self._file_system_manager.path_exists(plan_path):
409
+ error_msg = f"Plan file not found: {plan_path}"
410
+ if is_session:
411
+ return self._lifecycle_manager.trigger_replan(
412
+ plan_path=plan_path,
413
+ errors=[error_msg],
414
+ original_plan_content="",
415
+ )
416
+ return self._replanner.build_failure_report(
417
+ errors=[error_msg],
418
+ title="Missing Plan",
419
+ rationale="The plan file could not be found on disk.",
420
+ failed_resources={},
421
+ )
422
+
423
+ content = plan_content or (
424
+ self._file_system_manager.read_file(plan_path) if plan_path else ""
425
+ )
426
+ if not plan:
427
+ try:
428
+ plan = self._plan_parser.parse(content, plan_path=plan_path)
429
+ except Exception as e:
430
+ if is_session and plan_path:
431
+ return self._lifecycle_manager.trigger_replan(
432
+ plan_path=plan_path,
433
+ errors=[f"Structural error: {str(e)}"],
434
+ original_plan_content=content,
435
+ )
436
+ raise
437
+ return plan
438
+
439
+ def _validate_plan_with_context(
440
+ self,
441
+ plan: Plan,
442
+ plan_path: Optional[str],
443
+ is_session: bool,
444
+ project_context: Optional[Any],
445
+ ) -> Plan | ExecutionReport:
446
+ """Handles logical validation with optional refined context."""
447
+ context_paths = None
448
+ if is_session and plan_path:
449
+ # Prefer active project context (respecting pruning) if available
450
+ if project_context and hasattr(project_context, "items"):
451
+ context_paths = {
452
+ "Session": [], # Pruning logic already applied
453
+ "Turn": [
454
+ item.path for item in project_context.items if item.selected
455
+ ],
456
+ }
457
+ else:
458
+ context_paths = self._session_service.resolve_context_paths(plan_path)
459
+
460
+ errors = self._plan_validator.validate(plan, context_paths=context_paths)
461
+ if errors:
462
+ content = (
463
+ self._file_system_manager.read_file(plan_path) if plan_path else ""
464
+ )
465
+ return self._handle_logical_validation_errors(
466
+ plan, errors, content, plan_path, is_session
467
+ )
468
+
469
+ return plan
470
+
471
+ def _is_session_mode(self, plan_path: Optional[str]) -> bool:
472
+ """Determines if the orchestrator should operate in Session Mode."""
473
+ if not plan_path:
474
+ return False
475
+ meta_path = Path(plan_path).parent / "meta.yaml"
476
+ return self._file_system_manager.path_exists(str(meta_path))
477
+
478
+ def _parse_and_handle_structural_errors(
479
+ self, content: str, plan_path: Optional[str], is_session: bool
480
+ ) -> Plan:
481
+ """Parses the plan and triggers a replan on structural failure."""
482
+ try:
483
+ return self._plan_parser.parse(content, plan_path=plan_path)
484
+ except Exception as e:
485
+ if is_session and plan_path:
486
+ # Ensure the rich diagnostic is visible to the user
487
+ self._user_interactor.display_message(str(e))
488
+ self._lifecycle_manager.trigger_replan(
489
+ plan_path=plan_path,
490
+ errors=[f"Structural error: {str(e)}"],
491
+ original_plan_content=content,
492
+ )
493
+ # Re-planning is already handled by trigger_replan
494
+ raise RuntimeError(
495
+ "Structural validation failed. Re-plan triggered."
496
+ ) from e
497
+ raise
498
+
499
+ def _handle_logical_validation_errors( # noqa: PLR0913
500
+ self,
501
+ plan: Plan,
502
+ errors: list[Any],
503
+ content: str,
504
+ plan_path: Optional[str],
505
+ is_session: bool,
506
+ ) -> ExecutionReport:
507
+ """Formats logical errors and handles the failure report/replan."""
508
+ rich_ast = (
509
+ format_hybrid_ast_view(plan.source_doc, errors) if plan.source_doc else ""
510
+ )
511
+ error_messages = [e.message for e in errors]
512
+
513
+ failed_resources = self._replanner.gather_failed_resources(
514
+ errors, is_session=is_session
515
+ )
516
+
517
+ if is_session and plan_path:
518
+ return self._lifecycle_manager.trigger_replan(
519
+ plan_path=plan_path,
520
+ errors=error_messages,
521
+ original_plan_content=content,
522
+ title=plan.title,
523
+ rationale=plan.rationale,
524
+ failed_resources=failed_resources,
525
+ validation_ast=rich_ast,
526
+ original_actions=plan.actions,
527
+ plan=plan,
528
+ is_session=is_session,
529
+ )
530
+
531
+ return self._replanner.build_failure_report(
532
+ errors=error_messages,
533
+ title=plan.title,
534
+ rationale=plan.rationale,
535
+ failed_resources=failed_resources,
536
+ validation_ast=rich_ast,
537
+ original_actions=plan.actions,
538
+ )
@@ -0,0 +1,43 @@
1
+ import logging
2
+ from pathlib import Path
3
+ from typing import Optional
4
+
5
+ from teddy_executor.core.ports.outbound.file_system_manager import IFileSystemManager
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class SessionPlanner:
11
+ """Handles interactive turn planning and dynamic session renaming."""
12
+
13
+ def __init__(
14
+ self,
15
+ file_system_manager: IFileSystemManager,
16
+ planning_service,
17
+ user_interactor,
18
+ session_service,
19
+ ):
20
+ self._file_system_manager = file_system_manager
21
+ self._planning_service = planning_service
22
+ self._user_interactor = user_interactor
23
+ self._session_service = session_service
24
+
25
+ def trigger_new_plan(
26
+ self, turn_dir: str, message: Optional[str] = None
27
+ ) -> Optional[str]:
28
+ """Prompts user and triggers planning. Returns session name on success."""
29
+ # Note: PlanningService.generate_plan handles tiered message resolution
30
+ # via PromptManager (CLI -> initial_request.md -> Prompt).
31
+ resolved_message = message
32
+ # We pass it to generate_plan which handles the resolution and hint.
33
+
34
+ plan_path, turn_cost = self._planning_service.generate_plan(
35
+ user_message=resolved_message,
36
+ turn_dir=turn_dir,
37
+ )
38
+
39
+ # Handle planning cancellation/empty input
40
+ if plan_path is None:
41
+ return "CANCELLED"
42
+
43
+ return Path(turn_dir).parent.name