@dev-loops/core 1.0.0-rc.5 → 1.0.0-rc.7

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 (47) hide show
  1. package/package.json +12 -1
  2. package/src/analysis/change-classifier.mjs +10 -0
  3. package/src/analysis/diff-analyzer.mjs +68 -1
  4. package/src/claude/hook-decisions.mjs +204 -5
  5. package/src/cli/primitives.mjs +51 -1
  6. package/src/config/config.mjs +307 -14
  7. package/src/config/extension-defaults.yaml +39 -1
  8. package/src/github/comment-id-guard.mjs +158 -0
  9. package/src/github/copilot-helpers.mjs +145 -5
  10. package/src/github/gh.mjs +94 -0
  11. package/src/github/issue-ops.mjs +13 -0
  12. package/src/loop/agent-stall.mjs +196 -0
  13. package/src/loop/bash-command-classify.mjs +277 -0
  14. package/src/loop/cache-telemetry-evidence.mjs +437 -0
  15. package/src/loop/copilot-loop-iterations.mjs +2 -1
  16. package/src/loop/default-branch-guard.mjs +35 -2
  17. package/src/loop/gate-carry-forward.mjs +19 -6
  18. package/src/loop/gate-fanin.mjs +190 -29
  19. package/src/loop/handoff-envelope.mjs +40 -20
  20. package/src/loop/issue-refinement-artifact.mjs +94 -0
  21. package/src/loop/lifecycle-state.mjs +21 -2
  22. package/src/loop/main-checkout-ff.mjs +73 -0
  23. package/src/loop/markdown-sections.mjs +40 -0
  24. package/src/loop/normalize.mjs +7 -0
  25. package/src/loop/plan-file-promote-contract.mjs +14 -1
  26. package/src/loop/plan-file-refine-contract.mjs +92 -8
  27. package/src/loop/policy-constants.mjs +9 -0
  28. package/src/loop/pr-gate-coordination.mjs +65 -12
  29. package/src/loop/primer-evidence.mjs +375 -0
  30. package/src/loop/public-dev-loop-routing.mjs +7 -15
  31. package/src/loop/queue-board-sync.mjs +1 -26
  32. package/src/loop/queue-driver.mjs +14 -1
  33. package/src/loop/refinement-grill-state.mjs +3 -5
  34. package/src/loop/review-dispatch-plan.mjs +1034 -0
  35. package/src/loop/review-lineage.mjs +588 -0
  36. package/src/loop/reviewer-loop-state.mjs +8 -13
  37. package/src/loop/run-post-merge-actions.mjs +148 -0
  38. package/src/loop/size-budget-merge-gate.mjs +121 -0
  39. package/src/loop/tracker-pr-state.mjs +5 -15
  40. package/src/loop/ui-designer-review-scoping.mjs +171 -0
  41. package/src/loop/ui-review-drive.mjs +3 -1
  42. package/src/loop/ui-review-report.mjs +2 -5
  43. package/src/loop/ui-review-teardown.mjs +3 -1
  44. package/src/loop/worktree-guard.mjs +80 -0
  45. package/src/projects/list-queue-items.mjs +1 -27
  46. package/src/projects/move-queue-item.mjs +38 -28
  47. package/src/security/secret-scan.mjs +330 -0
@@ -0,0 +1,158 @@
1
+ /**
2
+ * ISSUE/PR-ID GUARD for generated comment bodies.
3
+ *
4
+ * Mandate (#1731, operator directive): generated gate/review/verdict comment
5
+ * bodies must NEVER emit raw issue or PR ids. Public comment surfaces are
6
+ * world-readable, and a bare `#<digits>` in a comment body is auto-linked by
7
+ * GitHub to that issue/PR — leaking internal cross-references and violating
8
+ * the no-ids-in-comments rule.
9
+ *
10
+ * This helper fails CLOSED: it refuses (throws) a body that contains a raw
11
+ * `#<digits>` token, unless that id is explicitly allowlisted as a deliberate
12
+ * cross-reference (`allowedRefs`). There is deliberately NO silent stripping —
13
+ * a stripped id could silently drop a needed cross-ref while still posting;
14
+ * refusal forces the caller to make the cross-ref deliberate (or reword).
15
+ *
16
+ * Wire this into every comment/review write helper that posts a GENERATED
17
+ * body (verdict comments, gate findings reviews, inline finding comments,
18
+ * review-thread replies, and the generic comment/edit writers). Applying it at
19
+ * the low-level POST/PATCH write point means current AND future comment flows
20
+ * are guarded automatically — a future writer that routes through these
21
+ * helpers cannot emit an issue/PR id without an explicit allowlist entry.
22
+ *
23
+ * Deliberate cross-reference mechanism: pass the id(s) to allow as
24
+ * `allowedRefs: ["1670"]`. Aside from a genuine HTML numeric character
25
+ * reference (`&#<digits>;`, e.g. `&#91;` for `[`) — each such OCCURRENCE is
26
+ * skipped; the same digit run appearing elsewhere as a bare token still
27
+ * refuses — this is the ONLY sanctioned way a generated comment body may
28
+ * carry a `#<digits>` token. Extraction is decode-aware on BOTH sides of the
29
+ * token: the body is also scanned after a single left-to-right decode of the
30
+ * entity forms GitHub's renderer resolves (numeric character references,
31
+ * zero-padding and hex included at cmark-gfm's 8-digit bound, plus the named
32
+ * hash entity),
33
+ * so a hash or any digit of the id smuggled as an entity — `&#35;123`,
34
+ * `&num;123`, `#&#49;23`, any mix — still refuses. The decode is single-pass
35
+ * like the renderer's: a double-encoded form (`&amp;#35;123`) renders as
36
+ * inert literal text and the decode pass never manufactures a refusal of its
37
+ * INNER id — though the raw scan still refuses the outer digit run of the
38
+ * numeric form as a pre-existing fail-closed near-miss (the outer entity's
39
+ * semicolon sits before the hash, so the well-formed-entity exclusion does
40
+ * not apply). Case-variants of the named hash entity are decoded too even
41
+ * where GitHub would not (`&NUM;`): deliberate over-refusal, keeping the
42
+ * guard fail-closed. Keep the allowlist small and deliberate.
43
+ */
44
+
45
+ // Matches a bare GitHub auto-link issue/PR reference: `#<digits>`. Bound to
46
+ // 1..9 digits to avoid absurd ids while covering the full GitHub id space.
47
+ // A match is excluded only when it forms a well-formed HTML numeric character
48
+ // reference — preceded by `&` AND immediately followed by `;` (e.g. `&#91;`,
49
+ // the entity-encoded form of `[`). Any other shape (a bare `#123`, an
50
+ // `&`-preceded run with no terminating `;`, or a `;`-followed run with no
51
+ // preceding `&`) is not a well-formed entity and still refuses as a genuine
52
+ // auto-link candidate.
53
+ const ISSUE_PR_ID_RE = /#(\d{1,9})/g;
54
+
55
+ function isNumericCharacterReference(body, match) {
56
+ // The digit bound mirrors cmark-gfm's 8-digit entity parser: a 9-digit
57
+ // ampersand-wrapped run is NOT decoded by the renderer, so it renders
58
+ // literally and must not be excluded as a spent entity.
59
+ return match[1].length <= 8
60
+ && body[match.index - 1] === "&"
61
+ && body[match.index + match[0].length] === ";";
62
+ }
63
+
64
+ // Entity forms the renderer resolves that can participate in assembling a
65
+ // rendered `#<digits>` auto-link: numeric character references (any code
66
+ // point — the hash AND the digits themselves are smuggleable) plus the named
67
+ // hash entity. The digit bounds match cmark-gfm's numeric-entity parser
68
+ // (up to 8 digits, decimal or hex) so nothing GitHub decodes escapes the
69
+ // pass. Single non-rescanning replace = one decode, like the renderer, so a
70
+ // double-encoded form's output is never re-read as a fresh entity.
71
+ const DECODABLE_ENTITY_RE = /&(?:#(?:\d{1,8}|x[0-9a-f]{1,8})|num);/gi;
72
+
73
+ function decodeRenderedText(body) {
74
+ return body.replace(DECODABLE_ENTITY_RE, (entity) => {
75
+ const inner = entity.slice(1, -1).toLowerCase();
76
+ if (inner === "num") return "#";
77
+ const code = inner[1] === "x" ? Number.parseInt(inner.slice(2), 16) : Number.parseInt(inner.slice(1), 10);
78
+ try {
79
+ return String.fromCodePoint(code);
80
+ } catch {
81
+ // cmark substitutes the replacement character for a reference it cannot
82
+ // decode; mirroring that keeps decodable-shaped text from lingering in
83
+ // the decoded scan, where it could masquerade as a spent entity.
84
+ return "�";
85
+ }
86
+ });
87
+ }
88
+
89
+ function collectBareIds(text, found, { excludeEntities }) {
90
+ for (const m of text.matchAll(ISSUE_PR_ID_RE)) {
91
+ if (excludeEntities && isNumericCharacterReference(text, m)) continue;
92
+ found.add(m[1]);
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Extract the raw issue/PR id tokens found in a body (as strings, deduped).
98
+ * Scans the body as written AND after a single renderer-like entity decode,
99
+ * so an id assembled from entity-encoded pieces is still found. The entity
100
+ * exclusion applies only to the RAW scan: in decoded text the renderer's one
101
+ * decode is already spent, so an ampersand-then-digits-then-semicolon shape
102
+ * there is plain text a wrapper cannot re-protect (an ampersand-wrapped
103
+ * encoded hash plus digits must refuse, not hide). Returns [] for non-string
104
+ * input (and for a body with no `#<digits>`).
105
+ */
106
+ export function extractIssuePrIds(body) {
107
+ if (typeof body !== "string" || body.length === 0) return [];
108
+ const found = new Set();
109
+ collectBareIds(body, found, { excludeEntities: true });
110
+ const decoded = decodeRenderedText(body);
111
+ if (decoded !== body) collectBareIds(decoded, found, { excludeEntities: false });
112
+ return [...found];
113
+ }
114
+
115
+ // A caller-supplied allowlist is normally already an array (or other
116
+ // iterable) of ids. Guard the one mis-shaped input that would otherwise
117
+ // silently produce the wrong set: a plain CSV string. `Array.from` over a
118
+ // string character-splits it ("1670" -> ["1","6","7","0"]), which would
119
+ // spuriously allowlist single-digit refs while still refusing the id the
120
+ // caller meant to allow. Mirrors parseAllowedRefsCsv's comma-split (trim,
121
+ // drop empties) — deliberately without its numeric validation, since this is
122
+ // a permissive low-level guard, not the CLI arg parser.
123
+ function normalizeAllowedRefs(allowedRefs) {
124
+ if (allowedRefs == null) return [];
125
+ if (typeof allowedRefs === "string") {
126
+ return allowedRefs.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
127
+ }
128
+ return Array.from(allowedRefs, (id) => String(id));
129
+ }
130
+
131
+ /**
132
+ * Fail-closed guard: returns `body` unchanged when it contains no raw
133
+ * issue/PR id (or every id it contains is explicitly allowlisted). Throws
134
+ * otherwise, refusing to emit the body.
135
+ *
136
+ * @param {string} body - the generated comment body to guard.
137
+ * @param {object} [opts]
138
+ * @param {string} [opts.ref] - human label for the guarded surface (error context).
139
+ * @param {Iterable<number|string>|string} [opts.allowedRefs] - explicit
140
+ * allowlist of deliberate cross-reference ids permitted to appear in the
141
+ * body. A plain string is treated as a comma-separated list (like a CLI
142
+ * `--allowed-refs` value), never character-split.
143
+ * @returns {string} the (unchanged, since no stripping) body.
144
+ */
145
+ export function guardCommentBodyNoIssuePrIds(body, { ref = "generated comment body", allowedRefs = [] } = {}) {
146
+ if (typeof body !== "string") return body;
147
+ const allow = new Set(normalizeAllowedRefs(allowedRefs));
148
+ const offending = extractIssuePrIds(body).filter((id) => !allow.has(id));
149
+ if (offending.length > 0) {
150
+ throw new Error(
151
+ `comment-id-guard refused to emit ${ref}: contains raw issue/PR id reference(s) ` +
152
+ `#${offending.join(", #")}. Bare #digits in generated comment bodies violate the ` +
153
+ `no-ids-in-comments rule (public leakage). Reword to a generic reference, or pass the ` +
154
+ `id(s) to allowedRefs on the guarded write to make an explicit deliberate cross-reference.`,
155
+ );
156
+ }
157
+ return body;
158
+ }
@@ -6,14 +6,32 @@
6
6
  * scripts and other packages/core modules.
7
7
  */
8
8
 
9
+ import { GATE_REVIEW_VERDICT_SET } from "../loop/policy-constants.mjs";
10
+ import { trimmedOrNull } from "../loop/normalize.mjs";
11
+
9
12
  // Exported so anything deciding "is there a real prior review" uses the same
10
13
  // whitelist as the loop-state reader — two copies could drift, and a guard
11
14
  // acting on the gate's behalf must agree with the gate about what a submitted
12
15
  // review is.
13
16
  export const SUBMITTED_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"]);
14
17
  const GATE_REVIEW_NAMES = new Set(["draft_gate", "pre_approval_gate"]);
15
- const GATE_REVIEW_VERDICTS = new Set(["clean", "findings_present", "blocked"]);
18
+ // `review` (the standalone review entrypoint, upsert-checkpoint-verdict.mjs's
19
+ // `--gate review`) is a RECOGNIZED gate header — it is identified, not
20
+ // absent — but carries no draft/pre-approval evidence by design (#1808 AC3).
21
+ // Recognizing it (rather than leaving it unrecognized) is what lets
22
+ // parseGateReviewCommentFields below short-circuit to null the instant a
23
+ // `review` header is seen, instead of falling through to the lenient
24
+ // draft_gate/pre_approval_gate token scan — the fallthrough that previously
25
+ // let a `review` verdict whose findings text merely MENTIONED "draft_gate"
26
+ // get recorded as real draft-gate evidence (a draft-gate bypass).
27
+ const NON_EVIDENCE_GATE_NAMES = new Set(["review"]);
28
+ const RECOGNIZED_GATE_NAMES = new Set([...GATE_REVIEW_NAMES, ...NON_EVIDENCE_GATE_NAMES]);
16
29
  const GATE_EXECUTION_MODES = new Set(["fanout_fanin", "inline_single_agent"]);
30
+ // Size-budget outcome vocabulary — mirrors
31
+ // check-size-budget.mjs's computeSizeBudget outcome enum exactly; this file
32
+ // never recomputes the outcome, only round-trips it through the verdict
33
+ // comment.
34
+ const GATE_SIZE_OUTCOMES = new Set(["pass", "escalate", "block"]);
17
35
 
18
36
  // The literal header line the gate review body always emits first
19
37
  // (upsert-checkpoint-verdict.mjs's renderGateReviewCommentBody, re-exported
@@ -80,6 +98,37 @@ export function isCopilotLogin(login) {
80
98
  return typeof login === "string" && /^copilot(?:[^a-z]|$)/i.test(login);
81
99
  }
82
100
 
101
+ /**
102
+ * Resolve whether Copilot is present as a reviewer on a PR from the REVIEW
103
+ * surface only — requested reviewers plus submitted reviews — never from
104
+ * assignees (#1670).
105
+ *
106
+ * Copilot review is configured in two ways: Copilot is either formally listed
107
+ * in the PR's `requested_reviewers`, or it is a configured auto-reviewer
108
+ * (`copilot-pull-request-reviewer[bot]`) that submits an actual review without
109
+ * ever appearing in `requested_reviewers`. Both are review-surface facts.
110
+ * Assignment is a disjoint surface and must never decide presence: on a
111
+ * reviewer-configured repo Copilot is never an assignee, so an assignee-based
112
+ * proxy would falsely report a fully-configured Copilot reviewer as absent and
113
+ * could let the gate skip the Copilot-convergence requirement on a false premise.
114
+ *
115
+ * @param {object} params
116
+ * @param {boolean} [params.requested] - Copilot is listed in the PR's requested_reviewers
117
+ * @param {Array<{author?: {login?: string}}>} [params.reviews] - PR review list
118
+ * @returns {{ present: boolean, sources: string[] }}
119
+ */
120
+ export function resolveCopilotReviewPresence({ requested = false, reviews = [] } = {}) {
121
+ const list = Array.isArray(reviews) ? reviews : [];
122
+ const sources = [];
123
+ if (requested === true) {
124
+ sources.push("requested_reviewer");
125
+ }
126
+ if (list.some((review) => isCopilotLogin(review?.author?.login))) {
127
+ sources.push("submitted_review");
128
+ }
129
+ return { present: sources.length > 0, sources };
130
+ }
131
+
83
132
  // Anti-summon literal: bare-text `@copilot` or a `/copilot*` slash command. Both
84
133
  // the write-side sanitizer and the read-side guard scan key off this shape so a
85
134
  // gate-evidence comment can quote the rule (inside a code span/fenced block)
@@ -257,14 +306,19 @@ function stripGateCommentMarkdown(rawLine) {
257
306
  return line.trim();
258
307
  }
259
308
 
309
+ // Recognizes BOTH evidence gates (draft_gate/pre_approval_gate) and the
310
+ // non-evidence `review` gate — parseGateReviewCommentFields below relies on
311
+ // `review` coming back as an identified value (not null) so it can
312
+ // short-circuit to non-evidence explicitly, rather than leaving the field
313
+ // null and falling through to the lenient token-scan fallback.
260
314
  function normalizeGateReviewName(value) {
261
315
  const normalized = stripOptionalCodeTicks(value).toLowerCase();
262
- return GATE_REVIEW_NAMES.has(normalized) ? normalized : null;
316
+ return RECOGNIZED_GATE_NAMES.has(normalized) ? normalized : null;
263
317
  }
264
318
 
265
319
  function normalizeGateReviewVerdict(value) {
266
320
  const normalized = stripOptionalCodeTicks(value).toLowerCase();
267
- return GATE_REVIEW_VERDICTS.has(normalized) ? normalized : null;
321
+ return GATE_REVIEW_VERDICT_SET.has(normalized) ? normalized : null;
268
322
  }
269
323
 
270
324
  function normalizeGateReviewHeadSha(value) {
@@ -277,6 +331,32 @@ function normalizeGateExecutionMode(value) {
277
331
  return GATE_EXECUTION_MODES.has(normalized) ? normalized : null;
278
332
  }
279
333
 
334
+ function normalizeGateSizeOutcome(value) {
335
+ const normalized = stripOptionalCodeTicks(value).toLowerCase();
336
+ return GATE_SIZE_OUTCOMES.has(normalized) ? normalized : null;
337
+ }
338
+
339
+ function normalizeGateSizeTouchesT1(value) {
340
+ const normalized = stripOptionalCodeTicks(value).toLowerCase();
341
+ if (normalized === "touched") return true;
342
+ if (normalized === "not touched") return false;
343
+ return null;
344
+ }
345
+
346
+ // "none" | "granted" | "granted by <name>" — the approver name is free text
347
+ // (already sanitized by the poster via sanitizeInline before rendering), so
348
+ // no further normalization beyond trimming is applied here.
349
+ function normalizeGateSizeWaiver(value) {
350
+ const normalized = stripOptionalCodeTicks(value).trim();
351
+ if (/^none$/iu.test(normalized)) return { granted: false, approvedBy: null };
352
+ const grantedMatch = normalized.match(/^granted(?:\s+by\s+(.+))?$/iu);
353
+ if (grantedMatch) {
354
+ const approvedBy = grantedMatch[1]?.trim();
355
+ return { granted: true, approvedBy: approvedBy && approvedBy.length > 0 ? approvedBy : null };
356
+ }
357
+ return null;
358
+ }
359
+
280
360
  function parseGateReviewCommentFields(body) {
281
361
  if (typeof body !== "string" || body.trim().length === 0) {
282
362
  return null;
@@ -290,6 +370,10 @@ function parseGateReviewCommentFields(body) {
290
370
  nextAction: null,
291
371
  executionMode: null,
292
372
  inlineReason: null,
373
+ sizeOutcome: null,
374
+ sizeTouchesT1: null,
375
+ sizeWaiverGranted: null,
376
+ sizeWaiverApprovedBy: null,
293
377
  };
294
378
 
295
379
  for (const rawLine of body.split(/\r?\n/u)) {
@@ -382,6 +466,50 @@ function parseGateReviewCommentFields(body) {
382
466
  }
383
467
  continue;
384
468
  }
469
+
470
+ match = line.match(/^(?:[-*]\s*)?size[\s-]budget\s+outcome\s*:\s*(.+)$/iu);
471
+ if (match) {
472
+ if (fields.sizeOutcome === null) {
473
+ fields.sizeOutcome = normalizeGateSizeOutcome(match[1]);
474
+ }
475
+ continue;
476
+ }
477
+
478
+ match = line.match(/^(?:[-*]\s*)?size[\s-]budget\s+t1\s+slice\s*:\s*(.+)$/iu);
479
+ if (match) {
480
+ if (fields.sizeTouchesT1 === null) {
481
+ fields.sizeTouchesT1 = normalizeGateSizeTouchesT1(match[1]);
482
+ }
483
+ continue;
484
+ }
485
+
486
+ match = line.match(/^(?:[-*]\s*)?size[\s-]budget\s+waiver\s*:\s*(.+)$/iu);
487
+ if (match) {
488
+ if (fields.sizeWaiverGranted === null) {
489
+ const parsedWaiver = normalizeGateSizeWaiver(match[1]);
490
+ if (parsedWaiver) {
491
+ fields.sizeWaiverGranted = parsedWaiver.granted;
492
+ fields.sizeWaiverApprovedBy = parsedWaiver.approvedBy;
493
+ }
494
+ }
495
+ continue;
496
+ }
497
+ }
498
+
499
+ // An explicit, RECOGNIZED `review` gate field is authoritative and returns
500
+ // null here — before the lenient token-scan fallback below ever runs. A
501
+ // `review` verdict comment carries no draft/pre-approval evidence by
502
+ // design (#1808 AC3); without this short circuit, `fields.gate` would stay
503
+ // "review" (not one of the two evidence gates) but the lenient fallback
504
+ // below only fires when `!fields.gate` — so a *recognized* `review` gate
505
+ // would otherwise skip the fallback yet still return non-null fields keyed
506
+ // to "review", which is harmless for the two summarizers here (they only
507
+ // read `.draft_gate`/`.pre_approval_gate`) but leaves the non-evidence
508
+ // contract implicit rather than explicit. Stated plainly: an identified
509
+ // non-evidence gate must never be treated as an unidentified body, and an
510
+ // unidentified body is the ONLY case the token-scan fallback exists for.
511
+ if (NON_EVIDENCE_GATE_NAMES.has(fields.gate)) {
512
+ return null;
385
513
  }
386
514
 
387
515
  // Lenient fallback: detect gate name and head SHA anywhere in body
@@ -450,6 +578,10 @@ export function parseGateReviewCommentMarkerBody(body) {
450
578
  nextAction: fields.nextAction,
451
579
  executionMode: fields.executionMode,
452
580
  inlineReason: fields.inlineReason,
581
+ sizeOutcome: fields.sizeOutcome,
582
+ sizeTouchesT1: fields.sizeTouchesT1,
583
+ sizeWaiverGranted: fields.sizeWaiverGranted,
584
+ sizeWaiverApprovedBy: fields.sizeWaiverApprovedBy,
453
585
  contractComplete: Boolean(fields.verdict && fields.findingsSummary && fields.nextAction),
454
586
  };
455
587
  }
@@ -494,9 +626,13 @@ export function summarizeGateReviewComments(comments) {
494
626
  nextAction: parsed.nextAction,
495
627
  executionMode: parsed.executionMode ?? null,
496
628
  inlineReason: parsed.inlineReason ?? null,
629
+ sizeOutcome: parsed.sizeOutcome ?? null,
630
+ sizeTouchesT1: parsed.sizeTouchesT1 ?? null,
631
+ sizeWaiverGranted: parsed.sizeWaiverGranted ?? null,
632
+ sizeWaiverApprovedBy: parsed.sizeWaiverApprovedBy ?? null,
497
633
  surface: normalizeVerdictSurface(comment?.surface),
498
634
  commentId: Number.isInteger(comment?.id) ? comment.id : null,
499
- commentUrl: typeof comment?.html_url === "string" && comment.html_url.trim().length > 0 ? comment.html_url.trim() : null,
635
+ commentUrl: trimmedOrNull(comment?.html_url),
500
636
  updatedAt: typeof (comment?.updated_at ?? comment?.updatedAt) === "string"
501
637
  ? (comment.updated_at ?? comment.updatedAt).trim()
502
638
  : typeof (comment?.created_at ?? comment?.createdAt) === "string"
@@ -548,10 +684,14 @@ export function summarizeGateReviewCommentMarkers(comments, { headSha } = {}) {
548
684
  nextAction: parsed.nextAction,
549
685
  executionMode: parsed.executionMode ?? null,
550
686
  inlineReason: parsed.inlineReason ?? null,
687
+ sizeOutcome: parsed.sizeOutcome ?? null,
688
+ sizeTouchesT1: parsed.sizeTouchesT1 ?? null,
689
+ sizeWaiverGranted: parsed.sizeWaiverGranted ?? null,
690
+ sizeWaiverApprovedBy: parsed.sizeWaiverApprovedBy ?? null,
551
691
  contractComplete: parsed.contractComplete,
552
692
  surface: normalizeVerdictSurface(comment?.surface),
553
693
  commentId: Number.isInteger(comment?.id) ? comment.id : null,
554
- commentUrl: typeof comment?.html_url === "string" && comment.html_url.trim().length > 0 ? comment.html_url.trim() : null,
694
+ commentUrl: trimmedOrNull(comment?.html_url),
555
695
  updatedAt: typeof (comment?.updated_at ?? comment?.updatedAt) === "string"
556
696
  ? (comment.updated_at ?? comment.updatedAt).trim()
557
697
  : typeof (comment?.created_at ?? comment?.createdAt) === "string"
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Shared `gh` CLI invoke-and-parse helpers (child 6 of the simplification
3
+ * epic #1689). This module introduces the shared implementation only; the
4
+ * ~19 call-site migrations are the follow-up children (projects: #1696;
5
+ * github/loop/refine: #1697).
6
+ *
7
+ * `ghJson` is the composed SUPERSET the callers share (per #1695's AC): a
8
+ * label-conditional non-zero-exit message — `gh command failed: <detail>` with
9
+ * no label (probe-ci-status shape) or `<label> failed: <detail>` with one
10
+ * (fetch-ci-logs shape) — always carrying `code: "GH_API_ERROR"`, plus the
11
+ * inline `Invalid JSON from gh: <stdout|<empty>>` malformed-stdout shape
12
+ * (probe-ci-status). `ghGraphql` reproduces scripts/projects/add-queue-item.mjs's
13
+ * superset (`gh api graphql failed` / `GraphQL errors:` with GH_API_ERROR /
14
+ * GRAPHQL_ERROR; `parseJsonText` → `Invalid JSON input`).
15
+ *
16
+ * Because `ghJson` is a composed superset, NO current caller matches it exactly
17
+ * — migrating each is a deliberate behavior harmonization, not a blind swap:
18
+ * - probe-ci-status.mjs: `gh command failed:` / `Invalid JSON from gh:` already
19
+ * match; it gains the `GH_API_ERROR` code on non-zero exit.
20
+ * - fetch-ci-logs.mjs: `<label> failed:` matches (pass its label); it gains the
21
+ * `GH_API_ERROR` code and its malformed-JSON message becomes
22
+ * `Invalid JSON from gh:` (was `parseJsonText` → `Invalid JSON input`).
23
+ * - upsert-checkpoint-verdict.mjs / post-gate-findings.mjs: gain the code and
24
+ * their malformed-JSON message becomes `Invalid JSON from gh:` (was
25
+ * `Invalid JSON input`).
26
+ * The follow-up children (projects: #1696; github/loop/refine: #1697) migrate
27
+ * each caller and update its pinned test messages accordingly.
28
+ */
29
+
30
+ import { runChild as defaultRunChild } from "../cli/primitives.mjs";
31
+ import { parseJsonText } from "./review-threads.mjs";
32
+
33
+ /**
34
+ * Run a `gh` subcommand and parse its stdout as JSON. Fails loudly on a
35
+ * non-zero exit (naming the command's stderr) and on malformed JSON stdout.
36
+ *
37
+ * @param {string[]} args - argv passed to `ghCommand`.
38
+ * @param {object} [opts]
39
+ * @param {NodeJS.ProcessEnv} [opts.env]
40
+ * @param {string} [opts.ghCommand] - defaults to `"gh"`.
41
+ * @param {typeof defaultRunChild} [opts.runChild] - injectable child-exec seam.
42
+ * @param {string} [opts.label] - controls the non-zero-exit message: when set,
43
+ * the thrown error reads `<label> failed: <detail>` (the fetch-ci-logs shape);
44
+ * when omitted it reads `gh command failed: <detail>` (the probe-ci-status
45
+ * shape). Either way the non-zero-exit error carries `code: "GH_API_ERROR"`.
46
+ */
47
+ export async function ghJson(args, { env, ghCommand = "gh", runChild = defaultRunChild, label } = {}) {
48
+ const result = await runChild(ghCommand, args, env);
49
+ if (result.code !== 0) {
50
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
51
+ const prefix = label ? `${label} failed` : "gh command failed";
52
+ throw Object.assign(new Error(`${prefix}: ${detail}`), { code: "GH_API_ERROR" });
53
+ }
54
+ try {
55
+ return JSON.parse(result.stdout);
56
+ } catch {
57
+ throw new Error(`Invalid JSON from gh: ${result.stdout.trim() || "<empty>"}`);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Run a `gh api graphql` query and parse its response.
63
+ *
64
+ * @param {string} query - the GraphQL document.
65
+ * @param {Record<string, string>} vars - `--field key=value` variables.
66
+ * @param {NodeJS.ProcessEnv} env
67
+ * @param {typeof defaultRunChild} [runChild] - injectable child-exec seam.
68
+ * @param {object} [opts]
69
+ * @param {boolean} [opts.allowErrors] - when true, a GraphQL `errors` array
70
+ * in the response is returned instead of thrown.
71
+ */
72
+ export async function ghGraphql(query, vars, env, runChild = defaultRunChild, { allowErrors = false } = {}) {
73
+ const fieldArgs = [];
74
+ for (const [key, value] of Object.entries(vars)) {
75
+ fieldArgs.push("--field", `${key}=${value}`);
76
+ }
77
+ const result = await runChild(
78
+ "gh",
79
+ ["api", "graphql", "--field", `query=${query}`, ...fieldArgs],
80
+ env,
81
+ );
82
+ if (result.code !== 0) {
83
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
84
+ throw Object.assign(new Error(`gh api graphql failed: ${detail}`), { code: "GH_API_ERROR" });
85
+ }
86
+ const payload = parseJsonText(result.stdout);
87
+ if (!allowErrors && payload.errors && payload.errors.length > 0) {
88
+ throw Object.assign(
89
+ new Error(`GraphQL errors: ${payload.errors.map((e) => e.message).join("; ")}`),
90
+ { code: "GRAPHQL_ERROR" },
91
+ );
92
+ }
93
+ return payload;
94
+ }
@@ -3,6 +3,7 @@ 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";
6
+ import { guardCommentBodyNoIssuePrIds } from "./comment-id-guard.mjs";
6
7
 
7
8
  /**
8
9
  * Core `gh issue` operations, extracted from the thin CLI wrappers under
@@ -227,6 +228,11 @@ export async function resolveCommentBody(options) {
227
228
 
228
229
  export async function commentIssue(options, { env = process.env, ghCommand = "gh", run = defaultRunChild } = {}) {
229
230
  const body = await resolveCommentBody(options);
231
+ // ISSUE/PR-ID GUARD (#1731): a generated comment body must never emit a raw
232
+ // issue/PR id (fail-closed unless explicitly allowlisted). `allowedRefs` is
233
+ // the ONLY sanctioned escape for a deliberate cross-reference, threaded from
234
+ // the generic CLI writers' --allowed-refs option.
235
+ guardCommentBodyNoIssuePrIds(body, { ref: "issue comment body", allowedRefs: options.allowedRefs });
230
236
  const result = await run(
231
237
  ghCommand,
232
238
  ["issue", "comment", String(options.issue), "--repo", options.repo, "--body", body],
@@ -282,6 +288,13 @@ export async function listIssues(options, { env = process.env, ghCommand = "gh",
282
288
  for (const label of options.labels ?? []) {
283
289
  args.push("--label", label);
284
290
  }
291
+ // `--search` narrows the result set to gh's own full-text search (title,
292
+ // body, comments) rather than the bare paged listing — needed by a caller
293
+ // that must find one specific issue by title without trusting that it falls
294
+ // within the default 30-issue page.
295
+ if (typeof options.search === "string" && options.search.length > 0) {
296
+ args.push("--search", options.search);
297
+ }
285
298
  const result = await run(ghCommand, args, env);
286
299
  if (result.code !== 0) {
287
300
  const detail = result.stderr.trim() || `exit code ${result.code}`;