devflow-kit 3.7.0 → 3.9.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 (205) hide show
  1. package/CHANGELOG.md +479 -1
  2. package/README.md +15 -15
  3. package/dist/agents/code.md +6 -8
  4. package/dist/agents/design.md +1 -1
  5. package/dist/agents/fix.md +7 -8
  6. package/dist/agents/git.md +3 -3
  7. package/dist/agents/harden.md +1 -1
  8. package/dist/agents/refactor.md +2 -4
  9. package/dist/agents/review.md +4 -4
  10. package/dist/agents/skim.md +2 -2
  11. package/dist/agents/triage.md +9 -2
  12. package/dist/cli/commands/capture.js +1 -1
  13. package/dist/cli/commands/curation-move-io.js +102 -0
  14. package/dist/cli/commands/curation-move-step.js +374 -0
  15. package/dist/cli/commands/curation.js +808 -0
  16. package/dist/cli/commands/gate-level-prompts.js +428 -0
  17. package/dist/cli/commands/gates.js +35 -11
  18. package/dist/cli/commands/init-seed.js +11 -8
  19. package/dist/cli/commands/init.js +244 -57
  20. package/dist/cli/commands/memory.js +60 -13
  21. package/dist/cli/commands/prompt-io.js +37 -0
  22. package/dist/cli/commands/uninstall.js +157 -17
  23. package/dist/cli.js +2 -2
  24. package/dist/commands/bug-analysis.md +25 -36
  25. package/dist/commands/code-review.md +40 -44
  26. package/dist/commands/debug.md +5 -5
  27. package/dist/commands/dynamic-build.md +26 -25
  28. package/dist/commands/dynamic-plan.md +5 -5
  29. package/dist/commands/dynamic-profile.md +5 -5
  30. package/dist/commands/dynamic-tickets.md +5 -5
  31. package/dist/commands/explore.md +6 -6
  32. package/dist/commands/implement.md +53 -82
  33. package/dist/commands/plan.md +6 -6
  34. package/dist/commands/release.md +5 -5
  35. package/dist/commands/research.md +6 -6
  36. package/dist/commands/resolve.md +39 -41
  37. package/dist/commands/self-review.md +5 -13
  38. package/dist/core/area-pause-notice.js +44 -0
  39. package/dist/core/assets.js +20 -20
  40. package/dist/core/curate-tuning-config.js +5 -6
  41. package/dist/core/curation-move-notice.js +84 -0
  42. package/dist/core/curation-move-skip.js +35 -0
  43. package/dist/core/curation-move.js +35 -0
  44. package/dist/core/curation-queue-cleanup.js +135 -0
  45. package/dist/core/curation-store.js +99 -0
  46. package/dist/core/{learning-variants.js → curation-variants.js} +65 -41
  47. package/dist/core/echo-value.js +11 -5
  48. package/dist/core/evidence-policy.js +11 -9
  49. package/dist/core/feature-switch.js +95 -19
  50. package/dist/core/fs-atomic.js +1 -1
  51. package/dist/core/gate-level.js +10 -2
  52. package/dist/core/git.js +16 -14
  53. package/dist/core/ledger-root.js +82 -22
  54. package/dist/core/linked-path.js +4 -4
  55. package/dist/core/manifest-lock.js +238 -0
  56. package/dist/core/manifest.js +58 -14
  57. package/dist/core/migrations.js +1 -13
  58. package/dist/core/observations.js +2 -2
  59. package/dist/core/plugins.js +19 -19
  60. package/dist/core/pool.js +26 -0
  61. package/dist/core/project-paths.js +103 -34
  62. package/dist/core/queue-drain.js +2 -2
  63. package/dist/core/tracked-areas.js +159 -0
  64. package/dist/core/tracker.js +1 -1
  65. package/dist/{learning-off → curation-off}/agents/code.md +6 -8
  66. package/dist/{learning-off → curation-off}/agents/fix.md +7 -8
  67. package/dist/{learning-off → curation-off}/agents/refactor.md +2 -4
  68. package/dist/{learning-off → curation-off}/agents/review.md +3 -3
  69. package/dist/{learning-off → curation-off}/agents/triage.md +8 -1
  70. package/dist/{learning-off → curation-off}/commands/bug-analysis.md +20 -31
  71. package/dist/{learning-off → curation-off}/commands/code-review.md +37 -41
  72. package/dist/{learning-off → curation-off}/commands/debug.md +2 -2
  73. package/dist/{learning-off → curation-off}/commands/dynamic-build.md +23 -22
  74. package/dist/{learning-off → curation-off}/commands/explore.md +2 -2
  75. package/dist/{learning-off → curation-off}/commands/implement.md +50 -79
  76. package/dist/{learning-off → curation-off}/commands/plan.md +2 -2
  77. package/dist/{learning-off → curation-off}/commands/resolve.md +36 -38
  78. package/dist/{learning-off → curation-off}/commands/self-review.md +2 -10
  79. package/dist/hud/components/{learning-counts.js → curation-counts.js} +63 -17
  80. package/dist/hud/config.js +1 -1
  81. package/dist/hud/index.js +6 -6
  82. package/dist/hud/render.js +3 -3
  83. package/dist/skills/git/references/pr/post-resolution-summary.md +12 -11
  84. package/dist/skills/git/references/pr/post-review-summary.md +12 -11
  85. package/dist/skills/git/references/pr/update-pr-evidence.md +2 -2
  86. package/dist/skills/git/references/pr/validate-branch.md +8 -5
  87. package/dist/skills/git/references/publication-gate.md +6 -7
  88. package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +13 -5
  89. package/dist/skills/git/references/tracker/github/manage-debt.md +6 -2
  90. package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +13 -5
  91. package/dist/skills/git/references/tracker/jira/manage-debt.md +6 -2
  92. package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +13 -5
  93. package/dist/skills/git/references/tracker/linear/manage-debt.md +6 -2
  94. package/dist/targets/claude-code/{learning-install.js → curation-install.js} +85 -65
  95. package/dist/targets/claude-code/installer.js +15 -15
  96. package/dist/targets/claude-code/language-stamp.js +5 -5
  97. package/dist/targets/claude-code/post-install.js +2 -2
  98. package/dist/targets/claude-code/tracker-servers.js +1 -1
  99. package/package.json +3 -2
  100. package/src/assets/agents/_partials/_code.mds +11 -11
  101. package/src/assets/agents/code.mds +6 -6
  102. package/src/assets/agents/curate.md +8 -8
  103. package/src/assets/agents/debug.mds +6 -6
  104. package/src/assets/agents/design.mds +18 -18
  105. package/src/assets/agents/evaluate.md +6 -12
  106. package/src/assets/agents/fix.mds +7 -6
  107. package/src/assets/agents/git.mds +3 -3
  108. package/src/assets/agents/harden.mds +7 -7
  109. package/src/assets/agents/knowledge.mds +23 -23
  110. package/src/assets/agents/refactor.mds +2 -2
  111. package/src/assets/agents/research.mds +15 -15
  112. package/src/assets/agents/review.mds +43 -43
  113. package/src/assets/agents/skim.mds +11 -11
  114. package/src/assets/agents/triage.mds +29 -22
  115. package/src/assets/agents/validate.md +30 -50
  116. package/src/assets/commands/_partials/_decisions.mds +22 -20
  117. package/src/assets/commands/_partials/_engine.mds +8 -8
  118. package/src/assets/commands/_partials/_knowledge.mds +29 -15
  119. package/src/assets/commands/_partials/_preamble.mds +4 -4
  120. package/src/assets/commands/_partials/_settings.mds +3 -3
  121. package/src/assets/commands/_partials/_wave.mds +7 -7
  122. package/src/assets/commands/bug-analysis.mds +37 -48
  123. package/src/assets/commands/code-review.mds +53 -57
  124. package/src/assets/commands/debug.mds +14 -14
  125. package/src/assets/commands/dynamic-build.mds +38 -37
  126. package/src/assets/commands/dynamic-plan.mds +22 -22
  127. package/src/assets/commands/dynamic-profile.mds +2 -2
  128. package/src/assets/commands/dynamic-tickets.mds +8 -8
  129. package/src/assets/commands/explore.mds +20 -20
  130. package/src/assets/commands/implement.mds +77 -98
  131. package/src/assets/commands/plan.mds +27 -27
  132. package/src/assets/commands/release.mds +12 -12
  133. package/src/assets/commands/research.mds +19 -19
  134. package/src/assets/commands/resolve.mds +66 -60
  135. package/src/assets/commands/self-review.mds +20 -20
  136. package/src/assets/mds/git/_ops.mds +9 -2
  137. package/src/assets/mds/git/_ops_pr.mds +21 -11
  138. package/src/assets/mds/git/_ops_steps.mds +20 -4
  139. package/src/assets/mds/git/_pr.mds +21 -18
  140. package/src/assets/mds/git/_references.mds +6 -7
  141. package/src/assets/mds/tracker/_github.mds +1 -4
  142. package/src/assets/mds/tracker/_jira.mds +1 -4
  143. package/src/assets/mds/tracker/_linear.mds +1 -4
  144. package/src/assets/rules/quality.md +1 -0
  145. package/src/assets/scripts/ci-wait.cjs +389 -92
  146. package/src/assets/scripts/hooks/background-memory-update +81 -3
  147. package/src/assets/scripts/hooks/capture-prompt +12 -12
  148. package/src/assets/scripts/hooks/capture-question +11 -11
  149. package/src/assets/scripts/hooks/capture-turn +12 -12
  150. package/src/assets/scripts/hooks/{learning-lock → curation-lock} +2 -2
  151. package/src/assets/scripts/hooks/curation-move +293 -0
  152. package/src/assets/scripts/hooks/ensure-devflow-init +45 -13
  153. package/src/assets/scripts/hooks/ensure-proxy +4 -4
  154. package/src/assets/scripts/hooks/ensure-root-gitignore +4 -4
  155. package/src/assets/scripts/hooks/git-marker +81 -0
  156. package/src/assets/scripts/hooks/json-helper.cjs +176 -51
  157. package/src/assets/scripts/hooks/json-parse +1 -1
  158. package/src/assets/scripts/hooks/lib/curation-move.cjs +1582 -0
  159. package/src/assets/scripts/hooks/lib/{learning-store.cjs → curation-store.cjs} +308 -122
  160. package/src/assets/scripts/hooks/lib/decisions-format.cjs +3 -3
  161. package/src/assets/scripts/hooks/lib/mkdir-lock.cjs +24 -7
  162. package/src/assets/scripts/hooks/lib/project-paths.cjs +132 -39
  163. package/src/assets/scripts/hooks/lib/render-decisions.cjs +42 -22
  164. package/src/assets/scripts/hooks/lib/tracked-areas.cjs +452 -0
  165. package/src/assets/scripts/hooks/memory-permission +10 -2
  166. package/src/assets/scripts/hooks/memory-worker +12 -1
  167. package/src/assets/scripts/hooks/pre-compact-memory +17 -16
  168. package/src/assets/scripts/hooks/queue-append +444 -102
  169. package/src/assets/scripts/hooks/resolve-project-root +2 -2
  170. package/src/assets/scripts/hooks/session-start-context +198 -72
  171. package/src/assets/scripts/hooks/session-start-memory +42 -43
  172. package/src/assets/scripts/lib/project-config.cjs +12 -5
  173. package/src/assets/scripts/pr-evidence.cjs +5 -2
  174. package/src/assets/scripts/resolve-base.cjs +1043 -0
  175. package/src/assets/scripts/resolve-settings.cjs +283 -45
  176. package/src/assets/scripts/verify-evidence.cjs +307 -75
  177. package/src/assets/skills/agent-references/SKILL.md +11 -2
  178. package/src/assets/skills/agent-references/references/commands/implement-levels.md +29 -0
  179. package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +6 -4
  180. package/src/assets/skills/agent-references/references/commands/resolve-levels.md +25 -0
  181. package/src/assets/skills/agent-references/references/synthesize/review.md +4 -4
  182. package/src/assets/skills/agent-references/references/triage/severity-tiers.md +43 -0
  183. package/src/assets/skills/agent-references/references/validate/passed-alone.md +14 -0
  184. package/src/assets/skills/apply-decisions/SKILL.md +1 -1
  185. package/src/assets/skills/docs-framework/SKILL.md +7 -7
  186. package/src/assets/skills/docs-framework/references/patterns.md +3 -4
  187. package/src/assets/skills/quality-gates/SKILL.md +1 -1
  188. package/src/assets/skills/worktree-support/references/roots.md +2 -2
  189. package/templates/lint-pack/.lint-pack.conf +86 -0
  190. package/templates/lint-pack/README.md +190 -0
  191. package/templates/lint-pack/lint-pack.sh +905 -0
  192. package/dist/cli/commands/learning.js +0 -554
  193. package/dist/core/learning-queue-cleanup.js +0 -27
  194. package/dist/core/learning-store.js +0 -61
  195. /package/dist/{learning-off → curation-off}/agents/debug.md +0 -0
  196. /package/dist/{learning-off → curation-off}/agents/design.md +0 -0
  197. /package/dist/{learning-off → curation-off}/agents/harden.md +0 -0
  198. /package/dist/{learning-off → curation-off}/agents/knowledge.md +0 -0
  199. /package/dist/{learning-off → curation-off}/agents/research.md +0 -0
  200. /package/dist/{learning-off → curation-off}/agents/skim.md +0 -0
  201. /package/dist/{learning-off → curation-off}/commands/dynamic-plan.md +0 -0
  202. /package/dist/{learning-off → curation-off}/commands/dynamic-profile.md +0 -0
  203. /package/dist/{learning-off → curation-off}/commands/dynamic-tickets.md +0 -0
  204. /package/dist/{learning-off → curation-off}/commands/release.md +0 -0
  205. /package/dist/{learning-off → curation-off}/commands/research.md +0 -0
@@ -4,9 +4,11 @@
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 stays empty (D-CI-WORKFLOWS): at
9
+ // most one answered probe per run; a failed probe is a failed poll, and the next poll
10
+ // probes again. It classifies the head it was told about, and prints exactly one line.
11
+ // Installed beside hud.sh and redact-secrets.cjs under ~/.devflow/scripts/.
10
12
  //
11
13
  // Usage:
12
14
  // node ci-wait.cjs --pr <n> --head <sha>
@@ -24,12 +26,19 @@
24
26
  // writes it. Exit 0 whenever a line was printed; a reader treats a missing or
25
27
  // unparseable line, an unlisted status or a non-zero exit as INDETERMINATE.
26
28
  //
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.
29
+ // Design constraints (binding). The text of each verdict rule is written once, at the
30
+ // marker named below; this list points to it.
31
+ // - D-CI-WAIT-INLINE: this is plumbing. The only judgment in it is the classifier
32
+ // (D-CI-CLASSIFIER, D-CI-WORKFLOW-RULE) and the merge gate (D-CI-MERGE-STATE,
33
+ // D-CI-WORKFLOWS, D-CI-PR-STATE). Every subprocess is `gh`, spawned through exec()
34
+ // with an argv array and `shell: false`, stdin ignored, a timeout and a maxBuffer.
35
+ // gh output never reaches stdout raw: a check name passes sanitizeName() first.
36
+ // - D-CI-WORKFLOW-RULE: see classifyChecks. PASSING does not prove that the test
37
+ // workflow ran by name.
38
+ // - D-CI-MERGE-STATE, D-CI-WORKFLOWS: see mergeGate. The workflow files are listed,
39
+ // never opened: no workflow YAML is parsed, so which events a workflow reacts to is
40
+ // not judged.
41
+ // - D-CI-PR-STATE, D-CI-HEAD-BINDING: see waitForCi.
33
42
  // - D-CI-NO-LITERAL-SLEEP: the wait happens inside node, through clock.sleep(),
34
43
  // never through a shell `sleep`. Claude Code 2.1.294 blocks any Bash command
35
44
  // that starts with a literal `sleep N`, so no prompt and no script may wait that
@@ -42,14 +51,31 @@
42
51
  // name lists), and every wait is capped at CI_WAIT_MAX_SECONDS.
43
52
  //
44
53
  // 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.
54
+ // - `gh pr view 999999999 --json headRefOid,mergeable,state` exits 1 with stderr
55
+ // `GraphQL: Could not resolve to a PullRequest with the number of 999999999.
56
+ // (repository.pullRequest)` and an empty stdout (re-probed with `mergeable` and
57
+ // `state` in the field list on 2026-10-22: the same text). Only that text on exit 1
58
+ // is NO_PR; any other non-zero exit is a gh failure and never PASSING.
49
59
  // - `gh pr checks <n> --json` offers exactly bucket, completedAt, description,
50
60
  // event, link, name, startedAt, state and workflow. There is no SHA field, and it
51
61
  // reports only the PR's current head. So the head is bound by comparing
52
62
  // `gh pr view --json headRefOid` with `--head` (D-CI-HEAD-BINDING).
63
+ // - `workflow` names the GitHub Actions workflow of a check, and is "" for a commit
64
+ // status and for a third-party app's check run (probed on public PRs, gh 2.88.1,
65
+ // 2026-10-22).
66
+ // - `gh pr view --json mergeable` is MERGEABLE, CONFLICTING or UNKNOWN. GitHub
67
+ // computes it lazily, so UNKNOWN is common right after a push or a base move. A
68
+ // merged PR reads `state` MERGED with `mergeable` UNKNOWN (probed on gh 2.88.1,
69
+ // 2026-10-22), which is why the poll asks `state` (D-CI-PR-STATE).
70
+ // - `gh api` fills the placeholders `{owner}` and `{repo}` from the repository of the
71
+ // current directory (`gh api --help`), and a call with no field flag is a GET.
72
+ // `repos/{owner}/{repo}/actions/workflows?per_page=100` answers `{ total_count,
73
+ // workflows: [{ path, state, ... }] }`, one row per workflow, with `state` active
74
+ // for a live one and a `path` such as `.github/workflows/ci.yml` (probed on gh
75
+ // 2.88.1, 2026-10-22). It is used rather than `gh workflow list` because
76
+ // `total_count` tells a whole list from a cut one. A row whose path is not under
77
+ // `.github/workflows/`, such as a `dynamic/...` path that GitHub gives its own
78
+ // apps, is not a workflow file of the repository.
53
79
 
54
80
  'use strict';
55
81
 
@@ -69,10 +95,13 @@ const STATUSES = Object.freeze(['PASSING', 'FAILING', 'PENDING', 'NO_CI', 'NO_PR
69
95
  /**
70
96
  * D-CI-REASONS: why an INDETERMINATE was returned. `unrecognised-bucket` also covers
71
97
  * a set of buckets that gives no verdict (every check `skipping`): the classifier
72
- * could call neither a pass nor a failure.
98
+ * could call neither a pass nor a failure. The other rule reasons are defined at their
99
+ * markers: `no-workflow-check` (D-CI-WORKFLOW-RULE, D-CI-WORKFLOWS), `merge-conflict`
100
+ * (D-CI-MERGE-STATE) and `not-open` (D-CI-PR-STATE).
73
101
  */
74
102
  const REASONS = Object.freeze([
75
103
  'gh-failed', 'unparseable', 'unrecognised-bucket', 'head-mismatch', 'deadline', 'usage',
104
+ 'no-workflow-check', 'merge-conflict', 'not-open',
76
105
  ]);
77
106
 
78
107
  /** `gh pr checks` categorises `state` into these buckets (gh 2.88.1, `--help`). */
@@ -85,10 +114,12 @@ const BUCKETS = Object.freeze(['pass', 'fail', 'pending', 'skipping', 'cancel'])
85
114
  * Bash timeout of 600000 ms the callers pass
86
115
  * POLL_INTERVAL_SECONDS the sleep between polls; polls cost no model tokens,
87
116
  * 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)
117
+ * SETTLE_WINDOW_SECONDS how long a head mismatch, an empty check list or a
118
+ * list of external checks only may persist before it
119
+ * is a verdict (GitHub creates checks and moves the PR
120
+ * head a few seconds after a push); the workflow probe
121
+ * is no window of its own
122
+ * WORKFLOWS_PAGE_ROWS the rows asked of the workflow list in its one page
92
123
  * GH_CALL_CAP_SECONDS one gh call's timeout is min(this, time remaining),
93
124
  * so the last call ends before the deadline
94
125
  * MAX_POLLS the loop bound when the clock does not move
@@ -100,6 +131,7 @@ const SETTLE_WINDOW_SECONDS = 90;
100
131
  const GH_CALL_CAP_SECONDS = 30;
101
132
  const MAX_POLLS = 40;
102
133
  const MAX_CONSECUTIVE_GH_FAILURES = 3;
134
+ const WORKFLOWS_PAGE_ROWS = 100;
103
135
 
104
136
  /** A PR with more check rows than this is read as an unparseable reply. */
105
137
  const MAX_CHECKS = 1000;
@@ -143,6 +175,18 @@ const LINE_RE = new RegExp(
143
175
 
144
176
  const USAGE_LINE = 'CI INDETERMINATE pr=- head=- waited=0 reason=usage';
145
177
 
178
+ /**
179
+ * D-CI-WORKFLOWS: the probe, a GET of the repository's workflow list. It is a fixed
180
+ * argv with no field flag, no method flag and no input, and it names neither the PR nor
181
+ * the head; gh fills `{owner}/{repo}` from the working directory.
182
+ */
183
+ const WORKFLOWS_ARGS = Object.freeze(['api', 'repos/{owner}/{repo}/actions/workflows?per_page=' + WORKFLOWS_PAGE_ROWS]);
184
+ /** A workflow file lives here; the `dynamic/...` paths GitHub adds for its own apps do not. */
185
+ const WORKFLOWS_DIR = '.github/workflows/';
186
+
187
+ /** The fields every `gh pr view` call of a poll asks for. */
188
+ const VIEW_FIELDS = 'headRefOid,mergeable,state';
189
+
146
190
  // ---------------------------------------------------------------------------
147
191
  // Types
148
192
  // ---------------------------------------------------------------------------
@@ -156,12 +200,15 @@ const USAGE_LINE = 'CI INDETERMINATE pr=- head=- waited=0 reason=usage';
156
200
  * @typedef {{ now: () => number, sleep: (ms: number) => Promise<void> }} Clock
157
201
  * `now` is in milliseconds. Tests inject a clock whose sleep and exec advance it.
158
202
  *
159
- * @typedef {{ name: string, bucket: string }} Check
203
+ * @typedef {{ name: string, bucket: string, workflow?: string }} Check
204
+ * `workflow` is the GitHub Actions workflow name; "" or absent marks an external check.
160
205
  * @typedef {{ status: string, reason: string | null, failing: string[], pending: string[] }} Classification
161
206
  * @typedef {{ status: string, pr: string, head: string, failing: string[], pending: string[],
162
207
  * waited: number, reason: string | null }} CiResult
163
208
  * `head` is the first 7 hex characters.
164
209
  * @typedef {{ pr: string, head: string, exec: ExecFn, clock: Clock, env?: NodeJS.ProcessEnv }} WaitInput
210
+ * @typedef {{ state: 'deadline' } | { state: 'failed' } | { state: 'pending', pending: string[] } | { state: 'open' }
211
+ * | { state: 'done', result: CiResult }} PollStep
165
212
  * @typedef {{ exec?: ExecFn, clock?: Clock, env?: NodeJS.ProcessEnv }} MainDeps
166
213
  * @typedef {{ code: number, stdout: string }} Outcome
167
214
  */
@@ -224,14 +271,22 @@ function formatNames(names) {
224
271
  }
225
272
 
226
273
  /**
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.)
274
+ * D-CI-CLASSIFIER: check-ci-status step 5 over `bucket`, plus the stricter workflow
275
+ * rule. In priority order: any `pending` is PENDING; else any `fail` or `cancel` is
276
+ * FAILING; else every check `pass` or `skipping` with at least one `pass` is PASSING;
277
+ * else INDETERMINATE. PASSING is a positive conjunction, so an undocumented bucket, an
278
+ * all-`skipping` set or an empty set never reaches it.
279
+ *
280
+ * D-CI-WORKFLOW-RULE: a set that passes by step 5 is PASSING only when at least one
281
+ * passing check is a GitHub Actions job (a non-empty string `workflow`). A set with
282
+ * passes from external checks alone, a commit status or a third-party app, is
283
+ * INDETERMINATE (no-workflow-check): the repository's workflow may not have run. So
284
+ * PASSING proves that a GitHub Actions check passed and nothing is pending or failing;
285
+ * it does not prove that the test workflow ran by name. merge-readiness.test.ts runs
286
+ * every multiset of 1 to 3 checks through this and through the prose clauses and
287
+ * requires the statuses to agree, or this to be stricter only by no-workflow-check.
288
+ * (The empty set is not classified here: an empty list is "no checks", which
289
+ * waitForCi settles before it classifies.)
235
290
  *
236
291
  * @param {readonly Check[]} checks
237
292
  * @returns {Classification}
@@ -240,6 +295,7 @@ function classifyChecks(checks) {
240
295
  const failing = [];
241
296
  const pending = [];
242
297
  let pass = 0;
298
+ let actionsPass = 0;
243
299
  let unrecognised = 0;
244
300
  const count = Math.min(Array.isArray(checks) ? checks.length : 0, MAX_CHECKS);
245
301
  for (let i = 0; i < count; i++) {
@@ -248,14 +304,21 @@ function classifyChecks(checks) {
248
304
  case 'pending': pending.push(check.name); break;
249
305
  case 'fail':
250
306
  case 'cancel': failing.push(check.name); break;
251
- case 'pass': pass++; break;
307
+ case 'pass':
308
+ pass++;
309
+ if (typeof check.workflow === 'string' && check.workflow !== '') actionsPass++;
310
+ break;
252
311
  case 'skipping': break;
253
312
  default: unrecognised++;
254
313
  }
255
314
  }
256
315
  if (pending.length > 0) return { status: 'PENDING', reason: null, failing, pending };
257
316
  if (failing.length > 0) return { status: 'FAILING', reason: null, failing, pending };
258
- if (unrecognised === 0 && pass > 0) return { status: 'PASSING', reason: null, failing, pending };
317
+ if (unrecognised === 0 && pass > 0) {
318
+ return actionsPass > 0
319
+ ? { status: 'PASSING', reason: null, failing, pending }
320
+ : { status: 'INDETERMINATE', reason: 'no-workflow-check', failing, pending };
321
+ }
259
322
  return { status: 'INDETERMINATE', reason: 'unrecognised-bucket', failing, pending };
260
323
  }
261
324
 
@@ -355,13 +418,20 @@ function parseJson(text) {
355
418
  }
356
419
 
357
420
  /**
421
+ * The `gh pr view --json headRefOid,mergeable,state` reply. `headRefOid` and `state` are
422
+ * strict: a head that is not 40 lowercase hex, or a `state` that is not a string, makes the
423
+ * reply unparseable. A `mergeable` that is absent or not a string is read as null, the same
424
+ * as UNKNOWN, and never makes the reply unparseable.
425
+ *
358
426
  * @param {string} stdout
359
- * @returns {string | null} the 40-hex headRefOid, or null for any other shape
427
+ * @returns {{ oid: string, mergeable: string | null, state: string } | null} null for any other shape
360
428
  */
361
- function parseHeadRefOid(stdout) {
429
+ function parseView(stdout) {
362
430
  const value = parseJson(stdout);
363
431
  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;
432
+ if (typeof value.headRefOid !== 'string' || !HEAD_RE.test(value.headRefOid)) return null;
433
+ if (typeof value.state !== 'string') return null;
434
+ return { oid: value.headRefOid, mergeable: typeof value.mergeable === 'string' ? value.mergeable : null, state: value.state };
365
435
  }
366
436
 
367
437
  /**
@@ -374,11 +444,41 @@ function parseChecks(stdout) {
374
444
  const checks = [];
375
445
  for (const row of value) {
376
446
  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 });
447
+ checks.push({ name: row.name, bucket: row.bucket, workflow: typeof row.workflow === 'string' ? row.workflow : '' });
378
448
  }
379
449
  return checks;
380
450
  }
381
451
 
452
+ /**
453
+ * D-CI-WORKFLOWS: whether the workflow list holds an active workflow file. A row is a
454
+ * workflow file when its `state` is exactly `active` and its `path` starts with
455
+ * `.github/workflows/`; the `dynamic/...` paths of GitHub's own apps and every
456
+ * disabled or deleted row are not. The reply is read whole before it is judged, so a
457
+ * row without a string `path` and `state` makes the reply unreadable even beside an
458
+ * active one. The list is one page: no match is a "no" only when the rows read equal
459
+ * `total_count` (and fit the page), and a page that is not all of the list proves
460
+ * nothing about the rest. The workflow files themselves are never opened.
461
+ *
462
+ * @param {string} stdout the `gh api` reply
463
+ * @returns {boolean | null} true: some active workflow file. false: none, and the
464
+ * list is complete. null: bad JSON, a bad row, or an incomplete page.
465
+ */
466
+ function parseActiveWorkflows(stdout) {
467
+ const value = parseJson(stdout);
468
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
469
+ const listed = value.workflows;
470
+ const total = value.total_count;
471
+ if (!Array.isArray(listed) || listed.length > WORKFLOWS_PAGE_ROWS) return null;
472
+ if (typeof total !== 'number' || !Number.isInteger(total) || total < 0) return null;
473
+ let active = false;
474
+ for (const row of listed) {
475
+ if (row === null || typeof row !== 'object' || typeof row.path !== 'string' || typeof row.state !== 'string') return null;
476
+ if (row.state === 'active' && row.path.startsWith(WORKFLOWS_DIR)) active = true;
477
+ }
478
+ if (active) return true;
479
+ return listed.length === total ? false : null;
480
+ }
481
+
382
482
  // ---------------------------------------------------------------------------
383
483
  // The wait
384
484
  // ---------------------------------------------------------------------------
@@ -399,23 +499,171 @@ function settle(firstAt, now, settleMs) {
399
499
  return { firstAt: opened, done: now - opened >= settleMs };
400
500
  }
401
501
 
502
+ /**
503
+ * D-CI-MERGE-STATE: whether GitHub reports the PR as conflicting. It is the one rule for a
504
+ * conflict. mergeGate answers 'conflict' by it, and a caller that has read only the merge
505
+ * state asks it before it reads any check.
506
+ *
507
+ * @param {unknown} mergeable the `gh pr view` value in any shape
508
+ * @returns {boolean}
509
+ */
510
+ function isConflicting(mergeable) {
511
+ return mergeable === 'CONFLICTING';
512
+ }
513
+
514
+ /**
515
+ * D-CI-MERGE-STATE, D-CI-WORKFLOWS: the one merge rule. GitHub computes `mergeable`
516
+ * lazily, and runs no pull request workflow on a conflicting PR, so the checks of a head
517
+ * say nothing about the PR unless GitHub reports it MERGEABLE. An empty check list says
518
+ * nothing either unless the repository has no workflow that could still start.
519
+ * `status` is a classifier status ('NO_CI' for an empty list), or null before any check
520
+ * is read; `mergeable` is the `gh pr view` value in any shape; `workflowsActive` is what
521
+ * the workflow probe found: true (an active workflow file), false (none), or null or
522
+ * left out when it was not made or gave no answer. Each case has its own answer, so a
523
+ * caller never reads `mergeable` or `workflowsActive` again:
524
+ * 'conflict' isConflicting(mergeable), whatever the status.
525
+ * 'await-merge-state' PASSING or NO_CI while mergeable is anything but MERGEABLE
526
+ * (UNKNOWN, absent or odd). It is neither a verdict nor a conflict:
527
+ * waitForCi keeps the wait open for another read, and a single read
528
+ * takes it as unknown.
529
+ * 'probe' NO_CI on MERGEABLE with no usable probe answer (null, left out or
530
+ * not a boolean): the workflow list has to be asked.
531
+ * 'workflow-active' NO_CI on MERGEABLE, and the probe found an active workflow file:
532
+ * a run may still start.
533
+ * 'keep' the status stands: PASSING on MERGEABLE, NO_CI on MERGEABLE with a
534
+ * probe that found no workflow file, and FAILING, PENDING and
535
+ * INDETERMINATE, which do not depend on the merge state or the probe.
536
+ *
537
+ * @param {string | null} status
538
+ * @param {unknown} mergeable
539
+ * @param {boolean | null} [workflowsActive]
540
+ * @returns {'conflict' | 'await-merge-state' | 'probe' | 'workflow-active' | 'keep'}
541
+ */
542
+ function mergeGate(status, mergeable, workflowsActive) {
543
+ if (isConflicting(mergeable)) return 'conflict';
544
+ const mergeableNow = mergeable === 'MERGEABLE';
545
+ if (status === 'PASSING') return mergeableNow ? 'keep' : 'await-merge-state';
546
+ if (status !== 'NO_CI') return 'keep';
547
+ if (!mergeableNow) return 'await-merge-state';
548
+ if (workflowsActive === true) return 'workflow-active';
549
+ return workflowsActive === false ? 'keep' : 'probe';
550
+ }
551
+
552
+ /**
553
+ * @param {string} status
554
+ * @param {string | null} reason
555
+ * @param {string[]} [failing]
556
+ * @param {string[]} [pending]
557
+ * @returns {{ state: 'done', status: string, reason: string | null, failing: string[], pending: string[] }}
558
+ */
559
+ function done(status, reason, failing = [], pending = []) {
560
+ return { state: 'done', status, reason, failing, pending };
561
+ }
562
+
563
+ /**
564
+ * The outcome of one poll on the named head, as a pure function of what the poll read
565
+ * (the rules are classifyChecks's and mergeGate's). `verdict` is the classification
566
+ * of the rows, or null for an empty list. `windowDone` is whether the settle window has
567
+ * run out; it only matters for an empty list and for rows that are all external
568
+ * (no-workflow-check), the two cases a late GitHub Actions run can still change.
569
+ * `workflowsActive` is the probe's answer (see mergeGate) and matters for an empty list
570
+ * only. This function never reads `mergeable` or `workflowsActive` itself: each answer of
571
+ * mergeGate maps to one step. The outcome is one of:
572
+ * pending keep waiting: a pending check, or an unsettled window
573
+ * open a pass or an empty list while the gate awaits the merge state
574
+ * probe an empty list, window done, and the gate asks for the probe: the caller makes
575
+ * the workflow probe, then asks again with its answer
576
+ * done a final status and reason, with the failing and pending names
577
+ *
578
+ * @param {Classification | null} verdict
579
+ * @param {unknown} mergeable
580
+ * @param {boolean} windowDone
581
+ * @param {boolean | null} [workflowsActive]
582
+ * @returns {{ state: 'pending', pending: string[] } | { state: 'open' } | { state: 'probe' }
583
+ * | ReturnType<typeof done>}
584
+ */
585
+ function matchedStep(verdict, mergeable, windowDone, workflowsActive) {
586
+ const gate = mergeGate(verdict === null ? 'NO_CI' : verdict.status, mergeable, workflowsActive);
587
+ if (gate === 'conflict') return done('INDETERMINATE', 'merge-conflict');
588
+ if (verdict === null) {
589
+ if (!windowDone) return { state: 'pending', pending: [] };
590
+ if (gate === 'keep') return done('NO_CI', null);
591
+ if (gate === 'workflow-active') return done('INDETERMINATE', 'no-workflow-check');
592
+ if (gate === 'probe') return { state: 'probe' };
593
+ return { state: 'open' };
594
+ }
595
+ if (verdict.reason === 'no-workflow-check') {
596
+ return windowDone ? done('INDETERMINATE', 'no-workflow-check') : { state: 'pending', pending: [] };
597
+ }
598
+ if (verdict.status === 'PENDING') return { state: 'pending', pending: verdict.pending };
599
+ if (gate !== 'keep') return { state: 'open' };
600
+ return done(verdict.status, verdict.reason, verdict.failing, verdict.pending);
601
+ }
602
+
603
+ /**
604
+ * D-CI-WORKFLOWS: the workflow probe, as one result. `gh` is waitForCi's bounded gh
605
+ * call, so the probe gets its timeout and, with no time left, no call at all. The result is
606
+ * one of:
607
+ * deadline no time was left, so no call was made
608
+ * failed the call was not answered or exited non-zero: a failed poll
609
+ * unparseable the reply cannot be read, or is one page of many (parseActiveWorkflows)
610
+ * answer `active`: true for some active workflow file, false for none
611
+ *
612
+ * @param {(args: readonly string[]) => Promise<GhAnswer | null>} gh
613
+ * @returns {Promise<{ kind: 'deadline' } | { kind: 'failed' } | { kind: 'unparseable' }
614
+ * | { kind: 'answer', active: boolean }>}
615
+ */
616
+ async function askWorkflows(gh) {
617
+ const probe = await gh(WORKFLOWS_ARGS);
618
+ if (probe === null) return { kind: 'deadline' };
619
+ if (!probe.answered || probe.status !== 0) return { kind: 'failed' };
620
+ const active = parseActiveWorkflows(probe.stdout);
621
+ return active === null ? { kind: 'unparseable' } : { kind: 'answer', active };
622
+ }
623
+
402
624
  /**
403
625
  * D-CI-HEAD-BINDING: wait for the CI verdict of `head`, and of nothing else. `gh pr
404
626
  * checks` has no SHA field and reports the PR's current head, so each poll reads
405
627
  * `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.
628
+ * head: they are not read, the merge state is not used, and the poll is PENDING until
629
+ * SETTLE_WINDOW_SECONDS have passed since the first mismatch (GitHub moves the head
630
+ * and creates checks a few seconds after a push), then INDETERMINATE (head-mismatch).
631
+ * A matching head clears the mismatch clock. The poll reads `headRefOid` once more after
632
+ * the checks call: the checks count only when it is still `head`. A head that moved
633
+ * leaves the poll open (no verdict, no settle clock moved); a second read that fails or
634
+ * cannot be read is a failed poll. A PASSING needs a poll that saw this head before and
635
+ * after its checks and passed the rules of classifyChecks (D-CI-WORKFLOW-RULE) and
636
+ * mergeGate (D-CI-MERGE-STATE).
411
637
  *
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).
638
+ * D-CI-PR-STATE: GitHub reports `mergeable` UNKNOWN for a merged PR for good (gh 2.88.1,
639
+ * probed on 2026-10-22), so a PR that is no longer open would wait to the deadline for a
640
+ * merge state that never comes. The first read of a poll also asks `state`: anything but
641
+ * the exact string OPEN ends the run at once as INDETERMINATE (not-open), before the head
642
+ * is compared, and a `state` that is not a string is unparseable. Never PASSING.
643
+ *
644
+ * D-CI-MERGE-STATE, D-CI-WORKFLOWS: the rules are mergeGate's; this loop acts on its
645
+ * answers. On a matching head, `mergeable` CONFLICTING is INDETERMINATE (merge-conflict)
646
+ * at once, before the checks are read. An open step (the gate awaits the merge state) is
647
+ * not PENDING, so a head that never reads MERGEABLE ends INDETERMINATE (deadline). Rows
648
+ * that are all external checks share the empty list's settle window, which a pending or
649
+ * failing row clears, and end INDETERMINATE (no-workflow-check) when it runs out. A step
650
+ * that asks for the probe gets it from askWorkflows, `gh api` over WORKFLOWS_ARGS,
651
+ * through the same bounded gh call (timeout min(GH_CALL_CAP_SECONDS, time left), none
652
+ * when no time is left, which ends the loop as for any call). At most one probe is
653
+ * answered per run: an answered probe ends the run in that poll, so no later poll reads
654
+ * it and no settle() clock runs for it. A probe whose gh call fails is a failed poll, and
655
+ * the next poll probes again; a reply parseActiveWorkflows cannot read is INDETERMINATE
656
+ * (unparseable). No other path makes the probe.
657
+ *
658
+ * Per poll: `gh pr view <n> --json headRefOid,mergeable,state`, then `gh pr checks <n>
659
+ * --json name,state,bucket,workflow` (exit 0 or 8 are results, as in check-ci-status
660
+ * step 3), then the same view call again (a bounded gh call like the others, not made
661
+ * when no time is left), then, on that one path, the workflow probe. A PENDING or open
662
+ * poll sleeps min(POLL_INTERVAL_SECONDS, remaining) and polls again; any other result
663
+ * returns. A poll whose gh call fails is not a result: three failed polls in a row are
664
+ * INDETERMINATE (gh-failed), fewer are retried. The loop stops at the deadline or after
665
+ * MAX_POLLS with its last state: PENDING stays PENDING, and anything unknown, an open
666
+ * step included, becomes INDETERMINATE (deadline).
419
667
  *
420
668
  * It never throws: an exec that throws is a gh failure.
421
669
  *
@@ -466,68 +714,110 @@ async function waitForCi(input) {
466
714
  return normalize(res);
467
715
  };
468
716
 
469
- let failedPolls = 0;
470
717
  let firstMismatchAt = null;
471
718
  let firstEmptyAt = null;
472
- /** @type {{ status: string, pending: string[] }} */
473
- let last = { status: 'INDETERMINATE', pending: [] };
474
719
 
475
- for (let poll = 0; poll < MAX_POLLS; poll++) {
476
- if (clock.now() >= deadline) break;
720
+ /**
721
+ * A step that ends the run with a result.
722
+ *
723
+ * @param {string} status
724
+ * @param {string | null} reason
725
+ * @param {string[]} failing
726
+ * @param {string[]} pending
727
+ * @returns {PollStep}
728
+ */
729
+ const resultStep = (status, reason, failing, pending) => ({ state: 'done', result: finish(status, reason, failing, pending) });
477
730
 
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, [], []);
731
+ /**
732
+ * The step for what matchedStep decided. A probe request cannot be a final step: it is
733
+ * no verdict, and the wait stays open.
734
+ *
735
+ * @param {ReturnType<typeof matchedStep>} next
736
+ * @returns {PollStep}
737
+ */
738
+ const stepOf = next => {
739
+ if (next.state === 'done') return resultStep(next.status, next.reason, next.failing, next.pending);
740
+ return next.state === 'probe' ? { state: 'open' } : next;
741
+ };
481
742
 
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' };
743
+ const viewArgs = ['pr', 'view', pr, '--json', VIEW_FIELDS];
744
+
745
+ /**
746
+ * One poll: the view, then, on a matching head that does not conflict, the checks, then
747
+ * the view again, then, on the one path that needs it, the workflow probe. Both settle
748
+ * windows run here, after the second view, so a poll that does not count moves neither.
749
+ *
750
+ * @returns {Promise<PollStep>}
751
+ */
752
+ const readPoll = async () => {
753
+ const view = await gh(viewArgs);
754
+ if (view === null) return { state: 'deadline' };
755
+ if (view.answered && view.status === 1 && NO_PR_RE.test(view.stderr)) return resultStep('NO_PR', null, [], []);
756
+ if (!view.answered || view.status !== 0) return { state: 'failed' };
757
+ const parsed = parseView(view.stdout);
758
+ if (parsed === null) return resultStep('INDETERMINATE', 'unparseable', [], []);
759
+ if (parsed.state !== 'OPEN') return resultStep('INDETERMINATE', 'not-open', [], []);
760
+ if (parsed.oid !== head) {
761
+ const mismatch = settle(firstMismatchAt, clock.now(), settleMs);
762
+ firstMismatchAt = mismatch.firstAt;
763
+ return mismatch.done ? resultStep('INDETERMINATE', 'head-mismatch', [], []) : { state: 'pending', pending: [] };
764
+ }
765
+ firstMismatchAt = null;
766
+ if (isConflicting(parsed.mergeable)) return resultStep('INDETERMINATE', 'merge-conflict', [], []);
767
+
768
+ const checks = await gh(['pr', 'checks', pr, '--json', 'name,state,bucket,workflow']);
769
+ if (checks === null) return { state: 'deadline' };
770
+ const isResult = checks.answered && (checks.status === 0 || checks.status === 8);
771
+ const isEmpty = checks.answered && checks.status === 1 && NO_CHECKS_RE.test(checks.stderr);
772
+ if (!isResult && !isEmpty) return { state: 'failed' };
773
+ const second = await gh(viewArgs);
774
+ if (second === null) return { state: 'deadline' };
775
+ const after = second.answered && second.status === 0 ? parseView(second.stdout) : null;
776
+ if (after === null) return { state: 'failed' };
777
+ if (after.oid !== head) return { state: 'open' };
778
+ const rows = isResult ? parseChecks(checks.stdout) : [];
779
+ if (rows === null) return resultStep('INDETERMINATE', 'unparseable', [], []);
780
+ const verdict = rows.length > 0 ? classifyChecks(rows) : null;
781
+ let windowDone = false;
782
+ if (verdict === null || verdict.reason === 'no-workflow-check') {
783
+ const empty = settle(firstEmptyAt, clock.now(), settleMs);
784
+ firstEmptyAt = empty.firstAt;
785
+ windowDone = empty.done;
486
786
  } 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
- }
787
+ firstEmptyAt = null;
521
788
  }
522
789
 
790
+ const next = matchedStep(verdict, parsed.mergeable, windowDone, null);
791
+ if (next.state !== 'probe') return stepOf(next);
792
+ const asked = await askWorkflows(gh);
793
+ if (asked.kind === 'deadline') return { state: 'deadline' };
794
+ if (asked.kind === 'failed') return { state: 'failed' };
795
+ if (asked.kind === 'unparseable') return resultStep('INDETERMINATE', 'unparseable', [], []);
796
+ return stepOf(matchedStep(verdict, parsed.mergeable, windowDone, asked.active));
797
+ };
798
+
799
+ let failedPolls = 0;
800
+ /** @type {{ status: string, pending: string[] }} */
801
+ let last = { status: 'INDETERMINATE', pending: [] };
802
+
803
+ for (let poll = 0; poll < MAX_POLLS; poll++) {
804
+ if (clock.now() >= deadline) break;
805
+
806
+ const step = await readPoll();
807
+ if (step.state === 'deadline') break;
523
808
  if (step.state === 'done') return step.result;
524
809
  if (step.state === 'failed') {
525
810
  failedPolls++;
526
811
  if (failedPolls >= MAX_CONSECUTIVE_GH_FAILURES) return finish('INDETERMINATE', 'gh-failed', [], []);
527
812
  last = { status: 'INDETERMINATE', pending: [] };
528
- } else {
813
+ } else if (step.state === 'pending') {
529
814
  failedPolls = 0;
530
815
  last = { status: 'PENDING', pending: step.pending };
816
+ } else {
817
+ // An open step: no verdict, and the deadline exit after it is INDETERMINATE, never
818
+ // PENDING.
819
+ failedPolls = 0;
820
+ last = { status: 'INDETERMINATE', pending: [] };
531
821
  }
532
822
 
533
823
  const left = deadline - clock.now();
@@ -643,10 +933,17 @@ module.exports = Object.freeze({
643
933
  NAMES_LISTED,
644
934
  LINE_RE,
645
935
  USAGE_LINE,
936
+ WORKFLOWS_ARGS,
646
937
  parseArgs,
647
938
  sanitizeName,
648
939
  formatNames,
649
940
  classifyChecks,
941
+ isConflicting,
942
+ mergeGate,
943
+ matchedStep,
944
+ parseChecks,
945
+ parseActiveWorkflows,
946
+ askWorkflows,
650
947
  renderLine,
651
948
  checkLine,
652
949
  waitForCi,