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