@cxi-lmai/ci-agent-platform 3.0.0 → 3.1.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 (69) hide show
  1. package/README.md +27 -5
  2. package/package.json +2 -2
  3. package/payload/INSTALL.md +14 -5
  4. package/payload/agents/agent-architect.md +1 -1
  5. package/payload/agents/code-reviewer.md +1 -1
  6. package/payload/agents/codebase-auditor.md +1 -1
  7. package/payload/agents/coder.md +3 -3
  8. package/payload/agents/decomposer.md +1 -1
  9. package/payload/agents/docs-sync.md +1 -1
  10. package/payload/agents/e2e-test-writer.md +1 -1
  11. package/payload/agents/performance-reviewer.md +1 -1
  12. package/payload/agents/release-mr.md +1 -1
  13. package/payload/agents/security-reviewer.md +1 -1
  14. package/payload/agents/test-fix.md +4 -4
  15. package/payload/agents/test-writer.md +3 -3
  16. package/payload/agents-omp/agent-architect.md +101 -0
  17. package/payload/agents-omp/code-reviewer.md +86 -0
  18. package/payload/agents-omp/codebase-auditor.md +73 -0
  19. package/payload/agents-omp/coder.md +57 -0
  20. package/payload/agents-omp/decomposer.md +70 -0
  21. package/payload/agents-omp/docs-sync.md +114 -0
  22. package/payload/agents-omp/e2e-test-writer.md +47 -0
  23. package/payload/agents-omp/migration-reviewer.md +99 -0
  24. package/payload/agents-omp/orchestrator.md +50 -0
  25. package/payload/agents-omp/performance-reviewer.md +81 -0
  26. package/payload/agents-omp/postmortem.md +82 -0
  27. package/payload/agents-omp/release-mr.md +274 -0
  28. package/payload/agents-omp/security-reviewer.md +121 -0
  29. package/payload/agents-omp/test-fix.md +33 -0
  30. package/payload/agents-omp/test-writer.md +39 -0
  31. package/payload/ci-templates/claude-pipeline.gitlab-ci.yml +220 -7
  32. package/payload/ci-templates/github/claude-issue-pipeline.yml +1 -1
  33. package/payload/ci-templates/github/claude-pipeline.yml +2 -2
  34. package/payload/ci-templates/github/claude-test-fix.yml +1 -1
  35. package/payload/ci-templates/scripts/agent-architect.sh +233 -0
  36. package/payload/ci-templates/scripts/code.sh +35 -35
  37. package/payload/ci-templates/scripts/codebase-audit.sh +252 -0
  38. package/payload/ci-templates/scripts/coverage-ratchet.sh +77 -0
  39. package/payload/ci-templates/scripts/cve-fix.sh +246 -0
  40. package/payload/ci-templates/scripts/docs-sync.sh +225 -0
  41. package/payload/ci-templates/scripts/e2e-test-gen.sh +201 -0
  42. package/payload/ci-templates/scripts/lib/failure-notice.sh +104 -0
  43. package/payload/ci-templates/scripts/lib/issue-loop.sh +155 -40
  44. package/payload/ci-templates/scripts/lib/pipeline-common.sh +233 -31
  45. package/payload/ci-templates/scripts/lib/platform.sh +270 -13
  46. package/payload/ci-templates/scripts/lib/usage-capture-omp.sh +123 -0
  47. package/payload/ci-templates/scripts/lib/usage-capture.sh +7 -1
  48. package/payload/ci-templates/scripts/metrics-snapshot.sh +377 -0
  49. package/payload/ci-templates/scripts/orchestrate.sh +56 -38
  50. package/payload/ci-templates/scripts/postmortem.sh +11 -1
  51. package/payload/ci-templates/scripts/review-fix.sh +23 -12
  52. package/payload/ci-templates/scripts/review.sh +12 -8
  53. package/payload/ci-templates/scripts/test-fix.sh +8 -1
  54. package/payload/skills/agent-architect/SKILL.md +45 -0
  55. package/payload/skills/codebase-audit/SKILL.md +84 -0
  56. package/payload/skills/cve-fix/SKILL.md +98 -0
  57. package/payload/skills/docs-sync/SKILL.md +74 -0
  58. package/payload/skills/e2e-test-gen/SKILL.md +65 -0
  59. package/payload/skills/fix-review-findings/SKILL.md +3 -3
  60. package/payload/skills/fix-tests/SKILL.md +4 -4
  61. package/payload/skills/implement-issue/SKILL.md +1 -1
  62. package/payload/skills/init-pipeline-config/SKILL.md +4 -4
  63. package/payload/skills/postmortem-mr/SKILL.md +1 -1
  64. package/payload/skills/review-mr/SKILL.md +1 -1
  65. package/payload/skills/triage-issue/SKILL.md +2 -2
  66. package/payload/templates/memory-index.template.md +34 -0
  67. package/payload/templates/pipeline-config.template.md +11 -1
  68. package/payload/templates/review_suppressions.template.md +55 -0
  69. package/payload/templates/spec-issue.template.md +39 -7
@@ -8,7 +8,8 @@
8
8
  # Common (both platforms):
9
9
  # PIPE_MR_IID MR iid (GitLab) or PR number (GitHub)
10
10
  # PIPE_TARGET_BRANCH branch the MR/PR targets
11
- # PIPE_SOURCE_BRANCH the MR/PR source branch
11
+ # PIPE_SOURCE_BRANCH the MR/PR source branch (optional: platform_mr_meta
12
+ # reports it as `source_branch` when the runtime does not)
12
13
  # PIPE_HEAD_SHA current head commit under review
13
14
  # PIPE_PROJECT_PATH namespace/project (for git push URL)
14
15
  # PIPE_SERVER_HOST git host (for git push URL)
@@ -58,7 +59,10 @@ _gl_get() {
58
59
  "$PIPE_API_URL/projects/$PIPE_PROJECT_ID/merge_requests/$PIPE_MR_IID$1"
59
60
  }
60
61
 
61
- _gl_mr_meta() { _gl_get "" | jq -r '{title: (.title // ""), description: (.description // ""), labels: ((.labels // []) | join(","))}'; }
62
+ # `source_branch` is the branch the MR/PR was opened from. It comes from the API
63
+ # rather than the CI runtime because branch and workflow_run pipelines carry no
64
+ # merge-request source ref, and a runner that pushes needs it.
65
+ _gl_mr_meta() { _gl_get "" | jq -r '{title: (.title // ""), description: (.description // ""), labels: ((.labels // []) | join(",")), source_branch: (.source_branch // "")}'; }
62
66
  _gl_mr_labels() { _gl_get "" | jq -r '((.labels // []) | join(","))'; }
63
67
  # Callers take the LAST matching note, so the array must be chronological AND
64
68
  # must contain the newest notes. One asc page would return the oldest 100 on a
@@ -66,11 +70,12 @@ _gl_mr_labels() { _gl_get "" | jq -r '((.labels // []) | join(","))'; }
66
70
  _gl_notes() { _gl_get "/notes?per_page=100&sort=desc" | jq -r '[.[] | {author: .author.username, body: .body}] | reverse'; }
67
71
  _gl_issue() { curl -s -H "PRIVATE-TOKEN: $PIPE_TOKEN" "$PIPE_API_URL/projects/$PIPE_PROJECT_ID/issues/$1" | jq -r '{iid: .iid, title: (.title // ""), description: (.description // "")}'; }
68
72
 
69
- _gl_post_comment() {
73
+ _gl_post_comment_file() {
74
+ # $1 = body file under PIPE_CONTEXT_DIR.
70
75
  curl --fail-with-body -s -X POST \
71
76
  -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
72
77
  "$PIPE_API_URL/projects/$PIPE_PROJECT_ID/merge_requests/$PIPE_MR_IID/notes" \
73
- --data-urlencode "body=$1" > /dev/null
78
+ --data-urlencode "body@$1" > /dev/null
74
79
  }
75
80
 
76
81
  _gl_set_labels() {
@@ -102,15 +107,22 @@ _gl_compute_diff_base() {
102
107
 
103
108
  _gh_get() { gh api "repos/$PIPE_REPO/$1"; }
104
109
 
105
- _gh_mr_meta() { _gh_get "pulls/$PIPE_MR_IID" | jq -r '{title: (.title // ""), description: (.body // ""), labels: ((.labels // []) | map(.name) | join(","))}'; }
110
+ _gh_mr_meta() { _gh_get "pulls/$PIPE_MR_IID" | jq -r '{title: (.title // ""), description: (.body // ""), labels: ((.labels // []) | map(.name) | join(",")), source_branch: (.head.ref // "")}'; }
106
111
  _gh_mr_labels() { _gh_get "pulls/$PIPE_MR_IID" | jq -r '((.labels // []) | map(.name) | join(","))'; }
107
112
  # The per-issue comments endpoint has no sort param, so paginate all pages to
108
113
  # guarantee the newest comments are present (callers take the last match).
109
114
  _gh_notes() { gh api --paginate "repos/$PIPE_REPO/issues/$PIPE_MR_IID/comments?per_page=100" --jq '.[] | {author: .user.login, body: .body}' | jq -s '.'; }
110
115
  _gh_issue() { _gh_get "issues/$1" | jq -r '{iid: .number, title: (.title // ""), description: (.body // "")}'; }
111
116
 
112
- _gh_post_comment() {
113
- gh api -X POST "repos/$PIPE_REPO/issues/$PIPE_MR_IID/comments" -f "body=$1" > /dev/null
117
+ _gh_post_comment_file() {
118
+ # $1 = body file under PIPE_CONTEXT_DIR.
119
+ local payload status=0
120
+ payload=$(mktemp "$PIPE_CONTEXT_DIR/github-comment.XXXXXX.json")
121
+ jq -n --rawfile body "$1" '{body:$body}' > "$payload"
122
+ gh api -X POST "repos/$PIPE_REPO/issues/$PIPE_MR_IID/comments" \
123
+ -H "Content-Type: application/json" --input "$payload" > /dev/null || status=$?
124
+ rm -f "$payload"
125
+ return "$status"
114
126
  }
115
127
 
116
128
  _gh_set_labels() {
@@ -135,6 +147,179 @@ _gh_compute_diff_base() {
135
147
  fi
136
148
  }
137
149
 
150
+ # --- Pipeline and artifact helpers ------------------------------------------
151
+
152
+ _gl_completed_pipelines() {
153
+ local updated_after
154
+ updated_after=$(date -u -d "$1 days ago" '+%Y-%m-%dT%H:%M:%SZ') || return 1
155
+ curl -s --fail -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
156
+ "$PIPE_API_URL/projects/$PIPE_PROJECT_ID/pipelines?scope=finished&updated_after=$updated_after&per_page=100" |
157
+ jq -c '[.[] | {id, status: (.status // ""), ref: (.ref // ""), updated_at: (.updated_at // "")}]'
158
+ }
159
+
160
+ _gl_pipeline_artifacts() {
161
+ curl -s --fail -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
162
+ "$PIPE_API_URL/projects/$PIPE_PROJECT_ID/pipelines/$1/jobs?per_page=100" |
163
+ jq -c '[.[] | {job_id: .id, job_name: (.name // ""), artifact_available: (((.artifacts_file // {}) | .filename) != null)}]'
164
+ }
165
+
166
+ _gl_download_artifact() {
167
+ curl -sSL --fail -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
168
+ "$PIPE_API_URL/projects/$PIPE_PROJECT_ID/jobs/$1/artifacts" -o "$2"
169
+ }
170
+
171
+ _gl_latest_success_coverage() {
172
+ curl -s --fail -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
173
+ "$PIPE_API_URL/projects/$PIPE_PROJECT_ID/pipelines?ref=$1&status=success&per_page=1" |
174
+ jq -r '.[0].coverage // ""'
175
+ }
176
+
177
+ _gl_merge_requests_updated_since() {
178
+ local page response page_size results
179
+ page=1
180
+ results='[]'
181
+ while :; do
182
+ response=$(curl -s --fail -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
183
+ "$PIPE_API_URL/projects/$PIPE_PROJECT_ID/merge_requests?updated_after=$1&per_page=100&page=$page&scope=all") || return 1
184
+ page_size=$(jq -r 'length' <<< "$response") || return 1
185
+ results=$(jq -c --argjson results "$results" \
186
+ '$results + [.[] | {iid, title: (.title // ""), state: (.state // ""), author: (.author.username // ""), labels: (.labels // []), created_at: (.created_at // ""), merged_at: .merged_at, target_branch: (.target_branch // ""), description: (.description // "")}]' <<< "$response") || return 1
187
+ [ "$page_size" -lt 100 ] && break
188
+ page=$((page + 1))
189
+ done
190
+ printf '%s\n' "$results"
191
+ }
192
+
193
+ # Merged MRs targeting a branch, newest first, filtered to iid > since_iid.
194
+ # Used by the codebase-audit job to find work since the last audit.
195
+ _gl_merged_mrs() {
196
+ # $1=target_branch $2=since_iid $3=limit. One page, newest merged first;
197
+ # matches the source script's own non-paginated fetch. Captured into a
198
+ # variable (not piped straight into jq) so a curl failure propagates: a
199
+ # pipeline's exit status is jq's, not curl's, without `set -o pipefail`.
200
+ local response
201
+ response=$(curl -s --fail -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
202
+ "$PIPE_API_URL/projects/$PIPE_PROJECT_ID/merge_requests?target_branch=$1&state=merged&per_page=$3&order_by=created_at&sort=desc") || return 1
203
+ jq -c --argjson since "$2" '[.[] | select(.iid > $since) | {iid, title: (.title // ""), created_at: (.created_at // "")}]' <<< "$response"
204
+ }
205
+
206
+ _gl_mr_changes() {
207
+ # $1=iid $2=limit. Changed file paths for one merged MR, capped at limit.
208
+ local response
209
+ response=$(curl -s --fail -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
210
+ "$PIPE_API_URL/projects/$PIPE_PROJECT_ID/merge_requests/$1/changes?per_page=$2") || return 1
211
+ jq -r --argjson limit "$2" '[.changes[]?.new_path] | .[0:$limit][]' <<< "$response" 2>/dev/null
212
+ }
213
+
214
+ # $1=iid $2=limit $3=sort("asc"|"desc"). Normalized, non-system MR notes.
215
+ _gl_mr_notes() {
216
+ local response
217
+ [[ "$2" =~ ^[1-9][0-9]*$ ]] || return 1
218
+ [[ "$3" = "asc" || "$3" = "desc" ]] || return 1
219
+ response=$(curl -s --fail -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
220
+ "$PIPE_API_URL/projects/$PIPE_PROJECT_ID/merge_requests/$1/notes?per_page=$2&sort=$3") || return 1
221
+ jq --argjson limit "$2" '[.[] | select(.system != true)
222
+ | {author: (.author.username // ""), body: (.body // "")}] | .[0:$limit]' <<< "$response"
223
+ }
224
+
225
+ # These APIs return downloadable binary responses as well as JSON. Use curl
226
+ # rather than gh so artifact downloads remain a silent, status-only operation.
227
+ _gh_pipeline_api() {
228
+ local token="${GH_TOKEN:-${GITHUB_TOKEN:-}}"
229
+ curl -s --fail \
230
+ -H "Accept: application/vnd.github+json" \
231
+ -H "Authorization: Bearer $token" \
232
+ "${GITHUB_API_URL:-https://api.github.com}/repos/$PIPE_REPO/$1"
233
+ }
234
+
235
+ _gh_completed_pipelines() {
236
+ local updated_after page response page_size results
237
+ updated_after=$(date -u -d "$1 days ago" '+%Y-%m-%dT%H:%M:%SZ') || return 1
238
+ page=1
239
+ results='[]'
240
+ while :; do
241
+ response=$(_gh_pipeline_api "actions/runs?status=completed&per_page=100&page=$page") || return 1
242
+ page_size=$(jq -r '(.workflow_runs // []) | length' <<< "$response") || return 1
243
+ results=$(jq -c --argjson results "$results" --arg updated_after "$updated_after" \
244
+ '$results + [.workflow_runs[] | select((.updated_at // "") >= $updated_after) | {id, status: (.conclusion // .status // ""), ref: (.head_branch // ""), updated_at: (.updated_at // "")}]' <<< "$response") || return 1
245
+ [ "$page_size" -lt 100 ] && break
246
+ page=$((page + 1))
247
+ done
248
+ printf '%s\n' "$results"
249
+ }
250
+
251
+ _gh_pipeline_artifacts() {
252
+ _gh_pipeline_api "actions/runs/$1/artifacts?per_page=100" |
253
+ jq -c '[.artifacts[] | {job_id: .id, job_name: (.name // ""), artifact_available: ((.expired // true) | not)}]'
254
+ }
255
+
256
+ _gh_download_artifact() {
257
+ local token="${GH_TOKEN:-${GITHUB_TOKEN:-}}"
258
+ curl -sSL --fail \
259
+ -H "Accept: application/vnd.github+json" \
260
+ -H "Authorization: Bearer $token" \
261
+ "${GITHUB_API_URL:-https://api.github.com}/repos/$PIPE_REPO/actions/artifacts/$1/zip" -o "$2"
262
+ }
263
+
264
+ _gh_latest_success_coverage() {
265
+ _gh_pipeline_api "actions/runs?branch=$1&status=success&per_page=1" |
266
+ jq -r '.workflow_runs[0].coverage // ""'
267
+ }
268
+
269
+ # The pull-request API has no updated-since filter, so the newest-first listing
270
+ # is walked until a page carries nothing inside the window.
271
+ _gh_merge_requests_updated_since() {
272
+ local page response page_size kept kept_size results
273
+ page=1
274
+ results='[]'
275
+ while :; do
276
+ response=$(_gh_pipeline_api "pulls?state=all&sort=updated&direction=desc&per_page=100&page=$page") || return 1
277
+ page_size=$(jq -r 'length' <<< "$response") || return 1
278
+ kept=$(jq -c --arg since "$1" \
279
+ '[.[] | select((.updated_at // "") >= $since) | {iid: .number, title: (.title // ""), state: (if .merged_at then "merged" else (.state // "") end), author: (.user.login // ""), labels: [(.labels // [])[].name], created_at: (.created_at // ""), merged_at: .merged_at, target_branch: (.base.ref // ""), description: (.body // "")}]' <<< "$response") || return 1
280
+ kept_size=$(jq -r 'length' <<< "$kept") || return 1
281
+ results=$(jq -nc --argjson results "$results" --argjson kept "$kept" '$results + $kept') || return 1
282
+ [ "$kept_size" -lt "$page_size" ] && break
283
+ [ "$page_size" -lt 100 ] && break
284
+ page=$((page + 1))
285
+ done
286
+ printf '%s\n' "$results"
287
+ }
288
+
289
+ # Merged PRs targeting a branch, newest first, filtered to number > since_iid.
290
+ # GitHub's closed-PR listing has no server-side merged-only filter, so
291
+ # merged_at != null is applied client-side.
292
+ _gh_merged_mrs() {
293
+ # $1=target_branch $2=since_iid $3=limit.
294
+ local response
295
+ response=$(_gh_pipeline_api "pulls?base=$1&state=closed&per_page=$3&sort=created&direction=desc") || return 1
296
+ jq -c --argjson since "$2" '[.[] | select(.merged_at != null and .number > $since) | {iid: .number, title: (.title // ""), created_at: (.created_at // "")}]' <<< "$response"
297
+ }
298
+
299
+ _gh_mr_changes() {
300
+ # $1=iid $2=limit. Changed file paths for one merged PR, capped at limit.
301
+ local response
302
+ response=$(_gh_pipeline_api "pulls/$1/files?per_page=$2") || return 1
303
+ jq -r --argjson limit "$2" '[.[]?.filename] | .[0:$limit][]' <<< "$response" 2>/dev/null
304
+ }
305
+
306
+ # $1=iid $2=limit $3=sort("asc"|"desc"). GitHub returns comments oldest
307
+ # first, so descend requests paginate before reversing and slicing to preserve
308
+ # real newest-first semantics rather than reversing an arbitrary first page.
309
+ _gh_mr_notes() {
310
+ local response filter
311
+ [[ "$2" =~ ^[1-9][0-9]*$ ]] || return 1
312
+ [[ "$3" = "asc" || "$3" = "desc" ]] || return 1
313
+ if [ "$3" = "asc" ]; then
314
+ response=$(gh api "repos/$PIPE_REPO/issues/$1/comments?per_page=$2") || return 1
315
+ else
316
+ response=$(gh api --paginate "repos/$PIPE_REPO/issues/$1/comments?per_page=100") || return 1
317
+ fi
318
+ filter='[.[] | {author: (.user.login // ""), body: (.body // "")}]'
319
+ [ "$3" = "desc" ] && filter="$filter | reverse"
320
+ jq --argjson limit "$2" "$filter | .[0:\$limit]" <<< "$response"
321
+ }
322
+
138
323
  # --- Bot identity -------------------------------------------------------------
139
324
  # The scripts filter MR/PR comments by the bot's username to recognise their own
140
325
  # previous output. The token already knows whose it is, so an empty
@@ -155,12 +340,84 @@ platform_resolve_bot_user() {
155
340
 
156
341
  # --- Public dispatch --------------------------------------------------------
157
342
 
158
- platform_mr_meta() { case "$PIPE_PLATFORM" in gitlab) _gl_mr_meta ;; github) _gh_mr_meta ;; esac; }
159
- platform_mr_labels() { case "$PIPE_PLATFORM" in gitlab) _gl_mr_labels ;; github) _gh_mr_labels ;; esac; }
160
- platform_notes() { case "$PIPE_PLATFORM" in gitlab) _gl_notes ;; github) _gh_notes ;; esac; }
161
- platform_issue() { case "$PIPE_PLATFORM" in gitlab) _gl_issue "$1" ;; github) _gh_issue "$1" ;; esac; }
162
- platform_post_comment() { case "$PIPE_PLATFORM" in gitlab) _gl_post_comment "$1" ;; github) _gh_post_comment "$1" ;; esac; }
163
- platform_set_labels() { case "$PIPE_PLATFORM" in gitlab) _gl_set_labels "$1" "$2" ;; github) _gh_set_labels "$1" "$2" ;; esac; }
343
+ platform_mr_meta() { case "$PIPE_PLATFORM" in gitlab) _gl_mr_meta ;; github) _gh_mr_meta ;; esac; }
344
+ platform_mr_labels() { case "$PIPE_PLATFORM" in gitlab) _gl_mr_labels ;; github) _gh_mr_labels ;; esac; }
345
+ platform_notes() { case "$PIPE_PLATFORM" in gitlab) _gl_notes ;; github) _gh_notes ;; esac; }
346
+ platform_issue() { case "$PIPE_PLATFORM" in gitlab) _gl_issue "$1" ;; github) _gh_issue "$1" ;; esac; }
347
+ platform_post_comment_file() { case "$PIPE_PLATFORM" in gitlab) _gl_post_comment_file "$1" ;; github) _gh_post_comment_file "$1" ;; esac; }
348
+ platform_set_labels() { case "$PIPE_PLATFORM" in gitlab) _gl_set_labels "$1" "$2" ;; github) _gh_set_labels "$1" "$2" ;; esac; }
349
+
350
+ platform_completed_pipelines() {
351
+ [ "$#" -eq 1 ] || return 1
352
+ case "$PIPE_PLATFORM" in
353
+ gitlab) _gl_completed_pipelines "$1" ;;
354
+ github) _gh_completed_pipelines "$1" ;;
355
+ *) return 1 ;;
356
+ esac
357
+ }
358
+
359
+ platform_pipeline_artifacts() {
360
+ [ "$#" -eq 1 ] || return 1
361
+ case "$PIPE_PLATFORM" in
362
+ gitlab) _gl_pipeline_artifacts "$1" ;;
363
+ github) _gh_pipeline_artifacts "$1" ;;
364
+ *) return 1 ;;
365
+ esac
366
+ }
367
+
368
+ platform_download_artifact() {
369
+ [ "$#" -eq 2 ] || return 1
370
+ case "$PIPE_PLATFORM" in
371
+ gitlab) _gl_download_artifact "$1" "$2" ;;
372
+ github) _gh_download_artifact "$1" "$2" ;;
373
+ *) return 1 ;;
374
+ esac
375
+ }
376
+
377
+ platform_latest_success_coverage() {
378
+ [ "$#" -eq 1 ] || return 1
379
+ case "$PIPE_PLATFORM" in
380
+ gitlab) _gl_latest_success_coverage "$1" ;;
381
+ github) _gh_latest_success_coverage "$1" ;;
382
+ *) return 1 ;;
383
+ esac
384
+ }
385
+
386
+ platform_merge_requests_updated_since() {
387
+ [ "$#" -eq 1 ] || return 1
388
+ case "$PIPE_PLATFORM" in
389
+ gitlab) _gl_merge_requests_updated_since "$1" ;;
390
+ github) _gh_merge_requests_updated_since "$1" ;;
391
+ *) return 1 ;;
392
+ esac
393
+ }
394
+
395
+ platform_merged_mrs() {
396
+ [ "$#" -eq 3 ] || return 1
397
+ case "$PIPE_PLATFORM" in
398
+ gitlab) _gl_merged_mrs "$1" "$2" "$3" ;;
399
+ github) _gh_merged_mrs "$1" "$2" "$3" ;;
400
+ *) return 1 ;;
401
+ esac
402
+ }
403
+
404
+ platform_mr_changes() {
405
+ [ "$#" -eq 2 ] || return 1
406
+ case "$PIPE_PLATFORM" in
407
+ gitlab) _gl_mr_changes "$1" "$2" ;;
408
+ github) _gh_mr_changes "$1" "$2" ;;
409
+ *) return 1 ;;
410
+ esac
411
+ }
412
+
413
+ platform_mr_notes() {
414
+ [ "$#" -eq 3 ] || return 1
415
+ case "$PIPE_PLATFORM" in
416
+ gitlab) _gl_mr_notes "$1" "$2" "$3" ;;
417
+ github) _gh_mr_notes "$1" "$2" "$3" ;;
418
+ *) return 1 ;;
419
+ esac
420
+ }
164
421
 
165
422
  # Sets PIPE_DIFF_BASE and PIPE_IS_INCREMENTAL. Call before building the diff.
166
423
  platform_compute_diff_base() {
@@ -0,0 +1,123 @@
1
+ # shellcheck shell=bash
2
+ # Source-only library. Wraps an `omp --mode json` invocation, sums usage/cost
3
+ # from the agent_end event's message transcript, writes a metrics record, and
4
+ # surfaces the assistant text on stdout — the omp equivalent of
5
+ # usage-capture.sh's capture_claude. Same function contract, same metrics
6
+ # record shape (bar the harness-specific exit-code key), different event
7
+ # parsing because omp's schema differs from Claude's stream-json.
8
+ #
9
+ # Schema verified live against a real omp install (18.0.6): the final
10
+ # "agent_end" line carries {"type":"agent_end","messages":[...],
11
+ # "isTerminal":true}. Each assistant message in `messages[]` carries its own
12
+ # `usage` object (per-message, not session-cumulative) and top-level
13
+ # `model`/`provider`/`duration` fields. See
14
+ # docs/superpowers/specs/2026-08-26-omp-openrouter-harness-design.md section 3
15
+ # for the full verification record.
16
+ #
17
+ # Usage: capture_omp <agent_name> -- <args to pass to `omp`...>
18
+ # Stdin: forwarded to `omp` stdin
19
+ # Stdout: concatenated text from assistant turns (drop-in for `omp -p`)
20
+ # Side effect: writes one JSON record to $PIPE_METRICS_DIR
21
+
22
+ set -u
23
+
24
+ capture_omp() {
25
+ local agent="$1"; shift
26
+ [ "${1:-}" = "--" ] && shift
27
+
28
+ local metrics_dir="${PIPE_METRICS_DIR:-${PIPE_CONTEXT_DIR:-build/pipeline}/metrics}"
29
+ mkdir -p "$metrics_dir"
30
+
31
+ local job_name="${PIPE_JOB_NAME:-${CI_JOB_NAME:-job}}"
32
+ local job_id="${PIPE_JOB_ID:-${CI_JOB_ID:-$$}}"
33
+ local pipeline_id="${PIPE_PIPELINE_ID:-${CI_PIPELINE_ID:-}}"
34
+ local mr_iid="${PIPE_MR_IID:-${CI_MERGE_REQUEST_IID:-null}}"
35
+ local issue_iid="${PIPE_ISSUE_IID:-${CODER_ISSUE:-null}}"
36
+
37
+ local stream_file stderr_file
38
+ stream_file=$(mktemp)
39
+ stderr_file=$(mktemp)
40
+ local start_ns end_ns
41
+ start_ns=$(date +%s%N)
42
+ local exit_code=0
43
+
44
+ # Force JSON mode so we always get an agent_end event with the full
45
+ # message transcript.
46
+ omp --mode json "$@" \
47
+ >"$stream_file" 2>"$stderr_file" || exit_code=$?
48
+
49
+ end_ns=$(date +%s%N)
50
+ local wall_ms=$(( (end_ns - start_ns) / 1000000 ))
51
+
52
+ # Extract the final agent_end event (last line that begins with
53
+ # {"type":"agent_end"). Its usage is per-message (one API call each), not
54
+ # pre-aggregated like Claude's single "result" event, so sum it ourselves.
55
+ local result_line
56
+ result_line=$(grep '^{"type":"agent_end"' "$stream_file" | tail -1 || true)
57
+
58
+ local model input output cache_c cache_r cost duration
59
+ if [ -n "$result_line" ]; then
60
+ model=$(echo "$result_line" | jq -r '[.messages[]? | select(.role=="assistant") | ((.provider // "") + "/" + (.model // ""))] | last // ""')
61
+ input=$(echo "$result_line" | jq '[.messages[]? | select(.role=="assistant") | .usage.input // 0] | add // 0')
62
+ output=$(echo "$result_line" | jq '[.messages[]? | select(.role=="assistant") | .usage.output // 0] | add // 0')
63
+ cache_c=$(echo "$result_line" | jq '[.messages[]? | select(.role=="assistant") | .usage.cacheWrite // 0] | add // 0')
64
+ cache_r=$(echo "$result_line" | jq '[.messages[]? | select(.role=="assistant") | .usage.cacheRead // 0] | add // 0')
65
+ cost=$(echo "$result_line" | jq '[.messages[]? | select(.role=="assistant") | .usage.cost.total // 0] | add // 0')
66
+ duration=$(echo "$result_line" | jq '[.messages[]? | select(.role=="assistant") | .duration // 0] | add // 0')
67
+ else
68
+ model=""; input=0; output=0; cache_c=0; cache_r=0; cost=0; duration=$wall_ms
69
+ fi
70
+ [ -n "${PIPE_CAPTURE_MODEL:-}" ] && [ -z "$model" ] && model="$PIPE_CAPTURE_MODEL"
71
+
72
+ # Recover the user-facing text (concatenate all assistant text blocks) so
73
+ # downstream consumers that read `omp -p`'s stdout still work.
74
+ grep '^{"type":"message_end"' "$stream_file" \
75
+ | jq -r 'select(.message.role=="assistant") | .message.content[]? | select(.type=="text") | .text' \
76
+ || true
77
+
78
+ # Write metrics record. Same field names as capture_claude's record; only
79
+ # the harness-specific exit-code key differs (omp_exit_code vs
80
+ # claude_exit_code), by the same convention the two capture_* files
81
+ # already use for their own harness-labelled fields.
82
+ local record_stamp record_file
83
+ record_stamp=$(date +%s%N)
84
+ record_file="$metrics_dir/${job_name}-${job_id}-${record_stamp}.json"
85
+ jq -n \
86
+ --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
87
+ --arg agent "$agent" \
88
+ --arg event "omp_invocation" \
89
+ --arg model "$model" \
90
+ --arg pipeline_id "$pipeline_id" \
91
+ --arg job_id "$job_id" \
92
+ --arg job_name "$job_name" \
93
+ --argjson mr_iid "$mr_iid" \
94
+ --argjson issue_iid "$issue_iid" \
95
+ --argjson input "$input" \
96
+ --argjson output "$output" \
97
+ --argjson cache_c "$cache_c" \
98
+ --argjson cache_r "$cache_r" \
99
+ --argjson cost "$cost" \
100
+ --argjson duration "$duration" \
101
+ --argjson wall_ms "$wall_ms" \
102
+ --argjson exit_code "$exit_code" \
103
+ '{
104
+ ts: $ts, agent: $agent, event: $event, model: $model,
105
+ pipeline_id: $pipeline_id, job_id: $job_id, job_name: $job_name,
106
+ mr_iid: $mr_iid, issue_iid: $issue_iid,
107
+ input_tokens: $input, output_tokens: $output,
108
+ cache_creation_tokens: $cache_c, cache_read_tokens: $cache_r,
109
+ total_cost_usd: $cost, duration_ms: $duration, wall_ms: $wall_ms,
110
+ omp_exit_code: $exit_code
111
+ }' > "$record_file"
112
+
113
+ # If the agent failed, forward stderr so debugging still works, and keep a
114
+ # durable copy: the failure detector runs after the invocation returns and
115
+ # cannot read a temp file the wrapper already deleted.
116
+ if [ -n "${PIPE_AGENT_STDERR:-}" ]; then
117
+ mkdir -p "$(dirname "$PIPE_AGENT_STDERR")" 2>/dev/null || true
118
+ cp "$stderr_file" "$PIPE_AGENT_STDERR" 2>/dev/null || true
119
+ fi
120
+ [ "$exit_code" -ne 0 ] && cat "$stderr_file" >&2
121
+ rm -f "$stream_file" "$stderr_file"
122
+ return "$exit_code"
123
+ }
@@ -103,7 +103,13 @@ capture_claude() {
103
103
  claude_exit_code: $exit_code
104
104
  }' > "$record_file"
105
105
 
106
- # If Claude failed, forward stderr so debugging still works
106
+ # If the agent failed, forward stderr so debugging still works, and keep a
107
+ # durable copy: the failure detector runs after the invocation returns and
108
+ # cannot read a temp file the wrapper already deleted.
109
+ if [ -n "${PIPE_AGENT_STDERR:-}" ]; then
110
+ mkdir -p "$(dirname "$PIPE_AGENT_STDERR")" 2>/dev/null || true
111
+ cp "$stderr_file" "$PIPE_AGENT_STDERR" 2>/dev/null || true
112
+ fi
107
113
  [ "$exit_code" -ne 0 ] && cat "$stderr_file" >&2
108
114
  rm -f "$stream_file" "$stderr_file"
109
115
  return "$exit_code"