@karmaniverous/jeeves-watcher 0.18.7 → 0.18.8

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.
package/dist/index.js CHANGED
@@ -4193,6 +4193,91 @@ async function introspectAllHelpers(config, configDir) {
4193
4193
  return result;
4194
4194
  }
4195
4195
 
4196
+ /**
4197
+ * @module vcs/CommitMessageBuilder
4198
+ * Builds commit messages — both template-based and AI-generated.
4199
+ */
4200
+ /**
4201
+ * Builds commit messages for VCS commits.
4202
+ * Supports template-based messages and AI-generated messages with fallback.
4203
+ */
4204
+ class CommitMessageBuilder {
4205
+ rootPath;
4206
+ logger;
4207
+ generator;
4208
+ constructor(rootPath, logger, generator) {
4209
+ this.rootPath = rootPath;
4210
+ this.logger = logger;
4211
+ this.generator = generator;
4212
+ }
4213
+ /**
4214
+ * Build the template commit message (no AI).
4215
+ */
4216
+ buildTemplateMessage(fileCount, isBaseline, pendingReversions) {
4217
+ if (pendingReversions.length === 0) {
4218
+ if (isBaseline) {
4219
+ return `baseline: batch (${String(fileCount)} files)`;
4220
+ }
4221
+ const timestamp = new Date().toISOString();
4222
+ return `watcher: batch ${timestamp} (${String(fileCount)} files)`;
4223
+ }
4224
+ // Count total reverted files across all pending reversions
4225
+ const revertedPaths = new Set(pendingReversions.flatMap((r) => r.paths));
4226
+ const revertedCount = revertedPaths.size;
4227
+ const otherCount = fileCount - revertedCount;
4228
+ // Use the first reversion's glob and commit for the message
4229
+ const { glob, commit } = pendingReversions[0];
4230
+ const shortCommit = commit.slice(0, 7);
4231
+ let message = `revert: ${glob} to ${shortCommit} — restored ${String(revertedCount)} files`;
4232
+ if (otherCount > 0) {
4233
+ message += ` (+ ${String(otherCount)} other changes)`;
4234
+ }
4235
+ return message;
4236
+ }
4237
+ /**
4238
+ * Build the commit message, using AI when available with template fallback.
4239
+ */
4240
+ async buildCommitMessage(files, isBaseline, pendingReversions) {
4241
+ const fileCount = files.length;
4242
+ const templateMessage = this.buildTemplateMessage(fileCount, isBaseline, pendingReversions);
4243
+ if (!this.generator) {
4244
+ return templateMessage;
4245
+ }
4246
+ try {
4247
+ const diffSummary = await this.getStagedDiff();
4248
+ const aiMessage = await this.generator.generate(files, diffSummary);
4249
+ if (!aiMessage) {
4250
+ this.logger.warn('AI commit message generation returned null; using template');
4251
+ return templateMessage;
4252
+ }
4253
+ // For reversions: prefix with revert info + AI description
4254
+ if (pendingReversions.length > 0) {
4255
+ const { glob, commit } = pendingReversions[0];
4256
+ const shortCommit = commit.slice(0, 7);
4257
+ return `revert: ${glob} to ${shortCommit} — ${aiMessage}`;
4258
+ }
4259
+ return aiMessage;
4260
+ }
4261
+ catch (error) {
4262
+ this.logger.warn({ err: normalizeError(error) }, 'AI commit message generation failed; using template');
4263
+ return templateMessage;
4264
+ }
4265
+ }
4266
+ /**
4267
+ * Get the staged diff for AI context.
4268
+ */
4269
+ async getStagedDiff() {
4270
+ try {
4271
+ const { stdout: stat } = await execFileAsync('git', ['diff', '--cached', '--stat'], { cwd: this.rootPath });
4272
+ const { stdout: patch } = await execFileAsync('git', ['diff', '--cached'], { cwd: this.rootPath });
4273
+ return `${stat}\n${patch}`;
4274
+ }
4275
+ catch {
4276
+ return '';
4277
+ }
4278
+ }
4279
+ }
4280
+
4196
4281
  /**
4197
4282
  * @module vcs/CommitMessageGenerator
4198
4283
  * Generates AI-powered commit messages via the Anthropic Messages API.
@@ -4819,6 +4904,50 @@ function resolveIgnored(ignored) {
4819
4904
  });
4820
4905
  }
4821
4906
 
4907
+ /**
4908
+ * @module vcs/vcsPush
4909
+ * Push logic for VCS commits.
4910
+ */
4911
+ /**
4912
+ * Push to the configured remote if remoteUrl is set.
4913
+ *
4914
+ * On failure: logs the error, appends to pushErrors, and returns undefined.
4915
+ * Never throws.
4916
+ *
4917
+ * @param rootPath - Absolute path of the git repository.
4918
+ * @param remoteUrl - Remote URL to push to, or undefined to skip.
4919
+ * @param accessToken - Optional token for HTTPS authentication.
4920
+ * @param pushErrors - Mutable array where push errors are recorded.
4921
+ * @param logger - Logger instance.
4922
+ * @returns ISO timestamp of a successful push, or undefined if skipped/failed.
4923
+ */
4924
+ async function pushToRemote(rootPath, remoteUrl, accessToken, pushErrors, logger) {
4925
+ if (!remoteUrl)
4926
+ return undefined;
4927
+ try {
4928
+ // Build the authenticated URL if a token is available
4929
+ const pushUrl = accessToken
4930
+ ? remoteUrl.replace(/^https:\/\//, `https://${encodeURIComponent(accessToken)}@`)
4931
+ : remoteUrl;
4932
+ await execFileAsync('git', ['push', pushUrl, 'HEAD'], {
4933
+ cwd: rootPath,
4934
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
4935
+ });
4936
+ const timestamp = new Date().toISOString();
4937
+ logger.info({ root: rootPath, remote: remoteUrl }, 'VCS push succeeded');
4938
+ return timestamp;
4939
+ }
4940
+ catch (error) {
4941
+ const pushError = {
4942
+ timestamp: new Date().toISOString(),
4943
+ message: normalizeError(error).message,
4944
+ };
4945
+ pushErrors.push(pushError);
4946
+ logger.error({ root: rootPath, err: normalizeError(error) }, 'VCS push failed');
4947
+ return undefined;
4948
+ }
4949
+ }
4950
+
4822
4951
  /**
4823
4952
  * @module vcs/VcsManager
4824
4953
  * Per-root VCS manager for git-backed content versioning.
@@ -4839,7 +4968,7 @@ class VcsManager {
4839
4968
  remoteUrl;
4840
4969
  accessToken;
4841
4970
  logger;
4842
- commitMessageGenerator;
4971
+ commitMessageBuilder;
4843
4972
  pending = new Set();
4844
4973
  pendingReversions = [];
4845
4974
  _pushErrors = [];
@@ -4847,15 +4976,15 @@ class VcsManager {
4847
4976
  debounceTimer;
4848
4977
  commitInFlight = Promise.resolve();
4849
4978
  started = false;
4850
- isBaseline = false;
4979
+ isBaseline = true;
4851
4980
  _lastPushTime = null;
4852
4981
  constructor(rootPath, config, logger, commitMessageGenerator, remoteUrl, accessToken) {
4853
4982
  this.rootPath = rootPath;
4854
4983
  this.config = config;
4855
4984
  this.logger = logger;
4856
- this.commitMessageGenerator = commitMessageGenerator;
4857
4985
  this.remoteUrl = remoteUrl;
4858
4986
  this.accessToken = accessToken;
4987
+ this.commitMessageBuilder = new CommitMessageBuilder(rootPath, logger, commitMessageGenerator);
4859
4988
  if (config.retention) {
4860
4989
  this.squashManager = new SquashManager(rootPath, config.retention, logger, remoteUrl, accessToken);
4861
4990
  }
@@ -4867,48 +4996,23 @@ class VcsManager {
4867
4996
  return this._pushErrors;
4868
4997
  }
4869
4998
  /**
4870
- * Begin accepting file changes. Sets up the debounce mechanism.
4871
- * Enqueues a baseline commit for pre-existing files if the repo has no commits.
4999
+ * Begin accepting file changes. Sets the manager to started state and
5000
+ * starts the squash manager if configured.
5001
+ *
5002
+ * Baseline mode (commit prefix `"baseline:"`) remains active until
5003
+ * {@link endBaseline} is called by the coordinator after the initial scan.
4872
5004
  */
4873
- async start() {
5005
+ start() {
4874
5006
  this.started = true;
4875
5007
  this.squashManager?.start();
4876
5008
  this.logger.info({ root: this.rootPath }, 'VcsManager started');
4877
- await this.enqueueBaselineIfNeeded();
4878
5009
  }
4879
5010
  /**
4880
- * If the repo has no commits, enumerate untracked files and feed them
4881
- * through fileChanged() to create a baseline commit.
5011
+ * Signal that the initial filesystem scan is complete.
5012
+ * Clears the baseline flag so subsequent commits use normal "watcher: batch" messages.
4882
5013
  */
4883
- async enqueueBaselineIfNeeded() {
4884
- try {
4885
- await execFileAsync('git', ['rev-parse', 'HEAD'], {
4886
- cwd: this.rootPath,
4887
- });
4888
- // HEAD exists — repo already has commits
4889
- return;
4890
- }
4891
- catch {
4892
- // No commits yet — proceed with baseline
4893
- }
4894
- try {
4895
- const { stdout } = await execFileAsync('git', ['ls-files', '--others', '--exclude-standard', '-z'], { cwd: this.rootPath });
4896
- const files = stdout
4897
- .split('\0')
4898
- .map((line) => line.trim())
4899
- .filter((line) => line.length > 0);
4900
- if (files.length === 0)
4901
- return;
4902
- this.logger.info({ root: this.rootPath, fileCount: files.length }, 'Enqueuing baseline commit for pre-existing files');
4903
- this.isBaseline = true;
4904
- const absolutePaths = files.map((f) => this.rootPath + '/' + f);
4905
- for (const filePath of absolutePaths) {
4906
- this.fileChanged(filePath);
4907
- }
4908
- }
4909
- catch (error) {
4910
- this.logger.warn({ root: this.rootPath, err: normalizeError(error) }, 'Failed to enumerate untracked files for baseline commit');
4911
- }
5014
+ endBaseline() {
5015
+ this.isBaseline = false;
4912
5016
  }
4913
5017
  /**
4914
5018
  * Add a file to the pending set and reset the debounce timer.
@@ -5005,72 +5109,6 @@ class VcsManager {
5005
5109
  void this.flush();
5006
5110
  }, this.config.commitDebounceMs);
5007
5111
  }
5008
- /**
5009
- * Get the cached diff for staged files, for AI context.
5010
- */
5011
- async getStagedDiff() {
5012
- try {
5013
- const { stdout: stat } = await execFileAsync('git', ['diff', '--cached', '--stat'], { cwd: this.rootPath });
5014
- const { stdout: patch } = await execFileAsync('git', ['diff', '--cached'], { cwd: this.rootPath });
5015
- return `${stat}\n${patch}`;
5016
- }
5017
- catch {
5018
- return '';
5019
- }
5020
- }
5021
- /**
5022
- * Build the template commit message (no AI).
5023
- */
5024
- buildTemplateMessage(fileCount) {
5025
- if (this.pendingReversions.length === 0) {
5026
- if (this.isBaseline) {
5027
- return `baseline: batch (${String(fileCount)} files)`;
5028
- }
5029
- const timestamp = new Date().toISOString();
5030
- return `watcher: batch ${timestamp} (${String(fileCount)} files)`;
5031
- }
5032
- // Count total reverted files across all pending reversions
5033
- const revertedPaths = new Set(this.pendingReversions.flatMap((r) => r.paths));
5034
- const revertedCount = revertedPaths.size;
5035
- const otherCount = fileCount - revertedCount;
5036
- // Use the first reversion's glob and commit for the message
5037
- const { glob, commit } = this.pendingReversions[0];
5038
- const shortCommit = commit.slice(0, 7);
5039
- let message = `revert: ${glob} to ${shortCommit} — restored ${String(revertedCount)} files`;
5040
- if (otherCount > 0) {
5041
- message += ` (+ ${String(otherCount)} other changes)`;
5042
- }
5043
- return message;
5044
- }
5045
- /**
5046
- * Build the commit message, using AI when available with template fallback.
5047
- */
5048
- async buildCommitMessage(files) {
5049
- const fileCount = files.length;
5050
- const templateMessage = this.buildTemplateMessage(fileCount);
5051
- if (!this.commitMessageGenerator) {
5052
- return templateMessage;
5053
- }
5054
- try {
5055
- const diffSummary = await this.getStagedDiff();
5056
- const aiMessage = await this.commitMessageGenerator.generate(files, diffSummary);
5057
- if (!aiMessage) {
5058
- this.logger.warn('AI commit message generation returned null; using template');
5059
- return templateMessage;
5060
- }
5061
- // For reversions: prefix with revert info + AI description
5062
- if (this.pendingReversions.length > 0) {
5063
- const { glob, commit } = this.pendingReversions[0];
5064
- const shortCommit = commit.slice(0, 7);
5065
- return `revert: ${glob} to ${shortCommit} — ${aiMessage}`;
5066
- }
5067
- return aiMessage;
5068
- }
5069
- catch (error) {
5070
- this.logger.warn({ err: normalizeError(error) }, 'AI commit message generation failed; using template');
5071
- return templateMessage;
5072
- }
5073
- }
5074
5112
  /**
5075
5113
  * Commit a batch of files to the git repo with index.lock retry.
5076
5114
  */
@@ -5080,23 +5118,21 @@ class VcsManager {
5080
5118
  try {
5081
5119
  await retry(async (attempt) => {
5082
5120
  await gitAddViaStdin(files, this.rootPath);
5083
- // Build message after staging so getStagedDiff can see the changes
5084
- // Skip AI for baselines and retries — use template directly
5121
+ // Build message after staging so getStagedDiff can see the changes.
5122
+ // Skip AI for baselines and retries — use template directly.
5085
5123
  const message = this.isBaseline || attempt > 1
5086
- ? this.buildTemplateMessage(files.length)
5087
- : await this.buildCommitMessage(files);
5124
+ ? this.commitMessageBuilder.buildTemplateMessage(files.length, this.isBaseline, this.pendingReversions)
5125
+ : await this.commitMessageBuilder.buildCommitMessage(files, this.isBaseline, this.pendingReversions);
5088
5126
  this.pendingReversions.length = 0;
5089
- // Only clear baseline flag when all baseline files have been committed
5090
- if (this.pending.size === 0) {
5091
- this.isBaseline = false;
5092
- }
5093
5127
  await execFileAsync('git', ['commit', '-m', message], {
5094
5128
  cwd: this.rootPath,
5095
5129
  });
5096
5130
  const { stdout: hashOut } = await execFileAsync('git', ['rev-parse', '--short', 'HEAD'], { cwd: this.rootPath });
5097
5131
  const hash = hashOut.trim();
5098
5132
  this.logger.info({ root: this.rootPath, hash, fileCount: files.length }, 'VCS commit created');
5099
- await this.pushIfConfigured();
5133
+ const pushTime = await pushToRemote(this.rootPath, this.remoteUrl, this.accessToken, this._pushErrors, this.logger);
5134
+ if (pushTime)
5135
+ this._lastPushTime = pushTime;
5100
5136
  }, {
5101
5137
  attempts: 4,
5102
5138
  baseDelayMs: 500,
@@ -5114,34 +5150,6 @@ class VcsManager {
5114
5150
  this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'VCS commit failed');
5115
5151
  }
5116
5152
  }
5117
- /**
5118
- * Push to the configured remote if remoteUrl is set.
5119
- * On failure: logs the error and records it in pushErrors; never throws.
5120
- */
5121
- async pushIfConfigured() {
5122
- if (!this.remoteUrl)
5123
- return;
5124
- try {
5125
- // Build the authenticated URL if a token is available
5126
- const pushUrl = this.accessToken
5127
- ? this.remoteUrl.replace(/^https:\/\//, `https://${encodeURIComponent(this.accessToken)}@`)
5128
- : this.remoteUrl;
5129
- await execFileAsync('git', ['push', pushUrl, 'HEAD'], {
5130
- cwd: this.rootPath,
5131
- env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
5132
- });
5133
- this._lastPushTime = new Date().toISOString();
5134
- this.logger.info({ root: this.rootPath, remote: this.remoteUrl }, 'VCS push succeeded');
5135
- }
5136
- catch (error) {
5137
- const pushError = {
5138
- timestamp: new Date().toISOString(),
5139
- message: normalizeError(error).message,
5140
- };
5141
- this._pushErrors.push(pushError);
5142
- this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'VCS push failed');
5143
- }
5144
- }
5145
5153
  }
5146
5154
 
5147
5155
  /**
@@ -5197,12 +5205,10 @@ class VcsCoordinator {
5197
5205
  /**
5198
5206
  * Start all VcsManager instances.
5199
5207
  */
5200
- async start() {
5201
- const startPromises = [];
5208
+ start() {
5202
5209
  this.managers.forEach((manager) => {
5203
- startPromises.push(manager.start());
5210
+ manager.start();
5204
5211
  });
5205
- await Promise.all(startPromises);
5206
5212
  this.logger.info({ rootCount: this.managers.size }, 'VcsCoordinator started');
5207
5213
  }
5208
5214
  /**
@@ -5223,6 +5229,21 @@ class VcsCoordinator {
5223
5229
  manager.fileChanged(normalizedPath);
5224
5230
  }
5225
5231
  }
5232
+ /**
5233
+ * Signal that the initial filesystem scan is complete.
5234
+ *
5235
+ * Flushes each manager's debounce buffer before ending baseline mode, ensuring
5236
+ * any files still pending in the VCS debounce are committed with a
5237
+ * `"baseline:"` prefix rather than `"watcher:"`. AI generation is not enabled
5238
+ * until all managers have completed their flush.
5239
+ */
5240
+ async onInitialScanComplete() {
5241
+ for (const manager of this.managers.values()) {
5242
+ await manager.flush();
5243
+ manager.endBaseline();
5244
+ }
5245
+ this.logger.debug('VcsCoordinator: initial scan complete, baseline ended');
5246
+ }
5226
5247
  /**
5227
5248
  * Flush all managers and clean up.
5228
5249
  */
@@ -5265,6 +5286,68 @@ class VcsCoordinator {
5265
5286
  }
5266
5287
  }
5267
5288
 
5289
+ /**
5290
+ * @module app/watcherFactory
5291
+ * Factory helpers for creating and rebuilding the filesystem watcher.
5292
+ * Extracted from initialization.ts to follow SRP.
5293
+ */
5294
+ /**
5295
+ * Create file system watcher with gitignore filtering.
5296
+ */
5297
+ function createWatcher(config, factories, queue, processor, logger, runtimeOptions, initialScanTracker, contentHashCache, onVcsFileChange, onInitialScanComplete) {
5298
+ const respectGitignore = config.watch.respectGitignore ?? true;
5299
+ const gitignoreFilter = respectGitignore
5300
+ ? new GitignoreFilter(extractWatchPathStrings(config.watch.paths))
5301
+ : undefined;
5302
+ const watcher = factories.createFileSystemWatcher(config.watch, queue, processor, logger, {
5303
+ maxRetries: config.maxRetries ?? runtimeOptions.maxRetries,
5304
+ maxBackoffMs: config.maxBackoffMs ?? runtimeOptions.maxBackoffMs,
5305
+ onFatalError: runtimeOptions.onFatalError,
5306
+ gitignoreFilter,
5307
+ initialScanTracker,
5308
+ contentHashCache,
5309
+ onVcsFileChange,
5310
+ onInitialScanComplete,
5311
+ });
5312
+ return { watcher, gitignoreFilter };
5313
+ }
5314
+ /**
5315
+ * Tear down and rebuild the filesystem watcher with new config.
5316
+ * Falls back to the old watcher if the new one fails to start.
5317
+ */
5318
+ async function rebuildWatcher(newConfig, factories, queue, processor, logger, runtimeOptions, oldState, initialScanTracker, contentHashCache) {
5319
+ try {
5320
+ await oldState.watcher.stop();
5321
+ const { watcher: newWatcher, gitignoreFilter: newGitignoreFilter } = createWatcher(newConfig, factories, queue, processor, logger, runtimeOptions, initialScanTracker, contentHashCache);
5322
+ newWatcher.start();
5323
+ logger.info('Filesystem watcher rebuilt successfully');
5324
+ return { watcher: newWatcher, gitignoreFilter: newGitignoreFilter };
5325
+ }
5326
+ catch (error) {
5327
+ logger.error({ err: normalizeError(error) }, 'Failed to rebuild watcher, restoring previous');
5328
+ try {
5329
+ oldState.watcher.start();
5330
+ }
5331
+ catch (restartError) {
5332
+ logger.error({ err: normalizeError(restartError) }, 'Failed to restart previous watcher');
5333
+ }
5334
+ return oldState;
5335
+ }
5336
+ }
5337
+ /**
5338
+ * Check whether watch-relevant config fields changed between old and new config.
5339
+ */
5340
+ function watchConfigChanged(oldConfig, newConfig) {
5341
+ return (JSON.stringify(oldConfig.watch.paths) !==
5342
+ JSON.stringify(newConfig.watch.paths) ||
5343
+ JSON.stringify(oldConfig.watch.ignored) !==
5344
+ JSON.stringify(newConfig.watch.ignored) ||
5345
+ JSON.stringify(oldConfig.watch.moveDetection) !==
5346
+ JSON.stringify(newConfig.watch.moveDetection) ||
5347
+ (oldConfig.watch.respectGitignore ?? true) !==
5348
+ (newConfig.watch.respectGitignore ?? true));
5349
+ }
5350
+
5268
5351
  /**
5269
5352
  * @module app/initialization
5270
5353
  * Initialization helpers for JeevesWatcher. Extracted to follow SRP.
@@ -5329,25 +5412,6 @@ function createProcessorConfig(config, configDir, customMapLib) {
5329
5412
  globalSchemas: config.schemas,
5330
5413
  };
5331
5414
  }
5332
- /**
5333
- * Create file system watcher with gitignore filtering.
5334
- */
5335
- function createWatcher(config, factories, queue, processor, logger, runtimeOptions, initialScanTracker, contentHashCache, onVcsFileChange) {
5336
- const respectGitignore = config.watch.respectGitignore ?? true;
5337
- const gitignoreFilter = respectGitignore
5338
- ? new GitignoreFilter(extractWatchPathStrings(config.watch.paths))
5339
- : undefined;
5340
- const watcher = factories.createFileSystemWatcher(config.watch, queue, processor, logger, {
5341
- maxRetries: config.maxRetries ?? runtimeOptions.maxRetries,
5342
- maxBackoffMs: config.maxBackoffMs ?? runtimeOptions.maxBackoffMs,
5343
- onFatalError: runtimeOptions.onFatalError,
5344
- gitignoreFilter,
5345
- initialScanTracker,
5346
- contentHashCache,
5347
- onVcsFileChange,
5348
- });
5349
- return { watcher, gitignoreFilter };
5350
- }
5351
5415
  /**
5352
5416
  * Introspect all helpers for merged document.
5353
5417
  */
@@ -5363,42 +5427,6 @@ async function introspectHelpers(config, configDir) {
5363
5427
  function getConfigDir(configPath) {
5364
5428
  return configPath ? dirname(configPath) : '.';
5365
5429
  }
5366
- /**
5367
- * Tear down and rebuild the filesystem watcher with new config.
5368
- * Falls back to the old watcher if the new one fails to start.
5369
- */
5370
- async function rebuildWatcher(newConfig, factories, queue, processor, logger, runtimeOptions, oldState, initialScanTracker, contentHashCache) {
5371
- try {
5372
- await oldState.watcher.stop();
5373
- const { watcher: newWatcher, gitignoreFilter: newGitignoreFilter } = createWatcher(newConfig, factories, queue, processor, logger, runtimeOptions, initialScanTracker, contentHashCache);
5374
- newWatcher.start();
5375
- logger.info('Filesystem watcher rebuilt successfully');
5376
- return { watcher: newWatcher, gitignoreFilter: newGitignoreFilter };
5377
- }
5378
- catch (error) {
5379
- logger.error({ err: normalizeError(error) }, 'Failed to rebuild watcher, restoring previous');
5380
- try {
5381
- oldState.watcher.start();
5382
- }
5383
- catch (restartError) {
5384
- logger.error({ err: normalizeError(restartError) }, 'Failed to restart previous watcher');
5385
- }
5386
- return oldState;
5387
- }
5388
- }
5389
- /**
5390
- * Check whether watch-relevant config fields changed between old and new config.
5391
- */
5392
- function watchConfigChanged(oldConfig, newConfig) {
5393
- return (JSON.stringify(oldConfig.watch.paths) !==
5394
- JSON.stringify(newConfig.watch.paths) ||
5395
- JSON.stringify(oldConfig.watch.ignored) !==
5396
- JSON.stringify(newConfig.watch.ignored) ||
5397
- JSON.stringify(oldConfig.watch.moveDetection) !==
5398
- JSON.stringify(newConfig.watch.moveDetection) ||
5399
- (oldConfig.watch.respectGitignore ?? true) !==
5400
- (newConfig.watch.respectGitignore ?? true));
5401
- }
5402
5430
  /**
5403
5431
  * Resolve package version from nearest package.json.
5404
5432
  */
@@ -8470,6 +8498,37 @@ class MoveCorrelator {
8470
8498
  }
8471
8499
  }
8472
8500
 
8501
+ /**
8502
+ * @module watcher/ScanStats
8503
+ * Tracks initial filesystem scan statistics for diagnostics.
8504
+ */
8505
+ /**
8506
+ * Mutable counter bag updated during the chokidar initial scan.
8507
+ * Extracted from the watcher start() method for clarity.
8508
+ */
8509
+ class ScanStats {
8510
+ total = 0;
8511
+ matched = 0;
8512
+ globRejected = 0;
8513
+ gitignored = 0;
8514
+ byRoot;
8515
+ constructor(roots) {
8516
+ this.byRoot = Object.fromEntries(roots.map((r) => [r, 0]));
8517
+ }
8518
+ /**
8519
+ * Attribute a newly discovered file to its root for per-root diagnostics.
8520
+ */
8521
+ classifyByRoot(path) {
8522
+ const normalized = path.replace(/\\/g, '/').toLowerCase();
8523
+ for (const root of Object.keys(this.byRoot)) {
8524
+ if (normalized.startsWith(`${root}/`) || normalized === root) {
8525
+ this.byRoot[root] = (this.byRoot[root] ?? 0) + 1;
8526
+ break;
8527
+ }
8528
+ }
8529
+ }
8530
+ }
8531
+
8473
8532
  /**
8474
8533
  * @module watcher
8475
8534
  * Filesystem watcher wrapping chokidar. I/O: watches files/directories for add/change/unlink events, enqueues to processing queue.
@@ -8487,6 +8546,7 @@ class FileSystemWatcher {
8487
8546
  initialScanTracker;
8488
8547
  contentHashCache;
8489
8548
  onVcsFileChange;
8549
+ onInitialScanComplete;
8490
8550
  moveCorrelator;
8491
8551
  globMatches;
8492
8552
  watcher;
@@ -8508,6 +8568,7 @@ class FileSystemWatcher {
8508
8568
  this.initialScanTracker = options.initialScanTracker;
8509
8569
  this.contentHashCache = options.contentHashCache;
8510
8570
  this.onVcsFileChange = options.onVcsFileChange;
8571
+ this.onInitialScanComplete = options.onInitialScanComplete;
8511
8572
  this.globMatches = () => true;
8512
8573
  const healthOptions = {
8513
8574
  maxRetries: options.maxRetries,
@@ -8535,24 +8596,9 @@ class FileSystemWatcher {
8535
8596
  ? resolveIgnored(this.config.ignored)
8536
8597
  : undefined;
8537
8598
  // Track initial scan statistics per root for diagnostics.
8538
- const scanStats = {
8539
- total: 0,
8540
- matched: 0,
8541
- globRejected: 0,
8542
- gitignored: 0,
8543
- byRoot: Object.fromEntries(roots.map((r) => [r, 0])),
8544
- };
8599
+ const scanStats = new ScanStats(roots);
8545
8600
  let initialScanComplete = false;
8546
8601
  this.initialScanTracker?.start();
8547
- const classifyByRoot = (path) => {
8548
- const normalized = path.replace(/\\/g, '/').toLowerCase();
8549
- for (const root of roots) {
8550
- if (normalized.startsWith(root + '/') || normalized === root) {
8551
- scanStats.byRoot[root] = (scanStats.byRoot[root] ?? 0) + 1;
8552
- break;
8553
- }
8554
- }
8555
- };
8556
8602
  // Create move correlator if move detection is configured and cache is available.
8557
8603
  const moveConfig = this.config.moveDetection;
8558
8604
  if (moveConfig?.enabled && this.contentHashCache) {
@@ -8585,7 +8631,7 @@ class FileSystemWatcher {
8585
8631
  this.watcher.on('add', (path) => {
8586
8632
  if (!initialScanComplete) {
8587
8633
  scanStats.total++;
8588
- classifyByRoot(path);
8634
+ scanStats.classifyByRoot(path);
8589
8635
  }
8590
8636
  this.handleGitignoreChange(path);
8591
8637
  if (!this.globMatches(path)) {
@@ -8606,8 +8652,7 @@ class FileSystemWatcher {
8606
8652
  this.initialScanTracker?.incrementEnqueued();
8607
8653
  }
8608
8654
  this.logger.debug({ path }, 'File added');
8609
- if (initialScanComplete)
8610
- this.onVcsFileChange?.(path, 'add');
8655
+ this.onVcsFileChange?.(path, 'add');
8611
8656
  if (correlator && initialScanComplete) {
8612
8657
  void correlator.handleAdd(path);
8613
8658
  }
@@ -8646,6 +8691,7 @@ class FileSystemWatcher {
8646
8691
  initialScanComplete = true;
8647
8692
  this.initialScanTracker?.complete();
8648
8693
  this.logger.info({ scanStats }, 'Initial scan complete');
8694
+ void this.onInitialScanComplete?.();
8649
8695
  });
8650
8696
  this.watcher.on('error', (error) => {
8651
8697
  this.logger.error({ err: normalizeError(error) }, 'Watcher error');
@@ -8919,6 +8965,12 @@ class JeevesWatcher {
8919
8965
  * Start the watcher, API server, and all components.
8920
8966
  */
8921
8967
  async start() {
8968
+ await this.initInfrastructure();
8969
+ await this.initPipeline();
8970
+ await this.startServicesAndWatcher();
8971
+ }
8972
+ /** Set up logger, VCS, embedding provider, and vector store. */
8973
+ async initInfrastructure() {
8922
8974
  const logger = this.factories.createLogger(this.config.logging);
8923
8975
  this.logger = logger;
8924
8976
  await initVcs(this.config, logger);
@@ -8928,6 +8980,10 @@ class JeevesWatcher {
8928
8980
  if (this.config.search?.hybrid?.enabled) {
8929
8981
  await vectorStore.ensureTextIndex('chunk_text');
8930
8982
  }
8983
+ }
8984
+ /** Build the processing pipeline: rules, templates, processor, queue, VCS. */
8985
+ async initPipeline() {
8986
+ const logger = this.logger;
8931
8987
  const compiledRules = this.factories.compileRules(this.config.inferenceRules ?? []);
8932
8988
  const configDir = getConfigDir(this.configPath);
8933
8989
  const { templateEngine, customMapLib } = await buildTemplateEngineAndCustomMapLib(this.config, configDir);
@@ -8937,36 +8993,39 @@ class JeevesWatcher {
8937
8993
  this.issuesManager = new IssuesManager(stateDir, logger);
8938
8994
  this.valuesManager = new ValuesManager(stateDir, logger);
8939
8995
  this.enrichmentStore = new EnrichmentStore(stateDir, logger);
8940
- const enrichmentStore = this.enrichmentStore;
8941
8996
  this.contentHashCache = new ContentHashCache();
8942
- const contentHashCache = this.contentHashCache;
8943
- const processor = this.factories.createDocumentProcessor({
8997
+ this.processor = this.factories.createDocumentProcessor({
8944
8998
  config: processorConfig,
8945
- embeddingProvider,
8946
- vectorStore,
8999
+ embeddingProvider: this.embeddingProvider,
9000
+ vectorStore: this.vectorStore,
8947
9001
  compiledRules,
8948
9002
  logger,
8949
9003
  templateEngine,
8950
- enrichmentStore,
9004
+ enrichmentStore: this.enrichmentStore,
8951
9005
  issuesManager: this.issuesManager,
8952
9006
  valuesManager: this.valuesManager,
8953
- contentHashCache,
9007
+ contentHashCache: this.contentHashCache,
8954
9008
  });
8955
- this.processor = processor;
8956
9009
  this.queue = this.factories.createEventQueue({
8957
9010
  debounceMs: this.config.watch.debounceMs ?? 2000,
8958
9011
  concurrency: this.config.embedding.concurrency ?? 5,
8959
9012
  rateLimitPerMinute: this.config.embedding.rateLimitPerMinute,
8960
9013
  });
8961
- const vcsCoordinator = new VcsCoordinator(this.config, logger);
8962
- this.vcsCoordinator = vcsCoordinator;
8963
- const { watcher, gitignoreFilter } = createWatcher(this.config, this.factories, this.queue, processor, logger, this.runtimeOptions, this.initialScanTracker, contentHashCache, (filePath, event) => {
9014
+ this.vcsCoordinator = new VcsCoordinator(this.config, logger);
9015
+ }
9016
+ /** Wire up watcher and VCS coordinator, then start all services. */
9017
+ async startServicesAndWatcher() {
9018
+ const logger = this.logger;
9019
+ const vcsCoordinator = this.vcsCoordinator;
9020
+ const { watcher, gitignoreFilter } = createWatcher(this.config, this.factories, this.queue, this.processor, logger, this.runtimeOptions, this.initialScanTracker, this.contentHashCache, (filePath, event) => {
8964
9021
  vcsCoordinator.onFileChange(filePath, event);
9022
+ }, () => {
9023
+ void vcsCoordinator.onInitialScanComplete();
8965
9024
  });
8966
9025
  this.watcher = watcher;
8967
9026
  this.gitignoreFilter = gitignoreFilter;
8968
9027
  this.server = await this.startApiServer();
8969
- await vcsCoordinator.start();
9028
+ vcsCoordinator.start();
8970
9029
  this.watcher.start();
8971
9030
  this.startConfigWatch();
8972
9031
  logger.info('jeeves-watcher started');