@dev-loops/core 1.0.0-rc.6 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/package.json +8 -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/commit-msg-guard.mjs +168 -0
  14. package/src/loop/copilot-loop-iterations.mjs +2 -1
  15. package/src/loop/default-branch-guard.mjs +34 -1
  16. package/src/loop/gate-carry-forward.mjs +19 -6
  17. package/src/loop/gate-fanin.mjs +190 -29
  18. package/src/loop/handoff-envelope.mjs +12 -19
  19. package/src/loop/issue-refinement-artifact.mjs +186 -42
  20. package/src/loop/lifecycle-state.mjs +21 -2
  21. package/src/loop/main-checkout-ff.mjs +34 -0
  22. package/src/loop/markdown-sections.mjs +40 -0
  23. package/src/loop/normalize.mjs +7 -0
  24. package/src/loop/plan-file-promote-contract.mjs +14 -1
  25. package/src/loop/plan-file-refine-contract.mjs +92 -8
  26. package/src/loop/policy-constants.mjs +9 -0
  27. package/src/loop/pr-gate-coordination.mjs +65 -12
  28. package/src/loop/public-dev-loop-routing.mjs +11 -15
  29. package/src/loop/queue-board-sync.mjs +1 -26
  30. package/src/loop/queue-driver.mjs +14 -1
  31. package/src/loop/refinement-grill-state.mjs +3 -5
  32. package/src/loop/retrospective-checkpoint.mjs +59 -1
  33. package/src/loop/review-dispatch-plan.mjs +448 -9
  34. package/src/loop/reviewer-loop-state.mjs +8 -13
  35. package/src/loop/run-post-merge-actions.mjs +148 -0
  36. package/src/loop/size-budget-merge-gate.mjs +121 -0
  37. package/src/loop/tracker-pr-state.mjs +5 -15
  38. package/src/loop/ui-designer-review-scoping.mjs +171 -0
  39. package/src/loop/ui-review-drive.mjs +3 -1
  40. package/src/loop/ui-review-report.mjs +2 -5
  41. package/src/loop/ui-review-teardown.mjs +3 -1
  42. package/src/projects/list-queue-items.mjs +1 -27
  43. package/src/projects/move-queue-item.mjs +2 -28
  44. package/src/security/secret-scan.mjs +330 -0
@@ -9,6 +9,13 @@
9
9
  * `Acceptance criteria` or `DoD` section cause the draft gate to post
10
10
  * `verdict=blocked` with the `missing_refinement_artifact` finding.
11
11
  *
12
+ * Since #1866 the check ALSO requires an explicit Non-goals section on the
13
+ * issue body (see `MISSING_EXPLICIT_NON_GOALS_FINDING` below).
14
+ */
15
+ import { existsSync } from "node:fs";
16
+ import path from "node:path";
17
+
18
+ /**
12
19
  * This module owns:
13
20
  * - canonical section-name matching for AC / DoD blocks
14
21
  * - bullet-item extraction (checklist `- [ ]`/`- [x]` and top-level `- ` bullets)
@@ -38,6 +45,15 @@ export const REFINEMENT_ARTIFACT_SOURCES = Object.freeze([
38
45
  "linked refinement doc",
39
46
  ]);
40
47
 
48
+ /**
49
+ * #1866: finding reported when the issue body carries a refinement artifact
50
+ * (AC/DoD checklist or a resolvable linked doc) but no explicit Non-goals
51
+ * section. Mirrors the PR-path narrative-invariant code
52
+ * (`PR_BODY_SPEC_NARRATIVE_SECTIONS.non_goals.code`) so both spec surfaces
53
+ * name the missing invariant identically.
54
+ */
55
+ export const MISSING_EXPLICIT_NON_GOALS_FINDING = "missing_explicit_non_goals";
56
+
41
57
  /**
42
58
  * Canonical list of section headings that satisfy the refinement check.
43
59
  * Matching is case-insensitive and tolerates trailing/leading whitespace.
@@ -248,6 +264,13 @@ export function detectLinkedRefinementDoc(body) {
248
264
 
249
265
  const pathMatch = /(?:^|\s|[`(\[<])(tmp\/refinement\/[A-Za-z0-9._/\-]+\.md)\b/u.exec(body);
250
266
  if (pathMatch) {
267
+ // Containment guard: reject actual '..' path segments (not benign
268
+ // double-dot filenames) so the new fs-probe wiring can never be used as a
269
+ // filesystem existence oracle outside tmp/refinement
270
+ // (e.g. `tmp/refinement/../../docs/some-existing.md`).
271
+ if (pathMatch[1].split("/").some((segment) => segment === "..")) {
272
+ return { found: false, path: null, reason: "path-escapes-refinement-dir" };
273
+ }
251
274
  return { found: true, path: pathMatch[1], reason: "explicit-path" };
252
275
  }
253
276
 
@@ -261,6 +284,11 @@ export function detectLinkedRefinementDoc(body) {
261
284
  if (refinementSection) {
262
285
  const inlinePath = /(?:^|\s)(tmp\/refinement\/[^\s)`'"]+\.md)\b/u.exec(refinementSection.bodyLines.join("\n"));
263
286
  if (inlinePath) {
287
+ // Containment guard: same segment-based '..' rejection as the
288
+ // explicit-path branch.
289
+ if (inlinePath[1].split("/").some((segment) => segment === "..")) {
290
+ return { found: false, path: null, reason: "path-escapes-refinement-dir" };
291
+ }
264
292
  return { found: true, path: inlinePath[1], reason: "refinement-section-path" };
265
293
  }
266
294
  }
@@ -271,25 +299,56 @@ export function detectLinkedRefinementDoc(body) {
271
299
  /**
272
300
  * Detect the refinement artifact on a parsed issue body.
273
301
  *
302
+ * #1866: the tracker-backed refinement floor is the artifact (AC checklist,
303
+ * DoD checklist, or a resolvable linked refinement doc) AND an explicit,
304
+ * non-empty Non-goals section — the loop-grill / artifact-authority contract
305
+ * requires Non-goals on a refined issue body, so the deterministic check
306
+ * enforces it (fail-closed) with the distinct finding
307
+ * `MISSING_EXPLICIT_NON_GOALS_FINDING`. The non-goals matcher is shared with
308
+ * `validatePrBodySpec` (`PR_BODY_SPEC_NARRATIVE_SECTIONS.non_goals.patterns`),
309
+ * so the two spec surfaces cannot drift on what counts as an explicit
310
+ * Non-goals section. `hasACs` keeps its caller-facing meaning: true only when
311
+ * the FULL check passes, so every `.hasACs` consumer (enqueue gate, draft
312
+ * gate, parked-items discovery, gate context) fails closed with no call-site
313
+ * change.
314
+ *
315
+ * `resolveLinkedDoc` (optional, #1866): a `(path) => boolean` callback used to
316
+ * verify that a linked `tmp/refinement/*.md` doc actually resolves (e.g.
317
+ * `existsSync`). Enforcement-point callers (enqueue gate, draft-gate
318
+ * linked-issue path) supply it; a linked doc found in the body then satisfies
319
+ * the artifact check only when the callback returns true. When the callback is
320
+ * not supplied the predicate stays pure/no-I/O and behavior is unchanged, and
321
+ * the `linkedDoc` result carries no `resolves` field. When supplied and the
322
+ * doc does not resolve, `linkedDoc.resolves === false` and the linked doc does
323
+ * not satisfy the artifact check (other artifact sources still count).
324
+ *
325
+ * Result-shape note: on a `missing_explicit_non_goals` result, `source` keeps
326
+ * the detected artifact origin (e.g. `issue-body-ac`) so callers/reporting can
327
+ * still see what artifact exists; `hasACs` is false because the full
328
+ * refinement check did not pass.
329
+ *
274
330
  * @param {object} input
275
331
  * @param {string} [input.body] Raw issue body Markdown.
276
332
  * @param {number} [input.issueNumber] Issue number, used for linked-doc convention.
333
+ * @param {Function} [input.resolveLinkedDoc] Optional `(path) => boolean` doc-resolution check.
277
334
  * @returns {{
278
335
  * hasACs: boolean,
336
+ * hasNonGoals: boolean,
279
337
  * source: string,
280
338
  * acItems: string[],
281
339
  * uncheckedAcItems: string[],
282
340
  * dodItems: string[],
283
341
  * sections: string[],
284
- * linkedDoc: { found: boolean, path: string|null, reason: string },
342
+ * linkedDoc: { found: boolean, path: string|null, reason: string, resolves?: boolean },
285
343
  * reason: string,
286
344
  * finding: string|null,
287
345
  * }}
288
346
  */
289
- export function detectIssueRefinementArtifact({ body = "", issueNumber = null } = {}) {
347
+ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, resolveLinkedDoc = null } = {}) {
290
348
  if (typeof body !== "string" || body.length === 0) {
291
349
  return {
292
350
  hasACs: false,
351
+ hasNonGoals: false,
293
352
  source: REFINEMENT_SOURCE.MISSING,
294
353
  acItems: [],
295
354
  uncheckedAcItems: [],
@@ -315,58 +374,88 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null }
315
374
  const uncheckedAcItems = acceptanceSection ? extractUncheckedChecklistItems(acceptanceSection.bodyLines.join("\n")) : [];
316
375
  const dodItems = dodSection ? extractChecklistItems(dodSection.bodyLines.join("\n")) : [];
317
376
 
318
- const linkedDoc = detectLinkedRefinementDoc(body);
319
-
320
- if (acItems.length > 0) {
321
- return {
322
- hasACs: true,
323
- source: REFINEMENT_SOURCE.ISSUE_BODY_AC,
324
- acItems,
325
- uncheckedAcItems,
326
- dodItems,
327
- sections: sectionNames,
328
- linkedDoc,
329
- reason: `Found ${acItems.length} Acceptance criteria checklist item(s) in the issue body.`,
330
- finding: null,
331
- };
377
+ let linkedDoc = detectLinkedRefinementDoc(body);
378
+ let linkedDocResolves = linkedDoc.found;
379
+ if (linkedDoc.found && typeof resolveLinkedDoc === "function") {
380
+ linkedDocResolves = resolveLinkedDoc(linkedDoc.path) === true;
381
+ linkedDoc = { ...linkedDoc, resolves: linkedDocResolves };
332
382
  }
333
383
 
334
- if (dodItems.length > 0) {
335
- return {
336
- hasACs: true,
337
- source: REFINEMENT_SOURCE.ISSUE_BODY_DOD,
338
- acItems,
339
- uncheckedAcItems,
340
- dodItems,
341
- sections: sectionNames,
342
- linkedDoc,
343
- reason: `Found ${dodItems.length} DoD checklist item(s) in the issue body.`,
344
- finding: null,
345
- };
346
- }
384
+ // #1866: explicit Non-goals section required on a refined tracker-backed
385
+ // issue body — same matcher the PR-body spec path uses, so the two cannot
386
+ // drift. A heading-only or fenced-only section does not count
387
+ // (sectionHasBody anti-spoof).
388
+ const hasNonGoals = sectionHasBody(
389
+ findSectionByPatterns(sections, PR_BODY_SPEC_NARRATIVE_SECTIONS.non_goals.patterns),
390
+ );
391
+
392
+ const artifactSource = acItems.length > 0
393
+ ? REFINEMENT_SOURCE.ISSUE_BODY_AC
394
+ : dodItems.length > 0
395
+ ? REFINEMENT_SOURCE.ISSUE_BODY_DOD
396
+ : linkedDocResolves
397
+ ? REFINEMENT_SOURCE.LINKED_DOC
398
+ : null;
347
399
 
348
- if (linkedDoc.found) {
400
+ const base = {
401
+ hasNonGoals,
402
+ acItems,
403
+ uncheckedAcItems,
404
+ dodItems,
405
+ sections: sectionNames,
406
+ linkedDoc,
407
+ };
408
+
409
+ if (artifactSource !== null) {
410
+ if (!hasNonGoals) {
411
+ return {
412
+ ...base,
413
+ hasACs: false,
414
+ source: artifactSource,
415
+ reason:
416
+ `Issue body carries a refinement artifact (${artifactSource}) but no explicit Non-goals section; ` +
417
+ "the tracker-backed refinement contract requires one (rule ARTIFACT-TRACKER-ISSUE-REFINEMENT-FLOOR; " +
418
+ "e.g. run the loop-grill synthesis). Refusing: the refinement check fails closed without an explicit Non-goals section.",
419
+ finding: MISSING_EXPLICIT_NON_GOALS_FINDING,
420
+ };
421
+ }
422
+ if (artifactSource === REFINEMENT_SOURCE.ISSUE_BODY_AC) {
423
+ return {
424
+ ...base,
425
+ hasACs: true,
426
+ source: REFINEMENT_SOURCE.ISSUE_BODY_AC,
427
+ reason: `Found ${acItems.length} Acceptance criteria checklist item(s) in the issue body.`,
428
+ finding: null,
429
+ };
430
+ }
431
+ if (artifactSource === REFINEMENT_SOURCE.ISSUE_BODY_DOD) {
432
+ return {
433
+ ...base,
434
+ hasACs: true,
435
+ source: REFINEMENT_SOURCE.ISSUE_BODY_DOD,
436
+ reason: `Found ${dodItems.length} DoD checklist item(s) in the issue body.`,
437
+ finding: null,
438
+ };
439
+ }
349
440
  return {
441
+ ...base,
350
442
  hasACs: true,
351
443
  source: REFINEMENT_SOURCE.LINKED_DOC,
352
444
  acItems: [],
353
445
  uncheckedAcItems: [],
354
446
  dodItems: [],
355
- sections: sectionNames,
356
- linkedDoc,
357
447
  reason: `Issue body links a refinement doc at ${linkedDoc.path}; treating that as the refinement artifact source.`,
358
448
  finding: null,
359
449
  };
360
450
  }
361
451
 
362
452
  return {
453
+ ...base,
363
454
  hasACs: false,
364
455
  source: REFINEMENT_SOURCE.MISSING,
365
456
  acItems: [],
366
457
  uncheckedAcItems: [],
367
458
  dodItems: [],
368
- sections: sectionNames,
369
- linkedDoc,
370
459
  reason: "Issue body has no Acceptance criteria section, no DoD section, and no linked refinement doc.",
371
460
  finding: REFINEMENT_ARTIFACT_FINDING,
372
461
  };
@@ -484,7 +573,16 @@ function sectionHasBody(section) {
484
573
  * pick exactly one mode (tracker-backed, with or without a specific
485
574
  * expected issue) or issue-less — never both.
486
575
  *
487
- * @param {{ body?: string, expectedIssue?: number, issueLess?: boolean }} input
576
+ * `requireOpenQuestions` (default `true`, issue #1863): the lightweight
577
+ * PR-body-as-spec contract (this function's original scope) requires an Open
578
+ * questions/risks section; the ordinary tracker-backed PR-description
579
+ * contract (skills/docs/copilot-loop-operations.md "PR description
580
+ * contract") does not name one. Pass `false` (see
581
+ * `validateTrackerBackedPrBodySpec` below) to skip the `missing_open_questions`
582
+ * check without touching any other invariant — the lightweight caller's
583
+ * default stays byte-identical.
584
+ *
585
+ * @param {{ body?: string, expectedIssue?: number, issueLess?: boolean, requireOpenQuestions?: boolean }} input
488
586
  * @returns {{ checker: "validate-pr-body-spec", ok: boolean, errors: { code: string, message: string }[], sections: string[], acItems: string[], dodItems: string[], closesIssues: number[] }}
489
587
  */
490
588
 
@@ -540,7 +638,7 @@ export function detectGrillEmbedHeading(body = "") {
540
638
  return null;
541
639
  }
542
640
 
543
- export function validatePrBodySpec({ body = "", expectedIssue = null, issueLess = false } = {}) {
641
+ export function validatePrBodySpec({ body = "", expectedIssue = null, issueLess = false, requireOpenQuestions = true } = {}) {
544
642
  if (issueLess && Number.isInteger(expectedIssue)) {
545
643
  // Fail closed at the library boundary too (not just the CLI): the two modes
546
644
  // are contradictory and silently preferring one would hide caller bugs.
@@ -550,7 +648,8 @@ export function validatePrBodySpec({ body = "", expectedIssue = null, issueLess
550
648
  const sections = parseMarkdownSections(bodyText);
551
649
  const errors = [];
552
650
 
553
- for (const { code, label, patterns } of Object.values(PR_BODY_SPEC_NARRATIVE_SECTIONS)) {
651
+ for (const [key, { code, label, patterns }] of Object.entries(PR_BODY_SPEC_NARRATIVE_SECTIONS)) {
652
+ if (key === "open_questions" && !requireOpenQuestions) continue;
554
653
  const section = findSectionByPatterns(sections, patterns);
555
654
  if (!sectionHasBody(section)) {
556
655
  errors.push({ code, message: `Missing or empty ${label} section.` });
@@ -606,6 +705,31 @@ export function validatePrBodySpec({ body = "", expectedIssue = null, issueLess
606
705
  };
607
706
  }
608
707
 
708
+ /**
709
+ * Validate a TRACKER-BACKED PR's own body against the PR-description contract
710
+ * (skills/docs/copilot-loop-operations.md "PR description contract", issue
711
+ * #1863): Acceptance criteria + Definition of done checklists, an explicit
712
+ * Non-goals section, and a `Closes #N`/`Fixes #N` reference — regardless of
713
+ * whether the linked issue itself already carries a refinement artifact. A
714
+ * linked issue with real ACs is necessary but not sufficient: the PR body is
715
+ * the portable spec-of-record a tracker-agnostic consumer reads.
716
+ *
717
+ * Thin wrapper over `validatePrBodySpec`, not a second divergent checker:
718
+ * `requireOpenQuestions: false` because the tracker-backed contract, unlike
719
+ * the lightweight PR-body-as-spec path, does not require an Open
720
+ * questions/risks section. `expectedIssue` is only checked when the PR closes
721
+ * exactly ONE issue — an umbrella PR closing several is not required to name
722
+ * any single one of them in the `expectedIssue` slot (each linked issue's
723
+ * refinement is verified separately by the caller).
724
+ *
725
+ * @param {{ body?: string, closingIssues?: number[] }} input
726
+ * @returns {ReturnType<typeof validatePrBodySpec>}
727
+ */
728
+ export function validateTrackerBackedPrBodySpec({ body = "", closingIssues = [] } = {}) {
729
+ const expectedIssue = Array.isArray(closingIssues) && closingIssues.length === 1 ? closingIssues[0] : null;
730
+ return validatePrBodySpec({ body, expectedIssue, requireOpenQuestions: false });
731
+ }
732
+
609
733
  /**
610
734
  * Decide what an enqueue caller should do with a refinement-artifact result,
611
735
  * so an un-refined item never lands in the Next Up pickup column in the first
@@ -623,12 +747,22 @@ export function validatePrBodySpec({ body = "", expectedIssue = null, issueLess
623
747
  * @returns {{ action: "enqueue" } | { action: "block"|"divert", reason: string, missing: string[] }}
624
748
  */
625
749
  export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = false }) {
626
- // `artifact.finding === null` is the explicit "has ANY refinement artifact"
627
- // signal (AC checklist OR DoD checklist OR linked doc) clearer than reading
628
- // `hasACs`, whose name understates that a DoD or linked doc also satisfies it.
750
+ // `artifact.finding === null` is the explicit "passes the full refinement
751
+ // check" signal (artifact AND since #1866an explicit Non-goals
752
+ // section), clearer than reading `hasACs`, whose name understates what it
753
+ // covers.
629
754
  if (!targetIsPickup || artifact.finding === null) {
630
755
  return { action: "enqueue" };
631
756
  }
757
+ // #1866: artifact present but the contract-mandated Non-goals section is
758
+ // absent/empty — a distinct failure with its own guidance.
759
+ if (artifact.finding === MISSING_EXPLICIT_NON_GOALS_FINDING) {
760
+ const reason =
761
+ "Issue carries a refinement artifact but no explicit Non-goals section. " +
762
+ "Add an explicit `## Non-goals` section to the issue body " +
763
+ "(rule ARTIFACT-TRACKER-ISSUE-REFINEMENT-FLOOR; e.g. run `/dev-loops:loop-grill <issue> --auto` (or `/loop-grill <issue> --auto` in the dev-loops repo itself)) — refusing to enqueue without an explicit Non-goals section.";
764
+ return { action: auto ? "divert" : "block", reason, missing: ["explicit Non-goals section"] };
765
+ }
632
766
  const missing = [...REFINEMENT_ARTIFACT_SOURCES];
633
767
  const reason =
634
768
  `Issue has no refinement artifact (none of: ${missing.join(", ")}). ` +
@@ -649,7 +783,7 @@ export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = f
649
783
  * @param {{ issueNumber: number, repo: string, env: object, runChild: Function, auto?: boolean }} input
650
784
  * @returns {Promise<{ action: "enqueue" } | { action: "divert"|"block", reason: string, missing: string[] }>}
651
785
  */
652
- export async function runPickupRefinementGate({ issueNumber, repo, env, runChild, auto = false }) {
786
+ export async function runPickupRefinementGate({ issueNumber, repo, env, runChild, auto = false, repoRoot = null }) {
653
787
  const bodyResult = await runChild(
654
788
  "gh",
655
789
  ["issue", "view", String(issueNumber), "--repo", repo, "--json", "body"],
@@ -666,7 +800,17 @@ export async function runPickupRefinementGate({ issueNumber, repo, env, runChild
666
800
  throw new Error("Invalid JSON input");
667
801
  }
668
802
  const body = typeof bodyPayload?.body === "string" ? bodyPayload.body : "";
669
- const artifact = detectIssueRefinementArtifact({ body, issueNumber });
803
+ // #1866: a linked refinement doc satisfies the gate only when it actually
804
+ // resolves. Paths follow the `tmp/refinement/*.md` convention and are
805
+ // anchored to the caller's repo root (`repoRoot` option, falling back to
806
+ // process.cwd()) — never the ambient cwd of whichever subdirectory the
807
+ // gate happened to run from.
808
+ const docAnchor = repoRoot ?? process.cwd();
809
+ const artifact = detectIssueRefinementArtifact({
810
+ body,
811
+ issueNumber,
812
+ resolveLinkedDoc: (p) => existsSync(path.isAbsolute(p) ? p : path.resolve(docAnchor, p)),
813
+ });
670
814
  const decision = decideEnqueueRefinementGate({ artifact, targetIsPickup: true, auto });
671
815
  if (decision.action === "block") {
672
816
  throw Object.assign(new Error(decision.reason), {
@@ -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
  }