@dev-loops/core 1.0.0-rc.3 → 1.0.0-rc.4

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.
@@ -125,19 +125,52 @@ gates:
125
125
  Scan PR comments for unresolved issues before declaring the gate clean. Check all PR comments and review threads for: - Comments from the repository owner or collaborators that point out
126
126
  implementation bugs, logic errors, contract violations, or security
127
127
  issues
128
- - Unresolved review threads that raise implementation concerns Flag any unresolved comment that identifies a concrete implementation problem as a blocking finding (severity: must-fix or worth-fixing-now). Do not flag: - Resolved threads - Style nits, formatting suggestions, or cosmetic feedback - Comments from non-collaborators or bots - Outdated comments that were already addressed in a later commit If no unresolved implementation concerns exist, return clean.
128
+ - Unresolved review threads that raise implementation concerns Flag any unresolved comment that identifies a concrete implementation problem as a blocking finding (severity: high or medium). Do not flag: - Resolved threads - Style nits, formatting suggestions, or cosmetic feedback - Comments from non-collaborators or bots - Outdated comments that were already addressed in a later commit If no unresolved implementation concerns exist, return clean.
129
129
  - contradiction-lens
130
130
  - code-conformance
131
131
  - semantic-drift
132
132
  - name: pr-description
133
133
  mandatory: true
134
134
  persona: review
135
- prompt: 'Review the PR description for completeness, contract fitness, and checkbox formatting before this PR is marked ready for review. The PR body is the implementation contract — it must have: - A Summary section explaining what changed and why - A Scope and context section defining the boundary of the change - An Acceptance criteria section with the linked issue acceptance criteria - A Definition of done section - A Non-goals section - A Validation command section describing exactly how to verify the change - The "Closes #N" line must match the linked issue; flag changes that alter or remove the operator-intended close target Checkboxes (`- [ ]` / `- [x]`, or `* [ ]` / `* [x]`) must appear inside genuine Markdown list items. Flag any checkbox marker used outside a list item (including table cells) as a worth-fixing-now finding. Flag any checkbox marker wrapped in backticks (e.g. `` `[x]` ``) as a worth-fixing-now finding. Flag PRs where the body is a single sentence or lacks any of these sections. Do not block on formatting preferences other than checkbox correctness.'
135
+ prompt: 'Review the PR description for completeness, contract fitness, and checkbox formatting before this PR is marked ready for review. The PR body is the implementation contract — it must have: - A Summary section explaining what changed and why - A Scope and context section defining the boundary of the change - An Acceptance criteria section with the linked issue acceptance criteria - A Definition of done section - A Non-goals section - A Validation command section describing exactly how to verify the change - The "Closes #N" line must match the linked issue; flag changes that alter or remove the operator-intended close target Checkboxes (`- [ ]` / `- [x]`, or `* [ ]` / `* [x]`) must appear inside genuine Markdown list items. Flag any checkbox marker used outside a list item (including table cells) as a medium finding. Flag any checkbox marker wrapped in backticks (e.g. `` `[x]` ``) as a medium finding. Flag PRs where the body is a single sentence or lacks any of these sections. Do not block on formatting preferences other than checkbox correctness.'
136
136
  required: true
137
137
  requireCi: true
138
- # Gate findings comments live ON the PR (the local-first spec-of-record /
139
- # human-review surface), so they are evidence, not tracker noise — keep them on.
140
- postFindingsComments: true
138
+ # Diff-class angle tiers (opt-in, ordered, first match wins). A matching tier
139
+ # replaces this gate's resolved angle set with its own for that round's
140
+ # fan-out (mandatory angles are always kept); it never changes execution
141
+ # mode. A tiered round is still a normal fanout_fanin round:
142
+ # tiers:
143
+ # - name: docs-only
144
+ # match: { kinds: [docs] }
145
+ # angles: [pr-description, link-check, gate-evidence]
146
+ # The gate round's verdict review already carries every finding, so the
147
+ # consolidated findings comment is opt-in duplication — keep it off.
148
+ postFindingsComments: false
149
+ # Grouped fan-out dispatch (AC6, default mode — see resolveFanoutGroups):
150
+ # angles that read the same surface batch onto one reviewer. Angles not
151
+ # named below join the auto-chunked leftover pool. Set `mode: per-angle`
152
+ # to restore full one-reviewer-per-angle fan-out repo-wide (bypasses
153
+ # maxAnglesPerGroup: 1 honors configured groups; the two match in unit size only when no configured multi-angle group matches). `gate:full` forces the full angle set but no
154
+ # longer restores per-angle dispatch (ADR 0047 superseded by 0048) — it
155
+ # dispatches grouped.
156
+ #
157
+ # Two orthogonal dispatch bounds (#1601):
158
+ # maxAnglesPerGroup (N, default 3, min 1) — leftover ungrouped angles
159
+ # auto-chunk into dispatch units of ≤N instead of singletons.
160
+ # maxConcurrent (M, default 4, min 1) — at most M dispatch units per wave.
161
+ # Both count dispatch units (groups), not angles.
162
+ fanout:
163
+ maxAnglesPerGroup: 3
164
+ maxConcurrent: 4
165
+ groups:
166
+ - name: docs-surface
167
+ angles: [docs, link-check, config-drift, contract-surface]
168
+ - name: process
169
+ angles: [scope, pr-description, gate-evidence, pr-checklist-matrix]
170
+ - name: correctness-input
171
+ angles: [correctness, input-validation]
172
+ - name: determinism-state
173
+ angles: [determinism, state-concurrency]
141
174
  preApproval:
142
175
  angles:
143
176
  - name: dry
@@ -6,11 +6,76 @@
6
6
  * scripts and other packages/core modules.
7
7
  */
8
8
 
9
- const SUBMITTED_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"]);
9
+ // Exported so anything deciding "is there a real prior review" uses the same
10
+ // whitelist as the loop-state reader — two copies could drift, and a guard
11
+ // acting on the gate's behalf must agree with the gate about what a submitted
12
+ // review is.
13
+ export const SUBMITTED_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"]);
10
14
  const GATE_REVIEW_NAMES = new Set(["draft_gate", "pre_approval_gate"]);
11
15
  const GATE_REVIEW_VERDICTS = new Set(["clean", "findings_present", "blocked"]);
12
16
  const GATE_EXECUTION_MODES = new Set(["fanout_fanin", "inline_single_agent"]);
13
17
 
18
+ // The literal header line the gate review body always emits first
19
+ // (upsert-checkpoint-verdict.mjs's renderGateReviewCommentBody, re-exported
20
+ // from there). Owned here so the machine-artifact filter below and every
21
+ // consumer that needs to recognize "is this a real gate verdict surface" read
22
+ // the same producer-owned literal instead of restating it. Line-start anchored
23
+ // (`m`) so a quoted header in a reply/blockquote can't match.
24
+ export const GATE_REVIEW_COMMENT_HEADER_RE = /^###\s+Gate review:\s*`(draft_gate|pre_approval_gate)`\s*$/m;
25
+
26
+ /** Returns the matched gate name when `body` carries a genuine gate verdict header, else null. */
27
+ export function matchGateReviewCommentHeader(body) {
28
+ if (typeof body !== "string") return null;
29
+ const match = body.match(GATE_REVIEW_COMMENT_HEADER_RE);
30
+ return match ? match[1] : null;
31
+ }
32
+
33
+ // Machine-authored gate artifacts that must never win the newest-gate-marker
34
+ // tie-break in summarizeGateReviewComments/summarizeGateReviewCommentMarkers:
35
+ // a historical standalone findings review always embedded this gate's name in
36
+ // its header line and could quote the current head sha inside a finding's own
37
+ // free text (the lenient gate-name+hex-token fallback in
38
+ // parseGateReviewCommentFields would otherwise happily match that), and the
39
+ // historical deferred-summary PR comment quoted a gate name plus a sha-shaped
40
+ // id in its table rows the same way. Both are excluded HERE, inside the two
41
+ // shared summarizers, because this module is the true merge point: every
42
+ // consumer (detect-checkpoint-evidence.mjs, pre-pr-ready-gate.mjs,
43
+ // ready-for-review.mjs, request-copilot-review.mjs) calls
44
+ // summarizeGateReviewComments/summarizeGateReviewCommentMarkers to turn a raw
45
+ // comment/review list into a gate verdict, so filtering here — rather than
46
+ // per-caller — covers all of them by construction.
47
+ //
48
+ // Anchored to the start of a line (`^` with `m`) so only a marker rendered as
49
+ // the first character of its own line is excluded — a genuine verdict
50
+ // comment whose findings summary merely QUOTES the marker text mid-line (for
51
+ // example, describing this very mechanism) still counts as evidence. Both
52
+ // producers render their marker at column 0, so the anchor costs nothing
53
+ // against genuine artifacts.
54
+ // The set covers exactly three marker tokens: the per-round review round
55
+ // marker (gate-findings-review), post-gate-findings.mjs's opt-in findings
56
+ // COMMENT marker (gate-findings gate=...), and the historical
57
+ // deferred-summary comment. Without the findings-comment marker, that comment
58
+ // parses as a verdict marker candidate (its "Gate fan-out findings:"/
59
+ // "Reviewed head:" lines yield gate+headSha) and the verdict upsert claims
60
+ // and overwrites it in place, silently destroying the round's visible
61
+ // findings record. Every branch is delimiter-anchored — the token must be
62
+ // followed by whitespace or the closing `-->` — so no suffixed `<token>-<x>`
63
+ // variant ever matches.
64
+ const GATE_MACHINE_ARTIFACT_MARKER_RE = /^<!--\s*dev-loops:(?:gate-findings-review|gate-findings|deferred-summary)(?=\s|-->)/mu;
65
+
66
+ export function isGateMachineArtifactBody(body) {
67
+ if (typeof body !== "string" || !GATE_MACHINE_ARTIFACT_MARKER_RE.test(body)) {
68
+ return false;
69
+ }
70
+ // A gate round now posts ONE PR review carrying BOTH the verdict header and
71
+ // the gate-findings-review marker (the findings it files live on that same
72
+ // surface). Such a body IS the verdict, not a separate machine artifact, so
73
+ // the producer-owned verdict header wins over the artifact marker. Only a
74
+ // marker-bearing body with NO genuine verdict header (a historical standalone
75
+ // findings review or deferred-summary comment) stays excluded.
76
+ return matchGateReviewCommentHeader(body) === null;
77
+ }
78
+
14
79
  export function isCopilotLogin(login) {
15
80
  return typeof login === "string" && /^copilot(?:[^a-z]|$)/i.test(login);
16
81
  }
@@ -234,50 +299,86 @@ function parseGateReviewCommentFields(body) {
234
299
  }
235
300
  const line = stripped;
236
301
 
302
+ // First-NON-EMPTY-wins per field: a genuine comment renders its structured
303
+ // block first, so the first column-0 match for each field is normally the
304
+ // real one. A free-text field (findings summary, next action) rendered
305
+ // later in the SAME comment can embed a newline plus a spoofed
306
+ // "Verdict: clean" (or any other field label) at column 0; capturing only
307
+ // the first match (rather than the last) stops that later line from
308
+ // winning and flipping/nulling the field. But the label regex's
309
+ // `\s*(.+)$` also matches a label followed by nothing but whitespace,
310
+ // capturing an empty string — for the enum fields (gate/headSha/verdict/
311
+ // executionMode) an empty capture normalizes to null already, so the
312
+ // `=== null` guard below naturally stays open for a later, genuine line.
313
+ // The two free-text fields (findingsSummary, nextAction) do NOT normalize
314
+ // through an enum, so an empty capture must be checked for explicitly:
315
+ // treat it as no-capture (leave the field open) rather than locking it to
316
+ // "" and hiding a real line that renders after it.
237
317
  let match = line.match(/^(?:[-*]\s*)?(?:gate(?:\s+name)?|gate\s+review)\s*:\s*(.+)$/iu);
238
318
  if (match) {
239
- fields.gate = normalizeGateReviewName(match[1]);
319
+ if (fields.gate === null) {
320
+ fields.gate = normalizeGateReviewName(match[1]);
321
+ }
240
322
  continue;
241
323
  }
242
324
 
243
325
  match = line.match(/^(?:[-*]\s*)?(?:head\s+sha(?:\s+reviewed)?|reviewed\s+head\s+sha)\s*:\s*(.+)$/iu);
244
326
  if (match) {
245
- fields.headSha = normalizeGateReviewHeadSha(match[1]);
327
+ if (fields.headSha === null) {
328
+ fields.headSha = normalizeGateReviewHeadSha(match[1]);
329
+ }
246
330
  continue;
247
331
  }
248
332
 
249
333
  match = line.match(/^(?:[-*]\s*)?verdict\s*:\s*(.+)$/iu);
250
334
  if (match) {
251
- fields.verdict = normalizeGateReviewVerdict(match[1]);
335
+ if (fields.verdict === null) {
336
+ fields.verdict = normalizeGateReviewVerdict(match[1]);
337
+ }
252
338
  continue;
253
339
  }
254
340
 
255
341
  match = line.match(/^(?:[-*]\s*)?(?:findings(?:\s+summary)?|summary)\s*:\s*(.+)$/iu);
256
342
  if (match) {
257
- fields.findingsSummary = match[1].trim();
343
+ if (fields.findingsSummary === null) {
344
+ const candidate = match[1].trim();
345
+ // An empty capture (label followed only by whitespace) is treated as
346
+ // no-capture: leave the field open so a later, genuine line can still
347
+ // win instead of first-wins locking it to "".
348
+ if (candidate.length > 0) {
349
+ fields.findingsSummary = candidate;
350
+ }
351
+ }
258
352
  continue;
259
353
  }
260
354
 
261
355
  match = line.match(/^(?:[-*]\s*)?next\s+action\s*:\s*(.+)$/iu);
262
356
  if (match) {
263
- fields.nextAction = match[1].trim();
357
+ if (fields.nextAction === null) {
358
+ const candidate = match[1].trim();
359
+ if (candidate.length > 0) {
360
+ fields.nextAction = candidate;
361
+ }
362
+ }
264
363
  continue;
265
364
  }
266
365
 
267
366
  match = line.match(/^(?:[-*]\s*)?execution\s+mode\s*:\s*(.+)$/iu);
268
367
  if (match) {
269
- const rest = match[1].trim();
270
- // Split on the first em-dash / en-dash / " - " separator to recover an
271
- // optional inline reason: "inline_single_agent — <reason>".
272
- const sepMatch = rest.match(/^(.*?)\s*(?:[—–]|\s-\s)\s*(.*)$/u);
273
- const modeToken = sepMatch ? sepMatch[1].trim() : rest;
274
- const reasonToken = sepMatch ? sepMatch[2].trim() : "";
275
- fields.executionMode = normalizeGateExecutionMode(modeToken);
276
- // Only record an inline reason for inline_single_agent. A trailing
277
- // "— text" on a fanout_fanin (or invalid) mode line must not surface an
278
- // inconsistent mode/reason pair, so leave inlineReason null otherwise.
279
- if (reasonToken.length > 0 && fields.executionMode === "inline_single_agent") {
280
- fields.inlineReason = reasonToken;
368
+ if (fields.executionMode === null) {
369
+ const rest = match[1].trim();
370
+ // Split on the first em-dash / en-dash / " - " separator to recover an
371
+ // optional inline reason: "inline_single_agent — <reason>".
372
+ const sepMatch = rest.match(/^(.*?)\s*(?:[—–]|\s-\s)\s*(.*)$/u);
373
+ const modeToken = sepMatch ? sepMatch[1].trim() : rest;
374
+ const reasonToken = sepMatch ? sepMatch[2].trim() : "";
375
+ fields.executionMode = normalizeGateExecutionMode(modeToken);
376
+ // Only record an inline reason for inline_single_agent. A trailing
377
+ // "— text" on a fanout_fanin (or invalid) mode line must not surface an
378
+ // inconsistent mode/reason pair, so leave inlineReason null otherwise.
379
+ if (reasonToken.length > 0 && fields.executionMode === "inline_single_agent") {
380
+ fields.inlineReason = reasonToken;
381
+ }
281
382
  }
282
383
  continue;
283
384
  }
@@ -353,6 +454,18 @@ export function parseGateReviewCommentMarkerBody(body) {
353
454
  };
354
455
  }
355
456
 
457
+ // Which GitHub surface carries a gate verdict. The poster needs it to pick the
458
+ // right in-place correction endpoint on a same-head rerun (a PR review is PUT
459
+ // to pulls/{pr}/reviews/{id}; a legacy verdict issue comment is PATCHed to
460
+ // issues/comments/{id}). Anything that is not the review surface — including a
461
+ // raw issue-comment payload with no `surface` field — is issue_comment, so the
462
+ // historical shape survives untouched. SINGLE definition: a restatement that
463
+ // misses a future third surface would silently route its body to the
464
+ // issue-comment endpoint, where it does not live.
465
+ export function normalizeVerdictSurface(value) {
466
+ return value === "review" ? "review" : "issue_comment";
467
+ }
468
+
356
469
  export function summarizeGateReviewComments(comments) {
357
470
  const summary = {
358
471
  draft_gate: null,
@@ -363,6 +476,9 @@ export function summarizeGateReviewComments(comments) {
363
476
 
364
477
  for (let index = 0; index < entries.length; index += 1) {
365
478
  const comment = entries[index];
479
+ if (isGateMachineArtifactBody(comment?.body)) {
480
+ continue;
481
+ }
366
482
  const parsed = parseGateReviewCommentBody(comment?.body);
367
483
  if (!parsed) {
368
484
  continue;
@@ -378,6 +494,7 @@ export function summarizeGateReviewComments(comments) {
378
494
  nextAction: parsed.nextAction,
379
495
  executionMode: parsed.executionMode ?? null,
380
496
  inlineReason: parsed.inlineReason ?? null,
497
+ surface: normalizeVerdictSurface(comment?.surface),
381
498
  commentId: Number.isInteger(comment?.id) ? comment.id : null,
382
499
  commentUrl: typeof comment?.html_url === "string" && comment.html_url.trim().length > 0 ? comment.html_url.trim() : null,
383
500
  updatedAt: typeof (comment?.updated_at ?? comment?.updatedAt) === "string"
@@ -409,6 +526,9 @@ export function summarizeGateReviewCommentMarkers(comments, { headSha } = {}) {
409
526
 
410
527
  for (let index = 0; index < entries.length; index += 1) {
411
528
  const comment = entries[index];
529
+ if (isGateMachineArtifactBody(comment?.body)) {
530
+ continue;
531
+ }
412
532
  const parsed = parseGateReviewCommentMarkerBody(comment?.body);
413
533
  if (!parsed) {
414
534
  continue;
@@ -429,6 +549,7 @@ export function summarizeGateReviewCommentMarkers(comments, { headSha } = {}) {
429
549
  executionMode: parsed.executionMode ?? null,
430
550
  inlineReason: parsed.inlineReason ?? null,
431
551
  contractComplete: parsed.contractComplete,
552
+ surface: normalizeVerdictSurface(comment?.surface),
432
553
  commentId: Number.isInteger(comment?.id) ? comment.id : null,
433
554
  commentUrl: typeof comment?.html_url === "string" && comment.html_url.trim().length > 0 ? comment.html_url.trim() : null,
434
555
  updatedAt: typeof (comment?.updated_at ?? comment?.updatedAt) === "string"
@@ -1,5 +1,5 @@
1
1
  import { readFile } from "node:fs/promises";
2
- import { readFileSync } from "node:fs";
2
+ import { readFileSync, statSync } from "node:fs";
3
3
  import { runChild as defaultRunChild } from "../cli/primitives.mjs";
4
4
  import { parseJsonText } from "./review-threads.mjs";
5
5
  import { parseRepoSlug } from "./repo-slug.mjs";
@@ -62,7 +62,36 @@ export function buildCreateArgs(options) {
62
62
  return args;
63
63
  }
64
64
 
65
+ // Reject a --body-file path that does not RESOLVE (following symlinks) to a
66
+ // regular file. The CLI layer's literal-string rejections (`-`, `/dev/stdin`,
67
+ // `/dev/fd/N`, ...) only catch known stdin-device spellings; a symlink to one
68
+ // of those devices (or to /dev/null, a FIFO, etc.) dodges that regex yet still
69
+ // reads as empty/non-file when `gh` re-reads the same path with stdin ignored.
70
+ // `statSync` follows symlinks, so this closes that gap regardless of path shape.
71
+ function assertRegularFilePath(path) {
72
+ if (!statSync(path).isFile()) {
73
+ throw new Error(`--body-file must be a regular file: ${path}`);
74
+ }
75
+ }
76
+
77
+ // Read (for validation only — the actual gh call still forwards the path, see
78
+ // buildCreateArgs) and reject a --body-file that isn't a regular file or whose
79
+ // content is empty/whitespace-only. This is the real guard behind the CLI's
80
+ // stdin-device rejection: `gh` is spawned with stdin ignored, so it re-reads
81
+ // the same path fresh — this validates what gh will actually see, not just the
82
+ // path's literal spelling.
83
+ export async function resolveCreateBody(options) {
84
+ if (options.bodyFile === undefined) return options.body;
85
+ assertRegularFilePath(options.bodyFile);
86
+ return await readFile(options.bodyFile, "utf8");
87
+ }
88
+
65
89
  export async function createIssue(options, { env = process.env, ghCommand = "gh", run = defaultRunChild } = {}) {
90
+ const body = await resolveCreateBody(options);
91
+ if (typeof body !== "string" || body.trim().length === 0) {
92
+ const source = options.bodyFile !== undefined ? `--body-file ${options.bodyFile}` : "--body";
93
+ throw new Error(`issue body resolved empty from ${source} — refusing to create a bodyless issue`);
94
+ }
66
95
  const args = buildCreateArgs(options);
67
96
  const result = await run(ghCommand, args, env);
68
97
  if (result.code !== 0) {
@@ -126,12 +155,55 @@ export async function buildEditArgs(options) {
126
155
  return { args, edited };
127
156
  }
128
157
 
158
+ // gh's own --reason values are space-separated ("not planned"), but the
159
+ // CLI-facing flag value stays the underscore form (`not_planned`) since it's
160
+ // stable and shell-friendly without quoting; map it here at the gh-args
161
+ // boundary rather than changing the public flag value.
162
+ const REASON_ARG_BY_CLI_VALUE = { not_planned: "not planned" };
163
+
164
+ // Build the `gh issue close`/`gh issue reopen` args for a --state change. Kept
165
+ // as a separate `gh` call from `gh issue edit` — that command has no --state
166
+ // flag, so a state change is its own invocation, run after the edit call.
167
+ export function buildStateChangeArgs(options) {
168
+ if (options.state === "closed") {
169
+ const args = ["issue", "close", String(options.issue), "--repo", options.repo];
170
+ if (options.reason !== undefined) {
171
+ args.push("--reason", REASON_ARG_BY_CLI_VALUE[options.reason] ?? options.reason);
172
+ }
173
+ return args;
174
+ }
175
+ if (options.state !== "open") {
176
+ // Fail closed: this is an exported seam, so an unexpected state must never
177
+ // silently degrade into a reopen.
178
+ throw new Error(`invalid state ${JSON.stringify(options.state)} — expected "open" or "closed"`);
179
+ }
180
+ return ["issue", "reopen", String(options.issue), "--repo", options.repo];
181
+ }
182
+
129
183
  export async function editIssue(options, { env = process.env, ghCommand = "gh", run = defaultRunChild } = {}) {
130
184
  const { args, edited } = await buildEditArgs(options);
131
- const result = await run(ghCommand, args, env);
132
- if (result.code !== 0) {
133
- const detail = result.stderr.trim() || `exit code ${result.code}`;
134
- throw new Error(`gh issue edit failed: ${detail}`);
185
+ // Skip the edit call entirely when --state is the only change requested —
186
+ // `gh issue edit` with no field flags errors ("no changed fields").
187
+ if (edited.length > 0) {
188
+ const result = await run(ghCommand, args, env);
189
+ if (result.code !== 0) {
190
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
191
+ throw new Error(`gh issue edit failed: ${detail}`);
192
+ }
193
+ }
194
+ if (options.state !== undefined) {
195
+ const stateArgs = buildStateChangeArgs(options);
196
+ const result = await run(ghCommand, stateArgs, env);
197
+ if (result.code !== 0) {
198
+ const verb = options.state === "closed" ? "close" : "reopen";
199
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
200
+ // Surface the edits that DID land before the state change failed, so a
201
+ // caller (or a human reading the error) knows the field edits are not
202
+ // rolled back — only the state change itself failed.
203
+ const landed = edited.length > 0 ? ` after edits were applied: ${edited.join(", ")}` : "";
204
+ throw new Error(`state change failed${landed} — gh issue ${verb} failed: ${detail}`);
205
+ }
206
+ edited.push("state");
135
207
  }
136
208
  return { ok: true, repo: options.repo, issue: options.issue, edited };
137
209
  }
@@ -166,6 +166,34 @@ export function parseReviewThreads(payload) {
166
166
  };
167
167
  }
168
168
 
169
+ /**
170
+ * The fix-loop's re-entry working set: only unresolved threads, each with its
171
+ * comment bodies joined in thread order. Location fields come from the thread
172
+ * node when the payload carries them (`path`/`line`/`isOutdated`); snapshots
173
+ * without them yield `path: null`, `line: null`, `isOutdated: false`.
174
+ *
175
+ * @returns {{ summary: object, threads: Array<{ threadId: string, path: string|null, line: number|null, isOutdated: boolean, bodies: string[] }> }}
176
+ */
177
+ export function parseUnresolvedThreadBodies(payload) {
178
+ const rawThreads = extractRawThreads(payload);
179
+ const { summary } = parseReviewThreads(payload);
180
+ const threads = rawThreads
181
+ .map((thread, threadIndex) => ({ thread, threadIndex }))
182
+ .filter(({ thread }) => !thread?.isResolved)
183
+ .map(({ thread, threadIndex }) => ({
184
+ threadId: normalizeId(thread?.id ?? thread?.databaseId, `thread-${threadIndex + 1}`),
185
+ path: typeof thread?.path === "string" && thread.path.length > 0 ? thread.path : null,
186
+ line: Number.isInteger(thread?.line) ? thread.line : null,
187
+ isOutdated: Boolean(thread?.isOutdated),
188
+ bodies: extractRawComments(thread).map((comment) =>
189
+ normalizeBody(comment?.body ?? comment?.bodyText ?? comment?.bodyHTML ?? ""),
190
+ ),
191
+ }))
192
+ .sort((left, right) => compareIds(left.threadId, right.threadId));
193
+
194
+ return { summary, threads };
195
+ }
196
+
169
197
  // ── Signal classification heuristics ──────────────────────────────────────
170
198
 
171
199
  const HIGH_SIGNAL_PATTERNS = [
@@ -303,10 +331,23 @@ export function parseJsonText(text) {
303
331
  }
304
332
  }
305
333
 
306
- export function formatCliError(error) {
334
+ // Renders the one shared { ok: false, error, hint? } envelope every
335
+ // JSON-emitting gate CLI's main() catch block prints to stderr. `usage` is
336
+ // accepted only as a PRESENCE check for a fallback usage string (a caller
337
+ // passing its own `USAGE` constant when the error itself might not already
338
+ // carry one) — the fallback's actual TEXT, like `error.usage`'s, is never
339
+ // embedded here. Argument errors (and any error a caller marks with a usage
340
+ // string) used to inline that string's full multi-KB text into this JSON
341
+ // payload, which every calling agent then paid to read back out of its own
342
+ // tool result on every mistyped flag. A one-line `hint` pointing at --help
343
+ // carries the same "usage exists, go look" signal at a fraction of the size;
344
+ // --help itself is unaffected (it prints the full USAGE text directly, never
345
+ // through this function).
346
+ export function formatCliError(error, { usage } = {}) {
307
347
  const payload = { ok: false, error: error instanceof Error ? error.message : String(error) };
308
- if (error instanceof Error && typeof error.usage === "string") {
309
- payload.usage = error.usage;
348
+ const hasUsage = (error instanceof Error && typeof error.usage === "string") || typeof usage === "string";
349
+ if (hasUsage) {
350
+ payload.hint = "run with --help for usage";
310
351
  }
311
352
  return JSON.stringify(payload);
312
353
  }
@@ -6,16 +6,31 @@ const STATUS_CONTEXT_PENDING_STATES = new Set(["PENDING", "EXPECTED"]);
6
6
  const STATUS_CONTEXT_SUCCESS_STATES = new Set(["SUCCESS"]);
7
7
 
8
8
  /**
9
- * Name of the server-side "Gate evidence" check dev-loops posts on its own
10
- * pull requests (`.github/workflows/gate-evidence.yml`). Its conclusion is
11
- * DERIVED from the loop's own progress (a clean current-head
12
- * pre_approval_gate verdict), not an independent build/test signal — so the
13
- * loop must exclude it, by this exact name only, when deriving the CI status
14
- * that gates its own pre_approval step. Otherwise the loop could never post
15
- * the very verdict that would turn this check green (#1358).
9
+ * Name of the explicit commit STATUS dev-loops posts on its own pull requests
10
+ * from `.github/workflows/gate-evidence.yml`. Its conclusion is DERIVED from
11
+ * the loop's own progress (a clean current-head pre_approval_gate verdict),
12
+ * not an independent build/test signal — so the loop must exclude it when
13
+ * deriving the CI status that gates its own pre_approval step. Otherwise the
14
+ * loop could never post the very verdict that would turn this check green
15
+ * (#1358). This constant names ONLY the status context; the workflow also
16
+ * surfaces as a check run, so anything partitioning check runs must use
17
+ * `LOOP_DERIVED_CI_CHECK_NAMES` below. It remains the label reported in
18
+ * `excludedFailureDetails` for either shape, since both name one workflow.
16
19
  */
17
20
  export const LOOP_DERIVED_CI_CHECK_NAME = "gate-evidence";
18
21
 
22
+ /**
23
+ * The same workflow ALSO surfaces as a check run under its job id
24
+ * (`gate-evidence-runner`) beside the commit status named above, and both are
25
+ * the loop's own derived signal. Excluding only the status context left the
26
+ * runner's conclusion gating the loop's own pre_approval step: once the
27
+ * workflow gained job-level concurrency, a superseded run is cancelled as
28
+ * normal operation, and a cancelled run is deliberately NOT treated as green
29
+ * (see normalizeStatusCheckRollupStatus) — so one routine cancellation made
30
+ * the whole head read "none" and the loop waited on CI forever.
31
+ */
32
+ export const LOOP_DERIVED_CI_CHECK_NAMES = Object.freeze([LOOP_DERIVED_CI_CHECK_NAME, "gate-evidence-runner"]);
33
+
19
34
  function checkEntryName(entry) {
20
35
  if (typeof entry?.name === "string" && entry.name.length > 0) return entry.name;
21
36
  if (typeof entry?.context === "string" && entry.context.length > 0) return entry.context;
@@ -28,15 +43,16 @@ function checkEntryName(entry) {
28
43
  * both use `.name` (check-runs also use `.context` for legacy StatusContext).
29
44
  *
30
45
  * @param {Array<object>} entries
31
- * @param {string} targetName
46
+ * @param {string|Array<string>} targetName One name, or several to match.
32
47
  * @returns {{ matched: Array<object>, rest: Array<object> }}
33
48
  */
34
49
  export function partitionEntriesByCheckName(entries, targetName) {
35
50
  const list = Array.isArray(entries) ? entries : [];
51
+ const targets = new Set(Array.isArray(targetName) ? targetName : [targetName]);
36
52
  const matched = [];
37
53
  const rest = [];
38
54
  for (const entry of list) {
39
- (checkEntryName(entry) === targetName ? matched : rest).push(entry);
55
+ (targets.has(checkEntryName(entry)) ? matched : rest).push(entry);
40
56
  }
41
57
  return { matched, rest };
42
58
  }
@@ -292,7 +308,8 @@ export function normalizeHeadScopedCiContract({
292
308
 
293
309
  /**
294
310
  * Derive a loop-safe CI status from a PR `statusCheckRollup` snapshot: the
295
- * `LOOP_DERIVED_CI_CHECK_NAME` entry (`gate-evidence`) is excluded from the
311
+ * `LOOP_DERIVED_CI_CHECK_NAMES` entries (the `gate-evidence` status and the
312
+ * workflow's own `gate-evidence-runner` check run) are excluded from the
296
313
  * status computation before it can block, and surfaced separately so a
297
314
  * genuinely failing check right beside it can never be masked. Every reason
298
315
  * gate-evidence can be red (missing draft_gate/pre_approval evidence,
@@ -305,7 +322,7 @@ export function normalizeHeadScopedCiContract({
305
322
  * @returns {{ status: "success"|"failure"|"pending"|"none", excludedFailureDetails: Array<string> }}
306
323
  */
307
324
  export function deriveLoopCiStatusFromRollup(rollup) {
308
- const { matched, rest } = partitionEntriesByCheckName(rollup, LOOP_DERIVED_CI_CHECK_NAME);
325
+ const { matched, rest } = partitionEntriesByCheckName(rollup, LOOP_DERIVED_CI_CHECK_NAMES);
309
326
  const status = normalizeStatusCheckRollupStatus(rest);
310
327
  const excludedFailureDetails = matched.length > 0 && normalizeStatusCheckRollupStatus(matched) === "failure"
311
328
  ? [LOOP_DERIVED_CI_CHECK_NAME]
@@ -1,7 +1,6 @@
1
- import { isCopilotLogin, normalizeTimestamp } from "../github/copilot-helpers.mjs";
1
+ import { SUBMITTED_REVIEW_STATES, isCopilotLogin, normalizeTimestamp } from "../github/copilot-helpers.mjs";
2
2
 
3
3
  const ACTIVE_COPILOT_REVIEW_REQUEST_STATUSES = new Set(["requested", "already-requested"]);
4
- const SUBMITTED_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"]);
5
4
 
6
5
  function normalizeReviewRequestEvents(events) {
7
6
  if (!Array.isArray(events)) {