@karmaniverous/jeeves-watcher 0.18.7 → 0.18.9

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
@@ -1,13 +1,15 @@
1
1
  import { join, dirname, resolve, relative, extname, basename, isAbsolute, sep } from 'node:path';
2
2
  import { createConfigQueryHandler as createConfigQueryHandler$1, createStatusHandler, createConfigApplyHandler, substituteEnvVars, DEFAULT_PORTS, getBindAddress, postJson, fetchJson, getPackageVersion } from '@karmaniverous/jeeves';
3
- import { extractWatchPathStrings, inferenceRuleSchema, jeevesWatcherConfigSchema, normalizeWatchPaths, vcsAuthorConfigSchema, LOGGING_DEFAULTS, API_DEFAULTS, EMBEDDING_DEFAULTS, CONFIG_WATCH_DEFAULTS, WATCH_DEFAULTS, ROOT_DEFAULTS, INIT_CONFIG_TEMPLATE, DEFAULT_PORT as DEFAULT_PORT$1, PLUGIN_PACKAGE, SERVICE_PACKAGE, COMPONENT_NAME } from '@karmaniverous/jeeves-watcher-core';
3
+ import { extractWatchPathStrings, inferenceRuleSchema, jeevesWatcherConfigSchema, normalizeWatchPaths, vcsRetentionConfigSchema, vcsAuthorConfigSchema, LOGGING_DEFAULTS, API_DEFAULTS, EMBEDDING_DEFAULTS, CONFIG_WATCH_DEFAULTS, WATCH_DEFAULTS, ROOT_DEFAULTS, INIT_CONFIG_TEMPLATE, DEFAULT_PORT as DEFAULT_PORT$1, PLUGIN_PACKAGE, SERVICE_PACKAGE, COMPONENT_NAME } from '@karmaniverous/jeeves-watcher-core';
4
4
  export { apiConfigSchema, configWatchConfigSchema, embeddingConfigSchema, inferenceRuleSchema, jeevesWatcherConfigSchema, loggingConfigSchema, vectorStoreConfigSchema, watchConfigSchema } from '@karmaniverous/jeeves-watcher-core';
5
5
  import Fastify from 'fastify';
6
- import { readdir, stat, readFile, writeFile, mkdir, appendFile, access, rm } from 'node:fs/promises';
6
+ import { readdir, stat, access, readFile, writeFile, mkdir, appendFile, rm } from 'node:fs/promises';
7
7
  import { parallel, capitalize, title, camel, snake, dash, isEqual, get, omit } from 'radash';
8
8
  import { existsSync, statSync, readFileSync, readdirSync, mkdirSync, writeFileSync, rmSync, renameSync } from 'node:fs';
9
9
  import ignore from 'ignore';
10
10
  import picomatch from 'picomatch';
11
+ import { execFile } from 'node:child_process';
12
+ import { promisify } from 'node:util';
11
13
  import Ajv from 'ajv';
12
14
  import addFormats from 'ajv-formats';
13
15
  import { z, ZodError } from 'zod';
@@ -21,8 +23,6 @@ import { toMarkdown } from 'mdast-util-to-markdown';
21
23
  import rehypeParse from 'rehype-parse';
22
24
  import { unified } from 'unified';
23
25
  import yaml from 'js-yaml';
24
- import { execFile } from 'node:child_process';
25
- import { promisify } from 'node:util';
26
26
  import crypto from 'crypto';
27
27
  import { packageDirectorySync } from 'package-directory';
28
28
  import { homedir } from 'node:os';
@@ -421,6 +421,77 @@ async function retry(fn, options) {
421
421
  throw lastError;
422
422
  }
423
423
 
424
+ /**
425
+ * @module vcs/gitExec
426
+ * Shared git execution utilities.
427
+ */
428
+ /** Promisified execFile for git commands. */
429
+ const execFileAsync = promisify(execFile);
430
+ /**
431
+ * Stage files via stdin to avoid ENAMETOOLONG on Windows.
432
+ *
433
+ * Uses `git add --pathspec-from-file=- --pathspec-file-nul` so the file list is piped
434
+ * through stdin (NUL-delimited) instead of passed as command-line
435
+ * arguments. This sidesteps the Windows CreateProcessW 32 767-char
436
+ * limit that triggers ENAMETOOLONG when batches contain many files
437
+ * with long absolute paths.
438
+ *
439
+ * @param files - Absolute paths of files to stage.
440
+ * @param cwd - Repository root (working directory for git).
441
+ */
442
+ function gitAddViaStdin(files, cwd) {
443
+ return new Promise((resolve, reject) => {
444
+ const child = execFile('git', ['add', '--pathspec-from-file=-', '--pathspec-file-nul'], { cwd }, (error) => {
445
+ if (error)
446
+ reject(error);
447
+ else
448
+ resolve();
449
+ });
450
+ if (!child.stdin) {
451
+ reject(new Error('Failed to open stdin for git add'));
452
+ return;
453
+ }
454
+ // Suppress EPIPE — expected if git exits before we finish writing
455
+ child.stdin.on('error', (err) => {
456
+ if (err.code !== 'EPIPE') {
457
+ reject(err);
458
+ }
459
+ });
460
+ child.stdin.end(files.join('\0'));
461
+ });
462
+ }
463
+ /**
464
+ * Normalize a path for case-insensitive comparison on Windows.
465
+ * On Windows, lowercases the entire path; on other platforms, returns as-is.
466
+ *
467
+ * @param p - The path string to normalize.
468
+ * @param platform - The platform to check against (default: `process.platform`).
469
+ * @returns The normalized path.
470
+ */
471
+ function normalizePathCase(p, platform = process.platform) {
472
+ return platform === 'win32' ? p.toLowerCase() : p;
473
+ }
474
+ /**
475
+ * Find the longest-prefix-match root for a normalized path.
476
+ * Roots must be sorted longest-first for correct nested matching.
477
+ *
478
+ * @param roots - Root paths sorted longest-first.
479
+ * @param normalizedPath - Normalized absolute path (forward slashes).
480
+ * @param platform - The platform to check against (default: `process.platform`).
481
+ * @returns The matching root, or undefined.
482
+ */
483
+ function findRootForPath(roots, normalizedPath, platform = process.platform) {
484
+ const comparePath = normalizePathCase(normalizedPath, platform);
485
+ for (const root of roots) {
486
+ const compareRoot = normalizePathCase(root, platform);
487
+ if (comparePath === compareRoot ||
488
+ comparePath.startsWith(compareRoot + '/')) {
489
+ return root;
490
+ }
491
+ }
492
+ return undefined;
493
+ }
494
+
424
495
  /**
425
496
  * Best-effort base directory inference for a glob pattern.
426
497
  *
@@ -638,7 +709,7 @@ function groupByRoot(filePaths, watchPaths) {
638
709
  /** Default page size for prune scroll (smaller than search to reduce connection strain). */
639
710
  const PRUNE_SCROLL_PAGE_SIZE = 500;
640
711
  /** Max retry attempts for scroll page failures. */
641
- const SCROLL_RETRY_ATTEMPTS = 3;
712
+ const SCROLL_RETRY_ATTEMPTS = 5;
642
713
  /** Base delay for scroll retry backoff (ms). */
643
714
  const SCROLL_RETRY_BASE_DELAY_MS = 1000;
644
715
  /**
@@ -664,26 +735,42 @@ async function scrollPageWithRetry(vectorStore, cursor, logger) {
664
735
  }
665
736
  throw lastError;
666
737
  }
738
+ /** Concurrency limit for fs.access() disk-existence checks. */
739
+ const FS_ACCESS_CONCURRENCY = 100;
667
740
  /**
668
- * Compute the plan for a prune operation.
669
- * Scrolls all Qdrant points page-by-page with retry/resume,
670
- * checks each file_path against watch scope + gitignore,
671
- * returns the list of orphaned point IDs and the plan.
741
+ * Compute the plan for a prune operation using a filter-first approach.
742
+ *
743
+ * 1. Enumerate all watched files from the filesystem → `watchedFiles` set.
744
+ * 2. Scroll Qdrant points. For each point:
745
+ * - No `file_path` → orphan.
746
+ * - `file_path` not in `watchedFiles` → orphan (out of watch scope).
747
+ * - `file_path` in `watchedFiles` → check disk existence via `fs.access()`.
748
+ * If missing from disk → orphan (deleted file still in index).
749
+ * 3. Batch `fs.access()` checks with bounded concurrency.
672
750
  */
673
751
  async function computePrunePlan(deps) {
674
752
  const { config, vectorStore, gitignoreFilter, logger } = deps;
675
753
  if (!vectorStore) {
676
754
  throw new Error('vectorStore is required for prune scope');
677
755
  }
756
+ // Step 1: Build set of watched files from filesystem
757
+ const isGitignored = createIsGitignored(gitignoreFilter);
758
+ const watchedFileList = await listFilesFromGlobs(extractWatchPathStrings(config.watch.paths), config.watch.ignored, isGitignored);
759
+ const watchedFiles = new Set(watchedFileList.map((f) => normalizePathCase(normalizeSlashes(f))));
760
+ logger.info({ watchedFileCount: watchedFiles.size }, 'Prune: enumerated watched files from filesystem');
761
+ // Step 2: Scroll Qdrant and classify points
678
762
  const seenFiles = new Set();
679
763
  const orphanedFiles = new Set();
764
+ const diskCheckFiles = new Set();
680
765
  const orphanedIds = [];
766
+ // Map from file_path → point IDs, for points needing disk-existence check
767
+ const diskCheckPointIds = new Map();
681
768
  let totalPoints = 0;
682
769
  let incomplete = false;
683
770
  let cursor;
684
771
  try {
685
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- cursor is updated each iteration
686
- while (true) {
772
+ let hasMore = true;
773
+ while (hasMore) {
687
774
  const page = await scrollPageWithRetry(vectorStore, cursor, logger);
688
775
  for (const point of page.points) {
689
776
  totalPoints++;
@@ -692,32 +779,62 @@ async function computePrunePlan(deps) {
692
779
  orphanedIds.push(point.id);
693
780
  continue;
694
781
  }
695
- if (seenFiles.has(filePath)) {
696
- if (orphanedFiles.has(filePath)) {
782
+ const normalized = normalizePathCase(normalizeSlashes(filePath));
783
+ if (seenFiles.has(normalized)) {
784
+ if (orphanedFiles.has(normalized)) {
697
785
  orphanedIds.push(point.id);
698
786
  }
787
+ else if (diskCheckFiles.has(normalized)) {
788
+ // Already queued for disk check — track its ID
789
+ const ids = diskCheckPointIds.get(normalized);
790
+ if (ids)
791
+ ids.push(point.id);
792
+ }
699
793
  continue;
700
794
  }
701
- seenFiles.add(filePath);
702
- const watched = isPathWatched(filePath, extractWatchPathStrings(config.watch.paths), config.watch.ignored);
703
- const gitignored = gitignoreFilter
704
- ? gitignoreFilter.isIgnored(filePath)
705
- : false;
706
- if (!watched || gitignored) {
707
- orphanedFiles.add(filePath);
795
+ seenFiles.add(normalized);
796
+ if (!watchedFiles.has(normalized)) {
797
+ // Not in watch scope → definitely orphaned
798
+ orphanedFiles.add(normalized);
708
799
  orphanedIds.push(point.id);
709
800
  }
801
+ else {
802
+ // In watch scope → needs disk-existence check
803
+ diskCheckFiles.add(normalized);
804
+ diskCheckPointIds.set(normalized, [point.id]);
805
+ }
806
+ }
807
+ // Progress logging every 100K points
808
+ if (totalPoints > 0 && totalPoints % 100000 === 0) {
809
+ logger.info({ totalPoints, orphanedCount: orphanedIds.length }, 'Prune scroll progress');
810
+ }
811
+ if (!page.nextCursor) {
812
+ hasMore = false;
813
+ }
814
+ else {
815
+ cursor = page.nextCursor;
710
816
  }
711
- if (!page.nextCursor)
712
- break;
713
- cursor = page.nextCursor;
714
817
  }
715
818
  }
716
819
  catch (error) {
717
- // All retries exhausted - return partial results
718
820
  logger.error({ err: normalizeError(error), totalPoints, cursor }, 'Prune scroll failed after retries; returning partial plan');
719
821
  incomplete = true;
720
822
  }
823
+ // Step 3: Batch fs.access() checks for files in watch scope
824
+ const filesToCheck = [...diskCheckFiles];
825
+ await parallel(FS_ACCESS_CONCURRENCY, filesToCheck, async (filePath) => {
826
+ try {
827
+ await access(filePath);
828
+ }
829
+ catch {
830
+ // File doesn't exist on disk → orphaned
831
+ orphanedFiles.add(filePath);
832
+ const pointIds = diskCheckPointIds.get(filePath);
833
+ if (pointIds) {
834
+ orphanedIds.push(...pointIds);
835
+ }
836
+ }
837
+ });
721
838
  const orphanedFileList = [...orphanedFiles];
722
839
  const byRoot = groupByRoot(orphanedFileList, extractWatchPathStrings(config.watch.paths));
723
840
  return {
@@ -775,6 +892,12 @@ async function executeReindex(deps, scope, path, dryRun = false) {
775
892
  deps.queue?.resume();
776
893
  return { filesProcessed: 0, durationMs: 0, errors: 0, plan };
777
894
  }
895
+ // Abort live prune when plan is incomplete — never silently delete partial data
896
+ if (plan.incomplete) {
897
+ deps.queue?.resume();
898
+ logger.error({ totalPoints: plan.total, pointsToDelete: plan.toDelete }, 'Aborting live prune — scroll was incomplete; partial deletes are unsafe');
899
+ return { filesProcessed: 0, durationMs: 0, errors: 1, plan };
900
+ }
778
901
  // Execute prune
779
902
  reindexTracker?.start(scope);
780
903
  const startTime = Date.now();
@@ -3369,77 +3492,6 @@ function createSearchHandler(deps) {
3369
3492
  }, deps.logger, 'Search');
3370
3493
  }
3371
3494
 
3372
- /**
3373
- * @module vcs/gitExec
3374
- * Shared git execution utilities.
3375
- */
3376
- /** Promisified execFile for git commands. */
3377
- const execFileAsync = promisify(execFile);
3378
- /**
3379
- * Stage files via stdin to avoid ENAMETOOLONG on Windows.
3380
- *
3381
- * Uses `git add --pathspec-from-file=- --pathspec-file-nul` so the file list is piped
3382
- * through stdin (NUL-delimited) instead of passed as command-line
3383
- * arguments. This sidesteps the Windows CreateProcessW 32 767-char
3384
- * limit that triggers ENAMETOOLONG when batches contain many files
3385
- * with long absolute paths.
3386
- *
3387
- * @param files - Absolute paths of files to stage.
3388
- * @param cwd - Repository root (working directory for git).
3389
- */
3390
- function gitAddViaStdin(files, cwd) {
3391
- return new Promise((resolve, reject) => {
3392
- const child = execFile('git', ['add', '--pathspec-from-file=-', '--pathspec-file-nul'], { cwd }, (error) => {
3393
- if (error)
3394
- reject(error);
3395
- else
3396
- resolve();
3397
- });
3398
- if (!child.stdin) {
3399
- reject(new Error('Failed to open stdin for git add'));
3400
- return;
3401
- }
3402
- // Suppress EPIPE — expected if git exits before we finish writing
3403
- child.stdin.on('error', (err) => {
3404
- if (err.code !== 'EPIPE') {
3405
- reject(err);
3406
- }
3407
- });
3408
- child.stdin.end(files.join('\0'));
3409
- });
3410
- }
3411
- /**
3412
- * Normalize a path for case-insensitive comparison on Windows.
3413
- * On Windows, lowercases the entire path; on other platforms, returns as-is.
3414
- *
3415
- * @param p - The path string to normalize.
3416
- * @param platform - The platform to check against (default: `process.platform`).
3417
- * @returns The normalized path.
3418
- */
3419
- function normalizePathCase(p, platform = process.platform) {
3420
- return platform === 'win32' ? p.toLowerCase() : p;
3421
- }
3422
- /**
3423
- * Find the longest-prefix-match root for a normalized path.
3424
- * Roots must be sorted longest-first for correct nested matching.
3425
- *
3426
- * @param roots - Root paths sorted longest-first.
3427
- * @param normalizedPath - Normalized absolute path (forward slashes).
3428
- * @param platform - The platform to check against (default: `process.platform`).
3429
- * @returns The matching root, or undefined.
3430
- */
3431
- function findRootForPath(roots, normalizedPath, platform = process.platform) {
3432
- const comparePath = normalizePathCase(normalizedPath, platform);
3433
- for (const root of roots) {
3434
- const compareRoot = normalizePathCase(root, platform);
3435
- if (comparePath === compareRoot ||
3436
- comparePath.startsWith(compareRoot + '/')) {
3437
- return root;
3438
- }
3439
- }
3440
- return undefined;
3441
- }
3442
-
3443
3495
  /**
3444
3496
  * @module vcs/resolveWatchRoot
3445
3497
  * Resolves which VCS-enabled watch root(s) own a given absolute path or glob.
@@ -4193,6 +4245,91 @@ async function introspectAllHelpers(config, configDir) {
4193
4245
  return result;
4194
4246
  }
4195
4247
 
4248
+ /**
4249
+ * @module vcs/CommitMessageBuilder
4250
+ * Builds commit messages — both template-based and AI-generated.
4251
+ */
4252
+ /**
4253
+ * Builds commit messages for VCS commits.
4254
+ * Supports template-based messages and AI-generated messages with fallback.
4255
+ */
4256
+ class CommitMessageBuilder {
4257
+ rootPath;
4258
+ logger;
4259
+ generator;
4260
+ constructor(rootPath, logger, generator) {
4261
+ this.rootPath = rootPath;
4262
+ this.logger = logger;
4263
+ this.generator = generator;
4264
+ }
4265
+ /**
4266
+ * Build the template commit message (no AI).
4267
+ */
4268
+ buildTemplateMessage(fileCount, isBaseline, pendingReversions) {
4269
+ if (pendingReversions.length === 0) {
4270
+ if (isBaseline) {
4271
+ return `baseline: batch (${String(fileCount)} files)`;
4272
+ }
4273
+ const timestamp = new Date().toISOString();
4274
+ return `watcher: batch ${timestamp} (${String(fileCount)} files)`;
4275
+ }
4276
+ // Count total reverted files across all pending reversions
4277
+ const revertedPaths = new Set(pendingReversions.flatMap((r) => r.paths));
4278
+ const revertedCount = revertedPaths.size;
4279
+ const otherCount = fileCount - revertedCount;
4280
+ // Use the first reversion's glob and commit for the message
4281
+ const { glob, commit } = pendingReversions[0];
4282
+ const shortCommit = commit.slice(0, 7);
4283
+ let message = `revert: ${glob} to ${shortCommit} — restored ${String(revertedCount)} files`;
4284
+ if (otherCount > 0) {
4285
+ message += ` (+ ${String(otherCount)} other changes)`;
4286
+ }
4287
+ return message;
4288
+ }
4289
+ /**
4290
+ * Build the commit message, using AI when available with template fallback.
4291
+ */
4292
+ async buildCommitMessage(files, isBaseline, pendingReversions) {
4293
+ const fileCount = files.length;
4294
+ const templateMessage = this.buildTemplateMessage(fileCount, isBaseline, pendingReversions);
4295
+ if (!this.generator) {
4296
+ return templateMessage;
4297
+ }
4298
+ try {
4299
+ const diffSummary = await this.getStagedDiff();
4300
+ const aiMessage = await this.generator.generate(files, diffSummary);
4301
+ if (!aiMessage) {
4302
+ this.logger.warn('AI commit message generation returned null; using template');
4303
+ return templateMessage;
4304
+ }
4305
+ // For reversions: prefix with revert info + AI description
4306
+ if (pendingReversions.length > 0) {
4307
+ const { glob, commit } = pendingReversions[0];
4308
+ const shortCommit = commit.slice(0, 7);
4309
+ return `revert: ${glob} to ${shortCommit} — ${aiMessage}`;
4310
+ }
4311
+ return aiMessage;
4312
+ }
4313
+ catch (error) {
4314
+ this.logger.warn({ err: normalizeError(error) }, 'AI commit message generation failed; using template');
4315
+ return templateMessage;
4316
+ }
4317
+ }
4318
+ /**
4319
+ * Get the staged diff for AI context.
4320
+ */
4321
+ async getStagedDiff() {
4322
+ try {
4323
+ const { stdout: stat } = await execFileAsync('git', ['diff', '--cached', '--stat'], { cwd: this.rootPath });
4324
+ const { stdout: patch } = await execFileAsync('git', ['diff', '--cached'], { cwd: this.rootPath });
4325
+ return `${stat}\n${patch}`;
4326
+ }
4327
+ catch {
4328
+ return '';
4329
+ }
4330
+ }
4331
+ }
4332
+
4196
4333
  /**
4197
4334
  * @module vcs/CommitMessageGenerator
4198
4335
  * Generates AI-powered commit messages via the Anthropic Messages API.
@@ -4819,6 +4956,50 @@ function resolveIgnored(ignored) {
4819
4956
  });
4820
4957
  }
4821
4958
 
4959
+ /**
4960
+ * @module vcs/vcsPush
4961
+ * Push logic for VCS commits.
4962
+ */
4963
+ /**
4964
+ * Push to the configured remote if remoteUrl is set.
4965
+ *
4966
+ * On failure: logs the error, appends to pushErrors, and returns undefined.
4967
+ * Never throws.
4968
+ *
4969
+ * @param rootPath - Absolute path of the git repository.
4970
+ * @param remoteUrl - Remote URL to push to, or undefined to skip.
4971
+ * @param accessToken - Optional token for HTTPS authentication.
4972
+ * @param pushErrors - Mutable array where push errors are recorded.
4973
+ * @param logger - Logger instance.
4974
+ * @returns ISO timestamp of a successful push, or undefined if skipped/failed.
4975
+ */
4976
+ async function pushToRemote(rootPath, remoteUrl, accessToken, pushErrors, logger) {
4977
+ if (!remoteUrl)
4978
+ return undefined;
4979
+ try {
4980
+ // Build the authenticated URL if a token is available
4981
+ const pushUrl = accessToken
4982
+ ? remoteUrl.replace(/^https:\/\//, `https://${encodeURIComponent(accessToken)}@`)
4983
+ : remoteUrl;
4984
+ await execFileAsync('git', ['push', pushUrl, 'HEAD'], {
4985
+ cwd: rootPath,
4986
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
4987
+ });
4988
+ const timestamp = new Date().toISOString();
4989
+ logger.info({ root: rootPath, remote: remoteUrl }, 'VCS push succeeded');
4990
+ return timestamp;
4991
+ }
4992
+ catch (error) {
4993
+ const pushError = {
4994
+ timestamp: new Date().toISOString(),
4995
+ message: normalizeError(error).message,
4996
+ };
4997
+ pushErrors.push(pushError);
4998
+ logger.error({ root: rootPath, err: normalizeError(error) }, 'VCS push failed');
4999
+ return undefined;
5000
+ }
5001
+ }
5002
+
4822
5003
  /**
4823
5004
  * @module vcs/VcsManager
4824
5005
  * Per-root VCS manager for git-backed content versioning.
@@ -4839,23 +5020,24 @@ class VcsManager {
4839
5020
  remoteUrl;
4840
5021
  accessToken;
4841
5022
  logger;
4842
- commitMessageGenerator;
5023
+ commitMessageBuilder;
4843
5024
  pending = new Set();
4844
5025
  pendingReversions = [];
4845
5026
  _pushErrors = [];
4846
5027
  squashManager;
4847
5028
  debounceTimer;
4848
5029
  commitInFlight = Promise.resolve();
5030
+ consecutiveCommitFailures = 0;
4849
5031
  started = false;
4850
- isBaseline = false;
5032
+ isBaseline = true;
4851
5033
  _lastPushTime = null;
4852
5034
  constructor(rootPath, config, logger, commitMessageGenerator, remoteUrl, accessToken) {
4853
5035
  this.rootPath = rootPath;
4854
5036
  this.config = config;
4855
5037
  this.logger = logger;
4856
- this.commitMessageGenerator = commitMessageGenerator;
4857
5038
  this.remoteUrl = remoteUrl;
4858
5039
  this.accessToken = accessToken;
5040
+ this.commitMessageBuilder = new CommitMessageBuilder(rootPath, logger, commitMessageGenerator);
4859
5041
  if (config.retention) {
4860
5042
  this.squashManager = new SquashManager(rootPath, config.retention, logger, remoteUrl, accessToken);
4861
5043
  }
@@ -4867,48 +5049,23 @@ class VcsManager {
4867
5049
  return this._pushErrors;
4868
5050
  }
4869
5051
  /**
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.
5052
+ * Begin accepting file changes. Sets the manager to started state and
5053
+ * starts the squash manager if configured.
5054
+ *
5055
+ * Baseline mode (commit prefix `"baseline:"`) remains active until
5056
+ * {@link endBaseline} is called by the coordinator after the initial scan.
4872
5057
  */
4873
- async start() {
5058
+ start() {
4874
5059
  this.started = true;
4875
5060
  this.squashManager?.start();
4876
5061
  this.logger.info({ root: this.rootPath }, 'VcsManager started');
4877
- await this.enqueueBaselineIfNeeded();
4878
5062
  }
4879
5063
  /**
4880
- * If the repo has no commits, enumerate untracked files and feed them
4881
- * through fileChanged() to create a baseline commit.
5064
+ * Signal that the initial filesystem scan is complete.
5065
+ * Clears the baseline flag so subsequent commits use normal "watcher: batch" messages.
4882
5066
  */
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
- }
5067
+ endBaseline() {
5068
+ this.isBaseline = false;
4912
5069
  }
4913
5070
  /**
4914
5071
  * Add a file to the pending set and reset the debounce timer.
@@ -4919,6 +5076,11 @@ class VcsManager {
4919
5076
  fileChanged(filePath) {
4920
5077
  if (!this.started)
4921
5078
  return;
5079
+ // Reset circuit breaker on new file change so the system can recover
5080
+ if (this.consecutiveCommitFailures >= this.config.maxConsecutiveFailures) {
5081
+ this.logger.info({ root: this.rootPath }, 'VCS circuit breaker reset — new file change received, retrying commits');
5082
+ this.consecutiveCommitFailures = 0;
5083
+ }
4922
5084
  this.pending.add(filePath);
4923
5085
  if (this.pending.size >= this.config.maxBatchSize) {
4924
5086
  this.clearDebounce();
@@ -5006,97 +5168,61 @@ class VcsManager {
5006
5168
  }, this.config.commitDebounceMs);
5007
5169
  }
5008
5170
  /**
5009
- * Get the cached diff for staged files, for AI context.
5171
+ * Check for and remove stale index.lock files before retrying.
5010
5172
  */
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
- }
5173
+ async removeStaleLock() {
5174
+ const lockPath = join(this.rootPath, '.git', 'index.lock');
5054
5175
  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;
5176
+ const lockStat = await stat(lockPath);
5177
+ const ageMs = Date.now() - lockStat.mtimeMs;
5178
+ if (ageMs > this.config.staleLockThresholdMs) {
5179
+ await rm(lockPath, { force: true });
5180
+ this.logger.warn({ root: this.rootPath, ageMs }, 'Removed stale index.lock');
5060
5181
  }
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
5182
  }
5069
5183
  catch (error) {
5070
- this.logger.warn({ err: normalizeError(error) }, 'AI commit message generation failed; using template');
5071
- return templateMessage;
5184
+ const code = error.code;
5185
+ if (code !== 'ENOENT') {
5186
+ this.logger.warn({ root: this.rootPath, err: normalizeError(error) }, 'Unable to remove stale index.lock');
5187
+ }
5072
5188
  }
5073
5189
  }
5074
5190
  /**
5075
- * Commit a batch of files to the git repo with index.lock retry.
5191
+ * Commit a batch of files to the git repo with index.lock retry,
5192
+ * stale lock detection, circuit breaker, and re-queue cap.
5076
5193
  */
5077
5194
  async commitBatch(files) {
5078
5195
  if (files.length === 0)
5079
5196
  return;
5197
+ // Circuit breaker: skip if too many consecutive failures
5198
+ if (this.consecutiveCommitFailures >= this.config.maxConsecutiveFailures) {
5199
+ this.logger.error({
5200
+ root: this.rootPath,
5201
+ consecutiveFailures: this.consecutiveCommitFailures,
5202
+ discardedFiles: files.length,
5203
+ }, 'VCS circuit breaker tripped — pending files discarded until next successful commit');
5204
+ return;
5205
+ }
5080
5206
  try {
5207
+ // Check for stale lock before first attempt
5208
+ await this.removeStaleLock();
5081
5209
  await retry(async (attempt) => {
5082
5210
  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
5211
+ // Build message after staging so getStagedDiff can see the changes.
5212
+ // Skip AI for baselines and retries — use template directly.
5085
5213
  const message = this.isBaseline || attempt > 1
5086
- ? this.buildTemplateMessage(files.length)
5087
- : await this.buildCommitMessage(files);
5214
+ ? this.commitMessageBuilder.buildTemplateMessage(files.length, this.isBaseline, this.pendingReversions)
5215
+ : await this.commitMessageBuilder.buildCommitMessage(files, this.isBaseline, this.pendingReversions);
5088
5216
  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
5217
  await execFileAsync('git', ['commit', '-m', message], {
5094
5218
  cwd: this.rootPath,
5095
5219
  });
5096
5220
  const { stdout: hashOut } = await execFileAsync('git', ['rev-parse', '--short', 'HEAD'], { cwd: this.rootPath });
5097
5221
  const hash = hashOut.trim();
5098
5222
  this.logger.info({ root: this.rootPath, hash, fileCount: files.length }, 'VCS commit created');
5099
- await this.pushIfConfigured();
5223
+ const pushTime = await pushToRemote(this.rootPath, this.remoteUrl, this.accessToken, this._pushErrors, this.logger);
5224
+ if (pushTime)
5225
+ this._lastPushTime = pushTime;
5100
5226
  }, {
5101
5227
  attempts: 4,
5102
5228
  baseDelayMs: 500,
@@ -5105,43 +5231,25 @@ class VcsManager {
5105
5231
  this.logger.warn({ root: this.rootPath, attempt, delay: delayMs }, 'index.lock contention, retrying');
5106
5232
  },
5107
5233
  });
5234
+ // Success — reset circuit breaker
5235
+ this.consecutiveCommitFailures = 0;
5108
5236
  }
5109
5237
  catch (error) {
5110
- for (const f of files) {
5238
+ this.consecutiveCommitFailures++;
5239
+ // Re-queue cap: only re-queue up to maxBatchSize files
5240
+ const cap = this.config.maxBatchSize;
5241
+ const toRequeue = files.slice(0, cap);
5242
+ const overflow = files.length - toRequeue.length;
5243
+ for (const f of toRequeue) {
5111
5244
  this.pending.add(f);
5112
5245
  }
5113
- this.logger.warn({ root: this.rootPath, fileCount: files.length }, 'Re-queued files after commit failure');
5246
+ if (overflow > 0) {
5247
+ this.logger.warn({ root: this.rootPath, overflow, cap }, 'Re-queue cap exceeded — discarded overflow files');
5248
+ }
5249
+ this.logger.warn({ root: this.rootPath, fileCount: toRequeue.length }, 'Re-queued files after commit failure');
5114
5250
  this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'VCS commit failed');
5115
5251
  }
5116
5252
  }
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
5253
  }
5146
5254
 
5147
5255
  /**
@@ -5170,10 +5278,14 @@ class VcsCoordinator {
5170
5278
  // Deduplicate: skip if another glob already resolved to this root.
5171
5279
  if (this.managers.has(rootKey))
5172
5280
  continue;
5281
+ const mergedRetention = entry.vcs?.retention ??
5282
+ config.vcs.retention ??
5283
+ vcsRetentionConfigSchema.parse({});
5173
5284
  const mergedConfig = {
5174
5285
  ...config.vcs,
5175
5286
  ...entry.vcs,
5176
5287
  enabled: true,
5288
+ retention: mergedRetention,
5177
5289
  };
5178
5290
  // Create CommitMessageGenerator if AI commit messages are configured
5179
5291
  const cmConfig = mergedConfig.commitMessage;
@@ -5197,12 +5309,10 @@ class VcsCoordinator {
5197
5309
  /**
5198
5310
  * Start all VcsManager instances.
5199
5311
  */
5200
- async start() {
5201
- const startPromises = [];
5312
+ start() {
5202
5313
  this.managers.forEach((manager) => {
5203
- startPromises.push(manager.start());
5314
+ manager.start();
5204
5315
  });
5205
- await Promise.all(startPromises);
5206
5316
  this.logger.info({ rootCount: this.managers.size }, 'VcsCoordinator started');
5207
5317
  }
5208
5318
  /**
@@ -5223,6 +5333,21 @@ class VcsCoordinator {
5223
5333
  manager.fileChanged(normalizedPath);
5224
5334
  }
5225
5335
  }
5336
+ /**
5337
+ * Signal that the initial filesystem scan is complete.
5338
+ *
5339
+ * Flushes each manager's debounce buffer before ending baseline mode, ensuring
5340
+ * any files still pending in the VCS debounce are committed with a
5341
+ * `"baseline:"` prefix rather than `"watcher:"`. AI generation is not enabled
5342
+ * until all managers have completed their flush.
5343
+ */
5344
+ async onInitialScanComplete() {
5345
+ for (const manager of this.managers.values()) {
5346
+ await manager.flush();
5347
+ manager.endBaseline();
5348
+ }
5349
+ this.logger.debug('VcsCoordinator: initial scan complete, baseline ended');
5350
+ }
5226
5351
  /**
5227
5352
  * Flush all managers and clean up.
5228
5353
  */
@@ -5265,6 +5390,68 @@ class VcsCoordinator {
5265
5390
  }
5266
5391
  }
5267
5392
 
5393
+ /**
5394
+ * @module app/watcherFactory
5395
+ * Factory helpers for creating and rebuilding the filesystem watcher.
5396
+ * Extracted from initialization.ts to follow SRP.
5397
+ */
5398
+ /**
5399
+ * Create file system watcher with gitignore filtering.
5400
+ */
5401
+ function createWatcher(config, factories, queue, processor, logger, runtimeOptions, initialScanTracker, contentHashCache, onVcsFileChange, onInitialScanComplete) {
5402
+ const respectGitignore = config.watch.respectGitignore ?? true;
5403
+ const gitignoreFilter = respectGitignore
5404
+ ? new GitignoreFilter(extractWatchPathStrings(config.watch.paths))
5405
+ : undefined;
5406
+ const watcher = factories.createFileSystemWatcher(config.watch, queue, processor, logger, {
5407
+ maxRetries: config.maxRetries ?? runtimeOptions.maxRetries,
5408
+ maxBackoffMs: config.maxBackoffMs ?? runtimeOptions.maxBackoffMs,
5409
+ onFatalError: runtimeOptions.onFatalError,
5410
+ gitignoreFilter,
5411
+ initialScanTracker,
5412
+ contentHashCache,
5413
+ onVcsFileChange,
5414
+ onInitialScanComplete,
5415
+ });
5416
+ return { watcher, gitignoreFilter };
5417
+ }
5418
+ /**
5419
+ * Tear down and rebuild the filesystem watcher with new config.
5420
+ * Falls back to the old watcher if the new one fails to start.
5421
+ */
5422
+ async function rebuildWatcher(newConfig, factories, queue, processor, logger, runtimeOptions, oldState, initialScanTracker, contentHashCache, onVcsFileChange, onInitialScanComplete) {
5423
+ try {
5424
+ await oldState.watcher.stop();
5425
+ const { watcher: newWatcher, gitignoreFilter: newGitignoreFilter } = createWatcher(newConfig, factories, queue, processor, logger, runtimeOptions, initialScanTracker, contentHashCache, onVcsFileChange, onInitialScanComplete);
5426
+ newWatcher.start();
5427
+ logger.info('Filesystem watcher rebuilt successfully');
5428
+ return { watcher: newWatcher, gitignoreFilter: newGitignoreFilter };
5429
+ }
5430
+ catch (error) {
5431
+ logger.error({ err: normalizeError(error) }, 'Failed to rebuild watcher, restoring previous');
5432
+ try {
5433
+ oldState.watcher.start();
5434
+ }
5435
+ catch (restartError) {
5436
+ logger.error({ err: normalizeError(restartError) }, 'Failed to restart previous watcher');
5437
+ }
5438
+ return oldState;
5439
+ }
5440
+ }
5441
+ /**
5442
+ * Check whether watch-relevant config fields changed between old and new config.
5443
+ */
5444
+ function watchConfigChanged(oldConfig, newConfig) {
5445
+ return (JSON.stringify(oldConfig.watch.paths) !==
5446
+ JSON.stringify(newConfig.watch.paths) ||
5447
+ JSON.stringify(oldConfig.watch.ignored) !==
5448
+ JSON.stringify(newConfig.watch.ignored) ||
5449
+ JSON.stringify(oldConfig.watch.moveDetection) !==
5450
+ JSON.stringify(newConfig.watch.moveDetection) ||
5451
+ (oldConfig.watch.respectGitignore ?? true) !==
5452
+ (newConfig.watch.respectGitignore ?? true));
5453
+ }
5454
+
5268
5455
  /**
5269
5456
  * @module app/initialization
5270
5457
  * Initialization helpers for JeevesWatcher. Extracted to follow SRP.
@@ -5329,25 +5516,6 @@ function createProcessorConfig(config, configDir, customMapLib) {
5329
5516
  globalSchemas: config.schemas,
5330
5517
  };
5331
5518
  }
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
5519
  /**
5352
5520
  * Introspect all helpers for merged document.
5353
5521
  */
@@ -5363,42 +5531,6 @@ async function introspectHelpers(config, configDir) {
5363
5531
  function getConfigDir(configPath) {
5364
5532
  return configPath ? dirname(configPath) : '.';
5365
5533
  }
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
5534
  /**
5403
5535
  * Resolve package version from nearest package.json.
5404
5536
  */
@@ -6431,7 +6563,7 @@ async function reloadConfig(state, deps) {
6431
6563
  // Rebuild filesystem watcher if watch config changed
6432
6564
  if (watchConfigChanged(oldConfig, newConfig)) {
6433
6565
  logger.info('Watch config changed, rebuilding filesystem watcher...');
6434
- const newState = await rebuildWatcher(newConfig, deps.factories, deps.queue, processor, logger, deps.runtimeOptions, { watcher: state.watcher, gitignoreFilter: state.gitignoreFilter }, deps.initialScanTracker, deps.contentHashCache);
6566
+ const newState = await rebuildWatcher(newConfig, deps.factories, deps.queue, processor, logger, deps.runtimeOptions, { watcher: state.watcher, gitignoreFilter: state.gitignoreFilter }, deps.initialScanTracker, deps.contentHashCache, deps.onVcsFileChange, deps.onInitialScanComplete);
6435
6567
  state.watcher = newState.watcher;
6436
6568
  state.gitignoreFilter = newState.gitignoreFilter;
6437
6569
  }
@@ -8470,6 +8602,37 @@ class MoveCorrelator {
8470
8602
  }
8471
8603
  }
8472
8604
 
8605
+ /**
8606
+ * @module watcher/ScanStats
8607
+ * Tracks initial filesystem scan statistics for diagnostics.
8608
+ */
8609
+ /**
8610
+ * Mutable counter bag updated during the chokidar initial scan.
8611
+ * Extracted from the watcher start() method for clarity.
8612
+ */
8613
+ class ScanStats {
8614
+ total = 0;
8615
+ matched = 0;
8616
+ globRejected = 0;
8617
+ gitignored = 0;
8618
+ byRoot;
8619
+ constructor(roots) {
8620
+ this.byRoot = Object.fromEntries(roots.map((r) => [r, 0]));
8621
+ }
8622
+ /**
8623
+ * Attribute a newly discovered file to its root for per-root diagnostics.
8624
+ */
8625
+ classifyByRoot(path) {
8626
+ const normalized = path.replace(/\\/g, '/').toLowerCase();
8627
+ for (const root of Object.keys(this.byRoot)) {
8628
+ if (normalized.startsWith(`${root}/`) || normalized === root) {
8629
+ this.byRoot[root] = (this.byRoot[root] ?? 0) + 1;
8630
+ break;
8631
+ }
8632
+ }
8633
+ }
8634
+ }
8635
+
8473
8636
  /**
8474
8637
  * @module watcher
8475
8638
  * Filesystem watcher wrapping chokidar. I/O: watches files/directories for add/change/unlink events, enqueues to processing queue.
@@ -8487,6 +8650,7 @@ class FileSystemWatcher {
8487
8650
  initialScanTracker;
8488
8651
  contentHashCache;
8489
8652
  onVcsFileChange;
8653
+ onInitialScanComplete;
8490
8654
  moveCorrelator;
8491
8655
  globMatches;
8492
8656
  watcher;
@@ -8508,6 +8672,7 @@ class FileSystemWatcher {
8508
8672
  this.initialScanTracker = options.initialScanTracker;
8509
8673
  this.contentHashCache = options.contentHashCache;
8510
8674
  this.onVcsFileChange = options.onVcsFileChange;
8675
+ this.onInitialScanComplete = options.onInitialScanComplete;
8511
8676
  this.globMatches = () => true;
8512
8677
  const healthOptions = {
8513
8678
  maxRetries: options.maxRetries,
@@ -8535,24 +8700,9 @@ class FileSystemWatcher {
8535
8700
  ? resolveIgnored(this.config.ignored)
8536
8701
  : undefined;
8537
8702
  // 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
- };
8703
+ const scanStats = new ScanStats(roots);
8545
8704
  let initialScanComplete = false;
8546
8705
  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
8706
  // Create move correlator if move detection is configured and cache is available.
8557
8707
  const moveConfig = this.config.moveDetection;
8558
8708
  if (moveConfig?.enabled && this.contentHashCache) {
@@ -8585,7 +8735,7 @@ class FileSystemWatcher {
8585
8735
  this.watcher.on('add', (path) => {
8586
8736
  if (!initialScanComplete) {
8587
8737
  scanStats.total++;
8588
- classifyByRoot(path);
8738
+ scanStats.classifyByRoot(path);
8589
8739
  }
8590
8740
  this.handleGitignoreChange(path);
8591
8741
  if (!this.globMatches(path)) {
@@ -8606,8 +8756,7 @@ class FileSystemWatcher {
8606
8756
  this.initialScanTracker?.incrementEnqueued();
8607
8757
  }
8608
8758
  this.logger.debug({ path }, 'File added');
8609
- if (initialScanComplete)
8610
- this.onVcsFileChange?.(path, 'add');
8759
+ this.onVcsFileChange?.(path, 'add');
8611
8760
  if (correlator && initialScanComplete) {
8612
8761
  void correlator.handleAdd(path);
8613
8762
  }
@@ -8646,6 +8795,7 @@ class FileSystemWatcher {
8646
8795
  initialScanComplete = true;
8647
8796
  this.initialScanTracker?.complete();
8648
8797
  this.logger.info({ scanStats }, 'Initial scan complete');
8798
+ void this.onInitialScanComplete?.();
8649
8799
  });
8650
8800
  this.watcher.on('error', (error) => {
8651
8801
  this.logger.error({ err: normalizeError(error) }, 'Watcher error');
@@ -8919,6 +9069,12 @@ class JeevesWatcher {
8919
9069
  * Start the watcher, API server, and all components.
8920
9070
  */
8921
9071
  async start() {
9072
+ await this.initInfrastructure();
9073
+ await this.initPipeline();
9074
+ await this.startServicesAndWatcher();
9075
+ }
9076
+ /** Set up logger, VCS, embedding provider, and vector store. */
9077
+ async initInfrastructure() {
8922
9078
  const logger = this.factories.createLogger(this.config.logging);
8923
9079
  this.logger = logger;
8924
9080
  await initVcs(this.config, logger);
@@ -8928,6 +9084,10 @@ class JeevesWatcher {
8928
9084
  if (this.config.search?.hybrid?.enabled) {
8929
9085
  await vectorStore.ensureTextIndex('chunk_text');
8930
9086
  }
9087
+ }
9088
+ /** Build the processing pipeline: rules, templates, processor, queue, VCS. */
9089
+ async initPipeline() {
9090
+ const logger = this.logger;
8931
9091
  const compiledRules = this.factories.compileRules(this.config.inferenceRules ?? []);
8932
9092
  const configDir = getConfigDir(this.configPath);
8933
9093
  const { templateEngine, customMapLib } = await buildTemplateEngineAndCustomMapLib(this.config, configDir);
@@ -8937,36 +9097,39 @@ class JeevesWatcher {
8937
9097
  this.issuesManager = new IssuesManager(stateDir, logger);
8938
9098
  this.valuesManager = new ValuesManager(stateDir, logger);
8939
9099
  this.enrichmentStore = new EnrichmentStore(stateDir, logger);
8940
- const enrichmentStore = this.enrichmentStore;
8941
9100
  this.contentHashCache = new ContentHashCache();
8942
- const contentHashCache = this.contentHashCache;
8943
- const processor = this.factories.createDocumentProcessor({
9101
+ this.processor = this.factories.createDocumentProcessor({
8944
9102
  config: processorConfig,
8945
- embeddingProvider,
8946
- vectorStore,
9103
+ embeddingProvider: this.embeddingProvider,
9104
+ vectorStore: this.vectorStore,
8947
9105
  compiledRules,
8948
9106
  logger,
8949
9107
  templateEngine,
8950
- enrichmentStore,
9108
+ enrichmentStore: this.enrichmentStore,
8951
9109
  issuesManager: this.issuesManager,
8952
9110
  valuesManager: this.valuesManager,
8953
- contentHashCache,
9111
+ contentHashCache: this.contentHashCache,
8954
9112
  });
8955
- this.processor = processor;
8956
9113
  this.queue = this.factories.createEventQueue({
8957
9114
  debounceMs: this.config.watch.debounceMs ?? 2000,
8958
9115
  concurrency: this.config.embedding.concurrency ?? 5,
8959
9116
  rateLimitPerMinute: this.config.embedding.rateLimitPerMinute,
8960
9117
  });
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) => {
9118
+ this.vcsCoordinator = new VcsCoordinator(this.config, logger);
9119
+ }
9120
+ /** Wire up watcher and VCS coordinator, then start all services. */
9121
+ async startServicesAndWatcher() {
9122
+ const logger = this.logger;
9123
+ const vcsCoordinator = this.vcsCoordinator;
9124
+ const { watcher, gitignoreFilter } = createWatcher(this.config, this.factories, this.queue, this.processor, logger, this.runtimeOptions, this.initialScanTracker, this.contentHashCache, (filePath, event) => {
8964
9125
  vcsCoordinator.onFileChange(filePath, event);
9126
+ }, () => {
9127
+ void vcsCoordinator.onInitialScanComplete();
8965
9128
  });
8966
9129
  this.watcher = watcher;
8967
9130
  this.gitignoreFilter = gitignoreFilter;
8968
9131
  this.server = await this.startApiServer();
8969
- await vcsCoordinator.start();
9132
+ vcsCoordinator.start();
8970
9133
  this.watcher.start();
8971
9134
  this.startConfigWatch();
8972
9135
  logger.info('jeeves-watcher started');
@@ -9081,6 +9244,8 @@ class JeevesWatcher {
9081
9244
  contentHashCache: this.contentHashCache,
9082
9245
  valuesManager: this.valuesManager,
9083
9246
  issuesManager: this.issuesManager,
9247
+ onVcsFileChange: (filePath, event) => this.vcsCoordinator?.onFileChange(filePath, event),
9248
+ onInitialScanComplete: () => this.vcsCoordinator?.onInitialScanComplete(),
9084
9249
  });
9085
9250
  this.config = state.config;
9086
9251
  this.watcher = state.watcher;