@dev-loops/core 1.0.4-pre.0 → 1.0.4-pre.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/analysis/change-classifier.mjs +16 -30
- package/src/analysis/diff-analyzer.mjs +38 -15
- package/src/claude/asset-generation.mjs +36 -1
- package/src/config/config.mjs +143 -81
- package/src/config/extension-defaults.yaml +18 -6
- package/src/github/copilot-helpers.mjs +22 -8
- package/src/github/gh.mjs +14 -1
- package/src/github/review-threads.mjs +6 -0
- package/src/loop/copilot-loop-state.mjs +38 -2
- package/src/loop/finding-cluster.mjs +9 -9
- package/src/loop/gate-fanin.mjs +21 -0
- package/src/loop/main-checkout-ff.mjs +169 -17
- package/src/loop/merge-approval.mjs +87 -22
- package/src/loop/pr-gate-coordination.mjs +270 -60
- package/src/loop/spec-authority.mjs +21 -0
- package/src/projects/move-queue-item.mjs +5 -79
- package/src/projects/projects-access.mjs +124 -0
package/package.json
CHANGED
|
@@ -99,7 +99,7 @@ export const ALWAYS_INCLUDE = new Set(["gate-evidence", "renderer-security", "pr
|
|
|
99
99
|
* merges addedAngles on top to form the full effective run set
|
|
100
100
|
* @property {string[]} skippedAngles — angles skipped with reasons
|
|
101
101
|
* @property {Record<string, string>} reasons — why each angle was skipped
|
|
102
|
-
* @property {boolean} fallbackToAll —
|
|
102
|
+
* @property {boolean} fallbackToAll — retained for compatibility; always false
|
|
103
103
|
* @property {string[]} addedAngles — catalog angles added (additive mode only, see #1048)
|
|
104
104
|
* @property {Record<string, string>} addedReasons — why each added angle was added
|
|
105
105
|
*/
|
|
@@ -108,8 +108,14 @@ export const ALWAYS_INCLUDE = new Set(["gate-evidence", "renderer-security", "pr
|
|
|
108
108
|
* Resolve which gate angles to run based on detected change categories.
|
|
109
109
|
*
|
|
110
110
|
* When the diff is ambiguous (no detected categories / analysis failure),
|
|
111
|
-
*
|
|
112
|
-
* diff
|
|
111
|
+
* uncertainty still resolves through the same best-effort selection as a
|
|
112
|
+
* classified diff; it never expands to every configured angle. A LOGIC_CHANGE
|
|
113
|
+
* diff likewise resolves to its core review subset.
|
|
114
|
+
*
|
|
115
|
+
* `configuredAngles` is the caller's candidate pool (mandatory angles have
|
|
116
|
+
* already been removed), so an uncertain diff may legitimately resolve to an
|
|
117
|
+
* empty candidate set. The caller combines this with its mandatory floor and
|
|
118
|
+
* falls back to the static pool if that combined selection would be empty.
|
|
113
119
|
*
|
|
114
120
|
* When `anglePool` is provided (additive mode, see #1048), catalog angles in
|
|
115
121
|
* the pool that the change categories recommend but that are not already in
|
|
@@ -121,8 +127,8 @@ export const ALWAYS_INCLUDE = new Set(["gate-evidence", "renderer-security", "pr
|
|
|
121
127
|
* angle) can still be recommended BY change category or file kind when it
|
|
122
128
|
* declares `categories`/`kinds` via `angleDeclarations`. This is purely
|
|
123
129
|
* additive: it can only SELECT such an angle when the diff intersects its
|
|
124
|
-
* declaration; it never drops any angle the catalog map
|
|
125
|
-
*
|
|
130
|
+
* declaration; it never drops any angle the catalog map or ALWAYS_INCLUDE
|
|
131
|
+
* would otherwise recommend.
|
|
126
132
|
*
|
|
127
133
|
* @param {object} options
|
|
128
134
|
* @param {string[]} options.configuredAngles — all angles configured for this gate
|
|
@@ -146,30 +152,6 @@ export function resolveDynamicAngles({
|
|
|
146
152
|
angleDeclarations = {},
|
|
147
153
|
fileKinds = [],
|
|
148
154
|
}) {
|
|
149
|
-
// Fallback: ambiguous diff → all angles
|
|
150
|
-
if (ambiguous) {
|
|
151
|
-
return {
|
|
152
|
-
recommendedAngles: [...configuredAngles],
|
|
153
|
-
skippedAngles: [],
|
|
154
|
-
reasons: {},
|
|
155
|
-
fallbackToAll: true,
|
|
156
|
-
addedAngles: [],
|
|
157
|
-
addedReasons: {},
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// No change categories → all angles (defensive)
|
|
162
|
-
if (changeCategories.length === 0) {
|
|
163
|
-
return {
|
|
164
|
-
recommendedAngles: [...configuredAngles],
|
|
165
|
-
skippedAngles: [],
|
|
166
|
-
reasons: {},
|
|
167
|
-
fallbackToAll: true,
|
|
168
|
-
addedAngles: [],
|
|
169
|
-
addedReasons: {},
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
|
|
173
155
|
// Build recommended set from category union, tracking the first trigger per angle
|
|
174
156
|
const recommended = new Set();
|
|
175
157
|
const triggers = new Map();
|
|
@@ -219,7 +201,11 @@ export function resolveDynamicAngles({
|
|
|
219
201
|
// Build reasons
|
|
220
202
|
const reasons = {};
|
|
221
203
|
for (const angle of skippedAngles) {
|
|
222
|
-
reasons[angle] =
|
|
204
|
+
reasons[angle] = changeCategories.length === 0
|
|
205
|
+
? "Skipped: no change category could be established (uncertain classification)"
|
|
206
|
+
: ambiguous
|
|
207
|
+
? `Skipped: analysis remained ambiguous despite detected categories (${changeCategories.join(", ")})`
|
|
208
|
+
: `Skipped: detected categories (${changeCategories.join(", ")}) do not trigger this angle`;
|
|
223
209
|
}
|
|
224
210
|
|
|
225
211
|
// Additive: pull in recommended catalog angles not already configured (#1048)
|
|
@@ -443,6 +443,8 @@ export function analyzeT1(diffOutput, t0) {
|
|
|
443
443
|
* @property {T0Result} t0
|
|
444
444
|
* @property {T1Result | null} t1
|
|
445
445
|
* @property {boolean} ambiguous — true when heuristics cannot confidently classify
|
|
446
|
+
* @property {boolean} fullDiffMissing — true when a mixed diff needed hunk-level
|
|
447
|
+
* analysis but the full-diff capture was absent/empty (see analyzeDiff)
|
|
446
448
|
*/
|
|
447
449
|
|
|
448
450
|
/**
|
|
@@ -496,9 +498,8 @@ function t0PresentSurfaceCategories(t0) {
|
|
|
496
498
|
function inferCategoriesFromT0(t0) {
|
|
497
499
|
const categories = t0FileCategories(t0);
|
|
498
500
|
// Pure code-only change (all files classify as code, not a rename) is a
|
|
499
|
-
// LOGIC_CHANGE. Without this an all-code diff yields no category
|
|
500
|
-
//
|
|
501
|
-
// the primary case: a code-only PR must resolve to the LOGIC_CHANGE subset.
|
|
501
|
+
// LOGIC_CHANGE. Without this an all-code diff yields no category and loses
|
|
502
|
+
// the justified code-review core from best-effort selection.
|
|
502
503
|
if (!t0.renameOnly && t0.files.length > 0 && t0.files.every((f) => classifyFile(f) === "code")) {
|
|
503
504
|
categories.push("LOGIC_CHANGE");
|
|
504
505
|
}
|
|
@@ -523,19 +524,31 @@ export function analyzeDiff({ nameStatusOutput, diffOutput }) {
|
|
|
523
524
|
const t0Ambiguous = !t0.renameOnly && !t0.allDocs && t0.files.length > 1 &&
|
|
524
525
|
new Set(t0.files.map(classifyFile)).size > 1;
|
|
525
526
|
|
|
526
|
-
|
|
527
|
+
// A full-diff capture is usable evidence only when it carries non-whitespace
|
|
528
|
+
// content. A whitespace-only capture (e.g. " \n") must take the SAME
|
|
529
|
+
// fail-closed path as an absent/empty one: T1 must not run and
|
|
530
|
+
// `fullDiffMissing` must fire, so a gate needing the full diff cannot read a
|
|
531
|
+
// hunk-less whitespace capture as complete evidence.
|
|
532
|
+
const hasDiffText = typeof diffOutput === "string" && diffOutput.trim().length > 0;
|
|
533
|
+
|
|
534
|
+
if (t0Ambiguous && hasDiffText) {
|
|
527
535
|
t1 = analyzeT1(diffOutput, t0);
|
|
528
536
|
}
|
|
529
537
|
|
|
530
538
|
// When t1 is null (unambiguous diff), infer categories from t0
|
|
531
539
|
// so dynamic angle resolution can narrow for config-only / test-only etc.
|
|
532
540
|
if (!t1) {
|
|
533
|
-
// A genuinely MIXED diff whose T1 never ran (no diffOutput)
|
|
534
|
-
// T0
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
const changeCategories = t0Ambiguous
|
|
541
|
+
// A genuinely MIXED diff whose T1 never ran (no diffOutput) still has
|
|
542
|
+
// honest T0 surface evidence. Reuse the same surface-presence categories
|
|
543
|
+
// as the hunk path, and add LOGIC_CHANGE when code is present, so
|
|
544
|
+
// best-effort selection retains the code-review core without widening to
|
|
545
|
+
// the full pool.
|
|
546
|
+
const changeCategories = t0Ambiguous
|
|
547
|
+
? [
|
|
548
|
+
...t0PresentSurfaceCategories(t0),
|
|
549
|
+
...(t0.files.some((f) => classifyFile(f) === "code") ? ["LOGIC_CHANGE"] : []),
|
|
550
|
+
]
|
|
551
|
+
: inferCategoriesFromT0(t0);
|
|
539
552
|
t1 = {
|
|
540
553
|
changeCategories,
|
|
541
554
|
hunkCount: 0,
|
|
@@ -551,11 +564,21 @@ export function analyzeDiff({ nameStatusOutput, diffOutput }) {
|
|
|
551
564
|
}
|
|
552
565
|
|
|
553
566
|
// `ambiguous` flags one case: a diff T0 could not classify (mixed categories)
|
|
554
|
-
// AND
|
|
555
|
-
//
|
|
556
|
-
//
|
|
557
|
-
// and not ambiguous, so LOGIC_CHANGE never forces fallback-to-all via this flag.
|
|
567
|
+
// AND its available analysis produced no category. Empty categories resolve
|
|
568
|
+
// through mandatory-floor best-effort selection, while a hunk-less mixed diff
|
|
569
|
+
// with T0 surface evidence stays classified and keeps its justified core.
|
|
558
570
|
const ambiguous = t0Ambiguous && t1.changeCategories.length === 0;
|
|
559
571
|
|
|
560
|
-
|
|
572
|
+
// Evidence-availability signal, SEPARATE from `ambiguous` on purpose. A mixed
|
|
573
|
+
// diff whose hunk-level analysis never ran (no full-diff capture) legitimately
|
|
574
|
+
// classifies through its honest T0 surfaces for ANGLE SELECTION — that is what
|
|
575
|
+
// keeps the code-review core in the best-effort subset without widening to the
|
|
576
|
+
// whole pool. But a fail-closed gate that needs the FULL diff (the size
|
|
577
|
+
// budget's unwaivable block) must not read that angle-selection fallback as
|
|
578
|
+
// complete evidence: `ambiguous` is now false for this case, so such a gate
|
|
579
|
+
// would silently downgrade. Consumers that need the diff itself key off THIS
|
|
580
|
+
// flag instead of piggybacking on the angle classifier's ambiguity flag.
|
|
581
|
+
const fullDiffMissing = t0Ambiguous && !hasDiffText;
|
|
582
|
+
|
|
583
|
+
return { t0, t1, ambiguous, fullDiffMissing };
|
|
561
584
|
}
|
|
@@ -21,7 +21,10 @@
|
|
|
21
21
|
* async-dispatch concerns. The user entrypoint under Claude is the dev-loop *skill*.)
|
|
22
22
|
* - Skills keep name/description/allowed-tools (space-separated) and preserve `user-invocable`
|
|
23
23
|
* (Claude honors it 1:1 — `user-invocable: false` hides the skill from the `/` menu). The
|
|
24
|
-
* Pi-specific `compatibility` text is dropped (no Claude field).
|
|
24
|
+
* Pi-specific `compatibility` text is dropped (no Claude field). Whole-file skill exclusion is
|
|
25
|
+
* applied by `collectGeneratedAssets` in scripts/claude/generate-claude-assets.mjs before these
|
|
26
|
+
* transforms run; this is distinct from `<!-- pi-only -->` blocks, which remove only marked body
|
|
27
|
+
* sections.
|
|
25
28
|
*/
|
|
26
29
|
|
|
27
30
|
import { parse as parseYaml } from "yaml";
|
|
@@ -295,6 +298,38 @@ export function transformCommand({ source, raw, version = "latest" }) {
|
|
|
295
298
|
return `${lines.join("\n")}\n${body}`;
|
|
296
299
|
}
|
|
297
300
|
|
|
301
|
+
/**
|
|
302
|
+
* Check whether a skill should be excluded from Claude asset generation.
|
|
303
|
+
* Supports:
|
|
304
|
+
* - `claude-sync: false`
|
|
305
|
+
* - `harness: pi` (or `harness: ["pi"]`)
|
|
306
|
+
* - `pi-only: true`
|
|
307
|
+
*
|
|
308
|
+
* @param {Record<string, unknown> | undefined} frontmatter
|
|
309
|
+
* @returns {boolean}
|
|
310
|
+
*/
|
|
311
|
+
export function isSkillExcludedFromClaude(frontmatter) {
|
|
312
|
+
if (!frontmatter || typeof frontmatter !== "object") {
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
const claudeSync = frontmatter["claude-sync"];
|
|
316
|
+
if (claudeSync === false || (typeof claudeSync === "string" && claudeSync.trim().toLowerCase() === "false")) {
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
const piOnly = frontmatter["pi-only"];
|
|
320
|
+
if (piOnly === true || (typeof piOnly === "string" && piOnly.trim().toLowerCase() === "true")) {
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
const harness = frontmatter.harness;
|
|
324
|
+
if (typeof harness === "string" && harness.trim().toLowerCase() === "pi") {
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
if (Array.isArray(harness) && harness.length === 1 && String(harness[0]).trim().toLowerCase() === "pi") {
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
|
|
298
333
|
/**
|
|
299
334
|
* Transform a canonical `skills/<name>/SKILL.md` into a Claude `.claude/skills/<name>/SKILL.md`.
|
|
300
335
|
* @param {{ source: string, raw: string, version?: string }} input
|
package/src/config/config.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { parse as parseYaml } from "yaml";
|
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { classifyFile } from "../analysis/diff-analyzer.mjs";
|
|
9
|
-
import { ChangeCategory } from "../analysis/change-classifier.mjs";
|
|
9
|
+
import { ChangeCategory, resolveDynamicAngles } from "../analysis/change-classifier.mjs";
|
|
10
10
|
import { isDevLoopConfigSourcePath } from "../loop/gate-carry-forward.mjs";
|
|
11
11
|
import { isClaudeHarness } from "../loop/run-context.mjs";
|
|
12
12
|
import { trimmedOrNull } from "../loop/normalize.mjs";
|
|
@@ -184,9 +184,9 @@ const GateTier = z.strictObject({
|
|
|
184
184
|
});
|
|
185
185
|
|
|
186
186
|
const GateDynamicConfig = z.strictObject({
|
|
187
|
-
// Diff-driven dynamic angle PRUNING, ON by default. mandatory:true
|
|
188
|
-
//
|
|
189
|
-
//
|
|
187
|
+
// Diff-driven dynamic angle PRUNING, ON by default. mandatory:true angles
|
|
188
|
+
// stay a hard always-run floor. fallbackToAll is retained in resolver output
|
|
189
|
+
// for compatibility but is always false; uncertainty never widens the set.
|
|
190
190
|
subtractive: z.boolean().default(true).describe("Enable diff-driven dynamic angle PRUNING for this gate (ON by default; set false to restore the full static angle pool). Was gates.<gate>.dynamicAngles."),
|
|
191
191
|
// Additive counterpart to the subtractive path: when true, the
|
|
192
192
|
// context-builder may also ADD catalog angles (from resolveAnglePool) that
|
|
@@ -2205,12 +2205,15 @@ export function resolveFanoutSequential(config) {
|
|
|
2205
2205
|
|
|
2206
2206
|
/**
|
|
2207
2207
|
* Claude-harness-scoped cap on effective fan-out concurrency (per ADR
|
|
2208
|
-
* docs/decisions/0069-claude-harness-fanout-concurrency-clamp.md
|
|
2209
|
-
*
|
|
2210
|
-
*
|
|
2211
|
-
*
|
|
2208
|
+
* docs/decisions/0069-claude-harness-fanout-concurrency-clamp.md, amended by
|
|
2209
|
+
* docs/decisions/0083-raise-claude-fanout-concurrency-cap-to-4.md). The
|
|
2210
|
+
* `GATE-EXEC-DISPATCH-RETRY-BACKOFF` retry/backoff policy turns a single 429
|
|
2211
|
+
* into latency instead of a failed drive, so this cap only bounds the
|
|
2212
|
+
* steady-state per-wave burst (driver + dispatch units) for a single-driver
|
|
2213
|
+
* Claude session; other harnesses (pi, unknown) are unaffected — see
|
|
2214
|
+
* `resolveFanoutEffectiveConcurrency`.
|
|
2212
2215
|
*/
|
|
2213
|
-
export const CLAUDE_MAX_EFFECTIVE_CONCURRENT =
|
|
2216
|
+
export const CLAUDE_MAX_EFFECTIVE_CONCURRENT = 4;
|
|
2214
2217
|
|
|
2215
2218
|
/**
|
|
2216
2219
|
* Resolve the effective fan-out concurrency (dispatch units per wave): 1 when
|
|
@@ -2482,6 +2485,51 @@ export function resolveGateTier(config, gate, { changedFiles, filesChanged, line
|
|
|
2482
2485
|
return { tier: matched.name, angles: [...new Set([...mandatoryAngles, ...matched.angles])], reason: "tier_match" };
|
|
2483
2486
|
}
|
|
2484
2487
|
|
|
2488
|
+
/**
|
|
2489
|
+
* Best-effort angle selection for a diff no diff-class tier matched (an
|
|
2490
|
+
* unclassifiable file, a dev-loop config-source delta, or no tier configured).
|
|
2491
|
+
* Uncertainty selects the mandatory floor plus the lenses the diff still
|
|
2492
|
+
* justifies — the always-include lens and any consumer angle whose declared
|
|
2493
|
+
* category/kind binding intersects the diff. It is never the whole pool,
|
|
2494
|
+
* except for the degenerate gate whose pool holds neither a mandatory nor an
|
|
2495
|
+
* always-include angle: an empty best-effort selection falls back to the static
|
|
2496
|
+
* pool (fail-closed — more angles, not fewer) rather than returning nothing.
|
|
2497
|
+
*
|
|
2498
|
+
* `dynamic.subtractive: false` is the documented opt-out ("restore the full
|
|
2499
|
+
* static angle pool"); this returns the static pool unchanged, mirroring
|
|
2500
|
+
* resolveGateAnglesDynamic's `!gateConfig.dynamicAngles` branch so the composer
|
|
2501
|
+
* and the resolver agree for the same diff.
|
|
2502
|
+
*
|
|
2503
|
+
* This composer has no diff TEXT (only the changed-file list), so it can bind
|
|
2504
|
+
* on file kinds but not on hunk-derived change categories. Except for the
|
|
2505
|
+
* `gate:full` path, it is therefore a lower bound: the round's authoritative
|
|
2506
|
+
* angle set is resolved by resolveGateAnglesDynamic, which can add lenses bound
|
|
2507
|
+
* to real change categories. On `gate:full`, the composer deliberately returns
|
|
2508
|
+
* the full static pool and is instead a superset of the dynamic resolver.
|
|
2509
|
+
*/
|
|
2510
|
+
function selectFloorPlusJustifiedAngles(config, gate, changedFiles) {
|
|
2511
|
+
const gateConfig = resolveGateConfig(config, gate);
|
|
2512
|
+
// Documented opt-out: `dynamic.subtractive: false` restores the full static
|
|
2513
|
+
// pool. Mirror resolveGateAnglesDynamic's `!gateConfig.dynamicAngles` branch
|
|
2514
|
+
// so the composer and the resolver never disagree under an explicit operator
|
|
2515
|
+
// config (both are callers of the same selection contract).
|
|
2516
|
+
if (!gateConfig.dynamicAngles) return resolveGateAngles(config, gate) ?? [];
|
|
2517
|
+
const { mandatoryAngles } = resolveGateAngleContract(config, gate);
|
|
2518
|
+
const pool = resolveGateAngles(config, gate) ?? [];
|
|
2519
|
+
const candidatePool = pool.filter((a) => !mandatoryAngles.includes(a));
|
|
2520
|
+
const validChangedFiles = (Array.isArray(changedFiles) ? changedFiles : [])
|
|
2521
|
+
.filter((f) => typeof f === "string" && f.trim().length > 0);
|
|
2522
|
+
const fileKinds = [...new Set(validChangedFiles.map((f) => classifyFile(f)))];
|
|
2523
|
+
const { recommendedAngles } = resolveDynamicAngles({
|
|
2524
|
+
configuredAngles: candidatePool,
|
|
2525
|
+
changeCategories: [],
|
|
2526
|
+
fileKinds,
|
|
2527
|
+
angleDeclarations: gateConfig.angleCategoryBindings,
|
|
2528
|
+
});
|
|
2529
|
+
const selected = [...new Set([...mandatoryAngles, ...recommendedAngles])];
|
|
2530
|
+
return selected.length > 0 ? selected : pool;
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2485
2533
|
/**
|
|
2486
2534
|
* The primer-owned deterministic review-proportionality plan
|
|
2487
2535
|
* (GATE-EXEC-PROPORTIONALITY, gate-review-sub-loop-contract.md): a single,
|
|
@@ -2489,25 +2537,25 @@ export function resolveGateTier(config, gate, { changedFiles, filesChanged, line
|
|
|
2489
2537
|
* set + execution mode + grouping) is one testable, persistable object.
|
|
2490
2538
|
* Delegates entirely to {@link resolveGateDispatchMode} (mode, including the
|
|
2491
2539
|
* non-overridable size-cap/risk-path/size-outcome/ambiguity floors),
|
|
2492
|
-
* {@link resolveGateTier} (angle set AND diff-classification),
|
|
2493
|
-
* {@link
|
|
2540
|
+
* {@link resolveGateTier} (angle set AND diff-classification),
|
|
2541
|
+
* {@link resolveDynamicAngles} (no-tier best-effort selection), and {@link
|
|
2542
|
+
* resolveFanoutGroups} (dispatch-unit grouping). No git I/O, no logic
|
|
2494
2543
|
* of its own beyond the floor-vs-tier precedence below: this is the ONE place
|
|
2495
2544
|
* the primer (emit) and the merge gate (re-verify) compose mode + angles +
|
|
2496
2545
|
* grouping, so they can never drift onto two different floor implementations.
|
|
2497
2546
|
*
|
|
2498
|
-
* Floor-vs-tier precedence:
|
|
2499
|
-
* denylist (`risk_path_touch`), a non-clean/
|
|
2500
|
-
* (`size_outcome_*`,
|
|
2501
|
-
*
|
|
2502
|
-
* (`
|
|
2503
|
-
*
|
|
2504
|
-
* hard size cap (`over_threshold`)
|
|
2505
|
-
*
|
|
2506
|
-
*
|
|
2507
|
-
*
|
|
2508
|
-
*
|
|
2509
|
-
*
|
|
2510
|
-
* (`light_mode_disabled` is not a floor).
|
|
2547
|
+
* Floor-vs-tier precedence: every fired floor affects DISPATCH only. A
|
|
2548
|
+
* RISK-signal floor — the risk-path denylist (`risk_path_touch`), a non-clean/
|
|
2549
|
+
* ambiguous size-budget outcome (`size_outcome_*`,
|
|
2550
|
+
* `size_outcome_unavailable`), missing changed-file evidence
|
|
2551
|
+
* (`changed_files_unavailable`), or an unclassifiable diff
|
|
2552
|
+
* (`resolveGateTier`'s `unclassifiable_file`) — ALWAYS forces `full_fanout`,
|
|
2553
|
+
* as does the hard size cap (`over_threshold`). The angle SET remains the
|
|
2554
|
+
* matched tier or, when no tier matched, the mandatory floor plus the lenses
|
|
2555
|
+
* justified by the changed-file kinds. It never widens to the full untriered
|
|
2556
|
+
* pool merely because a floor fired. A `gate:full` label (which self-bypasses
|
|
2557
|
+
* tier matching) and disabled light mode continue to govern dispatch mode
|
|
2558
|
+
* independently; `light_mode_disabled` is not a floor.
|
|
2511
2559
|
*
|
|
2512
2560
|
* @param {DevLoopConfig} config
|
|
2513
2561
|
* @param {"draft"|"preApproval"} gate
|
|
@@ -2544,23 +2592,24 @@ export function resolveReviewProportionality(config, gate, {
|
|
|
2544
2592
|
// dispatch-mode facts alone looked trivial.
|
|
2545
2593
|
unclassifiable: tier.reason === "unclassifiable_file",
|
|
2546
2594
|
});
|
|
2547
|
-
// sizeCap (over_threshold) is deliberately EXCLUDED from the
|
|
2548
|
-
//
|
|
2549
|
-
//
|
|
2550
|
-
// inline-cap-but-still-tier-classifiable diff on its reduced tier set — see
|
|
2551
|
-
// resolveGateTier's "small non-risky diff outside the inline cap but
|
|
2552
|
-
// matching a tier" contract. Only a genuine RISK signal (a risk-path touch,
|
|
2553
|
-
// a non-clean/ambiguous size-budget outcome, or an unclassifiable diff)
|
|
2554
|
-
// forces the full untriered pool.
|
|
2595
|
+
// sizeCap (over_threshold) is deliberately EXCLUDED from the risk-signal
|
|
2596
|
+
// floor set: it predates the risk/ambiguity floors. Every floor affects the
|
|
2597
|
+
// dispatch mode only; angle selection remains tiered or best-effort.
|
|
2555
2598
|
const dispatchFloorFired = floors.riskPath || floors.sizeOutcome || floors.ambiguity;
|
|
2556
2599
|
const floored = dispatchFloorFired || floors.unclassifiable;
|
|
2557
2600
|
const mode = floored ? "full_fanout" : dispatch.mode;
|
|
2558
2601
|
const reason = floored && !dispatchFloorFired ? "unclassifiable_diff" : dispatch.reason;
|
|
2559
2602
|
// The mandatory-angle floor is present either way: a tier match already
|
|
2560
|
-
// unions mandatoryAngles in (resolveGateTier), and the no-tier
|
|
2561
|
-
//
|
|
2562
|
-
//
|
|
2563
|
-
|
|
2603
|
+
// unions mandatoryAngles in (resolveGateTier), and the no-tier best-effort
|
|
2604
|
+
// selection does the same union — see AC-4 "mandatory angles combined,
|
|
2605
|
+
// never dropped". A `gate:full` label is the explicit "run everything"
|
|
2606
|
+
// escape hatch (ADR 0048): it keeps forcing the full configured pool, which
|
|
2607
|
+
// is also what resolveGateTier's gate_full_label bypass left in place before
|
|
2608
|
+
// best-effort selection existed.
|
|
2609
|
+
const staticAngles = resolveGateAngles(config, gate);
|
|
2610
|
+
const angles = hasFullLabel
|
|
2611
|
+
? staticAngles
|
|
2612
|
+
: (tier.angles ?? (staticAngles === null ? null : selectFloorPlusJustifiedAngles(config, gate, changedFiles)));
|
|
2564
2613
|
const groups = resolveFanoutGroups(config, gate, angles ?? [], { fullLabel: hasFullLabel });
|
|
2565
2614
|
return Object.freeze({
|
|
2566
2615
|
mode,
|
|
@@ -2575,9 +2624,10 @@ export function resolveReviewProportionality(config, gate, {
|
|
|
2575
2624
|
* Resolve gate angles dynamically when `dynamicAngles` is enabled.
|
|
2576
2625
|
*
|
|
2577
2626
|
* Diff analysis (../analysis/*) filters the configured angle list to angles
|
|
2578
|
-
* relevant to the change set. When `dynamic.subtractive: false
|
|
2579
|
-
*
|
|
2580
|
-
*
|
|
2627
|
+
* relevant to the change set. When `dynamic.subtractive: false`, returns the
|
|
2628
|
+
* full configured list. Without a diff, ordinary callers keep that static pool
|
|
2629
|
+
* while floor-aware callers use mandatory-floor best-effort selection. When
|
|
2630
|
+
* `additiveAngles` is on, catalog angles from resolveAnglePool may also be added, with
|
|
2581
2631
|
* `excludeAngles` a hard ceiling.
|
|
2582
2632
|
*
|
|
2583
2633
|
* Diff-class tiers (resolveGateTier) are consulted FIRST: a tier match returns
|
|
@@ -2586,15 +2636,14 @@ export function resolveReviewProportionality(config, gate, {
|
|
|
2586
2636
|
*
|
|
2587
2637
|
* GATE-EXEC-PROPORTIONALITY floor-awareness (opt-in via `checkFloors`): when
|
|
2588
2638
|
* the caller supplies `checkFloors: true` (and, when available, `sizeOutcome`
|
|
2589
|
-
* from check-size-budget.mjs), this delegates to {@link
|
|
2639
|
+
* from check-size-budget.mjs), this delegates floor determination to {@link
|
|
2590
2640
|
* resolveReviewProportionality} — the SAME composer resolve-gate-dispatch.mjs
|
|
2591
|
-
* uses — over the SAME diff-derived changed-file/scope facts
|
|
2592
|
-
*
|
|
2593
|
-
*
|
|
2594
|
-
*
|
|
2595
|
-
*
|
|
2596
|
-
*
|
|
2597
|
-
* size-budget evidence to hand is unaffected.
|
|
2641
|
+
* uses — over the SAME diff-derived changed-file/scope facts. A fired floor
|
|
2642
|
+
* forces `full_fanout` DISPATCH and refuses an explicit override, but the angle
|
|
2643
|
+
* SET is always the tier-or-dynamic best-effort selection below; uncertainty
|
|
2644
|
+
* never widens it to the full untriered pool. Omitted (default), this resolves
|
|
2645
|
+
* exactly as before — a caller that does not have size-budget evidence to hand
|
|
2646
|
+
* is unaffected.
|
|
2598
2647
|
*
|
|
2599
2648
|
* @param {import("./types.js").DevLoopConfig} config
|
|
2600
2649
|
* @param {"draft"|"preApproval"} gate
|
|
@@ -2603,7 +2652,7 @@ export function resolveReviewProportionality(config, gate, {
|
|
|
2603
2652
|
* @param {boolean} [options.hasFullLabel] — `gate:full` label present on the PR (bypasses tier resolution)
|
|
2604
2653
|
* @param {boolean} [options.checkFloors] — opt into the GATE-EXEC-PROPORTIONALITY floor check above
|
|
2605
2654
|
* @param {{ outcome?: "pass"|"escalate"|"block", tierLogicLoc?: { t1?: number } }|null} [options.sizeOutcome] — only consulted when `checkFloors` is true
|
|
2606
|
-
* @param {string[]} [options.explicitAngles] — caller-supplied verbatim override (e.g. CLI `--angles`); wins over tier/dynamic resolution but NEVER over a fired floor above (a fired floor
|
|
2655
|
+
* @param {string[]} [options.explicitAngles] — caller-supplied verbatim override (e.g. CLI `--angles`); wins over tier/dynamic resolution but NEVER over a fired floor above (a fired floor refuses the override and continues to tier/dynamic best-effort selection)
|
|
2607
2656
|
* @returns {{ recommendedAngles: string[] | null, skippedAngles: string[], reasons: Record<string,string>, fallbackToAll: boolean, dynamicAnglesActive: boolean, addedAngles: string[], addedReasons: Record<string,string> }}
|
|
2608
2657
|
*/
|
|
2609
2658
|
export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabel = false, checkFloors = false, sizeOutcome, explicitAngles } = {}) {
|
|
@@ -2625,30 +2674,20 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
|
|
|
2625
2674
|
linesChanged = lineStats.added + lineStats.deleted;
|
|
2626
2675
|
}
|
|
2627
2676
|
}
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
addedAngles: [],
|
|
2643
|
-
addedReasons: {},
|
|
2644
|
-
};
|
|
2645
|
-
}
|
|
2646
|
-
}
|
|
2647
|
-
// A fired floor above always wins (its full pool already includes the
|
|
2648
|
-
// mandatory floor via resolveGateAngles) — an explicit --angles override is
|
|
2649
|
-
// only honored once no floor fired, matching its documented "verbatim,
|
|
2650
|
-
// dynamic resolution bypassed" contract.
|
|
2651
|
-
if (Array.isArray(explicitAngles)) {
|
|
2677
|
+
const plan = checkFloors
|
|
2678
|
+
? resolveReviewProportionality(config, gate, {
|
|
2679
|
+
scope: { filesChanged, linesChanged },
|
|
2680
|
+
changedFiles,
|
|
2681
|
+
sizeOutcome,
|
|
2682
|
+
hasFullLabel,
|
|
2683
|
+
})
|
|
2684
|
+
: null;
|
|
2685
|
+
const floorFired = plan !== null && (
|
|
2686
|
+
plan.floors.riskPath || plan.floors.sizeOutcome || plan.floors.ambiguity || plan.floors.unclassifiable
|
|
2687
|
+
);
|
|
2688
|
+
// A fired floor refuses an explicit --angles override. Selection falls
|
|
2689
|
+
// through to the same tier/dynamic best-effort path as every other diff.
|
|
2690
|
+
if (Array.isArray(explicitAngles) && !floorFired) {
|
|
2652
2691
|
return {
|
|
2653
2692
|
recommendedAngles: explicitAngles,
|
|
2654
2693
|
skippedAngles: [],
|
|
@@ -2688,12 +2727,24 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
|
|
|
2688
2727
|
}
|
|
2689
2728
|
|
|
2690
2729
|
if (!gateConfig.dynamicAngles || !diff) {
|
|
2730
|
+
// No diff to select on (a thin briefing) or dynamic resolution explicitly
|
|
2731
|
+
// disabled by config. An explicitly disabled resolver keeps its static pool
|
|
2732
|
+
// — that is the config asking for every angle. When dynamic resolution is
|
|
2733
|
+
// enabled, a floor-aware caller that simply has no diff still gets the
|
|
2734
|
+
// composer's mandatory-floor-plus-justified set rather than the whole pool,
|
|
2735
|
+
// so absence of evidence never widens the angle set either.
|
|
2736
|
+
const bestEffortWithoutDiff = Boolean(!diff && plan && gateConfig.dynamicAngles);
|
|
2737
|
+
const recommendedAngles = bestEffortWithoutDiff ? (plan.angles ?? staticAngles) : staticAngles;
|
|
2738
|
+
const recommended = new Set(recommendedAngles);
|
|
2739
|
+
const skippedAngles = staticAngles.filter((a) => !recommended.has(a));
|
|
2691
2740
|
return {
|
|
2692
|
-
recommendedAngles
|
|
2693
|
-
skippedAngles
|
|
2694
|
-
reasons:
|
|
2741
|
+
recommendedAngles,
|
|
2742
|
+
skippedAngles,
|
|
2743
|
+
reasons: Object.fromEntries(
|
|
2744
|
+
skippedAngles.map((a) => [a, "Skipped: no diff is available to select on; the mandatory floor plus justified lenses were selected instead"]),
|
|
2745
|
+
),
|
|
2695
2746
|
fallbackToAll: false,
|
|
2696
|
-
dynamicAnglesActive:
|
|
2747
|
+
dynamicAnglesActive: bestEffortWithoutDiff,
|
|
2697
2748
|
addedAngles: [],
|
|
2698
2749
|
addedReasons: {},
|
|
2699
2750
|
};
|
|
@@ -2724,8 +2775,7 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
|
|
|
2724
2775
|
? resolveAnglePool(config).filter(a => !excluded.has(a))
|
|
2725
2776
|
: undefined;
|
|
2726
2777
|
|
|
2727
|
-
const
|
|
2728
|
-
const dynamicResult = resolve({
|
|
2778
|
+
const dynamicResult = resolveDynamicAngles({
|
|
2729
2779
|
configuredAngles: candidatePool,
|
|
2730
2780
|
changeCategories: categories,
|
|
2731
2781
|
ambiguous: analysis.ambiguous,
|
|
@@ -2746,12 +2796,24 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
|
|
|
2746
2796
|
Object.entries(dynamicResult.addedReasons ?? {}).filter(([a]) => !mandatory.has(a))
|
|
2747
2797
|
);
|
|
2748
2798
|
|
|
2749
|
-
const
|
|
2799
|
+
const mergedAngles = [...new Set([...filteredMandatory, ...dynamicResult.recommendedAngles, ...addedAngles])];
|
|
2800
|
+
|
|
2801
|
+
// Degenerate-pool fail-closed: a gate whose configured pool holds neither a
|
|
2802
|
+
// mandatory nor an always-include angle (e.g. `angles: ["kiss"]`) can legally
|
|
2803
|
+
// resolve to zero angles for an unclassifiable diff. Never resolve zero
|
|
2804
|
+
// angles — fall back to the static pool (already excludeAngles-filtered),
|
|
2805
|
+
// mirroring selectFloorPlusJustifiedAngles and the composer, so the resolver
|
|
2806
|
+
// and the composer agree for the identical input. The whole pool is selected,
|
|
2807
|
+
// so nothing is skipped and no angle carries a skip reason.
|
|
2808
|
+
const degenerateFallback = mergedAngles.length === 0;
|
|
2809
|
+
const recommendedAngles = degenerateFallback ? [...staticAngles] : mergedAngles;
|
|
2810
|
+
const skippedAngles = degenerateFallback ? [] : dynamicResult.skippedAngles;
|
|
2811
|
+
const reasons = degenerateFallback ? {} : dynamicResult.reasons;
|
|
2750
2812
|
|
|
2751
2813
|
return {
|
|
2752
2814
|
recommendedAngles,
|
|
2753
|
-
skippedAngles
|
|
2754
|
-
reasons
|
|
2815
|
+
skippedAngles,
|
|
2816
|
+
reasons,
|
|
2755
2817
|
fallbackToAll: dynamicResult.fallbackToAll,
|
|
2756
2818
|
dynamicAnglesActive: true,
|
|
2757
2819
|
addedAngles,
|
|
@@ -126,9 +126,15 @@ gates:
|
|
|
126
126
|
implementation bugs, logic errors, contract violations, or security
|
|
127
127
|
issues
|
|
128
128
|
- Unresolved review threads that raise implementation concerns Flag any unresolved comment that identifies a concrete implementation problem as a blocking finding (severity: high or medium). Do not flag: - Resolved threads - Style nits, formatting suggestions, or cosmetic feedback - Comments from non-collaborators or bots - Outdated comments that were already addressed in a later commit If no unresolved implementation concerns exist, return clean.
|
|
129
|
-
- contradiction-lens
|
|
130
|
-
|
|
131
|
-
|
|
129
|
+
- name: contradiction-lens
|
|
130
|
+
persona: review
|
|
131
|
+
prompt: "Apply STYLE-CONTRADICTION-LENS when the repo defines it (dev-loops: skills/docs/contract-style-guide.md): check every added or changed normative rule (MUST/SHOULD/MAY) against the repo's rule registry for opposing modality or a weaker restatement. Also flag statements in the diff, the PR body, touched contracts, or accepted decision records (for example docs/decisions/) that contradict each other. Cite both sides of each contradiction with file:line."
|
|
132
|
+
- name: code-conformance
|
|
133
|
+
persona: review
|
|
134
|
+
prompt: Review this change for conformance to existing repo code conventions and patterns. Compare naming, module layout, and helper reuse against neighboring code in the same package. Flag ad hoc patterns that diverge from an established convention without a stated reason.
|
|
135
|
+
- name: semantic-drift
|
|
136
|
+
persona: review
|
|
137
|
+
prompt: Review this change for semantic drift between code and its documented behavior. Compare the diff's actual logic against the intent stated in touched docs, contracts, and comments. Flag a documented behavior that no longer matches what the code does, or code whose behavior no longer matches what a touched doc or contract claims.
|
|
132
138
|
- name: pr-description
|
|
133
139
|
mandatory: true
|
|
134
140
|
persona: review
|
|
@@ -342,9 +348,15 @@ gates:
|
|
|
342
348
|
give a minimal fix. Respect declared non-goals — do not manufacture
|
|
343
349
|
scope. If the change is coherent and complete against the spec, return
|
|
344
350
|
clean.
|
|
345
|
-
- contradiction-lens
|
|
346
|
-
|
|
347
|
-
|
|
351
|
+
- name: contradiction-lens
|
|
352
|
+
persona: review
|
|
353
|
+
prompt: "Apply STYLE-CONTRADICTION-LENS when the repo defines it (dev-loops: skills/docs/contract-style-guide.md): check every added or changed normative rule (MUST/SHOULD/MAY) against the repo's rule registry for opposing modality or a weaker restatement. Also flag statements in the diff, the PR body, touched contracts, or accepted decision records (for example docs/decisions/) that contradict each other. Cite both sides of each contradiction with file:line."
|
|
354
|
+
- name: correctness-final
|
|
355
|
+
persona: review
|
|
356
|
+
prompt: Perform a final correctness pass on the current head. Flag residual logic errors, contract violations, and behavior mismatches introduced or left unfixed since the draft gate.
|
|
357
|
+
- name: ui-validation
|
|
358
|
+
persona: review
|
|
359
|
+
prompt: "Review this change against the repo's UI validation contract when it defines one (dev-loops: skills/docs/ui-validation-contract.md and skills/docs/ui-e2e-scoping-step.md). When the changed-file set matches the UI e2e scoping globs, verify passing UI e2e coverage exists and that each touched rendered artifact is registered; an unregistered new artifact is a blocking finding. Return clean when no changed path matches or the repo defines no UI validation contract."
|
|
348
360
|
required: true
|
|
349
361
|
# Relaxed spike gate profile (#965). A spike's deliverable is a findings doc,
|
|
350
362
|
# not production code, so it is intentionally lighter than the production
|