@aarwitz/tapp 0.16.5 โ†’ 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, 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") {
@@ -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,19 +1320,20 @@ 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} ยท ${qaScoreLabel(report)}${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" : ""}`);
1334
1335
  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`);
1336
+ L.push(`**Deterministic basis** โ€” ${report.deterministicFindingCounts?.total || 0} deterministic finding(s); ${report.sampledFindingCounts?.total || 0} sampled probe finding(s) are advisory`);
1336
1337
  }
1337
1338
  if (uiMap) L.push(`**UI Map** โ€” ${uiMap.nodeCount} states ยท ${uiMap.edgeCount} transitions ยท ${uiMap.controlCount} semantic controls ยท ${uiMap.path}`);
1338
1339
  if (reportHtml) L.push(`**Evidence** โ€” ๐Ÿ“„ ${reportHtml} (screenshots of every screen + findings, shareable)`);
@@ -1355,10 +1356,11 @@ function formatQaReport(report, { regression, inputHint, timedOut, bundleId, aiC
1355
1356
  }
1356
1357
  }
1357
1358
  if (regression && regression.counts) {
1358
- 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).
1359
1361
  L.push("");
1360
1362
  L.push(
1361
- `**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)`
1362
1364
  );
1363
1365
  }
1364
1366
  if (inputHint) {
@@ -1690,8 +1692,8 @@ export async function runInitExploration({
1690
1692
  merged.provenance.lastRun = {
1691
1693
  id: qa.structured.capture?.id || observed.provenance?.runIds?.at(-1) || "",
1692
1694
  platform: selected,
1693
- verdict: qa.structured.verdict,
1694
1695
  inconclusive: qa.structured.inconclusive === true,
1696
+ findingCount: qa.structured.findingCounts?.total ?? 0,
1695
1697
  statesExplored: Number(qa.structured.screensExplored || merged.nodes.length),
1696
1698
  actionsPerformed: Number(qa.structured.actionsPerformed || 0),
1697
1699
  observedAt: observed.provenance?.lastObservedAt || new Date().toISOString(),
@@ -1707,8 +1709,8 @@ export async function runInitExploration({
1707
1709
  resolution: targetResolution,
1708
1710
  evidence: {
1709
1711
  captureId: qa.structured.capture?.id || "",
1710
- verdict: qa.structured.verdict,
1711
1712
  inconclusive: qa.structured.inconclusive === true,
1713
+ findingCount: qa.structured.findingCounts?.total ?? 0,
1712
1714
  observedAt: observed.provenance?.lastObservedAt || new Date().toISOString(),
1713
1715
  },
1714
1716
  },
@@ -1721,7 +1723,6 @@ export async function runInitExploration({
1721
1723
  controlCount: merged.nodes.reduce((total, node) => total + node.controls.length, 0),
1722
1724
  },
1723
1725
  mapDiff: previous ? diffUiMaps(previous, observed, { comparableFullSweep: false }) : null,
1724
- verdict: qa.structured.verdict,
1725
1726
  inconclusive: qa.structured.inconclusive === true,
1726
1727
  findings: qa.structured.findings || [],
1727
1728
  capture: qa.structured.capture,
@@ -1735,6 +1736,91 @@ export async function runInitExploration({
1735
1736
  }
1736
1737
  }
1737
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
+
1738
1824
  function openLocalPort() {
1739
1825
  return new Promise((resolve, reject) => {
1740
1826
  const server = net.createServer();
@@ -1946,7 +2032,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1946
2032
  description:
1947
2033
  "Build the user's iOS app for the simulator from an Xcode project/workspace (auto-detects the " +
1948
2034
  "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 โ€” " +
2035
+ "return the bundle id. Use before tapp_explore / tapp_open_app when the app isn't installed yet โ€” " +
1950
2036
  "no bundle id needed up front.",
1951
2037
  inputSchema: {
1952
2038
  type: "object",
@@ -2068,22 +2154,23 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2068
2154
  },
2069
2155
  },
2070
2156
  {
2071
- name: "tapp_run_qa",
2072
- title: "Run autonomous QA",
2157
+ name: "tapp_explore",
2158
+ title: "Explore (autonomous)",
2073
2159
  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 " +
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 " +
2077
2165
  "runs for MINUTES exploring the whole app. Do NOT use it just to view, screenshot, or reach a specific " +
2078
2166
  "screen โ€” use tapp_open_app (launch + screenshot) or a session for that. Tapp explores the app " +
2079
2167
  "like a tester (taps, types, navigates, scrolls) and detects real issues โ€” crashes, dead buttons, failed sign-ins, error screens, " +
2080
2168
  "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 / " +
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 / " +
2087
2174
  "tapp_boot_simulator first). For web, only point it at an app/environment you own โ€” it CLICKS things. " +
2088
2175
  "Tapp explores autonomously and does NOT pause to prompt for input โ€” " +
2089
2176
  "it fills forms with safe defaults. The result includes `inputFieldsEncountered` (and `inputHint`): if " +
@@ -2138,10 +2225,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2138
2225
  type: "array",
2139
2226
  items: { type: "object" },
2140
2227
  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).",
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.",
2145
2233
  },
2146
2234
  },
2147
2235
  },
@@ -2356,7 +2444,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2356
2444
  description:
2357
2445
  "Replay a deterministic, authored end-to-end test (a Flow) against iOS (XCUITest), Android " +
2358
2446
  "(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 " +
2447
+ "(see docs/flows-architecture.md). Unlike tapp_explore (autonomous exploration), a Flow does EXACTLY " +
2360
2448
  "what you specify, the same way every time โ€” use it for regression tests and verifying a fix. Steps: " +
2361
2449
  "{tap: X} ยท {type: {field: F, value: V}} ยท {swipe: up} ยท {back} ยท {wait_for: SCREEN}. Assertions " +
2362
2450
  "(deterministic): {assert_screen: X} ยท {assert_exists: X} ยท {assert_absent: X} ยท {assert_text: {of, contains}}. " +
@@ -2411,8 +2499,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2411
2499
  "Write a deterministic E2E Flow from a natural-language goal (e.g. 'sign in and open Settings'), " +
2412
2500
  "GROUNDED in the app's real screens so it can't invent steps. Tapp explores the app to build a " +
2413
2501
  "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 " +
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 " +
2416
2506
  "ANTHROPIC_API_KEY). Use this to bootstrap a test you then refine; use tapp_flow_run to replay it.",
2417
2507
  inputSchema: {
2418
2508
  type: "object",
@@ -2487,7 +2577,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2487
2577
  description:
2488
2578
  "Launch an installed iOS or Android app and return a SCREENSHOT of the screen it lands on " +
2489
2579
  "(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 " +
2580
+ "screen. Use this โ€” NOT tapp_explore โ€” whenever the user wants to view or screenshot a screen. Pass " +
2491
2581
  "appLaunchArgs like [\"--uitesting\"] to bypass login and land on the home screen, and appLaunchEnv for " +
2492
2582
  "a backend override. The app is launched fresh and closed afterward. (To screenshot a screen reached by " +
2493
2583
  "real login or several taps, use a session instead and call tapp_screenshot along the way.)",
@@ -2529,7 +2619,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2529
2619
  name: "tapp_install_app",
2530
2620
  title: "Install app on sim",
2531
2621
  description:
2532
- "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 " +
2533
2623
  "a session. Provide the Xcode project OR workspace path + scheme. Best-effort โ€” apps with CocoaPods/" +
2534
2624
  "signing quirks may still need their normal build. Returns {ok, installed, simulator}.",
2535
2625
  inputSchema: {
@@ -2679,7 +2769,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2679
2769
  const text =
2680
2770
  `๐Ÿ”จ Built **${path.basename(built.appPath)}** (scheme \`${built.scheme}\`) in ${fmtDuration(Date.now() - startedAt)}` +
2681
2771
  (bundleId ? ` โ€” installed on the simulator as \`${bundleId}\`` : "") +
2682
- `\n\nNext: \`tapp_run_qa\` with \`appBundleId: "${bundleId || "<install it first>"}"\`.`;
2772
+ `\n\nNext: \`tapp_explore\` with \`appBundleId: "${bundleId || "<install it first>"}"\`.`;
2683
2773
  return richResult(text, { ok: true, appPath: built.appPath, scheme: built.scheme, container: built.container, bundleId });
2684
2774
  }
2685
2775
 
@@ -2845,7 +2935,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2845
2935
  return richResult(L.join("\n"), summary);
2846
2936
  }
2847
2937
 
2848
- if (name === "tapp_run_qa") {
2938
+ if (name === "tapp_explore" || name === "tapp_run_qa") { // tapp_run_qa: deprecated alias
2849
2939
  const unauthorized = ensureAuthorized(args);
2850
2940
  if (unauthorized) return unauthorized;
2851
2941
  const wantsWeb = isNonEmptyString(args.url);
@@ -2953,7 +3043,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2953
3043
  const { model, plan, written, exploration } = result;
2954
3044
  const blocking = model.requirements.filter((item) => item.severity === "blocking");
2955
3045
  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)" : ""}` : ""}`;
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)" : ""}` : ""}`;
2957
3047
  return richResult(summary, { model, plan, written: written ? { modelPath: written.modelPath, planPath: written.planPath } : null, exploration });
2958
3048
  } catch (error) { return errorResult("Could not initialize Tapp repository artifacts", { detail: error.message || String(error) }); }
2959
3049
  }
@@ -3493,21 +3583,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3493
3583
  const parsed = parseGeneratedFlow(mres.text);
3494
3584
  if (!parsed) return errorResult("Model did not return a valid Flow", { raw: mres.text.slice(0, 500) });
3495
3585
 
3496
- // 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).
3497
3589
  const ungrounded = ungroundedScreens(parsed.steps, grounding);
3498
3590
  const flow = { name: args.name || parsed.name, app: bundleId, steps: parsed.steps };
3499
3591
  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");
3592
+ const dir = path.join(repoRoot, ".tapp", "proposals", "flows");
3501
3593
  fs.mkdirSync(dir, { recursive: true });
3502
3594
  const outPath = path.join(dir, `${slug}.yml`);
3595
+ const promotedPath = path.join(".tapp", "flows", `${slug}.yml`);
3503
3596
  const yamlRes = await runCommand("python3", [path.join(scriptsDir, "flow_lib.py"), "to-yaml", JSON.stringify(flow)], { cwd: repoRoot });
3504
3597
  const yaml = (yamlRes.stdout || "").trim();
3505
3598
  if (!yaml) return errorResult("Failed to render flow YAML", { stderr: yamlRes.stderr });
3506
3599
  fs.writeFileSync(outPath, yaml + "\n");
3507
3600
  const rel = path.relative(repoRoot, outPath);
3508
3601
 
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."}`);
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.`);
3511
3605
  L.push("", "```yaml", yaml, "```");
3512
3606
 
3513
3607
  // 4) Optionally replay it now.
@@ -3522,9 +3616,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3522
3616
  L.push("", "---", "", (rep.stdout || "").trim());
3523
3617
  }
3524
3618
  } else {
3525
- 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/\`.`);
3526
3620
  }
3527
- 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 });
3528
3622
  }
3529
3623
 
3530
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 },