@aarwitz/tapp 0.16.5 → 0.17.0-rc.2

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") {
@@ -984,7 +984,10 @@ async function runExploreStreaming(bundleId, actions, timeout, env, onProgress)
984
984
  const before = new Set(listCaptureRuns(80).map((r) => r.id));
985
985
  const proc = spawn("bash", cmdArgs, { cwd: repoRoot, env: { ...process.env, ...env } });
986
986
  proc.stdout.on("data", () => {});
987
- proc.stderr.on("data", () => {});
987
+ let captureStderr = "";
988
+ proc.stderr.on("data", (chunk) => {
989
+ captureStderr = (captureStderr + chunk.toString("utf8")).slice(-8_000);
990
+ });
988
991
  const closed = new Promise((res) => proc.on("close", (code) => res(code ?? 1)));
989
992
 
990
993
  let captureDir = null;
@@ -1037,7 +1040,18 @@ async function runExploreStreaming(bundleId, actions, timeout, env, onProgress)
1037
1040
  await closed;
1038
1041
  const created = listCaptureRuns(80).find((r) => !before.has(r.id))
1039
1042
  || (captureDir ? { id: path.basename(captureDir), path: captureDir, relativePath: path.relative(repoRoot, captureDir) } : null);
1040
- return { created, timedOut };
1043
+ return { created, timedOut, captureStderr };
1044
+ }
1045
+
1046
+ export function recordingUnavailableReason(stderr = "") {
1047
+ const text = String(stderr);
1048
+ if (/resource busy|host recording is already in progress/i.test(text)) {
1049
+ return "the simulator recorder is busy with another host recording";
1050
+ }
1051
+ if (/could not start simulator video recording/i.test(text)) {
1052
+ return "the simulator could not start video recording";
1053
+ }
1054
+ return "the simulator did not produce a recording";
1041
1055
  }
1042
1056
 
1043
1057
  // Grab the booted simulator's current screen and return it downscaled + JPEG-compressed so the
@@ -1066,7 +1080,7 @@ export async function captureScreenshotImage(maxWidth) {
1066
1080
  // change data-handling behavior. A subscription token is an explicit tapp choice, and
1067
1081
  // explicitly-invoked AI tools (tapp_flow_generate, assert_ai) carry their own consent.
1068
1082
  export function remoteAiOptedIn(env = process.env) {
1069
- if ((env.TAPP_SUBSCRIPTION_TOKEN || env.AUTOTAP_SUBSCRIPTION_TOKEN || "").trim()) return true;
1083
+ if ((env.TAPP_SUBSCRIPTION_TOKEN || "").trim()) return true;
1070
1084
  return ["1", "true", "yes"].includes(String(env.TAPP_ENABLE_REMOTE_AI || "").trim().toLowerCase());
1071
1085
  }
1072
1086
 
@@ -1078,9 +1092,9 @@ export function isInsideDir(root, p) {
1078
1092
  }
1079
1093
 
1080
1094
  function resolveModelBackend() {
1081
- const token = (process.env.TAPP_SUBSCRIPTION_TOKEN || process.env.AUTOTAP_SUBSCRIPTION_TOKEN || "").trim();
1095
+ const token = (process.env.TAPP_SUBSCRIPTION_TOKEN || "").trim();
1082
1096
  if (token) {
1083
- const base = (process.env.TAPP_PROXY_URL || process.env.AUTOTAP_PROXY_URL || "http://localhost:8787").replace(/\/$/, "");
1097
+ const base = (process.env.TAPP_PROXY_URL || "http://localhost:8787").replace(/\/$/, "");
1084
1098
  const url = base.endsWith("/v1/messages") ? base : base + "/v1/messages";
1085
1099
  return { url, headers: { authorization: `Bearer ${token}`, "content-type": "application/json" } };
1086
1100
  }
@@ -1092,7 +1106,7 @@ function resolveModelBackend() {
1092
1106
  }
1093
1107
 
1094
1108
  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 }] });
1109
+ 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
1110
  const res = await fetch(backend.url, { method: "POST", headers: backend.headers, body });
1097
1111
  if (!res.ok) return { error: `model HTTP ${res.status}: ${(await res.text()).slice(0, 300)}` };
1098
1112
  const data = await res.json();
@@ -1307,7 +1321,7 @@ export function qaNextSteps(report, surface = "mcp") {
1307
1321
  if (surface === "cli") {
1308
1322
  const next = [];
1309
1323
  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");
1324
+ next.push("re-run with `--baseline <report.json>` to compare a fix (then `tapp ci` to gate it)");
1311
1325
  next.push("replay a committed journey with `tapp flow run <file>`");
1312
1326
  return next;
1313
1327
  }
@@ -1318,25 +1332,27 @@ export function qaNextSteps(report, surface = "mcp") {
1318
1332
  return next;
1319
1333
  }
1320
1334
 
1321
- function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiConfigured, reportHtml, recording, uiMap, surface = "mcp" } = {}) {
1335
+ function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiConfigured, reportHtml, recording, recordingWarning, uiMap, surface = "mcp" } = {}) {
1322
1336
  const c = report.findingCounts || {};
1323
- const badge = verdictBadge(report);
1337
+ const badge = observationBadge(report);
1324
1338
  const sevBits = ["critical", "high", "medium", "low"]
1325
1339
  .map((k) => (c[k] ? `${SEV[k]} ${c[k]} ${k}` : null))
1326
1340
  .filter(Boolean)
1327
1341
  .join(", ");
1328
1342
  const L = [];
1329
- L.push(`### 🧪 QA complete ${badge} · ${qaScoreLabel(report)}${bundleId ? `\n\`${bundleId}\`` : ""}`);
1343
+ // Exploration observes; it does not render a ship verdict. The release decision lives in the gate.
1344
+ L.push(`### 🔭 Exploration complete — ${badge} · ${observationSummary(report)}${bundleId ? `\n\`${bundleId}\`` : ""}`);
1330
1345
  L.push("");
1331
1346
  L.push(report.headline);
1332
1347
  L.push("");
1333
1348
  L.push(`**Coverage** — ${report.screensExplored} screens · ${report.actionsPerformed} actions${timedOut ? " · ⏱️ hit time limit" : ""}`);
1334
1349
  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`);
1350
+ L.push(`**Deterministic basis** — ${report.deterministicFindingCounts?.total || 0} deterministic finding(s); ${report.sampledFindingCounts?.total || 0} sampled probe finding(s) are advisory`);
1336
1351
  }
1337
1352
  if (uiMap) L.push(`**UI Map** — ${uiMap.nodeCount} states · ${uiMap.edgeCount} transitions · ${uiMap.controlCount} semantic controls · ${uiMap.path}`);
1338
1353
  if (reportHtml) L.push(`**Evidence** — 📄 ${reportHtml} (screenshots of every screen + findings, shareable)`);
1339
1354
  if (recording) L.push(`**Recording** — 🎬 ${recording} (full exploration, embedded in the evidence page)`);
1355
+ else if (recordingWarning) L.push(`**Recording** — ⚠️ unavailable: ${recordingWarning}; screenshots were still captured`);
1340
1356
  L.push(`**Issues** — ${c.total ? `${c.total}${sevBits ? ` (${sevBits})` : ""}` : "none found ✨"}`);
1341
1357
  if (Array.isArray(report.findings) && report.findings.length) {
1342
1358
  L.push("");
@@ -1355,17 +1371,18 @@ function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiC
1355
1371
  }
1356
1372
  }
1357
1373
  if (regression && regression.counts) {
1358
- const g = regression.gate || {};
1374
+ // Exploration reports the comparison (new/persisting/resolved) only — never a gate pass/fail.
1375
+ // The merge decision is the gate's job (tapp ci), not exploration's (ADR-0005).
1359
1376
  L.push("");
1360
1377
  L.push(
1361
- `**Since last run** — +${regression.counts.new} new · ${regression.counts.persisting} persisting · ${regression.counts.resolved} resolved · gate ${g.failed ? "🔴 FAIL" : "🟢 PASS"}`
1378
+ `**Since last run** — +${regression.counts.new} new · ${regression.counts.persisting} persisting · ${regression.counts.resolved} resolved (comparison only run \`tapp ci\` to gate)`
1362
1379
  );
1363
1380
  }
1364
1381
  if (inputHint) {
1365
1382
  L.push("");
1366
1383
  L.push(`> ℹ️ ${inputHint}`);
1367
1384
  }
1368
- // The honesty label: a "ready" is a claim about exactly these classes, nothing more.
1385
+ // The observation's scope: enumerate exactly what ran and what remains unchecked.
1369
1386
  if (Array.isArray(report.checkedFor) && report.checkedFor.length) {
1370
1387
  L.push("");
1371
1388
  L.push(`> ✅ Checked: ${report.checkedFor.join(" · ")}`);
@@ -1379,11 +1396,10 @@ function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiC
1379
1396
  const next = qaNextSteps(report, surface);
1380
1397
  L.push("");
1381
1398
  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.
1399
+ // The gate hook belongs at the moment the user thinks "I want these checks on every PR".
1384
1400
  if ((report.findings && report.findings.length) || regression) {
1385
1401
  L.push("");
1386
- L.push("> 🚦 Teams: get this verdict on every PR automatically (evidence + regression gate) — https://github.com/aarwitz/tapp#ci-gate");
1402
+ 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
1403
  }
1388
1404
  return L.join("\n");
1389
1405
  }
@@ -1464,7 +1480,7 @@ export async function runQaWeb({ url, maxActions, timeout, testEmail, testPasswo
1464
1480
  } catch (err) {
1465
1481
  return { error: String(err.message || err) };
1466
1482
  }
1467
- const report = buildQaReport(webResult.markersPath, { platform: "web" });
1483
+ const report = buildQaReport(webResult.markersPath, { platform: "web", target: url.trim() });
1468
1484
  if (!report) return { error: "Web exploration produced no markers", details: { capture: { id, path: outDir } } };
1469
1485
  const backend = remoteAiOptedIn() ? resolveModelBackend() : null;
1470
1486
  if (backend && report.findings.length) {
@@ -1507,7 +1523,7 @@ export async function runQaAndroid({ appId, apkPath, serial, maxActions, timeout
1507
1523
  } catch (error) {
1508
1524
  return { error: error.message || String(error), details: { capture: { id, path: outDir } } };
1509
1525
  }
1510
- const report = buildQaReport(androidResult.markersPath, { platform: "android" });
1526
+ const report = buildQaReport(androidResult.markersPath, { platform: "android", target: appId.trim() });
1511
1527
  if (!report) return { error: "Android exploration produced no markers", details: { capture: { id, path: outDir } } };
1512
1528
  const backend = remoteAiOptedIn() ? resolveModelBackend() : null;
1513
1529
  if (backend && report.findings.length) {
@@ -1539,10 +1555,10 @@ export async function runQaIos({ bundleId, maxActions, timeout, args = {}, surfa
1539
1555
  const timeoutSec = Math.max(30, Math.min(3600, asInteger(timeout, 600)));
1540
1556
  const env = explorationEnvFromArgs(args);
1541
1557
 
1542
- const { created, timedOut } = await runExploreStreaming(bundleId, actions, timeoutSec, env, onProgress);
1558
+ const { created, timedOut, captureStderr } = await runExploreStreaming(bundleId, actions, timeoutSec, env, onProgress);
1543
1559
  if (!created) return { error: "Exploration produced no capture run", details: { timedOut } };
1544
1560
 
1545
- const report = buildQaReport(path.join(created.path, "ocqa-markers.txt"));
1561
+ const report = buildQaReport(path.join(created.path, "ocqa-markers.txt"), { platform: "ios", target: bundleId });
1546
1562
  if (!report) {
1547
1563
  return {
1548
1564
  error: "No markers parsed from exploration (the app may not have launched)",
@@ -1572,13 +1588,14 @@ export async function runQaIos({ bundleId, maxActions, timeout, args = {}, surfa
1572
1588
  // Cross-run regression vs. a caller-supplied baseline (the CI gate).
1573
1589
  const regression = computeRegression(report.findings, args.baselineFindings);
1574
1590
  const uiMap = await writeRunUiMap({ markersPath: path.join(created.path, "ocqa-markers.txt"), platform: "ios", target: bundleId, runId: created.id, outDir: created.path });
1591
+ const recording =
1592
+ ["exploration.webm", "exploration.mov"].map((f) => path.join(created.path, f)).find((p) => fs.existsSync(p)) || null;
1593
+ const recordingWarning = recording ? null : recordingUnavailableReason(captureStderr);
1575
1594
  let reportHtml = null;
1576
1595
  try {
1577
1596
  const { writeHtmlReport } = await import("./html-report.js");
1578
- reportHtml = writeHtmlReport(created.path, { report, label: bundleId });
1597
+ reportHtml = writeHtmlReport(created.path, { report, label: bundleId, recordingWarning });
1579
1598
  } 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
1599
  const structured = {
1583
1600
  ...report,
1584
1601
  regression,
@@ -1586,11 +1603,12 @@ export async function runQaIos({ bundleId, maxActions, timeout, args = {}, surfa
1586
1603
  inputHint,
1587
1604
  reportHtml,
1588
1605
  recording,
1606
+ recordingWarning,
1589
1607
  capture: { id: created.id, path: created.path, relativePath: created.relativePath },
1590
1608
  timedOut,
1591
1609
  autoBooted: sim.autoBooted || false,
1592
1610
  };
1593
- const text = formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiConfigured: !!backend, reportHtml, recording, uiMap: uiMap.error ? null : uiMap, surface });
1611
+ const text = formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiConfigured: !!backend, reportHtml, recording, recordingWarning, uiMap: uiMap.error ? null : uiMap, surface });
1594
1612
  return { structured, text };
1595
1613
  }
1596
1614
 
@@ -1690,8 +1708,8 @@ export async function runInitExploration({
1690
1708
  merged.provenance.lastRun = {
1691
1709
  id: qa.structured.capture?.id || observed.provenance?.runIds?.at(-1) || "",
1692
1710
  platform: selected,
1693
- verdict: qa.structured.verdict,
1694
1711
  inconclusive: qa.structured.inconclusive === true,
1712
+ findingCount: qa.structured.findingCounts?.total ?? 0,
1695
1713
  statesExplored: Number(qa.structured.screensExplored || merged.nodes.length),
1696
1714
  actionsPerformed: Number(qa.structured.actionsPerformed || 0),
1697
1715
  observedAt: observed.provenance?.lastObservedAt || new Date().toISOString(),
@@ -1707,8 +1725,8 @@ export async function runInitExploration({
1707
1725
  resolution: targetResolution,
1708
1726
  evidence: {
1709
1727
  captureId: qa.structured.capture?.id || "",
1710
- verdict: qa.structured.verdict,
1711
1728
  inconclusive: qa.structured.inconclusive === true,
1729
+ findingCount: qa.structured.findingCounts?.total ?? 0,
1712
1730
  observedAt: observed.provenance?.lastObservedAt || new Date().toISOString(),
1713
1731
  },
1714
1732
  },
@@ -1721,7 +1739,6 @@ export async function runInitExploration({
1721
1739
  controlCount: merged.nodes.reduce((total, node) => total + node.controls.length, 0),
1722
1740
  },
1723
1741
  mapDiff: previous ? diffUiMaps(previous, observed, { comparableFullSweep: false }) : null,
1724
- verdict: qa.structured.verdict,
1725
1742
  inconclusive: qa.structured.inconclusive === true,
1726
1743
  findings: qa.structured.findings || [],
1727
1744
  capture: qa.structured.capture,
@@ -1735,6 +1752,97 @@ export async function runInitExploration({
1735
1752
  }
1736
1753
  }
1737
1754
 
1755
+ /**
1756
+ * Source-preparing bare explore (ADR-0005 §5): a `tapp explore` with no explicit target in a
1757
+ * repository with an application model. Selects the model's default target and prepares it from
1758
+ * source before exploring — managed web is built/started/waited-for and always stopped again,
1759
+ * iOS is built + installed on the simulator, Android is built to an APK + installed — then runs
1760
+ * the ordinary exploration engine. Returns the same `{ text, structured, error }` shape as the
1761
+ * direct runQa* entrypoints, so the CLI/MCP surfaces print it unchanged. This is an OBSERVATION:
1762
+ * no verdict, no UI-map write (that is `runInitExploration`'s job); the gate still judges.
1763
+ */
1764
+ export async function runExploreTarget({
1765
+ projectDir,
1766
+ platform = "",
1767
+ target = "",
1768
+ maxActions,
1769
+ timeout,
1770
+ testEmail,
1771
+ testPassword,
1772
+ appLaunchArgs,
1773
+ appLaunchEnv,
1774
+ baselineFindings,
1775
+ surface = "cli",
1776
+ onProgress = () => {},
1777
+ onStatus = () => {},
1778
+ } = {}) {
1779
+ let root;
1780
+ try { root = fs.realpathSync(path.resolve(projectDir || process.cwd())); }
1781
+ catch { return { error: `Repository directory not found: ${projectDir || process.cwd()}` }; }
1782
+
1783
+ const modelPath = existingProjectArtifactPath(root, "application-model.json");
1784
+ if (!fs.existsSync(modelPath)) {
1785
+ return { error: "No application model found — run `tapp init` first, or pass an explicit target (a bundle id, a path/to/App.app, a repo dir, --app-id/--apk, or an http(s) URL)." };
1786
+ }
1787
+ let model;
1788
+ try { model = JSON.parse(fs.readFileSync(modelPath, "utf8")); }
1789
+ catch (error) { return { error: `Application model is unreadable: ${error.message || String(error)}` }; }
1790
+
1791
+ const { selectApplicationTarget } = await import("./ci-setup.js");
1792
+ let selected;
1793
+ try { selected = selectApplicationTarget(model, { platform, target, useDefault: true }); }
1794
+ catch (error) { return { error: error.message || String(error) }; }
1795
+ const selectedPlatform = selected.platform;
1796
+
1797
+ if (selectedPlatform !== "ios" && ((Array.isArray(appLaunchArgs) && appLaunchArgs.length) || (appLaunchEnv && Object.keys(appLaunchEnv).length))) {
1798
+ return { error: "appLaunchArgs/appLaunchEnv apply only to iOS targets." };
1799
+ }
1800
+
1801
+ if (selectedPlatform === "web") {
1802
+ const ownedUrl = String(selected.runtime?.ownedUrl || "").trim();
1803
+ if (/^https?:\/\//i.test(ownedUrl)) {
1804
+ onStatus(`Exploring the owned URL from the application model: ${ownedUrl}`);
1805
+ return runQaWeb({ url: ownedUrl, maxActions, timeout, testEmail, testPassword, baselineFindings, surface, onProgress });
1806
+ }
1807
+ // Tapp-managed: build/start the repo's web target, wait for readiness, and ALWAYS stop it.
1808
+ onStatus(`Preparing the managed web runtime for ${selected.name}…`);
1809
+ const started = await startManagedWebTarget({ root, requestedTarget: selected.sourcePath || selected.name || "", timeout, onStatus });
1810
+ if (started.error) return started;
1811
+ try {
1812
+ return await runQaWeb({ url: started.url, maxActions, timeout, testEmail, testPassword, baselineFindings, surface, onProgress });
1813
+ } finally {
1814
+ await stopManagedWebTarget(started);
1815
+ onStatus("Stopped the managed web runtime.");
1816
+ }
1817
+ }
1818
+
1819
+ if (selectedPlatform === "android") {
1820
+ const appId = String(selected.runtime?.applicationId || "").trim();
1821
+ if (!appId) return { error: `The Android target '${selected.name}' has no confirmed application id — confirm it and rerun \`tapp init\`, or pass --app-id.` };
1822
+ const task = selected.build?.task || "assembleDebug";
1823
+ onStatus(`Building the Android APK (${task})…`);
1824
+ const built = await buildAndroidApp({
1825
+ projectDir: root,
1826
+ gradleProjectDir: path.resolve(root, selected.build?.projectDir || "."),
1827
+ moduleDir: path.resolve(root, selected.sourcePath || "."),
1828
+ task,
1829
+ });
1830
+ if (built.error) return built;
1831
+ onStatus(`Installing and exploring ${appId}…`);
1832
+ return runQaAndroid({ appId, apkPath: built.apkPath, maxActions, timeout, testEmail, testPassword, baselineFindings, surface, onProgress });
1833
+ }
1834
+
1835
+ // iOS
1836
+ if (process.platform !== "darwin") return { error: "iOS exploration requires macOS with Xcode." };
1837
+ const scheme = selected.build?.scheme || selected.build?.proposedScheme || "";
1838
+ const configuration = selected.build?.configuration || "Debug";
1839
+ onStatus(`Building and installing the iOS app${scheme ? ` (scheme ${scheme})` : ""}…`);
1840
+ const resolved = await resolveAppTarget(root, { cwd: root, onStatus, scheme, configuration });
1841
+ if (resolved.error) return resolved;
1842
+ if (resolved.via) onStatus(`Target ${resolved.bundleId} — ${resolved.via}`);
1843
+ return runQaIos({ bundleId: resolved.bundleId, maxActions, timeout, args: { testEmail, testPassword, baselineFindings, appLaunchArgs, appLaunchEnv }, surface, onProgress });
1844
+ }
1845
+
1738
1846
  function openLocalPort() {
1739
1847
  return new Promise((resolve, reject) => {
1740
1848
  const server = net.createServer();
@@ -1946,7 +2054,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1946
2054
  description:
1947
2055
  "Build the user's iOS app for the simulator from an Xcode project/workspace (auto-detects the " +
1948
2056
  "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 — " +
2057
+ "return the bundle id. Use before tapp_explore / tapp_open_app when the app isn't installed yet — " +
1950
2058
  "no bundle id needed up front.",
1951
2059
  inputSchema: {
1952
2060
  type: "object",
@@ -2068,22 +2176,23 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2068
2176
  },
2069
2177
  },
2070
2178
  {
2071
- name: "tapp_run_qa",
2072
- title: "Run autonomous QA",
2179
+ name: "tapp_explore",
2180
+ title: "Explore (autonomous)",
2073
2181
  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 " +
2182
+ "Autonomously explore iOS (appBundleId), Android (androidAppId), OR a web app " +
2183
+ "(url — beta, requires Playwright installed) and return a structured EXPLORATION OBSERVATION. " +
2184
+ "Exploration OBSERVES it surfaces findings + coverage + evidence; it does NOT render a ship " +
2185
+ "verdict or score. To gate a merge, run the deterministic gate (contracts + baseline via CI). " +
2186
+ "Use when the user wants to find bugs / observe what breaks — this " +
2077
2187
  "runs for MINUTES exploring the whole app. Do NOT use it just to view, screenshot, or reach a specific " +
2078
2188
  "screen — use tapp_open_app (launch + screenshot) or a session for that. Tapp explores the app " +
2079
2189
  "like a tester (taps, types, navigates, scrolls) and detects real issues — crashes, dead buttons, failed sign-ins, error screens, " +
2080
2190
  "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 / " +
2191
+ "Returns {kind:'tapp-exploration-run', headline, inconclusive, screensExplored, " +
2192
+ "actionsPerformed, findingCounts, findings:[{type,severity,category,authority,title,screen}]} NO " +
2193
+ "verdict/releaseScore. Web separates deterministic findings from advisory sampled control probes. " +
2194
+ "There is a coverage floor: if the app barely explored (crash on launch / sign-in wall) it reports " +
2195
+ "`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
2196
  "tapp_boot_simulator first). For web, only point it at an app/environment you own — it CLICKS things. " +
2088
2197
  "Tapp explores autonomously and does NOT pause to prompt for input — " +
2089
2198
  "it fills forms with safe defaults. The result includes `inputFieldsEncountered` (and `inputHint`): if " +
@@ -2138,10 +2247,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2138
2247
  type: "array",
2139
2248
  items: { type: "object" },
2140
2249
  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).",
2250
+ "Findings from a previous run (pass back the `findings` array a prior tapp_explore returned). " +
2251
+ "When provided, the result adds a `regression` COMPARISON {counts:{new,persisting,resolved}, " +
2252
+ "newFindings, resolved} vs. that baseline an observation, not a gate signal. To gate a merge, " +
2253
+ "run `tapp ci` (or the GitHub Action): it applies the deterministic policy and returns the " +
2254
+ "pass/fail/inconclusive outcome.",
2145
2255
  },
2146
2256
  },
2147
2257
  },
@@ -2356,7 +2466,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2356
2466
  description:
2357
2467
  "Replay a deterministic, authored end-to-end test (a Flow) against iOS (XCUITest), Android " +
2358
2468
  "(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 " +
2469
+ "(see docs/flows-architecture.md). Unlike tapp_explore (autonomous exploration), a Flow does EXACTLY " +
2360
2470
  "what you specify, the same way every time — use it for regression tests and verifying a fix. Steps: " +
2361
2471
  "{tap: X} · {type: {field: F, value: V}} · {swipe: up} · {back} · {wait_for: SCREEN}. Assertions " +
2362
2472
  "(deterministic): {assert_screen: X} · {assert_exists: X} · {assert_absent: X} · {assert_text: {of, contains}}. " +
@@ -2411,8 +2521,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2411
2521
  "Write a deterministic E2E Flow from a natural-language goal (e.g. 'sign in and open Settings'), " +
2412
2522
  "GROUNDED in the app's real screens so it can't invent steps. Tapp explores the app to build a " +
2413
2523
  "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 " +
2524
+ "screens/controls that were actually observed. Saves it as an UNTRUSTED PROPOSAL under " +
2525
+ ".tapp/proposals/flows/<name>.yml (never directly into .tapp/flows/) and returns the YAML for " +
2526
+ "review (optionally runs it). Review → replay against the real app → explicitly PROMOTE (move to " +
2527
+ ".tapp/flows/) before CI depends on it. Needs a model backend (Tapp subscription token or " +
2416
2528
  "ANTHROPIC_API_KEY). Use this to bootstrap a test you then refine; use tapp_flow_run to replay it.",
2417
2529
  inputSchema: {
2418
2530
  type: "object",
@@ -2487,7 +2599,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2487
2599
  description:
2488
2600
  "Launch an installed iOS or Android app and return a SCREENSHOT of the screen it lands on " +
2489
2601
  "(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 " +
2602
+ "screen. Use this — NOT tapp_explore — whenever the user wants to view or screenshot a screen. Pass " +
2491
2603
  "appLaunchArgs like [\"--uitesting\"] to bypass login and land on the home screen, and appLaunchEnv for " +
2492
2604
  "a backend override. The app is launched fresh and closed afterward. (To screenshot a screen reached by " +
2493
2605
  "real login or several taps, use a session instead and call tapp_screenshot along the way.)",
@@ -2529,7 +2641,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2529
2641
  name: "tapp_install_app",
2530
2642
  title: "Install app on sim",
2531
2643
  description:
2532
- "Build a target iOS app for the booted simulator and install it, so it's ready for tapp_run_qa or " +
2644
+ "Build a target iOS app for the booted simulator and install it, so it's ready for tapp_explore or " +
2533
2645
  "a session. Provide the Xcode project OR workspace path + scheme. Best-effort — apps with CocoaPods/" +
2534
2646
  "signing quirks may still need their normal build. Returns {ok, installed, simulator}.",
2535
2647
  inputSchema: {
@@ -2679,7 +2791,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2679
2791
  const text =
2680
2792
  `🔨 Built **${path.basename(built.appPath)}** (scheme \`${built.scheme}\`) in ${fmtDuration(Date.now() - startedAt)}` +
2681
2793
  (bundleId ? ` — installed on the simulator as \`${bundleId}\`` : "") +
2682
- `\n\nNext: \`tapp_run_qa\` with \`appBundleId: "${bundleId || "<install it first>"}"\`.`;
2794
+ `\n\nNext: \`tapp_explore\` with \`appBundleId: "${bundleId || "<install it first>"}"\`.`;
2683
2795
  return richResult(text, { ok: true, appPath: built.appPath, scheme: built.scheme, container: built.container, bundleId });
2684
2796
  }
2685
2797
 
@@ -2845,7 +2957,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2845
2957
  return richResult(L.join("\n"), summary);
2846
2958
  }
2847
2959
 
2848
- if (name === "tapp_run_qa") {
2960
+ if (name === "tapp_explore" || name === "tapp_run_qa") { // tapp_run_qa: deprecated alias
2849
2961
  const unauthorized = ensureAuthorized(args);
2850
2962
  if (unauthorized) return unauthorized;
2851
2963
  const wantsWeb = isNonEmptyString(args.url);
@@ -2859,12 +2971,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2859
2971
  // MCP concerns: auth, arg validation, and progress notifications.
2860
2972
  const progressToken = request.params && request.params._meta ? request.params._meta.progressToken : undefined;
2861
2973
  const budget = Math.max(1, Math.min(1000, asInteger(args.maxActions, 60)));
2862
- const notifyProgress = (unit) => (p) => {
2974
+ const notifyProgress = (metric) => (p) => {
2863
2975
  if (progressToken === undefined) return;
2864
2976
  const total = p.max || budget;
2865
2977
  server.notification({
2866
2978
  method: "notifications/progress",
2867
- params: { progressToken, progress: p.action || 0, total, message: `🔍 Exploring… ${p.action}/${total} actions · ${p.states} ${unit} reached` },
2979
+ params: { progressToken, progress: p.action || 0, total, message: `🔍 Exploring… ${p.action}/${total} actions · ${p.states} ${metric}` },
2868
2980
  }).catch(() => {});
2869
2981
  };
2870
2982
  if (wantsWeb) {
@@ -2875,7 +2987,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2875
2987
  testEmail: args.testEmail,
2876
2988
  testPassword: args.testPassword,
2877
2989
  baselineFindings: args.baselineFindings,
2878
- onProgress: notifyProgress("pages"),
2990
+ onProgress: notifyProgress("pages reached"),
2879
2991
  });
2880
2992
  if (r.error) return errorResult(r.error, r.details || {});
2881
2993
  return richResult(r.text, r.structured);
@@ -2892,14 +3004,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2892
3004
  testPassword: args.testPassword,
2893
3005
  baselineFindings: args.baselineFindings,
2894
3006
  clearData: args.clearData !== false,
2895
- onProgress: notifyProgress("screens"),
3007
+ onProgress: notifyProgress("screens reached"),
2896
3008
  });
2897
3009
  if (r.error) return errorResult(r.error, r.details || {});
2898
3010
  return richResult(r.text, r.structured);
2899
3011
  }
2900
3012
 
2901
3013
  let lastProgress = null;
2902
- const iosProgress = notifyProgress("screens");
3014
+ const iosProgress = notifyProgress("structural states observed");
2903
3015
  const r = await runQaIos({
2904
3016
  bundleId: String(args.appBundleId).trim(),
2905
3017
  maxActions: args.maxActions,
@@ -2953,7 +3065,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2953
3065
  const { model, plan, written, exploration } = result;
2954
3066
  const blocking = model.requirements.filter((item) => item.severity === "blocking");
2955
3067
  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)" : ""}` : ""}`;
3068
+ const summary = `🧭 Tapp init — ${model.application.name} · ${model.targets.length} target(s) · UI Map ${model.uiMap.status} (${model.uiMap.nodeCount} states/${model.uiMap.edgeCount} transitions) · ${plan.items.length} plan item(s), ${pending.length} pending · ${blocking.length} blocking requirement(s)${exploration ? ` · real ${exploration.platform} exploration: ${(exploration.findings || []).length} finding(s)${exploration.inconclusive ? " (inconclusive)" : ""}` : ""}`;
2957
3069
  return richResult(summary, { model, plan, written: written ? { modelPath: written.modelPath, planPath: written.planPath } : null, exploration });
2958
3070
  } catch (error) { return errorResult("Could not initialize Tapp repository artifacts", { detail: error.message || String(error) }); }
2959
3071
  }
@@ -3493,21 +3605,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3493
3605
  const parsed = parseGeneratedFlow(mres.text);
3494
3606
  if (!parsed) return errorResult("Model did not return a valid Flow", { raw: mres.text.slice(0, 500) });
3495
3607
 
3496
- // 3) Ground-check + write.
3608
+ // 3) Ground-check + write as an UNTRUSTED PROPOSAL (ADR-0005 decision 8: AI-generated work lands
3609
+ // as a draft under .tapp/proposals/, requires real-target replay + explicit human promotion,
3610
+ // and only then enters .tapp/flows/ where CI depends on it — never a direct write).
3497
3611
  const ungrounded = ungroundedScreens(parsed.steps, grounding);
3498
3612
  const flow = { name: args.name || parsed.name, app: bundleId, steps: parsed.steps };
3499
3613
  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");
3614
+ const dir = path.join(repoRoot, ".tapp", "proposals", "flows");
3501
3615
  fs.mkdirSync(dir, { recursive: true });
3502
3616
  const outPath = path.join(dir, `${slug}.yml`);
3617
+ const promotedPath = path.join(".tapp", "flows", `${slug}.yml`);
3503
3618
  const yamlRes = await runCommand("python3", [path.join(scriptsDir, "flow_lib.py"), "to-yaml", JSON.stringify(flow)], { cwd: repoRoot });
3504
3619
  const yaml = (yamlRes.stdout || "").trim();
3505
3620
  if (!yaml) return errorResult("Failed to render flow YAML", { stderr: yamlRes.stderr });
3506
3621
  fs.writeFileSync(outPath, yaml + "\n");
3507
3622
  const rel = path.relative(repoRoot, outPath);
3508
3623
 
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."}`);
3624
+ const L = [`🤖 Generated a flow **proposal** **${flow.name}** from your goal → \`${rel}\``];
3625
+ 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."}`);
3626
+ 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
3627
  L.push("", "```yaml", yaml, "```");
3512
3628
 
3513
3629
  // 4) Optionally replay it now.
@@ -3522,9 +3638,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3522
3638
  L.push("", "---", "", (rep.stdout || "").trim());
3523
3639
  }
3524
3640
  } else {
3525
- L.push("", `Replay it: \`tapp_flow_run\` with \`flowPath: "${rel}"\`.`);
3641
+ L.push("", `Replay the proposal: \`tapp_flow_run\` with \`flowPath: "${rel}"\`. After it passes, promote it to \`.tapp/flows/\`.`);
3526
3642
  }
3527
- return richResult(L.join("\n"), { path: rel, flow, groundedScreens: grounding.screens.length, ungrounded });
3643
+ return richResult(L.join("\n"), { path: rel, proposal: true, promotePath: promotedPath, flow, groundedScreens: grounding.screens.length, ungrounded });
3528
3644
  }
3529
3645
 
3530
3646
  if (name === "tapp_ui_tree") {
@@ -3592,7 +3708,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3592
3708
  if (isNonEmptyString(args.apkPath)) await driver.install(path.resolve(args.apkPath));
3593
3709
  const snap = await driver.launch({ clearData: args.clearData === true });
3594
3710
  const data = await driver.screenshot();
3595
- await driver.forceStop().catch(() => {});
3596
3711
  return {
3597
3712
  content: [
3598
3713
  { 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"));
@@ -13,7 +13,7 @@ import { compileReleaseContract, loadReleaseContractFile } from "./release-contr
13
13
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
14
14
 
15
15
  function executionHome() {
16
- return process.env.TAPP_HOME || process.env.AUTOTAP_HOME || path.join(os.homedir(), ".tapp");
16
+ return process.env.TAPP_HOME || path.join(os.homedir(), ".tapp");
17
17
  }
18
18
 
19
19
  function atomicJson(destination, value) {
@@ -65,7 +65,7 @@ function artifactPaths(root, outDir = ".tapp") {
65
65
  }
66
66
 
67
67
  function productRunRoot(root) {
68
- const home = process.env.TAPP_HOME || process.env.AUTOTAP_HOME || path.join(os.homedir(), ".tapp");
68
+ const home = process.env.TAPP_HOME || path.join(os.homedir(), ".tapp");
69
69
  const identity = crypto.createHash("sha256").update(root).digest("hex").slice(0, 16);
70
70
  return path.join(home, "product-runs", identity);
71
71
  }
@@ -84,7 +84,7 @@ function listProductRuns(root) {
84
84
  id: entry.name,
85
85
  createdAt: fs.statSync(dir).birthtime.toISOString(),
86
86
  status: report ? "completed" : "incomplete",
87
- verdict: report?.verdict || null,
87
+ outcome: report?.gate?.outcome || null,
88
88
  gate: report?.gate || null,
89
89
  reportPath: fs.existsSync(reportPath) ? reportPath : null,
90
90
  markdownPath: fs.existsSync(markdownPath) ? markdownPath : null,
@@ -1,9 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { LEGACY_TAPP_DIRECTORY, TAPP_DIRECTORY, projectArtifactDirectory } from "./project-paths.js";
3
+ import { TAPP_DIRECTORY, projectArtifactDirectory } from "./project-paths.js";
4
4
 
5
5
  export const PROJECT_CONFIG_RELATIVE_PATH = `${TAPP_DIRECTORY}/project.json`;
6
- export const LEGACY_PROJECT_CONFIG_RELATIVE_PATH = `${LEGACY_TAPP_DIRECTORY}/project.json`;
7
6
 
8
7
  const ACTOR_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
9
8
  const ENV_NAME = /^[A-Z_][A-Z0-9_]{0,127}$/;