@dev-loops/core 1.0.4-pre.0 → 1.0.4-pre.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json 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.2",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -72,6 +72,7 @@
72
72
  "./loop/execution-record": "./src/loop/execution-record.mjs",
73
73
  "./loop/primer-evidence": "./src/loop/primer-evidence.mjs",
74
74
  "./loop/review-dispatch-plan": "./src/loop/review-dispatch-plan.mjs",
75
+ "./loop/review-operation": "./src/loop/review-operation.mjs",
75
76
  "./loop/reviewer-loop-state": "./src/loop/reviewer-loop-state.mjs",
76
77
  "./loop/role-budget-bound": "./src/loop/role-budget-bound.mjs",
77
78
  "./loop/run-context": "./src/loop/run-context.mjs",
@@ -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";
@@ -110,6 +110,7 @@ const RefinementConfig = z.strictObject({
110
110
  fanOut: z.number().int().min(1).max(10).describe("Parallel reviewers per refinement round."),
111
111
  mode: z.enum(["parallel", "sequential"]).describe("Whether refinement reviewers run in parallel or one after another."),
112
112
  maxCopilotRounds: z.number().int().nonnegative().default(5).describe("Automated Copilot review rounds before converging; 0 disables Copilot review."),
113
+ requireCopilotConvergenceAtLatestHead: z.boolean().default(false).describe("Require a converged Copilot review at the latest head. False (default): one converged Copilot review stands for later heads, which pre_approval_gate covers. True: a significant change after convergence opens a new Copilot cycle."),
113
114
  lowSignal: LowSignalConfig.optional().describe("Early-stop policy for low-signal Copilot rounds."),
114
115
  roles: z.array(z.string().trim().min(1)).describe("Review lenses the refinement fan-out dispatches.").optional(),
115
116
  });
@@ -184,9 +185,9 @@ const GateTier = z.strictObject({
184
185
  });
185
186
 
186
187
  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.
188
+ // Diff-driven dynamic angle PRUNING, ON by default. mandatory:true angles
189
+ // stay a hard always-run floor. fallbackToAll is retained in resolver output
190
+ // for compatibility but is always false; uncertainty never widens the set.
190
191
  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
192
  // Additive counterpart to the subtractive path: when true, the
192
193
  // context-builder may also ADD catalog angles (from resolveAnglePool) that
@@ -1971,6 +1972,20 @@ export function resolveEffectiveCopilotRoundCap(config, { lightweight = false }
1971
1972
  return Math.min(effectiveLightCap, maxCopilotRounds);
1972
1973
  }
1973
1974
 
1975
+ /**
1976
+ * Resolve the Copilot convergence mode. False (the default) selects the
1977
+ * converged-once rule: one converged Copilot review stands for later heads.
1978
+ * True restores the strict rule: a significant change after convergence opens
1979
+ * a new Copilot cycle, and only a docs-only or integrate-only delta carries.
1980
+ * Loop and merge both read the mode through this resolver. Light-dispatched
1981
+ * PRs use the same value.
1982
+ * @param {DevLoopConfig} config
1983
+ * @returns {boolean}
1984
+ */
1985
+ export function resolveRequireCopilotConvergenceAtLatestHead(config) {
1986
+ return config?.refinement?.requireCopilotConvergenceAtLatestHead === true;
1987
+ }
1988
+
1974
1989
  /** Label that forces full fan-out regardless of change size. */
1975
1990
  export const GATE_FULL_LABEL = "gate:full";
1976
1991
 
@@ -2205,12 +2220,15 @@ export function resolveFanoutSequential(config) {
2205
2220
 
2206
2221
  /**
2207
2222
  * 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`.
2223
+ * docs/decisions/0069-claude-harness-fanout-concurrency-clamp.md, amended by
2224
+ * docs/decisions/0083-raise-claude-fanout-concurrency-cap-to-4.md). The
2225
+ * `GATE-EXEC-DISPATCH-RETRY-BACKOFF` retry/backoff policy turns a single 429
2226
+ * into latency instead of a failed drive, so this cap only bounds the
2227
+ * steady-state per-wave burst (driver + dispatch units) for a single-driver
2228
+ * Claude session; other harnesses (pi, unknown) are unaffected — see
2229
+ * `resolveFanoutEffectiveConcurrency`.
2212
2230
  */
2213
- export const CLAUDE_MAX_EFFECTIVE_CONCURRENT = 2;
2231
+ export const CLAUDE_MAX_EFFECTIVE_CONCURRENT = 4;
2214
2232
 
2215
2233
  /**
2216
2234
  * Resolve the effective fan-out concurrency (dispatch units per wave): 1 when
@@ -2482,6 +2500,51 @@ export function resolveGateTier(config, gate, { changedFiles, filesChanged, line
2482
2500
  return { tier: matched.name, angles: [...new Set([...mandatoryAngles, ...matched.angles])], reason: "tier_match" };
2483
2501
  }
2484
2502
 
2503
+ /**
2504
+ * Best-effort angle selection for a diff no diff-class tier matched (an
2505
+ * unclassifiable file, a dev-loop config-source delta, or no tier configured).
2506
+ * Uncertainty selects the mandatory floor plus the lenses the diff still
2507
+ * justifies — the always-include lens and any consumer angle whose declared
2508
+ * category/kind binding intersects the diff. It is never the whole pool,
2509
+ * except for the degenerate gate whose pool holds neither a mandatory nor an
2510
+ * always-include angle: an empty best-effort selection falls back to the static
2511
+ * pool (fail-closed — more angles, not fewer) rather than returning nothing.
2512
+ *
2513
+ * `dynamic.subtractive: false` is the documented opt-out ("restore the full
2514
+ * static angle pool"); this returns the static pool unchanged, mirroring
2515
+ * resolveGateAnglesDynamic's `!gateConfig.dynamicAngles` branch so the composer
2516
+ * and the resolver agree for the same diff.
2517
+ *
2518
+ * This composer has no diff TEXT (only the changed-file list), so it can bind
2519
+ * on file kinds but not on hunk-derived change categories. Except for the
2520
+ * `gate:full` path, it is therefore a lower bound: the round's authoritative
2521
+ * angle set is resolved by resolveGateAnglesDynamic, which can add lenses bound
2522
+ * to real change categories. On `gate:full`, the composer deliberately returns
2523
+ * the full static pool and is instead a superset of the dynamic resolver.
2524
+ */
2525
+ function selectFloorPlusJustifiedAngles(config, gate, changedFiles) {
2526
+ const gateConfig = resolveGateConfig(config, gate);
2527
+ // Documented opt-out: `dynamic.subtractive: false` restores the full static
2528
+ // pool. Mirror resolveGateAnglesDynamic's `!gateConfig.dynamicAngles` branch
2529
+ // so the composer and the resolver never disagree under an explicit operator
2530
+ // config (both are callers of the same selection contract).
2531
+ if (!gateConfig.dynamicAngles) return resolveGateAngles(config, gate) ?? [];
2532
+ const { mandatoryAngles } = resolveGateAngleContract(config, gate);
2533
+ const pool = resolveGateAngles(config, gate) ?? [];
2534
+ const candidatePool = pool.filter((a) => !mandatoryAngles.includes(a));
2535
+ const validChangedFiles = (Array.isArray(changedFiles) ? changedFiles : [])
2536
+ .filter((f) => typeof f === "string" && f.trim().length > 0);
2537
+ const fileKinds = [...new Set(validChangedFiles.map((f) => classifyFile(f)))];
2538
+ const { recommendedAngles } = resolveDynamicAngles({
2539
+ configuredAngles: candidatePool,
2540
+ changeCategories: [],
2541
+ fileKinds,
2542
+ angleDeclarations: gateConfig.angleCategoryBindings,
2543
+ });
2544
+ const selected = [...new Set([...mandatoryAngles, ...recommendedAngles])];
2545
+ return selected.length > 0 ? selected : pool;
2546
+ }
2547
+
2485
2548
  /**
2486
2549
  * The primer-owned deterministic review-proportionality plan
2487
2550
  * (GATE-EXEC-PROPORTIONALITY, gate-review-sub-loop-contract.md): a single,
@@ -2489,25 +2552,25 @@ export function resolveGateTier(config, gate, { changedFiles, filesChanged, line
2489
2552
  * set + execution mode + grouping) is one testable, persistable object.
2490
2553
  * Delegates entirely to {@link resolveGateDispatchMode} (mode, including the
2491
2554
  * 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
2555
+ * {@link resolveGateTier} (angle set AND diff-classification),
2556
+ * {@link resolveDynamicAngles} (no-tier best-effort selection), and {@link
2557
+ * resolveFanoutGroups} (dispatch-unit grouping). No git I/O, no logic
2494
2558
  * of its own beyond the floor-vs-tier precedence below: this is the ONE place
2495
2559
  * the primer (emit) and the merge gate (re-verify) compose mode + angles +
2496
2560
  * grouping, so they can never drift onto two different floor implementations.
2497
2561
  *
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).
2562
+ * Floor-vs-tier precedence: every fired floor affects DISPATCH only. A
2563
+ * RISK-signal floor — the risk-path denylist (`risk_path_touch`), a non-clean/
2564
+ * ambiguous size-budget outcome (`size_outcome_*`,
2565
+ * `size_outcome_unavailable`), missing changed-file evidence
2566
+ * (`changed_files_unavailable`), or an unclassifiable diff
2567
+ * (`resolveGateTier`'s `unclassifiable_file`) ALWAYS forces `full_fanout`,
2568
+ * as does the hard size cap (`over_threshold`). The angle SET remains the
2569
+ * matched tier or, when no tier matched, the mandatory floor plus the lenses
2570
+ * justified by the changed-file kinds. It never widens to the full untriered
2571
+ * pool merely because a floor fired. A `gate:full` label (which self-bypasses
2572
+ * tier matching) and disabled light mode continue to govern dispatch mode
2573
+ * independently; `light_mode_disabled` is not a floor.
2511
2574
  *
2512
2575
  * @param {DevLoopConfig} config
2513
2576
  * @param {"draft"|"preApproval"} gate
@@ -2544,23 +2607,24 @@ export function resolveReviewProportionality(config, gate, {
2544
2607
  // dispatch-mode facts alone looked trivial.
2545
2608
  unclassifiable: tier.reason === "unclassifiable_file",
2546
2609
  });
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.
2610
+ // sizeCap (over_threshold) is deliberately EXCLUDED from the risk-signal
2611
+ // floor set: it predates the risk/ambiguity floors. Every floor affects the
2612
+ // dispatch mode only; angle selection remains tiered or best-effort.
2555
2613
  const dispatchFloorFired = floors.riskPath || floors.sizeOutcome || floors.ambiguity;
2556
2614
  const floored = dispatchFloorFired || floors.unclassifiable;
2557
2615
  const mode = floored ? "full_fanout" : dispatch.mode;
2558
2616
  const reason = floored && !dispatchFloorFired ? "unclassifiable_diff" : dispatch.reason;
2559
2617
  // 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));
2618
+ // unions mandatoryAngles in (resolveGateTier), and the no-tier best-effort
2619
+ // selection does the same union — see AC-4 "mandatory angles combined,
2620
+ // never dropped". A `gate:full` label is the explicit "run everything"
2621
+ // escape hatch (ADR 0048): it keeps forcing the full configured pool, which
2622
+ // is also what resolveGateTier's gate_full_label bypass left in place before
2623
+ // best-effort selection existed.
2624
+ const staticAngles = resolveGateAngles(config, gate);
2625
+ const angles = hasFullLabel
2626
+ ? staticAngles
2627
+ : (tier.angles ?? (staticAngles === null ? null : selectFloorPlusJustifiedAngles(config, gate, changedFiles)));
2564
2628
  const groups = resolveFanoutGroups(config, gate, angles ?? [], { fullLabel: hasFullLabel });
2565
2629
  return Object.freeze({
2566
2630
  mode,
@@ -2575,9 +2639,10 @@ export function resolveReviewProportionality(config, gate, {
2575
2639
  * Resolve gate angles dynamically when `dynamicAngles` is enabled.
2576
2640
  *
2577
2641
  * 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
2642
+ * relevant to the change set. When `dynamic.subtractive: false`, returns the
2643
+ * full configured list. Without a diff, ordinary callers keep that static pool
2644
+ * while floor-aware callers use mandatory-floor best-effort selection. When
2645
+ * `additiveAngles` is on, catalog angles from resolveAnglePool may also be added, with
2581
2646
  * `excludeAngles` a hard ceiling.
2582
2647
  *
2583
2648
  * Diff-class tiers (resolveGateTier) are consulted FIRST: a tier match returns
@@ -2586,15 +2651,14 @@ export function resolveReviewProportionality(config, gate, {
2586
2651
  *
2587
2652
  * GATE-EXEC-PROPORTIONALITY floor-awareness (opt-in via `checkFloors`): when
2588
2653
  * the caller supplies `checkFloors: true` (and, when available, `sizeOutcome`
2589
- * from check-size-budget.mjs), this delegates to {@link
2654
+ * from check-size-budget.mjs), this delegates floor determination to {@link
2590
2655
  * 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.
2656
+ * uses — over the SAME diff-derived changed-file/scope facts. A fired floor
2657
+ * forces `full_fanout` DISPATCH and refuses an explicit override, but the angle
2658
+ * SET is always the tier-or-dynamic best-effort selection below; uncertainty
2659
+ * never widens it to the full untriered pool. Omitted (default), this resolves
2660
+ * exactly as before a caller that does not have size-budget evidence to hand
2661
+ * is unaffected.
2598
2662
  *
2599
2663
  * @param {import("./types.js").DevLoopConfig} config
2600
2664
  * @param {"draft"|"preApproval"} gate
@@ -2603,7 +2667,7 @@ export function resolveReviewProportionality(config, gate, {
2603
2667
  * @param {boolean} [options.hasFullLabel] — `gate:full` label present on the PR (bypasses tier resolution)
2604
2668
  * @param {boolean} [options.checkFloors] — opt into the GATE-EXEC-PROPORTIONALITY floor check above
2605
2669
  * @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)
2670
+ * @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
2671
  * @returns {{ recommendedAngles: string[] | null, skippedAngles: string[], reasons: Record<string,string>, fallbackToAll: boolean, dynamicAnglesActive: boolean, addedAngles: string[], addedReasons: Record<string,string> }}
2608
2672
  */
2609
2673
  export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabel = false, checkFloors = false, sizeOutcome, explicitAngles } = {}) {
@@ -2625,30 +2689,20 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
2625
2689
  linesChanged = lineStats.added + lineStats.deleted;
2626
2690
  }
2627
2691
  }
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)) {
2692
+ const plan = checkFloors
2693
+ ? resolveReviewProportionality(config, gate, {
2694
+ scope: { filesChanged, linesChanged },
2695
+ changedFiles,
2696
+ sizeOutcome,
2697
+ hasFullLabel,
2698
+ })
2699
+ : null;
2700
+ const floorFired = plan !== null && (
2701
+ plan.floors.riskPath || plan.floors.sizeOutcome || plan.floors.ambiguity || plan.floors.unclassifiable
2702
+ );
2703
+ // A fired floor refuses an explicit --angles override. Selection falls
2704
+ // through to the same tier/dynamic best-effort path as every other diff.
2705
+ if (Array.isArray(explicitAngles) && !floorFired) {
2652
2706
  return {
2653
2707
  recommendedAngles: explicitAngles,
2654
2708
  skippedAngles: [],
@@ -2688,12 +2742,24 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
2688
2742
  }
2689
2743
 
2690
2744
  if (!gateConfig.dynamicAngles || !diff) {
2745
+ // No diff to select on (a thin briefing) or dynamic resolution explicitly
2746
+ // disabled by config. An explicitly disabled resolver keeps its static pool
2747
+ // — that is the config asking for every angle. When dynamic resolution is
2748
+ // enabled, a floor-aware caller that simply has no diff still gets the
2749
+ // composer's mandatory-floor-plus-justified set rather than the whole pool,
2750
+ // so absence of evidence never widens the angle set either.
2751
+ const bestEffortWithoutDiff = Boolean(!diff && plan && gateConfig.dynamicAngles);
2752
+ const recommendedAngles = bestEffortWithoutDiff ? (plan.angles ?? staticAngles) : staticAngles;
2753
+ const recommended = new Set(recommendedAngles);
2754
+ const skippedAngles = staticAngles.filter((a) => !recommended.has(a));
2691
2755
  return {
2692
- recommendedAngles: staticAngles,
2693
- skippedAngles: [],
2694
- reasons: {},
2756
+ recommendedAngles,
2757
+ skippedAngles,
2758
+ reasons: Object.fromEntries(
2759
+ skippedAngles.map((a) => [a, "Skipped: no diff is available to select on; the mandatory floor plus justified lenses were selected instead"]),
2760
+ ),
2695
2761
  fallbackToAll: false,
2696
- dynamicAnglesActive: false,
2762
+ dynamicAnglesActive: bestEffortWithoutDiff,
2697
2763
  addedAngles: [],
2698
2764
  addedReasons: {},
2699
2765
  };
@@ -2724,8 +2790,7 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
2724
2790
  ? resolveAnglePool(config).filter(a => !excluded.has(a))
2725
2791
  : undefined;
2726
2792
 
2727
- const { resolveDynamicAngles: resolve } = await import("../analysis/change-classifier.mjs");
2728
- const dynamicResult = resolve({
2793
+ const dynamicResult = resolveDynamicAngles({
2729
2794
  configuredAngles: candidatePool,
2730
2795
  changeCategories: categories,
2731
2796
  ambiguous: analysis.ambiguous,
@@ -2746,12 +2811,24 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
2746
2811
  Object.entries(dynamicResult.addedReasons ?? {}).filter(([a]) => !mandatory.has(a))
2747
2812
  );
2748
2813
 
2749
- const recommendedAngles = [...new Set([...filteredMandatory, ...dynamicResult.recommendedAngles, ...addedAngles])];
2814
+ const mergedAngles = [...new Set([...filteredMandatory, ...dynamicResult.recommendedAngles, ...addedAngles])];
2815
+
2816
+ // Degenerate-pool fail-closed: a gate whose configured pool holds neither a
2817
+ // mandatory nor an always-include angle (e.g. `angles: ["kiss"]`) can legally
2818
+ // resolve to zero angles for an unclassifiable diff. Never resolve zero
2819
+ // angles — fall back to the static pool (already excludeAngles-filtered),
2820
+ // mirroring selectFloorPlusJustifiedAngles and the composer, so the resolver
2821
+ // and the composer agree for the identical input. The whole pool is selected,
2822
+ // so nothing is skipped and no angle carries a skip reason.
2823
+ const degenerateFallback = mergedAngles.length === 0;
2824
+ const recommendedAngles = degenerateFallback ? [...staticAngles] : mergedAngles;
2825
+ const skippedAngles = degenerateFallback ? [] : dynamicResult.skippedAngles;
2826
+ const reasons = degenerateFallback ? {} : dynamicResult.reasons;
2750
2827
 
2751
2828
  return {
2752
2829
  recommendedAngles,
2753
- skippedAngles: dynamicResult.skippedAngles,
2754
- reasons: dynamicResult.reasons,
2830
+ skippedAngles,
2831
+ reasons,
2755
2832
  fallbackToAll: dynamicResult.fallbackToAll,
2756
2833
  dynamicAnglesActive: true,
2757
2834
  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