@dev-loops/core 1.0.1 → 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.
@@ -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 families (their only child was
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
- // `tracker-first` renames the former `github-first` (issue #1408, the
24
- // tracker-agnostic seam: provider-neutral naming now that GitHub is one
25
- // tracker provider among a stable seam, not the only one). `github-first` is
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 shipped with zero config. A tier alias maps a
36
- // harness-neutral name (low/high) to a concrete per-harness model id; `null`
37
- // means "inherit" (pass no model override genuine no-op on that harness).
38
- // Pi ships null on every built-in tier, so zero-config resolution is a no-op on
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
- // actually needs. "full" (default) is today's omniscient briefing;
127
- // "changed-files" drops the adjacent-code bundle AND the invariant prefix's
128
- // "Changed files + adjacent-code summary" section (the diff itself still
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 }`. An object may also
136
- // set `mandatory` (always runs, survives dynamic pruning was
137
- // gates.<gate>.mandatoryAngles), `enabled: false` (drops it from the resolved
138
- // list was gates.<gate>.excludeAngles, D3), `persona`/`prompt`/`model`/
139
- // `tier` (was the top-level `personas` map + angle-keyed
140
- // `models.roles`/`models.roleTiers`, D4: model > tier > built-in precedence),
141
- // and `scope` (AC3: the surface briefing variant this angle needs — see
142
- // GATE_ANGLE_SCOPES).
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 selection is ON by default (#1579): a fresh
201
- // install narrows the angle pool to what the diff-classifier recommends.
202
- // mandatory:true angles stay a hard always-run floor; fallbackToAll fires
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 (#1048): when true, the
209
- // context-builder may also ADD catalog angles from resolveAnglePool()
210
- // (gates.anglePool, or else the union of the persona registry and this
211
- // config's own configured angles) — that change-category heuristics
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 (D2): the spike gate
218
- // profile ships `required: false, requireCi: false` and a small docs-first
219
- // angle set; `blockCleanOnFindingSeverities` and `dynamic.additive` are
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 below consumes these spellings verbatim, and resolveGateConfig's
225
- // fail-closed guard exact-matches raw entries against the same list, so the
226
- // guard's accept set is byte-identical to the schema's (no trim/normalize
227
- // superset) and widening the enum can never leave the runtime guard behind.
228
- // Exported (not just module-internal) so the vocabulary contract test
229
- // (test/contracts/gate-severity-vocabulary-contract.test.mjs) can pin this
230
- // list against SEVERITY_ORDER + LEGACY_SEVERITY_ALIASES
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: JSON.stringify raises on BigInt and circular
241
- // structures and returns undefined for undefined/symbol/function (those fall
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 (i.e. carries its
259
- // own blockCleanOnFindingSeverities) kept in sync with the `gates:
260
- // { draft, preApproval, spike }` keys below by construction (both list the
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 (high/medium/low, plus their pre-rename spellings)
270
- // "question"/"nit" are non-defect categories that never block a clean
271
- // verdict by severity: a question's own answered/never-deferred contract
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
- // Per-gate medium fix window (#1581): an open medium finding stays in the
281
- // in-gate fix loop through this many rounds of THIS gate's chain and is
282
- // deferred (replied-to + resolved) from the next round on. Defaults to 3
283
- // (the built-in MEDIUM_FIX_WINDOW fallback in
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, so a gate that never sets this key keeps
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 (AC6). `name` is recorded as the shared reviewer's
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 (AC6 + #1601 two-knob dispatch bounds). The
312
- // grouped default batches related angles from a static table onto one
313
- // reviewer per group, cutting the fixed per-reviewer briefing cost when
314
- // several angles read the same surface; `per-angle` keeps the original
315
- // one-reviewer-per-angle fan-out (bypasses configured groups). `gate:full` no
316
- // longer restores per-angle dispatch (ADR 0047 superseded by 0048): it forces
317
- // the full angle set upstream (resolveGateTier) and dispatches GROUPED here.
318
- // Two orthogonal bounds (issue #1601):
319
- // maxAnglesPerGroup (N, default 3, min 1) after configured-groups
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 (Phase 1 of the escalate-don't-chop size gate:
361
- // this schema plus check-size-budget.mjs's pure computation only no
362
- // enforcement wiring yet). `patterns` classifies a changed file into t1/t3
363
- // by path glob; the default tier is implicit for every file matching
364
- // neither, so it carries no `patterns` field of its own. `sliceHardLoc`
365
- // (t1 only) caps the T1-slice LOC, not the whole-PR LOC.
366
- //
367
- // Per-tier `softLoc`/`waiverLoc` on t1/t3, and `sliceHardLoc` on t3, are a
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). Computation lives in
396
- // scripts/loop/check-size-budget.mjs; this config carries only the
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
- // `requireCi` is honored on both gates: default true keeps CI a precondition,
400
- // false is an opt-out escape hatch so a repo with no CI is not held at the
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 (#965). A spike's deliverable is a findings doc,
406
- // not production code, so it should not carry the full draft → pre-approval →
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 gate verdict carries recorded,
418
- // internally-consistent fan-out *provenance* (distinct reviewer count +
419
- // per-angle dispatch). This RAISES THE BAR against a single agent self-producing
420
- // every artifact but does NOT prove independence — provenance is self-reported,
421
- // so it remains forgeable; un-forgeable recording is the Pi-harness bridge (see
422
- // the honest caveat in skills/docs/gate-review-sub-loop-contract.md). Layered ON TOP of
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
- // SUPERSEDED by gates.fanout.maxConcurrent (#1601, ADR 0048): the conductor
428
- // now dispatches wave-by-wave at most M dispatch units per wave via
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
- // #1462 GATE-EXEC-PRIME is MANDATORY (not a flag): every gate fan-out primes the
435
- // byte-identical briefing prefix before the reviewers read it — see
436
- // skills/docs/gate-review-sub-loop-contract.md.
437
- // Post the consolidated gate fan-out findings as a SECOND visible,
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 global lens catalog override for additive angle selection
444
- // (gates.<gate>.dynamic.additive, #1048). GLOBAL, not per-gate (D1): one
445
- // repo-wide catalog for additive selection. When absent, resolveAnglePool()
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 enforcement that a fanout_fanin gate's recorded per-angle
451
- // provenance names only angles in the gate's configured pool ad-hoc/foreign
452
- // angle labels are rejected rather than silently accepted. Default true
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
- // (AC6). GLOBAL, not per-gate — see resolveFanoutGroups.
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: secondary cleanup #6 (stopAt kebab values vs camelCase gate
463
- // keys) is DEFERRED — "draft-pr"/"pre-approval" are checkpoint/state-machine
464
- // vocabulary shared far beyond config (lifecycle-state.mjs, hook-decisions.mjs,
465
- // the handoff-envelope contract, skills/docs/reviewer-loop-state-graph.md, and ~20
466
- // more files), not a config-local spelling. Renaming here would mean
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, non-overridable human action: the agent never
473
- // runs `gh pr merge`, `resolveAutonomyStopAt` always includes "merge", and
474
- // any per-run merge authorization (envelope flag / explicit instruction) is
475
- // ignored — it fails closed. See resolveHumanMergeOnly / resolveEffectiveMergeAuthorized.
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 (#920, Request B of #910): at the pre-approval /
481
- * merge-handoff boundary, OFFER to assign the PR to a contributor
482
- * reviewer/assignee. Opt-in (default off). Pairs with autonomy.humanMergeOnly.
483
- * `candidatesFrom` selects which sources the resolver queries; `assignees` is a
484
- * static highest-priority candidate list. Absent/empty = disabled no-op.
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 (secondary cleanup
501
- // #5) is DEFERRED — that string is echoed verbatim into the persisted
502
- // handoff-envelope contract field (validated, rendered, and cross-checked by
503
- // workflow-handoff-contract.test.mjs / the inspect-run viewer), so renaming
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 (#1669): when a dev-loop child shows no turn
511
- // progress for `thresholdMinutes` with no pending request, the parent bails
512
- // to a fresh-context recovery dispatch instead of waiting through a manual
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
- // Copilot review round cap for light-dispatched PRs (#1210). Composes with
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 (`--lightweight` with no --issue) at ANY
542
- * change scope. Decoupled from lightMode: gate dispatch still resolves
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 (two parallel
552
- // keys folded into one selector object). `ownerKey` names the config key in
553
- // the refine failure message — each usage site gets its own accurate
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 (issue #1408, the tracker-agnostic seam). `provider` is a
576
- * free-form registry key (not a zod enum): an unknown provider fails closed
577
- * at `resolveTrackerAdapter` call time, not at config-parse time the
578
- * seam/resolver must not preclude a consumer registering an external
579
- * provider post-1.0 (`plugin`, reserved, not implemented in this pass).
580
- * `board` is the canonical GitHub Projects board identifier (see resolveTrackerBoard).
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 (#909): which gitignored files/dirs to provision
600
- * into a fresh worktree from the main checkout. Entries are repo-relative
601
- * literal paths OR glob patterns, each tagged with its mode (was two parallel
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 (one per line); `applyCommand` applies them.
489
+ * lists pending migrations; `applyCommand` applies them.
618
490
  *
619
491
  * Destructive detection is EXPLICIT and status-format-dependent: the
620
- * `destructivePattern` regex is matched (case-insensitive, per line) against the
621
- * STATUS OUTPUT not against the migration files. The shipped default
622
- * (DEFAULT_DESTRUCTIVE_MIGRATION_PATTERN) assumes SQL-bearing status output
623
- * (DROP/TRUNCATE/DELETE FROM ...); against a status command that emits migration
624
- * identifiers or filenames instead, it matches nothing and the destructive guard
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 it drives with a drive-session id (advertised to the app on the
654
- * DRIVE_SESSION_HEADER request header); this `deleteCommand` deletes exactly the
655
- * rows the app tagged with that session the id is passed in the
656
- * UI_REVIEW_DRIVE_SESSION env var and the command runs in the provisioned
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
- // Responsive/stateful captures: a declared viewport resizes the page before the
736
- // step and bakes into the named-state slug, so the mobile vs desktop (or
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 (#1457).
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
- // 1.0 hard break (no dual-form): the deprecated `localPlanning` key (removed
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
- // Maps gate-review angle names to reviewer personas. Only the persona name is
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). Angle persona/prompt/
1088
- * model/tier now live on the gate's own angle entry (D3/D4 folded from the
1089
- * removed top-level `personas` map and angle-keyed `models.roles`/
1090
- * `models.roleTiers`), so a lookup by name alone (no gate context, matching
1091
- * `resolveReviewerRole`/`resolveRoleModel`'s existing signatures) checks each
1092
- * gate in turn and returns the first match. The shipped default config never
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 (AC3, #1572): "full"
1121
- * (default), "changed-files", or "docs-only" see GATE_ANGLE_SCOPES. Unlike
1122
- * {@link findAngleEntry} (which searches every gate in a fixed priority
1123
- * order because persona/prompt resolution has no gate context), this looks up
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
- * Resolution order:
1164
- * 1. Look up the angle's own configured entry across this config's gates
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 configured
1216
- * `model` (concrete, found via {@link findAngleEntry}), else its `tier`,
1217
- * else the built-in `review` tier a gate review runs at review quality
1218
- * even when the angle's name collides with a routine role, e.g. the
1219
- * `docs` angle resolves via the `review` tier (high), not the `docs`
1220
- * writer role's low tier. (Its persona/agent still comes from
1221
- * `resolveReviewerRole`; only the tier is forced to review.)
1222
- * 2. `kind: "role"`/absent (routine subagent): `models.roles[role]`
1223
- * (concrete, highest precedence), else `models.roleTiers[role]` (or the
1224
- * built-in role tier) mapped through `models.tiers[tier][harness]` (or
1225
- * built-in tiers); `inherit`/absent/null `null`. When the name is not a
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
- // 1. Concrete per-role override wins outright (over any tier). Role-keyed
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 (D3): a later layer can add a new angle, or override
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` (D3): entries in `target`
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 (e.g., .devloops without extension).
1455
- // Success returns immediately.
1456
- // ENOENT: file genuinely absent try extension variants.
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 (issue #1408, the
1529
- // tracker-agnostic seam): normalized to "tracker-first" BEFORE this layer's
1530
- // own FileConfigSchema validation, since the schema enum only accepts the
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
- // Removed `gates.primeSharedPrefix` (#1462): GATE-EXEC-PRIME cache priming is
1541
- // now mandatory, not a knob. The schema is strictObject, so a stale key would
1542
- // otherwise drop the WHOLE gates layer as invalid. Strip it before validation
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. Pre-existing behavior
1555
- // (unrelated to the #1404 angle-entry redesign): a schema violation ANYWHERE
1556
- // in this layer's file drops the WHOLE layer (errors is populated, `merged`
1557
- // is returned unchanged) rather than merging the rest of the file's valid
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 (#1578): many consumers destructure
1567
- // only `config` (or `config` + `warnings`) and never read `errors`, so a
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
- // Gate the raw-key migration hint: only append it when the offending
1581
- // keys actually include the pre-redesign mandatoryAngles/excludeAngles
1582
- // names, so an unrelated schema failure (e.g. a type error) does not get
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
- // Check if .devloops exists (primary consumer override)
1652
- // Only ENOENT means the file is genuinely absent; any other error
1653
- // (EACCES, EISDIR, etc.) means the file exists but is unreadable,
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 dev-loop config.
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 from the merged dev-loop config.
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
- * Resolve the fixed human-merge-only invariant from the merged dev-loop config.
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 configuration value from the merged dev-loop config.
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 configuration from the merged dev-loop config.
1838
- *
1839
- * Returns `{ fanOut, mode, roles, maxCopilotRounds, stopOnLowSignal, lowSignalRoundThreshold, lowSignalMaxComments }` with sensible built-in
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
- // #1337: centralize the pre-approval CI opt-out here so every caller that
1858
- // builds its interpreter refinement config from `resolveRefinement(config)`
1859
- // (detect-copilot-loop-state, copilot-pr-handoff, gate coordination, etc.)
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 dev-loop config.
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
- * and consumer builds on `mandatoryAngles`/`excludeAngles`/`dynamicAngles`/
1920
- * `additiveAngles` are derived here from the unified `gates.<gate>.angles`
1921
- * array (`mandatory: true` / `enabled: false` per-entry, D3) and the
1922
- * `gates.<gate>.dynamic` sub-object, so downstream consumers keep reading the
1923
- * same field names the pre-1.0 flat config keys used. (`extraAngles` no
1924
- * longer exists as a concept: D3's merge-by-name lets a later config layer add
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 (draft, preApproval, or spike not only
1932
- * the requested `gate`'s) PRESENT `blockCleanOnFindingSeverities` key is
1933
- * any schema-invalid shape: a non-array value, an empty array, or an entry
1934
- * that is not one of the schema enum's exact spellings. Every such shape
1935
- * can only arrive through a config that failed schema validation (the
1936
- * schema requires a min-1 array of the enum spellings; the guard
1937
- * exact-matches raw entries against the same spelling list, so its accept
1938
- * set is byte-identical to the schema's), and passing it through would make
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 together
1957
- // (not just the requested `gate`'s) so an invalid list on ANY gate refuses
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 (or all-garbage/malformed) array is a real configured
1969
- // "no angles" distinct from the key being absent entirely, which callers
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 (above) so every consumer
1981
- // (envelope, verdict poster, fan-in, viewer) sees canonical spellings
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
- // `mediumFixWindow` wins; `worthFixingNowFixWindow` is the deprecated
1986
- // pre-rename key, still honored so an unmigrated config keeps its
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 review evidence is required for a gate verdict.
1995
- *
1996
- * Default-on (opt-out): enforcement is ON unless `gates.requireFanoutEvidence`
1997
- * is explicitly set to false. When ON, the pre-merge evidence check fails
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 *provenance* is required for a fanout_fanin gate
2023
- * verdict (distinct reviewer count + per-angle dispatch recorded in the ledger).
2024
- *
2025
- * Default-OFF (opt-in): unlike resolveRequireFanoutEvidence, this uses a strict
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 fan-out findings should ALSO be posted
2051
- * as a second visible, marker-tagged PR comment.
2052
- *
2053
- * Returns false unless `gates.postFindingsComments` is explicitly set to true.
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 config.
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 (#1349).
2091
- *
2092
- * True only when `localImplementation.issueless` is exactly `true`; absent,
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 (#1210).
2104
- *
2105
- * Full PRs (lightweight=false) use `refinement.maxCopilotRounds` unchanged
2106
- * (default 5). Light-dispatched PRs compose with it rather than replacing it:
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 should run as a single-agent inline check or the full
2134
- * fan-out, from light-mode config + authoritative PR facts.
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 (label override)
2138
- * 2. light mode disabled / no threshold → full_fanout (light mode off)
2139
- * 3. scope over threshold (files OR lines) → full_fanout (over threshold)
2140
- * 4. inline check produced a finding whose severity is in the gate's
2141
- * blockCleanOnFindingSeverities set → full_fanout (escalated)
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
- * Two call phases share this one function:
2145
- * - pre-check: omit `inlineFindingSeverities` (undefined) decides whether to
2146
- * run the inline pass at all.
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 (issue #1601). Mirrors the
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 (issue #1601). Mirrors the
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` (issue #1726, default false). Serial
2201
- * (one-at-a-time) dispatch of heavy reviewers so each completes and writes its
2202
- * evidence before the next starts the concurrency bound that keeps genuine
2203
- * fan-out from SIGTERMing under child-safe parallel overload. Separate from
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) for a
2217
- * round: 1 when `gates.fanout.sequential` is set (serial dispatch forces one
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` (issue #1601, default 3, min 1).
2230
- * The number of ungrouped angles auto-chunked into one dispatch unit.
2231
- * Defensive, independent of zod: a non-integer or sub-1 value falls back to
2232
- * the built-in default so a malformed raw merged config (which zod may have
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` (issue #1601, default 4, min 1). The
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 (AC6 + #1601 two-knob dispatch bounds):
2258
- * map a round's resolved review angles onto the dispatch units it actually
2259
- * dispatches.
1953
+ * Resolve grouped fan-out dispatch: map a round's resolved angles onto the
1954
+ * dispatch units it dispatches.
2260
1955
  *
2261
- * Dispatch shape precedence (first match wins):
1956
+ * Precedence (first match wins):
2262
1957
  * 1. `gates.fanout.mode === "per-angle"` → bypasses configured groups; one
2263
- * singleton unit per angle (the original one-reviewer-per-angle fan-out;
2264
- * NOT equivalent to maxAnglesPerGroup: 1 when configured groups match)
2265
- * 2. otherwise (default `grouped`) → configured `gates.fanout.groups` are
2266
- * matched first (unchanged), then the leftover ungrouped angles are
2267
- * auto-chunked into dispatch units of ≤ `maxAnglesPerGroup` (default 3)
2268
- * instead of singletons.
2269
- *
2270
- * `gate:full` (`options.fullLabel`) NO LONGER restores per-angle dispatch
2271
- * (ADR 0047 superseded by 0048): `gate:full` keeps forcing the full angle set
2272
- * UPSTREAM (resolveGateTier returns `gate_full_label`, so resolveGateAnglesDynamic
2273
- * skips diff-class tier reduction) and dispatches GROUPED here. The `fullLabel`
2274
- * parameter is retained on the signature (callers thread it) but no longer
2275
- * changes the dispatch shape it is a no-op here, kept only to avoid a breaking
2276
- * API change to the exported resolver; its angle-set effect lives upstream.
2277
- *
2278
- * A configured group is included only when at least one of its angles is in
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 (`gates.fanout`), not per-gate; accepted for symmetry
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 for API stability;
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 and emit one singleton unit per
2320
- // angle (the original one-reviewer-per-angle fan-out). gate:full no longer
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
- // Issue #1601: leftover ungrouped angles auto-chunk into dispatch units of
2348
- // maxAnglesPerGroup (default 3) instead of singletons. Configured groups
2349
- // are matched first and never split by this knob (only the leftover pool is
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
- * unit (issue #1601). A single-angle chunk keeps the pre-#1601 singleton
2366
- * convention (the angle name, disambiguated to `angle:<name>` on collision
2367
- * with an emitted group name); a multi-angle chunk is named
2368
- * `group:<a>+<b>+<c>` from its deterministically-ordered members, with a
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 specific gate from the merged dev-loop config.
2388
- *
2389
- * Unions the mandatory angle names (entries with `mandatory: true`) with the
2390
- * gate's full configured angle list, then removes disabled entries
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 (resolveGateConfig drops
2405
- // enabled:false entries); the excludeAngles filter below is a defensive
2406
- // no-op that keeps this correct even for a hand-built config object that
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 available for additive angle selection.
2416
- *
2417
- * Returns the explicit `gates.anglePool` override when configured (non-empty
2418
- * array of trimmed strings). Otherwise falls back to the union of all known
2419
- * review angles: the built-in persona registry's angle names, plus every
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 all angle-coverage enforcement consumers
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 configured, ordered
2472
- * `gates.<gate>.tiers` list (first-match-wins). Pure and synchronous the
2473
- * single source of truth for tier selection, consulted at the top of
2474
- * `resolveGateAnglesDynamic` before any dynamic subtractive/additive
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: the `gate:full` label, no tiers
2478
- * configured, an unavailable/malformed scope, a changed dev-loop
2479
- * config-source file (`isDevLoopConfigSourcePath`), or an unclassifiable
2480
- * changed file (`classifyFile` returns "unknown") all resolve to `tier: null`
2481
- * rather than a guess. A matched tier's angle set is additionally validated
2482
- * against the gate's angle pool (`resolveGateAngleContract`) — ANY tier angle
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 in config.
2178
+ * Resolve gate angles dynamically when `dynamicAngles` is enabled.
2536
2179
  *
2537
- * Uses diff analysis helpers (from ../analysis/*) to filter the
2538
- * configured angle list down to only angles relevant to the change set.
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
- * When `dynamicAngles` is disabled (opt-out via `dynamic.subtractive: false`,
2541
- * see #1579), returns the full configured angle list (same as
2542
- * `resolveGateAngles`); no diff also falls back to the full static pool.
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 (file-level), linesChanged
2567
- // from T1 (hunk-level) reused for its real added+deleted line count rather than
2568
- // T0's/analyzeDiff's own inferred-category path, which reports a fake 0 line
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; // #1442: gate deslop on the prose surface
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
- // #1442 (ADR 0041 prose half): deslop is a prose-only angle. A docs-kind
2590
- // tier (e.g. this repo's docs-only/small-non-code) names it so prose diffs
2591
- // keep it, but that same kind matches exempt normative contracts
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 (#1048)
2286
+ // candidates + additively-selected catalog angles
2658
2287
  const filteredMandatory = gateConfig.mandatoryAngles.filter(a => !excluded.has(a));
2659
2288
 
2660
- // An angle that is both mandatory AND additively recommended must stay
2661
- // attributed to the mandatory floor, not be reported as "added" the
2662
- // resolver has no concept of "mandatory", so the caller (this function,
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 configuration value from the merged dev-loop config.
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 cannot resolve anything (e.g. no
2732
- // git repo at cwd) — matches the branch name every prior hardcoded "main"/
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 never
2759
- * `origin/`-prefixed) for worktree creation, PR targeting, and merge-base
2760
- * scope measurement (#1368).
2761
- *
2762
- * `workflow.baseBranch` (a non-empty trimmed string) is the authoritative
2763
- * override; unset, malformed, or empty is treated identically to unset and
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 from the merged dev-loop config.
2802
- *
2803
- * Returns `{ copyOnInit, linkOnInit }` (split by each entry's `mode`) with
2804
- * empty-array defaults when the config omits `worktree.entries` or it is
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 statements that drop or wipe data.
2824
- * Matched (case-insensitive, per line) against the migration STATUS OUTPUT. This
2825
- * default only detects destructive intent when the status output is itself
2826
- * SQL-bearing; against status output that lists migration identifiers/filenames
2827
- * it matches nothing and the guard is inert (no false positives, but also no
2828
- * protection). Such a project MUST override via
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 from the merged config.
2837
- *
2838
- * Returns null when no `uiReview.run.command` is declared the provision+boot
2839
- * stage treats that as a stated stop reason (no app is ever guessed). Numeric
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) from the merged config.
2919
- *
2920
- * Returns null when no `uiReview.login` is declared the drive stage treats
2921
- * that as a stated stop reason (it cannot authenticate, so it drives nothing).
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 from the merged dev-loop config (#920).
2961
- *
2962
- * Returns a normalized `{ enabled, candidatesFrom, assignees }`. Defaults to
2963
- * disabled with empty arrays when the `approval` section is absent. When
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 (issue #1408). Defaults to
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
  *