From cd2da132a321187fda1dedfc45d8163901c54d91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 31 May 2026 11:19:14 +0300 Subject: [PATCH] fix(router-gate-v4): calibration 5 - cosmetic-detector exempts git-approval AskUser (scope fix, regression-tested) --- tools/askuser-cosmetic-detector.mjs | 23 +++++++++++++ tools/askuser-cosmetic-detector.test.mjs | 42 ++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/tools/askuser-cosmetic-detector.mjs b/tools/askuser-cosmetic-detector.mjs index 49205765..44606771 100644 --- a/tools/askuser-cosmetic-detector.mjs +++ b/tools/askuser-cosmetic-detector.mjs @@ -34,6 +34,22 @@ export function isSimpleAB(questions) { ); } +// Calibration 5 (2026-05-31) — git-operation APPROVAL prompts are the sanctioned +// git-approval channel (enforce-askuser-answer-parser turns the chosen answer +// into an approve_git_operation record), never a substitute for structured +// ideation. They must NOT be treated as cosmetic A/B. Identified structurally: +// an option label is a literal git command. (SCOPE fix, not a discipline drop — +// see decide(): design A/B questions with non-git labels are unaffected.) +const GIT_CMD_RE = /\bgit\s+(?:commit|push|add|pull|merge|rebase|reset|checkout|switch|branch|stash|cherry-pick|revert|clean|restore|fetch|tag)\b/i; + +/** True if this AskUser is a git-operation approval prompt (an option label is a git command). */ +export function isGitApprovalQuestion(questions) { + if (!Array.isArray(questions)) return false; + return questions.some((q) => + q && Array.isArray(q.options) && + q.options.some((o) => o && typeof o.label === 'string' && GIT_CMD_RE.test(o.label))); +} + /** * Pure cosmetic-AskUser decision (v4.1 §4.5). * Caller passes PRIOR counts; decide computes prospective new counts. @@ -42,6 +58,13 @@ export function isSimpleAB(questions) { * @returns {{action:'allow'|'soft_flag'|'hard_block', block:boolean, reason:string|null, isSimpleAB:boolean, newSessionCount:number, newTurnCount:number}} */ export function decide({ questions, simpleCountSession = 0, simpleCountTurn = 0, skillMatchedThisTurn = false, brainstormingInvoked = false }) { + // Calibration 5: git-operation approval prompts are exempt — the sanctioned + // git-approval channel, never cosmetic ideation. Allow, do not count, never + // block. (Cannot be abused to dodge ideation discipline: a git-command label + // makes the answer a real approve_git_operation, not a cosmetic clarification.) + if (isGitApprovalQuestion(questions)) { + return { action: 'allow', block: false, reason: null, isSimpleAB: false, newSessionCount: simpleCountSession, newTurnCount: simpleCountTurn }; + } const simple = isSimpleAB(questions); const newSessionCount = simpleCountSession + (simple ? 1 : 0); const newTurnCount = simpleCountTurn + (simple ? 1 : 0); diff --git a/tools/askuser-cosmetic-detector.test.mjs b/tools/askuser-cosmetic-detector.test.mjs index 88678c4c..c61b155c 100644 --- a/tools/askuser-cosmetic-detector.test.mjs +++ b/tools/askuser-cosmetic-detector.test.mjs @@ -92,3 +92,45 @@ describe('askuser-cosmetic-detector / transcript helpers', () => { 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); + }); +});