@aarwitz/tapp 0.17.0-rc.11 → 0.17.0-rc.13

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.
@@ -9,6 +9,8 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
9
9
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
10
  import {
11
11
  CallToolRequestSchema,
12
+ GetPromptRequestSchema,
13
+ ListPromptsRequestSchema,
12
14
  ListToolsRequestSchema,
13
15
  } from "@modelcontextprotocol/sdk/types.js";
14
16
 
@@ -17,7 +19,15 @@ import { existingProjectArtifactPath, projectArtifactDirectory } from "./project
17
19
 
18
20
  const __filename = fileURLToPath(import.meta.url);
19
21
  const __dirname = path.dirname(__filename);
22
+ // `repoRoot` is the installed Tapp package root: scripts and bundled harness assets live here.
23
+ // Repository-facing MCP operations must use `workspaceRoot` instead. In an npm/Claude-plugin
24
+ // installation those are different directories, even though source-repo tests historically made
25
+ // them look identical.
20
26
  const repoRoot = path.resolve(__dirname, "../..");
27
+ const workspaceRoot = (() => {
28
+ try { return fs.realpathSync(process.cwd()); }
29
+ catch { return path.resolve(process.cwd()); }
30
+ })();
21
31
  const scriptsDir = path.join(repoRoot, "scripts");
22
32
  // TAPP_HOME (set by the `tapp` CLI when installed) redirects writable output to a user directory.
23
33
  // The old alias remains a read-only fallback; unset repository development stays local.
@@ -1463,7 +1473,7 @@ export function formatScreen(screenTitle, elements) {
1463
1473
  // `tapp` CLI verbs in bin/tapp.js — same pattern as report.js. Keep orchestration HERE so
1464
1474
  // the surfaces can't drift.)
1465
1475
 
1466
- export async function runQaWeb({ url, maxActions, timeout, testEmail, testPassword, baselineFindings, seedRoutes = [], seedTargets = [], surface = "mcp", onProgress = () => {} }) {
1476
+ export async function runQaWeb({ url, maxActions, timeout, testEmail, testPassword, baselineFindings, seedRoutes = [], seedTargets = [], watch = false, surface = "mcp", onProgress = () => {} }) {
1467
1477
  const actions = Math.max(1, Math.min(1000, asInteger(maxActions, 60)));
1468
1478
  const timeoutSec = Math.max(30, Math.min(3600, asInteger(timeout, 600)));
1469
1479
  const id = "web-" + new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14).replace(/^(\d{8})/, "$1-");
@@ -1480,6 +1490,7 @@ export async function runQaWeb({ url, maxActions, timeout, testEmail, testPasswo
1480
1490
  testPassword: isNonEmptyString(testPassword) ? testPassword.trim() : "",
1481
1491
  seedRoutes,
1482
1492
  seedTargets,
1493
+ watch: watch === true,
1483
1494
  onProgress,
1484
1495
  });
1485
1496
  } catch (err) {
@@ -1640,6 +1651,7 @@ export async function runInitExploration({
1640
1651
  timeout,
1641
1652
  testEmail,
1642
1653
  testPassword,
1654
+ watch = false,
1643
1655
  onProgress = () => {},
1644
1656
  onStatus = () => {},
1645
1657
  } = {}) {
@@ -1655,17 +1667,18 @@ export async function runInitExploration({
1655
1667
  let targetResolution = null;
1656
1668
  let qa;
1657
1669
  let managedRuntime = null;
1670
+ if (watch && selected !== "web") return { error: "Watch mode is currently available for web exploration only." };
1658
1671
  if (selected === "web") {
1659
1672
  if (/^https?:\/\//i.test(String(url))) {
1660
1673
  resolvedTarget = String(url).trim();
1661
- qa = await runQaWeb({ url: resolvedTarget, maxActions, timeout, testEmail, testPassword, onProgress });
1674
+ qa = await runQaWeb({ url: resolvedTarget, maxActions, timeout, testEmail, testPassword, watch, onProgress });
1662
1675
  } else {
1663
1676
  const started = await startManagedWebTarget({ root, requestedTarget: target, timeout, onStatus });
1664
1677
  if (started.error) return started;
1665
1678
  managedRuntime = started;
1666
1679
  resolvedTarget = started.url;
1667
1680
  try {
1668
- qa = await runQaWeb({ url: resolvedTarget, maxActions, timeout, testEmail, testPassword, onProgress });
1681
+ qa = await runQaWeb({ url: resolvedTarget, maxActions, timeout, testEmail, testPassword, watch, onProgress });
1669
1682
  } finally {
1670
1683
  await stopManagedWebTarget(started);
1671
1684
  }
@@ -1777,6 +1790,7 @@ export async function runExploreTarget({
1777
1790
  appLaunchArgs,
1778
1791
  appLaunchEnv,
1779
1792
  baselineFindings,
1793
+ watch = false,
1780
1794
  surface = "cli",
1781
1795
  onProgress = () => {},
1782
1796
  onStatus = () => {},
@@ -1799,6 +1813,8 @@ export async function runExploreTarget({
1799
1813
  catch (error) { return { error: error.message || String(error) }; }
1800
1814
  const selectedPlatform = selected.platform;
1801
1815
 
1816
+ if (watch && selectedPlatform !== "web") return { error: "Watch mode is currently available for web exploration only." };
1817
+
1802
1818
  if (selectedPlatform !== "ios" && ((Array.isArray(appLaunchArgs) && appLaunchArgs.length) || (appLaunchEnv && Object.keys(appLaunchEnv).length))) {
1803
1819
  return { error: "appLaunchArgs/appLaunchEnv apply only to iOS targets." };
1804
1820
  }
@@ -1807,14 +1823,14 @@ export async function runExploreTarget({
1807
1823
  const ownedUrl = String(selected.runtime?.ownedUrl || "").trim();
1808
1824
  if (/^https?:\/\//i.test(ownedUrl)) {
1809
1825
  onStatus(`Exploring the owned URL from the application model: ${ownedUrl}`);
1810
- return runQaWeb({ url: ownedUrl, maxActions, timeout, testEmail, testPassword, baselineFindings, surface, onProgress });
1826
+ return runQaWeb({ url: ownedUrl, maxActions, timeout, testEmail, testPassword, baselineFindings, watch, surface, onProgress });
1811
1827
  }
1812
1828
  // Tapp-managed: build/start the repo's web target, wait for readiness, and ALWAYS stop it.
1813
1829
  onStatus(`Preparing the managed web runtime for ${selected.name}…`);
1814
1830
  const started = await startManagedWebTarget({ root, requestedTarget: selected.sourcePath || selected.name || "", timeout, onStatus });
1815
1831
  if (started.error) return started;
1816
1832
  try {
1817
- return await runQaWeb({ url: started.url, maxActions, timeout, testEmail, testPassword, baselineFindings, surface, onProgress });
1833
+ return await runQaWeb({ url: started.url, maxActions, timeout, testEmail, testPassword, baselineFindings, watch, surface, onProgress });
1818
1834
  } finally {
1819
1835
  await stopManagedWebTarget(started);
1820
1836
  onStatus("Stopped the managed web runtime.");
@@ -2037,11 +2053,61 @@ const server = new Server(
2037
2053
  },
2038
2054
  {
2039
2055
  capabilities: {
2056
+ prompts: {},
2040
2057
  tools: {},
2041
2058
  },
2042
2059
  }
2043
2060
  );
2044
2061
 
2062
+ const testAppPrompt = {
2063
+ name: "test-app",
2064
+ title: "Test this app with Tapp",
2065
+ description: "Use Tapp's real app surfaces to inspect, drive, or explore this repository and report evidence honestly.",
2066
+ arguments: [
2067
+ {
2068
+ name: "goal",
2069
+ description: "What to verify, such as finding bugs or exercising checkout",
2070
+ required: false,
2071
+ },
2072
+ {
2073
+ name: "target",
2074
+ description: "Optional repo target, bundle/app id, APK path, or owned URL",
2075
+ required: false,
2076
+ },
2077
+ ],
2078
+ };
2079
+
2080
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [testAppPrompt] }));
2081
+
2082
+ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
2083
+ if (request.params.name !== testAppPrompt.name) {
2084
+ throw new Error(`Unknown prompt: ${request.params.name}`);
2085
+ }
2086
+ const goal = isNonEmptyString(request.params.arguments?.goal)
2087
+ ? request.params.arguments.goal.trim()
2088
+ : "Test the app and find important bugs";
2089
+ const target = isNonEmptyString(request.params.arguments?.target)
2090
+ ? ` Use this target: ${request.params.arguments.target.trim()}.`
2091
+ : "";
2092
+ return {
2093
+ description: testAppPrompt.description,
2094
+ messages: [
2095
+ {
2096
+ role: "user",
2097
+ content: {
2098
+ type: "text",
2099
+ text:
2100
+ `${goal}.${target} Use the connected Tapp tools on the real UI surface. ` +
2101
+ "Use the smallest operation that satisfies the request; initialize/explore the source repo only for a general repository test. " +
2102
+ "If Tapp returns multiple target choices, ask me to select one instead of guessing. " +
2103
+ "Read visual evidence before describing it. Report findings, coverage, authority, inconclusive state, and checked/not-checked scope; " +
2104
+ "never turn exploration into a score or ship verdict. Do not edit the app unless I ask for a fix.",
2105
+ },
2106
+ },
2107
+ ],
2108
+ };
2109
+ });
2110
+
2045
2111
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
2046
2112
  tools: [
2047
2113
  {
@@ -2213,6 +2279,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2213
2279
  androidSerial: { type: "string", description: "Android: optional adb device serial; defaults to the first authorized device." },
2214
2280
  clearData: { type: "boolean", default: true, description: "Android: clear app data before launch for a repeatable starting state." },
2215
2281
  url: { type: "string", description: "Web (beta): URL of the app to explore in a real browser (same-origin only; your own app/staging). Provide exactly one of appBundleId | url." },
2282
+ watch: { type: "boolean", default: false, description: "Web only: open Tapp's controlled Chromium window and show a cursor/HUD for each exploration action. Evidence screenshots exclude the overlay." },
2216
2283
  maxActions: { type: "integer", minimum: 1, maximum: 1000, default: 60, description: "Exploration action budget" },
2217
2284
  timeout: { type: "integer", minimum: 30, maximum: 3600, default: 600, description: "Max wall-clock seconds" },
2218
2285
  testEmail: { type: "string", description: "Email for the login preamble, if the app has a sign-in" },
@@ -2283,6 +2350,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2283
2350
  timeout: { type: "integer", minimum: 30, maximum: 3600, default: 600 },
2284
2351
  testEmail: { type: "string", description: "Explore: actor/login email; never persisted in the model" },
2285
2352
  testPassword: { type: "string", description: "Explore: actor/login password; never persisted in the model" },
2353
+ watch: { type: "boolean", default: false, description: "Web explore only: show the controlled browser and Tapp's actions" },
2286
2354
  maxContracts: { type: "integer", minimum: 1, maximum: 50, default: 15 },
2287
2355
  outDir: { type: "string", description: "Repo-relative artifact directory; default .tapp" },
2288
2356
  },
@@ -2736,45 +2804,62 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2736
2804
  const { name, arguments: args = {} } = request.params;
2737
2805
 
2738
2806
  if (name === "tapp_health") {
2739
- const checks = [];
2740
-
2741
- checks.push({
2742
- check: "repoRoot",
2743
- ok: fs.existsSync(path.join(repoRoot, "Tapp.xcodeproj")),
2744
- value: repoRoot,
2807
+ const coreChecks = [];
2808
+ coreChecks.push({
2809
+ check: "workspace",
2810
+ ok: fs.existsSync(workspaceRoot) && fs.statSync(workspaceRoot).isDirectory(),
2811
+ value: workspaceRoot,
2812
+ required: true,
2745
2813
  });
2746
2814
 
2747
2815
  const nodeVersion = await runCommand("node", ["-v"]);
2748
- checks.push({
2816
+ coreChecks.push({
2749
2817
  check: "node",
2750
2818
  ok: nodeVersion.code === 0,
2751
2819
  value: nodeVersion.stdout.trim() || nodeVersion.stderr.trim(),
2820
+ required: true,
2752
2821
  });
2753
2822
 
2754
2823
  const xcodebuildVersion = await runCommand("xcodebuild", ["-version"]);
2755
- checks.push({
2756
- check: "xcodebuild",
2757
- ok: xcodebuildVersion.code === 0,
2758
- value: (xcodebuildVersion.stdout || xcodebuildVersion.stderr).trim().split("\n")[0] || "not found",
2759
- });
2760
-
2761
- const simctl = await runCommand("xcrun", ["simctl", "list", "devices", "booted"]);
2762
- checks.push({
2763
- check: "bootedSimulator",
2764
- ok: simctl.code === 0,
2765
- value: (simctl.stdout || simctl.stderr).trim(),
2766
- });
2767
-
2768
- const allOk = checks.every((c) => c.ok);
2824
+ const simctl = xcodebuildVersion.code === 0
2825
+ ? await runCommand("xcrun", ["simctl", "list", "devices", "booted"])
2826
+ : { code: 1, stdout: "", stderr: "Xcode not found" };
2769
2827
  const bootedLine = (simctl.stdout || "").split("\n").find((l) => /\(Booted\)/.test(l));
2770
2828
  const bootedName = bootedLine ? bootedLine.trim().replace(/\s*\(.*$/, "") : null;
2771
- const L = [`### ${allOk ? "🩺 Tapp ready" : "⚠️ Tapp not fully ready"}`, ""];
2829
+ const adb = await runCommand("adb", ["devices"]);
2830
+ const androidDevice = adb.code === 0
2831
+ ? (adb.stdout || "").split("\n").find((line) => /\tdevice\s*$/.test(line))
2832
+ : null;
2833
+ let web = { ok: false, value: "Playwright or Chromium not installed" };
2834
+ try {
2835
+ const { chromium } = await import("playwright");
2836
+ const executable = chromium.executablePath();
2837
+ web = { ok: fs.existsSync(executable), value: fs.existsSync(executable) ? executable : "Chromium not installed" };
2838
+ } catch { /* optional dependency may be intentionally absent */ }
2839
+ const platformChecks = [
2840
+ {
2841
+ check: "iOS",
2842
+ ok: xcodebuildVersion.code === 0 && !!bootedName,
2843
+ value: bootedName ? `${bootedName} booted` : xcodebuildVersion.code === 0 ? "Xcode available; no simulator booted" : "Xcode not found",
2844
+ required: false,
2845
+ },
2846
+ {
2847
+ check: "Android",
2848
+ ok: !!androidDevice,
2849
+ value: androidDevice ? `${androidDevice.split("\t")[0]} connected` : adb.code === 0 ? "adb available; no authorized device" : "adb not found",
2850
+ required: false,
2851
+ },
2852
+ { check: "web", ok: web.ok, value: web.value, required: false },
2853
+ ];
2854
+ const checks = [...coreChecks, ...platformChecks];
2855
+ const ready = coreChecks.every((check) => check.ok) && platformChecks.some((check) => check.ok);
2856
+ const L = [`### ${ready ? "🩺 Tapp ready" : "⚠️ Tapp needs a platform runtime"}`, ""];
2772
2857
  for (const c of checks) {
2773
- L.push(`- ${c.ok ? "✅" : "❌"} **${c.check}** — ${String(c.value).split("\n")[0] || "—"}`);
2858
+ const icon = c.ok ? "✅" : c.required ? "❌" : "⚪️";
2859
+ L.push(`- ${icon} **${c.check}** — ${String(c.value).split("\n")[0] || "—"}`);
2774
2860
  }
2775
- L.push("");
2776
- L.push(bootedName ? `📱 Simulator booted: **${bootedName}**` : "📱 No simulator booted — run `tapp_boot_simulator` first.");
2777
- return richResult(L.join("\n"), { ok: allOk, checks });
2861
+ if (!ready) L.push("", "Run `tapp doctor` in the application repository for exact remediation.");
2862
+ return richResult(L.join("\n"), { ok: ready, workspaceRoot, checks, platforms: Object.fromEntries(platformChecks.map((check) => [check.check.toLowerCase(), check.ok])) });
2778
2863
  }
2779
2864
 
2780
2865
  if (name === "tapp_build") {
@@ -2971,6 +3056,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2971
3056
  if (targets !== 1) {
2972
3057
  return errorResult("Provide exactly one of appBundleId (iOS), androidAppId (Android), or url (web beta)");
2973
3058
  }
3059
+ if (args.watch === true && !wantsWeb) return errorResult("watch is currently available for web exploration only");
2974
3060
 
2975
3061
  // Both branches call the shared engine (runQaWeb/runQaIos) — the handler only adds
2976
3062
  // MCP concerns: auth, arg validation, and progress notifications.
@@ -2992,6 +3078,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2992
3078
  testEmail: args.testEmail,
2993
3079
  testPassword: args.testPassword,
2994
3080
  baselineFindings: args.baselineFindings,
3081
+ watch: args.watch === true,
2995
3082
  onProgress: notifyProgress("pages reached"),
2996
3083
  });
2997
3084
  if (r.error) return errorResult(r.error, r.details || {});
@@ -3036,8 +3123,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3036
3123
  if (unauthorized) return unauthorized;
3037
3124
  const operation = String(args.operation || "inspect").toLowerCase();
3038
3125
  if (!["inspect", "write", "refresh", "explore"].includes(operation)) return errorResult("operation must be inspect|write|refresh|explore");
3039
- const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(repoRoot, args.projectDir.trim()) : repoRoot;
3040
- if (!isInsideDir(repoRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the repo");
3126
+ const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
3127
+ if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the workspace");
3041
3128
  const maxContracts = asInteger(args.maxContracts, 15);
3042
3129
  if (maxContracts < 1 || maxContracts > 50) return errorResult("maxContracts must be between 1 and 50");
3043
3130
  const { initializeProductProject } = await import("./product-operations.js");
@@ -3061,6 +3148,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3061
3148
  serial: isNonEmptyString(args.androidSerial) ? args.androidSerial.trim() : undefined,
3062
3149
  maxActions: args.maxActions, timeout: args.timeout, maxContracts,
3063
3150
  testEmail: args.testEmail, testPassword: args.testPassword,
3151
+ watch: args.watch === true,
3064
3152
  runExploration: runInitExploration,
3065
3153
  onProgress: (progress) => {
3066
3154
  if (progressToken === undefined) return;
@@ -3092,8 +3180,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3092
3180
  const allowedArguments = new Set(["authToken", "operation", "projectDir", "name", "role", "session", "provisioning", "credentialBindings", "replace"]);
3093
3181
  const unexpectedArguments = Object.keys(args).filter((key) => !allowedArguments.has(key));
3094
3182
  if (unexpectedArguments.length) return errorResult("Unsupported actor configuration fields; credential values are never accepted", { fields: unexpectedArguments });
3095
- const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(repoRoot, args.projectDir.trim()) : repoRoot;
3096
- if (!isInsideDir(repoRoot, projectDir) || !fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) return errorResult("projectDir must be an existing directory inside the repo");
3183
+ const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
3184
+ if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) return errorResult("projectDir must be an existing directory inside the workspace");
3097
3185
  const { configureActor, readProjectConfig } = await import("./project-config.js");
3098
3186
  if (operation === "read") {
3099
3187
  const loaded = readProjectConfig(projectDir);
@@ -3122,8 +3210,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3122
3210
  if (unauthorized) return unauthorized;
3123
3211
  const operation = String(args.operation || "read").toLowerCase();
3124
3212
  if (!["read", "review", "generate", "validate", "promote"].includes(operation)) return errorResult("operation must be read|review|generate|validate|promote");
3125
- const planPath = isNonEmptyString(args.planPath) ? path.resolve(repoRoot, args.planPath.trim()) : existingProjectArtifactPath(repoRoot, "release-plan.json");
3126
- if (!isInsideDir(repoRoot, planPath)) return errorResult("planPath must be inside the repo");
3213
+ const planPath = isNonEmptyString(args.planPath) ? path.resolve(workspaceRoot, args.planPath.trim()) : existingProjectArtifactPath(workspaceRoot, "release-plan.json");
3214
+ if (!isInsideDir(workspaceRoot, planPath)) return errorResult("planPath must be inside the workspace");
3127
3215
  if (!fs.existsSync(planPath)) return errorResult("Release plan not found", { planPath });
3128
3216
  let plan;
3129
3217
  try { plan = JSON.parse(fs.readFileSync(planPath, "utf8")); }
@@ -3133,11 +3221,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3133
3221
  if (![...decisions.approve, ...decisions.reject, ...decisions.defer].length) return errorResult("review requires at least one approve, reject, or defer item");
3134
3222
  const { reviewProductPlan } = await import("./product-operations.js");
3135
3223
  try {
3136
- plan = reviewProductPlan({ projectDir: repoRoot, planPath, ...decisions }).plan;
3224
+ plan = reviewProductPlan({ projectDir: workspaceRoot, planPath, ...decisions }).plan;
3137
3225
  } catch (error) { return errorResult("Could not review release plan", { detail: error.message || String(error) }); }
3138
3226
  } else if (operation === "generate") {
3139
- const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(repoRoot, args.projectDir.trim()) : repoRoot;
3140
- if (!isInsideDir(repoRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the repo");
3227
+ const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
3228
+ if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the workspace");
3141
3229
  const { generateProductPlan } = await import("./product-operations.js");
3142
3230
  try {
3143
3231
  const result = await generateProductPlan({ projectDir, planPath });
@@ -3145,8 +3233,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3145
3233
  return richResult(`🧩 Proposal drafts — ${result.generatedTasks.length} UI-Map-grounded Task(s) · ${result.generated.length} compile-checked/untrusted contract(s) · ${result.blocked.length} blocked; deterministic real-surface replay remains required`, { plan, planPath, generatedTasks: result.generatedTasks, generated: result.generated, blocked: result.blocked });
3146
3234
  } catch (error) { return errorResult("Could not generate contract drafts", { detail: error.message || String(error) }); }
3147
3235
  } else if (operation === "validate") {
3148
- const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(repoRoot, args.projectDir.trim()) : repoRoot;
3149
- if (!isInsideDir(repoRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the repo");
3236
+ const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
3237
+ if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the workspace");
3150
3238
  let apkPath = "";
3151
3239
  if (isNonEmptyString(args.apkPath)) {
3152
3240
  apkPath = path.resolve(projectDir, args.apkPath.trim());
@@ -3178,8 +3266,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3178
3266
  return richResult(`🔎 Generated contract validation passed — ${result.results.length}/${result.results.length} on ${result.platform}`, { ...result, planPath });
3179
3267
  } catch (error) { return errorResult("Generated contract validation failed", { detail: error.message || String(error), plan, planPath }); }
3180
3268
  } else if (operation === "promote") {
3181
- const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(repoRoot, args.projectDir.trim()) : repoRoot;
3182
- if (!isInsideDir(repoRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the repo");
3269
+ const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
3270
+ if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the workspace");
3183
3271
  const { promoteProductPlan } = await import("./product-operations.js");
3184
3272
  try {
3185
3273
  const result = await promoteProductPlan({ projectDir, planPath, items: Array.isArray(args.items) ? args.items : [] });
@@ -3195,9 +3283,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3195
3283
  if (unauthorized) return unauthorized;
3196
3284
  const operation = String(args.operation || "inspect").toLowerCase();
3197
3285
  if (!["inspect", "install", "baseline"].includes(operation)) return errorResult("operation must be inspect|install|baseline");
3198
- const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(repoRoot, args.projectDir.trim()) : repoRoot;
3199
- if (!isInsideDir(repoRoot, projectDir) || !fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) return errorResult("projectDir must be an existing directory inside the repo");
3200
- const modelPath = isNonEmptyString(args.modelPath) ? path.resolve(repoRoot, args.modelPath.trim()) : existingProjectArtifactPath(projectDir, "application-model.json");
3286
+ const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
3287
+ if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) return errorResult("projectDir must be an existing directory inside the workspace");
3288
+ const modelPath = isNonEmptyString(args.modelPath) ? path.resolve(workspaceRoot, args.modelPath.trim()) : existingProjectArtifactPath(projectDir, "application-model.json");
3201
3289
  if (!isInsideDir(projectDir, modelPath) || !fs.existsSync(modelPath)) return errorResult("Application model not found inside projectDir; run tapp_init first", { modelPath });
3202
3290
  let model;
3203
3291
  try { model = JSON.parse(fs.readFileSync(modelPath, "utf8")); }
@@ -3205,8 +3293,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3205
3293
  const { createProductBaseline, installProductCi, prepareProductCi } = await import("./product-operations.js");
3206
3294
  if (operation === "baseline") {
3207
3295
  if (!isNonEmptyString(args.reportPath)) return errorResult("baseline requires reportPath from a successful portable gate");
3208
- const reportPath = path.resolve(repoRoot, args.reportPath.trim());
3209
- if (!isInsideDir(repoRoot, reportPath) || !fs.existsSync(reportPath)) return errorResult("reportPath must be an existing JSON file inside the repo");
3296
+ const reportPath = path.resolve(workspaceRoot, args.reportPath.trim());
3297
+ if (!isInsideDir(workspaceRoot, reportPath) || !fs.existsSync(reportPath)) return errorResult("reportPath must be an existing JSON file inside the workspace");
3210
3298
  let report;
3211
3299
  try { report = JSON.parse(fs.readFileSync(reportPath, "utf8")); }
3212
3300
  catch (error) { return errorResult("Gate report is invalid JSON", { detail: error.message || String(error) }); }
@@ -3231,8 +3319,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3231
3319
  if (unauthorized) return unauthorized;
3232
3320
  const operation = String(args.operation || "read").toLowerCase();
3233
3321
  const resolveRepoFile = (value, fallback = "") => {
3234
- const resolved = path.resolve(repoRoot, isNonEmptyString(value) ? value.trim() : fallback);
3235
- return isInsideDir(repoRoot, resolved) ? resolved : null;
3322
+ const resolved = path.resolve(workspaceRoot, isNonEmptyString(value) ? value.trim() : fallback);
3323
+ return isInsideDir(workspaceRoot, resolved) ? resolved : null;
3236
3324
  };
3237
3325
  const capture = isNonEmptyString(args.captureId) ? listCaptureRuns(200).find((run) => run.id === args.captureId.trim()) : null;
3238
3326
  if (isNonEmptyString(args.captureId) && !capture) return errorResult("Capture not found", { captureId: args.captureId });
@@ -3252,18 +3340,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3252
3340
  const markersPath = capture ? path.join(capture.path, "ocqa-markers.txt") : resolveRepoFile(args.markersPath);
3253
3341
  if (!markersPath) return errorResult("markersPath must be inside the repo, or provide captureId");
3254
3342
  if (!fs.existsSync(markersPath)) return errorResult("OCQA markers not found", { markersPath });
3255
- const outPath = isNonEmptyString(args.mapPath) ? resolveRepoFile(args.mapPath) : existingProjectArtifactPath(repoRoot, "ui-map.json");
3343
+ const outPath = isNonEmptyString(args.mapPath) ? resolveRepoFile(args.mapPath) : existingProjectArtifactPath(workspaceRoot, "ui-map.json");
3256
3344
  if (!outPath) return errorResult("mapPath must be inside the repo");
3257
3345
  try {
3258
3346
  const observed = buildUiMapFromMarkers({ markersPath, platform: args.platform || "ios", target: args.target || "", runId: capture?.id || "" });
3259
3347
  const map = fs.existsSync(outPath) && args.replace !== true ? mergeUiMaps(JSON.parse(fs.readFileSync(outPath, "utf8")), observed) : observed;
3260
3348
  writeUiMap(outPath, map);
3261
3349
  const controls = map.nodes.reduce((total, node) => total + node.controls.length, 0);
3262
- return richResult(`🗺️ UI Map updated — ${map.nodes.length} states · ${map.edges.length} transitions · ${controls} semantic controls\n${path.relative(repoRoot, outPath)}`, { map, path: outPath });
3350
+ return richResult(`🗺️ UI Map updated — ${map.nodes.length} states · ${map.edges.length} transitions · ${controls} semantic controls\n${path.relative(workspaceRoot, outPath)}`, { map, path: outPath });
3263
3351
  } catch (error) { return errorResult("Could not build UI Map", { detail: error.message || String(error) }); }
3264
3352
  }
3265
3353
  if (operation !== "read") return errorResult("operation must be read|build|diff");
3266
- const mapPath = capture ? path.join(capture.path, "ui-map.json") : isNonEmptyString(args.mapPath) ? resolveRepoFile(args.mapPath) : existingProjectArtifactPath(repoRoot, "ui-map.json");
3354
+ const mapPath = capture ? path.join(capture.path, "ui-map.json") : isNonEmptyString(args.mapPath) ? resolveRepoFile(args.mapPath) : existingProjectArtifactPath(workspaceRoot, "ui-map.json");
3267
3355
  if (!mapPath) return errorResult("mapPath must be inside the repo");
3268
3356
  if (!fs.existsSync(mapPath)) return errorResult("UI Map not found; run QA or operation=build first", { mapPath });
3269
3357
  try {
@@ -3280,8 +3368,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3280
3368
  if (unauthorized) return unauthorized;
3281
3369
  const operation = String(args.operation || "validate").toLowerCase();
3282
3370
  if (!["read", "validate", "compile"].includes(operation)) return errorResult("operation must be read|validate|compile");
3283
- const taskPath = isNonEmptyString(args.taskPath) ? path.resolve(repoRoot, args.taskPath.trim()) : null;
3284
- if (!taskPath || !isInsideDir(repoRoot, taskPath)) return errorResult("taskPath must be inside the repo");
3371
+ const taskPath = isNonEmptyString(args.taskPath) ? path.resolve(workspaceRoot, args.taskPath.trim()) : null;
3372
+ if (!taskPath || !isInsideDir(workspaceRoot, taskPath)) return errorResult("taskPath must be inside the workspace");
3285
3373
  if (!fs.existsSync(taskPath)) return errorResult("Task file not found", { taskPath: args.taskPath });
3286
3374
  const { applyTaskCoverage, compileTaskSteps, loadTaskFile, loadTaskRegistry, validateTaskAgainstUiMap } = await import("./task-runtime.js");
3287
3375
  let task;
@@ -3292,8 +3380,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3292
3380
  let groundingMap = null;
3293
3381
  let groundingMapPath = null;
3294
3382
  if (isNonEmptyString(args.mapPath)) {
3295
- const mapPath = path.resolve(repoRoot, args.mapPath.trim());
3296
- if (!isInsideDir(repoRoot, mapPath)) return errorResult("mapPath must be inside the repo");
3383
+ const mapPath = path.resolve(workspaceRoot, args.mapPath.trim());
3384
+ if (!isInsideDir(workspaceRoot, mapPath)) return errorResult("mapPath must be inside the workspace");
3297
3385
  if (!fs.existsSync(mapPath)) return errorResult("UI Map not found", { mapPath: args.mapPath });
3298
3386
  groundingMapPath = mapPath;
3299
3387
  try {
@@ -3323,12 +3411,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3323
3411
  const flow = { name: `Task: ${task.name}`, kind: "flow", platform: args.platform || "", vars: compiled.vars, steps: compiled.steps, taskPlan: compiled.plan };
3324
3412
  let outPath = null;
3325
3413
  if (isNonEmptyString(args.outPath)) {
3326
- outPath = path.resolve(repoRoot, args.outPath.trim());
3327
- if (!isInsideDir(repoRoot, outPath)) return errorResult("outPath must be inside the repo");
3414
+ outPath = path.resolve(workspaceRoot, args.outPath.trim());
3415
+ if (!isInsideDir(workspaceRoot, outPath)) return errorResult("outPath must be inside the workspace");
3328
3416
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
3329
3417
  fs.writeFileSync(outPath, JSON.stringify(flow, null, 2) + "\n");
3330
3418
  }
3331
- return richResult(`🧩 Compiled ${task.name} into ${flow.steps.length} deterministic Flow steps${outPath ? `\n${path.relative(repoRoot, outPath)}` : ""}`, { flow, grounding, path: outPath });
3419
+ return richResult(`🧩 Compiled ${task.name} into ${flow.steps.length} deterministic Flow steps${outPath ? `\n${path.relative(workspaceRoot, outPath)}` : ""}`, { flow, grounding, path: outPath });
3332
3420
  }
3333
3421
 
3334
3422
  if (name === "tapp_release_contract") {
@@ -3336,8 +3424,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3336
3424
  if (unauthorized) return unauthorized;
3337
3425
  const operation = String(args.operation || "validate").toLowerCase();
3338
3426
  if (!["read", "validate", "compile", "run"].includes(operation)) return errorResult("operation must be read|validate|compile|run");
3339
- const contractPath = isNonEmptyString(args.contractPath) ? path.resolve(repoRoot, args.contractPath.trim()) : null;
3340
- if (!contractPath || !isInsideDir(repoRoot, contractPath)) return errorResult("contractPath must be inside the repo");
3427
+ const contractPath = isNonEmptyString(args.contractPath) ? path.resolve(workspaceRoot, args.contractPath.trim()) : null;
3428
+ if (!contractPath || !isInsideDir(workspaceRoot, contractPath)) return errorResult("contractPath must be inside the workspace");
3341
3429
  const {
3342
3430
  applyReleaseContractCoverage,
3343
3431
  compileReleaseContract,
@@ -3352,8 +3440,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3352
3440
  let groundingMap = null;
3353
3441
  let groundingMapPath = null;
3354
3442
  if (isNonEmptyString(args.mapPath)) {
3355
- groundingMapPath = path.resolve(repoRoot, args.mapPath.trim());
3356
- if (!isInsideDir(repoRoot, groundingMapPath)) return errorResult("mapPath must be inside the repo");
3443
+ groundingMapPath = path.resolve(workspaceRoot, args.mapPath.trim());
3444
+ if (!isInsideDir(workspaceRoot, groundingMapPath)) return errorResult("mapPath must be inside the workspace");
3357
3445
  if (!fs.existsSync(groundingMapPath)) return errorResult("UI Map not found", { mapPath: args.mapPath });
3358
3446
  try {
3359
3447
  groundingMap = JSON.parse(fs.readFileSync(groundingMapPath, "utf8"));
@@ -3374,8 +3462,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3374
3462
  catch (error) { return errorResult("Could not compile Release Contract", { detail: error.message || String(error) }); }
3375
3463
  let outPath = null;
3376
3464
  if (isNonEmptyString(args.outPath)) {
3377
- outPath = path.resolve(repoRoot, args.outPath.trim());
3378
- if (!isInsideDir(repoRoot, outPath)) return errorResult("outPath must be inside the repo");
3465
+ outPath = path.resolve(workspaceRoot, args.outPath.trim());
3466
+ if (!isInsideDir(workspaceRoot, outPath)) return errorResult("outPath must be inside the workspace");
3379
3467
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
3380
3468
  fs.writeFileSync(outPath, JSON.stringify(execution, null, 2) + "\n");
3381
3469
  }
@@ -3422,12 +3510,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3422
3510
  if (unauthorized) return unauthorized;
3423
3511
  const operation = isNonEmptyString(args.operation) ? args.operation.trim().toLowerCase() : "plan";
3424
3512
  if (!['plan', 'adopt'].includes(operation)) return errorResult("operation must be plan|adopt");
3425
- const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(repoRoot, args.projectDir.trim()) : repoRoot;
3426
- if (!isInsideDir(repoRoot, projectDir)) return errorResult("projectDir must be inside the repo");
3513
+ const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
3514
+ if (!isInsideDir(workspaceRoot, projectDir)) return errorResult("projectDir must be inside the workspace");
3427
3515
  if (operation === "adopt") {
3428
3516
  if (!isNonEmptyString(args.prPlanPath) || !isNonEmptyString(args.item)) return errorResult("adopt requires prPlanPath and item");
3429
3517
  const prPlanPath = path.resolve(projectDir, args.prPlanPath.trim());
3430
- if (!isInsideDir(repoRoot, prPlanPath)) return errorResult("prPlanPath must be inside the repo");
3518
+ if (!isInsideDir(workspaceRoot, prPlanPath)) return errorResult("prPlanPath must be inside the workspace");
3431
3519
  const { adoptPrCoverageProposal } = await import("./pr-selection.js");
3432
3520
  try {
3433
3521
  const adopted = adoptPrCoverageProposal({
@@ -3471,8 +3559,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3471
3559
  fs.writeFileSync(flowFile, JSON.stringify(args.flow));
3472
3560
  parsedFlow = args.flow;
3473
3561
  } else if (isNonEmptyString(args.flowPath)) {
3474
- const p = path.resolve(repoRoot, args.flowPath.trim());
3475
- if (!isInsideDir(repoRoot, p)) return errorResult("flowPath must be inside the repo");
3562
+ const p = path.resolve(workspaceRoot, args.flowPath.trim());
3563
+ if (!isInsideDir(workspaceRoot, p)) return errorResult("flowPath must be inside the workspace");
3476
3564
  if (!fs.existsSync(p)) return errorResult("Flow file not found", { flowPath: args.flowPath });
3477
3565
  flowFile = p;
3478
3566
  } else {
@@ -3554,8 +3642,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3554
3642
  if (args.scenario && typeof args.scenario === "object") {
3555
3643
  scenario = args.scenario;
3556
3644
  } else if (isNonEmptyString(args.scenarioPath)) {
3557
- const scenarioFile = path.resolve(repoRoot, args.scenarioPath.trim());
3558
- if (!isInsideDir(repoRoot, scenarioFile)) return errorResult("scenarioPath must be inside the repo");
3645
+ const scenarioFile = path.resolve(workspaceRoot, args.scenarioPath.trim());
3646
+ if (!isInsideDir(workspaceRoot, scenarioFile)) return errorResult("scenarioPath must be inside the workspace");
3559
3647
  if (!fs.existsSync(scenarioFile)) return errorResult("Scenario file not found", { scenarioPath: args.scenarioPath });
3560
3648
  try {
3561
3649
  const { loadScenarioFile } = await import("./scenario-runtime.js");
@@ -3625,7 +3713,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3625
3713
  const ungrounded = ungroundedScreens(parsed.steps, grounding);
3626
3714
  const flow = { name: args.name || parsed.name, app: bundleId, steps: parsed.steps };
3627
3715
  const slug = flow.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "generated-flow";
3628
- const dir = path.join(repoRoot, ".tapp", "proposals", "flows");
3716
+ const dir = path.join(workspaceRoot, ".tapp", "proposals", "flows");
3629
3717
  fs.mkdirSync(dir, { recursive: true });
3630
3718
  const outPath = path.join(dir, `${slug}.yml`);
3631
3719
  const promotedPath = path.join(".tapp", "flows", `${slug}.yml`);
@@ -3633,7 +3721,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3633
3721
  const yaml = (yamlRes.stdout || "").trim();
3634
3722
  if (!yaml) return errorResult("Failed to render flow YAML", { stderr: yamlRes.stderr });
3635
3723
  fs.writeFileSync(outPath, yaml + "\n");
3636
- const rel = path.relative(repoRoot, outPath);
3724
+ const rel = path.relative(workspaceRoot, outPath);
3637
3725
 
3638
3726
  const L = [`🤖 Generated a flow **proposal** **${flow.name}** from your goal → \`${rel}\``];
3639
3727
  L.push(`⚠️ This is an **untrusted draft**, not a committed test. Grounded in ${grounding.screens.length} observed screen(s). ${ungrounded.length ? `⚠️ references unobserved: ${ungrounded.join(", ")} — review before relying on it.` : "All referenced screens were observed."}`);
@@ -3888,7 +3976,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3888
3976
  const unauthorized = ensureAuthorized(args);
3889
3977
  if (unauthorized) return unauthorized;
3890
3978
  try {
3891
- const saved = await saveInteractiveSessionFlow({ projectDir:repoRoot, name:args.name, addFinalAssertion:args.addFinalAssertion !== false, replace:args.replace === true });
3979
+ const saved = await saveInteractiveSessionFlow({ projectDir:workspaceRoot, name:args.name, addFinalAssertion:args.addFinalAssertion !== false, replace:args.replace === true });
3892
3980
  const text = `💾 Saved flow **${saved.flow.name}** → \`${saved.path}\` (${saved.flow.steps.length} steps)\n\n\`\`\`yaml\n${saved.yaml}\n\`\`\`\n\nReplay it anytime: \`tapp_flow_run\` with \`flowPath: "${saved.path}"\`.`;
3893
3981
  return richResult(text, { path:saved.path, flow:saved.flow });
3894
3982
  } catch (error) {
@@ -363,6 +363,7 @@ export async function initializeProductProject({
363
363
  maxContracts = 15,
364
364
  testEmail,
365
365
  testPassword,
366
+ watch = false,
366
367
  runExploration,
367
368
  onProgress = () => {},
368
369
  onStatus = () => {},
@@ -394,7 +395,7 @@ export async function initializeProductProject({
394
395
  exploration = await runExploration({
395
396
  projectDir: root, platform: selectedTarget.platform, outDir, url: ownedUrl, target: sourceTarget,
396
397
  bundleId, appId: appId || selectedTarget.runtime?.applicationId || "", apkPath, serial, scheme, configuration, maxActions: Number(maxActions), timeout: Number(timeout),
397
- testEmail, testPassword, onProgress: (progress) => onProgress({ ...progress, platform: selectedTarget.platform }), onStatus,
398
+ testEmail, testPassword, watch, onProgress: (progress) => onProgress({ ...progress, platform: selectedTarget.platform }), onStatus,
398
399
  });
399
400
  if (exploration?.error) throw Object.assign(new Error(exploration.error), { details: exploration.details || {} });
400
401
  }