instar 1.3.35 → 1.3.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +1 -0
  2. package/dist/commands/server.d.ts.map +1 -1
  3. package/dist/commands/server.js +53 -3
  4. package/dist/commands/server.js.map +1 -1
  5. package/dist/config/ConfigDefaults.d.ts.map +1 -1
  6. package/dist/config/ConfigDefaults.js +15 -0
  7. package/dist/config/ConfigDefaults.js.map +1 -1
  8. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  9. package/dist/core/PostUpdateMigrator.js +15 -0
  10. package/dist/core/PostUpdateMigrator.js.map +1 -1
  11. package/dist/core/featureRolloutScan.d.ts +23 -0
  12. package/dist/core/featureRolloutScan.d.ts.map +1 -1
  13. package/dist/core/featureRolloutScan.js +102 -0
  14. package/dist/core/featureRolloutScan.js.map +1 -1
  15. package/dist/core/types.d.ts +45 -0
  16. package/dist/core/types.d.ts.map +1 -1
  17. package/dist/core/types.js.map +1 -1
  18. package/dist/messaging/AgentTelegramLedger.d.ts +65 -0
  19. package/dist/messaging/AgentTelegramLedger.d.ts.map +1 -0
  20. package/dist/messaging/AgentTelegramLedger.js +61 -0
  21. package/dist/messaging/AgentTelegramLedger.js.map +1 -0
  22. package/dist/messaging/ProcessedIdStore.d.ts +48 -0
  23. package/dist/messaging/ProcessedIdStore.d.ts.map +1 -0
  24. package/dist/messaging/ProcessedIdStore.js +111 -0
  25. package/dist/messaging/ProcessedIdStore.js.map +1 -0
  26. package/dist/monitoring/ReleaseReadinessSentinel.d.ts +172 -0
  27. package/dist/monitoring/ReleaseReadinessSentinel.d.ts.map +1 -0
  28. package/dist/monitoring/ReleaseReadinessSentinel.js +315 -0
  29. package/dist/monitoring/ReleaseReadinessSentinel.js.map +1 -0
  30. package/dist/monitoring/releaseReadinessWiring.d.ts +54 -0
  31. package/dist/monitoring/releaseReadinessWiring.d.ts.map +1 -0
  32. package/dist/monitoring/releaseReadinessWiring.js +241 -0
  33. package/dist/monitoring/releaseReadinessWiring.js.map +1 -0
  34. package/dist/scaffold/templates.d.ts.map +1 -1
  35. package/dist/scaffold/templates.js +5 -0
  36. package/dist/scaffold/templates.js.map +1 -1
  37. package/dist/server/AgentServer.d.ts +1 -0
  38. package/dist/server/AgentServer.d.ts.map +1 -1
  39. package/dist/server/AgentServer.js +1 -0
  40. package/dist/server/AgentServer.js.map +1 -1
  41. package/dist/server/CapabilityIndex.d.ts.map +1 -1
  42. package/dist/server/CapabilityIndex.js +14 -0
  43. package/dist/server/CapabilityIndex.js.map +1 -1
  44. package/dist/server/routes.d.ts +3 -0
  45. package/dist/server/routes.d.ts.map +1 -1
  46. package/dist/server/routes.js +55 -0
  47. package/dist/server/routes.js.map +1 -1
  48. package/package.json +1 -1
  49. package/scripts/analyze-release.js +221 -0
  50. package/scripts/upgrade-guide-validator.mjs +120 -0
  51. package/src/data/builtin-manifest.json +63 -63
  52. package/src/scaffold/templates/jobs/instar/release-readiness-check.md +24 -0
  53. package/src/scaffold/templates.ts +5 -0
  54. package/upgrades/1.3.37.md +59 -0
  55. package/upgrades/side-effects/a2a-ledger-and-processed-id-store.md +68 -0
  56. package/upgrades/side-effects/release-readiness-visibility-pr2a.md +27 -0
  57. package/upgrades/side-effects/release-readiness-visibility-pr2b.md +26 -0
  58. package/upgrades/side-effects/release-readiness-visibility-pr2c.md +37 -0
  59. package/upgrades/side-effects/release-readiness-visibility-pr3.md +33 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "1.3.35",
3
+ "version": "1.3.37",
4
4
  "description": "Coherence infrastructure for self-evolving AI agents — on the Claude Code or Codex subscription you already have.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -36,6 +36,11 @@ const ROOT = path.resolve(__dirname, '..');
36
36
  const args = process.argv.slice(2);
37
37
  const JSON_ONLY = args.includes('--json');
38
38
  const RECOMMEND_ONLY = args.includes('--recommend-only');
39
+ // --draft-guide writes/merges upgrades/NEXT.md from the computed change-list
40
+ // (Layer A of the release-readiness-visibility spec). Drafted content carries
41
+ // `auto-draft-unreviewed` markers that BOTH publish gates reject until a human
42
+ // reviews each section — so auto-fill can never ship un-reviewed notes.
43
+ const DRAFT_GUIDE = args.includes('--draft-guide');
39
44
 
40
45
  // --ref=<rev> selects the tip the analysis runs against (default HEAD).
41
46
  // Layer B of the release-readiness-visibility spec passes --ref=FETCH_HEAD so the
@@ -584,6 +589,214 @@ function generateChangeDescriptions(analysis) {
584
589
  return descriptions;
585
590
  }
586
591
 
592
+ // ── Auto-draft (Layer A of release-readiness-visibility spec) ────────
593
+ //
594
+ // Turns the computed change-list into a NEXT.md draft. Every drafted item
595
+ // carries an `auto-draft-unreviewed` marker. The publish gates
596
+ // (check-upgrade-guide.js validator + publish.yml skip predicate) refuse to
597
+ // ship a guide that still has those markers, so auto-fill removes the "blank
598
+ // guide" root cause WITHOUT defeating the human-review purpose of the gate.
599
+
600
+ const UNREVIEWED_BLOCK_MARKER = '<!-- auto-draft-unreviewed-block -->';
601
+ const UNCOVERED_BEGIN = '<!-- BEGIN auto-draft-uncovered (unreviewed) -->';
602
+ const UNCOVERED_END = '<!-- END auto-draft-uncovered (unreviewed) -->';
603
+
604
+ /**
605
+ * Neutralize commit-message-sourced text before it lands in a published guide:
606
+ * - strip HTML comments (prevents forging `<!-- bump: -->` / unreviewed markers)
607
+ * - collapse whitespace, cap length
608
+ * - escape leading markdown control chars so an item can't break section bounds
609
+ */
610
+ function sanitizeDraftText(s) {
611
+ let t = String(s ?? '')
612
+ .replace(/<!--[\s\S]*?-->/g, '') // strip HTML comments
613
+ .replace(/\s+/g, ' ')
614
+ .trim();
615
+ if (t.length > 200) t = t.slice(0, 197).trimEnd() + '…';
616
+ // Escape leading control sequences (heading / hr) so a crafted message can't
617
+ // inject a new section boundary.
618
+ t = t.replace(/^(#{1,6}\s)/, '\\$1').replace(/^(-{3,})/, '\\$1');
619
+ return t;
620
+ }
621
+
622
+ const FALLBACK_USER_IMPACTS = new Set([
623
+ 'Review the commit for user-facing changes',
624
+ 'Review the commit for specifics',
625
+ ]);
626
+
627
+ function unreviewed(slug) {
628
+ return `<!-- auto-draft-unreviewed: ${slug} -->`;
629
+ }
630
+
631
+ /** Does the existing guide already mention this change description? */
632
+ function descriptionMentioned(desc, content) {
633
+ const lc = content.toLowerCase();
634
+ // Endpoint: the path token (e.g. "/release-readiness").
635
+ const ep = /:\s*[A-Z]+\s+(\/\S+)/.exec(desc.summary);
636
+ if (ep) return lc.includes(ep[1].toLowerCase());
637
+ // CLI command: "instar <cmd>".
638
+ const cmd = /instar\s+([a-z][\w:-]*)/i.exec(desc.summary);
639
+ if (cmd) return lc.includes(cmd[1].toLowerCase());
640
+ // Config field: "New config option: <field>".
641
+ const cfg = /config option:\s*(\w+)/i.exec(desc.summary);
642
+ if (cfg) return lc.includes(cfg[1].toLowerCase());
643
+ // Otherwise (commit-derived): any >4-char keyword from the summary.
644
+ const kws = desc.summary.replace(/^(feat|fix)[:(]?\s*/i, '').split(/\s+/).filter((w) => w.length > 4).slice(0, 4);
645
+ return kws.length > 0 && kws.some((k) => lc.includes(k.toLowerCase()));
646
+ }
647
+
648
+ /** One "## What Changed" bullet for a description. */
649
+ function changedBullet(desc) {
650
+ return `- **${desc.type}**: ${sanitizeDraftText(desc.summary)} — ${sanitizeDraftText(desc.detail)}`;
651
+ }
652
+
653
+ /** Build the three required sections' bodies from the change-list. */
654
+ function buildSectionBodies(changeDescriptions, hasFix) {
655
+ const whatChanged = changeDescriptions.length
656
+ ? changeDescriptions.map(changedBullet).join('\n')
657
+ : '- (no structurally-detected changes; describe the release manually)';
658
+
659
+ // "What to Tell Your User" must stay plain — no backticks/camelCase/code.
660
+ // Use the generic userImpact lines (already plain) and a HUMAN-REQUIRED note
661
+ // for any commit whose impact the analyzer couldn't infer.
662
+ const userLines = [];
663
+ const seen = new Set();
664
+ for (const d of changeDescriptions) {
665
+ const impact = FALLBACK_USER_IMPACTS.has(d.userImpact)
666
+ ? 'HUMAN-REQUIRED: describe what this means for the user'
667
+ : d.userImpact;
668
+ const line = `- ${sanitizeDraftText(impact)}`;
669
+ if (!seen.has(line)) { seen.add(line); userLines.push(line); }
670
+ }
671
+ const tellUser = userLines.length ? userLines.join('\n') : '- HUMAN-REQUIRED: summarize the user-facing impact';
672
+
673
+ const capRows = changeDescriptions
674
+ .filter((d) => d.type === 'feature' || d.type === 'enhancement')
675
+ .map((d) => `| ${sanitizeDraftText(d.summary)} | ${sanitizeDraftText(d.agentImpact)} |`)
676
+ .join('\n') || '| (none) | — |';
677
+
678
+ let out =
679
+ `## What Changed
680
+
681
+ ${unreviewed('what-changed')}
682
+ ${whatChanged}
683
+
684
+ ## What to Tell Your User
685
+
686
+ ${unreviewed('tell-user')}
687
+ ${tellUser}
688
+
689
+ ## Summary of New Capabilities
690
+
691
+ ${unreviewed('capabilities')}
692
+ | Capability | How to Use |
693
+ |-----------|-----------|
694
+ ${capRows}
695
+ `;
696
+
697
+ if (hasFix) {
698
+ out +=
699
+ `
700
+ ## Evidence
701
+
702
+ ${unreviewed('evidence')}
703
+ HUMAN-REQUIRED: reproduction + observed before/after, or "Not reproducible in dev — [concrete reason]". Unit tests passing is not evidence.
704
+ `;
705
+ }
706
+ return out;
707
+ }
708
+
709
+ /** Full NEXT.md draft (used when the guide is absent or a pristine template). */
710
+ function buildFullDraft(changeDescriptions, recommended, hasFix) {
711
+ return `# Upgrade Guide — vNEXT
712
+
713
+ <!-- bump: ${recommended} -->
714
+ ${UNREVIEWED_BLOCK_MARKER}
715
+ <!-- Auto-drafted from the classified commit range. Every section below carries an
716
+ auto-draft-unreviewed marker. A human reviews each section and replaces its
717
+ marker with a reviewed-by receipt before this guide can publish.
718
+ See docs/specs/RELEASE-READINESS-VISIBILITY-SPEC.md §4.1.1. -->
719
+
720
+ ${buildSectionBodies(changeDescriptions, hasFix)}`;
721
+ }
722
+
723
+ /** The uncovered-delta block appended to a guide that already has human content. */
724
+ function buildUncoveredBlock(changeDescriptions, existingContent) {
725
+ const uncovered = changeDescriptions.filter((d) => !descriptionMentioned(d, existingContent));
726
+ if (uncovered.length === 0) return '';
727
+ const items = uncovered.map((d) => `- ${unreviewed(slugifyDesc(d))} **${d.type}**: ${sanitizeDraftText(d.summary)}`).join('\n');
728
+ return `${UNCOVERED_BEGIN}
729
+ <!-- These changes were detected in the commit range but not yet mentioned above.
730
+ Fold them into the sections above (or describe why they need no note), then
731
+ remove each marker with a reviewed-by receipt. -->
732
+ ${items}
733
+ ${UNCOVERED_END}`;
734
+ }
735
+
736
+ function slugifyDesc(d) {
737
+ return (d.summary || 'item').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'item';
738
+ }
739
+
740
+ const PRISTINE_TEMPLATE = (c) => c.includes('[Feature name]') && c.includes('[Capability]');
741
+
742
+ /**
743
+ * Write or merge upgrades/NEXT.md. Race-guarded against publish finalize.
744
+ * Returns a short status string for logging.
745
+ */
746
+ function writeOrMergeGuide(changeDescriptions, recommended, hasFix) {
747
+ const upgradesDir = path.join(ROOT, 'upgrades');
748
+ const nextPath = path.join(upgradesDir, 'NEXT.md');
749
+ const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf-8'));
750
+ const versionGuide = path.join(upgradesDir, `${pkg.version}.md`);
751
+
752
+ fs.mkdirSync(upgradesDir, { recursive: true });
753
+
754
+ // Race guard against publish-finalize: refuse to draft once a finalize has
755
+ // produced upgrades/{version}.md. Per RELEASE-READINESS-VISIBILITY-SPEC §4.1.3
756
+ // this version-file check IS the cross-host guarantee (finalize renames
757
+ // NEXT.md → {version}.md in CI on a separate checkout; git's ref-update
758
+ // atomicity covers the merge). The intra-host O_EXCL advisory lock the spec
759
+ // also describes is intentionally omitted here: releasing it requires a
760
+ // destructive fs.unlink (forbidden by lint-no-direct-destructive in this
761
+ // dependency-free, test-copyable script), and the draft job is single-runner
762
+ // per the multi-machine lease, so concurrent local drafters don't occur.
763
+ if (fs.existsSync(versionGuide)) {
764
+ return `skipped: upgrades/${pkg.version}.md already finalized — not drafting`;
765
+ }
766
+
767
+ const existing = fs.existsSync(nextPath) ? fs.readFileSync(nextPath, 'utf-8') : '';
768
+
769
+ if (!existing.trim() || PRISTINE_TEMPLATE(existing)) {
770
+ fs.writeFileSync(nextPath, buildFullDraft(changeDescriptions, recommended, hasFix));
771
+ return 'wrote full draft (guide was absent or a pristine template)';
772
+ }
773
+
774
+ // Human content present — additive, never-clobber. Regenerate only the
775
+ // uncovered-delta block (idempotent). Coverage is measured against the
776
+ // HUMAN content only: strip any prior auto-block first, otherwise the
777
+ // block's own text would count as "coverage" and the block would
778
+ // oscillate (present on one run, absent the next).
779
+ const beginIdx = existing.indexOf(UNCOVERED_BEGIN);
780
+ const endIdx = existing.indexOf(UNCOVERED_END);
781
+ let tail = '';
782
+ let humanContent = existing;
783
+ if (beginIdx !== -1) {
784
+ tail = endIdx !== -1 ? existing.slice(endIdx + UNCOVERED_END.length) : '';
785
+ humanContent = existing.slice(0, beginIdx) + tail;
786
+ }
787
+ const block = buildUncoveredBlock(changeDescriptions, humanContent);
788
+ let body;
789
+ if (beginIdx !== -1) {
790
+ body = existing.slice(0, beginIdx).replace(/\s+$/, '') + (block ? `\n\n${block}` : '') + tail;
791
+ } else if (block) {
792
+ body = existing.replace(/\s+$/, '') + `\n\n${block}\n`;
793
+ } else {
794
+ return 'no uncovered changes — guide already covers the change-list';
795
+ }
796
+ fs.writeFileSync(nextPath, body);
797
+ return block ? 'merged uncovered-delta block into existing guide' : 'removed stale uncovered-delta block (now fully covered)';
798
+ }
799
+
587
800
  // ── Main ─────────────────────────────────────────────────────────────
588
801
 
589
802
  const lastTag = getLastReleaseTag();
@@ -645,6 +858,14 @@ if (bumpRecommendation.patchSignals.length > 0) {
645
858
  for (const s of bumpRecommendation.patchSignals) log(` - ${s}`);
646
859
  }
647
860
 
861
+ if (DRAFT_GUIDE) {
862
+ const hasFix = analysis.commits.fixes.length > 0;
863
+ const status = writeOrMergeGuide(changeDescriptions, bumpRecommendation.recommended, hasFix);
864
+ log(`\n Draft guide: ${status}`);
865
+ console.log(status);
866
+ process.exit(0);
867
+ }
868
+
648
869
  if (RECOMMEND_ONLY) {
649
870
  console.log(bumpRecommendation.recommended);
650
871
  process.exit(0);
@@ -2,6 +2,8 @@
2
2
  // The CLI entry point (check-upgrade-guide.js) wraps this with I/O.
3
3
  // Extracted for unit testability.
4
4
 
5
+ import crypto from 'node:crypto';
6
+
5
7
  export const REQUIRED_SECTIONS = [
6
8
  '## What Changed',
7
9
  '## What to Tell Your User',
@@ -207,6 +209,9 @@ export function validateGuideContent(content) {
207
209
  issues.push(...evidenceIssues(content));
208
210
  }
209
211
 
212
+ // Auto-draft review gate — unreviewed markers + receipt validity.
213
+ issues.push(...autoDraftReviewIssues(content));
214
+
210
215
  return issues;
211
216
  }
212
217
 
@@ -218,3 +223,118 @@ export function parseBumpType(content) {
218
223
  const match = /<!--\s*bump:\s*(patch|minor|major)\s*-->/.exec(content);
219
224
  return match ? match[1] : null;
220
225
  }
226
+
227
+ // ── Auto-draft review gate (release-readiness-visibility spec §4.1.1) ──
228
+ //
229
+ // Layer A auto-drafts NEXT.md from the classified commit range. Every drafted
230
+ // section carries an `auto-draft-unreviewed` marker. The publish gate refuses
231
+ // to ship a guide while any such marker remains, so auto-fill removes the
232
+ // "blank guide" root cause WITHOUT shipping un-reviewed notes. A human signals
233
+ // review by REPLACING a section's marker with a hash-locked `reviewed-by`
234
+ // receipt; editing the section afterward invalidates the hash and re-blocks.
235
+
236
+ export const REVIEW_RECEIPT_MAX_AGE_DAYS = 30;
237
+
238
+ const UNREVIEWED_MARKER_RE = /<!--\s*auto-draft-unreviewed(?:-block)?(?::[^>]*?)?\s*-->/g;
239
+ const REVIEW_RECEIPT_RE = /<!--\s*reviewed-by:\s*(.+?)\s*@\s*([0-9T:.+\-Z]+)\s*:hash=([a-f0-9]{64})\s*-->/gi;
240
+ // A `reviewed-by` comment that does NOT match the strict form above — used to
241
+ // catch receipts missing the required :hash= (the iter-3 V2 fix).
242
+ const REVIEW_RECEIPT_LOOSE_RE = /<!--\s*reviewed-by:[^>]*-->/gi;
243
+
244
+ /**
245
+ * Canonicalize a section body for hashing: LF line endings, drop the receipt
246
+ * line itself, strip trailing whitespace per line, trim. Mirrors the rule in
247
+ * RELEASE-READINESS-VISIBILITY-SPEC §4.1.1.
248
+ */
249
+ export function canonicalizeSectionForHash(body) {
250
+ return String(body)
251
+ .replace(/\r\n/g, '\n')
252
+ .split('\n')
253
+ .filter((l) => !/<!--\s*reviewed-by:/i.test(l))
254
+ .map((l) => l.replace(/\s+$/, ''))
255
+ .join('\n')
256
+ .trim();
257
+ }
258
+
259
+ export function sectionReviewHash(body) {
260
+ return crypto.createHash('sha256').update(canonicalizeSectionForHash(body)).digest('hex');
261
+ }
262
+
263
+ /** Split content into H2 sections: [{ title, body }]. */
264
+ function h2Sections(content) {
265
+ const out = [];
266
+ const re = /^## (.+)$/gm;
267
+ let m;
268
+ const heads = [];
269
+ while ((m = re.exec(content)) !== null) heads.push({ title: m[1].trim(), idx: m.index, after: re.lastIndex });
270
+ for (let i = 0; i < heads.length; i++) {
271
+ const end = i + 1 < heads.length ? heads[i + 1].idx : content.length;
272
+ out.push({ title: heads[i].title, body: content.slice(heads[i].after, end) });
273
+ }
274
+ return out;
275
+ }
276
+
277
+ /**
278
+ * Gate issues for the auto-draft review state. Returns issue strings (empty = ok):
279
+ * 1. Any remaining `auto-draft-unreviewed` marker blocks publish.
280
+ * 2. A `reviewed-by` receipt missing the required `:hash=` blocks.
281
+ * 3. A receipt whose hash doesn't match its (canonicalized) section blocks
282
+ * — i.e. the section was edited after review.
283
+ * 4. A receipt older than REVIEW_RECEIPT_MAX_AGE_DAYS blocks.
284
+ * (Full "marker stripped without a receipt" detection needs a git-diff and is
285
+ * the tracked Phase-2 CI check per §4.1.1 — out of scope for this snapshot gate.)
286
+ */
287
+ export function autoDraftReviewIssues(content, now = Date.now()) {
288
+ const issues = [];
289
+
290
+ const unreviewed = content.match(UNREVIEWED_MARKER_RE) || [];
291
+ if (unreviewed.length > 0) {
292
+ issues.push(
293
+ `guide has ${unreviewed.length} unreviewed auto-draft marker(s) — a human must review each section and ` +
294
+ `replace its 'auto-draft-unreviewed' marker with a 'reviewed-by: <name> @ <ISO-date> :hash=<sha256>' receipt ` +
295
+ `before this guide can publish (RELEASE-READINESS-VISIBILITY-SPEC §4.1.1)`,
296
+ );
297
+ }
298
+
299
+ // Loose receipts that don't match the strict (with :hash=) form.
300
+ const looseAll = content.match(REVIEW_RECEIPT_LOOSE_RE) || [];
301
+ for (const loose of looseAll) {
302
+ REVIEW_RECEIPT_RE.lastIndex = 0;
303
+ if (!REVIEW_RECEIPT_RE.test(loose)) {
304
+ issues.push(
305
+ `malformed reviewed-by receipt (missing required ':hash=<sha256>' or bad date): ${loose.trim()}. ` +
306
+ `Required form: <!-- reviewed-by: <name> @ <ISO-date> :hash=<sha256> -->`,
307
+ );
308
+ }
309
+ }
310
+
311
+ // Strict receipts: validate hash-match against their section + age window.
312
+ for (const section of h2Sections(content)) {
313
+ REVIEW_RECEIPT_RE.lastIndex = 0;
314
+ let r;
315
+ while ((r = REVIEW_RECEIPT_RE.exec(section.body)) !== null) {
316
+ const [, , dateStr, hash] = r;
317
+ const ts = Date.parse(dateStr);
318
+ if (Number.isNaN(ts)) {
319
+ issues.push(`reviewed-by receipt in "## ${section.title}" has an unparseable date: "${dateStr}"`);
320
+ continue;
321
+ }
322
+ const ageDays = (now - ts) / (24 * 60 * 60 * 1000);
323
+ if (ageDays > REVIEW_RECEIPT_MAX_AGE_DAYS) {
324
+ issues.push(
325
+ `reviewed-by receipt in "## ${section.title}" is ${Math.floor(ageDays)} days old ` +
326
+ `(max ${REVIEW_RECEIPT_MAX_AGE_DAYS}) — re-review the section and refresh the receipt`,
327
+ );
328
+ }
329
+ const actual = sectionReviewHash(section.body);
330
+ if (actual !== hash) {
331
+ issues.push(
332
+ `reviewed-by receipt in "## ${section.title}" hash mismatch — the section was edited after review. ` +
333
+ `Re-review and update :hash= to ${actual}`,
334
+ );
335
+ }
336
+ }
337
+ }
338
+
339
+ return issues;
340
+ }