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
@@ -0,0 +1,392 @@
1
+ #!/usr/bin/env node
2
+ import { parsePrNumber, requireOptionValue, runChild } from "../_cli-primitives.mjs";
3
+ import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
4
+ import { loadDevLoopConfig, resolveGatePostFindingsComments } from "@dev-loops/core/config";
5
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
+
7
+ const USAGE = `Usage: post-gate-findings.mjs --repo <owner/name> --pr <number> --gate <draft_gate|pre_approval_gate> --head-sha <sha> --findings <json>
8
+ Post (or idempotently update) a visible, marker-tagged PR issue comment that lists the
9
+ consolidated gate fan-out findings, grouped by severity. The comment is idempotent
10
+ per gate: there is exactly one comment per gate, updated in place on each run
11
+ (the reviewed head is shown in the body) instead of duplicating it.
12
+
13
+ The disposition ledger (write-gate-findings-log.mjs) is the durable source of truth and is
14
+ written regardless of this comment. This helper only posts the auditable PR summary, and
15
+ no-ops when gates.postFindingsComments is set to false in config.
16
+
17
+ Required:
18
+ --repo <owner/name>
19
+ --pr <number>
20
+ --gate <draft_gate|pre_approval_gate>
21
+ --head-sha <sha> Current head SHA or hexadecimal prefix
22
+ --findings <json> JSON array of findings in the findings-log shape
23
+ ([{severity, angle, summary, disposition?, files?}])
24
+ Output (stdout, JSON):
25
+ { "ok": true, "action": "created"|"updated"|"noop"|"skipped", ... }
26
+ Exit codes:
27
+ 0 Success
28
+ 1 Argument error or gh failure`.trim();
29
+
30
+ const VALID_SEVERITIES = new Set(["must-fix", "worth-fixing-now", "defer"]);
31
+ // Severity ordering for grouped rendering (most-blocking first).
32
+ const SEVERITY_ORDER = ["must-fix", "worth-fixing-now", "defer"];
33
+ const SEVERITY_LABELS = {
34
+ "must-fix": "Must fix",
35
+ "worth-fixing-now": "Worth fixing now",
36
+ "defer": "Defer",
37
+ };
38
+
39
+ function parseError(message) {
40
+ return Object.assign(new Error(message), { usage: USAGE });
41
+ }
42
+
43
+ function normalizeGate(value) {
44
+ const gates = new Set(["draft_gate", "pre_approval_gate"]);
45
+ const normalized = String(value).trim().toLowerCase();
46
+ return gates.has(normalized) ? normalized : null;
47
+ }
48
+
49
+ function normalizeHeadSha(value) {
50
+ const normalized = String(value).trim().toLowerCase();
51
+ return /^[0-9a-f]{7,64}$/i.test(normalized) ? normalized : null;
52
+ }
53
+
54
+ // Validate via the centralized repo-slug validator shared by sibling GitHub
55
+ // scripts (parseRepoSlug). It enforces owner/name structure and rejects unsafe
56
+ // segments (".", "..", slashes, whitespace); we re-throw as a parseError so the
57
+ // CLI usage banner is preserved.
58
+ function validateRepo(repo) {
59
+ try {
60
+ parseRepoSlug(repo);
61
+ } catch (error) {
62
+ throw parseError(error instanceof Error ? error.message : String(error));
63
+ }
64
+ return repo;
65
+ }
66
+
67
+ export function parseFindings(raw) {
68
+ let parsed;
69
+ try {
70
+ parsed = JSON.parse(raw);
71
+ } catch {
72
+ throw parseError("--findings must be valid JSON");
73
+ }
74
+ if (!Array.isArray(parsed)) {
75
+ throw parseError("--findings must be a JSON array");
76
+ }
77
+ return parsed.map((f, i) => {
78
+ if (!f || typeof f !== "object") {
79
+ throw parseError(`--findings[${i}] must be an object`);
80
+ }
81
+ if (!f.severity || !VALID_SEVERITIES.has(f.severity)) {
82
+ throw parseError(`--findings[${i}].severity must be one of: must-fix, worth-fixing-now, defer`);
83
+ }
84
+ if (!f.angle || typeof f.angle !== "string" || f.angle.trim().length === 0) {
85
+ throw parseError(`--findings[${i}].angle is required`);
86
+ }
87
+ if (!f.summary || typeof f.summary !== "string" || f.summary.trim().length === 0) {
88
+ throw parseError(`--findings[${i}].summary is required`);
89
+ }
90
+ const entry = {
91
+ severity: f.severity,
92
+ angle: f.angle.trim(),
93
+ summary: f.summary.trim(),
94
+ };
95
+ if ("disposition" in f && typeof f.disposition === "string" && f.disposition.trim().length > 0) {
96
+ entry.disposition = f.disposition.trim();
97
+ }
98
+ if (Array.isArray(f.files)) {
99
+ entry.files = f.files.filter(x => typeof x === "string" && x.trim().length > 0).map(x => x.trim());
100
+ }
101
+ return entry;
102
+ });
103
+ }
104
+
105
+ export function parsePostGateFindingsCliArgs(argv) {
106
+ const args = [...argv];
107
+ const options = {
108
+ help: false,
109
+ repo: undefined,
110
+ pr: undefined,
111
+ gate: undefined,
112
+ headSha: undefined,
113
+ findings: undefined,
114
+ };
115
+ while (args.length > 0) {
116
+ const token = args.shift();
117
+ if (token === "--help" || token === "-h") {
118
+ options.help = true;
119
+ return options;
120
+ }
121
+ if (token === "--repo") {
122
+ options.repo = validateRepo(requireOptionValue(args, "--repo", parseError).trim());
123
+ continue;
124
+ }
125
+ if (token === "--pr") {
126
+ options.pr = parsePrNumber(requireOptionValue(args, "--pr", parseError), parseError);
127
+ continue;
128
+ }
129
+ if (token === "--gate") {
130
+ const gate = normalizeGate(requireOptionValue(args, "--gate", parseError));
131
+ if (!gate) throw parseError("--gate must be draft_gate or pre_approval_gate");
132
+ options.gate = gate;
133
+ continue;
134
+ }
135
+ if (token === "--head-sha") {
136
+ const sha = normalizeHeadSha(requireOptionValue(args, "--head-sha", parseError));
137
+ if (!sha) throw parseError("--head-sha must be a 7-64 character hex SHA");
138
+ options.headSha = sha;
139
+ continue;
140
+ }
141
+ if (token === "--findings") {
142
+ options.findings = requireOptionValue(args, "--findings", parseError);
143
+ continue;
144
+ }
145
+ throw parseError(`Unknown argument: ${token}`);
146
+ }
147
+ const missing = ["repo", "pr", "gate", "headSha", "findings"]
148
+ .filter(k => options[k] === undefined);
149
+ if (missing.length > 0) {
150
+ throw parseError(`Missing required arguments: ${missing.join(", ")}`);
151
+ }
152
+ return options;
153
+ }
154
+
155
+ // Hidden marker keyed by GATE ONLY. There is exactly one findings comment per
156
+ // gate, updated in place each run. The marker deliberately does NOT include the
157
+ // head SHA: --head-sha accepts any 7-64 hex prefix, so keying on its literal
158
+ // value would let a different prefix length (or the full SHA) for the same head
159
+ // miss the marker and post a duplicate. The reviewed head is still shown in the
160
+ // comment body for context. The HTML comment is not rendered by GitHub but is
161
+ // matched on the comment body.
162
+ export function buildFindingsMarker({ gate }) {
163
+ return `<!-- dev-loops:gate-findings gate=${gate} -->`;
164
+ }
165
+
166
+ // Collapse any run of whitespace (newlines, tabs, repeated spaces) to a single
167
+ // space and trim. LLM-generated free text often carries embedded newlines, which
168
+ // would otherwise break a single Markdown list item across lines.
169
+ //
170
+ // Additionally neutralize any embedded HTML-comment delimiters (`<!--` / `-->`).
171
+ // The findings comment is keyed by a hidden marker that IS an HTML comment
172
+ // (buildFindingsMarker), and free text comes from scoped-review agents. Without
173
+ // this, a finding field could inject a second `<!-- dev-loops:gate-findings ... -->`
174
+ // marker (breaking idempotent comment matching) or otherwise smuggle an HTML
175
+ // comment into the rendered body. We escape the opening/closing angle brackets so
176
+ // the delimiter renders as visible literal text and cannot form a real comment.
177
+ function sanitizeInline(value) {
178
+ return String(value)
179
+ .replace(/\s+/g, " ")
180
+ .replace(/<!--/g, "&lt;!--")
181
+ .replace(/-->/g, "--&gt;")
182
+ .trim();
183
+ }
184
+
185
+ // Sanitize free text that is rendered INSIDE an inline backtick code span
186
+ // (`angle`, file refs). On top of sanitizeInline, strip any literal backtick:
187
+ // a backtick inside the span would prematurely close it, breaking out into raw
188
+ // Markdown (injection) for the remainder of the list item. Backticks are never
189
+ // meaningful in an angle label or a file path, so dropping them is safe.
190
+ function sanitizeCodeSpan(value) {
191
+ return sanitizeInline(String(value).replace(/`/g, ""));
192
+ }
193
+
194
+ export function renderFindingsCommentBody({ gate, headSha, findings }) {
195
+ const marker = buildFindingsMarker({ gate });
196
+ const lines = [
197
+ marker,
198
+ `### Gate fan-out findings: ${gate}`,
199
+ "",
200
+ // Plain text head SHA (no backticks) so GitHub autolinks the commit.
201
+ `Reviewed head: ${headSha}`,
202
+ "",
203
+ ];
204
+ if (findings.length === 0) {
205
+ lines.push("No findings. All review angles passed for this head.");
206
+ return lines.join("\n");
207
+ }
208
+ const grouped = new Map();
209
+ for (const sev of SEVERITY_ORDER) {
210
+ grouped.set(sev, []);
211
+ }
212
+ for (const finding of findings) {
213
+ grouped.get(finding.severity).push(finding);
214
+ }
215
+ for (const sev of SEVERITY_ORDER) {
216
+ const group = grouped.get(sev);
217
+ if (group.length === 0) continue;
218
+ lines.push(`#### ${SEVERITY_LABELS[sev]} (${group.length})`);
219
+ for (const finding of group) {
220
+ // Sanitize free-text fields so embedded newlines/whitespace don't break
221
+ // the single-line Markdown list item.
222
+ const summary = sanitizeInline(finding.summary);
223
+ const dispositionSuffix = finding.disposition ? ` — _${sanitizeInline(finding.disposition)}_` : "";
224
+ // angle is a code/label literal → backticks; summary is prose. angle is
225
+ // free text from a scoped-review agent and is rendered inside an inline
226
+ // code span, so it must be sanitized too: an embedded backtick or newline
227
+ // would otherwise break the code span (markdown injection) or split the
228
+ // list item. Use sanitizeCodeSpan (backtick-stripping) since it lives
229
+ // inside backticks, consistent with the file refs below.
230
+ const angle = sanitizeCodeSpan(finding.angle);
231
+ lines.push(`- \`${angle}\`: ${summary}${dispositionSuffix}`);
232
+ if (Array.isArray(finding.files) && finding.files.length > 0) {
233
+ // File refs go inside backticks; sanitize each so embedded whitespace,
234
+ // newlines, or backticks can't break the single Markdown list item /
235
+ // code span, and drop any that sanitize to empty.
236
+ const refs = finding.files
237
+ .map(f => sanitizeCodeSpan(f))
238
+ .filter(f => f.length > 0)
239
+ .map(f => `\`${f}\``)
240
+ .join(", ");
241
+ if (refs.length > 0) {
242
+ lines.push(` - files: ${refs}`);
243
+ }
244
+ }
245
+ }
246
+ lines.push("");
247
+ }
248
+ // Drop trailing blank line.
249
+ while (lines.length > 0 && lines[lines.length - 1] === "") {
250
+ lines.pop();
251
+ }
252
+ return lines.join("\n");
253
+ }
254
+
255
+ async function runGhJson(args, { env, ghCommand }) {
256
+ const result = await runChild(ghCommand, args, env);
257
+ if (result.code !== 0) {
258
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
259
+ throw new Error(`gh command failed: ${detail}`);
260
+ }
261
+ return parseJsonText(result.stdout, { label: `gh ${args.slice(0, 3).join(" ")}` });
262
+ }
263
+
264
+ async function listIssueComments({ repo, pr }, { env, ghCommand }) {
265
+ const payload = await runGhJson(
266
+ ["api", "--paginate", "--slurp", `repos/${repo}/issues/${pr}/comments?per_page=100`],
267
+ { env, ghCommand },
268
+ );
269
+ // --slurp returns an array of pages; flatten to a single comment list.
270
+ if (Array.isArray(payload) && payload.every(p => Array.isArray(p))) {
271
+ return payload.flat();
272
+ }
273
+ return Array.isArray(payload) ? payload : [];
274
+ }
275
+
276
+ function findMarkedComment(comments, marker) {
277
+ for (const comment of comments) {
278
+ if (comment && typeof comment.body === "string" && comment.body.includes(marker)) {
279
+ return comment;
280
+ }
281
+ }
282
+ return null;
283
+ }
284
+
285
+ function parseCommentMutationResponse(payload) {
286
+ const commentId = Number.isInteger(payload?.id) ? payload.id : null;
287
+ const commentUrl = typeof payload?.html_url === "string" && payload.html_url.trim().length > 0
288
+ ? payload.html_url.trim()
289
+ : null;
290
+ if (commentId === null || commentUrl === null) {
291
+ throw new Error("Gate findings comment mutation did not return a comment id and html_url");
292
+ }
293
+ return { commentId, commentUrl };
294
+ }
295
+
296
+ async function createComment({ repo, pr, body }, { env, ghCommand }) {
297
+ const payload = await runGhJson(
298
+ ["api", `repos/${repo}/issues/${pr}/comments`, "-f", `body=${body}`],
299
+ { env, ghCommand },
300
+ );
301
+ return parseCommentMutationResponse(payload);
302
+ }
303
+
304
+ async function updateComment({ repo, commentId, body }, { env, ghCommand }) {
305
+ const payload = await runGhJson(
306
+ ["api", "-X", "PATCH", `repos/${repo}/issues/comments/${commentId}`, "-f", `body=${body}`],
307
+ { env, ghCommand },
308
+ );
309
+ return parseCommentMutationResponse(payload);
310
+ }
311
+
312
+ export async function postGateFindings(options, { env = process.env, ghCommand = "gh", repoRoot = process.cwd() } = {}) {
313
+ const findings = parseFindings(options.findings);
314
+ // loadDevLoopConfig never throws: it returns { config, warnings, errors }.
315
+ // A non-empty errors array means the config could not be loaded/validated, so
316
+ // log it (stderr) and fall back to default behavior (config-unavailable →
317
+ // null → resolveGatePostFindingsComments defaults on → proceed to post),
318
+ // rather than trusting a malformed/partial config object. Mirrors how
319
+ // detect-checkpoint-evidence treats config-unavailable.
320
+ const { config: loadedConfig, errors: configErrors } = await loadDevLoopConfig({ repoRoot });
321
+ let config = loadedConfig;
322
+ if (Array.isArray(configErrors) && configErrors.length > 0) {
323
+ process.stderr.write(
324
+ `post-gate-findings: dev-loop config could not be loaded/validated; using default behavior. errors=${JSON.stringify(configErrors)}\n`,
325
+ );
326
+ config = null;
327
+ }
328
+ if (!resolveGatePostFindingsComments(config)) {
329
+ return {
330
+ ok: true,
331
+ action: "skipped",
332
+ reason: "gates.postFindingsComments is false",
333
+ repo: options.repo,
334
+ pr: options.pr,
335
+ gate: options.gate,
336
+ headSha: options.headSha,
337
+ findingsCount: findings.length,
338
+ };
339
+ }
340
+ const marker = buildFindingsMarker({ gate: options.gate });
341
+ const desiredBody = renderFindingsCommentBody({ gate: options.gate, headSha: options.headSha, findings });
342
+ const comments = await listIssueComments({ repo: options.repo, pr: options.pr }, { env, ghCommand });
343
+ const existing = findMarkedComment(comments, marker);
344
+ const base = {
345
+ ok: true,
346
+ repo: options.repo,
347
+ pr: options.pr,
348
+ gate: options.gate,
349
+ headSha: options.headSha,
350
+ findingsCount: findings.length,
351
+ };
352
+ if (existing) {
353
+ if (typeof existing.body === "string" && existing.body === desiredBody) {
354
+ return {
355
+ ...base,
356
+ action: "noop",
357
+ commentId: Number.isInteger(existing.id) ? existing.id : null,
358
+ commentUrl: typeof existing.html_url === "string" ? existing.html_url : null,
359
+ };
360
+ }
361
+ const updated = await updateComment({ repo: options.repo, commentId: existing.id, body: desiredBody }, { env, ghCommand });
362
+ return { ...base, action: "updated", ...updated };
363
+ }
364
+ const created = await createComment({ repo: options.repo, pr: options.pr, body: desiredBody }, { env, ghCommand });
365
+ return { ...base, action: "created", ...created };
366
+ }
367
+
368
+ async function main() {
369
+ let options;
370
+ try {
371
+ options = parsePostGateFindingsCliArgs(process.argv.slice(2));
372
+ } catch (error) {
373
+ process.stderr.write(`${formatCliError(error, { usage: USAGE })}\n`);
374
+ process.exitCode = 1;
375
+ return;
376
+ }
377
+ if (options.help) {
378
+ process.stdout.write(`${USAGE}\n`);
379
+ return;
380
+ }
381
+ try {
382
+ const result = await postGateFindings(options);
383
+ process.stdout.write(`${JSON.stringify(result)}\n`);
384
+ } catch (error) {
385
+ process.stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
386
+ process.exitCode = 1;
387
+ }
388
+ }
389
+
390
+ if (isDirectCliRun(import.meta.url)) {
391
+ await main();
392
+ }
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { setTimeout as delay } from "node:timers/promises";
3
3
  import { buildParseError, formatCliError, isCopilotLogin, isDirectCliRun, parseJsonText, parseReviewThreads } from "../_core-helpers.mjs";
4
- import { parsePositiveInteger, requireOptionValue, runChild } from "../_cli-primitives.mjs";
4
+ import { parseArgs } from "node:util";
5
+ import { parsePositiveInteger, requireTokenValue, runChild } from "../_cli-primitives.mjs";
5
6
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
7
  import {
7
8
  DEFAULT_POLL_INTERVAL_MS,
@@ -88,7 +89,13 @@ function rejectRemovedFlag(token) {
88
89
  );
89
90
  }
90
91
  export function parseWatchCliArgs(argv) {
91
- const args = [...argv];
92
+ const { tokens } = parseArgs({
93
+ args: [...argv],
94
+ options: { help: { type: "boolean", short: "h" }, repo: { type: "string" }, pr: { type: "string" } },
95
+ allowPositionals: true,
96
+ strict: false,
97
+ tokens: true,
98
+ });
92
99
  const options = {
93
100
  help: false,
94
101
  repo: undefined,
@@ -96,24 +103,29 @@ export function parseWatchCliArgs(argv) {
96
103
  pollIntervalMs: DEFAULT_POLL_INTERVAL_MS,
97
104
  timeoutMs: COPILOT_REVIEW_WAIT_TIMEOUT_MS,
98
105
  };
99
- while (args.length > 0) {
100
- const token = args.shift();
101
- if (token === "--help" || token === "-h") {
106
+ for (const token of tokens) {
107
+ if (token.kind === "positional") {
108
+ throw parseError(`Unknown argument: ${token.value}`);
109
+ }
110
+ if (token.kind !== "option") {
111
+ continue;
112
+ }
113
+ if (token.name === "help") {
102
114
  options.help = true;
103
115
  return options;
104
116
  }
105
- if (REMOVED_FLAGS.has(token)) {
106
- rejectRemovedFlag(token);
117
+ if (REMOVED_FLAGS.has(token.rawName)) {
118
+ rejectRemovedFlag(token.rawName);
107
119
  }
108
- if (token === "--repo") {
109
- options.repo = requireOptionValue(args, "--repo", parseError).trim();
120
+ if (token.name === "repo") {
121
+ options.repo = requireTokenValue(token, parseError).trim();
110
122
  continue;
111
123
  }
112
- if (token === "--pr") {
113
- options.pr = parsePositiveInteger(requireOptionValue(args, "--pr", parseError), "--pr", parseError);
124
+ if (token.name === "pr") {
125
+ options.pr = parsePositiveInteger(requireTokenValue(token, parseError), "--pr", parseError);
114
126
  continue;
115
127
  }
116
- throw parseError(`Unknown argument: ${token}`);
128
+ throw parseError(`Unknown argument: ${token.rawName}`);
117
129
  }
118
130
  if (options.repo === undefined || options.pr === undefined) {
119
131
  throw parseError("Watching Copilot review requires both --repo <owner/name> and --pr <number>");
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
2
3
  import { buildParseError, formatCliError, isDirectCliRun, parseJsonText, summarizeGateReviewComments, summarizeGateReviewCommentMarkers } from "../_core-helpers.mjs";
3
- import { parsePrNumber, requireOptionValue, runChild } from "../_cli-primitives.mjs";
4
+ import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
4
5
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
5
6
  import { loadDevLoopConfig, resolveGateConfig } from "@dev-loops/core/config";
6
7
  import { findBlockingTitleMarkers } from "@dev-loops/core/loop/pr-title-markers";
@@ -10,13 +11,21 @@ const parseError = buildParseError(USAGE);
10
11
  const PR_VIEW_QUERY = `query($owner:String!, $name:String!, $number:Int!) { repository(owner:$owner, name:$name) { pullRequest(number:$number) { id, isDraft, headRefOid, state, mergeStateStatus, title } } }`;
11
12
 
12
13
  export function parseReadyForReviewCliArgs(argv) {
13
- const args = [...argv], opts = { help: false, repo: undefined, pr: undefined };
14
- while (args.length > 0) {
15
- const token = args.shift();
16
- if (token === "--help" || token === "-h") { opts.help = true; return opts; }
17
- if (token === "--repo") { opts.repo = requireOptionValue(args, "--repo", parseError).trim(); continue; }
18
- if (token === "--pr") { opts.pr = parsePrNumber(requireOptionValue(args, "--pr", parseError), parseError); continue; }
19
- throw parseError(`Unknown argument: ${token}`);
14
+ const { tokens } = parseArgs({
15
+ args: [...argv],
16
+ options: { help: { type: "boolean", short: "h" }, repo: { type: "string" }, pr: { type: "string" } },
17
+ allowPositionals: true,
18
+ strict: false,
19
+ tokens: true,
20
+ });
21
+ const opts = { help: false, repo: undefined, pr: undefined };
22
+ for (const token of tokens) {
23
+ if (token.kind === "positional") throw parseError(`Unknown argument: ${token.value}`);
24
+ if (token.kind !== "option") continue;
25
+ if (token.name === "help") { opts.help = true; return opts; }
26
+ if (token.name === "repo") { opts.repo = requireTokenValue(token, parseError).trim(); continue; }
27
+ if (token.name === "pr") { opts.pr = parsePrNumber(requireTokenValue(token, parseError), parseError); continue; }
28
+ throw parseError(`Unknown argument: ${token.rawName}`);
20
29
  }
21
30
  if (!opts.repo || opts.pr === undefined) throw parseError("ready-for-review requires --repo and --pr");
22
31
  parseRepoSlug(opts.repo);
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
2
3
  import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
3
- import { parsePrNumber, requireOptionValue, runChild } from "../_cli-primitives.mjs";
4
+ import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
4
5
  import { loadDevLoopConfig, resolveGateConfig } from "@dev-loops/core/config";
5
6
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
7
  import { detectCheckpointEvidence } from "./detect-checkpoint-evidence.mjs";
@@ -36,27 +37,38 @@ Exit codes:
36
37
  1 Argument error, gh failure, or unrecoverable state`.trim();
37
38
  const parseError = buildParseError(USAGE);
38
39
  export function parseReconcileDraftGateCliArgs(argv) {
39
- const args = [...argv];
40
+ const { tokens } = parseArgs({
41
+ args: [...argv],
42
+ options: { help: { type: "boolean", short: "h" }, repo: { type: "string" }, pr: { type: "string" } },
43
+ allowPositionals: true,
44
+ strict: false,
45
+ tokens: true,
46
+ });
40
47
  const options = {
41
48
  help: false,
42
49
  repo: undefined,
43
50
  pr: undefined,
44
51
  };
45
- while (args.length > 0) {
46
- const token = args.shift();
47
- if (token === "--help" || token === "-h") {
52
+ for (const token of tokens) {
53
+ if (token.kind === "positional") {
54
+ throw parseError(`Unknown argument: ${token.value}`);
55
+ }
56
+ if (token.kind !== "option") {
57
+ continue;
58
+ }
59
+ if (token.name === "help") {
48
60
  options.help = true;
49
61
  return options;
50
62
  }
51
- if (token === "--repo") {
52
- options.repo = requireOptionValue(args, "--repo", parseError).trim();
63
+ if (token.name === "repo") {
64
+ options.repo = requireTokenValue(token, parseError).trim();
53
65
  continue;
54
66
  }
55
- if (token === "--pr") {
56
- options.pr = parsePrNumber(requireOptionValue(args, "--pr", parseError), parseError);
67
+ if (token.name === "pr") {
68
+ options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
57
69
  continue;
58
70
  }
59
- throw parseError(`Unknown argument: ${token}`);
71
+ throw parseError(`Unknown argument: ${token.rawName}`);
60
72
  }
61
73
  const missing = ["repo", "pr"].filter((key) => options[key] === undefined);
62
74
  if (missing.length > 0) {
@@ -112,7 +124,7 @@ async function resolvePrNodeId({ repo, pr }, { env, ghCommand }) {
112
124
  }
113
125
  return { id: prData.id, isDraft: prData.isDraft };
114
126
  }
115
- async function convertPrToDraft({ repo, pr }, { env, ghCommand }) {
127
+ export async function convertPrToDraft({ repo, pr }, { env, ghCommand }) {
116
128
  const resolvedPr = await resolvePrNodeId({ repo, pr }, { env, ghCommand });
117
129
  if (resolvedPr.isDraft === true) {
118
130
  return {
@@ -142,7 +154,7 @@ async function convertPrToDraft({ repo, pr }, { env, ghCommand }) {
142
154
  alreadyDraft: false,
143
155
  };
144
156
  }
145
- async function markPrReady({ repo, pr }, { env, ghCommand }) {
157
+ export async function markPrReady({ repo, pr }, { env, ghCommand }) {
146
158
  const result = await runChild(ghCommand, [
147
159
  "pr", "ready", String(pr),
148
160
  "--repo", repo,
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
2
3
  import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
3
4
  import {
4
5
  parsePrNumber,
5
- requireOptionValue,
6
+ requireTokenValue,
6
7
  } from "../_cli-primitives.mjs";
7
8
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
8
9
  import {
@@ -34,7 +35,20 @@ Exit codes:
34
35
  1 Argument error or gh/runtime failure`.trim();
35
36
  const parseError = buildParseError(USAGE);
36
37
  export function parseReplyResolveThreadsCliArgs(argv) {
37
- const args = [...argv];
38
+ const { tokens } = parseArgs({
39
+ args: [...argv],
40
+ options: {
41
+ help: { type: "boolean", short: "h" },
42
+ repo: { type: "string" },
43
+ pr: { type: "string" },
44
+ author: { type: "string" },
45
+ message: { type: "string" },
46
+ resolve: { type: "boolean" },
47
+ },
48
+ allowPositionals: true,
49
+ strict: false,
50
+ tokens: true,
51
+ });
38
52
  const options = {
39
53
  help: false,
40
54
  repo: undefined,
@@ -43,33 +57,38 @@ export function parseReplyResolveThreadsCliArgs(argv) {
43
57
  message: undefined,
44
58
  resolve: false,
45
59
  };
46
- while (args.length > 0) {
47
- const token = args.shift();
48
- if (token === "--help" || token === "-h") {
60
+ for (const token of tokens) {
61
+ if (token.kind === "positional") {
62
+ throw parseError(`Unknown argument: ${token.value}`);
63
+ }
64
+ if (token.kind !== "option") {
65
+ continue;
66
+ }
67
+ if (token.name === "help") {
49
68
  options.help = true;
50
69
  return options;
51
70
  }
52
- if (token === "--repo") {
53
- options.repo = requireOptionValue(args, "--repo", parseError).trim();
71
+ if (token.name === "repo") {
72
+ options.repo = requireTokenValue(token, parseError).trim();
54
73
  continue;
55
74
  }
56
- if (token === "--pr") {
57
- options.pr = parsePrNumber(requireOptionValue(args, "--pr", parseError), parseError);
75
+ if (token.name === "pr") {
76
+ options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
58
77
  continue;
59
78
  }
60
- if (token === "--author") {
61
- options.author = requireOptionValue(args, "--author", parseError).trim();
79
+ if (token.name === "author") {
80
+ options.author = requireTokenValue(token, parseError).trim();
62
81
  continue;
63
82
  }
64
- if (token === "--message") {
65
- options.message = requireOptionValue(args, "--message", parseError);
83
+ if (token.name === "message") {
84
+ options.message = requireTokenValue(token, parseError);
66
85
  continue;
67
86
  }
68
- if (token === "--resolve") {
87
+ if (token.name === "resolve") {
69
88
  options.resolve = true;
70
89
  continue;
71
90
  }
72
- throw parseError(`Unknown argument: ${token}`);
91
+ throw parseError(`Unknown argument: ${token.rawName}`);
73
92
  }
74
93
  if (options.repo === undefined || options.pr === undefined) {
75
94
  throw parseError("Replying and resolving review threads requires both --repo <owner/name> and --pr <number>");