local-coding-agent 1.1.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 (101) hide show
  1. local_coding_agent/__init__.py +314 -0
  2. local_coding_agent/__main__.py +5 -0
  3. local_coding_agent/acp_server/__init__.py +38 -0
  4. local_coding_agent/acp_server/_codec.py +95 -0
  5. local_coding_agent/acp_server/_protocol.py +13 -0
  6. local_coding_agent/acp_server/_server.py +663 -0
  7. local_coding_agent/acp_server/_session.py +24 -0
  8. local_coding_agent/acp_server/_tools.py +126 -0
  9. local_coding_agent/ast_compactor.py +130 -0
  10. local_coding_agent/atomizer.py +77 -0
  11. local_coding_agent/benchmark/__init__.py +57 -0
  12. local_coding_agent/benchmark/_cases.py +307 -0
  13. local_coding_agent/benchmark/_judge.py +175 -0
  14. local_coding_agent/benchmark/_oracles.py +157 -0
  15. local_coding_agent/benchmark/_runner.py +259 -0
  16. local_coding_agent/benchmark/_stats.py +22 -0
  17. local_coding_agent/benchmark/_summary.py +143 -0
  18. local_coding_agent/benchmark_oracle_worker.py +169 -0
  19. local_coding_agent/calibration.py +154 -0
  20. local_coding_agent/capability.py +450 -0
  21. local_coding_agent/chat_compaction.py +276 -0
  22. local_coding_agent/cli/__init__.py +12 -0
  23. local_coding_agent/cli/_dispatch.py +377 -0
  24. local_coding_agent/cli/_handlers.py +320 -0
  25. local_coding_agent/cli/_handlers2.py +725 -0
  26. local_coding_agent/cli/_input.py +61 -0
  27. local_coding_agent/cli/_main.py +159 -0
  28. local_coding_agent/cli/_parser.py +327 -0
  29. local_coding_agent/context_manager.py +155 -0
  30. local_coding_agent/controller/__init__.py +18 -0
  31. local_coding_agent/controller/_constants.py +108 -0
  32. local_coding_agent/controller/_controller.py +1008 -0
  33. local_coding_agent/controller/_post_apply.py +216 -0
  34. local_coding_agent/delegator.py +180 -0
  35. local_coding_agent/desktop/__init__.py +8 -0
  36. local_coding_agent/desktop/app.py +100 -0
  37. local_coding_agent/desktop/assets/lucide.min.js +12 -0
  38. local_coding_agent/desktop/assets/tailwind.css +1 -0
  39. local_coding_agent/desktop/client_js.py +1271 -0
  40. local_coding_agent/desktop/components.py +555 -0
  41. local_coding_agent/desktop/server/__init__.py +37 -0
  42. local_coding_agent/desktop/server/_handlers.py +2178 -0
  43. local_coding_agent/desktop/server/_models.py +204 -0
  44. local_coding_agent/desktop/server/_server.py +347 -0
  45. local_coding_agent/desktop/server/_telemetry.py +87 -0
  46. local_coding_agent/desktop/styles.py +76 -0
  47. local_coding_agent/desktop/ui.py +53 -0
  48. local_coding_agent/doctor.py +413 -0
  49. local_coding_agent/hooks/__init__.py +19 -0
  50. local_coding_agent/hooks/_adapters.py +458 -0
  51. local_coding_agent/hooks/_bridge.py +346 -0
  52. local_coding_agent/hooks/_decision.py +26 -0
  53. local_coding_agent/lsp/__init__.py +60 -0
  54. local_coding_agent/lsp/_client.py +322 -0
  55. local_coding_agent/lsp/_fallback.py +297 -0
  56. local_coding_agent/lsp/_manager.py +173 -0
  57. local_coding_agent/lsp/_mock.py +200 -0
  58. local_coding_agent/lsp/_types.py +398 -0
  59. local_coding_agent/mcp_config.py +398 -0
  60. local_coding_agent/mcp_server.py +377 -0
  61. local_coding_agent/memory.py +140 -0
  62. local_coding_agent/mode_router.py +200 -0
  63. local_coding_agent/model_scanner.py +525 -0
  64. local_coding_agent/monitor.py +635 -0
  65. local_coding_agent/observation_policy.py +184 -0
  66. local_coding_agent/ollama_adapter.py +736 -0
  67. local_coding_agent/plan_mode.py +653 -0
  68. local_coding_agent/prescriptions.py +278 -0
  69. local_coding_agent/profiles.py +239 -0
  70. local_coding_agent/repository_tools.py +782 -0
  71. local_coding_agent/ripgrep.py +326 -0
  72. local_coding_agent/semantic_linter.py +122 -0
  73. local_coding_agent/service.py +405 -0
  74. local_coding_agent/session_events.py +684 -0
  75. local_coding_agent/session_query.py +578 -0
  76. local_coding_agent/skill_config.py +426 -0
  77. local_coding_agent/smoke.py +268 -0
  78. local_coding_agent/speculative_racing.py +85 -0
  79. local_coding_agent/spill.py +258 -0
  80. local_coding_agent/stats.py +255 -0
  81. local_coding_agent/stdio.py +132 -0
  82. local_coding_agent/subagent.py +516 -0
  83. local_coding_agent/task.py +90 -0
  84. local_coding_agent/task_store.py +142 -0
  85. local_coding_agent/tasks.py +240 -0
  86. local_coding_agent/terminal/__init__.py +35 -0
  87. local_coding_agent/terminal/_manager.py +156 -0
  88. local_coding_agent/terminal/_process.py +148 -0
  89. local_coding_agent/terminal/_session.py +452 -0
  90. local_coding_agent/terminal/_tools.py +233 -0
  91. local_coding_agent/terminal/_types.py +60 -0
  92. local_coding_agent/tool_call_parser.py +178 -0
  93. local_coding_agent/validators.py +563 -0
  94. local_coding_agent/vram_fit.py +240 -0
  95. local_coding_agent/worker_pool.py +516 -0
  96. local_coding_agent-1.1.0.dist-info/METADATA +202 -0
  97. local_coding_agent-1.1.0.dist-info/RECORD +101 -0
  98. local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
  99. local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
  100. local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
  101. local_coding_agent-1.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,563 @@
1
+ """Independent validation of the model's structured candidate result."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import os
7
+ import re
8
+ import shutil
9
+ import subprocess
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any, Mapping
13
+
14
+ from .task import TaskEnvelope
15
+
16
+
17
+ def _fold_path(path: str) -> str:
18
+ # ponytail: treat only Windows as case-insensitive; macOS default is
19
+ # case-insensitive but this keeps the common Linux case strict.
20
+ return path.casefold() if os.name == "nt" else path
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ValidationReport:
25
+ valid: bool
26
+ changed_files: tuple[str, ...]
27
+ issues: tuple[str, ...]
28
+ resolved_patch: str = ""
29
+
30
+
31
+ def _split_edit_lines(text: str) -> list[str]:
32
+ if text == "":
33
+ return []
34
+ lines = text.split("\n")
35
+ if text.endswith("\n"):
36
+ lines.pop()
37
+ return lines
38
+
39
+
40
+ def _build_edit_diff(
41
+ path: str,
42
+ search: str,
43
+ replace: str,
44
+ start_line: int,
45
+ old_no_newline: bool,
46
+ new_no_newline: bool,
47
+ ) -> str:
48
+ """Build a minimal single-file unified diff replacing ``search`` with ``replace``.
49
+
50
+ ``search`` is a line-aligned block already located by the caller; the diff
51
+ is controller-owned, so the model never computes line numbers or hunk
52
+ headers. ``old_no_newline`` marks the removed block's last line as lacking
53
+ a trailing newline in the file (i.e. it reaches EOF without one).
54
+ """
55
+
56
+ old_lines = _split_edit_lines(search)
57
+ new_lines = _split_edit_lines(replace)
58
+ header = f"@@ -{start_line},{len(old_lines)} +{start_line},{len(new_lines)} @@"
59
+ body: list[str] = []
60
+ for position, line in enumerate(old_lines):
61
+ body.append("-" + line)
62
+ if position == len(old_lines) - 1 and old_no_newline:
63
+ body.append("\")
64
+ for position, line in enumerate(new_lines):
65
+ body.append("+" + line)
66
+ if position == len(new_lines) - 1 and new_no_newline:
67
+ body.append("\")
68
+ hunk = "\n".join([header, *body]) if body else header
69
+ return (
70
+ f"diff --git a/{path} b/{path}\n"
71
+ f"--- a/{path}\n"
72
+ f"+++ b/{path}\n"
73
+ f"{hunk}\n"
74
+ )
75
+
76
+
77
+ def resolve_edits(
78
+ workspace_root: str | Path,
79
+ edits: Any,
80
+ *,
81
+ allowed_files: set[str],
82
+ max_files: int,
83
+ max_patch_bytes: int,
84
+ ) -> tuple[str, tuple[str, ...], list[str]]:
85
+ """Validate SEARCH/REPLACE edit blocks and synthesize one unified diff.
86
+
87
+ Returns ``(patch, changed_files, issues)``. An edit block is
88
+ ``{"file": ..., "search": ..., "replace": ...}``. ``search`` must match the
89
+ current file content exactly once and be line-aligned, so a model only has
90
+ to copy existing code instead of computing line numbers.
91
+ """
92
+
93
+ issues: list[str] = []
94
+ changed: list[str] = []
95
+ diff_parts: list[str] = []
96
+ root = Path(workspace_root)
97
+ seen: set[str] = set()
98
+ edits_by_file: dict[str, list[dict[str, Any]]] = {}
99
+
100
+ for edit in edits:
101
+ if not isinstance(edit, Mapping):
102
+ issues.append("each edit must be an object")
103
+ continue
104
+ raw_path = edit.get("file")
105
+ search = edit.get("search")
106
+ replace = edit.get("replace")
107
+ if not isinstance(raw_path, str) or not raw_path.strip():
108
+ issues.append("edit file must be a non-empty string")
109
+ continue
110
+ normalized, path_issue = _normalize_diff_path(raw_path)
111
+ if path_issue:
112
+ issues.append(path_issue)
113
+ continue
114
+ if normalized is None:
115
+ issues.append(f"edit file path is empty: {raw_path}")
116
+ continue
117
+ if _fold_path(normalized) not in allowed_files:
118
+ issues.append(f"edit file is outside task allowlist: {raw_path}")
119
+ continue
120
+ if not isinstance(search, str) or not search:
121
+ issues.append("edit search must be a non-empty string")
122
+ continue
123
+ if not isinstance(replace, str):
124
+ issues.append("edit replace must be a string")
125
+ continue
126
+ seen.add(_fold_path(normalized))
127
+ edits_by_file.setdefault(normalized, []).append(
128
+ {"raw_path": raw_path, "search": search, "replace": replace}
129
+ )
130
+
131
+ for normalized, file_edits in edits_by_file.items():
132
+ target = root / Path(normalized)
133
+ if not target.is_file():
134
+ issues.append(f"edit file does not exist: {file_edits[0]['raw_path']}")
135
+ continue
136
+ try:
137
+ original_content = target.read_text(encoding="utf-8")
138
+ except UnicodeDecodeError:
139
+ issues.append(f"edit file is not UTF-8 text: {file_edits[0]['raw_path']}")
140
+ continue
141
+
142
+ if len(file_edits) == 1:
143
+ e = file_edits[0]
144
+ search = e["search"]
145
+ replace = e["replace"]
146
+ raw_path = e["raw_path"]
147
+ count = original_content.count(search)
148
+ if count == 0 and "\\n" in search:
149
+ candidate_search = search.replace("\\r\\n", "\n").replace("\\n", "\n")
150
+ if original_content.count(candidate_search) > 0:
151
+ search = candidate_search
152
+ count = original_content.count(search)
153
+ if "\\n" in replace:
154
+ replace = replace.replace("\\r\\n", "\n").replace("\\n", "\n")
155
+ if count == 0:
156
+ issues.append(f"edit search block not found: {raw_path}")
157
+ continue
158
+ if count > 1:
159
+ issues.append(f"edit search block is ambiguous ({count} matches): {raw_path}")
160
+ continue
161
+ index = original_content.find(search)
162
+ end = index + len(search)
163
+ is_start_aligned = index == 0 or original_content[index - 1] == "\n"
164
+ is_end_aligned = (
165
+ end == len(original_content)
166
+ or search.endswith("\n")
167
+ or original_content[end] == "\n"
168
+ or (original_content[end] == "\r" and end + 1 < len(original_content) and original_content[end + 1] == "\n")
169
+ )
170
+ if not (is_start_aligned and is_end_aligned):
171
+ line_start = original_content.rfind("\n", 0, index) + 1 if index > 0 else 0
172
+ line_end = original_content.find("\n", end)
173
+ if line_end == -1:
174
+ line_end = len(original_content)
175
+ prefix = original_content[line_start:index]
176
+ suffix = original_content[end:line_end]
177
+ search = original_content[line_start:line_end]
178
+ replace = prefix + replace + suffix
179
+ index = line_start
180
+ end = line_end
181
+
182
+ start_line = original_content[:index].count("\n") + 1
183
+ old_no_newline = end == len(original_content) and not original_content.endswith("\n")
184
+ new_no_newline = end == len(original_content) and bool(replace) and not replace.endswith("\n")
185
+ try:
186
+ diff_parts.append(
187
+ _build_edit_diff(
188
+ normalized,
189
+ search,
190
+ replace,
191
+ start_line,
192
+ old_no_newline,
193
+ new_no_newline,
194
+ )
195
+ )
196
+ changed.append(normalized)
197
+ except ValueError as error:
198
+ issues.append(str(error))
199
+ continue
200
+ else:
201
+ current_content = original_content
202
+ file_ok = True
203
+ for e in file_edits:
204
+ search = e["search"]
205
+ replace = e["replace"]
206
+ raw_path = e["raw_path"]
207
+ count = current_content.count(search)
208
+ if count == 0 and "\\n" in search:
209
+ candidate_search = search.replace("\\r\\n", "\n").replace("\\n", "\n")
210
+ if current_content.count(candidate_search) > 0:
211
+ search = candidate_search
212
+ count = current_content.count(search)
213
+ if "\\n" in replace:
214
+ replace = replace.replace("\\r\\n", "\n").replace("\\n", "\n")
215
+ if count == 0:
216
+ issues.append(f"edit search block not found: {raw_path}")
217
+ file_ok = False
218
+ break
219
+ if count > 1:
220
+ issues.append(f"edit search block is ambiguous ({count} matches): {raw_path}")
221
+ file_ok = False
222
+ break
223
+ current_content = current_content.replace(search, replace, 1)
224
+ if file_ok:
225
+ diff_lines = list(
226
+ difflib.unified_diff(
227
+ original_content.splitlines(keepends=True),
228
+ current_content.splitlines(keepends=True),
229
+ fromfile=f"a/{normalized}",
230
+ tofile=f"b/{normalized}",
231
+ )
232
+ )
233
+ if diff_lines:
234
+ diff_parts.append(f"diff --git a/{normalized} b/{normalized}\n" + "".join(diff_lines))
235
+ changed.append(normalized)
236
+
237
+ if len(seen) > max_files:
238
+ issues.append(f"edits exceed max_patch_files={max_files}")
239
+ if issues:
240
+ return "", tuple(sorted(set(changed))), issues
241
+ patch = "\n".join(diff_parts)
242
+ if len(patch.encode("utf-8")) > max_patch_bytes:
243
+ issues.append(f"edits exceed max_patch_bytes={max_patch_bytes}")
244
+ return "", tuple(sorted(set(changed))), issues
245
+ return patch, tuple(sorted(set(changed))), []
246
+
247
+
248
+ def _evidence_facts(evidence: str | None) -> dict[str, str | None]:
249
+ """Extract security-relevant facts from an evidence string.
250
+
251
+ The evidence is a `key=value; ` segment list produced by
252
+ `_process_evidence` (e.g. `exit_code=0; passed=True; stdout_bytes=...`).
253
+ Only `exit_code` and `passed` matter for anti-fabrication; the byte-count
254
+ metadata may be rephrased by the model. Missing keys are parsed as None.
255
+
256
+ Fallback: if neither side parses an `exit_code`, the comparison falls back
257
+ to `passed` equality (already enforced separately as the core gate), so a
258
+ model that rephrases evidence without structural values is not falsely
259
+ rejected as long as `passed` matches.
260
+ """
261
+
262
+ facts: dict[str, str | None] = {"exit_code": None, "passed": None}
263
+ if not evidence:
264
+ return facts
265
+ for segment in evidence.split(";"):
266
+ if "=" not in segment:
267
+ continue
268
+ key, _, value = segment.partition("=")
269
+ key = key.strip()
270
+ value = value.strip()
271
+ if key in facts and value:
272
+ facts[key] = value
273
+ return facts
274
+
275
+
276
+ def validate_candidate(
277
+ candidate: Mapping[str, Any],
278
+ task: TaskEnvelope,
279
+ *,
280
+ max_patch_bytes: int = 32_000,
281
+ max_patch_files: int = 2,
282
+ observed_checks: Mapping[str, Mapping[str, Any]] | None = None,
283
+ workspace_root: str | Path | None = None,
284
+ ) -> ValidationReport:
285
+ issues: list[str] = []
286
+ changed_files: tuple[str, ...] = ()
287
+ if not isinstance(candidate, Mapping):
288
+ return ValidationReport(False, (), ("candidate must be a JSON object",))
289
+
290
+ if candidate.get("status") != "candidate":
291
+ issues.append("status must be 'candidate'")
292
+ if not isinstance(candidate.get("summary"), str) or not candidate["summary"].strip():
293
+ issues.append("summary must be a non-empty string")
294
+ patch = candidate.get("patch")
295
+ edits = candidate.get("edits")
296
+ if patch is None:
297
+ patch = ""
298
+ if not isinstance(patch, str):
299
+ issues.append("patch must be a string")
300
+ patch = ""
301
+ if edits is not None and not isinstance(edits, list):
302
+ issues.append("edits must be a list")
303
+ edits = None
304
+ checks = candidate.get("checks")
305
+ if not isinstance(checks, list):
306
+ issues.append("checks must be a list")
307
+ checks = []
308
+ if not isinstance(candidate.get("risks"), list):
309
+ issues.append("risks must be a list")
310
+
311
+ resolved_patch = ""
312
+ has_patch = bool(patch.strip()) if isinstance(patch, str) else False
313
+ has_edits = bool(edits) if isinstance(edits, list) else False
314
+ if has_patch and has_edits:
315
+ issues.append("candidate must provide either patch or edits, not both")
316
+ elif has_edits:
317
+ if workspace_root is None:
318
+ issues.append("edits require a workspace to resolve search blocks")
319
+ else:
320
+ allowed = {_normalize_task_path(path) for path in task.files}
321
+ resolved_patch, changed_files, edit_issues = resolve_edits(
322
+ workspace_root,
323
+ edits,
324
+ allowed_files=allowed,
325
+ max_files=max_patch_files,
326
+ max_patch_bytes=max_patch_bytes,
327
+ )
328
+ issues.extend(edit_issues)
329
+ if not edit_issues:
330
+ patch = resolved_patch
331
+
332
+ if patch:
333
+ if max_patch_bytes <= 0 or len(patch.encode("utf-8")) > max_patch_bytes:
334
+ issues.append(f"patch exceeds max_patch_bytes={max_patch_bytes}")
335
+ changed_files, diff_issues = parse_unified_diff(patch)
336
+ issues.extend(diff_issues)
337
+ if len(changed_files) > max_patch_files:
338
+ issues.append(f"patch exceeds max_patch_files={max_patch_files}")
339
+ allowed = {_normalize_task_path(path) for path in task.files}
340
+ for path in changed_files:
341
+ if _fold_path(path) not in allowed:
342
+ issues.append(f"patch file is outside task allowlist: {path}")
343
+ if workspace_root is not None and not issues:
344
+ applies, detail = check_patch_applies(workspace_root, patch)
345
+ if not applies:
346
+ issues.append(f"patch does not apply cleanly: {detail}")
347
+
348
+ expected_checks = set(task.checks)
349
+ seen_checks: set[str] = set()
350
+ for check in checks:
351
+ if not isinstance(check, Mapping):
352
+ issues.append("each check must be an object")
353
+ continue
354
+ command = check.get("command")
355
+ if not isinstance(command, str) or not command:
356
+ issues.append("check command must be a non-empty string")
357
+ continue
358
+ if command not in expected_checks:
359
+ issues.append(f"check command is not allowlisted: {command}")
360
+ if command in seen_checks:
361
+ issues.append(f"duplicate check command: {command}")
362
+ seen_checks.add(command)
363
+ if not isinstance(check.get("passed"), bool):
364
+ issues.append(f"check passed must be boolean: {command}")
365
+ elif check["passed"] is False:
366
+ issues.append(f"check failed: {command}")
367
+ evidence = check.get("evidence")
368
+ if not isinstance(evidence, str) or not evidence.strip():
369
+ issues.append(f"check evidence is required: {command}")
370
+ if observed_checks is None or command not in observed_checks:
371
+ issues.append(f"check has no external runner evidence: {command}")
372
+ else:
373
+ observed = observed_checks[command]
374
+ if check.get("passed") != observed.get("passed"):
375
+ issues.append(f"check result disagrees with external runner: {command}")
376
+ candidate_facts = _evidence_facts(check.get("evidence"))
377
+ observed_facts = _evidence_facts(observed.get("evidence"))
378
+ if (
379
+ candidate_facts.get("exit_code") is not None
380
+ and candidate_facts.get("exit_code") != observed_facts.get("exit_code")
381
+ ):
382
+ issues.append(f"check evidence disagrees with external runner: {command}")
383
+ elif (
384
+ candidate_facts.get("passed") is not None
385
+ and candidate_facts.get("passed") != observed_facts.get("passed")
386
+ ):
387
+ issues.append(f"check evidence disagrees with external runner: {command}")
388
+
389
+ if not (patch or resolved_patch or has_edits):
390
+ missing_checks = expected_checks - seen_checks
391
+ for command in sorted(missing_checks):
392
+ issues.append(f"missing check evidence: {command}")
393
+ if observed_checks is None or command not in observed_checks:
394
+ issues.append(f"check has no external runner evidence: {command}")
395
+
396
+ return ValidationReport(not issues, changed_files, tuple(issues), resolved_patch)
397
+
398
+
399
+ def check_patch_applies(
400
+ workspace_root: str | Path,
401
+ patch: str,
402
+ *,
403
+ timeout_seconds: float = 10,
404
+ ) -> tuple[bool, str]:
405
+ """Check a patch with git without modifying the workspace."""
406
+
407
+ return _run_git_apply(workspace_root, patch, check=True, timeout_seconds=timeout_seconds)
408
+
409
+
410
+ def apply_patch(
411
+ workspace_root: str | Path,
412
+ patch: str,
413
+ *,
414
+ timeout_seconds: float = 10,
415
+ reverse: bool = False,
416
+ ) -> tuple[bool, str]:
417
+ """Apply an already-validated patch to the workspace.
418
+
419
+ This is the controller-only mediated-apply seam. The local model never
420
+ reaches it directly: it has no `apply_patch` tool, and the controller only
421
+ calls this after `validate_candidate` has accepted the proposal.
422
+ """
423
+
424
+ return _run_git_apply(
425
+ workspace_root,
426
+ patch,
427
+ check=False,
428
+ reverse=reverse,
429
+ timeout_seconds=timeout_seconds,
430
+ )
431
+
432
+
433
+ def _run_git_apply(
434
+ workspace_root: str | Path,
435
+ patch: str,
436
+ *,
437
+ check: bool,
438
+ reverse: bool = False,
439
+ timeout_seconds: float,
440
+ ) -> tuple[bool, str]:
441
+ if timeout_seconds <= 0:
442
+ raise ValueError("timeout_seconds must be positive")
443
+ executable = shutil.which("git")
444
+ if executable is None:
445
+ return False, "git executable is unavailable"
446
+ root = Path(workspace_root).resolve()
447
+ if not root.is_dir():
448
+ return False, f"workspace directory does not exist: {root}"
449
+ command = [executable, "apply", "--unidiff-zero", "--whitespace=nowarn"]
450
+ if check:
451
+ command.append("--check")
452
+ if reverse:
453
+ command.append("--reverse")
454
+ command.append("-")
455
+ try:
456
+ completed = subprocess.run(
457
+ command,
458
+ cwd=root,
459
+ input=patch.encode("utf-8"),
460
+ stdout=subprocess.PIPE,
461
+ stderr=subprocess.PIPE,
462
+ timeout=timeout_seconds,
463
+ check=False,
464
+ )
465
+ except (OSError, subprocess.TimeoutExpired) as error:
466
+ return False, f"git apply{' check' if check else ''} failed: {error}"
467
+ if completed.returncode == 0:
468
+ return True, ""
469
+ detail = completed.stderr.decode("utf-8", errors="replace").strip()
470
+ return False, detail or f"git apply exited with code {completed.returncode}"
471
+
472
+
473
+ def parse_unified_diff(patch: str) -> tuple[tuple[str, ...], list[str]]:
474
+ paths: list[str] = []
475
+ issues: list[str] = []
476
+ saw_old = False
477
+ saw_new = False
478
+ saw_hunk = False
479
+ hunk: dict[str, int] | None = None
480
+
481
+ def finish_hunk() -> None:
482
+ nonlocal hunk
483
+ if hunk is None:
484
+ return
485
+ # ponytail: hunk line-count mismatch is intentionally NOT flagged here.
486
+ # git apply --check already rejects malformed hunks as "corrupt patch",
487
+ # so a stricter parser-side count check is a duplicate gate that rejects
488
+ # otherwise-valid patches (real models miscount hunk headers ~19%).
489
+ hunk = None
490
+
491
+ for line in patch.splitlines():
492
+ if line.startswith("diff --git "):
493
+ finish_hunk()
494
+ match = re.fullmatch(r"diff --git a/(.+) b/(.+)", line)
495
+ if not match:
496
+ issues.append("patch has invalid diff header")
497
+ continue
498
+ for raw_path in match.groups():
499
+ normalized, path_issue = _normalize_diff_path(raw_path)
500
+ if path_issue:
501
+ issues.append(path_issue)
502
+ elif normalized is not None and normalized not in paths:
503
+ paths.append(normalized)
504
+ elif line.startswith("@@ "):
505
+ finish_hunk()
506
+ saw_hunk = True
507
+ match = re.fullmatch(r"@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@.*", line)
508
+ if not match:
509
+ issues.append("patch has invalid hunk header")
510
+ continue
511
+ hunk = {
512
+ "old_expected": int(match.group(2) or "1"),
513
+ "new_expected": int(match.group(4) or "1"),
514
+ "old_seen": 0,
515
+ "new_seen": 0,
516
+ }
517
+ elif hunk is not None:
518
+ if line.startswith("\\"):
519
+ continue
520
+ if line.startswith(" "):
521
+ hunk["old_seen"] += 1
522
+ hunk["new_seen"] += 1
523
+ elif line.startswith("-"):
524
+ hunk["old_seen"] += 1
525
+ elif line.startswith("+"):
526
+ hunk["new_seen"] += 1
527
+ else:
528
+ issues.append("patch has invalid hunk line")
529
+ elif line.startswith("--- "):
530
+ saw_old = True
531
+ normalized, path_issue = _normalize_diff_path(line[4:].split("\t", 1)[0].strip())
532
+ if path_issue:
533
+ issues.append(path_issue)
534
+ elif normalized is not None and normalized not in paths:
535
+ paths.append(normalized)
536
+ elif line.startswith("+++ "):
537
+ saw_new = True
538
+ normalized, path_issue = _normalize_diff_path(line[4:].split("\t", 1)[0].strip())
539
+ if path_issue:
540
+ issues.append(path_issue)
541
+ elif normalized is not None and normalized not in paths:
542
+ paths.append(normalized)
543
+ finish_hunk()
544
+ if not paths or not saw_old or not saw_new or not saw_hunk:
545
+ issues.append("patch is not a unified diff")
546
+ return tuple(sorted(paths)), issues
547
+
548
+
549
+ def _normalize_diff_path(raw_path: str) -> tuple[str | None, str | None]:
550
+ if raw_path == "/dev/null":
551
+ return None, None
552
+ path = raw_path[2:] if raw_path[:2] in {"a/", "b/"} else raw_path
553
+ path = path.replace("\\", "/")
554
+ if not path or path.startswith("/") or re.match(r"^[A-Za-z]:", path):
555
+ return None, f"patch path is absolute or empty: {raw_path}"
556
+ parts = [part for part in path.split("/") if part not in {"", "."}]
557
+ if ".." in parts:
558
+ return None, f"patch path escapes workspace: {raw_path}"
559
+ return "/".join(parts), None
560
+
561
+
562
+ def _normalize_task_path(path: str) -> str:
563
+ return _fold_path(path.replace("\\", "/").strip("/"))