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
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from "node:fs/promises";
3
+ import { readFileSync } from "node:fs";
4
+ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
5
+ import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
6
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
7
+ import { parseArgs } from "node:util";
8
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
9
+
10
+ const USAGE = `Usage: edit-pr.mjs --repo <owner/name> --pr <number> [--title <t>] [--body <b> | --body-file <path>] [--add-assignee <u>] [--remove-assignee <u>] [--milestone <m>]
11
+ Edit PR title/body/assignees/milestone. Thin wrapper over \`gh pr edit\` — use this
12
+ instead of an agent-level raw \`gh pr edit\` so the loop's internal-tooling record
13
+ stays clean (siblings: view-pr.mjs, comment-issue.mjs; #1057).
14
+ Required:
15
+ --repo <owner/name> Repository slug (e.g. owner/repo)
16
+ --pr <number> Pull request number
17
+ At least one edit:
18
+ --title <t> New PR title
19
+ --body <b> New PR body as a single argument
20
+ --body-file <path> Read the new body from a file (- reads stdin)
21
+ --add-assignee <u> Assignee to add (repeatable)
22
+ --remove-assignee <u> Assignee to remove (repeatable)
23
+ --milestone <m> Milestone to set (empty string clears it)
24
+ (--title/--body/--body-file reject empty or
25
+ whitespace-only values; use --milestone "" only
26
+ to clear the milestone)
27
+ Output (stdout, JSON):
28
+ { "ok": true, "repo": "owner/repo", "pr": 17, "edited": ["title", "body", ...] }
29
+ Error output (stderr, JSON):
30
+ { "ok": false, "error": "...", "usage"?: "..." }
31
+ ${JQ_OUTPUT_USAGE}
32
+ Exit codes:
33
+ 0 Success
34
+ 1 Argument error or gh failure
35
+ 2 Invalid --jq filter`.trim();
36
+ const parseError = buildParseError(USAGE);
37
+
38
+ export function parseEditPrCliArgs(argv) {
39
+ const { tokens } = parseArgs({
40
+ args: [...argv],
41
+ options: {
42
+ help: { type: "boolean", short: "h" },
43
+ repo: { type: "string" },
44
+ pr: { type: "string" },
45
+ title: { type: "string" },
46
+ body: { type: "string" },
47
+ "body-file": { type: "string" },
48
+ "add-assignee": { type: "string", multiple: true },
49
+ "remove-assignee": { type: "string", multiple: true },
50
+ milestone: { type: "string" },
51
+ ...JQ_OUTPUT_PARSE_OPTIONS,
52
+ },
53
+ allowPositionals: true,
54
+ strict: false,
55
+ tokens: true,
56
+ });
57
+ const options = {
58
+ help: false,
59
+ repo: undefined,
60
+ pr: undefined,
61
+ title: undefined,
62
+ body: undefined,
63
+ bodyFile: undefined,
64
+ addAssignees: [],
65
+ removeAssignees: [],
66
+ milestone: undefined,
67
+ jq: undefined,
68
+ silent: false,
69
+ };
70
+ for (const token of tokens) {
71
+ if (token.kind === "positional") {
72
+ throw parseError(`Unknown argument: ${token.value}`);
73
+ }
74
+ if (token.kind !== "option") {
75
+ continue;
76
+ }
77
+ if (token.name === "help") {
78
+ options.help = true;
79
+ return options;
80
+ }
81
+ if (token.name === "repo") {
82
+ options.repo = requireTokenValue(token, parseError).trim();
83
+ continue;
84
+ }
85
+ if (token.name === "pr") {
86
+ options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
87
+ continue;
88
+ }
89
+ if (token.name === "title") {
90
+ const title = requireTokenValue(token, parseError);
91
+ if (title.trim().length === 0) {
92
+ throw parseError("--title must not be empty or whitespace-only");
93
+ }
94
+ options.title = title;
95
+ continue;
96
+ }
97
+ if (token.name === "body") {
98
+ const body = requireTokenValue(token, parseError);
99
+ if (body.trim().length === 0) {
100
+ throw parseError("--body must not be empty or whitespace-only");
101
+ }
102
+ options.body = body;
103
+ continue;
104
+ }
105
+ if (token.name === "body-file") {
106
+ const rawPath = requireTokenValue(token, parseError).trim();
107
+ if (rawPath.length === 0) {
108
+ throw parseError("--body-file must be a non-empty path");
109
+ }
110
+ options.bodyFile = rawPath;
111
+ continue;
112
+ }
113
+ if (token.name === "add-assignee") {
114
+ const u = requireTokenValue(token, parseError).trim();
115
+ if (u.length === 0) throw parseError("--add-assignee must be a non-empty login");
116
+ options.addAssignees.push(u);
117
+ continue;
118
+ }
119
+ if (token.name === "remove-assignee") {
120
+ const u = requireTokenValue(token, parseError).trim();
121
+ if (u.length === 0) throw parseError("--remove-assignee must be a non-empty login");
122
+ options.removeAssignees.push(u);
123
+ continue;
124
+ }
125
+ if (token.name === "milestone") {
126
+ // Read the raw token value: an empty string is a valid milestone value
127
+ // (`gh pr edit --milestone ""` clears it), so this deliberately does NOT
128
+ // go through requireTokenValue (which rejects empty). Guard only a truly
129
+ // missing value (`--milestone` with no following token). A whitespace-only
130
+ // value is neither a clear nor a real milestone name — fail closed rather
131
+ // than forwarding it to gh for a less actionable error.
132
+ if (typeof token.value !== "string") {
133
+ throw parseError("--milestone requires a value (use an empty string to clear)");
134
+ }
135
+ if (token.value.length > 0 && token.value.trim().length === 0) {
136
+ throw parseError('--milestone must be a milestone name or "" to clear (whitespace-only is not allowed)');
137
+ }
138
+ options.milestone = token.value;
139
+ continue;
140
+ }
141
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
142
+ throw parseError(`Unknown argument: ${token.rawName}`);
143
+ }
144
+ if (options.repo === undefined || options.pr === undefined) {
145
+ throw parseError("Editing a PR requires both --repo <owner/name> and --pr <number>");
146
+ }
147
+ if (options.body !== undefined && options.bodyFile !== undefined) {
148
+ throw parseError("--body and --body-file are mutually exclusive; pass only one");
149
+ }
150
+ const hasEdit =
151
+ options.title !== undefined ||
152
+ options.body !== undefined ||
153
+ options.bodyFile !== undefined ||
154
+ options.addAssignees.length > 0 ||
155
+ options.removeAssignees.length > 0 ||
156
+ options.milestone !== undefined;
157
+ if (!hasEdit) {
158
+ throw parseError("Editing a PR requires at least one of --title/--body/--body-file/--add-assignee/--remove-assignee/--milestone");
159
+ }
160
+ try {
161
+ parseRepoSlug(options.repo);
162
+ } catch (error) {
163
+ throw parseError(error instanceof Error ? error.message : String(error));
164
+ }
165
+ return options;
166
+ }
167
+
168
+ async function resolveBody(options) {
169
+ if (options.bodyFile === undefined) return options.body;
170
+ // Stdin (fd 0): the fs/promises readFile does NOT accept an integer fd, so read
171
+ // it synchronously via the callback-style API (which does). A real path stays on
172
+ // the async promise read.
173
+ const body =
174
+ options.bodyFile === "-" ? readFileSync(0, "utf8") : await readFile(options.bodyFile, "utf8");
175
+ // Fail closed on an empty / whitespace-only file so a blank --body-file cannot
176
+ // silently clear the PR body (USAGE promises --body/--title reject empties).
177
+ if (body.trim().length === 0) {
178
+ throw new Error(`--body-file ${options.bodyFile} is empty`);
179
+ }
180
+ return body;
181
+ }
182
+
183
+ // Build the `gh pr edit` args and the parallel `edited` list (which fields were
184
+ // touched) so callers get a stable summary without re-reading the PR.
185
+ async function buildEditArgs(options) {
186
+ const args = ["pr", "edit", String(options.pr), "--repo", options.repo];
187
+ const edited = [];
188
+ if (options.title !== undefined) {
189
+ args.push("--title", options.title);
190
+ edited.push("title");
191
+ }
192
+ // resolveBody still runs for validation (reads the file, throws on empty /
193
+ // whitespace-only). A REAL --body-file path is handed straight to gh so large
194
+ // bodies avoid command-length limits. But `--body-file -` (stdin) was already
195
+ // consumed by resolveBody reading fd 0; re-emitting `--body-file -` makes gh
196
+ // re-read an exhausted stdin and clear the body, so pass the resolved string
197
+ // inline via --body instead.
198
+ const body = await resolveBody(options);
199
+ if (body !== undefined) {
200
+ if (options.bodyFile !== undefined && options.bodyFile !== "-") {
201
+ args.push("--body-file", options.bodyFile);
202
+ } else {
203
+ args.push("--body", body);
204
+ }
205
+ edited.push("body");
206
+ }
207
+ for (const u of options.addAssignees) {
208
+ args.push("--add-assignee", u);
209
+ }
210
+ if (options.addAssignees.length > 0) edited.push("add-assignee");
211
+ for (const u of options.removeAssignees) {
212
+ args.push("--remove-assignee", u);
213
+ }
214
+ if (options.removeAssignees.length > 0) edited.push("remove-assignee");
215
+ if (options.milestone !== undefined) {
216
+ args.push("--milestone", options.milestone);
217
+ edited.push("milestone");
218
+ }
219
+ return { args, edited };
220
+ }
221
+
222
+ export async function editPr(options, { env = process.env, ghCommand = "gh", run = runChild } = {}) {
223
+ const { args, edited } = await buildEditArgs(options);
224
+ const result = await run(ghCommand, args, env);
225
+ if (result.code !== 0) {
226
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
227
+ throw new Error(`gh pr edit failed: ${detail}`);
228
+ }
229
+ return { ok: true, repo: options.repo, pr: options.pr, edited };
230
+ }
231
+
232
+ export async function runCli(
233
+ argv = process.argv.slice(2),
234
+ { stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh", run = runChild } = {},
235
+ ) {
236
+ let options;
237
+ try {
238
+ options = parseEditPrCliArgs(argv);
239
+ } catch (error) {
240
+ stderr.write(`${formatCliError(error)}\n`);
241
+ return 1;
242
+ }
243
+ if (options.help) {
244
+ stdout.write(`${USAGE}\n`);
245
+ return 0;
246
+ }
247
+ let result;
248
+ try {
249
+ result = await editPr(options, { env, ghCommand, run });
250
+ } catch (error) {
251
+ stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
252
+ return 1;
253
+ }
254
+ return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
255
+ }
256
+
257
+ if (isDirectCliRun(import.meta.url)) {
258
+ runCli().then((code) => { process.exitCode = code; });
259
+ }
@@ -3,7 +3,7 @@ import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "
3
3
  import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
4
4
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
5
5
  import { parseArgs } from "node:util";
6
- import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
6
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
7
7
 
8
8
  const DEFAULT_TAIL_LINES = 200;
9
9
 
@@ -91,14 +91,7 @@ export function parseFetchCiLogsCliArgs(argv) {
91
91
  options.tail = value;
92
92
  continue;
93
93
  }
94
- if (token.name === "jq") {
95
- options.jq = requireTokenValue(token, parseError);
96
- continue;
97
- }
98
- if (token.name === "silent") {
99
- options.silent = true;
100
- continue;
101
- }
94
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
102
95
  throw parseError(`Unknown argument: ${token.rawName}`);
103
96
  }
104
97
  if (options.repo === undefined || options.pr === undefined) {
@@ -3,7 +3,7 @@ import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "
3
3
  import { requireTokenValue, runChild } from "../_cli-primitives.mjs";
4
4
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
5
5
  import { parseArgs } from "node:util";
6
- import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
6
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
7
7
 
8
8
  const STATES = new Set(["open", "closed", "all"]);
9
9
 
@@ -93,14 +93,7 @@ export function parseListIssuesCliArgs(argv) {
93
93
  options.limit = value;
94
94
  continue;
95
95
  }
96
- if (token.name === "jq") {
97
- options.jq = requireTokenValue(token, parseError);
98
- continue;
99
- }
100
- if (token.name === "silent") {
101
- options.silent = true;
102
- continue;
103
- }
96
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
104
97
  throw parseError(`Unknown argument: ${token.rawName}`);
105
98
  }
106
99
  if (options.repo === undefined) {
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
4
+ import { parsePrNumber, requireTokenValue, runChild as defaultRunChild } from "../_cli-primitives.mjs";
5
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
+ import { authorMatchesFilter } from "./_review-thread-mutations.mjs";
7
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
8
+
9
+ // Dedicated query: unlike capture-review-threads.mjs's REVIEW_THREADS_QUERY
10
+ // (all comments, no path/line/isOutdated, no pagination past 100), this tool
11
+ // needs only the FIRST comment per thread (the reply-resolve target) plus the
12
+ // thread-level path/line/isOutdated fields, and must paginate past 100 threads.
13
+ export const LIST_REVIEW_THREADS_QUERY = [
14
+ "query($owner: String!, $name: String!, $pr: Int!, $after: String) {",
15
+ " repository(owner: $owner, name: $name) {",
16
+ " pullRequest(number: $pr) {",
17
+ " reviewThreads(first: 100, after: $after) {",
18
+ " pageInfo {",
19
+ " hasNextPage",
20
+ " endCursor",
21
+ " }",
22
+ " nodes {",
23
+ " id",
24
+ " isResolved",
25
+ " isOutdated",
26
+ " path",
27
+ " line",
28
+ " comments(first: 1) {",
29
+ " nodes {",
30
+ " databaseId",
31
+ " body",
32
+ " author {",
33
+ " login",
34
+ " }",
35
+ " }",
36
+ " }",
37
+ " }",
38
+ " }",
39
+ " }",
40
+ " }",
41
+ "}",
42
+ ].join("\n");
43
+
44
+ // Bounded excerpt so a listing over many threads stays scannable; full body
45
+ // text is available from capture-review-threads.mjs when actually needed.
46
+ const BODY_EXCERPT_MAX_CHARS = 200;
47
+
48
+ const USAGE = `Usage: list-review-threads.mjs --repo <owner/name> --pr <number> [--unresolved-only] [--author <login>]
49
+ List review threads on a pull request with the thread id + first-comment
50
+ databaseId reply-resolve-review-thread.mjs needs, so a fixer pass can go
51
+ list -> reply-resolve without hand-written GraphQL.
52
+ Required:
53
+ --repo <owner/name> Repository slug (e.g. owner/repo)
54
+ --pr <number> Pull request number
55
+ Optional:
56
+ --unresolved-only Only include threads where isResolved is false
57
+ --author <login> Only include threads whose first comment author matches
58
+ (case-insensitive; "copilot" matches any Copilot review
59
+ login variant; "all" matches every author)
60
+ Output (stdout, JSON):
61
+ { "ok": true, "repo": "owner/name", "pr": N, "threads": [
62
+ { "threadId": "...", "commentId": N|null, "author": "..."|null,
63
+ "body": "...", "isResolved": bool, "isOutdated": bool,
64
+ "path": "..."|null, "line": N|null }, ...
65
+ ] }
66
+ Error output (stderr, JSON):
67
+ Argument/usage errors:
68
+ { "ok": false, "error": "...", "usage": "..." }
69
+ gh/runtime failures:
70
+ { "ok": false, "error": "..." }
71
+ ${JQ_OUTPUT_USAGE}
72
+ Exit codes:
73
+ 0 Success
74
+ 1 Argument error or gh failure
75
+ 2 Invalid --jq filter`.trim();
76
+
77
+ const parseError = buildParseError(USAGE);
78
+
79
+ export function parseListReviewThreadsCliArgs(argv) {
80
+ const { tokens } = parseArgs({
81
+ args: [...argv],
82
+ options: {
83
+ help: { type: "boolean", short: "h" },
84
+ repo: { type: "string" },
85
+ pr: { type: "string" },
86
+ "unresolved-only": { type: "boolean" },
87
+ author: { type: "string" },
88
+ ...JQ_OUTPUT_PARSE_OPTIONS,
89
+ },
90
+ allowPositionals: true,
91
+ strict: false,
92
+ tokens: true,
93
+ });
94
+ const options = {
95
+ help: false,
96
+ repo: undefined,
97
+ pr: undefined,
98
+ unresolvedOnly: false,
99
+ author: undefined,
100
+ };
101
+ for (const token of tokens) {
102
+ if (token.kind === "positional") {
103
+ throw parseError(`Unknown argument: ${token.value}`);
104
+ }
105
+ if (token.kind !== "option") {
106
+ continue;
107
+ }
108
+ if (token.name === "help") {
109
+ options.help = true;
110
+ return options;
111
+ }
112
+ if (token.name === "repo") {
113
+ options.repo = requireTokenValue(token, parseError).trim();
114
+ continue;
115
+ }
116
+ if (token.name === "pr") {
117
+ options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
118
+ continue;
119
+ }
120
+ if (token.name === "unresolved-only") {
121
+ options.unresolvedOnly = true;
122
+ continue;
123
+ }
124
+ if (token.name === "author") {
125
+ options.author = requireTokenValue(token, parseError).trim();
126
+ continue;
127
+ }
128
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
129
+ throw parseError(`Unknown argument: ${token.rawName}`);
130
+ }
131
+ if (options.repo === undefined || options.pr === undefined) {
132
+ throw parseError("Listing review threads requires both --repo <owner/name> and --pr <number>");
133
+ }
134
+ try {
135
+ parseRepoSlug(options.repo);
136
+ } catch (error) {
137
+ throw parseError(error instanceof Error ? error.message : String(error));
138
+ }
139
+ return options;
140
+ }
141
+
142
+ function buildQueryArgs({ owner, name, pr, after }) {
143
+ const args = [
144
+ "api",
145
+ "graphql",
146
+ "--field",
147
+ `owner=${owner}`,
148
+ "--field",
149
+ `name=${name}`,
150
+ "--field",
151
+ `pr=${pr}`,
152
+ "--field",
153
+ `query=${LIST_REVIEW_THREADS_QUERY}`,
154
+ ];
155
+ if (typeof after === "string" && after.length > 0) {
156
+ args.push("--field", `after=${after}`);
157
+ }
158
+ return args;
159
+ }
160
+
161
+ function readThreadsConnection(payload) {
162
+ const connection = payload?.data?.repository?.pullRequest?.reviewThreads;
163
+ if (!connection || typeof connection !== "object") {
164
+ throw new Error("Invalid review-threads GraphQL payload: missing data.repository.pullRequest.reviewThreads");
165
+ }
166
+ const nodes = Array.isArray(connection.nodes) ? connection.nodes : [];
167
+ const pageInfo = connection.pageInfo ?? {};
168
+ return {
169
+ nodes,
170
+ hasNextPage: Boolean(pageInfo.hasNextPage),
171
+ endCursor: typeof pageInfo.endCursor === "string" ? pageInfo.endCursor : null,
172
+ };
173
+ }
174
+
175
+ function excerptBody(rawBody) {
176
+ const text = typeof rawBody === "string" ? rawBody.trim() : "";
177
+ if (text.length <= BODY_EXCERPT_MAX_CHARS) {
178
+ return text;
179
+ }
180
+ return `${text.slice(0, BODY_EXCERPT_MAX_CHARS)}…`;
181
+ }
182
+
183
+ function normalizeThreadNode(node) {
184
+ const firstComment = node?.comments?.nodes?.[0] ?? null;
185
+ const commentId = Number.isFinite(firstComment?.databaseId) ? firstComment.databaseId : null;
186
+ const authorLogin = firstComment?.author?.login;
187
+ const author = typeof authorLogin === "string" && authorLogin.length > 0 ? authorLogin : null;
188
+ return {
189
+ threadId: typeof node?.id === "string" ? node.id : "",
190
+ commentId,
191
+ author,
192
+ body: excerptBody(firstComment?.body),
193
+ isResolved: Boolean(node?.isResolved),
194
+ isOutdated: Boolean(node?.isOutdated),
195
+ path: typeof node?.path === "string" ? node.path : null,
196
+ line: typeof node?.line === "number" ? node.line : null,
197
+ };
198
+ }
199
+
200
+ export async function fetchAllReviewThreads(
201
+ { repo, pr },
202
+ { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {},
203
+ ) {
204
+ const { owner, name } = parseRepoSlug(repo);
205
+ const threads = [];
206
+ let after = null;
207
+ while (true) {
208
+ const result = await runChild(ghCommand, buildQueryArgs({ owner, name, pr, after }), env);
209
+ if (result.code !== 0) {
210
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
211
+ throw new Error(`gh command failed: ${detail}`);
212
+ }
213
+ const payload = parseJsonText(result.stdout);
214
+ const { nodes, hasNextPage, endCursor } = readThreadsConnection(payload);
215
+ for (const node of nodes) {
216
+ threads.push(normalizeThreadNode(node));
217
+ }
218
+ if (!hasNextPage) {
219
+ break;
220
+ }
221
+ if (!endCursor) {
222
+ throw new Error("Invalid review-threads GraphQL payload: pageInfo.hasNextPage is true but endCursor is missing");
223
+ }
224
+ after = endCursor;
225
+ }
226
+ return threads;
227
+ }
228
+
229
+ export function filterThreads(threads, { unresolvedOnly = false, author = undefined } = {}) {
230
+ // "all" is a match-everything sentinel: bypass the author filter entirely so
231
+ // threads with a missing/ghost first-comment author (author null) still list.
232
+ // authorMatchesFilter deliberately rejects empty logins for its reply-resolve
233
+ // callers, so routing "all" through it would drop those threads.
234
+ const authorFilter = typeof author === "string" && author.trim().toLowerCase() === "all"
235
+ ? undefined
236
+ : author;
237
+ return threads.filter((thread) => {
238
+ if (unresolvedOnly && thread.isResolved) {
239
+ return false;
240
+ }
241
+ if (authorFilter !== undefined && !authorMatchesFilter(thread.author, authorFilter)) {
242
+ return false;
243
+ }
244
+ return true;
245
+ });
246
+ }
247
+
248
+ export async function runCli(
249
+ argv = process.argv.slice(2),
250
+ {
251
+ stdout = process.stdout,
252
+ stderr = process.stderr,
253
+ env = process.env,
254
+ ghCommand = "gh",
255
+ } = {},
256
+ ) {
257
+ const options = parseListReviewThreadsCliArgs(argv);
258
+ if (options.help) {
259
+ stdout.write(`${USAGE}\n`);
260
+ return 0;
261
+ }
262
+ const allThreads = await fetchAllReviewThreads({ repo: options.repo, pr: options.pr }, { env, ghCommand });
263
+ const threads = filterThreads(allThreads, { unresolvedOnly: options.unresolvedOnly, author: options.author });
264
+ const result = { ok: true, repo: options.repo, pr: options.pr, threads };
265
+ return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
266
+ }
267
+
268
+ if (isDirectCliRun(import.meta.url)) {
269
+ runCli().then((code) => {
270
+ if (typeof code === "number") {
271
+ process.exitCode = code;
272
+ }
273
+ }).catch((error) => {
274
+ process.stderr.write(`${formatCliError(error)}\n`);
275
+ process.exitCode = 1;
276
+ });
277
+ }