@dev-loops/core 0.2.4 → 0.2.6
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dev-loops/core",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Shared deterministic support package for dev-loop skills, repo-local scripts, and GitHub automation.",
|
|
6
6
|
"exports": {
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"./loop/phase-files": "./src/loop/phase-files.mjs",
|
|
35
35
|
"./loop/policy-constants": "./src/loop/policy-constants.mjs",
|
|
36
36
|
"./loop/pr-gate-coordination": "./src/loop/pr-gate-coordination.mjs",
|
|
37
|
+
"./loop/pr-title-markers": "./src/loop/pr-title-markers.mjs",
|
|
37
38
|
"./loop/public-dev-loop-routing": "./src/loop/public-dev-loop-routing.mjs",
|
|
38
39
|
"./loop/queue-driver": "./src/loop/queue-driver.mjs",
|
|
39
40
|
"./loop/queue-parallel": "./src/loop/queue-parallel.mjs",
|
|
@@ -76,10 +76,18 @@ autonomy:
|
|
|
76
76
|
# Workflow enforcement defaults.
|
|
77
77
|
workflow:
|
|
78
78
|
asyncStartMode: required
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
# The retrospective is a dev-loop-development artifact; shipped defaults stay permissive so an
|
|
80
|
+
# ordinary consumer's product PRs do not carry the meta-process gate (#841). Matches the code
|
|
81
|
+
# default (DEFAULT_WORKFLOW_CONFIG) and the contract. The dev-loops repo opts in via its own
|
|
82
|
+
# repo-root .devloops, which takes precedence over these extension defaults.
|
|
83
|
+
requireRetrospective: false
|
|
84
|
+
requireRetrospectiveGate: false
|
|
81
85
|
requireDraftFirst: true
|
|
82
|
-
|
|
86
|
+
# Dev mode is the dev-loop self-improvement mode — it edits the loop's own skill/agent prompts
|
|
87
|
+
# after a phase, which is only meaningful in the dev-loops repo. Shipped defaults must not force
|
|
88
|
+
# it on consumers' product phases (#846). Matches the code default; the dev-loops repo opts in
|
|
89
|
+
# via its own repo-root .devloops (which takes precedence over these extension defaults).
|
|
90
|
+
devModeDefault: false
|
|
83
91
|
|
|
84
92
|
# Light-mode threshold for small local changes.
|
|
85
93
|
localImplementation:
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from "./public-dev-loop-routing-contract.mjs";
|
|
21
21
|
import { normalizeRepoSlug } from "../github/repo-slug.mjs";
|
|
22
22
|
import { COPILOT_REVIEW_WAIT_TIMEOUT_MS } from "./policy-constants.mjs";
|
|
23
|
+
import { resolveEffectiveAsyncStartMode } from "./async-start-contract.mjs";
|
|
23
24
|
|
|
24
25
|
// ---------------------------------------------------------------------------
|
|
25
26
|
// Constants
|
|
@@ -430,6 +431,16 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
|
|
|
430
431
|
? { ...options.overrides }
|
|
431
432
|
: undefined;
|
|
432
433
|
|
|
434
|
+
// Surface the *effective* async-start posture alongside the *configured* one (#834). The
|
|
435
|
+
// configured `asyncStartMode` is echoed verbatim from settings (back-compat), but the contract
|
|
436
|
+
// is relaxed at validation time under the Claude harness (resolveEffectiveAsyncStartMode →
|
|
437
|
+
// "allowed" when CLAUDECODE=1). Without surfacing the effective value, a `required` envelope
|
|
438
|
+
// reads as if it should still block even though the resolver correctly proceeds.
|
|
439
|
+
const env = options.env ?? (typeof process !== "undefined" ? process.env : {});
|
|
440
|
+
const configuredAsyncStartMode = settings?.workflow?.asyncStartMode ?? "required";
|
|
441
|
+
const effectiveAsyncStartMode = resolveEffectiveAsyncStartMode(configuredAsyncStartMode, env);
|
|
442
|
+
const asyncStartRelaxedBy = effectiveAsyncStartMode !== configuredAsyncStartMode ? "claude-harness" : null;
|
|
443
|
+
|
|
433
444
|
const envelope = {
|
|
434
445
|
handoffVersion: ENVELOPE_HANDOFF_VERSION,
|
|
435
446
|
derivedAt: (now ?? new Date()).toISOString(),
|
|
@@ -447,7 +458,9 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
|
|
|
447
458
|
requiredReads,
|
|
448
459
|
|
|
449
460
|
stopRules,
|
|
450
|
-
asyncStartMode:
|
|
461
|
+
asyncStartMode: configuredAsyncStartMode,
|
|
462
|
+
asyncStartEffective: effectiveAsyncStartMode,
|
|
463
|
+
asyncStartRelaxedBy,
|
|
451
464
|
requireDraftFirst: settings?.workflow?.requireDraftFirst ?? false,
|
|
452
465
|
|
|
453
466
|
cwd: derivedCwd,
|
|
@@ -697,6 +710,21 @@ export function validateHandoffEnvelope(envelope) {
|
|
|
697
710
|
});
|
|
698
711
|
}
|
|
699
712
|
|
|
713
|
+
// ----- asyncStartEffective (required field; the harness-resolved posture, #834) -----
|
|
714
|
+
if (envelope.asyncStartEffective === undefined || envelope.asyncStartEffective === null) {
|
|
715
|
+
errors.push({
|
|
716
|
+
field: "asyncStartEffective",
|
|
717
|
+
reason: "must be present",
|
|
718
|
+
got: envelope.asyncStartEffective,
|
|
719
|
+
});
|
|
720
|
+
} else if (!VALID_ASYNC_START_MODES.includes(envelope.asyncStartEffective)) {
|
|
721
|
+
errors.push({
|
|
722
|
+
field: "asyncStartEffective",
|
|
723
|
+
reason: `must be one of: ${VALID_ASYNC_START_MODES.join(", ")}`,
|
|
724
|
+
got: envelope.asyncStartEffective,
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
|
|
700
728
|
// ----- refinementContract (optional) -----
|
|
701
729
|
if (envelope.refinementContract !== undefined && envelope.refinementContract !== null) {
|
|
702
730
|
if (typeof envelope.refinementContract !== "object" || Array.isArray(envelope.refinementContract)) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DISPOSITION, STATE } from "./copilot-loop-state.mjs";
|
|
2
|
+
import { findBlockingTitleMarkers } from "./pr-title-markers.mjs";
|
|
2
3
|
|
|
3
4
|
export const PR_CHECKPOINT = Object.freeze({
|
|
4
5
|
DRAFT_REVIEW: "draft_review",
|
|
@@ -324,6 +325,56 @@ function buildRetrospectiveGatePendingResult({
|
|
|
324
325
|
}
|
|
325
326
|
|
|
326
327
|
|
|
328
|
+
/**
|
|
329
|
+
* Blocked result for a PR that would otherwise reach final_approval_ready but
|
|
330
|
+
* still carries a merge-blocking marker in its title (issue #842). The title is
|
|
331
|
+
* the most visible contract surface, so a WIP/DRAFT/DO NOT MERGE title must
|
|
332
|
+
* block the final-approval boundary just like the mark-ready transition does.
|
|
333
|
+
*/
|
|
334
|
+
function buildTitleMarkerBlockedResult({
|
|
335
|
+
input,
|
|
336
|
+
currentHeadSha,
|
|
337
|
+
draftGateAlreadySatisfied,
|
|
338
|
+
draftGate,
|
|
339
|
+
preApprovalGate,
|
|
340
|
+
mergeStateStatus,
|
|
341
|
+
conflictFiles,
|
|
342
|
+
markers,
|
|
343
|
+
refinementArtifact = null,
|
|
344
|
+
}) {
|
|
345
|
+
const allowedNextActions = [];
|
|
346
|
+
const forbiddenActions = [];
|
|
347
|
+
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);
|
|
348
|
+
pushUnique(forbiddenActions, [
|
|
349
|
+
PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
|
|
350
|
+
PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
|
|
351
|
+
PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW,
|
|
352
|
+
PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
|
|
353
|
+
PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
|
|
354
|
+
PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
|
|
355
|
+
]);
|
|
356
|
+
|
|
357
|
+
return buildResult({
|
|
358
|
+
repo: input.repo ?? null,
|
|
359
|
+
pr: Number.isInteger(input.pr) ? input.pr : null,
|
|
360
|
+
currentHeadSha,
|
|
361
|
+
lifecycleState: "title_marker_blocked",
|
|
362
|
+
loopDisposition: DISPOSITION.BLOCKED,
|
|
363
|
+
gateBoundary: PR_CHECKPOINT.BLOCKED,
|
|
364
|
+
draftGateAlreadySatisfied,
|
|
365
|
+
draftGate,
|
|
366
|
+
preApprovalGate,
|
|
367
|
+
allowedNextActions,
|
|
368
|
+
forbiddenActions,
|
|
369
|
+
nextAction: PR_CHECKPOINT_ACTION.REPORT_BLOCKED,
|
|
370
|
+
reason: `Blocked: the PR title contains merge-blocking marker(s): ${markers.join(", ")}. Remove them from the title before the PR can leave draft, enter the pre-approval gate, or reach final approval.`,
|
|
371
|
+
mergeStateStatus,
|
|
372
|
+
conflictFiles,
|
|
373
|
+
refinementArtifact,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
|
|
327
378
|
function buildDraftGateNeededForMergeResult({
|
|
328
379
|
input,
|
|
329
380
|
currentHeadSha,
|
|
@@ -473,7 +524,63 @@ export function shouldGuardCopilotReviewRequest({
|
|
|
473
524
|
return true;
|
|
474
525
|
}
|
|
475
526
|
|
|
527
|
+
/**
|
|
528
|
+
* Boundaries at which a non-draft PR must NOT carry a merge-blocking title
|
|
529
|
+
* marker (issue #842 / AC2). A WIP/DRAFT/DO NOT MERGE/🚧 title is acceptable
|
|
530
|
+
* while the PR is still in draft, but the moment the PR leaves draft and reaches
|
|
531
|
+
* the pre-approval gate boundary (entry) or the final-approval boundary, the
|
|
532
|
+
* title is a live merge-contract surface and must be clean. The guard is applied
|
|
533
|
+
* once, as a post-pass over the core evaluation result, so no individual return
|
|
534
|
+
* site can be missed even if a PR was un-drafted externally (bypassing
|
|
535
|
+
* ready-for-review).
|
|
536
|
+
*/
|
|
537
|
+
const TITLE_MARKER_GUARDED_BOUNDARIES = Object.freeze([
|
|
538
|
+
PR_CHECKPOINT.PRE_APPROVAL_GATE_NEEDED,
|
|
539
|
+
PR_CHECKPOINT.PRE_APPROVAL_GATE_WINDOW,
|
|
540
|
+
PR_CHECKPOINT.FINAL_APPROVAL_READY,
|
|
541
|
+
]);
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Evaluates PR gate coordination, then re-asserts the merge-blocking title guard
|
|
545
|
+
* (issue #842) at the pre-approval / final-approval boundary for non-draft PRs.
|
|
546
|
+
*
|
|
547
|
+
* The title check is also performed inline at the three FINAL_APPROVAL_READY
|
|
548
|
+
* sites (defense in depth); this wrapper additionally covers the pre-approval
|
|
549
|
+
* gate boundary, which is reached before any pre-approval evidence exists and so
|
|
550
|
+
* is not protected by the inline checks.
|
|
551
|
+
*/
|
|
476
552
|
export function evaluatePrGateCoordination(input = {}) {
|
|
553
|
+
const result = evaluatePrGateCoordinationCore(input);
|
|
554
|
+
|
|
555
|
+
const prDraft = input.prDraft === true;
|
|
556
|
+
const prTitle = typeof input.prTitle === "string" ? input.prTitle : "";
|
|
557
|
+
// Draft PRs may legitimately carry a WIP title; the marker only blocks once
|
|
558
|
+
// the PR has left draft and is at a pre-approval/final-approval boundary.
|
|
559
|
+
if (prDraft || !result || typeof result !== "object") {
|
|
560
|
+
return result;
|
|
561
|
+
}
|
|
562
|
+
if (!TITLE_MARKER_GUARDED_BOUNDARIES.includes(result.gateBoundary)) {
|
|
563
|
+
return result;
|
|
564
|
+
}
|
|
565
|
+
const markers = findBlockingTitleMarkers(prTitle);
|
|
566
|
+
if (markers.length === 0) {
|
|
567
|
+
return result;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return buildTitleMarkerBlockedResult({
|
|
571
|
+
input,
|
|
572
|
+
currentHeadSha: result.currentHeadSha ?? null,
|
|
573
|
+
draftGateAlreadySatisfied: result.draftGateAlreadySatisfied === true,
|
|
574
|
+
draftGate: result.draftGate,
|
|
575
|
+
preApprovalGate: result.preApprovalGate,
|
|
576
|
+
mergeStateStatus: result.mergeStateStatus ?? null,
|
|
577
|
+
conflictFiles: result.conflictFiles ?? [],
|
|
578
|
+
markers,
|
|
579
|
+
refinementArtifact: result.refinementArtifact ?? null,
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function evaluatePrGateCoordinationCore(input = {}) {
|
|
477
584
|
const currentHeadSha = typeof input.currentHeadSha === "string" && input.currentHeadSha.trim().length > 0
|
|
478
585
|
? input.currentHeadSha.trim()
|
|
479
586
|
: null;
|
|
@@ -500,6 +607,7 @@ export function evaluatePrGateCoordination(input = {}) {
|
|
|
500
607
|
const roundCapReached = maxCopilotRounds !== null && copilotReviewRoundCount >= maxCopilotRounds;
|
|
501
608
|
const requireRetrospectiveGate = input.requireRetrospectiveGate === true;
|
|
502
609
|
const retrospectiveCheckpoint = input.retrospectiveCheckpoint;
|
|
610
|
+
const prTitle = typeof input.prTitle === "string" ? input.prTitle : "";
|
|
503
611
|
const refinementArtifact = input.refinementArtifact && typeof input.refinementArtifact === "object"
|
|
504
612
|
? input.refinementArtifact
|
|
505
613
|
: null;
|
|
@@ -777,6 +885,20 @@ export function evaluatePrGateCoordination(input = {}) {
|
|
|
777
885
|
});
|
|
778
886
|
}
|
|
779
887
|
if (preApprovalGate.currentHeadClean) {
|
|
888
|
+
const titleMarkers = findBlockingTitleMarkers(prTitle);
|
|
889
|
+
if (titleMarkers.length > 0) {
|
|
890
|
+
return buildTitleMarkerBlockedResult({
|
|
891
|
+
input,
|
|
892
|
+
currentHeadSha,
|
|
893
|
+
draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
|
|
894
|
+
draftGate,
|
|
895
|
+
preApprovalGate,
|
|
896
|
+
mergeStateStatus,
|
|
897
|
+
conflictFiles,
|
|
898
|
+
markers: titleMarkers,
|
|
899
|
+
refinementArtifact,
|
|
900
|
+
});
|
|
901
|
+
}
|
|
780
902
|
if (requireRetrospectiveGate) {
|
|
781
903
|
const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint);
|
|
782
904
|
if (!retrospectiveGate.approved) {
|
|
@@ -1035,6 +1157,20 @@ export function evaluatePrGateCoordination(input = {}) {
|
|
|
1035
1157
|
}
|
|
1036
1158
|
|
|
1037
1159
|
if (preApprovalGate.currentHeadClean) {
|
|
1160
|
+
const titleMarkers = findBlockingTitleMarkers(prTitle);
|
|
1161
|
+
if (titleMarkers.length > 0) {
|
|
1162
|
+
return buildTitleMarkerBlockedResult({
|
|
1163
|
+
input,
|
|
1164
|
+
currentHeadSha,
|
|
1165
|
+
draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
|
|
1166
|
+
draftGate,
|
|
1167
|
+
preApprovalGate,
|
|
1168
|
+
mergeStateStatus,
|
|
1169
|
+
conflictFiles,
|
|
1170
|
+
markers: titleMarkers,
|
|
1171
|
+
refinementArtifact,
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1038
1174
|
if (requireRetrospectiveGate) {
|
|
1039
1175
|
const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint);
|
|
1040
1176
|
if (!retrospectiveGate.approved) {
|
|
@@ -1178,6 +1314,20 @@ export function evaluatePrGateCoordination(input = {}) {
|
|
|
1178
1314
|
});
|
|
1179
1315
|
}
|
|
1180
1316
|
if (preApprovalGate.currentHeadClean) {
|
|
1317
|
+
const titleMarkers = findBlockingTitleMarkers(prTitle);
|
|
1318
|
+
if (titleMarkers.length > 0) {
|
|
1319
|
+
return buildTitleMarkerBlockedResult({
|
|
1320
|
+
input,
|
|
1321
|
+
currentHeadSha,
|
|
1322
|
+
draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
|
|
1323
|
+
draftGate,
|
|
1324
|
+
preApprovalGate,
|
|
1325
|
+
mergeStateStatus,
|
|
1326
|
+
conflictFiles,
|
|
1327
|
+
markers: titleMarkers,
|
|
1328
|
+
refinementArtifact,
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1181
1331
|
if (requireRetrospectiveGate) {
|
|
1182
1332
|
const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint);
|
|
1183
1333
|
if (!retrospectiveGate.approved) {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge-blocking marker detection for PR titles (issue #842).
|
|
3
|
+
*
|
|
4
|
+
* The PR title is the single most visible contract surface of a pull request:
|
|
5
|
+
* it shows up in the PR list, in notifications, in the merge commit, and in the
|
|
6
|
+
* changelog. A "WIP"/"DRAFT"/"DO NOT MERGE" title on an otherwise merge-ready PR
|
|
7
|
+
* directly contradicts the gate's assertion that the work is done. The gate
|
|
8
|
+
* pipeline historically only inspected the PR body, so a stale work-in-progress
|
|
9
|
+
* title could slip through both the mark-ready transition and the final-approval
|
|
10
|
+
* boundary. This module provides the pure detection seam used at both points.
|
|
11
|
+
*
|
|
12
|
+
* It is intentionally pure and side-effect free.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Canonical merge-blocking markers and how to detect them.
|
|
17
|
+
*
|
|
18
|
+
* Word-boundary matching is used for the alphabetic markers so that real words
|
|
19
|
+
* are not false-positives (e.g. "swipe"/"wiped" must not match WIP;
|
|
20
|
+
* "drafting"/"redraft" must not match DRAFT). Bracket/paren/colon punctuation
|
|
21
|
+
* (`[WIP]`, `(wip)`, `WIP:`) are non-word characters, so `\b` boundaries still
|
|
22
|
+
* match those variants. The construction emoji has no word boundary, so it is
|
|
23
|
+
* matched literally anywhere in the title.
|
|
24
|
+
*/
|
|
25
|
+
const MARKER_MATCHERS = [
|
|
26
|
+
{ label: "WIP", pattern: /\bWIP\b/i },
|
|
27
|
+
{ label: "DRAFT", pattern: /\bDRAFT\b/i },
|
|
28
|
+
// Flexible (any) whitespace between the phrase words, case-insensitive.
|
|
29
|
+
{ label: "DO NOT MERGE", pattern: /\bDO\s+NOT\s+MERGE\b/i },
|
|
30
|
+
{ label: "🚧", pattern: /🚧/u },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Finds merge-blocking markers in a PR title.
|
|
35
|
+
*
|
|
36
|
+
* Returns the canonical labels of every matched marker, de-duped and in a
|
|
37
|
+
* stable order (the declaration order of {@link MARKER_MATCHERS}). Returns an
|
|
38
|
+
* empty array when the title is clean, empty, or not a string.
|
|
39
|
+
*
|
|
40
|
+
* @param {unknown} title - The PR title to inspect.
|
|
41
|
+
* @returns {string[]} Canonical labels of matched markers, e.g. ["WIP"] or
|
|
42
|
+
* ["DO NOT MERGE", "🚧"]. Empty when no markers are present.
|
|
43
|
+
*/
|
|
44
|
+
export function findBlockingTitleMarkers(title) {
|
|
45
|
+
if (typeof title !== "string" || title.length === 0) {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const matched = [];
|
|
50
|
+
for (const { label, pattern } of MARKER_MATCHERS) {
|
|
51
|
+
if (pattern.test(title) && !matched.includes(label)) {
|
|
52
|
+
matched.push(label);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return matched;
|
|
56
|
+
}
|