@karmaniverous/jeeves-watcher 0.17.11 → 0.18.0

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,7 +1,9 @@
1
- import { join, dirname, resolve, relative, extname, basename, isAbsolute } from 'node:path';
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, 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
+ export { apiConfigSchema, configWatchConfigSchema, embeddingConfigSchema, inferenceRuleSchema, jeevesWatcherConfigSchema, loggingConfigSchema, vectorStoreConfigSchema, watchConfigSchema } from '@karmaniverous/jeeves-watcher-core';
3
5
  import Fastify from 'fastify';
4
- import { readdir, stat, readFile } from 'node:fs/promises';
6
+ import { readdir, stat, readFile, writeFile, mkdir, appendFile, access, rm } from 'node:fs/promises';
5
7
  import { parallel, capitalize, title, camel, snake, dash, isEqual, get, omit } from 'radash';
6
8
  import { existsSync, statSync, readFileSync, readdirSync, mkdirSync, writeFileSync, rmSync, renameSync } from 'node:fs';
7
9
  import ignore from 'ignore';
@@ -9,8 +11,6 @@ import picomatch from 'picomatch';
9
11
  import Ajv from 'ajv';
10
12
  import addFormats from 'ajv-formats';
11
13
  import { z, ZodError } from 'zod';
12
- import { inferenceRuleSchema, jeevesWatcherConfigSchema, 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';
13
- export { apiConfigSchema, configWatchConfigSchema, embeddingConfigSchema, inferenceRuleSchema, jeevesWatcherConfigSchema, loggingConfigSchema, vectorStoreConfigSchema, watchConfigSchema } from '@karmaniverous/jeeves-watcher-core';
14
14
  import { JsonMap } from '@karmaniverous/jsonmap';
15
15
  import { pathToFileURL, fileURLToPath } from 'node:url';
16
16
  import Handlebars from 'handlebars';
@@ -21,8 +21,11 @@ import { toMarkdown } from 'mdast-util-to-markdown';
21
21
  import rehypeParse from 'rehype-parse';
22
22
  import { unified } from 'unified';
23
23
  import yaml from 'js-yaml';
24
+ import { execFile } from 'node:child_process';
25
+ import { promisify } from 'node:util';
24
26
  import crypto from 'crypto';
25
27
  import { packageDirectorySync } from 'package-directory';
28
+ import { homedir } from 'node:os';
26
29
  import Database from 'better-sqlite3';
27
30
  import { deserialize, serialize } from 'node:v8';
28
31
  import chokidar from 'chokidar';
@@ -696,7 +699,7 @@ async function computePrunePlan(deps) {
696
699
  continue;
697
700
  }
698
701
  seenFiles.add(filePath);
699
- const watched = isPathWatched(filePath, config.watch.paths, config.watch.ignored);
702
+ const watched = isPathWatched(filePath, extractWatchPathStrings(config.watch.paths), config.watch.ignored);
700
703
  const gitignored = gitignoreFilter
701
704
  ? gitignoreFilter.isIgnored(filePath)
702
705
  : false;
@@ -716,7 +719,7 @@ async function computePrunePlan(deps) {
716
719
  incomplete = true;
717
720
  }
718
721
  const orphanedFileList = [...orphanedFiles];
719
- const byRoot = groupByRoot(orphanedFileList, config.watch.paths);
722
+ const byRoot = groupByRoot(orphanedFileList, extractWatchPathStrings(config.watch.paths));
720
723
  return {
721
724
  plan: {
722
725
  total: totalPoints,
@@ -832,18 +835,18 @@ async function executeReindex(deps, scope, path, dryRun = false) {
832
835
  }
833
836
  else if (scope === 'rules' && pathArray && pathArray.length > 0) {
834
837
  // Rules scope with path filter: list from watch roots intersected with caller globs
835
- fileList = await listFilesFromWatchRoots(config.watch.paths, config.watch.ignored, pathArray, isGitignored);
838
+ fileList = await listFilesFromWatchRoots(extractWatchPathStrings(config.watch.paths), config.watch.ignored, pathArray, isGitignored);
836
839
  }
837
840
  else {
838
841
  // rules or full: list files from globs
839
- fileList = await listFilesFromGlobs(config.watch.paths, config.watch.ignored, isGitignored);
842
+ fileList = await listFilesFromGlobs(extractWatchPathStrings(config.watch.paths), config.watch.ignored, isGitignored);
840
843
  }
841
844
  if (fileList !== undefined) {
842
845
  plan = {
843
846
  total: fileList.length,
844
847
  toProcess: fileList.length,
845
848
  toDelete: 0,
846
- byRoot: groupByRoot(fileList, config.watch.paths),
849
+ byRoot: groupByRoot(fileList, extractWatchPathStrings(config.watch.paths)),
847
850
  };
848
851
  }
849
852
  if (dryRun) {
@@ -854,7 +857,7 @@ async function executeReindex(deps, scope, path, dryRun = false) {
854
857
  total: pathFiles.length,
855
858
  toProcess: pathFiles.length,
856
859
  toDelete: 0,
857
- byRoot: groupByRoot(pathFiles, config.watch.paths),
860
+ byRoot: groupByRoot(pathFiles, extractWatchPathStrings(config.watch.paths)),
858
861
  };
859
862
  }
860
863
  return { filesProcessed: 0, durationMs: 0, errors: 0, plan };
@@ -881,7 +884,7 @@ async function executeReindex(deps, scope, path, dryRun = false) {
881
884
  });
882
885
  }
883
886
  else if (scope === 'rules') {
884
- filesProcessed = await processAllFiles(config.watch.paths, config.watch.ignored, processor, 'processRulesUpdate', concurrency, {
887
+ filesProcessed = await processAllFiles(extractWatchPathStrings(config.watch.paths), config.watch.ignored, processor, 'processRulesUpdate', concurrency, {
885
888
  onTotal: (total) => reindexTracker?.setTotal(total),
886
889
  onFileProcessed: () => reindexTracker?.incrementProcessed(),
887
890
  }, isGitignored, fileList);
@@ -892,7 +895,7 @@ async function executeReindex(deps, scope, path, dryRun = false) {
892
895
  total: uniqueFiles.length,
893
896
  toProcess: uniqueFiles.length,
894
897
  toDelete: 0,
895
- byRoot: groupByRoot(uniqueFiles, config.watch.paths),
898
+ byRoot: groupByRoot(uniqueFiles, extractWatchPathStrings(config.watch.paths)),
896
899
  };
897
900
  reindexTracker?.setTotal(uniqueFiles.length);
898
901
  await parallel(concurrency, uniqueFiles, async (filePath) => {
@@ -903,7 +906,7 @@ async function executeReindex(deps, scope, path, dryRun = false) {
903
906
  }
904
907
  else {
905
908
  // Full reindex
906
- filesProcessed = await processAllFiles(config.watch.paths, config.watch.ignored, processor, 'processFile', concurrency, {
909
+ filesProcessed = await processAllFiles(extractWatchPathStrings(config.watch.paths), config.watch.ignored, processor, 'processFile', concurrency, {
907
910
  onTotal: (total) => reindexTracker?.setTotal(total),
908
911
  onFileProcessed: () => reindexTracker?.incrementProcessed(),
909
912
  }, isGitignored, fileList);
@@ -951,7 +954,7 @@ async function collectPathFiles(pathArray, config, gitignoreFilter) {
951
954
  * Get the file list for a path-scoped reindex (for plan computation).
952
955
  */
953
956
  async function getPathFileList(targetPath, config, gitignoreFilter) {
954
- const watched = isPathWatched(targetPath, config.watch.paths, config.watch.ignored);
957
+ const watched = isPathWatched(targetPath, extractWatchPathStrings(config.watch.paths), config.watch.ignored);
955
958
  if (!watched) {
956
959
  throw new Error(`Path is outside watch scope: ${targetPath}`);
957
960
  }
@@ -963,7 +966,7 @@ async function getPathFileList(targetPath, config, gitignoreFilter) {
963
966
  return [targetPath];
964
967
  if (stats.isDirectory()) {
965
968
  const isGitignored = createIsGitignored(gitignoreFilter);
966
- const allWatchedFiles = await listFilesFromGlobs(config.watch.paths, config.watch.ignored, isGitignored);
969
+ const allWatchedFiles = await listFilesFromGlobs(extractWatchPathStrings(config.watch.paths), config.watch.ignored, isGitignored);
967
970
  const targetAbsPath = resolve(targetPath);
968
971
  return allWatchedFiles.filter((f) => resolve(f).startsWith(targetAbsPath));
969
972
  }
@@ -1148,7 +1151,9 @@ function createConfigMatchHandler(options) {
1148
1151
  const config = getConfig();
1149
1152
  if (config !== cachedConfig) {
1150
1153
  compiledRules = compileRules(config.inferenceRules ?? []);
1151
- watchMatcher = picomatch(config.watch.paths, { dot: true });
1154
+ watchMatcher = picomatch(extractWatchPathStrings(config.watch.paths), {
1155
+ dot: true,
1156
+ });
1152
1157
  ignoreMatcher = config.watch.ignored.length
1153
1158
  ? picomatch(config.watch.ignored, { dot: true })
1154
1159
  : undefined;
@@ -3171,7 +3176,7 @@ function createRenderHandler(deps) {
3171
3176
  }
3172
3177
  // Validate path is within watched scope (live config)
3173
3178
  const watch = getWatch();
3174
- if (!isPathWatched(filePath, watch.paths, watch.ignored)) {
3179
+ if (!isPathWatched(filePath, extractWatchPathStrings(watch.paths), watch.ignored)) {
3175
3180
  return reply.status(403).send({ error: 'Path is outside watched scope' });
3176
3181
  }
3177
3182
  try {
@@ -3364,6 +3369,579 @@ function createSearchHandler(deps) {
3364
3369
  }, deps.logger, 'Search');
3365
3370
  }
3366
3371
 
3372
+ /**
3373
+ * @module vcs/gitExec
3374
+ * Shared git execution utilities.
3375
+ */
3376
+ /** Promisified execFile for git commands. */
3377
+ const execFileAsync = promisify(execFile);
3378
+ /**
3379
+ * Find the longest-prefix-match root for a normalized path.
3380
+ * Roots must be sorted longest-first for correct nested matching.
3381
+ *
3382
+ * @param roots - Root paths sorted longest-first.
3383
+ * @param normalizedPath - Normalized absolute path (forward slashes).
3384
+ * @returns The matching root, or undefined.
3385
+ */
3386
+ function findRootForPath(roots, normalizedPath) {
3387
+ for (const root of roots) {
3388
+ if (normalizedPath === root || normalizedPath.startsWith(root + '/')) {
3389
+ return root;
3390
+ }
3391
+ }
3392
+ return undefined;
3393
+ }
3394
+
3395
+ /**
3396
+ * @module vcs/resolveWatchRoot
3397
+ * Resolves which VCS-enabled watch root(s) own a given absolute path or glob.
3398
+ */
3399
+ /**
3400
+ * Resolve which VCS-enabled watch root owns an absolute path.
3401
+ *
3402
+ * @param coordinator - The VcsCoordinator with root information.
3403
+ * @param absolutePath - An absolute filesystem path.
3404
+ * @returns The matching root and relative path, or undefined if no root matches.
3405
+ */
3406
+ function resolveWatchRoot(coordinator, absolutePath) {
3407
+ const normalized = normalizeSlashes(resolve(absolutePath));
3408
+ const root = findRootForPath(coordinator.getRoots(), normalized);
3409
+ if (!root)
3410
+ return undefined;
3411
+ return {
3412
+ root,
3413
+ relativePath: normalizeSlashes(relative(root, normalized)),
3414
+ };
3415
+ }
3416
+ /**
3417
+ * Resolve which VCS-enabled watch roots match a glob pattern.
3418
+ * For globs that could span multiple roots, returns all matching roots.
3419
+ *
3420
+ * @param coordinator - The VcsCoordinator with root information.
3421
+ * @param glob - An absolute glob pattern.
3422
+ * @returns Array of matching roots with relative glob patterns.
3423
+ */
3424
+ function resolveWatchRootsForGlob(coordinator, glob) {
3425
+ const normalizedGlob = normalizeSlashes(glob);
3426
+ const roots = coordinator.getRoots();
3427
+ const results = [];
3428
+ for (const root of roots) {
3429
+ if (normalizedGlob.startsWith(root + '/') || normalizedGlob === root) {
3430
+ // Glob is under this root
3431
+ results.push({
3432
+ root,
3433
+ relativePath: normalizeSlashes(relative(root, normalizedGlob)),
3434
+ });
3435
+ }
3436
+ else if (root.startsWith(normalizedGlob.replace(/[*?[\]{}]/g, ''))) {
3437
+ // Root is under the glob's base path — include with root-relative glob
3438
+ results.push({
3439
+ root,
3440
+ relativePath: '.',
3441
+ });
3442
+ }
3443
+ }
3444
+ return results;
3445
+ }
3446
+
3447
+ /**
3448
+ * @module api/handlers/vcs/vcsCheckExclusion
3449
+ * Fastify route handler for GET /vcs/check-exclusion. Checks gitignore status of a path.
3450
+ */
3451
+ /**
3452
+ * Create handler for GET /vcs/check-exclusion.
3453
+ */
3454
+ function createVcsCheckExclusionHandler(deps) {
3455
+ return wrapHandler(async (request, reply) => {
3456
+ const { path } = request.query;
3457
+ if (!path) {
3458
+ void reply
3459
+ .status(400)
3460
+ .send({ error: 'Missing required query parameter: path' });
3461
+ return;
3462
+ }
3463
+ const resolved = resolveWatchRoot(deps.coordinator, path);
3464
+ if (!resolved) {
3465
+ void reply
3466
+ .status(404)
3467
+ .send({ error: 'Path does not match any VCS-enabled watch root' });
3468
+ return;
3469
+ }
3470
+ try {
3471
+ const { stdout } = await execFileAsync('git', ['check-ignore', '-v', resolved.relativePath], { cwd: resolved.root });
3472
+ // Output format: <source>:<linenum>:<pattern>\t<pathname>
3473
+ const trimmed = stdout.trim();
3474
+ const match = /^(.+):(\d+):(.+)\t/.exec(trimmed);
3475
+ if (match) {
3476
+ return {
3477
+ excluded: true,
3478
+ rule: match[3].trim(),
3479
+ source: match[1],
3480
+ };
3481
+ }
3482
+ return { excluded: true };
3483
+ }
3484
+ catch {
3485
+ // Exit code 1 means the path is NOT ignored
3486
+ return { excluded: false };
3487
+ }
3488
+ }, deps.logger, 'VcsCheckExclusion');
3489
+ }
3490
+
3491
+ /**
3492
+ * @module api/handlers/vcs/vcsDiff
3493
+ * Fastify route handler for GET /vcs/diff. Returns diff text for a glob between commits.
3494
+ */
3495
+ /**
3496
+ * Create handler for GET /vcs/diff.
3497
+ */
3498
+ function createVcsDiffHandler(deps) {
3499
+ return wrapHandler(async (request, reply) => {
3500
+ const { glob, commit, commitEnd } = request.query;
3501
+ if (!glob || !commit) {
3502
+ void reply
3503
+ .status(400)
3504
+ .send({ error: 'Missing required query parameters: glob, commit' });
3505
+ return;
3506
+ }
3507
+ const resolved = resolveWatchRootsForGlob(deps.coordinator, glob);
3508
+ if (resolved.length === 0) {
3509
+ void reply
3510
+ .status(404)
3511
+ .send({ error: 'No VCS-enabled watch root matches the given glob' });
3512
+ return;
3513
+ }
3514
+ const diffs = [];
3515
+ for (const { root, relativePath } of resolved) {
3516
+ const range = commitEnd ? `${commit}..${commitEnd}` : `${commit}..HEAD`;
3517
+ const args = ['diff', range, '--', relativePath || '.'];
3518
+ try {
3519
+ const { stdout } = await execFileAsync('git', args, { cwd: root });
3520
+ if (stdout.trim())
3521
+ diffs.push(stdout);
3522
+ }
3523
+ catch {
3524
+ // Invalid commit or path — skip
3525
+ }
3526
+ }
3527
+ void reply.header('content-type', 'text/plain').send(diffs.join('\n'));
3528
+ }, deps.logger, 'VcsDiff');
3529
+ }
3530
+
3531
+ /**
3532
+ * @module api/handlers/vcs/vcsExclude
3533
+ * Fastify route handler for POST /vcs/exclude. Manages .gitignore exclusions
3534
+ * with locality-based placement.
3535
+ */
3536
+ /**
3537
+ * Determine the deepest directory for .gitignore placement and the pattern
3538
+ * to add. Splits the glob at the first wildcard character.
3539
+ *
3540
+ * @param relativeGlob - Glob relative to the watch root.
3541
+ * @returns The directory (relative to root) and the pattern for .gitignore.
3542
+ */
3543
+ function splitGlobForLocality(relativeGlob) {
3544
+ // Find the first path segment containing a wildcard
3545
+ const segments = relativeGlob.split('/');
3546
+ const dirSegments = [];
3547
+ for (const segment of segments) {
3548
+ if (/[*?[\]{}]/.test(segment))
3549
+ break;
3550
+ dirSegments.push(segment);
3551
+ }
3552
+ // Last non-wildcard segment might be a filename, not a directory.
3553
+ // If no wildcards at all, the directory is the parent of the file.
3554
+ if (dirSegments.length === segments.length) {
3555
+ // No wildcards — the glob is a literal path, directory is parent
3556
+ const dir = dirSegments.slice(0, -1).join('/') || '.';
3557
+ const pattern = dirSegments[dirSegments.length - 1];
3558
+ return { dir, pattern };
3559
+ }
3560
+ const dir = dirSegments.join('/') || '.';
3561
+ const patternSegments = segments.slice(dirSegments.length);
3562
+ const pattern = patternSegments.join('/');
3563
+ return { dir, pattern };
3564
+ }
3565
+ /**
3566
+ * Create handler for POST /vcs/exclude.
3567
+ */
3568
+ function createVcsExcludeHandler(deps) {
3569
+ return wrapHandler(async (request, reply) => {
3570
+ const { glob, root: requestedRoot, remove } = request.body;
3571
+ if (!glob) {
3572
+ void reply
3573
+ .status(400)
3574
+ .send({ error: 'Missing required body parameter: glob' });
3575
+ return;
3576
+ }
3577
+ // Resolve the root
3578
+ let root;
3579
+ if (requestedRoot) {
3580
+ const normalizedRoot = normalizeSlashes(requestedRoot);
3581
+ const manager = deps.coordinator.getManager(normalizedRoot);
3582
+ if (!manager) {
3583
+ void reply
3584
+ .status(404)
3585
+ .send({ error: 'Root is not a VCS-enabled watch root' });
3586
+ return;
3587
+ }
3588
+ root = normalizedRoot;
3589
+ }
3590
+ else {
3591
+ // Resolve root from the glob path
3592
+ const normalizedGlob = normalizeSlashes(glob);
3593
+ const basePath = normalizedGlob.replace(/[*?[\]{}].*/g, '');
3594
+ const matchedRoot = findRootForPath(deps.coordinator.getRoots(), basePath);
3595
+ if (!matchedRoot) {
3596
+ void reply
3597
+ .status(404)
3598
+ .send({ error: 'Glob does not match any VCS-enabled watch root' });
3599
+ return;
3600
+ }
3601
+ root = matchedRoot;
3602
+ }
3603
+ // Compute relative glob from root
3604
+ const normalizedGlob = normalizeSlashes(glob);
3605
+ let relativeGlob;
3606
+ if (normalizedGlob.startsWith(root + '/')) {
3607
+ relativeGlob = normalizedGlob.slice(root.length + 1);
3608
+ }
3609
+ else {
3610
+ // Glob is the root itself or doesn't start with root — use as-is
3611
+ relativeGlob = normalizedGlob;
3612
+ }
3613
+ // Determine .gitignore directory and pattern using locality principle
3614
+ const { dir, pattern } = splitGlobForLocality(relativeGlob);
3615
+ const gitignoreDir = dir === '.' ? root : normalizeSlashes(join(root, dir));
3616
+ const gitignorePath = normalizeSlashes(join(gitignoreDir, '.gitignore'));
3617
+ if (remove) {
3618
+ // Remove the pattern from .gitignore
3619
+ let content;
3620
+ try {
3621
+ content = await readFile(gitignorePath, 'utf8');
3622
+ }
3623
+ catch {
3624
+ void reply
3625
+ .status(404)
3626
+ .send({ error: 'No .gitignore found at the expected location' });
3627
+ return;
3628
+ }
3629
+ const lines = content.split('\n');
3630
+ const filtered = lines.filter((line) => line.trim() !== pattern);
3631
+ if (filtered.length === lines.length) {
3632
+ void reply
3633
+ .status(404)
3634
+ .send({ error: 'Pattern not found in .gitignore' });
3635
+ return;
3636
+ }
3637
+ await writeFile(gitignorePath, filtered.join('\n'), 'utf8');
3638
+ return { ok: true, gitignorePath, action: 'removed' };
3639
+ }
3640
+ // Add the pattern to .gitignore
3641
+ await mkdir(gitignoreDir, { recursive: true });
3642
+ let existingContent = '';
3643
+ try {
3644
+ existingContent = await readFile(gitignorePath, 'utf8');
3645
+ }
3646
+ catch {
3647
+ // File doesn't exist yet
3648
+ }
3649
+ // Check if pattern already exists
3650
+ const existingLines = new Set(existingContent.split('\n').map((line) => line.trim()));
3651
+ if (existingLines.has(pattern)) {
3652
+ return { ok: true, gitignorePath, action: 'added' };
3653
+ }
3654
+ if (existingContent.length === 0) {
3655
+ await writeFile(gitignorePath, pattern + '\n', 'utf8');
3656
+ }
3657
+ else {
3658
+ const prefix = existingContent.endsWith('\n') ? '' : '\n';
3659
+ await appendFile(gitignorePath, prefix + pattern + '\n', 'utf8');
3660
+ }
3661
+ return { ok: true, gitignorePath, action: 'added' };
3662
+ }, deps.logger, 'VcsExclude');
3663
+ }
3664
+
3665
+ /**
3666
+ * @module api/handlers/vcs/vcsHistory
3667
+ * Fastify route handler for GET /vcs/history. Returns commit history for a glob.
3668
+ */
3669
+ /** Matches a commit header line: 40-hex-char hash, pipe, message, pipe, ISO timestamp. */
3670
+ const HEADER_RE = /^([0-9a-f]{40})\|(.+)\|(\d{4}-\d{2}-\d{2}T.+)$/;
3671
+ /**
3672
+ * Parse git log --format='%H|%s|%aI' --name-only output into structured entries.
3673
+ *
3674
+ * Output format per commit:
3675
+ * hash|subject|ISO-date
3676
+ * (empty line)
3677
+ * file1
3678
+ * file2
3679
+ * (empty line)
3680
+ */
3681
+ function parseGitLog(stdout) {
3682
+ const entries = [];
3683
+ const lines = stdout.split('\n');
3684
+ let current;
3685
+ for (const line of lines) {
3686
+ const trimmed = line.trim();
3687
+ const headerMatch = HEADER_RE.exec(trimmed);
3688
+ if (headerMatch) {
3689
+ // Start of a new commit
3690
+ current = {
3691
+ commit: headerMatch[1],
3692
+ message: headerMatch[2],
3693
+ timestamp: headerMatch[3],
3694
+ files: [],
3695
+ };
3696
+ entries.push(current);
3697
+ }
3698
+ else if (trimmed.length > 0 && current) {
3699
+ current.files.push(trimmed);
3700
+ }
3701
+ }
3702
+ return entries;
3703
+ }
3704
+ /**
3705
+ * Create handler for GET /vcs/history.
3706
+ */
3707
+ function createVcsHistoryHandler(deps) {
3708
+ return wrapHandler(async (request, reply) => {
3709
+ const { glob, since, until, limit: limitStr } = request.query;
3710
+ if (!glob) {
3711
+ void reply
3712
+ .status(400)
3713
+ .send({ error: 'Missing required query parameter: glob' });
3714
+ return;
3715
+ }
3716
+ const limit = limitStr ? parseInt(limitStr, 10) : 20;
3717
+ const resolved = resolveWatchRootsForGlob(deps.coordinator, glob);
3718
+ if (resolved.length === 0) {
3719
+ void reply
3720
+ .status(404)
3721
+ .send({ error: 'No VCS-enabled watch root matches the given glob' });
3722
+ return;
3723
+ }
3724
+ const allEntries = [];
3725
+ for (const { root, relativePath } of resolved) {
3726
+ const args = ['log', '--format=%H|%s|%aI', '--name-only'];
3727
+ if (since)
3728
+ args.push(`--since=${since}`);
3729
+ if (until)
3730
+ args.push(`--until=${until}`);
3731
+ args.push('--', relativePath || '.');
3732
+ try {
3733
+ const { stdout } = await execFileAsync('git', args, { cwd: root });
3734
+ allEntries.push(...parseGitLog(stdout));
3735
+ }
3736
+ catch {
3737
+ // No commits or invalid path — skip
3738
+ }
3739
+ }
3740
+ // Sort by timestamp descending
3741
+ allEntries.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
3742
+ return allEntries.slice(0, limit);
3743
+ }, deps.logger, 'VcsHistory');
3744
+ }
3745
+
3746
+ /**
3747
+ * @module api/handlers/vcs/vcsRevert
3748
+ * Fastify route handler for POST /vcs/revert. Restores files from a past commit.
3749
+ */
3750
+ /**
3751
+ * Create handler for POST /vcs/revert.
3752
+ */
3753
+ function createVcsRevertHandler(deps) {
3754
+ return wrapHandler(async (request, reply) => {
3755
+ const { glob, commit, existingOnly } = request.body;
3756
+ if (!glob || !commit) {
3757
+ void reply
3758
+ .status(400)
3759
+ .send({ error: 'Missing required body parameters: glob, commit' });
3760
+ return;
3761
+ }
3762
+ const resolved = resolveWatchRootsForGlob(deps.coordinator, glob);
3763
+ if (resolved.length === 0) {
3764
+ void reply
3765
+ .status(404)
3766
+ .send({ error: 'Glob does not match any VCS-enabled watch root' });
3767
+ return;
3768
+ }
3769
+ const allFiles = [];
3770
+ for (const { root, relativePath } of resolved) {
3771
+ const manager = deps.coordinator.getManager(root);
3772
+ if (!manager)
3773
+ continue;
3774
+ // List files matching the glob at the target commit
3775
+ let fileList;
3776
+ try {
3777
+ const args = relativePath === '' || relativePath === '.'
3778
+ ? ['ls-tree', '-r', '--name-only', commit]
3779
+ : ['ls-tree', '-r', '--name-only', commit, '--', relativePath];
3780
+ const { stdout } = await execFileAsync('git', args, { cwd: root });
3781
+ fileList = stdout
3782
+ .trim()
3783
+ .split('\n')
3784
+ .filter((line) => line.length > 0);
3785
+ }
3786
+ catch {
3787
+ continue;
3788
+ }
3789
+ const restoredPaths = [];
3790
+ for (const relFile of fileList) {
3791
+ const absPath = normalizeSlashes(join(root, relFile));
3792
+ // If existingOnly, skip files that don't exist on disk
3793
+ if (existingOnly) {
3794
+ try {
3795
+ await access(absPath);
3796
+ }
3797
+ catch {
3798
+ continue;
3799
+ }
3800
+ }
3801
+ // Get file content at the target commit
3802
+ let content;
3803
+ try {
3804
+ const { stdout } = await execFileAsync('git', ['show', `${commit}:${relFile}`], { cwd: root });
3805
+ content = stdout;
3806
+ }
3807
+ catch {
3808
+ continue;
3809
+ }
3810
+ // Ensure parent directory exists
3811
+ const dir = dirname(absPath);
3812
+ await mkdir(dir, { recursive: true });
3813
+ // Write the file
3814
+ await writeFile(absPath, content, 'utf8');
3815
+ restoredPaths.push(absPath);
3816
+ // Notify VcsManager of the change (enters debounce cycle)
3817
+ manager.fileChanged(absPath);
3818
+ }
3819
+ if (restoredPaths.length > 0) {
3820
+ manager.addPendingReversion({
3821
+ glob,
3822
+ commit,
3823
+ paths: restoredPaths,
3824
+ });
3825
+ allFiles.push(...restoredPaths);
3826
+ }
3827
+ }
3828
+ return { restored: allFiles.length, files: allFiles };
3829
+ }, deps.logger, 'VcsRevert');
3830
+ }
3831
+
3832
+ /**
3833
+ * @module api/handlers/vcs/vcsShow
3834
+ * Fastify route handler for GET /vcs/show. Returns file content at a specific commit.
3835
+ */
3836
+ const CONTENT_TYPES = {
3837
+ '.txt': 'text/plain',
3838
+ '.md': 'text/markdown',
3839
+ '.json': 'application/json',
3840
+ '.xml': 'application/xml',
3841
+ '.html': 'text/html',
3842
+ '.css': 'text/css',
3843
+ '.js': 'application/javascript',
3844
+ '.ts': 'text/plain',
3845
+ '.yaml': 'text/yaml',
3846
+ '.yml': 'text/yaml',
3847
+ '.csv': 'text/csv',
3848
+ };
3849
+ /**
3850
+ * Create handler for GET /vcs/show.
3851
+ */
3852
+ function createVcsShowHandler(deps) {
3853
+ return wrapHandler(async (request, reply) => {
3854
+ const { path, commit } = request.query;
3855
+ if (!path || !commit) {
3856
+ void reply
3857
+ .status(400)
3858
+ .send({ error: 'Missing required query parameters: path, commit' });
3859
+ return;
3860
+ }
3861
+ const resolved = resolveWatchRoot(deps.coordinator, path);
3862
+ if (!resolved) {
3863
+ void reply
3864
+ .status(404)
3865
+ .send({ error: 'Path does not match any VCS-enabled watch root' });
3866
+ return;
3867
+ }
3868
+ try {
3869
+ const { stdout } = await execFileAsync('git', ['show', `${commit}:${resolved.relativePath}`], { cwd: resolved.root });
3870
+ const ext = extname(path).toLowerCase();
3871
+ const contentType = CONTENT_TYPES[ext] ?? 'application/octet-stream';
3872
+ void reply.header('content-type', contentType).send(stdout);
3873
+ }
3874
+ catch {
3875
+ void reply
3876
+ .status(404)
3877
+ .send({ error: 'File not found at the specified commit' });
3878
+ }
3879
+ }, deps.logger, 'VcsShow');
3880
+ }
3881
+
3882
+ /**
3883
+ * @module api/handlers/vcs/vcsStatus
3884
+ * Fastify route handler for GET /vcs/status. Returns VCS state for all roots.
3885
+ */
3886
+ async function getLastCommit(cwd) {
3887
+ try {
3888
+ const { stdout } = await execFileAsync('git', ['log', '-1', '--format=%H|%s|%aI'], { cwd });
3889
+ const trimmed = stdout.trim();
3890
+ if (!trimmed)
3891
+ return null;
3892
+ const [hash, message, timestamp] = trimmed.split('|');
3893
+ return { hash, message, timestamp };
3894
+ }
3895
+ catch {
3896
+ return null;
3897
+ }
3898
+ }
3899
+ async function getTrackedCount(cwd) {
3900
+ try {
3901
+ const { stdout } = await execFileAsync('git', ['rev-list', '--count', 'HEAD'], { cwd });
3902
+ return parseInt(stdout.trim(), 10);
3903
+ }
3904
+ catch {
3905
+ return 0;
3906
+ }
3907
+ }
3908
+ async function getRemoteUrl(cwd) {
3909
+ try {
3910
+ const { stdout } = await execFileAsync('git', ['remote', 'get-url', 'origin'], { cwd });
3911
+ const url = stdout.trim();
3912
+ return url || null;
3913
+ }
3914
+ catch {
3915
+ return null;
3916
+ }
3917
+ }
3918
+ /**
3919
+ * Create handler for GET /vcs/status.
3920
+ */
3921
+ function createVcsStatusHandler(deps) {
3922
+ return wrapHandler(async () => {
3923
+ const roots = deps.coordinator.getRoots();
3924
+ const enabled = roots.length > 0;
3925
+ const rootStatuses = await Promise.all(roots.map(async (root) => {
3926
+ const manager = deps.coordinator.getManager(root);
3927
+ const [tracked, lastCommit, remoteUrl] = await Promise.all([
3928
+ getTrackedCount(root),
3929
+ getLastCommit(root),
3930
+ getRemoteUrl(root),
3931
+ ]);
3932
+ return {
3933
+ path: root,
3934
+ tracked,
3935
+ lastCommit,
3936
+ remoteUrl: manager?.remoteUrl ?? remoteUrl,
3937
+ lastPush: manager?.lastPushTime ?? null,
3938
+ pushErrors: manager?.pushErrors ?? [],
3939
+ };
3940
+ }));
3941
+ return { enabled, roots: rootStatuses };
3942
+ }, deps.logger, 'VcsStatus');
3943
+ }
3944
+
3367
3945
  /**
3368
3946
  * @module api/handlers/walk
3369
3947
  * Fastify route handler for POST /walk. Enumerates watched files matching caller-provided globs
@@ -3460,108 +4038,1019 @@ function withCache(ttlMs, handler) {
3460
4038
  if (fReply.statusCode >= 400) {
3461
4039
  return result;
3462
4040
  }
3463
- // Skip cache for responses with Cache-Control: no-cache
3464
- const cacheControl = fReply.getHeader('Cache-Control');
3465
- if (cacheControl === 'no-cache') {
3466
- return result;
4041
+ // Skip cache for responses with Cache-Control: no-cache
4042
+ const cacheControl = fReply.getHeader('Cache-Control');
4043
+ if (cacheControl === 'no-cache') {
4044
+ return result;
4045
+ }
4046
+ // Store payload in cache.
4047
+ // Prefer captured reply payload (wrapHandler path). Fall back to handler return value.
4048
+ const payloadToCache = capturedPayload !== undefined ? capturedPayload : result;
4049
+ cache.set(key, {
4050
+ value: payloadToCache,
4051
+ expiresAt: now + ttlMs,
4052
+ });
4053
+ return result;
4054
+ };
4055
+ }
4056
+
4057
+ /**
4058
+ * @module helpers/introspect
4059
+ * JSDoc introspection for helper modules. Extracts function exports and their descriptions.
4060
+ */
4061
+ /**
4062
+ * Extract JSDoc descriptions from source code for exported functions.
4063
+ *
4064
+ * Looks for JSDoc comments immediately before `export function name` or `export const name`.
4065
+ *
4066
+ * @param source - The source file content.
4067
+ * @returns Map of export name to JSDoc description.
4068
+ */
4069
+ function extractJsDocDescriptions(source) {
4070
+ const result = {};
4071
+ // Match JSDoc comment followed by export function/const
4072
+ const pattern = /\/\*\*([\s\S]*?)\*\/\s*export\s+(?:function|const)\s+(\w+)/g;
4073
+ let match;
4074
+ while ((match = pattern.exec(source)) !== null) {
4075
+ const [, commentBody, name] = match;
4076
+ if (!name || !commentBody)
4077
+ continue;
4078
+ // Extract the description: lines before any @tag
4079
+ const lines = commentBody
4080
+ .split('\n')
4081
+ .map((line) => line.replace(/^\s*\*\s?/, '').trim())
4082
+ .filter((line) => line.length > 0 && !line.startsWith('@'));
4083
+ result[name] = lines.join(' ');
4084
+ }
4085
+ return result;
4086
+ }
4087
+ /**
4088
+ * Introspect a single helper module, enumerating function exports and extracting JSDoc descriptions.
4089
+ *
4090
+ * @param filePath - Resolved path to the helper module.
4091
+ * @param namespace - The namespace prefix for this module.
4092
+ * @returns Introspection result with namespaced export names and descriptions.
4093
+ */
4094
+ async function introspectHelperModule(filePath, namespace) {
4095
+ const exports = {};
4096
+ // Load module exports using shared utility (use parent dir as configDir, path as relative)
4097
+ const namespaced = await loadNamespacedExports({ [namespace]: { path: filePath } }, '', (val) => typeof val === 'function');
4098
+ // Try to read source for JSDoc extraction
4099
+ let jsDocMap = {};
4100
+ for (const ext of ['.ts', '.js', '']) {
4101
+ const sourcePath = filePath.replace(/\.[jt]s$/, '') + ext;
4102
+ try {
4103
+ const source = readFileSync(ext === '' ? filePath : sourcePath, 'utf-8');
4104
+ jsDocMap = extractJsDocDescriptions(source);
4105
+ if (Object.keys(jsDocMap).length > 0)
4106
+ break;
4107
+ }
4108
+ catch {
4109
+ // File not found, try next
4110
+ }
4111
+ }
4112
+ const nsExports = namespaced[namespace] ?? {};
4113
+ for (const key of Object.keys(nsExports)) {
4114
+ exports[`${namespace}_${key}`] = jsDocMap[key] ?? '';
4115
+ }
4116
+ return { exports };
4117
+ }
4118
+ /**
4119
+ * Introspect all helper modules from config, returning exports for both mapHelpers and templateHelpers.
4120
+ *
4121
+ * @param config - Object with optional mapHelpers and templateHelpers config.
4122
+ * @param configDir - Directory to resolve relative paths against.
4123
+ * @returns Full introspection results for injection into the merged document.
4124
+ */
4125
+ async function introspectAllHelpers(config, configDir) {
4126
+ const result = {
4127
+ mapHelpers: {},
4128
+ templateHelpers: {},
4129
+ };
4130
+ if (config.mapHelpers) {
4131
+ for (const [namespace, { path: p }] of Object.entries(config.mapHelpers)) {
4132
+ const resolved = resolve(configDir, p);
4133
+ result.mapHelpers[namespace] = await introspectHelperModule(resolved, namespace);
4134
+ }
4135
+ }
4136
+ if (config.templateHelpers) {
4137
+ for (const [namespace, { path: p }] of Object.entries(config.templateHelpers)) {
4138
+ const resolved = resolve(configDir, p);
4139
+ result.templateHelpers[namespace] = await introspectHelperModule(resolved, namespace);
4140
+ }
4141
+ }
4142
+ return result;
4143
+ }
4144
+
4145
+ /**
4146
+ * @module vcs/CommitMessageGenerator
4147
+ * Generates AI-powered commit messages via the Anthropic Messages API.
4148
+ */
4149
+ /** Maximum characters of diff to send to the AI. */
4150
+ const MAX_DIFF_CHARS = 4000;
4151
+ /** Maximum length of the generated commit message. */
4152
+ const MAX_MESSAGE_LENGTH = 100;
4153
+ /** Request timeout in milliseconds. */
4154
+ const REQUEST_TIMEOUT_MS = 15000;
4155
+ /** System prompt for the commit message generator. */
4156
+ const SYSTEM_PROMPT = 'You are a commit message generator. Given file changes, produce a single concise one-line commit message. No prefix. No punctuation at end';
4157
+ /**
4158
+ * Generates AI commit messages using the Anthropic Messages API.
4159
+ */
4160
+ class CommitMessageGenerator {
4161
+ provider;
4162
+ model;
4163
+ apiKey;
4164
+ logger;
4165
+ constructor(provider, model, apiKey, logger) {
4166
+ this.provider = provider;
4167
+ this.model = model;
4168
+ this.apiKey = apiKey;
4169
+ this.logger = logger;
4170
+ }
4171
+ /**
4172
+ * Generate a commit message from file changes.
4173
+ *
4174
+ * @param files - List of changed file paths.
4175
+ * @param diffSummary - Combined diff output (stat + patch).
4176
+ * @returns A one-line commit message, or null on failure.
4177
+ */
4178
+ async generate(files, diffSummary) {
4179
+ if (!this.apiKey) {
4180
+ this.logger.warn('No API key configured for commit message generation');
4181
+ return null;
4182
+ }
4183
+ if (this.provider !== 'anthropic') {
4184
+ this.logger.warn({ provider: this.provider }, 'Unsupported commit message provider; only "anthropic" is supported');
4185
+ return null;
4186
+ }
4187
+ const truncatedDiff = diffSummary.slice(0, MAX_DIFF_CHARS);
4188
+ const userPrompt = `Files changed:\n${files.join('\n')}\n\nDiff:\n${truncatedDiff}`;
4189
+ try {
4190
+ const body = JSON.stringify({
4191
+ model: this.model,
4192
+ max_tokens: 256,
4193
+ system: SYSTEM_PROMPT,
4194
+ messages: [{ role: 'user', content: userPrompt }],
4195
+ });
4196
+ const response = await fetch('https://api.anthropic.com/v1/messages', {
4197
+ method: 'POST',
4198
+ headers: {
4199
+ 'Content-Type': 'application/json',
4200
+ 'x-api-key': this.apiKey,
4201
+ 'anthropic-version': '2023-06-01',
4202
+ },
4203
+ body,
4204
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
4205
+ });
4206
+ if (!response.ok) {
4207
+ const text = await response.text();
4208
+ this.logger.warn({ status: response.status, body: text }, 'Anthropic API returned non-OK status');
4209
+ return null;
4210
+ }
4211
+ const data = (await response.json());
4212
+ const textBlock = data.content?.find((b) => b.type === 'text');
4213
+ if (!textBlock?.text) {
4214
+ this.logger.warn('Anthropic API returned no text content');
4215
+ return null;
4216
+ }
4217
+ const message = textBlock.text.trim().slice(0, MAX_MESSAGE_LENGTH);
4218
+ this.logger.debug({ message }, 'AI commit message generated');
4219
+ return message;
4220
+ }
4221
+ catch (error) {
4222
+ this.logger.warn({ err: normalizeError(error) }, 'Failed to generate AI commit message');
4223
+ return null;
4224
+ }
4225
+ }
4226
+ }
4227
+
4228
+ /**
4229
+ * @module vcs/resolveCommitMessageApiKey
4230
+ * Resolves the API key for AI commit message generation.
4231
+ */
4232
+ /**
4233
+ * Resolve the API key for commit message generation.
4234
+ * Checks config first, then falls back to OpenClaw gateway credentials.
4235
+ */
4236
+ function resolveCommitMessageApiKey(provider, configApiKey, logger) {
4237
+ if (configApiKey) {
4238
+ logger.debug('Using commit message API key from config');
4239
+ return configApiKey;
4240
+ }
4241
+ try {
4242
+ const openclawPath = join(homedir(), '.openclaw', 'openclaw.json');
4243
+ const raw = readFileSync(openclawPath, 'utf8');
4244
+ const parsed = JSON.parse(raw);
4245
+ const gatewayKey = parsed.models?.providers?.[provider]?.apiKey;
4246
+ if (gatewayKey) {
4247
+ logger.debug({ provider }, 'Using commit message API key from OpenClaw gateway config');
4248
+ return gatewayKey;
4249
+ }
4250
+ logger.warn({ provider }, 'OpenClaw gateway config found but no API key for provider');
4251
+ }
4252
+ catch {
4253
+ logger.warn('No commit message API key in config or OpenClaw gateway');
4254
+ }
4255
+ return undefined;
4256
+ }
4257
+
4258
+ /**
4259
+ * @module vcs/SquashManager
4260
+ * Handles scheduled squash retention for git-backed VCS roots.
4261
+ */
4262
+ /**
4263
+ * Parse a standard 5-field cron expression and check if the current time matches.
4264
+ * Fields: minute hour dom month dow
4265
+ * Supports: numbers, wildcards, lists (1,3,5), ranges (1-5), steps (star/5)
4266
+ */
4267
+ function cronMatchesNow(cronExpr, now = new Date()) {
4268
+ const fields = cronExpr.trim().split(/\s+/);
4269
+ if (fields.length !== 5)
4270
+ return false;
4271
+ const minute = now.getMinutes();
4272
+ const hour = now.getHours();
4273
+ const dom = now.getDate();
4274
+ const month = now.getMonth() + 1; // 1-based
4275
+ const dow = now.getDay(); // 0=Sunday
4276
+ return (fieldMatches(fields[0], minute, 0, 59) &&
4277
+ fieldMatches(fields[1], hour, 0, 23) &&
4278
+ fieldMatches(fields[2], dom, 1, 31) &&
4279
+ fieldMatches(fields[3], month, 1, 12) &&
4280
+ fieldMatches(fields[4], dow, 0, 7) // 7 also means Sunday
4281
+ );
4282
+ }
4283
+ function fieldMatches(field, value, min, max) {
4284
+ for (const part of field.split(',')) {
4285
+ if (partMatches(part.trim(), value, min, max))
4286
+ return true;
4287
+ }
4288
+ return false;
4289
+ }
4290
+ function partMatches(part, value, min, max) {
4291
+ // Handle step: */N or range/N
4292
+ const [rangePart, stepStr] = part.split('/');
4293
+ const step = stepStr ? parseInt(stepStr, 10) : 1;
4294
+ if (rangePart === '*') {
4295
+ return (value - min) % step === 0;
4296
+ }
4297
+ // Handle range: N-M
4298
+ if (rangePart.includes('-')) {
4299
+ const [startStr, endStr] = rangePart.split('-');
4300
+ const start = parseInt(startStr, 10);
4301
+ const end = parseInt(endStr, 10);
4302
+ if (value < start || value > end)
4303
+ return false;
4304
+ return (value - start) % step === 0;
4305
+ }
4306
+ // Plain number
4307
+ const num = parseInt(rangePart, 10);
4308
+ if (step === 1) {
4309
+ // For dow, treat 7 as 0 (Sunday)
4310
+ if (max === 7 && num === 7)
4311
+ return value === 0;
4312
+ return value === num;
4313
+ }
4314
+ // Number with step doesn't make sense for single values, treat as exact
4315
+ return value === num;
4316
+ }
4317
+ /**
4318
+ * Manages scheduled squash retention for a single VCS root.
4319
+ *
4320
+ * Runs on a cron schedule (checked every 60s). When triggered, squashes
4321
+ * commits older than the retention boundary (the tighter of maxAgeDays
4322
+ * and maxVersions) into a single "historical baseline" orphan commit,
4323
+ * then cherry-picks retained commits on top. Force-pushes to remote
4324
+ * if configured (D13).
4325
+ */
4326
+ class SquashManager {
4327
+ rootPath;
4328
+ retention;
4329
+ remoteUrl;
4330
+ accessToken;
4331
+ logger;
4332
+ intervalTimer;
4333
+ running = false;
4334
+ constructor(rootPath, retention, logger, remoteUrl, accessToken) {
4335
+ this.rootPath = rootPath;
4336
+ this.retention = retention;
4337
+ this.logger = logger;
4338
+ this.remoteUrl = remoteUrl;
4339
+ this.accessToken = accessToken;
4340
+ }
4341
+ /**
4342
+ * Start the cron-based squash scheduler.
4343
+ * Checks every 60 seconds if the cron expression matches.
4344
+ */
4345
+ start() {
4346
+ if (this.running)
4347
+ return;
4348
+ this.running = true;
4349
+ this.intervalTimer = setInterval(() => {
4350
+ if (cronMatchesNow(this.retention.squashCron)) {
4351
+ void this.runSquash();
4352
+ }
4353
+ }, 60_000);
4354
+ this.logger.info({ root: this.rootPath, cron: this.retention.squashCron }, 'SquashManager started');
4355
+ }
4356
+ /**
4357
+ * Stop the cron scheduler.
4358
+ */
4359
+ stop() {
4360
+ this.running = false;
4361
+ if (this.intervalTimer !== undefined) {
4362
+ clearInterval(this.intervalTimer);
4363
+ this.intervalTimer = undefined;
4364
+ }
4365
+ this.logger.info({ root: this.rootPath }, 'SquashManager stopped');
4366
+ }
4367
+ /**
4368
+ * Run the squash operation. Exposed publicly for testing.
4369
+ */
4370
+ async runSquash() {
4371
+ // Check for index.lock before starting multi-step operation
4372
+ const lockPath = join(this.rootPath, '.git', 'index.lock');
4373
+ try {
4374
+ await access(lockPath);
4375
+ // Lock exists — abort and retry next cycle
4376
+ this.logger.warn({ root: this.rootPath }, 'Squash aborted: index.lock exists, will retry next cycle');
4377
+ return { squashed: false, error: 'index.lock exists' };
4378
+ }
4379
+ catch {
4380
+ // No lock — proceed
4381
+ }
4382
+ try {
4383
+ const commits = await this.getCommitLog();
4384
+ if (commits.length <= 1) {
4385
+ return { squashed: false };
4386
+ }
4387
+ const boundaryIndex = this.calculateRetentionBoundary(commits);
4388
+ if (boundaryIndex <= 0) {
4389
+ // All commits are within retention or only one commit before boundary
4390
+ return { squashed: false };
4391
+ }
4392
+ // Perform the squash
4393
+ await this.performSquash(commits, boundaryIndex);
4394
+ const commitsRemoved = boundaryIndex;
4395
+ const commitsRetained = commits.length - boundaryIndex;
4396
+ this.logger.info({ root: this.rootPath, commitsRemoved, commitsRetained }, 'Squash retention completed');
4397
+ // Force push if remote configured
4398
+ await this.forcePushIfConfigured();
4399
+ return { squashed: true, commitsRemoved, commitsRetained };
4400
+ }
4401
+ catch (error) {
4402
+ const message = normalizeError(error).message;
4403
+ this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'Squash operation failed');
4404
+ return { squashed: false, error: message };
4405
+ }
4406
+ }
4407
+ /**
4408
+ * Get all commits in chronological order (oldest first).
4409
+ */
4410
+ async getCommitLog() {
4411
+ try {
4412
+ const { stdout } = await execFileAsync('git', ['log', '--format=%H %aI', '--reverse'], { cwd: this.rootPath });
4413
+ if (!stdout.trim())
4414
+ return [];
4415
+ return stdout
4416
+ .trim()
4417
+ .split('\n')
4418
+ .map((line) => {
4419
+ const spaceIdx = line.indexOf(' ');
4420
+ return {
4421
+ hash: line.slice(0, spaceIdx),
4422
+ date: new Date(line.slice(spaceIdx + 1)),
4423
+ };
4424
+ });
4425
+ }
4426
+ catch {
4427
+ return [];
4428
+ }
4429
+ }
4430
+ /**
4431
+ * Calculate the retention boundary index.
4432
+ * Returns the index of the oldest commit to KEEP.
4433
+ * All commits before this index will be squashed into a baseline.
4434
+ *
4435
+ * Strategy: find the tighter constraint between age and count.
4436
+ * - Age: keep commits newer than maxAgeDays
4437
+ * - Count: keep the last maxVersions commits
4438
+ * The boundary is the one that retains FEWER old commits (tighter).
4439
+ */
4440
+ calculateRetentionBoundary(commits) {
4441
+ const now = new Date();
4442
+ const maxAgeMs = this.retention.maxAgeDays * 24 * 60 * 60 * 1000;
4443
+ const cutoffDate = new Date(now.getTime() - maxAgeMs);
4444
+ // Find the first commit that is within the age window (to keep)
4445
+ let ageBoundary = commits.length; // Default: all too old
4446
+ for (let i = 0; i < commits.length; i++) {
4447
+ if (commits[i].date >= cutoffDate) {
4448
+ ageBoundary = i;
4449
+ break;
4450
+ }
4451
+ }
4452
+ // Count boundary: keep the last maxVersions commits
4453
+ const countBoundary = Math.max(0, commits.length - this.retention.maxVersions);
4454
+ // The tighter constraint wins (keeps fewer old commits = higher boundary index)
4455
+ return Math.max(ageBoundary, countBoundary);
4456
+ }
4457
+ /**
4458
+ * Perform the squash: create orphan with baseline + cherry-pick retained commits.
4459
+ */
4460
+ async performSquash(commits, boundaryIndex) {
4461
+ // The commit just before boundary is the last one to squash
4462
+ const baselineHash = commits[boundaryIndex - 1].hash;
4463
+ // Get the current branch name
4464
+ const { stdout: branchOut } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: this.rootPath });
4465
+ const currentBranch = branchOut.trim();
4466
+ const orphanBranch = `__squash_orphan_${String(Date.now())}`;
4467
+ try {
4468
+ // 1. Create orphan branch with the tree state at the baseline commit
4469
+ await execFileAsync('git', ['checkout', '--orphan', orphanBranch], {
4470
+ cwd: this.rootPath,
4471
+ });
4472
+ // 2. Reset index to the baseline tree (the last squashed commit's tree)
4473
+ await execFileAsync('git', ['reset', '--hard', baselineHash], {
4474
+ cwd: this.rootPath,
4475
+ });
4476
+ // 3. Create the baseline commit with the same tree
4477
+ // Use commit-tree for a clean orphan commit
4478
+ const { stdout: treeOut } = await execFileAsync('git', ['rev-parse', `${baselineHash}^{tree}`], { cwd: this.rootPath });
4479
+ const treeHash = treeOut.trim();
4480
+ const { stdout: commitOut } = await execFileAsync('git', ['commit-tree', treeHash, '-m', 'historical baseline'], { cwd: this.rootPath });
4481
+ const baselineCommitHash = commitOut.trim();
4482
+ // 4. Reset orphan branch to this new baseline commit
4483
+ await execFileAsync('git', ['reset', '--hard', baselineCommitHash], {
4484
+ cwd: this.rootPath,
4485
+ });
4486
+ // 5. Cherry-pick boundary..HEAD (the retained commits)
4487
+ // Get the list of commits to cherry-pick (from boundary to the end)
4488
+ const commitsToCherry = commits.slice(boundaryIndex).map((c) => c.hash);
4489
+ if (commitsToCherry.length > 0) {
4490
+ await execFileAsync('git', ['cherry-pick', ...commitsToCherry], {
4491
+ cwd: this.rootPath,
4492
+ });
4493
+ }
4494
+ // 6. Force-update the original branch to the orphan
4495
+ const { stdout: newHead } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: this.rootPath });
4496
+ await execFileAsync('git', ['branch', '-f', currentBranch, newHead.trim()], { cwd: this.rootPath });
4497
+ // 7. Switch back to original branch
4498
+ await execFileAsync('git', ['checkout', currentBranch], {
4499
+ cwd: this.rootPath,
4500
+ });
4501
+ // 8. Delete orphan branch
4502
+ await execFileAsync('git', ['branch', '-D', orphanBranch], {
4503
+ cwd: this.rootPath,
4504
+ });
4505
+ }
4506
+ catch (error) {
4507
+ // Attempt cleanup: try to get back to original branch
4508
+ try {
4509
+ await execFileAsync('git', ['checkout', '-f', currentBranch], {
4510
+ cwd: this.rootPath,
4511
+ });
4512
+ await execFileAsync('git', ['branch', '-D', orphanBranch], {
4513
+ cwd: this.rootPath,
4514
+ });
4515
+ }
4516
+ catch {
4517
+ // Cleanup failed — log but re-throw original error
4518
+ }
4519
+ // Remove index.lock if left behind
4520
+ try {
4521
+ const lockFile = join(this.rootPath, '.git', 'index.lock');
4522
+ await rm(lockFile, { force: true });
4523
+ }
4524
+ catch {
4525
+ // ignore
4526
+ }
4527
+ throw error;
4528
+ }
4529
+ }
4530
+ /**
4531
+ * Force push to remote after squash (history rewrite requires force).
4532
+ */
4533
+ async forcePushIfConfigured() {
4534
+ if (!this.remoteUrl)
4535
+ return;
4536
+ try {
4537
+ const pushUrl = this.accessToken
4538
+ ? this.remoteUrl.replace(/^https:\/\//, `https://${encodeURIComponent(this.accessToken)}@`)
4539
+ : this.remoteUrl;
4540
+ await execFileAsync('git', ['push', '--force', pushUrl, 'HEAD'], {
4541
+ cwd: this.rootPath,
4542
+ });
4543
+ this.logger.info({ root: this.rootPath, remote: this.remoteUrl }, 'Squash force push succeeded');
4544
+ }
4545
+ catch (error) {
4546
+ this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'Squash force push failed');
4547
+ }
4548
+ }
4549
+ }
4550
+
4551
+ /**
4552
+ * @module vcs/validateStateDirOverlap
4553
+ * Validates that stateDir does not overlap with any watch path.
4554
+ */
4555
+ /**
4556
+ * Validate that stateDir does not overlap with any watch path.
4557
+ * Overlap means stateDir is a parent, child, or equal to a watch path.
4558
+ *
4559
+ * @param stateDir - The state directory path.
4560
+ * @param watchPaths - Array of watch path strings.
4561
+ * @throws Error if stateDir overlaps with any watch path.
4562
+ */
4563
+ function validateStateDirOverlap(stateDir, watchPaths) {
4564
+ const resolvedStateDir = resolve(stateDir);
4565
+ for (const watchPath of watchPaths) {
4566
+ const resolvedWatch = resolve(watchPath);
4567
+ if (resolvedStateDir === resolvedWatch) {
4568
+ throw new Error(`stateDir "${stateDir}" is the same as watch path "${watchPath}". They must not overlap.`);
4569
+ }
4570
+ const stateDirWithSep = resolvedStateDir.endsWith(sep)
4571
+ ? resolvedStateDir
4572
+ : resolvedStateDir + sep;
4573
+ const watchWithSep = resolvedWatch.endsWith(sep)
4574
+ ? resolvedWatch
4575
+ : resolvedWatch + sep;
4576
+ if (resolvedWatch.startsWith(stateDirWithSep)) {
4577
+ throw new Error(`Watch path "${watchPath}" is inside stateDir "${stateDir}". They must not overlap.`);
3467
4578
  }
3468
- // Store payload in cache.
3469
- // Prefer captured reply payload (wrapHandler path). Fall back to handler return value.
3470
- const payloadToCache = capturedPayload !== undefined ? capturedPayload : result;
3471
- cache.set(key, {
3472
- value: payloadToCache,
3473
- expiresAt: now + ttlMs,
3474
- });
3475
- return result;
3476
- };
4579
+ if (resolvedStateDir.startsWith(watchWithSep)) {
4580
+ throw new Error(`stateDir "${stateDir}" is inside watch path "${watchPath}". They must not overlap.`);
4581
+ }
4582
+ }
3477
4583
  }
3478
4584
 
3479
4585
  /**
3480
- * @module helpers/introspect
3481
- * JSDoc introspection for helper modules. Extracts function exports and their descriptions.
4586
+ * @module vcs/vcsBootstrap
4587
+ * Static bootstrap utilities for VCS-managed watch roots.
3482
4588
  */
4589
+ /** Always-on .gitignore entries for VCS-managed watch roots. */
4590
+ const ALWAYS_GITIGNORE_ENTRIES = [
4591
+ '.git/',
4592
+ 'node_modules/',
4593
+ '.jeeves-watcher/',
4594
+ '.jeeves-metadata/',
4595
+ ];
3483
4596
  /**
3484
- * Extract JSDoc descriptions from source code for exported functions.
4597
+ * Check whether git is available on the system PATH.
3485
4598
  *
3486
- * Looks for JSDoc comments immediately before `export function name` or `export const name`.
4599
+ * @returns true if git is available, false otherwise.
4600
+ */
4601
+ async function checkGitAvailable() {
4602
+ try {
4603
+ await execFileAsync('git', ['--version']);
4604
+ return true;
4605
+ }
4606
+ catch {
4607
+ return false;
4608
+ }
4609
+ }
4610
+ /**
4611
+ * Initialize a git repository at the given path if none exists.
4612
+ * Idempotent — does nothing if .git/ already exists.
3487
4613
  *
3488
- * @param source - The source file content.
3489
- * @returns Map of export name to JSDoc description.
4614
+ * @param rootPath - Directory to initialize as a git repo.
3490
4615
  */
3491
- function extractJsDocDescriptions(source) {
3492
- const result = {};
3493
- // Match JSDoc comment followed by export function/const
3494
- const pattern = /\/\*\*([\s\S]*?)\*\/\s*export\s+(?:function|const)\s+(\w+)/g;
3495
- let match;
3496
- while ((match = pattern.exec(source)) !== null) {
3497
- const [, commentBody, name] = match;
3498
- if (!name || !commentBody)
3499
- continue;
3500
- // Extract the description: lines before any @tag
3501
- const lines = commentBody
3502
- .split('\n')
3503
- .map((line) => line.replace(/^\s*\*\s?/, '').trim())
3504
- .filter((line) => line.length > 0 && !line.startsWith('@'));
3505
- result[name] = lines.join(' ');
4616
+ async function initRepo(rootPath) {
4617
+ const gitDir = join(rootPath, '.git');
4618
+ try {
4619
+ await access(gitDir);
4620
+ // .git exists, nothing to do
4621
+ }
4622
+ catch {
4623
+ await execFileAsync('git', ['init'], { cwd: rootPath });
3506
4624
  }
3507
- return result;
3508
4625
  }
3509
4626
  /**
3510
- * Introspect a single helper module, enumerating function exports and extracting JSDoc descriptions.
4627
+ * Ensure a .gitignore file exists at rootPath with all always-on entries.
4628
+ * Creates the file if missing. Appends only entries not already present.
3511
4629
  *
3512
- * @param filePath - Resolved path to the helper module.
3513
- * @param namespace - The namespace prefix for this module.
3514
- * @returns Introspection result with namespaced export names and descriptions.
4630
+ * @param rootPath - Directory containing the .gitignore.
4631
+ * @param alwaysOnEntries - Additional always-on entries beyond the defaults.
3515
4632
  */
3516
- async function introspectHelperModule(filePath, namespace) {
3517
- const exports = {};
3518
- // Load module exports using shared utility (use parent dir as configDir, path as relative)
3519
- const namespaced = await loadNamespacedExports({ [namespace]: { path: filePath } }, '', (val) => typeof val === 'function');
3520
- // Try to read source for JSDoc extraction
3521
- let jsDocMap = {};
3522
- for (const ext of ['.ts', '.js', '']) {
3523
- const sourcePath = filePath.replace(/\.[jt]s$/, '') + ext;
3524
- try {
3525
- const source = readFileSync(ext === '' ? filePath : sourcePath, 'utf-8');
3526
- jsDocMap = extractJsDocDescriptions(source);
3527
- if (Object.keys(jsDocMap).length > 0)
4633
+ async function ensureGitignore(rootPath, alwaysOnEntries = []) {
4634
+ const gitignorePath = join(rootPath, '.gitignore');
4635
+ const requiredEntries = [...ALWAYS_GITIGNORE_ENTRIES, ...alwaysOnEntries];
4636
+ let existingContent = '';
4637
+ try {
4638
+ existingContent = await readFile(gitignorePath, 'utf8');
4639
+ }
4640
+ catch {
4641
+ // File doesn't exist yet
4642
+ }
4643
+ const existingLines = new Set(existingContent.split('\n').map((line) => line.trim()));
4644
+ const missing = requiredEntries.filter((entry) => !existingLines.has(entry));
4645
+ if (missing.length === 0)
4646
+ return;
4647
+ if (existingContent.length === 0) {
4648
+ await writeFile(gitignorePath, missing.join('\n') + '\n', 'utf8');
4649
+ }
4650
+ else {
4651
+ const prefix = existingContent.endsWith('\n') ? '' : '\n';
4652
+ await appendFile(gitignorePath, prefix + missing.join('\n') + '\n', 'utf8');
4653
+ }
4654
+ }
4655
+
4656
+ /**
4657
+ * @module vcs/VcsManager
4658
+ * Per-root VCS manager for git-backed content versioning.
4659
+ */
4660
+ /**
4661
+ * Per-root VCS manager for git-backed content versioning.
4662
+ *
4663
+ * Each VCS-enabled watch root gets its own VcsManager instance, which owns
4664
+ * a debounced commit pipeline: file changes are batched, staged, committed
4665
+ * (with optional AI-generated messages), and optionally pushed to a remote.
4666
+ *
4667
+ * Concurrency: only one commit is in-flight at a time per root. Index.lock
4668
+ * contention is handled with exponential backoff retries (D10).
4669
+ */
4670
+ class VcsManager {
4671
+ config;
4672
+ rootPath;
4673
+ remoteUrl;
4674
+ accessToken;
4675
+ logger;
4676
+ commitMessageGenerator;
4677
+ pending = new Set();
4678
+ pendingReversions = [];
4679
+ _pushErrors = [];
4680
+ squashManager;
4681
+ debounceTimer;
4682
+ commitInFlight = Promise.resolve();
4683
+ started = false;
4684
+ _lastPushTime = null;
4685
+ constructor(rootPath, config, logger, commitMessageGenerator, remoteUrl, accessToken) {
4686
+ this.rootPath = rootPath;
4687
+ this.config = config;
4688
+ this.logger = logger;
4689
+ this.commitMessageGenerator = commitMessageGenerator;
4690
+ this.remoteUrl = remoteUrl;
4691
+ this.accessToken = accessToken;
4692
+ if (config.retention) {
4693
+ this.squashManager = new SquashManager(rootPath, config.retention, logger, remoteUrl, accessToken);
4694
+ }
4695
+ }
4696
+ get lastPushTime() {
4697
+ return this._lastPushTime;
4698
+ }
4699
+ get pushErrors() {
4700
+ return this._pushErrors;
4701
+ }
4702
+ /**
4703
+ * Begin accepting file changes. Sets up the debounce mechanism.
4704
+ */
4705
+ start() {
4706
+ this.started = true;
4707
+ this.squashManager?.start();
4708
+ this.logger.info({ root: this.rootPath }, 'VcsManager started');
4709
+ }
4710
+ /**
4711
+ * Add a file to the pending set and reset the debounce timer.
4712
+ * If the pending set exceeds maxBatchSize, flush immediately.
4713
+ *
4714
+ * @param filePath - Absolute path of the changed file.
4715
+ */
4716
+ fileChanged(filePath) {
4717
+ if (!this.started)
4718
+ return;
4719
+ this.pending.add(filePath);
4720
+ if (this.pending.size >= this.config.maxBatchSize) {
4721
+ this.clearDebounce();
4722
+ const batch = this.takeBatch(this.config.maxBatchSize);
4723
+ this.commitInFlight = this.commitInFlight.then(() => this.commitBatch(batch));
4724
+ if (this.pending.size > 0) {
4725
+ this.resetDebounce();
4726
+ }
4727
+ return;
4728
+ }
4729
+ this.resetDebounce();
4730
+ }
4731
+ /**
4732
+ * Record reversion metadata so the next commit uses a revert-prefixed message.
4733
+ *
4734
+ * @param reversion - The reversion metadata to record.
4735
+ */
4736
+ addPendingReversion(reversion) {
4737
+ this.pendingReversions.push(reversion);
4738
+ }
4739
+ /**
4740
+ * Stage a file deletion and add to pending set.
4741
+ * git add handles deleted files when the file is gone from disk.
4742
+ *
4743
+ * @param filePath - Absolute path of the deleted file.
4744
+ */
4745
+ handleUnlink(filePath) {
4746
+ if (!this.started)
4747
+ return;
4748
+ this.fileChanged(filePath);
4749
+ }
4750
+ /**
4751
+ * Flush all pending files immediately.
4752
+ */
4753
+ async flush() {
4754
+ this.clearDebounce();
4755
+ if (this.pending.size > 0) {
4756
+ const batch = [...this.pending];
4757
+ this.pending.clear();
4758
+ this.commitInFlight = this.commitInFlight.then(() => this.commitBatch(batch));
4759
+ }
4760
+ await this.commitInFlight;
4761
+ }
4762
+ /**
4763
+ * Stop accepting file changes, flush pending, and clean up timers.
4764
+ */
4765
+ async stop() {
4766
+ this.started = false;
4767
+ this.squashManager?.stop();
4768
+ await this.flush();
4769
+ this.logger.info({ root: this.rootPath }, 'VcsManager stopped');
4770
+ }
4771
+ /**
4772
+ * Take up to N items from the pending set.
4773
+ */
4774
+ takeBatch(n) {
4775
+ const batch = [];
4776
+ for (const item of this.pending) {
4777
+ if (batch.length >= n)
3528
4778
  break;
4779
+ batch.push(item);
4780
+ }
4781
+ for (const item of batch) {
4782
+ this.pending.delete(item);
4783
+ }
4784
+ return batch;
4785
+ }
4786
+ /**
4787
+ * Clear the debounce timer.
4788
+ */
4789
+ clearDebounce() {
4790
+ if (this.debounceTimer !== undefined) {
4791
+ clearTimeout(this.debounceTimer);
4792
+ this.debounceTimer = undefined;
4793
+ }
4794
+ }
4795
+ /**
4796
+ * Reset the debounce timer to fire flush() after commitDebounceMs.
4797
+ */
4798
+ resetDebounce() {
4799
+ this.clearDebounce();
4800
+ this.debounceTimer = setTimeout(() => {
4801
+ this.debounceTimer = undefined;
4802
+ void this.flush();
4803
+ }, this.config.commitDebounceMs);
4804
+ }
4805
+ /**
4806
+ * Get the cached diff for staged files, for AI context.
4807
+ */
4808
+ async getStagedDiff() {
4809
+ try {
4810
+ const { stdout: stat } = await execFileAsync('git', ['diff', '--cached', '--stat'], { cwd: this.rootPath });
4811
+ const { stdout: patch } = await execFileAsync('git', ['diff', '--cached'], { cwd: this.rootPath });
4812
+ return `${stat}\n${patch}`;
3529
4813
  }
3530
4814
  catch {
3531
- // File not found, try next
4815
+ return '';
3532
4816
  }
3533
4817
  }
3534
- const nsExports = namespaced[namespace] ?? {};
3535
- for (const key of Object.keys(nsExports)) {
3536
- exports[`${namespace}_${key}`] = jsDocMap[key] ?? '';
4818
+ /**
4819
+ * Build the template commit message (no AI).
4820
+ */
4821
+ buildTemplateMessage(fileCount) {
4822
+ if (this.pendingReversions.length === 0) {
4823
+ const timestamp = new Date().toISOString();
4824
+ return `watcher: batch ${timestamp} (${String(fileCount)} files)`;
4825
+ }
4826
+ // Count total reverted files across all pending reversions
4827
+ const revertedPaths = new Set(this.pendingReversions.flatMap((r) => r.paths));
4828
+ const revertedCount = revertedPaths.size;
4829
+ const otherCount = fileCount - revertedCount;
4830
+ // Use the first reversion's glob and commit for the message
4831
+ const { glob, commit } = this.pendingReversions[0];
4832
+ const shortCommit = commit.slice(0, 7);
4833
+ let message = `revert: ${glob} to ${shortCommit} — restored ${String(revertedCount)} files`;
4834
+ if (otherCount > 0) {
4835
+ message += ` (+ ${String(otherCount)} other changes)`;
4836
+ }
4837
+ return message;
4838
+ }
4839
+ /**
4840
+ * Build the commit message, using AI when available with template fallback.
4841
+ */
4842
+ async buildCommitMessage(files) {
4843
+ const fileCount = files.length;
4844
+ const templateMessage = this.buildTemplateMessage(fileCount);
4845
+ if (!this.commitMessageGenerator) {
4846
+ return templateMessage;
4847
+ }
4848
+ try {
4849
+ const diffSummary = await this.getStagedDiff();
4850
+ const aiMessage = await this.commitMessageGenerator.generate(files, diffSummary);
4851
+ if (!aiMessage) {
4852
+ this.logger.warn('AI commit message generation returned null; using template');
4853
+ return templateMessage;
4854
+ }
4855
+ // For reversions: prefix with revert info + AI description
4856
+ if (this.pendingReversions.length > 0) {
4857
+ const { glob, commit } = this.pendingReversions[0];
4858
+ const shortCommit = commit.slice(0, 7);
4859
+ return `revert: ${glob} to ${shortCommit} — ${aiMessage}`;
4860
+ }
4861
+ return aiMessage;
4862
+ }
4863
+ catch (error) {
4864
+ this.logger.warn({ err: normalizeError(error) }, 'AI commit message generation failed; using template');
4865
+ return templateMessage;
4866
+ }
4867
+ }
4868
+ /**
4869
+ * Commit a batch of files to the git repo with index.lock retry.
4870
+ */
4871
+ async commitBatch(files) {
4872
+ if (files.length === 0)
4873
+ return;
4874
+ try {
4875
+ await retry(async (attempt) => {
4876
+ await execFileAsync('git', ['add', '--', ...files], {
4877
+ cwd: this.rootPath,
4878
+ });
4879
+ // Build message after staging so getStagedDiff can see the changes
4880
+ const message = attempt === 1
4881
+ ? await this.buildCommitMessage(files)
4882
+ : this.buildTemplateMessage(files.length);
4883
+ this.pendingReversions.length = 0;
4884
+ await execFileAsync('git', ['commit', '-m', message], {
4885
+ cwd: this.rootPath,
4886
+ });
4887
+ const { stdout: hashOut } = await execFileAsync('git', ['rev-parse', '--short', 'HEAD'], { cwd: this.rootPath });
4888
+ const hash = hashOut.trim();
4889
+ this.logger.info({ root: this.rootPath, hash, fileCount: files.length }, 'VCS commit created');
4890
+ await this.pushIfConfigured();
4891
+ }, {
4892
+ attempts: 4,
4893
+ baseDelayMs: 500,
4894
+ maxDelayMs: 2000,
4895
+ onRetry: ({ attempt, delayMs }) => {
4896
+ this.logger.warn({ root: this.rootPath, attempt, delay: delayMs }, 'index.lock contention, retrying');
4897
+ },
4898
+ });
4899
+ }
4900
+ catch (error) {
4901
+ for (const f of files) {
4902
+ this.pending.add(f);
4903
+ }
4904
+ this.logger.warn({ root: this.rootPath, fileCount: files.length }, 'Re-queued files after commit failure');
4905
+ this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'VCS commit failed');
4906
+ }
4907
+ }
4908
+ /**
4909
+ * Push to the configured remote if remoteUrl is set.
4910
+ * On failure: logs the error and records it in pushErrors; never throws.
4911
+ */
4912
+ async pushIfConfigured() {
4913
+ if (!this.remoteUrl)
4914
+ return;
4915
+ try {
4916
+ // Build the authenticated URL if a token is available
4917
+ const pushUrl = this.accessToken
4918
+ ? this.remoteUrl.replace(/^https:\/\//, `https://${encodeURIComponent(this.accessToken)}@`)
4919
+ : this.remoteUrl;
4920
+ await execFileAsync('git', ['push', pushUrl, 'HEAD'], {
4921
+ cwd: this.rootPath,
4922
+ });
4923
+ this._lastPushTime = new Date().toISOString();
4924
+ this.logger.info({ root: this.rootPath, remote: this.remoteUrl }, 'VCS push succeeded');
4925
+ }
4926
+ catch (error) {
4927
+ const pushError = {
4928
+ timestamp: new Date().toISOString(),
4929
+ message: normalizeError(error).message,
4930
+ };
4931
+ this._pushErrors.push(pushError);
4932
+ this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'VCS push failed');
4933
+ }
3537
4934
  }
3538
- return { exports };
3539
4935
  }
4936
+
3540
4937
  /**
3541
- * Introspect all helper modules from config, returning exports for both mapHelpers and templateHelpers.
3542
- *
3543
- * @param config - Object with optional mapHelpers and templateHelpers config.
3544
- * @param configDir - Directory to resolve relative paths against.
3545
- * @returns Full introspection results for injection into the merged document.
4938
+ * @module vcs/VcsCoordinator
4939
+ * Orchestrates VCS across all watch roots. Routes file events to the correct
4940
+ * root's VcsManager instance.
3546
4941
  */
3547
- async function introspectAllHelpers(config, configDir) {
3548
- const result = {
3549
- mapHelpers: {},
3550
- templateHelpers: {},
3551
- };
3552
- if (config.mapHelpers) {
3553
- for (const [namespace, { path: p }] of Object.entries(config.mapHelpers)) {
3554
- const resolved = resolve(configDir, p);
3555
- result.mapHelpers[namespace] = await introspectHelperModule(resolved, namespace);
4942
+ /**
4943
+ * Orchestrates VCS across all VCS-enabled watch roots.
4944
+ */
4945
+ class VcsCoordinator {
4946
+ managers = new Map();
4947
+ roots = [];
4948
+ logger;
4949
+ constructor(config, logger) {
4950
+ this.logger = logger;
4951
+ if (!config.vcs?.enabled)
4952
+ return;
4953
+ const normalized = normalizeWatchPaths(config.watch.paths);
4954
+ for (const entry of normalized) {
4955
+ const rootVcs = entry.vcs?.enabled ?? config.vcs.enabled;
4956
+ if (!rootVcs)
4957
+ continue;
4958
+ if (/[*?{[]/.test(entry.path)) {
4959
+ logger.warn({ path: entry.path }, 'Skipping VCS for watch path containing glob characters');
4960
+ continue;
4961
+ }
4962
+ const resolvedRoot = normalizeSlashes(resolve(entry.path));
4963
+ const mergedConfig = {
4964
+ ...config.vcs,
4965
+ ...entry.vcs,
4966
+ enabled: true,
4967
+ };
4968
+ // Create CommitMessageGenerator if AI commit messages are configured
4969
+ const cmConfig = mergedConfig.commitMessage;
4970
+ const rootLogger = logger.child({ vcsRoot: resolvedRoot });
4971
+ const resolvedApiKey = cmConfig?.enabled !== false
4972
+ ? resolveCommitMessageApiKey(cmConfig?.provider ?? 'anthropic', cmConfig?.apiKey, rootLogger)
4973
+ : undefined;
4974
+ const generator = cmConfig?.enabled !== false && resolvedApiKey
4975
+ ? new CommitMessageGenerator(cmConfig?.provider ?? 'anthropic', cmConfig?.model ?? 'claude-haiku-4-0', resolvedApiKey, rootLogger)
4976
+ : undefined;
4977
+ // Resolve remote config: per-root overrides fall back to root-level defaults
4978
+ const remoteUrl = entry.vcs?.remote;
4979
+ const accessToken = entry.vcs?.accessToken ?? config.vcs.defaultAccessToken;
4980
+ const manager = new VcsManager(resolvedRoot, mergedConfig, logger.child({ vcsRoot: resolvedRoot }), generator, remoteUrl, accessToken);
4981
+ this.managers.set(resolvedRoot, manager);
4982
+ this.roots.push(resolvedRoot);
3556
4983
  }
4984
+ // Sort roots longest-first so nested paths match before parents.
4985
+ this.roots.sort((a, b) => b.length - a.length);
3557
4986
  }
3558
- if (config.templateHelpers) {
3559
- for (const [namespace, { path: p }] of Object.entries(config.templateHelpers)) {
3560
- const resolved = resolve(configDir, p);
3561
- result.templateHelpers[namespace] = await introspectHelperModule(resolved, namespace);
4987
+ /**
4988
+ * Start all VcsManager instances.
4989
+ */
4990
+ start() {
4991
+ this.managers.forEach((manager) => {
4992
+ manager.start();
4993
+ });
4994
+ this.logger.info({ rootCount: this.managers.size }, 'VcsCoordinator started');
4995
+ }
4996
+ /**
4997
+ * Route a file event to the correct root's VcsManager.
4998
+ *
4999
+ * @param filePath - Absolute path of the changed file.
5000
+ * @param event - The event type: add, change, or unlink.
5001
+ */
5002
+ onFileChange(filePath, event) {
5003
+ const normalizedPath = normalizeSlashes(resolve(filePath));
5004
+ const manager = this.findManagerForPath(normalizedPath);
5005
+ if (!manager)
5006
+ return;
5007
+ if (event === 'unlink') {
5008
+ manager.handleUnlink(normalizedPath);
5009
+ }
5010
+ else {
5011
+ manager.fileChanged(normalizedPath);
3562
5012
  }
3563
5013
  }
3564
- return result;
5014
+ /**
5015
+ * Flush all managers and clean up.
5016
+ */
5017
+ async stop() {
5018
+ const stopPromises = [];
5019
+ this.managers.forEach((manager) => {
5020
+ stopPromises.push(manager.stop());
5021
+ });
5022
+ await Promise.all(stopPromises);
5023
+ this.logger.info('VcsCoordinator stopped');
5024
+ }
5025
+ /**
5026
+ * Get the sorted root paths (longest-first).
5027
+ */
5028
+ getRoots() {
5029
+ return this.roots;
5030
+ }
5031
+ /**
5032
+ * Get the VcsManager for a specific root path.
5033
+ */
5034
+ getManager(root) {
5035
+ return this.managers.get(root);
5036
+ }
5037
+ /**
5038
+ * Get all managers as [root, manager] pairs.
5039
+ */
5040
+ getAllManagers() {
5041
+ return this.managers;
5042
+ }
5043
+ /**
5044
+ * Find the VcsManager that owns the given normalized file path.
5045
+ * Uses longest-prefix-match against resolved watch roots.
5046
+ *
5047
+ * @param normalizedPath - Normalized absolute path (forward slashes).
5048
+ * @returns The matching VcsManager, or undefined.
5049
+ */
5050
+ findManagerForPath(normalizedPath) {
5051
+ const root = findRootForPath(this.roots, normalizedPath);
5052
+ return root ? this.managers.get(root) : undefined;
5053
+ }
3565
5054
  }
3566
5055
 
3567
5056
  /**
@@ -3631,10 +5120,10 @@ function createProcessorConfig(config, configDir, customMapLib) {
3631
5120
  /**
3632
5121
  * Create file system watcher with gitignore filtering.
3633
5122
  */
3634
- function createWatcher(config, factories, queue, processor, logger, runtimeOptions, initialScanTracker, contentHashCache) {
5123
+ function createWatcher(config, factories, queue, processor, logger, runtimeOptions, initialScanTracker, contentHashCache, onVcsFileChange) {
3635
5124
  const respectGitignore = config.watch.respectGitignore ?? true;
3636
5125
  const gitignoreFilter = respectGitignore
3637
- ? new GitignoreFilter(config.watch.paths)
5126
+ ? new GitignoreFilter(extractWatchPathStrings(config.watch.paths))
3638
5127
  : undefined;
3639
5128
  const watcher = factories.createFileSystemWatcher(config.watch, queue, processor, logger, {
3640
5129
  maxRetries: config.maxRetries ?? runtimeOptions.maxRetries,
@@ -3643,6 +5132,7 @@ function createWatcher(config, factories, queue, processor, logger, runtimeOptio
3643
5132
  gitignoreFilter,
3644
5133
  initialScanTracker,
3645
5134
  contentHashCache,
5135
+ onVcsFileChange,
3646
5136
  });
3647
5137
  return { watcher, gitignoreFilter };
3648
5138
  }
@@ -3714,6 +5204,41 @@ function resolveVersion(referenceUrl) {
3714
5204
  return 'unknown';
3715
5205
  }
3716
5206
  }
5207
+ /**
5208
+ * Initialize VCS for all watch roots where VCS is enabled.
5209
+ * Checks git availability, validates stateDir overlap, and initializes repos.
5210
+ *
5211
+ * @param config - The resolved configuration (mutated in place if git unavailable).
5212
+ * @param logger - Logger instance.
5213
+ * @returns The effective VCS enabled state (false if git unavailable).
5214
+ */
5215
+ async function initVcs(config, logger) {
5216
+ if (!config.vcs?.enabled)
5217
+ return false;
5218
+ const gitAvailable = await checkGitAvailable();
5219
+ if (!gitAvailable) {
5220
+ logger.warn('git not found on PATH — VCS disabled for this session');
5221
+ return false;
5222
+ }
5223
+ const stateDir = config.stateDir ?? '.jeeves-metadata';
5224
+ const pathStrings = extractWatchPathStrings(config.watch.paths);
5225
+ const staticPaths = pathStrings.filter((p) => !/[*?{[]/.test(p));
5226
+ validateStateDirOverlap(stateDir, staticPaths);
5227
+ const normalized = normalizeWatchPaths(config.watch.paths);
5228
+ for (const entry of normalized) {
5229
+ const rootVcs = entry.vcs?.enabled ?? config.vcs.enabled;
5230
+ if (!rootVcs)
5231
+ continue;
5232
+ if (/[*?{[]/.test(entry.path)) {
5233
+ logger.warn({ path: entry.path }, 'Skipping VCS init for watch path containing glob characters');
5234
+ continue;
5235
+ }
5236
+ await initRepo(entry.path);
5237
+ await ensureGitignore(entry.path);
5238
+ logger.info({ root: entry.path }, 'VCS initialized for watch root');
5239
+ }
5240
+ return true;
5241
+ }
3717
5242
 
3718
5243
  /**
3719
5244
  * @module rules/virtualRules
@@ -4047,7 +5572,7 @@ function createApiServer(options) {
4047
5572
  logger,
4048
5573
  }));
4049
5574
  app.post('/walk', createWalkHandler({
4050
- getWatchPaths: () => getConfig().watch.paths,
5575
+ getWatchPaths: () => extractWatchPathStrings(getConfig().watch.paths),
4051
5576
  getFileSystemWatcher,
4052
5577
  logger,
4053
5578
  }));
@@ -4142,6 +5667,17 @@ function createApiServer(options) {
4142
5667
  app.post('/points/delete', createPointsDeleteHandler({ vectorStore, logger }));
4143
5668
  app.post('/rules/reapply', createRulesReapplyHandler({ processor, vectorStore, logger }));
4144
5669
  }
5670
+ // VCS routes — only registered when a VcsCoordinator is provided
5671
+ if (options.vcsCoordinator) {
5672
+ const coordinator = options.vcsCoordinator;
5673
+ app.get('/vcs/status', createVcsStatusHandler({ coordinator, logger }));
5674
+ app.get('/vcs/history', createVcsHistoryHandler({ coordinator, logger }));
5675
+ app.get('/vcs/show', createVcsShowHandler({ coordinator, logger }));
5676
+ app.get('/vcs/diff', createVcsDiffHandler({ coordinator, logger }));
5677
+ app.get('/vcs/check-exclusion', createVcsCheckExclusionHandler({ coordinator, logger }));
5678
+ app.post('/vcs/revert', createVcsRevertHandler({ coordinator, logger }));
5679
+ app.post('/vcs/exclude', createVcsExcludeHandler({ coordinator, logger }));
5680
+ }
4145
5681
  return app;
4146
5682
  }
4147
5683
 
@@ -6826,6 +8362,7 @@ class FileSystemWatcher {
6826
8362
  gitignoreFilter;
6827
8363
  initialScanTracker;
6828
8364
  contentHashCache;
8365
+ onVcsFileChange;
6829
8366
  moveCorrelator;
6830
8367
  globMatches;
6831
8368
  watcher;
@@ -6846,6 +8383,7 @@ class FileSystemWatcher {
6846
8383
  this.gitignoreFilter = options.gitignoreFilter;
6847
8384
  this.initialScanTracker = options.initialScanTracker;
6848
8385
  this.contentHashCache = options.contentHashCache;
8386
+ this.onVcsFileChange = options.onVcsFileChange;
6849
8387
  this.globMatches = () => true;
6850
8388
  const healthOptions = {
6851
8389
  maxRetries: options.maxRetries,
@@ -6863,7 +8401,7 @@ class FileSystemWatcher {
6863
8401
  // Glob patterns are silently treated as literal strings, producing zero
6864
8402
  // events. We extract static directory roots for chokidar to watch, then
6865
8403
  // filter emitted events against the original globs via picomatch.
6866
- const { roots, matches } = resolveWatchPaths(this.config.paths);
8404
+ const { roots, matches } = resolveWatchPaths(extractWatchPathStrings(this.config.paths));
6867
8405
  this.globMatches = matches;
6868
8406
  this.logger.info({ roots }, 'Resolved watch roots from globs');
6869
8407
  // Chokidar v5's inline anymatch does exact string equality for string
@@ -6944,6 +8482,8 @@ class FileSystemWatcher {
6944
8482
  this.initialScanTracker?.incrementEnqueued();
6945
8483
  }
6946
8484
  this.logger.debug({ path }, 'File added');
8485
+ if (initialScanComplete)
8486
+ this.onVcsFileChange?.(path, 'add');
6947
8487
  if (correlator && initialScanComplete) {
6948
8488
  void correlator.handleAdd(path);
6949
8489
  }
@@ -6960,6 +8500,7 @@ class FileSystemWatcher {
6960
8500
  if (this.isGitignored(path))
6961
8501
  return;
6962
8502
  this.logger.debug({ path }, 'File changed');
8503
+ this.onVcsFileChange?.(path, 'change');
6963
8504
  this.queue.enqueue({ type: 'modify', path, priority: 'normal' }, () => this.wrapProcessing(() => this.processor.processFile(path)));
6964
8505
  });
6965
8506
  this.watcher.on('unlink', (path) => {
@@ -6969,6 +8510,7 @@ class FileSystemWatcher {
6969
8510
  if (this.isGitignored(path))
6970
8511
  return;
6971
8512
  this.logger.debug({ path }, 'File removed');
8513
+ this.onVcsFileChange?.(path, 'unlink');
6972
8514
  if (correlator) {
6973
8515
  correlator.handleUnlink(path);
6974
8516
  }
@@ -7232,6 +8774,7 @@ class JeevesWatcher {
7232
8774
  gitignoreFilter;
7233
8775
  enrichmentStore;
7234
8776
  contentHashCache;
8777
+ vcsCoordinator;
7235
8778
  initialScanTracker;
7236
8779
  version;
7237
8780
  /** Create a new JeevesWatcher instance. */
@@ -7254,6 +8797,7 @@ class JeevesWatcher {
7254
8797
  async start() {
7255
8798
  const logger = this.factories.createLogger(this.config.logging);
7256
8799
  this.logger = logger;
8800
+ await initVcs(this.config, logger);
7257
8801
  const { embeddingProvider, vectorStore } = await initEmbeddingAndStore(this.config, this.factories, logger);
7258
8802
  this.embeddingProvider = embeddingProvider;
7259
8803
  this.vectorStore = vectorStore;
@@ -7290,10 +8834,15 @@ class JeevesWatcher {
7290
8834
  concurrency: this.config.embedding.concurrency ?? 5,
7291
8835
  rateLimitPerMinute: this.config.embedding.rateLimitPerMinute,
7292
8836
  });
7293
- const { watcher, gitignoreFilter } = createWatcher(this.config, this.factories, this.queue, processor, logger, this.runtimeOptions, this.initialScanTracker, contentHashCache);
8837
+ const vcsCoordinator = new VcsCoordinator(this.config, logger);
8838
+ this.vcsCoordinator = vcsCoordinator;
8839
+ const { watcher, gitignoreFilter } = createWatcher(this.config, this.factories, this.queue, processor, logger, this.runtimeOptions, this.initialScanTracker, contentHashCache, (filePath, event) => {
8840
+ vcsCoordinator.onFileChange(filePath, event);
8841
+ });
7294
8842
  this.watcher = watcher;
7295
8843
  this.gitignoreFilter = gitignoreFilter;
7296
8844
  this.server = await this.startApiServer();
8845
+ vcsCoordinator.start();
7297
8846
  this.watcher.start();
7298
8847
  this.startConfigWatch();
7299
8848
  logger.info('jeeves-watcher started');
@@ -7303,6 +8852,9 @@ class JeevesWatcher {
7303
8852
  */
7304
8853
  async stop() {
7305
8854
  await this.stopConfigWatch();
8855
+ if (this.vcsCoordinator) {
8856
+ await this.vcsCoordinator.stop();
8857
+ }
7306
8858
  if (this.watcher) {
7307
8859
  await this.watcher.stop();
7308
8860
  }
@@ -7350,6 +8902,7 @@ class JeevesWatcher {
7350
8902
  fileSystemWatcher: this.watcher,
7351
8903
  getFileSystemWatcher: () => this.watcher,
7352
8904
  enrichmentStore: this.enrichmentStore,
8905
+ vcsCoordinator: this.vcsCoordinator,
7353
8906
  });
7354
8907
  await server.listen({
7355
8908
  host: this.config.api?.host ?? getBindAddress('watcher'),
@@ -7699,4 +9252,4 @@ const watcherDescriptor = {
7699
9252
  },
7700
9253
  };
7701
9254
 
7702
- export { ContentHashCache, DocumentProcessor, EnrichmentStore, EventQueue, FileSystemWatcher, GitignoreFilter, InitialScanTracker, IssuesManager, JeevesWatcher, ReindexTracker, SystemHealth, TemplateEngine, ValuesManager, VectorStoreClient, VirtualRuleStore, applyRules, buildAttributes, buildTemplateEngine, compileRules, contentHash, createApiServer, createEmbeddingProvider, createHandlebarsInstance, createLogger, extractText, issueRecordSchema, loadConfig, loadCustomHelpers, mergeEnrichment, pointId, registerBuiltinHelpers, resolveTemplateSource, startFromConfig, watcherDescriptor };
9255
+ export { CommitMessageGenerator, ContentHashCache, DocumentProcessor, EnrichmentStore, EventQueue, FileSystemWatcher, GitignoreFilter, InitialScanTracker, IssuesManager, JeevesWatcher, ReindexTracker, SquashManager, SystemHealth, TemplateEngine, ValuesManager, VcsCoordinator, VcsManager, VectorStoreClient, VirtualRuleStore, applyRules, buildAttributes, buildTemplateEngine, compileRules, contentHash, createApiServer, createEmbeddingProvider, createHandlebarsInstance, createLogger, extractText, issueRecordSchema, loadConfig, loadCustomHelpers, mergeEnrichment, pointId, registerBuiltinHelpers, resolveTemplateSource, startFromConfig, watcherDescriptor };