@dev-loops/core 0.7.1 → 0.7.2

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.7.1",
3
+ "version": "0.7.2",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -38,6 +38,7 @@
38
38
  "./loop/plan-file-promote-contract": "./src/loop/plan-file-promote-contract.mjs",
39
39
  "./loop/plan-file-refine-contract": "./src/loop/plan-file-refine-contract.mjs",
40
40
  "./loop/pr-gate-coordination": "./src/loop/pr-gate-coordination.mjs",
41
+ "./loop/pr-lifecycle": "./src/loop/pr-lifecycle.mjs",
41
42
  "./loop/pr-title-markers": "./src/loop/pr-title-markers.mjs",
42
43
  "./loop/public-dev-loop-routing": "./src/loop/public-dev-loop-routing.mjs",
43
44
  "./loop/queue-board-ordering": "./src/loop/queue-board-ordering.mjs",
@@ -55,6 +56,9 @@
55
56
  "./loop/timeout-policy": "./src/loop/timeout-policy.mjs",
56
57
  "./loop/tracker-pr-state": "./src/loop/tracker-pr-state.mjs",
57
58
  "./loop/ui-e2e-scoping": "./src/loop/ui-e2e-scoping.mjs",
59
+ "./projects/list-queue-items": "./src/projects/list-queue-items.mjs",
60
+ "./projects/move-queue-item": "./src/projects/move-queue-item.mjs",
61
+ "./projects/resolve-project": "./src/projects/resolve-project.mjs",
58
62
  "./harness": "./src/harness/index.mjs",
59
63
  "./loop/worktree-guard": "./src/loop/worktree-guard.mjs",
60
64
  "./loop/tracker-first-loop-state": "./src/loop/tracker-first-loop-state.mjs"
@@ -98,6 +98,12 @@ const GatesConfig = z.strictObject({
98
98
  // and every angle configured across this config's own draft/preApproval/
99
99
  // spike gates (angles + mandatoryAngles).
100
100
  anglePool: z.array(z.string().trim().min(1)).optional(),
101
+ // Fail-closed enforcement that a fanout_fanin gate's recorded per-angle
102
+ // provenance names only angles in the gate's configured pool (angles +
103
+ // mandatoryAngles) — ad-hoc/foreign angle labels are rejected rather than
104
+ // silently accepted. Default true (reject); set false to warn instead of
105
+ // fail. See resolveRejectForeignAngles / docs/gate-review-sub-loop-contract.md.
106
+ rejectForeignAngles: z.boolean().default(true),
101
107
  });
102
108
 
103
109
  const AutonomyConfig = z.strictObject({
@@ -143,6 +149,10 @@ const LocalImplementationConfig = z.strictObject({
143
149
  enabled: z.boolean(),
144
150
  maxFiles: z.number().int().min(1),
145
151
  maxLines: z.number().int().min(1),
152
+ // Copilot review round cap for light-dispatched PRs (#1210). Composes with
153
+ // (does not replace) refinement.maxCopilotRounds — see
154
+ // resolveEffectiveCopilotRoundCap.
155
+ maxCopilotRounds: z.number().int().nonnegative().default(1),
146
156
  }).optional(),
147
157
  });
148
158
 
@@ -193,6 +203,7 @@ const FileGatesConfig = z.strictObject({
193
203
  maxFanoutReviewers: z.number().int().min(1).max(64).optional(),
194
204
  postFindingsComments: z.boolean().optional(),
195
205
  anglePool: z.array(z.string().trim().min(1)).optional(),
206
+ rejectForeignAngles: z.boolean().optional(),
196
207
  });
197
208
 
198
209
  // Partial persona entries for file-level config (allows omitting fields)
@@ -252,7 +263,7 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
252
263
  devModeDefault: false,
253
264
  }),
254
265
  localImplementation: Object.freeze({
255
- lightMode: Object.freeze({ enabled: false, maxFiles: 3, maxLines: 200 }),
266
+ lightMode: Object.freeze({ enabled: false, maxFiles: 3, maxLines: 200, maxCopilotRounds: 1 }),
256
267
  }),
257
268
  queue: Object.freeze({
258
269
  maxParallel: 3,
@@ -1021,6 +1032,17 @@ export function resolveRequireFanoutProvenance(config) {
1021
1032
  return config?.gates?.requireFanoutProvenance === true;
1022
1033
  }
1023
1034
 
1035
+ /**
1036
+ * Resolve whether a fan-out provenance entry naming an angle outside the
1037
+ * gate's configured pool should FAIL (default) or only WARN.
1038
+ *
1039
+ * @param {DevLoopConfig} config
1040
+ * @returns {boolean}
1041
+ */
1042
+ export function resolveRejectForeignAngles(config) {
1043
+ return config?.gates?.rejectForeignAngles !== false;
1044
+ }
1045
+
1024
1046
  /**
1025
1047
  * Resolve whether the consolidated gate fan-out findings should be posted as a
1026
1048
  * visible, marker-tagged PR comment.
@@ -1061,6 +1083,33 @@ export function resolveLightMode(config) {
1061
1083
  };
1062
1084
  }
1063
1085
 
1086
+ /**
1087
+ * Resolve the effective Copilot review round cap for a PR (#1210).
1088
+ *
1089
+ * Full PRs (lightweight=false) use `refinement.maxCopilotRounds` unchanged
1090
+ * (default 5). Light-dispatched PRs compose with it rather than replacing it:
1091
+ * `effective = min(localImplementation.lightMode.maxCopilotRounds ?? 1,
1092
+ * refinement.maxCopilotRounds)` — so setting `refinement.maxCopilotRounds: 0`
1093
+ * disables Copilot rounds everywhere, including lightweight, with that one
1094
+ * setting.
1095
+ *
1096
+ * @param {DevLoopConfig} config
1097
+ * @param {{ lightweight?: boolean }} [options]
1098
+ * @returns {number}
1099
+ */
1100
+ export function resolveEffectiveCopilotRoundCap(config, { lightweight = false } = {}) {
1101
+ // Clamp here, not only in the zod schema: programmatically-built config
1102
+ // objects bypass schema defaulting/validation, and a negative cap must never
1103
+ // reach round-cap comparisons.
1104
+ const maxCopilotRounds = Math.max(0, /** @type {number} */ (resolveRefinementConfig(config, "maxCopilotRounds")));
1105
+ if (!lightweight) return maxCopilotRounds;
1106
+ const lightMaxRounds = config?.localImplementation?.lightMode?.maxCopilotRounds;
1107
+ const effectiveLightCap = typeof lightMaxRounds === "number" && Number.isFinite(lightMaxRounds)
1108
+ ? Math.max(0, lightMaxRounds)
1109
+ : 1;
1110
+ return Math.min(effectiveLightCap, maxCopilotRounds);
1111
+ }
1112
+
1064
1113
  /** Label that forces full fan-out regardless of change size. */
1065
1114
  export const GATE_FULL_LABEL = "gate:full";
1066
1115
 
@@ -1161,6 +1210,36 @@ export function resolveAnglePool(config) {
1161
1210
  return [...new Set([...Object.keys(BUILTIN_PERSONAS), ...configured])];
1162
1211
  }
1163
1212
 
1213
+ /**
1214
+ * Resolve a gate's ANGLE ENFORCEMENT CONTRACT: the mandatory angles a
1215
+ * fanout_fanin verdict must cover and the pool its recorded angles must stay
1216
+ * within. Single source of truth for all angle-coverage enforcement consumers
1217
+ * (ledger write, verdict-comment write, merge-evidence read) so they agree.
1218
+ *
1219
+ * - `mandatoryAngles` is filtered through `excludeAngles`: a config that
1220
+ * excludes a mandatory angle must not deadlock every fanout write (the
1221
+ * angle would be missing-mandatory if omitted yet foreign if recorded).
1222
+ * - `pool` is `resolveGateAngles` (configured angles ∪ mandatoryAngles, minus
1223
+ * excludeAngles); when `additiveAngles` is enabled it widens to the global
1224
+ * lens catalog (`resolveAnglePool`) too — dynamic resolution may
1225
+ * legitimately dispatch catalog angles then — with `excludeAngles` still a
1226
+ * hard ceiling. A null pool skips the foreign-angle check entirely.
1227
+ *
1228
+ * @param {DevLoopConfig} config
1229
+ * @param {"draft"|"preApproval"|"spike"} gate
1230
+ * @returns {{ mandatoryAngles: string[], pool: string[]|null }}
1231
+ */
1232
+ export function resolveGateAngleContract(config, gate) {
1233
+ const gateConfig = resolveGateConfig(config, gate);
1234
+ const excluded = new Set(gateConfig.excludeAngles);
1235
+ const mandatoryAngles = gateConfig.mandatoryAngles.filter((a) => !excluded.has(a));
1236
+ let pool = resolveGateAngles(config, gate);
1237
+ if (gateConfig.additiveAngles && pool !== null) {
1238
+ pool = [...new Set([...pool, ...resolveAnglePool(config)])].filter((a) => !excluded.has(a));
1239
+ }
1240
+ return { mandatoryAngles, pool };
1241
+ }
1242
+
1164
1243
  /**
1165
1244
  * Resolve gate angles dynamically when `dynamicAngles` is enabled in config.
1166
1245
  *
@@ -44,6 +44,9 @@ gates:
44
44
  - renderer-security
45
45
  - determinism
46
46
  - pr-comments
47
+ - contradiction-lens
48
+ - code-conformance
49
+ - semantic-drift
47
50
  excludeAngles: []
48
51
  required: true
49
52
  requireCi: true
@@ -66,6 +69,9 @@ gates:
66
69
  - dip
67
70
  - docs
68
71
  - pr-checklist-matrix
72
+ - contradiction-lens
73
+ - correctness-final
74
+ - ui-validation
69
75
  excludeAngles: []
70
76
  required: true
71
77
  mandatoryAngles:
@@ -15,6 +15,149 @@ export function isCopilotLogin(login) {
15
15
  return typeof login === "string" && /^copilot(?:[^a-z]|$)/i.test(login);
16
16
  }
17
17
 
18
+ // Anti-summon literal: bare-text `@copilot` or a `/copilot*` slash command. Both
19
+ // the write-side sanitizer and the read-side guard scan key off this shape so a
20
+ // gate-evidence comment can quote the rule (inside a code span/fenced block)
21
+ // without arming the request-copilot-review.mjs anti-summon guard. The token
22
+ // regex carries the same left word-boundary as the guard regex so the sanitizer
23
+ // never mangles text the guard would not arm on (e.g. user@copilot.example).
24
+ const COPILOT_SUMMON_TOKEN_RE = /(?<=^|\W)(@copilot|\/copilot[a-z0-9_-]*)/gi;
25
+ const COPILOT_SUMMON_WORD_BOUNDARY_RE = /(?:^|\W)(@copilot|\/copilot)(?:$|\W)/i;
26
+ // GFM inline code span: an N-backtick run, lazy content, closed by a same-length
27
+ // run. Covers single-backtick spans as well as double-backtick spans wrapping a
28
+ // literal backtick.
29
+ const INLINE_CODE_SPAN_RE = /(`+)[\s\S]*?\1(?!`)/g;
30
+ const ZERO_WIDTH_JOINER = "\u200D";
31
+
32
+ // Apply `transformLine` to every markdown line OUTSIDE a fenced code block
33
+ // (```/~~~), leaving fence-delimiter lines and fenced content untouched.
34
+ // Mirrors the fenced-block tracking scripts/docs/validate-rule-ownership.mjs
35
+ // uses for its own lexical scan.
36
+ function transformNonFencedLines(text, transformLine) {
37
+ const lines = String(text).split(/\r?\n/);
38
+ let inFencedBlock = false;
39
+ let fencedDelimiter = "";
40
+ const transformed = lines.map((line) => {
41
+ const rawTrimmed = line.trim();
42
+ const fenceMatch = rawTrimmed.match(/^(```|~~~)/);
43
+ if (fenceMatch) {
44
+ if (!inFencedBlock) {
45
+ inFencedBlock = true;
46
+ fencedDelimiter = fenceMatch[1];
47
+ return line;
48
+ }
49
+ if (rawTrimmed.startsWith(fencedDelimiter)) {
50
+ inFencedBlock = false;
51
+ fencedDelimiter = "";
52
+ return line;
53
+ }
54
+ }
55
+ if (inFencedBlock) {
56
+ return line;
57
+ }
58
+ return transformLine(line);
59
+ });
60
+ return transformed.join("\n");
61
+ }
62
+
63
+ // Apply `replaceSegment` to every part of a line that lies OUTSIDE an inline
64
+ // code span (any N-backtick GFM span), leaving span content untouched.
65
+ function transformOutsideSpans(line, replaceSegment) {
66
+ let result = "";
67
+ let last = 0;
68
+ for (const span of line.matchAll(INLINE_CODE_SPAN_RE)) {
69
+ result += replaceSegment(line.slice(last, span.index));
70
+ result += span[0];
71
+ last = span.index + span[0].length;
72
+ }
73
+ return result + replaceSegment(line.slice(last));
74
+ }
75
+
76
+ // Wrap bare `@copilot`/`/copilot*` tokens in backticks so a comment can quote the
77
+ // anti-summon rule without arming it. Tokens already inside an inline code span
78
+ // are left untouched.
79
+ function wrapBareSummonTokensInLine(line) {
80
+ return transformOutsideSpans(line, (segment) => segment.replace(COPILOT_SUMMON_TOKEN_RE, "`$1`"));
81
+ }
82
+
83
+ // Does this single (non-fenced) line still arm the guard scan after inline code
84
+ // spans are dropped? Mirrors stripMarkdownCodeForScan's per-line step. Spans are
85
+ // replaced with a SPACE, not the empty string: the fragments flanking a span
86
+ // must never be rejoined into a token that was not present ("@copi`x`lot" is not
87
+ // a summon), while a token directly abutting a span ("text`x`@copilot", which
88
+ // GitHub renders as a real mention) still arms.
89
+ function lineArmsSummonGuard(line) {
90
+ return COPILOT_SUMMON_WORD_BOUNDARY_RE.test(line.replace(INLINE_CODE_SPAN_RE, " "));
91
+ }
92
+
93
+ const ZWJ_FALLBACK_RE = /(?<=^|\W)([@/])(copilot)/gi;
94
+
95
+ // Sanitize one line, verifying against the guard scan. Backtick-wrapping is the
96
+ // primary neutralization (visible, greppable), but pre-existing backticks on the
97
+ // line can destabilize it two ways: an UNBALANCED stray backtick pairs with an
98
+ // inserted one and re-exposes the token to the guard's span-stripping, and
99
+ // adjacent spans (e.g. a span ending right before the token's new wrap) can make
100
+ // the wrapped line re-tokenize differently on the next pass, re-wrapping the
101
+ // token and growing the comment by one backtick per rewrite. The wrapped result
102
+ // is therefore accepted only when it is BOTH guard-inert AND a fixed point of
103
+ // the wrapper (re-wrapping it changes nothing); otherwise fall back to inserting
104
+ // a zero-width joiner into the residual tokens still outside the wrapped line's
105
+ // spans — invisible, guard-inert, and idempotent (the joined token no longer
106
+ // matches the summon shape). Working on the wrapped line (not the original)
107
+ // preserves every stable backtick wrap and keeps the joiner out of legitimate
108
+ // pre-existing code spans.
109
+ function sanitizeSummonLine(line) {
110
+ const wrapped = wrapBareSummonTokensInLine(line);
111
+ if (!lineArmsSummonGuard(wrapped) && wrapBareSummonTokensInLine(wrapped) === wrapped) {
112
+ return wrapped;
113
+ }
114
+ return transformOutsideSpans(wrapped, (segment) => segment.replace(ZWJ_FALLBACK_RE, `$1${ZERO_WIDTH_JOINER}$2`));
115
+ }
116
+
117
+ export function sanitizeCopilotSummonTokens(text) {
118
+ return transformNonFencedLines(String(text), sanitizeSummonLine);
119
+ }
120
+
121
+ // Drop all markdown code content (fenced blocks entirely, inline code spans
122
+ // per line) from `text`, leaving only the bare-text markdown to scan. Unlike
123
+ // transformNonFencedLines (which leaves fenced lines verbatim — correct for
124
+ // sanitizing, where code content must not be rewritten), fenced content here
125
+ // must be REMOVED rather than kept: leaving it in place would let bare text
126
+ // inside a fence still match the anti-summon scan.
127
+ function stripMarkdownCodeForScan(text) {
128
+ const lines = String(text).split(/\r?\n/);
129
+ let inFencedBlock = false;
130
+ let fencedDelimiter = "";
131
+ const kept = [];
132
+ for (const line of lines) {
133
+ const rawTrimmed = line.trim();
134
+ const fenceMatch = rawTrimmed.match(/^(```|~~~)/);
135
+ if (fenceMatch) {
136
+ if (!inFencedBlock) {
137
+ inFencedBlock = true;
138
+ fencedDelimiter = fenceMatch[1];
139
+ } else if (rawTrimmed.startsWith(fencedDelimiter)) {
140
+ inFencedBlock = false;
141
+ fencedDelimiter = "";
142
+ }
143
+ continue;
144
+ }
145
+ if (inFencedBlock) {
146
+ continue;
147
+ }
148
+ // Space (not empty-string) replacement: see lineArmsSummonGuard.
149
+ kept.push(line.replace(INLINE_CODE_SPAN_RE, " "));
150
+ }
151
+ return kept.join("\n");
152
+ }
153
+
154
+ // The request-copilot-review.mjs anti-summon guard scan: true when `text`
155
+ // contains a bare-text (not code-spanned/fenced) `@copilot` or `/copilot`
156
+ // occurrence. Quoting the rule inside backticks or a fenced block is exempt.
157
+ export function containsBareCopilotSummon(text) {
158
+ return COPILOT_SUMMON_WORD_BOUNDARY_RE.test(stripMarkdownCodeForScan(text));
159
+ }
160
+
18
161
  export function normalizeTimestamp(value) {
19
162
  if (typeof value !== "string" || value.trim().length === 0) {
20
163
  return null;
@@ -116,6 +116,51 @@ export function provenanceConsistencyError(prov) {
116
116
  return null;
117
117
  }
118
118
 
119
+ /**
120
+ * Base angle name for a delta-suffixed re-review entry (`<angle>-delta-at-...`,
121
+ * e.g. `pr-checklist-matrix-delta-at-current-head`): a re-review scoped to only
122
+ * the current head's delta still counts toward its base angle for both
123
+ * mandatory-angle coverage and pool-membership checks.
124
+ *
125
+ * @param {string} angle
126
+ * @returns {string}
127
+ */
128
+ function baseAngleName(angle) {
129
+ return angle.replace(/-delta-at-.+$/, "");
130
+ }
131
+
132
+ /**
133
+ * Validate a recorded fan-out angle list against a gate's configured angle
134
+ * contract: every mandatory angle must be represented, and — when a pool is
135
+ * supplied — every recorded angle must be a member of it (delta-suffixed
136
+ * angles count toward their {@link baseAngleName}). Pure; shared by the write
137
+ * path (write-gate-findings-log's `provenance.perAngle`, upsert-checkpoint-verdict's
138
+ * `--findings-json` per-angle results) and the merge-evidence read path
139
+ * (detect-checkpoint-evidence re-validating the ledger's `provenance.perAngle`)
140
+ * so all three enforce identically.
141
+ *
142
+ * @param {unknown} recordedAngles — array of `{ angle: string, ... }` entries (provenance.perAngle or normalized per-angle findings)
143
+ * @param {object} [gateAngleContract]
144
+ * @param {string[]} [gateAngleContract.mandatoryAngles] — angles that must always be represented
145
+ * @param {string[]|null} [gateAngleContract.pool] — configured angle pool; null/omitted skips the foreign-angle check
146
+ * @returns {{ missingMandatory: string[], foreignAngles: string[] }}
147
+ */
148
+ export function checkFanoutAngleCoverage(recordedAngles, { mandatoryAngles = [], pool = null } = {}) {
149
+ const recorded = Array.isArray(recordedAngles)
150
+ ? recordedAngles
151
+ .map((e) => (e && typeof e === "object" && typeof e.angle === "string" ? e.angle.trim() : ""))
152
+ .filter((a) => a.length > 0)
153
+ : [];
154
+ const recordedBases = new Set(recorded.map(baseAngleName));
155
+ const missingMandatory = mandatoryAngles.filter((a) => !recordedBases.has(a));
156
+ let foreignAngles = [];
157
+ if (Array.isArray(pool) && pool.length > 0) {
158
+ const poolSet = new Set(pool);
159
+ foreignAngles = [...new Set(recorded.filter((a) => !poolSet.has(baseAngleName(a))))];
160
+ }
161
+ return { missingMandatory, foreignAngles };
162
+ }
163
+
119
164
  /**
120
165
  * Default cap on parallel fan-out reviewers when a caller does not supply one.
121
166
  * Mirrors the config default (gates.maxFanoutReviewers).
@@ -411,18 +411,34 @@ function sectionHasBody(section) {
411
411
  * Validate that a PR body carries every invariant required to serve as the
412
412
  * lightweight spec-of-record: Objective/why, in-scope, explicit non-goals,
413
413
  * testable Acceptance criteria (>=1 checklist item), Definition of done
414
- * (>=1 checklist item), Open questions/risks, and a GitHub closing-keyword
415
- * issue reference (`Closes #N` and GitHub's other accepted forms — the
416
- * lightweight path's `Closes #N` linkage, issue #1181). Reuses the generic
417
- * markdown logic (parseMarkdownSections / AC + DoD patterns /
418
- * extractChecklistItems) so there is no parallel validator. Fails closed:
419
- * every missing invariant is reported under its distinct `missing_*` code.
420
- * Pure; no side effects.
414
+ * (>=1 checklist item), Open questions/risks, and — unless explicit
415
+ * issue-less mode is requested — a GitHub closing-keyword issue reference
416
+ * (`Closes #N` and GitHub's other accepted forms — the lightweight path's
417
+ * `Closes #N` linkage, issue #1181). Reuses the generic markdown logic
418
+ * (parseMarkdownSections / AC + DoD patterns / extractChecklistItems) so
419
+ * there is no parallel validator. Fails closed: every missing invariant is
420
+ * reported under its distinct `missing_*` code. Pure; no side effects.
421
421
  *
422
- * @param {{ body?: string, expectedIssue?: number }} input
422
+ * Issue-less mode (`issueLess: true`, issue #1210): the narrative invariants
423
+ * stay unconditional, but the closing-issue linkage flips from REQUIRED to
424
+ * FORBIDDEN — the PR is the sole artifact, so it MUST NOT carry a closing
425
+ * reference to an issue that doesn't back it. A present reference in this
426
+ * mode fails closed under `unexpected_closing_issue_reference`, distinct
427
+ * from `missing_closing_issue_reference` (tracker-backed mode, the default)
428
+ * so callers can tell "no issue expected" apart from "issue expected but
429
+ * absent". `expectedIssue` and `issueLess` are mutually exclusive; callers
430
+ * pick exactly one mode (tracker-backed, with or without a specific
431
+ * expected issue) or issue-less — never both.
432
+ *
433
+ * @param {{ body?: string, expectedIssue?: number, issueLess?: boolean }} input
423
434
  * @returns {{ checker: "validate-pr-body-spec", ok: boolean, errors: { code: string, message: string }[], sections: string[], acItems: string[], dodItems: string[], closesIssues: number[] }}
424
435
  */
425
- export function validatePrBodySpec({ body = "", expectedIssue = null } = {}) {
436
+ export function validatePrBodySpec({ body = "", expectedIssue = null, issueLess = false } = {}) {
437
+ if (issueLess && Number.isInteger(expectedIssue)) {
438
+ // Fail closed at the library boundary too (not just the CLI): the two modes
439
+ // are contradictory and silently preferring one would hide caller bugs.
440
+ throw new Error("validatePrBodySpec: issueLess and expectedIssue are mutually exclusive; pass exactly one issue-linkage mode");
441
+ }
426
442
  const bodyText = typeof body === "string" ? body : "";
427
443
  const sections = parseMarkdownSections(bodyText);
428
444
  const errors = [];
@@ -453,7 +469,14 @@ export function validatePrBodySpec({ body = "", expectedIssue = null } = {}) {
453
469
  }
454
470
 
455
471
  const closesIssues = extractClosingIssueNumbers(bodyText);
456
- if (closesIssues.length === 0) {
472
+ if (issueLess) {
473
+ if (closesIssues.length > 0) {
474
+ errors.push({
475
+ code: "unexpected_closing_issue_reference",
476
+ message: `Issue-less PR body MUST NOT carry a closing reference to an issue that doesn't back it (found ${closesIssues.map((n) => `#${n}`).join(", ")}).`,
477
+ });
478
+ }
479
+ } else if (closesIssues.length === 0) {
457
480
  errors.push({
458
481
  code: "missing_closing_issue_reference",
459
482
  message: "Missing a GitHub closing-keyword issue reference (e.g. `Closes #123`).",
@@ -470,6 +470,110 @@ const TITLE_MARKER_GUARDED_BOUNDARIES = Object.freeze([
470
470
  PR_CHECKPOINT.FINAL_APPROVAL_READY,
471
471
  ]);
472
472
 
473
+ /**
474
+ * Independent gate-ENTRY re-check (issue #1190): even when the caller's
475
+ * lifecycleState/sameHeadCleanConverged claims a settled Copilot convergence,
476
+ * an outstanding (`requested`/`already-requested`) Copilot review request on
477
+ * the CURRENT head is a second, independent "unsettled" signal — not derived
478
+ * from sameHeadCleanConverged — that must refuse pre_approval_gate /
479
+ * final-approval entry outright.
480
+ *
481
+ * This mirrors the fail-closed predicate that previously only fired at
482
+ * *verdict-post* time (upsert-checkpoint-verdict.mjs, which refuses to post a
483
+ * pre_approval_gate verdict while this same evaluator forbids
484
+ * RUN_PRE_APPROVAL_GATE): asserting it here, at gate *entry*, refuses the
485
+ * pre-approval fan-out up front instead of only after reviewer tokens have
486
+ * already been spent.
487
+ *
488
+ * Skipped when Copilot review is not required at all — `reviewMode:
489
+ * "internal_only"` or `maxCopilotRounds: 0` — preserving the existing #613 /
490
+ * #1210 exemptions (internal-only and light-dispatched-with-disabled-review
491
+ * PRs never need a Copilot round in the first place).
492
+ */
493
+ const PRE_APPROVAL_ENTRY_BOUNDARIES = Object.freeze([
494
+ PR_CHECKPOINT.PRE_APPROVAL_GATE_NEEDED,
495
+ PR_CHECKPOINT.PRE_APPROVAL_GATE_WINDOW,
496
+ PR_CHECKPOINT.FINAL_APPROVAL_READY,
497
+ ]);
498
+
499
+ function applyUnsettledCopilotReviewEntryGuard(input, result) {
500
+ if (!result || typeof result !== "object" || !PRE_APPROVAL_ENTRY_BOUNDARIES.includes(result.gateBoundary)) {
501
+ return null;
502
+ }
503
+ if (input.maxCopilotRounds === 0) {
504
+ return null;
505
+ }
506
+ const reviewMode = typeof input.reviewMode === "string" ? input.reviewMode.trim().toLowerCase() : null;
507
+ if (reviewMode === "internal_only") {
508
+ return null;
509
+ }
510
+ const copilotReviewRequestStatus = typeof input.copilotReviewRequestStatus === "string"
511
+ ? input.copilotReviewRequestStatus.trim().toLowerCase()
512
+ : "none";
513
+ if (copilotReviewRequestStatus !== "requested" && copilotReviewRequestStatus !== "already-requested") {
514
+ return null;
515
+ }
516
+ // Round-cap exemption (mirrors shouldGuardCopilotReviewRequest, #896/#848):
517
+ // past the cap a lingering requested/already-requested status is for a review
518
+ // that can never come (no further round is permitted), so treating it as
519
+ // "unsettled" here would re-introduce the infinite-wait dead-end the
520
+ // ROUND_CAP_CLEAN_FALLBACK routing exists to prevent. When the cap is reached
521
+ // and the head is clean — either sameHeadCleanConverged or the interpreter's
522
+ // round_cap_clean_fallback state — the pre_approval_gate proceeds unless
523
+ // significant post-convergence changes require a new review cycle.
524
+ const roundCapReached = isCopilotRoundCapReached({
525
+ copilotReviewRoundCount: input.copilotReviewRoundCount,
526
+ maxCopilotRounds: input.maxCopilotRounds,
527
+ });
528
+ const lifecycleState = typeof input.lifecycleState === "string" ? input.lifecycleState.trim().toLowerCase() : "";
529
+ const roundCapCleanFallback = lifecycleState === STATE.ROUND_CAP_CLEAN_FALLBACK;
530
+ if (
531
+ roundCapReached
532
+ && (input.sameHeadCleanConverged === true || roundCapCleanFallback)
533
+ && input.postConvergenceSignificantChange !== true
534
+ ) {
535
+ return null;
536
+ }
537
+
538
+ const allowedNextActions = [];
539
+ const forbiddenActions = [];
540
+ pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.WAIT_FOR_COPILOT_REVIEW]);
541
+ // Full postDraftForbidden set (matching the canonical WAITING_FOR_COPILOT_REVIEW
542
+ // result this guard synthesizes) plus the final-approval actions the replaced
543
+ // boundary result also forbade — dropping RUN_DRAFT_GATE/MARK_READY_FOR_REVIEW
544
+ // here would let a draft_gate verdict post on a non-draft PR slip through where
545
+ // the replaced result would have refused it.
546
+ pushUnique(forbiddenActions, [
547
+ PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
548
+ PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
549
+ PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
550
+ PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
551
+ PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
552
+ ]);
553
+
554
+ return buildResult({
555
+ repo: input.repo ?? null,
556
+ pr: Number.isInteger(input.pr) ? input.pr : null,
557
+ currentHeadSha: result.currentHeadSha ?? null,
558
+ lifecycleState: STATE.WAITING_FOR_COPILOT_REVIEW,
559
+ loopDisposition: DISPOSITION.PENDING,
560
+ gateBoundary: PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW,
561
+ draftGateAlreadySatisfied: result.draftGateAlreadySatisfied === true,
562
+ draftGate: result.draftGate,
563
+ preApprovalGate: result.preApprovalGate,
564
+ allowedNextActions,
565
+ forbiddenActions,
566
+ nextAction: PR_CHECKPOINT_ACTION.WAIT_FOR_COPILOT_REVIEW,
567
+ reason: "A Copilot review request is still outstanding on the current head (independent gate-entry "
568
+ + "re-check, issue #1190) — pre_approval_gate/final-approval entry is refused until the current-head "
569
+ + "review settles, even though the caller-reported convergence signal claims otherwise.",
570
+ mergeStateStatus: result.mergeStateStatus ?? null,
571
+ conflictFiles: result.conflictFiles ?? [],
572
+ refinementArtifact: result.refinementArtifact ?? null,
573
+ copilotReviewRoundCount: normalizeNonNegativeInteger(input.copilotReviewRoundCount),
574
+ });
575
+ }
576
+
473
577
  /**
474
578
  * Evaluates PR gate coordination, then re-asserts the merge-blocking title guard
475
579
  * (issue #842) at the pre-approval / final-approval boundary for non-draft PRs.
@@ -482,6 +586,11 @@ const TITLE_MARKER_GUARDED_BOUNDARIES = Object.freeze([
482
586
  export function evaluatePrGateCoordination(input = {}) {
483
587
  const result = evaluatePrGateCoordinationCore(input);
484
588
 
589
+ const unsettledReviewResult = applyUnsettledCopilotReviewEntryGuard(input, result);
590
+ if (unsettledReviewResult) {
591
+ return unsettledReviewResult;
592
+ }
593
+
485
594
  const prDraft = input.prDraft === true;
486
595
  const prTitle = typeof input.prTitle === "string" ? input.prTitle : "";
487
596
  // Draft PRs may legitimately carry a WIP title; the marker only blocks once
@@ -0,0 +1,79 @@
1
+ /**
2
+ * PR lifecycle: the 13-state vocabulary + required transitions from
3
+ * skills/docs/pr-lifecycle-contract.md (issue #1193), promoted to a real
4
+ * exported contract surface.
5
+ *
6
+ * This is the single source of truth for the family-local PR lifecycle graph:
7
+ * both scripts/pages/build-state-atlas.mjs (site diagram generator) and
8
+ * scripts/docs/validate-state-machine-conformance.mjs (the L2/L3 conformance
9
+ * harness) import this same table, instead of one importing the other's
10
+ * module (which would pull the whole page generator — eager mermaid diagram
11
+ * rendering, duplicate core module instances via relative imports — into the
12
+ * harness's process at load time).
13
+ *
14
+ * Pure data + one derivation, no imports, no side effects.
15
+ */
16
+ export const PR_LIFECYCLE_STATES = Object.freeze([
17
+ 'draft_local_review_gate',
18
+ 'draft_local_remediation',
19
+ 'ready_state_needs_copilot_request',
20
+ 'waiting_for_copilot_review',
21
+ 'copilot_feedback_remediation',
22
+ 'copilot_reply_resolve_pending',
23
+ 'merge_conflict_resolution',
24
+ 'final_local_preapproval_gate',
25
+ 'final_gate_remediation',
26
+ 'waiting_for_human_pr_approval',
27
+ 'waiting_for_merge',
28
+ 'terminal_slice_complete',
29
+ 'stopped_needs_user_decision',
30
+ ]);
31
+
32
+ // '[*]' is the synthetic terminal-marker target (see build-state-atlas.mjs's
33
+ // renderStateDiagram and validate-state-machine-conformance.mjs's realEdges):
34
+ // a row `[state, '[*]']` marks `state` as absorbing without being a real edge.
35
+ const TERMINAL_MARKER = '[*]';
36
+
37
+ export const PR_LIFECYCLE_TRANSITIONS = Object.freeze([
38
+ Object.freeze(['draft_local_review_gate', 'draft_local_remediation']),
39
+ Object.freeze(['draft_local_review_gate', 'ready_state_needs_copilot_request']),
40
+ Object.freeze(['draft_local_review_gate', 'stopped_needs_user_decision']),
41
+ Object.freeze(['draft_local_remediation', 'draft_local_review_gate']),
42
+ Object.freeze(['ready_state_needs_copilot_request', 'waiting_for_copilot_review']),
43
+ Object.freeze(['ready_state_needs_copilot_request', 'stopped_needs_user_decision']),
44
+ Object.freeze(['waiting_for_copilot_review', 'copilot_feedback_remediation']),
45
+ Object.freeze(['copilot_feedback_remediation', 'copilot_reply_resolve_pending']),
46
+ Object.freeze(['copilot_reply_resolve_pending', 'ready_state_needs_copilot_request']),
47
+ Object.freeze(['waiting_for_copilot_review', 'merge_conflict_resolution']),
48
+ Object.freeze(['merge_conflict_resolution', 'waiting_for_copilot_review']),
49
+ Object.freeze(['waiting_for_copilot_review', 'final_local_preapproval_gate']),
50
+ Object.freeze(['final_local_preapproval_gate', 'final_gate_remediation']),
51
+ Object.freeze(['final_local_preapproval_gate', 'waiting_for_human_pr_approval']),
52
+ Object.freeze(['final_gate_remediation', 'final_local_preapproval_gate']),
53
+ Object.freeze(['waiting_for_human_pr_approval', 'waiting_for_merge']),
54
+ Object.freeze(['waiting_for_human_pr_approval', 'draft_local_review_gate']),
55
+ Object.freeze(['waiting_for_merge', 'terminal_slice_complete']),
56
+ Object.freeze(['terminal_slice_complete', TERMINAL_MARKER]),
57
+ Object.freeze(['stopped_needs_user_decision', TERMINAL_MARKER]),
58
+ ]);
59
+
60
+ // Derived, not hand-listed (lesson from #1157: a hand-copied terminal list can
61
+ // silently drift from the transition table it is supposed to describe). A
62
+ // state is terminal when it has zero real (non-marker) outgoing edges.
63
+ function deriveTerminalStates(states, transitions) {
64
+ const hasRealOutgoing = new Set(
65
+ transitions.filter(([, to]) => to !== TERMINAL_MARKER).map(([from]) => from),
66
+ );
67
+ return states.filter((state) => !hasRealOutgoing.has(state));
68
+ }
69
+
70
+ export const PR_LIFECYCLE_TERMINAL_STATES = Object.freeze(deriveTerminalStates(PR_LIFECYCLE_STATES, PR_LIFECYCLE_TRANSITIONS));
71
+
72
+ // Enum-style access (SCREAMING_SNAKE_CASE key -> the same state string), so
73
+ // handoff scripts can reference `PR_LIFECYCLE_STATE.READY_STATE_NEEDS_COPILOT_REQUEST`
74
+ // instead of hardcoding the literal, mirroring the STATE/OUTER_STATE/REVIEWER_STATE
75
+ // convention used by the other loop state machines. Derived from PR_LIFECYCLE_STATES
76
+ // so a new state cannot be added to one without the other.
77
+ export const PR_LIFECYCLE_STATE = Object.freeze(
78
+ Object.fromEntries(PR_LIFECYCLE_STATES.map((state) => [state.toUpperCase(), state])),
79
+ );
@@ -1,5 +1,5 @@
1
1
  import { loadBoardConfig, resolveProjectNumber, loadStateColumnMap, LOGICAL_COLUMN } from "./queue-board-sync.mjs";
2
- import { main as listQueueItemsMain } from "../../../../scripts/projects/list-queue-items.mjs";
2
+ import { main as listQueueItemsMain } from "../projects/list-queue-items.mjs";
3
3
 
4
4
  // Canonical fail-closed Next Up tokens — the SINGLE source of truth so the reason
5
5
  // codes and the empty-queue message stay byte-identical across every layer that
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { parse as parseYaml } from "yaml";
4
4
  import { runChild as coreRunChild } from "../cli/primitives.mjs";
5
- import { main as moveQueueItemMain } from "../../../../scripts/projects/move-queue-item.mjs";
5
+ import { main as moveQueueItemMain } from "../projects/move-queue-item.mjs";
6
6
 
7
7
  const DEFAULT_NON_SUCCESS_COLUMN = "Backlog";
8
8