@cxi-lmai/ci-agent-platform 3.0.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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +219 -0
  3. package/bin/init.mjs +236 -0
  4. package/package.json +47 -0
  5. package/payload/INSTALL.md +113 -0
  6. package/payload/agents/agent-architect.md +101 -0
  7. package/payload/agents/code-reviewer.md +87 -0
  8. package/payload/agents/codebase-auditor.md +73 -0
  9. package/payload/agents/coder.md +56 -0
  10. package/payload/agents/decomposer.md +70 -0
  11. package/payload/agents/docs-sync.md +115 -0
  12. package/payload/agents/e2e-test-writer.md +47 -0
  13. package/payload/agents/migration-reviewer.md +100 -0
  14. package/payload/agents/orchestrator.md +50 -0
  15. package/payload/agents/performance-reviewer.md +82 -0
  16. package/payload/agents/postmortem.md +83 -0
  17. package/payload/agents/release-mr.md +274 -0
  18. package/payload/agents/security-reviewer.md +122 -0
  19. package/payload/agents/test-fix.md +33 -0
  20. package/payload/agents/test-writer.md +40 -0
  21. package/payload/ci-templates/claude-pipeline.gitlab-ci.yml +233 -0
  22. package/payload/ci-templates/github/README.md +76 -0
  23. package/payload/ci-templates/github/claude-issue-pipeline.yml +141 -0
  24. package/payload/ci-templates/github/claude-pipeline.yml +141 -0
  25. package/payload/ci-templates/github/claude-test-fix.yml +104 -0
  26. package/payload/ci-templates/scripts/code.sh +114 -0
  27. package/payload/ci-templates/scripts/lib/issue-loop.sh +430 -0
  28. package/payload/ci-templates/scripts/lib/pipeline-common.sh +280 -0
  29. package/payload/ci-templates/scripts/lib/platform.sh +177 -0
  30. package/payload/ci-templates/scripts/lib/usage-capture.sh +110 -0
  31. package/payload/ci-templates/scripts/orchestrate.sh +294 -0
  32. package/payload/ci-templates/scripts/postmortem.sh +45 -0
  33. package/payload/ci-templates/scripts/review-fix.sh +90 -0
  34. package/payload/ci-templates/scripts/review.sh +93 -0
  35. package/payload/ci-templates/scripts/test-fix.sh +58 -0
  36. package/payload/skills/fix-review-findings/SKILL.md +79 -0
  37. package/payload/skills/fix-tests/SKILL.md +70 -0
  38. package/payload/skills/implement-issue/SKILL.md +62 -0
  39. package/payload/skills/init-pipeline-config/SKILL.md +96 -0
  40. package/payload/skills/postmortem-mr/SKILL.md +50 -0
  41. package/payload/skills/review-mr/SKILL.md +82 -0
  42. package/payload/skills/triage-issue/SKILL.md +74 -0
  43. package/payload/templates/pipeline-config.template.md +98 -0
  44. package/payload/templates/review_suppressions.template.md +25 -0
  45. package/payload/templates/spec-issue.template.md +64 -0
@@ -0,0 +1,430 @@
1
+ # shellcheck shell=bash
2
+ # Source-only library. Platform abstraction for the issue -> code loop, so the
3
+ # orchestrate.sh and code.sh runners read the same functions on GitLab and
4
+ # GitHub. It complements platform.sh (which covers the MR/PR review loop).
5
+ #
6
+ # Selection: $PIPE_PLATFORM ("gitlab" or "github"), set by pipe_detect_platform.
7
+ # Runtime variables expected (set by each platform's CI template):
8
+ #
9
+ # GitLab:
10
+ # PIPE_API_URL $CI_API_V4_URL
11
+ # PIPE_PROJECT_ID $CI_PROJECT_ID
12
+ # PIPE_TOKEN bot token with api + write_repository (triage, MR author)
13
+ # PIPE_TRIGGER_TOKEN pipeline trigger token (POST /projects/:id/pipeline)
14
+ # GitHub:
15
+ # PIPE_REPO owner/repo (for `gh api`)
16
+ # GH_TOKEN bot PAT (PIPE_BOT_TOKEN) with issues + contents + PR
17
+ # PIPE_CODE_WORKFLOW filename of the code workflow to dispatch (default claude-issue-pipeline.yml)
18
+ #
19
+ # Blocking model (decision D1): GitHub has no native is_blocked_by. This loop
20
+ # uses the text convention "Blocked by #N" in the issue description. GitLab uses
21
+ # native issue links. See issue_blockers_open_count / issue_children.
22
+ #
23
+ # All functions print to stdout and never call `exit`.
24
+
25
+ set -u
26
+
27
+ # =============================================================================
28
+ # GitLab helpers
29
+ # =============================================================================
30
+
31
+ _gli_api() { echo "$PIPE_API_URL/projects/$PIPE_PROJECT_ID"; }
32
+
33
+ _gli_ensure_label() {
34
+ # $1=name $2=color $3=desc. Project-level (generic: no group-admin needed).
35
+ # The original used group-level labels shared across a group; the template keeps
36
+ # them project-level so any project works without namespace privileges.
37
+ local http
38
+ http=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
39
+ -H "PRIVATE-TOKEN: $PIPE_TOKEN" -H "Content-Type: application/json" \
40
+ "$(_gli_api)/labels" \
41
+ -d "$(jq -n --arg n "$1" --arg c "$2" --arg d "$3" '{name:$n,color:$c,description:$d}')")
42
+ # 201 created, 409 already exists: both fine.
43
+ [ "$http" = "201" ] || [ "$http" = "409" ] || echo "[pipeline] WARNING: label '$1' ensure returned HTTP $http" >&2
44
+ }
45
+
46
+ _gli_issues_by_label() {
47
+ # $1=label. Returns JSON array of {iid,title,description,labels,milestone_id}.
48
+ curl -s -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
49
+ "$(_gli_api)/issues?labels=$1&state=opened&per_page=${PIPE_ISSUE_SCAN:-20}" \
50
+ | jq '[.[] | {iid, title: (.title // ""), description: (.description // ""),
51
+ labels: ((.labels // []) | join(",")),
52
+ milestone_id: (.milestone.id // null)}]'
53
+ }
54
+
55
+ _gli_issues_closed_by_label() {
56
+ # $1=label. Closed issues still carrying a lifecycle label (janitor input).
57
+ curl -s -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
58
+ "$(_gli_api)/issues?labels=$1&state=closed&per_page=${PIPE_ISSUE_SCAN:-20}" \
59
+ | jq '[.[] | {iid}]'
60
+ }
61
+
62
+ _gli_issue_notes() {
63
+ # $1=iid. Non-system notes, newest first. The caller (orchestrate.sh) caps
64
+ # the total volume and adds a visible truncation marker, so the newest and
65
+ # most relevant comment survives when a long discussion is trimmed.
66
+ curl -s -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
67
+ "$(_gli_api)/issues/$1/notes?per_page=50&sort=desc" \
68
+ | jq -r '[.[] | select(.system == false) | "**" + .author.name + ":** " + .body] | join("\n\n")'
69
+ }
70
+
71
+ _gli_issue_body() {
72
+ # $1=iid. Description only.
73
+ curl -s -H "PRIVATE-TOKEN: $PIPE_TOKEN" "$(_gli_api)/issues/$1" | jq -r '.description // ""'
74
+ }
75
+
76
+ _gli_issue_meta() {
77
+ # $1=iid. Returns {iid,title,description,labels}.
78
+ curl -s -H "PRIVATE-TOKEN: $PIPE_TOKEN" "$(_gli_api)/issues/$1" \
79
+ | jq '{iid, title: (.title // ""), description: (.description // ""), labels: ((.labels // []) | join(","))}'
80
+ }
81
+
82
+ _gli_issue_state() {
83
+ # $1=iid. Prints "opened" or "closed".
84
+ curl -s -H "PRIVATE-TOKEN: $PIPE_TOKEN" "$(_gli_api)/issues/$1" | jq -r '.state // "opened"'
85
+ }
86
+
87
+ _gli_blockers_open_count() {
88
+ # $1=iid. Count of open issues that block this one (native links).
89
+ curl -s -H "PRIVATE-TOKEN: $PIPE_TOKEN" "$(_gli_api)/issues/$1/links" \
90
+ | jq '[.[] | select(.link_type == "is_blocked_by" and .state == "opened")] | length' 2>/dev/null || echo 1
91
+ }
92
+
93
+ _gli_children_state() {
94
+ # $1=iid. Prints "<total> <open>". New installs keep native links when
95
+ # available; the comment marker fallback also supports already-decomposed
96
+ # parents and GitLab editions where issue-link behavior differs.
97
+ local links total open kids n
98
+ links=$(curl -s -H "PRIVATE-TOKEN: $PIPE_TOKEN" "$(_gli_api)/issues/$1/links")
99
+ total=$(echo "$links" | jq '[.[] | select(.link_type == "blocks")] | length' 2>/dev/null || echo 0)
100
+ open=$(echo "$links" | jq '[.[] | select(.link_type == "blocks" and .state == "opened")] | length' 2>/dev/null || echo 1)
101
+ if [ "${total:-0}" = "0" ]; then
102
+ kids=$(curl -s -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
103
+ "$(_gli_api)/issues/$1/notes?per_page=100&sort=desc" \
104
+ | jq -r '.[].body // empty' 2>/dev/null \
105
+ | grep -oE '<!-- pipe-children:[^>]*-->' \
106
+ | head -1 | grep -oE '#[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
107
+ total=0; open=0
108
+ for n in $kids; do
109
+ total=$((total + 1))
110
+ [ "$(_gli_issue_state "$n")" = "opened" ] && open=$((open + 1))
111
+ done
112
+ fi
113
+ echo "$total $open"
114
+ }
115
+
116
+ _gli_create_issue() {
117
+ # $1=title $2=desc $3=labels(comma). Prints new iid or empty.
118
+ curl -s -X POST -H "PRIVATE-TOKEN: $PIPE_TOKEN" -H "Content-Type: application/json" \
119
+ "$(_gli_api)/issues" \
120
+ -d "$(jq -n --arg t "$1" --arg b "$2" --arg l "$3" '{title:$t,description:$b,labels:$l}')" \
121
+ | jq -r '.iid // empty'
122
+ }
123
+
124
+ _gli_link_blocked_by() {
125
+ # $1=blocked_iid $2=blocking_iid
126
+ curl -s -X POST -H "PRIVATE-TOKEN: $PIPE_TOKEN" -H "Content-Type: application/json" \
127
+ "$(_gli_api)/issues/$1/links" \
128
+ -d "$(jq -n --argjson t "$2" '{target_issue_iid:$t, link_type:"is_blocked_by"}')" > /dev/null 2>&1 || true
129
+ }
130
+
131
+ _gli_set_issue_labels() {
132
+ # $1=iid $2=add(comma) $3=remove(comma). Returns HTTP-independent (best effort).
133
+ curl -s -X PUT -H "PRIVATE-TOKEN: $PIPE_TOKEN" -H "Content-Type: application/json" \
134
+ "$(_gli_api)/issues/$1" \
135
+ -d "$(jq -n --arg a "$2" --arg r "$3" '{add_labels:$a, remove_labels:$r}')" > /dev/null
136
+ }
137
+
138
+ _gli_close_issue() {
139
+ curl -s -X PUT -H "PRIVATE-TOKEN: $PIPE_TOKEN" -H "Content-Type: application/json" \
140
+ "$(_gli_api)/issues/$1" -d '{"state_event":"close"}' > /dev/null || true
141
+ }
142
+
143
+ _gli_issue_comment() {
144
+ curl -s -X POST -H "PRIVATE-TOKEN: $PIPE_TOKEN" "$(_gli_api)/issues/$1/notes" \
145
+ --data-urlencode "body=$2" > /dev/null
146
+ }
147
+
148
+ _gli_code_trigger() {
149
+ # $1=iid $2=branch. Fires the coder via the Pipeline Trigger API. Returns 0/1.
150
+ # On failure the API response is logged so token/permission problems are
151
+ # visible in the job log instead of a bare "trigger failed".
152
+ # Trigger tokens (glptt-*) are only accepted as the `token` form field of
153
+ # POST /projects/:id/trigger/pipeline. Passing one as PRIVATE-TOKEN to
154
+ # /projects/:id/pipeline returns 401 (pilot 2 finding, P7).
155
+ local out
156
+ out=$(curl --fail-with-body -s -X POST \
157
+ --form "token=$PIPE_TRIGGER_TOKEN" \
158
+ --form "ref=$PIPE_TARGET_BRANCH" \
159
+ --form "variables[CODER_ISSUE]=$1" \
160
+ --form "variables[CODER_BRANCH]=$2" \
161
+ "$(_gli_api)/trigger/pipeline" 2>&1) || {
162
+ echo "[pipeline] code trigger API error: $out" >&2
163
+ return 1
164
+ }
165
+ }
166
+
167
+ _gli_mr_create() {
168
+ # $1=source $2=target $3=title $4=desc $5=labels(comma). Prints web_url.
169
+ curl --fail-with-body -s -X POST -H "PRIVATE-TOKEN: $PIPE_TOKEN" -H "Content-Type: application/json" \
170
+ "$(_gli_api)/merge_requests" \
171
+ -d "$(jq -n --arg s "$1" --arg t "$2" --arg ti "$3" --arg d "$4" --arg l "$5" \
172
+ '{source_branch:$s, target_branch:$t, title:$ti, description:$d, labels:$l, remove_source_branch:true}')" \
173
+ | jq -r '.web_url // empty'
174
+ }
175
+
176
+ # =============================================================================
177
+ # GitHub helpers (gh CLI, GH_TOKEN)
178
+ # =============================================================================
179
+
180
+ _ghi_ensure_label() {
181
+ # $1=name $2=color(hex, no #) $3=desc. Repo-level.
182
+ gh api -X POST "repos/$PIPE_REPO/labels" \
183
+ -f "name=$1" -f "color=${2#\#}" -f "description=$3" > /dev/null 2>&1 || true
184
+ }
185
+
186
+ _ghi_issues_by_label() {
187
+ # $1=label. Returns JSON array; filters out pull requests (they share the
188
+ # issues endpoint but carry a .pull_request field).
189
+ gh api --paginate "repos/$PIPE_REPO/issues?labels=$1&state=open&per_page=${PIPE_ISSUE_SCAN:-20}" \
190
+ | jq -s 'add // []' \
191
+ | jq '[.[] | select(has("pull_request") | not)
192
+ | {iid: .number, title: (.title // ""), description: (.body // ""),
193
+ labels: ((.labels // []) | map(.name) | join(",")),
194
+ milestone_id: (.milestone.number // null)}]'
195
+ }
196
+
197
+ _ghi_issues_closed_by_label() {
198
+ # $1=label. Closed issues still carrying a lifecycle label (janitor input).
199
+ # PRs share the issues endpoint; keep issues only, closed PR labels stay
200
+ # as history.
201
+ gh api "repos/$PIPE_REPO/issues?labels=$1&state=closed&per_page=${PIPE_ISSUE_SCAN:-20}" \
202
+ | jq '[.[] | select(has("pull_request") | not) | {iid: .number}]'
203
+ }
204
+
205
+ _ghi_issue_notes() {
206
+ # $1=iid. Comments newest first (GitHub returns them oldest first, so reverse).
207
+ # The caller (orchestrate.sh) caps the total volume and adds a visible
208
+ # truncation marker, so the newest comment survives when the discussion is long.
209
+ gh api --paginate "repos/$PIPE_REPO/issues/$1/comments?per_page=100" \
210
+ | jq -s -r 'add // [] | reverse | .[] | "**" + .user.login + ":** " + .body' || true
211
+ }
212
+
213
+ _ghi_issue_body() {
214
+ gh api "repos/$PIPE_REPO/issues/$1" --jq '.body // ""'
215
+ }
216
+
217
+ _ghi_issue_meta() {
218
+ # $1=iid. Returns {iid,title,description,labels}.
219
+ gh api "repos/$PIPE_REPO/issues/$1" \
220
+ | jq '{iid: .number, title: (.title // ""), description: (.body // ""), labels: ((.labels // []) | map(.name) | join(","))}'
221
+ }
222
+
223
+ _ghi_issue_state() {
224
+ # Prints "opened" or "closed" (normalised to the GitLab vocabulary).
225
+ local s; s=$(gh api "repos/$PIPE_REPO/issues/$1" --jq '.state // "open"' 2>/dev/null || echo open)
226
+ [ "$s" = "closed" ] && echo closed || echo opened
227
+ }
228
+
229
+ _ghi_link_blocked_by() {
230
+ # GitHub has no native blocking link. Persist the real issue number in the
231
+ # dependent issue body after all local decomposition keys have been mapped.
232
+ # The marker is visible and deliberately idempotent because the release poll
233
+ # parses the same "Blocked by #N" convention.
234
+ local blocked="$1" blocker="$2" body marker
235
+ body=$(_ghi_issue_body "$blocked")
236
+ marker="Blocked by #$blocker"
237
+ printf '%s\n' "$body" | grep -qiE "(^|[[:space:]])Blocked by #${blocker}([^0-9]|$)" && return 0
238
+ gh api -X PATCH "repos/$PIPE_REPO/issues/$blocked" \
239
+ -f "body=${body}${body:+$'\n\n'}$marker" > /dev/null
240
+ }
241
+
242
+ _ghi_create_issue() {
243
+ # $1=title $2=desc $3=labels(comma). Prints new number.
244
+ local args=() l
245
+ IFS=',' read -ra _labels <<< "$3"
246
+ for l in "${_labels[@]}"; do [ -n "$l" ] && args+=(-f "labels[]=$l"); done
247
+ gh api -X POST "repos/$PIPE_REPO/issues" -f "title=$1" -f "body=$2" "${args[@]}" \
248
+ --jq '.number // empty'
249
+ }
250
+
251
+ _ghi_set_issue_labels() {
252
+ # $1=iid $2=add(comma) $3=remove(comma)
253
+ local l
254
+ local IFS=','
255
+ for l in $2; do [ -n "$l" ] && gh api -X POST "repos/$PIPE_REPO/issues/$1/labels" -f "labels[]=$l" > /dev/null 2>&1 || true; done
256
+ for l in $3; do [ -n "$l" ] && gh api -X DELETE "repos/$PIPE_REPO/issues/$1/labels/$l" > /dev/null 2>&1 || true; done
257
+ }
258
+
259
+ _ghi_close_issue() {
260
+ gh api -X PATCH "repos/$PIPE_REPO/issues/$1" -f "state=closed" > /dev/null 2>&1 || true
261
+ }
262
+
263
+ _ghi_issue_comment() {
264
+ gh api -X POST "repos/$PIPE_REPO/issues/$1/comments" -f "body=$2" > /dev/null
265
+ }
266
+
267
+ _ghi_code_trigger() {
268
+ # $1=iid $2=branch. Dispatches the code workflow with explicit inputs. Uses
269
+ # the bot PAT (already in GH_TOKEN) so the coder's push and PR re-trigger
270
+ # downstream workflows. On failure the API response is logged so token
271
+ # permission problems (Actions write) are visible in the job log.
272
+ local out
273
+ out=$(gh api -X POST "repos/$PIPE_REPO/actions/workflows/${PIPE_CODE_WORKFLOW:-claude-issue-pipeline.yml}/dispatches" \
274
+ -f "ref=$PIPE_TARGET_BRANCH" \
275
+ -f "inputs[coder_issue]=$1" -f "inputs[coder_branch]=$2" 2>&1) || {
276
+ echo "[pipeline] code dispatch API error: $out" >&2
277
+ return 1
278
+ }
279
+ }
280
+
281
+ _ghi_pr_create() {
282
+ # $1=source $2=target $3=title $4=desc $5=labels(comma). Prints html_url.
283
+ # Created as a draft and flipped to ready only after the labels are on
284
+ # (finding P5): GitHub cannot attach labels at PR creation, so a non-draft
285
+ # PR emits an `opened` + `labeled` event pair and the review workflow runs
286
+ # twice. Draft PRs do not trigger the review job, which makes the single
287
+ # `ready_for_review` event the only trigger. Falls back to a plain PR when
288
+ # the plan does not support drafts on this repo (free plan, private repo).
289
+ local url num
290
+ url=$(gh api -X POST "repos/$PIPE_REPO/pulls" \
291
+ -f "head=$1" -f "base=$2" -f "title=$3" -f "body=$4" -F "draft=true" --jq '.html_url // empty' 2>/dev/null || true)
292
+ if [ -z "$url" ]; then
293
+ url=$(gh api -X POST "repos/$PIPE_REPO/pulls" \
294
+ -f "head=$1" -f "base=$2" -f "title=$3" -f "body=$4" --jq '.html_url // empty' 2>/dev/null || true)
295
+ fi
296
+ num=$(echo "$url" | grep -oE '[0-9]+$' || true)
297
+ if [ -n "$num" ] && [ -n "$5" ]; then
298
+ # Labels go through the issues API (a PR is an issue on GitHub).
299
+ _ghi_set_issue_labels "$num" "$5" ""
300
+ fi
301
+ # Flip to ready (a no-op when the fallback already created a non-draft PR).
302
+ { [ -n "$num" ] && gh pr ready "$num" -R "$PIPE_REPO" > /dev/null 2>&1; } || true
303
+ echo "$url"
304
+ }
305
+
306
+ # =============================================================================
307
+ # Public dispatch
308
+ # =============================================================================
309
+
310
+ issue_ensure_label() { case "$PIPE_PLATFORM" in gitlab) _gli_ensure_label "$1" "$2" "$3" ;; github) _ghi_ensure_label "$1" "$2" "$3" ;; esac; }
311
+ issues_by_label() { case "$PIPE_PLATFORM" in gitlab) _gli_issues_by_label "$1" ;; github) _ghi_issues_by_label "$1" ;; esac; }
312
+ issues_closed_by_label() { case "$PIPE_PLATFORM" in gitlab) _gli_issues_closed_by_label "$1" ;; github) _ghi_issues_closed_by_label "$1" ;; esac; }
313
+ issue_notes() { case "$PIPE_PLATFORM" in gitlab) _gli_issue_notes "$1" ;; github) _ghi_issue_notes "$1" ;; esac; }
314
+ issue_body() { case "$PIPE_PLATFORM" in gitlab) _gli_issue_body "$1" ;; github) _ghi_issue_body "$1" ;; esac; }
315
+ issue_meta() { case "$PIPE_PLATFORM" in gitlab) _gli_issue_meta "$1" ;; github) _ghi_issue_meta "$1" ;; esac; }
316
+ issue_state() { case "$PIPE_PLATFORM" in gitlab) _gli_issue_state "$1" ;; github) _ghi_issue_state "$1" ;; esac; }
317
+ issue_create() { case "$PIPE_PLATFORM" in gitlab) _gli_create_issue "$1" "$2" "$3" ;; github) _ghi_create_issue "$1" "$2" "$3" ;; esac; }
318
+ issue_set_labels() { case "$PIPE_PLATFORM" in gitlab) _gli_set_issue_labels "$1" "$2" "$3" ;; github) _ghi_set_issue_labels "$1" "$2" "$3" ;; esac; }
319
+ issue_close() { case "$PIPE_PLATFORM" in gitlab) _gli_close_issue "$1" ;; github) _ghi_close_issue "$1" ;; esac; }
320
+ issue_comment() { case "$PIPE_PLATFORM" in gitlab) _gli_issue_comment "$1" "$2" ;; github) _ghi_issue_comment "$1" "$2" ;; esac; }
321
+ code_trigger() { case "$PIPE_PLATFORM" in gitlab) _gli_code_trigger "$1" "$2" ;; github) _ghi_code_trigger "$1" "$2" ;; esac; }
322
+ mr_create() { case "$PIPE_PLATFORM" in gitlab) _gli_mr_create "$1" "$2" "$3" "$4" "$5" ;; github) _ghi_pr_create "$1" "$2" "$3" "$4" "$5" ;; esac; }
323
+ issue_link_blocked_by() { case "$PIPE_PLATFORM" in gitlab) _gli_link_blocked_by "$1" "$2" ;; github) _ghi_link_blocked_by "$1" "$2" ;; esac; }
324
+
325
+ # Count how many issues that block $1 are still open. GitLab uses native links,
326
+ # GitHub parses "Blocked by #N" from the issue body (decision D1, text convention).
327
+ issue_blockers_open_count() {
328
+ case "$PIPE_PLATFORM" in
329
+ gitlab) _gli_blockers_open_count "$1" ;;
330
+ github)
331
+ local body refs n open=0
332
+ body=$(_ghi_issue_body "$1")
333
+ # Match "Blocked by #12" / "blocked by #12, #13" case-insensitively.
334
+ refs=$(printf '%s' "$body" | grep -ioE 'blocked by[^\n]*' | grep -oE '#[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
335
+ for n in $refs; do
336
+ [ "$(_ghi_issue_state "$n")" = "opened" ] && open=$((open + 1))
337
+ done
338
+ echo "$open"
339
+ ;;
340
+ esac
341
+ }
342
+
343
+ # Prints "<total> <open>" child count for a decomposed parent.
344
+ # GitLab reads native links and falls back to the machine marker. GitHub parses
345
+ # the machine marker from the decomposition comment. The marker lives in a
346
+ # comment, not in the issue body, so both implementations query comments.
347
+ issue_children_state() {
348
+ case "$PIPE_PLATFORM" in
349
+ gitlab) _gli_children_state "$1" ;;
350
+ github)
351
+ local kids n total=0 open=0
352
+ kids=$(gh api --paginate "repos/$PIPE_REPO/issues/$1/comments?per_page=100" \
353
+ --jq '.[].body // empty' 2>/dev/null \
354
+ | grep -oE '<!-- pipe-children:[^>]*-->' \
355
+ | tail -1 | grep -oE '#[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
356
+ for n in $kids; do
357
+ total=$((total + 1))
358
+ [ "$(_ghi_issue_state "$n")" = "opened" ] && open=$((open + 1))
359
+ done
360
+ echo "$total $open"
361
+ ;;
362
+ esac
363
+ }
364
+
365
+ # =============================================================================
366
+ # Decomposition validation (mechanical gate, runner side)
367
+ # =============================================================================
368
+ # Deterministic re-check of the decomposition JSON the triage-issue skill
369
+ # produced, before the runner mutates the platform (creates sub-issues). The
370
+ # skill is instructed to self-validate; this is the runner's defensive gate.
371
+ # Written in jq (already a hard dependency everywhere in this pipeline), so it
372
+ # needs no extra runtime such as python3.
373
+ #
374
+ # IMPORTANT: the 8 required section headers and the placeholder tokens below MUST
375
+ # stay in sync with the spec template shipped in
376
+ # templates/spec-issue.template.md. If you rename a section
377
+ # header or change a placeholder token in the template, update both lists here.
378
+ DECOMP_REJECT_REASON=""
379
+ pipe_validate_decomposition() {
380
+ # Prints the first problem to stdout on failure (empty on success). Sets
381
+ # DECOMP_REJECT_REASON and returns 1 when invalid, 0 when valid.
382
+ local json="$1"
383
+ DECOMP_REJECT_REASON=""
384
+ local reason
385
+ reason=$(printf '%s' "$json" | jq -r '
386
+ def required: ["## 1. Outcome","## 2. Context","## 3. Acceptance Criteria",
387
+ "## 4. Scope Envelope","## 5. Existing Code Anchors","## 6. Risk Flags",
388
+ "## 7. Test Plan","## 8. Out of Scope"];
389
+ def placeholders: ["<one sentence","<why now","<at least one","<what will be tested","TODO"];
390
+
391
+ (.confidence // "") as $conf
392
+ | (.sub_issues // []) as $subs
393
+ | [ $subs[].key ] as $keys
394
+ | if ($conf != "high" and $conf != "low") then "confidence must be high or low, got: \($conf)"
395
+ elif (($subs | length) < 2 or ($subs | length) > 5) then "sub_issues count must be 2-5, got: \($subs | length)"
396
+ elif ($keys | any(. == null or . == "")) then "every sub_issue needs a non-empty key"
397
+ else
398
+ ( [ $subs[] | (.depends_on // [])[] as $d | select(($keys | index($d)) == null) | $d ] | first ) as $unknown
399
+ | if $unknown != null then "depends_on references unknown key: \($unknown)"
400
+ else
401
+ ( [ $subs[]
402
+ | . as $s
403
+ | (($s.spec_markdown // "")) as $md
404
+ | ( required | map(. as $r | select(($md | contains($r)) | not)) ) as $miss
405
+ | if ($miss | length) > 0 then "sub_issue \($s.key) missing sections: \($miss | join(", "))"
406
+ elif (placeholders | any(. as $p | $md | contains($p))) then "sub_issue \($s.key) contains unfilled placeholder text"
407
+ else empty end ] | first ) as $secfail
408
+ | if $secfail != null then $secfail
409
+ else
410
+ # Cycle check: iteratively drop nodes whose deps are all removed.
411
+ ( { rem: [ $subs[] | {key: .key, deps: (.depends_on // [])} ] }
412
+ | reduce range(0; ($subs | length)) as $_ (.;
413
+ (.rem | map(.key)) as $present
414
+ | (.rem | map(. as $e
415
+ | select(([ $e.deps[] as $d | select(($present | index($d)) != null) ] | length) == 0))) as $ready
416
+ | ($ready | map(.key)) as $rk
417
+ | if ($ready | length) == 0 then .
418
+ else { rem: (.rem | map(. as $e | select(($rk | index($e.key)) == null))) } end)
419
+ | if (.rem | length) > 0 then "depends_on graph contains a cycle" else "" end )
420
+ end
421
+ end
422
+ end
423
+ ' 2>/dev/null || echo "invalid JSON")
424
+ if [ -n "$reason" ]; then
425
+ # shellcheck disable=SC2034 # read by orchestrate.sh once this returns 1
426
+ DECOMP_REJECT_REASON="$reason"
427
+ return 1
428
+ fi
429
+ return 0
430
+ }