dev-loops 0.7.1 → 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 (107) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +7 -7
  3. package/.claude/agents/fixer.md +1 -0
  4. package/.claude/agents/refiner.md +4 -4
  5. package/.claude/agents/review.md +10 -13
  6. package/.claude/commands/loop-continue.md +2 -1
  7. package/.claude/commands/loop-enqueue.md +9 -2
  8. package/.claude/commands/loop-info.md +1 -1
  9. package/.claude/commands/loop-start-spike.md +1 -1
  10. package/.claude/hooks/_bash-command-classify.mjs +7 -6
  11. package/.claude/hooks/_hook-decisions.mjs +5 -4
  12. package/.claude/skills/copilot-pr-followup/SKILL.md +42 -28
  13. package/.claude/skills/dev-loop/SKILL.md +8 -8
  14. package/.claude/skills/docs/acceptance-criteria-verification.md +13 -4
  15. package/.claude/skills/docs/anti-patterns.md +6 -5
  16. package/.claude/skills/docs/artifact-authority-contract.md +17 -14
  17. package/.claude/skills/docs/confirmation-rules.md +2 -1
  18. package/.claude/skills/docs/contract-style-guide.md +37 -0
  19. package/.claude/skills/docs/copilot-loop-operations.md +21 -15
  20. package/.claude/skills/docs/cross-harness-regression-contract.md +2 -2
  21. package/.claude/skills/docs/debt-remediation-contract.md +1 -1
  22. package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
  23. package/.claude/skills/docs/issue-intake-procedure.md +37 -22
  24. package/.claude/skills/docs/merge-preconditions.md +17 -13
  25. package/.claude/skills/docs/pr-lifecycle-contract.md +45 -36
  26. package/.claude/skills/docs/public-dev-loop-contract.md +61 -44
  27. package/.claude/skills/docs/retrospective-checkpoint-contract.md +25 -9
  28. package/.claude/skills/docs/spike-mode-contract.md +14 -7
  29. package/.claude/skills/docs/stop-conditions.md +30 -15
  30. package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
  31. package/.claude/skills/docs/validation-policy.md +4 -5
  32. package/.claude/skills/local-implementation/SKILL.md +49 -143
  33. package/.claude/skills/loop-grill/SKILL.md +34 -14
  34. package/AGENTS.md +1 -1
  35. package/CHANGELOG.md +36 -0
  36. package/README.md +95 -189
  37. package/agents/dev-loop.agent.md +8 -8
  38. package/agents/fixer.agent.md +1 -0
  39. package/agents/refiner.agent.md +4 -4
  40. package/agents/review.agent.md +10 -13
  41. package/extension/README.md +5 -4
  42. package/package.json +7 -5
  43. package/scripts/_core-helpers.mjs +2 -0
  44. package/scripts/claude/headless-dev-loop.mjs +1 -1
  45. package/scripts/docs/validate-rule-ownership.mjs +442 -0
  46. package/scripts/docs/validate-state-machine-conformance.mjs +1169 -0
  47. package/scripts/github/_gate-names.mjs +5 -0
  48. package/scripts/github/_review-thread-mutations.mjs +5 -2
  49. package/scripts/github/capture-review-threads.mjs +2 -2
  50. package/scripts/github/create-pr.mjs +119 -10
  51. package/scripts/github/detect-checkpoint-evidence.mjs +109 -24
  52. package/scripts/github/edit-issue.mjs +259 -0
  53. package/scripts/github/list-review-threads.mjs +277 -0
  54. package/scripts/github/post-gate-findings.mjs +6 -3
  55. package/scripts/github/probe-ci-status.mjs +18 -0
  56. package/scripts/github/probe-copilot-review.mjs +24 -3
  57. package/scripts/github/reconcile-draft-gate.mjs +13 -13
  58. package/scripts/github/request-copilot-review.mjs +109 -42
  59. package/scripts/github/upsert-checkpoint-verdict.mjs +98 -27
  60. package/scripts/github/verify-briefing-prefixes.mjs +382 -0
  61. package/scripts/github/verify-fresh-review-context.mjs +78 -3
  62. package/scripts/github/wait-pr-checks.mjs +171 -0
  63. package/scripts/github/write-gate-context.mjs +220 -10
  64. package/scripts/github/write-gate-findings-log.mjs +54 -2
  65. package/scripts/loop/_post-convergence-change.mjs +2 -2
  66. package/scripts/loop/_pr-runner-coordination.mjs +112 -13
  67. package/scripts/loop/check-retro-tooling.mjs +14 -9
  68. package/scripts/loop/copilot-pr-handoff.mjs +47 -16
  69. package/scripts/loop/detect-change-scope.mjs +2 -2
  70. package/scripts/loop/detect-copilot-loop-state.mjs +34 -14
  71. package/scripts/loop/detect-internal-only-pr.mjs +6 -6
  72. package/scripts/loop/detect-pr-gate-coordination-state.mjs +152 -18
  73. package/scripts/loop/detect-refinement-grill-state.mjs +136 -0
  74. package/scripts/loop/resolve-dev-loop-startup.mjs +145 -8
  75. package/scripts/loop/run-watch-cycle.mjs +42 -7
  76. package/scripts/loop/sanctioned-commands.mjs +3 -0
  77. package/scripts/loop/validate-pr-body-spec.mjs +21 -5
  78. package/scripts/pages/build-state-atlas.mjs +65 -48
  79. package/scripts/projects/_resolve-project.mjs +1 -148
  80. package/scripts/projects/add-queue-item.mjs +87 -4
  81. package/scripts/projects/list-queue-items.mjs +3 -377
  82. package/scripts/projects/move-queue-item.mjs +3 -410
  83. package/scripts/projects/reorder-queue-item.mjs +3 -22
  84. package/skills/copilot-pr-followup/SKILL.md +42 -28
  85. package/skills/dev-loop/SKILL.md +3 -3
  86. package/skills/docs/acceptance-criteria-verification.md +13 -4
  87. package/skills/docs/anti-patterns.md +6 -5
  88. package/skills/docs/artifact-authority-contract.md +17 -14
  89. package/skills/docs/confirmation-rules.md +2 -1
  90. package/skills/docs/contract-style-guide.md +37 -0
  91. package/skills/docs/copilot-loop-operations.md +21 -15
  92. package/skills/docs/cross-harness-regression-contract.md +2 -2
  93. package/skills/docs/debt-remediation-contract.md +1 -1
  94. package/skills/docs/epic-tree-refinement-procedure.md +25 -22
  95. package/skills/docs/issue-intake-procedure.md +37 -22
  96. package/skills/docs/merge-preconditions.md +17 -13
  97. package/skills/docs/pr-lifecycle-contract.md +45 -36
  98. package/skills/docs/public-dev-loop-contract.md +61 -44
  99. package/skills/docs/required-rules.json +165 -0
  100. package/skills/docs/retrospective-checkpoint-contract.md +25 -9
  101. package/skills/docs/spike-mode-contract.md +14 -7
  102. package/skills/docs/stop-conditions.md +30 -15
  103. package/skills/docs/tracker-first-loop-state.md +13 -8
  104. package/skills/docs/validation-policy.md +4 -5
  105. package/skills/local-implementation/SKILL.md +49 -143
  106. package/skills/loop-grill/SKILL.md +38 -17
  107. package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
@@ -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",
@@ -2,6 +2,7 @@
2
2
  import { parseArgs } from "node:util";
3
3
  import {
4
4
  buildParseError,
5
+ containsBareCopilotSummon,
5
6
  formatCliError,
6
7
  isCopilotLogin,
7
8
  isDirectCliRun,
@@ -10,11 +11,11 @@ import {
10
11
  summarizeCopilotReviews,
11
12
  summarizeGateReviewComments,
12
13
  } from "../_core-helpers.mjs";
13
- import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
14
+ import { parsePrNumber, requireTokenValue, runChild as defaultRunChild } from "../_cli-primitives.mjs";
14
15
  import { fetchGithubReviewThreadsPayload } from "./capture-review-threads.mjs";
15
16
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
16
17
  import { buildSnapshotFromPrFacts, interpretLoopState } from "@dev-loops/core/loop/copilot-loop-state";
17
- import { loadDevLoopConfig, resolveRefinement } from "@dev-loops/core/config";
18
+ import { loadDevLoopConfig, resolveEffectiveCopilotRoundCap, resolveRefinement } from "@dev-loops/core/config";
18
19
  import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
19
20
  const BLOCKED_BY_COPILOT_COMMENT_STATUS = "blocked_by_copilot_comment";
20
21
  const SUPPRESSED_SAME_HEAD_CLEAN_STATUS = "suppressed_same_head_clean";
@@ -30,13 +31,19 @@ Optional:
30
31
  --force-rerequest-review Bypass the round cap when new commits exist since
31
32
  the last Copilot review. Refused when the PR head
32
33
  has not changed since the last review.
34
+ --lightweight This PR is light-dispatched (#1210): enforce the
35
+ composed round cap min(localImplementation.lightMode.
36
+ maxCopilotRounds ?? 1, refinement.maxCopilotRounds)
37
+ instead of refinement.maxCopilotRounds alone.
33
38
  Debug:
34
39
  DEVLOOPS_DEBUG=1 Emit stderr traces when best-effort same-head clean
35
40
  convergence detection falls back to unsuppressed behavior
36
41
  Output (stdout, JSON):
37
42
  { "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",
38
43
  "repo": "...", "pr": N, "reviewer": "Copilot", "detail"?: "...",
39
- "sameHeadCleanConverged"?: true, "violationCommentIds"?: [N], "completedRounds"?: N, "maxRounds"?: N }
44
+ "sameHeadCleanConverged"?: true, "violationCommentIds"?: [N], "completedRounds"?: N, "maxRounds"?: N,
45
+ "configWarning"?: "..." (present only when --lightweight and dev-loop config failed to load/validate;
46
+ the lightweight default cap of 1 was applied instead of the full-PR default) }
40
47
  Request statuses:
41
48
  requested Copilot review was successfully requested
42
49
  already-requested Copilot review was already observably in progress; no new request needed
@@ -52,9 +59,20 @@ Error output (stderr, JSON):
52
59
  gh/runtime failures:
53
60
  { "ok": false, "error": "..." }
54
61
  ${JQ_OUTPUT_USAGE}
62
+ Status contract: "ok": true means the helper ran without error, NOT that a
63
+ review was placed. Callers MUST branch on "status", never on "ok" truthiness
64
+ alone — every non-"requested" status (including blocked_by_copilot_comment)
65
+ is a caller-must-branch outcome, not a silent success.
66
+ --silent exit code: 0 only when status is "requested" (a new request was just
67
+ placed this run); non-zero for every other status, including
68
+ already-requested/suppressed_same_head_clean/unavailable/blocked_by_copilot_comment/
69
+ round_cap_reached/no_changes_since_last_review/suppressed_draft. Without
70
+ --silent the JSON body always prints regardless of status. --jq combined with
71
+ --silent keeps the shared jq-stream truthiness semantics (exit reflects the
72
+ filtered value) and is exempt from the status-based rule above.
55
73
  Exit codes:
56
- 0 Success (including unavailable)
57
- 1 Argument error or gh failure
74
+ 0 Success (including unavailable); with --silent, only when status is "requested"
75
+ 1 Argument error, gh failure, or (--silent) any non-"requested" status
58
76
  2 Invalid --jq filter`.trim();
59
77
  const parseError = buildParseError(USAGE);
60
78
  export function parseRequestCliArgs(argv) {
@@ -63,6 +81,7 @@ export function parseRequestCliArgs(argv) {
63
81
  options: {
64
82
  help: { type: "boolean", short: "h" },
65
83
  "force-rerequest-review": { type: "boolean" },
84
+ lightweight: { type: "boolean" },
66
85
  repo: { type: "string" },
67
86
  pr: { type: "string" },
68
87
  ...JQ_OUTPUT_PARSE_OPTIONS,
@@ -76,6 +95,7 @@ export function parseRequestCliArgs(argv) {
76
95
  repo: undefined,
77
96
  pr: undefined,
78
97
  forceRerequestReview: false,
98
+ lightweight: false,
79
99
  };
80
100
  for (const token of tokens) {
81
101
  if (token.kind === "positional") {
@@ -92,6 +112,10 @@ export function parseRequestCliArgs(argv) {
92
112
  options.forceRerequestReview = true;
93
113
  continue;
94
114
  }
115
+ if (token.name === "lightweight") {
116
+ options.lightweight = true;
117
+ continue;
118
+ }
95
119
  if (token.name === "repo") {
96
120
  options.repo = requireTokenValue(token, parseError).trim();
97
121
  continue;
@@ -152,7 +176,7 @@ function parseReviewsPayload(text, { draftGateResetAtMs = null } = {}) {
152
176
  completedCopilotReviewRounds: reviewSummary.completedCopilotReviewRounds,
153
177
  };
154
178
  }
155
- async function fetchRequestedReviewers({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
179
+ async function fetchRequestedReviewers({ repo, pr }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
156
180
  const result = await runChild(
157
181
  ghCommand,
158
182
  ["api", `repos/${repo}/pulls/${pr}/requested_reviewers`],
@@ -164,7 +188,7 @@ async function fetchRequestedReviewers({ repo, pr }, { env = process.env, ghComm
164
188
  }
165
189
  return parseRequestedReviewersPayload(result.stdout);
166
190
  }
167
- async function fetchCopilotReviewIds({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
191
+ async function fetchCopilotReviewIds({ repo, pr }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
168
192
  const result = await runChild(
169
193
  ghCommand,
170
194
  ["pr", "view", String(pr), "--repo", repo, "--json", "headRefOid,isDraft,state,number,reviews,statusCheckRollup"],
@@ -189,7 +213,7 @@ async function fetchCopilotReviewIds({ repo, pr }, { env = process.env, ghComman
189
213
  // detector uses for the latest clean draft_gate marker), not the full checkpoint-
190
214
  // evidence pipeline, to keep the added surface minimal. Best-effort: a fetch failure
191
215
  // falls back to the raw count, so the cap is never silently disabled.
192
- async function resolveDraftGateAdjustedRounds(options, { env = process.env, ghCommand = "gh" } = {}, before) {
216
+ async function resolveDraftGateAdjustedRounds(options, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}, before) {
193
217
  try {
194
218
  const currentHeadSha = typeof before?.prData?.headRefOid === "string" && before.prData.headRefOid.trim().length > 0
195
219
  ? before.prData.headRefOid.trim()
@@ -362,7 +386,7 @@ function classifyRequestFailure(detail) {
362
386
  }
363
387
  return undefined;
364
388
  }
365
- async function requestCopilotReview({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
389
+ async function requestCopilotReview({ repo, pr }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
366
390
  const result = await runChild(
367
391
  ghCommand,
368
392
  ["pr", "edit", String(pr), "--repo", repo, "--add-reviewer", "@copilot"],
@@ -374,7 +398,7 @@ async function requestCopilotReview({ repo, pr }, { env = process.env, ghCommand
374
398
  if (classified === "unavailable") {
375
399
  let existing;
376
400
  try {
377
- existing = await fetchCopilotReviewIds({ repo, pr }, { env, ghCommand });
401
+ existing = await fetchCopilotReviewIds({ repo, pr }, { env, ghCommand, runChild });
378
402
  } catch {
379
403
  // Best-effort: if gh pr view fails transiently (rate limit, network, auth),
380
404
  // return unavailable rather than throwing — the 422 failure is already stable.
@@ -415,7 +439,7 @@ async function requestCopilotReview({ repo, pr }, { env = process.env, ghCommand
415
439
  reviewer: "Copilot",
416
440
  };
417
441
  }
418
- 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 } = {}) {
419
443
  const result = await runChild(
420
444
  ghCommand,
421
445
  ["api", `repos/${repo}/issues/${pr}/comments`, "--paginate", "--jq", ".[]"],
@@ -442,7 +466,9 @@ export async function checkForCopilotComments({ repo, pr }, { env = process.env,
442
466
  if (isCopilotLogin(author)) {
443
467
  continue;
444
468
  }
445
- if (/(?:^|\W)(@copilot|\/copilot)(?:$|\W)/i.test(body)) {
469
+ // Exempt bare-text occurrences inside inline code spans/fenced blocks: a
470
+ // gate-evidence comment legitimately quotes the anti-summon rule itself.
471
+ if (containsBareCopilotSummon(body)) {
446
472
  violationCommentIds.push(comment.id);
447
473
  }
448
474
  }
@@ -451,8 +477,9 @@ export async function checkForCopilotComments({ repo, pr }, { env = process.env,
451
477
  violationCommentIds,
452
478
  };
453
479
  }
454
- export async function performCopilotReviewRequest(options, { env = process.env, ghCommand = "gh" } = {}) {
455
- 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);
456
483
  if (before.prData?.isDraft) {
457
484
  return {
458
485
  ok: true,
@@ -464,7 +491,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
464
491
  };
465
492
  }
466
493
  if (!env.GH_SEQUENCE_PATH) {
467
- const copilotCommentCheck = await checkForCopilotComments(options, { env, ghCommand });
494
+ const copilotCommentCheck = await checkForCopilotComments(options, runtime);
468
495
  if (copilotCommentCheck.blocked) {
469
496
  return {
470
497
  ok: true,
@@ -479,16 +506,47 @@ export async function performCopilotReviewRequest(options, { env = process.env,
479
506
  }
480
507
  let refinementConfig = { maxCopilotRounds: 5 };
481
508
  let maxRounds = 5; // Built-in default; overridden by config when loadable
509
+ // Lightweight fallback when config is unreadable/invalid: fail toward the
510
+ // SAFE (smaller) lightweight cap instead of silently inheriting the
511
+ // full-PR default of 5 above, which would let a light-dispatched PR run
512
+ // far more review rounds than intended whenever the config can't be read.
513
+ const LIGHTWEIGHT_DEFAULT_CAP = 1;
514
+ let configWarning = null;
482
515
  try {
483
516
  const { config, errors } = await loadDevLoopConfig();
484
517
  if (!errors || errors.length === 0) {
485
518
  refinementConfig = resolveRefinement(config);
486
- if (Number.isFinite(refinementConfig.maxCopilotRounds) && refinementConfig.maxCopilotRounds > 0) {
487
- maxRounds = refinementConfig.maxCopilotRounds;
519
+ // Light-dispatched PRs (#1210) enforce the COMPOSED cap
520
+ // min(lightMode.maxCopilotRounds ?? 1, refinement.maxCopilotRounds) — so
521
+ // this enforcement backstop cannot permit rounds beyond the lightweight cap.
522
+ const effectiveCap = options.lightweight
523
+ ? resolveEffectiveCopilotRoundCap(config, { lightweight: true })
524
+ : refinementConfig.maxCopilotRounds;
525
+ // >= 0 (not > 0): maxCopilotRounds: 0 is documented as "disable Copilot
526
+ // rounds"; it must be honored as an immediate refusal, not silently
527
+ // ignored in favor of the built-in default of 5.
528
+ if (Number.isFinite(effectiveCap) && effectiveCap >= 0) {
529
+ maxRounds = effectiveCap;
488
530
  }
531
+ if (options.lightweight) {
532
+ refinementConfig = { ...refinementConfig, maxCopilotRounds: effectiveCap };
533
+ }
534
+ } else if (options.lightweight) {
535
+ maxRounds = LIGHTWEIGHT_DEFAULT_CAP;
536
+ refinementConfig = { ...refinementConfig, maxCopilotRounds: LIGHTWEIGHT_DEFAULT_CAP };
537
+ configWarning = `dev-loop config could not be validated; using the lightweight default cap of ${LIGHTWEIGHT_DEFAULT_CAP} instead of the full-PR default. errors=${JSON.stringify(errors)}`;
538
+ }
539
+ } catch (err) {
540
+ if (options.lightweight) {
541
+ maxRounds = LIGHTWEIGHT_DEFAULT_CAP;
542
+ refinementConfig = { ...refinementConfig, maxCopilotRounds: LIGHTWEIGHT_DEFAULT_CAP };
543
+ configWarning = `dev-loop config could not be loaded; using the lightweight default cap of ${LIGHTWEIGHT_DEFAULT_CAP} instead of the full-PR default. error=${err instanceof Error ? err.message : String(err)}`;
489
544
  }
490
- } catch {
491
545
  }
546
+ // Every remaining return in this function is config-dependent (round-cap
547
+ // decisions, the request itself); surface a config-load fallback on all of
548
+ // them rather than just the path a given test happens to exercise.
549
+ const withConfigWarning = (result) => (configWarning ? { ...result, configWarning } : result);
492
550
  // Reconcile the completed-round count with detect-pr-gate-coordination-state (#896):
493
551
  // when the raw count has reached the cap, re-derive it with the draft-gate round
494
552
  // reset applied. A clean draft_gate re-pass on an earlier head resets the count, so
@@ -498,7 +556,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
498
556
  if (completedRounds >= maxRounds
499
557
  && !before.requested
500
558
  && !before.hasPendingReviewOnCurrentHead) {
501
- completedRounds = await resolveDraftGateAdjustedRounds(options, { env, ghCommand }, before);
559
+ completedRounds = await resolveDraftGateAdjustedRounds(options, runtime, before);
502
560
  }
503
561
  if (completedRounds >= maxRounds
504
562
  && !before.requested
@@ -506,12 +564,12 @@ export async function performCopilotReviewRequest(options, { env = process.env,
506
564
  if (!options.forceRerequestReview) {
507
565
  const roundCapAutoRerequest = await detectRoundCapAutoRerequestEligibility(
508
566
  options,
509
- { env, ghCommand },
567
+ runtime,
510
568
  before,
511
569
  refinementConfig,
512
570
  );
513
571
  if (!roundCapAutoRerequest.eligible) {
514
- return {
572
+ return withConfigWarning({
515
573
  ok: true,
516
574
  status: ROUND_CAP_REACHED_STATUS,
517
575
  repo: options.repo,
@@ -520,7 +578,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
520
578
  completedRounds,
521
579
  maxRounds,
522
580
  detail: `Round cap of ${maxRounds} reached with ${completedRounds} completed rounds. No further re-requests will be made.`,
523
- };
581
+ });
524
582
  }
525
583
  }
526
584
  // --force-rerequest-review: only bypass when there are new commits since the last review
@@ -531,7 +589,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
531
589
  const canCompare = currentHeadSha !== null && lastReviewSha !== null;
532
590
  const hasNewCommits = canCompare && currentHeadSha !== lastReviewSha;
533
591
  if (!canCompare) {
534
- return {
592
+ return withConfigWarning({
535
593
  ok: true,
536
594
  status: ROUND_CAP_REACHED_STATUS,
537
595
  repo: options.repo,
@@ -540,10 +598,10 @@ export async function performCopilotReviewRequest(options, { env = process.env,
540
598
  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.`,
541
599
  completedRounds,
542
600
  maxRounds,
543
- };
601
+ });
544
602
  }
545
603
  if (!hasNewCommits) {
546
- return {
604
+ return withConfigWarning({
547
605
  ok: true,
548
606
  status: NO_CHANGES_SINCE_LAST_REVIEW_STATUS,
549
607
  repo: options.repo,
@@ -552,17 +610,17 @@ export async function performCopilotReviewRequest(options, { env = process.env,
552
610
  detail: "No changes since last Copilot review. --force-rerequest-review requires new commits on the PR head.",
553
611
  completedRounds,
554
612
  maxRounds,
555
- };
613
+ });
556
614
  }
557
615
  // Has new commits — bypass the round cap and proceed with the request
558
616
  }
559
617
  const sameHeadCleanConverged = await detectSameHeadCleanConvergence(
560
618
  options,
561
- { env, ghCommand },
619
+ runtime,
562
620
  before,
563
621
  );
564
622
  if (sameHeadCleanConverged) {
565
- return {
623
+ return withConfigWarning({
566
624
  ok: true,
567
625
  status: SUPPRESSED_SAME_HEAD_CLEAN_STATUS,
568
626
  repo: options.repo,
@@ -570,45 +628,45 @@ export async function performCopilotReviewRequest(options, { env = process.env,
570
628
  reviewer: "Copilot",
571
629
  sameHeadCleanConverged: true,
572
630
  detail: "Current head already has a clean submitted Copilot review; same-head clean-convergence suppression is always enforced.",
573
- };
631
+ });
574
632
  }
575
633
  if (before.requested || before.hasPendingReviewOnCurrentHead) {
576
- return {
634
+ return withConfigWarning({
577
635
  ok: true,
578
636
  status: "already-requested",
579
637
  repo: options.repo,
580
638
  pr: options.pr,
581
639
  reviewer: "Copilot",
582
- };
640
+ });
583
641
  }
584
- const requestResult = await requestCopilotReview(options, { env, ghCommand });
642
+ const requestResult = await requestCopilotReview(options, runtime);
585
643
  if (requestResult.status === "unavailable") {
586
- const after = await fetchCopilotReviewState(options, { env, ghCommand });
644
+ const after = await fetchCopilotReviewState(options, runtime);
587
645
  if (after.requested || after.hasPendingReviewOnCurrentHead || after.hasSubmittedReviewOnCurrentHead) {
588
- return {
646
+ return withConfigWarning({
589
647
  ok: true,
590
648
  status: "already-requested",
591
649
  repo: options.repo,
592
650
  pr: options.pr,
593
651
  reviewer: "Copilot",
594
- };
652
+ });
595
653
  }
596
- return {
654
+ return withConfigWarning({
597
655
  ...requestResult,
598
- };
656
+ });
599
657
  }
600
658
  if (requestResult.status === "already-requested") {
601
- return requestResult;
659
+ return withConfigWarning(requestResult);
602
660
  }
603
- const after = await fetchCopilotReviewState(options, { env, ghCommand });
661
+ const after = await fetchCopilotReviewState(options, runtime);
604
662
  const reviewCountIncreased = after.copilotReviewIds.length > before.copilotReviewIds.length;
605
663
  const reviewNowObservablyInProgress = after.requested || after.hasPendingReviewOnCurrentHead || reviewCountIncreased;
606
664
  if (!reviewNowObservablyInProgress) {
607
665
  throw new Error("Copilot review request did not appear in requested reviewers or fresh/in-progress Copilot reviews after gh pr edit");
608
666
  }
609
- return {
667
+ return withConfigWarning({
610
668
  ...requestResult,
611
- };
669
+ });
612
670
  }
613
671
  export async function runCli(
614
672
  argv = process.argv.slice(2),
@@ -625,7 +683,16 @@ export async function runCli(
625
683
  return;
626
684
  }
627
685
  const result = await performCopilotReviewRequest(options, { env, ghCommand });
628
- process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
686
+ // Honest status under --silent: `ok: true` reports "the helper ran without
687
+ // error", not "a review was placed" — a caller checking only exit-code
688
+ // truthiness must NOT read a non-`requested` status (blocked_by_copilot_comment,
689
+ // round_cap_reached, etc.) as a placed request. --silent therefore answers
690
+ // "was a request just placed" specifically: exit 0 only for `requested`,
691
+ // non-zero for every other status. Non-silent output is unaffected — the full
692
+ // JSON body (with `ok: true`) still prints for every documented status; the
693
+ // caller MUST branch on `.status`, not `.ok`.
694
+ const silentOk = options.silent ? result.status === "requested" : undefined;
695
+ process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr, ok: silentOk });
629
696
  }
630
697
  if (isDirectCliRun(import.meta.url)) {
631
698
  runCli().catch((error) => {