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