@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.
Files changed (47) hide show
  1. package/package.json +12 -1
  2. package/src/analysis/change-classifier.mjs +10 -0
  3. package/src/analysis/diff-analyzer.mjs +68 -1
  4. package/src/claude/hook-decisions.mjs +204 -5
  5. package/src/cli/primitives.mjs +51 -1
  6. package/src/config/config.mjs +307 -14
  7. package/src/config/extension-defaults.yaml +39 -1
  8. package/src/github/comment-id-guard.mjs +158 -0
  9. package/src/github/copilot-helpers.mjs +145 -5
  10. package/src/github/gh.mjs +94 -0
  11. package/src/github/issue-ops.mjs +13 -0
  12. package/src/loop/agent-stall.mjs +196 -0
  13. package/src/loop/bash-command-classify.mjs +277 -0
  14. package/src/loop/cache-telemetry-evidence.mjs +437 -0
  15. package/src/loop/copilot-loop-iterations.mjs +2 -1
  16. package/src/loop/default-branch-guard.mjs +35 -2
  17. package/src/loop/gate-carry-forward.mjs +19 -6
  18. package/src/loop/gate-fanin.mjs +190 -29
  19. package/src/loop/handoff-envelope.mjs +40 -20
  20. package/src/loop/issue-refinement-artifact.mjs +94 -0
  21. package/src/loop/lifecycle-state.mjs +21 -2
  22. package/src/loop/main-checkout-ff.mjs +73 -0
  23. package/src/loop/markdown-sections.mjs +40 -0
  24. package/src/loop/normalize.mjs +7 -0
  25. package/src/loop/plan-file-promote-contract.mjs +14 -1
  26. package/src/loop/plan-file-refine-contract.mjs +92 -8
  27. package/src/loop/policy-constants.mjs +9 -0
  28. package/src/loop/pr-gate-coordination.mjs +65 -12
  29. package/src/loop/primer-evidence.mjs +375 -0
  30. package/src/loop/public-dev-loop-routing.mjs +7 -15
  31. package/src/loop/queue-board-sync.mjs +1 -26
  32. package/src/loop/queue-driver.mjs +14 -1
  33. package/src/loop/refinement-grill-state.mjs +3 -5
  34. package/src/loop/review-dispatch-plan.mjs +1034 -0
  35. package/src/loop/review-lineage.mjs +588 -0
  36. package/src/loop/reviewer-loop-state.mjs +8 -13
  37. package/src/loop/run-post-merge-actions.mjs +148 -0
  38. package/src/loop/size-budget-merge-gate.mjs +121 -0
  39. package/src/loop/tracker-pr-state.mjs +5 -15
  40. package/src/loop/ui-designer-review-scoping.mjs +171 -0
  41. package/src/loop/ui-review-drive.mjs +3 -1
  42. package/src/loop/ui-review-report.mjs +2 -5
  43. package/src/loop/ui-review-teardown.mjs +3 -1
  44. package/src/loop/worktree-guard.mjs +80 -0
  45. package/src/projects/list-queue-items.mjs +1 -27
  46. package/src/projects/move-queue-item.mjs +38 -28
  47. 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 = typeof target.branch === "string" && target.branch.trim().length > 0 ? target.branch.trim() : null;
87
- const phase = typeof target.phase === "string" && target.phase.trim().length > 0 ? target.phase.trim() : null;
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: normalizeSha(evidence.currentHeadSha),
134
+ currentHeadSha: trimmedOrNull(evidence.currentHeadSha),
143
135
  preApprovalGate: {
144
136
  visible: preApprovalGate.visible === true,
145
- headSha: normalizeSha(preApprovalGate.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 = normalizeSha(value);
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
- if (opts.mergeAuthorized) {
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: normalizeStringOrNull(raw.targetRef),
103
+ targetRef: trimmedOrNull(raw.targetRef),
106
104
 
107
105
  loaded: Boolean(raw.loaded),
108
106
  loadFailed: Boolean(raw.loadFailed),