@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.
- package/package.json +8 -1
- package/src/analysis/change-classifier.mjs +10 -0
- package/src/analysis/diff-analyzer.mjs +68 -1
- package/src/claude/hook-decisions.mjs +36 -4
- package/src/cli/primitives.mjs +30 -1
- package/src/config/config.mjs +254 -13
- package/src/config/extension-defaults.yaml +34 -1
- package/src/github/comment-id-guard.mjs +97 -9
- package/src/github/copilot-helpers.mjs +114 -5
- package/src/github/gh.mjs +94 -0
- package/src/github/issue-ops.mjs +7 -0
- package/src/loop/agent-stall.mjs +4 -2
- package/src/loop/commit-msg-guard.mjs +168 -0
- package/src/loop/copilot-loop-iterations.mjs +2 -1
- package/src/loop/default-branch-guard.mjs +34 -1
- package/src/loop/gate-carry-forward.mjs +19 -6
- package/src/loop/gate-fanin.mjs +190 -29
- package/src/loop/handoff-envelope.mjs +12 -19
- package/src/loop/issue-refinement-artifact.mjs +186 -42
- package/src/loop/lifecycle-state.mjs +21 -2
- package/src/loop/main-checkout-ff.mjs +34 -0
- package/src/loop/markdown-sections.mjs +40 -0
- package/src/loop/normalize.mjs +7 -0
- package/src/loop/plan-file-promote-contract.mjs +14 -1
- package/src/loop/plan-file-refine-contract.mjs +92 -8
- package/src/loop/policy-constants.mjs +9 -0
- package/src/loop/pr-gate-coordination.mjs +65 -12
- package/src/loop/public-dev-loop-routing.mjs +11 -15
- package/src/loop/queue-board-sync.mjs +1 -26
- package/src/loop/queue-driver.mjs +14 -1
- package/src/loop/refinement-grill-state.mjs +3 -5
- package/src/loop/retrospective-checkpoint.mjs +59 -1
- package/src/loop/review-dispatch-plan.mjs +448 -9
- package/src/loop/reviewer-loop-state.mjs +8 -13
- package/src/loop/run-post-merge-actions.mjs +148 -0
- package/src/loop/size-budget-merge-gate.mjs +121 -0
- package/src/loop/tracker-pr-state.mjs +5 -15
- package/src/loop/ui-designer-review-scoping.mjs +171 -0
- package/src/loop/ui-review-drive.mjs +3 -1
- package/src/loop/ui-review-report.mjs +2 -5
- package/src/loop/ui-review-teardown.mjs +3 -1
- package/src/projects/list-queue-items.mjs +1 -27
- package/src/projects/move-queue-item.mjs +2 -28
- 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
|
-
|
|
319
|
-
|
|
320
|
-
if (
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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.
|
|
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 "
|
|
627
|
-
// signal (
|
|
628
|
-
// `hasACs`, whose name understates
|
|
750
|
+
// `artifact.finding === null` is the explicit "passes the full refinement
|
|
751
|
+
// check" signal (artifact AND — since #1866 — an 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
|
-
|
|
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
|
|
191
|
+
* 3. Merge authorized + pre-approval passed + linked PR + size-budget gate
|
|
192
|
+
* clear (not sizeBudgetHumanApprovalRequired) → merge
|
|
184
193
|
* 4. Pre-approval passed + PR exists → pre_approval_gate
|
|
185
194
|
* 5. Unresolved threads + PR exists → feedback_resolution
|
|
186
195
|
* 6. Draft PR → implementation
|
|
@@ -197,6 +206,7 @@ export function resolveLifecycleState(input = {}) {
|
|
|
197
206
|
mergeAuthorized = false,
|
|
198
207
|
humanMergeOnly = false,
|
|
199
208
|
isMerged = false,
|
|
209
|
+
sizeBudgetHumanApprovalRequired = false,
|
|
200
210
|
} = input;
|
|
201
211
|
|
|
202
212
|
// Fail closed: when the repo enforces human-only merge, the agent is never
|
|
@@ -205,7 +215,16 @@ export function resolveLifecycleState(input = {}) {
|
|
|
205
215
|
// exact `true` clears merge), matching the authoritative
|
|
206
216
|
// `resolveEffectiveMergeAuthorized` gate. An already-merged PR (isMerged) is
|
|
207
217
|
// still terminal below.
|
|
208
|
-
|
|
218
|
+
//
|
|
219
|
+
// The size-budget merge gate (resolveSizeBudgetHumanApprovalRequired) is
|
|
220
|
+
// consulted IN ADDITION TO the two invariants above, never in their place:
|
|
221
|
+
// an escalated/T1 PR without a human APPROVED review (and zero unresolved
|
|
222
|
+
// CHANGES_REQUESTED) parks at PRE_APPROVAL_GATE — the existing "await human
|
|
223
|
+
// approval" phase — instead of advancing to MERGE, even under a standing
|
|
224
|
+
// merge authorization.
|
|
225
|
+
const effectiveMergeAuthorized = humanMergeOnly !== true
|
|
226
|
+
&& mergeAuthorized === true
|
|
227
|
+
&& sizeBudgetHumanApprovalRequired !== true;
|
|
209
228
|
|
|
210
229
|
// 1. Explicit phase override — canonical or fail closed
|
|
211
230
|
if (phase !== null && phase !== undefined) {
|
|
@@ -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
|
}
|