@dev-loops/core 1.0.2-pre.0 → 1.0.2-slim.0
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 +2 -1
- package/src/analysis/diff-analyzer.mjs +85 -137
- package/src/claude/asset-generation.mjs +7 -7
- package/src/claude/hook-decisions.mjs +29 -36
- package/src/config/config.mjs +388 -787
- package/src/github/comment-id-guard.mjs +2 -2
- package/src/github/copilot-helpers.mjs +90 -158
- package/src/loop/bash-command-classify.mjs +34 -49
- package/src/loop/conductor-routing.mjs +15 -23
- package/src/loop/copilot-loop-state.mjs +46 -94
- package/src/loop/gate-carry-forward.mjs +2 -2
- package/src/loop/gate-fanin.mjs +252 -442
- package/src/loop/handoff-envelope.mjs +19 -19
- package/src/loop/issue-refinement-artifact.mjs +158 -252
- package/src/loop/lifecycle-state.mjs +10 -21
- package/src/loop/pr-gate-coordination.mjs +37 -37
- package/src/loop/queue-board-sync.mjs +16 -55
- package/src/loop/review-dispatch-plan.mjs +60 -122
- package/src/loop/review-lineage.mjs +19 -44
- package/src/loop/spec-authority.mjs +39 -69
- package/src/loop/steering.mjs +16 -68
- package/src/projects/list-queue-items.mjs +16 -146
- package/src/projects/move-queue-item.mjs +15 -141
- package/src/projects/projects-access.mjs +202 -0
package/src/config/config.mjs
CHANGED
|
@@ -11,32 +11,21 @@ import { trimmedOrNull } from "../loop/normalize.mjs";
|
|
|
11
11
|
|
|
12
12
|
// ============================================================================
|
|
13
13
|
// Sub-schemas
|
|
14
|
-
//
|
|
15
|
-
// BUILT_IN_DEFAULTS remains the canonical shipped default surface for loader
|
|
16
|
-
// fallbacks. Select field-level defaults may still exist where merged-schema
|
|
17
|
-
// callers need a stable value even when they construct config objects directly.
|
|
18
14
|
// ============================================================================
|
|
19
15
|
|
|
20
|
-
// `strategy` and `inputSource` are single-value
|
|
21
|
-
// a `default` wrapper) — flattened to a bare enum at the family key itself.
|
|
16
|
+
// `strategy` and `inputSource` are bare single-value enums.
|
|
22
17
|
//
|
|
23
|
-
// `
|
|
24
|
-
// tracker-
|
|
25
|
-
//
|
|
26
|
-
// still ACCEPTED as a deprecated alias — normalized to `tracker-first` with a
|
|
27
|
-
// load-time warning in `loadDevLoopConfig` (see the alias-normalization pass
|
|
28
|
-
// below `mergeConfigLayers`) — but this schema only validates the canonical
|
|
29
|
-
// value, so the alias must be normalized on the raw merged object BEFORE it
|
|
30
|
-
// reaches this parse.
|
|
18
|
+
// `github-first` is a deprecated accepted alias for the canonical
|
|
19
|
+
// `tracker-first`, normalized before this parse (the schema only validates the
|
|
20
|
+
// canonical value), with a load-time warning in loadDevLoopConfig.
|
|
31
21
|
const StrategyConfig = z.enum(["local-first", "tracker-first"]).describe("Work-intake strategy: local-first starts from a repo plan file, tracker-first from a tracked issue (\"github-first\" is a deprecated accepted alias).");
|
|
32
22
|
|
|
33
23
|
const InputSourceConfig = z.enum(["tracker", "phase-docs"]).describe("Where local-first work reads its spec: the tracker issue body, or repo phase docs.");
|
|
34
24
|
|
|
35
|
-
// Built-in tier aliases
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
// Pi until an operator sets concrete Pi ids.
|
|
25
|
+
// Built-in tier aliases: a harness-neutral name (low/high) → a concrete
|
|
26
|
+
// per-harness model id; `null` means "inherit" (no model override, a genuine
|
|
27
|
+
// no-op on that harness). Pi ships null on every built-in tier, so zero-config
|
|
28
|
+
// resolution is a no-op on Pi until an operator sets concrete Pi ids.
|
|
40
29
|
export const BUILTIN_TIER_ALIASES = Object.freeze(["low", "high"]);
|
|
41
30
|
|
|
42
31
|
const BUILTIN_TIERS = Object.freeze({
|
|
@@ -96,18 +85,12 @@ function refineRoleTiers(models, ctx) {
|
|
|
96
85
|
const ModelsConfigBase = z.strictObject({
|
|
97
86
|
conductor: z.string().trim().min(1).describe("Model override for the conductor (dev-loop) session; absent = inherit the session model.").optional(),
|
|
98
87
|
roles: z.record(z.string(), z.string().trim().min(1)).describe("Concrete per-role/angle model overrides (highest precedence, above tiers).").optional(),
|
|
99
|
-
// Tier alias → per-harness concrete model (null = inherit / no-op).
|
|
100
88
|
tiers: z.record(z.string().min(1), ModelTierMapping).describe("Tier alias → per-harness concrete model; null on a harness means inherit (no override).").optional(),
|
|
101
|
-
// Role / angle → tier alias (a built-in/custom alias or "inherit").
|
|
102
89
|
roleTiers: z.record(z.string().min(1), z.string().trim().min(1)).describe("Role or gate angle → tier alias: a built-in alias (low, high), a custom models.tiers alias, or \"inherit\".").optional(),
|
|
103
90
|
});
|
|
104
91
|
|
|
105
92
|
const ModelsConfig = ModelsConfigBase.superRefine(refineRoleTiers);
|
|
106
93
|
|
|
107
|
-
// A round with at most this many comments (after this many rounds) counts as
|
|
108
|
-
// low-signal and stops further Copilot rounds early — folded from the three
|
|
109
|
-
// flat `stopOnLowSignal`/`lowSignalRoundThreshold`/`lowSignalMaxComments` keys
|
|
110
|
-
// into one sub-object (they are one feature).
|
|
111
94
|
const LowSignalConfig = z.strictObject({
|
|
112
95
|
enabled: z.boolean().default(false).describe("Stop Copilot rounds early once they stop producing signal."),
|
|
113
96
|
roundThreshold: z.number().int().nonnegative().default(3).describe("Rounds counted toward the low-signal stop decision."),
|
|
@@ -122,34 +105,20 @@ const RefinementConfig = z.strictObject({
|
|
|
122
105
|
roles: z.array(z.string().trim().min(1)).describe("Review lenses the refinement fan-out dispatches.").optional(),
|
|
123
106
|
});
|
|
124
107
|
|
|
125
|
-
// Per-angle surface scope: how much of the gate-context bundle an angle
|
|
126
|
-
//
|
|
127
|
-
// "
|
|
128
|
-
//
|
|
129
|
-
// carries every changed file); "docs-only" narrows further to doc-file
|
|
130
|
-
// hunks only. Resolution (resolveGateAngleScope) fails open to "full" for an
|
|
131
|
-
// unknown/missing value — a narrow scope is an opt-in cost saving, never a
|
|
132
|
-
// silently-enforced information cut.
|
|
108
|
+
// Per-angle surface scope: how much of the gate-context bundle an angle needs
|
|
109
|
+
// (see the `scope` describe on GateAngleEntry). resolveGateAngleScope fails
|
|
110
|
+
// open to "full" for an unknown/missing value — a narrow scope is an opt-in
|
|
111
|
+
// cost saving, never a silently-enforced information cut.
|
|
133
112
|
export const GATE_ANGLE_SCOPES = Object.freeze(["full", "changed-files", "docs-only"]);
|
|
134
113
|
|
|
135
|
-
// One review angle: a bare string is sugar for `{ name }
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
// list
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
// This is the ONE identity for a gate-review angle (was five separate places
|
|
144
|
-
// — see the config-schema RFC). `mergeConfigLayers` merges these arrays BY
|
|
145
|
-
// `name` across config layers (D3), so a later layer can add or disable a
|
|
146
|
-
// single angle without restating the whole list.
|
|
147
|
-
// A bare string is sugar for { name }; preprocessing the string→object wrap
|
|
148
|
-
// BEFORE validation (rather than a z.union of the two shapes) means every
|
|
149
|
-
// malformed angle entry validates against this ONE object schema, so a bad
|
|
150
|
-
// field (e.g. `mandatory: "yes"`) reports its own actionable path/message
|
|
151
|
-
// (`gates.draft.angles.1.mandatory: ...`) instead of zod's opaque
|
|
152
|
-
// invalid_union "Invalid input" that swallows which branch failed why.
|
|
114
|
+
// One review angle: a bare string is sugar for `{ name }`; the fields are
|
|
115
|
+
// documented on the schema below. mergeConfigLayers merges these arrays BY
|
|
116
|
+
// `name` across config layers, so a later layer can add or disable a single
|
|
117
|
+
// angle without restating the whole list. Preprocessing the string→object wrap
|
|
118
|
+
// BEFORE validation (rather than a z.union) means every malformed entry
|
|
119
|
+
// validates against this ONE object schema, so a bad field reports its own
|
|
120
|
+
// actionable path/message (`gates.draft.angles.1.mandatory: ...`) instead of
|
|
121
|
+
// zod's opaque invalid_union "Invalid input".
|
|
153
122
|
const GateAngleEntry = z.preprocess(
|
|
154
123
|
(v) => (typeof v === "string" ? { name: v } : v),
|
|
155
124
|
z.strictObject({
|
|
@@ -197,50 +166,34 @@ const GateTier = z.strictObject({
|
|
|
197
166
|
});
|
|
198
167
|
|
|
199
168
|
const GateDynamicConfig = z.strictObject({
|
|
200
|
-
// Diff-driven dynamic angle
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
// when classification is ambiguous, degrading to the full static pool. Set
|
|
204
|
-
// subtractive:false to restore the full static angle pool (the gate:full label
|
|
205
|
-
// only forces per-angle dispatch of the still-pruned set, not the full pool —
|
|
206
|
-
// combine both for the original full static fan-out).
|
|
169
|
+
// Diff-driven dynamic angle PRUNING, ON by default. mandatory:true
|
|
170
|
+
// angles stay a hard always-run floor; fallbackToAll degrades to the full
|
|
171
|
+
// static pool when classification is ambiguous.
|
|
207
172
|
subtractive: z.boolean().default(true).describe("Enable diff-driven dynamic angle PRUNING for this gate (ON by default; set false to restore the full static angle pool). Was gates.<gate>.dynamicAngles."),
|
|
208
|
-
// Additive counterpart to the subtractive path
|
|
209
|
-
// context-builder may also ADD catalog angles
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
// recommend but that are not already in this gate's configured pool.
|
|
213
|
-
// Default false preserves the subtractive-only behavior exactly.
|
|
173
|
+
// Additive counterpart to the subtractive path: when true, the
|
|
174
|
+
// context-builder may also ADD catalog angles (from resolveAnglePool) that
|
|
175
|
+
// change-category heuristics recommend. Default false preserves the
|
|
176
|
+
// subtractive-only behavior.
|
|
214
177
|
additive: z.boolean().default(false).describe("Allow diff-driven addition of catalog angles beyond this gate's configured pool (was gates.<gate>.additiveAngles)."),
|
|
215
178
|
});
|
|
216
179
|
|
|
217
|
-
// One unified gate schema for draft/preApproval/spike
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
// accepted but INERT for spike (a findings-doc deliverable has no "clean
|
|
221
|
-
// verdict" escalation path and no additive dynamic pool) rather than being
|
|
222
|
-
// split into a second schema.
|
|
180
|
+
// One unified gate schema for draft/preApproval/spike: for spike,
|
|
181
|
+
// blockCleanOnFindingSeverities and dynamic.additive are accepted but INERT
|
|
182
|
+
// (a findings-doc deliverable has no clean-verdict escalation or additive pool).
|
|
223
183
|
// Single source for the blockCleanOnFindingSeverities vocabulary: the schema
|
|
224
|
-
// enum
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
// (@dev-loops/core/loop/gate-fanin) — a DEFECT severity added to
|
|
232
|
-
// SEVERITY_ORDER (one not also added to NON_DEFECT_SEVERITIES) without
|
|
233
|
-
// updating this canonical defect trio plus its legacy alias spellings (or a
|
|
234
|
-
// new defect-targeting legacy alias added without a matching entry here)
|
|
235
|
-
// must fail that test rather than leaving this enum silently stale.
|
|
184
|
+
// enum consumes these spellings verbatim and resolveGateConfig's fail-closed
|
|
185
|
+
// guard exact-matches raw entries against the same list, so the guard's accept
|
|
186
|
+
// set is byte-identical to the schema's. Exported so the vocabulary contract
|
|
187
|
+
// test (test/contracts/gate-severity-vocabulary-contract.test.mjs) pins this
|
|
188
|
+
// list against SEVERITY_ORDER + LEGACY_SEVERITY_ALIASES (@dev-loops/core/loop/
|
|
189
|
+
// gate-fanin): a new defect severity or legacy alias that skips this trio must
|
|
190
|
+
// fail that test rather than leave the enum silently stale.
|
|
236
191
|
export const BLOCKING_SEVERITY_SPELLINGS = Object.freeze(["high", "medium", "low", "must-fix", "worth-fixing-now", "nice-to-have", "defer"]);
|
|
237
192
|
const BLOCKING_SEVERITY_SPELLING_SET = new Set(BLOCKING_SEVERITY_SPELLINGS);
|
|
238
193
|
|
|
239
194
|
// Render an offending config value for a refusal message without letting the
|
|
240
|
-
// renderer itself throw
|
|
241
|
-
//
|
|
242
|
-
// back to String()), and String() itself can throw for exotic values (a
|
|
243
|
-
// null-prototype cycle, a throwing Symbol.toPrimitive) — those get a literal
|
|
195
|
+
// renderer itself throw (JSON.stringify raises on BigInt/circular; String()
|
|
196
|
+
// can throw on exotic values) — an unrenderable value gets a literal
|
|
244
197
|
// placeholder so the refusal always surfaces as the refusal.
|
|
245
198
|
function formatConfigValue(value) {
|
|
246
199
|
try {
|
|
@@ -255,10 +208,9 @@ function formatConfigValue(value) {
|
|
|
255
208
|
}
|
|
256
209
|
}
|
|
257
210
|
|
|
258
|
-
// The three GatesConfig keys whose value is a GateConfig (
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
// same three names; a fourth GateConfig-typed gate would need both updated).
|
|
211
|
+
// The three GatesConfig keys whose value is a GateConfig (carries its own
|
|
212
|
+
// blockCleanOnFindingSeverities); a fourth would need both this and the gates
|
|
213
|
+
// object below updated.
|
|
262
214
|
const GATE_KEYS_WITH_BLOCKING_SEVERITIES = /** @type {const} */ (["draft", "preApproval", "spike"]);
|
|
263
215
|
|
|
264
216
|
const GateConfig = z.strictObject({
|
|
@@ -266,64 +218,42 @@ const GateConfig = z.strictObject({
|
|
|
266
218
|
dynamic: GateDynamicConfig.optional().describe("Diff-driven dynamic angle selection policy for this gate."),
|
|
267
219
|
required: z.boolean().default(true).describe("Whether this gate must run."),
|
|
268
220
|
requireCi: z.boolean().default(true).describe("Per-gate CI prerequisite (default true): the gate requires green CI on the current head; false opts this gate out of the CI precondition entirely, including a real failure."),
|
|
269
|
-
// Defect severities only
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
// and a nit's immediate-defer disposition already decide its fate, so
|
|
273
|
-
// admitting either here would let a config block on a severity the
|
|
274
|
-
// disposition pass simultaneously auto-resolves.
|
|
221
|
+
// Defect severities only — "question"/"nit" are non-defect categories that
|
|
222
|
+
// never block a clean verdict by severity (their own answered/defer
|
|
223
|
+
// dispositions decide their fate).
|
|
275
224
|
blockCleanOnFindingSeverities: z
|
|
276
225
|
.array(z.enum(/** @type {[string, ...string[]]} */ (BLOCKING_SEVERITY_SPELLINGS)))
|
|
277
226
|
.min(1)
|
|
278
227
|
.default(["high"])
|
|
279
228
|
.describe("Defect finding severities that block a clean gate verdict (high/medium/low only — \"question\"/\"nit\" are non-defect categories and never block by severity). \"must-fix\" is the deprecated legacy spelling of \"high\", \"worth-fixing-now\" of \"medium\", and \"nice-to-have\"/\"defer\" of \"low\"; consumers normalize them."),
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
// scripts/github/_gate-finding-surface.mjs). high is exempt: it never
|
|
285
|
-
// defers and forces per-gate continuation until the gate round cap escalates.
|
|
286
|
-
// No schema-level `.default()`: resolveGateConfig applies the built-in
|
|
287
|
-
// fallback (3) only after checking BOTH this key and the deprecated
|
|
288
|
-
// `worthFixingNowFixWindow` alias. A schema-level default would fill this
|
|
289
|
-
// key on every config LAYER independently (each layer is parsed through
|
|
290
|
-
// this schema on its own before merging), permanently shadowing a layer
|
|
291
|
-
// that sets only the deprecated alias.
|
|
229
|
+
// No schema-level `.default()`: a default would fill this key on every config
|
|
230
|
+
// LAYER independently (each is parsed before merging), permanently shadowing
|
|
231
|
+
// a layer that sets only the deprecated `worthFixingNowFixWindow` alias.
|
|
232
|
+
// resolveGateConfig applies the built-in fallback (3) after checking both.
|
|
292
233
|
mediumFixWindow: z.number().int().nonnegative().optional().describe("Per-gate medium fix window: an open medium finding stays in the in-gate fix loop through this many rounds of this gate's chain before deferral. high is exempt (never defers). Default 3."),
|
|
293
|
-
// Deprecated alias for `mediumFixWindow` (pre-rename key); accepted on read
|
|
294
|
-
// and normalized in resolveGateConfig so an unmigrated config still behaves
|
|
295
|
-
// identically. `mediumFixWindow` wins when both are set.
|
|
296
234
|
worthFixingNowFixWindow: z.number().int().nonnegative().optional().describe("Deprecated alias for mediumFixWindow (pre-rename key name); mediumFixWindow wins when both are set."),
|
|
297
235
|
// Ordered, first-match-wins diff-class angle tiers (see resolveGateTier).
|
|
298
|
-
// Absent/empty = tiers never apply
|
|
299
|
-
// today's dynamic-subtractive/additive/full-pool resolution unchanged.
|
|
236
|
+
// Absent/empty = tiers never apply.
|
|
300
237
|
tiers: z.array(GateTier).min(1).describe("Ordered, first-match-wins diff-class angle tiers for this gate. When the first-matching tier's angle set is inside the gate's angle pool, it replaces dynamic angle reduction for that diff class.").optional(),
|
|
301
238
|
});
|
|
302
239
|
|
|
303
240
|
// One named group of angles dispatched together onto a single reviewer under
|
|
304
|
-
// grouped fan-out
|
|
241
|
+
// grouped fan-out. `name` is recorded as the shared reviewer's
|
|
305
242
|
// provenance `group` (see resolveFanoutGroups / fanoutReviewerPairingError).
|
|
306
243
|
const FanoutGroup = z.strictObject({
|
|
307
244
|
name: z.string().trim().min(1).describe("Group name; recorded as the shared reviewer's provenance `group` when this group dispatches."),
|
|
308
245
|
angles: z.array(z.string().trim().min(1)).min(1).describe("Angle names batched onto one reviewer when this group resolves."),
|
|
309
246
|
});
|
|
310
247
|
|
|
311
|
-
// Angle-dispatch fan-out policy (
|
|
312
|
-
//
|
|
313
|
-
// reviewer per
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
// matching, leftover ungrouped angles auto-chunk into dispatch units of
|
|
321
|
-
// ≤N instead of singletons. mode: per-angle bypasses the table entirely
|
|
322
|
-
// maxConcurrent (M, default 4, min 1) — the conductor dispatches at most M
|
|
323
|
-
// dispatch units per wave (scheduleFanoutWaves via scheduleParallelWaves).
|
|
324
|
-
// An angle resolved for a round but not named in any configured group joins
|
|
325
|
-
// the auto-chunked leftover pool — `groups` need only list the angles worth
|
|
326
|
-
// batching explicitly.
|
|
248
|
+
// Angle-dispatch fan-out policy (two-knob dispatch bounds). grouped
|
|
249
|
+
// (default) batches related angles onto one reviewer per group; per-angle emits
|
|
250
|
+
// one reviewer per angle (bypasses configured groups). gate:full forces the full
|
|
251
|
+
// angle set upstream (resolveGateTier) and dispatches GROUPED here (ADR 0048).
|
|
252
|
+
// maxAnglesPerGroup (N, default 3, min 1) — leftover ungrouped angles
|
|
253
|
+
// auto-chunk into units of ≤N after configured groups match.
|
|
254
|
+
// maxConcurrent (M, default 4, min 1) — at most M dispatch units per wave
|
|
255
|
+
// (scheduleFanoutWaves).
|
|
256
|
+
// An angle in no configured group joins the auto-chunked leftover pool.
|
|
327
257
|
const FanoutConfig = z.strictObject({
|
|
328
258
|
mode: z.enum(["grouped", "per-angle"]).default("grouped").describe("Angle dispatch mode: grouped batches related angles onto one reviewer each (default); per-angle bypasses the configured-groups table and emits one singleton unit per angle (the original full-scrutiny shape). per-angle is equivalent to maxAnglesPerGroup: 1 in dispatch unit size ONLY when no configured multi-angle group matches a resolved angle; otherwise per-angle bypasses configured groups while maxAnglesPerGroup: 1 honors them (matched first, never split)."),
|
|
329
259
|
groups: z.array(FanoutGroup).optional().describe("Static named angle groups consulted in grouped mode. An angle absent from every group joins the auto-chunked leftover pool (chunked into units of ≤maxAnglesPerGroup)."),
|
|
@@ -357,20 +287,14 @@ function rejectDuplicateFanoutGroupNames(val, ctx) {
|
|
|
357
287
|
}
|
|
358
288
|
}
|
|
359
289
|
|
|
360
|
-
// Fail-closed PR size budget (
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
// later phase's escalation surface (e.g. a t3 "relaxed" tier with its own
|
|
369
|
-
// softLoc, or a t1 slice with its own waiver ceiling) — not honored by
|
|
370
|
-
// computeSizeBudget yet, so they are parked out of the schema for Phase 1
|
|
371
|
-
// rather than shipped as inert accepted-but-ignored knobs. Only the
|
|
372
|
-
// default tier's softLoc/waiverLoc and t1's sliceHardLoc drive Phase 1's
|
|
373
|
-
// outcome; see check-size-budget.mjs.
|
|
290
|
+
// Fail-closed PR size budget (see check-size-budget.mjs's pure computation).
|
|
291
|
+
// `patterns` classifies a changed file into t1/t3 by path glob; the default
|
|
292
|
+
// tier is implicit (no `patterns`). `sliceHardLoc` (t1 only) caps the T1-slice
|
|
293
|
+
// LOC, not the whole-PR LOC. Per-tier softLoc/waiverLoc on t1/t3 and
|
|
294
|
+
// sliceHardLoc on t3 are not honored by computeSizeBudget yet, so they are
|
|
295
|
+
// parked OUT of the schema rather than shipped as inert accepted-but-ignored
|
|
296
|
+
// knobs; only the default tier's softLoc/waiverLoc and t1's sliceHardLoc drive
|
|
297
|
+
// the outcome.
|
|
374
298
|
const SizeTierConfig = z.strictObject({
|
|
375
299
|
patterns: z.array(z.string().trim().min(1)).optional().describe("Glob-style path patterns; a changed file matching one resolves to this tier."),
|
|
376
300
|
softLoc: z.number().int().positive().nullable().optional().describe("Escalate above this many logic LOC; null disables the soft threshold for this tier."),
|
|
@@ -392,21 +316,14 @@ const SizeConfig = z.strictObject({
|
|
|
392
316
|
|
|
393
317
|
const GatesConfig = z.strictObject({
|
|
394
318
|
draft: GateConfig.optional(),
|
|
395
|
-
// Fail-closed PR size/tier budget (active by default)
|
|
396
|
-
// scripts/loop/check-size-budget.mjs
|
|
397
|
-
// thresholds and tier patterns it reads.
|
|
319
|
+
// Fail-closed PR size/tier budget (active by default); computation lives in
|
|
320
|
+
// scripts/loop/check-size-budget.mjs.
|
|
398
321
|
size: SizeConfig.optional(),
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
// gate. The pre-approval gate mirrors the draft gate's `requireCi` semantics —
|
|
402
|
-
// when false the CI verdict is ignored entirely at that boundary, including a
|
|
403
|
-
// real failure (not merely "green optional").
|
|
322
|
+
// requireCi mirrors the draft gate: false ignores the CI verdict entirely at
|
|
323
|
+
// this boundary, including a real failure (not merely "green optional").
|
|
404
324
|
preApproval: GateConfig.optional(),
|
|
405
|
-
// Relaxed spike gate profile
|
|
406
|
-
//
|
|
407
|
-
// Copilot production set. Resolved through the same config-merge layering and
|
|
408
|
-
// the same resolveGateConfig path as draft/preApproval — no new strategy→knob
|
|
409
|
-
// resolver. Absent for non-spike work, so production gates are unaffected.
|
|
325
|
+
// Relaxed spike gate profile: a findings-doc deliverable, resolved
|
|
326
|
+
// through the same layering/resolveGateConfig path as draft/preApproval.
|
|
410
327
|
spike: GateConfig.optional(),
|
|
411
328
|
// Fail-closed enforcement that a gate verdict was produced by the
|
|
412
329
|
// fan-out/fan-in review sub-loop (executionMode === "fanout_fanin" plus a
|
|
@@ -414,78 +331,56 @@ const GatesConfig = z.strictObject({
|
|
|
414
331
|
// true (opt-out): a clean gate verdict requires fan-out/fan-in evidence
|
|
415
332
|
// unless explicitly disabled. See skills/docs/gate-review-sub-loop-contract.md.
|
|
416
333
|
requireFanoutEvidence: z.boolean().default(true),
|
|
417
|
-
// Fail-closed enforcement that a fanout_fanin
|
|
418
|
-
// internally-consistent fan-out
|
|
419
|
-
// per-angle dispatch).
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
// requireFanoutEvidence — only takes effect when fan-out evidence enforcement
|
|
424
|
-
// is active. Default false (opt-in): closing this loophole is additive and
|
|
425
|
-
// does not change behavior for existing ledgers that carry no provenance.
|
|
334
|
+
// Fail-closed enforcement that a fanout_fanin verdict carries recorded,
|
|
335
|
+
// internally-consistent fan-out provenance (distinct reviewer count +
|
|
336
|
+
// per-angle dispatch). RAISES THE BAR against one agent self-producing every
|
|
337
|
+
// artifact but does NOT prove independence — provenance is self-reported and
|
|
338
|
+
// forgeable (honest caveat in skills/docs/gate-review-sub-loop-contract.md).
|
|
339
|
+
// Layered on top of requireFanoutEvidence. Default false (opt-in).
|
|
426
340
|
requireFanoutProvenance: z.boolean().default(false),
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
// scheduleFanoutWaves (the wave plan emitted by write-gate-context.mjs), so
|
|
430
|
-
// maxFanoutReviewers no longer governs fan-out dispatch. Kept for back-compat
|
|
431
|
-
// (zero non-test callers in the dispatch path); a consumer setting it gets
|
|
432
|
-
// no dispatch effect. See gates.fanout.maxConcurrent for the active cap.
|
|
341
|
+
// Accepted but inert: setting it has no dispatch effect. The active
|
|
342
|
+
// concurrency cap is gates.fanout.maxConcurrent (ADR 0048).
|
|
433
343
|
maxFanoutReviewers: z.number().int().min(1).max(64).default(8).describe("SUPERSEDED by gates.fanout.maxConcurrent (#1601, ADR 0048): no longer governs fan-out dispatch — the conductor dispatches wave-by-wave at most gates.fanout.maxConcurrent (M) dispatch units per wave via scheduleFanoutWaves (the wave plan emitted by write-gate-context.mjs). Kept for back-compat; setting it has no dispatch effect."),
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
// marker-tagged PR comment. Default false (opt-in): the round's verdict
|
|
439
|
-
// review already carries every finding (GATE-COMMENT-SINGLE-SURFACE), so this
|
|
440
|
-
// comment renders each finding's text a second time. The disposition ledger
|
|
441
|
-
// is written regardless. See skills/docs/gate-review-sub-loop-contract.md.
|
|
344
|
+
// GATE-EXEC-PRIME is MANDATORY: every gate fan-out primes the byte-identical
|
|
345
|
+
// briefing prefix before reviewers read it.
|
|
346
|
+
// postFindingsComments: opt-in duplicate findings surface; the disposition
|
|
347
|
+
// ledger is written regardless.
|
|
442
348
|
postFindingsComments: z.boolean().default(false),
|
|
443
|
-
// Explicit
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
// falls back to the union of the built-in persona registry's angle names
|
|
447
|
-
// and every angle configured across this config's own draft/preApproval/
|
|
448
|
-
// spike gates.
|
|
349
|
+
// Explicit GLOBAL (not per-gate) lens catalog override for additive angle
|
|
350
|
+
// selection; resolveAnglePool falls back to persona registry ∪
|
|
351
|
+
// configured angles when absent.
|
|
449
352
|
anglePool: z.array(z.string().trim().min(1)).optional(),
|
|
450
|
-
// Fail-closed
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
// (reject); set false to warn instead of fail. See resolveRejectForeignAngles
|
|
454
|
-
// / skills/docs/gate-review-sub-loop-contract.md.
|
|
353
|
+
// Fail-closed: a fanout_fanin gate's per-angle provenance may name only
|
|
354
|
+
// angles in the gate's configured pool; foreign labels are rejected. Default
|
|
355
|
+
// true (reject); false warns instead. See resolveRejectForeignAngles.
|
|
455
356
|
rejectForeignAngles: z.boolean().default(true),
|
|
456
|
-
// Grouped vs per-angle fan-out dispatch policy + static grouping table
|
|
457
|
-
//
|
|
357
|
+
// Grouped vs per-angle fan-out dispatch policy + static grouping table.
|
|
358
|
+
// GLOBAL, not per-gate — see resolveFanoutGroups.
|
|
458
359
|
fanout: FanoutConfig.superRefine(rejectDuplicateFanoutGroupNames).optional(),
|
|
459
360
|
});
|
|
460
361
|
|
|
461
362
|
const AutonomyConfig = z.strictObject({
|
|
462
|
-
// ponytail:
|
|
463
|
-
// keys
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
// renaming that shared vocabulary, a materially larger change than this
|
|
468
|
-
// config-schema RFC's scope.
|
|
363
|
+
// ponytail: stopAt kebab values ("draft-pr"/"pre-approval") vs camelCase gate
|
|
364
|
+
// keys is DEFERRED — these are checkpoint/state-machine vocabulary shared far
|
|
365
|
+
// beyond config (lifecycle-state, hook-decisions, the handoff-envelope
|
|
366
|
+
// contract, ~20 more files); renaming here means renaming that shared
|
|
367
|
+
// vocabulary, out of scope.
|
|
469
368
|
stopAt: z.array(
|
|
470
369
|
z.enum(["refinement", "draft-pr", "pre-approval", "merge"])
|
|
471
370
|
).describe("Checkpoints that require operator confirmation before the loop proceeds (default: [\"merge\"])."),
|
|
472
|
-
// When true, merge is a fixed
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
//
|
|
371
|
+
// When true, merge is a fixed human-only action: the agent never runs
|
|
372
|
+
// `gh pr merge`, resolveAutonomyStopAt always includes "merge", and any
|
|
373
|
+
// per-run merge authorization is ignored (fails closed). See
|
|
374
|
+
// resolveEffectiveMergeAuthorized.
|
|
476
375
|
humanMergeOnly: z.boolean().describe("Merge stays a fixed human-only action: the agent never merges and any per-run merge authorization is ignored (fails closed).").optional(),
|
|
477
376
|
});
|
|
478
377
|
|
|
479
378
|
/**
|
|
480
|
-
* Human-handoff config
|
|
481
|
-
*
|
|
482
|
-
*
|
|
483
|
-
*
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
* Lifted directly onto `approval` (its only child) rather than nested under
|
|
487
|
-
* `approval.humanHandoff` — `approval` had exactly one sub-key, so the wrapper
|
|
488
|
-
* added a level without adding meaning.
|
|
379
|
+
* Human-handoff config: at the pre-approval / merge-handoff boundary,
|
|
380
|
+
* OFFER to assign the PR to a contributor reviewer/assignee. Opt-in (default
|
|
381
|
+
* off). Pairs with autonomy.humanMergeOnly. `candidatesFrom` selects which
|
|
382
|
+
* sources the resolver queries; `assignees` is a static highest-priority
|
|
383
|
+
* candidate list. Absent/empty = disabled no-op.
|
|
489
384
|
*/
|
|
490
385
|
const ApprovalConfig = z.strictObject({
|
|
491
386
|
enabled: z.boolean().default(false),
|
|
@@ -497,21 +392,16 @@ const ApprovalConfig = z.strictObject({
|
|
|
497
392
|
|
|
498
393
|
const WorkflowConfig = z.strictObject({
|
|
499
394
|
asyncStartMode: z.enum(["required", "allowed"]).default("required").describe("Whether the async start contract is required or merely allowed."),
|
|
500
|
-
// ponytail: workflow.asyncStartMode -> asyncStartRequired
|
|
501
|
-
//
|
|
502
|
-
// handoff-
|
|
503
|
-
//
|
|
504
|
-
// it here would also mean renaming a shipped artifact contract, not just a
|
|
505
|
-
// config key. Out of scope for this config-shape RFC; revisit as its own
|
|
506
|
-
// change against skills/docs/gate-review-comment-contract.md + the envelope schema.
|
|
395
|
+
// ponytail: workflow.asyncStartMode -> asyncStartRequired is DEFERRED — the
|
|
396
|
+
// string is echoed verbatim into the persisted handoff-envelope contract
|
|
397
|
+
// field (workflow-handoff-contract.test.mjs / inspect-run viewer), so
|
|
398
|
+
// renaming it means renaming a shipped artifact contract. Out of scope.
|
|
507
399
|
requireRetrospective: z.boolean().describe("Require a retrospective checkpoint for the previous qualifying async completion before the next dev-loop start/resume."),
|
|
508
400
|
requireDraftFirst: z.boolean().describe("Open pull requests as drafts and promote via the draft gate."),
|
|
509
401
|
devModeDefault: z.boolean().describe("Default new loops to dev mode."),
|
|
510
|
-
// Agent-level stall detection
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
// interrupt+resume. `enabled: false` disables the auto-bail and restores
|
|
514
|
-
// the old wait behavior.
|
|
402
|
+
// Agent-level stall detection: a child with no turn progress for
|
|
403
|
+
// thresholdMinutes and no pending request triggers a fresh-context recovery
|
|
404
|
+
// dispatch. enabled:false restores the old wait behavior.
|
|
515
405
|
stallDetection: z
|
|
516
406
|
.strictObject({
|
|
517
407
|
enabled: z.boolean().default(true).describe("Enable agent-level stall -> auto-fresh-dispatch."),
|
|
@@ -532,26 +422,20 @@ const LocalImplementationConfig = z.strictObject({
|
|
|
532
422
|
enabled: z.boolean().describe("Opt small scoped changes into the lightweight dispatch path."),
|
|
533
423
|
maxFiles: z.number().int().min(1).describe("Light mode applies only when the change touches at most this many files."),
|
|
534
424
|
maxLines: z.number().int().min(1).describe("Light mode applies only when the change stays within this many lines."),
|
|
535
|
-
//
|
|
536
|
-
// (does not replace) refinement.maxCopilotRounds — see
|
|
425
|
+
// Composes with (does not replace) refinement.maxCopilotRounds — see
|
|
537
426
|
// resolveEffectiveCopilotRoundCap.
|
|
538
427
|
maxCopilotRounds: z.number().int().nonnegative().default(1).describe("Copilot round cap for light-dispatched PRs; composes as min(this, refinement.maxCopilotRounds)."),
|
|
539
428
|
}).optional(),
|
|
540
429
|
/**
|
|
541
|
-
* Opt into issue-less PR-first
|
|
542
|
-
*
|
|
543
|
-
* inline vs full_fanout from scope on its own, so over-threshold issue-less
|
|
544
|
-
* PRs get the full fan-out and the full-PR Copilot round cap.
|
|
545
|
-
*
|
|
546
|
-
* Flattened to a bare boolean — `enabled` was its only child key.
|
|
430
|
+
* Opt into issue-less PR-first at ANY change scope. Decoupled from lightMode:
|
|
431
|
+
* gate dispatch still resolves inline vs full_fanout from scope on its own.
|
|
547
432
|
*/
|
|
548
433
|
issueless: z.boolean().describe("Opt into issue-less PR-first dispatch at any change scope; gate dispatch still resolves inline vs full fan-out from scope on its own.").optional(),
|
|
549
434
|
});
|
|
550
435
|
|
|
551
|
-
// GitHub Projects board identifier: exactly one of number/title
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
// message rather than a shared one that could name the wrong key.
|
|
436
|
+
// GitHub Projects board identifier: exactly one of number/title. `ownerKey`
|
|
437
|
+
// names the config key in the refine failure message so each usage site gets
|
|
438
|
+
// an accurate message.
|
|
555
439
|
function boardRefConfig(ownerKey) {
|
|
556
440
|
return z
|
|
557
441
|
.strictObject({
|
|
@@ -572,22 +456,13 @@ const QueueConfig = z.strictObject({
|
|
|
572
456
|
});
|
|
573
457
|
|
|
574
458
|
/**
|
|
575
|
-
* Tracker config (
|
|
576
|
-
*
|
|
577
|
-
*
|
|
578
|
-
*
|
|
579
|
-
* provider
|
|
580
|
-
* `
|
|
581
|
-
*
|
|
582
|
-
* No generic `fieldMappings` (logical-column -> provider-status) key here:
|
|
583
|
-
* the github provider's logical-column -> Status mapping IS the existing,
|
|
584
|
-
* already-load-bearing `queue.statusColumns` (read by `loadStateColumnMap` in
|
|
585
|
-
* `../loop/queue-board-sync.mjs`; `next_up` is the fail-closed pickup column
|
|
586
|
-
* `resolve-active-board-item.mjs` reads). Adding a second, inert mapping key
|
|
587
|
-
* here would collide with that live one rather than replace it. A future
|
|
588
|
-
* external provider defines its OWN logical -> status mapping (its shape is
|
|
589
|
-
* provider-specific) when one is actually implemented — YAGNI to generalize
|
|
590
|
-
* this now for a provider that does not exist yet.
|
|
459
|
+
* Tracker config (the tracker-agnostic seam). `provider` is a free-form
|
|
460
|
+
* registry key (not a zod enum): an unknown provider fails closed at
|
|
461
|
+
* `resolveTrackerAdapter` call time, not at parse time, so a consumer can
|
|
462
|
+
* register an external provider post-1.0. No generic `fieldMappings` key: the
|
|
463
|
+
* github provider's logical-column -> Status mapping IS the existing
|
|
464
|
+
* `queue.statusColumns` (a second key would collide with it); a future external
|
|
465
|
+
* provider defines its own mapping when implemented (YAGNI now).
|
|
591
466
|
*/
|
|
592
467
|
const TrackerConfig = z.strictObject({
|
|
593
468
|
provider: z.string().trim().min(1).describe("Tracker provider registry key. Built-in: \"github\" (default).").optional(),
|
|
@@ -596,12 +471,9 @@ const TrackerConfig = z.strictObject({
|
|
|
596
471
|
});
|
|
597
472
|
|
|
598
473
|
/**
|
|
599
|
-
* Worktree lifecycle config
|
|
600
|
-
*
|
|
601
|
-
*
|
|
602
|
-
* `copyOnInit`/`linkOnInit` arrays encoding the mode via which array it lived
|
|
603
|
-
* in). `copy` → `fs.cp` (isolated per worktree); `link` → absolute symlink
|
|
604
|
-
* into the main checkout (read-only data). Empty/absent is a valid no-op.
|
|
474
|
+
* Worktree lifecycle config: gitignored files/dirs provisioned into a
|
|
475
|
+
* fresh worktree from the main checkout. Entries are repo-relative literal
|
|
476
|
+
* paths or globs, each tagged copy or link. Empty/absent is a valid no-op.
|
|
605
477
|
*/
|
|
606
478
|
const WorktreeEntry = z.strictObject({
|
|
607
479
|
path: z.string().trim().min(1).describe("Repo-relative path or glob."),
|
|
@@ -614,18 +486,14 @@ const WorktreeConfig = z.strictObject({
|
|
|
614
486
|
|
|
615
487
|
/**
|
|
616
488
|
* Dev-DB migration sub-recipe for the ui-review run recipe. `statusCommand`
|
|
617
|
-
* lists pending migrations
|
|
489
|
+
* lists pending migrations; `applyCommand` applies them.
|
|
618
490
|
*
|
|
619
491
|
* Destructive detection is EXPLICIT and status-format-dependent: the
|
|
620
|
-
* `destructivePattern` regex
|
|
621
|
-
* STATUS OUTPUT
|
|
622
|
-
*
|
|
623
|
-
*
|
|
624
|
-
*
|
|
625
|
-
* is inert. A project whose status output is NOT SQL therefore MUST set a
|
|
626
|
-
* `destructivePattern` that matches its own status format (e.g. a `destructive`/
|
|
627
|
-
* `down` marker), or make `statusCommand` emit the destructive SQL/marker — the
|
|
628
|
-
* default cannot detect what its status output never prints.
|
|
492
|
+
* `destructivePattern` regex matches (case-insensitive, per line) against the
|
|
493
|
+
* STATUS OUTPUT, not the migration files. The shipped default assumes
|
|
494
|
+
* SQL-bearing status output; against non-SQL status output it matches nothing
|
|
495
|
+
* and the guard is inert, so such a project MUST set a `destructivePattern`
|
|
496
|
+
* matching its own status format (or make statusCommand emit the SQL/marker).
|
|
629
497
|
*/
|
|
630
498
|
const UiReviewMigrateConfig = z.strictObject({
|
|
631
499
|
statusCommand: z.string().trim().min(1),
|
|
@@ -650,11 +518,10 @@ const UiReviewMigrateConfig = z.strictObject({
|
|
|
650
518
|
|
|
651
519
|
/**
|
|
652
520
|
* Per-project dev-DB row-teardown recipe (Stage 5). The drive stamps each
|
|
653
|
-
* mutating step
|
|
654
|
-
*
|
|
655
|
-
*
|
|
656
|
-
*
|
|
657
|
-
* worktree (dev DB only). Teardown runs it only on explicit confirmation.
|
|
521
|
+
* mutating step with a drive-session id; this `deleteCommand` deletes exactly
|
|
522
|
+
* the rows the app tagged with that session (id in the UI_REVIEW_DRIVE_SESSION
|
|
523
|
+
* env var; runs in the provisioned worktree, dev DB only). Runs only on
|
|
524
|
+
* explicit confirmation.
|
|
658
525
|
*/
|
|
659
526
|
const UiReviewRowTeardownConfig = z.strictObject({
|
|
660
527
|
deleteCommand: z.string().trim().min(1),
|
|
@@ -732,10 +599,8 @@ const UiReviewFlowStepConfig = z.strictObject({
|
|
|
732
599
|
path: z.string().trim().min(1).optional(),
|
|
733
600
|
value: z.string().optional(),
|
|
734
601
|
event: z.string().trim().min(1).optional(),
|
|
735
|
-
//
|
|
736
|
-
//
|
|
737
|
-
// default vs error) render lands in a distinct reviewable directory. The route
|
|
738
|
-
// NAMES its interaction states — the drive never enumerates them itself.
|
|
602
|
+
// A declared viewport resizes the page before the step and bakes into the
|
|
603
|
+
// named-state slug, so distinct renders land in distinct reviewable dirs.
|
|
739
604
|
viewport: z.strictObject({ width: z.number().int().positive(), height: z.number().int().positive() }).optional(),
|
|
740
605
|
interactionState: z.enum(["none", "focus", "hover", "error"]).optional(),
|
|
741
606
|
}).superRefine((step, ctx) => {
|
|
@@ -804,7 +669,7 @@ const UiReviewConfig = z.strictObject({
|
|
|
804
669
|
.optional(),
|
|
805
670
|
});
|
|
806
671
|
|
|
807
|
-
// Default/ceiling bounds for a post-merge action's run/verify timing
|
|
672
|
+
// Default/ceiling bounds for a post-merge action's run/verify timing.
|
|
808
673
|
// The default keeps a config-declared action from hanging a harness hook
|
|
809
674
|
// forever when the author leaves timeoutMs unset; the ceiling caps how far a
|
|
810
675
|
// config CAN push it — a config can only tighten these, never loosen past the
|
|
@@ -870,13 +735,6 @@ const FileGatesConfig = z.strictObject({
|
|
|
870
735
|
|
|
871
736
|
// ============================================================================
|
|
872
737
|
// Full schema — families are optional (BUILT_IN_DEFAULTS provides fallback)
|
|
873
|
-
//
|
|
874
|
-
// The `tracker:` config block is intentionally reserved here; a future
|
|
875
|
-
// tracker-seam change adds it on top of this restructured schema. Not added
|
|
876
|
-
// in this pass — this is the config-shape redesign only — but resolvers in
|
|
877
|
-
// this module take the effective config as a plain parameter (no
|
|
878
|
-
// global/singleton reads), so a later tracker adapter (and any multi-tracker
|
|
879
|
-
// layer on top of it) stays additive.
|
|
880
738
|
// ============================================================================
|
|
881
739
|
|
|
882
740
|
/**
|
|
@@ -974,23 +832,14 @@ export const FileConfigSchema = z.strictObject({
|
|
|
974
832
|
worktree: WorktreeConfig.partial().describe("Worktree provisioning: gitignored files/dirs copied or symlinked into fresh worktrees.").optional(),
|
|
975
833
|
uiReview: UiReviewConfig.partial().describe("UI-review route recipes: per-project run/boot, dev-login, driven flows, and caps.").optional(),
|
|
976
834
|
postMerge: PostMergeConfig.partial().describe("Post-merge local hook actions (postMerge.actions): consumer-declared commands run sequentially, in order, after a merge succeeds — optionally scoped to changed-file substrings (onlyIfChanged) and polled for readiness (verify).").optional(),
|
|
977
|
-
//
|
|
978
|
-
// behavior in #1088, tolerated-but-unread since) is dropped from the 1.0
|
|
979
|
-
// schema entirely — an unknown key now fails closed like any other typo,
|
|
980
|
-
// rather than silently parsing and doing nothing.
|
|
835
|
+
// Unknown keys fail closed like any typo (strictObject).
|
|
981
836
|
});
|
|
982
837
|
|
|
983
838
|
// ============================================================================
|
|
984
839
|
// Built-in persona registry — fallback for gate-review angle → reviewer
|
|
985
|
-
// persona resolution.
|
|
986
|
-
//
|
|
987
|
-
//
|
|
988
|
-
// defined here; prompts and per-angle model overrides live on the angle's own
|
|
989
|
-
// config entry (gates.<gate>.angles[].persona/.prompt/.model/.tier) when a
|
|
990
|
-
// consumer wants to override this registry — see resolveReviewerRole.
|
|
991
|
-
//
|
|
992
|
-
// Angle names come from the gate-angle config (gates.draft.angles /
|
|
993
|
-
// gates.preApproval.angles in extension-defaults.yaml).
|
|
840
|
+
// persona resolution. Only the persona name is defined here; prompts and
|
|
841
|
+
// per-angle model overrides live on the angle's own config entry (see
|
|
842
|
+
// resolveReviewerRole).
|
|
994
843
|
// ============================================================================
|
|
995
844
|
|
|
996
845
|
const BUILTIN_PERSONAS = Object.freeze({
|
|
@@ -1084,25 +933,12 @@ function normalizeAngleEntries(raw) {
|
|
|
1084
933
|
|
|
1085
934
|
/**
|
|
1086
935
|
* Find a named angle's configured entry, searching this config's own gates in
|
|
1087
|
-
* a fixed priority order (draft, preApproval, spike)
|
|
1088
|
-
*
|
|
1089
|
-
*
|
|
1090
|
-
*
|
|
1091
|
-
*
|
|
1092
|
-
*
|
|
1093
|
-
* gives the same angle name divergent overrides across gates, so this is
|
|
1094
|
-
* unambiguous in practice.
|
|
1095
|
-
*
|
|
1096
|
-
* A DISABLED entry (`enabled: false`) is skipped, never returned: the same
|
|
1097
|
-
* angle name can be a real, enabled angle with its own persona/prompt on one
|
|
1098
|
-
* gate while merely disabled (a bare `enabled:false` placeholder, no override
|
|
1099
|
-
* fields) on another — e.g. a gate that inherited the name via merge-by-name
|
|
1100
|
-
* (D3) and dropped it. Returning that placeholder would shadow the other
|
|
1101
|
-
* gate's real override. Both callers of this function (resolveReviewerRole,
|
|
1102
|
-
* resolveRoleModel's angle path) only ever look up a name already present in
|
|
1103
|
-
* SOME gate's enabled, resolved angle list (`resolveGateAngles`), so a name
|
|
1104
|
-
* disabled everywhere and enabled nowhere is never actually queried — there
|
|
1105
|
-
* is no "return the disabled entry as a last resort" case to serve.
|
|
936
|
+
* a fixed priority order (draft, preApproval, spike) and returning the first
|
|
937
|
+
* match. A DISABLED entry (`enabled: false`) is SKIPPED, never returned:
|
|
938
|
+
* returning a bare `enabled:false` placeholder would shadow another gate's real
|
|
939
|
+
* override of the same angle name. Both callers only ever look up a name
|
|
940
|
+
* already present in some gate's enabled resolved list, so a name disabled
|
|
941
|
+
* everywhere is never queried.
|
|
1106
942
|
* @param {DevLoopConfig} config
|
|
1107
943
|
* @param {string} name
|
|
1108
944
|
* @returns {{name: string, mandatory?: boolean, enabled?: boolean, persona?: string, prompt?: string, model?: string, tier?: string}|null}
|
|
@@ -1117,14 +953,10 @@ function findAngleEntry(config, name) {
|
|
|
1117
953
|
}
|
|
1118
954
|
|
|
1119
955
|
/**
|
|
1120
|
-
* Resolve a gate angle's declared surface scope
|
|
1121
|
-
*
|
|
1122
|
-
*
|
|
1123
|
-
*
|
|
1124
|
-
* the entry within the ONE named gate — an angle's scope is meaningful only
|
|
1125
|
-
* for the specific gate pass building its briefing. Fails open to "full" for
|
|
1126
|
-
* an angle with no configured entry, a disabled entry, or an
|
|
1127
|
-
* unknown/malformed `scope` value: a narrow scope is an opt-in cost saving,
|
|
956
|
+
* Resolve a gate angle's declared surface scope: see GATE_ANGLE_SCOPES.
|
|
957
|
+
* Looks up the entry within the ONE named gate (scope is meaningful only for
|
|
958
|
+
* that gate's briefing pass). Fails open to "full" for a missing/disabled entry
|
|
959
|
+
* or an unknown/malformed `scope` — a narrow scope is an opt-in cost saving,
|
|
1128
960
|
* never a silently-enforced information cut.
|
|
1129
961
|
* @param {DevLoopConfig} config
|
|
1130
962
|
* @param {"draft"|"preApproval"|"spike"} gate
|
|
@@ -1158,23 +990,15 @@ function resolveTierMapping(config, tierAlias, harness) {
|
|
|
1158
990
|
}
|
|
1159
991
|
|
|
1160
992
|
/**
|
|
1161
|
-
* Resolve a gate angle name to a reviewer persona and model.
|
|
1162
|
-
*
|
|
1163
|
-
*
|
|
1164
|
-
*
|
|
1165
|
-
* (`gates.<gate>.angles[].persona`/`.prompt`/`.model` — consumer overrides,
|
|
1166
|
-
* see {@link findAngleEntry})
|
|
1167
|
-
* 2. If not found in config, look up in BUILTIN_PERSONAS
|
|
1168
|
-
* 3. If found in either, apply the entry's `model` override if present
|
|
1169
|
-
* 4. If not found anywhere, fall back to default reviewer with angle as focus lens,
|
|
1170
|
-
* still applying any `model` override from the entry
|
|
1171
|
-
*
|
|
993
|
+
* Resolve a gate angle name to a reviewer persona and model. Resolution:
|
|
994
|
+
* the angle's own configured entry (findAngleEntry), else BUILTIN_PERSONAS,
|
|
995
|
+
* applying any entry `model` override; an unknown angle falls back to the
|
|
996
|
+
* default reviewer (still honoring a `model` override).
|
|
1172
997
|
* @param {object} config - DevLoopConfig (or a partial with gates)
|
|
1173
998
|
* @param {string|null|undefined} angle - Gate angle / lens name
|
|
1174
999
|
* @returns {RoleResolutionResult}
|
|
1175
1000
|
*/
|
|
1176
1001
|
export function resolveReviewerRole(config, angle) {
|
|
1177
|
-
// Null/undefined/empty angle → fallback
|
|
1178
1002
|
if (angle == null || angle === "") {
|
|
1179
1003
|
return {
|
|
1180
1004
|
persona: DEFAULT_REVIEWER_PERSONA,
|
|
@@ -1212,28 +1036,17 @@ export function resolveReviewerRole(config, angle) {
|
|
|
1212
1036
|
* `null` (inherit → pass no model override).
|
|
1213
1037
|
*
|
|
1214
1038
|
* Precedence:
|
|
1215
|
-
* 1. `kind: "angle"` (gate review dispatch): the angle's own
|
|
1216
|
-
* `
|
|
1217
|
-
*
|
|
1218
|
-
*
|
|
1219
|
-
*
|
|
1220
|
-
*
|
|
1221
|
-
* `
|
|
1222
|
-
*
|
|
1223
|
-
*
|
|
1224
|
-
*
|
|
1225
|
-
*
|
|
1226
|
-
* named role, falls back to the tier for its review persona (so a
|
|
1227
|
-
* non-colliding gate angle passed without `kind` still resolves high via
|
|
1228
|
-
* `review`).
|
|
1229
|
-
*
|
|
1230
|
-
* Callers dispatching a gate review angle whose name may collide with a routine
|
|
1231
|
-
* role (only `docs` today) MUST pass `kind: "angle"` to avoid the silent
|
|
1232
|
-
* downgrade; role dispatch leaves `kind` unset.
|
|
1233
|
-
*
|
|
1234
|
-
* Zero-config is a genuine no-op on Pi (built-in tiers are null for pi) and
|
|
1235
|
-
* reproduces the standing policy on Claude (routine=low, refiner/review=high,
|
|
1236
|
-
* dev-loop=inherit).
|
|
1039
|
+
* 1. `kind: "angle"` (gate review dispatch): the angle's own `model`, else its
|
|
1040
|
+
* `tier`, else the built-in `review` tier — so a gate review runs at review
|
|
1041
|
+
* quality even when the angle name collides with a routine role (e.g. the
|
|
1042
|
+
* `docs` angle resolves high via `review`, not the `docs` writer's low tier).
|
|
1043
|
+
* 2. `kind: "role"`/absent (routine subagent): `models.roles[role]`, else
|
|
1044
|
+
* `models.roleTiers[role]` (or the built-in role tier) mapped through
|
|
1045
|
+
* `models.tiers`; `inherit`/absent/null → null. A non-role name falls back
|
|
1046
|
+
* to its review persona's tier.
|
|
1047
|
+
*
|
|
1048
|
+
* Callers dispatching a gate angle whose name may collide with a routine role
|
|
1049
|
+
* (only `docs` today) MUST pass `kind: "angle"` to avoid the silent downgrade.
|
|
1237
1050
|
*
|
|
1238
1051
|
* @param {DevLoopConfig} config
|
|
1239
1052
|
* @param {{ role: string, harness: "claude"|"pi", kind?: "role"|"angle" }} params
|
|
@@ -1249,9 +1062,7 @@ export function resolveRoleModel(config, { role, harness, kind } = {}) {
|
|
|
1249
1062
|
return resolveTierMapping(config, tierAlias, harness);
|
|
1250
1063
|
}
|
|
1251
1064
|
|
|
1252
|
-
//
|
|
1253
|
-
// only — angle-keyed concrete overrides moved to the gate's angle entry
|
|
1254
|
-
// (kind: "angle", above).
|
|
1065
|
+
// Concrete per-role override wins outright over any tier (role-keyed only).
|
|
1255
1066
|
const concrete = config?.models?.roles?.[role];
|
|
1256
1067
|
if (typeof concrete === "string" && concrete.trim().length > 0) {
|
|
1257
1068
|
return concrete.trim();
|
|
@@ -1347,7 +1158,7 @@ function mergeGatesFamily(target, source) {
|
|
|
1347
1158
|
|
|
1348
1159
|
/**
|
|
1349
1160
|
* Merge one gate object (draft/preApproval/spike) across config layers.
|
|
1350
|
-
* `angles` merges BY NAME
|
|
1161
|
+
* `angles` merges BY NAME: a later layer can add a new angle, or override
|
|
1351
1162
|
* an existing angle's flags (including `enabled: false` to drop it), without
|
|
1352
1163
|
* restating the whole array. `dynamic` merges shallowly (its two booleans).
|
|
1353
1164
|
* Every other key (`required`, `requireCi`, `blockCleanOnFindingSeverities`)
|
|
@@ -1368,7 +1179,7 @@ function mergeGateObject(target, source) {
|
|
|
1368
1179
|
}
|
|
1369
1180
|
|
|
1370
1181
|
/**
|
|
1371
|
-
* Merge two `gates.<gate>.angles` arrays BY `name
|
|
1182
|
+
* Merge two `gates.<gate>.angles` arrays BY `name`: entries in `target`
|
|
1372
1183
|
* keep their position; a `source` entry with a name already in `target`
|
|
1373
1184
|
* overrides that entry's fields (shallow — e.g. `{ enabled: false }` drops it
|
|
1374
1185
|
* without touching its `persona`/`prompt`); a `source` entry with a new name
|
|
@@ -1451,12 +1262,9 @@ async function findConfigFile(basePaths) {
|
|
|
1451
1262
|
const candidates = Array.isArray(basePaths) ? basePaths : [basePaths];
|
|
1452
1263
|
|
|
1453
1264
|
for (const basePath of candidates) {
|
|
1454
|
-
// Try bare path first
|
|
1455
|
-
//
|
|
1456
|
-
//
|
|
1457
|
-
// Other errors (EISDIR, EACCES): file exists but is unreadable —
|
|
1458
|
-
// try extension variants as fallback, but surface the original
|
|
1459
|
-
// error if no extension variant exists.
|
|
1265
|
+
// Try bare path first. ENOENT: try extension variants. Other errors
|
|
1266
|
+
// (EISDIR/EACCES) mean the bare file exists but is unreadable — try
|
|
1267
|
+
// extension variants, but surface the original error if none exists.
|
|
1460
1268
|
let bareData = null;
|
|
1461
1269
|
let bareError = null;
|
|
1462
1270
|
try {
|
|
@@ -1525,10 +1333,9 @@ async function applyLayer(merged, basePaths, layer, warnings, errors, options =
|
|
|
1525
1333
|
return merged;
|
|
1526
1334
|
}
|
|
1527
1335
|
|
|
1528
|
-
// Deprecated `strategy: "github-first"` alias
|
|
1529
|
-
//
|
|
1530
|
-
//
|
|
1531
|
-
// canonical value and would otherwise drop the whole layer as invalid.
|
|
1336
|
+
// Deprecated `strategy: "github-first"` alias: normalized to
|
|
1337
|
+
// "tracker-first" BEFORE this layer's FileConfigSchema validation (the enum
|
|
1338
|
+
// only accepts the canonical value, else the whole layer drops as invalid).
|
|
1532
1339
|
if (data.strategy === "github-first") {
|
|
1533
1340
|
warnings.push(
|
|
1534
1341
|
`strategy: "github-first" is a deprecated alias for "tracker-first" (issue #1408). ` +
|
|
@@ -1537,11 +1344,9 @@ async function applyLayer(merged, basePaths, layer, warnings, errors, options =
|
|
|
1537
1344
|
data = { ...data, strategy: "tracker-first" };
|
|
1538
1345
|
}
|
|
1539
1346
|
|
|
1540
|
-
//
|
|
1541
|
-
//
|
|
1542
|
-
//
|
|
1543
|
-
// with a deprecation warning — old configs keep loading; priming happens
|
|
1544
|
-
// unconditionally regardless of the removed value.
|
|
1347
|
+
// gates.primeSharedPrefix is not a knob (priming is always on). The schema is
|
|
1348
|
+
// strictObject, so strip this stale key before validation (with a deprecation
|
|
1349
|
+
// warning) rather than let it drop the whole gates layer.
|
|
1545
1350
|
if (data?.gates && Object.prototype.hasOwnProperty.call(data.gates, "primeSharedPrefix")) {
|
|
1546
1351
|
warnings.push(
|
|
1547
1352
|
`gates.primeSharedPrefix is removed (#1462): cache priming is now mandatory, not configurable. ` +
|
|
@@ -1551,24 +1356,15 @@ async function applyLayer(merged, basePaths, layer, warnings, errors, options =
|
|
|
1551
1356
|
data = { ...data, gates: gatesRest };
|
|
1552
1357
|
}
|
|
1553
1358
|
|
|
1554
|
-
// Validate the file's structure before merging
|
|
1555
|
-
//
|
|
1556
|
-
//
|
|
1557
|
-
//
|
|
1558
|
-
// keys — a single typo'd angle field is exactly as disruptive as a
|
|
1559
|
-
// completely broken file. `errors[].message` now names the offending
|
|
1560
|
-
// path/field (see GateAngleEntry's preprocess-not-union shape), so the
|
|
1561
|
-
// failure is at least actionable; the whole-layer-skip granularity itself
|
|
1562
|
-
// is an existing, separate concern.
|
|
1359
|
+
// Validate the file's structure before merging: a schema violation ANYWHERE
|
|
1360
|
+
// in this layer drops the WHOLE layer (errors populated, `merged` returned
|
|
1361
|
+
// unchanged) rather than merging the file's other valid keys. errors[].message
|
|
1362
|
+
// names the offending path/field so the failure is actionable.
|
|
1563
1363
|
const validation = FileConfigSchema.safeParse(data);
|
|
1564
1364
|
if (!validation.success) {
|
|
1565
1365
|
// Surface a visible WARNING (not just the structured error) so the
|
|
1566
|
-
// whole-layer drop is never silent
|
|
1567
|
-
//
|
|
1568
|
-
// schema-rejected layer would vanish without a trace. Naming the
|
|
1569
|
-
// offending keys here lets a stale raw-key config (e.g.
|
|
1570
|
-
// gates.<gate>.mandatoryAngles/excludeAngles) point at the canonical
|
|
1571
|
-
// angle-entry migration path.
|
|
1366
|
+
// whole-layer drop is never silent: many consumers never read
|
|
1367
|
+
// `errors`, so a schema-rejected layer would vanish without a trace.
|
|
1572
1368
|
const offendingKeys = validation.error.issues
|
|
1573
1369
|
.flatMap((i) => {
|
|
1574
1370
|
if (i.code === "unrecognized_keys" && Array.isArray(i.keys) && i.keys.length) {
|
|
@@ -1577,10 +1373,9 @@ async function applyLayer(merged, basePaths, layer, warnings, errors, options =
|
|
|
1577
1373
|
}
|
|
1578
1374
|
return i.path.length ? [i.path.join(".")] : [];
|
|
1579
1375
|
});
|
|
1580
|
-
//
|
|
1581
|
-
//
|
|
1582
|
-
//
|
|
1583
|
-
// misleading raw-key migration guidance. (#1578)
|
|
1376
|
+
// Only append the raw-key migration hint when the offending keys actually
|
|
1377
|
+
// include the raw mandatoryAngles/excludeAngles names, so an unrelated
|
|
1378
|
+
// failure does not get misleading guidance.
|
|
1584
1379
|
const hasRawGateKey = offendingKeys.some((k) => /mandatoryAngles|excludeAngles/.test(k));
|
|
1585
1380
|
const migrationHint = hasRawGateKey
|
|
1586
1381
|
? ` Migrate raw gates.<gate>.mandatoryAngles/excludeAngles to the canonical angle-entry shape ` +
|
|
@@ -1648,11 +1443,9 @@ export async function loadDevLoopConfig(options = {}) {
|
|
|
1648
1443
|
warnOnMissing: true,
|
|
1649
1444
|
});
|
|
1650
1445
|
|
|
1651
|
-
//
|
|
1652
|
-
//
|
|
1653
|
-
//
|
|
1654
|
-
// so we must select the .devloops path so applyLayer can record the
|
|
1655
|
-
// structured error.
|
|
1446
|
+
// .devloops (primary override) existence: only ENOENT means genuinely absent.
|
|
1447
|
+
// Any other error (EACCES/EISDIR) means it exists but is unreadable, so
|
|
1448
|
+
// select the .devloops path and let applyLayer record the structured error.
|
|
1656
1449
|
let primaryExists = false;
|
|
1657
1450
|
for (const ext of ["", ".yaml", ".yml", ".json"]) {
|
|
1658
1451
|
try {
|
|
@@ -1669,7 +1462,6 @@ export async function loadDevLoopConfig(options = {}) {
|
|
|
1669
1462
|
}
|
|
1670
1463
|
|
|
1671
1464
|
if (primaryExists) {
|
|
1672
|
-
// .devloops is the primary override — apply it
|
|
1673
1465
|
merged = await applyLayer(merged, devloopsPath, "devloops", warnings, errors);
|
|
1674
1466
|
}
|
|
1675
1467
|
|
|
@@ -1689,14 +1481,8 @@ export async function loadDevLoopConfig(options = {}) {
|
|
|
1689
1481
|
}
|
|
1690
1482
|
|
|
1691
1483
|
/**
|
|
1692
|
-
* Resolve the conductor model from the merged
|
|
1693
|
-
*
|
|
1694
|
-
* Returns the configured model string if present, or null when the config
|
|
1695
|
-
* does not specify a conductor model override (caller falls back to its
|
|
1696
|
-
* own built-in default).
|
|
1697
|
-
*
|
|
1698
|
-
* Accepts the validated DevLoopConfig from {@link loadDevLoopConfig}.
|
|
1699
|
-
*
|
|
1484
|
+
* Resolve the conductor model override from the merged config, or null when
|
|
1485
|
+
* unset (caller falls back to its own default).
|
|
1700
1486
|
* @param {DevLoopConfig} config
|
|
1701
1487
|
* @returns {string|null}
|
|
1702
1488
|
*/
|
|
@@ -1709,17 +1495,8 @@ export function resolveConductorModel(config) {
|
|
|
1709
1495
|
}
|
|
1710
1496
|
|
|
1711
1497
|
/**
|
|
1712
|
-
* Resolve the autonomy stop-at list
|
|
1713
|
-
*
|
|
1714
|
-
* Returns the set of gates that require operator confirmation. Gates not in
|
|
1715
|
-
* the returned list may proceed automatically once their review conditions
|
|
1716
|
-
* are satisfied.
|
|
1717
|
-
*
|
|
1718
|
-
* Defaults to `["merge"]` when the config does not specify `autonomy.stopAt`
|
|
1719
|
-
* (the conservative built-in posture: everything auto-continues until merge).
|
|
1720
|
-
*
|
|
1721
|
-
* Accepts the validated DevLoopConfig from {@link loadDevLoopConfig}.
|
|
1722
|
-
*
|
|
1498
|
+
* Resolve the autonomy stop-at list (gates that require operator confirmation)
|
|
1499
|
+
* from the merged config. Defaults to `["merge"]` when unset.
|
|
1723
1500
|
* @param {DevLoopConfig} config
|
|
1724
1501
|
* @returns {string[]}
|
|
1725
1502
|
*/
|
|
@@ -1736,12 +1513,8 @@ export function resolveAutonomyStopAt(config) {
|
|
|
1736
1513
|
}
|
|
1737
1514
|
|
|
1738
1515
|
/**
|
|
1739
|
-
*
|
|
1740
|
-
*
|
|
1741
|
-
* When true, the agent must never perform the merge itself: `gh pr merge` is a
|
|
1742
|
-
* human-only action and any per-run merge authorization is ignored. Defaults to
|
|
1743
|
-
* false (the agent may merge once authorized).
|
|
1744
|
-
*
|
|
1516
|
+
* True when `autonomy.humanMergeOnly` forces merge to be a human-only action
|
|
1517
|
+
* (the agent never merges; per-run authorization is ignored). Defaults false.
|
|
1745
1518
|
* @param {DevLoopConfig} config
|
|
1746
1519
|
* @returns {boolean}
|
|
1747
1520
|
*/
|
|
@@ -1790,11 +1563,7 @@ const DEFAULT_REFINEMENT_CONFIG = BUILT_IN_DEFAULTS.refinement;
|
|
|
1790
1563
|
const DEFAULT_WORKFLOW_CONFIG = BUILT_IN_DEFAULTS.workflow;
|
|
1791
1564
|
|
|
1792
1565
|
/**
|
|
1793
|
-
* Resolve one refinement
|
|
1794
|
-
*
|
|
1795
|
-
* Returns the configured value when present, or the built-in default for the
|
|
1796
|
-
* requested key.
|
|
1797
|
-
*
|
|
1566
|
+
* Resolve one refinement config value, or its built-in default.
|
|
1798
1567
|
* @param {DevLoopConfig} config
|
|
1799
1568
|
* @param {"fanOut"|"mode"|"roles"|"maxCopilotRounds"|"stopOnLowSignal"|"lowSignalRoundThreshold"|"lowSignalMaxComments"} key
|
|
1800
1569
|
* @returns {number|"parallel"|"sequential"|string[]|boolean|null}
|
|
@@ -1834,15 +1603,9 @@ export function resolveRefinementConfig(config, key) {
|
|
|
1834
1603
|
}
|
|
1835
1604
|
|
|
1836
1605
|
/**
|
|
1837
|
-
* Resolve the refinement
|
|
1838
|
-
*
|
|
1839
|
-
*
|
|
1840
|
-
* defaults (`fanOut: 3`, `mode: "parallel"`, `roles: null`,
|
|
1841
|
-
* `maxCopilotRounds: 5`, `stopOnLowSignal: false`, `lowSignalRoundThreshold: 3`,
|
|
1842
|
-
* `lowSignalMaxComments: 2`).
|
|
1843
|
-
*
|
|
1844
|
-
* Accepts the validated DevLoopConfig from {@link loadDevLoopConfig}.
|
|
1845
|
-
*
|
|
1606
|
+
* Resolve the full refinement config with built-in defaults (fanOut 3, mode
|
|
1607
|
+
* parallel, roles null, maxCopilotRounds 5, low-signal off/3/2), plus the
|
|
1608
|
+
* resolved preApproval requireCi.
|
|
1846
1609
|
* @param {DevLoopConfig} config
|
|
1847
1610
|
* @returns {{ fanOut: number, mode: "parallel"|"sequential", roles: string[]|null, maxCopilotRounds: number, stopOnLowSignal: boolean, lowSignalRoundThreshold: number, lowSignalMaxComments: number }}
|
|
1848
1611
|
*/
|
|
@@ -1854,11 +1617,9 @@ export function resolveRefinement(config) {
|
|
|
1854
1617
|
const stopOnLowSignal = /** @type {boolean} */ (resolveRefinementConfig(config, "stopOnLowSignal"));
|
|
1855
1618
|
const lowSignalRoundThreshold = /** @type {number} */ (resolveRefinementConfig(config, "lowSignalRoundThreshold"));
|
|
1856
1619
|
const lowSignalMaxComments = /** @type {number} */ (resolveRefinementConfig(config, "lowSignalMaxComments"));
|
|
1857
|
-
//
|
|
1858
|
-
//
|
|
1859
|
-
//
|
|
1860
|
-
// reliably honors `gates.preApproval.requireCi: false` — otherwise a CI-less
|
|
1861
|
-
// repo would still be interpreted as waiting_for_ci / blocked in those tools.
|
|
1620
|
+
// Centralize the pre-approval CI opt-out here so every caller building
|
|
1621
|
+
// its refinement config from resolveRefinement honors
|
|
1622
|
+
// gates.preApproval.requireCi: false.
|
|
1862
1623
|
const preApprovalRequireCi = resolveGateConfig(config, "preApproval").requireCi;
|
|
1863
1624
|
return { fanOut, mode, roles, maxCopilotRounds, stopOnLowSignal, lowSignalRoundThreshold, lowSignalMaxComments, preApprovalRequireCi };
|
|
1864
1625
|
}
|
|
@@ -1909,65 +1670,40 @@ function resolveBlockingSeverities(config, gate) {
|
|
|
1909
1670
|
}
|
|
1910
1671
|
|
|
1911
1672
|
/**
|
|
1912
|
-
* Resolve one gate configuration object from the merged
|
|
1913
|
-
*
|
|
1914
|
-
* Returns the configured gate angles when present, or null for angles when the
|
|
1915
|
-
* config omits them (caller falls back to skill-defined defaults). Boolean gate
|
|
1916
|
-
* flags always resolve to stable defaults.
|
|
1673
|
+
* Resolve one gate configuration object from the merged config.
|
|
1917
1674
|
*
|
|
1918
1675
|
* The returned shape is the STABLE, resolved view every other angle resolver
|
|
1919
|
-
*
|
|
1920
|
-
*
|
|
1921
|
-
*
|
|
1922
|
-
*
|
|
1923
|
-
*
|
|
1924
|
-
*
|
|
1925
|
-
* a plain, non-mandatory angle to `angles` directly, without restating the
|
|
1926
|
-
* list — the exact ergonomic `extraAngles` used to provide.)
|
|
1676
|
+
* builds on: `mandatoryAngles`/`excludeAngles`/`dynamicAngles`/`additiveAngles`
|
|
1677
|
+
* are derived here from the unified `gates.<gate>.angles` array (`mandatory` /
|
|
1678
|
+
* `enabled: false` per entry) and `gates.<gate>.dynamic`, so downstream
|
|
1679
|
+
* consumers keep reading the flat field names. `angles: null` means the
|
|
1680
|
+
* key was absent (fall back to skill defaults); an empty array is a real
|
|
1681
|
+
* configured "no angles".
|
|
1927
1682
|
*
|
|
1928
1683
|
* @param {DevLoopConfig} config
|
|
1929
1684
|
* @param {"draft"|"preApproval"|"spike"} gate
|
|
1930
1685
|
* @returns {{ angles: string[]|null, excludeAngles: string[], mandatoryAngles: string[], required: boolean, requireCi: boolean, blockCleanOnFindingSeverities: string[], dynamicAngles: boolean, additiveAngles: boolean, mediumFixWindow: number, tiers: Array<{name: string, match: object, angles: string[]}> }}
|
|
1931
|
-
* @throws {Error} when ANY gate's (
|
|
1932
|
-
*
|
|
1933
|
-
*
|
|
1934
|
-
*
|
|
1935
|
-
*
|
|
1936
|
-
*
|
|
1937
|
-
*
|
|
1938
|
-
*
|
|
1939
|
-
* the affected gate block on the wrong severities or on nothing at all.
|
|
1940
|
-
* Validated EAGERLY across all three gates on every call — not lazily,
|
|
1941
|
-
* only for the requested `gate` — so that a single-gate consumer (e.g. a
|
|
1942
|
-
* draft-only fan-in consolidation) can never proceed and produce a
|
|
1943
|
-
* side-effect (write a ledger artifact, flip ready-for-review) while a
|
|
1944
|
-
* DIFFERENT gate's severity list is invalid; that invalid gate would only
|
|
1945
|
-
* have surfaced later, lazily, at a dual-gate call site (e.g. verdict
|
|
1946
|
-
* posting), after the single-gate side effect already happened. This is
|
|
1947
|
-
* the stated boundary with the module's degrade-quietly convention for
|
|
1948
|
-
* dispatch-ergonomics keys (resolveMaxAnglesPerGroup substitutes its
|
|
1949
|
-
* default, resolveFanoutGroups drops malformed entries): those keys only
|
|
1950
|
-
* shape dispatch, so they degrade; the key that decides what blocks a
|
|
1951
|
-
* clean verdict refuses, fail-closed, before any gate proceeds. An ABSENT
|
|
1952
|
-
* key still falls back to the default unchanged.
|
|
1686
|
+
* @throws {Error} when ANY gate's (not only the requested one's) PRESENT
|
|
1687
|
+
* `blockCleanOnFindingSeverities` is schema-invalid (non-array, empty, or an
|
|
1688
|
+
* out-of-vocabulary entry). Validated EAGERLY across all three gates on every
|
|
1689
|
+
* call so a single-gate consumer can never produce a side effect while a
|
|
1690
|
+
* DIFFERENT gate's severity list is invalid. Dispatch-ergonomics keys degrade
|
|
1691
|
+
* quietly instead (resolveMaxAnglesPerGroup/resolveFanoutGroups); the key that
|
|
1692
|
+
* decides what blocks a clean verdict refuses, fail-closed. An ABSENT key
|
|
1693
|
+
* falls back to the default.
|
|
1953
1694
|
*/
|
|
1954
1695
|
export function resolveGateConfig(config, gate) {
|
|
1955
1696
|
const gateConfig = config?.gates?.[gate];
|
|
1956
|
-
// Eagerly validate every gate's blockCleanOnFindingSeverities
|
|
1957
|
-
//
|
|
1958
|
-
// up front, before this call's single-gate result can be used for a
|
|
1959
|
-
// side effect that a later, different-gate call would otherwise still be
|
|
1960
|
-
// able to reach lazily. See the @throws doc above for the reachability
|
|
1961
|
-
// this closes.
|
|
1697
|
+
// Eagerly validate every gate's blockCleanOnFindingSeverities (not just the
|
|
1698
|
+
// requested one) so an invalid list on ANY gate refuses up front. See @throws.
|
|
1962
1699
|
let blockCleanOnFindingSeverities = ["high"];
|
|
1963
1700
|
for (const g of GATE_KEYS_WITH_BLOCKING_SEVERITIES) {
|
|
1964
1701
|
const resolved = resolveBlockingSeverities(config, g);
|
|
1965
1702
|
if (g === gate) blockCleanOnFindingSeverities = resolved;
|
|
1966
1703
|
}
|
|
1967
1704
|
const entries = normalizeAngleEntries(gateConfig?.angles);
|
|
1968
|
-
// An explicitly-empty
|
|
1969
|
-
//
|
|
1970
|
-
// read as "fall back to skill-defined defaults" (angles: null).
|
|
1705
|
+
// An explicitly-empty array is a real configured "no angles" — distinct from
|
|
1706
|
+
// the key being absent (angles: null → fall back to skill defaults).
|
|
1971
1707
|
const hasAngles = Array.isArray(gateConfig?.angles);
|
|
1972
1708
|
return {
|
|
1973
1709
|
angles: hasAngles ? entries.filter((e) => e.enabled !== false).map((e) => e.name) : null,
|
|
@@ -1977,30 +1713,22 @@ export function resolveGateConfig(config, gate) {
|
|
|
1977
1713
|
requireCi: gateConfig?.requireCi ?? true,
|
|
1978
1714
|
dynamicAngles: gateConfig?.dynamic?.subtractive ?? true,
|
|
1979
1715
|
additiveAngles: gateConfig?.dynamic?.additive ?? false,
|
|
1980
|
-
// Normalized + deduped at the resolve boundary
|
|
1981
|
-
//
|
|
1982
|
-
// only; a half-migrated ["must-fix","low","defer"] collapses to two
|
|
1983
|
-
// entries, and anything outside the vocabulary has already thrown.
|
|
1716
|
+
// Normalized + deduped at the resolve boundary so every consumer sees
|
|
1717
|
+
// canonical spellings only (anything outside the vocabulary already threw).
|
|
1984
1718
|
blockCleanOnFindingSeverities,
|
|
1985
|
-
//
|
|
1986
|
-
//
|
|
1987
|
-
// configured window rather than silently reverting to the default.
|
|
1719
|
+
// mediumFixWindow wins; worthFixingNowFixWindow is the deprecated alias,
|
|
1720
|
+
// still honored so an unmigrated config keeps its window.
|
|
1988
1721
|
mediumFixWindow: gateConfig?.mediumFixWindow ?? gateConfig?.worthFixingNowFixWindow ?? 3,
|
|
1989
1722
|
tiers: gateConfig?.tiers ?? [],
|
|
1990
1723
|
};
|
|
1991
1724
|
}
|
|
1992
1725
|
|
|
1993
1726
|
/**
|
|
1994
|
-
* Resolve whether fan-out/fan-in
|
|
1995
|
-
*
|
|
1996
|
-
*
|
|
1997
|
-
*
|
|
1998
|
-
* closed unless a required gate's recorded executionMode is "fanout_fanin" and
|
|
1999
|
-
* a durable findings-log ledger exists for that gate + head SHA. Using a
|
|
2000
|
-
* `!== false` test (rather than `=== true`) keeps the opt-out semantics robust
|
|
2001
|
-
* for programmatically-built config objects that bypass schema defaulting. See
|
|
1727
|
+
* Resolve whether fan-out/fan-in evidence is required for a gate verdict.
|
|
1728
|
+
* Default-on (opt-out): ON unless `gates.requireFanoutEvidence` is false. The
|
|
1729
|
+
* `!== false` test (not `=== true`) keeps the opt-out robust for
|
|
1730
|
+
* programmatically-built configs that bypass schema defaulting. See
|
|
2002
1731
|
* skills/docs/gate-review-sub-loop-contract.md.
|
|
2003
|
-
*
|
|
2004
1732
|
* @param {DevLoopConfig} config
|
|
2005
1733
|
* @returns {boolean}
|
|
2006
1734
|
*/
|
|
@@ -2019,15 +1747,10 @@ export function resolveRequireFanoutEvidence(config) {
|
|
|
2019
1747
|
export const FANOUT_PROVENANCE_MIN_REVIEWERS = 2;
|
|
2020
1748
|
|
|
2021
1749
|
/**
|
|
2022
|
-
* Resolve whether fan-out
|
|
2023
|
-
*
|
|
2024
|
-
*
|
|
2025
|
-
*
|
|
2026
|
-
* `=== true` test so behavior is byte-identical to today unless a repo
|
|
2027
|
-
* explicitly opts in via `gates.requireFanoutProvenance: true`. Layered on top
|
|
2028
|
-
* of fan-out evidence enforcement (see buildFanoutEnforcement). See
|
|
2029
|
-
* skills/docs/gate-review-sub-loop-contract.md.
|
|
2030
|
-
*
|
|
1750
|
+
* Resolve whether fan-out provenance is required for a fanout_fanin verdict.
|
|
1751
|
+
* Default-OFF (opt-in): a strict `=== true` test keeps behavior byte-identical
|
|
1752
|
+
* unless a repo sets `gates.requireFanoutProvenance: true`. Layered on top of
|
|
1753
|
+
* fan-out evidence enforcement. See skills/docs/gate-review-sub-loop-contract.md.
|
|
2031
1754
|
* @param {DevLoopConfig} config
|
|
2032
1755
|
* @returns {boolean}
|
|
2033
1756
|
*/
|
|
@@ -2047,16 +1770,10 @@ export function resolveRejectForeignAngles(config) {
|
|
|
2047
1770
|
}
|
|
2048
1771
|
|
|
2049
1772
|
/**
|
|
2050
|
-
* Resolve whether the consolidated gate
|
|
2051
|
-
*
|
|
2052
|
-
*
|
|
2053
|
-
*
|
|
2054
|
-
* The round's verdict review is already the findings surface
|
|
2055
|
-
* (`GATE-COMMENT-SINGLE-SURFACE`), so this comment is opt-in duplication; the
|
|
2056
|
-
* `=== true` test keeps that opt-in semantics for programmatically-built config
|
|
2057
|
-
* objects that bypass schema defaulting. The disposition ledger is written
|
|
2058
|
-
* regardless. See skills/docs/gate-review-sub-loop-contract.md.
|
|
2059
|
-
*
|
|
1773
|
+
* Resolve whether the consolidated gate findings should ALSO post as a second
|
|
1774
|
+
* marker-tagged PR comment. False unless `gates.postFindingsComments === true`
|
|
1775
|
+
* (opt-in duplication; the verdict review is already the findings surface). The
|
|
1776
|
+
* disposition ledger is written regardless.
|
|
2060
1777
|
* @param {DevLoopConfig} config
|
|
2061
1778
|
* @returns {boolean}
|
|
2062
1779
|
*/
|
|
@@ -2065,11 +1782,8 @@ export function resolveGatePostFindingsComments(config) {
|
|
|
2065
1782
|
}
|
|
2066
1783
|
|
|
2067
1784
|
/**
|
|
2068
|
-
* Resolve local implementation light mode
|
|
2069
|
-
*
|
|
2070
|
-
* Returns null when light mode is disabled (config absent or enabled=false).
|
|
2071
|
-
* Returns { maxFiles, maxLines } when enabled.
|
|
2072
|
-
*
|
|
1785
|
+
* Resolve local implementation light mode: null when disabled (absent or
|
|
1786
|
+
* enabled=false), else { maxFiles, maxLines }.
|
|
2073
1787
|
* @param {DevLoopConfig} config
|
|
2074
1788
|
* @returns {{ maxFiles: number, maxLines: number } | null}
|
|
2075
1789
|
*/
|
|
@@ -2087,11 +1801,9 @@ export function resolveLightMode(config) {
|
|
|
2087
1801
|
}
|
|
2088
1802
|
|
|
2089
1803
|
/**
|
|
2090
|
-
* Resolve the issue-less PR-first any-scope opt-in
|
|
2091
|
-
*
|
|
2092
|
-
*
|
|
2093
|
-
* false, or malformed values resolve to false (fail closed).
|
|
2094
|
-
*
|
|
1804
|
+
* Resolve the issue-less PR-first any-scope opt-in. True only when
|
|
1805
|
+
* `localImplementation.issueless` is exactly `true`; absent/false/malformed
|
|
1806
|
+
* resolve to false (fail closed).
|
|
2095
1807
|
* @param {DevLoopConfig} config
|
|
2096
1808
|
* @returns {boolean}
|
|
2097
1809
|
*/
|
|
@@ -2100,15 +1812,10 @@ export function resolveIssuelessEnabled(config) {
|
|
|
2100
1812
|
}
|
|
2101
1813
|
|
|
2102
1814
|
/**
|
|
2103
|
-
* Resolve the effective Copilot review round cap for a PR
|
|
2104
|
-
*
|
|
2105
|
-
*
|
|
2106
|
-
*
|
|
2107
|
-
* `effective = min(localImplementation.lightMode.maxCopilotRounds ?? 1,
|
|
2108
|
-
* refinement.maxCopilotRounds)` — so setting `refinement.maxCopilotRounds: 0`
|
|
2109
|
-
* disables Copilot rounds everywhere, including lightweight, with that one
|
|
2110
|
-
* setting.
|
|
2111
|
-
*
|
|
1815
|
+
* Resolve the effective Copilot review round cap for a PR. Full PRs use
|
|
1816
|
+
* `refinement.maxCopilotRounds` (default 5); light-dispatched PRs compose as
|
|
1817
|
+
* min(lightMode.maxCopilotRounds ?? 1, refinement.maxCopilotRounds), so
|
|
1818
|
+
* `refinement.maxCopilotRounds: 0` disables Copilot rounds everywhere.
|
|
2112
1819
|
* @param {DevLoopConfig} config
|
|
2113
1820
|
* @param {{ lightweight?: boolean }} [options]
|
|
2114
1821
|
* @returns {number}
|
|
@@ -2130,25 +1837,20 @@ export function resolveEffectiveCopilotRoundCap(config, { lightweight = false }
|
|
|
2130
1837
|
export const GATE_FULL_LABEL = "gate:full";
|
|
2131
1838
|
|
|
2132
1839
|
/**
|
|
2133
|
-
* Decide whether a gate
|
|
2134
|
-
*
|
|
1840
|
+
* Decide whether a gate runs as a single-agent inline check or full fan-out,
|
|
1841
|
+
* from light-mode config + authoritative PR facts.
|
|
2135
1842
|
*
|
|
2136
1843
|
* Precedence (first match wins):
|
|
2137
|
-
* 1. `gate:full` label present → full_fanout
|
|
2138
|
-
* 2. light mode disabled / no threshold → full_fanout
|
|
2139
|
-
* 3. scope over threshold (files OR lines) → full_fanout
|
|
2140
|
-
* 4. inline
|
|
2141
|
-
*
|
|
1844
|
+
* 1. `gate:full` label present → full_fanout
|
|
1845
|
+
* 2. light mode disabled / no threshold → full_fanout
|
|
1846
|
+
* 3. scope over threshold (files OR lines) → full_fanout
|
|
1847
|
+
* 4. inline finding severity in the gate's blockCleanOnFindingSeverities set
|
|
1848
|
+
* → full_fanout (escalated)
|
|
2142
1849
|
* 5. otherwise → inline
|
|
2143
1850
|
*
|
|
2144
|
-
*
|
|
2145
|
-
*
|
|
2146
|
-
*
|
|
2147
|
-
* - escalation: pass the inline pass's finding severities → auto-escalates when
|
|
2148
|
-
* the inline check surfaced anything worth fixing.
|
|
2149
|
-
*
|
|
2150
|
-
* Absent or partial `facts.scope` fails safe to full_fanout (missing
|
|
2151
|
-
* filesChanged/linesChanged are treated as `Infinity` → over threshold).
|
|
1851
|
+
* Pre-check omits `inlineFindingSeverities` (decides whether to run the inline
|
|
1852
|
+
* pass at all); escalation passes the inline pass's severities. Absent/partial
|
|
1853
|
+
* `facts.scope` fails safe to full_fanout (missing counts → Infinity).
|
|
2152
1854
|
*
|
|
2153
1855
|
* @param {DevLoopConfig} config
|
|
2154
1856
|
* @param {"draft"|"preApproval"} gate
|
|
@@ -2183,13 +1885,13 @@ export function resolveGateDispatchMode(config, gate, { scope, hasFullLabel = fa
|
|
|
2183
1885
|
}
|
|
2184
1886
|
|
|
2185
1887
|
/**
|
|
2186
|
-
* Default auto-chunk size for ungrouped angles
|
|
1888
|
+
* Default auto-chunk size for ungrouped angles. Mirrors the
|
|
2187
1889
|
* zod default on `gates.fanout.maxAnglesPerGroup`.
|
|
2188
1890
|
*/
|
|
2189
1891
|
export const DEFAULT_MAX_ANGLES_PER_GROUP = 3;
|
|
2190
1892
|
|
|
2191
1893
|
/**
|
|
2192
|
-
* Default concurrent-dispatch-unit cap per wave
|
|
1894
|
+
* Default concurrent-dispatch-unit cap per wave. Mirrors the
|
|
2193
1895
|
* zod default on `gates.fanout.maxConcurrent`; consumed by
|
|
2194
1896
|
* `scheduleFanoutWaves` (@dev-loops/core/loop/gate-fanin).
|
|
2195
1897
|
*/
|
|
@@ -2197,13 +1899,10 @@ export const DEFAULT_FANOUT_MAX_CONCURRENT = 4;
|
|
|
2197
1899
|
export const DEFAULT_FANOUT_SEQUENTIAL = false;
|
|
2198
1900
|
|
|
2199
1901
|
/**
|
|
2200
|
-
* Resolve `gates.fanout.sequential` (
|
|
2201
|
-
*
|
|
2202
|
-
*
|
|
2203
|
-
*
|
|
2204
|
-
* `maxConcurrent` so a repo may choose either serial (sequential: true) or a
|
|
2205
|
-
* small parallel cap (maxConcurrent: 1-2, sequential: false); the shipped
|
|
2206
|
-
* default stays false for cross-harness non-regression (#1086).
|
|
1902
|
+
* Resolve `gates.fanout.sequential` (default false). Serial one-at-a-time
|
|
1903
|
+
* dispatch of heavy reviewers so each writes its evidence before the next
|
|
1904
|
+
* starts. Separate from `maxConcurrent`. The shipped default stays false for
|
|
1905
|
+
* cross-harness non-regression.
|
|
2207
1906
|
* @param {DevLoopConfig} config
|
|
2208
1907
|
* @returns {boolean}
|
|
2209
1908
|
*/
|
|
@@ -2213,10 +1912,8 @@ export function resolveFanoutSequential(config) {
|
|
|
2213
1912
|
}
|
|
2214
1913
|
|
|
2215
1914
|
/**
|
|
2216
|
-
* Resolve the effective fan-out concurrency (dispatch units per wave)
|
|
2217
|
-
*
|
|
2218
|
-
* unit per wave), else `resolveFanoutMaxConcurrent`. The conductor builds the
|
|
2219
|
-
* wave plan from this effective value (issue #1726).
|
|
1915
|
+
* Resolve the effective fan-out concurrency (dispatch units per wave): 1 when
|
|
1916
|
+
* `gates.fanout.sequential` is set, else `resolveFanoutMaxConcurrent`.
|
|
2220
1917
|
* @param {DevLoopConfig} config
|
|
2221
1918
|
* @returns {number}
|
|
2222
1919
|
*/
|
|
@@ -2226,11 +1923,10 @@ export function resolveFanoutEffectiveConcurrency(config) {
|
|
|
2226
1923
|
}
|
|
2227
1924
|
|
|
2228
1925
|
/**
|
|
2229
|
-
* Resolve `gates.fanout.maxAnglesPerGroup` (
|
|
2230
|
-
*
|
|
2231
|
-
*
|
|
2232
|
-
*
|
|
2233
|
-
* rejected at load time while still returning it) never crashes Phase 2.
|
|
1926
|
+
* Resolve `gates.fanout.maxAnglesPerGroup` (default 3, min 1). Defensive,
|
|
1927
|
+
* independent of zod: a non-integer or sub-1 value falls back to the default so
|
|
1928
|
+
* a malformed raw merged config (which loadDevLoopConfig still returns) never
|
|
1929
|
+
* crashes Phase 2.
|
|
2234
1930
|
* @param {DevLoopConfig} config
|
|
2235
1931
|
* @returns {number}
|
|
2236
1932
|
*/
|
|
@@ -2241,7 +1937,7 @@ export function resolveMaxAnglesPerGroup(config) {
|
|
|
2241
1937
|
}
|
|
2242
1938
|
|
|
2243
1939
|
/**
|
|
2244
|
-
* Resolve `gates.fanout.maxConcurrent` (
|
|
1940
|
+
* Resolve `gates.fanout.maxConcurrent` (default 4, min 1). The
|
|
2245
1941
|
* max dispatch units (groups) the conductor dispatches concurrently per wave.
|
|
2246
1942
|
* Defensive, independent of zod (same rationale as resolveMaxAnglesPerGroup).
|
|
2247
1943
|
* @param {DevLoopConfig} config
|
|
@@ -2254,61 +1950,33 @@ export function resolveFanoutMaxConcurrent(config) {
|
|
|
2254
1950
|
}
|
|
2255
1951
|
|
|
2256
1952
|
/**
|
|
2257
|
-
* Resolve grouped fan-out dispatch
|
|
2258
|
-
*
|
|
2259
|
-
* dispatches.
|
|
1953
|
+
* Resolve grouped fan-out dispatch: map a round's resolved angles onto the
|
|
1954
|
+
* dispatch units it dispatches.
|
|
2260
1955
|
*
|
|
2261
|
-
*
|
|
1956
|
+
* Precedence (first match wins):
|
|
2262
1957
|
* 1. `gates.fanout.mode === "per-angle"` → bypasses configured groups; one
|
|
2263
|
-
* singleton unit per angle (
|
|
2264
|
-
*
|
|
2265
|
-
* 2.
|
|
2266
|
-
*
|
|
2267
|
-
*
|
|
2268
|
-
*
|
|
2269
|
-
*
|
|
2270
|
-
*
|
|
2271
|
-
*
|
|
2272
|
-
*
|
|
2273
|
-
*
|
|
2274
|
-
*
|
|
2275
|
-
*
|
|
2276
|
-
*
|
|
2277
|
-
*
|
|
2278
|
-
*
|
|
2279
|
-
* `resolvedAngles` this round — an unmatched group is dropped, never emitted
|
|
2280
|
-
* empty. Configured groups are NEVER split by `maxAnglesPerGroup` (the knob
|
|
2281
|
-
* chunks only the leftover ungrouped pool). Each reviewer still writes ONE
|
|
2282
|
-
* artifact per angle at the existing per-angle paths; grouping only changes how
|
|
2283
|
-
* many reviewers are dispatched, not the artifact shape (see
|
|
2284
|
-
* skills/docs/gate-review-sub-loop-contract.md).
|
|
2285
|
-
*
|
|
2286
|
-
* Auto-chunk unit names are deterministic and stable (issue #1601): a
|
|
2287
|
-
* single-angle leftover chunk is named by its angle (collisions with an emitted
|
|
2288
|
-
* group name disambiguated to `angle:<name>`, preserving the pre-#1601
|
|
2289
|
-
* singleton convention); a multi-angle chunk is named `group:<a>+<b>+<c>` from
|
|
2290
|
-
* its deterministically-ordered members. Unit names key reviewer-sentinel
|
|
2291
|
-
* scopes and provenance `group`, so they must be unique — a chunk whose base
|
|
2292
|
-
* name still collides gets a `#2`/`#3`/… suffix.
|
|
2293
|
-
*
|
|
2294
|
-
* Defensive, independent of zod: `loadDevLoopConfig` returns the raw merged
|
|
2295
|
-
* config even when schema validation fails (on ANY layer, not necessarily
|
|
2296
|
-
* `gates.fanout` itself), so a malformed `gates.fanout.groups` entry can
|
|
2297
|
-
* reach here. A non-object entry, a non-array/blank `angles`, or a
|
|
2298
|
-
* blank/duplicate `name` is dropped (its angles fall through to the leftover
|
|
2299
|
-
* auto-chunk pool) rather than thrown — mirroring the sibling
|
|
2300
|
-
* `normalizeAngleEntries` convention: this resolver degrades to a smaller
|
|
2301
|
-
* grouping table, never crashes the conductor's Phase 2 planning.
|
|
2302
|
-
* `resolvedAngles` is deduplicated up front so a duplicated entry (e.g. a
|
|
2303
|
-
* hand-built `--angles` list) never mints two dispatch units sharing one name.
|
|
1958
|
+
* singleton unit per angle (NOT equivalent to maxAnglesPerGroup: 1 when
|
|
1959
|
+
* configured groups match)
|
|
1960
|
+
* 2. default `grouped` → configured `gates.fanout.groups` match first, then
|
|
1961
|
+
* leftover ungrouped angles auto-chunk into units of ≤ `maxAnglesPerGroup`
|
|
1962
|
+
*
|
|
1963
|
+
* `gate:full` (`options.fullLabel`) does NOT restore per-angle dispatch: it
|
|
1964
|
+
* forces the full angle set upstream (resolveGateTier) and dispatches GROUPED
|
|
1965
|
+
* here (ADR 0048); `fullLabel` is a no-op, accepted for API stability.
|
|
1966
|
+
* Configured groups are NEVER split by `maxAnglesPerGroup` (only the leftover
|
|
1967
|
+
* pool is chunked). An unmatched group is dropped, never emitted empty.
|
|
1968
|
+
*
|
|
1969
|
+
* Defensive, independent of zod: a malformed `gates.fanout.groups` entry (from
|
|
1970
|
+
* a raw merged config that failed validation on any layer) is dropped (its
|
|
1971
|
+
* angles fall to the leftover pool), never thrown — this resolver degrades to a
|
|
1972
|
+
* smaller grouping table rather than crash Phase 2. `resolvedAngles` is
|
|
1973
|
+
* deduplicated up front so a duplicate never mints two units sharing one name.
|
|
2304
1974
|
*
|
|
2305
1975
|
* @param {DevLoopConfig} config
|
|
2306
1976
|
* @param {"draft"|"preApproval"|"spike"} gate unused today — fan-out grouping
|
|
2307
|
-
* is a global policy
|
|
2308
|
-
* with the other `resolveGate*(config, gate, ...)` resolvers.
|
|
1977
|
+
* is a global policy, accepted for symmetry with the other resolvers.
|
|
2309
1978
|
* @param {string[]} resolvedAngles this round's resolved angle names
|
|
2310
|
-
* @param {{ fullLabel?: boolean }} [options] — retained
|
|
2311
|
-
* no longer changes the dispatch shape (see `gate:full` note above).
|
|
1979
|
+
* @param {{ fullLabel?: boolean }} [options] — retained no-op (see above).
|
|
2312
1980
|
* @returns {{ name: string, angles: string[] }[]}
|
|
2313
1981
|
*/
|
|
2314
1982
|
export function resolveFanoutGroups(config, gate, resolvedAngles, { fullLabel = false } = {}) {
|
|
@@ -2316,9 +1984,8 @@ export function resolveFanoutGroups(config, gate, resolvedAngles, { fullLabel =
|
|
|
2316
1984
|
? [...new Set(resolvedAngles.filter((a) => typeof a === "string" && a.trim().length > 0).map((a) => a.trim()))]
|
|
2317
1985
|
: [];
|
|
2318
1986
|
const perAngleGroups = () => angles.map((name) => ({ name, angles: [name] }));
|
|
2319
|
-
// per-angle: bypass configured groups
|
|
2320
|
-
//
|
|
2321
|
-
// takes this branch (ADR 0047 superseded by 0048): fullLabel is a no-op here.
|
|
1987
|
+
// per-angle: bypass configured groups, one singleton unit per angle. gate:full
|
|
1988
|
+
// does not take this branch (ADR 0048): fullLabel is a no-op here.
|
|
2322
1989
|
const fanout = config?.gates?.fanout ?? {};
|
|
2323
1990
|
if (fanout.mode === "per-angle") return perAngleGroups();
|
|
2324
1991
|
const angleSet = new Set(angles);
|
|
@@ -2344,10 +2011,9 @@ export function resolveFanoutGroups(config, gate, resolvedAngles, { fullLabel =
|
|
|
2344
2011
|
for (const a of members) grouped.add(a);
|
|
2345
2012
|
result.push({ name: group.name, angles: members });
|
|
2346
2013
|
}
|
|
2347
|
-
//
|
|
2348
|
-
//
|
|
2349
|
-
//
|
|
2350
|
-
// chunked). Deterministic order (input order) + stable unit names.
|
|
2014
|
+
// Leftover ungrouped angles auto-chunk into units of ≤ maxAnglesPerGroup;
|
|
2015
|
+
// configured groups (matched above) are never split by this knob.
|
|
2016
|
+
// Deterministic input order + stable unit names.
|
|
2351
2017
|
const usedNames = new Set(result.map((g) => g.name));
|
|
2352
2018
|
const leftover = angles.filter((name) => !grouped.has(name));
|
|
2353
2019
|
const maxAnglesPerGroup = resolveMaxAnglesPerGroup(config);
|
|
@@ -2361,12 +2027,11 @@ export function resolveFanoutGroups(config, gate, resolvedAngles, { fullLabel =
|
|
|
2361
2027
|
}
|
|
2362
2028
|
|
|
2363
2029
|
/**
|
|
2364
|
-
* Deterministic, stable dispatch-unit name for an auto-chunked leftover
|
|
2365
|
-
*
|
|
2366
|
-
*
|
|
2367
|
-
* with
|
|
2368
|
-
*
|
|
2369
|
-
* `#N` suffix when even that base collides. Pure.
|
|
2030
|
+
* Deterministic, stable dispatch-unit name for an auto-chunked leftover unit.
|
|
2031
|
+
* A single-angle chunk uses the angle name (disambiguated to `angle:<name>` on
|
|
2032
|
+
* collision); a multi-angle chunk is `group:<a>+<b>+<c>` from its ordered
|
|
2033
|
+
* members (with a `#N` suffix on collision). Names key reviewer-sentinel scopes
|
|
2034
|
+
* and provenance, so they must be unique. Pure.
|
|
2370
2035
|
* @param {string[]} chunk — non-empty, deterministically ordered
|
|
2371
2036
|
* @param {Set<string>} usedNames — already-emitted unit names (mutated by caller)
|
|
2372
2037
|
* @returns {string}
|
|
@@ -2384,16 +2049,10 @@ function stableAutoChunkUnitName(chunk, usedNames) {
|
|
|
2384
2049
|
}
|
|
2385
2050
|
|
|
2386
2051
|
/**
|
|
2387
|
-
* Resolve review angles for a
|
|
2388
|
-
*
|
|
2389
|
-
*
|
|
2390
|
-
*
|
|
2391
|
-
* (`enabled: false`): `mandatoryAngles ∪ angles − disabled`, deduplicated (a
|
|
2392
|
-
* mandatory angle also present in `angles` is a no-op — it appears exactly
|
|
2393
|
-
* once and keeps its mandatory status). Returns null only when the gate has
|
|
2394
|
-
* no configured `angles` at all (caller falls back to skill-defined
|
|
2395
|
-
* defaults); an explicitly-empty `angles: []` returns `[]`.
|
|
2396
|
-
*
|
|
2052
|
+
* Resolve review angles for a gate: `mandatoryAngles ∪ angles − disabled`,
|
|
2053
|
+
* deduplicated. Returns null when the gate has no configured `angles` at all
|
|
2054
|
+
* (caller falls back to skill defaults); an explicitly-empty `angles: []`
|
|
2055
|
+
* returns `[]`.
|
|
2397
2056
|
* @param {DevLoopConfig} config
|
|
2398
2057
|
* @param {"draft"|"preApproval"} gate
|
|
2399
2058
|
* @returns {string[]|null}
|
|
@@ -2401,27 +2060,20 @@ function stableAutoChunkUnitName(chunk, usedNames) {
|
|
|
2401
2060
|
export function resolveGateAngles(config, gate) {
|
|
2402
2061
|
const gateConfig = resolveGateConfig(config, gate);
|
|
2403
2062
|
if (gateConfig.angles === null && gateConfig.mandatoryAngles.length === 0) return null;
|
|
2404
|
-
// gateConfig.angles is already exclude-filtered
|
|
2405
|
-
//
|
|
2406
|
-
//
|
|
2407
|
-
// sets excludeAngles/angles independently rather than through the
|
|
2408
|
-
// gates.<gate>.angles[].enabled shape.
|
|
2063
|
+
// gateConfig.angles is already exclude-filtered; the excludeAngles filter
|
|
2064
|
+
// below is a defensive no-op for hand-built config objects that set
|
|
2065
|
+
// excludeAngles/angles independently.
|
|
2409
2066
|
const excluded = new Set(gateConfig.excludeAngles);
|
|
2410
2067
|
const merged = [...new Set([...gateConfig.mandatoryAngles, ...(gateConfig.angles ?? [])])];
|
|
2411
2068
|
return merged.filter(a => !excluded.has(a));
|
|
2412
2069
|
}
|
|
2413
2070
|
|
|
2414
2071
|
/**
|
|
2415
|
-
* Resolve the global lens catalog
|
|
2416
|
-
*
|
|
2417
|
-
*
|
|
2418
|
-
*
|
|
2419
|
-
*
|
|
2420
|
-
* angle actually configured across this config's own draft/preApproval/spike
|
|
2421
|
-
* gates (angles + mandatoryAngles). The persona registry alone omits angles
|
|
2422
|
-
* that ship in extension-defaults.yaml gate pools but have no dedicated
|
|
2423
|
-
* persona (e.g. ci-guard, link-check) — see #1048.
|
|
2424
|
-
*
|
|
2072
|
+
* Resolve the global lens catalog for additive angle selection: the explicit
|
|
2073
|
+
* `gates.anglePool` override when configured, else the union of the persona
|
|
2074
|
+
* registry's angle names and every angle configured across this config's own
|
|
2075
|
+
* gates. The persona registry alone omits pool angles with no dedicated
|
|
2076
|
+
* persona (e.g. ci-guard, link-check).
|
|
2425
2077
|
* @param {DevLoopConfig} config
|
|
2426
2078
|
* @returns {string[]}
|
|
2427
2079
|
*/
|
|
@@ -2440,18 +2092,13 @@ export function resolveAnglePool(config) {
|
|
|
2440
2092
|
/**
|
|
2441
2093
|
* Resolve a gate's ANGLE ENFORCEMENT CONTRACT: the mandatory angles a
|
|
2442
2094
|
* fanout_fanin verdict must cover and the pool its recorded angles must stay
|
|
2443
|
-
* within. Single source of truth for
|
|
2444
|
-
* (ledger write, verdict-comment write, merge-evidence read) so they agree.
|
|
2445
|
-
*
|
|
2446
|
-
* - `mandatoryAngles` is filtered through `excludeAngles`: a config that
|
|
2447
|
-
* excludes a mandatory angle must not deadlock every fanout write (the
|
|
2448
|
-
* angle would be missing-mandatory if omitted yet foreign if recorded).
|
|
2449
|
-
* - `pool` is `resolveGateAngles` (configured angles ∪ mandatoryAngles, minus
|
|
2450
|
-
* excludeAngles); when `additiveAngles` is enabled it widens to the global
|
|
2451
|
-
* lens catalog (`resolveAnglePool`) too — dynamic resolution may
|
|
2452
|
-
* legitimately dispatch catalog angles then — with `excludeAngles` still a
|
|
2453
|
-
* hard ceiling. A null pool skips the foreign-angle check entirely.
|
|
2095
|
+
* within. Single source of truth for every angle-coverage consumer.
|
|
2454
2096
|
*
|
|
2097
|
+
* `mandatoryAngles` is filtered through `excludeAngles` so excluding a
|
|
2098
|
+
* mandatory angle cannot deadlock every fanout write (missing-mandatory if
|
|
2099
|
+
* omitted, foreign if recorded). `pool` is resolveGateAngles, widened to the
|
|
2100
|
+
* global catalog (resolveAnglePool) when `additiveAngles` is on, with
|
|
2101
|
+
* excludeAngles still a hard ceiling; a null pool skips the foreign check.
|
|
2455
2102
|
* @param {DevLoopConfig} config
|
|
2456
2103
|
* @param {"draft"|"preApproval"|"spike"} gate
|
|
2457
2104
|
* @returns {{ mandatoryAngles: string[], pool: string[]|null }}
|
|
@@ -2468,21 +2115,17 @@ export function resolveGateAngleContract(config, gate) {
|
|
|
2468
2115
|
}
|
|
2469
2116
|
|
|
2470
2117
|
/**
|
|
2471
|
-
* Resolve the diff-class angle tier for a gate from its
|
|
2472
|
-
* `gates.<gate>.tiers`
|
|
2473
|
-
*
|
|
2474
|
-
*
|
|
2475
|
-
* reduction runs.
|
|
2118
|
+
* Resolve the diff-class angle tier for a gate from its ordered
|
|
2119
|
+
* `gates.<gate>.tiers` (first-match-wins). Pure; the single source of truth for
|
|
2120
|
+
* tier selection, consulted at the top of resolveGateAnglesDynamic before any
|
|
2121
|
+
* dynamic reduction.
|
|
2476
2122
|
*
|
|
2477
|
-
* FAIL CLOSED at every uncertain step:
|
|
2478
|
-
*
|
|
2479
|
-
*
|
|
2480
|
-
*
|
|
2481
|
-
*
|
|
2482
|
-
*
|
|
2483
|
-
* outside a non-null pool voids the whole match (no partial intersection): a
|
|
2484
|
-
* typo'd tier angle is caught here, not by silently dropping reviewers at
|
|
2485
|
-
* gate time.
|
|
2123
|
+
* FAIL CLOSED at every uncertain step: `gate:full`, no tiers, an
|
|
2124
|
+
* unavailable/malformed scope, a changed dev-loop config-source file, or an
|
|
2125
|
+
* unclassifiable file all resolve to `tier: null`. A matched tier's angle set
|
|
2126
|
+
* is validated against the gate's pool — ANY tier angle outside a non-null pool
|
|
2127
|
+
* voids the WHOLE match (a typo'd tier angle is caught here, not by silently
|
|
2128
|
+
* dropping reviewers at gate time).
|
|
2486
2129
|
*
|
|
2487
2130
|
* @param {DevLoopConfig} config
|
|
2488
2131
|
* @param {"draft"|"preApproval"|"spike"} gate
|
|
@@ -2532,28 +2175,17 @@ export function resolveGateTier(config, gate, { changedFiles, filesChanged, line
|
|
|
2532
2175
|
}
|
|
2533
2176
|
|
|
2534
2177
|
/**
|
|
2535
|
-
* Resolve gate angles dynamically when `dynamicAngles` is enabled
|
|
2178
|
+
* Resolve gate angles dynamically when `dynamicAngles` is enabled.
|
|
2536
2179
|
*
|
|
2537
|
-
*
|
|
2538
|
-
*
|
|
2180
|
+
* Diff analysis (../analysis/*) filters the configured angle list to angles
|
|
2181
|
+
* relevant to the change set. When `dynamic.subtractive: false` or no
|
|
2182
|
+
* diff is given, returns the full configured list. When `additiveAngles` is on,
|
|
2183
|
+
* catalog angles from resolveAnglePool may also be added, with
|
|
2184
|
+
* `excludeAngles` a hard ceiling.
|
|
2539
2185
|
*
|
|
2540
|
-
*
|
|
2541
|
-
*
|
|
2542
|
-
*
|
|
2543
|
-
*
|
|
2544
|
-
* When `additiveAngles` is also enabled (default off, see #1048), catalog
|
|
2545
|
-
* angles from `resolveAnglePool()` (`gates.anglePool`, or else the union of
|
|
2546
|
-
* the persona registry and this config's own configured angles) recommended
|
|
2547
|
-
* by change-category heuristics but absent from the gate's configured pool
|
|
2548
|
-
* may also be added; `excludeAngles` remains a hard ceiling on additions.
|
|
2549
|
-
*
|
|
2550
|
-
* Diff-class angle tiers (`gates.<gate>.tiers`, see `resolveGateTier`) are
|
|
2551
|
-
* consulted FIRST, ahead of any subtractive/additive reduction below: when the
|
|
2552
|
-
* diff's changed-file scope matches a configured tier, that tier's angle set
|
|
2553
|
-
* (unioned with mandatory angles) is returned directly and the
|
|
2554
|
-
* subtractive/additive machinery below is skipped entirely. No tier match
|
|
2555
|
-
* (including "no tiers configured") falls through to the existing behavior
|
|
2556
|
-
* unchanged.
|
|
2186
|
+
* Diff-class tiers (resolveGateTier) are consulted FIRST: a tier match returns
|
|
2187
|
+
* that tier's angle set (unioned with mandatory) directly and skips the
|
|
2188
|
+
* subtractive/additive machinery.
|
|
2557
2189
|
*
|
|
2558
2190
|
* @param {import("./types.js").DevLoopConfig} config
|
|
2559
2191
|
* @param {"draft"|"preApproval"} gate
|
|
@@ -2563,10 +2195,9 @@ export function resolveGateTier(config, gate, { changedFiles, filesChanged, line
|
|
|
2563
2195
|
* @returns {{ recommendedAngles: string[] | null, skippedAngles: string[], reasons: Record<string,string>, fallbackToAll: boolean, dynamicAnglesActive: boolean, addedAngles: string[], addedReasons: Record<string,string> }}
|
|
2564
2196
|
*/
|
|
2565
2197
|
export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabel = false } = {}) {
|
|
2566
|
-
// Tier scope facts: changedFiles/filesChanged from T0
|
|
2567
|
-
//
|
|
2568
|
-
//
|
|
2569
|
-
// count for an unambiguous (e.g. docs-only) diff — see analyzeT1/analyzeDiff.
|
|
2198
|
+
// Tier scope facts: changedFiles/filesChanged from T0, linesChanged from T1's
|
|
2199
|
+
// real added+deleted count (analyzeDiff's inferred-category path reports a
|
|
2200
|
+
// fake 0 for an unambiguous docs-only diff — see analyzeT1/analyzeDiff).
|
|
2570
2201
|
let changedFiles;
|
|
2571
2202
|
let filesChanged;
|
|
2572
2203
|
let linesChanged;
|
|
@@ -2576,7 +2207,7 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
|
|
|
2576
2207
|
const t0 = analyzeT0(diff.nameStatusOutput);
|
|
2577
2208
|
changedFiles = t0.files;
|
|
2578
2209
|
filesChanged = changedFiles.length;
|
|
2579
|
-
prosePresent = t0.prosePresent; //
|
|
2210
|
+
prosePresent = t0.prosePresent; // gate deslop on the prose surface
|
|
2580
2211
|
if (diff.diffOutput) {
|
|
2581
2212
|
const lineStats = analyzeT1(diff.diffOutput, t0).lineStats;
|
|
2582
2213
|
linesChanged = lineStats.added + lineStats.deleted;
|
|
@@ -2586,11 +2217,9 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
|
|
|
2586
2217
|
if (tierResult.tier) {
|
|
2587
2218
|
const configuredAngles = resolveGateAngles(config, gate) ?? [];
|
|
2588
2219
|
let recommendedAngles = tierResult.angles;
|
|
2589
|
-
//
|
|
2590
|
-
//
|
|
2591
|
-
//
|
|
2592
|
-
// (skills/docs/**). Strip deslop when the diff touches no prose surface so
|
|
2593
|
-
// exemption holds even through the tier path.
|
|
2220
|
+
// deslop is a prose-only angle. A docs-kind tier keeps it for prose
|
|
2221
|
+
// diffs, but that kind also matches exempt normative contracts
|
|
2222
|
+
// (skills/docs/**), so strip deslop when the diff touches no prose surface.
|
|
2594
2223
|
if (recommendedAngles.includes("deslop") && prosePresent === false) {
|
|
2595
2224
|
recommendedAngles = recommendedAngles.filter((a) => a !== "deslop");
|
|
2596
2225
|
}
|
|
@@ -2654,13 +2283,12 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
|
|
|
2654
2283
|
});
|
|
2655
2284
|
|
|
2656
2285
|
// Merge: mandatory always included (filtered by excludeAngles) + dynamically-selected
|
|
2657
|
-
// candidates + additively-selected catalog angles
|
|
2286
|
+
// candidates + additively-selected catalog angles
|
|
2658
2287
|
const filteredMandatory = gateConfig.mandatoryAngles.filter(a => !excluded.has(a));
|
|
2659
2288
|
|
|
2660
|
-
// An angle
|
|
2661
|
-
//
|
|
2662
|
-
//
|
|
2663
|
-
// which already owns the mandatory Set) filters its output.
|
|
2289
|
+
// An angle both mandatory AND additively recommended stays attributed to the
|
|
2290
|
+
// mandatory floor, not reported as "added" (the resolver has no concept of
|
|
2291
|
+
// mandatory, so this caller filters its output).
|
|
2664
2292
|
const addedAngles = (dynamicResult.addedAngles ?? []).filter(a => !mandatory.has(a));
|
|
2665
2293
|
const addedReasons = Object.fromEntries(
|
|
2666
2294
|
Object.entries(dynamicResult.addedReasons ?? {}).filter(([a]) => !mandatory.has(a))
|
|
@@ -2680,11 +2308,7 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
|
|
|
2680
2308
|
}
|
|
2681
2309
|
|
|
2682
2310
|
/**
|
|
2683
|
-
* Resolve one workflow
|
|
2684
|
-
*
|
|
2685
|
-
* Returns the configured workflow value when present, or the built-in default
|
|
2686
|
-
* for the requested key.
|
|
2687
|
-
*
|
|
2311
|
+
* Resolve one workflow config value, or its built-in default.
|
|
2688
2312
|
* @param {DevLoopConfig} config
|
|
2689
2313
|
* @param {"asyncStartMode"|"requireRetrospective"|"requireDraftFirst"|"devModeDefault"} key
|
|
2690
2314
|
* @returns {string|boolean}
|
|
@@ -2728,9 +2352,8 @@ function tryGit(args, cwd) {
|
|
|
2728
2352
|
}
|
|
2729
2353
|
}
|
|
2730
2354
|
|
|
2731
|
-
// Last-resort literal when git auto-detection
|
|
2732
|
-
//
|
|
2733
|
-
// "origin/main" call site already assumed.
|
|
2355
|
+
// Last-resort literal when git auto-detection resolves nothing (e.g. no git
|
|
2356
|
+
// repo at cwd).
|
|
2734
2357
|
const AUTO_DETECT_BASE_BRANCH_FALLBACK = "main";
|
|
2735
2358
|
|
|
2736
2359
|
/**
|
|
@@ -2755,18 +2378,12 @@ function autoDetectDefaultBranch(cwd) {
|
|
|
2755
2378
|
}
|
|
2756
2379
|
|
|
2757
2380
|
/**
|
|
2758
|
-
* Resolve the effective base/integration branch (bare name
|
|
2759
|
-
* `origin/`-prefixed) for worktree creation, PR targeting, and merge-base
|
|
2760
|
-
*
|
|
2761
|
-
*
|
|
2762
|
-
*
|
|
2763
|
-
*
|
|
2764
|
-
* falls back to the existing auto-detect: the remote's advertised default
|
|
2765
|
-
* branch (`origin/HEAD`), else `main`/`master`, else the literal "main".
|
|
2766
|
-
* Never throws.
|
|
2767
|
-
*
|
|
2768
|
-
* Callers own the `origin/` prefix: worktree creation prepends it (a remote
|
|
2769
|
-
* ref), gh/PR base flags pass the bare name straight through.
|
|
2381
|
+
* Resolve the effective base/integration branch (bare name, never
|
|
2382
|
+
* `origin/`-prefixed) for worktree creation, PR targeting, and merge-base scope
|
|
2383
|
+
* `workflow.baseBranch` is the authoritative override; unset/malformed/
|
|
2384
|
+
* empty falls back to auto-detect (origin/HEAD, else main/master, else "main").
|
|
2385
|
+
* Never throws. Callers own the `origin/` prefix (worktree creation prepends it;
|
|
2386
|
+
* gh/PR base passes the bare name through).
|
|
2770
2387
|
*
|
|
2771
2388
|
* @param {DevLoopConfig|null|undefined} config
|
|
2772
2389
|
* @param {{ cwd?: string }} [options]
|
|
@@ -2798,14 +2415,10 @@ export function normalizeToBareBranch(value) {
|
|
|
2798
2415
|
}
|
|
2799
2416
|
|
|
2800
2417
|
/**
|
|
2801
|
-
* Resolve the worktree lifecycle config
|
|
2802
|
-
*
|
|
2803
|
-
*
|
|
2804
|
-
*
|
|
2805
|
-
* empty. Entries are trimmed, repo-relative literal paths or glob patterns
|
|
2806
|
-
* expanded against the main checkout at provision time. See
|
|
2807
|
-
* scripts/loop/provision-worktree.mjs.
|
|
2808
|
-
*
|
|
2418
|
+
* Resolve the worktree lifecycle config into `{ copyOnInit, linkOnInit }` (split
|
|
2419
|
+
* by each entry's `mode`), empty-array defaults when `worktree.entries` is
|
|
2420
|
+
* absent/empty. Paths are trimmed, repo-relative literals or globs expanded
|
|
2421
|
+
* against the main checkout at provision time (scripts/loop/provision-worktree.mjs).
|
|
2809
2422
|
* @param {DevLoopConfig} config
|
|
2810
2423
|
* @returns {{ copyOnInit: string[], linkOnInit: string[] }}
|
|
2811
2424
|
*/
|
|
@@ -2820,27 +2433,21 @@ export function resolveWorktreeConfig(config) {
|
|
|
2820
2433
|
}
|
|
2821
2434
|
|
|
2822
2435
|
/**
|
|
2823
|
-
* Default destructive-migration signal: SQL
|
|
2824
|
-
*
|
|
2825
|
-
*
|
|
2826
|
-
*
|
|
2827
|
-
*
|
|
2828
|
-
*
|
|
2829
|
-
* `uiReview.run.migrate.destructivePattern` to match its own status format (or
|
|
2830
|
-
* emit the destructive SQL/marker from `statusCommand`).
|
|
2436
|
+
* Default destructive-migration signal: SQL that drops or wipes data, matched
|
|
2437
|
+
* (case-insensitive, per line) against the migration STATUS OUTPUT. Only detects
|
|
2438
|
+
* destructive intent when the status output is itself SQL-bearing; against a
|
|
2439
|
+
* status output of migration ids/filenames it matches nothing and the guard is
|
|
2440
|
+
* inert, so such a project MUST override `uiReview.run.migrate.destructivePattern`
|
|
2441
|
+
* (or emit the SQL/marker from `statusCommand`).
|
|
2831
2442
|
*/
|
|
2832
2443
|
export const DEFAULT_DESTRUCTIVE_MIGRATION_PATTERN =
|
|
2833
2444
|
"\\b(DROP\\s+(TABLE|COLUMN|DATABASE|SCHEMA)|TRUNCATE|DELETE\\s+FROM|ALTER\\s+TABLE\\s+.*\\bDROP\\b)";
|
|
2834
2445
|
|
|
2835
2446
|
/**
|
|
2836
|
-
* Resolve the ui-review provision+boot run recipe
|
|
2837
|
-
*
|
|
2838
|
-
*
|
|
2839
|
-
*
|
|
2840
|
-
* probe bounds fall back to sane defaults defensively: zod `.partial()` is
|
|
2841
|
-
* shallow (it does not drop nested numeric defaults), so a schema-validated
|
|
2842
|
-
* config already carries them — the fallback covers programmatically-built
|
|
2843
|
-
* config objects that bypass schema defaulting, not the `.partial()` path.
|
|
2447
|
+
* Resolve the ui-review provision+boot run recipe. Returns null when no
|
|
2448
|
+
* `uiReview.run.command` is declared — a stated stop reason (no app is ever
|
|
2449
|
+
* guessed). Numeric probe bounds fall back to defaults defensively for
|
|
2450
|
+
* programmatically-built config objects that bypass schema defaulting.
|
|
2844
2451
|
*
|
|
2845
2452
|
* @param {DevLoopConfig} config
|
|
2846
2453
|
* @returns {null | { command: string, readyUrl: string, readyTimeoutMs: number,
|
|
@@ -2915,12 +2522,10 @@ export const DEFAULT_SERVER_LOG_EXCEPTION_PATTERN =
|
|
|
2915
2522
|
"\\b(5\\d{2}\\b|Internal Server Error|Unhandled|Uncaught|Traceback|Exception|FATAL|\\bERROR\\b)";
|
|
2916
2523
|
|
|
2917
2524
|
/**
|
|
2918
|
-
* Resolve the ui-review drive recipe (Stage 2)
|
|
2919
|
-
*
|
|
2920
|
-
*
|
|
2921
|
-
*
|
|
2922
|
-
* The server-log exception pattern falls back to the shipped heuristic default
|
|
2923
|
-
* when a `serverLogPath` is set without an explicit pattern.
|
|
2525
|
+
* Resolve the ui-review drive recipe (Stage 2). Returns null when no
|
|
2526
|
+
* `uiReview.login` is declared — a stated stop reason (it cannot authenticate,
|
|
2527
|
+
* so it drives nothing). The server-log exception pattern falls back to the
|
|
2528
|
+
* shipped heuristic default when a `serverLogPath` is set without one.
|
|
2924
2529
|
*
|
|
2925
2530
|
* @param {DevLoopConfig} config
|
|
2926
2531
|
* @returns {null | { login: object, interstitials: object[], flows: object[],
|
|
@@ -2957,14 +2562,10 @@ export function resolveUiReviewDriveRecipe(config) {
|
|
|
2957
2562
|
}
|
|
2958
2563
|
|
|
2959
2564
|
/**
|
|
2960
|
-
* Resolve the human-handoff config
|
|
2961
|
-
*
|
|
2962
|
-
*
|
|
2963
|
-
*
|
|
2964
|
-
* disabled (default), this is a no-op: callers must not source candidates or
|
|
2965
|
-
* assign anyone. Pairs with `autonomy.humanMergeOnly`: when human-merge is
|
|
2966
|
-
* enforced, this names who should take the merge.
|
|
2967
|
-
*
|
|
2565
|
+
* Resolve the human-handoff config into a normalized
|
|
2566
|
+
* `{ enabled, candidatesFrom, assignees }`. Disabled with empty arrays when
|
|
2567
|
+
* `approval` is absent; when disabled (default) callers must not source or
|
|
2568
|
+
* assign anyone. Pairs with `autonomy.humanMergeOnly`.
|
|
2968
2569
|
* @param {DevLoopConfig} config
|
|
2969
2570
|
* @returns {{ enabled: boolean, candidatesFrom: ("codeowners"|"recent-committers")[], assignees: string[] }}
|
|
2970
2571
|
*/
|
|
@@ -2992,7 +2593,7 @@ export function resolveHumanHandoffConfig(config) {
|
|
|
2992
2593
|
}
|
|
2993
2594
|
|
|
2994
2595
|
/**
|
|
2995
|
-
* Resolve the tracker provider registry key
|
|
2596
|
+
* Resolve the tracker provider registry key. Defaults to
|
|
2996
2597
|
* `"github"` — the only built-in provider in v1 — when unset. Callers pass
|
|
2997
2598
|
* this to `resolveTrackerAdapter` (`@dev-loops/core/tracker`).
|
|
2998
2599
|
*
|