diff --git a/.github/workflows/brain-l1-watcher-weekly.yml b/.github/workflows/brain-l1-watcher-weekly.yml new file mode 100644 index 00000000..d3c934ca --- /dev/null +++ b/.github/workflows/brain-l1-watcher-weekly.yml @@ -0,0 +1,31 @@ +name: brain-l1-watcher (weekly) + +on: + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + +jobs: + drift: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: run l1-watcher + id: l1 + run: node tools/l1-watcher.mjs + continue-on-error: true + - name: open issue on drift + if: steps.l1.outcome == 'failure' + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `[l1-watcher] drift detected (weekly cron ${new Date().toISOString().slice(0,10)})`, + body: `Run failed. Check workflow logs and run /claude-md-management:claude-md-improver.`, + labels: ['brain', 'drift'] + }); diff --git a/app/vitest.config.tools.mjs b/app/vitest.config.tools.mjs index 796ba059..6d9701fd 100644 --- a/app/vitest.config.tools.mjs +++ b/app/vitest.config.tools.mjs @@ -7,6 +7,7 @@ export default defineConfig({ test: { globals: true, environment: 'node', - include: ['../tools/observer-pii-filter.test.mjs'], + include: ['../tools/*.test.mjs'], + exclude: ['../tools/ruflo-*.test.mjs', '../tools/subagent-prompt-prefix.test.mjs'], }, }); diff --git a/tools/l1-watcher.mjs b/tools/l1-watcher.mjs new file mode 100644 index 00000000..67ca8a6b --- /dev/null +++ b/tools/l1-watcher.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; + +const PLUGIN_NAME_PATTERN = /[a-z][\w-]*(?:@[\w-]+)?/g; + +export function detectDrift(settings, toolingText) { + const enabled = Object.entries(settings.enabledPlugins || {}) + .filter(([, v]) => v === true) + .map(([k]) => k); + const found = new Set(); + for (const m of toolingText.matchAll(PLUGIN_NAME_PATTERN)) found.add(m[0]); + const missingInTooling = enabled.filter((p) => !found.has(p)); + const inToolingButNotSettings = []; + const toolingNumbered = toolingText.match(/#\d+\s+([\w-]+(?:@[\w-]+)?)/g) || []; + const toolingPluginNames = toolingNumbered.map((s) => s.split(/\s+/)[1]); + for (const p of toolingPluginNames) { + if (!enabled.includes(p)) inToolingButNotSettings.push(p); + } + return { missingInTooling, missingInSettings: inToolingButNotSettings }; +} + +function loadFileMaybe(path) { + try { + return existsSync(path) ? readFileSync(path, 'utf-8') : null; + } catch { + return null; + } +} + +export function loadInputs(projectRoot = process.cwd()) { + const userSettings = JSON.parse(loadFileMaybe(join(homedir(), '.claude', 'settings.json')) || '{}'); + const projectSettings = JSON.parse(loadFileMaybe(join(projectRoot, '.claude', 'settings.json')) || '{}'); + const merged = { + enabledPlugins: { ...(userSettings.enabledPlugins || {}), ...(projectSettings.enabledPlugins || {}) }, + }; + const tooling = loadFileMaybe(join(projectRoot, 'docs', 'Tooling_v8_3.md')) || ''; + return { settings: merged, tooling }; +} + +if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/l1-watcher.mjs')) { + const { settings, tooling } = loadInputs(); + const drift = detectDrift(settings, tooling); + if (drift.missingInTooling.length > 0) { + console.error(`[l1-watcher] FAIL — plugins in settings but not formalized in Tooling Прил. Н:`); + drift.missingInTooling.forEach((p) => console.error(` - ${p}`)); + console.error(`Run /claude-md-management:claude-md-improver to formalize.`); + process.exit(1); + } + if (drift.missingInSettings.length > 0) { + console.warn(`[l1-watcher] WARN — plugins in Tooling but disabled in settings:`); + drift.missingInSettings.forEach((p) => console.warn(` - ${p}`)); + } + console.log(`[l1-watcher] OK — 0 drift`); + process.exit(0); +} diff --git a/tools/l1-watcher.test.mjs b/tools/l1-watcher.test.mjs new file mode 100644 index 00000000..cb8eb0ea --- /dev/null +++ b/tools/l1-watcher.test.mjs @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { detectDrift } from './l1-watcher.mjs'; + +describe('detectDrift', () => { + it('finds plugins in settings but not in tooling', () => { + const settings = { enabledPlugins: { 'foo@org': true, 'bar@org': true } }; + const tooling = 'Описание #56 foo@org интегрирован.'; + const drift = detectDrift(settings, tooling); + expect(drift.missingInTooling).toEqual(['bar@org']); + expect(drift.missingInSettings).toEqual([]); + }); + + it('finds plugins in tooling but not in settings', () => { + const settings = { enabledPlugins: { 'foo@org': true } }; + const tooling = '#56 foo@org. #57 baz@org включён.'; + const drift = detectDrift(settings, tooling); + expect(drift.missingInSettings).toEqual(['baz@org']); + }); + + it('returns empty arrays when in sync', () => { + const settings = { enabledPlugins: { 'foo@org': true } }; + const tooling = '#56 foo@org описан.'; + const drift = detectDrift(settings, tooling); + expect(drift.missingInTooling).toEqual([]); + expect(drift.missingInSettings).toEqual([]); + }); + + it('handles disabled plugins (value false)', () => { + const settings = { enabledPlugins: { 'foo@org': false, 'bar@org': true } }; + const tooling = '#56 bar@org.'; + const drift = detectDrift(settings, tooling); + expect(drift.missingInTooling).toEqual([]); + }); +}); diff --git a/tools/observer-stop-hook.mjs b/tools/observer-stop-hook.mjs index ebe6f2c6..e4896874 100644 --- a/tools/observer-stop-hook.mjs +++ b/tools/observer-stop-hook.mjs @@ -98,7 +98,7 @@ function currentMonth() { } // CLI entry point: read JSON context from stdin (Claude Code Stop-event hook contract) -if (import.meta.url === `file://${process.argv[1].replace(/\\/g, '/')}`) { +if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/observer-stop-hook.mjs')) { const chunks = []; process.stdin.on('data', (c) => chunks.push(c)); process.stdin.on('end', () => {