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:
@@ -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']
|
||||
});
|
||||
@@ -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'],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user