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

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,8 +6,11 @@ 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
10
  import { isDevLoopConfigSourcePath } from "../loop/gate-carry-forward.mjs";
11
+ import { isClaudeHarness } from "../loop/run-context.mjs";
10
12
  import { trimmedOrNull } from "../loop/normalize.mjs";
13
+ import { matchesDiffExcludeGlob } from "../loop/review-dispatch-plan.mjs";
11
14
 
12
15
  // ============================================================================
13
16
  // Sub-schemas
@@ -43,6 +46,12 @@ const BUILTIN_ROLE_TIERS = Object.freeze({
43
46
  quality: "low",
44
47
  refiner: "high",
45
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",
46
55
  "dev-loop": "inherit",
47
56
  });
48
57
 
@@ -111,6 +120,13 @@ const RefinementConfig = z.strictObject({
111
120
  // cost saving, never a silently-enforced information cut.
112
121
  export const GATE_ANGLE_SCOPES = Object.freeze(["full", "changed-files", "docs-only"]);
113
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
+
114
130
  // One review angle: a bare string is sugar for `{ name }`; the fields are
115
131
  // documented on the schema below. mergeConfigLayers merges these arrays BY
116
132
  // `name` across config layers, so a later layer can add or disable a single
@@ -130,13 +146,15 @@ const GateAngleEntry = z.preprocess(
130
146
  model: z.string().trim().min(1).optional().describe("Concrete model override for this angle (highest precedence)."),
131
147
  tier: z.string().trim().min(1).optional().describe("Model tier alias for this angle (used when `model` is absent)."),
132
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."),
133
151
  }),
134
152
  );
135
153
 
136
154
  // Diff-class kinds a tier's `match` can name — exactly classifyFile()'s
137
155
  // output range (../analysis/diff-analyzer.mjs), so a tier config can never
138
156
  // name a kind the classifier could not produce.
139
- const GateTierMatchKind = z.enum(["code", "docs", "config", "test", "ci", "unknown"]);
157
+ const GateTierMatchKind = z.enum(FILE_KIND_NAMES);
140
158
 
141
159
  // A tier's match conditions: EVERY changed file's kind must be in `kinds`
142
160
  // (when set) AND the change must stay within `maxFiles`/`maxLines` (when
@@ -214,7 +232,7 @@ function formatConfigValue(value) {
214
232
  const GATE_KEYS_WITH_BLOCKING_SEVERITIES = /** @type {const} */ (["draft", "preApproval", "spike"]);
215
233
 
216
234
  const GateConfig = z.strictObject({
217
- 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."),
218
236
  dynamic: GateDynamicConfig.optional().describe("Diff-driven dynamic angle selection policy for this gate."),
219
237
  required: z.boolean().default(true).describe("Whether this gate must run."),
220
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."),
@@ -232,6 +250,13 @@ const GateConfig = z.strictObject({
232
250
  // resolveGateConfig applies the built-in fallback (3) after checking both.
233
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."),
234
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
+ ),
235
260
  // Ordered, first-match-wins diff-class angle tiers (see resolveGateTier).
236
261
  // Absent/empty = tiers never apply.
237
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(),
@@ -425,6 +450,12 @@ const LocalImplementationConfig = z.strictObject({
425
450
  // Composes with (does not replace) refinement.maxCopilotRounds — see
426
451
  // resolveEffectiveCopilotRoundCap.
427
452
  maxCopilotRounds: z.number().int().nonnegative().default(1).describe("Copilot round cap for light-dispatched PRs; composes as min(this, refinement.maxCopilotRounds)."),
453
+ // Purely ADDITIVE on top of the hard-coded RISK_PATH_DENYLIST_DEFAULT floor
454
+ // (resolveGateDispatchMode/touchesRiskPath) — this field can only ADD extra
455
+ // risk-path globs for a repo, never remove/replace the shipped floor, so a
456
+ // layer that sets its own lightMode block (as this repo's .devloops already
457
+ // does for maxFiles/maxLines) can never silently drop the floor.
458
+ riskPaths: z.array(z.string().trim().min(1)).describe("Repo-specific extra glob patterns that force full fan-out regardless of size, layered ON TOP of the shipped risk-path denylist floor (never replacing it).").optional(),
428
459
  }).optional(),
429
460
  /**
430
461
  * Opt into issue-less PR-first at ANY change scope. Decoupled from lightMode:
@@ -908,13 +939,13 @@ const DEFAULT_REVIEWER_PERSONA = "default-reviewer";
908
939
  /**
909
940
  * Normalize one raw `gates.<gate>.angles[]` entry (string sugar or object,
910
941
  * possibly hand-built and never zod-validated — e.g. a test config object) to
911
- * `{ name, mandatory?, enabled?, persona?, prompt?, model?, tier?, scope? }`.
942
+ * `{ name, mandatory?, enabled?, persona?, prompt?, model?, tier?, scope?, categories?, kinds? }`.
912
943
  * Returns null for a malformed/empty entry so callers can filter it out. An
913
944
  * invalid `scope` (not one of GATE_ANGLE_SCOPES) is dropped rather than
914
945
  * kept verbatim — resolveGateAngleScope's fail-open default only ever needs
915
946
  * to handle an ABSENT field, never a foreign value.
916
947
  * @param {unknown} a
917
- * @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}
918
949
  */
919
950
  function normalizeAngleEntry(a) {
920
951
  if (typeof a === "string") {
@@ -932,6 +963,17 @@ function normalizeAngleEntry(a) {
932
963
  if (typeof a.model === "string" && a.model.trim().length > 0) entry.model = a.model.trim();
933
964
  if (typeof a.tier === "string" && a.tier.trim().length > 0) entry.tier = a.tier.trim();
934
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;
935
977
  return entry;
936
978
  }
937
979
  return null;
@@ -941,7 +983,7 @@ function normalizeAngleEntry(a) {
941
983
  * Normalize a raw `gates.<gate>.angles` array into full entry objects,
942
984
  * dropping malformed entries.
943
985
  * @param {unknown} raw
944
- * @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[]}>}
945
987
  */
946
988
  function normalizeAngleEntries(raw) {
947
989
  if (!Array.isArray(raw)) return [];
@@ -1231,15 +1273,18 @@ function mergeAngleArrays(targetRaw, sourceRaw) {
1231
1273
  * @param {string} filePath
1232
1274
  * @returns {Promise<object|null>}
1233
1275
  */
1234
- async function readConfigFile(filePath) {
1235
- let raw;
1236
- try {
1237
- raw = await readFile(filePath, "utf8");
1238
- } catch (err) {
1239
- if (err.code === "ENOENT") return null;
1240
- throw configError(`Cannot read config file: ${err.message}`, err.code, filePath);
1241
- }
1242
-
1276
+ /**
1277
+ * Parse already-read config text (YAML or JSON, keyed off `filePath`'s
1278
+ * extension) into a plain object. Split out of {@link readConfigFile} so a
1279
+ * caller that already has the raw text from somewhere other than this
1280
+ * checkout's disk (e.g. `loadDevLoopConfig`'s `devloopsOverride`, which reads
1281
+ * a PR head commit's `.devloops` via git) can reuse the exact same parsing
1282
+ * rules instead of re-implementing them.
1283
+ * @param {string} raw
1284
+ * @param {string} filePath - used only for its extension and in error messages
1285
+ * @returns {Record<string, unknown>}
1286
+ */
1287
+ function parseConfigContent(raw, filePath) {
1243
1288
  if (raw.trim() === "") {
1244
1289
  throw configError("Config file is empty", "EMPTY_FILE", filePath);
1245
1290
  }
@@ -1274,6 +1319,17 @@ async function readConfigFile(filePath) {
1274
1319
  return parsed;
1275
1320
  }
1276
1321
 
1322
+ async function readConfigFile(filePath) {
1323
+ let raw;
1324
+ try {
1325
+ raw = await readFile(filePath, "utf8");
1326
+ } catch (err) {
1327
+ if (err.code === "ENOENT") return null;
1328
+ throw configError(`Cannot read config file: ${err.message}`, err.code, filePath);
1329
+ }
1330
+ return parseConfigContent(raw, filePath);
1331
+ }
1332
+
1277
1333
  /**
1278
1334
  * Find a config file by trying one or more base names in order.
1279
1335
  * Each base name prefers YAML (.yaml, then .yml) before JSON.
@@ -1355,6 +1411,24 @@ async function applyLayer(merged, basePaths, layer, warnings, errors, options =
1355
1411
  return merged;
1356
1412
  }
1357
1413
 
1414
+ return applyParsedLayer(merged, filePath, data, layer, warnings, errors);
1415
+ }
1416
+
1417
+ /**
1418
+ * Validate + merge one already-parsed config layer's data into `merged`.
1419
+ * Split out of {@link applyLayer} so `loadDevLoopConfig`'s `devloopsOverride`
1420
+ * (a PR head commit's `.devloops`, read via git rather than this checkout's
1421
+ * disk) goes through the exact same deprecation normalization, schema
1422
+ * validation, and merge rules as every disk-sourced layer.
1423
+ * @param {Record<string, unknown>} merged
1424
+ * @param {string} filePath - source path/label, used in warnings/errors only
1425
+ * @param {Record<string, unknown>} data - already-parsed layer content
1426
+ * @param {"extensionDefaults"|"defaults"|"devloops"} layer
1427
+ * @param {string[]} warnings
1428
+ * @param {ConfigLoadError[]} errors
1429
+ * @returns {Record<string, unknown>}
1430
+ */
1431
+ function applyParsedLayer(merged, filePath, data, layer, warnings, errors) {
1358
1432
  // Deprecated `strategy: "github-first"` alias: normalized to
1359
1433
  // "tracker-first" BEFORE this layer's FileConfigSchema validation (the enum
1360
1434
  // only accepts the canonical value, else the whole layer drops as invalid).
@@ -1434,6 +1508,7 @@ async function applyLayer(merged, basePaths, layer, warnings, errors, options =
1434
1508
  * @typedef {object} LoadOptions
1435
1509
  * @property {string} [repoRoot] - Path to repository root (default: process.cwd())
1436
1510
  * @property {string} [extensionDefaultsBasePath] - Base path (no extension) to extension defaults; overrides the package-relative default
1511
+ * @property {{ raw: string|null, path?: string }} [devloopsOverride] - When present, sources the devloops (primary override) layer from `raw` instead of reading `<repoRoot>/.devloops*` off disk; `raw: null` means "no .devloops at this source" (a legitimate state, distinct from omitting the option entirely, which reads disk as usual)
1437
1512
  */
1438
1513
 
1439
1514
  /**
@@ -1465,26 +1540,52 @@ export async function loadDevLoopConfig(options = {}) {
1465
1540
  warnOnMissing: true,
1466
1541
  });
1467
1542
 
1468
- // .devloops (primary override) existence: only ENOENT means genuinely absent.
1469
- // Any other error (EACCES/EISDIR) means it exists but is unreadable, so
1470
- // select the .devloops path and let applyLayer record the structured error.
1471
- let primaryExists = false;
1472
- for (const ext of ["", ".yaml", ".yml", ".json"]) {
1473
- try {
1474
- await readFile(devloopsPath + ext, "utf8");
1475
- primaryExists = true;
1476
- break;
1477
- } catch (err) {
1478
- if (err?.code !== "ENOENT") {
1543
+ // `devloopsOverride` sources the devloops (primary override) layer's
1544
+ // content directly instead of reading this checkout's disk file — used to
1545
+ // resolve config from a different ref (e.g. a PR head commit, read via git)
1546
+ // while extensionDefaults and .pi/dev-loop/defaults still come from
1547
+ // repoRoot on disk. Presence of the key (even `{ raw: null }`,
1548
+ // meaning "no .devloops at that ref") switches modes; omitting the option
1549
+ // entirely preserves today's disk-read behavior.
1550
+ if (options.devloopsOverride !== undefined) {
1551
+ const { raw, path: overridePath = devloopsPath } = options.devloopsOverride ?? {};
1552
+ if (typeof raw === "string") {
1553
+ try {
1554
+ const data = parseConfigContent(raw, overridePath);
1555
+ merged = applyParsedLayer(merged, overridePath, data, "devloops", warnings, errors);
1556
+ } catch (err) {
1557
+ errors.push({
1558
+ path: overridePath,
1559
+ message: `${path.basename(overridePath)}: ${err.message}`,
1560
+ layer: "devloops",
1561
+ });
1562
+ }
1563
+ }
1564
+ // raw == null: no .devloops present at the overridden source — leave
1565
+ // `merged` at extensionDefaults+defaults, mirroring primaryExists: false
1566
+ // below.
1567
+ } else {
1568
+ // .devloops (primary override) existence: only ENOENT means genuinely absent.
1569
+ // Any other error (EACCES/EISDIR) means it exists but is unreadable, so
1570
+ // select the .devloops path and let applyLayer record the structured error.
1571
+ let primaryExists = false;
1572
+ for (const ext of ["", ".yaml", ".yml", ".json"]) {
1573
+ try {
1574
+ await readFile(devloopsPath + ext, "utf8");
1479
1575
  primaryExists = true;
1480
1576
  break;
1577
+ } catch (err) {
1578
+ if (err?.code !== "ENOENT") {
1579
+ primaryExists = true;
1580
+ break;
1581
+ }
1582
+ // ENOENT — genuinely absent, try next extension
1481
1583
  }
1482
- // ENOENT — genuinely absent, try next extension
1483
1584
  }
1484
- }
1485
1585
 
1486
- if (primaryExists) {
1487
- merged = await applyLayer(merged, devloopsPath, "devloops", warnings, errors);
1586
+ if (primaryExists) {
1587
+ merged = await applyLayer(merged, devloopsPath, "devloops", warnings, errors);
1588
+ }
1488
1589
  }
1489
1590
 
1490
1591
  // Validate final merged config
@@ -1704,7 +1805,7 @@ function resolveBlockingSeverities(config, gate) {
1704
1805
  *
1705
1806
  * @param {DevLoopConfig} config
1706
1807
  * @param {"draft"|"preApproval"|"spike"} gate
1707
- * @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[]}> }}
1708
1809
  * @throws {Error} when ANY gate's (not only the requested one's) PRESENT
1709
1810
  * `blockCleanOnFindingSeverities` is schema-invalid (non-array, empty, or an
1710
1811
  * out-of-vocabulary entry). Validated EAGERLY across all three gates on every
@@ -1741,7 +1842,17 @@ export function resolveGateConfig(config, gate) {
1741
1842
  // mediumFixWindow wins; worthFixingNowFixWindow is the deprecated alias,
1742
1843
  // still honored so an unmigrated config keeps its window.
1743
1844
  mediumFixWindow: gateConfig?.mediumFixWindow ?? gateConfig?.worthFixingNowFixWindow ?? 3,
1845
+ inlineSeverityFloor: gateConfig?.inlineSeverityFloor ?? "medium",
1744
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
+ ),
1745
1856
  };
1746
1857
  }
1747
1858
 
@@ -1819,6 +1930,11 @@ export function resolveLightMode(config) {
1819
1930
  maxLines: typeof cfg.maxLines === "number" && Number.isFinite(cfg.maxLines) && cfg.maxLines > 0
1820
1931
  ? cfg.maxLines
1821
1932
  : 200,
1933
+ // Repo-specific ADDITIONS to the risk-path floor (see touchesRiskPath) —
1934
+ // never the floor itself, which is hard-coded and always applied first.
1935
+ riskPaths: Array.isArray(cfg.riskPaths)
1936
+ ? cfg.riskPaths.filter((p) => typeof p === "string" && p.trim().length > 0)
1937
+ : [],
1822
1938
  };
1823
1939
  }
1824
1940
 
@@ -1858,17 +1974,144 @@ export function resolveEffectiveCopilotRoundCap(config, { lightweight = false }
1858
1974
  /** Label that forces full fan-out regardless of change size. */
1859
1975
  export const GATE_FULL_LABEL = "gate:full";
1860
1976
 
1977
+ /**
1978
+ * Conservative, hard-coded risk-path denylist floor (GATE-EXEC-PROPORTIONALITY,
1979
+ * gate-review-sub-loop-contract.md): a diff touching any of these trees forces
1980
+ * full fan-out regardless of size. Hard-coded here — never sourced purely from
1981
+ * `.devloops`/extension-defaults layers — so a config layer that replaces its
1982
+ * own `localImplementation.lightMode` block (as this repo's own `.devloops`
1983
+ * already does for maxFiles/maxLines) can only ADD extra globs
1984
+ * (`lightMode.riskPaths`, unioned in by {@link touchesRiskPath}) and can never
1985
+ * drop this floor. Mirrors `DEFAULT_DIFF_EXCLUDE_GLOBS`'s "shipped default
1986
+ * always applied first, caller can only extend it" pattern
1987
+ * (review-dispatch-plan.mjs). Every glob is deliberately OVER-inclusive per the
1988
+ * "ambiguity resolves toward MORE review" rule — a borderline path SHOULD trip
1989
+ * full fan-out, never quietly pass through:
1990
+ * - gate/review: the dispatch-decision and fan-out/fan-in review-sub-loop
1991
+ * machinery itself — a change here can move the very floor that decides
1992
+ * review depth, so it always gets full review.
1993
+ * - security/auth: any path naming auth/token/secret/credential, plus the
1994
+ * dedicated security-tooling tree.
1995
+ * - contract: normative contract docs and the ADR/test surfaces that back
1996
+ * them.
1997
+ * - hook: repo/CI hook wiring that runs on every commit or tool call.
1998
+ * - release: publish/tag machinery, release CI workflows, and package
1999
+ * publication metadata.
2000
+ * Glob subset (see {@link matchesDiffExcludeGlob}): `**\/` matches
2001
+ * zero-or-more whole path segments, a lone `**` matches any suffix, a single
2002
+ * `*` matches within one path segment only.
2003
+ */
2004
+ export const RISK_PATH_DENYLIST_DEFAULT = Object.freeze([
2005
+ // gate / review — the proportionality mechanism's own implementation, plus
2006
+ // any path named gate/review anywhere under scripts/ or packages/core/src/loop.
2007
+ "packages/core/src/config/config.mjs",
2008
+ "packages/core/src/config/extension-defaults.yaml",
2009
+ "scripts/loop/check-size-budget.mjs",
2010
+ "scripts/loop/check-adr-tripwire.mjs",
2011
+ "scripts/loop/resolve-gate-dispatch.mjs",
2012
+ "scripts/loop/detect-change-scope.mjs",
2013
+ "scripts/github/detect-checkpoint-evidence.mjs",
2014
+ "scripts/github/upsert-checkpoint-verdict.mjs",
2015
+ "scripts/github/emit-fanout-dispatch.mjs",
2016
+ "scripts/loop/consolidate-fanin.mjs",
2017
+ "scripts/**/*gate*",
2018
+ "scripts/**/*review*",
2019
+ "packages/core/src/loop/*gate*",
2020
+ "packages/core/src/loop/*gate*/**",
2021
+ "packages/core/src/loop/*review*",
2022
+ "packages/core/src/loop/*review*/**",
2023
+ "skills/docs/gate-review-*",
2024
+ // security / auth
2025
+ "**/*auth*",
2026
+ "**/*token*",
2027
+ "**/*secret*",
2028
+ "**/*credential*",
2029
+ "scripts/security/**",
2030
+ // contract
2031
+ "skills/docs/*-contract.md",
2032
+ "test/contracts/**",
2033
+ "docs/decisions/**",
2034
+ // hook
2035
+ ".claude/hooks/**",
2036
+ ".githooks/**",
2037
+ "scripts/**/*hook*",
2038
+ // release
2039
+ "scripts/release/**",
2040
+ "scripts/**/*release*",
2041
+ "scripts/**/*publish*",
2042
+ ".github/workflows/*release*",
2043
+ ".github/workflows/*publish*",
2044
+ "package.json",
2045
+ "**/package.json",
2046
+ ]);
2047
+
2048
+ /**
2049
+ * Pure risk-path predicate: does ANY changed file match the shipped
2050
+ * {@link RISK_PATH_DENYLIST_DEFAULT} floor or a repo's additive
2051
+ * `localImplementation.lightMode.riskPaths` globs? Fails CLOSED (returns
2052
+ * `true`) when `changedFiles` is not a readable array — absence of evidence is
2053
+ * never triviality.
2054
+ * @param {unknown} changedFiles — repo-relative paths, or anything non-array (ambiguous)
2055
+ * @param {string[]} [extraDenylist] — additive globs from config; never replaces the floor
2056
+ * @returns {boolean}
2057
+ */
2058
+ export function touchesRiskPath(changedFiles, extraDenylist = []) {
2059
+ if (!Array.isArray(changedFiles)) return true;
2060
+ const denylist = [...RISK_PATH_DENYLIST_DEFAULT, ...(Array.isArray(extraDenylist) ? extraDenylist : [])];
2061
+ return changedFiles.some((f) => {
2062
+ const posix = String(f).replace(/\\/g, "/");
2063
+ return denylist.some((pattern) => matchesDiffExcludeGlob(posix, pattern));
2064
+ });
2065
+ }
2066
+
2067
+ /**
2068
+ * Pure predicate: is a check-size-budget.mjs sizeOutcome genuinely T1-clean —
2069
+ * a `pass` outcome AND a finite, non-negative T1-tier slice equal to 0 (the
2070
+ * clean value)? `undefined > 0` and `NaN > 0` both evaluate false, so a bare
2071
+ * `!(t1 > 0)` comparison would read malformed/absent T1 evidence (a missing
2072
+ * `tierLogicLoc`, a non-numeric `t1`) as clean and admit the light path on
2073
+ * unreadable evidence. This requires a genuine NUMBER, never a truthiness
2074
+ * check, so malformed evidence fails CLOSED exactly like a real
2075
+ * size-budget computation error. The ONE shared predicate for this floor
2076
+ * (GATE-EXEC-PROPORTIONALITY): `resolveGateDispatchMode` below and the
2077
+ * size-budget merge gate (scripts/github/detect-checkpoint-evidence.mjs) both
2078
+ * call it, so they never drift onto two independently-maintained floor
2079
+ * implementations — mirroring how {@link touchesRiskPath} is the one shared
2080
+ * risk-path predicate.
2081
+ * @param {{ outcome?: string, tierLogicLoc?: { t1?: number } }|null|undefined} sizeOutcome
2082
+ * @returns {boolean}
2083
+ */
2084
+ export function isSizeOutcomeT1Clean(sizeOutcome) {
2085
+ if (sizeOutcome == null || typeof sizeOutcome !== "object") return false;
2086
+ if (sizeOutcome.outcome !== "pass") return false;
2087
+ const t1 = sizeOutcome.tierLogicLoc?.t1;
2088
+ return typeof t1 === "number" && Number.isFinite(t1) && t1 === 0;
2089
+ }
2090
+
1861
2091
  /**
1862
2092
  * Decide whether a gate runs as a single-agent inline check or full fan-out,
1863
2093
  * from light-mode config + authoritative PR facts.
1864
2094
  *
1865
2095
  * Precedence (first match wins):
1866
- * 1. `gate:full` label present → full_fanout
1867
- * 2. light mode disabled / no threshold → full_fanout
1868
- * 3. scope over threshold (files OR lines) → full_fanout
1869
- * 4. inline finding severity in the gate's blockCleanOnFindingSeverities set
1870
- * → full_fanout (escalated)
1871
- * 5. otherwise → inline
2096
+ * 1. `gate:full` label present → full_fanout
2097
+ * 2. light mode disabled / no threshold → full_fanout
2098
+ * 3. scope over threshold (files OR lines) → full_fanout
2099
+ * 4. `changedFiles` unavailable (ambiguous) → full_fanout
2100
+ * 5. a changed file touches a risk path → full_fanout
2101
+ * 6. `sizeOutcome` unavailable (ambiguous) → full_fanout
2102
+ * 7. size-outcome escalate/block → full_fanout
2103
+ * 8. size-outcome touches the T1 risk tier → full_fanout
2104
+ * 9. inline finding severity in the gate's blockCleanOnFindingSeverities set
2105
+ * → full_fanout (escalated)
2106
+ * 10. otherwise → inline
2107
+ *
2108
+ * Steps 4-8 are the GATE-EXEC-PROPORTIONALITY non-overridable floors
2109
+ * (gate-review-sub-loop-contract.md): they run only once the cheap file/line
2110
+ * cap (step 3) has already passed, so an already-over-cap diff costs the
2111
+ * caller nothing extra. A caller that omits `changedFiles`/`sizeOutcome` for
2112
+ * an otherwise-under-cap diff fails CLOSED (full fan-out) rather than silently
2113
+ * treating missing evidence as trivial — no flag/waiver/prompt can lower these
2114
+ * floors.
1872
2115
  *
1873
2116
  * Pre-check omits `inlineFindingSeverities` (decides whether to run the inline
1874
2117
  * pass at all); escalation passes the inline pass's severities. Absent/partial
@@ -1878,11 +2121,13 @@ export const GATE_FULL_LABEL = "gate:full";
1878
2121
  * @param {"draft"|"preApproval"} gate
1879
2122
  * @param {object} facts
1880
2123
  * @param {{ filesChanged?: number, linesChanged?: number }} [facts.scope] PR scope; absent/partial fields fail safe to full_fanout
2124
+ * @param {string[]} [facts.changedFiles] repo-relative changed-file paths; absent/non-array fails safe to full_fanout
2125
+ * @param {{ outcome?: "pass"|"escalate"|"block", tierLogicLoc?: { t1?: number } }|null} [facts.sizeOutcome] check-size-budget.mjs's computeSizeBudget outcome; absent/null fails safe to full_fanout
1881
2126
  * @param {boolean} [facts.hasFullLabel] `gate:full` label present on the PR
1882
2127
  * @param {string[]} [facts.inlineFindingSeverities] severities from the inline pass (escalation phase)
1883
- * @returns {{ mode: "inline"|"full_fanout", reason: string, threshold: {maxFiles:number,maxLines:number}|null }}
2128
+ * @returns {{ mode: "inline"|"full_fanout", reason: string, threshold: ({maxFiles:number,maxLines:number,riskPaths:string[]})|null }}
1884
2129
  */
1885
- export function resolveGateDispatchMode(config, gate, { scope, hasFullLabel = false, inlineFindingSeverities } = {}) {
2130
+ export function resolveGateDispatchMode(config, gate, { scope, changedFiles, sizeOutcome, hasFullLabel = false, inlineFindingSeverities } = {}) {
1886
2131
  if (hasFullLabel) {
1887
2132
  return { mode: "full_fanout", reason: "gate_full_label", threshold: null };
1888
2133
  }
@@ -1895,6 +2140,31 @@ export function resolveGateDispatchMode(config, gate, { scope, hasFullLabel = fa
1895
2140
  if (filesChanged > threshold.maxFiles || linesChanged > threshold.maxLines) {
1896
2141
  return { mode: "full_fanout", reason: "over_threshold", threshold };
1897
2142
  }
2143
+ if (!Array.isArray(changedFiles)) {
2144
+ return { mode: "full_fanout", reason: "changed_files_unavailable", threshold };
2145
+ }
2146
+ if (touchesRiskPath(changedFiles, threshold.riskPaths)) {
2147
+ return { mode: "full_fanout", reason: "risk_path_touch", threshold };
2148
+ }
2149
+ if (sizeOutcome == null || typeof sizeOutcome !== "object") {
2150
+ return { mode: "full_fanout", reason: "size_outcome_unavailable", threshold };
2151
+ }
2152
+ if (sizeOutcome.outcome !== "pass") {
2153
+ const outcomeLabel = typeof sizeOutcome.outcome === "string" && sizeOutcome.outcome.length > 0 ? sizeOutcome.outcome : "unknown";
2154
+ return { mode: "full_fanout", reason: `size_outcome_${outcomeLabel}`, threshold };
2155
+ }
2156
+ // GATE-EXEC-PROPORTIONALITY: delegate the pass+T1-clean decision to the one
2157
+ // shared predicate (isSizeOutcomeT1Clean, above) so this resolver and the
2158
+ // size-budget merge gate never drift onto two independently-maintained
2159
+ // floor implementations. A malformed/partial T1 value (missing, NaN,
2160
+ // negative, non-numeric) is ambiguity, not triviality, so it fails CLOSED to
2161
+ // `size_outcome_unavailable` exactly like a missing sizeOutcome altogether —
2162
+ // never a naive `t1 > 0` truthiness read.
2163
+ if (!isSizeOutcomeT1Clean(sizeOutcome)) {
2164
+ const t1 = sizeOutcome.tierLogicLoc?.t1;
2165
+ const reason = typeof t1 === "number" && Number.isFinite(t1) && t1 > 0 ? "size_outcome_t1" : "size_outcome_unavailable";
2166
+ return { mode: "full_fanout", reason, threshold };
2167
+ }
1898
2168
  if (Array.isArray(inlineFindingSeverities) && inlineFindingSeverities.length > 0) {
1899
2169
  // Both sides normalize legacy spellings so a "defer" finding still
1900
2170
  // compares against a "low" blocking entry and vice versa.
@@ -1933,15 +2203,31 @@ export function resolveFanoutSequential(config) {
1933
2203
  return s === true;
1934
2204
  }
1935
2205
 
2206
+ /**
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`.
2212
+ */
2213
+ export const CLAUDE_MAX_EFFECTIVE_CONCURRENT = 2;
2214
+
1936
2215
  /**
1937
2216
  * Resolve the effective fan-out concurrency (dispatch units per wave): 1 when
1938
- * `gates.fanout.sequential` is set, else `resolveFanoutMaxConcurrent`.
2217
+ * `gates.fanout.sequential` is set, else `resolveFanoutMaxConcurrent`. Under the
2218
+ * Claude harness (`isClaudeHarness(env)`) that value is additionally
2219
+ * clamped to `CLAUDE_MAX_EFFECTIVE_CONCURRENT` so a single-driver Claude
2220
+ * session's per-wave burst (driver + dispatch units) stays within its rate
2221
+ * limit without lowering the shipped cross-harness default (schema/config
2222
+ * surface unchanged) or requiring an operator-imposed throttle. Every other
2223
+ * harness (pi, unknown, no env) returns the configured value unchanged.
1939
2224
  * @param {DevLoopConfig} config
2225
+ * @param {Record<string, string|undefined>} [env] — defaults to `process.env`
1940
2226
  * @returns {number}
1941
2227
  */
1942
- export function resolveFanoutEffectiveConcurrency(config) {
1943
- if (resolveFanoutSequential(config)) return 1;
1944
- return resolveFanoutMaxConcurrent(config);
2228
+ export function resolveFanoutEffectiveConcurrency(config, env = process.env) {
2229
+ const base = resolveFanoutSequential(config) ? 1 : resolveFanoutMaxConcurrent(config);
2230
+ return isClaudeHarness(env) ? Math.min(base, CLAUDE_MAX_EFFECTIVE_CONCURRENT) : base;
1945
2231
  }
1946
2232
 
1947
2233
  /**
@@ -2196,6 +2482,95 @@ export function resolveGateTier(config, gate, { changedFiles, filesChanged, line
2196
2482
  return { tier: matched.name, angles: [...new Set([...mandatoryAngles, ...matched.angles])], reason: "tier_match" };
2197
2483
  }
2198
2484
 
2485
+ /**
2486
+ * The primer-owned deterministic review-proportionality plan
2487
+ * (GATE-EXEC-PROPORTIONALITY, gate-review-sub-loop-contract.md): a single,
2488
+ * pure composition of the existing decision functions so "the plan" (angle
2489
+ * set + execution mode + grouping) is one testable, persistable object.
2490
+ * Delegates entirely to {@link resolveGateDispatchMode} (mode, including the
2491
+ * 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
2494
+ * of its own beyond the floor-vs-tier precedence below: this is the ONE place
2495
+ * the primer (emit) and the merge gate (re-verify) compose mode + angles +
2496
+ * grouping, so they can never drift onto two different floor implementations.
2497
+ *
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).
2511
+ *
2512
+ * @param {DevLoopConfig} config
2513
+ * @param {"draft"|"preApproval"} gate
2514
+ * @param {object} facts
2515
+ * @param {{ filesChanged?: number, linesChanged?: number }} [facts.scope]
2516
+ * @param {string[]} [facts.changedFiles]
2517
+ * @param {{ outcome?: "pass"|"escalate"|"block", tierLogicLoc?: { t1?: number } }|null} [facts.sizeOutcome]
2518
+ * @param {boolean} [facts.hasFullLabel]
2519
+ * @param {string[]} [facts.inlineFindingSeverities]
2520
+ * @returns {{ mode: "inline"|"full_fanout", angles: string[]|null, groups: { name: string, angles: string[] }[], reason: string, floors: { sizeCap: boolean, riskPath: boolean, sizeOutcome: boolean, ambiguity: boolean, unclassifiable: boolean } }}
2521
+ */
2522
+ export function resolveReviewProportionality(config, gate, {
2523
+ scope,
2524
+ changedFiles,
2525
+ sizeOutcome,
2526
+ hasFullLabel = false,
2527
+ inlineFindingSeverities,
2528
+ } = {}) {
2529
+ const dispatch = resolveGateDispatchMode(config, gate, { scope, changedFiles, sizeOutcome, hasFullLabel, inlineFindingSeverities });
2530
+ const tier = resolveGateTier(config, gate, {
2531
+ changedFiles,
2532
+ filesChanged: scope?.filesChanged,
2533
+ linesChanged: scope?.linesChanged,
2534
+ hasFullLabel,
2535
+ });
2536
+ const floors = Object.freeze({
2537
+ sizeCap: dispatch.reason === "over_threshold",
2538
+ riskPath: dispatch.reason === "risk_path_touch",
2539
+ sizeOutcome: typeof dispatch.reason === "string" && dispatch.reason.startsWith("size_outcome_") && dispatch.reason !== "size_outcome_unavailable",
2540
+ ambiguity: dispatch.reason === "changed_files_unavailable" || dispatch.reason === "size_outcome_unavailable",
2541
+ // resolveGateDispatchMode has no diff-classification awareness of its own
2542
+ // (only resolveGateTier classifies files); an unclassifiable diff is
2543
+ // ambiguity too and must not silently reach inline just because the
2544
+ // dispatch-mode facts alone looked trivial.
2545
+ unclassifiable: tier.reason === "unclassifiable_file",
2546
+ });
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.
2555
+ const dispatchFloorFired = floors.riskPath || floors.sizeOutcome || floors.ambiguity;
2556
+ const floored = dispatchFloorFired || floors.unclassifiable;
2557
+ const mode = floored ? "full_fanout" : dispatch.mode;
2558
+ const reason = floored && !dispatchFloorFired ? "unclassifiable_diff" : dispatch.reason;
2559
+ // 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));
2564
+ const groups = resolveFanoutGroups(config, gate, angles ?? [], { fullLabel: hasFullLabel });
2565
+ return Object.freeze({
2566
+ mode,
2567
+ angles,
2568
+ groups,
2569
+ reason,
2570
+ floors,
2571
+ });
2572
+ }
2573
+
2199
2574
  /**
2200
2575
  * Resolve gate angles dynamically when `dynamicAngles` is enabled.
2201
2576
  *
@@ -2209,14 +2584,29 @@ export function resolveGateTier(config, gate, { changedFiles, filesChanged, line
2209
2584
  * that tier's angle set (unioned with mandatory) directly and skips the
2210
2585
  * subtractive/additive machinery.
2211
2586
  *
2587
+ * GATE-EXEC-PROPORTIONALITY floor-awareness (opt-in via `checkFloors`): when
2588
+ * the caller supplies `checkFloors: true` (and, when available, `sizeOutcome`
2589
+ * from check-size-budget.mjs), this delegates to {@link
2590
+ * 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.
2598
+ *
2212
2599
  * @param {import("./types.js").DevLoopConfig} config
2213
2600
  * @param {"draft"|"preApproval"} gate
2214
2601
  * @param {object} [options]
2215
2602
  * @param {{ nameStatusOutput: string, diffOutput?: string }} [options.diff]
2216
2603
  * @param {boolean} [options.hasFullLabel] — `gate:full` label present on the PR (bypasses tier resolution)
2604
+ * @param {boolean} [options.checkFloors] — opt into the GATE-EXEC-PROPORTIONALITY floor check above
2605
+ * @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)
2217
2607
  * @returns {{ recommendedAngles: string[] | null, skippedAngles: string[], reasons: Record<string,string>, fallbackToAll: boolean, dynamicAnglesActive: boolean, addedAngles: string[], addedReasons: Record<string,string> }}
2218
2608
  */
2219
- export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabel = false } = {}) {
2609
+ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabel = false, checkFloors = false, sizeOutcome, explicitAngles } = {}) {
2220
2610
  // Tier scope facts: changedFiles/filesChanged from T0, linesChanged from T1's
2221
2611
  // real added+deleted count (analyzeDiff's inferred-category path reports a
2222
2612
  // fake 0 for an unambiguous docs-only diff — see analyzeT1/analyzeDiff).
@@ -2235,6 +2625,40 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
2235
2625
  linesChanged = lineStats.added + lineStats.deleted;
2236
2626
  }
2237
2627
  }
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)) {
2652
+ return {
2653
+ recommendedAngles: explicitAngles,
2654
+ skippedAngles: [],
2655
+ reasons: {},
2656
+ fallbackToAll: false,
2657
+ dynamicAnglesActive: false,
2658
+ addedAngles: [],
2659
+ addedReasons: {},
2660
+ };
2661
+ }
2238
2662
  const tierResult = resolveGateTier(config, gate, { changedFiles, filesChanged, linesChanged, hasFullLabel });
2239
2663
  if (tierResult.tier) {
2240
2664
  const configuredAngles = resolveGateAngles(config, gate) ?? [];
@@ -2288,6 +2712,10 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
2288
2712
  });
2289
2713
 
2290
2714
  const categories = [...new Set(analysis.t1?.changeCategories ?? [])];
2715
+ // File kinds present in the diff, to honor a consumer angle's `kinds`
2716
+ // binding. classifyFile is the same classifier the categories above derive
2717
+ // from, so this adds no new classification surface.
2718
+ const fileKinds = [...new Set((analysis.t0?.files ?? []).map(classifyFile))];
2291
2719
 
2292
2720
  // excludeAngles is a hard ceiling: computed once and reused both to cap the
2293
2721
  // additive anglePool and to filter mandatoryAngles below.
@@ -2302,6 +2730,8 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
2302
2730
  changeCategories: categories,
2303
2731
  ambiguous: analysis.ambiguous,
2304
2732
  anglePool,
2733
+ angleDeclarations: gateConfig.angleCategoryBindings,
2734
+ fileKinds,
2305
2735
  });
2306
2736
 
2307
2737
  // Merge: mandatory always included (filtered by excludeAngles) + dynamically-selected