@dev-loops/core 1.0.2 → 1.0.3

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.
@@ -0,0 +1,308 @@
1
+ /**
2
+ * reviewer-unit-bound.mjs — dev-loop execution-cap bounded scoped-reviewer
3
+ * dispatch unit. Mirrors
4
+ * ./child-launch-bound.mjs's style: a bounded, deterministic primitive that
5
+ * caps a reviewer unit at a small assigned-angle set, denies every operation
6
+ * outside a narrow allow-list by default, and always produces a durable
7
+ * blocker record — never a silent pass — when the unit runs over budget or
8
+ * leaves an assigned angle unreviewed.
9
+ *
10
+ * Pure and offline: no runtime/harness adapter import, no file reads, no
11
+ * network, no state held across calls.
12
+ */
13
+
14
+ /**
15
+ * Dev-loop harnesses this bound recognizes. Re-declared locally (not
16
+ * imported from child-launch-bound.mjs) so this primitive stays free of any
17
+ * cross-module coupling; it does not branch on harness, it only carries the
18
+ * label through caller-supplied gateContext.
19
+ */
20
+ export const HARNESS_VALUES = Object.freeze(["pi", "claude", "codex"]);
21
+
22
+ /** A reviewer unit is bounded to at most this many assigned angles. */
23
+ export const REVIEWER_UNIT_MAX_ANGLES = 3;
24
+
25
+ /** The fixed execution budget for one scoped-reviewer dispatch unit. */
26
+ export const REVIEWER_UNIT_BUDGET = Object.freeze({
27
+ maxModelTurns: 45,
28
+ maxToolCalls: 50,
29
+ maxAngles: REVIEWER_UNIT_MAX_ANGLES,
30
+ });
31
+
32
+ /**
33
+ * Operation kinds a reviewer unit is never allowed to perform, verbatim from
34
+ * the issue AC enumeration. Default-deny governs everything else that is not
35
+ * explicitly on the allow-list in assertReviewerOperationAllowed.
36
+ *
37
+ * Exported as a frozen ARRAY, not a Set: `Object.freeze(new Set(...))`
38
+ * freezes only the Set's own properties, not its contents — `.add`/
39
+ * `.delete`/`.clear` still work on a frozen Set and the mutation persists on
40
+ * this module-singleton export. A frozen array has no such escape hatch.
41
+ */
42
+ export const PROHIBITED_REVIEWER_OPERATIONS = Object.freeze([
43
+ "poll_pr_state",
44
+ "poll_ci_state",
45
+ "poll_copilot_state",
46
+ "network_status_probe",
47
+ "rerun_validation",
48
+ "inspect_orchestration_runtime",
49
+ "review_unassigned_angle",
50
+ ]);
51
+
52
+ /** Private O(1)-membership mirror of PROHIBITED_REVIEWER_OPERATIONS. */
53
+ const PROHIBITED_REVIEWER_OPERATIONS_SET = new Set(PROHIBITED_REVIEWER_OPERATIONS);
54
+
55
+ /** @param {unknown} value @returns {boolean} */
56
+ function isNonEmptyString(value) {
57
+ return typeof value === "string" && value.trim().length > 0;
58
+ }
59
+
60
+ /**
61
+ * Recursively freeze a plain object/array value's own nested plain
62
+ * objects/arrays. A shallow Object.freeze leaves nested values mutable;
63
+ * the gate context must be genuinely immutable, not just its top level.
64
+ *
65
+ * Recurses into children even when the current container is already frozen
66
+ * (an already-frozen container can still hold mutable grandchildren — an
67
+ * early return on Object.isFrozen would skip them). A WeakSet cycle guard
68
+ * prevents infinite recursion on a cyclic object graph.
69
+ * @param {unknown} value
70
+ * @param {WeakSet<object>} [seen]
71
+ * @returns {unknown} the same value, deep-frozen.
72
+ */
73
+ function deepFreeze(value, seen = new WeakSet()) {
74
+ if (value === null || typeof value !== "object" || seen.has(value)) {
75
+ return value;
76
+ }
77
+ seen.add(value);
78
+ for (const key of Object.keys(value)) {
79
+ deepFreeze(value[key], seen);
80
+ }
81
+ return Object.freeze(value);
82
+ }
83
+
84
+ /**
85
+ * Validate + normalize a reviewer unit at the trust boundary. Fails closed
86
+ * (TypeError naming the violation) on any malformed field.
87
+ * @param {{run:string, gateContext:object, angles:string[]}} unit
88
+ * @returns {{run:string, gateContext:object, angles:string[]}} frozen, normalized.
89
+ */
90
+ export function validateReviewerUnit(unit) {
91
+ if (!unit || typeof unit !== "object") {
92
+ throw new TypeError("validateReviewerUnit requires a unit object");
93
+ }
94
+ const { run, gateContext, angles } = unit;
95
+ if (!isNonEmptyString(run)) {
96
+ throw new TypeError("unit.run must be a non-empty string");
97
+ }
98
+ if (!gateContext || typeof gateContext !== "object") {
99
+ throw new TypeError("unit.gateContext must be a non-null object");
100
+ }
101
+ if (!isNonEmptyString(gateContext.headSha)) {
102
+ throw new TypeError("unit.gateContext.headSha must be a non-empty string");
103
+ }
104
+ // harness is optional (absent is allowed), but when present it must be a
105
+ // recognized dev-loop harness — mirrors child-launch-bound.mjs's own
106
+ // harness enforcement instead of silently carrying an unrecognized label.
107
+ if (gateContext.harness != null && !HARNESS_VALUES.includes(gateContext.harness)) {
108
+ throw new TypeError(`unit.gateContext.harness must be one of ${HARNESS_VALUES.join(", ")}, got ${JSON.stringify(gateContext.harness)}`);
109
+ }
110
+ if (!Array.isArray(angles) || angles.length === 0) {
111
+ throw new TypeError("unit.angles must be a non-empty array of angle names");
112
+ }
113
+ if (angles.length > REVIEWER_UNIT_MAX_ANGLES) {
114
+ throw new TypeError(`unit.angles must not exceed ${REVIEWER_UNIT_MAX_ANGLES} angles, got ${angles.length}`);
115
+ }
116
+
117
+ const normalizedAngles = [];
118
+ const seen = new Set();
119
+ for (const angle of angles) {
120
+ if (!isNonEmptyString(angle)) {
121
+ throw new TypeError("unit.angles must contain only non-empty strings");
122
+ }
123
+ const trimmed = angle.trim();
124
+ const lower = trimmed.toLowerCase();
125
+ if (seen.has(lower)) {
126
+ throw new TypeError(`unit.angles must not contain duplicate angle (case-insensitive): ${trimmed}`);
127
+ }
128
+ seen.add(lower);
129
+ normalizedAngles.push(trimmed);
130
+ }
131
+
132
+ // Freeze the gate context so the reviewer cannot mutate the current-head
133
+ // identity it was handed; freeze the returned unit + angles for the same
134
+ // reason. The freeze is deep — a shallow freeze would leave a nested
135
+ // gateContext value (e.g. provenance) mutable. Deep-CLONE before freezing:
136
+ // a shallow `{ ...gateContext }` spread shares nested objects with the
137
+ // caller, so deepFreeze would freeze the CALLER's own objects in place —
138
+ // an observable side effect on this otherwise-pure validator. A gate
139
+ // context is plain data; if it is not structured-cloneable that is a
140
+ // malformed non-data context, and throwing a TypeError here (not letting
141
+ // structuredClone's own DataCloneError escape) keeps the fail-closed
142
+ // posture consistent with every other malformed-input branch in this
143
+ // module.
144
+ let clonedGateContext;
145
+ try {
146
+ clonedGateContext = structuredClone(gateContext);
147
+ } catch {
148
+ throw new TypeError("unit.gateContext must be structured-cloneable (plain data, no functions/symbols/etc.)");
149
+ }
150
+ // structuredClone (like object spread) copies only own-enumerable
151
+ // properties, so an inherited/non-enumerable headSha would validate above
152
+ // yet be absent from the clone. Re-assert the validated value explicitly
153
+ // so the frozen context always carries it.
154
+ clonedGateContext.headSha = gateContext.headSha;
155
+
156
+ return Object.freeze({
157
+ run: run.trim(),
158
+ gateContext: deepFreeze(clonedGateContext),
159
+ angles: Object.freeze(normalizedAngles),
160
+ });
161
+ }
162
+
163
+ /**
164
+ * Pure default-deny guard for one reviewer operation. Only inspect_diff,
165
+ * inspect_adjacent_code, and review_angle (for an assigned angle) pass.
166
+ * @param {{kind:string, angle?:string}} operation
167
+ * @param {{assignedAngles?: string[]}} [options]
168
+ * @returns {object} the operation, unchanged, when allowed.
169
+ */
170
+ export function assertReviewerOperationAllowed(operation, { assignedAngles = [] } = {}) {
171
+ if (!operation || typeof operation !== "object" || !isNonEmptyString(operation.kind)) {
172
+ // Malformed op fails closed — never silently allowed.
173
+ throw new TypeError("assertReviewerOperationAllowed requires operation.kind to be a non-empty string");
174
+ }
175
+ const { kind } = operation;
176
+
177
+ if (PROHIBITED_REVIEWER_OPERATIONS_SET.has(kind)) {
178
+ throw new Error(`reviewer operation prohibited: ${kind}`);
179
+ }
180
+
181
+ if (kind === "review_angle") {
182
+ const angle = operation.angle;
183
+ // A non-string assignedAngles entry (e.g. a bare number) must never
184
+ // coerce into a matching authorization — skip it instead of
185
+ // String()-coercing, so it can never default-deny-bypass an angle.
186
+ const assignedLower = new Set(
187
+ assignedAngles.filter((a) => isNonEmptyString(a)).map((a) => a.trim().toLowerCase()),
188
+ );
189
+ if (!isNonEmptyString(angle) || !assignedLower.has(angle.trim().toLowerCase())) {
190
+ throw new Error(`review_unassigned_angle: ${JSON.stringify(angle ?? null)} is not assigned to this reviewer unit`);
191
+ }
192
+ return operation;
193
+ }
194
+
195
+ if (kind === "inspect_diff" || kind === "inspect_adjacent_code") {
196
+ return operation;
197
+ }
198
+
199
+ // Neither explicitly allowed nor explicitly prohibited: default-deny.
200
+ throw new Error(`unknown_reviewer_operation: ${kind}`);
201
+ }
202
+
203
+ /** @param {unknown} value @returns {boolean} */
204
+ function isNonNegativeInteger(value) {
205
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
206
+ }
207
+
208
+ /**
209
+ * @param {{modelTurns:unknown, toolCalls:unknown}} consumed
210
+ * @returns {{modelTurns:number, toolCalls:number}}
211
+ */
212
+ function validateConsumed(consumed) {
213
+ if (!consumed || typeof consumed !== "object") {
214
+ throw new TypeError("enforceReviewerUnitBound requires consumed to be an object with modelTurns and toolCalls");
215
+ }
216
+ const { modelTurns, toolCalls } = consumed;
217
+ // modelTurns/toolCalls are discrete counters — a fractional value (e.g.
218
+ // 44.5) is never a genuine count and must fail closed, not round/truncate.
219
+ if (!isNonNegativeInteger(modelTurns)) {
220
+ throw new TypeError("consumed.modelTurns must be a non-negative integer");
221
+ }
222
+ if (!isNonNegativeInteger(toolCalls)) {
223
+ throw new TypeError("consumed.toolCalls must be a non-negative integer");
224
+ }
225
+ return { modelTurns, toolCalls };
226
+ }
227
+
228
+ /**
229
+ * @param {Iterable<string>|null|undefined} completedAngles
230
+ * @returns {Set<string>} lower-cased, trimmed angle names.
231
+ */
232
+ function normalizeCompletedAngles(completedAngles) {
233
+ if (completedAngles == null) return new Set();
234
+ if (typeof completedAngles === "string") {
235
+ // A bare string is iterable (per-character) but must never be accepted
236
+ // as a set of completed angles — fail closed instead of silently
237
+ // iterating characters.
238
+ throw new TypeError("enforceReviewerUnitBound requires completedAngles to be an iterable of angle names, not a bare string");
239
+ }
240
+ if (typeof completedAngles[Symbol.iterator] !== "function") {
241
+ throw new TypeError("enforceReviewerUnitBound requires completedAngles to be an iterable of angle names");
242
+ }
243
+ const completed = new Set();
244
+ for (const angle of completedAngles) {
245
+ if (isNonEmptyString(angle)) completed.add(angle.trim().toLowerCase());
246
+ }
247
+ return completed;
248
+ }
249
+
250
+ /**
251
+ * Enforce the bounded scoped-reviewer-unit protocol. Validates the unit,
252
+ * measures consumption against the fixed REVIEWER_UNIT_BUDGET, and computes
253
+ * angle coverage. Two independent fail-closed REVOKE conditions can block
254
+ * the unit; budget exhaustion always wins over "completion" — an over-budget
255
+ * run's reported completion is untrustworthy and can never be clean.
256
+ *
257
+ * @param {object} options
258
+ * @param {{run:string, gateContext:object, angles:string[]}} options.unit
259
+ * @param {{modelTurns:number, toolCalls:number}} options.consumed
260
+ * @param {Iterable<string>} [options.completedAngles]
261
+ * @returns {object} `{ ok: true, unit, consumed, reviewedAngles }` on
262
+ * success, or the durable blocker `{ ok: false, verdict: "blocked",
263
+ * reason, unreviewedAngles, headSha, unit, consumed, budget }` on failure.
264
+ */
265
+ export function enforceReviewerUnitBound({ unit, consumed, completedAngles } = {}) {
266
+ const normalizedUnit = validateReviewerUnit(unit);
267
+ const normalizedConsumed = validateConsumed(consumed);
268
+ const completedSet = normalizeCompletedAngles(completedAngles);
269
+
270
+ const assignedAngles = normalizedUnit.angles;
271
+ const unreviewedAngles = assignedAngles.filter((angle) => !completedSet.has(angle.toLowerCase()));
272
+
273
+ /** @param {string} reason @param {string[]} angles @returns {object} the durable blocker. */
274
+ const blockedResult = (reason, angles) => ({
275
+ ok: false,
276
+ verdict: "blocked",
277
+ reason,
278
+ unreviewedAngles: angles,
279
+ headSha: normalizedUnit.gateContext.headSha,
280
+ unit: normalizedUnit,
281
+ consumed: normalizedConsumed,
282
+ budget: REVIEWER_UNIT_BUDGET,
283
+ });
284
+
285
+ const budgetExceeded = normalizedConsumed.modelTurns > REVIEWER_UNIT_BUDGET.maxModelTurns
286
+ || normalizedConsumed.toolCalls > REVIEWER_UNIT_BUDGET.maxToolCalls;
287
+
288
+ if (budgetExceeded) {
289
+ // Fail-closed revoke: even a nominally "complete" run cannot be reported
290
+ // clean once it ran over budget, so the verdict is always "blocked" —
291
+ // never a silent pass — regardless of angle coverage. unreviewedAngles
292
+ // still names the genuinely-remaining angles (not the full assigned
293
+ // set): the "never reported clean" guarantee comes from the blocked
294
+ // verdict itself, not from inflating this list.
295
+ return blockedResult("reviewer_budget_exhausted", unreviewedAngles);
296
+ }
297
+
298
+ if (unreviewedAngles.length > 0) {
299
+ return blockedResult("reviewer_coverage_incomplete", unreviewedAngles);
300
+ }
301
+
302
+ return {
303
+ ok: true,
304
+ unit: normalizedUnit,
305
+ consumed: normalizedConsumed,
306
+ reviewedAngles: [...assignedAngles],
307
+ };
308
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * role-budget-bound.mjs — dev-loop execution-cap bounded role-budget
3
+ * primitive. Mirrors ./reviewer-unit-bound.mjs's style: a bounded,
4
+ * deterministic primitive that caps a judge-round, fixer-pass, or
5
+ * coordinator-phase role unit at a fixed per-role execution budget and
6
+ * always produces a durable blocked record — never a silent pass — when the
7
+ * unit runs over budget.
8
+ *
9
+ * Consolidated from the execution-cap epic's deferred per-role budgets.
10
+ * Coordinator phase: 40 model turns / 50 tool calls / 20,000 output tokens.
11
+ *
12
+ * Pure and offline: no runtime/harness adapter import, no file reads, no
13
+ * network, no state held across calls. This primitive is a post-hoc verdict
14
+ * over a `consumed` snapshot — it does not sequence or time the caller's
15
+ * before/after measurement; that stays the caller's concern.
16
+ */
17
+
18
+ /**
19
+ * Dev-loop harnesses this bound recognizes. Re-declared locally (not
20
+ * imported from child-launch-bound.mjs) so this primitive stays free of any
21
+ * cross-module coupling; it does not branch on harness, it only carries the
22
+ * label through caller-supplied gateContext.
23
+ */
24
+ export const HARNESS_VALUES = Object.freeze(["pi", "claude", "codex"]);
25
+
26
+ /** The three roles this primitive caps. */
27
+ export const ROLE_VALUES = Object.freeze(["judge_round", "fixer_pass", "coordinator_phase"]);
28
+
29
+ /**
30
+ * The fixed per-role execution budgets. Judge-round: 12 model turns / 15
31
+ * tool calls / 100k input tokens / 10k output tokens. Fixer-pass: 45 model
32
+ * turns / 50 tool calls / at most 1 push per gate round. Coordinator-phase:
33
+ * 40 model turns / 50 tool calls / 20,000 output tokens.
34
+ */
35
+ export const ROLE_BUDGETS = Object.freeze({
36
+ judge_round: Object.freeze({
37
+ maxModelTurns: 12,
38
+ maxToolCalls: 15,
39
+ maxInputTokens: 100000,
40
+ maxOutputTokens: 10000,
41
+ }),
42
+ fixer_pass: Object.freeze({
43
+ maxModelTurns: 45,
44
+ maxToolCalls: 50,
45
+ maxPushesPerGateRound: 1,
46
+ }),
47
+ coordinator_phase: Object.freeze({
48
+ maxModelTurns: 40,
49
+ maxToolCalls: 50,
50
+ maxOutputTokens: 20000,
51
+ }),
52
+ });
53
+
54
+ /**
55
+ * Maps each role's required `consumed` dimension name to the matching
56
+ * ROLE_BUDGETS max-field name. A consumed dimension belonging to the OTHER
57
+ * role is simply never read here — this map is the sole source of which
58
+ * dimensions a given role's consumed object must carry.
59
+ */
60
+ const ROLE_DIMENSION_BUDGET_KEYS = Object.freeze({
61
+ judge_round: Object.freeze({
62
+ modelTurns: "maxModelTurns",
63
+ toolCalls: "maxToolCalls",
64
+ inputTokens: "maxInputTokens",
65
+ outputTokens: "maxOutputTokens",
66
+ }),
67
+ fixer_pass: Object.freeze({
68
+ modelTurns: "maxModelTurns",
69
+ toolCalls: "maxToolCalls",
70
+ pushesThisGateRound: "maxPushesPerGateRound",
71
+ }),
72
+ coordinator_phase: Object.freeze({
73
+ modelTurns: "maxModelTurns",
74
+ toolCalls: "maxToolCalls",
75
+ outputTokens: "maxOutputTokens",
76
+ }),
77
+ });
78
+
79
+ /** @param {unknown} value @returns {boolean} */
80
+ function isNonEmptyString(value) {
81
+ return typeof value === "string" && value.trim().length > 0;
82
+ }
83
+
84
+ /**
85
+ * Recursively freeze a plain object/array value's own nested plain
86
+ * objects/arrays. A shallow Object.freeze leaves nested values mutable;
87
+ * the gate context must be genuinely immutable, not just its top level.
88
+ *
89
+ * Recurses into children even when the current container is already frozen
90
+ * (an already-frozen container can still hold mutable grandchildren — an
91
+ * early return on Object.isFrozen would skip them). A WeakSet cycle guard
92
+ * prevents infinite recursion on a cyclic object graph.
93
+ * @param {unknown} value
94
+ * @param {WeakSet<object>} [seen]
95
+ * @returns {unknown} the same value, deep-frozen.
96
+ */
97
+ function deepFreeze(value, seen = new WeakSet()) {
98
+ if (value === null || typeof value !== "object" || seen.has(value)) {
99
+ return value;
100
+ }
101
+ seen.add(value);
102
+ for (const key of Object.keys(value)) {
103
+ deepFreeze(value[key], seen);
104
+ }
105
+ return Object.freeze(value);
106
+ }
107
+
108
+ /**
109
+ * Validate + normalize a role unit at the trust boundary. Fails closed
110
+ * (TypeError naming the violation) on any malformed field.
111
+ * @param {{role:string, run:string, gateContext:object}} unit
112
+ * @returns {{role:string, run:string, gateContext:object}} frozen, normalized.
113
+ */
114
+ export function validateRoleUnit(unit) {
115
+ if (!unit || typeof unit !== "object") {
116
+ throw new TypeError("validateRoleUnit requires a unit object");
117
+ }
118
+ const { role, run, gateContext } = unit;
119
+ if (!ROLE_VALUES.includes(role)) {
120
+ throw new TypeError(`unit.role must be one of ${ROLE_VALUES.join(", ")}, got ${JSON.stringify(role)}`);
121
+ }
122
+ if (!isNonEmptyString(run)) {
123
+ throw new TypeError("unit.run must be a non-empty string");
124
+ }
125
+ if (!gateContext || typeof gateContext !== "object") {
126
+ throw new TypeError("unit.gateContext must be a non-null object");
127
+ }
128
+ if (!isNonEmptyString(gateContext.headSha)) {
129
+ throw new TypeError("unit.gateContext.headSha must be a non-empty string");
130
+ }
131
+ // harness is optional (absent is allowed), but when present it must be a
132
+ // recognized dev-loop harness — mirrors reviewer-unit-bound.mjs's own
133
+ // harness enforcement instead of silently carrying an unrecognized label.
134
+ if (gateContext.harness != null && !HARNESS_VALUES.includes(gateContext.harness)) {
135
+ throw new TypeError(`unit.gateContext.harness must be one of ${HARNESS_VALUES.join(", ")}, got ${JSON.stringify(gateContext.harness)}`);
136
+ }
137
+
138
+ // Freeze the gate context so the role unit cannot mutate the current-head
139
+ // identity it was handed; freeze the returned unit for the same reason.
140
+ // The freeze is deep — a shallow freeze would leave a nested gateContext
141
+ // value (e.g. provenance) mutable. Deep-CLONE before freezing: a shallow
142
+ // `{ ...gateContext }` spread shares nested objects with the caller, so
143
+ // deepFreeze would freeze the CALLER's own objects in place — an
144
+ // observable side effect on this otherwise-pure validator. A gate context
145
+ // is plain data; if it is not structured-cloneable that is a malformed
146
+ // non-data context, and throwing a TypeError here (not letting
147
+ // structuredClone's own DataCloneError escape) keeps the fail-closed
148
+ // posture consistent with every other malformed-input branch in this
149
+ // module.
150
+ let clonedGateContext;
151
+ try {
152
+ clonedGateContext = structuredClone(gateContext);
153
+ } catch {
154
+ throw new TypeError("unit.gateContext must be structured-cloneable (plain data, no functions/symbols/etc.)");
155
+ }
156
+ // structuredClone (like object spread) copies only own-enumerable
157
+ // properties, so an inherited/non-enumerable headSha would validate above
158
+ // yet be absent from the clone. Re-assert the validated value explicitly
159
+ // so the frozen context always carries it.
160
+ clonedGateContext.headSha = gateContext.headSha;
161
+
162
+ return Object.freeze({
163
+ role,
164
+ run: run.trim(),
165
+ gateContext: deepFreeze(clonedGateContext),
166
+ });
167
+ }
168
+
169
+ /** @param {unknown} value @returns {boolean} */
170
+ function isNonNegativeInteger(value) {
171
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
172
+ }
173
+
174
+ /**
175
+ * Validate `consumed` against exactly the dimensions the given role
176
+ * requires — a dimension belonging to the OTHER role, if present, is never
177
+ * read (it is silently ignored, not accepted as satisfying this role's own
178
+ * requirement).
179
+ * @param {"judge_round"|"fixer_pass"|"coordinator_phase"} role
180
+ * @param {object} consumed
181
+ * @returns {object} normalized consumed, containing only this role's dimensions.
182
+ */
183
+ function validateConsumedForRole(role, consumed) {
184
+ const dimensionBudgetKeys = ROLE_DIMENSION_BUDGET_KEYS[role];
185
+ if (!consumed || typeof consumed !== "object") {
186
+ throw new TypeError(`enforceRoleBudget requires consumed to be an object with ${Object.keys(dimensionBudgetKeys).join(", ")}`);
187
+ }
188
+ const normalized = {};
189
+ for (const dimension of Object.keys(dimensionBudgetKeys)) {
190
+ const value = consumed[dimension];
191
+ // Each dimension is a discrete counter — a fractional value (e.g. 44.5)
192
+ // is never a genuine count and must fail closed, not round/truncate.
193
+ if (!isNonNegativeInteger(value)) {
194
+ throw new TypeError(`consumed.${dimension} must be a non-negative integer`);
195
+ }
196
+ normalized[dimension] = value;
197
+ }
198
+ return normalized;
199
+ }
200
+
201
+ /**
202
+ * Enforce the fixed per-role execution budget. Validates the unit, selects
203
+ * the budget by `unit.role`, validates `consumed` against exactly that
204
+ * role's dimensions, and blocks (fail-closed, durable — never a silent
205
+ * pass) when any dimension exceeds its budget max.
206
+ *
207
+ * @param {object} options
208
+ * @param {{role:"judge_round"|"fixer_pass"|"coordinator_phase", run:string, gateContext:object}} options.unit
209
+ * @param {object} options.consumed
210
+ * @returns {object} `{ ok: true, role, unit, consumed, budget }` on success,
211
+ * or the durable blocker `{ ok: false, verdict: "blocked", reason,
212
+ * exceededDimensions, headSha, role, unit, consumed, budget }` on failure.
213
+ */
214
+ export function enforceRoleBudget({ unit, consumed } = {}) {
215
+ const normalizedUnit = validateRoleUnit(unit);
216
+ const { role } = normalizedUnit;
217
+ const budget = ROLE_BUDGETS[role];
218
+ const dimensionBudgetKeys = ROLE_DIMENSION_BUDGET_KEYS[role];
219
+ const normalizedConsumed = validateConsumedForRole(role, consumed);
220
+
221
+ const exceededDimensions = Object.keys(dimensionBudgetKeys).filter(
222
+ (dimension) => normalizedConsumed[dimension] > budget[dimensionBudgetKeys[dimension]],
223
+ );
224
+
225
+ if (exceededDimensions.length > 0) {
226
+ // Fail-closed: never a silent pass on exhaustion — the durable blocked
227
+ // record names every exceeded dimension, not just the first found.
228
+ return {
229
+ ok: false,
230
+ verdict: "blocked",
231
+ reason: `${role}_budget_exhausted`,
232
+ exceededDimensions,
233
+ headSha: normalizedUnit.gateContext.headSha,
234
+ role,
235
+ unit: normalizedUnit,
236
+ consumed: normalizedConsumed,
237
+ budget,
238
+ };
239
+ }
240
+
241
+ return { ok: true, role, unit: normalizedUnit, consumed: normalizedConsumed, budget };
242
+ }
@@ -3,8 +3,16 @@
3
3
  * fail-closed PR size budget). Pure, no I/O: consumes an
4
4
  * already-resolved size-budget outcome (see check-size-budget.mjs's
5
5
  * computeSizeBudget/evaluatePrSizeBudget — this module never recomputes it)
6
- * plus a human-scoped review decision, and decides whether merge must wait
7
- * for a human APPROVED review with zero unresolved CHANGES_REQUESTED.
6
+ * plus a resolved human-approval signal, and decides whether merge must wait
7
+ * for that approval with zero unresolved CHANGES_REQUESTED.
8
+ *
9
+ * The production approval signal is `humanApprovalSatisfied`, fed from the
10
+ * shared resolver `verifyFreshHumanApproval` (merge-approval.mjs): it accepts
11
+ * either a head-pinned `APPROVED` review OR a head-pinned
12
+ * `approve merge <headSha>` comment by the named approver, both excluding
13
+ * bot/agent authors and stale (non-head) commits. `reviewDecision ===
14
+ * "APPROVED"` is kept only as a compatibility-only fallback for callers with
15
+ * no comment context.
8
16
  *
9
17
  * This gate is consulted IN ADDITION TO resolveEffectiveMergeAuthorized /
10
18
  * humanMergeOnly (@dev-loops/core/config) — never instead of, and never as a
@@ -21,11 +29,21 @@ const VALID_SIZE_OUTCOMES = new Set(["pass", "escalate", "block"]);
21
29
  * Resolve whether an escalated/T1 PR's merge must wait for a human APPROVED
22
30
  * review with zero unresolved CHANGES_REQUESTED.
23
31
  *
24
- * FAILS CLOSED: an unreadable `sizeOutcome`, a non-boolean `touchesT1`, a
25
- * `reviewDecision` that is not exactly `"APPROVED"`, or a non-zero/unreadable
26
- * `unresolvedChangesRequestedCount` all require human approval (return
27
- * `true`). A `pass` outcome that never touches the T1 tier returns `false`
28
- * (no size-imposed requirement).
32
+ * FAILS CLOSED: an unreadable `sizeOutcome` or a non-boolean `touchesT1` is
33
+ * treated the SAME as an escalated/T1 outcome — it still routes through the
34
+ * approval check below rather than returning early, so a fresh approval can
35
+ * clear the gate even when size evidence is absent. An absent approval
36
+ * (`humanApprovalSatisfied` not `true` AND `reviewDecision` not exactly
37
+ * `"APPROVED"`), or a non-zero/unreadable `unresolvedChangesRequestedCount`,
38
+ * both still require human approval (return `true`) on every path, including
39
+ * the absent-evidence one. A `pass` outcome that never touches the T1 tier
40
+ * returns `false` (no size-imposed requirement).
41
+ *
42
+ * `humanApprovalSatisfied` is the PRODUCTION approval signal (the shared
43
+ * resolver's boolean output — see the paragraph below); `reviewDecision ===
44
+ * "APPROVED"` is kept only as a compatibility fallback for callers with no
45
+ * comment context. A caller wiring this gate MUST feed `humanApprovalSatisfied`
46
+ * when it has one; do not reimplement approval from `reviewDecision` alone.
29
47
  *
30
48
  * `sizeOutcome === "block"` is treated at least as strictly as `"escalate"`:
31
49
  * the issue's own wording names only "escalated or T1", but a block outcome
@@ -37,6 +55,14 @@ const VALID_SIZE_OUTCOMES = new Set(["pass", "escalate", "block"]);
37
55
  * {@link resolveHumanReviewDecision}, which derives it from raw PR reviews
38
56
  * and treats a Copilot review as never satisfying it.
39
57
  *
58
+ * `humanApprovalSatisfied` is the shared-resolver signal (see
59
+ * `verifyFreshHumanApproval` in merge-approval.mjs): a head-pinned APPROVED
60
+ * review OR a head-pinned `approve merge <headSha>` operator comment, either
61
+ * one already excluding bot/agent authors and stale (non-head) commits. When
62
+ * `true`, it satisfies this gate the same as `reviewDecision === "APPROVED"`
63
+ * — the comment fallback is a solo-owner path (GitHub forbids self-approval,
64
+ * so a solo-owner PR can never carry an `APPROVED` review object).
65
+ *
40
66
  * `touchesT1` is unprefixed here, but the persisted verdict field a caller
41
67
  * would source it from is size-namespaced (`sizeTouchesT1` in
42
68
  * copilot-helpers.mjs's detect-checkpoint-evidence output) — a caller wiring
@@ -46,6 +72,7 @@ const VALID_SIZE_OUTCOMES = new Set(["pass", "escalate", "block"]);
46
72
  * sizeOutcome?: "pass"|"escalate"|"block"|null,
47
73
  * touchesT1?: boolean,
48
74
  * reviewDecision?: "APPROVED"|"CHANGES_REQUESTED"|null,
75
+ * humanApprovalSatisfied?: boolean,
49
76
  * unresolvedChangesRequestedCount?: number,
50
77
  * }} [input]
51
78
  * @returns {boolean}
@@ -54,15 +81,24 @@ export function resolveSizeBudgetHumanApprovalRequired({
54
81
  sizeOutcome,
55
82
  touchesT1,
56
83
  reviewDecision,
84
+ humanApprovalSatisfied,
57
85
  unresolvedChangesRequestedCount,
58
86
  } = {}) {
59
- if (!VALID_SIZE_OUTCOMES.has(sizeOutcome)) return true; // size evidence absent/unreadable
60
- if (typeof touchesT1 !== "boolean") return true; // T1-touch signal missing/unreadable
61
-
62
- const requiresEscalatedReview = sizeOutcome === "escalate" || sizeOutcome === "block" || touchesT1 === true;
87
+ // Absent/unreadable size evidence folds into the escalated-review path
88
+ // instead of returning early, so it STILL reaches the approval check below
89
+ // — an early return here would make a fresh human approval unsatisfiable
90
+ // whenever size evidence is missing (the deadlock class this gate must
91
+ // never reintroduce).
92
+ const sizeEvidenceUsable = VALID_SIZE_OUTCOMES.has(sizeOutcome) && typeof touchesT1 === "boolean";
93
+ const requiresEscalatedReview =
94
+ !sizeEvidenceUsable || sizeOutcome === "escalate" || sizeOutcome === "block" || touchesT1 === true;
63
95
  if (!requiresEscalatedReview) return false; // pass, T1 untouched — no size-imposed requirement
64
96
 
65
- if (reviewDecision !== "APPROVED") return true; // absent / CHANGES_REQUESTED / Copilot-only / unknown
97
+ // ponytail: reviewDecision === "APPROVED" is kept only as the back-compat
98
+ // path for the queue-driver.mjs caller (no comment context) and the
99
+ // pure-function unit tests; production wiring feeds humanApprovalSatisfied.
100
+ const approvalPresent = humanApprovalSatisfied === true || reviewDecision === "APPROVED";
101
+ if (!approvalPresent) return true; // absent / CHANGES_REQUESTED / Copilot-only / unknown
66
102
  if (typeof unresolvedChangesRequestedCount !== "number" || unresolvedChangesRequestedCount !== 0) return true;
67
103
  return false;
68
104
  }