@dev-loops/core 1.0.2-pre.0 → 1.0.2-slim.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.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * ISSUE/PR-ID GUARD for generated comment bodies.
3
3
  *
4
- * Mandate (#1731, operator directive): generated gate/review/verdict comment
4
+ * Mandate (operator directive): generated gate/review/verdict comment
5
5
  * bodies must NEVER emit raw issue or PR ids. Public comment surfaces are
6
6
  * world-readable, and a bare `#<digits>` in a comment body is auto-linked by
7
7
  * GitHub to that issue/PR — leaking internal cross-references and violating
@@ -119,7 +119,7 @@ export function extractIssuePrIds(body) {
119
119
  const BARE_ISSUE_PR_ID_RE = /#+(?=\d)/g;
120
120
 
121
121
  /**
122
- * The sanctioned pre-guard transform for GENERATED comment bodies (#1922):
122
+ * The sanctioned pre-guard transform for GENERATED comment bodies:
123
123
  * neutralize a bare `#<digits>` auto-link token to a guard-safe, non-auto-linking
124
124
  * form by stripping the leading `#` (`#123` -> `123`). Auto-link syntax requires
125
125
  * the leading `#`, so the result neither auto-links on GitHub nor trips
@@ -1,44 +1,34 @@
1
1
  /**
2
2
  * Shared deterministic helpers for Copilot-related GitHub data.
3
- *
4
- * These are pure functions with no filesystem or network dependencies.
5
- * Owner: packages/core — reusable deterministic logic consumed by both
6
- * scripts and other packages/core modules.
3
+ * Pure functions with no filesystem or network dependencies.
7
4
  */
8
5
 
9
6
  import { GATE_REVIEW_VERDICT_SET } from "../loop/policy-constants.mjs";
10
7
  import { trimmedOrNull } from "../loop/normalize.mjs";
11
8
 
12
- // Exported so anything deciding "is there a real prior review" uses the same
13
- // whitelist as the loop-state reader — two copies could drift, and a guard
9
+ // Same whitelist as the loop-state reader: two copies could drift, and a guard
14
10
  // acting on the gate's behalf must agree with the gate about what a submitted
15
11
  // review is.
16
12
  export const SUBMITTED_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"]);
17
13
  const GATE_REVIEW_NAMES = new Set(["draft_gate", "pre_approval_gate"]);
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).
14
+ // `review` is a RECOGNIZED gate header that carries no draft/pre-approval
15
+ // evidence by design. Recognizing it lets
16
+ // parseGateReviewCommentFields short-circuit to null on a `review` header
17
+ // instead of falling through to the lenient draft_gate/pre_approval_gate token
18
+ // scan the fallthrough that would otherwise record a `review` verdict whose
19
+ // findings merely mention "draft_gate" as real draft-gate evidence (a
20
+ // draft-gate bypass).
27
21
  const NON_EVIDENCE_GATE_NAMES = new Set(["review"]);
28
22
  const RECOGNIZED_GATE_NAMES = new Set([...GATE_REVIEW_NAMES, ...NON_EVIDENCE_GATE_NAMES]);
29
23
  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.
24
+ // Size-budget outcome vocabulary; mirrors check-size-budget.mjs's
25
+ // computeSizeBudget outcome enum exactly. This file only round-trips it.
34
26
  const GATE_SIZE_OUTCOMES = new Set(["pass", "escalate", "block"]);
35
27
 
36
- // The literal header line the gate review body always emits first
37
- // (upsert-checkpoint-verdict.mjs's renderGateReviewCommentBody, re-exported
38
- // from there). Owned here so the machine-artifact filter below and every
39
- // consumer that needs to recognize "is this a real gate verdict surface" read
40
- // the same producer-owned literal instead of restating it. Line-start anchored
41
- // (`m`) so a quoted header in a reply/blockquote can't match.
28
+ // The literal header line the gate review body emits first (producer:
29
+ // upsert-checkpoint-verdict.mjs's renderGateReviewCommentBody). Owned here so
30
+ // every consumer reads the same literal instead of restating it. Line-start
31
+ // anchored (`m`) so a quoted header in a reply/blockquote can't match.
42
32
  export const GATE_REVIEW_COMMENT_HEADER_RE = /^###\s+Gate review:\s*`(draft_gate|pre_approval_gate)`\s*$/m;
43
33
 
44
34
  /** Returns the matched gate name when `body` carries a genuine gate verdict header, else null. */
@@ -49,48 +39,31 @@ export function matchGateReviewCommentHeader(body) {
49
39
  }
50
40
 
51
41
  // Machine-authored gate artifacts that must never win the newest-gate-marker
52
- // tie-break in summarizeGateReviewComments/summarizeGateReviewCommentMarkers:
53
- // a historical standalone findings review always embedded this gate's name in
54
- // its header line and could quote the current head sha inside a finding's own
55
- // free text (the lenient gate-name+hex-token fallback in
56
- // parseGateReviewCommentFields would otherwise happily match that), and the
57
- // historical deferred-summary PR comment quoted a gate name plus a sha-shaped
58
- // id in its table rows the same way. Both are excluded HERE, inside the two
59
- // shared summarizers, because this module is the true merge point: every
60
- // consumer (detect-checkpoint-evidence.mjs, pre-pr-ready-gate.mjs,
61
- // ready-for-review.mjs, request-copilot-review.mjs) calls
62
- // summarizeGateReviewComments/summarizeGateReviewCommentMarkers to turn a raw
63
- // comment/review list into a gate verdict, so filtering here — rather than
64
- // per-caller — covers all of them by construction.
42
+ // tie-break in the two summarizers below: a historical standalone findings
43
+ // review or deferred-summary comment embeds a gate name and a sha-shaped id
44
+ // that the lenient parseGateReviewCommentFields fallback would otherwise match.
45
+ // Excluded here because this module is the merge point every consumer routes
46
+ // through.
65
47
  //
66
- // Anchored to the start of a line (`^` with `m`) so only a marker rendered as
67
- // the first character of its own line is excluded a genuine verdict
68
- // comment whose findings summary merely QUOTES the marker text mid-line (for
69
- // example, describing this very mechanism) still counts as evidence. Both
70
- // producers render their marker at column 0, so the anchor costs nothing
71
- // against genuine artifacts.
72
- // The set covers exactly three marker tokens: the per-round review round
73
- // marker (gate-findings-review), post-gate-findings.mjs's opt-in findings
74
- // COMMENT marker (gate-findings gate=...), and the historical
75
- // deferred-summary comment. Without the findings-comment marker, that comment
76
- // parses as a verdict marker candidate (its "Gate fan-out findings:"/
77
- // "Reviewed head:" lines yield gate+headSha) and the verdict upsert claims
78
- // and overwrites it in place, silently destroying the round's visible
79
- // findings record. Every branch is delimiter-anchored — the token must be
80
- // followed by whitespace or the closing `-->` — so no suffixed `<token>-<x>`
81
- // variant ever matches.
48
+ // Line-anchored (`^` with `m`) so only a marker at column 0 is excluded; a
49
+ // genuine verdict whose findings merely QUOTE the marker mid-line still counts.
50
+ // The set covers exactly three tokens: the per-round review marker
51
+ // (gate-findings-review), post-gate-findings.mjs's findings-COMMENT marker
52
+ // (gate-findings), and the deferred-summary comment. Without the
53
+ // findings-comment marker, that comment parses as a verdict candidate and the
54
+ // verdict upsert overwrites it in place, silently destroying the round's
55
+ // findings record. Every branch is delimiter-anchored (token followed by
56
+ // whitespace or `-->`) so no suffixed `<token>-<x>` variant matches.
82
57
  const GATE_MACHINE_ARTIFACT_MARKER_RE = /^<!--\s*dev-loops:(?:gate-findings-review|gate-findings|deferred-summary)(?=\s|-->)/mu;
83
58
 
84
59
  export function isGateMachineArtifactBody(body) {
85
60
  if (typeof body !== "string" || !GATE_MACHINE_ARTIFACT_MARKER_RE.test(body)) {
86
61
  return false;
87
62
  }
88
- // A gate round now posts ONE PR review carrying BOTH the verdict header and
89
- // the gate-findings-review marker (the findings it files live on that same
90
- // surface). Such a body IS the verdict, not a separate machine artifact, so
91
- // the producer-owned verdict header wins over the artifact marker. Only a
92
- // marker-bearing body with NO genuine verdict header (a historical standalone
93
- // findings review or deferred-summary comment) stays excluded.
63
+ // A gate round posts ONE PR review carrying BOTH the verdict header and the
64
+ // gate-findings-review marker. Such a body IS the verdict, so the
65
+ // producer-owned header wins over the artifact marker. Only a marker-bearing
66
+ // body with NO verdict header stays excluded.
94
67
  return matchGateReviewCommentHeader(body) === null;
95
68
  }
96
69
 
@@ -101,16 +74,10 @@ export function isCopilotLogin(login) {
101
74
  /**
102
75
  * Resolve whether Copilot is present as a reviewer on a PR from the REVIEW
103
76
  * 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.
77
+ * assignees. Assignment is a disjoint surface: on a reviewer-configured
78
+ * repo Copilot is never an assignee, so an assignee-based proxy would falsely
79
+ * report a configured Copilot reviewer as absent and let the gate skip the
80
+ * Copilot-convergence requirement on a false premise.
114
81
  *
115
82
  * @param {object} params
116
83
  * @param {boolean} [params.requested] - Copilot is listed in the PR's requested_reviewers
@@ -129,12 +96,12 @@ export function resolveCopilotReviewPresence({ requested = false, reviews = [] }
129
96
  return { present: sources.length > 0, sources };
130
97
  }
131
98
 
132
- // Anti-summon literal: bare-text `@copilot` or a `/copilot*` slash command. Both
133
- // the write-side sanitizer and the read-side guard scan key off this shape so a
134
- // gate-evidence comment can quote the rule (inside a code span/fenced block)
135
- // without arming the request-copilot-review.mjs anti-summon guard. The token
136
- // regex carries the same left word-boundary as the guard regex so the sanitizer
137
- // never mangles text the guard would not arm on (e.g. user@copilot.example).
99
+ // Anti-summon literal: bare-text `@copilot` or a `/copilot*` slash command.
100
+ // Both the write-side sanitizer and the read-side guard key off this shape so a
101
+ // gate-evidence comment can quote the rule (in a code span/fence) without arming
102
+ // the request-copilot-review.mjs anti-summon guard. The token regex carries the
103
+ // same left word-boundary as the guard regex so the sanitizer never mangles text
104
+ // the guard would not arm on (e.g. user@copilot.example).
138
105
  const COPILOT_SUMMON_TOKEN_RE = /(?<=^|\W)(@copilot|\/copilot[a-z0-9_-]*)/gi;
139
106
  const COPILOT_SUMMON_WORD_BOUNDARY_RE = /(?:^|\W)(@copilot|\/copilot)(?:$|\W)/i;
140
107
  // GFM inline code span: an N-backtick run, lazy content, closed by a same-length
@@ -145,8 +112,6 @@ const ZERO_WIDTH_JOINER = "\u200D";
145
112
 
146
113
  // Apply `transformLine` to every markdown line OUTSIDE a fenced code block
147
114
  // (```/~~~), leaving fence-delimiter lines and fenced content untouched.
148
- // Mirrors the fenced-block tracking scripts/docs/validate-rule-ownership.mjs
149
- // uses for its own lexical scan.
150
115
  function transformNonFencedLines(text, transformLine) {
151
116
  const lines = String(text).split(/\r?\n/);
152
117
  let inFencedBlock = false;
@@ -207,19 +172,15 @@ function lineArmsSummonGuard(line) {
207
172
  const ZWJ_FALLBACK_RE = /(?<=^|\W)([@/])(copilot)/gi;
208
173
 
209
174
  // Sanitize one line, verifying against the guard scan. Backtick-wrapping is the
210
- // primary neutralization (visible, greppable), but pre-existing backticks on the
211
- // line can destabilize it two ways: an UNBALANCED stray backtick pairs with an
212
- // inserted one and re-exposes the token to the guard's span-stripping, and
213
- // adjacent spans (e.g. a span ending right before the token's new wrap) can make
214
- // the wrapped line re-tokenize differently on the next pass, re-wrapping the
215
- // token and growing the comment by one backtick per rewrite. The wrapped result
216
- // is therefore accepted only when it is BOTH guard-inert AND a fixed point of
217
- // the wrapper (re-wrapping it changes nothing); otherwise fall back to inserting
218
- // a zero-width joiner into the residual tokens still outside the wrapped line's
219
- // spans — invisible, guard-inert, and idempotent (the joined token no longer
220
- // matches the summon shape). Working on the wrapped line (not the original)
221
- // preserves every stable backtick wrap and keeps the joiner out of legitimate
222
- // pre-existing code spans.
175
+ // primary neutralization (visible, greppable), but pre-existing backticks can
176
+ // destabilize it: an unbalanced stray backtick re-exposes the token to the
177
+ // guard's span-stripping, and adjacent spans can make the wrapped line
178
+ // re-tokenize on the next pass and grow by a backtick per rewrite. So the
179
+ // wrapped result is accepted only when it is BOTH guard-inert AND a fixed point
180
+ // of the wrapper; otherwise fall back to a zero-width joiner in the residual
181
+ // tokens outside the wrapped line's spans invisible, guard-inert, and
182
+ // idempotent. Working on the wrapped line preserves stable wraps and keeps the
183
+ // joiner out of legitimate pre-existing code spans.
223
184
  function sanitizeSummonLine(line) {
224
185
  const wrapped = wrapBareSummonTokensInLine(line);
225
186
  if (!lineArmsSummonGuard(wrapped) && wrapBareSummonTokensInLine(wrapped) === wrapped) {
@@ -232,12 +193,10 @@ export function sanitizeCopilotSummonTokens(text) {
232
193
  return transformNonFencedLines(String(text), sanitizeSummonLine);
233
194
  }
234
195
 
235
- // Drop all markdown code content (fenced blocks entirely, inline code spans
236
- // per line) from `text`, leaving only the bare-text markdown to scan. Unlike
237
- // transformNonFencedLines (which leaves fenced lines verbatim correct for
238
- // sanitizing, where code content must not be rewritten), fenced content here
239
- // must be REMOVED rather than kept: leaving it in place would let bare text
240
- // inside a fence still match the anti-summon scan.
196
+ // Drop all markdown code content (fenced blocks entirely, inline spans per
197
+ // line) from `text`, leaving only bare-text markdown to scan. Unlike
198
+ // transformNonFencedLines, fenced content here must be REMOVED, not kept:
199
+ // leaving it would let bare text inside a fence still match the summon scan.
241
200
  function stripMarkdownCodeForScan(text) {
242
201
  const lines = String(text).split(/\r?\n/);
243
202
  let inFencedBlock = false;
@@ -306,11 +265,10 @@ function stripGateCommentMarkdown(rawLine) {
306
265
  return line.trim();
307
266
  }
308
267
 
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.
268
+ // Recognizes BOTH evidence gates and the non-evidence `review` gate:
269
+ // parseGateReviewCommentFields relies on `review` coming back identified (not
270
+ // null) so it can short-circuit rather than fall through to the lenient
271
+ // token-scan fallback.
314
272
  function normalizeGateReviewName(value) {
315
273
  const normalized = stripOptionalCodeTicks(value).toLowerCase();
316
274
  return RECOGNIZED_GATE_NAMES.has(normalized) ? normalized : null;
@@ -383,21 +341,13 @@ function parseGateReviewCommentFields(body) {
383
341
  }
384
342
  const line = stripped;
385
343
 
386
- // First-NON-EMPTY-wins per field: a genuine comment renders its structured
387
- // block first, so the first column-0 match for each field is normally the
388
- // real one. A free-text field (findings summary, next action) rendered
389
- // later in the SAME comment can embed a newline plus a spoofed
390
- // "Verdict: clean" (or any other field label) at column 0; capturing only
391
- // the first match (rather than the last) stops that later line from
392
- // winning and flipping/nulling the field. But the label regex's
393
- // `\s*(.+)$` also matches a label followed by nothing but whitespace,
394
- // capturing an empty string — for the enum fields (gate/headSha/verdict/
395
- // executionMode) an empty capture normalizes to null already, so the
396
- // `=== null` guard below naturally stays open for a later, genuine line.
397
- // The two free-text fields (findingsSummary, nextAction) do NOT normalize
398
- // through an enum, so an empty capture must be checked for explicitly:
399
- // treat it as no-capture (leave the field open) rather than locking it to
400
- // "" and hiding a real line that renders after it.
344
+ // First-NON-EMPTY-wins per field: the first column-0 match is the genuine
345
+ // structured block. A later free-text field (findings, next action) can
346
+ // embed a spoofed "Verdict: clean" at column 0; capturing only the first
347
+ // match stops that from flipping the field. Enum fields normalize an empty
348
+ // capture (label + whitespace only) to null, so their `=== null` guard
349
+ // stays open for a later genuine line; the two free-text fields do NOT, so
350
+ // an empty capture is checked explicitly and treated as no-capture.
401
351
  let match = line.match(/^(?:[-*]\s*)?(?:gate(?:\s+name)?|gate\s+review)\s*:\s*(.+)$/iu);
402
352
  if (match) {
403
353
  if (fields.gate === null) {
@@ -426,9 +376,7 @@ function parseGateReviewCommentFields(body) {
426
376
  if (match) {
427
377
  if (fields.findingsSummary === null) {
428
378
  const candidate = match[1].trim();
429
- // An empty capture (label followed only by whitespace) is treated as
430
- // no-capture: leave the field open so a later, genuine line can still
431
- // win instead of first-wins locking it to "".
379
+ // Empty capture treated as no-capture (see first-non-empty-wins above).
432
380
  if (candidate.length > 0) {
433
381
  fields.findingsSummary = candidate;
434
382
  }
@@ -457,9 +405,8 @@ function parseGateReviewCommentFields(body) {
457
405
  const modeToken = sepMatch ? sepMatch[1].trim() : rest;
458
406
  const reasonToken = sepMatch ? sepMatch[2].trim() : "";
459
407
  fields.executionMode = normalizeGateExecutionMode(modeToken);
460
- // Only record an inline reason for inline_single_agent. A trailing
461
- // "— text" on a fanout_fanin (or invalid) mode line must not surface an
462
- // inconsistent mode/reason pair, so leave inlineReason null otherwise.
408
+ // Only record an inline reason for inline_single_agent; a trailing
409
+ // "— text" on any other mode must not surface an inconsistent pair.
463
410
  if (reasonToken.length > 0 && fields.executionMode === "inline_single_agent") {
464
411
  fields.inlineReason = reasonToken;
465
412
  }
@@ -496,18 +443,11 @@ function parseGateReviewCommentFields(body) {
496
443
  }
497
444
  }
498
445
 
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.
446
+ // A recognized `review` gate is authoritative and returns null before the
447
+ // lenient token-scan fallback runs: a `review` verdict carries no
448
+ // draft/pre-approval evidence by design. An identified
449
+ // non-evidence gate must never be treated as an unidentified body, which is
450
+ // the only case the token-scan fallback exists for.
511
451
  if (NON_EVIDENCE_GATE_NAMES.has(fields.gate)) {
512
452
  return null;
513
453
  }
@@ -528,9 +468,8 @@ function parseGateReviewCommentFields(body) {
528
468
  }
529
469
 
530
470
  if (!fields.headSha) {
531
- // Prefer SHA following a "head" context marker to avoid false
532
- // matches on plain-text numeric IDs (issue/comment IDs, etc.)
533
- // Example: "pre_approval_gate for head e284c2e341" or "commit abc1234def"
471
+ // Prefer SHA following a "head" context marker to avoid false matches on
472
+ // plain-text numeric IDs (issue/comment IDs, etc.).
534
473
  const ctxShaMatch = flatBody.match(
535
474
  /\b(?:head|sha|commit)\b\s*(?:sha)?\s*[:=]?\s*`?\b([0-9a-f]{7,64})\b`?/iu
536
475
  );
@@ -586,14 +525,12 @@ export function parseGateReviewCommentMarkerBody(body) {
586
525
  };
587
526
  }
588
527
 
589
- // Which GitHub surface carries a gate verdict. The poster needs it to pick the
590
- // right in-place correction endpoint on a same-head rerun (a PR review is PUT
591
- // to pulls/{pr}/reviews/{id}; a legacy verdict issue comment is PATCHed to
592
- // issues/comments/{id}). Anything that is not the review surface including a
593
- // raw issue-comment payload with no `surface` field is issue_comment, so the
594
- // historical shape survives untouched. SINGLE definition: a restatement that
595
- // misses a future third surface would silently route its body to the
596
- // issue-comment endpoint, where it does not live.
528
+ // Which GitHub surface carries a gate verdict; the poster uses it to pick the
529
+ // in-place correction endpoint on a same-head rerun (review PUT
530
+ // pulls/{pr}/reviews/{id}; issue comment PATCH issues/comments/{id}).
531
+ // Anything not the review surface (including a payload with no `surface` field)
532
+ // is issue_comment. SINGLE definition: a restatement missing a future third
533
+ // surface would misroute its body to the issue-comment endpoint.
597
534
  export function normalizeVerdictSurface(value) {
598
535
  return value === "review" ? "review" : "issue_comment";
599
536
  }
@@ -711,18 +648,13 @@ export function summarizeGateReviewCommentMarkers(comments, { headSha } = {}) {
711
648
  }
712
649
 
713
650
  /**
714
- * Resolve the draft-gate round-reset timestamp (ms) used to suppress stale Copilot
715
- * review rounds from the count (#896 consistency).
716
- *
717
- * When the draft gate was re-passed clean on a DIFFERENT head than the current one,
718
- * only Copilot reviews submitted after that re-pass should count toward the round
719
- * cap. Returning the re-pass `updatedAt` (ms) lets {@link summarizeCopilotReviews}
720
- * drop earlier rounds. Returns null when no reset applies (no clean draft gate, or
721
- * the clean draft gate is already on the current head).
722
- *
723
- * Both detect-pr-gate-coordination-state and request-copilot-review must derive the
724
- * reset identically, or the two scripts disagree on the completed round count and
725
- * the cap (the inconsistency reported in #896). This is the single shared source.
651
+ * Resolve the draft-gate round-reset timestamp (ms) used to suppress stale
652
+ * Copilot review rounds from the count. When the draft gate re-passed
653
+ * clean on a DIFFERENT head, only Copilot reviews after that re-pass count
654
+ * toward the round cap; returning the re-pass `updatedAt` (ms) lets
655
+ * summarizeCopilotReviews drop earlier rounds. Null when no reset applies.
656
+ * Single shared source: detect-pr-gate-coordination-state and
657
+ * request-copilot-review must derive the reset identically.
726
658
  *
727
659
  * @param {object} params
728
660
  * @param {{ verdict?: string|null, headSha?: string|null, updatedAt?: string|null }|null} params.draftGate
@@ -26,7 +26,7 @@ const SHELL_SEGMENT_SEPARATOR = /\s*(?:&&|\|\||;|\||\n|\r)\s*/;
26
26
  /**
27
27
  * Strip a single balanced surrounding quote pair (`'…'` or `"…"`) from a shell arg value.
28
28
  * A repo flag value may reach us quoted (`--repo 'owner/name'`); the scope check compares against
29
- * the bare slug, so quotes must be normalized or a quoted on-target repo evades the guard (#1074).
29
+ * the bare slug, so quotes must be normalized or a quoted on-target repo evades the guard.
30
30
  * ponytail: single balanced pair only — no full shell tokenization (mismatched/partial quotes stay).
31
31
  * @param {string|null} value @returns {string|null}
32
32
  */
@@ -43,7 +43,7 @@ function stripSurroundingQuotes(value) {
43
43
  * Read an inline `GH_REPO=<value>` env-assignment prefix on a single command segment.
44
44
  * `gh` resolves its target repo from the `GH_REPO` env var, and a segment may set it inline
45
45
  * (`GH_REPO=owner/name gh issue create …`) — same targeting intent as `--repo owner/name`, so the
46
- * scope check must treat it the same or an off-cwd redirect evades the guard (#1074). Only the
46
+ * scope check must treat it the same or an off-cwd redirect evades the guard. Only the
47
47
  * FIRST leading env assignment matching `GH_REPO=` is read (env assignments precede the executable);
48
48
  * the value is quote-normalized. Ambient `process.env.GH_REPO` is out of scope — this is a static
49
49
  * command-string classifier, so only the inline assignment in the string is considered.
@@ -177,16 +177,15 @@ const GIT_GLOBAL_OPTION_RUN =
177
177
  "(?:(?:-C|-c)\\s+\\S+\\s+|--(?:git-dir|work-tree)=\\S+\\s+|--?[A-Za-z][\\w-]*\\s+)*";
178
178
 
179
179
  /**
180
- * Whether `command` contains a `git stash` invocation (any subcommand: bare, `push`, `pop`,
181
- * `apply`, `save`, `list`, ...) in ANY shell segment — including behind the same env-assignment /
182
- * `command`/`env`/`exec` wrapper / binary-path prefix (`GIT_DIR=.git git stash`, `command git
183
- * stash`, `/usr/bin/git stash`) and git global options between `git` and `stash` (`git -C /tmp
184
- * stash`, `git -c name=value stash pop`) that the sibling `gh` classifiers in this file already
185
- * tolerate. Anchored per-segment, so `git stashed`, `git commit -m "git stash"`, or a path literal
186
- * containing "git stash" never match. `refs/stash` is a single ref shared by every worktree over
187
- * this repo's one `.git` directory, so a stash from one worktree can pop into another's — the
188
- * PreToolUse gate blocks it outright on the target repo (see
189
- * `skills/docs/worktree-guidance.md#never-git-stash-in-a-shared-git-layout`).
180
+ * Whether `command` contains a `git stash` invocation (any subcommand: bare,
181
+ * `push`, `pop`, `apply`, `save`, `list`, ...) in ANY shell segment —
182
+ * including behind an env-assignment/wrapper/path prefix and git global
183
+ * options between `git` and `stash` (mirrors the `gh` classifiers' tolerance
184
+ * in this file). Anchored per-segment, so `git stashed` or a path literal
185
+ * containing "git stash" never match. `refs/stash` is shared by every
186
+ * worktree over this repo's one `.git` directory, so a stash from one
187
+ * worktree can pop into another's — the PreToolUse gate blocks it outright
188
+ * (see `skills/docs/worktree-guidance.md#never-git-stash-in-a-shared-git-layout`).
190
189
  * @param {string} command @returns {boolean}
191
190
  */
192
191
  export function commandContainsGitStash(command) {
@@ -237,7 +236,7 @@ function extractRepoFlagFromSubcmdSegment(segment, subcmd, verb) {
237
236
  if (repoEqMatch) return stripSurroundingQuotes(repoEqMatch[1]);
238
237
  }
239
238
  // No explicit --repo/-R flag: fall back to an inline GH_REPO= env assignment (flag wins,
240
- // mirroring gh's own precedence). This closes the GH_REPO repo-targeting bypass (#1074).
239
+ // mirroring gh's own precedence). This closes the GH_REPO repo-targeting bypass.
241
240
  return extractGhRepoEnvAssignment(segment);
242
241
  }
243
242
 
@@ -379,7 +378,7 @@ function extractRepoFlagFromSegment(segment, verb) {
379
378
  }
380
379
  // No explicit --repo/-R flag: fall back to an inline GH_REPO= env assignment (flag wins,
381
380
  // mirroring gh's own precedence). Applied here too so gh pr ready/merge/create scope checks get
382
- // consistent GH_REPO handling — the root-cause fix, not just the external-write path (#1074).
381
+ // consistent GH_REPO handling — the root-cause fix, not just the external-write path.
383
382
  return extractGhRepoEnvAssignment(segment);
384
383
  }
385
384
 
@@ -498,21 +497,20 @@ export function extractRepoFlagFromGhPrMerge(command) {
498
497
  }
499
498
 
500
499
  // ---------------------------------------------------------------------------
501
- // gh api URL-path matchers + the six guard-rule classifiers (#1622).
500
+ // gh api URL-path matchers + the six guard-rule classifiers.
502
501
  // These make the six rules that describe operations the Bash gate could refuse
503
502
  // enforceable at one seam (decideBashGate in hook-decisions.mjs), where raw
504
503
  // `gh api` shapes were previously unclassified (anything expressed as a raw API
505
504
  // call was invisible to the gate).
506
505
  // ---------------------------------------------------------------------------
507
506
 
508
- /** gh api value-taking flags (short forms). Each consumes the following token. Lowercase (compared
509
- * against token.toLowerCase()) — covers every value-taking short flag gh api accepts so a flag
510
- * placed BEFORE the endpoint skips its value and the real endpoint is still read (#1622):
511
- * -X/--method, -m/--method, -f/--field, -F/--raw-field (both case-fold to -f), -q/--jq, -p/--preview,
512
- * -t/--template, -r/--repo. `-h` (help) is intentionally EXCLUDED: it is a boolean help flag that
513
- * consumes no value, and case-folding it together with `-H` (header) made a mid-command `-h`
514
- * swallow the real endpoint and bypass the write-path deny (#1622). `-H` is matched as an exact
515
- * token in the scanner so it stays a value-taking flag despite the case-fold. */
507
+ /** gh api value-taking flags (short forms); each consumes the following token. Lowercase-compared,
508
+ * covering every value-taking short flag gh api accepts so a flag placed BEFORE the endpoint skips
509
+ * its value and the real endpoint is still read: -X/--method, -m/--method, -f/--field, -F/--raw-field
510
+ * (both case-fold to -f), -q/--jq, -p/--preview, -t/--template, -r/--repo. `-h` (help) is EXCLUDED —
511
+ * it takes no value, and folding it with `-H` (header) would let a mid-command `-h` swallow the real
512
+ * endpoint and bypass the write-path deny. `-H` stays an exact-token value-taking flag
513
+ * despite the case-fold. */
516
514
  const GH_API_VALUE_FLAGS = new Set(["-x", "-m", "-f", "-r", "-q", "-p", "-t"]);
517
515
  /** gh api value-taking flags (long forms). Each consumes the following token. */
518
516
  const GH_API_VALUE_LONG_FLAGS = new Set([
@@ -588,7 +586,7 @@ function targetGhApiPathRegex(suffix) {
588
586
  /** Strip a `scheme://host` prefix from an absolute gh api URL endpoint (`https://api.github.com/...`),
589
587
  * yielding the bare `/repos/<slug>/…` path that the write-path anchors match. gh api accepts both a
590
588
  * bare `repos/<slug>/…`/`issues/…` path and an absolute https:// URL, so both must reach the same
591
- * anchors or an absolute-URL write bypasses the deny (#1622). */
589
+ * anchors or an absolute-URL write bypasses the deny. */
592
590
  function normalizeGhApiEndpoint(endpoint) {
593
591
  if (!endpoint) return endpoint;
594
592
  return endpoint.replace(/^https?:\/\/[^/]+/, "").replace(/^\//, "").replace(/\/+$/, "");
@@ -679,14 +677,10 @@ export function commandContainsCopilotRequestBypass(command) {
679
677
  */
680
678
  export function commandContainsCopilotSummonComment(command) {
681
679
  if (!findGhSubcmdVerbSegment(command, "pr", "comment")) return false;
682
- // A bare summon is `/copilot` or `/copilot re-review` on its own (optionally quoted)never a
683
- // prose mention like `see /copilot for more` / `see /copilot docs`. A bare `/copilot` must run to
684
- // the end of the (quoted) body; the explicit `re-review` form allows trailing modifiers
685
- // (`/copilot re-review now`) so appending a word cannot defeat the summon deny (#1622).
686
- // A summon is `/copilot`/`/copilot re-review` at the START of the quoted body — anchored on the
687
- // opening quote so a trailing prose mention (`--body "see /copilot"` / `"thanks /copilot"`) is
688
- // NOT misread as a bare summon, and an in-prose `/copilot re-review` (`"see ... re-review in
689
- // docs"`) is likewise not a summon. Only `gh pr comment` segments reach here (guard above).
680
+ // A summon is `/copilot`/`/copilot re-review` anchored at the START of the quoted body — a
681
+ // trailing prose mention (`--body "see /copilot"`) or in-prose `/copilot re-review` never
682
+ // matches. The `re-review` form allows trailing modifiers (`/copilot re-review now`) so
683
+ // appending a word cannot defeat the deny. Only `gh pr comment` segments reach here.
690
684
  return /(["'])\s*\/copilot(?:\s+re-review\b(?:\s+[^\s"']+)*|\s*(?:["']|$))/i.test(command);
691
685
  }
692
686
 
@@ -699,17 +693,10 @@ export function commandContainsCopilotSummonComment(command) {
699
693
  */
700
694
  export function commandContainsDetachedWaitTool(command) {
701
695
  const whole = command.trim();
702
- // while/until/seq polling loop with both a sleep and a gh or loop-state call. The loop body is
703
- // `;`-delimited, so this is checked against the whole command (a per-segment split would
704
- // separate the `while` head from the `sleep`/`gh` body calls and miss the pattern).
705
- // A polling loop is detected wherever the `while`/`until`/`seq` head appears (a leading expression
706
- // like `gh pr view 1 && while ...` must not silence the deny) as long as the body carries both a
707
- // `sleep` and a gh/loop-state call. `gh` must be a standalone token (followed by whitespace/end) —
708
- // a bare mention of `gh` inside another word (`grep gh-notes`) is not a GitHub call.
709
- // while/until/for loop heads (a bare `seq` sequence generator is not a loop head on its own —
710
- // `seq | while read` is caught by the `while` head), with `sleep` and a gh/loop-state *call*.
711
- // loop-state must sit at a command-head position (`; lo`, `&& lo`, start), not be a substring of
712
- // a grep/echo target (no false-deny on `grep loop-state x`).
696
+ // Checked on the WHOLE command (not per-segment): the `while`/`until`/`for` loop body is
697
+ // `;`-delimited, so a per-segment split would separate the loop head from its `sleep`/`gh`
698
+ // body calls and miss the pattern. `gh` must be a standalone token (not `grep gh-notes`), and
699
+ // `loop-state` must sit at a command-head position (not a substring inside `grep loop-state x`).
713
700
  if (/(?:while|until|for)\b/i.test(whole) && /\bsleep\b/.test(whole) && /\bgh(?=\s|$)|(?:^|[;&|(])\s*loop-state(?=\s|$)/.test(whole)) {
714
701
  return true;
715
702
  }
@@ -730,11 +717,9 @@ function interpreterRegex(bin) {
730
717
 
731
718
  /**
732
719
  * OPS-NO-INLINE-INTERPRETER: an inline interpreter — `node -e`/`--eval`/`-p`, `python3 -c`, or a
733
- * heredoc fed to node/python (`node - <<EOF`, `python3 - <<EOF`). Ported from the long-orphaned
734
- * inline-interpreter classifier in the retrospective-tooling check (zero production callers). Sanctioned
735
- * output parsing uses `--jq`/`--silent`, never an inline interpreter. Actor-independent: the rule bars
736
- * "Coordinator and agent flows" (both actors). Script-path invocations (running a `.mjs` file,
737
- * `python3 script.py`) never match.
720
+ * heredoc fed to node/python (`node - <<EOF`, `python3 - <<EOF`). Sanctioned output parsing uses
721
+ * `--jq`/`--silent`, never an inline interpreter. Actor-independent (bars both coordinator and
722
+ * agent flows). Script-path invocations (running a `.mjs` file, `python3 script.py`) never match.
738
723
  * @param {string} command @returns {boolean}
739
724
  */
740
725
  export function commandContainsInlineInterpreter(command) {
@@ -748,7 +733,7 @@ export function commandContainsInlineInterpreter(command) {
748
733
  const tokens = code.split(/\s+/).filter(Boolean);
749
734
  // Node value-taking flags (short + long) each consume the following token. Consuming them lets
750
735
  // a value-taking flag BEFORE the interpreter flag (`node --require ./setup.js -e "..."`) route
751
- // on to `-e`/`--eval`/`-p` instead of breaking the scan at the flag's value (#1622).
736
+ // on to `-e`/`--eval`/`-p` instead of breaking the scan at the flag's value.
752
737
  const NODE_VALUE_FLAGS = new Set(["-r", "--require", "--import", "--loader", "--experimental-loader", "--env-file", "--conditions", "-C", "--cwd"]);
753
738
  for (let i = 0; i < tokens.length; i++) {
754
739
  const t = tokens[i];