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,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()