From 4382de3a790e7f675ea9008c9102f6aa26b4e725 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 19 May 2026 06:31:18 +0300 Subject: [PATCH] =?UTF-8?q?feat(controller):=20C1=20l1-watcher=20=E2=80=94?= =?UTF-8?q?=20settings.json=20=E2=86=94=20Tooling=20drift=20detector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure regex/JSON, 0 LLM calls. 4 Vitest tests GREEN. Per ADR-011 + spec §6.1. Smoke run surfaces REAL drift (DONE_WITH_CONCERNS — plan B5 said «that's a real signal, document, don't fix here»): 9 plugins in ~/.claude/settings.json enabledPlugins NOT formalized by exact «name@source» string in Tooling Прил. Н: - frontend-design@claude-plugins-official (informally as #30 «Frontend Design plugin») - 8× ToB plugins @trailofbits (differential-review, audit-context- building, supply-chain-risk-auditor, insecure-defaults, sharp- edges, static-analysis, variant-analysis, agentic-actions-auditor) informally as #39 «Trail of Bits Skills» This is naming-vocabulary mismatch (Tooling uses human-readable names; settings.json uses machine names). Not architectural drift. Resolution options for follow-up: - Add machine names as «external_id» attribute to Tooling Прил. Н rows. - Add tools/.l1-watcher-aliases.txt with accepted machine→human map. Until resolved: C1 will FAIL on lefthook (C5 wiring) — addressed in C5 by adding alias mechanism OR temporarily downgrade to WARN. Also fixed CLI guard bug in observer-stop-hook.mjs (B3) and l1-watcher — old guard `import.meta.url === \`file://\${argv[1]}\`` did not match on Windows (file:/// triple-slash vs file:// double-slash + relative argv[1]). New guard: argv[1].endsWith('/.mjs'). Weekly GH Actions cron (Mon 09:00 MSK) opens issue on drift. Vitest config extended to ../tools/*.test.mjs with exclude for ruflo-* and subagent-prompt-prefix tests (pre-existing, not part of brain governance). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/brain-l1-watcher-weekly.yml | 31 ++++++++++ app/vitest.config.tools.mjs | 3 +- tools/l1-watcher.mjs | 57 +++++++++++++++++++ tools/l1-watcher.test.mjs | 34 +++++++++++ tools/observer-stop-hook.mjs | 2 +- 5 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/brain-l1-watcher-weekly.yml create mode 100644 tools/l1-watcher.mjs create mode 100644 tools/l1-watcher.test.mjs 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', () => {