linear-fetch · diff

git:20260909.ae44051 to git:20260919.dd5c52f

6 added, 6 removed. Audit A to A.

---
name: linear-fetch
description: "This skill should be used when a user input contains a Linear issue reference (e.g., SOL-39 or linear.app/.../issue/<ID>) and the downstream agent needs the screenshots embedded in the issue as visual context."
allowed-tools:
- Bash
- mcp__linear-server__get_issue
- mcp__linear-server__list_comments
- mcp__linear-server__extract_images
preconditions:
- Linear MCP server is authenticated (mcp__linear-server__authenticate has been run for this session)
---
# Linear Issue Image Context
- This skill detects Linear issue references in caller input, fetches the issue body and recent comments, and streams the embedded screenshots directly into the active model conversation. The caller (typically `/soleur:one-shot` Step 0a or `/soleur:brainstorm` Phase 0.4) receives two return artifacts and is responsible for choosing the right one for each downstream consumer.
+ This skill detects Linear issue references in caller input, fetches the issue body and recent comments, and streams the embedded screenshots directly into the active model conversation. The caller (typically `soleur:one-shot` Step 0a or `soleur:brainstorm` Phase 0.4) receives two return artifacts and is responsible for choosing the right one for each downstream consumer.
## Caller Contract
This skill returns **two artifacts**. Mixing them up is the load-bearing failure mode the skill exists to prevent.
1. **Agent context** — the full markdown blob (description + comment bodies) with Linear image content blocks streamed into the active conversation by `mcp__linear-server__extract_images`. **Callers MUST NOT write this artifact to disk, a PR body, a commit message, or any file under `knowledge-base/`.** The Linear image URLs inside it (`uploads.linear.app/*`) are signed bearer credentials that grant public-internet read access for their validity window.
2. **Persist-safe summary** — the same text with every `uploads.linear.app/*` URL redacted to the literal token `[linear-image: REDACTED]`. **Callers MUST use this variant for any persistence**: writes to `knowledge-base/`, PR descriptions, commit messages, learning files, downstream subagent prompts.
The redaction primitive lives at [scripts/redact-linear-urls.sh](./scripts/redact-linear-urls.sh). It uses a **case-insensitive hostname match** (`[Hh][Tt][Tt][Pp][Ss]?://[Uu][Pp][Ll][Oo][Aa][Dd][Ss]\.[Ll][Ii][Nn][Ee][Aa][Rr]\.[Aa][Pp][Pp]/`) — DNS is case-insensitive and `https://Uploads.Linear.App/...` serves the same signed bearer credential as the lowercase form. The URL path uses a positive ASCII-only character class (RFC 3986 unreserved + reserved set), so Unicode separators (U+2028, U+2029, NBSP) and markdown/HTML terminators (`< > " ' ) ]`) all terminate the match cleanly. The full justification per character is inline in the script. The companion CI `pii-grep` job in `.github/workflows/pr-quality-guards.yml` mirrors the case-insensitive matching with `grep -iE`.
The repository is public on GitHub. A single leaked `uploads.linear.app` URL is a `single-user incident` brand-survival event per `knowledge-base/project/specs/feat-linear-issue-image-context/spec.md`.
## Phases
### Phase A — Reference detection
Scan the input string for substrings matching either:
- The identifier shape `[A-Z]{2,}-[0-9]+` (matches `SOL-39`, `LIN-12`, `ENG-100`, but also `PR-123`, `HTTP-200`, `CVE-2024` — false positives are handled in Phase B, not at the regex level), OR
- The URL shape `linear\.app/[^/]+/issue/([A-Z]+-[0-9]+)` — extract the captured identifier.
Uppercase each captured identifier and deduplicate (case-sensitive after uppercasing, exact-match on the number — `SOL-39` and `sol-39` collapse to one entry).
If more than 5 distinct identifiers match, use the `AskUserQuestion` tool to ask the user how to proceed before any MCP call:
- **Header:** "Linear cap"
- **Question:** "Found N Linear references; the default cap is 5 to bound prompt size and MCP cost. Process the first 5, all N, or abort?"
- **Options:** "Process first 5 (default)", "Process all (may bloat context)", "Abort". The "Other" escape is appended automatically by the runtime.
Take the user's choice as the working set for Phase B.
If 5 or fewer matches, proceed directly.
If zero matches, return immediately with no disclosure line — the skill is a no-op for inputs that do not reference Linear.
### Phase B — Fetch + comment scan
For each identifier in the working set:
1. Invoke `mcp__linear-server__get_issue(id=<identifier>)`. On a `404 Not Found` response, silently drop the identifier — this converts false-positive matches like `PR-123` or `CVE-2024` into no-ops with no user-visible failure. On any other failure (`403`, network error, token expired), emit the warning from Phase E and skip to the next identifier; do not abort.
2. Invoke `mcp__linear-server__list_comments(issueId=<identifier>, orderBy="createdAt", limit=250)`. The MCP schema (verified at Phase 0 of the plan) supports `orderBy: createdAt | updatedAt` and `limit` up to 250. The schema does NOT expose an order-direction parameter, so the response order is treated as ambiguous: parse each comment's `createdAt` field and sort client-side **descending**, then take the first 10 entries as "10 most-recent by creation time." If the response contains 10 or fewer comments, skip the sort.
3. Concatenate the issue description and the 10 most-recent comment bodies into a single markdown blob, delimited as `\n\n--- comment by <author.displayName> on <createdAt> ---\n\n` between each block.
### Phase B.0 — Pre-fetch preflight (blob-independent; runs BEFORE Phase C)
**Run this fence before invoking `extract_images`, and abort the whole skill on a non-zero exit.**
Both checks below are **blob-independent** — they interrogate the plugin installation, not the
issue. Until #7450 they sat in Phase D, *after* Phase C had already streamed the issue's image
content blocks into the conversation. So on every halt path the signed `uploads.linear.app`
bearer URLs were already in the parent transcript, and the "fail closed" refusal protected only
the artifact nobody had produced yet. A guard that cannot run before the exposure is not a guard
against it. Nothing here depends on the blob, so nothing forced that ordering.
```bash
[ -f "${CLAUDE_PLUGIN_ROOT}/.claude-plugin/plugin.json" ] \
&& grep -q '"name"[[:space:]]*:[[:space:]]*"soleur"' "${CLAUDE_PLUGIN_ROOT}/.claude-plugin/plugin.json" \
|| { echo "SOLEUR_LINEAR_FETCH_HALT reason=plugin-root-unverified root=[${CLAUDE_PLUGIN_ROOT}]"
echo "linear-fetch: cannot verify the Soleur plugin installation — stopping before anything is fetched." >&2
echo " Resolved plugin root: [${CLAUDE_PLUGIN_ROOT}]" >&2
echo " If that is EMPTY: no Soleur plugin is loaded in this session. Install it and start a NEW session — re-running here resolves the same empty root." >&2
echo " If it names a path: that path is not a Soleur install (a repo checkout is not an install). Run 'claude plugin update soleur', then RESTART Claude Code — plugin changes apply only on restart. If you installed with --scope project or --scope local, pass the same scope. Reinstall only if that does not clear it." >&2
echo " Nothing has been fetched yet, so nothing has leaked." >&2
exit 2; }
SCRUBBER="${CLAUDE_PLUGIN_ROOT}/skills/linear-fetch/scripts/redact-linear-urls.sh"
[[ -r "$SCRUBBER" ]] || { echo "SOLEUR_LINEAR_FETCH_HALT reason=scrubber-unreadable scrubber=[$SCRUBBER]"
echo "linear-fetch: the redaction primitive is missing from an otherwise valid Soleur install — stopping." >&2
echo " Expected at: [$SCRUBBER]" >&2
echo " The install is partial or out of date. Run 'claude plugin update soleur', then RESTART Claude Code — plugin changes apply only on restart. If you installed with --scope project or --scope local, pass the same scope. Reinstall only if that does not clear it." >&2
echo " Nothing has been fetched yet, so nothing has leaked." >&2
exit 2; }
echo "SOLEUR_LINEAR_FETCH_PREFLIGHT_OK scrubber=present"
```
If this fence exits non-zero, **stop**: do not invoke `extract_images`, do not emit
`agent_context`, and do not emit a disclosure line. The correct operator-visible outcome is the
halt message above, not a partially-fetched issue.
### Phase C — Image passthrough
For each issue's combined markdown blob from Phase B, invoke `mcp__linear-server__extract_images(markdown=<blob>)`. The MCP server resolves Linear-authenticated CDN URLs server-side and streams the underlying image bytes back into the active conversation as image content blocks. The skill never sees the bytes, never writes them to disk, never re-fetches them.
Count the image content blocks returned per issue (call this `N_images_total`), and count how many were extracted from comment bodies specifically (call this `M_comments_with_images`). The MCP response shape should make this distinguishable; if not, infer from the per-source split by passing description and comments through `extract_images` separately.
### Phase D — Disclosure & dual-artifact return
For each issue, emit exactly one disclosure line to stdout based on the Phase C counts:
<!-- markdownlint-disable MD038 -->
<!-- #7927: MD038 on this table is cosmetic. Verified with the repo's own markdown-it:
every span here renders with no edge space (CommonMark strips a single leading and
trailing space), except one where the leading space is the point -- the cell
documents a string that is APPENDED, so its space is content. A disable-line comment
cannot be used: GFM discards anything past a row's closing pipe. -->
| Condition | Disclosure line |
|---|---|
| Images present | `Detected <ID> — fetched issue + N_images_total images from description and M_comments_with_images comments.` |
| Text-only (description and comments had zero `uploads.linear.app` references and `extract_images` returned zero images) | `Detected <ID> — text-only issue, no images.` |
| Soft anomaly (description or comments contained `uploads.linear.app` URLs but `extract_images` returned zero images) | `Detected <ID> — N image URLs found but extraction returned 0 images (soft anomaly).` |
| Comment cap hit (`list_comments` returned exactly 250 — Linear MCP's per-call limit, may have more comments past the cap) | append ` (comment cap hit — may be missing newer comments)` to the appropriate line above. The skill takes the 10 most-recent from the 250-window, so very high-volume issues may miss comments past position 250. Cursor pagination is a v1.1 follow-up (issue filed at plan finalization). |
<!-- markdownlint-enable MD038 -->
Construct the two return artifacts:
- **agent_context** — emit the full markdown blob inline in the conversation, so the parent's message history holds both the text and the image content blocks from `extract_images`. The parent retains these for downstream phases (one-shot Steps 3+, brainstorm Phase 2 Synthesis, brainstorm Phase 3 Capture). Per `2026-05-12-task-subagent-prompt-text-only.md`, image content blocks do NOT propagate to Task subagents — design accordingly.
- **persist_safe_summary** — produce by piping the full markdown blob through the redaction primitive, resolved from the **deployed plugin root** (ADR-179's canonical bare anchor, no fallback arm). The redacted text is the only artifact callers may persist. Return it as a fenced text block clearly labeled `PERSIST-SAFE SUMMARY (use this for any write to disk):` so the parent skill cannot confuse it with the agent_context.
**The exit-code dispatch below is load-bearing, and this site had none before #7450.** With no
guard, an unresolved root yields exit `127` and **empty stdout** — and empty stdout is exactly
the shape an agent can mistake for "the redacted text" and persist. Empty output is a failure,
never a clean summary. The default arm was removed, not re-pointed: `review/SKILL.md` instructs
`gh pr checkout`, so that arm resolved this scrubber from the reviewed party's tree.
The identity and readability checks are NOT repeated here — they ran in **Phase B.0**, before
anything was fetched. What remains is the blob-dependent half.
Substitute the full markdown blob verbatim between the heredoc delimiters. **The delimiter is
quoted (`<<'LINEAR_BLOB'`), which is load-bearing twice over**: it stops the shell expanding
issue text that Linear users control, and it keeps the blob off disk. The pre-#7450 form
(`bash "$SCRUBBER" <blob-tmpfile>`) wrote the **un-redacted, bearer-URL-bearing** blob to a file
that was never created, named, or removed by any step in this skill, on all four halt paths —
while the prose two paragraphs up still described the operation as piping.
```bash
# RE-DERIVED, not inherited. Phase B.0 is a SEPARATE Bash call, and shell state does not
# persist across calls — so `$SCRUBBER` is EMPTY here unless it is defined in this fence.
# Assuming otherwise is not a theoretical risk: it shipped, and it bricked this skill on
# every invocation (`bash: : No such file or directory` -> reason=scrubber-nonzero-exit ->
# rc 2, so `persist_safe_summary` could never be produced and every caller hard-stopped).
# It failed CLOSED only because the anchor is quoted; unquoted, `bash $SCRUBBER <<'…'`
# executes the heredoc, i.e. runs Linear issue text as a shell script.
# B.0 still owns the identity preflight and the readability check — those are the
# blob-independent halts that must precede the fetch. This line is the same bare anchor,
# re-stated where it is used.
SCRUBBER="${CLAUDE_PLUGIN_ROOT}/skills/linear-fetch/scripts/redact-linear-urls.sh"
# The scrubber reads stdin and writes redacted text to stdout.
PERSIST_SAFE="$(bash "$SCRUBBER" <<'LINEAR_BLOB'
<the full markdown blob from Phase B, verbatim>
LINEAR_BLOB
)" || { echo "SOLEUR_LINEAR_FETCH_HALT reason=scrubber-nonzero-exit"
echo "linear-fetch: the redaction primitive exited non-zero — stopping." >&2
echo " Nothing is persisted. Do NOT fall back to agent_context; it carries the bearer URLs." >&2
echo " What to do: re-run once. If it repeats, this is a Soleur defect — file it with the exit status; do not hand-copy the issue text." >&2
exit 2; }
[ -n "$PERSIST_SAFE" ] \
|| { echo "SOLEUR_LINEAR_FETCH_HALT reason=redaction-empty-output"
echo "linear-fetch: redaction produced EMPTY output — stopping." >&2
echo " Empty output is a failure, not a clean summary. Do NOT persist it." >&2
echo " What to do: re-run once. If it repeats, this is a Soleur defect — file it; do not paste the issue text by hand." >&2
exit 2; }
# Redaction must have HAPPENED, not merely have run. A scrubber that returns its input
# unchanged is non-empty and exits 0, so every other guard here stays green while the
# artifact labelled PERSIST-SAFE still carries the signed bearer URLs. This is also the
# only check on this path that does not depend on the anchor being trustworthy.
# Herestring, not a pipe: under `set -o pipefail` a `grep -q` that matches early closes
# the pipe, the producer takes SIGPIPE, and the pipeline reports failure on a match.
if grep -qiE 'uploads\.linear\.app' <<<"$PERSIST_SAFE"; then
echo "SOLEUR_LINEAR_FETCH_HALT reason=redaction-ineffective"
echo "linear-fetch: redaction did NOT remove the signed uploads.linear.app URLs — stopping." >&2
echo " The scrubber ran and returned output, but the output still carries bearer credentials." >&2
echo " Do NOT persist this text." >&2
echo " This is a SOLEUR DEFECT, not a problem with your issue: the scrubber ran and returned bearer URLs anyway." >&2
echo " What to do: do NOT retry — a retry will produce the same result. File this with the issue identifier (not its contents)." >&2
exit 2
fi
# Emit it. Shell state does not persist across Bash calls, so a block that assigns
# PERSIST_SAFE and never prints it cannot produce the artifact it exists to produce.
printf '%s\n' "$PERSIST_SAFE"
```
On any halt above, `persist_safe_summary` **does not exist**. Callers must treat its absence as a hard stop and MUST NOT substitute `agent_context` — see the absent-artifact contract in [one-shot](../one-shot/SKILL.md) Step 0a and [brainstorm](../brainstorm/SKILL.md) Phase 0.4. `agent_context` carries the signed `uploads.linear.app` bearer URLs this primitive exists to remove, so falling back to it converts a refusal into the exact leak.
**Telemetry redaction (TR7).** This skill MUST NOT include the matched Linear identifier, the issue title, any image URL, or any signed-URL fragment in incident telemetry (`.claude/hooks/lib/incidents.sh emit_incident`). If telemetry is ever emitted from inside this skill, use generic strings only (e.g., `linear-fetch applied`). The current implementation has zero `emit_incident` call sites; a future maintainer adding one must pass the [assert-no-linear-telemetry.sh](./scripts/assert-no-linear-telemetry.sh) assertion or extend the assertion to cover the new emission shape.
### Phase E — Failure handling
For any MCP failure that is NOT a benign 404 false-positive:
- Emit a one-line warning to stdout: `Linear fetch failed for <ID>: <reason>. Continuing without image context.`
- Do NOT abort the parent skill. The original identifier text remains in `$ARGUMENTS`; the downstream agent can still reason about the issue from prose alone.
- Do NOT include the matched identifier in any telemetry emission per TR7. The warning above is operator-facing stdout, not telemetry — the identifier is permitted in the warning so the operator can take corrective action.
The most common reasons (and what they mean):
| Reason | What it usually means |
|---|---|
| `MCP tool not found` | Linear MCP server is not registered for this session. Run `mcp__linear-server__authenticate`. |
| `403 Forbidden` | Token is valid but lacks access to the issue's workspace. Confirm Linear workspace membership. |
| `Network timeout` | Linear API unreachable. Try again, or work offline from prose only. |
| `Token expired` | OAuth token rotation needed. Re-run `mcp__linear-server__authenticate`. |
## Caller invocation contract
This skill is invoked via the `Skill` tool from inside another skill (not directly by the operator). Two known callers as of v1:
- - `/soleur:one-shot` Step 0a — parent fetches once, retains images for Steps 3+, substitutes `persist_safe_summary` into the Steps 1-2 Task subagent's prompt template.
- - `/soleur:brainstorm` Phase 0.4 — parent fetches once, retains images for Phase 2 Synthesis and Phase 3 Capture, embeds `persist_safe_summary` in Phase 0.5 domain-leader prompts.
+ - `soleur:one-shot` Step 0a — parent fetches once, retains images for Steps 3+, substitutes `persist_safe_summary` into the Steps 1-2 Task subagent's prompt template.
+ - `soleur:brainstorm` Phase 0.4 — parent fetches once, retains images for Phase 2 Synthesis and Phase 3 Capture, embeds `persist_safe_summary` in Phase 0.5 domain-leader prompts.
- The skill does NOT modify `/soleur:plan`, `/soleur:fix-issue`, or `/soleur:work` in v1. Those are tracked as a v2 follow-up issue at plan-finalization time.
+ The skill does NOT modify `soleur:plan`, `soleur:fix-issue`, or `soleur:work` in v1. Those are tracked as a v2 follow-up issue at plan-finalization time.
## Manual Test Runbook
The 10 spec acceptance-test scenarios. Operators run these against a real Linear workspace before marking the post-merge acceptance criteria done. Each step lists: input to type into Claude Code, expected disclosure, and post-condition to verify.
- 1. **Happy path — single-issue, description-only image.** Pick a real `SOL-*` issue whose description contains one Linear-CDN-hosted image (markdown shape `![](URL)`). Type `fix <ID>` into a `/soleur:one-shot`-routed prompt. Expected: disclosure `Detected <ID> — fetched issue + 1 images from description and 0 comments.` Visual check: the image is rendered inline in the current conversation. Persist check: the plan subagent's plan document at `knowledge-base/project/plans/...` contains zero `uploads.linear.app` matches (`grep -c` returns 0).
+ 1. **Happy path — single-issue, description-only image.** Pick a real `SOL-*` issue whose description contains one Linear-CDN-hosted image (markdown shape `![](URL)`). Type `fix <ID>` into a `soleur:one-shot`-routed prompt. Expected: disclosure `Detected <ID> — fetched issue + 1 images from description and 0 comments.` Visual check: the image is rendered inline in the current conversation. Persist check: the plan subagent's plan document at `knowledge-base/project/plans/...` contains zero `uploads.linear.app` matches (`grep -c` returns 0).
2. **Happy path — comments-only image.** Pick an issue with a text-only description and one image attached in a comment. Same invocation. Expected: `fetched issue + 1 images from description and 1 comments.`
3. **Text-only issue.** Pick an issue with no images anywhere. Expected: `text-only issue, no images.`
4. **Multi-issue input.** Type `compare <ID-1> and <ID-2>`. Expected: two disclosure lines, one per issue.
5. **404 issue.** Type `fix SOL-999999` (or any non-existent identifier). Expected: silent no-op, no warning printed (false-positive path).
6. **False-positive identifier.** Type `the PR-123 we shipped`. Expected: silent no-op for `PR-123` (404 → silently dropped). No warning, no MCP error surfacing to the operator.
7. **Comments cap.** Pick an issue with 15+ comments, several of which contain images. Expected: only 10 most-recent processed; the disclosure's `M_comments_with_images` count is at most 10.
- 8. **Persist-safe round-trip.** After a `/soleur:brainstorm` invocation that triggered the skill, run `grep -c 'uploads.linear.app' knowledge-base/project/brainstorms/<latest>.md`. Expected: `0`.
+ 8. **Persist-safe round-trip.** After a `soleur:brainstorm` invocation that triggered the skill, run `grep -c 'uploads.linear.app' knowledge-base/project/brainstorms/<latest>.md`. Expected: `0`.
9. **CI grep gate fires.** Create a fixture branch outside Claude Code, manually `git add` and `git commit` a file containing `https://uploads.linear.app/TEST-FIXTURE-NOT-REAL.png`, push, and open a PR. Expected: the `pii-grep` CI job fails the PR with a clear `::error::` annotation pointing at `knowledge-base/project/specs/feat-linear-issue-image-context/spec.md`.
10. **Telemetry redaction.** Run any skill that emits incident telemetry while the conversation has linear-fetch context. Inspect `.claude/incidents/*.jsonl` after the test run. Expected: zero lines contain a Linear identifier (`SOL-\d+` or similar) or `uploads.linear.app`.
If any scenario fails post-merge, file a P1 follow-up issue, leave `#3635` open, and do not mark the skill production-ready until the failure is reproduced and fixed.
## Sharp Edges
- The `LINEAR_CDN_PATTERNS` array in [scripts/redact-linear-urls.sh](./scripts/redact-linear-urls.sh) is intentionally extensible (currently one pattern: `uploads.linear.app`). If Linear introduces a new CDN hostname (`cdn.linear.app`, `linear-assets.com`), add a new pattern to the array AND update the CI `pii-grep` workflow regex in the same PR. The redaction primitive's unit-test fixtures must also gain a matching fixture per new pattern.
- The character class excludes single quote via the bash `$'\x27'` ANSI-C escape (so the regex literal contains `'`). This works on bash 5.x (Linux) and bash 3.2 (macOS) but the test runbook explicitly tests on both before declaring the script production-ready.
- `list_comments` `orderBy` direction is not exposed in the MCP schema; client-side `createdAt desc` sort + take-10 is the hard fallback to remain order-independent. Do not assume server-side direction.
- The skill must be invoked via the `Skill` tool, not run as a CLI. The `Skill` invocation places the image content blocks from `extract_images` into the caller's conversation; a standalone CLI run would lose the images entirely.
- `extract_images` may return zero images for an issue whose markdown contains `uploads.linear.app` URLs (e.g., an upstream Linear bug, transient CDN failure, expired URL inside the issue). The Phase D soft-anomaly disclosure exists precisely so this case is operator-visible and not silent.