dev-loops 0.2.6 → 0.3.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 (92) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +2 -1
  3. package/.claude/agents/developer.md +1 -0
  4. package/.claude/agents/fixer.md +1 -0
  5. package/.claude/agents/review.md +30 -0
  6. package/.claude/skills/copilot-pr-followup/SKILL.md +57 -3
  7. package/.claude/skills/dev-loop/SKILL.md +5 -5
  8. package/.claude/skills/docs/anti-patterns.md +2 -0
  9. package/.claude/skills/docs/copilot-loop-operations.md +2 -2
  10. package/.claude/skills/local-implementation/SKILL.md +17 -3
  11. package/AGENTS.md +1 -1
  12. package/CHANGELOG.md +60 -0
  13. package/agents/dev-loop.agent.md +5 -1
  14. package/agents/developer.agent.md +1 -0
  15. package/agents/fixer.agent.md +1 -0
  16. package/agents/review.agent.md +30 -0
  17. package/cli/index.mjs +42 -7
  18. package/package.json +2 -2
  19. package/scripts/README.md +6 -5
  20. package/scripts/_cli-primitives.mjs +2 -0
  21. package/scripts/_core-helpers.mjs +1 -0
  22. package/scripts/claude/generate-claude-assets.mjs +12 -2
  23. package/scripts/claude/headless-dev-loop.mjs +53 -13
  24. package/scripts/claude/headless-info-smoke.mjs +45 -11
  25. package/scripts/docs/validate-links.mjs +20 -11
  26. package/scripts/github/build-adjacent-bundle.mjs +448 -0
  27. package/scripts/github/capture-review-threads.mjs +32 -14
  28. package/scripts/github/{create-draft-pr.mjs → create-pr.mjs} +28 -12
  29. package/scripts/github/detect-checkpoint-evidence.mjs +123 -15
  30. package/scripts/github/detect-linked-issue-pr.mjs +22 -10
  31. package/scripts/github/manage-sub-issues.mjs +37 -16
  32. package/scripts/github/post-gate-findings.mjs +392 -0
  33. package/scripts/github/probe-copilot-review.mjs +24 -12
  34. package/scripts/github/ready-for-review.mjs +17 -8
  35. package/scripts/github/reconcile-draft-gate.mjs +24 -12
  36. package/scripts/github/reply-resolve-review-threads.mjs +34 -15
  37. package/scripts/github/request-copilot-review.mjs +97 -19
  38. package/scripts/github/resolve-tracker-local-spec.mjs +29 -12
  39. package/scripts/github/stage-reviewer-draft.mjs +32 -14
  40. package/scripts/github/upsert-checkpoint-verdict.mjs +646 -41
  41. package/scripts/github/verify-fresh-review-context.mjs +12 -1
  42. package/scripts/github/write-gate-context.mjs +634 -0
  43. package/scripts/github/write-gate-findings-log.mjs +42 -21
  44. package/scripts/loop/build-handoff-envelope.mjs +32 -14
  45. package/scripts/loop/conductor-monitor.mjs +25 -9
  46. package/scripts/loop/conductor.mjs +31 -12
  47. package/scripts/loop/copilot-pr-handoff.mjs +31 -14
  48. package/scripts/loop/debt-remediate.mjs +28 -11
  49. package/scripts/loop/detect-change-scope.mjs +36 -11
  50. package/scripts/loop/detect-copilot-loop-state.mjs +29 -12
  51. package/scripts/loop/detect-copilot-session-activity.mjs +29 -12
  52. package/scripts/loop/detect-initial-copilot-pr-state.mjs +26 -10
  53. package/scripts/loop/detect-internal-only-pr.mjs +31 -13
  54. package/scripts/loop/detect-issue-refinement-artifact.mjs +29 -12
  55. package/scripts/loop/detect-pr-gate-coordination-state.mjs +56 -28
  56. package/scripts/loop/detect-reviewer-loop-state.mjs +35 -16
  57. package/scripts/loop/detect-stale-runner.mjs +32 -14
  58. package/scripts/loop/detect-tracker-first-loop-state.mjs +38 -11
  59. package/scripts/loop/detect-tracker-pr-state.mjs +23 -8
  60. package/scripts/loop/info.mjs +28 -10
  61. package/scripts/loop/inspect-run-viewer/cli.mjs +44 -21
  62. package/scripts/loop/inspect-run.mjs +35 -16
  63. package/scripts/loop/outer-loop.mjs +35 -16
  64. package/scripts/loop/pr-runner-coordination.mjs +31 -12
  65. package/scripts/loop/pre-commit-branch-guard.mjs +26 -9
  66. package/scripts/loop/pre-flight-gate.mjs +25 -9
  67. package/scripts/loop/pre-pr-ready-gate.mjs +24 -8
  68. package/scripts/loop/pre-push-main-guard.mjs +19 -5
  69. package/scripts/loop/pre-write-remote-freshness-guard.mjs +23 -7
  70. package/scripts/loop/resolve-dev-loop-startup.mjs +29 -12
  71. package/scripts/loop/run-conductor-cycle.mjs +23 -8
  72. package/scripts/loop/run-queue.mjs +87 -15
  73. package/scripts/loop/run-refinement-audit.mjs +44 -22
  74. package/scripts/loop/run-watch-cycle.mjs +28 -12
  75. package/scripts/loop/steer-loop.mjs +122 -62
  76. package/scripts/loop/watch-initial-copilot-pr.mjs +28 -12
  77. package/scripts/projects/add-queue-item.mjs +60 -43
  78. package/scripts/projects/archive-done-items.mjs +506 -0
  79. package/scripts/projects/ensure-queue-board.mjs +65 -64
  80. package/scripts/projects/list-queue-items.mjs +57 -56
  81. package/scripts/projects/move-queue-item.mjs +123 -124
  82. package/scripts/projects/reorder-queue-item.mjs +384 -108
  83. package/scripts/projects/sync-item-status.mjs +198 -0
  84. package/scripts/refine/_refine-helpers.mjs +21 -9
  85. package/scripts/refine/verify.mjs +31 -13
  86. package/skills/copilot-pr-followup/SKILL.md +57 -3
  87. package/skills/dev-loop/SKILL.md +9 -5
  88. package/skills/dev-loop/scripts/log-bash-exit-1.mjs +2 -2
  89. package/skills/dev-loop/scripts/phase-files.mjs +2 -2
  90. package/skills/docs/anti-patterns.md +2 -0
  91. package/skills/docs/copilot-loop-operations.md +2 -2
  92. package/skills/local-implementation/SKILL.md +17 -3
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
3
- import { parsePositiveInteger, requireOptionValue, runChild } from "../_cli-primitives.mjs";
3
+ import { parsePositiveInteger, requireTokenValue, runChild } from "../_cli-primitives.mjs";
4
4
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
5
+ import { parseArgs } from "node:util";
5
6
  const USAGE = `Usage: detect-copilot-session-activity.mjs --repo <owner/name> --branch <name> [--limit <number>]
6
7
  Detect Copilot GitHub Actions session activity on a branch.
7
8
  Required:
@@ -41,32 +42,48 @@ const COPILOT_RUN_NAME_PATTERNS = Object.freeze([
41
42
  ]);
42
43
  const parseError = buildParseError(USAGE);
43
44
  export function parseDetectCopilotSessionActivityCliArgs(argv) {
44
- const args = [...argv];
45
45
  const options = {
46
46
  help: false,
47
47
  repo: undefined,
48
48
  branch: undefined,
49
49
  limit: DEFAULT_LIMIT,
50
50
  };
51
- while (args.length > 0) {
52
- const token = args.shift();
53
- if (token === "--help" || token === "-h") {
51
+ const { tokens } = parseArgs({
52
+ args: [...argv],
53
+ options: {
54
+ help: { type: "boolean", short: "h" },
55
+ repo: { type: "string" },
56
+ branch: { type: "string" },
57
+ limit: { type: "string" },
58
+ },
59
+ allowPositionals: true,
60
+ strict: false,
61
+ tokens: true,
62
+ });
63
+ for (const token of tokens) {
64
+ if (token.kind === "positional") {
65
+ throw parseError(`Unknown argument: ${token.value}`);
66
+ }
67
+ if (token.kind !== "option") {
68
+ continue;
69
+ }
70
+ if (token.name === "help") {
54
71
  options.help = true;
55
72
  return options;
56
73
  }
57
- if (token === "--repo") {
58
- options.repo = requireOptionValue(args, "--repo", parseError).trim();
74
+ if (token.name === "repo") {
75
+ options.repo = requireTokenValue(token, parseError).trim();
59
76
  continue;
60
77
  }
61
- if (token === "--branch") {
62
- options.branch = requireOptionValue(args, "--branch", parseError).trim();
78
+ if (token.name === "branch") {
79
+ options.branch = requireTokenValue(token, parseError).trim();
63
80
  continue;
64
81
  }
65
- if (token === "--limit") {
66
- options.limit = parsePositiveInteger(requireOptionValue(args, "--limit", parseError), "--limit", parseError);
82
+ if (token.name === "limit") {
83
+ options.limit = parsePositiveInteger(requireTokenValue(token, parseError), "--limit", parseError);
67
84
  continue;
68
85
  }
69
- throw parseError(`Unknown argument: ${token}`);
86
+ throw parseError(`Unknown argument: ${token.rawName}`);
70
87
  }
71
88
  if (options.repo === undefined || options.branch === undefined || options.branch.length === 0) {
72
89
  throw parseError("detect-copilot-session-activity requires both --repo <owner/name> and --branch <name>");
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
3
- import { parseIssueNumber, requireOptionValue, runChild } from "../_cli-primitives.mjs";
3
+ import { parseIssueNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
4
4
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
5
5
  import { detectLinkedIssuePr } from "../github/detect-linked-issue-pr.mjs";
6
6
  import { detectCopilotSessionActivity } from "./detect-copilot-session-activity.mjs";
7
+ import { parseArgs } from "node:util";
7
8
  const USAGE = `Usage: detect-initial-copilot-pr-state.mjs --repo <owner/name> --issue <number>
8
9
  Detect whether an assigned issue is still on the bootstrap-only Copilot draft PR
9
10
  or has moved into normal linked-PR follow-up.
@@ -79,27 +80,42 @@ const INITIAL_COPILOT_PR_FACTS_QUERY = [
79
80
  ].join("\n");
80
81
  const parseError = buildParseError(USAGE);
81
82
  export function parseDetectInitialCopilotPrStateCliArgs(argv) {
82
- const args = [...argv];
83
83
  const options = {
84
84
  help: false,
85
85
  repo: undefined,
86
86
  issue: undefined,
87
87
  };
88
- while (args.length > 0) {
89
- const token = args.shift();
90
- if (token === "--help" || token === "-h") {
88
+ const { tokens } = parseArgs({
89
+ args: [...argv],
90
+ options: {
91
+ help: { type: "boolean", short: "h" },
92
+ repo: { type: "string" },
93
+ issue: { type: "string" },
94
+ },
95
+ allowPositionals: true,
96
+ strict: false,
97
+ tokens: true,
98
+ });
99
+ for (const token of tokens) {
100
+ if (token.kind === "positional") {
101
+ throw parseError(`Unknown argument: ${token.value}`);
102
+ }
103
+ if (token.kind !== "option") {
104
+ continue;
105
+ }
106
+ if (token.name === "help") {
91
107
  options.help = true;
92
108
  return options;
93
109
  }
94
- if (token === "--repo") {
95
- options.repo = requireOptionValue(args, "--repo", parseError).trim();
110
+ if (token.name === "repo") {
111
+ options.repo = requireTokenValue(token, parseError).trim();
96
112
  continue;
97
113
  }
98
- if (token === "--issue") {
99
- options.issue = parseIssueNumber(requireOptionValue(args, "--issue", parseError), parseError);
114
+ if (token.name === "issue") {
115
+ options.issue = parseIssueNumber(requireTokenValue(token, parseError), parseError);
100
116
  continue;
101
117
  }
102
- throw parseError(`Unknown argument: ${token}`);
118
+ throw parseError(`Unknown argument: ${token.rawName}`);
103
119
  }
104
120
  if (options.repo === undefined || options.issue === undefined) {
105
121
  throw parseError("detect-initial-copilot-pr-state requires both --repo <owner/name> and --issue <number>");
@@ -3,8 +3,9 @@ import { statSync, readFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { parse as parseYaml } from "yaml";
5
5
  import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
6
- import { parsePrNumber, requireOptionValue, runChild } from "../_cli-primitives.mjs";
6
+ import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
7
7
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
8
+ import { parseArgs } from "node:util";
8
9
 
9
10
  const USAGE = `Usage: detect-internal-only-pr.mjs --repo <owner/name> --pr <number> [--config <path>]
10
11
  Detect whether a PR only touches internal tooling files (scripts, docs, tests, config)
@@ -118,7 +119,6 @@ function buildPatternMatchers(patterns) {
118
119
  }
119
120
 
120
121
  export function parseCliArgs(argv) {
121
- const args = [...argv];
122
122
  const options = {
123
123
  help: false,
124
124
  repo: undefined,
@@ -126,29 +126,47 @@ export function parseCliArgs(argv) {
126
126
  config: undefined,
127
127
  labelCheck: false,
128
128
  };
129
- while (args.length > 0) {
130
- const token = args.shift();
131
- if (token === "--help" || token === "-h") {
129
+ const { tokens } = parseArgs({
130
+ args: [...argv],
131
+ options: {
132
+ help: { type: "boolean", short: "h" },
133
+ repo: { type: "string" },
134
+ pr: { type: "string" },
135
+ config: { type: "string" },
136
+ "label-check": { type: "boolean" },
137
+ },
138
+ allowPositionals: true,
139
+ strict: false,
140
+ tokens: true,
141
+ });
142
+ for (const token of tokens) {
143
+ if (token.kind === "positional") {
144
+ throw parseError(`Unknown argument: ${token.value}`);
145
+ }
146
+ if (token.kind !== "option") {
147
+ continue;
148
+ }
149
+ if (token.name === "help") {
132
150
  options.help = true;
133
151
  return options;
134
152
  }
135
- if (token === "--repo") {
136
- options.repo = requireOptionValue(args, "--repo", parseError).trim();
153
+ if (token.name === "repo") {
154
+ options.repo = requireTokenValue(token, parseError).trim();
137
155
  continue;
138
156
  }
139
- if (token === "--pr") {
140
- options.pr = parsePrNumber(requireOptionValue(args, "--pr", parseError), parseError);
157
+ if (token.name === "pr") {
158
+ options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
141
159
  continue;
142
160
  }
143
- if (token === "--config") {
144
- options.config = requireOptionValue(args, "--config", parseError).trim();
161
+ if (token.name === "config") {
162
+ options.config = requireTokenValue(token, parseError).trim();
145
163
  continue;
146
164
  }
147
- if (token === "--label-check") {
165
+ if (token.name === "label-check") {
148
166
  options.labelCheck = true;
149
167
  continue;
150
168
  }
151
- throw parseError(`Unknown argument: ${token}`);
169
+ throw parseError(`Unknown argument: ${token.rawName}`);
152
170
  }
153
171
  if (options.repo === undefined || options.pr === undefined) {
154
172
  throw parseError("detect-internal-only-pr requires both --repo <owner/name> and --pr <number>");
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
4
- import { requireOptionValue, runChild } from "../_cli-primitives.mjs";
4
+ import { requireTokenValue, runChild } from "../_cli-primitives.mjs";
5
5
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
+ import { parseArgs } from "node:util";
6
7
  import {
7
8
  detectIssueRefinementArtifact,
8
9
  REFINEMENT_SOURCE,
@@ -33,36 +34,52 @@ Error output (stderr, JSON):
33
34
  { "ok": false, "error": "...", "usage": "..." }`.trim();
34
35
  const parseError = buildParseError(USAGE);
35
36
  export function parseDetectIssueRefinementArtifactCliArgs(argv) {
36
- const args = [...argv];
37
37
  const options = {
38
38
  help: false,
39
39
  repo: undefined,
40
40
  issue: undefined,
41
41
  input: undefined,
42
42
  };
43
- while (args.length > 0) {
44
- const token = args.shift();
45
- if (token === "--help" || token === "-h") {
43
+ const { tokens } = parseArgs({
44
+ args: [...argv],
45
+ options: {
46
+ help: { type: "boolean", short: "h" },
47
+ repo: { type: "string" },
48
+ issue: { type: "string" },
49
+ input: { type: "string" },
50
+ },
51
+ allowPositionals: true,
52
+ strict: false,
53
+ tokens: true,
54
+ });
55
+ for (const token of tokens) {
56
+ if (token.kind === "positional") {
57
+ throw parseError(`Unknown argument: ${token.value}`);
58
+ }
59
+ if (token.kind !== "option") {
60
+ continue;
61
+ }
62
+ if (token.name === "help") {
46
63
  options.help = true;
47
64
  return options;
48
65
  }
49
- if (token === "--repo") {
50
- options.repo = requireOptionValue(args, "--repo", parseError).trim();
66
+ if (token.name === "repo") {
67
+ options.repo = requireTokenValue(token, parseError).trim();
51
68
  continue;
52
69
  }
53
- if (token === "--issue") {
54
- const value = requireOptionValue(args, "--issue", parseError);
70
+ if (token.name === "issue") {
71
+ const value = requireTokenValue(token, parseError);
55
72
  if (!/^\d+$/.test(value) || Number(value) === 0) {
56
73
  throw parseError("--issue must be a positive integer");
57
74
  }
58
75
  options.issue = Number(value);
59
76
  continue;
60
77
  }
61
- if (token === "--input") {
62
- options.input = requireOptionValue(args, "--input", parseError).trim();
78
+ if (token.name === "input") {
79
+ options.input = requireTokenValue(token, parseError).trim();
63
80
  continue;
64
81
  }
65
- throw parseError(`Unknown argument: ${token}`);
82
+ throw parseError(`Unknown argument: ${token.rawName}`);
66
83
  }
67
84
  const hasInput = typeof options.input === "string" && options.input.length > 0;
68
85
  const hasRemote = typeof options.repo === "string" && options.repo.length > 0 && Number.isInteger(options.issue);
@@ -6,19 +6,20 @@ import {
6
6
  formatCliError,
7
7
  isCopilotLogin,
8
8
  isDirectCliRun,
9
- normalizeTimestamp,
10
9
  parseJsonText,
11
10
  parseReviewThreads,
11
+ resolveDraftGateRoundResetMs,
12
12
  summarizeCopilotReviews,
13
13
  } from "../_core-helpers.mjs";
14
- import { parsePrNumber, requireOptionValue, runChild } from "../_cli-primitives.mjs";
15
- import { loadDevLoopConfig, resolveGateConfig, resolveRefinementConfig, resolveWorkflowConfig } from "@dev-loops/core/config";
14
+ import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
15
+ import { loadDevLoopConfig, resolveGateConfig, resolveRefinement, resolveRefinementConfig, resolveWorkflowConfig } from "@dev-loops/core/config";
16
16
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
17
17
  import { buildSnapshotFromPrFacts, interpretLoopState, summarizeLoopInterpretation } from "@dev-loops/core/loop/copilot-loop-state";
18
18
  import { evaluatePrGateCoordination, PR_CHECKPOINT, PR_CHECKPOINT_ACTION } from "@dev-loops/core/loop/pr-gate-coordination";
19
19
  import { shouldGuardCopilotReviewRequest } from "@dev-loops/core/loop/pr-gate-coordination";
20
20
  import { fetchGithubReviewThreadsPayload } from "../github/capture-review-threads.mjs";
21
21
  import { detectCheckpointEvidence } from "../github/detect-checkpoint-evidence.mjs";
22
+ import { parseArgs } from "node:util";
22
23
  const UNMERGED_GIT_STATUS_CODES = new Set(["DD", "AU", "UD", "UA", "DU", "AA", "UU"]);
23
24
  const USAGE = `Usage: detect-pr-gate-coordination-state.mjs --repo <owner/name> --pr <number>
24
25
  Determine which PR gate/transition is legal next for a pull request.
@@ -70,27 +71,42 @@ Exit codes:
70
71
  1 Argument error or gh/runtime failure`.trim();
71
72
  const parseError = buildParseError(USAGE);
72
73
  export function parseDetectPrGateCoordinationCliArgs(argv) {
73
- const args = [...argv];
74
74
  const options = {
75
75
  help: false,
76
76
  repo: undefined,
77
77
  pr: undefined,
78
78
  };
79
- while (args.length > 0) {
80
- const token = args.shift();
81
- if (token === "--help" || token === "-h") {
79
+ const { tokens } = parseArgs({
80
+ args: [...argv],
81
+ options: {
82
+ help: { type: "boolean", short: "h" },
83
+ repo: { type: "string" },
84
+ pr: { type: "string" },
85
+ },
86
+ allowPositionals: true,
87
+ strict: false,
88
+ tokens: true,
89
+ });
90
+ for (const token of tokens) {
91
+ if (token.kind === "positional") {
92
+ throw parseError(`Unknown argument: ${token.value}`);
93
+ }
94
+ if (token.kind !== "option") {
95
+ continue;
96
+ }
97
+ if (token.name === "help") {
82
98
  options.help = true;
83
99
  return options;
84
100
  }
85
- if (token === "--repo") {
86
- options.repo = requireOptionValue(args, "--repo", parseError).trim();
101
+ if (token.name === "repo") {
102
+ options.repo = requireTokenValue(token, parseError).trim();
87
103
  continue;
88
104
  }
89
- if (token === "--pr") {
90
- options.pr = parsePrNumber(requireOptionValue(args, "--pr", parseError), parseError);
105
+ if (token.name === "pr") {
106
+ options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
91
107
  continue;
92
108
  }
93
- throw parseError(`Unknown argument: ${token}`);
109
+ throw parseError(`Unknown argument: ${token.rawName}`);
94
110
  }
95
111
  if (options.repo === undefined || options.pr === undefined) {
96
112
  throw parseError("detect-pr-gate-coordination-state requires both --repo <owner/name> and --pr <number>");
@@ -287,18 +303,13 @@ export async function loadPrGateCoordinationContext(options, runtime = {}) {
287
303
  const gateEvidence = await detectCheckpointEvidence(options, runtime);
288
304
  // When draft gate was re-passed on a different head, use its timestamp
289
305
  // to reset the Copilot round count — only reviews after the re-pass count.
290
- // Use prefix matching for the head SHA comparison so shortened SHAs (7+)
291
- // from gate comments match the full headRefOid.
292
- const draftGateHeadSha = gateEvidence.draftGate?.headSha;
293
- const draftGateOnCurrentHead = typeof draftGateHeadSha === "string"
294
- && typeof currentHeadSha === "string"
295
- && currentHeadSha.startsWith(draftGateHeadSha);
296
- const draftGateResetAtMs = gateEvidence.draftGate?.verdict === "clean"
297
- && typeof draftGateHeadSha === "string"
298
- && !draftGateOnCurrentHead
299
- && typeof gateEvidence.draftGate?.updatedAt === "string"
300
- ? normalizeTimestamp(gateEvidence.draftGate.updatedAt)
301
- : null;
306
+ // Shared with request-copilot-review so both scripts compute the same
307
+ // completed round count / cap (#896). Prefix matching for the head SHA lets
308
+ // shortened SHAs (7+) from gate comments match the full headRefOid.
309
+ const draftGateResetAtMs = resolveDraftGateRoundResetMs({
310
+ draftGate: gateEvidence.draftGate,
311
+ currentHeadSha,
312
+ });
302
313
  const reviewSummary = summarizeCopilotReviews(prData?.reviews, { headSha: currentHeadSha, draftGateResetAtMs });
303
314
  const reviewRequestStatus = requestedReviewers.requested
304
315
  ? "requested"
@@ -322,8 +333,19 @@ export async function loadPrGateCoordinationContext(options, runtime = {}) {
322
333
  if (gateEvidence.currentHeadSha !== currentHeadSha) {
323
334
  throw new Error(`PR head changed while loading gate coordination facts for ${options.repo}#${options.pr}; refuse to evaluate mixed-head gate state.`);
324
335
  }
325
- const interpretation = interpretLoopState(snapshot);
326
- const disposition = summarizeLoopInterpretation(interpretation);
336
+ // Resolve the refinement config (round cap, low-signal heuristic) and feed it to
337
+ // the interpreter. Without it, the interpreter cannot see maxCopilotRounds and so
338
+ // never resolves ROUND_CAP_CLEAN_FALLBACK — a post-cap clean head would fall to
339
+ // READY_TO_REREQUEST_REVIEW, dead-ending the loop at the round cap (#896). This
340
+ // keeps the gate-coordination interpretation consistent with the standalone
341
+ // detect-copilot-loop-state path and with request-copilot-review's cap logic.
342
+ const interpreterRepoRoot = runtime.repoRoot ?? process.cwd();
343
+ const interpreterConfigResult = await loadDevLoopConfig({ repoRoot: interpreterRepoRoot });
344
+ const interpreterRefinementConfig = (Array.isArray(interpreterConfigResult.errors) && interpreterConfigResult.errors.length > 0)
345
+ ? resolveRefinement({ version: 1 })
346
+ : resolveRefinement(interpreterConfigResult.config ?? { version: 1 });
347
+ const interpretation = interpretLoopState(snapshot, interpreterRefinementConfig);
348
+ const disposition = summarizeLoopInterpretation(interpretation, interpreterRefinementConfig);
327
349
  const mergeStateStatus = typeof prData?.mergeStateStatus === "string" && prData.mergeStateStatus.trim().length > 0
328
350
  ? prData.mergeStateStatus.trim().toUpperCase()
329
351
  : null;
@@ -346,6 +368,7 @@ export async function loadPrGateCoordinationContext(options, runtime = {}) {
346
368
  interpretation,
347
369
  disposition,
348
370
  refinementArtifact,
371
+ refinementConfig: interpreterRefinementConfig,
349
372
  };
350
373
  }
351
374
 
@@ -413,9 +436,13 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
413
436
  && typeof (context.snapshot?.copilotReviewRoundCount) === "number"
414
437
  && context.snapshot?.copilotReviewRoundCount >= maxCopilotRounds;
415
438
  const sameHeadCleanConverged = context.interpretation?.sameHeadCleanConverged ?? false;
439
+ // Round-cap clean fallback (#896): the interpreter resolved a clean post-cap head
440
+ // (zero unresolved threads + green CI) that Copilot will not re-review. The formal
441
+ // request guard must not fire here — pre_approval_gate reviews the post-cap head.
442
+ const roundCapCleanFallback = context.interpretation?.roundCapCleanEligible ?? false;
416
443
  const copilotReviewEverFormallyRequested = copilotReviewRequestStatus === "none"
417
444
  && guardBoundaries.has(result.gateBoundary)
418
- && !(roundCapReached && sameHeadCleanConverged)
445
+ && !(roundCapReached && (sameHeadCleanConverged || roundCapCleanFallback))
419
446
  ? await fetchCopilotEverFormallyRequested(
420
447
  { repo: context.repo, pr: context.pr },
421
448
  runtime,
@@ -426,7 +453,8 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
426
453
  copilotReviewRoundCount: context.snapshot?.copilotReviewRoundCount ?? 0,
427
454
  copilotReviewEverFormallyRequested,
428
455
  maxCopilotRounds,
429
- sameHeadCleanConverged: context.interpretation?.sameHeadCleanConverged ?? false,
456
+ sameHeadCleanConverged,
457
+ roundCapCleanFallback,
430
458
  gateBoundary: result.gateBoundary,
431
459
  })) {
432
460
  result.gateBoundary = PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW;
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFile } from "node:fs/promises";
3
- import { parsePrNumber, requireOptionValue, runChild } from "../_cli-primitives.mjs";
3
+ import { parseArgs } from "node:util";
4
+ import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
4
5
  import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
5
6
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
7
  import {
@@ -26,7 +27,20 @@ function parseBool(value, flag) {
26
27
  throw new Error(`${flag} must be true or false`);
27
28
  }
28
29
  export function parseDetectReviewerCliArgs(argv) {
29
- const args = [...argv];
30
+ const { tokens } = parseArgs({
31
+ args: [...argv],
32
+ options: {
33
+ help: { type: "boolean", short: "h" },
34
+ input: { type: "string" },
35
+ repo: { type: "string" },
36
+ pr: { type: "string" },
37
+ "review-requested": { type: "string" },
38
+ "local-state": { type: "string" },
39
+ },
40
+ allowPositionals: true,
41
+ strict: false,
42
+ tokens: true,
43
+ });
30
44
  const options = {
31
45
  inputPath: undefined,
32
46
  repo: undefined,
@@ -35,36 +49,41 @@ export function parseDetectReviewerCliArgs(argv) {
35
49
  localStatePath: undefined,
36
50
  help: false,
37
51
  };
38
- while (args.length > 0) {
39
- const token = args.shift();
40
- if (token === "--help" || token === "-h") {
52
+ for (const token of tokens) {
53
+ if (token.kind === "positional") {
54
+ throw new Error(`Unknown argument: ${token.value}`);
55
+ }
56
+ if (token.kind !== "option") {
57
+ continue;
58
+ }
59
+ if (token.name === "help") {
41
60
  options.help = true;
42
61
  return options;
43
62
  }
44
- if (token === "--input") {
45
- options.inputPath = requireOptionValue(args, "--input");
63
+ if (token.name === "input") {
64
+ options.inputPath = requireTokenValue(token);
46
65
  continue;
47
66
  }
48
- if (token === "--repo") {
49
- options.repo = requireOptionValue(args, "--repo").trim();
67
+ if (token.name === "repo") {
68
+ options.repo = requireTokenValue(token).trim();
50
69
  continue;
51
70
  }
52
- if (token === "--pr") {
53
- options.pr = parsePrNumber(requireOptionValue(args, "--pr"));
71
+ if (token.name === "pr") {
72
+ options.pr = parsePrNumber(requireTokenValue(token));
54
73
  continue;
55
74
  }
56
- if (token === "--review-requested") {
75
+ if (token.name === "review-requested") {
57
76
  options.reviewRequestedOverride = parseBool(
58
- requireOptionValue(args, "--review-requested"),
77
+ requireTokenValue(token),
59
78
  "--review-requested",
60
79
  );
61
80
  continue;
62
81
  }
63
- if (token === "--local-state") {
64
- options.localStatePath = requireOptionValue(args, "--local-state");
82
+ if (token.name === "local-state") {
83
+ options.localStatePath = requireTokenValue(token);
65
84
  continue;
66
85
  }
67
- throw new Error(`Unknown argument: ${token}`);
86
+ throw new Error(`Unknown argument: ${token.rawName}`);
68
87
  }
69
88
  if (options.inputPath !== undefined) {
70
89
  if (options.repo !== undefined || options.pr !== undefined) {
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import process from "node:process";
3
3
  import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
4
- import { parsePrNumber, requireOptionValue } from "../_cli-primitives.mjs";
4
+ import { parsePrNumber, requireTokenValue } from "../_cli-primitives.mjs";
5
5
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
6
  import { resolveRunId as resolveEnvRunId } from "@dev-loops/core/loop/run-context";
7
+ import { parseArgs } from "node:util";
7
8
  import {
8
9
  detectStaleRunner,
9
10
  STALE_RUNNER_ERROR,
@@ -50,7 +51,6 @@ Exit codes:
50
51
  1 Argument error or stale/exit-signal condition detected`.trim();
51
52
  const parseError = buildParseError(USAGE);
52
53
  function parseCliArgs(argv) {
53
- const args = [...argv];
54
54
  const options = {
55
55
  help: false,
56
56
  repo: undefined,
@@ -58,22 +58,40 @@ function parseCliArgs(argv) {
58
58
  staleRunnerMaxAgeMs: undefined,
59
59
  runId: undefined,
60
60
  };
61
- while (args.length > 0) {
62
- const token = args.shift();
63
- if (token === "--help" || token === "-h") {
61
+ const { tokens } = parseArgs({
62
+ args: [...argv],
63
+ options: {
64
+ help: { type: "boolean", short: "h" },
65
+ repo: { type: "string" },
66
+ pr: { type: "string" },
67
+ "stale-runner-max-age-ms": { type: "string" },
68
+ "run-id": { type: "string" },
69
+ },
70
+ allowPositionals: true,
71
+ strict: false,
72
+ tokens: true,
73
+ });
74
+ for (const token of tokens) {
75
+ if (token.kind === "positional") {
76
+ throw parseError(`Unknown argument: ${token.value}`);
77
+ }
78
+ if (token.kind !== "option") {
79
+ continue;
80
+ }
81
+ if (token.name === "help") {
64
82
  options.help = true;
65
83
  return options;
66
84
  }
67
- if (token === "--repo") {
68
- options.repo = requireOptionValue(args, "--repo", parseError).trim();
85
+ if (token.name === "repo") {
86
+ options.repo = requireTokenValue(token, parseError).trim();
69
87
  continue;
70
88
  }
71
- if (token === "--pr") {
72
- options.pr = parsePrNumber(requireOptionValue(args, "--pr", parseError), parseError);
89
+ if (token.name === "pr") {
90
+ options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
73
91
  continue;
74
92
  }
75
- if (token === "--stale-runner-max-age-ms") {
76
- const raw = requireOptionValue(args, "--stale-runner-max-age-ms", parseError).trim();
93
+ if (token.name === "stale-runner-max-age-ms") {
94
+ const raw = requireTokenValue(token, parseError).trim();
77
95
  const parsed = Number(raw);
78
96
  if (!Number.isFinite(parsed) || parsed <= 0) {
79
97
  throw parseError(`--stale-runner-max-age-ms must be a positive integer (ms), got: ${raw}`);
@@ -81,11 +99,11 @@ function parseCliArgs(argv) {
81
99
  options.staleRunnerMaxAgeMs = Math.floor(parsed);
82
100
  continue;
83
101
  }
84
- if (token === "--run-id") {
85
- options.runId = requireOptionValue(args, "--run-id", parseError).trim();
102
+ if (token.name === "run-id") {
103
+ options.runId = requireTokenValue(token, parseError).trim();
86
104
  continue;
87
105
  }
88
- throw parseError(`Unknown argument: ${token}`);
106
+ throw parseError(`Unknown argument: ${token.rawName}`);
89
107
  }
90
108
  if (options.repo === undefined || options.pr === undefined) {
91
109
  throw parseError("detect-stale-runner requires both --repo <owner/name> and --pr <number>");