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