@dev-loops/core 0.6.0 → 0.7.2
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 -7
- package/src/analysis/change-classifier.mjs +50 -6
- package/src/analysis/diff-analyzer.mjs +68 -12
- package/src/claude/hook-decisions.mjs +138 -15
- package/src/config/config.mjs +247 -98
- package/src/config/extension-defaults.yaml +6 -11
- package/src/github/copilot-helpers.mjs +143 -0
- package/src/harness/extension-adapter.mjs +1 -0
- package/src/harness/index.mjs +0 -1
- package/src/loop/bash-command-classify.mjs +333 -29
- package/src/loop/conductor-routing.mjs +0 -27
- package/src/loop/copilot-loop-state.mjs +25 -2
- package/src/loop/gate-fanin.mjs +137 -0
- package/src/loop/handoff-envelope.mjs +142 -70
- package/src/loop/issue-refinement-artifact.mjs +259 -8
- package/src/loop/lifecycle-state.mjs +1 -1
- package/src/loop/pr-gate-coordination.mjs +158 -238
- package/src/loop/pr-lifecycle.mjs +79 -0
- package/src/loop/public-dev-loop-routing.mjs +2 -2
- package/src/loop/queue-board-ordering.mjs +52 -8
- package/src/loop/queue-board-sync.mjs +62 -3
- package/src/loop/queue-driver.mjs +80 -8
- package/src/loop/queue-state.mjs +13 -2
- package/src/loop/reviewer-loop-state.mjs +20 -2
- package/src/projects/list-queue-items.mjs +380 -0
- package/src/projects/move-queue-item.mjs +394 -0
- package/src/projects/resolve-project.mjs +183 -0
- package/bin/capture-deep-persona-signals.mjs +0 -143
- package/src/debt/deep-persona-signals.mjs +0 -266
- package/src/harness/claude-extension-adapter.mjs +0 -102
- package/src/refinement/ac-dod-matrix.mjs +0 -95
package/src/config/config.mjs
CHANGED
|
@@ -46,6 +46,13 @@ const GateConfig = z.strictObject({
|
|
|
46
46
|
.min(1)
|
|
47
47
|
.default(["must-fix"]),
|
|
48
48
|
dynamicAngles: z.boolean().default(false),
|
|
49
|
+
// Additive counterpart to the subtractive dynamicAngles path (#1048): when
|
|
50
|
+
// true, the context-builder may also ADD catalog angles — from
|
|
51
|
+
// resolveAnglePool() (gates.anglePool, or else the union of the persona
|
|
52
|
+
// registry and this config's own configured angles) — that change-category
|
|
53
|
+
// heuristics recommend but that are not already in this gate's configured
|
|
54
|
+
// pool. Default false preserves today's subtractive-only behavior exactly.
|
|
55
|
+
additiveAngles: z.boolean().default(false),
|
|
49
56
|
});
|
|
50
57
|
|
|
51
58
|
const GatesConfig = z.strictObject({
|
|
@@ -65,6 +72,16 @@ const GatesConfig = z.strictObject({
|
|
|
65
72
|
// true (opt-out): a clean gate verdict requires fan-out/fan-in evidence
|
|
66
73
|
// unless explicitly disabled. See docs/gate-review-sub-loop-contract.md.
|
|
67
74
|
requireFanoutEvidence: z.boolean().default(true),
|
|
75
|
+
// Fail-closed enforcement that a fanout_fanin gate verdict carries recorded,
|
|
76
|
+
// internally-consistent fan-out *provenance* (distinct reviewer count +
|
|
77
|
+
// per-angle dispatch). This RAISES THE BAR against a single agent self-producing
|
|
78
|
+
// every artifact but does NOT prove independence — provenance is self-reported,
|
|
79
|
+
// so it remains forgeable; un-forgeable recording is the Pi-harness bridge (see
|
|
80
|
+
// the honest caveat in docs/gate-review-sub-loop-contract.md). Layered ON TOP of
|
|
81
|
+
// requireFanoutEvidence — only takes effect when fan-out evidence enforcement
|
|
82
|
+
// is active. Default false (opt-in): closing this loophole is additive and
|
|
83
|
+
// does not change behavior for existing ledgers that carry no provenance.
|
|
84
|
+
requireFanoutProvenance: z.boolean().default(false),
|
|
68
85
|
// Cap on how many scoped `review` reviewers the gate fan-out spawns in
|
|
69
86
|
// parallel. When the resolved angle set exceeds this cap, the overflow runs
|
|
70
87
|
// in sequential batches and the degradation is recorded in the gate evidence.
|
|
@@ -75,6 +92,18 @@ const GatesConfig = z.strictObject({
|
|
|
75
92
|
// suppresses the PR comment when explicitly false. See
|
|
76
93
|
// docs/gate-review-sub-loop-contract.md.
|
|
77
94
|
postFindingsComments: z.boolean().default(true),
|
|
95
|
+
// Explicit global lens catalog override for additive angle selection
|
|
96
|
+
// (gates.<gate>.additiveAngles, #1048). When absent, resolveAnglePool()
|
|
97
|
+
// falls back to the union of the built-in persona registry's angle names
|
|
98
|
+
// and every angle configured across this config's own draft/preApproval/
|
|
99
|
+
// spike gates (angles + mandatoryAngles).
|
|
100
|
+
anglePool: z.array(z.string().trim().min(1)).optional(),
|
|
101
|
+
// Fail-closed enforcement that a fanout_fanin gate's recorded per-angle
|
|
102
|
+
// provenance names only angles in the gate's configured pool (angles +
|
|
103
|
+
// mandatoryAngles) — ad-hoc/foreign angle labels are rejected rather than
|
|
104
|
+
// silently accepted. Default true (reject); set false to warn instead of
|
|
105
|
+
// fail. See resolveRejectForeignAngles / docs/gate-review-sub-loop-contract.md.
|
|
106
|
+
rejectForeignAngles: z.boolean().default(true),
|
|
78
107
|
});
|
|
79
108
|
|
|
80
109
|
const AutonomyConfig = z.strictObject({
|
|
@@ -90,7 +119,7 @@ const AutonomyConfig = z.strictObject({
|
|
|
90
119
|
|
|
91
120
|
/**
|
|
92
121
|
* Human-handoff config (#920, Request B of #910): at the pre-approval /
|
|
93
|
-
* merge-handoff boundary, OFFER to assign the PR to a
|
|
122
|
+
* merge-handoff boundary, OFFER to assign the PR to a contributor
|
|
94
123
|
* reviewer/assignee. Opt-in (default off). Pairs with autonomy.humanMergeOnly.
|
|
95
124
|
* `candidatesFrom` selects which sources the resolver queries; `assignees` is a
|
|
96
125
|
* static highest-priority candidate list. Absent/empty = disabled no-op.
|
|
@@ -110,12 +139,6 @@ const ApprovalConfig = z.strictObject({
|
|
|
110
139
|
const WorkflowConfig = z.strictObject({
|
|
111
140
|
asyncStartMode: z.enum(["required", "allowed"]).default("required"),
|
|
112
141
|
requireRetrospective: z.boolean(),
|
|
113
|
-
requireRetrospectiveGate: z.boolean().default(false),
|
|
114
|
-
// Developer-mode retro step (#982): enforce internal-tooling-only execution
|
|
115
|
-
// (no agent-level raw gh/python/node -e) in the retrospective gate. This is the
|
|
116
|
-
// dev-loops maintainers' own dogfooding discipline — opt-in, default OFF so
|
|
117
|
-
// consumers of the extension are never blocked by it.
|
|
118
|
-
requireRetrospectiveInternalTooling: z.boolean().default(false),
|
|
119
142
|
requireDraftFirst: z.boolean(),
|
|
120
143
|
devModeDefault: z.boolean(),
|
|
121
144
|
});
|
|
@@ -126,6 +149,10 @@ const LocalImplementationConfig = z.strictObject({
|
|
|
126
149
|
enabled: z.boolean(),
|
|
127
150
|
maxFiles: z.number().int().min(1),
|
|
128
151
|
maxLines: z.number().int().min(1),
|
|
152
|
+
// Copilot review round cap for light-dispatched PRs (#1210). Composes with
|
|
153
|
+
// (does not replace) refinement.maxCopilotRounds — see
|
|
154
|
+
// resolveEffectiveCopilotRoundCap.
|
|
155
|
+
maxCopilotRounds: z.number().int().nonnegative().default(1),
|
|
129
156
|
}).optional(),
|
|
130
157
|
});
|
|
131
158
|
|
|
@@ -151,16 +178,6 @@ const WorktreeConfig = z.strictObject({
|
|
|
151
178
|
linkOnInit: z.array(z.string().trim().min(1)).optional(),
|
|
152
179
|
});
|
|
153
180
|
|
|
154
|
-
/**
|
|
155
|
-
* Local-planning config (#949): where persisted markdown plan files (phase-doc
|
|
156
|
-
* format) live when work originates from a plan file rather than a tracker
|
|
157
|
-
* issue. `plansDir` is a repo-relative directory; defaults to the existing
|
|
158
|
-
* phase-docs directory. See skills/docs/plan-file-contract.md.
|
|
159
|
-
*/
|
|
160
|
-
const LocalPlanningConfig = z.strictObject({
|
|
161
|
-
plansDir: z.string().trim().min(1).optional(),
|
|
162
|
-
});
|
|
163
|
-
|
|
164
181
|
/** Internal path whitelist for internal-only PR detection — flat array of regex strings */
|
|
165
182
|
const InternalPatternsConfig = z.array(z.string().trim().min(1)).min(1);
|
|
166
183
|
|
|
@@ -182,8 +199,11 @@ const FileGatesConfig = z.strictObject({
|
|
|
182
199
|
preApproval: FileGateConfig.optional(),
|
|
183
200
|
spike: FileGateConfig.optional(),
|
|
184
201
|
requireFanoutEvidence: z.boolean().optional(),
|
|
202
|
+
requireFanoutProvenance: z.boolean().optional(),
|
|
185
203
|
maxFanoutReviewers: z.number().int().min(1).max(64).optional(),
|
|
186
204
|
postFindingsComments: z.boolean().optional(),
|
|
205
|
+
anglePool: z.array(z.string().trim().min(1)).optional(),
|
|
206
|
+
rejectForeignAngles: z.boolean().optional(),
|
|
187
207
|
});
|
|
188
208
|
|
|
189
209
|
// Partial persona entries for file-level config (allows omitting fields)
|
|
@@ -212,7 +232,9 @@ export const DevLoopConfigSchema = z.strictObject({
|
|
|
212
232
|
personas: PersonasConfig.optional(),
|
|
213
233
|
internalPathPatterns: InternalPatternsConfig.optional(),
|
|
214
234
|
worktree: WorktreeConfig.optional(),
|
|
215
|
-
|
|
235
|
+
// Deprecated (removed in #1088): tolerated so consumer .devloops files that
|
|
236
|
+
// still carry a localPlanning block keep parsing. Accepted, never read.
|
|
237
|
+
localPlanning: z.unknown().optional(),
|
|
216
238
|
});
|
|
217
239
|
|
|
218
240
|
// ============================================================================
|
|
@@ -221,7 +243,7 @@ export const DevLoopConfigSchema = z.strictObject({
|
|
|
221
243
|
|
|
222
244
|
export const BUILT_IN_DEFAULTS = Object.freeze({
|
|
223
245
|
version: 1,
|
|
224
|
-
strategy: Object.freeze({ default: "
|
|
246
|
+
strategy: Object.freeze({ default: "local-first" }),
|
|
225
247
|
inputSource: Object.freeze({ default: "tracker" }),
|
|
226
248
|
models: Object.freeze({}),
|
|
227
249
|
refinement: Object.freeze({ fanOut: 3, mode: "parallel", maxCopilotRounds: 5, stopOnLowSignal: false, lowSignalRoundThreshold: 3, lowSignalMaxComments: 2 }),
|
|
@@ -237,13 +259,11 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
|
|
|
237
259
|
workflow: Object.freeze({
|
|
238
260
|
asyncStartMode: "required",
|
|
239
261
|
requireRetrospective: false,
|
|
240
|
-
requireRetrospectiveGate: false,
|
|
241
|
-
requireRetrospectiveInternalTooling: false,
|
|
242
262
|
requireDraftFirst: false,
|
|
243
263
|
devModeDefault: false,
|
|
244
264
|
}),
|
|
245
265
|
localImplementation: Object.freeze({
|
|
246
|
-
lightMode: Object.freeze({ enabled: false, maxFiles: 3, maxLines: 200 }),
|
|
266
|
+
lightMode: Object.freeze({ enabled: false, maxFiles: 3, maxLines: 200, maxCopilotRounds: 1 }),
|
|
247
267
|
}),
|
|
248
268
|
queue: Object.freeze({
|
|
249
269
|
maxParallel: 3,
|
|
@@ -263,7 +283,6 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
|
|
|
263
283
|
"^test/",
|
|
264
284
|
]),
|
|
265
285
|
worktree: Object.freeze({ copyOnInit: Object.freeze([]), linkOnInit: Object.freeze([]) }),
|
|
266
|
-
localPlanning: Object.freeze({ plansDir: "docs/phases/" }),
|
|
267
286
|
});
|
|
268
287
|
|
|
269
288
|
// ============================================================================
|
|
@@ -285,7 +304,9 @@ export const FileConfigSchema = z.strictObject({
|
|
|
285
304
|
personas: FilePersonasConfig.optional(),
|
|
286
305
|
internalPathPatterns: InternalPatternsConfig.optional(),
|
|
287
306
|
worktree: WorktreeConfig.partial().optional(),
|
|
288
|
-
|
|
307
|
+
// Deprecated (removed in #1088): tolerated so consumer .devloops files that
|
|
308
|
+
// still carry a localPlanning block keep parsing. Accepted, never read.
|
|
309
|
+
localPlanning: z.unknown().optional(),
|
|
289
310
|
});
|
|
290
311
|
|
|
291
312
|
// ============================================================================
|
|
@@ -943,7 +964,7 @@ export function resolveRefinement(config) {
|
|
|
943
964
|
*
|
|
944
965
|
* @param {DevLoopConfig} config
|
|
945
966
|
* @param {"draft"|"preApproval"|"spike"} gate
|
|
946
|
-
* @returns {{ angles: string[]|null, excludeAngles: string[], mandatoryAngles: string[], required: boolean, requireCi: boolean, blockCleanOnFindingSeverities: string[], dynamicAngles: boolean }}
|
|
967
|
+
* @returns {{ angles: string[]|null, excludeAngles: string[], mandatoryAngles: string[], required: boolean, requireCi: boolean, blockCleanOnFindingSeverities: string[], dynamicAngles: boolean, additiveAngles: boolean }}
|
|
947
968
|
*/
|
|
948
969
|
export function resolveGateConfig(config, gate) {
|
|
949
970
|
const gateConfig = config?.gates?.[gate];
|
|
@@ -960,6 +981,7 @@ export function resolveGateConfig(config, gate) {
|
|
|
960
981
|
required: gateConfig?.required ?? true,
|
|
961
982
|
requireCi: gateConfig?.requireCi ?? true,
|
|
962
983
|
dynamicAngles: gateConfig?.dynamicAngles ?? false,
|
|
984
|
+
additiveAngles: gateConfig?.additiveAngles ?? false,
|
|
963
985
|
blockCleanOnFindingSeverities: gateConfig?.blockCleanOnFindingSeverities && Array.isArray(gateConfig.blockCleanOnFindingSeverities)
|
|
964
986
|
? [...gateConfig.blockCleanOnFindingSeverities]
|
|
965
987
|
: ["must-fix"],
|
|
@@ -984,28 +1006,41 @@ export function resolveRequireFanoutEvidence(config) {
|
|
|
984
1006
|
return config?.gates?.requireFanoutEvidence !== false;
|
|
985
1007
|
}
|
|
986
1008
|
|
|
987
|
-
/**
|
|
988
|
-
|
|
1009
|
+
/**
|
|
1010
|
+
* Minimum distinct reviewer count for a fanout_fanin ledger to satisfy
|
|
1011
|
+
* requireFanoutProvenance. A floor of 2 is the smallest count that is not a
|
|
1012
|
+
* single agent; it raises the bar but does not prove independence (provenance
|
|
1013
|
+
* is self-reported — see the honest caveat in
|
|
1014
|
+
* docs/gate-review-sub-loop-contract.md).
|
|
1015
|
+
*/
|
|
1016
|
+
export const FANOUT_PROVENANCE_MIN_REVIEWERS = 2;
|
|
989
1017
|
|
|
990
1018
|
/**
|
|
991
|
-
* Resolve
|
|
1019
|
+
* Resolve whether fan-out *provenance* is required for a fanout_fanin gate
|
|
1020
|
+
* verdict (distinct reviewer count + per-angle dispatch recorded in the ledger).
|
|
992
1021
|
*
|
|
993
|
-
*
|
|
994
|
-
*
|
|
995
|
-
*
|
|
996
|
-
*
|
|
997
|
-
*
|
|
998
|
-
* sequentially.
|
|
1022
|
+
* Default-OFF (opt-in): unlike resolveRequireFanoutEvidence, this uses a strict
|
|
1023
|
+
* `=== true` test so behavior is byte-identical to today unless a repo
|
|
1024
|
+
* explicitly opts in via `gates.requireFanoutProvenance: true`. Layered on top
|
|
1025
|
+
* of fan-out evidence enforcement (see buildFanoutEnforcement). See
|
|
1026
|
+
* docs/gate-review-sub-loop-contract.md.
|
|
999
1027
|
*
|
|
1000
1028
|
* @param {DevLoopConfig} config
|
|
1001
|
-
* @returns {
|
|
1029
|
+
* @returns {boolean}
|
|
1002
1030
|
*/
|
|
1003
|
-
export function
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1031
|
+
export function resolveRequireFanoutProvenance(config) {
|
|
1032
|
+
return config?.gates?.requireFanoutProvenance === true;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Resolve whether a fan-out provenance entry naming an angle outside the
|
|
1037
|
+
* gate's configured pool should FAIL (default) or only WARN.
|
|
1038
|
+
*
|
|
1039
|
+
* @param {DevLoopConfig} config
|
|
1040
|
+
* @returns {boolean}
|
|
1041
|
+
*/
|
|
1042
|
+
export function resolveRejectForeignAngles(config) {
|
|
1043
|
+
return config?.gates?.rejectForeignAngles !== false;
|
|
1009
1044
|
}
|
|
1010
1045
|
|
|
1011
1046
|
/**
|
|
@@ -1048,6 +1083,87 @@ export function resolveLightMode(config) {
|
|
|
1048
1083
|
};
|
|
1049
1084
|
}
|
|
1050
1085
|
|
|
1086
|
+
/**
|
|
1087
|
+
* Resolve the effective Copilot review round cap for a PR (#1210).
|
|
1088
|
+
*
|
|
1089
|
+
* Full PRs (lightweight=false) use `refinement.maxCopilotRounds` unchanged
|
|
1090
|
+
* (default 5). Light-dispatched PRs compose with it rather than replacing it:
|
|
1091
|
+
* `effective = min(localImplementation.lightMode.maxCopilotRounds ?? 1,
|
|
1092
|
+
* refinement.maxCopilotRounds)` — so setting `refinement.maxCopilotRounds: 0`
|
|
1093
|
+
* disables Copilot rounds everywhere, including lightweight, with that one
|
|
1094
|
+
* setting.
|
|
1095
|
+
*
|
|
1096
|
+
* @param {DevLoopConfig} config
|
|
1097
|
+
* @param {{ lightweight?: boolean }} [options]
|
|
1098
|
+
* @returns {number}
|
|
1099
|
+
*/
|
|
1100
|
+
export function resolveEffectiveCopilotRoundCap(config, { lightweight = false } = {}) {
|
|
1101
|
+
// Clamp here, not only in the zod schema: programmatically-built config
|
|
1102
|
+
// objects bypass schema defaulting/validation, and a negative cap must never
|
|
1103
|
+
// reach round-cap comparisons.
|
|
1104
|
+
const maxCopilotRounds = Math.max(0, /** @type {number} */ (resolveRefinementConfig(config, "maxCopilotRounds")));
|
|
1105
|
+
if (!lightweight) return maxCopilotRounds;
|
|
1106
|
+
const lightMaxRounds = config?.localImplementation?.lightMode?.maxCopilotRounds;
|
|
1107
|
+
const effectiveLightCap = typeof lightMaxRounds === "number" && Number.isFinite(lightMaxRounds)
|
|
1108
|
+
? Math.max(0, lightMaxRounds)
|
|
1109
|
+
: 1;
|
|
1110
|
+
return Math.min(effectiveLightCap, maxCopilotRounds);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/** Label that forces full fan-out regardless of change size. */
|
|
1114
|
+
export const GATE_FULL_LABEL = "gate:full";
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* Decide whether a gate should run as a single-agent inline check or the full
|
|
1118
|
+
* fan-out, from light-mode config + authoritative PR facts.
|
|
1119
|
+
*
|
|
1120
|
+
* Precedence (first match wins):
|
|
1121
|
+
* 1. `gate:full` label present → full_fanout (label override)
|
|
1122
|
+
* 2. light mode disabled / no threshold → full_fanout (light mode off)
|
|
1123
|
+
* 3. scope over threshold (files OR lines) → full_fanout (over threshold)
|
|
1124
|
+
* 4. inline check produced a finding whose severity is in the gate's
|
|
1125
|
+
* blockCleanOnFindingSeverities set → full_fanout (escalated)
|
|
1126
|
+
* 5. otherwise → inline
|
|
1127
|
+
*
|
|
1128
|
+
* Two call phases share this one function:
|
|
1129
|
+
* - pre-check: omit `inlineFindingSeverities` (undefined) → decides whether to
|
|
1130
|
+
* run the inline pass at all.
|
|
1131
|
+
* - escalation: pass the inline pass's finding severities → auto-escalates when
|
|
1132
|
+
* the inline check surfaced anything worth fixing.
|
|
1133
|
+
*
|
|
1134
|
+
* Absent or partial `facts.scope` fails safe to full_fanout (missing
|
|
1135
|
+
* filesChanged/linesChanged are treated as `Infinity` → over threshold).
|
|
1136
|
+
*
|
|
1137
|
+
* @param {DevLoopConfig} config
|
|
1138
|
+
* @param {"draft"|"preApproval"} gate
|
|
1139
|
+
* @param {object} facts
|
|
1140
|
+
* @param {{ filesChanged?: number, linesChanged?: number }} [facts.scope] PR scope; absent/partial fields fail safe to full_fanout
|
|
1141
|
+
* @param {boolean} [facts.hasFullLabel] `gate:full` label present on the PR
|
|
1142
|
+
* @param {string[]} [facts.inlineFindingSeverities] severities from the inline pass (escalation phase)
|
|
1143
|
+
* @returns {{ mode: "inline"|"full_fanout", reason: string, threshold: {maxFiles:number,maxLines:number}|null }}
|
|
1144
|
+
*/
|
|
1145
|
+
export function resolveGateDispatchMode(config, gate, { scope, hasFullLabel = false, inlineFindingSeverities } = {}) {
|
|
1146
|
+
if (hasFullLabel) {
|
|
1147
|
+
return { mode: "full_fanout", reason: "gate_full_label", threshold: null };
|
|
1148
|
+
}
|
|
1149
|
+
const threshold = resolveLightMode(config);
|
|
1150
|
+
if (!threshold) {
|
|
1151
|
+
return { mode: "full_fanout", reason: "light_mode_disabled", threshold: null };
|
|
1152
|
+
}
|
|
1153
|
+
const filesChanged = Number(scope?.filesChanged ?? Infinity);
|
|
1154
|
+
const linesChanged = Number(scope?.linesChanged ?? Infinity);
|
|
1155
|
+
if (filesChanged > threshold.maxFiles || linesChanged > threshold.maxLines) {
|
|
1156
|
+
return { mode: "full_fanout", reason: "over_threshold", threshold };
|
|
1157
|
+
}
|
|
1158
|
+
if (Array.isArray(inlineFindingSeverities) && inlineFindingSeverities.length > 0) {
|
|
1159
|
+
const blocking = new Set(resolveGateConfig(config, gate).blockCleanOnFindingSeverities);
|
|
1160
|
+
if (inlineFindingSeverities.some((s) => blocking.has(s))) {
|
|
1161
|
+
return { mode: "full_fanout", reason: "escalated", threshold };
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
return { mode: "inline", reason: "under_threshold", threshold };
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1051
1167
|
/**
|
|
1052
1168
|
* Resolve review angles for a specific gate from the merged dev-loop config.
|
|
1053
1169
|
*
|
|
@@ -1068,6 +1184,62 @@ export function resolveGateAngles(config, gate) {
|
|
|
1068
1184
|
return merged.filter(a => !excluded.has(a));
|
|
1069
1185
|
}
|
|
1070
1186
|
|
|
1187
|
+
/**
|
|
1188
|
+
* Resolve the global lens catalog available for additive angle selection.
|
|
1189
|
+
*
|
|
1190
|
+
* Returns the explicit `gates.anglePool` override when configured (non-empty
|
|
1191
|
+
* array of trimmed strings). Otherwise falls back to the union of all known
|
|
1192
|
+
* review angles: the built-in persona registry's angle names, plus every
|
|
1193
|
+
* angle actually configured across this config's own draft/preApproval/spike
|
|
1194
|
+
* gates (angles + mandatoryAngles). The persona registry alone omits angles
|
|
1195
|
+
* that ship in extension-defaults.yaml gate pools but have no dedicated
|
|
1196
|
+
* persona (e.g. ci-guard, link-check) — see #1048.
|
|
1197
|
+
*
|
|
1198
|
+
* @param {DevLoopConfig} config
|
|
1199
|
+
* @returns {string[]}
|
|
1200
|
+
*/
|
|
1201
|
+
export function resolveAnglePool(config) {
|
|
1202
|
+
const explicit = config?.gates?.anglePool;
|
|
1203
|
+
if (Array.isArray(explicit) && explicit.length > 0) {
|
|
1204
|
+
return [...new Set(explicit.map(a => (typeof a === "string" ? a.trim() : "")).filter(a => a.length > 0))];
|
|
1205
|
+
}
|
|
1206
|
+
const configured = ["draft", "preApproval", "spike"].flatMap((gate) => {
|
|
1207
|
+
const gateConfig = resolveGateConfig(config, gate);
|
|
1208
|
+
return [...(gateConfig.angles ?? []), ...gateConfig.mandatoryAngles];
|
|
1209
|
+
});
|
|
1210
|
+
return [...new Set([...Object.keys(BUILTIN_PERSONAS), ...configured])];
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* Resolve a gate's ANGLE ENFORCEMENT CONTRACT: the mandatory angles a
|
|
1215
|
+
* fanout_fanin verdict must cover and the pool its recorded angles must stay
|
|
1216
|
+
* within. Single source of truth for all angle-coverage enforcement consumers
|
|
1217
|
+
* (ledger write, verdict-comment write, merge-evidence read) so they agree.
|
|
1218
|
+
*
|
|
1219
|
+
* - `mandatoryAngles` is filtered through `excludeAngles`: a config that
|
|
1220
|
+
* excludes a mandatory angle must not deadlock every fanout write (the
|
|
1221
|
+
* angle would be missing-mandatory if omitted yet foreign if recorded).
|
|
1222
|
+
* - `pool` is `resolveGateAngles` (configured angles ∪ mandatoryAngles, minus
|
|
1223
|
+
* excludeAngles); when `additiveAngles` is enabled it widens to the global
|
|
1224
|
+
* lens catalog (`resolveAnglePool`) too — dynamic resolution may
|
|
1225
|
+
* legitimately dispatch catalog angles then — with `excludeAngles` still a
|
|
1226
|
+
* hard ceiling. A null pool skips the foreign-angle check entirely.
|
|
1227
|
+
*
|
|
1228
|
+
* @param {DevLoopConfig} config
|
|
1229
|
+
* @param {"draft"|"preApproval"|"spike"} gate
|
|
1230
|
+
* @returns {{ mandatoryAngles: string[], pool: string[]|null }}
|
|
1231
|
+
*/
|
|
1232
|
+
export function resolveGateAngleContract(config, gate) {
|
|
1233
|
+
const gateConfig = resolveGateConfig(config, gate);
|
|
1234
|
+
const excluded = new Set(gateConfig.excludeAngles);
|
|
1235
|
+
const mandatoryAngles = gateConfig.mandatoryAngles.filter((a) => !excluded.has(a));
|
|
1236
|
+
let pool = resolveGateAngles(config, gate);
|
|
1237
|
+
if (gateConfig.additiveAngles && pool !== null) {
|
|
1238
|
+
pool = [...new Set([...pool, ...resolveAnglePool(config)])].filter((a) => !excluded.has(a));
|
|
1239
|
+
}
|
|
1240
|
+
return { mandatoryAngles, pool };
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1071
1243
|
/**
|
|
1072
1244
|
* Resolve gate angles dynamically when `dynamicAngles` is enabled in config.
|
|
1073
1245
|
*
|
|
@@ -1077,17 +1249,23 @@ export function resolveGateAngles(config, gate) {
|
|
|
1077
1249
|
* When `dynamicAngles` is disabled (default), returns the full configured
|
|
1078
1250
|
* angle list (same as `resolveGateAngles`).
|
|
1079
1251
|
*
|
|
1252
|
+
* When `additiveAngles` is also enabled (default off, see #1048), catalog
|
|
1253
|
+
* angles from `resolveAnglePool()` (`gates.anglePool`, or else the union of
|
|
1254
|
+
* the persona registry and this config's own configured angles) recommended
|
|
1255
|
+
* by change-category heuristics but absent from the gate's configured pool
|
|
1256
|
+
* may also be added; `excludeAngles` remains a hard ceiling on additions.
|
|
1257
|
+
*
|
|
1080
1258
|
* @param {import("./types.js").DevLoopConfig} config
|
|
1081
1259
|
* @param {"draft"|"preApproval"} gate
|
|
1082
1260
|
* @param {object} [options]
|
|
1083
1261
|
* @param {{ nameStatusOutput: string, diffOutput?: string }} [options.diff]
|
|
1084
|
-
* @returns {{ recommendedAngles: string[] | null, skippedAngles: string[], reasons: Record<string,string>, fallbackToAll: boolean, dynamicAnglesActive: boolean }}
|
|
1262
|
+
* @returns {{ recommendedAngles: string[] | null, skippedAngles: string[], reasons: Record<string,string>, fallbackToAll: boolean, dynamicAnglesActive: boolean, addedAngles: string[], addedReasons: Record<string,string> }}
|
|
1085
1263
|
*/
|
|
1086
1264
|
export async function resolveGateAnglesDynamic(config, gate, { diff } = {}) {
|
|
1087
1265
|
const gateConfig = resolveGateConfig(config, gate);
|
|
1088
1266
|
const staticAngles = resolveGateAngles(config, gate);
|
|
1089
1267
|
if (staticAngles === null) {
|
|
1090
|
-
return { recommendedAngles: null, skippedAngles: [], reasons: {}, fallbackToAll: false, dynamicAnglesActive: false };
|
|
1268
|
+
return { recommendedAngles: null, skippedAngles: [], reasons: {}, fallbackToAll: false, dynamicAnglesActive: false, addedAngles: [], addedReasons: {} };
|
|
1091
1269
|
}
|
|
1092
1270
|
|
|
1093
1271
|
if (!gateConfig.dynamicAngles || !diff) {
|
|
@@ -1097,6 +1275,8 @@ export async function resolveGateAnglesDynamic(config, gate, { diff } = {}) {
|
|
|
1097
1275
|
reasons: {},
|
|
1098
1276
|
fallbackToAll: false,
|
|
1099
1277
|
dynamicAnglesActive: false,
|
|
1278
|
+
addedAngles: [],
|
|
1279
|
+
addedReasons: {},
|
|
1100
1280
|
};
|
|
1101
1281
|
}
|
|
1102
1282
|
|
|
@@ -1114,17 +1294,35 @@ export async function resolveGateAnglesDynamic(config, gate, { diff } = {}) {
|
|
|
1114
1294
|
|
|
1115
1295
|
const categories = [...new Set(analysis.t1?.changeCategories ?? [])];
|
|
1116
1296
|
|
|
1297
|
+
// excludeAngles is a hard ceiling: computed once and reused both to cap the
|
|
1298
|
+
// additive anglePool and to filter mandatoryAngles below.
|
|
1299
|
+
const excluded = new Set(gateConfig.excludeAngles);
|
|
1300
|
+
const anglePool = gateConfig.additiveAngles
|
|
1301
|
+
? resolveAnglePool(config).filter(a => !excluded.has(a))
|
|
1302
|
+
: undefined;
|
|
1303
|
+
|
|
1117
1304
|
const { resolveDynamicAngles: resolve } = await import("../analysis/change-classifier.mjs");
|
|
1118
1305
|
const dynamicResult = resolve({
|
|
1119
1306
|
configuredAngles: candidatePool,
|
|
1120
1307
|
changeCategories: categories,
|
|
1121
1308
|
ambiguous: analysis.ambiguous,
|
|
1309
|
+
anglePool,
|
|
1122
1310
|
});
|
|
1123
1311
|
|
|
1124
|
-
// Merge: mandatory always included (filtered by excludeAngles) + dynamically-selected
|
|
1125
|
-
|
|
1312
|
+
// Merge: mandatory always included (filtered by excludeAngles) + dynamically-selected
|
|
1313
|
+
// candidates + additively-selected catalog angles (#1048)
|
|
1126
1314
|
const filteredMandatory = gateConfig.mandatoryAngles.filter(a => !excluded.has(a));
|
|
1127
|
-
|
|
1315
|
+
|
|
1316
|
+
// An angle that is both mandatory AND additively recommended must stay
|
|
1317
|
+
// attributed to the mandatory floor, not be reported as "added" — the
|
|
1318
|
+
// resolver has no concept of "mandatory", so the caller (this function,
|
|
1319
|
+
// which already owns the mandatory Set) filters its output.
|
|
1320
|
+
const addedAngles = (dynamicResult.addedAngles ?? []).filter(a => !mandatory.has(a));
|
|
1321
|
+
const addedReasons = Object.fromEntries(
|
|
1322
|
+
Object.entries(dynamicResult.addedReasons ?? {}).filter(([a]) => !mandatory.has(a))
|
|
1323
|
+
);
|
|
1324
|
+
|
|
1325
|
+
const recommendedAngles = [...new Set([...filteredMandatory, ...dynamicResult.recommendedAngles, ...addedAngles])];
|
|
1128
1326
|
|
|
1129
1327
|
return {
|
|
1130
1328
|
recommendedAngles,
|
|
@@ -1132,6 +1330,8 @@ export async function resolveGateAnglesDynamic(config, gate, { diff } = {}) {
|
|
|
1132
1330
|
reasons: dynamicResult.reasons,
|
|
1133
1331
|
fallbackToAll: dynamicResult.fallbackToAll,
|
|
1134
1332
|
dynamicAnglesActive: true,
|
|
1333
|
+
addedAngles,
|
|
1334
|
+
addedReasons,
|
|
1135
1335
|
};
|
|
1136
1336
|
}
|
|
1137
1337
|
|
|
@@ -1142,7 +1342,7 @@ export async function resolveGateAnglesDynamic(config, gate, { diff } = {}) {
|
|
|
1142
1342
|
* for the requested key.
|
|
1143
1343
|
*
|
|
1144
1344
|
* @param {DevLoopConfig} config
|
|
1145
|
-
* @param {"asyncStartMode"|"requireRetrospective"|"
|
|
1345
|
+
* @param {"asyncStartMode"|"requireRetrospective"|"requireDraftFirst"|"devModeDefault"} key
|
|
1146
1346
|
* @returns {string|boolean}
|
|
1147
1347
|
*/
|
|
1148
1348
|
export function resolveWorkflowConfig(config, key) {
|
|
@@ -1154,14 +1354,6 @@ export function resolveWorkflowConfig(config, key) {
|
|
|
1154
1354
|
return config?.workflow?.requireRetrospective ?? DEFAULT_WORKFLOW_CONFIG.requireRetrospective;
|
|
1155
1355
|
}
|
|
1156
1356
|
|
|
1157
|
-
if (key === "requireRetrospectiveGate") {
|
|
1158
|
-
return config?.workflow?.requireRetrospectiveGate ?? DEFAULT_WORKFLOW_CONFIG.requireRetrospectiveGate;
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
if (key === "requireRetrospectiveInternalTooling") {
|
|
1162
|
-
return config?.workflow?.requireRetrospectiveInternalTooling ?? DEFAULT_WORKFLOW_CONFIG.requireRetrospectiveInternalTooling;
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
1357
|
if (key === "requireDraftFirst") {
|
|
1166
1358
|
return config?.workflow?.requireDraftFirst ?? DEFAULT_WORKFLOW_CONFIG.requireDraftFirst;
|
|
1167
1359
|
}
|
|
@@ -1173,20 +1365,6 @@ export function resolveWorkflowConfig(config, key) {
|
|
|
1173
1365
|
throw new Error(`Unknown workflow config key: ${key}`);
|
|
1174
1366
|
}
|
|
1175
1367
|
|
|
1176
|
-
const DEFAULT_INTERNAL_PATH_PATTERNS = BUILT_IN_DEFAULTS.internalPathPatterns;
|
|
1177
|
-
|
|
1178
|
-
/**
|
|
1179
|
-
* Resolve the internal path patterns from the merged dev-loop config.
|
|
1180
|
-
*
|
|
1181
|
-
* Returns an array of regex pattern strings used by detect-internal-only-pr.mjs
|
|
1182
|
-
* to classify files as internal tooling (vs consumer-facing). When the config
|
|
1183
|
-
* omits this section, returns the built-in shipped defaults.
|
|
1184
|
-
*
|
|
1185
|
-
* Consumers can override these in .devloops at repo root.
|
|
1186
|
-
*
|
|
1187
|
-
* @param {DevLoopConfig} config
|
|
1188
|
-
* @returns {string[]}
|
|
1189
|
-
*/
|
|
1190
1368
|
/**
|
|
1191
1369
|
* Resolve the worktree lifecycle config from the merged dev-loop config.
|
|
1192
1370
|
*
|
|
@@ -1207,24 +1385,6 @@ export function resolveWorktreeConfig(config) {
|
|
|
1207
1385
|
return { copyOnInit: list(wt?.copyOnInit), linkOnInit: list(wt?.linkOnInit) };
|
|
1208
1386
|
}
|
|
1209
1387
|
|
|
1210
|
-
/**
|
|
1211
|
-
* Resolve the local-planning plans directory from the merged dev-loop config.
|
|
1212
|
-
*
|
|
1213
|
-
* Returns the configured `localPlanning.plansDir` (trimmed) when present and
|
|
1214
|
-
* non-empty, otherwise the built-in default (`docs/phases/`) — the existing
|
|
1215
|
-
* phase-docs directory. See skills/docs/plan-file-contract.md.
|
|
1216
|
-
*
|
|
1217
|
-
* @param {DevLoopConfig} config
|
|
1218
|
-
* @returns {string}
|
|
1219
|
-
*/
|
|
1220
|
-
export function resolvePlansDir(config) {
|
|
1221
|
-
const raw = config?.localPlanning?.plansDir;
|
|
1222
|
-
if (typeof raw === "string" && raw.trim().length > 0) {
|
|
1223
|
-
return raw.trim();
|
|
1224
|
-
}
|
|
1225
|
-
return BUILT_IN_DEFAULTS.localPlanning.plansDir;
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
1388
|
/**
|
|
1229
1389
|
* Resolve the human-handoff config from the merged dev-loop config (#920).
|
|
1230
1390
|
*
|
|
@@ -1259,14 +1419,3 @@ export function resolveHumanHandoffConfig(config) {
|
|
|
1259
1419
|
assignees: enabled ? assignees : [],
|
|
1260
1420
|
};
|
|
1261
1421
|
}
|
|
1262
|
-
|
|
1263
|
-
export function resolveInternalPathPatterns(config) {
|
|
1264
|
-
if (
|
|
1265
|
-
config?.internalPathPatterns &&
|
|
1266
|
-
Array.isArray(config.internalPathPatterns) &&
|
|
1267
|
-
config.internalPathPatterns.length > 0
|
|
1268
|
-
) {
|
|
1269
|
-
return [...config.internalPathPatterns];
|
|
1270
|
-
}
|
|
1271
|
-
return [...DEFAULT_INTERNAL_PATH_PATTERNS];
|
|
1272
|
-
}
|
|
@@ -44,6 +44,9 @@ gates:
|
|
|
44
44
|
- renderer-security
|
|
45
45
|
- determinism
|
|
46
46
|
- pr-comments
|
|
47
|
+
- contradiction-lens
|
|
48
|
+
- code-conformance
|
|
49
|
+
- semantic-drift
|
|
47
50
|
excludeAngles: []
|
|
48
51
|
required: true
|
|
49
52
|
requireCi: true
|
|
@@ -66,6 +69,9 @@ gates:
|
|
|
66
69
|
- dip
|
|
67
70
|
- docs
|
|
68
71
|
- pr-checklist-matrix
|
|
72
|
+
- contradiction-lens
|
|
73
|
+
- correctness-final
|
|
74
|
+
- ui-validation
|
|
69
75
|
excludeAngles: []
|
|
70
76
|
required: true
|
|
71
77
|
mandatoryAngles:
|
|
@@ -100,12 +106,6 @@ workflow:
|
|
|
100
106
|
# default (DEFAULT_WORKFLOW_CONFIG) and the contract. The dev-loops repo opts in via its own
|
|
101
107
|
# repo-root .devloops, which takes precedence over these extension defaults.
|
|
102
108
|
requireRetrospective: false
|
|
103
|
-
requireRetrospectiveGate: false
|
|
104
|
-
# Internal-tooling-only retro check (#982) is a DEVELOPER-MODE step — the dev-loops
|
|
105
|
-
# maintainers' own dogfooding discipline. It must never block a consumer's state
|
|
106
|
-
# changes (consumers may legitimately use raw gh/python/node -e), so it ships OFF.
|
|
107
|
-
# The dev-loops repo opts in via its own repo-root .devloops (takes precedence here).
|
|
108
|
-
requireRetrospectiveInternalTooling: false
|
|
109
109
|
requireDraftFirst: true
|
|
110
110
|
# Dev mode is the dev-loop self-improvement mode — it edits the loop's own skill/agent prompts
|
|
111
111
|
# after a phase, which is only meaningful in the dev-loops repo. Shipped defaults must not force
|
|
@@ -113,11 +113,6 @@ workflow:
|
|
|
113
113
|
# via its own repo-root .devloops (which takes precedence over these extension defaults).
|
|
114
114
|
devModeDefault: false
|
|
115
115
|
|
|
116
|
-
# Local-planning: where persisted markdown plan files (phase-doc format) live
|
|
117
|
-
# when work originates from a plan file rather than a tracker issue (#949).
|
|
118
|
-
localPlanning:
|
|
119
|
-
plansDir: docs/phases/
|
|
120
|
-
|
|
121
116
|
# Light-mode threshold for small local changes.
|
|
122
117
|
localImplementation:
|
|
123
118
|
lightMode:
|