dev-loops 0.7.2 → 0.8.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 (71) 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 +2 -2
  4. package/.claude/agents/review.md +9 -12
  5. package/.claude/commands/loop-continue.md +2 -1
  6. package/.claude/commands/loop-enqueue.md +8 -1
  7. package/.claude/commands/loop-info.md +1 -1
  8. package/.claude/hooks/_bash-command-classify.mjs +7 -6
  9. package/.claude/hooks/_hook-decisions.mjs +5 -4
  10. package/.claude/skills/copilot-pr-followup/SKILL.md +5 -5
  11. package/.claude/skills/dev-loop/SKILL.md +7 -7
  12. package/.claude/skills/docs/acceptance-criteria-verification.md +12 -4
  13. package/.claude/skills/docs/anti-patterns.md +3 -3
  14. package/.claude/skills/docs/artifact-authority-contract.md +6 -4
  15. package/.claude/skills/docs/confirmation-rules.md +1 -1
  16. package/.claude/skills/docs/copilot-loop-operations.md +4 -2
  17. package/.claude/skills/docs/issue-intake-procedure.md +12 -3
  18. package/.claude/skills/docs/merge-preconditions.md +5 -3
  19. package/.claude/skills/docs/pr-lifecycle-contract.md +1 -1
  20. package/.claude/skills/docs/public-dev-loop-contract.md +2 -1
  21. package/.claude/skills/docs/retrospective-checkpoint-contract.md +9 -6
  22. package/.claude/skills/docs/validation-policy.md +1 -1
  23. package/.claude/skills/local-implementation/SKILL.md +3 -3
  24. package/.claude/skills/loop-grill/SKILL.md +34 -14
  25. package/AGENTS.md +1 -1
  26. package/CHANGELOG.md +30 -0
  27. package/README.md +95 -189
  28. package/agents/refiner.agent.md +2 -2
  29. package/agents/review.agent.md +9 -12
  30. package/extension/README.md +5 -4
  31. package/package.json +5 -4
  32. package/scripts/docs/validate-state-machine-conformance.mjs +78 -1
  33. package/scripts/github/_gate-names.mjs +5 -0
  34. package/scripts/github/capture-review-threads.mjs +2 -2
  35. package/scripts/github/detect-checkpoint-evidence.mjs +7 -7
  36. package/scripts/github/edit-issue.mjs +259 -0
  37. package/scripts/github/probe-ci-status.mjs +18 -0
  38. package/scripts/github/probe-copilot-review.mjs +24 -3
  39. package/scripts/github/reconcile-draft-gate.mjs +13 -13
  40. package/scripts/github/request-copilot-review.mjs +17 -16
  41. package/scripts/github/upsert-checkpoint-verdict.mjs +25 -23
  42. package/scripts/github/verify-briefing-prefixes.mjs +224 -33
  43. package/scripts/github/write-gate-context.mjs +8 -4
  44. package/scripts/loop/_post-convergence-change.mjs +2 -2
  45. package/scripts/loop/_pr-runner-coordination.mjs +112 -13
  46. package/scripts/loop/check-retro-tooling.mjs +14 -9
  47. package/scripts/loop/copilot-pr-handoff.mjs +18 -14
  48. package/scripts/loop/detect-copilot-loop-state.mjs +11 -11
  49. package/scripts/loop/detect-internal-only-pr.mjs +6 -6
  50. package/scripts/loop/detect-pr-gate-coordination-state.mjs +117 -15
  51. package/scripts/loop/detect-refinement-grill-state.mjs +136 -0
  52. package/scripts/loop/run-watch-cycle.mjs +42 -7
  53. package/scripts/loop/sanctioned-commands.mjs +1 -0
  54. package/scripts/pages/build-state-atlas.mjs +15 -0
  55. package/scripts/projects/add-queue-item.mjs +87 -4
  56. package/skills/copilot-pr-followup/SKILL.md +5 -5
  57. package/skills/dev-loop/SKILL.md +2 -2
  58. package/skills/docs/acceptance-criteria-verification.md +12 -4
  59. package/skills/docs/anti-patterns.md +3 -3
  60. package/skills/docs/artifact-authority-contract.md +6 -4
  61. package/skills/docs/confirmation-rules.md +1 -1
  62. package/skills/docs/copilot-loop-operations.md +4 -2
  63. package/skills/docs/issue-intake-procedure.md +12 -3
  64. package/skills/docs/merge-preconditions.md +5 -3
  65. package/skills/docs/pr-lifecycle-contract.md +1 -1
  66. package/skills/docs/public-dev-loop-contract.md +2 -1
  67. package/skills/docs/required-rules.json +17 -1
  68. package/skills/docs/retrospective-checkpoint-contract.md +9 -6
  69. package/skills/docs/validation-policy.md +1 -1
  70. package/skills/local-implementation/SKILL.md +3 -3
  71. package/skills/loop-grill/SKILL.md +38 -17
@@ -9,6 +9,8 @@ import {
9
9
  DEFAULT_POLL_INTERVAL_MS,
10
10
  COPILOT_REVIEW_WAIT_TIMEOUT_MS,
11
11
  } from "@dev-loops/core/loop/policy-constants";
12
+ import { ensureAsyncRunnerOwnership } from "../loop/_pr-runner-coordination.mjs";
13
+ import { resolveRepoRoot } from "../loop/_repo-root-resolver.mjs";
12
14
 
13
15
  /** Maximum interval between heartbeat outputs during watch delays.
14
16
  * Must be shorter than pi-subagents default needsAttentionAfterMs (60s). */
@@ -275,14 +277,18 @@ export async function watchCopilotReview(
275
277
  {
276
278
  env = process.env,
277
279
  ghCommand = "gh",
280
+ delayImpl = delay,
281
+ now = Date.now,
282
+ ensureOwnershipImpl = ensureAsyncRunnerOwnership,
278
283
  } = {},
279
284
  ) {
285
+ const leaseCwd = resolveRepoRoot(process.cwd());
280
286
  const baseline = parseCopilotActivity(await fetchGithubCopilotActivityPayload(
281
287
  { repo: options.repo, pr: options.pr },
282
288
  { env, ghCommand },
283
289
  ));
284
290
  const attemptBudget = buildAttemptBudget(options.timeoutMs, options.pollIntervalMs);
285
- const watchStartedAtMs = Date.now();
291
+ const watchStartedAtMs = now();
286
292
  for (let attempt = 1; attempt <= attemptBudget; attempt += 1) {
287
293
  if (!(options.timeoutMs === 0 && attempt === 1)) {
288
294
  const pollDelayMs = buildPollDelayMs(
@@ -290,15 +296,16 @@ export async function watchCopilotReview(
290
296
  options.timeoutMs,
291
297
  options.pollIntervalMs,
292
298
  attempt,
299
+ now(),
293
300
  );
294
301
  if (pollDelayMs > 0) {
295
302
  let remainingMs = pollDelayMs;
296
303
  while (remainingMs > 0) {
297
304
  const chunkMs = Math.min(WATCH_HEARTBEAT_MS, remainingMs);
298
- await delay(chunkMs);
305
+ await delayImpl(chunkMs);
299
306
  remainingMs -= chunkMs;
300
307
  if (remainingMs > 0) {
301
- const nowMs = Date.now();
308
+ const nowMs = now();
302
309
  process.stderr.write(
303
310
  JSON.stringify({
304
311
  ok: true,
@@ -309,6 +316,20 @@ export async function watchCopilotReview(
309
316
  maxPolls: attemptBudget,
310
317
  }) + "\n",
311
318
  );
319
+ // The blocking review wait can span the full watch budget, which
320
+ // equals the runner-coordination stale window; refresh the lease
321
+ // alongside each heartbeat so the claim stays fresh for every caller
322
+ // of this engine. No-ops without DEVLOOPS_RUN_ID; best-effort.
323
+ try {
324
+ await ensureOwnershipImpl({
325
+ repo: options.repo,
326
+ pr: options.pr,
327
+ env,
328
+ cwd: leaseCwd,
329
+ claimIfMissing: true,
330
+ requireExisting: false,
331
+ });
332
+ } catch { /* best-effort: never affect the watch */ }
312
333
  }
313
334
  }
314
335
  }
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from "node:util";
3
3
  import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
4
- import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
4
+ import { parsePrNumber, requireTokenValue, runChild as defaultRunChild } from "../_cli-primitives.mjs";
5
5
  import { loadDevLoopConfig, resolveGateConfig } from "@dev-loops/core/config";
6
6
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
7
7
  import { detectCheckpointEvidence } from "./detect-checkpoint-evidence.mjs";
@@ -105,7 +105,7 @@ const PR_ID_QUERY = [
105
105
  " }",
106
106
  "}",
107
107
  ].join("\n");
108
- async function resolvePrNodeId({ repo, pr }, { env, ghCommand }) {
108
+ async function resolvePrNodeId({ repo, pr }, { env, ghCommand, runChild = defaultRunChild }) {
109
109
  const [owner, name] = repo.split("/");
110
110
  const result = await runChild(ghCommand, [
111
111
  "api", "graphql",
@@ -128,8 +128,8 @@ async function resolvePrNodeId({ repo, pr }, { env, ghCommand }) {
128
128
  }
129
129
  return { id: prData.id, isDraft: prData.isDraft };
130
130
  }
131
- export async function convertPrToDraft({ repo, pr }, { env, ghCommand }) {
132
- const resolvedPr = await resolvePrNodeId({ repo, pr }, { env, ghCommand });
131
+ export async function convertPrToDraft({ repo, pr }, { env, ghCommand, runChild = defaultRunChild }) {
132
+ const resolvedPr = await resolvePrNodeId({ repo, pr }, { env, ghCommand, runChild });
133
133
  if (resolvedPr.isDraft === true) {
134
134
  return {
135
135
  ...resolvedPr,
@@ -158,7 +158,7 @@ export async function convertPrToDraft({ repo, pr }, { env, ghCommand }) {
158
158
  alreadyDraft: false,
159
159
  };
160
160
  }
161
- export async function markPrReady({ repo, pr }, { env, ghCommand }) {
161
+ export async function markPrReady({ repo, pr }, { env, ghCommand, runChild = defaultRunChild }) {
162
162
  const result = await runChild(ghCommand, [
163
163
  "pr", "ready", String(pr),
164
164
  "--repo", repo,
@@ -201,7 +201,7 @@ function summarizeBlockingChecks(blockingChecks) {
201
201
  .map((check) => `${check.name || "unnamed-check"}=${check.bucket}`)
202
202
  .join(", ");
203
203
  }
204
- async function checkCiStatus({ repo, pr, headSha }, { env, ghCommand }) {
204
+ async function checkCiStatus({ repo, pr, headSha }, { env, ghCommand, runChild = defaultRunChild }) {
205
205
  const result = await runChild(ghCommand, [
206
206
  "pr", "checks", String(pr),
207
207
  "--repo", repo,
@@ -244,12 +244,12 @@ async function checkCiStatus({ repo, pr, headSha }, { env, ghCommand }) {
244
244
  : `Blocking CI/check state on head ${headSha.slice(0, 7)}: ${summarizeBlockingChecks(blockingChecks)}.`,
245
245
  };
246
246
  }
247
- export async function reconcileDraftGate(options, { env = process.env, ghCommand = "gh", repoRoot = process.cwd() } = {}) {
247
+ export async function reconcileDraftGate(options, { env = process.env, ghCommand = "gh", repoRoot = process.cwd(), runChild = defaultRunChild } = {}) {
248
248
  const { config } = await loadDevLoopConfig({ repoRoot });
249
249
  const draftGateConfig = resolveGateConfig(config, "draft");
250
250
  const initialEvidence = await detectCheckpointEvidence(
251
251
  { repo: options.repo, pr: options.pr },
252
- { env, ghCommand }
252
+ { env, ghCommand, runChild }
253
253
  );
254
254
  const headSha = initialEvidence.currentHeadSha;
255
255
  if (!headSha) {
@@ -271,7 +271,7 @@ export async function reconcileDraftGate(options, { env = process.env, ghCommand
271
271
  if (draftGateConfig.requireCi) {
272
272
  const ciStatus = await checkCiStatus(
273
273
  { repo: options.repo, pr: options.pr, headSha },
274
- { env, ghCommand }
274
+ { env, ghCommand, runChild }
275
275
  );
276
276
  if (ciStatus.status !== "success") {
277
277
  throw new Error(
@@ -280,7 +280,7 @@ export async function reconcileDraftGate(options, { env = process.env, ghCommand
280
280
  );
281
281
  }
282
282
  }
283
- const draftConversion = await convertPrToDraft({ repo: options.repo, pr: options.pr }, { env, ghCommand });
283
+ const draftConversion = await convertPrToDraft({ repo: options.repo, pr: options.pr }, { env, ghCommand, runChild });
284
284
  let gateResult;
285
285
  try {
286
286
  gateResult = await upsertCheckpointVerdict({
@@ -294,17 +294,17 @@ export async function reconcileDraftGate(options, { env = process.env, ghCommand
294
294
  ? "Reconciled non-draft PR — draft gate auto-reconciled (CI green)."
295
295
  : "Reconciled non-draft PR — draft gate auto-reconciled (CI optional by config).",
296
296
  nextAction: "Mark ready for review (auto-reconciled).",
297
- }, { env, ghCommand, repoRoot });
297
+ }, { env, ghCommand, repoRoot, runChild });
298
298
  } catch (error) {
299
299
  if (draftConversion.alreadyDraft !== true) {
300
300
  try {
301
- await markPrReady({ repo: options.repo, pr: options.pr }, { env, ghCommand });
301
+ await markPrReady({ repo: options.repo, pr: options.pr }, { env, ghCommand, runChild });
302
302
  } catch {
303
303
  }
304
304
  }
305
305
  throw error;
306
306
  }
307
- await markPrReady({ repo: options.repo, pr: options.pr }, { env, ghCommand });
307
+ await markPrReady({ repo: options.repo, pr: options.pr }, { env, ghCommand, runChild });
308
308
  return {
309
309
  ok: true,
310
310
  action: "reconciled",
@@ -11,7 +11,7 @@ import {
11
11
  summarizeCopilotReviews,
12
12
  summarizeGateReviewComments,
13
13
  } from "../_core-helpers.mjs";
14
- import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
14
+ import { parsePrNumber, requireTokenValue, runChild as defaultRunChild } from "../_cli-primitives.mjs";
15
15
  import { fetchGithubReviewThreadsPayload } from "./capture-review-threads.mjs";
16
16
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
17
17
  import { buildSnapshotFromPrFacts, interpretLoopState } from "@dev-loops/core/loop/copilot-loop-state";
@@ -176,7 +176,7 @@ function parseReviewsPayload(text, { draftGateResetAtMs = null } = {}) {
176
176
  completedCopilotReviewRounds: reviewSummary.completedCopilotReviewRounds,
177
177
  };
178
178
  }
179
- async function fetchRequestedReviewers({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
179
+ async function fetchRequestedReviewers({ repo, pr }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
180
180
  const result = await runChild(
181
181
  ghCommand,
182
182
  ["api", `repos/${repo}/pulls/${pr}/requested_reviewers`],
@@ -188,7 +188,7 @@ async function fetchRequestedReviewers({ repo, pr }, { env = process.env, ghComm
188
188
  }
189
189
  return parseRequestedReviewersPayload(result.stdout);
190
190
  }
191
- async function fetchCopilotReviewIds({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
191
+ async function fetchCopilotReviewIds({ repo, pr }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
192
192
  const result = await runChild(
193
193
  ghCommand,
194
194
  ["pr", "view", String(pr), "--repo", repo, "--json", "headRefOid,isDraft,state,number,reviews,statusCheckRollup"],
@@ -213,7 +213,7 @@ async function fetchCopilotReviewIds({ repo, pr }, { env = process.env, ghComman
213
213
  // detector uses for the latest clean draft_gate marker), not the full checkpoint-
214
214
  // evidence pipeline, to keep the added surface minimal. Best-effort: a fetch failure
215
215
  // falls back to the raw count, so the cap is never silently disabled.
216
- async function resolveDraftGateAdjustedRounds(options, { env = process.env, ghCommand = "gh" } = {}, before) {
216
+ async function resolveDraftGateAdjustedRounds(options, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}, before) {
217
217
  try {
218
218
  const currentHeadSha = typeof before?.prData?.headRefOid === "string" && before.prData.headRefOid.trim().length > 0
219
219
  ? before.prData.headRefOid.trim()
@@ -386,7 +386,7 @@ function classifyRequestFailure(detail) {
386
386
  }
387
387
  return undefined;
388
388
  }
389
- async function requestCopilotReview({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
389
+ async function requestCopilotReview({ repo, pr }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
390
390
  const result = await runChild(
391
391
  ghCommand,
392
392
  ["pr", "edit", String(pr), "--repo", repo, "--add-reviewer", "@copilot"],
@@ -398,7 +398,7 @@ async function requestCopilotReview({ repo, pr }, { env = process.env, ghCommand
398
398
  if (classified === "unavailable") {
399
399
  let existing;
400
400
  try {
401
- existing = await fetchCopilotReviewIds({ repo, pr }, { env, ghCommand });
401
+ existing = await fetchCopilotReviewIds({ repo, pr }, { env, ghCommand, runChild });
402
402
  } catch {
403
403
  // Best-effort: if gh pr view fails transiently (rate limit, network, auth),
404
404
  // return unavailable rather than throwing — the 422 failure is already stable.
@@ -439,7 +439,7 @@ async function requestCopilotReview({ repo, pr }, { env = process.env, ghCommand
439
439
  reviewer: "Copilot",
440
440
  };
441
441
  }
442
- export async function checkForCopilotComments({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
442
+ export async function checkForCopilotComments({ repo, pr }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
443
443
  const result = await runChild(
444
444
  ghCommand,
445
445
  ["api", `repos/${repo}/issues/${pr}/comments`, "--paginate", "--jq", ".[]"],
@@ -477,8 +477,9 @@ export async function checkForCopilotComments({ repo, pr }, { env = process.env,
477
477
  violationCommentIds,
478
478
  };
479
479
  }
480
- export async function performCopilotReviewRequest(options, { env = process.env, ghCommand = "gh" } = {}) {
481
- const before = await fetchCopilotReviewState(options, { env, ghCommand });
480
+ export async function performCopilotReviewRequest(options, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
481
+ const runtime = { env, ghCommand, runChild };
482
+ const before = await fetchCopilotReviewState(options, runtime);
482
483
  if (before.prData?.isDraft) {
483
484
  return {
484
485
  ok: true,
@@ -490,7 +491,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
490
491
  };
491
492
  }
492
493
  if (!env.GH_SEQUENCE_PATH) {
493
- const copilotCommentCheck = await checkForCopilotComments(options, { env, ghCommand });
494
+ const copilotCommentCheck = await checkForCopilotComments(options, runtime);
494
495
  if (copilotCommentCheck.blocked) {
495
496
  return {
496
497
  ok: true,
@@ -555,7 +556,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
555
556
  if (completedRounds >= maxRounds
556
557
  && !before.requested
557
558
  && !before.hasPendingReviewOnCurrentHead) {
558
- completedRounds = await resolveDraftGateAdjustedRounds(options, { env, ghCommand }, before);
559
+ completedRounds = await resolveDraftGateAdjustedRounds(options, runtime, before);
559
560
  }
560
561
  if (completedRounds >= maxRounds
561
562
  && !before.requested
@@ -563,7 +564,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
563
564
  if (!options.forceRerequestReview) {
564
565
  const roundCapAutoRerequest = await detectRoundCapAutoRerequestEligibility(
565
566
  options,
566
- { env, ghCommand },
567
+ runtime,
567
568
  before,
568
569
  refinementConfig,
569
570
  );
@@ -615,7 +616,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
615
616
  }
616
617
  const sameHeadCleanConverged = await detectSameHeadCleanConvergence(
617
618
  options,
618
- { env, ghCommand },
619
+ runtime,
619
620
  before,
620
621
  );
621
622
  if (sameHeadCleanConverged) {
@@ -638,9 +639,9 @@ export async function performCopilotReviewRequest(options, { env = process.env,
638
639
  reviewer: "Copilot",
639
640
  });
640
641
  }
641
- const requestResult = await requestCopilotReview(options, { env, ghCommand });
642
+ const requestResult = await requestCopilotReview(options, runtime);
642
643
  if (requestResult.status === "unavailable") {
643
- const after = await fetchCopilotReviewState(options, { env, ghCommand });
644
+ const after = await fetchCopilotReviewState(options, runtime);
644
645
  if (after.requested || after.hasPendingReviewOnCurrentHead || after.hasSubmittedReviewOnCurrentHead) {
645
646
  return withConfigWarning({
646
647
  ok: true,
@@ -657,7 +658,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
657
658
  if (requestResult.status === "already-requested") {
658
659
  return withConfigWarning(requestResult);
659
660
  }
660
- const after = await fetchCopilotReviewState(options, { env, ghCommand });
661
+ const after = await fetchCopilotReviewState(options, runtime);
661
662
  const reviewCountIncreased = after.copilotReviewIds.length > before.copilotReviewIds.length;
662
663
  const reviewNowObservablyInProgress = after.requested || after.hasPendingReviewOnCurrentHead || reviewCountIncreased;
663
664
  if (!reviewNowObservablyInProgress) {
@@ -5,7 +5,7 @@ import { loadDevLoopConfig, resolveEffectiveCopilotRoundCap, resolveGateAngleCon
5
5
  import { checkFanoutAngleCoverage } from "@dev-loops/core/loop/gate-fanin";
6
6
  import { parseArgs } from "node:util";
7
7
  import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
8
- import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
8
+ import { parsePrNumber, requireTokenValue, runChild as defaultRunChild } from "../_cli-primitives.mjs";
9
9
  import { truncateText } from "@dev-loops/core/bash-exit-one";
10
10
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
11
11
  import { loadPrGateCoordinationContext } from "../loop/detect-pr-gate-coordination-state.mjs";
@@ -839,7 +839,7 @@ function detectStaleGateCommentWarning({ strict, headSha, gate }) {
839
839
  }
840
840
  return `A gate comment for \`${gate}\` already exists on a different head SHA \`${strict.headSha}\` (comment ${strict.commentId}). The old comment is stale for the current head.`;
841
841
  }
842
- async function runGhJson(args, { env, ghCommand }) {
842
+ async function runGhJson(args, { env, ghCommand, runChild = defaultRunChild }) {
843
843
  const result = await runChild(ghCommand, args, env);
844
844
  if (result.code !== 0) {
845
845
  const detail = result.stderr.trim() || `exit code ${result.code}`;
@@ -857,18 +857,18 @@ function parseCommentMutationResponse(payload) {
857
857
  }
858
858
  return { commentId, commentUrl };
859
859
  }
860
- async function createComment({ repo, pr, body }, { env, ghCommand }) {
861
- const payload = await runGhJson(["api", "repos/" + repo + "/issues/" + pr + "/comments", "-f", `body=${body}`], { env, ghCommand });
860
+ async function createComment({ repo, pr, body }, { env, ghCommand, runChild = defaultRunChild }) {
861
+ const payload = await runGhJson(["api", "repos/" + repo + "/issues/" + pr + "/comments", "-f", `body=${body}`], { env, ghCommand, runChild });
862
862
  return parseCommentMutationResponse(payload);
863
863
  }
864
- async function updateComment({ repo, commentId, body }, { env, ghCommand }) {
865
- const payload = await runGhJson(["api", "-X", "PATCH", `repos/${repo}/issues/comments/${commentId}`, "-f", `body=${body}`], { env, ghCommand });
864
+ async function updateComment({ repo, commentId, body }, { env, ghCommand, runChild = defaultRunChild }) {
865
+ const payload = await runGhJson(["api", "-X", "PATCH", `repos/${repo}/issues/comments/${commentId}`, "-f", `body=${body}`], { env, ghCommand, runChild });
866
866
  return parseCommentMutationResponse(payload);
867
867
  }
868
868
 
869
- async function verifyComment({ repo, commentId }, { env, ghCommand }) {
869
+ async function verifyComment({ repo, commentId }, { env, ghCommand, runChild = defaultRunChild }) {
870
870
  try {
871
- const payload = await runGhJson(["api", `repos/${repo}/issues/comments/${commentId}`], { env, ghCommand });
871
+ const payload = await runGhJson(["api", `repos/${repo}/issues/comments/${commentId}`], { env, ghCommand, runChild });
872
872
  return payload?.id != null;
873
873
  } catch {
874
874
  return false;
@@ -895,13 +895,13 @@ async function verifyComment({ repo, commentId }, { env, ghCommand }) {
895
895
  // markPrReady mutations are individually idempotent, so concurrent cooperating
896
896
  // runners cause at most a transient draft flicker (not a stuck draft) — only a hard
897
897
  // crash mid-transition can leave the PR drafted until a subsequent run.
898
- async function postDraftGateViaDraftTransition(options, { env, ghCommand, repoRoot }) {
898
+ async function postDraftGateViaDraftTransition(options, { env, ghCommand, repoRoot, runChild = defaultRunChild }) {
899
899
  const { convertPrToDraft, markPrReady } = await import("./reconcile-draft-gate.mjs");
900
900
  process.stderr.write(
901
901
  `[draft_gate] ${options.repo}#${options.pr} is ready but needs clean draft_gate evidence; ` +
902
902
  `temporarily converting to draft to post the verdict, then restoring ready.\n`,
903
903
  );
904
- const conversion = await convertPrToDraft({ repo: options.repo, pr: options.pr }, { env, ghCommand });
904
+ const conversion = await convertPrToDraft({ repo: options.repo, pr: options.pr }, { env, ghCommand, runChild });
905
905
  let result;
906
906
  try {
907
907
  // The PR is now a draft, so RUN_DRAFT_GATE is the legal action. Re-enter with
@@ -912,12 +912,12 @@ async function postDraftGateViaDraftTransition(options, { env, ghCommand, repoRo
912
912
  // with a clear error instead of recursing indefinitely (exit 13). (#1020)
913
913
  result = await upsertCheckpointVerdict(
914
914
  { ...options, _draftTransitionInProgress: true },
915
- { env, ghCommand, repoRoot },
915
+ { env, ghCommand, repoRoot, runChild },
916
916
  );
917
917
  } catch (error) {
918
918
  if (conversion.alreadyDraft !== true) {
919
919
  try {
920
- await markPrReady({ repo: options.repo, pr: options.pr }, { env, ghCommand });
920
+ await markPrReady({ repo: options.repo, pr: options.pr }, { env, ghCommand, runChild });
921
921
  process.stderr.write(`[draft_gate] restored ${options.repo}#${options.pr} to ready after a failed verdict post.\n`);
922
922
  } catch (restoreError) {
923
923
  // Best-effort restore; surface the original error but log the restore failure
@@ -932,7 +932,7 @@ async function postDraftGateViaDraftTransition(options, { env, ghCommand, repoRo
932
932
  }
933
933
  if (conversion.alreadyDraft !== true) {
934
934
  try {
935
- await markPrReady({ repo: options.repo, pr: options.pr }, { env, ghCommand });
935
+ await markPrReady({ repo: options.repo, pr: options.pr }, { env, ghCommand, runChild });
936
936
  } catch (restoreError) {
937
937
  // The verdict WAS posted successfully; only the ready-restore failed. Make that
938
938
  // explicit so the caller does not re-post the gate (the comment already exists)
@@ -949,7 +949,8 @@ async function postDraftGateViaDraftTransition(options, { env, ghCommand, repoRo
949
949
  return { ...result, draftTransition: true };
950
950
  }
951
951
 
952
- export async function upsertCheckpointVerdict(options, { env = process.env, ghCommand = "gh", repoRoot = process.cwd() } = {}) {
952
+ export async function upsertCheckpointVerdict(options, { env = process.env, ghCommand = "gh", repoRoot = process.cwd(), runChild = defaultRunChild } = {}) {
953
+ const gh = { env, ghCommand, repoRoot, runChild };
953
954
  // Root cause 1: allow resurrected sessions to claim ownership when the previous
954
955
  // run's coordination record is stale. Without this, a new run ID is rejected even
955
956
  // though the old run is dead, forcing manual file deletion.
@@ -968,7 +969,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
968
969
  // Thread the light-dispatch signal (#1210) so the context interpreter and the
969
970
  // maxCopilotRounds resolution below both use the composed lightweight cap —
970
971
  // the two must never disagree at the cap boundary (#1126).
971
- const coordinationContext = await loadPrGateCoordinationContext({ repo: options.repo, pr: options.pr, lightweight: options.lightweight === true }, { env, ghCommand });
972
+ const coordinationContext = await loadPrGateCoordinationContext({ repo: options.repo, pr: options.pr, lightweight: options.lightweight === true }, gh);
972
973
  const evidence = coordinationContext.gateEvidence;
973
974
  const canonicalHeadSha = resolveRequestedHeadSha(options.headSha, evidence.currentHeadSha);
974
975
  const { config } = await loadDevLoopConfig({ repoRoot });
@@ -982,7 +983,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
982
983
  // without requiring an external Copilot review cycle.
983
984
  let reviewMode = null;
984
985
  try {
985
- const internalResult = await detectInternalOnly({ repo: options.repo, pr: options.pr }, { env, ghCommand });
986
+ const internalResult = await detectInternalOnly({ repo: options.repo, pr: options.pr }, gh);
986
987
  if (internalResult?.ok && internalResult.internalOnly) {
987
988
  reviewMode = "internal_only";
988
989
  }
@@ -1010,6 +1011,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
1010
1011
  draftGateRequireCi: draftGateConfig.requireCi,
1011
1012
  draftGate: coordinationContext.gateEvidence.draftGate,
1012
1013
  draftGateMarker: coordinationContext.gateEvidence.draftGateMarker,
1014
+ refinementArtifact: coordinationContext.refinementArtifact,
1013
1015
  preApprovalGate: coordinationContext.gateEvidence.preApprovalGate,
1014
1016
  preApprovalGateMarker: coordinationContext.gateEvidence.preApprovalGateMarker,
1015
1017
  ...(reviewMode ? { reviewMode } : {}),
@@ -1089,7 +1091,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
1089
1091
  && !coordination.draftGateAlreadySatisfied
1090
1092
  && coordination.allowedNextActions.includes(PR_CHECKPOINT_ACTION.RECONCILE_DRAFT_GATE)
1091
1093
  ) {
1092
- return await postDraftGateViaDraftTransition(options, { env, ghCommand, repoRoot });
1094
+ return await postDraftGateViaDraftTransition(options, { env, ghCommand, repoRoot, runChild });
1093
1095
  }
1094
1096
  // Fail closed on a lagged draft-state read: we are re-entering FROM
1095
1097
  // postDraftGateViaDraftTransition (which just converted the PR to draft) yet the
@@ -1296,15 +1298,15 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
1296
1298
  };
1297
1299
  }
1298
1300
  if (existing) {
1299
- const updated = await updateComment({ repo: options.repo, commentId: existing.commentId, body: desiredBody }, { env, ghCommand });
1301
+ const updated = await updateComment({ repo: options.repo, commentId: existing.commentId, body: desiredBody }, gh);
1300
1302
  // Post-update verification: verify the updated comment is visible via direct API fetch by comment ID.
1301
1303
  // A run id is set (production context) — DEVLOOPS_RUN_ID.
1302
1304
  let updateVerificationWarning = null;
1303
1305
  if (envRunId) {
1304
- let verified = await verifyComment({ repo: options.repo, commentId: updated.commentId }, { env, ghCommand });
1306
+ let verified = await verifyComment({ repo: options.repo, commentId: updated.commentId }, gh);
1305
1307
  if (!verified) {
1306
1308
  await new Promise((resolve) => setTimeout(resolve, 2000));
1307
- verified = await verifyComment({ repo: options.repo, commentId: updated.commentId }, { env, ghCommand });
1309
+ verified = await verifyComment({ repo: options.repo, commentId: updated.commentId }, gh);
1308
1310
  }
1309
1311
  updateVerificationWarning = !verified
1310
1312
  ? `Post-update verification failed: comment ${updated.commentId} not retrievable after retry.`
@@ -1327,7 +1329,7 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
1327
1329
  ...(updateVerificationWarning ? { verificationWarning: updateVerificationWarning } : {}),
1328
1330
  };
1329
1331
  }
1330
- const created = await createComment({ repo: options.repo, pr: options.pr, body: desiredBody }, { env, ghCommand });
1332
+ const created = await createComment({ repo: options.repo, pr: options.pr, body: desiredBody }, gh);
1331
1333
  // Post-creation verification: verify the comment is retrievable before returning.
1332
1334
  // GitHub API can have brief eventual-consistency windows where a just-posted
1333
1335
  // comment is not yet returned by paginated list endpoints. A direct fetch
@@ -1337,11 +1339,11 @@ export async function upsertCheckpointVerdict(options, { env = process.env, ghCo
1337
1339
  let verified = true;
1338
1340
  let verificationWarning = null;
1339
1341
  if (envRunId) {
1340
- verified = await verifyComment({ repo: options.repo, commentId: created.commentId }, { env, ghCommand });
1342
+ verified = await verifyComment({ repo: options.repo, commentId: created.commentId }, gh);
1341
1343
  if (!verified) {
1342
1344
  // Brief wait then retry — eventual consistency should resolve within ~2s.
1343
1345
  await new Promise((resolve) => setTimeout(resolve, 2000));
1344
- verified = await verifyComment({ repo: options.repo, commentId: created.commentId }, { env, ghCommand });
1346
+ verified = await verifyComment({ repo: options.repo, commentId: created.commentId }, gh);
1345
1347
  }
1346
1348
  verificationWarning = !verified
1347
1349
  ? `Post-creation verification failed: comment ${created.commentId} not retrievable after retry. The comment was created (API confirmed) but may not appear in list endpoints immediately.`