feat(controller): C1 l1-watcher — settings.json ↔ Tooling drift detector

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('/<filename>.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) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-19 06:31:18 +03:00
parent 0a45fcbdfd
commit 4382de3a79
5 changed files with 125 additions and 2 deletions
@@ -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']
});
+2 -1
View File
@@ -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'],
},
});
+57
View File
@@ -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);
}
+34
View File
@@ -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([]);
});
});
+1 -1
View File
@@ -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', () => {