@dev-loops/core 1.0.0-rc.2 → 1.0.0-rc.4

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.
Files changed (33) hide show
  1. package/package.json +7 -1
  2. package/src/analysis/diff-analyzer.mjs +31 -5
  3. package/src/claude/hook-decisions.mjs +14 -0
  4. package/src/cli/primitives.mjs +10 -2
  5. package/src/cli/retry-wrapper.mjs +14 -6
  6. package/src/config/config.mjs +1125 -240
  7. package/src/config/extension-defaults.yaml +217 -426
  8. package/src/github/copilot-helpers.mjs +139 -18
  9. package/src/github/issue-ops.mjs +556 -0
  10. package/src/github/ownership-helpers.mjs +79 -0
  11. package/src/github/review-threads.mjs +44 -3
  12. package/src/loop/bash-command-classify.mjs +35 -5
  13. package/src/loop/conductor-routing.mjs +1 -1
  14. package/src/loop/copilot-ci-status.mjs +76 -0
  15. package/src/loop/copilot-loop-iterations.mjs +1 -2
  16. package/src/loop/copilot-loop-state.mjs +9 -5
  17. package/src/loop/default-branch-guard.mjs +380 -0
  18. package/src/loop/gate-carry-forward.mjs +29 -2
  19. package/src/loop/gate-fanin.mjs +481 -31
  20. package/src/loop/handoff-envelope.mjs +43 -23
  21. package/src/loop/main-checkout-ff.mjs +58 -0
  22. package/src/loop/pr-gate-coordination.mjs +204 -47
  23. package/src/loop/pr-title-markers.mjs +76 -15
  24. package/src/loop/queue-board-sync.mjs +26 -9
  25. package/src/loop/reviewer-loop-state.mjs +2 -2
  26. package/src/loop/ui-e2e-scoping.mjs +2 -0
  27. package/src/loop/ui-review-drive.mjs +23 -0
  28. package/src/loop/ui-review-provision.mjs +36 -0
  29. package/src/projects/resolve-project.mjs +14 -7
  30. package/src/tracker/adapter.mjs +127 -0
  31. package/src/tracker/github-adapter.mjs +150 -0
  32. package/src/tracker/index.mjs +50 -0
  33. package/src/tracker/noop-adapter.mjs +35 -0
@@ -1,8 +1,12 @@
1
1
  import { readFile } from "node:fs/promises";
2
+ import { normalizeSeverity } from "../loop/gate-fanin.mjs";
3
+ import { execFileSync } from "node:child_process";
2
4
  import path from "node:path";
3
5
  import { parse as parseYaml } from "yaml";
4
6
  import { fileURLToPath } from "node:url";
5
7
  import { z } from "zod";
8
+ import { classifyFile } from "../analysis/diff-analyzer.mjs";
9
+ import { isDevLoopConfigSourcePath } from "../loop/gate-carry-forward.mjs";
6
10
 
7
11
  // ============================================================================
8
12
  // Sub-schemas
@@ -12,13 +16,20 @@ import { z } from "zod";
12
16
  // callers need a stable value even when they construct config objects directly.
13
17
  // ============================================================================
14
18
 
15
- const StrategyConfig = z.strictObject({
16
- default: z.enum(["local-first", "github-first"]).describe("Default work-intake strategy: local-first starts from a repo plan file, github-first from a tracked issue."),
17
- });
18
-
19
- const InputSourceConfig = z.strictObject({
20
- default: z.enum(["tracker", "phase-docs"]).describe("Where local-first work reads its spec: the tracker issue body, or repo phase docs."),
21
- });
19
+ // `strategy` and `inputSource` are single-value families (their only child was
20
+ // a `default` wrapper) — flattened to a bare enum at the family key itself.
21
+ //
22
+ // `tracker-first` renames the former `github-first` (issue #1408, the
23
+ // tracker-agnostic seam: provider-neutral naming now that GitHub is one
24
+ // tracker provider among a stable seam, not the only one). `github-first` is
25
+ // still ACCEPTED as a deprecated alias — normalized to `tracker-first` with a
26
+ // load-time warning in `loadDevLoopConfig` (see the alias-normalization pass
27
+ // below `mergeConfigLayers`) — but this schema only validates the canonical
28
+ // value, so the alias must be normalized on the raw merged object BEFORE it
29
+ // reaches this parse.
30
+ 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).");
31
+
32
+ const InputSourceConfig = z.enum(["tracker", "phase-docs"]).describe("Where local-first work reads its spec: the tracker issue body, or repo phase docs.");
22
33
 
23
34
  // Built-in tier aliases shipped with zero config. A tier alias maps a
24
35
  // harness-neutral name (low/high) to a concrete per-harness model id; `null`
@@ -92,37 +103,217 @@ const ModelsConfigBase = z.strictObject({
92
103
 
93
104
  const ModelsConfig = ModelsConfigBase.superRefine(refineRoleTiers);
94
105
 
106
+ // A round with at most this many comments (after this many rounds) counts as
107
+ // low-signal and stops further Copilot rounds early — folded from the three
108
+ // flat `stopOnLowSignal`/`lowSignalRoundThreshold`/`lowSignalMaxComments` keys
109
+ // into one sub-object (they are one feature).
110
+ const LowSignalConfig = z.strictObject({
111
+ enabled: z.boolean().default(false).describe("Stop Copilot rounds early once they stop producing signal."),
112
+ roundThreshold: z.number().int().nonnegative().default(3).describe("Rounds counted toward the low-signal stop decision."),
113
+ maxComments: z.number().int().nonnegative().default(2).describe("A round with at most this many comments counts as low-signal."),
114
+ });
115
+
95
116
  const RefinementConfig = z.strictObject({
96
117
  fanOut: z.number().int().min(1).max(10).describe("Parallel reviewers per refinement round."),
97
118
  mode: z.enum(["parallel", "sequential"]).describe("Whether refinement reviewers run in parallel or one after another."),
98
119
  maxCopilotRounds: z.number().int().nonnegative().default(5).describe("Automated Copilot review rounds before converging; 0 disables Copilot review."),
99
- stopOnLowSignal: z.boolean().default(false).describe("Stop Copilot rounds early once they stop producing signal."),
100
- lowSignalRoundThreshold: z.number().int().nonnegative().default(3).describe("Rounds counted toward the low-signal stop decision."),
101
- lowSignalMaxComments: z.number().int().nonnegative().default(2).describe("A round with at most this many comments counts as low-signal."),
120
+ lowSignal: LowSignalConfig.optional().describe("Early-stop policy for low-signal Copilot rounds."),
102
121
  roles: z.array(z.string().trim().min(1)).describe("Review lenses the refinement fan-out dispatches.").optional(),
103
122
  });
104
123
 
124
+ // Per-angle surface scope: how much of the gate-context bundle an angle
125
+ // actually needs. "full" (default) is today's omniscient briefing;
126
+ // "changed-files" drops the adjacent-code bundle AND the invariant prefix's
127
+ // "Changed files + adjacent-code summary" section (the diff itself still
128
+ // carries every changed file); "docs-only" narrows further to doc-file
129
+ // hunks only. Resolution (resolveGateAngleScope) fails open to "full" for an
130
+ // unknown/missing value — a narrow scope is an opt-in cost saving, never a
131
+ // silently-enforced information cut.
132
+ export const GATE_ANGLE_SCOPES = Object.freeze(["full", "changed-files", "docs-only"]);
133
+
134
+ // One review angle: a bare string is sugar for `{ name }`. An object may also
135
+ // set `mandatory` (always runs, survives dynamic pruning — was
136
+ // gates.<gate>.mandatoryAngles), `enabled: false` (drops it from the resolved
137
+ // list — was gates.<gate>.excludeAngles, D3), `persona`/`prompt`/`model`/
138
+ // `tier` (was the top-level `personas` map + angle-keyed
139
+ // `models.roles`/`models.roleTiers`, D4: model > tier > built-in precedence),
140
+ // and `scope` (AC3: the surface briefing variant this angle needs — see
141
+ // GATE_ANGLE_SCOPES).
142
+ // This is the ONE identity for a gate-review angle (was five separate places
143
+ // — see the config-schema RFC). `mergeConfigLayers` merges these arrays BY
144
+ // `name` across config layers (D3), so a later layer can add or disable a
145
+ // single angle without restating the whole list.
146
+ // A bare string is sugar for { name }; preprocessing the string→object wrap
147
+ // BEFORE validation (rather than a z.union of the two shapes) means every
148
+ // malformed angle entry validates against this ONE object schema, so a bad
149
+ // field (e.g. `mandatory: "yes"`) reports its own actionable path/message
150
+ // (`gates.draft.angles.1.mandatory: ...`) instead of zod's opaque
151
+ // invalid_union "Invalid input" that swallows which branch failed why.
152
+ const GateAngleEntry = z.preprocess(
153
+ (v) => (typeof v === "string" ? { name: v } : v),
154
+ z.strictObject({
155
+ name: z.string().trim().min(1),
156
+ mandatory: z.boolean().optional().describe("Always run this angle, regardless of diff-based dynamic selection."),
157
+ enabled: z.boolean().optional().describe("Set false to drop this angle from the resolved list (a later config layer disabling a base angle)."),
158
+ persona: z.string().trim().min(1).optional().describe("Reviewer persona for this angle."),
159
+ prompt: z.string().min(1).optional().describe("Short focused instruction for the reviewer agent — what to look for and how to judge this angle."),
160
+ model: z.string().trim().min(1).optional().describe("Concrete model override for this angle (highest precedence)."),
161
+ tier: z.string().trim().min(1).optional().describe("Model tier alias for this angle (used when `model` is absent)."),
162
+ scope: z.enum(GATE_ANGLE_SCOPES).optional().describe("Surface scope this angle needs: full (default), changed-files (diff without the adjacent-code bundle or its changed-files/adjacent-file summary section), or docs-only (doc-file hunks only). Unknown/omitted resolves to full."),
163
+ }),
164
+ );
165
+
166
+ // Diff-class kinds a tier's `match` can name — exactly classifyFile()'s
167
+ // output range (../analysis/diff-analyzer.mjs), so a tier config can never
168
+ // name a kind the classifier could not produce.
169
+ const GateTierMatchKind = z.enum(["code", "docs", "config", "test", "ci", "unknown"]);
170
+
171
+ // A tier's match conditions: EVERY changed file's kind must be in `kinds`
172
+ // (when set) AND the change must stay within `maxFiles`/`maxLines` (when
173
+ // set). At least one condition is required — a bare `{}` would match every
174
+ // diff unconditionally, which is never the intent of an explicit tier entry.
175
+ const GateTierMatch = z
176
+ .strictObject({
177
+ kinds: z.array(GateTierMatchKind).min(1).describe("Changed-file kinds this tier matches; every changed file's classifyFile() kind must be in this set.").optional(),
178
+ maxFiles: z.number().int().min(1).describe("Match only when the change touches at most this many files.").optional(),
179
+ maxLines: z.number().int().min(1).describe("Match only when the change stays within this many changed lines.").optional(),
180
+ })
181
+ .superRefine((match, ctx) => {
182
+ if (match.kinds === undefined && match.maxFiles === undefined && match.maxLines === undefined) {
183
+ ctx.addIssue({
184
+ code: z.ZodIssueCode.custom,
185
+ message: "match must set at least one of kinds, maxFiles, maxLines",
186
+ });
187
+ }
188
+ });
189
+
190
+ // One diff-class angle tier: a fixed angle set applied instead of dynamic
191
+ // subtractive/additive reduction when `match` holds. See resolveGateTier.
192
+ const GateTier = z.strictObject({
193
+ name: z.string().trim().min(1).describe("Tier name; surfaces as the tier:<name> resolution reason."),
194
+ match: GateTierMatch.describe("Diff-class conditions that select this tier."),
195
+ angles: z.array(z.string().trim().min(1)).min(1).describe("Angle set this tier resolves to when matched; unioned with the gate's mandatory angles."),
196
+ });
197
+
198
+ const GateDynamicConfig = z.strictObject({
199
+ // Diff-driven dynamic angle selection is ON by default (#1579): a fresh
200
+ // install narrows the angle pool to what the diff-classifier recommends.
201
+ // mandatory:true angles stay a hard always-run floor; fallbackToAll fires
202
+ // when classification is ambiguous, degrading to the full static pool. Set
203
+ // subtractive:false to restore the full static angle pool (the gate:full label
204
+ // only forces per-angle dispatch of the still-pruned set, not the full pool —
205
+ // combine both for the original full static fan-out).
206
+ 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."),
207
+ // Additive counterpart to the subtractive path (#1048): when true, the
208
+ // context-builder may also ADD catalog angles — from resolveAnglePool()
209
+ // (gates.anglePool, or else the union of the persona registry and this
210
+ // config's own configured angles) — that change-category heuristics
211
+ // recommend but that are not already in this gate's configured pool.
212
+ // Default false preserves the subtractive-only behavior exactly.
213
+ additive: z.boolean().default(false).describe("Allow diff-driven addition of catalog angles beyond this gate's configured pool (was gates.<gate>.additiveAngles)."),
214
+ });
215
+
216
+ // One unified gate schema for draft/preApproval/spike (D2): the spike gate
217
+ // profile ships `required: false, requireCi: false` and a small docs-first
218
+ // angle set; `blockCleanOnFindingSeverities` and `dynamic.additive` are
219
+ // accepted but INERT for spike (a findings-doc deliverable has no "clean
220
+ // verdict" escalation path and no additive dynamic pool) rather than being
221
+ // split into a second schema.
105
222
  const GateConfig = z.strictObject({
106
- angles: z.array(z.string().trim().min(1)).describe("Review lenses this gate fans out to.").optional(),
107
- excludeAngles: z.array(z.string().trim().min(1)).default([]).describe("Angles removed from the resolved angle list."),
108
- mandatoryAngles: z.array(z.string().trim().min(1)).default([]).describe("Angles that always run, regardless of diff-based dynamic selection."),
223
+ angles: z.array(GateAngleEntry).optional().describe("Review lenses this gate fans out to. A bare string is sugar for { name }; an object may set mandatory/enabled/persona/prompt/model/tier."),
224
+ dynamic: GateDynamicConfig.optional().describe("Diff-driven dynamic angle selection policy for this gate."),
109
225
  required: z.boolean().default(true).describe("Whether this gate must run."),
110
226
  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."),
227
+ // Defect severities only (high/medium/low, plus their pre-rename spellings)
228
+ // — "question"/"nit" are non-defect categories that never block a clean
229
+ // verdict by severity: a question's own answered/never-deferred contract
230
+ // and a nit's immediate-defer disposition already decide its fate, so
231
+ // admitting either here would let a config block on a severity the
232
+ // disposition pass simultaneously auto-resolves.
111
233
  blockCleanOnFindingSeverities: z
112
- .array(z.enum(["must-fix", "worth-fixing-now", "defer"]))
234
+ .array(z.enum(["high", "medium", "low", "must-fix", "worth-fixing-now", "nice-to-have", "defer"]))
113
235
  .min(1)
114
- .default(["must-fix"])
115
- .describe("Finding severities that block a clean gate verdict."),
116
- dynamicAngles: z.boolean().default(false).describe("Enable diff-driven dynamic angle resolution for this gate."),
117
- // Additive counterpart to the subtractive dynamicAngles path (#1048): when
118
- // true, the context-builder may also ADD catalog angles — from
119
- // resolveAnglePool() (gates.anglePool, or else the union of the persona
120
- // registry and this config's own configured angles) — that change-category
121
- // heuristics recommend but that are not already in this gate's configured
122
- // pool. Default false preserves today's subtractive-only behavior exactly.
123
- additiveAngles: z.boolean().default(false).describe("Allow diff-driven addition of catalog angles beyond this gate's configured pool."),
236
+ .default(["high"])
237
+ .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."),
238
+ // Per-gate medium fix window (#1581): an open medium finding stays in the
239
+ // in-gate fix loop through this many rounds of THIS gate's chain and is
240
+ // deferred (replied-to + resolved) from the next round on. Defaults to 3
241
+ // (the built-in MEDIUM_FIX_WINDOW fallback in
242
+ // scripts/github/_gate-finding-surface.mjs). high is exempt: it never
243
+ // defers and forces per-gate continuation until the gate round cap escalates.
244
+ // No schema-level `.default()`: resolveGateConfig applies the built-in
245
+ // fallback (3) only after checking BOTH this key and the deprecated
246
+ // `worthFixingNowFixWindow` alias. A schema-level default would fill this
247
+ // key on every config LAYER independently (each layer is parsed through
248
+ // this schema on its own before merging), permanently shadowing a layer
249
+ // that sets only the deprecated alias.
250
+ 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."),
251
+ // Deprecated alias for `mediumFixWindow` (pre-rename key); accepted on read
252
+ // and normalized in resolveGateConfig so an unmigrated config still behaves
253
+ // identically. `mediumFixWindow` wins when both are set.
254
+ worthFixingNowFixWindow: z.number().int().nonnegative().optional().describe("Deprecated alias for mediumFixWindow (pre-rename key name); mediumFixWindow wins when both are set."),
255
+ // Ordered, first-match-wins diff-class angle tiers (see resolveGateTier).
256
+ // Absent/empty = tiers never apply, so a gate that never sets this key keeps
257
+ // today's dynamic-subtractive/additive/full-pool resolution unchanged.
258
+ 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(),
259
+ });
260
+
261
+ // One named group of angles dispatched together onto a single reviewer under
262
+ // grouped fan-out (AC6). `name` is recorded as the shared reviewer's
263
+ // provenance `group` (see resolveFanoutGroups / fanoutReviewerPairingError).
264
+ const FanoutGroup = z.strictObject({
265
+ name: z.string().trim().min(1).describe("Group name; recorded as the shared reviewer's provenance `group` when this group dispatches."),
266
+ angles: z.array(z.string().trim().min(1)).min(1).describe("Angle names batched onto one reviewer when this group resolves."),
267
+ });
268
+
269
+ // Angle-dispatch fan-out policy (AC6 + #1601 two-knob dispatch bounds). The
270
+ // grouped default batches related angles from a static table onto one
271
+ // reviewer per group, cutting the fixed per-reviewer briefing cost when
272
+ // several angles read the same surface; `per-angle` keeps the original
273
+ // one-reviewer-per-angle fan-out (bypasses configured groups). `gate:full` no
274
+ // longer restores per-angle dispatch (ADR 0047 superseded by 0048): it forces
275
+ // the full angle set upstream (resolveGateTier) and dispatches GROUPED here.
276
+ // Two orthogonal bounds (issue #1601):
277
+ // maxAnglesPerGroup (N, default 3, min 1) — after configured-groups
278
+ // matching, leftover ungrouped angles auto-chunk into dispatch units of
279
+ // ≤N instead of singletons. mode: per-angle bypasses the table entirely
280
+ // maxConcurrent (M, default 4, min 1) — the conductor dispatches at most M
281
+ // dispatch units per wave (scheduleFanoutWaves via scheduleParallelWaves).
282
+ // An angle resolved for a round but not named in any configured group joins
283
+ // the auto-chunked leftover pool — `groups` need only list the angles worth
284
+ // batching explicitly.
285
+ const FanoutConfig = z.strictObject({
286
+ 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)."),
287
+ 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)."),
288
+ maxAnglesPerGroup: z.number().int().min(1).default(3).describe("Max angles per auto-chunked dispatch unit for leftover ungrouped angles (default 3, min 1). Configured groups are matched first and never split by this knob; mode: per-angle bypasses the table entirely (one singleton per angle)."),
289
+ maxConcurrent: z.number().int().min(1).default(4).describe("Max dispatch units (groups) the conductor dispatches concurrently per wave (default 4, min 1). The wave plan is emitted by write-gate-context.mjs via scheduleFanoutWaves (scheduleParallelWaves)."),
124
290
  });
125
291
 
292
+ /**
293
+ * Two `gates.fanout.groups` entries sharing one `name` would resolve to two
294
+ * dispatch units with the same reviewer-sentinel scope (resolveFanoutGroups
295
+ * keys the scope by group name) — reject at config-validation time rather
296
+ * than let it degrade silently at dispatch time. Applied via `.superRefine`
297
+ * where `FanoutConfig` is used (zod v4 rejects `.partial()` on a schema that
298
+ * already carries a refinement), not on `FanoutConfig` itself.
299
+ * @param {{ groups?: Array<{ name: string }> }} val
300
+ * @param {import("zod").RefinementCtx} ctx
301
+ */
302
+ function rejectDuplicateFanoutGroupNames(val, ctx) {
303
+ if (!Array.isArray(val.groups)) return;
304
+ const seen = new Set();
305
+ for (const [index, group] of val.groups.entries()) {
306
+ if (seen.has(group.name)) {
307
+ ctx.addIssue({
308
+ code: z.ZodIssueCode.custom,
309
+ path: ["groups", index, "name"],
310
+ message: `duplicate gates.fanout.groups name "${group.name}"`,
311
+ });
312
+ }
313
+ seen.add(group.name);
314
+ }
315
+ }
316
+
126
317
  const GatesConfig = z.strictObject({
127
318
  draft: GateConfig.optional(),
128
319
  // `requireCi` is honored on both gates: default true keeps CI a precondition,
@@ -141,43 +332,60 @@ const GatesConfig = z.strictObject({
141
332
  // fan-out/fan-in review sub-loop (executionMode === "fanout_fanin" plus a
142
333
  // durable findings-log ledger), not an inline single-agent run. Default
143
334
  // true (opt-out): a clean gate verdict requires fan-out/fan-in evidence
144
- // unless explicitly disabled. See docs/gate-review-sub-loop-contract.md.
335
+ // unless explicitly disabled. See skills/docs/gate-review-sub-loop-contract.md.
145
336
  requireFanoutEvidence: z.boolean().default(true),
146
337
  // Fail-closed enforcement that a fanout_fanin gate verdict carries recorded,
147
338
  // internally-consistent fan-out *provenance* (distinct reviewer count +
148
339
  // per-angle dispatch). This RAISES THE BAR against a single agent self-producing
149
340
  // every artifact but does NOT prove independence — provenance is self-reported,
150
341
  // so it remains forgeable; un-forgeable recording is the Pi-harness bridge (see
151
- // the honest caveat in docs/gate-review-sub-loop-contract.md). Layered ON TOP of
342
+ // the honest caveat in skills/docs/gate-review-sub-loop-contract.md). Layered ON TOP of
152
343
  // requireFanoutEvidence — only takes effect when fan-out evidence enforcement
153
344
  // is active. Default false (opt-in): closing this loophole is additive and
154
345
  // does not change behavior for existing ledgers that carry no provenance.
155
346
  requireFanoutProvenance: z.boolean().default(false),
156
- // Cap on how many scoped `review` reviewers the gate fan-out spawns in
157
- // parallel. When the resolved angle set exceeds this cap, the overflow runs
158
- // in sequential batches and the degradation is recorded in the gate evidence.
159
- maxFanoutReviewers: z.number().int().min(1).max(64).default(8),
160
- // Post the consolidated gate fan-out findings as a visible, marker-tagged PR
161
- // comment so they are auditable and Copilot/humans are aware of them. Default
162
- // true (opt-out). The disposition ledger is written regardless; this flag only
163
- // suppresses the PR comment when explicitly false. See
164
- // docs/gate-review-sub-loop-contract.md.
165
- postFindingsComments: z.boolean().default(true),
347
+ // SUPERSEDED by gates.fanout.maxConcurrent (#1601, ADR 0048): the conductor
348
+ // now dispatches wave-by-wave at most M dispatch units per wave via
349
+ // scheduleFanoutWaves (the wave plan emitted by write-gate-context.mjs), so
350
+ // maxFanoutReviewers no longer governs fan-out dispatch. Kept for back-compat
351
+ // (zero non-test callers in the dispatch path); a consumer setting it gets
352
+ // no dispatch effect. See gates.fanout.maxConcurrent for the active cap.
353
+ 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."),
354
+ // #1462 GATE-EXEC-PRIME is MANDATORY (not a flag): every gate fan-out primes the
355
+ // byte-identical briefing prefix before the reviewers read it — see
356
+ // skills/docs/gate-review-sub-loop-contract.md.
357
+ // Post the consolidated gate fan-out findings as a SECOND visible,
358
+ // marker-tagged PR comment. Default false (opt-in): the round's verdict
359
+ // review already carries every finding (GATE-COMMENT-SINGLE-SURFACE), so this
360
+ // comment renders each finding's text a second time. The disposition ledger
361
+ // is written regardless. See skills/docs/gate-review-sub-loop-contract.md.
362
+ postFindingsComments: z.boolean().default(false),
166
363
  // Explicit global lens catalog override for additive angle selection
167
- // (gates.<gate>.additiveAngles, #1048). When absent, resolveAnglePool()
364
+ // (gates.<gate>.dynamic.additive, #1048). GLOBAL, not per-gate (D1): one
365
+ // repo-wide catalog for additive selection. When absent, resolveAnglePool()
168
366
  // falls back to the union of the built-in persona registry's angle names
169
367
  // and every angle configured across this config's own draft/preApproval/
170
- // spike gates (angles + mandatoryAngles).
368
+ // spike gates.
171
369
  anglePool: z.array(z.string().trim().min(1)).optional(),
172
370
  // Fail-closed enforcement that a fanout_fanin gate's recorded per-angle
173
- // provenance names only angles in the gate's configured pool (angles +
174
- // mandatoryAngles) — ad-hoc/foreign angle labels are rejected rather than
175
- // silently accepted. Default true (reject); set false to warn instead of
176
- // fail. See resolveRejectForeignAngles / docs/gate-review-sub-loop-contract.md.
371
+ // provenance names only angles in the gate's configured pool — ad-hoc/foreign
372
+ // angle labels are rejected rather than silently accepted. Default true
373
+ // (reject); set false to warn instead of fail. See resolveRejectForeignAngles
374
+ // / skills/docs/gate-review-sub-loop-contract.md.
177
375
  rejectForeignAngles: z.boolean().default(true),
376
+ // Grouped vs per-angle fan-out dispatch policy + static grouping table
377
+ // (AC6). GLOBAL, not per-gate — see resolveFanoutGroups.
378
+ fanout: FanoutConfig.superRefine(rejectDuplicateFanoutGroupNames).optional(),
178
379
  });
179
380
 
180
381
  const AutonomyConfig = z.strictObject({
382
+ // ponytail: secondary cleanup #6 (stopAt kebab values vs camelCase gate
383
+ // keys) is DEFERRED — "draft-pr"/"pre-approval" are checkpoint/state-machine
384
+ // vocabulary shared far beyond config (lifecycle-state.mjs, hook-decisions.mjs,
385
+ // the handoff-envelope contract, skills/docs/reviewer-loop-state-graph.md, and ~20
386
+ // more files), not a config-local spelling. Renaming here would mean
387
+ // renaming that shared vocabulary, a materially larger change than this
388
+ // config-schema RFC's scope.
181
389
  stopAt: z.array(
182
390
  z.enum(["refinement", "draft-pr", "pre-approval", "merge"])
183
391
  ).describe("Checkpoints that require operator confirmation before the loop proceeds (default: [\"merge\"])."),
@@ -194,8 +402,12 @@ const AutonomyConfig = z.strictObject({
194
402
  * reviewer/assignee. Opt-in (default off). Pairs with autonomy.humanMergeOnly.
195
403
  * `candidatesFrom` selects which sources the resolver queries; `assignees` is a
196
404
  * static highest-priority candidate list. Absent/empty = disabled no-op.
405
+ *
406
+ * Lifted directly onto `approval` (its only child) rather than nested under
407
+ * `approval.humanHandoff` — `approval` had exactly one sub-key, so the wrapper
408
+ * added a level without adding meaning.
197
409
  */
198
- const HumanHandoffConfig = z.strictObject({
410
+ const ApprovalConfig = z.strictObject({
199
411
  enabled: z.boolean().default(false),
200
412
  candidatesFrom: z
201
413
  .array(z.enum(["codeowners", "recent-committers"]))
@@ -203,15 +415,24 @@ const HumanHandoffConfig = z.strictObject({
203
415
  assignees: z.array(z.string().trim().min(1)).optional(),
204
416
  });
205
417
 
206
- const ApprovalConfig = z.strictObject({
207
- humanHandoff: HumanHandoffConfig.optional(),
208
- });
209
-
210
418
  const WorkflowConfig = z.strictObject({
211
419
  asyncStartMode: z.enum(["required", "allowed"]).default("required").describe("Whether the async start contract is required or merely allowed."),
420
+ // ponytail: workflow.asyncStartMode -> asyncStartRequired (secondary cleanup
421
+ // #5) is DEFERRED — that string is echoed verbatim into the persisted
422
+ // handoff-envelope contract field (validated, rendered, and cross-checked by
423
+ // workflow-handoff-contract.test.mjs / the inspect-run viewer), so renaming
424
+ // it here would also mean renaming a shipped artifact contract, not just a
425
+ // config key. Out of scope for this config-shape RFC; revisit as its own
426
+ // change against skills/docs/gate-review-comment-contract.md + the envelope schema.
212
427
  requireRetrospective: z.boolean().describe("Require a retrospective checkpoint before a loop completes."),
213
428
  requireDraftFirst: z.boolean().describe("Open pull requests as drafts and promote via the draft gate."),
214
429
  devModeDefault: z.boolean().describe("Default new loops to dev mode."),
430
+ // No default here and absent from BUILT_IN_DEFAULTS — unset means "keep
431
+ // auto-detecting the default branch" (see resolveBaseBranch), never a static
432
+ // "main". Bare branch name; consumers add the `origin/` remote-ref prefix
433
+ // where one is needed (worktree creation) and pass the bare name where one
434
+ // is not (gh/PR base).
435
+ baseBranch: z.string().trim().min(1).describe("Repo-level base/integration branch override (bare name, e.g. \"main\" or \"spike/foo\"). When set, worktree creation and PR targeting use it instead of the auto-detected default branch. Unset = auto-detect (origin/HEAD, else main/master).").optional(),
215
436
  });
216
437
 
217
438
  const LocalImplementationConfig = z.strictObject({
@@ -230,32 +451,80 @@ const LocalImplementationConfig = z.strictObject({
230
451
  * change scope. Decoupled from lightMode: gate dispatch still resolves
231
452
  * inline vs full_fanout from scope on its own, so over-threshold issue-less
232
453
  * PRs get the full fan-out and the full-PR Copilot round cap.
454
+ *
455
+ * Flattened to a bare boolean — `enabled` was its only child key.
233
456
  */
234
- issueless: z.strictObject({
235
- enabled: 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."),
236
- }).optional(),
457
+ 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(),
237
458
  });
238
459
 
460
+ // GitHub Projects board identifier: exactly one of number/title (two parallel
461
+ // keys folded into one selector object). `ownerKey` names the config key in
462
+ // the refine failure message — each usage site gets its own accurate
463
+ // message rather than a shared one that could name the wrong key.
464
+ function boardRefConfig(ownerKey) {
465
+ return z
466
+ .strictObject({
467
+ number: z.number().int().positive().describe("GitHub Projects board number.").optional(),
468
+ title: z.string().trim().min(1).describe("GitHub Projects board title.").optional(),
469
+ })
470
+ .refine((v) => typeof v.number === "number" || typeof v.title === "string", {
471
+ message: `${ownerKey} must set number or title`,
472
+ });
473
+ }
474
+
475
+ const QueueBoardConfig = boardRefConfig("queue.board");
476
+
239
477
  /** Queue mode config */
240
478
  const QueueConfig = z.strictObject({
241
479
  maxParallel: z.number().int().min(1).max(10).default(3).describe("Maximum queue items worked in parallel."),
242
480
  maxAutoFiledIssues: z.number().int().min(0).max(100).default(10).describe("Cap on auto-filed issues per run."),
243
481
  reDispatchMaxRetries: z.number().int().min(0).max(10).default(1).describe("Retries when re-dispatching a failed queue item."),
244
- projectNumber: z.number().int().positive().describe("GitHub Projects board number (explicit opt-in to Projects-based queue ordering).").optional(),
245
- boardTitle: z.string().trim().min(1).describe("GitHub Projects board title (explicit opt-in to Projects-based queue ordering).").optional(),
482
+ // Deprecated: superseded by `tracker.board` (issue #1408, the tracker-agnostic
483
+ // seam). Kept accepted for back-compat — see resolveTrackerBoard, which reads
484
+ // `tracker.board` first and falls back to this field with a load-time warning.
485
+ board: QueueBoardConfig.describe("Deprecated: use tracker.board instead. GitHub Projects board identifier.").optional(),
246
486
  archiveOlderThanDays: z.number().int().positive().describe("Archive done board items older than this many days.").optional(),
247
487
  });
248
488
 
489
+ /**
490
+ * Tracker config (issue #1408, the tracker-agnostic seam). `provider` is a
491
+ * free-form registry key (not a zod enum): an unknown provider fails closed
492
+ * at `resolveTrackerAdapter` call time, not at config-parse time — the
493
+ * seam/resolver must not preclude a consumer registering an external
494
+ * provider post-1.0 (`plugin`, reserved, not implemented in this pass).
495
+ * `board` supersedes the deprecated `queue.board` (see resolveTrackerBoard).
496
+ *
497
+ * No generic `fieldMappings` (logical-column -> provider-status) key here:
498
+ * the github provider's logical-column -> Status mapping IS the existing,
499
+ * already-load-bearing `queue.statusColumns` (read by `loadStateColumnMap` in
500
+ * `../loop/queue-board-sync.mjs`; `next_up` is the fail-closed pickup column
501
+ * `resolve-active-board-item.mjs` reads). Adding a second, inert mapping key
502
+ * here would collide with that live one rather than replace it. A future
503
+ * external provider defines its OWN logical -> status mapping (its shape is
504
+ * provider-specific) when one is actually implemented — YAGNI to generalize
505
+ * this now for a provider that does not exist yet.
506
+ */
507
+ const TrackerConfig = z.strictObject({
508
+ provider: z.string().trim().min(1).describe("Tracker provider registry key. Built-in: \"github\" (default).").optional(),
509
+ plugin: z.string().trim().min(1).describe("Reserved: module specifier for an external tracker provider plugin (post-1.0, not implemented in this pass).").optional(),
510
+ board: boardRefConfig("tracker.board").describe("Tracker board identifier; supersedes the deprecated queue.board.").optional(),
511
+ });
512
+
249
513
  /**
250
514
  * Worktree lifecycle config (#909): which gitignored files/dirs to provision
251
515
  * into a fresh worktree from the main checkout. Entries are repo-relative
252
- * literal paths OR glob patterns. `copyOnInit` → `fs.cp` (isolated per
253
- * worktree); `linkOnInit` → absolute symlink into the main checkout (read-only
254
- * data). Both optional; empty/absent is a valid no-op.
516
+ * literal paths OR glob patterns, each tagged with its mode (was two parallel
517
+ * `copyOnInit`/`linkOnInit` arrays encoding the mode via which array it lived
518
+ * in). `copy` → `fs.cp` (isolated per worktree); `link` → absolute symlink
519
+ * into the main checkout (read-only data). Empty/absent is a valid no-op.
255
520
  */
521
+ const WorktreeEntry = z.strictObject({
522
+ path: z.string().trim().min(1).describe("Repo-relative path or glob."),
523
+ mode: z.enum(["copy", "link"]).describe("copy = fs.cp into the worktree (isolated, mutable); link = absolute symlink to the main checkout (shared, read-only)."),
524
+ });
525
+
256
526
  const WorktreeConfig = z.strictObject({
257
- copyOnInit: z.array(z.string().trim().min(1)).describe("Repo-relative paths/globs copied into a fresh worktree (isolated per worktree — use for mutable files).").optional(),
258
- linkOnInit: z.array(z.string().trim().min(1)).describe("Repo-relative paths/globs symlinked to the main checkout (shared — read-only data only).").optional(),
527
+ entries: z.array(WorktreeEntry).optional().describe("Gitignored paths/globs provisioned into a fresh worktree."),
259
528
  });
260
529
 
261
530
  /**
@@ -453,16 +722,6 @@ const UiReviewConfig = z.strictObject({
453
722
  /** Internal path whitelist for internal-only PR detection — flat array of regex strings */
454
723
  const InternalPatternsConfig = z.array(z.string().trim().min(1)).min(1);
455
724
 
456
- const PersonaEntry = z.strictObject({
457
- persona: z.string().min(1),
458
- // Optional in the merged/full schema so consumer overrides can replace
459
- // only persona/defaultModel without having to restate the inherited prompt.
460
- prompt: z.string().min(1).optional().describe("Short focused instruction for the reviewer agent — what to look for and how to judge this angle"),
461
- defaultModel: z.string().trim().min(1).nullable().default(null),
462
- });
463
-
464
- const PersonasConfig = z.record(z.string().min(1), PersonaEntry);
465
-
466
725
  // Partial nested gate entries for file-level config (allows overriding only
467
726
  // requireCi/required/angles without restating the whole gate object).
468
727
  const FileGatesConfig = z.strictObject({
@@ -474,17 +733,22 @@ const FileGatesConfig = z.strictObject({
474
733
  spike: GateConfig.partial().describe("Relaxed spike gate profile; applies only to spike-mode work.").optional(),
475
734
  requireFanoutEvidence: z.boolean().describe("Require fan-out/fan-in review evidence on gate verdicts; inline single-agent verdicts are rejected except under the strict light-mode exception (under-threshold scope, no gate:full label, recorded inline reason).").optional(),
476
735
  requireFanoutProvenance: z.boolean().describe("Additionally require recorded, internally-consistent fan-out provenance (distinct reviewer count + per-angle dispatch).").optional(),
477
- maxFanoutReviewers: z.number().int().min(1).max(64).describe("Cap on parallel gate fan-out reviewers; overflow runs in sequential batches.").optional(),
478
- postFindingsComments: z.boolean().describe("Post consolidated gate findings as a marker-tagged PR comment (default true).").optional(),
479
- anglePool: z.array(z.string().trim().min(1)).describe("Explicit global lens catalog for additive angle selection.").optional(),
736
+ maxFanoutReviewers: z.number().int().min(1).max(64).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.").optional(),
737
+ postFindingsComments: z.boolean().describe("Also post consolidated gate findings as a second marker-tagged PR comment, duplicating the verdict review's own findings (default false).").optional(),
738
+ anglePool: z.array(z.string().trim().min(1)).describe("Explicit global lens catalog for additive angle selection (global, not per-gate).").optional(),
480
739
  rejectForeignAngles: z.boolean().describe("Reject fan-out provenance naming angles outside the gate's configured pool (default true).").optional(),
740
+ fanout: FanoutConfig.partial().superRefine(rejectDuplicateFanoutGroupNames).describe("Grouped vs per-angle fan-out dispatch policy + static grouping table (global, not per-gate).").optional(),
481
741
  });
482
742
 
483
- // Partial persona entries for file-level config (allows omitting fields)
484
- const FilePersonasConfig = z.record(z.string().min(1), PersonaEntry.partial());
485
-
486
743
  // ============================================================================
487
744
  // Full schema — families are optional (BUILT_IN_DEFAULTS provides fallback)
745
+ //
746
+ // The `tracker:` config block is intentionally reserved here; a future
747
+ // tracker-seam change adds it on top of this restructured schema. Not added
748
+ // in this pass — this is the config-shape redesign only — but resolvers in
749
+ // this module take the effective config as a plain parameter (no
750
+ // global/singleton reads), so a later tracker adapter (and any multi-tracker
751
+ // layer on top of it) stays additive.
488
752
  // ============================================================================
489
753
 
490
754
  /**
@@ -503,13 +767,10 @@ export const DevLoopConfigSchema = z.strictObject({
503
767
  workflow: WorkflowConfig.optional(),
504
768
  localImplementation: LocalImplementationConfig.optional(),
505
769
  queue: QueueConfig.optional(),
506
- personas: PersonasConfig.optional(),
770
+ tracker: TrackerConfig.optional(),
507
771
  internalPathPatterns: InternalPatternsConfig.optional(),
508
772
  worktree: WorktreeConfig.optional(),
509
773
  uiReview: UiReviewConfig.optional(),
510
- // Deprecated (removed in #1088): tolerated so consumer .devloops files that
511
- // still carry a localPlanning block keep parsing. Accepted, never read.
512
- localPlanning: z.unknown().optional(),
513
774
  });
514
775
 
515
776
  // ============================================================================
@@ -518,18 +779,16 @@ export const DevLoopConfigSchema = z.strictObject({
518
779
 
519
780
  export const BUILT_IN_DEFAULTS = Object.freeze({
520
781
  version: 1,
521
- strategy: Object.freeze({ default: "local-first" }),
522
- inputSource: Object.freeze({ default: "tracker" }),
782
+ strategy: "local-first",
783
+ inputSource: "tracker",
523
784
  models: Object.freeze({}),
524
- refinement: Object.freeze({ fanOut: 3, mode: "parallel", maxCopilotRounds: 5, stopOnLowSignal: false, lowSignalRoundThreshold: 3, lowSignalMaxComments: 2 }),
785
+ refinement: Object.freeze({ fanOut: 3, mode: "parallel", maxCopilotRounds: 5, lowSignal: Object.freeze({ enabled: false, roundThreshold: 3, maxComments: 2 }) }),
525
786
  gates: Object.freeze({}),
526
787
  autonomy: Object.freeze({ stopAt: Object.freeze(["merge"]), humanMergeOnly: false }),
527
788
  approval: Object.freeze({
528
- humanHandoff: Object.freeze({
529
- enabled: false,
530
- candidatesFrom: Object.freeze([]),
531
- assignees: Object.freeze([]),
532
- }),
789
+ enabled: false,
790
+ candidatesFrom: Object.freeze([]),
791
+ assignees: Object.freeze([]),
533
792
  }),
534
793
  workflow: Object.freeze({
535
794
  asyncStartMode: "required",
@@ -539,17 +798,22 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
539
798
  }),
540
799
  localImplementation: Object.freeze({
541
800
  lightMode: Object.freeze({ enabled: false, maxFiles: 3, maxLines: 200, maxCopilotRounds: 1 }),
542
- issueless: Object.freeze({ enabled: false }),
801
+ issueless: false,
543
802
  }),
544
803
  queue: Object.freeze({
545
804
  maxParallel: 3,
546
805
  maxAutoFiledIssues: 10,
547
806
  reDispatchMaxRetries: 1,
548
- // projectNumber and boardTitle are intentionally absent from defaults
549
- // — setting either is an explicit operator opt-in for Projects-based
550
- // queue ordering.
807
+ // queue.board is intentionally absent from defaults — setting it is an
808
+ // explicit operator opt-in for Projects-based queue ordering.
809
+ }),
810
+ tracker: Object.freeze({
811
+ provider: "github",
812
+ // tracker.board is intentionally absent from defaults — setting it is an
813
+ // explicit operator opt-in (mirrors queue.board). The logical-column ->
814
+ // Status mapping is queue.statusColumns (see TrackerConfig above), not a
815
+ // tracker-owned default.
551
816
  }),
552
- personas: Object.freeze({}),
553
817
  internalPathPatterns: Object.freeze([
554
818
  "^scripts/",
555
819
  "^docs/",
@@ -558,7 +822,7 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
558
822
  "^\\.github/",
559
823
  "^test/",
560
824
  ]),
561
- worktree: Object.freeze({ copyOnInit: Object.freeze([]), linkOnInit: Object.freeze([]) }),
825
+ worktree: Object.freeze({ entries: Object.freeze([]) }),
562
826
  });
563
827
 
564
828
  // ============================================================================
@@ -567,9 +831,9 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
567
831
 
568
832
  export const FileConfigSchema = z.strictObject({
569
833
  version: z.literal(1).describe("Config format version; always 1."),
570
- strategy: StrategyConfig.partial().describe("Work-intake strategy defaults.").optional(),
571
- inputSource: InputSourceConfig.partial().describe("Spec source for local-first work.").optional(),
572
- models: ModelsConfigBase.partial().superRefine(refineRoleTiers).describe("Model routing: conductor override, per-role/angle overrides, tier aliases, and role→tier policy.").optional(),
834
+ strategy: StrategyConfig.optional().describe("Work-intake strategy default."),
835
+ inputSource: InputSourceConfig.optional().describe("Spec source for local-first work."),
836
+ models: ModelsConfigBase.partial().superRefine(refineRoleTiers).describe("Model routing: conductor override, per-role overrides, tier aliases, and role→tier policy.").optional(),
573
837
  refinement: RefinementConfig.partial().describe("Refinement fan-out and Copilot review-round behavior.").optional(),
574
838
  gates: FileGatesConfig.describe("Gate review configuration: per-gate angle sets plus fan-out enforcement knobs.").optional(),
575
839
  autonomy: AutonomyConfig.partial().describe("How far the loop proceeds without operator confirmation.").optional(),
@@ -577,28 +841,27 @@ export const FileConfigSchema = z.strictObject({
577
841
  workflow: WorkflowConfig.partial().describe("Workflow posture: draft-first, retrospectives, dev mode, async start.").optional(),
578
842
  localImplementation: LocalImplementationConfig.partial().describe("Local implementation dispatch (light mode for small scoped changes).").optional(),
579
843
  queue: QueueConfig.partial().describe("Queue mode: parallelism, auto-filing caps, and Projects board opt-in.").optional(),
580
- personas: FilePersonasConfig.describe("Gate-angle → reviewer persona registry overrides (angle name → persona, prompt, default model).").optional(),
844
+ tracker: TrackerConfig.partial().describe("Tracker seam config: provider (default \"github\") and board. The github provider's logical-column->Status mapping is the existing queue.statusColumns; a future external provider defines its own.").optional(),
581
845
  internalPathPatterns: InternalPatternsConfig.describe("Regex whitelist for internal-only PR detection.").optional(),
582
846
  worktree: WorktreeConfig.partial().describe("Worktree provisioning: gitignored files/dirs copied or symlinked into fresh worktrees.").optional(),
583
847
  uiReview: UiReviewConfig.partial().describe("UI-review route recipes: per-project run/boot, dev-login, driven flows, and caps.").optional(),
584
- // Deprecated (removed in #1088): tolerated so consumer .devloops files that
585
- // still carry a localPlanning block keep parsing. Accepted, never read.
586
- localPlanning: z.unknown().optional(),
848
+ // 1.0 hard break (no dual-form): the deprecated `localPlanning` key (removed
849
+ // behavior in #1088, tolerated-but-unread since) is dropped from the 1.0
850
+ // schema entirely — an unknown key now fails closed like any other typo,
851
+ // rather than silently parsing and doing nothing.
587
852
  });
588
853
 
589
854
  // ============================================================================
590
- // Built-in persona registry — fallback when config.personas is absent
855
+ // Built-in persona registry — fallback for gate-review angle → reviewer
856
+ // persona resolution.
591
857
  //
592
- // Maps gate-review angle names to reviewer personas. Only the persona name
593
- // is defined here; prompts and per-angle model defaults live in the config
594
- // (.pi/dev-loop/defaults.yaml personas section).
595
- //
596
- // Consumers can extend or override these by adding personas entries to
597
- // their .pi/dev-loop/defaults.* or settings.* config files (with legacy overrides.* fallback). Config-resolved
598
- // personas take priority over this built-in registry.
858
+ // Maps gate-review angle names to reviewer personas. Only the persona name is
859
+ // defined here; prompts and per-angle model overrides live on the angle's own
860
+ // config entry (gates.<gate>.angles[].persona/.prompt/.model/.tier) when a
861
+ // consumer wants to override this registry — see resolveReviewerRole.
599
862
  //
600
863
  // Angle names come from the gate-angle config (gates.draft.angles /
601
- // gates.preApproval.angles in .pi/dev-loop/defaults.yaml).
864
+ // gates.preApproval.angles in extension-defaults.yaml).
602
865
  // ============================================================================
603
866
 
604
867
  const BUILTIN_PERSONAS = Object.freeze({
@@ -641,17 +904,142 @@ const DEFAULT_REVIEWER_PERSONA = "default-reviewer";
641
904
  * @property {boolean} fallback - True when no specialized persona was found
642
905
  */
643
906
 
907
+ /**
908
+ * Normalize one raw `gates.<gate>.angles[]` entry (string sugar or object,
909
+ * possibly hand-built and never zod-validated — e.g. a test config object) to
910
+ * `{ name, mandatory?, enabled?, persona?, prompt?, model?, tier?, scope? }`.
911
+ * Returns null for a malformed/empty entry so callers can filter it out. An
912
+ * invalid `scope` (not one of GATE_ANGLE_SCOPES) is dropped rather than
913
+ * kept verbatim — resolveGateAngleScope's fail-open default only ever needs
914
+ * to handle an ABSENT field, never a foreign value.
915
+ * @param {unknown} a
916
+ * @returns {{name: string, mandatory?: boolean, enabled?: boolean, persona?: string, prompt?: string, model?: string, tier?: string, scope?: string}|null}
917
+ */
918
+ function normalizeAngleEntry(a) {
919
+ if (typeof a === "string") {
920
+ const name = a.trim();
921
+ return name.length > 0 ? { name } : null;
922
+ }
923
+ if (a && typeof a === "object" && !Array.isArray(a)) {
924
+ const name = typeof a.name === "string" ? a.name.trim() : "";
925
+ if (name.length === 0) return null;
926
+ const entry = { name };
927
+ if (a.mandatory === true) entry.mandatory = true;
928
+ if (a.enabled === false) entry.enabled = false;
929
+ if (typeof a.persona === "string" && a.persona.trim().length > 0) entry.persona = a.persona.trim();
930
+ if (typeof a.prompt === "string" && a.prompt.length > 0) entry.prompt = a.prompt;
931
+ if (typeof a.model === "string" && a.model.trim().length > 0) entry.model = a.model.trim();
932
+ if (typeof a.tier === "string" && a.tier.trim().length > 0) entry.tier = a.tier.trim();
933
+ if (typeof a.scope === "string" && GATE_ANGLE_SCOPES.includes(a.scope.trim())) entry.scope = a.scope.trim();
934
+ return entry;
935
+ }
936
+ return null;
937
+ }
938
+
939
+ /**
940
+ * Normalize a raw `gates.<gate>.angles` array into full entry objects,
941
+ * dropping malformed entries.
942
+ * @param {unknown} raw
943
+ * @returns {Array<{name: string, mandatory?: boolean, enabled?: boolean, persona?: string, prompt?: string, model?: string, tier?: string, scope?: string}>}
944
+ */
945
+ function normalizeAngleEntries(raw) {
946
+ if (!Array.isArray(raw)) return [];
947
+ const out = [];
948
+ for (const a of raw) {
949
+ const entry = normalizeAngleEntry(a);
950
+ if (entry) out.push(entry);
951
+ }
952
+ return out;
953
+ }
954
+
955
+ /**
956
+ * Find a named angle's configured entry, searching this config's own gates in
957
+ * a fixed priority order (draft, preApproval, spike). Angle persona/prompt/
958
+ * model/tier now live on the gate's own angle entry (D3/D4 — folded from the
959
+ * removed top-level `personas` map and angle-keyed `models.roles`/
960
+ * `models.roleTiers`), so a lookup by name alone (no gate context, matching
961
+ * `resolveReviewerRole`/`resolveRoleModel`'s existing signatures) checks each
962
+ * gate in turn and returns the first match. The shipped default config never
963
+ * gives the same angle name divergent overrides across gates, so this is
964
+ * unambiguous in practice.
965
+ *
966
+ * A DISABLED entry (`enabled: false`) is skipped, never returned: the same
967
+ * angle name can be a real, enabled angle with its own persona/prompt on one
968
+ * gate while merely disabled (a bare `enabled:false` placeholder, no override
969
+ * fields) on another — e.g. a gate that inherited the name via merge-by-name
970
+ * (D3) and dropped it. Returning that placeholder would shadow the other
971
+ * gate's real override. Both callers of this function (resolveReviewerRole,
972
+ * resolveRoleModel's angle path) only ever look up a name already present in
973
+ * SOME gate's enabled, resolved angle list (`resolveGateAngles`), so a name
974
+ * disabled everywhere and enabled nowhere is never actually queried — there
975
+ * is no "return the disabled entry as a last resort" case to serve.
976
+ * @param {DevLoopConfig} config
977
+ * @param {string} name
978
+ * @returns {{name: string, mandatory?: boolean, enabled?: boolean, persona?: string, prompt?: string, model?: string, tier?: string}|null}
979
+ */
980
+ function findAngleEntry(config, name) {
981
+ for (const gate of ["draft", "preApproval", "spike"]) {
982
+ const entries = normalizeAngleEntries(config?.gates?.[gate]?.angles);
983
+ const found = entries.find((e) => e.name === name && e.enabled !== false);
984
+ if (found) return found;
985
+ }
986
+ return null;
987
+ }
988
+
989
+ /**
990
+ * Resolve a gate angle's declared surface scope (AC3, #1572): "full"
991
+ * (default), "changed-files", or "docs-only" — see GATE_ANGLE_SCOPES. Unlike
992
+ * {@link findAngleEntry} (which searches every gate in a fixed priority
993
+ * order because persona/prompt resolution has no gate context), this looks up
994
+ * the entry within the ONE named gate — an angle's scope is meaningful only
995
+ * for the specific gate pass building its briefing. Fails open to "full" for
996
+ * an angle with no configured entry, a disabled entry, or an
997
+ * unknown/malformed `scope` value: a narrow scope is an opt-in cost saving,
998
+ * never a silently-enforced information cut.
999
+ * @param {DevLoopConfig} config
1000
+ * @param {"draft"|"preApproval"|"spike"} gate
1001
+ * @param {string} name
1002
+ * @returns {"full"|"changed-files"|"docs-only"}
1003
+ */
1004
+ export function resolveGateAngleScope(config, gate, name) {
1005
+ const entries = normalizeAngleEntries(config?.gates?.[gate]?.angles);
1006
+ const found = entries.find((e) => e.name === name && e.enabled !== false);
1007
+ return found?.scope ?? "full";
1008
+ }
1009
+
1010
+ /**
1011
+ * Resolve a tier alias to its per-harness concrete model, or `null`
1012
+ * (`inherit`/unmapped/absent → no override). Deep-merges the alias mapping so
1013
+ * a partial config override (e.g. `{ pi: "..." }`) preserves the untouched
1014
+ * built-in harness key rather than erasing the whole `{claude,pi}` mapping.
1015
+ * @param {DevLoopConfig} config
1016
+ * @param {string|undefined} tierAlias
1017
+ * @param {"claude"|"pi"} harness
1018
+ * @returns {string|null}
1019
+ */
1020
+ function resolveTierMapping(config, tierAlias, harness) {
1021
+ if (!tierAlias || tierAlias === "inherit") return null;
1022
+ const builtinMapping = BUILTIN_TIERS[tierAlias];
1023
+ const configMapping = config?.models?.tiers?.[tierAlias];
1024
+ if (!builtinMapping && !configMapping) return null;
1025
+ const mapping = { ...builtinMapping, ...configMapping };
1026
+ const model = mapping[harness];
1027
+ return typeof model === "string" && model.trim().length > 0 ? model.trim() : null;
1028
+ }
1029
+
644
1030
  /**
645
1031
  * Resolve a gate angle name to a reviewer persona and model.
646
1032
  *
647
1033
  * Resolution order:
648
- * 1. Look up angle in config.personas[angle] (consumer overrides)
1034
+ * 1. Look up the angle's own configured entry across this config's gates
1035
+ * (`gates.<gate>.angles[].persona`/`.prompt`/`.model` — consumer overrides,
1036
+ * see {@link findAngleEntry})
649
1037
  * 2. If not found in config, look up in BUILTIN_PERSONAS
650
- * 3. If found in either, apply model override from config.models.roles[angle] if present
1038
+ * 3. If found in either, apply the entry's `model` override if present
651
1039
  * 4. If not found anywhere, fall back to default reviewer with angle as focus lens,
652
- * still applying any model override from config
1040
+ * still applying any `model` override from the entry
653
1041
  *
654
- * @param {object} config - DevLoopConfig (or partial with personas, models.roles)
1042
+ * @param {object} config - DevLoopConfig (or a partial with gates)
655
1043
  * @param {string|null|undefined} angle - Gate angle / lens name
656
1044
  * @returns {RoleResolutionResult}
657
1045
  */
@@ -666,17 +1054,16 @@ export function resolveReviewerRole(config, angle) {
666
1054
  };
667
1055
  }
668
1056
 
669
- // Resolution: config.personas > BUILTIN_PERSONAS > default-reviewer
670
- const configPersona = config?.personas?.[angle] ?? null;
1057
+ const entry = findAngleEntry(config, angle);
671
1058
  const builtinPersona = BUILTIN_PERSONAS[angle] ?? null;
672
- const persona = configPersona ?? builtinPersona;
673
- const modelOverride = config?.models?.roles?.[angle] || null;
1059
+ const personaName = entry?.persona ?? builtinPersona?.persona ?? null;
1060
+ const modelOverride = entry?.model ?? null;
674
1061
 
675
- if (persona) {
1062
+ if (personaName) {
676
1063
  return {
677
- persona: persona.persona,
678
- model: modelOverride || persona.defaultModel || null,
679
- prompt: persona.prompt || null,
1064
+ persona: personaName,
1065
+ model: modelOverride || builtinPersona?.defaultModel || null,
1066
+ prompt: entry?.prompt ?? null,
680
1067
  fallback: false,
681
1068
  };
682
1069
  }
@@ -695,19 +1082,20 @@ export function resolveReviewerRole(config, angle) {
695
1082
  * `null` (inherit → pass no model override).
696
1083
  *
697
1084
  * Precedence:
698
- * 1. `models.roles[role]` — concrete per-role/angle override (highest).
699
- * 2. Tier alias, mapped through `models.tiers[tier][harness]` (or built-in
700
- * tiers); `inherit`/absent/null → `null`. The alias depends on `kind`:
701
- * - `kind: "angle"` (gate review dispatch): an explicit
702
- * `models.roleTiers[role]` override, else the `review` tier. A gate
703
- * review runs at review quality even when the angle's name collides with
704
- * a routine role — e.g. the `docs` angle resolves via the `review` tier
705
- * (high), not the `docs` writer role's low tier. (Its persona/agent still
706
- * comes from `resolveReviewerRole`; only the tier is forced to review.)
707
- * - `kind: "role"`/absent (routine subagent): `models.roleTiers[role]` (or
708
- * the built-in role tier), else — when the name is not a named role — the
709
- * tier for its review persona (so a non-colliding gate angle passed
710
- * without `kind` still resolves high via `review`).
1085
+ * 1. `kind: "angle"` (gate review dispatch): the angle's own configured
1086
+ * `model` (concrete, found via {@link findAngleEntry}), else its `tier`,
1087
+ * else the built-in `review` tier — a gate review runs at review quality
1088
+ * even when the angle's name collides with a routine role, e.g. the
1089
+ * `docs` angle resolves via the `review` tier (high), not the `docs`
1090
+ * writer role's low tier. (Its persona/agent still comes from
1091
+ * `resolveReviewerRole`; only the tier is forced to review.)
1092
+ * 2. `kind: "role"`/absent (routine subagent): `models.roles[role]`
1093
+ * (concrete, highest precedence), else `models.roleTiers[role]` (or the
1094
+ * built-in role tier) mapped through `models.tiers[tier][harness]` (or
1095
+ * built-in tiers); `inherit`/absent/null → `null`. When the name is not a
1096
+ * named role, falls back to the tier for its review persona (so a
1097
+ * non-colliding gate angle passed without `kind` still resolves high via
1098
+ * `review`).
711
1099
  *
712
1100
  * Callers dispatching a gate review angle whose name may collide with a routine
713
1101
  * role (only `docs` today) MUST pass `kind: "angle"` to avoid the silent
@@ -724,40 +1112,31 @@ export function resolveReviewerRole(config, angle) {
724
1112
  export function resolveRoleModel(config, { role, harness, kind } = {}) {
725
1113
  if (!role || (harness !== "claude" && harness !== "pi")) return null;
726
1114
 
727
- // 1. Concrete per-role/angle override wins outright (over any tier).
1115
+ if (kind === "angle") {
1116
+ const entry = findAngleEntry(config, role);
1117
+ if (typeof entry?.model === "string" && entry.model.length > 0) return entry.model;
1118
+ const tierAlias = entry?.tier ?? BUILTIN_ROLE_TIERS.review;
1119
+ return resolveTierMapping(config, tierAlias, harness);
1120
+ }
1121
+
1122
+ // 1. Concrete per-role override wins outright (over any tier). Role-keyed
1123
+ // only — angle-keyed concrete overrides moved to the gate's angle entry
1124
+ // (kind: "angle", above).
728
1125
  const concrete = config?.models?.roles?.[role];
729
1126
  if (typeof concrete === "string" && concrete.trim().length > 0) {
730
1127
  return concrete.trim();
731
1128
  }
732
1129
 
733
- // 2. Resolve a tier alias for this role/angle.
1130
+ // 2. Resolve a tier alias for this role.
734
1131
  const roleTiers = { ...BUILTIN_ROLE_TIERS, ...(config?.models?.roleTiers ?? {}) };
735
- let tierAlias;
736
- if (kind === "angle") {
737
- // Gate review angle: an explicit per-angle override wins, else the review
738
- // tier — a gate review is review-quality regardless of a coincidental
739
- // routine-role persona name (the `docs` angle must not inherit `docs`→low).
740
- tierAlias = config?.models?.roleTiers?.[role] ?? roleTiers.review;
741
- } else {
742
- tierAlias = roleTiers[role];
743
- if (tierAlias === undefined) {
744
- // Not a named role — treat as a gate angle and inherit its review
745
- // persona's tier (critical angles resolve high via the `review` persona).
746
- const { persona } = resolveReviewerRole(config, role);
747
- tierAlias = roleTiers[persona];
748
- }
1132
+ let tierAlias = roleTiers[role];
1133
+ if (tierAlias === undefined) {
1134
+ // Not a named role — treat as a gate angle and inherit its review
1135
+ // persona's tier (critical angles resolve high via the `review` persona).
1136
+ const { persona } = resolveReviewerRole(config, role);
1137
+ tierAlias = roleTiers[persona];
749
1138
  }
750
- if (!tierAlias || tierAlias === "inherit") return null;
751
-
752
- // Deep-merge the alias mapping so a partial override (e.g. `{ pi: "..." }`,
753
- // which the schema allows) preserves the untouched built-in harness key rather
754
- // than erasing the whole {claude,pi} mapping and resolving null for that harness.
755
- const builtinMapping = BUILTIN_TIERS[tierAlias];
756
- const configMapping = config?.models?.tiers?.[tierAlias];
757
- if (!builtinMapping && !configMapping) return null;
758
- const mapping = { ...builtinMapping, ...configMapping };
759
- const model = mapping[harness];
760
- return typeof model === "string" && model.trim().length > 0 ? model.trim() : null;
1139
+ return resolveTierMapping(config, tierAlias, harness);
761
1140
  }
762
1141
 
763
1142
  // ============================================================================
@@ -789,11 +1168,16 @@ function resolveExtensionDefaultsPath(options = {}) {
789
1168
 
790
1169
  // ============================================================================
791
1170
 
1171
+ /** True for a non-null, non-array plain object. */
1172
+ function isPlainObject(v) {
1173
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1174
+ }
1175
+
792
1176
  /**
793
1177
  * Merge two config objects. Keys in `source` override keys in `target`.
794
1178
  * Family objects merge at one level, except `gates`, which merges one extra
795
1179
  * nested gate-object level so settings can override `draft.requireCi` without
796
- * restating the shipped draft angles.
1180
+ * restating the shipped draft angles (see {@link mergeGatesFamily}).
797
1181
  * @param {Record<string, unknown>} target
798
1182
  * @param {Record<string, unknown>} source
799
1183
  * @returns {Record<string, unknown>}
@@ -801,17 +1185,9 @@ function resolveExtensionDefaultsPath(options = {}) {
801
1185
  function mergeConfigLayers(target, source) {
802
1186
  const result = { ...target };
803
1187
  for (const key of Object.keys(source)) {
804
- if (
805
- key !== "version" &&
806
- typeof source[key] === "object" &&
807
- source[key] !== null &&
808
- !Array.isArray(source[key]) &&
809
- typeof result[key] === "object" &&
810
- result[key] !== null &&
811
- !Array.isArray(result[key])
812
- ) {
1188
+ if (key !== "version" && isPlainObject(source[key]) && isPlainObject(result[key])) {
813
1189
  result[key] = key === "gates"
814
- ? mergeNestedObject(result[key], source[key])
1190
+ ? mergeGatesFamily(result[key], source[key])
815
1191
  : { ...(result[key] || {}), ...(source[key] || {}) };
816
1192
  } else {
817
1193
  result[key] = source[key];
@@ -820,27 +1196,70 @@ function mergeConfigLayers(target, source) {
820
1196
  return result;
821
1197
  }
822
1198
 
823
- function mergeNestedObject(target, source) {
824
- const result = { ...(target || {}) };
1199
+ const MERGE_BY_NAME_GATE_KEYS = Object.freeze(["draft", "preApproval", "spike"]);
825
1200
 
1201
+ /** Merge the `gates` family: draft/preApproval/spike get the gate-object merge
1202
+ * ({@link mergeGateObject}, angle-array-by-name aware); every other `gates.*`
1203
+ * key (`anglePool`, `requireFanoutEvidence`, ...) merges shallowly as before. */
1204
+ function mergeGatesFamily(target, source) {
1205
+ const result = { ...(target || {}) };
826
1206
  for (const key of Object.keys(source || {})) {
827
- if (
828
- typeof source[key] === "object" &&
829
- source[key] !== null &&
830
- !Array.isArray(source[key]) &&
831
- typeof result[key] === "object" &&
832
- result[key] !== null &&
833
- !Array.isArray(result[key])
834
- ) {
1207
+ if (MERGE_BY_NAME_GATE_KEYS.includes(key) && isPlainObject(source[key]) && isPlainObject(result[key])) {
1208
+ result[key] = mergeGateObject(result[key], source[key]);
1209
+ } else if (isPlainObject(source[key]) && isPlainObject(result[key])) {
835
1210
  result[key] = { ...(result[key] || {}), ...(source[key] || {}) };
836
1211
  } else {
837
1212
  result[key] = source[key];
838
1213
  }
839
1214
  }
1215
+ return result;
1216
+ }
840
1217
 
1218
+ /**
1219
+ * Merge one gate object (draft/preApproval/spike) across config layers.
1220
+ * `angles` merges BY NAME (D3): a later layer can add a new angle, or override
1221
+ * an existing angle's flags (including `enabled: false` to drop it), without
1222
+ * restating the whole array. `dynamic` merges shallowly (its two booleans).
1223
+ * Every other key (`required`, `requireCi`, `blockCleanOnFindingSeverities`)
1224
+ * is replaced wholesale, same as any scalar/array config value.
1225
+ */
1226
+ function mergeGateObject(target, source) {
1227
+ const result = { ...(target || {}) };
1228
+ for (const key of Object.keys(source || {})) {
1229
+ if (key === "angles") {
1230
+ result.angles = mergeAngleArrays(result.angles, source.angles);
1231
+ } else if (key === "dynamic" && isPlainObject(source.dynamic) && isPlainObject(result.dynamic)) {
1232
+ result.dynamic = { ...(result.dynamic || {}), ...(source.dynamic || {}) };
1233
+ } else {
1234
+ result[key] = source[key];
1235
+ }
1236
+ }
841
1237
  return result;
842
1238
  }
843
1239
 
1240
+ /**
1241
+ * Merge two `gates.<gate>.angles` arrays BY `name` (D3): entries in `target`
1242
+ * keep their position; a `source` entry with a name already in `target`
1243
+ * overrides that entry's fields (shallow — e.g. `{ enabled: false }` drops it
1244
+ * without touching its `persona`/`prompt`); a `source` entry with a new name
1245
+ * is appended. This is what lets a later config layer add or disable a single
1246
+ * angle without restating the whole upstream list.
1247
+ * @param {unknown} targetRaw
1248
+ * @param {unknown} sourceRaw
1249
+ * @returns {Array<{name: string}>}
1250
+ */
1251
+ function mergeAngleArrays(targetRaw, sourceRaw) {
1252
+ const targetEntries = normalizeAngleEntries(targetRaw);
1253
+ const sourceEntries = normalizeAngleEntries(sourceRaw);
1254
+ if (targetEntries.length === 0) return sourceEntries;
1255
+ const byName = new Map(targetEntries.map((e) => [e.name, e]));
1256
+ for (const entry of sourceEntries) {
1257
+ const existing = byName.get(entry.name);
1258
+ byName.set(entry.name, existing ? { ...existing, ...entry } : entry);
1259
+ }
1260
+ return [...byName.values()];
1261
+ }
1262
+
844
1263
  /**
845
1264
  * Try to read and parse a config file (YAML preferred, JSON fallback).
846
1265
  * Detects format from file extension: .yaml/.yml → YAML, .json → JSON.
@@ -975,7 +1394,41 @@ async function applyLayer(merged, basePaths, layer, warnings, errors, options =
975
1394
  return merged;
976
1395
  }
977
1396
 
978
- // Validate the file's structure before merging
1397
+ // Deprecated `strategy: "github-first"` alias (issue #1408, the
1398
+ // tracker-agnostic seam): normalized to "tracker-first" BEFORE this layer's
1399
+ // own FileConfigSchema validation, since the schema enum only accepts the
1400
+ // canonical value and would otherwise drop the whole layer as invalid.
1401
+ if (data.strategy === "github-first") {
1402
+ warnings.push(
1403
+ `strategy: "github-first" is a deprecated alias for "tracker-first" (issue #1408). ` +
1404
+ `Update ${path.basename(filePath)} to use "tracker-first"; the alias will be removed in a future version.`
1405
+ );
1406
+ data = { ...data, strategy: "tracker-first" };
1407
+ }
1408
+
1409
+ // Removed `gates.primeSharedPrefix` (#1462): GATE-EXEC-PRIME cache priming is
1410
+ // now mandatory, not a knob. The schema is strictObject, so a stale key would
1411
+ // otherwise drop the WHOLE gates layer as invalid. Strip it before validation
1412
+ // with a deprecation warning — old configs keep loading; priming happens
1413
+ // unconditionally regardless of the removed value.
1414
+ if (data?.gates && Object.prototype.hasOwnProperty.call(data.gates, "primeSharedPrefix")) {
1415
+ warnings.push(
1416
+ `gates.primeSharedPrefix is removed (#1462): cache priming is now mandatory, not configurable. ` +
1417
+ `Remove it from ${path.basename(filePath)}; the key is ignored.`
1418
+ );
1419
+ const { primeSharedPrefix: _removed, ...gatesRest } = data.gates;
1420
+ data = { ...data, gates: gatesRest };
1421
+ }
1422
+
1423
+ // Validate the file's structure before merging. Pre-existing behavior
1424
+ // (unrelated to the #1404 angle-entry redesign): a schema violation ANYWHERE
1425
+ // in this layer's file drops the WHOLE layer (errors is populated, `merged`
1426
+ // is returned unchanged) rather than merging the rest of the file's valid
1427
+ // keys — a single typo'd angle field is exactly as disruptive as a
1428
+ // completely broken file. `errors[].message` now names the offending
1429
+ // path/field (see GateAngleEntry's preprocess-not-union shape), so the
1430
+ // failure is at least actionable; the whole-layer-skip granularity itself
1431
+ // is an existing, separate concern.
979
1432
  const validation = FileConfigSchema.safeParse(data);
980
1433
  if (!validation.success) {
981
1434
  errors.push({
@@ -1119,6 +1572,20 @@ export async function loadDevLoopConfig(options = {}) {
1119
1572
  }
1120
1573
  }
1121
1574
 
1575
+ // Deprecated `queue.board` -> `tracker.board` alias (issue #1408, the
1576
+ // tracker-agnostic seam). Runs on the fully-merged object (unlike the
1577
+ // `strategy: "github-first"` alias above, this only affects cross-layer
1578
+ // MERGE PRECEDENCE, not per-layer schema validity — queue.board is still a
1579
+ // valid FileConfigSchema shape on its own — so normalizing once here, after
1580
+ // every layer has merged, is sufficient).
1581
+ if (isPlainObject(merged.queue?.board) && !isPlainObject(merged.tracker?.board)) {
1582
+ warnings.push(
1583
+ `queue.board is a deprecated alias for tracker.board (issue #1408). ` +
1584
+ `Update .devloops to set tracker.board instead; the alias will be removed in a future version.`
1585
+ );
1586
+ merged = { ...merged, tracker: { ...(merged.tracker ?? {}), board: merged.queue.board } };
1587
+ }
1588
+
1122
1589
  // Validate final merged config
1123
1590
  const result = DevLoopConfigSchema.safeParse(merged);
1124
1591
  if (!result.success) {
@@ -1265,15 +1732,15 @@ export function resolveRefinementConfig(config, key) {
1265
1732
  }
1266
1733
 
1267
1734
  if (key === "stopOnLowSignal") {
1268
- return config?.refinement?.stopOnLowSignal ?? DEFAULT_REFINEMENT_CONFIG.stopOnLowSignal;
1735
+ return config?.refinement?.lowSignal?.enabled ?? DEFAULT_REFINEMENT_CONFIG.lowSignal.enabled;
1269
1736
  }
1270
1737
 
1271
1738
  if (key === "lowSignalRoundThreshold") {
1272
- return config?.refinement?.lowSignalRoundThreshold ?? DEFAULT_REFINEMENT_CONFIG.lowSignalRoundThreshold;
1739
+ return config?.refinement?.lowSignal?.roundThreshold ?? DEFAULT_REFINEMENT_CONFIG.lowSignal.roundThreshold;
1273
1740
  }
1274
1741
 
1275
1742
  if (key === "lowSignalMaxComments") {
1276
- return config?.refinement?.lowSignalMaxComments ?? DEFAULT_REFINEMENT_CONFIG.lowSignalMaxComments;
1743
+ return config?.refinement?.lowSignal?.maxComments ?? DEFAULT_REFINEMENT_CONFIG.lowSignal.maxComments;
1277
1744
  }
1278
1745
 
1279
1746
  throw new Error(`Unknown refinement config key: ${key}`);
@@ -1316,29 +1783,46 @@ export function resolveRefinement(config) {
1316
1783
  * config omits them (caller falls back to skill-defined defaults). Boolean gate
1317
1784
  * flags always resolve to stable defaults.
1318
1785
  *
1786
+ * The returned shape is the STABLE, resolved view every other angle resolver
1787
+ * and consumer builds on — `mandatoryAngles`/`excludeAngles`/`dynamicAngles`/
1788
+ * `additiveAngles` are derived here from the unified `gates.<gate>.angles`
1789
+ * array (`mandatory: true` / `enabled: false` per-entry, D3) and the
1790
+ * `gates.<gate>.dynamic` sub-object, so downstream consumers keep reading the
1791
+ * same field names the pre-1.0 flat config keys used. (`extraAngles` no
1792
+ * longer exists as a concept: D3's merge-by-name lets a later config layer add
1793
+ * a plain, non-mandatory angle to `angles` directly, without restating the
1794
+ * list — the exact ergonomic `extraAngles` used to provide.)
1795
+ *
1319
1796
  * @param {DevLoopConfig} config
1320
1797
  * @param {"draft"|"preApproval"|"spike"} gate
1321
- * @returns {{ angles: string[]|null, excludeAngles: string[], mandatoryAngles: string[], required: boolean, requireCi: boolean, blockCleanOnFindingSeverities: string[], dynamicAngles: boolean, additiveAngles: boolean }}
1798
+ * @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[]}> }}
1322
1799
  */
1323
1800
  export function resolveGateConfig(config, gate) {
1324
1801
  const gateConfig = config?.gates?.[gate];
1802
+ const entries = normalizeAngleEntries(gateConfig?.angles);
1803
+ // An explicitly-empty (or all-garbage/malformed) array is a real configured
1804
+ // "no angles" — distinct from the key being absent entirely, which callers
1805
+ // read as "fall back to skill-defined defaults" (angles: null).
1806
+ const hasAngles = Array.isArray(gateConfig?.angles);
1325
1807
  return {
1326
- angles: gateConfig?.angles && Array.isArray(gateConfig.angles)
1327
- ? gateConfig.angles.map(a => (typeof a === "string" ? a.trim() : "")).filter(a => a.length > 0)
1328
- : null,
1329
- excludeAngles: gateConfig?.excludeAngles && Array.isArray(gateConfig.excludeAngles)
1330
- ? gateConfig.excludeAngles.map(a => (typeof a === "string" ? a.trim() : "")).filter(a => a.length > 0)
1331
- : [],
1332
- mandatoryAngles: gateConfig?.mandatoryAngles && Array.isArray(gateConfig.mandatoryAngles)
1333
- ? gateConfig.mandatoryAngles.map(a => (typeof a === "string" ? a.trim() : "")).filter(a => a.length > 0)
1334
- : [],
1808
+ angles: hasAngles ? entries.filter((e) => e.enabled !== false).map((e) => e.name) : null,
1809
+ excludeAngles: entries.filter((e) => e.enabled === false).map((e) => e.name),
1810
+ mandatoryAngles: entries.filter((e) => e.enabled !== false && e.mandatory === true).map((e) => e.name),
1335
1811
  required: gateConfig?.required ?? true,
1336
1812
  requireCi: gateConfig?.requireCi ?? true,
1337
- dynamicAngles: gateConfig?.dynamicAngles ?? false,
1338
- additiveAngles: gateConfig?.additiveAngles ?? false,
1813
+ dynamicAngles: gateConfig?.dynamic?.subtractive ?? true,
1814
+ additiveAngles: gateConfig?.dynamic?.additive ?? false,
1815
+ // Normalized + deduped at the resolve boundary so every consumer (envelope,
1816
+ // verdict poster, fan-in, viewer) sees canonical spellings only; a
1817
+ // half-migrated ["must-fix","low","defer"] collapses to two entries.
1339
1818
  blockCleanOnFindingSeverities: gateConfig?.blockCleanOnFindingSeverities && Array.isArray(gateConfig.blockCleanOnFindingSeverities)
1340
- ? [...gateConfig.blockCleanOnFindingSeverities]
1341
- : ["must-fix"],
1819
+ ? [...new Set(gateConfig.blockCleanOnFindingSeverities.map((s) => normalizeSeverity(s)))]
1820
+ : ["high"],
1821
+ // `mediumFixWindow` wins; `worthFixingNowFixWindow` is the deprecated
1822
+ // pre-rename key, still honored so an unmigrated config keeps its
1823
+ // configured window rather than silently reverting to the default.
1824
+ mediumFixWindow: gateConfig?.mediumFixWindow ?? gateConfig?.worthFixingNowFixWindow ?? 3,
1825
+ tiers: gateConfig?.tiers ?? [],
1342
1826
  };
1343
1827
  }
1344
1828
 
@@ -1351,7 +1835,7 @@ export function resolveGateConfig(config, gate) {
1351
1835
  * a durable findings-log ledger exists for that gate + head SHA. Using a
1352
1836
  * `!== false` test (rather than `=== true`) keeps the opt-out semantics robust
1353
1837
  * for programmatically-built config objects that bypass schema defaulting. See
1354
- * docs/gate-review-sub-loop-contract.md.
1838
+ * skills/docs/gate-review-sub-loop-contract.md.
1355
1839
  *
1356
1840
  * @param {DevLoopConfig} config
1357
1841
  * @returns {boolean}
@@ -1361,11 +1845,12 @@ export function resolveRequireFanoutEvidence(config) {
1361
1845
  }
1362
1846
 
1363
1847
  /**
1364
- * Minimum distinct reviewer count for a fanout_fanin ledger to satisfy
1365
- * requireFanoutProvenance. A floor of 2 is the smallest count that is not a
1366
- * single agent; it raises the bar but does not prove independence (provenance
1367
- * is self-reported — see the honest caveat in
1368
- * docs/gate-review-sub-loop-contract.md).
1848
+ * ABSOLUTE minimum distinct reviewer count for a fanout_fanin ledger to
1849
+ * satisfy requireFanoutProvenance; the effective read-time floor scales to
1850
+ * max(this, the ledger's fresh-angle count). A floor of 2 is the smallest
1851
+ * count that is not a single agent; it raises the bar but does not prove
1852
+ * independence (provenance is self-reported — see the honest caveat in
1853
+ * skills/docs/gate-review-sub-loop-contract.md).
1369
1854
  */
1370
1855
  export const FANOUT_PROVENANCE_MIN_REVIEWERS = 2;
1371
1856
 
@@ -1377,7 +1862,7 @@ export const FANOUT_PROVENANCE_MIN_REVIEWERS = 2;
1377
1862
  * `=== true` test so behavior is byte-identical to today unless a repo
1378
1863
  * explicitly opts in via `gates.requireFanoutProvenance: true`. Layered on top
1379
1864
  * of fan-out evidence enforcement (see buildFanoutEnforcement). See
1380
- * docs/gate-review-sub-loop-contract.md.
1865
+ * skills/docs/gate-review-sub-loop-contract.md.
1381
1866
  *
1382
1867
  * @param {DevLoopConfig} config
1383
1868
  * @returns {boolean}
@@ -1398,21 +1883,21 @@ export function resolveRejectForeignAngles(config) {
1398
1883
  }
1399
1884
 
1400
1885
  /**
1401
- * Resolve whether the consolidated gate fan-out findings should be posted as a
1402
- * visible, marker-tagged PR comment.
1886
+ * Resolve whether the consolidated gate fan-out findings should ALSO be posted
1887
+ * as a second visible, marker-tagged PR comment.
1403
1888
  *
1404
- * Returns true (post the comment) unless `gates.postFindingsComments` is
1405
- * explicitly set to false. Using a `!== false` test (rather than `=== true`)
1406
- * keeps the opt-out semantics robust for programmatically-built config objects
1407
- * that bypass schema defaulting. The disposition ledger is written regardless;
1408
- * this flag only suppresses the auditable PR comment. See
1409
- * docs/gate-review-sub-loop-contract.md.
1889
+ * Returns false unless `gates.postFindingsComments` is explicitly set to true.
1890
+ * The round's verdict review is already the findings surface
1891
+ * (`GATE-COMMENT-SINGLE-SURFACE`), so this comment is opt-in duplication; the
1892
+ * `=== true` test keeps that opt-in semantics for programmatically-built config
1893
+ * objects that bypass schema defaulting. The disposition ledger is written
1894
+ * regardless. See skills/docs/gate-review-sub-loop-contract.md.
1410
1895
  *
1411
1896
  * @param {DevLoopConfig} config
1412
1897
  * @returns {boolean}
1413
1898
  */
1414
1899
  export function resolveGatePostFindingsComments(config) {
1415
- return config?.gates?.postFindingsComments !== false;
1900
+ return config?.gates?.postFindingsComments === true;
1416
1901
  }
1417
1902
 
1418
1903
  /**
@@ -1440,14 +1925,14 @@ export function resolveLightMode(config) {
1440
1925
  /**
1441
1926
  * Resolve the issue-less PR-first any-scope opt-in (#1349).
1442
1927
  *
1443
- * True only when `localImplementation.issueless.enabled` is exactly `true`;
1444
- * absent, false, or malformed values resolve to false (fail closed).
1928
+ * True only when `localImplementation.issueless` is exactly `true`; absent,
1929
+ * false, or malformed values resolve to false (fail closed).
1445
1930
  *
1446
1931
  * @param {DevLoopConfig} config
1447
1932
  * @returns {boolean}
1448
1933
  */
1449
1934
  export function resolveIssuelessEnabled(config) {
1450
- return config?.localImplementation?.issueless?.enabled === true;
1935
+ return config?.localImplementation?.issueless === true;
1451
1936
  }
1452
1937
 
1453
1938
  /**
@@ -1523,21 +2008,197 @@ export function resolveGateDispatchMode(config, gate, { scope, hasFullLabel = fa
1523
2008
  return { mode: "full_fanout", reason: "over_threshold", threshold };
1524
2009
  }
1525
2010
  if (Array.isArray(inlineFindingSeverities) && inlineFindingSeverities.length > 0) {
1526
- const blocking = new Set(resolveGateConfig(config, gate).blockCleanOnFindingSeverities);
1527
- if (inlineFindingSeverities.some((s) => blocking.has(s))) {
2011
+ // Both sides normalize legacy spellings so a "defer" finding still
2012
+ // compares against a "low" blocking entry and vice versa.
2013
+ const blocking = new Set(resolveGateConfig(config, gate).blockCleanOnFindingSeverities.map((s) => normalizeSeverity(s)));
2014
+ if (inlineFindingSeverities.some((s) => blocking.has(normalizeSeverity(s)))) {
1528
2015
  return { mode: "full_fanout", reason: "escalated", threshold };
1529
2016
  }
1530
2017
  }
1531
2018
  return { mode: "inline", reason: "under_threshold", threshold };
1532
2019
  }
1533
2020
 
2021
+ /**
2022
+ * Default auto-chunk size for ungrouped angles (issue #1601). Mirrors the
2023
+ * zod default on `gates.fanout.maxAnglesPerGroup`.
2024
+ */
2025
+ export const DEFAULT_MAX_ANGLES_PER_GROUP = 3;
2026
+
2027
+ /**
2028
+ * Default concurrent-dispatch-unit cap per wave (issue #1601). Mirrors the
2029
+ * zod default on `gates.fanout.maxConcurrent`; consumed by
2030
+ * `scheduleFanoutWaves` (@dev-loops/core/loop/gate-fanin).
2031
+ */
2032
+ export const DEFAULT_FANOUT_MAX_CONCURRENT = 4;
2033
+
2034
+ /**
2035
+ * Resolve `gates.fanout.maxAnglesPerGroup` (issue #1601, default 3, min 1).
2036
+ * The number of ungrouped angles auto-chunked into one dispatch unit.
2037
+ * Defensive, independent of zod: a non-integer or sub-1 value falls back to
2038
+ * the built-in default so a malformed raw merged config (which zod may have
2039
+ * rejected at load time while still returning it) never crashes Phase 2.
2040
+ * @param {DevLoopConfig} config
2041
+ * @returns {number}
2042
+ */
2043
+ export function resolveMaxAnglesPerGroup(config) {
2044
+ const n = config?.gates?.fanout?.maxAnglesPerGroup;
2045
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 1) return DEFAULT_MAX_ANGLES_PER_GROUP;
2046
+ return n;
2047
+ }
2048
+
2049
+ /**
2050
+ * Resolve `gates.fanout.maxConcurrent` (issue #1601, default 4, min 1). The
2051
+ * max dispatch units (groups) the conductor dispatches concurrently per wave.
2052
+ * Defensive, independent of zod (same rationale as resolveMaxAnglesPerGroup).
2053
+ * @param {DevLoopConfig} config
2054
+ * @returns {number}
2055
+ */
2056
+ export function resolveFanoutMaxConcurrent(config) {
2057
+ const m = config?.gates?.fanout?.maxConcurrent;
2058
+ if (typeof m !== "number" || !Number.isInteger(m) || m < 1) return DEFAULT_FANOUT_MAX_CONCURRENT;
2059
+ return m;
2060
+ }
2061
+
2062
+ /**
2063
+ * Resolve grouped fan-out dispatch (AC6 + #1601 two-knob dispatch bounds):
2064
+ * map a round's resolved review angles onto the dispatch units it actually
2065
+ * dispatches.
2066
+ *
2067
+ * Dispatch shape precedence (first match wins):
2068
+ * 1. `gates.fanout.mode === "per-angle"` → bypasses configured groups; one
2069
+ * singleton unit per angle (the original one-reviewer-per-angle fan-out;
2070
+ * NOT equivalent to maxAnglesPerGroup: 1 when configured groups match)
2071
+ * 2. otherwise (default `grouped`) → configured `gates.fanout.groups` are
2072
+ * matched first (unchanged), then the leftover ungrouped angles are
2073
+ * auto-chunked into dispatch units of ≤ `maxAnglesPerGroup` (default 3)
2074
+ * instead of singletons.
2075
+ *
2076
+ * `gate:full` (`options.fullLabel`) NO LONGER restores per-angle dispatch
2077
+ * (ADR 0047 superseded by 0048): `gate:full` keeps forcing the full angle set
2078
+ * UPSTREAM (resolveGateTier returns `gate_full_label`, so resolveGateAnglesDynamic
2079
+ * skips diff-class tier reduction) and dispatches GROUPED here. The `fullLabel`
2080
+ * parameter is retained on the signature (callers thread it) but no longer
2081
+ * changes the dispatch shape — it is a no-op here, kept only to avoid a breaking
2082
+ * API change to the exported resolver; its angle-set effect lives upstream.
2083
+ *
2084
+ * A configured group is included only when at least one of its angles is in
2085
+ * `resolvedAngles` this round — an unmatched group is dropped, never emitted
2086
+ * empty. Configured groups are NEVER split by `maxAnglesPerGroup` (the knob
2087
+ * chunks only the leftover ungrouped pool). Each reviewer still writes ONE
2088
+ * artifact per angle at the existing per-angle paths; grouping only changes how
2089
+ * many reviewers are dispatched, not the artifact shape (see
2090
+ * skills/docs/gate-review-sub-loop-contract.md).
2091
+ *
2092
+ * Auto-chunk unit names are deterministic and stable (issue #1601): a
2093
+ * single-angle leftover chunk is named by its angle (collisions with an emitted
2094
+ * group name disambiguated to `angle:<name>`, preserving the pre-#1601
2095
+ * singleton convention); a multi-angle chunk is named `group:<a>+<b>+<c>` from
2096
+ * its deterministically-ordered members. Unit names key reviewer-sentinel
2097
+ * scopes and provenance `group`, so they must be unique — a chunk whose base
2098
+ * name still collides gets a `#2`/`#3`/… suffix.
2099
+ *
2100
+ * Defensive, independent of zod: `loadDevLoopConfig` returns the raw merged
2101
+ * config even when schema validation fails (on ANY layer, not necessarily
2102
+ * `gates.fanout` itself), so a malformed `gates.fanout.groups` entry can
2103
+ * reach here. A non-object entry, a non-array/blank `angles`, or a
2104
+ * blank/duplicate `name` is dropped (its angles fall through to the leftover
2105
+ * auto-chunk pool) rather than thrown — mirroring the sibling
2106
+ * `normalizeAngleEntries` convention: this resolver degrades to a smaller
2107
+ * grouping table, never crashes the conductor's Phase 2 planning.
2108
+ * `resolvedAngles` is deduplicated up front so a duplicated entry (e.g. a
2109
+ * hand-built `--angles` list) never mints two dispatch units sharing one name.
2110
+ *
2111
+ * @param {DevLoopConfig} config
2112
+ * @param {"draft"|"preApproval"|"spike"} gate unused today — fan-out grouping
2113
+ * is a global policy (`gates.fanout`), not per-gate; accepted for symmetry
2114
+ * with the other `resolveGate*(config, gate, ...)` resolvers.
2115
+ * @param {string[]} resolvedAngles this round's resolved angle names
2116
+ * @param {{ fullLabel?: boolean }} [options] — retained for API stability;
2117
+ * no longer changes the dispatch shape (see `gate:full` note above).
2118
+ * @returns {{ name: string, angles: string[] }[]}
2119
+ */
2120
+ export function resolveFanoutGroups(config, gate, resolvedAngles, { fullLabel = false } = {}) {
2121
+ const angles = Array.isArray(resolvedAngles)
2122
+ ? [...new Set(resolvedAngles.filter((a) => typeof a === "string" && a.trim().length > 0).map((a) => a.trim()))]
2123
+ : [];
2124
+ const perAngleGroups = () => angles.map((name) => ({ name, angles: [name] }));
2125
+ // per-angle: bypass configured groups and emit one singleton unit per
2126
+ // angle (the original one-reviewer-per-angle fan-out). gate:full no longer
2127
+ // takes this branch (ADR 0047 superseded by 0048): fullLabel is a no-op here.
2128
+ const fanout = config?.gates?.fanout ?? {};
2129
+ if (fanout.mode === "per-angle") return perAngleGroups();
2130
+ const angleSet = new Set(angles);
2131
+ const rawGroups = Array.isArray(fanout.groups) ? fanout.groups : [];
2132
+ const configuredGroups = [];
2133
+ const seenGroupNames = new Set();
2134
+ for (const group of rawGroups) {
2135
+ if (!group || typeof group !== "object" || Array.isArray(group)) continue;
2136
+ const name = typeof group.name === "string" ? group.name.trim() : "";
2137
+ if (name.length === 0 || seenGroupNames.has(name)) continue;
2138
+ const groupAngles = Array.isArray(group.angles)
2139
+ ? [...new Set(group.angles.filter((a) => typeof a === "string" && a.trim().length > 0).map((a) => a.trim()))]
2140
+ : [];
2141
+ if (groupAngles.length === 0) continue;
2142
+ seenGroupNames.add(name);
2143
+ configuredGroups.push({ name, angles: groupAngles });
2144
+ }
2145
+ const grouped = new Set();
2146
+ const result = [];
2147
+ for (const group of configuredGroups) {
2148
+ const members = group.angles.filter((a) => angleSet.has(a) && !grouped.has(a));
2149
+ if (members.length === 0) continue;
2150
+ for (const a of members) grouped.add(a);
2151
+ result.push({ name: group.name, angles: members });
2152
+ }
2153
+ // Issue #1601: leftover ungrouped angles auto-chunk into dispatch units of
2154
+ // ≤ maxAnglesPerGroup (default 3) instead of singletons. Configured groups
2155
+ // are matched first and never split by this knob (only the leftover pool is
2156
+ // chunked). Deterministic order (input order) + stable unit names.
2157
+ const usedNames = new Set(result.map((g) => g.name));
2158
+ const leftover = angles.filter((name) => !grouped.has(name));
2159
+ const maxAnglesPerGroup = resolveMaxAnglesPerGroup(config);
2160
+ for (let i = 0; i < leftover.length; i += maxAnglesPerGroup) {
2161
+ const chunk = leftover.slice(i, i + maxAnglesPerGroup);
2162
+ const unitName = stableAutoChunkUnitName(chunk, usedNames);
2163
+ usedNames.add(unitName);
2164
+ result.push({ name: unitName, angles: chunk });
2165
+ }
2166
+ return result;
2167
+ }
2168
+
2169
+ /**
2170
+ * Deterministic, stable dispatch-unit name for an auto-chunked leftover
2171
+ * unit (issue #1601). A single-angle chunk keeps the pre-#1601 singleton
2172
+ * convention (the angle name, disambiguated to `angle:<name>` on collision
2173
+ * with an emitted group name); a multi-angle chunk is named
2174
+ * `group:<a>+<b>+<c>` from its deterministically-ordered members, with a
2175
+ * `#N` suffix when even that base collides. Pure.
2176
+ * @param {string[]} chunk — non-empty, deterministically ordered
2177
+ * @param {Set<string>} usedNames — already-emitted unit names (mutated by caller)
2178
+ * @returns {string}
2179
+ */
2180
+ function stableAutoChunkUnitName(chunk, usedNames) {
2181
+ if (chunk.length === 1) {
2182
+ const name = chunk[0];
2183
+ return usedNames.has(name) ? `angle:${name}` : name;
2184
+ }
2185
+ const base = `group:${chunk.join("+")}`;
2186
+ if (!usedNames.has(base)) return base;
2187
+ let k = 2;
2188
+ while (usedNames.has(`${base}#${k}`)) k++;
2189
+ return `${base}#${k}`;
2190
+ }
2191
+
1534
2192
  /**
1535
2193
  * Resolve review angles for a specific gate from the merged dev-loop config.
1536
2194
  *
1537
- * Merges mandatoryAngles with the configured candidate angles, filters
1538
- * through excludeAngles, and deduplicates. Returns null only when both
1539
- * angles and mandatoryAngles are absent/empty for the given gate (caller
1540
- * falls back to skill-defined defaults).
2195
+ * Unions the mandatory angle names (entries with `mandatory: true`) with the
2196
+ * gate's full configured angle list, then removes disabled entries
2197
+ * (`enabled: false`): `mandatoryAngles ∪ angles − disabled`, deduplicated (a
2198
+ * mandatory angle also present in `angles` is a no-op — it appears exactly
2199
+ * once and keeps its mandatory status). Returns null only when the gate has
2200
+ * no configured `angles` at all (caller falls back to skill-defined
2201
+ * defaults); an explicitly-empty `angles: []` returns `[]`.
1541
2202
  *
1542
2203
  * @param {DevLoopConfig} config
1543
2204
  * @param {"draft"|"preApproval"} gate
@@ -1546,6 +2207,11 @@ export function resolveGateDispatchMode(config, gate, { scope, hasFullLabel = fa
1546
2207
  export function resolveGateAngles(config, gate) {
1547
2208
  const gateConfig = resolveGateConfig(config, gate);
1548
2209
  if (gateConfig.angles === null && gateConfig.mandatoryAngles.length === 0) return null;
2210
+ // gateConfig.angles is already exclude-filtered (resolveGateConfig drops
2211
+ // enabled:false entries); the excludeAngles filter below is a defensive
2212
+ // no-op that keeps this correct even for a hand-built config object that
2213
+ // sets excludeAngles/angles independently rather than through the
2214
+ // gates.<gate>.angles[].enabled shape.
1549
2215
  const excluded = new Set(gateConfig.excludeAngles);
1550
2216
  const merged = [...new Set([...gateConfig.mandatoryAngles, ...(gateConfig.angles ?? [])])];
1551
2217
  return merged.filter(a => !excluded.has(a));
@@ -1607,14 +2273,79 @@ export function resolveGateAngleContract(config, gate) {
1607
2273
  return { mandatoryAngles, pool };
1608
2274
  }
1609
2275
 
2276
+ /**
2277
+ * Resolve the diff-class angle tier for a gate from its configured, ordered
2278
+ * `gates.<gate>.tiers` list (first-match-wins). Pure and synchronous — the
2279
+ * single source of truth for tier selection, consulted at the top of
2280
+ * `resolveGateAnglesDynamic` before any dynamic subtractive/additive
2281
+ * reduction runs.
2282
+ *
2283
+ * FAIL CLOSED at every uncertain step: the `gate:full` label, no tiers
2284
+ * configured, an unavailable/malformed scope, a changed dev-loop
2285
+ * config-source file (`isDevLoopConfigSourcePath`), or an unclassifiable
2286
+ * changed file (`classifyFile` returns "unknown") all resolve to `tier: null`
2287
+ * rather than a guess. A matched tier's angle set is additionally validated
2288
+ * against the gate's angle pool (`resolveGateAngleContract`) — ANY tier angle
2289
+ * outside a non-null pool voids the whole match (no partial intersection): a
2290
+ * typo'd tier angle is caught here, not by silently dropping reviewers at
2291
+ * gate time.
2292
+ *
2293
+ * @param {DevLoopConfig} config
2294
+ * @param {"draft"|"preApproval"|"spike"} gate
2295
+ * @param {object} facts
2296
+ * @param {string[]} [facts.changedFiles] — repo-relative changed file paths for this diff
2297
+ * @param {number} [facts.filesChanged] — count of changed files
2298
+ * @param {number} [facts.linesChanged] — count of changed lines (added + deleted)
2299
+ * @param {boolean} [facts.hasFullLabel] — `gate:full` label present on the PR
2300
+ * @returns {{ tier: string|null, angles: string[]|null, reason: string }}
2301
+ */
2302
+ export function resolveGateTier(config, gate, { changedFiles, filesChanged, linesChanged, hasFullLabel = false } = {}) {
2303
+ if (hasFullLabel) {
2304
+ return { tier: null, angles: null, reason: "gate_full_label" };
2305
+ }
2306
+ const tiers = resolveGateConfig(config, gate).tiers;
2307
+ if (tiers.length === 0) {
2308
+ return { tier: null, angles: null, reason: "no_tiers_configured" };
2309
+ }
2310
+ if (
2311
+ !Array.isArray(changedFiles) || changedFiles.length === 0 ||
2312
+ !Number.isFinite(filesChanged) || !Number.isFinite(linesChanged)
2313
+ ) {
2314
+ return { tier: null, angles: null, reason: "scope_unavailable" };
2315
+ }
2316
+ if (changedFiles.some((f) => isDevLoopConfigSourcePath(f))) {
2317
+ return { tier: null, angles: null, reason: "config_source_delta" };
2318
+ }
2319
+ const kinds = changedFiles.map((f) => classifyFile(f));
2320
+ if (kinds.some((k) => k === "unknown")) {
2321
+ return { tier: null, angles: null, reason: "unclassifiable_file" };
2322
+ }
2323
+ const matched = tiers.find((t) => {
2324
+ const match = t.match ?? {};
2325
+ if (Array.isArray(match.kinds) && !kinds.every((k) => match.kinds.includes(k))) return false;
2326
+ if (typeof match.maxFiles === "number" && filesChanged > match.maxFiles) return false;
2327
+ if (typeof match.maxLines === "number" && linesChanged > match.maxLines) return false;
2328
+ return true;
2329
+ });
2330
+ if (!matched) {
2331
+ return { tier: null, angles: null, reason: "no_tier_match" };
2332
+ }
2333
+ const { mandatoryAngles, pool } = resolveGateAngleContract(config, gate);
2334
+ if (pool !== null && matched.angles.some((a) => !pool.includes(a))) {
2335
+ return { tier: null, angles: null, reason: "angle_outside_pool" };
2336
+ }
2337
+ return { tier: matched.name, angles: [...new Set([...mandatoryAngles, ...matched.angles])], reason: "tier_match" };
2338
+ }
2339
+
1610
2340
  /**
1611
2341
  * Resolve gate angles dynamically when `dynamicAngles` is enabled in config.
1612
2342
  *
1613
2343
  * Uses diff analysis helpers (from ../analysis/*) to filter the
1614
2344
  * configured angle list down to only angles relevant to the change set.
1615
2345
  *
1616
- * When `dynamicAngles` is disabled (default), returns the full configured
1617
- * angle list (same as `resolveGateAngles`).
2346
+ * When `dynamicAngles` is disabled (opt-out via `dynamic.subtractive: false`,
2347
+ * see #1579), returns the full configured angle list (same as
2348
+ * `resolveGateAngles`); no diff also falls back to the full static pool.
1618
2349
  *
1619
2350
  * When `additiveAngles` is also enabled (default off, see #1048), catalog
1620
2351
  * angles from `resolveAnglePool()` (`gates.anglePool`, or else the union of
@@ -1622,13 +2353,55 @@ export function resolveGateAngleContract(config, gate) {
1622
2353
  * by change-category heuristics but absent from the gate's configured pool
1623
2354
  * may also be added; `excludeAngles` remains a hard ceiling on additions.
1624
2355
  *
2356
+ * Diff-class angle tiers (`gates.<gate>.tiers`, see `resolveGateTier`) are
2357
+ * consulted FIRST, ahead of any subtractive/additive reduction below: when the
2358
+ * diff's changed-file scope matches a configured tier, that tier's angle set
2359
+ * (unioned with mandatory angles) is returned directly and the
2360
+ * subtractive/additive machinery below is skipped entirely. No tier match
2361
+ * (including "no tiers configured") falls through to the existing behavior
2362
+ * unchanged.
2363
+ *
1625
2364
  * @param {import("./types.js").DevLoopConfig} config
1626
2365
  * @param {"draft"|"preApproval"} gate
1627
2366
  * @param {object} [options]
1628
2367
  * @param {{ nameStatusOutput: string, diffOutput?: string }} [options.diff]
2368
+ * @param {boolean} [options.hasFullLabel] — `gate:full` label present on the PR (bypasses tier resolution)
1629
2369
  * @returns {{ recommendedAngles: string[] | null, skippedAngles: string[], reasons: Record<string,string>, fallbackToAll: boolean, dynamicAnglesActive: boolean, addedAngles: string[], addedReasons: Record<string,string> }}
1630
2370
  */
1631
- export async function resolveGateAnglesDynamic(config, gate, { diff } = {}) {
2371
+ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabel = false } = {}) {
2372
+ // Tier scope facts: changedFiles/filesChanged from T0 (file-level), linesChanged
2373
+ // from T1 (hunk-level) reused for its real added+deleted line count rather than
2374
+ // T0's/analyzeDiff's own inferred-category path, which reports a fake 0 line
2375
+ // count for an unambiguous (e.g. docs-only) diff — see analyzeT1/analyzeDiff.
2376
+ let changedFiles;
2377
+ let filesChanged;
2378
+ let linesChanged;
2379
+ if (diff) {
2380
+ const { analyzeT0, analyzeT1 } = await import("../analysis/diff-analyzer.mjs");
2381
+ const t0 = analyzeT0(diff.nameStatusOutput);
2382
+ changedFiles = t0.files;
2383
+ filesChanged = changedFiles.length;
2384
+ if (diff.diffOutput) {
2385
+ const lineStats = analyzeT1(diff.diffOutput, t0).lineStats;
2386
+ linesChanged = lineStats.added + lineStats.deleted;
2387
+ }
2388
+ }
2389
+ const tierResult = resolveGateTier(config, gate, { changedFiles, filesChanged, linesChanged, hasFullLabel });
2390
+ if (tierResult.tier) {
2391
+ const configuredAngles = resolveGateAngles(config, gate) ?? [];
2392
+ const tierAngleSet = new Set(tierResult.angles);
2393
+ const skippedAngles = configuredAngles.filter((a) => !tierAngleSet.has(a));
2394
+ return {
2395
+ recommendedAngles: tierResult.angles,
2396
+ skippedAngles,
2397
+ reasons: Object.fromEntries(skippedAngles.map((a) => [a, `tier:${tierResult.tier}`])),
2398
+ fallbackToAll: false,
2399
+ dynamicAnglesActive: true,
2400
+ addedAngles: [],
2401
+ addedReasons: {},
2402
+ };
2403
+ }
2404
+
1632
2405
  const gateConfig = resolveGateConfig(config, gate);
1633
2406
  const staticAngles = resolveGateAngles(config, gate);
1634
2407
  if (staticAngles === null) {
@@ -1732,24 +2505,105 @@ export function resolveWorkflowConfig(config, key) {
1732
2505
  throw new Error(`Unknown workflow config key: ${key}`);
1733
2506
  }
1734
2507
 
2508
+ /** Best-effort `git` probe: stdout trimmed on success, `null` on any failure
2509
+ * (missing repo, missing ref, git not on PATH, etc.) — never throws. */
2510
+ function tryGit(args, cwd) {
2511
+ try {
2512
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
2513
+ } catch {
2514
+ return null;
2515
+ }
2516
+ }
2517
+
2518
+ // Last-resort literal when git auto-detection cannot resolve anything (e.g. no
2519
+ // git repo at cwd) — matches the branch name every prior hardcoded "main"/
2520
+ // "origin/main" call site already assumed.
2521
+ const AUTO_DETECT_BASE_BRANCH_FALLBACK = "main";
2522
+
2523
+ /**
2524
+ * Auto-detect the repo's default branch (bare name) at `cwd`: prefer the
2525
+ * remote's advertised default (`origin/HEAD`, works for any branch name), else
2526
+ * probe `main`/`master` as a remote-tracking or local ref, else fall back to
2527
+ * the literal "main". Every probe is best-effort; a missing/unreadable repo
2528
+ * degrades to the literal fallback rather than throwing.
2529
+ * @param {string} cwd
2530
+ * @returns {string}
2531
+ */
2532
+ function autoDetectDefaultBranch(cwd) {
2533
+ const originHead = tryGit(["rev-parse", "--abbrev-ref", "origin/HEAD"], cwd);
2534
+ if (originHead && originHead.startsWith("origin/")) {
2535
+ return originHead.slice("origin/".length);
2536
+ }
2537
+ for (const candidate of ["main", "master"]) {
2538
+ if (tryGit(["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${candidate}`], cwd) !== null) return candidate;
2539
+ if (tryGit(["rev-parse", "--verify", "--quiet", `refs/heads/${candidate}`], cwd) !== null) return candidate;
2540
+ }
2541
+ return AUTO_DETECT_BASE_BRANCH_FALLBACK;
2542
+ }
2543
+
2544
+ /**
2545
+ * Resolve the effective base/integration branch (bare name — never
2546
+ * `origin/`-prefixed) for worktree creation, PR targeting, and merge-base
2547
+ * scope measurement (#1368).
2548
+ *
2549
+ * `workflow.baseBranch` (a non-empty trimmed string) is the authoritative
2550
+ * override; unset, malformed, or empty is treated identically to unset and
2551
+ * falls back to the existing auto-detect: the remote's advertised default
2552
+ * branch (`origin/HEAD`), else `main`/`master`, else the literal "main".
2553
+ * Never throws.
2554
+ *
2555
+ * Callers own the `origin/` prefix: worktree creation prepends it (a remote
2556
+ * ref), gh/PR base flags pass the bare name straight through.
2557
+ *
2558
+ * @param {DevLoopConfig|null|undefined} config
2559
+ * @param {{ cwd?: string }} [options]
2560
+ * @returns {string} bare branch name
2561
+ */
2562
+ export function resolveBaseBranch(config, { cwd = process.cwd() } = {}) {
2563
+ const configured = config?.workflow?.baseBranch;
2564
+ if (typeof configured === "string" && configured.trim().length > 0) {
2565
+ // A prefix-only value (e.g. "origin/", "refs/heads/") normalizes to empty —
2566
+ // treat that as unset and fall through to auto-detect, never return "".
2567
+ const bare = normalizeToBareBranch(configured.trim());
2568
+ if (bare.length > 0) return bare;
2569
+ }
2570
+ return autoDetectDefaultBranch(cwd);
2571
+ }
2572
+
2573
+ /**
2574
+ * Reduce a configured base value to a BARE branch name. Callers prepend
2575
+ * `origin/` for remote refs, so a configured `origin/main` /
2576
+ * `refs/remotes/origin/main` / `refs/heads/main` must be stripped to `main`
2577
+ * first — otherwise the worktree base double-prefixes to `origin/origin/main`.
2578
+ * A branch name that merely contains a slash (e.g. `spike/vite`) is left intact.
2579
+ */
2580
+ export function normalizeToBareBranch(value) {
2581
+ return value
2582
+ .replace(/^refs\/remotes\/origin\//, "")
2583
+ .replace(/^refs\/heads\//, "")
2584
+ .replace(/^origin\//, "");
2585
+ }
2586
+
1735
2587
  /**
1736
2588
  * Resolve the worktree lifecycle config from the merged dev-loop config.
1737
2589
  *
1738
- * Returns `{ copyOnInit, linkOnInit }` with empty-array defaults when the
1739
- * config omits the `worktree` section or either list. Entries are trimmed,
1740
- * repo-relative literal paths or glob patterns expanded against the main
1741
- * checkout at provision time. See scripts/loop/provision-worktree.mjs.
2590
+ * Returns `{ copyOnInit, linkOnInit }` (split by each entry's `mode`) with
2591
+ * empty-array defaults when the config omits `worktree.entries` or it is
2592
+ * empty. Entries are trimmed, repo-relative literal paths or glob patterns
2593
+ * expanded against the main checkout at provision time. See
2594
+ * scripts/loop/provision-worktree.mjs.
1742
2595
  *
1743
2596
  * @param {DevLoopConfig} config
1744
2597
  * @returns {{ copyOnInit: string[], linkOnInit: string[] }}
1745
2598
  */
1746
2599
  export function resolveWorktreeConfig(config) {
1747
- const wt = config?.worktree;
1748
- const list = (v) =>
1749
- Array.isArray(v)
1750
- ? v.map((s) => (typeof s === "string" ? s.trim() : "")).filter((s) => s.length > 0)
1751
- : [];
1752
- return { copyOnInit: list(wt?.copyOnInit), linkOnInit: list(wt?.linkOnInit) };
2600
+ const entries = Array.isArray(config?.worktree?.entries) ? config.worktree.entries : [];
2601
+ const pathsForMode = (mode) =>
2602
+ entries
2603
+ .filter((e) => e && typeof e === "object" && e.mode === mode)
2604
+ .map((e) => (typeof e.path === "string" ? e.path.trim() : ""))
2605
+ .filter((p) => p.length > 0);
2606
+ return { copyOnInit: pathsForMode("copy"), linkOnInit: pathsForMode("link") };
1753
2607
  }
1754
2608
 
1755
2609
  /**
@@ -1864,16 +2718,16 @@ export function resolveUiReviewDriveRecipe(config) {
1864
2718
  * Resolve the human-handoff config from the merged dev-loop config (#920).
1865
2719
  *
1866
2720
  * Returns a normalized `{ enabled, candidatesFrom, assignees }`. Defaults to
1867
- * disabled with empty arrays when the `approval.humanHandoff` section is absent.
1868
- * When disabled (default), this is a no-op: callers must not source candidates
1869
- * or assign anyone. Pairs with `autonomy.humanMergeOnly`: when human-merge is
2721
+ * disabled with empty arrays when the `approval` section is absent. When
2722
+ * disabled (default), this is a no-op: callers must not source candidates or
2723
+ * assign anyone. Pairs with `autonomy.humanMergeOnly`: when human-merge is
1870
2724
  * enforced, this names who should take the merge.
1871
2725
  *
1872
2726
  * @param {DevLoopConfig} config
1873
2727
  * @returns {{ enabled: boolean, candidatesFrom: ("codeowners"|"recent-committers")[], assignees: string[] }}
1874
2728
  */
1875
2729
  export function resolveHumanHandoffConfig(config) {
1876
- const hh = config?.approval?.humanHandoff;
2730
+ const hh = config?.approval;
1877
2731
  const enabled = hh?.enabled === true;
1878
2732
  const list = (v) =>
1879
2733
  Array.isArray(v)
@@ -1894,3 +2748,34 @@ export function resolveHumanHandoffConfig(config) {
1894
2748
  assignees: enabled ? assignees : [],
1895
2749
  };
1896
2750
  }
2751
+
2752
+ /**
2753
+ * Resolve the tracker provider registry key (issue #1408). Defaults to
2754
+ * `"github"` — the only built-in provider in v1 — when unset. Callers pass
2755
+ * this to `resolveTrackerAdapter` (`@dev-loops/core/tracker`).
2756
+ *
2757
+ * @param {DevLoopConfig} config
2758
+ * @returns {string}
2759
+ */
2760
+ export function resolveTrackerProvider(config) {
2761
+ const raw = config?.tracker?.provider;
2762
+ return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : "github";
2763
+ }
2764
+
2765
+ /**
2766
+ * Resolve the effective tracker board identifier. `tracker.board` is
2767
+ * canonical; `queue.board` is a DEPRECATED alias, already normalized onto
2768
+ * `tracker.board` by `loadDevLoopConfig` (with a load-time warning) for any
2769
+ * config that went through the loader. This resolver also accepts a
2770
+ * hand-built config object that sets `queue.board` directly (bypassing the
2771
+ * loader, e.g. in a test) and falls back to it — with no warning, since only
2772
+ * the loader surfaces warnings.
2773
+ *
2774
+ * @param {DevLoopConfig} config
2775
+ * @returns {{ number?: number, title?: string } | null}
2776
+ */
2777
+ export function resolveTrackerBoard(config) {
2778
+ if (isPlainObject(config?.tracker?.board)) return config.tracker.board;
2779
+ if (isPlainObject(config?.queue?.board)) return config.queue.board;
2780
+ return null;
2781
+ }