dev-loops 0.7.1 → 0.7.2

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 (86) 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 +2 -2
  5. package/.claude/agents/review.md +2 -2
  6. package/.claude/commands/loop-enqueue.md +1 -1
  7. package/.claude/commands/loop-info.md +1 -1
  8. package/.claude/commands/loop-start-spike.md +1 -1
  9. package/.claude/skills/copilot-pr-followup/SKILL.md +37 -23
  10. package/.claude/skills/dev-loop/SKILL.md +6 -6
  11. package/.claude/skills/docs/acceptance-criteria-verification.md +3 -2
  12. package/.claude/skills/docs/anti-patterns.md +3 -2
  13. package/.claude/skills/docs/artifact-authority-contract.md +12 -11
  14. package/.claude/skills/docs/confirmation-rules.md +1 -0
  15. package/.claude/skills/docs/contract-style-guide.md +37 -0
  16. package/.claude/skills/docs/copilot-loop-operations.md +18 -14
  17. package/.claude/skills/docs/cross-harness-regression-contract.md +2 -2
  18. package/.claude/skills/docs/debt-remediation-contract.md +1 -1
  19. package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
  20. package/.claude/skills/docs/issue-intake-procedure.md +25 -19
  21. package/.claude/skills/docs/merge-preconditions.md +13 -11
  22. package/.claude/skills/docs/pr-lifecycle-contract.md +44 -35
  23. package/.claude/skills/docs/public-dev-loop-contract.md +59 -43
  24. package/.claude/skills/docs/retrospective-checkpoint-contract.md +18 -5
  25. package/.claude/skills/docs/spike-mode-contract.md +14 -7
  26. package/.claude/skills/docs/stop-conditions.md +30 -15
  27. package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
  28. package/.claude/skills/docs/validation-policy.md +4 -5
  29. package/.claude/skills/local-implementation/SKILL.md +48 -142
  30. package/CHANGELOG.md +6 -0
  31. package/agents/dev-loop.agent.md +8 -8
  32. package/agents/fixer.agent.md +1 -0
  33. package/agents/refiner.agent.md +2 -2
  34. package/agents/review.agent.md +2 -2
  35. package/package.json +5 -4
  36. package/scripts/_core-helpers.mjs +2 -0
  37. package/scripts/claude/headless-dev-loop.mjs +1 -1
  38. package/scripts/docs/validate-rule-ownership.mjs +442 -0
  39. package/scripts/docs/validate-state-machine-conformance.mjs +1092 -0
  40. package/scripts/github/_review-thread-mutations.mjs +5 -2
  41. package/scripts/github/create-pr.mjs +119 -10
  42. package/scripts/github/detect-checkpoint-evidence.mjs +102 -17
  43. package/scripts/github/list-review-threads.mjs +277 -0
  44. package/scripts/github/post-gate-findings.mjs +6 -3
  45. package/scripts/github/request-copilot-review.mjs +92 -26
  46. package/scripts/github/upsert-checkpoint-verdict.mjs +74 -5
  47. package/scripts/github/verify-briefing-prefixes.mjs +191 -0
  48. package/scripts/github/verify-fresh-review-context.mjs +78 -3
  49. package/scripts/github/wait-pr-checks.mjs +171 -0
  50. package/scripts/github/write-gate-context.mjs +213 -7
  51. package/scripts/github/write-gate-findings-log.mjs +54 -2
  52. package/scripts/loop/copilot-pr-handoff.mjs +29 -2
  53. package/scripts/loop/detect-change-scope.mjs +2 -2
  54. package/scripts/loop/detect-copilot-loop-state.mjs +23 -3
  55. package/scripts/loop/detect-pr-gate-coordination-state.mjs +35 -3
  56. package/scripts/loop/resolve-dev-loop-startup.mjs +145 -8
  57. package/scripts/loop/sanctioned-commands.mjs +2 -0
  58. package/scripts/loop/validate-pr-body-spec.mjs +21 -5
  59. package/scripts/pages/build-state-atlas.mjs +50 -48
  60. package/scripts/projects/_resolve-project.mjs +1 -148
  61. package/scripts/projects/list-queue-items.mjs +3 -377
  62. package/scripts/projects/move-queue-item.mjs +3 -410
  63. package/scripts/projects/reorder-queue-item.mjs +3 -22
  64. package/skills/copilot-pr-followup/SKILL.md +37 -23
  65. package/skills/dev-loop/SKILL.md +1 -1
  66. package/skills/docs/acceptance-criteria-verification.md +3 -2
  67. package/skills/docs/anti-patterns.md +3 -2
  68. package/skills/docs/artifact-authority-contract.md +12 -11
  69. package/skills/docs/confirmation-rules.md +1 -0
  70. package/skills/docs/contract-style-guide.md +37 -0
  71. package/skills/docs/copilot-loop-operations.md +18 -14
  72. package/skills/docs/cross-harness-regression-contract.md +2 -2
  73. package/skills/docs/debt-remediation-contract.md +1 -1
  74. package/skills/docs/epic-tree-refinement-procedure.md +25 -22
  75. package/skills/docs/issue-intake-procedure.md +25 -19
  76. package/skills/docs/merge-preconditions.md +13 -11
  77. package/skills/docs/pr-lifecycle-contract.md +44 -35
  78. package/skills/docs/public-dev-loop-contract.md +59 -43
  79. package/skills/docs/required-rules.json +149 -0
  80. package/skills/docs/retrospective-checkpoint-contract.md +18 -5
  81. package/skills/docs/spike-mode-contract.md +14 -7
  82. package/skills/docs/stop-conditions.md +30 -15
  83. package/skills/docs/tracker-first-loop-state.md +13 -8
  84. package/skills/docs/validation-policy.md +4 -5
  85. package/skills/local-implementation/SKILL.md +48 -142
  86. package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
4
+ import { parsePrNumber, requireTokenValue, runChild as defaultRunChild } from "../_cli-primitives.mjs";
5
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
+ import { authorMatchesFilter } from "./_review-thread-mutations.mjs";
7
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
8
+
9
+ // Dedicated query: unlike capture-review-threads.mjs's REVIEW_THREADS_QUERY
10
+ // (all comments, no path/line/isOutdated, no pagination past 100), this tool
11
+ // needs only the FIRST comment per thread (the reply-resolve target) plus the
12
+ // thread-level path/line/isOutdated fields, and must paginate past 100 threads.
13
+ export const LIST_REVIEW_THREADS_QUERY = [
14
+ "query($owner: String!, $name: String!, $pr: Int!, $after: String) {",
15
+ " repository(owner: $owner, name: $name) {",
16
+ " pullRequest(number: $pr) {",
17
+ " reviewThreads(first: 100, after: $after) {",
18
+ " pageInfo {",
19
+ " hasNextPage",
20
+ " endCursor",
21
+ " }",
22
+ " nodes {",
23
+ " id",
24
+ " isResolved",
25
+ " isOutdated",
26
+ " path",
27
+ " line",
28
+ " comments(first: 1) {",
29
+ " nodes {",
30
+ " databaseId",
31
+ " body",
32
+ " author {",
33
+ " login",
34
+ " }",
35
+ " }",
36
+ " }",
37
+ " }",
38
+ " }",
39
+ " }",
40
+ " }",
41
+ "}",
42
+ ].join("\n");
43
+
44
+ // Bounded excerpt so a listing over many threads stays scannable; full body
45
+ // text is available from capture-review-threads.mjs when actually needed.
46
+ const BODY_EXCERPT_MAX_CHARS = 200;
47
+
48
+ const USAGE = `Usage: list-review-threads.mjs --repo <owner/name> --pr <number> [--unresolved-only] [--author <login>]
49
+ List review threads on a pull request with the thread id + first-comment
50
+ databaseId reply-resolve-review-thread.mjs needs, so a fixer pass can go
51
+ list -> reply-resolve without hand-written GraphQL.
52
+ Required:
53
+ --repo <owner/name> Repository slug (e.g. owner/repo)
54
+ --pr <number> Pull request number
55
+ Optional:
56
+ --unresolved-only Only include threads where isResolved is false
57
+ --author <login> Only include threads whose first comment author matches
58
+ (case-insensitive; "copilot" matches any Copilot review
59
+ login variant; "all" matches every author)
60
+ Output (stdout, JSON):
61
+ { "ok": true, "repo": "owner/name", "pr": N, "threads": [
62
+ { "threadId": "...", "commentId": N|null, "author": "..."|null,
63
+ "body": "...", "isResolved": bool, "isOutdated": bool,
64
+ "path": "..."|null, "line": N|null }, ...
65
+ ] }
66
+ Error output (stderr, JSON):
67
+ Argument/usage errors:
68
+ { "ok": false, "error": "...", "usage": "..." }
69
+ gh/runtime failures:
70
+ { "ok": false, "error": "..." }
71
+ ${JQ_OUTPUT_USAGE}
72
+ Exit codes:
73
+ 0 Success
74
+ 1 Argument error or gh failure
75
+ 2 Invalid --jq filter`.trim();
76
+
77
+ const parseError = buildParseError(USAGE);
78
+
79
+ export function parseListReviewThreadsCliArgs(argv) {
80
+ const { tokens } = parseArgs({
81
+ args: [...argv],
82
+ options: {
83
+ help: { type: "boolean", short: "h" },
84
+ repo: { type: "string" },
85
+ pr: { type: "string" },
86
+ "unresolved-only": { type: "boolean" },
87
+ author: { type: "string" },
88
+ ...JQ_OUTPUT_PARSE_OPTIONS,
89
+ },
90
+ allowPositionals: true,
91
+ strict: false,
92
+ tokens: true,
93
+ });
94
+ const options = {
95
+ help: false,
96
+ repo: undefined,
97
+ pr: undefined,
98
+ unresolvedOnly: false,
99
+ author: undefined,
100
+ };
101
+ for (const token of tokens) {
102
+ if (token.kind === "positional") {
103
+ throw parseError(`Unknown argument: ${token.value}`);
104
+ }
105
+ if (token.kind !== "option") {
106
+ continue;
107
+ }
108
+ if (token.name === "help") {
109
+ options.help = true;
110
+ return options;
111
+ }
112
+ if (token.name === "repo") {
113
+ options.repo = requireTokenValue(token, parseError).trim();
114
+ continue;
115
+ }
116
+ if (token.name === "pr") {
117
+ options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
118
+ continue;
119
+ }
120
+ if (token.name === "unresolved-only") {
121
+ options.unresolvedOnly = true;
122
+ continue;
123
+ }
124
+ if (token.name === "author") {
125
+ options.author = requireTokenValue(token, parseError).trim();
126
+ continue;
127
+ }
128
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
129
+ throw parseError(`Unknown argument: ${token.rawName}`);
130
+ }
131
+ if (options.repo === undefined || options.pr === undefined) {
132
+ throw parseError("Listing review threads requires both --repo <owner/name> and --pr <number>");
133
+ }
134
+ try {
135
+ parseRepoSlug(options.repo);
136
+ } catch (error) {
137
+ throw parseError(error instanceof Error ? error.message : String(error));
138
+ }
139
+ return options;
140
+ }
141
+
142
+ function buildQueryArgs({ owner, name, pr, after }) {
143
+ const args = [
144
+ "api",
145
+ "graphql",
146
+ "--field",
147
+ `owner=${owner}`,
148
+ "--field",
149
+ `name=${name}`,
150
+ "--field",
151
+ `pr=${pr}`,
152
+ "--field",
153
+ `query=${LIST_REVIEW_THREADS_QUERY}`,
154
+ ];
155
+ if (typeof after === "string" && after.length > 0) {
156
+ args.push("--field", `after=${after}`);
157
+ }
158
+ return args;
159
+ }
160
+
161
+ function readThreadsConnection(payload) {
162
+ const connection = payload?.data?.repository?.pullRequest?.reviewThreads;
163
+ if (!connection || typeof connection !== "object") {
164
+ throw new Error("Invalid review-threads GraphQL payload: missing data.repository.pullRequest.reviewThreads");
165
+ }
166
+ const nodes = Array.isArray(connection.nodes) ? connection.nodes : [];
167
+ const pageInfo = connection.pageInfo ?? {};
168
+ return {
169
+ nodes,
170
+ hasNextPage: Boolean(pageInfo.hasNextPage),
171
+ endCursor: typeof pageInfo.endCursor === "string" ? pageInfo.endCursor : null,
172
+ };
173
+ }
174
+
175
+ function excerptBody(rawBody) {
176
+ const text = typeof rawBody === "string" ? rawBody.trim() : "";
177
+ if (text.length <= BODY_EXCERPT_MAX_CHARS) {
178
+ return text;
179
+ }
180
+ return `${text.slice(0, BODY_EXCERPT_MAX_CHARS)}…`;
181
+ }
182
+
183
+ function normalizeThreadNode(node) {
184
+ const firstComment = node?.comments?.nodes?.[0] ?? null;
185
+ const commentId = Number.isFinite(firstComment?.databaseId) ? firstComment.databaseId : null;
186
+ const authorLogin = firstComment?.author?.login;
187
+ const author = typeof authorLogin === "string" && authorLogin.length > 0 ? authorLogin : null;
188
+ return {
189
+ threadId: typeof node?.id === "string" ? node.id : "",
190
+ commentId,
191
+ author,
192
+ body: excerptBody(firstComment?.body),
193
+ isResolved: Boolean(node?.isResolved),
194
+ isOutdated: Boolean(node?.isOutdated),
195
+ path: typeof node?.path === "string" ? node.path : null,
196
+ line: typeof node?.line === "number" ? node.line : null,
197
+ };
198
+ }
199
+
200
+ export async function fetchAllReviewThreads(
201
+ { repo, pr },
202
+ { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {},
203
+ ) {
204
+ const { owner, name } = parseRepoSlug(repo);
205
+ const threads = [];
206
+ let after = null;
207
+ while (true) {
208
+ const result = await runChild(ghCommand, buildQueryArgs({ owner, name, pr, after }), env);
209
+ if (result.code !== 0) {
210
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
211
+ throw new Error(`gh command failed: ${detail}`);
212
+ }
213
+ const payload = parseJsonText(result.stdout);
214
+ const { nodes, hasNextPage, endCursor } = readThreadsConnection(payload);
215
+ for (const node of nodes) {
216
+ threads.push(normalizeThreadNode(node));
217
+ }
218
+ if (!hasNextPage) {
219
+ break;
220
+ }
221
+ if (!endCursor) {
222
+ throw new Error("Invalid review-threads GraphQL payload: pageInfo.hasNextPage is true but endCursor is missing");
223
+ }
224
+ after = endCursor;
225
+ }
226
+ return threads;
227
+ }
228
+
229
+ export function filterThreads(threads, { unresolvedOnly = false, author = undefined } = {}) {
230
+ // "all" is a match-everything sentinel: bypass the author filter entirely so
231
+ // threads with a missing/ghost first-comment author (author null) still list.
232
+ // authorMatchesFilter deliberately rejects empty logins for its reply-resolve
233
+ // callers, so routing "all" through it would drop those threads.
234
+ const authorFilter = typeof author === "string" && author.trim().toLowerCase() === "all"
235
+ ? undefined
236
+ : author;
237
+ return threads.filter((thread) => {
238
+ if (unresolvedOnly && thread.isResolved) {
239
+ return false;
240
+ }
241
+ if (authorFilter !== undefined && !authorMatchesFilter(thread.author, authorFilter)) {
242
+ return false;
243
+ }
244
+ return true;
245
+ });
246
+ }
247
+
248
+ export async function runCli(
249
+ argv = process.argv.slice(2),
250
+ {
251
+ stdout = process.stdout,
252
+ stderr = process.stderr,
253
+ env = process.env,
254
+ ghCommand = "gh",
255
+ } = {},
256
+ ) {
257
+ const options = parseListReviewThreadsCliArgs(argv);
258
+ if (options.help) {
259
+ stdout.write(`${USAGE}\n`);
260
+ return 0;
261
+ }
262
+ const allThreads = await fetchAllReviewThreads({ repo: options.repo, pr: options.pr }, { env, ghCommand });
263
+ const threads = filterThreads(allThreads, { unresolvedOnly: options.unresolvedOnly, author: options.author });
264
+ const result = { ok: true, repo: options.repo, pr: options.pr, threads };
265
+ return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
266
+ }
267
+
268
+ if (isDirectCliRun(import.meta.url)) {
269
+ runCli().then((code) => {
270
+ if (typeof code === "number") {
271
+ process.exitCode = code;
272
+ }
273
+ }).catch((error) => {
274
+ process.stderr.write(`${formatCliError(error)}\n`);
275
+ process.exitCode = 1;
276
+ });
277
+ }
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { parsePrNumber, requireOptionValue, runChild } from "../_cli-primitives.mjs";
3
- import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
3
+ import { formatCliError, isDirectCliRun, parseJsonText, sanitizeCopilotSummonTokens } from "../_core-helpers.mjs";
4
4
  import { loadDevLoopConfig, resolveGatePostFindingsComments } from "@dev-loops/core/config";
5
5
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
6
  import { JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
@@ -215,7 +215,7 @@ export function renderFindingsCommentBody({ gate, headSha, findings }) {
215
215
  ];
216
216
  if (findings.length === 0) {
217
217
  lines.push("No findings. All review angles passed for this head.");
218
- return lines.join("\n");
218
+ return sanitizeCopilotSummonTokens(lines.join("\n"));
219
219
  }
220
220
  const grouped = new Map();
221
221
  for (const sev of SEVERITY_ORDER) {
@@ -261,7 +261,10 @@ export function renderFindingsCommentBody({ gate, headSha, findings }) {
261
261
  while (lines.length > 0 && lines[lines.length - 1] === "") {
262
262
  lines.pop();
263
263
  }
264
- return lines.join("\n");
264
+ // Neutralize any bare @copilot/`/copilot`* tokens a finding summary quotes
265
+ // (e.g. an excerpt of the anti-summon rule itself) so this comment can never
266
+ // arm request-copilot-review.mjs's anti-summon guard.
267
+ return sanitizeCopilotSummonTokens(lines.join("\n"));
265
268
  }
266
269
 
267
270
  async function runGhJson(args, { env, ghCommand }) {
@@ -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,
@@ -14,7 +15,7 @@ import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.m
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;
@@ -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
  }
@@ -479,16 +505,47 @@ export async function performCopilotReviewRequest(options, { env = process.env,
479
505
  }
480
506
  let refinementConfig = { maxCopilotRounds: 5 };
481
507
  let maxRounds = 5; // Built-in default; overridden by config when loadable
508
+ // Lightweight fallback when config is unreadable/invalid: fail toward the
509
+ // SAFE (smaller) lightweight cap instead of silently inheriting the
510
+ // full-PR default of 5 above, which would let a light-dispatched PR run
511
+ // far more review rounds than intended whenever the config can't be read.
512
+ const LIGHTWEIGHT_DEFAULT_CAP = 1;
513
+ let configWarning = null;
482
514
  try {
483
515
  const { config, errors } = await loadDevLoopConfig();
484
516
  if (!errors || errors.length === 0) {
485
517
  refinementConfig = resolveRefinement(config);
486
- if (Number.isFinite(refinementConfig.maxCopilotRounds) && refinementConfig.maxCopilotRounds > 0) {
487
- maxRounds = refinementConfig.maxCopilotRounds;
518
+ // Light-dispatched PRs (#1210) enforce the COMPOSED cap
519
+ // min(lightMode.maxCopilotRounds ?? 1, refinement.maxCopilotRounds) — so
520
+ // this enforcement backstop cannot permit rounds beyond the lightweight cap.
521
+ const effectiveCap = options.lightweight
522
+ ? resolveEffectiveCopilotRoundCap(config, { lightweight: true })
523
+ : refinementConfig.maxCopilotRounds;
524
+ // >= 0 (not > 0): maxCopilotRounds: 0 is documented as "disable Copilot
525
+ // rounds"; it must be honored as an immediate refusal, not silently
526
+ // ignored in favor of the built-in default of 5.
527
+ if (Number.isFinite(effectiveCap) && effectiveCap >= 0) {
528
+ maxRounds = effectiveCap;
488
529
  }
530
+ if (options.lightweight) {
531
+ refinementConfig = { ...refinementConfig, maxCopilotRounds: effectiveCap };
532
+ }
533
+ } else if (options.lightweight) {
534
+ maxRounds = LIGHTWEIGHT_DEFAULT_CAP;
535
+ refinementConfig = { ...refinementConfig, maxCopilotRounds: LIGHTWEIGHT_DEFAULT_CAP };
536
+ 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)}`;
537
+ }
538
+ } catch (err) {
539
+ if (options.lightweight) {
540
+ maxRounds = LIGHTWEIGHT_DEFAULT_CAP;
541
+ refinementConfig = { ...refinementConfig, maxCopilotRounds: LIGHTWEIGHT_DEFAULT_CAP };
542
+ 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
543
  }
490
- } catch {
491
544
  }
545
+ // Every remaining return in this function is config-dependent (round-cap
546
+ // decisions, the request itself); surface a config-load fallback on all of
547
+ // them rather than just the path a given test happens to exercise.
548
+ const withConfigWarning = (result) => (configWarning ? { ...result, configWarning } : result);
492
549
  // Reconcile the completed-round count with detect-pr-gate-coordination-state (#896):
493
550
  // when the raw count has reached the cap, re-derive it with the draft-gate round
494
551
  // reset applied. A clean draft_gate re-pass on an earlier head resets the count, so
@@ -511,7 +568,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
511
568
  refinementConfig,
512
569
  );
513
570
  if (!roundCapAutoRerequest.eligible) {
514
- return {
571
+ return withConfigWarning({
515
572
  ok: true,
516
573
  status: ROUND_CAP_REACHED_STATUS,
517
574
  repo: options.repo,
@@ -520,7 +577,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
520
577
  completedRounds,
521
578
  maxRounds,
522
579
  detail: `Round cap of ${maxRounds} reached with ${completedRounds} completed rounds. No further re-requests will be made.`,
523
- };
580
+ });
524
581
  }
525
582
  }
526
583
  // --force-rerequest-review: only bypass when there are new commits since the last review
@@ -531,7 +588,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
531
588
  const canCompare = currentHeadSha !== null && lastReviewSha !== null;
532
589
  const hasNewCommits = canCompare && currentHeadSha !== lastReviewSha;
533
590
  if (!canCompare) {
534
- return {
591
+ return withConfigWarning({
535
592
  ok: true,
536
593
  status: ROUND_CAP_REACHED_STATUS,
537
594
  repo: options.repo,
@@ -540,10 +597,10 @@ export async function performCopilotReviewRequest(options, { env = process.env,
540
597
  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
598
  completedRounds,
542
599
  maxRounds,
543
- };
600
+ });
544
601
  }
545
602
  if (!hasNewCommits) {
546
- return {
603
+ return withConfigWarning({
547
604
  ok: true,
548
605
  status: NO_CHANGES_SINCE_LAST_REVIEW_STATUS,
549
606
  repo: options.repo,
@@ -552,7 +609,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
552
609
  detail: "No changes since last Copilot review. --force-rerequest-review requires new commits on the PR head.",
553
610
  completedRounds,
554
611
  maxRounds,
555
- };
612
+ });
556
613
  }
557
614
  // Has new commits — bypass the round cap and proceed with the request
558
615
  }
@@ -562,7 +619,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
562
619
  before,
563
620
  );
564
621
  if (sameHeadCleanConverged) {
565
- return {
622
+ return withConfigWarning({
566
623
  ok: true,
567
624
  status: SUPPRESSED_SAME_HEAD_CLEAN_STATUS,
568
625
  repo: options.repo,
@@ -570,35 +627,35 @@ export async function performCopilotReviewRequest(options, { env = process.env,
570
627
  reviewer: "Copilot",
571
628
  sameHeadCleanConverged: true,
572
629
  detail: "Current head already has a clean submitted Copilot review; same-head clean-convergence suppression is always enforced.",
573
- };
630
+ });
574
631
  }
575
632
  if (before.requested || before.hasPendingReviewOnCurrentHead) {
576
- return {
633
+ return withConfigWarning({
577
634
  ok: true,
578
635
  status: "already-requested",
579
636
  repo: options.repo,
580
637
  pr: options.pr,
581
638
  reviewer: "Copilot",
582
- };
639
+ });
583
640
  }
584
641
  const requestResult = await requestCopilotReview(options, { env, ghCommand });
585
642
  if (requestResult.status === "unavailable") {
586
643
  const after = await fetchCopilotReviewState(options, { env, ghCommand });
587
644
  if (after.requested || after.hasPendingReviewOnCurrentHead || after.hasSubmittedReviewOnCurrentHead) {
588
- return {
645
+ return withConfigWarning({
589
646
  ok: true,
590
647
  status: "already-requested",
591
648
  repo: options.repo,
592
649
  pr: options.pr,
593
650
  reviewer: "Copilot",
594
- };
651
+ });
595
652
  }
596
- return {
653
+ return withConfigWarning({
597
654
  ...requestResult,
598
- };
655
+ });
599
656
  }
600
657
  if (requestResult.status === "already-requested") {
601
- return requestResult;
658
+ return withConfigWarning(requestResult);
602
659
  }
603
660
  const after = await fetchCopilotReviewState(options, { env, ghCommand });
604
661
  const reviewCountIncreased = after.copilotReviewIds.length > before.copilotReviewIds.length;
@@ -606,9 +663,9 @@ export async function performCopilotReviewRequest(options, { env = process.env,
606
663
  if (!reviewNowObservablyInProgress) {
607
664
  throw new Error("Copilot review request did not appear in requested reviewers or fresh/in-progress Copilot reviews after gh pr edit");
608
665
  }
609
- return {
666
+ return withConfigWarning({
610
667
  ...requestResult,
611
- };
668
+ });
612
669
  }
613
670
  export async function runCli(
614
671
  argv = process.argv.slice(2),
@@ -625,7 +682,16 @@ export async function runCli(
625
682
  return;
626
683
  }
627
684
  const result = await performCopilotReviewRequest(options, { env, ghCommand });
628
- process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
685
+ // Honest status under --silent: `ok: true` reports "the helper ran without
686
+ // error", not "a review was placed" — a caller checking only exit-code
687
+ // truthiness must NOT read a non-`requested` status (blocked_by_copilot_comment,
688
+ // round_cap_reached, etc.) as a placed request. --silent therefore answers
689
+ // "was a request just placed" specifically: exit 0 only for `requested`,
690
+ // non-zero for every other status. Non-silent output is unaffected — the full
691
+ // JSON body (with `ok: true`) still prints for every documented status; the
692
+ // caller MUST branch on `.status`, not `.ok`.
693
+ const silentOk = options.silent ? result.status === "requested" : undefined;
694
+ process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr, ok: silentOk });
629
695
  }
630
696
  if (isDirectCliRun(import.meta.url)) {
631
697
  runCli().catch((error) => {