@dev-loops/core 1.0.4-pre.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev-loops/core",
3
- "version": "1.0.4-pre.0",
3
+ "version": "1.0.4-pre.1",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -99,7 +99,7 @@ export const ALWAYS_INCLUDE = new Set(["gate-evidence", "renderer-security", "pr
99
99
  * merges addedAngles on top to form the full effective run set
100
100
  * @property {string[]} skippedAngles — angles skipped with reasons
101
101
  * @property {Record<string, string>} reasons — why each angle was skipped
102
- * @property {boolean} fallbackToAll — true when ambiguous → all angles recommended
102
+ * @property {boolean} fallbackToAll — retained for compatibility; always false
103
103
  * @property {string[]} addedAngles — catalog angles added (additive mode only, see #1048)
104
104
  * @property {Record<string, string>} addedReasons — why each added angle was added
105
105
  */
@@ -108,8 +108,14 @@ export const ALWAYS_INCLUDE = new Set(["gate-evidence", "renderer-security", "pr
108
108
  * Resolve which gate angles to run based on detected change categories.
109
109
  *
110
110
  * When the diff is ambiguous (no detected categories / analysis failure),
111
- * all configured angles are recommended (fallback-to-all). A LOGIC_CHANGE
112
- * diff resolves to its core review subset, not fallback-to-all.
111
+ * uncertainty still resolves through the same best-effort selection as a
112
+ * classified diff; it never expands to every configured angle. A LOGIC_CHANGE
113
+ * diff likewise resolves to its core review subset.
114
+ *
115
+ * `configuredAngles` is the caller's candidate pool (mandatory angles have
116
+ * already been removed), so an uncertain diff may legitimately resolve to an
117
+ * empty candidate set. The caller combines this with its mandatory floor and
118
+ * falls back to the static pool if that combined selection would be empty.
113
119
  *
114
120
  * When `anglePool` is provided (additive mode, see #1048), catalog angles in
115
121
  * the pool that the change categories recommend but that are not already in
@@ -121,8 +127,8 @@ export const ALWAYS_INCLUDE = new Set(["gate-evidence", "renderer-security", "pr
121
127
  * angle) can still be recommended BY change category or file kind when it
122
128
  * declares `categories`/`kinds` via `angleDeclarations`. This is purely
123
129
  * additive: it can only SELECT such an angle when the diff intersects its
124
- * declaration; it never drops any angle the catalog map, ALWAYS_INCLUDE, or the
125
- * fallback-to-all path would otherwise recommend.
130
+ * declaration; it never drops any angle the catalog map or ALWAYS_INCLUDE
131
+ * would otherwise recommend.
126
132
  *
127
133
  * @param {object} options
128
134
  * @param {string[]} options.configuredAngles — all angles configured for this gate
@@ -146,30 +152,6 @@ export function resolveDynamicAngles({
146
152
  angleDeclarations = {},
147
153
  fileKinds = [],
148
154
  }) {
149
- // Fallback: ambiguous diff → all angles
150
- if (ambiguous) {
151
- return {
152
- recommendedAngles: [...configuredAngles],
153
- skippedAngles: [],
154
- reasons: {},
155
- fallbackToAll: true,
156
- addedAngles: [],
157
- addedReasons: {},
158
- };
159
- }
160
-
161
- // No change categories → all angles (defensive)
162
- if (changeCategories.length === 0) {
163
- return {
164
- recommendedAngles: [...configuredAngles],
165
- skippedAngles: [],
166
- reasons: {},
167
- fallbackToAll: true,
168
- addedAngles: [],
169
- addedReasons: {},
170
- };
171
- }
172
-
173
155
  // Build recommended set from category union, tracking the first trigger per angle
174
156
  const recommended = new Set();
175
157
  const triggers = new Map();
@@ -219,7 +201,11 @@ export function resolveDynamicAngles({
219
201
  // Build reasons
220
202
  const reasons = {};
221
203
  for (const angle of skippedAngles) {
222
- reasons[angle] = `Skipped: detected categories (${changeCategories.join(", ") || "none"}) do not trigger this angle`;
204
+ reasons[angle] = changeCategories.length === 0
205
+ ? "Skipped: no change category could be established (uncertain classification)"
206
+ : ambiguous
207
+ ? `Skipped: analysis remained ambiguous despite detected categories (${changeCategories.join(", ")})`
208
+ : `Skipped: detected categories (${changeCategories.join(", ")}) do not trigger this angle`;
223
209
  }
224
210
 
225
211
  // Additive: pull in recommended catalog angles not already configured (#1048)
@@ -443,6 +443,8 @@ export function analyzeT1(diffOutput, t0) {
443
443
  * @property {T0Result} t0
444
444
  * @property {T1Result | null} t1
445
445
  * @property {boolean} ambiguous — true when heuristics cannot confidently classify
446
+ * @property {boolean} fullDiffMissing — true when a mixed diff needed hunk-level
447
+ * analysis but the full-diff capture was absent/empty (see analyzeDiff)
446
448
  */
447
449
 
448
450
  /**
@@ -496,9 +498,8 @@ function t0PresentSurfaceCategories(t0) {
496
498
  function inferCategoriesFromT0(t0) {
497
499
  const categories = t0FileCategories(t0);
498
500
  // Pure code-only change (all files classify as code, not a rename) is a
499
- // LOGIC_CHANGE. Without this an all-code diff yields no category, which
500
- // resolveDynamicAngles treats as unclassifiable → fallback-to-all, regressing
501
- // the primary case: a code-only PR must resolve to the LOGIC_CHANGE subset.
501
+ // LOGIC_CHANGE. Without this an all-code diff yields no category and loses
502
+ // the justified code-review core from best-effort selection.
502
503
  if (!t0.renameOnly && t0.files.length > 0 && t0.files.every((f) => classifyFile(f) === "code")) {
503
504
  categories.push("LOGIC_CHANGE");
504
505
  }
@@ -523,19 +524,31 @@ export function analyzeDiff({ nameStatusOutput, diffOutput }) {
523
524
  const t0Ambiguous = !t0.renameOnly && !t0.allDocs && t0.files.length > 1 &&
524
525
  new Set(t0.files.map(classifyFile)).size > 1;
525
526
 
526
- if (t0Ambiguous && diffOutput) {
527
+ // A full-diff capture is usable evidence only when it carries non-whitespace
528
+ // content. A whitespace-only capture (e.g. " \n") must take the SAME
529
+ // fail-closed path as an absent/empty one: T1 must not run and
530
+ // `fullDiffMissing` must fire, so a gate needing the full diff cannot read a
531
+ // hunk-less whitespace capture as complete evidence.
532
+ const hasDiffText = typeof diffOutput === "string" && diffOutput.trim().length > 0;
533
+
534
+ if (t0Ambiguous && hasDiffText) {
527
535
  t1 = analyzeT1(diffOutput, t0);
528
536
  }
529
537
 
530
538
  // When t1 is null (unambiguous diff), infer categories from t0
531
539
  // so dynamic angle resolution can narrow for config-only / test-only etc.
532
540
  if (!t1) {
533
- // A genuinely MIXED diff whose T1 never ran (no diffOutput) must NOT get a
534
- // T0-only category: non-empty categories set ambiguous=false, so it would
535
- // under-select and drop the code-review core. T0-only inference is safe only
536
- // for unambiguous diffs; a mixed diff without hunk content is unclassifiable,
537
- // so return empty categories and fall back to the full angle set (fail closed).
538
- const changeCategories = t0Ambiguous ? [] : inferCategoriesFromT0(t0);
541
+ // A genuinely MIXED diff whose T1 never ran (no diffOutput) still has
542
+ // honest T0 surface evidence. Reuse the same surface-presence categories
543
+ // as the hunk path, and add LOGIC_CHANGE when code is present, so
544
+ // best-effort selection retains the code-review core without widening to
545
+ // the full pool.
546
+ const changeCategories = t0Ambiguous
547
+ ? [
548
+ ...t0PresentSurfaceCategories(t0),
549
+ ...(t0.files.some((f) => classifyFile(f) === "code") ? ["LOGIC_CHANGE"] : []),
550
+ ]
551
+ : inferCategoriesFromT0(t0);
539
552
  t1 = {
540
553
  changeCategories,
541
554
  hunkCount: 0,
@@ -551,11 +564,21 @@ export function analyzeDiff({ nameStatusOutput, diffOutput }) {
551
564
  }
552
565
 
553
566
  // `ambiguous` flags one case: a diff T0 could not classify (mixed categories)
554
- // AND whose hunk analysis produced no category. It is NOT the only fallback
555
- // trigger — resolveDynamicAngles also falls back whenever changeCategories is
556
- // empty. A mixed diff that yields a category (e.g. LOGIC_CHANGE) is classified
557
- // and not ambiguous, so LOGIC_CHANGE never forces fallback-to-all via this flag.
567
+ // AND its available analysis produced no category. Empty categories resolve
568
+ // through mandatory-floor best-effort selection, while a hunk-less mixed diff
569
+ // with T0 surface evidence stays classified and keeps its justified core.
558
570
  const ambiguous = t0Ambiguous && t1.changeCategories.length === 0;
559
571
 
560
- return { t0, t1, ambiguous };
572
+ // Evidence-availability signal, SEPARATE from `ambiguous` on purpose. A mixed
573
+ // diff whose hunk-level analysis never ran (no full-diff capture) legitimately
574
+ // classifies through its honest T0 surfaces for ANGLE SELECTION — that is what
575
+ // keeps the code-review core in the best-effort subset without widening to the
576
+ // whole pool. But a fail-closed gate that needs the FULL diff (the size
577
+ // budget's unwaivable block) must not read that angle-selection fallback as
578
+ // complete evidence: `ambiguous` is now false for this case, so such a gate
579
+ // would silently downgrade. Consumers that need the diff itself key off THIS
580
+ // flag instead of piggybacking on the angle classifier's ambiguity flag.
581
+ const fullDiffMissing = t0Ambiguous && !hasDiffText;
582
+
583
+ return { t0, t1, ambiguous, fullDiffMissing };
561
584
  }
@@ -21,7 +21,10 @@
21
21
  * async-dispatch concerns. The user entrypoint under Claude is the dev-loop *skill*.)
22
22
  * - Skills keep name/description/allowed-tools (space-separated) and preserve `user-invocable`
23
23
  * (Claude honors it 1:1 — `user-invocable: false` hides the skill from the `/` menu). The
24
- * Pi-specific `compatibility` text is dropped (no Claude field).
24
+ * Pi-specific `compatibility` text is dropped (no Claude field). Whole-file skill exclusion is
25
+ * applied by `collectGeneratedAssets` in scripts/claude/generate-claude-assets.mjs before these
26
+ * transforms run; this is distinct from `<!-- pi-only -->` blocks, which remove only marked body
27
+ * sections.
25
28
  */
26
29
 
27
30
  import { parse as parseYaml } from "yaml";
@@ -295,6 +298,38 @@ export function transformCommand({ source, raw, version = "latest" }) {
295
298
  return `${lines.join("\n")}\n${body}`;
296
299
  }
297
300
 
301
+ /**
302
+ * Check whether a skill should be excluded from Claude asset generation.
303
+ * Supports:
304
+ * - `claude-sync: false`
305
+ * - `harness: pi` (or `harness: ["pi"]`)
306
+ * - `pi-only: true`
307
+ *
308
+ * @param {Record<string, unknown> | undefined} frontmatter
309
+ * @returns {boolean}
310
+ */
311
+ export function isSkillExcludedFromClaude(frontmatter) {
312
+ if (!frontmatter || typeof frontmatter !== "object") {
313
+ return false;
314
+ }
315
+ const claudeSync = frontmatter["claude-sync"];
316
+ if (claudeSync === false || (typeof claudeSync === "string" && claudeSync.trim().toLowerCase() === "false")) {
317
+ return true;
318
+ }
319
+ const piOnly = frontmatter["pi-only"];
320
+ if (piOnly === true || (typeof piOnly === "string" && piOnly.trim().toLowerCase() === "true")) {
321
+ return true;
322
+ }
323
+ const harness = frontmatter.harness;
324
+ if (typeof harness === "string" && harness.trim().toLowerCase() === "pi") {
325
+ return true;
326
+ }
327
+ if (Array.isArray(harness) && harness.length === 1 && String(harness[0]).trim().toLowerCase() === "pi") {
328
+ return true;
329
+ }
330
+ return false;
331
+ }
332
+
298
333
  /**
299
334
  * Transform a canonical `skills/<name>/SKILL.md` into a Claude `.claude/skills/<name>/SKILL.md`.
300
335
  * @param {{ source: string, raw: string, version?: string }} input
@@ -6,7 +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 } from "../analysis/change-classifier.mjs";
9
+ import { ChangeCategory, resolveDynamicAngles } from "../analysis/change-classifier.mjs";
10
10
  import { isDevLoopConfigSourcePath } from "../loop/gate-carry-forward.mjs";
11
11
  import { isClaudeHarness } from "../loop/run-context.mjs";
12
12
  import { trimmedOrNull } from "../loop/normalize.mjs";
@@ -184,9 +184,9 @@ const GateTier = z.strictObject({
184
184
  });
185
185
 
186
186
  const GateDynamicConfig = z.strictObject({
187
- // Diff-driven dynamic angle PRUNING, ON by default. mandatory:true
188
- // angles stay a hard always-run floor; fallbackToAll degrades to the full
189
- // 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.
190
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."),
191
191
  // Additive counterpart to the subtractive path: when true, the
192
192
  // context-builder may also ADD catalog angles (from resolveAnglePool) that
@@ -2205,12 +2205,15 @@ export function resolveFanoutSequential(config) {
2205
2205
 
2206
2206
  /**
2207
2207
  * Claude-harness-scoped cap on effective fan-out concurrency (per ADR
2208
- * docs/decisions/0069-claude-harness-fanout-concurrency-clamp.md). The
2209
- * shipped cross-harness `gates.fanout.maxConcurrent` default (4) plus the
2210
- * driver's own call still 429s a single-driver Claude session; other
2211
- * 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`.
2212
2215
  */
2213
- export const CLAUDE_MAX_EFFECTIVE_CONCURRENT = 2;
2216
+ export const CLAUDE_MAX_EFFECTIVE_CONCURRENT = 4;
2214
2217
 
2215
2218
  /**
2216
2219
  * Resolve the effective fan-out concurrency (dispatch units per wave): 1 when
@@ -2482,6 +2485,51 @@ export function resolveGateTier(config, gate, { changedFiles, filesChanged, line
2482
2485
  return { tier: matched.name, angles: [...new Set([...mandatoryAngles, ...matched.angles])], reason: "tier_match" };
2483
2486
  }
2484
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
+
2485
2533
  /**
2486
2534
  * The primer-owned deterministic review-proportionality plan
2487
2535
  * (GATE-EXEC-PROPORTIONALITY, gate-review-sub-loop-contract.md): a single,
@@ -2489,25 +2537,25 @@ export function resolveGateTier(config, gate, { changedFiles, filesChanged, line
2489
2537
  * set + execution mode + grouping) is one testable, persistable object.
2490
2538
  * Delegates entirely to {@link resolveGateDispatchMode} (mode, including the
2491
2539
  * non-overridable size-cap/risk-path/size-outcome/ambiguity floors),
2492
- * {@link resolveGateTier} (angle set AND diff-classification), and
2493
- * {@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
2494
2543
  * of its own beyond the floor-vs-tier precedence below: this is the ONE place
2495
2544
  * the primer (emit) and the merge gate (re-verify) compose mode + angles +
2496
2545
  * grouping, so they can never drift onto two different floor implementations.
2497
2546
  *
2498
- * Floor-vs-tier precedence: a fired RISK-signal floor — the risk-path
2499
- * denylist (`risk_path_touch`), a non-clean/ambiguous size-budget outcome
2500
- * (`size_outcome_*`, `size_outcome_unavailable`), missing changed-file
2501
- * evidence (`changed_files_unavailable`), or an unclassifiable diff
2502
- * (`resolveGateTier`'s `unclassifiable_file`) — ALWAYS forces `full_fanout`
2503
- * with the FULL untriered angle pool, never a matched tier's reduced set. The
2504
- * hard size cap (`over_threshold`) differs: it ALWAYS forces `full_fanout`
2505
- * MODE (distinct-reviewer-per-angle, never the light single-combined path)
2506
- * but does NOT force the full untriered pool — a merely-over-cap-but-tier-
2507
- * classifiable diff keeps its diff-class-tier-reduced angle set (the
2508
- * pre-existing, orthogonal mechanism), untouched for a `gate:full`-labelled
2509
- * PR (resolveGateTier self-bypasses) or a repo with light mode disabled
2510
- * (`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.
2511
2559
  *
2512
2560
  * @param {DevLoopConfig} config
2513
2561
  * @param {"draft"|"preApproval"} gate
@@ -2544,23 +2592,24 @@ export function resolveReviewProportionality(config, gate, {
2544
2592
  // dispatch-mode facts alone looked trivial.
2545
2593
  unclassifiable: tier.reason === "unclassifiable_file",
2546
2594
  });
2547
- // sizeCap (over_threshold) is deliberately EXCLUDED from the forced-full-
2548
- // pool set: it predates this change's risk/ambiguity floors and pre-existing
2549
- // behavior (the diff-class-tier mechanism) keeps a merely-over-the-tiny-
2550
- // inline-cap-but-still-tier-classifiable diff on its reduced tier set — see
2551
- // resolveGateTier's "small non-risky diff outside the inline cap but
2552
- // matching a tier" contract. Only a genuine RISK signal (a risk-path touch,
2553
- // a non-clean/ambiguous size-budget outcome, or an unclassifiable diff)
2554
- // 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.
2555
2598
  const dispatchFloorFired = floors.riskPath || floors.sizeOutcome || floors.ambiguity;
2556
2599
  const floored = dispatchFloorFired || floors.unclassifiable;
2557
2600
  const mode = floored ? "full_fanout" : dispatch.mode;
2558
2601
  const reason = floored && !dispatchFloorFired ? "unclassifiable_diff" : dispatch.reason;
2559
2602
  // The mandatory-angle floor is present either way: a tier match already
2560
- // unions mandatoryAngles in (resolveGateTier), and the no-tier fallback
2561
- // (resolveGateAngles) does the same union — see AC-4 "mandatory angles
2562
- // combined, never dropped".
2563
- 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)));
2564
2613
  const groups = resolveFanoutGroups(config, gate, angles ?? [], { fullLabel: hasFullLabel });
2565
2614
  return Object.freeze({
2566
2615
  mode,
@@ -2575,9 +2624,10 @@ export function resolveReviewProportionality(config, gate, {
2575
2624
  * Resolve gate angles dynamically when `dynamicAngles` is enabled.
2576
2625
  *
2577
2626
  * Diff analysis (../analysis/*) filters the configured angle list to angles
2578
- * relevant to the change set. When `dynamic.subtractive: false` or no
2579
- * diff is given, returns the full configured list. When `additiveAngles` is on,
2580
- * 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
2581
2631
  * `excludeAngles` a hard ceiling.
2582
2632
  *
2583
2633
  * Diff-class tiers (resolveGateTier) are consulted FIRST: a tier match returns
@@ -2586,15 +2636,14 @@ export function resolveReviewProportionality(config, gate, {
2586
2636
  *
2587
2637
  * GATE-EXEC-PROPORTIONALITY floor-awareness (opt-in via `checkFloors`): when
2588
2638
  * the caller supplies `checkFloors: true` (and, when available, `sizeOutcome`
2589
- * from check-size-budget.mjs), this delegates to {@link
2639
+ * from check-size-budget.mjs), this delegates floor determination to {@link
2590
2640
  * resolveReviewProportionality} — the SAME composer resolve-gate-dispatch.mjs
2591
- * uses — over the SAME diff-derived changed-file/scope facts, so a diff whose
2592
- * dispatch decision is floored (risk-path touch, a non-clean/ambiguous
2593
- * size-budget outcome, or an unclassifiable diff) NEVER keeps a tier's
2594
- * reduced (or dynamically-pruned) angle set here: it gets the full untriered
2595
- * pool, exactly like the primer's own dispatch-decision step. Omitted
2596
- * (default), this resolves exactly as before — a caller that does not have
2597
- * 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.
2598
2647
  *
2599
2648
  * @param {import("./types.js").DevLoopConfig} config
2600
2649
  * @param {"draft"|"preApproval"} gate
@@ -2603,7 +2652,7 @@ export function resolveReviewProportionality(config, gate, {
2603
2652
  * @param {boolean} [options.hasFullLabel] — `gate:full` label present on the PR (bypasses tier resolution)
2604
2653
  * @param {boolean} [options.checkFloors] — opt into the GATE-EXEC-PROPORTIONALITY floor check above
2605
2654
  * @param {{ outcome?: "pass"|"escalate"|"block", tierLogicLoc?: { t1?: number } }|null} [options.sizeOutcome] — only consulted when `checkFloors` is true
2606
- * @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)
2607
2656
  * @returns {{ recommendedAngles: string[] | null, skippedAngles: string[], reasons: Record<string,string>, fallbackToAll: boolean, dynamicAnglesActive: boolean, addedAngles: string[], addedReasons: Record<string,string> }}
2608
2657
  */
2609
2658
  export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabel = false, checkFloors = false, sizeOutcome, explicitAngles } = {}) {
@@ -2625,30 +2674,20 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
2625
2674
  linesChanged = lineStats.added + lineStats.deleted;
2626
2675
  }
2627
2676
  }
2628
- if (checkFloors) {
2629
- const plan = resolveReviewProportionality(config, gate, {
2630
- scope: { filesChanged, linesChanged },
2631
- changedFiles,
2632
- sizeOutcome,
2633
- hasFullLabel,
2634
- });
2635
- if (plan.floors.riskPath || plan.floors.sizeOutcome || plan.floors.ambiguity || plan.floors.unclassifiable) {
2636
- return {
2637
- recommendedAngles: plan.angles ?? [],
2638
- skippedAngles: [],
2639
- reasons: {},
2640
- fallbackToAll: false,
2641
- dynamicAnglesActive: false,
2642
- addedAngles: [],
2643
- addedReasons: {},
2644
- };
2645
- }
2646
- }
2647
- // A fired floor above always wins (its full pool already includes the
2648
- // mandatory floor via resolveGateAngles) — an explicit --angles override is
2649
- // only honored once no floor fired, matching its documented "verbatim,
2650
- // dynamic resolution bypassed" contract.
2651
- 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) {
2652
2691
  return {
2653
2692
  recommendedAngles: explicitAngles,
2654
2693
  skippedAngles: [],
@@ -2688,12 +2727,24 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
2688
2727
  }
2689
2728
 
2690
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));
2691
2740
  return {
2692
- recommendedAngles: staticAngles,
2693
- skippedAngles: [],
2694
- 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
+ ),
2695
2746
  fallbackToAll: false,
2696
- dynamicAnglesActive: false,
2747
+ dynamicAnglesActive: bestEffortWithoutDiff,
2697
2748
  addedAngles: [],
2698
2749
  addedReasons: {},
2699
2750
  };
@@ -2724,8 +2775,7 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
2724
2775
  ? resolveAnglePool(config).filter(a => !excluded.has(a))
2725
2776
  : undefined;
2726
2777
 
2727
- const { resolveDynamicAngles: resolve } = await import("../analysis/change-classifier.mjs");
2728
- const dynamicResult = resolve({
2778
+ const dynamicResult = resolveDynamicAngles({
2729
2779
  configuredAngles: candidatePool,
2730
2780
  changeCategories: categories,
2731
2781
  ambiguous: analysis.ambiguous,
@@ -2746,12 +2796,24 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
2746
2796
  Object.entries(dynamicResult.addedReasons ?? {}).filter(([a]) => !mandatory.has(a))
2747
2797
  );
2748
2798
 
2749
- 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;
2750
2812
 
2751
2813
  return {
2752
2814
  recommendedAngles,
2753
- skippedAngles: dynamicResult.skippedAngles,
2754
- reasons: dynamicResult.reasons,
2815
+ skippedAngles,
2816
+ reasons,
2755
2817
  fallbackToAll: dynamicResult.fallbackToAll,
2756
2818
  dynamicAnglesActive: true,
2757
2819
  addedAngles,
@@ -126,9 +126,15 @@ 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
@@ -342,9 +348,15 @@ gates:
342
348
  give a minimal fix. Respect declared non-goals — do not manufacture
343
349
  scope. If the change is coherent and complete against the spec, return
344
350
  clean.
345
- - contradiction-lens
346
- - correctness-final
347
- - ui-validation
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."
348
360
  required: true
349
361
  # Relaxed spike gate profile (#965). A spike's deliverable is a findings doc,
350
362
  # not production code, so it is intentionally lighter than the production