Files
portal/tools/enforce-chain-recommendation.test.mjs
T
Дмитрий dbaf0e0e76 feat(brain-governance): classifier threshold 0.7→0.8 + chain-recommendation enforcer + registry test bump
Three brain-governance hardening changes from retro #8 follow-up:

1. enforce-classifier-match: confidence threshold raised 0.7→0.8 (was producing false-positives on borderline LLM recommendations like #3 GitHub MCP for local debug, #36 adr-kit for status readouts). 2 new vitest tests cover boundary values 0.7 and 0.75 (now allowed).

2. enforce-chain-recommendation (NEW): PreToolUse hook blocking mutating tool calls when router gave recommended_chain length >= 2 and controller is not expanding it. Allows pass when: any chain node already invoked, inline 'chain-override: <reason>' present, or global override-phrase in user prompt. 20 vitest tests cover empty chain, single-node bypass, override variants, alias resolution, mixed numeric/string ids.

3. registry-load.test.mjs: bump expected counts 85→86 nodes / 77→78 active (collateral fix after parallel session added #86 graphifyy in 06ee5ad3).

Full vitest tools-sweep: 1022/1022 GREEN.

Reviewer APPROVE on spec compliance + code quality (non-blocking observations: test count mis-report in implementer's claim 33→20 actual, hardcoded 'superpowers:' alias prefix, no direct test for extractCalledSkillIds — deferred).

Hook activation in .claude/settings.json deferred — controller will register separately based on owner's choice (block / warn-only / defer).
2026-05-28 05:33:22 +03:00

243 lines
7.6 KiB
JavaScript

import { describe, it, expect } from 'vitest';
import { decide } from './enforce-chain-recommendation.mjs';
// Shared helpers
const EDIT_TOOL = { name: 'Edit', input: { file_path: 'x.mjs' } };
const READ_TOOL = { name: 'Read', input: { file_path: 'x.mjs' } };
const GREP_TOOL = { name: 'Grep', input: {} };
describe('enforce-chain-recommendation / decide', () => {
// Test 1: empty chain → pass
it('empty chain → pass', () => {
expect(decide({
toolUses: [EDIT_TOOL],
recommendedChain: [],
calledSkillIds: new Set(),
assistantText: '',
override: null,
}).block).toBe(false);
});
// Test 2: chain of 1 → pass (single-node handled by enforce-classifier-match)
it('chain of 1 → pass (single-node handled elsewhere)', () => {
expect(decide({
toolUses: [EDIT_TOOL],
recommendedChain: ['#19'],
calledSkillIds: new Set(),
assistantText: '',
override: null,
}).block).toBe(false);
});
// Test 3: chain of 2, no skill called, no override → block
it('chain of 2, no skill called, no override → block', () => {
const r = decide({
toolUses: [EDIT_TOOL],
recommendedChain: ['#19', '#34'],
calledSkillIds: new Set(),
assistantText: '',
override: null,
});
expect(r.block).toBe(true);
expect(r.message).toMatch(/#19 → #34/);
expect(r.message).toMatch(/chain-override:/);
});
// Test 4: chain of 2, first skill called → pass
it('chain of 2, first skill called → pass', () => {
expect(decide({
toolUses: [EDIT_TOOL],
recommendedChain: ['#19', '#34'],
calledSkillIds: new Set(['#19']),
assistantText: '',
override: null,
}).block).toBe(false);
});
// Test 5: chain of 2, second skill called → pass (any one is enough)
it('chain of 2, second skill called → pass (any one is enough)', () => {
expect(decide({
toolUses: [EDIT_TOOL],
recommendedChain: ['#19', '#34'],
calledSkillIds: new Set(['#34']),
assistantText: '',
override: null,
}).block).toBe(false);
});
// Test 6: chain of 2, valid chain-override present → pass
it('chain of 2, chain-override with reason present → pass', () => {
expect(decide({
toolUses: [EDIT_TOOL],
recommendedChain: ['#19', '#34'],
calledSkillIds: new Set(),
assistantText: 'chain-override: трёхшаговая цепочка не нужна — задача чисто читающая\nдалее обычный ответ...',
override: null,
}).block).toBe(false);
});
// Test 7: chain of 2, chain-override present BUT empty reason → block
it('chain of 2, chain-override with empty reason → block', () => {
const r = decide({
toolUses: [EDIT_TOOL],
recommendedChain: ['#19', '#34'],
calledSkillIds: new Set(),
assistantText: 'chain-override:\n',
override: null,
});
expect(r.block).toBe(true);
});
// Test 8: chain of 2, global override → pass
it('chain of 2, global override → pass', () => {
expect(decide({
toolUses: [EDIT_TOOL],
recommendedChain: ['#19', '#34'],
calledSkillIds: new Set(),
assistantText: '',
override: { phrase: 'срочно', suppresses: ['chain-recommendation'] },
}).block).toBe(false);
});
// Test 9: chain of 2, but no mutating tool (only Read/Grep) → pass
it('chain of 2, no mutating tools used → pass', () => {
expect(decide({
toolUses: [READ_TOOL, GREP_TOOL],
recommendedChain: ['#19', '#34'],
calledSkillIds: new Set(),
assistantText: '',
override: null,
}).block).toBe(false);
});
// Test 10: chain of 5 (long), one mid-chain skill called → pass
it('chain of 5, one mid-chain skill called → pass', () => {
expect(decide({
toolUses: [EDIT_TOOL],
recommendedChain: ['#19', '#34', '#18', '#10', '#3'],
calledSkillIds: new Set(['#18']),
assistantText: '',
override: null,
}).block).toBe(false);
});
// Test 11: block message contains arrow-rendered chain
it('block message format includes arrow-rendered chain', () => {
const r = decide({
toolUses: [EDIT_TOOL],
recommendedChain: ['#19', '#34', '#18'],
calledSkillIds: new Set(),
assistantText: '',
override: null,
});
expect(r.block).toBe(true);
expect(r.message).toMatch(/#19 → #34 → #18/);
});
// Additional edge cases
it('chain-override with whitespace-only reason → block', () => {
const r = decide({
toolUses: [EDIT_TOOL],
recommendedChain: ['#19', '#34'],
calledSkillIds: new Set(),
assistantText: 'chain-override: \n',
override: null,
});
expect(r.block).toBe(true);
});
it('chain-override mid-text (not at line start) → block (must be line-start)', () => {
// Regex requires ^ in multiline mode, so inline text should not match
const r = decide({
toolUses: [EDIT_TOOL],
recommendedChain: ['#19', '#34'],
calledSkillIds: new Set(),
assistantText: 'some text chain-override: inline reason here',
override: null,
});
expect(r.block).toBe(true);
});
it('chain-override at true line start → pass', () => {
const r = decide({
toolUses: [EDIT_TOOL],
recommendedChain: ['#19', '#34'],
calledSkillIds: new Set(),
assistantText: 'reasoning here\nchain-override: direct edit acceptable for single-file fix\nmore text',
override: null,
});
expect(r.block).toBe(false);
});
it('empty toolUses → pass (no mutating tools)', () => {
expect(decide({
toolUses: [],
recommendedChain: ['#19', '#34'],
calledSkillIds: new Set(),
assistantText: '',
override: null,
}).block).toBe(false);
});
it('calledSkillIds contains by-name resolution (slug match) → pass', () => {
// If main() resolves #19 to its slug and adds it to calledSkillIds,
// decide() should accept it via the set-intersection.
expect(decide({
toolUses: [EDIT_TOOL],
recommendedChain: ['#19', '#34'],
calledSkillIds: new Set(['superpowers:writing-plans', '#19']),
assistantText: '',
override: null,
}).block).toBe(false);
});
it('block message mentions chain-override instruction text', () => {
const r = decide({
toolUses: [EDIT_TOOL],
recommendedChain: ['#19', '#34'],
calledSkillIds: new Set(),
assistantText: '',
override: null,
});
expect(r.block).toBe(true);
expect(r.message).toContain('[enforce-chain-recommendation]');
expect(r.message).toContain('chain-override:');
});
it('decide() has no side-effects: calling twice returns same result', () => {
const args = {
toolUses: [EDIT_TOOL],
recommendedChain: ['#19', '#34'],
calledSkillIds: new Set(),
assistantText: '',
override: null,
};
const r1 = decide({ ...args, calledSkillIds: new Set() });
const r2 = decide({ ...args, calledSkillIds: new Set() });
expect(r1.block).toBe(r2.block);
});
it('Bash tool counts as mutating', () => {
const r = decide({
toolUses: [{ name: 'Bash', input: { command: 'echo hi' } }],
recommendedChain: ['#19', '#34'],
calledSkillIds: new Set(),
assistantText: '',
override: null,
});
expect(r.block).toBe(true);
});
it('Task tool counts as mutating', () => {
const r = decide({
toolUses: [{ name: 'Task', input: { subagent_type: 'general-purpose' } }],
recommendedChain: ['#19', '#34'],
calledSkillIds: new Set(),
assistantText: '',
override: null,
});
expect(r.block).toBe(true);
});
});