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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "tapp",
3
+ "description": "The official Tapp plugin: one agent skill and MCP tools for testing iOS, Android, and web apps on real surfaces.",
4
+ "owner": {
5
+ "name": "Aaron Horowitz",
6
+ "url": "https://github.com/aarwitz"
7
+ },
8
+ "plugins": [
9
+ {
10
+ "name": "tapp",
11
+ "source": {
12
+ "source": "npm",
13
+ "package": "@aarwitz/tapp"
14
+ },
15
+ "description": "Test real iOS, Android, and web apps with Tapp. Includes the Tapp agent skill plus MCP hands, eyes, exploration, replayable Flows, and deterministic release evidence.",
16
+ "author": {
17
+ "name": "Aaron Horowitz",
18
+ "url": "https://github.com/aarwitz"
19
+ },
20
+ "homepage": "https://github.com/aarwitz/tapp",
21
+ "license": "MIT",
22
+ "keywords": [
23
+ "ios",
24
+ "simulator",
25
+ "testing",
26
+ "qa",
27
+ "xcuitest",
28
+ "mcp",
29
+ "agent-skill",
30
+ "copilot"
31
+ ]
32
+ }
33
+ ]
34
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "tapp",
3
+ "description": "Test real iOS, Android, and web apps with Tapp. Includes the Tapp agent skill plus MCP hands, eyes, exploration, replayable Flows, and deterministic release evidence.",
4
+ "version": "0.17.0-rc.12",
5
+ "author": {
6
+ "name": "Aaron Horowitz",
7
+ "url": "https://github.com/aarwitz"
8
+ },
9
+ "homepage": "https://github.com/aarwitz/tapp",
10
+ "repository": "https://github.com/aarwitz/tapp",
11
+ "license": "MIT",
12
+ "keywords": [
13
+ "ios",
14
+ "simulator",
15
+ "testing",
16
+ "qa",
17
+ "xcuitest",
18
+ "mcp",
19
+ "agent-skill",
20
+ "copilot"
21
+ ],
22
+ "mcpServers": {
23
+ "tapp": {
24
+ "command": "node",
25
+ "args": [
26
+ "${CLAUDE_PLUGIN_ROOT}/bin/tapp.js",
27
+ "mcp"
28
+ ],
29
+ "cwd": "${CLAUDE_PROJECT_DIR}"
30
+ }
31
+ }
32
+ }
package/AGENTS.md CHANGED
@@ -12,6 +12,7 @@ bundle id, or (web) an http(s) URL. You never need to know a bundle id up front.
12
12
 
13
13
  ```bash
14
14
  npx -y @aarwitz/tapp explore [target] # autonomous exploration → findings + evidence (observation, not a gate; ≈ tapp_explore)
15
+ npx -y @aarwitz/tapp explore https://your-app.example --watch # web: visibly follow the same exploration
15
16
  npx -y @aarwitz/tapp open [target] # launch + screen summary + screenshot saved to a file (≈ tapp_open_app)
16
17
  npx -y @aarwitz/tapp tree [target] # accessibility tree, --json for every element (≈ tapp_ui_tree)
17
18
  npx -y @aarwitz/tapp shot # screenshot the booted sim → file path (≈ tapp_screenshot)
@@ -43,7 +44,9 @@ that IS the screen. If you cannot (Cursor, VS Code Copilot), connect the MCP ser
43
44
  instead: its tool results carry the screenshot inline. Screen *recordings* are for the
44
45
  human: on **iOS**, `tapp explore` records the full exploration and embeds it in the report.html
45
46
  evidence page (Android does not currently record video) — tell the user the report path so they can
46
- watch it.
47
+ watch it. On **web**, explicit `--watch` opens the isolated Playwright Chromium window and overlays
48
+ Tapp's current action and pointer; the overlay is omitted from evidence screenshots. It does not
49
+ drive the person's existing/default browser profile.
47
50
 
48
51
  The interactive session/record loop is MCP-only (it needs a long-lived process). Flow replay is
49
52
  also available in the CLI. The rest of this playbook assumes the `tapp_*` MCP tools are connected. With
package/README.md CHANGED
@@ -4,8 +4,10 @@
4
4
  [![npm](https://img.shields.io/npm/v/%40aarwitz%2Ftapp?color=cb3837&label=npm)](https://www.npmjs.com/package/@aarwitz/tapp)
5
5
  [![npm downloads](https://img.shields.io/npm/dw/%40aarwitz%2Ftapp?label=downloads)](https://www.npmjs.com/package/@aarwitz/tapp)
6
6
  [![license](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)
7
+ [![Agent Skill](https://img.shields.io/badge/Agent_Skill-install-6b5cff)](https://skills.sh/aarwitz/tapp)
8
+ [![VS Code extension](https://img.shields.io/badge/VS_Code-extension-0098FF)](https://marketplace.visualstudio.com/items?itemName=lidi-solutions.tapp)
7
9
  [![Install in Cursor](https://img.shields.io/badge/Cursor-Install_MCP-000000)](cursor://anysphere.cursor-deeplink/mcp/install?name=tapp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBhYXJ3aXR6L3RhcHAiLCJtY3AiXX0=)
8
- [![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_MCP-0098FF)](https://insiders.vscode.dev/redirect/mcp/install?name=tapp&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40aarwitz%2Ftapp%22%2C%22mcp%22%5D%7D)
10
+ [![VS Code MCP](https://img.shields.io/badge/VS_Code-Install_MCP-0098FF)](https://insiders.vscode.dev/redirect/mcp/install?name=tapp&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40aarwitz%2Ftapp%22%2C%22mcp%22%5D%7D)
9
11
 
10
12
  **Tapp is the release-contract and evidence layer for teams shipping agent-authored applications.**
11
13
  It turns a repository and real product into an observed UI Map, a compact reviewed deterministic
@@ -31,6 +33,47 @@ Three platforms, one observe-and-gate engine:
31
33
  autonomous exploration, the deterministic detectors (uncaught exceptions, failed requests,
32
34
  dead buttons, broken links, placeholder `href="#"` links, error pages), and the same gate.
33
35
 
36
+ ## Give Tapp to your coding agent
37
+
38
+ After setup, the whole user prompt is:
39
+
40
+ > Use Tapp to test this app.
41
+
42
+ The official skill teaches the agent to choose the smallest useful operation, handle repositories
43
+ with multiple app targets, inspect visual evidence, and keep exploration observations separate from
44
+ release judgment.
45
+
46
+ **Claude Code — skill and MCP tools together:**
47
+
48
+ ```bash
49
+ claude plugin marketplace add aarwitz/tapp
50
+ claude plugin install tapp@tapp
51
+ ```
52
+
53
+ Restart Claude Code after installation, open the application repository, and use the short prompt
54
+ above. The plugin bundles both the `tapp` Agent Skill and the local stdio MCP server; no additional
55
+ prompt block or bundle id is required.
56
+
57
+ **Claude, Codex, Cursor, Copilot, and other Agent Skills clients — skill only:**
58
+
59
+ ```bash
60
+ npx -y skills add aarwitz/tapp --skill tapp
61
+ ```
62
+
63
+ This installs the open Agent Skills version of the same instructions into the current project and
64
+ lets the agent fall back to the npm CLI when MCP is not connected. Add `-g` for a user-wide install,
65
+ or `--agent claude-code`, `--agent codex`, and similar selectors to constrain the clients.
66
+
67
+ **No agent integration:** run the npm package directly from an app repository:
68
+
69
+ ```bash
70
+ npx -y @aarwitz/tapp init . --explore
71
+ ```
72
+
73
+ **VS Code:** install [Tapp from the Marketplace](https://marketplace.visualstudio.com/items?itemName=lidi-solutions.tapp).
74
+ It contributes the same cross-platform Agent Skill to Copilot plus focused iOS simulator tools and a
75
+ live simulator panel. Android and web remain available through the skill's CLI/MCP workflow.
76
+
34
77
  ```
35
78
  you: "Add a logout button to the settings screen"
36
79
  agent: *writes the Swift*
@@ -38,7 +81,7 @@ agent: *tapp: builds, opens the app, navigates to Settings, screenshots it*
38
81
  agent: "Done — and here it is working on the simulator: [screenshot]"
39
82
  ```
40
83
 
41
- ## Quickstart for coding agents
84
+ ## npm CLI quickstart
42
85
 
43
86
  Requirements: **Node ≥ 18**. iOS needs **macOS + Xcode**; Android needs `adb` plus a connected
44
87
  emulator/device; web needs Playwright + Chromium.
@@ -109,7 +152,9 @@ npx -y @aarwitz/tapp build [dir] # just build + install (scheme auto-detecte
109
152
  ```
110
153
 
111
154
  Web (beta): `npx -y @aarwitz/tapp explore http://localhost:3000` *(one-time setup:
112
- `npm i -g playwright && npx playwright install chromium`)*
155
+ `npm i -g playwright && npx playwright install chromium`)*. Add `--watch` to open Tapp's controlled,
156
+ isolated Chromium window and follow its clicks with an on-page pointer/action label. Tapp hides that
157
+ watch UI from saved evidence screenshots and does not automate your personal/default browser profile.
113
158
 
114
159
  Focused web inspection waits briefly for loading states to settle. If a consent or location modal
115
160
  blocks the screen, dismiss it and wait for the content you care about in the same package-only call:
@@ -194,7 +239,9 @@ Then ask your agent:
194
239
  | 📱 | `tapp_list_simulators` / `boot_simulator` / `install_app` | Simulator + app management. |
195
240
  | 🩺 | `tapp_health`, `tapp_capture*`, `tapp_parse_markers` | Diagnostics and capture history. |
196
241
 
197
- Full agent playbook: [AGENTS.md](./AGENTS.md) ships inside the package so agents can read it too.
242
+ Canonical installable skill: [`skills/tapp/SKILL.md`](skills/tapp/SKILL.md). Full low-level tool
243
+ reference: [AGENTS.md](./AGENTS.md). Both ship inside the npm package; installing the Claude plugin
244
+ or Agent Skill is what makes the workflow discoverable without pasting instructions.
198
245
  Application-model and import contract: [`docs/application-model.md`](docs/application-model.md).
199
246
  The desktop Coverage view reads the same `.tapp/application-model.json`,
200
247
  `.tapp/release-plan.json`, and `.tapp/ui-map.json`, including explicit proposal review; it
package/bin/tapp.js CHANGED
@@ -240,8 +240,8 @@ async function resolveTargetOrExit(engine, input) {
240
240
 
241
241
  function safeCommandUsage(verb) {
242
242
  const usage = {
243
- explore: "tapp explore [target] [--platform ios|android|web] [--actions N] [--timeout SEC] [--email VALUE] [--password VALUE] [--baseline FILE] [--json FILE]\n iOS launch configuration: [--launch-arg VALUE ...] [--launch-env '{\"KEY\":\"VALUE\"}']\n Android: [--app-id ID] [--apk FILE] [--serial ID] [--keep-data]",
244
- init: "tapp init [repo] [--explore] [--refresh] [--platform PLATFORM] [--target NAME] [--url URL] [--dry-run]",
243
+ explore: "tapp explore [target] [--platform ios|android|web] [--actions N] [--timeout SEC] [--email VALUE] [--password VALUE] [--baseline FILE] [--json FILE]\n Web: [--watch] opens Tapp's controlled browser and shows its actions\n iOS launch configuration: [--launch-arg VALUE ...] [--launch-env '{\"KEY\":\"VALUE\"}']\n Android: [--app-id ID] [--apk FILE] [--serial ID] [--keep-data]",
244
+ init: "tapp init [repo] [--explore] [--refresh] [--platform PLATFORM] [--target NAME] [--url URL] [--watch] [--dry-run]",
245
245
  open: "tapp open [target] [--platform ios|android|web] [--out FILE] [--tap TEXT] [--wait-for TEXT]",
246
246
  tree: "tapp tree [target] [--platform ios|android|web] [--json] [--tap TEXT] [--wait-for TEXT]",
247
247
  shot: "tapp shot [--out FILE]",
@@ -346,6 +346,7 @@ switch (command) {
346
346
  timeout,
347
347
  testEmail: typeof flags.email === "string" ? flags.email : undefined,
348
348
  testPassword: typeof flags.password === "string" ? flags.password : undefined,
349
+ watch: flags.watch === true,
349
350
  runExploration: engine?.runInitExploration,
350
351
  onProgress: (progress) => {
351
352
  const activePlatform = progress.platform || platform;
@@ -557,6 +558,7 @@ switch (command) {
557
558
  testPassword: flags.password,
558
559
  ...launchOptions,
559
560
  baselineFindings,
561
+ watch: flags.watch === true,
560
562
  surface: "cli",
561
563
  onProgress,
562
564
  onStatus: (t) => console.error(`ℹ️ ${t}`),
@@ -577,6 +579,10 @@ switch (command) {
577
579
  process.exit(2);
578
580
  }
579
581
  if (platform === "ios") requireMacFor("iOS testing");
582
+ if (flags.watch === true && platform !== "web") {
583
+ console.error("❌ --watch is currently available for web exploration only");
584
+ process.exit(2);
585
+ }
580
586
  if (platform !== "ios" && Object.keys(launchOptions).length) {
581
587
  console.error("❌ --launch-arg and --launch-env apply only to iOS targets");
582
588
  process.exit(2);
@@ -598,6 +604,7 @@ switch (command) {
598
604
  testEmail: flags.email,
599
605
  testPassword: flags.password,
600
606
  baselineFindings,
607
+ watch: flags.watch === true,
601
608
  surface: "cli",
602
609
  onProgress,
603
610
  })
@@ -1569,7 +1576,7 @@ switch (command) {
1569
1576
  Core — explore, prove, gate (agents and humans can just run these — no server, no setup):
1570
1577
  tapp explore [target] Autonomous exploration → findings + evidence (an observation, NOT a
1571
1578
  release decision — run 'tapp ci' to gate a merge)
1572
- (--platform ios|android|web · --app-id ID · --apk FILE · --actions N)
1579
+ (web: --watch · all: --platform ios|android|web · --actions N)
1573
1580
  tapp contract run FILE Replay a business-level release contract — the guarantees that must hold
1574
1581
  tapp ci ... Merge-blocking release gate — explore + suites + baseline → pass/fail/inconclusive
1575
1582
  (see: tapp ci --help)
@@ -1629,6 +1636,11 @@ Setup:
1629
1636
  tapp doctor Check Xcode / simulators / toolchain
1630
1637
  tapp mcp Start the MCP server on stdio (adds inline screenshots + interactive sessions)
1631
1638
 
1639
+ Agent Skill (optional — so a short “Use Tapp to test this app” prompt is enough):
1640
+ Any supported agent: npx -y skills add aarwitz/tapp --skill tapp
1641
+ Claude skill + MCP: claude plugin marketplace add aarwitz/tapp
1642
+ claude plugin install tapp@tapp
1643
+
1632
1644
  MCP hookup (optional — for inline screenshots and the tap/type/inspect session loop):
1633
1645
  Claude Code: claude mcp add tapp -- npx -y @aarwitz/tapp mcp
1634
1646
  Cursor/VS Code (mcp.json):
@@ -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
  }
@@ -23,6 +23,7 @@ import { execFileSync } from "child_process";
23
23
  const CLICK_SETTLE_MS = 700;
24
24
  const NAV_TIMEOUT_MS = 15_000;
25
25
  const BUTTONS_PER_PAGE = 4;
26
+ const WATCH_ACTION_DELAY_MS = 350;
26
27
  const ERROR_TEXT_RE = /\b(something went wrong|internal server error|an error occurred|failed to load|unhandled exception)\b/i;
27
28
  const STANDALONE_ERROR_TEXT_RE = /^(something went wrong|internal server error|an error occurred|failed to load|unhandled exception)(?:[.!:]|\s|$)/i;
28
29
 
@@ -249,13 +250,14 @@ export async function loadPlaywright() {
249
250
  );
250
251
  }
251
252
 
252
- export async function submitWebLogin(page) {
253
+ export async function submitWebLogin(page, beforeClick = null) {
253
254
  const candidates = [
254
255
  page.locator("button[type=submit], input[type=submit], form button").first(),
255
256
  page.getByRole("button", { name: /sign ?in|log ?in|continue/i }).first(),
256
257
  ];
257
258
  for (const candidate of candidates) {
258
259
  if (await candidate.isVisible().catch(() => false)) {
260
+ if (beforeClick) await beforeClick(candidate);
259
261
  await candidate.click({ timeout: 3000 });
260
262
  return true;
261
263
  }
@@ -279,13 +281,14 @@ export function webTransitionOrigin(pendingNavigation, currentScreen) {
279
281
  return pendingNavigation?.fromScreen || currentScreen || null;
280
282
  }
281
283
 
282
- export function webBrowserLaunchOptions(environment = process.env) {
284
+ export function webBrowserLaunchOptions(environment = process.env, { watch = false } = {}) {
283
285
  const browserProxy = String(environment.TAPP_BROWSER_PROXY_SERVER || "").trim();
284
286
  if (environment.TAPP_ENFORCE_PUBLIC_EGRESS === "1" && !/^http:\/\/127\.0\.0\.1:\d+$/.test(browserProxy)) {
285
287
  throw new Error("public egress policy proxy is required");
286
288
  }
287
289
  return {
288
- headless: true,
290
+ headless: !watch,
291
+ ...(watch ? { slowMo: 200 } : {}),
289
292
  ...(browserProxy ? { proxy: { server: browserProxy, bypass: "<-loopback>" } } : {}),
290
293
  args: browserProxy ? [
291
294
  "--disable-quic",
@@ -296,6 +299,102 @@ export function webBrowserLaunchOptions(environment = process.env) {
296
299
  };
297
300
  }
298
301
 
302
+ // A headed Playwright browser does not move the host OS pointer when locator.click() runs. In
303
+ // explicit watch mode, draw a pointer inside the controlled page so a human can follow Tapp's
304
+ // real actions. The UI lives in a closed shadow root, ignores pointer events, and is hidden from
305
+ // evidence screenshots; it therefore cannot become an app control or alter detector input.
306
+ async function installWebWatchUi(context) {
307
+ await context.addInitScript(() => {
308
+ const stateKey = Symbol.for("tapp.watchUi");
309
+ const ensure = () => {
310
+ if (window[stateKey]?.host?.isConnected) return window[stateKey];
311
+ const host = document.createElement("div");
312
+ host.setAttribute("data-tapp-watch-ui", "");
313
+ host.setAttribute("aria-hidden", "true");
314
+ Object.assign(host.style, {
315
+ position: "fixed",
316
+ inset: "0",
317
+ zIndex: "2147483647",
318
+ pointerEvents: "none",
319
+ });
320
+ const shadow = host.attachShadow({ mode: "closed" });
321
+ const style = document.createElement("style");
322
+ style.textContent = `
323
+ .cursor { position: fixed; left: 24px; top: 72px; width: 22px; height: 28px;
324
+ filter: drop-shadow(0 2px 2px rgba(0,0,0,.45)); transition: left 260ms ease, top 260ms ease;
325
+ transform: rotate(-8deg); }
326
+ .cursor::before { content: ""; display: block; width: 100%; height: 100%; background: #111827;
327
+ clip-path: polygon(0 0, 0 88%, 25% 67%, 39% 100%, 53% 93%, 39% 61%, 70% 61%); }
328
+ .cursor::after { content: ""; position: absolute; inset: 2px 3px 4px 2px; background: white;
329
+ clip-path: polygon(0 0, 0 79%, 25% 59%, 40% 91%, 46% 88%, 32% 55%, 60% 55%); }
330
+ .hud { position: fixed; top: 14px; right: 14px; max-width: min(420px, calc(100vw - 28px));
331
+ box-sizing: border-box; padding: 9px 12px; border-radius: 10px; color: white;
332
+ background: rgba(17,24,39,.92); box-shadow: 0 5px 18px rgba(0,0,0,.24);
333
+ font: 600 13px/1.35 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
334
+ .brand { color: #93c5fd; margin-right: 6px; }
335
+ `;
336
+ const cursor = document.createElement("div");
337
+ cursor.className = "cursor";
338
+ const hud = document.createElement("div");
339
+ hud.className = "hud";
340
+ shadow.append(style, cursor, hud);
341
+ (document.documentElement || document).appendChild(host);
342
+ const state = { host, cursor, hud };
343
+ Object.defineProperty(window, stateKey, { value: state, configurable: true });
344
+ return state;
345
+ };
346
+ Object.defineProperty(window, "__tappShowWatchAction", {
347
+ configurable: true,
348
+ value: ({ x, y, action, target }) => {
349
+ const state = ensure();
350
+ state.host.style.display = "block";
351
+ if (Number.isFinite(x) && Number.isFinite(y)) {
352
+ state.cursor.style.left = `${Math.max(4, Math.min(window.innerWidth - 26, x))}px`;
353
+ state.cursor.style.top = `${Math.max(4, Math.min(window.innerHeight - 32, y))}px`;
354
+ }
355
+ state.hud.replaceChildren();
356
+ const brand = document.createElement("span");
357
+ brand.className = "brand";
358
+ brand.textContent = "Tapp";
359
+ state.hud.append(brand, document.createTextNode(`${action}${target ? ` · ${target}` : ""}`));
360
+ },
361
+ });
362
+ Object.defineProperty(window, "__tappSetWatchUiVisible", {
363
+ configurable: true,
364
+ value: (visible) => {
365
+ if (window[stateKey]?.host) window[stateKey].host.style.display = visible ? "block" : "none";
366
+ },
367
+ });
368
+ });
369
+ }
370
+
371
+ async function showWebWatchAction(page, { locator = null, action = "Exploring", target = "" } = {}) {
372
+ let x = 28;
373
+ let y = 76;
374
+ if (locator) {
375
+ await locator.scrollIntoViewIfNeeded().catch(() => {});
376
+ const box = await locator.boundingBox().catch(() => null);
377
+ if (box) {
378
+ x = box.x + box.width / 2;
379
+ y = box.y + box.height / 2;
380
+ }
381
+ }
382
+ await page.evaluate(({ x, y, action, target }) => {
383
+ window.__tappShowWatchAction?.({ x, y, action, target });
384
+ }, { x, y, action, target }).catch(() => {});
385
+ await page.waitForTimeout(WATCH_ACTION_DELAY_MS).catch(() => {});
386
+ }
387
+
388
+ async function screenshotWithoutWebWatchUi(page, options, watch) {
389
+ if (!watch) return page.screenshot(options);
390
+ await page.evaluate(() => window.__tappSetWatchUiVisible?.(false)).catch(() => {});
391
+ try {
392
+ return await page.screenshot(options);
393
+ } finally {
394
+ await page.evaluate(() => window.__tappSetWatchUiVisible?.(true)).catch(() => {});
395
+ }
396
+ }
397
+
299
398
  // Focused one-screen inspection for the agent-facing `tapp open <url>` and `tapp tree <url>`
300
399
  // commands. This deliberately does no exploration or judgment; it opens exactly one page,
301
400
  // captures the visible semantic controls, and optionally takes one screenshot.
@@ -425,7 +524,7 @@ export function normalizeWebSeedTargets(seedTargets = [], limit = 5) {
425
524
  return result;
426
525
  }
427
526
 
428
- export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDir, testEmail = "", testPassword = "", seedRoutes = [], seedTargets = [], onProgress }) {
527
+ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDir, testEmail = "", testPassword = "", seedRoutes = [], seedTargets = [], watch = false, onProgress }) {
429
528
  const start = new URL(url);
430
529
  if (!/^https?:$/.test(start.protocol)) throw new Error("url must be http(s)");
431
530
  fs.mkdirSync(outDir, { recursive: true });
@@ -434,9 +533,10 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
434
533
  const emit = (kind, payload) => fs.writeSync(markersFd, `OCQA_${kind}:${JSON.stringify(payload)}\n`);
435
534
 
436
535
  const { chromium } = await loadPlaywright();
437
- const browser = await chromium.launch(webBrowserLaunchOptions());
536
+ const browser = await chromium.launch(webBrowserLaunchOptions(process.env, { watch }));
438
537
  const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
439
538
  await installWebListenerTracking(context);
539
+ if (watch) await installWebWatchUi(context);
440
540
  const page = await context.newPage();
441
541
  page.setDefaultTimeout(NAV_TIMEOUT_MS);
442
542
 
@@ -604,7 +704,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
604
704
  if (existingKey && existingKey !== evidenceKey) screenshotFor.delete(existingKey);
605
705
  screenshotFor.set(evidenceKey, { path: screenshotPath, busy: info.busy, route: key });
606
706
  screenCount = screenshotFor.size;
607
- await page.screenshot({ path: screenshotPath }).catch(() => {});
707
+ await screenshotWithoutWebWatchUi(page, { path: screenshotPath }, watch).catch(() => {});
608
708
  // Deterministic per-page detectors run once per distinct screen.
609
709
  if (webPageAppearsBlank(info)) issue("blank_screen", "high", "Page rendered no visible content", screen);
610
710
  else {
@@ -628,12 +728,19 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
628
728
  if (!(await pw.isVisible().catch(() => false))) return null;
629
729
  loginTried = true;
630
730
  const emailSel = "input[type=email], input[name*=mail i], input[name*=user i], input[id*=mail i], input[id*=user i]";
631
- if (testEmail) await page.locator(emailSel).first().fill(testEmail).catch(() => {});
731
+ if (testEmail) {
732
+ const email = page.locator(emailSel).first();
733
+ if (watch) await showWebWatchAction(page, { locator: email, action: "Type", target: "Email" });
734
+ await email.fill(testEmail).catch(() => {});
735
+ }
736
+ if (watch) await showWebWatchAction(page, { locator: pw, action: "Type", target: "Password" });
632
737
  await pw.fill(testPassword).catch(() => {});
633
738
  lastActionTarget = "Sign in";
634
739
  emit("ACTION", { type: "login", target: "Sign in", screen, narrative: "Filled and submitted the sign-in form with the provided test credentials" });
635
740
  actions += 1;
636
- await submitWebLogin(page).catch(() => false);
741
+ await submitWebLogin(page, watch
742
+ ? (locator) => showWebWatchAction(page, { locator, action: "Click", target: "Sign in" })
743
+ : null).catch(() => false);
637
744
  await waitForWebStability(page, { timeoutMs: Math.min(5_000, CLICK_SETTLE_MS * 6) });
638
745
  // Still on the login form after a submit = the sign-in failed — full stop. (A quiet
639
746
  // credential rejection often shows NO other symptom, so this must not be coupled to
@@ -688,6 +795,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
688
795
  progress();
689
796
  continue;
690
797
  }
798
+ if (watch) await showWebWatchAction(page, { action: "Open", target });
691
799
  await waitForWebStability(page);
692
800
  if (nav && typeof nav.status === "function" && nav.status() === 404) {
693
801
  issue("broken_link", "medium", `Broken link: ${target} → 404`, target);
@@ -708,6 +816,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
708
816
  emit("ACTION", { type: action.type, target: action.target, screen: beforeScreen, reason: "pr_ui_map_path", narrative: `Following observed UI Map path: ${action.type} ${action.target}` });
709
817
  let acted = false;
710
818
  if (action.type === "back") {
819
+ if (watch) await showWebWatchAction(page, { action: "Back", target: beforeScreen });
711
820
  await page.goBack({ waitUntil: "domcontentloaded", timeout: step.wait?.timeoutMs || NAV_TIMEOUT_MS }).catch(() => {});
712
821
  acted = true;
713
822
  } else {
@@ -721,6 +830,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
721
830
  else continue;
722
831
  if (await locator.isVisible().catch(() => false)) {
723
832
  try {
833
+ if (watch) await showWebWatchAction(page, { locator, action: "Click", target: action.target });
724
834
  await locator.click({ timeout: Math.min(step.wait?.timeoutMs || NAV_TIMEOUT_MS, NAV_TIMEOUT_MS) });
725
835
  acted = true;
726
836
  break;
@@ -785,6 +895,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
785
895
  emit("ACTION", { type: "tap", target: label, screen: webActionScreen(ob), narrative: `Tapped "${label}"` });
786
896
  let clickSucceeded = false;
787
897
  try {
898
+ if (watch) await showWebWatchAction(page, { locator: b, action: "Click", target: label });
788
899
  await b.click({ timeout: 3000 });
789
900
  clickSucceeded = true;
790
901
  } catch {}
@@ -795,6 +906,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
795
906
  await waitForWebStability(page);
796
907
  if (page.url() !== beforeState.url) {
797
908
  await observe();
909
+ if (watch) await showWebWatchAction(page, { action: "Back", target: ob.screen });
798
910
  await page.goBack({ waitUntil: "domcontentloaded" }).catch(() => {});
799
911
  await waitForWebStability(page);
800
912
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aarwitz/tapp",
3
- "version": "0.17.0-rc.11",
3
+ "version": "0.17.0-rc.12",
4
4
  "mcpName": "io.github.aarwitz/tapp",
5
5
  "description": "Release contracts, autonomous QA, and evidence-backed CI gates for iOS, Android, and web.",
6
6
  "license": "MIT",
@@ -41,6 +41,8 @@
41
41
  "Harness/OCQAHarnessUITests/",
42
42
  "Harness/OCQAHarness.xcodeproj/",
43
43
  "Harness/generate-harness-xcodeproj.rb",
44
+ "skills/",
45
+ ".claude-plugin/",
44
46
  "AGENTS.md"
45
47
  ],
46
48
  "dependencies": {
@@ -87,7 +89,7 @@
87
89
  "mobile"
88
90
  ],
89
91
  "scripts": {
90
- "test": "node --test tests/report.test.js tests/regression.test.js tests/engine.test.js tests/project-config.test.js tests/application-model.test.js tests/ui-map.test.js tests/task-runtime.test.js tests/release-contract.test.js tests/pr-selection.test.js tests/flow-runtime.test.js tests/web-explorer.test.js tests/web-session.test.js tests/web-flow.test.js tests/scenario-runtime.test.js tests/android-driver.test.js tests/android-explorer.test.js tests/android-flow.test.js tests/android-primitives-protocol.test.js tests/managed-web.test.js tests/product-operations.test.js tests/browser-product.test.js tests/browser-onboarding.test.js tests/managed-operation.test.js tests/cloud-runner.test.js tests/ci-setup.test.js tests/ci-install.test.js tests/cli.test.js tests/action.test.js tests/package-surface.test.js tests/landing-brand.test.js tests/ci-gate.test.js tests/ci-report.test.js tests/desktop-protocol.test.js tests/ios-flow-protocol.test.js",
92
+ "test": "node --test tests/report.test.js tests/regression.test.js tests/engine.test.js tests/project-config.test.js tests/application-model.test.js tests/ui-map.test.js tests/task-runtime.test.js tests/release-contract.test.js tests/pr-selection.test.js tests/flow-runtime.test.js tests/web-explorer.test.js tests/web-session.test.js tests/web-flow.test.js tests/scenario-runtime.test.js tests/android-driver.test.js tests/android-explorer.test.js tests/android-flow.test.js tests/android-primitives-protocol.test.js tests/managed-web.test.js tests/product-operations.test.js tests/browser-product.test.js tests/browser-onboarding.test.js tests/managed-operation.test.js tests/cloud-runner.test.js tests/ci-setup.test.js tests/ci-install.test.js tests/cli.test.js tests/mcp-workspace.test.js tests/action.test.js tests/package-surface.test.js tests/agent-surface.test.js tests/landing-brand.test.js tests/ci-gate.test.js tests/ci-report.test.js tests/desktop-protocol.test.js tests/ios-flow-protocol.test.js vscode-extension/test/bridge.test.js",
91
93
  "test:browser-journey": "node --test tests/browser-journey.test.js",
92
94
  "test:browser-native": "TAPP_RUN_NATIVE_BROWSER=1 node --test tests/browser-native-journey.test.js"
93
95
  }
@@ -0,0 +1,74 @@
1
+ ---
2
+ name: tapp
3
+ description: Use Tapp to see, drive, explore, and verify real application surfaces on iOS simulators, Android emulators/devices, or the web. Use when a user asks an agent to test an app or UI change, find bugs, inspect or screenshot a screen, exercise a journey, create a replayable flow, gather release evidence, or run the deterministic Tapp gate. Also use when the user mentions Tapp, @aarwitz/tapp, tapp_* tools, .tapp artifacts, or asks whether agent-authored UI actually works.
4
+ ---
5
+
6
+ # Tapp
7
+
8
+ Use Tapp as the app's hands and eyes. Work on the real UI surface and show evidence; do not claim a
9
+ screen or journey works from source inspection alone.
10
+
11
+ ## Choose the smallest operation
12
+
13
+ | Intent | Operation |
14
+ |---|---|
15
+ | See or screenshot one screen | `open` / `tapp_open_app` |
16
+ | Inspect controls on the current screen | `tree` / `tapp_ui_tree` |
17
+ | Drive a specific journey | MCP session start → act → end |
18
+ | Find bugs autonomously | `explore` / `tapp_explore` |
19
+ | Preserve a journey | record and save a Flow; replay it deterministically |
20
+ | Decide whether a merge passes policy | `ci`; exploration never decides this |
21
+
22
+ Prefer connected `tapp_*` MCP tools when available: they keep interactive sessions alive and return
23
+ screenshots inline. Otherwise run `npx -y @aarwitz/tapp` from the app repository. Do not require MCP,
24
+ an account, an API key, or a global install for the core workflow.
25
+
26
+ ## Start source-connected
27
+
28
+ When the user asks for a general first test of a repository:
29
+
30
+ 1. If `.tapp/application-model.json` exists, run `npx -y @aarwitz/tapp explore`.
31
+ 2. Otherwise run `npx -y @aarwitz/tapp init . --explore` or call `tapp_init` with
32
+ `{operation:"explore", projectDir:"."}`.
33
+ 3. If Tapp returns `target-selection-required`, present its actual choices and ask the user to pick.
34
+ Never guess among multiple targets. Re-run with the selected platform/target exactly as Tapp
35
+ instructs.
36
+ 4. If a prerequisite is missing, run `tapp doctor`, apply only the stated remediation that is in
37
+ scope, and retry once.
38
+
39
+ For a focused request, use the requested target directly rather than forcing repository onboarding.
40
+ Targets may be a repository path, Xcode container, `.app`, iOS bundle id, APK plus Android app id,
41
+ or owned HTTP(S) URL. Never explore a third-party web property without authorization: exploration
42
+ clicks and types.
43
+
44
+ ## Observe honestly
45
+
46
+ Exploration returns findings, coverage, evidence, and `inconclusive`; it does not return a score or
47
+ ship verdict. Report:
48
+
49
+ - target and platform;
50
+ - screens/actions and whether coverage was conclusive;
51
+ - deterministic versus advisory finding counts;
52
+ - each important finding and its evidence/report path;
53
+ - what Tapp explicitly did not check.
54
+
55
+ If `inconclusive: true`, explain the blocker. A login wall or missing test data is not a pass. Ask for
56
+ credentials or launch configuration instead of rerunning blindly. Do not infer content accuracy,
57
+ privacy, brand consistency, or business guarantees from a generic crawl; those require a reviewed
58
+ Flow, Scenario, contract, verifier, or human review.
59
+
60
+ When a screenshot path is printed, open it with the client's image-reading tool before describing
61
+ the screen. For web, use `--watch` when the human wants to follow Tapp's controlled browser. For iOS,
62
+ point the human to the report's exploration recording when available.
63
+
64
+ ## Drive safely
65
+
66
+ For an interactive MCP session, read returned `elements[]` before every action, target accessibility
67
+ ids or visible labels, check `hittable`, tap a field before typing, and wait for navigation or async
68
+ content. Use coordinates only as a last resort. End the session when finished.
69
+
70
+ Do not edit the app merely because testing found a defect unless the user also asked for a fix. State
71
+ what the evidence proves and what remains untested.
72
+
73
+ Read [references/commands.md](references/commands.md) only when exact CLI/MCP syntax, Flow replay,
74
+ credentials, or platform prerequisites are needed.
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "Tapp"
3
+ short_description: "Test real iOS, Android, and web apps"
4
+ default_prompt: "Use $tapp to test this app on its real UI surface and report evidence-backed findings."
@@ -0,0 +1,102 @@
1
+ # Tapp command reference
2
+
3
+ ## Plain CLI
4
+
5
+ Run from the application repository. `[target]` is optional when Tapp can read the repository model
6
+ or detect one unambiguous target.
7
+
8
+ ```bash
9
+ npx -y @aarwitz/tapp init . --explore
10
+ npx -y @aarwitz/tapp explore [target]
11
+ npx -y @aarwitz/tapp open [target]
12
+ npx -y @aarwitz/tapp tree [target] --json
13
+ npx -y @aarwitz/tapp shot
14
+ npx -y @aarwitz/tapp report latest
15
+ npx -y @aarwitz/tapp doctor
16
+ ```
17
+
18
+ Platform examples:
19
+
20
+ ```bash
21
+ # Web: Tapp may build/start/stop a source target; --watch is human-visible.
22
+ npx -y @aarwitz/tapp explore --platform web --target website --watch
23
+ npx -y @aarwitz/tapp explore https://staging.example.com
24
+
25
+ # iOS: source repo, .app, or bundle id.
26
+ npx -y @aarwitz/tapp explore MyApp.xcodeproj --platform ios
27
+ npx -y @aarwitz/tapp open com.example.MyApp --platform ios
28
+
29
+ # Android: app id is required; APK is optional if already installed.
30
+ npx -y @aarwitz/tapp explore app-debug.apk --platform android --app-id com.example.app
31
+ ```
32
+
33
+ Focused web evidence can perform one semantic interaction and wait for async content:
34
+
35
+ ```bash
36
+ npx -y @aarwitz/tapp open https://example.com --tap "Not now" --wait-for "Dashboard"
37
+ ```
38
+
39
+ ## MCP mapping
40
+
41
+ - `tapp_init`: inspect or initialize a source repository; `operation:"explore"` prepares and explores.
42
+ - `tapp_build`: build and install an iOS app without needing its bundle id first.
43
+ - `tapp_open_app`: launch and return a screen summary plus inline screenshot.
44
+ - `tapp_ui_tree` / `tapp_screenshot`: inspect the current real surface.
45
+ - `tapp_session_start` → `tapp_session_act` → `tapp_session_end`: drive one persistent journey.
46
+ - `tapp_explore`: autonomous iOS, Android, or web exploration; observation only.
47
+ - `tapp_flow_save` / `tapp_flow_run`: save a driven journey and replay it deterministically.
48
+ - `tapp_release_contract`: validate, compile, or run a reviewed business guarantee.
49
+ - `tapp_ci_setup`: create a target-scoped baseline or reviewable CI installation.
50
+
51
+ An iOS no-bundle-id MCP path is `tapp_build {projectDir:"."}` followed by `tapp_explore` with the
52
+ returned `bundleId`. Android uses `androidAppId` and optional `apkPath`; web uses `url` or
53
+ source-connected `tapp_init`.
54
+
55
+ ## Interactive session loop
56
+
57
+ ```text
58
+ tapp_session_start {appBundleId:"com.example.app"}
59
+ tapp_session_act {action:"tap", id:"Email"}
60
+ tapp_session_act {action:"type", text:"qa@example.com"}
61
+ tapp_session_act {action:"tap", id:"Password"}
62
+ tapp_session_act {action:"type", text:"..."}
63
+ tapp_session_act {action:"tap", id:"Sign In"}
64
+ tapp_session_act {action:"wait", text:"Home", timeoutMs:10000}
65
+ tapp_screenshot
66
+ tapp_session_end
67
+ ```
68
+
69
+ Android sessions use `androidAppId`, optional `apkPath`, and the same action loop.
70
+
71
+ ## Credentials and test configuration
72
+
73
+ For autonomous exploration, pass test-only values when authorized:
74
+
75
+ - CLI: `--email`, `--password`, repeated `--launch-arg`, and JSON `--launch-env`.
76
+ - MCP: `testEmail`, `testPassword`, `inputOverrides`, `appLaunchArgs`, `appLaunchEnv`, or explicit
77
+ `loginSteps`.
78
+
79
+ Do not persist secrets in `.tapp/`. If the result reports input fields and no values were supplied,
80
+ ask the user rather than pretending the explored surface was complete.
81
+
82
+ ## Replay and gating
83
+
84
+ Flow YAML belongs under `.tapp/flows/` and can replay without a model or API key:
85
+
86
+ ```bash
87
+ npx -y @aarwitz/tapp flow run .tapp/flows/smoke.yml
88
+ npx -y @aarwitz/tapp ci
89
+ ```
90
+
91
+ `tapp explore` observes. `tapp ci` applies versioned deterministic policy to evidence, selected
92
+ Flows/Scenarios/contracts, coverage, and any target-scoped baseline. Its outcomes are `pass`, `fail`,
93
+ or `inconclusive`; both `fail` and `inconclusive` block a merge.
94
+
95
+ ## Platform prerequisites
96
+
97
+ - iOS: macOS, Xcode, and a booted simulator. First use builds a cached harness under `~/.tapp`.
98
+ - Android: `adb` and a connected authorized emulator/device.
99
+ - Web: Playwright and Chromium. If Tapp reports the browser missing, run
100
+ `npx playwright install chromium` and retry.
101
+
102
+ Captures and screenshots are stored under `~/.tapp/captures/` and `~/.tapp/shots/`.