dev-loops 0.7.1 → 0.8.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 (107) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +7 -7
  3. package/.claude/agents/fixer.md +1 -0
  4. package/.claude/agents/refiner.md +4 -4
  5. package/.claude/agents/review.md +10 -13
  6. package/.claude/commands/loop-continue.md +2 -1
  7. package/.claude/commands/loop-enqueue.md +9 -2
  8. package/.claude/commands/loop-info.md +1 -1
  9. package/.claude/commands/loop-start-spike.md +1 -1
  10. package/.claude/hooks/_bash-command-classify.mjs +7 -6
  11. package/.claude/hooks/_hook-decisions.mjs +5 -4
  12. package/.claude/skills/copilot-pr-followup/SKILL.md +42 -28
  13. package/.claude/skills/dev-loop/SKILL.md +8 -8
  14. package/.claude/skills/docs/acceptance-criteria-verification.md +13 -4
  15. package/.claude/skills/docs/anti-patterns.md +6 -5
  16. package/.claude/skills/docs/artifact-authority-contract.md +17 -14
  17. package/.claude/skills/docs/confirmation-rules.md +2 -1
  18. package/.claude/skills/docs/contract-style-guide.md +37 -0
  19. package/.claude/skills/docs/copilot-loop-operations.md +21 -15
  20. package/.claude/skills/docs/cross-harness-regression-contract.md +2 -2
  21. package/.claude/skills/docs/debt-remediation-contract.md +1 -1
  22. package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
  23. package/.claude/skills/docs/issue-intake-procedure.md +37 -22
  24. package/.claude/skills/docs/merge-preconditions.md +17 -13
  25. package/.claude/skills/docs/pr-lifecycle-contract.md +45 -36
  26. package/.claude/skills/docs/public-dev-loop-contract.md +61 -44
  27. package/.claude/skills/docs/retrospective-checkpoint-contract.md +25 -9
  28. package/.claude/skills/docs/spike-mode-contract.md +14 -7
  29. package/.claude/skills/docs/stop-conditions.md +30 -15
  30. package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
  31. package/.claude/skills/docs/validation-policy.md +4 -5
  32. package/.claude/skills/local-implementation/SKILL.md +49 -143
  33. package/.claude/skills/loop-grill/SKILL.md +34 -14
  34. package/AGENTS.md +1 -1
  35. package/CHANGELOG.md +36 -0
  36. package/README.md +95 -189
  37. package/agents/dev-loop.agent.md +8 -8
  38. package/agents/fixer.agent.md +1 -0
  39. package/agents/refiner.agent.md +4 -4
  40. package/agents/review.agent.md +10 -13
  41. package/extension/README.md +5 -4
  42. package/package.json +7 -5
  43. package/scripts/_core-helpers.mjs +2 -0
  44. package/scripts/claude/headless-dev-loop.mjs +1 -1
  45. package/scripts/docs/validate-rule-ownership.mjs +442 -0
  46. package/scripts/docs/validate-state-machine-conformance.mjs +1169 -0
  47. package/scripts/github/_gate-names.mjs +5 -0
  48. package/scripts/github/_review-thread-mutations.mjs +5 -2
  49. package/scripts/github/capture-review-threads.mjs +2 -2
  50. package/scripts/github/create-pr.mjs +119 -10
  51. package/scripts/github/detect-checkpoint-evidence.mjs +109 -24
  52. package/scripts/github/edit-issue.mjs +259 -0
  53. package/scripts/github/list-review-threads.mjs +277 -0
  54. package/scripts/github/post-gate-findings.mjs +6 -3
  55. package/scripts/github/probe-ci-status.mjs +18 -0
  56. package/scripts/github/probe-copilot-review.mjs +24 -3
  57. package/scripts/github/reconcile-draft-gate.mjs +13 -13
  58. package/scripts/github/request-copilot-review.mjs +109 -42
  59. package/scripts/github/upsert-checkpoint-verdict.mjs +98 -27
  60. package/scripts/github/verify-briefing-prefixes.mjs +382 -0
  61. package/scripts/github/verify-fresh-review-context.mjs +78 -3
  62. package/scripts/github/wait-pr-checks.mjs +171 -0
  63. package/scripts/github/write-gate-context.mjs +220 -10
  64. package/scripts/github/write-gate-findings-log.mjs +54 -2
  65. package/scripts/loop/_post-convergence-change.mjs +2 -2
  66. package/scripts/loop/_pr-runner-coordination.mjs +112 -13
  67. package/scripts/loop/check-retro-tooling.mjs +14 -9
  68. package/scripts/loop/copilot-pr-handoff.mjs +47 -16
  69. package/scripts/loop/detect-change-scope.mjs +2 -2
  70. package/scripts/loop/detect-copilot-loop-state.mjs +34 -14
  71. package/scripts/loop/detect-internal-only-pr.mjs +6 -6
  72. package/scripts/loop/detect-pr-gate-coordination-state.mjs +152 -18
  73. package/scripts/loop/detect-refinement-grill-state.mjs +136 -0
  74. package/scripts/loop/resolve-dev-loop-startup.mjs +145 -8
  75. package/scripts/loop/run-watch-cycle.mjs +42 -7
  76. package/scripts/loop/sanctioned-commands.mjs +3 -0
  77. package/scripts/loop/validate-pr-body-spec.mjs +21 -5
  78. package/scripts/pages/build-state-atlas.mjs +65 -48
  79. package/scripts/projects/_resolve-project.mjs +1 -148
  80. package/scripts/projects/add-queue-item.mjs +87 -4
  81. package/scripts/projects/list-queue-items.mjs +3 -377
  82. package/scripts/projects/move-queue-item.mjs +3 -410
  83. package/scripts/projects/reorder-queue-item.mjs +3 -22
  84. package/skills/copilot-pr-followup/SKILL.md +42 -28
  85. package/skills/dev-loop/SKILL.md +3 -3
  86. package/skills/docs/acceptance-criteria-verification.md +13 -4
  87. package/skills/docs/anti-patterns.md +6 -5
  88. package/skills/docs/artifact-authority-contract.md +17 -14
  89. package/skills/docs/confirmation-rules.md +2 -1
  90. package/skills/docs/contract-style-guide.md +37 -0
  91. package/skills/docs/copilot-loop-operations.md +21 -15
  92. package/skills/docs/cross-harness-regression-contract.md +2 -2
  93. package/skills/docs/debt-remediation-contract.md +1 -1
  94. package/skills/docs/epic-tree-refinement-procedure.md +25 -22
  95. package/skills/docs/issue-intake-procedure.md +37 -22
  96. package/skills/docs/merge-preconditions.md +17 -13
  97. package/skills/docs/pr-lifecycle-contract.md +45 -36
  98. package/skills/docs/public-dev-loop-contract.md +61 -44
  99. package/skills/docs/required-rules.json +165 -0
  100. package/skills/docs/retrospective-checkpoint-contract.md +25 -9
  101. package/skills/docs/spike-mode-contract.md +14 -7
  102. package/skills/docs/stop-conditions.md +30 -15
  103. package/skills/docs/tracker-first-loop-state.md +13 -8
  104. package/skills/docs/validation-policy.md +4 -5
  105. package/skills/local-implementation/SKILL.md +49 -143
  106. package/skills/loop-grill/SKILL.md +38 -17
  107. package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
@@ -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 { parseIssueNumber, 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-issue.mjs --repo <owner/name> --issue <number> [--title <t>] [--body <b> | --body-file <path>] [--add-assignee <u>] [--remove-assignee <u>] [--milestone <m>]
11
+ Edit issue title/body/assignees/milestone. Thin wrapper over \`gh issue edit\` — use this
12
+ instead of an agent-level raw \`gh issue edit\` so the loop's internal-tooling record
13
+ stays clean (siblings: edit-pr.mjs, comment-issue.mjs).
14
+ Required:
15
+ --repo <owner/name> Repository slug (e.g. owner/repo)
16
+ --issue <number> Issue number
17
+ At least one edit:
18
+ --title <t> New issue title
19
+ --body <b> New issue 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", "issue": 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 parseEditIssueCliArgs(argv) {
39
+ const { tokens } = parseArgs({
40
+ args: [...argv],
41
+ options: {
42
+ help: { type: "boolean", short: "h" },
43
+ repo: { type: "string" },
44
+ issue: { 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
+ issue: 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 === "issue") {
86
+ options.issue = parseIssueNumber(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 issue 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.issue === undefined) {
145
+ throw parseError("Editing an issue requires both --repo <owner/name> and --issue <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 an issue 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 issue 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 issue edit` args and the parallel `edited` list (which fields were
184
+ // touched) so callers get a stable summary without re-reading the issue.
185
+ async function buildEditArgs(options) {
186
+ const args = ["issue", "edit", String(options.issue), "--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 editIssue(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 issue edit failed: ${detail}`);
228
+ }
229
+ return { ok: true, repo: options.repo, issue: options.issue, 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 = parseEditIssueCliArgs(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 editIssue(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
+ }
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
4
+ import { parsePrNumber, requireTokenValue, runChild as defaultRunChild } from "../_cli-primitives.mjs";
5
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
+ import { authorMatchesFilter } from "./_review-thread-mutations.mjs";
7
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
8
+
9
+ // Dedicated query: unlike capture-review-threads.mjs's REVIEW_THREADS_QUERY
10
+ // (all comments, no path/line/isOutdated, no pagination past 100), this tool
11
+ // needs only the FIRST comment per thread (the reply-resolve target) plus the
12
+ // thread-level path/line/isOutdated fields, and must paginate past 100 threads.
13
+ export const LIST_REVIEW_THREADS_QUERY = [
14
+ "query($owner: String!, $name: String!, $pr: Int!, $after: String) {",
15
+ " repository(owner: $owner, name: $name) {",
16
+ " pullRequest(number: $pr) {",
17
+ " reviewThreads(first: 100, after: $after) {",
18
+ " pageInfo {",
19
+ " hasNextPage",
20
+ " endCursor",
21
+ " }",
22
+ " nodes {",
23
+ " id",
24
+ " isResolved",
25
+ " isOutdated",
26
+ " path",
27
+ " line",
28
+ " comments(first: 1) {",
29
+ " nodes {",
30
+ " databaseId",
31
+ " body",
32
+ " author {",
33
+ " login",
34
+ " }",
35
+ " }",
36
+ " }",
37
+ " }",
38
+ " }",
39
+ " }",
40
+ " }",
41
+ "}",
42
+ ].join("\n");
43
+
44
+ // Bounded excerpt so a listing over many threads stays scannable; full body
45
+ // text is available from capture-review-threads.mjs when actually needed.
46
+ const BODY_EXCERPT_MAX_CHARS = 200;
47
+
48
+ const USAGE = `Usage: list-review-threads.mjs --repo <owner/name> --pr <number> [--unresolved-only] [--author <login>]
49
+ List review threads on a pull request with the thread id + first-comment
50
+ databaseId reply-resolve-review-thread.mjs needs, so a fixer pass can go
51
+ list -> reply-resolve without hand-written GraphQL.
52
+ Required:
53
+ --repo <owner/name> Repository slug (e.g. owner/repo)
54
+ --pr <number> Pull request number
55
+ Optional:
56
+ --unresolved-only Only include threads where isResolved is false
57
+ --author <login> Only include threads whose first comment author matches
58
+ (case-insensitive; "copilot" matches any Copilot review
59
+ login variant; "all" matches every author)
60
+ Output (stdout, JSON):
61
+ { "ok": true, "repo": "owner/name", "pr": N, "threads": [
62
+ { "threadId": "...", "commentId": N|null, "author": "..."|null,
63
+ "body": "...", "isResolved": bool, "isOutdated": bool,
64
+ "path": "..."|null, "line": N|null }, ...
65
+ ] }
66
+ Error output (stderr, JSON):
67
+ Argument/usage errors:
68
+ { "ok": false, "error": "...", "usage": "..." }
69
+ gh/runtime failures:
70
+ { "ok": false, "error": "..." }
71
+ ${JQ_OUTPUT_USAGE}
72
+ Exit codes:
73
+ 0 Success
74
+ 1 Argument error or gh failure
75
+ 2 Invalid --jq filter`.trim();
76
+
77
+ const parseError = buildParseError(USAGE);
78
+
79
+ export function parseListReviewThreadsCliArgs(argv) {
80
+ const { tokens } = parseArgs({
81
+ args: [...argv],
82
+ options: {
83
+ help: { type: "boolean", short: "h" },
84
+ repo: { type: "string" },
85
+ pr: { type: "string" },
86
+ "unresolved-only": { type: "boolean" },
87
+ author: { type: "string" },
88
+ ...JQ_OUTPUT_PARSE_OPTIONS,
89
+ },
90
+ allowPositionals: true,
91
+ strict: false,
92
+ tokens: true,
93
+ });
94
+ const options = {
95
+ help: false,
96
+ repo: undefined,
97
+ pr: undefined,
98
+ unresolvedOnly: false,
99
+ author: undefined,
100
+ };
101
+ for (const token of tokens) {
102
+ if (token.kind === "positional") {
103
+ throw parseError(`Unknown argument: ${token.value}`);
104
+ }
105
+ if (token.kind !== "option") {
106
+ continue;
107
+ }
108
+ if (token.name === "help") {
109
+ options.help = true;
110
+ return options;
111
+ }
112
+ if (token.name === "repo") {
113
+ options.repo = requireTokenValue(token, parseError).trim();
114
+ continue;
115
+ }
116
+ if (token.name === "pr") {
117
+ options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
118
+ continue;
119
+ }
120
+ if (token.name === "unresolved-only") {
121
+ options.unresolvedOnly = true;
122
+ continue;
123
+ }
124
+ if (token.name === "author") {
125
+ options.author = requireTokenValue(token, parseError).trim();
126
+ continue;
127
+ }
128
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
129
+ throw parseError(`Unknown argument: ${token.rawName}`);
130
+ }
131
+ if (options.repo === undefined || options.pr === undefined) {
132
+ throw parseError("Listing review threads requires both --repo <owner/name> and --pr <number>");
133
+ }
134
+ try {
135
+ parseRepoSlug(options.repo);
136
+ } catch (error) {
137
+ throw parseError(error instanceof Error ? error.message : String(error));
138
+ }
139
+ return options;
140
+ }
141
+
142
+ function buildQueryArgs({ owner, name, pr, after }) {
143
+ const args = [
144
+ "api",
145
+ "graphql",
146
+ "--field",
147
+ `owner=${owner}`,
148
+ "--field",
149
+ `name=${name}`,
150
+ "--field",
151
+ `pr=${pr}`,
152
+ "--field",
153
+ `query=${LIST_REVIEW_THREADS_QUERY}`,
154
+ ];
155
+ if (typeof after === "string" && after.length > 0) {
156
+ args.push("--field", `after=${after}`);
157
+ }
158
+ return args;
159
+ }
160
+
161
+ function readThreadsConnection(payload) {
162
+ const connection = payload?.data?.repository?.pullRequest?.reviewThreads;
163
+ if (!connection || typeof connection !== "object") {
164
+ throw new Error("Invalid review-threads GraphQL payload: missing data.repository.pullRequest.reviewThreads");
165
+ }
166
+ const nodes = Array.isArray(connection.nodes) ? connection.nodes : [];
167
+ const pageInfo = connection.pageInfo ?? {};
168
+ return {
169
+ nodes,
170
+ hasNextPage: Boolean(pageInfo.hasNextPage),
171
+ endCursor: typeof pageInfo.endCursor === "string" ? pageInfo.endCursor : null,
172
+ };
173
+ }
174
+
175
+ function excerptBody(rawBody) {
176
+ const text = typeof rawBody === "string" ? rawBody.trim() : "";
177
+ if (text.length <= BODY_EXCERPT_MAX_CHARS) {
178
+ return text;
179
+ }
180
+ return `${text.slice(0, BODY_EXCERPT_MAX_CHARS)}…`;
181
+ }
182
+
183
+ function normalizeThreadNode(node) {
184
+ const firstComment = node?.comments?.nodes?.[0] ?? null;
185
+ const commentId = Number.isFinite(firstComment?.databaseId) ? firstComment.databaseId : null;
186
+ const authorLogin = firstComment?.author?.login;
187
+ const author = typeof authorLogin === "string" && authorLogin.length > 0 ? authorLogin : null;
188
+ return {
189
+ threadId: typeof node?.id === "string" ? node.id : "",
190
+ commentId,
191
+ author,
192
+ body: excerptBody(firstComment?.body),
193
+ isResolved: Boolean(node?.isResolved),
194
+ isOutdated: Boolean(node?.isOutdated),
195
+ path: typeof node?.path === "string" ? node.path : null,
196
+ line: typeof node?.line === "number" ? node.line : null,
197
+ };
198
+ }
199
+
200
+ export async function fetchAllReviewThreads(
201
+ { repo, pr },
202
+ { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {},
203
+ ) {
204
+ const { owner, name } = parseRepoSlug(repo);
205
+ const threads = [];
206
+ let after = null;
207
+ while (true) {
208
+ const result = await runChild(ghCommand, buildQueryArgs({ owner, name, pr, after }), env);
209
+ if (result.code !== 0) {
210
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
211
+ throw new Error(`gh command failed: ${detail}`);
212
+ }
213
+ const payload = parseJsonText(result.stdout);
214
+ const { nodes, hasNextPage, endCursor } = readThreadsConnection(payload);
215
+ for (const node of nodes) {
216
+ threads.push(normalizeThreadNode(node));
217
+ }
218
+ if (!hasNextPage) {
219
+ break;
220
+ }
221
+ if (!endCursor) {
222
+ throw new Error("Invalid review-threads GraphQL payload: pageInfo.hasNextPage is true but endCursor is missing");
223
+ }
224
+ after = endCursor;
225
+ }
226
+ return threads;
227
+ }
228
+
229
+ export function filterThreads(threads, { unresolvedOnly = false, author = undefined } = {}) {
230
+ // "all" is a match-everything sentinel: bypass the author filter entirely so
231
+ // threads with a missing/ghost first-comment author (author null) still list.
232
+ // authorMatchesFilter deliberately rejects empty logins for its reply-resolve
233
+ // callers, so routing "all" through it would drop those threads.
234
+ const authorFilter = typeof author === "string" && author.trim().toLowerCase() === "all"
235
+ ? undefined
236
+ : author;
237
+ return threads.filter((thread) => {
238
+ if (unresolvedOnly && thread.isResolved) {
239
+ return false;
240
+ }
241
+ if (authorFilter !== undefined && !authorMatchesFilter(thread.author, authorFilter)) {
242
+ return false;
243
+ }
244
+ return true;
245
+ });
246
+ }
247
+
248
+ export async function runCli(
249
+ argv = process.argv.slice(2),
250
+ {
251
+ stdout = process.stdout,
252
+ stderr = process.stderr,
253
+ env = process.env,
254
+ ghCommand = "gh",
255
+ } = {},
256
+ ) {
257
+ const options = parseListReviewThreadsCliArgs(argv);
258
+ if (options.help) {
259
+ stdout.write(`${USAGE}\n`);
260
+ return 0;
261
+ }
262
+ const allThreads = await fetchAllReviewThreads({ repo: options.repo, pr: options.pr }, { env, ghCommand });
263
+ const threads = filterThreads(allThreads, { unresolvedOnly: options.unresolvedOnly, author: options.author });
264
+ const result = { ok: true, repo: options.repo, pr: options.pr, threads };
265
+ return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
266
+ }
267
+
268
+ if (isDirectCliRun(import.meta.url)) {
269
+ runCli().then((code) => {
270
+ if (typeof code === "number") {
271
+ process.exitCode = code;
272
+ }
273
+ }).catch((error) => {
274
+ process.stderr.write(`${formatCliError(error)}\n`);
275
+ process.exitCode = 1;
276
+ });
277
+ }
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { parsePrNumber, requireOptionValue, runChild } from "../_cli-primitives.mjs";
3
- import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
3
+ import { formatCliError, isDirectCliRun, parseJsonText, sanitizeCopilotSummonTokens } from "../_core-helpers.mjs";
4
4
  import { loadDevLoopConfig, resolveGatePostFindingsComments } from "@dev-loops/core/config";
5
5
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
6
  import { JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
@@ -215,7 +215,7 @@ export function renderFindingsCommentBody({ gate, headSha, findings }) {
215
215
  ];
216
216
  if (findings.length === 0) {
217
217
  lines.push("No findings. All review angles passed for this head.");
218
- return lines.join("\n");
218
+ return sanitizeCopilotSummonTokens(lines.join("\n"));
219
219
  }
220
220
  const grouped = new Map();
221
221
  for (const sev of SEVERITY_ORDER) {
@@ -261,7 +261,10 @@ export function renderFindingsCommentBody({ gate, headSha, findings }) {
261
261
  while (lines.length > 0 && lines[lines.length - 1] === "") {
262
262
  lines.pop();
263
263
  }
264
- return lines.join("\n");
264
+ // Neutralize any bare @copilot/`/copilot`* tokens a finding summary quotes
265
+ // (e.g. an excerpt of the anti-summon rule itself) so this comment can never
266
+ // arm request-copilot-review.mjs's anti-summon guard.
267
+ return sanitizeCopilotSummonTokens(lines.join("\n"));
265
268
  }
266
269
 
267
270
  async function runGhJson(args, { env, ghCommand }) {
@@ -14,6 +14,8 @@ import {
14
14
  DEFAULT_POLL_INTERVAL_MS,
15
15
  COPILOT_REVIEW_WAIT_TIMEOUT_MS,
16
16
  } from "@dev-loops/core/loop/policy-constants";
17
+ import { ensureAsyncRunnerOwnership } from "../loop/_pr-runner-coordination.mjs";
18
+ import { resolveRepoRoot } from "../loop/_repo-root-resolver.mjs";
17
19
 
18
20
  /** Maximum interval between heartbeat outputs during watch delays.
19
21
  * Must be shorter than pi-subagents default needsAttentionAfterMs (60s). */
@@ -341,8 +343,10 @@ export async function watchCiStatus(
341
343
  ghCommand = "gh",
342
344
  delayImpl = delay,
343
345
  now = Date.now,
346
+ ensureOwnershipImpl = ensureAsyncRunnerOwnership,
344
347
  } = {},
345
348
  ) {
349
+ const leaseCwd = resolveRepoRoot(process.cwd());
346
350
  const { headSha: baselineSha, prVisibleCheckNames } = await fetchPrHeadSha(
347
351
  { repo: options.repo, pr: options.pr },
348
352
  { env, ghCommand },
@@ -382,6 +386,20 @@ export async function watchCiStatus(
382
386
  maxPolls: attemptBudget,
383
387
  }) + "\n",
384
388
  );
389
+ // The blocking CI wait can span the full watch budget, which equals the
390
+ // runner-coordination stale window; refresh the lease alongside each
391
+ // heartbeat so the claim stays fresh for every caller of this engine.
392
+ // No-ops without DEVLOOPS_RUN_ID; best-effort, never affects the watch.
393
+ try {
394
+ await ensureOwnershipImpl({
395
+ repo: options.repo,
396
+ pr: options.pr,
397
+ env,
398
+ cwd: leaseCwd,
399
+ claimIfMissing: true,
400
+ requireExisting: false,
401
+ });
402
+ } catch { /* best-effort: never affect the watch */ }
385
403
  }
386
404
  }
387
405
  }
@@ -9,6 +9,8 @@ import {
9
9
  DEFAULT_POLL_INTERVAL_MS,
10
10
  COPILOT_REVIEW_WAIT_TIMEOUT_MS,
11
11
  } from "@dev-loops/core/loop/policy-constants";
12
+ import { ensureAsyncRunnerOwnership } from "../loop/_pr-runner-coordination.mjs";
13
+ import { resolveRepoRoot } from "../loop/_repo-root-resolver.mjs";
12
14
 
13
15
  /** Maximum interval between heartbeat outputs during watch delays.
14
16
  * Must be shorter than pi-subagents default needsAttentionAfterMs (60s). */
@@ -275,14 +277,18 @@ export async function watchCopilotReview(
275
277
  {
276
278
  env = process.env,
277
279
  ghCommand = "gh",
280
+ delayImpl = delay,
281
+ now = Date.now,
282
+ ensureOwnershipImpl = ensureAsyncRunnerOwnership,
278
283
  } = {},
279
284
  ) {
285
+ const leaseCwd = resolveRepoRoot(process.cwd());
280
286
  const baseline = parseCopilotActivity(await fetchGithubCopilotActivityPayload(
281
287
  { repo: options.repo, pr: options.pr },
282
288
  { env, ghCommand },
283
289
  ));
284
290
  const attemptBudget = buildAttemptBudget(options.timeoutMs, options.pollIntervalMs);
285
- const watchStartedAtMs = Date.now();
291
+ const watchStartedAtMs = now();
286
292
  for (let attempt = 1; attempt <= attemptBudget; attempt += 1) {
287
293
  if (!(options.timeoutMs === 0 && attempt === 1)) {
288
294
  const pollDelayMs = buildPollDelayMs(
@@ -290,15 +296,16 @@ export async function watchCopilotReview(
290
296
  options.timeoutMs,
291
297
  options.pollIntervalMs,
292
298
  attempt,
299
+ now(),
293
300
  );
294
301
  if (pollDelayMs > 0) {
295
302
  let remainingMs = pollDelayMs;
296
303
  while (remainingMs > 0) {
297
304
  const chunkMs = Math.min(WATCH_HEARTBEAT_MS, remainingMs);
298
- await delay(chunkMs);
305
+ await delayImpl(chunkMs);
299
306
  remainingMs -= chunkMs;
300
307
  if (remainingMs > 0) {
301
- const nowMs = Date.now();
308
+ const nowMs = now();
302
309
  process.stderr.write(
303
310
  JSON.stringify({
304
311
  ok: true,
@@ -309,6 +316,20 @@ export async function watchCopilotReview(
309
316
  maxPolls: attemptBudget,
310
317
  }) + "\n",
311
318
  );
319
+ // The blocking review wait can span the full watch budget, which
320
+ // equals the runner-coordination stale window; refresh the lease
321
+ // alongside each heartbeat so the claim stays fresh for every caller
322
+ // of this engine. No-ops without DEVLOOPS_RUN_ID; best-effort.
323
+ try {
324
+ await ensureOwnershipImpl({
325
+ repo: options.repo,
326
+ pr: options.pr,
327
+ env,
328
+ cwd: leaseCwd,
329
+ claimIfMissing: true,
330
+ requireExisting: false,
331
+ });
332
+ } catch { /* best-effort: never affect the watch */ }
312
333
  }
313
334
  }
314
335
  }