@windyroad/itil 1.0.0 → 1.1.0-preview.1067

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.
@@ -497,5 +497,5 @@
497
497
  }
498
498
  },
499
499
  "name": "wr-itil",
500
- "version": "1.0.0"
500
+ "version": "1.1.0"
501
501
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/itil",
3
- "version": "1.0.0",
3
+ "version": "1.1.0-preview.1067",
4
4
  "description": "ITIL-aligned IT service management for Claude Code (problem, and future incident/change skills)",
5
5
  "bin": {
6
6
  "windyroad-itil": "./bin/install.mjs"
@@ -45,7 +45,7 @@
45
45
  # Structured stdout (one per actionable upstream entry; <= 150 bytes per
46
46
  # line per ADR-038). ASCII `->` for the transition arrow per the P334
47
47
  # awk/script portability lesson (no Unicode in machine-read output):
48
- # CATCHUP P<NNN> <url> state=<state> transition=<KE->Verifying|Verifying->Closed>
48
+ # CATCHUP P<NNN> <url> state=<state> transition=<KE->Verifying|Verifying->Closed> disclosure=<issue|pull-request>
49
49
  # CATCHUP P<NNN> inbound-<ref> state=<state> transition=<…> direction=inbound
50
50
  # SKIP P<NNN> <url> reason=already-logged
51
51
  # SKIP P<NNN> inbound-<ref> reason=already-logged
@@ -64,6 +64,10 @@
64
64
  # @adr ADR-049 (invoked via wr-itil-catchup-scan bin shim, never repo-relative path)
65
65
  # @adr ADR-032 (foreground synchronous skill)
66
66
  # @adr ADR-076 (reads the `**Origin**: inbound-reported (#NN)` field for the inbound leg)
67
+ # @adr ADR-117 (prefer an upstream pull request over an issue — the CATCHUP
68
+ # row carries a `disclosure=` discriminator so the consuming skill knows
69
+ # whether to comment on a pull request or an issue. A `pull request` path
70
+ # is actionable, so it is NOT skipped alongside out-of-band / mailbox.)
67
71
  # @problem P376 — catchup scanner misses the inbound direction (cross-direction parity)
68
72
  # @rfc RFC-028 (consume the Origin field for inbound-reported verdict — extended to the catchup surface)
69
73
  # @jtbd JTBD-301 (reporter feedback loop — the catchup's primary job)
@@ -264,8 +268,17 @@ for ticket_file in "${TICKET_FILES[@]}"; do
264
268
  printf "SKIP %s %s reason=already-logged\n" "$ticket_id" "$upstream_url"
265
269
  SKIP_LOGGED=$((SKIP_LOGGED + 1))
266
270
  else
267
- printf "CATCHUP %s %s state=%s transition=%s\n" \
268
- "$ticket_id" "$upstream_url" "$state" "$transition"
271
+ # Carry the artefact kind to the consumer. Without it the value is
272
+ # recorded on the ticket and never acted on, and `/wr-itil:update-upstream`
273
+ # runs `gh issue comment` against a pull request. Hyphenated on the wire
274
+ # so the row stays whitespace-tokenisable; the ticket body keeps the
275
+ # spaced `pull request` spelling.
276
+ case "$disclosure" in
277
+ *pull*request*) disclosure_field="pull-request" ;;
278
+ *) disclosure_field="issue" ;;
279
+ esac
280
+ printf "CATCHUP %s %s state=%s transition=%s disclosure=%s\n" \
281
+ "$ticket_id" "$upstream_url" "$state" "$transition" "$disclosure_field"
269
282
  CATCHUP_COUNT=$((CATCHUP_COUNT + 1))
270
283
  fi
271
284
  fi
@@ -5,10 +5,11 @@
5
5
  # discovery pipeline. Scans local problem tickets for `## Reported
6
6
  # Upstream` back-link sections (written by `/wr-itil:report-upstream`
7
7
  # Step 7), polls each upstream issue via `gh issue view`, diffs against
8
- # cache, and surfaces new comments / state changes / label changes since
8
+ # cache, and surfaces new responses / state changes / label changes since
9
9
  # last check.
10
10
  #
11
- # Read-only externally: only `gh issue view` (read-only) no
11
+ # Read-only externally: `gh issue view`, or `gh pr view` plus the pull
12
+ # request's inline review comments via `gh api` — no
12
13
  # `gh issue comment` / `gh issue create`. Does NOT trip ADR-028
13
14
  # external-comms gate. AFK-safe.
14
15
  #
@@ -28,13 +29,13 @@
28
29
  # written to cache + audit-log
29
30
  #
30
31
  # Structured stdout (one per ticket; ≤ 150 bytes per line per ADR-038):
31
- # NEW P<NNN> <url> state=<state> new-comments=<N>
32
+ # NEW P<NNN> <url> state=<state> new-responses=<N>
32
33
  # STATE P<NNN> <url> state=<old>→<new>
33
34
  # LABEL P<NNN> <url> labels-added=<csv> labels-removed=<csv>
34
35
  # NONE P<NNN> <url> no-change-since=<last-checked>
35
36
  # FAIL P<NNN> <url> reason=<gh-error-short>
36
37
  #
37
- # Precedence when multiple change classes apply: STATE > NEW (comments) > LABEL > NONE.
38
+ # Precedence when multiple change classes apply: STATE > NEW (responses) > LABEL > NONE.
38
39
  #
39
40
  # @problem P249 — no process for issue reporters to check for responses (Phase 1)
40
41
  # @adr ADR-014 (governance skills commit their own work)
@@ -44,6 +45,14 @@
44
45
  # @adr ADR-038 (progressive disclosure — per-row byte budget)
45
46
  # @adr ADR-049 (invoked via wr-itil-check-upstream-responses bin shim)
46
47
  # @adr ADR-062 (inbound discovery — symmetric counterpart)
48
+ # @adr ADR-117 (prefer an upstream pull request over an issue — the
49
+ # `## Reported Upstream` disclosure path selects `gh pr view` over
50
+ # `gh issue view`. An ABSENT path means a ticket written before that
51
+ # decision, which the pre-ADR-117 skill could only ever have filed as
52
+ # a public issue — so absent means issue, and no probe is needed. This
53
+ # is the explicit legacy-path decision ADR-117 requires; an
54
+ # issue-then-pr probe would double the call count for every legacy
55
+ # ticket on every cycle against a `gh` rate budget nothing governs.)
47
56
  # @jtbd JTBD-004 (cross-repo coordination — primary anchor)
48
57
  # @jtbd JTBD-006 (AFK-safe)
49
58
  # @jtbd JTBD-001 (governance without slowing down)
@@ -146,6 +155,30 @@ extract_upstream_url() {
146
155
  ' "$1"
147
156
  }
148
157
 
158
+ # Extract the `## Reported Upstream` disclosure-path line (lower-cased).
159
+ # Same awk shape as catchup-scan.sh's helper of the same name.
160
+ extract_disclosure_path() {
161
+ awk '
162
+ /^## Reported Upstream/ { in_section = 1; next }
163
+ /^## / && in_section { in_section = 0 }
164
+ in_section && /^- \*\*Disclosure path\*\*:/ {
165
+ sub(/^- \*\*Disclosure path\*\*: */, "")
166
+ print
167
+ exit
168
+ }
169
+ ' "$1" | tr "[:upper:]" "[:lower:]"
170
+ }
171
+
172
+ # Which `gh` read subcommand polls this ticket's upstream artefact?
173
+ # `pull request` (or `pull-request`) → `gh pr view`; anything else,
174
+ # INCLUDING an absent disclosure path, → `gh issue view` (ADR-117).
175
+ upstream_view_noun() {
176
+ case "$1" in
177
+ *pull*request*) echo "pr" ;;
178
+ *) echo "issue" ;;
179
+ esac
180
+ }
181
+
149
182
  # Extract numeric ID prefix from a ticket file basename.
150
183
  extract_ticket_id() {
151
184
  local base
@@ -189,8 +222,16 @@ for ticket_file in "${TICKET_FILES[@]}"; do
189
222
 
190
223
  POLL_COUNT=$((POLL_COUNT + 1))
191
224
 
192
- # Poll the upstream.
193
- if ! gh_output="$("$GH_BIN" issue view "$upstream_url" --json comments,state,labels,updatedAt 2>&1)"; then
225
+ # Poll the upstream. Pull requests need one additional read because
226
+ # `gh pr view` omits inline review-thread comments. `state` carries MERGED
227
+ # for a merged pull request, distinct from CLOSED.
228
+ view_noun="$(upstream_view_noun "$(extract_disclosure_path "$ticket_file")")"
229
+ if [ "$view_noun" = "pr" ]; then
230
+ view_fields="comments,reviews,state,labels,updatedAt"
231
+ else
232
+ view_fields="comments,state,labels,updatedAt"
233
+ fi
234
+ if ! gh_output="$("$GH_BIN" "$view_noun" view "$upstream_url" --json "$view_fields" 2>&1)"; then
194
235
  short_reason="$(echo "$gh_output" | head -1 | cut -c1-80)"
195
236
  printf "FAIL %s %s reason=%s\n" "$ticket_id" "$upstream_url" "$short_reason"
196
237
  PARTIAL_FAILURE=1
@@ -198,39 +239,67 @@ for ticket_file in "${TICKET_FILES[@]}"; do
198
239
  continue
199
240
  fi
200
241
 
242
+ if [ "$view_noun" = "pr" ]; then
243
+ pr_path="${upstream_url#https://github.com/}"
244
+ pr_path="${pr_path%/}"
245
+ review_endpoint="repos/${pr_path/\/pull\//\/pulls\/}/comments"
246
+ if ! review_pages="$("$GH_BIN" api "$review_endpoint" --paginate --slurp 2>&1)"; then
247
+ short_reason="$(echo "$review_pages" | head -1 | cut -c1-80)"
248
+ printf "FAIL %s %s reason=%s\n" "$ticket_id" "$upstream_url" "$short_reason"
249
+ PARTIAL_FAILURE=1
250
+ FAIL_COUNT=$((FAIL_COUNT + 1))
251
+ continue
252
+ fi
253
+ if ! review_comments="$(echo "$review_pages" | jq -c 'add // []' 2>/dev/null)"; then
254
+ printf "FAIL %s %s reason=malformed-inline-review-response\n" "$ticket_id" "$upstream_url"
255
+ PARTIAL_FAILURE=1
256
+ FAIL_COUNT=$((FAIL_COUNT + 1))
257
+ continue
258
+ fi
259
+ gh_output="$(jq -cn --argjson base "$gh_output" --argjson inline "$review_comments" '$base + {reviewComments: $inline}')"
260
+ else
261
+ gh_output="$(jq -cn --argjson base "$gh_output" '$base + {reviewComments: []}')"
262
+ fi
263
+
201
264
  # Parse upstream state.
202
265
  current_state="$(echo "$gh_output" | jq -r '.state // "UNKNOWN"')"
203
- current_comment_count="$(echo "$gh_output" | jq -r '.comments | length')"
266
+ current_response_count="$(echo "$gh_output" | jq -r '((.comments // []) | length) + ((.reviews // []) | length) + ((.reviewComments // []) | length)')"
204
267
  current_labels_csv="$(echo "$gh_output" | jq -r '[.labels[].name] | sort | join(",")')"
205
268
  current_updated_at="$(echo "$gh_output" | jq -r '.updatedAt // ""')"
269
+ current_response_updated_at="$(echo "$gh_output" | jq -r '[
270
+ (.comments[]? | .updatedAt // .createdAt),
271
+ (.reviews[]? | .updatedAt // .submittedAt // .createdAt),
272
+ (.reviewComments[]? | .updatedAt // .createdAt)
273
+ ] | map(select(. != null and . != "")) | max // ""')"
206
274
 
207
275
  # Look up cache entry.
208
276
  cache_state="$(echo "$UPDATED_CACHE" | jq -r ".tickets[\"$ticket_id\"].last_seen_state // \"\"")"
209
- cache_comment_count="$(echo "$UPDATED_CACHE" | jq -r ".tickets[\"$ticket_id\"].last_seen_comment_count // -1")"
277
+ cache_response_count="$(echo "$UPDATED_CACHE" | jq -r ".tickets[\"$ticket_id\"].last_seen_response_count // .tickets[\"$ticket_id\"].last_seen_comment_count // -1")"
210
278
  cache_labels_csv="$(echo "$UPDATED_CACHE" | jq -r ".tickets[\"$ticket_id\"].last_seen_labels // [] | sort | join(\",\")")"
211
279
  cache_last_checked="$(echo "$UPDATED_CACHE" | jq -r ".tickets[\"$ticket_id\"].last_checked_at // \"\"")"
280
+ cache_response_updated_at="$(echo "$UPDATED_CACHE" | jq -r ".tickets[\"$ticket_id\"].last_seen_response_updated_at // \"\"")"
212
281
 
213
- # Decide the change class (precedence: STATE > NEW (comments) > LABEL > NONE).
282
+ # Decide the change class (precedence: STATE > NEW (responses/activity) > LABEL > NONE).
214
283
  no_cache_entry=0
215
- if [ -z "$cache_state" ] || [ "$cache_comment_count" = "-1" ]; then
284
+ if [ -z "$cache_state" ] || [ "$cache_response_count" = "-1" ]; then
216
285
  no_cache_entry=1
217
286
  fi
218
287
 
219
288
  if [ "$FORCE_RECHECK" -eq 1 ] || [ "$no_cache_entry" -eq 1 ]; then
220
- delta="$current_comment_count"
289
+ delta="$current_response_count"
221
290
  if [ "$no_cache_entry" -eq 0 ]; then
222
- delta=$((current_comment_count - cache_comment_count))
291
+ delta=$((current_response_count - cache_response_count))
223
292
  [ "$delta" -lt 0 ] && delta=0
224
293
  fi
225
- printf "NEW %s %s state=%s new-comments=%s\n" "$ticket_id" "$upstream_url" "$current_state" "$delta"
294
+ printf "NEW %s %s state=%s new-responses=%s\n" "$ticket_id" "$upstream_url" "$current_state" "$delta"
226
295
  NEW_COUNT=$((NEW_COUNT + 1))
227
296
  elif [ "$current_state" != "$cache_state" ]; then
228
297
  printf "STATE %s %s state=%s→%s\n" "$ticket_id" "$upstream_url" "$cache_state" "$current_state"
229
298
  STATE_CHANGE_COUNT=$((STATE_CHANGE_COUNT + 1))
230
- elif [ "$current_comment_count" -ne "$cache_comment_count" ]; then
231
- delta=$((current_comment_count - cache_comment_count))
299
+ elif [ "$current_response_count" -ne "$cache_response_count" ] || { [ -n "$cache_response_updated_at" ] && [ "$current_response_updated_at" != "$cache_response_updated_at" ]; }; then
300
+ delta=$((current_response_count - cache_response_count))
232
301
  [ "$delta" -lt 0 ] && delta=0
233
- printf "NEW %s %s state=%s new-comments=%s\n" "$ticket_id" "$upstream_url" "$current_state" "$delta"
302
+ printf "NEW %s %s state=%s new-responses=%s\n" "$ticket_id" "$upstream_url" "$current_state" "$delta"
234
303
  NEW_COUNT=$((NEW_COUNT + 1))
235
304
  elif [ "$current_labels_csv" != "$cache_labels_csv" ]; then
236
305
  # Compute labels added/removed.
@@ -251,15 +320,17 @@ for ticket_file in "${TICKET_FILES[@]}"; do
251
320
  --arg state "$current_state" \
252
321
  --arg checked "$now_iso" \
253
322
  --arg updated "$current_updated_at" \
254
- --argjson count "$current_comment_count" \
323
+ --arg response_updated "$current_response_updated_at" \
324
+ --argjson count "$current_response_count" \
255
325
  --argjson labels "$(echo "$gh_output" | jq '[.labels[].name] | sort')" \
256
326
  '.tickets[$id] = {
257
327
  upstream_url: $url,
258
328
  last_checked_at: $checked,
259
329
  last_seen_state: $state,
260
- last_seen_comment_count: $count,
330
+ last_seen_response_count: $count,
261
331
  last_seen_labels: $labels,
262
- last_seen_updated_at: $updated
332
+ last_seen_updated_at: $updated,
333
+ last_seen_response_updated_at: $response_updated
263
334
  }')"
264
335
  done
265
336
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: wr-itil:check-upstream-responses
3
- description: Poll upstream issues we've filed via `/wr-itil:report-upstream` and surface new comments, state changes, or label changes since last check. Reads `## Reported Upstream` back-link sections in local problem tickets, queries `gh issue view` (read-only), diffs against `docs/problems/.outbound-responses-cache.json`, and appends an audit-log entry to `docs/audits/outbound-responses-log.md`. Outbound symmetric counterpart to ADR-062's inbound discovery pipeline (P249 Phase 1).
3
+ description: Poll upstream issues and pull requests we've filed via `/wr-itil:report-upstream` and surface new comments or reviews, state changes, or label changes since last check. Reads `## Reported Upstream` back-link sections in local problem tickets, queries GitHub read-only, diffs against `docs/problems/.outbound-responses-cache.json`, and appends an audit-log entry to `docs/audits/outbound-responses-log.md`. Outbound symmetric counterpart to ADR-062's inbound discovery pipeline (P249 Phase 1).
4
4
  allowed-tools: Read, Edit, Write, Bash, Glob, Grep
5
5
  ---
6
6
 
@@ -14,14 +14,14 @@ This is **Phase 1** of P249. Phase 1 covers the **us-as-upstream-reporter** half
14
14
 
15
15
  In scope:
16
16
  - Scan `docs/problems/**/*.md` for `## Reported Upstream` back-link sections (the contract section written by `/wr-itil:report-upstream` Step 7 — see [ADR-024](../../../../docs/decisions/024-cross-project-problem-reporting-contract.proposed.md) Step 7).
17
- - For each ticket with a back-link, extract the `- **URL**:` line and poll the upstream issue via `gh issue view <url> --json comments,state,labels,updatedAt`.
17
+ - For each ticket with a back-link, extract the `- **URL**:` line and poll the upstream artefact. Read the `- **Disclosure path**:` line to pick the subcommand: any pull-request path uses `gh pr view` plus a read-only `gh api` call for inline review comments; anything else, **including an absent line**, uses `gh issue view`. Absent means a ticket written before ADR-117, which could only have been filed as a public issue — so it is read as an issue, with no probe.
18
18
  - Diff against `docs/problems/.outbound-responses-cache.json` (cache file mirroring the inbound `.upstream-cache.json` shape per ADR-031 § "Cache files live under docs/problems/").
19
- - Surface five response classes: NEW (new comments), STATE (state change), LABEL (label change), NONE (no change), FAIL (gh poll error).
19
+ - Surface five response classes: NEW (new comments, reviews, or other upstream activity), STATE (state change), LABEL (label change), NONE (no change), FAIL (gh poll error).
20
20
  - Update the cache file with the latest seen state.
21
21
  - Append a timestamped pass entry to `docs/audits/outbound-responses-log.md` (audit-log mirroring `docs/audits/inbound-discovery-log.md` per ADR-062's audit-log surface contract).
22
22
 
23
23
  Out of scope:
24
- - Posting comments back to the upstream issue. The skill is **read-only externally** — does not trip ADR-028's external-comms gate.
24
+ - Posting comments back to the upstream issue or pull request. The skill is **read-only externally** — does not trip ADR-028's external-comms gate.
25
25
  - Auto-transitioning local ticket lifecycle based on upstream state change (that is P080's bidirectional update axis, separate).
26
26
  - Polling against the inbound-discovery channels (`docs/problems/.upstream-channels.json`). That is the inverse axis, owned by `/wr-itil:review-problems` Step 4.5 per ADR-062.
27
27
  - Phase 2 external-reporter-as-our-reporter surface (deferred).
@@ -43,7 +43,7 @@ Out of scope:
43
43
 
44
44
  This skill is **AFK-safe by construction**:
45
45
 
46
- - Read-only `gh issue view` calls — does NOT fire ADR-028 external-comms gate.
46
+ - Read-only `gh issue view`, `gh pr view`, and pull-request review-comment `gh api` calls — none fires ADR-028's external-comms gate.
47
47
  - No `AskUserQuestion` calls — five flag-based knobs (`--problems-dir`, `--cache-file`, `--audit-log`, `--ticket`, `--force-recheck`) are the user-direction surface per CLAUDE.md `act on obvious, AskUserQuestion for ambiguous, NEVER prose-ask` (P085).
48
48
  - Partial-failure exit code (2) lets AFK orchestrators distinguish "some upstream URLs were unreachable" from "everything broke" without halting the loop.
49
49
 
@@ -63,7 +63,7 @@ The script:
63
63
 
64
64
  1. Walks `<problems-dir>` (both flat layout `<NNN>-*.<state>.md` AND per-state subdir layout `<state>/<NNN>-*.md` per RFC-002 dual-tolerant migration).
65
65
  2. For each ticket file, extracts the `## Reported Upstream` URL line. Tickets without that section are silently skipped.
66
- 3. For each URL, calls `gh issue view <url> --json comments,state,labels,updatedAt`.
66
+ 3. For each URL, calls `gh issue view --json comments,state,labels,updatedAt` or `gh pr view --json comments,reviews,state,labels,updatedAt` (chosen by the disclosure path). Pull requests also fetch inline review comments through `gh api`. The response count and response-specific timestamp combine all three response surfaces; parent metadata changes do not masquerade as review activity. A merged pull request reports `state=MERGED`, distinct from `CLOSED`, so a rejected one is not recorded as a resolution.
67
67
  4. Compares the response against the cached entry for that ticket and emits one of: NEW / STATE / LABEL / NONE / FAIL.
68
68
  5. Updates the cache file and appends an audit-log entry.
69
69
 
@@ -78,7 +78,7 @@ Exit codes:
78
78
  Read the stdout output and summarise the response classes in chat for the user. The audit-log is the durable surface — the agent's inline summary is the in-session affordance. Do NOT re-dump the full stdout; lead with the most-important classes:
79
79
 
80
80
  - STATE changes (upstream state OPEN → CLOSED / REOPENED) — most actionable; usually a verification signal.
81
- - NEW comments (delta count > 0) — second-most actionable; may carry triage labels, follow-up questions, or fix confirmation.
81
+ - NEW responses or activity — second-most actionable; may carry comments, reviews, inline review comments, follow-up questions, or fix confirmation. A zero response delta means an existing response was edited.
82
82
  - LABEL changes — informational; signals maintainer triage activity.
83
83
  - NONE — quiet; only mention the count, not each ticket.
84
84
  - FAIL — call out per-ticket reasons so the user can investigate (URL changed, repo renamed, auth issue).
@@ -110,7 +110,7 @@ Three invocation surfaces:
110
110
 
111
111
  This skill's contract holds when:
112
112
 
113
- 1. The script `packages/itil/scripts/check-upstream-responses.sh` is read-only externally — only `gh issue view` (no `gh issue comment`, no `gh issue create`, no `gh api`).
113
+ 1. The script `packages/itil/scripts/check-upstream-responses.sh` is read-only externally — `gh issue view`, `gh pr view`, and a pull-request review-comment `gh api` read only; it makes no comment, create, edit, close, or write API call.
114
114
  2. The script extracts the URL from `## Reported Upstream` sections matching the format `- **URL**: <url>` per ADR-024 Step 7's back-link contract.
115
115
  3. After a successful pass, the cache file exists, is valid JSON, and contains a `tickets.<P<NNN>>` entry for every polled ticket.
116
116
  4. After a successful pass, the audit-log file exists and has a new `## YYYY-MM-DDTHH:MM:SSZ` heading appended.
@@ -70,7 +70,7 @@ To un-park: `git mv` back to `docs/problems/open/<NNN>-<title>.md` (or `docs/pro
70
70
 
71
71
  **Verification Pending problems** are also excluded from WSJF ranking — their remaining work is user-side verification, not dev effort. They appear in a dedicated "Verification Queue" section in review output so the user can see what's waiting on them without mixing with dev-work ranking. See step 9c for the queue layout.
72
72
 
73
- **Allowed optional appendages**: a problem ticket file may carry a `## Reported Upstream` section appended after the standard sections. This is written by the `/wr-itil:report-upstream` skill (per ADR-024 Confirmation criterion 3a) and records the upstream issue or advisory URL, the matched template, and the disclosure path. The presence or absence of this section does not affect WSJF ranking or status transitions.
73
+ **Allowed optional appendages**: a problem ticket file may carry a `## Reported Upstream` section appended after the standard sections. This is written by the `/wr-itil:report-upstream` skill (per ADR-024 Confirmation criterion 3a) and records the upstream URL — an issue, a pull request (per ADR-117), or an advisory plus the matched template and the disclosure path. The presence or absence of this section does not affect WSJF ranking or status transitions.
74
74
 
75
75
  **Test-driven resolution:** When root cause is identified, create a failing test that reproduces the problem. Skip/disable the test if a feature-disabling workaround is applied. Re-enable the test when the permanent fix is implemented — the test passing confirms resolution.
76
76
 
@@ -6,13 +6,13 @@ allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion, Skill, Agen
6
6
 
7
7
  # Report Upstream — Cross-Project Problem-Reporting Skill
8
8
 
9
- File a local `docs/problems/<NNN>` ticket as an issue (or private security advisory) against an upstream repository. Discover upstream issue templates, fall through to a structured default when none exist, route security-classified tickets via the upstream's `SECURITY.md`, and back-write a cross-reference into the local ticket.
9
+ Report a local `docs/problems/<NNN>` ticket upstream. **Prefer opening a pull request when the upstream accepts pull requests**; fall back to filing an issue otherwise (or a private security advisory when the ticket is security-classified). Discover upstream templates, fall through to a structured default when none exist, route security-classified tickets via the upstream's `SECURITY.md`, and back-write a cross-reference into the local ticket.
10
10
 
11
11
  This skill implements the contract documented in [ADR-024](../../../docs/decisions/024-cross-project-problem-reporting-contract.proposed.md) (Cross-project problem-reporting contract). All step numbering below maps 1:1 to ADR-024 Decision Outcome.
12
12
 
13
13
  [ADR-033](../../../docs/decisions/033-report-upstream-classifier-problem-first.proposed.md) (Report-upstream classifier is problem-first) partially supersedes ADR-024 Decision Outcome **Steps 3 and 5 only** — the classifier is problem-first with best-fit backward-compat fallback (per Step 3 below), and the structured default body is problem-shaped (per Step 5 below). ADR-024 Steps 1, 2, 4, 6, 7, 8 and all Consequences / Confirmation clauses remain in force unchanged.
14
14
 
15
- The **ADR-024 amendment of 2026-04-25 (P070)** adds Step 4b (dedup check — own re-run + third-party search via `gh issue list --search` + inline LLM semantic match) and Step 5c (comment path — `gh issue comment` with cross-reference body when dedup match found). The maintainer-annoyance risk evaluator named in the P070 Direction decision is deferred to compose with the `wr-risk-scorer:external-comms` subagent declared in ADR-028 (per ADR-028 line 117 — third-evaluator extension point); the AFK auto-comment branch is on the interim **static heuristic** described in Step 4b until that evaluator lands. See Step 4b below.
15
+ The **ADR-024 amendment of 2026-04-25 (P070)** adds Step 4b (dedup check — own re-run + third-party search across issues and pull requests, followed by inline LLM semantic matching) and Step 5c (kind-aware comment path). AFK comments use the shipped `wr-risk-scorer:external-comms` gate; unresolved above-appetite prose is queued without halting the orchestrator.
16
16
 
17
17
  ## Invocation
18
18
 
@@ -44,7 +44,7 @@ This skill files reports against an upstream's intake surface. Its reciprocal
44
44
 
45
45
  ## Voice-tone gate interaction (ADR-028)
46
46
 
47
- The skill's `gh issue create` (Step 5) and `gh api repos/.../security-advisories` (Step 6) calls are **on the gated surface list per [ADR-028](../../../docs/decisions/028-voice-tone-gate-external-comms.proposed.md)** (Voice-tone gate on external communications). Expected behaviour during these tool calls:
47
+ The skill's `gh issue create` (Step 5), `gh pr create` (Step 5b), `gh issue comment` / `gh pr comment` (Step 5c) and `gh api repos/.../security-advisories` (Step 6) calls are **on the gated surface list per [ADR-028](../../../docs/decisions/028-voice-tone-gate-external-comms.proposed.md)** (Voice-tone gate on external communications). Expected behaviour during these tool calls:
48
48
 
49
49
  1. The voice-tone gate fires `PreToolUse:Bash` with a deny-plus-delegate response.
50
50
  2. The hook delegates to `wr-voice-tone:agent` to review the drafted body for brand-voice + tone alignment against `docs/VOICE-AND-TONE.md`.
@@ -54,6 +54,13 @@ The skill should treat this transient deny-plus-delegate as the expected path, n
54
54
 
55
55
  If `wr-voice-tone:agent` is not installed in the project, the gate is dormant and the skill proceeds without delegation.
56
56
 
57
+ **A pull request carries two outbound surfaces; only one of them is gated.** The prose must be reviewed, but the shipped hook extracts only the `--body` value. Before `gh pr create`, explicitly send the **combined title and body** through `wr-risk-scorer:external-comms` and `wr-voice-tone:external-comms`; the hook then enforces the body review on the command itself. The **diff is not gated by anything**. Nothing in this repo scores a diff against an upstream's policy or contribution standards. Read the diff yourself before opening the pull request; that gap is tracked as P497 and is deliberately not closed by this skill.
58
+
59
+ Two mechanical notes so a re-route is not mistaken for a defect:
60
+
61
+ - The surface label is part of the gate's marker key. A body first reviewed as an issue and then re-routed to a pull request misses its marker and pays a second full review by both evaluators, even though the prose is byte-identical. Expected, not a bug.
62
+ - The gate's surface regex anchors on a command start, a `;`, an `&&` or a `||`. A **piped** invocation (`… | gh pr create`) does not match and the gate silently no-ops. Invoke `gh pr create` in an anchor-matching form.
63
+
57
64
  ## Steps
58
65
 
59
66
  ### 1. Read the local problem ticket
@@ -73,13 +80,18 @@ Extract:
73
80
 
74
81
  If the ticket is not found, halt with a clear error: `Error: local ticket P<NNN> not found in docs/problems/. Did you mean a different ID?`
75
82
 
76
- ### 2. Discover upstream issue templates
83
+ ### 2. Discover upstream metadata, contribution guidance, and issue templates
77
84
 
78
85
  ```bash
79
86
  UPSTREAM_OWNER_REPO=$(echo "$UPSTREAM_URL" | sed -E 's|https?://github.com/([^/]+/[^/]+)(/.*)?|\1|')
87
+ REPO_JSON=$(gh api "repos/${UPSTREAM_OWNER_REPO}")
80
88
  TEMPLATES_JSON=$(gh api "repos/${UPSTREAM_OWNER_REPO}/contents/.github/ISSUE_TEMPLATE" 2>/dev/null)
89
+ CONTRIBUTING_MD=$(gh api "repos/${UPSTREAM_OWNER_REPO}/contents/CONTRIBUTING.md" --jq '.content' 2>/dev/null | base64 -d)
90
+ PR_TEMPLATE_MD=$(gh api "repos/${UPSTREAM_OWNER_REPO}/contents/.github/PULL_REQUEST_TEMPLATE.md" --jq '.content' 2>/dev/null | base64 -d)
81
91
  ```
82
92
 
93
+ If the conventional contribution files are absent, also try `.github/CONTRIBUTING.md`, `.github/pull_request_template.md`, and `docs/PULL_REQUEST_TEMPLATE.md`. A 404 for any optional file means absent; cache successful responses for Steps 4c and 5b. A failure fetching `REPO_JSON` is fatal because its `archived`, `disabled`, and `default_branch` fields govern the route and checkout.
94
+
83
95
  Parse the response:
84
96
  - HTTP 200 with a JSON array → upstream has templates. List the names + types (`.yml` for forms, `.md` for legacy markdown templates).
85
97
  - HTTP 404 → upstream has no `.github/ISSUE_TEMPLATE/` directory; treat as no-templates (proceed to structured default in Step 5).
@@ -120,13 +132,56 @@ The local ticket is **security-classified** if any of:
120
132
  - The ticket body has a `## Security classification` section.
121
133
  - The CLI `--classification security` argument was passed.
122
134
 
123
- If security-classified, route to Step 6. Otherwise, route to Step 4b (dedup check) before Step 5 (public-issue path).
135
+ If security-classified, route to Step 6. Otherwise, route to Step 4b (dedup check), then Step 4c (choose the outbound artefact), then Step 5 or Step 5b.
136
+
137
+ Security classification is checked **first and wins outright**. A security-classified ticket never reaches Step 4c, so it can never be routed to a pull request — the private disclosure path in Step 6 stands exactly as it is.
138
+
139
+ ### 4c. Choose the outbound artefact — pull request or issue (ADR-117)
140
+
141
+ This step is governed by [ADR-117](../../../docs/decisions/117-prefer-an-upstream-pull-request-over-an-issue.proposed.md) (Prefer an upstream pull request over an issue when the upstream accepts pull requests), which adds this branch alongside ADR-024's confirmed reporting contract and ahead of Step 5.
142
+
143
+ **Prefer a pull request. File an issue when one of the four fallbacks below applies.**
144
+
145
+ #### The predicate is "does this upstream accept pull requests"
146
+
147
+ It is **not** "do we have write access". Fork-and-PR is the ordinary way a third party contributes, and it needs no permission we have to negotiate for. Most adopters of this plugin are consumers of their dependencies, not owners of them — a write-access predicate would make this whole branch a no-op for exactly the case the skill was written for.
148
+
149
+ Read the signal from the discovery calls Step 2 makes:
150
+
151
+ - The `repos/<owner>/<repo>` payload's `archived` and `disabled` flags. Either one true → the upstream is not accepting contributions.
152
+ - The presence of `CONTRIBUTING.md`, or of a pull-request template fetched in Step 2. Either is positive evidence that pull requests are wanted.
153
+ - An explicit statement in `CONTRIBUTING.md` that the project does not accept outside contributions.
154
+
155
+ **On an absent or ambiguous signal, default to "accepts".** Do not escalate through a probe ladder to prove acceptance — a refuse-unless-proven predicate is the same failure mode ADR-024 already rejected when it declined to refuse upstreams that ship no issue templates. State which signal was read, or say the signal was absent and the default applied. Do not write "upstream likely accepts pull requests".
156
+
157
+ #### File an issue instead when
158
+
159
+ 1. **The upstream does not accept contributions.** Archived, disabled, or says so.
160
+ 2. **The fix needs a design decision that is the maintainers' to make.** More than one defensible approach, and picking between them is their call, not ours.
161
+ 3. **The ticket is security-classified.** Already routed at Step 4 — this fallback records that the routing exists, and it is Step 6's private path that runs, not this one. Nothing here re-asserts the pre-P270 blanket ban.
162
+ 4. **There is no defensible fix in hand.** A symptom, or even a confident diagnosis, is not a change we can responsibly author in a codebase we do not know.
163
+
164
+ #### Fallback 4 is decided silently — never ask the reporter
165
+
166
+ The reporter describes a symptom. That is the whole of their obligation and this decision must not touch it. **Do not raise an `AskUserQuestion` about whether to write a patch.** The skill already carries two interactive gates (Step 4b dedup, Step 6 missing-`SECURITY.md`); a third would push intake past the couple of minutes beyond which a report simply gets abandoned, and it would be asking the reporter to ratify a judgement the agent is there to make.
167
+
168
+ The preference is a preference. It never converts the reporter into a patch author. The added cost lands on agent time and token budget, not on anyone's attention.
169
+
170
+ #### Draft the issue body first; the pull request is an upgrade of it
171
+
172
+ Compose the Step 5 issue body **before** attempting the pull request, always. Then attempt the pull request. If the attempt fails for any reason — fork refused, patch will not apply, upstream tests red, effort budget spent — **file the already-drafted issue** rather than aborting.
173
+
174
+ Without this, a failed pull-request attempt loses a report that the issue-only path would have filed, which is strictly worse than not having tried.
175
+
176
+ #### Effort budget
177
+
178
+ Spend at most **20 minutes of wall-clock and 3 attempts** on getting a working patch. Past that, fall back to fallback 4 and file the drafted issue. The budget is a real number rather than a judgement call because an open-ended "author a fix in an unfamiliar codebase" is how a loop burns a quota and how an idle guard that measures progress in commits kills a session doing legitimate work.
124
179
 
125
180
  ### 4b. Dedup check (P070)
126
181
 
127
- This step is governed by the [ADR-024](../../../docs/decisions/024-cross-project-problem-reporting-contract.proposed.md) 2026-04-25 amendment, which adds dedup checking to the Decision Outcome step list (P070). Two duplication windows close at the same insertion point: own re-run (4b.1) and third-party search (4b.2). Both branches share the same AskUserQuestion surface and the same AFK halt-and-save behaviour.
182
+ This step is governed by the [ADR-024](../../../docs/decisions/024-cross-project-problem-reporting-contract.proposed.md) 2026-04-25 amendment, which adds dedup checking to the Decision Outcome step list (P070). Two duplication windows close at the same insertion point: own re-run (4b.1) and third-party search (4b.2). Both branches share the same AskUserQuestion surface and AFK risk-gated queue-and-continue behaviour.
128
183
 
129
- > **Serves**: JTBD-004 (cross-repo coordination — dedup is the difference between coordination and spam), JTBD-001 (solo developer "without slowing down" — dedup protects the user from policing upstream duplicates manually), JTBD-006 (AFK persona — halt-and-surface protects loops from duplicate-firing), JTBD-101 (clear pattern — pattern ships without a duplication hole).
184
+ > **Serves**: JTBD-004 (cross-repo coordination — dedup is the difference between coordination and spam), JTBD-001 (solo developer "without slowing down" — dedup protects the user from policing upstream duplicates manually), JTBD-006 (AFK persona — risk-gated queue-and-continue avoids duplicate firing), JTBD-101 (clear pattern — pattern ships without a duplication hole).
130
185
 
131
186
  #### 4b.1. Own re-run check
132
187
 
@@ -134,8 +189,13 @@ Detect whether the local ticket already records a previous upstream report. The
134
189
 
135
190
  ```bash
136
191
  LOCAL_URL=$(grep -A5 '^## Reported Upstream' "$LOCAL_TICKET" | grep -oE 'https?://[^ )]+' | head -1)
192
+ LOCAL_DISCLOSURE=$(grep -A5 '^## Reported Upstream' "$LOCAL_TICKET" | sed -n 's/^- \*\*Disclosure path\*\*: //p' | head -1)
193
+ case "$(printf '%s' "$LOCAL_DISCLOSURE" | tr '[:upper:]' '[:lower:]')" in
194
+ *pull*request*) LOCAL_KIND="pull-request" ;;
195
+ *) LOCAL_KIND="issue" ;;
196
+ esac
137
197
  if [ -n "$LOCAL_URL" ]; then
138
- echo "Local ticket P${LOCAL_ID} already records an existing upstream report: $LOCAL_URL"
198
+ echo "Local ticket P${LOCAL_ID} already records an existing upstream ${LOCAL_KIND}: $LOCAL_URL"
139
199
  # Branch interactive vs AFK below.
140
200
  fi
141
201
  ```
@@ -146,10 +206,10 @@ fi
146
206
  - `multiSelect: false`
147
207
  - Options:
148
208
  1. `Halt — local ticket already records ${LOCAL_URL}` (Recommended) — abort the invocation; the existing report is current.
149
- 2. `Comment on the existing upstream report` — route to Step 5c with the existing URL's issue number; appropriate when new evidence has emerged since the previous report.
209
+ 2. `Comment on the existing upstream report` — route to Step 5c with the existing URL's number and `${LOCAL_KIND}`; appropriate when new evidence has emerged since the previous report.
150
210
  3. `File a new upstream issue anyway (override)` — explicit override after user has reviewed the existing record and judged the second filing warranted (e.g. previous report was closed without resolution and a fresh tracker is needed).
151
211
 
152
- **AFK / non-interactive branch** — apply the **interim static heuristic** (no subagent dispatch; the maintainer-annoyance risk evaluator is deferred per ADR-028 line 117 — see "AFK static heuristic" below). Default action: halt and save the drafted report to the local ticket's `## Queued Upstream Report` section; do NOT auto-comment. The static heuristic remains in place until `wr-risk-scorer:external-comms` ships, at which point the AFK branch wires the gate combination (maintainer-annoyance + leak gate, both within appetite) per the ticket Direction decision (2026-04-21).
212
+ **AFK / non-interactive branch** — apply the external-communications gate in the AFK behaviour summary. Below-appetite prose comments on the existing `${LOCAL_KIND}`; unresolved above-appetite prose is queued and the orchestrator continues.
153
213
 
154
214
  #### 4b.2. Third-party search
155
215
 
@@ -158,26 +218,48 @@ Detect whether a different reporter (or another agent in a parallel session) has
158
218
  ```bash
159
219
  # Stage 1: gh-search pre-filter on title keywords (cheap, ~500ms-2s).
160
220
  KEYWORDS=$(extract_3-5_keywords_from "$LOCAL_TICKET_TITLE + $LOCAL_TICKET_DESCRIPTION")
161
- MATCHES=$(gh issue list \
221
+ ISSUE_MATCHES=$(gh issue list \
222
+ --repo "$UPSTREAM_OWNER_REPO" \
223
+ --state all \
224
+ --search "$KEYWORDS" \
225
+ --json number,title,state,url \
226
+ --limit 10)
227
+
228
+ # ADR-117: `gh issue list` does NOT return pull requests. Now that this skill
229
+ # opens pull requests, searching issues alone leaves the whole pull-request
230
+ # window open — including against pull requests we opened ourselves.
231
+ PR_MATCHES=$(gh pr list \
162
232
  --repo "$UPSTREAM_OWNER_REPO" \
163
233
  --state all \
164
234
  --search "$KEYWORDS" \
165
235
  --json number,title,state,url \
166
236
  --limit 10)
237
+
238
+ MATCHES=$(jq -cn \
239
+ --argjson issues "$ISSUE_MATCHES" \
240
+ --argjson prs "$PR_MATCHES" \
241
+ '($issues | map(. + {kind: "issue"})) +
242
+ ($prs | map(. + {kind: "pull-request"}))')
167
243
  ```
168
244
 
245
+ Carry the artefact kind alongside each candidate. Step 5c needs it to pick between `gh issue comment` and `gh pr comment`, and the user needs it to judge a match — "already fixed in an open pull request" is a different situation from "already reported in an open issue".
246
+
169
247
  For each candidate returned by Stage 1, fetch the full body and run **Stage 2 — inline LLM semantic judgement**:
170
248
 
171
249
  ```bash
172
250
  # Stage 2: per-candidate body fetch + inline classification.
173
- for n in $(echo "$MATCHES" | jq -r '.[].number'); do
174
- CANDIDATE=$(gh issue view "$n" --repo "$UPSTREAM_OWNER_REPO" --json title,body,state,url)
251
+ while IFS=$'\t' read -r kind n; do
252
+ if [ "$kind" = "pull-request" ]; then
253
+ CANDIDATE=$(gh pr view "$n" --repo "$UPSTREAM_OWNER_REPO" --json title,body,state,url)
254
+ else
255
+ CANDIDATE=$(gh issue view "$n" --repo "$UPSTREAM_OWNER_REPO" --json title,body,state,url)
256
+ fi
175
257
  # Inline LLM judgement: read {local ticket Description + Symptoms, candidate title + body}
176
258
  # and return one of: same-problem | different-problem | uncertain.
177
259
  # No subagent dispatch — Direction decision 2026-04-21 pins inline classification
178
260
  # for simplicity. Promotion to a `wr-itil:dedup-check` subagent is a future
179
261
  # ADR amendment if architect review later flags context-isolation concerns.
180
- done
262
+ done < <(echo "$MATCHES" | jq -r '.[] | [.kind, .number] | @tsv')
181
263
  ```
182
264
 
183
265
  Notes on inline LLM classification:
@@ -197,31 +279,23 @@ If Stage 2 produces one or more `same-problem` matches, surface them to the user
197
279
 
198
280
  `uncertain` matches surface alongside `same-problem` matches with their verdict labelled, so the user can review. The skill never auto-resolves an `uncertain` verdict.
199
281
 
200
- **AFK / non-interactive branch** — apply the same interim static heuristic as 4b.1: halt and save the drafted report to the local ticket's `## Queued Upstream Report` section. The third-party-match auto-comment path requires the deferred `wr-risk-scorer:external-comms` gate (maintainer-annoyance + leak), so the AFK branch must NOT auto-comment under the static heuristic.
201
-
202
- #### AFK static heuristic (interim, until `wr-risk-scorer:external-comms` ships)
203
-
204
- The Direction decision (2026-04-21) pins the AFK auto-comment branch on **two gates passing together**: the maintainer-annoyance risk evaluator AND the P064 external-comms leak gate, both within RISK-POLICY.md's commit-layer appetite (Low, ≤4/25). Neither gate exists yet — ADR-028 declares the `wr-risk-scorer:external-comms` subagent type but P064's implementation is open at WSJF 3.0 (Effort L), and the maintainer-annoyance evaluator was deferred by architect review on P070 to compose with the same subagent rather than ship as a separate evaluator (per ADR-028 line 117 — *"Third evaluator (licence-compliance, etc.) adding to the same gate — when it emerges, amend this ADR's evaluator list and the composite marker's `evaluator_set` component; no new ADR expected."*).
282
+ **AFK / non-interactive branch** — apply the external-communications gate in the AFK behaviour summary, preserving the matched artefact kind. Below-appetite prose comments on the matching issue or pull request; unresolved above-appetite prose is queued and the orchestrator continues.
205
283
 
206
- **Static heuristic, valid until both gates ship**: in AFK mode, both 4b.1 and 4b.2 default to **halt and save the drafted report**. No auto-comment, no auto-file. The drafted report is appended to the local ticket's `## Queued Upstream Report` section so the user can review and act manually on return. This matches JTBD-006's "does not trust the agent to make judgement calls" stance — the conservative default is the right interim behaviour.
207
-
208
- **Re-wire trigger**: when `wr-risk-scorer:external-comms` lands (ADR-028 implementation, P064 closure), amend this section to invoke both evaluators and proceed with auto-comment ONLY when both verdicts return PASS within appetite. Update the AFK behaviour summary table accordingly. Until then, the static heuristic stands.
209
-
210
- **Queued Upstream Report save format** (used by both 4b.1 and 4b.2 AFK halts; mirrors the security-path halt pattern from Step 6 per ADR-024 Consequences lines 116, 123. **Renamed from `## Drafted Upstream Report` per ADR-024 2026-06-04 second-amendment ratification (c)** — same shape; new name reflects post-amendment semantics, queue-for-review-on-return not loop-stopping halt-and-save):
284
+ **Queued Upstream Report save format** (used by both 4b.1 and 4b.2 when risk cannot be reduced within appetite):
211
285
 
212
286
  ```markdown
213
287
  ## Queued Upstream Report
214
288
 
215
289
  - **Drafted**: <YYYY-MM-DD>
216
290
  - **Target upstream**: <upstream-repo-url>
217
- - **Halt reason**: dedup match (own re-run | third-party `same-problem`) — interim static heuristic awaiting `wr-risk-scorer:external-comms` (ADR-028 / P064)
291
+ - **Queue reason**: dedup match (own re-run | third-party `same-problem`) — external-communications risk remains above appetite
218
292
  - **Matched URL(s)**: <existing-issue-or-report-URL(s)>
219
293
  - **Drafted body**:
220
294
 
221
- <the body that would have been posted as a `gh issue comment` or `gh issue create`, ready for manual copy-paste review>
295
+ <the body that would have been posted as a comment or new report, ready for review>
222
296
  ```
223
297
 
224
- The halt is a loop-stopping event for AFK orchestrators — same pattern as the security-path halt-and-surface branch — so the user sees the dedup match on return rather than the orchestrator silently auto-commenting.
298
+ Queue an `outstanding_questions` entry and include the queued report in the progress summary; do not halt the orchestrator.
225
299
 
226
300
  ### 5. Public-issue path
227
301
 
@@ -395,25 +469,118 @@ Reported from <downstream-repo-url>/<local-ticket-relative-path>
395
469
  This issue is tracked locally as P<NNN> in the downstream project's `docs/problems/` directory.
396
470
  ```
397
471
 
472
+ Retain the completed issue-shaped body separately so a failed pull-request attempt cannot overwrite its fallback:
473
+
474
+ ```bash
475
+ ISSUE_BODY="${FILLED_BODY}"
476
+ ```
477
+
398
478
  Open the issue:
399
479
 
400
480
  ```bash
401
481
  gh issue create \
402
482
  --repo "${UPSTREAM_OWNER_REPO}" \
403
483
  --title "${TITLE_PREFIXED_BY_TEMPLATE}" \
404
- --body "${FILLED_BODY}"
484
+ --body "${ISSUE_BODY}"
405
485
  ```
406
486
 
407
487
  Do **not** pass `--label` on this call (P207). Labels are supplied by the matched template's YAML `labels:` frontmatter and applied by GitHub when the issue form is submitted; passing `--label <name>` for a label that has not been pre-created on the upstream repo causes `gh issue create` to hard-fail with `could not add label: '<name>' not found`. The flag is redundant when the matched template carries `labels:` and a hard-fail surface when it does not. If the upstream has no matched template at all (structured-default body path, Step 3 preference order item 7), omit labels entirely — leave triage to the upstream maintainer's existing routing.
408
488
 
409
489
  Capture the returned issue URL. The voice-tone gate per ADR-028 may delegate-and-retry; treat this as expected (see "Voice-tone gate interaction" above). Proceed to Step 7 once the issue is created.
410
490
 
491
+ ### 5b. Pull-request path (ADR-117)
492
+
493
+ Reached when Step 4c chose a pull request. The Step 5 issue body is already drafted at this point and is held as the fallback.
494
+
495
+ #### The body defers to the upstream's own template
496
+
497
+ ADR-033's structured default is a **problem report** — Description, Symptoms, Workaround, Affected plugin, Frequency, Versions, Evidence, Cross-reference. That shape is incoherent on a pull request, where the diff already resolves the symptoms it would recite. ADR-033's classifier and its structured default remain unchanged and in force on the issue branch; only this branch differs.
498
+
499
+ 1. Fetch `.github/PULL_REQUEST_TEMPLATE.md` (also try `.github/pull_request_template.md` and `docs/PULL_REQUEST_TEMPLATE.md`). **If one exists, fill it.** This is the same posture as respecting the upstream's curated issue templates, applied to a second artefact kind.
500
+ 2. If none exists, use the reduced shape below — rationale plus cross-reference, not a problem report:
501
+
502
+ ```markdown
503
+ ## What this changes
504
+
505
+ <one paragraph: the change, in the upstream's own vocabulary>
506
+
507
+ ## Why
508
+
509
+ <the problem this fixes, stated as the upstream experiences it — not as our
510
+ ticket experiences it. Link an existing upstream issue if one covers it.>
511
+
512
+ ## Cross-reference
513
+
514
+ Reported from <downstream-repo-url>/<local-ticket-relative-path>, where this
515
+ is tracked as P<NNN>.
516
+ ```
517
+
518
+ Keep it short. A pull request body that recites symptoms the diff already answers reads as noise to a reviewer.
519
+
520
+ Retain this separately as `PR_BODY`; never overwrite `ISSUE_BODY`.
521
+
522
+ #### Show the diff before opening it
523
+
524
+ The external-comms gate forces a turn here to review the prose. Nothing reviews the **patch** — no surface in this repo reads a diff against an upstream's conventions (P497). So make the patch visible at the stop that is already happening, rather than letting it go out unseen:
525
+
526
+ 1. Print the full patch to the session — `git diff` against the upstream's base branch — immediately before `gh pr create`.
527
+ 2. State in the drafted body which of the upstream's own convention files you read and complied with: `CONTRIBUTING.md`, `.github/PULL_REQUEST_TEMPLATE.md`, a linter config, a CI workflow. Name the file. If you read none, say so.
528
+
529
+ This adds no `AskUserQuestion` and does not reopen Step 4c's silent determination. It converts an invisible diff into a visible one at a turn the reviewer is already stopped on, which is the cheapest control available until P497 is closed.
530
+
531
+ #### Build and open it from an isolated upstream checkout
532
+
533
+ Do not author the upstream patch in the downstream project's working tree. Use a temporary clone, create one branch, apply the smallest defensible patch, run the upstream's documented checks, commit it, and push it to a fork (or directly only when write access is already available).
534
+
535
+ ```bash
536
+ UPSTREAM_BASE=$(echo "$REPO_JSON" | jq -r '.default_branch')
537
+ UPSTREAM_CHECKOUT=$(mktemp -d)
538
+ BRANCH="windyroad/p${LOCAL_ID}-$(printf '%s' "$TITLE" | tr '[:upper:] ' '[:lower:]-' | tr -cd '[:alnum:]-' | cut -c1-40)"
539
+
540
+ PR_URL=$(
541
+ gh repo clone "$UPSTREAM_OWNER_REPO" "$UPSTREAM_CHECKOUT/repo" -- --filter=blob:none
542
+ cd "$UPSTREAM_CHECKOUT/repo"
543
+ git switch -c "$BRANCH" "origin/$UPSTREAM_BASE"
544
+
545
+ # Apply the patch, run the upstream's documented checks, then:
546
+ git add --all
547
+ git commit -m "fix: <upstream-facing summary>"
548
+ gh repo fork "$UPSTREAM_OWNER_REPO" --remote --remote-name fork
549
+ git push --set-upstream fork "$BRANCH"
550
+
551
+ GH_USER=$(gh api user --jq .login)
552
+ git diff "origin/$UPSTREAM_BASE...HEAD" >&2
553
+ gh pr create \
554
+ --repo "${UPSTREAM_OWNER_REPO}" \
555
+ --base "${UPSTREAM_BASE}" \
556
+ --head "${GH_USER}:${BRANCH}" \
557
+ --title "${TITLE}" \
558
+ --body "${PR_BODY}"
559
+ )
560
+ ```
561
+
562
+ The command substitution is a subshell, so Step 7 resumes in the downstream checkout. Immediately before `gh pr create`, explicitly review `${TITLE}` plus `${PR_BODY}` together through both external-communications evaluators. Do not rely on the command hook for the title: it extracts the body only.
563
+
564
+ Invoke it at a command start or after `;` / `&&` / `||`, never through a pipe, or the external-comms gate does not match the surface and silently skips the review. Do not pass `--label` for the same reason Step 5 does not (P207).
565
+
566
+ Capture the returned pull-request URL and proceed to Step 7, recording disclosure path `pull request`.
567
+
568
+ If the attempt fails at any point, file the drafted Step 5 issue instead and record `public issue`. A failed pull request must never cost us the report.
569
+
411
570
  ### 5c. Comment path (P070)
412
571
 
413
572
  Used when Step 4b's dedup check (own re-run or third-party search) finds a match AND the user picks the "comment instead" option. Skips `gh issue create` and posts a cross-reference comment on the existing upstream issue:
414
573
 
574
+ **Branch on what the dedup check actually matched (ADR-117).** Step 4b.2 now searches pull requests as well as issues, so the match may be either. `gh issue comment` errors on a pull request:
575
+
415
576
  ```bash
416
- gh issue comment "${EXISTING_ISSUE_NUMBER}" \
577
+ # Issue match:
578
+ gh issue comment "${EXISTING_NUMBER}" \
579
+ --repo "${UPSTREAM_OWNER_REPO}" \
580
+ --body "${COMMENT_BODY}"
581
+
582
+ # Pull-request match:
583
+ gh pr comment "${EXISTING_NUMBER}" \
417
584
  --repo "${UPSTREAM_OWNER_REPO}" \
418
585
  --body "${COMMENT_BODY}"
419
586
  ```
@@ -437,7 +604,7 @@ Empty subsections are skipped — the comment should add information, not restat
437
604
 
438
605
  The voice-tone gate per ADR-028 also fires on `gh issue comment` (per the canonical hook's regex list at ADR-028 line 61); treat the deny-plus-delegate-and-retry as expected, same as Step 5.
439
606
 
440
- Capture the returned comment URL (gh prints `https://github.com/<owner>/<repo>/issues/<n>#issuecomment-<id>`). The Step 7 back-write records this as the cross-reference URL with disclosure path `commented-on-existing-issue`. Proceed to Step 7.
607
+ Capture the returned comment URL. The Step 7 back-write records an issue match as `commented-on-existing-issue` and a pull-request match as `commented-on-existing-pull-request`. Proceed to Step 7.
441
608
 
442
609
  ### 6. Security path
443
610
 
@@ -489,10 +656,12 @@ After the upstream issue or advisory is created (or drafted-and-saved in the sec
489
656
  - **URL**: <upstream-issue-or-advisory-url>
490
657
  - **Reported**: <YYYY-MM-DD>
491
658
  - **Template used**: <template-name-or-"structured default">
492
- - **Disclosure path**: <public issue | security advisory | drafted-and-saved (mailbox / out-of-band) | commented-on-existing-issue (Step 5c, P070)>
659
+ - **Disclosure path**: <public issue | pull request (Step 5b, ADR-117) | security advisory | drafted-and-saved (mailbox / out-of-band) | commented-on-existing-issue (Step 5c, P070) | commented-on-existing-pull-request (Step 5c, ADR-117)>
493
660
  - **Cross-reference confirmed**: <yes/no — true once the upstream issue body contains the local ticket reference>
494
661
  ```
495
662
 
663
+ The `pull request` and `commented-on-existing-pull-request` values are what `/wr-itil:check-upstream-responses` and `/wr-itil:update-upstream` read to pick `gh pr view` and `gh pr comment` over their issue equivalents. Write them exactly; an absent line is read as `public issue`, which is correct for tickets predating ADR-117 but wrong for a pull request.
664
+
496
665
  ### 8. Commit per ADR-014
497
666
 
498
667
  Follow the ADR-014 ordering:
@@ -505,14 +674,15 @@ If the cumulative pipeline risk lands above appetite and `AskUserQuestion` is un
505
674
 
506
675
  ## AFK behaviour summary
507
676
 
508
- Five distinct AFK branches; **per the ADR-024 2026-06-04 (P270) amendment, ALL pre-commit branches now route through the `wr-risk-scorer:external-comms` gate** (ADR-028) — below-appetite proceeds, above-appetite risk-reduces then queues. The legacy "halt the orchestrator" semantics for dedup-match and security-path-without-declared-channel are **superseded** by queue-and-continue per P352:
677
+ Six distinct AFK branches; **per the ADR-024 2026-06-04 (P270) amendment, ALL pre-commit branches now route through the `wr-risk-scorer:external-comms` gate** (ADR-028) — below-appetite proceeds, above-appetite risk-reduces then queues. The legacy "halt the orchestrator" semantics for dedup-match and security-path-without-declared-channel are **superseded** by queue-and-continue per P352:
509
678
 
510
679
  | Branch | AFK behaviour | Authority |
511
680
  |---|---|---|
512
681
  | Public-issue path (Step 5) | Score drafted prose via `wr-risk-scorer:external-comms` (ADR-028). Below-appetite → proceed via `gh issue create`. Above-appetite → risk-reduce + re-score; if within → proceed; else → save draft to `## Queued Upstream Report` + queue `outstanding_questions` entry; orchestrator continues. Risk-reducing measures vocabulary is **open-ended LLM judgement** per ADR-024 2026-06-04 second-amendment leaf (a) — `wr-risk-scorer:external-comms` picks the remedy case-by-case. Voice-tone gate per ADR-028 may also delegate-and-retry on the proceed path. | ADR-024 2026-06-04 amendment (P270); ADR-024 2026-06-04 second-amendment (leaf a — open vocabulary); ADR-028 line 126 |
513
- | Dedup match — Step 4b (own re-run OR third-party `same-problem`) | Score the proposed comment body via `wr-risk-scorer:external-comms`. Below-appetite → proceed via `gh issue comment` (Step 5c). Above-appetite → risk-reduce + re-score (open-ended LLM judgement per leaf (a)); if within → proceed; else → save draft to `## Queued Upstream Report` + queue `outstanding_questions` entry; orchestrator continues. **The 2026-04-25 (P070) "interim static heuristic in force until that subagent ships" deferral is LIFTED** — the subagent (`packages/risk-scorer/agents/external-comms.md`) has shipped. | ADR-024 2026-06-04 amendment (P270); ADR-024 2026-04-25 amendment (P070); ADR-024 2026-06-04 second-amendment (leaf a) |
682
+ | Dedup match — Step 4b (own re-run OR third-party `same-problem`) | Score the proposed comment body via `wr-risk-scorer:external-comms`. Below-appetite → comment on the matching issue or pull request (Step 5c). Above-appetite → risk-reduce + re-score (open-ended LLM judgement per leaf (a)); if within → proceed; else → save draft to `## Queued Upstream Report` + queue `outstanding_questions` entry; orchestrator continues. | ADR-024 2026-06-04 amendment (P270); ADR-024 2026-04-25 amendment (P070); ADR-024 2026-06-04 second-amendment (leaf a) |
514
683
  | Security path with declared channel (Step 6, GitHub Advisories — upstream has `SECURITY.md`) | Per ADR-024 2026-06-04 second-amendment leaf (b) — ratified: if upstream has `SECURITY.md` AND below-appetite → **file** via the SECURITY.md-declared channel. Above-appetite → risk-reduce + re-score (open-ended LLM judgement per leaf (a)); if within → proceed; else → save draft + queue; orchestrator continues. | ADR-024 2026-06-04 amendment (P270); ADR-024 2026-06-04 second-amendment (leaf b); ADR-024 Decision Outcome step 6 |
515
684
  | Security path with `security@` / other / missing-SECURITY.md (Step 6) | Per ADR-024 2026-06-04 second-amendment leaf (b) — ratified: when upstream has NO `SECURITY.md` but another disclosure channel exists, score drafted prose via `wr-risk-scorer:external-comms` considering impact to (i) our repository, (ii) our reputation, (iii) the party we are reporting to. Below-appetite → save drafted report to `## Queued Upstream Report` + queue `outstanding_questions` entry naming the channel the user must follow on return (the no-infra-for-email constraint still holds — the channel-action remains user-side, but the queue surface replaces the loop-stopping halt). Above-appetite → risk-reduce + re-score (open-ended LLM judgement per leaf (a)) then queue per the same shape. Orchestrator continues. The pre-2026-06-04 "AFK orchestrators must never auto-report a security-classified ticket" rule is **superseded** by the external-comms-gated per-classification branching. | ADR-024 2026-06-04 amendment (P270); ADR-024 2026-06-04 second-amendment (leaf b); ADR-024 Consequences lines 116, 123 (superseded) |
685
+ | **Pull-request path (Step 5b)** | **Degrade to the issue branch.** No unattended session opens a pull request against a repository we do not own. File the issue per the Public-issue-path row above, and append the drafted pull request (body + the change it would have made) to `## Queued Upstream Report` for the interactive return. **Also surface it in the orchestrator's progress summary** — a returning developer must see "a pull request was drafted and queued" without opening the ticket. The external-comms gate reads prose; it cannot authorise pushing code into a third party's repository under our name, and that is a judgement call an unattended loop does not get to make. | ADR-117 (AFK degrade); JTBD-006 (no unattended judgement calls; visible on return) |
516
686
  | Above-appetite commit (Step 8) | Skip the commit, report uncommitted state. | ADR-013 Rule 6 |
517
687
 
518
688
  ## References
@@ -521,7 +691,9 @@ Five distinct AFK branches; **per the ADR-024 2026-06-04 (P270) amendment, ALL p
521
691
  - [ADR-033](../../../docs/decisions/033-report-upstream-classifier-problem-first.proposed.md) — partially supersedes ADR-024 Decision Outcome Steps 3 + 5; governs the problem-first classifier and problem-shaped structured default body.
522
692
  - [P070](../../../docs/problems/) — driver ticket for the Step 4b dedup check + Step 5c comment path; carries the 2026-04-21 Direction decision (gh search + inline LLM, no subagent dispatch) and the AFK static-heuristic interim behaviour.
523
693
  - [ADR-027](../../../docs/decisions/027-governance-skill-auto-delegation.proposed.md) — Step-0 deferral rationale (held for reassessment).
524
- - [ADR-028](../../../docs/decisions/028-voice-tone-gate-external-comms.proposed.md) — voice-tone gate on `gh issue create` and `gh api .../security-advisories`.
694
+ - [ADR-117](../../../docs/decisions/117-prefer-an-upstream-pull-request-over-an-issue.proposed.md) — prefer an upstream pull request over an issue when the upstream accepts pull requests. Adds Step 4c (artefact choice) and Step 5b (pull-request path), extends Step 4b.2's dedup search to pull requests, branches Step 5c's comment call, and widens Step 7's disclosure-path enumeration without editing ADR-024 or ADR-033.
695
+ - [P497](../../../docs/problems/open/497-upstream-pull-request-diff-is-unscored.md) — the pull-request **diff** is unscored. The prose is gated; nothing reads the diff against an upstream's policy. Named by ADR-117 and deliberately left open.
696
+ - [ADR-028](../../../docs/decisions/028-voice-tone-gate-external-comms.proposed.md) — voice-tone gate on `gh issue create`, `gh pr create`, `gh issue comment`, `gh pr comment` and `gh api .../security-advisories`.
525
697
  - [ADR-013](../../../docs/decisions/013-structured-user-interaction-for-governance-decisions.proposed.md) — interaction policy; Rule 1 governs Step 6 missing-SECURITY.md `AskUserQuestion`; Rule 6 governs the commit-gate AFK branch.
526
698
  - [ADR-014](../../../docs/decisions/014-governance-skills-commit-their-own-work.proposed.md) — work → score → commit ordering.
527
699
  - [ADR-015](../../../docs/decisions/015-on-demand-assessment-skills.proposed.md) — fallback path for `wr-risk-scorer:assess-release`.
@@ -23,7 +23,7 @@ allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion, Skill, Agen
23
23
 
24
24
  # Update Upstream — Lifecycle-Update Skill
25
25
 
26
- Post a lifecycle-update comment to an upstream issue (or close it) when the local problem ticket transitions. Reads the local ticket's `## Reported Upstream` section, drafts a transition-specific update from the templates below, composes the draft through the external-comms risk gate (`wr-risk-scorer:external-comms`) and voice-tone gate (`wr-voice-tone:external-comms`), auto-posts via `gh issue comment` (or `gh issue close` on Verifying → Closed) when both gates pass within appetite, and queues an `outstanding_questions` entry when either gate scores above appetite.
26
+ Post a lifecycle-update comment to an upstream issue or pull request when the local problem ticket transitions. Reads the local ticket's `## Reported Upstream` section, drafts a transition-specific update from the templates below, composes the draft through the external-comms risk gate (`wr-risk-scorer:external-comms`) and voice-tone gate (`wr-voice-tone:external-comms`), auto-posts via `gh issue comment` or `gh pr comment` when both gates pass within appetite, closes only issue targets on Verifying → Closed, and queues an `outstanding_questions` entry when either gate scores above appetite.
27
27
 
28
28
  This skill is the **reciprocal sibling** to [`/wr-itil:report-upstream`](../report-upstream/SKILL.md) — that skill files the initial upstream report; this skill keeps the upstream record in sync as the local ticket walks its lifecycle. The split is per [ADR-010](../../../docs/decisions/010-rename-wr-problem-to-wr-itil.proposed.md) amended Skill Granularity rule (one skill per distinct user intent) — initial-filing and lifecycle-update are distinct user intents with distinct autocomplete surfaces.
29
29
 
@@ -49,7 +49,7 @@ The single-ticket form is typically invoked from `/wr-itil:transition-problem` S
49
49
  - Determine the local ticket's current Status from the filename suffix.
50
50
  - Draft a transition-specific lifecycle-update comment per the templates below (Open→KE / KE→Verifying / Verifying→Closed).
51
51
  - Compose the drafted prose through `wr-risk-scorer:external-comms` + `wr-voice-tone:external-comms` gates.
52
- - Within appetite → post via `gh issue comment <n>`; on Verifying→Closed also run `gh issue close <n>`.
52
+ - Within appetite → post via `gh issue comment <n>`, or `gh pr comment <n>` when the disclosure path records a pull request (ADR-117); on Verifying→Closed also run `gh issue close <n>` — **but never `gh pr close`** (see below).
53
53
  - Above appetite → AskUserQuestion (interactive) / queue `outstanding_questions` (AFK, per P352 queue-and-continue).
54
54
  - Back-write a `## Upstream Lifecycle Updates` log entry to the local ticket recording the transition, the matched URL, the posted comment URL, and the disclosure path.
55
55
  - **Historical catch-up migration (`--catchup`, P080 Phase 2)** — one-shot retroactive scan of the existing `.verifying.md` + `.closed.md` corpus; posts the lifecycle update each linked-upstream ticket should already carry. Idempotent — re-running is safe. See [§ Catchup migration mode](#catchup-migration-mode-phase-2).
@@ -269,6 +269,14 @@ gh issue close "${UPSTREAM_ISSUE_NUMBER}" \
269
269
 
270
270
  If the issue is already closed upstream (someone else closed it manually), `gh issue close` returns a benign error — capture the existing state and continue to Step 6 with `closed-already-upstream` recorded in the back-write disclosure path.
271
271
 
272
+ #### Pull-request targets never get closed (ADR-117)
273
+
274
+ When the `## Reported Upstream` disclosure path records a **pull request**, post the comment with `gh pr comment` and **stop there**. Do not run `gh pr close`, on this transition or any other.
275
+
276
+ A merged pull request closes itself, so closing is redundant. An unmerged one is still work we authored and offered; closing it is a hostile act against our own contribution and withdraws it from the maintainer's queue without saying so. The local ticket reaching `.closed.md` means *we* consider the problem resolved locally — it does not mean the upstream has finished deciding.
277
+
278
+ Record `posted-pr-comment` in the back-write disclosure path. On a pull-request target `posted-comment-and-closed` is unreachable by construction.
279
+
272
280
  ### 6. Back-write to local ticket
273
281
 
274
282
  Append a log entry to the local ticket's `## Upstream Lifecycle Updates` section (create the section if absent — never inserted mid-document; appended after all existing sections per the same discipline as `## Reported Upstream` in `/wr-itil:report-upstream` Step 7):
@@ -279,7 +287,7 @@ Append a log entry to the local ticket's `## Upstream Lifecycle Updates` section
279
287
  - **<YYYY-MM-DD>** — Open → Known Error
280
288
  - **Target URL**: <upstream-issue-url>
281
289
  - **Comment URL**: <posted-comment-url> (or "queued — see ## Queued Upstream Update" when above-appetite)
282
- - **Disclosure path**: posted-comment | posted-comment-and-closed (Verifying → Closed) | queued-above-appetite | closed-already-upstream | skipped-out-of-band
290
+ - **Disclosure path**: posted-comment | posted-pr-comment (pull-request target, ADR-117 — never closed) | posted-comment-and-closed (Verifying → Closed, issue targets only) | queued-above-appetite | closed-already-upstream | skipped-out-of-band
283
291
  - **Gate verdict**: external-comms <band/score> + voice-tone <pass|fail>
284
292
 
285
293
  - **<YYYY-MM-DD>** — Known Error → Verification Pending
@@ -495,9 +503,9 @@ The append-only log (written by Step 6 on every post) is the source of truth —
495
503
 
496
504
  ### C3. Process each CATCHUP entry
497
505
 
498
- For each `CATCHUP` line, run the **existing per-ticket flow** (Steps 4–6) against that ticket ID:
506
+ For each `CATCHUP` line, parse its `disclosure=` token and run the **existing per-ticket flow** (Steps 4–6) against that ticket ID. `disclosure=pull-request` selects `gh pr comment` and forbids any close command; `disclosure=issue` selects `gh issue comment` and permits `gh issue close` for Verifying → Closed:
499
507
 
500
- 1. Draft the transition template (Step 4) for the entry's transition (`KE->Verifying` → Known Error → Verification Pending template; `Verifying->Closed` → Verification Pending → Closed template, which also runs `gh issue close`).
508
+ 1. Draft the transition template (Step 4) for the entry's transition (`KE->Verifying` → Known Error → Verification Pending template; `Verifying->Closed` → Verification Pending → Closed template). Only an issue disclosure also runs `gh issue close`; a pull-request disclosure never runs `gh pr close`.
501
509
  2. Compose through the external-comms + voice-tone gates (Step 5) — **identical** dual-gate composition as the per-ticket path. Above-appetite handling (Step 5c) is unchanged: silent risk-reduce + re-score, then queue to `## Queued Upstream Update` + `outstanding_questions` (category `deviation-approval`) per P352 if still above. Catchup does NOT bypass the gates.
502
510
  3. Post within appetite (Step 5b final) and back-write the `## Upstream Lifecycle Updates` log (Step 6).
503
511