@aarwitz/tapp 0.17.0-rc.8 → 0.17.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.
@@ -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.
@@ -35,8 +45,13 @@ function clampOutput(value, maxChars = MAX_OUTPUT_CHARS) {
35
45
  return value;
36
46
  }
37
47
 
38
- const dropped = value.length - maxChars;
39
- return `${value.slice(0, maxChars)}\n...[truncated ${dropped} chars]`;
48
+ // Compiler/build diagnostics are normally emitted at the end. Preserve both ends so a long
49
+ // dependency build cannot truncate away the one actionable error the user needs.
50
+ const marker = "\n...[output truncated]...\n";
51
+ const retained = Math.max(0, maxChars - marker.length);
52
+ const head = Math.ceil(retained / 2);
53
+ const tail = Math.floor(retained / 2);
54
+ return `${value.slice(0, head)}${marker}${value.slice(value.length - tail)}`;
40
55
  }
41
56
 
42
57
  function asBoolean(value, fallback = false) {
@@ -320,6 +335,9 @@ export function findXcodeContainer(startDir) {
320
335
  }
321
336
 
322
337
  export async function buildAppForSim({ dir, container, scheme, configuration = "Debug" } = {}) {
338
+ const { storagePreflight } = await import("./environment-preflight.js");
339
+ const storage = storagePreflight(path.join(tappHome || os.tmpdir(), "app-builds"));
340
+ if (!storage.ok) return { error: storage.message, details: { environment: "storage", storage } };
323
341
  const target = container || findXcodeContainer(dir || process.cwd());
324
342
  if (!target) return { error: `No Xcode project or workspace found under ${dir || process.cwd()}` };
325
343
  const isWorkspace = target.endsWith(".xcworkspace");
@@ -700,6 +718,28 @@ function templateValue(text) {
700
718
  return text;
701
719
  }
702
720
 
721
+ export function isStableFlowCheckpoint(value) {
722
+ const text = String(value || "").replace(/\s+/g, " ").trim();
723
+ if (!text || /^(loading|fetching|please wait|preparing|connecting|syncing|signing in)(?:[.…!]*|\s.*)$/i.test(text)) return false;
724
+ if (/^(?:mon|tues?|wed(?:nes)?|thu(?:rs)?|fri|sat(?:ur)?|sun)(?:day)?\b/i.test(text)) return false;
725
+ if (/^(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\s+\d{1,2}(?:,\s+\d{4})?$/i.test(text)) return false;
726
+ if (/^\d{4}-\d{2}-\d{2}(?:[ T].*)?$/.test(text)) return false;
727
+ return true;
728
+ }
729
+
730
+ export function semanticTargetAtPoint(elements, x, y) {
731
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return "";
732
+ return (elements || [])
733
+ .filter((element) => {
734
+ const frame = element.frame || {};
735
+ return element.hittable !== false && Number.isFinite(frame.x) && Number.isFinite(frame.y) && Number.isFinite(frame.width) && Number.isFinite(frame.height)
736
+ && x >= frame.x && y >= frame.y && x <= frame.x + frame.width && y <= frame.y + frame.height
737
+ && String(element.id || element.identifier || element.label || "").trim();
738
+ })
739
+ .sort((a, b) => (a.frame.width * a.frame.height) - (b.frame.width * b.frame.height))
740
+ .map((element) => String(element.id || element.identifier || element.label || "").trim())[0] || "";
741
+ }
742
+
703
743
  /** Append a Flow step for an act (record-by-doing). Inserts wait_for on screen change for
704
744
  * deterministic replay. Inspection acts (tree/screenshot/wait) are not recorded. */
705
745
  function recordStep(cmd, result) {
@@ -710,7 +750,7 @@ function recordStep(cmd, result) {
710
750
  case "tap": {
711
751
  const target = cmd.id || cmd.label || (typeof cmd.x === "number" ? `${cmd.x},${cmd.y}` : "");
712
752
  if (target) activeSession.recording.push({ tap: target });
713
- if (changed) activeSession.recording.push({ wait_for: newScreen });
753
+ if (changed && isStableFlowCheckpoint(newScreen)) activeSession.recording.push({ wait_for: newScreen });
714
754
  break;
715
755
  }
716
756
  case "type": {
@@ -719,12 +759,16 @@ function recordStep(cmd, result) {
719
759
  activeSession.recording.push({ type: step });
720
760
  break;
721
761
  }
762
+ case "login":
763
+ activeSession.recording.push({ login: { email: "$TEST_EMAIL", password: "$TEST_PASSWORD" } });
764
+ if (changed && isStableFlowCheckpoint(newScreen)) activeSession.recording.push({ wait_for: newScreen });
765
+ break;
722
766
  case "swipe":
723
767
  activeSession.recording.push({ swipe: cmd.direction || "up" });
724
768
  break;
725
769
  case "back":
726
770
  activeSession.recording.push({ back: true });
727
- if (changed) activeSession.recording.push({ wait_for: newScreen });
771
+ if (changed && isStableFlowCheckpoint(newScreen)) activeSession.recording.push({ wait_for: newScreen });
728
772
  break;
729
773
  default:
730
774
  break; // tree / screenshot / wait are inspection, not test steps
@@ -733,7 +777,17 @@ function recordStep(cmd, result) {
733
777
  }
734
778
 
735
779
  async function sessionAct(cmd) {
736
- if (!activeSession || activeSession.ended) return { error: "No active session. Call tapp_session_start first." };
780
+ const startedAt = Date.now();
781
+ const done = (result) => ({ ...result, durationMs: Date.now() - startedAt });
782
+ if (!activeSession || activeSession.ended) return done({ error: "No active session. Call tapp_session_start first." });
783
+ let coordinateResolvedTarget = "";
784
+ if (cmd.action === "tap" && !cmd.id && Number.isFinite(cmd.x) && Number.isFinite(cmd.y)) {
785
+ coordinateResolvedTarget = semanticTargetAtPoint(activeSession.latestTree?.elements, cmd.x, cmd.y);
786
+ if (coordinateResolvedTarget) {
787
+ const { x: _x, y: _y, ...semanticCommand } = cmd;
788
+ cmd = { ...semanticCommand, id: coordinateResolvedTarget };
789
+ }
790
+ }
737
791
  if (activeSession.platform === "web") {
738
792
  const session = activeSession;
739
793
  let status = "ok";
@@ -741,9 +795,13 @@ async function sessionAct(cmd) {
741
795
  let typedInto = null;
742
796
  try {
743
797
  if (cmd.action === "tap") {
744
- const locator = await firstVisibleWebLocator(session.page, cmd.id || cmd.label || "");
745
- if (!locator) { status = "not_found"; detail = "No visible web control matched the semantic target"; }
746
- else await locator.click({ timeout:10_000 });
798
+ const target = cmd.id || cmd.label || "";
799
+ if (!target && Number.isFinite(cmd.x) && Number.isFinite(cmd.y)) await session.page.mouse.click(cmd.x, cmd.y);
800
+ else {
801
+ const locator = await firstVisibleWebLocator(session.page, target);
802
+ if (!locator) { status = "not_found"; detail = "No visible web control matched the semantic target"; }
803
+ else await locator.click({ timeout:10_000 });
804
+ }
747
805
  } else if (cmd.action === "type") {
748
806
  const locator = await firstVisibleWebLocator(session.page, cmd.id || cmd.label || "", { input:true });
749
807
  if (!locator) { status = "not_found"; detail = "No visible web field matched the semantic target"; }
@@ -756,6 +814,28 @@ async function sessionAct(cmd) {
756
814
  if (!locator) await session.page.waitForTimeout(120);
757
815
  }
758
816
  if (!locator) { status = "timeout"; detail = `Timed out waiting for ${cmd.id || cmd.text || "target"}`; }
817
+ } else if (cmd.action === "login") {
818
+ const emailValue = cmd.email || session.creds.email || "";
819
+ const passwordValue = cmd.password || session.creds.password || "";
820
+ if (!emailValue || !passwordValue) { status = "missing_credentials"; detail = "Email and password are required"; }
821
+ else {
822
+ const email = await firstVisibleWebLocator(session.page, "Email", { input:true })
823
+ || session.page.locator("input[type=email], input[name*=mail i], input[name*=user i], input[id*=mail i], input[id*=user i]").first();
824
+ const password = session.page.locator("input[type=password]").first();
825
+ if (!(await email.isVisible().catch(() => false)) || !(await password.isVisible().catch(() => false))) {
826
+ status = "not_found"; detail = "Could not identify email and password fields";
827
+ } else {
828
+ await email.fill(emailValue);
829
+ await password.fill(passwordValue);
830
+ const submit = session.page.getByRole("button", { name:/sign in|log in|login|continue|submit/i }).first();
831
+ if (!(await submit.isVisible().catch(() => false))) { status = "not_found"; detail = "Could not identify a sign-in control"; }
832
+ else {
833
+ await submit.click();
834
+ await session.page.waitForTimeout(500);
835
+ if (await password.isVisible().catch(() => false)) { status = "still_on_login"; detail = "Submit left the app on the login screen"; }
836
+ }
837
+ }
838
+ }
759
839
  } else if (cmd.action === "back") {
760
840
  await session.page.goBack({ waitUntil:"domcontentloaded", timeout:10_000 }).catch(() => {});
761
841
  } else if (cmd.action === "swipe") {
@@ -774,7 +854,7 @@ async function sessionAct(cmd) {
774
854
  }
775
855
  const snapshot = treeSnapshot();
776
856
  if (status === "ok") recordStep(cmd, snapshot);
777
- return { status, typedInto, detail, ...snapshot, recordedSteps:session.recording.length, url:session.latestTree?.url || "" };
857
+ return done({ status, typedInto, detail, ...snapshot, recordedSteps:session.recording.length, url:session.latestTree?.url || "", ...(coordinateResolvedTarget ? { coordinateResolvedTarget } : {}) });
778
858
  }
779
859
  if (activeSession.platform === "android") {
780
860
  const s = activeSession;
@@ -830,7 +910,7 @@ async function sessionAct(cmd) {
830
910
  s.treeVersion += 1;
831
911
  const snap = treeSnapshot();
832
912
  if (status === "ok") recordStep(cmd, snap);
833
- return { status, typedInto, detail, ...snap, recordedSteps: s.recording.length };
913
+ return done({ status, typedInto, detail, ...snap, recordedSteps: s.recording.length, ...(coordinateResolvedTarget ? { coordinateResolvedTarget } : {}) });
834
914
  }
835
915
  activeSession.seq += 1;
836
916
  const seq = activeSession.seq;
@@ -859,7 +939,7 @@ async function sessionAct(cmd) {
859
939
  while (activeSession.treeVersion === beforeVer && Date.now() < td && !activeSession.ended) await sleep(150);
860
940
  const snap = treeSnapshot();
861
941
  if (status === "ok") recordStep(cmd, snap); // record only successful acts
862
- return { status, typedInto, detail, ...snap, recordedSteps: activeSession ? activeSession.recording.length : 0 };
942
+ return done({ status, typedInto, detail, ...snap, recordedSteps: activeSession ? activeSession.recording.length : 0, ...(coordinateResolvedTarget ? { coordinateResolvedTarget } : {}) });
863
943
  }
864
944
 
865
945
  async function endSession() {
@@ -904,7 +984,7 @@ export async function saveInteractiveSessionFlow({ projectDir, name, addFinalAss
904
984
  if (!projectDir || !fs.existsSync(root) || !fs.statSync(root).isDirectory()) throw new Error("A valid repository root is required to save a Flow");
905
985
  const steps = [...(activeSession.recording || [])];
906
986
  if (steps.length === 0) throw new Error("Nothing recorded yet — perform some live-session actions first.");
907
- if (addFinalAssertion && activeSession.lastScreen) {
987
+ if (addFinalAssertion && activeSession.lastScreen && isStableFlowCheckpoint(activeSession.lastScreen)) {
908
988
  const last = steps[steps.length - 1] || {};
909
989
  if (!("assert_screen" in last)) steps.push({ assert_screen: activeSession.lastScreen });
910
990
  }
@@ -1229,6 +1309,7 @@ export function explorationEnvFromArgs(args) {
1229
1309
  const env = {};
1230
1310
  if (isNonEmptyString(args.testEmail)) env.OCQA_TEST_EMAIL = args.testEmail;
1231
1311
  if (isNonEmptyString(args.testPassword)) env.OCQA_TEST_PASSWORD = args.testPassword;
1312
+ if (isNonEmptyString(args.testEmail) || isNonEmptyString(args.testPassword)) env.OCQA_CREDENTIALS_EXPLICIT = "1";
1232
1313
  if (Array.isArray(args.appLaunchArgs)) {
1233
1314
  const a = args.appLaunchArgs.filter((s) => typeof s === "string" && s.length > 0);
1234
1315
  if (a.length) env.OCQA_APP_LAUNCH_ARGS_JSON = JSON.stringify(a);
@@ -1320,9 +1401,9 @@ function fmtDuration(ms) {
1320
1401
  export function qaNextSteps(report, surface = "mcp") {
1321
1402
  if (surface === "cli") {
1322
1403
  const next = [];
1323
- if (report?.findings?.length) next.push("inspect the evidence with `tapp report latest`");
1324
- next.push("re-run with `--baseline <report.json>` to compare a fix (then `tapp ci` to gate it)");
1325
- next.push("replay a committed journey with `tapp flow run <file>`");
1404
+ if (report?.findings?.length) next.push("inspect the evidence with `npx -y @aarwitz/tapp@latest report latest`");
1405
+ next.push("save this run with `--json <report.json>`, then re-run with `--baseline <report.json>` to compare a fix (`tapp ci` gates it)");
1406
+ next.push("replay a committed journey with `npx -y @aarwitz/tapp@latest flow run <file>`");
1326
1407
  return next;
1327
1408
  }
1328
1409
  const next = [];
@@ -1344,6 +1425,7 @@ function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiC
1344
1425
  L.push(`### šŸ”­ Exploration complete — ${badge} Ā· ${observationSummary(report)}${bundleId ? `\n\`${bundleId}\`` : ""}`);
1345
1426
  L.push("");
1346
1427
  L.push(report.headline);
1428
+ if (report.credentialWarning) L.push("", `> āš ļø ${report.credentialWarning}`);
1347
1429
  L.push("");
1348
1430
  L.push(`**Coverage** — ${report.screensExplored} screens Ā· ${report.actionsPerformed} actions${timedOut ? " Ā· ā±ļø hit time limit" : ""}`);
1349
1431
  if (report.platform === "web") {
@@ -1358,7 +1440,7 @@ function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiC
1358
1440
  L.push("");
1359
1441
  L.push("**Findings**");
1360
1442
  for (const f of report.findings.slice(0, 12)) {
1361
- L.push(`- ${SEV[f.severity] || "•"} \`${f.severity}\` ${f.title}${f.screen ? ` — on *${f.screen}*` : ""}`);
1443
+ L.push(`- ${SEV[f.severity] || "•"} \`${f.severity}\` ${f.title}${f.screen ? ` — on *${f.screen}*` : ""}${f.url ? ` — ${f.url}` : ""}`);
1362
1444
  if (f.aiAnalysis) L.push(` - why: ${String(f.aiAnalysis).slice(0, 200)}`);
1363
1445
  if (f.suggestedFix) L.push(` - fix: ${String(f.suggestedFix).slice(0, 200)}`);
1364
1446
  }
@@ -1375,7 +1457,7 @@ function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiC
1375
1457
  // The merge decision is the gate's job (tapp ci), not exploration's (ADR-0005).
1376
1458
  L.push("");
1377
1459
  L.push(
1378
- `**Since last run** — +${regression.counts.new} new Ā· ${regression.counts.persisting} persisting Ā· ${regression.counts.resolved} resolved (comparison only — run \`tapp ci\` to gate)`
1460
+ `**Since last run** — +${regression.counts.new} new Ā· ${regression.counts.persisting} persisting Ā· ${regression.counts.resolved} resolved (comparison only — run \`npx -y @aarwitz/tapp@latest ci\` to gate)`
1379
1461
  );
1380
1462
  }
1381
1463
  if (inputHint) {
@@ -1458,7 +1540,10 @@ export function formatScreen(screenTitle, elements) {
1458
1540
  // `tapp` CLI verbs in bin/tapp.js — same pattern as report.js. Keep orchestration HERE so
1459
1541
  // the surfaces can't drift.)
1460
1542
 
1461
- export async function runQaWeb({ url, maxActions, timeout, testEmail, testPassword, baselineFindings, seedRoutes = [], seedTargets = [], surface = "mcp", onProgress = () => {} }) {
1543
+ export async function runQaWeb({ url, maxActions, timeout, testEmail, testPassword, baselineFindings, seedRoutes = [], seedTargets = [], watch = false, surface = "mcp", onProgress = () => {} }) {
1544
+ const { storagePreflight } = await import("./environment-preflight.js");
1545
+ const storage = storagePreflight(capturesDir);
1546
+ if (!storage.ok) return { error: storage.message, details: { environment: "storage", storage } };
1462
1547
  const actions = Math.max(1, Math.min(1000, asInteger(maxActions, 60)));
1463
1548
  const timeoutSec = Math.max(30, Math.min(3600, asInteger(timeout, 600)));
1464
1549
  const id = "web-" + new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14).replace(/^(\d{8})/, "$1-");
@@ -1475,6 +1560,7 @@ export async function runQaWeb({ url, maxActions, timeout, testEmail, testPasswo
1475
1560
  testPassword: isNonEmptyString(testPassword) ? testPassword.trim() : "",
1476
1561
  seedRoutes,
1477
1562
  seedTargets,
1563
+ watch: watch === true,
1478
1564
  onProgress,
1479
1565
  });
1480
1566
  } catch (err) {
@@ -1500,6 +1586,9 @@ export async function runQaWeb({ url, maxActions, timeout, testEmail, testPasswo
1500
1586
  }
1501
1587
 
1502
1588
  export async function runQaAndroid({ appId, apkPath, serial, maxActions, timeout, testEmail, testPassword, baselineFindings, clearData = true, seedTargets = [], surface = "mcp", onProgress = () => {} }) {
1589
+ const { storagePreflight } = await import("./environment-preflight.js");
1590
+ const storage = storagePreflight(capturesDir);
1591
+ if (!storage.ok) return { error: storage.message, details: { environment: "storage", storage } };
1503
1592
  const actions = Math.max(1, Math.min(1000, asInteger(maxActions, 60)));
1504
1593
  const timeoutSec = Math.max(30, Math.min(3600, asInteger(timeout, 600)));
1505
1594
  const id = "android-" + new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14).replace(/^(\d{8})/, "$1-");
@@ -1543,6 +1632,9 @@ export async function runQaAndroid({ appId, apkPath, serial, maxActions, timeout
1543
1632
  }
1544
1633
 
1545
1634
  export async function runQaIos({ bundleId, maxActions, timeout, args = {}, surface = "mcp", onProgress = () => {} }) {
1635
+ const { storagePreflight } = await import("./environment-preflight.js");
1636
+ const storage = storagePreflight(capturesDir);
1637
+ if (!storage.ok) return { error: storage.message, details: { environment: "storage", storage } };
1546
1638
  const captureScript = path.join(scriptsDir, "quick-capture.sh");
1547
1639
  if (!fs.existsSync(captureScript)) return { error: "Capture script not found", details: { captureScript } };
1548
1640
 
@@ -1635,6 +1727,7 @@ export async function runInitExploration({
1635
1727
  timeout,
1636
1728
  testEmail,
1637
1729
  testPassword,
1730
+ watch = false,
1638
1731
  onProgress = () => {},
1639
1732
  onStatus = () => {},
1640
1733
  } = {}) {
@@ -1650,17 +1743,18 @@ export async function runInitExploration({
1650
1743
  let targetResolution = null;
1651
1744
  let qa;
1652
1745
  let managedRuntime = null;
1746
+ if (watch && selected !== "web") return { error: "Watch mode is currently available for web exploration only." };
1653
1747
  if (selected === "web") {
1654
1748
  if (/^https?:\/\//i.test(String(url))) {
1655
1749
  resolvedTarget = String(url).trim();
1656
- qa = await runQaWeb({ url: resolvedTarget, maxActions, timeout, testEmail, testPassword, onProgress });
1750
+ qa = await runQaWeb({ url: resolvedTarget, maxActions, timeout, testEmail, testPassword, watch, onProgress });
1657
1751
  } else {
1658
1752
  const started = await startManagedWebTarget({ root, requestedTarget: target, timeout, onStatus });
1659
1753
  if (started.error) return started;
1660
1754
  managedRuntime = started;
1661
1755
  resolvedTarget = started.url;
1662
1756
  try {
1663
- qa = await runQaWeb({ url: resolvedTarget, maxActions, timeout, testEmail, testPassword, onProgress });
1757
+ qa = await runQaWeb({ url: resolvedTarget, maxActions, timeout, testEmail, testPassword, watch, onProgress });
1664
1758
  } finally {
1665
1759
  await stopManagedWebTarget(started);
1666
1760
  }
@@ -1772,6 +1866,7 @@ export async function runExploreTarget({
1772
1866
  appLaunchArgs,
1773
1867
  appLaunchEnv,
1774
1868
  baselineFindings,
1869
+ watch = false,
1775
1870
  surface = "cli",
1776
1871
  onProgress = () => {},
1777
1872
  onStatus = () => {},
@@ -1782,7 +1877,7 @@ export async function runExploreTarget({
1782
1877
 
1783
1878
  const modelPath = existingProjectArtifactPath(root, "application-model.json");
1784
1879
  if (!fs.existsSync(modelPath)) {
1785
- return { error: "No application model found — run `tapp init` first, or pass an explicit target (a bundle id, a path/to/App.app, a repo dir, --app-id/--apk, or an http(s) URL)." };
1880
+ return { error: "No application model found — call `tapp_init` or run `npx -y @aarwitz/tapp@latest init` first, or pass an explicit target (a bundle id, a path/to/App.app, a repo dir, --app-id/--apk, or an http(s) URL)." };
1786
1881
  }
1787
1882
  let model;
1788
1883
  try { model = JSON.parse(fs.readFileSync(modelPath, "utf8")); }
@@ -1794,6 +1889,8 @@ export async function runExploreTarget({
1794
1889
  catch (error) { return { error: error.message || String(error) }; }
1795
1890
  const selectedPlatform = selected.platform;
1796
1891
 
1892
+ if (watch && selectedPlatform !== "web") return { error: "Watch mode is currently available for web exploration only." };
1893
+
1797
1894
  if (selectedPlatform !== "ios" && ((Array.isArray(appLaunchArgs) && appLaunchArgs.length) || (appLaunchEnv && Object.keys(appLaunchEnv).length))) {
1798
1895
  return { error: "appLaunchArgs/appLaunchEnv apply only to iOS targets." };
1799
1896
  }
@@ -1802,14 +1899,14 @@ export async function runExploreTarget({
1802
1899
  const ownedUrl = String(selected.runtime?.ownedUrl || "").trim();
1803
1900
  if (/^https?:\/\//i.test(ownedUrl)) {
1804
1901
  onStatus(`Exploring the owned URL from the application model: ${ownedUrl}`);
1805
- return runQaWeb({ url: ownedUrl, maxActions, timeout, testEmail, testPassword, baselineFindings, surface, onProgress });
1902
+ return runQaWeb({ url: ownedUrl, maxActions, timeout, testEmail, testPassword, baselineFindings, watch, surface, onProgress });
1806
1903
  }
1807
1904
  // Tapp-managed: build/start the repo's web target, wait for readiness, and ALWAYS stop it.
1808
1905
  onStatus(`Preparing the managed web runtime for ${selected.name}…`);
1809
1906
  const started = await startManagedWebTarget({ root, requestedTarget: selected.sourcePath || selected.name || "", timeout, onStatus });
1810
1907
  if (started.error) return started;
1811
1908
  try {
1812
- return await runQaWeb({ url: started.url, maxActions, timeout, testEmail, testPassword, baselineFindings, surface, onProgress });
1909
+ return await runQaWeb({ url: started.url, maxActions, timeout, testEmail, testPassword, baselineFindings, watch, surface, onProgress });
1813
1910
  } finally {
1814
1911
  await stopManagedWebTarget(started);
1815
1912
  onStatus("Stopped the managed web runtime.");
@@ -1818,7 +1915,7 @@ export async function runExploreTarget({
1818
1915
 
1819
1916
  if (selectedPlatform === "android") {
1820
1917
  const appId = String(selected.runtime?.applicationId || "").trim();
1821
- if (!appId) return { error: `The Android target '${selected.name}' has no confirmed application id — confirm it and rerun \`tapp init\`, or pass --app-id.` };
1918
+ if (!appId) return { error: `The Android target '${selected.name}' has no confirmed application id — confirm it and call \`tapp_init\` or rerun \`npx -y @aarwitz/tapp@latest init\`, or pass --app-id.` };
1822
1919
  const task = selected.build?.task || "assembleDebug";
1823
1920
  onStatus(`Building the Android APK (${task})…`);
1824
1921
  const built = await buildAndroidApp({
@@ -1856,6 +1953,22 @@ function openLocalPort() {
1856
1953
  });
1857
1954
  }
1858
1955
 
1956
+ function localPortAvailable(port) {
1957
+ return new Promise((resolve) => {
1958
+ const server = net.createServer();
1959
+ server.unref();
1960
+ server.once("error", () => resolve(false));
1961
+ server.listen(port, "127.0.0.1", () => server.close(() => resolve(true)));
1962
+ });
1963
+ }
1964
+
1965
+ export function managedWebDefaultPort(dependencies = {}) {
1966
+ if (dependencies.vite) return 5173;
1967
+ if (dependencies.next) return 3000;
1968
+ if (dependencies["react-scripts"]) return 3000;
1969
+ return 0;
1970
+ }
1971
+
1859
1972
  function managedInstallSpec(command) {
1860
1973
  const known = {
1861
1974
  "npm ci": ["npm", ["ci"]],
@@ -1940,7 +2053,9 @@ export async function startManagedWebTarget({ root, requestedTarget = "", timeou
1940
2053
  const pkg = (() => { try { return JSON.parse(fs.readFileSync(path.join(projectDir, "package.json"), "utf8")); } catch { return {}; } })();
1941
2054
  const dependencies = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
1942
2055
  const declaredPort = startMatch ? declaredPortFromStartScript(pkg.scripts?.[startMatch[1]]) : 0;
1943
- const port = declaredPort || await openLocalPort();
2056
+ const frameworkPort = declaredPort ? 0 : managedWebDefaultPort(dependencies);
2057
+ const port = declaredPort || (frameworkPort && await localPortAvailable(frameworkPort) ? frameworkPort : await openLocalPort());
2058
+ const portBasis = declaredPort ? "repository-declared" : port === frameworkPort ? "framework-default" : "available-ephemeral";
1944
2059
  let command = "npm";
1945
2060
  let startArgs;
1946
2061
  let startDir = projectDir;
@@ -1967,13 +2082,13 @@ export async function startManagedWebTarget({ root, requestedTarget = "", timeou
1967
2082
  const append = (chunk) => fs.appendFileSync(logPath, String(chunk));
1968
2083
  child.stdout.on("data", append);
1969
2084
  child.stderr.on("data", append);
1970
- onStatus(`Managed web runtime: ${command === process.execPath ? "Tapp static server" : `npm ${startArgs.join(" ")}`} → http://127.0.0.1:${port}`);
2085
+ onStatus(`Managed web runtime: ${command === process.execPath ? "Tapp static server" : `npm ${startArgs.join(" ")}`} → http://127.0.0.1:${port} (${portBasis} port)`);
1971
2086
  const ready = await waitForOwnedUrl(`http://127.0.0.1:${port}`, child, Math.min(budgetMs, 60_000), logPath);
1972
2087
  if (ready.error) {
1973
2088
  await stopManagedWebTarget({ child, detached: process.platform !== "win32" });
1974
2089
  return ready;
1975
2090
  }
1976
- return { child, detached: process.platform !== "win32", url: `http://127.0.0.1:${port}`, logPath, install, build, start: { command, args: startArgs, cwd: startDir } };
2091
+ return { child, detached: process.platform !== "win32", url: `http://127.0.0.1:${port}`, logPath, install, build, start: { command, args: startArgs, cwd: startDir, portBasis } };
1977
2092
  }
1978
2093
 
1979
2094
  export async function stopManagedWebTarget(runtime) {
@@ -2032,11 +2147,61 @@ const server = new Server(
2032
2147
  },
2033
2148
  {
2034
2149
  capabilities: {
2150
+ prompts: {},
2035
2151
  tools: {},
2036
2152
  },
2037
2153
  }
2038
2154
  );
2039
2155
 
2156
+ const testAppPrompt = {
2157
+ name: "test-app",
2158
+ title: "Test this app with Tapp",
2159
+ description: "Use Tapp's real app surfaces to inspect, drive, or explore this repository and report evidence honestly.",
2160
+ arguments: [
2161
+ {
2162
+ name: "goal",
2163
+ description: "What to verify, such as finding bugs or exercising checkout",
2164
+ required: false,
2165
+ },
2166
+ {
2167
+ name: "target",
2168
+ description: "Optional repo target, bundle/app id, APK path, or owned URL",
2169
+ required: false,
2170
+ },
2171
+ ],
2172
+ };
2173
+
2174
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [testAppPrompt] }));
2175
+
2176
+ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
2177
+ if (request.params.name !== testAppPrompt.name) {
2178
+ throw new Error(`Unknown prompt: ${request.params.name}`);
2179
+ }
2180
+ const goal = isNonEmptyString(request.params.arguments?.goal)
2181
+ ? request.params.arguments.goal.trim()
2182
+ : "Test the app and find important bugs";
2183
+ const target = isNonEmptyString(request.params.arguments?.target)
2184
+ ? ` Use this target: ${request.params.arguments.target.trim()}.`
2185
+ : "";
2186
+ return {
2187
+ description: testAppPrompt.description,
2188
+ messages: [
2189
+ {
2190
+ role: "user",
2191
+ content: {
2192
+ type: "text",
2193
+ text:
2194
+ `${goal}.${target} Use the connected Tapp tools on the real UI surface. ` +
2195
+ "Use the smallest operation that satisfies the request; initialize/explore the source repo only for a general repository test. " +
2196
+ "If Tapp returns multiple target choices, ask me to select one instead of guessing. " +
2197
+ "Read visual evidence before describing it. Report findings, coverage, authority, inconclusive state, and checked/not-checked scope; " +
2198
+ "never turn exploration into a score or ship verdict. Do not edit the app unless I ask for a fix.",
2199
+ },
2200
+ },
2201
+ ],
2202
+ };
2203
+ });
2204
+
2040
2205
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
2041
2206
  tools: [
2042
2207
  {
@@ -2208,6 +2373,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2208
2373
  androidSerial: { type: "string", description: "Android: optional adb device serial; defaults to the first authorized device." },
2209
2374
  clearData: { type: "boolean", default: true, description: "Android: clear app data before launch for a repeatable starting state." },
2210
2375
  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." },
2376
+ 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." },
2211
2377
  maxActions: { type: "integer", minimum: 1, maximum: 1000, default: 60, description: "Exploration action budget" },
2212
2378
  timeout: { type: "integer", minimum: 30, maximum: 3600, default: 600, description: "Max wall-clock seconds" },
2213
2379
  testEmail: { type: "string", description: "Email for the login preamble, if the app has a sign-in" },
@@ -2250,7 +2416,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2250
2416
  "Findings from a previous run (pass back the `findings` array a prior tapp_explore returned). " +
2251
2417
  "When provided, the result adds a `regression` COMPARISON {counts:{new,persisting,resolved}, " +
2252
2418
  "newFindings, resolved} vs. that baseline — an observation, not a gate signal. To gate a merge, " +
2253
- "run `tapp ci` (or the GitHub Action): it applies the deterministic policy and returns the " +
2419
+ "run `npx -y @aarwitz/tapp@latest ci` (or the GitHub Action): it applies the deterministic policy and returns the " +
2254
2420
  "pass/fail/inconclusive outcome.",
2255
2421
  },
2256
2422
  },
@@ -2278,6 +2444,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2278
2444
  timeout: { type: "integer", minimum: 30, maximum: 3600, default: 600 },
2279
2445
  testEmail: { type: "string", description: "Explore: actor/login email; never persisted in the model" },
2280
2446
  testPassword: { type: "string", description: "Explore: actor/login password; never persisted in the model" },
2447
+ watch: { type: "boolean", default: false, description: "Web explore only: show the controlled browser and Tapp's actions" },
2281
2448
  maxContracts: { type: "integer", minimum: 1, maximum: 50, default: 15 },
2282
2449
  outDir: { type: "string", description: "Repo-relative artifact directory; default .tapp" },
2283
2450
  },
@@ -2731,45 +2898,62 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2731
2898
  const { name, arguments: args = {} } = request.params;
2732
2899
 
2733
2900
  if (name === "tapp_health") {
2734
- const checks = [];
2735
-
2736
- checks.push({
2737
- check: "repoRoot",
2738
- ok: fs.existsSync(path.join(repoRoot, "Tapp.xcodeproj")),
2739
- value: repoRoot,
2901
+ const coreChecks = [];
2902
+ coreChecks.push({
2903
+ check: "workspace",
2904
+ ok: fs.existsSync(workspaceRoot) && fs.statSync(workspaceRoot).isDirectory(),
2905
+ value: workspaceRoot,
2906
+ required: true,
2740
2907
  });
2741
2908
 
2742
2909
  const nodeVersion = await runCommand("node", ["-v"]);
2743
- checks.push({
2910
+ coreChecks.push({
2744
2911
  check: "node",
2745
2912
  ok: nodeVersion.code === 0,
2746
2913
  value: nodeVersion.stdout.trim() || nodeVersion.stderr.trim(),
2914
+ required: true,
2747
2915
  });
2748
2916
 
2749
2917
  const xcodebuildVersion = await runCommand("xcodebuild", ["-version"]);
2750
- checks.push({
2751
- check: "xcodebuild",
2752
- ok: xcodebuildVersion.code === 0,
2753
- value: (xcodebuildVersion.stdout || xcodebuildVersion.stderr).trim().split("\n")[0] || "not found",
2754
- });
2755
-
2756
- const simctl = await runCommand("xcrun", ["simctl", "list", "devices", "booted"]);
2757
- checks.push({
2758
- check: "bootedSimulator",
2759
- ok: simctl.code === 0,
2760
- value: (simctl.stdout || simctl.stderr).trim(),
2761
- });
2762
-
2763
- const allOk = checks.every((c) => c.ok);
2918
+ const simctl = xcodebuildVersion.code === 0
2919
+ ? await runCommand("xcrun", ["simctl", "list", "devices", "booted"])
2920
+ : { code: 1, stdout: "", stderr: "Xcode not found" };
2764
2921
  const bootedLine = (simctl.stdout || "").split("\n").find((l) => /\(Booted\)/.test(l));
2765
2922
  const bootedName = bootedLine ? bootedLine.trim().replace(/\s*\(.*$/, "") : null;
2766
- const L = [`### ${allOk ? "🩺 Tapp ready" : "āš ļø Tapp not fully ready"}`, ""];
2923
+ const adb = await runCommand("adb", ["devices"]);
2924
+ const androidDevice = adb.code === 0
2925
+ ? (adb.stdout || "").split("\n").find((line) => /\tdevice\s*$/.test(line))
2926
+ : null;
2927
+ let web = { ok: false, value: "Playwright or Chromium not installed" };
2928
+ try {
2929
+ const { chromium } = await import("playwright");
2930
+ const executable = chromium.executablePath();
2931
+ web = { ok: fs.existsSync(executable), value: fs.existsSync(executable) ? executable : "Chromium not installed" };
2932
+ } catch { /* optional dependency may be intentionally absent */ }
2933
+ const platformChecks = [
2934
+ {
2935
+ check: "iOS",
2936
+ ok: xcodebuildVersion.code === 0 && !!bootedName,
2937
+ value: bootedName ? `${bootedName} booted` : xcodebuildVersion.code === 0 ? "Xcode available; no simulator booted" : "Xcode not found",
2938
+ required: false,
2939
+ },
2940
+ {
2941
+ check: "Android",
2942
+ ok: !!androidDevice,
2943
+ value: androidDevice ? `${androidDevice.split("\t")[0]} connected` : adb.code === 0 ? "adb available; no authorized device" : "adb not found",
2944
+ required: false,
2945
+ },
2946
+ { check: "web", ok: web.ok, value: web.value, required: false },
2947
+ ];
2948
+ const checks = [...coreChecks, ...platformChecks];
2949
+ const ready = coreChecks.every((check) => check.ok) && platformChecks.some((check) => check.ok);
2950
+ const L = [`### ${ready ? "🩺 Tapp ready" : "āš ļø Tapp needs a platform runtime"}`, ""];
2767
2951
  for (const c of checks) {
2768
- L.push(`- ${c.ok ? "āœ…" : "āŒ"} **${c.check}** — ${String(c.value).split("\n")[0] || "—"}`);
2952
+ const icon = c.ok ? "āœ…" : c.required ? "āŒ" : "āšŖļø";
2953
+ L.push(`- ${icon} **${c.check}** — ${String(c.value).split("\n")[0] || "—"}`);
2769
2954
  }
2770
- L.push("");
2771
- L.push(bootedName ? `šŸ“± Simulator booted: **${bootedName}**` : "šŸ“± No simulator booted — run `tapp_boot_simulator` first.");
2772
- return richResult(L.join("\n"), { ok: allOk, checks });
2955
+ if (!ready) L.push("", "Run `npx -y @aarwitz/tapp@latest doctor` in the application repository for exact remediation.");
2956
+ return richResult(L.join("\n"), { ok: ready, workspaceRoot, checks, platforms: Object.fromEntries(platformChecks.map((check) => [check.check.toLowerCase(), check.ok])) });
2773
2957
  }
2774
2958
 
2775
2959
  if (name === "tapp_build") {
@@ -2788,11 +2972,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2788
2972
  if (inst.error) return errorResult(inst.error);
2789
2973
  bundleId = inst.bundleId;
2790
2974
  }
2975
+ let modelRefresh = null;
2976
+ let modelRefreshWarning = "";
2977
+ if (bundleId) {
2978
+ try {
2979
+ const { persistIosBuildValidation } = await import("./application-model.js");
2980
+ modelRefresh = await persistIosBuildValidation({ projectDir: dir, bundleId, container: built.container, scheme: built.scheme, configuration: built.configuration });
2981
+ } catch (error) {
2982
+ modelRefreshWarning = error.message || String(error);
2983
+ }
2984
+ }
2791
2985
  const text =
2792
2986
  `šŸ”Ø Built **${path.basename(built.appPath)}** (scheme \`${built.scheme}\`) in ${fmtDuration(Date.now() - startedAt)}` +
2793
2987
  (bundleId ? ` — installed on the simulator as \`${bundleId}\`` : "") +
2794
2988
  `\n\nNext: \`tapp_explore\` with \`appBundleId: "${bundleId || "<install it first>"}"\`.`;
2795
- return richResult(text, { ok: true, appPath: built.appPath, scheme: built.scheme, container: built.container, bundleId });
2989
+ return richResult(text + (modelRefresh ? `\nApplication model refreshed: \`${modelRefresh.modelPath}\`.` : modelRefreshWarning ? `\nāš ļø Application model refresh failed: ${modelRefreshWarning}` : ""), { ok: true, appPath: built.appPath, scheme: built.scheme, container: built.container, bundleId, modelRefreshed: !!modelRefresh, ...(modelRefreshWarning ? { modelRefreshWarning } : {}) });
2796
2990
  }
2797
2991
 
2798
2992
  if (name === "tapp_capture") {
@@ -2966,6 +3160,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2966
3160
  if (targets !== 1) {
2967
3161
  return errorResult("Provide exactly one of appBundleId (iOS), androidAppId (Android), or url (web beta)");
2968
3162
  }
3163
+ if (args.watch === true && !wantsWeb) return errorResult("watch is currently available for web exploration only");
2969
3164
 
2970
3165
  // Both branches call the shared engine (runQaWeb/runQaIos) — the handler only adds
2971
3166
  // MCP concerns: auth, arg validation, and progress notifications.
@@ -2987,6 +3182,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2987
3182
  testEmail: args.testEmail,
2988
3183
  testPassword: args.testPassword,
2989
3184
  baselineFindings: args.baselineFindings,
3185
+ watch: args.watch === true,
2990
3186
  onProgress: notifyProgress("pages reached"),
2991
3187
  });
2992
3188
  if (r.error) return errorResult(r.error, r.details || {});
@@ -3031,8 +3227,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3031
3227
  if (unauthorized) return unauthorized;
3032
3228
  const operation = String(args.operation || "inspect").toLowerCase();
3033
3229
  if (!["inspect", "write", "refresh", "explore"].includes(operation)) return errorResult("operation must be inspect|write|refresh|explore");
3034
- const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(repoRoot, args.projectDir.trim()) : repoRoot;
3035
- if (!isInsideDir(repoRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the repo");
3230
+ const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
3231
+ if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the workspace");
3036
3232
  const maxContracts = asInteger(args.maxContracts, 15);
3037
3233
  if (maxContracts < 1 || maxContracts > 50) return errorResult("maxContracts must be between 1 and 50");
3038
3234
  const { initializeProductProject } = await import("./product-operations.js");
@@ -3042,32 +3238,42 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3042
3238
  if (!isInsideDir(projectDir, resolvedOut)) return errorResult("outDir must be inside projectDir");
3043
3239
  const selectedPlatform = isNonEmptyString(args.platform) ? args.platform.trim().toLowerCase()
3044
3240
  : isNonEmptyString(args.url) ? "web"
3045
- : isNonEmptyString(args.androidAppId) || isNonEmptyString(args.apkPath) ? "android" : "ios";
3241
+ : isNonEmptyString(args.androidAppId) || isNonEmptyString(args.apkPath) ? "android" : "";
3046
3242
  const progressToken = request.params && request.params._meta ? request.params._meta.progressToken : undefined;
3047
3243
  const budget = Math.max(1, Math.min(1000, asInteger(args.maxActions, 40)));
3048
3244
  const result = await initializeProductProject({
3049
3245
  projectDir, mode: operation, outDir,
3050
3246
  ownedUrl: isNonEmptyString(args.url) ? args.url.trim() : "",
3051
3247
  platform: isNonEmptyString(args.platform) ? args.platform.trim().toLowerCase() : operation === "explore" ? selectedPlatform : "",
3052
- target: isNonEmptyString(args.target) ? args.target.trim() : projectDir,
3248
+ target: isNonEmptyString(args.target) ? args.target.trim() : "",
3053
3249
  bundleId: isNonEmptyString(args.appBundleId) ? args.appBundleId.trim() : "",
3054
3250
  appId: isNonEmptyString(args.androidAppId) ? args.androidAppId.trim() : "",
3055
3251
  apkPath: isNonEmptyString(args.apkPath) ? path.resolve(projectDir, args.apkPath.trim()) : undefined,
3056
3252
  serial: isNonEmptyString(args.androidSerial) ? args.androidSerial.trim() : undefined,
3057
3253
  maxActions: args.maxActions, timeout: args.timeout, maxContracts,
3058
3254
  testEmail: args.testEmail, testPassword: args.testPassword,
3255
+ watch: args.watch === true,
3059
3256
  runExploration: runInitExploration,
3060
3257
  onProgress: (progress) => {
3061
3258
  if (progressToken === undefined) return;
3062
3259
  server.notification({ method: "notifications/progress", params: { progressToken, progress: progress.action || 0, total: progress.max || budget, message: `Import exploration Ā· ${progress.states} state(s) reached` } }).catch(() => {});
3063
3260
  },
3064
3261
  });
3065
- const { model, plan, written, exploration } = result;
3066
- const blocking = model.requirements.filter((item) => item.severity === "blocking");
3262
+ const { model, plan, written, exploration, selectedTarget, requirementScope } = result;
3263
+ const blocking = (requirementScope?.active || model.requirements).filter((item) => item.severity === "blocking");
3264
+ const deferredBlocking = (requirementScope?.deferred || []).filter((item) => item.severity === "blocking");
3067
3265
  const pending = plan.items.filter((item) => item.decision === "pending");
3068
- const summary = `🧭 Tapp init — ${model.application.name} Ā· ${model.targets.length} target(s) Ā· UI Map ${model.uiMap.status} (${model.uiMap.nodeCount} states/${model.uiMap.edgeCount} transitions) Ā· ${plan.items.length} plan item(s), ${pending.length} pending Ā· ${blocking.length} blocking requirement(s)${exploration ? ` Ā· real ${exploration.platform} exploration: ${(exploration.findings || []).length} finding(s)${exploration.inconclusive ? " (inconclusive)" : ""}` : ""}`;
3069
- return richResult(summary, { model, plan, written: written ? { modelPath: written.modelPath, planPath: written.planPath } : null, exploration });
3070
- } catch (error) { return errorResult("Could not initialize Tapp repository artifacts", { detail: error.message || String(error) }); }
3266
+ const selectedLabel = selectedTarget ? ` for ${selectedTarget.platform}:${selectedTarget.name}` : "";
3267
+ const deferredLabel = deferredBlocking.length ? ` Ā· ${deferredBlocking.length} setup gap(s) on unselected target(s)` : "";
3268
+ const summary = `🧭 Tapp init — ${model.application.name} Ā· ${model.targets.length} target(s) Ā· UI Map ${model.uiMap.status} (${model.uiMap.nodeCount} states/${model.uiMap.edgeCount} transitions) Ā· ${plan.items.length} plan item(s), ${pending.length} pending Ā· ${blocking.length} blocking requirement(s)${selectedLabel}${deferredLabel}${exploration ? ` Ā· real ${exploration.platform} exploration: ${(exploration.findings || []).length} finding(s)${exploration.inconclusive ? " (inconclusive)" : ""}` : ""}`;
3269
+ return richResult(summary, { model, plan, selectedTarget, requirementScope, written: written ? { modelPath: written.modelPath, planPath: written.planPath } : null, exploration });
3270
+ } catch (error) {
3271
+ const detail = error.message || String(error);
3272
+ const message = error.details?.reason === "target-selection-required"
3273
+ ? detail
3274
+ : "Could not initialize Tapp repository artifacts";
3275
+ return errorResult(message, { detail, ...(error.details || {}) });
3276
+ }
3071
3277
  }
3072
3278
 
3073
3279
  if (name === "tapp_actor_config") {
@@ -3078,8 +3284,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3078
3284
  const allowedArguments = new Set(["authToken", "operation", "projectDir", "name", "role", "session", "provisioning", "credentialBindings", "replace"]);
3079
3285
  const unexpectedArguments = Object.keys(args).filter((key) => !allowedArguments.has(key));
3080
3286
  if (unexpectedArguments.length) return errorResult("Unsupported actor configuration fields; credential values are never accepted", { fields: unexpectedArguments });
3081
- const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(repoRoot, args.projectDir.trim()) : repoRoot;
3082
- if (!isInsideDir(repoRoot, projectDir) || !fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) return errorResult("projectDir must be an existing directory inside the repo");
3287
+ const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
3288
+ if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) return errorResult("projectDir must be an existing directory inside the workspace");
3083
3289
  const { configureActor, readProjectConfig } = await import("./project-config.js");
3084
3290
  if (operation === "read") {
3085
3291
  const loaded = readProjectConfig(projectDir);
@@ -3099,7 +3305,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3099
3305
  credentials,
3100
3306
  replace: asBoolean(args.replace),
3101
3307
  });
3102
- return richResult(`āœ… Actor '${args.name.trim()}' configured with ${Object.keys(result.actor.credentials).length} environment binding(s); no credential values were accepted or written`, { path: result.path, actor: result.actor, next: "Run tapp_init refresh to update the application model and release plan." });
3308
+ const { refreshExistingInitArtifacts } = await import("./application-model.js");
3309
+ let refreshed = null;
3310
+ let refreshWarning = "";
3311
+ try { refreshed = await refreshExistingInitArtifacts({ projectDir }); }
3312
+ catch (error) { refreshWarning = error.message || String(error); }
3313
+ return richResult(
3314
+ `āœ… Actor '${args.name.trim()}' configured with ${Object.keys(result.actor.credentials).length} environment binding(s); no credential values were accepted or written${refreshed ? " Ā· application model refreshed" : ""}${refreshWarning ? `\nāš ļø Application model refresh failed: ${refreshWarning}` : ""}`,
3315
+ { path: result.path, actor: result.actor, modelRefreshed: !!refreshed, ...(refreshed ? { modelPath: refreshed.modelPath, planPath: refreshed.planPath } : {}), ...(refreshWarning ? { refreshWarning } : {}) },
3316
+ );
3103
3317
  } catch (error) { return errorResult("Actor not configured", { detail: error.message || String(error) }); }
3104
3318
  }
3105
3319
 
@@ -3108,8 +3322,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3108
3322
  if (unauthorized) return unauthorized;
3109
3323
  const operation = String(args.operation || "read").toLowerCase();
3110
3324
  if (!["read", "review", "generate", "validate", "promote"].includes(operation)) return errorResult("operation must be read|review|generate|validate|promote");
3111
- const planPath = isNonEmptyString(args.planPath) ? path.resolve(repoRoot, args.planPath.trim()) : existingProjectArtifactPath(repoRoot, "release-plan.json");
3112
- if (!isInsideDir(repoRoot, planPath)) return errorResult("planPath must be inside the repo");
3325
+ const planPath = isNonEmptyString(args.planPath) ? path.resolve(workspaceRoot, args.planPath.trim()) : existingProjectArtifactPath(workspaceRoot, "release-plan.json");
3326
+ if (!isInsideDir(workspaceRoot, planPath)) return errorResult("planPath must be inside the workspace");
3113
3327
  if (!fs.existsSync(planPath)) return errorResult("Release plan not found", { planPath });
3114
3328
  let plan;
3115
3329
  try { plan = JSON.parse(fs.readFileSync(planPath, "utf8")); }
@@ -3119,11 +3333,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3119
3333
  if (![...decisions.approve, ...decisions.reject, ...decisions.defer].length) return errorResult("review requires at least one approve, reject, or defer item");
3120
3334
  const { reviewProductPlan } = await import("./product-operations.js");
3121
3335
  try {
3122
- plan = reviewProductPlan({ projectDir: repoRoot, planPath, ...decisions }).plan;
3336
+ plan = reviewProductPlan({ projectDir: workspaceRoot, planPath, ...decisions }).plan;
3123
3337
  } catch (error) { return errorResult("Could not review release plan", { detail: error.message || String(error) }); }
3124
3338
  } else if (operation === "generate") {
3125
- const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(repoRoot, args.projectDir.trim()) : repoRoot;
3126
- if (!isInsideDir(repoRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the repo");
3339
+ const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
3340
+ if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the workspace");
3127
3341
  const { generateProductPlan } = await import("./product-operations.js");
3128
3342
  try {
3129
3343
  const result = await generateProductPlan({ projectDir, planPath });
@@ -3131,8 +3345,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3131
3345
  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 });
3132
3346
  } catch (error) { return errorResult("Could not generate contract drafts", { detail: error.message || String(error) }); }
3133
3347
  } else if (operation === "validate") {
3134
- const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(repoRoot, args.projectDir.trim()) : repoRoot;
3135
- if (!isInsideDir(repoRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the repo");
3348
+ const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
3349
+ if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the workspace");
3136
3350
  let apkPath = "";
3137
3351
  if (isNonEmptyString(args.apkPath)) {
3138
3352
  apkPath = path.resolve(projectDir, args.apkPath.trim());
@@ -3164,8 +3378,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3164
3378
  return richResult(`šŸ”Ž Generated contract validation passed — ${result.results.length}/${result.results.length} on ${result.platform}`, { ...result, planPath });
3165
3379
  } catch (error) { return errorResult("Generated contract validation failed", { detail: error.message || String(error), plan, planPath }); }
3166
3380
  } else if (operation === "promote") {
3167
- const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(repoRoot, args.projectDir.trim()) : repoRoot;
3168
- if (!isInsideDir(repoRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the repo");
3381
+ const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
3382
+ if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the workspace");
3169
3383
  const { promoteProductPlan } = await import("./product-operations.js");
3170
3384
  try {
3171
3385
  const result = await promoteProductPlan({ projectDir, planPath, items: Array.isArray(args.items) ? args.items : [] });
@@ -3181,9 +3395,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3181
3395
  if (unauthorized) return unauthorized;
3182
3396
  const operation = String(args.operation || "inspect").toLowerCase();
3183
3397
  if (!["inspect", "install", "baseline"].includes(operation)) return errorResult("operation must be inspect|install|baseline");
3184
- const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(repoRoot, args.projectDir.trim()) : repoRoot;
3185
- if (!isInsideDir(repoRoot, projectDir) || !fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) return errorResult("projectDir must be an existing directory inside the repo");
3186
- const modelPath = isNonEmptyString(args.modelPath) ? path.resolve(repoRoot, args.modelPath.trim()) : existingProjectArtifactPath(projectDir, "application-model.json");
3398
+ const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
3399
+ if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) return errorResult("projectDir must be an existing directory inside the workspace");
3400
+ const modelPath = isNonEmptyString(args.modelPath) ? path.resolve(workspaceRoot, args.modelPath.trim()) : existingProjectArtifactPath(projectDir, "application-model.json");
3187
3401
  if (!isInsideDir(projectDir, modelPath) || !fs.existsSync(modelPath)) return errorResult("Application model not found inside projectDir; run tapp_init first", { modelPath });
3188
3402
  let model;
3189
3403
  try { model = JSON.parse(fs.readFileSync(modelPath, "utf8")); }
@@ -3191,8 +3405,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3191
3405
  const { createProductBaseline, installProductCi, prepareProductCi } = await import("./product-operations.js");
3192
3406
  if (operation === "baseline") {
3193
3407
  if (!isNonEmptyString(args.reportPath)) return errorResult("baseline requires reportPath from a successful portable gate");
3194
- const reportPath = path.resolve(repoRoot, args.reportPath.trim());
3195
- if (!isInsideDir(repoRoot, reportPath) || !fs.existsSync(reportPath)) return errorResult("reportPath must be an existing JSON file inside the repo");
3408
+ const reportPath = path.resolve(workspaceRoot, args.reportPath.trim());
3409
+ if (!isInsideDir(workspaceRoot, reportPath) || !fs.existsSync(reportPath)) return errorResult("reportPath must be an existing JSON file inside the workspace");
3196
3410
  let report;
3197
3411
  try { report = JSON.parse(fs.readFileSync(reportPath, "utf8")); }
3198
3412
  catch (error) { return errorResult("Gate report is invalid JSON", { detail: error.message || String(error) }); }
@@ -3217,8 +3431,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3217
3431
  if (unauthorized) return unauthorized;
3218
3432
  const operation = String(args.operation || "read").toLowerCase();
3219
3433
  const resolveRepoFile = (value, fallback = "") => {
3220
- const resolved = path.resolve(repoRoot, isNonEmptyString(value) ? value.trim() : fallback);
3221
- return isInsideDir(repoRoot, resolved) ? resolved : null;
3434
+ const resolved = path.resolve(workspaceRoot, isNonEmptyString(value) ? value.trim() : fallback);
3435
+ return isInsideDir(workspaceRoot, resolved) ? resolved : null;
3222
3436
  };
3223
3437
  const capture = isNonEmptyString(args.captureId) ? listCaptureRuns(200).find((run) => run.id === args.captureId.trim()) : null;
3224
3438
  if (isNonEmptyString(args.captureId) && !capture) return errorResult("Capture not found", { captureId: args.captureId });
@@ -3238,18 +3452,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3238
3452
  const markersPath = capture ? path.join(capture.path, "ocqa-markers.txt") : resolveRepoFile(args.markersPath);
3239
3453
  if (!markersPath) return errorResult("markersPath must be inside the repo, or provide captureId");
3240
3454
  if (!fs.existsSync(markersPath)) return errorResult("OCQA markers not found", { markersPath });
3241
- const outPath = isNonEmptyString(args.mapPath) ? resolveRepoFile(args.mapPath) : existingProjectArtifactPath(repoRoot, "ui-map.json");
3455
+ const outPath = isNonEmptyString(args.mapPath) ? resolveRepoFile(args.mapPath) : existingProjectArtifactPath(workspaceRoot, "ui-map.json");
3242
3456
  if (!outPath) return errorResult("mapPath must be inside the repo");
3243
3457
  try {
3244
3458
  const observed = buildUiMapFromMarkers({ markersPath, platform: args.platform || "ios", target: args.target || "", runId: capture?.id || "" });
3245
3459
  const map = fs.existsSync(outPath) && args.replace !== true ? mergeUiMaps(JSON.parse(fs.readFileSync(outPath, "utf8")), observed) : observed;
3246
3460
  writeUiMap(outPath, map);
3247
3461
  const controls = map.nodes.reduce((total, node) => total + node.controls.length, 0);
3248
- return richResult(`šŸ—ŗļø UI Map updated — ${map.nodes.length} states Ā· ${map.edges.length} transitions Ā· ${controls} semantic controls\n${path.relative(repoRoot, outPath)}`, { map, path: outPath });
3462
+ return richResult(`šŸ—ŗļø UI Map updated — ${map.nodes.length} states Ā· ${map.edges.length} transitions Ā· ${controls} semantic controls\n${path.relative(workspaceRoot, outPath)}`, { map, path: outPath });
3249
3463
  } catch (error) { return errorResult("Could not build UI Map", { detail: error.message || String(error) }); }
3250
3464
  }
3251
3465
  if (operation !== "read") return errorResult("operation must be read|build|diff");
3252
- const mapPath = capture ? path.join(capture.path, "ui-map.json") : isNonEmptyString(args.mapPath) ? resolveRepoFile(args.mapPath) : existingProjectArtifactPath(repoRoot, "ui-map.json");
3466
+ const mapPath = capture ? path.join(capture.path, "ui-map.json") : isNonEmptyString(args.mapPath) ? resolveRepoFile(args.mapPath) : existingProjectArtifactPath(workspaceRoot, "ui-map.json");
3253
3467
  if (!mapPath) return errorResult("mapPath must be inside the repo");
3254
3468
  if (!fs.existsSync(mapPath)) return errorResult("UI Map not found; run QA or operation=build first", { mapPath });
3255
3469
  try {
@@ -3266,8 +3480,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3266
3480
  if (unauthorized) return unauthorized;
3267
3481
  const operation = String(args.operation || "validate").toLowerCase();
3268
3482
  if (!["read", "validate", "compile"].includes(operation)) return errorResult("operation must be read|validate|compile");
3269
- const taskPath = isNonEmptyString(args.taskPath) ? path.resolve(repoRoot, args.taskPath.trim()) : null;
3270
- if (!taskPath || !isInsideDir(repoRoot, taskPath)) return errorResult("taskPath must be inside the repo");
3483
+ const taskPath = isNonEmptyString(args.taskPath) ? path.resolve(workspaceRoot, args.taskPath.trim()) : null;
3484
+ if (!taskPath || !isInsideDir(workspaceRoot, taskPath)) return errorResult("taskPath must be inside the workspace");
3271
3485
  if (!fs.existsSync(taskPath)) return errorResult("Task file not found", { taskPath: args.taskPath });
3272
3486
  const { applyTaskCoverage, compileTaskSteps, loadTaskFile, loadTaskRegistry, validateTaskAgainstUiMap } = await import("./task-runtime.js");
3273
3487
  let task;
@@ -3278,8 +3492,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3278
3492
  let groundingMap = null;
3279
3493
  let groundingMapPath = null;
3280
3494
  if (isNonEmptyString(args.mapPath)) {
3281
- const mapPath = path.resolve(repoRoot, args.mapPath.trim());
3282
- if (!isInsideDir(repoRoot, mapPath)) return errorResult("mapPath must be inside the repo");
3495
+ const mapPath = path.resolve(workspaceRoot, args.mapPath.trim());
3496
+ if (!isInsideDir(workspaceRoot, mapPath)) return errorResult("mapPath must be inside the workspace");
3283
3497
  if (!fs.existsSync(mapPath)) return errorResult("UI Map not found", { mapPath: args.mapPath });
3284
3498
  groundingMapPath = mapPath;
3285
3499
  try {
@@ -3309,12 +3523,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3309
3523
  const flow = { name: `Task: ${task.name}`, kind: "flow", platform: args.platform || "", vars: compiled.vars, steps: compiled.steps, taskPlan: compiled.plan };
3310
3524
  let outPath = null;
3311
3525
  if (isNonEmptyString(args.outPath)) {
3312
- outPath = path.resolve(repoRoot, args.outPath.trim());
3313
- if (!isInsideDir(repoRoot, outPath)) return errorResult("outPath must be inside the repo");
3526
+ outPath = path.resolve(workspaceRoot, args.outPath.trim());
3527
+ if (!isInsideDir(workspaceRoot, outPath)) return errorResult("outPath must be inside the workspace");
3314
3528
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
3315
3529
  fs.writeFileSync(outPath, JSON.stringify(flow, null, 2) + "\n");
3316
3530
  }
3317
- return richResult(`🧩 Compiled ${task.name} into ${flow.steps.length} deterministic Flow steps${outPath ? `\n${path.relative(repoRoot, outPath)}` : ""}`, { flow, grounding, path: outPath });
3531
+ return richResult(`🧩 Compiled ${task.name} into ${flow.steps.length} deterministic Flow steps${outPath ? `\n${path.relative(workspaceRoot, outPath)}` : ""}`, { flow, grounding, path: outPath });
3318
3532
  }
3319
3533
 
3320
3534
  if (name === "tapp_release_contract") {
@@ -3322,8 +3536,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3322
3536
  if (unauthorized) return unauthorized;
3323
3537
  const operation = String(args.operation || "validate").toLowerCase();
3324
3538
  if (!["read", "validate", "compile", "run"].includes(operation)) return errorResult("operation must be read|validate|compile|run");
3325
- const contractPath = isNonEmptyString(args.contractPath) ? path.resolve(repoRoot, args.contractPath.trim()) : null;
3326
- if (!contractPath || !isInsideDir(repoRoot, contractPath)) return errorResult("contractPath must be inside the repo");
3539
+ const contractPath = isNonEmptyString(args.contractPath) ? path.resolve(workspaceRoot, args.contractPath.trim()) : null;
3540
+ if (!contractPath || !isInsideDir(workspaceRoot, contractPath)) return errorResult("contractPath must be inside the workspace");
3327
3541
  const {
3328
3542
  applyReleaseContractCoverage,
3329
3543
  compileReleaseContract,
@@ -3338,8 +3552,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3338
3552
  let groundingMap = null;
3339
3553
  let groundingMapPath = null;
3340
3554
  if (isNonEmptyString(args.mapPath)) {
3341
- groundingMapPath = path.resolve(repoRoot, args.mapPath.trim());
3342
- if (!isInsideDir(repoRoot, groundingMapPath)) return errorResult("mapPath must be inside the repo");
3555
+ groundingMapPath = path.resolve(workspaceRoot, args.mapPath.trim());
3556
+ if (!isInsideDir(workspaceRoot, groundingMapPath)) return errorResult("mapPath must be inside the workspace");
3343
3557
  if (!fs.existsSync(groundingMapPath)) return errorResult("UI Map not found", { mapPath: args.mapPath });
3344
3558
  try {
3345
3559
  groundingMap = JSON.parse(fs.readFileSync(groundingMapPath, "utf8"));
@@ -3360,8 +3574,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3360
3574
  catch (error) { return errorResult("Could not compile Release Contract", { detail: error.message || String(error) }); }
3361
3575
  let outPath = null;
3362
3576
  if (isNonEmptyString(args.outPath)) {
3363
- outPath = path.resolve(repoRoot, args.outPath.trim());
3364
- if (!isInsideDir(repoRoot, outPath)) return errorResult("outPath must be inside the repo");
3577
+ outPath = path.resolve(workspaceRoot, args.outPath.trim());
3578
+ if (!isInsideDir(workspaceRoot, outPath)) return errorResult("outPath must be inside the workspace");
3365
3579
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
3366
3580
  fs.writeFileSync(outPath, JSON.stringify(execution, null, 2) + "\n");
3367
3581
  }
@@ -3408,12 +3622,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3408
3622
  if (unauthorized) return unauthorized;
3409
3623
  const operation = isNonEmptyString(args.operation) ? args.operation.trim().toLowerCase() : "plan";
3410
3624
  if (!['plan', 'adopt'].includes(operation)) return errorResult("operation must be plan|adopt");
3411
- const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(repoRoot, args.projectDir.trim()) : repoRoot;
3412
- if (!isInsideDir(repoRoot, projectDir)) return errorResult("projectDir must be inside the repo");
3625
+ const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
3626
+ if (!isInsideDir(workspaceRoot, projectDir)) return errorResult("projectDir must be inside the workspace");
3413
3627
  if (operation === "adopt") {
3414
3628
  if (!isNonEmptyString(args.prPlanPath) || !isNonEmptyString(args.item)) return errorResult("adopt requires prPlanPath and item");
3415
3629
  const prPlanPath = path.resolve(projectDir, args.prPlanPath.trim());
3416
- if (!isInsideDir(repoRoot, prPlanPath)) return errorResult("prPlanPath must be inside the repo");
3630
+ if (!isInsideDir(workspaceRoot, prPlanPath)) return errorResult("prPlanPath must be inside the workspace");
3417
3631
  const { adoptPrCoverageProposal } = await import("./pr-selection.js");
3418
3632
  try {
3419
3633
  const adopted = adoptPrCoverageProposal({
@@ -3457,8 +3671,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3457
3671
  fs.writeFileSync(flowFile, JSON.stringify(args.flow));
3458
3672
  parsedFlow = args.flow;
3459
3673
  } else if (isNonEmptyString(args.flowPath)) {
3460
- const p = path.resolve(repoRoot, args.flowPath.trim());
3461
- if (!isInsideDir(repoRoot, p)) return errorResult("flowPath must be inside the repo");
3674
+ const p = path.resolve(workspaceRoot, args.flowPath.trim());
3675
+ if (!isInsideDir(workspaceRoot, p)) return errorResult("flowPath must be inside the workspace");
3462
3676
  if (!fs.existsSync(p)) return errorResult("Flow file not found", { flowPath: args.flowPath });
3463
3677
  flowFile = p;
3464
3678
  } else {
@@ -3540,8 +3754,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3540
3754
  if (args.scenario && typeof args.scenario === "object") {
3541
3755
  scenario = args.scenario;
3542
3756
  } else if (isNonEmptyString(args.scenarioPath)) {
3543
- const scenarioFile = path.resolve(repoRoot, args.scenarioPath.trim());
3544
- if (!isInsideDir(repoRoot, scenarioFile)) return errorResult("scenarioPath must be inside the repo");
3757
+ const scenarioFile = path.resolve(workspaceRoot, args.scenarioPath.trim());
3758
+ if (!isInsideDir(workspaceRoot, scenarioFile)) return errorResult("scenarioPath must be inside the workspace");
3545
3759
  if (!fs.existsSync(scenarioFile)) return errorResult("Scenario file not found", { scenarioPath: args.scenarioPath });
3546
3760
  try {
3547
3761
  const { loadScenarioFile } = await import("./scenario-runtime.js");
@@ -3611,7 +3825,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3611
3825
  const ungrounded = ungroundedScreens(parsed.steps, grounding);
3612
3826
  const flow = { name: args.name || parsed.name, app: bundleId, steps: parsed.steps };
3613
3827
  const slug = flow.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "generated-flow";
3614
- const dir = path.join(repoRoot, ".tapp", "proposals", "flows");
3828
+ const dir = path.join(workspaceRoot, ".tapp", "proposals", "flows");
3615
3829
  fs.mkdirSync(dir, { recursive: true });
3616
3830
  const outPath = path.join(dir, `${slug}.yml`);
3617
3831
  const promotedPath = path.join(".tapp", "flows", `${slug}.yml`);
@@ -3619,7 +3833,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3619
3833
  const yaml = (yamlRes.stdout || "").trim();
3620
3834
  if (!yaml) return errorResult("Failed to render flow YAML", { stderr: yamlRes.stderr });
3621
3835
  fs.writeFileSync(outPath, yaml + "\n");
3622
- const rel = path.relative(repoRoot, outPath);
3836
+ const rel = path.relative(workspaceRoot, outPath);
3623
3837
 
3624
3838
  const L = [`šŸ¤– Generated a flow **proposal** **${flow.name}** from your goal → \`${rel}\``];
3625
3839
  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."}`);
@@ -3867,14 +4081,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3867
4081
  const detailNote = !ok && r.detail ? ` — ${r.detail}` : "";
3868
4082
  const head = `${did} — ${ok ? "ok" : `āš ļø ${r.status}${detailNote}`} → now on **${r.screenTitle || "Unknown"}**`;
3869
4083
  const rec = typeof r.recordedSteps === "number" ? `\n\nšŸ”“ Recording — ${r.recordedSteps} step(s). \`tapp_flow_save\` to keep it as a test.` : "";
3870
- return richResult(head + "\n\n" + formatScreen(r.screenTitle, r.elements) + rec, r);
4084
+ const result = richResult(head + "\n\n" + formatScreen(r.screenTitle, r.elements) + rec, r);
4085
+ if (!ok) result.isError = true;
4086
+ return result;
3871
4087
  }
3872
4088
 
3873
4089
  if (name === "tapp_flow_save") {
3874
4090
  const unauthorized = ensureAuthorized(args);
3875
4091
  if (unauthorized) return unauthorized;
3876
4092
  try {
3877
- const saved = await saveInteractiveSessionFlow({ projectDir:repoRoot, name:args.name, addFinalAssertion:args.addFinalAssertion !== false, replace:args.replace === true });
4093
+ const saved = await saveInteractiveSessionFlow({ projectDir:workspaceRoot, name:args.name, addFinalAssertion:args.addFinalAssertion !== false, replace:args.replace === true });
3878
4094
  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}"\`.`;
3879
4095
  return richResult(text, { path:saved.path, flow:saved.flow });
3880
4096
  } catch (error) {