@amityco/foundry-mcp 0.1.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.
package/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # Foundry MCP Server
2
+
3
+ This is a small stdio MCP server for local social.plus integration assistance.
4
+
5
+ The server can inspect repositories, detect design/theme signals, search hosted docs, plan harness guides/sensors, produce grounded integration plans with structured intake questions, validate setup patterns, and run detected project sensors.
6
+
7
+ Frequent customer paths have dedicated guardrails:
8
+
9
+ - notification setup: platform credentials, token source, registration after login, unregister on logout/user switch
10
+ - Live Objects/Collections: object vs collection, observed domain, lifecycle owner, loading/error states, cleanup
11
+ - monorepos: detected app surfaces can be selected with `surfacePath`
12
+
13
+ `run_sensors` is constrained to detected project commands. It does not accept arbitrary shell input.
14
+
15
+ ## Commands
16
+
17
+ ```sh
18
+ npm install
19
+ npm run build
20
+ npm start
21
+ ```
22
+
23
+ CLI checks:
24
+
25
+ ```sh
26
+ node dist/server.js --version
27
+ node dist/server.js doctor
28
+ ```
29
+
30
+ Full local validation:
31
+
32
+ ```sh
33
+ npm run validate
34
+ ```
35
+
36
+ Product-oriented Foundry improvement discovery:
37
+
38
+ ```sh
39
+ npm run test:improvements
40
+ ```
41
+
42
+ ## Docs Source
43
+
44
+ By default, docs are read from:
45
+
46
+ ```text
47
+ https://learn.social.plus/llms-full.txt
48
+ ```
49
+
50
+ Maintainers can set `SOCIAL_PLUS_DOCS_ROOT` to test against a local `social-plus-docs` checkout.
package/dist/server.js ADDED
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
5
+ import { getDocPageTool, searchDocsTool } from "./tools/docs.js";
6
+ import { planHarnessTool } from "./tools/harness.js";
7
+ import { planIntegrationTool } from "./tools/integration.js";
8
+ import { inspectProjectTool, validateSetupTool } from "./tools/project.js";
9
+ import { resolveRequestTool, suggestPatchTool } from "./tools/resolve.js";
10
+ import { runSensorsTool } from "./tools/sensors.js";
11
+ const tools = new Map([searchDocsTool, getDocPageTool, inspectProjectTool, planHarnessTool, planIntegrationTool, resolveRequestTool, runSensorsTool, validateSetupTool, suggestPatchTool].map((tool) => [
12
+ tool.name,
13
+ tool,
14
+ ]));
15
+ const packageName = "@amityco/foundry-mcp";
16
+ const packageVersion = "0.1.0";
17
+ const cliResult = handleCli(process.argv.slice(2));
18
+ if (cliResult === "exit") {
19
+ process.exit(0);
20
+ }
21
+ const server = new Server({
22
+ name: "social-plus-foundry",
23
+ version: packageVersion,
24
+ }, {
25
+ capabilities: {
26
+ tools: {},
27
+ },
28
+ });
29
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
30
+ tools: Array.from(tools.values()).map((tool) => ({
31
+ name: tool.name,
32
+ description: tool.description,
33
+ inputSchema: tool.inputSchema,
34
+ })),
35
+ }));
36
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
37
+ const tool = tools.get(request.params.name);
38
+ if (!tool) {
39
+ throw new Error(`Unknown tool: ${request.params.name}`);
40
+ }
41
+ return tool.call(request.params.arguments);
42
+ });
43
+ await server.connect(new StdioServerTransport());
44
+ function handleCli(args) {
45
+ const command = args[0];
46
+ if (!command) {
47
+ return "serve";
48
+ }
49
+ if (command === "--version" || command === "-v" || command === "version") {
50
+ console.log(`${packageName} ${packageVersion}`);
51
+ return "exit";
52
+ }
53
+ if (command === "--help" || command === "-h" || command === "help") {
54
+ console.log(helpText());
55
+ return "exit";
56
+ }
57
+ if (command === "doctor" || command === "--doctor") {
58
+ console.log(JSON.stringify(doctorResult(), null, 2));
59
+ return "exit";
60
+ }
61
+ if (command.startsWith("-")) {
62
+ console.error(`Unknown option: ${command}`);
63
+ console.error("Run with --help for usage.");
64
+ return "exit";
65
+ }
66
+ return "serve";
67
+ }
68
+ function helpText() {
69
+ return `${packageName}
70
+
71
+ Local MCP server for social.plus SDK integration assistance.
72
+
73
+ Usage:
74
+ npx -y ${packageName} Start the stdio MCP server
75
+ npx -y ${packageName} --help Show this help
76
+ npx -y ${packageName} --version Show package version
77
+ npx -y ${packageName} doctor Print install diagnostics
78
+
79
+ MCP config:
80
+ {
81
+ "mcpServers": {
82
+ "social-plus": {
83
+ "command": "npx",
84
+ "args": ["-y", "${packageName}"]
85
+ }
86
+ }
87
+ }
88
+
89
+ Customers do not need environment variables. Maintainers may set
90
+ SOCIAL_PLUS_DOCS_ROOT to test against a local social-plus-docs checkout.`;
91
+ }
92
+ function doctorResult() {
93
+ const nodeMajor = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10);
94
+ return {
95
+ package: packageName,
96
+ version: packageVersion,
97
+ status: nodeMajor >= 18 ? "ok" : "unsupported-node",
98
+ node: process.versions.node,
99
+ requiredNodeMajor: ">=18",
100
+ docsSource: process.env.SOCIAL_PLUS_DOCS_ROOT ? "local" : "hosted",
101
+ docsRoot: process.env.SOCIAL_PLUS_DOCS_ROOT,
102
+ docsBaseUrl: process.env.SOCIAL_PLUS_DOCS_BASE_URL ?? "https://learn.social.plus",
103
+ transport: "stdio",
104
+ tools: Array.from(tools.keys()),
105
+ };
106
+ }
@@ -0,0 +1,255 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { objectInput, optionalNumberField, stringField, textResult } from "../types.js";
4
+ let hostedPagesCache;
5
+ function localDocsRoot() {
6
+ return process.env.SOCIAL_PLUS_DOCS_ROOT ? path.resolve(process.env.SOCIAL_PLUS_DOCS_ROOT) : undefined;
7
+ }
8
+ function docsBaseUrl() {
9
+ return (process.env.SOCIAL_PLUS_DOCS_BASE_URL ?? "https://learn.social.plus").replace(/\/+$/, "");
10
+ }
11
+ export const searchDocsTool = {
12
+ name: "search_docs",
13
+ description: "Search social.plus documentation. Uses hosted docs by default and local docs only when SOCIAL_PLUS_DOCS_ROOT is set.",
14
+ inputSchema: {
15
+ type: "object",
16
+ properties: {
17
+ query: { type: "string" },
18
+ limit: { type: "number", default: 5 },
19
+ },
20
+ required: ["query"],
21
+ additionalProperties: false,
22
+ },
23
+ async call(input) {
24
+ const args = objectInput(input);
25
+ const query = stringField(args, "query");
26
+ const limit = optionalNumberField(args, "limit", 5);
27
+ return textResult({ results: await searchDocs(query, limit) });
28
+ },
29
+ };
30
+ export const getDocPageTool = {
31
+ name: "get_doc_page",
32
+ description: "Read a single social.plus docs page by docs path.",
33
+ inputSchema: {
34
+ type: "object",
35
+ properties: {
36
+ path: { type: "string" },
37
+ },
38
+ required: ["path"],
39
+ additionalProperties: false,
40
+ },
41
+ async call(input) {
42
+ const args = objectInput(input);
43
+ const requestedPath = stringField(args, "path");
44
+ const page = await getDocPage(requestedPath);
45
+ return textResult(page);
46
+ },
47
+ };
48
+ async function searchDocs(query, limit) {
49
+ const pages = await loadDocPages();
50
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
51
+ const results = [];
52
+ for (const page of pages) {
53
+ const lower = page.content.toLowerCase();
54
+ const pathAndTitle = `${page.path} ${page.title}`.toLowerCase();
55
+ let score = terms.reduce((sum, term) => {
56
+ const contentScore = countOccurrences(lower, term);
57
+ const metadataScore = countOccurrences(pathAndTitle, term) * 25;
58
+ return sum + contentScore + metadataScore;
59
+ }, 0);
60
+ if (terms.every((term) => pathAndTitle.includes(term))) {
61
+ score += 100;
62
+ }
63
+ if (score === 0) {
64
+ continue;
65
+ }
66
+ results.push({
67
+ path: page.path,
68
+ title: page.title,
69
+ snippet: snippetFor(page.content, terms),
70
+ score,
71
+ });
72
+ }
73
+ return results
74
+ .sort((a, b) => b.score - a.score)
75
+ .slice(0, Math.max(1, Math.min(limit, 20)))
76
+ .map(({ score: _score, ...result }) => result);
77
+ }
78
+ async function getDocPage(requestedPath) {
79
+ const normalized = normalizeDocsPath(requestedPath);
80
+ const pages = await loadDocPages();
81
+ const page = pages.find((candidate) => normalizeDocsPath(candidate.path) === normalized);
82
+ if (!page) {
83
+ throw new Error(`Docs page not found: ${requestedPath}. Use search_docs to find the canonical path.`);
84
+ }
85
+ return page;
86
+ }
87
+ async function loadDocPages() {
88
+ const root = localDocsRoot();
89
+ if (root) {
90
+ return loadLocalDocPages(root);
91
+ }
92
+ hostedPagesCache ??= loadHostedDocPages();
93
+ return hostedPagesCache;
94
+ }
95
+ async function loadHostedDocPages() {
96
+ const baseUrl = docsBaseUrl();
97
+ const fullUrl = `${baseUrl}/llms-full.txt`;
98
+ const indexUrl = `${baseUrl}/llms.txt`;
99
+ let content;
100
+ let pages;
101
+ try {
102
+ content = await fetchText(fullUrl);
103
+ pages = parseLlmsFull(content, baseUrl);
104
+ }
105
+ catch {
106
+ content = await fetchText(indexUrl);
107
+ pages = parseLlmsIndex(content, baseUrl);
108
+ }
109
+ if (pages.length === 0) {
110
+ throw new Error(`No docs pages found in ${fullUrl} or ${indexUrl}.`);
111
+ }
112
+ return pages;
113
+ }
114
+ async function loadLocalDocPages(root) {
115
+ const files = await findDocFiles(root);
116
+ return Promise.all(files.map(async (file) => {
117
+ const raw = await readFile(file, "utf8");
118
+ const content = cleanMdx(raw);
119
+ const relativePath = path.relative(root, file);
120
+ return {
121
+ path: relativePath,
122
+ title: titleFromContent(content, file),
123
+ content,
124
+ };
125
+ }));
126
+ }
127
+ async function fetchText(url) {
128
+ const response = await fetch(url, {
129
+ headers: {
130
+ accept: "text/plain, text/markdown, */*",
131
+ "user-agent": "social-plus-foundry-mcp/0.1.0",
132
+ },
133
+ });
134
+ if (!response.ok) {
135
+ throw new Error(`Failed to fetch docs from ${url}: ${response.status} ${response.statusText}`);
136
+ }
137
+ return response.text();
138
+ }
139
+ function parseLlmsFull(content, baseUrl) {
140
+ const pageHeading = /^### \[([^\]]+)\]\(([^)]+)\)\s*$/gm;
141
+ const matches = Array.from(content.matchAll(pageHeading));
142
+ const pages = [];
143
+ for (let index = 0; index < matches.length; index += 1) {
144
+ const match = matches[index];
145
+ const nextMatch = matches[index + 1];
146
+ const title = match[1].trim();
147
+ const url = absolutizeUrl(match[2].trim(), baseUrl);
148
+ const start = (match.index ?? 0) + match[0].length;
149
+ const end = nextMatch?.index ?? content.length;
150
+ const pageContent = content.slice(start, end).trim();
151
+ pages.push({
152
+ path: pathFromDocsUrl(url),
153
+ title,
154
+ url,
155
+ content: cleanMdx(`# ${title}\n\n${pageContent}`),
156
+ });
157
+ }
158
+ return pages;
159
+ }
160
+ function parseLlmsIndex(content, baseUrl) {
161
+ const pageLine = /^- \[([^\]]+)\]\(([^)]+)\)(?::\s*(.*))?\s*$/gm;
162
+ const pages = [];
163
+ for (const match of content.matchAll(pageLine)) {
164
+ const title = match[1].trim();
165
+ const url = absolutizeUrl(match[2].trim(), baseUrl);
166
+ const summary = match[3]?.trim() || "No summary available.";
167
+ pages.push({
168
+ path: pathFromDocsUrl(url),
169
+ title,
170
+ url,
171
+ content: cleanMdx(`# ${title}\n\n${summary}`),
172
+ });
173
+ }
174
+ return pages;
175
+ }
176
+ function absolutizeUrl(url, baseUrl) {
177
+ return new URL(url, baseUrl).toString();
178
+ }
179
+ function pathFromDocsUrl(url) {
180
+ return normalizeDocsPath(new URL(url).pathname);
181
+ }
182
+ function normalizeDocsPath(docsPath) {
183
+ let normalized = docsPath.trim();
184
+ if (normalized.startsWith("http://") || normalized.startsWith("https://")) {
185
+ normalized = new URL(normalized).pathname;
186
+ }
187
+ normalized = normalized.replace(/^\/+/, "").replace(/\/+$/, "");
188
+ normalized = normalized.replace(/\.(md|mdx)$/i, "");
189
+ return normalized;
190
+ }
191
+ function safeDocsPath(root, relativePath) {
192
+ const fullPath = path.resolve(root, relativePath);
193
+ if (!fullPath.startsWith(root + path.sep)) {
194
+ throw new Error("Docs path must stay inside social-plus-docs.");
195
+ }
196
+ return fullPath;
197
+ }
198
+ async function findDocFiles(root) {
199
+ const files = [];
200
+ async function walk(directory) {
201
+ let entries;
202
+ try {
203
+ entries = await readdir(directory, { withFileTypes: true });
204
+ }
205
+ catch {
206
+ return;
207
+ }
208
+ for (const entry of entries) {
209
+ if (entry.name === "node_modules" || entry.name === ".git" || entry.name === ".next") {
210
+ continue;
211
+ }
212
+ const entryPath = path.join(directory, entry.name);
213
+ if (entry.isDirectory()) {
214
+ await walk(entryPath);
215
+ }
216
+ else if (entry.name.endsWith(".md") || entry.name.endsWith(".mdx")) {
217
+ safeDocsPath(root, path.relative(root, entryPath));
218
+ files.push(entryPath);
219
+ }
220
+ }
221
+ }
222
+ await walk(root);
223
+ return files;
224
+ }
225
+ function cleanMdx(content) {
226
+ return content
227
+ .replace(/^---[\s\S]*?---\s*/m, "")
228
+ .replace(/^import .*$/gm, "")
229
+ .replace(/<[^>\n]+>/g, "")
230
+ .replace(/\n{3,}/g, "\n\n")
231
+ .trim();
232
+ }
233
+ function titleFromContent(content, file) {
234
+ const heading = content.match(/^#\s+(.+)$/m);
235
+ if (heading) {
236
+ return heading[1].trim();
237
+ }
238
+ return path.basename(file).replace(/\.(md|mdx)$/i, "");
239
+ }
240
+ function snippetFor(content, terms) {
241
+ const lower = content.toLowerCase();
242
+ const index = terms.map((term) => lower.indexOf(term)).filter((value) => value >= 0).sort((a, b) => a - b)[0] ?? 0;
243
+ const start = Math.max(0, index - 120);
244
+ const end = Math.min(content.length, index + 240);
245
+ return content.slice(start, end).replace(/\s+/g, " ").trim();
246
+ }
247
+ function countOccurrences(content, term) {
248
+ let count = 0;
249
+ let index = content.indexOf(term);
250
+ while (index >= 0) {
251
+ count += 1;
252
+ index = content.indexOf(term, index + term.length);
253
+ }
254
+ return count;
255
+ }
@@ -0,0 +1,246 @@
1
+ import { access, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { objectInput, optionalStringField, stringField, textResult } from "../types.js";
4
+ import { inspectProject } from "./project.js";
5
+ export function harnessControlsFor(outcome, platforms) {
6
+ const docsQuery = docsQueryFor(outcome, platforms);
7
+ return {
8
+ guides: [
9
+ {
10
+ name: "Resolve request",
11
+ kind: "guide",
12
+ execution: "computational",
13
+ timing: "before-change",
14
+ action: "resolve_request",
15
+ purpose: "Narrow the user's request into a known social.plus integration outcome.",
16
+ },
17
+ {
18
+ name: "Canonical docs",
19
+ kind: "guide",
20
+ execution: "computational",
21
+ timing: "before-change",
22
+ action: `search_docs query=\"${docsQuery}\"`,
23
+ purpose: "Load source-of-truth setup guidance before proposing code.",
24
+ },
25
+ {
26
+ name: "Integration plan",
27
+ kind: "guide",
28
+ execution: "inferential",
29
+ timing: "during-change",
30
+ action: "plan_integration",
31
+ purpose: "Give the coding agent an evidence-backed implementation packet before edits happen.",
32
+ },
33
+ ],
34
+ sensors: [
35
+ {
36
+ name: "Project inspection",
37
+ kind: "sensor",
38
+ execution: "computational",
39
+ timing: "before-change",
40
+ action: "inspect_project",
41
+ purpose: "Detect platform and framework signals from customer-owned files.",
42
+ },
43
+ {
44
+ name: "Setup validator",
45
+ kind: "sensor",
46
+ execution: "computational",
47
+ timing: "after-change",
48
+ action: "validate_setup",
49
+ purpose: "Catch common setup mistakes after the agent inspects or changes the app.",
50
+ },
51
+ ],
52
+ steeringLoop: [
53
+ "Resolve the request and inspect the project.",
54
+ "Fetch only the docs needed for the detected platform and outcome.",
55
+ "Generate a reviewable integration plan before edits.",
56
+ "Apply edits in the host coding agent, not inside Foundry v1.",
57
+ "Run deterministic sensors first: setup validation and detected project checks.",
58
+ "Use inferential review only after deterministic signals are available.",
59
+ ],
60
+ };
61
+ }
62
+ export const planHarnessTool = {
63
+ name: "plan_harness",
64
+ description: "Build the feedforward guides, feedback sensors, and steering loop for a social.plus SDK integration request.",
65
+ inputSchema: {
66
+ type: "object",
67
+ properties: {
68
+ repoPath: {
69
+ type: "string",
70
+ description: "Absolute or relative path to the customer repository root.",
71
+ },
72
+ request: {
73
+ type: "string",
74
+ description: "Natural-language integration request.",
75
+ },
76
+ surfacePath: {
77
+ type: "string",
78
+ description: "Optional app/workspace path inside repoPath, such as apps/web or apps/mobile.",
79
+ },
80
+ },
81
+ required: ["repoPath", "request"],
82
+ additionalProperties: false,
83
+ },
84
+ async call(input) {
85
+ const args = objectInput(input);
86
+ const repoPath = stringField(args, "repoPath");
87
+ const request = stringField(args, "request");
88
+ return textResult(await buildHarnessPlan(repoPath, request, optionalStringField(args, "surfacePath")));
89
+ },
90
+ };
91
+ async function buildHarnessPlan(repoPath, request, surfacePath) {
92
+ const inspection = await inspectProject(repoPath, surfacePath);
93
+ const outcome = classifyOutcome(request);
94
+ const controls = harnessControlsFor(outcome, inspection.platforms);
95
+ const commandSensors = await detectCommandSensors(inspection.effectiveRoot, inspection.platforms);
96
+ const harnessability = assessHarnessability(inspection.platforms, commandSensors, inspection.designSignals.length);
97
+ return {
98
+ outcome,
99
+ surface: inspection.selectedSurface ? { path: inspection.selectedSurface.path, platforms: inspection.selectedSurface.platforms } : undefined,
100
+ availableSurfaces: inspection.surfaces.map((surface) => ({ path: surface.path, platforms: surface.platforms })),
101
+ targetPlatforms: inspection.platforms,
102
+ harnessability,
103
+ guides: controls.guides,
104
+ sensors: [...controls.sensors, ...commandSensors],
105
+ steeringLoop: controls.steeringLoop,
106
+ };
107
+ }
108
+ function classifyOutcome(request) {
109
+ const normalized = request.toLowerCase();
110
+ if (/\b(push|notification|firebase|fcm|apns)\b/.test(normalized)) {
111
+ return "setup-push";
112
+ }
113
+ if (/\b(live object|live objects|live collection|live collections|realtime collection|real-time collection|observe|observer|subscribe|subscription|unsubscribe|live update|live updates)\b/.test(normalized)) {
114
+ return "setup-live-data";
115
+ }
116
+ if (/\b(social feature|social features|feed|timeline|post list|news feed)\b/.test(normalized)) {
117
+ return "add-feed";
118
+ }
119
+ if (/\b(error|broken|crash|not working|fail|timeout|401|403)\b/.test(normalized)) {
120
+ return "troubleshoot";
121
+ }
122
+ if (/\b(validate|check|correct|setup right|initiali[sz])\b/.test(normalized)) {
123
+ return "validate-setup";
124
+ }
125
+ if (/\b(setup|set up|install|integrate|wire|configure)\b/.test(normalized)) {
126
+ return "setup-sdk";
127
+ }
128
+ return "unknown";
129
+ }
130
+ function docsQueryFor(outcome, platforms) {
131
+ const platform = platforms[0] ?? "sdk";
132
+ if (outcome === "setup-push") {
133
+ return `${platform} push notification setup`;
134
+ }
135
+ if (outcome === "add-feed") {
136
+ return `${platform} social feed posts`;
137
+ }
138
+ if (outcome === "setup-live-data") {
139
+ return `${platform} live objects live collections`;
140
+ }
141
+ if (outcome === "troubleshoot") {
142
+ return `${platform} troubleshooting authentication setup`;
143
+ }
144
+ return `${platform} quick start setup`;
145
+ }
146
+ export async function detectCommandSensors(repoPath, platforms) {
147
+ const root = path.resolve(repoPath);
148
+ const sensors = [];
149
+ if (platforms.includes("typescript") || platforms.includes("react-native")) {
150
+ sensors.push(...(await packageJsonSensors(root)));
151
+ }
152
+ if (platforms.includes("android") && (await exists(path.join(root, "gradlew")))) {
153
+ sensors.push({
154
+ name: "Android assemble",
155
+ command: ["./gradlew", "assembleDebug"],
156
+ timing: "after-change",
157
+ purpose: "Check Android project compilation after SDK setup changes.",
158
+ source: "gradlew",
159
+ }, {
160
+ name: "Android unit tests",
161
+ command: ["./gradlew", "test"],
162
+ timing: "after-change",
163
+ purpose: "Run available Android JVM tests after integration changes.",
164
+ source: "gradlew",
165
+ });
166
+ }
167
+ if (platforms.includes("flutter")) {
168
+ sensors.push({
169
+ name: "Flutter analyze",
170
+ command: ["flutter", "analyze"],
171
+ timing: "after-change",
172
+ purpose: "Check Dart static analysis after SDK setup changes.",
173
+ source: "pubspec.yaml",
174
+ }, {
175
+ name: "Flutter tests",
176
+ command: ["flutter", "test"],
177
+ timing: "after-change",
178
+ purpose: "Run available Flutter tests after integration changes.",
179
+ source: "pubspec.yaml",
180
+ });
181
+ }
182
+ return sensors;
183
+ }
184
+ async function packageJsonSensors(root) {
185
+ const packageJsonPath = path.join(root, "package.json");
186
+ let parsed;
187
+ try {
188
+ parsed = JSON.parse(await readFile(packageJsonPath, "utf8"));
189
+ }
190
+ catch {
191
+ return [];
192
+ }
193
+ const scripts = parsed.scripts ?? {};
194
+ const preferredScripts = ["typecheck", "build", "test", "lint"];
195
+ return preferredScripts
196
+ .filter((script) => scripts[script])
197
+ .map((script) => ({
198
+ name: `npm ${script}`,
199
+ command: ["npm", "run", script],
200
+ timing: "after-change",
201
+ purpose: `Run the project's ${script} script as a deterministic feedback sensor.`,
202
+ source: "package.json",
203
+ }));
204
+ }
205
+ function assessHarnessability(platforms, commandSensors, designSignalCount) {
206
+ const affordances = [];
207
+ const gaps = [];
208
+ if (platforms.length > 0) {
209
+ affordances.push(`Detected platform signals: ${platforms.join(", ")}.`);
210
+ }
211
+ else {
212
+ gaps.push("No platform signals detected; ask the user for the app framework or repository root.");
213
+ }
214
+ if (platforms.some((platform) => ["typescript", "react-native", "android", "flutter"].includes(platform))) {
215
+ affordances.push("Detected a platform with deterministic setup checks available in Foundry.");
216
+ }
217
+ if (commandSensors.length > 0) {
218
+ affordances.push(`Detected ${commandSensors.length} project command sensor(s).`);
219
+ }
220
+ else {
221
+ gaps.push("No project build/typecheck/test command sensors were detected yet.");
222
+ }
223
+ if (designSignalCount > 0) {
224
+ affordances.push(`Detected ${designSignalCount} design/theme signal(s) for UI integration grounding.`);
225
+ }
226
+ if (platforms.includes("ios")) {
227
+ gaps.push("iOS support is guided until deterministic validators are expanded.");
228
+ }
229
+ if (platforms.length === 0) {
230
+ return { level: "weak", affordances, gaps };
231
+ }
232
+ return {
233
+ level: commandSensors.length > 0 ? "strong" : "moderate",
234
+ affordances,
235
+ gaps,
236
+ };
237
+ }
238
+ async function exists(filePath) {
239
+ try {
240
+ await access(filePath);
241
+ return true;
242
+ }
243
+ catch {
244
+ return false;
245
+ }
246
+ }