@davmash/bygone 0.9.8 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +21 -1
  2. package/README.md +74 -1
  3. package/cli/commandSpec.js +2 -0
  4. package/cli/completions.js +3 -3
  5. package/cli/present.js +28 -10
  6. package/cli/tour.js +22 -1
  7. package/cli/tourExport.js +147 -0
  8. package/cli/tourFile.js +109 -4
  9. package/cli/tourHistory.js +31 -1
  10. package/completions/_bygone +1 -1
  11. package/completions/bygone +1 -1
  12. package/completions/bygone.fish +1 -1
  13. package/docs/change-tour-format.md +85 -68
  14. package/docs/explore.md +14 -3
  15. package/docs/generating-change-tours.md +84 -46
  16. package/docs/present.md +103 -14
  17. package/docs/product-surface.md +19 -8
  18. package/docs/sharing-tours.md +95 -0
  19. package/docs/visual-walkthrough/README.md +89 -0
  20. package/docs/visual-walkthrough/build-tour.mjs +54 -0
  21. package/docs/visual-walkthrough/capture-desktop.cjs +42 -0
  22. package/docs/visual-walkthrough/capture-desktop.mjs +21 -0
  23. package/docs/visual-walkthrough/captures.md +49 -0
  24. package/docs/visual-walkthrough/create-demo.mjs +57 -0
  25. package/docs/visual-walkthrough/deconstructed-demo.bygone +100 -0
  26. package/docs/visual-walkthrough/images/01-two-files.png +0 -0
  27. package/docs/visual-walkthrough/images/02-many-panels.png +0 -0
  28. package/docs/visual-walkthrough/images/03-directories.png +0 -0
  29. package/docs/visual-walkthrough/images/04-search.png +0 -0
  30. package/docs/visual-walkthrough/images/05-repository-history.png +0 -0
  31. package/docs/visual-walkthrough/images/06-file-history.png +0 -0
  32. package/docs/visual-walkthrough/images/07-branch-review.png +0 -0
  33. package/docs/visual-walkthrough/images/08-tour-reader.png +0 -0
  34. package/docs/visual-walkthrough/images/09-focused-step.png +0 -0
  35. package/docs/visual-walkthrough/images/10-deconstructed.png +0 -0
  36. package/docs/visual-walkthrough/images/11-exploration.png +0 -0
  37. package/docs/visual-walkthrough/images/12-authoring.png +0 -0
  38. package/docs/visual-walkthrough/images/13-export.png +0 -0
  39. package/docs/visual-walkthrough/images/14-vscode.png +0 -0
  40. package/docs/visual-walkthrough/images/15-cli.png +0 -0
  41. package/docs/visual-walkthrough/narrative.md +134 -0
  42. package/docs/visual-walkthrough/outline.md +52 -0
  43. package/docs/visual-walkthrough/walkthrough.bygone +340 -0
  44. package/docs/walkthrough.md +6 -101
  45. package/examples/bygone-history.bygone +11 -44
  46. package/examples/marker-focus.bygone +116 -0
  47. package/examples/navigation-lab.bygone.yaml +401 -336
  48. package/examples/tour-orientation.bygone +349 -0
  49. package/media/diff.worker.js +13 -3
  50. package/media/webview.css +1 -1
  51. package/media/webview.js +394 -319
  52. package/out/changeTour.js +865 -397
  53. package/out/deepLink.js +167 -0
  54. package/out/historyDirectory.js +66 -0
  55. package/out/standalone-main.js +20514 -16821
  56. package/out/tourCoverage.js +850 -386
  57. package/out/tourDocument.js +97 -0
  58. package/package.json +1 -1
  59. package/schemas/change-tour-source.schema.json +91 -64
  60. package/skills/pr-tour-guide/SKILL.md +144 -128
  61. package/skills/pr-tour-guide/references/expectations-and-feedback.md +57 -0
  62. package/standalone/index.html +10 -25
  63. package/web/export-bootstrap.js +62 -0
  64. package/web/export-licenses.txt +78 -0
  65. package/web/export-runtime.json +12 -0
  66. package/web/index.html +54 -47
  67. package/web/presenter.css +332 -178
  68. package/web/web-host.js +239 -137
@@ -154,8 +154,8 @@ function listBranchCommits(repoRoot, mergeBaseOid, headOid, runGit2 = createGitR
154
154
  "--format=%H%x00%h%x00%cI%x00%s%x00%P%x1e",
155
155
  `${mergeBaseOid}..${headOid}`
156
156
  ], repoRoot);
157
- return output.split("").map((record) => record.replace(/^\n+|\n+$/g, "")).filter(Boolean).map((record) => {
158
- const [oid = "", shortOid = "", timestamp = "", summary = "", parents = ""] = record.split("\0");
157
+ return output.split("").map((record2) => record2.replace(/^\n+|\n+$/g, "")).filter(Boolean).map((record2) => {
158
+ const [oid = "", shortOid = "", timestamp = "", summary = "", parents = ""] = record2.split("\0");
159
159
  return {
160
160
  oid,
161
161
  shortOid,
@@ -471,21 +471,150 @@ function runGit(repoRoot, args) {
471
471
  });
472
472
  }
473
473
 
474
+ // src/tourImage.ts
475
+ var MAX_TOUR_IMAGE_BYTES = 8 * 1024 * 1024;
476
+ function record(value) {
477
+ return value !== null && typeof value === "object" && !Array.isArray(value);
478
+ }
479
+ function validPath(value) {
480
+ return typeof value === "string" && value.length > 0 && !/[\\\x00-\x1f:]/.test(value) && value.split("/").every((part) => part !== "" && part !== "." && part !== "..");
481
+ }
482
+ function validateTourImageSource(value, label) {
483
+ if (value === void 0) return;
484
+ if (!record(value) || Object.keys(value).some((key) => !["file", "revision", "alt"].includes(key)) || !validPath(value.file) || !["base", "head"].includes(String(value.revision)) || typeof value.alt !== "string" || !value.alt.trim()) {
485
+ throw new Error(`${label} must contain a repository-relative file, base/head revision, and non-empty alt text.`);
486
+ }
487
+ }
488
+ function validateTourImage(value, label) {
489
+ if (value === void 0) return;
490
+ if (!record(value) || !validPath(value.path) || typeof value.alt !== "string" || !value.alt.trim() || typeof value.revision !== "string" || !/^[a-f0-9]{40,64}$/.test(value.revision) || typeof value.dataUrl !== "string" || !value.dataUrl.startsWith("data:image/png;base64,")) {
491
+ throw new Error(`${label} must contain pinned, embedded PNG evidence and alt text.`);
492
+ }
493
+ const base64 = value.dataUrl.slice(22);
494
+ const decodedBytes = base64.length / 4 * 3 - (base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0);
495
+ if (decodedBytes > MAX_TOUR_IMAGE_BYTES || base64.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(base64)) throw new Error(`${label} must be a PNG of at most 8 MiB.`);
496
+ const header = Uint8Array.from(atob(base64.slice(0, 44)), (character) => character.charCodeAt(0));
497
+ validatePngHeader(header, label);
498
+ }
499
+ function validatePngHeader(bytes, label) {
500
+ const signature = [137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82];
501
+ if (bytes.length < 33 || signature.some((byte, index) => bytes[index] !== byte)) {
502
+ throw new Error(`${label} must be a PNG with an IHDR header.`);
503
+ }
504
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
505
+ const width = view.getUint32(16), height = view.getUint32(20);
506
+ if (!width || !height || width > 16384 || height > 16384 || width * height > 64 * 1024 * 1024) {
507
+ throw new Error(`${label} exceeds the PNG dimension limit.`);
508
+ }
509
+ }
510
+
474
511
  // src/changeTour.ts
475
512
  var import_child_process3 = require("child_process");
476
513
 
514
+ // src/tourFraming.ts
515
+ function validateTourFraming(value, version, path) {
516
+ for (const key of ["opening", "conclusion"]) {
517
+ const passage = value[key];
518
+ if (passage === void 0) continue;
519
+ if (Number(version) < 5) throw new Error(`${path}.${key} requires version 5.`);
520
+ if (!passage || typeof passage !== "object" || Array.isArray(passage)) {
521
+ throw new Error(`${path}.${key} must be an object.`);
522
+ }
523
+ const fields = passage;
524
+ for (const field of Object.keys(fields)) {
525
+ if (!["title", "summary", "bullets"].includes(field)) throw new Error(`${path}.${key}.${field} is unsupported.`);
526
+ }
527
+ for (const field of ["title", "summary"]) requireProse(fields[field], `${path}.${key}.${field}`);
528
+ if (fields.bullets !== void 0) {
529
+ if (!Array.isArray(fields.bullets)) throw new Error(`${path}.${key}.bullets must be an array.`);
530
+ fields.bullets.forEach((bullet, index) => requireProse(bullet, `${path}.${key}.bullets[${index}]`));
531
+ }
532
+ }
533
+ }
534
+ function validateOverviewPurpose(value, version, path) {
535
+ if (value === void 0) return;
536
+ if (version < 5) {
537
+ if (value && typeof value === "object" && "purpose" in value) throw new Error(`${path}.purpose requires version 5.`);
538
+ return;
539
+ }
540
+ requireProse(value == null ? void 0 : value.purpose, `${path}.purpose`);
541
+ }
542
+ function requireProse(value, path) {
543
+ if (typeof value !== "string" || !value.trim()) throw new Error(`${path} must be a non-empty string.`);
544
+ }
545
+
546
+ // src/tourDetails.ts
547
+ function validateChangeTourDetails(value, path) {
548
+ if (value === void 0) return;
549
+ if (!isRecord(value)) throw new Error(`${path} must be an object.`);
550
+ requireOnlyKeys(value, ["audience"], path);
551
+ validateAudience(value.audience, `${path}.audience`);
552
+ }
553
+ function validateAudience(value, path) {
554
+ if (value === void 0) return;
555
+ if (!isRecord(value)) throw new Error(`${path} must be an object.`);
556
+ requireOnlyKeys(value, [
557
+ "codebase",
558
+ "language",
559
+ "domain",
560
+ "readingDemand",
561
+ "purpose",
562
+ "scope",
563
+ "estimatedMinutes"
564
+ ], path);
565
+ if (typeof value.codebase !== "string" || !["none", "some", "maintainer"].includes(value.codebase)) {
566
+ throw new Error(`${path}.codebase must be none, some, or maintainer.`);
567
+ }
568
+ if (typeof value.language !== "string" || !["none", "basic", "fluent", "advanced"].includes(value.language)) {
569
+ throw new Error(`${path}.language must be none, basic, fluent, or advanced.`);
570
+ }
571
+ if (value.domain !== void 0) requireStringArray(value.domain, `${path}.domain`);
572
+ if (typeof value.readingDemand !== "string" || !["general", "technical", "specialist"].includes(value.readingDemand)) {
573
+ throw new Error(`${path}.readingDemand must be general, technical, or specialist.`);
574
+ }
575
+ if (typeof value.purpose !== "string" || !["overview", "explanation", "review"].includes(value.purpose)) {
576
+ throw new Error(`${path}.purpose must be overview, explanation, or review.`);
577
+ }
578
+ requireString(value.scope, `${path}.scope`);
579
+ if (value.estimatedMinutes !== void 0 && (typeof value.estimatedMinutes !== "number" || !Number.isFinite(value.estimatedMinutes) || value.estimatedMinutes <= 0)) {
580
+ throw new Error(`${path}.estimatedMinutes must be a finite positive number.`);
581
+ }
582
+ }
583
+ function isRecord(value) {
584
+ return typeof value === "object" && value !== null && !Array.isArray(value);
585
+ }
586
+ function requireString(value, path) {
587
+ if (typeof value !== "string" || value.trim().length === 0) {
588
+ throw new Error(`${path} must be a non-empty string.`);
589
+ }
590
+ }
591
+ function requireStringArray(value, path) {
592
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim().length === 0)) {
593
+ throw new Error(`${path} must be an array of strings.`);
594
+ }
595
+ }
596
+ function requireOnlyKeys(value, allowed, path) {
597
+ const unexpected = Object.keys(value).filter((key) => !allowed.includes(key));
598
+ if (unexpected.length > 0) {
599
+ throw new Error(`${path} contains unknown field${unexpected.length === 1 ? "" : "s"}: ${unexpected.join(", ")}`);
600
+ }
601
+ }
602
+
477
603
  // src/changeTourSource.ts
478
- var CHANGE_TOUR_SOURCE_VERSION = 3;
604
+ var CHANGE_TOUR_SOURCE_VERSION = 5;
479
605
  function parseChangeTourSource(value) {
480
- if (isRecord(value) && Number.isInteger(value.version) && Number(value.version) > CHANGE_TOUR_SOURCE_VERSION) {
606
+ if (isRecord2(value) && Number.isInteger(value.version) && Number(value.version) > CHANGE_TOUR_SOURCE_VERSION) {
481
607
  throw new Error(
482
608
  `This tour uses source format version ${value.version}, but this version of Bygone supports up to version ${CHANGE_TOUR_SOURCE_VERSION}. Upgrade Bygone to open it.`
483
609
  );
484
610
  }
485
- if (!isRecord(value) || value.version !== 1 && value.version !== 2 && value.version !== CHANGE_TOUR_SOURCE_VERSION) {
611
+ if (isRecord2(value) && value.version === 3) {
612
+ throw new Error("Tour source format version 3 is retired. Remove the review block, set source version to 4, and recompile the tour.");
613
+ }
614
+ if (!isRecord2(value) || value.version !== 1 && value.version !== 2 && value.version !== 4 && value.version !== CHANGE_TOUR_SOURCE_VERSION) {
486
615
  throw new Error("Unsupported or missing change-tour source version.");
487
616
  }
488
- requireOnlyKeys(value, [
617
+ requireOnlyKeys2(value, [
489
618
  "version",
490
619
  "title",
491
620
  "windowTitle",
@@ -496,62 +625,65 @@ function parseChangeTourSource(value) {
496
625
  "chapters",
497
626
  "tours",
498
627
  "coverage",
499
- "review"
628
+ "opening",
629
+ "conclusion",
630
+ "details"
500
631
  ], "source");
632
+ validateTourFraming(value, value.version, "source");
501
633
  optionalString(value.title, "title");
502
634
  optionalString(value.windowTitle, "windowTitle");
503
635
  optionalString(value.sourceUrl, "sourceUrl");
504
636
  if (value.range !== void 0) {
505
- if (!isRecord(value.range)) throw new Error("range must be an object.");
506
- requireOnlyKeys(value.range, ["base", "head"], "range");
507
- requireString(value.range.base, "range.base");
508
- requireString(value.range.head, "range.head");
637
+ if (!isRecord2(value.range)) throw new Error("range must be an object.");
638
+ requireOnlyKeys2(value.range, ["base", "head"], "range");
639
+ requireString2(value.range.base, "range.base");
640
+ requireString2(value.range.head, "range.head");
509
641
  }
510
642
  if (value.coverage !== void 0) {
511
- if (!isRecord(value.coverage) || !Array.isArray(value.coverage.exclusions)) {
643
+ if (!isRecord2(value.coverage) || !Array.isArray(value.coverage.exclusions)) {
512
644
  throw new Error("coverage must contain an exclusions array.");
513
645
  }
514
- requireOnlyKeys(value.coverage, ["exclusions"], "coverage");
646
+ requireOnlyKeys2(value.coverage, ["exclusions"], "coverage");
515
647
  for (const [index, exclusion] of value.coverage.exclusions.entries()) {
516
648
  const exclusionPath = `coverage.exclusions[${index}]`;
517
- if (!isRecord(exclusion)) throw new Error(`${exclusionPath} must be an object.`);
518
- requireOnlyKeys(exclusion, ["path", "hunks", "reason"], exclusionPath);
519
- requireString(exclusion.path, `${exclusionPath}.path`);
520
- requireString(exclusion.reason, `${exclusionPath}.reason`);
521
- if (exclusion.hunks !== void 0) requireStringArray(exclusion.hunks, `${exclusionPath}.hunks`);
649
+ if (!isRecord2(exclusion)) throw new Error(`${exclusionPath} must be an object.`);
650
+ requireOnlyKeys2(exclusion, ["path", "hunks", "reason"], exclusionPath);
651
+ requireString2(exclusion.path, `${exclusionPath}.path`);
652
+ requireString2(exclusion.reason, `${exclusionPath}.reason`);
653
+ if (exclusion.hunks !== void 0) requireStringArray2(exclusion.hunks, `${exclusionPath}.hunks`);
522
654
  }
523
655
  }
524
- if (!isRecord(value.anchors)) {
656
+ if (!isRecord2(value.anchors)) {
525
657
  throw new Error("anchors must be an object.");
526
658
  }
527
659
  for (const [id, anchor] of Object.entries(value.anchors)) {
528
- if (!isRecord(anchor)) {
660
+ if (!isRecord2(anchor)) {
529
661
  throw new Error(`anchors.${id} must be an object.`);
530
662
  }
531
- requireOnlyKeys(anchor, ["file", "revision", "contains", "occurrence"], `anchors.${id}`);
532
- requireString(anchor.file, `anchors.${id}.file`);
663
+ requireOnlyKeys2(anchor, ["file", "revision", "contains", "occurrence"], `anchors.${id}`);
664
+ requireString2(anchor.file, `anchors.${id}.file`);
533
665
  if (anchor.revision !== "base" && anchor.revision !== "head") {
534
666
  throw new Error(`anchors.${id}.revision must be base or head.`);
535
667
  }
536
- requireString(anchor.contains, `anchors.${id}.contains`);
668
+ requireString2(anchor.contains, `anchors.${id}.contains`);
537
669
  if (anchor.occurrence !== void 0 && (!Number.isInteger(anchor.occurrence) || Number(anchor.occurrence) < 1)) {
538
670
  throw new Error(`anchors.${id}.occurrence must be a positive integer.`);
539
671
  }
540
672
  }
541
- const rawConnections = Array.isArray(value.connections) ? value.connections : isRecord(value.connections) ? Object.entries(value.connections).map(([id, connection]) => isRecord(connection) ? { id, ...connection } : connection) : null;
673
+ const rawConnections = Array.isArray(value.connections) ? value.connections : isRecord2(value.connections) ? Object.entries(value.connections).map(([id, connection]) => isRecord2(connection) ? { id, ...connection } : connection) : null;
542
674
  if (!rawConnections || !Array.isArray(value.chapters) || value.chapters.length === 0) {
543
675
  throw new Error("connections must be an array or object, and chapters must be a non-empty array.");
544
676
  }
545
677
  const connectionIds = /* @__PURE__ */ new Set();
546
678
  for (const [index, connection] of rawConnections.entries()) {
547
- if (!isRecord(connection)) {
679
+ if (!isRecord2(connection)) {
548
680
  throw new Error(`connections[${index}] must be an object.`);
549
681
  }
550
- requireOnlyKeys(connection, ["id", "from", "to", "label"], `connections[${index}]`);
551
- requireString(connection.id, `connections[${index}].id`);
552
- requireString(connection.from, `connections[${index}].from`);
553
- requireString(connection.to, `connections[${index}].to`);
554
- requireString(connection.label, `connections[${index}].label`);
682
+ requireOnlyKeys2(connection, ["id", "from", "to", "label"], `connections[${index}]`);
683
+ requireString2(connection.id, `connections[${index}].id`);
684
+ requireString2(connection.from, `connections[${index}].from`);
685
+ requireString2(connection.to, `connections[${index}].to`);
686
+ requireString2(connection.label, `connections[${index}].label`);
555
687
  const { id, from, to } = connection;
556
688
  if (!value.anchors[from] || !value.anchors[to]) {
557
689
  throw new Error(`Connection ${id} references an unknown anchor.`);
@@ -561,7 +693,7 @@ function parseChangeTourSource(value) {
561
693
  }
562
694
  connectionIds.add(id);
563
695
  }
564
- const authoredWalkthroughSteps = validateSourceChapters(
696
+ validateSourceChapters(
565
697
  value.chapters,
566
698
  "chapters",
567
699
  value.version,
@@ -569,10 +701,14 @@ function parseChangeTourSource(value) {
569
701
  connectionIds,
570
702
  "root"
571
703
  );
704
+ if (value.details !== void 0) {
705
+ if (value.version < 5) throw new Error("source.details requires version 5.");
706
+ validateChangeTourDetails(value.details, "details");
707
+ }
572
708
  if (value.tours !== void 0) {
573
- if (value.version !== 3) throw new Error("Independent tours require version 3.");
574
- if (!isRecord(value.tours)) throw new Error("tours must be an object.");
575
- requireOnlyKeys(value.tours, ["historical", "deconstructed"], "tours");
709
+ if (value.version < 4) throw new Error("Independent tours require version 4.");
710
+ if (!isRecord2(value.tours)) throw new Error("tours must be an object.");
711
+ requireOnlyKeys2(value.tours, ["historical", "deconstructed"], "tours");
576
712
  if (value.tours.historical === void 0 && value.tours.deconstructed === void 0) {
577
713
  throw new Error("tours must contain a historical or deconstructed tour.");
578
714
  }
@@ -580,15 +716,16 @@ function parseChangeTourSource(value) {
580
716
  const tour = value.tours[mode];
581
717
  if (tour === void 0) continue;
582
718
  const path = `tours.${mode}`;
583
- if (!isRecord(tour)) throw new Error(`${path} must be an object.`);
584
- requireOnlyKeys(tour, ["chapters"], path);
719
+ if (!isRecord2(tour)) throw new Error(`${path} must be an object.`);
720
+ requireOnlyKeys2(tour, ["chapters", "opening", "conclusion", "details"], path);
721
+ validateTourFraming(tour, value.version, path);
585
722
  validateSourceChapters(tour.chapters, `${path}.chapters`, value.version, value.anchors, connectionIds, mode);
723
+ if (tour.details !== void 0) {
724
+ if (value.version < 5) throw new Error(`${path}.details requires source version 5.`);
725
+ validateChangeTourDetails(tour.details, `${path}.details`);
726
+ }
586
727
  }
587
728
  }
588
- if (value.review !== void 0) {
589
- if (value.version !== 3) throw new Error("Review notes require version 3.");
590
- validateChangeTourReview(value.review, { authoredWalkthroughSteps });
591
- }
592
729
  return { ...value, connections: rawConnections };
593
730
  }
594
731
  function validateSourceChapters(chaptersValue, chaptersPath, version, anchors, connectionIds, mode) {
@@ -597,27 +734,31 @@ function validateSourceChapters(chaptersValue, chaptersPath, version, anchors, c
597
734
  }
598
735
  const chapterIds = /* @__PURE__ */ new Set();
599
736
  const sceneIds = /* @__PURE__ */ new Set();
600
- const authoredWalkthroughSteps = /* @__PURE__ */ new Map();
601
737
  for (const [chapterIndex, chapter] of chaptersValue.entries()) {
602
738
  const chapterPath = `${chaptersPath}[${chapterIndex}]`;
603
- if (!isRecord(chapter) || !Array.isArray(chapter.scenes) || chapter.scenes.length === 0) {
739
+ if (!isRecord2(chapter) || !Array.isArray(chapter.scenes) || chapter.scenes.length === 0) {
604
740
  throw new Error(`${chapterPath} must contain a non-empty scenes array.`);
605
741
  }
606
- requireString(chapter.id, `${chapterPath}.id`);
607
- requireString(chapter.title, `${chapterPath}.title`);
608
- requireOnlyKeys(chapter, ["id", "title", "scenes"], chapterPath);
742
+ requireString2(chapter.id, `${chapterPath}.id`);
743
+ requireString2(chapter.title, `${chapterPath}.title`);
744
+ if (chapter.summary !== void 0) {
745
+ if (version < 5) throw new Error(`${chapterPath}.summary requires source version 5.`);
746
+ requireProse2(chapter.summary, `${chapterPath}.summary`);
747
+ }
748
+ requireOnlyKeys2(chapter, ["id", "title", "summary", "scenes"], chapterPath);
609
749
  if (chapterIds.has(chapter.id)) throw new Error(`Duplicate chapter id: ${chapter.id}`);
610
750
  chapterIds.add(chapter.id);
611
751
  for (const [sceneIndex, scene] of chapter.scenes.entries()) {
612
752
  const path = `${chapterPath}.scenes[${sceneIndex}]`;
613
- if (!isRecord(scene)) throw new Error(`${path} must be an object.`);
614
- requireString(scene.id, `${path}.id`);
615
- requireString(scene.title, `${path}.title`);
753
+ if (!isRecord2(scene)) throw new Error(`${path} must be an object.`);
754
+ requireString2(scene.id, `${path}.id`);
755
+ requireString2(scene.title, `${path}.title`);
616
756
  if (sceneIds.has(scene.id)) throw new Error(`Duplicate scene id: ${scene.id}`);
617
757
  sceneIds.add(scene.id);
618
- if (scene.overview !== void 0 && version !== 3) {
619
- throw new Error(`${path}.overview requires version 3.`);
758
+ if (scene.overview !== void 0 && version < 4) {
759
+ throw new Error(`${path}.overview requires version 4.`);
620
760
  }
761
+ validateOverviewPurpose(scene.overview, version, `${path}.overview`);
621
762
  validateNarrative(scene, path);
622
763
  if (scene.kind === "deconstructed-diff") {
623
764
  if (mode === "historical") {
@@ -633,9 +774,6 @@ function validateSourceChapters(chaptersValue, chaptersPath, version, anchors, c
633
774
  }
634
775
  if (requireRealEvidence || scene.steps !== void 0) {
635
776
  validateWalkthroughSteps(scene, path, anchors, connectionIds);
636
- if (scene.steps !== void 0) {
637
- authoredWalkthroughSteps.set(scene.id, collectStepIds(scene.steps));
638
- }
639
777
  }
640
778
  continue;
641
779
  }
@@ -652,22 +790,20 @@ function validateSourceChapters(chaptersValue, chaptersPath, version, anchors, c
652
790
  if (scene.kind !== void 0 && scene.kind !== "walkthrough") {
653
791
  throw new Error(`${path}.kind must be walkthrough, stacked-diff, or deconstructed-diff.`);
654
792
  }
655
- requireOnlyKeys(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "overview", "steps"], path);
793
+ requireOnlyKeys2(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "overview", "steps"], path);
656
794
  validateSceneOverview(scene.overview, `${path}.overview`, { allowComparison: false });
657
795
  validateWalkthroughSteps(scene, path, anchors, connectionIds);
658
- authoredWalkthroughSteps.set(scene.id, collectStepIds(scene.steps));
659
796
  }
660
797
  }
661
- if (mode === "deconstructed" && !chaptersValue.some((chapter) => isRecord(chapter) && Array.isArray(chapter.scenes) && chapter.scenes.some((scene) => isRecord(scene) && scene.kind === "deconstructed-diff"))) {
798
+ if (mode === "deconstructed" && !chaptersValue.some((chapter) => isRecord2(chapter) && Array.isArray(chapter.scenes) && chapter.scenes.some((scene) => isRecord2(scene) && scene.kind === "deconstructed-diff"))) {
662
799
  throw new Error(`${chaptersPath} must contain at least one deconstructed-diff scene.`);
663
800
  }
664
- return authoredWalkthroughSteps;
665
801
  }
666
802
  function stackedOverviewEndpointIds(scene, path) {
667
803
  if (!Array.isArray(scene.stack)) {
668
804
  throw new Error(`${path}.stack must be an array.`);
669
805
  }
670
- return scene.stack.filter(isRecord).map((entry) => typeof entry.id === "string" ? entry.id : "").filter((id) => id.length > 0);
806
+ return scene.stack.filter(isRecord2).map((entry) => typeof entry.id === "string" ? entry.id : "").filter((id) => id.length > 0);
671
807
  }
672
808
  function deconstructedOverviewEndpointIds(scene, path) {
673
809
  if (!Array.isArray(scene.stages)) {
@@ -675,78 +811,11 @@ function deconstructedOverviewEndpointIds(scene, path) {
675
811
  }
676
812
  return [
677
813
  "explanation-baseline",
678
- ...scene.stages.filter(isRecord).map((stage) => typeof stage.id === "string" ? `explanation-stage-${stage.id}` : "").filter((id) => id.length > 0)
814
+ ...scene.stages.filter(isRecord2).map((stage) => typeof stage.id === "string" ? `explanation-stage-${stage.id}` : "").filter((id) => id.length > 0)
679
815
  ];
680
816
  }
681
- function validateChangeTourReview(value, context = {}) {
682
- if (!isRecord(value)) {
683
- throw new Error("review must be an object.");
684
- }
685
- requireOnlyKeys(value, ["baseOid", "headOid", "items"], "review");
686
- requireFullGitOid(value.baseOid, "review.baseOid");
687
- requireFullGitOid(value.headOid, "review.headOid");
688
- if (!Array.isArray(value.items) || value.items.length === 0) {
689
- throw new Error("review.items must be a non-empty array.");
690
- }
691
- if (context.expectedRange) {
692
- if (normalizeGitOid(value.baseOid) !== normalizeGitOid(context.expectedRange.baseOid)) {
693
- throw new Error(
694
- `Stale review: review.baseOid ${value.baseOid} does not match the resolved range.mergeBaseOid ${context.expectedRange.baseOid}.`
695
- );
696
- }
697
- if (normalizeGitOid(value.headOid) !== normalizeGitOid(context.expectedRange.headOid)) {
698
- throw new Error(
699
- `Stale review: review.headOid ${value.headOid} does not match the resolved range.headOid ${context.expectedRange.headOid}.`
700
- );
701
- }
702
- }
703
- const itemIds = /* @__PURE__ */ new Set();
704
- for (const [index, item] of value.items.entries()) {
705
- const itemPath = `review.items[${index}]`;
706
- if (!isRecord(item)) throw new Error(`${itemPath} must be an object.`);
707
- requireOnlyKeys(item, ["id", "kind", "title", "body", "evidence", "nextCheck"], itemPath);
708
- requireReviewString(item.id, `${itemPath}.id`);
709
- if (itemIds.has(item.id)) throw new Error(`Duplicate review item id: ${item.id}`);
710
- itemIds.add(item.id);
711
- if (typeof item.kind !== "string" || !["concept", "boundary", "tradeoff", "question"].includes(item.kind)) {
712
- throw new Error(`${itemPath}.kind must be concept, boundary, tradeoff, or question.`);
713
- }
714
- requireReviewString(item.title, `${itemPath}.title`);
715
- requireReviewString(item.body, `${itemPath}.body`);
716
- if (!Array.isArray(item.evidence)) {
717
- throw new Error(`${itemPath}.evidence must be an array.`);
718
- }
719
- if (item.kind !== "question" && item.evidence.length === 0) {
720
- throw new Error(`${itemPath}.evidence must contain at least one reference for claims.`);
721
- }
722
- if (item.kind === "question" && item.nextCheck === void 0) {
723
- throw new Error(`${itemPath}.nextCheck must be a non-empty string for question items.`);
724
- }
725
- if (item.nextCheck !== void 0) requireReviewString(item.nextCheck, `${itemPath}.nextCheck`);
726
- for (const [evidenceIndex, evidence] of item.evidence.entries()) {
727
- const evidencePath = `${itemPath}.evidence[${evidenceIndex}]`;
728
- if (!isRecord(evidence)) throw new Error(`${evidencePath} must be an object.`);
729
- requireOnlyKeys(evidence, ["sceneId", "stepId"], evidencePath);
730
- requireReviewString(evidence.sceneId, `${evidencePath}.sceneId`);
731
- requireReviewString(evidence.stepId, `${evidencePath}.stepId`);
732
- if (context.authoredWalkthroughSteps) {
733
- const stepIds = context.authoredWalkthroughSteps.get(evidence.sceneId);
734
- if (!stepIds) {
735
- throw new Error(
736
- `${evidencePath} references unknown authored walkthrough scene ${evidence.sceneId}.`
737
- );
738
- }
739
- if (!stepIds.has(evidence.stepId)) {
740
- throw new Error(
741
- `${evidencePath} references unknown authored walkthrough step ${evidence.sceneId}/${evidence.stepId}.`
742
- );
743
- }
744
- }
745
- }
746
- }
747
- }
748
817
  function validateDeconstructedScene(scene, path) {
749
- requireOnlyKeys(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "overview", "base", "target", "stack", "steps", "stages", "exclusions"], path);
818
+ requireOnlyKeys2(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "overview", "base", "target", "stack", "steps", "stages", "exclusions"], path);
750
819
  optionalString(scene.base, `${path}.base`);
751
820
  optionalString(scene.target, `${path}.target`);
752
821
  if (!Array.isArray(scene.stages) || scene.stages.length === 0 || scene.stages.length > 12) {
@@ -755,11 +824,11 @@ function validateDeconstructedScene(scene, path) {
755
824
  const stageIds = /* @__PURE__ */ new Set();
756
825
  for (const [stageIndex, stage] of scene.stages.entries()) {
757
826
  const stagePath = `${path}.stages[${stageIndex}]`;
758
- if (!isRecord(stage)) throw new Error(`${stagePath} must be an object.`);
759
- requireOnlyKeys(stage, ["id", "title", "narration", "changes"], stagePath);
760
- requireString(stage.id, `${stagePath}.id`);
761
- requireString(stage.title, `${stagePath}.title`);
762
- requireString(stage.narration, `${stagePath}.narration`);
827
+ if (!isRecord2(stage)) throw new Error(`${stagePath} must be an object.`);
828
+ requireOnlyKeys2(stage, ["id", "title", "narration", "changes"], stagePath);
829
+ requireString2(stage.id, `${stagePath}.id`);
830
+ requireString2(stage.title, `${stagePath}.title`);
831
+ requireString2(stage.narration, `${stagePath}.narration`);
763
832
  if (!Array.isArray(stage.changes) || stage.changes.length === 0) {
764
833
  throw new Error(`${stagePath}.changes must be a non-empty array.`);
765
834
  }
@@ -787,16 +856,17 @@ function validateWalkthroughSteps(scene, path, anchors, connectionIds) {
787
856
  const stepIds = /* @__PURE__ */ new Set();
788
857
  for (const [stepIndex, step] of scene.steps.entries()) {
789
858
  const stepPath = `${path}.steps[${stepIndex}]`;
790
- if (!isRecord(step)) throw new Error(`${stepPath} must be an object.`);
791
- requireString(step.id, `${stepPath}.id`);
792
- requireString(step.title, `${stepPath}.title`);
793
- requireString(step.body, `${stepPath}.body`);
794
- requireString(step.focus, `${stepPath}.focus`);
795
- requireOnlyKeys(step, ["id", "title", "body", "focus", "connection", "depth", "requirement"], stepPath);
859
+ if (!isRecord2(step)) throw new Error(`${stepPath} must be an object.`);
860
+ requireString2(step.id, `${stepPath}.id`);
861
+ requireString2(step.title, `${stepPath}.title`);
862
+ requireString2(step.body, `${stepPath}.body`);
863
+ requireString2(step.focus, `${stepPath}.focus`);
864
+ requireOnlyKeys2(step, ["id", "title", "body", "focus", "connection", "depth", "requirement", "image"], stepPath);
796
865
  if (step.depth !== void 0 && !["mentioned", "explained", "contextualized"].includes(String(step.depth))) {
797
866
  throw new Error(`${stepPath}.depth must be mentioned, explained, or contextualized.`);
798
867
  }
799
868
  validateStepRequirement(step.requirement, `${stepPath}.requirement`);
869
+ validateTourImageSource(step.image, `${stepPath}.image`);
800
870
  if (stepIds.has(step.id)) throw new Error(`Duplicate step id in scene ${scene.id}: ${step.id}`);
801
871
  stepIds.add(step.id);
802
872
  if (!anchors[step.focus]) throw new Error(`${stepPath} references unknown anchor ${step.focus}.`);
@@ -807,14 +877,14 @@ function validateWalkthroughSteps(scene, path, anchors, connectionIds) {
807
877
  }
808
878
  }
809
879
  function validateDeconstructedSelection(value, path, requiresReason) {
810
- if (!isRecord(value)) throw new Error(`${path} must be an object.`);
811
- requireOnlyKeys(value, requiresReason ? ["file", "hunks", "reason"] : ["file", "hunks"], path);
812
- requireString(value.file, `${path}.file`);
880
+ if (!isRecord2(value)) throw new Error(`${path} must be an object.`);
881
+ requireOnlyKeys2(value, requiresReason ? ["file", "hunks", "reason"] : ["file", "hunks"], path);
882
+ requireString2(value.file, `${path}.file`);
813
883
  if (!requiresReason || value.hunks !== void 0) {
814
- requireStringArray(value.hunks, `${path}.hunks`);
884
+ requireStringArray2(value.hunks, `${path}.hunks`);
815
885
  if (value.hunks.length === 0) throw new Error(`${path}.hunks must not be empty.`);
816
886
  }
817
- if (requiresReason) requireString(value.reason, `${path}.reason`);
887
+ if (requiresReason) requireString2(value.reason, `${path}.reason`);
818
888
  }
819
889
  function validateRealStack(stack, path, minimum) {
820
890
  if (!Array.isArray(stack) || stack.length < minimum || stack.length > 6) {
@@ -822,42 +892,42 @@ function validateRealStack(stack, path, minimum) {
822
892
  }
823
893
  const ids = /* @__PURE__ */ new Set();
824
894
  for (const [index, entry] of stack.entries()) {
825
- if (!isRecord(entry)) throw new Error(`${path}[${index}] must be an object.`);
826
- requireOnlyKeys(entry, ["id", "ref", "label"], `${path}[${index}]`);
827
- requireString(entry.id, `${path}[${index}].id`);
828
- requireString(entry.ref, `${path}[${index}].ref`);
895
+ if (!isRecord2(entry)) throw new Error(`${path}[${index}] must be an object.`);
896
+ requireOnlyKeys2(entry, ["id", "ref", "label"], `${path}[${index}]`);
897
+ requireString2(entry.id, `${path}[${index}].id`);
898
+ requireString2(entry.ref, `${path}[${index}].ref`);
829
899
  optionalString(entry.label, `${path}[${index}].label`);
830
900
  if (ids.has(entry.id)) throw new Error(`Duplicate stack entry id: ${entry.id}`);
831
901
  ids.add(entry.id);
832
902
  }
833
903
  }
834
904
  function validateStackedScene(scene, path, minimum = 3) {
835
- requireOnlyKeys(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "overview", "stack", "files", "steps"], path);
905
+ requireOnlyKeys2(scene, ["id", "kind", "title", "summary", "bullets", "tags", "takeaway", "overview", "stack", "files", "steps"], path);
836
906
  if (!Array.isArray(scene.stack) || scene.stack.length < minimum || scene.stack.length > 6) {
837
907
  throw new Error(`${path}.stack must contain between ${minimum} and 6 revisions.`);
838
908
  }
839
909
  const stackIds = /* @__PURE__ */ new Set();
840
910
  for (const [index, entry] of scene.stack.entries()) {
841
911
  const entryPath = `${path}.stack[${index}]`;
842
- if (!isRecord(entry)) throw new Error(`${entryPath} must be an object.`);
843
- requireOnlyKeys(entry, ["id", "ref", "label"], entryPath);
844
- requireString(entry.id, `${entryPath}.id`);
845
- requireString(entry.ref, `${entryPath}.ref`);
912
+ if (!isRecord2(entry)) throw new Error(`${entryPath} must be an object.`);
913
+ requireOnlyKeys2(entry, ["id", "ref", "label"], entryPath);
914
+ requireString2(entry.id, `${entryPath}.id`);
915
+ requireString2(entry.ref, `${entryPath}.ref`);
846
916
  optionalString(entry.label, `${entryPath}.label`);
847
917
  if (stackIds.has(entry.id)) throw new Error(`Duplicate stack entry id: ${entry.id}`);
848
918
  stackIds.add(entry.id);
849
919
  }
850
- if (scene.files !== void 0) requireStringArray(scene.files, `${path}.files`);
920
+ if (scene.files !== void 0) requireStringArray2(scene.files, `${path}.files`);
851
921
  if (!Array.isArray(scene.steps) || scene.steps.length === 0) throw new Error(`${path}.steps must be a non-empty array.`);
852
922
  const stepIds = /* @__PURE__ */ new Set();
853
923
  for (const [index, step] of scene.steps.entries()) {
854
924
  const stepPath = `${path}.steps[${index}]`;
855
- if (!isRecord(step)) throw new Error(`${stepPath} must be an object.`);
856
- requireOnlyKeys(step, ["id", "title", "body", "file", "pair", "side", "lines"], stepPath);
857
- requireString(step.id, `${stepPath}.id`);
858
- requireString(step.title, `${stepPath}.title`);
859
- requireString(step.body, `${stepPath}.body`);
860
- requireString(step.file, `${stepPath}.file`);
925
+ if (!isRecord2(step)) throw new Error(`${stepPath} must be an object.`);
926
+ requireOnlyKeys2(step, ["id", "title", "body", "file", "pair", "side", "lines"], stepPath);
927
+ requireString2(step.id, `${stepPath}.id`);
928
+ requireString2(step.title, `${stepPath}.title`);
929
+ requireString2(step.body, `${stepPath}.body`);
930
+ requireString2(step.file, `${stepPath}.file`);
861
931
  const stepId = step.id;
862
932
  if (stepIds.has(stepId)) throw new Error(`Duplicate step id in scene ${scene.id}: ${stepId}`);
863
933
  stepIds.add(stepId);
@@ -865,8 +935,8 @@ function validateStackedScene(scene, path, minimum = 3) {
865
935
  throw new Error(`${stepPath}.pair must contain two stack entry ids.`);
866
936
  }
867
937
  const pair = step.pair;
868
- const leftIndex = scene.stack.findIndex((entry) => isRecord(entry) && entry.id === pair[0]);
869
- const rightIndex = scene.stack.findIndex((entry) => isRecord(entry) && entry.id === pair[1]);
938
+ const leftIndex = scene.stack.findIndex((entry) => isRecord2(entry) && entry.id === pair[0]);
939
+ const rightIndex = scene.stack.findIndex((entry) => isRecord2(entry) && entry.id === pair[1]);
870
940
  if (leftIndex < 0 || rightIndex !== leftIndex + 1) throw new Error(`${stepPath}.pair must reference adjacent stack entries in order.`);
871
941
  if (step.side !== void 0 && step.side !== "left" && step.side !== "right") throw new Error(`${stepPath}.side must be left or right.`);
872
942
  if (step.lines !== void 0 && (!Array.isArray(step.lines) || step.lines.length !== 2 || step.lines.some((line) => !Number.isInteger(line) || Number(line) < 1) || Number(step.lines[1]) < Number(step.lines[0]))) {
@@ -876,10 +946,10 @@ function validateStackedScene(scene, path, minimum = 3) {
876
946
  }
877
947
  function validateStepRequirement(value, path) {
878
948
  if (value === void 0) return;
879
- if (!isRecord(value)) throw new Error(`${path} must be an object.`);
880
- requireOnlyKeys(value, ["id", "text", "status", "source", "confidence"], path);
881
- requireString(value.id, `${path}.id`);
882
- requireString(value.text, `${path}.text`);
949
+ if (!isRecord2(value)) throw new Error(`${path} must be an object.`);
950
+ requireOnlyKeys2(value, ["id", "text", "status", "source", "confidence"], path);
951
+ requireString2(value.id, `${path}.id`);
952
+ requireString2(value.text, `${path}.text`);
883
953
  if (value.status !== void 0 && !["fulfilled", "gap"].includes(String(value.status))) {
884
954
  throw new Error(`${path}.status must be fulfilled or gap.`);
885
955
  }
@@ -890,20 +960,21 @@ function validateStepRequirement(value, path) {
890
960
  }
891
961
  function validateSceneOverview(value, path, options = {}) {
892
962
  if (value === void 0) return;
893
- if (!isRecord(value)) throw new Error(`${path} must be an object.`);
894
- requireOnlyKeys(value, ["kind", "path", "comparison"], path);
963
+ if (!isRecord2(value)) throw new Error(`${path} must be an object.`);
964
+ requireOnlyKeys2(value, ["kind", "path", "comparison", "purpose"], path);
895
965
  if (value.kind !== "directory-diff") {
896
966
  throw new Error(`${path}.kind must be directory-diff.`);
897
967
  }
968
+ if (value.purpose !== void 0) requireString2(value.purpose, `${path}.purpose`);
898
969
  if (value.path !== void 0) validateDirectoryOverviewPath(value.path, `${path}.path`);
899
970
  if (value.comparison === void 0) return;
900
971
  if (options.allowComparison === false) {
901
972
  throw new Error(`${path}.comparison is not supported for walkthrough scenes; use the fixed base-to-head comparison.`);
902
973
  }
903
- if (!isRecord(value.comparison)) throw new Error(`${path}.comparison must be an object.`);
904
- requireOnlyKeys(value.comparison, ["from", "to"], `${path}.comparison`);
905
- requireString(value.comparison.from, `${path}.comparison.from`);
906
- requireString(value.comparison.to, `${path}.comparison.to`);
974
+ if (!isRecord2(value.comparison)) throw new Error(`${path}.comparison must be an object.`);
975
+ requireOnlyKeys2(value.comparison, ["from", "to"], `${path}.comparison`);
976
+ requireString2(value.comparison.from, `${path}.comparison.from`);
977
+ requireString2(value.comparison.to, `${path}.comparison.to`);
907
978
  if (value.comparison.from === value.comparison.to) {
908
979
  throw new Error(`${path}.comparison.from and to must identify distinct endpoints.`);
909
980
  }
@@ -916,7 +987,7 @@ function validateSceneOverview(value, path, options = {}) {
916
987
  }
917
988
  }
918
989
  function validateDirectoryOverviewPath(value, path) {
919
- requireString(value, path);
990
+ requireString2(value, path);
920
991
  if (value.includes("\\")) {
921
992
  throw new Error(`${path} must use a relative POSIX directory path without backslashes.`);
922
993
  }
@@ -927,53 +998,36 @@ function validateDirectoryOverviewPath(value, path) {
927
998
  throw new Error(`${path} must not contain parent-directory traversal.`);
928
999
  }
929
1000
  }
930
- function collectStepIds(value) {
931
- const ids = /* @__PURE__ */ new Set();
932
- if (!Array.isArray(value)) return ids;
933
- for (const step of value) {
934
- if (isRecord(step) && typeof step.id === "string") ids.add(step.id);
935
- }
936
- return ids;
937
- }
938
1001
  function validateNarrative(value, path) {
939
- requireString(value.summary, `${path}.summary`);
940
- requireStringArray(value.bullets, `${path}.bullets`);
941
- requireStringArray(value.tags, `${path}.tags`);
942
- requireString(value.takeaway, `${path}.takeaway`);
1002
+ requireString2(value.summary, `${path}.summary`);
1003
+ requireStringArray2(value.bullets, `${path}.bullets`);
1004
+ requireStringArray2(value.tags, `${path}.tags`);
1005
+ requireString2(value.takeaway, `${path}.takeaway`);
943
1006
  }
944
- function isRecord(value) {
1007
+ function isRecord2(value) {
945
1008
  return typeof value === "object" && value !== null && !Array.isArray(value);
946
1009
  }
947
- function requireString(value, path) {
1010
+ function requireString2(value, path) {
948
1011
  if (typeof value !== "string" || value.length === 0) {
949
1012
  throw new Error(`${path} must be a non-empty string.`);
950
1013
  }
951
1014
  }
952
- function requireFullGitOid(value, path) {
953
- requireString(value, path);
954
- if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value)) {
955
- throw new Error(`${path} must be a full 40- or 64-character hexadecimal Git OID.`);
1015
+ function optionalString(value, path) {
1016
+ if (value !== void 0) {
1017
+ requireString2(value, path);
956
1018
  }
957
1019
  }
958
- function requireReviewString(value, path) {
959
- if (typeof value !== "string" || value.trim().length === 0) {
1020
+ function requireProse2(value, path) {
1021
+ if (typeof value !== "string" || !value.trim()) {
960
1022
  throw new Error(`${path} must be a non-empty string.`);
961
1023
  }
962
1024
  }
963
- function normalizeGitOid(value) {
964
- return value.toLowerCase();
965
- }
966
- function optionalString(value, path) {
967
- if (value !== void 0) {
968
- requireString(value, path);
969
- }
970
- }
971
- function requireStringArray(value, path) {
1025
+ function requireStringArray2(value, path) {
972
1026
  if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
973
1027
  throw new Error(`${path} must be an array of strings.`);
974
1028
  }
975
1029
  }
976
- function requireOnlyKeys(value, allowed, path) {
1030
+ function requireOnlyKeys2(value, allowed, path) {
977
1031
  const unexpected = Object.keys(value).filter((key) => !allowed.includes(key));
978
1032
  if (unexpected.length > 0) {
979
1033
  throw new Error(`${path} contains unknown field${unexpected.length === 1 ? "" : "s"}: ${unexpected.join(", ")}`);
@@ -981,37 +1035,42 @@ function requireOnlyKeys(value, allowed, path) {
981
1035
  }
982
1036
 
983
1037
  // src/changeTourManifest.ts
984
- var CHANGE_TOUR_MANIFEST_VERSION = 3;
985
- function parseChangeTourManifest(value) {
986
- if (isRecord2(value) && Number.isInteger(value.version) && Number(value.version) > CHANGE_TOUR_MANIFEST_VERSION) {
1038
+ var CHANGE_TOUR_MANIFEST_VERSION = 5;
1039
+ function parseChangeTourManifest(value, options = {}) {
1040
+ if (isRecord3(value) && Number.isInteger(value.version) && Number(value.version) > CHANGE_TOUR_MANIFEST_VERSION) {
987
1041
  throw new Error(
988
1042
  `This tour uses manifest format version ${value.version}, but this version of Bygone supports up to version ${CHANGE_TOUR_MANIFEST_VERSION}. Upgrade Bygone to open it.`
989
1043
  );
990
1044
  }
991
- if (!isRecord2(value) || value.version !== 1 && value.version !== 2 && value.version !== CHANGE_TOUR_MANIFEST_VERSION) {
1045
+ if (isRecord3(value) && value.version === 3) {
1046
+ throw new Error("Tour manifest format version 3 is retired. Remove the review block, set source version to 4, and recompile the tour.");
1047
+ }
1048
+ if (!isRecord3(value) || value.version !== 1 && value.version !== 2 && value.version !== 4 && value.version !== CHANGE_TOUR_MANIFEST_VERSION) {
992
1049
  throw new Error(`Unsupported or missing change-tour manifest version.`);
993
1050
  }
994
- requireString2(value.title, "title");
995
- requireString2(value.generatedAt, "generatedAt");
1051
+ if ("review" in value) throw new Error("Change-tour manifests do not support the review field. Put observations in scene and step narrative.");
1052
+ validateTourFraming(value, value.version, "manifest");
1053
+ requireString3(value.title, "title");
1054
+ requireString3(value.generatedAt, "generatedAt");
996
1055
  if (value.windowTitle !== void 0) {
997
- requireString2(value.windowTitle, "windowTitle");
1056
+ requireString3(value.windowTitle, "windowTitle");
998
1057
  }
999
1058
  if (value.sourceUrl !== void 0) {
1000
- requireString2(value.sourceUrl, "sourceUrl");
1059
+ requireString3(value.sourceUrl, "sourceUrl");
1001
1060
  }
1002
- if (!isRecord2(value.range)) {
1061
+ if (!isRecord3(value.range)) {
1003
1062
  throw new Error("Change-tour manifest range must be an object.");
1004
1063
  }
1005
1064
  for (const key of ["baseRef", "headRef", "mergeBaseOid", "headOid"]) {
1006
- requireString2(value.range[key], `range.${key}`);
1065
+ requireString3(value.range[key], `range.${key}`);
1007
1066
  }
1008
- if (!isRecord2(value.summary)) {
1067
+ if (!isRecord3(value.summary)) {
1009
1068
  throw new Error("Change-tour manifest summary must be an object.");
1010
1069
  }
1011
1070
  for (const key of ["changedFiles", "includedScenes", "additions", "deletions", "commitCount"]) {
1012
1071
  requireNonNegativeInteger(value.summary[key], `summary.${key}`);
1013
1072
  }
1014
- requireStringArray2(value.summary.omittedFiles, "summary.omittedFiles");
1073
+ requireStringArray3(value.summary.omittedFiles, "summary.omittedFiles");
1015
1074
  if (!Array.isArray(value.commits) || !Array.isArray(value.chapters) || !Array.isArray(value.scenes)) {
1016
1075
  throw new Error("Change-tour manifest commits, chapters, and scenes must be arrays.");
1017
1076
  }
@@ -1027,12 +1086,13 @@ function parseChangeTourManifest(value) {
1027
1086
  sceneIds.add(candidate.id);
1028
1087
  }
1029
1088
  for (const [index, candidate] of value.chapters.entries()) {
1030
- if (!isRecord2(candidate)) {
1089
+ if (!isRecord3(candidate)) {
1031
1090
  throw new Error(`chapters[${index}] must be an object.`);
1032
1091
  }
1033
- requireString2(candidate.id, `chapters[${index}].id`);
1034
- requireString2(candidate.title, `chapters[${index}].title`);
1035
- requireStringArray2(candidate.sceneIds, `chapters[${index}].sceneIds`);
1092
+ requireString3(candidate.id, `chapters[${index}].id`);
1093
+ requireString3(candidate.title, `chapters[${index}].title`);
1094
+ validateChapterSummary(candidate.summary, value.version, `chapters[${index}].summary`);
1095
+ requireStringArray3(candidate.sceneIds, `chapters[${index}].sceneIds`);
1036
1096
  for (const sceneId of candidate.sceneIds) {
1037
1097
  if (!sceneIds.has(sceneId)) {
1038
1098
  throw new Error(`Chapter references unknown scene id: ${sceneId}`);
@@ -1042,6 +1102,10 @@ function parseChangeTourManifest(value) {
1042
1102
  if (value.summary.includedScenes !== value.scenes.length) {
1043
1103
  throw new Error("summary.includedScenes must match the number of scenes.");
1044
1104
  }
1105
+ if (value.details !== void 0) {
1106
+ if (value.version !== 5) throw new Error("details requires manifest version 5.");
1107
+ validateChangeTourDetails(value.details, "details");
1108
+ }
1045
1109
  const files = value.files === void 0 ? collectLegacyTourFiles(value.scenes) : value.files.map((candidate, index) => {
1046
1110
  validateTourFile(candidate, index);
1047
1111
  return candidate;
@@ -1053,40 +1117,14 @@ function parseChangeTourManifest(value) {
1053
1117
  validateAuthoringCoverage(value.authoringCoverage, value.version);
1054
1118
  }
1055
1119
  if (value.tours !== void 0) {
1056
- if (value.version !== 3) throw new Error("Independent tours require manifest version 3.");
1120
+ if (value.version !== 4 && value.version !== 5) throw new Error("Independent tours require manifest version 4.");
1057
1121
  validateModeTours(value.tours, value.version);
1058
1122
  }
1059
- if (value.version !== 1) validateZoom(value, value.version);
1060
- if (value.review !== void 0) {
1061
- if (value.version !== 3) throw new Error("Review notes require manifest version 3.");
1062
- const evidenceScenes = value.version >= 2 && isRecord2(value.zoom) && isRecord2(value.zoom.final) ? value.zoom.final.scenes : value.scenes;
1063
- validateChangeTourReview(value.review, {
1064
- expectedRange: {
1065
- baseOid: String(value.range.mergeBaseOid),
1066
- headOid: String(value.range.headOid)
1067
- },
1068
- authoredWalkthroughSteps: collectAuthoredWalkthroughStepIds(evidenceScenes)
1069
- });
1070
- }
1123
+ if (value.version !== 1) validateZoom(value, value.version, Boolean(options.exported));
1071
1124
  return { ...value, files };
1072
1125
  }
1073
- function collectAuthoredWalkthroughStepIds(value) {
1074
- const scenes = /* @__PURE__ */ new Map();
1075
- if (!Array.isArray(value)) return scenes;
1076
- for (const scene of value) {
1077
- if (!isRecord2(scene) || scene.kind !== "walkthrough" || !Array.isArray(scene.steps) || typeof scene.id !== "string") {
1078
- continue;
1079
- }
1080
- const stepIds = /* @__PURE__ */ new Set();
1081
- for (const step of scene.steps) {
1082
- if (isRecord2(step) && typeof step.id === "string") stepIds.add(step.id);
1083
- }
1084
- scenes.set(scene.id, stepIds);
1085
- }
1086
- return scenes;
1087
- }
1088
1126
  function validateAuthoringCoverage(value, version) {
1089
- if (!isRecord2(value) || !isRecord2(value.walkthrough) || !Array.isArray(value.explanationAssignments)) {
1127
+ if (!isRecord3(value) || !isRecord3(value.walkthrough) || !Array.isArray(value.explanationAssignments)) {
1090
1128
  throw new Error("authoringCoverage must contain walkthrough and explanationAssignments.");
1091
1129
  }
1092
1130
  const expectedScope = version >= 2 ? "final" : "tour";
@@ -1101,8 +1139,8 @@ function validateAuthoringCoverage(value, version) {
1101
1139
  }
1102
1140
  for (const [index, assignment] of value.explanationAssignments.entries()) {
1103
1141
  const path = `authoringCoverage.explanationAssignments[${index}]`;
1104
- if (!isRecord2(assignment)) throw new Error(`${path} must be an object.`);
1105
- requireString2(assignment.sceneId, `${path}.sceneId`);
1142
+ if (!isRecord3(assignment)) throw new Error(`${path} must be an object.`);
1143
+ requireString3(assignment.sceneId, `${path}.sceneId`);
1106
1144
  requireNonNegativeInteger(assignment.assignedUnits, `${path}.assignedUnits`);
1107
1145
  requireNonNegativeInteger(assignment.excludedUnits, `${path}.excludedUnits`);
1108
1146
  requireNonNegativeInteger(assignment.completeUnits, `${path}.completeUnits`);
@@ -1114,16 +1152,17 @@ function validateAuthoringCoverage(value, version) {
1114
1152
  }
1115
1153
  }
1116
1154
  function validateModeTours(value, version) {
1117
- if (!isRecord2(value)) throw new Error("Change-tour manifest tours must be an object.");
1155
+ if (!isRecord3(value)) throw new Error("Change-tour manifest tours must be an object.");
1118
1156
  if (value.historical === void 0 && value.deconstructed === void 0) {
1119
1157
  throw new Error("Change-tour manifest tours must contain a historical or deconstructed tour.");
1120
1158
  }
1121
1159
  for (const mode of ["historical", "deconstructed"]) {
1122
1160
  const tour = value[mode];
1123
1161
  if (tour === void 0) continue;
1124
- if (!isRecord2(tour) || !Array.isArray(tour.chapters) || !Array.isArray(tour.scenes) || tour.chapters.length === 0 || tour.scenes.length === 0) {
1162
+ if (!isRecord3(tour) || !Array.isArray(tour.chapters) || !Array.isArray(tour.scenes) || tour.chapters.length === 0 || tour.scenes.length === 0) {
1125
1163
  throw new Error(`Change-tour manifest tours.${mode} must contain non-empty chapters and scenes arrays.`);
1126
1164
  }
1165
+ validateTourFraming(tour, version, `tours.${mode}`);
1127
1166
  const sceneIds = /* @__PURE__ */ new Set();
1128
1167
  let hasDeconstructed = false;
1129
1168
  for (const [index, scene] of tour.scenes.entries()) {
@@ -1137,16 +1176,21 @@ function validateModeTours(value, version) {
1137
1176
  throw new Error("Historical tours cannot contain synthetic deconstructed scenes.");
1138
1177
  }
1139
1178
  }
1179
+ if (tour.details !== void 0) {
1180
+ if (version !== 5) throw new Error(`tours.${mode}.details requires manifest version 5.`);
1181
+ validateChangeTourDetails(tour.details, `tours.${mode}.details`);
1182
+ }
1140
1183
  if (mode === "deconstructed" && !hasDeconstructed) {
1141
1184
  throw new Error("Deconstructed tours must contain a constructed deconstructed-diff scene.");
1142
1185
  }
1143
1186
  for (const [index, chapter] of tour.chapters.entries()) {
1144
- if (!isRecord2(chapter)) {
1187
+ if (!isRecord3(chapter)) {
1145
1188
  throw new Error(`tours.${mode}.chapters[${index}] must be an object.`);
1146
1189
  }
1147
- requireString2(chapter.id, `tours.${mode}.chapters[${index}].id`);
1148
- requireString2(chapter.title, `tours.${mode}.chapters[${index}].title`);
1149
- requireStringArray2(chapter.sceneIds, `tours.${mode}.chapters[${index}].sceneIds`);
1190
+ requireString3(chapter.id, `tours.${mode}.chapters[${index}].id`);
1191
+ requireString3(chapter.title, `tours.${mode}.chapters[${index}].title`);
1192
+ validateChapterSummary(chapter.summary, version, `tours.${mode}.chapters[${index}].summary`);
1193
+ requireStringArray3(chapter.sceneIds, `tours.${mode}.chapters[${index}].sceneIds`);
1150
1194
  for (const sceneId of chapter.sceneIds) {
1151
1195
  if (!sceneIds.has(sceneId)) {
1152
1196
  throw new Error(`tours.${mode} chapter references unknown scene id: ${sceneId}`);
@@ -1155,11 +1199,11 @@ function validateModeTours(value, version) {
1155
1199
  }
1156
1200
  }
1157
1201
  }
1158
- function validateZoom(value, version) {
1159
- if (!isRecord2(value.repository) || typeof value.repository.root !== "string" || !value.repository.root) {
1202
+ function validateZoom(value, version, exported) {
1203
+ if (!exported && (!isRecord3(value.repository) || typeof value.repository.root !== "string" || !value.repository.root)) {
1160
1204
  throw new Error("A v2 tour requires its originating repository.root.");
1161
1205
  }
1162
- if (!isRecord2(value.zoom) || !Array.isArray(value.zoom.revisions) || !isRecord2(value.zoom.final)) {
1206
+ if (!isRecord3(value.zoom) || !Array.isArray(value.zoom.revisions) || !isRecord3(value.zoom.final)) {
1163
1207
  throw new Error("A v2 tour requires zoom metadata and real revision evidence.");
1164
1208
  }
1165
1209
  const scenes = value.scenes;
@@ -1204,27 +1248,29 @@ function validateZoom(value, version) {
1204
1248
  finalSceneIds.add(scene.id);
1205
1249
  }
1206
1250
  for (const [index, chapter] of value.zoom.final.chapters.entries()) {
1207
- if (!isRecord2(chapter) || !Array.isArray(chapter.sceneIds)) {
1251
+ if (!isRecord3(chapter) || !Array.isArray(chapter.sceneIds)) {
1208
1252
  throw new Error(`zoom.final.chapters[${index}] must contain sceneIds.`);
1209
1253
  }
1210
- requireString2(chapter.id, `zoom.final.chapters[${index}].id`);
1211
- requireString2(chapter.title, `zoom.final.chapters[${index}].title`);
1254
+ requireString3(chapter.id, `zoom.final.chapters[${index}].id`);
1255
+ requireString3(chapter.title, `zoom.final.chapters[${index}].title`);
1256
+ validateChapterSummary(chapter.summary, version, `zoom.final.chapters[${index}].summary`);
1212
1257
  for (const sceneId of chapter.sceneIds) {
1213
- requireString2(sceneId, `zoom.final.chapters[${index}].sceneIds`);
1258
+ requireString3(sceneId, `zoom.final.chapters[${index}].sceneIds`);
1214
1259
  if (!finalSceneIds.has(sceneId)) throw new Error(`Final tour chapter references unknown scene: ${sceneId}`);
1215
1260
  }
1216
1261
  }
1217
1262
  }
1218
1263
  function validateScene(value, index, version) {
1219
- if (!isRecord2(value) || !["text-diff", "discussion", "walkthrough", "stacked-diff", "deconstructed-diff"].includes(String(value.kind))) {
1264
+ if (!isRecord3(value) || !["text-diff", "discussion", "walkthrough", "stacked-diff", "deconstructed-diff"].includes(String(value.kind))) {
1220
1265
  throw new Error(`scenes[${index}] must be a text-diff, discussion, walkthrough, stacked-diff, or deconstructed-diff scene.`);
1221
1266
  }
1222
1267
  for (const key of ["id", "title"]) {
1223
- requireString2(value[key], `scenes[${index}].${key}`);
1268
+ requireString3(value[key], `scenes[${index}].${key}`);
1224
1269
  }
1225
- if (value.overview !== void 0 && version !== 3) {
1226
- throw new Error(`scenes[${index}].overview requires manifest version 3.`);
1270
+ if (value.overview !== void 0 && version < 4) {
1271
+ throw new Error(`scenes[${index}].overview requires manifest version 4.`);
1227
1272
  }
1273
+ validateOverviewPurpose(value.overview, version, `scenes[${index}].overview`);
1228
1274
  validateNarrative2(value, `scenes[${index}]`);
1229
1275
  if (value.kind === "discussion") {
1230
1276
  validateSceneOverview(value.overview, `scenes[${index}].overview`, { allowComparison: false });
@@ -1237,21 +1283,22 @@ function validateScene(value, index, version) {
1237
1283
  }
1238
1284
  for (const [stepIndex, step] of value.steps.entries()) {
1239
1285
  const path = `scenes[${index}].steps[${stepIndex}]`;
1240
- if (!isRecord2(step) || !isRecord2(step.focus) || !isRecord2(step.diff)) {
1286
+ if (!isRecord3(step) || !isRecord3(step.focus) || !isRecord3(step.diff)) {
1241
1287
  throw new Error(`${path} must contain focus and diff objects.`);
1242
1288
  }
1243
- for (const key of ["id", "title", "body"]) requireString2(step[key], `${path}.${key}`);
1289
+ for (const key of ["id", "title", "body"]) requireString3(step[key], `${path}.${key}`);
1244
1290
  if (step.depth !== void 0 && !["mentioned", "explained", "contextualized"].includes(String(step.depth))) {
1245
1291
  throw new Error(`${path}.depth must be mentioned, explained, or contextualized.`);
1246
1292
  }
1247
1293
  validateStepRequirement(step.requirement, `${path}.requirement`);
1294
+ validateTourImage(step.image, `${path}.image`);
1248
1295
  validateResolvedAnchor(step.focus, `${path}.focus`);
1249
1296
  validateScene(step.diff, index, version);
1250
1297
  if (step.diff.kind !== "text-diff") throw new Error(`${path}.diff must be a text-diff scene.`);
1251
1298
  if (step.connection !== void 0) {
1252
- if (!isRecord2(step.connection)) throw new Error(`${path}.connection must be an object.`);
1253
- requireString2(step.connection.id, `${path}.connection.id`);
1254
- requireString2(step.connection.label, `${path}.connection.label`);
1299
+ if (!isRecord3(step.connection)) throw new Error(`${path}.connection must be an object.`);
1300
+ requireString3(step.connection.id, `${path}.connection.id`);
1301
+ requireString3(step.connection.label, `${path}.connection.label`);
1255
1302
  validateResolvedAnchor(step.connection.from, `${path}.connection.from`);
1256
1303
  validateResolvedAnchor(step.connection.to, `${path}.connection.to`);
1257
1304
  }
@@ -1274,12 +1321,12 @@ function validateScene(value, index, version) {
1274
1321
  }
1275
1322
  validateSceneOverview(value.overview, `scenes[${index}].overview`, { allowComparison: false });
1276
1323
  for (const key of ["path", "leftLabel", "rightLabel", "leftContent", "rightContent"]) {
1277
- requireString2(value[key], `scenes[${index}].${key}`);
1324
+ requireString3(value[key], `scenes[${index}].${key}`);
1278
1325
  }
1279
1326
  if (value.previousPath !== void 0) {
1280
- requireString2(value.previousPath, `scenes[${index}].previousPath`);
1327
+ requireString3(value.previousPath, `scenes[${index}].previousPath`);
1281
1328
  }
1282
- requireString2(value.changeKind, `scenes[${index}].changeKind`);
1329
+ requireString3(value.changeKind, `scenes[${index}].changeKind`);
1283
1330
  requireNonNegativeInteger(value.additions, `scenes[${index}].additions`);
1284
1331
  requireNonNegativeInteger(value.deletions, `scenes[${index}].deletions`);
1285
1332
  if (value.focusChangeIndex !== void 0) {
@@ -1291,8 +1338,8 @@ function validateStackedScene2(value, index) {
1291
1338
  throw new Error(`scenes[${index}].stack must contain between 2 and 6 revisions.`);
1292
1339
  }
1293
1340
  for (const [panelIndex, panel] of value.stack.entries()) {
1294
- if (!isRecord2(panel)) throw new Error(`scenes[${index}].stack[${panelIndex}] must be an object.`);
1295
- for (const key of ["id", "ref", "oid", "label"]) requireString2(panel[key], `scenes[${index}].stack[${panelIndex}].${key}`);
1341
+ if (!isRecord3(panel)) throw new Error(`scenes[${index}].stack[${panelIndex}] must be an object.`);
1342
+ for (const key of ["id", "ref", "oid", "label"]) requireString3(panel[key], `scenes[${index}].stack[${panelIndex}].${key}`);
1296
1343
  }
1297
1344
  validateMultiPanelFiles(value.files, value.stack.length, index);
1298
1345
  validateMultiPanelSteps(value.steps, value.stack.length, index, false);
@@ -1301,33 +1348,33 @@ function validateDeconstructedScene2(value, index) {
1301
1348
  if (value.stageLabel !== "Explanation stages") {
1302
1349
  throw new Error(`scenes[${index}].stageLabel must be Explanation stages.`);
1303
1350
  }
1304
- if (!isRecord2(value.realRange)) throw new Error(`scenes[${index}].realRange must be an object.`);
1351
+ if (!isRecord3(value.realRange)) throw new Error(`scenes[${index}].realRange must be an object.`);
1305
1352
  for (const key of ["baseRef", "targetRef", "baseOid", "targetOid"]) {
1306
- requireString2(value.realRange[key], `scenes[${index}].realRange.${key}`);
1353
+ requireString3(value.realRange[key], `scenes[${index}].realRange.${key}`);
1307
1354
  }
1308
1355
  if (!Array.isArray(value.panels) || value.panels.length < 2 || value.panels.length > 13) {
1309
1356
  throw new Error(`scenes[${index}].panels must contain a baseline and between 1 and 12 stages.`);
1310
1357
  }
1311
1358
  const panels = value.panels;
1312
1359
  for (const [panelIndex, panel] of panels.entries()) {
1313
- if (!isRecord2(panel)) throw new Error(`scenes[${index}].panels[${panelIndex}] must be an object.`);
1314
- for (const key of ["id", "label", "role"]) requireString2(panel[key], `scenes[${index}].panels[${panelIndex}].${key}`);
1360
+ if (!isRecord3(panel)) throw new Error(`scenes[${index}].panels[${panelIndex}] must be an object.`);
1361
+ for (const key of ["id", "label", "role"]) requireString3(panel[key], `scenes[${index}].panels[${panelIndex}].${key}`);
1315
1362
  const expectedRole = panelIndex === 0 ? "baseline" : "stage";
1316
1363
  if (panel.role !== expectedRole) throw new Error(`scenes[${index}].panels[${panelIndex}].role must be ${expectedRole}.`);
1317
- if (panelIndex > 0) requireString2(panel.stageId, `scenes[${index}].panels[${panelIndex}].stageId`);
1364
+ if (panelIndex > 0) requireString3(panel.stageId, `scenes[${index}].panels[${panelIndex}].stageId`);
1318
1365
  }
1319
1366
  validateMultiPanelFiles(value.files, panels.length, index);
1320
1367
  validateMultiPanelSteps(value.steps, panels.length, index, true);
1321
1368
  if (Array.isArray(value.steps)) {
1322
1369
  let previousPairIndex = -1;
1323
1370
  value.steps.forEach((step, stepIndex) => {
1324
- if (!isRecord2(step)) return;
1371
+ if (!isRecord3(step)) return;
1325
1372
  const pairIndex = Number(step.pairIndex);
1326
1373
  if (pairIndex < previousPairIndex) {
1327
1374
  throw new Error(`scenes[${index}].steps must remain grouped in explanation stage order.`);
1328
1375
  }
1329
1376
  previousPairIndex = pairIndex;
1330
- if (step.stageId !== void 0) requireString2(step.stageId, `scenes[${index}].steps[${stepIndex}].stageId`);
1377
+ if (step.stageId !== void 0) requireString3(step.stageId, `scenes[${index}].steps[${stepIndex}].stageId`);
1331
1378
  if (step.stageIndex !== void 0) requireNonNegativeInteger(step.stageIndex, `scenes[${index}].steps[${stepIndex}].stageIndex`);
1332
1379
  if (step.focusIndex !== void 0) requireNonNegativeInteger(step.focusIndex, `scenes[${index}].steps[${stepIndex}].focusIndex`);
1333
1380
  if (step.focusCount !== void 0) requirePositiveInteger(step.focusCount, `scenes[${index}].steps[${stepIndex}].focusCount`);
@@ -1340,14 +1387,14 @@ function validateDeconstructedScene2(value, index) {
1340
1387
  });
1341
1388
  for (let stageIndex = 0; stageIndex < panels.length - 1; stageIndex += 1) {
1342
1389
  const panel = panels[stageIndex + 1];
1343
- const stageSteps = value.steps.filter((step) => isRecord2(step) && Number(step.pairIndex) === stageIndex);
1344
- if (!isRecord2(panel) || stageSteps.length === 0) {
1390
+ const stageSteps = value.steps.filter((step) => isRecord3(step) && Number(step.pairIndex) === stageIndex);
1391
+ if (!isRecord3(panel) || stageSteps.length === 0) {
1345
1392
  throw new Error(`scenes[${index}].steps must cover every explanation stage.`);
1346
1393
  }
1347
1394
  const firstStep = stageSteps[0];
1348
- const fallbackStageId = isRecord2(firstStep) ? firstStep.id : void 0;
1395
+ const fallbackStageId = isRecord3(firstStep) ? firstStep.id : void 0;
1349
1396
  if (stageSteps.some((step) => {
1350
- if (!isRecord2(step)) return true;
1397
+ if (!isRecord3(step)) return true;
1351
1398
  return (step.stageId ?? fallbackStageId) !== panel.stageId;
1352
1399
  })) {
1353
1400
  throw new Error(`scenes[${index}].panels must align with explanation stage ids.`);
@@ -1358,15 +1405,15 @@ function validateDeconstructedScene2(value, index) {
1358
1405
  function validateMultiPanelFiles(value, panelCount, index) {
1359
1406
  if (!Array.isArray(value) || value.length === 0) throw new Error(`scenes[${index}].files must be non-empty.`);
1360
1407
  for (const [fileIndex, file] of value.entries()) {
1361
- if (!isRecord2(file)) throw new Error(`scenes[${index}].files[${fileIndex}] must be an object.`);
1362
- requireString2(file.path, `scenes[${index}].files[${fileIndex}].path`);
1408
+ if (!isRecord3(file)) throw new Error(`scenes[${index}].files[${fileIndex}] must be an object.`);
1409
+ requireString3(file.path, `scenes[${index}].files[${fileIndex}].path`);
1363
1410
  if (!Array.isArray(file.panels) || file.panels.length !== panelCount) {
1364
1411
  throw new Error(`scenes[${index}].files[${fileIndex}].panels must match panel count.`);
1365
1412
  }
1366
1413
  for (const [panelIndex, panel] of file.panels.entries()) {
1367
- if (!isRecord2(panel)) throw new Error(`scenes[${index}].files[${fileIndex}].panels[${panelIndex}] must be an object.`);
1368
- for (const key of ["id", "label", "content"]) requireString2(panel[key], `scenes[${index}].files[${fileIndex}].panels[${panelIndex}].${key}`);
1369
- if (panel.path !== void 0) requireString2(panel.path, `scenes[${index}].files[${fileIndex}].panels[${panelIndex}].path`);
1414
+ if (!isRecord3(panel)) throw new Error(`scenes[${index}].files[${fileIndex}].panels[${panelIndex}] must be an object.`);
1415
+ for (const key of ["id", "label", "content"]) requireString3(panel[key], `scenes[${index}].files[${fileIndex}].panels[${panelIndex}].${key}`);
1416
+ if (panel.path !== void 0) requireString3(panel.path, `scenes[${index}].files[${fileIndex}].panels[${panelIndex}].path`);
1370
1417
  if (typeof panel.exists !== "boolean") throw new Error(`scenes[${index}].files[${fileIndex}].panels[${panelIndex}].exists must be boolean.`);
1371
1418
  }
1372
1419
  }
@@ -1374,12 +1421,12 @@ function validateMultiPanelFiles(value, panelCount, index) {
1374
1421
  function validateMultiPanelSteps(value, panelCount, index, deconstructed) {
1375
1422
  if (!Array.isArray(value) || value.length === 0) throw new Error(`scenes[${index}].steps must be non-empty.`);
1376
1423
  for (const [stepIndex, step] of value.entries()) {
1377
- if (!isRecord2(step)) throw new Error(`scenes[${index}].steps[${stepIndex}] must be an object.`);
1378
- for (const key of ["id", "title", "body", "file", "side"]) requireString2(step[key], `scenes[${index}].steps[${stepIndex}].${key}`);
1424
+ if (!isRecord3(step)) throw new Error(`scenes[${index}].steps[${stepIndex}] must be an object.`);
1425
+ for (const key of ["id", "title", "body", "file", "side"]) requireString3(step[key], `scenes[${index}].steps[${stepIndex}].${key}`);
1379
1426
  requireNonNegativeInteger(step.pairIndex, `scenes[${index}].steps[${stepIndex}].pairIndex`);
1380
1427
  if (Number(step.pairIndex) >= panelCount - 1) throw new Error(`scenes[${index}].steps[${stepIndex}].pairIndex is outside the panels.`);
1381
1428
  if (deconstructed) {
1382
- requireStringArray2(step.introducedHunks, `scenes[${index}].steps[${stepIndex}].introducedHunks`);
1429
+ requireStringArray3(step.introducedHunks, `scenes[${index}].steps[${stepIndex}].introducedHunks`);
1383
1430
  if (step.introducedHunks.length === 0) throw new Error(`scenes[${index}].steps[${stepIndex}].introducedHunks must be non-empty.`);
1384
1431
  }
1385
1432
  if (step.side !== "left" && step.side !== "right") throw new Error(`scenes[${index}].steps[${stepIndex}].side must be left or right.`);
@@ -1388,7 +1435,7 @@ function validateMultiPanelSteps(value, panelCount, index, deconstructed) {
1388
1435
  }
1389
1436
  }
1390
1437
  function validateTourFile(value, index) {
1391
- if (!isRecord2(value)) throw new Error(`files[${index}] must be an object.`);
1438
+ if (!isRecord3(value)) throw new Error(`files[${index}] must be an object.`);
1392
1439
  if (value.kind === "text-diff") {
1393
1440
  validateScene(value, index, 1);
1394
1441
  return;
@@ -1397,9 +1444,9 @@ function validateTourFile(value, index) {
1397
1444
  throw new Error(`files[${index}].kind must be text-diff or omitted.`);
1398
1445
  }
1399
1446
  for (const key of ["id", "title", "path", "changeKind", "reason"]) {
1400
- requireString2(value[key], `files[${index}].${key}`);
1447
+ requireString3(value[key], `files[${index}].${key}`);
1401
1448
  }
1402
- if (value.previousPath !== void 0) requireString2(value.previousPath, `files[${index}].previousPath`);
1449
+ if (value.previousPath !== void 0) requireString3(value.previousPath, `files[${index}].previousPath`);
1403
1450
  requireNonNegativeInteger(value.additions, `files[${index}].additions`);
1404
1451
  requireNonNegativeInteger(value.deletions, `files[${index}].deletions`);
1405
1452
  }
@@ -1419,28 +1466,35 @@ function collectLegacyTourFiles(scenes) {
1419
1466
  return [...files.values()].sort((left, right) => left.path.localeCompare(right.path));
1420
1467
  }
1421
1468
  function validateResolvedAnchor(value, path) {
1422
- if (!isRecord2(value)) throw new Error(`${path} must be an object.`);
1423
- for (const key of ["id", "path", "revision", "excerpt"]) requireString2(value[key], `${path}.${key}`);
1469
+ if (!isRecord3(value)) throw new Error(`${path} must be an object.`);
1470
+ for (const key of ["id", "path", "revision", "excerpt"]) requireString3(value[key], `${path}.${key}`);
1424
1471
  if (value.revision !== "base" && value.revision !== "head") throw new Error(`${path}.revision must be base or head.`);
1425
1472
  requirePositiveInteger(value.startLine, `${path}.startLine`);
1426
1473
  requirePositiveInteger(value.endLine, `${path}.endLine`);
1427
1474
  if (Number(value.endLine) < Number(value.startLine)) throw new Error(`${path}.endLine must not precede startLine.`);
1428
1475
  }
1429
1476
  function validateNarrative2(value, path) {
1430
- requireString2(value.summary, `${path}.summary`);
1431
- requireStringArray2(value.bullets, `${path}.bullets`);
1432
- requireStringArray2(value.tags, `${path}.tags`);
1433
- requireString2(value.takeaway, `${path}.takeaway`);
1477
+ requireString3(value.summary, `${path}.summary`);
1478
+ requireStringArray3(value.bullets, `${path}.bullets`);
1479
+ requireStringArray3(value.tags, `${path}.tags`);
1480
+ requireString3(value.takeaway, `${path}.takeaway`);
1434
1481
  }
1435
- function isRecord2(value) {
1482
+ function validateChapterSummary(value, version, path) {
1483
+ if (value === void 0) return;
1484
+ if (version < 5) throw new Error(`${path} requires manifest version 5.`);
1485
+ if (typeof value !== "string" || !value.trim()) {
1486
+ throw new Error(`${path} must be a non-empty string.`);
1487
+ }
1488
+ }
1489
+ function isRecord3(value) {
1436
1490
  return typeof value === "object" && value !== null && !Array.isArray(value);
1437
1491
  }
1438
- function requireString2(value, path) {
1492
+ function requireString3(value, path) {
1439
1493
  if (typeof value !== "string") {
1440
1494
  throw new Error(`${path} must be a string.`);
1441
1495
  }
1442
1496
  }
1443
- function requireStringArray2(value, path) {
1497
+ function requireStringArray3(value, path) {
1444
1498
  if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
1445
1499
  throw new Error(`${path} must be an array of strings.`);
1446
1500
  }
@@ -1982,7 +2036,7 @@ function diffArrays(oldArr, newArr, callback) {
1982
2036
  }
1983
2037
 
1984
2038
  // src/diffEngine.ts
1985
- var DEFAULT_MAX_INLINE_HIGHLIGHT_LINE_LENGTH = 500;
2039
+ var DEFAULT_MAX_INLINE_HIGHLIGHT_LINE_LENGTH = 2e4;
1986
2040
  var DEFAULT_DIFF_TIMEOUT_MS = 100;
1987
2041
  var MAX_INLINE_HIGHLIGHT_LINE_LENGTH = readPositiveIntegerEnv(
1988
2042
  "BYGONE_MAX_INLINE_HIGHLIGHT_LINE_LENGTH",
@@ -1995,6 +2049,22 @@ function buildTwoWayDiffModel(leftContent, rightContent, options = {}) {
1995
2049
  if (leftContent === rightContent) {
1996
2050
  return buildIdenticalTwoWayDiffModel(leftLines);
1997
2051
  }
2052
+ if (leftLines.length === rightLines.length && leftLines.every((line, index) => line === rightLines[index])) {
2053
+ const model = buildIdenticalTwoWayDiffModel(leftLines);
2054
+ const rawLeft = leftContent.match(/[^\n]*\n|[^\n]+$/g) || [];
2055
+ const rawRight = rightContent.match(/[^\n]*\n|[^\n]+$/g) || [];
2056
+ leftLines.forEach((_, index) => {
2057
+ if (rawLeft[index] === rawRight[index]) return;
2058
+ model.leftLines[index].kind = model.rows[index].left.kind = "removed";
2059
+ model.rightLines[index].kind = model.rows[index].right.kind = "added";
2060
+ model.leftLines[index].inlineDetail = model.rightLines[index].inlineDetail = "line-ending";
2061
+ model.blocks.push(makeDiffBlock("replace", index, index + 1, index, index + 1));
2062
+ });
2063
+ model.blocks = mergeTouchingBlocks(model.blocks);
2064
+ model.hasChanges = true;
2065
+ model.formattingOnly = true;
2066
+ return model;
2067
+ }
1998
2068
  const interned = internDiffLines(leftLines, rightLines);
1999
2069
  const internedChanges = diffArrays(interned.left, interned.right, {
2000
2070
  timeout: options.timeoutMs ?? DIFF_TIMEOUT_MS
@@ -2071,14 +2141,54 @@ function buildTwoWayDiffModel(leftContent, rightContent, options = {}) {
2071
2141
  }
2072
2142
  }
2073
2143
  const hasChanges = rows.some((row) => row.left.kind !== "context" || row.right.kind !== "context");
2074
- return {
2144
+ return markUndetailedReplacements({
2075
2145
  rows,
2076
2146
  leftLines: renderedLeftLines,
2077
2147
  rightLines: renderedRightLines,
2078
- blocks,
2148
+ blocks: mergeTouchingBlocks(blocks),
2149
+ reflows: blocks.filter((block) => block.reflow),
2079
2150
  hasChanges,
2080
2151
  quality: "exact"
2081
- };
2152
+ });
2153
+ }
2154
+ function markUndetailedReplacements(model) {
2155
+ for (const block of model.blocks) {
2156
+ if (block.kind !== "replace") continue;
2157
+ for (const side of ["left", "right"]) {
2158
+ for (let index = block[`${side}Start`]; index < block[`${side}End`]; index++) {
2159
+ const line = model[`${side}Lines`][index];
2160
+ if (line.kind !== "context" && !line.segments) line.inlineDetail = "omitted";
2161
+ }
2162
+ }
2163
+ }
2164
+ return model;
2165
+ }
2166
+ function mergeTouchingBlocks(blocks) {
2167
+ const regions = [];
2168
+ let oneSidedRun = false;
2169
+ for (const original of blocks) {
2170
+ const oneSided = original.kind !== "replace";
2171
+ const previous = regions[regions.length - 1];
2172
+ if (oneSided && oneSidedRun && previous.leftEnd === original.leftStart && previous.rightEnd === original.rightStart) {
2173
+ previous.leftEnd = original.leftEnd;
2174
+ previous.rightEnd = original.rightEnd;
2175
+ if (previous.leftEnd > previous.leftStart && previous.rightEnd > previous.rightStart) {
2176
+ previous.kind = "replace";
2177
+ }
2178
+ } else regions.push({ ...original });
2179
+ oneSidedRun = oneSided;
2180
+ }
2181
+ const merged = [];
2182
+ for (const original of regions) {
2183
+ const block = { ...original };
2184
+ delete block.reflow;
2185
+ const previous = merged[merged.length - 1];
2186
+ if ((previous == null ? void 0 : previous.kind) === block.kind && previous.leftEnd === block.leftStart && previous.rightEnd === block.rightStart) {
2187
+ previous.leftEnd = block.leftEnd;
2188
+ previous.rightEnd = block.rightEnd;
2189
+ } else merged.push(block);
2190
+ }
2191
+ return merged;
2082
2192
  }
2083
2193
  function internDiffLines(leftLines, rightLines) {
2084
2194
  const lineIds = /* @__PURE__ */ new Map();
@@ -2131,14 +2241,14 @@ function buildConservativeTwoWayDiffModel(leftLines, rightLines) {
2131
2241
  } else if (rightLines.length === 0) {
2132
2242
  blockKind = "delete";
2133
2243
  }
2134
- return {
2244
+ return markUndetailedReplacements({
2135
2245
  rows,
2136
2246
  leftLines: renderedLeftLines,
2137
2247
  rightLines: renderedRightLines,
2138
2248
  blocks: rows.length === 0 ? [] : [makeDiffBlock(blockKind, 0, leftLines.length, 0, rightLines.length)],
2139
2249
  hasChanges: rows.length > 0,
2140
2250
  quality: "fallback"
2141
- };
2251
+ });
2142
2252
  }
2143
2253
  function normalizeLines(content) {
2144
2254
  if (content.length === 0) {
@@ -2153,33 +2263,64 @@ function normalizeLines(content) {
2153
2263
  function appendReplacementCandidate(leftLines, rightLines, initialLeftLineNumber, initialRightLineNumber, rows, renderedLeftLines, renderedRightLines, blocks) {
2154
2264
  const aligned = alignReplacementLines(leftLines, rightLines);
2155
2265
  const crediblePairs = aligned.filter((row) => row.left !== void 0 && row.right !== void 0 && hasCredibleLineCorrespondence(row.left, row.right)).length;
2156
- const coherentBlock = hasCoherentBlockCorrespondence(leftLines, rightLines, crediblePairs);
2157
- const normalized = aligned.flatMap((row) => {
2158
- if (row.left === void 0 || row.right === void 0 || hasCredibleLineCorrespondence(row.left, row.right)) {
2266
+ const coherentBlock = !aligned.some((row) => row.reflow) && hasCoherentBlockCorrespondence(leftLines, rightLines, crediblePairs);
2267
+ const candidates = aligned.flatMap((row) => {
2268
+ if (row.reflow || row.left === void 0 || row.right === void 0 || hasCredibleLineCorrespondence(row.left, row.right)) {
2159
2269
  return [row];
2160
2270
  }
2161
2271
  return [{ left: row.left }, { right: row.right }];
2162
2272
  });
2273
+ const normalized = [];
2274
+ for (let index = 0; index < candidates.length; ) {
2275
+ const row = candidates[index];
2276
+ if (row.reflow || row.left !== void 0 && row.right !== void 0) {
2277
+ normalized.push(row);
2278
+ index++;
2279
+ continue;
2280
+ }
2281
+ const start = index;
2282
+ while (index < candidates.length && !candidates[index].reflow && (candidates[index].left === void 0 || candidates[index].right === void 0)) index++;
2283
+ const gap = candidates.slice(start, index);
2284
+ const left = gap.filter((item) => item.left !== void 0);
2285
+ const right = gap.filter((item) => item.right !== void 0);
2286
+ if (left.length === 1 && right.length === 1) {
2287
+ normalized.push({ left: left[0].left, right: right[0].right });
2288
+ } else {
2289
+ for (const item of gap) normalized.push(item);
2290
+ }
2291
+ }
2163
2292
  let leftLineNumber = initialLeftLineNumber;
2164
2293
  let rightLineNumber = initialRightLineNumber;
2294
+ let unmatchedLeft = [];
2295
+ let unmatchedRight = [];
2296
+ const flushUnmatched = () => {
2297
+ applyWrappedReplacementHighlights(unmatchedLeft, unmatchedRight);
2298
+ unmatchedLeft = [];
2299
+ unmatchedRight = [];
2300
+ };
2165
2301
  let blockKind;
2302
+ let blockReflow = false;
2166
2303
  let blockLeftStart = renderedLeftLines.length;
2167
2304
  let blockRightStart = renderedRightLines.length;
2168
2305
  const flushBlock = () => {
2169
2306
  if (!blockKind) return;
2170
- blocks.push(makeDiffBlock(
2307
+ const block = makeDiffBlock(
2171
2308
  blockKind,
2172
2309
  blockLeftStart,
2173
2310
  renderedLeftLines.length,
2174
2311
  blockRightStart,
2175
2312
  renderedRightLines.length
2176
- ));
2313
+ );
2314
+ if (blockReflow) block.reflow = true;
2315
+ blocks.push(block);
2177
2316
  };
2178
- for (const { left, right } of normalized) {
2179
- const nextKind = coherentBlock ? "replace" : left !== void 0 && right !== void 0 ? "replace" : left !== void 0 ? "delete" : "insert";
2180
- if (blockKind !== nextKind) {
2317
+ for (const { left, right, reflow } of normalized) {
2318
+ if (reflow || left !== void 0 && right !== void 0) flushUnmatched();
2319
+ const nextKind = coherentBlock || reflow ? "replace" : left !== void 0 && right !== void 0 ? "replace" : left !== void 0 ? "delete" : "insert";
2320
+ if (blockKind !== nextKind || blockReflow !== Boolean(reflow) || (reflow == null ? void 0 : reflow.start)) {
2181
2321
  flushBlock();
2182
2322
  blockKind = nextKind;
2323
+ blockReflow = Boolean(reflow);
2183
2324
  blockLeftStart = renderedLeftLines.length;
2184
2325
  blockRightStart = renderedRightLines.length;
2185
2326
  }
@@ -2193,21 +2334,30 @@ function appendReplacementCandidate(leftLines, rightLines, initialLeftLineNumber
2193
2334
  renderedRightLine = makeDiffLine("added", right, rightLineNumber);
2194
2335
  renderedRightLines.push(renderedRightLine);
2195
2336
  }
2196
- if (renderedLeftLine && renderedRightLine) {
2337
+ if (reflow) {
2338
+ if (renderedLeftLine) renderedLeftLine.segments = reflow.leftSegments;
2339
+ if (renderedRightLine) renderedRightLine.segments = reflow.rightSegments;
2340
+ } else if (renderedLeftLine && renderedRightLine) {
2197
2341
  applyInlineHighlightPair(renderedLeftLine, renderedRightLine);
2198
2342
  } else if (coherentBlock) {
2199
2343
  if (renderedLeftLine) applyFullLineHighlight(renderedLeftLine);
2200
2344
  if (renderedRightLine) applyFullLineHighlight(renderedRightLine);
2201
2345
  }
2346
+ if (!reflow && (left === void 0 || right === void 0)) {
2347
+ if (renderedLeftLine) unmatchedLeft.push(renderedLeftLine);
2348
+ if (renderedRightLine) unmatchedRight.push(renderedRightLine);
2349
+ }
2202
2350
  rows.push(makeDiffRow(
2203
2351
  left === void 0 ? makePlaceholder() : makeDiffCell("removed", left, leftLineNumber++),
2204
2352
  right === void 0 ? makePlaceholder() : makeDiffCell("added", right, rightLineNumber++)
2205
2353
  ));
2206
2354
  }
2207
2355
  flushBlock();
2356
+ flushUnmatched();
2208
2357
  return { left: leftLineNumber, right: rightLineNumber };
2209
2358
  }
2210
2359
  function hasCredibleLineCorrespondence(left, right) {
2360
+ if (!left.trim() && !right.trim()) return true;
2211
2361
  const score = scoreReplacementLinePair(left, right);
2212
2362
  if (score.eligible) return true;
2213
2363
  const leftAnchor = declarationAnchorKey(left);
@@ -2260,24 +2410,101 @@ var MAX_ALIGNMENT_CHARACTER_CELLS = 5e7;
2260
2410
  var MAX_RARE_TOKEN_OCCURRENCES = 4;
2261
2411
  var MAX_CONTEXTUAL_HUNK_LINES = 8;
2262
2412
  var MAX_CONTEXTUAL_LENGTH_DELTA = 4;
2413
+ function wrappedTextUnits(lines) {
2414
+ const units = [];
2415
+ for (let index = 0; index < lines.length; index++) {
2416
+ const first = lines[index];
2417
+ const paragraph = [first];
2418
+ const start = first.match(/^(\s*)(?:[\w-]+:\s+|[-*+]\s+)(\S.*)$/);
2419
+ if (start && !/^[|>\[{]/.test(start[2])) {
2420
+ while (index + 1 < lines.length) {
2421
+ const next = lines[index + 1];
2422
+ if (!next.trim() || next.length - next.trimStart().length <= start[1].length || /^\s*(?:[\w-]+:|[-*+]\s|\d+[.)]\s|#)/.test(next)) break;
2423
+ paragraph.push(next);
2424
+ index++;
2425
+ }
2426
+ }
2427
+ units.push(paragraph.join("\n"));
2428
+ }
2429
+ return units;
2430
+ }
2263
2431
  function alignReplacementLines(leftLines, rightLines) {
2432
+ if ([...leftLines, ...rightLines].some((line) => line.includes("\n"))) {
2433
+ return alignPhysicalReplacementLines(leftLines, rightLines);
2434
+ }
2435
+ const leftUnits = wrappedTextUnits(leftLines);
2436
+ const rightUnits = wrappedTextUnits(rightLines);
2437
+ if (leftUnits.length === leftLines.length && rightUnits.length === rightLines.length) {
2438
+ return alignPhysicalReplacementLines(leftLines, rightLines);
2439
+ }
2440
+ return alignPhysicalReplacementLines(leftUnits, rightUnits).flatMap((row) => {
2441
+ var _a, _b;
2442
+ const left = ((_a = row.left) == null ? void 0 : _a.split("\n")) || [];
2443
+ const right = ((_b = row.right) == null ? void 0 : _b.split("\n")) || [];
2444
+ if (!left.length || !right.length) {
2445
+ return [...left.map((left2) => ({ left: left2 })), ...right.map((right2) => ({ right: right2 }))];
2446
+ }
2447
+ if (left.length === 1 && right.length === 1) return [row];
2448
+ const whitespace = buildWhitespaceOnlySegments(row.left, row.right);
2449
+ const segments = whitespace || buildInlineSegments(row.left, row.right);
2450
+ const leftSegments = segments && splitSegmentsIntoLines(segments.leftSegments);
2451
+ const rightSegments = segments && splitSegmentsIntoLines(segments.rightSegments);
2452
+ return Array.from({ length: Math.max(left.length, right.length) }, (_, index) => ({
2453
+ left: left[index],
2454
+ right: right[index],
2455
+ reflow: { start: index === 0, leftSegments: leftSegments == null ? void 0 : leftSegments[index], rightSegments: rightSegments == null ? void 0 : rightSegments[index] }
2456
+ }));
2457
+ });
2458
+ }
2459
+ function alignPhysicalReplacementLines(leftLines, rightLines) {
2264
2460
  if (leftLines.length === 0) {
2265
2461
  return rightLines.map((right) => ({ right }));
2266
2462
  }
2267
2463
  if (rightLines.length === 0) {
2268
2464
  return leftLines.map((left) => ({ left }));
2269
2465
  }
2270
- const leftCharacterCount = leftLines.reduce((total, line) => total + line.length, 0);
2271
- const rightCharacterCount = rightLines.reduce((total, line) => total + line.length, 0);
2272
- if (leftCharacterCount * rightCharacterCount > MAX_ALIGNMENT_CHARACTER_CELLS) {
2466
+ let prefix = 0;
2467
+ while (prefix < Math.min(leftLines.length, rightLines.length) && leftLines[prefix].trim() === rightLines[prefix].trim()) prefix++;
2468
+ if (prefix) {
2273
2469
  return [
2274
- ...leftLines.map((left) => ({ left })),
2275
- ...rightLines.map((right) => ({ right }))
2470
+ ...leftLines.slice(0, prefix).map((left, index) => ({ left, right: rightLines[index] })),
2471
+ ...alignReplacementLines(leftLines.slice(prefix), rightLines.slice(prefix))
2276
2472
  ];
2277
2473
  }
2474
+ let leftEnd = 1;
2475
+ let rightEnd = 1;
2476
+ let leftText = leftLines[0].trim().replace(/\s+/g, " ");
2477
+ let rightText = rightLines[0].trim().replace(/\s+/g, " ");
2478
+ while (leftText && rightText && leftText !== rightText) {
2479
+ if (rightText.startsWith(leftText + " ") && leftEnd < leftLines.length) {
2480
+ leftText += " " + leftLines[leftEnd++].trim().replace(/\s+/g, " ");
2481
+ } else if (leftText.startsWith(rightText + " ") && rightEnd < rightLines.length) {
2482
+ rightText += " " + rightLines[rightEnd++].trim().replace(/\s+/g, " ");
2483
+ } else break;
2484
+ }
2485
+ if (leftText === rightText && (leftEnd > 1 || rightEnd > 1) && ![...leftLines, ...rightLines].some((line) => line.includes("\n"))) {
2486
+ const reflow = buildWhitespaceOnlySegments(leftLines.slice(0, leftEnd).join("\n"), rightLines.slice(0, rightEnd).join("\n"));
2487
+ if (reflow) {
2488
+ const leftSegments = splitSegmentsIntoLines(reflow.leftSegments);
2489
+ const rightSegments = splitSegmentsIntoLines(reflow.rightSegments);
2490
+ return [
2491
+ ...Array.from({ length: Math.max(leftEnd, rightEnd) }, (_, index) => ({
2492
+ left: index < leftEnd ? leftLines[index] : void 0,
2493
+ right: index < rightEnd ? rightLines[index] : void 0,
2494
+ reflow: { start: index === 0, leftSegments: leftSegments[index], rightSegments: rightSegments[index] }
2495
+ })),
2496
+ ...alignReplacementLines(leftLines.slice(leftEnd), rightLines.slice(rightEnd))
2497
+ ];
2498
+ }
2499
+ }
2500
+ const leftCharacterCount = leftLines.reduce((total, line) => total + Math.min(line.length, MAX_SCORING_LINE_LENGTH), 0);
2501
+ const rightCharacterCount = rightLines.reduce((total, line) => total + Math.min(line.length, MAX_SCORING_LINE_LENGTH), 0);
2502
+ if (leftCharacterCount * rightCharacterCount > MAX_ALIGNMENT_CHARACTER_CELLS) {
2503
+ return alignLargeReplacementLines(leftLines, rightLines, true);
2504
+ }
2278
2505
  if (leftLines.length === 1 && rightLines.length === 1) {
2279
2506
  const singletonScore = scoreReplacementLinePair(leftLines[0], rightLines[0]);
2280
- if ((singletonScore.eligible || singletonScore.score >= MINIMUM_SINGLE_PAIR_SCORE) && hasCredibleLineCorrespondence(leftLines[0], rightLines[0]) && haveCompatibleLineRoles(leftLines[0], rightLines[0]) && isInformativeLine(normalizeMatchingContent(leftLines[0])) && isInformativeLine(normalizeMatchingContent(rightLines[0]))) {
2507
+ if (!leftLines[0].trim() && !rightLines[0].trim() || (singletonScore.eligible || singletonScore.score >= MINIMUM_SINGLE_PAIR_SCORE) && hasCredibleLineCorrespondence(leftLines[0], rightLines[0]) && haveCompatibleLineRoles(leftLines[0], rightLines[0]) && isInformativeLine(normalizeMatchingContent(leftLines[0])) && isInformativeLine(normalizeMatchingContent(rightLines[0]))) {
2281
2508
  return [{ left: leftLines[0], right: rightLines[0] }];
2282
2509
  }
2283
2510
  return [{ left: leftLines[0] }, { right: rightLines[0] }];
@@ -2339,16 +2566,7 @@ function scoreReplacementLinePair(left, right) {
2339
2566
  const normalizedRight = normalizeMatchingContent(right);
2340
2567
  const leftInformative = isInformativeLine(normalizedLeft);
2341
2568
  const rightInformative = isInformativeLine(normalizedRight);
2342
- if (!leftInformative || !rightInformative || normalizedLeft.length > MAX_SCORING_LINE_LENGTH || normalizedRight.length > MAX_SCORING_LINE_LENGTH) {
2343
- return {
2344
- score: 0,
2345
- eligible: false,
2346
- characterSimilarity: 0,
2347
- tokenSimilarity: 0,
2348
- lengthSimilarity: 0
2349
- };
2350
- }
2351
- if (normalizedLeft === normalizedRight) {
2569
+ if (leftInformative && rightInformative && normalizedLeft === normalizedRight) {
2352
2570
  return {
2353
2571
  score: 1,
2354
2572
  eligible: true,
@@ -2357,14 +2575,30 @@ function scoreReplacementLinePair(left, right) {
2357
2575
  lengthSimilarity: 1
2358
2576
  };
2359
2577
  }
2578
+ if (!leftInformative || !rightInformative) {
2579
+ return {
2580
+ score: 0,
2581
+ eligible: false,
2582
+ characterSimilarity: 0,
2583
+ tokenSimilarity: 0,
2584
+ lengthSimilarity: 0
2585
+ };
2586
+ }
2360
2587
  const leftTokens = tokenizeMatchingContent(normalizedLeft);
2361
2588
  const rightTokens = tokenizeMatchingContent(normalizedRight);
2362
- const characterSimilarity = lineSimilarity(normalizedLeft, normalizedRight);
2589
+ const sample = (text) => text.length <= MAX_SCORING_LINE_LENGTH ? text : [0, Math.floor(text.length / 2) - 300, text.length - 600].map((start) => text.slice(start, start + 600)).join(" ");
2590
+ const characterSimilarity = lineSimilarity(sample(normalizedLeft), sample(normalizedRight));
2363
2591
  const tokenSimilarity = multisetDiceSimilarity(leftTokens, rightTokens);
2364
2592
  const lengthSimilarity = Math.min(normalizedLeft.length, normalizedRight.length) / Math.max(normalizedLeft.length, normalizedRight.length);
2365
2593
  const boundarySimilarity = tokenBoundarySimilarity(leftTokens, rightTokens);
2366
- const score = characterSimilarity * 0.52 + tokenSimilarity * 0.32 + lengthSimilarity * 0.1 + boundarySimilarity * 0.06;
2367
- const hasContentEvidence = characterSimilarity >= 0.52 || tokenSimilarity >= 0.45 || characterSimilarity >= 0.48 && tokenSimilarity >= 0.28;
2594
+ const prefix = (text) => {
2595
+ var _a;
2596
+ return (_a = text.match(/^(?:(?:const|let|var)\s+)?([\p{L}_$][\p{L}\p{N}_$.-]*\s*(?::|=(?!=)))\s*/u)) == null ? void 0 : _a[1].replace(/\s+/g, "");
2597
+ };
2598
+ const leftPrefix = prefix(normalizedLeft);
2599
+ const prefixBonus = leftPrefix && leftPrefix === prefix(normalizedRight) ? 0.12 : 0;
2600
+ const score = Math.min(1, characterSimilarity * 0.52 + tokenSimilarity * 0.32 + lengthSimilarity * 0.1 + boundarySimilarity * 0.06 + prefixBonus);
2601
+ const hasContentEvidence = prefixBonus > 0 || characterSimilarity >= 0.52 || tokenSimilarity >= 0.45 || characterSimilarity >= 0.48 && tokenSimilarity >= 0.28;
2368
2602
  return {
2369
2603
  score,
2370
2604
  eligible: hasContentEvidence && score >= MINIMUM_MATCH_SCORE,
@@ -2514,8 +2748,11 @@ function rankEligibleScores(scores) {
2514
2748
  }
2515
2749
  return { best, second };
2516
2750
  }
2517
- function alignLargeReplacementLines(leftLines, rightLines) {
2518
- const anchors = collectLargeHunkAnchors(leftLines, rightLines);
2751
+ function alignLargeReplacementLines(leftLines, rightLines, exactOnly = false) {
2752
+ const anchors = collectLargeHunkAnchors(leftLines, rightLines, exactOnly);
2753
+ if (anchors.length === 0) {
2754
+ return [...leftLines.map((left) => ({ left })), ...rightLines.map((right) => ({ right }))];
2755
+ }
2519
2756
  const aligned = [];
2520
2757
  let leftStart = 0;
2521
2758
  let rightStart = 0;
@@ -2532,8 +2769,8 @@ function alignLargeReplacementLines(leftLines, rightLines) {
2532
2769
  appendBoundedSegment(aligned, leftLines.slice(leftStart), rightLines.slice(rightStart));
2533
2770
  return aligned;
2534
2771
  }
2535
- function collectLargeHunkAnchors(leftLines, rightLines) {
2536
- const candidatePairs = collectRareTokenCandidatePairs(leftLines, rightLines);
2772
+ function collectLargeHunkAnchors(leftLines, rightLines, exactOnly) {
2773
+ const candidatePairs = collectRareTokenCandidatePairs(leftLines, rightLines, exactOnly);
2537
2774
  if (candidatePairs === null) {
2538
2775
  return [];
2539
2776
  }
@@ -2562,7 +2799,7 @@ function collectLargeHunkAnchors(leftLines, rightLines) {
2562
2799
  const anchors = selectMaximumWeightMonotonicAnchors(confident, rightLines.length);
2563
2800
  return anchors.length <= MAX_LARGE_HUNK_ANCHORS ? anchors : [];
2564
2801
  }
2565
- function collectRareTokenCandidatePairs(leftLines, rightLines) {
2802
+ function collectRareTokenCandidatePairs(leftLines, rightLines, exactOnly) {
2566
2803
  const leftExact = buildLinePostings(leftLines, normalizeMatchingContent);
2567
2804
  const rightExact = buildLinePostings(rightLines, normalizeMatchingContent);
2568
2805
  const pairs = /* @__PURE__ */ new Map();
@@ -2578,6 +2815,9 @@ function collectRareTokenCandidatePairs(leftLines, rightLines) {
2578
2815
  return null;
2579
2816
  }
2580
2817
  }
2818
+ if (exactOnly) {
2819
+ return [...pairs.values()];
2820
+ }
2581
2821
  const leftTokens = buildTokenPostings(leftLines);
2582
2822
  const rightTokens = buildTokenPostings(rightLines);
2583
2823
  for (const [token, leftIndices] of leftTokens) {
@@ -2790,22 +3030,113 @@ function lineSimilarity(left, right) {
2790
3030
  return 2 * commonLength / maxLength;
2791
3031
  }
2792
3032
  function applyInlineHighlightPair(leftLine, rightLine) {
2793
- if (leftLine.content.length > MAX_INLINE_HIGHLIGHT_LINE_LENGTH || rightLine.content.length > MAX_INLINE_HIGHLIGHT_LINE_LENGTH) {
3033
+ const whitespace = buildWhitespaceOnlySegments(leftLine.content, rightLine.content);
3034
+ if (whitespace) {
3035
+ leftLine.segments = whitespace.leftSegments;
3036
+ rightLine.segments = whitespace.rightSegments;
2794
3037
  return;
2795
3038
  }
2796
- const { leftSegments, rightSegments, hasInlineChanges } = buildInlineSegments(leftLine.content, rightLine.content);
2797
- if (hasInlineChanges) {
2798
- leftLine.segments = leftSegments;
2799
- rightLine.segments = rightSegments;
2800
- }
3039
+ const segments = buildInlineSegments(leftLine.content, rightLine.content);
3040
+ if (segments) {
3041
+ leftLine.segments = segments.leftSegments;
3042
+ rightLine.segments = segments.rightSegments;
3043
+ }
3044
+ }
3045
+ function applyWrappedReplacementHighlights(leftLines, rightLines) {
3046
+ if (!leftLines.length || !rightLines.length || leftLines.length === 1 && rightLines.length === 1) return;
3047
+ const length = (lines) => lines.reduce((total, line) => total + line.content.length + 1, -1);
3048
+ if (length(leftLines) > MAX_INLINE_HIGHLIGHT_LINE_LENGTH || length(rightLines) > MAX_INLINE_HIGHLIGHT_LINE_LENGTH) return;
3049
+ const left = leftLines.map((line) => line.content).join("\n");
3050
+ const right = rightLines.map((line) => line.content).join("\n");
3051
+ const segments = buildInlineSegments(left, right);
3052
+ if (!segments) return;
3053
+ const { leftSegments, rightSegments } = segments;
3054
+ const leftParts = splitSegmentsIntoLines(leftSegments);
3055
+ const rightParts = splitSegmentsIntoLines(rightSegments);
3056
+ leftLines.forEach((line, index) => {
3057
+ line.segments = leftParts[index];
3058
+ });
3059
+ rightLines.forEach((line, index) => {
3060
+ line.segments = rightParts[index];
3061
+ });
2801
3062
  }
2802
3063
  function applyFullLineHighlight(line) {
2803
3064
  if (/[^\s]/.test(line.content)) {
2804
3065
  line.segments = [{ kind: line.kind, text: line.content, emphasis: true }];
2805
3066
  }
2806
3067
  }
3068
+ var inlineGraphemes = new Intl.Segmenter(void 0, { granularity: "grapheme" });
3069
+ function trimReplacementEdges(left, right) {
3070
+ if (left === right) return left ? [{ value: left }] : [];
3071
+ const leftChars = Array.from(inlineGraphemes.segment(left), (part) => part.segment);
3072
+ const rightChars = Array.from(inlineGraphemes.segment(right), (part) => part.segment);
3073
+ const limit = Math.min(leftChars.length, rightChars.length);
3074
+ let prefix = 0;
3075
+ let suffix = 0;
3076
+ while (prefix < limit && leftChars[prefix] === rightChars[prefix]) prefix++;
3077
+ while (suffix < limit && leftChars[leftChars.length - suffix - 1] === rightChars[rightChars.length - suffix - 1]) suffix++;
3078
+ if (suffix === limit && prefix < limit) prefix = 0;
3079
+ suffix = Math.min(suffix, limit - prefix);
3080
+ return [
3081
+ { value: leftChars.slice(0, prefix).join("") },
3082
+ { value: leftChars.slice(prefix, leftChars.length - suffix).join(""), removed: true },
3083
+ { value: rightChars.slice(prefix, rightChars.length - suffix).join(""), added: true },
3084
+ { value: suffix ? leftChars.slice(-suffix).join("") : "" }
3085
+ ].filter((change) => change.value.length > 0);
3086
+ }
3087
+ function edgeTrimmedWordChanges(left, right) {
3088
+ const words = diffWordsWithSpace(left, right, { timeout: DIFF_TIMEOUT_MS, maxEditLength: 2e3 });
3089
+ if (!words) return void 0;
3090
+ const boundaries = (text) => /* @__PURE__ */ new Set([
3091
+ ...Array.from(inlineGraphemes.segment(text), (part) => part.index),
3092
+ text.length
3093
+ ]);
3094
+ const leftBoundaries = boundaries(left);
3095
+ const rightBoundaries = boundaries(right);
3096
+ const result = [];
3097
+ let leftOffset = 0;
3098
+ let rightOffset = 0;
3099
+ let pendingLeft = "";
3100
+ let pendingRight = "";
3101
+ for (let index = 0; index < words.length; ) {
3102
+ const word = words[index];
3103
+ if (!word.added && !word.removed) {
3104
+ pendingLeft += word.value;
3105
+ pendingRight += word.value;
3106
+ leftOffset += word.value.length;
3107
+ rightOffset += word.value.length;
3108
+ index++;
3109
+ } else {
3110
+ while (index < words.length && (words[index].added || words[index].removed)) {
3111
+ const change = words[index++];
3112
+ if (change.removed) {
3113
+ pendingLeft += change.value;
3114
+ leftOffset += change.value.length;
3115
+ } else {
3116
+ pendingRight += change.value;
3117
+ rightOffset += change.value.length;
3118
+ }
3119
+ }
3120
+ }
3121
+ if (leftBoundaries.has(leftOffset) && rightBoundaries.has(rightOffset)) {
3122
+ for (const change of trimReplacementEdges(pendingLeft, pendingRight)) {
3123
+ const previous = result[result.length - 1];
3124
+ if (previous && previous.added === change.added && previous.removed === change.removed) {
3125
+ previous.value += change.value;
3126
+ } else {
3127
+ result.push(change);
3128
+ }
3129
+ }
3130
+ pendingLeft = "";
3131
+ pendingRight = "";
3132
+ }
3133
+ }
3134
+ return result;
3135
+ }
2807
3136
  function buildInlineSegments(leftContent, rightContent) {
2808
- const changes = diffWordsWithSpace(leftContent, rightContent);
3137
+ if (leftContent.length > MAX_INLINE_HIGHLIGHT_LINE_LENGTH || rightContent.length > MAX_INLINE_HIGHLIGHT_LINE_LENGTH) return void 0;
3138
+ const changes = edgeTrimmedWordChanges(leftContent, rightContent);
3139
+ if (!changes) return void 0;
2809
3140
  const leftSegments = [];
2810
3141
  const rightSegments = [];
2811
3142
  let hasInlineChanges = false;
@@ -2821,7 +3152,7 @@ function buildInlineSegments(leftContent, rightContent) {
2821
3152
  rightSegments.push(contextSegment);
2822
3153
  continue;
2823
3154
  }
2824
- const emphasis = /[^\s]/.test(value);
3155
+ const emphasis = value.length > 0;
2825
3156
  hasInlineChanges = hasInlineChanges || emphasis;
2826
3157
  if (change.removed) {
2827
3158
  leftSegments.push({
@@ -2844,6 +3175,48 @@ function buildInlineSegments(leftContent, rightContent) {
2844
3175
  hasInlineChanges
2845
3176
  };
2846
3177
  }
3178
+ function buildWhitespaceOnlySegments(left, right) {
3179
+ const leftParts = left.split(/(\S+)/);
3180
+ const rightParts = right.split(/(\S+)/);
3181
+ if (leftParts.length !== rightParts.length || leftParts.some((part, index) => index % 2 === 1 && part !== rightParts[index])) return void 0;
3182
+ const leftSegments = [];
3183
+ const rightSegments = [];
3184
+ const append = (segments, kind, text) => {
3185
+ if (!text) return;
3186
+ const previous = segments[segments.length - 1];
3187
+ if ((previous == null ? void 0 : previous.kind) === kind) previous.text += text;
3188
+ else segments.push({ kind, text, emphasis: kind !== "context" });
3189
+ };
3190
+ leftParts.forEach((part, index) => {
3191
+ const other = rightParts[index];
3192
+ let prefix = 0;
3193
+ if (index > 0) {
3194
+ while (prefix < Math.min(part.length, other.length) && part[prefix] === other[prefix]) prefix++;
3195
+ }
3196
+ let suffix = 0;
3197
+ while (suffix < Math.min(part.length, other.length) - prefix && part[part.length - suffix - 1] === other[other.length - suffix - 1]) suffix++;
3198
+ if (index === 0) {
3199
+ while (prefix < Math.min(part.length, other.length) - suffix && part[prefix] === other[prefix]) prefix++;
3200
+ }
3201
+ append(leftSegments, "context", part.slice(0, prefix));
3202
+ append(rightSegments, "context", other.slice(0, prefix));
3203
+ append(leftSegments, "removed", part.slice(prefix, part.length - suffix));
3204
+ append(rightSegments, "added", other.slice(prefix, other.length - suffix));
3205
+ append(leftSegments, "context", part.slice(part.length - suffix));
3206
+ append(rightSegments, "context", other.slice(other.length - suffix));
3207
+ });
3208
+ return { leftSegments, rightSegments };
3209
+ }
3210
+ function splitSegmentsIntoLines(segments) {
3211
+ const lines = [[]];
3212
+ for (const segment of segments) {
3213
+ segment.text.split("\n").forEach((text, index) => {
3214
+ if (index > 0) lines.push([]);
3215
+ if (text) lines[lines.length - 1].push({ ...segment, text });
3216
+ });
3217
+ }
3218
+ return lines;
3219
+ }
2847
3220
  function makePlaceholder() {
2848
3221
  return {
2849
3222
  kind: "placeholder",
@@ -3093,20 +3466,16 @@ var CHAPTERS = {
3093
3466
  };
3094
3467
  function buildChangeTourManifest(startPath, options = {}) {
3095
3468
  var _a, _b, _c, _d;
3096
- const source = options.source ? parseChangeTourSource(options.source) : void 0;
3469
+ let source = options.source ? parseChangeTourSource(options.source) : void 0;
3097
3470
  const range = resolveBranchReviewRange(
3098
3471
  startPath,
3099
3472
  options.headRef ?? ((_a = source == null ? void 0 : source.range) == null ? void 0 : _a.head),
3100
3473
  options.baseRef ?? ((_b = source == null ? void 0 : source.range) == null ? void 0 : _b.base)
3101
3474
  );
3102
- if (source == null ? void 0 : source.review) {
3103
- validateChangeTourReview(source.review, {
3104
- expectedRange: {
3105
- baseOid: range.mergeBaseOid,
3106
- headOid: range.headOid
3107
- }
3108
- });
3109
- }
3475
+ if (source) source = pinTourSource(source, range.repoRoot, /* @__PURE__ */ new Map([
3476
+ [range.headRef, range.headOid],
3477
+ [range.baseRef, range.baseOid]
3478
+ ]), range.baseOid, range.headOid);
3110
3479
  const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_TOUR_FILE_BYTES;
3111
3480
  const maxLineBytes = options.maxLineBytes ?? DEFAULT_MAX_TOUR_LINE_BYTES;
3112
3481
  const omittedFiles = [];
@@ -3187,7 +3556,7 @@ function buildChangeTourManifest(startPath, options = {}) {
3187
3556
  };
3188
3557
  defaultScenes = [...defaultScenes].sort((left, right) => Number(right.path === attention.entryPath) - Number(left.path === attention.entryPath) || focusRank(left.path) - focusRank(right.path) || left.id.localeCompare(right.id));
3189
3558
  }
3190
- const authored = source ? applySource(source, defaultScenes, range.repoRoot) : options.story ? applyStory(options.story, defaultScenes) : buildGeneratedTourLanding(
3559
+ const authored = source ? applySourceChapters(source, source.chapters, defaultScenes, range.repoRoot, { base: range.mergeBaseOid, head: range.headOid }) : options.story ? applyStory(options.story, defaultScenes) : buildGeneratedTourLanding(
3191
3560
  defaultScenes,
3192
3561
  buildChapters(sceneRecords, new Map(defaultScenes.map((scene) => [scene.path, scene.id]))),
3193
3562
  attention,
@@ -3209,6 +3578,8 @@ function buildChangeTourManifest(startPath, options = {}) {
3209
3578
  version: (source == null ? void 0 : source.version) ?? 1,
3210
3579
  title: (source == null ? void 0 : source.title) || ((_c = options.story) == null ? void 0 : _c.title) || options.title || `${range.headRef} against ${range.baseRef}`,
3211
3580
  windowTitle: source == null ? void 0 : source.windowTitle,
3581
+ opening: source == null ? void 0 : source.opening,
3582
+ conclusion: source == null ? void 0 : source.conclusion,
3212
3583
  sourceUrl: (source == null ? void 0 : source.sourceUrl) || ((_d = options.story) == null ? void 0 : _d.sourceUrl) || options.sourceUrl,
3213
3584
  generatedAt: options.generatedAt || (/* @__PURE__ */ new Date()).toISOString(),
3214
3585
  range: {
@@ -3229,9 +3600,9 @@ function buildChangeTourManifest(startPath, options = {}) {
3229
3600
  files,
3230
3601
  chapters,
3231
3602
  scenes,
3232
- ...(source == null ? void 0 : source.review) ? { review: source.review } : {}
3603
+ ...(source == null ? void 0 : source.details) !== void 0 ? { details: source.details } : {}
3233
3604
  };
3234
- const authoredModeTours = (source == null ? void 0 : source.version) === 3 ? buildAuthoredModeTours(
3605
+ const authoredModeTours = source && source.version >= 4 ? buildAuthoredModeTours(
3235
3606
  source,
3236
3607
  authored,
3237
3608
  defaultScenes,
@@ -3286,10 +3657,7 @@ function buildChangeTourManifest(startPath, options = {}) {
3286
3657
  }
3287
3658
  return parseChangeTourManifest(manifest);
3288
3659
  }
3289
- function applySource(source, defaultScenes, repoRoot) {
3290
- return applySourceChapters(source, source.chapters, defaultScenes, repoRoot);
3291
- }
3292
- function applySourceChapters(source, chaptersToCompile, defaultScenes, repoRoot) {
3660
+ function applySourceChapters(source, chaptersToCompile, defaultScenes, repoRoot, imageRevisions) {
3293
3661
  var _a, _b;
3294
3662
  const available = new Map(defaultScenes.map((scene) => [scene.path, scene]));
3295
3663
  const resolvedAnchors = /* @__PURE__ */ new Map();
@@ -3330,7 +3698,8 @@ function applySourceChapters(source, chaptersToCompile, defaultScenes, repoRoot)
3330
3698
  { ...authoredScene, steps: authoredScene.steps },
3331
3699
  available,
3332
3700
  resolvedAnchors,
3333
- connections
3701
+ connections,
3702
+ (image) => compileTourImage(repoRoot, image, imageRevisions[image.revision])
3334
3703
  );
3335
3704
  finalScenes.push(finalScene);
3336
3705
  finalSceneIds.push(finalScene.id);
@@ -3354,14 +3723,30 @@ function applySourceChapters(source, chaptersToCompile, defaultScenes, repoRoot)
3354
3723
  }
3355
3724
  continue;
3356
3725
  }
3357
- const scene = buildWalkthroughManifestScene(authoredScene, available, resolvedAnchors, connections);
3726
+ const scene = buildWalkthroughManifestScene(
3727
+ authoredScene,
3728
+ available,
3729
+ resolvedAnchors,
3730
+ connections,
3731
+ (image) => compileTourImage(repoRoot, image, imageRevisions[image.revision])
3732
+ );
3358
3733
  scenes.push(scene);
3359
3734
  sceneIds.push(scene.id);
3360
3735
  finalScenes.push(scene);
3361
3736
  finalSceneIds.push(scene.id);
3362
3737
  }
3363
- chapters.push({ id: chapter.id, title: chapter.title, sceneIds });
3364
- finalChapters.push({ id: chapter.id, title: chapter.title, sceneIds: finalSceneIds });
3738
+ chapters.push({
3739
+ id: chapter.id,
3740
+ title: chapter.title,
3741
+ ...chapter.summary === void 0 ? {} : { summary: chapter.summary },
3742
+ sceneIds
3743
+ });
3744
+ finalChapters.push({
3745
+ id: chapter.id,
3746
+ title: chapter.title,
3747
+ ...chapter.summary === void 0 ? {} : { summary: chapter.summary },
3748
+ sceneIds: finalSceneIds
3749
+ });
3365
3750
  }
3366
3751
  return { scenes, chapters, finalScenes, finalChapters };
3367
3752
  }
@@ -3369,18 +3754,41 @@ function buildAuthoredModeTours(source, root, defaultScenes, repoRoot, mergeBase
3369
3754
  var _a, _b;
3370
3755
  const tours = {};
3371
3756
  if ((_a = source.tours) == null ? void 0 : _a.historical) {
3372
- const compiled = applySourceChapters(source, source.tours.historical.chapters, defaultScenes, repoRoot);
3757
+ const compiled = applySourceChapters(source, source.tours.historical.chapters, defaultScenes, repoRoot, { base: mergeBaseOid, head: headOid });
3373
3758
  validateHistoricalStackEndpoints(compiled.scenes, mergeBaseOid, headOid);
3374
- tours.historical = { chapters: compiled.chapters, scenes: compiled.scenes };
3375
- } else if (source.version === 3) {
3759
+ tours.historical = {
3760
+ opening: source.tours.historical.opening,
3761
+ conclusion: source.tours.historical.conclusion,
3762
+ chapters: compiled.chapters,
3763
+ scenes: compiled.scenes,
3764
+ ...source.tours.historical.details !== void 0 ? { details: source.tours.historical.details } : {}
3765
+ };
3766
+ } else if (source.version >= 4) {
3376
3767
  const fallback = buildHistoricalFallback(source.chapters, root);
3377
- if (fallback) tours.historical = fallback;
3768
+ if (fallback) {
3769
+ tours.historical = {
3770
+ ...fallback,
3771
+ ...root.scenes.every((scene) => scene.kind !== "deconstructed-diff") ? { opening: source.opening, conclusion: source.conclusion, details: source.details } : {}
3772
+ };
3773
+ }
3378
3774
  }
3379
3775
  if ((_b = source.tours) == null ? void 0 : _b.deconstructed) {
3380
- const compiled = applySourceChapters(source, source.tours.deconstructed.chapters, defaultScenes, repoRoot);
3381
- tours.deconstructed = { chapters: compiled.chapters, scenes: compiled.scenes };
3382
- } else if (source.version === 3 && source.chapters.some((chapter) => chapter.scenes.some((scene) => scene.kind === "deconstructed-diff"))) {
3383
- tours.deconstructed = { chapters: root.chapters, scenes: root.scenes };
3776
+ const compiled = applySourceChapters(source, source.tours.deconstructed.chapters, defaultScenes, repoRoot, { base: mergeBaseOid, head: headOid });
3777
+ tours.deconstructed = {
3778
+ opening: source.tours.deconstructed.opening,
3779
+ conclusion: source.tours.deconstructed.conclusion,
3780
+ chapters: compiled.chapters,
3781
+ scenes: compiled.scenes,
3782
+ ...source.tours.deconstructed.details !== void 0 ? { details: source.tours.deconstructed.details } : {}
3783
+ };
3784
+ } else if (source.version >= 4 && source.chapters.some((chapter) => chapter.scenes.some((scene) => scene.kind === "deconstructed-diff"))) {
3785
+ tours.deconstructed = {
3786
+ opening: source.opening,
3787
+ conclusion: source.conclusion,
3788
+ details: source.details,
3789
+ chapters: root.chapters,
3790
+ scenes: root.scenes
3791
+ };
3384
3792
  }
3385
3793
  return tours.historical || tours.deconstructed ? tours : void 0;
3386
3794
  }
@@ -3405,7 +3813,14 @@ function buildHistoricalFallback(sourceChapters, root) {
3405
3813
  scenes.push(scene);
3406
3814
  sceneIds.push(scene.id);
3407
3815
  }
3408
- if (sceneIds.length > 0) chapters.push({ id: chapter.id, title: chapter.title, sceneIds });
3816
+ if (sceneIds.length > 0) {
3817
+ chapters.push({
3818
+ id: chapter.id,
3819
+ title: chapter.title,
3820
+ ...chapter.summary === void 0 ? {} : { summary: chapter.summary },
3821
+ sceneIds
3822
+ });
3823
+ }
3409
3824
  }
3410
3825
  return scenes.length > 0 ? { chapters, scenes } : void 0;
3411
3826
  }
@@ -3413,11 +3828,12 @@ function compileSceneOverview(overview, includeComparison = true) {
3413
3828
  if (!overview) return void 0;
3414
3829
  return {
3415
3830
  kind: overview.kind,
3831
+ ...overview.purpose === void 0 ? {} : { purpose: overview.purpose },
3416
3832
  ...overview.path === void 0 ? {} : { path: overview.path },
3417
3833
  ...includeComparison && overview.comparison ? { comparison: { ...overview.comparison } } : {}
3418
3834
  };
3419
3835
  }
3420
- function buildWalkthroughManifestScene(authoredScene, available, resolvedAnchors, connections) {
3836
+ function buildWalkthroughManifestScene(authoredScene, available, resolvedAnchors, connections, resolveImage) {
3421
3837
  const overview = compileSceneOverview(authoredScene.overview, false);
3422
3838
  return {
3423
3839
  id: authoredScene.id,
@@ -3438,6 +3854,7 @@ function buildWalkthroughManifestScene(authoredScene, available, resolvedAnchors
3438
3854
  body: step.body,
3439
3855
  depth: step.depth,
3440
3856
  requirement: step.requirement,
3857
+ ...step.image ? { image: resolveImage(step.image) } : {},
3441
3858
  focus,
3442
3859
  connection: step.connection ? connections.get(step.connection) : void 0,
3443
3860
  diff: { ...diff2, id: `${authoredScene.id}-${step.id}` }
@@ -3550,8 +3967,10 @@ function buildDeconstructedFocusSteps(sceneId, stages, files) {
3550
3967
  });
3551
3968
  return focuses.map((focus, focusIndex) => ({
3552
3969
  id: focusIndex === 0 ? claimDeconstructedStageId(stage.id, usedIds) : claimDeconstructedFocusId(`${stage.id}-focus-${focusIndex + 1}`, reservedIds, usedIds),
3553
- title: focuses.length === 1 ? stage.title : `${stage.title} \xB7 ${focus.file} \xB7 Focus ${focusIndex + 1}/${focuses.length}`,
3554
- body: stage.narration,
3970
+ title: focusIndex === 0 ? stage.title : `${focus.file} \xB7 Evidence ${focusIndex + 1} of ${focuses.length}`,
3971
+ // A stage is one argument supported by multiple code focuses.
3972
+ // Keep its explanation once in the reading/narration sequence.
3973
+ body: focusIndex === 0 ? stage.narration : "",
3555
3974
  file: focus.file,
3556
3975
  pairIndex: stageIndex,
3557
3976
  side: focus.side,
@@ -3854,6 +4273,8 @@ function readGitText(repoRoot, oid, relativePath, maxBytes, maxLineBytes) {
3854
4273
  if (!relativePath) {
3855
4274
  return { kind: "text", content: "" };
3856
4275
  }
4276
+ const size = Number((0, import_child_process3.execFileSync)("git", ["cat-file", "-s", `${oid}:${relativePath}`], { cwd: repoRoot, encoding: "utf8" }).trim());
4277
+ if (size > maxBytes) return { kind: "binary-or-large", content: "" };
3857
4278
  const content = (0, import_child_process3.execFileSync)("git", ["show", `${oid}:${relativePath}`], {
3858
4279
  cwd: repoRoot,
3859
4280
  encoding: "buffer",
@@ -3895,6 +4316,49 @@ function readGitLineStats(repoRoot, baseOid, headOid, filePath, previousPath) {
3895
4316
  binary: additionsText === "-" || deletionsText === "-"
3896
4317
  };
3897
4318
  }
4319
+ function pinTourSource(source, repoRoot, resolved, baseOid, headOid) {
4320
+ const copy = JSON.parse(JSON.stringify(source));
4321
+ const pin = (ref) => {
4322
+ let oid = resolved.get(ref);
4323
+ if (!oid) {
4324
+ oid = runGitText(repoRoot, ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`]);
4325
+ resolved.set(ref, oid);
4326
+ }
4327
+ return oid;
4328
+ };
4329
+ copy.range = { base: baseOid, head: headOid };
4330
+ const chapters = [...copy.chapters, ...Object.values(copy.tours || {}).flatMap((tour) => (tour == null ? void 0 : tour.chapters) || [])];
4331
+ for (const chapter of chapters) for (const scene of chapter.scenes) {
4332
+ if (scene.kind === "stacked-diff") for (const entry of scene.stack) {
4333
+ entry.label ||= entry.ref;
4334
+ entry.ref = pin(entry.ref);
4335
+ }
4336
+ if (scene.kind === "deconstructed-diff") {
4337
+ scene.base = scene.base ? pin(scene.base) : baseOid;
4338
+ scene.target = scene.target ? pin(scene.target) : headOid;
4339
+ }
4340
+ }
4341
+ return copy;
4342
+ }
4343
+ function compileTourImage(repoRoot, source, revision) {
4344
+ const object = `${revision}:${source.file}`;
4345
+ let size;
4346
+ try {
4347
+ size = Number((0, import_child_process3.execFileSync)("git", ["cat-file", "-s", object], {
4348
+ cwd: repoRoot,
4349
+ encoding: "utf8",
4350
+ stdio: ["ignore", "pipe", "pipe"]
4351
+ }).trim());
4352
+ } catch {
4353
+ throw new Error(`Image ${source.file} is unavailable at pinned revision ${revision}.`);
4354
+ }
4355
+ if (!Number.isSafeInteger(size) || size < 33 || size > MAX_TOUR_IMAGE_BYTES) {
4356
+ throw new Error(`Image ${source.file} must be a PNG of at most 8 MiB.`);
4357
+ }
4358
+ const bytes = (0, import_child_process3.execFileSync)("git", ["cat-file", "blob", object], { cwd: repoRoot, maxBuffer: MAX_TOUR_IMAGE_BYTES });
4359
+ validatePngHeader(bytes, source.file);
4360
+ return { path: source.file, revision, alt: source.alt, dataUrl: `data:image/png;base64,${bytes.toString("base64")}` };
4361
+ }
3898
4362
 
3899
4363
  // src/tourCoverage.ts
3900
4364
  var TOUR_COVERAGE_REPORT_VERSION = 2;