dev-loops 0.6.0 → 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 (203) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +8 -7
  3. package/.claude/agents/fixer.md +1 -0
  4. package/.claude/agents/refiner.md +2 -2
  5. package/.claude/agents/review.md +4 -3
  6. package/.claude/commands/{auto.md → loop-auto.md} +1 -1
  7. package/.claude/commands/loop-continue.md +15 -0
  8. package/.claude/commands/loop-enqueue.md +22 -0
  9. package/.claude/commands/loop-grill.md +17 -0
  10. package/.claude/commands/{info.md → loop-info.md} +2 -2
  11. package/.claude/commands/loop-queue-status.md +24 -0
  12. package/.claude/commands/{start-spike.md → loop-start-spike.md} +2 -2
  13. package/.claude/commands/{start.md → loop-start.md} +1 -1
  14. package/.claude/commands/{status.md → loop-status.md} +1 -1
  15. package/.claude/hooks/_bash-command-classify.mjs +333 -29
  16. package/.claude/hooks/_hook-decisions.mjs +138 -15
  17. package/.claude/hooks/pre-tool-use-bash-gate.mjs +36 -15
  18. package/.claude/skills/copilot-pr-followup/SKILL.md +60 -34
  19. package/.claude/skills/dev-loop/SKILL.md +16 -12
  20. package/.claude/skills/docs/acceptance-criteria-verification.md +8 -3
  21. package/.claude/skills/docs/anti-patterns.md +5 -3
  22. package/.claude/skills/docs/artifact-authority-contract.md +30 -11
  23. package/.claude/skills/docs/confirmation-rules.md +1 -0
  24. package/.claude/skills/docs/contract-style-guide.md +37 -0
  25. package/.claude/skills/docs/copilot-loop-operations.md +19 -15
  26. package/.claude/skills/docs/cross-harness-regression-contract.md +60 -0
  27. package/.claude/skills/docs/debt-remediation-contract.md +1 -1
  28. package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
  29. package/.claude/skills/docs/issue-intake-procedure.md +26 -19
  30. package/.claude/skills/docs/local-planning-flow.md +1 -1
  31. package/.claude/skills/docs/local-planning-worked-example.md +1 -1
  32. package/.claude/skills/docs/merge-preconditions.md +30 -12
  33. package/.claude/skills/docs/plan-file-contract.md +1 -1
  34. package/.claude/skills/docs/pr-lifecycle-contract.md +44 -35
  35. package/.claude/skills/docs/public-dev-loop-contract.md +60 -44
  36. package/.claude/skills/docs/retrospective-checkpoint-contract.md +106 -79
  37. package/.claude/skills/docs/spike-mode-contract.md +14 -7
  38. package/.claude/skills/docs/stop-conditions.md +30 -15
  39. package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
  40. package/.claude/skills/docs/ui-e2e-scoping-step.md +32 -0
  41. package/.claude/skills/docs/validation-policy.md +4 -5
  42. package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
  43. package/.claude/skills/local-implementation/SKILL.md +62 -145
  44. package/.claude/skills/loop-grill/SKILL.md +163 -0
  45. package/AGENTS.md +1 -0
  46. package/CHANGELOG.md +101 -0
  47. package/README.md +9 -9
  48. package/agents/dev-loop.agent.md +11 -10
  49. package/agents/developer.agent.md +1 -1
  50. package/agents/docs.agent.md +1 -1
  51. package/agents/fixer.agent.md +2 -1
  52. package/agents/quality.agent.md +1 -1
  53. package/agents/refiner.agent.md +3 -3
  54. package/agents/review.agent.md +5 -4
  55. package/cli/index.mjs +35 -42
  56. package/extension/README.md +4 -4
  57. package/extension/checks.ts +1 -5
  58. package/extension/harness-types.ts +1 -0
  59. package/extension/index.ts +6 -0
  60. package/extension/pi-extension-adapter.ts +2 -0
  61. package/extension/presentation.ts +7 -19
  62. package/package.json +12 -12
  63. package/scripts/_core-helpers.mjs +2 -0
  64. package/scripts/claude/headless-dev-loop.mjs +1 -1
  65. package/scripts/docs/validate-rule-ownership.mjs +442 -0
  66. package/scripts/docs/validate-state-machine-conformance.mjs +1092 -0
  67. package/scripts/github/_review-thread-mutations.mjs +5 -2
  68. package/scripts/github/capture-review-threads.mjs +2 -9
  69. package/scripts/github/comment-issue.mjs +2 -9
  70. package/scripts/github/create-label.mjs +133 -0
  71. package/scripts/github/create-pr.mjs +119 -10
  72. package/scripts/github/detect-checkpoint-evidence.mjs +259 -18
  73. package/scripts/github/detect-linked-issue-pr.mjs +22 -6
  74. package/scripts/github/edit-pr.mjs +259 -0
  75. package/scripts/github/fetch-ci-logs.mjs +2 -9
  76. package/scripts/github/list-issues.mjs +2 -9
  77. package/scripts/github/list-review-threads.mjs +277 -0
  78. package/scripts/github/manage-sub-issues.mjs +46 -10
  79. package/scripts/github/offer-human-handoff.mjs +8 -5
  80. package/scripts/github/post-gate-findings.mjs +20 -5
  81. package/scripts/github/probe-ci-status.mjs +8 -2
  82. package/scripts/github/probe-copilot-review.mjs +21 -14
  83. package/scripts/github/ready-for-review.mjs +26 -8
  84. package/scripts/github/reconcile-draft-gate.mjs +7 -3
  85. package/scripts/github/reply-resolve-review-thread.mjs +16 -5
  86. package/scripts/github/reply-resolve-review-threads.mjs +69 -7
  87. package/scripts/github/request-copilot-review.mjs +98 -26
  88. package/scripts/github/resolve-handoff-candidates.mjs +7 -3
  89. package/scripts/github/resolve-tracker-local-spec.mjs +9 -3
  90. package/scripts/github/stage-reviewer-draft.mjs +10 -2
  91. package/scripts/github/tick-verified-checkboxes.mjs +202 -0
  92. package/scripts/github/upsert-checkpoint-verdict.mjs +105 -15
  93. package/scripts/github/verify-briefing-prefixes.mjs +191 -0
  94. package/scripts/github/verify-fresh-review-context.mjs +226 -32
  95. package/scripts/github/view-pr.mjs +150 -0
  96. package/scripts/github/wait-pr-checks.mjs +171 -0
  97. package/scripts/github/write-gate-context.mjs +458 -65
  98. package/scripts/github/write-gate-findings-log.mjs +128 -2
  99. package/scripts/lib/jq-output.mjs +18 -0
  100. package/scripts/loop/_post-convergence-change.mjs +211 -0
  101. package/scripts/loop/_pr-runner-coordination.mjs +70 -0
  102. package/scripts/loop/_repo-root-resolver.mjs +47 -0
  103. package/scripts/loop/build-handoff-envelope.mjs +14 -4
  104. package/scripts/loop/check-retro-tooling.mjs +14 -3
  105. package/scripts/loop/checkpoint-contract.mjs +7 -4
  106. package/scripts/loop/cleanup-worktree.mjs +12 -3
  107. package/scripts/loop/conductor-monitor.mjs +12 -18
  108. package/scripts/loop/copilot-pr-handoff.mjs +162 -14
  109. package/scripts/loop/debt-remediate.mjs +24 -12
  110. package/scripts/loop/detect-change-scope.mjs +38 -9
  111. package/scripts/loop/detect-copilot-loop-state.mjs +34 -8
  112. package/scripts/loop/detect-copilot-session-activity.mjs +7 -3
  113. package/scripts/loop/detect-initial-copilot-pr-state.mjs +7 -3
  114. package/scripts/loop/detect-internal-only-pr.mjs +8 -2
  115. package/scripts/loop/detect-issue-refinement-artifact.mjs +6 -3
  116. package/scripts/loop/detect-pr-gate-coordination-state.mjs +184 -69
  117. package/scripts/loop/detect-reviewer-loop-state.mjs +12 -2
  118. package/scripts/loop/detect-tracker-first-loop-state.mjs +9 -1
  119. package/scripts/loop/detect-tracker-pr-state.mjs +10 -3
  120. package/scripts/loop/ensure-worktree.mjs +8 -3
  121. package/scripts/loop/info.mjs +12 -4
  122. package/scripts/loop/inspect-run-viewer/constants.mjs +4 -18
  123. package/scripts/loop/inspect-run-viewer/vendor/mermaid.min.js +3405 -0
  124. package/scripts/loop/inspect-run-viewer-ci-changes.mjs +18 -6
  125. package/scripts/loop/inspect-run-viewer.mjs +67 -4
  126. package/scripts/loop/inspect-run.mjs +8 -2
  127. package/scripts/loop/outer-loop.mjs +8 -4
  128. package/scripts/loop/pr-runner-coordination.mjs +2 -9
  129. package/scripts/loop/pre-commit-branch-guard.mjs +16 -10
  130. package/scripts/loop/pre-flight-gate.mjs +9 -9
  131. package/scripts/loop/pre-pr-ready-gate.mjs +8 -4
  132. package/scripts/loop/pre-write-remote-freshness-guard.mjs +13 -4
  133. package/scripts/loop/provision-worktree.mjs +74 -3
  134. package/scripts/loop/resolve-dev-loop-startup.mjs +216 -10
  135. package/scripts/loop/resolve-gate-dispatch.mjs +134 -0
  136. package/scripts/loop/resolve-pr-conflicts.mjs +14 -7
  137. package/scripts/loop/run-conductor-cycle.mjs +8 -2
  138. package/scripts/loop/run-queue.mjs +18 -9
  139. package/scripts/loop/run-refinement-audit.mjs +8 -9
  140. package/scripts/loop/run-watch-cycle.mjs +2 -9
  141. package/scripts/loop/sanctioned-commands.mjs +106 -0
  142. package/scripts/loop/steer-loop.mjs +29 -16
  143. package/scripts/loop/validate-pr-body-spec.mjs +223 -0
  144. package/scripts/loop/watch-initial-copilot-pr.mjs +8 -3
  145. package/scripts/pages/build-site.mjs +59 -8
  146. package/scripts/pages/build-state-atlas.mjs +443 -0
  147. package/scripts/projects/_resolve-project.mjs +1 -0
  148. package/scripts/projects/add-queue-item.mjs +60 -54
  149. package/scripts/projects/archive-done-items.mjs +49 -84
  150. package/scripts/projects/ensure-queue-board.mjs +10 -36
  151. package/scripts/projects/list-queue-items.mjs +59 -382
  152. package/scripts/projects/move-queue-item.mjs +21 -449
  153. package/scripts/projects/reconcile-queue.mjs +253 -0
  154. package/scripts/projects/reorder-queue-item.mjs +43 -68
  155. package/scripts/projects/resolve-active-board-item.mjs +108 -46
  156. package/scripts/projects/sync-item-status.mjs +15 -10
  157. package/scripts/refine/_refine-helpers.mjs +21 -5
  158. package/scripts/refine/exit-spike.mjs +18 -8
  159. package/scripts/refine/promote-plan.mjs +22 -16
  160. package/scripts/refine/prose-linkage-detector.mjs +1 -1
  161. package/scripts/refine/refine-plan-file.mjs +13 -4
  162. package/scripts/refine/refinement-completeness-checker.mjs +1 -1
  163. package/scripts/refine/scaffold-spike-file.mjs +4 -8
  164. package/scripts/refine/scope-boundary-cross-checker.mjs +1 -1
  165. package/scripts/refine/tree-integrity-validator.mjs +1 -1
  166. package/scripts/refine/validate-plan-file.mjs +1 -1
  167. package/scripts/refine/validate-spike-file.mjs +1 -1
  168. package/scripts/refine/verify.mjs +11 -6
  169. package/scripts/release/assert-core-dependency-version.mjs +123 -0
  170. package/scripts/release/extract-changelog-section.mjs +16 -2
  171. package/skills/copilot-pr-followup/SKILL.md +60 -34
  172. package/skills/dev-loop/SKILL.md +11 -7
  173. package/skills/docs/acceptance-criteria-verification.md +8 -3
  174. package/skills/docs/anti-patterns.md +5 -3
  175. package/skills/docs/artifact-authority-contract.md +30 -11
  176. package/skills/docs/confirmation-rules.md +1 -0
  177. package/skills/docs/contract-style-guide.md +37 -0
  178. package/skills/docs/copilot-loop-operations.md +19 -15
  179. package/skills/docs/cross-harness-regression-contract.md +60 -0
  180. package/skills/docs/debt-remediation-contract.md +1 -1
  181. package/skills/docs/epic-tree-refinement-procedure.md +25 -22
  182. package/skills/docs/issue-intake-procedure.md +26 -19
  183. package/skills/docs/local-planning-flow.md +1 -1
  184. package/skills/docs/local-planning-worked-example.md +1 -1
  185. package/skills/docs/merge-preconditions.md +30 -12
  186. package/skills/docs/plan-file-contract.md +1 -1
  187. package/skills/docs/pr-lifecycle-contract.md +44 -35
  188. package/skills/docs/public-dev-loop-contract.md +60 -44
  189. package/skills/docs/required-rules.json +149 -0
  190. package/skills/docs/retrospective-checkpoint-contract.md +106 -79
  191. package/skills/docs/spike-mode-contract.md +14 -7
  192. package/skills/docs/stop-conditions.md +30 -15
  193. package/skills/docs/tracker-first-loop-state.md +13 -8
  194. package/skills/docs/ui-e2e-scoping-step.md +32 -0
  195. package/skills/docs/validation-policy.md +4 -5
  196. package/skills/docs/workflow-handoff-contract.md +39 -0
  197. package/skills/local-implementation/SKILL.md +62 -145
  198. package/skills/loop-grill/SKILL.md +165 -0
  199. package/.claude/commands/continue.md +0 -15
  200. package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
  201. package/scripts/loop/conductor.mjs +0 -233
  202. package/scripts/loop/detect-stale-runner.mjs +0 -265
  203. package/scripts/loop/pre-push-main-guard.mjs +0 -117
@@ -12,6 +12,7 @@ import {
12
12
  replyAndMaybeResolve,
13
13
  validateResolutionMessage,
14
14
  } from "./_review-thread-mutations.mjs";
15
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
15
16
  const USAGE = `Usage: reply-resolve-review-threads.mjs --repo <owner/name> --pr <number> [--author <login>] [--message <text>] [--resolve]
16
17
  Reply to all matching unresolved review threads on one PR and optionally resolve them.
17
18
  Required:
@@ -30,9 +31,11 @@ Error output (stderr, JSON):
30
31
  { "ok": false, "error": "...", "usage": "..." }
31
32
  Runtime/gh failures:
32
33
  { "ok": false, "error": "...", "partialProgress"?: { ... } }
34
+ ${JQ_OUTPUT_USAGE}
33
35
  Exit codes:
34
36
  0 Success
35
- 1 Argument error or gh/runtime failure`.trim();
37
+ 1 Argument error or gh/runtime failure
38
+ 2 Invalid --jq filter`.trim();
36
39
  const parseError = buildParseError(USAGE);
37
40
  export function parseReplyResolveThreadsCliArgs(argv) {
38
41
  const { tokens } = parseArgs({
@@ -44,6 +47,7 @@ export function parseReplyResolveThreadsCliArgs(argv) {
44
47
  author: { type: "string" },
45
48
  message: { type: "string" },
46
49
  resolve: { type: "boolean" },
50
+ ...JQ_OUTPUT_PARSE_OPTIONS,
47
51
  },
48
52
  allowPositionals: true,
49
53
  strict: false,
@@ -88,6 +92,7 @@ export function parseReplyResolveThreadsCliArgs(argv) {
88
92
  options.resolve = true;
89
93
  continue;
90
94
  }
95
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
91
96
  throw parseError(`Unknown argument: ${token.rawName}`);
92
97
  }
93
98
  if (options.repo === undefined || options.pr === undefined) {
@@ -111,6 +116,62 @@ async function readStdinText(stdin) {
111
116
  }
112
117
  return text;
113
118
  }
119
+ // When --message is set, stdin is read only to detect a conflicting second
120
+ // message source. A detached/idle pipe never sends EOF, so an unbounded read
121
+ // hangs forever and the process never exits (issue #1012). Resolve as soon as
122
+ // any NON-WHITESPACE byte arrives (a conflicting body is detected the instant
123
+ // real content appears — no need to wait for EOF); keep buffering while only
124
+ // whitespace has arrived (a leading newline is not yet a conflict and more may
125
+ // follow); resolve on natural EOF; and time out on a silent/idle pipe. Either
126
+ // way the stdin handle is released so the event loop can drain and the tool
127
+ // always terminates.
128
+ const CONFLICT_STDIN_TIMEOUT_MS = 500;
129
+ function readStdinConflictProbe(stdin, timeoutMs) {
130
+ return new Promise((resolve) => {
131
+ let settled = false;
132
+ let timer;
133
+ const cleanup = () => {
134
+ clearTimeout(timer);
135
+ stdin.off?.("data", onData);
136
+ stdin.off?.("end", onEnd);
137
+ stdin.off?.("error", onEnd);
138
+ // Release the handle: an abandoned reader on a still-open pipe would
139
+ // otherwise keep the event loop alive and re-introduce the hang.
140
+ stdin.pause?.();
141
+ if (typeof stdin.unref === "function") {
142
+ stdin.unref();
143
+ } else {
144
+ stdin.destroy?.();
145
+ }
146
+ };
147
+ const finish = (value) => {
148
+ if (settled) {
149
+ return;
150
+ }
151
+ settled = true;
152
+ cleanup();
153
+ resolve(value);
154
+ };
155
+ // Resolve early only once non-whitespace content is seen (a real conflict);
156
+ // '' or whitespace-only on clean EOF is not a conflict; undefined only on
157
+ // timeout (idle pipe) so the caller proceeds with --message.
158
+ let text = "";
159
+ const onData = (chunk) => {
160
+ text += String(chunk);
161
+ if (text.trim().length > 0) {
162
+ finish(text);
163
+ }
164
+ };
165
+ const onEnd = () => finish(text);
166
+ timer = setTimeout(() => finish(undefined), timeoutMs);
167
+ timer.unref?.();
168
+ stdin.setEncoding?.("utf8");
169
+ stdin.on?.("data", onData);
170
+ stdin.on?.("end", onEnd);
171
+ stdin.on?.("error", onEnd);
172
+ });
173
+ }
174
+
114
175
  async function resolveMessageInput(options, { stdin = process.stdin } = {}) {
115
176
  if (typeof options.message === "string") {
116
177
  if (stdin.isTTY) {
@@ -119,8 +180,8 @@ async function resolveMessageInput(options, { stdin = process.stdin } = {}) {
119
180
  }
120
181
  return options.message;
121
182
  }
122
- const stdinText = await readStdinText(stdin);
123
- if (stdinText.trim().length > 0) {
183
+ const stdinText = await readStdinConflictProbe(stdin, CONFLICT_STDIN_TIMEOUT_MS);
184
+ if (typeof stdinText === "string" && stdinText.trim().length > 0) {
124
185
  throw parseError("Choose exactly one message source: --message <text> or stdin");
125
186
  }
126
187
  if (options.message.trim().length === 0) {
@@ -241,6 +302,7 @@ export async function runCli(
241
302
  {
242
303
  stdin = process.stdin,
243
304
  stdout = process.stdout,
305
+ stderr = process.stderr,
244
306
  env = process.env,
245
307
  ghCommand = "gh",
246
308
  } = {},
@@ -258,7 +320,7 @@ export async function runCli(
258
320
  );
259
321
  const { matchedTargets, skippedThreadCount } = planBatchReplyTargets(parsed, options.author);
260
322
  if (matchedTargets.length === 0) {
261
- stdout.write(`${JSON.stringify(createSuccessPayload({
323
+ process.exitCode = emitResult(createSuccessPayload({
262
324
  repo: options.repo,
263
325
  pr: options.pr,
264
326
  author: options.author,
@@ -268,7 +330,7 @@ export async function runCli(
268
330
  resolvedThreadCount: 0,
269
331
  skippedThreadCount,
270
332
  results: [],
271
- }))}\n`);
333
+ }), { jq: options.jq, silent: options.silent, stdout, stderr });
272
334
  return;
273
335
  }
274
336
  const results = [];
@@ -328,7 +390,7 @@ export async function runCli(
328
390
  }
329
391
  const repliedThreadCount = results.length;
330
392
  const resolvedThreadCount = results.filter((entry) => entry.resolved).length;
331
- stdout.write(`${JSON.stringify(createSuccessPayload({
393
+ process.exitCode = emitResult(createSuccessPayload({
332
394
  repo: options.repo,
333
395
  pr: options.pr,
334
396
  author: options.author,
@@ -338,7 +400,7 @@ export async function runCli(
338
400
  resolvedThreadCount,
339
401
  skippedThreadCount,
340
402
  results,
341
- }))}\n`);
403
+ }), { jq: options.jq, silent: options.silent, stdout, stderr });
342
404
  }
343
405
  if (isDirectCliRun(import.meta.url)) {
344
406
  runCli().catch((error) => {
@@ -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,8 @@ 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";
19
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
18
20
  const BLOCKED_BY_COPILOT_COMMENT_STATUS = "blocked_by_copilot_comment";
19
21
  const SUPPRESSED_SAME_HEAD_CLEAN_STATUS = "suppressed_same_head_clean";
20
22
  const ROUND_CAP_REACHED_STATUS = "round_cap_reached";
@@ -29,13 +31,19 @@ Optional:
29
31
  --force-rerequest-review Bypass the round cap when new commits exist since
30
32
  the last Copilot review. Refused when the PR head
31
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.
32
38
  Debug:
33
39
  DEVLOOPS_DEBUG=1 Emit stderr traces when best-effort same-head clean
34
40
  convergence detection falls back to unsuppressed behavior
35
41
  Output (stdout, JSON):
36
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",
37
43
  "repo": "...", "pr": N, "reviewer": "Copilot", "detail"?: "...",
38
- "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) }
39
47
  Request statuses:
40
48
  requested Copilot review was successfully requested
41
49
  already-requested Copilot review was already observably in progress; no new request needed
@@ -50,9 +58,22 @@ Error output (stderr, JSON):
50
58
  { "ok": false, "error": "...", "usage": "..." }
51
59
  gh/runtime failures:
52
60
  { "ok": false, "error": "..." }
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.
53
73
  Exit codes:
54
- 0 Success (including unavailable)
55
- 1 Argument error or gh failure`.trim();
74
+ 0 Success (including unavailable); with --silent, only when status is "requested"
75
+ 1 Argument error, gh failure, or (--silent) any non-"requested" status
76
+ 2 Invalid --jq filter`.trim();
56
77
  const parseError = buildParseError(USAGE);
57
78
  export function parseRequestCliArgs(argv) {
58
79
  const { tokens } = parseArgs({
@@ -60,8 +81,10 @@ export function parseRequestCliArgs(argv) {
60
81
  options: {
61
82
  help: { type: "boolean", short: "h" },
62
83
  "force-rerequest-review": { type: "boolean" },
84
+ lightweight: { type: "boolean" },
63
85
  repo: { type: "string" },
64
86
  pr: { type: "string" },
87
+ ...JQ_OUTPUT_PARSE_OPTIONS,
65
88
  },
66
89
  allowPositionals: true,
67
90
  strict: false,
@@ -72,6 +95,7 @@ export function parseRequestCliArgs(argv) {
72
95
  repo: undefined,
73
96
  pr: undefined,
74
97
  forceRerequestReview: false,
98
+ lightweight: false,
75
99
  };
76
100
  for (const token of tokens) {
77
101
  if (token.kind === "positional") {
@@ -88,6 +112,10 @@ export function parseRequestCliArgs(argv) {
88
112
  options.forceRerequestReview = true;
89
113
  continue;
90
114
  }
115
+ if (token.name === "lightweight") {
116
+ options.lightweight = true;
117
+ continue;
118
+ }
91
119
  if (token.name === "repo") {
92
120
  options.repo = requireTokenValue(token, parseError).trim();
93
121
  continue;
@@ -96,6 +124,7 @@ export function parseRequestCliArgs(argv) {
96
124
  options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
97
125
  continue;
98
126
  }
127
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
99
128
  throw parseError(`Unknown argument: ${token.rawName}`);
100
129
  }
101
130
  if (options.repo === undefined || options.pr === undefined) {
@@ -437,7 +466,9 @@ export async function checkForCopilotComments({ repo, pr }, { env = process.env,
437
466
  if (isCopilotLogin(author)) {
438
467
  continue;
439
468
  }
440
- 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)) {
441
472
  violationCommentIds.push(comment.id);
442
473
  }
443
474
  }
@@ -474,16 +505,47 @@ export async function performCopilotReviewRequest(options, { env = process.env,
474
505
  }
475
506
  let refinementConfig = { maxCopilotRounds: 5 };
476
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;
477
514
  try {
478
515
  const { config, errors } = await loadDevLoopConfig();
479
516
  if (!errors || errors.length === 0) {
480
517
  refinementConfig = resolveRefinement(config);
481
- if (Number.isFinite(refinementConfig.maxCopilotRounds) && refinementConfig.maxCopilotRounds > 0) {
482
- 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;
483
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)}`;
484
543
  }
485
- } catch {
486
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);
487
549
  // Reconcile the completed-round count with detect-pr-gate-coordination-state (#896):
488
550
  // when the raw count has reached the cap, re-derive it with the draft-gate round
489
551
  // reset applied. A clean draft_gate re-pass on an earlier head resets the count, so
@@ -506,7 +568,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
506
568
  refinementConfig,
507
569
  );
508
570
  if (!roundCapAutoRerequest.eligible) {
509
- return {
571
+ return withConfigWarning({
510
572
  ok: true,
511
573
  status: ROUND_CAP_REACHED_STATUS,
512
574
  repo: options.repo,
@@ -515,7 +577,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
515
577
  completedRounds,
516
578
  maxRounds,
517
579
  detail: `Round cap of ${maxRounds} reached with ${completedRounds} completed rounds. No further re-requests will be made.`,
518
- };
580
+ });
519
581
  }
520
582
  }
521
583
  // --force-rerequest-review: only bypass when there are new commits since the last review
@@ -526,7 +588,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
526
588
  const canCompare = currentHeadSha !== null && lastReviewSha !== null;
527
589
  const hasNewCommits = canCompare && currentHeadSha !== lastReviewSha;
528
590
  if (!canCompare) {
529
- return {
591
+ return withConfigWarning({
530
592
  ok: true,
531
593
  status: ROUND_CAP_REACHED_STATUS,
532
594
  repo: options.repo,
@@ -535,10 +597,10 @@ export async function performCopilotReviewRequest(options, { env = process.env,
535
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.`,
536
598
  completedRounds,
537
599
  maxRounds,
538
- };
600
+ });
539
601
  }
540
602
  if (!hasNewCommits) {
541
- return {
603
+ return withConfigWarning({
542
604
  ok: true,
543
605
  status: NO_CHANGES_SINCE_LAST_REVIEW_STATUS,
544
606
  repo: options.repo,
@@ -547,7 +609,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
547
609
  detail: "No changes since last Copilot review. --force-rerequest-review requires new commits on the PR head.",
548
610
  completedRounds,
549
611
  maxRounds,
550
- };
612
+ });
551
613
  }
552
614
  // Has new commits — bypass the round cap and proceed with the request
553
615
  }
@@ -557,7 +619,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
557
619
  before,
558
620
  );
559
621
  if (sameHeadCleanConverged) {
560
- return {
622
+ return withConfigWarning({
561
623
  ok: true,
562
624
  status: SUPPRESSED_SAME_HEAD_CLEAN_STATUS,
563
625
  repo: options.repo,
@@ -565,35 +627,35 @@ export async function performCopilotReviewRequest(options, { env = process.env,
565
627
  reviewer: "Copilot",
566
628
  sameHeadCleanConverged: true,
567
629
  detail: "Current head already has a clean submitted Copilot review; same-head clean-convergence suppression is always enforced.",
568
- };
630
+ });
569
631
  }
570
632
  if (before.requested || before.hasPendingReviewOnCurrentHead) {
571
- return {
633
+ return withConfigWarning({
572
634
  ok: true,
573
635
  status: "already-requested",
574
636
  repo: options.repo,
575
637
  pr: options.pr,
576
638
  reviewer: "Copilot",
577
- };
639
+ });
578
640
  }
579
641
  const requestResult = await requestCopilotReview(options, { env, ghCommand });
580
642
  if (requestResult.status === "unavailable") {
581
643
  const after = await fetchCopilotReviewState(options, { env, ghCommand });
582
644
  if (after.requested || after.hasPendingReviewOnCurrentHead || after.hasSubmittedReviewOnCurrentHead) {
583
- return {
645
+ return withConfigWarning({
584
646
  ok: true,
585
647
  status: "already-requested",
586
648
  repo: options.repo,
587
649
  pr: options.pr,
588
650
  reviewer: "Copilot",
589
- };
651
+ });
590
652
  }
591
- return {
653
+ return withConfigWarning({
592
654
  ...requestResult,
593
- };
655
+ });
594
656
  }
595
657
  if (requestResult.status === "already-requested") {
596
- return requestResult;
658
+ return withConfigWarning(requestResult);
597
659
  }
598
660
  const after = await fetchCopilotReviewState(options, { env, ghCommand });
599
661
  const reviewCountIncreased = after.copilotReviewIds.length > before.copilotReviewIds.length;
@@ -601,14 +663,15 @@ export async function performCopilotReviewRequest(options, { env = process.env,
601
663
  if (!reviewNowObservablyInProgress) {
602
664
  throw new Error("Copilot review request did not appear in requested reviewers or fresh/in-progress Copilot reviews after gh pr edit");
603
665
  }
604
- return {
666
+ return withConfigWarning({
605
667
  ...requestResult,
606
- };
668
+ });
607
669
  }
608
670
  export async function runCli(
609
671
  argv = process.argv.slice(2),
610
672
  {
611
673
  stdout = process.stdout,
674
+ stderr = process.stderr,
612
675
  env = process.env,
613
676
  ghCommand = "gh",
614
677
  } = {},
@@ -619,7 +682,16 @@ export async function runCli(
619
682
  return;
620
683
  }
621
684
  const result = await performCopilotReviewRequest(options, { env, ghCommand });
622
- stdout.write(`${JSON.stringify(result)}\n`);
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 });
623
695
  }
624
696
  if (isDirectCliRun(import.meta.url)) {
625
697
  runCli().catch((error) => {
@@ -9,6 +9,7 @@ import {
9
9
  loadDevLoopConfig,
10
10
  resolveHumanHandoffConfig,
11
11
  } from "@dev-loops/core/config";
12
+ import { JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
12
13
 
13
14
  const USAGE = `Usage: resolve-handoff-candidates.mjs --repo <owner/name> --pr <number> [--changed-files <a,b,c>] [--pr-author <login>]
14
15
  Resolve an ordered, deduped list of human-handoff reviewer/assignee candidates
@@ -39,9 +40,11 @@ Optional:
39
40
  Output (stdout, JSON):
40
41
  { "ok": true, "enabled": bool, "candidates": [{ "login", "source", "isTeam"?, "paths"? }],
41
42
  "changedFiles": [...], "sources": [...], "warnings": [...] }
43
+ ${JQ_OUTPUT_USAGE}
42
44
  Exit codes:
43
45
  0 Success (including disabled no-op and fail-soft per-source skips)
44
- 1 Argument error`.trim();
46
+ 1 Argument error
47
+ 2 Invalid --jq filter`.trim();
45
48
 
46
49
  const parseError = buildParseError(USAGE);
47
50
 
@@ -74,6 +77,8 @@ export function parseResolveCandidatesCliArgs(argv) {
74
77
  continue;
75
78
  }
76
79
  if (token === "--pr-author") { options.prAuthor = nextValue(args, ++i, "--pr-author").trim().replace(/^@/, ""); continue; }
80
+ if (token === "--jq") { options.jq = nextValue(args, ++i, "--jq"); continue; }
81
+ if (token === "--silent" || token === "-s") { options.silent = true; continue; }
77
82
  throw parseError(`Unknown argument: ${token}`);
78
83
  }
79
84
  if (options.repo === undefined || options.pr === undefined) {
@@ -403,8 +408,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
403
408
  { repo: options.repo, pr: options.pr, changedFiles: options.changedFiles, prAuthor: options.prAuthor ?? null },
404
409
  { ...deps, config, repoRoot },
405
410
  );
406
- process.stdout.write(`${JSON.stringify(result)}\n`);
407
- return 0;
411
+ return emitResult(result, { jq: options.jq, silent: options.silent });
408
412
  }
409
413
 
410
414
  if (isDirectCliRun(import.meta.url)) {
@@ -3,6 +3,7 @@ import { parseArgs } from "node:util";
3
3
  import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
4
4
  import { parseIssueNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
5
5
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
6
7
  const ISSUE_JSON_FIELDS = "number,title,body,url,state";
7
8
  const USAGE = `Usage: resolve-tracker-local-spec.mjs (--repo <owner/name> --issue <number> | --issue-url <github-issue-url>)
8
9
  Resolve the canonical tracker-backed local spec bundle from one GitHub issue reference.
@@ -30,7 +31,8 @@ Error output (stderr, JSON):
30
31
  Argument/usage errors:
31
32
  { "ok": false, "error": "...", "usage": "..." }
32
33
  gh/runtime failures:
33
- { "ok": false, "error": "..." }`.trim();
34
+ { "ok": false, "error": "..." }
35
+ ${JQ_OUTPUT_USAGE}`.trim();
34
36
  const parseError = buildParseError(USAGE);
35
37
  export function parseGitHubIssueUrl(value) {
36
38
  let parsedUrl;
@@ -68,6 +70,7 @@ export function parseResolveTrackerLocalSpecCliArgs(argv) {
68
70
  repo: { type: "string" },
69
71
  issue: { type: "string" },
70
72
  "issue-url": { type: "string" },
73
+ ...JQ_OUTPUT_PARSE_OPTIONS,
71
74
  },
72
75
  allowPositionals: true,
73
76
  strict: false,
@@ -102,6 +105,7 @@ export function parseResolveTrackerLocalSpecCliArgs(argv) {
102
105
  options.issueUrl = requireTokenValue(token, parseError).trim();
103
106
  continue;
104
107
  }
108
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
105
109
  throw parseError(`Unknown argument: ${token.rawName}`);
106
110
  }
107
111
  const usingIssueUrl = typeof options.issueUrl === "string";
@@ -119,6 +123,8 @@ export function parseResolveTrackerLocalSpecCliArgs(argv) {
119
123
  repo,
120
124
  issue,
121
125
  issueUrl: options.issueUrl,
126
+ ...(options.jq !== undefined ? { jq: options.jq } : {}),
127
+ ...(options.silent !== undefined ? { silent: options.silent } : {}),
122
128
  };
123
129
  }
124
130
  try {
@@ -201,7 +207,7 @@ export async function resolveTrackerLocalSpec(
201
207
  }
202
208
  export async function runCli(
203
209
  argv = process.argv.slice(2),
204
- { stdout = process.stdout, env = process.env, ghCommand = "gh" } = {},
210
+ { stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh" } = {},
205
211
  ) {
206
212
  const options = parseResolveTrackerLocalSpecCliArgs(argv);
207
213
  if (options.help) {
@@ -212,7 +218,7 @@ export async function runCli(
212
218
  { repo: options.repo, issue: options.issue },
213
219
  { env, ghCommand },
214
220
  );
215
- stdout.write(`${JSON.stringify(result)}\n`);
221
+ process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
216
222
  }
217
223
  if (isDirectCliRun(import.meta.url)) {
218
224
  runCli().catch((error) => {
@@ -7,6 +7,7 @@ import { parseArgs } from "node:util";
7
7
  import { parsePositiveInteger, requireTokenValue } from "../_cli-primitives.mjs";
8
8
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
9
9
  import { buildDraftReviewPayload } from "@dev-loops/core/loop/reviewer-loop-state";
10
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
10
11
  const HELP = `Usage: stage-reviewer-draft.mjs --repo <owner/name> --pr <number> --review-file <path> [--local-state-output <path>]
11
12
  Stage a pending draft review on a GitHub pull request.
12
13
  Options:
@@ -15,9 +16,13 @@ Options:
15
16
  --review-file <path> Path to JSON file containing review payload (required)
16
17
  --local-state-output <path> Path to write local state snapshot (optional)
17
18
  --help, -h Show this help
19
+
20
+ ${JQ_OUTPUT_USAGE}
21
+
18
22
  Exit codes:
19
23
  0 Success
20
24
  1 Error
25
+ 2 Invalid --jq filter
21
26
  `;
22
27
  export function parseStageDraftCliArgs(argv) {
23
28
  const { tokens } = parseArgs({
@@ -28,6 +33,7 @@ export function parseStageDraftCliArgs(argv) {
28
33
  pr: { type: "string" },
29
34
  "review-file": { type: "string" },
30
35
  "local-state-output": { type: "string" },
36
+ ...JQ_OUTPUT_PARSE_OPTIONS,
31
37
  },
32
38
  allowPositionals: true,
33
39
  strict: false,
@@ -67,6 +73,7 @@ export function parseStageDraftCliArgs(argv) {
67
73
  options.localStateOutput = requireTokenValue(token);
68
74
  continue;
69
75
  }
76
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t))) continue;
70
77
  throw new Error(`Unknown argument: ${token.rawName}`);
71
78
  }
72
79
  if (!options.repo || !options.pr || !options.reviewFile) {
@@ -169,6 +176,7 @@ export async function runCli(
169
176
  argv = process.argv.slice(2),
170
177
  {
171
178
  stdout = process.stdout,
179
+ stderr = process.stderr,
172
180
  env = process.env,
173
181
  ghCommand = "gh",
174
182
  } = {},
@@ -190,7 +198,7 @@ export async function runCli(
190
198
  await postDraftReview({ repo: options.repo, pr: options.pr, reviewPayload }, { env, ghCommand }),
191
199
  );
192
200
  const localStatePath = await writeLocalState(options.localStateOutput, draftReview);
193
- stdout.write(`${JSON.stringify({
201
+ process.exitCode = emitResult({
194
202
  ok: true,
195
203
  repo: options.repo,
196
204
  pr: options.pr,
@@ -199,7 +207,7 @@ export async function runCli(
199
207
  reviewState: draftReview.state,
200
208
  commitSha: draftReview.commitSha,
201
209
  localStatePath,
202
- })}\n`);
210
+ }, { jq: options.jq, silent: options.silent, stdout, stderr });
203
211
  }
204
212
  if (isDirectCliRun(import.meta.url)) {
205
213
  runCli().catch((error) => {