@dev-loops/core 0.6.0 → 0.7.1
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 +3 -7
- package/src/analysis/change-classifier.mjs +50 -6
- package/src/analysis/diff-analyzer.mjs +68 -12
- package/src/claude/hook-decisions.mjs +138 -15
- package/src/config/config.mjs +167 -97
- package/src/config/extension-defaults.yaml +0 -11
- package/src/harness/extension-adapter.mjs +1 -0
- package/src/harness/index.mjs +0 -1
- package/src/loop/bash-command-classify.mjs +333 -29
- package/src/loop/conductor-routing.mjs +0 -27
- package/src/loop/copilot-loop-state.mjs +25 -2
- package/src/loop/gate-fanin.mjs +92 -0
- package/src/loop/handoff-envelope.mjs +142 -70
- package/src/loop/issue-refinement-artifact.mjs +236 -8
- package/src/loop/lifecycle-state.mjs +1 -1
- package/src/loop/pr-gate-coordination.mjs +49 -238
- package/src/loop/public-dev-loop-routing.mjs +2 -2
- package/src/loop/queue-board-ordering.mjs +51 -7
- package/src/loop/queue-board-sync.mjs +61 -2
- package/src/loop/queue-driver.mjs +80 -8
- package/src/loop/queue-state.mjs +13 -2
- package/bin/capture-deep-persona-signals.mjs +0 -143
- package/src/debt/deep-persona-signals.mjs +0 -266
- package/src/harness/claude-extension-adapter.mjs +0 -102
- package/src/refinement/ac-dod-matrix.mjs +0 -95
|
@@ -269,6 +269,55 @@ function deriveRequiredReads(bundle, resolverOutput) {
|
|
|
269
269
|
return Array.isArray(reads) ? [...reads] : [];
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// specSource derivation (issue #1025 — lightweight PR-body-as-spec)
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The LOCAL-FIRST spec-source subset the envelope distinguishes: phase_doc vs
|
|
278
|
+
* pr_body. This is NOT the full `canonicalSpecSource` value space — the same
|
|
279
|
+
* field name also carries "tracker_issue" in the tracker-backed mode
|
|
280
|
+
* (scripts/github/resolve-tracker-local-spec.mjs), which the envelope does not
|
|
281
|
+
* model (deriveSpecSource coerces it to null).
|
|
282
|
+
*/
|
|
283
|
+
export const CANONICAL_SPEC_SOURCE = Object.freeze({
|
|
284
|
+
PHASE_DOC: "phase_doc",
|
|
285
|
+
PR_BODY: "pr_body",
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Derive the canonical spec source. Prefer the resolver-output-level field,
|
|
290
|
+
* fall back to bundle-level (mirrors deriveRequiredReads). Returns null when
|
|
291
|
+
* absent so the default (phase-doc) path carries no specSource field and stays
|
|
292
|
+
* byte-identical. Any value outside the local-first subset {phase_doc, pr_body}
|
|
293
|
+
* — e.g. the tracker-backed "tracker_issue" carried by the same field name — is
|
|
294
|
+
* coerced to null so the envelope can never set a specSource that
|
|
295
|
+
* validateHandoffEnvelope would then reject.
|
|
296
|
+
*/
|
|
297
|
+
function deriveSpecSource(bundle, resolverOutput) {
|
|
298
|
+
const raw = normalizeStringOrNull(resolverOutput?.canonicalSpecSource)
|
|
299
|
+
?? normalizeStringOrNull(bundle?.canonicalSpecSource);
|
|
300
|
+
return raw === CANONICAL_SPEC_SOURCE.PHASE_DOC || raw === CANONICAL_SPEC_SOURCE.PR_BODY ? raw : null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Apply the spec-source variant to acceptance criteria. Under the lightweight
|
|
305
|
+
* PR-body-as-spec path the phase-doc criterion text is retargeted to the PR
|
|
306
|
+
* description; the default (null/phase_doc) path returns the criteria verbatim
|
|
307
|
+
* so the phase-doc template text stays identical.
|
|
308
|
+
*/
|
|
309
|
+
function applySpecSourceVariant(criteria, specSource) {
|
|
310
|
+
// ponytail: free-text substring retarget is a no-op for any strategy whose
|
|
311
|
+
// criteria lack the phase-doc phrase — fine while lightweight only composes
|
|
312
|
+
// with local_implementation; make it a structured criterion-id lookup if
|
|
313
|
+
// lightweight is ever extended to another strategy.
|
|
314
|
+
if (specSource !== CANONICAL_SPEC_SOURCE.PR_BODY) return [...criteria];
|
|
315
|
+
return criteria.map((c) => ({
|
|
316
|
+
...c,
|
|
317
|
+
must: c.must.replace("from the active phase doc", "from the PR description"),
|
|
318
|
+
}));
|
|
319
|
+
}
|
|
320
|
+
|
|
272
321
|
// ---------------------------------------------------------------------------
|
|
273
322
|
// Gate config derivation
|
|
274
323
|
// ---------------------------------------------------------------------------
|
|
@@ -412,6 +461,37 @@ function normalizeGateState(gateState) {
|
|
|
412
461
|
};
|
|
413
462
|
}
|
|
414
463
|
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Normalize the structured retrospective findings (issue #1077, Reading B).
|
|
467
|
+
*
|
|
468
|
+
* The retrospective is advisory: it never blocks merge or any lifecycle
|
|
469
|
+
* transition. Its findings travel in the handoff envelope (the conductor's
|
|
470
|
+
* decision input) and in an advisory PR comment — never on disk as a gate.
|
|
471
|
+
*
|
|
472
|
+
* The source is the `check-retro-tooling.mjs` JSON output shape:
|
|
473
|
+
* { ok, internalToolingOnly, rawCallViolations, allowedWriteOps }
|
|
474
|
+
*
|
|
475
|
+
* Returns a normalized object carrying the substantive fields, or null when no
|
|
476
|
+
* findings were supplied (the field is optional — present only when the loop
|
|
477
|
+
* subagent ran the retrospective tooling).
|
|
478
|
+
*/
|
|
479
|
+
function normalizeRetrospectiveFindings(findings) {
|
|
480
|
+
if (findings === null || findings === undefined) return null;
|
|
481
|
+
if (typeof findings !== "object" || Array.isArray(findings)) return null;
|
|
482
|
+
|
|
483
|
+
const toStrArray = (v) => Array.isArray(v)
|
|
484
|
+
? v.map((x) => (typeof x === "string" ? x : String(x)).trim()).filter((x) => x.length > 0)
|
|
485
|
+
: [];
|
|
486
|
+
|
|
487
|
+
const internalToolingOnly = findings.internalToolingOnly === true;
|
|
488
|
+
return {
|
|
489
|
+
internalToolingOnly,
|
|
490
|
+
rawCallViolations: toStrArray(findings.rawCallViolations),
|
|
491
|
+
allowedWriteOps: toStrArray(findings.allowedWriteOps),
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
415
495
|
// ---------------------------------------------------------------------------
|
|
416
496
|
// Sub-gate resolution
|
|
417
497
|
// ---------------------------------------------------------------------------
|
|
@@ -461,6 +541,11 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
|
|
|
461
541
|
|
|
462
542
|
const gs = normalizeGateState(gateState);
|
|
463
543
|
const subGate = resolveSubGate(strategy, gs);
|
|
544
|
+
// Normalize each source independently, then fall back on the normalized result
|
|
545
|
+
// (not the raw value): a present-but-invalid gateState value must NOT shadow a
|
|
546
|
+
// valid options.retrospectiveFindings fallback (issue #1077 review finding).
|
|
547
|
+
const retrospectiveFindings = normalizeRetrospectiveFindings(gateState?.retrospectiveFindings)
|
|
548
|
+
?? normalizeRetrospectiveFindings(options.retrospectiveFindings);
|
|
464
549
|
|
|
465
550
|
const target = deriveTarget(bundle, repo);
|
|
466
551
|
const requiredReads = deriveRequiredReads(bundle, resolverOutput);
|
|
@@ -468,11 +553,25 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
|
|
|
468
553
|
const gateConfig = deriveGateConfig(settings, subGate);
|
|
469
554
|
const derivedCwd = deriveCwd(bundle, { repoRoot: options.repoRoot, worktreeCwd: options.worktreeCwd });
|
|
470
555
|
const template = lookupAcceptanceTemplate(strategy, subGate);
|
|
556
|
+
// Lightweight PR-body-as-spec (issue #1025): retarget the phase-doc criterion
|
|
557
|
+
// text to the PR description. Null/phase_doc leaves the criteria untouched, so
|
|
558
|
+
// the non-lightweight path stays byte-identical.
|
|
559
|
+
const specSource = deriveSpecSource(bundle, resolverOutput);
|
|
560
|
+
const acceptanceCriteria = applySpecSourceVariant(template.criteria, specSource);
|
|
471
561
|
|
|
472
562
|
const overrides = options.overrides && typeof options.overrides === "object" && Object.keys(options.overrides).length > 0
|
|
473
563
|
? { ...options.overrides }
|
|
474
564
|
: undefined;
|
|
475
565
|
|
|
566
|
+
// Sanctioned operation → wrapper command map (issue #1081). Core is
|
|
567
|
+
// consumer-agnostic: it carries whatever map the consumer supplies (the
|
|
568
|
+
// `loop build-envelope` CLI injects this repo's scripts/... paths) so every
|
|
569
|
+
// spawned subagent receives it by DEFAULT. Core defines the SHAPE only —
|
|
570
|
+
// it never hardcodes repo-specific paths. A non-object is ignored.
|
|
571
|
+
const sanctionedCommands = options.sanctionedCommands && typeof options.sanctionedCommands === "object" && !Array.isArray(options.sanctionedCommands)
|
|
572
|
+
? options.sanctionedCommands
|
|
573
|
+
: undefined;
|
|
574
|
+
|
|
476
575
|
// Surface the *effective* async-start posture alongside the *configured* one (#834). The
|
|
477
576
|
// configured `asyncStartMode` is echoed verbatim from settings (back-compat), but the contract
|
|
478
577
|
// is relaxed at validation time under the Claude harness (resolveEffectiveAsyncStartMode →
|
|
@@ -509,7 +608,7 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
|
|
|
509
608
|
worktreeRequired: true,
|
|
510
609
|
|
|
511
610
|
acceptance: {
|
|
512
|
-
criteria:
|
|
611
|
+
criteria: acceptanceCriteria,
|
|
513
612
|
evidence: [...template.evidence],
|
|
514
613
|
maxFinalizationTurns: template.maxFinalizationTurns,
|
|
515
614
|
},
|
|
@@ -528,11 +627,22 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
|
|
|
528
627
|
envelope.overrides = overrides;
|
|
529
628
|
}
|
|
530
629
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
630
|
+
if (sanctionedCommands) {
|
|
631
|
+
envelope.sanctionedCommands = sanctionedCommands;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Advisory retrospective findings (issue #1077, Reading B). Optional structured
|
|
635
|
+
// field carrying the check-retro-tooling.mjs JSON output to the conductor. Never a
|
|
636
|
+
// gate — the conductor surfaces these as an advisory PR comment, not a block.
|
|
637
|
+
if (retrospectiveFindings) {
|
|
638
|
+
envelope.retrospectiveFindings = retrospectiveFindings;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Canonical spec source (issue #1025). Optional: only set when the resolver
|
|
642
|
+
// marks a lightweight PR-body-as-spec session, so the default (phase-doc) path
|
|
643
|
+
// carries no specSource field and its envelope stays byte-identical.
|
|
644
|
+
if (specSource) {
|
|
645
|
+
envelope.specSource = specSource;
|
|
536
646
|
}
|
|
537
647
|
|
|
538
648
|
return deepFreeze(envelope);
|
|
@@ -767,79 +877,40 @@ export function validateHandoffEnvelope(envelope) {
|
|
|
767
877
|
});
|
|
768
878
|
}
|
|
769
879
|
|
|
770
|
-
// -----
|
|
771
|
-
if (envelope.
|
|
772
|
-
|
|
880
|
+
// ----- retrospectiveFindings (optional, advisory — issue #1077) -----
|
|
881
|
+
if (envelope.retrospectiveFindings !== undefined && envelope.retrospectiveFindings !== null) {
|
|
882
|
+
const rf = envelope.retrospectiveFindings;
|
|
883
|
+
if (typeof rf !== "object" || Array.isArray(rf)) {
|
|
773
884
|
errors.push({
|
|
774
|
-
field: "
|
|
775
|
-
reason: "if present, must be a non-array object
|
|
776
|
-
got:
|
|
885
|
+
field: "retrospectiveFindings",
|
|
886
|
+
reason: "if present, must be a non-array object { internalToolingOnly, rawCallViolations, allowedWriteOps }",
|
|
887
|
+
got: rf,
|
|
777
888
|
});
|
|
778
889
|
} else {
|
|
779
|
-
if (
|
|
780
|
-
|
|
781
|
-
field: "refinementContract.schema",
|
|
782
|
-
reason: "expected 'ac-dod-matrix/v1'",
|
|
783
|
-
got: envelope.refinementContract.schema,
|
|
784
|
-
});
|
|
785
|
-
}
|
|
786
|
-
if (!Array.isArray(envelope.refinementContract.items) || envelope.refinementContract.items.length === 0) {
|
|
787
|
-
errors.push({
|
|
788
|
-
field: "refinementContract.items",
|
|
789
|
-
reason: "must be a non-empty array of AC/DoD matrix items",
|
|
790
|
-
got: envelope.refinementContract.items,
|
|
791
|
-
});
|
|
792
|
-
} else {
|
|
793
|
-
const bad = [];
|
|
794
|
-
for (let i = 0; i < envelope.refinementContract.items.length; i++) {
|
|
795
|
-
const item = envelope.refinementContract.items[i];
|
|
796
|
-
if (
|
|
797
|
-
!item || typeof item !== "object" ||
|
|
798
|
-
typeof item.item !== "string" || !item.item.trim() ||
|
|
799
|
-
!["AC", "DoD", "Non-goal"].includes(item.type) ||
|
|
800
|
-
!["Met", "Partial", "Unmet", "Unverified"].includes(item.status) ||
|
|
801
|
-
typeof item.evidence !== "string" ||
|
|
802
|
-
typeof item.notes !== "string"
|
|
803
|
-
) {
|
|
804
|
-
bad.push(i);
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
if (bad.length > 0) {
|
|
808
|
-
errors.push({
|
|
809
|
-
field: "refinementContract.items",
|
|
810
|
-
reason: `entries at indices [${bad.join(",")}] must have valid item, type, status, evidence, and notes fields`,
|
|
811
|
-
got: envelope.refinementContract.items,
|
|
812
|
-
});
|
|
813
|
-
}
|
|
890
|
+
if (typeof rf.internalToolingOnly !== "boolean") {
|
|
891
|
+
errors.push({ field: "retrospectiveFindings.internalToolingOnly", reason: "must be a boolean", got: rf.internalToolingOnly });
|
|
814
892
|
}
|
|
815
|
-
if (
|
|
816
|
-
errors.push({
|
|
817
|
-
field: "refinementContract.generatedAt",
|
|
818
|
-
reason: "must be a valid ISO 8601 timestamp",
|
|
819
|
-
got: envelope.refinementContract.generatedAt,
|
|
820
|
-
});
|
|
893
|
+
if (!Array.isArray(rf.rawCallViolations) || rf.rawCallViolations.some((v) => typeof v !== "string")) {
|
|
894
|
+
errors.push({ field: "retrospectiveFindings.rawCallViolations", reason: "must be an array of strings", got: rf.rawCallViolations });
|
|
821
895
|
}
|
|
822
|
-
if (
|
|
823
|
-
errors.push({
|
|
824
|
-
field: "refinementContract.isComplete",
|
|
825
|
-
reason: "must be a boolean",
|
|
826
|
-
got: envelope.refinementContract.isComplete,
|
|
827
|
-
});
|
|
828
|
-
} else if (envelope.refinementContract.items && Array.isArray(envelope.refinementContract.items)) {
|
|
829
|
-
const allMet = envelope.refinementContract.items.every(
|
|
830
|
-
(item) => item && typeof item === "object" && item.status === "Met"
|
|
831
|
-
);
|
|
832
|
-
if (envelope.refinementContract.isComplete !== allMet) {
|
|
833
|
-
errors.push({
|
|
834
|
-
field: "refinementContract.isComplete",
|
|
835
|
-
reason: "must match items status (true iff every item has status 'Met')",
|
|
836
|
-
got: { isComplete: envelope.refinementContract.isComplete, allItemsMet: allMet },
|
|
837
|
-
});
|
|
838
|
-
}
|
|
896
|
+
if (!Array.isArray(rf.allowedWriteOps) || rf.allowedWriteOps.some((v) => typeof v !== "string")) {
|
|
897
|
+
errors.push({ field: "retrospectiveFindings.allowedWriteOps", reason: "must be an array of strings", got: rf.allowedWriteOps });
|
|
839
898
|
}
|
|
840
899
|
}
|
|
841
900
|
}
|
|
842
901
|
|
|
902
|
+
// ----- specSource (optional — issue #1025, lightweight PR-body-as-spec) -----
|
|
903
|
+
if (envelope.specSource !== undefined && envelope.specSource !== null) {
|
|
904
|
+
const validSources = [CANONICAL_SPEC_SOURCE.PHASE_DOC, CANONICAL_SPEC_SOURCE.PR_BODY];
|
|
905
|
+
if (typeof envelope.specSource !== "string" || !validSources.includes(envelope.specSource)) {
|
|
906
|
+
errors.push({
|
|
907
|
+
field: "specSource",
|
|
908
|
+
reason: `if present, must be one of ${validSources.join(", ")}`,
|
|
909
|
+
got: envelope.specSource,
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
843
914
|
// ----- derivedAt (informational, warn on missing) -----
|
|
844
915
|
if (typeof envelope.derivedAt !== "string" || !envelope.derivedAt.trim()) {
|
|
845
916
|
warnings.push({ field: "derivedAt", reason: "should be an ISO 8601 timestamp" });
|
|
@@ -863,6 +934,7 @@ export {
|
|
|
863
934
|
deriveCwd,
|
|
864
935
|
deriveRequiredReads,
|
|
865
936
|
normalizeGateState,
|
|
937
|
+
normalizeRetrospectiveFindings,
|
|
866
938
|
resolveSubGate,
|
|
867
939
|
lookupAcceptanceTemplate,
|
|
868
940
|
buildWorktreeSlug,
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* This module owns:
|
|
13
13
|
* - canonical section-name matching for AC / DoD blocks
|
|
14
|
-
* - bullet-item extraction (
|
|
14
|
+
* - bullet-item extraction (checklist `- [ ]`/`- [x]` and top-level `- ` bullets)
|
|
15
15
|
* - linked-refinement-doc detection from issue body
|
|
16
16
|
*
|
|
17
17
|
* It deliberately does NOT:
|
|
@@ -47,9 +47,44 @@ const DOD_SECTION_PATTERNS = Object.freeze([
|
|
|
47
47
|
/^dod\s*$/i,
|
|
48
48
|
]);
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Fenced-code-span tracker. Given the previous fence state and the current
|
|
52
|
+
* line, returns { fence, insideFence } where:
|
|
53
|
+
* - `fence` is the next state ({ char, len } while open, else null)
|
|
54
|
+
* - `insideFence` is true when the line's CONTENT is inside a code span
|
|
55
|
+
* (i.e. a fence line, or a line between an open and its close)
|
|
56
|
+
*
|
|
57
|
+
* CommonMark: an N-marker fence (``` or ~~~) closes only on a line of >= N
|
|
58
|
+
* markers of the SAME char with no info string. This is the single source of
|
|
59
|
+
* truth shared by parseMarkdownSections (headings) and extractChecklistItems
|
|
60
|
+
* (checkboxes) so the two anti-spoof layers cannot drift (issue #1025).
|
|
61
|
+
*/
|
|
62
|
+
function stepFence(fence, line) {
|
|
63
|
+
const openMatch = /^\s*(`{3,}|~{3,})/u.exec(line);
|
|
64
|
+
if (openMatch) {
|
|
65
|
+
const char = openMatch[1][0];
|
|
66
|
+
const len = openMatch[1].length;
|
|
67
|
+
// A closing fence is a bare run of >= N markers of ONLY the opening char
|
|
68
|
+
// (CommonMark: no mixed markers, no info string).
|
|
69
|
+
const isBareRun = new RegExp(`^\\s*${char}+\\s*$`, "u").test(line);
|
|
70
|
+
if (fence === null) {
|
|
71
|
+
return { fence: { char, len }, insideFence: true };
|
|
72
|
+
}
|
|
73
|
+
if (fence.char === char && len >= fence.len && isBareRun) {
|
|
74
|
+
return { fence: null, insideFence: true };
|
|
75
|
+
}
|
|
76
|
+
return { fence, insideFence: true };
|
|
77
|
+
}
|
|
78
|
+
return { fence, insideFence: fence !== null };
|
|
79
|
+
}
|
|
80
|
+
|
|
50
81
|
/**
|
|
51
82
|
* Extract `## ...` heading boundaries from a Markdown body.
|
|
52
83
|
* Returns a sorted array of { level, name, bodyLines } records.
|
|
84
|
+
*
|
|
85
|
+
* Headings inside a fenced code span (``` or ~~~) are NOT treated as headings —
|
|
86
|
+
* otherwise a body could spoof the refinement/spec gate with real-looking
|
|
87
|
+
* headings that carry no real spec (gate integrity, issue #1025).
|
|
53
88
|
*/
|
|
54
89
|
export function parseMarkdownSections(body) {
|
|
55
90
|
if (typeof body !== "string" || body.length === 0) {
|
|
@@ -59,8 +94,15 @@ export function parseMarkdownSections(body) {
|
|
|
59
94
|
const lines = body.split(/\r?\n/u);
|
|
60
95
|
const sections = [];
|
|
61
96
|
let current = null;
|
|
97
|
+
let fence = null;
|
|
62
98
|
|
|
63
99
|
for (const line of lines) {
|
|
100
|
+
const step = stepFence(fence, line);
|
|
101
|
+
fence = step.fence;
|
|
102
|
+
if (step.insideFence) {
|
|
103
|
+
if (current) current.bodyLines.push(line);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
64
106
|
const match = /^(#{1,6})\s+(.+?)\s*$/u.exec(line);
|
|
65
107
|
if (match) {
|
|
66
108
|
if (current) {
|
|
@@ -97,10 +139,18 @@ function findSectionByPatterns(sections, patterns) {
|
|
|
97
139
|
}
|
|
98
140
|
|
|
99
141
|
/**
|
|
100
|
-
* Extract
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
142
|
+
* Extract bullet items from a section body. Counts both `- [ ]`/`- [x]`
|
|
143
|
+
* checklist items and top-level plain `- ` bullets (dash at column 0, so
|
|
144
|
+
* nested/indented sub-bullets are not counted). Empty checkbox placeholders
|
|
145
|
+
* (`- [ ]` / `- [x]` with no trailing text) are skipped, not counted, so a
|
|
146
|
+
* section of only unfilled placeholders reports as unrefined. Returns the
|
|
147
|
+
* trimmed item text for each matching line. The checkbox state (checked vs
|
|
148
|
+
* unchecked) is intentionally not preserved: callers only need the item
|
|
149
|
+
* text to satisfy the refinement-artifact contract.
|
|
150
|
+
*
|
|
151
|
+
* This is only ever called on the body of an already-recognized AC/DoD
|
|
152
|
+
* section (see `detectIssueRefinementArtifact`), so counting plain bullets
|
|
153
|
+
* is scoped to those sections and never affects prose sections.
|
|
104
154
|
*/
|
|
105
155
|
export function extractChecklistItems(sectionBody) {
|
|
106
156
|
if (typeof sectionBody !== "string" || sectionBody.length === 0) {
|
|
@@ -109,11 +159,33 @@ export function extractChecklistItems(sectionBody) {
|
|
|
109
159
|
|
|
110
160
|
const items = [];
|
|
111
161
|
const lines = sectionBody.split(/\r?\n/u);
|
|
162
|
+
let fence = null;
|
|
112
163
|
|
|
113
164
|
for (const line of lines) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
165
|
+
// Checkboxes/bullets inside a fenced code span are non-interactive text, not
|
|
166
|
+
// real items — skip them so a body cannot spoof the AC/DoD gate with
|
|
167
|
+
// code-fenced checkboxes (issue #1025). Same fence logic as parseMarkdownSections.
|
|
168
|
+
const step = stepFence(fence, line);
|
|
169
|
+
fence = step.fence;
|
|
170
|
+
if (step.insideFence) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
// Checklist item: `- [ ]` / `- [x]` (leading indentation tolerated).
|
|
174
|
+
// Consume ANY checkbox-marker line here; push only when it carries text,
|
|
175
|
+
// so empty placeholders (`- [ ]`) are skipped rather than counted.
|
|
176
|
+
const checkboxMatch = /^\s*-\s+\[(?:[ xX])\](?:\s+(.+?))?\s*$/u.exec(line);
|
|
177
|
+
if (checkboxMatch) {
|
|
178
|
+
const text = (checkboxMatch[1] ?? "").trim();
|
|
179
|
+
if (text.length > 0) {
|
|
180
|
+
items.push(text);
|
|
181
|
+
}
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
// Top-level plain bullet: dash at column 0, space required (so `---`
|
|
185
|
+
// horizontal rules and `-x` do not match; indented sub-bullets do not).
|
|
186
|
+
const bulletMatch = /^-\s+(.+?)\s*$/u.exec(line);
|
|
187
|
+
if (bulletMatch) {
|
|
188
|
+
const text = bulletMatch[1].trim();
|
|
117
189
|
if (text.length > 0) {
|
|
118
190
|
items.push(text);
|
|
119
191
|
}
|
|
@@ -248,6 +320,162 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null }
|
|
|
248
320
|
};
|
|
249
321
|
}
|
|
250
322
|
|
|
323
|
+
/**
|
|
324
|
+
* PR-body-as-spec invariant sections (issue #1025, lightweight path).
|
|
325
|
+
*
|
|
326
|
+
* When a lightweight session uses the PR description itself as the
|
|
327
|
+
* spec-of-record (no committed phase/plan doc), the PR body must still carry
|
|
328
|
+
* the same invariants a durable spec doc would. AC/DoD reuse the checklist
|
|
329
|
+
* patterns above; these are the narrative sections not covered by those.
|
|
330
|
+
* Key order = validation/report order. Each key maps to its distinct
|
|
331
|
+
* `missing_*` code (mirrors `checkBaseSections` in _refine-helpers.mjs).
|
|
332
|
+
*/
|
|
333
|
+
export const PR_BODY_SPEC_NARRATIVE_SECTIONS = Object.freeze({
|
|
334
|
+
objective: {
|
|
335
|
+
code: "missing_objective",
|
|
336
|
+
label: "Objective/why",
|
|
337
|
+
patterns: [/^objective\b/iu, /^why\b/iu, /^goals?\b/iu, /^summary\b/iu, /^problem\b/iu],
|
|
338
|
+
},
|
|
339
|
+
in_scope: {
|
|
340
|
+
code: "missing_in_scope",
|
|
341
|
+
label: "In scope",
|
|
342
|
+
patterns: [/^in[- ]?scope\b/iu, /^scope\b/iu],
|
|
343
|
+
},
|
|
344
|
+
non_goals: {
|
|
345
|
+
code: "missing_explicit_non_goals",
|
|
346
|
+
label: "Explicit non-goals",
|
|
347
|
+
patterns: [/^explicit non-?goals\b/iu, /^non-?goals\b/iu, /^out of scope\b/iu],
|
|
348
|
+
},
|
|
349
|
+
open_questions: {
|
|
350
|
+
code: "missing_open_questions",
|
|
351
|
+
label: "Open questions/risks",
|
|
352
|
+
patterns: [/^open questions\b/iu, /^risks?\b/iu, /^questions\b/iu],
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* GitHub's accepted closing-keyword issue references (close/closes/closed,
|
|
358
|
+
* fix/fixes/fixed, resolve/resolves/resolved), case-insensitive, followed by
|
|
359
|
+
* `#N` or the cross-repo `owner/repo#N` form. Mirrors the linkage the
|
|
360
|
+
* lightweight path (#1025) requires the PR body to carry (issue #1181: five
|
|
361
|
+
* lightweight PRs merged without this and none auto-closed their issue).
|
|
362
|
+
*/
|
|
363
|
+
const CLOSING_ISSUE_REFERENCE_PATTERN =
|
|
364
|
+
/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+(?:[\w.-]+\/[\w.-]+)?#(\d+)/giu;
|
|
365
|
+
|
|
366
|
+
function extractClosingIssueNumbers(body) {
|
|
367
|
+
// Same fence-skip as sectionHasBody: a `Closes #N` line quoted inside a
|
|
368
|
+
// ```fenced``` example (e.g. a PR-template sample) must not spoof the gate.
|
|
369
|
+
let fence = null;
|
|
370
|
+
const unfenced = [];
|
|
371
|
+
for (const line of body.split("\n")) {
|
|
372
|
+
const step = stepFence(fence, line);
|
|
373
|
+
fence = step.fence;
|
|
374
|
+
if (step.insideFence) continue;
|
|
375
|
+
unfenced.push(line);
|
|
376
|
+
}
|
|
377
|
+
// Inline `code` spans don't auto-close on GitHub either: blank out any
|
|
378
|
+
// backtick-run-delimited span (equal-length runs pair, so ``a `b` c`` works).
|
|
379
|
+
// ponytail: not full CommonMark span matching; an unbalanced stray backtick
|
|
380
|
+
// over-strips toward fail-closed, which is the safe direction for this gate.
|
|
381
|
+
const text = unfenced.join("\n").replace(/(`+)[\s\S]*?\1/gu, " ");
|
|
382
|
+
const seen = new Set();
|
|
383
|
+
const numbers = [];
|
|
384
|
+
for (const match of text.matchAll(CLOSING_ISSUE_REFERENCE_PATTERN)) {
|
|
385
|
+
const n = Number(match[1]);
|
|
386
|
+
if (Number.isInteger(n) && n > 0 && !seen.has(n)) {
|
|
387
|
+
seen.add(n);
|
|
388
|
+
numbers.push(n);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return numbers;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function sectionHasBody(section) {
|
|
395
|
+
// A real body needs >=1 non-whitespace line OUTSIDE any fenced code span —
|
|
396
|
+
// a section whose only content is a ```fenced``` block is treated as empty so
|
|
397
|
+
// it cannot spoof the narrative-invariant gate (issue #1025, same stepFence as
|
|
398
|
+
// parseMarkdownSections + extractChecklistItems).
|
|
399
|
+
if (!section) return false;
|
|
400
|
+
let fence = null;
|
|
401
|
+
for (const line of section.bodyLines) {
|
|
402
|
+
const step = stepFence(fence, line);
|
|
403
|
+
fence = step.fence;
|
|
404
|
+
if (step.insideFence) continue;
|
|
405
|
+
if (line.trim().length > 0) return true;
|
|
406
|
+
}
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Validate that a PR body carries every invariant required to serve as the
|
|
412
|
+
* lightweight spec-of-record: Objective/why, in-scope, explicit non-goals,
|
|
413
|
+
* testable Acceptance criteria (>=1 checklist item), Definition of done
|
|
414
|
+
* (>=1 checklist item), Open questions/risks, and a GitHub closing-keyword
|
|
415
|
+
* issue reference (`Closes #N` and GitHub's other accepted forms — the
|
|
416
|
+
* lightweight path's `Closes #N` linkage, issue #1181). Reuses the generic
|
|
417
|
+
* markdown logic (parseMarkdownSections / AC + DoD patterns /
|
|
418
|
+
* extractChecklistItems) so there is no parallel validator. Fails closed:
|
|
419
|
+
* every missing invariant is reported under its distinct `missing_*` code.
|
|
420
|
+
* Pure; no side effects.
|
|
421
|
+
*
|
|
422
|
+
* @param {{ body?: string, expectedIssue?: number }} input
|
|
423
|
+
* @returns {{ checker: "validate-pr-body-spec", ok: boolean, errors: { code: string, message: string }[], sections: string[], acItems: string[], dodItems: string[], closesIssues: number[] }}
|
|
424
|
+
*/
|
|
425
|
+
export function validatePrBodySpec({ body = "", expectedIssue = null } = {}) {
|
|
426
|
+
const bodyText = typeof body === "string" ? body : "";
|
|
427
|
+
const sections = parseMarkdownSections(bodyText);
|
|
428
|
+
const errors = [];
|
|
429
|
+
|
|
430
|
+
for (const { code, label, patterns } of Object.values(PR_BODY_SPEC_NARRATIVE_SECTIONS)) {
|
|
431
|
+
const section = findSectionByPatterns(sections, patterns);
|
|
432
|
+
if (!sectionHasBody(section)) {
|
|
433
|
+
errors.push({ code, message: `Missing or empty ${label} section.` });
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const acSection = findSectionByPatterns(sections, ACCEPTANCE_SECTION_PATTERNS);
|
|
438
|
+
const acItems = acSection ? extractChecklistItems(acSection.bodyLines.join("\n")) : [];
|
|
439
|
+
if (acItems.length === 0) {
|
|
440
|
+
errors.push({
|
|
441
|
+
code: "missing_acceptance_criteria",
|
|
442
|
+
message: "Missing testable Acceptance criteria (no checklist items found).",
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const dodSection = findSectionByPatterns(sections, DOD_SECTION_PATTERNS);
|
|
447
|
+
const dodItems = dodSection ? extractChecklistItems(dodSection.bodyLines.join("\n")) : [];
|
|
448
|
+
if (dodItems.length === 0) {
|
|
449
|
+
errors.push({
|
|
450
|
+
code: "missing_definition_of_done",
|
|
451
|
+
message: "Missing Definition of done (no checklist items found).",
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const closesIssues = extractClosingIssueNumbers(bodyText);
|
|
456
|
+
if (closesIssues.length === 0) {
|
|
457
|
+
errors.push({
|
|
458
|
+
code: "missing_closing_issue_reference",
|
|
459
|
+
message: "Missing a GitHub closing-keyword issue reference (e.g. `Closes #123`).",
|
|
460
|
+
});
|
|
461
|
+
} else if (Number.isInteger(expectedIssue) && !closesIssues.includes(expectedIssue)) {
|
|
462
|
+
errors.push({
|
|
463
|
+
code: "closes_wrong_issue",
|
|
464
|
+
message: `PR body closes ${closesIssues.map((n) => `#${n}`).join(", ")}, not the expected #${expectedIssue}.`,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return {
|
|
469
|
+
checker: "validate-pr-body-spec",
|
|
470
|
+
ok: errors.length === 0,
|
|
471
|
+
errors,
|
|
472
|
+
sections: sections.map((s) => s.name),
|
|
473
|
+
acItems,
|
|
474
|
+
dodItems,
|
|
475
|
+
closesIssues,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
251
479
|
/**
|
|
252
480
|
* Map a draft-gate refinement check to the result surface consumed by
|
|
253
481
|
* `evaluatePrGateCoordination`. The mapping keeps the contract
|
|
@@ -133,7 +133,7 @@ export const LIFECYCLE_NEXT_ACTIONS = Object.freeze({
|
|
|
133
133
|
[LIFECYCLE_STATE.PRE_APPROVAL_GATE]:
|
|
134
134
|
"Run pre-approval gate review; verify gate evidence, CI, and unresolved threads.",
|
|
135
135
|
[LIFECYCLE_STATE.MERGE]:
|
|
136
|
-
"Merge is authorized; run the final merge step and
|
|
136
|
+
"Merge is authorized; run the final merge step. The retrospective is advisory and post-merge: it records flagged raw-calls for the conductor, never blocks a transition.",
|
|
137
137
|
});
|
|
138
138
|
|
|
139
139
|
// ---------------------------------------------------------------------------
|