@dev-loops/core 1.0.2-pre.0 → 1.0.2

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({
@@ -563,31 +447,44 @@ function boardRefConfig(ownerKey) {
563
447
  });
564
448
  }
565
449
 
450
+ /**
451
+ * Logical board columns the queue status-column config recognizes. Mirrors
452
+ * LOGICAL_COLUMN in loop/queue-board-sync.mjs; kept inline (a frozen 4-value
453
+ * list) so this low-level config-schema module does not depend on
454
+ * queue-board-sync, which pulls in the projects/GitHub-access modules through
455
+ * its own imports. The two lists are pinned in lockstep by the schema test.
456
+ */
457
+ const QueueLogicalColumn = z.enum(["next_up", "in_progress", "ready_for_review", "done"]);
458
+
566
459
  /** Queue mode config */
567
460
  const QueueConfig = z.strictObject({
568
461
  maxParallel: z.number().int().min(1).max(10).default(3).describe("Maximum queue items worked in parallel."),
569
462
  maxAutoFiledIssues: z.number().int().min(0).max(100).default(10).describe("Cap on auto-filed issues per run."),
570
463
  reDispatchMaxRetries: z.number().int().min(0).max(10).default(1).describe("Retries when re-dispatching a failed queue item."),
571
464
  archiveOlderThanDays: z.number().int().positive().describe("Archive done board items older than this many days.").optional(),
465
+ statusColumns: z
466
+ .strictObject({
467
+ next_up: z.string().trim().min(1).optional(),
468
+ in_progress: z.string().trim().min(1).optional(),
469
+ ready_for_review: z.string().trim().min(1).optional(),
470
+ done: z.string().trim().min(1).optional(),
471
+ })
472
+ .describe("Logical-column -> board display-name overrides. Consumed by loadStateColumnMap (loop/queue-board-sync.mjs).")
473
+ .optional(),
474
+ stateColumnMap: z
475
+ .record(z.string().trim().min(1), QueueLogicalColumn)
476
+ .describe("Loop-state -> known logical column overrides. Consumed by loadStateColumnMap (loop/queue-board-sync.mjs).")
477
+ .optional(),
572
478
  });
573
479
 
574
480
  /**
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.
481
+ * Tracker config (the tracker-agnostic seam). `provider` is a free-form
482
+ * registry key (not a zod enum): an unknown provider fails closed at
483
+ * `resolveTrackerAdapter` call time, not at parse time, so a consumer can
484
+ * register an external provider post-1.0. No generic `fieldMappings` key: the
485
+ * github provider's logical-column -> Status mapping IS the existing
486
+ * `queue.statusColumns` (a second key would collide with it); a future external
487
+ * provider defines its own mapping when implemented (YAGNI now).
591
488
  */
592
489
  const TrackerConfig = z.strictObject({
593
490
  provider: z.string().trim().min(1).describe("Tracker provider registry key. Built-in: \"github\" (default).").optional(),
@@ -596,12 +493,9 @@ const TrackerConfig = z.strictObject({
596
493
  });
597
494
 
598
495
  /**
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.
496
+ * Worktree lifecycle config: gitignored files/dirs provisioned into a
497
+ * fresh worktree from the main checkout. Entries are repo-relative literal
498
+ * paths or globs, each tagged copy or link. Empty/absent is a valid no-op.
605
499
  */
606
500
  const WorktreeEntry = z.strictObject({
607
501
  path: z.string().trim().min(1).describe("Repo-relative path or glob."),
@@ -614,18 +508,14 @@ const WorktreeConfig = z.strictObject({
614
508
 
615
509
  /**
616
510
  * Dev-DB migration sub-recipe for the ui-review run recipe. `statusCommand`
617
- * lists pending migrations (one per line); `applyCommand` applies them.
511
+ * lists pending migrations; `applyCommand` applies them.
618
512
  *
619
513
  * 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.
514
+ * `destructivePattern` regex matches (case-insensitive, per line) against the
515
+ * STATUS OUTPUT, not the migration files. The shipped default assumes
516
+ * SQL-bearing status output; against non-SQL status output it matches nothing
517
+ * and the guard is inert, so such a project MUST set a `destructivePattern`
518
+ * matching its own status format (or make statusCommand emit the SQL/marker).
629
519
  */
630
520
  const UiReviewMigrateConfig = z.strictObject({
631
521
  statusCommand: z.string().trim().min(1),
@@ -650,11 +540,10 @@ const UiReviewMigrateConfig = z.strictObject({
650
540
 
651
541
  /**
652
542
  * 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.
543
+ * mutating step with a drive-session id; this `deleteCommand` deletes exactly
544
+ * the rows the app tagged with that session (id in the UI_REVIEW_DRIVE_SESSION
545
+ * env var; runs in the provisioned worktree, dev DB only). Runs only on
546
+ * explicit confirmation.
658
547
  */
659
548
  const UiReviewRowTeardownConfig = z.strictObject({
660
549
  deleteCommand: z.string().trim().min(1),
@@ -732,10 +621,8 @@ const UiReviewFlowStepConfig = z.strictObject({
732
621
  path: z.string().trim().min(1).optional(),
733
622
  value: z.string().optional(),
734
623
  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.
624
+ // A declared viewport resizes the page before the step and bakes into the
625
+ // named-state slug, so distinct renders land in distinct reviewable dirs.
739
626
  viewport: z.strictObject({ width: z.number().int().positive(), height: z.number().int().positive() }).optional(),
740
627
  interactionState: z.enum(["none", "focus", "hover", "error"]).optional(),
741
628
  }).superRefine((step, ctx) => {
@@ -804,7 +691,7 @@ const UiReviewConfig = z.strictObject({
804
691
  .optional(),
805
692
  });
806
693
 
807
- // Default/ceiling bounds for a post-merge action's run/verify timing (#1457).
694
+ // Default/ceiling bounds for a post-merge action's run/verify timing.
808
695
  // The default keeps a config-declared action from hanging a harness hook
809
696
  // forever when the author leaves timeoutMs unset; the ceiling caps how far a
810
697
  // config CAN push it — a config can only tighten these, never loosen past the
@@ -870,13 +757,6 @@ const FileGatesConfig = z.strictObject({
870
757
 
871
758
  // ============================================================================
872
759
  // 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
760
  // ============================================================================
881
761
 
882
762
  /**
@@ -974,23 +854,14 @@ export const FileConfigSchema = z.strictObject({
974
854
  worktree: WorktreeConfig.partial().describe("Worktree provisioning: gitignored files/dirs copied or symlinked into fresh worktrees.").optional(),
975
855
  uiReview: UiReviewConfig.partial().describe("UI-review route recipes: per-project run/boot, dev-login, driven flows, and caps.").optional(),
976
856
  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.
857
+ // Unknown keys fail closed like any typo (strictObject).
981
858
  });
982
859
 
983
860
  // ============================================================================
984
861
  // 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).
862
+ // persona resolution. Only the persona name is defined here; prompts and
863
+ // per-angle model overrides live on the angle's own config entry (see
864
+ // resolveReviewerRole).
994
865
  // ============================================================================
995
866
 
996
867
  const BUILTIN_PERSONAS = Object.freeze({
@@ -1084,25 +955,12 @@ function normalizeAngleEntries(raw) {
1084
955
 
1085
956
  /**
1086
957
  * 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.
958
+ * a fixed priority order (draft, preApproval, spike) and returning the first
959
+ * match. A DISABLED entry (`enabled: false`) is SKIPPED, never returned:
960
+ * returning a bare `enabled:false` placeholder would shadow another gate's real
961
+ * override of the same angle name. Both callers only ever look up a name
962
+ * already present in some gate's enabled resolved list, so a name disabled
963
+ * everywhere is never queried.
1106
964
  * @param {DevLoopConfig} config
1107
965
  * @param {string} name
1108
966
  * @returns {{name: string, mandatory?: boolean, enabled?: boolean, persona?: string, prompt?: string, model?: string, tier?: string}|null}
@@ -1117,14 +975,10 @@ function findAngleEntry(config, name) {
1117
975
  }
1118
976
 
1119
977
  /**
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,
978
+ * Resolve a gate angle's declared surface scope: see GATE_ANGLE_SCOPES.
979
+ * Looks up the entry within the ONE named gate (scope is meaningful only for
980
+ * that gate's briefing pass). Fails open to "full" for a missing/disabled entry
981
+ * or an unknown/malformed `scope` — a narrow scope is an opt-in cost saving,
1128
982
  * never a silently-enforced information cut.
1129
983
  * @param {DevLoopConfig} config
1130
984
  * @param {"draft"|"preApproval"|"spike"} gate
@@ -1158,23 +1012,15 @@ function resolveTierMapping(config, tierAlias, harness) {
1158
1012
  }
1159
1013
 
1160
1014
  /**
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
- *
1015
+ * Resolve a gate angle name to a reviewer persona and model. Resolution:
1016
+ * the angle's own configured entry (findAngleEntry), else BUILTIN_PERSONAS,
1017
+ * applying any entry `model` override; an unknown angle falls back to the
1018
+ * default reviewer (still honoring a `model` override).
1172
1019
  * @param {object} config - DevLoopConfig (or a partial with gates)
1173
1020
  * @param {string|null|undefined} angle - Gate angle / lens name
1174
1021
  * @returns {RoleResolutionResult}
1175
1022
  */
1176
1023
  export function resolveReviewerRole(config, angle) {
1177
- // Null/undefined/empty angle → fallback
1178
1024
  if (angle == null || angle === "") {
1179
1025
  return {
1180
1026
  persona: DEFAULT_REVIEWER_PERSONA,
@@ -1212,28 +1058,17 @@ export function resolveReviewerRole(config, angle) {
1212
1058
  * `null` (inherit → pass no model override).
1213
1059
  *
1214
1060
  * 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).
1061
+ * 1. `kind: "angle"` (gate review dispatch): the angle's own `model`, else its
1062
+ * `tier`, else the built-in `review` tier — so a gate review runs at review
1063
+ * quality even when the angle name collides with a routine role (e.g. the
1064
+ * `docs` angle resolves high via `review`, not the `docs` writer's low tier).
1065
+ * 2. `kind: "role"`/absent (routine subagent): `models.roles[role]`, else
1066
+ * `models.roleTiers[role]` (or the built-in role tier) mapped through
1067
+ * `models.tiers`; `inherit`/absent/null → null. A non-role name falls back
1068
+ * to its review persona's tier.
1069
+ *
1070
+ * Callers dispatching a gate angle whose name may collide with a routine role
1071
+ * (only `docs` today) MUST pass `kind: "angle"` to avoid the silent downgrade.
1237
1072
  *
1238
1073
  * @param {DevLoopConfig} config
1239
1074
  * @param {{ role: string, harness: "claude"|"pi", kind?: "role"|"angle" }} params
@@ -1249,9 +1084,7 @@ export function resolveRoleModel(config, { role, harness, kind } = {}) {
1249
1084
  return resolveTierMapping(config, tierAlias, harness);
1250
1085
  }
1251
1086
 
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).
1087
+ // Concrete per-role override wins outright over any tier (role-keyed only).
1255
1088
  const concrete = config?.models?.roles?.[role];
1256
1089
  if (typeof concrete === "string" && concrete.trim().length > 0) {
1257
1090
  return concrete.trim();
@@ -1347,7 +1180,7 @@ function mergeGatesFamily(target, source) {
1347
1180
 
1348
1181
  /**
1349
1182
  * 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
1183
+ * `angles` merges BY NAME: a later layer can add a new angle, or override
1351
1184
  * an existing angle's flags (including `enabled: false` to drop it), without
1352
1185
  * restating the whole array. `dynamic` merges shallowly (its two booleans).
1353
1186
  * Every other key (`required`, `requireCi`, `blockCleanOnFindingSeverities`)
@@ -1368,7 +1201,7 @@ function mergeGateObject(target, source) {
1368
1201
  }
1369
1202
 
1370
1203
  /**
1371
- * Merge two `gates.<gate>.angles` arrays BY `name` (D3): entries in `target`
1204
+ * Merge two `gates.<gate>.angles` arrays BY `name`: entries in `target`
1372
1205
  * keep their position; a `source` entry with a name already in `target`
1373
1206
  * overrides that entry's fields (shallow — e.g. `{ enabled: false }` drops it
1374
1207
  * without touching its `persona`/`prompt`); a `source` entry with a new name
@@ -1451,12 +1284,9 @@ async function findConfigFile(basePaths) {
1451
1284
  const candidates = Array.isArray(basePaths) ? basePaths : [basePaths];
1452
1285
 
1453
1286
  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.
1287
+ // Try bare path first. ENOENT: try extension variants. Other errors
1288
+ // (EISDIR/EACCES) mean the bare file exists but is unreadable — try
1289
+ // extension variants, but surface the original error if none exists.
1460
1290
  let bareData = null;
1461
1291
  let bareError = null;
1462
1292
  try {
@@ -1525,10 +1355,9 @@ async function applyLayer(merged, basePaths, layer, warnings, errors, options =
1525
1355
  return merged;
1526
1356
  }
1527
1357
 
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.
1358
+ // Deprecated `strategy: "github-first"` alias: normalized to
1359
+ // "tracker-first" BEFORE this layer's FileConfigSchema validation (the enum
1360
+ // only accepts the canonical value, else the whole layer drops as invalid).
1532
1361
  if (data.strategy === "github-first") {
1533
1362
  warnings.push(
1534
1363
  `strategy: "github-first" is a deprecated alias for "tracker-first" (issue #1408). ` +
@@ -1537,11 +1366,9 @@ async function applyLayer(merged, basePaths, layer, warnings, errors, options =
1537
1366
  data = { ...data, strategy: "tracker-first" };
1538
1367
  }
1539
1368
 
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.
1369
+ // gates.primeSharedPrefix is not a knob (priming is always on). The schema is
1370
+ // strictObject, so strip this stale key before validation (with a deprecation
1371
+ // warning) rather than let it drop the whole gates layer.
1545
1372
  if (data?.gates && Object.prototype.hasOwnProperty.call(data.gates, "primeSharedPrefix")) {
1546
1373
  warnings.push(
1547
1374
  `gates.primeSharedPrefix is removed (#1462): cache priming is now mandatory, not configurable. ` +
@@ -1551,24 +1378,15 @@ async function applyLayer(merged, basePaths, layer, warnings, errors, options =
1551
1378
  data = { ...data, gates: gatesRest };
1552
1379
  }
1553
1380
 
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.
1381
+ // Validate the file's structure before merging: a schema violation ANYWHERE
1382
+ // in this layer drops the WHOLE layer (errors populated, `merged` returned
1383
+ // unchanged) rather than merging the file's other valid keys. errors[].message
1384
+ // names the offending path/field so the failure is actionable.
1563
1385
  const validation = FileConfigSchema.safeParse(data);
1564
1386
  if (!validation.success) {
1565
1387
  // 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.
1388
+ // whole-layer drop is never silent: many consumers never read
1389
+ // `errors`, so a schema-rejected layer would vanish without a trace.
1572
1390
  const offendingKeys = validation.error.issues
1573
1391
  .flatMap((i) => {
1574
1392
  if (i.code === "unrecognized_keys" && Array.isArray(i.keys) && i.keys.length) {
@@ -1577,10 +1395,9 @@ async function applyLayer(merged, basePaths, layer, warnings, errors, options =
1577
1395
  }
1578
1396
  return i.path.length ? [i.path.join(".")] : [];
1579
1397
  });
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)
1398
+ // Only append the raw-key migration hint when the offending keys actually
1399
+ // include the raw mandatoryAngles/excludeAngles names, so an unrelated
1400
+ // failure does not get misleading guidance.
1584
1401
  const hasRawGateKey = offendingKeys.some((k) => /mandatoryAngles|excludeAngles/.test(k));
1585
1402
  const migrationHint = hasRawGateKey
1586
1403
  ? ` Migrate raw gates.<gate>.mandatoryAngles/excludeAngles to the canonical angle-entry shape ` +
@@ -1648,11 +1465,9 @@ export async function loadDevLoopConfig(options = {}) {
1648
1465
  warnOnMissing: true,
1649
1466
  });
1650
1467
 
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.
1468
+ // .devloops (primary override) existence: only ENOENT means genuinely absent.
1469
+ // Any other error (EACCES/EISDIR) means it exists but is unreadable, so
1470
+ // select the .devloops path and let applyLayer record the structured error.
1656
1471
  let primaryExists = false;
1657
1472
  for (const ext of ["", ".yaml", ".yml", ".json"]) {
1658
1473
  try {
@@ -1669,7 +1484,6 @@ export async function loadDevLoopConfig(options = {}) {
1669
1484
  }
1670
1485
 
1671
1486
  if (primaryExists) {
1672
- // .devloops is the primary override — apply it
1673
1487
  merged = await applyLayer(merged, devloopsPath, "devloops", warnings, errors);
1674
1488
  }
1675
1489
 
@@ -1689,14 +1503,8 @@ export async function loadDevLoopConfig(options = {}) {
1689
1503
  }
1690
1504
 
1691
1505
  /**
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
- *
1506
+ * Resolve the conductor model override from the merged config, or null when
1507
+ * unset (caller falls back to its own default).
1700
1508
  * @param {DevLoopConfig} config
1701
1509
  * @returns {string|null}
1702
1510
  */
@@ -1709,17 +1517,8 @@ export function resolveConductorModel(config) {
1709
1517
  }
1710
1518
 
1711
1519
  /**
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
- *
1520
+ * Resolve the autonomy stop-at list (gates that require operator confirmation)
1521
+ * from the merged config. Defaults to `["merge"]` when unset.
1723
1522
  * @param {DevLoopConfig} config
1724
1523
  * @returns {string[]}
1725
1524
  */
@@ -1736,12 +1535,8 @@ export function resolveAutonomyStopAt(config) {
1736
1535
  }
1737
1536
 
1738
1537
  /**
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
- *
1538
+ * True when `autonomy.humanMergeOnly` forces merge to be a human-only action
1539
+ * (the agent never merges; per-run authorization is ignored). Defaults false.
1745
1540
  * @param {DevLoopConfig} config
1746
1541
  * @returns {boolean}
1747
1542
  */
@@ -1790,11 +1585,7 @@ const DEFAULT_REFINEMENT_CONFIG = BUILT_IN_DEFAULTS.refinement;
1790
1585
  const DEFAULT_WORKFLOW_CONFIG = BUILT_IN_DEFAULTS.workflow;
1791
1586
 
1792
1587
  /**
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
- *
1588
+ * Resolve one refinement config value, or its built-in default.
1798
1589
  * @param {DevLoopConfig} config
1799
1590
  * @param {"fanOut"|"mode"|"roles"|"maxCopilotRounds"|"stopOnLowSignal"|"lowSignalRoundThreshold"|"lowSignalMaxComments"} key
1800
1591
  * @returns {number|"parallel"|"sequential"|string[]|boolean|null}
@@ -1834,15 +1625,9 @@ export function resolveRefinementConfig(config, key) {
1834
1625
  }
1835
1626
 
1836
1627
  /**
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
- *
1628
+ * Resolve the full refinement config with built-in defaults (fanOut 3, mode
1629
+ * parallel, roles null, maxCopilotRounds 5, low-signal off/3/2), plus the
1630
+ * resolved preApproval requireCi.
1846
1631
  * @param {DevLoopConfig} config
1847
1632
  * @returns {{ fanOut: number, mode: "parallel"|"sequential", roles: string[]|null, maxCopilotRounds: number, stopOnLowSignal: boolean, lowSignalRoundThreshold: number, lowSignalMaxComments: number }}
1848
1633
  */
@@ -1854,11 +1639,9 @@ export function resolveRefinement(config) {
1854
1639
  const stopOnLowSignal = /** @type {boolean} */ (resolveRefinementConfig(config, "stopOnLowSignal"));
1855
1640
  const lowSignalRoundThreshold = /** @type {number} */ (resolveRefinementConfig(config, "lowSignalRoundThreshold"));
1856
1641
  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.
1642
+ // Centralize the pre-approval CI opt-out here so every caller building
1643
+ // its refinement config from resolveRefinement honors
1644
+ // gates.preApproval.requireCi: false.
1862
1645
  const preApprovalRequireCi = resolveGateConfig(config, "preApproval").requireCi;
1863
1646
  return { fanOut, mode, roles, maxCopilotRounds, stopOnLowSignal, lowSignalRoundThreshold, lowSignalMaxComments, preApprovalRequireCi };
1864
1647
  }
@@ -1909,65 +1692,40 @@ function resolveBlockingSeverities(config, gate) {
1909
1692
  }
1910
1693
 
1911
1694
  /**
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.
1695
+ * Resolve one gate configuration object from the merged config.
1917
1696
  *
1918
1697
  * 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.)
1698
+ * builds on: `mandatoryAngles`/`excludeAngles`/`dynamicAngles`/`additiveAngles`
1699
+ * are derived here from the unified `gates.<gate>.angles` array (`mandatory` /
1700
+ * `enabled: false` per entry) and `gates.<gate>.dynamic`, so downstream
1701
+ * consumers keep reading the flat field names. `angles: null` means the
1702
+ * key was absent (fall back to skill defaults); an empty array is a real
1703
+ * configured "no angles".
1927
1704
  *
1928
1705
  * @param {DevLoopConfig} config
1929
1706
  * @param {"draft"|"preApproval"|"spike"} gate
1930
1707
  * @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.
1708
+ * @throws {Error} when ANY gate's (not only the requested one's) PRESENT
1709
+ * `blockCleanOnFindingSeverities` is schema-invalid (non-array, empty, or an
1710
+ * out-of-vocabulary entry). Validated EAGERLY across all three gates on every
1711
+ * call so a single-gate consumer can never produce a side effect while a
1712
+ * DIFFERENT gate's severity list is invalid. Dispatch-ergonomics keys degrade
1713
+ * quietly instead (resolveMaxAnglesPerGroup/resolveFanoutGroups); the key that
1714
+ * decides what blocks a clean verdict refuses, fail-closed. An ABSENT key
1715
+ * falls back to the default.
1953
1716
  */
1954
1717
  export function resolveGateConfig(config, gate) {
1955
1718
  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.
1719
+ // Eagerly validate every gate's blockCleanOnFindingSeverities (not just the
1720
+ // requested one) so an invalid list on ANY gate refuses up front. See @throws.
1962
1721
  let blockCleanOnFindingSeverities = ["high"];
1963
1722
  for (const g of GATE_KEYS_WITH_BLOCKING_SEVERITIES) {
1964
1723
  const resolved = resolveBlockingSeverities(config, g);
1965
1724
  if (g === gate) blockCleanOnFindingSeverities = resolved;
1966
1725
  }
1967
1726
  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).
1727
+ // An explicitly-empty array is a real configured "no angles" — distinct from
1728
+ // the key being absent (angles: null → fall back to skill defaults).
1971
1729
  const hasAngles = Array.isArray(gateConfig?.angles);
1972
1730
  return {
1973
1731
  angles: hasAngles ? entries.filter((e) => e.enabled !== false).map((e) => e.name) : null,
@@ -1977,30 +1735,22 @@ export function resolveGateConfig(config, gate) {
1977
1735
  requireCi: gateConfig?.requireCi ?? true,
1978
1736
  dynamicAngles: gateConfig?.dynamic?.subtractive ?? true,
1979
1737
  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.
1738
+ // Normalized + deduped at the resolve boundary so every consumer sees
1739
+ // canonical spellings only (anything outside the vocabulary already threw).
1984
1740
  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.
1741
+ // mediumFixWindow wins; worthFixingNowFixWindow is the deprecated alias,
1742
+ // still honored so an unmigrated config keeps its window.
1988
1743
  mediumFixWindow: gateConfig?.mediumFixWindow ?? gateConfig?.worthFixingNowFixWindow ?? 3,
1989
1744
  tiers: gateConfig?.tiers ?? [],
1990
1745
  };
1991
1746
  }
1992
1747
 
1993
1748
  /**
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
1749
+ * Resolve whether fan-out/fan-in evidence is required for a gate verdict.
1750
+ * Default-on (opt-out): ON unless `gates.requireFanoutEvidence` is false. The
1751
+ * `!== false` test (not `=== true`) keeps the opt-out robust for
1752
+ * programmatically-built configs that bypass schema defaulting. See
2002
1753
  * skills/docs/gate-review-sub-loop-contract.md.
2003
- *
2004
1754
  * @param {DevLoopConfig} config
2005
1755
  * @returns {boolean}
2006
1756
  */
@@ -2019,15 +1769,10 @@ export function resolveRequireFanoutEvidence(config) {
2019
1769
  export const FANOUT_PROVENANCE_MIN_REVIEWERS = 2;
2020
1770
 
2021
1771
  /**
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
- *
1772
+ * Resolve whether fan-out provenance is required for a fanout_fanin verdict.
1773
+ * Default-OFF (opt-in): a strict `=== true` test keeps behavior byte-identical
1774
+ * unless a repo sets `gates.requireFanoutProvenance: true`. Layered on top of
1775
+ * fan-out evidence enforcement. See skills/docs/gate-review-sub-loop-contract.md.
2031
1776
  * @param {DevLoopConfig} config
2032
1777
  * @returns {boolean}
2033
1778
  */
@@ -2047,16 +1792,10 @@ export function resolveRejectForeignAngles(config) {
2047
1792
  }
2048
1793
 
2049
1794
  /**
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
- *
1795
+ * Resolve whether the consolidated gate findings should ALSO post as a second
1796
+ * marker-tagged PR comment. False unless `gates.postFindingsComments === true`
1797
+ * (opt-in duplication; the verdict review is already the findings surface). The
1798
+ * disposition ledger is written regardless.
2060
1799
  * @param {DevLoopConfig} config
2061
1800
  * @returns {boolean}
2062
1801
  */
@@ -2065,11 +1804,8 @@ export function resolveGatePostFindingsComments(config) {
2065
1804
  }
2066
1805
 
2067
1806
  /**
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
- *
1807
+ * Resolve local implementation light mode: null when disabled (absent or
1808
+ * enabled=false), else { maxFiles, maxLines }.
2073
1809
  * @param {DevLoopConfig} config
2074
1810
  * @returns {{ maxFiles: number, maxLines: number } | null}
2075
1811
  */
@@ -2087,11 +1823,9 @@ export function resolveLightMode(config) {
2087
1823
  }
2088
1824
 
2089
1825
  /**
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
- *
1826
+ * Resolve the issue-less PR-first any-scope opt-in. True only when
1827
+ * `localImplementation.issueless` is exactly `true`; absent/false/malformed
1828
+ * resolve to false (fail closed).
2095
1829
  * @param {DevLoopConfig} config
2096
1830
  * @returns {boolean}
2097
1831
  */
@@ -2100,15 +1834,10 @@ export function resolveIssuelessEnabled(config) {
2100
1834
  }
2101
1835
 
2102
1836
  /**
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
- *
1837
+ * Resolve the effective Copilot review round cap for a PR. Full PRs use
1838
+ * `refinement.maxCopilotRounds` (default 5); light-dispatched PRs compose as
1839
+ * min(lightMode.maxCopilotRounds ?? 1, refinement.maxCopilotRounds), so
1840
+ * `refinement.maxCopilotRounds: 0` disables Copilot rounds everywhere.
2112
1841
  * @param {DevLoopConfig} config
2113
1842
  * @param {{ lightweight?: boolean }} [options]
2114
1843
  * @returns {number}
@@ -2130,25 +1859,20 @@ export function resolveEffectiveCopilotRoundCap(config, { lightweight = false }
2130
1859
  export const GATE_FULL_LABEL = "gate:full";
2131
1860
 
2132
1861
  /**
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.
1862
+ * Decide whether a gate runs as a single-agent inline check or full fan-out,
1863
+ * from light-mode config + authoritative PR facts.
2135
1864
  *
2136
1865
  * 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)
1866
+ * 1. `gate:full` label present → full_fanout
1867
+ * 2. light mode disabled / no threshold → full_fanout
1868
+ * 3. scope over threshold (files OR lines) → full_fanout
1869
+ * 4. inline finding severity in the gate's blockCleanOnFindingSeverities set
1870
+ * → full_fanout (escalated)
2142
1871
  * 5. otherwise → inline
2143
1872
  *
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).
1873
+ * Pre-check omits `inlineFindingSeverities` (decides whether to run the inline
1874
+ * pass at all); escalation passes the inline pass's severities. Absent/partial
1875
+ * `facts.scope` fails safe to full_fanout (missing counts → Infinity).
2152
1876
  *
2153
1877
  * @param {DevLoopConfig} config
2154
1878
  * @param {"draft"|"preApproval"} gate
@@ -2183,13 +1907,13 @@ export function resolveGateDispatchMode(config, gate, { scope, hasFullLabel = fa
2183
1907
  }
2184
1908
 
2185
1909
  /**
2186
- * Default auto-chunk size for ungrouped angles (issue #1601). Mirrors the
1910
+ * Default auto-chunk size for ungrouped angles. Mirrors the
2187
1911
  * zod default on `gates.fanout.maxAnglesPerGroup`.
2188
1912
  */
2189
1913
  export const DEFAULT_MAX_ANGLES_PER_GROUP = 3;
2190
1914
 
2191
1915
  /**
2192
- * Default concurrent-dispatch-unit cap per wave (issue #1601). Mirrors the
1916
+ * Default concurrent-dispatch-unit cap per wave. Mirrors the
2193
1917
  * zod default on `gates.fanout.maxConcurrent`; consumed by
2194
1918
  * `scheduleFanoutWaves` (@dev-loops/core/loop/gate-fanin).
2195
1919
  */
@@ -2197,13 +1921,10 @@ export const DEFAULT_FANOUT_MAX_CONCURRENT = 4;
2197
1921
  export const DEFAULT_FANOUT_SEQUENTIAL = false;
2198
1922
 
2199
1923
  /**
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).
1924
+ * Resolve `gates.fanout.sequential` (default false). Serial one-at-a-time
1925
+ * dispatch of heavy reviewers so each writes its evidence before the next
1926
+ * starts. Separate from `maxConcurrent`. The shipped default stays false for
1927
+ * cross-harness non-regression.
2207
1928
  * @param {DevLoopConfig} config
2208
1929
  * @returns {boolean}
2209
1930
  */
@@ -2213,10 +1934,8 @@ export function resolveFanoutSequential(config) {
2213
1934
  }
2214
1935
 
2215
1936
  /**
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).
1937
+ * Resolve the effective fan-out concurrency (dispatch units per wave): 1 when
1938
+ * `gates.fanout.sequential` is set, else `resolveFanoutMaxConcurrent`.
2220
1939
  * @param {DevLoopConfig} config
2221
1940
  * @returns {number}
2222
1941
  */
@@ -2226,11 +1945,10 @@ export function resolveFanoutEffectiveConcurrency(config) {
2226
1945
  }
2227
1946
 
2228
1947
  /**
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.
1948
+ * Resolve `gates.fanout.maxAnglesPerGroup` (default 3, min 1). Defensive,
1949
+ * independent of zod: a non-integer or sub-1 value falls back to the default so
1950
+ * a malformed raw merged config (which loadDevLoopConfig still returns) never
1951
+ * crashes Phase 2.
2234
1952
  * @param {DevLoopConfig} config
2235
1953
  * @returns {number}
2236
1954
  */
@@ -2241,7 +1959,7 @@ export function resolveMaxAnglesPerGroup(config) {
2241
1959
  }
2242
1960
 
2243
1961
  /**
2244
- * Resolve `gates.fanout.maxConcurrent` (issue #1601, default 4, min 1). The
1962
+ * Resolve `gates.fanout.maxConcurrent` (default 4, min 1). The
2245
1963
  * max dispatch units (groups) the conductor dispatches concurrently per wave.
2246
1964
  * Defensive, independent of zod (same rationale as resolveMaxAnglesPerGroup).
2247
1965
  * @param {DevLoopConfig} config
@@ -2254,61 +1972,33 @@ export function resolveFanoutMaxConcurrent(config) {
2254
1972
  }
2255
1973
 
2256
1974
  /**
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.
1975
+ * Resolve grouped fan-out dispatch: map a round's resolved angles onto the
1976
+ * dispatch units it dispatches.
2260
1977
  *
2261
- * Dispatch shape precedence (first match wins):
1978
+ * Precedence (first match wins):
2262
1979
  * 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.
1980
+ * singleton unit per angle (NOT equivalent to maxAnglesPerGroup: 1 when
1981
+ * configured groups match)
1982
+ * 2. default `grouped` → configured `gates.fanout.groups` match first, then
1983
+ * leftover ungrouped angles auto-chunk into units of ≤ `maxAnglesPerGroup`
1984
+ *
1985
+ * `gate:full` (`options.fullLabel`) does NOT restore per-angle dispatch: it
1986
+ * forces the full angle set upstream (resolveGateTier) and dispatches GROUPED
1987
+ * here (ADR 0048); `fullLabel` is a no-op, accepted for API stability.
1988
+ * Configured groups are NEVER split by `maxAnglesPerGroup` (only the leftover
1989
+ * pool is chunked). An unmatched group is dropped, never emitted empty.
1990
+ *
1991
+ * Defensive, independent of zod: a malformed `gates.fanout.groups` entry (from
1992
+ * a raw merged config that failed validation on any layer) is dropped (its
1993
+ * angles fall to the leftover pool), never thrown — this resolver degrades to a
1994
+ * smaller grouping table rather than crash Phase 2. `resolvedAngles` is
1995
+ * deduplicated up front so a duplicate never mints two units sharing one name.
2304
1996
  *
2305
1997
  * @param {DevLoopConfig} config
2306
1998
  * @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.
1999
+ * is a global policy, accepted for symmetry with the other resolvers.
2309
2000
  * @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).
2001
+ * @param {{ fullLabel?: boolean }} [options] — retained no-op (see above).
2312
2002
  * @returns {{ name: string, angles: string[] }[]}
2313
2003
  */
2314
2004
  export function resolveFanoutGroups(config, gate, resolvedAngles, { fullLabel = false } = {}) {
@@ -2316,9 +2006,8 @@ export function resolveFanoutGroups(config, gate, resolvedAngles, { fullLabel =
2316
2006
  ? [...new Set(resolvedAngles.filter((a) => typeof a === "string" && a.trim().length > 0).map((a) => a.trim()))]
2317
2007
  : [];
2318
2008
  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.
2009
+ // per-angle: bypass configured groups, one singleton unit per angle. gate:full
2010
+ // does not take this branch (ADR 0048): fullLabel is a no-op here.
2322
2011
  const fanout = config?.gates?.fanout ?? {};
2323
2012
  if (fanout.mode === "per-angle") return perAngleGroups();
2324
2013
  const angleSet = new Set(angles);
@@ -2344,10 +2033,9 @@ export function resolveFanoutGroups(config, gate, resolvedAngles, { fullLabel =
2344
2033
  for (const a of members) grouped.add(a);
2345
2034
  result.push({ name: group.name, angles: members });
2346
2035
  }
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.
2036
+ // Leftover ungrouped angles auto-chunk into units of ≤ maxAnglesPerGroup;
2037
+ // configured groups (matched above) are never split by this knob.
2038
+ // Deterministic input order + stable unit names.
2351
2039
  const usedNames = new Set(result.map((g) => g.name));
2352
2040
  const leftover = angles.filter((name) => !grouped.has(name));
2353
2041
  const maxAnglesPerGroup = resolveMaxAnglesPerGroup(config);
@@ -2361,12 +2049,11 @@ export function resolveFanoutGroups(config, gate, resolvedAngles, { fullLabel =
2361
2049
  }
2362
2050
 
2363
2051
  /**
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.
2052
+ * Deterministic, stable dispatch-unit name for an auto-chunked leftover unit.
2053
+ * A single-angle chunk uses the angle name (disambiguated to `angle:<name>` on
2054
+ * collision); a multi-angle chunk is `group:<a>+<b>+<c>` from its ordered
2055
+ * members (with a `#N` suffix on collision). Names key reviewer-sentinel scopes
2056
+ * and provenance, so they must be unique. Pure.
2370
2057
  * @param {string[]} chunk — non-empty, deterministically ordered
2371
2058
  * @param {Set<string>} usedNames — already-emitted unit names (mutated by caller)
2372
2059
  * @returns {string}
@@ -2384,16 +2071,10 @@ function stableAutoChunkUnitName(chunk, usedNames) {
2384
2071
  }
2385
2072
 
2386
2073
  /**
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
- *
2074
+ * Resolve review angles for a gate: `mandatoryAngles ∪ angles − disabled`,
2075
+ * deduplicated. Returns null when the gate has no configured `angles` at all
2076
+ * (caller falls back to skill defaults); an explicitly-empty `angles: []`
2077
+ * returns `[]`.
2397
2078
  * @param {DevLoopConfig} config
2398
2079
  * @param {"draft"|"preApproval"} gate
2399
2080
  * @returns {string[]|null}
@@ -2401,27 +2082,20 @@ function stableAutoChunkUnitName(chunk, usedNames) {
2401
2082
  export function resolveGateAngles(config, gate) {
2402
2083
  const gateConfig = resolveGateConfig(config, gate);
2403
2084
  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.
2085
+ // gateConfig.angles is already exclude-filtered; the excludeAngles filter
2086
+ // below is a defensive no-op for hand-built config objects that set
2087
+ // excludeAngles/angles independently.
2409
2088
  const excluded = new Set(gateConfig.excludeAngles);
2410
2089
  const merged = [...new Set([...gateConfig.mandatoryAngles, ...(gateConfig.angles ?? [])])];
2411
2090
  return merged.filter(a => !excluded.has(a));
2412
2091
  }
2413
2092
 
2414
2093
  /**
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
- *
2094
+ * Resolve the global lens catalog for additive angle selection: the explicit
2095
+ * `gates.anglePool` override when configured, else the union of the persona
2096
+ * registry's angle names and every angle configured across this config's own
2097
+ * gates. The persona registry alone omits pool angles with no dedicated
2098
+ * persona (e.g. ci-guard, link-check).
2425
2099
  * @param {DevLoopConfig} config
2426
2100
  * @returns {string[]}
2427
2101
  */
@@ -2440,18 +2114,13 @@ export function resolveAnglePool(config) {
2440
2114
  /**
2441
2115
  * Resolve a gate's ANGLE ENFORCEMENT CONTRACT: the mandatory angles a
2442
2116
  * 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.
2117
+ * within. Single source of truth for every angle-coverage consumer.
2454
2118
  *
2119
+ * `mandatoryAngles` is filtered through `excludeAngles` so excluding a
2120
+ * mandatory angle cannot deadlock every fanout write (missing-mandatory if
2121
+ * omitted, foreign if recorded). `pool` is resolveGateAngles, widened to the
2122
+ * global catalog (resolveAnglePool) when `additiveAngles` is on, with
2123
+ * excludeAngles still a hard ceiling; a null pool skips the foreign check.
2455
2124
  * @param {DevLoopConfig} config
2456
2125
  * @param {"draft"|"preApproval"|"spike"} gate
2457
2126
  * @returns {{ mandatoryAngles: string[], pool: string[]|null }}
@@ -2468,21 +2137,17 @@ export function resolveGateAngleContract(config, gate) {
2468
2137
  }
2469
2138
 
2470
2139
  /**
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.
2140
+ * Resolve the diff-class angle tier for a gate from its ordered
2141
+ * `gates.<gate>.tiers` (first-match-wins). Pure; the single source of truth for
2142
+ * tier selection, consulted at the top of resolveGateAnglesDynamic before any
2143
+ * dynamic reduction.
2476
2144
  *
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.
2145
+ * FAIL CLOSED at every uncertain step: `gate:full`, no tiers, an
2146
+ * unavailable/malformed scope, a changed dev-loop config-source file, or an
2147
+ * unclassifiable file all resolve to `tier: null`. A matched tier's angle set
2148
+ * is validated against the gate's pool — ANY tier angle outside a non-null pool
2149
+ * voids the WHOLE match (a typo'd tier angle is caught here, not by silently
2150
+ * dropping reviewers at gate time).
2486
2151
  *
2487
2152
  * @param {DevLoopConfig} config
2488
2153
  * @param {"draft"|"preApproval"|"spike"} gate
@@ -2532,28 +2197,17 @@ export function resolveGateTier(config, gate, { changedFiles, filesChanged, line
2532
2197
  }
2533
2198
 
2534
2199
  /**
2535
- * Resolve gate angles dynamically when `dynamicAngles` is enabled in config.
2200
+ * Resolve gate angles dynamically when `dynamicAngles` is enabled.
2536
2201
  *
2537
- * Uses diff analysis helpers (from ../analysis/*) to filter the
2538
- * configured angle list down to only angles relevant to the change set.
2202
+ * Diff analysis (../analysis/*) filters the configured angle list to angles
2203
+ * relevant to the change set. When `dynamic.subtractive: false` or no
2204
+ * diff is given, returns the full configured list. When `additiveAngles` is on,
2205
+ * catalog angles from resolveAnglePool may also be added, with
2206
+ * `excludeAngles` a hard ceiling.
2539
2207
  *
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.
2208
+ * Diff-class tiers (resolveGateTier) are consulted FIRST: a tier match returns
2209
+ * that tier's angle set (unioned with mandatory) directly and skips the
2210
+ * subtractive/additive machinery.
2557
2211
  *
2558
2212
  * @param {import("./types.js").DevLoopConfig} config
2559
2213
  * @param {"draft"|"preApproval"} gate
@@ -2563,10 +2217,9 @@ export function resolveGateTier(config, gate, { changedFiles, filesChanged, line
2563
2217
  * @returns {{ recommendedAngles: string[] | null, skippedAngles: string[], reasons: Record<string,string>, fallbackToAll: boolean, dynamicAnglesActive: boolean, addedAngles: string[], addedReasons: Record<string,string> }}
2564
2218
  */
2565
2219
  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.
2220
+ // Tier scope facts: changedFiles/filesChanged from T0, linesChanged from T1's
2221
+ // real added+deleted count (analyzeDiff's inferred-category path reports a
2222
+ // fake 0 for an unambiguous docs-only diff — see analyzeT1/analyzeDiff).
2570
2223
  let changedFiles;
2571
2224
  let filesChanged;
2572
2225
  let linesChanged;
@@ -2576,7 +2229,7 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
2576
2229
  const t0 = analyzeT0(diff.nameStatusOutput);
2577
2230
  changedFiles = t0.files;
2578
2231
  filesChanged = changedFiles.length;
2579
- prosePresent = t0.prosePresent; // #1442: gate deslop on the prose surface
2232
+ prosePresent = t0.prosePresent; // gate deslop on the prose surface
2580
2233
  if (diff.diffOutput) {
2581
2234
  const lineStats = analyzeT1(diff.diffOutput, t0).lineStats;
2582
2235
  linesChanged = lineStats.added + lineStats.deleted;
@@ -2586,11 +2239,9 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
2586
2239
  if (tierResult.tier) {
2587
2240
  const configuredAngles = resolveGateAngles(config, gate) ?? [];
2588
2241
  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.
2242
+ // deslop is a prose-only angle. A docs-kind tier keeps it for prose
2243
+ // diffs, but that kind also matches exempt normative contracts
2244
+ // (skills/docs/**), so strip deslop when the diff touches no prose surface.
2594
2245
  if (recommendedAngles.includes("deslop") && prosePresent === false) {
2595
2246
  recommendedAngles = recommendedAngles.filter((a) => a !== "deslop");
2596
2247
  }
@@ -2654,13 +2305,12 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
2654
2305
  });
2655
2306
 
2656
2307
  // Merge: mandatory always included (filtered by excludeAngles) + dynamically-selected
2657
- // candidates + additively-selected catalog angles (#1048)
2308
+ // candidates + additively-selected catalog angles
2658
2309
  const filteredMandatory = gateConfig.mandatoryAngles.filter(a => !excluded.has(a));
2659
2310
 
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.
2311
+ // An angle both mandatory AND additively recommended stays attributed to the
2312
+ // mandatory floor, not reported as "added" (the resolver has no concept of
2313
+ // mandatory, so this caller filters its output).
2664
2314
  const addedAngles = (dynamicResult.addedAngles ?? []).filter(a => !mandatory.has(a));
2665
2315
  const addedReasons = Object.fromEntries(
2666
2316
  Object.entries(dynamicResult.addedReasons ?? {}).filter(([a]) => !mandatory.has(a))
@@ -2680,11 +2330,7 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
2680
2330
  }
2681
2331
 
2682
2332
  /**
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
- *
2333
+ * Resolve one workflow config value, or its built-in default.
2688
2334
  * @param {DevLoopConfig} config
2689
2335
  * @param {"asyncStartMode"|"requireRetrospective"|"requireDraftFirst"|"devModeDefault"} key
2690
2336
  * @returns {string|boolean}
@@ -2728,9 +2374,8 @@ function tryGit(args, cwd) {
2728
2374
  }
2729
2375
  }
2730
2376
 
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.
2377
+ // Last-resort literal when git auto-detection resolves nothing (e.g. no git
2378
+ // repo at cwd).
2734
2379
  const AUTO_DETECT_BASE_BRANCH_FALLBACK = "main";
2735
2380
 
2736
2381
  /**
@@ -2755,18 +2400,12 @@ function autoDetectDefaultBranch(cwd) {
2755
2400
  }
2756
2401
 
2757
2402
  /**
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.
2403
+ * Resolve the effective base/integration branch (bare name, never
2404
+ * `origin/`-prefixed) for worktree creation, PR targeting, and merge-base scope
2405
+ * `workflow.baseBranch` is the authoritative override; unset/malformed/
2406
+ * empty falls back to auto-detect (origin/HEAD, else main/master, else "main").
2407
+ * Never throws. Callers own the `origin/` prefix (worktree creation prepends it;
2408
+ * gh/PR base passes the bare name through).
2770
2409
  *
2771
2410
  * @param {DevLoopConfig|null|undefined} config
2772
2411
  * @param {{ cwd?: string }} [options]
@@ -2798,14 +2437,10 @@ export function normalizeToBareBranch(value) {
2798
2437
  }
2799
2438
 
2800
2439
  /**
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
- *
2440
+ * Resolve the worktree lifecycle config into `{ copyOnInit, linkOnInit }` (split
2441
+ * by each entry's `mode`), empty-array defaults when `worktree.entries` is
2442
+ * absent/empty. Paths are trimmed, repo-relative literals or globs expanded
2443
+ * against the main checkout at provision time (scripts/loop/provision-worktree.mjs).
2809
2444
  * @param {DevLoopConfig} config
2810
2445
  * @returns {{ copyOnInit: string[], linkOnInit: string[] }}
2811
2446
  */
@@ -2820,27 +2455,21 @@ export function resolveWorktreeConfig(config) {
2820
2455
  }
2821
2456
 
2822
2457
  /**
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`).
2458
+ * Default destructive-migration signal: SQL that drops or wipes data, matched
2459
+ * (case-insensitive, per line) against the migration STATUS OUTPUT. Only detects
2460
+ * destructive intent when the status output is itself SQL-bearing; against a
2461
+ * status output of migration ids/filenames it matches nothing and the guard is
2462
+ * inert, so such a project MUST override `uiReview.run.migrate.destructivePattern`
2463
+ * (or emit the SQL/marker from `statusCommand`).
2831
2464
  */
2832
2465
  export const DEFAULT_DESTRUCTIVE_MIGRATION_PATTERN =
2833
2466
  "\\b(DROP\\s+(TABLE|COLUMN|DATABASE|SCHEMA)|TRUNCATE|DELETE\\s+FROM|ALTER\\s+TABLE\\s+.*\\bDROP\\b)";
2834
2467
 
2835
2468
  /**
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.
2469
+ * Resolve the ui-review provision+boot run recipe. Returns null when no
2470
+ * `uiReview.run.command` is declared — a stated stop reason (no app is ever
2471
+ * guessed). Numeric probe bounds fall back to defaults defensively for
2472
+ * programmatically-built config objects that bypass schema defaulting.
2844
2473
  *
2845
2474
  * @param {DevLoopConfig} config
2846
2475
  * @returns {null | { command: string, readyUrl: string, readyTimeoutMs: number,
@@ -2915,12 +2544,10 @@ export const DEFAULT_SERVER_LOG_EXCEPTION_PATTERN =
2915
2544
  "\\b(5\\d{2}\\b|Internal Server Error|Unhandled|Uncaught|Traceback|Exception|FATAL|\\bERROR\\b)";
2916
2545
 
2917
2546
  /**
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.
2547
+ * Resolve the ui-review drive recipe (Stage 2). Returns null when no
2548
+ * `uiReview.login` is declared — a stated stop reason (it cannot authenticate,
2549
+ * so it drives nothing). The server-log exception pattern falls back to the
2550
+ * shipped heuristic default when a `serverLogPath` is set without one.
2924
2551
  *
2925
2552
  * @param {DevLoopConfig} config
2926
2553
  * @returns {null | { login: object, interstitials: object[], flows: object[],
@@ -2957,14 +2584,10 @@ export function resolveUiReviewDriveRecipe(config) {
2957
2584
  }
2958
2585
 
2959
2586
  /**
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
- *
2587
+ * Resolve the human-handoff config into a normalized
2588
+ * `{ enabled, candidatesFrom, assignees }`. Disabled with empty arrays when
2589
+ * `approval` is absent; when disabled (default) callers must not source or
2590
+ * assign anyone. Pairs with `autonomy.humanMergeOnly`.
2968
2591
  * @param {DevLoopConfig} config
2969
2592
  * @returns {{ enabled: boolean, candidatesFrom: ("codeowners"|"recent-committers")[], assignees: string[] }}
2970
2593
  */
@@ -2992,7 +2615,7 @@ export function resolveHumanHandoffConfig(config) {
2992
2615
  }
2993
2616
 
2994
2617
  /**
2995
- * Resolve the tracker provider registry key (issue #1408). Defaults to
2618
+ * Resolve the tracker provider registry key. Defaults to
2996
2619
  * `"github"` — the only built-in provider in v1 — when unset. Callers pass
2997
2620
  * this to `resolveTrackerAdapter` (`@dev-loops/core/tracker`).
2998
2621
  *