dbaf0e0e76
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).
243 lines
7.6 KiB
JavaScript
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);
|
|
});
|
|
});
|