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,377 @@
1
+ """Build report generation, ported from /build Phase 6.
2
+
3
+ Produces a structured markdown report summarizing the build session.
4
+ """
5
+
6
+ import json
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ from misterdev.core.planning.assessment import (
12
+ HealthCheck,
13
+ ProjectAssessment,
14
+ )
15
+ from misterdev.core.models import Task
16
+ from misterdev.core.context.scratchpad import Scratchpad
17
+ from misterdev.core.modes import BuildMode
18
+ from misterdev.core.verification.validator import ValidationResult
19
+ from misterdev.logging_setup import setup_logger
20
+
21
+ logger = setup_logger(__name__)
22
+
23
+
24
+ def _failure_reason(task: Task) -> str:
25
+ """A concise, table-safe reason a task failed, from its last execution result.
26
+
27
+ Surfaces WHY a task failed (gate error, acceptance, low certainty, …) in the
28
+ report instead of forcing a dig through logs. Falls back to the status when no
29
+ result detail is available.
30
+ """
31
+ history = getattr(task, "execution_history", None) or []
32
+ detail = ""
33
+ if history:
34
+ last = history[-1]
35
+ detail = (
36
+ getattr(last, "logs", "") or getattr(last, "message", "") or ""
37
+ ).strip()
38
+ if not detail:
39
+ return getattr(task, "status", "failed") or "failed"
40
+ # First non-empty line, flattened and escaped for a markdown table cell.
41
+ line = next((ln.strip() for ln in detail.splitlines() if ln.strip()), detail)
42
+ line = line.replace("|", "\\|")
43
+ return line[:120] + ("…" if len(line) > 120 else "")
44
+
45
+
46
+ class BuildReport:
47
+ def __init__(
48
+ self,
49
+ mode: BuildMode,
50
+ project_name: str,
51
+ assessment: ProjectAssessment,
52
+ start_time: datetime,
53
+ ):
54
+ self.mode = mode
55
+ self.project_name = project_name
56
+ self.assessment = assessment
57
+ self.start_time = start_time
58
+ self.end_time: Optional[datetime] = None
59
+ self.completed_tasks: list[Task] = []
60
+ self.failed_tasks: list[Task] = []
61
+ self.deferred_tasks: list[Task] = []
62
+ self.key_decisions: list[str] = []
63
+ self.scratchpad: Optional[Scratchpad] = None
64
+ self.health_before: Optional[HealthCheck] = None
65
+ self.health_after: Optional[HealthCheck] = None
66
+ self.validation: Optional[ValidationResult] = None
67
+ self.validation_passed: Optional[bool] = None
68
+ self.llm_calls: int = 0
69
+ self.llm_tokens: int = 0
70
+ # Split so a run's token profile is legible: input (prompt/context) vs
71
+ # output (completion). A large input:output ratio means the run is
72
+ # context-bound — the lever is narrower context / prompt caching, not
73
+ # shorter outputs.
74
+ self.llm_prompt_tokens: int = 0
75
+ self.llm_completion_tokens: int = 0
76
+ self.llm_cost: float = 0.0
77
+ self.llm_cache_read_tokens: int = 0
78
+ self.cost_by_task: dict = {}
79
+ # Best-effort subsystems that threw during the run (e.g. AB-MCTS,
80
+ # probes). Surfaced in the report so a silently-dead subsystem is
81
+ # visible to the morning reader, not just buried in a log.
82
+ self.degraded_subsystems: list[str] = []
83
+ # Unmet-goal gaps from the optional goal-completion check (advisory: the
84
+ # gates can be green while the goal is not fully met). Empty unless the
85
+ # check ran and returned a GAP verdict.
86
+ self.goal_gaps: list[str] = []
87
+
88
+ def finalize(self, end_time: Optional[datetime] = None):
89
+ self.end_time = end_time or datetime.now(timezone.utc)
90
+
91
+ def to_dict(self) -> dict:
92
+ """Structured representation for programmatic access / history."""
93
+ return {
94
+ "mode": self.mode.value,
95
+ "project": self.project_name,
96
+ "start_time": self.start_time.isoformat(),
97
+ "end_time": self.end_time.isoformat() if self.end_time else None,
98
+ "completed": [t.id for t in self.completed_tasks],
99
+ "failed": [t.id for t in self.failed_tasks],
100
+ "deferred": [t.id for t in self.deferred_tasks],
101
+ "validation_passed": self.validation_passed,
102
+ "llm_calls": self.llm_calls,
103
+ "llm_tokens": self.llm_tokens,
104
+ "llm_prompt_tokens": self.llm_prompt_tokens,
105
+ "llm_completion_tokens": self.llm_completion_tokens,
106
+ "llm_cache_read_tokens": self.llm_cache_read_tokens,
107
+ "llm_cost": self.llm_cost,
108
+ "degraded_subsystems": list(self.degraded_subsystems),
109
+ "goal_gaps": list(self.goal_gaps),
110
+ }
111
+
112
+ def save(self, project_path: Path) -> Optional[Path]:
113
+ """Persist the report (markdown + JSON) under .orchestrator/reports.
114
+
115
+ Failure to write a report must never abort a build, so write errors are
116
+ logged and swallowed rather than propagated.
117
+ """
118
+ try:
119
+ reports_dir = Path(project_path) / ".orchestrator" / "reports"
120
+ reports_dir.mkdir(parents=True, exist_ok=True)
121
+ stamp = (self.end_time or self.start_time).strftime("%Y%m%d_%H%M%S")
122
+ md_path = reports_dir / f"report_{stamp}.md"
123
+ md_path.write_text(self.to_markdown(), encoding="utf-8")
124
+ (reports_dir / f"report_{stamp}.json").write_text(
125
+ json.dumps(self.to_dict(), indent=2), encoding="utf-8"
126
+ )
127
+ logger.info(f"Report saved to {md_path}")
128
+ return md_path
129
+ except OSError as e:
130
+ logger.error(f"Failed to save build report: {e}")
131
+ return None
132
+
133
+ def _verdict_block(self) -> list[str]:
134
+ """Top-line go/no-go verdict plus an evidence block, derived purely
135
+ from existing report fields (no LLM calls, no I/O)."""
136
+ n_completed = len(self.completed_tasks)
137
+ n_failed = len(self.failed_tasks)
138
+ n_deferred = len(self.deferred_tasks)
139
+ nothing_done = (n_completed + n_failed + n_deferred) == 0
140
+
141
+ if self.validation_passed is False or (
142
+ nothing_done and self.validation_passed is not True
143
+ ):
144
+ verdict = "FAILED"
145
+ reason = "build/test gate is red or nothing meaningful completed."
146
+ elif self.validation_passed and n_failed == 0:
147
+ verdict = "SHIP"
148
+ reason = "validation passed and no tasks failed."
149
+ else:
150
+ verdict = "NEEDS REVIEW"
151
+ reason = "build largely succeeded but has open issues; see below."
152
+
153
+ lines = [f"## Verdict: {verdict}", f"_{reason}_\n", "### Evidence"]
154
+ lines.append(
155
+ f"- Tasks: {n_completed} completed, {n_failed} failed, {n_deferred} deferred"
156
+ )
157
+ if self.validation:
158
+ lines.append(f"- Validation: {self.validation.summary()}")
159
+ elif self.validation_passed is not None:
160
+ lines.append(
161
+ f"- Validation: {'passed' if self.validation_passed else 'failed'}"
162
+ )
163
+ if self.health_before or self.health_after:
164
+ hb = self.health_before or HealthCheck()
165
+ ha = self.health_after or HealthCheck()
166
+ lines.append(
167
+ f"- Health: builds {'YES' if hb.builds else 'NO'} -> "
168
+ f"{'YES' if ha.builds else 'NO'}, "
169
+ f"tests {hb.test_failures} fail -> {ha.test_failures} fail, "
170
+ f"lint {hb.lint_warnings} -> {ha.lint_warnings} warnings"
171
+ )
172
+ if self.validation and self.validation.diff_stats:
173
+ lines.append(f"- Diff: {self.validation.diff_stats}")
174
+ if self.llm_calls > 0:
175
+ lines.append(
176
+ f"- LLM: {self.llm_calls} calls, {self.llm_tokens:,} tokens, "
177
+ f"${self.llm_cost:.4f}"
178
+ )
179
+
180
+ blocking = list(self.failed_tasks)
181
+ issues = list(self.validation.issues) if self.validation else []
182
+ if blocking or issues:
183
+ lines.append("\n**Blocking items:**")
184
+ for t in blocking:
185
+ lines.append(f"- Failed task {t.id}: {t.title or t.description[:60]}")
186
+ for issue in issues:
187
+ lines.append(f"- {issue}")
188
+ if self.degraded_subsystems:
189
+ names = ", ".join(d.split(":")[0] for d in self.degraded_subsystems)
190
+ lines.append(f"\n**Degraded subsystems** (ran WITHOUT: {names}):")
191
+ for d in self.degraded_subsystems:
192
+ lines.append(f"- {d}")
193
+ if self.goal_gaps:
194
+ lines.append(
195
+ "\n**Goal gaps** (advisory: gates passed but the goal may not be "
196
+ "fully met):"
197
+ )
198
+ for gap in self.goal_gaps:
199
+ lines.append(f"- {gap}")
200
+ lines.append("")
201
+ return lines
202
+
203
+ def to_markdown(self) -> str:
204
+ self.end_time = self.end_time or datetime.now(timezone.utc)
205
+ duration = (self.end_time - self.start_time).total_seconds()
206
+ duration_min = duration / 60
207
+
208
+ s = self.assessment.structure
209
+ langs = ", ".join(s.languages) if s.languages else "unknown"
210
+
211
+ lines = [
212
+ "## Build Report\n",
213
+ f"**Mode**: {self.mode.value} | **Duration**: ~{duration_min:.1f} minutes",
214
+ f"**Project**: {self.project_name} | **Type**: {s.project_type} | **Languages**: {langs}\n",
215
+ ]
216
+
217
+ # Go/no-go verdict: someone returning to an unattended run must see
218
+ # "can I ship this?" up front, derived purely from existing fields,
219
+ # before scanning the task tables below.
220
+ lines.extend(self._verdict_block())
221
+
222
+ # Validation banner: a failed quality gate must be visible at the top,
223
+ # not buried while the report otherwise reads as a success.
224
+ if self.validation_passed is not None:
225
+ if self.validation_passed:
226
+ lines.append("**Validation: PASSED**\n")
227
+ else:
228
+ lines.append(
229
+ "**Validation: FAILED** - quality gate did not pass; see issues below."
230
+ )
231
+ if self.validation and self.validation.issues:
232
+ for issue in self.validation.issues:
233
+ lines.append(f"- {issue}")
234
+ lines.append("")
235
+
236
+ # Health Before -> After
237
+ if self.health_before or self.health_after:
238
+ lines.append("### Health Before -> After")
239
+ lines.append("| Check | Before | After |")
240
+ lines.append("|-------|--------|-------|")
241
+ hb = self.health_before or HealthCheck()
242
+ ha = self.health_after or HealthCheck()
243
+ lines.append(
244
+ f"| Builds | {'YES' if hb.builds else 'NO'} | {'YES' if ha.builds else 'NO'} |"
245
+ )
246
+ lines.append(
247
+ f"| Tests | {hb.test_count - hb.test_failures} pass, {hb.test_failures} fail | "
248
+ f"{ha.test_count - ha.test_failures} pass, {ha.test_failures} fail |"
249
+ )
250
+ lines.append(
251
+ f"| Lint | {hb.lint_warnings} warnings | {ha.lint_warnings} warnings |"
252
+ )
253
+ lines.append("")
254
+
255
+ # Technical Debt & Risk
256
+ lines.append("### Technical Debt & Risk")
257
+ lines.append(f"**Debt Score**: {self.assessment.tech_debt.score}/100")
258
+ lines.append(f"**Risk Level**: {self.assessment.risk.level.upper()}")
259
+ if self.assessment.risk.factors:
260
+ lines.append("\n**Risk Factors:**")
261
+ for factor in self.assessment.risk.factors:
262
+ lines.append(f"- {factor}")
263
+ lines.append("")
264
+
265
+ # Task summary
266
+ lines.append("### Tasks")
267
+ lines.append("| Status | Count |")
268
+ lines.append("|--------|-------|")
269
+ lines.append(f"| Completed | {len(self.completed_tasks)} |")
270
+ lines.append(f"| Failed | {len(self.failed_tasks)} |")
271
+ lines.append(f"| Deferred | {len(self.deferred_tasks)} |")
272
+ lines.append("")
273
+
274
+ # Completed tasks
275
+ if self.completed_tasks:
276
+ lines.append("### Completed Tasks")
277
+ lines.append("| # | ID | Title |")
278
+ lines.append("|---|------|-------|")
279
+ for i, t in enumerate(self.completed_tasks, 1):
280
+ lines.append(f"| {i} | {t.id} | {t.title or t.description[:60]} |")
281
+ lines.append("")
282
+
283
+ # Failed tasks
284
+ if self.failed_tasks:
285
+ lines.append("### Failed Tasks")
286
+ lines.append("| ID | Title | Reason |")
287
+ lines.append("|------|-------|--------|")
288
+ for t in self.failed_tasks:
289
+ lines.append(
290
+ f"| {t.id} | {t.title or t.description[:50]} | "
291
+ f"{_failure_reason(t)} |"
292
+ )
293
+ lines.append("")
294
+
295
+ # Deferred tasks
296
+ if self.deferred_tasks:
297
+ lines.append("### Deferred Tasks")
298
+ lines.append("| ID | Title |")
299
+ lines.append("|------|-------|")
300
+ for t in self.deferred_tasks:
301
+ lines.append(f"| {t.id} | {t.title or t.description[:60]} |")
302
+ lines.append("")
303
+
304
+ # Key decisions
305
+ if self.key_decisions:
306
+ lines.append("### Key Decisions")
307
+ for d in self.key_decisions:
308
+ lines.append(f"- {d}")
309
+ lines.append("")
310
+
311
+ # Scratchpad discoveries
312
+ if self.scratchpad and len(self.scratchpad) > 0:
313
+ lines.append("### Scratchpad Discoveries")
314
+ for entry in self.scratchpad.entries:
315
+ lines.append(f"- [{entry.category}] {entry.discovery}")
316
+ lines.append("")
317
+
318
+ # Validation
319
+ if self.validation:
320
+ lines.append("### Validation")
321
+ lines.append(f"- {self.validation.summary()}")
322
+ if self.validation.diff_stats:
323
+ lines.append(f"\n```\n{self.validation.diff_stats}\n```")
324
+ lines.append("")
325
+
326
+ # LLM usage
327
+ if self.llm_calls > 0:
328
+ lines.append("### LLM Usage")
329
+ lines.append(
330
+ f"- {self.llm_calls} calls, {self.llm_tokens:,} tokens, "
331
+ f"${self.llm_cost:.4f} estimated cost"
332
+ )
333
+ if self.llm_prompt_tokens or self.llm_completion_tokens:
334
+ lines.append(
335
+ f"- Tokens: {self.llm_prompt_tokens:,} input (context) / "
336
+ f"{self.llm_completion_tokens:,} output"
337
+ )
338
+ if self.llm_cache_read_tokens > 0 and self.llm_tokens > 0:
339
+ rate = 100.0 * self.llm_cache_read_tokens / self.llm_tokens
340
+ lines.append(
341
+ f"- Cache: {self.llm_cache_read_tokens:,} tokens read from cache ({rate:.0f}% of total)"
342
+ )
343
+ # Actionable token-efficiency signal: a run whose input dwarfs its
344
+ # output is context-bound — the lever is narrower context / more
345
+ # prompt-cache reuse, not shorter completions.
346
+ if self.llm_completion_tokens > 0:
347
+ ratio = self.llm_prompt_tokens / self.llm_completion_tokens
348
+ if ratio >= 8:
349
+ lines.append(
350
+ f"- Token profile: context-bound ({ratio:.0f}:1 input:output). "
351
+ "Consider enabling prompt caching and narrowing context."
352
+ )
353
+ if self.cost_by_task:
354
+ top = sorted(
355
+ self.cost_by_task.items(), key=lambda kv: kv[1], reverse=True
356
+ )[:5]
357
+ lines.append("- Most expensive tasks:")
358
+ for tid, cost in top:
359
+ lines.append(f" - {tid}: ${cost:.4f}")
360
+ lines.append("")
361
+
362
+ # Summary line
363
+ total = (
364
+ len(self.completed_tasks)
365
+ + len(self.failed_tasks)
366
+ + len(self.deferred_tasks)
367
+ )
368
+ validation_note = ""
369
+ if self.validation_passed is False:
370
+ validation_note = " VALIDATION FAILED."
371
+ lines.append("---")
372
+ lines.append(
373
+ f"*{len(self.completed_tasks)}/{total} tasks completed.{validation_note} "
374
+ f"Duration: {duration_min:.1f}m.*"
375
+ )
376
+
377
+ return "\n".join(lines)
@@ -0,0 +1,151 @@
1
+ """Aggregated, read-only view over a project's ``.orchestrator/`` artifacts.
2
+
3
+ The orchestrator writes three streams under ``.orchestrator/``: an append-only
4
+ audit trail (``audit.jsonl``), the persistent model-performance ledger
5
+ (``model_stats.json``), and per-build reports (``reports/report_*.json``). Each
6
+ is observability that, until now, had no consolidated reader. This module
7
+ summarizes all three into plain dicts so a ``report`` command (or any caller) can
8
+ show what happened, what each model actually cost, and how the last build went —
9
+ without re-running anything. Pure and defensive: a missing or malformed file
10
+ yields an empty/None summary, never an error.
11
+ """
12
+
13
+ import json
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ _ARTIFACT_DIR = ".orchestrator"
18
+
19
+
20
+ def _read_jsonl(path: Path) -> List[Dict[str, Any]]:
21
+ """Read a JSONL file into a list of objects, skipping unreadable lines."""
22
+ events: List[Dict[str, Any]] = []
23
+ try:
24
+ with open(path, encoding="utf-8") as fh:
25
+ for line in fh:
26
+ line = line.strip()
27
+ if not line:
28
+ continue
29
+ try:
30
+ obj = json.loads(line)
31
+ except ValueError:
32
+ continue
33
+ if isinstance(obj, dict):
34
+ events.append(obj)
35
+ except OSError:
36
+ return []
37
+ return events
38
+
39
+
40
+ def summarize_audit(audit_path: Path) -> Dict[str, Any]:
41
+ """Aggregate the audit trail: event counts, command pass/fail, edits, gates.
42
+
43
+ Returns zeros/empties when the file is absent or empty, so the caller can
44
+ render a consistent shape regardless.
45
+ """
46
+ events = _read_jsonl(Path(audit_path))
47
+ by_type: Dict[str, int] = {}
48
+ cmd_ok = cmd_failed = 0
49
+ edits: Dict[str, int] = {}
50
+ gov_escalated = gov_blocked = 0
51
+ for e in events:
52
+ etype = str(e.get("type", "?"))
53
+ by_type[etype] = by_type.get(etype, 0) + 1
54
+ if etype == "command":
55
+ if e.get("ok"):
56
+ cmd_ok += 1
57
+ else:
58
+ cmd_failed += 1
59
+ elif etype == "edit":
60
+ path = str(e.get("path", "?"))
61
+ edits[path] = edits.get(path, 0) + 1
62
+ elif etype == "gate":
63
+ if e.get("escalated"):
64
+ gov_escalated += 1
65
+ if e.get("allowed") is False:
66
+ gov_blocked += 1
67
+ return {
68
+ "total_events": len(events),
69
+ "by_type": by_type,
70
+ "commands": {"ok": cmd_ok, "failed": cmd_failed},
71
+ "edits": {"total": sum(edits.values()), "by_file": edits},
72
+ "governance": {"escalated": gov_escalated, "blocked": gov_blocked},
73
+ }
74
+
75
+
76
+ def summarize_models(ledger_path: Path) -> List[Dict[str, Any]]:
77
+ """Per-model performance from the ledger, aggregated across category/complexity.
78
+
79
+ Surfaces the data that drives selection — attempts, gate-pass rate, first-try
80
+ rate, and mean cost of a success — so a previously-invisible model choice
81
+ becomes legible. Sorted by attempts (most-exercised first). Empty when the
82
+ ledger is absent.
83
+ """
84
+ path = Path(ledger_path)
85
+ if not path.exists():
86
+ return []
87
+ from misterdev.core.economics.model_ledger import ModelLedger
88
+
89
+ ledger = ModelLedger(path)
90
+ agg: Dict[str, Dict[str, float]] = {}
91
+ for s in ledger.all_stats():
92
+ a = agg.setdefault(
93
+ s.model,
94
+ {
95
+ "attempts": 0.0,
96
+ "successes": 0.0,
97
+ "first_try_attempts": 0.0,
98
+ "first_try_successes": 0.0,
99
+ "total_cost": 0.0,
100
+ },
101
+ )
102
+ a["attempts"] += s.attempts
103
+ a["successes"] += s.successes
104
+ a["first_try_attempts"] += s.first_try_attempts
105
+ a["first_try_successes"] += s.first_try_successes
106
+ a["total_cost"] += s.total_cost
107
+ rows: List[Dict[str, Any]] = []
108
+ for model, a in agg.items():
109
+ att = a["attempts"]
110
+ fta = a["first_try_attempts"]
111
+ succ = a["successes"]
112
+ rows.append(
113
+ {
114
+ "model": model,
115
+ "attempts": round(att, 1),
116
+ "success_rate": (succ / att) if att else 0.0,
117
+ "first_try_rate": (a["first_try_successes"] / fta) if fta else 0.0,
118
+ "avg_cost": (a["total_cost"] / succ) if succ else 0.0,
119
+ }
120
+ )
121
+ rows.sort(key=lambda r: r["attempts"], reverse=True)
122
+ return rows
123
+
124
+
125
+ def latest_report(reports_dir: Path) -> Optional[Dict[str, Any]]:
126
+ """The most recent saved build report (JSON), or None.
127
+
128
+ Report filenames are timestamp-stamped (``report_YYYYMMDD_HHMMSS.json``), so
129
+ a lexical sort is chronological and the last entry is the newest.
130
+ """
131
+ d = Path(reports_dir)
132
+ if not d.is_dir():
133
+ return None
134
+ candidates = sorted(d.glob("report_*.json"))
135
+ if not candidates:
136
+ return None
137
+ try:
138
+ obj = json.loads(candidates[-1].read_text(encoding="utf-8"))
139
+ except (OSError, ValueError):
140
+ return None
141
+ return obj if isinstance(obj, dict) else None
142
+
143
+
144
+ def collect(project_path: Path) -> Dict[str, Any]:
145
+ """Collect the audit, model, and latest-report summaries for a project."""
146
+ root = Path(project_path) / _ARTIFACT_DIR
147
+ return {
148
+ "audit": summarize_audit(root / "audit.jsonl"),
149
+ "models": summarize_models(root / "model_stats.json"),
150
+ "latest_report": latest_report(root / "reports"),
151
+ }
misterdev/core/task.py ADDED
@@ -0,0 +1,163 @@
1
+ import frontmatter
2
+ from pathlib import Path
3
+ from typing import List, Dict, Any, Optional
4
+
5
+ from misterdev.config import get_setting
6
+ from misterdev.core.models import Task
7
+ from misterdev.logging_setup import setup_logger
8
+
9
+ logger = setup_logger(__name__)
10
+
11
+
12
+ class TaskManager:
13
+ """Manages discovery, loading, and updating of tasks for a project."""
14
+
15
+ def __init__(self, project: Any):
16
+ self.project = project
17
+ self.devplan_dir = self.project.config.get("devplan_dir", "devplan")
18
+ self.tasks: Dict[str, Task] = {}
19
+
20
+ def discover_tasks(self) -> List[Task]:
21
+ """Scans the devplan directory for markdown files with front-matter."""
22
+ devplan_path = self.project.path / self.devplan_dir
23
+ if not devplan_path.exists():
24
+ logger.warning(f"Devplan directory {devplan_path} does not exist.")
25
+ return []
26
+
27
+ self.tasks.clear()
28
+ discovered_tasks = []
29
+ for file_path in sorted(devplan_path.rglob("*.md")):
30
+ try:
31
+ task = self._load_task_from_file(file_path)
32
+ if task:
33
+ self.tasks[task.id] = task
34
+ discovered_tasks.append(task)
35
+ except Exception as e:
36
+ logger.error(f"Error loading task from {file_path}: {e}")
37
+
38
+ self._resolve_dependency_ids()
39
+ if get_setting(self.project.config, "orchestrator", "auto_detect_dependencies"):
40
+ self._detect_file_overlaps()
41
+ logger.info(f"Discovered {len(discovered_tasks)} tasks.")
42
+ return discovered_tasks
43
+
44
+ def _detect_file_overlaps(self):
45
+ """Add implicit dependencies when tasks touch the same file.
46
+
47
+ Two independent tasks that modify (or create-then-modify) the same file
48
+ would conflict if run in the same wave. Chaining them by ID keeps the
49
+ edits serialized in a deterministic order.
50
+ """
51
+ file_to_tasks: Dict[str, List[str]] = {}
52
+ for task in sorted(self.tasks.values(), key=lambda t: t.id):
53
+ for f in list(task.files_to_modify) + list(task.files_to_create):
54
+ file_to_tasks.setdefault(f, []).append(task.id)
55
+
56
+ added = 0
57
+ for file_path, task_ids in file_to_tasks.items():
58
+ if len(task_ids) < 2:
59
+ continue
60
+ for i in range(1, len(task_ids)):
61
+ later = self.tasks[task_ids[i]]
62
+ earlier_id = task_ids[i - 1]
63
+ if earlier_id != later.id and earlier_id not in later.dependencies:
64
+ later.dependencies.append(earlier_id)
65
+ added += 1
66
+ logger.info(
67
+ f"Implicit dependency: {later.id} depends on {earlier_id} "
68
+ f"(both touch {file_path})"
69
+ )
70
+ if added:
71
+ logger.info(f"Detected {added} implicit dependencies from file overlaps")
72
+
73
+ def _resolve_dependency_ids(self):
74
+ """Resolve short dependency IDs (e.g., '001') to full task IDs (e.g., '001-posting-shard')."""
75
+ all_ids = set(self.tasks.keys())
76
+ for task in self.tasks.values():
77
+ resolved = []
78
+ for dep in task.dependencies:
79
+ # YAML may parse a numeric id (e.g. `depends_on: [999]`) as int;
80
+ # coerce so prefix matching and comparisons don't crash.
81
+ dep = str(dep)
82
+ if dep in all_ids:
83
+ resolved.append(dep)
84
+ else:
85
+ # Try prefix match: "001" matches "001-posting-shard"
86
+ matches = [
87
+ tid
88
+ for tid in all_ids
89
+ if tid.startswith(dep + "-") or tid == dep
90
+ ]
91
+ if len(matches) == 1:
92
+ resolved.append(matches[0])
93
+ elif len(matches) > 1:
94
+ logger.warning(
95
+ f"Ambiguous dependency '{dep}' in {task.id}: matches {matches}"
96
+ )
97
+ resolved.append(matches[0])
98
+ else:
99
+ logger.warning(f"Unresolved dependency '{dep}' in {task.id}")
100
+ resolved.append(dep)
101
+ task.dependencies = resolved
102
+
103
+ def _load_task_from_file(self, file_path: Path) -> Optional[Task]:
104
+ with open(file_path, "r", encoding="utf-8") as f:
105
+ post = frontmatter.load(f)
106
+
107
+ if not post.metadata or "status" not in post.metadata:
108
+ return None
109
+
110
+ task_id = file_path.stem
111
+
112
+ meta = post.metadata
113
+ depends_on = meta.get("depends_on", [])
114
+ if not isinstance(depends_on, list):
115
+ depends_on = [depends_on]
116
+ # YAML may parse a numeric id (e.g. `depends_on: [999]`) as int; coerce
117
+ # so the typed Task model accepts it instead of dropping the whole task.
118
+ depends_on = [str(d) for d in depends_on]
119
+
120
+ return Task(
121
+ id=task_id,
122
+ description=post.content,
123
+ type="markdown_planner",
124
+ status=meta.get("status", "pending"),
125
+ source_ref=str(file_path),
126
+ project_ref=str(self.project.path),
127
+ processor_data=meta,
128
+ dependencies=depends_on,
129
+ files_to_modify=meta.get("files_to_modify", []),
130
+ files_to_create=meta.get("files_to_create", []),
131
+ context_files=meta.get("context_files", []),
132
+ category=meta.get("category", "feature"),
133
+ complexity=meta.get("complexity", "medium"),
134
+ title=meta.get("title", task_id),
135
+ )
136
+
137
+ def get_pending_tasks(self) -> List[Task]:
138
+ return [t for t in self.tasks.values() if t.status == "pending"]
139
+
140
+ def update_task_status(self, task_id: str, new_status: str):
141
+ if task_id not in self.tasks:
142
+ logger.warning(
143
+ f"Task {task_id} not in task registry, status update skipped."
144
+ )
145
+ return
146
+
147
+ task = self.tasks[task_id]
148
+ task.status = new_status
149
+
150
+ # Persist back to the markdown file. Decomposed tasks (from build())
151
+ # have no backing file, so persistence is expected to be skipped.
152
+ if task.source_ref:
153
+ try:
154
+ with open(task.source_ref, "r", encoding="utf-8") as f:
155
+ post = frontmatter.load(f)
156
+
157
+ post.metadata["status"] = new_status
158
+
159
+ with open(task.source_ref, "w", encoding="utf-8") as f:
160
+ f.write(frontmatter.dumps(post))
161
+ logger.info(f"Updated task {task_id} status to {new_status}")
162
+ except Exception as e:
163
+ logger.error(f"Failed to persist task status for {task_id}: {e}")
File without changes