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.
- misterdev/__init__.py +3 -0
- misterdev/agent.py +2166 -0
- misterdev/agent_helpers.py +194 -0
- misterdev/analyzers/__init__.py +0 -0
- misterdev/analyzers/project_analyzer/__init__.py +246 -0
- misterdev/analyzers/project_analyzer/detection.py +146 -0
- misterdev/analyzers/project_analyzer/merge.py +137 -0
- misterdev/analyzers/project_analyzer/overview.py +312 -0
- misterdev/analyzers/project_analyzer/prompts.py +83 -0
- misterdev/cli.py +370 -0
- misterdev/config.py +521 -0
- misterdev/core/__init__.py +0 -0
- misterdev/core/audit.py +88 -0
- misterdev/core/config.py +10 -0
- misterdev/core/context/__init__.py +0 -0
- misterdev/core/context/change_tracker.py +218 -0
- misterdev/core/context/contracts/__init__.py +63 -0
- misterdev/core/context/contracts/_log.py +9 -0
- misterdev/core/context/contracts/_text.py +12 -0
- misterdev/core/context/contracts/extraction.py +30 -0
- misterdev/core/context/contracts/python_generic.py +42 -0
- misterdev/core/context/contracts/registry.py +127 -0
- misterdev/core/context/contracts/rust_line.py +225 -0
- misterdev/core/context/contracts/rust_tree_sitter.py +141 -0
- misterdev/core/context/lsp.py +174 -0
- misterdev/core/context/scratchpad.py +111 -0
- misterdev/core/context/topography/__init__.py +43 -0
- misterdev/core/context/topography/_log.py +10 -0
- misterdev/core/context/topography/cache.py +91 -0
- misterdev/core/context/topography/engine.py +172 -0
- misterdev/core/context/topography/graph.py +675 -0
- misterdev/core/context/topography/nodes.py +55 -0
- misterdev/core/context/topography/parsers.py +95 -0
- misterdev/core/context/topography/syntax.py +54 -0
- misterdev/core/economics/__init__.py +0 -0
- misterdev/core/economics/context_budget.py +175 -0
- misterdev/core/economics/embeddings.py +232 -0
- misterdev/core/economics/free_models.py +108 -0
- misterdev/core/economics/llm_cache.py +105 -0
- misterdev/core/economics/model_catalog.py +79 -0
- misterdev/core/economics/model_ledger.py +331 -0
- misterdev/core/economics/model_selector.py +281 -0
- misterdev/core/execution/__init__.py +0 -0
- misterdev/core/execution/bounded.py +50 -0
- misterdev/core/execution/container.py +221 -0
- misterdev/core/execution/error_classifier.py +366 -0
- misterdev/core/execution/error_resolver.py +201 -0
- misterdev/core/execution/governance.py +283 -0
- misterdev/core/execution/outcomes.py +50 -0
- misterdev/core/execution/progress.py +120 -0
- misterdev/core/execution/project.py +231 -0
- misterdev/core/execution/registry.py +97 -0
- misterdev/core/execution/runtime.py +279 -0
- misterdev/core/gitcmd.py +39 -0
- misterdev/core/integration/__init__.py +0 -0
- misterdev/core/integration/mcp.py +368 -0
- misterdev/core/integration/mcp_gather.py +186 -0
- misterdev/core/models.py +35 -0
- misterdev/core/modes.py +184 -0
- misterdev/core/planning/__init__.py +0 -0
- misterdev/core/planning/advisor.py +89 -0
- misterdev/core/planning/assessment.py +135 -0
- misterdev/core/planning/decomposer.py +387 -0
- misterdev/core/planning/metacognition.py +103 -0
- misterdev/core/planning/sovereign.py +308 -0
- misterdev/core/planning/targets.py +201 -0
- misterdev/core/reporting/__init__.py +0 -0
- misterdev/core/reporting/report.py +377 -0
- misterdev/core/reporting/report_view.py +151 -0
- misterdev/core/task.py +163 -0
- misterdev/core/verification/__init__.py +0 -0
- misterdev/core/verification/claim_verifier.py +210 -0
- misterdev/core/verification/critic.py +324 -0
- misterdev/core/verification/gatekeeper/__init__.py +631 -0
- misterdev/core/verification/gatekeeper/constants.py +138 -0
- misterdev/core/verification/gatekeeper/helpers.py +28 -0
- misterdev/core/verification/goal_check.py +219 -0
- misterdev/core/verification/independent.py +68 -0
- misterdev/core/verification/mutation_gate.py +221 -0
- misterdev/core/verification/preflight.py +95 -0
- misterdev/core/verification/spec_tests.py +175 -0
- misterdev/core/verification/validator.py +495 -0
- misterdev/core/verification/vision_verify.py +185 -0
- misterdev/core/verification/web_verify.py +408 -0
- misterdev/environments/__init__.py +0 -0
- misterdev/environments/base_env.py +18 -0
- misterdev/environments/container_env.py +87 -0
- misterdev/environments/venv_env.py +42 -0
- misterdev/llm/__init__.py +0 -0
- misterdev/llm/client/__init__.py +152 -0
- misterdev/llm/client/base.py +382 -0
- misterdev/llm/client/edits.py +70 -0
- misterdev/llm/client/embeddings.py +121 -0
- misterdev/llm/client/errors.py +134 -0
- misterdev/llm/client/providers.py +535 -0
- misterdev/llm/client/response.py +24 -0
- misterdev/llm/prompt_manager.py +82 -0
- misterdev/llm/responses/__init__.py +34 -0
- misterdev/llm/responses/apply.py +131 -0
- misterdev/llm/responses/json_extract.py +80 -0
- misterdev/llm/responses/models.py +43 -0
- misterdev/llm/responses/parsing.py +494 -0
- misterdev/logging_setup.py +20 -0
- misterdev/mcp_server.py +208 -0
- misterdev/nl_cli.py +149 -0
- misterdev/plugins.py +115 -0
- misterdev/py.typed +0 -0
- misterdev/task_executors/__init__.py +0 -0
- misterdev/task_executors/base_executor.py +10 -0
- misterdev/task_executors/markdown_plan_executor/__init__.py +90 -0
- misterdev/task_executors/markdown_plan_executor/commands_mixin.py +82 -0
- misterdev/task_executors/markdown_plan_executor/context_mixin.py +221 -0
- misterdev/task_executors/markdown_plan_executor/critic_spec_mixin.py +174 -0
- misterdev/task_executors/markdown_plan_executor/edits_mixin.py +251 -0
- misterdev/task_executors/markdown_plan_executor/execute_mixin.py +727 -0
- misterdev/task_executors/markdown_plan_executor/gates_mixin.py +203 -0
- misterdev/task_executors/markdown_plan_executor/git_mixin.py +219 -0
- misterdev/task_executors/markdown_plan_executor/helpers.py +521 -0
- misterdev/task_executors/markdown_plan_executor/llm_mixin.py +238 -0
- misterdev/task_executors/markdown_plan_executor/results_mixin.py +23 -0
- misterdev/tools/__init__.py +19 -0
- misterdev/tools/base_tool.py +14 -0
- misterdev/tools/command.py +75 -0
- misterdev/tools/file_io.py +69 -0
- misterdev/tools/formatter.py +26 -0
- misterdev/tools/git_tool.py +90 -0
- misterdev/utils/__init__.py +0 -0
- misterdev/utils/file_utils.py +169 -0
- misterdev/utils/process.py +23 -0
- misterdev-0.2.0.dist-info/METADATA +326 -0
- misterdev-0.2.0.dist-info/RECORD +136 -0
- misterdev-0.2.0.dist-info/WHEEL +5 -0
- misterdev-0.2.0.dist-info/entry_points.txt +3 -0
- misterdev-0.2.0.dist-info/licenses/COMMERCIAL_LICENSE.md +34 -0
- misterdev-0.2.0.dist-info/licenses/LICENSE +661 -0
- 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
|