Files
portal/tools/enforce-decomposition-detector.test.mjs
T
Дмитрий 63686fa5b2 feat(router-gate-v4): Stream H Task 5 — decomposition-detector wrapper hook (PreToolUse, deferred activation)
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
2026-05-30 11:31:00 +03:00

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);
});
});