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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import { readFile } from "node:fs/promises";
2
+ import { execFileSync } from "node:child_process";
2
3
  import path from "node:path";
3
4
  import { parse as parseYaml } from "yaml";
4
5
  import { fileURLToPath } from "node:url";
@@ -12,13 +13,20 @@ import { z } from "zod";
12
13
  // callers need a stable value even when they construct config objects directly.
13
14
  // ============================================================================
14
15
 
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
- });
16
+ // `strategy` and `inputSource` are single-value families (their only child was
17
+ // a `default` wrapper) — flattened to a bare enum at the family key itself.
18
+ //
19
+ // `tracker-first` renames the former `github-first` (issue #1408, the
20
+ // tracker-agnostic seam: provider-neutral naming now that GitHub is one
21
+ // tracker provider among a stable seam, not the only one). `github-first` is
22
+ // still ACCEPTED as a deprecated alias — normalized to `tracker-first` with a
23
+ // load-time warning in `loadDevLoopConfig` (see the alias-normalization pass
24
+ // below `mergeConfigLayers`) — but this schema only validates the canonical
25
+ // value, so the alias must be normalized on the raw merged object BEFORE it
26
+ // reaches this parse.
27
+ 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).");
28
+
29
+ const InputSourceConfig = z.enum(["tracker", "phase-docs"]).describe("Where local-first work reads its spec: the tracker issue body, or repo phase docs.");
22
30
 
23
31
  // Built-in tier aliases shipped with zero config. A tier alias maps a
24
32
  // harness-neutral name (low/high) to a concrete per-harness model id; `null`
@@ -92,20 +100,73 @@ const ModelsConfigBase = z.strictObject({
92
100
 
93
101
  const ModelsConfig = ModelsConfigBase.superRefine(refineRoleTiers);
94
102
 
103
+ // A round with at most this many comments (after this many rounds) counts as
104
+ // low-signal and stops further Copilot rounds early — folded from the three
105
+ // flat `stopOnLowSignal`/`lowSignalRoundThreshold`/`lowSignalMaxComments` keys
106
+ // into one sub-object (they are one feature).
107
+ const LowSignalConfig = z.strictObject({
108
+ enabled: z.boolean().default(false).describe("Stop Copilot rounds early once they stop producing signal."),
109
+ roundThreshold: z.number().int().nonnegative().default(3).describe("Rounds counted toward the low-signal stop decision."),
110
+ maxComments: z.number().int().nonnegative().default(2).describe("A round with at most this many comments counts as low-signal."),
111
+ });
112
+
95
113
  const RefinementConfig = z.strictObject({
96
114
  fanOut: z.number().int().min(1).max(10).describe("Parallel reviewers per refinement round."),
97
115
  mode: z.enum(["parallel", "sequential"]).describe("Whether refinement reviewers run in parallel or one after another."),
98
116
  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."),
117
+ lowSignal: LowSignalConfig.optional().describe("Early-stop policy for low-signal Copilot rounds."),
102
118
  roles: z.array(z.string().trim().min(1)).describe("Review lenses the refinement fan-out dispatches.").optional(),
103
119
  });
104
120
 
121
+ // One review angle: a bare string is sugar for `{ name }`. An object may also
122
+ // set `mandatory` (always runs, survives dynamic pruning — was
123
+ // gates.<gate>.mandatoryAngles), `enabled: false` (drops it from the resolved
124
+ // list — was gates.<gate>.excludeAngles, D3), and `persona`/`prompt`/`model`/
125
+ // `tier` (was the top-level `personas` map + angle-keyed
126
+ // `models.roles`/`models.roleTiers`, D4: model > tier > built-in precedence).
127
+ // This is the ONE identity for a gate-review angle (was five separate places
128
+ // — see the config-schema RFC). `mergeConfigLayers` merges these arrays BY
129
+ // `name` across config layers (D3), so a later layer can add or disable a
130
+ // single angle without restating the whole list.
131
+ // A bare string is sugar for { name }; preprocessing the string→object wrap
132
+ // BEFORE validation (rather than a z.union of the two shapes) means every
133
+ // malformed angle entry validates against this ONE object schema, so a bad
134
+ // field (e.g. `mandatory: "yes"`) reports its own actionable path/message
135
+ // (`gates.draft.angles.1.mandatory: ...`) instead of zod's opaque
136
+ // invalid_union "Invalid input" that swallows which branch failed why.
137
+ const GateAngleEntry = z.preprocess(
138
+ (v) => (typeof v === "string" ? { name: v } : v),
139
+ z.strictObject({
140
+ name: z.string().trim().min(1),
141
+ mandatory: z.boolean().optional().describe("Always run this angle, regardless of diff-based dynamic selection."),
142
+ enabled: z.boolean().optional().describe("Set false to drop this angle from the resolved list (a later config layer disabling a base angle)."),
143
+ persona: z.string().trim().min(1).optional().describe("Reviewer persona for this angle."),
144
+ prompt: z.string().min(1).optional().describe("Short focused instruction for the reviewer agent — what to look for and how to judge this angle."),
145
+ model: z.string().trim().min(1).optional().describe("Concrete model override for this angle (highest precedence)."),
146
+ tier: z.string().trim().min(1).optional().describe("Model tier alias for this angle (used when `model` is absent)."),
147
+ }),
148
+ );
149
+
150
+ const GateDynamicConfig = z.strictObject({
151
+ subtractive: z.boolean().default(false).describe("Enable diff-driven dynamic angle PRUNING for this gate (was gates.<gate>.dynamicAngles)."),
152
+ // Additive counterpart to the subtractive path (#1048): when true, the
153
+ // context-builder may also ADD catalog angles — from resolveAnglePool()
154
+ // (gates.anglePool, or else the union of the persona registry and this
155
+ // config's own configured angles) — that change-category heuristics
156
+ // recommend but that are not already in this gate's configured pool.
157
+ // Default false preserves the subtractive-only behavior exactly.
158
+ additive: z.boolean().default(false).describe("Allow diff-driven addition of catalog angles beyond this gate's configured pool (was gates.<gate>.additiveAngles)."),
159
+ });
160
+
161
+ // One unified gate schema for draft/preApproval/spike (D2): the spike gate
162
+ // profile ships `required: false, requireCi: false` and a small docs-first
163
+ // angle set; `blockCleanOnFindingSeverities` and `dynamic.additive` are
164
+ // accepted but INERT for spike (a findings-doc deliverable has no "clean
165
+ // verdict" escalation path and no additive dynamic pool) rather than being
166
+ // split into a second schema.
105
167
  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."),
168
+ 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."),
169
+ dynamic: GateDynamicConfig.optional().describe("Diff-driven dynamic angle selection policy for this gate."),
109
170
  required: z.boolean().default(true).describe("Whether this gate must run."),
110
171
  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."),
111
172
  blockCleanOnFindingSeverities: z
@@ -113,14 +174,6 @@ const GateConfig = z.strictObject({
113
174
  .min(1)
114
175
  .default(["must-fix"])
115
176
  .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."),
124
177
  });
125
178
 
126
179
  const GatesConfig = z.strictObject({
@@ -141,14 +194,14 @@ const GatesConfig = z.strictObject({
141
194
  // fan-out/fan-in review sub-loop (executionMode === "fanout_fanin" plus a
142
195
  // durable findings-log ledger), not an inline single-agent run. Default
143
196
  // 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.
197
+ // unless explicitly disabled. See skills/docs/gate-review-sub-loop-contract.md.
145
198
  requireFanoutEvidence: z.boolean().default(true),
146
199
  // Fail-closed enforcement that a fanout_fanin gate verdict carries recorded,
147
200
  // internally-consistent fan-out *provenance* (distinct reviewer count +
148
201
  // per-angle dispatch). This RAISES THE BAR against a single agent self-producing
149
202
  // every artifact but does NOT prove independence — provenance is self-reported,
150
203
  // 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
204
+ // the honest caveat in skills/docs/gate-review-sub-loop-contract.md). Layered ON TOP of
152
205
  // requireFanoutEvidence — only takes effect when fan-out evidence enforcement
153
206
  // is active. Default false (opt-in): closing this loophole is additive and
154
207
  // does not change behavior for existing ledgers that carry no provenance.
@@ -161,23 +214,31 @@ const GatesConfig = z.strictObject({
161
214
  // comment so they are auditable and Copilot/humans are aware of them. Default
162
215
  // true (opt-out). The disposition ledger is written regardless; this flag only
163
216
  // suppresses the PR comment when explicitly false. See
164
- // docs/gate-review-sub-loop-contract.md.
217
+ // skills/docs/gate-review-sub-loop-contract.md.
165
218
  postFindingsComments: z.boolean().default(true),
166
219
  // Explicit global lens catalog override for additive angle selection
167
- // (gates.<gate>.additiveAngles, #1048). When absent, resolveAnglePool()
220
+ // (gates.<gate>.dynamic.additive, #1048). GLOBAL, not per-gate (D1): one
221
+ // repo-wide catalog for additive selection. When absent, resolveAnglePool()
168
222
  // falls back to the union of the built-in persona registry's angle names
169
223
  // and every angle configured across this config's own draft/preApproval/
170
- // spike gates (angles + mandatoryAngles).
224
+ // spike gates.
171
225
  anglePool: z.array(z.string().trim().min(1)).optional(),
172
226
  // 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.
227
+ // provenance names only angles in the gate's configured pool — ad-hoc/foreign
228
+ // angle labels are rejected rather than silently accepted. Default true
229
+ // (reject); set false to warn instead of fail. See resolveRejectForeignAngles
230
+ // / skills/docs/gate-review-sub-loop-contract.md.
177
231
  rejectForeignAngles: z.boolean().default(true),
178
232
  });
179
233
 
180
234
  const AutonomyConfig = z.strictObject({
235
+ // ponytail: secondary cleanup #6 (stopAt kebab values vs camelCase gate
236
+ // keys) is DEFERRED — "draft-pr"/"pre-approval" are checkpoint/state-machine
237
+ // vocabulary shared far beyond config (lifecycle-state.mjs, hook-decisions.mjs,
238
+ // the handoff-envelope contract, skills/docs/reviewer-loop-state-graph.md, and ~20
239
+ // more files), not a config-local spelling. Renaming here would mean
240
+ // renaming that shared vocabulary, a materially larger change than this
241
+ // config-schema RFC's scope.
181
242
  stopAt: z.array(
182
243
  z.enum(["refinement", "draft-pr", "pre-approval", "merge"])
183
244
  ).describe("Checkpoints that require operator confirmation before the loop proceeds (default: [\"merge\"])."),
@@ -194,8 +255,12 @@ const AutonomyConfig = z.strictObject({
194
255
  * reviewer/assignee. Opt-in (default off). Pairs with autonomy.humanMergeOnly.
195
256
  * `candidatesFrom` selects which sources the resolver queries; `assignees` is a
196
257
  * static highest-priority candidate list. Absent/empty = disabled no-op.
258
+ *
259
+ * Lifted directly onto `approval` (its only child) rather than nested under
260
+ * `approval.humanHandoff` — `approval` had exactly one sub-key, so the wrapper
261
+ * added a level without adding meaning.
197
262
  */
198
- const HumanHandoffConfig = z.strictObject({
263
+ const ApprovalConfig = z.strictObject({
199
264
  enabled: z.boolean().default(false),
200
265
  candidatesFrom: z
201
266
  .array(z.enum(["codeowners", "recent-committers"]))
@@ -203,15 +268,24 @@ const HumanHandoffConfig = z.strictObject({
203
268
  assignees: z.array(z.string().trim().min(1)).optional(),
204
269
  });
205
270
 
206
- const ApprovalConfig = z.strictObject({
207
- humanHandoff: HumanHandoffConfig.optional(),
208
- });
209
-
210
271
  const WorkflowConfig = z.strictObject({
211
272
  asyncStartMode: z.enum(["required", "allowed"]).default("required").describe("Whether the async start contract is required or merely allowed."),
273
+ // ponytail: workflow.asyncStartMode -> asyncStartRequired (secondary cleanup
274
+ // #5) is DEFERRED — that string is echoed verbatim into the persisted
275
+ // handoff-envelope contract field (validated, rendered, and cross-checked by
276
+ // workflow-handoff-contract.test.mjs / the inspect-run viewer), so renaming
277
+ // it here would also mean renaming a shipped artifact contract, not just a
278
+ // config key. Out of scope for this config-shape RFC; revisit as its own
279
+ // change against skills/docs/gate-review-comment-contract.md + the envelope schema.
212
280
  requireRetrospective: z.boolean().describe("Require a retrospective checkpoint before a loop completes."),
213
281
  requireDraftFirst: z.boolean().describe("Open pull requests as drafts and promote via the draft gate."),
214
282
  devModeDefault: z.boolean().describe("Default new loops to dev mode."),
283
+ // No default here and absent from BUILT_IN_DEFAULTS — unset means "keep
284
+ // auto-detecting the default branch" (see resolveBaseBranch), never a static
285
+ // "main". Bare branch name; consumers add the `origin/` remote-ref prefix
286
+ // where one is needed (worktree creation) and pass the bare name where one
287
+ // is not (gh/PR base).
288
+ 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
289
  });
216
290
 
217
291
  const LocalImplementationConfig = z.strictObject({
@@ -230,32 +304,80 @@ const LocalImplementationConfig = z.strictObject({
230
304
  * change scope. Decoupled from lightMode: gate dispatch still resolves
231
305
  * inline vs full_fanout from scope on its own, so over-threshold issue-less
232
306
  * PRs get the full fan-out and the full-PR Copilot round cap.
307
+ *
308
+ * Flattened to a bare boolean — `enabled` was its only child key.
233
309
  */
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(),
310
+ 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
311
  });
238
312
 
313
+ // GitHub Projects board identifier: exactly one of number/title (two parallel
314
+ // keys folded into one selector object). `ownerKey` names the config key in
315
+ // the refine failure message — each usage site gets its own accurate
316
+ // message rather than a shared one that could name the wrong key.
317
+ function boardRefConfig(ownerKey) {
318
+ return z
319
+ .strictObject({
320
+ number: z.number().int().positive().describe("GitHub Projects board number.").optional(),
321
+ title: z.string().trim().min(1).describe("GitHub Projects board title.").optional(),
322
+ })
323
+ .refine((v) => typeof v.number === "number" || typeof v.title === "string", {
324
+ message: `${ownerKey} must set number or title`,
325
+ });
326
+ }
327
+
328
+ const QueueBoardConfig = boardRefConfig("queue.board");
329
+
239
330
  /** Queue mode config */
240
331
  const QueueConfig = z.strictObject({
241
332
  maxParallel: z.number().int().min(1).max(10).default(3).describe("Maximum queue items worked in parallel."),
242
333
  maxAutoFiledIssues: z.number().int().min(0).max(100).default(10).describe("Cap on auto-filed issues per run."),
243
334
  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(),
335
+ // Deprecated: superseded by `tracker.board` (issue #1408, the tracker-agnostic
336
+ // seam). Kept accepted for back-compat — see resolveTrackerBoard, which reads
337
+ // `tracker.board` first and falls back to this field with a load-time warning.
338
+ board: QueueBoardConfig.describe("Deprecated: use tracker.board instead. GitHub Projects board identifier.").optional(),
246
339
  archiveOlderThanDays: z.number().int().positive().describe("Archive done board items older than this many days.").optional(),
247
340
  });
248
341
 
342
+ /**
343
+ * Tracker config (issue #1408, the tracker-agnostic seam). `provider` is a
344
+ * free-form registry key (not a zod enum): an unknown provider fails closed
345
+ * at `resolveTrackerAdapter` call time, not at config-parse time — the
346
+ * seam/resolver must not preclude a consumer registering an external
347
+ * provider post-1.0 (`plugin`, reserved, not implemented in this pass).
348
+ * `board` supersedes the deprecated `queue.board` (see resolveTrackerBoard).
349
+ *
350
+ * No generic `fieldMappings` (logical-column -> provider-status) key here:
351
+ * the github provider's logical-column -> Status mapping IS the existing,
352
+ * already-load-bearing `queue.statusColumns` (read by `loadStateColumnMap` in
353
+ * `../loop/queue-board-sync.mjs`; `next_up` is the fail-closed pickup column
354
+ * `resolve-active-board-item.mjs` reads). Adding a second, inert mapping key
355
+ * here would collide with that live one rather than replace it. A future
356
+ * external provider defines its OWN logical -> status mapping (its shape is
357
+ * provider-specific) when one is actually implemented — YAGNI to generalize
358
+ * this now for a provider that does not exist yet.
359
+ */
360
+ const TrackerConfig = z.strictObject({
361
+ provider: z.string().trim().min(1).describe("Tracker provider registry key. Built-in: \"github\" (default).").optional(),
362
+ 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(),
363
+ board: boardRefConfig("tracker.board").describe("Tracker board identifier; supersedes the deprecated queue.board.").optional(),
364
+ });
365
+
249
366
  /**
250
367
  * Worktree lifecycle config (#909): which gitignored files/dirs to provision
251
368
  * 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.
369
+ * literal paths OR glob patterns, each tagged with its mode (was two parallel
370
+ * `copyOnInit`/`linkOnInit` arrays encoding the mode via which array it lived
371
+ * in). `copy` → `fs.cp` (isolated per worktree); `link` → absolute symlink
372
+ * into the main checkout (read-only data). Empty/absent is a valid no-op.
255
373
  */
374
+ const WorktreeEntry = z.strictObject({
375
+ path: z.string().trim().min(1).describe("Repo-relative path or glob."),
376
+ mode: z.enum(["copy", "link"]).describe("copy = fs.cp into the worktree (isolated, mutable); link = absolute symlink to the main checkout (shared, read-only)."),
377
+ });
378
+
256
379
  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(),
380
+ entries: z.array(WorktreeEntry).optional().describe("Gitignored paths/globs provisioned into a fresh worktree."),
259
381
  });
260
382
 
261
383
  /**
@@ -453,16 +575,6 @@ const UiReviewConfig = z.strictObject({
453
575
  /** Internal path whitelist for internal-only PR detection — flat array of regex strings */
454
576
  const InternalPatternsConfig = z.array(z.string().trim().min(1)).min(1);
455
577
 
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
578
  // Partial nested gate entries for file-level config (allows overriding only
467
579
  // requireCi/required/angles without restating the whole gate object).
468
580
  const FileGatesConfig = z.strictObject({
@@ -476,15 +588,19 @@ const FileGatesConfig = z.strictObject({
476
588
  requireFanoutProvenance: z.boolean().describe("Additionally require recorded, internally-consistent fan-out provenance (distinct reviewer count + per-angle dispatch).").optional(),
477
589
  maxFanoutReviewers: z.number().int().min(1).max(64).describe("Cap on parallel gate fan-out reviewers; overflow runs in sequential batches.").optional(),
478
590
  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(),
591
+ anglePool: z.array(z.string().trim().min(1)).describe("Explicit global lens catalog for additive angle selection (global, not per-gate).").optional(),
480
592
  rejectForeignAngles: z.boolean().describe("Reject fan-out provenance naming angles outside the gate's configured pool (default true).").optional(),
481
593
  });
482
594
 
483
- // Partial persona entries for file-level config (allows omitting fields)
484
- const FilePersonasConfig = z.record(z.string().min(1), PersonaEntry.partial());
485
-
486
595
  // ============================================================================
487
596
  // Full schema — families are optional (BUILT_IN_DEFAULTS provides fallback)
597
+ //
598
+ // The `tracker:` config block is intentionally reserved here; a future
599
+ // tracker-seam change adds it on top of this restructured schema. Not added
600
+ // in this pass — this is the config-shape redesign only — but resolvers in
601
+ // this module take the effective config as a plain parameter (no
602
+ // global/singleton reads), so a later tracker adapter (and any multi-tracker
603
+ // layer on top of it) stays additive.
488
604
  // ============================================================================
489
605
 
490
606
  /**
@@ -503,13 +619,10 @@ export const DevLoopConfigSchema = z.strictObject({
503
619
  workflow: WorkflowConfig.optional(),
504
620
  localImplementation: LocalImplementationConfig.optional(),
505
621
  queue: QueueConfig.optional(),
506
- personas: PersonasConfig.optional(),
622
+ tracker: TrackerConfig.optional(),
507
623
  internalPathPatterns: InternalPatternsConfig.optional(),
508
624
  worktree: WorktreeConfig.optional(),
509
625
  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
626
  });
514
627
 
515
628
  // ============================================================================
@@ -518,18 +631,16 @@ export const DevLoopConfigSchema = z.strictObject({
518
631
 
519
632
  export const BUILT_IN_DEFAULTS = Object.freeze({
520
633
  version: 1,
521
- strategy: Object.freeze({ default: "local-first" }),
522
- inputSource: Object.freeze({ default: "tracker" }),
634
+ strategy: "local-first",
635
+ inputSource: "tracker",
523
636
  models: Object.freeze({}),
524
- refinement: Object.freeze({ fanOut: 3, mode: "parallel", maxCopilotRounds: 5, stopOnLowSignal: false, lowSignalRoundThreshold: 3, lowSignalMaxComments: 2 }),
637
+ refinement: Object.freeze({ fanOut: 3, mode: "parallel", maxCopilotRounds: 5, lowSignal: Object.freeze({ enabled: false, roundThreshold: 3, maxComments: 2 }) }),
525
638
  gates: Object.freeze({}),
526
639
  autonomy: Object.freeze({ stopAt: Object.freeze(["merge"]), humanMergeOnly: false }),
527
640
  approval: Object.freeze({
528
- humanHandoff: Object.freeze({
529
- enabled: false,
530
- candidatesFrom: Object.freeze([]),
531
- assignees: Object.freeze([]),
532
- }),
641
+ enabled: false,
642
+ candidatesFrom: Object.freeze([]),
643
+ assignees: Object.freeze([]),
533
644
  }),
534
645
  workflow: Object.freeze({
535
646
  asyncStartMode: "required",
@@ -539,17 +650,22 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
539
650
  }),
540
651
  localImplementation: Object.freeze({
541
652
  lightMode: Object.freeze({ enabled: false, maxFiles: 3, maxLines: 200, maxCopilotRounds: 1 }),
542
- issueless: Object.freeze({ enabled: false }),
653
+ issueless: false,
543
654
  }),
544
655
  queue: Object.freeze({
545
656
  maxParallel: 3,
546
657
  maxAutoFiledIssues: 10,
547
658
  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.
659
+ // queue.board is intentionally absent from defaults — setting it is an
660
+ // explicit operator opt-in for Projects-based queue ordering.
661
+ }),
662
+ tracker: Object.freeze({
663
+ provider: "github",
664
+ // tracker.board is intentionally absent from defaults — setting it is an
665
+ // explicit operator opt-in (mirrors queue.board). The logical-column ->
666
+ // Status mapping is queue.statusColumns (see TrackerConfig above), not a
667
+ // tracker-owned default.
551
668
  }),
552
- personas: Object.freeze({}),
553
669
  internalPathPatterns: Object.freeze([
554
670
  "^scripts/",
555
671
  "^docs/",
@@ -558,7 +674,7 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
558
674
  "^\\.github/",
559
675
  "^test/",
560
676
  ]),
561
- worktree: Object.freeze({ copyOnInit: Object.freeze([]), linkOnInit: Object.freeze([]) }),
677
+ worktree: Object.freeze({ entries: Object.freeze([]) }),
562
678
  });
563
679
 
564
680
  // ============================================================================
@@ -567,9 +683,9 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
567
683
 
568
684
  export const FileConfigSchema = z.strictObject({
569
685
  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(),
686
+ strategy: StrategyConfig.optional().describe("Work-intake strategy default."),
687
+ inputSource: InputSourceConfig.optional().describe("Spec source for local-first work."),
688
+ models: ModelsConfigBase.partial().superRefine(refineRoleTiers).describe("Model routing: conductor override, per-role overrides, tier aliases, and role→tier policy.").optional(),
573
689
  refinement: RefinementConfig.partial().describe("Refinement fan-out and Copilot review-round behavior.").optional(),
574
690
  gates: FileGatesConfig.describe("Gate review configuration: per-gate angle sets plus fan-out enforcement knobs.").optional(),
575
691
  autonomy: AutonomyConfig.partial().describe("How far the loop proceeds without operator confirmation.").optional(),
@@ -577,28 +693,27 @@ export const FileConfigSchema = z.strictObject({
577
693
  workflow: WorkflowConfig.partial().describe("Workflow posture: draft-first, retrospectives, dev mode, async start.").optional(),
578
694
  localImplementation: LocalImplementationConfig.partial().describe("Local implementation dispatch (light mode for small scoped changes).").optional(),
579
695
  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(),
696
+ 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
697
  internalPathPatterns: InternalPatternsConfig.describe("Regex whitelist for internal-only PR detection.").optional(),
582
698
  worktree: WorktreeConfig.partial().describe("Worktree provisioning: gitignored files/dirs copied or symlinked into fresh worktrees.").optional(),
583
699
  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(),
700
+ // 1.0 hard break (no dual-form): the deprecated `localPlanning` key (removed
701
+ // behavior in #1088, tolerated-but-unread since) is dropped from the 1.0
702
+ // schema entirely — an unknown key now fails closed like any other typo,
703
+ // rather than silently parsing and doing nothing.
587
704
  });
588
705
 
589
706
  // ============================================================================
590
- // Built-in persona registry — fallback when config.personas is absent
591
- //
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).
707
+ // Built-in persona registry — fallback for gate-review angle → reviewer
708
+ // persona resolution.
595
709
  //
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.
710
+ // Maps gate-review angle names to reviewer personas. Only the persona name is
711
+ // defined here; prompts and per-angle model overrides live on the angle's own
712
+ // config entry (gates.<gate>.angles[].persona/.prompt/.model/.tier) when a
713
+ // consumer wants to override this registry — see resolveReviewerRole.
599
714
  //
600
715
  // Angle names come from the gate-angle config (gates.draft.angles /
601
- // gates.preApproval.angles in .pi/dev-loop/defaults.yaml).
716
+ // gates.preApproval.angles in extension-defaults.yaml).
602
717
  // ============================================================================
603
718
 
604
719
  const BUILTIN_PERSONAS = Object.freeze({
@@ -641,17 +756,117 @@ const DEFAULT_REVIEWER_PERSONA = "default-reviewer";
641
756
  * @property {boolean} fallback - True when no specialized persona was found
642
757
  */
643
758
 
759
+ /**
760
+ * Normalize one raw `gates.<gate>.angles[]` entry (string sugar or object,
761
+ * possibly hand-built and never zod-validated — e.g. a test config object) to
762
+ * `{ name, mandatory?, enabled?, persona?, prompt?, model?, tier? }`. Returns
763
+ * null for a malformed/empty entry so callers can filter it out.
764
+ * @param {unknown} a
765
+ * @returns {{name: string, mandatory?: boolean, enabled?: boolean, persona?: string, prompt?: string, model?: string, tier?: string}|null}
766
+ */
767
+ function normalizeAngleEntry(a) {
768
+ if (typeof a === "string") {
769
+ const name = a.trim();
770
+ return name.length > 0 ? { name } : null;
771
+ }
772
+ if (a && typeof a === "object" && !Array.isArray(a)) {
773
+ const name = typeof a.name === "string" ? a.name.trim() : "";
774
+ if (name.length === 0) return null;
775
+ const entry = { name };
776
+ if (a.mandatory === true) entry.mandatory = true;
777
+ if (a.enabled === false) entry.enabled = false;
778
+ if (typeof a.persona === "string" && a.persona.trim().length > 0) entry.persona = a.persona.trim();
779
+ if (typeof a.prompt === "string" && a.prompt.length > 0) entry.prompt = a.prompt;
780
+ if (typeof a.model === "string" && a.model.trim().length > 0) entry.model = a.model.trim();
781
+ if (typeof a.tier === "string" && a.tier.trim().length > 0) entry.tier = a.tier.trim();
782
+ return entry;
783
+ }
784
+ return null;
785
+ }
786
+
787
+ /**
788
+ * Normalize a raw `gates.<gate>.angles` array into full entry objects,
789
+ * dropping malformed entries.
790
+ * @param {unknown} raw
791
+ * @returns {Array<{name: string, mandatory?: boolean, enabled?: boolean, persona?: string, prompt?: string, model?: string, tier?: string}>}
792
+ */
793
+ function normalizeAngleEntries(raw) {
794
+ if (!Array.isArray(raw)) return [];
795
+ const out = [];
796
+ for (const a of raw) {
797
+ const entry = normalizeAngleEntry(a);
798
+ if (entry) out.push(entry);
799
+ }
800
+ return out;
801
+ }
802
+
803
+ /**
804
+ * Find a named angle's configured entry, searching this config's own gates in
805
+ * a fixed priority order (draft, preApproval, spike). Angle persona/prompt/
806
+ * model/tier now live on the gate's own angle entry (D3/D4 — folded from the
807
+ * removed top-level `personas` map and angle-keyed `models.roles`/
808
+ * `models.roleTiers`), so a lookup by name alone (no gate context, matching
809
+ * `resolveReviewerRole`/`resolveRoleModel`'s existing signatures) checks each
810
+ * gate in turn and returns the first match. The shipped default config never
811
+ * gives the same angle name divergent overrides across gates, so this is
812
+ * unambiguous in practice.
813
+ *
814
+ * A DISABLED entry (`enabled: false`) is skipped, never returned: the same
815
+ * angle name can be a real, enabled angle with its own persona/prompt on one
816
+ * gate while merely disabled (a bare `enabled:false` placeholder, no override
817
+ * fields) on another — e.g. a gate that inherited the name via merge-by-name
818
+ * (D3) and dropped it. Returning that placeholder would shadow the other
819
+ * gate's real override. Both callers of this function (resolveReviewerRole,
820
+ * resolveRoleModel's angle path) only ever look up a name already present in
821
+ * SOME gate's enabled, resolved angle list (`resolveGateAngles`), so a name
822
+ * disabled everywhere and enabled nowhere is never actually queried — there
823
+ * is no "return the disabled entry as a last resort" case to serve.
824
+ * @param {DevLoopConfig} config
825
+ * @param {string} name
826
+ * @returns {{name: string, mandatory?: boolean, enabled?: boolean, persona?: string, prompt?: string, model?: string, tier?: string}|null}
827
+ */
828
+ function findAngleEntry(config, name) {
829
+ for (const gate of ["draft", "preApproval", "spike"]) {
830
+ const entries = normalizeAngleEntries(config?.gates?.[gate]?.angles);
831
+ const found = entries.find((e) => e.name === name && e.enabled !== false);
832
+ if (found) return found;
833
+ }
834
+ return null;
835
+ }
836
+
837
+ /**
838
+ * Resolve a tier alias to its per-harness concrete model, or `null`
839
+ * (`inherit`/unmapped/absent → no override). Deep-merges the alias mapping so
840
+ * a partial config override (e.g. `{ pi: "..." }`) preserves the untouched
841
+ * built-in harness key rather than erasing the whole `{claude,pi}` mapping.
842
+ * @param {DevLoopConfig} config
843
+ * @param {string|undefined} tierAlias
844
+ * @param {"claude"|"pi"} harness
845
+ * @returns {string|null}
846
+ */
847
+ function resolveTierMapping(config, tierAlias, harness) {
848
+ if (!tierAlias || tierAlias === "inherit") return null;
849
+ const builtinMapping = BUILTIN_TIERS[tierAlias];
850
+ const configMapping = config?.models?.tiers?.[tierAlias];
851
+ if (!builtinMapping && !configMapping) return null;
852
+ const mapping = { ...builtinMapping, ...configMapping };
853
+ const model = mapping[harness];
854
+ return typeof model === "string" && model.trim().length > 0 ? model.trim() : null;
855
+ }
856
+
644
857
  /**
645
858
  * Resolve a gate angle name to a reviewer persona and model.
646
859
  *
647
860
  * Resolution order:
648
- * 1. Look up angle in config.personas[angle] (consumer overrides)
861
+ * 1. Look up the angle's own configured entry across this config's gates
862
+ * (`gates.<gate>.angles[].persona`/`.prompt`/`.model` — consumer overrides,
863
+ * see {@link findAngleEntry})
649
864
  * 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
865
+ * 3. If found in either, apply the entry's `model` override if present
651
866
  * 4. If not found anywhere, fall back to default reviewer with angle as focus lens,
652
- * still applying any model override from config
867
+ * still applying any `model` override from the entry
653
868
  *
654
- * @param {object} config - DevLoopConfig (or partial with personas, models.roles)
869
+ * @param {object} config - DevLoopConfig (or a partial with gates)
655
870
  * @param {string|null|undefined} angle - Gate angle / lens name
656
871
  * @returns {RoleResolutionResult}
657
872
  */
@@ -666,17 +881,16 @@ export function resolveReviewerRole(config, angle) {
666
881
  };
667
882
  }
668
883
 
669
- // Resolution: config.personas > BUILTIN_PERSONAS > default-reviewer
670
- const configPersona = config?.personas?.[angle] ?? null;
884
+ const entry = findAngleEntry(config, angle);
671
885
  const builtinPersona = BUILTIN_PERSONAS[angle] ?? null;
672
- const persona = configPersona ?? builtinPersona;
673
- const modelOverride = config?.models?.roles?.[angle] || null;
886
+ const personaName = entry?.persona ?? builtinPersona?.persona ?? null;
887
+ const modelOverride = entry?.model ?? null;
674
888
 
675
- if (persona) {
889
+ if (personaName) {
676
890
  return {
677
- persona: persona.persona,
678
- model: modelOverride || persona.defaultModel || null,
679
- prompt: persona.prompt || null,
891
+ persona: personaName,
892
+ model: modelOverride || builtinPersona?.defaultModel || null,
893
+ prompt: entry?.prompt ?? null,
680
894
  fallback: false,
681
895
  };
682
896
  }
@@ -695,19 +909,20 @@ export function resolveReviewerRole(config, angle) {
695
909
  * `null` (inherit → pass no model override).
696
910
  *
697
911
  * 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`).
912
+ * 1. `kind: "angle"` (gate review dispatch): the angle's own configured
913
+ * `model` (concrete, found via {@link findAngleEntry}), else its `tier`,
914
+ * else the built-in `review` tier — a gate review runs at review quality
915
+ * even when the angle's name collides with a routine role, e.g. the
916
+ * `docs` angle resolves via the `review` tier (high), not the `docs`
917
+ * writer role's low tier. (Its persona/agent still comes from
918
+ * `resolveReviewerRole`; only the tier is forced to review.)
919
+ * 2. `kind: "role"`/absent (routine subagent): `models.roles[role]`
920
+ * (concrete, highest precedence), else `models.roleTiers[role]` (or the
921
+ * built-in role tier) mapped through `models.tiers[tier][harness]` (or
922
+ * built-in tiers); `inherit`/absent/null → `null`. When the name is not a
923
+ * named role, falls back to the tier for its review persona (so a
924
+ * non-colliding gate angle passed without `kind` still resolves high via
925
+ * `review`).
711
926
  *
712
927
  * Callers dispatching a gate review angle whose name may collide with a routine
713
928
  * role (only `docs` today) MUST pass `kind: "angle"` to avoid the silent
@@ -724,40 +939,31 @@ export function resolveReviewerRole(config, angle) {
724
939
  export function resolveRoleModel(config, { role, harness, kind } = {}) {
725
940
  if (!role || (harness !== "claude" && harness !== "pi")) return null;
726
941
 
727
- // 1. Concrete per-role/angle override wins outright (over any tier).
942
+ if (kind === "angle") {
943
+ const entry = findAngleEntry(config, role);
944
+ if (typeof entry?.model === "string" && entry.model.length > 0) return entry.model;
945
+ const tierAlias = entry?.tier ?? BUILTIN_ROLE_TIERS.review;
946
+ return resolveTierMapping(config, tierAlias, harness);
947
+ }
948
+
949
+ // 1. Concrete per-role override wins outright (over any tier). Role-keyed
950
+ // only — angle-keyed concrete overrides moved to the gate's angle entry
951
+ // (kind: "angle", above).
728
952
  const concrete = config?.models?.roles?.[role];
729
953
  if (typeof concrete === "string" && concrete.trim().length > 0) {
730
954
  return concrete.trim();
731
955
  }
732
956
 
733
- // 2. Resolve a tier alias for this role/angle.
957
+ // 2. Resolve a tier alias for this role.
734
958
  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
- }
959
+ let tierAlias = roleTiers[role];
960
+ if (tierAlias === undefined) {
961
+ // Not a named role — treat as a gate angle and inherit its review
962
+ // persona's tier (critical angles resolve high via the `review` persona).
963
+ const { persona } = resolveReviewerRole(config, role);
964
+ tierAlias = roleTiers[persona];
749
965
  }
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;
966
+ return resolveTierMapping(config, tierAlias, harness);
761
967
  }
762
968
 
763
969
  // ============================================================================
@@ -789,11 +995,16 @@ function resolveExtensionDefaultsPath(options = {}) {
789
995
 
790
996
  // ============================================================================
791
997
 
998
+ /** True for a non-null, non-array plain object. */
999
+ function isPlainObject(v) {
1000
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1001
+ }
1002
+
792
1003
  /**
793
1004
  * Merge two config objects. Keys in `source` override keys in `target`.
794
1005
  * Family objects merge at one level, except `gates`, which merges one extra
795
1006
  * nested gate-object level so settings can override `draft.requireCi` without
796
- * restating the shipped draft angles.
1007
+ * restating the shipped draft angles (see {@link mergeGatesFamily}).
797
1008
  * @param {Record<string, unknown>} target
798
1009
  * @param {Record<string, unknown>} source
799
1010
  * @returns {Record<string, unknown>}
@@ -801,17 +1012,9 @@ function resolveExtensionDefaultsPath(options = {}) {
801
1012
  function mergeConfigLayers(target, source) {
802
1013
  const result = { ...target };
803
1014
  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
- ) {
1015
+ if (key !== "version" && isPlainObject(source[key]) && isPlainObject(result[key])) {
813
1016
  result[key] = key === "gates"
814
- ? mergeNestedObject(result[key], source[key])
1017
+ ? mergeGatesFamily(result[key], source[key])
815
1018
  : { ...(result[key] || {}), ...(source[key] || {}) };
816
1019
  } else {
817
1020
  result[key] = source[key];
@@ -820,27 +1023,70 @@ function mergeConfigLayers(target, source) {
820
1023
  return result;
821
1024
  }
822
1025
 
823
- function mergeNestedObject(target, source) {
824
- const result = { ...(target || {}) };
1026
+ const MERGE_BY_NAME_GATE_KEYS = Object.freeze(["draft", "preApproval", "spike"]);
825
1027
 
1028
+ /** Merge the `gates` family: draft/preApproval/spike get the gate-object merge
1029
+ * ({@link mergeGateObject}, angle-array-by-name aware); every other `gates.*`
1030
+ * key (`anglePool`, `requireFanoutEvidence`, ...) merges shallowly as before. */
1031
+ function mergeGatesFamily(target, source) {
1032
+ const result = { ...(target || {}) };
826
1033
  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
- ) {
1034
+ if (MERGE_BY_NAME_GATE_KEYS.includes(key) && isPlainObject(source[key]) && isPlainObject(result[key])) {
1035
+ result[key] = mergeGateObject(result[key], source[key]);
1036
+ } else if (isPlainObject(source[key]) && isPlainObject(result[key])) {
835
1037
  result[key] = { ...(result[key] || {}), ...(source[key] || {}) };
836
1038
  } else {
837
1039
  result[key] = source[key];
838
1040
  }
839
1041
  }
1042
+ return result;
1043
+ }
840
1044
 
1045
+ /**
1046
+ * Merge one gate object (draft/preApproval/spike) across config layers.
1047
+ * `angles` merges BY NAME (D3): a later layer can add a new angle, or override
1048
+ * an existing angle's flags (including `enabled: false` to drop it), without
1049
+ * restating the whole array. `dynamic` merges shallowly (its two booleans).
1050
+ * Every other key (`required`, `requireCi`, `blockCleanOnFindingSeverities`)
1051
+ * is replaced wholesale, same as any scalar/array config value.
1052
+ */
1053
+ function mergeGateObject(target, source) {
1054
+ const result = { ...(target || {}) };
1055
+ for (const key of Object.keys(source || {})) {
1056
+ if (key === "angles") {
1057
+ result.angles = mergeAngleArrays(result.angles, source.angles);
1058
+ } else if (key === "dynamic" && isPlainObject(source.dynamic) && isPlainObject(result.dynamic)) {
1059
+ result.dynamic = { ...(result.dynamic || {}), ...(source.dynamic || {}) };
1060
+ } else {
1061
+ result[key] = source[key];
1062
+ }
1063
+ }
841
1064
  return result;
842
1065
  }
843
1066
 
1067
+ /**
1068
+ * Merge two `gates.<gate>.angles` arrays BY `name` (D3): entries in `target`
1069
+ * keep their position; a `source` entry with a name already in `target`
1070
+ * overrides that entry's fields (shallow — e.g. `{ enabled: false }` drops it
1071
+ * without touching its `persona`/`prompt`); a `source` entry with a new name
1072
+ * is appended. This is what lets a later config layer add or disable a single
1073
+ * angle without restating the whole upstream list.
1074
+ * @param {unknown} targetRaw
1075
+ * @param {unknown} sourceRaw
1076
+ * @returns {Array<{name: string}>}
1077
+ */
1078
+ function mergeAngleArrays(targetRaw, sourceRaw) {
1079
+ const targetEntries = normalizeAngleEntries(targetRaw);
1080
+ const sourceEntries = normalizeAngleEntries(sourceRaw);
1081
+ if (targetEntries.length === 0) return sourceEntries;
1082
+ const byName = new Map(targetEntries.map((e) => [e.name, e]));
1083
+ for (const entry of sourceEntries) {
1084
+ const existing = byName.get(entry.name);
1085
+ byName.set(entry.name, existing ? { ...existing, ...entry } : entry);
1086
+ }
1087
+ return [...byName.values()];
1088
+ }
1089
+
844
1090
  /**
845
1091
  * Try to read and parse a config file (YAML preferred, JSON fallback).
846
1092
  * Detects format from file extension: .yaml/.yml → YAML, .json → JSON.
@@ -975,7 +1221,27 @@ async function applyLayer(merged, basePaths, layer, warnings, errors, options =
975
1221
  return merged;
976
1222
  }
977
1223
 
978
- // Validate the file's structure before merging
1224
+ // Deprecated `strategy: "github-first"` alias (issue #1408, the
1225
+ // tracker-agnostic seam): normalized to "tracker-first" BEFORE this layer's
1226
+ // own FileConfigSchema validation, since the schema enum only accepts the
1227
+ // canonical value and would otherwise drop the whole layer as invalid.
1228
+ if (data.strategy === "github-first") {
1229
+ warnings.push(
1230
+ `strategy: "github-first" is a deprecated alias for "tracker-first" (issue #1408). ` +
1231
+ `Update ${path.basename(filePath)} to use "tracker-first"; the alias will be removed in a future version.`
1232
+ );
1233
+ data = { ...data, strategy: "tracker-first" };
1234
+ }
1235
+
1236
+ // Validate the file's structure before merging. Pre-existing behavior
1237
+ // (unrelated to the #1404 angle-entry redesign): a schema violation ANYWHERE
1238
+ // in this layer's file drops the WHOLE layer (errors is populated, `merged`
1239
+ // is returned unchanged) rather than merging the rest of the file's valid
1240
+ // keys — a single typo'd angle field is exactly as disruptive as a
1241
+ // completely broken file. `errors[].message` now names the offending
1242
+ // path/field (see GateAngleEntry's preprocess-not-union shape), so the
1243
+ // failure is at least actionable; the whole-layer-skip granularity itself
1244
+ // is an existing, separate concern.
979
1245
  const validation = FileConfigSchema.safeParse(data);
980
1246
  if (!validation.success) {
981
1247
  errors.push({
@@ -1119,6 +1385,20 @@ export async function loadDevLoopConfig(options = {}) {
1119
1385
  }
1120
1386
  }
1121
1387
 
1388
+ // Deprecated `queue.board` -> `tracker.board` alias (issue #1408, the
1389
+ // tracker-agnostic seam). Runs on the fully-merged object (unlike the
1390
+ // `strategy: "github-first"` alias above, this only affects cross-layer
1391
+ // MERGE PRECEDENCE, not per-layer schema validity — queue.board is still a
1392
+ // valid FileConfigSchema shape on its own — so normalizing once here, after
1393
+ // every layer has merged, is sufficient).
1394
+ if (isPlainObject(merged.queue?.board) && !isPlainObject(merged.tracker?.board)) {
1395
+ warnings.push(
1396
+ `queue.board is a deprecated alias for tracker.board (issue #1408). ` +
1397
+ `Update .devloops to set tracker.board instead; the alias will be removed in a future version.`
1398
+ );
1399
+ merged = { ...merged, tracker: { ...(merged.tracker ?? {}), board: merged.queue.board } };
1400
+ }
1401
+
1122
1402
  // Validate final merged config
1123
1403
  const result = DevLoopConfigSchema.safeParse(merged);
1124
1404
  if (!result.success) {
@@ -1265,15 +1545,15 @@ export function resolveRefinementConfig(config, key) {
1265
1545
  }
1266
1546
 
1267
1547
  if (key === "stopOnLowSignal") {
1268
- return config?.refinement?.stopOnLowSignal ?? DEFAULT_REFINEMENT_CONFIG.stopOnLowSignal;
1548
+ return config?.refinement?.lowSignal?.enabled ?? DEFAULT_REFINEMENT_CONFIG.lowSignal.enabled;
1269
1549
  }
1270
1550
 
1271
1551
  if (key === "lowSignalRoundThreshold") {
1272
- return config?.refinement?.lowSignalRoundThreshold ?? DEFAULT_REFINEMENT_CONFIG.lowSignalRoundThreshold;
1552
+ return config?.refinement?.lowSignal?.roundThreshold ?? DEFAULT_REFINEMENT_CONFIG.lowSignal.roundThreshold;
1273
1553
  }
1274
1554
 
1275
1555
  if (key === "lowSignalMaxComments") {
1276
- return config?.refinement?.lowSignalMaxComments ?? DEFAULT_REFINEMENT_CONFIG.lowSignalMaxComments;
1556
+ return config?.refinement?.lowSignal?.maxComments ?? DEFAULT_REFINEMENT_CONFIG.lowSignal.maxComments;
1277
1557
  }
1278
1558
 
1279
1559
  throw new Error(`Unknown refinement config key: ${key}`);
@@ -1316,26 +1596,35 @@ export function resolveRefinement(config) {
1316
1596
  * config omits them (caller falls back to skill-defined defaults). Boolean gate
1317
1597
  * flags always resolve to stable defaults.
1318
1598
  *
1599
+ * The returned shape is the STABLE, resolved view every other angle resolver
1600
+ * and consumer builds on — `mandatoryAngles`/`excludeAngles`/`dynamicAngles`/
1601
+ * `additiveAngles` are derived here from the unified `gates.<gate>.angles`
1602
+ * array (`mandatory: true` / `enabled: false` per-entry, D3) and the
1603
+ * `gates.<gate>.dynamic` sub-object, so downstream consumers keep reading the
1604
+ * same field names the pre-1.0 flat config keys used. (`extraAngles` no
1605
+ * longer exists as a concept: D3's merge-by-name lets a later config layer add
1606
+ * a plain, non-mandatory angle to `angles` directly, without restating the
1607
+ * list — the exact ergonomic `extraAngles` used to provide.)
1608
+ *
1319
1609
  * @param {DevLoopConfig} config
1320
1610
  * @param {"draft"|"preApproval"|"spike"} gate
1321
1611
  * @returns {{ angles: string[]|null, excludeAngles: string[], mandatoryAngles: string[], required: boolean, requireCi: boolean, blockCleanOnFindingSeverities: string[], dynamicAngles: boolean, additiveAngles: boolean }}
1322
1612
  */
1323
1613
  export function resolveGateConfig(config, gate) {
1324
1614
  const gateConfig = config?.gates?.[gate];
1615
+ const entries = normalizeAngleEntries(gateConfig?.angles);
1616
+ // An explicitly-empty (or all-garbage/malformed) array is a real configured
1617
+ // "no angles" — distinct from the key being absent entirely, which callers
1618
+ // read as "fall back to skill-defined defaults" (angles: null).
1619
+ const hasAngles = Array.isArray(gateConfig?.angles);
1325
1620
  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
- : [],
1621
+ angles: hasAngles ? entries.filter((e) => e.enabled !== false).map((e) => e.name) : null,
1622
+ excludeAngles: entries.filter((e) => e.enabled === false).map((e) => e.name),
1623
+ mandatoryAngles: entries.filter((e) => e.enabled !== false && e.mandatory === true).map((e) => e.name),
1335
1624
  required: gateConfig?.required ?? true,
1336
1625
  requireCi: gateConfig?.requireCi ?? true,
1337
- dynamicAngles: gateConfig?.dynamicAngles ?? false,
1338
- additiveAngles: gateConfig?.additiveAngles ?? false,
1626
+ dynamicAngles: gateConfig?.dynamic?.subtractive ?? false,
1627
+ additiveAngles: gateConfig?.dynamic?.additive ?? false,
1339
1628
  blockCleanOnFindingSeverities: gateConfig?.blockCleanOnFindingSeverities && Array.isArray(gateConfig.blockCleanOnFindingSeverities)
1340
1629
  ? [...gateConfig.blockCleanOnFindingSeverities]
1341
1630
  : ["must-fix"],
@@ -1351,7 +1640,7 @@ export function resolveGateConfig(config, gate) {
1351
1640
  * a durable findings-log ledger exists for that gate + head SHA. Using a
1352
1641
  * `!== false` test (rather than `=== true`) keeps the opt-out semantics robust
1353
1642
  * for programmatically-built config objects that bypass schema defaulting. See
1354
- * docs/gate-review-sub-loop-contract.md.
1643
+ * skills/docs/gate-review-sub-loop-contract.md.
1355
1644
  *
1356
1645
  * @param {DevLoopConfig} config
1357
1646
  * @returns {boolean}
@@ -1365,7 +1654,7 @@ export function resolveRequireFanoutEvidence(config) {
1365
1654
  * requireFanoutProvenance. A floor of 2 is the smallest count that is not a
1366
1655
  * single agent; it raises the bar but does not prove independence (provenance
1367
1656
  * is self-reported — see the honest caveat in
1368
- * docs/gate-review-sub-loop-contract.md).
1657
+ * skills/docs/gate-review-sub-loop-contract.md).
1369
1658
  */
1370
1659
  export const FANOUT_PROVENANCE_MIN_REVIEWERS = 2;
1371
1660
 
@@ -1377,7 +1666,7 @@ export const FANOUT_PROVENANCE_MIN_REVIEWERS = 2;
1377
1666
  * `=== true` test so behavior is byte-identical to today unless a repo
1378
1667
  * explicitly opts in via `gates.requireFanoutProvenance: true`. Layered on top
1379
1668
  * of fan-out evidence enforcement (see buildFanoutEnforcement). See
1380
- * docs/gate-review-sub-loop-contract.md.
1669
+ * skills/docs/gate-review-sub-loop-contract.md.
1381
1670
  *
1382
1671
  * @param {DevLoopConfig} config
1383
1672
  * @returns {boolean}
@@ -1406,7 +1695,7 @@ export function resolveRejectForeignAngles(config) {
1406
1695
  * keeps the opt-out semantics robust for programmatically-built config objects
1407
1696
  * that bypass schema defaulting. The disposition ledger is written regardless;
1408
1697
  * this flag only suppresses the auditable PR comment. See
1409
- * docs/gate-review-sub-loop-contract.md.
1698
+ * skills/docs/gate-review-sub-loop-contract.md.
1410
1699
  *
1411
1700
  * @param {DevLoopConfig} config
1412
1701
  * @returns {boolean}
@@ -1440,14 +1729,14 @@ export function resolveLightMode(config) {
1440
1729
  /**
1441
1730
  * Resolve the issue-less PR-first any-scope opt-in (#1349).
1442
1731
  *
1443
- * True only when `localImplementation.issueless.enabled` is exactly `true`;
1444
- * absent, false, or malformed values resolve to false (fail closed).
1732
+ * True only when `localImplementation.issueless` is exactly `true`; absent,
1733
+ * false, or malformed values resolve to false (fail closed).
1445
1734
  *
1446
1735
  * @param {DevLoopConfig} config
1447
1736
  * @returns {boolean}
1448
1737
  */
1449
1738
  export function resolveIssuelessEnabled(config) {
1450
- return config?.localImplementation?.issueless?.enabled === true;
1739
+ return config?.localImplementation?.issueless === true;
1451
1740
  }
1452
1741
 
1453
1742
  /**
@@ -1534,10 +1823,13 @@ export function resolveGateDispatchMode(config, gate, { scope, hasFullLabel = fa
1534
1823
  /**
1535
1824
  * Resolve review angles for a specific gate from the merged dev-loop config.
1536
1825
  *
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).
1826
+ * Unions the mandatory angle names (entries with `mandatory: true`) with the
1827
+ * gate's full configured angle list, then removes disabled entries
1828
+ * (`enabled: false`): `mandatoryAngles ∪ angles − disabled`, deduplicated (a
1829
+ * mandatory angle also present in `angles` is a no-op — it appears exactly
1830
+ * once and keeps its mandatory status). Returns null only when the gate has
1831
+ * no configured `angles` at all (caller falls back to skill-defined
1832
+ * defaults); an explicitly-empty `angles: []` returns `[]`.
1541
1833
  *
1542
1834
  * @param {DevLoopConfig} config
1543
1835
  * @param {"draft"|"preApproval"} gate
@@ -1546,6 +1838,11 @@ export function resolveGateDispatchMode(config, gate, { scope, hasFullLabel = fa
1546
1838
  export function resolveGateAngles(config, gate) {
1547
1839
  const gateConfig = resolveGateConfig(config, gate);
1548
1840
  if (gateConfig.angles === null && gateConfig.mandatoryAngles.length === 0) return null;
1841
+ // gateConfig.angles is already exclude-filtered (resolveGateConfig drops
1842
+ // enabled:false entries); the excludeAngles filter below is a defensive
1843
+ // no-op that keeps this correct even for a hand-built config object that
1844
+ // sets excludeAngles/angles independently rather than through the
1845
+ // gates.<gate>.angles[].enabled shape.
1549
1846
  const excluded = new Set(gateConfig.excludeAngles);
1550
1847
  const merged = [...new Set([...gateConfig.mandatoryAngles, ...(gateConfig.angles ?? [])])];
1551
1848
  return merged.filter(a => !excluded.has(a));
@@ -1732,24 +2029,105 @@ export function resolveWorkflowConfig(config, key) {
1732
2029
  throw new Error(`Unknown workflow config key: ${key}`);
1733
2030
  }
1734
2031
 
2032
+ /** Best-effort `git` probe: stdout trimmed on success, `null` on any failure
2033
+ * (missing repo, missing ref, git not on PATH, etc.) — never throws. */
2034
+ function tryGit(args, cwd) {
2035
+ try {
2036
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
2037
+ } catch {
2038
+ return null;
2039
+ }
2040
+ }
2041
+
2042
+ // Last-resort literal when git auto-detection cannot resolve anything (e.g. no
2043
+ // git repo at cwd) — matches the branch name every prior hardcoded "main"/
2044
+ // "origin/main" call site already assumed.
2045
+ const AUTO_DETECT_BASE_BRANCH_FALLBACK = "main";
2046
+
2047
+ /**
2048
+ * Auto-detect the repo's default branch (bare name) at `cwd`: prefer the
2049
+ * remote's advertised default (`origin/HEAD`, works for any branch name), else
2050
+ * probe `main`/`master` as a remote-tracking or local ref, else fall back to
2051
+ * the literal "main". Every probe is best-effort; a missing/unreadable repo
2052
+ * degrades to the literal fallback rather than throwing.
2053
+ * @param {string} cwd
2054
+ * @returns {string}
2055
+ */
2056
+ function autoDetectDefaultBranch(cwd) {
2057
+ const originHead = tryGit(["rev-parse", "--abbrev-ref", "origin/HEAD"], cwd);
2058
+ if (originHead && originHead.startsWith("origin/")) {
2059
+ return originHead.slice("origin/".length);
2060
+ }
2061
+ for (const candidate of ["main", "master"]) {
2062
+ if (tryGit(["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${candidate}`], cwd) !== null) return candidate;
2063
+ if (tryGit(["rev-parse", "--verify", "--quiet", `refs/heads/${candidate}`], cwd) !== null) return candidate;
2064
+ }
2065
+ return AUTO_DETECT_BASE_BRANCH_FALLBACK;
2066
+ }
2067
+
2068
+ /**
2069
+ * Resolve the effective base/integration branch (bare name — never
2070
+ * `origin/`-prefixed) for worktree creation, PR targeting, and merge-base
2071
+ * scope measurement (#1368).
2072
+ *
2073
+ * `workflow.baseBranch` (a non-empty trimmed string) is the authoritative
2074
+ * override; unset, malformed, or empty is treated identically to unset and
2075
+ * falls back to the existing auto-detect: the remote's advertised default
2076
+ * branch (`origin/HEAD`), else `main`/`master`, else the literal "main".
2077
+ * Never throws.
2078
+ *
2079
+ * Callers own the `origin/` prefix: worktree creation prepends it (a remote
2080
+ * ref), gh/PR base flags pass the bare name straight through.
2081
+ *
2082
+ * @param {DevLoopConfig|null|undefined} config
2083
+ * @param {{ cwd?: string }} [options]
2084
+ * @returns {string} bare branch name
2085
+ */
2086
+ export function resolveBaseBranch(config, { cwd = process.cwd() } = {}) {
2087
+ const configured = config?.workflow?.baseBranch;
2088
+ if (typeof configured === "string" && configured.trim().length > 0) {
2089
+ // A prefix-only value (e.g. "origin/", "refs/heads/") normalizes to empty —
2090
+ // treat that as unset and fall through to auto-detect, never return "".
2091
+ const bare = normalizeToBareBranch(configured.trim());
2092
+ if (bare.length > 0) return bare;
2093
+ }
2094
+ return autoDetectDefaultBranch(cwd);
2095
+ }
2096
+
2097
+ /**
2098
+ * Reduce a configured base value to a BARE branch name. Callers prepend
2099
+ * `origin/` for remote refs, so a configured `origin/main` /
2100
+ * `refs/remotes/origin/main` / `refs/heads/main` must be stripped to `main`
2101
+ * first — otherwise the worktree base double-prefixes to `origin/origin/main`.
2102
+ * A branch name that merely contains a slash (e.g. `spike/vite`) is left intact.
2103
+ */
2104
+ export function normalizeToBareBranch(value) {
2105
+ return value
2106
+ .replace(/^refs\/remotes\/origin\//, "")
2107
+ .replace(/^refs\/heads\//, "")
2108
+ .replace(/^origin\//, "");
2109
+ }
2110
+
1735
2111
  /**
1736
2112
  * Resolve the worktree lifecycle config from the merged dev-loop config.
1737
2113
  *
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.
2114
+ * Returns `{ copyOnInit, linkOnInit }` (split by each entry's `mode`) with
2115
+ * empty-array defaults when the config omits `worktree.entries` or it is
2116
+ * empty. Entries are trimmed, repo-relative literal paths or glob patterns
2117
+ * expanded against the main checkout at provision time. See
2118
+ * scripts/loop/provision-worktree.mjs.
1742
2119
  *
1743
2120
  * @param {DevLoopConfig} config
1744
2121
  * @returns {{ copyOnInit: string[], linkOnInit: string[] }}
1745
2122
  */
1746
2123
  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) };
2124
+ const entries = Array.isArray(config?.worktree?.entries) ? config.worktree.entries : [];
2125
+ const pathsForMode = (mode) =>
2126
+ entries
2127
+ .filter((e) => e && typeof e === "object" && e.mode === mode)
2128
+ .map((e) => (typeof e.path === "string" ? e.path.trim() : ""))
2129
+ .filter((p) => p.length > 0);
2130
+ return { copyOnInit: pathsForMode("copy"), linkOnInit: pathsForMode("link") };
1753
2131
  }
1754
2132
 
1755
2133
  /**
@@ -1864,16 +2242,16 @@ export function resolveUiReviewDriveRecipe(config) {
1864
2242
  * Resolve the human-handoff config from the merged dev-loop config (#920).
1865
2243
  *
1866
2244
  * 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
2245
+ * disabled with empty arrays when the `approval` section is absent. When
2246
+ * disabled (default), this is a no-op: callers must not source candidates or
2247
+ * assign anyone. Pairs with `autonomy.humanMergeOnly`: when human-merge is
1870
2248
  * enforced, this names who should take the merge.
1871
2249
  *
1872
2250
  * @param {DevLoopConfig} config
1873
2251
  * @returns {{ enabled: boolean, candidatesFrom: ("codeowners"|"recent-committers")[], assignees: string[] }}
1874
2252
  */
1875
2253
  export function resolveHumanHandoffConfig(config) {
1876
- const hh = config?.approval?.humanHandoff;
2254
+ const hh = config?.approval;
1877
2255
  const enabled = hh?.enabled === true;
1878
2256
  const list = (v) =>
1879
2257
  Array.isArray(v)
@@ -1894,3 +2272,34 @@ export function resolveHumanHandoffConfig(config) {
1894
2272
  assignees: enabled ? assignees : [],
1895
2273
  };
1896
2274
  }
2275
+
2276
+ /**
2277
+ * Resolve the tracker provider registry key (issue #1408). Defaults to
2278
+ * `"github"` — the only built-in provider in v1 — when unset. Callers pass
2279
+ * this to `resolveTrackerAdapter` (`@dev-loops/core/tracker`).
2280
+ *
2281
+ * @param {DevLoopConfig} config
2282
+ * @returns {string}
2283
+ */
2284
+ export function resolveTrackerProvider(config) {
2285
+ const raw = config?.tracker?.provider;
2286
+ return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : "github";
2287
+ }
2288
+
2289
+ /**
2290
+ * Resolve the effective tracker board identifier. `tracker.board` is
2291
+ * canonical; `queue.board` is a DEPRECATED alias, already normalized onto
2292
+ * `tracker.board` by `loadDevLoopConfig` (with a load-time warning) for any
2293
+ * config that went through the loader. This resolver also accepts a
2294
+ * hand-built config object that sets `queue.board` directly (bypassing the
2295
+ * loader, e.g. in a test) and falls back to it — with no warning, since only
2296
+ * the loader surfaces warnings.
2297
+ *
2298
+ * @param {DevLoopConfig} config
2299
+ * @returns {{ number?: number, title?: string } | null}
2300
+ */
2301
+ export function resolveTrackerBoard(config) {
2302
+ if (isPlainObject(config?.tracker?.board)) return config.tracker.board;
2303
+ if (isPlainObject(config?.queue?.board)) return config.queue.board;
2304
+ return null;
2305
+ }