@dev-loops/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/bin/capture-deep-persona-signals.mjs +143 -0
  2. package/bin/ensure-phase-files.mjs +7 -0
  3. package/bin/log-bash-exit-1.mjs +7 -0
  4. package/bin/parse-review-threads.mjs +7 -0
  5. package/package.json +78 -0
  6. package/src/analysis/change-classifier.mjs +146 -0
  7. package/src/analysis/diff-analyzer.mjs +285 -0
  8. package/src/bash-exit-one.mjs +130 -0
  9. package/src/cli/helpers.mjs +22 -0
  10. package/src/cli/primitives.mjs +70 -0
  11. package/src/cli/retry-wrapper.mjs +169 -0
  12. package/src/cli/subcommand-runner.mjs +246 -0
  13. package/src/config/config.mjs +965 -0
  14. package/src/debt/cluster.mjs +240 -0
  15. package/src/debt/debt-finding.mjs +68 -0
  16. package/src/debt/debt-signal.mjs +46 -0
  17. package/src/debt/deep-persona-signals.mjs +266 -0
  18. package/src/debt/remediation-to-issue.mjs +121 -0
  19. package/src/debt/score.mjs +127 -0
  20. package/src/debt/shape.mjs +214 -0
  21. package/src/github/copilot-helpers.mjs +343 -0
  22. package/src/github/repo-slug.mjs +105 -0
  23. package/src/github/review-threads.mjs +343 -0
  24. package/src/harness/adapter.mjs +57 -0
  25. package/src/harness/index.mjs +3 -0
  26. package/src/harness/noop-adapter.mjs +22 -0
  27. package/src/harness/pi-adapter.mjs +47 -0
  28. package/src/loop/async-start-contract.mjs +170 -0
  29. package/src/loop/conductor-routing.mjs +817 -0
  30. package/src/loop/copilot-ci-status.mjs +255 -0
  31. package/src/loop/copilot-loop-iterations.mjs +161 -0
  32. package/src/loop/copilot-loop-state.mjs +510 -0
  33. package/src/loop/handoff-envelope.mjs +800 -0
  34. package/src/loop/issue-refinement-artifact.mjs +268 -0
  35. package/src/loop/lifecycle-state.mjs +342 -0
  36. package/src/loop/phase-files.mjs +187 -0
  37. package/src/loop/policy-constants.mjs +17 -0
  38. package/src/loop/pr-gate-coordination.mjs +1278 -0
  39. package/src/loop/public-dev-loop-routing-contract.mjs +277 -0
  40. package/src/loop/public-dev-loop-routing.mjs +1746 -0
  41. package/src/loop/queue-board-ordering.mjs +38 -0
  42. package/src/loop/queue-board-sync.mjs +223 -0
  43. package/src/loop/queue-driver.mjs +164 -0
  44. package/src/loop/queue-parallel.mjs +190 -0
  45. package/src/loop/queue-state.mjs +230 -0
  46. package/src/loop/retrospective-checkpoint.mjs +178 -0
  47. package/src/loop/reviewer-loop-state.mjs +456 -0
  48. package/src/loop/run-inspection.mjs +604 -0
  49. package/src/loop/steering.mjs +793 -0
  50. package/src/loop/timeout-policy.mjs +73 -0
  51. package/src/loop/tracker-first-loop-state.mjs +87 -0
  52. package/src/loop/tracker-pr-state.mjs +301 -0
  53. package/src/loop/worktree-guard.mjs +141 -0
  54. package/src/refinement/ac-dod-matrix.mjs +95 -0
@@ -0,0 +1,965 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { parse as parseYaml } from "yaml";
4
+ import { z } from "zod";
5
+
6
+ // ============================================================================
7
+ // Sub-schemas
8
+ //
9
+ // BUILT_IN_DEFAULTS remains the canonical shipped default surface for loader
10
+ // fallbacks. Select field-level defaults may still exist where merged-schema
11
+ // callers need a stable value even when they construct config objects directly.
12
+ // ============================================================================
13
+
14
+ const StrategyConfig = z.strictObject({
15
+ default: z.enum(["local-first", "github-first"]),
16
+ });
17
+
18
+ const InputSourceConfig = z.strictObject({
19
+ default: z.enum(["tracker", "phase-docs"]),
20
+ });
21
+
22
+ const ModelsConfig = z.strictObject({
23
+ conductor: z.string().trim().min(1).optional(),
24
+ roles: z.record(z.string(), z.string().trim().min(1)).optional(),
25
+ });
26
+
27
+ const RefinementConfig = z.strictObject({
28
+ fanOut: z.number().int().min(1).max(10),
29
+ mode: z.enum(["parallel", "sequential"]),
30
+ maxCopilotRounds: z.number().int().positive().default(5),
31
+ stopOnLowSignal: z.boolean().default(false),
32
+ lowSignalRoundThreshold: z.number().int().nonnegative().default(3),
33
+ lowSignalMaxComments: z.number().int().nonnegative().default(2),
34
+ roles: z.array(z.string().trim().min(1)).optional(),
35
+ });
36
+
37
+ const GateConfig = z.strictObject({
38
+ angles: z.array(z.string().trim().min(1)).optional(),
39
+ excludeAngles: z.array(z.string().trim().min(1)).default([]),
40
+ mandatoryAngles: z.array(z.string().trim().min(1)).default([]),
41
+ required: z.boolean().default(true),
42
+ requireCi: z.boolean().default(true),
43
+ blockCleanOnFindingSeverities: z
44
+ .array(z.enum(["must-fix", "worth-fixing-now", "defer"]))
45
+ .min(1)
46
+ .default(["must-fix"]),
47
+ dynamicAngles: z.boolean().default(false),
48
+ });
49
+
50
+ const GatesConfig = z.strictObject({
51
+ draft: GateConfig.optional(),
52
+ // `requireCi` is only behaviorally configurable for the draft gate.
53
+ // preApproval always requires CI even if config repeats `requireCi`.
54
+ preApproval: GateConfig.optional(),
55
+ });
56
+
57
+ const AutonomyConfig = z.strictObject({
58
+ stopAt: z.array(
59
+ z.enum(["refinement", "draft-pr", "pre-approval", "merge"])
60
+ ),
61
+ });
62
+
63
+ const WorkflowConfig = z.strictObject({
64
+ asyncStartMode: z.enum(["required", "allowed"]).default("required"),
65
+ requireRetrospective: z.boolean(),
66
+ requireRetrospectiveGate: z.boolean().default(false),
67
+ requireDraftFirst: z.boolean(),
68
+ devModeDefault: z.boolean(),
69
+ });
70
+
71
+ const LocalImplementationConfig = z.strictObject({
72
+ /** Opt into light mode for small scoped changes */
73
+ lightMode: z.strictObject({
74
+ enabled: z.boolean(),
75
+ maxFiles: z.number().int().min(1),
76
+ maxLines: z.number().int().min(1),
77
+ }).optional(),
78
+ });
79
+
80
+ /** Queue mode config */
81
+ const QueueConfig = z.strictObject({
82
+ maxParallel: z.number().int().min(1).max(10).default(3),
83
+ maxAutoFiledIssues: z.number().int().min(0).max(100).default(10),
84
+ reDispatchMaxRetries: z.number().int().min(0).max(10).default(1),
85
+ projectNumber: z.number().int().positive().optional(),
86
+ boardTitle: z.string().trim().min(1).optional(),
87
+ });
88
+
89
+ /** Internal path whitelist for internal-only PR detection — flat array of regex strings */
90
+ const InternalPatternsConfig = z.array(z.string().trim().min(1)).min(1);
91
+
92
+ const PersonaEntry = z.strictObject({
93
+ persona: z.string().min(1),
94
+ // Optional in the merged/full schema so consumer overrides can replace
95
+ // only persona/defaultModel without having to restate the inherited prompt.
96
+ prompt: z.string().min(1).optional().describe("Short focused instruction for the reviewer agent — what to look for and how to judge this angle"),
97
+ defaultModel: z.string().trim().min(1).nullable().default(null),
98
+ });
99
+
100
+ const PersonasConfig = z.record(z.string().min(1), PersonaEntry);
101
+
102
+ // Partial nested gate entries for file-level config (allows overriding only
103
+ // requireCi/required/angles without restating the whole gate object).
104
+ const FileGateConfig = GateConfig.partial();
105
+ const FileGatesConfig = z.strictObject({
106
+ draft: FileGateConfig.optional(),
107
+ preApproval: FileGateConfig.optional(),
108
+ });
109
+
110
+ // Partial persona entries for file-level config (allows omitting fields)
111
+ const FilePersonasConfig = z.record(z.string().min(1), PersonaEntry.partial());
112
+
113
+ // ============================================================================
114
+ // Full schema — families are optional (BUILT_IN_DEFAULTS provides fallback)
115
+ // ============================================================================
116
+
117
+ /**
118
+ * @typedef {z.infer<typeof DevLoopConfigSchema>} DevLoopConfig
119
+ */
120
+
121
+ export const DevLoopConfigSchema = z.strictObject({
122
+ version: z.literal(1),
123
+ strategy: StrategyConfig.optional(),
124
+ inputSource: InputSourceConfig.optional(),
125
+ models: ModelsConfig.optional(),
126
+ refinement: RefinementConfig.optional(),
127
+ gates: GatesConfig.optional(),
128
+ autonomy: AutonomyConfig.optional(),
129
+ workflow: WorkflowConfig.optional(),
130
+ localImplementation: LocalImplementationConfig.optional(),
131
+ queue: QueueConfig.optional(),
132
+ personas: PersonasConfig.optional(),
133
+ internalPathPatterns: InternalPatternsConfig.optional(),
134
+ });
135
+
136
+ // ============================================================================
137
+ // Built-in defaults — frozen canonical single source of truth
138
+ // ============================================================================
139
+
140
+ export const BUILT_IN_DEFAULTS = Object.freeze({
141
+ version: 1,
142
+ strategy: Object.freeze({ default: "github-first" }),
143
+ inputSource: Object.freeze({ default: "tracker" }),
144
+ models: Object.freeze({}),
145
+ refinement: Object.freeze({ fanOut: 3, mode: "parallel", maxCopilotRounds: 5, stopOnLowSignal: false, lowSignalRoundThreshold: 3, lowSignalMaxComments: 2 }),
146
+ gates: Object.freeze({}),
147
+ autonomy: Object.freeze({ stopAt: Object.freeze(["merge"]) }),
148
+ workflow: Object.freeze({
149
+ asyncStartMode: "required",
150
+ requireRetrospective: false,
151
+ requireRetrospectiveGate: false,
152
+ requireDraftFirst: false,
153
+ devModeDefault: false,
154
+ }),
155
+ localImplementation: Object.freeze({
156
+ lightMode: Object.freeze({ enabled: false, maxFiles: 3, maxLines: 200 }),
157
+ }),
158
+ queue: Object.freeze({
159
+ maxParallel: 3,
160
+ maxAutoFiledIssues: 10,
161
+ reDispatchMaxRetries: 1,
162
+ // projectNumber and boardTitle are intentionally absent from defaults
163
+ // — setting either is an explicit operator opt-in for Projects-based
164
+ // queue ordering.
165
+ }),
166
+ personas: Object.freeze({}),
167
+ internalPathPatterns: Object.freeze([
168
+ "^scripts/",
169
+ "^docs/",
170
+ "^skills/docs/",
171
+ "^\\.pi/",
172
+ "^\\.github/",
173
+ "^test/",
174
+ ]),
175
+ });
176
+
177
+ // ============================================================================
178
+ // File-level validation schema — allows partial family objects
179
+ // ============================================================================
180
+
181
+ export const FileConfigSchema = z.strictObject({
182
+ version: z.literal(1),
183
+ strategy: StrategyConfig.partial().optional(),
184
+ inputSource: InputSourceConfig.partial().optional(),
185
+ models: ModelsConfig.partial().optional(),
186
+ refinement: RefinementConfig.partial().optional(),
187
+ gates: FileGatesConfig.optional(),
188
+ autonomy: AutonomyConfig.partial().optional(),
189
+ workflow: WorkflowConfig.partial().optional(),
190
+ localImplementation: LocalImplementationConfig.partial().optional(),
191
+ queue: QueueConfig.partial().optional(),
192
+ personas: FilePersonasConfig.optional(),
193
+ internalPathPatterns: InternalPatternsConfig.optional(),
194
+ });
195
+
196
+ // ============================================================================
197
+ // Built-in persona registry — fallback when config.personas is absent
198
+ //
199
+ // Maps gate-review angle names to reviewer personas. Only the persona name
200
+ // is defined here; prompts and per-angle model defaults live in the config
201
+ // (.pi/dev-loop/defaults.yaml personas section).
202
+ //
203
+ // Consumers can extend or override these by adding personas entries to
204
+ // their .pi/dev-loop/defaults.* or settings.* config files (with legacy overrides.* fallback). Config-resolved
205
+ // personas take priority over this built-in registry.
206
+ //
207
+ // Angle names come from the gate-angle config (gates.draft.angles /
208
+ // gates.preApproval.angles in .pi/dev-loop/defaults.yaml).
209
+ // ============================================================================
210
+
211
+ const BUILTIN_PERSONAS = Object.freeze({
212
+ scope: { persona: "review", defaultModel: null },
213
+ coverage: { persona: "review", defaultModel: null },
214
+ correctness: { persona: "review", defaultModel: null },
215
+ docs: { persona: "docs", defaultModel: null },
216
+ deep: { persona: "review", defaultModel: null },
217
+ dry: { persona: "review", defaultModel: null },
218
+ kiss: { persona: "review", defaultModel: null },
219
+ srp: { persona: "review", defaultModel: null },
220
+ ocp: { persona: "review", defaultModel: null },
221
+ lsp: { persona: "review", defaultModel: null },
222
+ isp: { persona: "review", defaultModel: null },
223
+ dip: { persona: "review", defaultModel: null },
224
+ soc: { persona: "review", defaultModel: null },
225
+ yagni: { persona: "review", defaultModel: null },
226
+ "contract-surface": { persona: "review", defaultModel: null },
227
+ "input-validation": { persona: "review", defaultModel: null },
228
+ "packaging-runtime": { persona: "review", defaultModel: null },
229
+ "state-concurrency": { persona: "review", defaultModel: null },
230
+ "renderer-security": { persona: "review", defaultModel: null },
231
+ determinism: { persona: "review", defaultModel: null },
232
+ });
233
+
234
+ const DEFAULT_REVIEWER_PERSONA = "default-reviewer";
235
+
236
+ // ============================================================================
237
+ // Role resolution
238
+ // ============================================================================
239
+
240
+ /**
241
+ * @typedef {object} RoleResolutionResult
242
+ * @property {string} persona - Agent persona name to use
243
+ * @property {string|null} model - Effective model (null = use persona default)
244
+ * @property {string|null} prompt - Focused review instruction for this angle (null when fallback)
245
+ * @property {boolean} fallback - True when no specialized persona was found
246
+ */
247
+
248
+ /**
249
+ * Resolve a gate angle name to a reviewer persona and model.
250
+ *
251
+ * Resolution order:
252
+ * 1. Look up angle in config.personas[angle] (consumer overrides)
253
+ * 2. If not found in config, look up in BUILTIN_PERSONAS
254
+ * 3. If found in either, apply model override from config.models.roles[angle] if present
255
+ * 4. If not found anywhere, fall back to default reviewer with angle as focus lens,
256
+ * still applying any model override from config
257
+ *
258
+ * @param {object} config - DevLoopConfig (or partial with personas, models.roles)
259
+ * @param {string|null|undefined} angle - Gate angle / lens name
260
+ * @returns {RoleResolutionResult}
261
+ */
262
+ export function resolveReviewerRole(config, angle) {
263
+ // Null/undefined/empty angle → fallback
264
+ if (angle == null || angle === "") {
265
+ return {
266
+ persona: DEFAULT_REVIEWER_PERSONA,
267
+ model: null,
268
+ prompt: null,
269
+ fallback: true,
270
+ };
271
+ }
272
+
273
+ // Resolution: config.personas > BUILTIN_PERSONAS > default-reviewer
274
+ const configPersona = config?.personas?.[angle] ?? null;
275
+ const builtinPersona = BUILTIN_PERSONAS[angle] ?? null;
276
+ const persona = configPersona ?? builtinPersona;
277
+ const modelOverride = config?.models?.roles?.[angle] || null;
278
+
279
+ if (persona) {
280
+ return {
281
+ persona: persona.persona,
282
+ model: modelOverride || persona.defaultModel || null,
283
+ prompt: persona.prompt || null,
284
+ fallback: false,
285
+ };
286
+ }
287
+
288
+ // Unknown angle — fall back to default reviewer, but still apply model override
289
+ return {
290
+ persona: DEFAULT_REVIEWER_PERSONA,
291
+ model: modelOverride || null,
292
+ prompt: null,
293
+ fallback: true,
294
+ };
295
+ }
296
+
297
+ // ============================================================================
298
+ // Error types
299
+ // ============================================================================
300
+
301
+ /**
302
+ * @typedef {object} ConfigLoadError
303
+ * @property {string} path - Human-readable file path or layer name
304
+ * @property {string} message - Error description
305
+ * @property {"defaults"|"settings"|"merged"} layer - Which config layer failed
306
+ */
307
+
308
+ // ============================================================================
309
+ // Helpers
310
+ // ============================================================================
311
+
312
+ /**
313
+ * Merge two config objects. Keys in `source` override keys in `target`.
314
+ * Family objects merge at one level, except `gates`, which merges one extra
315
+ * nested gate-object level so settings can override `draft.requireCi` without
316
+ * restating the shipped draft angles.
317
+ * @param {Record<string, unknown>} target
318
+ * @param {Record<string, unknown>} source
319
+ * @returns {Record<string, unknown>}
320
+ */
321
+ function mergeConfigLayers(target, source) {
322
+ const result = { ...target };
323
+ for (const key of Object.keys(source)) {
324
+ if (
325
+ key !== "version" &&
326
+ typeof source[key] === "object" &&
327
+ source[key] !== null &&
328
+ !Array.isArray(source[key]) &&
329
+ typeof result[key] === "object" &&
330
+ result[key] !== null &&
331
+ !Array.isArray(result[key])
332
+ ) {
333
+ result[key] = key === "gates"
334
+ ? mergeNestedObject(result[key], source[key])
335
+ : { ...(result[key] || {}), ...(source[key] || {}) };
336
+ } else {
337
+ result[key] = source[key];
338
+ }
339
+ }
340
+ return result;
341
+ }
342
+
343
+ function mergeNestedObject(target, source) {
344
+ const result = { ...(target || {}) };
345
+
346
+ for (const key of Object.keys(source || {})) {
347
+ if (
348
+ typeof source[key] === "object" &&
349
+ source[key] !== null &&
350
+ !Array.isArray(source[key]) &&
351
+ typeof result[key] === "object" &&
352
+ result[key] !== null &&
353
+ !Array.isArray(result[key])
354
+ ) {
355
+ result[key] = { ...(result[key] || {}), ...(source[key] || {}) };
356
+ } else {
357
+ result[key] = source[key];
358
+ }
359
+ }
360
+
361
+ return result;
362
+ }
363
+
364
+ /**
365
+ * Try to read and parse a config file (YAML preferred, JSON fallback).
366
+ * Detects format from file extension: .yaml/.yml → YAML, .json → JSON.
367
+ * Returns the parsed object or null if the file doesn't exist.
368
+ * Throws on read errors other than ENOENT.
369
+ * @param {string} filePath
370
+ * @returns {Promise<object|null>}
371
+ */
372
+ async function readConfigFile(filePath) {
373
+ let raw;
374
+ try {
375
+ raw = await readFile(filePath, "utf8");
376
+ } catch (err) {
377
+ if (err.code === "ENOENT") return null;
378
+ throw configError(`Cannot read config file: ${err.message}`, err.code, filePath);
379
+ }
380
+
381
+ if (raw.trim() === "") {
382
+ throw configError("Config file is empty", "EMPTY_FILE", filePath);
383
+ }
384
+
385
+ const hasExt = filePath.endsWith(".yaml") || filePath.endsWith(".yml") || filePath.endsWith(".json");
386
+ const isYaml = filePath.endsWith(".yaml") || filePath.endsWith(".yml");
387
+ let parsed;
388
+ if (hasExt) {
389
+ try {
390
+ parsed = isYaml ? parseYaml(raw) : JSON.parse(raw);
391
+ } catch (err) {
392
+ const format = isYaml ? "YAML" : "JSON";
393
+ throw configError(`Invalid ${format} in config file: ${err.message}`, `INVALID_${format.toUpperCase()}`, filePath);
394
+ }
395
+ } else {
396
+ // Bare file (no recognized extension) — try YAML first, fallback JSON
397
+ try {
398
+ parsed = parseYaml(raw);
399
+ } catch {
400
+ try {
401
+ parsed = JSON.parse(raw);
402
+ } catch (err) {
403
+ throw configError(`Invalid config file (tried YAML and JSON): ${err.message}`, "INVALID_BARE_FILE", filePath);
404
+ }
405
+ }
406
+ }
407
+
408
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
409
+ throw configError("Config file must be an object", "NOT_AN_OBJECT", filePath);
410
+ }
411
+
412
+ return parsed;
413
+ }
414
+
415
+ /**
416
+ * Find a config file by trying one or more base names in order.
417
+ * Each base name prefers YAML (.yaml, then .yml) before JSON.
418
+ * @param {string|string[]} basePaths - Path(s) without extension (e.g. .../defaults)
419
+ * @returns {Promise<{ path: string, data: object|null }>}
420
+ */
421
+ async function findConfigFile(basePaths) {
422
+ const candidates = Array.isArray(basePaths) ? basePaths : [basePaths];
423
+
424
+ for (const basePath of candidates) {
425
+ // Try bare path first (e.g., .devloops without extension).
426
+ // Success returns immediately.
427
+ // ENOENT: file genuinely absent — try extension variants.
428
+ // Other errors (EISDIR, EACCES): file exists but is unreadable —
429
+ // try extension variants as fallback, but surface the original
430
+ // error if no extension variant exists.
431
+ let bareData = null;
432
+ let bareError = null;
433
+ try {
434
+ bareData = await readConfigFile(basePath);
435
+ } catch (err) {
436
+ bareError = err;
437
+ }
438
+ if (bareData !== null) return { path: basePath, data: bareData };
439
+
440
+ for (const ext of [".yaml", ".yml", ".json"]) {
441
+ const filePath = basePath + ext;
442
+ const data = await readConfigFile(filePath);
443
+ if (data !== null) return { path: filePath, data };
444
+ }
445
+
446
+ // No extension variant found either — if the bare path exists but is
447
+ // unreadable, surface that error rather than silently falling back.
448
+ if (bareError) throw bareError;
449
+ }
450
+
451
+ return { path: candidates[0] + ".yaml", data: null };
452
+ }
453
+
454
+ /**
455
+ * @param {string} message
456
+ * @param {string} code
457
+ * @param {string} filePath
458
+ * @returns {Error & { code: string, path: string }}
459
+ */
460
+ function configError(message, code, filePath) {
461
+ return Object.assign(new Error(message), { code, path: filePath });
462
+ }
463
+
464
+ /**
465
+ * Try to load and merge one config layer (defaults or settings).
466
+ * @param {Record<string, unknown>} merged - Current merged config
467
+ * @param {string|string[]} basePaths - Config file base path(s) without extension
468
+ * @param {"defaults"|"settings"} layer - Layer name
469
+ * @param {string[]} warnings
470
+ * @param {ConfigLoadError[]} errors
471
+ * @param {{ warnOnMissing?: boolean }} [options]
472
+ * @returns {Promise<Record<string, unknown>>}
473
+ */
474
+ async function applyLayer(merged, basePaths, layer, warnings, errors, options = {}) {
475
+ let filePath, data = null;
476
+ try {
477
+ const found = await findConfigFile(basePaths);
478
+ filePath = found.path;
479
+ data = found.data;
480
+ } catch (err) {
481
+ const preferredBasePath = Array.isArray(basePaths) ? basePaths[0] : basePaths;
482
+ const errorPath = err.path ?? preferredBasePath + ".yaml";
483
+ errors.push({
484
+ path: errorPath,
485
+ message: `${path.basename(errorPath)}: ${err.message}`,
486
+ layer,
487
+ });
488
+ return merged;
489
+ }
490
+
491
+ if (data === null) {
492
+ if (options.warnOnMissing) {
493
+ warnings.push(`Committed ${layer} config not found (tried .yaml, .yml, and .json), using built-in defaults`);
494
+ }
495
+ return merged;
496
+ }
497
+
498
+ // Validate the file's structure before merging
499
+ const validation = FileConfigSchema.safeParse(data);
500
+ if (!validation.success) {
501
+ errors.push({
502
+ path: filePath,
503
+ message: `${path.basename(filePath)}: Schema validation failed: ${validation.error.issues.map(i => `${i.path.join(".")}: ${i.message}`).join("; ")}`,
504
+ layer,
505
+ });
506
+ return merged;
507
+ }
508
+
509
+ return mergeConfigLayers(merged, data);
510
+ }
511
+
512
+ // ============================================================================
513
+ // Loader
514
+ // ============================================================================
515
+
516
+ /**
517
+ * @typedef {object} LoadResult
518
+ * @property {DevLoopConfig} config
519
+ * @property {string[]} warnings
520
+ * @property {ConfigLoadError[]} errors
521
+ */
522
+
523
+ /**
524
+ * @typedef {object} LoadOptions
525
+ * @property {string} [repoRoot] - Path to repository root (default: process.cwd())
526
+ */
527
+
528
+ /**
529
+ * Load the dev-loop configuration with full precedence:
530
+ * settings.(yaml|yml|json) > legacy overrides.(yaml|yml|json) > defaults.(yaml|yml|json) > built-in defaults
531
+ *
532
+ * Never throws for config-related problems.
533
+ * Returns built-in defaults even when all files are missing or broken.
534
+ *
535
+ * @param {LoadOptions} [options]
536
+ * @returns {Promise<LoadResult>}
537
+ */
538
+ export async function loadDevLoopConfig(options = {}) {
539
+ const repoRoot = options.repoRoot ?? process.cwd();
540
+ const configDir = path.join(repoRoot, ".pi", "dev-loop");
541
+ const defaultsPath = path.join(configDir, "defaults");
542
+ const devloopsPath = path.join(repoRoot, ".devloops");
543
+ const settingsPaths = [path.join(configDir, "settings"), path.join(configDir, "overrides")];
544
+
545
+ /** @type {string[]} */
546
+ const warnings = [];
547
+ /** @type {ConfigLoadError[]} */
548
+ const errors = [];
549
+
550
+ let merged = { ...BUILT_IN_DEFAULTS };
551
+
552
+ merged = await applyLayer(merged, defaultsPath, "defaults", warnings, errors, {
553
+ warnOnMissing: true,
554
+ });
555
+
556
+ // Check if .devloops exists (primary consumer override)
557
+ // Only ENOENT means the file is genuinely absent; any other error
558
+ // (EACCES, EISDIR, etc.) means the file exists but is unreadable,
559
+ // so we must select the .devloops path so applyLayer can record the
560
+ // structured error.
561
+ let primaryExists = false;
562
+ for (const ext of ["", ".yaml", ".yml", ".json"]) {
563
+ try {
564
+ await readFile(devloopsPath + ext, "utf8");
565
+ primaryExists = true;
566
+ break;
567
+ } catch (err) {
568
+ if (err?.code !== "ENOENT") {
569
+ primaryExists = true;
570
+ break;
571
+ }
572
+ // ENOENT — genuinely absent, try next extension
573
+ }
574
+ }
575
+
576
+ if (primaryExists) {
577
+ // .devloops is the primary override — apply it
578
+ merged = await applyLayer(merged, devloopsPath, "settings", warnings, errors);
579
+
580
+ // Warn if legacy files still exist alongside .devloops (but don't load them —
581
+ // .devloops is authoritative; legacy must not override it)
582
+ let legacyAlongside = false;
583
+ for (const legacyPath of settingsPaths) {
584
+ for (const ext of [".yaml", ".yml", ".json"]) {
585
+ try {
586
+ await readFile(legacyPath + ext, "utf8");
587
+ legacyAlongside = true;
588
+ break;
589
+ } catch (err) {
590
+ if (err?.code !== "ENOENT") {
591
+ // File exists but is unreadable — treat as "found" so the
592
+ // deprecation warning fires (applyLayer is not called for legacy
593
+ // paths when .devloops is present, so the flag only controls the warning).
594
+ legacyAlongside = true;
595
+ break;
596
+ }
597
+ }
598
+ }
599
+ if (legacyAlongside) break;
600
+ }
601
+ if (legacyAlongside) {
602
+ warnings.push(
603
+ `Deprecated config path(s) found under .pi/dev-loop/settings.* or .pi/dev-loop/overrides.*. ` +
604
+ `Migrate to .devloops (or .devloops.yaml/.devloops.yml/.devloops.json) at repo root. ` +
605
+ `Legacy paths will be removed in a future version.`
606
+ );
607
+ }
608
+ } else {
609
+ // No .devloops — fall back to legacy .pi/dev-loop/settings.* or overrides.* (deprecated)
610
+ let legacyFound = false;
611
+ for (const legacyPath of settingsPaths) {
612
+ for (const ext of [".yaml", ".yml", ".json"]) {
613
+ try {
614
+ await readFile(legacyPath + ext, "utf8");
615
+ legacyFound = true;
616
+ break;
617
+ } catch (err) {
618
+ if (err?.code !== "ENOENT") {
619
+ // File exists but is unreadable — treat as "found" so the
620
+ // deprecation warning fires and applyLayer can surface the error
621
+ // (legacy applyLayer runs in this branch).
622
+ legacyFound = true;
623
+ break;
624
+ }
625
+ }
626
+ }
627
+ if (legacyFound) break;
628
+ }
629
+ if (legacyFound) {
630
+ warnings.push(
631
+ `Deprecated config path(s) found under .pi/dev-loop/settings.* or .pi/dev-loop/overrides.*. ` +
632
+ `Migrate to .devloops (or .devloops.yaml/.devloops.yml/.devloops.json) at repo root. ` +
633
+ `Legacy paths will be removed in a future version.`
634
+ );
635
+ merged = await applyLayer(merged, settingsPaths, "settings", warnings, errors);
636
+ }
637
+ }
638
+
639
+ // Validate final merged config
640
+ const result = DevLoopConfigSchema.safeParse(merged);
641
+ if (!result.success) {
642
+ errors.push({
643
+ path: "<merged>",
644
+ message: `Config validation failed: ${result.error.issues.map(i => `${i.path.join(".")}: ${i.message}`).join("; ")}`,
645
+ layer: "merged",
646
+ });
647
+ // Return merged as-is — caller gets validation errors but still has config with all layers applied
648
+ return { config: /** @type {*} */ (merged), warnings, errors };
649
+ }
650
+
651
+ return { config: result.data, warnings, errors };
652
+ }
653
+
654
+ /**
655
+ * Resolve the conductor model from the merged dev-loop config.
656
+ *
657
+ * Returns the configured model string if present, or null when the config
658
+ * does not specify a conductor model override (caller falls back to its
659
+ * own built-in default).
660
+ *
661
+ * Accepts the validated DevLoopConfig from {@link loadDevLoopConfig}.
662
+ *
663
+ * @param {DevLoopConfig} config
664
+ * @returns {string|null}
665
+ */
666
+ export function resolveConductorModel(config) {
667
+ const raw = config?.models?.conductor;
668
+ if (typeof raw === "string" && raw.trim().length > 0) {
669
+ return raw.trim();
670
+ }
671
+ return null;
672
+ }
673
+
674
+ /**
675
+ * Resolve the autonomy stop-at list from the merged dev-loop config.
676
+ *
677
+ * Returns the set of gates that require operator confirmation. Gates not in
678
+ * the returned list may proceed automatically once their review conditions
679
+ * are satisfied.
680
+ *
681
+ * Defaults to `["merge"]` when the config does not specify `autonomy.stopAt`
682
+ * (the conservative built-in posture: everything auto-continues until merge).
683
+ *
684
+ * Accepts the validated DevLoopConfig from {@link loadDevLoopConfig}.
685
+ *
686
+ * @param {DevLoopConfig} config
687
+ * @returns {string[]}
688
+ */
689
+ export function resolveAutonomyStopAt(config) {
690
+ if (config?.autonomy?.stopAt && Array.isArray(config.autonomy.stopAt)) {
691
+ return [...config.autonomy.stopAt];
692
+ }
693
+ return ["merge"];
694
+ }
695
+
696
+ const DEFAULT_REFINEMENT_CONFIG = BUILT_IN_DEFAULTS.refinement;
697
+ const DEFAULT_WORKFLOW_CONFIG = BUILT_IN_DEFAULTS.workflow;
698
+
699
+ /**
700
+ * Resolve one refinement configuration value from the merged dev-loop config.
701
+ *
702
+ * Returns the configured value when present, or the built-in default for the
703
+ * requested key.
704
+ *
705
+ * @param {DevLoopConfig} config
706
+ * @param {"fanOut"|"mode"|"roles"|"maxCopilotRounds"|"stopOnLowSignal"|"lowSignalRoundThreshold"|"lowSignalMaxComments"} key
707
+ * @returns {number|"parallel"|"sequential"|string[]|boolean|null}
708
+ */
709
+ export function resolveRefinementConfig(config, key) {
710
+ if (key === "roles") {
711
+ return config?.refinement?.roles && Array.isArray(config.refinement.roles)
712
+ ? [...config.refinement.roles]
713
+ : null;
714
+ }
715
+
716
+ if (key === "fanOut") {
717
+ return config?.refinement?.fanOut ?? DEFAULT_REFINEMENT_CONFIG.fanOut;
718
+ }
719
+
720
+ if (key === "mode") {
721
+ return config?.refinement?.mode ?? DEFAULT_REFINEMENT_CONFIG.mode;
722
+ }
723
+
724
+ if (key === "maxCopilotRounds") {
725
+ return config?.refinement?.maxCopilotRounds ?? DEFAULT_REFINEMENT_CONFIG.maxCopilotRounds;
726
+ }
727
+
728
+ if (key === "stopOnLowSignal") {
729
+ return config?.refinement?.stopOnLowSignal ?? DEFAULT_REFINEMENT_CONFIG.stopOnLowSignal;
730
+ }
731
+
732
+ if (key === "lowSignalRoundThreshold") {
733
+ return config?.refinement?.lowSignalRoundThreshold ?? DEFAULT_REFINEMENT_CONFIG.lowSignalRoundThreshold;
734
+ }
735
+
736
+ if (key === "lowSignalMaxComments") {
737
+ return config?.refinement?.lowSignalMaxComments ?? DEFAULT_REFINEMENT_CONFIG.lowSignalMaxComments;
738
+ }
739
+
740
+ throw new Error(`Unknown refinement config key: ${key}`);
741
+ }
742
+
743
+ /**
744
+ * Resolve the refinement configuration from the merged dev-loop config.
745
+ *
746
+ * Returns `{ fanOut, mode, roles, maxCopilotRounds, stopOnLowSignal, lowSignalRoundThreshold, lowSignalMaxComments }` with sensible built-in
747
+ * defaults (`fanOut: 3`, `mode: "parallel"`, `roles: null`,
748
+ * `maxCopilotRounds: 5`, `stopOnLowSignal: false`, `lowSignalRoundThreshold: 3`,
749
+ * `lowSignalMaxComments: 2`).
750
+ *
751
+ * Accepts the validated DevLoopConfig from {@link loadDevLoopConfig}.
752
+ *
753
+ * @param {DevLoopConfig} config
754
+ * @returns {{ fanOut: number, mode: "parallel"|"sequential", roles: string[]|null, maxCopilotRounds: number, stopOnLowSignal: boolean, lowSignalRoundThreshold: number, lowSignalMaxComments: number }}
755
+ */
756
+ export function resolveRefinement(config) {
757
+ const fanOut = /** @type {number} */ (resolveRefinementConfig(config, "fanOut"));
758
+ const mode = /** @type {"parallel"|"sequential"} */ (resolveRefinementConfig(config, "mode"));
759
+ const roles = /** @type {string[]|null} */ (resolveRefinementConfig(config, "roles"));
760
+ const maxCopilotRounds = /** @type {number} */ (resolveRefinementConfig(config, "maxCopilotRounds"));
761
+ const stopOnLowSignal = /** @type {boolean} */ (resolveRefinementConfig(config, "stopOnLowSignal"));
762
+ const lowSignalRoundThreshold = /** @type {number} */ (resolveRefinementConfig(config, "lowSignalRoundThreshold"));
763
+ const lowSignalMaxComments = /** @type {number} */ (resolveRefinementConfig(config, "lowSignalMaxComments"));
764
+ return { fanOut, mode, roles, maxCopilotRounds, stopOnLowSignal, lowSignalRoundThreshold, lowSignalMaxComments };
765
+ }
766
+
767
+ /**
768
+ * Resolve one gate configuration object from the merged dev-loop config.
769
+ *
770
+ * Returns the configured gate angles when present, or null for angles when the
771
+ * config omits them (caller falls back to skill-defined defaults). Boolean gate
772
+ * flags always resolve to stable defaults.
773
+ *
774
+ * @param {DevLoopConfig} config
775
+ * @param {"draft"|"preApproval"} gate
776
+ * @returns {{ angles: string[]|null, excludeAngles: string[], mandatoryAngles: string[], required: boolean, requireCi: boolean, blockCleanOnFindingSeverities: string[], dynamicAngles: boolean }}
777
+ */
778
+ export function resolveGateConfig(config, gate) {
779
+ const gateConfig = config?.gates?.[gate];
780
+ return {
781
+ angles: gateConfig?.angles && Array.isArray(gateConfig.angles)
782
+ ? gateConfig.angles.map(a => (typeof a === "string" ? a.trim() : "")).filter(a => a.length > 0)
783
+ : null,
784
+ excludeAngles: gateConfig?.excludeAngles && Array.isArray(gateConfig.excludeAngles)
785
+ ? gateConfig.excludeAngles.map(a => (typeof a === "string" ? a.trim() : "")).filter(a => a.length > 0)
786
+ : [],
787
+ mandatoryAngles: gateConfig?.mandatoryAngles && Array.isArray(gateConfig.mandatoryAngles)
788
+ ? gateConfig.mandatoryAngles.map(a => (typeof a === "string" ? a.trim() : "")).filter(a => a.length > 0)
789
+ : [],
790
+ required: gateConfig?.required ?? true,
791
+ requireCi: gateConfig?.requireCi ?? true,
792
+ dynamicAngles: gateConfig?.dynamicAngles ?? false,
793
+ blockCleanOnFindingSeverities: gateConfig?.blockCleanOnFindingSeverities && Array.isArray(gateConfig.blockCleanOnFindingSeverities)
794
+ ? [...gateConfig.blockCleanOnFindingSeverities]
795
+ : ["must-fix"],
796
+ };
797
+ }
798
+
799
+ /**
800
+ * Resolve local implementation light mode config.
801
+ *
802
+ * Returns null when light mode is disabled (config absent or enabled=false).
803
+ * Returns { maxFiles, maxLines } when enabled.
804
+ *
805
+ * @param {DevLoopConfig} config
806
+ * @returns {{ maxFiles: number, maxLines: number } | null}
807
+ */
808
+ export function resolveLightMode(config) {
809
+ const cfg = config?.localImplementation?.lightMode;
810
+ if (!cfg || cfg.enabled === false) return null;
811
+ return {
812
+ maxFiles: typeof cfg.maxFiles === "number" && Number.isFinite(cfg.maxFiles) && cfg.maxFiles > 0
813
+ ? cfg.maxFiles
814
+ : 3,
815
+ maxLines: typeof cfg.maxLines === "number" && Number.isFinite(cfg.maxLines) && cfg.maxLines > 0
816
+ ? cfg.maxLines
817
+ : 200,
818
+ };
819
+ }
820
+
821
+ /**
822
+ * Resolve review angles for a specific gate from the merged dev-loop config.
823
+ *
824
+ * Merges mandatoryAngles with the configured candidate angles, filters
825
+ * through excludeAngles, and deduplicates. Returns null only when both
826
+ * angles and mandatoryAngles are absent/empty for the given gate (caller
827
+ * falls back to skill-defined defaults).
828
+ *
829
+ * @param {DevLoopConfig} config
830
+ * @param {"draft"|"preApproval"} gate
831
+ * @returns {string[]|null}
832
+ */
833
+ export function resolveGateAngles(config, gate) {
834
+ const gateConfig = resolveGateConfig(config, gate);
835
+ if (gateConfig.angles === null && gateConfig.mandatoryAngles.length === 0) return null;
836
+ const excluded = new Set(gateConfig.excludeAngles);
837
+ const merged = [...new Set([...gateConfig.mandatoryAngles, ...(gateConfig.angles ?? [])])];
838
+ return merged.filter(a => !excluded.has(a));
839
+ }
840
+
841
+ /**
842
+ * Resolve gate angles dynamically when `dynamicAngles` is enabled in config.
843
+ *
844
+ * Uses diff analysis helpers (from ../analysis/*) to filter the
845
+ * configured angle list down to only angles relevant to the change set.
846
+ *
847
+ * When `dynamicAngles` is disabled (default), returns the full configured
848
+ * angle list (same as `resolveGateAngles`).
849
+ *
850
+ * @param {import("./types.js").DevLoopConfig} config
851
+ * @param {"draft"|"preApproval"} gate
852
+ * @param {object} [options]
853
+ * @param {{ nameStatusOutput: string, diffOutput?: string }} [options.diff]
854
+ * @returns {{ recommendedAngles: string[] | null, skippedAngles: string[], reasons: Record<string,string>, fallbackToAll: boolean, dynamicAnglesActive: boolean }}
855
+ */
856
+ export async function resolveGateAnglesDynamic(config, gate, { diff } = {}) {
857
+ const gateConfig = resolveGateConfig(config, gate);
858
+ const staticAngles = resolveGateAngles(config, gate);
859
+ if (staticAngles === null) {
860
+ return { recommendedAngles: null, skippedAngles: [], reasons: {}, fallbackToAll: false, dynamicAnglesActive: false };
861
+ }
862
+
863
+ if (!gateConfig.dynamicAngles || !diff) {
864
+ return {
865
+ recommendedAngles: staticAngles,
866
+ skippedAngles: [],
867
+ reasons: {},
868
+ fallbackToAll: false,
869
+ dynamicAnglesActive: false,
870
+ };
871
+ }
872
+
873
+ // Split into mandatory (always run) and candidate pool (dynamic selection)
874
+ // staticAngles is already filtered by excludeAngles via resolveGateAngles
875
+ const mandatory = new Set(gateConfig.mandatoryAngles);
876
+ const candidatePool = staticAngles.filter(a => !mandatory.has(a));
877
+
878
+ // Dynamic resolution
879
+ const { analyzeDiff } = await import("../analysis/diff-analyzer.mjs");
880
+ const analysis = analyzeDiff({
881
+ nameStatusOutput: diff.nameStatusOutput,
882
+ diffOutput: diff.diffOutput,
883
+ });
884
+
885
+ const categories = [...new Set(analysis.t1?.changeCategories ?? [])];
886
+
887
+ const { resolveDynamicAngles: resolve } = await import("../analysis/change-classifier.mjs");
888
+ const dynamicResult = resolve({
889
+ configuredAngles: candidatePool,
890
+ changeCategories: categories,
891
+ ambiguous: analysis.ambiguous,
892
+ });
893
+
894
+ // Merge: mandatory always included (filtered by excludeAngles) + dynamically-selected candidates
895
+ const excluded = new Set(gateConfig.excludeAngles);
896
+ const filteredMandatory = gateConfig.mandatoryAngles.filter(a => !excluded.has(a));
897
+ const recommendedAngles = [...new Set([...filteredMandatory, ...dynamicResult.recommendedAngles])];
898
+
899
+ return {
900
+ recommendedAngles,
901
+ skippedAngles: dynamicResult.skippedAngles,
902
+ reasons: dynamicResult.reasons,
903
+ fallbackToAll: dynamicResult.fallbackToAll,
904
+ dynamicAnglesActive: true,
905
+ };
906
+ }
907
+
908
+ /**
909
+ * Resolve one workflow configuration value from the merged dev-loop config.
910
+ *
911
+ * Returns the configured workflow value when present, or the built-in default
912
+ * for the requested key.
913
+ *
914
+ * @param {DevLoopConfig} config
915
+ * @param {"asyncStartMode"|"requireRetrospective"|"requireRetrospectiveGate"|"requireDraftFirst"|"devModeDefault"} key
916
+ * @returns {string|boolean}
917
+ */
918
+ export function resolveWorkflowConfig(config, key) {
919
+ if (key === "asyncStartMode") {
920
+ return config?.workflow?.asyncStartMode ?? DEFAULT_WORKFLOW_CONFIG.asyncStartMode;
921
+ }
922
+
923
+ if (key === "requireRetrospective") {
924
+ return config?.workflow?.requireRetrospective ?? DEFAULT_WORKFLOW_CONFIG.requireRetrospective;
925
+ }
926
+
927
+ if (key === "requireRetrospectiveGate") {
928
+ return config?.workflow?.requireRetrospectiveGate ?? DEFAULT_WORKFLOW_CONFIG.requireRetrospectiveGate;
929
+ }
930
+
931
+ if (key === "requireDraftFirst") {
932
+ return config?.workflow?.requireDraftFirst ?? DEFAULT_WORKFLOW_CONFIG.requireDraftFirst;
933
+ }
934
+
935
+ if (key === "devModeDefault") {
936
+ return config?.workflow?.devModeDefault ?? DEFAULT_WORKFLOW_CONFIG.devModeDefault;
937
+ }
938
+
939
+ throw new Error(`Unknown workflow config key: ${key}`);
940
+ }
941
+
942
+ const DEFAULT_INTERNAL_PATH_PATTERNS = BUILT_IN_DEFAULTS.internalPathPatterns;
943
+
944
+ /**
945
+ * Resolve the internal path patterns from the merged dev-loop config.
946
+ *
947
+ * Returns an array of regex pattern strings used by detect-internal-only-pr.mjs
948
+ * to classify files as internal tooling (vs consumer-facing). When the config
949
+ * omits this section, returns the built-in shipped defaults.
950
+ *
951
+ * Consumers can override these in .devloops at repo root.
952
+ *
953
+ * @param {DevLoopConfig} config
954
+ * @returns {string[]}
955
+ */
956
+ export function resolveInternalPathPatterns(config) {
957
+ if (
958
+ config?.internalPathPatterns &&
959
+ Array.isArray(config.internalPathPatterns) &&
960
+ config.internalPathPatterns.length > 0
961
+ ) {
962
+ return [...config.internalPathPatterns];
963
+ }
964
+ return [...DEFAULT_INTERNAL_PATH_PATTERNS];
965
+ }