@dev-loops/core 1.0.2-slim.0 → 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.
- package/package.json +11 -1
- package/src/claude/asset-generation.mjs +64 -3
- package/src/claude/hook-decisions.mjs +97 -48
- package/src/config/config.mjs +439 -37
- package/src/config/extension-defaults.yaml +17 -0
- package/src/github/closing-ref-guard.mjs +80 -0
- package/src/github/copilot-helpers.mjs +28 -1
- package/src/github/issue-ops.mjs +4 -0
- package/src/github/repo-slug.mjs +25 -4
- package/src/github/test-mode-write-guard.mjs +81 -0
- package/src/loop/bash-command-classify.mjs +145 -28
- package/src/loop/child-launch-bound.mjs +152 -0
- package/src/loop/copilot-loop-state.mjs +20 -4
- package/src/loop/execution-record.mjs +412 -0
- package/src/loop/finding-cluster.mjs +277 -0
- package/src/loop/fixer-disposition.mjs +200 -0
- package/src/loop/gate-fanin.mjs +45 -0
- package/src/loop/handoff-envelope.mjs +113 -6
- package/src/loop/issue-refinement-artifact.mjs +30 -11
- package/src/loop/merge-approval.mjs +283 -0
- package/src/loop/pr-gate-coordination.mjs +49 -0
- package/src/loop/queue-board-sync.mjs +6 -3
- package/src/loop/retrospective-checkpoint.mjs +7 -8
- package/src/loop/reviewer-unit-bound.mjs +308 -0
- package/src/loop/role-budget-bound.mjs +242 -0
- package/src/loop/size-budget-merge-gate.mjs +48 -12
- package/src/loop/watcher-exclusivity.mjs +302 -0
- package/src/loop/worktree-guard.mjs +80 -13
- package/src/security/secret-scan.mjs +13 -0
|
@@ -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-
|
|
7
|
-
* for
|
|
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
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* (
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
}
|