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 +7 -3
- package/CHANGELOG.md +26 -0
- package/README.md +7 -2
- package/dist/index.mjs +436 -167
- package/docs/CLI_PROVIDERS.md +11 -9
- package/docs/ROADMAP.md +19 -0
- package/docs/SEMANTIC_DIFF_PAYLOAD.md +9 -0
- package/docs/TROUBLESHOOTING.md +2 -2
- package/docs/USAGE.md +6 -3
- package/docs/project/VARIABLES.md +14 -14
- package/docs/project/api/mcp-tools.md +7 -3
- package/package.json +2 -1
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
|
-
# ---
|
|
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/
|
|
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 `
|
|
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]: "
|
|
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.
|
|
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.
|
|
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)
|
|
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 {
|
|
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(
|
|
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
|
|
2017
|
+
function formatSemanticDiffFileBlock(relativePath, content, truncated = false) {
|
|
2002
2018
|
const body = content.endsWith("\n") ? content : `${content}\n`;
|
|
2003
|
-
|
|
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(
|
|
2125
|
+
const read = await readSourceFileContent(sizeCheck.canonicalPath, relativePath);
|
|
2108
2126
|
if (!read.ok) return read.result;
|
|
2109
|
-
blocks.push(
|
|
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
|
-
|
|
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:
|
|
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
|
|
2150
|
+
const response = await client.responses.create({
|
|
2138
2151
|
model,
|
|
2139
|
-
|
|
2140
|
-
|
|
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:
|
|
2145
|
-
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
|
-
|
|
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
|
|
2551
|
-
|
|
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
|
|
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
|
|
2979
|
-
const
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
[PROVIDERS.
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
[PROVIDERS.
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
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}
|
|
3913
|
+
function buildUserContent(args, historySummary, requestedContext = "") {
|
|
3914
|
+
const parts = [`Phase: ${args.phaseId}`];
|
|
3625
3915
|
if (historySummary) parts.unshift(`Previous rounds:\n${historySummary}\n`);
|
|
3626
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4052
|
+
buildContextBlock(workspaceRoot, args.dependencies ?? [], args.semanticDiff),
|
|
3807
4053
|
loadRules(workspaceRoot),
|
|
3808
4054
|
readPreferencesLog(workspaceRoot)
|
|
3809
4055
|
]);
|
|
3810
|
-
|
|
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
|
|
3816
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
4318
|
+
if (input.files) {
|
|
4086
4319
|
const built = await buildSemanticDiffFromSourceFiles(workspaceRoot, input.files);
|
|
4087
|
-
if (!built.ok) return {
|
|
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
|
|
4094
|
-
const loaded = await loadSemanticDiffFromWorkspacePath(workspaceRoot, input.semanticDiffPath
|
|
4095
|
-
if (!loaded.ok) return {
|
|
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,
|
|
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
|
package/docs/CLI_PROVIDERS.md
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
| `
|
|
12
|
-
|
|
|
13
|
-
| `
|
|
14
|
-
| `
|
|
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
|
-
|
|
52
|
-
|
|
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/
|
|
62
|
+
# CURSOR_AGENT_CLI_PATH=/absolute/path/to/agent
|
|
61
63
|
# Optional model override:
|
|
62
64
|
# CRITIC_MODEL=gpt-5
|
|
63
65
|
```
|
package/docs/ROADMAP.md
ADDED
|
@@ -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
|
```
|
package/docs/TROUBLESHOOTING.md
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
##
|
|
6
|
-
|
|
7
|
-
| Variable | When required
|
|
8
|
-
| ------------------------------ |
|
|
9
|
-
| `CRITIC_PROVIDER` |
|
|
10
|
-
| `OPENAI_API_KEY` | `CRITIC_PROVIDER=openai`
|
|
11
|
-
| `ANTHROPIC_API_KEY` | `CRITIC_PROVIDER=anthropic`
|
|
12
|
-
| `GOOGLE_GENERATIVE_AI_API_KEY` | `CRITIC_PROVIDER=google`
|
|
13
|
-
| `MINIMAX_API_KEY` | `CRITIC_PROVIDER=minimax`
|
|
14
|
-
| `OPENCODE_API_KEY` | `CRITIC_PROVIDER=opencode`
|
|
15
|
-
| `CODEX_CLI_PATH` | `CRITIC_PROVIDER=codex-cli`
|
|
16
|
-
| `CLAUDE_CODE_CLI_PATH` | `CRITIC_PROVIDER=claude-code`
|
|
17
|
-
| `CURSOR_AGENT_CLI_PATH` | `CRITIC_PROVIDER=cursor-agent`
|
|
18
|
-
| `OPENCODE_CLI_PATH` | `CRITIC_PROVIDER=opencode-cli`
|
|
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
|
|
25
|
+
parseSemanticDiff() → filesChanged count
|
|
26
26
|
↓
|
|
27
|
-
buildContextBlock() →
|
|
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.
|
|
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",
|