@opengsd/gsd-core 1.19.0 → 1.20.1

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 (131) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-code-fixer.compact.md +5 -5
  4. package/agents/gsd-code-fixer.md +9 -4
  5. package/agents/gsd-code-reviewer.compact.md +49 -8
  6. package/agents/gsd-code-reviewer.md +53 -14
  7. package/agents/gsd-executor.md +8 -4
  8. package/bin/install.js +36 -40
  9. package/commands/gsd/new-milestone.md +2 -2
  10. package/commands/gsd/new-project.md +2 -2
  11. package/commands/gsd/update.md +5 -2
  12. package/gsd-core/bin/gsd-tools.cjs +177 -93
  13. package/gsd-core/bin/lib/capability-lifecycle.cjs +1 -12
  14. package/gsd-core/bin/lib/capability-registry.cjs +284 -93
  15. package/gsd-core/bin/lib/capability-validator.cjs +15 -0
  16. package/gsd-core/bin/lib/commands.cjs +46 -17
  17. package/gsd-core/bin/lib/config-loader.cjs +3 -3
  18. package/gsd-core/bin/lib/core-utils.cjs +11 -0
  19. package/gsd-core/bin/lib/frontmatter.cjs +21 -3
  20. package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +2 -2
  21. package/gsd-core/bin/lib/gate-context-drift.cjs +1 -1
  22. package/gsd-core/bin/lib/gate-phase-context.cjs +7 -8
  23. package/gsd-core/bin/lib/gate-schema-drift.cjs +1 -1
  24. package/gsd-core/bin/lib/git-info-exclude.cjs +1 -6
  25. package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
  26. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +13 -9
  27. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +9 -7
  28. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +3 -3
  29. package/gsd-core/bin/lib/init.cjs +181 -68
  30. package/gsd-core/bin/lib/install-engine.cjs +9 -9
  31. package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
  32. package/gsd-core/bin/lib/install-profiles.cjs +4 -2
  33. package/gsd-core/bin/lib/io.cjs +6 -0
  34. package/gsd-core/bin/lib/markdown-sectionizer.cjs +34 -1
  35. package/gsd-core/bin/lib/markdown-table.cjs +23 -0
  36. package/gsd-core/bin/lib/mcp-server.cjs +8 -5
  37. package/gsd-core/bin/lib/milestone.cjs +18 -50
  38. package/gsd-core/bin/lib/phase-entry-grammar.cjs +119 -0
  39. package/gsd-core/bin/lib/phase-id-display.cjs +2 -0
  40. package/gsd-core/bin/lib/phase-id.cjs +627 -36
  41. package/gsd-core/bin/lib/phase-lifecycle.cjs +9 -74
  42. package/gsd-core/bin/lib/phase-locator.cjs +328 -19
  43. package/gsd-core/bin/lib/phase.cjs +3039 -666
  44. package/gsd-core/bin/lib/planning-document.cjs +26 -5
  45. package/gsd-core/bin/lib/planning-inspect.cjs +27 -35
  46. package/gsd-core/bin/lib/planning-snapshot.cjs +12 -7
  47. package/gsd-core/bin/lib/planning-workspace.cjs +3 -3
  48. package/gsd-core/bin/lib/read-only-git-env.cjs +23 -2
  49. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +2 -2
  50. package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -0
  51. package/gsd-core/bin/lib/review-lane-invocation.cjs +125 -18
  52. package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +11 -9
  53. package/gsd-core/bin/lib/roadmap-parser.cjs +759 -144
  54. package/gsd-core/bin/lib/roadmap-progress.cjs +136 -0
  55. package/gsd-core/bin/lib/roadmap-upgrade.cjs +8 -5
  56. package/gsd-core/bin/lib/roadmap.cjs +358 -106
  57. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +310 -130
  58. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
  59. package/gsd-core/bin/lib/shell-command-projection.cjs +36 -9
  60. package/gsd-core/bin/lib/smart-entry.cjs +22 -11
  61. package/gsd-core/bin/lib/state-contract.cjs +11 -7
  62. package/gsd-core/bin/lib/state-document.cjs +332 -23
  63. package/gsd-core/bin/lib/state-transition.cjs +51 -15
  64. package/gsd-core/bin/lib/state.cjs +499 -180
  65. package/gsd-core/bin/lib/template.cjs +2 -2
  66. package/gsd-core/bin/lib/validate.cjs +3 -2
  67. package/gsd-core/bin/lib/vendor/re2js.cjs +863 -577
  68. package/gsd-core/bin/lib/vendor/re2js.d.cts +284 -219
  69. package/gsd-core/bin/lib/verify.cjs +2 -2
  70. package/gsd-core/bin/lib/workstream-inventory.cjs +27 -21
  71. package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
  72. package/gsd-core/bin/verify-reapply-patches.cjs +411 -10
  73. package/gsd-core/references/cicd-review-patterns.md +45 -0
  74. package/gsd-core/references/necessity-review.md +48 -0
  75. package/gsd-core/workflows/code-review-fix.md +46 -5
  76. package/gsd-core/workflows/code-review.md +3 -0
  77. package/gsd-core/workflows/execute-phase.md +1 -1
  78. package/gsd-core/workflows/execute-plan.md +1 -1
  79. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +13 -9
  80. package/gsd-core/workflows/plan-phase.md +1 -1
  81. package/gsd-core/workflows/pr-branch.md +2 -0
  82. package/gsd-core/workflows/reapply-patches.md +104 -0
  83. package/gsd-core/workflows/review.md +54 -141
  84. package/gsd-core/workflows/secure-phase.md +1 -1
  85. package/gsd-core/workflows/settings-advanced.md +57 -9
  86. package/gsd-core/workflows/ship.md +12 -0
  87. package/hooks/dist/gsd-agent-isolation-guard.js +1 -1
  88. package/hooks/dist/gsd-cursor-subagent-start.js +1 -1
  89. package/hooks/dist/gsd-graphify-update.sh +6 -0
  90. package/hooks/dist/gsd-statusline.js +3 -2
  91. package/hooks/dist/gsd-windsurf-pre-write.js +4 -4
  92. package/hooks/dist/gsd-workflow-guard.js +2 -1
  93. package/hooks/dist/gsd-worktree-path-guard.js +4 -4
  94. package/hooks/dist/lib/git-probe.js +16 -0
  95. package/hooks/dist/lib/isolation-sentinel.js +77 -2
  96. package/hooks/gsd-agent-isolation-guard.js +1 -1
  97. package/hooks/gsd-cursor-subagent-start.js +1 -1
  98. package/hooks/gsd-graphify-update.sh +6 -0
  99. package/hooks/gsd-statusline.js +3 -2
  100. package/hooks/gsd-windsurf-pre-write.js +4 -4
  101. package/hooks/gsd-workflow-guard.js +2 -1
  102. package/hooks/gsd-worktree-path-guard.js +4 -4
  103. package/hooks/lib/git-probe.js +16 -0
  104. package/hooks/lib/isolation-sentinel.js +77 -2
  105. package/package.json +3 -3
  106. package/pi/package-agents/gsd-code-fixer.md +9 -4
  107. package/pi/package-agents/gsd-code-reviewer.md +53 -14
  108. package/pi/package-agents/gsd-executor.md +8 -4
  109. package/pi/package-agents/manifest.json +3 -3
  110. package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
  111. package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
  112. package/pi/package-skills/gsd-update/SKILL.md +4 -1
  113. package/scripts/changeset/lint.cjs +28 -3
  114. package/scripts/ci-next-health.cjs +246 -23
  115. package/scripts/docs-guard-registry.cjs +2 -0
  116. package/scripts/gen-features.cjs +14 -2
  117. package/scripts/lib/drift-scan.cjs +27 -0
  118. package/scripts/lib/macos-conformance-tier.generated.cjs +10 -0
  119. package/scripts/lib/platform-conformance-tier.generated.cjs +15 -0
  120. package/scripts/lint-phase-enumeration-drift.cjs +8 -4
  121. package/scripts/lint-roadmap-progress-drift.cjs +421 -0
  122. package/scripts/merge-commit-pr.cjs +186 -0
  123. package/scripts/mutation-matrix.cjs +23 -1
  124. package/scripts/release-ancestry.cjs +86 -0
  125. package/scripts/release-pipeline-pr.cjs +92 -0
  126. package/scripts/require-issue-link-policy.cjs +18 -9
  127. package/scripts/sync-next-version.cjs +48 -7
  128. package/skills/gsd-new-milestone/SKILL.md +1 -1
  129. package/skills/gsd-new-project/SKILL.md +1 -1
  130. package/skills/gsd-update/SKILL.md +5 -2
  131. package/vscode/package.json +1 -1
@@ -60,6 +60,8 @@ This ensures project-specific patterns, conventions, and best practices are appl
60
60
 
61
61
  **3. Code Quality** — Dead code, unused imports/variables, poor naming conventions, missing error handling, inconsistent patterns, overly complex functions (high cyclomatic complexity), code duplication, magic numbers, commented-out code
62
62
 
63
+ **4. Necessity** (#4100) — reachable code whose guarded-against problem cannot occur in this stack: impossible-state guard, already-defended-elsewhere, cargo-culted pattern, phantom requirement, generality without instantiation. At `standard`/`deep`, Read `__GSD_PI_ENGINE_ROOT__/gsd-core/references/necessity-review.md` before classifying findings; if it cannot be read, say so in the Summary and omit `necessity:`. `quick` does not assess necessity. Warning when provable from the repo, Info when it rests on judgment, Never Critical; each finding cites the evidence that the state cannot occur and carries a `**Category:** necessity` line.
64
+
63
65
  **Out of Scope (v1):** Performance issues (O(n²) algorithms, memory leaks, inefficient queries) are NOT in scope for v1. Focus on correctness, security, and maintainability.
64
66
 
65
67
  </review_scope>
@@ -70,12 +72,17 @@ This ensures project-specific patterns, conventions, and best practices are appl
70
72
 
71
73
  **quick** — Pattern-matching only. Use grep/regex to scan for common anti-patterns without reading full file contents. Target: under 2 minutes.
72
74
 
73
- Patterns checked:
74
- - Hardcoded secrets: `(password|secret|api_key|token|apikey|api-key)\s*[=:]\s*['"][^'"]+['"]`
75
- - Dangerous functions: `eval\(|innerHTML|dangerouslySetInnerHTML|exec\(|system\(|shell_exec|passthru`
76
- - Debug artifacts: `console\.log|debugger;|TODO|FIXME|XXX|HACK`
77
- - Empty catch blocks: `catch\s*\([^)]*\)\s*\{\s*\}`
78
- - Commented-out code: `^\s*//.*[{};]|^\s*#.*:|^\s*/\*`
75
+ Checks (commands in `review_by_depth`):
76
+ - Hardcoded secrets
77
+ - Dangerous functions
78
+ - Debug artifacts
79
+ - Empty catch blocks
80
+ - Commented-out code
81
+ - Unpinned actions
82
+ - Non-idempotent install
83
+ - Missing job timeout
84
+
85
+ The last three: GitHub Actions workflows only.
79
86
 
80
87
  **standard** (default) — Read each changed file. Check for bugs, security issues, and quality problems in context. Cross-reference imports and exports. Target: 5-15 minutes.
81
88
 
@@ -85,6 +92,7 @@ Language-aware checks:
85
92
  - **Go**: Unchecked error returns, goroutine leaks, context not passed, `defer` in loops, race conditions
86
93
  - **C/C++**: Buffer overflow patterns, use-after-free indicators, null pointer dereferences, missing bounds checks, memory leaks
87
94
  - **Shell**: Unquoted variables, `eval` usage, missing `set -e`, command injection via interpolation
95
+ - **CI/CD pipelines** (by path: `.github/workflows/*.yml`, `Dockerfile*`, `docker-compose*.yml`, deploy scripts): Read `__GSD_PI_ENGINE_ROOT__/gsd-core/references/cicd-review-patterns.md`; if it cannot be read, say so in the Summary and check: non-idempotent steps (`npm install` vs `npm ci`, unlocked `pip install`, unguarded `mkdir`/`rm`), no job `timeout-minutes`, deploy without `concurrency` + `cancel-in-progress`, per-environment rebuild (not build-once), no health check or rollback, load-bearing `continue-on-error`, static credentials where OIDC works, unpinned actions
88
96
 
89
97
  **deep** — All of standard, plus cross-file analysis. Trace function call chains across imports. Target: 15-30 minutes.
90
98
 
@@ -173,6 +181,7 @@ NOTE: Do NOT exclude all `.md` files — commands, workflows, and agents are sou
173
181
  - Go: `.go`
174
182
  - C/C++: `.c`, `.cpp`, `.h`, `.hpp`
175
183
  - Shell: `.sh`, `.bash`
184
+ - CI/CD (by path): `.github/workflows/*.yml`/`*.yaml`, `Dockerfile*`, `docker-compose*.yml`, `deploy*.sh` (also Shell)
176
185
  - Other: Review generically
177
186
 
178
187
  **3. Exit early if empty:** If no source files remain after filtering, create REVIEW.md with:
@@ -193,22 +202,48 @@ NOTE: `status: clean` means "reviewed and found no issues." `status: skipped` me
193
202
  Branch on depth level:
194
203
 
195
204
  **For depth=quick:**
196
- Run grep patterns (from `<depth_levels>` quick section) against all files:
205
+ Run every check from `<depth_levels>` quick section:
197
206
  ```bash
207
+ # Set FILE per scoped path with: while IFS= read -r FILE (never paste a name into code).
208
+
198
209
  # Hardcoded secrets
199
- grep -n -E "(password|secret|api_key|token|apikey|api-key)\s*[=:]\s*['\"]\w+['\"]" file
210
+ grep -n -E -- "(password|secret|api_key|token|apikey|api-key)\s*[=:]\s*['\"][^'\"]+['\"]" "$FILE"
200
211
 
201
212
  # Dangerous functions
202
- grep -n -E "eval\(|innerHTML|dangerouslySetInnerHTML|exec\(|system\(|shell_exec" file
213
+ grep -n -E -- "eval\(|innerHTML|dangerouslySetInnerHTML|exec\(|system\(|shell_exec|passthru" "$FILE"
203
214
 
204
215
  # Debug artifacts
205
- grep -n -E "console\.log|debugger;|TODO|FIXME|XXX|HACK" file
206
-
207
- # Empty catch
208
- grep -n -E "catch\s*\([^)]*\)\s*\{\s*\}" file
216
+ grep -n -E -- "console\.log|debugger;|TODO|FIXME|XXX|HACK" "$FILE"
217
+
218
+ # Empty catch blocks
219
+ grep -n -E -- "catch\s*\([^)]*\)\s*\{\s*\}" "$FILE"
220
+
221
+ # Commented-out code
222
+ grep -n -E -- "^\s*//.*[{};]|^\s*#[[:space:]]*(if|for|while|def|class|elif|else|try|except|with)[[:space:](].*:[[:space:]]*$|^\s*/\*[^*].*[{};]" "$FILE"
223
+
224
+ # Unpinned actions
225
+ grep -n -E -- "^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*['\"]?[^[:space:]'\"]+@(main|master|latest)['\"]?([[:space:]]|$)" "$FILE"
226
+
227
+ # Non-idempotent install
228
+ [ -f package-lock.json ] && grep -n -E -- "(^|[^[:alnum:]_-])npm[[:space:]]+(install|i)([[:space:]]|$)" "$FILE" | grep -v -E "^[0-9]+:[[:space:]]*#|[[:space:]](-g|--global)([[:space:]]|$)"
229
+
230
+ # Missing job timeout
231
+ awk '
232
+ function flush() { if (job != "" && runs && !timed) print line ": " job ": no job-level timeout-minutes"; job = "" }
233
+ { sub(/\r$/, "") }
234
+ /^[^[:space:]#]/ { flush(); injobs = ($0 ~ /^jobs:[[:space:]]*(#.*)?$/); jind = -1; next }
235
+ !injobs || /^[[:space:]]*(#.*)?$/ { next }
236
+ { ind = match($0, /[^ ]/) - 1 }
237
+ jind < 0 { jind = ind }
238
+ ind == jind { flush(); job = $0; sub(/^ +/, "", job); sub(/:.*/, "", job); line = NR; kind = -1; runs = 0; timed = 0; next }
239
+ kind < 0 { kind = ind }
240
+ ind == kind && /^ *runs-on:/ { runs = 1 }
241
+ ind == kind && /^ *timeout-minutes:/ { timed = 1 }
242
+ END { flush() }
243
+ ' "$FILE"
209
244
  ```
210
245
 
211
- Record findings with severity: secrets/dangerous=Critical, debug=Info, empty catch=Warning
246
+ Record findings with severity: secrets/dangerous=Critical, debug and commented-out code=Info, empty catch and the three CI/CD checks=Warning
212
247
 
213
248
  **For depth=standard:**
214
249
  For each file:
@@ -252,6 +287,7 @@ For each finding, assign severity:
252
287
  - Type coercion issues (`==` vs `===`)
253
288
  - Unhandled promise rejections
254
289
  - Dead code paths that indicate logic errors
290
+ - CI/CD pipeline findings: every check in the CI/CD entry
255
291
 
256
292
  **Info** — Style issues, naming improvements, dead code, unused imports, suggestions:
257
293
  - Unused imports/variables
@@ -286,10 +322,13 @@ findings:
286
322
  warning: N
287
323
  info: N
288
324
  total: N
325
+ necessity: N
289
326
  status: clean | issues_found
290
327
  ---
291
328
  ```
292
329
 
330
+ Emit `necessity:` only at standard/deep depth. It counts the necessity findings among `warning` + `info` and is never added to `total`.
331
+
293
332
  **3. Body sections (required order):**
294
333
  1) `## Structural Findings (fallow)` — only when structural findings were provided; list normalized items first.
295
334
  2) `## Narrative Findings (AI reviewer)` — your adversarial findings from direct code review, including any external-reviewer claim you independently verified (`(external: {slug})`, see `load_context` step 5).
@@ -761,7 +761,14 @@ Do NOT skip. Do NOT proceed to state updates if self-check fails.
761
761
  </self_check>
762
762
 
763
763
  <state_updates>
764
- After SUMMARY.md, update STATE.md using `gsd-tools query` state handlers (named flags):
764
+ After SUMMARY.md, update ROADMAP.md progress FIRST, then STATE.md. The ROADMAP write is the step that can decline (`missing_phase_details`), and a decline stops the plan before any STATE.md write, so STATE.md never advances past a plan the ROADMAP did not record:
765
+
766
+ ```bash
767
+ # Update ROADMAP.md progress for this phase (plan counts, status). A decline stops here, before STATE.md moves.
768
+ PROGRESS=$(gsd_run query roadmap.update-plan-progress "${PHASE_NUMBER}") && REASON=$(jq -er '.reason//""' <<<"$PROGRESS") && [ "$REASON" != missing_phase_details ] || { echo "ERROR: ROADMAP update-plan-progress did not complete for ${PHASE_NUMBER}-${PLAN} (missing_phase_details decline, failed query, or jq not installed). STOP: skip this plan's completion steps." >&2; exit 1; }
769
+ ```
770
+
771
+ Then update STATE.md using `gsd-tools query` state handlers (named flags):
765
772
 
766
773
  ```bash
767
774
  # Advance plan counter (handles edge cases automatically)
@@ -788,9 +795,6 @@ gsd_run query state.record-session \
788
795
  ```
789
796
 
790
797
  ```bash
791
- # Update ROADMAP.md progress for this phase (plan counts, status)
792
- gsd_run query roadmap.update-plan-progress "${PHASE_NUMBER}"
793
-
794
798
  # Mark completed requirements from PLAN.md frontmatter
795
799
  # Extract the `requirements` array from the plan's frontmatter, then mark each complete
796
800
  gsd_run query requirements.mark-complete ${REQ_IDS}
@@ -3,8 +3,8 @@
3
3
  "gsd-advisor-researcher.md": "6988b236b53568bbcea7886d11ca9f0badf9aedb3f650d42ca894211d91aafe4",
4
4
  "gsd-ai-researcher.md": "bdc567357f7361d7e427a2730bdac17c0b28ecb1bcbbc687df68d573f0ee9f3e",
5
5
  "gsd-assumptions-analyzer.md": "56b243f4c50403cbc133ad8c91ae40aa1aeceadd6624d0fd26406ebf8c113205",
6
- "gsd-code-fixer.md": "11a76631ffe74bf5deb707d69b8412bf4251fe8ae87e03cdd59bc28fc6e3904d",
7
- "gsd-code-reviewer.md": "de2a37a6e76bf1d70ff16cb16881326b60a7236194d6126abe5a40d0fd9a98f5",
6
+ "gsd-code-fixer.md": "2d806b6bae3797a7757b4872a93790fe33a4f22e90f6e71f6344b52df3048d52",
7
+ "gsd-code-reviewer.md": "459b38676fd7e6b7bf147a9c3d88243630ff289366a359c7c489536d38b2bd21",
8
8
  "gsd-codebase-mapper.md": "5901db6721a6f2365be52819a41ab1c1590a7cbbbdc6d992dce2a7b99f7d12fb",
9
9
  "gsd-debug-session-manager.md": "7b68386a6ab0169eb4c1b44afa921b8196db1eac1af3a0dd07a87d5ae2b208ef",
10
10
  "gsd-debugger.md": "7328920b7fbee1b76af7259a14b96290562544d97c6ced1291e1e1f3529e1598",
@@ -16,7 +16,7 @@
16
16
  "gsd-domain-researcher.md": "f1b07d9c79dcb89c90e5af7f61adfa5dc73ebe8429783d8cb281d7ae2bdc682b",
17
17
  "gsd-eval-auditor.md": "9d77e7329c52741e7e62185b566231eb872c6b34afa6c869960a922af573b3d5",
18
18
  "gsd-eval-planner.md": "efc12a2822d77a5989db2e3efe83b7fda9f0509bb203910d7e26eb9369f89949",
19
- "gsd-executor.md": "2302fb8b45608b422eb956db27d449e7d5f5c2d41c772188d675ad4f260c342e",
19
+ "gsd-executor.md": "45e9540f6380b1883ad5b144411b21447eef20f310cbd0ab7c72c2bdaf5f8c47",
20
20
  "gsd-framework-selector.md": "96d52931468372ee88c4f1d551c13c97d2be8079053b55d992e0ea1720d3d5c9",
21
21
  "gsd-integration-checker.md": "a233ab8c5c97b6099c649d1e9a09e88ff1ed1bf7df7c1b13bc766c33d58e09b9",
22
22
  "gsd-intel-updater.md": "26c70b81580cc5bf713959bdcc84a16a35569861d2e79c07c71258ccd05ba9bc",
@@ -26,7 +26,7 @@ Brownfield equivalent of new-project. Project exists, PROJECT.md has history. Ga
26
26
  - `.planning/ROADMAP.md` — phase structure (continues numbering)
27
27
  - `.planning/STATE.md` — reset for new milestone
28
28
 
29
- **After:** `/skill:gsd-plan-phase [N]` to start execution.
29
+ **After:** Run `/skill:gsd-discuss-phase [N]` to clarify the approach (or `/skill:gsd-plan-phase [N]` to plan directly); then `/skill:gsd-execute-phase [N]` to execute.
30
30
  </objective>
31
31
 
32
32
  <execution_context>
@@ -32,7 +32,7 @@ Initialize a new project through unified flow: questioning → research (optiona
32
32
  - `.planning/ROADMAP.md` — phase structure
33
33
  - `.planning/STATE.md` — project memory
34
34
 
35
- **After this command:** Run `/skill:gsd-plan-phase 1` to start execution.
35
+ **After this command:** Run `/skill:gsd-discuss-phase 1` to clarify the approach (or `/skill:gsd-plan-phase 1` to plan directly); then `/skill:gsd-execute-phase 1` to execute.
36
36
  </objective>
37
37
 
38
38
  <execution_context>
@@ -33,14 +33,17 @@ Routes to the update workflow which handles:
33
33
  <flags>
34
34
  - **--sync**: Sync managed GSD skills across runtime roots so multi-runtime users stay aligned after an update. Runs the sync-skills workflow (--from, --to, --dry-run, --apply flags supported).
35
35
  - **--reapply**: Reapply local modifications after a GSD update. Uses three-way comparison (pristine baseline, user-modified backup, newly installed version) to merge user customizations back. Runs the reapply-patches workflow.
36
+ - **--interactive[=exceptions|all]** (with `--reapply` only): show each user edit next to the stock text with a recommended disposition and wait for a keep / retire / rewrite decision per edit. `all` shows every edit; `exceptions` (bare `--interactive`) shows only edits upstream adopted, partly adopted, reworded around, or orphaned, and keeps the rest. Without it, `--reapply` behaves exactly as before.
36
37
  - **--next** (alias **--rc**): Target the `@next` RC dist-tag instead of `@latest` so you can install or refresh a release candidate (e.g. `1.4.0-rc.1`) through the normal update flow — scope/runtime detection, changelog preview, custom-file backup, and cache clearing all still apply. Omitting it keeps targeting `@latest` (no change). See ADR #660 for the RC channel.
37
38
  - **(no flag)**: Standard update — check for new version, show changelog, install.
38
39
  </flags>
39
40
 
40
41
  <process>
42
+ If the arguments contain `--interactive` (bare or with a value) but not `--reapply`, STOP with: `--interactive applies only to --reapply (for example: /skill:gsd-update --reapply --interactive=all)`. Do nothing else.
43
+
41
44
  Parse the first token of the `<arguments>` block:
42
45
  - If it is `--sync`: strip the flag, execute the sync-skills workflow (passing remaining args for --from/--to/--dry-run/--apply).
43
- - If it is `--reapply`: strip the flag, execute the reapply-patches workflow.
46
+ - If it is `--reapply`: strip the flag, execute the reapply-patches workflow, passing the remaining arguments (for example `--interactive=all`) to it.
44
47
  - Otherwise (including `--next` / `--rc`): execute the update workflow end-to-end, passing the `<arguments>` block through so the workflow's parse_update_channel step can select the release channel.
45
48
 
46
49
  </process>
@@ -20,6 +20,9 @@ const LINT_REASON = Object.freeze({
20
20
  OK_FRAGMENT_PRESENT: 'ok_fragment_present',
21
21
  OK_OPT_OUT_LABEL: 'ok_opt_out_label',
22
22
  OK_NO_USER_FACING_CHANGES: 'ok_no_user_facing_changes',
23
+ // #5903: the release pipeline's own PRs consume every fragment into
24
+ // CHANGELOG.md, so they carry none by design.
25
+ OK_RELEASE_PIPELINE: 'ok_release_pipeline',
23
26
  FAIL_MISSING_FRAGMENT: 'fail_missing_fragment',
24
27
  FAIL_INVALID_FRAGMENT: 'fail_invalid_fragment',
25
28
  FAIL_PR_FIELD_DRIFT: 'fail_pr_field_drift',
@@ -86,13 +89,18 @@ function findPrFieldDrift(prEntries, realPrNumber) {
86
89
  return drift;
87
90
  }
88
91
 
89
- function evaluateLint({ changedFiles, labels, fragmentFailures = [], prFieldDrift = [] }) {
92
+ function evaluateLint({
93
+ changedFiles, labels, fragmentFailures = [], prFieldDrift = [], releasePipelineKind = null,
94
+ }) {
90
95
  if (fragmentFailures.length > 0) {
91
96
  return { ok: false, reason: LINT_REASON.FAIL_INVALID_FRAGMENT, failures: fragmentFailures };
92
97
  }
93
98
  if (prFieldDrift.length > 0) {
94
99
  return { ok: false, reason: LINT_REASON.FAIL_PR_FIELD_DRIFT, drift: prFieldDrift };
95
100
  }
101
+ if (releasePipelineKind) {
102
+ return { ok: true, reason: LINT_REASON.OK_RELEASE_PIPELINE, releasePipelineKind };
103
+ }
96
104
  if (changedFiles.some(isFragment)) {
97
105
  return { ok: true, reason: LINT_REASON.OK_FRAGMENT_PRESENT };
98
106
  }
@@ -107,6 +115,7 @@ function evaluateLint({ changedFiles, labels, fragmentFailures = [], prFieldDrif
107
115
 
108
116
  const { ExitError, runMain } = require('../lib/cli-exit.cjs');
109
117
  const { parseFragment } = require('./parse.cjs');
118
+ const { releasePipelinePrKind } = require('../release-pipeline-pr.cjs');
110
119
 
111
120
  function main() {
112
121
  const fs = require('node:fs');
@@ -119,6 +128,12 @@ function main() {
119
128
  // `pull_request` in the payload, or no payload at all) — the drift check
120
129
  // below is a no-op in that case, it never fails a push run.
121
130
  let realPrNumber = null;
131
+ // #5903: head/base refs and whether the head lives in this repository, for
132
+ // the release-pipeline exemption. sameRepo is false when anything is missing
133
+ // (a deleted fork has head.repo === null).
134
+ let headRef;
135
+ let baseRef;
136
+ let sameRepo = false;
122
137
  if (eventPath && fs.existsSync(eventPath)) {
123
138
  try {
124
139
  const event = JSON.parse(fs.readFileSync(eventPath, 'utf8'));
@@ -126,6 +141,12 @@ function main() {
126
141
  if (event.pull_request && Number.isInteger(event.pull_request.number)) {
127
142
  realPrNumber = event.pull_request.number;
128
143
  }
144
+ headRef = event.pull_request?.head?.ref;
145
+ baseRef = event.pull_request?.base?.ref;
146
+ const headRepo = event.pull_request?.head?.repo?.full_name;
147
+ const thisRepo = process.env.GITHUB_REPOSITORY;
148
+ sameRepo = typeof headRepo === 'string' && typeof thisRepo === 'string'
149
+ && headRepo !== '' && headRepo.toLowerCase() === thisRepo.toLowerCase();
129
150
  } catch { /* fall through */ }
130
151
  }
131
152
  // #2988: local fallback must match the repo's integration branch (`next`),
@@ -176,11 +197,15 @@ function main() {
176
197
  }
177
198
 
178
199
  const prFieldDrift = findPrFieldDrift(prEntries, realPrNumber);
179
- const verdict = evaluateLint({ changedFiles, labels, fragmentFailures, prFieldDrift });
200
+ const releasePipelineKind = releasePipelinePrKind({ headRef, baseRef, sameRepo });
201
+ const verdict = evaluateLint({
202
+ changedFiles, labels, fragmentFailures, prFieldDrift, releasePipelineKind,
203
+ });
180
204
  if (process.argv.includes('--json')) {
181
205
  process.stdout.write(JSON.stringify({ ...verdict, changedFiles, labels }, null, 2) + '\n');
182
206
  } else if (verdict.ok) {
183
- process.stdout.write(`ok changeset-lint: ${verdict.reason}\n`);
207
+ const kind = verdict.releasePipelineKind ? ` (${verdict.releasePipelineKind})` : '';
208
+ process.stdout.write(`ok changeset-lint: ${verdict.reason}${kind}\n`);
184
209
  } else if (verdict.reason === LINT_REASON.FAIL_INVALID_FRAGMENT) {
185
210
  process.stderr.write(`\nERROR changeset-lint: ${verdict.reason}\n`);
186
211
  process.stderr.write(`The following .changeset fragment(s) failed content validation:\n`);
@@ -59,14 +59,158 @@ function classifyRunConclusion(payload) {
59
59
  return VERDICT.RED;
60
60
  }
61
61
 
62
+ // Run statuses that mean "an attempt is still going". A run object reports the
63
+ // state of its LATEST attempt only, so a re-run of a red run reads as
64
+ // in-flight with no trace of the failure it is re-running.
65
+ const IN_FLIGHT_STATUSES = new Set(['queued', 'in_progress', 'waiting', 'requested', 'pending']);
66
+
67
+ function isInFlight(run) {
68
+ return Boolean(run) && IN_FLIGHT_STATUSES.has(run.status);
69
+ }
70
+
71
+ /**
72
+ * Pure, total: the "list workflow runs" payload (newest first, ANY status) ->
73
+ * the verdict, the run it rests on, and why. Never throws.
74
+ *
75
+ * A completed newest run is judged by classifyRunConclusion. An in-flight
76
+ * newest run is never read as healthy on its own:
77
+ * - a re-run (`run_attempt` > 1) whose previous attempt did not succeed is RED
78
+ * — the fetcher attaches that attempt's conclusion as
79
+ * `previous_attempt_conclusion`, because the list endpoint cannot show it;
80
+ * - otherwise the newest SETTLED run decides (red stays RED, green is CLEAN).
81
+ * This is what stops a mid-flight re-run from hiding the failure it re-runs
82
+ * (2026-10-27: it let #5755 and #5750 through behind a red `next`).
83
+ *
84
+ * @returns {{verdict:string, run:(object|null), reason:string}}
85
+ */
86
+ function classifyBaseRuns(payload) {
87
+ if (payload === null || typeof payload !== 'object' || !Array.isArray(payload.workflow_runs)) {
88
+ return { verdict: VERDICT.INDETERMINATE, run: null, reason: 'malformed-payload' };
89
+ }
90
+ const runs = payload.workflow_runs;
91
+ if (runs.length === 0) return { verdict: VERDICT.CLEAN, run: null, reason: 'resolved' };
92
+
93
+ const [newest] = runs;
94
+ if (!isInFlight(newest)) {
95
+ return {
96
+ verdict: classifyRunConclusion({ workflow_runs: [newest] }),
97
+ run: newest,
98
+ reason: 'resolved',
99
+ };
100
+ }
101
+
102
+ const prior = newest.previous_attempt_conclusion;
103
+ if (typeof prior === 'string' && prior !== '' && prior !== 'success') {
104
+ return { verdict: VERDICT.RED, run: newest, reason: 'rerun-in-flight-after-failure' };
105
+ }
106
+ // A re-run of an attempt that succeeded: the newest evidence is green, so an
107
+ // older red run must not turn the gate red.
108
+ if (prior === 'success') {
109
+ return { verdict: VERDICT.CLEAN, run: newest, reason: 'rerun-in-flight-after-success' };
110
+ }
111
+
112
+ const settled = runs.find((run) => !isInFlight(run));
113
+ if (!settled) return { verdict: VERDICT.CLEAN, run: null, reason: 'newest-in-flight-no-history' };
114
+ return {
115
+ verdict: classifyRunConclusion({ workflow_runs: [settled] }),
116
+ run: settled,
117
+ reason: 'newest-in-flight',
118
+ };
119
+ }
120
+
121
+ // merge_group heads are `gh-readonly-queue/<base>/pr-<N>-<40-hex sha>`; the
122
+ // name carries only the LAST PR of the group.
123
+ const QUEUE_REF_PATTERN = /^(?:refs\/heads\/)?gh-readonly-queue\/(.+)\/pr-(\d+)-[0-9a-f]{40}$/;
124
+
125
+ /** @returns {{base:string, prNumber:number}|null} */
126
+ function parseQueueRef(ref) {
127
+ if (typeof ref !== 'string') return null;
128
+ const match = QUEUE_REF_PATTERN.exec(ref.trim());
129
+ if (!match) return null;
130
+ return { base: match[1], prNumber: Number(match[2]) };
131
+ }
132
+
133
+ // Repository permissions that may apply the bypass label. `maintain` is
134
+ // reported as `write` by this endpoint's legacy `permission` field; both are
135
+ // accepted so a role-name change cannot silently lock the bypass out.
136
+ const BYPASS_PERMISSIONS = new Set(['admin', 'maintain', 'write']);
137
+ const EVENT_PAGES_MAX = 10;
138
+
139
+ /**
140
+ * merge_group has no label surface, so read it back from the API. The bypass
141
+ * is honoured only when ALL hold, and every failure is "not authorized" (RED),
142
+ * never fail-open:
143
+ * 1. the ref names a PR and the group holds exactly that one commit — a group
144
+ * of several PRs would let one labelled PR carry unlabelled ones;
145
+ * 2. the PR currently carries the label;
146
+ * 3. the actor of the label's latest `labeled` event has write or admin
147
+ * permission (triage-role users can apply labels).
148
+ *
149
+ * `ghGet(path)` returns parsed JSON or throws.
150
+ *
151
+ * @returns {Promise<{authorized:boolean, reason:string, actor?:string, prNumber?:number}>}
152
+ */
153
+ async function evaluateQueueBypass({ headRef, baseRef, baseSha, headSha, repo, ghGet } = {}) {
154
+ const parsed = parseQueueRef(headRef);
155
+ if (!parsed) return { authorized: false, reason: 'queue-ref-unparseable' };
156
+ // The queue branch must be for the branch whose health is being judged.
157
+ if (typeof baseRef === 'string' && baseRef !== '' && parsed.base !== baseRef) {
158
+ return { authorized: false, reason: 'queue-base-mismatch', prNumber: parsed.prNumber };
159
+ }
160
+ const { prNumber } = parsed;
161
+ if (!baseSha || !headSha) return { authorized: false, reason: 'no-group-shas', prNumber };
162
+
163
+ try {
164
+ const compare = await ghGet(`/repos/${repo}/compare/${baseSha}...${headSha}`);
165
+ if (!compare || compare.total_commits !== 1) {
166
+ return { authorized: false, reason: 'multi-pr-group', prNumber };
167
+ }
168
+
169
+ const labels = await ghGet(`/repos/${repo}/issues/${prNumber}/labels?per_page=100`);
170
+ const hasLabel = Array.isArray(labels) && labels.some((l) => l && l.name === BYPASS_LABEL);
171
+ if (!hasLabel) return { authorized: false, reason: 'no-label', prNumber };
172
+
173
+ let actor = null;
174
+ let exhausted = false;
175
+ for (let page = 1; page <= EVENT_PAGES_MAX; page += 1) {
176
+ const events = await ghGet(`/repos/${repo}/issues/${prNumber}/events?per_page=100&page=${page}`);
177
+ if (!Array.isArray(events)) break;
178
+ for (const event of events) {
179
+ if (event && event.event === 'labeled' && event.label && event.label.name === BYPASS_LABEL) {
180
+ actor = event.actor && typeof event.actor.login === 'string' ? event.actor.login : null;
181
+ }
182
+ }
183
+ if (events.length < 100) break;
184
+ if (page === EVENT_PAGES_MAX) exhausted = true;
185
+ }
186
+ // More events than we read: the latest `labeled` event may be unseen, so
187
+ // the applier is unknown. Fail closed.
188
+ if (exhausted) return { authorized: false, reason: 'label-applier-unknown', prNumber };
189
+ if (!actor) return { authorized: false, reason: 'label-applier-unknown', prNumber };
190
+
191
+ const perm = await ghGet(`/repos/${repo}/collaborators/${encodeURIComponent(actor)}/permission`);
192
+ const level = perm && typeof perm.permission === 'string' ? perm.permission : '';
193
+ if (!BYPASS_PERMISSIONS.has(level)) {
194
+ return { authorized: false, reason: 'label-applier-lacks-write', actor, prNumber };
195
+ }
196
+ return { authorized: true, reason: 'queue-label', actor, prNumber };
197
+ } catch (err) {
198
+ return { authorized: false, reason: `bypass-check-failed: ${err.message}`, prNumber };
199
+ }
200
+ }
201
+
62
202
  /**
63
203
  * Dependency-injected orchestrator. `fetchLatestRun` is an async function
64
204
  * taking the resolved base branch name and returning the parsed API payload
65
205
  * (or throwing) — injected so tests never touch the network.
206
+ * `evaluateQueueBypass` (merge_group only) is an async function returning
207
+ * `{authorized, reason, actor}`; it is how a queued fix-forward is recognised.
66
208
  *
67
209
  * @returns {Promise<{verdict:string, payload:*, reason:string}>}
68
210
  */
69
- async function resolveNextHealth({ fetchLatestRun, eventName, baseRef, labels } = {}) {
211
+ async function resolveNextHealth({
212
+ fetchLatestRun, eventName, baseRef, labels, evaluateQueueBypass: queueBypass,
213
+ } = {}) {
70
214
  if (!APPLICABLE_EVENTS.has(eventName)) {
71
215
  return { verdict: VERDICT.SKIPPED_NOT_APPLICABLE, payload: null, reason: 'not-applicable-event' };
72
216
  }
@@ -82,23 +226,46 @@ async function resolveNextHealth({ fetchLatestRun, eventName, baseRef, labels }
82
226
  return { verdict: VERDICT.INDETERMINATE, payload: null, reason: `fetch-failed: ${err.message}` };
83
227
  }
84
228
 
85
- const classified = classifyRunConclusion(payload);
86
- if (classified !== VERDICT.RED) {
87
- return { verdict: classified, payload, reason: 'resolved' };
229
+ const assessed = classifyBaseRuns(payload);
230
+ // The run the verdict rests on, so annotations link the red run and not the
231
+ // in-flight re-run that sits in front of it.
232
+ const evidence = assessed.run ? { workflow_runs: [assessed.run] } : payload;
233
+ if (assessed.verdict !== VERDICT.RED) {
234
+ const reason = assessed.reason === 'resolved' ? 'resolved' : assessed.reason;
235
+ return { verdict: assessed.verdict, payload: evidence, reason };
88
236
  }
89
237
 
90
- // Escape hatch. `labels` is empty/absent for merge_group — that event has no
91
- // label surface today, which means a queued merge-group commit cannot use
92
- // this bypass. Known gap, not solved here: a maintainer must land the
93
- // fix-forward as a direct pull_request merge (where the label IS readable)
94
- // rather than through the merge queue, until GitHub exposes an equivalent
95
- // signal for merge_group.
238
+ // Escape hatch for the PR that is itself the fix-forward. pull_request runs
239
+ // read the label from the event (`labels`). merge_group events carry no
240
+ // labels, so the queue run asks the API instead (evaluateQueueBypass).
96
241
  const labelList = Array.isArray(labels) ? labels : [];
97
242
  if (labelList.includes(BYPASS_LABEL)) {
98
- return { verdict: VERDICT.BYPASSED, payload, reason: 'bypass-label' };
243
+ return { verdict: VERDICT.BYPASSED, payload: evidence, reason: 'bypass-label' };
244
+ }
245
+
246
+ if (eventName === 'merge_group' && typeof queueBypass === 'function') {
247
+ let decision;
248
+ try {
249
+ decision = await queueBypass();
250
+ } catch (err) {
251
+ decision = { authorized: false, reason: `bypass-check-failed: ${err.message}` };
252
+ }
253
+ if (decision && decision.authorized) {
254
+ return {
255
+ verdict: VERDICT.BYPASSED,
256
+ payload: evidence,
257
+ reason: decision.reason || 'queue-label',
258
+ actor: decision.actor,
259
+ };
260
+ }
261
+ return {
262
+ verdict: VERDICT.RED,
263
+ payload: evidence,
264
+ reason: `${assessed.reason === 'resolved' ? 'red' : assessed.reason}; bypass: ${decision && decision.reason}`,
265
+ };
99
266
  }
100
267
 
101
- return { verdict: VERDICT.RED, payload, reason: 'red' };
268
+ return { verdict: VERDICT.RED, payload: evidence, reason: assessed.reason === 'resolved' ? 'red' : assessed.reason };
102
269
  }
103
270
 
104
271
  function usage() {
@@ -108,9 +275,15 @@ function usage() {
108
275
  '',
109
276
  'CI-only gate: checks whether the PR/merge-group\'s base branch\'s own last',
110
277
  'push-triggered Tests run is red, and fails the job (exit 1) when it is —',
111
- 'unless the PR carries the "fix-next" bypass label. Every other case',
112
- '(wrong event, no resolvable base ref, an unreadable API read) fails open',
113
- '(exit 0).',
278
+ 'unless the PR carries the "fix-next" bypass label. A push run that is',
279
+ 'still in flight (including a re-run of a failed run) never counts as',
280
+ 'healthy: a re-run whose previous attempt failed is RED, otherwise the',
281
+ 'newest settled run decides. Every other case (wrong event, no',
282
+ 'resolvable base ref, an unreadable API read) fails open (exit 0).',
283
+ '',
284
+ 'On merge_group the label is read back from the API: the queue ref must',
285
+ 'name a PR, the group must hold exactly one commit, the PR must carry the',
286
+ 'label, and whoever applied it must have write or admin permission.',
114
287
  '',
115
288
  'Environment variables read:',
116
289
  ' GITHUB_EVENT_NAME workflow trigger event; only "pull_request" and',
@@ -128,6 +301,12 @@ function usage() {
128
301
  ' the caller workflow must populate this from',
129
302
  ' github.event.pull_request.labels.*.name); checked',
130
303
  ' for the literal "fix-next" bypass label',
304
+ ' MERGE_GROUP_HEAD_REF merge-group queue branch',
305
+ ' (github.event.merge_group.head_ref; falls back to',
306
+ ' GITHUB_REF)',
307
+ ' MERGE_GROUP_BASE_SHA merge-group base sha (github.event.merge_group.base_sha)',
308
+ ' MERGE_GROUP_HEAD_SHA merge-group head sha (github.event.merge_group.head_sha;',
309
+ ' falls back to GITHUB_SHA)',
131
310
  ' GITHUB_OUTPUT path to append verdict= step output to',
132
311
  ' GITHUB_STEP_SUMMARY path to append a human-readable summary to',
133
312
  ].join('\n');
@@ -200,9 +379,10 @@ async function main(argv = process.argv.slice(2)) {
200
379
  const baseRef = resolveBaseRef(eventName);
201
380
  const labels = parseLabels(process.env.PR_LABELS);
202
381
 
203
- const fetchLatestRun = async (branch) => {
204
- const url = `${apiBase}/repos/${repo}/actions/workflows/${TESTS_WORKFLOW_FILE}/runs`
205
- + `?branch=${encodeURIComponent(branch)}&event=push&status=completed&per_page=1`;
382
+ // JSON GET against the API. `path` is either absolute-from-root (starts with
383
+ // `/`) or a full URL. Returns parsed JSON (null if the body is not JSON).
384
+ const ghGet = async (pathOrUrl) => {
385
+ const url = pathOrUrl.startsWith('http') ? pathOrUrl : `${apiBase}${pathOrUrl}`;
206
386
  const headers = {
207
387
  accept: 'application/vnd.github+json',
208
388
  'x-github-api-version': '2022-11-28',
@@ -221,26 +401,66 @@ async function main(argv = process.argv.slice(2)) {
221
401
  }
222
402
  };
223
403
 
224
- const result = await resolveNextHealth({ fetchLatestRun, eventName, baseRef, labels });
404
+ // No `status=completed` filter: an in-flight re-run must be visible so it
405
+ // cannot hide the failure it is re-running (see classifyBaseRuns).
406
+ const fetchLatestRun = async (branch) => {
407
+ const payload = await ghGet(
408
+ `/repos/${repo}/actions/workflows/${TESTS_WORKFLOW_FILE}/runs`
409
+ + `?branch=${encodeURIComponent(branch)}&event=push&per_page=5`,
410
+ );
411
+ const newest = payload && Array.isArray(payload.workflow_runs) ? payload.workflow_runs[0] : undefined;
412
+ if (isInFlight(newest) && Number(newest.run_attempt) > 1) {
413
+ // The list shows only the latest attempt; the previous one holds the
414
+ // failure being re-run. An API error here propagates -> INDETERMINATE.
415
+ // If this read fails we cannot show the re-run is not hiding a failure,
416
+ // and failing open here would reopen the hole this closes: treat an
417
+ // unreadable previous attempt as not-success (RED).
418
+ try {
419
+ const previous = await ghGet(
420
+ `/repos/${repo}/actions/runs/${newest.id}/attempts/${Number(newest.run_attempt) - 1}`,
421
+ );
422
+ newest.previous_attempt_conclusion = previous && typeof previous.conclusion === 'string'
423
+ ? previous.conclusion : 'unknown';
424
+ } catch {
425
+ newest.previous_attempt_conclusion = 'unknown';
426
+ }
427
+ }
428
+ return payload;
429
+ };
430
+
431
+ const evaluateQueue = () => evaluateQueueBypass({
432
+ headRef: process.env.MERGE_GROUP_HEAD_REF || process.env.GITHUB_REF || '',
433
+ baseRef,
434
+ baseSha: process.env.MERGE_GROUP_BASE_SHA || '',
435
+ headSha: process.env.MERGE_GROUP_HEAD_SHA || process.env.GITHUB_SHA || '',
436
+ repo,
437
+ ghGet,
438
+ });
439
+
440
+ const result = await resolveNextHealth({
441
+ fetchLatestRun, eventName, baseRef, labels, evaluateQueueBypass: evaluateQueue,
442
+ });
225
443
 
226
444
  writeOutput([`verdict=${result.verdict}`]);
227
- writeSummary(`Base branch health: ${result.verdict}`);
445
+ writeSummary(`Base branch health: ${result.verdict}${result.reason ? ` (${result.reason})` : ''}`);
228
446
 
229
447
  if (result.verdict === VERDICT.RED) {
230
448
  const runUrl = runUrlOf(result.payload);
231
449
  process.stderr.write(
232
- `::error::the base branch "${baseRef}"'s own last Tests run is red: ${runUrl} — `
450
+ `::error::the base branch "${baseRef}"'s own last Tests run is red or still re-running after a failure: ${runUrl} (${result.reason}) — `
233
451
  + 'wait for a fix-forward merge, or if THIS pull request is the fix, ask a '
234
- + `maintainer to apply the "${BYPASS_LABEL}" label to explicitly override this gate.\n`,
452
+ + `maintainer with write access to apply the "${BYPASS_LABEL}" label and re-queue it `
453
+ + '(the queue run reads the label from the API; it must be the only PR in its merge group).\n',
235
454
  );
236
455
  return 1;
237
456
  }
238
457
 
239
458
  if (result.verdict === VERDICT.BYPASSED) {
240
459
  const runUrl = runUrlOf(result.payload);
460
+ const by = result.actor ? ` (label applied by ${result.actor})` : '';
241
461
  process.stderr.write(
242
462
  `::warning::the base branch "${baseRef}"'s own last Tests run is red: ${runUrl} — `
243
- + `a human applied the "${BYPASS_LABEL}" label to explicitly override this gate.\n`,
463
+ + `a human applied the "${BYPASS_LABEL}" label to explicitly override this gate${by}.\n`,
244
464
  );
245
465
  return 0;
246
466
  }
@@ -266,6 +486,9 @@ module.exports = {
266
486
  BYPASS_LABEL,
267
487
  TESTS_WORKFLOW_FILE,
268
488
  classifyRunConclusion,
489
+ classifyBaseRuns,
490
+ parseQueueRef,
491
+ evaluateQueueBypass,
269
492
  resolveNextHealth,
270
493
  main,
271
494
  };