@karmaniverous/jeeves-watcher 0.18.2 → 0.18.4

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.
@@ -747,6 +747,104 @@ class ValuesManager extends BinaryFileStore {
747
747
  }
748
748
  }
749
749
 
750
+ /**
751
+ * @module watcher/globToDir
752
+ * Adapts glob-based watch config to chokidar v4+, which removed glob support
753
+ * (see paulmillr/chokidar#1350). Chokidar v4 treats glob patterns as literal
754
+ * strings, silently producing zero events. This module extracts static directory
755
+ * roots from glob patterns for chokidar to watch, then filters emitted events
756
+ * against the original globs via picomatch.
757
+ */
758
+ /**
759
+ * Extract the static directory root from a glob pattern.
760
+ * Stops at the first segment containing glob characters (`*`, `{`, `?`, `[`).
761
+ *
762
+ * @param glob - A glob pattern (e.g., `j:/domains/**\/*.json`).
763
+ * @returns The static directory prefix (e.g., `j:/domains`).
764
+ */
765
+ function globRoot(glob) {
766
+ const normalized = normalizeSlashes(glob);
767
+ const segments = normalized.split('/');
768
+ const staticSegments = [];
769
+ for (const seg of segments) {
770
+ if (/[*?{[\]]/.test(seg))
771
+ break;
772
+ staticSegments.push(seg);
773
+ }
774
+ return staticSegments.join('/') || '.';
775
+ }
776
+ /**
777
+ * Deduplicate directory roots, removing paths that are subdirectories of others.
778
+ *
779
+ * @param roots - Array of directory paths.
780
+ * @returns Deduplicated array with subdirectories removed.
781
+ */
782
+ function deduplicateRoots(roots) {
783
+ const normalized = roots.map((r) => normalizeSlashes(r).toLowerCase());
784
+ const sorted = [...new Set(normalized)].sort();
785
+ return sorted.filter((root, _i, arr) => {
786
+ const withSlash = root.endsWith('/') ? root : root + '/';
787
+ return !arr.some((other) => other !== root && withSlash.startsWith(other + '/'));
788
+ });
789
+ }
790
+ /**
791
+ * Build a picomatch matcher from an array of glob patterns.
792
+ * Normalizes Windows paths (backslash → forward slash, lowercase drive letter)
793
+ * before matching.
794
+ *
795
+ * @param globs - Glob patterns to match against.
796
+ * @returns A function that tests whether a file path matches any of the globs.
797
+ */
798
+ function buildGlobMatcher(globs) {
799
+ const normalizedGlobs = globs.map((g) => normalizeSlashes(g));
800
+ const isMatch = picomatch(normalizedGlobs, { dot: true, nocase: true });
801
+ return (filePath) => {
802
+ const normalized = normalizeSlashes(filePath);
803
+ return isMatch(normalized);
804
+ };
805
+ }
806
+ /**
807
+ * Convert an array of glob patterns into chokidar-compatible directory roots
808
+ * and a filter function for post-hoc event filtering.
809
+ *
810
+ * @param globs - Glob patterns from the watch config.
811
+ * @returns Object with `roots` (directories for chokidar) and `matches` (filter function).
812
+ */
813
+ function resolveWatchPaths(globs) {
814
+ const rawRoots = globs.map(globRoot);
815
+ const roots = deduplicateRoots(rawRoots);
816
+ const matches = buildGlobMatcher(globs);
817
+ return { roots, matches };
818
+ }
819
+ /**
820
+ * Convert ignored glob patterns to picomatch matcher functions.
821
+ *
822
+ * Chokidar v5 replaced the external `anymatch` dependency with an inline
823
+ * implementation that does **exact string equality** for string matchers,
824
+ * breaking glob-based `ignored` patterns. This function converts glob strings
825
+ * to picomatch functions that chokidar's `createPattern` passes through
826
+ * unchanged (`typeof matcher === 'function'`).
827
+ *
828
+ * Non-string entries (functions, RegExps) are passed through as-is.
829
+ *
830
+ * @param ignored - Array of ignored patterns (globs, functions, RegExps).
831
+ * @returns Array with glob strings replaced by picomatch matcher functions.
832
+ */
833
+ function resolveIgnored(ignored) {
834
+ return ignored.map((entry) => {
835
+ if (typeof entry !== 'string')
836
+ return entry;
837
+ // If the string contains glob characters, convert to a picomatch function.
838
+ // Literal strings (exact paths) are also converted for consistent matching.
839
+ const normalizedPattern = normalizeSlashes(entry);
840
+ const matcher = picomatch(normalizedPattern, { dot: true, nocase: true });
841
+ return (filePath) => {
842
+ const normalized = normalizeSlashes(filePath);
843
+ return matcher(normalized);
844
+ };
845
+ });
846
+ }
847
+
750
848
  /**
751
849
  * @module vcs/CommitMessageGenerator
752
850
  * Generates AI-powered commit messages via the Anthropic Messages API.
@@ -836,17 +934,32 @@ class CommitMessageGenerator {
836
934
  */
837
935
  /** Promisified execFile for git commands. */
838
936
  const execFileAsync = promisify(execFile);
937
+ /**
938
+ * Normalize a path for case-insensitive comparison on Windows.
939
+ * On Windows, lowercases the entire path; on other platforms, returns as-is.
940
+ *
941
+ * @param p - The path string to normalize.
942
+ * @param platform - The platform to check against (default: `process.platform`).
943
+ * @returns The normalized path.
944
+ */
945
+ function normalizePathCase(p, platform = process.platform) {
946
+ return platform === 'win32' ? p.toLowerCase() : p;
947
+ }
839
948
  /**
840
949
  * Find the longest-prefix-match root for a normalized path.
841
950
  * Roots must be sorted longest-first for correct nested matching.
842
951
  *
843
952
  * @param roots - Root paths sorted longest-first.
844
953
  * @param normalizedPath - Normalized absolute path (forward slashes).
954
+ * @param platform - The platform to check against (default: `process.platform`).
845
955
  * @returns The matching root, or undefined.
846
956
  */
847
- function findRootForPath(roots, normalizedPath) {
957
+ function findRootForPath(roots, normalizedPath, platform = process.platform) {
958
+ const comparePath = normalizePathCase(normalizedPath, platform);
848
959
  for (const root of roots) {
849
- if (normalizedPath === root || normalizedPath.startsWith(root + '/')) {
960
+ const compareRoot = normalizePathCase(root, platform);
961
+ if (comparePath === compareRoot ||
962
+ comparePath.startsWith(compareRoot + '/')) {
850
963
  return root;
851
964
  }
852
965
  }
@@ -1205,6 +1318,7 @@ class VcsManager {
1205
1318
  debounceTimer;
1206
1319
  commitInFlight = Promise.resolve();
1207
1320
  started = false;
1321
+ isBaseline = false;
1208
1322
  _lastPushTime = null;
1209
1323
  constructor(rootPath, config, logger, commitMessageGenerator, remoteUrl, accessToken) {
1210
1324
  this.rootPath = rootPath;
@@ -1225,11 +1339,47 @@ class VcsManager {
1225
1339
  }
1226
1340
  /**
1227
1341
  * Begin accepting file changes. Sets up the debounce mechanism.
1342
+ * Enqueues a baseline commit for pre-existing files if the repo has no commits.
1228
1343
  */
1229
- start() {
1344
+ async start() {
1230
1345
  this.started = true;
1231
1346
  this.squashManager?.start();
1232
1347
  this.logger.info({ root: this.rootPath }, 'VcsManager started');
1348
+ await this.enqueueBaselineIfNeeded();
1349
+ }
1350
+ /**
1351
+ * If the repo has no commits, enumerate untracked files and feed them
1352
+ * through fileChanged() to create a baseline commit.
1353
+ */
1354
+ async enqueueBaselineIfNeeded() {
1355
+ try {
1356
+ await execFileAsync('git', ['rev-parse', 'HEAD'], {
1357
+ cwd: this.rootPath,
1358
+ });
1359
+ // HEAD exists — repo already has commits
1360
+ return;
1361
+ }
1362
+ catch {
1363
+ // No commits yet — proceed with baseline
1364
+ }
1365
+ try {
1366
+ const { stdout } = await execFileAsync('git', ['ls-files', '--others', '--exclude-standard', '-z'], { cwd: this.rootPath });
1367
+ const files = stdout
1368
+ .split('\0')
1369
+ .map((line) => line.trim())
1370
+ .filter((line) => line.length > 0);
1371
+ if (files.length === 0)
1372
+ return;
1373
+ this.logger.info({ root: this.rootPath, fileCount: files.length }, 'Enqueuing baseline commit for pre-existing files');
1374
+ this.isBaseline = true;
1375
+ const absolutePaths = files.map((f) => this.rootPath + '/' + f);
1376
+ for (const filePath of absolutePaths) {
1377
+ this.fileChanged(filePath);
1378
+ }
1379
+ }
1380
+ catch (error) {
1381
+ this.logger.warn({ root: this.rootPath, err: normalizeError(error) }, 'Failed to enumerate untracked files for baseline commit');
1382
+ }
1233
1383
  }
1234
1384
  /**
1235
1385
  * Add a file to the pending set and reset the debounce timer.
@@ -1344,6 +1494,9 @@ class VcsManager {
1344
1494
  */
1345
1495
  buildTemplateMessage(fileCount) {
1346
1496
  if (this.pendingReversions.length === 0) {
1497
+ if (this.isBaseline) {
1498
+ return `baseline: batch (${String(fileCount)} files)`;
1499
+ }
1347
1500
  const timestamp = new Date().toISOString();
1348
1501
  return `watcher: batch ${timestamp} (${String(fileCount)} files)`;
1349
1502
  }
@@ -1401,10 +1554,15 @@ class VcsManager {
1401
1554
  cwd: this.rootPath,
1402
1555
  });
1403
1556
  // Build message after staging so getStagedDiff can see the changes
1404
- const message = attempt === 1
1405
- ? await this.buildCommitMessage(files)
1406
- : this.buildTemplateMessage(files.length);
1557
+ // Skip AI for baselines and retries — use template directly
1558
+ const message = this.isBaseline || attempt > 1
1559
+ ? this.buildTemplateMessage(files.length)
1560
+ : await this.buildCommitMessage(files);
1407
1561
  this.pendingReversions.length = 0;
1562
+ // Only clear baseline flag when all baseline files have been committed
1563
+ if (this.pending.size === 0) {
1564
+ this.isBaseline = false;
1565
+ }
1408
1566
  await execFileAsync('git', ['commit', '-m', message], {
1409
1567
  cwd: this.rootPath,
1410
1568
  });
@@ -1480,11 +1638,11 @@ class VcsCoordinator {
1480
1638
  const rootVcs = entry.vcs?.enabled ?? config.vcs.enabled;
1481
1639
  if (!rootVcs)
1482
1640
  continue;
1483
- if (/[*?{[]/.test(entry.path)) {
1484
- logger.warn({ path: entry.path }, 'Skipping VCS for watch path containing glob characters');
1641
+ const resolvedRoot = normalizeSlashes(resolve(globRoot(entry.path)));
1642
+ const rootKey = normalizePathCase(resolvedRoot);
1643
+ // Deduplicate: skip if another glob already resolved to this root.
1644
+ if (this.managers.has(rootKey))
1485
1645
  continue;
1486
- }
1487
- const resolvedRoot = normalizeSlashes(resolve(entry.path));
1488
1646
  const mergedConfig = {
1489
1647
  ...config.vcs,
1490
1648
  ...entry.vcs,
@@ -1503,7 +1661,7 @@ class VcsCoordinator {
1503
1661
  const remoteUrl = entry.vcs?.remote;
1504
1662
  const accessToken = entry.vcs?.accessToken ?? config.vcs.defaultAccessToken;
1505
1663
  const manager = new VcsManager(resolvedRoot, mergedConfig, logger.child({ vcsRoot: resolvedRoot }), generator, remoteUrl, accessToken);
1506
- this.managers.set(resolvedRoot, manager);
1664
+ this.managers.set(rootKey, manager);
1507
1665
  this.roots.push(resolvedRoot);
1508
1666
  }
1509
1667
  // Sort roots longest-first so nested paths match before parents.
@@ -1512,10 +1670,12 @@ class VcsCoordinator {
1512
1670
  /**
1513
1671
  * Start all VcsManager instances.
1514
1672
  */
1515
- start() {
1673
+ async start() {
1674
+ const startPromises = [];
1516
1675
  this.managers.forEach((manager) => {
1517
- manager.start();
1676
+ startPromises.push(manager.start());
1518
1677
  });
1678
+ await Promise.all(startPromises);
1519
1679
  this.logger.info({ rootCount: this.managers.size }, 'VcsCoordinator started');
1520
1680
  }
1521
1681
  /**
@@ -1557,7 +1717,7 @@ class VcsCoordinator {
1557
1717
  * Get the VcsManager for a specific root path.
1558
1718
  */
1559
1719
  getManager(root) {
1560
- return this.managers.get(root);
1720
+ return this.managers.get(normalizePathCase(root));
1561
1721
  }
1562
1722
  /**
1563
1723
  * Get all managers as [root, manager] pairs.
@@ -1574,7 +1734,7 @@ class VcsCoordinator {
1574
1734
  */
1575
1735
  findManagerForPath(normalizedPath) {
1576
1736
  const root = findRootForPath(this.roots, normalizedPath);
1577
- return root ? this.managers.get(root) : undefined;
1737
+ return root ? this.managers.get(normalizePathCase(root)) : undefined;
1578
1738
  }
1579
1739
  }
1580
1740
 
@@ -2795,15 +2955,18 @@ function resolveWatchRootsForGlob(coordinator, glob) {
2795
2955
  const normalizedGlob = normalizeSlashes(glob);
2796
2956
  const roots = coordinator.getRoots();
2797
2957
  const results = [];
2958
+ const compareGlob = normalizePathCase(normalizedGlob);
2798
2959
  for (const root of roots) {
2799
- if (normalizedGlob.startsWith(root + '/') || normalizedGlob === root) {
2960
+ const compareRoot = normalizePathCase(root);
2961
+ if (compareGlob.startsWith(compareRoot + '/') ||
2962
+ compareGlob === compareRoot) {
2800
2963
  // Glob is under this root
2801
2964
  results.push({
2802
2965
  root,
2803
2966
  relativePath: normalizeSlashes(relative(root, normalizedGlob)),
2804
2967
  });
2805
2968
  }
2806
- else if (root.startsWith(normalizedGlob.replace(/[*?[\]{}]/g, ''))) {
2969
+ else if (compareRoot.startsWith(compareGlob.replace(/[*?[\]{}]/g, ''))) {
2807
2970
  // Root is under the glob's base path — include with root-relative glob
2808
2971
  results.push({
2809
2972
  root,
@@ -3088,20 +3251,22 @@ async function initVcs(config, logger) {
3088
3251
  }
3089
3252
  const stateDir = config.stateDir ?? '.jeeves-metadata';
3090
3253
  const pathStrings = extractWatchPathStrings(config.watch.paths);
3091
- const staticPaths = pathStrings.filter((p) => !/[*?{[]/.test(p));
3254
+ const staticPaths = pathStrings.map((p) => globRoot(p));
3092
3255
  validateStateDirOverlap(stateDir, staticPaths);
3093
3256
  const normalized = normalizeWatchPaths(config.watch.paths);
3257
+ const initializedRoots = new Set();
3094
3258
  for (const entry of normalized) {
3095
3259
  const rootVcs = entry.vcs?.enabled ?? config.vcs.enabled;
3096
3260
  if (!rootVcs)
3097
3261
  continue;
3098
- if (/[*?{[]/.test(entry.path)) {
3099
- logger.warn({ path: entry.path }, 'Skipping VCS init for watch path containing glob characters');
3262
+ const root = globRoot(entry.path);
3263
+ // Deduplicate: skip if another glob already resolved to this root.
3264
+ if (initializedRoots.has(root))
3100
3265
  continue;
3101
- }
3102
- await initRepo(entry.path);
3103
- await ensureGitignore(entry.path);
3104
- logger.info({ root: entry.path }, 'VCS initialized for watch root');
3266
+ initializedRoots.add(root);
3267
+ await initRepo(root);
3268
+ await ensureGitignore(root);
3269
+ logger.info({ root }, 'VCS initialized for watch root');
3105
3270
  }
3106
3271
  return true;
3107
3272
  }
@@ -7054,104 +7219,6 @@ class SystemHealth {
7054
7219
  }
7055
7220
  }
7056
7221
 
7057
- /**
7058
- * @module watcher/globToDir
7059
- * Adapts glob-based watch config to chokidar v4+, which removed glob support
7060
- * (see paulmillr/chokidar#1350). Chokidar v4 treats glob patterns as literal
7061
- * strings, silently producing zero events. This module extracts static directory
7062
- * roots from glob patterns for chokidar to watch, then filters emitted events
7063
- * against the original globs via picomatch.
7064
- */
7065
- /**
7066
- * Extract the static directory root from a glob pattern.
7067
- * Stops at the first segment containing glob characters (`*`, `{`, `?`, `[`).
7068
- *
7069
- * @param glob - A glob pattern (e.g., `j:/domains/**\/*.json`).
7070
- * @returns The static directory prefix (e.g., `j:/domains`).
7071
- */
7072
- function globRoot(glob) {
7073
- const normalized = normalizeSlashes(glob);
7074
- const segments = normalized.split('/');
7075
- const staticSegments = [];
7076
- for (const seg of segments) {
7077
- if (/[*?{[\]]/.test(seg))
7078
- break;
7079
- staticSegments.push(seg);
7080
- }
7081
- return staticSegments.join('/') || '.';
7082
- }
7083
- /**
7084
- * Deduplicate directory roots, removing paths that are subdirectories of others.
7085
- *
7086
- * @param roots - Array of directory paths.
7087
- * @returns Deduplicated array with subdirectories removed.
7088
- */
7089
- function deduplicateRoots(roots) {
7090
- const normalized = roots.map((r) => normalizeSlashes(r).toLowerCase());
7091
- const sorted = [...new Set(normalized)].sort();
7092
- return sorted.filter((root, _i, arr) => {
7093
- const withSlash = root.endsWith('/') ? root : root + '/';
7094
- return !arr.some((other) => other !== root && withSlash.startsWith(other + '/'));
7095
- });
7096
- }
7097
- /**
7098
- * Build a picomatch matcher from an array of glob patterns.
7099
- * Normalizes Windows paths (backslash → forward slash, lowercase drive letter)
7100
- * before matching.
7101
- *
7102
- * @param globs - Glob patterns to match against.
7103
- * @returns A function that tests whether a file path matches any of the globs.
7104
- */
7105
- function buildGlobMatcher(globs) {
7106
- const normalizedGlobs = globs.map((g) => normalizeSlashes(g));
7107
- const isMatch = picomatch(normalizedGlobs, { dot: true, nocase: true });
7108
- return (filePath) => {
7109
- const normalized = normalizeSlashes(filePath);
7110
- return isMatch(normalized);
7111
- };
7112
- }
7113
- /**
7114
- * Convert an array of glob patterns into chokidar-compatible directory roots
7115
- * and a filter function for post-hoc event filtering.
7116
- *
7117
- * @param globs - Glob patterns from the watch config.
7118
- * @returns Object with `roots` (directories for chokidar) and `matches` (filter function).
7119
- */
7120
- function resolveWatchPaths(globs) {
7121
- const rawRoots = globs.map(globRoot);
7122
- const roots = deduplicateRoots(rawRoots);
7123
- const matches = buildGlobMatcher(globs);
7124
- return { roots, matches };
7125
- }
7126
- /**
7127
- * Convert ignored glob patterns to picomatch matcher functions.
7128
- *
7129
- * Chokidar v5 replaced the external `anymatch` dependency with an inline
7130
- * implementation that does **exact string equality** for string matchers,
7131
- * breaking glob-based `ignored` patterns. This function converts glob strings
7132
- * to picomatch functions that chokidar's `createPattern` passes through
7133
- * unchanged (`typeof matcher === 'function'`).
7134
- *
7135
- * Non-string entries (functions, RegExps) are passed through as-is.
7136
- *
7137
- * @param ignored - Array of ignored patterns (globs, functions, RegExps).
7138
- * @returns Array with glob strings replaced by picomatch matcher functions.
7139
- */
7140
- function resolveIgnored(ignored) {
7141
- return ignored.map((entry) => {
7142
- if (typeof entry !== 'string')
7143
- return entry;
7144
- // If the string contains glob characters, convert to a picomatch function.
7145
- // Literal strings (exact paths) are also converted for consistent matching.
7146
- const normalizedPattern = normalizeSlashes(entry);
7147
- const matcher = picomatch(normalizedPattern, { dot: true, nocase: true });
7148
- return (filePath) => {
7149
- const normalized = normalizeSlashes(filePath);
7150
- return matcher(normalized);
7151
- };
7152
- });
7153
- }
7154
-
7155
7222
  /**
7156
7223
  * @module watcher/MoveCorrelator
7157
7224
  * Correlates unlink+add events as file moves using content hash matching.
@@ -7693,7 +7760,7 @@ class JeevesWatcher {
7693
7760
  this.watcher = watcher;
7694
7761
  this.gitignoreFilter = gitignoreFilter;
7695
7762
  this.server = await this.startApiServer();
7696
- vcsCoordinator.start();
7763
+ await vcsCoordinator.start();
7697
7764
  this.watcher.start();
7698
7765
  this.startConfigWatch();
7699
7766
  logger.info('jeeves-watcher started');
@@ -224,7 +224,7 @@ async function startFromConfig(configPath, descriptor) {
224
224
  const config = await loadConfig(resolvedPath);
225
225
  // Dynamic import breaks the circular dependency between this module
226
226
  // and app/index.ts (which defines JeevesWatcher and re-exports startFromConfig).
227
- const { JeevesWatcher } = await import('./index-1dpUCttm.js');
227
+ const { JeevesWatcher } = await import('./index-ChYxOGtD.js');
228
228
  const app = new JeevesWatcher(config, resolvedPath, descriptor);
229
229
  installShutdownHandlers(() => app.stop());
230
230
  await app.start();
package/dist/index.d.ts CHANGED
@@ -1291,14 +1291,21 @@ declare class VcsManager {
1291
1291
  private debounceTimer;
1292
1292
  private commitInFlight;
1293
1293
  private started;
1294
+ private isBaseline;
1294
1295
  private _lastPushTime;
1295
1296
  constructor(rootPath: string, config: VcsConfig, logger: pino.Logger, commitMessageGenerator?: CommitMessageGenerator, remoteUrl?: string, accessToken?: string);
1296
1297
  get lastPushTime(): string | null;
1297
1298
  get pushErrors(): readonly PushError[];
1298
1299
  /**
1299
1300
  * Begin accepting file changes. Sets up the debounce mechanism.
1301
+ * Enqueues a baseline commit for pre-existing files if the repo has no commits.
1300
1302
  */
1301
- start(): void;
1303
+ start(): Promise<void>;
1304
+ /**
1305
+ * If the repo has no commits, enumerate untracked files and feed them
1306
+ * through fileChanged() to create a baseline commit.
1307
+ */
1308
+ private enqueueBaselineIfNeeded;
1302
1309
  /**
1303
1310
  * Add a file to the pending set and reset the debounce timer.
1304
1311
  * If the pending set exceeds maxBatchSize, flush immediately.
@@ -1379,7 +1386,7 @@ declare class VcsCoordinator {
1379
1386
  /**
1380
1387
  * Start all VcsManager instances.
1381
1388
  */
1382
- start(): void;
1389
+ start(): Promise<void>;
1383
1390
  /**
1384
1391
  * Route a file event to the correct root's VcsManager.
1385
1392
  *
package/dist/index.js CHANGED
@@ -3375,17 +3375,32 @@ function createSearchHandler(deps) {
3375
3375
  */
3376
3376
  /** Promisified execFile for git commands. */
3377
3377
  const execFileAsync = promisify(execFile);
3378
+ /**
3379
+ * Normalize a path for case-insensitive comparison on Windows.
3380
+ * On Windows, lowercases the entire path; on other platforms, returns as-is.
3381
+ *
3382
+ * @param p - The path string to normalize.
3383
+ * @param platform - The platform to check against (default: `process.platform`).
3384
+ * @returns The normalized path.
3385
+ */
3386
+ function normalizePathCase(p, platform = process.platform) {
3387
+ return platform === 'win32' ? p.toLowerCase() : p;
3388
+ }
3378
3389
  /**
3379
3390
  * Find the longest-prefix-match root for a normalized path.
3380
3391
  * Roots must be sorted longest-first for correct nested matching.
3381
3392
  *
3382
3393
  * @param roots - Root paths sorted longest-first.
3383
3394
  * @param normalizedPath - Normalized absolute path (forward slashes).
3395
+ * @param platform - The platform to check against (default: `process.platform`).
3384
3396
  * @returns The matching root, or undefined.
3385
3397
  */
3386
- function findRootForPath(roots, normalizedPath) {
3398
+ function findRootForPath(roots, normalizedPath, platform = process.platform) {
3399
+ const comparePath = normalizePathCase(normalizedPath, platform);
3387
3400
  for (const root of roots) {
3388
- if (normalizedPath === root || normalizedPath.startsWith(root + '/')) {
3401
+ const compareRoot = normalizePathCase(root, platform);
3402
+ if (comparePath === compareRoot ||
3403
+ comparePath.startsWith(compareRoot + '/')) {
3389
3404
  return root;
3390
3405
  }
3391
3406
  }
@@ -3425,15 +3440,18 @@ function resolveWatchRootsForGlob(coordinator, glob) {
3425
3440
  const normalizedGlob = normalizeSlashes(glob);
3426
3441
  const roots = coordinator.getRoots();
3427
3442
  const results = [];
3443
+ const compareGlob = normalizePathCase(normalizedGlob);
3428
3444
  for (const root of roots) {
3429
- if (normalizedGlob.startsWith(root + '/') || normalizedGlob === root) {
3445
+ const compareRoot = normalizePathCase(root);
3446
+ if (compareGlob.startsWith(compareRoot + '/') ||
3447
+ compareGlob === compareRoot) {
3430
3448
  // Glob is under this root
3431
3449
  results.push({
3432
3450
  root,
3433
3451
  relativePath: normalizeSlashes(relative(root, normalizedGlob)),
3434
3452
  });
3435
3453
  }
3436
- else if (root.startsWith(normalizedGlob.replace(/[*?[\]{}]/g, ''))) {
3454
+ else if (compareRoot.startsWith(compareGlob.replace(/[*?[\]{}]/g, ''))) {
3437
3455
  // Root is under the glob's base path — include with root-relative glob
3438
3456
  results.push({
3439
3457
  root,
@@ -4654,6 +4672,104 @@ async function ensureGitignore(rootPath, alwaysOnEntries = []) {
4654
4672
  }
4655
4673
  }
4656
4674
 
4675
+ /**
4676
+ * @module watcher/globToDir
4677
+ * Adapts glob-based watch config to chokidar v4+, which removed glob support
4678
+ * (see paulmillr/chokidar#1350). Chokidar v4 treats glob patterns as literal
4679
+ * strings, silently producing zero events. This module extracts static directory
4680
+ * roots from glob patterns for chokidar to watch, then filters emitted events
4681
+ * against the original globs via picomatch.
4682
+ */
4683
+ /**
4684
+ * Extract the static directory root from a glob pattern.
4685
+ * Stops at the first segment containing glob characters (`*`, `{`, `?`, `[`).
4686
+ *
4687
+ * @param glob - A glob pattern (e.g., `j:/domains/**\/*.json`).
4688
+ * @returns The static directory prefix (e.g., `j:/domains`).
4689
+ */
4690
+ function globRoot(glob) {
4691
+ const normalized = normalizeSlashes(glob);
4692
+ const segments = normalized.split('/');
4693
+ const staticSegments = [];
4694
+ for (const seg of segments) {
4695
+ if (/[*?{[\]]/.test(seg))
4696
+ break;
4697
+ staticSegments.push(seg);
4698
+ }
4699
+ return staticSegments.join('/') || '.';
4700
+ }
4701
+ /**
4702
+ * Deduplicate directory roots, removing paths that are subdirectories of others.
4703
+ *
4704
+ * @param roots - Array of directory paths.
4705
+ * @returns Deduplicated array with subdirectories removed.
4706
+ */
4707
+ function deduplicateRoots(roots) {
4708
+ const normalized = roots.map((r) => normalizeSlashes(r).toLowerCase());
4709
+ const sorted = [...new Set(normalized)].sort();
4710
+ return sorted.filter((root, _i, arr) => {
4711
+ const withSlash = root.endsWith('/') ? root : root + '/';
4712
+ return !arr.some((other) => other !== root && withSlash.startsWith(other + '/'));
4713
+ });
4714
+ }
4715
+ /**
4716
+ * Build a picomatch matcher from an array of glob patterns.
4717
+ * Normalizes Windows paths (backslash → forward slash, lowercase drive letter)
4718
+ * before matching.
4719
+ *
4720
+ * @param globs - Glob patterns to match against.
4721
+ * @returns A function that tests whether a file path matches any of the globs.
4722
+ */
4723
+ function buildGlobMatcher(globs) {
4724
+ const normalizedGlobs = globs.map((g) => normalizeSlashes(g));
4725
+ const isMatch = picomatch(normalizedGlobs, { dot: true, nocase: true });
4726
+ return (filePath) => {
4727
+ const normalized = normalizeSlashes(filePath);
4728
+ return isMatch(normalized);
4729
+ };
4730
+ }
4731
+ /**
4732
+ * Convert an array of glob patterns into chokidar-compatible directory roots
4733
+ * and a filter function for post-hoc event filtering.
4734
+ *
4735
+ * @param globs - Glob patterns from the watch config.
4736
+ * @returns Object with `roots` (directories for chokidar) and `matches` (filter function).
4737
+ */
4738
+ function resolveWatchPaths(globs) {
4739
+ const rawRoots = globs.map(globRoot);
4740
+ const roots = deduplicateRoots(rawRoots);
4741
+ const matches = buildGlobMatcher(globs);
4742
+ return { roots, matches };
4743
+ }
4744
+ /**
4745
+ * Convert ignored glob patterns to picomatch matcher functions.
4746
+ *
4747
+ * Chokidar v5 replaced the external `anymatch` dependency with an inline
4748
+ * implementation that does **exact string equality** for string matchers,
4749
+ * breaking glob-based `ignored` patterns. This function converts glob strings
4750
+ * to picomatch functions that chokidar's `createPattern` passes through
4751
+ * unchanged (`typeof matcher === 'function'`).
4752
+ *
4753
+ * Non-string entries (functions, RegExps) are passed through as-is.
4754
+ *
4755
+ * @param ignored - Array of ignored patterns (globs, functions, RegExps).
4756
+ * @returns Array with glob strings replaced by picomatch matcher functions.
4757
+ */
4758
+ function resolveIgnored(ignored) {
4759
+ return ignored.map((entry) => {
4760
+ if (typeof entry !== 'string')
4761
+ return entry;
4762
+ // If the string contains glob characters, convert to a picomatch function.
4763
+ // Literal strings (exact paths) are also converted for consistent matching.
4764
+ const normalizedPattern = normalizeSlashes(entry);
4765
+ const matcher = picomatch(normalizedPattern, { dot: true, nocase: true });
4766
+ return (filePath) => {
4767
+ const normalized = normalizeSlashes(filePath);
4768
+ return matcher(normalized);
4769
+ };
4770
+ });
4771
+ }
4772
+
4657
4773
  /**
4658
4774
  * @module vcs/VcsManager
4659
4775
  * Per-root VCS manager for git-backed content versioning.
@@ -4682,6 +4798,7 @@ class VcsManager {
4682
4798
  debounceTimer;
4683
4799
  commitInFlight = Promise.resolve();
4684
4800
  started = false;
4801
+ isBaseline = false;
4685
4802
  _lastPushTime = null;
4686
4803
  constructor(rootPath, config, logger, commitMessageGenerator, remoteUrl, accessToken) {
4687
4804
  this.rootPath = rootPath;
@@ -4702,11 +4819,47 @@ class VcsManager {
4702
4819
  }
4703
4820
  /**
4704
4821
  * Begin accepting file changes. Sets up the debounce mechanism.
4822
+ * Enqueues a baseline commit for pre-existing files if the repo has no commits.
4705
4823
  */
4706
- start() {
4824
+ async start() {
4707
4825
  this.started = true;
4708
4826
  this.squashManager?.start();
4709
4827
  this.logger.info({ root: this.rootPath }, 'VcsManager started');
4828
+ await this.enqueueBaselineIfNeeded();
4829
+ }
4830
+ /**
4831
+ * If the repo has no commits, enumerate untracked files and feed them
4832
+ * through fileChanged() to create a baseline commit.
4833
+ */
4834
+ async enqueueBaselineIfNeeded() {
4835
+ try {
4836
+ await execFileAsync('git', ['rev-parse', 'HEAD'], {
4837
+ cwd: this.rootPath,
4838
+ });
4839
+ // HEAD exists — repo already has commits
4840
+ return;
4841
+ }
4842
+ catch {
4843
+ // No commits yet — proceed with baseline
4844
+ }
4845
+ try {
4846
+ const { stdout } = await execFileAsync('git', ['ls-files', '--others', '--exclude-standard', '-z'], { cwd: this.rootPath });
4847
+ const files = stdout
4848
+ .split('\0')
4849
+ .map((line) => line.trim())
4850
+ .filter((line) => line.length > 0);
4851
+ if (files.length === 0)
4852
+ return;
4853
+ this.logger.info({ root: this.rootPath, fileCount: files.length }, 'Enqueuing baseline commit for pre-existing files');
4854
+ this.isBaseline = true;
4855
+ const absolutePaths = files.map((f) => this.rootPath + '/' + f);
4856
+ for (const filePath of absolutePaths) {
4857
+ this.fileChanged(filePath);
4858
+ }
4859
+ }
4860
+ catch (error) {
4861
+ this.logger.warn({ root: this.rootPath, err: normalizeError(error) }, 'Failed to enumerate untracked files for baseline commit');
4862
+ }
4710
4863
  }
4711
4864
  /**
4712
4865
  * Add a file to the pending set and reset the debounce timer.
@@ -4821,6 +4974,9 @@ class VcsManager {
4821
4974
  */
4822
4975
  buildTemplateMessage(fileCount) {
4823
4976
  if (this.pendingReversions.length === 0) {
4977
+ if (this.isBaseline) {
4978
+ return `baseline: batch (${String(fileCount)} files)`;
4979
+ }
4824
4980
  const timestamp = new Date().toISOString();
4825
4981
  return `watcher: batch ${timestamp} (${String(fileCount)} files)`;
4826
4982
  }
@@ -4878,10 +5034,15 @@ class VcsManager {
4878
5034
  cwd: this.rootPath,
4879
5035
  });
4880
5036
  // Build message after staging so getStagedDiff can see the changes
4881
- const message = attempt === 1
4882
- ? await this.buildCommitMessage(files)
4883
- : this.buildTemplateMessage(files.length);
5037
+ // Skip AI for baselines and retries — use template directly
5038
+ const message = this.isBaseline || attempt > 1
5039
+ ? this.buildTemplateMessage(files.length)
5040
+ : await this.buildCommitMessage(files);
4884
5041
  this.pendingReversions.length = 0;
5042
+ // Only clear baseline flag when all baseline files have been committed
5043
+ if (this.pending.size === 0) {
5044
+ this.isBaseline = false;
5045
+ }
4885
5046
  await execFileAsync('git', ['commit', '-m', message], {
4886
5047
  cwd: this.rootPath,
4887
5048
  });
@@ -4957,11 +5118,11 @@ class VcsCoordinator {
4957
5118
  const rootVcs = entry.vcs?.enabled ?? config.vcs.enabled;
4958
5119
  if (!rootVcs)
4959
5120
  continue;
4960
- if (/[*?{[]/.test(entry.path)) {
4961
- logger.warn({ path: entry.path }, 'Skipping VCS for watch path containing glob characters');
5121
+ const resolvedRoot = normalizeSlashes(resolve(globRoot(entry.path)));
5122
+ const rootKey = normalizePathCase(resolvedRoot);
5123
+ // Deduplicate: skip if another glob already resolved to this root.
5124
+ if (this.managers.has(rootKey))
4962
5125
  continue;
4963
- }
4964
- const resolvedRoot = normalizeSlashes(resolve(entry.path));
4965
5126
  const mergedConfig = {
4966
5127
  ...config.vcs,
4967
5128
  ...entry.vcs,
@@ -4980,7 +5141,7 @@ class VcsCoordinator {
4980
5141
  const remoteUrl = entry.vcs?.remote;
4981
5142
  const accessToken = entry.vcs?.accessToken ?? config.vcs.defaultAccessToken;
4982
5143
  const manager = new VcsManager(resolvedRoot, mergedConfig, logger.child({ vcsRoot: resolvedRoot }), generator, remoteUrl, accessToken);
4983
- this.managers.set(resolvedRoot, manager);
5144
+ this.managers.set(rootKey, manager);
4984
5145
  this.roots.push(resolvedRoot);
4985
5146
  }
4986
5147
  // Sort roots longest-first so nested paths match before parents.
@@ -4989,10 +5150,12 @@ class VcsCoordinator {
4989
5150
  /**
4990
5151
  * Start all VcsManager instances.
4991
5152
  */
4992
- start() {
5153
+ async start() {
5154
+ const startPromises = [];
4993
5155
  this.managers.forEach((manager) => {
4994
- manager.start();
5156
+ startPromises.push(manager.start());
4995
5157
  });
5158
+ await Promise.all(startPromises);
4996
5159
  this.logger.info({ rootCount: this.managers.size }, 'VcsCoordinator started');
4997
5160
  }
4998
5161
  /**
@@ -5034,7 +5197,7 @@ class VcsCoordinator {
5034
5197
  * Get the VcsManager for a specific root path.
5035
5198
  */
5036
5199
  getManager(root) {
5037
- return this.managers.get(root);
5200
+ return this.managers.get(normalizePathCase(root));
5038
5201
  }
5039
5202
  /**
5040
5203
  * Get all managers as [root, manager] pairs.
@@ -5051,7 +5214,7 @@ class VcsCoordinator {
5051
5214
  */
5052
5215
  findManagerForPath(normalizedPath) {
5053
5216
  const root = findRootForPath(this.roots, normalizedPath);
5054
- return root ? this.managers.get(root) : undefined;
5217
+ return root ? this.managers.get(normalizePathCase(root)) : undefined;
5055
5218
  }
5056
5219
  }
5057
5220
 
@@ -5224,20 +5387,22 @@ async function initVcs(config, logger) {
5224
5387
  }
5225
5388
  const stateDir = config.stateDir ?? '.jeeves-metadata';
5226
5389
  const pathStrings = extractWatchPathStrings(config.watch.paths);
5227
- const staticPaths = pathStrings.filter((p) => !/[*?{[]/.test(p));
5390
+ const staticPaths = pathStrings.map((p) => globRoot(p));
5228
5391
  validateStateDirOverlap(stateDir, staticPaths);
5229
5392
  const normalized = normalizeWatchPaths(config.watch.paths);
5393
+ const initializedRoots = new Set();
5230
5394
  for (const entry of normalized) {
5231
5395
  const rootVcs = entry.vcs?.enabled ?? config.vcs.enabled;
5232
5396
  if (!rootVcs)
5233
5397
  continue;
5234
- if (/[*?{[]/.test(entry.path)) {
5235
- logger.warn({ path: entry.path }, 'Skipping VCS init for watch path containing glob characters');
5398
+ const root = globRoot(entry.path);
5399
+ // Deduplicate: skip if another glob already resolved to this root.
5400
+ if (initializedRoots.has(root))
5236
5401
  continue;
5237
- }
5238
- await initRepo(entry.path);
5239
- await ensureGitignore(entry.path);
5240
- logger.info({ root: entry.path }, 'VCS initialized for watch root');
5402
+ initializedRoots.add(root);
5403
+ await initRepo(root);
5404
+ await ensureGitignore(root);
5405
+ logger.info({ root }, 'VCS initialized for watch root');
5241
5406
  }
5242
5407
  return true;
5243
5408
  }
@@ -8093,104 +8258,6 @@ class SystemHealth {
8093
8258
  }
8094
8259
  }
8095
8260
 
8096
- /**
8097
- * @module watcher/globToDir
8098
- * Adapts glob-based watch config to chokidar v4+, which removed glob support
8099
- * (see paulmillr/chokidar#1350). Chokidar v4 treats glob patterns as literal
8100
- * strings, silently producing zero events. This module extracts static directory
8101
- * roots from glob patterns for chokidar to watch, then filters emitted events
8102
- * against the original globs via picomatch.
8103
- */
8104
- /**
8105
- * Extract the static directory root from a glob pattern.
8106
- * Stops at the first segment containing glob characters (`*`, `{`, `?`, `[`).
8107
- *
8108
- * @param glob - A glob pattern (e.g., `j:/domains/**\/*.json`).
8109
- * @returns The static directory prefix (e.g., `j:/domains`).
8110
- */
8111
- function globRoot(glob) {
8112
- const normalized = normalizeSlashes(glob);
8113
- const segments = normalized.split('/');
8114
- const staticSegments = [];
8115
- for (const seg of segments) {
8116
- if (/[*?{[\]]/.test(seg))
8117
- break;
8118
- staticSegments.push(seg);
8119
- }
8120
- return staticSegments.join('/') || '.';
8121
- }
8122
- /**
8123
- * Deduplicate directory roots, removing paths that are subdirectories of others.
8124
- *
8125
- * @param roots - Array of directory paths.
8126
- * @returns Deduplicated array with subdirectories removed.
8127
- */
8128
- function deduplicateRoots(roots) {
8129
- const normalized = roots.map((r) => normalizeSlashes(r).toLowerCase());
8130
- const sorted = [...new Set(normalized)].sort();
8131
- return sorted.filter((root, _i, arr) => {
8132
- const withSlash = root.endsWith('/') ? root : root + '/';
8133
- return !arr.some((other) => other !== root && withSlash.startsWith(other + '/'));
8134
- });
8135
- }
8136
- /**
8137
- * Build a picomatch matcher from an array of glob patterns.
8138
- * Normalizes Windows paths (backslash → forward slash, lowercase drive letter)
8139
- * before matching.
8140
- *
8141
- * @param globs - Glob patterns to match against.
8142
- * @returns A function that tests whether a file path matches any of the globs.
8143
- */
8144
- function buildGlobMatcher(globs) {
8145
- const normalizedGlobs = globs.map((g) => normalizeSlashes(g));
8146
- const isMatch = picomatch(normalizedGlobs, { dot: true, nocase: true });
8147
- return (filePath) => {
8148
- const normalized = normalizeSlashes(filePath);
8149
- return isMatch(normalized);
8150
- };
8151
- }
8152
- /**
8153
- * Convert an array of glob patterns into chokidar-compatible directory roots
8154
- * and a filter function for post-hoc event filtering.
8155
- *
8156
- * @param globs - Glob patterns from the watch config.
8157
- * @returns Object with `roots` (directories for chokidar) and `matches` (filter function).
8158
- */
8159
- function resolveWatchPaths(globs) {
8160
- const rawRoots = globs.map(globRoot);
8161
- const roots = deduplicateRoots(rawRoots);
8162
- const matches = buildGlobMatcher(globs);
8163
- return { roots, matches };
8164
- }
8165
- /**
8166
- * Convert ignored glob patterns to picomatch matcher functions.
8167
- *
8168
- * Chokidar v5 replaced the external `anymatch` dependency with an inline
8169
- * implementation that does **exact string equality** for string matchers,
8170
- * breaking glob-based `ignored` patterns. This function converts glob strings
8171
- * to picomatch functions that chokidar's `createPattern` passes through
8172
- * unchanged (`typeof matcher === 'function'`).
8173
- *
8174
- * Non-string entries (functions, RegExps) are passed through as-is.
8175
- *
8176
- * @param ignored - Array of ignored patterns (globs, functions, RegExps).
8177
- * @returns Array with glob strings replaced by picomatch matcher functions.
8178
- */
8179
- function resolveIgnored(ignored) {
8180
- return ignored.map((entry) => {
8181
- if (typeof entry !== 'string')
8182
- return entry;
8183
- // If the string contains glob characters, convert to a picomatch function.
8184
- // Literal strings (exact paths) are also converted for consistent matching.
8185
- const normalizedPattern = normalizeSlashes(entry);
8186
- const matcher = picomatch(normalizedPattern, { dot: true, nocase: true });
8187
- return (filePath) => {
8188
- const normalized = normalizeSlashes(filePath);
8189
- return matcher(normalized);
8190
- };
8191
- });
8192
- }
8193
-
8194
8261
  /**
8195
8262
  * @module watcher/MoveCorrelator
8196
8263
  * Correlates unlink+add events as file moves using content hash matching.
@@ -8844,7 +8911,7 @@ class JeevesWatcher {
8844
8911
  this.watcher = watcher;
8845
8912
  this.gitignoreFilter = gitignoreFilter;
8846
8913
  this.server = await this.startApiServer();
8847
- vcsCoordinator.start();
8914
+ await vcsCoordinator.start();
8848
8915
  this.watcher.start();
8849
8916
  this.startConfigWatch();
8850
8917
  logger.info('jeeves-watcher started');
package/package.json CHANGED
@@ -149,5 +149,5 @@
149
149
  },
150
150
  "type": "module",
151
151
  "types": "dist/index.d.ts",
152
- "version": "0.18.2"
152
+ "version": "0.18.4"
153
153
  }