@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.
@@ -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 named human
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
- localPlanning: LocalPlanningConfig.optional(),
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: "github-first" }),
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
- localPlanning: LocalPlanningConfig.partial().optional(),
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
- /** Default parallel fan-out reviewer cap (mirrors GatesConfig.maxFanoutReviewers). */
988
- export const DEFAULT_MAX_FANOUT_REVIEWERS = 8;
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 the parallel fan-out reviewer cap for the gate sub-loop.
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
- * Returns the configured `gates.maxFanoutReviewers` when it is an integer in
994
- * the schema-bounded range 1..64; otherwise the built-in default (8). Clamping
995
- * here (not just the Zod schema) keeps programmatically-constructed config
996
- * objects that bypass schema validation within the same bound. The fan-out
997
- * spawns at most this many scoped `review` reviewers in parallel; overflow runs
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 {number}
1029
+ * @returns {boolean}
1002
1030
  */
1003
- export function resolveMaxFanoutReviewers(config) {
1004
- const raw = config?.gates?.maxFanoutReviewers;
1005
- if (typeof raw === "number" && Number.isInteger(raw) && raw >= 1 && raw <= 64) {
1006
- return raw;
1007
- }
1008
- return DEFAULT_MAX_FANOUT_REVIEWERS;
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 candidates
1125
- const excluded = new Set(gateConfig.excludeAngles);
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
- const recommendedAngles = [...new Set([...filteredMandatory, ...dynamicResult.recommendedAngles])];
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"|"requireRetrospectiveGate"|"requireRetrospectiveInternalTooling"|"requireDraftFirst"|"devModeDefault"} key
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: