import { describe, it, expect } from 'vitest'; import { isSimpleAB, decide, countSimpleSession, brainstormingInvokedSession, skillMatchedThisTurn, } from './askuser-cosmetic-detector.mjs'; const simpleQ = { question: 'A или B?', options: [{ label: 'Да' }, { label: 'Нет' }] }; const richQ = { question: 'Какой подход?', options: [{ label: 'Использовать skill brainstorming' }, { label: 'Свой путь' }, { label: 'Стоп' }], }; describe('askuser-cosmetic-detector / isSimpleAB', () => { it('true for 2-option short-label questions with no skill mention', () => { expect(isSimpleAB([simpleQ])).toBe(true); }); it('false when an option mentions a skill', () => { expect(isSimpleAB([richQ])).toBe(false); }); it('false for 3-option questions', () => { expect(isSimpleAB([{ question: 'q', options: [{ label: 'a' }, { label: 'b' }, { label: 'c' }] }])).toBe(false); }); it('false when a label is long (>=30 chars)', () => { expect(isSimpleAB([{ question: 'q', options: [{ label: 'a' }, { label: 'x'.repeat(40) }] }])).toBe(false); }); it('false for empty/invalid input', () => { expect(isSimpleAB(null)).toBe(false); expect(isSimpleAB([])).toBe(false); }); }); describe('askuser-cosmetic-detector / decide', () => { it('allows a rich (non-simple) AskUser', () => { const r = decide({ questions: [richQ], simpleCountSession: 0, simpleCountTurn: 0, skillMatchedThisTurn: false, brainstormingInvoked: false }); expect(r.action).toBe('allow'); expect(r.block).toBe(false); expect(r.isSimpleAB).toBe(false); expect(r.newSessionCount).toBe(0); expect(r.newTurnCount).toBe(0); }); it('soft-flags first simple A/B in a turn without skill match', () => { const r = decide({ questions: [simpleQ], simpleCountSession: 0, simpleCountTurn: 0, skillMatchedThisTurn: false, brainstormingInvoked: false }); expect(r.action).toBe('soft_flag'); expect(r.block).toBe(false); expect(r.newSessionCount).toBe(1); expect(r.newTurnCount).toBe(1); }); it('allows simple A/B when a skill matched this turn', () => { const r = decide({ questions: [simpleQ], simpleCountSession: 0, simpleCountTurn: 0, skillMatchedThisTurn: true, brainstormingInvoked: false }); expect(r.action).toBe('allow'); }); it('hard-blocks the 3rd simple AskUser in session without brainstorming', () => { const r = decide({ questions: [simpleQ], simpleCountSession: 2, simpleCountTurn: 0, skillMatchedThisTurn: false, brainstormingInvoked: false }); expect(r.action).toBe('hard_block'); expect(r.block).toBe(true); expect(r.reason).toMatch(/brainstorming/i); }); it('does NOT hard-block when brainstorming was invoked this session', () => { const r = decide({ questions: [simpleQ], simpleCountSession: 5, simpleCountTurn: 0, skillMatchedThisTurn: false, brainstormingInvoked: true }); expect(r.action).not.toBe('hard_block'); expect(r.block).toBe(false); }); it('hard-block takes precedence over soft_flag', () => { const r = decide({ questions: [simpleQ], simpleCountSession: 2, simpleCountTurn: 0, skillMatchedThisTurn: false, brainstormingInvoked: false }); expect(r.action).toBe('hard_block'); }); }); describe('askuser-cosmetic-detector / transcript helpers', () => { const sess = (uses) => uses.map((u) => ({ message: { content: [{ type: 'tool_use', name: u.name, input: u.input || {} }] } })); it('brainstormingInvokedSession true when Skill(superpowers:brainstorming) used', () => { const entries = sess([{ name: 'Skill', input: { skill: 'superpowers:brainstorming' } }]); expect(brainstormingInvokedSession(entries)).toBe(true); }); it('brainstormingInvokedSession false when only other skills used', () => { const entries = sess([{ name: 'Skill', input: { skill: 'superpowers:writing-plans' } }]); expect(brainstormingInvokedSession(entries)).toBe(false); }); it('skillMatchedThisTurn true when a Skill tool_use is in the last turn', () => { const entries = [ { type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'go' }] } }, { type: 'assistant', message: { role: 'assistant', content: [{ type: 'tool_use', name: 'Skill', input: { skill: 'graphify' } }] } }, ]; expect(skillMatchedThisTurn(entries)).toBe(true); }); it('countSimpleSession reads prior count from a flags file array', () => { const flags = [{ isSimpleAB: true }, { isSimpleAB: false }, { isSimpleAB: true }]; expect(countSimpleSession(flags)).toBe(2); }); }); import { isGitApprovalQuestion } from './askuser-cosmetic-detector.mjs'; // Calibration 5 (2026-05-31, SCOPE fix, NOT a discipline drop): a git-operation // APPROVAL AskUser (an option label is a literal git command) is the sanctioned // git-approval channel — enforce-askuser-answer-parser turns the chosen answer // into an approve_git_operation record. It is never a substitute for structured // ideation, so it must not be counted/blocked as "cosmetic A/B". Design A/B // questions (non-git labels) are unchanged — still counted, still hard-blocked. describe('isGitApprovalQuestion (calibration 5)', () => { it('true when an option label is a git command (push)', () => { expect(isGitApprovalQuestion([{ options: [{ label: 'git push origin main' }, { label: 'Не пушить' }] }])).toBe(true); }); it('true when an option label is a git command (commit with pathspec)', () => { expect(isGitApprovalQuestion([{ options: [{ label: 'git commit -F x.txt -- a.mjs b.mjs' }, { label: 'Отмена' }] }])).toBe(true); }); it('false for a non-git A/B', () => { expect(isGitApprovalQuestion([{ options: [{ label: 'Вариант А' }, { label: 'Вариант Б' }] }])).toBe(false); }); it('false for empty/invalid input', () => { expect(isGitApprovalQuestion(null)).toBe(false); expect(isGitApprovalQuestion([])).toBe(false); }); }); describe('decide — git-approval exemption (calibration 5)', () => { const gitQ = { question: 'Подтверди?', options: [{ label: 'git push origin main' }, { label: 'Не пушить' }] }; it('allows a git-approval question and does NOT count it even past the session limit', () => { const r = decide({ questions: [gitQ], simpleCountSession: 5, simpleCountTurn: 0, skillMatchedThisTurn: false, brainstormingInvoked: false }); expect(r.block).toBe(false); expect(r.action).toBe('allow'); expect(r.isSimpleAB).toBe(false); expect(r.newSessionCount).toBe(5); // unchanged — not counted toward the cosmetic limit }); it('REGRESSION: a non-git simple A/B past the limit STILL hard-blocks (discipline intact)', () => { const r = decide({ questions: [simpleQ], simpleCountSession: 5, simpleCountTurn: 0, skillMatchedThisTurn: false, brainstormingInvoked: false }); expect(r.action).toBe('hard_block'); expect(r.block).toBe(true); }); });