@dev-loops/core 0.2.5 → 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.5",
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
- requireRetrospective: true
80
- requireRetrospectiveGate: true
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
- devModeDefault: true
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: settings?.workflow?.asyncStartMode ?? "required",
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
+ }