@dev-loops/core 1.0.2 → 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 +10 -1
- package/src/analysis/change-classifier.mjs +35 -0
- package/src/analysis/diff-analyzer.mjs +89 -16
- package/src/claude/asset-generation.mjs +64 -3
- package/src/claude/hook-decisions.mjs +213 -65
- package/src/config/config.mjs +473 -43
- package/src/config/extension-defaults.yaml +48 -0
- package/src/github/copilot-helpers.mjs +79 -1
- package/src/github/issue-ops.mjs +4 -0
- package/src/github/repo-slug.mjs +25 -4
- package/src/github/test-mode-write-guard.mjs +81 -0
- package/src/loop/bash-command-classify.mjs +396 -42
- package/src/loop/child-launch-bound.mjs +152 -0
- package/src/loop/copilot-ci-status.mjs +116 -6
- package/src/loop/copilot-loop-state.mjs +20 -4
- package/src/loop/execution-record.mjs +412 -0
- package/src/loop/finding-cluster.mjs +296 -0
- package/src/loop/fixer-disposition.mjs +200 -0
- package/src/loop/gate-carry-forward.mjs +39 -6
- package/src/loop/gate-fanin.mjs +82 -3
- package/src/loop/issue-refinement-artifact.mjs +117 -9
- package/src/loop/merge-approval.mjs +399 -0
- package/src/loop/pr-gate-coordination.mjs +123 -12
- package/src/loop/queue-board-sync.mjs +6 -3
- package/src/loop/reviewer-unit-bound.mjs +308 -0
- package/src/loop/role-budget-bound.mjs +242 -0
- package/src/loop/run-inspection.mjs +6 -0
- package/src/loop/size-budget-merge-gate.mjs +48 -12
- package/src/loop/spec-authority.mjs +19 -6
- package/src/loop/ui-e2e-scoping.mjs +1 -0
- package/src/loop/watcher-exclusivity.mjs +302 -0
- package/src/security/secret-scan.mjs +13 -0
package/src/loop/gate-fanin.mjs
CHANGED
|
@@ -68,6 +68,51 @@ export function backoffMaxConcurrent(maxConcurrent) {
|
|
|
68
68
|
return Math.max(1, Math.floor(cap / 2));
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/** 30s/60s/120s same-unit retry schedule (`GATE-EXEC-DISPATCH-RETRY-BACKOFF`). */
|
|
72
|
+
const DISPATCH_RETRY_BACKOFF_SCHEDULE_MS = Object.freeze([30_000, 60_000, 120_000]);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* True when `errorClass` names a transient provider error (429 rate-limit or
|
|
76
|
+
* any 5xx), the class `GATE-EXEC-DISPATCH-RETRY-BACKOFF` retries — as opposed
|
|
77
|
+
* to a hard 4xx (e.g. `402`), which never is.
|
|
78
|
+
* @param {string|number} errorClass
|
|
79
|
+
* @returns {boolean}
|
|
80
|
+
*/
|
|
81
|
+
function isTransientDispatchErrorClass(errorClass) {
|
|
82
|
+
const s = String(errorClass ?? "").trim();
|
|
83
|
+
return s === "429" || /^5\d\d$/.test(s) || s.toLowerCase() === "5xx";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Pure retry-decision policy for `GATE-EXEC-DISPATCH-RETRY-BACKOFF`: whether a
|
|
88
|
+
* failed dispatch retries the SAME unit, and when a batch reduction is due.
|
|
89
|
+
*
|
|
90
|
+
* A transient failure (429 / 5xx) always retries — the round is never aborted
|
|
91
|
+
* on a transient error, safe because a retry overwrites the same idempotent
|
|
92
|
+
* per-angle findings artifact path (`GATE-EXEC-COLLECTABLE-DISPATCH`), never
|
|
93
|
+
* minting a second one. `delayMs` follows the 30s/60s/120s schedule (holding
|
|
94
|
+
* at 120s past the 3rd attempt); `reduceConcurrency` flips true starting at
|
|
95
|
+
* the 3rd exhausted attempt (`attempt` index 2), signaling the caller to halve
|
|
96
|
+
* the active batch via `backoffMaxConcurrent` — this function only signals
|
|
97
|
+
* WHEN, the halving itself stays `backoffMaxConcurrent`'s job.
|
|
98
|
+
*
|
|
99
|
+
* A hard 4xx (e.g. `402 Insufficient Balance`) is never transient: it escalates
|
|
100
|
+
* to the supervisor/operator immediately instead of retrying into the same wall.
|
|
101
|
+
*
|
|
102
|
+
* @param {number} attempt — 0-based count of prior failed attempts on this unit
|
|
103
|
+
* @param {string|number} errorClass — e.g. `"429"`, `"500"`, `"5xx"`, `"402"`
|
|
104
|
+
* @returns {{ retry: true, delayMs: number, reduceConcurrency: boolean } | { retry: false, escalate: true }}
|
|
105
|
+
*/
|
|
106
|
+
export function planDispatchRetry(attempt, errorClass) {
|
|
107
|
+
if (!isTransientDispatchErrorClass(errorClass)) {
|
|
108
|
+
return { retry: false, escalate: true };
|
|
109
|
+
}
|
|
110
|
+
const n = Number.isInteger(attempt) && attempt >= 0 ? attempt : 0;
|
|
111
|
+
const lastIndex = DISPATCH_RETRY_BACKOFF_SCHEDULE_MS.length - 1;
|
|
112
|
+
const delayMs = DISPATCH_RETRY_BACKOFF_SCHEDULE_MS[Math.min(n, lastIndex)];
|
|
113
|
+
return { retry: true, delayMs, reduceConcurrency: n >= lastIndex };
|
|
114
|
+
}
|
|
115
|
+
|
|
71
116
|
/**
|
|
72
117
|
* Reviewer-budget preflight for a gate fan-out.
|
|
73
118
|
*
|
|
@@ -783,6 +828,7 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
|
|
|
783
828
|
disposition: deriveDisposition(severity, { isBlocking, locatable: hasLocatableShape(f) }),
|
|
784
829
|
};
|
|
785
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());
|
|
786
832
|
if (typeof f.line === "number" && Number.isFinite(f.line)) entry.line = f.line;
|
|
787
833
|
if (typeof f.recommendation === "string" && f.recommendation.trim().length > 0) {
|
|
788
834
|
entry.recommendation = f.recommendation.trim();
|
|
@@ -826,6 +872,38 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
|
|
|
826
872
|
*/
|
|
827
873
|
export const JUDGE_DISPOSITIONS = Object.freeze(["act", "defer", "reject"]);
|
|
828
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
|
+
|
|
829
907
|
/**
|
|
830
908
|
* Validate a judge verdict artifact shape (the `judge` agent's only write).
|
|
831
909
|
* Pure; throws on a malformed verdict rather than enriching findings with
|
|
@@ -856,8 +934,9 @@ export function validateJudgeVerdict(verdict) {
|
|
|
856
934
|
throw new Error("judge verdict.scopeDrift must be an object");
|
|
857
935
|
}
|
|
858
936
|
const sd = /** @type {Record<string, unknown>} */ (v.scopeDrift);
|
|
859
|
-
|
|
860
|
-
|
|
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(", ")}`);
|
|
861
940
|
}
|
|
862
941
|
if (typeof sd.rationale !== "string" || sd.rationale.trim().length === 0) {
|
|
863
942
|
throw new Error("judge verdict.scopeDrift.rationale must be a non-empty string");
|
|
@@ -904,7 +983,7 @@ export function validateJudgeVerdict(verdict) {
|
|
|
904
983
|
}
|
|
905
984
|
}
|
|
906
985
|
}
|
|
907
|
-
return { headSha: v.headSha, scopeDrift:
|
|
986
|
+
return { headSha: v.headSha, scopeDrift: { ...sd, verdict: normalizedVerdict }, dispositions: v.dispositions };
|
|
908
987
|
}
|
|
909
988
|
|
|
910
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
|
|