@aarwitz/tapp 0.15.1 → 0.16.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.
package/AGENTS.md CHANGED
@@ -18,7 +18,7 @@ npx -y @aarwitz/tapp shot # screenshot the booted sim → file path (
18
18
  npx -y @aarwitz/tapp apps # what's installed on the simulator, with bundle ids
19
19
  npx -y @aarwitz/tapp build [dir] # build the app in an Xcode repo + install it (≈ tapp_build)
20
20
  npx -y @aarwitz/tapp qa app.apk --platform android --app-id com.acme.app
21
- npx -y @aarwitz/tapp flow run .autotap/flows/smoke.yml # committed, keyless E2E replay
21
+ npx -y @aarwitz/tapp flow run .tapp/flows/smoke.yml # committed, keyless E2E replay
22
22
  ```
23
23
 
24
24
  **Seeing the screen, per client:** if you can read image files into your context (Claude
@@ -92,13 +92,13 @@ Returns `{verdict, confidence, headline, screensExplored, actionsPerformed, find
92
92
 
93
93
  ## Flows (deterministic E2E tests)
94
94
 
95
- Flow YAML is repository-native test code. Commit it under `.autotap/flows/`; CI can replay it
95
+ Flow YAML is repository-native test code. Commit it under `.tapp/flows/`; CI can replay it
96
96
  without a coding agent, model, subscription, or API key. AI generation and `assert_ai` are optional.
97
97
 
98
98
  - **Record:** every successful `session_act` is recorded. After driving a flow, call
99
- `tapp_flow_save { name: "checkout" }` → writes `.autotap/flows/checkout.yml` with waits and
99
+ `tapp_flow_save { name: "checkout" }` → writes `.tapp/flows/checkout.yml` with waits and
100
100
  a final screen assertion auto-inserted; typed credentials are templated to `$TEST_EMAIL`/`$TEST_PASSWORD`.
101
- - **Replay:** `tapp_flow_run { flowPath: ".autotap/flows/checkout.yml" }` — exact steps,
101
+ - **Replay:** `tapp_flow_run { flowPath: ".tapp/flows/checkout.yml" }` — exact steps,
102
102
  deterministic assertions, same result every time. A failed assertion is a finding.
103
103
  - **Generate:** `tapp_flow_generate { goal: "log in and add the first item to cart" }` —
104
104
  grounded in the app's actually-explored screens, so it can't invent steps.
package/README.md CHANGED
@@ -35,34 +35,22 @@ agent: *tapp: builds, opens the app, navigates to Settings, screenshots it*
35
35
  agent: "Done — and here it is working on the simulator: [screenshot]"
36
36
  ```
37
37
 
38
- ## Quickstart
38
+ ## Quickstart for coding agents
39
39
 
40
40
  Requirements: **Node ≥ 18**. iOS needs **macOS + Xcode**; Android needs `adb` plus a connected
41
41
  emulator/device; web needs Playwright + Chromium.
42
42
 
43
- **Launch experience browser Release Studio.** Open the source chooser from anywhere:
43
+ From the app repository, let the agent see the current screen and then ask for a release verdict:
44
44
 
45
45
  ```bash
46
- npx -y @aarwitz/tapp app
46
+ npx -y @aarwitz/tapp open # builds/launches as needed; prints a screenshot path + screen summary
47
+ npx -y @aarwitz/tapp qa # explores the real app; prints verdict, findings, and evidence report
47
48
  ```
48
49
 
49
- Drag and drop a local repository folder or connect through your authenticated GitHub CLI and select
50
- an authorized repository. To work directly in an existing writable checkout, use `tapp app .`.
51
- The local, loopback-only workspace detects iOS, Android, and web targets. A single configured target
52
- builds and explores automatically; Tapp asks only when selection is ambiguous or configuration is
53
- genuinely missing. It renders the UI Map,
54
- supports release-contract review and keyless validation, records live semantic actions as committed
55
- Flows, and produces target-scoped gate, baseline, evidence, and CI artifacts. CLI, MCP, VS Code, and
56
- the GitHub Action use the same product/gate operations; the retained managed-runner prototype is
57
- being replaced by the future isolated SaaS worker boundary.
58
-
59
- **Zero config — get a verdict right now.** From your app's repo, one command. No server, no
60
- config file, no test code — you don't even need to know your bundle id:
61
-
62
- ```bash
63
- cd YourApp
64
- npx -y @aarwitz/tapp qa # finds your Xcode project → builds → installs on the simulator → explores → verdict
65
- ```
50
+ Claude Code can read the saved image with its file-reading tool; Codex can open it with
51
+ `view_image`. The agent should report what the screenshot proves, preserve Tapp's exact
52
+ `ready`/`caution`/`blocked` verdict, and link the HTML evidence report. No server, account, config
53
+ file, test code, API key, or bundle id is required for this loop.
66
54
 
67
55
  The product, executable, and package leaf are all Tapp: npm distributes it as
68
56
  `@aarwitz/tapp`, while the installed command remains `tapp`.
@@ -90,9 +78,9 @@ npx -y @aarwitz/tapp ci install .
90
78
  ```
91
79
 
92
80
  The baseline command writes only after autonomous QA and every selected deterministic suite pass
93
- conclusively. It stores `.autotap/baselines/<platform>/<target-id>.json`; the generated workflow
81
+ conclusively. It stores `.tapp/baselines/<platform>/<target-id>.json`; the generated workflow
94
82
  uses that exact target identity so two apps on the same platform never share a baseline. `ci
95
- install` writes `.github/workflows/tapp.yml` plus `.autotap/ci.json`, refuses unresolved build
83
+ install` writes `.github/workflows/tapp.yml` plus `.tapp/ci.json`, refuses unresolved build
96
84
  configuration and existing-file collisions, and never commits, pushes, enables branch protection,
97
85
  or creates GitHub resources. Review and pin the generated Tapp release reference to its immutable
98
86
  commit SHA before production.
@@ -124,6 +112,12 @@ npx -y @aarwitz/tapp install # ~2 min, one time
124
112
  npx -y @aarwitz/tapp doctor # verify Xcode / simulators / toolchain
125
113
  ```
126
114
 
115
+ ### Optional browser workspace
116
+
117
+ `npx -y @aarwitz/tapp app .` opens a local Release Studio for people who want visual repository
118
+ onboarding, release-plan review, and CI preparation. It is not required for the coding-agent
119
+ `open`/`qa` workflow.
120
+
127
121
  ### MCP hookup (optional)
128
122
 
129
123
  The MCP server adds the two things a CLI can't do: **screenshots inline in your agent's
@@ -182,8 +176,8 @@ Then ask your agent:
182
176
 
183
177
  Full agent playbook: [AGENTS.md](./AGENTS.md) — ships inside the package so agents can read it too.
184
178
  Application-model and import contract: [`docs/application-model.md`](docs/application-model.md).
185
- The desktop Coverage view reads the same `.autotap/application-model.json`,
186
- `.autotap/release-plan.json`, and `.autotap/ui-map.json`, including explicit proposal review; it
179
+ The desktop Coverage view reads the same `.tapp/application-model.json`,
180
+ `.tapp/release-plan.json`, and `.tapp/ui-map.json`, including explicit proposal review; it
187
181
  does not maintain a separate product model. Map nodes identify both the real launch entry and the
188
182
  deterministic per-platform navigation root used for bounded changed-surface replay.
189
183
 
@@ -271,8 +265,8 @@ The first successful, conclusive run on `main` seeds a repository-scoped Actions
271
265
  90-day baseline artifact. Both are keyed by platform and stable application-model target id. Pull requests automatically restore it and fail only on **new**
272
266
  high/critical findings or broken Flows—not pre-existing debt. No baseline commit or PAT is required.
273
267
  If you prefer a reviewed, durable baseline, run `tapp baseline create` and commit the generated
274
- `.autotap/baselines/<platform>/<target-id>.json`; `tapp ci install` wires its explicit path into the
275
- corresponding job. The legacy `.autotap/baseline.json` is still recognized. Automatic baseline restore and the PR comment need `actions: read` and
268
+ `.tapp/baselines/<platform>/<target-id>.json`; `tapp ci install` wires its explicit path into the
269
+ corresponding job. The legacy `.tapp/baseline.json` is still recognized. Automatic baseline restore and the PR comment need `actions: read` and
276
270
  `pull-requests: write` as shown above. Secrets are unavailable to workflows from forks, so
277
271
  auth-gated apps should either use a non-secret UI-testing launch argument or skip the gate for
278
272
  untrusted forks.
@@ -305,14 +299,14 @@ or accept a prebuilt one:
305
299
  android-app-id: com.acme.app
306
300
  android-project: android
307
301
  android-task: :app:assembleDebug
308
- flows: android/.autotap/flows/*.yml
302
+ flows: android/.tapp/flows/*.yml
309
303
  ```
310
304
 
311
305
  For web, pass `platform: web` plus `web-target:` and Tapp uses the application model to run its
312
306
  lockfile-backed install/build, start a detected package script or read-only static server, wait for
313
307
  readiness, gate it, and stop it even on failure. Pass `url:` instead for an already-running owned
314
308
  environment. Add
315
- `scenarios: .autotap/scenarios/*.yml` to gate isolated cross-account journeys; see
309
+ `scenarios: .tapp/scenarios/*.yml` to gate isolated cross-account journeys; see
316
310
  [`docs/scenarios.md`](docs/scenarios.md). Automatic
317
311
  baselines are isolated by platform and target, so two same-platform apps are never compared.
318
312
 
@@ -351,8 +345,11 @@ verdict calculation run entirely locally — no telemetry, nothing phones home.
351
345
  features are explicit: finding enrichment requires `TAPP_ENABLE_REMOTE_AI=1` (an ambient
352
346
  API key alone never changes data handling), and AI flow generation / `assert_ai` only run
353
347
  when you invoke them; these send selected metadata (screen names, finding titles) to your
354
- configured model provider. Runtime configuration uses `TAPP_*` environment variables; deprecated
355
- aliases remain readable for compatibility.
348
+ configured model provider. Runtime configuration uses `TAPP_*` environment variables. Repository
349
+ artifacts live under `.tapp/`, and desktop run configuration uses `.tapp.yml`. Existing
350
+ `.autotap/`, `.autotap.yml`, and `AUTOTAP_*` inputs remain readable as migration fallbacks; when
351
+ both names exist, Tapp uses the canonical one. Rename the old directory/config in your next
352
+ reviewed repository change—no secrets or evidence formats need to be rewritten.
356
353
 
357
354
  Committed Flow replay, recording a driven session, autonomous exploration, exact assertions,
358
355
  regression comparison, and CI gating require **no API key and no coding agent at runtime**. AI is
package/bin/tapp.js CHANGED
@@ -22,16 +22,16 @@ import fs from "node:fs";
22
22
  import os from "node:os";
23
23
  import path from "node:path";
24
24
  import { fileURLToPath } from "node:url";
25
+ import { existingProjectArtifactPath, isProjectArtifactDirectory } from "../mcp-server/src/project-paths.js";
25
26
 
26
27
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
27
28
  const packageRoot = path.resolve(__dirname, "..");
28
29
  const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
29
30
 
30
31
  // Redirect all writable output away from the (possibly read-only) package dir.
31
- // The old environment alias remains populated only for older helper integrations.
32
+ // The old environment alias remains a read-only fallback for older integrations.
32
33
  const tappHome = (process.env.TAPP_HOME || process.env.AUTOTAP_HOME || path.join(os.homedir(), ".tapp")).trim();
33
34
  process.env.TAPP_HOME = tappHome;
34
- if (!process.env.AUTOTAP_HOME) process.env.AUTOTAP_HOME = tappHome;
35
35
  fs.mkdirSync(tappHome, { recursive: true });
36
36
 
37
37
  const [, , command = "help", ...rest] = process.argv;
@@ -221,7 +221,7 @@ switch (command) {
221
221
  console.error("❌ --explore writes grounded UI Map evidence and cannot be combined with --dry-run");
222
222
  process.exit(2);
223
223
  }
224
- const outDir = typeof flags["out-dir"] === "string" ? flags["out-dir"] : ".autotap";
224
+ const outDir = typeof flags["out-dir"] === "string" ? flags["out-dir"] : ".tapp";
225
225
  const artifactDir = path.resolve(projectDir, outDir);
226
226
  if (!artifactDir.startsWith(projectDir + path.sep) && artifactDir !== projectDir) {
227
227
  console.error("❌ --out-dir must remain inside the repository");
@@ -292,7 +292,7 @@ switch (command) {
292
292
  case "plan": {
293
293
  const { flags, positionals } = parseVerbArgs(rest);
294
294
  const verb = positionals[0] || "show";
295
- const planPath = path.resolve(positionals[1] || ".autotap/release-plan.json");
295
+ const planPath = positionals[1] ? path.resolve(positionals[1]) : existingProjectArtifactPath(process.cwd(), "release-plan.json");
296
296
  if (!fs.existsSync(planPath)) {
297
297
  console.error(`❌ Release plan not found: ${planPath}`);
298
298
  process.exit(2);
@@ -366,7 +366,7 @@ switch (command) {
366
366
  break;
367
367
  }
368
368
  if (verb !== "review") {
369
- console.error("usage: tapp plan show [.autotap/release-plan.json]\n tapp plan review [.autotap/release-plan.json] --approve name[,name] --reject name[,name] --defer name[,name]\n tapp plan generate [.autotap/release-plan.json] [--project-dir DIR]\n tapp plan validate [.autotap/release-plan.json] --project-dir DIR --platform web [--url URL] [--target NAME|PATH]\n tapp plan promote [.autotap/release-plan.json] --project-dir DIR [--item name[,name]]");
369
+ console.error("usage: tapp plan show [.tapp/release-plan.json]\n tapp plan review [.tapp/release-plan.json] --approve name[,name] --reject name[,name] --defer name[,name]\n tapp plan generate [.tapp/release-plan.json] [--project-dir DIR]\n tapp plan validate [.tapp/release-plan.json] --project-dir DIR --platform web [--url URL] [--target NAME|PATH]\n tapp plan promote [.tapp/release-plan.json] --project-dir DIR [--item name[,name]]");
370
370
  process.exit(2);
371
371
  }
372
372
  const list = (value) => typeof value === "string" ? value.split(",").map((item) => item.trim()).filter(Boolean) : [];
@@ -377,7 +377,7 @@ switch (command) {
377
377
  }
378
378
  const { reviewProductPlan } = await import(path.join(packageRoot, "mcp-server", "src", "product-operations.js"));
379
379
  const reviewProjectDir = typeof flags["project-dir"] === "string" ? path.resolve(flags["project-dir"])
380
- : path.basename(path.dirname(planPath)) === ".autotap" ? path.dirname(path.dirname(planPath)) : path.dirname(planPath);
380
+ : isProjectArtifactDirectory(path.basename(path.dirname(planPath))) ? path.dirname(path.dirname(planPath)) : path.dirname(planPath);
381
381
  try { plan = reviewProductPlan({ projectDir: reviewProjectDir, planPath, ...decisions }).plan; }
382
382
  catch (error) { console.error(`❌ Could not review release plan: ${error.message || String(error)}`); process.exit(2); }
383
383
  console.log(`✅ Release plan updated — ${plan.items.filter((item) => ["approved", "accepted"].includes(item.decision)).length} accepted/approved · ${plan.items.filter((item) => item.decision === "rejected").length} rejected · ${plan.items.filter((item) => item.decision === "pending").length} pending`);
@@ -461,6 +461,27 @@ switch (command) {
461
461
  const { flags, positionals } = parseVerbArgs(rest);
462
462
  const engine = await engineImport();
463
463
  const platform = requestedPlatform(flags, positionals[0] || "");
464
+ if (platform === "web") {
465
+ const url = positionals[0] || "";
466
+ if (!/^https?:\/\//i.test(url)) {
467
+ console.error("❌ Web open needs an http(s) URL");
468
+ process.exit(2);
469
+ }
470
+ try {
471
+ const { inspectWebPage } = await import(path.join(packageRoot, "mcp-server", "src", "web-explorer.js"));
472
+ const snap = await inspectWebPage({ url, timeoutMs: Number(flags.timeout) * 1000 || 15_000 });
473
+ const out = typeof flags.out === "string" ? path.resolve(flags.out) : path.join(tappHome, "shots", `web-${Date.now()}.png`);
474
+ fs.mkdirSync(path.dirname(out), { recursive: true });
475
+ fs.writeFileSync(out, snap.image);
476
+ console.log(`🌐 Opened \`${snap.url}\`\n`);
477
+ console.log(engine.formatScreen(snap.screenTitle, snap.elements));
478
+ console.log(`\n📸 Screenshot: ${out}`);
479
+ } catch (error) {
480
+ console.error(`❌ ${error.message || String(error)}`);
481
+ process.exit(1);
482
+ }
483
+ break;
484
+ }
464
485
  if (platform === "android") {
465
486
  const target = androidTarget(flags, positionals[0] || "");
466
487
  const { AndroidDriver } = await import(path.join(packageRoot, "mcp-server", "src", "android-driver.js"));
@@ -504,6 +525,23 @@ switch (command) {
504
525
  const { flags, positionals } = parseVerbArgs(rest);
505
526
  const engine = await engineImport();
506
527
  const platform = requestedPlatform(flags, positionals[0] || "");
528
+ if (platform === "web") {
529
+ const url = positionals[0] || "";
530
+ if (!/^https?:\/\//i.test(url)) {
531
+ console.error("❌ Web tree needs an http(s) URL");
532
+ process.exit(2);
533
+ }
534
+ try {
535
+ const { inspectWebPage } = await import(path.join(packageRoot, "mcp-server", "src", "web-explorer.js"));
536
+ const snap = await inspectWebPage({ url, timeoutMs: Number(flags.timeout) * 1000 || 15_000, screenshot: false });
537
+ if (flags.json) console.log(JSON.stringify({ platform: "web", url: snap.url, screenTitle: snap.screenTitle, elements: snap.elements }, null, 2));
538
+ else console.log(engine.formatScreen(snap.screenTitle, snap.elements));
539
+ } catch (error) {
540
+ console.error(`❌ ${error.message || String(error)}`);
541
+ process.exit(1);
542
+ }
543
+ break;
544
+ }
507
545
  if (platform === "android") {
508
546
  const target = androidTarget(flags, positionals[0] || "");
509
547
  const { AndroidDriver } = await import(path.join(packageRoot, "mcp-server", "src", "android-driver.js"));
@@ -608,7 +646,7 @@ switch (command) {
608
646
  const verb = positionals[0] || "validate";
609
647
  const taskPath = positionals[1] ? path.resolve(positionals[1]) : "";
610
648
  if (!["validate", "compile", "run"].includes(verb) || !taskPath) {
611
- console.error("usage: tapp task validate <task.yml> [--platform ios|android|web] [--map .autotap/ui-map.json]\n tapp task compile <task.yml> --platform PLATFORM [--inputs '{\"name\":\"value\"}'] [--out compiled.json]\n tapp task run <task.yml> --platform PLATFORM [--url URL|--bundle-id ID|--app-id ID] [--inputs JSON]");
649
+ console.error("usage: tapp task validate <task.yml> [--platform ios|android|web] [--map .tapp/ui-map.json]\n tapp task compile <task.yml> --platform PLATFORM [--inputs '{\"name\":\"value\"}'] [--out compiled.json]\n tapp task run <task.yml> --platform PLATFORM [--url URL|--bundle-id ID|--app-id ID] [--inputs JSON]");
612
650
  process.exit(2);
613
651
  }
614
652
  if (!fs.existsSync(taskPath)) { console.error(`❌ Task not found: ${taskPath}`); process.exit(2); }
@@ -741,7 +779,7 @@ switch (command) {
741
779
  const verb = positionals[0] || "validate";
742
780
  const contractPath = positionals[1] ? path.resolve(positionals[1]) : "";
743
781
  if (!["validate", "compile", "run"].includes(verb) || !contractPath) {
744
- console.error("usage: tapp contract validate <name.contract.ts> [--platform ios|android|web] [--map .autotap/ui-map.json]\n tapp contract compile <name.contract.ts> --platform PLATFORM [--out compiled.json]\n tapp contract run <name.contract.ts> --platform PLATFORM [--url URL|--bundle-id ID|--app-id ID]");
782
+ console.error("usage: tapp contract validate <name.contract.ts> [--platform ios|android|web] [--map .tapp/ui-map.json]\n tapp contract compile <name.contract.ts> --platform PLATFORM [--out compiled.json]\n tapp contract run <name.contract.ts> --platform PLATFORM [--url URL|--bundle-id ID|--app-id ID]");
745
783
  process.exit(2);
746
784
  }
747
785
  const {
@@ -918,7 +956,7 @@ switch (command) {
918
956
  console.error("❌ --platform must be ios|android|web");
919
957
  process.exit(2);
920
958
  }
921
- const out = path.resolve(typeof flags.out === "string" ? flags.out : path.join(".autotap", "ui-map.json"));
959
+ const out = path.resolve(typeof flags.out === "string" ? flags.out : path.join(".tapp", "ui-map.json"));
922
960
  const observed = buildUiMapFromMarkers({
923
961
  markersPath,
924
962
  platform,
@@ -936,7 +974,7 @@ switch (command) {
936
974
  break;
937
975
  }
938
976
  if (verb === "inspect") {
939
- const mapPath = path.resolve(positionals[1] || path.join(".autotap", "ui-map.json"));
977
+ const mapPath = positionals[1] ? path.resolve(positionals[1]) : existingProjectArtifactPath(process.cwd(), "ui-map.json");
940
978
  if (!fs.existsSync(mapPath)) { console.error(`❌ UI Map not found: ${mapPath}`); process.exit(2); }
941
979
  const map = JSON.parse(fs.readFileSync(mapPath, "utf8"));
942
980
  const errors = validateUiMap(map);
@@ -959,7 +997,7 @@ switch (command) {
959
997
  if (!diff.comparableFullSweep && (diff.notObservedNodes.length || diff.notObservedEdges.length)) console.log("ℹ️ Absence is not labeled a regression because the runs were not declared comparable full sweeps.");
960
998
  process.exit(diff.lostReachability.length || diff.lostTransitions.length ? 1 : 0);
961
999
  }
962
- console.error("usage: tapp map build <ocqa-markers.txt> [--platform ios|android|web] [--out .autotap/ui-map.json]\n tapp map inspect [ui-map.json]\n tapp map diff <before.json> <after.json> [--comparable]");
1000
+ console.error("usage: tapp map build <ocqa-markers.txt> [--platform ios|android|web] [--out .tapp/ui-map.json]\n tapp map inspect [ui-map.json]\n tapp map diff <before.json> <after.json> [--comparable]");
963
1001
  process.exit(2);
964
1002
  }
965
1003
 
@@ -1096,7 +1134,7 @@ switch (command) {
1096
1134
  process.exit(2);
1097
1135
  }
1098
1136
  const projectDir = fs.realpathSync(path.resolve(positionals[1] || (typeof flags["project-dir"] === "string" ? flags["project-dir"] : process.cwd())));
1099
- const modelPath = path.resolve(projectDir, typeof flags.model === "string" ? flags.model : path.join(".autotap", "application-model.json"));
1137
+ const modelPath = typeof flags.model === "string" ? path.resolve(projectDir, flags.model) : existingProjectArtifactPath(projectDir, "application-model.json");
1100
1138
  if (!modelPath.startsWith(projectDir + path.sep) || !fs.existsSync(modelPath)) {
1101
1139
  console.error(`❌ Application model not found inside the repository: ${modelPath}\n Run tapp init --explore, review/generate/validate/promote the plan, then create the baseline.`);
1102
1140
  process.exit(2);
@@ -1203,7 +1241,7 @@ switch (command) {
1203
1241
  let projectDir;
1204
1242
  try { projectDir = fs.realpathSync(path.resolve(positionals[0] || (typeof flags["project-dir"] === "string" ? flags["project-dir"] : process.cwd()))); }
1205
1243
  catch { console.error(`❌ Repository directory not found: ${positionals[0] || flags["project-dir"] || process.cwd()}`); process.exit(2); }
1206
- const modelPath = path.resolve(projectDir, typeof flags.model === "string" ? flags.model : path.join(".autotap", "application-model.json"));
1244
+ const modelPath = typeof flags.model === "string" ? path.resolve(projectDir, flags.model) : existingProjectArtifactPath(projectDir, "application-model.json");
1207
1245
  if (!modelPath.startsWith(projectDir + path.sep) || !fs.existsSync(modelPath)) {
1208
1246
  console.error(`❌ Application model not found inside the repository: ${modelPath}\n Run tapp init --explore first.`);
1209
1247
  process.exit(2);
@@ -1242,7 +1280,7 @@ switch (command) {
1242
1280
  actionRef,
1243
1281
  defaultBranch: typeof flags["default-branch"] === "string" ? flags["default-branch"] : "main",
1244
1282
  workflowPath: typeof flags.out === "string" ? flags.out : ".github/workflows/tapp.yml",
1245
- manifestPath: typeof flags.manifest === "string" ? flags.manifest : ".autotap/ci.json",
1283
+ manifestPath: typeof flags.manifest === "string" ? flags.manifest : ".tapp/ci.json",
1246
1284
  replace: flags.replace === true,
1247
1285
  allowUnresolved: flags["allow-unresolved"] === true,
1248
1286
  });
@@ -1317,21 +1355,9 @@ switch (command) {
1317
1355
  console.log(`tapp v${pkg.version} — ship with proof. Autonomous QA and deterministic Flows for iOS, Android, and web.
1318
1356
 
1319
1357
  Zero-config verbs (agents and humans can just run these — no server, no setup):
1320
- tapp app [repo] Open the browser onboarding, contract-review, and release-evidence workspace
1321
- (loopback-only; --no-open · --port PORT)
1322
- tapp init [repo] Detect targets and write the application model + reviewable release plan
1323
- (--explore builds/starts or connects, grounds the UI Map, then tears down)
1324
- (--url URL · --platform PLATFORM · --dry-run · --refresh)
1325
- tapp actor list [repo] Inspect named actors, sessions, provisioning, and secret env bindings
1326
- tapp actor set NAME Configure an actor using environment-variable names only (never values)
1327
- tapp plan show [FILE] Inspect the proposed/accepted release-contract plan
1328
- tapp plan review [FILE] Explicitly approve, reject, or defer proposed plan items
1329
- tapp plan generate [FILE] Generate compile-checked, untrusted contract drafts from approved Tasks
1330
- tapp plan validate [FILE] Replay drafts on a real target; trust only after all platforms pass
1331
- tapp plan promote [FILE] Move fully validated drafts into reviewed Tasks/contracts + map coverage
1358
+ tapp open [target] Launch the app screen summary + screenshot saved to a file
1332
1359
  tapp qa [target] Autonomous QA → verdict + findings + evidence
1333
1360
  (--platform ios|android|web · --app-id ID · --apk FILE · --actions N)
1334
- tapp open [target] Launch the app → screen summary + screenshot saved to a file
1335
1361
  tapp tree [target] Accessibility tree of the current screen (--json for every element)
1336
1362
  tapp flow run FILE Replay a committed deterministic Flow (no AI/API key)
1337
1363
  tapp flow validate FILE Validate a Flow without launching a target
@@ -1353,6 +1379,18 @@ Zero-config verbs (agents and humans can just run these — no server, no setup)
1353
1379
  tapp build [dir] Build the iOS app in a repo for the simulator + install it (--scheme S)
1354
1380
  tapp apps List apps installed on the booted simulator (with bundle ids)
1355
1381
  tapp report [captureId] Open the HTML evidence page for a capture (default: latest)
1382
+ tapp app [repo] Optional local browser workspace for repository onboarding and review
1383
+ (loopback-only; --no-open · --port PORT)
1384
+ tapp init [repo] Detect targets and write the application model + reviewable release plan
1385
+ (--explore builds/starts or connects, grounds the UI Map, then tears down)
1386
+ (--url URL · --platform PLATFORM · --dry-run · --refresh)
1387
+ tapp actor list [repo] Inspect named actors, sessions, provisioning, and secret env bindings
1388
+ tapp actor set NAME Configure an actor using environment-variable names only (never values)
1389
+ tapp plan show [FILE] Inspect the proposed/accepted release-contract plan
1390
+ tapp plan review [FILE] Explicitly approve, reject, or defer proposed plan items
1391
+ tapp plan generate [FILE] Generate compile-checked, untrusted contract drafts from approved Tasks
1392
+ tapp plan validate [FILE] Replay drafts on a real target; trust only after all platforms pass
1393
+ tapp plan promote [FILE] Move fully validated drafts into reviewed Tasks/contracts + map coverage
1356
1394
  tapp ci ... Merge-blocking release gate — explore + flows + baseline diff (see: tapp ci --help)
1357
1395
  tapp ci install [repo] Generate a reviewable target-aware GitHub workflow + CI manifest
1358
1396
 
package/browser/app.js CHANGED
@@ -471,7 +471,7 @@ async function entriesFromDrop(event) {
471
471
  }
472
472
 
473
473
  // The webkitdirectory input (and some drop implementations) silently exclude hidden
474
- // entries, which destroys committed .autotap/ config. The File System Access picker
474
+ // entries, which destroys committed .tapp/ config. The File System Access picker
475
475
  // enumerates hidden files, so it is preferred wherever it exists.
476
476
  async function entriesFromDirectoryHandle(handle, prefix = "") {
477
477
  const entries = [];
@@ -526,7 +526,7 @@ async function uploadRepository(name, entries, { hiddenComplete = false } = {})
526
526
  $("#operation-progress-bar").style.width = "100%";
527
527
  const hasHiddenEntries = entries.some((entry) => entry.path.split("/").some((part) => part.startsWith(".")));
528
528
  if (!hiddenComplete && !hasHiddenEntries) {
529
- toast("Imported, but no hidden files (like .autotap/) came through — this browser's folder picker skips them. Committed Tapp config was not uploaded.", true);
529
+ toast("Imported, but no hidden files (like .tapp/) came through — this browser's folder picker skips them. Committed Tapp config was not uploaded.", true);
530
530
  } else {
531
531
  toast("Repository imported into an isolated Tapp workspace");
532
532
  }
@@ -41,7 +41,7 @@
41
41
  <div class="source-icon">↥</div>
42
42
  <p class="eyebrow">CLOUD COPY</p>
43
43
  <h2>Upload a project folder</h2>
44
- <p>Upload an isolated working copy from this computer. Committed hidden config (like <code>.autotap/</code>) is included; dependency and build output folders are skipped.</p>
44
+ <p>Upload an isolated working copy from this computer. Committed hidden config (like <code>.tapp/</code>) is included; dependency and build output folders are skipped.</p>
45
45
  <button class="primary" id="browse-folder">Browse folder</button>
46
46
  <input id="folder-input" type="file" webkitdirectory multiple hidden>
47
47
  <small>For direct edits to an existing checkout, start with <code>tapp app /path/to/repo</code>.</small>
@@ -8,6 +8,7 @@ import { credentialBindingsFromValue, readProjectConfig } from "./project-config
8
8
  import { applyReleaseContractCoverage, compileReleaseContract, loadReleaseContractFile, validateReleaseContractAgainstUiMap } from "./release-contract.js";
9
9
  import { applyTaskCoverage, loadTaskFile, validateTaskAgainstUiMap } from "./task-runtime.js";
10
10
  import { semanticUiKey } from "./ui-map.js";
11
+ import { isProjectArtifactDirectory, projectArtifactDirectory } from "./project-paths.js";
11
12
 
12
13
  const SKIP = new Set([".git", ".build", ".gradle", ".next", ".swiftpm", "Pods", "Carthage", "DerivedData", "build", "dist", "node_modules", "vendor"]);
13
14
 
@@ -25,7 +26,7 @@ function walk(root, maxDepth = 4) {
25
26
  let entries;
26
27
  try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
27
28
  for (const entry of entries) {
28
- if (SKIP.has(entry.name) || (entry.name.startsWith(".") && entry.name !== ".autotap")) continue;
29
+ if (SKIP.has(entry.name) || (entry.name.startsWith(".") && !isProjectArtifactDirectory(entry.name))) continue;
29
30
  const absolute = path.join(dir, entry.name);
30
31
  if (entry.isDirectory()) {
31
32
  directories.push(absolute);
@@ -124,7 +125,8 @@ function applyRuntimeTargetValidation(root, targets, validation) {
124
125
  }
125
126
 
126
127
  function persistedTargetValidations(root, outDir) {
127
- const artifactDir = path.resolve(root, String(outDir || ".autotap"));
128
+ const requested = String(outDir || ".tapp");
129
+ const artifactDir = path.resolve(root, projectArtifactDirectory(root, requested));
128
130
  const relativeArtifactDir = path.relative(root, artifactDir);
129
131
  if (path.isAbsolute(relativeArtifactDir) || relativeArtifactDir === ".." || relativeArtifactDir.startsWith(`..${path.sep}`)) return [];
130
132
  const prior = readJson(path.join(artifactDir, "application-model.json"));
@@ -299,10 +301,11 @@ function uiMapTargetsTarget(map, target, targets) {
299
301
  }
300
302
 
301
303
  function loadTargetUiMaps(root, targets) {
302
- const rootMap = loadUiMapAt(root, path.join(".autotap", "ui-map.json"));
304
+ const rootMap = loadUiMapAt(root, path.join(projectArtifactDirectory(root), "ui-map.json"));
303
305
  const records = targets.map((target) => {
304
306
  const scope = targetArtifactScope(target);
305
- const expectedPath = posix(path.join(scope === "." ? "" : scope, ".autotap", "ui-map.json"));
307
+ const scopeRoot = path.join(root, scope === "." ? "" : scope);
308
+ const expectedPath = posix(path.join(scope === "." ? "" : scope, projectArtifactDirectory(scopeRoot), "ui-map.json"));
306
309
  let loaded = expectedPath === rootMap.summary.path ? rootMap : loadUiMapAt(root, expectedPath);
307
310
  if (!loaded.map && rootMap.map && (targets.length === 1 || uiMapTargetsTarget(rootMap.map, target, targets))) loaded = rootMap;
308
311
  return {
@@ -328,7 +331,7 @@ function loadTargetUiMaps(root, targets) {
328
331
  const prefixCoverage = unique.length > 1;
329
332
  const coverageValues = (field) => unique.flatMap((record) => (record.summary[field] || []).map((id) => prefixCoverage ? `${record.summary.targetId}:${id}` : id));
330
333
  const summary = {
331
- path: unique.length === 1 ? unique[0].summary.path : ".autotap/ui-map.json",
334
+ path: unique.length === 1 ? unique[0].summary.path : ".tapp/ui-map.json",
332
335
  paths: unique.map((record) => record.summary.path).sort(),
333
336
  status: allObserved ? "observed" : someObserved ? "partial" : someInconclusive ? "inconclusive" : "missing",
334
337
  nodeCount: unique.reduce((total, record) => total + record.summary.nodeCount, 0),
@@ -348,14 +351,14 @@ function loadTargetUiMaps(root, targets) {
348
351
 
349
352
  function artifactScope(root, file) {
350
353
  const parts = relative(root, file).split("/");
351
- const index = parts.indexOf(".autotap");
354
+ const index = parts.findIndex(isProjectArtifactDirectory);
352
355
  return index > 0 ? parts.slice(0, index).join("/") : ".";
353
356
  }
354
357
 
355
358
  function artifactFiles(root, inventory, kind, pattern) {
356
359
  return inventory.files.filter((file) => {
357
360
  const parts = relative(root, file).split("/");
358
- const index = parts.indexOf(".autotap");
361
+ const index = parts.findIndex(isProjectArtifactDirectory);
359
362
  return index >= 0 && parts[index + 1] === kind && pattern.test(path.basename(file));
360
363
  }).sort();
361
364
  }
@@ -421,7 +424,7 @@ function applicationName(root, targets) {
421
424
  return pkg?.name || (targets.length === 1 ? targets[0].name : path.basename(root));
422
425
  }
423
426
 
424
- export async function inspectApplicationRepository({ projectDir, ownedUrl = "", platform = "", targetValidation = null, outDir = ".autotap" } = {}) {
427
+ export async function inspectApplicationRepository({ projectDir, ownedUrl = "", platform = "", targetValidation = null, outDir = ".tapp" } = {}) {
425
428
  const root = fs.realpathSync(path.resolve(projectDir || process.cwd()));
426
429
  const inventory = walk(root);
427
430
  let targets = [
@@ -550,7 +553,7 @@ export async function inspectApplicationRepository({ projectDir, ownedUrl = "",
550
553
  requirements.push({
551
554
  id: targets.length <= 1 ? "ui-map" : `${target.id}:ui-map`, severity: "blocking", status: summary.status === "inconclusive" ? "inconclusive" : "missing",
552
555
  message: target ? (summary.status === "inconclusive" ? `The UI Map for ${target.name} is inconclusive.` : `No grounded UI Map exists for ${target.name}.`) : "No repository UI Map has been grounded in a real run.",
553
- remediation: target ? `Build/launch ${target.name}, explore the real target, and retain its map at ${summary.expectedPath || summary.path}.` : "Build/launch the target and run tapp init --explore so real exploration evidence is merged into .autotap/ui-map.json.",
556
+ remediation: target ? `Build/launch ${target.name}, explore the real target, and retain its map at ${summary.expectedPath || summary.path}.` : "Build/launch the target and run tapp init --explore so real exploration evidence is merged into .tapp/ui-map.json.",
554
557
  });
555
558
  }
556
559
  if (!contracts.length) requirements.push({ id: "contracts", severity: "warning", status: "missing", message: "No reviewed release contracts exist yet.", remediation: "Review the proposed release plan, then generate and validate a compact set of contracts." });
@@ -925,7 +928,7 @@ function invalidateGeneratedTaskFiles(root, plan) {
925
928
  for (const record of plan.generation?.generatedTasks || []) {
926
929
  if (!record.path) continue;
927
930
  const absolute = path.resolve(root, record.path);
928
- const proposalRoot = path.join(root, ".autotap", "proposals", "tasks");
931
+ const proposalRoot = path.join(root, projectArtifactDirectory(root), "proposals", "tasks");
929
932
  if (!isInsideRoot(proposalRoot, absolute) || !fs.existsSync(absolute)) continue;
930
933
  const task = readJson(absolute);
931
934
  if (!task || task.generation?.origin !== "deterministic-ui-map") continue;
@@ -937,8 +940,8 @@ function invalidateGeneratedTaskFiles(root, plan) {
937
940
  }
938
941
  }
939
942
 
940
- export function writeInitArtifacts({ root, model, plan, outDir = ".autotap", refresh = false, invalidateValidation = false } = {}) {
941
- const directory = path.resolve(root, outDir);
943
+ export function writeInitArtifacts({ root, model, plan, outDir = ".tapp", refresh = false, invalidateValidation = false } = {}) {
944
+ const directory = path.resolve(root, projectArtifactDirectory(root, outDir));
942
945
  const modelPath = path.join(directory, "application-model.json");
943
946
  const planPath = path.join(directory, "release-plan.json");
944
947
  if (!refresh && (fs.existsSync(modelPath) || fs.existsSync(planPath))) throw new Error(`Init artifacts already exist under ${relative(root, directory)}; inspect them or rerun with --refresh to preserve reviewed decisions while updating evidence`);
@@ -1069,7 +1072,7 @@ function generatedTaskName(node, entryOnly, occupied, identity) {
1069
1072
  return `${base}${crypto.createHash("sha256").update(identity).digest("hex").slice(0, 6)}`;
1070
1073
  }
1071
1074
 
1072
- function prepareMapBackedItem(item, map, existingTasks, taskDrafts, { targetId = "", mapPath = ".autotap/ui-map.json" } = {}) {
1075
+ function prepareMapBackedItem(item, map, existingTasks, taskDrafts, { targetId = "", mapPath = ".tapp/ui-map.json" } = {}) {
1073
1076
  const ground = (item.groundedBy || []).find((entry) => entry.type === "ui-map-node");
1074
1077
  if (!ground) throw new Error("Approved UI-only proposal is not grounded by a UI Map node");
1075
1078
  const nodes = new Map((map.nodes || []).map((node) => [node.id, node]));
@@ -1168,7 +1171,7 @@ function writeGeneratedTaskDrafts(root, taskDrafts) {
1168
1171
  for (const draft of [...taskDrafts.values()].sort((a, b) => a.name.localeCompare(b.name))) {
1169
1172
  const scopeRoot = path.resolve(root, draft.scope === "." || !draft.scope ? "" : draft.scope);
1170
1173
  if (!isInsideRoot(root, scopeRoot)) throw new Error(`Generated Task scope escapes repository: ${draft.scope}`);
1171
- const output = path.join(scopeRoot, ".autotap", "proposals", "tasks", `${kebab(draft.name)}.task.json`);
1174
+ const output = path.join(scopeRoot, projectArtifactDirectory(scopeRoot), "proposals", "tasks", `${kebab(draft.name)}.task.json`);
1172
1175
  const definition = {
1173
1176
  kind: "task", version: 1, name: draft.name, description: draft.description,
1174
1177
  ...(Object.keys(draft.inputs || {}).length ? { inputs: draft.inputs } : {}),
@@ -1189,7 +1192,7 @@ function writeGeneratedTaskDrafts(root, taskDrafts) {
1189
1192
  }
1190
1193
  try {
1191
1194
  const task = loadTaskFile(output);
1192
- const mapPath = path.resolve(root, draft.mapPath || ".autotap/ui-map.json");
1195
+ const mapPath = path.resolve(root, draft.mapPath || ".tapp/ui-map.json");
1193
1196
  if (!isInsideRoot(root, mapPath)) throw new Error(`Generated Task UI Map escapes repository: ${draft.mapPath}`);
1194
1197
  const map = readJson(mapPath);
1195
1198
  if (!map || map.schemaVersion !== 1) throw new Error(`Generated Task UI Map is missing or invalid: ${draft.mapPath}`);
@@ -1304,7 +1307,7 @@ export async function generateApprovedContractProposals(plan, { projectDir } = {
1304
1307
  let prepared = item;
1305
1308
  if (item.origin === "deterministic-ui-map-proposal") {
1306
1309
  const grounding = (item.groundedBy || []).find((entry) => entry.type === "ui-map-node");
1307
- const mapPath = grounding?.mapPath || ".autotap/ui-map.json";
1310
+ const mapPath = grounding?.mapPath || ".tapp/ui-map.json";
1308
1311
  const absoluteMapPath = path.resolve(root, mapPath);
1309
1312
  const itemMap = isInsideRoot(root, absoluteMapPath) ? readJson(absoluteMapPath) : null;
1310
1313
  if (!itemMap || itemMap.schemaVersion !== 1) {
@@ -1329,7 +1332,7 @@ export async function generateApprovedContractProposals(plan, { projectDir } = {
1329
1332
  if (!prepared) continue;
1330
1333
  const scopeRoot = path.resolve(root, item.scope === "." || !item.scope ? "" : item.scope);
1331
1334
  if (scopeRoot !== root && !scopeRoot.startsWith(root + path.sep)) throw new Error(`Plan scope escapes repository: ${item.scope}`);
1332
- const output = path.join(scopeRoot, ".autotap", "proposals", "contracts", `${kebab(item.name)}.contract.ts`);
1335
+ const output = path.join(scopeRoot, projectArtifactDirectory(scopeRoot), "proposals", "contracts", `${kebab(item.name)}.contract.ts`);
1333
1336
  const source = draftContractSource(prepared, projectConfiguration.config || {});
1334
1337
  if (fs.existsSync(output) && fs.readFileSync(output, "utf8") !== source) throw new Error(`Draft contract already exists with different content and was not overwritten: ${relative(root, output)}`);
1335
1338
  const created = !fs.existsSync(output);
@@ -1412,13 +1415,13 @@ export function recordContractProposalValidation(plan, { id = "", name = "", pla
1412
1415
  export function recordGeneratedTaskProposalValidation({ projectDir, item, platform, evidence = "", detail = "" } = {}) {
1413
1416
  if (!["ios", "android", "web"].includes(platform)) throw new Error("platform must be ios|android|web");
1414
1417
  const root = fs.realpathSync(path.resolve(projectDir || process.cwd()));
1415
- const proposalMarker = `${path.sep}.autotap${path.sep}proposals${path.sep}tasks${path.sep}`;
1416
- const reviewedMarker = `${path.sep}.autotap${path.sep}tasks${path.sep}`;
1418
+ const proposalMarkers = [".tapp", ".autotap"].map((directory) => `${path.sep}${directory}${path.sep}proposals${path.sep}tasks${path.sep}`);
1419
+ const reviewedMarkers = [".tapp", ".autotap"].map((directory) => `${path.sep}${directory}${path.sep}tasks${path.sep}`);
1417
1420
  const updated = [];
1418
1421
  for (const taskPath of item?.generation?.taskPaths || []) {
1419
1422
  const absolute = path.resolve(root, taskPath);
1420
- const proposed = isInsideRoot(root, absolute) && absolute.includes(proposalMarker);
1421
- const reviewed = isInsideRoot(root, absolute) && absolute.includes(reviewedMarker) && !absolute.includes(proposalMarker);
1423
+ const proposed = isInsideRoot(root, absolute) && proposalMarkers.some((marker) => absolute.includes(marker));
1424
+ const reviewed = isInsideRoot(root, absolute) && reviewedMarkers.some((marker) => absolute.includes(marker)) && !proposed;
1422
1425
  if ((!proposed && !reviewed) || !fs.existsSync(absolute)) throw new Error(`Generated Task is missing or outside Tapp Task directories: ${taskPath}`);
1423
1426
  const task = readJson(absolute);
1424
1427
  if (!task || task.kind !== "task" || task.generation?.origin !== "deterministic-ui-map") throw new Error(`Generated Task draft has invalid provenance: ${taskPath}`);
@@ -1458,10 +1461,13 @@ export function mergeGeneratedTaskProposalValidation(plan, updates = []) {
1458
1461
  }
1459
1462
 
1460
1463
  function promotedDestination(root, source, kind) {
1461
- const marker = `${path.sep}.autotap${path.sep}proposals${path.sep}${kind}${path.sep}`;
1464
+ const marker = [".tapp", ".autotap"]
1465
+ .map((directory) => `${path.sep}${directory}${path.sep}proposals${path.sep}${kind}${path.sep}`)
1466
+ .find((candidate) => source.includes(candidate));
1467
+ if (!marker) throw new Error(`Proposal ${kind.slice(0, -1)} is outside .tapp/proposals/${kind}: ${relative(root, source)}`);
1462
1468
  const index = source.indexOf(marker);
1463
- if (index < 0) throw new Error(`Proposal ${kind.slice(0, -1)} is outside .autotap/proposals/${kind}: ${relative(root, source)}`);
1464
- const destination = `${source.slice(0, index)}${path.sep}.autotap${path.sep}${kind}${path.sep}${source.slice(index + marker.length)}`;
1469
+ const directory = marker.split(path.sep).filter(Boolean)[0];
1470
+ const destination = `${source.slice(0, index)}${path.sep}${directory}${path.sep}${kind}${path.sep}${source.slice(index + marker.length)}`;
1465
1471
  if (!isInsideRoot(root, destination)) throw new Error(`Promotion destination escapes repository: ${destination}`);
1466
1472
  return destination;
1467
1473
  }
@@ -1480,7 +1486,7 @@ export async function promoteValidatedProposals(plan, { projectDir, ids = [] } =
1480
1486
 
1481
1487
  const mapCache = new Map();
1482
1488
  const mapForItem = (item) => {
1483
- const relativeMapPath = item.generation?.mapPath || (item.groundedBy || []).find((entry) => entry.type === "ui-map-node")?.mapPath || ".autotap/ui-map.json";
1489
+ const relativeMapPath = item.generation?.mapPath || (item.groundedBy || []).find((entry) => entry.type === "ui-map-node")?.mapPath || ".tapp/ui-map.json";
1484
1490
  const absolute = path.resolve(root, relativeMapPath);
1485
1491
  if (!isInsideRoot(root, absolute)) throw new Error(`UI Map for '${item.name}' escapes the repository: ${relativeMapPath}`);
1486
1492
  if (!mapCache.has(absolute)) {
@@ -1499,7 +1505,7 @@ export async function promoteValidatedProposals(plan, { projectDir, ids = [] } =
1499
1505
  for (const taskPath of item.generation.taskPaths || []) {
1500
1506
  const source = path.resolve(root, taskPath);
1501
1507
  if (!fs.existsSync(source)) throw new Error(`Generated Task is missing: ${taskPath}`);
1502
- if (!String(source).includes(`${path.sep}.autotap${path.sep}proposals${path.sep}tasks${path.sep}`)) continue;
1508
+ if (![".tapp", ".autotap"].some((directory) => String(source).includes(`${path.sep}${directory}${path.sep}proposals${path.sep}tasks${path.sep}`))) continue;
1503
1509
  const destination = promotedDestination(root, source, "tasks");
1504
1510
  moves.set(source, destination);
1505
1511
  let task = taskRecords.get(source);