@windyroad/itil 0.55.3 → 0.55.4-preview.848

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": "0.55.3"
500
+ "version": "0.55.4"
501
501
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/itil",
3
- "version": "0.55.3",
3
+ "version": "0.55.4-preview.848",
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"
@@ -31,14 +31,28 @@
31
31
  # 0 = success (zero or more worklist lines on stdout)
32
32
  # 1 = error (problems-dir missing, malformed CLI args)
33
33
  #
34
+ # Two surfaces are walked (P376 — cross-direction parity):
35
+ # OUTBOUND — tickets with a `## Reported Upstream` section (issues WE filed
36
+ # against an upstream we depend on).
37
+ # INBOUND — tickets with an `**Origin**: inbound-reported (#NN)` field
38
+ # (ADR-076 — issues someone else filed *against us* on our own repo, which
39
+ # the P363 rework made dispatchable). Without this leg the inbound catchup
40
+ # candidates were a manual `grep -lE '^\*\*Origin\*\*:\s*inbound-reported'`
41
+ # surface the maintainer had to remember after every `--catchup` run.
42
+ # A ticket carrying BOTH surfaces emits BOTH an outbound and an inbound line
43
+ # (the two legs are independent, mirroring the update-upstream SKILL).
44
+ #
34
45
  # Structured stdout (one per actionable upstream entry; <= 150 bytes per
35
46
  # line per ADR-038). ASCII `->` for the transition arrow per the P334
36
47
  # awk/script portability lesson (no Unicode in machine-read output):
37
48
  # CATCHUP P<NNN> <url> state=<state> transition=<KE->Verifying|Verifying->Closed>
49
+ # CATCHUP P<NNN> inbound-<ref> state=<state> transition=<…> direction=inbound
38
50
  # SKIP P<NNN> <url> reason=already-logged
51
+ # SKIP P<NNN> inbound-<ref> reason=already-logged
39
52
  # SKIP P<NNN> <url> reason=out-of-band
40
- # Tickets with no `## Reported Upstream` section are skipped silently (the
41
- # common case most tickets were never reported upstream).
53
+ # Tickets with neither surface (and inbound tickets whose Origin ref carries no
54
+ # actionable `#NN`, e.g. `inbound-reported (relayed from other projects)`) are
55
+ # skipped silently — the common case.
42
56
  #
43
57
  # Trailing summary line (stderr) for the SKILL / human reader:
44
58
  # SUMMARY scanned=<N> catchup=<N> skip-logged=<N> skip-out-of-band=<N>
@@ -49,6 +63,9 @@
49
63
  # @adr ADR-038 (progressive disclosure — per-row byte budget)
50
64
  # @adr ADR-049 (invoked via wr-itil-catchup-scan bin shim, never repo-relative path)
51
65
  # @adr ADR-032 (foreground synchronous skill)
66
+ # @adr ADR-076 (reads the `**Origin**: inbound-reported (#NN)` field for the inbound leg)
67
+ # @problem P376 — catchup scanner misses the inbound direction (cross-direction parity)
68
+ # @rfc RFC-028 (consume the Origin field for inbound-reported verdict — extended to the catchup surface)
52
69
  # @jtbd JTBD-301 (reporter feedback loop — the catchup's primary job)
53
70
  # @jtbd JTBD-006 (AFK-safe worklist scanner)
54
71
  # @jtbd JTBD-004 (cross-repo coordination — reconcile local corpus vs upstream trackers)
@@ -148,6 +165,37 @@ extract_ticket_id() {
148
165
  echo "P${base%%-*}"
149
166
  }
150
167
 
168
+ # Extract the actionable inbound issue ref from the `**Origin**:
169
+ # inbound-reported (<ref>)` field (ADR-076). Returns the `#NN` /
170
+ # `<repo>#NN` token, or empty when the Origin is not inbound-reported OR
171
+ # carries no actionable issue number (e.g. "relayed from other projects").
172
+ extract_inbound_ref() {
173
+ local line ref
174
+ line="$(grep -m1 -E '^\*\*Origin\*\*:[[:space:]]*inbound-reported' "$1" 2>/dev/null)"
175
+ [ -z "$line" ] && return 0
176
+ # First parenthesised group after `inbound-reported`.
177
+ ref="$(printf '%s\n' "$line" | sed -n 's/.*inbound-reported[[:space:]]*(\([^)]*\)).*/\1/p')"
178
+ [ -z "$ref" ] && return 0
179
+ # Must contain an actionable `#NN` (optionally repo-qualified). Emit the
180
+ # normalised `<repo>#<num>` token; drop trailing prose / spaces.
181
+ printf '%s\n' "$ref" | grep -oE '[A-Za-z0-9._/-]*#[0-9]+' | head -1
182
+ }
183
+
184
+ # Does the `## Upstream Lifecycle Updates` log already record an
185
+ # `(inbound)`-tagged entry for the target transition? Distinct from
186
+ # log_has_target: the inbound leg's idempotency must NOT be satisfied by an
187
+ # outbound-tagged entry for the same target (a ticket reported BOTH ways may
188
+ # have posted its outbound verdict but not its inbound one).
189
+ log_has_inbound_target() {
190
+ local file="$1" target="$2"
191
+ awk -v target="$target" '
192
+ /^## Upstream Lifecycle Updates/ { in_section = 1; next }
193
+ /^## / && in_section { in_section = 0 }
194
+ in_section && index($0, "(inbound)") > 0 && index($0, target) > 0 { found = 1 }
195
+ END { exit(found ? 0 : 1) }
196
+ ' "$file"
197
+ }
198
+
151
199
  # ── Per-ticket scan loop ────────────────────────────────────────────────────
152
200
 
153
201
  SCANNED=0
@@ -173,14 +221,20 @@ for ticket_file in "${TICKET_FILES[@]}"; do
173
221
  fi
174
222
  SEEN_IDS[$ticket_id]="$ticket_file"
175
223
 
176
- # Filter to tickets carrying a `## Reported Upstream` section.
177
- if ! grep -q '^## Reported Upstream' "$ticket_file"; then
224
+ # Detect both surfaces. Outbound = `## Reported Upstream` section;
225
+ # inbound = an actionable `**Origin**: inbound-reported (#NN)` ref.
226
+ has_outbound=0
227
+ grep -q '^## Reported Upstream' "$ticket_file" && has_outbound=1
228
+ inbound_ref="$(extract_inbound_ref "$ticket_file")"
229
+
230
+ # Neither surface → silent skip (the common case).
231
+ if [ "$has_outbound" -eq 0 ] && [ -z "$inbound_ref" ]; then
178
232
  continue
179
233
  fi
180
234
 
181
235
  SCANNED=$((SCANNED + 1))
182
236
 
183
- # Derive the transition the current suffix implies.
237
+ # Derive the transition the current suffix implies (direction-agnostic).
184
238
  case "$ticket_file" in
185
239
  *.verifying.md|*/verifying/*)
186
240
  state="verifying"
@@ -194,28 +248,40 @@ for ticket_file in "${TICKET_FILES[@]}"; do
194
248
  continue ;;
195
249
  esac
196
250
 
197
- upstream_url="$(extract_upstream_url "$ticket_file")"
198
- disclosure="$(extract_disclosure_path "$ticket_file")"
251
+ # ── Outbound leg (`## Reported Upstream`) ──────────────────────────────────
252
+ if [ "$has_outbound" -eq 1 ]; then
253
+ upstream_url="$(extract_upstream_url "$ticket_file")"
254
+ disclosure="$(extract_disclosure_path "$ticket_file")"
199
255
 
200
- # Out-of-band / non-gh disclosure path, or no actionable URL → SKIP.
201
- if [ -z "$upstream_url" ] \
202
- || [[ "$disclosure" == *out-of-band* ]] \
203
- || [[ "$disclosure" == *mailbox* ]]; then
204
- printf "SKIP %s %s reason=out-of-band\n" "$ticket_id" "${upstream_url:-none}"
205
- SKIP_OUT_OF_BAND=$((SKIP_OUT_OF_BAND + 1))
206
- continue
256
+ if [ -z "$upstream_url" ] \
257
+ || [[ "$disclosure" == *out-of-band* ]] \
258
+ || [[ "$disclosure" == *mailbox* ]]; then
259
+ # Out-of-band / non-gh disclosure path, or no actionable URL → SKIP.
260
+ printf "SKIP %s %s reason=out-of-band\n" "$ticket_id" "${upstream_url:-none}"
261
+ SKIP_OUT_OF_BAND=$((SKIP_OUT_OF_BAND + 1))
262
+ elif log_has_target "$ticket_file" "$log_target"; then
263
+ # Idempotency: the lifecycle log already records this target → SKIP.
264
+ printf "SKIP %s %s reason=already-logged\n" "$ticket_id" "$upstream_url"
265
+ SKIP_LOGGED=$((SKIP_LOGGED + 1))
266
+ else
267
+ printf "CATCHUP %s %s state=%s transition=%s\n" \
268
+ "$ticket_id" "$upstream_url" "$state" "$transition"
269
+ CATCHUP_COUNT=$((CATCHUP_COUNT + 1))
270
+ fi
207
271
  fi
208
272
 
209
- # Idempotency: the lifecycle log already records this target → SKIP.
210
- if log_has_target "$ticket_file" "$log_target"; then
211
- printf "SKIP %s %s reason=already-logged\n" "$ticket_id" "$upstream_url"
212
- SKIP_LOGGED=$((SKIP_LOGGED + 1))
213
- continue
273
+ # ── Inbound leg (`**Origin**: inbound-reported (#NN)`) ─────────────────────
274
+ if [ -n "$inbound_ref" ]; then
275
+ if log_has_inbound_target "$ticket_file" "$log_target"; then
276
+ # Idempotency: an (inbound)-tagged log entry already records this target.
277
+ printf "SKIP %s inbound-%s reason=already-logged\n" "$ticket_id" "$inbound_ref"
278
+ SKIP_LOGGED=$((SKIP_LOGGED + 1))
279
+ else
280
+ printf "CATCHUP %s inbound-%s state=%s transition=%s direction=inbound\n" \
281
+ "$ticket_id" "$inbound_ref" "$state" "$transition"
282
+ CATCHUP_COUNT=$((CATCHUP_COUNT + 1))
283
+ fi
214
284
  fi
215
-
216
- printf "CATCHUP %s %s state=%s transition=%s\n" \
217
- "$ticket_id" "$upstream_url" "$state" "$transition"
218
- CATCHUP_COUNT=$((CATCHUP_COUNT + 1))
219
285
  done
220
286
 
221
287
  printf "SUMMARY scanned=%s catchup=%s skip-logged=%s skip-out-of-band=%s\n" \
@@ -61,6 +61,25 @@ append_lifecycle_log() {
61
61
  } >> "$path"
62
62
  }
63
63
 
64
+ # Write a ticket carrying an inbound `**Origin**: inbound-reported (<ref>)`
65
+ # field (ADR-076) — the INBOUND surface. Args:
66
+ # $1 = filename (relative to $FIX)
67
+ # $2 = origin-paren content (e.g. "#63", "bbstats#195", "relayed from other projects")
68
+ make_inbound_ticket() {
69
+ local path="$FIX/$1" ref="$2"
70
+ mkdir -p "$(dirname "$path")"
71
+ {
72
+ echo "# Problem: inbound fixture"
73
+ echo ""
74
+ echo "**Status**: fixture"
75
+ echo "**Origin**: inbound-reported ($ref)"
76
+ echo ""
77
+ echo "## Description"
78
+ echo ""
79
+ echo "fixture body"
80
+ } > "$path"
81
+ }
82
+
64
83
  @test "catchup-scan: emits CATCHUP for a closed ticket with Reported Upstream and no lifecycle log" {
65
84
  make_reported_ticket "113-foo.closed.md" "https://github.com/o/r/issues/5" "public issue"
66
85
  run bash "$SCRIPT" --problems-dir "$FIX"
@@ -173,3 +192,83 @@ append_lifecycle_log() {
173
192
  [[ "$output" == *"Usage:"* ]]
174
193
  [[ "$output" == *"--catchup"* ]] || [[ "$output" == *"catchup-scan"* ]]
175
194
  }
195
+
196
+ # ── Inbound direction (P376) ────────────────────────────────────────────────
197
+ # The scanner must ALSO enumerate inbound-reported tickets (`**Origin**:
198
+ # inbound-reported (#NN)`, ADR-076) so the maintainer no longer hand-greps
199
+ # them after each catchup run. Direction-tagged so the worklist stays
200
+ # unambiguous; idempotency re-checks the `(inbound)`-tagged lifecycle log.
201
+
202
+ @test "catchup-scan: emits inbound CATCHUP for a verifying ticket with Origin inbound-reported (KE->Verifying)" {
203
+ make_inbound_ticket "220-inbound.verifying.md" "#63"
204
+ run bash "$SCRIPT" --problems-dir "$FIX"
205
+ [ "$status" -eq 0 ]
206
+ [[ "$output" == *"CATCHUP P220 inbound-#63 state=verifying transition=KE->Verifying direction=inbound"* ]]
207
+ }
208
+
209
+ @test "catchup-scan: emits inbound CATCHUP for a closed ticket with Origin inbound-reported (Verifying->Closed)" {
210
+ make_inbound_ticket "211-inbound.closed.md" "#97"
211
+ run bash "$SCRIPT" --problems-dir "$FIX"
212
+ [ "$status" -eq 0 ]
213
+ [[ "$output" == *"CATCHUP P211 inbound-#97 state=closed transition=Verifying->Closed direction=inbound"* ]]
214
+ }
215
+
216
+ @test "catchup-scan: inbound idempotency — SKIP/already-logged when an (inbound)-tagged log records the target" {
217
+ make_inbound_ticket "220-inbound.verifying.md" "#63"
218
+ append_lifecycle_log "220-inbound.verifying.md" "Known Error → Verification Pending (inbound)"
219
+ run bash "$SCRIPT" --problems-dir "$FIX"
220
+ [ "$status" -eq 0 ]
221
+ [[ "$output" == *"SKIP P220 inbound-#63 reason=already-logged"* ]]
222
+ [[ "$output" != *"CATCHUP P220"* ]]
223
+ }
224
+
225
+ @test "catchup-scan: inbound idempotency does NOT match an outbound-tagged log for the same target" {
226
+ # A Closed log entry without (inbound) is the outbound leg's marker; the
227
+ # inbound leg must still emit CATCHUP (its own verdict was never posted).
228
+ make_inbound_ticket "228-inbound.closed.md" "#42"
229
+ append_lifecycle_log "228-inbound.closed.md" "Verification Pending → Closed"
230
+ run bash "$SCRIPT" --problems-dir "$FIX"
231
+ [ "$status" -eq 0 ]
232
+ [[ "$output" == *"CATCHUP P228 inbound-#42 state=closed transition=Verifying->Closed direction=inbound"* ]]
233
+ }
234
+
235
+ @test "catchup-scan: non-actionable Origin (no #NN) emits no inbound line" {
236
+ make_inbound_ticket "300-relayed.verifying.md" "relayed from other projects"
237
+ run bash "$SCRIPT" --problems-dir "$FIX"
238
+ [ "$status" -eq 0 ]
239
+ [[ "$output" != *"P300"* ]]
240
+ }
241
+
242
+ @test "catchup-scan: cross-repo inbound ref is preserved in the worklist line" {
243
+ make_inbound_ticket "250-xrepo.closed.md" "bbstats#195"
244
+ run bash "$SCRIPT" --problems-dir "$FIX"
245
+ [ "$status" -eq 0 ]
246
+ [[ "$output" == *"CATCHUP P250 inbound-bbstats#195 state=closed transition=Verifying->Closed direction=inbound"* ]]
247
+ }
248
+
249
+ @test "catchup-scan: a ticket with BOTH surfaces emits an outbound AND an inbound line" {
250
+ make_reported_ticket "330-both.closed.md" "https://github.com/o/r/issues/5" "public issue"
251
+ printf '**Origin**: inbound-reported (#71)\n' >> "$FIX/330-both.closed.md"
252
+ run bash "$SCRIPT" --problems-dir "$FIX"
253
+ [ "$status" -eq 0 ]
254
+ [[ "$output" == *"CATCHUP P330 https://github.com/o/r/issues/5 state=closed transition=Verifying->Closed"* ]]
255
+ [[ "$output" == *"CATCHUP P330 inbound-#71 state=closed transition=Verifying->Closed direction=inbound"* ]]
256
+ }
257
+
258
+ @test "catchup-scan: inbound worklist line stays within the ADR-038 150-byte budget" {
259
+ make_inbound_ticket "999-long.closed.md" "averylongupstreamreponame/withdeepsubpath#123456"
260
+ run bash "$SCRIPT" --problems-dir "$FIX"
261
+ [ "$status" -eq 0 ]
262
+ line="$(printf '%s\n' "$output" | grep '^CATCHUP P999')"
263
+ [ -n "$line" ]
264
+ [ "${#line}" -le 150 ]
265
+ }
266
+
267
+ @test "catchup-scan: inbound-only Open/Known-Error tickets are out of the catchup corpus" {
268
+ make_inbound_ticket "400-open.open.md" "#12"
269
+ make_inbound_ticket "401-ke.known-error.md" "#13"
270
+ run bash "$SCRIPT" --problems-dir "$FIX"
271
+ [ "$status" -eq 0 ]
272
+ [[ "$output" != *"P400"* ]]
273
+ [[ "$output" != *"P401"* ]]
274
+ }