# Feature: External Context Injection (Phase 1 Step 1.5)

Phase 0 Step 1b catalogued every typed external link from the task description into `state.contextLinks[]`. Phase 1 dispatches each entry to its matching fetcher and prepends the result to the analysis prompt under a **Referenced External Sources** section, so the agent doesn't re-discover what the ticket already pointed at.

```bash
jq -c '.contextLinks // []' "$STATE_FILE" \
  | python3 -c '
import json, sys, os
links = json.loads(sys.stdin.read() or "[]")
by_type = {}
for l in links:
    by_type.setdefault(l["type"], []).append(l)
print(json.dumps(by_type))
' > /tmp/context-by-type-${TASK_ID}.json
```

## Dispatch table

One fetcher per type. Each fetcher emits a normalized JSON view that the analysis prompt consumes as ground truth for the referenced source.

| Type | Fetcher | Output stored in |
|---|---|---|
| `crashlytics` | `~/.claude/lib/fetch-crashlytics.sh <url>` (already invoked in Phase 0 Step 1b.1 for the legacy `state.crashContext` field; Phase 1 reads that field directly) | `state.crashContext` |
| `fortify` | `~/.claude/lib/fetch-fortify.sh <url>` (already invoked in Phase 0 Step 1b.2 for the legacy `state.fortifyFinding` field; Phase 1 reads that field, Phase 4 reads the full payload for the security gate) | `state.fortifyFinding` |
| `graylog` | `~/.claude/lib/fetch-graylog.sh --trx <id>` / `--conv <id>` (already invoked in Phase 0 Step 1b.3 for the `state.graylogContext` field; Phase 1 reads that field directly). Diagnostic logs, **advisory only**  -  no Phase 4 gate, no generated task. Exception: `/multi-agent:complaint-analysis` consumes Graylog as its **primary evidence** by design (its Locked 1); the advisory-only rule scopes to the dev pipeline | `state.graylogContext` |
| `swagger` | `~/.claude/lib/fetch-swagger.sh <url>` → endpoints[], request/response examples | `state.fetchedContext.swagger[]` |
| `confluence` | `~/.claude/lib/fetch-confluence.sh <url>` → page body, code blocks, extracted API contracts | `state.fetchedContext.confluence[]` |
| `figma` | no standalone fetcher - resolve via the Figma 3-tier chain (Tier 1 MCP tools, Tier 2 `~/.claude/lib/figma-screenshot.sh` + REST) when the task is a component; otherwise advisory only | `state.fetchedContext.figma[]` |
| `generic-doc` | no automatic fetcher  -  surfaced as advisory; the agent uses WebFetch on demand when the URL turns out to be load-bearing | n/a |

## Exit code handling

- `0` (success) → output appended to `state.fetchedContext.<type>[]` and injected into the prompt.
- `2` (blocked / missing token) → run the inline Token Save Flow per `setup.md`; on skip, mark the entry as `{status: "skipped", reason: "missing-token"}` and continue.
- `3` (network/auth) → **surface the Unreachable-source decision below.** Never silently downgrade.
- `4`/`5`/`6` (config errors) → mark as `{status: "failed", reason: "<exit-msg>"}`, continue and log a setup hint.

### MUST: an unreachable source is announced, not absorbed (BLOCKING)

A fetcher that fails is the pipeline losing the ground truth it was told to use. The
user is the only one who can fix it - the token is theirs to refresh, the VPN is theirs
to connect - and they cannot fix what they are not told about.

This used to read "mark as failed, continue". The observed result: an expired token and
a VPN-off remote both reached the analysis phase as *no data*, indistinguishable from a
ticket that referenced nothing. The run then produced a plan from a partial picture and
reported success, and the user found out by reading the output.

So on exit `3`, classify the stderr and surface an `AskUserQuestion` before continuing.
The classification is the same shape the Phase 6 remote gate uses, because the failures
are the same failures:

| stderr signal | Diagnosis | Question offers |
|---|---|---|
| `401`, `403`, `invalid_grant`, `token expired`, `unauthorized` | the credential is dead | **Refresh `<KEY_NAME>`** (Save Flow Step B, then retry) · **Continue without this source** · **Abort** |
| `could not resolve host`, `connection refused`, `timed out`, `network is unreachable`, corporate host in the URL | the host is unreachable, VPN almost certainly off | **Connect the VPN, then retry** (default) · **Continue without this source** · **Abort** |
| `404`, `no such issue`, `not found` | the reference is stale or the link points at something deleted | **Supply a current URL** · **Continue without this source** · **Abort** |
| anything else | unknown | verbatim stderr in the `description`, same three options |

Rules that make this actionable rather than decorative:

1. **Name the thing.** The question states which source (`crashlytics`), which URL, and
   which credential key by its logical name (`firebase`, never the raw Keychain item).
2. **Say what is lost.** "Without it I plan from the exception message alone, with no
   stack frames and no affected-version spread" beats "continuing without context".
3. **Retry is a real branch.** On "Connect the VPN, then retry" / "Refresh", re-run the
   same fetcher and re-enter this table on a second failure. Do not fall through to
   "continue" after one attempt.
4. **Record the choice.** `state.fetchedContext.<type>[].status` ∈ `ok | skipped | failed`
   plus `userDecision` ∈ `retried-ok | continued-without | aborted`, so Phase 1 can put
   "planned without Crashlytics, user's choice at <ts>" in the analysis doc's limitations
   section instead of leaving a silent hole.
5. **Autopilot still reports.** Autopilot takes "continue without this source" without
   asking, but the skipped source is logged and carried into the Phase 7 report. An
   autopilot run that quietly planned from a partial picture is the same defect with a
   flag on it.

Failures remain non-fatal at Phase 1 by default - the agent still runs analysis. What
changed is that the user learns about it while the choice is still theirs to make.

**Graylog specifics.** `fetch-graylog.sh` degrades to empty on any network/VPN failure: it emits a normalized empty object and exits `0`, so an unreachable host reads as `{status:"skipped", reason:"vpn-unreachable"}` and never blocks. **Exiting `0` is not permission to stay quiet.** The payload carries `degraded: true` and a `degradeReason`; when it does, log one line naming the host and the reason, and carry it into the Phase 7 report. Logs are advisory, so this never asks a question and never blocks - but "I searched the logs and found nothing" and "I never reached the log server" are different statements, and only one of them is true. Only a genuine auth rejection on a reachable host exits `3` (marked `failed`, still non-fatal here). `state.graylogContext` is prepended to the analysis prompt inside the **Referenced External Sources** section as diagnostic context that is **advisory only**  -  the agent may use it to orient on a reported error, but code remains ground truth, and there is no Phase 4 review gate for logs.

## Prompt injection shape

Added immediately after the knowledge-injection block, before codebase exploration:

```
## Referenced External Sources

(when fetchers populated `state.fetchedContext.<type>`)
### <type>  -  <url>
<fetched content, scoped to relevance>

(when fetcher pending)
### Pending references
- <type>: <url>  -  <one-line metadata>
```

The agent treats fetched content as **ground truth** for the referenced source (no re-discovery, no contradiction). Pending references are advisories  -  the agent decides whether to fetch via WebFetch on demand when the task hinges on that source.

## Log line shape

```
→ context injection: total=<N>, fetched=<n>, pending=<n>, by-type={swagger:F/P, confluence:F/P, ...}
```

`F` = fetched, `P` = pending. Empty contextLinks → `total=0`, skip the section entirely.
