dev-loops 0.3.0 → 0.5.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 (85) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +1 -1
  3. package/.claude/agents/refiner.md +1 -0
  4. package/.claude/hooks/_run-context.mjs +9 -16
  5. package/.claude/skills/copilot-pr-followup/SKILL.md +9 -7
  6. package/.claude/skills/dev-loop/SKILL.md +17 -7
  7. package/.claude/skills/dev-loop/templates/slides-story-review.md +54 -0
  8. package/.claude/skills/docs/anti-patterns.md +1 -1
  9. package/.claude/skills/docs/artifact-authority-contract.md +86 -31
  10. package/.claude/skills/docs/copilot-loop-operations.md +1 -1
  11. package/.claude/skills/docs/issue-intake-procedure.md +4 -0
  12. package/.claude/skills/docs/local-planning-flow.md +63 -0
  13. package/.claude/skills/docs/local-planning-worked-example.md +139 -0
  14. package/.claude/skills/docs/merge-preconditions.md +100 -1
  15. package/.claude/skills/docs/plan-file-contract.md +37 -0
  16. package/.claude/skills/docs/retrospective-checkpoint-contract.md +55 -7
  17. package/.claude/skills/docs/spike-mode-contract.md +237 -0
  18. package/.claude/skills/docs/stop-conditions.md +1 -0
  19. package/.claude/skills/docs/tracker-first-loop-state.md +6 -6
  20. package/.claude/skills/local-implementation/SKILL.md +8 -2
  21. package/CHANGELOG.md +69 -0
  22. package/README.md +9 -2
  23. package/agents/refiner.agent.md +1 -0
  24. package/cli/index.mjs +21 -2
  25. package/extension/README.md +1 -1
  26. package/package.json +6 -4
  27. package/scripts/README.md +2 -2
  28. package/scripts/github/capture-review-threads.mjs +20 -2
  29. package/scripts/github/offer-human-handoff.mjs +147 -0
  30. package/scripts/github/probe-ci-status.mjs +468 -0
  31. package/scripts/github/probe-copilot-review.mjs +69 -3
  32. package/scripts/github/request-copilot-review.mjs +3 -3
  33. package/scripts/github/resolve-handoff-candidates.mjs +412 -0
  34. package/scripts/github/upsert-checkpoint-verdict.mjs +20 -5
  35. package/scripts/lib/jq-output.mjs +297 -0
  36. package/scripts/loop/_stale-runner-detection.mjs +1 -1
  37. package/scripts/loop/_worktree-path.mjs +27 -0
  38. package/scripts/loop/check-retro-tooling.mjs +246 -0
  39. package/scripts/loop/cleanup-worktree.mjs +175 -0
  40. package/scripts/loop/copilot-pr-handoff.mjs +22 -4
  41. package/scripts/loop/detect-pr-gate-coordination-state.mjs +35 -2
  42. package/scripts/loop/detect-stale-runner.mjs +3 -4
  43. package/scripts/loop/docs-grill-contract.mjs +70 -0
  44. package/scripts/loop/ensure-worktree.mjs +219 -0
  45. package/scripts/loop/info.mjs +21 -2
  46. package/scripts/loop/outer-loop.mjs +1 -1
  47. package/scripts/loop/pr-runner-coordination.mjs +21 -5
  48. package/scripts/loop/pre-flight-gate.mjs +10 -7
  49. package/scripts/loop/pre-push-main-guard.mjs +4 -4
  50. package/scripts/loop/provision-worktree.mjs +243 -0
  51. package/scripts/loop/resolve-dev-loop-startup.mjs +181 -10
  52. package/scripts/loop/resolve-pr-conflicts.mjs +357 -0
  53. package/scripts/loop/run-queue.mjs +25 -1
  54. package/scripts/loop/run-watch-cycle.mjs +152 -25
  55. package/scripts/loop/slides-story-review-contract.mjs +123 -0
  56. package/scripts/pages/build-site.mjs +136 -0
  57. package/scripts/projects/add-queue-item.mjs +32 -7
  58. package/scripts/projects/archive-done-items.mjs +2 -1
  59. package/scripts/projects/ensure-queue-board.mjs +2 -1
  60. package/scripts/projects/list-queue-items.mjs +14 -3
  61. package/scripts/projects/move-queue-item.mjs +14 -3
  62. package/scripts/projects/reorder-queue-item.mjs +5 -4
  63. package/scripts/projects/sync-item-status.mjs +2 -1
  64. package/scripts/refine/_refine-helpers.mjs +20 -0
  65. package/scripts/refine/exit-spike.mjs +186 -0
  66. package/scripts/refine/promote-plan.mjs +387 -0
  67. package/scripts/refine/refine-plan-file.mjs +165 -0
  68. package/scripts/refine/validate-plan-file.mjs +64 -0
  69. package/scripts/refine/validate-spike-file.mjs +87 -0
  70. package/skills/copilot-pr-followup/SKILL.md +9 -7
  71. package/skills/dev-loop/SKILL.md +13 -3
  72. package/skills/dev-loop/templates/slides-story-review.md +54 -0
  73. package/skills/docs/anti-patterns.md +1 -1
  74. package/skills/docs/artifact-authority-contract.md +86 -31
  75. package/skills/docs/copilot-loop-operations.md +1 -1
  76. package/skills/docs/issue-intake-procedure.md +4 -0
  77. package/skills/docs/local-planning-flow.md +63 -0
  78. package/skills/docs/local-planning-worked-example.md +139 -0
  79. package/skills/docs/merge-preconditions.md +100 -1
  80. package/skills/docs/plan-file-contract.md +37 -0
  81. package/skills/docs/retrospective-checkpoint-contract.md +55 -7
  82. package/skills/docs/spike-mode-contract.md +237 -0
  83. package/skills/docs/stop-conditions.md +1 -0
  84. package/skills/docs/tracker-first-loop-state.md +6 -6
  85. package/skills/local-implementation/SKILL.md +8 -2
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
3
+ import { parsePrNumber, runChild } from "../_cli-primitives.mjs";
4
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
5
+ import { loadDevLoopConfig } from "@dev-loops/core/config";
6
+ import { resolveHandoffCandidates } from "./resolve-handoff-candidates.mjs";
7
+
8
+ const USAGE = `Usage: offer-human-handoff.mjs --repo <owner/name> --pr <number> [--assign <login>...] [--request-review <login>...] [--changed-files <csv>] [--pr-author <login>]
9
+ Human-handoff offer at the pre-approval / merge-handoff boundary (#920, Request B
10
+ of #910). OFFER-only: without --assign/--request-review it resolves and prints
11
+ the candidate list (the "offer"); the operator confirms who takes it. With
12
+ --assign / --request-review it performs the confirmed action via \`gh pr edit\`.
13
+
14
+ This pairs with autonomy.humanMergeOnly: when human-merge is enforced, the offer
15
+ names who should take the merge. No-op when approval.humanHandoff is disabled
16
+ (default) — prints an empty offer.
17
+
18
+ Required:
19
+ --repo <owner/name>
20
+ --pr <number>
21
+ Optional:
22
+ --assign <login> Add a confirmed assignee (repeatable). Performs
23
+ \`gh pr edit --add-assignee\`.
24
+ --request-review <login> Request review from a confirmed reviewer (repeatable).
25
+ Performs \`gh pr edit --add-reviewer\`.
26
+ --changed-files <csv> Forwarded to the candidate resolver.
27
+ --pr-author <login> Forwarded to the candidate resolver.
28
+ Output (stdout, JSON):
29
+ Offer mode: { "ok": true, "mode": "offer", "enabled", "candidates": [...], ... }
30
+ Apply mode: { "ok": true, "mode": "apply", "assigned": [...], "requestedReview": [...] }
31
+ Exit codes:
32
+ 0 Success (including disabled no-op offer)
33
+ 1 Argument error or gh failure`.trim();
34
+
35
+ const parseError = buildParseError(USAGE);
36
+
37
+ function nextValue(args, i, flag) {
38
+ const value = args[i];
39
+ if (typeof value !== "string" || value.length === 0 || value.startsWith("--")) {
40
+ throw parseError(`Missing value for ${flag}`);
41
+ }
42
+ return value;
43
+ }
44
+
45
+ // A login flag value with the leading `@` stripped and trimmed. Reject an empty
46
+ // result (e.g. `--assign @`) so a blank login can never reach `gh pr edit
47
+ // --add-assignee ""`.
48
+ function loginValue(args, i, flag) {
49
+ const login = nextValue(args, i, flag).trim().replace(/^@/, "").trim();
50
+ if (login === "") throw parseError(`${flag} requires a non-empty login`);
51
+ return login;
52
+ }
53
+
54
+ export function parseOfferCliArgs(argv) {
55
+ const args = [...argv];
56
+ if (args.includes("--help") || args.includes("-h")) return { help: true };
57
+ const options = {
58
+ help: false, repo: undefined, pr: undefined,
59
+ assign: [], requestReview: [], changedFiles: undefined, prAuthor: undefined,
60
+ };
61
+ for (let i = 0; i < args.length; i += 1) {
62
+ const token = args[i];
63
+ if (token === "--repo") { options.repo = nextValue(args, ++i, "--repo"); continue; }
64
+ if (token === "--pr") { options.pr = parsePrNumber(nextValue(args, ++i, "--pr"), parseError); continue; }
65
+ if (token === "--assign") { options.assign.push(loginValue(args, ++i, "--assign")); continue; }
66
+ if (token === "--request-review") { options.requestReview.push(loginValue(args, ++i, "--request-review")); continue; }
67
+ if (token === "--changed-files") {
68
+ options.changedFiles = nextValue(args, ++i, "--changed-files")
69
+ .split(",").map((s) => s.trim()).filter((s) => s.length > 0);
70
+ continue;
71
+ }
72
+ if (token === "--pr-author") { options.prAuthor = nextValue(args, ++i, "--pr-author").trim().replace(/^@/, ""); continue; }
73
+ throw parseError(`Unknown argument: ${token}`);
74
+ }
75
+ if (options.repo === undefined || options.pr === undefined) {
76
+ throw parseError("Offering human handoff requires both --repo <owner/name> and --pr <number>");
77
+ }
78
+ try {
79
+ parseRepoSlug(options.repo);
80
+ } catch (error) {
81
+ throw parseError(error instanceof Error ? error.message : String(error));
82
+ }
83
+ return options;
84
+ }
85
+
86
+ /**
87
+ * Perform the confirmed assign / request-review actions via `gh pr edit`.
88
+ * OFFER-only contract: this is reached only when the operator passed
89
+ * --assign / --request-review (the confirmation). It never auto-assigns.
90
+ * @returns {Promise<{ ok: boolean, mode: "apply", assigned: string[], requestedReview: string[] }>}
91
+ */
92
+ export async function applyHandoff({ repo, pr, assign, requestReview }, { run = (cmd, args) => runChild(cmd, args), ghCommand = "gh" } = {}) {
93
+ const ghArgs = ["pr", "edit", String(pr), "--repo", repo];
94
+ for (const login of assign) ghArgs.push("--add-assignee", login);
95
+ for (const login of requestReview) ghArgs.push("--add-reviewer", login);
96
+ const result = await run(ghCommand, ghArgs);
97
+ if (result.code !== 0) {
98
+ throw new Error(result.stderr.trim() || `gh pr edit exited ${result.code}`);
99
+ }
100
+ return { ok: true, mode: "apply", assigned: assign, requestedReview: requestReview };
101
+ }
102
+
103
+ export async function main(argv = process.argv.slice(2), deps = {}) {
104
+ let options;
105
+ try {
106
+ options = parseOfferCliArgs(argv);
107
+ } catch (error) {
108
+ process.stderr.write(`${formatCliError(error)}\n`);
109
+ return 1;
110
+ }
111
+ if (options.help) {
112
+ process.stdout.write(`${USAGE}\n`);
113
+ return 0;
114
+ }
115
+
116
+ const run = deps.run ?? ((cmd, args) => runChild(cmd, args));
117
+ const ghCommand = deps.ghCommand ?? "gh";
118
+
119
+ // Apply mode: operator confirmed an assignee/reviewer.
120
+ if (options.assign.length > 0 || options.requestReview.length > 0) {
121
+ try {
122
+ const applied = await applyHandoff(
123
+ { repo: options.repo, pr: options.pr, assign: options.assign, requestReview: options.requestReview },
124
+ { run, ghCommand },
125
+ );
126
+ process.stdout.write(`${JSON.stringify(applied)}\n`);
127
+ return 0;
128
+ } catch (error) {
129
+ process.stderr.write(`${formatCliError(error)}\n`);
130
+ return 1;
131
+ }
132
+ }
133
+
134
+ // Offer mode: resolve + print candidates, assign nobody.
135
+ const repoRoot = deps.repoRoot ?? process.cwd();
136
+ const config = deps.config !== undefined ? deps.config : (await loadDevLoopConfig({ repoRoot })).config;
137
+ const offer = await resolveHandoffCandidates(
138
+ { repo: options.repo, pr: options.pr, changedFiles: options.changedFiles, prAuthor: options.prAuthor ?? null },
139
+ { ...deps, config, repoRoot, run, ghCommand },
140
+ );
141
+ process.stdout.write(`${JSON.stringify({ ...offer, mode: "offer" })}\n`);
142
+ return 0;
143
+ }
144
+
145
+ if (isDirectCliRun(import.meta.url)) {
146
+ main().then((code) => { process.exitCode = code; });
147
+ }
@@ -0,0 +1,468 @@
1
+ #!/usr/bin/env node
2
+ import { setTimeout as delay } from "node:timers/promises";
3
+ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
4
+ import { parseArgs } from "node:util";
5
+ import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
6
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
7
+ import {
8
+ summarizeHeadScopedCheckRunsSignal,
9
+ normalizeHeadScopedCommitStatus,
10
+ normalizeHeadScopedCiContract,
11
+ } from "@dev-loops/core/loop/copilot-ci-status";
12
+ import {
13
+ DEFAULT_POLL_INTERVAL_MS,
14
+ COPILOT_REVIEW_WAIT_TIMEOUT_MS,
15
+ } from "@dev-loops/core/loop/policy-constants";
16
+
17
+ /** Maximum interval between heartbeat outputs during watch delays.
18
+ * Must be shorter than pi-subagents default needsAttentionAfterMs (60s). */
19
+ const WATCH_HEARTBEAT_MS = 45_000; // 45 seconds
20
+ const USAGE = `Usage: probe-ci-status.mjs --repo <owner/name> --pr <number> [--timeout-ms <n>] [--poll-interval-ms <n>]
21
+ Block-wait on a PR's combined CI check/status state (GitHub Actions + CircleCI +
22
+ any external commit-status / check-run) for the current head SHA, until terminal
23
+ or timeout. Provider-agnostic — unlike \`gh run watch\`, which is Actions-only.
24
+ Required:
25
+ --repo <owner/name> Repository slug (e.g. owner/repo)
26
+ --pr <number> Pull request number
27
+ Optional:
28
+ --timeout-ms <n> Total watch budget (default 1800000; 0 = single check, no wait)
29
+ --poll-interval-ms <n> Delay between polls (default 60000)
30
+ Output (stdout, JSON):
31
+ { "ok": true, "status": "success"|"failure"|"pending"|"timeout"|"changed",
32
+ "settled": bool, "ciStatus": "success"|"failure"|"pending"|"none",
33
+ "failedChecks": [{ "name": "...", "conclusion"?: "..." }], "headSha": "...", "attempts": N }
34
+ Statuses:
35
+ success Combined CI is green (or no checks present — see no-checks rule)
36
+ failure At least one check/status failed (failedChecks populated)
37
+ pending Timed-out single check (timeout-ms 0) found CI still in flight
38
+ timeout Watch budget elapsed while CI was still pending
39
+ changed Head SHA advanced during the wait; caller must re-baseline
40
+ No-checks rule (grace, race-safe):
41
+ Zero check-runs AND zero commit-statuses is NOT settled green on the first
42
+ poll — a provider (CircleCI/Actions) may post its first check a beat after a
43
+ fresh push, and settling early would report green before any CI ran. Instead
44
+ the watcher awaits 2 consecutive zero-check polls (a ~2-poll-interval grace)
45
+ before settling success (ciStatus "none"): a genuinely check-less repo still
46
+ settles instead of hanging, while a late first check is awaited. If the PR's
47
+ statusCheckRollup lists EXPECTED checks while the APIs still report zero, that
48
+ is pending (checks expected, not yet reported), never none. A gh-api / parse
49
+ failure is never treated as empty — it forces pending so the watch keeps
50
+ polling, and a persistent error settles as "timeout", never fabricated green.
51
+ (timeout-ms 0 single check has no waiting budget, so a clean no-checks head
52
+ settles immediately.)
53
+ Diagnostic output (stderr):
54
+ { "ok": true, "type": "watch_heartbeat", "elapsedMs": N, "totalBudgetMs": N, "poll": N, "maxPolls": N }
55
+ { "ok": false, "error": "...", "usage"?: "..." }
56
+ Exit codes:
57
+ 0 Success
58
+ 1 Argument error or gh failure`.trim();
59
+ const parseError = buildParseError(USAGE);
60
+
61
+ export function parseCiWatchCliArgs(argv) {
62
+ const { tokens } = parseArgs({
63
+ args: [...argv],
64
+ options: {
65
+ help: { type: "boolean", short: "h" },
66
+ repo: { type: "string" },
67
+ pr: { type: "string" },
68
+ "timeout-ms": { type: "string" },
69
+ "poll-interval-ms": { type: "string" },
70
+ },
71
+ allowPositionals: true,
72
+ strict: false,
73
+ tokens: true,
74
+ });
75
+ const options = {
76
+ help: false,
77
+ repo: undefined,
78
+ pr: undefined,
79
+ pollIntervalMs: DEFAULT_POLL_INTERVAL_MS,
80
+ timeoutMs: COPILOT_REVIEW_WAIT_TIMEOUT_MS,
81
+ };
82
+ for (const token of tokens) {
83
+ if (token.kind === "positional") {
84
+ throw parseError(`Unknown argument: ${token.value}`);
85
+ }
86
+ if (token.kind !== "option") {
87
+ continue;
88
+ }
89
+ if (token.name === "help") {
90
+ options.help = true;
91
+ return options;
92
+ }
93
+ if (token.name === "repo") {
94
+ options.repo = requireTokenValue(token, parseError).trim();
95
+ continue;
96
+ }
97
+ if (token.name === "pr") {
98
+ options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
99
+ continue;
100
+ }
101
+ if (token.name === "timeout-ms") {
102
+ options.timeoutMs = parseNonNegativeMs(requireTokenValue(token, parseError), "--timeout-ms");
103
+ continue;
104
+ }
105
+ if (token.name === "poll-interval-ms") {
106
+ options.pollIntervalMs = parsePositiveMs(requireTokenValue(token, parseError), "--poll-interval-ms");
107
+ continue;
108
+ }
109
+ throw parseError(`Unknown argument: ${token.rawName}`);
110
+ }
111
+ if (options.repo === undefined || options.pr === undefined) {
112
+ throw parseError("Watching CI requires both --repo <owner/name> and --pr <number>");
113
+ }
114
+ try {
115
+ parseRepoSlug(options.repo);
116
+ } catch (error) {
117
+ throw parseError(error instanceof Error ? error.message : String(error));
118
+ }
119
+ return options;
120
+ }
121
+
122
+ function parseNonNegativeMs(raw, flag) {
123
+ const value = Number(raw);
124
+ if (!Number.isInteger(value) || value < 0) {
125
+ throw parseError(`${flag} must be a non-negative integer`);
126
+ }
127
+ return value;
128
+ }
129
+
130
+ function parsePositiveMs(raw, flag) {
131
+ const value = Number(raw);
132
+ if (!Number.isInteger(value) || value <= 0) {
133
+ throw parseError(`${flag} must be a positive integer`);
134
+ }
135
+ return value;
136
+ }
137
+
138
+ async function ghJson(ghCommand, args, env) {
139
+ const result = await runChild(ghCommand, args, env);
140
+ if (result.code !== 0) {
141
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
142
+ throw new Error(`gh command failed: ${detail}`);
143
+ }
144
+ try {
145
+ return JSON.parse(result.stdout);
146
+ } catch {
147
+ throw new Error(`Invalid JSON from gh: ${result.stdout.trim() || "<empty>"}`);
148
+ }
149
+ }
150
+
151
+ function extractPrVisibleCheckNames(statusCheckRollup) {
152
+ if (!Array.isArray(statusCheckRollup)) return [];
153
+ return statusCheckRollup
154
+ .map((entry) => entry?.name || entry?.context)
155
+ .filter((name) => typeof name === "string" && name.length > 0);
156
+ }
157
+
158
+ // Failing commit-status contexts (state "failure"/"error"), e.g. CircleCI which
159
+ // reports through the status API rather than as check-runs.
160
+ function extractFailedStatusContexts(statuses) {
161
+ if (!Array.isArray(statuses)) return [];
162
+ return statuses
163
+ .filter((s) => {
164
+ const state = typeof s?.state === "string" ? s.state.toLowerCase() : "";
165
+ return state === "failure" || state === "error";
166
+ })
167
+ .map((s) => ({
168
+ name: typeof s?.context === "string" && s.context.length > 0 ? s.context : "unknown",
169
+ conclusion: typeof s?.state === "string" ? s.state.toLowerCase() : "",
170
+ }));
171
+ }
172
+
173
+ async function fetchPrHeadSha({ repo, pr }, { env, ghCommand }) {
174
+ const payload = await ghJson(
175
+ ghCommand,
176
+ ["pr", "view", String(pr), "--repo", repo, "--json", "headRefOid,statusCheckRollup"],
177
+ env,
178
+ );
179
+ const headSha = typeof payload.headRefOid === "string" ? payload.headRefOid.trim() : "";
180
+ if (headSha.length === 0) {
181
+ throw new Error("Missing required PR facts: headRefOid");
182
+ }
183
+ return { headSha, prVisibleCheckNames: extractPrVisibleCheckNames(payload.statusCheckRollup) };
184
+ }
185
+
186
+ /**
187
+ * Read the combined check-runs + commit-status state for one head SHA.
188
+ * Provider-agnostic: covers GitHub Actions, CircleCI, and any external
189
+ * commit-status / check-run reported against the commit.
190
+ *
191
+ * `fetchError` is true when a `gh api` call failed (non-zero exit) or returned
192
+ * an unparseable / malformed payload. The caller MUST NOT treat a fetchError as
193
+ * a genuine empty (no-checks) state — a transient API error would otherwise
194
+ * fabricate green. On fetchError, ciStatus is forced to "pending" so the watch
195
+ * keeps polling (and a persistent error settles as "timeout", never success).
196
+ *
197
+ * @returns {{ ciStatus: "success"|"failure"|"pending"|"none", noChecks: boolean, fetchError: boolean, failedChecks: Array<{ name: string, conclusion?: string }> }}
198
+ */
199
+ async function fetchHeadCiState({ repo, headSha, prVisibleCheckNames }, { env, ghCommand }) {
200
+ const [checkRunsResult, statusesResult] = await Promise.all([
201
+ runChild(ghCommand, ["api", `repos/${repo}/commits/${headSha}/check-runs?per_page=100`], env),
202
+ runChild(ghCommand, ["api", `repos/${repo}/commits/${headSha}/status?per_page=100`], env),
203
+ ]);
204
+
205
+ let checkRunsSignal = null;
206
+ let checkRunsCount = 0;
207
+ let checkRunsError = checkRunsResult.code !== 0;
208
+ if (checkRunsResult.code === 0) {
209
+ try {
210
+ const payload = JSON.parse(checkRunsResult.stdout);
211
+ if (Array.isArray(payload?.check_runs)) {
212
+ const visibleSet = prVisibleCheckNames?.length > 0 ? new Set(prVisibleCheckNames) : null;
213
+ const visibleRuns = visibleSet
214
+ ? payload.check_runs.filter((run) => !run.name || visibleSet.has(run.name))
215
+ : payload.check_runs;
216
+ const visibleSignal = summarizeHeadScopedCheckRunsSignal({ check_runs: visibleRuns });
217
+ const fullSignal = summarizeHeadScopedCheckRunsSignal(payload);
218
+ checkRunsSignal = { ...visibleSignal, unsupportedCompleted: fullSignal.unsupportedCompleted };
219
+ checkRunsCount = payload.check_runs.length;
220
+ } else {
221
+ checkRunsError = true; // exit 0 but no check_runs array → malformed payload, not empty
222
+ }
223
+ } catch {
224
+ checkRunsSignal = null;
225
+ checkRunsError = true;
226
+ }
227
+ }
228
+
229
+ let commitStatus = null;
230
+ let statusesCount = 0;
231
+ let statusFailures = [];
232
+ let statusesError = statusesResult.code !== 0;
233
+ if (statusesResult.code === 0) {
234
+ try {
235
+ const payload = JSON.parse(statusesResult.stdout);
236
+ if (Array.isArray(payload?.statuses)) {
237
+ commitStatus = normalizeHeadScopedCommitStatus(payload);
238
+ statusesCount = payload.statuses.length;
239
+ statusFailures = extractFailedStatusContexts(payload.statuses);
240
+ } else {
241
+ statusesError = true; // exit 0 but no statuses array → malformed payload, not empty
242
+ }
243
+ } catch {
244
+ commitStatus = null;
245
+ statusesError = true;
246
+ }
247
+ }
248
+
249
+ const fetchError = checkRunsError || statusesError;
250
+ const ciStatus = fetchError
251
+ ? "pending"
252
+ : normalizeHeadScopedCiContract({
253
+ checkRunsStatus: checkRunsSignal?.status ?? "none",
254
+ commitStatus: commitStatus ?? "none",
255
+ checkRunsUnsupportedCompleted: checkRunsSignal?.unsupportedCompleted ?? false,
256
+ }).overallStatus;
257
+ // Provider-agnostic failure reporting: check-runs failures AND failing
258
+ // commit-status contexts (e.g. CircleCI reports via the status API with NO
259
+ // check-runs). Without the status side, a CircleCI failure would surface
260
+ // ciStatus "failure" with an empty failedChecks.
261
+ const failedChecks = fetchError
262
+ ? []
263
+ : [
264
+ ...(checkRunsSignal?.failureDetails ?? []).map((name) => ({ name })),
265
+ ...statusFailures,
266
+ ];
267
+ // No-checks: zero check-runs AND zero commit-statuses, observed cleanly (no
268
+ // fetchError) AND with no PR-visible expected checks. If statusCheckRollup
269
+ // lists expected checks the providers haven't reported yet, that is pending
270
+ // (checks expected but not yet posted), not a genuinely check-less head.
271
+ const noChecks =
272
+ !fetchError &&
273
+ checkRunsCount === 0 &&
274
+ statusesCount === 0 &&
275
+ !(prVisibleCheckNames?.length > 0);
276
+ return { ciStatus, noChecks, fetchError, failedChecks };
277
+ }
278
+
279
+ function buildAttemptBudget(timeoutMs, pollIntervalMs) {
280
+ if (timeoutMs === 0) {
281
+ return 1;
282
+ }
283
+ // Polls land at t=0, interval, 2*interval, ... so floor(timeout/interval)+1
284
+ // polls fit inside the budget (the first poll costs no delay).
285
+ return Math.max(1, Math.floor(timeoutMs / pollIntervalMs) + 1);
286
+ }
287
+
288
+ // Attempt 1 polls immediately (t=0); attempt N waits (N-1)*pollIntervalMs from
289
+ // the watch start, capped by the total timeout budget.
290
+ function buildPollDelayMs(watchStartedAtMs, timeoutMs, pollIntervalMs, attempt, nowMs) {
291
+ if (timeoutMs === 0 || attempt <= 1) {
292
+ return 0;
293
+ }
294
+ const scheduledAtMs = watchStartedAtMs + Math.min(timeoutMs, (attempt - 1) * pollIntervalMs);
295
+ return Math.max(0, scheduledAtMs - nowMs);
296
+ }
297
+
298
+ function settledResult(state, { settled, status }) {
299
+ return {
300
+ ok: true,
301
+ status,
302
+ settled,
303
+ ciStatus: state.ciStatus,
304
+ failedChecks: state.failedChecks,
305
+ headSha: state.headSha,
306
+ attempts: state.attempts,
307
+ };
308
+ }
309
+
310
+ /** Consecutive clean zero-check polls required before settling none->success.
311
+ * A provider (CircleCI/Actions) may post its first check a beat after the push;
312
+ * settling on the FIRST zero-check poll would fabricate green before any CI ran.
313
+ * So we await this many consecutive zero-check observations (a grace of ~2 poll
314
+ * intervals) before treating a head as genuinely check-less. A repo that truly
315
+ * has no CI still settles after the grace instead of hanging to timeout. */
316
+ export const NO_CHECKS_GRACE_POLLS = 2;
317
+
318
+ /**
319
+ * Map a head CI state to a terminal watcher status, or null when still in flight.
320
+ * - failure / success classify immediately (a check is terminal).
321
+ * - none (zero checks, clean fetch, no expected checks) settles success only
322
+ * after NO_CHECKS_GRACE_POLLS consecutive observations (see constant).
323
+ * - fetchError / expected-but-unreported checks → pending (keep polling).
324
+ */
325
+ function terminalStatusFor({ ciStatus, noChecks }, consecutiveNoChecks, graceFloor) {
326
+ if (ciStatus === "failure") return "failure";
327
+ if (ciStatus === "success") return "success";
328
+ if (noChecks && consecutiveNoChecks >= graceFloor) return "success";
329
+ return null;
330
+ }
331
+
332
+ export async function watchCiStatus(
333
+ options,
334
+ {
335
+ env = process.env,
336
+ ghCommand = "gh",
337
+ delayImpl = delay,
338
+ now = Date.now,
339
+ } = {},
340
+ ) {
341
+ const { headSha: baselineSha, prVisibleCheckNames } = await fetchPrHeadSha(
342
+ { repo: options.repo, pr: options.pr },
343
+ { env, ghCommand },
344
+ );
345
+ const attemptBudget = buildAttemptBudget(options.timeoutMs, options.pollIntervalMs);
346
+ const watchStartedAtMs = now();
347
+ // timeout-ms 0 is a single live check with no waiting budget: there is no
348
+ // grace window to await a late first check, so a clean no-checks head settles
349
+ // immediately (preserves single-check semantics). A real watch awaits the grace.
350
+ const graceFloor = options.timeoutMs === 0 ? 1 : NO_CHECKS_GRACE_POLLS;
351
+ let consecutiveNoChecks = 0;
352
+ for (let attempt = 1; attempt <= attemptBudget; attempt += 1) {
353
+ // Attempt 1 polls at t=0 (CI may already be terminal); sleep only between
354
+ // subsequent polls so the watcher never burns a full interval before its
355
+ // first observation.
356
+ if (attempt > 1) {
357
+ const pollDelayMs = buildPollDelayMs(
358
+ watchStartedAtMs,
359
+ options.timeoutMs,
360
+ options.pollIntervalMs,
361
+ attempt,
362
+ now(),
363
+ );
364
+ let remainingMs = pollDelayMs;
365
+ while (remainingMs > 0) {
366
+ const chunkMs = Math.min(WATCH_HEARTBEAT_MS, remainingMs);
367
+ await delayImpl(chunkMs);
368
+ remainingMs -= chunkMs;
369
+ if (remainingMs > 0) {
370
+ process.stderr.write(
371
+ JSON.stringify({
372
+ ok: true,
373
+ type: "watch_heartbeat",
374
+ elapsedMs: now() - watchStartedAtMs,
375
+ totalBudgetMs: options.timeoutMs,
376
+ poll: attempt,
377
+ maxPolls: attemptBudget,
378
+ }) + "\n",
379
+ );
380
+ }
381
+ }
382
+ }
383
+ // Re-resolve the head SHA every poll: a new push must short-circuit to
384
+ // "changed" so the caller re-baselines instead of waiting on a stale head.
385
+ const { headSha: currentSha, prVisibleCheckNames: currentNames } = await fetchPrHeadSha(
386
+ { repo: options.repo, pr: options.pr },
387
+ { env, ghCommand },
388
+ );
389
+ if (currentSha !== baselineSha) {
390
+ const changedState = await fetchHeadCiState(
391
+ { repo: options.repo, headSha: currentSha, prVisibleCheckNames: currentNames },
392
+ { env, ghCommand },
393
+ );
394
+ return settledResult({ ...changedState, headSha: currentSha, attempts: attempt }, {
395
+ settled: false,
396
+ status: "changed",
397
+ });
398
+ }
399
+ // Use the per-poll currentNames (not the baseline): statusCheckRollup may
400
+ // start empty and later populate expected checks for the SAME head SHA. With
401
+ // the stale baseline names this head would look check-less and wrongly settle
402
+ // none->success; currentNames keeps it pending until the checks report.
403
+ const state = await fetchHeadCiState(
404
+ { repo: options.repo, headSha: currentSha, prVisibleCheckNames: currentNames },
405
+ { env, ghCommand },
406
+ );
407
+ consecutiveNoChecks = state.noChecks ? consecutiveNoChecks + 1 : 0;
408
+ const terminal = terminalStatusFor(state, consecutiveNoChecks, graceFloor);
409
+ if (terminal !== null) {
410
+ return settledResult({ ...state, headSha: currentSha, attempts: attempt }, {
411
+ settled: true,
412
+ status: terminal,
413
+ });
414
+ }
415
+ }
416
+ // Budget exhausted while still pending. Re-resolve the head before the final
417
+ // fetch: the head may have advanced during the last delay (short-circuit to
418
+ // "changed" so the caller re-baselines), and the rollup may have populated
419
+ // expected checks for the same SHA (use the current names, not baseline).
420
+ // A zero-timeout single check reports the live "pending" state; a real watch
421
+ // budget reports "timeout".
422
+ const { headSha: finalSha, prVisibleCheckNames: finalNames } = await fetchPrHeadSha(
423
+ { repo: options.repo, pr: options.pr },
424
+ { env, ghCommand },
425
+ );
426
+ if (finalSha !== baselineSha) {
427
+ const changedState = await fetchHeadCiState(
428
+ { repo: options.repo, headSha: finalSha, prVisibleCheckNames: finalNames },
429
+ { env, ghCommand },
430
+ );
431
+ return settledResult({ ...changedState, headSha: finalSha, attempts: attemptBudget }, {
432
+ settled: false,
433
+ status: "changed",
434
+ });
435
+ }
436
+ const finalState = await fetchHeadCiState(
437
+ { repo: options.repo, headSha: finalSha, prVisibleCheckNames: finalNames },
438
+ { env, ghCommand },
439
+ );
440
+ return settledResult({ ...finalState, headSha: finalSha, attempts: attemptBudget }, {
441
+ settled: false,
442
+ status: options.timeoutMs === 0 ? "pending" : "timeout",
443
+ });
444
+ }
445
+
446
+ export async function runCli(
447
+ argv = process.argv.slice(2),
448
+ {
449
+ stdout = process.stdout,
450
+ env = process.env,
451
+ ghCommand = "gh",
452
+ } = {},
453
+ ) {
454
+ const options = parseCiWatchCliArgs(argv);
455
+ if (options.help) {
456
+ stdout.write(`${USAGE}\n`);
457
+ return;
458
+ }
459
+ const result = await watchCiStatus(options, { env, ghCommand });
460
+ stdout.write(`${JSON.stringify(result)}\n`);
461
+ }
462
+
463
+ if (isDirectCliRun(import.meta.url)) {
464
+ runCli().catch((error) => {
465
+ process.stderr.write(`${formatCliError(error)}\n`);
466
+ process.exitCode = 1;
467
+ });
468
+ }