@dev-loops/core 1.0.0-rc.5 → 1.0.0-rc.7
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 +12 -1
- package/src/analysis/change-classifier.mjs +10 -0
- package/src/analysis/diff-analyzer.mjs +68 -1
- package/src/claude/hook-decisions.mjs +204 -5
- package/src/cli/primitives.mjs +51 -1
- package/src/config/config.mjs +307 -14
- package/src/config/extension-defaults.yaml +39 -1
- package/src/github/comment-id-guard.mjs +158 -0
- package/src/github/copilot-helpers.mjs +145 -5
- package/src/github/gh.mjs +94 -0
- package/src/github/issue-ops.mjs +13 -0
- package/src/loop/agent-stall.mjs +196 -0
- package/src/loop/bash-command-classify.mjs +277 -0
- package/src/loop/cache-telemetry-evidence.mjs +437 -0
- package/src/loop/copilot-loop-iterations.mjs +2 -1
- package/src/loop/default-branch-guard.mjs +35 -2
- package/src/loop/gate-carry-forward.mjs +19 -6
- package/src/loop/gate-fanin.mjs +190 -29
- package/src/loop/handoff-envelope.mjs +40 -20
- package/src/loop/issue-refinement-artifact.mjs +94 -0
- package/src/loop/lifecycle-state.mjs +21 -2
- package/src/loop/main-checkout-ff.mjs +73 -0
- package/src/loop/markdown-sections.mjs +40 -0
- package/src/loop/normalize.mjs +7 -0
- package/src/loop/plan-file-promote-contract.mjs +14 -1
- package/src/loop/plan-file-refine-contract.mjs +92 -8
- package/src/loop/policy-constants.mjs +9 -0
- package/src/loop/pr-gate-coordination.mjs +65 -12
- package/src/loop/primer-evidence.mjs +375 -0
- package/src/loop/public-dev-loop-routing.mjs +7 -15
- package/src/loop/queue-board-sync.mjs +1 -26
- package/src/loop/queue-driver.mjs +14 -1
- package/src/loop/refinement-grill-state.mjs +3 -5
- package/src/loop/review-dispatch-plan.mjs +1034 -0
- package/src/loop/review-lineage.mjs +588 -0
- package/src/loop/reviewer-loop-state.mjs +8 -13
- package/src/loop/run-post-merge-actions.mjs +148 -0
- package/src/loop/size-budget-merge-gate.mjs +121 -0
- package/src/loop/tracker-pr-state.mjs +5 -15
- package/src/loop/ui-designer-review-scoping.mjs +171 -0
- package/src/loop/ui-review-drive.mjs +3 -1
- package/src/loop/ui-review-report.mjs +2 -5
- package/src/loop/ui-review-teardown.mjs +3 -1
- package/src/loop/worktree-guard.mjs +80 -0
- package/src/projects/list-queue-items.mjs +1 -27
- package/src/projects/move-queue-item.mjs +38 -28
- package/src/security/secret-scan.mjs +330 -0
package/src/loop/gate-fanin.mjs
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
36
|
import { scheduleParallelWaves } from "./queue-parallel.mjs";
|
|
37
|
+
import { trimmedOrNull } from "./normalize.mjs";
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
40
|
* Schedule fan-out dispatch units into bounded-concurrency waves (issue #1601).
|
|
@@ -106,30 +107,46 @@ export function backoffMaxConcurrent(maxConcurrent) {
|
|
|
106
107
|
*
|
|
107
108
|
* @param {{ name: string, angles: string[] }[]} dispatchGroups — `resolveFanoutGroups` output (fresh angles + re-verifications)
|
|
108
109
|
* @param {number|null} [availableReviewers] — harness remaining reviewer budget; null/non-finite = unknown/unexposed
|
|
109
|
-
* @param {{ completedAngles?: Iterable<string> }} [options] — `completedAngles`:
|
|
110
|
-
* already have a clean per-angle findings artifact stamped for THIS head.
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
110
|
+
* @param {{ completedAngles?: Iterable<string>, carriedAngles?: Iterable<string> }} [options] — `completedAngles`:
|
|
111
|
+
* angle names that already have a clean per-angle findings artifact stamped for THIS head.
|
|
112
|
+
* `carriedAngles`: angle names the fail-closed carry-forward seam (resolve-angle-carry-forward.mjs)
|
|
113
|
+
* has proven carried from a prior clean head, so no reviewer re-runs them this round either — that
|
|
114
|
+
* carry-forward resolution runs AFTER this preflight, so a head-bump re-gate must feed its result
|
|
115
|
+
* back in to avoid over-counting. A dispatch unit (group) whose angles are ALL complete-or-carried
|
|
116
|
+
* is excluded from the required count and from `pendingGroups`, so a later session resumes the
|
|
117
|
+
* fan-out instead of restarting it: it re-runs the preflight and dispatches only the groups not
|
|
118
|
+
* already resolved at this head. Membership is matched trim+lowercase (mirrors
|
|
119
|
+
* consolidate-fanin.mjs's own carried-key normalization) so a config/plan case difference in an
|
|
120
|
+
* angle name still excludes the right group instead of silently spending a reviewer on it.
|
|
121
|
+
* @returns {{ ok: boolean, dispatch: boolean, requiredReviewers: number, availableReviewers: number|null, shortfall: number|null, reason: string, verdict: null, executionMode: null, pendingGroups: { name: string, angles: string[] }[], skippedGroups: { name: string, angles: string[] }[], completedAngles: string[], carriedAngles: string[] }}
|
|
115
122
|
*/
|
|
116
|
-
export function reviewerBudgetPreflight(dispatchGroups, availableReviewers, { completedAngles } = {}) {
|
|
123
|
+
export function reviewerBudgetPreflight(dispatchGroups, availableReviewers, { completedAngles, carriedAngles } = {}) {
|
|
117
124
|
const groups = Array.isArray(dispatchGroups) ? dispatchGroups : [];
|
|
118
|
-
const
|
|
119
|
-
Array.isArray(
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
)
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
125
|
+
const toSet = (iterable) =>
|
|
126
|
+
new Set(Array.isArray(iterable) ? iterable : iterable == null ? [] : [...iterable]);
|
|
127
|
+
const completedSet = toSet(completedAngles);
|
|
128
|
+
const carriedSet = toSet(carriedAngles);
|
|
129
|
+
// Same-head resume + head-bump carry-forward: one reviewer per dispatch unit
|
|
130
|
+
// (a group of N angles is one reviewer's scoped dispatch — see
|
|
131
|
+
// resolveFanoutGroups / countFreshDispatchUnits), but a group already
|
|
132
|
+
// RESOLVED for this round — every one of its angles either has a clean
|
|
133
|
+
// artifact stamped for this head OR is proven carried forward from a prior
|
|
134
|
+
// clean head — needs no reviewer and is excluded from the required count
|
|
135
|
+
// and the pending plan. The conductor dispatches only `pendingGroups`.
|
|
136
|
+
// Membership is matched trim+lowercase (`normalizeAngleKey`) — the same
|
|
137
|
+
// normalization consolidate-fanin.mjs applies to its own carried keys — so a
|
|
138
|
+
// config/plan case difference in an angle name still excludes the group
|
|
139
|
+
// instead of leaving it (and its exempted sibling) silently disagreeing.
|
|
140
|
+
const normalizeAngleKey = (a) => String(a).trim().toLowerCase();
|
|
141
|
+
const completedKeys = new Set([...completedSet].map(normalizeAngleKey));
|
|
142
|
+
const carriedKeys = new Set([...carriedSet].map(normalizeAngleKey));
|
|
131
143
|
const groupIsComplete = (g) =>
|
|
132
|
-
Array.isArray(g?.angles) &&
|
|
144
|
+
Array.isArray(g?.angles) &&
|
|
145
|
+
g.angles.length > 0 &&
|
|
146
|
+
g.angles.every((a) => {
|
|
147
|
+
const key = normalizeAngleKey(a);
|
|
148
|
+
return completedKeys.has(key) || carriedKeys.has(key);
|
|
149
|
+
});
|
|
133
150
|
const pendingGroups = groups.filter((g) => !groupIsComplete(g));
|
|
134
151
|
const skippedGroups = groups.filter((g) => groupIsComplete(g));
|
|
135
152
|
// One reviewer per dispatch unit: a group of N angles is one reviewer's
|
|
@@ -138,7 +155,7 @@ export function reviewerBudgetPreflight(dispatchGroups, availableReviewers, { co
|
|
|
138
155
|
const requiredReviewers = pendingGroups.length;
|
|
139
156
|
const verdict = null;
|
|
140
157
|
const executionMode = null;
|
|
141
|
-
const resume = { pendingGroups, skippedGroups, completedAngles: [...completedSet] };
|
|
158
|
+
const resume = { pendingGroups, skippedGroups, completedAngles: [...completedSet], carriedAngles: [...carriedSet] };
|
|
142
159
|
if (typeof availableReviewers !== "number" || !Number.isFinite(availableReviewers)) {
|
|
143
160
|
return { ok: true, dispatch: true, requiredReviewers, availableReviewers: null, shortfall: null, reason: "budget_unknown", verdict, executionMode, ...resume };
|
|
144
161
|
}
|
|
@@ -175,13 +192,33 @@ export function reviewerBudgetPreflight(dispatchGroups, availableReviewers, { co
|
|
|
175
192
|
// finding via the fix loop, a question via never being auto-deferred) — it
|
|
176
193
|
// outranks "medium"/"low", which both eventually defer. "nit" trails last:
|
|
177
194
|
// it defers immediately, with no fixer cycle at all.
|
|
178
|
-
export const SEVERITY_ORDER = ["high", "question", "medium", "low", "nit"];
|
|
195
|
+
export const SEVERITY_ORDER = Object.freeze(["high", "question", "medium", "low", "nit"]);
|
|
196
|
+
|
|
197
|
+
// The non-defect subset of SEVERITY_ORDER: a "question" is answered (never
|
|
198
|
+
// fixed or deferred like a defect — see deriveDisposition), and a "nit"
|
|
199
|
+
// always defers regardless of any gate's blockCleanOnFindingSeverities
|
|
200
|
+
// config (see isDefaultDeferrableSeverity) — neither belongs in a
|
|
201
|
+
// defect-only blocking vocabulary. Exported as the single source for that
|
|
202
|
+
// partition so a consumer (e.g. config.mjs's BLOCKING_SEVERITY_SPELLINGS
|
|
203
|
+
// vocabulary contract test) derives "defect severities" as
|
|
204
|
+
// SEVERITY_ORDER minus this set, rather than re-hand-listing "question"/"nit".
|
|
205
|
+
// Object.freeze on a Set only locks its OWN properties, not the add/delete
|
|
206
|
+
// methods that mutate its internal collection — freezing is still applied
|
|
207
|
+
// here for consistency with SEVERITY_ORDER and this file's other frozen
|
|
208
|
+
// exports (GATE_CONFIG_KEY, LEGACY_SEVERITY_ALIASES, etc.), and it does stop
|
|
209
|
+
// a caller from attaching a stray own property to the Set object itself.
|
|
210
|
+
export const NON_DEFECT_SEVERITIES = Object.freeze(new Set(["question", "nit"]));
|
|
179
211
|
|
|
180
212
|
// Marker gate name → gates.<key> config key. Owned here so every caller of
|
|
181
213
|
// resolveFanoutGroups maps the same way; passing the marker name verbatim
|
|
182
214
|
// resolves no groups and silently downgrades pairing enforcement.
|
|
183
215
|
export const GATE_CONFIG_KEY = Object.freeze({ draft_gate: "draft", pre_approval_gate: "preApproval" });
|
|
184
|
-
|
|
216
|
+
// Object.freeze on a Set only locks its OWN properties, not the add/delete
|
|
217
|
+
// methods that mutate its internal collection — freezing is still applied
|
|
218
|
+
// here for consistency with SEVERITY_ORDER and this file's other frozen
|
|
219
|
+
// exports (GATE_CONFIG_KEY, LEGACY_SEVERITY_ALIASES, etc.), and it does stop
|
|
220
|
+
// a caller from attaching a stray own property to the Set object itself.
|
|
221
|
+
export const VALID_SEVERITIES = Object.freeze(new Set(SEVERITY_ORDER));
|
|
185
222
|
|
|
186
223
|
// Pre-rename spellings. Old ledgers, markers, and configs still carry them;
|
|
187
224
|
// every read boundary normalizes through this map. Every SANCTIONED producer
|
|
@@ -241,6 +278,47 @@ export function severityRank(severity) {
|
|
|
241
278
|
return idx === -1 ? SEVERITY_ORDER.length : idx;
|
|
242
279
|
}
|
|
243
280
|
|
|
281
|
+
/**
|
|
282
|
+
* A zero-initialized severity→count map, one key per SEVERITY_ORDER entry, in
|
|
283
|
+
* SEVERITY_ORDER's order. The shared starting point every severity tally in
|
|
284
|
+
* this codebase (consolidateFanin's own `bySeverity`, consolidate-fanin.mjs's
|
|
285
|
+
* `buildAngleMarker`, reconcile-draft-gate.mjs's no-findings placeholder) used
|
|
286
|
+
* to hand-roll separately via `Object.fromEntries(SEVERITY_ORDER.map((s) =>
|
|
287
|
+
* [s, 0]))` — one copy here means a severity added to SEVERITY_ORDER is
|
|
288
|
+
* zero-initialized everywhere at once.
|
|
289
|
+
* @returns {Record<string, number>}
|
|
290
|
+
*/
|
|
291
|
+
export function zeroSeverityCounts() {
|
|
292
|
+
return Object.fromEntries(SEVERITY_ORDER.map((s) => [s, 0]));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Tally `findings` by (normalized) severity into a {@link zeroSeverityCounts}
|
|
297
|
+
* map. Each finding's severity is normalized through `normalizeSeverity`
|
|
298
|
+
* before counting, so a legacy spelling still lands on its canonical key. A
|
|
299
|
+
* finding whose normalized severity is not a recognized SEVERITY_ORDER member
|
|
300
|
+
* is silently excluded from the tally rather than inflating an unknown key —
|
|
301
|
+
* every routed call site here counts already-validated findings in practice
|
|
302
|
+
* (consolidateFanin validates every result's severity before this runs;
|
|
303
|
+
* buildAngleMarker tallies consolidateFanin's own output), so this guard is a
|
|
304
|
+
* defensive floor against future drift, not an escape hatch for accepting
|
|
305
|
+
* unvalidated severities. `findings` and its entries are NOT nullish-tolerant:
|
|
306
|
+
* a nullish `findings` argument throws (not iterable), and a nullish
|
|
307
|
+
* individual entry throws reading `.severity` — no routed caller passes
|
|
308
|
+
* either shape, so a caller that does gets a loud failure instead of a
|
|
309
|
+
* silently wrong all-zero tally.
|
|
310
|
+
* @param {Iterable<{severity: unknown}>} findings
|
|
311
|
+
* @returns {Record<string, number>}
|
|
312
|
+
*/
|
|
313
|
+
export function tallySeverities(findings) {
|
|
314
|
+
const counts = zeroSeverityCounts();
|
|
315
|
+
for (const f of findings) {
|
|
316
|
+
const severity = /** @type {string} */ (normalizeSeverity(f.severity));
|
|
317
|
+
if (Object.hasOwn(counts, severity)) counts[severity] += 1;
|
|
318
|
+
}
|
|
319
|
+
return counts;
|
|
320
|
+
}
|
|
321
|
+
|
|
244
322
|
/**
|
|
245
323
|
* Merge a severity→count map's legacy-spelled keys into their canonical keys
|
|
246
324
|
* (summing counts) so both the CLI parser and direct programmatic callers of
|
|
@@ -457,7 +535,7 @@ function* freshEntries(perAngle) {
|
|
|
457
535
|
if (typeof entry.carriedFromHead === "string" && entry.carriedFromHead.trim().length > 0) continue;
|
|
458
536
|
const angle = typeof entry.angle === "string" ? entry.angle.trim() : "";
|
|
459
537
|
if (!angle) continue;
|
|
460
|
-
const group =
|
|
538
|
+
const group = trimmedOrNull(entry.group);
|
|
461
539
|
yield { entry, angle, group };
|
|
462
540
|
}
|
|
463
541
|
}
|
|
@@ -665,6 +743,63 @@ export function checkFanoutAngleCoverage(recordedAngles, { mandatoryAngles = [],
|
|
|
665
743
|
*/
|
|
666
744
|
export const FANIN_SYNTHETIC_ANGLES = Object.freeze(["pr-checklist-matrix"]);
|
|
667
745
|
|
|
746
|
+
/**
|
|
747
|
+
* Validate a round's RESOLVED angle set — the full angle list the round
|
|
748
|
+
* targeted, independent of any single gate's configured MANDATORY subset —
|
|
749
|
+
* against the evidence actually recorded for it: every resolved angle must
|
|
750
|
+
* have either a per-angle artifact in `recordedAngles` (matched by
|
|
751
|
+
* {@link baseAngleName} plus a case-insensitive compare — same base+lowercase
|
|
752
|
+
* rule consolidate-fanin.mjs applies to its own angle keys) or be
|
|
753
|
+
* named in `carriedAngles` (angle names a caller has already PROVEN carried
|
|
754
|
+
* forward from a prior clean head — never a bare, unverified name; the
|
|
755
|
+
* consolidate-fanin CLI's own `--carried-angles` is only ever populated after
|
|
756
|
+
* its `--carry-forward-plan` proof check, so passing it straight through here
|
|
757
|
+
* keeps that same guarantee).
|
|
758
|
+
*
|
|
759
|
+
* This closes a gap {@link checkFanoutAngleCoverage} leaves open: that check
|
|
760
|
+
* only protects a CALLER-SUPPLIED mandatory subset, so a wrong carry-forward
|
|
761
|
+
* declaration naming only NON-mandatory angles under-dispatches with no
|
|
762
|
+
* mechanical refusal — visible only in the ledger's own carried-angle
|
|
763
|
+
* provenance (see the Gate Review Sub-Loop Contract's Phase 3 backstop
|
|
764
|
+
* paragraph). This function protects every resolved angle, not just the
|
|
765
|
+
* mandatory ones. Pure.
|
|
766
|
+
*
|
|
767
|
+
* @param {unknown} resolvedAngles — the round's full resolved angle-name list
|
|
768
|
+
* @param {object} [evidence]
|
|
769
|
+
* @param {unknown} [evidence.recordedAngles] — array of `{ angle: string, ... }` entries (per-angle artifacts this round consolidated)
|
|
770
|
+
* @param {Iterable<string>} [evidence.carriedAngles] — angle names already proven carried forward
|
|
771
|
+
* @returns {{ missingAngles: string[] }}
|
|
772
|
+
*/
|
|
773
|
+
export function checkResolvedAngleEvidence(resolvedAngles, { recordedAngles, carriedAngles } = {}) {
|
|
774
|
+
const resolved = Array.isArray(resolvedAngles)
|
|
775
|
+
? [...new Set(
|
|
776
|
+
resolvedAngles
|
|
777
|
+
.map((a) => (typeof a === "string" ? a.trim() : ""))
|
|
778
|
+
.filter((a) => a.length > 0),
|
|
779
|
+
)]
|
|
780
|
+
: [];
|
|
781
|
+
const recorded = Array.isArray(recordedAngles)
|
|
782
|
+
? recordedAngles
|
|
783
|
+
.map((e) => (e && typeof e === "object" && typeof e.angle === "string" ? e.angle.trim() : ""))
|
|
784
|
+
.filter((a) => a.length > 0)
|
|
785
|
+
: [];
|
|
786
|
+
// Matched base+lowercase, same as checkFanoutAngleCoverage's callers
|
|
787
|
+
// (consolidate-fanin's realAngleKeys/exemptCarriedKeys) and
|
|
788
|
+
// reviewerBudgetPreflight's normalizeAngleKey: per-angle artifacts are
|
|
789
|
+
// independently authored, so a case difference between a resolved angle
|
|
790
|
+
// name and its recorded/carried evidence must not read as missing.
|
|
791
|
+
const normalizeAngleBase = (a) => baseAngleName(a).toLowerCase();
|
|
792
|
+
const recordedBases = new Set(recorded.map(normalizeAngleBase));
|
|
793
|
+
const carriedBases = new Set(
|
|
794
|
+
[...(carriedAngles ?? [])].map((a) => normalizeAngleBase(String(a).trim())),
|
|
795
|
+
);
|
|
796
|
+
const missingAngles = resolved.filter((a) => {
|
|
797
|
+
const base = normalizeAngleBase(a);
|
|
798
|
+
return !recordedBases.has(base) && !carriedBases.has(base);
|
|
799
|
+
});
|
|
800
|
+
return { missingAngles };
|
|
801
|
+
}
|
|
802
|
+
|
|
668
803
|
/**
|
|
669
804
|
* Default cap on parallel fan-out reviewers when a caller does not supply one.
|
|
670
805
|
* Mirrors the config default (gates.maxFanoutReviewers).
|
|
@@ -770,7 +905,6 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
|
|
|
770
905
|
if (err) malformed.push({ index, reason: err });
|
|
771
906
|
});
|
|
772
907
|
|
|
773
|
-
const bySeverity = Object.fromEntries(SEVERITY_ORDER.map((s) => [s, 0]));
|
|
774
908
|
/** @type {Array<{severity: string, angle: string, summary: string, file?: string, line?: number, recommendation?: string, disposition: string}>} */
|
|
775
909
|
const findings = [];
|
|
776
910
|
let blockingCount = 0;
|
|
@@ -782,7 +916,6 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
|
|
|
782
916
|
const severity = /** @type {string} */ (normalizeSeverity(f.severity));
|
|
783
917
|
const isBlocking = blocking.has(severity);
|
|
784
918
|
if (isBlocking) blockingCount += 1;
|
|
785
|
-
bySeverity[severity] += 1;
|
|
786
919
|
const entry = {
|
|
787
920
|
severity,
|
|
788
921
|
angle,
|
|
@@ -810,6 +943,9 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
|
|
|
810
943
|
verdict = "clean";
|
|
811
944
|
}
|
|
812
945
|
|
|
946
|
+
// `findings` already carries each entry's normalized severity, so tallying
|
|
947
|
+
// it directly (rather than incrementing a running map inside the loop
|
|
948
|
+
// above) reproduces the same counts via the one shared tally rule.
|
|
813
949
|
return {
|
|
814
950
|
verdict,
|
|
815
951
|
findings,
|
|
@@ -817,7 +953,7 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
|
|
|
817
953
|
angles: results.length,
|
|
818
954
|
findings: findings.length,
|
|
819
955
|
blocking: blockingCount,
|
|
820
|
-
bySeverity,
|
|
956
|
+
bySeverity: tallySeverities(findings),
|
|
821
957
|
},
|
|
822
958
|
malformed,
|
|
823
959
|
};
|
|
@@ -938,7 +1074,10 @@ export function validateJudgeVerdict(verdict) {
|
|
|
938
1074
|
*
|
|
939
1075
|
* Pure. Fails closed (throws) when a disposition references an out-of-range
|
|
940
1076
|
* index — a judge verdict that names a finding that is not in the ledger is a
|
|
941
|
-
* mismatch, never a silent enrichment
|
|
1077
|
+
* mismatch, never a silent enrichment — and when the dispositions do not
|
|
1078
|
+
* cover every finding: an undisposed finding must never be silently dropped
|
|
1079
|
+
* from the fixer's act list. An empty findings array with an empty
|
|
1080
|
+
* dispositions array is vacuously covered and returns without error.
|
|
942
1081
|
*
|
|
943
1082
|
* @param {Array<object>} findings — the flat consolidated findings array
|
|
944
1083
|
* @param {object} judgeVerdict — the validated judge verdict artifact
|
|
@@ -953,6 +1092,13 @@ export function applyJudgeDispositions(findings, judgeVerdict) {
|
|
|
953
1092
|
throw new Error(`judge disposition index ${d.index} is out of range (findings has ${enriched.length} entries)`);
|
|
954
1093
|
}
|
|
955
1094
|
const target = enriched[d.index];
|
|
1095
|
+
// Reset judge-owned fields before the re-merge: a pre-enriched finding
|
|
1096
|
+
// (already-enriched from a prior round, re-disposed by THIS verdict)
|
|
1097
|
+
// must not let stale judgeCriterion/followUpDraft survive a
|
|
1098
|
+
// defer -> act/reject re-disposition — the merged copy carries only
|
|
1099
|
+
// what the current disposition provides, never prior-round residue.
|
|
1100
|
+
delete target.judgeCriterion;
|
|
1101
|
+
delete target.followUpDraft;
|
|
956
1102
|
target.judgeDisposition = d.disposition;
|
|
957
1103
|
target.judgeRationale = d.rationale;
|
|
958
1104
|
if (typeof d.criterion === "string" && d.criterion.trim().length > 0) {
|
|
@@ -962,6 +1108,21 @@ export function applyJudgeDispositions(findings, judgeVerdict) {
|
|
|
962
1108
|
target.followUpDraft = d.followUpDraft;
|
|
963
1109
|
}
|
|
964
1110
|
}
|
|
1111
|
+
// Coverage is judged against THIS verdict's disposed-index set, not field
|
|
1112
|
+
// presence on the merged copy — an already-enriched ledger (a finding that
|
|
1113
|
+
// already carries judgeDisposition from a prior round) must not let a
|
|
1114
|
+
// verdict that disposes nothing pass silently. validateJudgeVerdict already
|
|
1115
|
+
// rejects duplicate indexes, so the Set is exact.
|
|
1116
|
+
const disposed = new Set(validated.dispositions.map((d) => d.index));
|
|
1117
|
+
const uncovered = enriched.reduce((positions, _f, i) => {
|
|
1118
|
+
if (!disposed.has(i)) positions.push(i);
|
|
1119
|
+
return positions;
|
|
1120
|
+
}, /** @type {number[]} */ ([]));
|
|
1121
|
+
if (uncovered.length > 0) {
|
|
1122
|
+
throw new Error(
|
|
1123
|
+
`judge verdict does not dispose ${uncovered.length} finding(s) (indexes: ${uncovered.join(", ")}) — fail closed; an undisposed finding must never be silently dropped from the fixer act list`
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
965
1126
|
return { findings: enriched, scopeDrift: validated.scopeDrift };
|
|
966
1127
|
}
|
|
967
1128
|
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from "./public-dev-loop-routing-contract.mjs";
|
|
21
21
|
import { normalizeRepoSlug } from "../github/repo-slug.mjs";
|
|
22
22
|
import { COPILOT_REVIEW_WAIT_TIMEOUT_MS } from "./policy-constants.mjs";
|
|
23
|
+
import { trimmedOrNull } from "./normalize.mjs";
|
|
23
24
|
import { resolveEffectiveAsyncStartMode } from "./async-start-contract.mjs";
|
|
24
25
|
import { resolveGateAngleContract, resolveGateAngles, resolveGateConfig, resolveHumanMergeOnly } from "../config/config.mjs";
|
|
25
26
|
|
|
@@ -130,6 +131,21 @@ register(INTERNAL_DEV_LOOP_STRATEGY.LOCAL_IMPLEMENTATION, "default", {
|
|
|
130
131
|
activeNoticeAfterMs: DEFAULT_ACTIVE_NOTICE_MS,
|
|
131
132
|
});
|
|
132
133
|
|
|
134
|
+
// local_implementation · spike run (SPIKE-RELAXED-GATE-PROFILE, #1628): a
|
|
135
|
+
// spike-mode spin resolves the relaxed `spike` gate profile instead of the
|
|
136
|
+
// default local-implementation gate. Kept as its own acceptance key so the
|
|
137
|
+
// generic default can stay approach-agnostic.
|
|
138
|
+
register(INTERNAL_DEV_LOOP_STRATEGY.LOCAL_IMPLEMENTATION, "spike", {
|
|
139
|
+
criteria: [
|
|
140
|
+
{ id: "spike-recorded", must: "The spike exploration and its recommendation are recorded (spike file + summary).", severity: "required" },
|
|
141
|
+
{ id: "verify-green", must: "`npm run verify` passes with no failures.", severity: "required" },
|
|
142
|
+
],
|
|
143
|
+
evidence: ["commands-run", "validation-output", "changed-files"],
|
|
144
|
+
maxFinalizationTurns: 6,
|
|
145
|
+
needsAttentionAfterMs: DEFAULT_NEEDS_ATTENTION_MS,
|
|
146
|
+
activeNoticeAfterMs: DEFAULT_ACTIVE_NOTICE_MS,
|
|
147
|
+
});
|
|
148
|
+
|
|
133
149
|
// wait_watch — dedicated window matching external healthy wait budget (policy-constants)
|
|
134
150
|
register(INTERNAL_DEV_LOOP_STRATEGY.WAIT_WATCH, "default", {
|
|
135
151
|
criteria: [
|
|
@@ -207,16 +223,8 @@ function normalizePositiveInt(v) {
|
|
|
207
223
|
return v;
|
|
208
224
|
}
|
|
209
225
|
|
|
210
|
-
function normalizeString(v) {
|
|
211
|
-
return typeof v === "string" && v.trim().length > 0 ? v.trim() : null;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function normalizeStringOrNull(v) {
|
|
215
|
-
return v === null || v === undefined ? null : normalizeString(v);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
226
|
function requireString(v, label) {
|
|
219
|
-
const s =
|
|
227
|
+
const s = trimmedOrNull(v);
|
|
220
228
|
if (s === null) throw new Error(`handoff-envelope: ${label} is required and must be a non-empty string`);
|
|
221
229
|
return s;
|
|
222
230
|
}
|
|
@@ -249,12 +257,12 @@ function deriveTarget(bundle, repo) {
|
|
|
249
257
|
target.pr = pr;
|
|
250
258
|
if (Number.isInteger(artifact.issue) && artifact.issue > 0) target.issue = artifact.issue;
|
|
251
259
|
} else if (kind === DEV_LOOP_TARGET_KIND.LOCAL_BRANCH) {
|
|
252
|
-
const branch =
|
|
260
|
+
const branch = trimmedOrNull(artifact.branch);
|
|
253
261
|
if (!branch) throw new Error("handoff-envelope: local_branch target must include a non-empty branch name");
|
|
254
262
|
target.branch = branch;
|
|
255
263
|
if (Number.isInteger(artifact.issue) && artifact.issue > 0) target.issue = artifact.issue;
|
|
256
264
|
} else if (kind === DEV_LOOP_TARGET_KIND.LOCAL_PHASE) {
|
|
257
|
-
const phase =
|
|
265
|
+
const phase = trimmedOrNull(artifact.phase);
|
|
258
266
|
const validIssue = Number.isInteger(artifact.issue) && artifact.issue > 0;
|
|
259
267
|
if (!phase && !validIssue) {
|
|
260
268
|
throw new Error("handoff-envelope: local_phase target must include a non-empty phase or a valid positive issue number");
|
|
@@ -325,8 +333,8 @@ export const CANONICAL_SPEC_SOURCE = Object.freeze({
|
|
|
325
333
|
* validateHandoffEnvelope would then reject.
|
|
326
334
|
*/
|
|
327
335
|
function deriveSpecSource(bundle, resolverOutput) {
|
|
328
|
-
const raw =
|
|
329
|
-
??
|
|
336
|
+
const raw = trimmedOrNull(resolverOutput?.canonicalSpecSource)
|
|
337
|
+
?? trimmedOrNull(bundle?.canonicalSpecSource);
|
|
330
338
|
return raw === CANONICAL_SPEC_SOURCE.PHASE_DOC || raw === CANONICAL_SPEC_SOURCE.PR_BODY ? raw : null;
|
|
331
339
|
}
|
|
332
340
|
|
|
@@ -444,7 +452,7 @@ export const WORKTREE_NAMESPACE = "tmp/worktrees/dev-loops";
|
|
|
444
452
|
* @returns {string} Absolute path `<repoRoot>/tmp/worktrees/dev-loops/<kind>-<number>`
|
|
445
453
|
*/
|
|
446
454
|
export function resolveWorktreePath({ repoRoot, kind, number } = {}) {
|
|
447
|
-
const root =
|
|
455
|
+
const root = trimmedOrNull(repoRoot);
|
|
448
456
|
if (!root) throw new Error("resolveWorktreePath: repoRoot is required and must be a non-empty string");
|
|
449
457
|
const k = typeof kind === "string" ? kind.trim().toLowerCase() : "";
|
|
450
458
|
if (k !== DEV_LOOP_TARGET_KIND.ISSUE && k !== DEV_LOOP_TARGET_KIND.PR) {
|
|
@@ -471,11 +479,11 @@ function buildWorktreeSlug(artifact, kind) {
|
|
|
471
479
|
return `pr-${artifact.pr}`;
|
|
472
480
|
}
|
|
473
481
|
if (kind === DEV_LOOP_TARGET_KIND.LOCAL_BRANCH) {
|
|
474
|
-
const branch =
|
|
482
|
+
const branch = trimmedOrNull(artifact.branch);
|
|
475
483
|
return branch ? flattenSlugSegment(branch) : null;
|
|
476
484
|
}
|
|
477
485
|
if (kind === DEV_LOOP_TARGET_KIND.LOCAL_PHASE) {
|
|
478
|
-
const phase =
|
|
486
|
+
const phase = trimmedOrNull(artifact.phase);
|
|
479
487
|
const issue = Number.isInteger(artifact.issue) && artifact.issue > 0 ? artifact.issue : null;
|
|
480
488
|
if (phase && issue) return `phase-${issue}-${flattenSlugSegment(phase)}`;
|
|
481
489
|
if (phase) return `phase-${flattenSlugSegment(phase)}`;
|
|
@@ -493,11 +501,11 @@ function normalizeGateState(gateState) {
|
|
|
493
501
|
const gs = gateState ?? {};
|
|
494
502
|
|
|
495
503
|
return {
|
|
496
|
-
currentHeadSha:
|
|
497
|
-
ciStatus:
|
|
504
|
+
currentHeadSha: trimmedOrNull(gs.currentHeadSha) ?? null,
|
|
505
|
+
ciStatus: trimmedOrNull(gs.ciStatus) ?? null,
|
|
498
506
|
unresolvedThreadCount: normalizePositiveInt(gs.unresolvedThreadCount) ?? 0,
|
|
499
507
|
copilotRoundCount: normalizePositiveInt(gs.copilotRoundCount) ?? 0,
|
|
500
|
-
currentSubGate:
|
|
508
|
+
currentSubGate: trimmedOrNull(gs.currentSubGate) ?? undefined,
|
|
501
509
|
};
|
|
502
510
|
}
|
|
503
511
|
|
|
@@ -545,6 +553,11 @@ function resolveSubGate(strategy, gateState) {
|
|
|
545
553
|
return "default";
|
|
546
554
|
}
|
|
547
555
|
|
|
556
|
+
/** True when the resolver output identifies a spike-mode run (#1628). */
|
|
557
|
+
function isSpikeRun(resolverOutput) {
|
|
558
|
+
return Boolean(resolverOutput && resolverOutput.spikeIntakeState);
|
|
559
|
+
}
|
|
560
|
+
|
|
548
561
|
|
|
549
562
|
// ---------------------------------------------------------------------------
|
|
550
563
|
// Deep freeze helper
|
|
@@ -580,7 +593,14 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
|
|
|
580
593
|
if (!repo) throw new Error("handoff-envelope: repo slug is required (owner/name)");
|
|
581
594
|
|
|
582
595
|
const gs = normalizeGateState(gateState);
|
|
583
|
-
|
|
596
|
+
// SPIKE-RELAXED-GATE-PROFILE (#1628): a spike-mode spin (startup resolver
|
|
597
|
+
// result carrying `spikeIntakeState`) resolves the relaxed `spike` gate
|
|
598
|
+
// profile instead of the default local-implementation gate. The spike
|
|
599
|
+
// marker lives at the TOP level of the resolver output (the bundle does not
|
|
600
|
+
// carry it), so it is read off `resolverOutput` directly.
|
|
601
|
+
const subGate = (strategy === INTERNAL_DEV_LOOP_STRATEGY.LOCAL_IMPLEMENTATION && isSpikeRun(resolverOutput))
|
|
602
|
+
? "spike"
|
|
603
|
+
: resolveSubGate(strategy, gs);
|
|
584
604
|
// Normalize each source independently, then fall back on the normalized result
|
|
585
605
|
// (not the raw value): a present-but-invalid gateState value must NOT shadow a
|
|
586
606
|
// valid options.retrospectiveFindings fallback (issue #1077 review finding).
|
|
@@ -241,6 +241,7 @@ export function extractUncheckedChecklistItems(sectionBody) {
|
|
|
241
241
|
* `## Refinement` / `## Plan` / `## Refinement doc` sections.
|
|
242
242
|
*/
|
|
243
243
|
export function detectLinkedRefinementDoc(body) {
|
|
244
|
+
|
|
244
245
|
if (typeof body !== "string" || body.length === 0) {
|
|
245
246
|
return { found: false, path: null, reason: "empty-body" };
|
|
246
247
|
}
|
|
@@ -486,6 +487,59 @@ function sectionHasBody(section) {
|
|
|
486
487
|
* @param {{ body?: string, expectedIssue?: number, issueLess?: boolean }} input
|
|
487
488
|
* @returns {{ checker: "validate-pr-body-spec", ok: boolean, errors: { code: string, message: string }[], sections: string[], acItems: string[], dodItems: string[], closesIssues: number[] }}
|
|
488
489
|
*/
|
|
490
|
+
|
|
491
|
+
// ---------------------------------------------------------------------------
|
|
492
|
+
// Grill sub-loop body predicates (GRILL-SUBLOOP-*, #1628)
|
|
493
|
+
// ---------------------------------------------------------------------------
|
|
494
|
+
// The loop-grill skill writes its raw Q&A transcript and synthesis to an
|
|
495
|
+
// ephemeral tmp artifact and keeps only the canonical synthesized sections
|
|
496
|
+
// (Acceptance criteria / Definition of done / Non-goals) plus the sanctioned
|
|
497
|
+
// `<!-- loop-grill: ... -->` marker in the durable issue/PR body. The body
|
|
498
|
+
// MUST NOT embed the raw grill transcript/synthesis/Q&A headings
|
|
499
|
+
// (GRILL-SUBLOOP-NO-EMBED-SYNTHESIS). These pure predicates are the only
|
|
500
|
+
// mechanically-enforceable part of that contract; the judgment-bound clauses
|
|
501
|
+
// ("resolve every gap the grill decided", "stale contradicting prose") stay
|
|
502
|
+
// agent-level.
|
|
503
|
+
|
|
504
|
+
export const GRILL_MARKER_PATTERN = /<!--\s*loop-grill:\s*.*?-->/iu;
|
|
505
|
+
|
|
506
|
+
/** Case-insensitive **section heading names** that embed grill material. */
|
|
507
|
+
export const GRILL_EMBED_HEADING_PATTERNS = Object.freeze([
|
|
508
|
+
/^grill\s+findings$/iu,
|
|
509
|
+
/^grill\s+transcript$/iu,
|
|
510
|
+
/^grill\s+synthesis$/iu,
|
|
511
|
+
/^grill\s+q&a$/iu,
|
|
512
|
+
/^grill\s+qa$/iu,
|
|
513
|
+
]);
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Detect the sanctioned `<!-- loop-grill: ... -->` marker. Pure predicate.
|
|
517
|
+
* @param {string} [body]
|
|
518
|
+
* @returns {boolean} true when the marker is present.
|
|
519
|
+
*/
|
|
520
|
+
export function detectGrillMarker(body = "") {
|
|
521
|
+
return typeof body === "string" && GRILL_MARKER_PATTERN.test(body);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Detect a grill transcript/synthesis/Q&A embed heading in the body. Pure
|
|
526
|
+
* predicate; returns the first offending heading name at any markdown level
|
|
527
|
+
* (# through ######) or null.
|
|
528
|
+
* @param {string} [body]
|
|
529
|
+
* @returns {string|null} the offending heading name, or null when none.
|
|
530
|
+
*/
|
|
531
|
+
export function detectGrillEmbedHeading(body = "") {
|
|
532
|
+
if (typeof body !== "string" || body.length === 0) return null;
|
|
533
|
+
for (const section of parseMarkdownSections(body)) {
|
|
534
|
+
for (const pattern of GRILL_EMBED_HEADING_PATTERNS) {
|
|
535
|
+
if (pattern.test(String(section.name))) {
|
|
536
|
+
return String(section.name);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
|
|
489
543
|
export function validatePrBodySpec({ body = "", expectedIssue = null, issueLess = false } = {}) {
|
|
490
544
|
if (issueLess && Number.isInteger(expectedIssue)) {
|
|
491
545
|
// Fail closed at the library boundary too (not just the CLI): the two modes
|
|
@@ -583,6 +637,46 @@ export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = f
|
|
|
583
637
|
return { action: auto ? "divert" : "block", reason, missing };
|
|
584
638
|
}
|
|
585
639
|
|
|
640
|
+
/**
|
|
641
|
+
* Apply the pickup-column refinement gate to one issue: fetch the issue body,
|
|
642
|
+
* run `decideEnqueueRefinementGate`, and throw the canonical `GH_API_ERROR` /
|
|
643
|
+
* `MISSING_REFINEMENT_ARTIFACT` on failure. This is the single
|
|
644
|
+
* gate-application orchestration shared by `queue add` (enqueue-time) and
|
|
645
|
+
* `queue move` (move-time) — never a second copy. It returns the gate decision
|
|
646
|
+
* so add-only (divert/park) and move-only (refined-flag) handling stays with
|
|
647
|
+
* each caller.
|
|
648
|
+
*
|
|
649
|
+
* @param {{ issueNumber: number, repo: string, env: object, runChild: Function, auto?: boolean }} input
|
|
650
|
+
* @returns {Promise<{ action: "enqueue" } | { action: "divert"|"block", reason: string, missing: string[] }>}
|
|
651
|
+
*/
|
|
652
|
+
export async function runPickupRefinementGate({ issueNumber, repo, env, runChild, auto = false }) {
|
|
653
|
+
const bodyResult = await runChild(
|
|
654
|
+
"gh",
|
|
655
|
+
["issue", "view", String(issueNumber), "--repo", repo, "--json", "body"],
|
|
656
|
+
env,
|
|
657
|
+
);
|
|
658
|
+
if (bodyResult.code !== 0) {
|
|
659
|
+
const detail = bodyResult.stderr?.trim() || `exit code ${bodyResult.code}`;
|
|
660
|
+
throw Object.assign(new Error(`gh issue view failed: ${detail}`), { code: "GH_API_ERROR" });
|
|
661
|
+
}
|
|
662
|
+
let bodyPayload;
|
|
663
|
+
try {
|
|
664
|
+
bodyPayload = JSON.parse(bodyResult.stdout);
|
|
665
|
+
} catch {
|
|
666
|
+
throw new Error("Invalid JSON input");
|
|
667
|
+
}
|
|
668
|
+
const body = typeof bodyPayload?.body === "string" ? bodyPayload.body : "";
|
|
669
|
+
const artifact = detectIssueRefinementArtifact({ body, issueNumber });
|
|
670
|
+
const decision = decideEnqueueRefinementGate({ artifact, targetIsPickup: true, auto });
|
|
671
|
+
if (decision.action === "block") {
|
|
672
|
+
throw Object.assign(new Error(decision.reason), {
|
|
673
|
+
code: "MISSING_REFINEMENT_ARTIFACT",
|
|
674
|
+
missing: decision.missing,
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
return decision;
|
|
678
|
+
}
|
|
679
|
+
|
|
586
680
|
/**
|
|
587
681
|
* Map a draft-gate refinement check to the result surface consumed by
|
|
588
682
|
* `evaluatePrGateCoordination`. The mapping keeps the contract
|
|
@@ -164,6 +164,14 @@ function normalizeLifecycleState(value) {
|
|
|
164
164
|
* mergeAuthorized, // boolean: explicit merge authorization granted
|
|
165
165
|
* humanMergeOnly, // boolean: repo invariant — agent may never merge (fails closed)
|
|
166
166
|
* isMerged, // boolean: PR has been merged
|
|
167
|
+
* sizeBudgetHumanApprovalRequired, // boolean: the size-budget merge gate
|
|
168
|
+
* // (resolveSizeBudgetHumanApprovalRequired,
|
|
169
|
+
* // @dev-loops/core/loop/size-budget-merge-gate) says
|
|
170
|
+
* // this escalated/T1 PR still needs a human APPROVED
|
|
171
|
+
* // review — consulted IN ADDITION TO mergeAuthorized/
|
|
172
|
+
* // humanMergeOnly, defaults false (opt-in; callers
|
|
173
|
+
* // that do not evaluate the size budget see unchanged
|
|
174
|
+
* // behavior)
|
|
167
175
|
* }
|
|
168
176
|
* ```
|
|
169
177
|
*
|
|
@@ -180,7 +188,8 @@ function normalizeLifecycleState(value) {
|
|
|
180
188
|
* Resolution order (first-match):
|
|
181
189
|
* 1. Explicit phase → return canonical if recognized, fall through if not
|
|
182
190
|
* 2. Merged → merge (terminal)
|
|
183
|
-
* 3. Merge authorized + pre-approval passed + linked PR
|
|
191
|
+
* 3. Merge authorized + pre-approval passed + linked PR + size-budget gate
|
|
192
|
+
* clear (not sizeBudgetHumanApprovalRequired) → merge
|
|
184
193
|
* 4. Pre-approval passed + PR exists → pre_approval_gate
|
|
185
194
|
* 5. Unresolved threads + PR exists → feedback_resolution
|
|
186
195
|
* 6. Draft PR → implementation
|
|
@@ -197,6 +206,7 @@ export function resolveLifecycleState(input = {}) {
|
|
|
197
206
|
mergeAuthorized = false,
|
|
198
207
|
humanMergeOnly = false,
|
|
199
208
|
isMerged = false,
|
|
209
|
+
sizeBudgetHumanApprovalRequired = false,
|
|
200
210
|
} = input;
|
|
201
211
|
|
|
202
212
|
// Fail closed: when the repo enforces human-only merge, the agent is never
|
|
@@ -205,7 +215,16 @@ export function resolveLifecycleState(input = {}) {
|
|
|
205
215
|
// exact `true` clears merge), matching the authoritative
|
|
206
216
|
// `resolveEffectiveMergeAuthorized` gate. An already-merged PR (isMerged) is
|
|
207
217
|
// still terminal below.
|
|
208
|
-
|
|
218
|
+
//
|
|
219
|
+
// The size-budget merge gate (resolveSizeBudgetHumanApprovalRequired) is
|
|
220
|
+
// consulted IN ADDITION TO the two invariants above, never in their place:
|
|
221
|
+
// an escalated/T1 PR without a human APPROVED review (and zero unresolved
|
|
222
|
+
// CHANGES_REQUESTED) parks at PRE_APPROVAL_GATE — the existing "await human
|
|
223
|
+
// approval" phase — instead of advancing to MERGE, even under a standing
|
|
224
|
+
// merge authorization.
|
|
225
|
+
const effectiveMergeAuthorized = humanMergeOnly !== true
|
|
226
|
+
&& mergeAuthorized === true
|
|
227
|
+
&& sizeBudgetHumanApprovalRequired !== true;
|
|
209
228
|
|
|
210
229
|
// 1. Explicit phase override — canonical or fail closed
|
|
211
230
|
if (phase !== null && phase !== undefined) {
|