@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.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { createConfigQueryHandler as createConfigQueryHandler$1, createStatusHandler, createConfigApplyHandler, DEFAULT_PORTS, getBindAddress } from '@karmaniverous/jeeves';
3
- import { a as normalizeSlashes, n as normalizeError, r as retry, G as GitignoreFilter, e as executeReindex, V as VALID_REINDEX_SCOPES, m as mergeInferenceRules, i as isPathWatched, g as getWatchRootBases, C as CONFIG_WATCH_VALID_SCOPES, s as sleep, l as loadConfig } from './index.js';
4
- export { b as startFromConfig } from './index.js';
3
+ import { d as normalizeSlashes, n as normalizeError, e as execFileAsync, r as retry, b as gitAddViaStdin, c as normalizePathCase, f as findRootForPath, G as GitignoreFilter, a as executeReindex, V as VALID_REINDEX_SCOPES, m as mergeInferenceRules, i as isPathWatched, g as getWatchRootBases, C as CONFIG_WATCH_VALID_SCOPES, s as sleep, l as loadConfig } from './index.js';
4
+ export { h as startFromConfig } from './index.js';
5
5
  import { mkdirSync, existsSync, readFileSync, writeFileSync, rmSync, renameSync, readdirSync, statSync } from 'node:fs';
6
6
  import { join, dirname, resolve, extname, basename, relative, sep, isAbsolute } from 'node:path';
7
7
  import Database from 'better-sqlite3';
@@ -10,11 +10,9 @@ import Ajv from 'ajv';
10
10
  import addFormats from 'ajv-formats';
11
11
  import picomatch from 'picomatch';
12
12
  import { deserialize, serialize } from 'node:v8';
13
- import { normalizeWatchPaths, extractWatchPathStrings, vcsAuthorConfigSchema, inferenceRuleSchema, jeevesWatcherConfigSchema } from '@karmaniverous/jeeves-watcher-core';
14
- import { execFile } from 'node:child_process';
15
- import { promisify } from 'node:util';
13
+ import { normalizeWatchPaths, vcsRetentionConfigSchema, extractWatchPathStrings, vcsAuthorConfigSchema, inferenceRuleSchema, jeevesWatcherConfigSchema } from '@karmaniverous/jeeves-watcher-core';
16
14
  import { homedir } from 'node:os';
17
- import { access, rm, readFile, writeFile, appendFile, mkdir, stat } from 'node:fs/promises';
15
+ import { access, rm, stat, readFile, writeFile, appendFile, mkdir } from 'node:fs/promises';
18
16
  import { pathToFileURL, fileURLToPath } from 'node:url';
19
17
  import { packageDirectorySync } from 'package-directory';
20
18
  import { JsonMap } from '@karmaniverous/jsonmap';
@@ -27,6 +25,7 @@ import { capitalize, title, camel, snake, dash, isEqual, get, omit } from 'radas
27
25
  import rehypeParse from 'rehype-parse';
28
26
  import { unified } from 'unified';
29
27
  import yaml from 'js-yaml';
28
+ import 'ignore';
30
29
  import chokidar from 'chokidar';
31
30
  import Fastify from 'fastify';
32
31
  import crypto from 'crypto';
@@ -39,7 +38,8 @@ import mammoth from 'mammoth';
39
38
  import { MarkdownTextSplitter, RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
40
39
  import { QdrantClient } from '@qdrant/js-client-rest';
41
40
  import 'cosmiconfig';
42
- import 'ignore';
41
+ import 'node:child_process';
42
+ import 'node:util';
43
43
 
44
44
  /**
45
45
  * @module api/InitialScanTracker
@@ -928,77 +928,6 @@ class CommitMessageGenerator {
928
928
  }
929
929
  }
930
930
 
931
- /**
932
- * @module vcs/gitExec
933
- * Shared git execution utilities.
934
- */
935
- /** Promisified execFile for git commands. */
936
- const execFileAsync = promisify(execFile);
937
- /**
938
- * Stage files via stdin to avoid ENAMETOOLONG on Windows.
939
- *
940
- * Uses `git add --pathspec-from-file=- --pathspec-file-nul` so the file list is piped
941
- * through stdin (NUL-delimited) instead of passed as command-line
942
- * arguments. This sidesteps the Windows CreateProcessW 32 767-char
943
- * limit that triggers ENAMETOOLONG when batches contain many files
944
- * with long absolute paths.
945
- *
946
- * @param files - Absolute paths of files to stage.
947
- * @param cwd - Repository root (working directory for git).
948
- */
949
- function gitAddViaStdin(files, cwd) {
950
- return new Promise((resolve, reject) => {
951
- const child = execFile('git', ['add', '--pathspec-from-file=-', '--pathspec-file-nul'], { cwd }, (error) => {
952
- if (error)
953
- reject(error);
954
- else
955
- resolve();
956
- });
957
- if (!child.stdin) {
958
- reject(new Error('Failed to open stdin for git add'));
959
- return;
960
- }
961
- // Suppress EPIPE — expected if git exits before we finish writing
962
- child.stdin.on('error', (err) => {
963
- if (err.code !== 'EPIPE') {
964
- reject(err);
965
- }
966
- });
967
- child.stdin.end(files.join('\0'));
968
- });
969
- }
970
- /**
971
- * Normalize a path for case-insensitive comparison on Windows.
972
- * On Windows, lowercases the entire path; on other platforms, returns as-is.
973
- *
974
- * @param p - The path string to normalize.
975
- * @param platform - The platform to check against (default: `process.platform`).
976
- * @returns The normalized path.
977
- */
978
- function normalizePathCase(p, platform = process.platform) {
979
- return platform === 'win32' ? p.toLowerCase() : p;
980
- }
981
- /**
982
- * Find the longest-prefix-match root for a normalized path.
983
- * Roots must be sorted longest-first for correct nested matching.
984
- *
985
- * @param roots - Root paths sorted longest-first.
986
- * @param normalizedPath - Normalized absolute path (forward slashes).
987
- * @param platform - The platform to check against (default: `process.platform`).
988
- * @returns The matching root, or undefined.
989
- */
990
- function findRootForPath(roots, normalizedPath, platform = process.platform) {
991
- const comparePath = normalizePathCase(normalizedPath, platform);
992
- for (const root of roots) {
993
- const compareRoot = normalizePathCase(root, platform);
994
- if (comparePath === compareRoot ||
995
- comparePath.startsWith(compareRoot + '/')) {
996
- return root;
997
- }
998
- }
999
- return undefined;
1000
- }
1001
-
1002
931
  /**
1003
932
  * @module vcs/resolveCommitMessageApiKey
1004
933
  * Resolves the API key for AI commit message generation.
@@ -1029,6 +958,91 @@ function resolveCommitMessageApiKey(provider, configApiKey, logger) {
1029
958
  return undefined;
1030
959
  }
1031
960
 
961
+ /**
962
+ * @module vcs/CommitMessageBuilder
963
+ * Builds commit messages — both template-based and AI-generated.
964
+ */
965
+ /**
966
+ * Builds commit messages for VCS commits.
967
+ * Supports template-based messages and AI-generated messages with fallback.
968
+ */
969
+ class CommitMessageBuilder {
970
+ rootPath;
971
+ logger;
972
+ generator;
973
+ constructor(rootPath, logger, generator) {
974
+ this.rootPath = rootPath;
975
+ this.logger = logger;
976
+ this.generator = generator;
977
+ }
978
+ /**
979
+ * Build the template commit message (no AI).
980
+ */
981
+ buildTemplateMessage(fileCount, isBaseline, pendingReversions) {
982
+ if (pendingReversions.length === 0) {
983
+ if (isBaseline) {
984
+ return `baseline: batch (${String(fileCount)} files)`;
985
+ }
986
+ const timestamp = new Date().toISOString();
987
+ return `watcher: batch ${timestamp} (${String(fileCount)} files)`;
988
+ }
989
+ // Count total reverted files across all pending reversions
990
+ const revertedPaths = new Set(pendingReversions.flatMap((r) => r.paths));
991
+ const revertedCount = revertedPaths.size;
992
+ const otherCount = fileCount - revertedCount;
993
+ // Use the first reversion's glob and commit for the message
994
+ const { glob, commit } = pendingReversions[0];
995
+ const shortCommit = commit.slice(0, 7);
996
+ let message = `revert: ${glob} to ${shortCommit} — restored ${String(revertedCount)} files`;
997
+ if (otherCount > 0) {
998
+ message += ` (+ ${String(otherCount)} other changes)`;
999
+ }
1000
+ return message;
1001
+ }
1002
+ /**
1003
+ * Build the commit message, using AI when available with template fallback.
1004
+ */
1005
+ async buildCommitMessage(files, isBaseline, pendingReversions) {
1006
+ const fileCount = files.length;
1007
+ const templateMessage = this.buildTemplateMessage(fileCount, isBaseline, pendingReversions);
1008
+ if (!this.generator) {
1009
+ return templateMessage;
1010
+ }
1011
+ try {
1012
+ const diffSummary = await this.getStagedDiff();
1013
+ const aiMessage = await this.generator.generate(files, diffSummary);
1014
+ if (!aiMessage) {
1015
+ this.logger.warn('AI commit message generation returned null; using template');
1016
+ return templateMessage;
1017
+ }
1018
+ // For reversions: prefix with revert info + AI description
1019
+ if (pendingReversions.length > 0) {
1020
+ const { glob, commit } = pendingReversions[0];
1021
+ const shortCommit = commit.slice(0, 7);
1022
+ return `revert: ${glob} to ${shortCommit} — ${aiMessage}`;
1023
+ }
1024
+ return aiMessage;
1025
+ }
1026
+ catch (error) {
1027
+ this.logger.warn({ err: normalizeError(error) }, 'AI commit message generation failed; using template');
1028
+ return templateMessage;
1029
+ }
1030
+ }
1031
+ /**
1032
+ * Get the staged diff for AI context.
1033
+ */
1034
+ async getStagedDiff() {
1035
+ try {
1036
+ const { stdout: stat } = await execFileAsync('git', ['diff', '--cached', '--stat'], { cwd: this.rootPath });
1037
+ const { stdout: patch } = await execFileAsync('git', ['diff', '--cached'], { cwd: this.rootPath });
1038
+ return `${stat}\n${patch}`;
1039
+ }
1040
+ catch {
1041
+ return '';
1042
+ }
1043
+ }
1044
+ }
1045
+
1032
1046
  /**
1033
1047
  * @module vcs/SquashManager
1034
1048
  * Handles scheduled squash retention for git-backed VCS roots.
@@ -1323,6 +1337,50 @@ class SquashManager {
1323
1337
  }
1324
1338
  }
1325
1339
 
1340
+ /**
1341
+ * @module vcs/vcsPush
1342
+ * Push logic for VCS commits.
1343
+ */
1344
+ /**
1345
+ * Push to the configured remote if remoteUrl is set.
1346
+ *
1347
+ * On failure: logs the error, appends to pushErrors, and returns undefined.
1348
+ * Never throws.
1349
+ *
1350
+ * @param rootPath - Absolute path of the git repository.
1351
+ * @param remoteUrl - Remote URL to push to, or undefined to skip.
1352
+ * @param accessToken - Optional token for HTTPS authentication.
1353
+ * @param pushErrors - Mutable array where push errors are recorded.
1354
+ * @param logger - Logger instance.
1355
+ * @returns ISO timestamp of a successful push, or undefined if skipped/failed.
1356
+ */
1357
+ async function pushToRemote(rootPath, remoteUrl, accessToken, pushErrors, logger) {
1358
+ if (!remoteUrl)
1359
+ return undefined;
1360
+ try {
1361
+ // Build the authenticated URL if a token is available
1362
+ const pushUrl = accessToken
1363
+ ? remoteUrl.replace(/^https:\/\//, `https://${encodeURIComponent(accessToken)}@`)
1364
+ : remoteUrl;
1365
+ await execFileAsync('git', ['push', pushUrl, 'HEAD'], {
1366
+ cwd: rootPath,
1367
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
1368
+ });
1369
+ const timestamp = new Date().toISOString();
1370
+ logger.info({ root: rootPath, remote: remoteUrl }, 'VCS push succeeded');
1371
+ return timestamp;
1372
+ }
1373
+ catch (error) {
1374
+ const pushError = {
1375
+ timestamp: new Date().toISOString(),
1376
+ message: normalizeError(error).message,
1377
+ };
1378
+ pushErrors.push(pushError);
1379
+ logger.error({ root: rootPath, err: normalizeError(error) }, 'VCS push failed');
1380
+ return undefined;
1381
+ }
1382
+ }
1383
+
1326
1384
  /**
1327
1385
  * @module vcs/VcsManager
1328
1386
  * Per-root VCS manager for git-backed content versioning.
@@ -1343,23 +1401,24 @@ class VcsManager {
1343
1401
  remoteUrl;
1344
1402
  accessToken;
1345
1403
  logger;
1346
- commitMessageGenerator;
1404
+ commitMessageBuilder;
1347
1405
  pending = new Set();
1348
1406
  pendingReversions = [];
1349
1407
  _pushErrors = [];
1350
1408
  squashManager;
1351
1409
  debounceTimer;
1352
1410
  commitInFlight = Promise.resolve();
1411
+ consecutiveCommitFailures = 0;
1353
1412
  started = false;
1354
- isBaseline = false;
1413
+ isBaseline = true;
1355
1414
  _lastPushTime = null;
1356
1415
  constructor(rootPath, config, logger, commitMessageGenerator, remoteUrl, accessToken) {
1357
1416
  this.rootPath = rootPath;
1358
1417
  this.config = config;
1359
1418
  this.logger = logger;
1360
- this.commitMessageGenerator = commitMessageGenerator;
1361
1419
  this.remoteUrl = remoteUrl;
1362
1420
  this.accessToken = accessToken;
1421
+ this.commitMessageBuilder = new CommitMessageBuilder(rootPath, logger, commitMessageGenerator);
1363
1422
  if (config.retention) {
1364
1423
  this.squashManager = new SquashManager(rootPath, config.retention, logger, remoteUrl, accessToken);
1365
1424
  }
@@ -1371,48 +1430,23 @@ class VcsManager {
1371
1430
  return this._pushErrors;
1372
1431
  }
1373
1432
  /**
1374
- * Begin accepting file changes. Sets up the debounce mechanism.
1375
- * Enqueues a baseline commit for pre-existing files if the repo has no commits.
1433
+ * Begin accepting file changes. Sets the manager to started state and
1434
+ * starts the squash manager if configured.
1435
+ *
1436
+ * Baseline mode (commit prefix `"baseline:"`) remains active until
1437
+ * {@link endBaseline} is called by the coordinator after the initial scan.
1376
1438
  */
1377
- async start() {
1439
+ start() {
1378
1440
  this.started = true;
1379
1441
  this.squashManager?.start();
1380
1442
  this.logger.info({ root: this.rootPath }, 'VcsManager started');
1381
- await this.enqueueBaselineIfNeeded();
1382
1443
  }
1383
1444
  /**
1384
- * If the repo has no commits, enumerate untracked files and feed them
1385
- * through fileChanged() to create a baseline commit.
1445
+ * Signal that the initial filesystem scan is complete.
1446
+ * Clears the baseline flag so subsequent commits use normal "watcher: batch" messages.
1386
1447
  */
1387
- async enqueueBaselineIfNeeded() {
1388
- try {
1389
- await execFileAsync('git', ['rev-parse', 'HEAD'], {
1390
- cwd: this.rootPath,
1391
- });
1392
- // HEAD exists — repo already has commits
1393
- return;
1394
- }
1395
- catch {
1396
- // No commits yet — proceed with baseline
1397
- }
1398
- try {
1399
- const { stdout } = await execFileAsync('git', ['ls-files', '--others', '--exclude-standard', '-z'], { cwd: this.rootPath });
1400
- const files = stdout
1401
- .split('\0')
1402
- .map((line) => line.trim())
1403
- .filter((line) => line.length > 0);
1404
- if (files.length === 0)
1405
- return;
1406
- this.logger.info({ root: this.rootPath, fileCount: files.length }, 'Enqueuing baseline commit for pre-existing files');
1407
- this.isBaseline = true;
1408
- const absolutePaths = files.map((f) => this.rootPath + '/' + f);
1409
- for (const filePath of absolutePaths) {
1410
- this.fileChanged(filePath);
1411
- }
1412
- }
1413
- catch (error) {
1414
- this.logger.warn({ root: this.rootPath, err: normalizeError(error) }, 'Failed to enumerate untracked files for baseline commit');
1415
- }
1448
+ endBaseline() {
1449
+ this.isBaseline = false;
1416
1450
  }
1417
1451
  /**
1418
1452
  * Add a file to the pending set and reset the debounce timer.
@@ -1423,6 +1457,11 @@ class VcsManager {
1423
1457
  fileChanged(filePath) {
1424
1458
  if (!this.started)
1425
1459
  return;
1460
+ // Reset circuit breaker on new file change so the system can recover
1461
+ if (this.consecutiveCommitFailures >= this.config.maxConsecutiveFailures) {
1462
+ this.logger.info({ root: this.rootPath }, 'VCS circuit breaker reset — new file change received, retrying commits');
1463
+ this.consecutiveCommitFailures = 0;
1464
+ }
1426
1465
  this.pending.add(filePath);
1427
1466
  if (this.pending.size >= this.config.maxBatchSize) {
1428
1467
  this.clearDebounce();
@@ -1510,97 +1549,61 @@ class VcsManager {
1510
1549
  }, this.config.commitDebounceMs);
1511
1550
  }
1512
1551
  /**
1513
- * Get the cached diff for staged files, for AI context.
1552
+ * Check for and remove stale index.lock files before retrying.
1514
1553
  */
1515
- async getStagedDiff() {
1554
+ async removeStaleLock() {
1555
+ const lockPath = join(this.rootPath, '.git', 'index.lock');
1516
1556
  try {
1517
- const { stdout: stat } = await execFileAsync('git', ['diff', '--cached', '--stat'], { cwd: this.rootPath });
1518
- const { stdout: patch } = await execFileAsync('git', ['diff', '--cached'], { cwd: this.rootPath });
1519
- return `${stat}\n${patch}`;
1520
- }
1521
- catch {
1522
- return '';
1523
- }
1524
- }
1525
- /**
1526
- * Build the template commit message (no AI).
1527
- */
1528
- buildTemplateMessage(fileCount) {
1529
- if (this.pendingReversions.length === 0) {
1530
- if (this.isBaseline) {
1531
- return `baseline: batch (${String(fileCount)} files)`;
1557
+ const lockStat = await stat(lockPath);
1558
+ const ageMs = Date.now() - lockStat.mtimeMs;
1559
+ if (ageMs > this.config.staleLockThresholdMs) {
1560
+ await rm(lockPath, { force: true });
1561
+ this.logger.warn({ root: this.rootPath, ageMs }, 'Removed stale index.lock');
1532
1562
  }
1533
- const timestamp = new Date().toISOString();
1534
- return `watcher: batch ${timestamp} (${String(fileCount)} files)`;
1535
- }
1536
- // Count total reverted files across all pending reversions
1537
- const revertedPaths = new Set(this.pendingReversions.flatMap((r) => r.paths));
1538
- const revertedCount = revertedPaths.size;
1539
- const otherCount = fileCount - revertedCount;
1540
- // Use the first reversion's glob and commit for the message
1541
- const { glob, commit } = this.pendingReversions[0];
1542
- const shortCommit = commit.slice(0, 7);
1543
- let message = `revert: ${glob} to ${shortCommit} — restored ${String(revertedCount)} files`;
1544
- if (otherCount > 0) {
1545
- message += ` (+ ${String(otherCount)} other changes)`;
1546
- }
1547
- return message;
1548
- }
1549
- /**
1550
- * Build the commit message, using AI when available with template fallback.
1551
- */
1552
- async buildCommitMessage(files) {
1553
- const fileCount = files.length;
1554
- const templateMessage = this.buildTemplateMessage(fileCount);
1555
- if (!this.commitMessageGenerator) {
1556
- return templateMessage;
1557
- }
1558
- try {
1559
- const diffSummary = await this.getStagedDiff();
1560
- const aiMessage = await this.commitMessageGenerator.generate(files, diffSummary);
1561
- if (!aiMessage) {
1562
- this.logger.warn('AI commit message generation returned null; using template');
1563
- return templateMessage;
1564
- }
1565
- // For reversions: prefix with revert info + AI description
1566
- if (this.pendingReversions.length > 0) {
1567
- const { glob, commit } = this.pendingReversions[0];
1568
- const shortCommit = commit.slice(0, 7);
1569
- return `revert: ${glob} to ${shortCommit} — ${aiMessage}`;
1570
- }
1571
- return aiMessage;
1572
1563
  }
1573
1564
  catch (error) {
1574
- this.logger.warn({ err: normalizeError(error) }, 'AI commit message generation failed; using template');
1575
- return templateMessage;
1565
+ const code = error.code;
1566
+ if (code !== 'ENOENT') {
1567
+ this.logger.warn({ root: this.rootPath, err: normalizeError(error) }, 'Unable to remove stale index.lock');
1568
+ }
1576
1569
  }
1577
1570
  }
1578
1571
  /**
1579
- * Commit a batch of files to the git repo with index.lock retry.
1572
+ * Commit a batch of files to the git repo with index.lock retry,
1573
+ * stale lock detection, circuit breaker, and re-queue cap.
1580
1574
  */
1581
1575
  async commitBatch(files) {
1582
1576
  if (files.length === 0)
1583
1577
  return;
1578
+ // Circuit breaker: skip if too many consecutive failures
1579
+ if (this.consecutiveCommitFailures >= this.config.maxConsecutiveFailures) {
1580
+ this.logger.error({
1581
+ root: this.rootPath,
1582
+ consecutiveFailures: this.consecutiveCommitFailures,
1583
+ discardedFiles: files.length,
1584
+ }, 'VCS circuit breaker tripped — pending files discarded until next successful commit');
1585
+ return;
1586
+ }
1584
1587
  try {
1588
+ // Check for stale lock before first attempt
1589
+ await this.removeStaleLock();
1585
1590
  await retry(async (attempt) => {
1586
1591
  await gitAddViaStdin(files, this.rootPath);
1587
- // Build message after staging so getStagedDiff can see the changes
1588
- // Skip AI for baselines and retries — use template directly
1592
+ // Build message after staging so getStagedDiff can see the changes.
1593
+ // Skip AI for baselines and retries — use template directly.
1589
1594
  const message = this.isBaseline || attempt > 1
1590
- ? this.buildTemplateMessage(files.length)
1591
- : await this.buildCommitMessage(files);
1595
+ ? this.commitMessageBuilder.buildTemplateMessage(files.length, this.isBaseline, this.pendingReversions)
1596
+ : await this.commitMessageBuilder.buildCommitMessage(files, this.isBaseline, this.pendingReversions);
1592
1597
  this.pendingReversions.length = 0;
1593
- // Only clear baseline flag when all baseline files have been committed
1594
- if (this.pending.size === 0) {
1595
- this.isBaseline = false;
1596
- }
1597
1598
  await execFileAsync('git', ['commit', '-m', message], {
1598
1599
  cwd: this.rootPath,
1599
1600
  });
1600
1601
  const { stdout: hashOut } = await execFileAsync('git', ['rev-parse', '--short', 'HEAD'], { cwd: this.rootPath });
1601
1602
  const hash = hashOut.trim();
1602
1603
  this.logger.info({ root: this.rootPath, hash, fileCount: files.length }, 'VCS commit created');
1603
- await this.pushIfConfigured();
1604
+ const pushTime = await pushToRemote(this.rootPath, this.remoteUrl, this.accessToken, this._pushErrors, this.logger);
1605
+ if (pushTime)
1606
+ this._lastPushTime = pushTime;
1604
1607
  }, {
1605
1608
  attempts: 4,
1606
1609
  baseDelayMs: 500,
@@ -1609,43 +1612,25 @@ class VcsManager {
1609
1612
  this.logger.warn({ root: this.rootPath, attempt, delay: delayMs }, 'index.lock contention, retrying');
1610
1613
  },
1611
1614
  });
1615
+ // Success — reset circuit breaker
1616
+ this.consecutiveCommitFailures = 0;
1612
1617
  }
1613
1618
  catch (error) {
1614
- for (const f of files) {
1619
+ this.consecutiveCommitFailures++;
1620
+ // Re-queue cap: only re-queue up to maxBatchSize files
1621
+ const cap = this.config.maxBatchSize;
1622
+ const toRequeue = files.slice(0, cap);
1623
+ const overflow = files.length - toRequeue.length;
1624
+ for (const f of toRequeue) {
1615
1625
  this.pending.add(f);
1616
1626
  }
1617
- this.logger.warn({ root: this.rootPath, fileCount: files.length }, 'Re-queued files after commit failure');
1627
+ if (overflow > 0) {
1628
+ this.logger.warn({ root: this.rootPath, overflow, cap }, 'Re-queue cap exceeded — discarded overflow files');
1629
+ }
1630
+ this.logger.warn({ root: this.rootPath, fileCount: toRequeue.length }, 'Re-queued files after commit failure');
1618
1631
  this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'VCS commit failed');
1619
1632
  }
1620
1633
  }
1621
- /**
1622
- * Push to the configured remote if remoteUrl is set.
1623
- * On failure: logs the error and records it in pushErrors; never throws.
1624
- */
1625
- async pushIfConfigured() {
1626
- if (!this.remoteUrl)
1627
- return;
1628
- try {
1629
- // Build the authenticated URL if a token is available
1630
- const pushUrl = this.accessToken
1631
- ? this.remoteUrl.replace(/^https:\/\//, `https://${encodeURIComponent(this.accessToken)}@`)
1632
- : this.remoteUrl;
1633
- await execFileAsync('git', ['push', pushUrl, 'HEAD'], {
1634
- cwd: this.rootPath,
1635
- env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
1636
- });
1637
- this._lastPushTime = new Date().toISOString();
1638
- this.logger.info({ root: this.rootPath, remote: this.remoteUrl }, 'VCS push succeeded');
1639
- }
1640
- catch (error) {
1641
- const pushError = {
1642
- timestamp: new Date().toISOString(),
1643
- message: normalizeError(error).message,
1644
- };
1645
- this._pushErrors.push(pushError);
1646
- this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'VCS push failed');
1647
- }
1648
- }
1649
1634
  }
1650
1635
 
1651
1636
  /**
@@ -1674,10 +1659,14 @@ class VcsCoordinator {
1674
1659
  // Deduplicate: skip if another glob already resolved to this root.
1675
1660
  if (this.managers.has(rootKey))
1676
1661
  continue;
1662
+ const mergedRetention = entry.vcs?.retention ??
1663
+ config.vcs.retention ??
1664
+ vcsRetentionConfigSchema.parse({});
1677
1665
  const mergedConfig = {
1678
1666
  ...config.vcs,
1679
1667
  ...entry.vcs,
1680
1668
  enabled: true,
1669
+ retention: mergedRetention,
1681
1670
  };
1682
1671
  // Create CommitMessageGenerator if AI commit messages are configured
1683
1672
  const cmConfig = mergedConfig.commitMessage;
@@ -1701,12 +1690,10 @@ class VcsCoordinator {
1701
1690
  /**
1702
1691
  * Start all VcsManager instances.
1703
1692
  */
1704
- async start() {
1705
- const startPromises = [];
1693
+ start() {
1706
1694
  this.managers.forEach((manager) => {
1707
- startPromises.push(manager.start());
1695
+ manager.start();
1708
1696
  });
1709
- await Promise.all(startPromises);
1710
1697
  this.logger.info({ rootCount: this.managers.size }, 'VcsCoordinator started');
1711
1698
  }
1712
1699
  /**
@@ -1727,6 +1714,21 @@ class VcsCoordinator {
1727
1714
  manager.fileChanged(normalizedPath);
1728
1715
  }
1729
1716
  }
1717
+ /**
1718
+ * Signal that the initial filesystem scan is complete.
1719
+ *
1720
+ * Flushes each manager's debounce buffer before ending baseline mode, ensuring
1721
+ * any files still pending in the VCS debounce are committed with a
1722
+ * `"baseline:"` prefix rather than `"watcher:"`. AI generation is not enabled
1723
+ * until all managers have completed their flush.
1724
+ */
1725
+ async onInitialScanComplete() {
1726
+ for (const manager of this.managers.values()) {
1727
+ await manager.flush();
1728
+ manager.endBaseline();
1729
+ }
1730
+ this.logger.debug('VcsCoordinator: initial scan complete, baseline ended');
1731
+ }
1730
1732
  /**
1731
1733
  * Flush all managers and clean up.
1732
1734
  */
@@ -3129,6 +3131,68 @@ async function ensureGitignore(rootPath, alwaysOnEntries = []) {
3129
3131
  }
3130
3132
  }
3131
3133
 
3134
+ /**
3135
+ * @module app/watcherFactory
3136
+ * Factory helpers for creating and rebuilding the filesystem watcher.
3137
+ * Extracted from initialization.ts to follow SRP.
3138
+ */
3139
+ /**
3140
+ * Create file system watcher with gitignore filtering.
3141
+ */
3142
+ function createWatcher(config, factories, queue, processor, logger, runtimeOptions, initialScanTracker, contentHashCache, onVcsFileChange, onInitialScanComplete) {
3143
+ const respectGitignore = config.watch.respectGitignore ?? true;
3144
+ const gitignoreFilter = respectGitignore
3145
+ ? new GitignoreFilter(extractWatchPathStrings(config.watch.paths))
3146
+ : undefined;
3147
+ const watcher = factories.createFileSystemWatcher(config.watch, queue, processor, logger, {
3148
+ maxRetries: config.maxRetries ?? runtimeOptions.maxRetries,
3149
+ maxBackoffMs: config.maxBackoffMs ?? runtimeOptions.maxBackoffMs,
3150
+ onFatalError: runtimeOptions.onFatalError,
3151
+ gitignoreFilter,
3152
+ initialScanTracker,
3153
+ contentHashCache,
3154
+ onVcsFileChange,
3155
+ onInitialScanComplete,
3156
+ });
3157
+ return { watcher, gitignoreFilter };
3158
+ }
3159
+ /**
3160
+ * Tear down and rebuild the filesystem watcher with new config.
3161
+ * Falls back to the old watcher if the new one fails to start.
3162
+ */
3163
+ async function rebuildWatcher(newConfig, factories, queue, processor, logger, runtimeOptions, oldState, initialScanTracker, contentHashCache, onVcsFileChange, onInitialScanComplete) {
3164
+ try {
3165
+ await oldState.watcher.stop();
3166
+ const { watcher: newWatcher, gitignoreFilter: newGitignoreFilter } = createWatcher(newConfig, factories, queue, processor, logger, runtimeOptions, initialScanTracker, contentHashCache, onVcsFileChange, onInitialScanComplete);
3167
+ newWatcher.start();
3168
+ logger.info('Filesystem watcher rebuilt successfully');
3169
+ return { watcher: newWatcher, gitignoreFilter: newGitignoreFilter };
3170
+ }
3171
+ catch (error) {
3172
+ logger.error({ err: normalizeError(error) }, 'Failed to rebuild watcher, restoring previous');
3173
+ try {
3174
+ oldState.watcher.start();
3175
+ }
3176
+ catch (restartError) {
3177
+ logger.error({ err: normalizeError(restartError) }, 'Failed to restart previous watcher');
3178
+ }
3179
+ return oldState;
3180
+ }
3181
+ }
3182
+ /**
3183
+ * Check whether watch-relevant config fields changed between old and new config.
3184
+ */
3185
+ function watchConfigChanged(oldConfig, newConfig) {
3186
+ return (JSON.stringify(oldConfig.watch.paths) !==
3187
+ JSON.stringify(newConfig.watch.paths) ||
3188
+ JSON.stringify(oldConfig.watch.ignored) !==
3189
+ JSON.stringify(newConfig.watch.ignored) ||
3190
+ JSON.stringify(oldConfig.watch.moveDetection) !==
3191
+ JSON.stringify(newConfig.watch.moveDetection) ||
3192
+ (oldConfig.watch.respectGitignore ?? true) !==
3193
+ (newConfig.watch.respectGitignore ?? true));
3194
+ }
3195
+
3132
3196
  /**
3133
3197
  * @module app/initialization
3134
3198
  * Initialization helpers for JeevesWatcher. Extracted to follow SRP.
@@ -3193,25 +3257,6 @@ function createProcessorConfig(config, configDir, customMapLib) {
3193
3257
  globalSchemas: config.schemas,
3194
3258
  };
3195
3259
  }
3196
- /**
3197
- * Create file system watcher with gitignore filtering.
3198
- */
3199
- function createWatcher(config, factories, queue, processor, logger, runtimeOptions, initialScanTracker, contentHashCache, onVcsFileChange) {
3200
- const respectGitignore = config.watch.respectGitignore ?? true;
3201
- const gitignoreFilter = respectGitignore
3202
- ? new GitignoreFilter(extractWatchPathStrings(config.watch.paths))
3203
- : undefined;
3204
- const watcher = factories.createFileSystemWatcher(config.watch, queue, processor, logger, {
3205
- maxRetries: config.maxRetries ?? runtimeOptions.maxRetries,
3206
- maxBackoffMs: config.maxBackoffMs ?? runtimeOptions.maxBackoffMs,
3207
- onFatalError: runtimeOptions.onFatalError,
3208
- gitignoreFilter,
3209
- initialScanTracker,
3210
- contentHashCache,
3211
- onVcsFileChange,
3212
- });
3213
- return { watcher, gitignoreFilter };
3214
- }
3215
3260
  /**
3216
3261
  * Introspect all helpers for merged document.
3217
3262
  */
@@ -3227,42 +3272,6 @@ async function introspectHelpers(config, configDir) {
3227
3272
  function getConfigDir(configPath) {
3228
3273
  return configPath ? dirname(configPath) : '.';
3229
3274
  }
3230
- /**
3231
- * Tear down and rebuild the filesystem watcher with new config.
3232
- * Falls back to the old watcher if the new one fails to start.
3233
- */
3234
- async function rebuildWatcher(newConfig, factories, queue, processor, logger, runtimeOptions, oldState, initialScanTracker, contentHashCache) {
3235
- try {
3236
- await oldState.watcher.stop();
3237
- const { watcher: newWatcher, gitignoreFilter: newGitignoreFilter } = createWatcher(newConfig, factories, queue, processor, logger, runtimeOptions, initialScanTracker, contentHashCache);
3238
- newWatcher.start();
3239
- logger.info('Filesystem watcher rebuilt successfully');
3240
- return { watcher: newWatcher, gitignoreFilter: newGitignoreFilter };
3241
- }
3242
- catch (error) {
3243
- logger.error({ err: normalizeError(error) }, 'Failed to rebuild watcher, restoring previous');
3244
- try {
3245
- oldState.watcher.start();
3246
- }
3247
- catch (restartError) {
3248
- logger.error({ err: normalizeError(restartError) }, 'Failed to restart previous watcher');
3249
- }
3250
- return oldState;
3251
- }
3252
- }
3253
- /**
3254
- * Check whether watch-relevant config fields changed between old and new config.
3255
- */
3256
- function watchConfigChanged(oldConfig, newConfig) {
3257
- return (JSON.stringify(oldConfig.watch.paths) !==
3258
- JSON.stringify(newConfig.watch.paths) ||
3259
- JSON.stringify(oldConfig.watch.ignored) !==
3260
- JSON.stringify(newConfig.watch.ignored) ||
3261
- JSON.stringify(oldConfig.watch.moveDetection) !==
3262
- JSON.stringify(newConfig.watch.moveDetection) ||
3263
- (oldConfig.watch.respectGitignore ?? true) !==
3264
- (newConfig.watch.respectGitignore ?? true));
3265
- }
3266
3275
  /**
3267
3276
  * Resolve package version from nearest package.json.
3268
3277
  */
@@ -3349,7 +3358,7 @@ async function reloadConfig(state, deps) {
3349
3358
  // Rebuild filesystem watcher if watch config changed
3350
3359
  if (watchConfigChanged(oldConfig, newConfig)) {
3351
3360
  logger.info('Watch config changed, rebuilding filesystem watcher...');
3352
- const newState = await rebuildWatcher(newConfig, deps.factories, deps.queue, processor, logger, deps.runtimeOptions, { watcher: state.watcher, gitignoreFilter: state.gitignoreFilter }, deps.initialScanTracker, deps.contentHashCache);
3361
+ 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);
3353
3362
  state.watcher = newState.watcher;
3354
3363
  state.gitignoreFilter = newState.gitignoreFilter;
3355
3364
  }
@@ -7431,6 +7440,37 @@ class MoveCorrelator {
7431
7440
  }
7432
7441
  }
7433
7442
 
7443
+ /**
7444
+ * @module watcher/ScanStats
7445
+ * Tracks initial filesystem scan statistics for diagnostics.
7446
+ */
7447
+ /**
7448
+ * Mutable counter bag updated during the chokidar initial scan.
7449
+ * Extracted from the watcher start() method for clarity.
7450
+ */
7451
+ class ScanStats {
7452
+ total = 0;
7453
+ matched = 0;
7454
+ globRejected = 0;
7455
+ gitignored = 0;
7456
+ byRoot;
7457
+ constructor(roots) {
7458
+ this.byRoot = Object.fromEntries(roots.map((r) => [r, 0]));
7459
+ }
7460
+ /**
7461
+ * Attribute a newly discovered file to its root for per-root diagnostics.
7462
+ */
7463
+ classifyByRoot(path) {
7464
+ const normalized = path.replace(/\\/g, '/').toLowerCase();
7465
+ for (const root of Object.keys(this.byRoot)) {
7466
+ if (normalized.startsWith(`${root}/`) || normalized === root) {
7467
+ this.byRoot[root] = (this.byRoot[root] ?? 0) + 1;
7468
+ break;
7469
+ }
7470
+ }
7471
+ }
7472
+ }
7473
+
7434
7474
  /**
7435
7475
  * @module watcher
7436
7476
  * Filesystem watcher wrapping chokidar. I/O: watches files/directories for add/change/unlink events, enqueues to processing queue.
@@ -7448,6 +7488,7 @@ class FileSystemWatcher {
7448
7488
  initialScanTracker;
7449
7489
  contentHashCache;
7450
7490
  onVcsFileChange;
7491
+ onInitialScanComplete;
7451
7492
  moveCorrelator;
7452
7493
  globMatches;
7453
7494
  watcher;
@@ -7469,6 +7510,7 @@ class FileSystemWatcher {
7469
7510
  this.initialScanTracker = options.initialScanTracker;
7470
7511
  this.contentHashCache = options.contentHashCache;
7471
7512
  this.onVcsFileChange = options.onVcsFileChange;
7513
+ this.onInitialScanComplete = options.onInitialScanComplete;
7472
7514
  this.globMatches = () => true;
7473
7515
  const healthOptions = {
7474
7516
  maxRetries: options.maxRetries,
@@ -7496,24 +7538,9 @@ class FileSystemWatcher {
7496
7538
  ? resolveIgnored(this.config.ignored)
7497
7539
  : undefined;
7498
7540
  // Track initial scan statistics per root for diagnostics.
7499
- const scanStats = {
7500
- total: 0,
7501
- matched: 0,
7502
- globRejected: 0,
7503
- gitignored: 0,
7504
- byRoot: Object.fromEntries(roots.map((r) => [r, 0])),
7505
- };
7541
+ const scanStats = new ScanStats(roots);
7506
7542
  let initialScanComplete = false;
7507
7543
  this.initialScanTracker?.start();
7508
- const classifyByRoot = (path) => {
7509
- const normalized = path.replace(/\\/g, '/').toLowerCase();
7510
- for (const root of roots) {
7511
- if (normalized.startsWith(root + '/') || normalized === root) {
7512
- scanStats.byRoot[root] = (scanStats.byRoot[root] ?? 0) + 1;
7513
- break;
7514
- }
7515
- }
7516
- };
7517
7544
  // Create move correlator if move detection is configured and cache is available.
7518
7545
  const moveConfig = this.config.moveDetection;
7519
7546
  if (moveConfig?.enabled && this.contentHashCache) {
@@ -7546,7 +7573,7 @@ class FileSystemWatcher {
7546
7573
  this.watcher.on('add', (path) => {
7547
7574
  if (!initialScanComplete) {
7548
7575
  scanStats.total++;
7549
- classifyByRoot(path);
7576
+ scanStats.classifyByRoot(path);
7550
7577
  }
7551
7578
  this.handleGitignoreChange(path);
7552
7579
  if (!this.globMatches(path)) {
@@ -7567,8 +7594,7 @@ class FileSystemWatcher {
7567
7594
  this.initialScanTracker?.incrementEnqueued();
7568
7595
  }
7569
7596
  this.logger.debug({ path }, 'File added');
7570
- if (initialScanComplete)
7571
- this.onVcsFileChange?.(path, 'add');
7597
+ this.onVcsFileChange?.(path, 'add');
7572
7598
  if (correlator && initialScanComplete) {
7573
7599
  void correlator.handleAdd(path);
7574
7600
  }
@@ -7607,6 +7633,7 @@ class FileSystemWatcher {
7607
7633
  initialScanComplete = true;
7608
7634
  this.initialScanTracker?.complete();
7609
7635
  this.logger.info({ scanStats }, 'Initial scan complete');
7636
+ void this.onInitialScanComplete?.();
7610
7637
  });
7611
7638
  this.watcher.on('error', (error) => {
7612
7639
  this.logger.error({ err: normalizeError(error) }, 'Watcher error');
@@ -7768,6 +7795,12 @@ class JeevesWatcher {
7768
7795
  * Start the watcher, API server, and all components.
7769
7796
  */
7770
7797
  async start() {
7798
+ await this.initInfrastructure();
7799
+ await this.initPipeline();
7800
+ await this.startServicesAndWatcher();
7801
+ }
7802
+ /** Set up logger, VCS, embedding provider, and vector store. */
7803
+ async initInfrastructure() {
7771
7804
  const logger = this.factories.createLogger(this.config.logging);
7772
7805
  this.logger = logger;
7773
7806
  await initVcs(this.config, logger);
@@ -7777,6 +7810,10 @@ class JeevesWatcher {
7777
7810
  if (this.config.search?.hybrid?.enabled) {
7778
7811
  await vectorStore.ensureTextIndex('chunk_text');
7779
7812
  }
7813
+ }
7814
+ /** Build the processing pipeline: rules, templates, processor, queue, VCS. */
7815
+ async initPipeline() {
7816
+ const logger = this.logger;
7780
7817
  const compiledRules = this.factories.compileRules(this.config.inferenceRules ?? []);
7781
7818
  const configDir = getConfigDir(this.configPath);
7782
7819
  const { templateEngine, customMapLib } = await buildTemplateEngineAndCustomMapLib(this.config, configDir);
@@ -7786,36 +7823,39 @@ class JeevesWatcher {
7786
7823
  this.issuesManager = new IssuesManager(stateDir, logger);
7787
7824
  this.valuesManager = new ValuesManager(stateDir, logger);
7788
7825
  this.enrichmentStore = new EnrichmentStore(stateDir, logger);
7789
- const enrichmentStore = this.enrichmentStore;
7790
7826
  this.contentHashCache = new ContentHashCache();
7791
- const contentHashCache = this.contentHashCache;
7792
- const processor = this.factories.createDocumentProcessor({
7827
+ this.processor = this.factories.createDocumentProcessor({
7793
7828
  config: processorConfig,
7794
- embeddingProvider,
7795
- vectorStore,
7829
+ embeddingProvider: this.embeddingProvider,
7830
+ vectorStore: this.vectorStore,
7796
7831
  compiledRules,
7797
7832
  logger,
7798
7833
  templateEngine,
7799
- enrichmentStore,
7834
+ enrichmentStore: this.enrichmentStore,
7800
7835
  issuesManager: this.issuesManager,
7801
7836
  valuesManager: this.valuesManager,
7802
- contentHashCache,
7837
+ contentHashCache: this.contentHashCache,
7803
7838
  });
7804
- this.processor = processor;
7805
7839
  this.queue = this.factories.createEventQueue({
7806
7840
  debounceMs: this.config.watch.debounceMs ?? 2000,
7807
7841
  concurrency: this.config.embedding.concurrency ?? 5,
7808
7842
  rateLimitPerMinute: this.config.embedding.rateLimitPerMinute,
7809
7843
  });
7810
- const vcsCoordinator = new VcsCoordinator(this.config, logger);
7811
- this.vcsCoordinator = vcsCoordinator;
7812
- const { watcher, gitignoreFilter } = createWatcher(this.config, this.factories, this.queue, processor, logger, this.runtimeOptions, this.initialScanTracker, contentHashCache, (filePath, event) => {
7844
+ this.vcsCoordinator = new VcsCoordinator(this.config, logger);
7845
+ }
7846
+ /** Wire up watcher and VCS coordinator, then start all services. */
7847
+ async startServicesAndWatcher() {
7848
+ const logger = this.logger;
7849
+ const vcsCoordinator = this.vcsCoordinator;
7850
+ const { watcher, gitignoreFilter } = createWatcher(this.config, this.factories, this.queue, this.processor, logger, this.runtimeOptions, this.initialScanTracker, this.contentHashCache, (filePath, event) => {
7813
7851
  vcsCoordinator.onFileChange(filePath, event);
7852
+ }, () => {
7853
+ void vcsCoordinator.onInitialScanComplete();
7814
7854
  });
7815
7855
  this.watcher = watcher;
7816
7856
  this.gitignoreFilter = gitignoreFilter;
7817
7857
  this.server = await this.startApiServer();
7818
- await vcsCoordinator.start();
7858
+ vcsCoordinator.start();
7819
7859
  this.watcher.start();
7820
7860
  this.startConfigWatch();
7821
7861
  logger.info('jeeves-watcher started');
@@ -7930,6 +7970,8 @@ class JeevesWatcher {
7930
7970
  contentHashCache: this.contentHashCache,
7931
7971
  valuesManager: this.valuesManager,
7932
7972
  issuesManager: this.issuesManager,
7973
+ onVcsFileChange: (filePath, event) => this.vcsCoordinator?.onFileChange(filePath, event),
7974
+ onInitialScanComplete: () => this.vcsCoordinator?.onInitialScanComplete(),
7933
7975
  });
7934
7976
  this.config = state.config;
7935
7977
  this.watcher = state.watcher;