@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.
- package/bin/capture-deep-persona-signals.mjs +143 -0
- package/bin/ensure-phase-files.mjs +7 -0
- package/bin/log-bash-exit-1.mjs +7 -0
- package/bin/parse-review-threads.mjs +7 -0
- package/package.json +78 -0
- package/src/analysis/change-classifier.mjs +146 -0
- package/src/analysis/diff-analyzer.mjs +285 -0
- package/src/bash-exit-one.mjs +130 -0
- package/src/cli/helpers.mjs +22 -0
- package/src/cli/primitives.mjs +70 -0
- package/src/cli/retry-wrapper.mjs +169 -0
- package/src/cli/subcommand-runner.mjs +246 -0
- package/src/config/config.mjs +965 -0
- package/src/debt/cluster.mjs +240 -0
- package/src/debt/debt-finding.mjs +68 -0
- package/src/debt/debt-signal.mjs +46 -0
- package/src/debt/deep-persona-signals.mjs +266 -0
- package/src/debt/remediation-to-issue.mjs +121 -0
- package/src/debt/score.mjs +127 -0
- package/src/debt/shape.mjs +214 -0
- package/src/github/copilot-helpers.mjs +343 -0
- package/src/github/repo-slug.mjs +105 -0
- package/src/github/review-threads.mjs +343 -0
- package/src/harness/adapter.mjs +57 -0
- package/src/harness/index.mjs +3 -0
- package/src/harness/noop-adapter.mjs +22 -0
- package/src/harness/pi-adapter.mjs +47 -0
- package/src/loop/async-start-contract.mjs +170 -0
- package/src/loop/conductor-routing.mjs +817 -0
- package/src/loop/copilot-ci-status.mjs +255 -0
- package/src/loop/copilot-loop-iterations.mjs +161 -0
- package/src/loop/copilot-loop-state.mjs +510 -0
- package/src/loop/handoff-envelope.mjs +800 -0
- package/src/loop/issue-refinement-artifact.mjs +268 -0
- package/src/loop/lifecycle-state.mjs +342 -0
- package/src/loop/phase-files.mjs +187 -0
- package/src/loop/policy-constants.mjs +17 -0
- package/src/loop/pr-gate-coordination.mjs +1278 -0
- package/src/loop/public-dev-loop-routing-contract.mjs +277 -0
- package/src/loop/public-dev-loop-routing.mjs +1746 -0
- package/src/loop/queue-board-ordering.mjs +38 -0
- package/src/loop/queue-board-sync.mjs +223 -0
- package/src/loop/queue-driver.mjs +164 -0
- package/src/loop/queue-parallel.mjs +190 -0
- package/src/loop/queue-state.mjs +230 -0
- package/src/loop/retrospective-checkpoint.mjs +178 -0
- package/src/loop/reviewer-loop-state.mjs +456 -0
- package/src/loop/run-inspection.mjs +604 -0
- package/src/loop/steering.mjs +793 -0
- package/src/loop/timeout-policy.mjs +73 -0
- package/src/loop/tracker-first-loop-state.mjs +87 -0
- package/src/loop/tracker-pr-state.mjs +301 -0
- package/src/loop/worktree-guard.mjs +141 -0
- 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
|
+
}
|