@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.
@@ -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: [...template.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
- // Optional refinement contract (AC/DoD matrix) from the refiner.
532
- // Set via options.refinementContract, bundle.refinementContract, or resolverOutput.refinementContract.
533
- const refinementContract = options.refinementContract ?? bundle.refinementContract ?? resolverOutput.refinementContract ?? null;
534
- if (refinementContract != null) {
535
- envelope.refinementContract = refinementContract;
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
- // ----- refinementContract (optional) -----
771
- if (envelope.refinementContract !== undefined && envelope.refinementContract !== null) {
772
- if (typeof envelope.refinementContract !== "object" || Array.isArray(envelope.refinementContract)) {
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: "refinementContract",
775
- reason: "if present, must be a non-array object with schema, items, generatedAt, and isComplete",
776
- got: envelope.refinementContract,
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 (envelope.refinementContract.schema !== "ac-dod-matrix/v1") {
780
- warnings.push({
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 (typeof envelope.refinementContract.generatedAt !== "string" || isNaN(Date.parse(envelope.refinementContract.generatedAt))) {
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 (typeof envelope.refinementContract.isComplete !== "boolean") {
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 (both `- [ ]` and `- [x]`)
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 checklist bullet items (`- [ ]` and `- [x]`) from a section body.
101
- * Returns the trimmed item text for each matching line. The checkbox state
102
- * (checked vs unchecked) is intentionally not preserved: callers only need
103
- * the item text to satisfy the refinement-artifact contract.
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
- const match = /^\s*-\s+\[(?:[ xX])\]\s+(.+?)\s*$/u.exec(line);
115
- if (match) {
116
- const text = match[1].trim();
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 write the retrospective checkpoint.",
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
  // ---------------------------------------------------------------------------