@dev-loops/core 1.0.2-slim.0 → 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.
- package/package.json +11 -1
- package/src/claude/asset-generation.mjs +64 -3
- package/src/claude/hook-decisions.mjs +97 -48
- package/src/config/config.mjs +439 -37
- package/src/config/extension-defaults.yaml +17 -0
- package/src/github/closing-ref-guard.mjs +80 -0
- package/src/github/copilot-helpers.mjs +28 -1
- package/src/github/issue-ops.mjs +4 -0
- package/src/github/repo-slug.mjs +25 -4
- package/src/github/test-mode-write-guard.mjs +81 -0
- package/src/loop/bash-command-classify.mjs +145 -28
- package/src/loop/child-launch-bound.mjs +152 -0
- package/src/loop/copilot-loop-state.mjs +20 -4
- package/src/loop/execution-record.mjs +412 -0
- package/src/loop/finding-cluster.mjs +277 -0
- package/src/loop/fixer-disposition.mjs +200 -0
- package/src/loop/gate-fanin.mjs +45 -0
- package/src/loop/handoff-envelope.mjs +113 -6
- package/src/loop/issue-refinement-artifact.mjs +30 -11
- package/src/loop/merge-approval.mjs +283 -0
- package/src/loop/pr-gate-coordination.mjs +49 -0
- package/src/loop/queue-board-sync.mjs +6 -3
- package/src/loop/retrospective-checkpoint.mjs +7 -8
- package/src/loop/reviewer-unit-bound.mjs +308 -0
- package/src/loop/role-budget-bound.mjs +242 -0
- package/src/loop/size-budget-merge-gate.mjs +48 -12
- package/src/loop/watcher-exclusivity.mjs +302 -0
- package/src/loop/worktree-guard.mjs +80 -13
- package/src/security/secret-scan.mjs +13 -0
package/src/config/config.mjs
CHANGED
|
@@ -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:
|
|
@@ -447,12 +455,34 @@ function boardRefConfig(ownerKey) {
|
|
|
447
455
|
});
|
|
448
456
|
}
|
|
449
457
|
|
|
458
|
+
/**
|
|
459
|
+
* Logical board columns the queue status-column config recognizes. Mirrors
|
|
460
|
+
* LOGICAL_COLUMN in loop/queue-board-sync.mjs; kept inline (a frozen 4-value
|
|
461
|
+
* list) so this low-level config-schema module does not depend on
|
|
462
|
+
* queue-board-sync, which pulls in the projects/GitHub-access modules through
|
|
463
|
+
* its own imports. The two lists are pinned in lockstep by the schema test.
|
|
464
|
+
*/
|
|
465
|
+
const QueueLogicalColumn = z.enum(["next_up", "in_progress", "ready_for_review", "done"]);
|
|
466
|
+
|
|
450
467
|
/** Queue mode config */
|
|
451
468
|
const QueueConfig = z.strictObject({
|
|
452
469
|
maxParallel: z.number().int().min(1).max(10).default(3).describe("Maximum queue items worked in parallel."),
|
|
453
470
|
maxAutoFiledIssues: z.number().int().min(0).max(100).default(10).describe("Cap on auto-filed issues per run."),
|
|
454
471
|
reDispatchMaxRetries: z.number().int().min(0).max(10).default(1).describe("Retries when re-dispatching a failed queue item."),
|
|
455
472
|
archiveOlderThanDays: z.number().int().positive().describe("Archive done board items older than this many days.").optional(),
|
|
473
|
+
statusColumns: z
|
|
474
|
+
.strictObject({
|
|
475
|
+
next_up: z.string().trim().min(1).optional(),
|
|
476
|
+
in_progress: z.string().trim().min(1).optional(),
|
|
477
|
+
ready_for_review: z.string().trim().min(1).optional(),
|
|
478
|
+
done: z.string().trim().min(1).optional(),
|
|
479
|
+
})
|
|
480
|
+
.describe("Logical-column -> board display-name overrides. Consumed by loadStateColumnMap (loop/queue-board-sync.mjs).")
|
|
481
|
+
.optional(),
|
|
482
|
+
stateColumnMap: z
|
|
483
|
+
.record(z.string().trim().min(1), QueueLogicalColumn)
|
|
484
|
+
.describe("Loop-state -> known logical column overrides. Consumed by loadStateColumnMap (loop/queue-board-sync.mjs).")
|
|
485
|
+
.optional(),
|
|
456
486
|
});
|
|
457
487
|
|
|
458
488
|
/**
|
|
@@ -1209,15 +1239,18 @@ function mergeAngleArrays(targetRaw, sourceRaw) {
|
|
|
1209
1239
|
* @param {string} filePath
|
|
1210
1240
|
* @returns {Promise<object|null>}
|
|
1211
1241
|
*/
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
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) {
|
|
1221
1254
|
if (raw.trim() === "") {
|
|
1222
1255
|
throw configError("Config file is empty", "EMPTY_FILE", filePath);
|
|
1223
1256
|
}
|
|
@@ -1252,6 +1285,17 @@ async function readConfigFile(filePath) {
|
|
|
1252
1285
|
return parsed;
|
|
1253
1286
|
}
|
|
1254
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
|
+
|
|
1255
1299
|
/**
|
|
1256
1300
|
* Find a config file by trying one or more base names in order.
|
|
1257
1301
|
* Each base name prefers YAML (.yaml, then .yml) before JSON.
|
|
@@ -1333,6 +1377,24 @@ async function applyLayer(merged, basePaths, layer, warnings, errors, options =
|
|
|
1333
1377
|
return merged;
|
|
1334
1378
|
}
|
|
1335
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) {
|
|
1336
1398
|
// Deprecated `strategy: "github-first"` alias: normalized to
|
|
1337
1399
|
// "tracker-first" BEFORE this layer's FileConfigSchema validation (the enum
|
|
1338
1400
|
// only accepts the canonical value, else the whole layer drops as invalid).
|
|
@@ -1412,6 +1474,7 @@ async function applyLayer(merged, basePaths, layer, warnings, errors, options =
|
|
|
1412
1474
|
* @typedef {object} LoadOptions
|
|
1413
1475
|
* @property {string} [repoRoot] - Path to repository root (default: process.cwd())
|
|
1414
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)
|
|
1415
1478
|
*/
|
|
1416
1479
|
|
|
1417
1480
|
/**
|
|
@@ -1443,26 +1506,52 @@ export async function loadDevLoopConfig(options = {}) {
|
|
|
1443
1506
|
warnOnMissing: true,
|
|
1444
1507
|
});
|
|
1445
1508
|
|
|
1446
|
-
//
|
|
1447
|
-
//
|
|
1448
|
-
//
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
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");
|
|
1457
1541
|
primaryExists = true;
|
|
1458
1542
|
break;
|
|
1543
|
+
} catch (err) {
|
|
1544
|
+
if (err?.code !== "ENOENT") {
|
|
1545
|
+
primaryExists = true;
|
|
1546
|
+
break;
|
|
1547
|
+
}
|
|
1548
|
+
// ENOENT — genuinely absent, try next extension
|
|
1459
1549
|
}
|
|
1460
|
-
// ENOENT — genuinely absent, try next extension
|
|
1461
1550
|
}
|
|
1462
|
-
}
|
|
1463
1551
|
|
|
1464
|
-
|
|
1465
|
-
|
|
1552
|
+
if (primaryExists) {
|
|
1553
|
+
merged = await applyLayer(merged, devloopsPath, "devloops", warnings, errors);
|
|
1554
|
+
}
|
|
1466
1555
|
}
|
|
1467
1556
|
|
|
1468
1557
|
// Validate final merged config
|
|
@@ -1797,6 +1886,11 @@ export function resolveLightMode(config) {
|
|
|
1797
1886
|
maxLines: typeof cfg.maxLines === "number" && Number.isFinite(cfg.maxLines) && cfg.maxLines > 0
|
|
1798
1887
|
? cfg.maxLines
|
|
1799
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
|
+
: [],
|
|
1800
1894
|
};
|
|
1801
1895
|
}
|
|
1802
1896
|
|
|
@@ -1836,17 +1930,144 @@ export function resolveEffectiveCopilotRoundCap(config, { lightweight = false }
|
|
|
1836
1930
|
/** Label that forces full fan-out regardless of change size. */
|
|
1837
1931
|
export const GATE_FULL_LABEL = "gate:full";
|
|
1838
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
|
+
|
|
1839
2047
|
/**
|
|
1840
2048
|
* Decide whether a gate runs as a single-agent inline check or full fan-out,
|
|
1841
2049
|
* from light-mode config + authoritative PR facts.
|
|
1842
2050
|
*
|
|
1843
2051
|
* Precedence (first match wins):
|
|
1844
|
-
* 1. `gate:full` label present
|
|
1845
|
-
* 2. light mode disabled / no threshold
|
|
1846
|
-
* 3. scope over threshold (files OR lines)
|
|
1847
|
-
* 4.
|
|
1848
|
-
*
|
|
1849
|
-
*
|
|
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.
|
|
1850
2071
|
*
|
|
1851
2072
|
* Pre-check omits `inlineFindingSeverities` (decides whether to run the inline
|
|
1852
2073
|
* pass at all); escalation passes the inline pass's severities. Absent/partial
|
|
@@ -1856,11 +2077,13 @@ export const GATE_FULL_LABEL = "gate:full";
|
|
|
1856
2077
|
* @param {"draft"|"preApproval"} gate
|
|
1857
2078
|
* @param {object} facts
|
|
1858
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
|
|
1859
2082
|
* @param {boolean} [facts.hasFullLabel] `gate:full` label present on the PR
|
|
1860
2083
|
* @param {string[]} [facts.inlineFindingSeverities] severities from the inline pass (escalation phase)
|
|
1861
|
-
* @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 }}
|
|
1862
2085
|
*/
|
|
1863
|
-
export function resolveGateDispatchMode(config, gate, { scope, hasFullLabel = false, inlineFindingSeverities } = {}) {
|
|
2086
|
+
export function resolveGateDispatchMode(config, gate, { scope, changedFiles, sizeOutcome, hasFullLabel = false, inlineFindingSeverities } = {}) {
|
|
1864
2087
|
if (hasFullLabel) {
|
|
1865
2088
|
return { mode: "full_fanout", reason: "gate_full_label", threshold: null };
|
|
1866
2089
|
}
|
|
@@ -1873,6 +2096,31 @@ export function resolveGateDispatchMode(config, gate, { scope, hasFullLabel = fa
|
|
|
1873
2096
|
if (filesChanged > threshold.maxFiles || linesChanged > threshold.maxLines) {
|
|
1874
2097
|
return { mode: "full_fanout", reason: "over_threshold", threshold };
|
|
1875
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
|
+
}
|
|
1876
2124
|
if (Array.isArray(inlineFindingSeverities) && inlineFindingSeverities.length > 0) {
|
|
1877
2125
|
// Both sides normalize legacy spellings so a "defer" finding still
|
|
1878
2126
|
// compares against a "low" blocking entry and vice versa.
|
|
@@ -1911,15 +2159,31 @@ export function resolveFanoutSequential(config) {
|
|
|
1911
2159
|
return s === true;
|
|
1912
2160
|
}
|
|
1913
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
|
+
|
|
1914
2171
|
/**
|
|
1915
2172
|
* Resolve the effective fan-out concurrency (dispatch units per wave): 1 when
|
|
1916
|
-
* `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.
|
|
1917
2180
|
* @param {DevLoopConfig} config
|
|
2181
|
+
* @param {Record<string, string|undefined>} [env] — defaults to `process.env`
|
|
1918
2182
|
* @returns {number}
|
|
1919
2183
|
*/
|
|
1920
|
-
export function resolveFanoutEffectiveConcurrency(config) {
|
|
1921
|
-
|
|
1922
|
-
return
|
|
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;
|
|
1923
2187
|
}
|
|
1924
2188
|
|
|
1925
2189
|
/**
|
|
@@ -2174,6 +2438,95 @@ export function resolveGateTier(config, gate, { changedFiles, filesChanged, line
|
|
|
2174
2438
|
return { tier: matched.name, angles: [...new Set([...mandatoryAngles, ...matched.angles])], reason: "tier_match" };
|
|
2175
2439
|
}
|
|
2176
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
|
+
|
|
2177
2530
|
/**
|
|
2178
2531
|
* Resolve gate angles dynamically when `dynamicAngles` is enabled.
|
|
2179
2532
|
*
|
|
@@ -2187,14 +2540,29 @@ export function resolveGateTier(config, gate, { changedFiles, filesChanged, line
|
|
|
2187
2540
|
* that tier's angle set (unioned with mandatory) directly and skips the
|
|
2188
2541
|
* subtractive/additive machinery.
|
|
2189
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
|
+
*
|
|
2190
2555
|
* @param {import("./types.js").DevLoopConfig} config
|
|
2191
2556
|
* @param {"draft"|"preApproval"} gate
|
|
2192
2557
|
* @param {object} [options]
|
|
2193
2558
|
* @param {{ nameStatusOutput: string, diffOutput?: string }} [options.diff]
|
|
2194
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)
|
|
2195
2563
|
* @returns {{ recommendedAngles: string[] | null, skippedAngles: string[], reasons: Record<string,string>, fallbackToAll: boolean, dynamicAnglesActive: boolean, addedAngles: string[], addedReasons: Record<string,string> }}
|
|
2196
2564
|
*/
|
|
2197
|
-
export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabel = false } = {}) {
|
|
2565
|
+
export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabel = false, checkFloors = false, sizeOutcome, explicitAngles } = {}) {
|
|
2198
2566
|
// Tier scope facts: changedFiles/filesChanged from T0, linesChanged from T1's
|
|
2199
2567
|
// real added+deleted count (analyzeDiff's inferred-category path reports a
|
|
2200
2568
|
// fake 0 for an unambiguous docs-only diff — see analyzeT1/analyzeDiff).
|
|
@@ -2213,6 +2581,40 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
|
|
|
2213
2581
|
linesChanged = lineStats.added + lineStats.deleted;
|
|
2214
2582
|
}
|
|
2215
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
|
+
}
|
|
2216
2618
|
const tierResult = resolveGateTier(config, gate, { changedFiles, filesChanged, linesChanged, hasFullLabel });
|
|
2217
2619
|
if (tierResult.tier) {
|
|
2218
2620
|
const configuredAngles = resolveGateAngles(config, gate) ?? [];
|
|
@@ -195,6 +195,17 @@ gates:
|
|
|
195
195
|
fanout:
|
|
196
196
|
maxAnglesPerGroup: 3
|
|
197
197
|
maxConcurrent: 4
|
|
198
|
+
# The table is global; a group is only emitted for a gate that actually
|
|
199
|
+
# resolves at least one of its angles, so a group naming preApproval-only
|
|
200
|
+
# angles is inert for the draft/spike gates (their angle sets never include
|
|
201
|
+
# these), and vice versa. The first four groups name draft-gate surfaces;
|
|
202
|
+
# the design-* and finalization groups name preApproval-exclusive angles so
|
|
203
|
+
# a grouped preApproval round collapses to one reviewer per group instead of
|
|
204
|
+
# scattering those angles across arbitrary auto-chunked leftover units.
|
|
205
|
+
# finalization names only correctness-final/ui-validation: contradiction-lens
|
|
206
|
+
# is deliberately NOT grouped here because it is also a draft-gate angle, and
|
|
207
|
+
# a global group naming it would peel it into a finalization unit in the draft
|
|
208
|
+
# gate too. It stays an auto-chunk leftover in both gates.
|
|
198
209
|
groups:
|
|
199
210
|
- name: docs-surface
|
|
200
211
|
angles: [docs, link-check, config-drift, contract-surface]
|
|
@@ -204,6 +215,12 @@ gates:
|
|
|
204
215
|
angles: [correctness, input-validation]
|
|
205
216
|
- name: determinism-state
|
|
206
217
|
angles: [determinism, state-concurrency]
|
|
218
|
+
- name: design-simplicity
|
|
219
|
+
angles: [dry, kiss, yagni, deep]
|
|
220
|
+
- name: design-solid
|
|
221
|
+
angles: [srp, soc, ocp, lsp, isp, dip]
|
|
222
|
+
- name: finalization
|
|
223
|
+
angles: [correctness-final, ui-validation]
|
|
207
224
|
preApproval:
|
|
208
225
|
angles:
|
|
209
226
|
- name: dry
|