vibe-gate-mcp 0.1.4 → 0.1.5

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/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.5] - 2026-09-23
11
+
12
+ ### Fixed
13
+
14
+ - Enforce bounded review inputs, close the conflict loop as deadlocked after a rejected third round, and avoid duplicating or persisting source payloads between rounds.
15
+ - 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.
16
+ - Use the OpenAI Responses API so current reasoning models work without model-specific request parameters or a Vibe-Gate model allowlist.
17
+
18
+ ### Docs
19
+
20
+ - Document the review limits and requested-context behavior, and add a release checklist and scope review.
21
+
10
22
  ## [0.1.4] - 2026-09-21
11
23
 
12
24
  ### Added
package/README.md CHANGED
@@ -45,6 +45,8 @@ OPENAI_API_KEY=YOUR_OPENAI_API_KEY
45
45
 
46
46
  `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
47
 
48
+ `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).
49
+
48
50
  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
51
 
50
52
  ### 2. Configure Cursor MCP (any consumer repo)
@@ -122,6 +124,7 @@ Probes: `updateStatus: false` or `phaseId` prefixes `mcp-smoke-` / `vibe-gate-pr
122
124
  | [docs/USAGE.md](docs/USAGE.md) | First run and providers |
123
125
  | [docs/CLI_PROVIDERS.md](docs/CLI_PROVIDERS.md) | Local CLI providers |
124
126
  | [docs/SEMANTIC_DIFF_PAYLOAD.md](docs/SEMANTIC_DIFF_PAYLOAD.md) | `files[]` contract |
127
+ | [docs/ROADMAP.md](docs/ROADMAP.md) | Release checklist |
125
128
  | [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) | Stale MCP, path errors |
126
129
  | [docs/project/VARIABLES.md](docs/project/VARIABLES.md) | Env SSoT |
127
130
  | [examples/](examples/) | Cursor mcp.json templates |
package/dist/index.mjs CHANGED
@@ -395,6 +395,18 @@ const SEMANTIC_DIFF_SOURCE_FILES = {
395
395
  MAX_BYTES_PER_FILE: 1048576,
396
396
  MAX_TOTAL_BYTES: 5242880
397
397
  };
398
+ /** MCP review input bounds; apply size checks again after files are read. */
399
+ const REVIEW_INPUT_LIMITS = {
400
+ MAX_PHASE_ID_CHARS: 256,
401
+ MAX_REPORT_CHARS: 5e4,
402
+ MAX_SEMANTIC_DIFF_CHARS: 5e5,
403
+ MAX_PATH_CHARS: 1024,
404
+ MAX_DEPENDENCIES: 100,
405
+ MAX_DEPENDENCY_NAME_CHARS: 256,
406
+ MAX_DEBT_SUBJECT_CHARS: 200,
407
+ MAX_DEBT_RATIONALE_CHARS: 4e3,
408
+ MAX_REQUESTED_CONTEXT_LINES: 120
409
+ };
398
410
  /**
399
411
  * Status.json write policy on ACCEPT.
400
412
  * Probe phaseIds must not pollute consumer `.vibe/status.json` unless updateStatus:true.
@@ -693,7 +705,6 @@ const concernVerificationSchema = z.object({
693
705
  const reviewRoundSchema = z.object({
694
706
  round: z.number(),
695
707
  report: z.string(),
696
- semanticDiff: z.string().optional(),
697
708
  verdict: z.string(),
698
709
  criticResponse: z.string(),
699
710
  concerns: z.array(concernSchema).optional(),
@@ -1847,7 +1858,10 @@ async function verifyCanonicalPathUnderWorkspace(workspaceRoot, absolutePath, pa
1847
1858
  code: "PATH_OUTSIDE_WORKSPACE",
1848
1859
  message: `${pathKind} resolves outside VIBE_WORKSPACE_ROOT after canonical resolution.`
1849
1860
  };
1850
- return { ok: true };
1861
+ return {
1862
+ ok: true,
1863
+ canonicalPath: fileReal
1864
+ };
1851
1865
  }
1852
1866
  /**
1853
1867
  * Resolve a user-supplied relative path to an absolute path confined to workspaceRoot.
@@ -1946,7 +1960,7 @@ async function loadSemanticDiffFromWorkspacePath(workspaceRoot, userRelativePath
1946
1960
  };
1947
1961
  let raw;
1948
1962
  try {
1949
- raw = await readFile(absolutePath, "utf8");
1963
+ raw = await readFile(canonical.canonicalPath, "utf8");
1950
1964
  } catch {
1951
1965
  return {
1952
1966
  ok: false,
@@ -1998,9 +2012,10 @@ function mapPathError(code, message) {
1998
2012
  message
1999
2013
  };
2000
2014
  }
2001
- function formatFileBlock(relativePath, content) {
2015
+ function formatSemanticDiffFileBlock(relativePath, content, truncated = false) {
2002
2016
  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}`;
2017
+ const note = truncated ? `[Context limited to ${REVIEW_INPUT_LIMITS.MAX_REQUESTED_CONTEXT_LINES} lines.]\n` : "";
2018
+ return `${SEMANTIC_DIFF_PAYLOAD_MARKERS.FILE_LINE_PREFIX}${relativePath}\n${SEMANTIC_DIFF_PAYLOAD_MARKERS.CONTENT_LINE}\n${body}${note}`;
2004
2019
  }
2005
2020
  function validateFilesArray(paths) {
2006
2021
  if (paths.length === 0) return {
@@ -2061,7 +2076,8 @@ async function assertReadableSourceFile(workspaceRoot, relativePath, absolutePat
2061
2076
  };
2062
2077
  return {
2063
2078
  ok: true,
2064
- nextTotalBytes
2079
+ nextTotalBytes,
2080
+ canonicalPath: canonical.canonicalPath
2065
2081
  };
2066
2082
  }
2067
2083
  async function readSourceFileContent(absolutePath, relativePath) {
@@ -2104,9 +2120,9 @@ async function buildSemanticDiffFromSourceFiles(workspaceRoot, relativePaths) {
2104
2120
  const sizeCheck = await assertReadableSourceFile(workspaceRoot, relativePath, pathResult.absolutePath, totalBytes);
2105
2121
  if (!sizeCheck.ok) return sizeCheck.result;
2106
2122
  totalBytes = sizeCheck.nextTotalBytes;
2107
- const read = await readSourceFileContent(pathResult.absolutePath, relativePath);
2123
+ const read = await readSourceFileContent(sizeCheck.canonicalPath, relativePath);
2108
2124
  if (!read.ok) return read.result;
2109
- blocks.push(formatFileBlock(relativePath, read.content));
2125
+ blocks.push(formatSemanticDiffFileBlock(relativePath, read.content));
2110
2126
  filesLoaded.push(relativePath);
2111
2127
  }
2112
2128
  return {
@@ -2120,32 +2136,27 @@ async function buildSemanticDiffFromSourceFiles(workspaceRoot, relativePaths) {
2120
2136
  /**
2121
2137
  * OpenAI API integration.
2122
2138
  */
2123
- const ROLE_MAP$1 = {
2124
- user: "user",
2125
- assistant: "assistant",
2126
- system: "system"
2127
- };
2128
- function toOpenAIMessages$1(messages) {
2139
+ function toOpenAIInput(messages) {
2129
2140
  return messages.map((m) => ({
2130
- role: ROLE_MAP$1[m.role],
2141
+ role: m.role,
2131
2142
  content: m.content
2132
2143
  }));
2133
2144
  }
2134
2145
  function createOpenAIProvider(apiKey, model) {
2135
2146
  const client = new OpenAI({ apiKey });
2136
2147
  return { async complete(messages) {
2137
- const completion = await client.chat.completions.create({
2148
+ const response = await client.responses.create({
2138
2149
  model,
2139
- max_completion_tokens: LLM_MAX_TOKENS,
2140
- temperature: .3,
2141
- messages: toOpenAIMessages$1(messages)
2150
+ max_output_tokens: LLM_MAX_TOKENS,
2151
+ input: toOpenAIInput(messages)
2142
2152
  });
2153
+ const usage = response.usage ? {
2154
+ promptTokens: response.usage.input_tokens,
2155
+ completionTokens: response.usage.output_tokens
2156
+ } : void 0;
2143
2157
  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
2158
+ content: response.output_text,
2159
+ usage
2149
2160
  };
2150
2161
  } };
2151
2162
  }
@@ -3345,6 +3356,57 @@ function parseVerificationsFromResponse(response, existingConcerns) {
3345
3356
  }
3346
3357
  return verifications;
3347
3358
  }
3359
+ function splitRequestedPath(part) {
3360
+ let remainder = part.trim();
3361
+ if (!remainder) return null;
3362
+ if (remainder.startsWith("\"") || remainder.startsWith("'")) {
3363
+ const quote = remainder[0];
3364
+ const closingQuote = remainder.indexOf(quote, 1);
3365
+ if (closingQuote < 2) return null;
3366
+ const filePath = remainder.slice(1, closingQuote);
3367
+ remainder = remainder.slice(closingQuote + 1).trim();
3368
+ return {
3369
+ filePath,
3370
+ remainder
3371
+ };
3372
+ }
3373
+ const firstSpace = remainder.search(/\s/);
3374
+ const filePath = firstSpace === -1 ? remainder : remainder.slice(0, firstSpace);
3375
+ remainder = firstSpace === -1 ? "" : remainder.slice(firstSpace).trim();
3376
+ return {
3377
+ filePath,
3378
+ remainder
3379
+ };
3380
+ }
3381
+ function parseFileRequestPart(part) {
3382
+ const split = splitRequestedPath(part);
3383
+ if (!split) return null;
3384
+ let { filePath, remainder } = split;
3385
+ const pathRange = /:(\d+(?:-\d+)?)$/.exec(filePath);
3386
+ const leadingRange = /^:(\d+(?:-\d+)?)(?:\s+|$)/.exec(remainder);
3387
+ const lineRange = pathRange?.[1] ?? leadingRange?.[1];
3388
+ if (pathRange) filePath = filePath.slice(0, -pathRange[0].length);
3389
+ else if (leadingRange) remainder = remainder.slice(leadingRange[0].length).trim();
3390
+ if (!filePath) return null;
3391
+ return {
3392
+ filePath,
3393
+ lineRange,
3394
+ reason: remainder || void 0
3395
+ };
3396
+ }
3397
+ function parseRequestsFromResponse(response) {
3398
+ const requests = [];
3399
+ const requestRegex = /\bREQUEST:\s*(.+)/gi;
3400
+ let match;
3401
+ while ((match = requestRegex.exec(response)) !== null) {
3402
+ const parts = match[1].trim().split(",").map((p) => p.trim());
3403
+ for (const part of parts) {
3404
+ const req = parseFileRequestPart(part);
3405
+ if (req) requests.push(req);
3406
+ }
3407
+ }
3408
+ return requests;
3409
+ }
3348
3410
  function hasRequestBlocks(response) {
3349
3411
  if (response.toUpperCase().includes(RESPONSE_BLOCKS.REQUEST)) return true;
3350
3412
  const upper = response.toUpperCase();
@@ -3578,6 +3640,104 @@ function computeSemanticDiffLineHints(semanticDiff) {
3578
3640
  return hints;
3579
3641
  }
3580
3642
  //#endregion
3643
+ //#region src/summarizer/read-changed-files.ts
3644
+ /**
3645
+ * Read contents of changed files with truncation.
3646
+ * Used for relevance filtering - only read files that were actually changed.
3647
+ * Includes auto-import expansion for TypeScript/JavaScript files.
3648
+ */
3649
+ async function readChangedFileContent(workspaceRoot, filePath, maxLines = CONTEXT_LIMITS.MAX_LINES_PER_FILE, lineRange) {
3650
+ const pathResult = resolveSafePathInWorkspace(workspaceRoot, filePath, WORKSPACE_PATH_KIND.SOURCE_FILE);
3651
+ if (!pathResult.ok) return {
3652
+ path: filePath,
3653
+ content: CONTEXT_LIMITS.FILE_UNREADABLE,
3654
+ truncated: false,
3655
+ error: pathResult.message
3656
+ };
3657
+ try {
3658
+ const canonical = await verifyCanonicalPathUnderWorkspace(workspaceRoot, pathResult.absolutePath, WORKSPACE_PATH_KIND.SOURCE_FILE);
3659
+ if (!canonical.ok) return {
3660
+ path: filePath,
3661
+ content: CONTEXT_LIMITS.FILE_UNREADABLE,
3662
+ truncated: false,
3663
+ error: canonical.message
3664
+ };
3665
+ const fileStat = await stat(canonical.canonicalPath);
3666
+ if (!fileStat.isFile()) return {
3667
+ path: filePath,
3668
+ content: CONTEXT_LIMITS.FILE_UNREADABLE,
3669
+ truncated: false,
3670
+ error: "Not a regular file."
3671
+ };
3672
+ if (fileStat.size > SEMANTIC_DIFF_SOURCE_FILES.MAX_BYTES_PER_FILE) return {
3673
+ path: filePath,
3674
+ content: CONTEXT_LIMITS.FILE_UNREADABLE,
3675
+ truncated: false,
3676
+ error: "File exceeds size limit."
3677
+ };
3678
+ const lines = (await readFile(canonical.canonicalPath, "utf-8")).split("\n");
3679
+ if (lineRange) {
3680
+ const range = /^(\d+)(?:-(\d+))?$/.exec(lineRange);
3681
+ const start = Number(range?.[1]);
3682
+ const end = range?.[2] ? Number(range[2]) : start;
3683
+ if (!range || start < 1 || end < start) return {
3684
+ path: filePath,
3685
+ content: CONTEXT_LIMITS.FILE_UNREADABLE,
3686
+ truncated: false,
3687
+ error: "Invalid line range."
3688
+ };
3689
+ let startIdx = Math.max(0, start - 1);
3690
+ let endIdx = Math.min(lines.length, end);
3691
+ if (!range[2]) {
3692
+ startIdx = Math.max(0, startIdx - 50);
3693
+ endIdx = Math.min(lines.length, startIdx + 100);
3694
+ }
3695
+ const requestedEndIdx = endIdx;
3696
+ endIdx = Math.min(endIdx, startIdx + REVIEW_INPUT_LIMITS.MAX_REQUESTED_CONTEXT_LINES);
3697
+ return {
3698
+ path: filePath,
3699
+ content: lines.slice(startIdx, endIdx).map((l, i) => `${startIdx + i + 1} | ${l}`).join("\n"),
3700
+ truncated: endIdx < requestedEndIdx
3701
+ };
3702
+ }
3703
+ return {
3704
+ path: filePath,
3705
+ content: lines.slice(0, maxLines).join("\n"),
3706
+ truncated: lines.length > maxLines
3707
+ };
3708
+ } catch {
3709
+ return {
3710
+ path: filePath,
3711
+ content: CONTEXT_LIMITS.FILE_UNREADABLE,
3712
+ truncated: false,
3713
+ error: "File could not be read."
3714
+ };
3715
+ }
3716
+ }
3717
+ async function readRequestedFiles(workspaceRoot, response) {
3718
+ const requests = parseRequestsFromResponse(response);
3719
+ if (requests.length > SEMANTIC_DIFF_SOURCE_FILES.MAX_COUNT) throw new Error(`Critic requested more than ${SEMANTIC_DIFF_SOURCE_FILES.MAX_COUNT} files.`);
3720
+ const uniqueRequests = [...new Map(requests.map((request) => [`${request.filePath}:${request.lineRange ?? ""}`, request])).values()];
3721
+ const blocks = [];
3722
+ const files = /* @__PURE__ */ new Set();
3723
+ let totalChars = 0;
3724
+ for (const request of uniqueRequests) {
3725
+ 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.`);
3726
+ const file = await readChangedFileContent(workspaceRoot, request.filePath, REVIEW_INPUT_LIMITS.MAX_REQUESTED_CONTEXT_LINES, request.lineRange);
3727
+ if (file.error) throw new Error(`Could not read requested file ${request.filePath}: ${file.error}`);
3728
+ const block = formatSemanticDiffFileBlock(request.filePath, file.content, file.truncated);
3729
+ const nextLength = totalChars + block.length;
3730
+ if (nextLength > REVIEW_INPUT_LIMITS.MAX_SEMANTIC_DIFF_CHARS) throw new Error(`Critic requested context exceeds ${REVIEW_INPUT_LIMITS.MAX_SEMANTIC_DIFF_CHARS} characters.`);
3731
+ blocks.push(block);
3732
+ totalChars = nextLength;
3733
+ files.add(request.filePath);
3734
+ }
3735
+ return {
3736
+ semanticDiff: blocks.join(SEMANTIC_DIFF_PAYLOAD_MARKERS.FILE_BLOCK_SEPARATOR),
3737
+ filesAnalyzed: files.size
3738
+ };
3739
+ }
3740
+ //#endregion
3581
3741
  //#region src/tools/submit-phase-review.ts
3582
3742
  /**
3583
3743
  * MCP tool: submit_phase_review
@@ -3620,15 +3780,16 @@ function buildHistorySummary(history, maxTokens = CRITIC_THRESHOLDS.HISTORY_SUMM
3620
3780
  }
3621
3781
  return parts.join("\n\n---\n\n");
3622
3782
  }
3623
- function buildUserContent(args, historySummary) {
3624
- const parts = [`Phase: ${args.phaseId}`, `<developer_report>\n${args.report}\n</developer_report>`];
3783
+ function buildUserContent(args, historySummary, requestedContext = "") {
3784
+ const parts = [`Phase: ${args.phaseId}`];
3625
3785
  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>`);
3786
+ parts.push(`<developer_report>\n${args.report}\n</developer_report>`);
3787
+ const codeContent = [args.semanticDiff, requestedContext].filter(Boolean).join("\n\n");
3788
+ parts.push(`## CHANGED FILES\n<code_content>\n${codeContent}\n</code_content>`);
3627
3789
  if (args.dependencies?.length) parts.push(`Dependencies: ${args.dependencies.join(", ")}`);
3628
- debugLog(`buildUserContent - semanticDiff length: ${args.semanticDiff?.length ?? 0}`);
3629
3790
  return parts.join("\n\n");
3630
3791
  }
3631
- async function buildContextBlock(workspaceRoot, newDeps, semanticDiff, report) {
3792
+ async function buildContextBlock(workspaceRoot, newDeps, semanticDiff) {
3632
3793
  const [blueprint, pkgDeps] = await Promise.all([extractProjectBlueprint(workspaceRoot), parseDependencyListFromPackageJson(workspaceRoot).catch(() => ({
3633
3794
  dependencies: [],
3634
3795
  devDependencies: []
@@ -3637,46 +3798,12 @@ async function buildContextBlock(workspaceRoot, newDeps, semanticDiff, report) {
3637
3798
  const bloatWarn = newDeps.length >= DEPENDENCY_THRESHOLDS.BLOAT_WARNING_NEW_PACKAGES || totalDeps >= DEPENDENCY_THRESHOLDS.BLOAT_WARNING_TOTAL_DEPS;
3638
3799
  const parts = [`Project: ${blueprint.framework}. Structures: ${blueprint.structures.join(", ") || "none"}.`, `Current deps: ${totalDeps}. New/updated: ${newDeps.join(", ") || "none"}.`];
3639
3800
  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
- }
3801
+ const filesAnalyzed = semanticDiff ? parseSemanticDiff(semanticDiff).filesChanged.length : 0;
3647
3802
  return {
3648
3803
  context: parts.join(" "),
3649
3804
  filesAnalyzed
3650
3805
  };
3651
3806
  }
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
3807
  function toTextContent(json) {
3681
3808
  return {
3682
3809
  type: "text",
@@ -3720,18 +3847,7 @@ async function writeCaseFile(workspaceRoot, caseFile) {
3720
3847
  await writeFile(path, JSON.stringify(caseFile, null, 2), "utf-8");
3721
3848
  }
3722
3849
  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) {
3850
+ if ((args.round ?? 1) > 1 && session?.phaseId === args.phaseId && session.round >= CONFLICT_LOOP.MAX_ROUNDS) {
3735
3851
  await clearSession(workspaceRoot);
3736
3852
  await updateConflictCount(workspaceRoot, 1);
3737
3853
  const caseFile = buildCaseFile(args.phaseId, session.round, session.history);
@@ -3803,25 +3919,23 @@ async function runCriticReview(workspaceRoot, args, session, config, provider) {
3803
3919
  const model = getEffectiveModel(config);
3804
3920
  const [status, contextBlockResult, rules, preferencesLog] = await Promise.all([
3805
3921
  getStatus(workspaceRoot),
3806
- buildContextBlock(workspaceRoot, args.dependencies ?? [], args.semanticDiff, args.report),
3922
+ buildContextBlock(workspaceRoot, args.dependencies ?? [], args.semanticDiff),
3807
3923
  loadRules(workspaceRoot),
3808
3924
  readPreferencesLog(workspaceRoot)
3809
3925
  ]);
3810
- let enrichedContextBlock = contextBlockResult.context;
3926
+ const enrichedContextBlock = contextBlockResult.context;
3811
3927
  let totalFilesRead = contextBlockResult.filesAnalyzed;
3928
+ let requestedContext = "";
3812
3929
  if (round > 1 && session?.history && session.history.length > 0) {
3813
3930
  const previousCriticResponse = session.history[session.history.length - 1].criticResponse;
3814
3931
  if (hasRequestBlocks(previousCriticResponse)) {
3815
- const requestedBlockResult = await appendRequestedFilesToContext(workspaceRoot, enrichedContextBlock, previousCriticResponse);
3816
- enrichedContextBlock = requestedBlockResult.context;
3932
+ const requestedBlockResult = await readRequestedFiles(workspaceRoot, previousCriticResponse);
3933
+ requestedContext = requestedBlockResult.semanticDiff;
3817
3934
  totalFilesRead += requestedBlockResult.filesAnalyzed;
3818
3935
  }
3819
- const previousRoundsResult = await appendPreviousRoundsFilesToContext(workspaceRoot, enrichedContextBlock, session);
3820
- enrichedContextBlock = previousRoundsResult.context;
3821
- totalFilesRead += previousRoundsResult.filesAnalyzed;
3822
3936
  }
3823
3937
  const rulesBlock = formatRulesForPrompt(rules);
3824
- const userContent = buildUserContent(args, session?.phaseId === args.phaseId ? buildHistorySummary(session.history) : void 0);
3938
+ const userContent = buildUserContent(args, session?.phaseId === args.phaseId ? buildHistorySummary(session.history) : void 0, requestedContext);
3825
3939
  const messages = [{
3826
3940
  role: "system",
3827
3941
  content: buildSystemPrompt(personaPrompt, status, enrichedContextBlock, rulesBlock, preferencesLog, round)
@@ -3849,7 +3963,6 @@ async function runCriticReview(workspaceRoot, args, session, config, provider) {
3849
3963
  roundData: {
3850
3964
  round,
3851
3965
  report: args.report,
3852
- semanticDiff: args.semanticDiff,
3853
3966
  verdict: String(verdict),
3854
3967
  criticResponse: response.content,
3855
3968
  concerns: concerns.length > 0 ? concerns : void 0,
@@ -3894,7 +4007,7 @@ async function handleRejectOrContinue(result, session, workspaceRoot, args, sema
3894
4007
  const noActivePriorConcerns = !priorConcerns.some((c) => c.reviewStatus === CONCERN_REVIEW_STATUS.REVIEWED_VALID);
3895
4008
  if (allPriorReviewed && noActivePriorConcerns) return handleAcceptVerdict(result, workspaceRoot, args, semanticDiffHints);
3896
4009
  }
3897
- if (round > CONFLICT_LOOP.MAX_ROUNDS) {
4010
+ if (round >= CONFLICT_LOOP.MAX_ROUNDS) {
3898
4011
  await clearSession(workspaceRoot);
3899
4012
  await updateConflictCount(workspaceRoot, 1);
3900
4013
  const caseFile = buildCaseFile(args.phaseId, round, nextSession.history, String(result.verdict));
@@ -3926,17 +4039,17 @@ async function handleRejectOrContinue(result, session, workspaceRoot, args, sema
3926
4039
  * which advertises `properties: {}` to IDEs (agents then cannot discover `files` / `semanticDiffPath`).
3927
4040
  */
3928
4041
  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."),
4042
+ 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)"),
4043
+ 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)."),
4044
+ 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.`),
4045
+ 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."),
4046
+ 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
4047
  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"),
4048
+ 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"),
4049
+ round: z.number().int().min(1).max(CONFLICT_LOOP.MAX_ROUNDS).optional().describe(`Round number (1-${CONFLICT_LOOP.MAX_ROUNDS}), default 1`),
3937
4050
  logToDebt: z.object({
3938
- subject: z.string(),
3939
- rationale: z.string()
4051
+ subject: z.string().trim().min(1).max(REVIEW_INPUT_LIMITS.MAX_DEBT_SUBJECT_CHARS),
4052
+ rationale: z.string().trim().min(1).max(REVIEW_INPUT_LIMITS.MAX_DEBT_RATIONALE_CHARS)
3940
4053
  }).optional().describe("When DEBT verdict and Implementer accepts, log to DEBT.md")
3941
4054
  });
3942
4055
  function countPayloadSources(data) {
@@ -4070,35 +4183,53 @@ async function handleAcceptVerdictFlow(reviewResult, session, workspaceRoot, arg
4070
4183
  if (session && session.concerns.length > 0 && !(allReviewed && noActiveConcernsLeft)) return handleAcceptWithUnverifiedConcerns(reviewResult, updatedSession ?? session, semanticDiffHints);
4071
4184
  return handleAcceptVerdict(reviewResult, workspaceRoot, args, semanticDiffHints);
4072
4185
  }
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();
4186
+ async function resolveSemanticDiffInput(workspaceRoot, input) {
4084
4187
  let semanticDiff;
4085
- if (input.files && input.files.some((f) => f.trim().length > 0)) {
4188
+ if (input.files) {
4086
4189
  const built = await buildSemanticDiffFromSourceFiles(workspaceRoot, input.files);
4087
- if (!built.ok) return { content: [toTextContent({
4190
+ if (!built.ok) return {
4191
+ ok: false,
4088
4192
  error: built.message,
4089
4193
  code: built.code
4090
- })] };
4194
+ };
4091
4195
  semanticDiff = built.semanticDiff;
4092
4196
  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({
4197
+ } else if (input.semanticDiffPath) {
4198
+ const loaded = await loadSemanticDiffFromWorkspacePath(workspaceRoot, input.semanticDiffPath);
4199
+ if (!loaded.ok) return {
4200
+ ok: false,
4096
4201
  error: loaded.message,
4097
4202
  code: loaded.code
4098
- })] };
4203
+ };
4099
4204
  semanticDiff = loaded.semanticDiff;
4100
4205
  debugLog(`semanticDiff loaded from file: ${loaded.resolvedFromPath}`);
4101
4206
  } else semanticDiff = input.semanticDiff.trim();
4207
+ if (semanticDiff.length > REVIEW_INPUT_LIMITS.MAX_SEMANTIC_DIFF_CHARS) return {
4208
+ ok: false,
4209
+ error: `resolved semanticDiff exceeds the maximum size of ${REVIEW_INPUT_LIMITS.MAX_SEMANTIC_DIFF_CHARS} characters.`
4210
+ };
4211
+ return {
4212
+ ok: true,
4213
+ semanticDiff
4214
+ };
4215
+ }
4216
+ async function handleSubmitPhaseReview(rawArgs) {
4217
+ const parsed = submitPhaseReviewInputSchema.safeParse(rawArgs);
4218
+ if (!parsed.success) return { content: [toTextContent({
4219
+ error: "Invalid submit_phase_review arguments",
4220
+ issues: z.flattenError(parsed.error)
4221
+ })] };
4222
+ const input = parsed.data;
4223
+ const config = loadConfig();
4224
+ const provider = createLLMProvider(config);
4225
+ if (!provider) return { content: [toTextContent({ error: ERROR_MESSAGES.NO_LLM_PROVIDER })] };
4226
+ const workspaceRoot = getWorkspaceRoot();
4227
+ const resolution = await resolveSemanticDiffInput(workspaceRoot, input);
4228
+ if (!resolution.ok) return { content: [toTextContent({
4229
+ error: resolution.error,
4230
+ ...resolution.code ? { code: resolution.code } : {}
4231
+ })] };
4232
+ const { semanticDiff } = resolution;
4102
4233
  const args = {
4103
4234
  phaseId: input.phaseId,
4104
4235
  report: input.report,
@@ -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/USAGE.md CHANGED
@@ -70,6 +70,8 @@ OPENAI_API_KEY=YOUR_OPENAI_API_KEY
70
70
  # CRITIC_MODEL=gpt-5.4 # optional, default
71
71
  ```
72
72
 
73
+ 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.
74
+
73
75
  ### Anthropic
74
76
 
75
77
  ```env
@@ -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.5",
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",