feat: add manual export provider and ReviewRequest pattern

Implement Task 8.0 — Manual Export Provider with full provider abstraction:

Source files:
- src/providers/manual-export.js (104 lines) — new provider that wraps
  pre-built prompts in copy-ready format for ChatGPT Web/Business use
- src/providers/factory.js — register 'manual' provider in whitelist
- src/providers/openai.js — update JSDoc for ProviderRequest parameter

All 5 handlers updated with ReviewRequest pattern:
- src/tools/ask-chatgpt.js
- src/tools/debug-issue.js
- src/tools/review-code.js
- src/tools/review-plan.js
- src/tools/architecture-review.js
Each handler now packages shared data as { prompt, input } between step 5
and 6 of the orchestration flow.

Tests:
- test/providers/manual-export.test.js (56 tests) — covers structure,
  copy-ready box formatting, tool name detection, long prompts, Unicode,
  edge cases, provider contract compliance, idempotency
- test/providers/factory.test.js (+9 tests) — manual provider whitelist,
  factory routing, send delegation

Docs:
- ARCHITECTURE.md — provider selection table with openai/manual values
- README.md — provider comparison table and manual workflow description
This commit is contained in:
2026-06-15 10:35:45 +01:00
parent 732d6edd38
commit f38b988a8f
12 changed files with 736 additions and 17 deletions
+6 -2
View File
@@ -2,15 +2,17 @@
// Returns a chat provider based on config value CHATGPT_MCP_PROVIDER.
import { openaiProvider } from "./openai.js";
import { manualExportProvider } from "./manual-export.js";
const SUPPORTED_PROVIDERS = new Set(["openai"]);
const SUPPORTED_PROVIDERS = new Set(["openai", "manual"]);
/**
* Create a chat provider from configuration.
* Defaults to OpenAI if no provider is specified or value is invalid.
* @param {object} config - Loaded config object (must contain openaiApiKey).
* @param {string} config.chatgptMcpProvider - Provider name (default: "openai").
* @returns {{ send: (input: object, cfg: object) => Promise<{ content: string }> }} Chat provider.
* @returns {{ send: (request: ProviderRequest, cfg: object) => Promise<{ content: string }> }} Chat provider.
* ProviderRequest = { prompt?: string, input?: object }
*/
export function createChatProvider(config) {
const providerName = config?.chatgptMcpProvider || "openai";
@@ -24,6 +26,8 @@ export function createChatProvider(config) {
switch (providerName) {
case "openai":
return openaiProvider;
case "manual":
return manualExportProvider;
default:
// Should not reach here because of the set check above.
throw new Error(`Unknown provider "${providerName}".`);
+104
View File
@@ -0,0 +1,104 @@
// Manual Export chat provider adapter.
// Wraps the pre-built prompt into a copy-ready format for manual
// use in ChatGPT Web, ChatGPT Business, or Claude Code.
/**
* Detect which MCP tool produced this prompt from available input fields.
* Uses heuristic field detection — best-effort when only { prompt } is present.
* @param {object} request - ProviderRequest with optional { input } field.
* @returns {string} Lowercase tool name (e.g., "ask_chatgpt", "review_plan").
*/
function detectToolName(request) {
const input = request?.input;
if (!input) return "unknown";
if (input.logs) return "debug_issue";
if (input.relevantFiles && input.relevantFiles.some(f => f.path)) return "review_code";
if (input.context?.toString().toLowerCase().includes("architecture")) return "architecture_review";
// Distinguish review_plan from ask_chatgpt: review_plan typically has a projectSummary or taskSummary
if (input.projectSummary || input.taskSummary) return "review_plan";
return "ask_chatgpt";
}
/**
* Format prompt output with user instructions for manual copy/paste.
* @param {string} prompt - The pre-built prompt from the prompt builder.
* @param {string} toolName - Detected or default tool name.
* @returns {string} Formatted manual export content.
*/
function formatManualExport(prompt, toolName) {
const length = prompt.length.toLocaleString();
const lines = [
"═══════════════════════════════════════════════════",
`MANUAL EXPORT — ${toolName}`,
];
if (prompt.length > 30_000) {
lines.push("⚠️ NOTE: Prompt is ~" + prompt.length.toLocaleString() + " characters — may approach ChatGPT context limits.");
}
lines.push("");
lines.push("📋 COPY THIS PROMPT INTO ChatGPT WEB / BUSINESS:");
lines.push("");
lines.push("┌───────────────────────────────────────────────────┐");
// The prompt itself — displayed as-is for accurate copy/paste
const promptLines = prompt.split("\n");
for (const pline of promptLines) {
lines.push("│ " + pline);
}
lines.push("└───────────────────────────────────────────────────┘");
lines.push("");
lines.push("📝 INSTRUCTIONS:");
lines.push("1. Open https://chat.openai.com (or your ChatGPT Business URL)");
lines.push("2. Paste the prompt above into the input box");
lines.push("3. Click Send");
lines.push("4. Review ChatGPT's second-opinion response");
lines.push("5. Compare with Claude Code's analysis — use both perspectives");
lines.push("");
lines.push("📊 METADATA:");
lines.push(`Tool: ${toolName}`);
lines.push("Provider: Manual Export (no API calls)");
lines.push(`Prompt Length: ${length} characters`);
lines.push("");
lines.push("═══════════════════════════════════════════════════");
lines.push("This is advisory output only. Claude Code remains the executor.");
lines.push("═══════════════════════════════════════════════════");
return lines.join("\n");
}
/**
* Chat provider that wraps the pre-built prompt for manual copy/paste
* into ChatGPT Web, ChatGPT Business, or Claude Code.
* Implements { send(request, config) => Promise<{ content: string }> }.
*/
export const manualExportProvider = {
/**
* @param {{ prompt?: string, input?: object }} request - ProviderRequest with pre-built prompt.
* @param {object} _config - Config (unused — Manual Export is stateless).
* @returns {Promise<{ content: string }>} Copy-ready prompt output.
*/
async send(request, _config) {
const prompt = request?.prompt || "";
if (!prompt) {
return {
content: [
"⚠️ Manual Export Provider received no prompt to export.",
"",
"This indicates the handler did not pass the built prompt",
"in the provider request. Please check your handler",
"implementation and ensure reviewRequest includes { prompt }.",
].join("\n"),
};
}
const toolName = detectToolName(request);
return { content: formatManualExport(prompt, toolName) };
},
};
+1 -1
View File
@@ -10,7 +10,7 @@ import { sendOpenAIResponse } from "../openai/responses.js";
*/
export const openaiProvider = {
/**
* @param {object} input - validated tool input (post-budget check).
* @param {{ prompt?: string, input?: object }} request - ProviderRequest (prompt is unused by OpenAI provider).
* @param {object} config - full config from loadConfig().
* @returns {Promise<{ content: string }>}
*/
+7 -2
View File
@@ -15,7 +15,8 @@ import { buildArchitectureReviewPrompt } from "../prompts/architecture-review.js
* Raw tool input per ARCHITECTURE.md §7 schema.
* @param {{
* loadConfig: () => object,
* createProvider: (config: object) => { send: (input: object, cfg: object) => Promise<{ content: string }> }
* createProvider: (config: object) => { send: (request: ProviderRequest, cfg: object) => Promise<{ content: string }> }
* ProviderRequest = { prompt?: string, input?: object }
* }} deps
* Injected external dependencies.
* @returns {Promise<{ ok: true, answer: string, warnings: string[] } | { ok: false, error: string, warnings: string[] }>}
@@ -57,11 +58,15 @@ export async function handleArchitectureReview(input, deps) {
return { ok: false, error: String(err), warnings: [] };
}
// --- 5b. Package shared data for provider ---
const reviewRequest = { prompt: promptMessages, input: budget.input };
// --- 6. Send via provider ---
let aiResult;
try {
aiResult = await provider.send(budget.input, config);
aiResult = await provider.send(reviewRequest, config);
} catch (err) {
return { ok: false, error: String(err), warnings: [] };
}
+7 -2
View File
@@ -15,7 +15,8 @@ import { buildAskChatGptPrompt } from "../prompts/ask-chatgpt.js";
* Raw tool input per ARCHITECTURE.md §7 schema.
* @param {{
* loadConfig: () => object,
* createProvider: (config: object) => { send: (input: object, cfg: object) => Promise<{ content: string }> }
* createProvider: (config: object) => { send: (request: ProviderRequest, cfg: object) => Promise<{ content: string }> }
* ProviderRequest = { prompt?: string, input?: object }
* }} deps
* Injected external dependencies.
* @returns {Promise<{ ok: true, answer: string, warnings: string[] } | { ok: false, error: string, warnings: string[] }>}
@@ -57,11 +58,15 @@ export async function handleAskChatGpt(input, deps) {
return { ok: false, error: String(err), warnings: [] };
}
// --- 5b. Package shared data for provider ---
const reviewRequest = { prompt: promptMessages, input: budget.input };
// --- 6. Send via provider ---
let aiResult;
try {
aiResult = await provider.send(budget.input, config);
aiResult = await provider.send(reviewRequest, config);
} catch (err) {
return { ok: false, error: String(err), warnings: [] };
+7 -2
View File
@@ -15,7 +15,8 @@ import { buildDebugIssuePrompt } from "../prompts/debug-issue.js";
* Raw tool input per ARCHITECTURE.md §7 schema.
* @param {{
* loadConfig: () => object,
* createProvider: (config: object) => { send: (input: object, cfg: object) => Promise<{ content: string }> }
* createProvider: (config: object) => { send: (request: ProviderRequest, cfg: object) => Promise<{ content: string }> }
* ProviderRequest = { prompt?: string, input?: object }
* }} deps
* Injected external dependencies.
* @returns {Promise<{ ok: true, answer: string, warnings: string[] } | { ok: false, error: string, warnings: string[] }>}
@@ -57,11 +58,15 @@ export async function handleDebugIssue(input, deps) {
return { ok: false, error: String(err), warnings: [] };
}
// --- 5b. Package shared data for provider ---
const reviewRequest = { prompt: promptMessages, input: budget.input };
// --- 6. Send via provider ---
let aiResult;
try {
aiResult = await provider.send(budget.input, config);
aiResult = await provider.send(reviewRequest, config);
} catch (err) {
return { ok: false, error: String(err), warnings: [] };
}
+7 -2
View File
@@ -15,7 +15,8 @@ import { buildReviewCodePrompt } from "../prompts/review-code.js";
* Raw tool input per ARCHITECTURE.md §7 schema.
* @param {{
* loadConfig: () => object,
* createProvider: (config: object) => { send: (input: object, cfg: object) => Promise<{ content: string }> }
* createProvider: (config: object) => { send: (request: ProviderRequest, cfg: object) => Promise<{ content: string }> }
* ProviderRequest = { prompt?: string, input?: object }
* }} deps
* Injected external dependencies.
* @returns {Promise<{ ok: true, answer: string, warnings: string[] } | { ok: false, error: string, warnings: string[] }>}
@@ -57,11 +58,15 @@ export async function handleReviewCode(input, deps) {
return { ok: false, error: String(err), warnings: [] };
}
// --- 5b. Package shared data for provider ---
const reviewRequest = { prompt: promptMessages, input: budget.input };
// --- 6. Send via provider ---
let aiResult;
try {
aiResult = await provider.send(budget.input, config);
aiResult = await provider.send(reviewRequest, config);
} catch (err) {
return { ok: false, error: String(err), warnings: [] };
}
+7 -2
View File
@@ -15,7 +15,8 @@ import { buildReviewPlanPrompt } from "../prompts/review-plan.js";
* Raw tool input per ARCHITECTURE.md §7 schema.
* @param {{
* loadConfig: () => object,
* createProvider: (config: object) => { send: (input: object, cfg: object) => Promise<{ content: string }> }
* createProvider: (config: object) => { send: (request: ProviderRequest, cfg: object) => Promise<{ content: string }> }
* ProviderRequest = { prompt?: string, input?: object }
* }} deps
* Injected external dependencies.
* @returns {Promise<{ ok: true, answer: string, warnings: string[] } | { ok: false, error: string, warnings: string[] }>}
@@ -57,11 +58,15 @@ export async function handleReviewPlan(input, deps) {
return { ok: false, error: String(err), warnings: [] };
}
// --- 5b. Package shared data for provider ---
const reviewRequest = { prompt: promptMessages, input: budget.input };
// --- 6. Send via provider ---
let aiResult;
try {
aiResult = await provider.send(budget.input, config);
aiResult = await provider.send(reviewRequest, config);
} catch (err) {
return { ok: false, error: String(err), warnings: [] };