@dev-loops/core 1.0.0-rc.6 → 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 (41) hide show
  1. package/package.json +7 -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 +36 -4
  5. package/src/cli/primitives.mjs +30 -1
  6. package/src/config/config.mjs +254 -13
  7. package/src/config/extension-defaults.yaml +34 -1
  8. package/src/github/comment-id-guard.mjs +97 -9
  9. package/src/github/copilot-helpers.mjs +114 -5
  10. package/src/github/gh.mjs +94 -0
  11. package/src/github/issue-ops.mjs +7 -0
  12. package/src/loop/agent-stall.mjs +4 -2
  13. package/src/loop/copilot-loop-iterations.mjs +2 -1
  14. package/src/loop/default-branch-guard.mjs +34 -1
  15. package/src/loop/gate-carry-forward.mjs +19 -6
  16. package/src/loop/gate-fanin.mjs +190 -29
  17. package/src/loop/handoff-envelope.mjs +12 -19
  18. package/src/loop/lifecycle-state.mjs +21 -2
  19. package/src/loop/main-checkout-ff.mjs +34 -0
  20. package/src/loop/markdown-sections.mjs +40 -0
  21. package/src/loop/normalize.mjs +7 -0
  22. package/src/loop/plan-file-promote-contract.mjs +14 -1
  23. package/src/loop/plan-file-refine-contract.mjs +92 -8
  24. package/src/loop/policy-constants.mjs +9 -0
  25. package/src/loop/pr-gate-coordination.mjs +65 -12
  26. package/src/loop/public-dev-loop-routing.mjs +7 -15
  27. package/src/loop/queue-board-sync.mjs +1 -26
  28. package/src/loop/queue-driver.mjs +14 -1
  29. package/src/loop/refinement-grill-state.mjs +3 -5
  30. package/src/loop/review-dispatch-plan.mjs +448 -9
  31. package/src/loop/reviewer-loop-state.mjs +8 -13
  32. package/src/loop/run-post-merge-actions.mjs +148 -0
  33. package/src/loop/size-budget-merge-gate.mjs +121 -0
  34. package/src/loop/tracker-pr-state.mjs +5 -15
  35. package/src/loop/ui-designer-review-scoping.mjs +171 -0
  36. package/src/loop/ui-review-drive.mjs +3 -1
  37. package/src/loop/ui-review-report.mjs +2 -5
  38. package/src/loop/ui-review-teardown.mjs +3 -1
  39. package/src/projects/list-queue-items.mjs +1 -27
  40. package/src/projects/move-queue-item.mjs +1 -27
  41. 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
 
@@ -222,16 +223,8 @@ function normalizePositiveInt(v) {
222
223
  return v;
223
224
  }
224
225
 
225
- function normalizeString(v) {
226
- return typeof v === "string" && v.trim().length > 0 ? v.trim() : null;
227
- }
228
-
229
- function normalizeStringOrNull(v) {
230
- return v === null || v === undefined ? null : normalizeString(v);
231
- }
232
-
233
226
  function requireString(v, label) {
234
- const s = normalizeString(v);
227
+ const s = trimmedOrNull(v);
235
228
  if (s === null) throw new Error(`handoff-envelope: ${label} is required and must be a non-empty string`);
236
229
  return s;
237
230
  }
@@ -264,12 +257,12 @@ function deriveTarget(bundle, repo) {
264
257
  target.pr = pr;
265
258
  if (Number.isInteger(artifact.issue) && artifact.issue > 0) target.issue = artifact.issue;
266
259
  } else if (kind === DEV_LOOP_TARGET_KIND.LOCAL_BRANCH) {
267
- const branch = normalizeString(artifact.branch);
260
+ const branch = trimmedOrNull(artifact.branch);
268
261
  if (!branch) throw new Error("handoff-envelope: local_branch target must include a non-empty branch name");
269
262
  target.branch = branch;
270
263
  if (Number.isInteger(artifact.issue) && artifact.issue > 0) target.issue = artifact.issue;
271
264
  } else if (kind === DEV_LOOP_TARGET_KIND.LOCAL_PHASE) {
272
- const phase = normalizeString(artifact.phase);
265
+ const phase = trimmedOrNull(artifact.phase);
273
266
  const validIssue = Number.isInteger(artifact.issue) && artifact.issue > 0;
274
267
  if (!phase && !validIssue) {
275
268
  throw new Error("handoff-envelope: local_phase target must include a non-empty phase or a valid positive issue number");
@@ -340,8 +333,8 @@ export const CANONICAL_SPEC_SOURCE = Object.freeze({
340
333
  * validateHandoffEnvelope would then reject.
341
334
  */
342
335
  function deriveSpecSource(bundle, resolverOutput) {
343
- const raw = normalizeStringOrNull(resolverOutput?.canonicalSpecSource)
344
- ?? normalizeStringOrNull(bundle?.canonicalSpecSource);
336
+ const raw = trimmedOrNull(resolverOutput?.canonicalSpecSource)
337
+ ?? trimmedOrNull(bundle?.canonicalSpecSource);
345
338
  return raw === CANONICAL_SPEC_SOURCE.PHASE_DOC || raw === CANONICAL_SPEC_SOURCE.PR_BODY ? raw : null;
346
339
  }
347
340
 
@@ -459,7 +452,7 @@ export const WORKTREE_NAMESPACE = "tmp/worktrees/dev-loops";
459
452
  * @returns {string} Absolute path `<repoRoot>/tmp/worktrees/dev-loops/<kind>-<number>`
460
453
  */
461
454
  export function resolveWorktreePath({ repoRoot, kind, number } = {}) {
462
- const root = normalizeString(repoRoot);
455
+ const root = trimmedOrNull(repoRoot);
463
456
  if (!root) throw new Error("resolveWorktreePath: repoRoot is required and must be a non-empty string");
464
457
  const k = typeof kind === "string" ? kind.trim().toLowerCase() : "";
465
458
  if (k !== DEV_LOOP_TARGET_KIND.ISSUE && k !== DEV_LOOP_TARGET_KIND.PR) {
@@ -486,11 +479,11 @@ function buildWorktreeSlug(artifact, kind) {
486
479
  return `pr-${artifact.pr}`;
487
480
  }
488
481
  if (kind === DEV_LOOP_TARGET_KIND.LOCAL_BRANCH) {
489
- const branch = normalizeString(artifact.branch);
482
+ const branch = trimmedOrNull(artifact.branch);
490
483
  return branch ? flattenSlugSegment(branch) : null;
491
484
  }
492
485
  if (kind === DEV_LOOP_TARGET_KIND.LOCAL_PHASE) {
493
- const phase = normalizeString(artifact.phase);
486
+ const phase = trimmedOrNull(artifact.phase);
494
487
  const issue = Number.isInteger(artifact.issue) && artifact.issue > 0 ? artifact.issue : null;
495
488
  if (phase && issue) return `phase-${issue}-${flattenSlugSegment(phase)}`;
496
489
  if (phase) return `phase-${flattenSlugSegment(phase)}`;
@@ -508,11 +501,11 @@ function normalizeGateState(gateState) {
508
501
  const gs = gateState ?? {};
509
502
 
510
503
  return {
511
- currentHeadSha: normalizeStringOrNull(gs.currentHeadSha) ?? null,
512
- ciStatus: normalizeStringOrNull(gs.ciStatus) ?? null,
504
+ currentHeadSha: trimmedOrNull(gs.currentHeadSha) ?? null,
505
+ ciStatus: trimmedOrNull(gs.ciStatus) ?? null,
513
506
  unresolvedThreadCount: normalizePositiveInt(gs.unresolvedThreadCount) ?? 0,
514
507
  copilotRoundCount: normalizePositiveInt(gs.copilotRoundCount) ?? 0,
515
- currentSubGate: normalizeString(gs.currentSubGate) ?? undefined,
508
+ currentSubGate: trimmedOrNull(gs.currentSubGate) ?? undefined,
516
509
  };
517
510
  }
518
511
 
@@ -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) {
@@ -95,3 +95,37 @@ export function buildWorktreeCleanupCommand(mainCheckout, prNumber) {
95
95
  // non-fatal with `|| true` — removal must never break a merge-completion flow.
96
96
  return `if [ -f ${script} ]; then node ${script} --repo-root ${quotedMain} --pr "${pr}"; fi || true`;
97
97
  }
98
+
99
+ /**
100
+ * Overall timeout (ms) for the post-merge actions runner invocation. Generous:
101
+ * the runner itself bounds each declared action by its own timeoutMs/verify
102
+ * budget (each individually capped at the config-schema ceiling), and this is
103
+ * only the outer harness-hook guard against a runner that never returns.
104
+ */
105
+ export const POST_MERGE_ACTIONS_TIMEOUT_MS = 900_000;
106
+
107
+ /**
108
+ * Build the best-effort `postMerge.actions` runner command (#1457): the shared,
109
+ * dependency-free command string both harness hooks (Pi `post-merge-update`,
110
+ * Claude `post-tool-use-merge`) run after a successful merge, for the repo that
111
+ * merged. Existence-guarded (a checkout without the runner script is a silent
112
+ * no-op) and non-fatal (`|| true` — a runner failure must never break a
113
+ * merge-completion flow; the runner itself reports per-action failures in its
114
+ * own JSON result). `mainCheckout` and the script path are POSIX
115
+ * single-quoted; `prNumber` (when a valid positive integer) is passed as a
116
+ * double-quoted `--pr` argument — never interpolated into `run`/`verify`
117
+ * command strings, which the runner executes verbatim from the repo's own
118
+ * `.devloops`.
119
+ *
120
+ * @param {string} mainCheckout - Absolute path to the main (primary) git checkout.
121
+ * @param {string | number | undefined} [prNumber] - Merged PR number, when known.
122
+ * @returns {string} the runner command (always non-empty; a missing PR number
123
+ * just omits `--pr`, since `onlyIfChanged` scoping bypasses cleanly without one).
124
+ */
125
+ export function buildPostMergeActionsCommand(mainCheckout, prNumber) {
126
+ const quotedMain = shellQuotePath(mainCheckout);
127
+ const script = shellQuotePath(path.join(mainCheckout, "scripts", "loop", "run-post-merge-actions.mjs"));
128
+ const pr = String(prNumber ?? "").trim();
129
+ const prArg = /^[0-9]+$/u.test(pr) ? ` --pr "${pr}"` : "";
130
+ return `if [ -f ${script} ]; then node ${script} --repo-root ${quotedMain}${prArg}; fi || true`;
131
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Shared `## <heading>` markdown-section helpers. A "section" is an H2
3
+ * heading line and everything up to (but not including) the next H2.
4
+ */
5
+
6
+ /** Build the case-insensitive, multiline `^## <heading>$` matcher shared by
7
+ * extractSection/hasSection/stripSection. */
8
+ export function buildSectionHeadingPattern(headingText) {
9
+ // Public export: a non-string or empty heading has no section to match, so
10
+ // return a never-match pattern rather than throwing (non-string) or building
11
+ // a bare `^##\s+\s*$` that matches any H2 (empty). Every in-repo caller passes
12
+ // a canonical heading string; this only hardens the new public boundary.
13
+ if (typeof headingText !== "string" || headingText.length === 0) {
14
+ return /(?!)/u;
15
+ }
16
+ const escapedHeading = headingText.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
17
+ return new RegExp(`^##\\s+${escapedHeading}\\s*$`, "imu");
18
+ }
19
+
20
+ /**
21
+ * Extract the trimmed body of a `## <headingText>` section from `body`, or
22
+ * null when the heading isn't present.
23
+ */
24
+ export function extractSection(body, headingText) {
25
+ if (typeof body !== "string" || body.length === 0) {
26
+ return null;
27
+ }
28
+ const headingPattern = buildSectionHeadingPattern(headingText);
29
+ const match = headingPattern.exec(body);
30
+ if (!match || match.index === undefined) {
31
+ return null;
32
+ }
33
+ const start = match.index + match[0].length;
34
+ const remaining = body.slice(start);
35
+ const nextHeadingMatch = /^##\s+/imu.exec(remaining);
36
+ const end = nextHeadingMatch && nextHeadingMatch.index !== undefined
37
+ ? start + nextHeadingMatch.index
38
+ : body.length;
39
+ return body.slice(start, end).trim();
40
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Shared string-normalization primitive used across the loop layer:
3
+ * trim a value and return it, or null when it isn't a non-empty string.
4
+ */
5
+ export function trimmedOrNull(value) {
6
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
7
+ }
@@ -219,16 +219,28 @@ function neutralizeIssueCloseKeywords(text) {
219
219
  * authority. Issue-closing keywords inside the embedded AC/DoD are neutralized
220
220
  * so untrusted plan content cannot smuggle one in.
221
221
  *
222
+ * The plan's `## Size estimate` section (phase 4 of #1480's plan-time size budget —
223
+ * see `validatePhaseSizeEstimate` in `plan-file-refine-contract.mjs`) is carried
224
+ * through verbatim when present, so an over-budget-but-cohesive phase's
225
+ * `oversize: justified` note flows into the PR the fail-closed post-hoc size
226
+ * budget (`check-size-budget.mjs`, wired at draft-exit) later escalates: a human
227
+ * reading that PR's escalated review sees the plan-time reasoning right in the
228
+ * body, not just that the diff came out large. Optional — an already-promoted
229
+ * or hand-authored plan without the section still promotes; the section is
230
+ * simply omitted from the PR body.
231
+ *
222
232
  * @param {object} params
223
233
  * @param {string} params.planDocPath repo-relative path of the committed plan doc
224
234
  * @param {string} params.acceptanceCriteria full Acceptance criteria section body
225
235
  * @param {string} params.definitionOfDone full Definition of done section body
236
+ * @param {string} [params.sizeEstimate] full Size estimate section body, if present
226
237
  * @returns {string}
227
238
  */
228
- export function buildPromotionPrBody({ planDocPath, acceptanceCriteria, definitionOfDone } = {}) {
239
+ export function buildPromotionPrBody({ planDocPath, acceptanceCriteria, definitionOfDone, sizeEstimate } = {}) {
229
240
  const docPath = String(planDocPath ?? "").trim();
230
241
  const ac = String(acceptanceCriteria ?? "").trim();
231
242
  const dod = String(definitionOfDone ?? "").trim();
243
+ const size = String(sizeEstimate ?? "").trim();
232
244
  if (docPath.length === 0) {
233
245
  throw new Error("buildPromotionPrBody requires a planDocPath");
234
246
  }
@@ -252,5 +264,6 @@ export function buildPromotionPrBody({ planDocPath, acceptanceCriteria, definiti
252
264
  "",
253
265
  safeDod,
254
266
  "",
267
+ ...(size.length > 0 ? ["## Size estimate", "", neutralizeIssueCloseKeywords(size), ""] : []),
255
268
  ].join("\n");
256
269
  }