vibe-gate-mcp 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -3,9 +3,13 @@
3
3
  # NEVER commit real keys. Pick one API provider with a key OR a signed-in local CLI.
4
4
  # =============================================================================
5
5
 
6
- # --- Required: Critic provider ---
6
+ # --- Critic provider (optional) ---
7
+ # When unset, Vibe-Gate selects the first installed CLI in this order:
8
+ # codex-cli -> claude-code -> cursor-agent -> opencode-cli; provider/model CRITIC_MODEL prefers opencode-cli.
9
+ # If no local CLI is found, OpenAI remains the default and requires OPENAI_API_KEY.
10
+ # To choose an API provider or override auto-detection, set CRITIC_PROVIDER explicitly.
7
11
  # One of: openai | anthropic | google | minimax | opencode | codex-cli | claude-code | cursor-agent | opencode-cli
8
- CRITIC_PROVIDER=openai
12
+ # CRITIC_PROVIDER=openai
9
13
 
10
14
  # --- API key for direct API providers (uncomment ONE block when needed) ---
11
15
 
@@ -35,7 +39,7 @@ OPENAI_API_KEY=
35
39
  # Optional executable paths when the MCP host PATH cannot find the CLI
36
40
  # CODEX_CLI_PATH=/absolute/path/to/codex
37
41
  # CLAUDE_CODE_CLI_PATH=/absolute/path/to/claude
38
- # CURSOR_AGENT_CLI_PATH=/absolute/path/to/cursor-agent
42
+ # CURSOR_AGENT_CLI_PATH=/absolute/path/to/agent
39
43
  # OPENCODE_CLI_PATH=/absolute/path/to/opencode
40
44
  # CRITIC_CLI_TIMEOUT_MS=120000
41
45
 
package/CHANGELOG.md CHANGED
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.6] - 2026-09-24
11
+
12
+ ### Added
13
+
14
+ - Auto-detect an installed local CLI when `CRITIC_PROVIDER` is unset and report the selected CLI in the first review response.
15
+
16
+ ### Fixed
17
+
18
+ - Use Cursor's current `agent` command by default and fall back to the legacy `cursor-agent` command when it is unavailable.
19
+
20
+ ### Tests
21
+
22
+ - Cover local CLI auto-detection and Cursor Agent's legacy executable fallback.
23
+
24
+ ## [0.1.5] - 2026-09-23
25
+
26
+ ### Fixed
27
+
28
+ - Enforce bounded review inputs, close the conflict loop as deadlocked after a rejected third round, and avoid duplicating or persisting source payloads between rounds.
29
+ - Load Critic `REQUEST:` paths from the workspace with path, file-count, file-size, line-count, and total-context limits; reject traversal and symlink escapes in changed-file reads.
30
+ - Use the OpenAI Responses API so current reasoning models work without model-specific request parameters or a Vibe-Gate model allowlist.
31
+
32
+ ### Docs
33
+
34
+ - Document the review limits and requested-context behavior, and add a release checklist and scope review.
35
+
10
36
  ## [0.1.4] - 2026-09-21
11
37
 
12
38
  ### Added
package/README.md CHANGED
@@ -6,7 +6,7 @@ An **Adversarial Quality Gate** for AI-assisted IDEs: the IDE agent and a Critic
6
6
 
7
7
  ### 1. Choose a Critic provider
8
8
 
9
- Use a direct API provider with its key, or use a local CLI that is already installed and signed in. Local CLI providers do not need a separate provider API key.
9
+ Use a direct API provider with its key, or use a local CLI that is already installed and signed in. Local CLI providers do not need a separate provider API key. If `CRITIC_PROVIDER` is omitted, Vibe-Gate selects the first installed local CLI in this order: Codex, Claude Code, Cursor Agent, then OpenCode CLI. OpenCode CLI moves to the front when `CRITIC_MODEL` is in `provider/model` form. The first review response includes a notice naming the selected CLI. If none is found, OpenAI remains the default and requires `OPENAI_API_KEY`. Set `CRITIC_PROVIDER` to choose explicitly.
10
10
 
11
11
  Copy from the package’s [`.env.example`](.env.example):
12
12
 
@@ -40,11 +40,15 @@ OPENAI_API_KEY=YOUR_OPENAI_API_KEY
40
40
  | OpenCode | `opencode` | `OPENCODE_API_KEY` (+ optional `OPENCODE_PLAN`) |
41
41
  | Codex CLI | `codex-cli` | Existing `codex login` session |
42
42
  | Claude Code | `claude-code` | Existing Claude Code account session |
43
- | Cursor Agent | `cursor-agent` | Existing `cursor-agent login` session |
43
+ | Cursor Agent | `cursor-agent` | Existing `agent login` session |
44
44
  | OpenCode CLI | `opencode-cli` | Saved `opencode auth login` credentials + model |
45
45
 
46
+ For Cursor Agent, Vibe-Gate runs `agent` first and falls back to the legacy `cursor-agent` executable if the primary command is unavailable.
47
+
46
48
  `opencode` is still the separate Zen/Go HTTP provider and needs `OPENCODE_API_KEY`. `opencode-cli` runs the local CLI and requires a `provider/model` value in `CRITIC_MODEL`; see the CLI guide for details.
47
49
 
50
+ `CRITIC_MODEL` is passed to the selected provider or CLI without a Vibe-Gate model allowlist; the provider must support that model ID. The OpenAI API provider uses the Responses API. See [provider configuration](docs/USAGE.md#configuration--providers).
51
+
48
52
  See [CLI provider setup and alternatives](docs/CLI_PROVIDERS.md) for CLI installation, login, configuration, OpenCode session details, and other candidates we evaluated. Full variable list: [docs/project/VARIABLES.md](docs/project/VARIABLES.md).
49
53
 
50
54
  ### 2. Configure Cursor MCP (any consumer repo)
@@ -122,6 +126,7 @@ Probes: `updateStatus: false` or `phaseId` prefixes `mcp-smoke-` / `vibe-gate-pr
122
126
  | [docs/USAGE.md](docs/USAGE.md) | First run and providers |
123
127
  | [docs/CLI_PROVIDERS.md](docs/CLI_PROVIDERS.md) | Local CLI providers |
124
128
  | [docs/SEMANTIC_DIFF_PAYLOAD.md](docs/SEMANTIC_DIFF_PAYLOAD.md) | `files[]` contract |
129
+ | [docs/ROADMAP.md](docs/ROADMAP.md) | Release checklist |
125
130
  | [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) | Stale MCP, path errors |
126
131
  | [docs/project/VARIABLES.md](docs/project/VARIABLES.md) | Env SSoT |
127
132
  | [examples/](examples/) | Cursor mcp.json templates |
package/dist/index.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { dirname, isAbsolute, join, normalize, relative, resolve } from "node:path";
2
+ import { basename, delimiter, dirname, extname, isAbsolute, join, normalize, relative, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { config } from "dotenv";
5
- import { existsSync, readFileSync } from "node:fs";
5
+ import { accessSync, constants, existsSync, readFileSync } from "node:fs";
6
6
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
7
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
8
  import { z } from "zod";
@@ -111,9 +111,11 @@ const PROVIDERS = {
111
111
  const CLI_DEFAULT_COMMANDS = {
112
112
  [PROVIDERS.CODEX_CLI]: "codex",
113
113
  [PROVIDERS.CLAUDE_CODE]: "claude",
114
- [PROVIDERS.CURSOR_AGENT]: "cursor-agent",
114
+ [PROVIDERS.CURSOR_AGENT]: "agent",
115
115
  [PROVIDERS.OPENCODE_CLI]: "opencode"
116
116
  };
117
+ /** Previous Cursor CLI executable name retained for older installations. */
118
+ const CURSOR_AGENT_LEGACY_COMMAND = "cursor-agent";
117
119
  /** Environment keys removed from local CLI processes unless a provider explicitly needs one. */
118
120
  const CLI_STRIPPED_ENV_KEYS = [
119
121
  ENV_KEYS.OPENAI_API_KEY,
@@ -329,7 +331,7 @@ const CONFLICT_LOOP = {
329
331
  };
330
332
  /** Error messages (SSoT) */
331
333
  const ERROR_MESSAGES = {
332
- NO_LLM_PROVIDER: "No LLM provider available. Set CRITIC_PROVIDER and either the required API key or an installed, authenticated local CLI.",
334
+ NO_LLM_PROVIDER: "No LLM provider available. Check CRITIC_PROVIDER and its required API key, or install and sign in to a supported local CLI.",
333
335
  STARTUP_FAILED: "Vibe-Gate failed to start:"
334
336
  };
335
337
  /** Debug log prefix (stderr) */
@@ -395,6 +397,18 @@ const SEMANTIC_DIFF_SOURCE_FILES = {
395
397
  MAX_BYTES_PER_FILE: 1048576,
396
398
  MAX_TOTAL_BYTES: 5242880
397
399
  };
400
+ /** MCP review input bounds; apply size checks again after files are read. */
401
+ const REVIEW_INPUT_LIMITS = {
402
+ MAX_PHASE_ID_CHARS: 256,
403
+ MAX_REPORT_CHARS: 5e4,
404
+ MAX_SEMANTIC_DIFF_CHARS: 5e5,
405
+ MAX_PATH_CHARS: 1024,
406
+ MAX_DEPENDENCIES: 100,
407
+ MAX_DEPENDENCY_NAME_CHARS: 256,
408
+ MAX_DEBT_SUBJECT_CHARS: 200,
409
+ MAX_DEBT_RATIONALE_CHARS: 4e3,
410
+ MAX_REQUESTED_CONTEXT_LINES: 120
411
+ };
398
412
  /**
399
413
  * Status.json write policy on ACCEPT.
400
414
  * Probe phaseIds must not pollute consumer `.vibe/status.json` unless updateStatus:true.
@@ -591,7 +605,7 @@ const personaSchema = z.enum([
591
605
  PERSONAS.CLEAN_CODE_MONK
592
606
  ]);
593
607
  const configSchema = z.object({
594
- criticProvider: providerSchema.default(PROVIDERS.OPENAI),
608
+ criticProvider: providerSchema.optional(),
595
609
  criticModel: z.string().min(1).optional(),
596
610
  criticPersona: personaSchema.default(PERSONAS.CLEAN_CODE_MONK),
597
611
  openaiApiKey: z.string().optional(),
@@ -619,7 +633,7 @@ function getEnv(key) {
619
633
  }
620
634
  function loadConfig() {
621
635
  const raw = {
622
- criticProvider: getEnv(ENV_KEYS.CRITIC_PROVIDER) ?? PROVIDERS.OPENAI,
636
+ criticProvider: getEnv(ENV_KEYS.CRITIC_PROVIDER)?.trim() || void 0,
623
637
  criticModel: getEnv(ENV_KEYS.CRITIC_MODEL),
624
638
  criticPersona: getEnv(ENV_KEYS.CRITIC_PERSONA) ?? PERSONAS.CLEAN_CODE_MONK,
625
639
  openaiApiKey: getEnv(ENV_KEYS.OPENAI_API_KEY),
@@ -636,9 +650,9 @@ function loadConfig() {
636
650
  };
637
651
  return configSchema.parse(raw);
638
652
  }
639
- function getEffectiveModel(config) {
653
+ function getEffectiveModel(config, providerId) {
640
654
  if (config.criticModel) return config.criticModel;
641
- return DEFAULT_MODELS[config.criticProvider];
655
+ return DEFAULT_MODELS[providerId ?? config.criticProvider ?? PROVIDERS.OPENAI];
642
656
  }
643
657
  //#endregion
644
658
  //#region src/utils/debug.ts
@@ -693,7 +707,6 @@ const concernVerificationSchema = z.object({
693
707
  const reviewRoundSchema = z.object({
694
708
  round: z.number(),
695
709
  report: z.string(),
696
- semanticDiff: z.string().optional(),
697
710
  verdict: z.string(),
698
711
  criticResponse: z.string(),
699
712
  concerns: z.array(concernSchema).optional(),
@@ -1847,7 +1860,10 @@ async function verifyCanonicalPathUnderWorkspace(workspaceRoot, absolutePath, pa
1847
1860
  code: "PATH_OUTSIDE_WORKSPACE",
1848
1861
  message: `${pathKind} resolves outside VIBE_WORKSPACE_ROOT after canonical resolution.`
1849
1862
  };
1850
- return { ok: true };
1863
+ return {
1864
+ ok: true,
1865
+ canonicalPath: fileReal
1866
+ };
1851
1867
  }
1852
1868
  /**
1853
1869
  * Resolve a user-supplied relative path to an absolute path confined to workspaceRoot.
@@ -1946,7 +1962,7 @@ async function loadSemanticDiffFromWorkspacePath(workspaceRoot, userRelativePath
1946
1962
  };
1947
1963
  let raw;
1948
1964
  try {
1949
- raw = await readFile(absolutePath, "utf8");
1965
+ raw = await readFile(canonical.canonicalPath, "utf8");
1950
1966
  } catch {
1951
1967
  return {
1952
1968
  ok: false,
@@ -1998,9 +2014,10 @@ function mapPathError(code, message) {
1998
2014
  message
1999
2015
  };
2000
2016
  }
2001
- function formatFileBlock(relativePath, content) {
2017
+ function formatSemanticDiffFileBlock(relativePath, content, truncated = false) {
2002
2018
  const body = content.endsWith("\n") ? content : `${content}\n`;
2003
- return `${SEMANTIC_DIFF_PAYLOAD_MARKERS.FILE_LINE_PREFIX}${relativePath}\n${SEMANTIC_DIFF_PAYLOAD_MARKERS.CONTENT_LINE}\n${body}`;
2019
+ const note = truncated ? `[Context limited to ${REVIEW_INPUT_LIMITS.MAX_REQUESTED_CONTEXT_LINES} lines.]\n` : "";
2020
+ return `${SEMANTIC_DIFF_PAYLOAD_MARKERS.FILE_LINE_PREFIX}${relativePath}\n${SEMANTIC_DIFF_PAYLOAD_MARKERS.CONTENT_LINE}\n${body}${note}`;
2004
2021
  }
2005
2022
  function validateFilesArray(paths) {
2006
2023
  if (paths.length === 0) return {
@@ -2061,7 +2078,8 @@ async function assertReadableSourceFile(workspaceRoot, relativePath, absolutePat
2061
2078
  };
2062
2079
  return {
2063
2080
  ok: true,
2064
- nextTotalBytes
2081
+ nextTotalBytes,
2082
+ canonicalPath: canonical.canonicalPath
2065
2083
  };
2066
2084
  }
2067
2085
  async function readSourceFileContent(absolutePath, relativePath) {
@@ -2104,9 +2122,9 @@ async function buildSemanticDiffFromSourceFiles(workspaceRoot, relativePaths) {
2104
2122
  const sizeCheck = await assertReadableSourceFile(workspaceRoot, relativePath, pathResult.absolutePath, totalBytes);
2105
2123
  if (!sizeCheck.ok) return sizeCheck.result;
2106
2124
  totalBytes = sizeCheck.nextTotalBytes;
2107
- const read = await readSourceFileContent(pathResult.absolutePath, relativePath);
2125
+ const read = await readSourceFileContent(sizeCheck.canonicalPath, relativePath);
2108
2126
  if (!read.ok) return read.result;
2109
- blocks.push(formatFileBlock(relativePath, read.content));
2127
+ blocks.push(formatSemanticDiffFileBlock(relativePath, read.content));
2110
2128
  filesLoaded.push(relativePath);
2111
2129
  }
2112
2130
  return {
@@ -2120,32 +2138,27 @@ async function buildSemanticDiffFromSourceFiles(workspaceRoot, relativePaths) {
2120
2138
  /**
2121
2139
  * OpenAI API integration.
2122
2140
  */
2123
- const ROLE_MAP$1 = {
2124
- user: "user",
2125
- assistant: "assistant",
2126
- system: "system"
2127
- };
2128
- function toOpenAIMessages$1(messages) {
2141
+ function toOpenAIInput(messages) {
2129
2142
  return messages.map((m) => ({
2130
- role: ROLE_MAP$1[m.role],
2143
+ role: m.role,
2131
2144
  content: m.content
2132
2145
  }));
2133
2146
  }
2134
2147
  function createOpenAIProvider(apiKey, model) {
2135
2148
  const client = new OpenAI({ apiKey });
2136
2149
  return { async complete(messages) {
2137
- const completion = await client.chat.completions.create({
2150
+ const response = await client.responses.create({
2138
2151
  model,
2139
- max_completion_tokens: LLM_MAX_TOKENS,
2140
- temperature: .3,
2141
- messages: toOpenAIMessages$1(messages)
2152
+ max_output_tokens: LLM_MAX_TOKENS,
2153
+ input: toOpenAIInput(messages)
2142
2154
  });
2155
+ const usage = response.usage ? {
2156
+ promptTokens: response.usage.input_tokens,
2157
+ completionTokens: response.usage.output_tokens
2158
+ } : void 0;
2143
2159
  return {
2144
- content: completion.choices[0]?.message?.content ?? "",
2145
- usage: completion.usage ? {
2146
- promptTokens: completion.usage.prompt_tokens,
2147
- completionTokens: completion.usage.completion_tokens
2148
- } : void 0
2160
+ content: response.output_text,
2161
+ usage
2149
2162
  };
2150
2163
  } };
2151
2164
  }
@@ -2472,9 +2485,11 @@ function createOpenCodeProvider(apiKey, model, plan = OPENCODE_PLANS.ZEN) {
2472
2485
  */
2473
2486
  var CliExecutionError = class extends Error {
2474
2487
  stdout;
2475
- constructor(message, stdout) {
2488
+ code;
2489
+ constructor(message, stdout, code) {
2476
2490
  super(message);
2477
2491
  this.stdout = stdout;
2492
+ this.code = code;
2478
2493
  this.name = "CliExecutionError";
2479
2494
  }
2480
2495
  };
@@ -2547,8 +2562,9 @@ function runCli({ command, args, cwd, input, timeoutMs, label, env, keepCredenti
2547
2562
  stderr = keepTail(stderr, chunk, CLI_PROVIDER_MAX_STDERR_BYTES);
2548
2563
  });
2549
2564
  child.on("error", (error) => {
2550
- const detail = error.code === "ENOENT" ? `Install ${label} or set its executable path in the MCP environment.` : error.message;
2551
- finish(new CliExecutionError(`${label} CLI could not be started. ${detail}`, stdout.toString("utf8")));
2565
+ const errorCode = error.code;
2566
+ const detail = errorCode === "ENOENT" ? `Install ${label} or set its executable path in the MCP environment.` : error.message;
2567
+ finish(new CliExecutionError(`${label} CLI could not be started. ${detail}`, stdout.toString("utf8"), errorCode));
2552
2568
  });
2553
2569
  child.on("close", (code, signal) => {
2554
2570
  if (failure) {
@@ -2573,6 +2589,18 @@ function runCli({ command, args, cwd, input, timeoutMs, label, env, keepCredenti
2573
2589
  child.stdin.end(input);
2574
2590
  });
2575
2591
  }
2592
+ async function runCliWithFallback(options, fallbackCommands = []) {
2593
+ const commands = [options.command, ...fallbackCommands];
2594
+ for (const [index, command] of commands.entries()) try {
2595
+ return await runCli({
2596
+ ...options,
2597
+ command
2598
+ });
2599
+ } catch (error) {
2600
+ if (!(error instanceof CliExecutionError && error.code === "ENOENT" && index < commands.length - 1)) throw error;
2601
+ }
2602
+ throw new Error(`${options.label} CLI could not be started.`);
2603
+ }
2576
2604
  function parseCodexOutput(stdout) {
2577
2605
  const messages = [];
2578
2606
  for (const line of stdout.split(/\r?\n/)) {
@@ -2833,14 +2861,14 @@ async function completeCursorAgent(options, cwd, input) {
2833
2861
  "json",
2834
2862
  ...modelArgs(options.model, "--model")
2835
2863
  ];
2836
- return { content: parseJsonResult(await runCli({
2864
+ return { content: parseJsonResult(await runCliWithFallback({
2837
2865
  command: options.command,
2838
2866
  args,
2839
2867
  cwd,
2840
2868
  input,
2841
2869
  timeoutMs: options.timeoutMs,
2842
2870
  label: options.label
2843
- }), options.label) };
2871
+ }, options.fallbackCommands), options.label) };
2844
2872
  }
2845
2873
  async function deleteOpenCodeSession(options, cwd, env, sessionId) {
2846
2874
  await runCli({
@@ -2954,13 +2982,14 @@ function createClaudeCodeProvider(command, model, timeoutMs) {
2954
2982
  timeoutMs
2955
2983
  });
2956
2984
  }
2957
- function createCursorAgentProvider(command, model, timeoutMs) {
2985
+ function createCursorAgentProvider(command, model, timeoutMs, allowLegacyFallback = command === CLI_DEFAULT_COMMANDS[PROVIDERS.CURSOR_AGENT]) {
2958
2986
  return createCliProvider({
2959
2987
  id: PROVIDERS.CURSOR_AGENT,
2960
2988
  label: "Cursor Agent",
2961
2989
  command,
2962
2990
  model,
2963
- timeoutMs
2991
+ timeoutMs,
2992
+ fallbackCommands: allowLegacyFallback ? [CURSOR_AGENT_LEGACY_COMMAND] : []
2964
2993
  });
2965
2994
  }
2966
2995
  function createOpenCodeCliProvider(command, model, timeoutMs) {
@@ -2974,40 +3003,123 @@ function createOpenCodeCliProvider(command, model, timeoutMs) {
2974
3003
  }
2975
3004
  //#endregion
2976
3005
  //#region src/llm/index.ts
3006
+ /**
3007
+ * LLM provider factory and local CLI auto-detection.
3008
+ */
3009
+ const AUTO_DETECT_CLI_ORDER = [
3010
+ PROVIDERS.CODEX_CLI,
3011
+ PROVIDERS.CLAUDE_CODE,
3012
+ PROVIDERS.CURSOR_AGENT,
3013
+ PROVIDERS.OPENCODE_CLI
3014
+ ];
3015
+ function configuredCliPath(id, config) {
3016
+ switch (id) {
3017
+ case PROVIDERS.CODEX_CLI: return config.codexCliPath;
3018
+ case PROVIDERS.CLAUDE_CODE: return config.claudeCodeCliPath;
3019
+ case PROVIDERS.CURSOR_AGENT: return config.cursorAgentCliPath;
3020
+ case PROVIDERS.OPENCODE_CLI: return config.opencodeCliPath;
3021
+ }
3022
+ }
3023
+ function commandCandidates(id, config) {
3024
+ const configuredPath = configuredCliPath(id, config);
3025
+ if (configuredPath) return [configuredPath];
3026
+ if (id === PROVIDERS.CURSOR_AGENT) return [CLI_DEFAULT_COMMANDS[id], CURSOR_AGENT_LEGACY_COMMAND];
3027
+ return [CLI_DEFAULT_COMMANDS[id]];
3028
+ }
3029
+ function commandSearchBases(command) {
3030
+ if (isAbsolute(command) || command.includes("/") || command.includes("\\")) return [resolve(command)];
3031
+ return (process.env.PATH ?? process.env.Path ?? "").split(delimiter).filter(Boolean).map((dir) => join(dir, command));
3032
+ }
3033
+ function executableExtensions(command, isWindows) {
3034
+ if (!isWindows || extname(command)) return [""];
3035
+ return (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter((extension) => [".COM", ".EXE"].includes(extension.toUpperCase()));
3036
+ }
3037
+ function canExecute(path, isWindows) {
3038
+ try {
3039
+ accessSync(path, isWindows ? constants.F_OK : constants.X_OK);
3040
+ return true;
3041
+ } catch {
3042
+ return false;
3043
+ }
3044
+ }
3045
+ function findExecutable(command) {
3046
+ const isWindows = process.platform === "win32";
3047
+ const bases = commandSearchBases(command);
3048
+ const extensions = executableExtensions(command, isWindows);
3049
+ for (const base of bases) for (const extension of extensions) {
3050
+ const candidate = extension ? `${base}${extension}` : base;
3051
+ if (canExecute(candidate, isWindows)) return candidate;
3052
+ }
3053
+ }
3054
+ function hasOpenCodeModel(config) {
3055
+ const model = config.criticModel;
3056
+ const separator = model?.indexOf("/") ?? -1;
3057
+ return Boolean(model && separator > 0 && separator < model.length - 1);
3058
+ }
3059
+ function detectLocalCli(config) {
3060
+ const configuredOrder = AUTO_DETECT_CLI_ORDER.filter((id) => configuredCliPath(id, config));
3061
+ const defaultOrder = AUTO_DETECT_CLI_ORDER.filter((id) => !configuredCliPath(id, config));
3062
+ const openCodeHasExplicitModel = hasOpenCodeModel(config) && defaultOrder.includes(PROVIDERS.OPENCODE_CLI);
3063
+ const preferredOrder = [
3064
+ ...configuredOrder,
3065
+ ...openCodeHasExplicitModel ? [PROVIDERS.OPENCODE_CLI] : [],
3066
+ ...defaultOrder.filter((id) => !openCodeHasExplicitModel || id !== PROVIDERS.OPENCODE_CLI)
3067
+ ];
3068
+ for (const id of preferredOrder) {
3069
+ if (id === PROVIDERS.OPENCODE_CLI && !hasOpenCodeModel(config)) continue;
3070
+ for (const candidate of commandCandidates(id, config)) {
3071
+ const command = findExecutable(candidate);
3072
+ if (command) return {
3073
+ id,
3074
+ command
3075
+ };
3076
+ }
3077
+ }
3078
+ }
3079
+ const providerLabels = {
3080
+ [PROVIDERS.OPENAI]: "OpenAI",
3081
+ [PROVIDERS.ANTHROPIC]: "Anthropic",
3082
+ [PROVIDERS.GOOGLE]: "Google Gemini",
3083
+ [PROVIDERS.MINIMAX]: "MiniMax",
3084
+ [PROVIDERS.OPENCODE]: "OpenCode",
3085
+ [PROVIDERS.CODEX_CLI]: "Codex CLI",
3086
+ [PROVIDERS.CLAUDE_CODE]: "Claude Code",
3087
+ [PROVIDERS.CURSOR_AGENT]: "Cursor Agent",
3088
+ [PROVIDERS.OPENCODE_CLI]: "OpenCode CLI"
3089
+ };
3090
+ function isCliProvider(id) {
3091
+ return AUTO_DETECT_CLI_ORDER.includes(id);
3092
+ }
3093
+ function getProviderLabel(id) {
3094
+ return providerLabels[id];
3095
+ }
2977
3096
  function createLLMProvider(config) {
2978
- const model = getEffectiveModel(config);
2979
- const factory = {
2980
- [PROVIDERS.OPENAI]: () => {
2981
- const key = config.openaiApiKey;
2982
- if (!key) return null;
2983
- return createOpenAIProvider(key, model);
2984
- },
2985
- [PROVIDERS.ANTHROPIC]: () => {
2986
- const key = config.anthropicApiKey;
2987
- if (!key) return null;
2988
- return createAnthropicProvider(key, model);
2989
- },
2990
- [PROVIDERS.GOOGLE]: () => {
2991
- const key = config.googleApiKey;
2992
- if (!key) return null;
2993
- return createGoogleProvider(key, model);
2994
- },
2995
- [PROVIDERS.MINIMAX]: () => {
2996
- const key = config.minimaxApiKey;
2997
- if (!key) return null;
2998
- return createMiniMaxProvider(key, model);
2999
- },
3000
- [PROVIDERS.OPENCODE]: () => {
3001
- const key = config.opencodeApiKey;
3002
- if (!key) return null;
3003
- return createOpenCodeProvider(key, model, config.opencodePlan);
3004
- },
3005
- [PROVIDERS.CODEX_CLI]: () => createCodexCliProvider(config.codexCliPath ?? CLI_DEFAULT_COMMANDS[PROVIDERS.CODEX_CLI], model, config.criticCliTimeoutMs),
3006
- [PROVIDERS.CLAUDE_CODE]: () => createClaudeCodeProvider(config.claudeCodeCliPath ?? CLI_DEFAULT_COMMANDS[PROVIDERS.CLAUDE_CODE], model, config.criticCliTimeoutMs),
3007
- [PROVIDERS.CURSOR_AGENT]: () => createCursorAgentProvider(config.cursorAgentCliPath ?? CLI_DEFAULT_COMMANDS[PROVIDERS.CURSOR_AGENT], model, config.criticCliTimeoutMs),
3008
- [PROVIDERS.OPENCODE_CLI]: () => createOpenCodeCliProvider(config.opencodeCliPath ?? CLI_DEFAULT_COMMANDS[PROVIDERS.OPENCODE_CLI], model, config.criticCliTimeoutMs)
3009
- }[config.criticProvider];
3010
- return factory ? factory() : null;
3097
+ const detected = config.criticProvider ? void 0 : detectLocalCli(config);
3098
+ const providerId = config.criticProvider ?? detected?.id ?? PROVIDERS.OPENAI;
3099
+ const autoDetected = !config.criticProvider && detected !== void 0;
3100
+ const model = getEffectiveModel(config, providerId);
3101
+ const command = (id, configuredPath) => {
3102
+ if (detected && id === detected.id) return detected.command;
3103
+ return configuredPath ?? CLI_DEFAULT_COMMANDS[id];
3104
+ };
3105
+ const provider = {
3106
+ [PROVIDERS.OPENAI]: () => config.openaiApiKey ? createOpenAIProvider(config.openaiApiKey, model) : null,
3107
+ [PROVIDERS.ANTHROPIC]: () => config.anthropicApiKey ? createAnthropicProvider(config.anthropicApiKey, model) : null,
3108
+ [PROVIDERS.GOOGLE]: () => config.googleApiKey ? createGoogleProvider(config.googleApiKey, model) : null,
3109
+ [PROVIDERS.MINIMAX]: () => config.minimaxApiKey ? createMiniMaxProvider(config.minimaxApiKey, model) : null,
3110
+ [PROVIDERS.OPENCODE]: () => config.opencodeApiKey ? createOpenCodeProvider(config.opencodeApiKey, model, config.opencodePlan) : null,
3111
+ [PROVIDERS.CODEX_CLI]: () => createCodexCliProvider(command(PROVIDERS.CODEX_CLI, config.codexCliPath), model, config.criticCliTimeoutMs),
3112
+ [PROVIDERS.CLAUDE_CODE]: () => createClaudeCodeProvider(command(PROVIDERS.CLAUDE_CODE, config.claudeCodeCliPath), model, config.criticCliTimeoutMs),
3113
+ [PROVIDERS.CURSOR_AGENT]: () => createCursorAgentProvider(command(PROVIDERS.CURSOR_AGENT, config.cursorAgentCliPath), model, config.criticCliTimeoutMs, !config.cursorAgentCliPath),
3114
+ [PROVIDERS.OPENCODE_CLI]: () => createOpenCodeCliProvider(command(PROVIDERS.OPENCODE_CLI, config.opencodeCliPath), model, config.criticCliTimeoutMs)
3115
+ }[providerId]();
3116
+ if (!provider) return null;
3117
+ return {
3118
+ ...provider,
3119
+ providerId,
3120
+ autoDetected: autoDetected && isCliProvider(providerId),
3121
+ ...autoDetected && detected ? { providerCommand: detected.command } : {}
3122
+ };
3011
3123
  }
3012
3124
  //#endregion
3013
3125
  //#region src/summarizer/parse-semantic-diff.ts
@@ -3345,6 +3457,57 @@ function parseVerificationsFromResponse(response, existingConcerns) {
3345
3457
  }
3346
3458
  return verifications;
3347
3459
  }
3460
+ function splitRequestedPath(part) {
3461
+ let remainder = part.trim();
3462
+ if (!remainder) return null;
3463
+ if (remainder.startsWith("\"") || remainder.startsWith("'")) {
3464
+ const quote = remainder[0];
3465
+ const closingQuote = remainder.indexOf(quote, 1);
3466
+ if (closingQuote < 2) return null;
3467
+ const filePath = remainder.slice(1, closingQuote);
3468
+ remainder = remainder.slice(closingQuote + 1).trim();
3469
+ return {
3470
+ filePath,
3471
+ remainder
3472
+ };
3473
+ }
3474
+ const firstSpace = remainder.search(/\s/);
3475
+ const filePath = firstSpace === -1 ? remainder : remainder.slice(0, firstSpace);
3476
+ remainder = firstSpace === -1 ? "" : remainder.slice(firstSpace).trim();
3477
+ return {
3478
+ filePath,
3479
+ remainder
3480
+ };
3481
+ }
3482
+ function parseFileRequestPart(part) {
3483
+ const split = splitRequestedPath(part);
3484
+ if (!split) return null;
3485
+ let { filePath, remainder } = split;
3486
+ const pathRange = /:(\d+(?:-\d+)?)$/.exec(filePath);
3487
+ const leadingRange = /^:(\d+(?:-\d+)?)(?:\s+|$)/.exec(remainder);
3488
+ const lineRange = pathRange?.[1] ?? leadingRange?.[1];
3489
+ if (pathRange) filePath = filePath.slice(0, -pathRange[0].length);
3490
+ else if (leadingRange) remainder = remainder.slice(leadingRange[0].length).trim();
3491
+ if (!filePath) return null;
3492
+ return {
3493
+ filePath,
3494
+ lineRange,
3495
+ reason: remainder || void 0
3496
+ };
3497
+ }
3498
+ function parseRequestsFromResponse(response) {
3499
+ const requests = [];
3500
+ const requestRegex = /\bREQUEST:\s*(.+)/gi;
3501
+ let match;
3502
+ while ((match = requestRegex.exec(response)) !== null) {
3503
+ const parts = match[1].trim().split(",").map((p) => p.trim());
3504
+ for (const part of parts) {
3505
+ const req = parseFileRequestPart(part);
3506
+ if (req) requests.push(req);
3507
+ }
3508
+ }
3509
+ return requests;
3510
+ }
3348
3511
  function hasRequestBlocks(response) {
3349
3512
  if (response.toUpperCase().includes(RESPONSE_BLOCKS.REQUEST)) return true;
3350
3513
  const upper = response.toUpperCase();
@@ -3578,11 +3741,138 @@ function computeSemanticDiffLineHints(semanticDiff) {
3578
3741
  return hints;
3579
3742
  }
3580
3743
  //#endregion
3744
+ //#region src/summarizer/read-changed-files.ts
3745
+ /**
3746
+ * Read contents of changed files with truncation.
3747
+ * Used for relevance filtering - only read files that were actually changed.
3748
+ * Includes auto-import expansion for TypeScript/JavaScript files.
3749
+ */
3750
+ async function readChangedFileContent(workspaceRoot, filePath, maxLines = CONTEXT_LIMITS.MAX_LINES_PER_FILE, lineRange) {
3751
+ const pathResult = resolveSafePathInWorkspace(workspaceRoot, filePath, WORKSPACE_PATH_KIND.SOURCE_FILE);
3752
+ if (!pathResult.ok) return {
3753
+ path: filePath,
3754
+ content: CONTEXT_LIMITS.FILE_UNREADABLE,
3755
+ truncated: false,
3756
+ error: pathResult.message
3757
+ };
3758
+ try {
3759
+ const canonical = await verifyCanonicalPathUnderWorkspace(workspaceRoot, pathResult.absolutePath, WORKSPACE_PATH_KIND.SOURCE_FILE);
3760
+ if (!canonical.ok) return {
3761
+ path: filePath,
3762
+ content: CONTEXT_LIMITS.FILE_UNREADABLE,
3763
+ truncated: false,
3764
+ error: canonical.message
3765
+ };
3766
+ const fileStat = await stat(canonical.canonicalPath);
3767
+ if (!fileStat.isFile()) return {
3768
+ path: filePath,
3769
+ content: CONTEXT_LIMITS.FILE_UNREADABLE,
3770
+ truncated: false,
3771
+ error: "Not a regular file."
3772
+ };
3773
+ if (fileStat.size > SEMANTIC_DIFF_SOURCE_FILES.MAX_BYTES_PER_FILE) return {
3774
+ path: filePath,
3775
+ content: CONTEXT_LIMITS.FILE_UNREADABLE,
3776
+ truncated: false,
3777
+ error: "File exceeds size limit."
3778
+ };
3779
+ const lines = (await readFile(canonical.canonicalPath, "utf-8")).split("\n");
3780
+ if (lineRange) {
3781
+ const range = /^(\d+)(?:-(\d+))?$/.exec(lineRange);
3782
+ const start = Number(range?.[1]);
3783
+ const end = range?.[2] ? Number(range[2]) : start;
3784
+ if (!range || start < 1 || end < start) return {
3785
+ path: filePath,
3786
+ content: CONTEXT_LIMITS.FILE_UNREADABLE,
3787
+ truncated: false,
3788
+ error: "Invalid line range."
3789
+ };
3790
+ let startIdx = Math.max(0, start - 1);
3791
+ let endIdx = Math.min(lines.length, end);
3792
+ if (!range[2]) {
3793
+ startIdx = Math.max(0, startIdx - 50);
3794
+ endIdx = Math.min(lines.length, startIdx + 100);
3795
+ }
3796
+ const requestedEndIdx = endIdx;
3797
+ endIdx = Math.min(endIdx, startIdx + REVIEW_INPUT_LIMITS.MAX_REQUESTED_CONTEXT_LINES);
3798
+ return {
3799
+ path: filePath,
3800
+ content: lines.slice(startIdx, endIdx).map((l, i) => `${startIdx + i + 1} | ${l}`).join("\n"),
3801
+ truncated: endIdx < requestedEndIdx
3802
+ };
3803
+ }
3804
+ return {
3805
+ path: filePath,
3806
+ content: lines.slice(0, maxLines).join("\n"),
3807
+ truncated: lines.length > maxLines
3808
+ };
3809
+ } catch {
3810
+ return {
3811
+ path: filePath,
3812
+ content: CONTEXT_LIMITS.FILE_UNREADABLE,
3813
+ truncated: false,
3814
+ error: "File could not be read."
3815
+ };
3816
+ }
3817
+ }
3818
+ async function readRequestedFiles(workspaceRoot, response) {
3819
+ const requests = parseRequestsFromResponse(response);
3820
+ if (requests.length > SEMANTIC_DIFF_SOURCE_FILES.MAX_COUNT) throw new Error(`Critic requested more than ${SEMANTIC_DIFF_SOURCE_FILES.MAX_COUNT} files.`);
3821
+ const uniqueRequests = [...new Map(requests.map((request) => [`${request.filePath}:${request.lineRange ?? ""}`, request])).values()];
3822
+ const blocks = [];
3823
+ const files = /* @__PURE__ */ new Set();
3824
+ let totalChars = 0;
3825
+ for (const request of uniqueRequests) {
3826
+ if (request.filePath.length > REVIEW_INPUT_LIMITS.MAX_PATH_CHARS) throw new Error(`Critic requested a path longer than ${REVIEW_INPUT_LIMITS.MAX_PATH_CHARS} characters.`);
3827
+ const file = await readChangedFileContent(workspaceRoot, request.filePath, REVIEW_INPUT_LIMITS.MAX_REQUESTED_CONTEXT_LINES, request.lineRange);
3828
+ if (file.error) throw new Error(`Could not read requested file ${request.filePath}: ${file.error}`);
3829
+ const block = formatSemanticDiffFileBlock(request.filePath, file.content, file.truncated);
3830
+ const nextLength = totalChars + block.length;
3831
+ if (nextLength > REVIEW_INPUT_LIMITS.MAX_SEMANTIC_DIFF_CHARS) throw new Error(`Critic requested context exceeds ${REVIEW_INPUT_LIMITS.MAX_SEMANTIC_DIFF_CHARS} characters.`);
3832
+ blocks.push(block);
3833
+ totalChars = nextLength;
3834
+ files.add(request.filePath);
3835
+ }
3836
+ return {
3837
+ semanticDiff: blocks.join(SEMANTIC_DIFF_PAYLOAD_MARKERS.FILE_BLOCK_SEPARATOR),
3838
+ filesAnalyzed: files.size
3839
+ };
3840
+ }
3841
+ //#endregion
3581
3842
  //#region src/tools/submit-phase-review.ts
3582
3843
  /**
3583
3844
  * MCP tool: submit_phase_review
3584
3845
  * Implementer reports phase completion; triggers Critic review.
3585
3846
  */
3847
+ let autoProviderNoticeSent = false;
3848
+ function addAutoProviderNotice(result, provider, providerWasInvoked) {
3849
+ if (autoProviderNoticeSent || !providerWasInvoked || !provider.autoDetected || !provider.providerId || !provider.providerCommand) return result;
3850
+ const command = basename(provider.providerCommand);
3851
+ const notice = `FYI: CRITIC_PROVIDER was not set; Vibe-Gate found ${getProviderLabel(provider.providerId)} (${command}) and is using it for this review.`;
3852
+ const [first, ...rest] = result.content;
3853
+ if (!first) return result;
3854
+ let payload;
3855
+ try {
3856
+ payload = JSON.parse(first.text);
3857
+ } catch {
3858
+ payload = { result: first.text };
3859
+ }
3860
+ const notifiedPayload = typeof payload === "object" && payload !== null && !Array.isArray(payload) ? {
3861
+ ...payload,
3862
+ providerNotice: notice
3863
+ } : {
3864
+ result: payload,
3865
+ providerNotice: notice
3866
+ };
3867
+ autoProviderNoticeSent = true;
3868
+ return {
3869
+ ...result,
3870
+ content: [{
3871
+ ...first,
3872
+ text: JSON.stringify(notifiedPayload)
3873
+ }, ...rest]
3874
+ };
3875
+ }
3586
3876
  /**
3587
3877
  * Build a structured history summary from prior rounds.
3588
3878
  *
@@ -3620,15 +3910,16 @@ function buildHistorySummary(history, maxTokens = CRITIC_THRESHOLDS.HISTORY_SUMM
3620
3910
  }
3621
3911
  return parts.join("\n\n---\n\n");
3622
3912
  }
3623
- function buildUserContent(args, historySummary) {
3624
- const parts = [`Phase: ${args.phaseId}`, `<developer_report>\n${args.report}\n</developer_report>`];
3913
+ function buildUserContent(args, historySummary, requestedContext = "") {
3914
+ const parts = [`Phase: ${args.phaseId}`];
3625
3915
  if (historySummary) parts.unshift(`Previous rounds:\n${historySummary}\n`);
3626
- if (args.semanticDiff?.trim()) parts.push(`## CHANGED FILES (MCP resolved payload from files[], semanticDiffPath, or inline semanticDiff — this is the review corpus):\n<code_content>\n${args.semanticDiff.trim()}\n</code_content>`);
3916
+ parts.push(`<developer_report>\n${args.report}\n</developer_report>`);
3917
+ const codeContent = [args.semanticDiff, requestedContext].filter(Boolean).join("\n\n");
3918
+ parts.push(`## CHANGED FILES\n<code_content>\n${codeContent}\n</code_content>`);
3627
3919
  if (args.dependencies?.length) parts.push(`Dependencies: ${args.dependencies.join(", ")}`);
3628
- debugLog(`buildUserContent - semanticDiff length: ${args.semanticDiff?.length ?? 0}`);
3629
3920
  return parts.join("\n\n");
3630
3921
  }
3631
- async function buildContextBlock(workspaceRoot, newDeps, semanticDiff, report) {
3922
+ async function buildContextBlock(workspaceRoot, newDeps, semanticDiff) {
3632
3923
  const [blueprint, pkgDeps] = await Promise.all([extractProjectBlueprint(workspaceRoot), parseDependencyListFromPackageJson(workspaceRoot).catch(() => ({
3633
3924
  dependencies: [],
3634
3925
  devDependencies: []
@@ -3637,46 +3928,12 @@ async function buildContextBlock(workspaceRoot, newDeps, semanticDiff, report) {
3637
3928
  const bloatWarn = newDeps.length >= DEPENDENCY_THRESHOLDS.BLOAT_WARNING_NEW_PACKAGES || totalDeps >= DEPENDENCY_THRESHOLDS.BLOAT_WARNING_TOTAL_DEPS;
3638
3929
  const parts = [`Project: ${blueprint.framework}. Structures: ${blueprint.structures.join(", ") || "none"}.`, `Current deps: ${totalDeps}. New/updated: ${newDeps.join(", ") || "none"}.`];
3639
3930
  if (bloatWarn) parts.push(BLOAT_WARNING_MESSAGE);
3640
- let filesAnalyzed = 0;
3641
- const combinedText = [semanticDiff, report].filter(Boolean).join("\n");
3642
- if (combinedText) {
3643
- filesAnalyzed = parseSemanticDiff(combinedText).filesChanged.length;
3644
- const contentBlock = buildContentBlockFromInput(semanticDiff, report);
3645
- parts.push(contentBlock);
3646
- }
3931
+ const filesAnalyzed = semanticDiff ? parseSemanticDiff(semanticDiff).filesChanged.length : 0;
3647
3932
  return {
3648
3933
  context: parts.join(" "),
3649
3934
  filesAnalyzed
3650
3935
  };
3651
3936
  }
3652
- function buildContentBlockFromInput(semanticDiff, report) {
3653
- const sections = [];
3654
- if (semanticDiff?.trim()) sections.push(`## CHANGED FILES (MCP resolved FILE:...CONTENT: payload from files[], semanticDiffPath, or inline semanticDiff):\n${semanticDiff.trim()}`);
3655
- if (report?.trim()) sections.push(`## DEVELOPER REPORT:\n${report.trim()}`);
3656
- return sections.join("\n\n");
3657
- }
3658
- async function appendRequestedFilesToContext(workspaceRoot, originalContext, criticResponse) {
3659
- if (!hasRequestBlocks(criticResponse)) return {
3660
- context: originalContext,
3661
- filesAnalyzed: 0
3662
- };
3663
- return {
3664
- context: `${originalContext}\n\n## CRITIC REQUESTED MORE CONTEXT\nNote: Resubmit with files[] (or semanticDiffPath / inline semanticDiff) covering every path the Critic needs. MCP reads those workspace paths when you provide them.`,
3665
- filesAnalyzed: 0
3666
- };
3667
- }
3668
- async function appendPreviousRoundsFilesToContext(workspaceRoot, originalContext, session) {
3669
- const previousContents = [];
3670
- for (const h of session.history) if (h.semanticDiff?.trim()) previousContents.push(`--- Round ${h.round} ---\n${h.semanticDiff.trim()}`);
3671
- if (previousContents.length === 0) return {
3672
- context: originalContext,
3673
- filesAnalyzed: 0
3674
- };
3675
- return {
3676
- context: `${originalContext}\n\n## PREVIOUS ROUNDS CONTENT (preserved):\n${previousContents.join("\n\n")}`,
3677
- filesAnalyzed: 0
3678
- };
3679
- }
3680
3937
  function toTextContent(json) {
3681
3938
  return {
3682
3939
  type: "text",
@@ -3720,18 +3977,7 @@ async function writeCaseFile(workspaceRoot, caseFile) {
3720
3977
  await writeFile(path, JSON.stringify(caseFile, null, 2), "utf-8");
3721
3978
  }
3722
3979
  async function checkDeadlockEarly(workspaceRoot, args, session, semanticDiffHints) {
3723
- const round = args.round ?? 1;
3724
- if (round > CONFLICT_LOOP.MAX_ROUNDS) {
3725
- await clearSession(workspaceRoot);
3726
- await updateConflictCount(workspaceRoot, 1);
3727
- const caseFile = buildCaseFile(args.phaseId, round, []);
3728
- await writeCaseFile(workspaceRoot, caseFile);
3729
- return { content: [toTextContentWithHints({
3730
- ...caseFile,
3731
- filesAnalyzed: 0
3732
- }, semanticDiffHints)] };
3733
- }
3734
- if (round > 1 && session?.phaseId === args.phaseId && session.round >= CONFLICT_LOOP.MAX_ROUNDS) {
3980
+ if ((args.round ?? 1) > 1 && session?.phaseId === args.phaseId && session.round >= CONFLICT_LOOP.MAX_ROUNDS) {
3735
3981
  await clearSession(workspaceRoot);
3736
3982
  await updateConflictCount(workspaceRoot, 1);
3737
3983
  const caseFile = buildCaseFile(args.phaseId, session.round, session.history);
@@ -3800,28 +4046,26 @@ async function runCriticReview(workspaceRoot, args, session, config, provider) {
3800
4046
  }
3801
4047
  const round = args.round ?? 1;
3802
4048
  const personaPrompt = getPersonaPrompt(config.criticPersona);
3803
- const model = getEffectiveModel(config);
4049
+ const model = getEffectiveModel(config, provider.providerId);
3804
4050
  const [status, contextBlockResult, rules, preferencesLog] = await Promise.all([
3805
4051
  getStatus(workspaceRoot),
3806
- buildContextBlock(workspaceRoot, args.dependencies ?? [], args.semanticDiff, args.report),
4052
+ buildContextBlock(workspaceRoot, args.dependencies ?? [], args.semanticDiff),
3807
4053
  loadRules(workspaceRoot),
3808
4054
  readPreferencesLog(workspaceRoot)
3809
4055
  ]);
3810
- let enrichedContextBlock = contextBlockResult.context;
4056
+ const enrichedContextBlock = contextBlockResult.context;
3811
4057
  let totalFilesRead = contextBlockResult.filesAnalyzed;
4058
+ let requestedContext = "";
3812
4059
  if (round > 1 && session?.history && session.history.length > 0) {
3813
4060
  const previousCriticResponse = session.history[session.history.length - 1].criticResponse;
3814
4061
  if (hasRequestBlocks(previousCriticResponse)) {
3815
- const requestedBlockResult = await appendRequestedFilesToContext(workspaceRoot, enrichedContextBlock, previousCriticResponse);
3816
- enrichedContextBlock = requestedBlockResult.context;
4062
+ const requestedBlockResult = await readRequestedFiles(workspaceRoot, previousCriticResponse);
4063
+ requestedContext = requestedBlockResult.semanticDiff;
3817
4064
  totalFilesRead += requestedBlockResult.filesAnalyzed;
3818
4065
  }
3819
- const previousRoundsResult = await appendPreviousRoundsFilesToContext(workspaceRoot, enrichedContextBlock, session);
3820
- enrichedContextBlock = previousRoundsResult.context;
3821
- totalFilesRead += previousRoundsResult.filesAnalyzed;
3822
4066
  }
3823
4067
  const rulesBlock = formatRulesForPrompt(rules);
3824
- const userContent = buildUserContent(args, session?.phaseId === args.phaseId ? buildHistorySummary(session.history) : void 0);
4068
+ const userContent = buildUserContent(args, session?.phaseId === args.phaseId ? buildHistorySummary(session.history) : void 0, requestedContext);
3825
4069
  const messages = [{
3826
4070
  role: "system",
3827
4071
  content: buildSystemPrompt(personaPrompt, status, enrichedContextBlock, rulesBlock, preferencesLog, round)
@@ -3849,7 +4093,6 @@ async function runCriticReview(workspaceRoot, args, session, config, provider) {
3849
4093
  roundData: {
3850
4094
  round,
3851
4095
  report: args.report,
3852
- semanticDiff: args.semanticDiff,
3853
4096
  verdict: String(verdict),
3854
4097
  criticResponse: response.content,
3855
4098
  concerns: concerns.length > 0 ? concerns : void 0,
@@ -3894,7 +4137,7 @@ async function handleRejectOrContinue(result, session, workspaceRoot, args, sema
3894
4137
  const noActivePriorConcerns = !priorConcerns.some((c) => c.reviewStatus === CONCERN_REVIEW_STATUS.REVIEWED_VALID);
3895
4138
  if (allPriorReviewed && noActivePriorConcerns) return handleAcceptVerdict(result, workspaceRoot, args, semanticDiffHints);
3896
4139
  }
3897
- if (round > CONFLICT_LOOP.MAX_ROUNDS) {
4140
+ if (round >= CONFLICT_LOOP.MAX_ROUNDS) {
3898
4141
  await clearSession(workspaceRoot);
3899
4142
  await updateConflictCount(workspaceRoot, 1);
3900
4143
  const caseFile = buildCaseFile(args.phaseId, round, nextSession.history, String(result.verdict));
@@ -3926,17 +4169,17 @@ async function handleRejectOrContinue(result, session, workspaceRoot, args, sema
3926
4169
  * which advertises `properties: {}` to IDEs (agents then cannot discover `files` / `semanticDiffPath`).
3927
4170
  */
3928
4171
  const submitPhaseReviewFieldsSchema = z.object({
3929
- phaseId: z.string().describe("Phase identifier (e.g., phase-6-§1a or 1.1.1)"),
3930
- report: z.string().describe("Implementer report. MUST INCLUDE: 1. Specific file paths & line numbers. 2. What changed and why. 3. Confirmation that NO \"future solutions\" or \"TODOs\" remain (instant fixes only)."),
3931
- files: z.array(z.string()).optional().describe("PREFERRED. Workspace-relative source paths under VIBE_WORKSPACE_ROOT. MCP reads each file and builds FILE:...CONTENT: payload. Exactly one of: files | semanticDiffPath | semanticDiff. Max 10 files."),
3932
- semanticDiffPath: z.string().optional().describe("Workspace-relative path to a pre-built FILE:...CONTENT: payload file (raw or JSON {\"semanticDiff\":\"...\"}). Exactly one of: files | semanticDiffPath | semanticDiff."),
3933
- semanticDiff: z.string().optional().describe("Inline FILE:...CONTENT: payload. Prefer files[]. Exactly one of: files | semanticDiffPath | semanticDiff. NOT git diff."),
4172
+ phaseId: z.string().trim().min(1).max(REVIEW_INPUT_LIMITS.MAX_PHASE_ID_CHARS).describe("Phase identifier (e.g., phase-6-§1a or 1.1.1)"),
4173
+ report: z.string().trim().min(1).max(REVIEW_INPUT_LIMITS.MAX_REPORT_CHARS).describe("Implementer report. MUST INCLUDE: 1. Specific file paths & line numbers. 2. What changed and why. 3. Confirmation that NO \"future solutions\" or \"TODOs\" remain (instant fixes only)."),
4174
+ files: z.array(z.string().trim().min(1).max(REVIEW_INPUT_LIMITS.MAX_PATH_CHARS)).min(1).max(SEMANTIC_DIFF_SOURCE_FILES.MAX_COUNT).optional().describe(`PREFERRED. Workspace-relative source paths under VIBE_WORKSPACE_ROOT. MCP reads each file and builds FILE:...CONTENT: payload. Exactly one of: files | semanticDiffPath | semanticDiff. Max ${SEMANTIC_DIFF_SOURCE_FILES.MAX_COUNT} files.`),
4175
+ semanticDiffPath: z.string().trim().min(1).max(REVIEW_INPUT_LIMITS.MAX_PATH_CHARS).optional().describe("Workspace-relative path to a pre-built FILE:...CONTENT: payload file (raw or JSON {\"semanticDiff\":\"...\"}). Exactly one of: files | semanticDiffPath | semanticDiff."),
4176
+ semanticDiff: z.string().trim().min(1).max(REVIEW_INPUT_LIMITS.MAX_SEMANTIC_DIFF_CHARS).optional().describe("Inline FILE:...CONTENT: payload. Prefer files[]. Exactly one of: files | semanticDiffPath | semanticDiff. NOT git diff."),
3934
4177
  updateStatus: z.boolean().optional().describe("When false, ACCEPT does not write .vibe/status.json. Default: true except phaseIds with mcp-smoke- / vibe-gate-probe- prefixes."),
3935
- dependencies: z.array(z.string()).optional().describe("New/updated package names"),
3936
- round: z.number().optional().describe("Round number (1-3), default 1"),
4178
+ dependencies: z.array(z.string().trim().min(1).max(REVIEW_INPUT_LIMITS.MAX_DEPENDENCY_NAME_CHARS)).max(REVIEW_INPUT_LIMITS.MAX_DEPENDENCIES).optional().describe("New/updated package names"),
4179
+ round: z.number().int().min(1).max(CONFLICT_LOOP.MAX_ROUNDS).optional().describe(`Round number (1-${CONFLICT_LOOP.MAX_ROUNDS}), default 1`),
3937
4180
  logToDebt: z.object({
3938
- subject: z.string(),
3939
- rationale: z.string()
4181
+ subject: z.string().trim().min(1).max(REVIEW_INPUT_LIMITS.MAX_DEBT_SUBJECT_CHARS),
4182
+ rationale: z.string().trim().min(1).max(REVIEW_INPUT_LIMITS.MAX_DEBT_RATIONALE_CHARS)
3940
4183
  }).optional().describe("When DEBT verdict and Implementer accepts, log to DEBT.md")
3941
4184
  });
3942
4185
  function countPayloadSources(data) {
@@ -3955,7 +4198,7 @@ const submitPhaseReviewInputSchema = submitPhaseReviewFieldsSchema.superRefine((
3955
4198
  });
3956
4199
  const SUBMIT_PHASE_REVIEW_SCHEMA = {
3957
4200
  title: "Submit Phase Review",
3958
- description: "IDE AI (Implementer) submits a phase completion report for Critic review. Prefer files[] (workspace-relative paths; MCP reads disk and builds FILE:...CONTENT:). Alternatives: semanticDiffPath or inline semanticDiff — exactly one. Set updateStatus:false for connectivity probes.",
4201
+ description: "IDE AI (Implementer) submits a phase completion report for Critic review. Prefer files[] (workspace-relative paths; MCP reads disk and builds FILE:...CONTENT:). Alternatives: semanticDiffPath or inline semanticDiff — exactly one. If CRITIC_PROVIDER is unset, Vibe-Gate selects the first installed local CLI and includes providerNotice in the first result. Set updateStatus:false for connectivity probes.",
3959
4202
  inputSchema: submitPhaseReviewFieldsSchema
3960
4203
  };
3961
4204
  const INSUFFICIENT_REVIEW_GUIDANCE = `
@@ -4070,35 +4313,53 @@ async function handleAcceptVerdictFlow(reviewResult, session, workspaceRoot, arg
4070
4313
  if (session && session.concerns.length > 0 && !(allReviewed && noActiveConcernsLeft)) return handleAcceptWithUnverifiedConcerns(reviewResult, updatedSession ?? session, semanticDiffHints);
4071
4314
  return handleAcceptVerdict(reviewResult, workspaceRoot, args, semanticDiffHints);
4072
4315
  }
4073
- async function handleSubmitPhaseReview(rawArgs) {
4074
- const parsed = submitPhaseReviewInputSchema.safeParse(rawArgs);
4075
- if (!parsed.success) return { content: [toTextContent({
4076
- error: "Invalid submit_phase_review arguments",
4077
- issues: z.flattenError(parsed.error)
4078
- })] };
4079
- const input = parsed.data;
4080
- const config = loadConfig();
4081
- const provider = createLLMProvider(config);
4082
- if (!provider) return { content: [toTextContent({ error: ERROR_MESSAGES.NO_LLM_PROVIDER })] };
4083
- const workspaceRoot = getWorkspaceRoot();
4316
+ async function resolveSemanticDiffInput(workspaceRoot, input) {
4084
4317
  let semanticDiff;
4085
- if (input.files && input.files.some((f) => f.trim().length > 0)) {
4318
+ if (input.files) {
4086
4319
  const built = await buildSemanticDiffFromSourceFiles(workspaceRoot, input.files);
4087
- if (!built.ok) return { content: [toTextContent({
4320
+ if (!built.ok) return {
4321
+ ok: false,
4088
4322
  error: built.message,
4089
4323
  code: built.code
4090
- })] };
4324
+ };
4091
4325
  semanticDiff = built.semanticDiff;
4092
4326
  debugLog(`semanticDiff built from files[]: ${built.filesLoaded.join(", ")}`);
4093
- } else if (input.semanticDiffPath?.trim()) {
4094
- const loaded = await loadSemanticDiffFromWorkspacePath(workspaceRoot, input.semanticDiffPath.trim());
4095
- if (!loaded.ok) return { content: [toTextContent({
4327
+ } else if (input.semanticDiffPath) {
4328
+ const loaded = await loadSemanticDiffFromWorkspacePath(workspaceRoot, input.semanticDiffPath);
4329
+ if (!loaded.ok) return {
4330
+ ok: false,
4096
4331
  error: loaded.message,
4097
4332
  code: loaded.code
4098
- })] };
4333
+ };
4099
4334
  semanticDiff = loaded.semanticDiff;
4100
4335
  debugLog(`semanticDiff loaded from file: ${loaded.resolvedFromPath}`);
4101
4336
  } else semanticDiff = input.semanticDiff.trim();
4337
+ if (semanticDiff.length > REVIEW_INPUT_LIMITS.MAX_SEMANTIC_DIFF_CHARS) return {
4338
+ ok: false,
4339
+ error: `resolved semanticDiff exceeds the maximum size of ${REVIEW_INPUT_LIMITS.MAX_SEMANTIC_DIFF_CHARS} characters.`
4340
+ };
4341
+ return {
4342
+ ok: true,
4343
+ semanticDiff
4344
+ };
4345
+ }
4346
+ async function handleSubmitPhaseReview(rawArgs) {
4347
+ const parsed = submitPhaseReviewInputSchema.safeParse(rawArgs);
4348
+ if (!parsed.success) return { content: [toTextContent({
4349
+ error: "Invalid submit_phase_review arguments",
4350
+ issues: z.flattenError(parsed.error)
4351
+ })] };
4352
+ const input = parsed.data;
4353
+ const config = loadConfig();
4354
+ const provider = createLLMProvider(config);
4355
+ if (!provider) return { content: [toTextContent({ error: ERROR_MESSAGES.NO_LLM_PROVIDER })] };
4356
+ const workspaceRoot = getWorkspaceRoot();
4357
+ const resolution = await resolveSemanticDiffInput(workspaceRoot, input);
4358
+ if (!resolution.ok) return { content: [toTextContent({
4359
+ error: resolution.error,
4360
+ ...resolution.code ? { code: resolution.code } : {}
4361
+ })] };
4362
+ const { semanticDiff } = resolution;
4102
4363
  const args = {
4103
4364
  phaseId: input.phaseId,
4104
4365
  report: input.report,
@@ -4114,16 +4375,24 @@ async function handleSubmitPhaseReview(rawArgs) {
4114
4375
  const session = await readSession(workspaceRoot);
4115
4376
  const deadlockResult = await checkDeadlockEarly(workspaceRoot, args, session, semanticDiffHints);
4116
4377
  if (deadlockResult) return deadlockResult;
4378
+ let providerWasInvoked = false;
4379
+ const trackedProvider = {
4380
+ ...provider,
4381
+ async complete(messages) {
4382
+ providerWasInvoked = true;
4383
+ return provider.complete(messages);
4384
+ }
4385
+ };
4117
4386
  try {
4118
- return processVerdict(await runCriticReview(workspaceRoot, args, session, config, provider), session, workspaceRoot, args, round, semanticDiffHints);
4387
+ return addAutoProviderNotice(await processVerdict(await runCriticReview(workspaceRoot, args, session, config, trackedProvider), session, workspaceRoot, args, round, semanticDiffHints), provider, providerWasInvoked);
4119
4388
  } catch (err) {
4120
4389
  const errorMessage = getErrorMessage(err);
4121
4390
  const errorObj = err;
4122
- if (errorMessage.includes("401") || errorObj?.status === 401 || errorObj?.statusCode === 401) return { content: [toTextContent({
4391
+ if (!(provider.providerId !== void 0 && isCliProvider(provider.providerId)) && (errorMessage.includes("401") || errorObj?.status === 401 || errorObj?.statusCode === 401)) return addAutoProviderNotice({ content: [toTextContent({
4123
4392
  error: "Authentication failed (401 Unauthorized). Invalid or missing API keys.",
4124
4393
  details: "Vibe-Gate reads keys in this order:\n1. Process Environment (MCP config, shell env)\n2. Workspace .env (if VIBE_WORKSPACE_ROOT is set)\n3. Package .env (where vibe-gate-mcp is installed)\n\nPlease ensure your key is correct."
4125
- })] };
4126
- return { content: [toTextContent({ error: errorMessage })] };
4394
+ })] }, provider, providerWasInvoked);
4395
+ return addAutoProviderNotice({ content: [toTextContent({ error: errorMessage })] }, provider, providerWasInvoked);
4127
4396
  }
4128
4397
  }
4129
4398
  //#endregion
@@ -6,12 +6,14 @@ The CLI is installed locally, but its model request still goes to the provider.
6
6
 
7
7
  ## Supported CLIs
8
8
 
9
- | `CRITIC_PROVIDER` | Command | Sign in | Model selection |
10
- | ----------------- | -------------- | ---------------------------------------------- | -------------------------------------------------- |
11
- | `codex-cli` | `codex` | `codex login` | Optional `CRITIC_MODEL` |
12
- | `claude-code` | `claude` | `claude auth login` | Optional `CRITIC_MODEL` |
13
- | `cursor-agent` | `cursor-agent` | `cursor-agent login` | Optional `CRITIC_MODEL` |
14
- | `opencode-cli` | `opencode` | `opencode auth login` or `/connect` in its TUI | Required `CRITIC_MODEL` in `provider/model` format |
9
+ When `CRITIC_PROVIDER` is empty or unset, Vibe-Gate checks local CLI executables in this order: Codex (`codex`), Claude Code (`claude`), Cursor Agent (`agent`, then legacy `cursor-agent`), and OpenCode CLI (`opencode`). OpenCode CLI is considered only with a `provider/model` `CRITIC_MODEL`, and that model format moves it to the front of the default order. Configured `*_CLI_PATH` values are checked before default PATH names, using the same provider order. The first tool response reports the selected CLI in `providerNotice`. Detection checks whether the executable is available; it does not verify that the CLI is signed in. If the selected CLI needs login, Vibe-Gate returns its error and does not switch to another account or service. If no local CLI is found, Vibe-Gate retains its OpenAI default and requires `OPENAI_API_KEY`. Set `CRITIC_PROVIDER` to select a provider explicitly.
10
+
11
+ | `CRITIC_PROVIDER` | Command | Sign in | Model selection |
12
+ | ----------------- | ----------------------------------------- | ---------------------------------------------- | -------------------------------------------------- |
13
+ | `codex-cli` | `codex` | `codex login` | Optional `CRITIC_MODEL` |
14
+ | `claude-code` | `claude` | `claude auth login` | Optional `CRITIC_MODEL` |
15
+ | `cursor-agent` | `agent` (legacy fallback: `cursor-agent`) | `agent login` (legacy: `cursor-agent login`) | Optional `CRITIC_MODEL` |
16
+ | `opencode-cli` | `opencode` | `opencode auth login` or `/connect` in its TUI | Required `CRITIC_MODEL` in `provider/model` format |
15
17
 
16
18
  If `CRITIC_MODEL` is omitted, Vibe-Gate leaves model selection to Codex, Claude Code, or Cursor Agent. Use model IDs accepted by the selected CLI when setting an override. Codex is deliberately started with user configuration ignored, so it uses its built-in default model rather than a custom model from `config.toml`; the saved login remains available. OpenCode CLI requires `CRITIC_MODEL` because Vibe-Gate isolates its user configuration.
17
19
 
@@ -48,8 +50,8 @@ Vibe-Gate uses print mode and safe mode, disables built-in tools and MCP tools,
48
50
  Install Cursor CLI and sign in to your Cursor account:
49
51
 
50
52
  ```sh
51
- cursor-agent login
52
- cursor-agent status
53
+ agent login
54
+ agent status
53
55
  ```
54
56
 
55
57
  Then configure Vibe-Gate:
@@ -57,7 +59,7 @@ Then configure Vibe-Gate:
57
59
  ```env
58
60
  CRITIC_PROVIDER=cursor-agent
59
61
  # Optional when PATH in the IDE differs from your terminal:
60
- # CURSOR_AGENT_CLI_PATH=/absolute/path/to/cursor-agent
62
+ # CURSOR_AGENT_CLI_PATH=/absolute/path/to/agent
61
63
  # Optional model override:
62
64
  # CRITIC_MODEL=gpt-5
63
65
  ```
@@ -0,0 +1,19 @@
1
+ # Roadmap and release checklist
2
+
3
+ ## 0.1.5 — review safety and conflict-loop fixes
4
+
5
+ - [x] Bound MCP review inputs and resolved payload size.
6
+ - [x] End a rejected third Critic round with a deadlock case.
7
+ - [x] Keep source payloads out of the system prompt and conflict-session history.
8
+ - [x] Read Critic `REQUEST:` paths within the workspace and enforce file and context limits.
9
+ - [x] Use canonical paths for changed-file reads to reject traversal and symlink escapes.
10
+ - [x] Keep model IDs configurable and use the OpenAI Responses API for current reasoning-model compatibility.
11
+ - [x] Add focused tests and update user documentation.
12
+ - [x] Run typecheck, lint, unit tests, build, MCP tool smoke test, and npm package inspection.
13
+ - [ ] Publish `vibe-gate-mcp@0.1.5` from the maintainer's npm account.
14
+
15
+ ## Scope review
16
+
17
+ The earlier release plan included extra diagnostics and automatic provider fallback. Those do not address a demonstrated defect in the current package. Fallback could also select a different account or incur unexpected provider charges, so providers remain explicitly configured. The 0.1.5 scope is limited to behavior that was broken or unbounded in the existing review path; broader provider work should wait for a concrete user need.
18
+
19
+ The npm package is not published by the current GitHub Actions workflow; CI only validates and builds it. After the commit is pushed, publish the version from an authenticated maintainer environment.
@@ -22,6 +22,7 @@ Limits (SSoT: `SEMANTIC_DIFF_SOURCE_FILES` in `src/constants.ts`):
22
22
  - Max **10** paths per call
23
23
  - Max **1 MiB** per file
24
24
  - Max **5 MiB** total
25
+ - The resolved payload must also stay under **500,000 characters**; the same limit applies to inline payloads and payload files.
25
26
 
26
27
  Paths must be **relative to `VIBE_WORKSPACE_ROOT`**. Absolute paths and `..` traversal are rejected.
27
28
 
@@ -44,6 +45,14 @@ Write the same FILE:…CONTENT: string to a UTF-8 file under the workspace (raw
44
45
 
45
46
  Same FILE:…CONTENT: string as the tool argument. Prefer `files[]`.
46
47
 
48
+ ## Critic-requested context
49
+
50
+ On a later review round, the MCP reads paths from the previous Critic response's `REQUEST:` lines under `VIBE_WORKSPACE_ROOT`. Paths are subject to workspace and symlink checks. Each request is limited to 10 files, 1 MiB per file, and 120 context lines; the combined requested context is limited to 500,000 characters. Use a narrower line range when a file needs more context.
51
+
52
+ The review loop allows three rounds. A rejected or blocked third round produces a deadlock case for human review.
53
+
54
+ Source payloads are submitted again with each review round and are not stored in `.vibe/review-session.json`.
55
+
47
56
  ## Payload format (what the Critic sees)
48
57
 
49
58
  ```
@@ -10,7 +10,7 @@
10
10
 
11
11
  1. For direct API providers, set its key: `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GOOGLE_GENERATIVE_AI_API_KEY`, `MINIMAX_API_KEY`, or `OPENCODE_API_KEY`.
12
12
  2. For a local CLI provider, install the selected CLI, sign in with its account, and check the executable path.
13
- 3. Ensure `CRITIC_PROVIDER` matches one of: `openai`, `anthropic`, `google`, `minimax`, `opencode`, `codex-cli`, `claude-code`, `cursor-agent`, or `opencode-cli`.
13
+ 3. If `CRITIC_PROVIDER` is set, ensure it matches one of: `openai`, `anthropic`, `google`, `minimax`, `opencode`, `codex-cli`, `claude-code`, `cursor-agent`, or `opencode-cli`. If it is unset, Vibe-Gate checks for local CLI executables in the documented order.
14
14
  4. For `opencode-cli`, set `CRITIC_MODEL` to an available `provider/model` value from `opencode models`.
15
15
  5. Verify `.env` is loaded (MCP config must pass `env` or the process must inherit it).
16
16
 
@@ -18,7 +18,7 @@
18
18
 
19
19
  **Cause:** IDE-launched MCP servers may have a shorter `PATH` than an interactive terminal, or the CLI may not have an account session for this OS user.
20
20
 
21
- **Fix:** Run the CLI's login flow in a terminal as the same user running the IDE, then set its matching `*_CLI_PATH` to the absolute executable path if needed. For Claude Code, `claude auth login` defaults to Claude subscription sign-in; `--console` selects API billing. See [CLI_PROVIDERS.md](CLI_PROVIDERS.md) for provider-specific setup.
21
+ **Fix:** Run the CLI's login flow in a terminal as the same user running the IDE, then set its matching `*_CLI_PATH` to the absolute executable path if needed. For Cursor, `cursor-agent` remains the `CRITIC_PROVIDER` value, while the current executable is `agent`; `cursor-agent` is only a legacy fallback. For Claude Code, `claude auth login` defaults to Claude subscription sign-in; `--console` selects API billing. See [CLI_PROVIDERS.md](CLI_PROVIDERS.md) for provider-specific setup.
22
22
 
23
23
  ### OpenCode CLI reports insufficient balance
24
24
 
package/docs/USAGE.md CHANGED
@@ -48,10 +48,11 @@ Vibe-Gate can call a direct API provider with an API key, or a local CLI using i
48
48
 
49
49
  ### Local CLI providers (no separate API key)
50
50
 
51
- Install and sign in to one supported CLI, then select it:
51
+ Install and sign in to one supported CLI. If `CRITIC_PROVIDER` is unset, Vibe-Gate automatically uses the first available local CLI in this order: Codex, Claude Code, Cursor Agent, then OpenCode CLI. A `provider/model` `CRITIC_MODEL` moves OpenCode CLI to the front. The first review response identifies the selected CLI. Set `CRITIC_PROVIDER` to choose explicitly.
52
52
 
53
53
  ```env
54
- CRITIC_PROVIDER=codex-cli
54
+ # Optional override; omit this to auto-detect a local CLI
55
+ # CRITIC_PROVIDER=codex-cli
55
56
  # Or: claude-code | cursor-agent | opencode-cli
56
57
  # OpenCode CLI reuses its saved login and requires a model:
57
58
  # CRITIC_PROVIDER=opencode-cli
@@ -62,7 +63,7 @@ When `CRITIC_MODEL` is omitted, Codex, Claude Code, and Cursor Agent choose thei
62
63
 
63
64
  Full setup, security behavior, and alternatives we evaluated are in [CLI_PROVIDERS.md](CLI_PROVIDERS.md).
64
65
 
65
- ### OpenAI (default)
66
+ ### OpenAI
66
67
 
67
68
  ```env
68
69
  CRITIC_PROVIDER=openai
@@ -70,6 +71,8 @@ OPENAI_API_KEY=YOUR_OPENAI_API_KEY
70
71
  # CRITIC_MODEL=gpt-5.4 # optional, default
71
72
  ```
72
73
 
74
+ Vibe-Gate sends OpenAI requests through the Responses API. `CRITIC_MODEL` is passed through without a Vibe-Gate model allowlist, so you can select a newer model such as `gpt-6-luna` without waiting for a package update. The model must be available to your OpenAI account and support the Responses API.
75
+
73
76
  ### Anthropic
74
77
 
75
78
  ```env
@@ -2,20 +2,20 @@
2
2
 
3
3
  Copy [`.env.example`](../../.env.example) → `.env` in the package directory **or** set the same keys in MCP `env`. Choose a direct API provider with its key, or a signed-in local CLI provider without a separate API key.
4
4
 
5
- ## Required (pick one provider)
6
-
7
- | Variable | When required | Description |
8
- | ------------------------------ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
9
- | `CRITIC_PROVIDER` | Recommended (default `openai`) | `openai` \| `anthropic` \| `google` \| `minimax` \| `opencode` \| `codex-cli` \| `claude-code` \| `cursor-agent` \| `opencode-cli` |
10
- | `OPENAI_API_KEY` | `CRITIC_PROVIDER=openai` | OpenAI API key |
11
- | `ANTHROPIC_API_KEY` | `CRITIC_PROVIDER=anthropic` | Anthropic API key |
12
- | `GOOGLE_GENERATIVE_AI_API_KEY` | `CRITIC_PROVIDER=google` | Google Gemini API key |
13
- | `MINIMAX_API_KEY` | `CRITIC_PROVIDER=minimax` | MiniMax API key |
14
- | `OPENCODE_API_KEY` | `CRITIC_PROVIDER=opencode` | From https://opencode.ai/auth |
15
- | `CODEX_CLI_PATH` | `CRITIC_PROVIDER=codex-cli` | Optional path to the `codex` executable |
16
- | `CLAUDE_CODE_CLI_PATH` | `CRITIC_PROVIDER=claude-code` | Optional path to the `claude` executable |
17
- | `CURSOR_AGENT_CLI_PATH` | `CRITIC_PROVIDER=cursor-agent` | Optional path to the `cursor-agent` executable |
18
- | `OPENCODE_CLI_PATH` | `CRITIC_PROVIDER=opencode-cli` | Optional path to the `opencode` executable |
5
+ ## Provider selection (optional)
6
+
7
+ | Variable | When required | Description |
8
+ | ------------------------------ | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
9
+ | `CRITIC_PROVIDER` | Optional (auto-detect local CLI) | `openai` \| `anthropic` \| `google` \| `minimax` \| `opencode` \| `codex-cli` \| `claude-code` \| `cursor-agent` \| `opencode-cli` |
10
+ | `OPENAI_API_KEY` | `CRITIC_PROVIDER=openai` | OpenAI API key |
11
+ | `ANTHROPIC_API_KEY` | `CRITIC_PROVIDER=anthropic` | Anthropic API key |
12
+ | `GOOGLE_GENERATIVE_AI_API_KEY` | `CRITIC_PROVIDER=google` | Google Gemini API key |
13
+ | `MINIMAX_API_KEY` | `CRITIC_PROVIDER=minimax` | MiniMax API key |
14
+ | `OPENCODE_API_KEY` | `CRITIC_PROVIDER=opencode` | From https://opencode.ai/auth |
15
+ | `CODEX_CLI_PATH` | `CRITIC_PROVIDER=codex-cli` | Optional path to the `codex` executable |
16
+ | `CLAUDE_CODE_CLI_PATH` | `CRITIC_PROVIDER=claude-code` | Optional path to the `claude` executable |
17
+ | `CURSOR_AGENT_CLI_PATH` | `CRITIC_PROVIDER=cursor-agent` | Optional path to the `agent` executable (`cursor-agent` is accepted for legacy installs) |
18
+ | `OPENCODE_CLI_PATH` | `CRITIC_PROVIDER=opencode-cli` | Optional path to the `opencode` executable |
19
19
 
20
20
  ## Optional
21
21
 
@@ -22,14 +22,16 @@ exactly one payload source:
22
22
  semanticDiffPath → loadSemanticDiffFromWorkspacePath
23
23
  semanticDiff → use inline string
24
24
  ↓
25
- parseSemanticDiff() → filesChanged count / context
25
+ parseSemanticDiff() → filesChanged count
26
26
  ↓
27
- buildContextBlock() → blueprint, deps, FILE:…CONTENT: corpus
27
+ buildContextBlock() → project summary and dependencies
28
28
  ↓
29
- provider.complete([system, user]) → Critic LLM
29
+ provider.complete([system, user]) → Critic LLM (report and source payload sent once in user message)
30
30
  ↓
31
31
  parseVerdictFromResponse → ACCEPT | REJECT | …
32
32
  ↓
33
+ on the next round, read workspace paths from prior REQUEST: blocks (bounded and canonical-path checked)
34
+ ↓
33
35
  if ACCEPT && shouldPersistPhaseStatus(phaseId, updateStatus) → updatePhaseOnAccept → .vibe/status.json
34
36
  ↓
35
37
  Response JSON: { verdict, model, usage, statusUpdated, statusSkipped?, statusError?, … }
@@ -49,6 +51,8 @@ Response JSON: { verdict, model, usage, statusUpdated, statusSkipped?, statusErr
49
51
  | round | number | no | Round (1–3), default 1 |
50
52
  | logToDebt | object | no | When DEBT: `{ subject, rationale }` |
51
53
 
54
+ Input limits are enforced by the MCP schema: phase id 256 characters, report 50,000, dependency list 100 names (256 characters each), and paths 1,024 characters. The resolved source payload is rechecked after loading and limited to 500,000 characters. Critic-requested context is limited to 10 files, 1 MiB per file, and 120 lines per request.
55
+
52
56
  † **Exactly one** of `files` (non-empty), `semanticDiffPath` (non-empty), or `semanticDiff` (non-empty). See [SEMANTIC_DIFF_PAYLOAD.md](../../SEMANTIC_DIFF_PAYLOAD.md).
53
57
 
54
58
  **ListTools note:** MCP registers `submitPhaseReviewFieldsSchema` (plain ZodObject). Exactly-one rules run in the handler via `submitPhaseReviewInputSchema`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibe-gate-mcp",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "description": "Adversarial Quality Gate MCP for vibe-coding: IDE AI vs Critic AI, human decides on deadlock",
6
6
  "license": "MIT",
@@ -34,6 +34,7 @@
34
34
  ".env.example",
35
35
  "README.md",
36
36
  "CHANGELOG.md",
37
+ "docs/ROADMAP.md",
37
38
  "docs/INSTALLATION.md",
38
39
  "docs/CLI_PROVIDERS.md",
39
40
  "docs/USAGE.md",