@openparachute/vault 0.4.7-rc.1 → 0.4.7-rc.2

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.
@@ -25,6 +25,7 @@ import { tmpdir } from "os";
25
25
 
26
26
  import { SqliteStore } from "./store.js";
27
27
  import {
28
+ CaseCollisionError,
28
29
  emitYamlDoc,
29
30
  exportVaultToDir,
30
31
  importPortableVault,
@@ -1551,4 +1552,250 @@ describe("case-collision detection (vault#327)", async () => {
1551
1552
  expect(lower!.path).toBe("Tabular/budget-2026");
1552
1553
  expect(lower!.content).toBe("month,total\n2026-01,1");
1553
1554
  });
1555
+
1556
+ // ---------------------------------------------------------------------------
1557
+ // failOnCaseCollision — strict mode (vault#327 Phase 2)
1558
+ // ---------------------------------------------------------------------------
1559
+ //
1560
+ // The default behavior (auto-disambiguate) is lossless but silent on the
1561
+ // wire — the CLI didn't surface it before #vault-rc.2. Strict mode is the
1562
+ // opt-in fail-fast path: throws `CaseCollisionError` with every colliding
1563
+ // path enumerated, so the operator can rename one of each pair in the
1564
+ // vault before re-exporting.
1565
+
1566
+ it("failOnCaseCollision throws CaseCollisionError on case-insensitive FS", async () => {
1567
+ await store.createNote("# in Balance", {
1568
+ id: "2025-05-26-09-15-42-aaaaaa",
1569
+ path: "Journal/2025-05-26 Technology in Balance",
1570
+ });
1571
+ await store.createNote("# in balance", {
1572
+ id: "2025-05-26-09-15-42-bbbbbb",
1573
+ path: "Journal/2025-05-26 Technology in balance",
1574
+ });
1575
+
1576
+ const outDir = join(tmpBase, "strict-throw");
1577
+ let thrown: unknown;
1578
+ try {
1579
+ await exportVaultToDir(store, {
1580
+ outDir,
1581
+ vaultName: "test",
1582
+ exportedAt: "2026-05-15T00:00:00.000Z",
1583
+ caseSensitiveOverride: false,
1584
+ failOnCaseCollision: true,
1585
+ });
1586
+ } catch (err) {
1587
+ thrown = err;
1588
+ }
1589
+ expect(thrown).toBeInstanceOf(CaseCollisionError);
1590
+ const err = thrown as CaseCollisionError;
1591
+ expect(err.collisions).toHaveLength(1);
1592
+ expect(err.collisions[0]).toHaveLength(2);
1593
+ const ids = err.collisions[0]!.map((c) => c.note_id).sort();
1594
+ expect(ids).toEqual(["2025-05-26-09-15-42-aaaaaa", "2025-05-26-09-15-42-bbbbbb"]);
1595
+ // Error message names BOTH paths + the actionable instruction.
1596
+ expect(err.message).toContain("Journal/2025-05-26 Technology in Balance");
1597
+ expect(err.message).toContain("Journal/2025-05-26 Technology in balance");
1598
+ expect(err.message).toContain("Rename one of them");
1599
+ // Pre-scan throws BEFORE any per-note file write. The .parachute/
1600
+ // sidecar dir is still created (cheap, idempotent), but no per-note
1601
+ // .md file landed.
1602
+ expect(existsSync(join(outDir, "Journal/2025-05-26 Technology in Balance.md"))).toBe(false);
1603
+ });
1604
+
1605
+ it("failOnCaseCollision is a no-op when FS is case-sensitive", async () => {
1606
+ // Same fixture as above, but force the case-sensitive code path. The
1607
+ // strict flag becomes a no-op — both files land at their canonical
1608
+ // paths, no error.
1609
+ await store.createNote("# in Balance", {
1610
+ id: "2025-05-26-09-15-42-aaaaaa",
1611
+ path: "Journal/2025-05-26 Technology in Balance",
1612
+ });
1613
+ await store.createNote("# in balance", {
1614
+ id: "2025-05-26-09-15-42-bbbbbb",
1615
+ path: "Journal/2025-05-26 Technology in balance",
1616
+ });
1617
+
1618
+ const outDir = join(tmpBase, "strict-cs");
1619
+ const stats = await exportVaultToDir(store, {
1620
+ outDir,
1621
+ vaultName: "test",
1622
+ exportedAt: "2026-05-15T00:00:00.000Z",
1623
+ caseSensitiveOverride: true,
1624
+ failOnCaseCollision: true,
1625
+ });
1626
+ expect(stats.notes).toBe(2);
1627
+ expect(stats.disambiguated_paths).toHaveLength(0);
1628
+ });
1629
+
1630
+ it("failOnCaseCollision is a no-op on case-insensitive FS when nothing collides", async () => {
1631
+ // One note. Strict mode + case-insensitive FS — should not throw,
1632
+ // should ship clean. Pre-scan walks the (single-note) list and
1633
+ // finds no collision groups.
1634
+ await store.createNote("# solo", {
1635
+ id: "2025-05-26-09-15-42-aaaaaa",
1636
+ path: "Journal/Solo Note",
1637
+ });
1638
+
1639
+ const outDir = join(tmpBase, "strict-solo");
1640
+ const stats = await exportVaultToDir(store, {
1641
+ outDir,
1642
+ vaultName: "test",
1643
+ exportedAt: "2026-05-15T00:00:00.000Z",
1644
+ caseSensitiveOverride: false,
1645
+ failOnCaseCollision: true,
1646
+ });
1647
+ expect(stats.notes).toBe(1);
1648
+ expect(stats.disambiguated_paths).toHaveLength(0);
1649
+ expect(existsSync(join(outDir, "Journal/Solo Note.md"))).toBe(true);
1650
+ });
1651
+
1652
+ it("three-way collision lists all three paths in the error", async () => {
1653
+ // Foo.md + foo.md + FOO.md — all share the same lowercased
1654
+ // `(path, ext)` slot. CaseCollisionError.collisions[0] should
1655
+ // include every one of them so the operator sees the full set in
1656
+ // a single error report, not a paint-by-numbers re-export cycle.
1657
+ await store.createNote("# upper-camel", {
1658
+ id: "2025-05-26-09-15-42-aaaaaa",
1659
+ path: "Journal/Foo",
1660
+ });
1661
+ await store.createNote("# lower", {
1662
+ id: "2025-05-26-09-15-42-bbbbbb",
1663
+ path: "Journal/foo",
1664
+ });
1665
+ await store.createNote("# all-caps", {
1666
+ id: "2025-05-26-09-15-42-cccccc",
1667
+ path: "Journal/FOO",
1668
+ });
1669
+
1670
+ const outDir = join(tmpBase, "strict-3way");
1671
+ let thrown: unknown;
1672
+ try {
1673
+ await exportVaultToDir(store, {
1674
+ outDir,
1675
+ vaultName: "test",
1676
+ exportedAt: "2026-05-15T00:00:00.000Z",
1677
+ caseSensitiveOverride: false,
1678
+ failOnCaseCollision: true,
1679
+ });
1680
+ } catch (err) {
1681
+ thrown = err;
1682
+ }
1683
+ expect(thrown).toBeInstanceOf(CaseCollisionError);
1684
+ const err = thrown as CaseCollisionError;
1685
+ expect(err.collisions).toHaveLength(1);
1686
+ expect(err.collisions[0]).toHaveLength(3);
1687
+ const paths = err.collisions[0]!.map((c) => c.path).sort();
1688
+ expect(paths).toEqual(["Journal/FOO", "Journal/Foo", "Journal/foo"]);
1689
+ expect(err.message).toContain("Journal/FOO");
1690
+ expect(err.message).toContain("Journal/Foo");
1691
+ expect(err.message).toContain("Journal/foo");
1692
+ });
1693
+
1694
+ it("multiple independent collision groups all surface", async () => {
1695
+ // Two distinct collision groups: (Bar.md, bar.md) and (Baz.md,
1696
+ // baz.md). Both groups should appear in the error so the operator
1697
+ // doesn't have to fix-rebuild-fix in a loop. Pairs are independent —
1698
+ // resolving one doesn't reveal the other.
1699
+ await store.createNote("# bar-upper", {
1700
+ id: "2025-05-26-09-15-42-aaaaaa",
1701
+ path: "Bar",
1702
+ });
1703
+ await store.createNote("# bar-lower", {
1704
+ id: "2025-05-26-09-15-42-bbbbbb",
1705
+ path: "bar",
1706
+ });
1707
+ await store.createNote("# baz-upper", {
1708
+ id: "2025-05-26-09-15-42-cccccc",
1709
+ path: "Baz",
1710
+ });
1711
+ await store.createNote("# baz-lower", {
1712
+ id: "2025-05-26-09-15-42-dddddd",
1713
+ path: "baz",
1714
+ });
1715
+
1716
+ const outDir = join(tmpBase, "strict-multi-group");
1717
+ let thrown: unknown;
1718
+ try {
1719
+ await exportVaultToDir(store, {
1720
+ outDir,
1721
+ vaultName: "test",
1722
+ exportedAt: "2026-05-15T00:00:00.000Z",
1723
+ caseSensitiveOverride: false,
1724
+ failOnCaseCollision: true,
1725
+ });
1726
+ } catch (err) {
1727
+ thrown = err;
1728
+ }
1729
+ expect(thrown).toBeInstanceOf(CaseCollisionError);
1730
+ const err = thrown as CaseCollisionError;
1731
+ expect(err.collisions).toHaveLength(2);
1732
+ const allIds = err.collisions.flat().map((c) => c.note_id).sort();
1733
+ expect(allIds).toEqual([
1734
+ "2025-05-26-09-15-42-aaaaaa",
1735
+ "2025-05-26-09-15-42-bbbbbb",
1736
+ "2025-05-26-09-15-42-cccccc",
1737
+ "2025-05-26-09-15-42-dddddd",
1738
+ ]);
1739
+ });
1740
+
1741
+ it("directory-level case difference triggers collision detection", async () => {
1742
+ // Two notes at `Notes/foo` and `notes/foo` — the basename matches
1743
+ // but the parent dir differs only by case. On a case-insensitive
1744
+ // FS, both files would land in the same directory because
1745
+ // `Notes/` and `notes/` resolve to the same inode. Verify the
1746
+ // lowercased-path key catches this: `notes/foo.md`.
1747
+ await store.createNote("# notes-upper", {
1748
+ id: "2025-05-26-09-15-42-aaaaaa",
1749
+ path: "Notes/foo",
1750
+ });
1751
+ await store.createNote("# notes-lower", {
1752
+ id: "2025-05-26-09-15-42-bbbbbb",
1753
+ path: "notes/foo",
1754
+ });
1755
+
1756
+ const outDir = join(tmpBase, "strict-dir-case");
1757
+ let thrown: unknown;
1758
+ try {
1759
+ await exportVaultToDir(store, {
1760
+ outDir,
1761
+ vaultName: "test",
1762
+ exportedAt: "2026-05-15T00:00:00.000Z",
1763
+ caseSensitiveOverride: false,
1764
+ failOnCaseCollision: true,
1765
+ });
1766
+ } catch (err) {
1767
+ thrown = err;
1768
+ }
1769
+ expect(thrown).toBeInstanceOf(CaseCollisionError);
1770
+ const err = thrown as CaseCollisionError;
1771
+ expect(err.collisions).toHaveLength(1);
1772
+ expect(err.collisions[0]).toHaveLength(2);
1773
+ });
1774
+
1775
+ it("default (no failOnCaseCollision) still auto-disambiguates — back-compat", async () => {
1776
+ // The new strict mode is opt-in. Leaving failOnCaseCollision unset
1777
+ // (or false) keeps the existing lossless auto-disambiguation path
1778
+ // unchanged. This pins the back-compat contract — watch/mirror
1779
+ // loops that don't opt in to strict mode never see a thrown
1780
+ // CaseCollisionError on a colliding vault.
1781
+ await store.createNote("# upper", {
1782
+ id: "2025-05-26-09-15-42-aaaaaa",
1783
+ path: "Journal/Note",
1784
+ });
1785
+ await store.createNote("# lower", {
1786
+ id: "2025-05-26-09-15-42-bbbbbb",
1787
+ path: "Journal/note",
1788
+ });
1789
+
1790
+ const outDir = join(tmpBase, "default-disambig");
1791
+ const stats = await exportVaultToDir(store, {
1792
+ outDir,
1793
+ vaultName: "test",
1794
+ exportedAt: "2026-05-15T00:00:00.000Z",
1795
+ caseSensitiveOverride: false,
1796
+ // failOnCaseCollision deliberately omitted — default behavior.
1797
+ });
1798
+ expect(stats.notes).toBe(2);
1799
+ expect(stats.disambiguated_paths).toHaveLength(1);
1800
+ });
1554
1801
  });
@@ -631,6 +631,78 @@ export interface ExportOptions {
631
631
  * run on. When unset (the production default), the probe runs.
632
632
  */
633
633
  caseSensitiveOverride?: boolean;
634
+ /**
635
+ * Strict mode for case-collision handling on case-insensitive
636
+ * filesystems (vault#327 Phase 2). When `true`, the first detected
637
+ * collision aborts the export by throwing a `CaseCollisionError`
638
+ * naming every colliding path. When `false` (the default), the
639
+ * existing lossless behavior is preserved — the colliding note is
640
+ * written to a disambiguated filename (`<base>__<id-short>.<ext>`)
641
+ * and recorded in `ExportStats.disambiguated_paths`.
642
+ *
643
+ * Use `true` for one-shot CLI flows where the operator wants to be
644
+ * forced to fix the source-of-truth (rename one of the colliding
645
+ * notes in the vault before re-exporting). Leave `false` for
646
+ * long-running watch / mirror loops where a hard failure mid-loop
647
+ * would block the operator's actual work.
648
+ */
649
+ failOnCaseCollision?: boolean;
650
+ }
651
+
652
+ /**
653
+ * Thrown by `exportVaultToDir` when `failOnCaseCollision: true` is set
654
+ * and the export detects two-or-more notes whose paths differ only by
655
+ * case on a case-insensitive filesystem (vault#327).
656
+ *
657
+ * The error names every colliding path (full N-way group, not just
658
+ * the first pair) so the operator can audit the whole set in one
659
+ * pass. Caller catches by type and surfaces `.collisions` for a
660
+ * clean error report:
661
+ *
662
+ * ```ts
663
+ * try {
664
+ * await exportVaultToDir(store, { ..., failOnCaseCollision: true });
665
+ * } catch (err) {
666
+ * if (err instanceof CaseCollisionError) {
667
+ * for (const group of err.collisions) {
668
+ * console.error(`collision: ${group.map((g) => g.path).join(", ")}`);
669
+ * }
670
+ * }
671
+ * }
672
+ * ```
673
+ */
674
+ export class CaseCollisionError extends Error {
675
+ /**
676
+ * Each entry is one collision group — every note that shares the same
677
+ * lowercased `(path, extension)` slot. Two notes per group is the
678
+ * common case; three-or-more (`Foo.md` + `foo.md` + `FOO.md`) is rare
679
+ * but supported. Notes are listed in the order they were encountered
680
+ * during the export walk (deterministic — `queryNotes` sorts ASC).
681
+ */
682
+ readonly collisions: Array<Array<{ note_id: string; path: string; extension: string }>>;
683
+ constructor(collisions: CaseCollisionError["collisions"]) {
684
+ const lines: string[] = [
685
+ "Export failed: case-collision detected on case-insensitive filesystem.",
686
+ "The following notes have paths that differ only by case:",
687
+ ];
688
+ // Separate distinct collision groups with ` ---` so operators
689
+ // reading the error in a terminal can tell where one (Foo.md /
690
+ // foo.md) pair ends and the next (Bar.md / bar.md) begins. Without
691
+ // a separator, multi-group output runs together as an unbroken
692
+ // bullet list. vault#350.
693
+ collisions.forEach((group, idx) => {
694
+ if (idx > 0) lines.push(" ---");
695
+ for (const entry of group) {
696
+ lines.push(` - ${entry.path}.${entry.extension} (note id: ${entry.note_id})`);
697
+ }
698
+ });
699
+ lines.push(
700
+ "Rename one of them in the vault before re-exporting, or run from a case-sensitive filesystem.",
701
+ );
702
+ super(lines.join("\n"));
703
+ this.name = "CaseCollisionError";
704
+ this.collisions = collisions;
705
+ }
634
706
  }
635
707
 
636
708
  /**
@@ -733,9 +805,54 @@ export async function exportVaultToDir(
733
805
  // case-insensitive filesystems.
734
806
  const seenLowerKeys = new Map<string, string>();
735
807
 
808
+ // Strict-mode pre-scan (vault#327 Phase 2). When the caller passes
809
+ // `failOnCaseCollision: true`, surface every collision group in one
810
+ // typed error BEFORE any write lands on disk — partial-export-then-
811
+ // throw would leave the operator with a half-mirrored output dir to
812
+ // clean up. The pre-scan walks every note in the vault (NOT
813
+ // since-filtered: a since-filter belongs in the write loop —
814
+ // collisions can involve one old + one new path, and a since-only
815
+ // pre-scan would miss those entirely, silently degrading the strict
816
+ // guarantee on every poll cycle after the initial export). When no
817
+ // collisions exist on a case-insensitive FS, the pre-scan is a
818
+ // no-op (cheap); on a case-sensitive FS it's skipped entirely.
819
+ //
820
+ // Perf: the pre-scan calls `noteToPortable` (3 DB queries per note:
821
+ // links, attachments, content). The main loop below also calls
822
+ // `noteToPortable` — without caching, every note that ALSO passes
823
+ // the since-filter would be serialized twice (~2x the DB round-
824
+ // trips on a large strict-mode export). Stash every pre-scan result
825
+ // in `prescanPortables` and reuse it below; cache-miss falls back
826
+ // to a fresh `noteToPortable` for safety. vault#350.
827
+ const prescanPortables = new Map<string, PortableNote>();
828
+ if (opts.failOnCaseCollision && !caseSensitive) {
829
+ const groups = new Map<string, Array<{ note_id: string; path: string; extension: string }>>();
830
+ for (const note of allNotes) {
831
+ const portable = await noteToPortable(note, store);
832
+ prescanPortables.set(portable.id, portable);
833
+ if (!portable.path) continue; // _unpathed/<id>.<ext> is case-stable
834
+ const ext = portable.extension ?? "md";
835
+ const key = `${portable.path.toLowerCase()}|${ext.toLowerCase()}`;
836
+ const entry = { note_id: portable.id, path: portable.path, extension: ext };
837
+ const existing = groups.get(key);
838
+ if (existing) {
839
+ existing.push(entry);
840
+ } else {
841
+ groups.set(key, [entry]);
842
+ }
843
+ }
844
+ const collisions = Array.from(groups.values()).filter((g) => g.length > 1);
845
+ if (collisions.length > 0) {
846
+ throw new CaseCollisionError(collisions);
847
+ }
848
+ }
849
+
736
850
  for (const note of allNotes) {
737
851
  if (since && !shouldIncludeForSince(note, since)) continue;
738
- const portable = await noteToPortable(note, store);
852
+ // Reuse a pre-scan result when strict-mode populated the cache;
853
+ // otherwise serialize fresh. Same PortableNote shape either way,
854
+ // so the rest of the loop is untouched. vault#350.
855
+ const portable = prescanPortables.get(note.id) ?? (await noteToPortable(note, store));
739
856
  let relPath = portableExportFilePath(portable);
740
857
 
741
858
  // Decide whether this note's filename needs disambiguation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.4.7-rc.1",
3
+ "version": "0.4.7-rc.2",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
package/src/cli.ts CHANGED
@@ -2872,6 +2872,7 @@ async function cmdExport(args: string[]) {
2872
2872
  const { DEFAULT_COMMIT_TEMPLATE } = await import("./export-watch.ts");
2873
2873
  let gitMessageTemplate = DEFAULT_COMMIT_TEMPLATE;
2874
2874
  let gitPush = false;
2875
+ let strictCaseCollision = false;
2875
2876
 
2876
2877
  const positional: string[] = [];
2877
2878
  for (let i = 0; i < args.length; i++) {
@@ -2923,6 +2924,8 @@ async function cmdExport(args: string[]) {
2923
2924
  gitMessageTemplate = v;
2924
2925
  } else if (arg === "--git-push") {
2925
2926
  gitPush = true;
2927
+ } else if (arg === "--strict-case-collision") {
2928
+ strictCaseCollision = true;
2926
2929
  } else {
2927
2930
  positional.push(arg);
2928
2931
  }
@@ -2952,6 +2955,12 @@ async function cmdExport(args: string[]) {
2952
2955
  console.error(" {{first_note_title}}, {{vault_name}}");
2953
2956
  console.error(" (default: \"export: {{date}} ({{notes_changed}} note{{plural}})\")");
2954
2957
  console.error(" --git-push After commit, run `git push` (non-fatal on failure).");
2958
+ console.error(" --strict-case-collision On case-insensitive filesystems (macOS APFS-default,");
2959
+ console.error(" Windows NTFS, FAT/exFAT), abort the export if two notes");
2960
+ console.error(" have paths differing only by case (vault#327).");
2961
+ console.error(" Without this flag, colliding notes are auto-disambiguated");
2962
+ console.error(" with an `__<id-short>` filename suffix (lossless; both");
2963
+ console.error(" files land, canonical path preserved in frontmatter).");
2955
2964
  process.exit(1);
2956
2965
  }
2957
2966
 
@@ -3010,6 +3019,7 @@ async function cmdExport(args: string[]) {
3010
3019
  assetsDir: assetsDirPath,
3011
3020
  ...(vaultDescription ? { vaultDescription } : {}),
3012
3021
  ...(opts.sinceCursor ? { since: opts.sinceCursor } : {}),
3022
+ ...(strictCaseCollision ? { failOnCaseCollision: true } : {}),
3013
3023
  });
3014
3024
 
3015
3025
  if (opts.isInitial) {
@@ -3030,6 +3040,28 @@ async function cmdExport(args: string[]) {
3030
3040
  `Note: ${stats.skipped_attachments.length} attachment(s) skipped. See [export] warnings above.`,
3031
3041
  );
3032
3042
  }
3043
+ // vault#327 Phase 2 — surface case-collision auto-disambiguation
3044
+ // explicitly. The previous fix landed the logic silently; the
3045
+ // operator had no signal that filenames had been munged. Now we
3046
+ // print the count + every disambiguated path so the operator can
3047
+ // (a) audit, (b) decide whether to rename a colliding note in
3048
+ // the vault and re-export, or (c) re-run with
3049
+ // --strict-case-collision to refuse the disambiguation entirely.
3050
+ if (stats.disambiguated_paths.length > 0) {
3051
+ const n = stats.disambiguated_paths.length;
3052
+ console.warn(
3053
+ `Warning: ${n} note${n === 1 ? "" : "s"} had path${n === 1 ? "" : "s"} that ` +
3054
+ `differ only by case on this case-insensitive filesystem. ` +
3055
+ `Auto-disambiguated on disk (canonical paths preserved in frontmatter):`,
3056
+ );
3057
+ for (const d of stats.disambiguated_paths) {
3058
+ console.warn(` - ${d.original_path} → ${d.disambiguated_filename} (id: ${d.note_id})`);
3059
+ }
3060
+ console.warn(
3061
+ `Re-run with --strict-case-collision to abort instead, or rename one of each ` +
3062
+ `colliding pair in the vault before re-exporting. See vault#327.`,
3063
+ );
3064
+ }
3033
3065
  } else {
3034
3066
  // Watch-mode status line: keep tight; the loop logs every interval.
3035
3067
  if (stats.notes > 0) {
@@ -3039,6 +3071,17 @@ async function cmdExport(args: string[]) {
3039
3071
  } else {
3040
3072
  console.log(`[watch] no changes`);
3041
3073
  }
3074
+ // Watch-mode: log new disambiguations per cycle. Operators running
3075
+ // a long-lived watch loop want to be notified the moment a
3076
+ // collision shows up — they can fix it at the source without
3077
+ // waiting for the next manual full export.
3078
+ if (stats.disambiguated_paths.length > 0) {
3079
+ const n = stats.disambiguated_paths.length;
3080
+ console.warn(
3081
+ `[watch] case-collision: ${n} disambiguated path${n === 1 ? "" : "s"} this cycle ` +
3082
+ `(see vault#327; --strict-case-collision to abort instead).`,
3083
+ );
3084
+ }
3042
3085
  }
3043
3086
 
3044
3087
  let committed = false;
@@ -3058,15 +3101,44 @@ async function cmdExport(args: string[]) {
3058
3101
  return { stats, nextCursor, committed };
3059
3102
  }
3060
3103
 
3104
+ // Import the typed CaseCollisionError once so both the single-shot and
3105
+ // watch-initial paths can render it the same way. vault#327 Phase 2.
3106
+ const { CaseCollisionError } = await import("../core/src/portable-md.ts");
3107
+
3061
3108
  // ---- Single-shot mode ----
3062
3109
  if (!watch) {
3063
- await runCycle({ sinceCursor: since, isInitial: true });
3110
+ try {
3111
+ await runCycle({ sinceCursor: since, isInitial: true });
3112
+ } catch (err) {
3113
+ if (err instanceof CaseCollisionError) {
3114
+ // The error's own message already includes the actionable
3115
+ // instruction ("Rename one of them..."). Print verbatim + exit
3116
+ // non-zero so scripts catch the failure deterministically.
3117
+ console.error(err.message);
3118
+ process.exit(1);
3119
+ }
3120
+ throw err;
3121
+ }
3064
3122
  return;
3065
3123
  }
3066
3124
 
3067
3125
  // ---- Watch mode ----
3068
3126
  // Initial full (or since-filtered) export, then poll every interval.
3069
- const initial = await runCycle({ sinceCursor: since, isInitial: true });
3127
+ let initial: Awaited<ReturnType<typeof runCycle>>;
3128
+ try {
3129
+ initial = await runCycle({ sinceCursor: since, isInitial: true });
3130
+ } catch (err) {
3131
+ if (err instanceof CaseCollisionError) {
3132
+ console.error(err.message);
3133
+ console.error(
3134
+ "\n--strict-case-collision is enabled; refusing to start the watch loop until the " +
3135
+ "collision is resolved in the vault. Re-export without --strict-case-collision to " +
3136
+ "auto-disambiguate instead.",
3137
+ );
3138
+ process.exit(1);
3139
+ }
3140
+ throw err;
3141
+ }
3070
3142
  let cursor = initial.nextCursor;
3071
3143
  console.log(`[watch] polling every ${intervalSeconds}s; press Ctrl-C to stop.`);
3072
3144
 
@@ -3095,6 +3167,26 @@ async function cmdExport(args: string[]) {
3095
3167
  const cycle = await runCycle({ sinceCursor: cursor, isInitial: false });
3096
3168
  cursor = cycle.nextCursor;
3097
3169
  } catch (err) {
3170
+ // CaseCollisionError under --strict-case-collision means the
3171
+ // operator opted into "abort on any collision". A new collision
3172
+ // that appears mid-watch (e.g. an LLM client just wrote
3173
+ // `Inbox/Foo` to a vault that already had `Inbox/foo`) must NOT
3174
+ // be swallowed into the generic [watch] export-error log — the
3175
+ // strict-mode guarantee would silently degrade after the initial
3176
+ // export. Print the full collision message + actionable hint and
3177
+ // stop the loop via the same SIGINT-style pathway used by Ctrl-C
3178
+ // (clears timer, gives in-flight work a 250ms settle window).
3179
+ // Exits non-zero so supervisors / git-watch sidecars catch it.
3180
+ if (err instanceof CaseCollisionError) {
3181
+ console.error(err.message);
3182
+ console.error(
3183
+ "Resolve the collision in the vault or re-run without --strict-case-collision to fall back to auto-disambiguate mode.",
3184
+ );
3185
+ stopping = true;
3186
+ if (timer) clearInterval(timer);
3187
+ setTimeout(() => process.exit(1), 0);
3188
+ return;
3189
+ }
3098
3190
  // Don't kill the loop on a transient export error — log and keep
3099
3191
  // polling. Operator can Ctrl-C if they want to bail.
3100
3192
  console.error(`[watch] export error: ${(err as Error).message ?? err}`);
@@ -763,6 +763,105 @@ describe("export CLI: --watch", () => {
763
763
  30_000,
764
764
  );
765
765
 
766
+ test(
767
+ "--strict-case-collision: mid-watch collision stops the loop with the full error + hint",
768
+ async () => {
769
+ // vault#350 reviewer fix. Before: the watch polling timer's
770
+ // generic catch swallowed a CaseCollisionError thrown by a
771
+ // post-initial-export poll, logging only `[watch] export error:
772
+ // ...` and continuing to spin. The strict-mode guarantee
773
+ // ("refuse to continue when a collision appears") evaporated
774
+ // after the initial export.
775
+ //
776
+ // Scenario: start --watch --strict-case-collision against a
777
+ // vault whose initial state has no collisions (so the watch
778
+ // loop boots). Write a colliding note out-of-band. On the next
779
+ // poll cycle, runCycle throws CaseCollisionError; the watch
780
+ // catch path should now (a) print the full err.message to
781
+ // stderr including every colliding path, (b) print the
782
+ // actionable hint, (c) exit non-zero.
783
+ //
784
+ // The CLI doesn't expose `caseSensitiveOverride`, so this test
785
+ // is meaningful only on a case-insensitive FS (macOS APFS
786
+ // default, Windows NTFS default). On a case-sensitive Linux
787
+ // ext4, the pre-scan is a no-op by design and the path under
788
+ // test never fires — skip rather than assert a behavior that
789
+ // can't manifest there.
790
+ const { probeCaseSensitive } = await import("../core/src/portable-md.ts");
791
+ if (probeCaseSensitive(exportDir)) {
792
+ console.log(
793
+ "skipping mid-watch strict-collision test on case-sensitive FS — the strict pre-scan is a no-op here",
794
+ );
795
+ return;
796
+ }
797
+ const watch = spawnWatchCli(
798
+ [
799
+ "export",
800
+ exportDir,
801
+ "--watch",
802
+ "--interval",
803
+ "1",
804
+ "--strict-case-collision",
805
+ ],
806
+ tmp,
807
+ );
808
+ try {
809
+ // Initial export must succeed (seed has no collision).
810
+ await watch.awaitLine((l) => l.includes("Exported 1 note"), 10_000);
811
+ await watch.awaitLine((l) => l.includes("[watch] polling every 1s"), 5_000);
812
+
813
+ // Inject a collision: existing seed is `Inbox/seed`; write
814
+ // `Inbox/SEED` so the lowercased (path, ext) key collides.
815
+ const { clearVaultStoreCache, getVaultStore } = await import("./vault-store.ts");
816
+ clearVaultStoreCache();
817
+ const store = getVaultStore("default");
818
+ await store.createNote("# upper\n", {
819
+ id: "01HZB222222222222222222222",
820
+ path: "Inbox/SEED",
821
+ });
822
+ clearVaultStoreCache();
823
+ } catch (err) {
824
+ watch.proc.kill("SIGKILL");
825
+ throw err;
826
+ }
827
+
828
+ // The strict-mode catch path exits the process. Wait for it.
829
+ // Use a guarded race so a hung process doesn't eat the full
830
+ // 30s suite budget — surface a useful failure message instead.
831
+ const exit = await Promise.race([
832
+ watch.proc.exited,
833
+ new Promise<number>((_, reject) =>
834
+ setTimeout(
835
+ () =>
836
+ reject(
837
+ new Error(
838
+ `CLI did not exit within 15s of collision injection.\n` +
839
+ `stdout:\n${watch.seenLines.join("\n")}\n` +
840
+ `stderr:\n${watch.seenStderr.join("\n")}`,
841
+ ),
842
+ ),
843
+ 15_000,
844
+ ),
845
+ ),
846
+ ]).catch((err) => {
847
+ watch.proc.kill("SIGKILL");
848
+ throw err;
849
+ });
850
+ // Non-zero exit: strict mode refused to continue.
851
+ expect(exit).not.toBe(0);
852
+
853
+ const stderr = watch.seenStderr.join("\n");
854
+ // Full collision message: header line + every colliding path.
855
+ expect(stderr).toContain("case-collision detected");
856
+ expect(stderr).toContain("Inbox/seed.md");
857
+ expect(stderr).toContain("Inbox/SEED.md");
858
+ // Actionable hint (the new line added by this fix).
859
+ expect(stderr).toContain("Resolve the collision in the vault");
860
+ expect(stderr).toContain("--strict-case-collision");
861
+ },
862
+ 30_000,
863
+ );
864
+
766
865
  test(
767
866
  "--watch + --git-commit: vault write → re-export → auto-commit → continues",
768
867
  async () => {