@aarwitz/tapp 0.16.4 โ†’ 0.17.0-rc.1

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, 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") {
@@ -1066,7 +1066,7 @@ export async function captureScreenshotImage(maxWidth) {
1066
1066
  // change data-handling behavior. A subscription token is an explicit tapp choice, and
1067
1067
  // explicitly-invoked AI tools (tapp_flow_generate, assert_ai) carry their own consent.
1068
1068
  export function remoteAiOptedIn(env = process.env) {
1069
- if ((env.TAPP_SUBSCRIPTION_TOKEN || env.AUTOTAP_SUBSCRIPTION_TOKEN || "").trim()) return true;
1069
+ if ((env.TAPP_SUBSCRIPTION_TOKEN || "").trim()) return true;
1070
1070
  return ["1", "true", "yes"].includes(String(env.TAPP_ENABLE_REMOTE_AI || "").trim().toLowerCase());
1071
1071
  }
1072
1072
 
@@ -1078,9 +1078,9 @@ export function isInsideDir(root, p) {
1078
1078
  }
1079
1079
 
1080
1080
  function resolveModelBackend() {
1081
- const token = (process.env.TAPP_SUBSCRIPTION_TOKEN || process.env.AUTOTAP_SUBSCRIPTION_TOKEN || "").trim();
1081
+ const token = (process.env.TAPP_SUBSCRIPTION_TOKEN || "").trim();
1082
1082
  if (token) {
1083
- const base = (process.env.TAPP_PROXY_URL || process.env.AUTOTAP_PROXY_URL || "http://localhost:8787").replace(/\/$/, "");
1083
+ const base = (process.env.TAPP_PROXY_URL || "http://localhost:8787").replace(/\/$/, "");
1084
1084
  const url = base.endsWith("/v1/messages") ? base : base + "/v1/messages";
1085
1085
  return { url, headers: { authorization: `Bearer ${token}`, "content-type": "application/json" } };
1086
1086
  }
@@ -1092,7 +1092,7 @@ function resolveModelBackend() {
1092
1092
  }
1093
1093
 
1094
1094
  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 }] });
1095
+ 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
1096
  const res = await fetch(backend.url, { method: "POST", headers: backend.headers, body });
1097
1097
  if (!res.ok) return { error: `model HTTP ${res.status}: ${(await res.text()).slice(0, 300)}` };
1098
1098
  const data = await res.json();
@@ -1307,7 +1307,7 @@ export function qaNextSteps(report, surface = "mcp") {
1307
1307
  if (surface === "cli") {
1308
1308
  const next = [];
1309
1309
  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");
1310
+ next.push("re-run with `--baseline <report.json>` to compare a fix (then `tapp ci` to gate it)");
1311
1311
  next.push("replay a committed journey with `tapp flow run <file>`");
1312
1312
  return next;
1313
1313
  }
@@ -1320,17 +1320,21 @@ export function qaNextSteps(report, surface = "mcp") {
1320
1320
 
1321
1321
  function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiConfigured, reportHtml, recording, uiMap, surface = "mcp" } = {}) {
1322
1322
  const c = report.findingCounts || {};
1323
- const badge = verdictBadge(report);
1323
+ const badge = observationBadge(report);
1324
1324
  const sevBits = ["critical", "high", "medium", "low"]
1325
1325
  .map((k) => (c[k] ? `${SEV[k]} ${c[k]} ${k}` : null))
1326
1326
  .filter(Boolean)
1327
1327
  .join(", ");
1328
1328
  const L = [];
1329
- L.push(`### ๐Ÿงช QA complete โ€” ${badge} ยท release score ${report.confidence}/100${bundleId ? `\n\`${bundleId}\`` : ""}`);
1329
+ // Exploration observes; it does not render a ship verdict. The release decision lives in the gate.
1330
+ L.push(`### ๐Ÿ”ญ Exploration complete โ€” ${badge} ยท ${observationSummary(report)}${bundleId ? `\n\`${bundleId}\`` : ""}`);
1330
1331
  L.push("");
1331
1332
  L.push(report.headline);
1332
1333
  L.push("");
1333
1334
  L.push(`**Coverage** โ€” ${report.screensExplored} screens ยท ${report.actionsPerformed} actions${timedOut ? " ยท โฑ๏ธ hit time limit" : ""}`);
1335
+ if (report.platform === "web") {
1336
+ L.push(`**Deterministic basis** โ€” ${report.deterministicFindingCounts?.total || 0} deterministic finding(s); ${report.sampledFindingCounts?.total || 0} sampled probe finding(s) are advisory`);
1337
+ }
1334
1338
  if (uiMap) L.push(`**UI Map** โ€” ${uiMap.nodeCount} states ยท ${uiMap.edgeCount} transitions ยท ${uiMap.controlCount} semantic controls ยท ${uiMap.path}`);
1335
1339
  if (reportHtml) L.push(`**Evidence** โ€” ๐Ÿ“„ ${reportHtml} (screenshots of every screen + findings, shareable)`);
1336
1340
  if (recording) L.push(`**Recording** โ€” ๐ŸŽฌ ${recording} (full exploration, embedded in the evidence page)`);
@@ -1352,10 +1356,11 @@ function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiC
1352
1356
  }
1353
1357
  }
1354
1358
  if (regression && regression.counts) {
1355
- const g = regression.gate || {};
1359
+ // Exploration reports the comparison (new/persisting/resolved) only โ€” never a gate pass/fail.
1360
+ // The merge decision is the gate's job (tapp ci), not exploration's (ADR-0005).
1356
1361
  L.push("");
1357
1362
  L.push(
1358
- `**Since last run** โ€” +${regression.counts.new} new ยท ${regression.counts.persisting} persisting ยท ${regression.counts.resolved} resolved ยท gate ${g.failed ? "๐Ÿ”ด FAIL" : "๐ŸŸข PASS"}`
1363
+ `**Since last run** โ€” +${regression.counts.new} new ยท ${regression.counts.persisting} persisting ยท ${regression.counts.resolved} resolved (comparison only โ€” run \`tapp ci\` to gate)`
1359
1364
  );
1360
1365
  }
1361
1366
  if (inputHint) {
@@ -1687,8 +1692,8 @@ export async function runInitExploration({
1687
1692
  merged.provenance.lastRun = {
1688
1693
  id: qa.structured.capture?.id || observed.provenance?.runIds?.at(-1) || "",
1689
1694
  platform: selected,
1690
- verdict: qa.structured.verdict,
1691
1695
  inconclusive: qa.structured.inconclusive === true,
1696
+ findingCount: qa.structured.findingCounts?.total ?? 0,
1692
1697
  statesExplored: Number(qa.structured.screensExplored || merged.nodes.length),
1693
1698
  actionsPerformed: Number(qa.structured.actionsPerformed || 0),
1694
1699
  observedAt: observed.provenance?.lastObservedAt || new Date().toISOString(),
@@ -1704,8 +1709,8 @@ export async function runInitExploration({
1704
1709
  resolution: targetResolution,
1705
1710
  evidence: {
1706
1711
  captureId: qa.structured.capture?.id || "",
1707
- verdict: qa.structured.verdict,
1708
1712
  inconclusive: qa.structured.inconclusive === true,
1713
+ findingCount: qa.structured.findingCounts?.total ?? 0,
1709
1714
  observedAt: observed.provenance?.lastObservedAt || new Date().toISOString(),
1710
1715
  },
1711
1716
  },
@@ -1718,7 +1723,6 @@ export async function runInitExploration({
1718
1723
  controlCount: merged.nodes.reduce((total, node) => total + node.controls.length, 0),
1719
1724
  },
1720
1725
  mapDiff: previous ? diffUiMaps(previous, observed, { comparableFullSweep: false }) : null,
1721
- verdict: qa.structured.verdict,
1722
1726
  inconclusive: qa.structured.inconclusive === true,
1723
1727
  findings: qa.structured.findings || [],
1724
1728
  capture: qa.structured.capture,
@@ -1732,6 +1736,91 @@ export async function runInitExploration({
1732
1736
  }
1733
1737
  }
1734
1738
 
1739
+ /**
1740
+ * Source-preparing bare explore (ADR-0005 ยง5): a `tapp explore` with no explicit target in a
1741
+ * repository with an application model. Selects the model's default target and prepares it from
1742
+ * source before exploring โ€” managed web is built/started/waited-for and always stopped again,
1743
+ * iOS is built + installed on the simulator, Android is built to an APK + installed โ€” then runs
1744
+ * the ordinary exploration engine. Returns the same `{ text, structured, error }` shape as the
1745
+ * direct runQa* entrypoints, so the CLI/MCP surfaces print it unchanged. This is an OBSERVATION:
1746
+ * no verdict, no UI-map write (that is `runInitExploration`'s job); the gate still judges.
1747
+ */
1748
+ export async function runExploreTarget({
1749
+ projectDir,
1750
+ platform = "",
1751
+ target = "",
1752
+ maxActions,
1753
+ timeout,
1754
+ testEmail,
1755
+ testPassword,
1756
+ baselineFindings,
1757
+ surface = "cli",
1758
+ onProgress = () => {},
1759
+ onStatus = () => {},
1760
+ } = {}) {
1761
+ let root;
1762
+ try { root = fs.realpathSync(path.resolve(projectDir || process.cwd())); }
1763
+ catch { return { error: `Repository directory not found: ${projectDir || process.cwd()}` }; }
1764
+
1765
+ const modelPath = existingProjectArtifactPath(root, "application-model.json");
1766
+ if (!fs.existsSync(modelPath)) {
1767
+ 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)." };
1768
+ }
1769
+ let model;
1770
+ try { model = JSON.parse(fs.readFileSync(modelPath, "utf8")); }
1771
+ catch (error) { return { error: `Application model is unreadable: ${error.message || String(error)}` }; }
1772
+
1773
+ const { selectApplicationTarget } = await import("./ci-setup.js");
1774
+ let selected;
1775
+ try { selected = selectApplicationTarget(model, { platform, target, useDefault: true }); }
1776
+ catch (error) { return { error: error.message || String(error) }; }
1777
+ const selectedPlatform = selected.platform;
1778
+
1779
+ if (selectedPlatform === "web") {
1780
+ const ownedUrl = String(selected.runtime?.ownedUrl || "").trim();
1781
+ if (/^https?:\/\//i.test(ownedUrl)) {
1782
+ onStatus(`Exploring the owned URL from the application model: ${ownedUrl}`);
1783
+ return runQaWeb({ url: ownedUrl, maxActions, timeout, testEmail, testPassword, baselineFindings, surface, onProgress });
1784
+ }
1785
+ // Tapp-managed: build/start the repo's web target, wait for readiness, and ALWAYS stop it.
1786
+ onStatus(`Preparing the managed web runtime for ${selected.name}โ€ฆ`);
1787
+ const started = await startManagedWebTarget({ root, requestedTarget: selected.sourcePath || selected.name || "", timeout, onStatus });
1788
+ if (started.error) return started;
1789
+ try {
1790
+ return await runQaWeb({ url: started.url, maxActions, timeout, testEmail, testPassword, baselineFindings, surface, onProgress });
1791
+ } finally {
1792
+ await stopManagedWebTarget(started);
1793
+ onStatus("Stopped the managed web runtime.");
1794
+ }
1795
+ }
1796
+
1797
+ if (selectedPlatform === "android") {
1798
+ const appId = String(selected.runtime?.applicationId || "").trim();
1799
+ if (!appId) return { error: `The Android target '${selected.name}' has no confirmed application id โ€” confirm it and rerun \`tapp init\`, or pass --app-id.` };
1800
+ const task = selected.build?.task || "assembleDebug";
1801
+ onStatus(`Building the Android APK (${task})โ€ฆ`);
1802
+ const built = await buildAndroidApp({
1803
+ projectDir: root,
1804
+ gradleProjectDir: path.resolve(root, selected.build?.projectDir || "."),
1805
+ moduleDir: path.resolve(root, selected.sourcePath || "."),
1806
+ task,
1807
+ });
1808
+ if (built.error) return built;
1809
+ onStatus(`Installing and exploring ${appId}โ€ฆ`);
1810
+ return runQaAndroid({ appId, apkPath: built.apkPath, maxActions, timeout, testEmail, testPassword, baselineFindings, surface, onProgress });
1811
+ }
1812
+
1813
+ // iOS
1814
+ if (process.platform !== "darwin") return { error: "iOS exploration requires macOS with Xcode." };
1815
+ const scheme = selected.build?.scheme || selected.build?.proposedScheme || "";
1816
+ const configuration = selected.build?.configuration || "Debug";
1817
+ onStatus(`Building and installing the iOS app${scheme ? ` (scheme ${scheme})` : ""}โ€ฆ`);
1818
+ const resolved = await resolveAppTarget(root, { cwd: root, onStatus, scheme, configuration });
1819
+ if (resolved.error) return resolved;
1820
+ if (resolved.via) onStatus(`Target ${resolved.bundleId} โ€” ${resolved.via}`);
1821
+ return runQaIos({ bundleId: resolved.bundleId, maxActions, timeout, args: { testEmail, testPassword, baselineFindings }, surface, onProgress });
1822
+ }
1823
+
1735
1824
  function openLocalPort() {
1736
1825
  return new Promise((resolve, reject) => {
1737
1826
  const server = net.createServer();
@@ -1943,7 +2032,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1943
2032
  description:
1944
2033
  "Build the user's iOS app for the simulator from an Xcode project/workspace (auto-detects the " +
1945
2034
  "container and scheme under projectDir, default cwd), install it on the booted simulator, and " +
1946
- "return the bundle id. Use before tapp_run_qa / tapp_open_app when the app isn't installed yet โ€” " +
2035
+ "return the bundle id. Use before tapp_explore / tapp_open_app when the app isn't installed yet โ€” " +
1947
2036
  "no bundle id needed up front.",
1948
2037
  inputSchema: {
1949
2038
  type: "object",
@@ -2065,20 +2154,23 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2065
2154
  },
2066
2155
  },
2067
2156
  {
2068
- name: "tapp_run_qa",
2069
- title: "Run autonomous QA",
2157
+ name: "tapp_explore",
2158
+ title: "Explore (autonomous)",
2070
2159
  description:
2071
- "Run autonomous QA against iOS (appBundleId), Android (androidAppId), OR a web app " +
2072
- "(url โ€” beta, requires Playwright installed) and return a structured " +
2073
- "ship/no-ship verdict. Use ONLY when the user wants a QA assessment / to find bugs / a verdict โ€” this " +
2160
+ "Autonomously explore iOS (appBundleId), Android (androidAppId), OR a web app " +
2161
+ "(url โ€” beta, requires Playwright installed) and return a structured EXPLORATION OBSERVATION. " +
2162
+ "Exploration OBSERVES โ€” it surfaces findings + coverage + evidence; it does NOT render a ship " +
2163
+ "verdict or score. To gate a merge, run the deterministic gate (contracts + baseline via CI). " +
2164
+ "Use when the user wants to find bugs / observe what breaks โ€” this " +
2074
2165
  "runs for MINUTES exploring the whole app. Do NOT use it just to view, screenshot, or reach a specific " +
2075
2166
  "screen โ€” use tapp_open_app (launch + screenshot) or a session for that. Tapp explores the app " +
2076
2167
  "like a tester (taps, types, navigates, scrolls) and detects real issues โ€” crashes, dead buttons, failed sign-ins, error screens, " +
2077
2168
  "stuck/hung screens; on web also uncaught JS exceptions, failed/5xx requests, broken links and assets. " +
2078
- "Returns {verdict: ready|caution|blocked, confidence, headline, screensExplored, " +
2079
- "actionsPerformed, findings:[{type,severity,category,title,screen}]}. The verdict has a coverage floor: " +
2080
- "if the app barely explored (crash on launch / sign-in wall) it returns 'caution' + inconclusive, never a " +
2081
- "false pass. For iOS the app must already be installed on a booted simulator (use tapp_list_simulators / " +
2169
+ "Returns {kind:'tapp-exploration-run', headline, inconclusive, screensExplored, " +
2170
+ "actionsPerformed, findingCounts, findings:[{type,severity,category,authority,title,screen}]} โ€” NO " +
2171
+ "verdict/releaseScore. Web separates deterministic findings from advisory sampled control probes. " +
2172
+ "There is a coverage floor: if the app barely explored (crash on launch / sign-in wall) it reports " +
2173
+ "`inconclusive` โ€” absence of findings is NEVER a pass. For iOS the app must already be installed on a booted simulator (use tapp_list_simulators / " +
2082
2174
  "tapp_boot_simulator first). For web, only point it at an app/environment you own โ€” it CLICKS things. " +
2083
2175
  "Tapp explores autonomously and does NOT pause to prompt for input โ€” " +
2084
2176
  "it fills forms with safe defaults. The result includes `inputFieldsEncountered` (and `inputHint`): if " +
@@ -2133,10 +2225,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2133
2225
  type: "array",
2134
2226
  items: { type: "object" },
2135
2227
  description:
2136
- "Findings from a previous run (pass back the `findings` array a prior tapp_run_qa returned). " +
2137
- "When provided, the result adds `regression` {counts:{new,persisting,resolved}, newFindings, resolved, " +
2138
- "gate:{newHigh,newCritical,failed}} comparing this run to that baseline. For a CI gate: store the " +
2139
- "baseline once, then fail the build when regression.gate.failed is true (new high/critical introduced).",
2228
+ "Findings from a previous run (pass back the `findings` array a prior tapp_explore returned). " +
2229
+ "When provided, the result adds a `regression` COMPARISON {counts:{new,persisting,resolved}, " +
2230
+ "newFindings, resolved} vs. that baseline โ€” an observation, not a gate signal. To gate a merge, " +
2231
+ "run `tapp ci` (or the GitHub Action): it applies the deterministic policy and returns the " +
2232
+ "pass/fail/inconclusive outcome.",
2140
2233
  },
2141
2234
  },
2142
2235
  },
@@ -2351,7 +2444,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2351
2444
  description:
2352
2445
  "Replay a deterministic, authored end-to-end test (a Flow) against iOS (XCUITest), Android " +
2353
2446
  "(ADB/UIAutomator), or web (Playwright), and return a scannable pass/fail report. A Flow is a list of steps + assertions " +
2354
- "(see docs/flows-architecture.md). Unlike tapp_run_qa (autonomous exploration), a Flow does EXACTLY " +
2447
+ "(see docs/flows-architecture.md). Unlike tapp_explore (autonomous exploration), a Flow does EXACTLY " +
2355
2448
  "what you specify, the same way every time โ€” use it for regression tests and verifying a fix. Steps: " +
2356
2449
  "{tap: X} ยท {type: {field: F, value: V}} ยท {swipe: up} ยท {back} ยท {wait_for: SCREEN}. Assertions " +
2357
2450
  "(deterministic): {assert_screen: X} ยท {assert_exists: X} ยท {assert_absent: X} ยท {assert_text: {of, contains}}. " +
@@ -2406,8 +2499,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2406
2499
  "Write a deterministic E2E Flow from a natural-language goal (e.g. 'sign in and open Settings'), " +
2407
2500
  "GROUNDED in the app's real screens so it can't invent steps. Tapp explores the app to build a " +
2408
2501
  "screen/control map (or reuses a recent run via captureId), then a model authors a Flow using only " +
2409
- "screens/controls that were actually observed. Saves it to .tapp/flows/<name>.yml and returns the " +
2410
- "YAML for review (optionally runs it). Needs a model backend (Tapp subscription token or " +
2502
+ "screens/controls that were actually observed. Saves it as an UNTRUSTED PROPOSAL under " +
2503
+ ".tapp/proposals/flows/<name>.yml (never directly into .tapp/flows/) and returns the YAML for " +
2504
+ "review (optionally runs it). Review โ†’ replay against the real app โ†’ explicitly PROMOTE (move to " +
2505
+ ".tapp/flows/) before CI depends on it. Needs a model backend (Tapp subscription token or " +
2411
2506
  "ANTHROPIC_API_KEY). Use this to bootstrap a test you then refine; use tapp_flow_run to replay it.",
2412
2507
  inputSchema: {
2413
2508
  type: "object",
@@ -2482,7 +2577,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2482
2577
  description:
2483
2578
  "Launch an installed iOS or Android app and return a SCREENSHOT of the screen it lands on " +
2484
2579
  "(plus the accessibility tree) โ€” with NO exploration. This is the fast way (seconds) to just SEE a " +
2485
- "screen. Use this โ€” NOT tapp_run_qa โ€” whenever the user wants to view or screenshot a screen. Pass " +
2580
+ "screen. Use this โ€” NOT tapp_explore โ€” whenever the user wants to view or screenshot a screen. Pass " +
2486
2581
  "appLaunchArgs like [\"--uitesting\"] to bypass login and land on the home screen, and appLaunchEnv for " +
2487
2582
  "a backend override. The app is launched fresh and closed afterward. (To screenshot a screen reached by " +
2488
2583
  "real login or several taps, use a session instead and call tapp_screenshot along the way.)",
@@ -2524,7 +2619,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2524
2619
  name: "tapp_install_app",
2525
2620
  title: "Install app on sim",
2526
2621
  description:
2527
- "Build a target iOS app for the booted simulator and install it, so it's ready for tapp_run_qa or " +
2622
+ "Build a target iOS app for the booted simulator and install it, so it's ready for tapp_explore or " +
2528
2623
  "a session. Provide the Xcode project OR workspace path + scheme. Best-effort โ€” apps with CocoaPods/" +
2529
2624
  "signing quirks may still need their normal build. Returns {ok, installed, simulator}.",
2530
2625
  inputSchema: {
@@ -2674,7 +2769,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2674
2769
  const text =
2675
2770
  `๐Ÿ”จ Built **${path.basename(built.appPath)}** (scheme \`${built.scheme}\`) in ${fmtDuration(Date.now() - startedAt)}` +
2676
2771
  (bundleId ? ` โ€” installed on the simulator as \`${bundleId}\`` : "") +
2677
- `\n\nNext: \`tapp_run_qa\` with \`appBundleId: "${bundleId || "<install it first>"}"\`.`;
2772
+ `\n\nNext: \`tapp_explore\` with \`appBundleId: "${bundleId || "<install it first>"}"\`.`;
2678
2773
  return richResult(text, { ok: true, appPath: built.appPath, scheme: built.scheme, container: built.container, bundleId });
2679
2774
  }
2680
2775
 
@@ -2840,7 +2935,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2840
2935
  return richResult(L.join("\n"), summary);
2841
2936
  }
2842
2937
 
2843
- if (name === "tapp_run_qa") {
2938
+ if (name === "tapp_explore" || name === "tapp_run_qa") { // tapp_run_qa: deprecated alias
2844
2939
  const unauthorized = ensureAuthorized(args);
2845
2940
  if (unauthorized) return unauthorized;
2846
2941
  const wantsWeb = isNonEmptyString(args.url);
@@ -2948,7 +3043,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2948
3043
  const { model, plan, written, exploration } = result;
2949
3044
  const blocking = model.requirements.filter((item) => item.severity === "blocking");
2950
3045
  const pending = plan.items.filter((item) => item.decision === "pending");
2951
- 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)" : ""}` : ""}`;
3046
+ 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)" : ""}` : ""}`;
2952
3047
  return richResult(summary, { model, plan, written: written ? { modelPath: written.modelPath, planPath: written.planPath } : null, exploration });
2953
3048
  } catch (error) { return errorResult("Could not initialize Tapp repository artifacts", { detail: error.message || String(error) }); }
2954
3049
  }
@@ -3488,21 +3583,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3488
3583
  const parsed = parseGeneratedFlow(mres.text);
3489
3584
  if (!parsed) return errorResult("Model did not return a valid Flow", { raw: mres.text.slice(0, 500) });
3490
3585
 
3491
- // 3) Ground-check + write.
3586
+ // 3) Ground-check + write as an UNTRUSTED PROPOSAL (ADR-0005 decision 8: AI-generated work lands
3587
+ // as a draft under .tapp/proposals/, requires real-target replay + explicit human promotion,
3588
+ // and only then enters .tapp/flows/ where CI depends on it โ€” never a direct write).
3492
3589
  const ungrounded = ungroundedScreens(parsed.steps, grounding);
3493
3590
  const flow = { name: args.name || parsed.name, app: bundleId, steps: parsed.steps };
3494
3591
  const slug = flow.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "generated-flow";
3495
- const dir = path.join(repoRoot, ".tapp", "flows");
3592
+ const dir = path.join(repoRoot, ".tapp", "proposals", "flows");
3496
3593
  fs.mkdirSync(dir, { recursive: true });
3497
3594
  const outPath = path.join(dir, `${slug}.yml`);
3595
+ const promotedPath = path.join(".tapp", "flows", `${slug}.yml`);
3498
3596
  const yamlRes = await runCommand("python3", [path.join(scriptsDir, "flow_lib.py"), "to-yaml", JSON.stringify(flow)], { cwd: repoRoot });
3499
3597
  const yaml = (yamlRes.stdout || "").trim();
3500
3598
  if (!yaml) return errorResult("Failed to render flow YAML", { stderr: yamlRes.stderr });
3501
3599
  fs.writeFileSync(outPath, yaml + "\n");
3502
3600
  const rel = path.relative(repoRoot, outPath);
3503
3601
 
3504
- const L = [`๐Ÿค– Generated flow **${flow.name}** from your goal โ†’ \`${rel}\``];
3505
- 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."}`);
3602
+ const L = [`๐Ÿค– Generated a flow **proposal** **${flow.name}** from your goal โ†’ \`${rel}\``];
3603
+ 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."}`);
3604
+ 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.`);
3506
3605
  L.push("", "```yaml", yaml, "```");
3507
3606
 
3508
3607
  // 4) Optionally replay it now.
@@ -3517,9 +3616,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3517
3616
  L.push("", "---", "", (rep.stdout || "").trim());
3518
3617
  }
3519
3618
  } else {
3520
- L.push("", `Replay it: \`tapp_flow_run\` with \`flowPath: "${rel}"\`.`);
3619
+ L.push("", `Replay the proposal: \`tapp_flow_run\` with \`flowPath: "${rel}"\`. After it passes, promote it to \`.tapp/flows/\`.`);
3521
3620
  }
3522
- return richResult(L.join("\n"), { path: rel, flow, groundedScreens: grounding.screens.length, ungrounded });
3621
+ return richResult(L.join("\n"), { path: rel, proposal: true, promotePath: promotedPath, flow, groundedScreens: grounding.screens.length, ungrounded });
3523
3622
  }
3524
3623
 
3525
3624
  if (name === "tapp_ui_tree") {
@@ -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}$/;
@@ -1,32 +1,20 @@
1
- import fs from "node:fs";
2
1
  import path from "node:path";
3
2
 
4
3
  export const TAPP_DIRECTORY = ".tapp";
5
- export const LEGACY_TAPP_DIRECTORY = ".autotap";
6
4
  export const TAPP_CONFIG = ".tapp.yml";
7
- export const LEGACY_TAPP_CONFIG = ".autotap.yml";
8
5
 
9
- export function projectArtifactDirectory(projectDir, requested = TAPP_DIRECTORY) {
10
- const root = path.resolve(projectDir);
11
- if (requested !== TAPP_DIRECTORY) return requested;
12
- const canonical = path.join(root, TAPP_DIRECTORY);
13
- const legacy = path.join(root, LEGACY_TAPP_DIRECTORY);
14
- if (!fs.existsSync(canonical) && fs.existsSync(legacy)) return LEGACY_TAPP_DIRECTORY;
15
- return TAPP_DIRECTORY;
6
+ export function projectArtifactDirectory(_projectDir, requested = TAPP_DIRECTORY) {
7
+ return requested;
16
8
  }
17
9
 
18
10
  export function projectArtifactPath(projectDir, ...parts) {
19
- return path.join(path.resolve(projectDir), projectArtifactDirectory(projectDir), ...parts);
11
+ return path.join(path.resolve(projectDir), TAPP_DIRECTORY, ...parts);
20
12
  }
21
13
 
22
14
  export function existingProjectArtifactPath(projectDir, ...parts) {
23
- const root = path.resolve(projectDir);
24
- const canonical = path.join(root, TAPP_DIRECTORY, ...parts);
25
- if (fs.existsSync(canonical)) return canonical;
26
- const legacy = path.join(root, LEGACY_TAPP_DIRECTORY, ...parts);
27
- return fs.existsSync(legacy) ? legacy : canonical;
15
+ return path.join(path.resolve(projectDir), TAPP_DIRECTORY, ...parts);
28
16
  }
29
17
 
30
18
  export function isProjectArtifactDirectory(name) {
31
- return name === TAPP_DIRECTORY || name === LEGACY_TAPP_DIRECTORY;
19
+ return name === TAPP_DIRECTORY;
32
20
  }
@@ -11,8 +11,8 @@ import { semanticUiKey } from "./ui-map.js";
11
11
 
12
12
  const PLATFORMS = new Set(["ios", "android", "web"]);
13
13
  const CRITICALITIES = new Set(["low", "medium", "high", "critical"]);
14
- const CONTRACT_AUTHORING_SPECIFIERS = new Set(["@aarwitz/tapp/contracts", "runtapp/contracts", "tapp-mcp/contracts"]);
15
- const CONTRACT_AUTHORING_IMPORT = /(["'])(?:@aarwitz\/tapp|runtapp|tapp-mcp)\/contracts\1/g;
14
+ const CONTRACT_AUTHORING_SPECIFIERS = new Set(["@aarwitz/tapp/contracts"]);
15
+ const CONTRACT_AUTHORING_IMPORT = /(["'])@aarwitz\/tapp\/contracts\1/g;
16
16
  const authoringUrl = pathToFileURL(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "contract-authoring.js")).href;
17
17
 
18
18
  function expectationAction(expectation) {
@@ -86,7 +86,7 @@ function rewriteAuthoringImport(source, contractPath) {
86
86
  }
87
87
  const imports = [...source.matchAll(/(?:from\s*|import\s*)["']([^"']+)["']/g)].map((match) => match[1]);
88
88
  const unsupported = imports.filter((specifier) => !CONTRACT_AUTHORING_SPECIFIERS.has(specifier));
89
- if (unsupported.length) throw new Error(`Release contract imports are limited to @aarwitz/tapp/contracts (legacy runtapp/contracts and tapp-mcp/contracts are also accepted; found ${unsupported.join(", ")})`);
89
+ if (unsupported.length) throw new Error(`Release contract imports are limited to @aarwitz/tapp/contracts (found ${unsupported.join(", ")})`);
90
90
  const output = ts.transpileModule(source, {
91
91
  fileName: contractPath,
92
92
  compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022, verbatimModuleSyntax: true },