@dev-loops/core 1.0.3 → 1.0.4-pre.1

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.
@@ -6,6 +6,7 @@ import { parse as parseYaml } from "yaml";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { z } from "zod";
8
8
  import { classifyFile } from "../analysis/diff-analyzer.mjs";
9
+ import { ChangeCategory, resolveDynamicAngles } from "../analysis/change-classifier.mjs";
9
10
  import { isDevLoopConfigSourcePath } from "../loop/gate-carry-forward.mjs";
10
11
  import { isClaudeHarness } from "../loop/run-context.mjs";
11
12
  import { trimmedOrNull } from "../loop/normalize.mjs";
@@ -45,6 +46,12 @@ const BUILTIN_ROLE_TIERS = Object.freeze({
45
46
  quality: "low",
46
47
  refiner: "high",
47
48
  review: "high",
49
+ // The pre-PR review pass (skills/docs/pre-pr-review-contract.md) runs one
50
+ // fresh-context general-purpose reviewer before the first push. Default tier
51
+ // is high (strongest): with zero config that resolves to opus on Claude and
52
+ // null (inherit) on Pi. Operators opt into a concrete strong model per
53
+ // harness via models.tiers/roleTiers.
54
+ "pre-PR-reviewer": "high",
48
55
  "dev-loop": "inherit",
49
56
  });
50
57
 
@@ -113,6 +120,13 @@ const RefinementConfig = z.strictObject({
113
120
  // cost saving, never a silently-enforced information cut.
114
121
  export const GATE_ANGLE_SCOPES = Object.freeze(["full", "changed-files", "docs-only"]);
115
122
 
123
+ // Change-category and file-kind vocabularies a consumer angle can bind to.
124
+ // CHANGE_CATEGORY_NAMES mirrors ChangeCategory; FILE_KIND_NAMES mirrors
125
+ // classifyFile()'s output range. Both feed z.enum so an unknown name is
126
+ // rejected fail-closed at validation instead of silently never matching.
127
+ const CHANGE_CATEGORY_NAMES = Object.freeze(Object.values(ChangeCategory));
128
+ const FILE_KIND_NAMES = Object.freeze(["code", "docs", "config", "test", "ci", "unknown"]);
129
+
116
130
  // One review angle: a bare string is sugar for `{ name }`; the fields are
117
131
  // documented on the schema below. mergeConfigLayers merges these arrays BY
118
132
  // `name` across config layers, so a later layer can add or disable a single
@@ -132,13 +146,15 @@ const GateAngleEntry = z.preprocess(
132
146
  model: z.string().trim().min(1).optional().describe("Concrete model override for this angle (highest precedence)."),
133
147
  tier: z.string().trim().min(1).optional().describe("Model tier alias for this angle (used when `model` is absent)."),
134
148
  scope: z.enum(GATE_ANGLE_SCOPES).optional().describe("Surface scope this angle needs: full (default), changed-files (diff without the adjacent-code bundle or its changed-files/adjacent-file summary section), or docs-only (doc-file hunks only). Unknown/omitted resolves to full."),
149
+ categories: z.array(z.enum(CHANGE_CATEGORY_NAMES)).min(1).optional().describe("Change categories (e.g. LOGIC_CHANGE, CONFIG_ONLY, SECURITY_SENSITIVE_SEAM) that dynamically SELECT this consumer angle by diff, so it need not be forced mandatory. Unknown names are rejected fail-closed."),
150
+ kinds: z.array(z.enum(FILE_KIND_NAMES)).min(1).optional().describe("File kinds (code/config/test/ci/docs/unknown, classifyFile output) that dynamically SELECT this consumer angle by diff. Unknown names are rejected fail-closed."),
135
151
  }),
136
152
  );
137
153
 
138
154
  // Diff-class kinds a tier's `match` can name — exactly classifyFile()'s
139
155
  // output range (../analysis/diff-analyzer.mjs), so a tier config can never
140
156
  // name a kind the classifier could not produce.
141
- const GateTierMatchKind = z.enum(["code", "docs", "config", "test", "ci", "unknown"]);
157
+ const GateTierMatchKind = z.enum(FILE_KIND_NAMES);
142
158
 
143
159
  // A tier's match conditions: EVERY changed file's kind must be in `kinds`
144
160
  // (when set) AND the change must stay within `maxFiles`/`maxLines` (when
@@ -168,9 +184,9 @@ const GateTier = z.strictObject({
168
184
  });
169
185
 
170
186
  const GateDynamicConfig = z.strictObject({
171
- // Diff-driven dynamic angle PRUNING, ON by default. mandatory:true
172
- // angles stay a hard always-run floor; fallbackToAll degrades to the full
173
- // static pool when classification is ambiguous.
187
+ // Diff-driven dynamic angle PRUNING, ON by default. mandatory:true angles
188
+ // stay a hard always-run floor. fallbackToAll is retained in resolver output
189
+ // for compatibility but is always false; uncertainty never widens the set.
174
190
  subtractive: z.boolean().default(true).describe("Enable diff-driven dynamic angle PRUNING for this gate (ON by default; set false to restore the full static angle pool). Was gates.<gate>.dynamicAngles."),
175
191
  // Additive counterpart to the subtractive path: when true, the
176
192
  // context-builder may also ADD catalog angles (from resolveAnglePool) that
@@ -216,7 +232,7 @@ function formatConfigValue(value) {
216
232
  const GATE_KEYS_WITH_BLOCKING_SEVERITIES = /** @type {const} */ (["draft", "preApproval", "spike"]);
217
233
 
218
234
  const GateConfig = z.strictObject({
219
- 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."),
235
+ 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/scope/categories/kinds."),
220
236
  dynamic: GateDynamicConfig.optional().describe("Diff-driven dynamic angle selection policy for this gate."),
221
237
  required: z.boolean().default(true).describe("Whether this gate must run."),
222
238
  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."),
@@ -234,6 +250,13 @@ const GateConfig = z.strictObject({
234
250
  // resolveGateConfig applies the built-in fallback (3) after checking both.
235
251
  mediumFixWindow: z.number().int().nonnegative().optional().describe("Per-gate medium fix window: an open medium finding stays in the in-gate fix loop through this many rounds of this gate's chain before deferral. high is exempt (never defers). Default 3."),
236
252
  worthFixingNowFixWindow: z.number().int().nonnegative().optional().describe("Deprecated alias for mediumFixWindow (pre-rename key name); mediumFixWindow wins when both are set."),
253
+ // No schema-level `.default()` for the same reason as mediumFixWindow above:
254
+ // a default would fill this key on every config layer independently and
255
+ // shadow a layer that sets only this key. resolveGateConfig applies the
256
+ // built-in fallback ("medium") when the key is absent on the resolved gate.
257
+ inlineSeverityFloor: z.enum(["medium", "low", "nit"]).optional().describe(
258
+ "Lowest defect severity still posted as an inline resolvable review thread. Valid values: \"medium\" (default), \"low\", \"nit\" — the floor can never be raised above \"medium\", so medium/high/question always post inline and only low/nit can ever fold. Findings BELOW this floor are folded into a collapsed <details> block in the verdict-marker body instead of posting inline (they create no gate-authored thread); this enforces the \"never suppress medium/high\" non-goal, keeping the folded-summary \"low/nit\" label accurate by construction. A \"question\" always posts inline regardless of this floor (it must keep its resolvable thread to block gate-close until answered). Lower it (e.g. \"low\" or \"nit\") to restore inline posting of lower severities."
259
+ ),
237
260
  // Ordered, first-match-wins diff-class angle tiers (see resolveGateTier).
238
261
  // Absent/empty = tiers never apply.
239
262
  tiers: z.array(GateTier).min(1).describe("Ordered, first-match-wins diff-class angle tiers for this gate. When the first-matching tier's angle set is inside the gate's angle pool, it replaces dynamic angle reduction for that diff class.").optional(),
@@ -916,13 +939,13 @@ const DEFAULT_REVIEWER_PERSONA = "default-reviewer";
916
939
  /**
917
940
  * Normalize one raw `gates.<gate>.angles[]` entry (string sugar or object,
918
941
  * possibly hand-built and never zod-validated — e.g. a test config object) to
919
- * `{ name, mandatory?, enabled?, persona?, prompt?, model?, tier?, scope? }`.
942
+ * `{ name, mandatory?, enabled?, persona?, prompt?, model?, tier?, scope?, categories?, kinds? }`.
920
943
  * Returns null for a malformed/empty entry so callers can filter it out. An
921
944
  * invalid `scope` (not one of GATE_ANGLE_SCOPES) is dropped rather than
922
945
  * kept verbatim — resolveGateAngleScope's fail-open default only ever needs
923
946
  * to handle an ABSENT field, never a foreign value.
924
947
  * @param {unknown} a
925
- * @returns {{name: string, mandatory?: boolean, enabled?: boolean, persona?: string, prompt?: string, model?: string, tier?: string, scope?: string}|null}
948
+ * @returns {{name: string, mandatory?: boolean, enabled?: boolean, persona?: string, prompt?: string, model?: string, tier?: string, scope?: string, categories?: string[], kinds?: string[]}|null}
926
949
  */
927
950
  function normalizeAngleEntry(a) {
928
951
  if (typeof a === "string") {
@@ -940,6 +963,17 @@ function normalizeAngleEntry(a) {
940
963
  if (typeof a.model === "string" && a.model.trim().length > 0) entry.model = a.model.trim();
941
964
  if (typeof a.tier === "string" && a.tier.trim().length > 0) entry.tier = a.tier.trim();
942
965
  if (typeof a.scope === "string" && GATE_ANGLE_SCOPES.includes(a.scope.trim())) entry.scope = a.scope.trim();
966
+ // Category/file-kind bindings for consumer angles. Enum membership is
967
+ // enforced by the schema; this hand-built path only keeps non-empty string
968
+ // entries (bad names simply never match at resolve time).
969
+ const cats = Array.isArray(a.categories)
970
+ ? a.categories.filter((c) => typeof c === "string" && c.trim().length > 0).map((c) => c.trim())
971
+ : [];
972
+ if (cats.length > 0) entry.categories = cats;
973
+ const kinds = Array.isArray(a.kinds)
974
+ ? a.kinds.filter((k) => typeof k === "string" && k.trim().length > 0).map((k) => k.trim())
975
+ : [];
976
+ if (kinds.length > 0) entry.kinds = kinds;
943
977
  return entry;
944
978
  }
945
979
  return null;
@@ -949,7 +983,7 @@ function normalizeAngleEntry(a) {
949
983
  * Normalize a raw `gates.<gate>.angles` array into full entry objects,
950
984
  * dropping malformed entries.
951
985
  * @param {unknown} raw
952
- * @returns {Array<{name: string, mandatory?: boolean, enabled?: boolean, persona?: string, prompt?: string, model?: string, tier?: string, scope?: string}>}
986
+ * @returns {Array<{name: string, mandatory?: boolean, enabled?: boolean, persona?: string, prompt?: string, model?: string, tier?: string, scope?: string, categories?: string[], kinds?: string[]}>}
953
987
  */
954
988
  function normalizeAngleEntries(raw) {
955
989
  if (!Array.isArray(raw)) return [];
@@ -1771,7 +1805,7 @@ function resolveBlockingSeverities(config, gate) {
1771
1805
  *
1772
1806
  * @param {DevLoopConfig} config
1773
1807
  * @param {"draft"|"preApproval"|"spike"} gate
1774
- * @returns {{ angles: string[]|null, excludeAngles: string[], mandatoryAngles: string[], required: boolean, requireCi: boolean, blockCleanOnFindingSeverities: string[], dynamicAngles: boolean, additiveAngles: boolean, mediumFixWindow: number, tiers: Array<{name: string, match: object, angles: string[]}> }}
1808
+ * @returns {{ angles: string[]|null, excludeAngles: string[], mandatoryAngles: string[], required: boolean, requireCi: boolean, blockCleanOnFindingSeverities: string[], dynamicAngles: boolean, additiveAngles: boolean, mediumFixWindow: number, inlineSeverityFloor: string, tiers: Array<{name: string, match: object, angles: string[]}>, angleCategoryBindings: Record<string, {categories: string[], kinds: string[]}> }}
1775
1809
  * @throws {Error} when ANY gate's (not only the requested one's) PRESENT
1776
1810
  * `blockCleanOnFindingSeverities` is schema-invalid (non-array, empty, or an
1777
1811
  * out-of-vocabulary entry). Validated EAGERLY across all three gates on every
@@ -1808,7 +1842,17 @@ export function resolveGateConfig(config, gate) {
1808
1842
  // mediumFixWindow wins; worthFixingNowFixWindow is the deprecated alias,
1809
1843
  // still honored so an unmigrated config keeps its window.
1810
1844
  mediumFixWindow: gateConfig?.mediumFixWindow ?? gateConfig?.worthFixingNowFixWindow ?? 3,
1845
+ inlineSeverityFloor: gateConfig?.inlineSeverityFloor ?? "medium",
1811
1846
  tiers: gateConfig?.tiers ?? [],
1847
+ // Per-angle category/file-kind bindings for enabled entries that declare
1848
+ // them, so dynamic resolution can select a consumer angle by diff instead
1849
+ // of forcing it mandatory. Only entries WITH a declaration appear here;
1850
+ // everything else keeps today's behavior.
1851
+ angleCategoryBindings: Object.fromEntries(
1852
+ entries
1853
+ .filter((e) => e.enabled !== false && (e.categories || e.kinds))
1854
+ .map((e) => [e.name, { categories: e.categories ?? [], kinds: e.kinds ?? [] }]),
1855
+ ),
1812
1856
  };
1813
1857
  }
1814
1858
 
@@ -2161,12 +2205,15 @@ export function resolveFanoutSequential(config) {
2161
2205
 
2162
2206
  /**
2163
2207
  * Claude-harness-scoped cap on effective fan-out concurrency (per ADR
2164
- * docs/decisions/0069-claude-harness-fanout-concurrency-clamp.md). The
2165
- * shipped cross-harness `gates.fanout.maxConcurrent` default (4) plus the
2166
- * driver's own call still 429s a single-driver Claude session; other
2167
- * harnesses (pi, unknown) are unaffected — see `resolveFanoutEffectiveConcurrency`.
2208
+ * docs/decisions/0069-claude-harness-fanout-concurrency-clamp.md, amended by
2209
+ * docs/decisions/0083-raise-claude-fanout-concurrency-cap-to-4.md). The
2210
+ * `GATE-EXEC-DISPATCH-RETRY-BACKOFF` retry/backoff policy turns a single 429
2211
+ * into latency instead of a failed drive, so this cap only bounds the
2212
+ * steady-state per-wave burst (driver + dispatch units) for a single-driver
2213
+ * Claude session; other harnesses (pi, unknown) are unaffected — see
2214
+ * `resolveFanoutEffectiveConcurrency`.
2168
2215
  */
2169
- export const CLAUDE_MAX_EFFECTIVE_CONCURRENT = 2;
2216
+ export const CLAUDE_MAX_EFFECTIVE_CONCURRENT = 4;
2170
2217
 
2171
2218
  /**
2172
2219
  * Resolve the effective fan-out concurrency (dispatch units per wave): 1 when
@@ -2438,6 +2485,51 @@ export function resolveGateTier(config, gate, { changedFiles, filesChanged, line
2438
2485
  return { tier: matched.name, angles: [...new Set([...mandatoryAngles, ...matched.angles])], reason: "tier_match" };
2439
2486
  }
2440
2487
 
2488
+ /**
2489
+ * Best-effort angle selection for a diff no diff-class tier matched (an
2490
+ * unclassifiable file, a dev-loop config-source delta, or no tier configured).
2491
+ * Uncertainty selects the mandatory floor plus the lenses the diff still
2492
+ * justifies — the always-include lens and any consumer angle whose declared
2493
+ * category/kind binding intersects the diff. It is never the whole pool,
2494
+ * except for the degenerate gate whose pool holds neither a mandatory nor an
2495
+ * always-include angle: an empty best-effort selection falls back to the static
2496
+ * pool (fail-closed — more angles, not fewer) rather than returning nothing.
2497
+ *
2498
+ * `dynamic.subtractive: false` is the documented opt-out ("restore the full
2499
+ * static angle pool"); this returns the static pool unchanged, mirroring
2500
+ * resolveGateAnglesDynamic's `!gateConfig.dynamicAngles` branch so the composer
2501
+ * and the resolver agree for the same diff.
2502
+ *
2503
+ * This composer has no diff TEXT (only the changed-file list), so it can bind
2504
+ * on file kinds but not on hunk-derived change categories. Except for the
2505
+ * `gate:full` path, it is therefore a lower bound: the round's authoritative
2506
+ * angle set is resolved by resolveGateAnglesDynamic, which can add lenses bound
2507
+ * to real change categories. On `gate:full`, the composer deliberately returns
2508
+ * the full static pool and is instead a superset of the dynamic resolver.
2509
+ */
2510
+ function selectFloorPlusJustifiedAngles(config, gate, changedFiles) {
2511
+ const gateConfig = resolveGateConfig(config, gate);
2512
+ // Documented opt-out: `dynamic.subtractive: false` restores the full static
2513
+ // pool. Mirror resolveGateAnglesDynamic's `!gateConfig.dynamicAngles` branch
2514
+ // so the composer and the resolver never disagree under an explicit operator
2515
+ // config (both are callers of the same selection contract).
2516
+ if (!gateConfig.dynamicAngles) return resolveGateAngles(config, gate) ?? [];
2517
+ const { mandatoryAngles } = resolveGateAngleContract(config, gate);
2518
+ const pool = resolveGateAngles(config, gate) ?? [];
2519
+ const candidatePool = pool.filter((a) => !mandatoryAngles.includes(a));
2520
+ const validChangedFiles = (Array.isArray(changedFiles) ? changedFiles : [])
2521
+ .filter((f) => typeof f === "string" && f.trim().length > 0);
2522
+ const fileKinds = [...new Set(validChangedFiles.map((f) => classifyFile(f)))];
2523
+ const { recommendedAngles } = resolveDynamicAngles({
2524
+ configuredAngles: candidatePool,
2525
+ changeCategories: [],
2526
+ fileKinds,
2527
+ angleDeclarations: gateConfig.angleCategoryBindings,
2528
+ });
2529
+ const selected = [...new Set([...mandatoryAngles, ...recommendedAngles])];
2530
+ return selected.length > 0 ? selected : pool;
2531
+ }
2532
+
2441
2533
  /**
2442
2534
  * The primer-owned deterministic review-proportionality plan
2443
2535
  * (GATE-EXEC-PROPORTIONALITY, gate-review-sub-loop-contract.md): a single,
@@ -2445,25 +2537,25 @@ export function resolveGateTier(config, gate, { changedFiles, filesChanged, line
2445
2537
  * set + execution mode + grouping) is one testable, persistable object.
2446
2538
  * Delegates entirely to {@link resolveGateDispatchMode} (mode, including the
2447
2539
  * non-overridable size-cap/risk-path/size-outcome/ambiguity floors),
2448
- * {@link resolveGateTier} (angle set AND diff-classification), and
2449
- * {@link resolveFanoutGroups} (dispatch-unit grouping). No git I/O, no logic
2540
+ * {@link resolveGateTier} (angle set AND diff-classification),
2541
+ * {@link resolveDynamicAngles} (no-tier best-effort selection), and {@link
2542
+ * resolveFanoutGroups} (dispatch-unit grouping). No git I/O, no logic
2450
2543
  * of its own beyond the floor-vs-tier precedence below: this is the ONE place
2451
2544
  * the primer (emit) and the merge gate (re-verify) compose mode + angles +
2452
2545
  * grouping, so they can never drift onto two different floor implementations.
2453
2546
  *
2454
- * Floor-vs-tier precedence: a fired RISK-signal floor — the risk-path
2455
- * denylist (`risk_path_touch`), a non-clean/ambiguous size-budget outcome
2456
- * (`size_outcome_*`, `size_outcome_unavailable`), missing changed-file
2457
- * evidence (`changed_files_unavailable`), or an unclassifiable diff
2458
- * (`resolveGateTier`'s `unclassifiable_file`) — ALWAYS forces `full_fanout`
2459
- * with the FULL untriered angle pool, never a matched tier's reduced set. The
2460
- * hard size cap (`over_threshold`) differs: it ALWAYS forces `full_fanout`
2461
- * MODE (distinct-reviewer-per-angle, never the light single-combined path)
2462
- * but does NOT force the full untriered pool — a merely-over-cap-but-tier-
2463
- * classifiable diff keeps its diff-class-tier-reduced angle set (the
2464
- * pre-existing, orthogonal mechanism), untouched for a `gate:full`-labelled
2465
- * PR (resolveGateTier self-bypasses) or a repo with light mode disabled
2466
- * (`light_mode_disabled` is not a floor).
2547
+ * Floor-vs-tier precedence: every fired floor affects DISPATCH only. A
2548
+ * RISK-signal floor — the risk-path denylist (`risk_path_touch`), a non-clean/
2549
+ * ambiguous size-budget outcome (`size_outcome_*`,
2550
+ * `size_outcome_unavailable`), missing changed-file evidence
2551
+ * (`changed_files_unavailable`), or an unclassifiable diff
2552
+ * (`resolveGateTier`'s `unclassifiable_file`) — ALWAYS forces `full_fanout`,
2553
+ * as does the hard size cap (`over_threshold`). The angle SET remains the
2554
+ * matched tier or, when no tier matched, the mandatory floor plus the lenses
2555
+ * justified by the changed-file kinds. It never widens to the full untriered
2556
+ * pool merely because a floor fired. A `gate:full` label (which self-bypasses
2557
+ * tier matching) and disabled light mode continue to govern dispatch mode
2558
+ * independently; `light_mode_disabled` is not a floor.
2467
2559
  *
2468
2560
  * @param {DevLoopConfig} config
2469
2561
  * @param {"draft"|"preApproval"} gate
@@ -2500,23 +2592,24 @@ export function resolveReviewProportionality(config, gate, {
2500
2592
  // dispatch-mode facts alone looked trivial.
2501
2593
  unclassifiable: tier.reason === "unclassifiable_file",
2502
2594
  });
2503
- // sizeCap (over_threshold) is deliberately EXCLUDED from the forced-full-
2504
- // pool set: it predates this change's risk/ambiguity floors and pre-existing
2505
- // behavior (the diff-class-tier mechanism) keeps a merely-over-the-tiny-
2506
- // inline-cap-but-still-tier-classifiable diff on its reduced tier set — see
2507
- // resolveGateTier's "small non-risky diff outside the inline cap but
2508
- // matching a tier" contract. Only a genuine RISK signal (a risk-path touch,
2509
- // a non-clean/ambiguous size-budget outcome, or an unclassifiable diff)
2510
- // forces the full untriered pool.
2595
+ // sizeCap (over_threshold) is deliberately EXCLUDED from the risk-signal
2596
+ // floor set: it predates the risk/ambiguity floors. Every floor affects the
2597
+ // dispatch mode only; angle selection remains tiered or best-effort.
2511
2598
  const dispatchFloorFired = floors.riskPath || floors.sizeOutcome || floors.ambiguity;
2512
2599
  const floored = dispatchFloorFired || floors.unclassifiable;
2513
2600
  const mode = floored ? "full_fanout" : dispatch.mode;
2514
2601
  const reason = floored && !dispatchFloorFired ? "unclassifiable_diff" : dispatch.reason;
2515
2602
  // The mandatory-angle floor is present either way: a tier match already
2516
- // unions mandatoryAngles in (resolveGateTier), and the no-tier fallback
2517
- // (resolveGateAngles) does the same union — see AC-4 "mandatory angles
2518
- // combined, never dropped".
2519
- const angles = floored ? resolveGateAngles(config, gate) : (tier.angles ?? resolveGateAngles(config, gate));
2603
+ // unions mandatoryAngles in (resolveGateTier), and the no-tier best-effort
2604
+ // selection does the same union — see AC-4 "mandatory angles combined,
2605
+ // never dropped". A `gate:full` label is the explicit "run everything"
2606
+ // escape hatch (ADR 0048): it keeps forcing the full configured pool, which
2607
+ // is also what resolveGateTier's gate_full_label bypass left in place before
2608
+ // best-effort selection existed.
2609
+ const staticAngles = resolveGateAngles(config, gate);
2610
+ const angles = hasFullLabel
2611
+ ? staticAngles
2612
+ : (tier.angles ?? (staticAngles === null ? null : selectFloorPlusJustifiedAngles(config, gate, changedFiles)));
2520
2613
  const groups = resolveFanoutGroups(config, gate, angles ?? [], { fullLabel: hasFullLabel });
2521
2614
  return Object.freeze({
2522
2615
  mode,
@@ -2531,9 +2624,10 @@ export function resolveReviewProportionality(config, gate, {
2531
2624
  * Resolve gate angles dynamically when `dynamicAngles` is enabled.
2532
2625
  *
2533
2626
  * Diff analysis (../analysis/*) filters the configured angle list to angles
2534
- * relevant to the change set. When `dynamic.subtractive: false` or no
2535
- * diff is given, returns the full configured list. When `additiveAngles` is on,
2536
- * catalog angles from resolveAnglePool may also be added, with
2627
+ * relevant to the change set. When `dynamic.subtractive: false`, returns the
2628
+ * full configured list. Without a diff, ordinary callers keep that static pool
2629
+ * while floor-aware callers use mandatory-floor best-effort selection. When
2630
+ * `additiveAngles` is on, catalog angles from resolveAnglePool may also be added, with
2537
2631
  * `excludeAngles` a hard ceiling.
2538
2632
  *
2539
2633
  * Diff-class tiers (resolveGateTier) are consulted FIRST: a tier match returns
@@ -2542,15 +2636,14 @@ export function resolveReviewProportionality(config, gate, {
2542
2636
  *
2543
2637
  * GATE-EXEC-PROPORTIONALITY floor-awareness (opt-in via `checkFloors`): when
2544
2638
  * the caller supplies `checkFloors: true` (and, when available, `sizeOutcome`
2545
- * from check-size-budget.mjs), this delegates to {@link
2639
+ * from check-size-budget.mjs), this delegates floor determination to {@link
2546
2640
  * resolveReviewProportionality} — the SAME composer resolve-gate-dispatch.mjs
2547
- * uses — over the SAME diff-derived changed-file/scope facts, so a diff whose
2548
- * dispatch decision is floored (risk-path touch, a non-clean/ambiguous
2549
- * size-budget outcome, or an unclassifiable diff) NEVER keeps a tier's
2550
- * reduced (or dynamically-pruned) angle set here: it gets the full untriered
2551
- * pool, exactly like the primer's own dispatch-decision step. Omitted
2552
- * (default), this resolves exactly as before — a caller that does not have
2553
- * size-budget evidence to hand is unaffected.
2641
+ * uses — over the SAME diff-derived changed-file/scope facts. A fired floor
2642
+ * forces `full_fanout` DISPATCH and refuses an explicit override, but the angle
2643
+ * SET is always the tier-or-dynamic best-effort selection below; uncertainty
2644
+ * never widens it to the full untriered pool. Omitted (default), this resolves
2645
+ * exactly as before — a caller that does not have size-budget evidence to hand
2646
+ * is unaffected.
2554
2647
  *
2555
2648
  * @param {import("./types.js").DevLoopConfig} config
2556
2649
  * @param {"draft"|"preApproval"} gate
@@ -2559,7 +2652,7 @@ export function resolveReviewProportionality(config, gate, {
2559
2652
  * @param {boolean} [options.hasFullLabel] — `gate:full` label present on the PR (bypasses tier resolution)
2560
2653
  * @param {boolean} [options.checkFloors] — opt into the GATE-EXEC-PROPORTIONALITY floor check above
2561
2654
  * @param {{ outcome?: "pass"|"escalate"|"block", tierLogicLoc?: { t1?: number } }|null} [options.sizeOutcome] — only consulted when `checkFloors` is true
2562
- * @param {string[]} [options.explicitAngles] — caller-supplied verbatim override (e.g. CLI `--angles`); wins over tier/dynamic resolution but NEVER over a fired floor above (a fired floor's full pool, mandatory angles included via resolveGateAngles, is returned instead)
2655
+ * @param {string[]} [options.explicitAngles] — caller-supplied verbatim override (e.g. CLI `--angles`); wins over tier/dynamic resolution but NEVER over a fired floor above (a fired floor refuses the override and continues to tier/dynamic best-effort selection)
2563
2656
  * @returns {{ recommendedAngles: string[] | null, skippedAngles: string[], reasons: Record<string,string>, fallbackToAll: boolean, dynamicAnglesActive: boolean, addedAngles: string[], addedReasons: Record<string,string> }}
2564
2657
  */
2565
2658
  export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabel = false, checkFloors = false, sizeOutcome, explicitAngles } = {}) {
@@ -2581,30 +2674,20 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
2581
2674
  linesChanged = lineStats.added + lineStats.deleted;
2582
2675
  }
2583
2676
  }
2584
- if (checkFloors) {
2585
- const plan = resolveReviewProportionality(config, gate, {
2586
- scope: { filesChanged, linesChanged },
2587
- changedFiles,
2588
- sizeOutcome,
2589
- hasFullLabel,
2590
- });
2591
- if (plan.floors.riskPath || plan.floors.sizeOutcome || plan.floors.ambiguity || plan.floors.unclassifiable) {
2592
- return {
2593
- recommendedAngles: plan.angles ?? [],
2594
- skippedAngles: [],
2595
- reasons: {},
2596
- fallbackToAll: false,
2597
- dynamicAnglesActive: false,
2598
- addedAngles: [],
2599
- addedReasons: {},
2600
- };
2601
- }
2602
- }
2603
- // A fired floor above always wins (its full pool already includes the
2604
- // mandatory floor via resolveGateAngles) — an explicit --angles override is
2605
- // only honored once no floor fired, matching its documented "verbatim,
2606
- // dynamic resolution bypassed" contract.
2607
- if (Array.isArray(explicitAngles)) {
2677
+ const plan = checkFloors
2678
+ ? resolveReviewProportionality(config, gate, {
2679
+ scope: { filesChanged, linesChanged },
2680
+ changedFiles,
2681
+ sizeOutcome,
2682
+ hasFullLabel,
2683
+ })
2684
+ : null;
2685
+ const floorFired = plan !== null && (
2686
+ plan.floors.riskPath || plan.floors.sizeOutcome || plan.floors.ambiguity || plan.floors.unclassifiable
2687
+ );
2688
+ // A fired floor refuses an explicit --angles override. Selection falls
2689
+ // through to the same tier/dynamic best-effort path as every other diff.
2690
+ if (Array.isArray(explicitAngles) && !floorFired) {
2608
2691
  return {
2609
2692
  recommendedAngles: explicitAngles,
2610
2693
  skippedAngles: [],
@@ -2644,12 +2727,24 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
2644
2727
  }
2645
2728
 
2646
2729
  if (!gateConfig.dynamicAngles || !diff) {
2730
+ // No diff to select on (a thin briefing) or dynamic resolution explicitly
2731
+ // disabled by config. An explicitly disabled resolver keeps its static pool
2732
+ // — that is the config asking for every angle. When dynamic resolution is
2733
+ // enabled, a floor-aware caller that simply has no diff still gets the
2734
+ // composer's mandatory-floor-plus-justified set rather than the whole pool,
2735
+ // so absence of evidence never widens the angle set either.
2736
+ const bestEffortWithoutDiff = Boolean(!diff && plan && gateConfig.dynamicAngles);
2737
+ const recommendedAngles = bestEffortWithoutDiff ? (plan.angles ?? staticAngles) : staticAngles;
2738
+ const recommended = new Set(recommendedAngles);
2739
+ const skippedAngles = staticAngles.filter((a) => !recommended.has(a));
2647
2740
  return {
2648
- recommendedAngles: staticAngles,
2649
- skippedAngles: [],
2650
- reasons: {},
2741
+ recommendedAngles,
2742
+ skippedAngles,
2743
+ reasons: Object.fromEntries(
2744
+ skippedAngles.map((a) => [a, "Skipped: no diff is available to select on; the mandatory floor plus justified lenses were selected instead"]),
2745
+ ),
2651
2746
  fallbackToAll: false,
2652
- dynamicAnglesActive: false,
2747
+ dynamicAnglesActive: bestEffortWithoutDiff,
2653
2748
  addedAngles: [],
2654
2749
  addedReasons: {},
2655
2750
  };
@@ -2668,6 +2763,10 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
2668
2763
  });
2669
2764
 
2670
2765
  const categories = [...new Set(analysis.t1?.changeCategories ?? [])];
2766
+ // File kinds present in the diff, to honor a consumer angle's `kinds`
2767
+ // binding. classifyFile is the same classifier the categories above derive
2768
+ // from, so this adds no new classification surface.
2769
+ const fileKinds = [...new Set((analysis.t0?.files ?? []).map(classifyFile))];
2671
2770
 
2672
2771
  // excludeAngles is a hard ceiling: computed once and reused both to cap the
2673
2772
  // additive anglePool and to filter mandatoryAngles below.
@@ -2676,12 +2775,13 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
2676
2775
  ? resolveAnglePool(config).filter(a => !excluded.has(a))
2677
2776
  : undefined;
2678
2777
 
2679
- const { resolveDynamicAngles: resolve } = await import("../analysis/change-classifier.mjs");
2680
- const dynamicResult = resolve({
2778
+ const dynamicResult = resolveDynamicAngles({
2681
2779
  configuredAngles: candidatePool,
2682
2780
  changeCategories: categories,
2683
2781
  ambiguous: analysis.ambiguous,
2684
2782
  anglePool,
2783
+ angleDeclarations: gateConfig.angleCategoryBindings,
2784
+ fileKinds,
2685
2785
  });
2686
2786
 
2687
2787
  // Merge: mandatory always included (filtered by excludeAngles) + dynamically-selected
@@ -2696,12 +2796,24 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
2696
2796
  Object.entries(dynamicResult.addedReasons ?? {}).filter(([a]) => !mandatory.has(a))
2697
2797
  );
2698
2798
 
2699
- const recommendedAngles = [...new Set([...filteredMandatory, ...dynamicResult.recommendedAngles, ...addedAngles])];
2799
+ const mergedAngles = [...new Set([...filteredMandatory, ...dynamicResult.recommendedAngles, ...addedAngles])];
2800
+
2801
+ // Degenerate-pool fail-closed: a gate whose configured pool holds neither a
2802
+ // mandatory nor an always-include angle (e.g. `angles: ["kiss"]`) can legally
2803
+ // resolve to zero angles for an unclassifiable diff. Never resolve zero
2804
+ // angles — fall back to the static pool (already excludeAngles-filtered),
2805
+ // mirroring selectFloorPlusJustifiedAngles and the composer, so the resolver
2806
+ // and the composer agree for the identical input. The whole pool is selected,
2807
+ // so nothing is skipped and no angle carries a skip reason.
2808
+ const degenerateFallback = mergedAngles.length === 0;
2809
+ const recommendedAngles = degenerateFallback ? [...staticAngles] : mergedAngles;
2810
+ const skippedAngles = degenerateFallback ? [] : dynamicResult.skippedAngles;
2811
+ const reasons = degenerateFallback ? {} : dynamicResult.reasons;
2700
2812
 
2701
2813
  return {
2702
2814
  recommendedAngles,
2703
- skippedAngles: dynamicResult.skippedAngles,
2704
- reasons: dynamicResult.reasons,
2815
+ skippedAngles,
2816
+ reasons,
2705
2817
  fallbackToAll: dynamicResult.fallbackToAll,
2706
2818
  dynamicAnglesActive: true,
2707
2819
  addedAngles,
@@ -126,13 +126,39 @@ gates:
126
126
  implementation bugs, logic errors, contract violations, or security
127
127
  issues
128
128
  - Unresolved review threads that raise implementation concerns Flag any unresolved comment that identifies a concrete implementation problem as a blocking finding (severity: high or medium). Do not flag: - Resolved threads - Style nits, formatting suggestions, or cosmetic feedback - Comments from non-collaborators or bots - Outdated comments that were already addressed in a later commit If no unresolved implementation concerns exist, return clean.
129
- - contradiction-lens
130
- - code-conformance
131
- - semantic-drift
129
+ - name: contradiction-lens
130
+ persona: review
131
+ prompt: "Apply STYLE-CONTRADICTION-LENS when the repo defines it (dev-loops: skills/docs/contract-style-guide.md): check every added or changed normative rule (MUST/SHOULD/MAY) against the repo's rule registry for opposing modality or a weaker restatement. Also flag statements in the diff, the PR body, touched contracts, or accepted decision records (for example docs/decisions/) that contradict each other. Cite both sides of each contradiction with file:line."
132
+ - name: code-conformance
133
+ persona: review
134
+ prompt: Review this change for conformance to existing repo code conventions and patterns. Compare naming, module layout, and helper reuse against neighboring code in the same package. Flag ad hoc patterns that diverge from an established convention without a stated reason.
135
+ - name: semantic-drift
136
+ persona: review
137
+ prompt: Review this change for semantic drift between code and its documented behavior. Compare the diff's actual logic against the intent stated in touched docs, contracts, and comments. Flag a documented behavior that no longer matches what the code does, or code whose behavior no longer matches what a touched doc or contract claims.
132
138
  - name: pr-description
133
139
  mandatory: true
134
140
  persona: review
135
141
  prompt: 'Review the PR description for completeness, contract fitness, and checkbox formatting before this PR is marked ready for review. The PR body is the implementation contract — it must have: - A Summary section explaining what changed and why - A Scope and context section defining the boundary of the change - An Acceptance criteria section with the linked issue acceptance criteria - A Definition of done section - A Non-goals section - A Validation command section describing exactly how to verify the change - The "Closes #N" line must match the linked issue; flag changes that alter or remove the operator-intended close target Checkboxes (`- [ ]` / `- [x]`, or `* [ ]` / `* [x]`) must appear inside genuine Markdown list items. Flag any checkbox marker used outside a list item (including table cells) as a medium finding. Flag any checkbox marker wrapped in backticks (e.g. `` `[x]` ``) as a medium finding. Flag PRs where the body is a single sentence or lacks any of these sections. Do not block on formatting preferences other than checkbox correctness.'
142
+ - name: holistic
143
+ mandatory: true
144
+ persona: review
145
+ prompt: >-
146
+ Review the WHOLE change holistically, on its merits, as a senior
147
+ engineer doing a final read of the entire diff. You are independent and
148
+ un-briefed: you receive only the spec (acceptance criteria, definition
149
+ of done, non-goals) and the diff — no author or developer brief and no
150
+ steering from the reviewed party. Your mandate is broad, not a single
151
+ named lens. Read the whole change end to end and judge whether, taken
152
+ together, it correctly and completely does what the spec asks, is
153
+ internally coherent, and is safe to ship. Concentrate on CROSS-CUTTING
154
+ problems that no narrow angle owns: mismatches between parts of the
155
+ change, gaps between the diff and the acceptance criteria, unintended
156
+ interactions across modules, missing pieces the spec implies, and
157
+ defects that would otherwise surface later in Copilot review or a
158
+ consumer repo. Cite concrete file:line evidence for each finding and
159
+ give a minimal fix. Respect declared non-goals — do not manufacture
160
+ scope. If the change is coherent and complete against the spec, return
161
+ clean.
136
162
  # #1442 (ADR 0041 prose half): required fail-closed deslop angle for prose
137
163
  # deliverables. Runs the A/B-contrast-removal deslop step (ab-contrast-
138
164
  # deslop-step.md) — flag surviving binary-contrast constructions so the gate
@@ -221,6 +247,14 @@ gates:
221
247
  angles: [srp, soc, ocp, lsp, isp, dip]
222
248
  - name: finalization
223
249
  angles: [correctness-final, ui-validation]
250
+ # The holistic reviewer reads the whole diff, so it gets its own
251
+ # reviewer rather than being auto-chunked with unrelated leftover
252
+ # angles — one reviewer reviews the whole change holistically.
253
+ # A consumer repo that overrides gates.fanout.groups replaces this
254
+ # table wholesale (shallow merge) and must restate this singleton to
255
+ # keep holistic un-batched.
256
+ - name: holistic
257
+ angles: [holistic]
224
258
  preApproval:
225
259
  angles:
226
260
  - name: dry
@@ -294,9 +328,35 @@ gates:
294
328
  require a matrix on the PR — the matrix lives on the issue; the PR carries the derived
295
329
  checklists. The boundary is explicit: the deterministic block enforces completeness (nothing
296
330
  left unchecked/forgotten); you verify each [x] is real and faithfully derived.
297
- - contradiction-lens
298
- - correctness-final
299
- - ui-validation
331
+ - name: holistic
332
+ mandatory: true
333
+ persona: review
334
+ prompt: >-
335
+ Review the WHOLE change holistically, on its merits, as a senior
336
+ engineer doing a final read of the entire diff. You are independent and
337
+ un-briefed: you receive only the spec (acceptance criteria, definition
338
+ of done, non-goals) and the diff — no author or developer brief and no
339
+ steering from the reviewed party. Your mandate is broad, not a single
340
+ named lens. Read the whole change end to end and judge whether, taken
341
+ together, it correctly and completely does what the spec asks, is
342
+ internally coherent, and is safe to ship. Concentrate on CROSS-CUTTING
343
+ problems that no narrow angle owns: mismatches between parts of the
344
+ change, gaps between the diff and the acceptance criteria, unintended
345
+ interactions across modules, missing pieces the spec implies, and
346
+ defects that would otherwise surface later in Copilot review or a
347
+ consumer repo. Cite concrete file:line evidence for each finding and
348
+ give a minimal fix. Respect declared non-goals — do not manufacture
349
+ scope. If the change is coherent and complete against the spec, return
350
+ clean.
351
+ - name: contradiction-lens
352
+ persona: review
353
+ prompt: "Apply STYLE-CONTRADICTION-LENS when the repo defines it (dev-loops: skills/docs/contract-style-guide.md): check every added or changed normative rule (MUST/SHOULD/MAY) against the repo's rule registry for opposing modality or a weaker restatement. Also flag statements in the diff, the PR body, touched contracts, or accepted decision records (for example docs/decisions/) that contradict each other. Cite both sides of each contradiction with file:line."
354
+ - name: correctness-final
355
+ persona: review
356
+ prompt: Perform a final correctness pass on the current head. Flag residual logic errors, contract violations, and behavior mismatches introduced or left unfixed since the draft gate.
357
+ - name: ui-validation
358
+ persona: review
359
+ prompt: "Review this change against the repo's UI validation contract when it defines one (dev-loops: skills/docs/ui-validation-contract.md and skills/docs/ui-e2e-scoping-step.md). When the changed-file set matches the UI e2e scoping globs, verify passing UI e2e coverage exists and that each touched rendered artifact is registered; an unregistered new artifact is a blocking finding. Return clean when no changed path matches or the repo defines no UI validation contract."
300
360
  required: true
301
361
  # Relaxed spike gate profile (#965). A spike's deliverable is a findings doc,
302
362
  # not production code, so it is intentionally lighter than the production