@launchchair/agent 0.4.11 → 0.5.0

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.
@@ -0,0 +1,227 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { bindProjectWorkspace } from "./project-workspace.mjs";
5
+
6
+ const string = { type: "string" };
7
+ const object = { type: "object", additionalProperties: true };
8
+ const patch = { type: "array", items: { type: "object", properties: { op: { type: "string", enum: ["add", "replace", "remove"] }, path: string, value: {} }, required: ["op", "path"], additionalProperties: false } };
9
+ const project = { projectId: string, projectName: string };
10
+ const bucket = { ...project, phaseKey: string, bucketKey: string };
11
+ const card = { ...project, cardId: string };
12
+ export const VALIDATION_GATE_SCHEMA = { anyOf: [{ type: "string", enum: ["very_high"] }, { type: "object", properties: { minPain: { type: "number", minimum: 0, maximum: 100 }, minOpportunity: { type: "number", minimum: 0, maximum: 100 }, maxSaturation: { type: "number", minimum: 0, maximum: 100 } }, additionalProperties: false }] };
13
+ export const MCP_WORKFLOW_INSTRUCTIONS = "Prefer launchchair_next_step: execute the returned claimed prompt in THIS host agent using its existing research, repository, GitHub, Vercel and Supabase tools, then launchchair_complete_run and repeat. Do not start a second model/CLI unless the user requests local-runner execution. The server cannot inherit or call the host's other plugins itself. Preserve a supplied idea by default, including when scores are low; report risks without rewriting the source problem, audience, solution or specified requirements. Only use ideaIntent=explore when the user requests discovery/pivots. Validation gates are opt-in: do not impose a gate on an established idea unless the user requested one. For 'VERY high' scores use validationGate=very_high (pain>=85, opportunity>=85, saturation<=30). Research real evidence, create separate candidates, finish validation and compare LaunchChair scorecards, never inflate evidence or use direction-option scores as validation. Stop at a failed gate. If no candidate qualifies, report that honestly. Use connected infrastructure plugins only after the gate passes, save their verified results with update_bucket plus executionEvidence, bind the repository, and continue. Present actual authorization/production SQL checkpoints; reuse existing plugin authorization. Do not claim external actions were done without evidence. Complete runnable QA yourself and record results; ask for human checks only when truly necessary. Never disclose bearer tokens or private login device codes.";
14
+
15
+ function tool(name, description, properties = {}, required = [], readOnly = false) {
16
+ return { name: `launchchair_${name}`, description, inputSchema: { type: "object", properties, required, additionalProperties: false }, annotations: { readOnlyHint: readOnly, destructiveHint: ["revoke_token", "cancel_run", "write_competitor_note", "upload_logo"].includes(name), openWorldHint: true } };
17
+ }
18
+
19
+ const definitions = [
20
+ ["list_competitor_notes", "Read competitor research notes in the current project.", project, [], "GET", "/competitor-notes"],
21
+ ["write_competitor_note", "Create, update, or delete a competitor note. Updating/deleting requires authorship or active owner access.", { ...project, operation: { type: "string", enum: ["create", "update", "delete"] }, noteId: string, competitorKey: string, competitorName: string, content: string }, ["operation"], "POST", "/competitor-notes"],
22
+ ["upload_logo", "Upload a PNG/WebP logo (base64, up to 1 MiB) or remove the existing logo with remove=true. Use the host's image tools to create the logo first.", { ...project, contentBase64: string, mimeType: { type: "string", enum: ["image/png", "image/webp"] }, generationPrompt: string, remove: { type: "boolean" } }, [], "POST", "/logo"],
23
+ ["research_sources", "Fetch LaunchChair research sources: Hacker News search, Product Hunt ranked posts, or a public HTTPS RSS feed (including Reddit/Indie Hackers). Returns source material without changing the idea. Inspect evidence before saving findings via bucket tools.", { ...project, source: { type: "string", enum: ["hn", "product_hunt", "rss"] }, query: string, url: string, limit: { type: "integer", minimum: 1, maximum: 40 } }, ["source"], "POST", "/research"],
24
+ ["search_media", "Search LaunchChair's Pexels integration for photos/videos. Retain source and creator attribution; save selected assets through the appropriate bucket.", { ...project, query: string, kind: { type: "string", enum: ["photo", "video"] }, limit: { type: "integer", minimum: 1, maximum: 40 } }, ["query"], "POST", "/media"],
25
+ ["get_starter", "Read the private LaunchChair starter manifest, then download batches of files (base64) using paths and treeSha. Paid, scoped, and validation-gated. Create/commit/deploy the repository with the host GitHub/Vercel/Supabase tools; no provider credentials are transferred to LaunchChair.", { ...project, paths: { type: "array", items: string, minItems: 1, maxItems: 20 }, treeSha: string }, [], "POST", "/starter"],
26
+ ["get_spec", "Read the normalized project spec, validation scores and saved idea intent.", project, [], "GET", "/spec"],
27
+ ["update_spec", "Advanced guarded spec patch. Requires spec.write; default tokens use bucket-specific tools. Cannot change idea intent or validation policy.", { ...project, patch }, ["patch"], "PATCH", "/spec"],
28
+ ["list_buckets", "List all phases/buckets and progress.", project, [], "GET", "/buckets"],
29
+ ["get_bucket", "Read a bucket's data, allowed patch paths and evidence requirements before editing.", bucket, ["phaseKey", "bucketKey"], "GET", "/buckets/:phaseKey/:bucketKey"],
30
+ ["update_bucket", "Save form/checklist data using allowed JSON Patch paths, or update status. For setup and checklists supply factual executionEvidence {source,checkedAt,summary} from the host's connected plugins/tests. Never store credentials. Read get_bucket first. Does not provision infrastructure itself.", { ...bucket, patch, status: { type: "string", enum: ["not_started", "in_progress", "done"] }, executionEvidence: object }, ["phaseKey", "bucketKey"], "PATCH", "/buckets/:phaseKey/:bucketKey"],
31
+ ["get_bucket_prompt", "Read a bucket's compiled prompt for execution by the current host agent. Brand supports variant=tagline or logo; campaign_builder requires campaignId. Save generated form output with update_bucket.", { ...bucket, variant: { type: "string", enum: ["tagline", "logo"] }, campaignId: string }, ["phaseKey", "bucketKey"], "GET", "/buckets/:phaseKey/:bucketKey/prompt"],
32
+ ["apply_bucket_update", "Apply validated SPEC_UPDATE JSON Patch for a prompt bucket. Prefer complete_run for claimed runs so validation/remediation executes.", { ...bucket, patch, compiledPrompt: string, templateKey: string, pastedResponse: string }, ["phaseKey", "bucketKey", "patch"], "POST", "/buckets/:phaseKey/:bucketKey/apply-spec-update"],
33
+ ["get_build_cards", "Inspect build cards, dependencies, SQL actions and QA checkpoints.", project, [], "GET", "/build-cards"],
34
+ ["update_build_card", "Record build-card status, acceptance, SQL completion and QA results only after verification. Supply executionEvidence {source,checkedAt,summary} when completing a card or checking results. This records results, it does not execute SQL or tests.", { ...card, status: { type: "string", enum: ["To Do", "In Progress", "Needs Review", "Done"] }, notes: string, acceptanceStatus: object, testingNotesStatus: object, sqlActionsStatus: object, executionEvidence: object }, ["cardId", "status"], "PATCH", "/build-cards/:cardId"],
35
+ ["get_build_card_prompt", "Read the compiled build-card prompt. Requires a passing validation gate when configured.", card, ["cardId"], "GET", "/build-cards/:cardId/prompt"],
36
+ ["apply_build_card_run", "Apply CHANGELOG_JSON/REQUIRES from an executed build card. Use the API payload contract returned by the catalog.", { ...card, payload: object }, ["cardId", "payload"], "POST", "/build-cards/:cardId/apply-run"],
37
+ ["export_mvp_spec", "Export the AI-ready MVP markdown specification.", project, [], "GET", "/exports/mvp-ai-spec"],
38
+ ["export_phase", "Export a phase synthesis as markdown.", { ...project, phaseKey: string }, ["phaseKey"], "GET", "/exports/phase/:phaseKey"],
39
+ ["get_runner_workspace", "Read expected repository and workspace requirements.", project, [], "GET", "/runner-workspace"],
40
+ ["get_validation_gate", "Evaluate the saved validation gate using current calculated LaunchChair scores.", project, [], "GET", "/validation-gate"],
41
+ ["set_validation_gate", "Set or tighten an explicitly user-requested gate. Does not authorize changing the user's idea. An existing gate cannot be weakened by this tool.", { ...project, validationGate: VALIDATION_GATE_SCHEMA }, ["validationGate"], "POST", "/validation-gate"],
42
+ ["list_runs", "List recent runs belonging to this token and project.", project, [], "GET", "/runs"],
43
+ ["queue_run", "Queue a specific prompt bucket or build-card run, including research reruns. Then claim it and execute in this host agent; complete_run applies and validates the result.", { ...bucket, cardId: string }, [], "POST", "/runs"],
44
+ ];
45
+
46
+ export const EXTRA_MCP_TOOLS = [
47
+ ...definitions.map(([name, description, properties, required, method]) => tool(name, description, properties, required, method === "GET")),
48
+ tool("login", "Start browser-assisted LaunchChair login/token creation without an existing token. Return the existing LaunchChair approval URL for the user; then poll login_status. No password or token is requested in chat.", { tokenName: string, restart: { type: "boolean" } }),
49
+ tool("login_status", "Poll the pending browser approval once. Respect retryAfterSeconds. On approval save the token privately and authenticate this MCP session immediately.", {}),
50
+ tool("logout", "Disconnect this MCP and remove its saved token. Optionally revoke the current token on the server first.", { revoke: { type: "boolean", default: false } }),
51
+ tool("list_tokens", "List redacted tokens. Requires fresh owner authorization with tokens.manage and workspace-wide access.", {}, [], true),
52
+ tool("create_token", "Create a token with no more privileges/lifetime than the current token. Store its secret in a private local file and return only metadata/path. Optionally activate it for this MCP. Requires tokens.manage from owner-approved login.", { name: string, projectIds: { type: "array", items: string }, scopes: { type: "array", items: string }, expiresAt: string, activate: { type: "boolean", default: false } }, ["name"]),
53
+ tool("revoke_token", "Revoke a token in the authorized workspace. Revoking the current token disconnects this MCP.", { tokenId: string }, ["tokenId"]),
54
+ tool("get_run", "Read a run's status and output. Do not execute a run already claimed by another worker.", { runId: string }, ["runId"], true),
55
+ tool("cancel_run", "Cancel a queued/running run so further results cannot be applied. Stop any host process separately.", { runId: string }, ["runId"]),
56
+ tool("next_step", "Queue and atomically claim ONE next step for THIS host agent, without spawning a separate runner. Execute its prompt with your own tools/plugins, complete_run, then call again. Returns forms/setup/gate checkpoints when applicable. Set stopPhaseKey=launch_sales for the entire Product workflow, or market_validation for research only.", { ...project, stopPhaseKey: string, completionTarget: { type: "string", enum: ["phase_complete", "build_board_ready"] }, ideaSnapshot: object, choiceSelections: { type: "array", items: object }, agentSetup: object }, []),
57
+ tool("bind_repository", "Bind a local clone after checking its Git origin against the repository saved in LaunchChair. No cloning or infrastructure provisioning occurs.", { ...project, cwd: string }, ["cwd"]),
58
+ tool("compare_projects", "Compare up to 20 candidate ideas using calculated validation scores and requested thresholds. Never mutates ideas or inflates scores; returns qualifying candidates separately.", { projectIds: { type: "array", items: string, minItems: 1, maxItems: 20 }, validationGate: VALIDATION_GATE_SCHEMA }, ["projectIds"], true),
59
+ tool("research_opportunities", "Get a bounded research workflow for an explicitly exploratory user request. Execute the research using this host's search tools, then create and validate separate LaunchChair candidates. This tool itself does not research or create projects.", { audience: string, maxCandidates: { type: "integer", minimum: 1, maximum: 20, default: 5 }, validationGate: VALIDATION_GATE_SCHEMA }, ["audience"], true),
60
+ ];
61
+
62
+ export function createMcpCapabilities({ config, configPath, api, resolveProjectId, onAuthChange }) {
63
+ let sessionToken;
64
+ let pollInFlight = false;
65
+ let authEpoch = 0;
66
+ const projectSteps = new Set();
67
+ const configDir = path.dirname(configPath);
68
+ function readConfig() { try { const value = JSON.parse(readFileSync(configPath, "utf8")); return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } catch { return {}; } }
69
+ function saveConfig(changes) {
70
+ const next = { ...readConfig(), ...changes };
71
+ for (const key of Object.keys(next)) if (next[key] === undefined) delete next[key];
72
+ mkdirSync(configDir, { recursive: true, mode: 0o700 });
73
+ const temporary = `${configPath}.${randomUUID()}.tmp`;
74
+ writeFileSync(temporary, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
75
+ renameSync(temporary, configPath);
76
+ chmodSync(configPath, 0o600);
77
+ }
78
+ function currentToken() {
79
+ if (sessionToken !== undefined) return sessionToken;
80
+ const saved = readConfig();
81
+ if (saved.mcpLoggedOut === true) return "";
82
+ if (saved.mcpPreferSavedToken === true && saved.agentToken) return saved.agentToken;
83
+ return process.env.LAUNCHCHAIR_AGENT_TOKEN?.trim() || process.env.LAUNCHCHAIR_API_KEY?.trim() || saved.agentToken || "";
84
+ }
85
+ function activate(token) {
86
+ saveConfig({ agentToken: token, baseUrl: config.baseUrl, mcpLoggedOut: false, mcpPreferSavedToken: true, pendingMcpLogin: undefined });
87
+ sessionToken = token;
88
+ onAuthChange();
89
+ }
90
+ function disconnect() {
91
+ authEpoch += 1;
92
+ saveConfig({ agentToken: undefined, pendingMcpLogin: undefined, mcpLoggedOut: true });
93
+ sessionToken = "";
94
+ onAuthChange();
95
+ }
96
+ async function publicRequest(endpoint, body) {
97
+ const response = await fetch(`${config.baseUrl}${endpoint}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(30_000) });
98
+ const payload = await response.json();
99
+ if (response.status === 429) return { status: "pending", retryAfterSeconds: Math.max(5, Number(response.headers.get("retry-after")) || 10) };
100
+ if (!response.ok) {
101
+ const error = new Error(String(payload.error || payload.status || `HTTP ${response.status}`));
102
+ error.status = response.status;
103
+ throw error;
104
+ }
105
+ return payload;
106
+ }
107
+ async function loginStatus() {
108
+ const saved = readConfig().pendingMcpLogin;
109
+ if (!saved) return { status: currentToken() ? "authenticated" : "not_started", nextTool: currentToken() ? "launchchair_status" : "launchchair_login" };
110
+ if (Date.now() >= saved.expiresAt) { saveConfig({ pendingMcpLogin: undefined }); return { status: "expired", nextTool: "launchchair_login" }; }
111
+ if (pollInFlight || Date.now() < (saved.nextPollAt || 0)) return { status: "pending", retryAfterSeconds: Math.max(1, Math.ceil(((saved.nextPollAt || Date.now() + 5000) - Date.now()) / 1000)) };
112
+ pollInFlight = true;
113
+ const epoch = authEpoch;
114
+ try {
115
+ const result = await publicRequest("/api/agent/v1/cli-login/poll", { deviceCode: saved.deviceCode });
116
+ if (epoch !== authEpoch || readConfig().pendingMcpLogin?.deviceCode !== saved.deviceCode) return { status: "cancelled", nextTool: "launchchair_login" };
117
+ if (result.status === "approved" && result.token) {
118
+ activate(result.token);
119
+ return { status: "authenticated", tokenRecord: result.tokenRecord, nextTool: "launchchair_status", message: "Login approved; token saved privately. MCP is ready without restarting." };
120
+ }
121
+ const retryAfterSeconds = Math.max(5, Number(result.retryAfterSeconds || result.interval) || 5);
122
+ saveConfig({ pendingMcpLogin: { ...saved, nextPollAt: Date.now() + retryAfterSeconds * 1000 } });
123
+ return { status: result.status || "pending", retryAfterSeconds };
124
+ } catch (error) {
125
+ if ([400, 403, 404, 410].includes(error.status)) saveConfig({ pendingMcpLogin: undefined });
126
+ throw error;
127
+ } finally { pollInFlight = false; }
128
+ }
129
+ async function dispatch(name, args) {
130
+ if (name === "launchchair_login") {
131
+ let pending = readConfig().pendingMcpLogin;
132
+ if (!pending || pending.expiresAt <= Date.now() || args.restart === true) {
133
+ const epoch = ++authEpoch;
134
+ const started = await publicRequest("/api/agent/v1/cli-login/start", { clientName: "LaunchChair MCP", tokenName: args.tokenName || "LaunchChair MCP" });
135
+ if (epoch !== authEpoch) return { status: "cancelled", nextTool: "launchchair_login" };
136
+ if (!started.deviceCode || !started.verificationUriComplete) return { status: "rate_limited", retryAfterSeconds: started.retryAfterSeconds || 10 };
137
+ const approvalUrl = new URL(started.verificationUriComplete);
138
+ if (approvalUrl.origin !== new URL(config.baseUrl).origin) throw new Error("Login approval URL does not match the configured LaunchChair origin.");
139
+ pending = { deviceCode: started.deviceCode, userCode: started.userCode, approvalUrl: approvalUrl.toString(), expiresAt: Date.now() + Number(started.expiresIn || 600) * 1000, nextPollAt: 0 };
140
+ saveConfig({ pendingMcpLogin: pending });
141
+ }
142
+ return { status: "approval_required", approvalUrl: pending.approvalUrl, userCode: pending.userCode, expiresAt: new Date(pending.expiresAt).toISOString(), nextTool: "launchchair_login_status", message: "Open this LaunchChair URL, sign in if needed, and approve this MCP. The workspace owner authorizes token creation." };
143
+ }
144
+ if (name === "launchchair_login_status") return loginStatus();
145
+ if (name === "launchchair_logout") {
146
+ if (args.revoke === true && currentToken()) {
147
+ const tokens = await api("/api/agent/v1/tokens");
148
+ await api(`/api/agent/v1/tokens/${encodeURIComponent(tokens.currentTokenId)}/revoke`, { method: "POST", body: {} });
149
+ }
150
+ disconnect();
151
+ return { status: "logged_out", revoked: args.revoke === true };
152
+ }
153
+ if (name === "launchchair_list_tokens") return api("/api/agent/v1/tokens");
154
+ if (name === "launchchair_create_token") {
155
+ const { activate: useToken, ...body } = args;
156
+ const created = await api("/api/agent/v1/tokens", { method: "POST", body });
157
+ if (!created.token || !created.tokenRecord?.id) throw new Error("Token creation returned incomplete credentials.");
158
+ const credentialPath = path.join(configDir, "tokens", `${randomUUID()}.json`);
159
+ mkdirSync(path.dirname(credentialPath), { recursive: true, mode: 0o700 });
160
+ writeFileSync(credentialPath, JSON.stringify({ agentToken: created.token, baseUrl: config.baseUrl, tokenRecord: created.tokenRecord }) + "\n", { mode: 0o600, flag: "wx" });
161
+ if (useToken === true) activate(created.token);
162
+ return { tokenRecord: created.tokenRecord, credentialPath, activated: useToken === true, message: "Token secret saved privately; do not print the credential file in chat." };
163
+ }
164
+ if (name === "launchchair_revoke_token") {
165
+ const result = await api(`/api/agent/v1/tokens/${encodeURIComponent(args.tokenId)}/revoke`, { method: "POST", body: {} });
166
+ if (result.currentTokenRevoked) disconnect();
167
+ return result;
168
+ }
169
+ if (name === "launchchair_get_run" || name === "launchchair_cancel_run") return api(`/api/agent/v1/runs/${encodeURIComponent(args.runId)}`, name.endsWith("cancel_run") ? { method: "PATCH", body: {} } : {});
170
+ if (name === "launchchair_next_step") {
171
+ const projectId = await resolveProjectId(args);
172
+ const { projectId: _id, projectName: _name, ...body } = args;
173
+ if (projectSteps.has(projectId)) return { status: "waiting_for_run", projectId, message: "A next_step request is already in progress for this project. Wait for its result." };
174
+ projectSteps.add(projectId);
175
+ try {
176
+ const step = await api(`/api/agent/v1/projects/${encodeURIComponent(projectId)}/loop/continue`, { method: "POST", body });
177
+ if (step.status === "needs_human_input" && step.current) return { ...step, hostAction: { tool: step.current.cardId ? "launchchair_get_build_cards" : "launchchair_get_bucket", arguments: { projectId, phaseKey: step.current.phaseKey, bucketKey: step.current.bucketKey }, instructions: "Inspect the checkpoint and act with existing user authorization and connected tools. Save verified form/QA results or select a choice consistent with the supplied idea. Ask the user only for information or authorization that is actually missing." } };
178
+ if (!["queued_run", "waiting_for_run"].includes(step.status) || !step.run?.id) return step;
179
+ const claimed = await api("/api/agent/v1/runs/next", { method: "POST", body: { projectId, runId: step.run.id, clientName: config.clientName, clientType: config.clientType, clientVersion: config.clientVersion } });
180
+ if (!claimed.run) return { ...step, run: { id: step.run.id }, status: "waiting_for_run", message: "This run is already claimed. Poll get_run; do not execute it again." };
181
+ return { ...step, status: "run_ready", run: claimed.run, execution: "host", nextTool: "launchchair_complete_run", instructions: MCP_WORKFLOW_INSTRUCTIONS };
182
+ } finally { projectSteps.delete(projectId); }
183
+ }
184
+ if (name === "launchchair_bind_repository") {
185
+ const projectId = await resolveProjectId(args);
186
+ const { workspace } = await api(`/api/agent/v1/projects/${encodeURIComponent(projectId)}/runner-workspace`);
187
+ const repositoryUrl = workspace?.repository?.repoUrl;
188
+ if (!repositoryUrl) throw new Error("Save the project repository URL in LaunchChair before binding a clone.");
189
+ const bound = bindProjectWorkspace({ configPath, projectId, cwd: args.cwd, repositoryUrl });
190
+ return { projectId, binding: bound };
191
+ }
192
+ if (name === "launchchair_research_opportunities") return { audience: args.audience, maxCandidates: args.maxCandidates || 5, validationGate: args.validationGate ?? null, ideaIntent: "explore", steps: ["Research the audience's complaints, current workflows, substitutes, willingness to pay and named competitors using the host's search tools. Cite real sources and distinguish hypotheses from evidence.", "Create separate Product candidates with ideaSnapshot, ideaIntent=explore and a stable idempotencyKey. Include validationGate only when the user requested a score threshold.", "For each candidate call next_step with stopPhaseKey=market_validation, execute the prompt, complete_run and repeat. Inspect errors and perform requested remediation.", "Compare using compare_projects. Build only a qualifying candidate; if none qualifies, continue bounded research or report the unmet thresholds honestly.", "Use host GitHub/Vercel/Supabase plugins after the gate passes, record verified setup with update_bucket, bind_repository, then next_step through launch_sales."], instructions: MCP_WORKFLOW_INSTRUCTIONS };
193
+ if (name === "launchchair_compare_projects") {
194
+ if (!Array.isArray(args.projectIds) || !args.projectIds.length || args.projectIds.length > 20) throw new Error("Provide 1–20 projectIds.");
195
+ const thresholds = args.validationGate && typeof args.validationGate === "object" ? { minPain: 85, minOpportunity: 85, maxSaturation: 30, ...args.validationGate } : { minPain: 85, minOpportunity: 85, maxSaturation: 30 };
196
+ if (Object.values(thresholds).some((value) => typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 100)) throw new Error("Validation thresholds must be numbers between 0 and 100.");
197
+ const candidates = [];
198
+ for (const projectId of [...new Set(args.projectIds)]) {
199
+ const payload = await api(`/api/agent/v1/projects/${encodeURIComponent(projectId)}/scorecards`);
200
+ const validation = payload.validationScorecards || payload.scorecards;
201
+ const cards = Array.isArray(validation) ? validation : validation?.scorecards || [];
202
+ const score = (id) => cards.find((entry) => entry.id === id)?.score;
203
+ const pain = score("icp_pain"), opportunity = score("wedge_opportunity"), saturation = score("market_saturation");
204
+ const qualifies = [pain, opportunity, saturation].every((value) => typeof value === "number") && pain >= thresholds.minPain && opportunity >= thresholds.minOpportunity && saturation <= thresholds.maxSaturation;
205
+ candidates.push({ projectId, qualifies, pain, opportunity, saturation, validationScorecards: payload });
206
+ }
207
+ return { thresholds, candidates, qualifyingProjectIds: candidates.filter((candidate) => candidate.qualifies).map((candidate) => candidate.projectId), message: "These are calculated LaunchChair validation scores, not a guarantee of market success. Preserve supplied ideas; only explore alternatives when requested." };
208
+ }
209
+ const definition = definitions.find(([short]) => name === `launchchair_${short}`);
210
+ if (!definition) return undefined;
211
+ const [, , , , method, suffix] = definition;
212
+ const projectId = await resolveProjectId(args);
213
+ let endpoint = `/api/agent/v1/projects/${encodeURIComponent(projectId)}${suffix.replace(/:(\w+)/g, (_, key) => {
214
+ if (!args[key]) throw new Error(`${key} is required.`);
215
+ return encodeURIComponent(args[key]);
216
+ })}`;
217
+ if (name === "launchchair_get_bucket_prompt") {
218
+ const query = new URLSearchParams();
219
+ if (args.variant) query.set("variant", args.variant);
220
+ if (args.campaignId) query.set("campaignId", args.campaignId);
221
+ if (query.size) endpoint += `?${query}`;
222
+ }
223
+ const body = args.payload || Object.fromEntries(Object.entries(args).filter(([key]) => !["projectId", "projectName", ...(name === "launchchair_queue_run" ? [] : ["phaseKey", "bucketKey", "cardId"])].includes(key)));
224
+ return api(endpoint, method === "GET" ? {} : { method, body });
225
+ }
226
+ return { currentToken, dispatch };
227
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@launchchair/agent",
3
- "version": "0.4.11",
3
+ "version": "0.5.0",
4
4
  "mcpName": "io.launchchair/agent",
5
5
  "description": "LaunchChair Agent CLI, MCP bridge, local bridge, and runners for Codex, Claude Code, ChatGPT, Claude, and Hermes workflows.",
6
6
  "type": "module",
@@ -15,6 +15,8 @@
15
15
  "launchchair-cli.mjs",
16
16
  "local-bridge.mjs",
17
17
  "mcp-bridge-server.mjs",
18
+ "mcp-capabilities.mjs",
19
+ "project-workspace.mjs",
18
20
  "runner-command.mjs",
19
21
  "codex-runner.mjs",
20
22
  "claude-code-runner.mjs",
@@ -0,0 +1,309 @@
1
+ import {
2
+ chmodSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ renameSync,
7
+ statSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+
14
+ function asRecord(value) {
15
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
16
+ }
17
+
18
+ function text(value) {
19
+ return typeof value === "string" ? value.trim() : "";
20
+ }
21
+
22
+ function readConfig(configPath) {
23
+ try {
24
+ const parsed = JSON.parse(readFileSync(configPath, "utf8"));
25
+ return asRecord(parsed);
26
+ } catch {
27
+ return {};
28
+ }
29
+ }
30
+
31
+ function writeConfig(configPath, next) {
32
+ const directory = path.dirname(configPath);
33
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
34
+ const temporary = path.join(directory, `.${path.basename(configPath)}.${process.pid}.${Date.now()}.tmp`);
35
+ try {
36
+ writeFileSync(temporary, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
37
+ renameSync(temporary, configPath);
38
+ try {
39
+ chmodSync(configPath, 0o600);
40
+ } catch {
41
+ // Best effort for filesystems without POSIX permissions.
42
+ }
43
+ } finally {
44
+ if (existsSync(temporary)) unlinkSync(temporary);
45
+ }
46
+ }
47
+
48
+ function safeProjectKey(projectId) {
49
+ const value = text(projectId);
50
+ if (!value) throw new Error("LaunchChair projectId is required to resolve a local workspace.");
51
+ return value.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 160);
52
+ }
53
+
54
+ export function normalizeGitRemote(value) {
55
+ let remote = text(value);
56
+ if (!remote) return "";
57
+ if (remote.startsWith("git@")) {
58
+ const match = remote.match(/^git@([^:]+):(.+)$/);
59
+ if (match) remote = `https://${match[1]}/${match[2]}`;
60
+ } else if (remote.startsWith("ssh://git@")) {
61
+ remote = remote.replace(/^ssh:\/\/git@/, "https://");
62
+ }
63
+ try {
64
+ const parsed = new URL(remote);
65
+ const pathname = parsed.pathname.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "");
66
+ return `${parsed.hostname.toLowerCase()}/${pathname.toLowerCase()}`;
67
+ } catch {
68
+ return remote.replace(/\.git$/i, "").replace(/\/+$/, "").toLowerCase();
69
+ }
70
+ }
71
+
72
+ export function repositoryNameFromUrl(value) {
73
+ const normalized = normalizeGitRemote(value);
74
+ return normalized.split("/").filter(Boolean).at(-1) ?? "";
75
+ }
76
+
77
+ function gitDirectory(repoPath) {
78
+ const dotGit = path.join(repoPath, ".git");
79
+ if (!existsSync(dotGit)) return "";
80
+ try {
81
+ if (statSync(dotGit).isDirectory()) return dotGit;
82
+ const pointer = readFileSync(dotGit, "utf8").match(/^gitdir:\s*(.+)$/im)?.[1]?.trim();
83
+ if (!pointer) return "";
84
+ return path.resolve(repoPath, pointer);
85
+ } catch {
86
+ return "";
87
+ }
88
+ }
89
+
90
+ export function readGitOrigin(repoPath) {
91
+ const gitDir = gitDirectory(repoPath);
92
+ if (!gitDir) return "";
93
+ try {
94
+ // Linked worktrees keep remote configuration in their common Git directory.
95
+ const commonPointer = path.join(gitDir, "commondir");
96
+ const commonDir = existsSync(commonPointer) ? path.resolve(gitDir, readFileSync(commonPointer, "utf8").trim()) : gitDir;
97
+ const raw = readFileSync(path.join(commonDir, "config"), "utf8");
98
+ const remoteBlock = raw.match(/\[remote\s+"origin"\]([\s\S]*?)(?=\n\[|$)/i)?.[1] ?? "";
99
+ return remoteBlock.match(/^\s*url\s*=\s*(.+)$/im)?.[1]?.trim() ?? "";
100
+ } catch {
101
+ return "";
102
+ }
103
+ }
104
+
105
+ function projectBindings(config) {
106
+ return asRecord(config.projectWorkspaces);
107
+ }
108
+
109
+ function bindingPath(config, projectId) {
110
+ const value = projectBindings(config)[projectId];
111
+ if (typeof value === "string") return text(value);
112
+ return text(asRecord(value).path);
113
+ }
114
+
115
+ function repositoryMatches(repoPath, expectedRepositoryUrl) {
116
+ const origin = readGitOrigin(repoPath);
117
+ if (!origin) return false;
118
+ const expected = normalizeGitRemote(expectedRepositoryUrl);
119
+ return !expected || normalizeGitRemote(origin) === expected;
120
+ }
121
+
122
+ function standardSearchRoots(config, legacyPaths = [], extraRoots = []) {
123
+ const home = os.homedir();
124
+ const configuredRoots = Array.isArray(config.repoSearchRoots) ? config.repoSearchRoots.map(text) : [];
125
+ const bindingParents = Object.values(projectBindings(config))
126
+ .map((value) => (typeof value === "string" ? value : asRecord(value).path))
127
+ .map(text)
128
+ .filter(Boolean)
129
+ .map((value) => path.dirname(path.resolve(value)));
130
+ const legacyParents = legacyPaths.map(text).filter(Boolean).map((value) => path.dirname(path.resolve(value)));
131
+ const legacyRoots = legacyPaths.map(text).filter(Boolean).map((value) => path.resolve(value));
132
+ return Array.from(
133
+ new Set(
134
+ [
135
+ ...extraRoots,
136
+ ...configuredRoots,
137
+ ...bindingParents,
138
+ ...legacyRoots,
139
+ ...legacyParents,
140
+ home ? path.join(home, "Documents", "GitHub") : "",
141
+ home ? path.join(home, "Developer") : "",
142
+ home ? path.join(home, "Projects") : "",
143
+ home ? path.join(home, "repos") : "",
144
+ ]
145
+ .map(text)
146
+ .filter(Boolean)
147
+ .map((value) => path.resolve(value)),
148
+ ),
149
+ );
150
+ }
151
+
152
+ function saveBinding(configPath, projectId, repoPath, repositoryUrl, source) {
153
+ const config = readConfig(configPath);
154
+ const bindings = projectBindings(config);
155
+ const next = {
156
+ ...config,
157
+ projectWorkspaces: {
158
+ ...bindings,
159
+ [projectId]: {
160
+ path: path.resolve(repoPath),
161
+ repositoryUrl: text(repositoryUrl) || text(asRecord(bindings[projectId]).repositoryUrl) || undefined,
162
+ source,
163
+ updatedAt: new Date().toISOString(),
164
+ },
165
+ },
166
+ };
167
+ writeConfig(configPath, next);
168
+ return next.projectWorkspaces[projectId];
169
+ }
170
+
171
+ export function bindProjectWorkspace({ configPath, projectId, cwd, repositoryUrl = "", source = "explicit" }) {
172
+ const id = text(projectId);
173
+ safeProjectKey(id);
174
+ const resolved = path.resolve(text(cwd));
175
+ if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
176
+ throw new Error(`Local project directory does not exist: ${resolved}`);
177
+ }
178
+ const expected = text(repositoryUrl);
179
+ const origin = readGitOrigin(resolved);
180
+ if (expected && !origin) {
181
+ throw new Error(`The selected directory is not a Git clone. Expected ${expected}.`);
182
+ }
183
+ if (expected && normalizeGitRemote(origin) !== normalizeGitRemote(expected)) {
184
+ throw new Error(`Git origin mismatch. Expected ${expected}, found ${origin || "no origin"}.`);
185
+ }
186
+ return saveBinding(configPath, id, resolved, expected || origin, source);
187
+ }
188
+
189
+ export function unbindProjectWorkspace({ configPath, projectId }) {
190
+ const id = text(projectId);
191
+ safeProjectKey(id);
192
+ const config = readConfig(configPath);
193
+ const bindings = { ...projectBindings(config) };
194
+ const existed = Object.prototype.hasOwnProperty.call(bindings, id);
195
+ delete bindings[id];
196
+ writeConfig(configPath, { ...config, projectWorkspaces: bindings });
197
+ return existed;
198
+ }
199
+
200
+ export function listProjectWorkspaceBindings(configPath) {
201
+ return projectBindings(readConfig(configPath));
202
+ }
203
+
204
+ export function isolatedProjectWorkspace(configDir, projectId) {
205
+ const key = safeProjectKey(projectId);
206
+ const target = path.join(configDir, "projects", key, "workspace");
207
+ mkdirSync(target, { recursive: true, mode: 0o700 });
208
+ const instructionsPath = path.join(target, "AGENTS.md");
209
+ if (!existsSync(instructionsPath)) {
210
+ writeFileSync(
211
+ instructionsPath,
212
+ [
213
+ "# LaunchChair isolated project workspace",
214
+ "",
215
+ `This folder belongs only to LaunchChair project ${projectId}.`,
216
+ "Use the LaunchChair prompt and MCP/API context for pre-code work.",
217
+ "Do not inspect, edit, or execute against unrelated repositories.",
218
+ "Before code-changing work, bind a verified local Git clone with `launchchair bind --project-id <id> --cwd <path>`.",
219
+ "",
220
+ ].join("\n"),
221
+ { mode: 0o600 },
222
+ );
223
+ }
224
+ return target;
225
+ }
226
+
227
+ export function resolveProjectWorkspace({
228
+ configDir,
229
+ configPath,
230
+ projectId,
231
+ repository = {},
232
+ requireRepository = false,
233
+ preferRepository = false,
234
+ legacyPaths = [],
235
+ searchRoots = [],
236
+ }) {
237
+ const id = text(projectId);
238
+ safeProjectKey(id);
239
+ const config = readConfig(configPath);
240
+ const repo = asRecord(repository);
241
+ const repositoryUrl = text(repo.repoUrl) || text(repo.cloneUrl);
242
+ const cloneUrl = text(repo.cloneUrl) || repositoryUrl;
243
+ const repoName = text(repo.repoName) || repositoryNameFromUrl(repositoryUrl);
244
+ const boundPath = bindingPath(config, id);
245
+ const scratchPath = isolatedProjectWorkspace(configDir, id);
246
+
247
+ if (!requireRepository && !preferRepository) {
248
+ return {
249
+ path: scratchPath,
250
+ source: "isolated",
251
+ repositoryVerified: false,
252
+ repositoryUrl,
253
+ cloneUrl,
254
+ };
255
+ }
256
+
257
+ if (!repositoryUrl) {
258
+ if (requireRepository) {
259
+ throw new Error(
260
+ `LaunchChair project ${id} does not have repository metadata yet. Finish Stack Setup before running code-changing work.`,
261
+ );
262
+ }
263
+ return {
264
+ path: scratchPath,
265
+ source: "isolated",
266
+ repositoryVerified: false,
267
+ repositoryUrl: "",
268
+ cloneUrl: "",
269
+ };
270
+ }
271
+
272
+ const candidates = [boundPath, scratchPath, ...legacyPaths.map(text).filter(Boolean)];
273
+
274
+ if (repoName) {
275
+ for (const root of standardSearchRoots(config, legacyPaths, searchRoots)) {
276
+ candidates.push(path.join(root, repoName));
277
+ }
278
+ }
279
+
280
+ for (const candidate of Array.from(new Set(candidates.filter(Boolean).map((value) => path.resolve(value))))) {
281
+ if (!existsSync(candidate)) continue;
282
+ if (!repositoryMatches(candidate, repositoryUrl)) continue;
283
+ const source = candidate === boundPath ? "binding" : "auto_detected";
284
+ if (candidate !== boundPath) saveBinding(configPath, id, candidate, repositoryUrl || readGitOrigin(candidate), source);
285
+ return {
286
+ path: candidate,
287
+ source,
288
+ repositoryVerified: true,
289
+ repositoryUrl: repositoryUrl || readGitOrigin(candidate),
290
+ cloneUrl,
291
+ };
292
+ }
293
+
294
+ if (requireRepository) {
295
+ const expected = repositoryUrl ? ` for ${repositoryUrl}` : "";
296
+ const cloneStep = cloneUrl && repoName ? ` Clone it with: git clone ${cloneUrl} \"${repoName}\".` : "";
297
+ throw new Error(
298
+ `No verified local Git clone is bound to LaunchChair project ${id}${expected}.${cloneStep} Then run: launchchair bind --project-id ${id} --cwd \"/absolute/path/to/${repoName || "repo"}\"`,
299
+ );
300
+ }
301
+
302
+ return {
303
+ path: scratchPath,
304
+ source: "isolated",
305
+ repositoryVerified: false,
306
+ repositoryUrl,
307
+ cloneUrl,
308
+ };
309
+ }
package/server.json CHANGED
@@ -3,13 +3,13 @@
3
3
  "name": "io.launchchair/agent",
4
4
  "title": "LaunchChair",
5
5
  "description": "Plan, validate, and build products and features with LaunchChair.",
6
- "version": "0.4.11",
6
+ "version": "0.5.0",
7
7
  "websiteUrl": "https://www.launchchair.io/docs/mcp",
8
8
  "packages": [
9
9
  {
10
10
  "registryType": "npm",
11
11
  "identifier": "@launchchair/agent",
12
- "version": "0.4.11",
12
+ "version": "0.5.0",
13
13
  "runtimeHint": "npx",
14
14
  "runtimeArguments": [
15
15
  {
@@ -29,8 +29,8 @@
29
29
  "environmentVariables": [
30
30
  {
31
31
  "name": "LAUNCHCHAIR_AGENT_TOKEN",
32
- "description": "LaunchChair Agent API token from Settings > API Access.",
33
- "isRequired": true,
32
+ "description": "Optional pre-existing token. Without it, call launchchair_login for browser approval and private token storage.",
33
+ "isRequired": false,
34
34
  "isSecret": true,
35
35
  "format": "string"
36
36
  }