@aarwitz/tapp 0.17.8 → 0.17.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tapp",
3
3
  "description": "Give Claude hands and eyes on iOS, Android, and web apps, with exploration, replayable flows, evidence, and deterministic CI gates.",
4
- "version": "0.17.8",
4
+ "version": "0.17.10",
5
5
  "author": {
6
6
  "name": "Aaron Horowitz",
7
7
  "url": "https://github.com/aarwitz"
@@ -24,7 +24,7 @@
24
24
  "command": "npx",
25
25
  "args": [
26
26
  "-y",
27
- "@aarwitz/tapp@0.17.8",
27
+ "@aarwitz/tapp@0.17.10",
28
28
  "mcp"
29
29
  ],
30
30
  "cwd": "${CLAUDE_PROJECT_DIR}"
@@ -1082,11 +1082,23 @@ class ExplorerTests: XCTestCase {
1082
1082
 
1083
1083
  let testEmail = resolve("OCQA_TEST_EMAIL")
1084
1084
  let testPassword = resolve("OCQA_TEST_PASSWORD")
1085
- if resolve("OCQA_CREDENTIALS_EXPLICIT") == "1" {
1086
- // Presence only: never print, persist, or expose credential values. Report rebuilding
1087
- // needs this durable marker to distinguish "not supplied" from "supplied but unused".
1085
+ // Presence only: never print, persist, or expose credential values. Report rebuilding
1086
+ // needs this durable marker to distinguish "not supplied" from "supplied but unused".
1087
+ // Decided by what actually reached the run config, not by a separate flag the run config
1088
+ // never carried (feedback #3: every iOS run reported credentialsProvided=false).
1089
+ if resolve("OCQA_CREDENTIALS_EXPLICIT") == "1" || !testEmail.isEmpty || !testPassword.isEmpty {
1088
1090
  print("OCQA_STATE:credentials_supplied")
1089
1091
  }
1092
+ // Capture conditions shared by every screenshot in this run, so a baseline diff across a
1093
+ // different device/scale is visibly a layout comparison (feedback #5: iOS reports carried
1094
+ // captureContext: null while the changelog promised it).
1095
+ do {
1096
+ let env = ProcessInfo.processInfo.environment
1097
+ let device = env["SIMULATOR_DEVICE_NAME"] ?? env["SIMULATOR_MODEL_IDENTIFIER"] ?? "iOS Simulator"
1098
+ let bounds = app.windows.firstMatch.exists ? app.windows.firstMatch.frame : app.frame
1099
+ let scale = UIScreen.main.scale
1100
+ print("OCQA_CONTEXT:{\"device\":\"\(escapeJSON(device))\",\"viewport\":{\"width\":\(Int(bounds.width.rounded())),\"height\":\(Int(bounds.height.rounded()))},\"deviceScaleFactor\":\(scale)}")
1101
+ }
1090
1102
 
1091
1103
  // --- Explicit login replay (config-driven): a recorded type/tap/wait sequence for custom
1092
1104
  // login UIs the heuristic preamble below can't parse. When configured it takes precedence. ---
package/README.md CHANGED
@@ -199,6 +199,7 @@ one by hand:
199
199
 
200
200
  ```bash
201
201
  npx -y @aarwitz/tapp@latest flow example
202
+ npx -y @aarwitz/tapp@latest flow steps # the step vocabulary: target semantics and pass condition per step
202
203
  npx -y @aarwitz/tapp@latest flow validate .tapp/flows/smoke.yml
203
204
  npx -y @aarwitz/tapp@latest flow run .tapp/flows/smoke.yml
204
205
  ```
@@ -349,7 +350,7 @@ jobs:
349
350
  timeout-minutes: 45
350
351
  steps:
351
352
  - uses: actions/checkout@v4
352
- - uses: aarwitz/tapp@v0.17.8 # or pin the reviewed release commit SHA
353
+ - uses: aarwitz/tapp@v0.17.9 # or pin the reviewed release commit SHA
353
354
  with:
354
355
  project: MyApp.xcodeproj # or MyApp.xcworkspace
355
356
  scheme: MyApp
@@ -399,7 +400,7 @@ Android CI runs on Linux with an emulator/device already connected. The Action c
399
400
  or accept a prebuilt one:
400
401
 
401
402
  ```yaml
402
- - uses: aarwitz/tapp@v0.17.8 # or pin the reviewed release commit SHA
403
+ - uses: aarwitz/tapp@v0.17.9 # or pin the reviewed release commit SHA
403
404
  with:
404
405
  platform: android
405
406
  android-app-id: com.acme.app
@@ -458,6 +459,15 @@ only an optional authoring/enrichment layer (`tapp_flow_generate`, `assert_ai`,
458
459
  The first tool call builds the harness once (~2 min, cached in `~/.tapp`; rebuilt automatically
459
460
  if you switch simulators). All captures land in `~/.tapp/captures/`.
460
461
 
462
+ ## Feedback
463
+
464
+ Tapp accepts feedback the way agents already work: as GitHub issues on
465
+ [aarwitz/tapp](https://github.com/aarwitz/tapp/issues). `tapp feedback "short title" --body "…"`
466
+ (or the `tapp_feedback` MCP tool) drafts an issue with the version, platform availability, and latest
467
+ capture id filled in and home paths and tokens redacted; `--submit` files it with your authenticated
468
+ `gh`. Drafts are the default because issues are public — an agent should show you the draft first.
469
+ Nothing is uploaded automatically; captures stay on your machine.
470
+
461
471
  ## License
462
472
 
463
473
  [MIT](./LICENSE)
package/bin/tapp.js CHANGED
@@ -270,7 +270,7 @@ function safeCommandUsage(verb) {
270
270
  shot: "tapp shot [--out FILE]",
271
271
  apps: "tapp apps",
272
272
  build: "tapp build [repo] [--scheme NAME] [--configuration NAME]",
273
- flow: "tapp flow example\ntapp flow validate FILE [--platform PLATFORM] [--map FILE]\ntapp flow run FILE [--actor NAME] [--email VALUE] [--password VALUE] [--device \"iPhone 13\"] [--viewport 390x844]\n Exit codes: 0 replay passed · 1 replay failed · 2 infrastructure/usage error",
273
+ flow: "tapp flow example\ntapp flow steps [--json]\ntapp flow validate FILE [--platform PLATFORM] [--map FILE]\ntapp flow run FILE [--actor NAME] [--email VALUE] [--password VALUE] [--device \"iPhone 13\"] [--viewport 390x844]\n Exit codes: 0 replay passed · 1 replay failed · 2 infrastructure/usage error",
274
274
  task: "tapp task validate FILE [--platform PLATFORM] [--map FILE]\ntapp task compile FILE --platform PLATFORM [--inputs JSON] [--out FILE]\ntapp task run FILE --platform PLATFORM [--url URL|--bundle-id ID|--app-id ID] [--inputs JSON]",
275
275
  contract: "tapp contract validate FILE [--platform PLATFORM] [--map FILE]\ntapp contract compile FILE --platform PLATFORM [--out FILE]\ntapp contract run FILE --platform PLATFORM [--url URL|--bundle-id ID|--app-id ID]\n Exit codes: 0 contract held · 1 contract failed · 2 infrastructure/usage error",
276
276
  scenario: "tapp scenario validate FILE [--project-dir DIR]\ntapp scenario run FILE --platform web --url URL [--project-dir DIR]\n Exit codes: 0 scenario passed · 1 scenario failed · 2 infrastructure/usage error",
@@ -282,6 +282,7 @@ function safeCommandUsage(verb) {
282
282
  actor: "tapp actor set NAME --email-env ENV --password-env ENV [--replace] [--project-dir DIR]\ntapp actor list [repo]",
283
283
  app: "tapp app [repo] [--no-open] [--port PORT]",
284
284
  report: "tapp report [captureId|latest]",
285
+ feedback: "tapp feedback \"short title\" [--body TEXT|--body-file FILE] [--type bug|idea|question] [--capture ID|latest|none] [--as human] [--submit] [--json]\n Drafts a public GitHub issue on aarwitz/tapp about tapp itself; --submit files it with the authenticated gh CLI.\n Exit codes: 0 drafted or filed · 1 gh submission failed · 2 usage error",
285
286
  doctor: "tapp doctor [--json]\n Exit codes: 0 environment healthy · 1 blocked (fix ❌ items)",
286
287
  install: "tapp install",
287
288
  mcp: "tapp mcp",
@@ -301,7 +302,7 @@ if (["--help", "-h"].includes(command)) {
301
302
  const knownCommands = new Set([
302
303
  "help", "version", "--version", "-v", "mcp", "init", "focus", "explore", "qa", "open",
303
304
  "tree", "shot", "screenshot", "apps", "build", "flow", "task", "contract", "scenario", "map",
304
- "pr", "plan", "baseline", "ci", "actor", "app", "studio", "report", "doctor", "install",
305
+ "pr", "plan", "baseline", "ci", "actor", "app", "studio", "report", "doctor", "install", "feedback",
305
306
  ]);
306
307
  if (!knownCommands.has(command)) {
307
308
  console.error(`❌ Unknown command: ${command}`);
@@ -695,15 +696,28 @@ switch (command) {
695
696
  const launchOptions = iosLaunchOptions(flags, rest);
696
697
  let target = positionals[0] || "";
697
698
  let baselineFindings;
699
+ let baselineCaptureContext = null;
698
700
  if (flags.baseline) {
699
701
  try {
700
702
  const parsed = JSON.parse(fs.readFileSync(flags.baseline, "utf8"));
701
703
  baselineFindings = Array.isArray(parsed) ? parsed : parsed.findings;
704
+ // Accept the 0.17.7 gate JSON's stamp-shaped `capture` too (explore JSON never
705
+ // stamped that key — there it is the evidence-folder record).
706
+ if (!Array.isArray(parsed)) baselineCaptureContext = parsed.captureContext || (parsed.capture?.viewport ? parsed.capture : null);
702
707
  } catch (e) {
703
708
  console.error(`❌ Could not read baseline ${flags.baseline}: ${e.message}`);
704
709
  process.exit(2);
705
710
  }
706
711
  }
712
+ // The same honesty rule the CI gate applies: a baseline captured at another device/viewport
713
+ // makes "resolved" a layout diff, not a fix — say so on the command the JSON hint points at.
714
+ const applyCaptureMismatch = (structured) => {
715
+ if (!structured?.regression || !baselineCaptureContext || !structured.captureContext) return;
716
+ if (JSON.stringify(baselineCaptureContext) === JSON.stringify(structured.captureContext)) return;
717
+ structured.regression.captureMismatch = { baseline: baselineCaptureContext, current: structured.captureContext };
718
+ const describe = (c) => [c?.device, c?.viewport ? `${c.viewport.width}x${c.viewport.height}` : null, c?.deviceScaleFactor ? `@${c.deviceScaleFactor}x` : null].filter(Boolean).join(" ") || "unknown";
719
+ console.error(`⚠️ Capture mismatch: the baseline was captured at ${describe(baselineCaptureContext)}, this run at ${describe(structured.captureContext)}. "Resolved" findings may reflect layout differences, not fixes — re-baseline at the same device/viewport to compare honestly.`);
720
+ };
707
721
  const engine = await engineImport();
708
722
  // Source-preparing bare explore (ADR-0005 §5): no explicit target + a repo application model →
709
723
  // drive the model's default target end to end. Managed web is built/started/waited-for and
@@ -734,6 +748,7 @@ switch (command) {
734
748
  });
735
749
  finishProgress();
736
750
  if (r.error) { printEngineError(r); process.exit(1); }
751
+ applyCaptureMismatch(r.structured);
737
752
  console.log(r.text);
738
753
  if (flags.json && typeof flags.json === "string") {
739
754
  fs.writeFileSync(flags.json, JSON.stringify(r.structured, null, 2));
@@ -804,6 +819,7 @@ switch (command) {
804
819
  printEngineError(r);
805
820
  process.exit(1);
806
821
  }
822
+ applyCaptureMismatch(r.structured);
807
823
  console.log(r.text);
808
824
  if (flags.json && typeof flags.json === "string") {
809
825
  fs.writeFileSync(flags.json, JSON.stringify(r.structured, null, 2));
@@ -1117,8 +1133,16 @@ switch (command) {
1117
1133
  console.log(`# Tapp Flow — deterministic, keyless replay\nname: sign-in-smoke\nplatform: web\nurl: https://example.test/login\nsteps:\n - login:\n email: $TEST_EMAIL\n password: $TEST_PASSWORD\n - wait_for: Dashboard\n - assert_screen: Dashboard\n`);
1118
1134
  break;
1119
1135
  }
1136
+ if (verb === "steps") {
1137
+ const { FLOW_ACTIONS } = await import(path.join(packageRoot, "mcp-server", "src", "flow-runtime.js"));
1138
+ if (flags.json === true) { console.log(JSON.stringify(FLOW_ACTIONS, null, 2)); break; }
1139
+ console.log("Flow step vocabulary — identical on ios, android, and web. Anything else fails `tapp flow validate`.\n");
1140
+ for (const a of FLOW_ACTIONS) console.log(` ${a.action.padEnd(14)} target: ${a.target}\n ${"".padEnd(14)} passes: ${a.passes}\n`);
1141
+ console.log("Targets are labels / accessibility ids / visible text, never coordinates. `assert_screen` checks the detected screen TITLE; use `assert_exists` for \"this text is on screen\".");
1142
+ break;
1143
+ }
1120
1144
  if (!["run", "validate"].includes(verb) || !flowPath) {
1121
- console.error("usage: tapp flow example\n tapp flow run <flow.yml> [--platform ios|android|web] [--actor NAME] [--email VALUE] [--password VALUE] [--url URL] [--app-id ID] [--apk FILE] [--serial ID]\n tapp flow validate <flow.yml>");
1145
+ console.error("usage: tapp flow example\n tapp flow steps [--json]\n tapp flow run <flow.yml> [--platform ios|android|web] [--actor NAME] [--email VALUE] [--password VALUE] [--url URL] [--app-id ID] [--apk FILE] [--serial ID]\n tapp flow validate <flow.yml>");
1122
1146
  process.exit(2);
1123
1147
  }
1124
1148
  const absolute = path.resolve(flowPath);
@@ -1126,7 +1150,7 @@ switch (command) {
1126
1150
  console.error(`❌ Flow not found: ${absolute}`);
1127
1151
  process.exit(2);
1128
1152
  }
1129
- const { loadFlowFile } = await import(path.join(packageRoot, "mcp-server", "src", "flow-runtime.js"));
1153
+ const { loadFlowFile, validateFlowSteps } = await import(path.join(packageRoot, "mcp-server", "src", "flow-runtime.js"));
1130
1154
  let flow;
1131
1155
  try { flow = loadFlowFile(absolute); } catch (error) {
1132
1156
  console.error(`❌ Invalid Flow: ${error.message}`);
@@ -1141,6 +1165,13 @@ switch (command) {
1141
1165
  console.error(`❌ Unsupported Flow platform: ${platform}`);
1142
1166
  process.exit(2);
1143
1167
  }
1168
+ // Static step checks run before validate AND run: a Flow outside the vocabulary (or a
1169
+ // coordinate tap) can never replay, so it must not reach a simulator (feedback #2).
1170
+ const stepErrors = validateFlowSteps(flow, platform);
1171
+ if (stepErrors.length) {
1172
+ console.error(`❌ Invalid ${platform} Flow — ${flow.name || path.basename(absolute)}:\n${stepErrors.map((e) => ` • ${e}`).join("\n")}\n Vocabulary: tapp flow steps`);
1173
+ process.exit(2);
1174
+ }
1144
1175
  if (verb === "validate") {
1145
1176
  const taskCount = Array.isArray(flow.taskPlan) ? flow.taskPlan.length : 0;
1146
1177
  console.log(`✅ Valid ${platform} Flow — ${flow.name} (${flow.steps.length} deterministic steps${taskCount ? ` compiled from ${taskCount} Task call${taskCount === 1 ? "" : "s"}` : ""})`);
@@ -1829,6 +1860,58 @@ switch (command) {
1829
1860
  break;
1830
1861
  }
1831
1862
 
1863
+ case "feedback": {
1864
+ const { flags, positionals } = parseVerbArgs(rest);
1865
+ const usage = 'Usage: tapp feedback "short title" [--body TEXT|--body-file FILE] [--type bug|idea|question] [--capture ID|latest|none] [--as human] [--submit] [--json]';
1866
+ const title = positionals.join(" ").trim();
1867
+ if (!title) { console.error(`❌ ${usage}`); process.exit(2); }
1868
+ const fb = await import(path.join(packageRoot, "mcp-server", "src", "feedback.js"));
1869
+ const type = typeof flags.type === "string" ? flags.type : "bug";
1870
+ if (!fb.FEEDBACK_TYPES.includes(type)) { console.error(`❌ --type must be one of ${fb.FEEDBACK_TYPES.join(", ")}\n${usage}`); process.exit(2); }
1871
+ let body = typeof flags.body === "string" ? flags.body : "";
1872
+ if (typeof flags["body-file"] === "string") {
1873
+ try { body = fs.readFileSync(flags["body-file"], "utf8"); } catch (error) { console.error(`❌ Could not read --body-file: ${error.message}`); process.exit(2); }
1874
+ }
1875
+ const captureFlag = typeof flags.capture === "string" ? flags.capture : "latest";
1876
+ const captureId = captureFlag === "none" ? null : captureFlag === "latest" ? fb.latestCaptureId(tappHome) : captureFlag;
1877
+ // Platform availability comes from doctor --json so the issue carries facts, not guesses.
1878
+ let doctor = null;
1879
+ try {
1880
+ const d = run(process.execPath, [path.join(packageRoot, "bin", "tapp.js"), "doctor", "--json"], { env: { ...process.env, TAPP_HOME: tappHome } });
1881
+ doctor = JSON.parse(d.stdout);
1882
+ } catch { /* feedback still works without the environment summary */ }
1883
+ let issue;
1884
+ try {
1885
+ issue = fb.composeFeedback({ title, body, type, version: pkg.version, doctor, captureId, filedBy: flags.as === "human" ? "human" : "agent" });
1886
+ } catch (error) { console.error(`❌ ${error.message}\n${usage}`); process.exit(2); }
1887
+ const url = fb.feedbackIssueUrl(issue);
1888
+ let submitted = false, issueUrl = null;
1889
+ if (flags.submit === true) {
1890
+ const status = fb.ghStatus();
1891
+ if (!status.available) {
1892
+ if (flags.json === true) console.log(JSON.stringify({ ...issue, url, submitted: false, error: "gh not authenticated" }, null, 2));
1893
+ else console.error(`❌ The GitHub CLI is not authenticated on this machine, so nothing was filed.\n Open this prefilled issue instead (it is public):\n ${url}`);
1894
+ process.exit(1);
1895
+ }
1896
+ const result = fb.submitFeedbackViaGh(issue);
1897
+ if (!result.ok) {
1898
+ if (flags.json === true) console.log(JSON.stringify({ ...issue, url, submitted: false, error: result.detail }, null, 2));
1899
+ else console.error(`❌ gh issue create failed: ${result.detail}\n Prefilled issue link: ${url}`);
1900
+ process.exit(1);
1901
+ }
1902
+ submitted = true; issueUrl = result.url;
1903
+ }
1904
+ if (flags.json === true) {
1905
+ console.log(JSON.stringify({ ...issue, url, submitted, issueUrl }, null, 2));
1906
+ break;
1907
+ }
1908
+ if (submitted) { ok("Filed feedback", issueUrl); break; }
1909
+ console.log(`📝 Feedback draft (not filed — issues on ${fb.FEEDBACK_REPO} are public; confirm with the user first):\n`);
1910
+ console.log(`Title: ${issue.title}\nLabels: ${issue.labels.join(", ")}\n\n${issue.body}`);
1911
+ console.log(`File it: tapp feedback ${JSON.stringify(issue.title)} … --submit (uses your authenticated gh)\nOr open: ${url}`);
1912
+ break;
1913
+ }
1914
+
1832
1915
  case "version":
1833
1916
  case "--version":
1834
1917
  case "-v": {
@@ -1859,6 +1942,8 @@ Repository & release:
1859
1942
  (--explore grounds the UI Map · --url URL · --platform · --dry-run · --refresh)
1860
1943
  tapp baseline create [repo] Run/import a conclusive full gate and save a target-scoped baseline
1861
1944
  tapp report [captureId] Open the HTML evidence page for a capture (default: latest)
1945
+ tapp feedback "title" Send a bug, idea, or question to the tapp maintainers as a GitHub issue
1946
+ (drafts by default · --submit files it · issues are public)
1862
1947
  tapp ci install [repo] Generate a reviewable target-aware GitHub workflow + CI manifest
1863
1948
  tapp actor set NAME Configure an actor using environment-variable names only (never values)
1864
1949
  tapp actor list [repo] Inspect named actors, sessions, provisioning, and secret env bindings
package/docs/scenarios.md CHANGED
@@ -74,7 +74,7 @@ tapp ci --platform web --url http://127.0.0.1:4180 \
74
74
  GitHub Action:
75
75
 
76
76
  ```yaml
77
- - uses: aarwitz/tapp@v0.17.8 # or pin the reviewed release commit SHA
77
+ - uses: aarwitz/tapp@v0.17.10 # or pin the reviewed release commit SHA
78
78
  with:
79
79
  platform: web
80
80
  url: http://127.0.0.1:4180
@@ -0,0 +1,104 @@
1
+ // Feedback to the tapp maintainers, the agent-native way: a GitHub issue on aarwitz/tapp.
2
+ //
3
+ // Shared by `tapp feedback` (CLI) and `tapp_feedback` (MCP). Both default to a DRAFT — the
4
+ // composed issue plus a prefilled github.com/.../issues/new URL — and only file the issue when
5
+ // explicitly asked (`--submit` / `submit: true`), because issues are public and an agent must not
6
+ // publish on a user's behalf without consent. Filing uses the machine's authenticated `gh` CLI;
7
+ // nothing is uploaded: captures stay local and only the capture id is referenced.
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import { spawnSync } from "node:child_process";
12
+
13
+ export const FEEDBACK_REPO = "aarwitz/tapp";
14
+ export const FEEDBACK_TYPES = ["bug", "idea", "question"];
15
+ const TYPE_LABEL = { bug: "bug", idea: "idea", question: "question" };
16
+
17
+ /** Strip home directories and token-shaped secrets before anything leaves the machine. */
18
+ export function redactText(text, home = os.homedir()) {
19
+ let s = String(text ?? "");
20
+ if (home && home.length > 1) s = s.split(home).join("~");
21
+ s = s
22
+ .replace(/\/Users\/[^/\s"']+/g, "~")
23
+ .replace(/\/home\/[^/\s"']+/g, "~")
24
+ .replace(/[A-Za-z]:\\Users\\[^\\\s"']+/g, "~")
25
+ .replace(/\b(sk-ant-[A-Za-z0-9_-]{8,}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[A-Z0-9]{16})\b/g, "[redacted]");
26
+ return s;
27
+ }
28
+
29
+ /** Newest capture directory name under TAPP_HOME, or null. Only the id is ever shared. */
30
+ export function latestCaptureId(tappHome) {
31
+ const dir = path.join(tappHome, "captures");
32
+ if (!fs.existsSync(dir)) return null;
33
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
34
+ .filter((e) => e.isDirectory())
35
+ .map((e) => ({ name: e.name, mtime: fs.statSync(path.join(dir, e.name)).mtimeMs }))
36
+ .sort((a, b) => b.mtime - a.mtime);
37
+ return entries[0]?.name ?? null;
38
+ }
39
+
40
+ /** One line of platform availability from `tapp doctor --json` output. */
41
+ export function summarizeDoctor(doctor) {
42
+ if (!doctor || typeof doctor !== "object") return null;
43
+ const p = doctor.platforms || {};
44
+ const parts = [];
45
+ if (p.ios) {
46
+ const detail = [p.ios.xcode, p.ios.bootedSimulator ? `${p.ios.bootedSimulator} booted` : ""].filter(Boolean).join(", ");
47
+ parts.push(`iOS ${p.ios.available ? "✅" : "⬜"}${detail ? ` (${detail})` : ""}`);
48
+ }
49
+ if (p.android) parts.push(`Android ${p.android.adb ? "✅" : "⬜"}${p.android.devicesConnected ? ` (${p.android.devicesConnected} device)` : ""}`);
50
+ if (p.web) parts.push(`web ${p.web.available ? "✅" : "⬜"}`);
51
+ return parts.join(" · ") || null;
52
+ }
53
+
54
+ /** Build the issue: redacted title/body plus an automatic, path-free context footer. */
55
+ export function composeFeedback({
56
+ title, body = "", type = "bug", version = "unknown", node = process.version,
57
+ platform = `${process.platform} ${process.arch}`, doctor = null, captureId = null, filedBy = "agent",
58
+ }) {
59
+ const cleanTitle = redactText(title).trim();
60
+ if (!cleanTitle) throw new Error("feedback needs a short title");
61
+ if (!FEEDBACK_TYPES.includes(type)) throw new Error(`type must be one of: ${FEEDBACK_TYPES.join(", ")}`);
62
+ const context = [`- tapp ${version} · node ${node} · ${platform}`];
63
+ const platforms = summarizeDoctor(doctor);
64
+ if (platforms) context.push(`- platforms: ${platforms}`);
65
+ if (captureId) context.push(`- capture: \`${redactText(captureId)}\` (kept locally — nothing is uploaded; evidence can be shared privately on request)`);
66
+ const description = redactText(body).trim() || "_(no description provided)_";
67
+ const byline = filedBy === "agent" ? ", filed by a coding agent on the user's behalf" : "";
68
+ const full = `${description}\n\n---\n_Filed with \`tapp feedback\` (${type}${byline}). This issue is public._\n${context.join("\n")}\n`;
69
+ const labels = ["feedback", TYPE_LABEL[type]];
70
+ if (filedBy === "agent") labels.push("agent-filed");
71
+ return { title: cleanTitle.slice(0, 200), body: full, labels };
72
+ }
73
+
74
+ /** Prefilled "new issue" link — works for anyone with a GitHub account, no CLI needed. */
75
+ export function feedbackIssueUrl({ title, body, labels }) {
76
+ const url = new URL(`https://github.com/${FEEDBACK_REPO}/issues/new`);
77
+ url.searchParams.set("title", title);
78
+ url.searchParams.set("body", body);
79
+ url.searchParams.set("labels", labels.join(","));
80
+ return url.toString();
81
+ }
82
+
83
+ export function ghStatus(ghBin = process.env.TAPP_GH_BIN || "gh") {
84
+ const r = spawnSync(ghBin, ["auth", "status"], { encoding: "utf8" });
85
+ return { available: r.status === 0, detail: (r.stderr || r.stdout || "").trim() };
86
+ }
87
+
88
+ export function submitFeedbackViaGh(issue, ghBin = process.env.TAPP_GH_BIN || "gh") {
89
+ // Labels can only be set by accounts with triage rights on the repo. Anyone else (which is
90
+ // every real user) gets the issue filed without them; the footer already carries the kind
91
+ // and the maintainer applies labels on triage. Try with labels first, then without.
92
+ const attempt = (withLabels) => spawnSync(ghBin, [
93
+ "issue", "create", "--repo", FEEDBACK_REPO,
94
+ "--title", issue.title, "--body-file", "-",
95
+ ...(withLabels ? ["--label", issue.labels.join(",")] : []),
96
+ ], { encoding: "utf8", input: issue.body });
97
+ let r = attempt(true);
98
+ let labelsApplied = r.status === 0;
99
+ if (r.status !== 0 && /label/i.test(`${r.stderr || ""}${r.stdout || ""}`)) { r = attempt(false); labelsApplied = false; }
100
+ const out = (r.stdout || "").trim();
101
+ const err = (r.stderr || "").trim();
102
+ const url = (out.match(/https:\/\/github\.com\/\S+/) || [])[0] || null;
103
+ return { ok: r.status === 0 && Boolean(url), url, labelsApplied, detail: r.status === 0 ? out : (err || out) };
104
+ }
@@ -36,6 +36,60 @@ export function normalizeFlowStep(raw) {
36
36
  return { action: key.toLowerCase(), target: body === true ? "" : String(body ?? ""), value: body === true ? "" : String(body ?? ""), params: {}, ...(raw.__tappTask?.name ? { task: raw.__tappTask.name } : {}) };
37
37
  }
38
38
 
39
+ // The committed Flow step vocabulary. Every driver (XCUITest, Android, browser) implements exactly
40
+ // this table; `tapp flow steps` prints it and `tapp flow validate` rejects anything outside it, so
41
+ // a Flow that validates can actually replay (feedback #2: coordinate taps and `click:` used to
42
+ // validate and then fail at runtime).
43
+ export const FLOW_ACTIONS = Object.freeze([
44
+ { action: "tap", target: "a visible label / accessibility id", passes: "the control was found and tapped; coordinates are not accepted — use the session's tap-by-point to learn the label" },
45
+ { action: "type", target: "{field, value}", passes: "the field was found and now holds the value ($TEST_EMAIL/$TEST_PASSWORD substitute)" },
46
+ { action: "login", target: "{email, password} (defaults to $TEST_EMAIL/$TEST_PASSWORD)", passes: "credentials were entered and submitted and the login form went away" },
47
+ { action: "swipe", target: "up | down | left | right", passes: "the gesture was performed" },
48
+ { action: "back", target: "(none)", passes: "the platform back navigation was performed" },
49
+ { action: "wait", target: "milliseconds (fixed pause; prefer wait_for)", passes: "always" },
50
+ { action: "wait_for", target: "label / text (+ timeoutMs)", passes: "the element appeared before the timeout" },
51
+ { action: "assert_screen", target: "the detected SCREEN TITLE (navigation bar / heading), not arbitrary text", passes: "the current screen's title equals the target" },
52
+ { action: "assert_exists", target: "label / text", passes: "an element with that text or id is present" },
53
+ { action: "assert_absent", target: "label / text", passes: "no element with that text or id is present" },
54
+ { action: "assert_text", target: "{of, contains}", passes: "the element's text contains the substring" },
55
+ { action: "assert_ai", target: "a natural-language expectation", passes: "the vision judge agrees (needs ANTHROPIC_API_KEY; advisory)" },
56
+ ]);
57
+ const FLOW_ACTION_NAMES = new Set(FLOW_ACTIONS.map((a) => a.action));
58
+ const ALIASES = { click: "tap", press: "tap", fill: "type", input: "type", sleep: "wait", wait_for_text: "wait_for", assert_visible: "assert_exists", expect: "assert_exists" };
59
+
60
+ // Static checks a Flow must pass before any runtime is launched. Returns human-readable errors;
61
+ // an empty array means every step is in the vocabulary and shaped so a driver can execute it.
62
+ export function validateFlowSteps(flow, platform = inferFlowPlatform(flow)) {
63
+ const errors = [];
64
+ const steps = Array.isArray(flow?.steps) ? flow.steps : [];
65
+ steps.forEach((raw, i) => {
66
+ const step = normalizeFlowStep(raw);
67
+ const n = i + 1;
68
+ if (step.action === "noop") { errors.push(`step ${n}: empty step`); return; }
69
+ if (!FLOW_ACTION_NAMES.has(step.action)) {
70
+ const alias = ALIASES[step.action];
71
+ errors.push(`step ${n}: unknown action '${step.action}'${alias ? ` — did you mean '${alias}'? (web flows use tap:, not click:)` : ""}; run \`tapp flow steps\` for the vocabulary`);
72
+ return;
73
+ }
74
+ if (step.action === "tap" && /^\s*-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?\s*$/.test(step.target)) {
75
+ errors.push(`step ${n}: tap target '${step.target.trim()}' is a coordinate; Flow taps are label-only on ${platform} (use tapp_session_act tap {x,y} to learn the label, then record it)`);
76
+ }
77
+ if (["tap", "wait_for", "assert_screen", "assert_exists", "assert_absent"].includes(step.action) && !step.target.trim()) {
78
+ errors.push(`step ${n}: ${step.action} needs a target`);
79
+ }
80
+ if (step.action === "type" && (!step.target.trim() || !("value" in (step.params || {})))) {
81
+ errors.push(`step ${n}: type needs {field, value}`);
82
+ }
83
+ if (step.action === "assert_text" && (!step.target.trim() || !step.value)) {
84
+ errors.push(`step ${n}: assert_text needs {of, contains}`);
85
+ }
86
+ if (step.action === "swipe" && step.target && !["up", "down", "left", "right"].includes(step.target.trim().toLowerCase())) {
87
+ errors.push(`step ${n}: swipe direction must be up|down|left|right`);
88
+ }
89
+ });
90
+ return errors;
91
+ }
92
+
39
93
  export function flowVariables(flow, overrides = {}) {
40
94
  return {
41
95
  TEST_EMAIL: process.env.OCQA_TEST_EMAIL || "test@example.com",
@@ -907,10 +907,39 @@ function recordStep(cmd, result) {
907
907
  if (newScreen) activeSession.lastScreen = newScreen;
908
908
  }
909
909
 
910
+ // Argument shapes tapp_session_act accepts, per action. A malformed call is answered here with
911
+ // the accepted shape and never reaches the driver, so it can neither time out nor disturb the
912
+ // session (feedback #7: `{action:"wait", seconds:3}` used to wait on an empty target).
913
+ const SESSION_ACT_ARGS = Object.freeze({
914
+ tap: "{id: <label|accessibility id>} or {x, y} (points)",
915
+ type: "{id|label: <field>, text: <value>}",
916
+ wait: "{text|id: <label to wait for>, timeoutMs?: <default 5000, max 60000>} — there is no fixed sleep; wait for something",
917
+ login: "{email?, password?} (defaults to the session's credentials)",
918
+ swipe: "{direction: up|down|left|right}",
919
+ back: "{}",
920
+ tree: "{verbose?: true}",
921
+ screenshot: "{label?}",
922
+ });
923
+ export function sessionActUsageError(cmd = {}) {
924
+ const action = String(cmd.action || "");
925
+ if (!SESSION_ACT_ARGS[action]) return `Unknown action '${action || "(none)"}'. Accepted: ${Object.keys(SESSION_ACT_ARGS).join(", ")}.`;
926
+ const has = (k) => cmd[k] !== undefined && cmd[k] !== null && String(cmd[k]).trim() !== "";
927
+ const bad = (why) => `${why}. ${action} takes ${SESSION_ACT_ARGS[action]}.`;
928
+ if (action === "wait" && !has("text") && !has("id")) return bad(`wait needs a target${cmd.seconds !== undefined || cmd.ms !== undefined ? " (seconds/ms are not arguments)" : ""}`);
929
+ if (action === "wait" && cmd.timeoutMs !== undefined && !(Number.isFinite(Number(cmd.timeoutMs)) && Number(cmd.timeoutMs) > 0)) return bad("timeoutMs must be a positive number of milliseconds");
930
+ if (action === "tap" && !has("id") && !has("label") && !(Number.isFinite(cmd.x) && Number.isFinite(cmd.y))) return bad("tap needs an id/label or both x and y");
931
+ if (action === "type" && !has("id") && !has("label")) return bad("type needs the field's id/label");
932
+ if (action === "type" && cmd.text === undefined) return bad("type needs text");
933
+ if (action === "swipe" && cmd.direction !== undefined && !["up", "down", "left", "right"].includes(String(cmd.direction))) return bad("direction must be up|down|left|right");
934
+ return null;
935
+ }
936
+
910
937
  async function sessionAct(cmd) {
911
938
  const startedAt = Date.now();
912
939
  const done = (result) => ({ ...result, durationMs: Date.now() - startedAt });
913
940
  if (!activeSession || activeSession.ended) return done({ error: "No active session. Call tapp_session_start first." });
941
+ const usage = sessionActUsageError(cmd);
942
+ if (usage) return done({ status: "usage", detail: usage, ...treeSnapshot(), recordedSteps: activeSession.recording.length });
914
943
  let coordinateResolvedTarget = "";
915
944
  if (cmd.action === "tap" && !cmd.id && Number.isFinite(cmd.x) && Number.isFinite(cmd.y)) {
916
945
  coordinateResolvedTarget = semanticTargetAtPoint(activeSession.latestTree?.elements, cmd.x, cmd.y);
@@ -2438,6 +2467,27 @@ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
2438
2467
 
2439
2468
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
2440
2469
  tools: [
2470
+ {
2471
+ name: "tapp_feedback",
2472
+ title: "Send feedback to the tapp maintainers",
2473
+ description:
2474
+ "Draft a public GitHub issue on aarwitz/tapp about tapp itself — a bug in tapp, an idea, or a " +
2475
+ "question (not a finding about the app under test). Adds the tapp version, platform availability, " +
2476
+ "and the latest capture id automatically and redacts home paths and tokens. Default is a DRAFT: " +
2477
+ "returns the composed issue plus a prefilled github.com URL the user can open. Pass submit:true only " +
2478
+ "after the user has agreed to file a public issue; filing uses this machine's authenticated GitHub CLI.",
2479
+ inputSchema: {
2480
+ type: "object",
2481
+ properties: {
2482
+ title: { type: "string", description: "Short, specific title" },
2483
+ body: { type: "string", description: "What happened, what you expected, the exact command or tool call. No secrets or private source." },
2484
+ type: { type: "string", enum: ["bug", "idea", "question"], description: "Default bug" },
2485
+ capture: { type: "string", description: "Capture id to reference, 'latest' (default) or 'none'" },
2486
+ submit: { type: "boolean", description: "File the issue now with gh (needs the user's consent). Default false = draft only." },
2487
+ },
2488
+ required: ["title"],
2489
+ },
2490
+ },
2441
2491
  {
2442
2492
  name: "tapp_health",
2443
2493
  title: "Check Tapp readiness",
@@ -3170,6 +3220,40 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
3170
3220
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
3171
3221
  const { name, arguments: args = {} } = request.params;
3172
3222
 
3223
+ if (name === "tapp_feedback") {
3224
+ const fb = await import("./feedback.js");
3225
+ const { fileURLToPath } = await import("node:url");
3226
+ const packageRootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
3227
+ const title = String(args.title || "").trim();
3228
+ if (!title) return errorResult("tapp_feedback needs a short title", {});
3229
+ const type = typeof args.type === "string" ? args.type : "bug";
3230
+ if (!fb.FEEDBACK_TYPES.includes(type)) return errorResult(`type must be one of ${fb.FEEDBACK_TYPES.join(", ")}`, {});
3231
+ const tappHomeDir = (process.env.TAPP_HOME || path.join(os.homedir(), ".tapp")).trim();
3232
+ const captureArg = typeof args.capture === "string" ? args.capture : "latest";
3233
+ const captureId = captureArg === "none" ? null : captureArg === "latest" ? fb.latestCaptureId(tappHomeDir) : captureArg;
3234
+ let doctor = null;
3235
+ try {
3236
+ const d = await runCommand(process.execPath, [path.join(packageRootDir, "bin", "tapp.js"), "doctor", "--json"], { timeoutMs: 60_000, env: { TAPP_HOME: tappHomeDir } });
3237
+ doctor = JSON.parse(d.stdout);
3238
+ } catch { /* optional context */ }
3239
+ const version = JSON.parse(fs.readFileSync(path.join(packageRootDir, "package.json"), "utf8")).version;
3240
+ let issue;
3241
+ try { issue = fb.composeFeedback({ title, body: String(args.body || ""), type, version, doctor, captureId, filedBy: "agent" }); }
3242
+ catch (error) { return errorResult(error.message, {}); }
3243
+ const url = fb.feedbackIssueUrl(issue);
3244
+ if (args.submit === true) {
3245
+ const status = fb.ghStatus();
3246
+ if (!status.available) return errorResult("The GitHub CLI is not authenticated on this machine, so nothing was filed. Give the user the prefilled link instead.", { url, ...issue, submitted: false });
3247
+ const result = fb.submitFeedbackViaGh(issue);
3248
+ if (!result.ok) return errorResult(`gh issue create failed: ${result.detail}`, { url, ...issue, submitted: false });
3249
+ return richResult(`✅ Filed feedback: ${result.url}`, { submitted: true, issueUrl: result.url, ...issue });
3250
+ }
3251
+ return richResult(
3252
+ `📝 Feedback drafted, not filed. Issues on ${fb.FEEDBACK_REPO} are public — confirm with the user, then call tapp_feedback again with submit: true, or give them this prefilled link:\n${url}\n\nTitle: ${issue.title}\nLabels: ${issue.labels.join(", ")}\n\n${issue.body}`,
3253
+ { submitted: false, url, ...issue },
3254
+ );
3255
+ }
3256
+
3173
3257
  if (name === "tapp_health") {
3174
3258
  const coreChecks = [];
3175
3259
  coreChecks.push({
@@ -298,6 +298,7 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
298
298
  // 40-action campaign. Drivers signal the real cause in COMPLETE.stop; captures from drivers
299
299
  // that predate the field keep the old inference.
300
300
  const driverStop = base.complete && typeof base.complete === "object" ? base.complete.stop : null;
301
+ const nativeOutcome = base.complete && typeof base.complete === "object" && typeof base.complete.outcome === "string" ? base.complete.outcome : null;
301
302
  const stopReason = unexercisedLoginWall ? (credentialsProvided ? "login-wall-credentials-unused" : "login-wall-no-credentials")
302
303
  : timeBudgetExhausted ? "time-budget-exhausted"
303
304
  : !coverageFloorMet ? "coverage-floor-not-met"
@@ -306,6 +307,11 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
306
307
  // Any other driver-signalled cause (navigation-trap, app-crashed, stuck-no-progress …)
307
308
  // passes through verbatim: "completed" is ONLY the exhausted action budget.
308
309
  : driverStop && driverStop !== "action-budget" && driverStop !== "time-budget" ? String(driverStop)
310
+ // XCUITest predates COMPLETE.stop and signals the cause through COMPLETE.outcome instead
311
+ // (feedback #5: a run whose marker said limited_surface must not read as "completed").
312
+ : nativeOutcome === "limited_surface" ? "limited-surface"
313
+ : nativeOutcome === "timeout" ? "time-budget-exhausted"
314
+ : nativeOutcome && nativeOutcome.startsWith("crash") ? "app-crashed"
309
315
  : "completed";
310
316
 
311
317
  const headline = timeBudgetExhausted
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aarwitz/tapp",
3
- "version": "0.17.8",
3
+ "version": "0.17.10",
4
4
  "mcpName": "io.github.aarwitz/tapp",
5
5
  "description": "Let coding agents verify UI changes on real iOS, Android, and web surfaces, then enforce reviewed proof in deterministic CI.",
6
6
  "license": "MIT",
@@ -63,6 +63,29 @@ def raw_json(path):
63
63
  print(json.dumps(load_flow(path) or {}))
64
64
 
65
65
 
66
+ ABORT_PATTERNS = [
67
+ r"Failed to synthesize event: [^\n]*",
68
+ r"Neither element nor any descendant has keyboard focus[^\n]*",
69
+ r"Test Case '[^']*' failed \([^)]*\)",
70
+ r"Error Domain=[^\n]*",
71
+ r"Unable to (?:find|launch|boot)[^\n]*",
72
+ r"App state is [^\n]*",
73
+ r"Timed out [^\n]*",
74
+ r"Testing failed:[^\n]*",
75
+ r"error: [^\n]*",
76
+ r"\*\* TEST (?:EXECUTE )?FAILED \*\*",
77
+ ]
78
+
79
+
80
+ def harness_abort_reason(log):
81
+ """First XCTest/xcodebuild line that explains why a run ended before step 1."""
82
+ for pat in ABORT_PATTERNS:
83
+ m = re.search(pat, log)
84
+ if m:
85
+ return m.group(0).strip()[:300]
86
+ return None
87
+
88
+
66
89
  def report(path, as_json=False):
67
90
  log = open(path, encoding="utf-8", errors="replace").read()
68
91
  steps = []
@@ -97,8 +120,19 @@ def report(path, as_json=False):
97
120
  executed = (result or {}).get("executed", len(steps))
98
121
  passed_steps = sum(1 for s in steps if s.get("status") == "pass")
99
122
 
123
+ # A run that died before step 1 used to print `0 passed · 0 failed · 0/0 executed` and
124
+ # nothing else; the XCTest reason lived only in flow.log (feedback #1). Surface it as the
125
+ # failure of the first step so the scoreboard says why instead of looking like a crash.
126
+ abort_reason = None
127
+ if not steps and not passed:
128
+ abort_reason = harness_abort_reason(log)
129
+ steps.append({"index": 1, "action": "harness", "target": "", "status": "fail",
130
+ "detail": abort_reason or "the harness exited before the first step; see flow.log"})
131
+ failed = max(failed, 1)
132
+
100
133
  if as_json:
101
- print(json.dumps({"name": name, "kind": kind, "passed": passed, "total": total, "executed": executed, "failed": failed, "steps": steps}))
134
+ print(json.dumps({"name": name, "kind": kind, "passed": passed, "total": total, "executed": executed, "failed": failed,
135
+ **({"abortReason": abort_reason} if abort_reason else {}), "steps": steps}))
102
136
  return 0 if passed else 1
103
137
 
104
138
  icon = {"pass": "✅", "fail": "❌", "skip": "⚪️"}
@@ -183,8 +183,9 @@ run_harness_test() {
183
183
  "OCQA_BUNDLE_ID": "$bundle_id",
184
184
  "OCQA_MAX_ACTIONS": "$max_actions",
185
185
  "OCQA_TIMEOUT_SECONDS": "$timeout_secs",
186
- "OCQA_TEST_EMAIL": "${OCQA_TEST_EMAIL:-qa@example.com}",
187
- "OCQA_TEST_PASSWORD": "${OCQA_TEST_PASSWORD:-Tapp123!}"$interactive_line$overrides_line$launch_args_line$launch_env_line$login_steps_line$pr_target_line$visual_ready_line$recording_started_line
186
+ "OCQA_TEST_EMAIL": "${OCQA_TEST_EMAIL:-}",
187
+ "OCQA_TEST_PASSWORD": "${OCQA_TEST_PASSWORD:-}",
188
+ "OCQA_CREDENTIALS_EXPLICIT": "${OCQA_CREDENTIALS_EXPLICIT:-}"$interactive_line$overrides_line$launch_args_line$launch_env_line$login_steps_line$pr_target_line$visual_ready_line$recording_started_line
188
189
  }
189
190
  CONF
190
191
 
@@ -447,8 +448,8 @@ OCQA_COMPLETE:{\"actions\":0,\"states\":0,\"issues\":1,\"screens\":\"\",\"outcom
447
448
  "OCQA_SESSION_CMD_PATH": "$SESS_CMD",
448
449
  "OCQA_SESSION_RESULT_PATH": "$SESS_RES",
449
450
  "OCQA_SESSION_TIMEOUT": "$SESS_TIMEOUT",
450
- "OCQA_TEST_EMAIL": "${OCQA_TEST_EMAIL:-qa@example.com}",
451
- "OCQA_TEST_PASSWORD": "${OCQA_TEST_PASSWORD:-Tapp123!}"$sess_args_line$sess_env_line
451
+ "OCQA_TEST_EMAIL": "${OCQA_TEST_EMAIL:-}",
452
+ "OCQA_TEST_PASSWORD": "${OCQA_TEST_PASSWORD:-}"$sess_args_line$sess_env_line
452
453
  }
453
454
  CONF
454
455
  xctestrun=$(find "$HARNESS_DERIVED/Build/Products" -name "*.xctestrun" 2>/dev/null | head -1)
@@ -92,7 +92,7 @@ After the focused fast path, read returned `elements[]` before any remaining act
92
92
  ids or visible labels, check `hittable`, tap a field before typing, and wait for navigation or async
93
93
  content. Use coordinates only as a last resort. End the session when finished.
94
94
 
95
- Do not edit the app merely because testing found a defect unless the user also asked for a fix. State
96
- what the evidence proves and what remains untested.
95
+ Do not edit the app merely because testing found a defect unless the user also asked for a fix. State what the evidence proves and what remains untested.
96
+ If tapp itself misbehaves, draft feedback with `npx -y @aarwitz/tapp@latest feedback "title"` or `tapp_feedback`; it is public — draft first, `--submit` only with the user's OK.
97
97
 
98
98
  Read [references/commands.md](references/commands.md) only for exact CLI/MCP syntax, Flow replay, credentials, or platform prerequisites.
@@ -14,6 +14,7 @@ npx -y @aarwitz/tapp@latest tree [target] --json
14
14
  npx -y @aarwitz/tapp@latest shot
15
15
  npx -y @aarwitz/tapp@latest report latest
16
16
  npx -y @aarwitz/tapp@latest doctor
17
+ npx -y @aarwitz/tapp@latest feedback "short title" --body "what happened" --type bug|idea|question # draft; add --submit to file (public)
17
18
  ```
18
19
 
19
20
  Platform examples:
@@ -100,6 +101,8 @@ ask the user rather than pretending the explored surface was complete.
100
101
  Flow YAML belongs under `.tapp/flows/` and can replay without a model or API key:
101
102
 
102
103
  ```bash
104
+ npx -y @aarwitz/tapp@latest flow steps # vocabulary: assert_screen = screen TITLE, assert_exists = text present; taps are label-only
105
+ npx -y @aarwitz/tapp@latest flow validate .tapp/flows/smoke.yml
103
106
  npx -y @aarwitz/tapp@latest flow run .tapp/flows/smoke.yml
104
107
  npx -y @aarwitz/tapp@latest ci
105
108
  ```