@dev-loops/core 1.0.3 → 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 +49 -28
- package/src/analysis/diff-analyzer.mjs +127 -31
- package/src/claude/asset-generation.mjs +36 -1
- package/src/claude/hook-decisions.mjs +117 -18
- package/src/config/config.mjs +198 -86
- package/src/config/extension-defaults.yaml +66 -6
- package/src/github/copilot-helpers.mjs +85 -20
- package/src/github/gh.mjs +14 -1
- package/src/github/review-threads.mjs +6 -0
- package/src/loop/bash-command-classify.mjs +251 -14
- package/src/loop/copilot-ci-status.mjs +116 -6
- package/src/loop/copilot-loop-state.mjs +38 -2
- package/src/loop/finding-cluster.mjs +30 -11
- package/src/loop/gate-carry-forward.mjs +39 -6
- package/src/loop/gate-fanin.mjs +58 -3
- package/src/loop/issue-refinement-artifact.mjs +117 -9
- package/src/loop/main-checkout-ff.mjs +169 -17
- package/src/loop/merge-approval.mjs +186 -5
- package/src/loop/pr-gate-coordination.mjs +339 -67
- package/src/loop/run-inspection.mjs +6 -0
- package/src/loop/spec-authority.mjs +40 -6
- package/src/loop/ui-e2e-scoping.mjs +1 -0
- package/src/projects/move-queue-item.mjs +5 -79
- package/src/projects/projects-access.mjs +124 -0
package/src/config/config.mjs
CHANGED
|
@@ -6,6 +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, resolveDynamicAngles } from "../analysis/change-classifier.mjs";
|
|
9
10
|
import { isDevLoopConfigSourcePath } from "../loop/gate-carry-forward.mjs";
|
|
10
11
|
import { isClaudeHarness } from "../loop/run-context.mjs";
|
|
11
12
|
import { trimmedOrNull } from "../loop/normalize.mjs";
|
|
@@ -45,6 +46,12 @@ const BUILTIN_ROLE_TIERS = Object.freeze({
|
|
|
45
46
|
quality: "low",
|
|
46
47
|
refiner: "high",
|
|
47
48
|
review: "high",
|
|
49
|
+
// The pre-PR review pass (skills/docs/pre-pr-review-contract.md) runs one
|
|
50
|
+
// fresh-context general-purpose reviewer before the first push. Default tier
|
|
51
|
+
// is high (strongest): with zero config that resolves to opus on Claude and
|
|
52
|
+
// null (inherit) on Pi. Operators opt into a concrete strong model per
|
|
53
|
+
// harness via models.tiers/roleTiers.
|
|
54
|
+
"pre-PR-reviewer": "high",
|
|
48
55
|
"dev-loop": "inherit",
|
|
49
56
|
});
|
|
50
57
|
|
|
@@ -113,6 +120,13 @@ const RefinementConfig = z.strictObject({
|
|
|
113
120
|
// cost saving, never a silently-enforced information cut.
|
|
114
121
|
export const GATE_ANGLE_SCOPES = Object.freeze(["full", "changed-files", "docs-only"]);
|
|
115
122
|
|
|
123
|
+
// Change-category and file-kind vocabularies a consumer angle can bind to.
|
|
124
|
+
// CHANGE_CATEGORY_NAMES mirrors ChangeCategory; FILE_KIND_NAMES mirrors
|
|
125
|
+
// classifyFile()'s output range. Both feed z.enum so an unknown name is
|
|
126
|
+
// rejected fail-closed at validation instead of silently never matching.
|
|
127
|
+
const CHANGE_CATEGORY_NAMES = Object.freeze(Object.values(ChangeCategory));
|
|
128
|
+
const FILE_KIND_NAMES = Object.freeze(["code", "docs", "config", "test", "ci", "unknown"]);
|
|
129
|
+
|
|
116
130
|
// One review angle: a bare string is sugar for `{ name }`; the fields are
|
|
117
131
|
// documented on the schema below. mergeConfigLayers merges these arrays BY
|
|
118
132
|
// `name` across config layers, so a later layer can add or disable a single
|
|
@@ -132,13 +146,15 @@ const GateAngleEntry = z.preprocess(
|
|
|
132
146
|
model: z.string().trim().min(1).optional().describe("Concrete model override for this angle (highest precedence)."),
|
|
133
147
|
tier: z.string().trim().min(1).optional().describe("Model tier alias for this angle (used when `model` is absent)."),
|
|
134
148
|
scope: z.enum(GATE_ANGLE_SCOPES).optional().describe("Surface scope this angle needs: full (default), changed-files (diff without the adjacent-code bundle or its changed-files/adjacent-file summary section), or docs-only (doc-file hunks only). Unknown/omitted resolves to full."),
|
|
149
|
+
categories: z.array(z.enum(CHANGE_CATEGORY_NAMES)).min(1).optional().describe("Change categories (e.g. LOGIC_CHANGE, CONFIG_ONLY, SECURITY_SENSITIVE_SEAM) that dynamically SELECT this consumer angle by diff, so it need not be forced mandatory. Unknown names are rejected fail-closed."),
|
|
150
|
+
kinds: z.array(z.enum(FILE_KIND_NAMES)).min(1).optional().describe("File kinds (code/config/test/ci/docs/unknown, classifyFile output) that dynamically SELECT this consumer angle by diff. Unknown names are rejected fail-closed."),
|
|
135
151
|
}),
|
|
136
152
|
);
|
|
137
153
|
|
|
138
154
|
// Diff-class kinds a tier's `match` can name — exactly classifyFile()'s
|
|
139
155
|
// output range (../analysis/diff-analyzer.mjs), so a tier config can never
|
|
140
156
|
// name a kind the classifier could not produce.
|
|
141
|
-
const GateTierMatchKind = z.enum(
|
|
157
|
+
const GateTierMatchKind = z.enum(FILE_KIND_NAMES);
|
|
142
158
|
|
|
143
159
|
// A tier's match conditions: EVERY changed file's kind must be in `kinds`
|
|
144
160
|
// (when set) AND the change must stay within `maxFiles`/`maxLines` (when
|
|
@@ -168,9 +184,9 @@ const GateTier = z.strictObject({
|
|
|
168
184
|
});
|
|
169
185
|
|
|
170
186
|
const GateDynamicConfig = z.strictObject({
|
|
171
|
-
// Diff-driven dynamic angle PRUNING, ON by default. mandatory:true
|
|
172
|
-
//
|
|
173
|
-
//
|
|
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.
|
|
174
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."),
|
|
175
191
|
// Additive counterpart to the subtractive path: when true, the
|
|
176
192
|
// context-builder may also ADD catalog angles (from resolveAnglePool) that
|
|
@@ -216,7 +232,7 @@ function formatConfigValue(value) {
|
|
|
216
232
|
const GATE_KEYS_WITH_BLOCKING_SEVERITIES = /** @type {const} */ (["draft", "preApproval", "spike"]);
|
|
217
233
|
|
|
218
234
|
const GateConfig = z.strictObject({
|
|
219
|
-
angles: z.array(GateAngleEntry).optional().describe("Review lenses this gate fans out to. A bare string is sugar for { name }; an object may set mandatory/enabled/persona/prompt/model/tier."),
|
|
235
|
+
angles: z.array(GateAngleEntry).optional().describe("Review lenses this gate fans out to. A bare string is sugar for { name }; an object may set mandatory/enabled/persona/prompt/model/tier/scope/categories/kinds."),
|
|
220
236
|
dynamic: GateDynamicConfig.optional().describe("Diff-driven dynamic angle selection policy for this gate."),
|
|
221
237
|
required: z.boolean().default(true).describe("Whether this gate must run."),
|
|
222
238
|
requireCi: z.boolean().default(true).describe("Per-gate CI prerequisite (default true): the gate requires green CI on the current head; false opts this gate out of the CI precondition entirely, including a real failure."),
|
|
@@ -234,6 +250,13 @@ const GateConfig = z.strictObject({
|
|
|
234
250
|
// resolveGateConfig applies the built-in fallback (3) after checking both.
|
|
235
251
|
mediumFixWindow: z.number().int().nonnegative().optional().describe("Per-gate medium fix window: an open medium finding stays in the in-gate fix loop through this many rounds of this gate's chain before deferral. high is exempt (never defers). Default 3."),
|
|
236
252
|
worthFixingNowFixWindow: z.number().int().nonnegative().optional().describe("Deprecated alias for mediumFixWindow (pre-rename key name); mediumFixWindow wins when both are set."),
|
|
253
|
+
// No schema-level `.default()` for the same reason as mediumFixWindow above:
|
|
254
|
+
// a default would fill this key on every config layer independently and
|
|
255
|
+
// shadow a layer that sets only this key. resolveGateConfig applies the
|
|
256
|
+
// built-in fallback ("medium") when the key is absent on the resolved gate.
|
|
257
|
+
inlineSeverityFloor: z.enum(["medium", "low", "nit"]).optional().describe(
|
|
258
|
+
"Lowest defect severity still posted as an inline resolvable review thread. Valid values: \"medium\" (default), \"low\", \"nit\" — the floor can never be raised above \"medium\", so medium/high/question always post inline and only low/nit can ever fold. Findings BELOW this floor are folded into a collapsed <details> block in the verdict-marker body instead of posting inline (they create no gate-authored thread); this enforces the \"never suppress medium/high\" non-goal, keeping the folded-summary \"low/nit\" label accurate by construction. A \"question\" always posts inline regardless of this floor (it must keep its resolvable thread to block gate-close until answered). Lower it (e.g. \"low\" or \"nit\") to restore inline posting of lower severities."
|
|
259
|
+
),
|
|
237
260
|
// Ordered, first-match-wins diff-class angle tiers (see resolveGateTier).
|
|
238
261
|
// Absent/empty = tiers never apply.
|
|
239
262
|
tiers: z.array(GateTier).min(1).describe("Ordered, first-match-wins diff-class angle tiers for this gate. When the first-matching tier's angle set is inside the gate's angle pool, it replaces dynamic angle reduction for that diff class.").optional(),
|
|
@@ -916,13 +939,13 @@ const DEFAULT_REVIEWER_PERSONA = "default-reviewer";
|
|
|
916
939
|
/**
|
|
917
940
|
* Normalize one raw `gates.<gate>.angles[]` entry (string sugar or object,
|
|
918
941
|
* possibly hand-built and never zod-validated — e.g. a test config object) to
|
|
919
|
-
* `{ name, mandatory?, enabled?, persona?, prompt?, model?, tier?, scope? }`.
|
|
942
|
+
* `{ name, mandatory?, enabled?, persona?, prompt?, model?, tier?, scope?, categories?, kinds? }`.
|
|
920
943
|
* Returns null for a malformed/empty entry so callers can filter it out. An
|
|
921
944
|
* invalid `scope` (not one of GATE_ANGLE_SCOPES) is dropped rather than
|
|
922
945
|
* kept verbatim — resolveGateAngleScope's fail-open default only ever needs
|
|
923
946
|
* to handle an ABSENT field, never a foreign value.
|
|
924
947
|
* @param {unknown} a
|
|
925
|
-
* @returns {{name: string, mandatory?: boolean, enabled?: boolean, persona?: string, prompt?: string, model?: string, tier?: string, scope?: string}|null}
|
|
948
|
+
* @returns {{name: string, mandatory?: boolean, enabled?: boolean, persona?: string, prompt?: string, model?: string, tier?: string, scope?: string, categories?: string[], kinds?: string[]}|null}
|
|
926
949
|
*/
|
|
927
950
|
function normalizeAngleEntry(a) {
|
|
928
951
|
if (typeof a === "string") {
|
|
@@ -940,6 +963,17 @@ function normalizeAngleEntry(a) {
|
|
|
940
963
|
if (typeof a.model === "string" && a.model.trim().length > 0) entry.model = a.model.trim();
|
|
941
964
|
if (typeof a.tier === "string" && a.tier.trim().length > 0) entry.tier = a.tier.trim();
|
|
942
965
|
if (typeof a.scope === "string" && GATE_ANGLE_SCOPES.includes(a.scope.trim())) entry.scope = a.scope.trim();
|
|
966
|
+
// Category/file-kind bindings for consumer angles. Enum membership is
|
|
967
|
+
// enforced by the schema; this hand-built path only keeps non-empty string
|
|
968
|
+
// entries (bad names simply never match at resolve time).
|
|
969
|
+
const cats = Array.isArray(a.categories)
|
|
970
|
+
? a.categories.filter((c) => typeof c === "string" && c.trim().length > 0).map((c) => c.trim())
|
|
971
|
+
: [];
|
|
972
|
+
if (cats.length > 0) entry.categories = cats;
|
|
973
|
+
const kinds = Array.isArray(a.kinds)
|
|
974
|
+
? a.kinds.filter((k) => typeof k === "string" && k.trim().length > 0).map((k) => k.trim())
|
|
975
|
+
: [];
|
|
976
|
+
if (kinds.length > 0) entry.kinds = kinds;
|
|
943
977
|
return entry;
|
|
944
978
|
}
|
|
945
979
|
return null;
|
|
@@ -949,7 +983,7 @@ function normalizeAngleEntry(a) {
|
|
|
949
983
|
* Normalize a raw `gates.<gate>.angles` array into full entry objects,
|
|
950
984
|
* dropping malformed entries.
|
|
951
985
|
* @param {unknown} raw
|
|
952
|
-
* @returns {Array<{name: string, mandatory?: boolean, enabled?: boolean, persona?: string, prompt?: string, model?: string, tier?: string, scope?: string}>}
|
|
986
|
+
* @returns {Array<{name: string, mandatory?: boolean, enabled?: boolean, persona?: string, prompt?: string, model?: string, tier?: string, scope?: string, categories?: string[], kinds?: string[]}>}
|
|
953
987
|
*/
|
|
954
988
|
function normalizeAngleEntries(raw) {
|
|
955
989
|
if (!Array.isArray(raw)) return [];
|
|
@@ -1771,7 +1805,7 @@ function resolveBlockingSeverities(config, gate) {
|
|
|
1771
1805
|
*
|
|
1772
1806
|
* @param {DevLoopConfig} config
|
|
1773
1807
|
* @param {"draft"|"preApproval"|"spike"} gate
|
|
1774
|
-
* @returns {{ angles: string[]|null, excludeAngles: string[], mandatoryAngles: string[], required: boolean, requireCi: boolean, blockCleanOnFindingSeverities: string[], dynamicAngles: boolean, additiveAngles: boolean, mediumFixWindow: number, tiers: Array<{name: string, match: object, angles: string[]}> }}
|
|
1808
|
+
* @returns {{ angles: string[]|null, excludeAngles: string[], mandatoryAngles: string[], required: boolean, requireCi: boolean, blockCleanOnFindingSeverities: string[], dynamicAngles: boolean, additiveAngles: boolean, mediumFixWindow: number, inlineSeverityFloor: string, tiers: Array<{name: string, match: object, angles: string[]}>, angleCategoryBindings: Record<string, {categories: string[], kinds: string[]}> }}
|
|
1775
1809
|
* @throws {Error} when ANY gate's (not only the requested one's) PRESENT
|
|
1776
1810
|
* `blockCleanOnFindingSeverities` is schema-invalid (non-array, empty, or an
|
|
1777
1811
|
* out-of-vocabulary entry). Validated EAGERLY across all three gates on every
|
|
@@ -1808,7 +1842,17 @@ export function resolveGateConfig(config, gate) {
|
|
|
1808
1842
|
// mediumFixWindow wins; worthFixingNowFixWindow is the deprecated alias,
|
|
1809
1843
|
// still honored so an unmigrated config keeps its window.
|
|
1810
1844
|
mediumFixWindow: gateConfig?.mediumFixWindow ?? gateConfig?.worthFixingNowFixWindow ?? 3,
|
|
1845
|
+
inlineSeverityFloor: gateConfig?.inlineSeverityFloor ?? "medium",
|
|
1811
1846
|
tiers: gateConfig?.tiers ?? [],
|
|
1847
|
+
// Per-angle category/file-kind bindings for enabled entries that declare
|
|
1848
|
+
// them, so dynamic resolution can select a consumer angle by diff instead
|
|
1849
|
+
// of forcing it mandatory. Only entries WITH a declaration appear here;
|
|
1850
|
+
// everything else keeps today's behavior.
|
|
1851
|
+
angleCategoryBindings: Object.fromEntries(
|
|
1852
|
+
entries
|
|
1853
|
+
.filter((e) => e.enabled !== false && (e.categories || e.kinds))
|
|
1854
|
+
.map((e) => [e.name, { categories: e.categories ?? [], kinds: e.kinds ?? [] }]),
|
|
1855
|
+
),
|
|
1812
1856
|
};
|
|
1813
1857
|
}
|
|
1814
1858
|
|
|
@@ -2161,12 +2205,15 @@ export function resolveFanoutSequential(config) {
|
|
|
2161
2205
|
|
|
2162
2206
|
/**
|
|
2163
2207
|
* Claude-harness-scoped cap on effective fan-out concurrency (per ADR
|
|
2164
|
-
* docs/decisions/0069-claude-harness-fanout-concurrency-clamp.md
|
|
2165
|
-
*
|
|
2166
|
-
*
|
|
2167
|
-
*
|
|
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`.
|
|
2168
2215
|
*/
|
|
2169
|
-
export const CLAUDE_MAX_EFFECTIVE_CONCURRENT =
|
|
2216
|
+
export const CLAUDE_MAX_EFFECTIVE_CONCURRENT = 4;
|
|
2170
2217
|
|
|
2171
2218
|
/**
|
|
2172
2219
|
* Resolve the effective fan-out concurrency (dispatch units per wave): 1 when
|
|
@@ -2438,6 +2485,51 @@ export function resolveGateTier(config, gate, { changedFiles, filesChanged, line
|
|
|
2438
2485
|
return { tier: matched.name, angles: [...new Set([...mandatoryAngles, ...matched.angles])], reason: "tier_match" };
|
|
2439
2486
|
}
|
|
2440
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
|
+
|
|
2441
2533
|
/**
|
|
2442
2534
|
* The primer-owned deterministic review-proportionality plan
|
|
2443
2535
|
* (GATE-EXEC-PROPORTIONALITY, gate-review-sub-loop-contract.md): a single,
|
|
@@ -2445,25 +2537,25 @@ export function resolveGateTier(config, gate, { changedFiles, filesChanged, line
|
|
|
2445
2537
|
* set + execution mode + grouping) is one testable, persistable object.
|
|
2446
2538
|
* Delegates entirely to {@link resolveGateDispatchMode} (mode, including the
|
|
2447
2539
|
* non-overridable size-cap/risk-path/size-outcome/ambiguity floors),
|
|
2448
|
-
* {@link resolveGateTier} (angle set AND diff-classification),
|
|
2449
|
-
* {@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
|
|
2450
2543
|
* of its own beyond the floor-vs-tier precedence below: this is the ONE place
|
|
2451
2544
|
* the primer (emit) and the merge gate (re-verify) compose mode + angles +
|
|
2452
2545
|
* grouping, so they can never drift onto two different floor implementations.
|
|
2453
2546
|
*
|
|
2454
|
-
* Floor-vs-tier precedence:
|
|
2455
|
-
* denylist (`risk_path_touch`), a non-clean/
|
|
2456
|
-
* (`size_outcome_*`,
|
|
2457
|
-
*
|
|
2458
|
-
* (`
|
|
2459
|
-
*
|
|
2460
|
-
* hard size cap (`over_threshold`)
|
|
2461
|
-
*
|
|
2462
|
-
*
|
|
2463
|
-
*
|
|
2464
|
-
*
|
|
2465
|
-
*
|
|
2466
|
-
* (`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.
|
|
2467
2559
|
*
|
|
2468
2560
|
* @param {DevLoopConfig} config
|
|
2469
2561
|
* @param {"draft"|"preApproval"} gate
|
|
@@ -2500,23 +2592,24 @@ export function resolveReviewProportionality(config, gate, {
|
|
|
2500
2592
|
// dispatch-mode facts alone looked trivial.
|
|
2501
2593
|
unclassifiable: tier.reason === "unclassifiable_file",
|
|
2502
2594
|
});
|
|
2503
|
-
// sizeCap (over_threshold) is deliberately EXCLUDED from the
|
|
2504
|
-
//
|
|
2505
|
-
//
|
|
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.
|
|
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.
|
|
2511
2598
|
const dispatchFloorFired = floors.riskPath || floors.sizeOutcome || floors.ambiguity;
|
|
2512
2599
|
const floored = dispatchFloorFired || floors.unclassifiable;
|
|
2513
2600
|
const mode = floored ? "full_fanout" : dispatch.mode;
|
|
2514
2601
|
const reason = floored && !dispatchFloorFired ? "unclassifiable_diff" : dispatch.reason;
|
|
2515
2602
|
// The mandatory-angle floor is present either way: a tier match already
|
|
2516
|
-
// unions mandatoryAngles in (resolveGateTier), and the no-tier
|
|
2517
|
-
//
|
|
2518
|
-
//
|
|
2519
|
-
|
|
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)));
|
|
2520
2613
|
const groups = resolveFanoutGroups(config, gate, angles ?? [], { fullLabel: hasFullLabel });
|
|
2521
2614
|
return Object.freeze({
|
|
2522
2615
|
mode,
|
|
@@ -2531,9 +2624,10 @@ export function resolveReviewProportionality(config, gate, {
|
|
|
2531
2624
|
* Resolve gate angles dynamically when `dynamicAngles` is enabled.
|
|
2532
2625
|
*
|
|
2533
2626
|
* Diff analysis (../analysis/*) filters the configured angle list to angles
|
|
2534
|
-
* relevant to the change set. When `dynamic.subtractive: false
|
|
2535
|
-
*
|
|
2536
|
-
*
|
|
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
|
|
2537
2631
|
* `excludeAngles` a hard ceiling.
|
|
2538
2632
|
*
|
|
2539
2633
|
* Diff-class tiers (resolveGateTier) are consulted FIRST: a tier match returns
|
|
@@ -2542,15 +2636,14 @@ export function resolveReviewProportionality(config, gate, {
|
|
|
2542
2636
|
*
|
|
2543
2637
|
* GATE-EXEC-PROPORTIONALITY floor-awareness (opt-in via `checkFloors`): when
|
|
2544
2638
|
* the caller supplies `checkFloors: true` (and, when available, `sizeOutcome`
|
|
2545
|
-
* from check-size-budget.mjs), this delegates to {@link
|
|
2639
|
+
* from check-size-budget.mjs), this delegates floor determination to {@link
|
|
2546
2640
|
* resolveReviewProportionality} — the SAME composer resolve-gate-dispatch.mjs
|
|
2547
|
-
* uses — over the SAME diff-derived changed-file/scope facts
|
|
2548
|
-
*
|
|
2549
|
-
*
|
|
2550
|
-
*
|
|
2551
|
-
*
|
|
2552
|
-
*
|
|
2553
|
-
* 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.
|
|
2554
2647
|
*
|
|
2555
2648
|
* @param {import("./types.js").DevLoopConfig} config
|
|
2556
2649
|
* @param {"draft"|"preApproval"} gate
|
|
@@ -2559,7 +2652,7 @@ export function resolveReviewProportionality(config, gate, {
|
|
|
2559
2652
|
* @param {boolean} [options.hasFullLabel] — `gate:full` label present on the PR (bypasses tier resolution)
|
|
2560
2653
|
* @param {boolean} [options.checkFloors] — opt into the GATE-EXEC-PROPORTIONALITY floor check above
|
|
2561
2654
|
* @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
|
|
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)
|
|
2563
2656
|
* @returns {{ recommendedAngles: string[] | null, skippedAngles: string[], reasons: Record<string,string>, fallbackToAll: boolean, dynamicAnglesActive: boolean, addedAngles: string[], addedReasons: Record<string,string> }}
|
|
2564
2657
|
*/
|
|
2565
2658
|
export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabel = false, checkFloors = false, sizeOutcome, explicitAngles } = {}) {
|
|
@@ -2581,30 +2674,20 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
|
|
|
2581
2674
|
linesChanged = lineStats.added + lineStats.deleted;
|
|
2582
2675
|
}
|
|
2583
2676
|
}
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
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)) {
|
|
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) {
|
|
2608
2691
|
return {
|
|
2609
2692
|
recommendedAngles: explicitAngles,
|
|
2610
2693
|
skippedAngles: [],
|
|
@@ -2644,12 +2727,24 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
|
|
|
2644
2727
|
}
|
|
2645
2728
|
|
|
2646
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));
|
|
2647
2740
|
return {
|
|
2648
|
-
recommendedAngles
|
|
2649
|
-
skippedAngles
|
|
2650
|
-
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
|
+
),
|
|
2651
2746
|
fallbackToAll: false,
|
|
2652
|
-
dynamicAnglesActive:
|
|
2747
|
+
dynamicAnglesActive: bestEffortWithoutDiff,
|
|
2653
2748
|
addedAngles: [],
|
|
2654
2749
|
addedReasons: {},
|
|
2655
2750
|
};
|
|
@@ -2668,6 +2763,10 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
|
|
|
2668
2763
|
});
|
|
2669
2764
|
|
|
2670
2765
|
const categories = [...new Set(analysis.t1?.changeCategories ?? [])];
|
|
2766
|
+
// File kinds present in the diff, to honor a consumer angle's `kinds`
|
|
2767
|
+
// binding. classifyFile is the same classifier the categories above derive
|
|
2768
|
+
// from, so this adds no new classification surface.
|
|
2769
|
+
const fileKinds = [...new Set((analysis.t0?.files ?? []).map(classifyFile))];
|
|
2671
2770
|
|
|
2672
2771
|
// excludeAngles is a hard ceiling: computed once and reused both to cap the
|
|
2673
2772
|
// additive anglePool and to filter mandatoryAngles below.
|
|
@@ -2676,12 +2775,13 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
|
|
|
2676
2775
|
? resolveAnglePool(config).filter(a => !excluded.has(a))
|
|
2677
2776
|
: undefined;
|
|
2678
2777
|
|
|
2679
|
-
const
|
|
2680
|
-
const dynamicResult = resolve({
|
|
2778
|
+
const dynamicResult = resolveDynamicAngles({
|
|
2681
2779
|
configuredAngles: candidatePool,
|
|
2682
2780
|
changeCategories: categories,
|
|
2683
2781
|
ambiguous: analysis.ambiguous,
|
|
2684
2782
|
anglePool,
|
|
2783
|
+
angleDeclarations: gateConfig.angleCategoryBindings,
|
|
2784
|
+
fileKinds,
|
|
2685
2785
|
});
|
|
2686
2786
|
|
|
2687
2787
|
// Merge: mandatory always included (filtered by excludeAngles) + dynamically-selected
|
|
@@ -2696,12 +2796,24 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
|
|
|
2696
2796
|
Object.entries(dynamicResult.addedReasons ?? {}).filter(([a]) => !mandatory.has(a))
|
|
2697
2797
|
);
|
|
2698
2798
|
|
|
2699
|
-
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;
|
|
2700
2812
|
|
|
2701
2813
|
return {
|
|
2702
2814
|
recommendedAngles,
|
|
2703
|
-
skippedAngles
|
|
2704
|
-
reasons
|
|
2815
|
+
skippedAngles,
|
|
2816
|
+
reasons,
|
|
2705
2817
|
fallbackToAll: dynamicResult.fallbackToAll,
|
|
2706
2818
|
dynamicAnglesActive: true,
|
|
2707
2819
|
addedAngles,
|
|
@@ -126,13 +126,39 @@ 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
|
|
135
141
|
prompt: 'Review the PR description for completeness, contract fitness, and checkbox formatting before this PR is marked ready for review. The PR body is the implementation contract — it must have: - A Summary section explaining what changed and why - A Scope and context section defining the boundary of the change - An Acceptance criteria section with the linked issue acceptance criteria - A Definition of done section - A Non-goals section - A Validation command section describing exactly how to verify the change - The "Closes #N" line must match the linked issue; flag changes that alter or remove the operator-intended close target Checkboxes (`- [ ]` / `- [x]`, or `* [ ]` / `* [x]`) must appear inside genuine Markdown list items. Flag any checkbox marker used outside a list item (including table cells) as a medium finding. Flag any checkbox marker wrapped in backticks (e.g. `` `[x]` ``) as a medium finding. Flag PRs where the body is a single sentence or lacks any of these sections. Do not block on formatting preferences other than checkbox correctness.'
|
|
142
|
+
- name: holistic
|
|
143
|
+
mandatory: true
|
|
144
|
+
persona: review
|
|
145
|
+
prompt: >-
|
|
146
|
+
Review the WHOLE change holistically, on its merits, as a senior
|
|
147
|
+
engineer doing a final read of the entire diff. You are independent and
|
|
148
|
+
un-briefed: you receive only the spec (acceptance criteria, definition
|
|
149
|
+
of done, non-goals) and the diff — no author or developer brief and no
|
|
150
|
+
steering from the reviewed party. Your mandate is broad, not a single
|
|
151
|
+
named lens. Read the whole change end to end and judge whether, taken
|
|
152
|
+
together, it correctly and completely does what the spec asks, is
|
|
153
|
+
internally coherent, and is safe to ship. Concentrate on CROSS-CUTTING
|
|
154
|
+
problems that no narrow angle owns: mismatches between parts of the
|
|
155
|
+
change, gaps between the diff and the acceptance criteria, unintended
|
|
156
|
+
interactions across modules, missing pieces the spec implies, and
|
|
157
|
+
defects that would otherwise surface later in Copilot review or a
|
|
158
|
+
consumer repo. Cite concrete file:line evidence for each finding and
|
|
159
|
+
give a minimal fix. Respect declared non-goals — do not manufacture
|
|
160
|
+
scope. If the change is coherent and complete against the spec, return
|
|
161
|
+
clean.
|
|
136
162
|
# #1442 (ADR 0041 prose half): required fail-closed deslop angle for prose
|
|
137
163
|
# deliverables. Runs the A/B-contrast-removal deslop step (ab-contrast-
|
|
138
164
|
# deslop-step.md) — flag surviving binary-contrast constructions so the gate
|
|
@@ -221,6 +247,14 @@ gates:
|
|
|
221
247
|
angles: [srp, soc, ocp, lsp, isp, dip]
|
|
222
248
|
- name: finalization
|
|
223
249
|
angles: [correctness-final, ui-validation]
|
|
250
|
+
# The holistic reviewer reads the whole diff, so it gets its own
|
|
251
|
+
# reviewer rather than being auto-chunked with unrelated leftover
|
|
252
|
+
# angles — one reviewer reviews the whole change holistically.
|
|
253
|
+
# A consumer repo that overrides gates.fanout.groups replaces this
|
|
254
|
+
# table wholesale (shallow merge) and must restate this singleton to
|
|
255
|
+
# keep holistic un-batched.
|
|
256
|
+
- name: holistic
|
|
257
|
+
angles: [holistic]
|
|
224
258
|
preApproval:
|
|
225
259
|
angles:
|
|
226
260
|
- name: dry
|
|
@@ -294,9 +328,35 @@ gates:
|
|
|
294
328
|
require a matrix on the PR — the matrix lives on the issue; the PR carries the derived
|
|
295
329
|
checklists. The boundary is explicit: the deterministic block enforces completeness (nothing
|
|
296
330
|
left unchecked/forgotten); you verify each [x] is real and faithfully derived.
|
|
297
|
-
-
|
|
298
|
-
|
|
299
|
-
|
|
331
|
+
- name: holistic
|
|
332
|
+
mandatory: true
|
|
333
|
+
persona: review
|
|
334
|
+
prompt: >-
|
|
335
|
+
Review the WHOLE change holistically, on its merits, as a senior
|
|
336
|
+
engineer doing a final read of the entire diff. You are independent and
|
|
337
|
+
un-briefed: you receive only the spec (acceptance criteria, definition
|
|
338
|
+
of done, non-goals) and the diff — no author or developer brief and no
|
|
339
|
+
steering from the reviewed party. Your mandate is broad, not a single
|
|
340
|
+
named lens. Read the whole change end to end and judge whether, taken
|
|
341
|
+
together, it correctly and completely does what the spec asks, is
|
|
342
|
+
internally coherent, and is safe to ship. Concentrate on CROSS-CUTTING
|
|
343
|
+
problems that no narrow angle owns: mismatches between parts of the
|
|
344
|
+
change, gaps between the diff and the acceptance criteria, unintended
|
|
345
|
+
interactions across modules, missing pieces the spec implies, and
|
|
346
|
+
defects that would otherwise surface later in Copilot review or a
|
|
347
|
+
consumer repo. Cite concrete file:line evidence for each finding and
|
|
348
|
+
give a minimal fix. Respect declared non-goals — do not manufacture
|
|
349
|
+
scope. If the change is coherent and complete against the spec, return
|
|
350
|
+
clean.
|
|
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."
|
|
300
360
|
required: true
|
|
301
361
|
# Relaxed spike gate profile (#965). A spike's deliverable is a findings doc,
|
|
302
362
|
# not production code, so it is intentionally lighter than the production
|