Conversation-owning agent only: selection requires user interaction in Step 2.
Never delegate this procedure to a worker/subagent that cannot surface questions to
the active conversation.
Attachment Handling
After fetching the ticket, check for attachments that may provide additional context (mockups, design specs, API docs, screenshots, etc.).
Step 1: Discover Attachments
If the calling skill already provides a discovered attachment list (e.g. from the context-gatherer digest), use it and skip the scan below — continue at Step 2.
Scan body and each comments[].body for URLs matching these domain patterns, embedded with either  (image embed) or [text](url) (link) markdown syntax:
https://user-images.githubusercontent.com/...
https://github.com/<owner>/<repo>/assets/...
https://github.com/user-attachments/files/...
https://github.com/user-attachments/assets/...
Note: user-attachments/assets/ URLs are often extensionless. If embedded with ![...]() syntax, classify as image; if embedded with [...]() syntax, classify as document (file download).
Extract the display name (alt text or link text, fallback to filename from URL) and URL. Size is unknown until download.
Note: this display name is for presentation only (Step 2 prompt). It is issue-author-controlled and is never used as the on-disk download filename — Step 3 derives that separately and validates it.
If no attachments found → skip the rest of this procedure and return to the calling skill.
Step 2: Present to User
Classify each attachment by extension:
- image: png, jpg, jpeg, gif, svg, webp
- document: md, txt, json, yaml, yml, csv, xml, html, log, pdf
- binary: everything else
Use the client's available user-input mechanism and allow multiple selections:
"Found N attachment(s) on this ticket. Which would you like to download for context?"
Options (one per attachment if ≤ 4, grouped by type if > 4):
"<name> (<type>, <size>)" per attachment when ≤ 4
"All images (N)", "All documents (N)", "All other files (N)" when > 4
If user selects none → skip the rest of this procedure and return to the calling skill.
Step 3: Download Selected Attachments
Store downloads under a freshly created, atomically-unique private directory —
never a shared, predictable path, which would let a symlink be pre-planted at a
guessable resolved download filename before the run starts. Create it as its
own standalone Bash call — never a shell-variable assignment that captures
the command's own output (an assignment wrapper never matches a granted
Bash(mktemp -d:*) prefix, and shell-rules forbids command substitution
where the agent can run the command and read the result):
mktemp -d "${TMPDIR:-/tmp}/cenci-attachments-XXXXXX"
mktemp -d both creates the directory and guarantees its name is unique and
unpredictable, so concurrent runs never collide and nothing can pre-plant a
symlink at it — no <scope> subdirectory is needed for uniqueness.
Verify before downloading, as its own standalone Bash call:
test -d "<attach-dir>"
Confirm the command succeeded and the printed directory is a non-empty,
existing directory before any download proceeds. If mktemp -d fails (disk
full, unwritable TMPDIR, etc.), abort the entire attachment step rather than
continuing with an unverified directory.
Carry the printed path forward as the literal text <attach-dir> for
every reference in the rest of this step; do not hold it in a shell
variable. Bash-tool shell state does not persist between calls, so a
shell-variable reference to a directory captured earlier would expand to
empty in a later, separate Bash call, and every path built from it would
silently collapse to an absolute root-relative path — precisely the hazard
the verification above guards against. This mirrors refine/SKILL.md's
per-run <token> rule: carry the literal value forward as text, never
re-derive it or rely on shell state across calls.
Prefer a connected GitHub attachment-download tool when one is available, especially
for private user-attachments URLs. Otherwise use curl. Before downloading, resolve
a safe on-disk filename for each attachment — never the Step 1 display/alt text, which
is issue-author-controlled and presentation-only, not a trusted filesystem input:
Derive a basename from the URL. Strip any ?query and #fragment, then take
the last /-separated path segment of what remains. Do not percent-decode this
segment — decoding could re-introduce / or .. sequences that the raw segment
didn't contain.
Validate the basename. It must match ^[A-Za-z0-9._-]+$, must not be exactly
. or .., and must not contain .. anywhere — the same rule used for scope keys
in the shell-rules skill (do not weaken this to a bare character-class check).
Then cap its length: if the validated basename exceeds 200 characters, truncate
the stem (everything before the last .) so stem+ext ≤ 200, preserving the
extension unchanged — a conservative cap that leaves headroom below common
NAME_MAX (255) filesystem limits for the -<k> collision suffixes applied later.
Fall back if invalid, absent, or extensionless. Generate attachment-<n>
(where <n> is the attachment's 1-based index within the current run's selected
set — stable and deterministic, not a global counter) when the basename fails
validation, when the URL has no last path segment at all, or when the segment is
valid but has no extension — no . in it (e.g. extensionless
user-attachments/assets/<uuid> URLs). An extensionless basename cannot satisfy
Case A below, which requires the extension to already be known before download;
Content-Type sniffing in Case B supplies it instead. Never fall back to the raw
invalid value and never to the Step 1 display/alt text.
Resolve and download — the sequencing differs by which path produced the name,
because a fallback name's extension can only be known after the response arrives:
Case A — valid URL basename (step 2 validation passed). The filename,
including its original extension, is already fully known before any download
starts — no sniffing applies. Check it for a collision against files already
written this run in <attach-dir>; if it
collides, append -<k> before the extension, starting at k=2 and incrementing
until unique (e.g. report.pdf → report-2.pdf). For a multi-dot name, split on
the last . only — the extension is everything after the final dot, so
archive.tar.gz → archive.tar-2.gz. Then download straight to the resolved
name:
curl -fsSL "<url>" -o "<attach-dir>/<resolved-filename>"
Case B — fallback (attachment-<n>). The extension is unknown until the
response's Content-Type is inspected, so the collision check and final name
cannot be resolved before downloading:
Download to a provisional, extension-less path using the fallback name,
capturing the response's Content-Type via curl's write-out format (or the
connector tool's response, if used instead):
curl -fsSL "<url>" -w '%{content_type}' -o "<attach-dir>/attachment-<n>.partial"
If this download reports failure (non-zero curl exit, or the connector tool's
equivalent failure signal), discard the .partial file and do not proceed to
steps 2–4 — this is a download failure for this attachment: apply the "On any
download failure → warn and continue with remaining files" rule below.
Map the captured Content-Type to an extension: image/png→.png,
image/jpeg→.jpg, image/gif→.gif, image/webp→.webp,
image/svg+xml→.svg, application/pdf→.pdf, text/plain→.txt,
text/markdown→.md, application/json→.json, text/csv→.csv,
text/html→.html, application/xml or text/xml→.xml. An unknown or
missing Content-Type means no extension is appended.
Check the extension-bearing name (attachment-<n><ext>) for a collision
against files already written this run in the same directory; if it
collides, append -<k> before the extension, starting at k=2 and
incrementing until unique.
Move the provisional file to the final resolved name with a portable mv
(no bash-only construct):
mv "<attach-dir>/attachment-<n>.partial" "<attach-dir>/<resolved-filename>"
If this mv fails (disk full, permissions, etc.), treat it as a download
failure for this attachment — apply the same "On any download failure → warn
and continue with remaining files" rule; never leave the .partial file in
place as a substitute and never read/inspect it directly under its
provisional name.
If a private-repository download fails and no connector can download it, retry with
the authenticated GitHub CLI token without printing the token.
Post-download: check size with stat. If > 10 MB and user wasn't warned → report and skip that file.
On any download failure → warn and continue with remaining files.
Step 4: Load Attachment Content
For each downloaded file, use the client's matching file or image inspection tool:
- Images (png, jpg, jpeg, gif, webp): use multimodal image inspection
- SVG: read as text unless visual rendering is specifically needed
- Text files (md, txt, json, yaml, yml, csv, xml, html, log): read as text
- PDF: use the client's PDF reader or extraction support
- Binary/other: Report file path, note content cannot be read as text
After reading each image, produce a brief structured summary (5–15 lines) covering applicable aspects:
- Type: mockup, wireframe, screenshot, diagram, flowchart, error state, etc.
- Layout: overall structure, regions, and spatial arrangement
- UI elements: buttons, inputs, tables, navigation, modals, etc. — include their labels
- Text content: headings, labels, error messages, data values visible in the image
- Visual style: colors, spacing, typography, and key design details
- Annotations: arrows, callouts, numbered markers, or highlighted areas
Keep all attachment context available for the rest of the calling skill's execution.
1---2name: attachments3description: Discover, select, download, and inspect GitHub ticket attachments. Use when an issue or pull request contains screenshots, mockups, documents, or uploaded files needed as task context.4---56**Conversation-owning agent only**: selection requires user interaction in Step 2.7Never delegate this procedure to a worker/subagent that cannot surface questions to8the active conversation.910## Attachment Handling1112After fetching the ticket, check for attachments that may provide additional context (mockups, design specs, API docs, screenshots, etc.).1314### Step 1: Discover Attachments1516If the calling skill already provides a discovered attachment list (e.g. from the `context-gatherer` digest), use it and skip the scan below — continue at Step 2.1718Scan `body` and each `comments[].body` for URLs matching these domain patterns, embedded with either `` (image embed) or `[text](url)` (link) markdown syntax:19- `https://user-images.githubusercontent.com/...`20- `https://github.com/<owner>/<repo>/assets/...`21- `https://github.com/user-attachments/files/...`22- `https://github.com/user-attachments/assets/...`2324**Note**: `user-attachments/assets/` URLs are often extensionless. If embedded with `![...]()` syntax, classify as **image**; if embedded with `[...]()` syntax, classify as **document** (file download).2526Extract the display name (alt text or link text, fallback to filename from URL) and URL. Size is unknown until download.2728**Note**: this display name is for presentation only (Step 2 prompt). It is issue-author-controlled and is never used as the on-disk download filename — Step 3 derives that separately and validates it.2930If **no attachments found** → skip the rest of this procedure and return to the calling skill.3132### Step 2: Present to User3334Classify each attachment by extension:35- **image**: png, jpg, jpeg, gif, svg, webp36- **document**: md, txt, json, yaml, yml, csv, xml, html, log, pdf37- **binary**: everything else3839Use the client's available user-input mechanism and allow multiple selections:4041> "Found N attachment(s) on this ticket. Which would you like to download for context?"4243Options (one per attachment if ≤ 4, grouped by type if > 4):44- `"<name> (<type>, <size>)"` per attachment when ≤ 445- `"All images (N)"`, `"All documents (N)"`, `"All other files (N)"` when > 44647If user selects none → skip the rest of this procedure and return to the calling skill.4849### Step 3: Download Selected Attachments5051Store downloads under a freshly created, atomically-unique private directory —52never a shared, predictable path, which would let a symlink be pre-planted at a53guessable resolved download filename before the run starts. Create it as its54own standalone Bash call — never a shell-variable assignment that captures55the command's own output (an assignment wrapper never matches a granted56`Bash(mktemp -d:*)` prefix, and `shell-rules` forbids command substitution57where the agent can run the command and read the result):5859```bash60mktemp -d "${TMPDIR:-/tmp}/cenci-attachments-XXXXXX"61```6263`mktemp -d` both creates the directory and guarantees its name is unique and64unpredictable, so concurrent runs never collide and nothing can pre-plant a65symlink at it — no `<scope>` subdirectory is needed for uniqueness.6667**Verify before downloading**, as its own standalone Bash call:6869```bash70test -d "<attach-dir>"71```7273Confirm the command succeeded and the printed directory is a non-empty,74existing directory before any download proceeds. If `mktemp -d` fails (disk75full, unwritable `TMPDIR`, etc.), abort the entire attachment step rather than76continuing with an unverified directory.7778**Carry the printed path forward as the literal text `<attach-dir>`** for79every reference in the rest of this step; do not hold it in a shell80variable. Bash-tool shell state does not persist between calls, so a81shell-variable reference to a directory captured earlier would expand to82empty in a later, separate Bash call, and every path built from it would83silently collapse to an absolute root-relative path — precisely the hazard84the verification above guards against. This mirrors `refine/SKILL.md`'s85per-run `<token>` rule: carry the literal value forward as text, never86re-derive it or rely on shell state across calls.8788Prefer a connected GitHub attachment-download tool when one is available, especially89for private `user-attachments` URLs. Otherwise use `curl`. Before downloading, resolve90a safe on-disk filename for each attachment — never the Step 1 display/alt text, which91is issue-author-controlled and presentation-only, not a trusted filesystem input:92931. **Derive a basename from the URL.** Strip any `?query` and `#fragment`, then take94 the last `/`-separated path segment of what remains. Do not percent-decode this95 segment — decoding could re-introduce `/` or `..` sequences that the raw segment96 didn't contain.972. **Validate the basename.** It must match `^[A-Za-z0-9._-]+$`, must not be exactly98 `.` or `..`, and must not contain `..` anywhere — the same rule used for scope keys99 in the `shell-rules` skill (do not weaken this to a bare character-class check).100 Then cap its length: if the validated basename exceeds 200 characters, truncate101 the stem (everything before the last `.`) so `stem+ext ≤ 200`, preserving the102 extension unchanged — a conservative cap that leaves headroom below common103 `NAME_MAX` (255) filesystem limits for the `-<k>` collision suffixes applied later.1043. **Fall back if invalid, absent, or extensionless.** Generate `attachment-<n>`105 (where `<n>` is the attachment's 1-based index within the current run's selected106 set — stable and deterministic, not a global counter) when the basename fails107 validation, when the URL has no last path segment at all, or when the segment is108 valid but has no extension — no `.` in it (e.g. extensionless109 `user-attachments/assets/<uuid>` URLs). An extensionless basename cannot satisfy110 Case A below, which requires the extension to already be known before download;111 Content-Type sniffing in Case B supplies it instead. Never fall back to the raw112 invalid value and never to the Step 1 display/alt text.1134. **Resolve and download — the sequencing differs by which path produced the name,114 because a fallback name's extension can only be known after the response arrives:**115116 - **Case A — valid URL basename** (step 2 validation passed). The filename,117 including its original extension, is already fully known before any download118 starts — no sniffing applies. Check it for a collision against files already119 written this run in `<attach-dir>`; if it120 collides, append `-<k>` before the extension, starting at `k=2` and incrementing121 until unique (e.g. `report.pdf` → `report-2.pdf`). For a multi-dot name, split on122 the *last* `.` only — the extension is everything after the final dot, so123 `archive.tar.gz` → `archive.tar-2.gz`. Then download straight to the resolved124 name:125126 ```bash127 curl -fsSL "<url>" -o "<attach-dir>/<resolved-filename>"128 ```129130 - **Case B — fallback (`attachment-<n>`)**. The extension is unknown until the131 response's Content-Type is inspected, so the collision check and final name132 cannot be resolved before downloading:133 1. Download to a provisional, extension-less path using the fallback name,134 capturing the response's Content-Type via curl's write-out format (or the135 connector tool's response, if used instead):136137 ```bash138 curl -fsSL "<url>" -w '%{content_type}' -o "<attach-dir>/attachment-<n>.partial"139 ```140141 If this download reports failure (non-zero curl exit, or the connector tool's142 equivalent failure signal), discard the `.partial` file and do not proceed to143 steps 2–4 — this is a download failure for this attachment: apply the "On any144 download failure → warn and continue with remaining files" rule below.145 2. Map the captured Content-Type to an extension: `image/png`→`.png`,146 `image/jpeg`→`.jpg`, `image/gif`→`.gif`, `image/webp`→`.webp`,147 `image/svg+xml`→`.svg`, `application/pdf`→`.pdf`, `text/plain`→`.txt`,148 `text/markdown`→`.md`, `application/json`→`.json`, `text/csv`→`.csv`,149 `text/html`→`.html`, `application/xml` or `text/xml`→`.xml`. An unknown or150 missing Content-Type means no extension is appended.151 3. Check the extension-bearing name (`attachment-<n><ext>`) for a collision152 against files already written this run in the same directory; if it153 collides, append `-<k>` before the extension, starting at `k=2` and154 incrementing until unique.155 4. Move the provisional file to the final resolved name with a portable `mv`156 (no bash-only construct):157158 ```bash159 mv "<attach-dir>/attachment-<n>.partial" "<attach-dir>/<resolved-filename>"160 ```161162 If this `mv` fails (disk full, permissions, etc.), treat it as a download163 failure for this attachment — apply the same "On any download failure → warn164 and continue with remaining files" rule; never leave the `.partial` file in165 place as a substitute and never read/inspect it directly under its166 provisional name.167168If a private-repository download fails and no connector can download it, retry with169the authenticated GitHub CLI token without printing the token.170171Post-download: check size with `stat`. If > 10 MB and user wasn't warned → report and skip that file.172173On any download failure → warn and continue with remaining files.174175### Step 4: Load Attachment Content176177For each downloaded file, use the client's matching file or image inspection tool:178- **Images** (png, jpg, jpeg, gif, webp): use multimodal image inspection179- **SVG**: read as text unless visual rendering is specifically needed180- **Text files** (md, txt, json, yaml, yml, csv, xml, html, log): read as text181- **PDF**: use the client's PDF reader or extraction support182- **Binary/other**: Report file path, note content cannot be read as text183184**After reading each image**, produce a brief structured summary (5–15 lines) covering applicable aspects:185- **Type**: mockup, wireframe, screenshot, diagram, flowchart, error state, etc.186- **Layout**: overall structure, regions, and spatial arrangement187- **UI elements**: buttons, inputs, tables, navigation, modals, etc. — include their labels188- **Text content**: headings, labels, error messages, data values visible in the image189- **Visual style**: colors, spacing, typography, and key design details190- **Annotations**: arrows, callouts, numbered markers, or highlighted areas191192Keep all attachment context available for the rest of the calling skill's execution.