Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions apps/mcp-server/src/mcp/handlers/mode.handler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1648,6 +1648,138 @@ describe('ModeHandler', () => {
});
});

// ==========================================================================
// Clarification-First Directive (#1423)
// ==========================================================================
describe('parse_mode Clarification-First Directive (#1423)', () => {
it('overrides instructions with clarification-first directive for ambiguous PLAN prompt', async () => {
mockKeywordService.parseMode = vi.fn().mockResolvedValue({
...mockParseModeResult,
mode: 'PLAN',
originalPrompt: '개선해줘',
});

const result = await handler.handle('parse_mode', {
prompt: 'PLAN 개선해줘',
});

expect(result?.isError).toBeFalsy();
const parsed = JSON.parse((result?.content[0] as { text: string }).text);
expect(parsed.clarificationNeeded).toBe(true);
expect(parsed.instructions).toContain('CLARIFICATION REQUIRED');
expect(parsed.instructions).toContain('DO NOT PLAN');
expect(parsed.instructions).toContain('Ask EXACTLY the question below and STOP');
expect(parsed.instructions).not.toContain('CONTEXT: Check contextDocument');
});

it('includes nextQuestion text in overridden instructions', async () => {
mockKeywordService.parseMode = vi.fn().mockResolvedValue({
...mockParseModeResult,
mode: 'PLAN',
originalPrompt: 'improve it',
});

const result = await handler.handle('parse_mode', {
prompt: 'PLAN improve it',
});

expect(result?.isError).toBeFalsy();
const parsed = JSON.parse((result?.content[0] as { text: string }).text);
expect(parsed.clarificationNeeded).toBe(true);
// The nextQuestion should appear in the instructions
expect(parsed.instructions).toContain(parsed.nextQuestion);
});

it('includes remaining questionBudget in overridden instructions', async () => {
mockKeywordService.parseMode = vi.fn().mockResolvedValue({
...mockParseModeResult,
mode: 'PLAN',
originalPrompt: 'improve it',
});

const result = await handler.handle('parse_mode', {
prompt: 'PLAN improve it',
question_budget: 2,
});

expect(result?.isError).toBeFalsy();
const parsed = JSON.parse((result?.content[0] as { text: string }).text);
expect(parsed.clarificationNeeded).toBe(true);
expect(parsed.instructions).toContain('question_budget=1');
});

it('does NOT override instructions when planReady=true', async () => {
mockKeywordService.parseMode = vi.fn().mockResolvedValue({
...mockParseModeResult,
mode: 'PLAN',
originalPrompt: 'add tests to src/auth.ts',
});

const result = await handler.handle('parse_mode', {
prompt: 'PLAN add tests to src/auth.ts',
});

expect(result?.isError).toBeFalsy();
const parsed = JSON.parse((result?.content[0] as { text: string }).text);
expect(parsed.planReady).toBe(true);
expect(parsed.instructions).not.toContain('CLARIFICATION REQUIRED');
});

it('applies directive in AUTO mode for ambiguous prompt', async () => {
mockKeywordService.parseMode = vi.fn().mockResolvedValue({
...mockParseModeResult,
mode: 'AUTO',
originalPrompt: '개선',
});

const result = await handler.handle('parse_mode', {
prompt: 'AUTO 개선',
});

expect(result?.isError).toBeFalsy();
const parsed = JSON.parse((result?.content[0] as { text: string }).text);
expect(parsed.clarificationNeeded).toBe(true);
expect(parsed.instructions).toContain('CLARIFICATION REQUIRED');
expect(parsed.instructions).toContain('DO NOT PLAN');
});

it('does NOT apply directive when budget is exhausted', async () => {
mockKeywordService.parseMode = vi.fn().mockResolvedValue({
...mockParseModeResult,
mode: 'PLAN',
originalPrompt: 'improve it',
});

const result = await handler.handle('parse_mode', {
prompt: 'PLAN improve it',
question_budget: 0,
});

expect(result?.isError).toBeFalsy();
const parsed = JSON.parse((result?.content[0] as { text: string }).text);
expect(parsed.clarificationNeeded).toBe(false);
expect(parsed.planReady).toBe(true);
expect(parsed.instructions).not.toContain('CLARIFICATION REQUIRED');
});

it('does NOT apply directive when override phrase is present', async () => {
mockKeywordService.parseMode = vi.fn().mockResolvedValue({
...mockParseModeResult,
mode: 'PLAN',
originalPrompt: 'improve it, just do it',
});

const result = await handler.handle('parse_mode', {
prompt: 'PLAN improve it, just do it',
});

expect(result?.isError).toBeFalsy();
const parsed = JSON.parse((result?.content[0] as { text: string }).text);
expect(parsed.clarificationNeeded).toBe(false);
expect(parsed.instructions).not.toContain('CLARIFICATION REQUIRED');
});
});

// ==========================================================================
// Planning Stage Router (#1372)
// ==========================================================================
Expand Down
43 changes: 43 additions & 0 deletions apps/mcp-server/src/mcp/handlers/mode.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,13 @@ export class ModeHandler extends AbstractHandler {
questionBudget,
);

// Clarification-first directive (#1423): when the gate determines the
// request is ambiguous, build an instruction override so the AI client
// asks the next question and STOPS before generating any plan.
// We intentionally do NOT mutate `result` — the override is applied
// later in the response spread to avoid side-effects on shared objects.
const clarificationInstructions = this.buildClarificationFirstInstructions(clarification);

// Planning Stage Router (#1372) — maps clarification output to
// discover / design / plan stage. Only for PLAN/AUTO modes.
const planningStage = this.buildPlanningStageMetadata(
Expand All @@ -380,6 +387,9 @@ export class ModeHandler extends AbstractHandler {

const response = createJsonResponse({
...result,
// Clarification-first instruction override (#1423) — applied AFTER
// spreading result so it wins over the original instructions field.
...(clarificationInstructions !== undefined && { instructions: clarificationInstructions }),
language,
languageInstruction: languageInstructionResult.instruction,
resolvedModel,
Expand Down Expand Up @@ -696,6 +706,39 @@ export class ModeHandler extends AbstractHandler {
});
}

/**
* Build a clarification-first instruction string (#1423).
*
* Returns the directive text when the Clarification Gate determines the
* request is ambiguous, or `undefined` when the request is clear.
* The caller spreads the result into the response so the original
* `result` object is never mutated (avoids shared-reference side-effects).
*/
private buildClarificationFirstInstructions(
clarification: ClarificationMetadata | undefined,
): string | undefined {
if (!clarification?.clarificationNeeded) {
return undefined;
}

const question =
clarification.nextQuestion ??
'Can you clarify the scope and success criteria of this request?';
const budget = clarification.questionBudget ?? 0;

return (
'🔴 CLARIFICATION REQUIRED — DO NOT PLAN.\n\n' +
'The request is ambiguous. You MUST:\n' +
'1. Ask EXACTLY the question below and STOP.\n' +
'2. Do NOT output any implementation plan, architecture, or code.\n' +
"3. Wait for the user's response before continuing.\n\n" +
`❓ Ask this: "${question}"\n\n` +
`Remaining question budget: ${budget}\n\n` +
'After the user answers, call parse_mode again with the clarified prompt ' +
`and question_budget=${budget} to continue.`
);
}

/**
* Build Planning Stage metadata for PLAN/AUTO modes (#1372).
* Returns undefined for ACT/EVAL modes.
Expand Down
146 changes: 145 additions & 1 deletion packages/claude-code-plugin/hooks/lib/mode_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,132 @@
}


# ---------------------------------------------------------------------------
# Standalone Clarification Gate (#1423)
# Mirrors MCP server clarification-gate.ts heuristics so standalone mode
# also asks before planning when the request is ambiguous.
# ---------------------------------------------------------------------------

MIN_PROMPT_LENGTH = 20

_OVERRIDE_PATTERNS = [
re.compile(r"\bjust\s+do\s+it\b", re.I),
re.compile(r"\buse\s+your\s+(?:judg(?:e)?ment|best\s+guess|discretion)\b", re.I),
re.compile(r"\bgo\s+ahead\b", re.I),
re.compile(r"\bmake\s+assumptions?\b", re.I),
re.compile(r"\bassume\s+(?:whatever|defaults?|reasonable)\b", re.I),
re.compile(r"알아서\s*(?:해|진행|처리)"),
re.compile(r"그냥\s*(?:해|진행)"),
re.compile(r"임의로\s*(?:해|진행)"),
]

_VAGUE_INTENT_PATTERNS = [
re.compile(r"\bimprove\b", re.I),
re.compile(r"\b(?:make\s+it\s+)?better\b", re.I),
re.compile(r"\benhance\b", re.I),
re.compile(r"\boptimi[sz]e\b", re.I),
re.compile(r"\brefactor\b", re.I),
re.compile(r"\bclean\s*up\b", re.I),
re.compile(r"\btweak\b", re.I),
re.compile(r"\bfix\s+(?:stuff|things|issues?)\b", re.I),
re.compile(r"개선"),
re.compile(r"향상"),
re.compile(r"최적화"),
re.compile(r"정리"),
re.compile(r"개량"),
]

_TECH_REFERENCE_PATTERNS = [
re.compile(
r"\.(?:ts|tsx|js|jsx|py|go|rs|java|kt|swift|rb|php|c|cpp|cs|md"
r"|json|ya?ml|toml|sql|sh)\b",
re.I,
),
re.compile(r"(?:^|[\s([`\"'])[\w.-]+/[\w.\-/]+"),
re.compile(r"\b[a-zA-Z_][\w]*\("),
re.compile(r"\b[A-Z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]+\b"),
re.compile(r"\b[a-z][a-z0-9]+[A-Z][a-zA-Z0-9]+\b"),
re.compile(r"\b[a-z]{3,}_[a-z]{3,}[a-z0-9_]*\b"),
re.compile(r"`[^`]+`"),
]

_MODE_KEYWORD_RE = re.compile(
r"^(PLAN|ACT|EVAL|AUTO|계획|실행|평가|자동|計画|実行|評価|自動"
r"|计划|执行|评估|自动|PLANIFICAR|ACTUAR|EVALUAR|AUTOMÁTICO)\s*[:\s]*",
re.I,
)


DEFAULT_QUESTION_BUDGET = 3


def evaluate_clarification_standalone(
prompt: str, question_budget: Optional[int] = None
) -> Optional[str]:
"""
Standalone clarification gate (#1423).

Returns a clarification-first directive string when the prompt is
ambiguous, or ``None`` when the request is clear enough to plan.

Args:
prompt: Raw user prompt including mode keyword.
question_budget: Remaining clarification rounds. Defaults to
``DEFAULT_QUESTION_BUDGET``. When 0 the gate falls back
to planning with explicit assumptions.
"""
budget = question_budget if question_budget is not None else DEFAULT_QUESTION_BUDGET

trimmed = prompt.strip()
if not trimmed:
return None

# Budget exhausted — proceed with explicit assumptions.
if budget <= 0:
return None

# Strip mode keyword prefix before evaluating content
stripped = _MODE_KEYWORD_RE.sub("", trimmed).strip()
if not stripped:
return None

if any(p.search(stripped) for p in _OVERRIDE_PATTERNS):
return None

if any(p.search(stripped) for p in _TECH_REFERENCE_PATTERNS):
return None

is_vague = any(p.search(stripped) for p in _VAGUE_INTENT_PATTERNS)
is_short = 0 < len(stripped) < MIN_PROMPT_LENGTH

if not is_vague and not is_short:
return None

remaining = budget - 1

if is_vague:
question = (
"What concrete change are you targeting — "
"which behavior, file, or metric should differ after this task?"
)
else:
question = (
"Can you describe the goal, inputs, and expected outcome "
"in a bit more detail?"
)

return (
"🔴 CLARIFICATION REQUIRED — DO NOT PLAN.\n\n"
"The request is ambiguous. You MUST:\n"
"1. Ask EXACTLY the question below and STOP.\n"
"2. Do NOT output any implementation plan, architecture, or code.\n"
"3. Wait for the user's response before continuing.\n\n"
f'❓ Ask this: "{question}"\n\n'
f"Remaining question budget: {remaining}\n\n"
"After the user answers, re-invoke the mode with the clarified prompt."
)


def _resolve_rules_dir(cwd: Optional[str] = None) -> Optional[str]:
"""
Resolve path to .ai-rules/ directory.
Expand Down Expand Up @@ -290,21 +416,39 @@ def build_council_scene(self, mode: str) -> Optional[dict]:
"format": "tiny-actor-grid",
}

def build_instructions(self, mode: str) -> str:
def build_instructions(
self,
mode: str,
prompt: Optional[str] = None,
question_budget: Optional[int] = None,
) -> str:
"""
Build complete mode instructions for hook output.

Enriches the static template with actual ``.ai-rules/`` data when
available and enforces the ``CHAR_LIMIT`` (2000) ceiling. Falls
back to the minimal template when ``.ai-rules/`` is absent.

When *prompt* is provided for PLAN/AUTO modes the standalone
Clarification Gate (#1423) is evaluated first. If the request is
ambiguous a clarification-first directive is returned instead of
the normal planning instructions.

Args:
mode: Mode name (PLAN, ACT, EVAL, AUTO)
prompt: Optional raw user prompt for clarification evaluation.

Returns:
Complete mode instructions string (≤ 2000 chars).
"""
mode_upper = mode.upper()

# Clarification gate for PLAN/AUTO modes (#1423)
if prompt and mode_upper in ("PLAN", "AUTO"):
directive = evaluate_clarification_standalone(prompt, question_budget)
if directive:
return directive

agent = self.get_default_agent(mode_upper)
template = MODE_TEMPLATES.get(mode_upper, MODE_TEMPLATES["ACT"])

Expand Down
Loading
Loading