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,203 @@
|
|
|
1
|
+
"""Test-gate acceptance, acceptance-criteria verification, and error context."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional, Tuple
|
|
4
|
+
|
|
5
|
+
from misterdev.core.models import Task
|
|
6
|
+
from misterdev.core.execution.project import Project
|
|
7
|
+
from misterdev.core.execution.error_classifier import classify_error, ErrorCategory
|
|
8
|
+
|
|
9
|
+
from .helpers import logger, _extract_acceptance_command, JUDGE_MIN_BUDGET_FRACTION
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GatesMixin:
|
|
13
|
+
@staticmethod
|
|
14
|
+
def _prior_failures_history(prior_errors: List[str]) -> str:
|
|
15
|
+
"""Render all-but-the-latest prior attempt errors as a retry-context
|
|
16
|
+
header, or '' when there is no earlier failure to summarize."""
|
|
17
|
+
if len(prior_errors) <= 1:
|
|
18
|
+
return ""
|
|
19
|
+
past = "\n".join(f"- {e}" for e in prior_errors[:-1])
|
|
20
|
+
return (
|
|
21
|
+
"### Previous Attempt Failures (a different approach is required)\n"
|
|
22
|
+
f"{past}\n\n"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def _build_error_context(
|
|
26
|
+
self,
|
|
27
|
+
prior_errors: List[str],
|
|
28
|
+
attempt: int,
|
|
29
|
+
output: str,
|
|
30
|
+
classified: str,
|
|
31
|
+
attributed_error: str,
|
|
32
|
+
) -> str:
|
|
33
|
+
"""Combine the current error with a summary of prior failed attempts.
|
|
34
|
+
|
|
35
|
+
Surfacing what already failed (and how it was classified) stops the LLM
|
|
36
|
+
from re-submitting the same broken fix across retries.
|
|
37
|
+
"""
|
|
38
|
+
prior_errors.append(f"Attempt {attempt + 1}: {classify_error(output)}")
|
|
39
|
+
history = self._prior_failures_history(prior_errors)
|
|
40
|
+
return f"{history}{classified}\n\n{attributed_error}"
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def _gate_accepts(
|
|
44
|
+
success: bool, output: str, baseline_failures: int
|
|
45
|
+
) -> Tuple[bool, Optional[int]]:
|
|
46
|
+
"""Whether the test gate accepts this attempt, plus the parsed failure count.
|
|
47
|
+
|
|
48
|
+
A green suite always passes (returns ``(True, 0)``). On a RED baseline
|
|
49
|
+
(``baseline_failures`` > 0), an attempt that leaves the suite no worse —
|
|
50
|
+
parsed failures <= baseline — also passes, so a multi-failure project can
|
|
51
|
+
be reduced one task at a time instead of requiring a single task to make
|
|
52
|
+
the whole suite green. An unparseable red result stays strict (rejected),
|
|
53
|
+
since we will not accept on a number we cannot read.
|
|
54
|
+
"""
|
|
55
|
+
if success:
|
|
56
|
+
return True, 0
|
|
57
|
+
if baseline_failures <= 0:
|
|
58
|
+
return False, None
|
|
59
|
+
from misterdev.core.verification.validator import (
|
|
60
|
+
_parse_test_counts,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
total, post = _parse_test_counts(output)
|
|
64
|
+
if total > 0 and post <= baseline_failures:
|
|
65
|
+
return True, post
|
|
66
|
+
return False, post if total > 0 else None
|
|
67
|
+
|
|
68
|
+
def _verify_acceptance(
|
|
69
|
+
self,
|
|
70
|
+
project: Project,
|
|
71
|
+
task: Task,
|
|
72
|
+
verify_acceptance: bool,
|
|
73
|
+
llm_acceptance_judge: bool,
|
|
74
|
+
timeout: int,
|
|
75
|
+
cwd=None,
|
|
76
|
+
) -> Tuple[bool, str]:
|
|
77
|
+
"""Verify the task's acceptance_criteria after build/test gates pass.
|
|
78
|
+
|
|
79
|
+
Returns (passed, output). Deterministic primary path: extract an
|
|
80
|
+
explicit runnable command from acceptance_criteria and run it; a
|
|
81
|
+
non-zero exit fails acceptance. When acceptance_criteria is empty, the
|
|
82
|
+
gate is disabled, or no command can be confidently extracted, this is a
|
|
83
|
+
no-op that passes (behaviour identical to before this gate) unless the
|
|
84
|
+
default-off ``orchestrator.llm_acceptance_judge`` flag is set, in which
|
|
85
|
+
case an LLM judge is consulted. Never blocks on un-parseable free text.
|
|
86
|
+
"""
|
|
87
|
+
if not verify_acceptance:
|
|
88
|
+
return True, ""
|
|
89
|
+
criteria = (task.acceptance_criteria or "").strip()
|
|
90
|
+
if not criteria:
|
|
91
|
+
return True, ""
|
|
92
|
+
command = _extract_acceptance_command(criteria)
|
|
93
|
+
if command:
|
|
94
|
+
logger.info(f"Verifying acceptance criteria via command: {command}")
|
|
95
|
+
success, output = self._run_command(
|
|
96
|
+
project, command, timeout=timeout, cwd=cwd
|
|
97
|
+
)
|
|
98
|
+
if success:
|
|
99
|
+
logger.info("Acceptance criteria command passed.")
|
|
100
|
+
return True, ""
|
|
101
|
+
# A command that can't locate the project manifest is a BROKEN
|
|
102
|
+
# acceptance command, not failing code: acceptance runs only AFTER
|
|
103
|
+
# the build/test gates pass, so the manifest demonstrably exists —
|
|
104
|
+
# a MANIFEST error here means the extracted command is malformed
|
|
105
|
+
# (the emathy run lost `--manifest-path` and every such task
|
|
106
|
+
# false-failed on `cargo test` from the repo root). Treat it as a
|
|
107
|
+
# pass-through. A genuine missing test path (FILE_NOT_FOUND) is left
|
|
108
|
+
# as a real failure.
|
|
109
|
+
if classify_error(output) == ErrorCategory.MANIFEST:
|
|
110
|
+
logger.warning(
|
|
111
|
+
"Acceptance command could not locate the project manifest; "
|
|
112
|
+
"the build/test gates already passed, so treating acceptance "
|
|
113
|
+
f"as satisfied rather than failing on a broken command: {command}"
|
|
114
|
+
)
|
|
115
|
+
return True, ""
|
|
116
|
+
return False, (
|
|
117
|
+
f"Acceptance criterion not met: `{criteria}`\n"
|
|
118
|
+
f"Ran: {command}\n"
|
|
119
|
+
f"Command exited non-zero:\n{output}"
|
|
120
|
+
)
|
|
121
|
+
if llm_acceptance_judge and self._judge_affordable(project):
|
|
122
|
+
return self._llm_acceptance_judge(project, task, criteria)
|
|
123
|
+
return True, ""
|
|
124
|
+
|
|
125
|
+
def _judge_affordable(self, project: Project) -> bool:
|
|
126
|
+
"""True while enough budget remains to spend on the LLM acceptance judge.
|
|
127
|
+
|
|
128
|
+
Cost control for the (now default-on) judge: once the run has burned
|
|
129
|
+
through all but ``JUDGE_MIN_BUDGET_FRACTION`` of the budget, stop paying
|
|
130
|
+
for free-text judging and let those criteria pass, reserving the last
|
|
131
|
+
funds for actually fixing code. Fail-open when budget can't be read.
|
|
132
|
+
"""
|
|
133
|
+
client = project.llm_client
|
|
134
|
+
remaining = getattr(client, "budget_remaining", None)
|
|
135
|
+
total = getattr(client, "_budget", None)
|
|
136
|
+
if not isinstance(remaining, (int, float)) or not isinstance(
|
|
137
|
+
total, (int, float)
|
|
138
|
+
):
|
|
139
|
+
return True
|
|
140
|
+
if total <= 0:
|
|
141
|
+
return True
|
|
142
|
+
return remaining > total * JUDGE_MIN_BUDGET_FRACTION
|
|
143
|
+
|
|
144
|
+
def _judge_generate(self, project: Project, prompt: str) -> str:
|
|
145
|
+
"""Run an acceptance-judge prompt, on the INDEPENDENT ``judge.model`` when
|
|
146
|
+
configured (so the judge doesn't share the generator's blind spots), else
|
|
147
|
+
on the generator's own model. Routed through ``with_model`` when possible.
|
|
148
|
+
"""
|
|
149
|
+
from misterdev.core.verification.independent import (
|
|
150
|
+
generate_independent,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
judge_model = (project.config.get("judge") or {}).get("model")
|
|
154
|
+
return generate_independent(project.llm_client, prompt, "", model=judge_model)
|
|
155
|
+
|
|
156
|
+
def _llm_acceptance_judge(
|
|
157
|
+
self, project: Project, task: Task, criteria: str
|
|
158
|
+
) -> Tuple[bool, str]:
|
|
159
|
+
"""Default-off LLM fallback judging free-text acceptance criteria.
|
|
160
|
+
|
|
161
|
+
Only reached when ``orchestrator.llm_acceptance_judge`` is true and no
|
|
162
|
+
runnable command could be extracted. A failure to reach a confident
|
|
163
|
+
verdict passes (fail-open) so an unreliable judge never blocks a task.
|
|
164
|
+
"""
|
|
165
|
+
try:
|
|
166
|
+
prompt = (
|
|
167
|
+
"A code task has just passed its build and test gates. Judge "
|
|
168
|
+
"ONLY whether the stated acceptance criterion is satisfied by "
|
|
169
|
+
"the task's implementation. Reply with PASS or FAIL on the "
|
|
170
|
+
"first line, then a brief reason.\n\n"
|
|
171
|
+
f"Task: {task.description}\n"
|
|
172
|
+
f"Acceptance criterion: {criteria}\n"
|
|
173
|
+
)
|
|
174
|
+
verdict = self._judge_generate(project, prompt)
|
|
175
|
+
except Exception as e:
|
|
176
|
+
logger.warning(f"LLM acceptance judge failed, passing open: {e}")
|
|
177
|
+
return True, ""
|
|
178
|
+
first = (verdict or "").strip().splitlines()
|
|
179
|
+
if first and first[0].strip().upper().startswith("FAIL"):
|
|
180
|
+
return False, (
|
|
181
|
+
f"Acceptance criterion not met (LLM judge): `{criteria}`\n{verdict}"
|
|
182
|
+
)
|
|
183
|
+
return True, ""
|
|
184
|
+
|
|
185
|
+
def _build_acceptance_error_context(
|
|
186
|
+
self,
|
|
187
|
+
prior_errors: List[str],
|
|
188
|
+
attempt: int,
|
|
189
|
+
task: Task,
|
|
190
|
+
classified: str,
|
|
191
|
+
) -> str:
|
|
192
|
+
"""Format an acceptance failure into the same retry context as other gates.
|
|
193
|
+
|
|
194
|
+
Makes the unmet criterion explicit so the next attempt targets it rather
|
|
195
|
+
than re-submitting a change that only satisfies the build/test gates.
|
|
196
|
+
"""
|
|
197
|
+
prior_errors.append(f"Attempt {attempt + 1}: acceptance criteria not met")
|
|
198
|
+
history = self._prior_failures_history(prior_errors)
|
|
199
|
+
return (
|
|
200
|
+
f"{history}### Acceptance criterion not met\n"
|
|
201
|
+
f"The build and tests passed, but the task's acceptance criterion "
|
|
202
|
+
f"was not satisfied:\n{task.acceptance_criteria}\n\n{classified}"
|
|
203
|
+
)
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Git branch-per-task operations and regression bisection."""
|
|
2
|
+
|
|
3
|
+
import shlex
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from misterdev.core.models import Task
|
|
8
|
+
from misterdev.core.execution.project import Project
|
|
9
|
+
from misterdev.core.verification.validator import _run_cmd
|
|
10
|
+
|
|
11
|
+
from .helpers import logger, _bisect_first_failing
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GitMixin:
|
|
15
|
+
# ----------------------------------------------------------------
|
|
16
|
+
# Git branch-per-task operations
|
|
17
|
+
# ----------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
def _is_git_repo(self, project: Project) -> bool:
|
|
20
|
+
return (project.path / ".git").exists()
|
|
21
|
+
|
|
22
|
+
# ----------------------------------------------------------------
|
|
23
|
+
# Regression bisection (post-build gate failure)
|
|
24
|
+
# ----------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
def find_task_commit(self, project: Project, task_id: str) -> Optional[str]:
|
|
27
|
+
"""SHA of the commit recording a task (message 'task(<id>):'), or None."""
|
|
28
|
+
ok, out = self._git(
|
|
29
|
+
project,
|
|
30
|
+
f"git log --all -n 1 --format=%H --fixed-strings --grep={shlex.quote(f'task({task_id}):')}",
|
|
31
|
+
)
|
|
32
|
+
sha = out.strip().splitlines()[0] if ok and out.strip() else ""
|
|
33
|
+
return sha or None
|
|
34
|
+
|
|
35
|
+
def bisect_regression(
|
|
36
|
+
self,
|
|
37
|
+
project: Project,
|
|
38
|
+
task_commits: List,
|
|
39
|
+
test_command: str,
|
|
40
|
+
timeout: int = 180,
|
|
41
|
+
) -> Optional[str]:
|
|
42
|
+
"""Find the earliest task commit whose checkout fails the test command.
|
|
43
|
+
|
|
44
|
+
task_commits is [(task_id, sha)] ordered oldest->newest. Returns the
|
|
45
|
+
culprit task_id, or None if no checked-out commit actually fails (so a
|
|
46
|
+
flaky/non-task regression isn't misattributed). Restores HEAD after.
|
|
47
|
+
"""
|
|
48
|
+
if not task_commits:
|
|
49
|
+
return None
|
|
50
|
+
# Restore to the BRANCH, not a bare SHA: checking out a SHA detaches
|
|
51
|
+
# HEAD, and a mid-build gate that left HEAD detached would make every
|
|
52
|
+
# subsequent task branch from / merge into a detached head instead of
|
|
53
|
+
# the working branch, silently diverging the build from the branch ref.
|
|
54
|
+
okb, branch = self._git(project, "git rev-parse --abbrev-ref HEAD")
|
|
55
|
+
branch = branch.strip() if okb else ""
|
|
56
|
+
if branch and branch != "HEAD":
|
|
57
|
+
restore = branch
|
|
58
|
+
else:
|
|
59
|
+
ok, head = self._git(project, "git rev-parse HEAD")
|
|
60
|
+
restore = head.strip() if ok else None
|
|
61
|
+
|
|
62
|
+
def passes_at(i: int) -> bool:
|
|
63
|
+
self._git(project, f"git checkout {shlex.quote(task_commits[i][1])}")
|
|
64
|
+
success, _ = self._run_command(project, test_command, timeout=timeout)
|
|
65
|
+
return success
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
idx = _bisect_first_failing(len(task_commits), passes_at)
|
|
69
|
+
culprit = None if passes_at(idx) else task_commits[idx][0]
|
|
70
|
+
finally:
|
|
71
|
+
if restore:
|
|
72
|
+
self._git(project, f"git checkout {shlex.quote(restore)}")
|
|
73
|
+
return culprit
|
|
74
|
+
|
|
75
|
+
def revert_task_commit(self, project: Project, sha: str) -> bool:
|
|
76
|
+
"""Revert a task's commit, leaving an explicit revert commit.
|
|
77
|
+
|
|
78
|
+
Aborts cleanly on conflict so a failed revert never leaves the working
|
|
79
|
+
tree in a half-reverted, conflict-marked state that would break later
|
|
80
|
+
suite runs and checkouts.
|
|
81
|
+
"""
|
|
82
|
+
ok, _ = self._git(project, f"git revert --no-edit {shlex.quote(sha)}")
|
|
83
|
+
if not ok:
|
|
84
|
+
self._git(project, "git revert --abort")
|
|
85
|
+
return ok
|
|
86
|
+
|
|
87
|
+
def _get_current_branch(self, project: Project) -> Optional[str]:
|
|
88
|
+
ok, output = self._git(project, "git rev-parse --abbrev-ref HEAD")
|
|
89
|
+
return output.strip() if ok else None
|
|
90
|
+
|
|
91
|
+
def _create_task_branch(self, project: Project, branch_name: str) -> bool:
|
|
92
|
+
ok, _ = self._git(project, f"git checkout -b {shlex.quote(branch_name)}")
|
|
93
|
+
if ok:
|
|
94
|
+
logger.info(f"Created task branch: {branch_name}")
|
|
95
|
+
return ok
|
|
96
|
+
|
|
97
|
+
def _commit_task(
|
|
98
|
+
self,
|
|
99
|
+
project: Project,
|
|
100
|
+
branch_name: Optional[str],
|
|
101
|
+
base_branch: Optional[str],
|
|
102
|
+
task: Task,
|
|
103
|
+
files: Optional[List[str]] = None,
|
|
104
|
+
):
|
|
105
|
+
"""Commit the task's own changes and merge the task branch back to base.
|
|
106
|
+
|
|
107
|
+
Stages only the named files, never `git add -A`: a blanket add sweeps
|
|
108
|
+
unrelated untracked files (other uncommitted user work, or files carried
|
|
109
|
+
onto the task branch) into the commit, which then get destroyed if the
|
|
110
|
+
task is later reverted. With no files, commit empty rather than sweep.
|
|
111
|
+
"""
|
|
112
|
+
msg = f"task({task.id}): {task.title or task.description[:50]}"
|
|
113
|
+
stage = list(files or [])
|
|
114
|
+
# Also commit the task's own source markdown so a status:completed write
|
|
115
|
+
# rides into the merge. Without this the status write is left uncommitted
|
|
116
|
+
# and the NEXT task's `git checkout -- .` (below) wipes it, so a finished
|
|
117
|
+
# devplan showed every task still "pending" (only the last survived).
|
|
118
|
+
source_rel = self._repo_relative(project, getattr(task, "source_ref", None))
|
|
119
|
+
if source_rel:
|
|
120
|
+
stage.append(source_rel)
|
|
121
|
+
if stage:
|
|
122
|
+
quoted = " ".join(shlex.quote(f) for f in stage)
|
|
123
|
+
self._git(project, f"git add -- {quoted}")
|
|
124
|
+
self._git(project, f"git commit -m {shlex.quote(msg)} --allow-empty")
|
|
125
|
+
|
|
126
|
+
if branch_name and base_branch:
|
|
127
|
+
# Drop tracked spillover before switching branches: a project-wide
|
|
128
|
+
# formatter (e.g. `ruff format .`) reformats files outside the task,
|
|
129
|
+
# which aren't committed and would otherwise be carried across the
|
|
130
|
+
# checkout and accumulate as a permanently dirty tree.
|
|
131
|
+
self._git(project, "git checkout -- .")
|
|
132
|
+
self._git(project, f"git checkout {shlex.quote(base_branch)}")
|
|
133
|
+
ok, output = self._git(
|
|
134
|
+
project,
|
|
135
|
+
f"git merge --no-ff {shlex.quote(branch_name)} -m {shlex.quote(f'Merge {branch_name}')}",
|
|
136
|
+
)
|
|
137
|
+
if ok:
|
|
138
|
+
self._git(project, f"git branch -d {shlex.quote(branch_name)}")
|
|
139
|
+
logger.info(f"Merged and cleaned up branch: {branch_name}")
|
|
140
|
+
else:
|
|
141
|
+
logger.error(f"Merge failed for {branch_name}: {output}")
|
|
142
|
+
|
|
143
|
+
def _repo_relative(self, project: Project, path: Optional[str]) -> Optional[str]:
|
|
144
|
+
"""Repo-relative form of a path inside the project, else None.
|
|
145
|
+
|
|
146
|
+
Returns None for an empty path or one outside the repo (e.g. a
|
|
147
|
+
decomposed build() task with no backing file), so those are never staged.
|
|
148
|
+
"""
|
|
149
|
+
if not path:
|
|
150
|
+
return None
|
|
151
|
+
try:
|
|
152
|
+
return str(Path(path).resolve().relative_to(project.path.resolve()))
|
|
153
|
+
except ValueError:
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
def _untracked_files(self, project: Project) -> set:
|
|
157
|
+
"""Set of untracked (non-ignored) paths in the working tree.
|
|
158
|
+
|
|
159
|
+
Used to bound revert cleanup: only files that became untracked DURING a
|
|
160
|
+
task are removed, so a user's pre-existing untracked work is never
|
|
161
|
+
touched.
|
|
162
|
+
"""
|
|
163
|
+
ok, out = self._git(project, "git status --porcelain --untracked-files=all")
|
|
164
|
+
if not ok:
|
|
165
|
+
return set()
|
|
166
|
+
paths = set()
|
|
167
|
+
for line in out.splitlines():
|
|
168
|
+
if line.startswith("?? "):
|
|
169
|
+
paths.add(line[3:].strip().strip('"'))
|
|
170
|
+
return paths
|
|
171
|
+
|
|
172
|
+
def _abort_task(
|
|
173
|
+
self,
|
|
174
|
+
project: Project,
|
|
175
|
+
branch_name: Optional[str],
|
|
176
|
+
base_branch: Optional[str],
|
|
177
|
+
snapshot: Optional[Dict],
|
|
178
|
+
untracked_before: Optional[set] = None,
|
|
179
|
+
):
|
|
180
|
+
"""Revert a failed task: delete branch or restore file snapshots.
|
|
181
|
+
|
|
182
|
+
Also removes files the task left UNTRACKED (new files it wrote, whether
|
|
183
|
+
or not committed on the branch) — ``git reset --hard`` reverts tracked
|
|
184
|
+
changes but leaves those orphans behind, which otherwise accumulate in
|
|
185
|
+
the tree (the emathy run left signing.rs/crdt.rs/out/ this way). Only the
|
|
186
|
+
delta against ``untracked_before`` is cleaned, so pre-existing untracked
|
|
187
|
+
files are preserved.
|
|
188
|
+
"""
|
|
189
|
+
if branch_name and base_branch:
|
|
190
|
+
self._git(project, "git reset --hard")
|
|
191
|
+
self._git(project, f"git checkout {shlex.quote(base_branch)}")
|
|
192
|
+
self._git(project, f"git branch -D {shlex.quote(branch_name)}")
|
|
193
|
+
logger.info(f"Aborted and deleted branch: {branch_name}")
|
|
194
|
+
self._clean_task_orphans(project, untracked_before)
|
|
195
|
+
elif snapshot is not None:
|
|
196
|
+
self._revert_files(project, snapshot)
|
|
197
|
+
self._clean_task_orphans(project, untracked_before)
|
|
198
|
+
# The revert changed the tree back; drop the stale graph so the next task
|
|
199
|
+
# rebuilds from the restored state.
|
|
200
|
+
topo = getattr(project, "topography", None)
|
|
201
|
+
if topo is not None and hasattr(topo, "invalidate"):
|
|
202
|
+
topo.invalidate()
|
|
203
|
+
|
|
204
|
+
def _clean_task_orphans(
|
|
205
|
+
self, project: Project, untracked_before: Optional[set]
|
|
206
|
+
) -> None:
|
|
207
|
+
"""Remove only files that became untracked during the aborted task."""
|
|
208
|
+
if untracked_before is None:
|
|
209
|
+
return
|
|
210
|
+
new_orphans = self._untracked_files(project) - untracked_before
|
|
211
|
+
if not new_orphans:
|
|
212
|
+
return
|
|
213
|
+
paths = " ".join(shlex.quote(p) for p in sorted(new_orphans))
|
|
214
|
+
self._git(project, f"git clean -fd -- {paths}")
|
|
215
|
+
logger.info(f"Cleaned {len(new_orphans)} orphan file(s) from reverted task.")
|
|
216
|
+
|
|
217
|
+
def _git(self, project: Project, command: str) -> tuple:
|
|
218
|
+
ok, output = _run_cmd(command, project.path, None, timeout=30)
|
|
219
|
+
return ok, output.strip()
|