dev-loops 0.2.7 → 0.4.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 (76) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +1 -1
  3. package/.claude/agents/developer.md +1 -0
  4. package/.claude/agents/fixer.md +1 -0
  5. package/.claude/agents/review.md +30 -0
  6. package/.claude/hooks/_run-context.mjs +9 -16
  7. package/.claude/skills/copilot-pr-followup/SKILL.md +62 -6
  8. package/.claude/skills/dev-loop/SKILL.md +6 -6
  9. package/.claude/skills/docs/anti-patterns.md +2 -0
  10. package/.claude/skills/docs/copilot-loop-operations.md +3 -3
  11. package/.claude/skills/docs/issue-intake-procedure.md +4 -0
  12. package/.claude/skills/docs/merge-preconditions.md +65 -1
  13. package/.claude/skills/docs/stop-conditions.md +1 -0
  14. package/.claude/skills/docs/tracker-first-loop-state.md +6 -6
  15. package/.claude/skills/local-implementation/SKILL.md +25 -5
  16. package/AGENTS.md +1 -1
  17. package/CHANGELOG.md +69 -0
  18. package/README.md +8 -2
  19. package/agents/developer.agent.md +1 -0
  20. package/agents/fixer.agent.md +1 -0
  21. package/agents/review.agent.md +30 -0
  22. package/cli/index.mjs +60 -8
  23. package/extension/README.md +1 -1
  24. package/package.json +3 -3
  25. package/scripts/README.md +8 -7
  26. package/scripts/_core-helpers.mjs +1 -0
  27. package/scripts/claude/headless-dev-loop.mjs +53 -13
  28. package/scripts/claude/headless-info-smoke.mjs +45 -11
  29. package/scripts/github/build-adjacent-bundle.mjs +448 -0
  30. package/scripts/github/{create-draft-pr.mjs → create-pr.mjs} +28 -12
  31. package/scripts/github/detect-checkpoint-evidence.mjs +95 -4
  32. package/scripts/github/offer-human-handoff.mjs +147 -0
  33. package/scripts/github/post-gate-findings.mjs +392 -0
  34. package/scripts/github/probe-ci-status.mjs +468 -0
  35. package/scripts/github/reconcile-draft-gate.mjs +2 -2
  36. package/scripts/github/request-copilot-review.mjs +72 -11
  37. package/scripts/github/resolve-handoff-candidates.mjs +412 -0
  38. package/scripts/github/upsert-checkpoint-verdict.mjs +599 -17
  39. package/scripts/github/verify-fresh-review-context.mjs +12 -1
  40. package/scripts/github/write-gate-context.mjs +634 -0
  41. package/scripts/github/write-gate-findings-log.mjs +1 -1
  42. package/scripts/loop/_stale-runner-detection.mjs +1 -1
  43. package/scripts/loop/_worktree-path.mjs +27 -0
  44. package/scripts/loop/cleanup-worktree.mjs +175 -0
  45. package/scripts/loop/copilot-pr-handoff.mjs +1 -1
  46. package/scripts/loop/detect-change-scope.mjs +36 -11
  47. package/scripts/loop/detect-pr-gate-coordination-state.mjs +30 -18
  48. package/scripts/loop/detect-stale-runner.mjs +3 -4
  49. package/scripts/loop/detect-tracker-first-loop-state.mjs +38 -11
  50. package/scripts/loop/ensure-worktree.mjs +219 -0
  51. package/scripts/loop/outer-loop.mjs +1 -1
  52. package/scripts/loop/pr-runner-coordination.mjs +1 -1
  53. package/scripts/loop/pre-flight-gate.mjs +10 -7
  54. package/scripts/loop/pre-push-main-guard.mjs +4 -4
  55. package/scripts/loop/provision-worktree.mjs +243 -0
  56. package/scripts/loop/resolve-dev-loop-startup.mjs +5 -5
  57. package/scripts/loop/run-queue.mjs +112 -16
  58. package/scripts/loop/run-watch-cycle.mjs +75 -22
  59. package/scripts/projects/add-queue-item.mjs +80 -48
  60. package/scripts/projects/archive-done-items.mjs +136 -39
  61. package/scripts/projects/ensure-queue-board.mjs +67 -65
  62. package/scripts/projects/list-queue-items.mjs +59 -57
  63. package/scripts/projects/move-queue-item.mjs +125 -125
  64. package/scripts/projects/reorder-queue-item.mjs +67 -48
  65. package/scripts/projects/sync-item-status.mjs +199 -0
  66. package/skills/copilot-pr-followup/SKILL.md +62 -6
  67. package/skills/dev-loop/SKILL.md +2 -2
  68. package/skills/dev-loop/scripts/log-bash-exit-1.mjs +2 -2
  69. package/skills/dev-loop/scripts/phase-files.mjs +2 -2
  70. package/skills/docs/anti-patterns.md +2 -0
  71. package/skills/docs/copilot-loop-operations.md +3 -3
  72. package/skills/docs/issue-intake-procedure.md +4 -0
  73. package/skills/docs/merge-preconditions.md +65 -1
  74. package/skills/docs/stop-conditions.md +1 -0
  75. package/skills/docs/tracker-first-loop-state.md +6 -6
  76. package/skills/local-implementation/SKILL.md +25 -5
@@ -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
+ }
@@ -124,7 +124,7 @@ async function resolvePrNodeId({ repo, pr }, { env, ghCommand }) {
124
124
  }
125
125
  return { id: prData.id, isDraft: prData.isDraft };
126
126
  }
127
- async function convertPrToDraft({ repo, pr }, { env, ghCommand }) {
127
+ export async function convertPrToDraft({ repo, pr }, { env, ghCommand }) {
128
128
  const resolvedPr = await resolvePrNodeId({ repo, pr }, { env, ghCommand });
129
129
  if (resolvedPr.isDraft === true) {
130
130
  return {
@@ -154,7 +154,7 @@ async function convertPrToDraft({ repo, pr }, { env, ghCommand }) {
154
154
  alreadyDraft: false,
155
155
  };
156
156
  }
157
- async function markPrReady({ repo, pr }, { env, ghCommand }) {
157
+ export async function markPrReady({ repo, pr }, { env, ghCommand }) {
158
158
  const result = await runChild(ghCommand, [
159
159
  "pr", "ready", String(pr),
160
160
  "--repo", repo,
@@ -6,7 +6,9 @@ import {
6
6
  isCopilotLogin,
7
7
  isDirectCliRun,
8
8
  parseReviewThreads,
9
+ resolveDraftGateRoundResetMs,
9
10
  summarizeCopilotReviews,
11
+ summarizeGateReviewComments,
10
12
  } from "../_core-helpers.mjs";
11
13
  import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
12
14
  import { fetchGithubReviewThreadsPayload } from "./capture-review-threads.mjs";
@@ -28,7 +30,7 @@ Optional:
28
30
  the last Copilot review. Refused when the PR head
29
31
  has not changed since the last review.
30
32
  Debug:
31
- PI_DEV_LOOPS_DEBUG=1 Emit stderr traces when best-effort same-head clean
33
+ DEVLOOPS_DEBUG=1 Emit stderr traces when best-effort same-head clean
32
34
  convergence detection falls back to unsuppressed behavior
33
35
  Output (stdout, JSON):
34
36
  { "ok": true, "status": "requested"|"already-requested"|"unavailable"|"suppressed_same_head_clean"|"blocked_by_copilot_comment"|"round_cap_reached"|"no_changes_since_last_review"|"suppressed_draft",
@@ -121,7 +123,7 @@ function parseRequestedReviewersPayload(text) {
121
123
  requested: users.some((user) => isCopilotLogin(user?.login)),
122
124
  };
123
125
  }
124
- function parseReviewsPayload(text) {
126
+ function parseReviewsPayload(text, { draftGateResetAtMs = null } = {}) {
125
127
  let payload;
126
128
  try {
127
129
  payload = JSON.parse(text);
@@ -131,7 +133,10 @@ function parseReviewsPayload(text) {
131
133
  const headSha = typeof payload?.headRefOid === "string" && payload.headRefOid.trim().length > 0
132
134
  ? payload.headRefOid.trim()
133
135
  : null;
134
- const reviewSummary = summarizeCopilotReviews(payload?.reviews, { headSha });
136
+ // Apply the draft-gate round reset so the completed round count matches what
137
+ // detect-pr-gate-coordination-state computes (#896): when the draft gate has
138
+ // re-passed clean on an earlier head, only reviews after that re-pass count.
139
+ const reviewSummary = summarizeCopilotReviews(payload?.reviews, { headSha, draftGateResetAtMs });
135
140
  return {
136
141
  prData: payload,
137
142
  headSha,
@@ -166,6 +171,51 @@ async function fetchCopilotReviewIds({ repo, pr }, { env = process.env, ghComman
166
171
  }
167
172
  return parseReviewsPayload(result.stdout);
168
173
  }
174
+
175
+ // Re-derive the completed Copilot round count with the draft-gate round reset
176
+ // applied, mirroring detect-pr-gate-coordination-state so both scripts agree on the
177
+ // completed count and therefore on round-cap-reached (#896). A clean draft_gate
178
+ // re-pass on an earlier head resets the count, so post-reset reviews must not be
179
+ // counted toward the cap.
180
+ //
181
+ // Queried lazily — only when the raw (un-reset) count has already hit the cap — so
182
+ // the common (under-cap) request path keeps its existing gh-call contract and adds
183
+ // no API round-trip. Uses a single issue-comments fetch (the same source the gate
184
+ // detector uses for the latest clean draft_gate marker), not the full checkpoint-
185
+ // evidence pipeline, to keep the added surface minimal. Best-effort: a fetch failure
186
+ // falls back to the raw count, so the cap is never silently disabled.
187
+ async function resolveDraftGateAdjustedRounds(options, { env = process.env, ghCommand = "gh" } = {}, before) {
188
+ try {
189
+ const currentHeadSha = typeof before?.prData?.headRefOid === "string" && before.prData.headRefOid.trim().length > 0
190
+ ? before.prData.headRefOid.trim()
191
+ : null;
192
+ const result = await runChild(
193
+ ghCommand,
194
+ ["api", "--paginate", "--slurp", `repos/${options.repo}/issues/${options.pr}/comments?per_page=100`],
195
+ env,
196
+ );
197
+ if (result.code !== 0) {
198
+ return before.completedCopilotReviewRounds ?? 0;
199
+ }
200
+ let comments;
201
+ try {
202
+ const payload = JSON.parse(result.stdout);
203
+ comments = Array.isArray(payload) ? payload.flat() : [];
204
+ } catch {
205
+ return before.completedCopilotReviewRounds ?? 0;
206
+ }
207
+ const gateSummary = summarizeGateReviewComments(comments);
208
+ const draftGateResetAtMs = resolveDraftGateRoundResetMs({ draftGate: gateSummary?.draft_gate, currentHeadSha });
209
+ if (draftGateResetAtMs == null) {
210
+ return before.completedCopilotReviewRounds ?? 0;
211
+ }
212
+ const adjusted = parseReviewsPayload(JSON.stringify(before.prData ?? {}), { draftGateResetAtMs });
213
+ return adjusted.completedCopilotReviewRounds ?? 0;
214
+ } catch {
215
+ return before.completedCopilotReviewRounds ?? 0;
216
+ }
217
+ }
218
+
169
219
  async function fetchCopilotReviewState(options, runtime) {
170
220
  const requestedReviewers = await fetchRequestedReviewers(options, runtime);
171
221
  const reviews = await fetchCopilotReviewIds(options, runtime);
@@ -212,7 +262,7 @@ async function detectSameHeadCleanConvergence(options, runtime, priorReviewState
212
262
  const interpretation = interpretLoopState(snapshot);
213
263
  return interpretation.sameHeadCleanConverged;
214
264
  } catch (error) {
215
- if (runtime?.env?.PI_DEV_LOOPS_DEBUG === "1") {
265
+ if (runtime?.env?.DEVLOOPS_DEBUG === "1") {
216
266
  const detail = error instanceof Error ? error.message : String(error);
217
267
  process.stderr.write(`[request-copilot-review] same-head clean-convergence detection unavailable: ${detail}\n`);
218
268
  }
@@ -252,7 +302,7 @@ async function detectRoundCapAutoRerequestEligibility(options, runtime, priorRev
252
302
  interpretation,
253
303
  };
254
304
  } catch (error) {
255
- if (runtime?.env?.PI_DEV_LOOPS_DEBUG === "1") {
305
+ if (runtime?.env?.DEVLOOPS_DEBUG === "1") {
256
306
  const detail = error instanceof Error ? error.message : String(error);
257
307
  process.stderr.write(`[request-copilot-review] round-cap auto-rerequest detection unavailable: ${detail}\n`);
258
308
  }
@@ -434,7 +484,18 @@ export async function performCopilotReviewRequest(options, { env = process.env,
434
484
  }
435
485
  } catch {
436
486
  }
437
- if ((before.completedCopilotReviewRounds ?? 0) >= maxRounds
487
+ // Reconcile the completed-round count with detect-pr-gate-coordination-state (#896):
488
+ // when the raw count has reached the cap, re-derive it with the draft-gate round
489
+ // reset applied. A clean draft_gate re-pass on an earlier head resets the count, so
490
+ // a post-reset PR that detect reports as under-cap must NOT be refused here as
491
+ // cap-reached. Only query checkpoint evidence on this (at/over-cap) path.
492
+ let completedRounds = before.completedCopilotReviewRounds ?? 0;
493
+ if (completedRounds >= maxRounds
494
+ && !before.requested
495
+ && !before.hasPendingReviewOnCurrentHead) {
496
+ completedRounds = await resolveDraftGateAdjustedRounds(options, { env, ghCommand }, before);
497
+ }
498
+ if (completedRounds >= maxRounds
438
499
  && !before.requested
439
500
  && !before.hasPendingReviewOnCurrentHead) {
440
501
  if (!options.forceRerequestReview) {
@@ -451,9 +512,9 @@ export async function performCopilotReviewRequest(options, { env = process.env,
451
512
  repo: options.repo,
452
513
  pr: options.pr,
453
514
  reviewer: "Copilot",
454
- completedRounds: before.completedCopilotReviewRounds,
515
+ completedRounds,
455
516
  maxRounds,
456
- detail: `Round cap of ${maxRounds} reached with ${before.completedCopilotReviewRounds} completed rounds. No further re-requests will be made.`,
517
+ detail: `Round cap of ${maxRounds} reached with ${completedRounds} completed rounds. No further re-requests will be made.`,
457
518
  };
458
519
  }
459
520
  }
@@ -471,8 +532,8 @@ export async function performCopilotReviewRequest(options, { env = process.env,
471
532
  repo: options.repo,
472
533
  pr: options.pr,
473
534
  reviewer: "Copilot",
474
- detail: `Round cap of ${maxRounds} reached with ${before.completedCopilotReviewRounds} completed rounds. --force-rerequest-review was supplied but commit SHA data is unavailable, so change-since-last-review could not be evaluated.`,
475
- completedRounds: before.completedCopilotReviewRounds,
535
+ detail: `Round cap of ${maxRounds} reached with ${completedRounds} completed rounds. --force-rerequest-review was supplied but commit SHA data is unavailable, so change-since-last-review could not be evaluated.`,
536
+ completedRounds,
476
537
  maxRounds,
477
538
  };
478
539
  }
@@ -484,7 +545,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
484
545
  pr: options.pr,
485
546
  reviewer: "Copilot",
486
547
  detail: "No changes since last Copilot review. --force-rerequest-review requires new commits on the PR head.",
487
- completedRounds: before.completedCopilotReviewRounds,
548
+ completedRounds,
488
549
  maxRounds,
489
550
  };
490
551
  }