@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.
Files changed (47) hide show
  1. package/package.json +12 -1
  2. package/src/analysis/change-classifier.mjs +10 -0
  3. package/src/analysis/diff-analyzer.mjs +68 -1
  4. package/src/claude/hook-decisions.mjs +204 -5
  5. package/src/cli/primitives.mjs +51 -1
  6. package/src/config/config.mjs +307 -14
  7. package/src/config/extension-defaults.yaml +39 -1
  8. package/src/github/comment-id-guard.mjs +158 -0
  9. package/src/github/copilot-helpers.mjs +145 -5
  10. package/src/github/gh.mjs +94 -0
  11. package/src/github/issue-ops.mjs +13 -0
  12. package/src/loop/agent-stall.mjs +196 -0
  13. package/src/loop/bash-command-classify.mjs +277 -0
  14. package/src/loop/cache-telemetry-evidence.mjs +437 -0
  15. package/src/loop/copilot-loop-iterations.mjs +2 -1
  16. package/src/loop/default-branch-guard.mjs +35 -2
  17. package/src/loop/gate-carry-forward.mjs +19 -6
  18. package/src/loop/gate-fanin.mjs +190 -29
  19. package/src/loop/handoff-envelope.mjs +40 -20
  20. package/src/loop/issue-refinement-artifact.mjs +94 -0
  21. package/src/loop/lifecycle-state.mjs +21 -2
  22. package/src/loop/main-checkout-ff.mjs +73 -0
  23. package/src/loop/markdown-sections.mjs +40 -0
  24. package/src/loop/normalize.mjs +7 -0
  25. package/src/loop/plan-file-promote-contract.mjs +14 -1
  26. package/src/loop/plan-file-refine-contract.mjs +92 -8
  27. package/src/loop/policy-constants.mjs +9 -0
  28. package/src/loop/pr-gate-coordination.mjs +65 -12
  29. package/src/loop/primer-evidence.mjs +375 -0
  30. package/src/loop/public-dev-loop-routing.mjs +7 -15
  31. package/src/loop/queue-board-sync.mjs +1 -26
  32. package/src/loop/queue-driver.mjs +14 -1
  33. package/src/loop/refinement-grill-state.mjs +3 -5
  34. package/src/loop/review-dispatch-plan.mjs +1034 -0
  35. package/src/loop/review-lineage.mjs +588 -0
  36. package/src/loop/reviewer-loop-state.mjs +8 -13
  37. package/src/loop/run-post-merge-actions.mjs +148 -0
  38. package/src/loop/size-budget-merge-gate.mjs +121 -0
  39. package/src/loop/tracker-pr-state.mjs +5 -15
  40. package/src/loop/ui-designer-review-scoping.mjs +171 -0
  41. package/src/loop/ui-review-drive.mjs +3 -1
  42. package/src/loop/ui-review-report.mjs +2 -5
  43. package/src/loop/ui-review-teardown.mjs +3 -1
  44. package/src/loop/worktree-guard.mjs +80 -0
  45. package/src/projects/list-queue-items.mjs +1 -27
  46. package/src/projects/move-queue-item.mjs +38 -28
  47. package/src/security/secret-scan.mjs +330 -0
@@ -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`: angle names that
110
- * already have a clean per-angle findings artifact stamped for THIS head. A dispatch unit (group)
111
- * whose angles are ALL complete is excluded from the required count and from `pendingGroups`, so a
112
- * later session resumes the fan-out instead of restarting it (issue #1507 AC3): it re-runs the
113
- * preflight and dispatches only the groups not already complete at this head.
114
- * @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[] }}
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 completedSet = new Set(
119
- Array.isArray(completedAngles)
120
- ? completedAngles
121
- : completedAngles == null
122
- ? []
123
- : [...completedAngles],
124
- );
125
- // #1507 AC3: resume instead of restart. One reviewer per dispatch unit (a
126
- // group of N angles is one reviewer's scoped dispatch — see resolveFanoutGroups /
127
- // countFreshDispatchUnits), but a group already COMPLETE at this head — every
128
- // one of its angles has a clean artifact stamped for this head — needs no
129
- // reviewer and is excluded from the required count and the pending plan. The
130
- // conductor dispatches only `pendingGroups`.
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) && g.angles.length > 0 && g.angles.every((a) => completedSet.has(a));
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
- export const VALID_SEVERITIES = new Set(SEVERITY_ORDER);
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 = typeof entry.group === "string" && entry.group.trim().length > 0 ? entry.group.trim() : null;
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 = normalizeString(v);
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 = normalizeString(artifact.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 = normalizeString(artifact.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 = normalizeStringOrNull(resolverOutput?.canonicalSpecSource)
329
- ?? normalizeStringOrNull(bundle?.canonicalSpecSource);
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 = normalizeString(repoRoot);
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 = normalizeString(artifact.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 = normalizeString(artifact.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: normalizeStringOrNull(gs.currentHeadSha) ?? null,
497
- ciStatus: normalizeStringOrNull(gs.ciStatus) ?? null,
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: normalizeString(gs.currentSubGate) ?? undefined,
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
- const subGate = resolveSubGate(strategy, gs);
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 → merge
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
- const effectiveMergeAuthorized = humanMergeOnly !== true && mergeAuthorized === true;
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) {