@dev-loops/core 1.0.0-rc.5 → 1.0.0-rc.7
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 +12 -1
- package/src/analysis/change-classifier.mjs +10 -0
- package/src/analysis/diff-analyzer.mjs +68 -1
- package/src/claude/hook-decisions.mjs +204 -5
- package/src/cli/primitives.mjs +51 -1
- package/src/config/config.mjs +307 -14
- package/src/config/extension-defaults.yaml +39 -1
- package/src/github/comment-id-guard.mjs +158 -0
- package/src/github/copilot-helpers.mjs +145 -5
- package/src/github/gh.mjs +94 -0
- package/src/github/issue-ops.mjs +13 -0
- package/src/loop/agent-stall.mjs +196 -0
- package/src/loop/bash-command-classify.mjs +277 -0
- package/src/loop/cache-telemetry-evidence.mjs +437 -0
- package/src/loop/copilot-loop-iterations.mjs +2 -1
- package/src/loop/default-branch-guard.mjs +35 -2
- package/src/loop/gate-carry-forward.mjs +19 -6
- package/src/loop/gate-fanin.mjs +190 -29
- package/src/loop/handoff-envelope.mjs +40 -20
- package/src/loop/issue-refinement-artifact.mjs +94 -0
- package/src/loop/lifecycle-state.mjs +21 -2
- package/src/loop/main-checkout-ff.mjs +73 -0
- package/src/loop/markdown-sections.mjs +40 -0
- package/src/loop/normalize.mjs +7 -0
- package/src/loop/plan-file-promote-contract.mjs +14 -1
- package/src/loop/plan-file-refine-contract.mjs +92 -8
- package/src/loop/policy-constants.mjs +9 -0
- package/src/loop/pr-gate-coordination.mjs +65 -12
- package/src/loop/primer-evidence.mjs +375 -0
- package/src/loop/public-dev-loop-routing.mjs +7 -15
- package/src/loop/queue-board-sync.mjs +1 -26
- package/src/loop/queue-driver.mjs +14 -1
- package/src/loop/refinement-grill-state.mjs +3 -5
- package/src/loop/review-dispatch-plan.mjs +1034 -0
- package/src/loop/review-lineage.mjs +588 -0
- package/src/loop/reviewer-loop-state.mjs +8 -13
- package/src/loop/run-post-merge-actions.mjs +148 -0
- package/src/loop/size-budget-merge-gate.mjs +121 -0
- package/src/loop/tracker-pr-state.mjs +5 -15
- package/src/loop/ui-designer-review-scoping.mjs +171 -0
- package/src/loop/ui-review-drive.mjs +3 -1
- package/src/loop/ui-review-report.mjs +2 -5
- package/src/loop/ui-review-teardown.mjs +3 -1
- package/src/loop/worktree-guard.mjs +80 -0
- package/src/projects/list-queue-items.mjs +1 -27
- package/src/projects/move-queue-item.mjs +38 -28
- package/src/security/secret-scan.mjs +330 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* primer-evidence.mjs — primer dispatch ordering evidence + fail-closed fan-in
|
|
3
|
+
* validation (issue #1468 slice 3).
|
|
4
|
+
*
|
|
5
|
+
* Slice 1-2 (review-dispatch-plan.mjs) produced the deterministic request-plan
|
|
6
|
+
* artifact, request-prefix fingerprints, stable/volatile separation, and
|
|
7
|
+
* per-model primer-group partitioning. This slice closes the gap between
|
|
8
|
+
* "the rules say prime before fan-out" and "we can assert it happened": it
|
|
9
|
+
* records evidence that each request group's primer actually landed before any
|
|
10
|
+
* of that group's reviewers were released, and makes fan-in fail closed when
|
|
11
|
+
* that ordering — or the model group / request fingerprint / shared-prefix hash
|
|
12
|
+
* binding — is missing or mismatched.
|
|
13
|
+
*
|
|
14
|
+
* This module is pure and offline (no GitHub, no harness, no clock). It owns:
|
|
15
|
+
*
|
|
16
|
+
* 1. primer-evidence artifact builder — one deterministic per-gate-run record
|
|
17
|
+
* ('<gate>-<headSha>.primer-evidence.json') pairing the request plan with
|
|
18
|
+
* the observed primer runs and reviewer releases and deriving the ordering
|
|
19
|
+
* verdict.
|
|
20
|
+
* 2. fail-closed validator — checks, named individually, that every request
|
|
21
|
+
* group got its own primer run, that each primer is scoped to its own
|
|
22
|
+
* model/prefix (never credited to another group), and that every reviewer
|
|
23
|
+
* release happened after its group's primer landed.
|
|
24
|
+
*/
|
|
25
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
|
|
28
|
+
export const PRIMER_EVIDENCE_SCHEMA_VERSION = 1;
|
|
29
|
+
|
|
30
|
+
const sha = (s) => `sha256:${String(s).replace(/^sha256:/, "").trim().toLowerCase()}`;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Deterministic artifact path for a gate run's primer evidence.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} input
|
|
36
|
+
* @param {string} input.dir - directory to write under (e.g. the gate-context dir).
|
|
37
|
+
* @param {string} input.gate - gate name (pre_approval_gate, draft_gate, ...).
|
|
38
|
+
* @param {string} input.headSha - reviewed head SHA (hex, 7-64).
|
|
39
|
+
* @returns {string} absolute-style path joined under `dir`.
|
|
40
|
+
*/
|
|
41
|
+
export function primerEvidencePath({ dir, gate, headSha } = {}) {
|
|
42
|
+
if (typeof dir !== "string" || dir.length === 0) throw new Error("primerEvidencePath requires a dir");
|
|
43
|
+
if (typeof gate !== "string" || gate.length === 0) throw new Error("primerEvidencePath requires a gate");
|
|
44
|
+
if (typeof headSha !== "string" || !/^[0-9a-f]{7,64}$/i.test(headSha.trim())) {
|
|
45
|
+
throw new Error("primerEvidencePath requires a hex headSha");
|
|
46
|
+
}
|
|
47
|
+
return path.join(dir, `${gate}-${headSha.trim().toLowerCase()}.primer-evidence.json`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Import a plan's request groups into a plain lookup keyed by canonical
|
|
52
|
+
* (model, requestPrefixFingerprint). Fingerprint-less groups are keyed by a
|
|
53
|
+
* per-model ordinal (`${model}::__unkeyed:<n>`), mirroring partitionPrimerGroups'
|
|
54
|
+
* per-partition separation.
|
|
55
|
+
*
|
|
56
|
+
* @param {object[]} requestGroups
|
|
57
|
+
* @returns {Map<string, object>}
|
|
58
|
+
*/
|
|
59
|
+
function planGroupIndex(requestGroups) {
|
|
60
|
+
const idx = new Map();
|
|
61
|
+
// Fingerprint-less groups are keyed by a PER-MODEL ordinal so a primer run
|
|
62
|
+
// maps to the SAME plan group regardless of array positions. The prior
|
|
63
|
+
// position-based `${model}::__unkeyed:${i}` key used the array index (the
|
|
64
|
+
// primer run's position), which was NOT the same index planGroupIndex used
|
|
65
|
+
// (the group's position in the plan) — a fingerprint-less run whose array
|
|
66
|
+
// index differed from its group's plan index threw a false "prefix not
|
|
67
|
+
// present" error, and validatePrimerEvidence keyed them without any index
|
|
68
|
+
// at all (`__unkeyed`). One ordinal scheme everywhere closes that drift.
|
|
69
|
+
const unkeyedOrdinals = new Map();
|
|
70
|
+
for (let i = 0; i < requestGroups.length; i++) {
|
|
71
|
+
const g = requestGroups[i];
|
|
72
|
+
let key;
|
|
73
|
+
if (g.requestPrefixFingerprint) {
|
|
74
|
+
key = `${g.model}::${g.requestPrefixFingerprint}`;
|
|
75
|
+
} else {
|
|
76
|
+
const n = unkeyedOrdinals.get(g.model) ?? 0;
|
|
77
|
+
unkeyedOrdinals.set(g.model, n + 1);
|
|
78
|
+
key = `${g.model}::__unkeyed:${n}`;
|
|
79
|
+
}
|
|
80
|
+
if (!idx.has(key)) idx.set(key, []);
|
|
81
|
+
idx.get(key).push(g);
|
|
82
|
+
}
|
|
83
|
+
return idx;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Normalize a fingerprint to a canonical `sha256:<hex>` or null. */
|
|
87
|
+
function normFp(v) {
|
|
88
|
+
if (v == null) return null;
|
|
89
|
+
return sha(v);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build the primer-evidence artifact for a gate run.
|
|
94
|
+
*
|
|
95
|
+
* @param {object} input
|
|
96
|
+
* @param {object} input.plan - the dispatch plan from buildReviewDispatchPlan().
|
|
97
|
+
* @param {Array<object>} input.primerRuns - [{ model, requestPrefixFingerprint, primerForm, landedAt }]
|
|
98
|
+
* @param {Array<object>} input.reviewerReleases - [{ model, requestPrefixFingerprint, releasedAt }]
|
|
99
|
+
* @returns {object} canonical evidence artifact.
|
|
100
|
+
*/
|
|
101
|
+
export function buildPrimerEvidence({ plan, primerRuns = [], reviewerReleases = [] } = {}) {
|
|
102
|
+
if (!plan || typeof plan !== "object" || !Array.isArray(plan.requestGroups)) {
|
|
103
|
+
throw new Error("buildPrimerEvidence requires a plan with requestGroups");
|
|
104
|
+
}
|
|
105
|
+
if (!Array.isArray(primerRuns)) throw new Error("primerRuns must be an array");
|
|
106
|
+
if (!Array.isArray(reviewerReleases)) throw new Error("reviewerReleases must be an array");
|
|
107
|
+
|
|
108
|
+
const groups = plan.requestGroups;
|
|
109
|
+
const idx = planGroupIndex(groups);
|
|
110
|
+
|
|
111
|
+
const runUnkeyedOrdinals = new Map();
|
|
112
|
+
const normRuns = primerRuns.map((r, i) => {
|
|
113
|
+
if (typeof r.model !== "string" || r.model.length === 0) {
|
|
114
|
+
throw new Error(`primerRuns[${i}].model must be a non-empty concrete model`);
|
|
115
|
+
}
|
|
116
|
+
const fp = normFp(r.requestPrefixFingerprint);
|
|
117
|
+
let key;
|
|
118
|
+
if (fp) {
|
|
119
|
+
key = `${r.model}::${fp}`;
|
|
120
|
+
} else {
|
|
121
|
+
const n = runUnkeyedOrdinals.get(r.model) ?? 0;
|
|
122
|
+
runUnkeyedOrdinals.set(r.model, n + 1);
|
|
123
|
+
key = `${r.model}::__unkeyed:${n}`;
|
|
124
|
+
}
|
|
125
|
+
if (!idx.has(key)) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`primerRuns[${i}] references model ${JSON.stringify(r.model)} with a prefix not present in the plan's request groups`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return Object.freeze({
|
|
131
|
+
model: r.model,
|
|
132
|
+
requestPrefixFingerprint: fp,
|
|
133
|
+
primerForm: r.primerForm ?? null,
|
|
134
|
+
landedAt: Number.isFinite(r.landedAt) ? r.landedAt : null,
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const normReleases = reviewerReleases.map((r, i) => {
|
|
139
|
+
return Object.freeze({
|
|
140
|
+
model: r.model,
|
|
141
|
+
requestPrefixFingerprint: normFp(r.requestPrefixFingerprint),
|
|
142
|
+
releasedAt: Number.isFinite(r.releasedAt) ? r.releasedAt : null,
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
return Object.freeze({
|
|
147
|
+
schemaVersion: PRIMER_EVIDENCE_SCHEMA_VERSION,
|
|
148
|
+
gate: plan.gate,
|
|
149
|
+
headSha: plan.headSha,
|
|
150
|
+
planHash: plan.planHash,
|
|
151
|
+
sharedPrefixHash: plan.sharedPrefixHash ?? null,
|
|
152
|
+
primerRuns: Object.freeze(normRuns),
|
|
153
|
+
reviewerReleases: Object.freeze(normReleases),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Fail-closed validation of primer-evidence against the request plan.
|
|
159
|
+
*
|
|
160
|
+
* @param {object} input
|
|
161
|
+
* @param {object} input.plan - dispatch plan.
|
|
162
|
+
* @param {object} input.evidence - artifact from buildPrimerEvidence().
|
|
163
|
+
* @returns {{ ok: boolean, failures: Array<{check: string, reason: string}> }}
|
|
164
|
+
*/
|
|
165
|
+
export function validatePrimerEvidence({ plan, evidence } = {}) {
|
|
166
|
+
const failures = [];
|
|
167
|
+
|
|
168
|
+
// shared-prefix hash binding. A plan that carries no shared-prefix hash HAS
|
|
169
|
+
// no cache-access binding to enforce (both null is a pass); when the plan
|
|
170
|
+
// carries one, the evidence must carry the SAME value. This is a real bug
|
|
171
|
+
// fix: the prior `evidence.sharedPrefixHash == null ||` clause failed even
|
|
172
|
+
// when plan and evidence were BOTH absent (a both-absent plan could never be
|
|
173
|
+
// validated).
|
|
174
|
+
if (evidence.sharedPrefixHash !== (plan.sharedPrefixHash ?? null)) {
|
|
175
|
+
failures.push({
|
|
176
|
+
check: "shared_prefix_hash",
|
|
177
|
+
reason: `evidence sharedPrefixHash ${JSON.stringify(evidence.sharedPrefixHash)} does not match the plan's ${JSON.stringify(plan.sharedPrefixHash ?? null)}`,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// plan hash binding: evidence must reference the same search. Fails closed
|
|
182
|
+
// on a MISSING evidence planHash too (the evidence was not derived from this
|
|
183
|
+
// search, or was tampered), not only on a mismatch — matching the contract
|
|
184
|
+
// text that "plan hash is missing or mismatched" refuses consolidation.
|
|
185
|
+
if (evidence.planHash == null || plan.planHash == null || evidence.planHash !== plan.planHash) {
|
|
186
|
+
failures.push({
|
|
187
|
+
check: "plan_hash",
|
|
188
|
+
reason: `evidence planHash ${JSON.stringify(evidence.planHash)} does not match the plan's ${JSON.stringify(plan.planHash)}`,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const groups = plan.requestGroups ?? [];
|
|
193
|
+
const idx = planGroupIndex(groups);
|
|
194
|
+
|
|
195
|
+
// group coverage: every request group must have a primer run bound to its
|
|
196
|
+
// model + request fingerprint.
|
|
197
|
+
const coveredKeys = new Set();
|
|
198
|
+
const coveredUnkeyedOrdinals = new Map();
|
|
199
|
+
for (const r of evidence.primerRuns) {
|
|
200
|
+
if (r.requestPrefixFingerprint) {
|
|
201
|
+
coveredKeys.add(`${r.model}::${r.requestPrefixFingerprint}`);
|
|
202
|
+
} else {
|
|
203
|
+
const n = coveredUnkeyedOrdinals.get(r.model) ?? 0;
|
|
204
|
+
coveredUnkeyedOrdinals.set(r.model, n + 1);
|
|
205
|
+
coveredKeys.add(`${r.model}::__unkeyed:${n}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const groupUnkeyedOrdinals = new Map();
|
|
209
|
+
for (let i = 0; i < groups.length; i++) {
|
|
210
|
+
const g = groups[i];
|
|
211
|
+
let key;
|
|
212
|
+
if (g.requestPrefixFingerprint) {
|
|
213
|
+
key = `${g.model}::${g.requestPrefixFingerprint}`;
|
|
214
|
+
} else {
|
|
215
|
+
const n = groupUnkeyedOrdinals.get(g.model) ?? 0;
|
|
216
|
+
groupUnkeyedOrdinals.set(g.model, n + 1);
|
|
217
|
+
key = `${g.model}::__unkeyed:${n}`;
|
|
218
|
+
}
|
|
219
|
+
if (!coveredKeys.has(key)) {
|
|
220
|
+
failures.push({
|
|
221
|
+
check: "group_coverage",
|
|
222
|
+
reason: `request group ${i} (model ${JSON.stringify(g.model)}) has no primer run in the evidence`,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// model-group / request-fingerprint binding per reviewer release.
|
|
228
|
+
for (let i = 0; i < evidence.reviewerReleases.length; i++) {
|
|
229
|
+
const rel = evidence.reviewerReleases[i];
|
|
230
|
+
const key = rel.requestPrefixFingerprint
|
|
231
|
+
? `${rel.model}::${rel.requestPrefixFingerprint}`
|
|
232
|
+
: `${rel.model}::__unkeyed`;
|
|
233
|
+
if (rel.requestPrefixFingerprint && idx.has(key)) {
|
|
234
|
+
// bound to a known group -> ok
|
|
235
|
+
} else if (rel.requestPrefixFingerprint && !idx.has(key)) {
|
|
236
|
+
failures.push({
|
|
237
|
+
check: "model_group",
|
|
238
|
+
reason: `reviewer release ${i} bound to model ${JSON.stringify(rel.model)} + fingerprint that is not a request group (heterogeneous routing must not credit one model's primer to another)`,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
// ordering: a released reviewer must have ITS group's primer landed before
|
|
242
|
+
// it. Candidate primers are scoped to the release's OWN primer group — a
|
|
243
|
+
// keyed release binds to a same-model + same-fingerprint run, a
|
|
244
|
+
// fingerprint-less release binds to a same-model fingerprint-less run —
|
|
245
|
+
// NEVER the first same-model primer (which could belong to a different
|
|
246
|
+
// group sharing the model, e.g. a keyed group). This closes the fail-open
|
|
247
|
+
// where an unkeyed release was credited to a keyed group's earlier primer
|
|
248
|
+
// even though its own group's primer landed after it.
|
|
249
|
+
const candidates = evidence.primerRuns.filter(
|
|
250
|
+
(r) =>
|
|
251
|
+
r.model === rel.model &&
|
|
252
|
+
(rel.requestPrefixFingerprint == null
|
|
253
|
+
? r.requestPrefixFingerprint == null
|
|
254
|
+
: r.requestPrefixFingerprint === rel.requestPrefixFingerprint),
|
|
255
|
+
);
|
|
256
|
+
if (candidates.length === 0) {
|
|
257
|
+
failures.push({
|
|
258
|
+
check: "model_group",
|
|
259
|
+
reason: `reviewer release ${i} has no primer run for model ${JSON.stringify(rel.model)}`,
|
|
260
|
+
});
|
|
261
|
+
} else {
|
|
262
|
+
// Deterministic: the barrier must hold against the LAST-landed candidate
|
|
263
|
+
// (reduce/max is order-independent, unlike .find()'s first-match). With a
|
|
264
|
+
// single same-group primer this is exactly that group's primer; only an
|
|
265
|
+
// anomalous multiplicity of same-group primers here is stricter, and
|
|
266
|
+
// stricter is the fail-closed direction.
|
|
267
|
+
const primerForRel = candidates.reduce((a, b) => (b.landedAt > a.landedAt ? b : a));
|
|
268
|
+
if (!Number.isFinite(rel.releasedAt) || !Number.isFinite(primerForRel.landedAt) || rel.releasedAt < primerForRel.landedAt) {
|
|
269
|
+
// Fail CLOSED when the ordering barrier is unprovable (missing / non-finite
|
|
270
|
+
// timestamps), not only when it is provably reversed: a release without a
|
|
271
|
+
// landed primer timestamp cannot attest the primer ran before it, so the
|
|
272
|
+
// evidence must not proceed.
|
|
273
|
+
failures.push({
|
|
274
|
+
check: "primer_order",
|
|
275
|
+
reason: `reviewer release ${i} (${JSON.stringify(rel.model)}) cannot prove its primer landed before it: releasedAt=${String(rel.releasedAt)}, primer landedAt=${String(primerForRel.landedAt)} (ordering barrier missing or violated)`,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// request-fingerprint binding for primer runs that reference a real prefix.
|
|
282
|
+
// (planUnkeyedKeys + the per-model run ordinal counter support the inverse
|
|
283
|
+
// fingerprint-LESS binding below.)
|
|
284
|
+
const planUnkeyedKeys = new Set();
|
|
285
|
+
{
|
|
286
|
+
const planUnkeyedOrdinals = new Map();
|
|
287
|
+
for (const g of groups) {
|
|
288
|
+
if (g.requestPrefixFingerprint) continue;
|
|
289
|
+
const n = planUnkeyedOrdinals.get(g.model) ?? 0;
|
|
290
|
+
planUnkeyedOrdinals.set(g.model, n + 1);
|
|
291
|
+
planUnkeyedKeys.add(`${g.model}::__unkeyed:${n}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const unkeyedRunOrdinals = new Map();
|
|
295
|
+
for (let i = 0; i < evidence.primerRuns.length; i++) {
|
|
296
|
+
const r = evidence.primerRuns[i];
|
|
297
|
+
if (r.requestPrefixFingerprint) {
|
|
298
|
+
const key = `${r.model}::${r.requestPrefixFingerprint}`;
|
|
299
|
+
if (!idx.has(key)) {
|
|
300
|
+
failures.push({
|
|
301
|
+
check: "request_fingerprint",
|
|
302
|
+
reason: `primer run ${i} request-prefix fingerprint not present in the plan's request groups for model ${JSON.stringify(r.model)}`,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
} else {
|
|
306
|
+
// Inverse group binding for fingerprint-LESS runs: mirror the keyed
|
|
307
|
+
// check above. group_coverage proves every plan group has a primer run;
|
|
308
|
+
// this proves the reverse — that every unkeyed primer run maps to a
|
|
309
|
+
// fingerprint-less plan group for its model. Without it, a hand-edited /
|
|
310
|
+
// legacy evidence file could carry extra fingerprint-less runs for models
|
|
311
|
+
// or groups the plan never requested and still validate, breaking the
|
|
312
|
+
// "derived from the plan" invariant for unkeyed groups.
|
|
313
|
+
const n = unkeyedRunOrdinals.get(r.model) ?? 0;
|
|
314
|
+
unkeyedRunOrdinals.set(r.model, n + 1);
|
|
315
|
+
if (!planUnkeyedKeys.has(`${r.model}::__unkeyed:${n}`)) {
|
|
316
|
+
failures.push({
|
|
317
|
+
check: "request_group_unkeyed",
|
|
318
|
+
reason: `primer run ${i} (${JSON.stringify(r.model)}) is fingerprint-less but no fingerprint-less request group exists in the plan for that ordinal`,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Drop duplicate entries (same check on the same release).
|
|
325
|
+
const seen = new Set();
|
|
326
|
+
const unique = failures.filter((f) => {
|
|
327
|
+
const key = `${f.check}|${f.reason}`;
|
|
328
|
+
if (seen.has(key)) return false;
|
|
329
|
+
seen.add(key);
|
|
330
|
+
return true;
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
return { ok: unique.length === 0, failures: unique };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Strict fail-closed enforcement surface (GATE-EXEC-PRIMER-EVIDENCE): throws
|
|
338
|
+
* when fan-in evidence is missing or invalid, naming the failing check. This is
|
|
339
|
+
* the refusal path a gate conductor calls after validatePrimerEvidence returns
|
|
340
|
+
* ok:false — it turns a reported failure into a hard stop.
|
|
341
|
+
*
|
|
342
|
+
* @param {object} input
|
|
343
|
+
* @param {object} input.plan - dispatch plan.
|
|
344
|
+
* @param {object} input.evidence - artifact from buildPrimerEvidence().
|
|
345
|
+
* @returns {true}
|
|
346
|
+
* @throws {Error} when any primer-evidence check fails.
|
|
347
|
+
*/
|
|
348
|
+
export function enforcePrimerEvidence({ plan, evidence } = {}) {
|
|
349
|
+
const r = validatePrimerEvidence({ plan, evidence });
|
|
350
|
+
if (!r.ok) {
|
|
351
|
+
throw new Error(
|
|
352
|
+
`GATE-EXEC-PRIMER-EVIDENCE: primer evidence failed validation; refusing to proceed (${r.failures.map((f) => `${f.check}: ${f.reason}`).join("; ")})`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Persist the evidence artifact to its deterministic path.
|
|
360
|
+
*
|
|
361
|
+
* @param {object} input
|
|
362
|
+
* @param {string} input.dir
|
|
363
|
+
* @param {object} input.evidence
|
|
364
|
+
* @returns {Promise<{ path: string }>}
|
|
365
|
+
*/
|
|
366
|
+
export async function writePrimerEvidence({ dir, evidence } = {}) {
|
|
367
|
+
const target = primerEvidencePath({
|
|
368
|
+
dir,
|
|
369
|
+
gate: evidence.gate,
|
|
370
|
+
headSha: evidence.headSha,
|
|
371
|
+
});
|
|
372
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
373
|
+
await writeFile(target, `${JSON.stringify(evidence, null, 2)}\n`, "utf8");
|
|
374
|
+
return { path: target };
|
|
375
|
+
}
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
EXTERNAL_HEALTHY_WAIT_TIMEOUT_POLICY,
|
|
9
9
|
PERSISTENT_INTERNAL_WAIT_TIMEOUT_POLICY,
|
|
10
10
|
} from "./timeout-policy.mjs";
|
|
11
|
+
import { trimmedOrNull } from "./normalize.mjs";
|
|
12
|
+
import { normalizeGateReviewVerdict } from "./policy-constants.mjs";
|
|
11
13
|
import {
|
|
12
14
|
DEV_LOOP_ACTOR,
|
|
13
15
|
DEV_LOOP_ARTIFACT_STATE,
|
|
@@ -57,7 +59,6 @@ const ISSUE_READINESS_SET = new Set(Object.values(DEV_LOOP_ISSUE_READINESS));
|
|
|
57
59
|
const ISSUE_ASSIGNMENT_STATE_SET = new Set(Object.values(DEV_LOOP_ISSUE_ASSIGNMENT_STATE));
|
|
58
60
|
const VARIATION_MODE_SET = new Set(DEV_LOOP_VARIATION_PARAMETER_CONTRACT.allowedModeValues);
|
|
59
61
|
const TARGET_PREFERENCE_SET = new Set(DEV_LOOP_VARIATION_PARAMETER_CONTRACT.allowedTargetPreferenceValues);
|
|
60
|
-
const GATE_REVIEW_VERDICT_SET = new Set(["clean", "findings_present", "blocked"]);
|
|
61
62
|
const ALLOWED_MODE_VALUES_TEXT = DEV_LOOP_VARIATION_PARAMETER_CONTRACT.allowedModeValues.join(", ");
|
|
62
63
|
const ALLOWED_TARGET_PREFERENCE_VALUES_TEXT = DEV_LOOP_VARIATION_PARAMETER_CONTRACT.allowedTargetPreferenceValues.join(", ");
|
|
63
64
|
const LINKED_PR_READY_FOR_FOLLOWUP_LOOP_STATE = "linked_pr_ready_for_followup";
|
|
@@ -83,8 +84,8 @@ function normalizeTarget(target) {
|
|
|
83
84
|
const pr = Number.isInteger(target.pr) && target.pr > 0 ? target.pr : null;
|
|
84
85
|
const hasLinkedPr = Object.hasOwn(target, "linkedPr") && target.linkedPr !== null && target.linkedPr !== undefined;
|
|
85
86
|
const linkedPr = Number.isInteger(target.linkedPr) && target.linkedPr > 0 ? target.linkedPr : null;
|
|
86
|
-
const branch =
|
|
87
|
-
const phase =
|
|
87
|
+
const branch = trimmedOrNull(target.branch);
|
|
88
|
+
const phase = trimmedOrNull(target.phase);
|
|
88
89
|
|
|
89
90
|
if (kind === DEV_LOOP_TARGET_KIND.ISSUE && issue === null) {
|
|
90
91
|
return null;
|
|
@@ -116,15 +117,6 @@ function normalizeActor(value) {
|
|
|
116
117
|
return ACTOR_SET.has(normalized) ? normalized : null;
|
|
117
118
|
}
|
|
118
119
|
|
|
119
|
-
function normalizeSha(value) {
|
|
120
|
-
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function normalizeGateReviewVerdict(value) {
|
|
124
|
-
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
125
|
-
return GATE_REVIEW_VERDICT_SET.has(normalized) ? normalized : null;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
120
|
function normalizeGateReviewEvidence(evidence) {
|
|
129
121
|
if (evidence === undefined || evidence === null) {
|
|
130
122
|
return null;
|
|
@@ -139,10 +131,10 @@ function normalizeGateReviewEvidence(evidence) {
|
|
|
139
131
|
}
|
|
140
132
|
|
|
141
133
|
return {
|
|
142
|
-
currentHeadSha:
|
|
134
|
+
currentHeadSha: trimmedOrNull(evidence.currentHeadSha),
|
|
143
135
|
preApprovalGate: {
|
|
144
136
|
visible: preApprovalGate.visible === true,
|
|
145
|
-
headSha:
|
|
137
|
+
headSha: trimmedOrNull(preApprovalGate.headSha),
|
|
146
138
|
verdict: normalizeGateReviewVerdict(preApprovalGate.verdict),
|
|
147
139
|
},
|
|
148
140
|
};
|
|
@@ -197,7 +189,7 @@ function normalizeOptionalLoopState(value) {
|
|
|
197
189
|
}
|
|
198
190
|
|
|
199
191
|
function normalizeAsyncRunId(value) {
|
|
200
|
-
const asString =
|
|
192
|
+
const asString = trimmedOrNull(value);
|
|
201
193
|
if (asString !== null) return asString;
|
|
202
194
|
return null;
|
|
203
195
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { parse as parseYaml } from "yaml";
|
|
4
|
-
import { runChild as coreRunChild } from "../cli/primitives.mjs";
|
|
5
4
|
import { main as moveQueueItemMain } from "../projects/move-queue-item.mjs";
|
|
5
|
+
import { ghGraphql } from "../github/gh.mjs";
|
|
6
6
|
|
|
7
7
|
const DEFAULT_NON_SUCCESS_COLUMN = "Backlog";
|
|
8
8
|
|
|
@@ -315,31 +315,6 @@ const LIST_ORG_PROJECTS = [
|
|
|
315
315
|
"}"
|
|
316
316
|
].join("\n");
|
|
317
317
|
|
|
318
|
-
async function ghGraphql(query, vars, env, runChild) {
|
|
319
|
-
const child = runChild ?? coreRunChild;
|
|
320
|
-
const fieldArgs = [];
|
|
321
|
-
for (const [key, value] of Object.entries(vars)) {
|
|
322
|
-
fieldArgs.push("--field", `${key}=${value}`);
|
|
323
|
-
}
|
|
324
|
-
const result = await child(
|
|
325
|
-
"gh",
|
|
326
|
-
["api", "graphql", "--field", `query=${query}`, ...fieldArgs],
|
|
327
|
-
env,
|
|
328
|
-
);
|
|
329
|
-
if (result.code !== 0) {
|
|
330
|
-
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
331
|
-
throw Object.assign(new Error(`gh api graphql failed: ${detail}`), { code: "GH_API_ERROR" });
|
|
332
|
-
}
|
|
333
|
-
const payload = JSON.parse(result.stdout);
|
|
334
|
-
if (payload.errors && payload.errors.length > 0) {
|
|
335
|
-
throw Object.assign(
|
|
336
|
-
new Error(`GraphQL errors: ${payload.errors.map((e) => e.message).join("; ")}`),
|
|
337
|
-
{ code: "GRAPHQL_ERROR" },
|
|
338
|
-
);
|
|
339
|
-
}
|
|
340
|
-
return payload;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
318
|
async function resolveOwner(login, env, runChild) {
|
|
344
319
|
const userPayload = await ghGraphql(GET_USER_ID, { login }, env, runChild);
|
|
345
320
|
if (userPayload?.data?.user?.id) {
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
REASON_NEXT_UP_TARGET_MISSING_LOCALLY,
|
|
28
28
|
EMPTY_NEXT_UP_MESSAGE,
|
|
29
29
|
} from "./queue-board-ordering.mjs";
|
|
30
|
+
import { resolveSizeBudgetHumanApprovalRequired } from "./size-budget-merge-gate.mjs";
|
|
30
31
|
|
|
31
32
|
export const DEFAULT_QUEUE_DRIVER_OPTIONS = {
|
|
32
33
|
mergeAuthorized: false,
|
|
@@ -237,7 +238,19 @@ export async function runQueue(repoRoot, repo, options = {}) {
|
|
|
237
238
|
if (entryResult.pr) {
|
|
238
239
|
await doTransition(entry, "waiting_review", queue, repoRoot, opts, { pr: entryResult.pr });
|
|
239
240
|
await doTransition(entry, "gates_passing", queue, repoRoot, opts);
|
|
240
|
-
|
|
241
|
+
// Size-budget merge gate (phase 3 of the fail-closed PR size budget): consulted IN
|
|
242
|
+
// ADDITION TO opts.mergeAuthorized, never in its place. Opt-in per
|
|
243
|
+
// entry — only engaged when the orchestrator's entryResult carries
|
|
244
|
+
// a `sizeBudget` object (the size-budget-aware evaluation actually
|
|
245
|
+
// ran for this PR); an orchestrator that has not been updated to
|
|
246
|
+
// evaluate the size budget sees unchanged behavior. When engaged,
|
|
247
|
+
// an escalated/T1 PR without a human APPROVED review (zero
|
|
248
|
+
// unresolved CHANGES_REQUESTED) is never auto-merged, even under a
|
|
249
|
+
// standing mergeAuthorized authorization — it routes to the same
|
|
250
|
+
// "final_approval_ready" column an unauthorized merge would.
|
|
251
|
+
const sizeBudgetBlocksMerge = entryResult.sizeBudget
|
|
252
|
+
&& resolveSizeBudgetHumanApprovalRequired(entryResult.sizeBudget) === true;
|
|
253
|
+
if (opts.mergeAuthorized && !sizeBudgetBlocksMerge) {
|
|
241
254
|
await doTransition(entry, "merging", queue, repoRoot, opts);
|
|
242
255
|
await doTransition(entry, "done", queue, repoRoot, opts, { retrospectiveWritten: true });
|
|
243
256
|
await syncColumn(entry.target, columnFor("done"));
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
* rather than fabricating an answer to force convergence.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
+
import { trimmedOrNull } from "./normalize.mjs";
|
|
23
|
+
|
|
22
24
|
export const GRILL_STATE = Object.freeze({
|
|
23
25
|
LOAD_TARGET: "load_target",
|
|
24
26
|
DETECT_GAPS: "detect_gaps",
|
|
@@ -85,10 +87,6 @@ function normalizeCount(value) {
|
|
|
85
87
|
: 0;
|
|
86
88
|
}
|
|
87
89
|
|
|
88
|
-
function normalizeStringOrNull(value) {
|
|
89
|
-
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
90
|
/**
|
|
93
91
|
* Canonicalize a raw grill snapshot into a deterministic shape.
|
|
94
92
|
*
|
|
@@ -102,7 +100,7 @@ export function normalizeGrillSnapshot(raw) {
|
|
|
102
100
|
|
|
103
101
|
return {
|
|
104
102
|
surface: VALID_SURFACES.has(raw.surface) ? raw.surface : "issue",
|
|
105
|
-
targetRef:
|
|
103
|
+
targetRef: trimmedOrNull(raw.targetRef),
|
|
106
104
|
|
|
107
105
|
loaded: Boolean(raw.loaded),
|
|
108
106
|
loadFailed: Boolean(raw.loadFailed),
|