bugpilot 0.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 (59) hide show
  1. bugpilot/__init__.py +5 -0
  2. bugpilot/__main__.py +5 -0
  3. bugpilot/cli.py +2615 -0
  4. bugpilot/cli_json.py +173 -0
  5. bugpilot/core/__init__.py +1 -0
  6. bugpilot/core/agent_runner.py +138 -0
  7. bugpilot/core/artifact_io.py +40 -0
  8. bugpilot/core/artifacts.py +47 -0
  9. bugpilot/core/attachments.py +247 -0
  10. bugpilot/core/branch_policy.py +223 -0
  11. bugpilot/core/cleanup.py +58 -0
  12. bugpilot/core/code_files.py +99 -0
  13. bugpilot/core/config.py +242 -0
  14. bugpilot/core/context.py +436 -0
  15. bugpilot/core/copilot.py +47 -0
  16. bugpilot/core/delivery_instructions.py +106 -0
  17. bugpilot/core/doctor.py +66 -0
  18. bugpilot/core/email_notify.py +316 -0
  19. bugpilot/core/errors.py +105 -0
  20. bugpilot/core/executables.py +87 -0
  21. bugpilot/core/fix_mode_state.py +212 -0
  22. bugpilot/core/fix_mode_store.py +600 -0
  23. bugpilot/core/fix_modes.py +412 -0
  24. bugpilot/core/fix_report.py +124 -0
  25. bugpilot/core/git_history.py +1579 -0
  26. bugpilot/core/git_ops.py +254 -0
  27. bugpilot/core/handoff.py +127 -0
  28. bugpilot/core/identity.py +112 -0
  29. bugpilot/core/input_adapters.py +97 -0
  30. bugpilot/core/instructions.py +337 -0
  31. bugpilot/core/issue.py +487 -0
  32. bugpilot/core/jira.py +886 -0
  33. bugpilot/core/jira_adf.py +167 -0
  34. bugpilot/core/jira_parse.py +442 -0
  35. bugpilot/core/keywords.py +386 -0
  36. bugpilot/core/logging_utils.py +28 -0
  37. bugpilot/core/memory.py +151 -0
  38. bugpilot/core/models.py +322 -0
  39. bugpilot/core/project_settings.py +283 -0
  40. bugpilot/core/prompts.py +567 -0
  41. bugpilot/core/repository_profile.py +739 -0
  42. bugpilot/core/retrieval.py +485 -0
  43. bugpilot/core/review_changes.py +155 -0
  44. bugpilot/core/review_report.py +171 -0
  45. bugpilot/core/run.py +171 -0
  46. bugpilot/core/safe_paths.py +173 -0
  47. bugpilot/core/search.py +765 -0
  48. bugpilot/core/search_terms.py +310 -0
  49. bugpilot/core/setup.py +212 -0
  50. bugpilot/core/user_config.py +211 -0
  51. bugpilot/core/verification_report.py +313 -0
  52. bugpilot/core/workflow.py +2385 -0
  53. bugpilot/mcp_server.py +651 -0
  54. bugpilot-0.1.0.dist-info/METADATA +270 -0
  55. bugpilot-0.1.0.dist-info/RECORD +59 -0
  56. bugpilot-0.1.0.dist-info/WHEEL +5 -0
  57. bugpilot-0.1.0.dist-info/entry_points.txt +3 -0
  58. bugpilot-0.1.0.dist-info/licenses/LICENSE +122 -0
  59. bugpilot-0.1.0.dist-info/top_level.txt +1 -0
bugpilot/cli_json.py ADDED
@@ -0,0 +1,173 @@
1
+ """The ``--json`` envelope: bugpilot's machine-facing output contract.
2
+
3
+ Lives in the CLI layer, not in ``core``, because rendering is an adapter's job
4
+ (design invariant 3). The VS Code extension consumes this; the MCP server gets
5
+ the same data as Python objects and never goes through here.
6
+
7
+ Contract, from ``docs/adapter_design.md`` section 5.1:
8
+
9
+ - Exactly one JSON object on stdout, whether the command succeeded or failed.
10
+ - ``schema_version`` from the first release, so the extension can refuse a
11
+ version it does not understand instead of misreading fields.
12
+ - On failure the human-readable message still goes to stderr and the exit code
13
+ is still non-zero. The two channels are additive, not alternatives.
14
+ - Consumers branch on ``error.code`` and never parse ``error.message``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import sys
21
+
22
+ SCHEMA_VERSION = 1
23
+
24
+
25
+ def success(command: str, **fields: object) -> dict[str, object]:
26
+ """Build a success envelope. ``fields`` are merged in as-is."""
27
+ payload: dict[str, object] = {
28
+ "schema_version": SCHEMA_VERSION,
29
+ "ok": True,
30
+ "command": command,
31
+ }
32
+ payload.update(fields)
33
+ payload.setdefault("warnings", [])
34
+ return payload
35
+
36
+
37
+ def failure(command: str, code: str, message: str, **fields: object) -> dict[str, object]:
38
+ """Build a failure envelope carrying a stable code and a human message."""
39
+ payload: dict[str, object] = {
40
+ "schema_version": SCHEMA_VERSION,
41
+ "ok": False,
42
+ "command": command,
43
+ "error": {"code": code, "message": message},
44
+ }
45
+ payload.update(fields)
46
+ return payload
47
+
48
+
49
+ def _utf8_stdout() -> None:
50
+ """Make stdout UTF-8 before JSON is written to it.
51
+
52
+ JSON between programs is UTF-8 (RFC 8259), and the VS Code extension
53
+ decodes it as UTF-8. Python on Windows writes a pipe in the ANSI code page
54
+ (cp1252) unless PYTHONIOENCODING or UTF-8 mode says otherwise: a "→" in an
55
+ instructions file or a Chinese bug title failed the command with "'charmap'
56
+ codec can't encode", and an "é" arrived as a byte the extension could not
57
+ decode. A console is unaffected — Python
58
+ writes it as UTF-16 — and a stream that is UTF-8 already is left alone.
59
+ """
60
+ stream = sys.stdout
61
+ encoding = (getattr(stream, "encoding", None) or "").lower().replace("-", "").replace("_", "")
62
+ reconfigure = getattr(stream, "reconfigure", None)
63
+ if encoding == "utf8" or reconfigure is None:
64
+ return
65
+ try:
66
+ reconfigure(encoding="utf-8")
67
+ except (ValueError, OSError):
68
+ pass
69
+
70
+
71
+ def emit(payload: dict[str, object]) -> None:
72
+ """Write one envelope to stdout.
73
+
74
+ ``ensure_ascii=False`` so a Chinese bug title stays readable in a terminal
75
+ and in an editor's output pane; the stream is made UTF-8 first.
76
+ """
77
+ _utf8_stdout()
78
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
79
+
80
+
81
+ def emit_failure(command: str, code: str, message: str, **fields: object) -> None:
82
+ """Write a failure envelope to stdout and the same message to stderr.
83
+
84
+ Callers still return a non-zero exit code: a consumer that ignores JSON and
85
+ only checks the exit status must see the failure too.
86
+ """
87
+ emit(failure(command, code, message, **fields))
88
+ print(f"ERROR: {message}", file=sys.stderr)
89
+
90
+
91
+ class JsonLinesEmitter:
92
+ """Streams one JSON object per line while a run is in progress.
93
+
94
+ The two output channels have different jobs, per design section 5.1:
95
+ JSONL is live events for as long as the process runs, and
96
+ ``run.json`` is the state that survives it. A consumer follows
97
+ the stream for progress and re-reads the file after a restart.
98
+
99
+ ``run_investigation``'s progress callback fires *before* each step, so
100
+ completion is inferred: the next ``step_started`` closes the previous step,
101
+ and :meth:`finish` closes the last one. A step that raises is never closed,
102
+ which is what distinguishes it from one that finished.
103
+ """
104
+
105
+ def __init__(self, work_item_id: str, source: str) -> None:
106
+ self._open_step: str | None = None
107
+ self._emit({"type": "started", "work_item_id": work_item_id, "source": source})
108
+
109
+ def _emit(self, event: dict[str, object]) -> None:
110
+ payload: dict[str, object] = {"schema_version": SCHEMA_VERSION}
111
+ payload.update(event)
112
+ _utf8_stdout()
113
+ print(json.dumps(payload, ensure_ascii=False), flush=True)
114
+
115
+ def skipped(self, steps: list[str]) -> None:
116
+ for step in steps:
117
+ self._emit({"type": "step_skipped", "step": step, "reason": "plan"})
118
+
119
+ def progress(self, event: str) -> None:
120
+ """Callback for ``run_investigation(progress=...)``.
121
+
122
+ Non-step events (the clean phase) are reported under their own type so a
123
+ consumer can show them without mistaking them for pipeline steps.
124
+ """
125
+ if event.startswith("clean_"):
126
+ self._emit({"type": "phase", "phase": event})
127
+ return
128
+ self._close_open_step()
129
+ self._open_step = event
130
+ self._emit({"type": "step_started", "step": event})
131
+
132
+ def _close_open_step(self) -> None:
133
+ if self._open_step is not None:
134
+ self._emit({"type": "step_completed", "step": self._open_step})
135
+ self._open_step = None
136
+
137
+ def finish(self, generated_files: list[str], warnings: list[str] | None = None) -> None:
138
+ self._close_open_step()
139
+ for path in generated_files:
140
+ self._emit({"type": "artifact", "path": path})
141
+ # Carried on the terminal event rather than as a new event type: a
142
+ # consumer that has not been taught about `warnings` ignores the extra
143
+ # key, where an unknown event type is a shape it has to decide about.
144
+ completed: dict[str, object] = {"type": "completed", "ok": True}
145
+ if warnings:
146
+ completed["warnings"] = list(warnings)
147
+ self._emit(completed)
148
+
149
+ def fail(self, code: str, message: str) -> None:
150
+ """End the stream without closing the step that raised."""
151
+ self._emit({"type": "completed", "ok": False, "error": {"code": code, "message": message}})
152
+
153
+
154
+ def emit_stream_failure(code: str, message: str) -> None:
155
+ """Close a --json-lines stream that failed before or outside a run.
156
+
157
+ A consumer waits for a terminal event; without one an aborted run is
158
+ indistinguishable from a process that is still working.
159
+ """
160
+ _utf8_stdout()
161
+ print(
162
+ json.dumps(
163
+ {
164
+ "schema_version": SCHEMA_VERSION,
165
+ "type": "completed",
166
+ "ok": False,
167
+ "error": {"code": code, "message": message},
168
+ },
169
+ ensure_ascii=False,
170
+ ),
171
+ flush=True,
172
+ )
173
+ print(f"ERROR: {message}", file=sys.stderr)
@@ -0,0 +1 @@
1
+ """Core modules for bugpilot."""
@@ -0,0 +1,138 @@
1
+ """Deprecated: launching a coding agent from bugpilot.
2
+
3
+ **This module is deprecated as of V1 and will be removed in a future
4
+ release** (design section 9, phase 7). It still works, and nothing that
5
+ depends on it has been changed.
6
+
7
+ It exists because bugpilot originally launched Claude in a terminal after
8
+ preparing a package. That is no longer the default: `bugpilot bug` prepares and
9
+ stops, and this module runs only when a command line asks for it with
10
+ ``--launch-agent claude`` or ``--launch-agent copilot``. The three-entry architecture made that the odd one out:
11
+ the MCP server is *called by* an agent, and the VS Code extension hands the
12
+ package over on a human's click. In both, deciding to involve a model is a
13
+ separate act from preparing the context — which is requirement R5, and the
14
+ reason this path is going away rather than being extended.
15
+
16
+ What to use instead:
17
+
18
+ - ``bugpilot bug <ID>`` (prepare-only is the default), then hand ``.ai/<ID>/task.md`` to
19
+ whatever agent you use. The extension's "Copy handoff prompt" does exactly
20
+ this, and the Claude Code skill in ``skills/`` tells the agent to do it
21
+ itself.
22
+ - The handoff wording now lives in :mod:`bugpilot.core.handoff`, shared with
23
+ the MCP prompt and the skill.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import os
29
+ import shlex
30
+ import subprocess
31
+ import sys
32
+ from dataclasses import dataclass
33
+ from pathlib import Path
34
+
35
+ from .config import AppConfig, load_config
36
+ from .executables import child_environment, find_executable
37
+ from .handoff import handoff_prompt, retry_handoff_prompt
38
+
39
+ # Kept as format strings because callers use `.format(...)` on them, but the
40
+ # wording now comes from `handoff.py` — the one place that says what an agent is
41
+ # told to do with a prepared package (design 5.5: a skill and an MCP prompt
42
+ # carrying the same instructions in two files will drift).
43
+ HANDOFF_PROMPT = handoff_prompt("{issue_key}")
44
+ RETRY_HANDOFF_PROMPT = retry_handoff_prompt("{prompt_file}")
45
+
46
+ # What `bugpilot bug --launch-agent` accepts. The flag is the only way the CLI
47
+ # starts an agent: there is no implicit default one.
48
+ LAUNCHABLE_AGENTS: tuple[str, ...] = ("claude", "copilot")
49
+
50
+
51
+ @dataclass
52
+ class AgentRunResult:
53
+ agent: str
54
+ ran: bool
55
+ command: list[str]
56
+ returncode: int | None = None
57
+ skipped_reason: str | None = None
58
+
59
+
60
+ def build_agent_command(agent: str, issue_key: str, config: AppConfig, prompt: str | None = None) -> list[str]:
61
+ prompt = prompt or HANDOFF_PROMPT.format(issue_key=issue_key)
62
+ if agent == "claude":
63
+ base = [config.claude_command, *shlex.split(config.claude_args)]
64
+ elif agent == "copilot":
65
+ base = [config.copilot_command, *shlex.split(config.copilot_args)]
66
+ else:
67
+ raise ValueError(f"Unknown agent: {agent}")
68
+ return [*base, prompt]
69
+
70
+
71
+ def run_agent(
72
+ repo_root: Path,
73
+ issue_key: str,
74
+ agent: str,
75
+ config: AppConfig | None = None,
76
+ prompt: str | None = None,
77
+ ) -> AgentRunResult:
78
+ _warn_deprecated(agent)
79
+ config = config if config is not None else load_config(repo_root)
80
+ command = build_agent_command(agent, issue_key, config, prompt)
81
+ launch = _resolve_launch_command(command)
82
+ if launch is None:
83
+ return AgentRunResult(
84
+ agent=agent,
85
+ ran=False,
86
+ command=command,
87
+ skipped_reason=f"{command[0]} was not found on PATH.",
88
+ )
89
+ # Inherit the terminal so the agent runs interactively and the developer can
90
+ # watch it work. Run in the target repo root (the current working directory),
91
+ # with the current directory out of the agent's own program lookups.
92
+ completed = subprocess.run(launch, cwd=repo_root, env=child_environment())
93
+ return AgentRunResult(
94
+ agent=agent,
95
+ ran=True,
96
+ command=command,
97
+ returncode=completed.returncode,
98
+ )
99
+
100
+
101
+ def _warn_deprecated(agent: str) -> None:
102
+ """Tell the developer, on stderr, that this path is going away.
103
+
104
+ A ``DeprecationWarning`` would be the library-shaped answer and invisible
105
+ here: Python hides those by default, and the audience is a person watching a
106
+ terminal. stderr also keeps requirement R1 intact — the human-readable
107
+ stdout of every existing command is untouched.
108
+ """
109
+ print(
110
+ f"NOTE: launching {agent} from bugpilot is deprecated and will be removed "
111
+ "in a future release.",
112
+ file=sys.stderr,
113
+ )
114
+ print(
115
+ " Prefer leaving out --launch-agent (prepare-only is the default) and handing "
116
+ "task.md over yourself (the VS Code extension's Copy handoff prompt does this).",
117
+ file=sys.stderr,
118
+ )
119
+
120
+
121
+ def _resolve_launch_command(command: list[str]) -> list[str] | None:
122
+ """Resolve the executable to a runnable form for the current OS.
123
+
124
+ `find_executable` honors PATHEXT (so it finds `claude.cmd` on Windows) and,
125
+ unlike shutil.which, never looks in the current directory — the repository.
126
+ CreateProcess cannot launch a .cmd/.bat directly, so a
127
+ Windows batch shim runs through the system's own cmd.exe, by absolute path.
128
+ """
129
+ resolved = find_executable(command[0])
130
+ if resolved is None:
131
+ return None
132
+ rest = command[1:]
133
+ if sys.platform == "win32" and resolved.lower().endswith((".cmd", ".bat")):
134
+ shell = find_executable(os.environ.get("ComSpec") or "") or find_executable("cmd")
135
+ if shell is None:
136
+ return None
137
+ return [shell, "/c", resolved, *rest]
138
+ return [resolved, *rest]
@@ -0,0 +1,40 @@
1
+ """Writes into `.ai/<work item>/` that another process may be reading."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ from pathlib import Path
8
+
9
+ from .safe_paths import refuse_link
10
+
11
+
12
+ def atomic_write_text(path: Path, text: str) -> None:
13
+ """Write via a temp file and one rename, so a reader never sees half a file.
14
+
15
+ The retry is for Windows: ``os.replace`` onto a path another process has open
16
+ fails with ``PermissionError`` there, and the readers of these files are
17
+ exactly that — an extension restoring its checklist, an MCP ``get_status``
18
+ call. Those reads last microseconds, so a couple of retries clear it.
19
+
20
+ If it still fails, write in place rather than raising: a torn read costs one
21
+ stale checklist, while a raised exception costs the whole step.
22
+
23
+ A target that is itself a symbolic link or junction is refused before
24
+ anything is written: the fallback below would follow it.
25
+ """
26
+ refuse_link(path)
27
+ temp = path.with_name(path.name + f".tmp{os.getpid()}")
28
+ temp.write_text(text, encoding="utf-8")
29
+ for attempt in range(4):
30
+ try:
31
+ os.replace(temp, path)
32
+ return
33
+ except PermissionError:
34
+ if attempt == 3:
35
+ break
36
+ time.sleep(0.05)
37
+ try:
38
+ path.write_text(text, encoding="utf-8")
39
+ finally:
40
+ temp.unlink(missing_ok=True)
@@ -0,0 +1,47 @@
1
+ """The standard BugPilot artifact contract: what a work item directory holds.
2
+
3
+ One place for the names, because every reader and writer has to agree on them
4
+ and a filename spelled out in five modules is five chances to drift.
5
+
6
+ issue.json the normalized bug and the guidance a run was given
7
+ retrieval.json search terms, relevant files and their matched lines
8
+ context.md the context an agent reads first
9
+ task.md the task package handed to the coding agent
10
+ run.json runtime state: step lifecycle, agent state
11
+ fix_report.md optional, only once a fix produced something to report
12
+ review_report.md optional, only once somebody recorded a review's result
13
+ verification_report.md
14
+ optional, only once somebody recorded verification evidence
15
+
16
+ Every JSON artifact carries ``schema_version``. There is deliberately no
17
+ migration and no reader for any earlier layout: BugPilot is pre-release, and a
18
+ work item prepared under an older layout is re-prepared rather than upgraded.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ ARTIFACT_SCHEMA_VERSION = 1
24
+
25
+ ISSUE_ARTIFACT = "issue.json"
26
+ RETRIEVAL_ARTIFACT = "retrieval.json"
27
+ CONTEXT_ARTIFACT = "context.md"
28
+ TASK_ARTIFACT = "task.md"
29
+ RUN_ARTIFACT = "run.json"
30
+ FIX_REPORT_ARTIFACT = "fix_report.md"
31
+ REVIEW_REPORT_ARTIFACT = "review_report.md"
32
+ VERIFICATION_REPORT_ARTIFACT = "verification_report.md"
33
+
34
+
35
+ class WorkItemNotFoundError(FileNotFoundError):
36
+ """The work item directory does not exist."""
37
+
38
+
39
+ # What a normal prepare-only run leaves behind once every batch has landed.
40
+ # The reports are not here: each exists only once something wrote it.
41
+ CORE_ARTIFACTS: tuple[str, ...] = (
42
+ ISSUE_ARTIFACT,
43
+ RETRIEVAL_ARTIFACT,
44
+ CONTEXT_ARTIFACT,
45
+ TASK_ARTIFACT,
46
+ RUN_ARTIFACT,
47
+ )
@@ -0,0 +1,247 @@
1
+ """Files a developer hands to the agent alongside the prepared package.
2
+
3
+ A crash log, a screenshot of the broken dialog, a config that reproduces it —
4
+ things that are not in the repository and not in the Jira description, and that
5
+ often decide whether a diagnosis is right.
6
+
7
+ Two rules shape this module, and both come from the same principle the rest of
8
+ the pipeline follows: **the agent must never be told about something it cannot
9
+ read.**
10
+
11
+ 1. An attachment that could not be copied is not listed in the task file. It
12
+ is reported back instead, so the developer learns it did not make it rather
13
+ than believing the agent saw it.
14
+ 2. Copying is byte-for-byte and makes no encoding assumption. A PNG is a PNG;
15
+ a log written by a Windows tool in cp1252 stays exactly as it was.
16
+
17
+ The files land in ``.ai/<work_item>/attachments/``, inside the directory the
18
+ agent is already pointed at, so nothing has to be given a path outside it.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import shutil
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+ from typing import Mapping, Sequence
27
+
28
+ from .safe_paths import refuse_link
29
+
30
+ ATTACHMENTS_DIR = "attachments"
31
+
32
+ # Per file. A crash dump or a video of the repro is not an attachment, it is a
33
+ # download — and copying one into `.ai/` bloats the repository the artifacts
34
+ # directory lives in.
35
+ MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024
36
+
37
+ # Past this it is a directory, not a set of attachments, and an agent handed
38
+ # thirty files reads none of them properly.
39
+ MAX_ATTACHMENTS = 10
40
+
41
+ # A description says why a file matters — a sentence or two — and is not a
42
+ # second bug description. Longer text is cut, not refused: the file is still
43
+ # worth attaching.
44
+ MAX_ATTACHMENT_NOTE_CHARS = 500
45
+
46
+
47
+ @dataclass
48
+ class AttachmentResult:
49
+ """What was copied, and what was not and why."""
50
+
51
+ # Names inside `attachments/`, in the order they were given.
52
+ copied: list[str] = field(default_factory=list)
53
+ # ``(what the developer asked for, why it did not make it)``.
54
+ skipped: list[tuple[str, str]] = field(default_factory=list)
55
+ # The developer's description of a copied file, by its name in
56
+ # `attachments/`. Only files that arrived and were described appear here.
57
+ notes: dict[str, str] = field(default_factory=dict)
58
+
59
+ @property
60
+ def relative_paths(self) -> list[str]:
61
+ return [f"{ATTACHMENTS_DIR}/{name}" for name in self.copied]
62
+
63
+
64
+ def normalize_attachment_note(text: str | None) -> str:
65
+ """A description as the task file carries it: one line, bounded.
66
+
67
+ One line because it sits under a heading in ``task.md``: a pasted newline
68
+ followed by ``##`` must not become a section of its own.
69
+ """
70
+ if not text:
71
+ return ""
72
+ return " ".join(text.split())[:MAX_ATTACHMENT_NOTE_CHARS].rstrip()
73
+
74
+
75
+ def copy_attachments(
76
+ target: Path, paths: Sequence[str], descriptions: Sequence[str] | None = None
77
+ ) -> AttachmentResult:
78
+ """Copy each path into ``target/attachments/``.
79
+
80
+ ``descriptions`` pairs with ``paths`` by position — the Nth describes the
81
+ Nth file; a blank one, or none, means no description. A description follows
82
+ its file to the name it was copied under, and is dropped with a file that
83
+ did not make it.
84
+
85
+ Never raises for a bad input: a missing or oversized file costs itself and
86
+ nothing else, because losing a whole investigation over one unreadable
87
+ screenshot would be the wrong trade. The caller surfaces ``skipped``.
88
+ """
89
+ result = AttachmentResult()
90
+ if not paths:
91
+ return result
92
+
93
+ directory = target / ATTACHMENTS_DIR
94
+ used: set[str] = set()
95
+ described = list(descriptions or [])
96
+
97
+ for index, raw in enumerate(paths):
98
+ if len(result.copied) >= MAX_ATTACHMENTS:
99
+ result.skipped.append((raw, f"more than {MAX_ATTACHMENTS} attachments"))
100
+ continue
101
+
102
+ source = Path(raw).expanduser()
103
+ try:
104
+ if not source.is_file():
105
+ result.skipped.append((raw, "not a file"))
106
+ continue
107
+ size = source.stat().st_size
108
+ except OSError as exc:
109
+ result.skipped.append((raw, f"could not be read: {exc.strerror or exc}"))
110
+ continue
111
+
112
+ if size > MAX_ATTACHMENT_BYTES:
113
+ megabytes = MAX_ATTACHMENT_BYTES // (1024 * 1024)
114
+ result.skipped.append((raw, f"larger than {megabytes} MB"))
115
+ continue
116
+
117
+ name = _unique_name(source.name, used)
118
+ try:
119
+ # Never into, or through, a link: the folder and
120
+ # the file are checked before the copy, which would follow either.
121
+ refuse_link(directory)
122
+ directory.mkdir(parents=True, exist_ok=True)
123
+ refuse_link(directory)
124
+ # copyfile, not copy2: the metadata of the developer's original is
125
+ # theirs, and a copied mtime makes the artifact look older than the
126
+ # run that produced it.
127
+ shutil.copyfile(source, refuse_link(directory / name))
128
+ except OSError as exc:
129
+ result.skipped.append((raw, f"could not be copied: {exc.strerror or exc}"))
130
+ continue
131
+
132
+ used.add(name.lower())
133
+ result.copied.append(name)
134
+ note = normalize_attachment_note(described[index] if index < len(described) else None)
135
+ if note:
136
+ result.notes[name] = note
137
+
138
+ return result
139
+
140
+
141
+ def merge_attachment_notes(previous: Mapping[str, str], result: AttachmentResult) -> dict[str, str]:
142
+ """The descriptions a work item carries after this run.
143
+
144
+ A file this run copied carries this run's description, or none — the
145
+ developer may have cleared it. A file from an earlier run that this one did
146
+ not re-copy keeps the description it had, because it is still on disk and
147
+ the task file still names it.
148
+ """
149
+ merged = dict(previous)
150
+ for name in result.copied:
151
+ if name in result.notes:
152
+ merged[name] = result.notes[name]
153
+ else:
154
+ merged.pop(name, None)
155
+ return merged
156
+
157
+
158
+ def _unique_name(name: str, used: set[str]) -> str:
159
+ """A file name that does not collide with one already copied.
160
+
161
+ Two attachments chosen from different directories can share a basename, and
162
+ the second silently overwriting the first is the kind of loss nobody
163
+ notices until the agent quotes the wrong log. Compared case-insensitively,
164
+ because the artifact directory may live on Windows.
165
+ """
166
+ # `Path.name` cannot contain a separator, so this cannot escape the
167
+ # directory; the fallback only matters for a pathological empty name.
168
+ candidate = name or "attachment"
169
+ if candidate.lower() not in used:
170
+ return candidate
171
+
172
+ stem = Path(candidate).stem
173
+ suffix = Path(candidate).suffix
174
+ for index in range(2, MAX_ATTACHMENTS + 2):
175
+ attempt = f"{stem}-{index}{suffix}"
176
+ if attempt.lower() not in used:
177
+ return attempt
178
+ return f"{stem}-{len(used) + 1}{suffix}"
179
+
180
+
181
+ def is_plain_attachment_name(name: str) -> bool:
182
+ """A single file name: no separator, no drive, not ``.``/``..``, nothing that leaves the folder."""
183
+ return (
184
+ bool(name)
185
+ and name not in {".", ".."}
186
+ and "/" not in name
187
+ and "\\" not in name
188
+ and ":" not in name
189
+ and Path(name).name == name
190
+ )
191
+
192
+
193
+ def remove_attachments(target: Path, names: Sequence[str]) -> list[str]:
194
+ """Delete these files from ``target/attachments/``, and nothing else; return what went.
195
+
196
+ Only ever called with names BugPilot itself recorded as copied — never with
197
+ whatever happens to be in the folder — and each one is refused unless it is
198
+ a plain file name naming a regular file (or a link, which is removed and not
199
+ followed) directly inside that folder, and the folder is inside ``target``.
200
+ A name that fails any check is skipped, not "cleaned": it is not ours to
201
+ guess about.
202
+ """
203
+ directory = target / ATTACHMENTS_DIR
204
+ try:
205
+ if not directory.is_dir() or directory.resolve().parent != target.resolve():
206
+ return []
207
+ except OSError:
208
+ return []
209
+ removed: list[str] = []
210
+ for name in names:
211
+ if not is_plain_attachment_name(name):
212
+ continue
213
+ candidate = directory / name
214
+ try:
215
+ if candidate.is_symlink() or candidate.is_file():
216
+ candidate.unlink()
217
+ removed.append(name)
218
+ except OSError:
219
+ # Locked or already gone: the record no longer names it either way.
220
+ continue
221
+ return removed
222
+
223
+
224
+ def listed_attachments(target: Path, recorded: Sequence[str] | None) -> list[str]:
225
+ """The attachments a task file names: the recorded selection, else the folder.
226
+
227
+ A work item prepared by the extension records which files it currently
228
+ has (``guidance.attachment_files``); then only those are named — and only
229
+ the ones still on disk. One prepared before the record existed, or only
230
+ ever by an additive CLI run, falls back to reading the folder as before.
231
+ """
232
+ if recorded is None:
233
+ return attachment_names(target)
234
+ directory = target / ATTACHMENTS_DIR
235
+ return [name for name in recorded if is_plain_attachment_name(name) and (directory / name).is_file()]
236
+
237
+
238
+ def attachment_names(target: Path) -> list[str]:
239
+ """What is in the attachments directory now, sorted.
240
+
241
+ Read from disk rather than remembered, so a `--resume` run lists what a
242
+ previous run copied.
243
+ """
244
+ directory = target / ATTACHMENTS_DIR
245
+ if not directory.is_dir():
246
+ return []
247
+ return sorted(path.name for path in directory.iterdir() if path.is_file())