@dev-loops/core 1.0.2 → 1.0.3

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.
@@ -7,7 +7,9 @@ import { fileURLToPath } from "node:url";
7
7
  import { z } from "zod";
8
8
  import { classifyFile } from "../analysis/diff-analyzer.mjs";
9
9
  import { isDevLoopConfigSourcePath } from "../loop/gate-carry-forward.mjs";
10
+ import { isClaudeHarness } from "../loop/run-context.mjs";
10
11
  import { trimmedOrNull } from "../loop/normalize.mjs";
12
+ import { matchesDiffExcludeGlob } from "../loop/review-dispatch-plan.mjs";
11
13
 
12
14
  // ============================================================================
13
15
  // Sub-schemas
@@ -425,6 +427,12 @@ const LocalImplementationConfig = z.strictObject({
425
427
  // Composes with (does not replace) refinement.maxCopilotRounds — see
426
428
  // resolveEffectiveCopilotRoundCap.
427
429
  maxCopilotRounds: z.number().int().nonnegative().default(1).describe("Copilot round cap for light-dispatched PRs; composes as min(this, refinement.maxCopilotRounds)."),
430
+ // Purely ADDITIVE on top of the hard-coded RISK_PATH_DENYLIST_DEFAULT floor
431
+ // (resolveGateDispatchMode/touchesRiskPath) — this field can only ADD extra
432
+ // risk-path globs for a repo, never remove/replace the shipped floor, so a
433
+ // layer that sets its own lightMode block (as this repo's .devloops already
434
+ // does for maxFiles/maxLines) can never silently drop the floor.
435
+ 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
436
  }).optional(),
429
437
  /**
430
438
  * Opt into issue-less PR-first at ANY change scope. Decoupled from lightMode:
@@ -1231,15 +1239,18 @@ function mergeAngleArrays(targetRaw, sourceRaw) {
1231
1239
  * @param {string} filePath
1232
1240
  * @returns {Promise<object|null>}
1233
1241
  */
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
-
1242
+ /**
1243
+ * Parse already-read config text (YAML or JSON, keyed off `filePath`'s
1244
+ * extension) into a plain object. Split out of {@link readConfigFile} so a
1245
+ * caller that already has the raw text from somewhere other than this
1246
+ * checkout's disk (e.g. `loadDevLoopConfig`'s `devloopsOverride`, which reads
1247
+ * a PR head commit's `.devloops` via git) can reuse the exact same parsing
1248
+ * rules instead of re-implementing them.
1249
+ * @param {string} raw
1250
+ * @param {string} filePath - used only for its extension and in error messages
1251
+ * @returns {Record<string, unknown>}
1252
+ */
1253
+ function parseConfigContent(raw, filePath) {
1243
1254
  if (raw.trim() === "") {
1244
1255
  throw configError("Config file is empty", "EMPTY_FILE", filePath);
1245
1256
  }
@@ -1274,6 +1285,17 @@ async function readConfigFile(filePath) {
1274
1285
  return parsed;
1275
1286
  }
1276
1287
 
1288
+ async function readConfigFile(filePath) {
1289
+ let raw;
1290
+ try {
1291
+ raw = await readFile(filePath, "utf8");
1292
+ } catch (err) {
1293
+ if (err.code === "ENOENT") return null;
1294
+ throw configError(`Cannot read config file: ${err.message}`, err.code, filePath);
1295
+ }
1296
+ return parseConfigContent(raw, filePath);
1297
+ }
1298
+
1277
1299
  /**
1278
1300
  * Find a config file by trying one or more base names in order.
1279
1301
  * Each base name prefers YAML (.yaml, then .yml) before JSON.
@@ -1355,6 +1377,24 @@ async function applyLayer(merged, basePaths, layer, warnings, errors, options =
1355
1377
  return merged;
1356
1378
  }
1357
1379
 
1380
+ return applyParsedLayer(merged, filePath, data, layer, warnings, errors);
1381
+ }
1382
+
1383
+ /**
1384
+ * Validate + merge one already-parsed config layer's data into `merged`.
1385
+ * Split out of {@link applyLayer} so `loadDevLoopConfig`'s `devloopsOverride`
1386
+ * (a PR head commit's `.devloops`, read via git rather than this checkout's
1387
+ * disk) goes through the exact same deprecation normalization, schema
1388
+ * validation, and merge rules as every disk-sourced layer.
1389
+ * @param {Record<string, unknown>} merged
1390
+ * @param {string} filePath - source path/label, used in warnings/errors only
1391
+ * @param {Record<string, unknown>} data - already-parsed layer content
1392
+ * @param {"extensionDefaults"|"defaults"|"devloops"} layer
1393
+ * @param {string[]} warnings
1394
+ * @param {ConfigLoadError[]} errors
1395
+ * @returns {Record<string, unknown>}
1396
+ */
1397
+ function applyParsedLayer(merged, filePath, data, layer, warnings, errors) {
1358
1398
  // Deprecated `strategy: "github-first"` alias: normalized to
1359
1399
  // "tracker-first" BEFORE this layer's FileConfigSchema validation (the enum
1360
1400
  // only accepts the canonical value, else the whole layer drops as invalid).
@@ -1434,6 +1474,7 @@ async function applyLayer(merged, basePaths, layer, warnings, errors, options =
1434
1474
  * @typedef {object} LoadOptions
1435
1475
  * @property {string} [repoRoot] - Path to repository root (default: process.cwd())
1436
1476
  * @property {string} [extensionDefaultsBasePath] - Base path (no extension) to extension defaults; overrides the package-relative default
1477
+ * @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
1478
  */
1438
1479
 
1439
1480
  /**
@@ -1465,26 +1506,52 @@ export async function loadDevLoopConfig(options = {}) {
1465
1506
  warnOnMissing: true,
1466
1507
  });
1467
1508
 
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") {
1509
+ // `devloopsOverride` sources the devloops (primary override) layer's
1510
+ // content directly instead of reading this checkout's disk file — used to
1511
+ // resolve config from a different ref (e.g. a PR head commit, read via git)
1512
+ // while extensionDefaults and .pi/dev-loop/defaults still come from
1513
+ // repoRoot on disk. Presence of the key (even `{ raw: null }`,
1514
+ // meaning "no .devloops at that ref") switches modes; omitting the option
1515
+ // entirely preserves today's disk-read behavior.
1516
+ if (options.devloopsOverride !== undefined) {
1517
+ const { raw, path: overridePath = devloopsPath } = options.devloopsOverride ?? {};
1518
+ if (typeof raw === "string") {
1519
+ try {
1520
+ const data = parseConfigContent(raw, overridePath);
1521
+ merged = applyParsedLayer(merged, overridePath, data, "devloops", warnings, errors);
1522
+ } catch (err) {
1523
+ errors.push({
1524
+ path: overridePath,
1525
+ message: `${path.basename(overridePath)}: ${err.message}`,
1526
+ layer: "devloops",
1527
+ });
1528
+ }
1529
+ }
1530
+ // raw == null: no .devloops present at the overridden source — leave
1531
+ // `merged` at extensionDefaults+defaults, mirroring primaryExists: false
1532
+ // below.
1533
+ } else {
1534
+ // .devloops (primary override) existence: only ENOENT means genuinely absent.
1535
+ // Any other error (EACCES/EISDIR) means it exists but is unreadable, so
1536
+ // select the .devloops path and let applyLayer record the structured error.
1537
+ let primaryExists = false;
1538
+ for (const ext of ["", ".yaml", ".yml", ".json"]) {
1539
+ try {
1540
+ await readFile(devloopsPath + ext, "utf8");
1479
1541
  primaryExists = true;
1480
1542
  break;
1543
+ } catch (err) {
1544
+ if (err?.code !== "ENOENT") {
1545
+ primaryExists = true;
1546
+ break;
1547
+ }
1548
+ // ENOENT — genuinely absent, try next extension
1481
1549
  }
1482
- // ENOENT — genuinely absent, try next extension
1483
1550
  }
1484
- }
1485
1551
 
1486
- if (primaryExists) {
1487
- merged = await applyLayer(merged, devloopsPath, "devloops", warnings, errors);
1552
+ if (primaryExists) {
1553
+ merged = await applyLayer(merged, devloopsPath, "devloops", warnings, errors);
1554
+ }
1488
1555
  }
1489
1556
 
1490
1557
  // Validate final merged config
@@ -1819,6 +1886,11 @@ export function resolveLightMode(config) {
1819
1886
  maxLines: typeof cfg.maxLines === "number" && Number.isFinite(cfg.maxLines) && cfg.maxLines > 0
1820
1887
  ? cfg.maxLines
1821
1888
  : 200,
1889
+ // Repo-specific ADDITIONS to the risk-path floor (see touchesRiskPath) —
1890
+ // never the floor itself, which is hard-coded and always applied first.
1891
+ riskPaths: Array.isArray(cfg.riskPaths)
1892
+ ? cfg.riskPaths.filter((p) => typeof p === "string" && p.trim().length > 0)
1893
+ : [],
1822
1894
  };
1823
1895
  }
1824
1896
 
@@ -1858,17 +1930,144 @@ export function resolveEffectiveCopilotRoundCap(config, { lightweight = false }
1858
1930
  /** Label that forces full fan-out regardless of change size. */
1859
1931
  export const GATE_FULL_LABEL = "gate:full";
1860
1932
 
1933
+ /**
1934
+ * Conservative, hard-coded risk-path denylist floor (GATE-EXEC-PROPORTIONALITY,
1935
+ * gate-review-sub-loop-contract.md): a diff touching any of these trees forces
1936
+ * full fan-out regardless of size. Hard-coded here — never sourced purely from
1937
+ * `.devloops`/extension-defaults layers — so a config layer that replaces its
1938
+ * own `localImplementation.lightMode` block (as this repo's own `.devloops`
1939
+ * already does for maxFiles/maxLines) can only ADD extra globs
1940
+ * (`lightMode.riskPaths`, unioned in by {@link touchesRiskPath}) and can never
1941
+ * drop this floor. Mirrors `DEFAULT_DIFF_EXCLUDE_GLOBS`'s "shipped default
1942
+ * always applied first, caller can only extend it" pattern
1943
+ * (review-dispatch-plan.mjs). Every glob is deliberately OVER-inclusive per the
1944
+ * "ambiguity resolves toward MORE review" rule — a borderline path SHOULD trip
1945
+ * full fan-out, never quietly pass through:
1946
+ * - gate/review: the dispatch-decision and fan-out/fan-in review-sub-loop
1947
+ * machinery itself — a change here can move the very floor that decides
1948
+ * review depth, so it always gets full review.
1949
+ * - security/auth: any path naming auth/token/secret/credential, plus the
1950
+ * dedicated security-tooling tree.
1951
+ * - contract: normative contract docs and the ADR/test surfaces that back
1952
+ * them.
1953
+ * - hook: repo/CI hook wiring that runs on every commit or tool call.
1954
+ * - release: publish/tag machinery, release CI workflows, and package
1955
+ * publication metadata.
1956
+ * Glob subset (see {@link matchesDiffExcludeGlob}): `**\/` matches
1957
+ * zero-or-more whole path segments, a lone `**` matches any suffix, a single
1958
+ * `*` matches within one path segment only.
1959
+ */
1960
+ export const RISK_PATH_DENYLIST_DEFAULT = Object.freeze([
1961
+ // gate / review — the proportionality mechanism's own implementation, plus
1962
+ // any path named gate/review anywhere under scripts/ or packages/core/src/loop.
1963
+ "packages/core/src/config/config.mjs",
1964
+ "packages/core/src/config/extension-defaults.yaml",
1965
+ "scripts/loop/check-size-budget.mjs",
1966
+ "scripts/loop/check-adr-tripwire.mjs",
1967
+ "scripts/loop/resolve-gate-dispatch.mjs",
1968
+ "scripts/loop/detect-change-scope.mjs",
1969
+ "scripts/github/detect-checkpoint-evidence.mjs",
1970
+ "scripts/github/upsert-checkpoint-verdict.mjs",
1971
+ "scripts/github/emit-fanout-dispatch.mjs",
1972
+ "scripts/loop/consolidate-fanin.mjs",
1973
+ "scripts/**/*gate*",
1974
+ "scripts/**/*review*",
1975
+ "packages/core/src/loop/*gate*",
1976
+ "packages/core/src/loop/*gate*/**",
1977
+ "packages/core/src/loop/*review*",
1978
+ "packages/core/src/loop/*review*/**",
1979
+ "skills/docs/gate-review-*",
1980
+ // security / auth
1981
+ "**/*auth*",
1982
+ "**/*token*",
1983
+ "**/*secret*",
1984
+ "**/*credential*",
1985
+ "scripts/security/**",
1986
+ // contract
1987
+ "skills/docs/*-contract.md",
1988
+ "test/contracts/**",
1989
+ "docs/decisions/**",
1990
+ // hook
1991
+ ".claude/hooks/**",
1992
+ ".githooks/**",
1993
+ "scripts/**/*hook*",
1994
+ // release
1995
+ "scripts/release/**",
1996
+ "scripts/**/*release*",
1997
+ "scripts/**/*publish*",
1998
+ ".github/workflows/*release*",
1999
+ ".github/workflows/*publish*",
2000
+ "package.json",
2001
+ "**/package.json",
2002
+ ]);
2003
+
2004
+ /**
2005
+ * Pure risk-path predicate: does ANY changed file match the shipped
2006
+ * {@link RISK_PATH_DENYLIST_DEFAULT} floor or a repo's additive
2007
+ * `localImplementation.lightMode.riskPaths` globs? Fails CLOSED (returns
2008
+ * `true`) when `changedFiles` is not a readable array — absence of evidence is
2009
+ * never triviality.
2010
+ * @param {unknown} changedFiles — repo-relative paths, or anything non-array (ambiguous)
2011
+ * @param {string[]} [extraDenylist] — additive globs from config; never replaces the floor
2012
+ * @returns {boolean}
2013
+ */
2014
+ export function touchesRiskPath(changedFiles, extraDenylist = []) {
2015
+ if (!Array.isArray(changedFiles)) return true;
2016
+ const denylist = [...RISK_PATH_DENYLIST_DEFAULT, ...(Array.isArray(extraDenylist) ? extraDenylist : [])];
2017
+ return changedFiles.some((f) => {
2018
+ const posix = String(f).replace(/\\/g, "/");
2019
+ return denylist.some((pattern) => matchesDiffExcludeGlob(posix, pattern));
2020
+ });
2021
+ }
2022
+
2023
+ /**
2024
+ * Pure predicate: is a check-size-budget.mjs sizeOutcome genuinely T1-clean —
2025
+ * a `pass` outcome AND a finite, non-negative T1-tier slice equal to 0 (the
2026
+ * clean value)? `undefined > 0` and `NaN > 0` both evaluate false, so a bare
2027
+ * `!(t1 > 0)` comparison would read malformed/absent T1 evidence (a missing
2028
+ * `tierLogicLoc`, a non-numeric `t1`) as clean and admit the light path on
2029
+ * unreadable evidence. This requires a genuine NUMBER, never a truthiness
2030
+ * check, so malformed evidence fails CLOSED exactly like a real
2031
+ * size-budget computation error. The ONE shared predicate for this floor
2032
+ * (GATE-EXEC-PROPORTIONALITY): `resolveGateDispatchMode` below and the
2033
+ * size-budget merge gate (scripts/github/detect-checkpoint-evidence.mjs) both
2034
+ * call it, so they never drift onto two independently-maintained floor
2035
+ * implementations — mirroring how {@link touchesRiskPath} is the one shared
2036
+ * risk-path predicate.
2037
+ * @param {{ outcome?: string, tierLogicLoc?: { t1?: number } }|null|undefined} sizeOutcome
2038
+ * @returns {boolean}
2039
+ */
2040
+ export function isSizeOutcomeT1Clean(sizeOutcome) {
2041
+ if (sizeOutcome == null || typeof sizeOutcome !== "object") return false;
2042
+ if (sizeOutcome.outcome !== "pass") return false;
2043
+ const t1 = sizeOutcome.tierLogicLoc?.t1;
2044
+ return typeof t1 === "number" && Number.isFinite(t1) && t1 === 0;
2045
+ }
2046
+
1861
2047
  /**
1862
2048
  * Decide whether a gate runs as a single-agent inline check or full fan-out,
1863
2049
  * from light-mode config + authoritative PR facts.
1864
2050
  *
1865
2051
  * 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
2052
+ * 1. `gate:full` label present → full_fanout
2053
+ * 2. light mode disabled / no threshold → full_fanout
2054
+ * 3. scope over threshold (files OR lines) → full_fanout
2055
+ * 4. `changedFiles` unavailable (ambiguous) → full_fanout
2056
+ * 5. a changed file touches a risk path → full_fanout
2057
+ * 6. `sizeOutcome` unavailable (ambiguous) → full_fanout
2058
+ * 7. size-outcome escalate/block → full_fanout
2059
+ * 8. size-outcome touches the T1 risk tier → full_fanout
2060
+ * 9. inline finding severity in the gate's blockCleanOnFindingSeverities set
2061
+ * → full_fanout (escalated)
2062
+ * 10. otherwise → inline
2063
+ *
2064
+ * Steps 4-8 are the GATE-EXEC-PROPORTIONALITY non-overridable floors
2065
+ * (gate-review-sub-loop-contract.md): they run only once the cheap file/line
2066
+ * cap (step 3) has already passed, so an already-over-cap diff costs the
2067
+ * caller nothing extra. A caller that omits `changedFiles`/`sizeOutcome` for
2068
+ * an otherwise-under-cap diff fails CLOSED (full fan-out) rather than silently
2069
+ * treating missing evidence as trivial — no flag/waiver/prompt can lower these
2070
+ * floors.
1872
2071
  *
1873
2072
  * Pre-check omits `inlineFindingSeverities` (decides whether to run the inline
1874
2073
  * pass at all); escalation passes the inline pass's severities. Absent/partial
@@ -1878,11 +2077,13 @@ export const GATE_FULL_LABEL = "gate:full";
1878
2077
  * @param {"draft"|"preApproval"} gate
1879
2078
  * @param {object} facts
1880
2079
  * @param {{ filesChanged?: number, linesChanged?: number }} [facts.scope] PR scope; absent/partial fields fail safe to full_fanout
2080
+ * @param {string[]} [facts.changedFiles] repo-relative changed-file paths; absent/non-array fails safe to full_fanout
2081
+ * @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
2082
  * @param {boolean} [facts.hasFullLabel] `gate:full` label present on the PR
1882
2083
  * @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 }}
2084
+ * @returns {{ mode: "inline"|"full_fanout", reason: string, threshold: ({maxFiles:number,maxLines:number,riskPaths:string[]})|null }}
1884
2085
  */
1885
- export function resolveGateDispatchMode(config, gate, { scope, hasFullLabel = false, inlineFindingSeverities } = {}) {
2086
+ export function resolveGateDispatchMode(config, gate, { scope, changedFiles, sizeOutcome, hasFullLabel = false, inlineFindingSeverities } = {}) {
1886
2087
  if (hasFullLabel) {
1887
2088
  return { mode: "full_fanout", reason: "gate_full_label", threshold: null };
1888
2089
  }
@@ -1895,6 +2096,31 @@ export function resolveGateDispatchMode(config, gate, { scope, hasFullLabel = fa
1895
2096
  if (filesChanged > threshold.maxFiles || linesChanged > threshold.maxLines) {
1896
2097
  return { mode: "full_fanout", reason: "over_threshold", threshold };
1897
2098
  }
2099
+ if (!Array.isArray(changedFiles)) {
2100
+ return { mode: "full_fanout", reason: "changed_files_unavailable", threshold };
2101
+ }
2102
+ if (touchesRiskPath(changedFiles, threshold.riskPaths)) {
2103
+ return { mode: "full_fanout", reason: "risk_path_touch", threshold };
2104
+ }
2105
+ if (sizeOutcome == null || typeof sizeOutcome !== "object") {
2106
+ return { mode: "full_fanout", reason: "size_outcome_unavailable", threshold };
2107
+ }
2108
+ if (sizeOutcome.outcome !== "pass") {
2109
+ const outcomeLabel = typeof sizeOutcome.outcome === "string" && sizeOutcome.outcome.length > 0 ? sizeOutcome.outcome : "unknown";
2110
+ return { mode: "full_fanout", reason: `size_outcome_${outcomeLabel}`, threshold };
2111
+ }
2112
+ // GATE-EXEC-PROPORTIONALITY: delegate the pass+T1-clean decision to the one
2113
+ // shared predicate (isSizeOutcomeT1Clean, above) so this resolver and the
2114
+ // size-budget merge gate never drift onto two independently-maintained
2115
+ // floor implementations. A malformed/partial T1 value (missing, NaN,
2116
+ // negative, non-numeric) is ambiguity, not triviality, so it fails CLOSED to
2117
+ // `size_outcome_unavailable` exactly like a missing sizeOutcome altogether —
2118
+ // never a naive `t1 > 0` truthiness read.
2119
+ if (!isSizeOutcomeT1Clean(sizeOutcome)) {
2120
+ const t1 = sizeOutcome.tierLogicLoc?.t1;
2121
+ const reason = typeof t1 === "number" && Number.isFinite(t1) && t1 > 0 ? "size_outcome_t1" : "size_outcome_unavailable";
2122
+ return { mode: "full_fanout", reason, threshold };
2123
+ }
1898
2124
  if (Array.isArray(inlineFindingSeverities) && inlineFindingSeverities.length > 0) {
1899
2125
  // Both sides normalize legacy spellings so a "defer" finding still
1900
2126
  // compares against a "low" blocking entry and vice versa.
@@ -1933,15 +2159,31 @@ export function resolveFanoutSequential(config) {
1933
2159
  return s === true;
1934
2160
  }
1935
2161
 
2162
+ /**
2163
+ * Claude-harness-scoped cap on effective fan-out concurrency (per ADR
2164
+ * docs/decisions/0069-claude-harness-fanout-concurrency-clamp.md). The
2165
+ * shipped cross-harness `gates.fanout.maxConcurrent` default (4) plus the
2166
+ * driver's own call still 429s a single-driver Claude session; other
2167
+ * harnesses (pi, unknown) are unaffected — see `resolveFanoutEffectiveConcurrency`.
2168
+ */
2169
+ export const CLAUDE_MAX_EFFECTIVE_CONCURRENT = 2;
2170
+
1936
2171
  /**
1937
2172
  * Resolve the effective fan-out concurrency (dispatch units per wave): 1 when
1938
- * `gates.fanout.sequential` is set, else `resolveFanoutMaxConcurrent`.
2173
+ * `gates.fanout.sequential` is set, else `resolveFanoutMaxConcurrent`. Under the
2174
+ * Claude harness (`isClaudeHarness(env)`) that value is additionally
2175
+ * clamped to `CLAUDE_MAX_EFFECTIVE_CONCURRENT` so a single-driver Claude
2176
+ * session's per-wave burst (driver + dispatch units) stays within its rate
2177
+ * limit without lowering the shipped cross-harness default (schema/config
2178
+ * surface unchanged) or requiring an operator-imposed throttle. Every other
2179
+ * harness (pi, unknown, no env) returns the configured value unchanged.
1939
2180
  * @param {DevLoopConfig} config
2181
+ * @param {Record<string, string|undefined>} [env] — defaults to `process.env`
1940
2182
  * @returns {number}
1941
2183
  */
1942
- export function resolveFanoutEffectiveConcurrency(config) {
1943
- if (resolveFanoutSequential(config)) return 1;
1944
- return resolveFanoutMaxConcurrent(config);
2184
+ export function resolveFanoutEffectiveConcurrency(config, env = process.env) {
2185
+ const base = resolveFanoutSequential(config) ? 1 : resolveFanoutMaxConcurrent(config);
2186
+ return isClaudeHarness(env) ? Math.min(base, CLAUDE_MAX_EFFECTIVE_CONCURRENT) : base;
1945
2187
  }
1946
2188
 
1947
2189
  /**
@@ -2196,6 +2438,95 @@ export function resolveGateTier(config, gate, { changedFiles, filesChanged, line
2196
2438
  return { tier: matched.name, angles: [...new Set([...mandatoryAngles, ...matched.angles])], reason: "tier_match" };
2197
2439
  }
2198
2440
 
2441
+ /**
2442
+ * The primer-owned deterministic review-proportionality plan
2443
+ * (GATE-EXEC-PROPORTIONALITY, gate-review-sub-loop-contract.md): a single,
2444
+ * pure composition of the existing decision functions so "the plan" (angle
2445
+ * set + execution mode + grouping) is one testable, persistable object.
2446
+ * Delegates entirely to {@link resolveGateDispatchMode} (mode, including the
2447
+ * non-overridable size-cap/risk-path/size-outcome/ambiguity floors),
2448
+ * {@link resolveGateTier} (angle set AND diff-classification), and
2449
+ * {@link resolveFanoutGroups} (dispatch-unit grouping). No git I/O, no logic
2450
+ * of its own beyond the floor-vs-tier precedence below: this is the ONE place
2451
+ * the primer (emit) and the merge gate (re-verify) compose mode + angles +
2452
+ * grouping, so they can never drift onto two different floor implementations.
2453
+ *
2454
+ * Floor-vs-tier precedence: a fired RISK-signal floor — the risk-path
2455
+ * denylist (`risk_path_touch`), a non-clean/ambiguous size-budget outcome
2456
+ * (`size_outcome_*`, `size_outcome_unavailable`), missing changed-file
2457
+ * evidence (`changed_files_unavailable`), or an unclassifiable diff
2458
+ * (`resolveGateTier`'s `unclassifiable_file`) — ALWAYS forces `full_fanout`
2459
+ * with the FULL untriered angle pool, never a matched tier's reduced set. The
2460
+ * hard size cap (`over_threshold`) differs: it ALWAYS forces `full_fanout`
2461
+ * MODE (distinct-reviewer-per-angle, never the light single-combined path)
2462
+ * but does NOT force the full untriered pool — a merely-over-cap-but-tier-
2463
+ * classifiable diff keeps its diff-class-tier-reduced angle set (the
2464
+ * pre-existing, orthogonal mechanism), untouched for a `gate:full`-labelled
2465
+ * PR (resolveGateTier self-bypasses) or a repo with light mode disabled
2466
+ * (`light_mode_disabled` is not a floor).
2467
+ *
2468
+ * @param {DevLoopConfig} config
2469
+ * @param {"draft"|"preApproval"} gate
2470
+ * @param {object} facts
2471
+ * @param {{ filesChanged?: number, linesChanged?: number }} [facts.scope]
2472
+ * @param {string[]} [facts.changedFiles]
2473
+ * @param {{ outcome?: "pass"|"escalate"|"block", tierLogicLoc?: { t1?: number } }|null} [facts.sizeOutcome]
2474
+ * @param {boolean} [facts.hasFullLabel]
2475
+ * @param {string[]} [facts.inlineFindingSeverities]
2476
+ * @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 } }}
2477
+ */
2478
+ export function resolveReviewProportionality(config, gate, {
2479
+ scope,
2480
+ changedFiles,
2481
+ sizeOutcome,
2482
+ hasFullLabel = false,
2483
+ inlineFindingSeverities,
2484
+ } = {}) {
2485
+ const dispatch = resolveGateDispatchMode(config, gate, { scope, changedFiles, sizeOutcome, hasFullLabel, inlineFindingSeverities });
2486
+ const tier = resolveGateTier(config, gate, {
2487
+ changedFiles,
2488
+ filesChanged: scope?.filesChanged,
2489
+ linesChanged: scope?.linesChanged,
2490
+ hasFullLabel,
2491
+ });
2492
+ const floors = Object.freeze({
2493
+ sizeCap: dispatch.reason === "over_threshold",
2494
+ riskPath: dispatch.reason === "risk_path_touch",
2495
+ sizeOutcome: typeof dispatch.reason === "string" && dispatch.reason.startsWith("size_outcome_") && dispatch.reason !== "size_outcome_unavailable",
2496
+ ambiguity: dispatch.reason === "changed_files_unavailable" || dispatch.reason === "size_outcome_unavailable",
2497
+ // resolveGateDispatchMode has no diff-classification awareness of its own
2498
+ // (only resolveGateTier classifies files); an unclassifiable diff is
2499
+ // ambiguity too and must not silently reach inline just because the
2500
+ // dispatch-mode facts alone looked trivial.
2501
+ unclassifiable: tier.reason === "unclassifiable_file",
2502
+ });
2503
+ // sizeCap (over_threshold) is deliberately EXCLUDED from the forced-full-
2504
+ // pool set: it predates this change's risk/ambiguity floors and pre-existing
2505
+ // behavior (the diff-class-tier mechanism) keeps a merely-over-the-tiny-
2506
+ // inline-cap-but-still-tier-classifiable diff on its reduced tier set — see
2507
+ // resolveGateTier's "small non-risky diff outside the inline cap but
2508
+ // matching a tier" contract. Only a genuine RISK signal (a risk-path touch,
2509
+ // a non-clean/ambiguous size-budget outcome, or an unclassifiable diff)
2510
+ // forces the full untriered pool.
2511
+ const dispatchFloorFired = floors.riskPath || floors.sizeOutcome || floors.ambiguity;
2512
+ const floored = dispatchFloorFired || floors.unclassifiable;
2513
+ const mode = floored ? "full_fanout" : dispatch.mode;
2514
+ const reason = floored && !dispatchFloorFired ? "unclassifiable_diff" : dispatch.reason;
2515
+ // The mandatory-angle floor is present either way: a tier match already
2516
+ // unions mandatoryAngles in (resolveGateTier), and the no-tier fallback
2517
+ // (resolveGateAngles) does the same union — see AC-4 "mandatory angles
2518
+ // combined, never dropped".
2519
+ const angles = floored ? resolveGateAngles(config, gate) : (tier.angles ?? resolveGateAngles(config, gate));
2520
+ const groups = resolveFanoutGroups(config, gate, angles ?? [], { fullLabel: hasFullLabel });
2521
+ return Object.freeze({
2522
+ mode,
2523
+ angles,
2524
+ groups,
2525
+ reason,
2526
+ floors,
2527
+ });
2528
+ }
2529
+
2199
2530
  /**
2200
2531
  * Resolve gate angles dynamically when `dynamicAngles` is enabled.
2201
2532
  *
@@ -2209,14 +2540,29 @@ export function resolveGateTier(config, gate, { changedFiles, filesChanged, line
2209
2540
  * that tier's angle set (unioned with mandatory) directly and skips the
2210
2541
  * subtractive/additive machinery.
2211
2542
  *
2543
+ * GATE-EXEC-PROPORTIONALITY floor-awareness (opt-in via `checkFloors`): when
2544
+ * the caller supplies `checkFloors: true` (and, when available, `sizeOutcome`
2545
+ * from check-size-budget.mjs), this delegates to {@link
2546
+ * resolveReviewProportionality} — the SAME composer resolve-gate-dispatch.mjs
2547
+ * uses — over the SAME diff-derived changed-file/scope facts, so a diff whose
2548
+ * dispatch decision is floored (risk-path touch, a non-clean/ambiguous
2549
+ * size-budget outcome, or an unclassifiable diff) NEVER keeps a tier's
2550
+ * reduced (or dynamically-pruned) angle set here: it gets the full untriered
2551
+ * pool, exactly like the primer's own dispatch-decision step. Omitted
2552
+ * (default), this resolves exactly as before — a caller that does not have
2553
+ * size-budget evidence to hand is unaffected.
2554
+ *
2212
2555
  * @param {import("./types.js").DevLoopConfig} config
2213
2556
  * @param {"draft"|"preApproval"} gate
2214
2557
  * @param {object} [options]
2215
2558
  * @param {{ nameStatusOutput: string, diffOutput?: string }} [options.diff]
2216
2559
  * @param {boolean} [options.hasFullLabel] — `gate:full` label present on the PR (bypasses tier resolution)
2560
+ * @param {boolean} [options.checkFloors] — opt into the GATE-EXEC-PROPORTIONALITY floor check above
2561
+ * @param {{ outcome?: "pass"|"escalate"|"block", tierLogicLoc?: { t1?: number } }|null} [options.sizeOutcome] — only consulted when `checkFloors` is true
2562
+ * @param {string[]} [options.explicitAngles] — caller-supplied verbatim override (e.g. CLI `--angles`); wins over tier/dynamic resolution but NEVER over a fired floor above (a fired floor's full pool, mandatory angles included via resolveGateAngles, is returned instead)
2217
2563
  * @returns {{ recommendedAngles: string[] | null, skippedAngles: string[], reasons: Record<string,string>, fallbackToAll: boolean, dynamicAnglesActive: boolean, addedAngles: string[], addedReasons: Record<string,string> }}
2218
2564
  */
2219
- export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabel = false } = {}) {
2565
+ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabel = false, checkFloors = false, sizeOutcome, explicitAngles } = {}) {
2220
2566
  // Tier scope facts: changedFiles/filesChanged from T0, linesChanged from T1's
2221
2567
  // real added+deleted count (analyzeDiff's inferred-category path reports a
2222
2568
  // fake 0 for an unambiguous docs-only diff — see analyzeT1/analyzeDiff).
@@ -2235,6 +2581,40 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
2235
2581
  linesChanged = lineStats.added + lineStats.deleted;
2236
2582
  }
2237
2583
  }
2584
+ if (checkFloors) {
2585
+ const plan = resolveReviewProportionality(config, gate, {
2586
+ scope: { filesChanged, linesChanged },
2587
+ changedFiles,
2588
+ sizeOutcome,
2589
+ hasFullLabel,
2590
+ });
2591
+ if (plan.floors.riskPath || plan.floors.sizeOutcome || plan.floors.ambiguity || plan.floors.unclassifiable) {
2592
+ return {
2593
+ recommendedAngles: plan.angles ?? [],
2594
+ skippedAngles: [],
2595
+ reasons: {},
2596
+ fallbackToAll: false,
2597
+ dynamicAnglesActive: false,
2598
+ addedAngles: [],
2599
+ addedReasons: {},
2600
+ };
2601
+ }
2602
+ }
2603
+ // A fired floor above always wins (its full pool already includes the
2604
+ // mandatory floor via resolveGateAngles) — an explicit --angles override is
2605
+ // only honored once no floor fired, matching its documented "verbatim,
2606
+ // dynamic resolution bypassed" contract.
2607
+ if (Array.isArray(explicitAngles)) {
2608
+ return {
2609
+ recommendedAngles: explicitAngles,
2610
+ skippedAngles: [],
2611
+ reasons: {},
2612
+ fallbackToAll: false,
2613
+ dynamicAnglesActive: false,
2614
+ addedAngles: [],
2615
+ addedReasons: {},
2616
+ };
2617
+ }
2238
2618
  const tierResult = resolveGateTier(config, gate, { changedFiles, filesChanged, linesChanged, hasFullLabel });
2239
2619
  if (tierResult.tier) {
2240
2620
  const configuredAngles = resolveGateAngles(config, gate) ?? [];
@@ -10,6 +10,22 @@ import { trimmedOrNull } from "../loop/normalize.mjs";
10
10
  // acting on the gate's behalf must agree with the gate about what a submitted
11
11
  // review is.
12
12
  export const SUBMITTED_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"]);
13
+
14
+ // Copilot's COMMENTED review summary opens with a disposition header whose
15
+ // emoji is the authoritative signal: "### 🟡 Changes recommended" (findings)
16
+ // vs "### 🟢 Approval recommended" (clean). Keying on the 🟡 marker means a
17
+ // clean body that merely quotes the phrase "changes recommended" — with or
18
+ // without markdown emphasis ("No **changes recommended**", "No _changes
19
+ // recommended_") — is never a false finding. The strong no-emoji signal is
20
+ // already covered by the CHANGES_REQUESTED state.
21
+ const COPILOT_CHANGES_RECOMMENDED_MARKER = "🟡";
22
+
23
+ export function copilotReviewBodySignalsChanges(state, body) {
24
+ const normalizedState = typeof state === "string" ? state.toUpperCase() : "";
25
+ if (normalizedState === "CHANGES_REQUESTED") return true;
26
+ if (normalizedState !== "COMMENTED") return false;
27
+ return typeof body === "string" && body.includes(COPILOT_CHANGES_RECOMMENDED_MARKER);
28
+ }
13
29
  const GATE_REVIEW_NAMES = new Set(["draft_gate", "pre_approval_gate"]);
14
30
  // `review` is a RECOGNIZED gate header that carries no draft/pre-approval
15
31
  // evidence by design. Recognizing it lets
@@ -700,6 +716,7 @@ export function summarizeCopilotReviews(reviews, { headSha, draftGateResetAtMs }
700
716
  let hasPendingReviewOnCurrentHead = false;
701
717
  let hasSubmittedReviewOnCurrentHead = false;
702
718
  let latestSubmittedReviewOnCurrentHeadAt = null;
719
+ let hasBodyFindingOnCurrentHead = false;
703
720
  let completedCopilotReviewRounds = 0;
704
721
 
705
722
  for (const review of effectiveReviews) {
@@ -722,9 +739,18 @@ export function summarizeCopilotReviews(reviews, { headSha, draftGateResetAtMs }
722
739
 
723
740
  if (SUBMITTED_REVIEW_STATES.has(state)) {
724
741
  hasSubmittedReviewOnCurrentHead = true;
725
- const submittedAt = typeof review?.submittedAt === "string" ? review.submittedAt : null;
742
+ const submittedAt = typeof review?.submittedAt === "string"
743
+ ? review.submittedAt
744
+ : (typeof review?.submitted_at === "string" ? review.submitted_at : null);
726
745
  if (submittedAt !== null && (latestSubmittedReviewOnCurrentHeadAt === null || submittedAt > latestSubmittedReviewOnCurrentHeadAt)) {
727
746
  latestSubmittedReviewOnCurrentHeadAt = submittedAt;
747
+ hasBodyFindingOnCurrentHead = copilotReviewBodySignalsChanges(state, review?.body);
748
+ } else if (submittedAt !== null && submittedAt === latestSubmittedReviewOnCurrentHeadAt) {
749
+ // Equal-timestamp tie on the same head: fail toward surfacing so array
750
+ // order never silently drops a finding when two reviews share a timestamp.
751
+ hasBodyFindingOnCurrentHead = hasBodyFindingOnCurrentHead || copilotReviewBodySignalsChanges(state, review?.body);
752
+ } else if (submittedAt === null && latestSubmittedReviewOnCurrentHeadAt === null) {
753
+ hasBodyFindingOnCurrentHead = hasBodyFindingOnCurrentHead || copilotReviewBodySignalsChanges(state, review?.body);
728
754
  }
729
755
  }
730
756
  }
@@ -740,5 +766,6 @@ export function summarizeCopilotReviews(reviews, { headSha, draftGateResetAtMs }
740
766
  hasPendingReviewOnCurrentHead,
741
767
  hasSubmittedReviewOnCurrentHead,
742
768
  latestSubmittedReviewOnCurrentHeadAt,
769
+ hasBodyFindingOnCurrentHead,
743
770
  };
744
771
  }