@dev-loops/core 1.0.0-rc.2 → 1.0.0-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -1
- package/src/analysis/diff-analyzer.mjs +31 -5
- package/src/claude/hook-decisions.mjs +14 -0
- package/src/cli/primitives.mjs +10 -2
- package/src/cli/retry-wrapper.mjs +14 -6
- package/src/config/config.mjs +1125 -240
- package/src/config/extension-defaults.yaml +217 -426
- package/src/github/copilot-helpers.mjs +139 -18
- package/src/github/issue-ops.mjs +556 -0
- package/src/github/ownership-helpers.mjs +79 -0
- package/src/github/review-threads.mjs +44 -3
- package/src/loop/bash-command-classify.mjs +35 -5
- package/src/loop/conductor-routing.mjs +1 -1
- package/src/loop/copilot-ci-status.mjs +76 -0
- package/src/loop/copilot-loop-iterations.mjs +1 -2
- package/src/loop/copilot-loop-state.mjs +9 -5
- package/src/loop/default-branch-guard.mjs +380 -0
- package/src/loop/gate-carry-forward.mjs +29 -2
- package/src/loop/gate-fanin.mjs +481 -31
- package/src/loop/handoff-envelope.mjs +43 -23
- package/src/loop/main-checkout-ff.mjs +58 -0
- package/src/loop/pr-gate-coordination.mjs +204 -47
- package/src/loop/pr-title-markers.mjs +76 -15
- package/src/loop/queue-board-sync.mjs +26 -9
- package/src/loop/reviewer-loop-state.mjs +2 -2
- package/src/loop/ui-e2e-scoping.mjs +2 -0
- package/src/loop/ui-review-drive.mjs +23 -0
- package/src/loop/ui-review-provision.mjs +36 -0
- package/src/projects/resolve-project.mjs +14 -7
- package/src/tracker/adapter.mjs +127 -0
- package/src/tracker/github-adapter.mjs +150 -0
- package/src/tracker/index.mjs +50 -0
- package/src/tracker/noop-adapter.mjs +35 -0
|
@@ -6,11 +6,76 @@
|
|
|
6
6
|
* scripts and other packages/core modules.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
// Exported so anything deciding "is there a real prior review" uses the same
|
|
10
|
+
// whitelist as the loop-state reader — two copies could drift, and a guard
|
|
11
|
+
// acting on the gate's behalf must agree with the gate about what a submitted
|
|
12
|
+
// review is.
|
|
13
|
+
export const SUBMITTED_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"]);
|
|
10
14
|
const GATE_REVIEW_NAMES = new Set(["draft_gate", "pre_approval_gate"]);
|
|
11
15
|
const GATE_REVIEW_VERDICTS = new Set(["clean", "findings_present", "blocked"]);
|
|
12
16
|
const GATE_EXECUTION_MODES = new Set(["fanout_fanin", "inline_single_agent"]);
|
|
13
17
|
|
|
18
|
+
// The literal header line the gate review body always emits first
|
|
19
|
+
// (upsert-checkpoint-verdict.mjs's renderGateReviewCommentBody, re-exported
|
|
20
|
+
// from there). Owned here so the machine-artifact filter below and every
|
|
21
|
+
// consumer that needs to recognize "is this a real gate verdict surface" read
|
|
22
|
+
// the same producer-owned literal instead of restating it. Line-start anchored
|
|
23
|
+
// (`m`) so a quoted header in a reply/blockquote can't match.
|
|
24
|
+
export const GATE_REVIEW_COMMENT_HEADER_RE = /^###\s+Gate review:\s*`(draft_gate|pre_approval_gate)`\s*$/m;
|
|
25
|
+
|
|
26
|
+
/** Returns the matched gate name when `body` carries a genuine gate verdict header, else null. */
|
|
27
|
+
export function matchGateReviewCommentHeader(body) {
|
|
28
|
+
if (typeof body !== "string") return null;
|
|
29
|
+
const match = body.match(GATE_REVIEW_COMMENT_HEADER_RE);
|
|
30
|
+
return match ? match[1] : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Machine-authored gate artifacts that must never win the newest-gate-marker
|
|
34
|
+
// tie-break in summarizeGateReviewComments/summarizeGateReviewCommentMarkers:
|
|
35
|
+
// a historical standalone findings review always embedded this gate's name in
|
|
36
|
+
// its header line and could quote the current head sha inside a finding's own
|
|
37
|
+
// free text (the lenient gate-name+hex-token fallback in
|
|
38
|
+
// parseGateReviewCommentFields would otherwise happily match that), and the
|
|
39
|
+
// historical deferred-summary PR comment quoted a gate name plus a sha-shaped
|
|
40
|
+
// id in its table rows the same way. Both are excluded HERE, inside the two
|
|
41
|
+
// shared summarizers, because this module is the true merge point: every
|
|
42
|
+
// consumer (detect-checkpoint-evidence.mjs, pre-pr-ready-gate.mjs,
|
|
43
|
+
// ready-for-review.mjs, request-copilot-review.mjs) calls
|
|
44
|
+
// summarizeGateReviewComments/summarizeGateReviewCommentMarkers to turn a raw
|
|
45
|
+
// comment/review list into a gate verdict, so filtering here — rather than
|
|
46
|
+
// per-caller — covers all of them by construction.
|
|
47
|
+
//
|
|
48
|
+
// Anchored to the start of a line (`^` with `m`) so only a marker rendered as
|
|
49
|
+
// the first character of its own line is excluded — a genuine verdict
|
|
50
|
+
// comment whose findings summary merely QUOTES the marker text mid-line (for
|
|
51
|
+
// example, describing this very mechanism) still counts as evidence. Both
|
|
52
|
+
// producers render their marker at column 0, so the anchor costs nothing
|
|
53
|
+
// against genuine artifacts.
|
|
54
|
+
// The set covers exactly three marker tokens: the per-round review round
|
|
55
|
+
// marker (gate-findings-review), post-gate-findings.mjs's opt-in findings
|
|
56
|
+
// COMMENT marker (gate-findings gate=...), and the historical
|
|
57
|
+
// deferred-summary comment. Without the findings-comment marker, that comment
|
|
58
|
+
// parses as a verdict marker candidate (its "Gate fan-out findings:"/
|
|
59
|
+
// "Reviewed head:" lines yield gate+headSha) and the verdict upsert claims
|
|
60
|
+
// and overwrites it in place, silently destroying the round's visible
|
|
61
|
+
// findings record. Every branch is delimiter-anchored — the token must be
|
|
62
|
+
// followed by whitespace or the closing `-->` — so no suffixed `<token>-<x>`
|
|
63
|
+
// variant ever matches.
|
|
64
|
+
const GATE_MACHINE_ARTIFACT_MARKER_RE = /^<!--\s*dev-loops:(?:gate-findings-review|gate-findings|deferred-summary)(?=\s|-->)/mu;
|
|
65
|
+
|
|
66
|
+
export function isGateMachineArtifactBody(body) {
|
|
67
|
+
if (typeof body !== "string" || !GATE_MACHINE_ARTIFACT_MARKER_RE.test(body)) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
// A gate round now posts ONE PR review carrying BOTH the verdict header and
|
|
71
|
+
// the gate-findings-review marker (the findings it files live on that same
|
|
72
|
+
// surface). Such a body IS the verdict, not a separate machine artifact, so
|
|
73
|
+
// the producer-owned verdict header wins over the artifact marker. Only a
|
|
74
|
+
// marker-bearing body with NO genuine verdict header (a historical standalone
|
|
75
|
+
// findings review or deferred-summary comment) stays excluded.
|
|
76
|
+
return matchGateReviewCommentHeader(body) === null;
|
|
77
|
+
}
|
|
78
|
+
|
|
14
79
|
export function isCopilotLogin(login) {
|
|
15
80
|
return typeof login === "string" && /^copilot(?:[^a-z]|$)/i.test(login);
|
|
16
81
|
}
|
|
@@ -234,50 +299,86 @@ function parseGateReviewCommentFields(body) {
|
|
|
234
299
|
}
|
|
235
300
|
const line = stripped;
|
|
236
301
|
|
|
302
|
+
// First-NON-EMPTY-wins per field: a genuine comment renders its structured
|
|
303
|
+
// block first, so the first column-0 match for each field is normally the
|
|
304
|
+
// real one. A free-text field (findings summary, next action) rendered
|
|
305
|
+
// later in the SAME comment can embed a newline plus a spoofed
|
|
306
|
+
// "Verdict: clean" (or any other field label) at column 0; capturing only
|
|
307
|
+
// the first match (rather than the last) stops that later line from
|
|
308
|
+
// winning and flipping/nulling the field. But the label regex's
|
|
309
|
+
// `\s*(.+)$` also matches a label followed by nothing but whitespace,
|
|
310
|
+
// capturing an empty string — for the enum fields (gate/headSha/verdict/
|
|
311
|
+
// executionMode) an empty capture normalizes to null already, so the
|
|
312
|
+
// `=== null` guard below naturally stays open for a later, genuine line.
|
|
313
|
+
// The two free-text fields (findingsSummary, nextAction) do NOT normalize
|
|
314
|
+
// through an enum, so an empty capture must be checked for explicitly:
|
|
315
|
+
// treat it as no-capture (leave the field open) rather than locking it to
|
|
316
|
+
// "" and hiding a real line that renders after it.
|
|
237
317
|
let match = line.match(/^(?:[-*]\s*)?(?:gate(?:\s+name)?|gate\s+review)\s*:\s*(.+)$/iu);
|
|
238
318
|
if (match) {
|
|
239
|
-
fields.gate
|
|
319
|
+
if (fields.gate === null) {
|
|
320
|
+
fields.gate = normalizeGateReviewName(match[1]);
|
|
321
|
+
}
|
|
240
322
|
continue;
|
|
241
323
|
}
|
|
242
324
|
|
|
243
325
|
match = line.match(/^(?:[-*]\s*)?(?:head\s+sha(?:\s+reviewed)?|reviewed\s+head\s+sha)\s*:\s*(.+)$/iu);
|
|
244
326
|
if (match) {
|
|
245
|
-
fields.headSha
|
|
327
|
+
if (fields.headSha === null) {
|
|
328
|
+
fields.headSha = normalizeGateReviewHeadSha(match[1]);
|
|
329
|
+
}
|
|
246
330
|
continue;
|
|
247
331
|
}
|
|
248
332
|
|
|
249
333
|
match = line.match(/^(?:[-*]\s*)?verdict\s*:\s*(.+)$/iu);
|
|
250
334
|
if (match) {
|
|
251
|
-
fields.verdict
|
|
335
|
+
if (fields.verdict === null) {
|
|
336
|
+
fields.verdict = normalizeGateReviewVerdict(match[1]);
|
|
337
|
+
}
|
|
252
338
|
continue;
|
|
253
339
|
}
|
|
254
340
|
|
|
255
341
|
match = line.match(/^(?:[-*]\s*)?(?:findings(?:\s+summary)?|summary)\s*:\s*(.+)$/iu);
|
|
256
342
|
if (match) {
|
|
257
|
-
fields.findingsSummary
|
|
343
|
+
if (fields.findingsSummary === null) {
|
|
344
|
+
const candidate = match[1].trim();
|
|
345
|
+
// An empty capture (label followed only by whitespace) is treated as
|
|
346
|
+
// no-capture: leave the field open so a later, genuine line can still
|
|
347
|
+
// win instead of first-wins locking it to "".
|
|
348
|
+
if (candidate.length > 0) {
|
|
349
|
+
fields.findingsSummary = candidate;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
258
352
|
continue;
|
|
259
353
|
}
|
|
260
354
|
|
|
261
355
|
match = line.match(/^(?:[-*]\s*)?next\s+action\s*:\s*(.+)$/iu);
|
|
262
356
|
if (match) {
|
|
263
|
-
fields.nextAction
|
|
357
|
+
if (fields.nextAction === null) {
|
|
358
|
+
const candidate = match[1].trim();
|
|
359
|
+
if (candidate.length > 0) {
|
|
360
|
+
fields.nextAction = candidate;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
264
363
|
continue;
|
|
265
364
|
}
|
|
266
365
|
|
|
267
366
|
match = line.match(/^(?:[-*]\s*)?execution\s+mode\s*:\s*(.+)$/iu);
|
|
268
367
|
if (match) {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
fields.
|
|
368
|
+
if (fields.executionMode === null) {
|
|
369
|
+
const rest = match[1].trim();
|
|
370
|
+
// Split on the first em-dash / en-dash / " - " separator to recover an
|
|
371
|
+
// optional inline reason: "inline_single_agent — <reason>".
|
|
372
|
+
const sepMatch = rest.match(/^(.*?)\s*(?:[—–]|\s-\s)\s*(.*)$/u);
|
|
373
|
+
const modeToken = sepMatch ? sepMatch[1].trim() : rest;
|
|
374
|
+
const reasonToken = sepMatch ? sepMatch[2].trim() : "";
|
|
375
|
+
fields.executionMode = normalizeGateExecutionMode(modeToken);
|
|
376
|
+
// Only record an inline reason for inline_single_agent. A trailing
|
|
377
|
+
// "— text" on a fanout_fanin (or invalid) mode line must not surface an
|
|
378
|
+
// inconsistent mode/reason pair, so leave inlineReason null otherwise.
|
|
379
|
+
if (reasonToken.length > 0 && fields.executionMode === "inline_single_agent") {
|
|
380
|
+
fields.inlineReason = reasonToken;
|
|
381
|
+
}
|
|
281
382
|
}
|
|
282
383
|
continue;
|
|
283
384
|
}
|
|
@@ -353,6 +454,18 @@ export function parseGateReviewCommentMarkerBody(body) {
|
|
|
353
454
|
};
|
|
354
455
|
}
|
|
355
456
|
|
|
457
|
+
// Which GitHub surface carries a gate verdict. The poster needs it to pick the
|
|
458
|
+
// right in-place correction endpoint on a same-head rerun (a PR review is PUT
|
|
459
|
+
// to pulls/{pr}/reviews/{id}; a legacy verdict issue comment is PATCHed to
|
|
460
|
+
// issues/comments/{id}). Anything that is not the review surface — including a
|
|
461
|
+
// raw issue-comment payload with no `surface` field — is issue_comment, so the
|
|
462
|
+
// historical shape survives untouched. SINGLE definition: a restatement that
|
|
463
|
+
// misses a future third surface would silently route its body to the
|
|
464
|
+
// issue-comment endpoint, where it does not live.
|
|
465
|
+
export function normalizeVerdictSurface(value) {
|
|
466
|
+
return value === "review" ? "review" : "issue_comment";
|
|
467
|
+
}
|
|
468
|
+
|
|
356
469
|
export function summarizeGateReviewComments(comments) {
|
|
357
470
|
const summary = {
|
|
358
471
|
draft_gate: null,
|
|
@@ -363,6 +476,9 @@ export function summarizeGateReviewComments(comments) {
|
|
|
363
476
|
|
|
364
477
|
for (let index = 0; index < entries.length; index += 1) {
|
|
365
478
|
const comment = entries[index];
|
|
479
|
+
if (isGateMachineArtifactBody(comment?.body)) {
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
366
482
|
const parsed = parseGateReviewCommentBody(comment?.body);
|
|
367
483
|
if (!parsed) {
|
|
368
484
|
continue;
|
|
@@ -378,6 +494,7 @@ export function summarizeGateReviewComments(comments) {
|
|
|
378
494
|
nextAction: parsed.nextAction,
|
|
379
495
|
executionMode: parsed.executionMode ?? null,
|
|
380
496
|
inlineReason: parsed.inlineReason ?? null,
|
|
497
|
+
surface: normalizeVerdictSurface(comment?.surface),
|
|
381
498
|
commentId: Number.isInteger(comment?.id) ? comment.id : null,
|
|
382
499
|
commentUrl: typeof comment?.html_url === "string" && comment.html_url.trim().length > 0 ? comment.html_url.trim() : null,
|
|
383
500
|
updatedAt: typeof (comment?.updated_at ?? comment?.updatedAt) === "string"
|
|
@@ -409,6 +526,9 @@ export function summarizeGateReviewCommentMarkers(comments, { headSha } = {}) {
|
|
|
409
526
|
|
|
410
527
|
for (let index = 0; index < entries.length; index += 1) {
|
|
411
528
|
const comment = entries[index];
|
|
529
|
+
if (isGateMachineArtifactBody(comment?.body)) {
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
412
532
|
const parsed = parseGateReviewCommentMarkerBody(comment?.body);
|
|
413
533
|
if (!parsed) {
|
|
414
534
|
continue;
|
|
@@ -429,6 +549,7 @@ export function summarizeGateReviewCommentMarkers(comments, { headSha } = {}) {
|
|
|
429
549
|
executionMode: parsed.executionMode ?? null,
|
|
430
550
|
inlineReason: parsed.inlineReason ?? null,
|
|
431
551
|
contractComplete: parsed.contractComplete,
|
|
552
|
+
surface: normalizeVerdictSurface(comment?.surface),
|
|
432
553
|
commentId: Number.isInteger(comment?.id) ? comment.id : null,
|
|
433
554
|
commentUrl: typeof comment?.html_url === "string" && comment.html_url.trim().length > 0 ? comment.html_url.trim() : null,
|
|
434
555
|
updatedAt: typeof (comment?.updated_at ?? comment?.updatedAt) === "string"
|