@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 +4 -4
- package/README.md +27 -30
- package/bin/tapp.js +65 -27
- package/browser/app.js +2 -2
- package/browser/index.html +1 -1
- package/mcp-server/src/application-model.js +32 -26
- package/mcp-server/src/ci-report.js +5 -4
- package/mcp-server/src/ci-setup.js +16 -7
- package/mcp-server/src/index.js +28 -27
- package/mcp-server/src/maintenance-proposal.js +4 -4
- package/mcp-server/src/pr-selection.js +9 -8
- package/mcp-server/src/product-operations.js +19 -17
- package/mcp-server/src/project-config.js +8 -5
- package/mcp-server/src/project-paths.js +32 -0
- package/mcp-server/src/task-runtime.js +14 -10
- package/mcp-server/src/web-explorer.js +59 -0
- package/package.json +2 -2
- package/scripts/ci-gate.sh +12 -7
- package/scripts/flow_lib.py +1 -1
- package/docs/BROWSER-PRODUCT.md +0 -72
- package/docs/PRODUCT-ENGINE.md +0 -102
- package/docs/application-model.md +0 -271
- package/docs/scenarios.md +0 -95
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 .
|
|
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 `.
|
|
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 `.
|
|
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: ".
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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 `.
|
|
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 `.
|
|
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 `.
|
|
186
|
-
`.
|
|
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
|
-
`.
|
|
275
|
-
corresponding job. The legacy `.
|
|
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/.
|
|
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: .
|
|
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
|
|
355
|
-
|
|
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
|
|
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"] : ".
|
|
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]
|
|
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 [.
|
|
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))
|
|
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 .
|
|
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 .
|
|
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(".
|
|
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]
|
|
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 .
|
|
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 =
|
|
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 =
|
|
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 : ".
|
|
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
|
|
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 .
|
|
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 .
|
|
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
|
}
|
package/browser/index.html
CHANGED
|
@@ -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>.
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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 : ".
|
|
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.
|
|
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.
|
|
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 = ".
|
|
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 .
|
|
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,
|
|
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 = ".
|
|
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 = ".
|
|
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,
|
|
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 || ".
|
|
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 || ".
|
|
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,
|
|
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
|
|
1416
|
-
const
|
|
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(
|
|
1421
|
-
const reviewed = isInsideRoot(root, absolute) &&
|
|
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 =
|
|
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
|
-
|
|
1464
|
-
const destination = `${source.slice(0, index)}${path.sep}
|
|
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 || ".
|
|
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}
|
|
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);
|