@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 +5 -1
- package/src/config/config.mjs +80 -1
- package/src/config/extension-defaults.yaml +6 -0
- package/src/github/copilot-helpers.mjs +143 -0
- package/src/loop/gate-fanin.mjs +45 -0
- package/src/loop/issue-refinement-artifact.mjs +33 -10
- package/src/loop/pr-gate-coordination.mjs +109 -0
- package/src/loop/pr-lifecycle.mjs +79 -0
- package/src/loop/queue-board-ordering.mjs +1 -1
- package/src/loop/queue-board-sync.mjs +1 -1
- package/src/loop/reviewer-loop-state.mjs +20 -2
- package/src/projects/list-queue-items.mjs +380 -0
- package/src/projects/move-queue-item.mjs +394 -0
- package/src/projects/resolve-project.mjs +183 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dev-loops/core",
|
|
3
|
-
"version": "0.7.
|
|
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"
|
package/src/config/config.mjs
CHANGED
|
@@ -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;
|
package/src/loop/gate-fanin.mjs
CHANGED
|
@@ -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
|
|
415
|
-
* issue
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
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
|
-
*
|
|
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 (
|
|
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 "
|
|
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 "
|
|
5
|
+
import { main as moveQueueItemMain } from "../projects/move-queue-item.mjs";
|
|
6
6
|
|
|
7
7
|
const DEFAULT_NON_SUCCESS_COLUMN = "Backlog";
|
|
8
8
|
|