@aarwitz/tapp 0.16.5 → 0.17.0-rc.10

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.
@@ -12,7 +12,7 @@ import {
12
12
  ListToolsRequestSchema,
13
13
  } from "@modelcontextprotocol/sdk/types.js";
14
14
 
15
- import { parseOcqaMarkers, buildQaReport, computeRegression, qaScoreLabel, verdictBadge } from "./report.js";
15
+ import { parseOcqaMarkers, buildQaReport, computeRegression, observationBadge, observationSummary } from "./report.js";
16
16
  import { existingProjectArtifactPath, projectArtifactDirectory } from "./project-paths.js";
17
17
 
18
18
  const __filename = fileURLToPath(import.meta.url);
@@ -21,10 +21,10 @@ const repoRoot = path.resolve(__dirname, "../..");
21
21
  const scriptsDir = path.join(repoRoot, "scripts");
22
22
  // TAPP_HOME (set by the `tapp` CLI when installed) redirects writable output to a user directory.
23
23
  // The old alias remains a read-only fallback; unset repository development stays local.
24
- const tappHome = (process.env.TAPP_HOME || process.env.AUTOTAP_HOME || "").trim();
24
+ const tappHome = (process.env.TAPP_HOME || "").trim();
25
25
  const capturesDir = tappHome ? path.join(tappHome, "captures") : path.join(repoRoot, "captures");
26
26
  const MAX_OUTPUT_CHARS = 60_000;
27
- const requiredAuthToken = (process.env.TAPP_MCP_TOKEN || process.env.AUTOTAP_MCP_TOKEN || "").trim();
27
+ const requiredAuthToken = (process.env.TAPP_MCP_TOKEN || "").trim();
28
28
 
29
29
  function clampOutput(value, maxChars = MAX_OUTPUT_CHARS) {
30
30
  if (typeof value !== "string") {
@@ -35,8 +35,13 @@ function clampOutput(value, maxChars = MAX_OUTPUT_CHARS) {
35
35
  return value;
36
36
  }
37
37
 
38
- const dropped = value.length - maxChars;
39
- return `${value.slice(0, maxChars)}\n...[truncated ${dropped} chars]`;
38
+ // Compiler/build diagnostics are normally emitted at the end. Preserve both ends so a long
39
+ // dependency build cannot truncate away the one actionable error the user needs.
40
+ const marker = "\n...[output truncated]...\n";
41
+ const retained = Math.max(0, maxChars - marker.length);
42
+ const head = Math.ceil(retained / 2);
43
+ const tail = Math.floor(retained / 2);
44
+ return `${value.slice(0, head)}${marker}${value.slice(value.length - tail)}`;
40
45
  }
41
46
 
42
47
  function asBoolean(value, fallback = false) {
@@ -984,7 +989,10 @@ async function runExploreStreaming(bundleId, actions, timeout, env, onProgress)
984
989
  const before = new Set(listCaptureRuns(80).map((r) => r.id));
985
990
  const proc = spawn("bash", cmdArgs, { cwd: repoRoot, env: { ...process.env, ...env } });
986
991
  proc.stdout.on("data", () => {});
987
- proc.stderr.on("data", () => {});
992
+ let captureStderr = "";
993
+ proc.stderr.on("data", (chunk) => {
994
+ captureStderr = (captureStderr + chunk.toString("utf8")).slice(-8_000);
995
+ });
988
996
  const closed = new Promise((res) => proc.on("close", (code) => res(code ?? 1)));
989
997
 
990
998
  let captureDir = null;
@@ -1037,7 +1045,18 @@ async function runExploreStreaming(bundleId, actions, timeout, env, onProgress)
1037
1045
  await closed;
1038
1046
  const created = listCaptureRuns(80).find((r) => !before.has(r.id))
1039
1047
  || (captureDir ? { id: path.basename(captureDir), path: captureDir, relativePath: path.relative(repoRoot, captureDir) } : null);
1040
- return { created, timedOut };
1048
+ return { created, timedOut, captureStderr };
1049
+ }
1050
+
1051
+ export function recordingUnavailableReason(stderr = "") {
1052
+ const text = String(stderr);
1053
+ if (/resource busy|host recording is already in progress/i.test(text)) {
1054
+ return "the simulator recorder is busy with another host recording";
1055
+ }
1056
+ if (/could not start simulator video recording/i.test(text)) {
1057
+ return "the simulator could not start video recording";
1058
+ }
1059
+ return "the simulator did not produce a recording";
1041
1060
  }
1042
1061
 
1043
1062
  // Grab the booted simulator's current screen and return it downscaled + JPEG-compressed so the
@@ -1066,7 +1085,7 @@ export async function captureScreenshotImage(maxWidth) {
1066
1085
  // change data-handling behavior. A subscription token is an explicit tapp choice, and
1067
1086
  // explicitly-invoked AI tools (tapp_flow_generate, assert_ai) carry their own consent.
1068
1087
  export function remoteAiOptedIn(env = process.env) {
1069
- if ((env.TAPP_SUBSCRIPTION_TOKEN || env.AUTOTAP_SUBSCRIPTION_TOKEN || "").trim()) return true;
1088
+ if ((env.TAPP_SUBSCRIPTION_TOKEN || "").trim()) return true;
1070
1089
  return ["1", "true", "yes"].includes(String(env.TAPP_ENABLE_REMOTE_AI || "").trim().toLowerCase());
1071
1090
  }
1072
1091
 
@@ -1078,9 +1097,9 @@ export function isInsideDir(root, p) {
1078
1097
  }
1079
1098
 
1080
1099
  function resolveModelBackend() {
1081
- const token = (process.env.TAPP_SUBSCRIPTION_TOKEN || process.env.AUTOTAP_SUBSCRIPTION_TOKEN || "").trim();
1100
+ const token = (process.env.TAPP_SUBSCRIPTION_TOKEN || "").trim();
1082
1101
  if (token) {
1083
- const base = (process.env.TAPP_PROXY_URL || process.env.AUTOTAP_PROXY_URL || "http://localhost:8787").replace(/\/$/, "");
1102
+ const base = (process.env.TAPP_PROXY_URL || "http://localhost:8787").replace(/\/$/, "");
1084
1103
  const url = base.endsWith("/v1/messages") ? base : base + "/v1/messages";
1085
1104
  return { url, headers: { authorization: `Bearer ${token}`, "content-type": "application/json" } };
1086
1105
  }
@@ -1092,7 +1111,7 @@ function resolveModelBackend() {
1092
1111
  }
1093
1112
 
1094
1113
  async function callModel(backend, { system, userText, model, maxTokens = 1500 }) {
1095
- const body = JSON.stringify({ model: model || process.env.TAPP_FLOW_MODEL || process.env.AUTOTAP_FLOW_MODEL || "claude-sonnet-4-6", max_tokens: maxTokens, system, messages: [{ role: "user", content: userText }] });
1114
+ const body = JSON.stringify({ model: model || process.env.TAPP_FLOW_MODEL || "claude-sonnet-4-6", max_tokens: maxTokens, system, messages: [{ role: "user", content: userText }] });
1096
1115
  const res = await fetch(backend.url, { method: "POST", headers: backend.headers, body });
1097
1116
  if (!res.ok) return { error: `model HTTP ${res.status}: ${(await res.text()).slice(0, 300)}` };
1098
1117
  const data = await res.json();
@@ -1307,7 +1326,7 @@ export function qaNextSteps(report, surface = "mcp") {
1307
1326
  if (surface === "cli") {
1308
1327
  const next = [];
1309
1328
  if (report?.findings?.length) next.push("inspect the evidence with `tapp report latest`");
1310
- next.push("re-run with `--baseline <report.json>` to gate a fix");
1329
+ next.push("re-run with `--baseline <report.json>` to compare a fix (then `tapp ci` to gate it)");
1311
1330
  next.push("replay a committed journey with `tapp flow run <file>`");
1312
1331
  return next;
1313
1332
  }
@@ -1318,25 +1337,27 @@ export function qaNextSteps(report, surface = "mcp") {
1318
1337
  return next;
1319
1338
  }
1320
1339
 
1321
- function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiConfigured, reportHtml, recording, uiMap, surface = "mcp" } = {}) {
1340
+ function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiConfigured, reportHtml, recording, recordingWarning, uiMap, surface = "mcp" } = {}) {
1322
1341
  const c = report.findingCounts || {};
1323
- const badge = verdictBadge(report);
1342
+ const badge = observationBadge(report);
1324
1343
  const sevBits = ["critical", "high", "medium", "low"]
1325
1344
  .map((k) => (c[k] ? `${SEV[k]} ${c[k]} ${k}` : null))
1326
1345
  .filter(Boolean)
1327
1346
  .join(", ");
1328
1347
  const L = [];
1329
- L.push(`### 🧪 QA complete ${badge} · ${qaScoreLabel(report)}${bundleId ? `\n\`${bundleId}\`` : ""}`);
1348
+ // Exploration observes; it does not render a ship verdict. The release decision lives in the gate.
1349
+ L.push(`### 🔭 Exploration complete — ${badge} · ${observationSummary(report)}${bundleId ? `\n\`${bundleId}\`` : ""}`);
1330
1350
  L.push("");
1331
1351
  L.push(report.headline);
1332
1352
  L.push("");
1333
1353
  L.push(`**Coverage** — ${report.screensExplored} screens · ${report.actionsPerformed} actions${timedOut ? " · ⏱️ hit time limit" : ""}`);
1334
1354
  if (report.platform === "web") {
1335
- L.push(`**Verdict basis** — ${report.verdictFindingCounts?.total || 0} deterministic finding(s); ${report.sampledFindingCounts?.total || 0} sampled probe finding(s) are advisory`);
1355
+ L.push(`**Deterministic basis** — ${report.deterministicFindingCounts?.total || 0} deterministic finding(s); ${report.sampledFindingCounts?.total || 0} sampled probe finding(s) are advisory`);
1336
1356
  }
1337
1357
  if (uiMap) L.push(`**UI Map** — ${uiMap.nodeCount} states · ${uiMap.edgeCount} transitions · ${uiMap.controlCount} semantic controls · ${uiMap.path}`);
1338
1358
  if (reportHtml) L.push(`**Evidence** — 📄 ${reportHtml} (screenshots of every screen + findings, shareable)`);
1339
1359
  if (recording) L.push(`**Recording** — 🎬 ${recording} (full exploration, embedded in the evidence page)`);
1360
+ else if (recordingWarning) L.push(`**Recording** — ⚠️ unavailable: ${recordingWarning}; screenshots were still captured`);
1340
1361
  L.push(`**Issues** — ${c.total ? `${c.total}${sevBits ? ` (${sevBits})` : ""}` : "none found ✨"}`);
1341
1362
  if (Array.isArray(report.findings) && report.findings.length) {
1342
1363
  L.push("");
@@ -1355,17 +1376,18 @@ function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiC
1355
1376
  }
1356
1377
  }
1357
1378
  if (regression && regression.counts) {
1358
- const g = regression.gate || {};
1379
+ // Exploration reports the comparison (new/persisting/resolved) only — never a gate pass/fail.
1380
+ // The merge decision is the gate's job (tapp ci), not exploration's (ADR-0005).
1359
1381
  L.push("");
1360
1382
  L.push(
1361
- `**Since last run** — +${regression.counts.new} new · ${regression.counts.persisting} persisting · ${regression.counts.resolved} resolved · gate ${g.failed ? "🔴 FAIL" : "🟢 PASS"}`
1383
+ `**Since last run** — +${regression.counts.new} new · ${regression.counts.persisting} persisting · ${regression.counts.resolved} resolved (comparison only run \`tapp ci\` to gate)`
1362
1384
  );
1363
1385
  }
1364
1386
  if (inputHint) {
1365
1387
  L.push("");
1366
1388
  L.push(`> ℹ️ ${inputHint}`);
1367
1389
  }
1368
- // The honesty label: a "ready" is a claim about exactly these classes, nothing more.
1390
+ // The observation's scope: enumerate exactly what ran and what remains unchecked.
1369
1391
  if (Array.isArray(report.checkedFor) && report.checkedFor.length) {
1370
1392
  L.push("");
1371
1393
  L.push(`> ✅ Checked: ${report.checkedFor.join(" · ")}`);
@@ -1379,11 +1401,10 @@ function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiC
1379
1401
  const next = qaNextSteps(report, surface);
1380
1402
  L.push("");
1381
1403
  L.push(`**Next** — ${next.join(" · ")}`);
1382
- // The gate hook belongs at the moment the user thinks "I want this on every PR"
1383
- // i.e. right after a verdict that found something, or after they hand-diffed a baseline.
1404
+ // The gate hook belongs at the moment the user thinks "I want these checks on every PR".
1384
1405
  if ((report.findings && report.findings.length) || regression) {
1385
1406
  L.push("");
1386
- L.push("> 🚦 Teams: get this verdict on every PR automatically (evidence + regression gate) — https://github.com/aarwitz/tapp#ci-gate");
1407
+ L.push("> 🚦 Teams: run these checks plus reviewed release contracts as a merge gate on every PR — https://github.com/aarwitz/tapp#ci-gate");
1387
1408
  }
1388
1409
  return L.join("\n");
1389
1410
  }
@@ -1464,7 +1485,7 @@ export async function runQaWeb({ url, maxActions, timeout, testEmail, testPasswo
1464
1485
  } catch (err) {
1465
1486
  return { error: String(err.message || err) };
1466
1487
  }
1467
- const report = buildQaReport(webResult.markersPath, { platform: "web" });
1488
+ const report = buildQaReport(webResult.markersPath, { platform: "web", target: url.trim() });
1468
1489
  if (!report) return { error: "Web exploration produced no markers", details: { capture: { id, path: outDir } } };
1469
1490
  const backend = remoteAiOptedIn() ? resolveModelBackend() : null;
1470
1491
  if (backend && report.findings.length) {
@@ -1507,7 +1528,7 @@ export async function runQaAndroid({ appId, apkPath, serial, maxActions, timeout
1507
1528
  } catch (error) {
1508
1529
  return { error: error.message || String(error), details: { capture: { id, path: outDir } } };
1509
1530
  }
1510
- const report = buildQaReport(androidResult.markersPath, { platform: "android" });
1531
+ const report = buildQaReport(androidResult.markersPath, { platform: "android", target: appId.trim() });
1511
1532
  if (!report) return { error: "Android exploration produced no markers", details: { capture: { id, path: outDir } } };
1512
1533
  const backend = remoteAiOptedIn() ? resolveModelBackend() : null;
1513
1534
  if (backend && report.findings.length) {
@@ -1539,10 +1560,10 @@ export async function runQaIos({ bundleId, maxActions, timeout, args = {}, surfa
1539
1560
  const timeoutSec = Math.max(30, Math.min(3600, asInteger(timeout, 600)));
1540
1561
  const env = explorationEnvFromArgs(args);
1541
1562
 
1542
- const { created, timedOut } = await runExploreStreaming(bundleId, actions, timeoutSec, env, onProgress);
1563
+ const { created, timedOut, captureStderr } = await runExploreStreaming(bundleId, actions, timeoutSec, env, onProgress);
1543
1564
  if (!created) return { error: "Exploration produced no capture run", details: { timedOut } };
1544
1565
 
1545
- const report = buildQaReport(path.join(created.path, "ocqa-markers.txt"));
1566
+ const report = buildQaReport(path.join(created.path, "ocqa-markers.txt"), { platform: "ios", target: bundleId });
1546
1567
  if (!report) {
1547
1568
  return {
1548
1569
  error: "No markers parsed from exploration (the app may not have launched)",
@@ -1572,13 +1593,14 @@ export async function runQaIos({ bundleId, maxActions, timeout, args = {}, surfa
1572
1593
  // Cross-run regression vs. a caller-supplied baseline (the CI gate).
1573
1594
  const regression = computeRegression(report.findings, args.baselineFindings);
1574
1595
  const uiMap = await writeRunUiMap({ markersPath: path.join(created.path, "ocqa-markers.txt"), platform: "ios", target: bundleId, runId: created.id, outDir: created.path });
1596
+ const recording =
1597
+ ["exploration.webm", "exploration.mov"].map((f) => path.join(created.path, f)).find((p) => fs.existsSync(p)) || null;
1598
+ const recordingWarning = recording ? null : recordingUnavailableReason(captureStderr);
1575
1599
  let reportHtml = null;
1576
1600
  try {
1577
1601
  const { writeHtmlReport } = await import("./html-report.js");
1578
- reportHtml = writeHtmlReport(created.path, { report, label: bundleId });
1602
+ reportHtml = writeHtmlReport(created.path, { report, label: bundleId, recordingWarning });
1579
1603
  } catch { /* evidence page is best-effort */ }
1580
- const recording =
1581
- ["exploration.webm", "exploration.mov"].map((f) => path.join(created.path, f)).find((p) => fs.existsSync(p)) || null;
1582
1604
  const structured = {
1583
1605
  ...report,
1584
1606
  regression,
@@ -1586,11 +1608,12 @@ export async function runQaIos({ bundleId, maxActions, timeout, args = {}, surfa
1586
1608
  inputHint,
1587
1609
  reportHtml,
1588
1610
  recording,
1611
+ recordingWarning,
1589
1612
  capture: { id: created.id, path: created.path, relativePath: created.relativePath },
1590
1613
  timedOut,
1591
1614
  autoBooted: sim.autoBooted || false,
1592
1615
  };
1593
- const text = formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiConfigured: !!backend, reportHtml, recording, uiMap: uiMap.error ? null : uiMap, surface });
1616
+ const text = formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiConfigured: !!backend, reportHtml, recording, recordingWarning, uiMap: uiMap.error ? null : uiMap, surface });
1594
1617
  return { structured, text };
1595
1618
  }
1596
1619
 
@@ -1690,8 +1713,8 @@ export async function runInitExploration({
1690
1713
  merged.provenance.lastRun = {
1691
1714
  id: qa.structured.capture?.id || observed.provenance?.runIds?.at(-1) || "",
1692
1715
  platform: selected,
1693
- verdict: qa.structured.verdict,
1694
1716
  inconclusive: qa.structured.inconclusive === true,
1717
+ findingCount: qa.structured.findingCounts?.total ?? 0,
1695
1718
  statesExplored: Number(qa.structured.screensExplored || merged.nodes.length),
1696
1719
  actionsPerformed: Number(qa.structured.actionsPerformed || 0),
1697
1720
  observedAt: observed.provenance?.lastObservedAt || new Date().toISOString(),
@@ -1707,8 +1730,8 @@ export async function runInitExploration({
1707
1730
  resolution: targetResolution,
1708
1731
  evidence: {
1709
1732
  captureId: qa.structured.capture?.id || "",
1710
- verdict: qa.structured.verdict,
1711
1733
  inconclusive: qa.structured.inconclusive === true,
1734
+ findingCount: qa.structured.findingCounts?.total ?? 0,
1712
1735
  observedAt: observed.provenance?.lastObservedAt || new Date().toISOString(),
1713
1736
  },
1714
1737
  },
@@ -1721,7 +1744,6 @@ export async function runInitExploration({
1721
1744
  controlCount: merged.nodes.reduce((total, node) => total + node.controls.length, 0),
1722
1745
  },
1723
1746
  mapDiff: previous ? diffUiMaps(previous, observed, { comparableFullSweep: false }) : null,
1724
- verdict: qa.structured.verdict,
1725
1747
  inconclusive: qa.structured.inconclusive === true,
1726
1748
  findings: qa.structured.findings || [],
1727
1749
  capture: qa.structured.capture,
@@ -1735,6 +1757,97 @@ export async function runInitExploration({
1735
1757
  }
1736
1758
  }
1737
1759
 
1760
+ /**
1761
+ * Source-preparing bare explore (ADR-0005 §5): a `tapp explore` with no explicit target in a
1762
+ * repository with an application model. Selects the model's default target and prepares it from
1763
+ * source before exploring — managed web is built/started/waited-for and always stopped again,
1764
+ * iOS is built + installed on the simulator, Android is built to an APK + installed — then runs
1765
+ * the ordinary exploration engine. Returns the same `{ text, structured, error }` shape as the
1766
+ * direct runQa* entrypoints, so the CLI/MCP surfaces print it unchanged. This is an OBSERVATION:
1767
+ * no verdict, no UI-map write (that is `runInitExploration`'s job); the gate still judges.
1768
+ */
1769
+ export async function runExploreTarget({
1770
+ projectDir,
1771
+ platform = "",
1772
+ target = "",
1773
+ maxActions,
1774
+ timeout,
1775
+ testEmail,
1776
+ testPassword,
1777
+ appLaunchArgs,
1778
+ appLaunchEnv,
1779
+ baselineFindings,
1780
+ surface = "cli",
1781
+ onProgress = () => {},
1782
+ onStatus = () => {},
1783
+ } = {}) {
1784
+ let root;
1785
+ try { root = fs.realpathSync(path.resolve(projectDir || process.cwd())); }
1786
+ catch { return { error: `Repository directory not found: ${projectDir || process.cwd()}` }; }
1787
+
1788
+ const modelPath = existingProjectArtifactPath(root, "application-model.json");
1789
+ if (!fs.existsSync(modelPath)) {
1790
+ 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)." };
1791
+ }
1792
+ let model;
1793
+ try { model = JSON.parse(fs.readFileSync(modelPath, "utf8")); }
1794
+ catch (error) { return { error: `Application model is unreadable: ${error.message || String(error)}` }; }
1795
+
1796
+ const { selectApplicationTarget } = await import("./ci-setup.js");
1797
+ let selected;
1798
+ try { selected = selectApplicationTarget(model, { platform, target, useDefault: true }); }
1799
+ catch (error) { return { error: error.message || String(error) }; }
1800
+ const selectedPlatform = selected.platform;
1801
+
1802
+ if (selectedPlatform !== "ios" && ((Array.isArray(appLaunchArgs) && appLaunchArgs.length) || (appLaunchEnv && Object.keys(appLaunchEnv).length))) {
1803
+ return { error: "appLaunchArgs/appLaunchEnv apply only to iOS targets." };
1804
+ }
1805
+
1806
+ if (selectedPlatform === "web") {
1807
+ const ownedUrl = String(selected.runtime?.ownedUrl || "").trim();
1808
+ if (/^https?:\/\//i.test(ownedUrl)) {
1809
+ onStatus(`Exploring the owned URL from the application model: ${ownedUrl}`);
1810
+ return runQaWeb({ url: ownedUrl, maxActions, timeout, testEmail, testPassword, baselineFindings, surface, onProgress });
1811
+ }
1812
+ // Tapp-managed: build/start the repo's web target, wait for readiness, and ALWAYS stop it.
1813
+ onStatus(`Preparing the managed web runtime for ${selected.name}…`);
1814
+ const started = await startManagedWebTarget({ root, requestedTarget: selected.sourcePath || selected.name || "", timeout, onStatus });
1815
+ if (started.error) return started;
1816
+ try {
1817
+ return await runQaWeb({ url: started.url, maxActions, timeout, testEmail, testPassword, baselineFindings, surface, onProgress });
1818
+ } finally {
1819
+ await stopManagedWebTarget(started);
1820
+ onStatus("Stopped the managed web runtime.");
1821
+ }
1822
+ }
1823
+
1824
+ if (selectedPlatform === "android") {
1825
+ const appId = String(selected.runtime?.applicationId || "").trim();
1826
+ if (!appId) return { error: `The Android target '${selected.name}' has no confirmed application id — confirm it and rerun \`tapp init\`, or pass --app-id.` };
1827
+ const task = selected.build?.task || "assembleDebug";
1828
+ onStatus(`Building the Android APK (${task})…`);
1829
+ const built = await buildAndroidApp({
1830
+ projectDir: root,
1831
+ gradleProjectDir: path.resolve(root, selected.build?.projectDir || "."),
1832
+ moduleDir: path.resolve(root, selected.sourcePath || "."),
1833
+ task,
1834
+ });
1835
+ if (built.error) return built;
1836
+ onStatus(`Installing and exploring ${appId}…`);
1837
+ return runQaAndroid({ appId, apkPath: built.apkPath, maxActions, timeout, testEmail, testPassword, baselineFindings, surface, onProgress });
1838
+ }
1839
+
1840
+ // iOS
1841
+ if (process.platform !== "darwin") return { error: "iOS exploration requires macOS with Xcode." };
1842
+ const scheme = selected.build?.scheme || selected.build?.proposedScheme || "";
1843
+ const configuration = selected.build?.configuration || "Debug";
1844
+ onStatus(`Building and installing the iOS app${scheme ? ` (scheme ${scheme})` : ""}…`);
1845
+ const resolved = await resolveAppTarget(root, { cwd: root, onStatus, scheme, configuration });
1846
+ if (resolved.error) return resolved;
1847
+ if (resolved.via) onStatus(`Target ${resolved.bundleId} — ${resolved.via}`);
1848
+ return runQaIos({ bundleId: resolved.bundleId, maxActions, timeout, args: { testEmail, testPassword, baselineFindings, appLaunchArgs, appLaunchEnv }, surface, onProgress });
1849
+ }
1850
+
1738
1851
  function openLocalPort() {
1739
1852
  return new Promise((resolve, reject) => {
1740
1853
  const server = net.createServer();
@@ -1946,7 +2059,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1946
2059
  description:
1947
2060
  "Build the user's iOS app for the simulator from an Xcode project/workspace (auto-detects the " +
1948
2061
  "container and scheme under projectDir, default cwd), install it on the booted simulator, and " +
1949
- "return the bundle id. Use before tapp_run_qa / tapp_open_app when the app isn't installed yet — " +
2062
+ "return the bundle id. Use before tapp_explore / tapp_open_app when the app isn't installed yet — " +
1950
2063
  "no bundle id needed up front.",
1951
2064
  inputSchema: {
1952
2065
  type: "object",
@@ -2068,22 +2181,23 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2068
2181
  },
2069
2182
  },
2070
2183
  {
2071
- name: "tapp_run_qa",
2072
- title: "Run autonomous QA",
2184
+ name: "tapp_explore",
2185
+ title: "Explore (autonomous)",
2073
2186
  description:
2074
- "Run autonomous QA against iOS (appBundleId), Android (androidAppId), OR a web app " +
2075
- "(url — beta, requires Playwright installed) and return a structured " +
2076
- "QA verdict. Use ONLY when the user wants a QA assessment / to find bugs / a verdict — this " +
2187
+ "Autonomously explore iOS (appBundleId), Android (androidAppId), OR a web app " +
2188
+ "(url — beta, requires Playwright installed) and return a structured EXPLORATION OBSERVATION. " +
2189
+ "Exploration OBSERVES it surfaces findings + coverage + evidence; it does NOT render a ship " +
2190
+ "verdict or score. To gate a merge, run the deterministic gate (contracts + baseline via CI). " +
2191
+ "Use when the user wants to find bugs / observe what breaks — this " +
2077
2192
  "runs for MINUTES exploring the whole app. Do NOT use it just to view, screenshot, or reach a specific " +
2078
2193
  "screen — use tapp_open_app (launch + screenshot) or a session for that. Tapp explores the app " +
2079
2194
  "like a tester (taps, types, navigates, scrolls) and detects real issues — crashes, dead buttons, failed sign-ins, error screens, " +
2080
2195
  "stuck/hung screens; on web also uncaught JS exceptions, failed/5xx requests, broken links and assets. " +
2081
- "Returns {verdict: ready|caution|blocked, confidence, releaseScore, headline, screensExplored, " +
2082
- "actionsPerformed, findings:[{type,severity,category,title,screen,evaluationTier}]}. Exploratory web " +
2083
- "sets confidence/releaseScore to null and separates deterministic verdict findings from advisory " +
2084
- "sampled control probes. The verdict has a coverage floor: " +
2085
- "if the app barely explored (crash on launch / sign-in wall) it returns 'caution' + inconclusive, never a " +
2086
- "false pass. For iOS the app must already be installed on a booted simulator (use tapp_list_simulators / " +
2196
+ "Returns {kind:'tapp-exploration-run', headline, inconclusive, screensExplored, " +
2197
+ "actionsPerformed, findingCounts, findings:[{type,severity,category,authority,title,screen}]} NO " +
2198
+ "verdict/releaseScore. Web separates deterministic findings from advisory sampled control probes. " +
2199
+ "There is a coverage floor: if the app barely explored (crash on launch / sign-in wall) it reports " +
2200
+ "`inconclusive` absence of findings is NEVER a pass. For iOS the app must already be installed on a booted simulator (use tapp_list_simulators / " +
2087
2201
  "tapp_boot_simulator first). For web, only point it at an app/environment you own — it CLICKS things. " +
2088
2202
  "Tapp explores autonomously and does NOT pause to prompt for input — " +
2089
2203
  "it fills forms with safe defaults. The result includes `inputFieldsEncountered` (and `inputHint`): if " +
@@ -2138,10 +2252,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2138
2252
  type: "array",
2139
2253
  items: { type: "object" },
2140
2254
  description:
2141
- "Findings from a previous run (pass back the `findings` array a prior tapp_run_qa returned). " +
2142
- "When provided, the result adds `regression` {counts:{new,persisting,resolved}, newFindings, resolved, " +
2143
- "gate:{newHigh,newCritical,failed}} comparing this run to that baseline. For a CI gate: store the " +
2144
- "baseline once, then fail the build when regression.gate.failed is true (new high/critical introduced).",
2255
+ "Findings from a previous run (pass back the `findings` array a prior tapp_explore returned). " +
2256
+ "When provided, the result adds a `regression` COMPARISON {counts:{new,persisting,resolved}, " +
2257
+ "newFindings, resolved} vs. that baseline an observation, not a gate signal. To gate a merge, " +
2258
+ "run `tapp ci` (or the GitHub Action): it applies the deterministic policy and returns the " +
2259
+ "pass/fail/inconclusive outcome.",
2145
2260
  },
2146
2261
  },
2147
2262
  },
@@ -2356,7 +2471,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2356
2471
  description:
2357
2472
  "Replay a deterministic, authored end-to-end test (a Flow) against iOS (XCUITest), Android " +
2358
2473
  "(ADB/UIAutomator), or web (Playwright), and return a scannable pass/fail report. A Flow is a list of steps + assertions " +
2359
- "(see docs/flows-architecture.md). Unlike tapp_run_qa (autonomous exploration), a Flow does EXACTLY " +
2474
+ "(see docs/flows-architecture.md). Unlike tapp_explore (autonomous exploration), a Flow does EXACTLY " +
2360
2475
  "what you specify, the same way every time — use it for regression tests and verifying a fix. Steps: " +
2361
2476
  "{tap: X} · {type: {field: F, value: V}} · {swipe: up} · {back} · {wait_for: SCREEN}. Assertions " +
2362
2477
  "(deterministic): {assert_screen: X} · {assert_exists: X} · {assert_absent: X} · {assert_text: {of, contains}}. " +
@@ -2411,8 +2526,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2411
2526
  "Write a deterministic E2E Flow from a natural-language goal (e.g. 'sign in and open Settings'), " +
2412
2527
  "GROUNDED in the app's real screens so it can't invent steps. Tapp explores the app to build a " +
2413
2528
  "screen/control map (or reuses a recent run via captureId), then a model authors a Flow using only " +
2414
- "screens/controls that were actually observed. Saves it to .tapp/flows/<name>.yml and returns the " +
2415
- "YAML for review (optionally runs it). Needs a model backend (Tapp subscription token or " +
2529
+ "screens/controls that were actually observed. Saves it as an UNTRUSTED PROPOSAL under " +
2530
+ ".tapp/proposals/flows/<name>.yml (never directly into .tapp/flows/) and returns the YAML for " +
2531
+ "review (optionally runs it). Review → replay against the real app → explicitly PROMOTE (move to " +
2532
+ ".tapp/flows/) before CI depends on it. Needs a model backend (Tapp subscription token or " +
2416
2533
  "ANTHROPIC_API_KEY). Use this to bootstrap a test you then refine; use tapp_flow_run to replay it.",
2417
2534
  inputSchema: {
2418
2535
  type: "object",
@@ -2487,7 +2604,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2487
2604
  description:
2488
2605
  "Launch an installed iOS or Android app and return a SCREENSHOT of the screen it lands on " +
2489
2606
  "(plus the accessibility tree) — with NO exploration. This is the fast way (seconds) to just SEE a " +
2490
- "screen. Use this — NOT tapp_run_qa — whenever the user wants to view or screenshot a screen. Pass " +
2607
+ "screen. Use this — NOT tapp_explore — whenever the user wants to view or screenshot a screen. Pass " +
2491
2608
  "appLaunchArgs like [\"--uitesting\"] to bypass login and land on the home screen, and appLaunchEnv for " +
2492
2609
  "a backend override. The app is launched fresh and closed afterward. (To screenshot a screen reached by " +
2493
2610
  "real login or several taps, use a session instead and call tapp_screenshot along the way.)",
@@ -2529,7 +2646,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2529
2646
  name: "tapp_install_app",
2530
2647
  title: "Install app on sim",
2531
2648
  description:
2532
- "Build a target iOS app for the booted simulator and install it, so it's ready for tapp_run_qa or " +
2649
+ "Build a target iOS app for the booted simulator and install it, so it's ready for tapp_explore or " +
2533
2650
  "a session. Provide the Xcode project OR workspace path + scheme. Best-effort — apps with CocoaPods/" +
2534
2651
  "signing quirks may still need their normal build. Returns {ok, installed, simulator}.",
2535
2652
  inputSchema: {
@@ -2679,7 +2796,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2679
2796
  const text =
2680
2797
  `🔨 Built **${path.basename(built.appPath)}** (scheme \`${built.scheme}\`) in ${fmtDuration(Date.now() - startedAt)}` +
2681
2798
  (bundleId ? ` — installed on the simulator as \`${bundleId}\`` : "") +
2682
- `\n\nNext: \`tapp_run_qa\` with \`appBundleId: "${bundleId || "<install it first>"}"\`.`;
2799
+ `\n\nNext: \`tapp_explore\` with \`appBundleId: "${bundleId || "<install it first>"}"\`.`;
2683
2800
  return richResult(text, { ok: true, appPath: built.appPath, scheme: built.scheme, container: built.container, bundleId });
2684
2801
  }
2685
2802
 
@@ -2845,7 +2962,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2845
2962
  return richResult(L.join("\n"), summary);
2846
2963
  }
2847
2964
 
2848
- if (name === "tapp_run_qa") {
2965
+ if (name === "tapp_explore" || name === "tapp_run_qa") { // tapp_run_qa: deprecated alias
2849
2966
  const unauthorized = ensureAuthorized(args);
2850
2967
  if (unauthorized) return unauthorized;
2851
2968
  const wantsWeb = isNonEmptyString(args.url);
@@ -2859,12 +2976,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2859
2976
  // MCP concerns: auth, arg validation, and progress notifications.
2860
2977
  const progressToken = request.params && request.params._meta ? request.params._meta.progressToken : undefined;
2861
2978
  const budget = Math.max(1, Math.min(1000, asInteger(args.maxActions, 60)));
2862
- const notifyProgress = (unit) => (p) => {
2979
+ const notifyProgress = (metric) => (p) => {
2863
2980
  if (progressToken === undefined) return;
2864
2981
  const total = p.max || budget;
2865
2982
  server.notification({
2866
2983
  method: "notifications/progress",
2867
- params: { progressToken, progress: p.action || 0, total, message: `🔍 Exploring… ${p.action}/${total} actions · ${p.states} ${unit} reached` },
2984
+ params: { progressToken, progress: p.action || 0, total, message: `🔍 Exploring… ${p.action}/${total} actions · ${p.states} ${metric}` },
2868
2985
  }).catch(() => {});
2869
2986
  };
2870
2987
  if (wantsWeb) {
@@ -2875,7 +2992,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2875
2992
  testEmail: args.testEmail,
2876
2993
  testPassword: args.testPassword,
2877
2994
  baselineFindings: args.baselineFindings,
2878
- onProgress: notifyProgress("pages"),
2995
+ onProgress: notifyProgress("pages reached"),
2879
2996
  });
2880
2997
  if (r.error) return errorResult(r.error, r.details || {});
2881
2998
  return richResult(r.text, r.structured);
@@ -2892,14 +3009,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2892
3009
  testPassword: args.testPassword,
2893
3010
  baselineFindings: args.baselineFindings,
2894
3011
  clearData: args.clearData !== false,
2895
- onProgress: notifyProgress("screens"),
3012
+ onProgress: notifyProgress("screens reached"),
2896
3013
  });
2897
3014
  if (r.error) return errorResult(r.error, r.details || {});
2898
3015
  return richResult(r.text, r.structured);
2899
3016
  }
2900
3017
 
2901
3018
  let lastProgress = null;
2902
- const iosProgress = notifyProgress("screens");
3019
+ const iosProgress = notifyProgress("structural states observed");
2903
3020
  const r = await runQaIos({
2904
3021
  bundleId: String(args.appBundleId).trim(),
2905
3022
  maxActions: args.maxActions,
@@ -2930,14 +3047,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2930
3047
  if (!isInsideDir(projectDir, resolvedOut)) return errorResult("outDir must be inside projectDir");
2931
3048
  const selectedPlatform = isNonEmptyString(args.platform) ? args.platform.trim().toLowerCase()
2932
3049
  : isNonEmptyString(args.url) ? "web"
2933
- : isNonEmptyString(args.androidAppId) || isNonEmptyString(args.apkPath) ? "android" : "ios";
3050
+ : isNonEmptyString(args.androidAppId) || isNonEmptyString(args.apkPath) ? "android" : "";
2934
3051
  const progressToken = request.params && request.params._meta ? request.params._meta.progressToken : undefined;
2935
3052
  const budget = Math.max(1, Math.min(1000, asInteger(args.maxActions, 40)));
2936
3053
  const result = await initializeProductProject({
2937
3054
  projectDir, mode: operation, outDir,
2938
3055
  ownedUrl: isNonEmptyString(args.url) ? args.url.trim() : "",
2939
3056
  platform: isNonEmptyString(args.platform) ? args.platform.trim().toLowerCase() : operation === "explore" ? selectedPlatform : "",
2940
- target: isNonEmptyString(args.target) ? args.target.trim() : projectDir,
3057
+ target: isNonEmptyString(args.target) ? args.target.trim() : "",
2941
3058
  bundleId: isNonEmptyString(args.appBundleId) ? args.appBundleId.trim() : "",
2942
3059
  appId: isNonEmptyString(args.androidAppId) ? args.androidAppId.trim() : "",
2943
3060
  apkPath: isNonEmptyString(args.apkPath) ? path.resolve(projectDir, args.apkPath.trim()) : undefined,
@@ -2953,9 +3070,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2953
3070
  const { model, plan, written, exploration } = result;
2954
3071
  const blocking = model.requirements.filter((item) => item.severity === "blocking");
2955
3072
  const pending = plan.items.filter((item) => item.decision === "pending");
2956
- 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.verdict}${exploration.inconclusive ? " (inconclusive)" : ""}` : ""}`;
3073
+ 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)" : ""}` : ""}`;
2957
3074
  return richResult(summary, { model, plan, written: written ? { modelPath: written.modelPath, planPath: written.planPath } : null, exploration });
2958
- } catch (error) { return errorResult("Could not initialize Tapp repository artifacts", { detail: error.message || String(error) }); }
3075
+ } catch (error) {
3076
+ const detail = error.message || String(error);
3077
+ const message = error.details?.reason === "target-selection-required"
3078
+ ? detail
3079
+ : "Could not initialize Tapp repository artifacts";
3080
+ return errorResult(message, { detail, ...(error.details || {}) });
3081
+ }
2959
3082
  }
2960
3083
 
2961
3084
  if (name === "tapp_actor_config") {
@@ -3493,21 +3616,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3493
3616
  const parsed = parseGeneratedFlow(mres.text);
3494
3617
  if (!parsed) return errorResult("Model did not return a valid Flow", { raw: mres.text.slice(0, 500) });
3495
3618
 
3496
- // 3) Ground-check + write.
3619
+ // 3) Ground-check + write as an UNTRUSTED PROPOSAL (ADR-0005 decision 8: AI-generated work lands
3620
+ // as a draft under .tapp/proposals/, requires real-target replay + explicit human promotion,
3621
+ // and only then enters .tapp/flows/ where CI depends on it — never a direct write).
3497
3622
  const ungrounded = ungroundedScreens(parsed.steps, grounding);
3498
3623
  const flow = { name: args.name || parsed.name, app: bundleId, steps: parsed.steps };
3499
3624
  const slug = flow.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "generated-flow";
3500
- const dir = path.join(repoRoot, ".tapp", "flows");
3625
+ const dir = path.join(repoRoot, ".tapp", "proposals", "flows");
3501
3626
  fs.mkdirSync(dir, { recursive: true });
3502
3627
  const outPath = path.join(dir, `${slug}.yml`);
3628
+ const promotedPath = path.join(".tapp", "flows", `${slug}.yml`);
3503
3629
  const yamlRes = await runCommand("python3", [path.join(scriptsDir, "flow_lib.py"), "to-yaml", JSON.stringify(flow)], { cwd: repoRoot });
3504
3630
  const yaml = (yamlRes.stdout || "").trim();
3505
3631
  if (!yaml) return errorResult("Failed to render flow YAML", { stderr: yamlRes.stderr });
3506
3632
  fs.writeFileSync(outPath, yaml + "\n");
3507
3633
  const rel = path.relative(repoRoot, outPath);
3508
3634
 
3509
- const L = [`🤖 Generated flow **${flow.name}** from your goal → \`${rel}\``];
3510
- L.push(`Grounded in ${grounding.screens.length} observed screen(s). ${ungrounded.length ? `⚠️ references unobserved: ${ungrounded.join(", ")} — review before relying on it.` : "All referenced screens were observed."}`);
3635
+ const L = [`🤖 Generated a flow **proposal** **${flow.name}** from your goal → \`${rel}\``];
3636
+ 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."}`);
3637
+ L.push(`**Review → replay → promote:** review it, replay it against the real app, and only then promote it (move to \`${promotedPath}\`) so CI can depend on it.`);
3511
3638
  L.push("", "```yaml", yaml, "```");
3512
3639
 
3513
3640
  // 4) Optionally replay it now.
@@ -3522,9 +3649,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3522
3649
  L.push("", "---", "", (rep.stdout || "").trim());
3523
3650
  }
3524
3651
  } else {
3525
- L.push("", `Replay it: \`tapp_flow_run\` with \`flowPath: "${rel}"\`.`);
3652
+ L.push("", `Replay the proposal: \`tapp_flow_run\` with \`flowPath: "${rel}"\`. After it passes, promote it to \`.tapp/flows/\`.`);
3526
3653
  }
3527
- return richResult(L.join("\n"), { path: rel, flow, groundedScreens: grounding.screens.length, ungrounded });
3654
+ return richResult(L.join("\n"), { path: rel, proposal: true, promotePath: promotedPath, flow, groundedScreens: grounding.screens.length, ungrounded });
3528
3655
  }
3529
3656
 
3530
3657
  if (name === "tapp_ui_tree") {
@@ -3592,7 +3719,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3592
3719
  if (isNonEmptyString(args.apkPath)) await driver.install(path.resolve(args.apkPath));
3593
3720
  const snap = await driver.launch({ clearData: args.clearData === true });
3594
3721
  const data = await driver.screenshot();
3595
- await driver.forceStop().catch(() => {});
3596
3722
  return {
3597
3723
  content: [
3598
3724
  { type: "text", text: `🚀 Launched \`${appId}\` (Android)\n\n` + formatScreen(snap.screenTitle, snap.elements) },
@@ -428,16 +428,17 @@ export function adoptPrCoverageProposal({ projectDir, prPlanPath, item, releaseP
428
428
  if (!ground || !fs.existsSync(mapPath)) throw new Error("Coverage proposal requires the repository's persistent UI Map");
429
429
  const map = JSON.parse(fs.readFileSync(mapPath, "utf8"));
430
430
  const node = (map.nodes || []).find((candidate) => candidate.id === ground.id && candidate.status !== "proposed");
431
- if (!node) throw new Error(`Coverage proposal UI Map node is stale or missing: ${ground.id}`);
431
+ const refreshAdvice = "refresh the persistent UI Map with `tapp init --explore --refresh`, rerun `tapp pr gate`, then retry `tapp pr adopt`";
432
+ if (!node) throw new Error(`Coverage proposal UI Map node is stale or missing: ${ground.id}; ${refreshAdvice}`);
432
433
  if (target.navigation?.route && !(node.routes || []).some((route) => route.platform === target.platform && route.path === target.navigation.route && route.replayable === true)) {
433
- throw new Error(`Coverage proposal route is stale in the persistent UI Map: ${target.navigation.route}`);
434
+ throw new Error(`Coverage proposal route is stale in the persistent UI Map: ${target.navigation.route}; ${refreshAdvice}`);
434
435
  }
435
436
  if (target.navigation?.mode === "ui-map-path") {
436
437
  const currentNavigation = replayableUiMapNavigation(map, node.id, target.platform);
437
438
  const expectedEdges = (target.navigation.steps || []).map((step) => step.edgeId);
438
439
  const currentEdges = (currentNavigation.steps || []).map((step) => step.edgeId);
439
440
  if (currentNavigation.status !== "replayable" || JSON.stringify(expectedEdges) !== JSON.stringify(currentEdges)) {
440
- throw new Error(`Coverage proposal UI Map path is stale for ${node.name}`);
441
+ throw new Error(`Coverage proposal UI Map path is stale for ${node.name}; ${refreshAdvice}`);
441
442
  }
442
443
  }
443
444
  const releasePlan = JSON.parse(fs.readFileSync(targetPath, "utf8"));