misterdev 0.2.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 (136) hide show
  1. misterdev/__init__.py +3 -0
  2. misterdev/agent.py +2166 -0
  3. misterdev/agent_helpers.py +194 -0
  4. misterdev/analyzers/__init__.py +0 -0
  5. misterdev/analyzers/project_analyzer/__init__.py +246 -0
  6. misterdev/analyzers/project_analyzer/detection.py +146 -0
  7. misterdev/analyzers/project_analyzer/merge.py +137 -0
  8. misterdev/analyzers/project_analyzer/overview.py +312 -0
  9. misterdev/analyzers/project_analyzer/prompts.py +83 -0
  10. misterdev/cli.py +370 -0
  11. misterdev/config.py +521 -0
  12. misterdev/core/__init__.py +0 -0
  13. misterdev/core/audit.py +88 -0
  14. misterdev/core/config.py +10 -0
  15. misterdev/core/context/__init__.py +0 -0
  16. misterdev/core/context/change_tracker.py +218 -0
  17. misterdev/core/context/contracts/__init__.py +63 -0
  18. misterdev/core/context/contracts/_log.py +9 -0
  19. misterdev/core/context/contracts/_text.py +12 -0
  20. misterdev/core/context/contracts/extraction.py +30 -0
  21. misterdev/core/context/contracts/python_generic.py +42 -0
  22. misterdev/core/context/contracts/registry.py +127 -0
  23. misterdev/core/context/contracts/rust_line.py +225 -0
  24. misterdev/core/context/contracts/rust_tree_sitter.py +141 -0
  25. misterdev/core/context/lsp.py +174 -0
  26. misterdev/core/context/scratchpad.py +111 -0
  27. misterdev/core/context/topography/__init__.py +43 -0
  28. misterdev/core/context/topography/_log.py +10 -0
  29. misterdev/core/context/topography/cache.py +91 -0
  30. misterdev/core/context/topography/engine.py +172 -0
  31. misterdev/core/context/topography/graph.py +675 -0
  32. misterdev/core/context/topography/nodes.py +55 -0
  33. misterdev/core/context/topography/parsers.py +95 -0
  34. misterdev/core/context/topography/syntax.py +54 -0
  35. misterdev/core/economics/__init__.py +0 -0
  36. misterdev/core/economics/context_budget.py +175 -0
  37. misterdev/core/economics/embeddings.py +232 -0
  38. misterdev/core/economics/free_models.py +108 -0
  39. misterdev/core/economics/llm_cache.py +105 -0
  40. misterdev/core/economics/model_catalog.py +79 -0
  41. misterdev/core/economics/model_ledger.py +331 -0
  42. misterdev/core/economics/model_selector.py +281 -0
  43. misterdev/core/execution/__init__.py +0 -0
  44. misterdev/core/execution/bounded.py +50 -0
  45. misterdev/core/execution/container.py +221 -0
  46. misterdev/core/execution/error_classifier.py +366 -0
  47. misterdev/core/execution/error_resolver.py +201 -0
  48. misterdev/core/execution/governance.py +283 -0
  49. misterdev/core/execution/outcomes.py +50 -0
  50. misterdev/core/execution/progress.py +120 -0
  51. misterdev/core/execution/project.py +231 -0
  52. misterdev/core/execution/registry.py +97 -0
  53. misterdev/core/execution/runtime.py +279 -0
  54. misterdev/core/gitcmd.py +39 -0
  55. misterdev/core/integration/__init__.py +0 -0
  56. misterdev/core/integration/mcp.py +368 -0
  57. misterdev/core/integration/mcp_gather.py +186 -0
  58. misterdev/core/models.py +35 -0
  59. misterdev/core/modes.py +184 -0
  60. misterdev/core/planning/__init__.py +0 -0
  61. misterdev/core/planning/advisor.py +89 -0
  62. misterdev/core/planning/assessment.py +135 -0
  63. misterdev/core/planning/decomposer.py +387 -0
  64. misterdev/core/planning/metacognition.py +103 -0
  65. misterdev/core/planning/sovereign.py +308 -0
  66. misterdev/core/planning/targets.py +201 -0
  67. misterdev/core/reporting/__init__.py +0 -0
  68. misterdev/core/reporting/report.py +377 -0
  69. misterdev/core/reporting/report_view.py +151 -0
  70. misterdev/core/task.py +163 -0
  71. misterdev/core/verification/__init__.py +0 -0
  72. misterdev/core/verification/claim_verifier.py +210 -0
  73. misterdev/core/verification/critic.py +324 -0
  74. misterdev/core/verification/gatekeeper/__init__.py +631 -0
  75. misterdev/core/verification/gatekeeper/constants.py +138 -0
  76. misterdev/core/verification/gatekeeper/helpers.py +28 -0
  77. misterdev/core/verification/goal_check.py +219 -0
  78. misterdev/core/verification/independent.py +68 -0
  79. misterdev/core/verification/mutation_gate.py +221 -0
  80. misterdev/core/verification/preflight.py +95 -0
  81. misterdev/core/verification/spec_tests.py +175 -0
  82. misterdev/core/verification/validator.py +495 -0
  83. misterdev/core/verification/vision_verify.py +185 -0
  84. misterdev/core/verification/web_verify.py +408 -0
  85. misterdev/environments/__init__.py +0 -0
  86. misterdev/environments/base_env.py +18 -0
  87. misterdev/environments/container_env.py +87 -0
  88. misterdev/environments/venv_env.py +42 -0
  89. misterdev/llm/__init__.py +0 -0
  90. misterdev/llm/client/__init__.py +152 -0
  91. misterdev/llm/client/base.py +382 -0
  92. misterdev/llm/client/edits.py +70 -0
  93. misterdev/llm/client/embeddings.py +121 -0
  94. misterdev/llm/client/errors.py +134 -0
  95. misterdev/llm/client/providers.py +535 -0
  96. misterdev/llm/client/response.py +24 -0
  97. misterdev/llm/prompt_manager.py +82 -0
  98. misterdev/llm/responses/__init__.py +34 -0
  99. misterdev/llm/responses/apply.py +131 -0
  100. misterdev/llm/responses/json_extract.py +80 -0
  101. misterdev/llm/responses/models.py +43 -0
  102. misterdev/llm/responses/parsing.py +494 -0
  103. misterdev/logging_setup.py +20 -0
  104. misterdev/mcp_server.py +208 -0
  105. misterdev/nl_cli.py +149 -0
  106. misterdev/plugins.py +115 -0
  107. misterdev/py.typed +0 -0
  108. misterdev/task_executors/__init__.py +0 -0
  109. misterdev/task_executors/base_executor.py +10 -0
  110. misterdev/task_executors/markdown_plan_executor/__init__.py +90 -0
  111. misterdev/task_executors/markdown_plan_executor/commands_mixin.py +82 -0
  112. misterdev/task_executors/markdown_plan_executor/context_mixin.py +221 -0
  113. misterdev/task_executors/markdown_plan_executor/critic_spec_mixin.py +174 -0
  114. misterdev/task_executors/markdown_plan_executor/edits_mixin.py +251 -0
  115. misterdev/task_executors/markdown_plan_executor/execute_mixin.py +727 -0
  116. misterdev/task_executors/markdown_plan_executor/gates_mixin.py +203 -0
  117. misterdev/task_executors/markdown_plan_executor/git_mixin.py +219 -0
  118. misterdev/task_executors/markdown_plan_executor/helpers.py +521 -0
  119. misterdev/task_executors/markdown_plan_executor/llm_mixin.py +238 -0
  120. misterdev/task_executors/markdown_plan_executor/results_mixin.py +23 -0
  121. misterdev/tools/__init__.py +19 -0
  122. misterdev/tools/base_tool.py +14 -0
  123. misterdev/tools/command.py +75 -0
  124. misterdev/tools/file_io.py +69 -0
  125. misterdev/tools/formatter.py +26 -0
  126. misterdev/tools/git_tool.py +90 -0
  127. misterdev/utils/__init__.py +0 -0
  128. misterdev/utils/file_utils.py +169 -0
  129. misterdev/utils/process.py +23 -0
  130. misterdev-0.2.0.dist-info/METADATA +326 -0
  131. misterdev-0.2.0.dist-info/RECORD +136 -0
  132. misterdev-0.2.0.dist-info/WHEEL +5 -0
  133. misterdev-0.2.0.dist-info/entry_points.txt +3 -0
  134. misterdev-0.2.0.dist-info/licenses/COMMERCIAL_LICENSE.md +34 -0
  135. misterdev-0.2.0.dist-info/licenses/LICENSE +661 -0
  136. misterdev-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,494 @@
1
+ """Parse LLM output into structured file edits.
2
+
3
+ A line-by-line state machine extracts whole-file fenced blocks, anchored
4
+ SEARCH/REPLACE hunks, and unified diffs, plus the pure helpers that detect
5
+ fences and resolve file paths from fence lines, leading comments, or
6
+ surrounding prose.
7
+ """
8
+
9
+ from typing import Dict, List, Optional
10
+
11
+ from .models import SearchReplaceEdit, _CodeBlock
12
+
13
+
14
+ class LLMResponseParser:
15
+ """Parses LLM output into structured file edits using a line-by-line
16
+ state machine. No regex; handles nested backticks, variable fence
17
+ widths, and all common LLM output formats.
18
+
19
+ Supported formats:
20
+ 1. Tagged: ```python:path/to/file.py
21
+ 2. Comment: ```python\\n# path/to/file.py\\n...
22
+ 3. Path-only: ```path/to/file.py
23
+ 4. Preceding: Update `path/to/file.py`:\\n```
24
+ 5. Unified diff: --- a/file +++ b/file
25
+ """
26
+
27
+ _CODE_EXTENSIONS = frozenset(
28
+ {
29
+ ".py",
30
+ ".js",
31
+ ".ts",
32
+ ".tsx",
33
+ ".jsx",
34
+ ".rs",
35
+ ".go",
36
+ ".java",
37
+ ".c",
38
+ ".cpp",
39
+ ".h",
40
+ ".hpp",
41
+ ".rb",
42
+ ".php",
43
+ ".swift",
44
+ ".kt",
45
+ ".scala",
46
+ ".zig",
47
+ ".cs",
48
+ ".csproj",
49
+ ".csx",
50
+ ".xaml",
51
+ ".sln",
52
+ ".cc",
53
+ ".cxx",
54
+ ".hh",
55
+ ".hxx",
56
+ ".toml",
57
+ ".yaml",
58
+ ".yml",
59
+ ".json",
60
+ ".md",
61
+ ".html",
62
+ ".css",
63
+ ".scss",
64
+ ".sql",
65
+ ".sh",
66
+ ".bash",
67
+ ".zsh",
68
+ ".cfg",
69
+ ".ini",
70
+ ".env",
71
+ ".txt",
72
+ ".xml",
73
+ }
74
+ )
75
+
76
+ @staticmethod
77
+ def parse_file_edits(llm_output: str) -> Dict[str, str]:
78
+ edits = LLMResponseParser._parse_unified_diffs(llm_output)
79
+ if edits:
80
+ return edits
81
+
82
+ blocks = LLMResponseParser._extract_code_blocks(llm_output)
83
+ result: Dict[str, str] = {}
84
+ for block in blocks:
85
+ path = LLMResponseParser._resolve_path(block)
86
+ if path:
87
+ result[path] = "\n".join(block.content_lines)
88
+ return result
89
+
90
+ @staticmethod
91
+ def parse_search_replace_blocks(llm_output: str) -> List[SearchReplaceEdit]:
92
+ """Extract surgical SEARCH/REPLACE hunks from the model's output.
93
+
94
+ Recognized format (the path may sit on a fence-open line, on a bare
95
+ line, or in backticks immediately above the marker)::
96
+
97
+ ```rust:path/to/file.rs
98
+ <<<<<<< SEARCH
99
+ exact existing snippet
100
+ =======
101
+ replacement snippet
102
+ >>>>>>> REPLACE
103
+ ```
104
+
105
+ Multiple hunks may follow one path. Returns an empty list when the
106
+ output contains no markers, in which case the caller falls back to the
107
+ whole-file parser.
108
+ """
109
+ lines = llm_output.split("\n")
110
+ edits: List[SearchReplaceEdit] = []
111
+ current_path: Optional[str] = None
112
+ i = 0
113
+ n = len(lines)
114
+ while i < n:
115
+ line = lines[i]
116
+ fence, remainder = _parse_fence_open(line)
117
+ if fence is not None:
118
+ _, path_hint = _parse_opening(remainder)
119
+ if path_hint:
120
+ current_path = path_hint.strip().strip("`'\"")
121
+ i += 1
122
+ continue
123
+ if _is_search_marker(line):
124
+ search_lines: List[str] = []
125
+ i += 1
126
+ while i < n and not _is_divider_marker(lines[i]):
127
+ search_lines.append(lines[i])
128
+ i += 1
129
+ i += 1 # skip divider
130
+ replace_lines: List[str] = []
131
+ while i < n and not _is_replace_marker(lines[i]):
132
+ replace_lines.append(lines[i])
133
+ i += 1
134
+ i += 1 # skip replace marker
135
+ if current_path:
136
+ edits.append(
137
+ SearchReplaceEdit(
138
+ path=current_path,
139
+ search="\n".join(search_lines),
140
+ replace="\n".join(replace_lines),
141
+ )
142
+ )
143
+ continue
144
+ stripped = line.strip().strip("`'\"")
145
+ if stripped.startswith("./"):
146
+ stripped = stripped[2:]
147
+ if _looks_like_path(stripped):
148
+ current_path = stripped
149
+ i += 1
150
+ return edits
151
+
152
+ # ------------------------------------------------------------------
153
+ # State machine: extract code blocks
154
+ # ------------------------------------------------------------------
155
+
156
+ @staticmethod
157
+ def _extract_code_blocks(text: str) -> List[_CodeBlock]:
158
+ blocks = []
159
+ lines = text.split("\n")
160
+ i = 0
161
+ preceding_start = 0
162
+
163
+ while i < len(lines):
164
+ fence, remainder = _parse_fence_open(lines[i])
165
+ if fence is None:
166
+ i += 1
167
+ continue
168
+
169
+ # Capture the text before this block for path detection
170
+ preceding = "\n".join(lines[preceding_start:i])
171
+
172
+ # Parse lang:path or lang or path from the remainder
173
+ lang, path_hint = _parse_opening(remainder)
174
+
175
+ # Collect content lines until closing fence
176
+ content_lines = []
177
+ i += 1
178
+ while i < len(lines):
179
+ if _is_fence_close(lines[i], fence):
180
+ break
181
+ content_lines.append(lines[i])
182
+ i += 1
183
+
184
+ blocks.append(
185
+ _CodeBlock(
186
+ lang=lang,
187
+ path_hint=path_hint,
188
+ content_lines=content_lines,
189
+ preceding_text=preceding,
190
+ )
191
+ )
192
+
193
+ preceding_start = i + 1
194
+ i += 1
195
+
196
+ return blocks
197
+
198
+ # ------------------------------------------------------------------
199
+ # Path resolution: try each strategy in priority order
200
+ # ------------------------------------------------------------------
201
+
202
+ @staticmethod
203
+ def _resolve_path(block: _CodeBlock) -> Optional[str]:
204
+ path = None
205
+
206
+ # 1. Explicit path from opening line (```python:path or ```path)
207
+ if block.path_hint:
208
+ path = block.path_hint
209
+
210
+ # 2. First line of content is a path comment
211
+ if not path and block.content_lines:
212
+ extracted = _extract_path_from_first_line(block.content_lines[0])
213
+ if extracted:
214
+ path = extracted
215
+ block.content_lines = block.content_lines[1:]
216
+
217
+ # 3. Preceding text contains a backtick-quoted or labeled path
218
+ if not path:
219
+ path = _extract_path_from_preceding(block.preceding_text)
220
+
221
+ if path:
222
+ path = path.strip().strip("`'\"")
223
+ if path.startswith("./"):
224
+ path = path[2:]
225
+ if _looks_like_path(path):
226
+ return path
227
+
228
+ return None
229
+
230
+ # ------------------------------------------------------------------
231
+ # Unified diff parser (line-by-line, no regex)
232
+ # ------------------------------------------------------------------
233
+
234
+ @staticmethod
235
+ def _parse_unified_diffs(text: str) -> Dict[str, str]:
236
+ if "--- a/" not in text and "--- " not in text:
237
+ return {}
238
+
239
+ edits: Dict[str, str] = {}
240
+ lines = text.split("\n")
241
+ i = 0
242
+
243
+ while i < len(lines):
244
+ # Look for --- a/path
245
+ if not lines[i].startswith("--- "):
246
+ i += 1
247
+ continue
248
+
249
+ # Next line must be +++ b/path
250
+ if i + 1 >= len(lines) or not lines[i + 1].startswith("+++ "):
251
+ i += 1
252
+ continue
253
+
254
+ plus_line = lines[i + 1]
255
+ file_path = plus_line[4:].strip()
256
+ if file_path.startswith("b/"):
257
+ file_path = file_path[2:]
258
+
259
+ i += 2
260
+ new_lines = []
261
+ hunk_count = 0
262
+ new_start: Optional[int] = None
263
+
264
+ # Collect hunks
265
+ while i < len(lines):
266
+ line = lines[i]
267
+ if line.startswith("--- "):
268
+ break # next diff
269
+ if line.startswith("@@"):
270
+ hunk_count += 1
271
+ if hunk_count == 1:
272
+ new_start = _unified_new_start(line)
273
+ i += 1
274
+ continue
275
+ if line.startswith("+") and not line.startswith("+++"):
276
+ new_lines.append(line[1:])
277
+ elif line.startswith(" "):
278
+ new_lines.append(line[1:])
279
+ elif not line.startswith("-"):
280
+ break # end of diff section
281
+ i += 1
282
+
283
+ # This parser rebuilds the whole file from the diff's +/context
284
+ # lines, which equals the real file ONLY for a single hunk that
285
+ # starts at line 1 (or a brand-new file). A partial or multi-hunk
286
+ # diff omits the unchanged regions between hunks, so reconstructing
287
+ # from it would TRUNCATE the file. Skip that unsafe case so the
288
+ # caller falls back (or retries) instead of writing a corrupt file.
289
+ if new_lines and hunk_count == 1 and new_start in (0, 1):
290
+ edits[file_path] = "\n".join(new_lines)
291
+
292
+ return edits
293
+
294
+
295
+ # ------------------------------------------------------------------
296
+ # Pure-function helpers (no regex, no state)
297
+ # ------------------------------------------------------------------
298
+
299
+
300
+ def _unified_new_start(header: str) -> Optional[int]:
301
+ """New-file start line from a ``@@ -a,b +c,d @@`` hunk header, or None.
302
+
303
+ Returns ``c`` (the first line number of the new side), used to confirm a
304
+ single-hunk diff begins at line 1 before its reconstruction is trusted as
305
+ whole-file content.
306
+ """
307
+ plus = header.find("+")
308
+ if plus == -1:
309
+ return None
310
+ digits = ""
311
+ for ch in header[plus + 1 :]:
312
+ if ch.isdigit():
313
+ digits += ch
314
+ else:
315
+ break
316
+ return int(digits) if digits else None
317
+
318
+
319
+ def _is_search_marker(line: str) -> bool:
320
+ stripped = line.strip()
321
+ return stripped.startswith("<<<<<<<") and "SEARCH" in stripped
322
+
323
+
324
+ def _is_replace_marker(line: str) -> bool:
325
+ stripped = line.strip()
326
+ return stripped.startswith(">>>>>>>") and "REPLACE" in stripped
327
+
328
+
329
+ def _is_divider_marker(line: str) -> bool:
330
+ stripped = line.strip()
331
+ return len(stripped) >= 5 and all(c == "=" for c in stripped)
332
+
333
+
334
+ def _parse_fence_open(line: str) -> tuple:
335
+ """Check if a line is a code fence opener (``` or ~~~~).
336
+
337
+ Returns (fence_char, remainder) or (None, None).
338
+ fence_char is the repeated character (` or ~) at the exact width used,
339
+ so the closer can be matched precisely.
340
+ """
341
+ stripped = line.lstrip()
342
+ for char in ("`", "~"):
343
+ if stripped.startswith(char * 3):
344
+ # Count fence width
345
+ width = 0
346
+ for c in stripped:
347
+ if c == char:
348
+ width += 1
349
+ else:
350
+ break
351
+ remainder = stripped[width:].strip()
352
+ fence = char * width
353
+ return fence, remainder
354
+ return None, None
355
+
356
+
357
+ def _is_fence_close(line: str, fence: str) -> bool:
358
+ """Check if a line closes the given fence."""
359
+ stripped = line.strip()
360
+ # Must start with at least as many fence chars and have nothing else
361
+ if not stripped.startswith(fence):
362
+ return False
363
+ # Everything after the fence chars must also be the fence char or empty
364
+ rest = stripped[len(fence) :]
365
+ return all(c == fence[0] for c in rest)
366
+
367
+
368
+ def _parse_opening(remainder: str) -> tuple:
369
+ """Parse the text after ``` into (lang, path_hint).
370
+
371
+ Examples:
372
+ "python:src/main.py" -> ("python", "src/main.py")
373
+ "python" -> ("python", "")
374
+ "src/main.py" -> ("", "src/main.py")
375
+ "" -> ("", "")
376
+ """
377
+ if not remainder:
378
+ return "", ""
379
+
380
+ # Check for lang:path format
381
+ if ":" in remainder:
382
+ parts = remainder.split(":", 1)
383
+ lang_candidate = parts[0].strip()
384
+ path_candidate = parts[1].strip()
385
+ if _looks_like_path(path_candidate):
386
+ return lang_candidate, path_candidate
387
+
388
+ # Is the whole thing a path?
389
+ if _looks_like_path(remainder):
390
+ return "", remainder
391
+
392
+ # Otherwise it's a language identifier
393
+ return remainder, ""
394
+
395
+
396
+ def _extract_path_from_first_line(line: str) -> Optional[str]:
397
+ """Extract a file path from a comment on the first line of a code block."""
398
+ stripped = line.strip()
399
+
400
+ # Comment prefixes: # path, // path, -- path, /* path */, <!-- path -->
401
+ comment_prefixes = ("#", "//", "--", "/*", "<!--")
402
+ for prefix in comment_prefixes:
403
+ if stripped.startswith(prefix):
404
+ remainder = stripped[len(prefix) :].strip()
405
+ # Strip trailing comment closers
406
+ for closer in ("*/", "-->"):
407
+ if remainder.endswith(closer):
408
+ remainder = remainder[: -len(closer)].strip()
409
+ if _looks_like_path(remainder):
410
+ return remainder
411
+
412
+ # "File: path" or "Filename: path" or "Path: path"
413
+ for label in ("file:", "filename:", "path:"):
414
+ lower = stripped.lower()
415
+ if lower.startswith(label):
416
+ candidate = stripped[len(label) :].strip()
417
+ if _looks_like_path(candidate):
418
+ return candidate
419
+
420
+ return None
421
+
422
+
423
+ def _extract_path_from_preceding(text: str) -> Optional[str]:
424
+ """Find a file path in text that precedes a code block.
425
+
426
+ Scans backward from the end looking for backtick-quoted paths,
427
+ bold paths, or labeled paths like "Update `file.py`:" or
428
+ "**file.py**:".
429
+ """
430
+ # Only look at the last ~200 chars
431
+ segment = text[-200:] if len(text) > 200 else text
432
+
433
+ # Strategy 1: find last backtick-quoted path
434
+ path = _find_last_quoted_path(segment, "`")
435
+ if path:
436
+ return path
437
+
438
+ # Strategy 2: find last bold-quoted path (**path**)
439
+ path = _find_last_delimited_path(segment, "**", "**")
440
+ if path:
441
+ return path
442
+
443
+ # Strategy 3: labeled path (File: path, Update: path, etc.)
444
+ for label in (
445
+ "file:",
446
+ "update:",
447
+ "create:",
448
+ "modify:",
449
+ "in:",
450
+ "filename:",
451
+ "path:",
452
+ ):
453
+ idx = segment.lower().rfind(label)
454
+ if idx >= 0:
455
+ after = segment[idx + len(label) :].strip()
456
+ # Take first whitespace-delimited token
457
+ candidate = after.split()[0].strip("`'\",:") if after else ""
458
+ if _looks_like_path(candidate):
459
+ return candidate
460
+
461
+ return None
462
+
463
+
464
+ def _find_last_quoted_path(text: str, quote: str) -> Optional[str]:
465
+ """Find the last `path.ext` in text — the same-delimiter special case."""
466
+ return _find_last_delimited_path(text, quote, quote)
467
+
468
+
469
+ def _find_last_delimited_path(text: str, opener: str, closer: str) -> Optional[str]:
470
+ """Find the last **path.ext** in text."""
471
+ end = len(text)
472
+ while True:
473
+ close = text.rfind(closer, 0, end)
474
+ if close < 0:
475
+ return None
476
+ open_pos = text.rfind(opener, 0, close)
477
+ if open_pos < 0:
478
+ return None
479
+ candidate = text[open_pos + len(opener) : close]
480
+ if _looks_like_path(candidate):
481
+ return candidate
482
+ end = open_pos
483
+
484
+
485
+ def _looks_like_path(s: str) -> bool:
486
+ """Check if a string looks like a file path."""
487
+ s = s.strip().strip("`'\"")
488
+ if not s or " " in s or len(s) > 200:
489
+ return False
490
+ dot = s.rfind(".")
491
+ if dot < 0:
492
+ return False
493
+ ext = s[dot:]
494
+ return ext in LLMResponseParser._CODE_EXTENSIONS
@@ -0,0 +1,20 @@
1
+ import logging
2
+ import sys
3
+
4
+
5
+ def setup_logger(name="project_orchestrator", level=logging.INFO):
6
+ """Sets up a standardized logger for the project orchestrator."""
7
+ logger = logging.getLogger(name)
8
+ logger.setLevel(level)
9
+
10
+ if not logger.handlers:
11
+ formatter = logging.Formatter(
12
+ "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
13
+ )
14
+
15
+ # Console Handler
16
+ console_handler = logging.StreamHandler(sys.stdout)
17
+ console_handler.setFormatter(formatter)
18
+ logger.addHandler(console_handler)
19
+
20
+ return logger