@dev-loops/core 1.0.3 → 1.0.4-pre.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/analysis/change-classifier.mjs +35 -0
- package/src/analysis/diff-analyzer.mjs +89 -16
- package/src/claude/hook-decisions.mjs +117 -18
- package/src/config/config.mjs +56 -6
- package/src/config/extension-defaults.yaml +48 -0
- package/src/github/copilot-helpers.mjs +63 -12
- package/src/loop/bash-command-classify.mjs +251 -14
- package/src/loop/copilot-ci-status.mjs +116 -6
- package/src/loop/finding-cluster.mjs +30 -11
- package/src/loop/gate-carry-forward.mjs +39 -6
- package/src/loop/gate-fanin.mjs +37 -3
- package/src/loop/issue-refinement-artifact.mjs +117 -9
- package/src/loop/merge-approval.mjs +121 -5
- package/src/loop/pr-gate-coordination.mjs +74 -12
- package/src/loop/run-inspection.mjs +6 -0
- package/src/loop/spec-authority.mjs +19 -6
- package/src/loop/ui-e2e-scoping.mjs +1 -0
|
@@ -177,10 +177,19 @@ export function angleReviewSurface(angle, { alwaysRerun } = {}) {
|
|
|
177
177
|
* @param {AngleReviewSurface} [input.angleSurface] — the angle's declared surface;
|
|
178
178
|
* derived from {@link angleReviewSurface} when omitted.
|
|
179
179
|
* @param {string[]} input.changedFiles — repo-relative paths changed between head
|
|
180
|
-
* A and head B (the delta, NOT the full PR diff against base).
|
|
180
|
+
* A and head B (the delta, NOT the full PR diff against base). For a base-move
|
|
181
|
+
* re-gate the caller passes the MAIN-RELATIVE incremental delta: files changed
|
|
182
|
+
* since head A whose head-B content is genuinely PR-own (differs from
|
|
183
|
+
* origin/main), NOT the raw two-dot A..B delta — so an integrate-only base-move
|
|
184
|
+
* that only replays already-merged main commits contributes an empty delta.
|
|
181
185
|
* @param {string} input.prevVerdict — the angle's verdict at head A. "clean" and
|
|
182
186
|
* "findings_present" are carry-forward-eligible; anything else (e.g.
|
|
183
187
|
* "blocked", missing) is not.
|
|
188
|
+
* @param {boolean} [input.deltaComplete=false] — the caller PROVES `changedFiles`
|
|
189
|
+
* is the complete, successfully-computed delta (git succeeded and, for a
|
|
190
|
+
* base-move, the main-relative reduction ran). Only then does an EMPTY delta
|
|
191
|
+
* mean "nothing PR-own changed" and carry forward; without the proof an empty
|
|
192
|
+
* delta is indistinguishable from an unavailable one and still fails closed.
|
|
184
193
|
* @returns {{ carryForward: boolean, reason: string }}
|
|
185
194
|
*/
|
|
186
195
|
|
|
@@ -207,7 +216,7 @@ export function isDevLoopConfigSourcePath(filePath) {
|
|
|
207
216
|
return DEV_LOOP_CONFIG_SOURCE_RE.test(filePath.trim().replace(/\\/g, "/"));
|
|
208
217
|
}
|
|
209
218
|
|
|
210
|
-
export function resolveAngleCarryForward({ angle, angleSurface, changedFiles, prevVerdict }) {
|
|
219
|
+
export function resolveAngleCarryForward({ angle, angleSurface, changedFiles, prevVerdict, deltaComplete = false }) {
|
|
211
220
|
if (!CARRY_FORWARD_ELIGIBLE_VERDICTS.has(prevVerdict)) {
|
|
212
221
|
return {
|
|
213
222
|
carryForward: false,
|
|
@@ -221,7 +230,16 @@ export function resolveAngleCarryForward({ angle, angleSurface, changedFiles, pr
|
|
|
221
230
|
if (surface.kind === "unknown") {
|
|
222
231
|
return { carryForward: false, reason: "angle has no declared review surface (fail-closed)" };
|
|
223
232
|
}
|
|
224
|
-
if (!Array.isArray(changedFiles)
|
|
233
|
+
if (!Array.isArray(changedFiles)) {
|
|
234
|
+
return { carryForward: false, reason: "delta is unavailable (fail-closed)" };
|
|
235
|
+
}
|
|
236
|
+
// A PROVEN-complete empty delta (deltaComplete) means the main-relative
|
|
237
|
+
// reduction found NO PR-own change since the prior reviewed head — an
|
|
238
|
+
// integrate-only base-move that only replays already-merged main commits.
|
|
239
|
+
// That carries forward: the zero-file loop below proves the surface untouched.
|
|
240
|
+
// Without that proof an empty delta is indistinguishable from an unavailable
|
|
241
|
+
// one, so it still fails closed.
|
|
242
|
+
if (changedFiles.length === 0 && !deltaComplete) {
|
|
225
243
|
return { carryForward: false, reason: "delta is empty or unavailable (fail-closed)" };
|
|
226
244
|
}
|
|
227
245
|
for (const file of changedFiles) {
|
|
@@ -287,12 +305,22 @@ const COPILOT_REVIEW_SURFACE_KINDS = new Set(["code", "test", "config", "ci"]);
|
|
|
287
305
|
* re-runs, since classifyFile is path-based). Any code/test/config/CI file, an unclassifiable
|
|
288
306
|
* file, or an empty/unavailable delta -> re-run (fresh blocking round required).
|
|
289
307
|
*
|
|
308
|
+
* `deltaComplete` mirrors {@link resolveAngleCarryForward}: when the caller PROVES
|
|
309
|
+
* `changedFiles` is the complete main-relative reduction, an EMPTY delta means an
|
|
310
|
+
* integrate-only base-move touched no Copilot surface and the convergence carries
|
|
311
|
+
* forward. Without the proof an empty delta still fails closed.
|
|
312
|
+
*
|
|
290
313
|
* @param {object} input
|
|
291
314
|
* @param {string[]} input.changedFiles — delta since the converged head
|
|
315
|
+
* @param {boolean} [input.deltaComplete=false] — proof the empty case is a real
|
|
316
|
+
* "nothing PR-own changed", not an unavailable delta
|
|
292
317
|
* @returns {{ carryForward: boolean, reason: string }}
|
|
293
318
|
*/
|
|
294
|
-
export function resolveConvergenceCarryForward({ changedFiles }) {
|
|
295
|
-
if (!Array.isArray(changedFiles)
|
|
319
|
+
export function resolveConvergenceCarryForward({ changedFiles, deltaComplete = false }) {
|
|
320
|
+
if (!Array.isArray(changedFiles)) {
|
|
321
|
+
return { carryForward: false, reason: "delta is unavailable (fail-closed)" };
|
|
322
|
+
}
|
|
323
|
+
if (changedFiles.length === 0 && !deltaComplete) {
|
|
296
324
|
return { carryForward: false, reason: "delta is empty or unavailable (fail-closed)" };
|
|
297
325
|
}
|
|
298
326
|
for (const file of changedFiles) {
|
|
@@ -304,5 +332,10 @@ export function resolveConvergenceCarryForward({ changedFiles }) {
|
|
|
304
332
|
return { carryForward: false, reason: `delta touches Copilot's review surface (${kind}): ${file}` };
|
|
305
333
|
}
|
|
306
334
|
}
|
|
307
|
-
return {
|
|
335
|
+
return {
|
|
336
|
+
carryForward: true,
|
|
337
|
+
reason: changedFiles.length === 0
|
|
338
|
+
? "no PR-own change since the converged head (integrate-only base-move), provably outside Copilot's review surface"
|
|
339
|
+
: "delta is a pure doc/prose bump, provably outside Copilot's review surface",
|
|
340
|
+
};
|
|
308
341
|
}
|
package/src/loop/gate-fanin.mjs
CHANGED
|
@@ -828,6 +828,7 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
|
|
|
828
828
|
disposition: deriveDisposition(severity, { isBlocking, locatable: hasLocatableShape(f) }),
|
|
829
829
|
};
|
|
830
830
|
if (typeof f.file === "string" && f.file.trim().length > 0) entry.file = f.file.trim();
|
|
831
|
+
if (Array.isArray(f.files)) entry.files = f.files.filter((file) => typeof file === "string" && file.trim().length > 0).map((file) => file.trim());
|
|
831
832
|
if (typeof f.line === "number" && Number.isFinite(f.line)) entry.line = f.line;
|
|
832
833
|
if (typeof f.recommendation === "string" && f.recommendation.trim().length > 0) {
|
|
833
834
|
entry.recommendation = f.recommendation.trim();
|
|
@@ -871,6 +872,38 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
|
|
|
871
872
|
*/
|
|
872
873
|
export const JUDGE_DISPOSITIONS = Object.freeze(["act", "defer", "reject"]);
|
|
873
874
|
|
|
875
|
+
/**
|
|
876
|
+
* The `scopeDrift.verdict` vocabulary — the single shared source. The validator
|
|
877
|
+
* below consumes it directly, and a divergence-guard test asserts the judge
|
|
878
|
+
* persona surface (`agents/judge.agent.md`) documents exactly this set, so the
|
|
879
|
+
* producer and enforcer cannot silently drift apart.
|
|
880
|
+
*/
|
|
881
|
+
export const SCOPE_DRIFT_VERDICTS = Object.freeze(["within_scope", "drift_detected"]);
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Aliases a persona-following judge reaches for, normalized to the canonical
|
|
885
|
+
* vocabulary before validation. `none` is the intuitive no-drift spelling; it
|
|
886
|
+
* maps to `within_scope` so a semantically-correct verdict passes with no
|
|
887
|
+
* resend or hand-edit, disposition and rationale byte-intact.
|
|
888
|
+
*/
|
|
889
|
+
export const SCOPE_DRIFT_VERDICT_ALIASES = Object.freeze({ none: "within_scope" });
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Normalize a `scopeDrift.verdict` alias to its canonical value. Canonical and
|
|
893
|
+
* unknown values pass through untouched; validation rejects unknowns.
|
|
894
|
+
* @param {unknown} verdict
|
|
895
|
+
* @returns {unknown}
|
|
896
|
+
*/
|
|
897
|
+
export function normalizeScopeDriftVerdict(verdict) {
|
|
898
|
+
// Only a primitive string spelling normalizes. `hasOwnProperty.call` coerces
|
|
899
|
+
// its key argument, so without this guard a boxed `new String("none")` or an
|
|
900
|
+
// object whose `toString()` returns `"none"` would alias to `within_scope` —
|
|
901
|
+
// a fail-open contradicting the validator's non-string-fails-closed contract.
|
|
902
|
+
return typeof verdict === "string" && Object.prototype.hasOwnProperty.call(SCOPE_DRIFT_VERDICT_ALIASES, verdict)
|
|
903
|
+
? SCOPE_DRIFT_VERDICT_ALIASES[verdict]
|
|
904
|
+
: verdict;
|
|
905
|
+
}
|
|
906
|
+
|
|
874
907
|
/**
|
|
875
908
|
* Validate a judge verdict artifact shape (the `judge` agent's only write).
|
|
876
909
|
* Pure; throws on a malformed verdict rather than enriching findings with
|
|
@@ -901,8 +934,9 @@ export function validateJudgeVerdict(verdict) {
|
|
|
901
934
|
throw new Error("judge verdict.scopeDrift must be an object");
|
|
902
935
|
}
|
|
903
936
|
const sd = /** @type {Record<string, unknown>} */ (v.scopeDrift);
|
|
904
|
-
|
|
905
|
-
|
|
937
|
+
const normalizedVerdict = normalizeScopeDriftVerdict(sd.verdict);
|
|
938
|
+
if (!SCOPE_DRIFT_VERDICTS.includes(/** @type {string} */ (normalizedVerdict))) {
|
|
939
|
+
throw new Error(`judge verdict.scopeDrift.verdict must be one of: ${SCOPE_DRIFT_VERDICTS.join(", ")}`);
|
|
906
940
|
}
|
|
907
941
|
if (typeof sd.rationale !== "string" || sd.rationale.trim().length === 0) {
|
|
908
942
|
throw new Error("judge verdict.scopeDrift.rationale must be a non-empty string");
|
|
@@ -949,7 +983,7 @@ export function validateJudgeVerdict(verdict) {
|
|
|
949
983
|
}
|
|
950
984
|
}
|
|
951
985
|
}
|
|
952
|
-
return { headSha: v.headSha, scopeDrift:
|
|
986
|
+
return { headSha: v.headSha, scopeDrift: { ...sd, verdict: normalizedVerdict }, dispositions: v.dispositions };
|
|
953
987
|
}
|
|
954
988
|
|
|
955
989
|
/**
|
|
@@ -949,14 +949,95 @@ function sectionHasBody(section) {
|
|
|
949
949
|
return false;
|
|
950
950
|
}
|
|
951
951
|
|
|
952
|
+
// A top-level list marker line (any GFM/CommonMark family: `-`/`*`/`+`,
|
|
953
|
+
// ordered `N.`/`N)`, optional leading blockquote `>` runs) with NO checkbox.
|
|
954
|
+
// `validatePrBodySpec`-local: unlike `parseChecklistItems` (whose
|
|
955
|
+
// `checked: null` state fires ONLY for the bare-dash form — see its
|
|
956
|
+
// docstring), this recognizes every marker family, so a mixed AC/DoD section
|
|
957
|
+
// (`- [ ] works` next to `* plain`) cannot escape detection just because the
|
|
958
|
+
// plain line used a non-dash marker. No leading-space match before the
|
|
959
|
+
// marker/blockquote keeps this top-level only: an indented continuation
|
|
960
|
+
// (` - detail`) is sub-content and is not matched. The negative lookahead
|
|
961
|
+
// excludes a real checkbox line so `- [ ] works` is never double-counted; the
|
|
962
|
+
// lookahead treats end-of-line as a checkbox terminator too (`(?:\s|$)`), so
|
|
963
|
+
// an empty checkbox placeholder (`- [ ]`/`- [x]`, no trailing text) is
|
|
964
|
+
// excluded rather than mis-caught as a plain bullet — `parseChecklistItems`
|
|
965
|
+
// already documents empty placeholders as skipped, and this scan must not
|
|
966
|
+
// contradict that.
|
|
967
|
+
const TOP_LEVEL_NON_CHECKBOX_BULLET_PATTERN = /^(?:>\s*)*(?:[-*+]|\d+[.)])\s+(?!\[[ xX]\](?:\s|$))(.+?)\s*$/u;
|
|
968
|
+
|
|
969
|
+
// A spaced Markdown thematic break (`* * *`, `- - -`) also matches
|
|
970
|
+
// `TOP_LEVEL_NON_CHECKBOX_BULLET_PATTERN` (marker, whitespace, more marker
|
|
971
|
+
// text) but is a divider, not a bullet — `parseChecklistItems` never treats
|
|
972
|
+
// it as an item, so counting it here would reject a legitimate AC/DoD
|
|
973
|
+
// section over a divider line. `***`/`---` (no spaces) already fail the
|
|
974
|
+
// bullet pattern's `\s+` requirement, so only the spaced form needs this
|
|
975
|
+
// guard. Same-marker-only via the backreference, and only `-`/`*`/`_` per
|
|
976
|
+
// the CommonMark thematic-break rule (3+ of the SAME char, optional spaces,
|
|
977
|
+
// nothing else) — `+` is NOT a valid thematic-break marker, so `+ + +`
|
|
978
|
+
// stays correctly counted as a real (single-item) plain bullet.
|
|
979
|
+
const THEMATIC_BREAK_RE = /^([-*_])(?:[ \t]*\1){2,}[ \t]*$/u;
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* Scan a flattened section body for top-level non-checkbox bullet lines
|
|
983
|
+
* (`TOP_LEVEL_NON_CHECKBOX_BULLET_PATTERN`). Fence-skipped via the shared
|
|
984
|
+
* `stepFence` so a fenced fake bullet cannot spoof this check. Thematic-break
|
|
985
|
+
* divider lines (`THEMATIC_BREAK_RE`) are excluded — see its docstring.
|
|
986
|
+
*/
|
|
987
|
+
function scanTopLevelNonCheckboxBulletLines(text) {
|
|
988
|
+
if (typeof text !== "string" || text.length === 0) return [];
|
|
989
|
+
const found = [];
|
|
990
|
+
let fence = null;
|
|
991
|
+
for (const line of text.split(/\r?\n/u)) {
|
|
992
|
+
const step = stepFence(fence, line);
|
|
993
|
+
fence = step.fence;
|
|
994
|
+
if (step.insideFence) continue;
|
|
995
|
+
const match = TOP_LEVEL_NON_CHECKBOX_BULLET_PATTERN.exec(line);
|
|
996
|
+
if (!match || match[1].trim().length === 0) continue;
|
|
997
|
+
const strippedLine = line.replace(/^(?:>\s*)*/u, "").trim();
|
|
998
|
+
if (THEMATIC_BREAK_RE.test(strippedLine)) continue;
|
|
999
|
+
found.push(match[1].trim());
|
|
1000
|
+
}
|
|
1001
|
+
return found;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* Scan every section the completeness block would read (ALL sections matching
|
|
1006
|
+
* `patterns`, deep-flattened past `###` sub-headings via
|
|
1007
|
+
* `findAllSectionsByPatterns` + `flattenSectionDeep` — the same union
|
|
1008
|
+
* `extractPrBodyUncheckedChecklistItems` reads) for top-level plain bullets
|
|
1009
|
+
* of ANY marker (no checkbox). Shares the section-set read with the
|
|
1010
|
+
* completeness block on purpose: a plain bullet invisible to
|
|
1011
|
+
* `validatePrBodySpec`'s own single-section read but visible to the
|
|
1012
|
+
* completeness block (a duplicate AC/DoD heading, or a bullet nested under a
|
|
1013
|
+
* `###` sub-heading) must still reject here, or the two surfaces diverge on
|
|
1014
|
+
* the same checkbox-marker fail-open class this module closes for the simple
|
|
1015
|
+
* single-section case. Local `scanTopLevelNonCheckboxBulletLines` scan, not
|
|
1016
|
+
* `parseChecklistItems`'s `checked: null` state, so a non-dash plain bullet
|
|
1017
|
+
* (`*`/`+`/ordered) is caught too — `parseChecklistItems` and the shared
|
|
1018
|
+
* deterministic pre-approval completeness-block logic it backs (see
|
|
1019
|
+
* acceptance-criteria-verification.md) stay untouched.
|
|
1020
|
+
*/
|
|
1021
|
+
function scanPlainBullets(sections, patterns) {
|
|
1022
|
+
const matched = findAllSectionsByPatterns(sections, patterns);
|
|
1023
|
+
const items = [];
|
|
1024
|
+
for (let i = 0; i < sections.length; i += 1) {
|
|
1025
|
+
if (!matched.includes(sections[i])) continue;
|
|
1026
|
+
items.push(...scanTopLevelNonCheckboxBulletLines(flattenSectionDeep(sections, i)));
|
|
1027
|
+
}
|
|
1028
|
+
return items;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
952
1031
|
/**
|
|
953
1032
|
* Validate that a PR body carries every invariant required to serve as the
|
|
954
1033
|
* lightweight spec-of-record: Objective/why, in-scope, explicit non-goals,
|
|
955
|
-
* testable Acceptance criteria (>=1
|
|
956
|
-
* (>=1
|
|
1034
|
+
* testable Acceptance criteria (>=1 checkbox item), Definition of done
|
|
1035
|
+
* (>=1 checkbox item), Open questions/risks, and — unless issue-less mode is
|
|
957
1036
|
* requested — a GitHub closing-keyword issue reference. Reuses the generic
|
|
958
1037
|
* markdown logic so there is no parallel validator. Fails closed: every missing
|
|
959
|
-
* invariant is reported under its distinct `missing_*` code
|
|
1038
|
+
* invariant is reported under its distinct `missing_*` code (plus the two
|
|
1039
|
+
* `*_not_checkboxes` codes for a plain-bullet AC/DoD section — see
|
|
1040
|
+
* `scanPlainBullets`). Pure; no I/O.
|
|
960
1041
|
*
|
|
961
1042
|
* Issue-less mode (`issueLess: true`): the closing-issue linkage flips from
|
|
962
1043
|
* REQUIRED to FORBIDDEN (the PR is the sole artifact), failing closed under
|
|
@@ -1044,21 +1125,48 @@ export function validatePrBodySpec({ body = "", expectedIssue = null, issueLess
|
|
|
1044
1125
|
}
|
|
1045
1126
|
}
|
|
1046
1127
|
|
|
1128
|
+
// Checkbox markers are REQUIRED (not just any bullet): the completeness
|
|
1129
|
+
// block (extractPrBodyUncheckedChecklistItems) only ever sees checkbox
|
|
1130
|
+
// items, so a plain bullet here would fail-open the completeness check
|
|
1131
|
+
// even though this validator accepted it. The plain-bullet scan reads the
|
|
1132
|
+
// SAME section set the completeness block reads (scanPlainBullets), not
|
|
1133
|
+
// just the first section, so a duplicate heading or a bullet nested under a
|
|
1134
|
+
// `###` sub-heading cannot hide from this check either. The scan runs
|
|
1135
|
+
// INDEPENDENTLY of parseChecklistItems (not gated on it finding items) and
|
|
1136
|
+
// is checked FIRST: an AC/DoD section made entirely of non-dash plain
|
|
1137
|
+
// bullets (`* plain`, `1. plain`) yields zero parsed checkbox items, so
|
|
1138
|
+
// gating the scan on "items found" would misreport that case as
|
|
1139
|
+
// missing_acceptance_criteria/missing_definition_of_done instead of the
|
|
1140
|
+
// distinct *_not_checkboxes code.
|
|
1047
1141
|
const acSection = findSectionByPatterns(sections, ACCEPTANCE_SECTION_PATTERNS);
|
|
1048
|
-
const
|
|
1049
|
-
|
|
1142
|
+
const acParsed = acSection ? parseChecklistItems(acSection.bodyLines.join("\n")) : [];
|
|
1143
|
+
const acItems = acParsed.filter((item) => item.checked !== null).map((item) => item.text);
|
|
1144
|
+
const acPlainBullets = scanPlainBullets(sections, ACCEPTANCE_SECTION_PATTERNS);
|
|
1145
|
+
if (acPlainBullets.length > 0) {
|
|
1146
|
+
errors.push({
|
|
1147
|
+
code: "acceptance_criteria_not_checkboxes",
|
|
1148
|
+
message: `Acceptance criteria must use checkbox markers ('- [ ]'/'- [x]'), not plain bullets (found ${acPlainBullets.length} plain bullet(s)); plain bullets fail-open the completeness block.`,
|
|
1149
|
+
});
|
|
1150
|
+
} else if (acParsed.length === 0) {
|
|
1050
1151
|
errors.push({
|
|
1051
1152
|
code: "missing_acceptance_criteria",
|
|
1052
|
-
message: "Missing testable Acceptance criteria (no
|
|
1153
|
+
message: "Missing testable Acceptance criteria (no checkbox items found).",
|
|
1053
1154
|
});
|
|
1054
1155
|
}
|
|
1055
1156
|
|
|
1056
1157
|
const dodSection = findSectionByPatterns(sections, DOD_SECTION_PATTERNS);
|
|
1057
|
-
const
|
|
1058
|
-
|
|
1158
|
+
const dodParsed = dodSection ? parseChecklistItems(dodSection.bodyLines.join("\n")) : [];
|
|
1159
|
+
const dodItems = dodParsed.filter((item) => item.checked !== null).map((item) => item.text);
|
|
1160
|
+
const dodPlainBullets = scanPlainBullets(sections, DOD_SECTION_PATTERNS);
|
|
1161
|
+
if (dodPlainBullets.length > 0) {
|
|
1162
|
+
errors.push({
|
|
1163
|
+
code: "definition_of_done_not_checkboxes",
|
|
1164
|
+
message: `Definition of done must use checkbox markers ('- [ ]'/'- [x]'), not plain bullets (found ${dodPlainBullets.length} plain bullet(s)); plain bullets fail-open the completeness block.`,
|
|
1165
|
+
});
|
|
1166
|
+
} else if (dodParsed.length === 0) {
|
|
1059
1167
|
errors.push({
|
|
1060
1168
|
code: "missing_definition_of_done",
|
|
1061
|
-
message: "Missing Definition of done (no
|
|
1169
|
+
message: "Missing Definition of done (no checkbox items found).",
|
|
1062
1170
|
});
|
|
1063
1171
|
}
|
|
1064
1172
|
|
|
@@ -11,13 +11,16 @@
|
|
|
11
11
|
*
|
|
12
12
|
* It reuses, never re-derives, the existing precondition set:
|
|
13
13
|
* `resolveSizeBudgetHumanApprovalRequired` (size-budget-merge-gate),
|
|
14
|
-
* `findBlockingTitleMarkers` (pr-title-markers),
|
|
14
|
+
* `findBlockingTitleMarkers` (pr-title-markers), the detect-checkpoint-evidence
|
|
15
15
|
* `preMergeGateCheck` bundle (draft/pre-approval verdicts, threads, runner lock,
|
|
16
|
-
* fan-out provenance)
|
|
17
|
-
*
|
|
16
|
+
* fan-out provenance), and `classifyCopilotReviewBodyDisposition` (copilot-helpers,
|
|
17
|
+
* the same current-head Copilot disposition detection the loop's
|
|
18
|
+
* `copilotBodyFeedbackUnresolved` reads). This module adds ONLY the
|
|
19
|
+
* human-approver identity, the merge-class split, the Copilot-convergence
|
|
20
|
+
* precondition, and the aggregate naming.
|
|
18
21
|
*/
|
|
19
22
|
|
|
20
|
-
import { isCopilotLogin } from "../github/copilot-helpers.mjs";
|
|
23
|
+
import { isCopilotLogin, classifyCopilotReviewBodyDisposition, COPILOT_DISPOSITION, SUBMITTED_REVIEW_STATES } from "../github/copilot-helpers.mjs";
|
|
21
24
|
import { findBlockingTitleMarkers } from "./pr-title-markers.mjs";
|
|
22
25
|
import { resolveSizeBudgetHumanApprovalRequired } from "./size-budget-merge-gate.mjs";
|
|
23
26
|
import { deriveLoopCiStatusFromRollup } from "./copilot-ci-status.mjs";
|
|
@@ -150,6 +153,103 @@ export function verifyFreshHumanApproval({ approvedBy, currentHeadSha, reviews =
|
|
|
150
153
|
};
|
|
151
154
|
}
|
|
152
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Copilot-convergence merge precondition. Wires the current-head Copilot
|
|
158
|
+
* body-disposition detection into the merge gate so the merge wrapper
|
|
159
|
+
* and the loop (`copilotBodyFeedbackUnresolved`) read the SAME classification
|
|
160
|
+
* (`classifyCopilotReviewBodyDisposition`). The classification is shared, so it
|
|
161
|
+
* cannot drift; the POLICY differs by design (the loop self-blocks on 🔵, this
|
|
162
|
+
* gate treats 🔵 as conductor-overridable). Fail-closed.
|
|
163
|
+
*
|
|
164
|
+
* Only the LATEST Copilot review pinned to `currentHeadSha` is judged, so a
|
|
165
|
+
* stale non-approval at an earlier head never blocks and a later same-head 🟢
|
|
166
|
+
* clears an earlier same-head finding.
|
|
167
|
+
*
|
|
168
|
+
* Policy (operator-resolved during the v1.0.4 drain):
|
|
169
|
+
* 🟡 "Changes recommended" on the current head -> BLOCK (actionable; the review
|
|
170
|
+
* body is unresolved feedback even with zero inline threads — the exact
|
|
171
|
+
* body-only fail-open the loop detects and this precondition enforces at merge).
|
|
172
|
+
* 🔵 "Needs a closer look" -> conductor-OVERRIDABLE (soft), NOT blocked here.
|
|
173
|
+
* Unresolved threads still gate it (detect-checkpoint-evidence refuses any
|
|
174
|
+
* unresolved review thread), so a 🔵 merges only with zero unresolved
|
|
175
|
+
* threads — the conductor's override is choosing to run the merge on a
|
|
176
|
+
* thread-clean 🔵.
|
|
177
|
+
* unrecognized disposition -> BLOCK (fail closed on a Copilot format change).
|
|
178
|
+
* 🟢 clean / no current-head Copilot review / stale earlier-head -> PASS.
|
|
179
|
+
*
|
|
180
|
+
* @returns {{ ok: boolean, disposition: string|null, reason: string|null }}
|
|
181
|
+
*/
|
|
182
|
+
export function evaluateCopilotConvergence({ currentHeadSha = null, reviews = [] } = {}) {
|
|
183
|
+
const head = typeof currentHeadSha === "string" ? currentHeadSha.trim() : "";
|
|
184
|
+
// Head unknown: no current-head review can be pinned. Fail closed (matches
|
|
185
|
+
// verifyFreshHumanApproval), so this precondition can never pass without a
|
|
186
|
+
// known head to pin the Copilot disposition to.
|
|
187
|
+
if (head.length === 0) return { ok: false, disposition: null, reason: "current head SHA is unknown; cannot pin a Copilot review to it" };
|
|
188
|
+
|
|
189
|
+
// Mirror summarizeCopilotReviews' current-head finding selection BYTE-FOR-BYTE
|
|
190
|
+
// so the merge gate and the loop can never diverge (they already share
|
|
191
|
+
// classifyCopilotReviewBodyDisposition at the detection layer). Use the SAME
|
|
192
|
+
// comparison the loop uses — the RAW submittedAt string (a non-string is null),
|
|
193
|
+
// compared with `>`/`===`, NOT a parsed timestamp: parsing would diverge on a
|
|
194
|
+
// malformed/mixed-offset submittedAt (an invalid-timestamp 🟡 that the loop
|
|
195
|
+
// keeps as latest could otherwise be superseded here — a fail-open). Consider
|
|
196
|
+
// only SUBMITTED current-head reviews, skip PENDING drafts (a PENDING never
|
|
197
|
+
// sets the finding), pick the latest by submittedAt string, and on an
|
|
198
|
+
// equal-string tie (or both-null) fold toward the most-blocking disposition so
|
|
199
|
+
// array order never silently drops a finding.
|
|
200
|
+
let latestDisposition = null;
|
|
201
|
+
let latestAt = null;
|
|
202
|
+
for (const entry of Array.isArray(reviews) ? reviews : []) {
|
|
203
|
+
const login = reviewLogin(entry);
|
|
204
|
+
if (login === null || !isCopilotLogin(login)) continue;
|
|
205
|
+
if (reviewCommit(entry) !== head) continue; // only current-head reviews
|
|
206
|
+
const state = typeof entry?.state === "string" ? entry.state.toUpperCase() : "";
|
|
207
|
+
if (state === "PENDING" || !SUBMITTED_REVIEW_STATES.has(state)) continue; // PENDING/unknown never sets the finding
|
|
208
|
+
const disposition = classifyCopilotReviewBodyDisposition(state, entry?.body);
|
|
209
|
+
const submittedAt = typeof entry?.submittedAt === "string"
|
|
210
|
+
? entry.submittedAt
|
|
211
|
+
: (typeof entry?.submitted_at === "string" ? entry.submitted_at : null);
|
|
212
|
+
if (submittedAt !== null && (latestAt === null || submittedAt > latestAt)) {
|
|
213
|
+
latestDisposition = disposition; // a lexicographically-later submittedAt supersedes (matches summarize)
|
|
214
|
+
latestAt = submittedAt;
|
|
215
|
+
} else if (submittedAt !== null && submittedAt === latestAt) {
|
|
216
|
+
latestDisposition = latestDisposition === null ? disposition : moreBlockingDisposition(latestDisposition, disposition);
|
|
217
|
+
} else if (submittedAt === null && latestAt === null) {
|
|
218
|
+
latestDisposition = latestDisposition === null ? disposition : moreBlockingDisposition(latestDisposition, disposition);
|
|
219
|
+
}
|
|
220
|
+
// a null submittedAt once a non-null latest exists is ignored (mirrors summarize)
|
|
221
|
+
}
|
|
222
|
+
if (latestDisposition === null) return { ok: true, disposition: null, reason: null };
|
|
223
|
+
|
|
224
|
+
if (latestDisposition === COPILOT_DISPOSITION.CHANGES_RECOMMENDED) {
|
|
225
|
+
return { ok: false, disposition: latestDisposition, reason: `current-head Copilot review is "Changes recommended" (🟡, actionable non-approval); converge to "Approval recommended" (🟢) or resolve the feedback before merge` };
|
|
226
|
+
}
|
|
227
|
+
if (latestDisposition === COPILOT_DISPOSITION.UNRECOGNIZED) {
|
|
228
|
+
return { ok: false, disposition: latestDisposition, reason: `current-head Copilot review carries an unrecognized disposition header (fail closed); a recognized "Approval recommended" (🟢) is required` };
|
|
229
|
+
}
|
|
230
|
+
// CLEAN, NONE, and NEEDS_CLOSER_LOOK (🔵, conductor-overridable) pass.
|
|
231
|
+
return { ok: true, disposition: latestDisposition, reason: null };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Disposition blocking precedence, most-blocking first. Used to fold an
|
|
235
|
+
// equal-timestamp same-head tie toward the most-blocking disposition so a tied
|
|
236
|
+
// 🟡/unrecognized is never silently dropped by a co-timestamped 🟢/🔵.
|
|
237
|
+
const COPILOT_DISPOSITION_BLOCKING_ORDER = [
|
|
238
|
+
COPILOT_DISPOSITION.CHANGES_RECOMMENDED,
|
|
239
|
+
COPILOT_DISPOSITION.UNRECOGNIZED,
|
|
240
|
+
COPILOT_DISPOSITION.NEEDS_CLOSER_LOOK,
|
|
241
|
+
COPILOT_DISPOSITION.CLEAN,
|
|
242
|
+
COPILOT_DISPOSITION.NONE,
|
|
243
|
+
];
|
|
244
|
+
function moreBlockingDisposition(a, b) {
|
|
245
|
+
const ia = COPILOT_DISPOSITION_BLOCKING_ORDER.indexOf(a);
|
|
246
|
+
const ib = COPILOT_DISPOSITION_BLOCKING_ORDER.indexOf(b);
|
|
247
|
+
// A value absent from the order (defensive) sorts last.
|
|
248
|
+
const ra = ia === -1 ? COPILOT_DISPOSITION_BLOCKING_ORDER.length : ia;
|
|
249
|
+
const rb = ib === -1 ? COPILOT_DISPOSITION_BLOCKING_ORDER.length : ib;
|
|
250
|
+
return ra <= rb ? a : b;
|
|
251
|
+
}
|
|
252
|
+
|
|
153
253
|
/**
|
|
154
254
|
* Decide whether merge is authorized given the class, the standing
|
|
155
255
|
* authorization signal, and any fresh per-merge approval.
|
|
@@ -273,11 +373,27 @@ export function evaluateMergePreconditions({
|
|
|
273
373
|
failures.push({ precondition: "size_budget_human_approval", reason: "size-budget requires a human APPROVED review OR a head-pinned \"approve merge <headSha>\" operator comment, with zero unresolved CHANGES_REQUESTED, for this escalated/T1 PR" });
|
|
274
374
|
}
|
|
275
375
|
|
|
376
|
+
// Copilot-convergence precondition: refuse a current-head Copilot non-approval
|
|
377
|
+
// body disposition, mirroring the loop's copilotBodyFeedbackUnresolved.
|
|
378
|
+
const copilotConvergence = evaluateCopilotConvergence({ currentHeadSha, reviews });
|
|
379
|
+
if (!copilotConvergence.ok) {
|
|
380
|
+
failures.push({ precondition: "copilot_convergence", reason: copilotConvergence.reason });
|
|
381
|
+
}
|
|
382
|
+
|
|
276
383
|
const mergeClass = resolveMergeClass({ sizeOutcome, touchesT1, stableRelease });
|
|
277
384
|
const decision = resolveMergeApprovalDecision({ mergeClass, standingAuthorized, freshApproval });
|
|
278
385
|
if (!decision.authorized) {
|
|
279
386
|
failures.push({ precondition: "merge_approval", reason: decision.reason });
|
|
280
387
|
}
|
|
281
388
|
|
|
282
|
-
return {
|
|
389
|
+
return {
|
|
390
|
+
ok: failures.length === 0,
|
|
391
|
+
failures,
|
|
392
|
+
mergeClass,
|
|
393
|
+
approvalVia: decision.authorized ? decision.via : null,
|
|
394
|
+
// Audit trace: the current-head Copilot disposition this verdict saw, so a
|
|
395
|
+
// merge that ran on a conductor-overridable 🔵 (or any disposition) is
|
|
396
|
+
// recorded on the machine-readable result rather than being invisible.
|
|
397
|
+
copilotDisposition: copilotConvergence.disposition,
|
|
398
|
+
};
|
|
283
399
|
}
|
|
@@ -592,7 +592,59 @@ function applyUnsettledCopilotReviewEntryGuard(input, result) {
|
|
|
592
592
|
const copilotReviewRequestStatus = typeof input.copilotReviewRequestStatus === "string"
|
|
593
593
|
? input.copilotReviewRequestStatus.trim().toLowerCase()
|
|
594
594
|
: "none";
|
|
595
|
-
|
|
595
|
+
const sameHeadCleanConverged = input.sameHeadCleanConverged === true;
|
|
596
|
+
const copilotReviewRoundCount = normalizeNonNegativeInteger(input.copilotReviewRoundCount);
|
|
597
|
+
const copilotReviewOnCurrentHead = input.copilotReviewOnCurrentHead === true;
|
|
598
|
+
const outstandingRequest = copilotReviewRequestStatus === "requested"
|
|
599
|
+
|| copilotReviewRequestStatus === "already-requested";
|
|
600
|
+
const roundCapReached = isCopilotRoundCapReached({
|
|
601
|
+
copilotReviewRoundCount: input.copilotReviewRoundCount,
|
|
602
|
+
maxCopilotRounds: input.maxCopilotRounds,
|
|
603
|
+
});
|
|
604
|
+
// Absent / never-driven: Copilot review is enabled (maxCopilotRounds
|
|
605
|
+
// > 0, not internal_only) but no round was requested or received for the
|
|
606
|
+
// CURRENT head, so a clean pre_approval_gate verdict would rest on nothing the
|
|
607
|
+
// loop actually drove on the head under review. The reconciled status alone is
|
|
608
|
+
// ambiguous: the reconciler (resolveCopilotReviewRequestStatus) folds a clean
|
|
609
|
+
// same-head submitted review into "none" too. Three facts each prove this is
|
|
610
|
+
// not the never-driven case and exempt this branch:
|
|
611
|
+
// - sameHeadCleanConverged: a clean same-head submitted Copilot review
|
|
612
|
+
// exists on THIS head (including GitHub's incidental auto-review) — the
|
|
613
|
+
// no-redundant-re-request case;
|
|
614
|
+
// - copilotReviewOnCurrentHead: a submitted Copilot review exists on THIS
|
|
615
|
+
// head (a settled current-head round; at a grant boundary its threads are
|
|
616
|
+
// already resolved). This is keyed on CURRENT-HEAD evidence, never a raw
|
|
617
|
+
// across-PR round count: copilotReviewRoundCount counts reviews on ANY
|
|
618
|
+
// head, so a PR with prior-head rounds and a NEW current head that carries
|
|
619
|
+
// no review (interpreter state low_signal_converged with
|
|
620
|
+
// copilotReviewOnCurrentHead false) must still fail closed;
|
|
621
|
+
// - roundCapReached: at/past the cap no further Copilot round can be driven,
|
|
622
|
+
// so requiring a current-head review is impossible to satisfy — the
|
|
623
|
+
// pre_approval_gate reviews the post-cap head. The core only reaches a
|
|
624
|
+
// grant boundary here after gating CI/threads, and a significant
|
|
625
|
+
// post-convergence change routes to a rerequest (a new cycle) before this
|
|
626
|
+
// guard runs, so this exemption cannot mask a genuinely-unreviewed change;
|
|
627
|
+
// - postConvergenceReviewSuppressed: an operator verified (via
|
|
628
|
+
// withdraw-copilot-review-request) that the current-head delta since
|
|
629
|
+
// Copilot's last submitted review is a pure doc/prose bump, so the prior
|
|
630
|
+
// converged review stands for this head — the core grants pre_approval on
|
|
631
|
+
// the same basis (never derived here from other snapshot facts).
|
|
632
|
+
// Fail closed on ANY non-outstanding status, not only the literal "none":
|
|
633
|
+
// this is the independent gate-ENTRY re-check, so it must not trust the
|
|
634
|
+
// caller's status string. A non-canonical/unknown value ("", "unavailable",
|
|
635
|
+
// "failed", a typo) with a grant-y lifecycleState and no current-head review
|
|
636
|
+
// fails closed rather than slipping through the "none"-only default. Only a
|
|
637
|
+
// driven current-head review (or the impossible-further-round cap state, or an
|
|
638
|
+
// operator-verified pure-doc-bump suppression) exempts, so an agent that skips
|
|
639
|
+
// the explicit Copilot round cannot reach a clean pre_approval verdict via any
|
|
640
|
+
// grant-y lifecycleState (e.g. a stale/racy low_signal_converged label, or one
|
|
641
|
+
// carried by prior-head rounds).
|
|
642
|
+
const absentNeverDriven = !outstandingRequest
|
|
643
|
+
&& !copilotReviewOnCurrentHead
|
|
644
|
+
&& !sameHeadCleanConverged
|
|
645
|
+
&& !roundCapReached
|
|
646
|
+
&& input.postConvergenceReviewSuppressed !== true;
|
|
647
|
+
if (!outstandingRequest && !absentNeverDriven) {
|
|
596
648
|
return null;
|
|
597
649
|
}
|
|
598
650
|
// Round-cap exemption (mirrors shouldGuardCopilotReviewRequest):
|
|
@@ -603,10 +655,6 @@ function applyUnsettledCopilotReviewEntryGuard(input, result) {
|
|
|
603
655
|
// and the head is clean — either sameHeadCleanConverged or the interpreter's
|
|
604
656
|
// round_cap_clean_fallback state — the pre_approval_gate proceeds unless
|
|
605
657
|
// significant post-convergence changes require a new review cycle.
|
|
606
|
-
const roundCapReached = isCopilotRoundCapReached({
|
|
607
|
-
copilotReviewRoundCount: input.copilotReviewRoundCount,
|
|
608
|
-
maxCopilotRounds: input.maxCopilotRounds,
|
|
609
|
-
});
|
|
610
658
|
const lifecycleState = typeof input.lifecycleState === "string" ? input.lifecycleState.trim().toLowerCase() : "";
|
|
611
659
|
// Also exempt the evaluator's own ROUND_CAP_REACHED grant shape:
|
|
612
660
|
// without this, this guard would rewrite that grant back to
|
|
@@ -624,7 +672,15 @@ function applyUnsettledCopilotReviewEntryGuard(input, result) {
|
|
|
624
672
|
|
|
625
673
|
const allowedNextActions = [];
|
|
626
674
|
const forbiddenActions = [];
|
|
627
|
-
|
|
675
|
+
// Two shapes fail closed here. An OUTSTANDING request (requested/
|
|
676
|
+
// already-requested) waits for the current-head review to settle. The
|
|
677
|
+
// ABSENT / never-driven case has nothing to wait for — no request is
|
|
678
|
+
// in flight — so the loop must first REQUEST a Copilot review, mirroring the
|
|
679
|
+
// detector-side shouldGuardCopilotReviewRequest override.
|
|
680
|
+
const nextAction = absentNeverDriven
|
|
681
|
+
? PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW
|
|
682
|
+
: PR_CHECKPOINT_ACTION.WAIT_FOR_COPILOT_REVIEW;
|
|
683
|
+
pushUnique(allowedNextActions, [nextAction]);
|
|
628
684
|
// Full postDraftForbidden set (matching the canonical WAITING_FOR_COPILOT_REVIEW
|
|
629
685
|
// result this guard synthesizes) plus the final-approval actions the replaced
|
|
630
686
|
// boundary result also forbade — dropping RUN_DRAFT_GATE/MARK_READY_FOR_REVIEW
|
|
@@ -642,18 +698,24 @@ function applyUnsettledCopilotReviewEntryGuard(input, result) {
|
|
|
642
698
|
repo: input.repo ?? null,
|
|
643
699
|
pr: Number.isInteger(input.pr) ? input.pr : null,
|
|
644
700
|
currentHeadSha: result.currentHeadSha ?? null,
|
|
645
|
-
lifecycleState: STATE.WAITING_FOR_COPILOT_REVIEW,
|
|
646
|
-
loopDisposition: DISPOSITION.PENDING,
|
|
701
|
+
lifecycleState: absentNeverDriven ? STATE.PR_READY_NO_FEEDBACK : STATE.WAITING_FOR_COPILOT_REVIEW,
|
|
702
|
+
loopDisposition: absentNeverDriven ? DISPOSITION.ACTION_REQUIRED : DISPOSITION.PENDING,
|
|
647
703
|
gateBoundary: PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW,
|
|
648
704
|
draftGateAlreadySatisfied: result.draftGateAlreadySatisfied === true,
|
|
649
705
|
draftGate: result.draftGate,
|
|
650
706
|
preApprovalGate: result.preApprovalGate,
|
|
651
707
|
allowedNextActions,
|
|
652
708
|
forbiddenActions,
|
|
653
|
-
nextAction
|
|
654
|
-
reason:
|
|
655
|
-
|
|
656
|
-
|
|
709
|
+
nextAction,
|
|
710
|
+
reason: absentNeverDriven
|
|
711
|
+
? "Copilot review is enabled for this repo (maxCopilotRounds > 0) but no Copilot review round has been "
|
|
712
|
+
+ "requested or received for the current head (independent gate-entry re-check, issue #2146) — a clean "
|
|
713
|
+
+ "pre_approval_gate/final-approval verdict requires a Copilot round the loop actually drove and awaited, "
|
|
714
|
+
+ "so request Copilot review first. A clean same-head submitted Copilot review (including GitHub's "
|
|
715
|
+
+ "auto-review) would satisfy this; an absent/never-driven round is not settled convergence."
|
|
716
|
+
: "A Copilot review request is still outstanding on the current head (independent gate-entry "
|
|
717
|
+
+ "re-check, issue #1190) — pre_approval_gate/final-approval entry is refused until the current-head "
|
|
718
|
+
+ "review settles, even though the caller-reported convergence signal claims otherwise.",
|
|
657
719
|
mergeStateStatus: result.mergeStateStatus ?? null,
|
|
658
720
|
conflictFiles: result.conflictFiles ?? [],
|
|
659
721
|
refinementArtifact: result.refinementArtifact ?? null,
|
|
@@ -555,6 +555,12 @@ export function composeRunInspectionSnapshot({
|
|
|
555
555
|
if (lifecyclePhase === null) {
|
|
556
556
|
// Fallback: derive from available PR facts
|
|
557
557
|
const loopIter = loopIterations ?? {};
|
|
558
|
+
// Deferring the loop-iteration fan-out cannot change the phase: every copilot
|
|
559
|
+
// state maps to one (COPILOT_INNER_STATE_MAP is total over STATE), so whenever
|
|
560
|
+
// copilot evidence is present the phase is already resolved above and this
|
|
561
|
+
// fallback never runs. It runs only when that evidence is ABSENT, where there
|
|
562
|
+
// is no thread count to consult from any source — so there is nothing here to
|
|
563
|
+
// reconcile between a deferred and a full inspection.
|
|
558
564
|
const hasUnresolvedThreads = typeof loopIter.unresolvedReviewThreads === "number"
|
|
559
565
|
&& loopIter.unresolvedReviewThreads > 0;
|
|
560
566
|
const copilotState = copilotLiveOk && copilotEvidence !== null
|
|
@@ -98,6 +98,17 @@ export const SPEC_AUTHORITY_OUTCOME_VALUES = Object.freeze(
|
|
|
98
98
|
* three resolve autonomously. Exported so no consumer re-hardcodes the set. */
|
|
99
99
|
export const HUMAN_SPEC_DECISION_OUTCOME = SPEC_AUTHORITY_OUTCOMES.SPEC_CANNOT_DECIDE;
|
|
100
100
|
|
|
101
|
+
/** The two outcomes that REQUIRE explicit conflict evidence: a non-empty
|
|
102
|
+
* `conflictingCriteria` array of criterion ids (SPEC-AUTHORITY-CONFLICT-EVIDENCE).
|
|
103
|
+
* The enforcer and the divergence guard bind to this set directly; the guard
|
|
104
|
+
* also pins the producer contract prose (`agents/judge.agent.md`) to it, so
|
|
105
|
+
* producer and enforcer cannot silently diverge. SPEC-AUTHORITY-CONFLICT-EVIDENCE
|
|
106
|
+
* in this module is the governing rule. */
|
|
107
|
+
export const SPEC_AUTHORITY_CONFLICT_OUTCOMES = Object.freeze([
|
|
108
|
+
SPEC_AUTHORITY_OUTCOMES.FINDING_CONFLICTS,
|
|
109
|
+
SPEC_AUTHORITY_OUTCOMES.REMEDIATION_CONFLICTS,
|
|
110
|
+
]);
|
|
111
|
+
|
|
101
112
|
/**
|
|
102
113
|
* Does an outcome require the loop to stop at the human-spec-decision state?
|
|
103
114
|
* Only `spec_cannot_decide` does — a finding/remediation conflict alone never
|
|
@@ -364,15 +375,17 @@ export function validateSpecAuthorityDecision(decision, { specDigest, headSha, c
|
|
|
364
375
|
// The two conflict outcomes require explicit conflict evidence: a non-empty
|
|
365
376
|
// conflictingCriteria drawn from the spec. Autonomous rejection is only
|
|
366
377
|
// legitimate when it names what the finding/remedy conflicts with.
|
|
367
|
-
const isConflict =
|
|
368
|
-
d.outcome === SPEC_AUTHORITY_OUTCOMES.FINDING_CONFLICTS ||
|
|
369
|
-
d.outcome === SPEC_AUTHORITY_OUTCOMES.REMEDIATION_CONFLICTS;
|
|
378
|
+
const isConflict = SPEC_AUTHORITY_CONFLICT_OUTCOMES.includes(d.outcome);
|
|
370
379
|
let conflictingCriteria = [];
|
|
371
380
|
if (isConflict) {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
381
|
+
// Absent, non-array, and empty all mean "no conflict evidence". Route them
|
|
382
|
+
// to the named rule (not the generic id-set shape error) so a human hitting
|
|
383
|
+
// this is pointed at the fix — populate the field — rather than a hand-edit;
|
|
384
|
+
// naming the criteria in `rationale` prose alone does not satisfy it.
|
|
385
|
+
if (!Array.isArray(d.conflictingCriteria) || d.conflictingCriteria.length === 0) {
|
|
386
|
+
throw new Error(`SPEC-AUTHORITY-CONFLICT-EVIDENCE: ${d.outcome} decision requires a non-empty conflictingCriteria array of criterion ids (explicit conflict evidence; naming them in rationale prose alone does not satisfy it; fail closed)`);
|
|
375
387
|
}
|
|
388
|
+
const conflicts = normalizeIdSet(d.conflictingCriteria, "decision.conflictingCriteria");
|
|
376
389
|
const unknownConflicts = [...conflicts].filter((id) => !fullCriteria.has(id));
|
|
377
390
|
if (unknownConflicts.length > 0) {
|
|
378
391
|
throw new Error(`spec-authority decision.conflictingCriteria names unknown criterion id(s): ${unknownConflicts.join(", ")}`);
|