From 95091cc198a28467e9b2ec7545278ae53339b355 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Thu, 12 Feb 2026 17:15:46 -0800 Subject: [PATCH] feat(skills): add debounced watcher foundation for phase 2 --- docs/plans/state.json | 22 +++++-- src/skills/index.ts | 2 + src/skills/watcher.test.ts | 101 ++++++++++++++++++++++++++++++++ src/skills/watcher.ts | 115 +++++++++++++++++++++++++++++++++++++ 4 files changed, 236 insertions(+), 4 deletions(-) create mode 100644 src/skills/watcher.test.ts create mode 100644 src/skills/watcher.ts diff --git a/docs/plans/state.json b/docs/plans/state.json index 024e170..dedd911 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -1269,9 +1269,23 @@ }, "phase_2_skills_watcher": { "priority": "P1", - "status": "not_started", + "status": "in_progress", "description": "Auto-reload skills with chokidar file watcher, configurable debounce", - "effort": "3-4 hours" + "effort": "3-4 hours", + "sub_slices": { + "watcher_bootstrap": { + "status": "completed", + "description": "Added core `SkillsWatcher` class with debounced change batching, lifecycle controls, and foundational tests", + "files_created": [ + "src/skills/watcher.ts", + "src/skills/watcher.test.ts" + ], + "files_modified": [ + "src/skills/index.ts" + ], + "test_status": "typecheck + targeted watcher tests + full test suite + lint (warnings only, 0 errors) + build passing" + } + } }, "phase_3_installer_specs": { "priority": "P1", @@ -1304,7 +1318,7 @@ }, "overall_progress": { - "total_test_count": 1504, + "total_test_count": 1509, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -1324,7 +1338,7 @@ "gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram", "native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback", "remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 2/2 (100%) — component registry, confidence routing. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening", - "next_up": "Skills infrastructure Phase 2: implement skill auto-reload watcher with configurable debounce" + "next_up": "Skills infrastructure Phase 2: wire SkillsWatcher into daemon init/shutdown and config toggle" }, "soul_md_and_cron_create": { "date": "2026-02-11", diff --git a/src/skills/index.ts b/src/skills/index.ts index 4321930..6a78234 100644 --- a/src/skills/index.ts +++ b/src/skills/index.ts @@ -2,3 +2,5 @@ export type { SkillTier, SkillRequirements, SkillManifest, Skill } from './types export { checkRequirements, loadSkill, discoverSkills, loadAllSkills } from './loader.js'; export { SkillRegistry } from './registry.js'; export { SkillInstaller } from './installer.js'; +export { SkillsWatcher } from './watcher.js'; +export type { SkillsWatcherConfig, SkillsWatcherEvent } from './watcher.js'; diff --git a/src/skills/watcher.test.ts b/src/skills/watcher.test.ts new file mode 100644 index 0000000..da62f5d --- /dev/null +++ b/src/skills/watcher.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { SkillsWatcher } from './watcher.js'; + +describe('SkillsWatcher', () => { + const roots: string[] = []; + + afterEach(() => { + vi.useRealTimers(); + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } + }); + + function makeDir(prefix: string): string { + const root = mkdtempSync(join(tmpdir(), prefix)); + roots.push(root); + return root; + } + + it('starts only on existing directories', () => { + const existing = makeDir('flynn-watcher-'); + const missing = join(existing, 'missing'); + + const watcher = new SkillsWatcher({ skillDirs: [existing, missing] }); + watcher.start(); + + expect(watcher.isRunning).toBe(true); + expect(watcher.watchedDirectoryCount).toBe(1); + + watcher.stop(); + }); + + it('debounces multiple change notifications into one callback', () => { + vi.useFakeTimers(); + const dir = makeDir('flynn-watcher-'); + const events: string[][] = []; + + const watcher = new SkillsWatcher({ + skillDirs: [dir], + debounceMs: 100, + onSkillsChanged: (event) => { + events.push(event.changedPaths); + }, + }); + watcher.start(); + + watcher.notifyPathChanged(join(dir, 'one/SKILL.md')); + watcher.notifyPathChanged(join(dir, 'two/manifest.json')); + vi.advanceTimersByTime(99); + expect(events.length).toBe(0); + + vi.advanceTimersByTime(1); + expect(events.length).toBe(1); + expect(events[0]?.length).toBe(2); + + watcher.stop(); + }); + + it('clears pending events on stop', () => { + vi.useFakeTimers(); + const dir = makeDir('flynn-watcher-'); + const onSkillsChanged = vi.fn(); + + const watcher = new SkillsWatcher({ + skillDirs: [dir], + debounceMs: 50, + onSkillsChanged, + }); + watcher.start(); + watcher.notifyPathChanged(join(dir, 'sample/SKILL.md')); + watcher.stop(); + + vi.advanceTimersByTime(60); + expect(onSkillsChanged).not.toHaveBeenCalled(); + expect(watcher.isRunning).toBe(false); + }); + + it('ignores notifyPathChanged calls before start', () => { + const dir = makeDir('flynn-watcher-'); + const onSkillsChanged = vi.fn(); + const watcher = new SkillsWatcher({ skillDirs: [dir], onSkillsChanged }); + + watcher.notifyPathChanged(join(dir, 'ignored')); + expect(onSkillsChanged).not.toHaveBeenCalled(); + }); + + it('handles repeated start calls safely', () => { + const dir = makeDir('flynn-watcher-'); + mkdirSync(join(dir, 'nested'), { recursive: true }); + const watcher = new SkillsWatcher({ skillDirs: [dir] }); + + watcher.start(); + watcher.start(); + + expect(watcher.watchedDirectoryCount).toBe(1); + watcher.stop(); + }); +}); diff --git a/src/skills/watcher.ts b/src/skills/watcher.ts new file mode 100644 index 0000000..b85c205 --- /dev/null +++ b/src/skills/watcher.ts @@ -0,0 +1,115 @@ +import { existsSync, statSync, watch } from 'fs'; +import { join, resolve } from 'path'; +import type { FSWatcher } from 'fs'; + +export interface SkillsWatcherEvent { + changedPaths: string[]; + triggeredAt: Date; +} + +export interface SkillsWatcherConfig { + skillDirs: string[]; + debounceMs?: number; + onSkillsChanged?: (event: SkillsWatcherEvent) => void; +} + +export class SkillsWatcher { + private readonly _skillDirs: string[]; + private readonly _debounceMs: number; + private readonly _onSkillsChanged?: (event: SkillsWatcherEvent) => void; + private readonly _watchers: FSWatcher[] = []; + private readonly _pendingPaths = new Set(); + private _timer?: NodeJS.Timeout; + private _running = false; + + constructor(config: SkillsWatcherConfig) { + this._skillDirs = [...new Set(config.skillDirs.map((dir) => resolve(dir)))]; + this._debounceMs = config.debounceMs ?? 250; + this._onSkillsChanged = config.onSkillsChanged; + } + + get isRunning(): boolean { + return this._running; + } + + get watchedDirectoryCount(): number { + return this._watchers.length; + } + + start(): void { + if (this._running) { + return; + } + + for (const dir of this._skillDirs) { + if (!existsSync(dir)) { + continue; + } + try { + if (!statSync(dir).isDirectory()) { + continue; + } + } catch { + continue; + } + + const watcher = watch(dir, (_eventType, filename) => { + if (!this._running) { + return; + } + const changedPath = filename ? join(dir, String(filename)) : dir; + this.notifyPathChanged(changedPath); + }); + this._watchers.push(watcher); + } + + this._running = true; + } + + stop(): void { + if (!this._running) { + return; + } + + for (const watcher of this._watchers) { + watcher.close(); + } + this._watchers.length = 0; + + if (this._timer) { + clearTimeout(this._timer); + this._timer = undefined; + } + this._pendingPaths.clear(); + this._running = false; + } + + notifyPathChanged(path: string): void { + if (!this._running) { + return; + } + + this._pendingPaths.add(resolve(path)); + if (this._timer) { + clearTimeout(this._timer); + } + + this._timer = setTimeout(() => { + this._flushPending(); + }, this._debounceMs); + } + + private _flushPending(): void { + this._timer = undefined; + if (this._pendingPaths.size === 0) { + return; + } + + const changedPaths = Array.from(this._pendingPaths).sort(); + this._pendingPaths.clear(); + this._onSkillsChanged?.({ + changedPaths, + triggeredAt: new Date(), + }); + } +}