devflow-kit 3.8.0 → 3.10.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 (211) hide show
  1. package/CHANGELOG.md +231 -0
  2. package/README.md +6 -6
  3. package/dist/agents/code.md +10 -10
  4. package/dist/agents/fix.md +7 -7
  5. package/dist/agents/git.md +2 -2
  6. package/dist/agents/harden.md +7 -6
  7. package/dist/agents/refactor.md +6 -6
  8. package/dist/agents/skim.md +1 -1
  9. package/dist/agents/triage.md +7 -0
  10. package/dist/cli/commands/ambient.js +10 -2
  11. package/dist/cli/commands/compliance-prompts.js +9 -4
  12. package/dist/cli/commands/compliance.js +171 -24
  13. package/dist/cli/commands/curation.js +79 -23
  14. package/dist/cli/commands/gate-level-prompts.js +39 -23
  15. package/dist/cli/commands/gates.js +6 -7
  16. package/dist/cli/commands/init.js +150 -145
  17. package/dist/cli/commands/install-report.js +34 -0
  18. package/dist/cli/commands/legacy-sweep.js +64 -0
  19. package/dist/cli/commands/memory.js +59 -12
  20. package/dist/cli/commands/proxy.js +6 -1
  21. package/dist/cli/commands/rules.js +29 -12
  22. package/dist/cli/commands/skills.js +42 -9
  23. package/dist/cli/commands/tracker.js +9 -3
  24. package/dist/cli/commands/uninstall.js +354 -60
  25. package/dist/commands/bug-analysis.md +71 -122
  26. package/dist/commands/code-review.md +11 -11
  27. package/dist/commands/debug.md +2 -2
  28. package/dist/commands/dynamic-build.md +25 -25
  29. package/dist/commands/dynamic-plan.md +2 -2
  30. package/dist/commands/dynamic-profile.md +2 -2
  31. package/dist/commands/dynamic-tickets.md +2 -2
  32. package/dist/commands/explore.md +2 -2
  33. package/dist/commands/implement.md +48 -34
  34. package/dist/commands/plan.md +2 -2
  35. package/dist/commands/release.md +3 -3
  36. package/dist/commands/research.md +2 -2
  37. package/dist/commands/resolve.md +50 -46
  38. package/dist/commands/self-review.md +10 -9
  39. package/dist/compliance-off/agents/git.md +120 -0
  40. package/dist/compliance-off/agents/triage.md +180 -0
  41. package/dist/compliance-off/commands/bug-analysis.md +398 -0
  42. package/dist/compliance-off/commands/code-review.md +520 -0
  43. package/dist/compliance-off/commands/dynamic-build.md +1251 -0
  44. package/dist/compliance-off/commands/dynamic-tickets.md +676 -0
  45. package/dist/compliance-off/commands/implement.md +736 -0
  46. package/dist/compliance-off/commands/plan.md +671 -0
  47. package/dist/compliance-off/commands/release.md +309 -0
  48. package/dist/compliance-off/commands/resolve.md +742 -0
  49. package/dist/compliance-off/skills/git/references/decision-markers.md +17 -0
  50. package/dist/compliance-off/skills/git/references/learn-conventions.md +80 -0
  51. package/dist/compliance-off/skills/git/references/pr/check-ci-status.md +33 -0
  52. package/dist/compliance-off/skills/git/references/pr/check-merge-readiness.md +49 -0
  53. package/dist/compliance-off/skills/git/references/pr/fetch-review-threads.md +52 -0
  54. package/dist/compliance-off/skills/git/references/pr/validate-branch.md +47 -0
  55. package/dist/compliance-off/skills/git/references/tracker/_contract.md +33 -0
  56. package/dist/compliance-off/skills/git/references/tracker/_mcp.md +153 -0
  57. package/dist/compliance-off/skills/git/references/tracker/github/create-release.md +35 -0
  58. package/dist/compliance-off/skills/git/references/tracker/github/ensure-pr-ready.md +100 -0
  59. package/dist/compliance-off/skills/git/references/tracker/github/ensure-traceable-issue.md +74 -0
  60. package/dist/compliance-off/skills/git/references/tracker/github/fetch-issue.md +69 -0
  61. package/dist/compliance-off/skills/git/references/tracker/github/fetch-issues-batch.md +65 -0
  62. package/dist/compliance-off/skills/git/references/tracker/github/gather-release-evidence.md +48 -0
  63. package/dist/compliance-off/skills/git/references/tracker/github/setup-task.md +77 -0
  64. package/dist/compliance-off/skills/git/references/tracker/jira/create-release.md +35 -0
  65. package/dist/compliance-off/skills/git/references/tracker/jira/ensure-pr-ready.md +100 -0
  66. package/dist/compliance-off/skills/git/references/tracker/jira/ensure-traceable-issue.md +61 -0
  67. package/dist/compliance-off/skills/git/references/tracker/jira/fetch-issue.md +51 -0
  68. package/dist/compliance-off/skills/git/references/tracker/jira/fetch-issues-batch.md +63 -0
  69. package/dist/compliance-off/skills/git/references/tracker/jira/gather-release-evidence.md +47 -0
  70. package/dist/compliance-off/skills/git/references/tracker/jira/setup-task.md +82 -0
  71. package/dist/compliance-off/skills/git/references/tracker/linear/create-release.md +35 -0
  72. package/dist/compliance-off/skills/git/references/tracker/linear/ensure-pr-ready.md +100 -0
  73. package/dist/compliance-off/skills/git/references/tracker/linear/ensure-traceable-issue.md +61 -0
  74. package/dist/compliance-off/skills/git/references/tracker/linear/fetch-issue.md +51 -0
  75. package/dist/compliance-off/skills/git/references/tracker/linear/fetch-issues-batch.md +63 -0
  76. package/dist/compliance-off/skills/git/references/tracker/linear/gather-release-evidence.md +47 -0
  77. package/dist/compliance-off/skills/git/references/tracker/linear/setup-task.md +83 -0
  78. package/dist/compliance-off/skills/git/references/trust-rule.md +7 -0
  79. package/dist/core/agent-models.js +19 -4
  80. package/dist/core/area-pause-notice.js +44 -0
  81. package/dist/core/assets.js +126 -17
  82. package/dist/core/compliance-off-files.js +27 -0
  83. package/dist/core/curation-move-skip.js +4 -5
  84. package/dist/core/curation-queue-cleanup.js +6 -5
  85. package/dist/core/curation-store.js +1 -0
  86. package/dist/core/curation-variants.js +460 -112
  87. package/dist/core/evidence-policy.js +10 -8
  88. package/dist/core/flags.js +2 -2
  89. package/dist/core/installed-variants.js +63 -0
  90. package/dist/core/manifest.js +48 -5
  91. package/dist/core/mds-variants.js +700 -3
  92. package/dist/core/orphan-sweep.js +11 -2
  93. package/dist/core/plugins.js +69 -12
  94. package/dist/core/tracked-areas.js +138 -14
  95. package/dist/curation-compliance-off/agents/triage.md +176 -0
  96. package/dist/curation-compliance-off/commands/bug-analysis.md +349 -0
  97. package/dist/curation-compliance-off/commands/code-review.md +481 -0
  98. package/dist/curation-compliance-off/commands/dynamic-build.md +1211 -0
  99. package/dist/curation-compliance-off/commands/dynamic-tickets.md +634 -0
  100. package/dist/curation-compliance-off/commands/implement.md +689 -0
  101. package/dist/curation-compliance-off/commands/plan.md +631 -0
  102. package/dist/curation-compliance-off/commands/release.md +279 -0
  103. package/dist/curation-compliance-off/commands/resolve.md +692 -0
  104. package/dist/curation-off/agents/code.md +10 -10
  105. package/dist/curation-off/agents/fix.md +7 -7
  106. package/dist/curation-off/agents/harden.md +7 -6
  107. package/dist/curation-off/agents/refactor.md +6 -6
  108. package/dist/curation-off/agents/triage.md +7 -0
  109. package/dist/curation-off/commands/bug-analysis.md +69 -119
  110. package/dist/curation-off/commands/code-review.md +11 -11
  111. package/dist/curation-off/commands/debug.md +2 -2
  112. package/dist/curation-off/commands/dynamic-build.md +25 -25
  113. package/dist/curation-off/commands/explore.md +2 -2
  114. package/dist/curation-off/commands/implement.md +48 -34
  115. package/dist/curation-off/commands/plan.md +2 -2
  116. package/dist/curation-off/commands/release.md +1 -1
  117. package/dist/curation-off/commands/resolve.md +50 -46
  118. package/dist/curation-off/commands/self-review.md +10 -9
  119. package/dist/hud/components/curation-counts.js +27 -0
  120. package/dist/skills/git/references/pr/post-resolution-summary.md +12 -11
  121. package/dist/skills/git/references/pr/post-review-summary.md +12 -11
  122. package/dist/skills/git/references/pr/resolve-review-threads.md +1 -1
  123. package/dist/skills/git/references/pr/update-pr-evidence.md +2 -2
  124. package/dist/skills/git/references/publication-gate.md +6 -7
  125. package/dist/skills/git/references/tracker/github/manage-debt.md +6 -2
  126. package/dist/skills/git/references/tracker/jira/manage-debt.md +6 -2
  127. package/dist/skills/git/references/tracker/linear/manage-debt.md +6 -2
  128. package/dist/targets/claude-code/behavior-install.js +23 -9
  129. package/dist/targets/claude-code/charter-render.js +374 -0
  130. package/dist/targets/claude-code/compliance-install.js +32 -12
  131. package/dist/targets/claude-code/compliance-off-files.js +245 -0
  132. package/dist/targets/claude-code/curation-install.js +570 -100
  133. package/dist/targets/claude-code/installer.js +467 -62
  134. package/dist/targets/claude-code/language-stamp.js +6 -0
  135. package/dist/targets/claude-code/linked-folder.js +182 -0
  136. package/dist/targets/claude-code/reference-converge.js +118 -0
  137. package/dist/targets/claude-code/tracker-install.js +21 -8
  138. package/dist/targets/claude-code/tracker-servers.js +20 -6
  139. package/dist/targets/claude-code/workflow-folder-install.js +231 -0
  140. package/dist/variant-axes.json +109 -0
  141. package/package.json +1 -1
  142. package/src/assets/agents/_partials/_code.mds +6 -6
  143. package/src/assets/agents/code.mds +4 -4
  144. package/src/assets/agents/evaluate.md +7 -13
  145. package/src/assets/agents/fix.mds +1 -1
  146. package/src/assets/agents/git.mds +34 -2
  147. package/src/assets/agents/harden.mds +7 -6
  148. package/src/assets/agents/skim.mds +1 -1
  149. package/src/assets/agents/test.md +21 -12
  150. package/src/assets/agents/triage.mds +9 -0
  151. package/src/assets/agents/validate.md +27 -34
  152. package/src/assets/commands/_partials/_compliance.mds +5 -3
  153. package/src/assets/commands/_partials/_decisions.mds +10 -8
  154. package/src/assets/commands/_partials/_engine.mds +9 -8
  155. package/src/assets/commands/_partials/_publication.mds +10 -1
  156. package/src/assets/commands/_partials/_settings.mds +2 -2
  157. package/src/assets/commands/bug-analysis.mds +80 -126
  158. package/src/assets/commands/code-review.mds +52 -9
  159. package/src/assets/commands/dynamic-build.mds +109 -15
  160. package/src/assets/commands/dynamic-tickets.mds +13 -0
  161. package/src/assets/commands/implement.mds +145 -33
  162. package/src/assets/commands/plan.mds +19 -0
  163. package/src/assets/commands/release.mds +50 -1
  164. package/src/assets/commands/resolve.mds +229 -44
  165. package/src/assets/commands/self-review.mds +8 -7
  166. package/src/assets/mds/git/_ops.mds +29 -1
  167. package/src/assets/mds/git/_ops_pr.mds +20 -10
  168. package/src/assets/mds/git/_ops_steps.mds +27 -0
  169. package/src/assets/mds/git/_pr.mds +15 -15
  170. package/src/assets/mds/git/_references.mds +12 -7
  171. package/src/assets/mds/tracker/_common.mds +4 -0
  172. package/src/assets/mds/tracker/_github.mds +43 -4
  173. package/src/assets/mds/tracker/_jira.mds +44 -4
  174. package/src/assets/mds/tracker/_linear.mds +44 -4
  175. package/src/assets/rules/quality.md +2 -0
  176. package/src/assets/scripts/ci-wait.cjs +493 -118
  177. package/src/assets/scripts/hooks/assets/implement-installed +1 -0
  178. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +13 -11
  179. package/src/assets/scripts/hooks/background-memory-update +79 -1
  180. package/src/assets/scripts/hooks/json-helper.cjs +44 -2
  181. package/src/assets/scripts/hooks/lib/curation-store.cjs +82 -20
  182. package/src/assets/scripts/hooks/lib/render-decisions.cjs +9 -1
  183. package/src/assets/scripts/hooks/lib/tracked-areas.cjs +226 -33
  184. package/src/assets/scripts/hooks/memory-permission +10 -2
  185. package/src/assets/scripts/hooks/memory-worker +11 -0
  186. package/src/assets/scripts/hooks/pre-compact-memory +11 -0
  187. package/src/assets/scripts/hooks/preamble +18 -8
  188. package/src/assets/scripts/hooks/queue-append +218 -1
  189. package/src/assets/scripts/hooks/session-start-context +89 -37
  190. package/src/assets/scripts/hooks/session-start-memory +39 -0
  191. package/src/assets/scripts/hooks/session-start-orchestrator +4 -2
  192. package/src/assets/scripts/pr-evidence.cjs +46 -4
  193. package/src/assets/scripts/resolve-settings.cjs +244 -30
  194. package/src/assets/scripts/verify-evidence.cjs +708 -124
  195. package/src/assets/skills/agent-references/SKILL.md +7 -1
  196. package/src/assets/skills/agent-references/references/commands/implement-levels.md +6 -4
  197. package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +3 -3
  198. package/src/assets/skills/agent-references/references/commands/plan-levels.md +1 -1
  199. package/src/assets/skills/agent-references/references/commands/resolve-levels.md +3 -2
  200. package/src/assets/skills/agent-references/references/synthesize/bug-analysis.md +6 -2
  201. package/src/assets/skills/agent-references/references/synthesize/review.md +4 -4
  202. package/src/assets/skills/agent-references/references/triage/severity-tiers.md +43 -0
  203. package/src/assets/skills/agent-references/references/validate/passed-alone.md +14 -0
  204. package/src/assets/skills/docs-framework/SKILL.md +5 -2
  205. package/src/assets/skills/git/SKILL.md +3 -3
  206. package/src/assets/skills/git/references/github-api.md +2 -139
  207. package/src/assets/skills/git/references/github-posting.md +158 -0
  208. package/src/assets/skills/review-methodology/SKILL.md +1 -1
  209. package/src/assets/skills/review-methodology/references/patterns.md +1 -1
  210. package/src/assets/skills/review-methodology/references/report-template.md +1 -2
  211. package/src/assets/skills/testing/SKILL.md +2 -4
@@ -4,9 +4,13 @@
4
4
  // The CI wait for one pushed head (Token Diet wave 2, #421). /implement Phase 9 and
5
5
  // /resolve Phase 8 call it inline, through the Bash tool, instead of re-spawning the
6
6
  // Git agent once a minute: a poll costs no model tokens, so the wait happens here.
7
- // It reads CI with `gh pr view` and `gh pr checks` only, classifies the head it was
8
- // told about, and prints exactly one line. Installed beside hud.sh and
9
- // redact-secrets.cjs under ~/.devflow/scripts/.
7
+ // It reads CI with `gh pr view` and `gh pr checks`, plus a read-only `gh api` probe of
8
+ // the repository's workflow list when the check list is empty or holds external checks
9
+ // only (D-CI-WORKFLOWS): at most one answered probe per run, and its answer is kept for
10
+ // the run; a failed probe is a failed poll, and the next poll probes again. It waits for
11
+ // CI to start until CI_WAIT_MAX_SECONDS, classifies the head it was told about, and
12
+ // prints exactly one line.
13
+ // Installed beside hud.sh and redact-secrets.cjs under ~/.devflow/scripts/.
10
14
  //
11
15
  // Usage:
12
16
  // node ci-wait.cjs --pr <n> --head <sha>
@@ -24,12 +28,21 @@
24
28
  // writes it. Exit 0 whenever a line was printed; a reader treats a missing or
25
29
  // unparseable line, an unlisted status or a non-zero exit as INDETERMINATE.
26
30
  //
27
- // Design constraints (binding):
28
- // - D-CI-WAIT-INLINE: this is plumbing. The only judgment in it is the bucket
29
- // classifier, which mirrors check-ci-status step 5 clause for clause. Every
30
- // subprocess is `gh`, spawned through exec() with an argv array and
31
- // `shell: false`, stdin ignored, a timeout and a maxBuffer. gh output never
32
- // reaches stdout raw: a check name passes sanitizeName() first.
31
+ // Design constraints (binding). The text of each verdict rule is written once, at the
32
+ // marker named below; this list points to it. The workflow-probe rule has two readers,
33
+ // mergeGate and matchedStep, and one reading of the probe value, probeAnswer.
34
+ // - D-CI-WAIT-INLINE: this is plumbing. The only judgment in it is the classifier
35
+ // (D-CI-CLASSIFIER, D-CI-WORKFLOW-RULE) and the merge gate (D-CI-MERGE-STATE,
36
+ // D-CI-WORKFLOWS, D-CI-PR-STATE). Every subprocess is `gh`, spawned through exec()
37
+ // with an argv array and `shell: false`, stdin ignored, a timeout and a maxBuffer.
38
+ // gh output never reaches stdout raw: a check name passes sanitizeName() first.
39
+ // - D-CI-WORKFLOW-RULE: see classifyChecks. PASSING does not prove that the test
40
+ // workflow ran by name.
41
+ // - D-CI-MERGE-STATE, D-CI-WORKFLOWS: see mergeGate (an empty check list) and matchedStep
42
+ // (rows that are all external checks, which read the probe answer themselves). The
43
+ // workflow files are listed, never opened: no workflow YAML is parsed, so which events
44
+ // a workflow reacts to is not judged.
45
+ // - D-CI-PR-STATE, D-CI-HEAD-BINDING: see waitForCi.
33
46
  // - D-CI-NO-LITERAL-SLEEP: the wait happens inside node, through clock.sleep(),
34
47
  // never through a shell `sleep`. Claude Code 2.1.294 blocks any Bash command
35
48
  // that starts with a literal `sleep N`, so no prompt and no script may wait that
@@ -42,14 +55,31 @@
42
55
  // name lists), and every wait is capped at CI_WAIT_MAX_SECONDS.
43
56
  //
44
57
  // gh facts this file rests on, probed on gh 2.88.1 on 2026-10-09:
45
- // - `gh pr view 999999999 --json headRefOid` exits 1 with stderr `GraphQL: Could
46
- // not resolve to a PullRequest with the number of 999999999.
47
- // (repository.pullRequest)` and an empty stdout. Only that text on exit 1 is
48
- // NO_PR; any other non-zero exit is a gh failure and never PASSING.
58
+ // - `gh pr view 999999999 --json headRefOid,mergeable,state` exits 1 with stderr
59
+ // `GraphQL: Could not resolve to a PullRequest with the number of 999999999.
60
+ // (repository.pullRequest)` and an empty stdout (re-probed with `mergeable` and
61
+ // `state` in the field list on 2026-10-22: the same text). Only that text on exit 1
62
+ // is NO_PR; any other non-zero exit is a gh failure and never PASSING.
49
63
  // - `gh pr checks <n> --json` offers exactly bucket, completedAt, description,
50
64
  // event, link, name, startedAt, state and workflow. There is no SHA field, and it
51
65
  // reports only the PR's current head. So the head is bound by comparing
52
66
  // `gh pr view --json headRefOid` with `--head` (D-CI-HEAD-BINDING).
67
+ // - `workflow` names the GitHub Actions workflow of a check, and is "" for a commit
68
+ // status and for a third-party app's check run (probed on public PRs, gh 2.88.1,
69
+ // 2026-10-22).
70
+ // - `gh pr view --json mergeable` is MERGEABLE, CONFLICTING or UNKNOWN. GitHub
71
+ // computes it lazily, so UNKNOWN is common right after a push or a base move. A
72
+ // merged PR reads `state` MERGED with `mergeable` UNKNOWN (probed on gh 2.88.1,
73
+ // 2026-10-22), which is why the poll asks `state` (D-CI-PR-STATE).
74
+ // - `gh api` fills the placeholders `{owner}` and `{repo}` from the repository of the
75
+ // current directory (`gh api --help`), and a call with no field flag is a GET.
76
+ // `repos/{owner}/{repo}/actions/workflows?per_page=100` answers `{ total_count,
77
+ // workflows: [{ path, state, ... }] }`, one row per workflow, with `state` active
78
+ // for a live one and a `path` such as `.github/workflows/ci.yml` (probed on gh
79
+ // 2.88.1, 2026-10-22). It is used rather than `gh workflow list` because
80
+ // `total_count` tells a whole list from a cut one. A row whose path is not under
81
+ // `.github/workflows/`, such as a `dynamic/...` path that GitHub gives its own
82
+ // apps, is not a workflow file of the repository.
53
83
 
54
84
  'use strict';
55
85
 
@@ -69,10 +99,13 @@ const STATUSES = Object.freeze(['PASSING', 'FAILING', 'PENDING', 'NO_CI', 'NO_PR
69
99
  /**
70
100
  * D-CI-REASONS: why an INDETERMINATE was returned. `unrecognised-bucket` also covers
71
101
  * a set of buckets that gives no verdict (every check `skipping`): the classifier
72
- * could call neither a pass nor a failure.
102
+ * could call neither a pass nor a failure. The other rule reasons are defined at their
103
+ * markers: `no-workflow-check` (D-CI-WORKFLOW-RULE, D-CI-WORKFLOWS), `merge-conflict`
104
+ * (D-CI-MERGE-STATE) and `not-open` (D-CI-PR-STATE).
73
105
  */
74
106
  const REASONS = Object.freeze([
75
107
  'gh-failed', 'unparseable', 'unrecognised-bucket', 'head-mismatch', 'deadline', 'usage',
108
+ 'no-workflow-check', 'merge-conflict', 'not-open',
76
109
  ]);
77
110
 
78
111
  /** `gh pr checks` categorises `state` into these buckets (gh 2.88.1, `--help`). */
@@ -85,10 +118,7 @@ const BUCKETS = Object.freeze(['pass', 'fail', 'pending', 'skipping', 'cancel'])
85
118
  * Bash timeout of 600000 ms the callers pass
86
119
  * POLL_INTERVAL_SECONDS the sleep between polls; polls cost no model tokens,
87
120
  * so the cadence is set by gh latency and rate limits
88
- * SETTLE_WINDOW_SECONDS how long a head mismatch or an empty check list may
89
- * persist before it is a verdict (GitHub creates
90
- * checks and moves the PR head a few seconds after a
91
- * push)
121
+ * WORKFLOWS_PAGE_ROWS the rows asked of the workflow list in its one page
92
122
  * GH_CALL_CAP_SECONDS one gh call's timeout is min(this, time remaining),
93
123
  * so the last call ends before the deadline
94
124
  * MAX_POLLS the loop bound when the clock does not move
@@ -96,10 +126,10 @@ const BUCKETS = Object.freeze(['pass', 'fail', 'pending', 'skipping', 'cancel'])
96
126
  */
97
127
  const CI_WAIT_MAX_SECONDS = 570;
98
128
  const POLL_INTERVAL_SECONDS = 20;
99
- const SETTLE_WINDOW_SECONDS = 90;
100
129
  const GH_CALL_CAP_SECONDS = 30;
101
130
  const MAX_POLLS = 40;
102
131
  const MAX_CONSECUTIVE_GH_FAILURES = 3;
132
+ const WORKFLOWS_PAGE_ROWS = 100;
103
133
 
104
134
  /** A PR with more check rows than this is read as an unparseable reply. */
105
135
  const MAX_CHECKS = 1000;
@@ -143,6 +173,18 @@ const LINE_RE = new RegExp(
143
173
 
144
174
  const USAGE_LINE = 'CI INDETERMINATE pr=- head=- waited=0 reason=usage';
145
175
 
176
+ /**
177
+ * D-CI-WORKFLOWS: the probe, a GET of the repository's workflow list. It is a fixed
178
+ * argv with no field flag, no method flag and no input, and it names neither the PR nor
179
+ * the head; gh fills `{owner}/{repo}` from the working directory.
180
+ */
181
+ const WORKFLOWS_ARGS = Object.freeze(['api', 'repos/{owner}/{repo}/actions/workflows?per_page=' + WORKFLOWS_PAGE_ROWS]);
182
+ /** A workflow file lives here; the `dynamic/...` paths GitHub adds for its own apps do not. */
183
+ const WORKFLOWS_DIR = '.github/workflows/';
184
+
185
+ /** The fields every `gh pr view` call of a poll asks for. */
186
+ const VIEW_FIELDS = 'headRefOid,mergeable,state';
187
+
146
188
  // ---------------------------------------------------------------------------
147
189
  // Types
148
190
  // ---------------------------------------------------------------------------
@@ -156,16 +198,34 @@ const USAGE_LINE = 'CI INDETERMINATE pr=- head=- waited=0 reason=usage';
156
198
  * @typedef {{ now: () => number, sleep: (ms: number) => Promise<void> }} Clock
157
199
  * `now` is in milliseconds. Tests inject a clock whose sleep and exec advance it.
158
200
  *
159
- * @typedef {{ name: string, bucket: string }} Check
201
+ * @typedef {{ name: string, bucket: string, workflow?: string }} Check
202
+ * `workflow` is the GitHub Actions workflow name; "" or absent marks an external check.
160
203
  * @typedef {{ status: string, reason: string | null, failing: string[], pending: string[] }} Classification
161
204
  * @typedef {{ status: string, pr: string, head: string, failing: string[], pending: string[],
162
205
  * waited: number, reason: string | null }} CiResult
163
206
  * `head` is the first 7 hex characters.
164
207
  * @typedef {{ pr: string, head: string, exec: ExecFn, clock: Clock, env?: NodeJS.ProcessEnv }} WaitInput
208
+ * @typedef {{ state: 'deadline', rowsRead: boolean } | { state: 'failed' } | { state: 'pending', pending: string[] }
209
+ * | { state: 'open' }
210
+ * | { state: 'stall', status: string, reason: string | null }
211
+ * | { state: 'done', result: CiResult }} PollStep
212
+ * A `stall` keeps the wait going and names the line to give if the deadline comes first. A
213
+ * `deadline` is a poll that the deadline cut short, so it is no counted poll; `rowsRead` is
214
+ * true when the poll had read check rows, or a reply it could not read, before the cut.
215
+ * @typedef {{ status: string, reason: string | null, pending: string[] }} Last
216
+ * The line the last counted poll gives at the deadline.
165
217
  * @typedef {{ exec?: ExecFn, clock?: Clock, env?: NodeJS.ProcessEnv }} MainDeps
166
218
  * @typedef {{ code: number, stdout: string }} Outcome
167
219
  */
168
220
 
221
+ /**
222
+ * The line of a wait that has no verdict at the deadline: the start of the loop, an open or a
223
+ * failed poll, and a loop that ran out of MAX_POLLS. It is shared, so it is frozen.
224
+ *
225
+ * @type {Readonly<Last>}
226
+ */
227
+ const DEADLINE_LAST = Object.freeze({ status: 'INDETERMINATE', reason: 'deadline', pending: Object.freeze([]) });
228
+
169
229
  // ---------------------------------------------------------------------------
170
230
  // Argument gate
171
231
  // ---------------------------------------------------------------------------
@@ -224,14 +284,22 @@ function formatNames(names) {
224
284
  }
225
285
 
226
286
  /**
227
- * D-CI-CLASSIFIER: check-ci-status step 5, clause for clause, over `bucket`. In
228
- * priority order: any `pending` is PENDING; else any `fail` or `cancel` is FAILING;
229
- * else every check `pass` or `skipping` with at least one `pass` is PASSING; else
230
- * INDETERMINATE. PASSING is a positive conjunction, so an undocumented bucket, an
231
- * all-`skipping` set or an empty set never reaches it. merge-readiness.test.ts runs
232
- * every bucket multiset of 1 to 3 checks through this and through the prose clauses
233
- * and requires the statuses to agree. (The empty set is not classified here: an empty
234
- * list is "no checks", which waitForCi settles before it classifies.)
287
+ * D-CI-CLASSIFIER: check-ci-status step 5 over `bucket`, plus the stricter workflow
288
+ * rule. In priority order: any `pending` is PENDING; else any `fail` or `cancel` is
289
+ * FAILING; else every check `pass` or `skipping` with at least one `pass` is PASSING;
290
+ * else INDETERMINATE. PASSING is a positive conjunction, so an undocumented bucket, an
291
+ * all-`skipping` set or an empty set never reaches it.
292
+ *
293
+ * D-CI-WORKFLOW-RULE: a set that passes by step 5 is PASSING only when at least one
294
+ * passing check is a GitHub Actions job (a non-empty string `workflow`). A set with
295
+ * passes from external checks alone, a commit status or a third-party app, is
296
+ * INDETERMINATE (no-workflow-check): the repository's workflow may not have run. So
297
+ * PASSING proves that a GitHub Actions check passed and nothing is pending or failing;
298
+ * it does not prove that the test workflow ran by name. merge-readiness.test.ts runs
299
+ * every multiset of 1 to 3 checks through this and through the prose clauses and
300
+ * requires the statuses to agree, or this to be stricter only by no-workflow-check.
301
+ * (The empty set is not classified here: an empty list is "no checks", which
302
+ * waitForCi reads as no classification at all.)
235
303
  *
236
304
  * @param {readonly Check[]} checks
237
305
  * @returns {Classification}
@@ -240,6 +308,7 @@ function classifyChecks(checks) {
240
308
  const failing = [];
241
309
  const pending = [];
242
310
  let pass = 0;
311
+ let actionsPass = 0;
243
312
  let unrecognised = 0;
244
313
  const count = Math.min(Array.isArray(checks) ? checks.length : 0, MAX_CHECKS);
245
314
  for (let i = 0; i < count; i++) {
@@ -248,14 +317,21 @@ function classifyChecks(checks) {
248
317
  case 'pending': pending.push(check.name); break;
249
318
  case 'fail':
250
319
  case 'cancel': failing.push(check.name); break;
251
- case 'pass': pass++; break;
320
+ case 'pass':
321
+ pass++;
322
+ if (typeof check.workflow === 'string' && check.workflow !== '') actionsPass++;
323
+ break;
252
324
  case 'skipping': break;
253
325
  default: unrecognised++;
254
326
  }
255
327
  }
256
328
  if (pending.length > 0) return { status: 'PENDING', reason: null, failing, pending };
257
329
  if (failing.length > 0) return { status: 'FAILING', reason: null, failing, pending };
258
- if (unrecognised === 0 && pass > 0) return { status: 'PASSING', reason: null, failing, pending };
330
+ if (unrecognised === 0 && pass > 0) {
331
+ return actionsPass > 0
332
+ ? { status: 'PASSING', reason: null, failing, pending }
333
+ : { status: 'INDETERMINATE', reason: 'no-workflow-check', failing, pending };
334
+ }
259
335
  return { status: 'INDETERMINATE', reason: 'unrecognised-bucket', failing, pending };
260
336
  }
261
337
 
@@ -338,6 +414,17 @@ function normalize(res) {
338
414
  };
339
415
  }
340
416
 
417
+ /**
418
+ * Whether a gh call ran out its timeout: spawnSync then reports the error code ETIMEDOUT.
419
+ * A call that failed in any other way, fast or slow, did not.
420
+ *
421
+ * @param {ExecResult | null} res
422
+ * @returns {boolean}
423
+ */
424
+ function timedOut(res) {
425
+ return res !== null && typeof res === 'object' && Boolean(res.error) && res.error.code === 'ETIMEDOUT';
426
+ }
427
+
341
428
  // ---------------------------------------------------------------------------
342
429
  // Reply parsers
343
430
  // ---------------------------------------------------------------------------
@@ -355,13 +442,20 @@ function parseJson(text) {
355
442
  }
356
443
 
357
444
  /**
445
+ * The `gh pr view --json headRefOid,mergeable,state` reply. `headRefOid` and `state` are
446
+ * strict: a head that is not 40 lowercase hex, or a `state` that is not a string, makes the
447
+ * reply unparseable. A `mergeable` that is absent or not a string is read as null, the same
448
+ * as UNKNOWN, and never makes the reply unparseable.
449
+ *
358
450
  * @param {string} stdout
359
- * @returns {string | null} the 40-hex headRefOid, or null for any other shape
451
+ * @returns {{ oid: string, mergeable: string | null, state: string } | null} null for any other shape
360
452
  */
361
- function parseHeadRefOid(stdout) {
453
+ function parseView(stdout) {
362
454
  const value = parseJson(stdout);
363
455
  if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
364
- return typeof value.headRefOid === 'string' && HEAD_RE.test(value.headRefOid) ? value.headRefOid : null;
456
+ if (typeof value.headRefOid !== 'string' || !HEAD_RE.test(value.headRefOid)) return null;
457
+ if (typeof value.state !== 'string') return null;
458
+ return { oid: value.headRefOid, mergeable: typeof value.mergeable === 'string' ? value.mergeable : null, state: value.state };
365
459
  }
366
460
 
367
461
  /**
@@ -374,48 +468,287 @@ function parseChecks(stdout) {
374
468
  const checks = [];
375
469
  for (const row of value) {
376
470
  if (row === null || typeof row !== 'object' || typeof row.name !== 'string' || typeof row.bucket !== 'string') return null;
377
- checks.push({ name: row.name, bucket: row.bucket });
471
+ checks.push({ name: row.name, bucket: row.bucket, workflow: typeof row.workflow === 'string' ? row.workflow : '' });
378
472
  }
379
473
  return checks;
380
474
  }
381
475
 
476
+ /**
477
+ * D-CI-WORKFLOWS: whether the workflow list holds an active workflow file. A row is a
478
+ * workflow file when its `state` is exactly `active` and its `path` starts with
479
+ * `.github/workflows/`; the `dynamic/...` paths of GitHub's own apps and every
480
+ * disabled or deleted row are not. The reply is read whole before it is judged, so a
481
+ * row without a string `path` and `state` makes the reply unreadable even beside an
482
+ * active one. The list is one page: no match is a "no" only when the rows read equal
483
+ * `total_count` (and fit the page), and a page that is not all of the list proves
484
+ * nothing about the rest. The workflow files themselves are never opened.
485
+ *
486
+ * @param {string} stdout the `gh api` reply
487
+ * @returns {boolean | null} true: some active workflow file. false: none, and the
488
+ * list is complete. null: bad JSON, a bad row, or an incomplete page.
489
+ */
490
+ function parseActiveWorkflows(stdout) {
491
+ const value = parseJson(stdout);
492
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
493
+ const listed = value.workflows;
494
+ const total = value.total_count;
495
+ if (!Array.isArray(listed) || listed.length > WORKFLOWS_PAGE_ROWS) return null;
496
+ if (typeof total !== 'number' || !Number.isInteger(total) || total < 0) return null;
497
+ let active = false;
498
+ for (const row of listed) {
499
+ if (row === null || typeof row !== 'object' || typeof row.path !== 'string' || typeof row.state !== 'string') return null;
500
+ if (row.state === 'active' && row.path.startsWith(WORKFLOWS_DIR)) active = true;
501
+ }
502
+ if (active) return true;
503
+ return listed.length === total ? false : null;
504
+ }
505
+
382
506
  // ---------------------------------------------------------------------------
383
507
  // The wait
384
508
  // ---------------------------------------------------------------------------
385
509
 
386
510
  /**
387
- * One settle window, as a pure step. `firstAt` is when the condition was first seen,
388
- * or null when the poll before did not see it; the window opens at `now` then. It is
389
- * done once `settleMs` have passed since it opened. The caller keeps one clock per
390
- * window and stores the returned `firstAt`.
511
+ * D-CI-MERGE-STATE: whether GitHub reports the PR as conflicting. It is the one rule for a
512
+ * conflict. mergeGate answers 'conflict' by it, and a caller that has read only the merge
513
+ * state asks it before it reads any check.
514
+ *
515
+ * @param {unknown} mergeable the `gh pr view` value in any shape
516
+ * @returns {boolean}
517
+ */
518
+ function isConflicting(mergeable) {
519
+ return mergeable === 'CONFLICTING';
520
+ }
521
+
522
+ /**
523
+ * D-CI-WORKFLOWS: what the workflow probe has said, as one answer. It is the one reading
524
+ * of the probe's value, used by mergeGate and by matchedStep alike. Only the booleans
525
+ * true and false are answers; null, left out and any other value ask for the probe.
526
+ *
527
+ * @param {unknown} workflowsActive
528
+ * @returns {'active' | 'none' | 'ask'}
529
+ */
530
+ function probeAnswer(workflowsActive) {
531
+ if (workflowsActive === true) return 'active';
532
+ return workflowsActive === false ? 'none' : 'ask';
533
+ }
534
+
535
+ /**
536
+ * D-CI-MERGE-STATE, D-CI-WORKFLOWS: the one merge rule. GitHub computes `mergeable`
537
+ * lazily, and runs no pull request workflow on a conflicting PR, so the checks of a head
538
+ * say nothing about the PR unless GitHub reports it MERGEABLE. An empty check list says
539
+ * nothing either unless the repository has no workflow that could still start.
540
+ * `status` is a classifier status ('NO_CI' for an empty list), or null before any check
541
+ * is read; `mergeable` is the `gh pr view` value in any shape; `workflowsActive` is what
542
+ * the workflow probe found: true (an active workflow file), false (none), or null or
543
+ * left out when it was not made or gave no answer. Each case has its own answer, so a
544
+ * caller never reads `mergeable` again. The probe value has two readers, both through
545
+ * probeAnswer: mergeGate, for an empty list, and matchedStep, for rows that are all external
546
+ * checks:
547
+ * 'conflict' isConflicting(mergeable), whatever the status.
548
+ * 'await-merge-state' PASSING or NO_CI while mergeable is anything but MERGEABLE
549
+ * (UNKNOWN, absent or odd). It is neither a verdict nor a conflict:
550
+ * waitForCi keeps the wait open for another read, and a single read
551
+ * takes it as unknown.
552
+ * 'probe' NO_CI on MERGEABLE with no usable probe answer (null, left out or
553
+ * not a boolean): the workflow list has to be asked.
554
+ * 'workflow-active' NO_CI on MERGEABLE, and the probe found an active workflow file:
555
+ * a run may still start.
556
+ * 'keep' the status stands: PASSING on MERGEABLE, NO_CI on MERGEABLE with a
557
+ * probe that found no workflow file (waitForCi holds that NO_CI until
558
+ * the deadline, so checks that appear later decide), and FAILING,
559
+ * PENDING and INDETERMINATE, which do not depend on the merge state
560
+ * or the probe.
561
+ *
562
+ * @param {string | null} status
563
+ * @param {unknown} mergeable
564
+ * @param {boolean | null} [workflowsActive]
565
+ * @returns {'conflict' | 'await-merge-state' | 'probe' | 'workflow-active' | 'keep'}
566
+ */
567
+ function mergeGate(status, mergeable, workflowsActive) {
568
+ if (isConflicting(mergeable)) return 'conflict';
569
+ const mergeableNow = mergeable === 'MERGEABLE';
570
+ if (status === 'PASSING') return mergeableNow ? 'keep' : 'await-merge-state';
571
+ if (status !== 'NO_CI') return 'keep';
572
+ if (!mergeableNow) return 'await-merge-state';
573
+ const answer = probeAnswer(workflowsActive);
574
+ if (answer === 'active') return 'workflow-active';
575
+ return answer === 'none' ? 'keep' : 'probe';
576
+ }
577
+
578
+ /**
579
+ * @param {string} status
580
+ * @param {string | null} reason
581
+ * @param {string[]} [failing]
582
+ * @param {string[]} [pending]
583
+ * @returns {{ state: 'done', status: string, reason: string | null, failing: string[], pending: string[] }}
584
+ */
585
+ function done(status, reason, failing = [], pending = []) {
586
+ return { state: 'done', status, reason, failing, pending };
587
+ }
588
+
589
+ /**
590
+ * A step that keeps the wait going and names the line to give if the deadline comes first.
391
591
  *
392
- * @param {number | null} firstAt
393
- * @param {number} now
394
- * @param {number} settleMs
395
- * @returns {{ firstAt: number, done: boolean }}
396
- */
397
- function settle(firstAt, now, settleMs) {
398
- const opened = firstAt === null ? now : firstAt;
399
- return { firstAt: opened, done: now - opened >= settleMs };
592
+ * @param {string} status
593
+ * @param {string | null} reason
594
+ * @returns {{ state: 'stall', status: string, reason: string | null }}
595
+ */
596
+ function stall(status, reason) {
597
+ return { state: 'stall', status, reason };
598
+ }
599
+
600
+ /**
601
+ * The outcome of one poll on the named head, as a pure function of what the poll read
602
+ * (the rules are classifyChecks's and mergeGate's). `verdict` is the classification
603
+ * of the rows, or null for an empty list. `workflowsActive` is the probe's answer (see
604
+ * mergeGate). An empty list reads it through mergeGate, and each answer of mergeGate maps
605
+ * to one step. This function reads it itself (probeAnswer) on the external-only path only:
606
+ * rows that are all external checks (no-workflow-check), where a workflow run that has not
607
+ * started yet could still change the verdict. It never reads `mergeable` itself. The
608
+ * outcome is one of:
609
+ * pending keep waiting: a pending check
610
+ * open a pass or an empty list while the gate awaits the merge state
611
+ * probe an empty list on MERGEABLE, or external-only rows, and the probe has no
612
+ * answer yet: the caller makes the workflow probe, then asks again with its
613
+ * answer
614
+ * stall keep waiting, with a line for the deadline: NO_CI for an empty list when the
615
+ * repository has no workflow file; INDETERMINATE (no-workflow-check) for an empty
616
+ * list or external-only rows when it has an active one, since a run may still
617
+ * start. A later poll that reads checks decides instead.
618
+ * done a final status and reason, with the failing and pending names
619
+ *
620
+ * @param {Classification | null} verdict
621
+ * @param {unknown} mergeable
622
+ * @param {boolean | null} [workflowsActive]
623
+ * @returns {{ state: 'pending', pending: string[] } | { state: 'open' } | { state: 'probe' }
624
+ * | { state: 'stall', status: 'INDETERMINATE', reason: 'no-workflow-check' }
625
+ * | { state: 'stall', status: 'NO_CI', reason: null }
626
+ * | ReturnType<typeof done>}
627
+ */
628
+ function matchedStep(verdict, mergeable, workflowsActive) {
629
+ const gate = mergeGate(verdict === null ? 'NO_CI' : verdict.status, mergeable, workflowsActive);
630
+ if (gate === 'conflict') return done('INDETERMINATE', 'merge-conflict');
631
+ if (verdict === null) {
632
+ if (gate === 'keep') return stall('NO_CI', null);
633
+ if (gate === 'workflow-active') return stall('INDETERMINATE', 'no-workflow-check');
634
+ return gate === 'probe' ? { state: 'probe' } : { state: 'open' };
635
+ }
636
+ if (verdict.reason === 'no-workflow-check') {
637
+ const answer = probeAnswer(workflowsActive);
638
+ if (answer === 'ask') return { state: 'probe' };
639
+ return answer === 'none' ? done('INDETERMINATE', 'no-workflow-check') : stall('INDETERMINATE', 'no-workflow-check');
640
+ }
641
+ if (verdict.status === 'PENDING') return { state: 'pending', pending: verdict.pending };
642
+ if (gate !== 'keep') return { state: 'open' };
643
+ return done(verdict.status, verdict.reason, verdict.failing, verdict.pending);
644
+ }
645
+
646
+ /**
647
+ * The line a counted poll leaves for the deadline: PENDING with its names, a stall with its
648
+ * status and reason, and INDETERMINATE (deadline) for an open or a failed poll, which
649
+ * clears an earlier stall. A step that is not a counted poll never gets here.
650
+ *
651
+ * @param {PollStep} step
652
+ * @returns {Last}
653
+ */
654
+ function lastOf(step) {
655
+ if (step.state === 'pending') return { status: 'PENDING', reason: null, pending: step.pending };
656
+ if (step.state === 'stall') return { status: step.status, reason: step.reason, pending: [] };
657
+ return DEADLINE_LAST;
658
+ }
659
+
660
+ /**
661
+ * The line at the end of the loop. NO_CI stands only when the deadline ended the loop:
662
+ * a loop that ran out of MAX_POLLS before the deadline has not waited out CI, so it reads
663
+ * INDETERMINATE (deadline).
664
+ *
665
+ * @param {Last} last
666
+ * @param {boolean} reachedDeadline
667
+ * @returns {Last}
668
+ */
669
+ function tailOf(last, reachedDeadline) {
670
+ return last.status === 'NO_CI' && !reachedDeadline ? DEADLINE_LAST : last;
671
+ }
672
+
673
+ /**
674
+ * D-CI-WORKFLOWS: the workflow probe, as one result. `gh` is waitForCi's bounded gh
675
+ * call, so the probe gets its timeout and, with no time left, no call at all. The result is
676
+ * one of:
677
+ * deadline no time was left, so no call was made
678
+ * failed the call was not answered or exited non-zero: a failed poll
679
+ * unparseable the reply cannot be read, or is one page of many (parseActiveWorkflows)
680
+ * answer `active`: true for some active workflow file, false for none
681
+ *
682
+ * @param {(args: readonly string[]) => Promise<GhAnswer | null>} gh
683
+ * @returns {Promise<{ kind: 'deadline' } | { kind: 'failed' } | { kind: 'unparseable' }
684
+ * | { kind: 'answer', active: boolean }>}
685
+ */
686
+ async function askWorkflows(gh) {
687
+ const probe = await gh(WORKFLOWS_ARGS);
688
+ if (probe === null) return { kind: 'deadline' };
689
+ if (!probe.answered || probe.status !== 0) return { kind: 'failed' };
690
+ const active = parseActiveWorkflows(probe.stdout);
691
+ return active === null ? { kind: 'unparseable' } : { kind: 'answer', active };
400
692
  }
401
693
 
402
694
  /**
403
695
  * D-CI-HEAD-BINDING: wait for the CI verdict of `head`, and of nothing else. `gh pr
404
696
  * checks` has no SHA field and reports the PR's current head, so each poll reads
405
697
  * `headRefOid` first. A different value means the checks on offer belong to another
406
- * head: they are not read, and the poll is PENDING until SETTLE_WINDOW_SECONDS have
407
- * passed since the first mismatch (GitHub moves the head and creates checks a few
408
- * seconds after a push), then INDETERMINATE (head-mismatch). A matching head clears
409
- * the mismatch clock. An empty check list settles the same way into NO_CI. Nothing
410
- * here is PASSING unless a poll saw this head and every check a pass.
698
+ * head: they are not read, the merge state is not used, and the poll is a stall
699
+ * (INDETERMINATE, head-mismatch) that the wait polls through until the deadline (GitHub
700
+ * moves the head and creates checks after a push, and CI can start much later). A poll
701
+ * that sees `head` again is decided by its checks. The poll reads `headRefOid` once more
702
+ * after the checks call: the checks count only when it is still `head`. A head that moved
703
+ * leaves the poll open (no verdict); a second read that fails or cannot be read is a failed
704
+ * poll. A PASSING needs a poll that saw this head before and after its checks and passed
705
+ * the rules of classifyChecks (D-CI-WORKFLOW-RULE) and mergeGate (D-CI-MERGE-STATE).
706
+ *
707
+ * D-CI-PR-STATE: GitHub reports `mergeable` UNKNOWN for a merged PR for good (gh 2.88.1,
708
+ * probed on 2026-10-22), so a PR that is no longer open would wait to the deadline for a
709
+ * merge state that never comes. The first read of a poll also asks `state`: anything but
710
+ * the exact string OPEN ends the run at once as INDETERMINATE (not-open), before the head
711
+ * is compared, and a `state` that is not a string is unparseable. Never PASSING.
712
+ *
713
+ * D-CI-MERGE-STATE, D-CI-WORKFLOWS: the rules are mergeGate's and matchedStep's; this loop
714
+ * acts on their answers. On a matching head, `mergeable` CONFLICTING is INDETERMINATE
715
+ * (merge-conflict) at once, before the checks are read. An open step (the gate awaits the
716
+ * merge state) is not PENDING, so a head that never reads MERGEABLE ends INDETERMINATE
717
+ * (deadline). An empty list on MERGEABLE, and rows that are all external checks, need the
718
+ * workflow probe. Rows that are all external checks end INDETERMINATE (no-workflow-check) at
719
+ * once when the repository has no active workflow file. In the other cases a workflow run
720
+ * may still start, so the step is a stall that keeps the wait going: an empty list with no
721
+ * workflow file stalls as NO_CI, and an empty list or external-only rows with an active
722
+ * workflow file stall as INDETERMINATE (no-workflow-check). Checks that appear decide.
723
+ * A step that asks for the probe gets it from askWorkflows, `gh api` over WORKFLOWS_ARGS,
724
+ * through the same bounded gh call (timeout min(GH_CALL_CAP_SECONDS, time left), none when
725
+ * no time is left, which ends the loop as for any call). At most one probe is answered per
726
+ * run: the first answer is kept and goes to every later poll, so no later poll asks again.
727
+ * A probe whose gh call fails is a failed poll, and the next poll probes again; a reply
728
+ * parseActiveWorkflows cannot read is INDETERMINATE (unparseable) at once. No other path
729
+ * makes the probe.
730
+ *
731
+ * Per poll: `gh pr view <n> --json headRefOid,mergeable,state`, then `gh pr checks <n>
732
+ * --json name,state,bucket,workflow` (exit 0 or 8 are results, as in check-ci-status
733
+ * step 3), then the same view call again (a bounded gh call like the others, not made
734
+ * when no time is left), then, on the paths that need it and only while the probe has no
735
+ * answer, the workflow probe. A PENDING, open or stall poll sleeps min(POLL_INTERVAL_SECONDS,
736
+ * remaining) and polls again; any other result returns. A poll whose gh call fails is not a
737
+ * result: three failed polls in a row are INDETERMINATE (gh-failed), fewer are retried; a
738
+ * pending, open or stall poll in between clears the count. A gh call that the time left cut
739
+ * below GH_CALL_CAP_SECONDS and that ran out that timeout is the deadline, not a failed poll:
740
+ * that poll does not count. Any other failure of that call is a failed poll.
411
741
  *
412
- * Per poll: `gh pr view <n> --json headRefOid`, then `gh pr checks <n> --json
413
- * name,state,bucket` (exit 0 or 8 are results, as in check-ci-status step 3). A
414
- * PENDING poll sleeps min(POLL_INTERVAL_SECONDS, remaining) and polls again; any
415
- * other result returns. A poll whose gh call fails is not a result: three failed
416
- * polls in a row are INDETERMINATE (gh-failed), fewer are retried. The loop stops at
417
- * the deadline or after MAX_POLLS with its last state: PENDING stays PENDING, and
418
- * anything unknown becomes INDETERMINATE (deadline).
742
+ * The loop stops at the deadline or after MAX_POLLS. The last counted poll gives the line:
743
+ * PENDING stays PENDING, a stall gives its status and reason, and an open or a failed poll,
744
+ * which clears an earlier stall, gives INDETERMINATE (deadline). NO_CI stands only when the
745
+ * deadline ended the loop; a loop that ran out of MAX_POLLS first is INDETERMINATE
746
+ * (deadline). One cut poll changes the line: when the deadline cuts the second head read or
747
+ * the workflow probe after the checks call gave rows (or a reply that cannot be read), those
748
+ * rows contradict an earlier stall, so the stall does not survive and the line is
749
+ * INDETERMINATE (deadline). An earlier PENDING stands. A cut before any row came back (the
750
+ * first head read, the checks call, or a later call after an empty list) leaves the line of
751
+ * the last counted poll.
419
752
  *
420
753
  * It never throws: an exec that throws is a gh failure.
421
754
  *
@@ -427,7 +760,6 @@ async function waitForCi(input) {
427
760
  const env = input.env || childEnv();
428
761
  const startedAt = clock.now();
429
762
  const deadline = startedAt + CI_WAIT_MAX_SECONDS * 1000;
430
- const settleMs = SETTLE_WINDOW_SECONDS * 1000;
431
763
 
432
764
  /** @returns {CiResult} */
433
765
  const finish = (status, reason, failing, pending) => ({
@@ -441,8 +773,10 @@ async function waitForCi(input) {
441
773
  });
442
774
 
443
775
  /**
444
- * One gh call, its timeout min(GH_CALL_CAP_SECONDS, time left); null when no time
445
- * is left, in which case no call is made.
776
+ * One gh call, its timeout min(GH_CALL_CAP_SECONDS, time left). It is null, with no
777
+ * call made, when no time is left. It is also null for a call that the time left held
778
+ * below GH_CALL_CAP_SECONDS and that ran out that timeout: the deadline cut it short, so
779
+ * it is no failed poll. A call that fails in any other way is a failed poll.
446
780
  *
447
781
  * @param {string[]} args
448
782
  * @returns {Promise<GhAnswer | null>}
@@ -463,81 +797,116 @@ async function waitForCi(input) {
463
797
  } catch (_) {
464
798
  res = null;
465
799
  }
466
- return normalize(res);
800
+ const cutShort = left < GH_CALL_CAP_SECONDS * 1000 && timedOut(res);
801
+ return cutShort ? null : normalize(res);
467
802
  };
468
803
 
469
- let failedPolls = 0;
470
- let firstMismatchAt = null;
471
- let firstEmptyAt = null;
472
- /** @type {{ status: string, pending: string[] }} */
473
- let last = { status: 'INDETERMINATE', pending: [] };
804
+ /** The probe's first answer, kept for the run; null until a probe answers. */
805
+ let workflowsActive = null;
474
806
 
475
- for (let poll = 0; poll < MAX_POLLS; poll++) {
476
- if (clock.now() >= deadline) break;
807
+ /**
808
+ * A step that ends the run with a result.
809
+ *
810
+ * @param {string} status
811
+ * @param {string | null} reason
812
+ * @param {string[]} failing
813
+ * @param {string[]} pending
814
+ * @returns {PollStep}
815
+ */
816
+ const resultStep = (status, reason, failing, pending) => ({ state: 'done', result: finish(status, reason, failing, pending) });
817
+
818
+ /**
819
+ * The step for what matchedStep decided. A probe request cannot be a final step: it is
820
+ * no verdict, and the wait stays open.
821
+ *
822
+ * @param {ReturnType<typeof matchedStep>} next
823
+ * @returns {PollStep}
824
+ */
825
+ const stepOf = next => {
826
+ if (next.state === 'done') return resultStep(next.status, next.reason, next.failing, next.pending);
827
+ return next.state === 'probe' ? { state: 'open' } : next;
828
+ };
477
829
 
478
- const view = await gh(['pr', 'view', pr, '--json', 'headRefOid']);
479
- if (view === null) break;
480
- if (view.answered && view.status === 1 && NO_PR_RE.test(view.stderr)) return finish('NO_PR', null, [], []);
830
+ const viewArgs = ['pr', 'view', pr, '--json', VIEW_FIELDS];
481
831
 
482
- /** @type {{ state: 'failed' } | { state: 'pending', pending: string[] } | { state: 'done', result: CiResult }} */
483
- let step;
484
- if (!view.answered || view.status !== 0) {
485
- step = { state: 'failed' };
486
- } else {
487
- const oid = parseHeadRefOid(view.stdout);
488
- if (oid === null) return finish('INDETERMINATE', 'unparseable', [], []);
489
- if (oid !== head) {
490
- const mismatch = settle(firstMismatchAt, clock.now(), settleMs);
491
- firstMismatchAt = mismatch.firstAt;
492
- step = mismatch.done
493
- ? { state: 'done', result: finish('INDETERMINATE', 'head-mismatch', [], []) }
494
- : { state: 'pending', pending: [] };
495
- } else {
496
- firstMismatchAt = null;
497
- const checks = await gh(['pr', 'checks', pr, '--json', 'name,state,bucket']);
498
- if (checks === null) break;
499
- const isResult = checks.answered && (checks.status === 0 || checks.status === 8);
500
- const isEmpty = checks.answered && checks.status === 1 && NO_CHECKS_RE.test(checks.stderr);
501
- if (!isResult && !isEmpty) {
502
- step = { state: 'failed' };
503
- } else {
504
- const rows = isResult ? parseChecks(checks.stdout) : [];
505
- if (rows === null) return finish('INDETERMINATE', 'unparseable', [], []);
506
- if (rows.length === 0) {
507
- const empty = settle(firstEmptyAt, clock.now(), settleMs);
508
- firstEmptyAt = empty.firstAt;
509
- step = empty.done
510
- ? { state: 'done', result: finish('NO_CI', null, [], []) }
511
- : { state: 'pending', pending: [] };
512
- } else {
513
- firstEmptyAt = null;
514
- const verdict = classifyChecks(rows);
515
- step = verdict.status === 'PENDING'
516
- ? { state: 'pending', pending: verdict.pending }
517
- : { state: 'done', result: finish(verdict.status, verdict.reason, verdict.failing, verdict.pending) };
518
- }
519
- }
520
- }
832
+ /**
833
+ * One poll: the view, then, on a matching head that does not conflict, the checks, then
834
+ * the view again, then, on the paths that need it while the probe has no answer, the
835
+ * workflow probe.
836
+ *
837
+ * @returns {Promise<PollStep>}
838
+ */
839
+ const readPoll = async () => {
840
+ const view = await gh(viewArgs);
841
+ if (view === null) return { state: 'deadline', rowsRead: false };
842
+ if (view.answered && view.status === 1 && NO_PR_RE.test(view.stderr)) return resultStep('NO_PR', null, [], []);
843
+ if (!view.answered || view.status !== 0) return { state: 'failed' };
844
+ const parsed = parseView(view.stdout);
845
+ if (parsed === null) return resultStep('INDETERMINATE', 'unparseable', [], []);
846
+ if (parsed.state !== 'OPEN') return resultStep('INDETERMINATE', 'not-open', [], []);
847
+ if (parsed.oid !== head) return stall('INDETERMINATE', 'head-mismatch');
848
+ if (isConflicting(parsed.mergeable)) return resultStep('INDETERMINATE', 'merge-conflict', [], []);
849
+
850
+ const checks = await gh(['pr', 'checks', pr, '--json', 'name,state,bucket,workflow']);
851
+ if (checks === null) return { state: 'deadline', rowsRead: false };
852
+ const isResult = checks.answered && (checks.status === 0 || checks.status === 8);
853
+ const isEmpty = checks.answered && checks.status === 1 && NO_CHECKS_RE.test(checks.stderr);
854
+ if (!isResult && !isEmpty) return { state: 'failed' };
855
+ const rows = isResult ? parseChecks(checks.stdout) : [];
856
+ const second = await gh(viewArgs);
857
+ if (second === null) return { state: 'deadline', rowsRead: rows === null || rows.length > 0 };
858
+ const after = second.answered && second.status === 0 ? parseView(second.stdout) : null;
859
+ if (after === null) return { state: 'failed' };
860
+ if (after.oid !== head) return { state: 'open' };
861
+ if (rows === null) return resultStep('INDETERMINATE', 'unparseable', [], []);
862
+ const verdict = rows.length > 0 ? classifyChecks(rows) : null;
863
+
864
+ const next = matchedStep(verdict, parsed.mergeable, workflowsActive);
865
+ if (next.state !== 'probe') return stepOf(next);
866
+ const asked = await askWorkflows(gh);
867
+ if (asked.kind === 'deadline') return { state: 'deadline', rowsRead: verdict !== null };
868
+ if (asked.kind === 'failed') return { state: 'failed' };
869
+ if (asked.kind === 'unparseable') return resultStep('INDETERMINATE', 'unparseable', [], []);
870
+ workflowsActive = asked.active;
871
+ return stepOf(matchedStep(verdict, parsed.mergeable, workflowsActive));
872
+ };
873
+
874
+ let failedPolls = 0;
875
+ let reachedDeadline = false;
876
+ /** @type {Readonly<Last>} */
877
+ let last = DEADLINE_LAST;
878
+
879
+ for (let poll = 0; poll < MAX_POLLS; poll++) {
880
+ if (clock.now() >= deadline) {
881
+ reachedDeadline = true;
882
+ break;
521
883
  }
522
884
 
885
+ const step = await readPoll();
886
+ if (step.state === 'deadline') {
887
+ reachedDeadline = true;
888
+ if (step.rowsRead && last.status !== 'PENDING') last = DEADLINE_LAST;
889
+ break;
890
+ }
523
891
  if (step.state === 'done') return step.result;
524
892
  if (step.state === 'failed') {
525
893
  failedPolls++;
526
894
  if (failedPolls >= MAX_CONSECUTIVE_GH_FAILURES) return finish('INDETERMINATE', 'gh-failed', [], []);
527
- last = { status: 'INDETERMINATE', pending: [] };
528
895
  } else {
529
896
  failedPolls = 0;
530
- last = { status: 'PENDING', pending: step.pending };
531
897
  }
898
+ last = lastOf(step);
532
899
 
533
900
  const left = deadline - clock.now();
534
- if (left <= 0) break;
901
+ if (left <= 0) {
902
+ reachedDeadline = true;
903
+ break;
904
+ }
535
905
  await clock.sleep(Math.min(POLL_INTERVAL_SECONDS * 1000, left));
536
906
  }
537
907
 
538
- return last.status === 'PENDING'
539
- ? finish('PENDING', null, [], last.pending)
540
- : finish('INDETERMINATE', 'deadline', [], []);
908
+ const tail = tailOf(last, reachedDeadline);
909
+ return finish(tail.status, tail.reason, [], tail.pending);
541
910
  }
542
911
 
543
912
  // ---------------------------------------------------------------------------
@@ -634,7 +1003,6 @@ module.exports = Object.freeze({
634
1003
  BUCKETS,
635
1004
  CI_WAIT_MAX_SECONDS,
636
1005
  POLL_INTERVAL_SECONDS,
637
- SETTLE_WINDOW_SECONDS,
638
1006
  GH_CALL_CAP_SECONDS,
639
1007
  MAX_POLLS,
640
1008
  MAX_CONSECUTIVE_GH_FAILURES,
@@ -643,10 +1011,17 @@ module.exports = Object.freeze({
643
1011
  NAMES_LISTED,
644
1012
  LINE_RE,
645
1013
  USAGE_LINE,
1014
+ WORKFLOWS_ARGS,
646
1015
  parseArgs,
647
1016
  sanitizeName,
648
1017
  formatNames,
649
1018
  classifyChecks,
1019
+ isConflicting,
1020
+ mergeGate,
1021
+ matchedStep,
1022
+ parseChecks,
1023
+ parseActiveWorkflows,
1024
+ askWorkflows,
650
1025
  renderLine,
651
1026
  checkLine,
652
1027
  waitForCi,