63686fa5b2
Closes Stream H Task 5 (H6). Adds the PreToolUse wrapper around the pure
decomposition-detector module (Stream A Direction 3 / v4.1 §3.8).
What this catches:
- A feature secretly decomposed into 3+ small prompts whose primary_keywords
overlap heavily AND no planning skill (writing-plans / brainstorming) has
been invoked in the window. v4.1 hard-blocks mutating tools when the LLM
judge confirms decomposition; soft-flags on legit-distinct verdict; allows
when threshold not met or a planning skill was invoked.
Defensive design choices:
- decide() takes llmVerdict as an explicit string ('YES'|'NO'|null), not an
async LLM call — keeps the function pure and unit-testable
without network.
- llmVerdict=null degrades to soft_flag (with degraded:true), NOT hard_block.
This avoids repeating the Stream G Task 8 self-lockout where a fail-CLOSE
LLM hook bricked the session.
- main() is a no-op (exit 0) until the deferred wiring lands (history-ledger
reader from observer Stop hook + LLM judge config from Stream D). Until
then, the hook never blocks anything.
Regression: vitest tools 1748/1748 GREEN (was 1742; +6 wrapper-decide tests
under "enforce-decomposition-detector wrapper (Stream H Task 5)" describe
block, covering: empty history → allow, below threshold → allow, threshold
+ LLM YES → hard_block_mutating, threshold + LLM NO → soft_flag, threshold
+ skill present → allow, threshold + LLM unavailable → degraded soft_flag).
DEFERRED: .claude/settings.json registration (PreToolUse matcher
"Edit|Write|MultiEdit|NotebookEdit|Bash|Task", timeout 8000ms) AND main()
wiring (history-ledger reader + LLM judge integration). Batched with
H5/H7/H8 hook activations at end of Phase H-α/H-β.
Stream H Task 5 of 11. Plan: docs/superpowers/plans/2026-05-30-router-gate-v4-stream-H.md
87 lines
3.8 KiB
JavaScript
87 lines
3.8 KiB
JavaScript
// tools/enforce-decomposition-detector.test.mjs
|
|
// Stream H Task 5 (H6) — wrapper tests around the pure decomposition-detector module.
|
|
import { describe, it, expect } from 'vitest';
|
|
import { decide } from './enforce-decomposition-detector.mjs';
|
|
|
|
describe('enforce-decomposition-detector wrapper (Stream H Task 5)', () => {
|
|
it('allows when history is empty', () => {
|
|
const r = decide({
|
|
history: [],
|
|
currentEntry: { primary_keywords: ['feature', 'login', 'form'], skill_invoked_this_prompt: false, prompt_idx: 1 },
|
|
llmVerdict: 'NO',
|
|
});
|
|
expect(r.action).toBe('allow');
|
|
});
|
|
|
|
it('allows when overlap below threshold (only 2 prompts share keywords)', () => {
|
|
const history = [
|
|
{ primary_keywords: ['feature', 'login', 'form'], skill_invoked_this_prompt: false, prompt_idx: 1 },
|
|
{ primary_keywords: ['feature', 'login', 'form'], skill_invoked_this_prompt: false, prompt_idx: 2 },
|
|
];
|
|
const r = decide({
|
|
history,
|
|
currentEntry: { primary_keywords: ['unrelated', 'topic', 'words'], skill_invoked_this_prompt: false, prompt_idx: 3 },
|
|
llmVerdict: 'YES',
|
|
});
|
|
expect(r.action).toBe('allow');
|
|
});
|
|
|
|
it('hard_block_mutating when 3+ overlap, no skill, LLM YES (v4.1)', () => {
|
|
const history = [
|
|
{ primary_keywords: ['feature', 'login', 'form'], skill_invoked_this_prompt: false, prompt_idx: 1 },
|
|
{ primary_keywords: ['feature', 'login', 'form'], skill_invoked_this_prompt: false, prompt_idx: 2 },
|
|
{ primary_keywords: ['feature', 'login', 'form'], skill_invoked_this_prompt: false, prompt_idx: 3 },
|
|
];
|
|
const r = decide({
|
|
history,
|
|
currentEntry: { primary_keywords: ['feature', 'login', 'form'], skill_invoked_this_prompt: false, prompt_idx: 4 },
|
|
llmVerdict: 'YES',
|
|
});
|
|
expect(r.action).toBe('hard_block_mutating');
|
|
expect(r.reason).toMatch(/decomp/i);
|
|
});
|
|
|
|
it('soft_flag when threshold met but LLM verdict NO (legit-distinct)', () => {
|
|
const history = [
|
|
{ primary_keywords: ['feature', 'login', 'form'], skill_invoked_this_prompt: false, prompt_idx: 1 },
|
|
{ primary_keywords: ['feature', 'login', 'form'], skill_invoked_this_prompt: false, prompt_idx: 2 },
|
|
{ primary_keywords: ['feature', 'login', 'form'], skill_invoked_this_prompt: false, prompt_idx: 3 },
|
|
];
|
|
const r = decide({
|
|
history,
|
|
currentEntry: { primary_keywords: ['feature', 'login', 'form'], skill_invoked_this_prompt: false, prompt_idx: 4 },
|
|
llmVerdict: 'NO',
|
|
});
|
|
expect(r.action).toBe('soft_flag');
|
|
});
|
|
|
|
it('allows when threshold met but a writing-plans skill was invoked', () => {
|
|
const history = [
|
|
{ primary_keywords: ['feature', 'login', 'form'], skill_invoked_this_prompt: true, prompt_idx: 1 },
|
|
{ primary_keywords: ['feature', 'login', 'form'], skill_invoked_this_prompt: false, prompt_idx: 2 },
|
|
{ primary_keywords: ['feature', 'login', 'form'], skill_invoked_this_prompt: false, prompt_idx: 3 },
|
|
];
|
|
const r = decide({
|
|
history,
|
|
currentEntry: { primary_keywords: ['feature', 'login', 'form'], skill_invoked_this_prompt: false, prompt_idx: 4 },
|
|
llmVerdict: 'YES',
|
|
});
|
|
expect(r.action).toBe('allow');
|
|
});
|
|
|
|
it('degraded allow when LLM verdict is missing/null (fail-open on LLM layer)', () => {
|
|
const history = [
|
|
{ primary_keywords: ['feature', 'login', 'form'], skill_invoked_this_prompt: false, prompt_idx: 1 },
|
|
{ primary_keywords: ['feature', 'login', 'form'], skill_invoked_this_prompt: false, prompt_idx: 2 },
|
|
{ primary_keywords: ['feature', 'login', 'form'], skill_invoked_this_prompt: false, prompt_idx: 3 },
|
|
];
|
|
const r = decide({
|
|
history,
|
|
currentEntry: { primary_keywords: ['feature', 'login', 'form'], skill_invoked_this_prompt: false, prompt_idx: 4 },
|
|
llmVerdict: null,
|
|
});
|
|
expect(r.action).toBe('soft_flag');
|
|
expect(r.degraded).toBe(true);
|
|
});
|
|
});
|