@ianwremmel/dispatch 0.32.1-bootstrap.0

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.
Files changed (165) hide show
  1. package/.claude-plugin/plugin.json +59 -0
  2. package/.mcp.json +8 -0
  3. package/LICENSE +21 -0
  4. package/README.md +93 -0
  5. package/agents/.gitkeep +0 -0
  6. package/agents/build-graph.md +99 -0
  7. package/agents/milestone-reviewer.md +50 -0
  8. package/agents/pr-worker.md +172 -0
  9. package/agents/ticket-worker.md +97 -0
  10. package/bin/dispatch +101 -0
  11. package/bin/dispatch-mcp +19 -0
  12. package/bin/pr-status +931 -0
  13. package/commands/.gitkeep +0 -0
  14. package/commands/orchestrate.md +6 -0
  15. package/hooks/.gitkeep +0 -0
  16. package/hooks/claim-guard.mts +98 -0
  17. package/hooks/hooks.json +15 -0
  18. package/package.json +46 -0
  19. package/skills/.gitkeep +0 -0
  20. package/skills/land/SKILL.md +238 -0
  21. package/skills/land/credentials-dedicated.md +33 -0
  22. package/skills/land/credentials-shared.md +76 -0
  23. package/skills/land/mode-solo.md +76 -0
  24. package/skills/land/mode-team.md +103 -0
  25. package/skills/land/reference.md +152 -0
  26. package/skills/land/ticket.md +94 -0
  27. package/skills/orchestrate/SKILL.md +87 -0
  28. package/skills/tracker-adapter-linear/SKILL.md +142 -0
  29. package/src/commands/CLAUDE.md +12 -0
  30. package/src/commands/claim/check.mts +88 -0
  31. package/src/commands/claim/guard.mts +95 -0
  32. package/src/commands/claim/status.mts +49 -0
  33. package/src/commands/edge/add.mts +44 -0
  34. package/src/commands/edge/rm.mts +44 -0
  35. package/src/commands/edge/set.mts +56 -0
  36. package/src/commands/greet.mts +34 -0
  37. package/src/commands/mcp/ack.mts +43 -0
  38. package/src/commands/mcp/ping.mts +61 -0
  39. package/src/commands/mcp/status.mts +89 -0
  40. package/src/commands/mcp.mts +155 -0
  41. package/src/commands/milestone/rm.mts +35 -0
  42. package/src/commands/milestone/set.mts +49 -0
  43. package/src/commands/outcome/rm.mts +36 -0
  44. package/src/commands/outcome/set.mts +86 -0
  45. package/src/commands/pr/rm.mts +33 -0
  46. package/src/commands/pr/set.mts +110 -0
  47. package/src/commands/pr/yield.mts +114 -0
  48. package/src/commands/project/rm.mts +33 -0
  49. package/src/commands/project/set.mts +50 -0
  50. package/src/commands/queue.mts +41 -0
  51. package/src/commands/refresh/done.mts +42 -0
  52. package/src/commands/refresh/status.mts +40 -0
  53. package/src/commands/refresh.mts +56 -0
  54. package/src/commands/review/record.mts +46 -0
  55. package/src/commands/review/release.mts +49 -0
  56. package/src/commands/status.mts +85 -0
  57. package/src/commands/ticket/missing.mts +31 -0
  58. package/src/commands/ticket/rm.mts +33 -0
  59. package/src/commands/ticket/set.mts +134 -0
  60. package/src/commands/worker/rm.mts +46 -0
  61. package/src/commands/worker/set.mts +63 -0
  62. package/src/lib/cli/CLAUDE.md +13 -0
  63. package/src/lib/cli/cli.mts +226 -0
  64. package/src/lib/cli/index.mts +1 -0
  65. package/src/lib/command/CLAUDE.md +26 -0
  66. package/src/lib/command/__fixtures__/bad-export/oops.mts +1 -0
  67. package/src/lib/command/__fixtures__/bad-name/mismatch.mts +19 -0
  68. package/src/lib/command/__fixtures__/commands/cli-only.mts +20 -0
  69. package/src/lib/command/__fixtures__/commands/greet.mts +39 -0
  70. package/src/lib/command/__fixtures__/commands/math/add.mts +32 -0
  71. package/src/lib/command/__fixtures__/commands/mcp-only.mts +20 -0
  72. package/src/lib/command/__fixtures__/commands/needs-token.mts +19 -0
  73. package/src/lib/command/__fixtures__/commands/store/get.mts +26 -0
  74. package/src/lib/command/__fixtures__/commands/store.mts +26 -0
  75. package/src/lib/command/abstract-command.mts +104 -0
  76. package/src/lib/command/discovery.mts +100 -0
  77. package/src/lib/command/env.mts +19 -0
  78. package/src/lib/command/index.mts +6 -0
  79. package/src/lib/command/parse.mts +64 -0
  80. package/src/lib/command/test-support.mts +81 -0
  81. package/src/lib/command/transports.mts +17 -0
  82. package/src/lib/command/types.mts +53 -0
  83. package/src/lib/db/CLAUDE.md +13 -0
  84. package/src/lib/db/database.mts +160 -0
  85. package/src/lib/db/index.mts +4 -0
  86. package/src/lib/db/schema.mts +195 -0
  87. package/src/lib/db/time.mts +24 -0
  88. package/src/lib/db/with-database.mts +56 -0
  89. package/src/lib/errors/CLAUDE.md +18 -0
  90. package/src/lib/errors/command-error.mts +13 -0
  91. package/src/lib/errors/data-error.mts +12 -0
  92. package/src/lib/errors/definition-error.mts +6 -0
  93. package/src/lib/errors/dispatch-error.mts +27 -0
  94. package/src/lib/errors/ensure.mts +22 -0
  95. package/src/lib/errors/environment-error.mts +7 -0
  96. package/src/lib/errors/index.mts +8 -0
  97. package/src/lib/errors/json-rpc-error.mts +18 -0
  98. package/src/lib/errors/usage-error.mts +7 -0
  99. package/src/lib/graph/CLAUDE.md +17 -0
  100. package/src/lib/graph/anomalies.mts +110 -0
  101. package/src/lib/graph/derive.mts +96 -0
  102. package/src/lib/graph/index.mts +26 -0
  103. package/src/lib/graph/pipeline.mts +410 -0
  104. package/src/lib/graph/queries.mts +207 -0
  105. package/src/lib/graph/rows.mts +99 -0
  106. package/src/lib/graph/types.mts +137 -0
  107. package/src/lib/liveness/CLAUDE.md +14 -0
  108. package/src/lib/liveness/index.mts +10 -0
  109. package/src/lib/liveness/liveness.mts +147 -0
  110. package/src/lib/liveness/retire.mts +63 -0
  111. package/src/lib/logger/CLAUDE.md +12 -0
  112. package/src/lib/logger/index.mts +2 -0
  113. package/src/lib/logger/logger.mts +58 -0
  114. package/src/lib/logger/stream-sink.mts +23 -0
  115. package/src/lib/mcp/CLAUDE.md +21 -0
  116. package/src/lib/mcp/channel.mts +41 -0
  117. package/src/lib/mcp/dispatch.mts +60 -0
  118. package/src/lib/mcp/drain.mts +83 -0
  119. package/src/lib/mcp/index.mts +5 -0
  120. package/src/lib/mcp/mcp.mts +267 -0
  121. package/src/lib/mcp/tools.mts +77 -0
  122. package/src/lib/model/CLAUDE.md +8 -0
  123. package/src/lib/model/index.mts +3 -0
  124. package/src/lib/model/repo-caps.mts +95 -0
  125. package/src/lib/model/status.mts +91 -0
  126. package/src/lib/model/types.mts +83 -0
  127. package/src/lib/refresh/index.mts +2 -0
  128. package/src/lib/refresh/placeholders.mts +43 -0
  129. package/src/lib/refresh/refresh-service.mts +203 -0
  130. package/src/lib/schedule/CLAUDE.md +18 -0
  131. package/src/lib/schedule/caps.mts +113 -0
  132. package/src/lib/schedule/correlate.mts +69 -0
  133. package/src/lib/schedule/index.mts +7 -0
  134. package/src/lib/schedule/scheduler.mts +355 -0
  135. package/src/lib/schedule/tick.mts +266 -0
  136. package/src/lib/stores/CLAUDE.md +24 -0
  137. package/src/lib/stores/coordination.mts +359 -0
  138. package/src/lib/stores/cursor.mts +41 -0
  139. package/src/lib/stores/edge.mts +138 -0
  140. package/src/lib/stores/fetch-request.mts +346 -0
  141. package/src/lib/stores/index.mts +19 -0
  142. package/src/lib/stores/materialize.mts +69 -0
  143. package/src/lib/stores/milestone.mts +74 -0
  144. package/src/lib/stores/notice.mts +57 -0
  145. package/src/lib/stores/policy.mts +48 -0
  146. package/src/lib/stores/pr-event.mts +94 -0
  147. package/src/lib/stores/pr.mts +167 -0
  148. package/src/lib/stores/project.mts +79 -0
  149. package/src/lib/stores/refresh.mts +197 -0
  150. package/src/lib/stores/review.mts +113 -0
  151. package/src/lib/stores/session.mts +170 -0
  152. package/src/lib/stores/ticket.mts +246 -0
  153. package/src/lib/stores/watch.mts +360 -0
  154. package/src/lib/stores/worker.mts +121 -0
  155. package/src/lib/watch/adopt.mts +151 -0
  156. package/src/lib/watch/arm.mts +48 -0
  157. package/src/lib/watch/cadence.mts +45 -0
  158. package/src/lib/watch/diff.mts +274 -0
  159. package/src/lib/watch/index.mts +11 -0
  160. package/src/lib/watch/marker.mts +24 -0
  161. package/src/lib/watch/payload.mts +56 -0
  162. package/src/lib/watch/poll.mts +87 -0
  163. package/src/lib/watch/render.mts +61 -0
  164. package/src/lib/watch/snapshot.mts +312 -0
  165. package/src/main.mts +18 -0
package/bin/pr-status ADDED
@@ -0,0 +1,931 @@
1
+ #!/usr/bin/env bash
2
+ # pr-status — emit PR state XML per §2.2.2 PR Status Protocol.
3
+ #
4
+ # Usage: pr-status <pr>
5
+ #
6
+ # Config comes from the plugin's userConfig. The harness exports
7
+ # CLAUDE_PLUGIN_OPTION_* to hook processes only — commands the model runs via
8
+ # the Bash tool never receive them — so when
9
+ # CLAUDE_PLUGIN_OPTION_OPERATOR_LOGIN is absent from the environment, the
10
+ # script reads the option from Claude Code's own settings files (managed →
11
+ # project local → project → user; first match wins). operator_login is the
12
+ # only required option; the script errors, pointing at the plugin config, if
13
+ # it can't be resolved either way.
14
+ #
15
+ # Requires: gh + jq (hard — the script exits if either is missing). git and
16
+ # claude are optional: content_present (terminal resolution) and summarize
17
+ # degrade gracefully without them. Run from inside the repo's git worktree.
18
+
19
+ set -euo pipefail
20
+
21
+ PR=""
22
+ REPO_ARG=""
23
+ while [[ $# -gt 0 ]]; do
24
+ case "$1" in
25
+ --repo) REPO_ARG="${2:-}"; shift 2 ;;
26
+ --repo=*) REPO_ARG="${1#--repo=}"; shift ;;
27
+ *) PR="$1"; shift ;;
28
+ esac
29
+ done
30
+ [[ -n "$PR" ]] || { echo "usage: pr-status [--repo <owner>/<name>] <pr>" >&2; exit 2; }
31
+
32
+ # The repository the PR belongs to. Without this the script can only read a PR
33
+ # in the repo it is standing in, so a server watching PRs across repos — or
34
+ # running outside a worktree at all — cannot produce a payload. `gh` reads
35
+ # GH_REPO, so resolving it once here reaches every call below.
36
+ #
37
+ # A URL is always decomposed, whether or not --repo was also given: leaving PR
38
+ # as a URL puts the URL in the emitted pr="" attribute, and a --repo that
39
+ # disagrees with it would label the output with one repo while reporting
40
+ # another's data. Disagreement is a caller error, not something to resolve by
41
+ # precedence.
42
+ if [[ "$PR" =~ ^https?://[^/]+/([^/]+)/([^/]+)/pull/([0-9]+) ]]; then
43
+ _url_repo="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}"
44
+ PR="${BASH_REMATCH[3]}"
45
+ if [[ -n "$REPO_ARG" && "$REPO_ARG" != "$_url_repo" ]]; then
46
+ echo "pr-status: --repo $REPO_ARG contradicts the URL, which names $_url_repo" >&2
47
+ exit 2
48
+ fi
49
+ REPO_ARG="$_url_repo"
50
+ fi
51
+ [[ -n "$REPO_ARG" ]] && export GH_REPO="$REPO_ARG"
52
+
53
+ # Required tools: the script cannot emit valid XML without gh + jq. Fail loudly
54
+ # up front rather than partway through with a cryptic error. (git and claude
55
+ # degrade gracefully in content_present/summarize, so they are not hard-required.)
56
+ for _tool in gh jq; do
57
+ command -v "$_tool" >/dev/null 2>&1 \
58
+ || { echo "pr-status: required tool not found on PATH: $_tool" >&2; exit 3; }
59
+ done
60
+
61
+ # claude is optional — without it items are emitted without a <summary>. Resolve
62
+ # it once here so the absence is reported once, not once per item.
63
+ HAVE_CLAUDE=true
64
+ if ! command -v claude >/dev/null 2>&1; then
65
+ HAVE_CLAUDE=false
66
+ echo "pr-status: claude not found on PATH; items will be emitted without summaries" >&2
67
+ fi
68
+
69
+ # Required plugin option (§2.2.2 Operator identity). The env var wins when set
70
+ # (hook processes get it injected; a caller may also export it). Otherwise read
71
+ # it from the settings files Claude Code itself reads, in Claude Code's
72
+ # precedence order. The plugin key is matched as `dispatch` or `dispatch@<any
73
+ # marketplace>` — the marketplace name is chosen at install time, so it can't
74
+ # be hardcoded. If nothing resolves, the operator hasn't configured the
75
+ # dispatch plugin — surface that (the agent must NOT invent a login or fall
76
+ # back to the ticket assigner; it's the operator's to set).
77
+ resolve_operator_login() {
78
+ if [[ -n "${CLAUDE_PLUGIN_OPTION_OPERATOR_LOGIN:-}" ]]; then
79
+ printf '%s' "$CLAUDE_PLUGIN_OPTION_OPERATOR_LOGIN"
80
+ return 0
81
+ fi
82
+ # CLAUDE_PROJECT_DIR is only injected into hook processes; fall back to the
83
+ # enclosing git worktree, or the current directory outside one (the script
84
+ # requires a worktree anyway and fails later in gh if there isn't one).
85
+ local project_root
86
+ project_root="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
87
+ # ${HOME:-} keeps `set -u` from aborting the whole lookup when HOME is unset;
88
+ # the resulting /.claude/... path simply fails the -f test.
89
+ local -a settings_files=(
90
+ '/etc/claude-code/managed-settings.json'
91
+ '/Library/Application Support/ClaudeCode/managed-settings.json'
92
+ "$project_root/.claude/settings.local.json"
93
+ "$project_root/.claude/settings.json"
94
+ "${HOME:-}/.claude/settings.json"
95
+ )
96
+ local f login
97
+ for f in "${settings_files[@]}"; do
98
+ [[ -f "$f" ]] || continue
99
+ # A file that exists but doesn't parse is warned about and skipped rather
100
+ # than treated as "no login found" silently — otherwise a corrupt
101
+ # higher-precedence file invisibly hands the answer to a lower one.
102
+ if ! login="$(jq -r '
103
+ [ (.pluginConfigs // {})
104
+ | to_entries[]
105
+ | select(.key == "dispatch" or (.key | startswith("dispatch@")))
106
+ | .value.options.operator_login // empty
107
+ | select(. != "")
108
+ ] | first // empty
109
+ ' "$f" 2>/dev/null)"; then
110
+ echo "pr-status: warning: could not parse $f; skipping it for operator_login resolution" >&2
111
+ continue
112
+ fi
113
+ if [[ -n "$login" ]]; then
114
+ printf '%s' "$login"
115
+ return 0
116
+ fi
117
+ done
118
+ return 1
119
+ }
120
+
121
+ OPERATOR_LOGIN="$(resolve_operator_login)" || {
122
+ echo "pr-status: the dispatch plugin option operator_login is not set; the operator must configure it in the dispatch plugin config (or export CLAUDE_PLUGIN_OPTION_OPERATOR_LOGIN) — pr-status cannot classify reviews without it" >&2
123
+ exit 3
124
+ }
125
+
126
+ OPERATOR_LC="$(printf '%s' "$OPERATOR_LOGIN" | tr '[:upper:]' '[:lower:]')"
127
+
128
+ REPO="${REPO_ARG:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}"
129
+ SLUG="${REPO/\//__}"
130
+ BASE="${DISPATCH_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/dispatch}"
131
+ DIR="$BASE/deliver/$SLUG/$PR"
132
+ mkdir -p "$DIR/comments" "$DIR/threads" "$DIR/annotations" "$DIR/reviews"
133
+
134
+ # A run killed mid-summary leaves its staging files behind. Nothing reads them —
135
+ # only <id>.summary.md is — but they accumulate. A day is far longer than any
136
+ # `claude -p` call, which has no timeout of its own, so sweeping at that age
137
+ # cannot take a staging file a live run still owns.
138
+ find "$DIR" -type f \( -name '*.summary.md.out.*' -o -name '*.summary.md.err.*' \) \
139
+ -mmin +1440 -delete 2>/dev/null || true
140
+
141
+ INFORMATIONAL_RE="${DISPATCH_INFORMATIONAL_CHECKS:-}" # e.g. "^(coverage|codeql)$"
142
+ STUCK_AFTER_SEC="${DISPATCH_STUCK_AFTER_SEC:-3600}"
143
+
144
+ # Per reference.md → Terminal signals: the text tokens must be the *last
145
+ # non-empty line* of the body. Canonical tokens are `Done.`, `Declined.`,
146
+ # `Shipped.`; `✓`/`✅` are accepted as inline reaction-equivalents; the
147
+ # remaining legacy tokens (`acknowledged`, etc.) preserve pre-existing
148
+ # behavior. Match is case-insensitive and tolerant of an optional trailing
149
+ # period.
150
+ TERMINAL_RE='^[[:space:]]*(✓|✅|done\.?|declined\.?|shipped\.?|acknowledged\.?|wontfix\.?|dismissed\.?|resolved\.?)[[:space:]]*$'
151
+
152
+ # gh-authenticated identity — the agent's own login. classify_actionable uses it
153
+ # (author match + marker) to recognize the calling agent's own plan/engagement/
154
+ # terminal-tagged artifacts and suppress them. It's the only identity source, so
155
+ # a failure here is fatal — without it every self-authored item stays actionable.
156
+ CALLER_LOGIN="$(gh api user --jq .login 2>/dev/null || true)"
157
+ [[ -n "$CALLER_LOGIN" ]] || { echo "pr-status: could not resolve the authenticated GitHub user (gh api user failed); run 'gh auth status' — cannot proceed" >&2; exit 3; }
158
+
159
+ xml_attr() { printf '%s' "$1" | sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g' -e 's/"/\&quot;/g'; }
160
+ xml_text() { printf '%s' "$1" | sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g'; }
161
+
162
+ # --- summary helper -----------------------------------------------------------
163
+ # Args: <body-file> <out-file>
164
+ #
165
+ # Writes <out-file> only when the call succeeds and produced a real summary, and
166
+ # returns non-zero otherwise. The emitters read the file's existence as "already
167
+ # summarized", so anything written on failure is never retried and latches for
168
+ # the life of the cache — a missing summary must stay missing. Nested `claude -p`
169
+ # calls intermittently answer with Claude Code's own "Not logged in" banner on a
170
+ # zero exit, so a plausible-looking body is checked too. Diagnostics go to
171
+ # stderr; stdout carries the XML payload.
172
+ #
173
+ # The output is staged in a sibling temp file and moved into place, so a
174
+ # concurrent pr-status over the same PR never reads a half-written summary.
175
+ summarize() {
176
+ local body="$1" out="$2"
177
+ [[ "$HAVE_CLAUDE" == true ]] || return 1
178
+
179
+ # Every caller guards this function with `|| true`, which disables errexit for
180
+ # its whole body — so each step that can fail is checked by hand here.
181
+ local tmp_out tmp_err rc=0
182
+ tmp_out="$(mktemp "$out.out.XXXXXX")" || {
183
+ echo "pr-status: could not create a temp file beside $out; skipping its summary" >&2
184
+ return 1
185
+ }
186
+ tmp_err="$(mktemp "$out.err.XXXXXX")" || {
187
+ echo "pr-status: could not create a temp file beside $out; skipping its summary" >&2
188
+ rm -f "$tmp_out"
189
+ return 1
190
+ }
191
+
192
+ claude -p --max-turns 1 \
193
+ "Summarize the following PR item in 1-3 sentences describing its outcome. Plain prose only.
194
+
195
+ $(cat "$body")" > "$tmp_out" 2>"$tmp_err" </dev/null || rc=$?
196
+
197
+ local text; text="$(cat "$tmp_out")" || text=""
198
+ local first_line; first_line="$(grep -m1 -v '^[[:space:]]*$' <<<"$text")" || first_line=""
199
+ local reason=""
200
+ if (( rc != 0 )); then
201
+ reason="claude exited $rc"
202
+ elif [[ -z "${text//[[:space:]]/}" ]]; then
203
+ reason="claude produced no output"
204
+ elif grep -qiE '^[[:space:]]*(not logged in|please run /login|invalid api key|authentication failed)' \
205
+ <<<"$first_line"; then
206
+ # Claude Code generates these banners locally and exits zero, so the exit
207
+ # code cannot tell them from a summary. Matching only the opening line keeps
208
+ # a recap that discusses an auth bug — which mentions the same words, but
209
+ # further in — from being thrown away, while still catching a banner that
210
+ # runs long or spans lines.
211
+ reason="claude answered with an auth error instead of a summary"
212
+ elif ! mv "$tmp_out" "$out"; then
213
+ reason="the summary could not be moved into place"
214
+ fi
215
+
216
+ if [[ -z "$reason" ]]; then
217
+ rm -f "$tmp_err"
218
+ return 0
219
+ fi
220
+
221
+ echo "pr-status: could not summarize $body ($reason); leaving it unsummarized so a later run retries" >&2
222
+ [[ -s "$tmp_err" ]] && sed 's/^/pr-status: /' "$tmp_err" >&2
223
+ rm -f "$tmp_out" "$tmp_err"
224
+ return 1
225
+ }
226
+
227
+ # Args: <summary-file>
228
+ # A summary file that is empty, or that holds the literal `(summary
229
+ # unavailable)`, records a failure rather than a recap. It still satisfies the
230
+ # existence check that gates regeneration, so evict it and let this run write a
231
+ # real one.
232
+ evict_placeholder_summary() {
233
+ local sum="$1"
234
+ [[ -f "$sum" ]] || return 0
235
+ if [[ ! -s "$sum" ]] || [[ "$(cat "$sum")" == "(summary unavailable)" ]]; then
236
+ # An unremovable file is this item's problem, not the payload's: swallow the
237
+ # failure so the run keeps emitting rather than dying mid-section.
238
+ rm -f "$sum" || true
239
+ fi
240
+ }
241
+
242
+ # Args: <id> <subdir> <body>
243
+ # Writes <id>.md when the body changes. The summary is intentionally NOT deleted
244
+ # on change: a summary, once generated for a settled (non-actionable) item, is a
245
+ # recap the agent reads alongside the new content when the item later flips back
246
+ # to actionable (a reviewer reply) — instead of re-reading the whole thread. It
247
+ # is generated lazily (see the per-section emitters: only when an item is
248
+ # non-actionable and no summary exists yet) and then persists.
249
+ cache_item() {
250
+ local id="$1" sub="$2" body="$3"
251
+ local md="$DIR/$sub/$id.md"
252
+ local hash_file="$DIR/$sub/$id.hash"
253
+ evict_placeholder_summary "$DIR/$sub/$id.summary.md"
254
+ local new_hash; new_hash="$(printf '%s' "$body" | sha256sum | cut -d' ' -f1)"
255
+ local old_hash=""; [[ -f "$hash_file" ]] && old_hash="$(cat "$hash_file")"
256
+ if [[ ! -f "$md" || "$new_hash" != "$old_hash" ]]; then
257
+ printf '%s' "$body" > "$md"
258
+ printf '%s' "$new_hash" > "$hash_file"
259
+ fi
260
+ }
261
+
262
+ # --- fetch --------------------------------------------------------------------
263
+ PR_JSON="$(gh pr view "$PR" --json \
264
+ number,headRefName,headRefOid,baseRefName,state,mergedAt,mergeable,reviewDecision,isDraft,statusCheckRollup)"
265
+
266
+ HEAD="$(jq -r .headRefName <<<"$PR_JSON")"
267
+
268
+ # reviewThreads and top-level comments aren't both exposed with the fields we
269
+ # need by `gh pr view --json` (in particular it omits comment reactions, which
270
+ # Gate 6 needs), so fetch them via GraphQL.
271
+ # Use the numeric PR number from PR_JSON since `$PR` may have been passed as
272
+ # a URL or branch name (both accepted by `gh pr view`).
273
+ #
274
+ # `state` on the thread comments is load-bearing. A review still being drafted
275
+ # has PENDING comments, visible only to their author — so when the agent shares
276
+ # the operator's credentials they come back on this query and would otherwise
277
+ # read as ordinary actionable threads, putting the agent to work on feedback
278
+ # nobody has submitted. They are dropped per comment rather than per thread: a
279
+ # draft reply on an existing thread must hide the reply without also hiding the
280
+ # submitted feedback beneath it. A thread left with no comments disappears.
281
+ PR_NUMBER="$(jq -r .number <<<"$PR_JSON")"
282
+ OWNER="${REPO%/*}"
283
+ REPO_NAME="${REPO#*/}"
284
+
285
+ # Gate 5 is "zero actionable threads", so a thread the query never returns reads
286
+ # as a passing gate: the review-heavy PR that exceeds one 100-node page is
287
+ # exactly the one where dropped feedback matters. `--paginate` walks every page
288
+ # via $endCursor; `--jq …nodes[]` emits one node per line across all pages, which
289
+ # `jq -s` collects back into a single array.
290
+ #
291
+ # The nested comments(last:50) cap stays. Actionability keys on the newest
292
+ # submitted comment, which `last:50` keeps unless all 50 newest are unsubmitted
293
+ # drafts — in that one corner the PENDING filter empties the thread and it drops
294
+ # out of the snapshot, so Gate 5 can pass over real feedback. Otherwise the cap
295
+ # costs only a truncated cached thread body on a >50-comment thread, tracked
296
+ # separately as issue #203. Lifting it needs a per-thread follow-up query rather
297
+ # than a flag, since `--paginate` follows exactly one connection's cursor and
298
+ # this query spends it on reviewThreads.
299
+ THREADS_JSON="$(gh api graphql --paginate \
300
+ -F owner="$OWNER" -F repo="$REPO_NAME" -F pr="$PR_NUMBER" \
301
+ -f query='
302
+ query($owner:String!, $repo:String!, $pr:Int!, $endCursor:String) {
303
+ repository(owner:$owner, name:$repo) {
304
+ pullRequest(number:$pr) {
305
+ reviewThreads(first:100, after:$endCursor) {
306
+ nodes {
307
+ id
308
+ isResolved
309
+ comments(last:50) {
310
+ nodes { body state author { login } }
311
+ }
312
+ }
313
+ pageInfo { hasNextPage endCursor }
314
+ }
315
+ }
316
+ }
317
+ }' --jq '.data.repository.pullRequest.reviewThreads.nodes[]' \
318
+ | jq -s '
319
+ map(.comments.nodes = ((.comments.nodes // []) | map(select(.state != "PENDING"))))
320
+ | map(select((.comments.nodes | length) > 0))
321
+ ')"
322
+
323
+ # §2.2.2 requires EVERY top-level comment to appear, and Gate 6 reads reactions
324
+ # off the (recent) engagement comment, so the comments connection must be fully
325
+ # paginated — a `first:100` single page silently drops the engagement comment
326
+ # (and its approval reaction) on long-running PRs. `--paginate` walks every page
327
+ # via $endCursor; `--jq …nodes[]` emits one node per line across all pages, which
328
+ # `jq -s` collects back into a single array.
329
+ COMMENTS_JSON="$(gh api graphql --paginate \
330
+ -F owner="$OWNER" -F repo="$REPO_NAME" -F pr="$PR_NUMBER" \
331
+ -f query='
332
+ query($owner:String!, $repo:String!, $pr:Int!, $endCursor:String) {
333
+ repository(owner:$owner, name:$repo) {
334
+ pullRequest(number:$pr) {
335
+ comments(first:100, after:$endCursor) {
336
+ nodes {
337
+ id
338
+ databaseId
339
+ body
340
+ author { login }
341
+ reactions(first:100) {
342
+ nodes { content user { login } }
343
+ }
344
+ reactionGroups { content viewerHasReacted }
345
+ }
346
+ pageInfo { hasNextPage endCursor }
347
+ }
348
+ }
349
+ }
350
+ }' --jq '.data.repository.pullRequest.comments.nodes[]' | jq -s '.')"
351
+
352
+ # Reviews and review requests come from GraphQL, not `gh pr view --json`, because
353
+ # the latter's exporter only marshals User/Team requested reviewers and silently
354
+ # drops Bot-typed ones (e.g. Copilot) — which would break the pending override
355
+ # for a re-requested bot review. GraphQL's RequestedReviewer union lets us pull
356
+ # the login for User/Bot/Mannequin (and name/slug for Team), and the review
357
+ # author's __typename gives an authoritative bot flag.
358
+ #
359
+ # The reviews connection must be fully paginated: every review event is a node,
360
+ # so a long-running PR exceeds a single 100-node page, and a `first:100` cap
361
+ # silently drops the newest reviews — breaking the "most recent submitted review
362
+ # per reviewer" rule (a recent changes_requested, or a bot review that has since
363
+ # landed against an outstanding request, would be missed). `--paginate` walks
364
+ # every page via $endCursor; `--jq …nodes[]` emits one node per line, which
365
+ # `jq -s` collects into a single array. reviewRequests is bounded by the
366
+ # reviewer count (only currently-outstanding requests stand), so its single
367
+ # 100-node page is sufficient and is fetched separately — `--paginate` follows
368
+ # exactly one connection's cursor, so the two can't share a query.
369
+ #
370
+ # `id` and `body` come along because the review's own prose appears nowhere else
371
+ # in the snapshot — inline remarks arrive as review threads, but the body a
372
+ # `changes_requested` was submitted with has no other carrier. `id` keys its
373
+ # cache file and its `.ack`.
374
+ REVIEW_NODES="$(gh api graphql --paginate \
375
+ -F owner="$OWNER" -F repo="$REPO_NAME" -F pr="$PR_NUMBER" \
376
+ -f query='
377
+ query($owner:String!, $repo:String!, $pr:Int!, $endCursor:String) {
378
+ repository(owner:$owner, name:$repo) {
379
+ pullRequest(number:$pr) {
380
+ reviews(first:100, after:$endCursor) {
381
+ nodes { id body author { login __typename } state }
382
+ pageInfo { hasNextPage endCursor }
383
+ }
384
+ }
385
+ }
386
+ }' --jq '.data.repository.pullRequest.reviews.nodes[]' | jq -s '.')"
387
+
388
+ REVIEW_REQUEST_NODES="$(gh api graphql \
389
+ -F owner="$OWNER" -F repo="$REPO_NAME" -F pr="$PR_NUMBER" \
390
+ -f query='
391
+ query($owner:String!, $repo:String!, $pr:Int!) {
392
+ repository(owner:$owner, name:$repo) {
393
+ pullRequest(number:$pr) {
394
+ reviewRequests(first:100) {
395
+ nodes {
396
+ requestedReviewer {
397
+ __typename
398
+ ... on User { login }
399
+ ... on Bot { login }
400
+ ... on Mannequin { login }
401
+ ... on Team { name slug }
402
+ }
403
+ }
404
+ }
405
+ }
406
+ }
407
+ }' --jq '.data.repository.pullRequest.reviewRequests.nodes // []')"
408
+
409
+ # Re-wrap the two connections back into the {reviews:{nodes:…},
410
+ # reviewRequests:{nodes:…}} pullRequest-node shape reviews_xml's jq reads.
411
+ REVIEWS_JSON="$(jq -n \
412
+ --argjson reviews "$REVIEW_NODES" \
413
+ --argjson reqs "$REVIEW_REQUEST_NODES" \
414
+ '{reviews: {nodes: $reviews}, reviewRequests: {nodes: $reqs}}')"
415
+
416
+ # GraphQL ReactionContent → platform-normalized name per §2.2.2 reactions schema.
417
+ reaction_emoji() {
418
+ case "$1" in
419
+ THUMBS_UP) printf '+1' ;;
420
+ THUMBS_DOWN) printf -- '-1' ;;
421
+ LAUGH) printf 'laugh' ;;
422
+ HOORAY) printf 'hooray' ;;
423
+ CONFUSED) printf 'confused' ;;
424
+ HEART) printf 'heart' ;;
425
+ ROCKET) printf 'rocket' ;;
426
+ EYES) printf 'eyes' ;;
427
+ *) printf '%s' "$(tr '[:upper:]' '[:lower:]' <<<"$1")" ;;
428
+ esac
429
+ }
430
+
431
+ # --- checks -------------------------------------------------------------------
432
+ checks_xml() {
433
+ jq -r --arg info "$INFORMATIONAL_RE" --argjson stuck_after "$STUCK_AFTER_SEC" '
434
+ (now | floor) as $now |
435
+ .statusCheckRollup // [] | map({
436
+ name: (.name // .context // "check"),
437
+ conclusion: (.conclusion // .state // ""),
438
+ status: (.status // ""),
439
+ url: (.detailsUrl // .targetUrl // ""),
440
+ started: (.startedAt // ""),
441
+ informational: ($info != "" and (((.name // .context // "")) | test($info; "i")))
442
+ }) | map(. + {
443
+ pending: (.status == "IN_PROGRESS" or .status == "QUEUED" or .status == "PENDING" or .conclusion == "" and .status != ""),
444
+ failing: ((.conclusion // "") | test("FAILURE|TIMED_OUT|CANCELLED|STARTUP_FAILURE"; "i")),
445
+ stuck: (.status == "IN_PROGRESS" and .started != "" and (($now - (.started | fromdateiso8601? // $now)) > $stuck_after))
446
+ }) as $cs
447
+ | (any($cs[]; .pending and (.stuck | not))) as $any_pending
448
+ | (any($cs[]; .failing and (.informational | not) and (.pending | not))) as $any_failing
449
+ | (if $any_pending then "pending" elif $any_failing then "failing" else "passing" end) as $rollup
450
+ | " <checks state=\"\($rollup)\">",
451
+ ($cs[] | " <check name=\"\(.name)\" conclusion=\"\(.conclusion)\" url=\"\(.url)\" informational=\"\(.informational)\" stuck=\"\(.stuck)\"/>"),
452
+ " </checks>"
453
+ ' <<<"$PR_JSON"
454
+ }
455
+
456
+ # --- merge conflicts ----------------------------------------------------------
457
+ conflicts_xml() {
458
+ local m; m="$(jq -r '.mergeable // ""' <<<"$PR_JSON")"
459
+ local present=false; [[ "$m" == "CONFLICTING" ]] && present=true
460
+ echo " <merge-conflicts present=\"$present\"/>"
461
+ }
462
+
463
+ # --- reviews ------------------------------------------------------------------
464
+ # One persistent record per reviewer (PR #132 model): a reviewer who was
465
+ # requested OR has reviewed appears exactly once, carrying a status that walks
466
+ # pending -> commented/changes_requested/approved (plus dismissed). An
467
+ # outstanding request OVERRIDES any prior verdict back to "pending" — a fresh
468
+ # request "replaces" the old review until the reviewer re-reviews — so a
469
+ # re-requested Copilot, or an operator re-requested after approving, reads as
470
+ # pending and the agent keeps polling instead of treating the stale verdict as
471
+ # current. `state="pending"` is the in-flight signal: an empty thread set while a
472
+ # pending review stands is NOT convergence. Mode is bot iff GitHub types the
473
+ # account a Bot or the login matches the agent-identity regex; role is operator
474
+ # iff the login is the configured operator, else team (humans only). Input is the
475
+ # GraphQL pullRequest node ($REVIEWS_JSON): `.reviews.nodes` and
476
+ # `.reviewRequests.nodes` (the latter wrapping a RequestedReviewer union).
477
+ #
478
+ # The review body is a first-class work item: it carries `actionable` and, when
479
+ # there is a body to read, `cache`. Actionable unless the review it came from was
480
+ # dismissed or the agent has written the `.ack` sibling; a review with no body (a
481
+ # bare verdict, or a requested reviewer who has not reviewed) is never
482
+ # actionable. Reviews have no reply thread and take no reactions, so `.ack` — the
483
+ # annotation mechanism — is the only settling signal available.
484
+ #
485
+ # Emitted as one compact JSON object per review rather than TSV: a body carries
486
+ # newlines and tabs, which @tsv cannot round-trip through `read`.
487
+ reviews_xml() {
488
+ echo " <reviews>"
489
+ jq -c '
490
+ # Requested reviewers (currently-outstanding requests): login + bot-ness.
491
+ # The GraphQL union nests under .requestedReviewer; User/Bot/Mannequin carry
492
+ # .login, Team carries .slug/.name.
493
+ ( [ (.reviewRequests.nodes // [])[]
494
+ | .requestedReviewer
495
+ | { login: ((.login // .slug // .name) // ""), is_bot: ((.__typename // "") == "Bot") }
496
+ | select(.login != "") ] ) as $reqs
497
+ | ( [ $reqs[].login | ascii_downcase ] ) as $reqset
498
+ # Latest SUBMITTED review per author, keyed by lowercased login. PENDING here
499
+ # means an unsubmitted draft (visible only to its author, e.g. the agent
500
+ # itself); it is invisible to the protocol, so drop it — "pending" in the
501
+ # output comes from an outstanding REQUEST, never an unsubmitted review.
502
+ | ( reduce (.reviews.nodes // [])[] as $r ({};
503
+ ( ($r.author.login // "") ) as $lg
504
+ | if $lg == "" or ($r.state == "PENDING") then .
505
+ else ($lg | ascii_downcase) as $k
506
+ | (.[$k] // {}) as $prev
507
+ | .[$k] = { login: $lg,
508
+ is_bot: (($r.author.__typename // "") == "Bot"),
509
+ state: ($r.state // "COMMENTED") }
510
+ # The newest review supplies the state; the newest one that
511
+ # actually carries prose supplies the body. Otherwise a bare
512
+ # verdict submitted on top of a substantive review erases the
513
+ # body before the agent ever polls — the exact disappearance
514
+ # this element exists to prevent.
515
+ + ( if (($r.body // "") | test("\\S"))
516
+ then { id: ($r.id // ""), body: ($r.body // ""),
517
+ body_state: ($r.state // "COMMENTED") }
518
+ else { id: ($prev.id // ""), body: ($prev.body // ""),
519
+ body_state: ($prev.body_state // "") }
520
+ end )
521
+ end
522
+ ) ) as $rev
523
+ # Add a pending stub for any requested reviewer that has not reviewed.
524
+ | ( $rev + ( reduce $reqs[] as $q ({};
525
+ ( $q.login | ascii_downcase ) as $k
526
+ | if ($rev[$k]) then . else .[$k] = { login: $q.login, is_bot: $q.is_bot, state: "PENDING", id: "", body: "", body_state: "" } end
527
+ ) ) )
528
+ # Pending override: an outstanding request forces the status back to pending.
529
+ | to_entries
530
+ | map( .key as $k | .value + { state: ( if ($reqset | index($k)) then "PENDING" else .value.state end ) } )
531
+ | .[]
532
+ ' <<<"$REVIEWS_JSON" |
533
+ while read -r r; do
534
+ local author is_bot state rid body body_state
535
+ author="$(jq -r '.login // ""' <<<"$r")"
536
+ [[ -n "$author" ]] || continue
537
+ is_bot="$(jq -r '.is_bot // false' <<<"$r")"
538
+ state="$(jq -r '.state // ""' <<<"$r")"
539
+ rid="$(jq -r '.id // ""' <<<"$r")"
540
+ body="$(jq -r '.body // ""' <<<"$r")"
541
+ # The state of the review the body came from, which is not always the
542
+ # element's state: a later bare verdict moves state without moving body.
543
+ body_state="$(jq -r '.body_state // ""' <<<"$r" | tr '[:upper:]' '[:lower:]')"
544
+
545
+ local mode="human"
546
+ if [[ "$is_bot" == "true" ]] || [[ "${author,,}" =~ (copilot|codex|claude|ai-agent) ]]; then
547
+ mode="bot"
548
+ fi
549
+ local s; s="$(tr '[:upper:]' '[:lower:]' <<<"$state")"
550
+ case "$s" in pending|commented|approved|changes_requested|dismissed) ;; *) s=commented ;; esac
551
+
552
+ local role_attr=""
553
+ if [[ "$mode" == "human" ]]; then
554
+ local role="team"
555
+ [[ "${author,,}" == "$OPERATOR_LC" ]] && role="operator"
556
+ role_attr=" role=\"$role\""
557
+ fi
558
+
559
+ # A body of only whitespace is the same as none: a bare verdict with nothing
560
+ # to read, so it gets no cache file and can never be work.
561
+ local actionable="false" reason_attr=' reason="no-body"' cache_attr="" inner=""
562
+ if [[ -n "$rid" && -n "${body//[[:space:]]/}" ]]; then
563
+ local id="${rid//[^A-Za-z0-9_=-]/_}"
564
+ cache_item "$id" "reviews" "$body"
565
+ local cache_path="$DIR/reviews/$id.md"
566
+ local sum_path="$DIR/reviews/$id.summary.md"
567
+ cache_attr=" cache=\"$(xml_attr "$cache_path")\""
568
+ if [[ -f "$DIR/reviews/$id.ack" ]]; then
569
+ reason_attr=' reason="acked"'
570
+ elif [[ "$body_state" == "dismissed" ]]; then
571
+ reason_attr=' reason="dismissed"'
572
+ else
573
+ actionable="true"; reason_attr=""
574
+ fi
575
+ # Same lazy recap as comments and threads: written once the item settles,
576
+ # then persisted and emitted in either state. A failed summary writes
577
+ # nothing and must not abort the payload — the item is emitted without one.
578
+ if [[ "$actionable" == "false" && ! -f "$sum_path" ]]; then
579
+ summarize "$cache_path" "$sum_path" || true
580
+ fi
581
+ [[ -f "$sum_path" ]] && inner="<summary>$(xml_text "$(cat "$sum_path")")</summary>"
582
+ fi
583
+
584
+ if [[ -z "$inner" ]]; then
585
+ printf ' <review author="%s" mode="%s"%s state="%s" actionable="%s"%s%s/>\n' \
586
+ "$(xml_attr "$author")" "$mode" "$role_attr" "$s" "$actionable" "$reason_attr" "$cache_attr"
587
+ else
588
+ printf ' <review author="%s" mode="%s"%s state="%s" actionable="%s"%s%s>%s</review>\n' \
589
+ "$(xml_attr "$author")" "$mode" "$role_attr" "$s" "$actionable" "$reason_attr" "$cache_attr" "$inner"
590
+ fi
591
+ done
592
+ echo " </reviews>"
593
+ }
594
+
595
+ # Returns 0 iff $1's last non-empty line is a canonical terminal signal.
596
+ # Anchoring to the last non-empty line matches reference.md ("must be the
597
+ # last non-empty line"); inline mentions of "done" in prose stay actionable.
598
+ has_terminal_signal() {
599
+ local last
600
+ last="$(printf '%s\n' "$1" | grep -v '^[[:space:]]*$' | tail -n1)"
601
+ [[ -n "$last" ]] || return 1
602
+ grep -qiE "$TERMINAL_RE" <<<"$last"
603
+ }
604
+
605
+ # --- actionability ------------------------------------------------------------
606
+ # Args: <newest-body> <newest-author-login> <thread-resolved:true|false>
607
+ # Echoes a tab-separated "<actionable>\t<reason>" pair: actionable is "true" or
608
+ # "false"; reason is empty when actionable, else a stable token explaining *why*
609
+ # the item is suppressed (`resolved`, `agent-artifact`, `agent-terminal-reply`).
610
+ # The reason is surfaced as a `reason=` attribute so the agent never has to
611
+ # re-derive suppression from the human-facing <summary> prose (which describes
612
+ # the thread's *content* and will read as if an addressed reviewer point still
613
+ # stands).
614
+ # Full rules: reference.md → Actionability. Summary: non-actionable iff
615
+ # thread-resolved, or the body is the calling agent's plan comment or
616
+ # engagement comment (line-anchored agent-plan/agent-engagement sentinel +
617
+ # author identity, so a human quoting the marker stays actionable), or the body
618
+ # is the calling agent's terminal-tagged reply (any agent-reply marker + author
619
+ # identity + terminal signal on the last non-empty line), or the calling agent
620
+ # reacted to the item with a terminal reaction (+1/-1/rocket). The author checks
621
+ # key on $CALLER_LOGIN (guaranteed set — the script exits earlier if it can't be
622
+ # resolved).
623
+ classify_actionable() {
624
+ local body="$1" author="$2" resolved="$3" reaction_groups="${4:-[]}"
625
+ [[ "$resolved" == "true" ]] && { printf 'false\tresolved\n'; return; }
626
+
627
+ # Plan or engagement comment by the calling agent. Both are agent artifacts,
628
+ # not reviewer items: the engagement comment in particular anchors operator
629
+ # approval (Gate 6) and would otherwise stay actionable forever — blocking
630
+ # Gate 4 and thus the draft-clear/merge transitions — since the agent never
631
+ # "addresses" its own request.
632
+ if [[ "$author" == "$CALLER_LOGIN" ]] \
633
+ && grep -qE '^<!-- agent-(plan|engagement):[^ ]+ -->$' <<<"$body"; then
634
+ printf 'false\tagent-artifact\n'; return
635
+ fi
636
+
637
+ # Terminal-tagged reply by the calling agent: own login + an agent-reply marker
638
+ # + a terminal signal on the last non-empty line.
639
+ if has_terminal_signal "$body" \
640
+ && [[ "$author" == "$CALLER_LOGIN" ]] \
641
+ && grep -qF '<!-- agent-reply:' <<<"$body"; then
642
+ printf 'false\tagent-terminal-reply\n'; return
643
+ fi
644
+
645
+ # A terminal reaction (+1/-1/rocket, reference.md → Terminal signals) by the
646
+ # calling agent. Top-level comments have no reply threading, so this is the
647
+ # only signal that can ever settle a comment someone else authored — without
648
+ # it an operator note stays actionable forever and Gate 4 never passes.
649
+ # Keys on reactionGroups' viewerHasReacted rather than scanning reaction
650
+ # nodes: the GraphQL viewer is the same identity as $CALLER_LOGIN (one gh
651
+ # token), and viewerHasReacted is set regardless of how many reactions the
652
+ # comment has — a nodes scan is capped at the first page and can miss the
653
+ # caller's reaction on a heavily-reacted comment.
654
+ if jq -e 'any(.[]; .viewerHasReacted == true
655
+ and (.content == "THUMBS_UP" or .content == "THUMBS_DOWN"
656
+ or .content == "ROCKET"))' \
657
+ <<<"$reaction_groups" >/dev/null 2>&1; then
658
+ printf 'false\tagent-terminal-reply\n'; return
659
+ fi
660
+ printf 'true\t\n'
661
+ }
662
+
663
+ # Parse a classify_actionable result into the globals $CA_ACTIONABLE and
664
+ # $CA_REASON_ATTR (the latter ready to splice into a start tag, empty unless
665
+ # non-actionable with a reason). Keeps the tab-splitting in one place.
666
+ parse_actionable() {
667
+ CA_ACTIONABLE="${1%%$'\t'*}"
668
+ local reason="${1#*$'\t'}"
669
+ if [[ "$CA_ACTIONABLE" == "false" && -n "$reason" ]]; then
670
+ CA_REASON_ATTR=" reason=\"$(xml_attr "$reason")\""
671
+ else
672
+ CA_REASON_ATTR=""
673
+ fi
674
+ }
675
+
676
+ # Emit <reactions>…</reactions> for a reactions-node JSON array, or nothing if
677
+ # the array is empty. Used by comments_xml.
678
+ reactions_xml_for() {
679
+ local rj="$1"
680
+ if [[ -z "$rj" || "$rj" == "[]" || "$rj" == "null" ]]; then
681
+ return 0
682
+ fi
683
+ echo "<reactions>"
684
+ jq -r '.[] | [(.user.login // ""), (.content // "")] | @tsv' <<<"$rj" |
685
+ while IFS=$'\t' read -r user content; do
686
+ [[ -n "$user" && -n "$content" ]] || continue
687
+ local emoji; emoji="$(reaction_emoji "$content")"
688
+ printf ' <reaction author="%s" emoji="%s"/>\n' \
689
+ "$(xml_attr "$user")" "$(xml_attr "$emoji")"
690
+ done
691
+ echo " </reactions>"
692
+ return 0
693
+ }
694
+
695
+ # --- comments (top-level PR comments) -----------------------------------------
696
+ comments_xml() {
697
+ echo " <comments>"
698
+ jq -c '.[]' <<<"$COMMENTS_JSON" | while read -r c; do
699
+ local raw_id id author body reactions reaction_groups
700
+ raw_id="$(jq -r '.id // .databaseId // ""' <<<"$c")"
701
+ author="$(jq -r '.author.login // ""' <<<"$c")"
702
+ body="$(jq -r '.body // ""' <<<"$c")"
703
+ reactions="$(jq -c '.reactions.nodes // []' <<<"$c")"
704
+ reaction_groups="$(jq -c '.reactionGroups // []' <<<"$c")"
705
+ [[ -n "$raw_id" ]] || continue
706
+ id="${raw_id//[^A-Za-z0-9_=-]/_}"
707
+ cache_item "$id" "comments" "$body"
708
+ parse_actionable "$(classify_actionable "$body" "$author" "false" "$reaction_groups")"
709
+ local actionable="$CA_ACTIONABLE" reason_attr="$CA_REASON_ATTR"
710
+ local cache_path="$DIR/comments/$id.md"
711
+ local sum_path="$DIR/comments/$id.summary.md"
712
+ # Generate the recap lazily, only for a settled (non-actionable) item with no
713
+ # summary yet; once written it persists and is emitted in either state so the
714
+ # agent can read it when the item later flips back to actionable.
715
+ if [[ "$actionable" == "false" && ! -f "$sum_path" ]]; then
716
+ summarize "$cache_path" "$sum_path" || true
717
+ fi
718
+ local inner=""
719
+ [[ -f "$sum_path" ]] && inner+="<summary>$(xml_text "$(cat "$sum_path")")</summary>"
720
+ inner+="$(reactions_xml_for "$reactions")"
721
+ if [[ -z "$inner" ]]; then
722
+ printf ' <comment id="%s" actionable="%s"%s cache="%s"/>\n' \
723
+ "$(xml_attr "$id")" "$actionable" "$reason_attr" "$(xml_attr "$cache_path")"
724
+ else
725
+ printf ' <comment id="%s" actionable="%s"%s cache="%s">%s</comment>\n' \
726
+ "$(xml_attr "$id")" "$actionable" "$reason_attr" "$(xml_attr "$cache_path")" "$inner"
727
+ fi
728
+ done
729
+ echo " </comments>"
730
+ }
731
+
732
+ # --- review threads -----------------------------------------------------------
733
+ threads_xml() {
734
+ echo " <threads>"
735
+ jq -c '.[]' <<<"$THREADS_JSON" | while read -r t; do
736
+ local id resolved newest_body newest_author body
737
+ id="$(jq -r '.id // ""' <<<"$t")"
738
+ resolved="$(jq -r '.isResolved // false' <<<"$t")"
739
+ body="$(jq -r '[.comments.nodes[]? | "[" + (.author.login // "?") + "] " + (.body // "")] | join("\n\n---\n\n")' <<<"$t")"
740
+ newest_body="$(jq -r '.comments.nodes // [] | last.body // ""' <<<"$t")"
741
+ newest_author="$(jq -r '.comments.nodes // [] | last.author.login // ""' <<<"$t")"
742
+ [[ -n "$id" ]] || continue
743
+ id="${id//[^A-Za-z0-9_=-]/_}"
744
+ cache_item "$id" "threads" "$body"
745
+ parse_actionable "$(classify_actionable "$newest_body" "$newest_author" "$resolved")"
746
+ local actionable="$CA_ACTIONABLE" reason_attr="$CA_REASON_ATTR"
747
+ local cache_path="$DIR/threads/$id.md"
748
+ local sum_path="$DIR/threads/$id.summary.md"
749
+ # Generate the recap lazily for a settled (non-actionable) thread with none
750
+ # yet; it persists and is emitted in either state. When the thread flips back
751
+ # to actionable (a reviewer reply), the agent reads this recap plus the new
752
+ # content from the cache file instead of re-reading the whole thread.
753
+ if [[ "$actionable" == "false" && ! -f "$sum_path" ]]; then
754
+ summarize "$cache_path" "$sum_path" || true
755
+ fi
756
+ if [[ -f "$sum_path" ]]; then
757
+ printf ' <thread id="%s" actionable="%s"%s cache="%s"><summary>%s</summary></thread>\n' \
758
+ "$(xml_attr "$id")" "$actionable" "$reason_attr" "$(xml_attr "$cache_path")" "$(xml_text "$(cat "$sum_path")")"
759
+ else
760
+ printf ' <thread id="%s" actionable="%s"%s cache="%s"/>\n' \
761
+ "$(xml_attr "$id")" "$actionable" "$reason_attr" "$(xml_attr "$cache_path")"
762
+ fi
763
+ done
764
+ echo " </threads>"
765
+ }
766
+
767
+ # --- annotations (code scanning / check annotations) --------------------------
768
+ annotations_xml() {
769
+ echo " <annotations>"
770
+ local owner="${REPO%/*}" repo="${REPO#*/}"
771
+ local sha; sha="$(jq -r .headRefOid <<<"$PR_JSON")"
772
+ local runs
773
+ runs="$(gh api "repos/$owner/$repo/commits/$sha/check-runs" --jq '.check_runs // []' 2>/dev/null || echo '[]')"
774
+ jq -c '.[]' <<<"$runs" | while read -r run; do
775
+ local run_id; run_id="$(jq -r '.id' <<<"$run")"
776
+ gh api "repos/$owner/$repo/check-runs/$run_id/annotations" 2>/dev/null \
777
+ | jq -c '.[]?' | while read -r a; do
778
+ local path line msg id body
779
+ path="$(jq -r '.path // ""' <<<"$a")"
780
+ line="$(jq -r '.start_line // 0' <<<"$a")"
781
+ msg="$(jq -r '.message // ""' <<<"$a")"
782
+ body="[$path:$line] $msg"
783
+ id="$(printf '%s' "$body" | sha256sum | cut -c1-16)"
784
+ cache_item "$id" "annotations" "$body"
785
+ local ack="$DIR/annotations/$id.ack"
786
+ local cache_path="$DIR/annotations/$id.md"
787
+ local sum_path="$DIR/annotations/$id.summary.md"
788
+ if [[ -f "$ack" ]]; then
789
+ [[ -f "$sum_path" ]] || summarize "$cache_path" "$sum_path" || true
790
+ # The summary is best-effort: an acked annotation is still emitted, and
791
+ # still non-actionable, when the summary could not be generated.
792
+ if [[ -f "$sum_path" ]]; then
793
+ printf ' <annotation id="%s" actionable="false" reason="acked" cache="%s"><summary>%s</summary></annotation>\n' \
794
+ "$(xml_attr "$id")" "$(xml_attr "$cache_path")" "$(xml_text "$(cat "$sum_path")")"
795
+ else
796
+ printf ' <annotation id="%s" actionable="false" reason="acked" cache="%s"/>\n' \
797
+ "$(xml_attr "$id")" "$(xml_attr "$cache_path")"
798
+ fi
799
+ else
800
+ printf ' <annotation id="%s" actionable="true" cache="%s"/>\n' \
801
+ "$(xml_attr "$id")" "$(xml_attr "$cache_path")"
802
+ fi
803
+ done
804
+ done
805
+ echo " </annotations>"
806
+ }
807
+
808
+ # --- terminal resolution ------------------------------------------------------
809
+ # Returns 0 if the PR's net change is present in the base tip (shipped), 1 if
810
+ # not (abandoned), 2 if the check could not run (no repo / git or fetch failure).
811
+ # Squash/rebase-safe: builds the PR's combined net patch and reverse-applies it
812
+ # against a temp index seeded from the base tip, so an n→1 squash or a rebase
813
+ # rewrite still matches by content (per-commit patch-ids, which `git cherry`
814
+ # uses, break under squash). Side-effect-free — never touches the caller's
815
+ # worktree, index, or HEAD. An empty net patch (no-op PR) reverse-applies
816
+ # trivially → present → shipped, which is intended.
817
+ content_present() {
818
+ local base_ref="$1"
819
+ command -v git >/dev/null 2>&1 || return 2
820
+ git rev-parse --git-dir >/dev/null 2>&1 || return 2
821
+
822
+ # Fetch the head commit by SHA via refs/pull/<n>/head — the head branch may
823
+ # have been deleted on close, but GitHub keeps the SHA reachable here.
824
+ git fetch --quiet origin "refs/pull/$PR_NUMBER/head" 2>/dev/null || return 2
825
+ local head_sha; head_sha="$(git rev-parse --verify --quiet FETCH_HEAD)" || return 2
826
+ [[ -n "$head_sha" ]] || return 2
827
+
828
+ # Fetch the base tip (FETCH_HEAD gets overwritten, so head_sha is captured first).
829
+ git fetch --quiet origin "$base_ref" 2>/dev/null || return 2
830
+ local base_sha; base_sha="$(git rev-parse --verify --quiet FETCH_HEAD)" || return 2
831
+ [[ -n "$base_sha" ]] || return 2
832
+
833
+ local mb; mb="$(git merge-base "$base_sha" "$head_sha" 2>/dev/null)" || return 2
834
+ [[ -n "$mb" ]] || return 2
835
+
836
+ # No-op PR: an empty net patch is trivially present in base → shipped. Must be
837
+ # short-circuited because `git apply --check` rejects empty input ("No valid
838
+ # patches in input"), which would otherwise misclassify a no-op PR as abandoned.
839
+ if git diff --quiet "$mb" "$head_sha" 2>/dev/null; then
840
+ return 0
841
+ fi
842
+
843
+ # Reverse-apply the net patch against a temp index seeded from the base tip.
844
+ # `rm -f` the mktemp file so the index path is free: some git versions read an
845
+ # existing empty GIT_INDEX_FILE as a corrupt index, failing read-tree. Clean up
846
+ # inline rather than via a RETURN trap — a RETURN trap would re-fire on the
847
+ # *caller's* return and, under `set -u`, abort the rest of the XML emission.
848
+ local tmp_index; tmp_index="$(mktemp)" || return 2
849
+ rm -f "$tmp_index"
850
+ if ! GIT_INDEX_FILE="$tmp_index" git read-tree "$base_sha" 2>/dev/null; then
851
+ rm -f "$tmp_index"
852
+ return 2
853
+ fi
854
+ # --binary emits an applyable full binary patch; without it git diff writes a
855
+ # "Binary files differ" placeholder that git apply rejects, misclassifying any
856
+ # PR touching binary files as abandoned.
857
+ local rc=1
858
+ if git diff --binary "$mb" "$head_sha" \
859
+ | GIT_INDEX_FILE="$tmp_index" git apply --reverse --cached --check - 2>/dev/null; then
860
+ rc=0
861
+ fi
862
+ rm -f "$tmp_index"
863
+ return "$rc"
864
+ }
865
+
866
+ # Resolve the PR's terminal end-to-end and emit <terminal>. Binary at closure
867
+ # (shipped|abandoned); non-terminal while the PR is live (open|draft). Cheapest
868
+ # signals first; git is shelled only on the CLOSED-but-not-merged + ahead_by>0
869
+ # branch, never on the hot poll loop. Carries the raw signals it used.
870
+ terminal_xml() {
871
+ local state merged_at base_ref head_oid is_draft
872
+ state="$(jq -r '.state // ""' <<<"$PR_JSON")"
873
+ merged_at="$(jq -r '.mergedAt // ""' <<<"$PR_JSON")"
874
+ base_ref="$(jq -r '.baseRefName // ""' <<<"$PR_JSON")"
875
+ head_oid="$(jq -r '.headRefOid // ""' <<<"$PR_JSON")"
876
+ is_draft="$(jq -r '.isDraft // false' <<<"$PR_JSON")"
877
+
878
+ local gh_merged=false
879
+ [[ "$state" == "MERGED" || ( -n "$merged_at" && "$merged_at" != "null" ) ]] && gh_merged=true
880
+
881
+ # Non-terminal: PR still open.
882
+ if [[ "$state" == "OPEN" ]]; then
883
+ local s=open; [[ "$is_draft" == "true" ]] && s=draft
884
+ printf ' <terminal state="%s" gh-merged="%s" ahead-by="-"/>\n' "$s" "$gh_merged"
885
+ return
886
+ fi
887
+
888
+ # Step 1: GitHub says merged → shipped. API only.
889
+ if [[ "$gh_merged" == "true" ]]; then
890
+ printf ' <terminal state="shipped" gh-merged="true" ahead-by="-"/>\n'
891
+ return
892
+ fi
893
+
894
+ # CLOSED without `merged`. Step 2: one three-dot compare call, read ahead_by.
895
+ # ahead_by == 0 → every head commit is already in base (plain merge /
896
+ # fast-forward / merge-queue close where GitHub never set merged) → shipped,
897
+ # no git. Works even if the head branch was deleted — compare accepts the SHA.
898
+ local ahead_by
899
+ ahead_by="$(gh api "repos/$OWNER/$REPO_NAME/compare/$base_ref...$head_oid" \
900
+ --jq '.ahead_by' 2>/dev/null || echo "")"
901
+
902
+ if [[ "$ahead_by" == "0" ]]; then
903
+ printf ' <terminal state="shipped" gh-merged="false" ahead-by="0"/>\n'
904
+ return
905
+ fi
906
+
907
+ # ahead_by > 0 (or compare failed): could be squash/rebase-landed or genuinely
908
+ # abandoned; the API can't tell. Step 3: content check via git (the only path
909
+ # that shells git). On no-repo / fetch failure we do NOT guess — emit abandoned
910
+ # with an error breadcrumb so delivery is never falsely claimed.
911
+ local ab_attr="${ahead_by:--}"
912
+ local rc=0
913
+ if content_present "$base_ref"; then rc=0; else rc=$?; fi
914
+ case "$rc" in
915
+ 0) printf ' <terminal state="shipped" gh-merged="false" ahead-by="%s"/>\n' "$ab_attr" ;;
916
+ 1) printf ' <terminal state="abandoned" gh-merged="false" ahead-by="%s"/>\n' "$ab_attr" ;;
917
+ *) printf ' <terminal state="abandoned" gh-merged="false" ahead-by="%s" error="content-check-unavailable"/>\n' "$ab_attr" ;;
918
+ esac
919
+ }
920
+
921
+ # --- emit ---------------------------------------------------------------------
922
+ printf '<pr-status repo="%s" pr="%s" head="%s">\n' \
923
+ "$(xml_attr "$REPO")" "$(xml_attr "$PR")" "$(xml_attr "$HEAD")"
924
+ terminal_xml
925
+ checks_xml
926
+ conflicts_xml
927
+ reviews_xml
928
+ comments_xml
929
+ threads_xml
930
+ annotations_xml
931
+ echo '</pr-status>'