@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.
- package/package.json +2 -1
- package/src/analysis/diff-analyzer.mjs +85 -137
- package/src/claude/asset-generation.mjs +7 -7
- package/src/claude/hook-decisions.mjs +29 -36
- package/src/config/config.mjs +388 -787
- package/src/github/comment-id-guard.mjs +2 -2
- package/src/github/copilot-helpers.mjs +90 -158
- package/src/loop/bash-command-classify.mjs +34 -49
- package/src/loop/conductor-routing.mjs +15 -23
- package/src/loop/copilot-loop-state.mjs +46 -94
- package/src/loop/gate-carry-forward.mjs +2 -2
- package/src/loop/gate-fanin.mjs +252 -442
- package/src/loop/handoff-envelope.mjs +19 -19
- package/src/loop/issue-refinement-artifact.mjs +158 -252
- package/src/loop/lifecycle-state.mjs +10 -21
- package/src/loop/pr-gate-coordination.mjs +37 -37
- package/src/loop/queue-board-sync.mjs +16 -55
- package/src/loop/review-dispatch-plan.mjs +60 -122
- package/src/loop/review-lineage.mjs +19 -44
- package/src/loop/spec-authority.mjs +39 -69
- package/src/loop/steering.mjs +16 -68
- package/src/projects/list-queue-items.mjs +16 -146
- package/src/projects/move-queue-item.mjs +15 -141
- package/src/projects/projects-access.mjs +202 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ISSUE/PR-ID GUARD for generated comment bodies.
|
|
3
3
|
*
|
|
4
|
-
* Mandate (
|
|
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
|
|
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
|
-
//
|
|
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`
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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
|
|
31
|
-
//
|
|
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
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
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
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
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
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
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
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
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
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
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.
|
|
133
|
-
// the write-side sanitizer and the read-side guard
|
|
134
|
-
// gate-evidence comment can quote the rule (
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
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
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
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
|
|
236
|
-
//
|
|
237
|
-
// transformNonFencedLines
|
|
238
|
-
//
|
|
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
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
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:
|
|
387
|
-
// block
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
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
|
-
//
|
|
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
|
|
461
|
-
// "— text" on
|
|
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
|
-
//
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
//
|
|
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
|
-
//
|
|
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
|
|
590
|
-
//
|
|
591
|
-
//
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
//
|
|
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
|
|
715
|
-
* review rounds from the count
|
|
716
|
-
*
|
|
717
|
-
*
|
|
718
|
-
*
|
|
719
|
-
*
|
|
720
|
-
*
|
|
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
|
|
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
|
|
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,
|
|
181
|
-
* `apply`, `save`, `list`, ...) in ANY shell segment —
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
509
|
-
*
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
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
|
|
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
|
|
683
|
-
// prose mention
|
|
684
|
-
//
|
|
685
|
-
//
|
|
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
|
-
//
|
|
703
|
-
// `;`-delimited, so
|
|
704
|
-
//
|
|
705
|
-
//
|
|
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`).
|
|
734
|
-
*
|
|
735
|
-
*
|
|
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
|
|
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];
|