@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.
@@ -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
- if (sd.verdict !== "within_scope" && sd.verdict !== "drift_detected") {
860
- throw new Error("judge verdict.scopeDrift.verdict must be 'within_scope' or 'drift_detected'");
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: v.scopeDrift, dispositions: v.dispositions };
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 checklist item), Definition of done
956
- * (>=1 checklist item), Open questions/risks, and — unless issue-less mode is
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. Pure; no I/O.
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 acItems = acSection ? extractChecklistItems(acSection.bodyLines.join("\n")) : [];
1049
- if (acItems.length === 0) {
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 checklist items found).",
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 dodItems = dodSection ? extractChecklistItems(dodSection.bodyLines.join("\n")) : [];
1058
- if (dodItems.length === 0) {
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 checklist items found).",
1169
+ message: "Missing Definition of done (no checkbox items found).",
1062
1170
  });
1063
1171
  }
1064
1172