From 09a7a1fe8f7fd7478933ad7dd7a91e4df7d3ee26 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 06:30:27 +0000 Subject: [PATCH 01/10] build(deps): bump ip-address from 10.2.0 to 10.4.0 in /mcp-adapter Bumps [ip-address](https://github.com/beaugunderson/ip-address) from 10.2.0 to 10.4.0. - [Release notes](https://github.com/beaugunderson/ip-address/releases) - [Commits](https://github.com/beaugunderson/ip-address/compare/v10.2.0...v10.4.0) --- updated-dependencies: - dependency-name: ip-address dependency-version: 10.4.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- mcp-adapter/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mcp-adapter/package-lock.json b/mcp-adapter/package-lock.json index 824e03d..f7b9f8a 100644 --- a/mcp-adapter/package-lock.json +++ b/mcp-adapter/package-lock.json @@ -647,9 +647,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "license": "MIT", "engines": { "node": ">= 12" From f39584faa99f5f7f582ea21503d29cc05cb6d763 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 06:30:32 +0000 Subject: [PATCH 02/10] build(deps): bump ip-address from 10.2.0 to 10.4.0 Bumps [ip-address](https://github.com/beaugunderson/ip-address) from 10.2.0 to 10.4.0. - [Release notes](https://github.com/beaugunderson/ip-address/releases) - [Commits](https://github.com/beaugunderson/ip-address/compare/v10.2.0...v10.4.0) --- updated-dependencies: - dependency-name: ip-address dependency-version: 10.4.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7101f0a..f0ced17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ }, "mcp-adapter": { "name": "@astrolabe/mcp-adapter", - "version": "2.0.0", + "version": "2.1.1", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", "zod": "^3.25.76" @@ -665,9 +665,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "license": "MIT", "engines": { "node": ">= 12" From 47f9817049e55a4777653563ec0012b99a0c6327 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:20:35 +0000 Subject: [PATCH 03/10] build(deps): bump fast-uri from 3.1.4 to 3.1.5 in /mcp-adapter Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.4 to 3.1.5. - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.4...v3.1.5) --- updated-dependencies: - dependency-name: fast-uri dependency-version: 3.1.5 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- mcp-adapter/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mcp-adapter/package-lock.json b/mcp-adapter/package-lock.json index 824e03d..52df060 100644 --- a/mcp-adapter/package-lock.json +++ b/mcp-adapter/package-lock.json @@ -459,9 +459,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", From e47ddbf7916a748c50e5d805f94be39d6902d73d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:20:43 +0000 Subject: [PATCH 04/10] build(deps): bump fast-uri from 3.1.4 to 3.1.5 Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.4 to 3.1.5. - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.4...v3.1.5) --- updated-dependencies: - dependency-name: fast-uri dependency-version: 3.1.5 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7101f0a..0c00783 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ }, "mcp-adapter": { "name": "@astrolabe/mcp-adapter", - "version": "2.0.0", + "version": "2.1.1", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", "zod": "^3.25.76" @@ -477,9 +477,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", From d28eeed686f14010aa0c00ed2727da07f8de3cba Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 12:07:04 +0000 Subject: [PATCH 05/10] build(deps): bump hono from 4.12.27 to 4.13.1 in /mcp-adapter Bumps [hono](https://github.com/honojs/hono) from 4.12.27 to 4.13.1. - [Release notes](https://github.com/honojs/hono/releases) - [Commits](https://github.com/honojs/hono/compare/v4.12.27...v4.13.1) --- updated-dependencies: - dependency-name: hono dependency-version: 4.13.1 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- mcp-adapter/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mcp-adapter/package-lock.json b/mcp-adapter/package-lock.json index 824e03d..b54b8e1 100644 --- a/mcp-adapter/package-lock.json +++ b/mcp-adapter/package-lock.json @@ -596,9 +596,9 @@ } }, "node_modules/hono": { - "version": "4.12.27", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", - "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.1.tgz", + "integrity": "sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==", "license": "MIT", "engines": { "node": ">=16.9.0" From 5201ec54097f6c5ced0b6d2a990c06313fc70c1a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 12:54:12 +0000 Subject: [PATCH 06/10] build(deps): bump hono from 4.12.31 to 4.13.1 Bumps [hono](https://github.com/honojs/hono) from 4.12.31 to 4.13.1. - [Release notes](https://github.com/honojs/hono/releases) - [Commits](https://github.com/honojs/hono/compare/v4.12.31...v4.13.1) --- updated-dependencies: - dependency-name: hono dependency-version: 4.13.1 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7101f0a..3165de5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ }, "mcp-adapter": { "name": "@astrolabe/mcp-adapter", - "version": "2.0.0", + "version": "2.1.1", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", "zod": "^3.25.76" @@ -614,9 +614,9 @@ } }, "node_modules/hono": { - "version": "4.12.31", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", - "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.1.tgz", + "integrity": "sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==", "license": "MIT", "engines": { "node": ">=16.9.0" From 372218ae8ea784d025944cdc6705534aa88f168b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BD=A9=E8=BE=95=E5=8D=81=E5=9B=9B?= Date: Mon, 17 Aug 2026 17:45:23 +0800 Subject: [PATCH 07/10] feat: enforce Skill design verification --- skills/astrolabe/SKILL.md | 51 +++++-- skills/astrolabe/agents/openai.yaml | 4 +- skills/astrolabe/evals/evals.json | 130 +++++++++++++++++ .../references/design-verification.md | 132 ++++++++++++++++++ test/review-contract.test.mjs | 87 ++++++++++++ 5 files changed, 390 insertions(+), 14 deletions(-) create mode 100644 skills/astrolabe/evals/evals.json create mode 100644 skills/astrolabe/references/design-verification.md diff --git a/skills/astrolabe/SKILL.md b/skills/astrolabe/SKILL.md index c2520b8..648d07d 100644 --- a/skills/astrolabe/SKILL.md +++ b/skills/astrolabe/SKILL.md @@ -27,8 +27,29 @@ is running. capabilities belong to the selected Runtime. - Use the exact latest `appId` for every subsequent call. -2. Freeze the screen context. - - Start with `inspect_screen` and retain its `snapshotId`. +2. Establish Target State Readiness before formal acceptance. + - Observe the actual current screen. Do not infer the target from source, + route names, or intended navigation. + - If the target is absent, use interaction only when both its MCP Tool and + Runtime capability exist, and only through visible normal UI. + - Otherwise request developer manual navigation, real-condition preparation, + or explicit debug-fixture/mock authorization. Never use an App Router, + private initializer, unapproved deep link, source shortcut, or business-state + mutation to prepare the target. + - After any preparation, rediscover the App and re-observe the target. Treat + every readiness-observation snapshot as preliminary and discard it; do not + reuse it for formal acceptance. Before readiness, do not issue a page + `passed`, `failed`, or `inconclusive` verdict because acceptance has not + started. + - An authorized mock is presentation-only; end-to-end behavior remains + unverified. Read [design-verification.md](references/design-verification.md) + before any design verification. + +3. Freeze the screen context. + - After readiness, start with `inspect_screen` to create and retain the fresh, + authoritative formal acceptance `snapshotId`. + - For design acceptance, create authoritative Design Expectations and a + complete Coverage Ledger before selecting or running checks. - Pass that same `appId` and `snapshotId` to hierarchy, lookup, detail, and structured-check tools while investigating the same state. - When tree structure or an implementation node is needed, call @@ -45,7 +66,7 @@ is running. - A screenshot is always latest-state evidence and is not pixel-synchronized with an older hierarchy snapshot. -3. Locate the actual rendering node. +4. Locate the actual rendering node. - Use `summarize_hierarchy` when counts and bounded lists are sufficient. Use `capture_hierarchy` only when the tree shape or implementation nodes matter. - `capture_hierarchy` returns at most 25 nodes by default. Set `nodeLimit` and @@ -62,7 +83,7 @@ is running. expires or changes. - Use the selected node's exact `oid` or `detailOid` for follow-up calls. -4. Prove rendered output, not only layout intent. +5. Prove rendered output, not only layout intent. - A layout box describes where a container participates in layout; it does not necessarily describe the rendered footprint of its content. - Before approving size, containment, clipping, alignment, or visual balance, @@ -78,7 +99,7 @@ is running. can render differently from its container, including images, text, masks, transforms, shadows, and descendant drawing. -5. Choose hierarchy or UI Graph deliberately. +6. Choose hierarchy or UI Graph deliberately. - Use hierarchy tools for ordinary parent-child structure. - Use `query_ui_graph` for ownership, cross-tree, non-hierarchy relations, or a bounded uniform relation traversal. Require both the Tool and the @@ -91,7 +112,7 @@ is running. - Read [ui-graph.md](references/ui-graph.md) for relation semantics, platform examples, and exact recovery behavior. -6. Turn requirements into evidence-backed checks. +7. Turn requirements into evidence-backed checks. - Use `check_node` for identity, text, visibility, or exact frame. - Use `check_node_detail` for one semantic attribute, `check_style` for a related style group, and `check_layout` for relations between nodes. @@ -101,7 +122,7 @@ is running. - Read [visual-regression.md](references/visual-regression.md) for screenshot, diff, baseline, coordinate, and dynamic-region rules. -7. Iterate until source-backed behavior is verified. +8. Iterate until source-backed behavior is verified. - After source changes, build and relaunch only when permitted by the user and repository instructions. - Run `list_apps` again after relaunch because `appId` can change. Capture a @@ -178,8 +199,14 @@ Report only evidence relevant to the question: - Screenshot source, dimensions, scale, and diff result when visual evidence is used. - Active temporary patches, clearly labeled as experiments. - -For a verification request, end with `passed`, `failed`, or `inconclusive`. -Only source implementation plus clean Runtime verification can produce -`passed`. For exploratory or factual questions, answer the finding directly and -state uncertainty instead of forcing a pass/fail label. +- For design verification: readiness, preparation mode, authorization, and + verification scope; design provenance and reference viewport; Target Context; + Coverage Ledger counts; each failed or inconclusive expectation with expected, + actual, and tolerance or range; screenshot/structured-evidence consistency; + and final verdict. + +For formal acceptance after readiness, end with `passed`, `failed`, or +`inconclusive`. Only source implementation plus clean Runtime verification can +produce `passed`. Before readiness, report only readiness, blocking condition, +and requested preparation. For exploratory or factual questions, answer the +finding directly and state uncertainty instead of forcing a pass/fail label. diff --git a/skills/astrolabe/agents/openai.yaml b/skills/astrolabe/agents/openai.yaml index 37b0a5d..19622ef 100644 --- a/skills/astrolabe/agents/openai.yaml +++ b/skills/astrolabe/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Astrolabe UI Inspector" - short_description: "Verify running mobile UI behavior with Runtime evidence" - default_prompt: "Use Astrolabe MCP to inspect the running mobile app, reuse one frozen snapshot, locate the real content-bearing nodes, and verify layout, rendered content, styles, UI relations, and screenshots with capability-driven evidence." + short_description: "Verify a running mobile app UI against design contracts and Runtime evidence" + default_prompt: "Use Astrolabe MCP to observe the real target state, prepare it only through authorized visible UI or developer assistance, capture a fresh acceptance snapshot, and verify design contracts, rendered content, styles, UI relations, and screenshots with capability-driven evidence." diff --git a/skills/astrolabe/evals/evals.json b/skills/astrolabe/evals/evals.json new file mode 100644 index 0000000..95c9587 --- /dev/null +++ b/skills/astrolabe/evals/evals.json @@ -0,0 +1,130 @@ +{ + "evals": [ + { + "id": "fixed-spacing-failure", + "prompt": "Review the ProfileHeader on Android. Design source: PRD DS-42 requires the avatar-to-name gap to be exact 10 logical units. Target Context: 360x800 compact portrait, fontScale 1.0, English, light appearance. Frozen Runtime evidence: Avatar frame {x: 16, width: 40}; Name frame {x: 74, width: 112}; both nodes are unique and visible. Screenshot has no contradiction. Verify the required spacing.", + "expected_output": "failed; report exact expected 10, actual 18, measurement tolerance, and the unique node evidence.", + "files": ["skills/astrolabe/SKILL.md", "skills/astrolabe/references/design-verification.md"] + }, + { + "id": "legal-adaptive-spacing", + "prompt": "Review a Settings row on an iPhone SE. Figma shows a 40-unit title-to-toggle gap on a wider reference frame, but its Auto Layout notes declare a minimum gap of 10 and distribute remaining space. Target Context: 320x568 portrait, English, fontScale 1.0. Frozen Runtime frames measure a 18-unit title-to-toggle gap, with fixed 16-unit row edges preserved. Verify this required adaptive spacing.", + "expected_output": "passed for the minimum-gap expectation; do not compare the adaptive gap to the static Figma coordinate.", + "files": ["skills/astrolabe/references/design-verification.md"] + }, + { + "id": "missing-adaptive-contract", + "prompt": "Review a tablet dashboard spacer. The only design material is a static Figma frame at 1440x1024 showing a 56-unit gap; it has no Auto Layout, breakpoint, min/max, token, formula, or matching accepted baseline. Target Context is 800x1280 portrait. Runtime measures 32 units. The spacer is required. Determine the verdict.", + "expected_output": "inconclusive; identify the missing authoritative adaptive contract and do not widen tolerance.", + "files": ["skills/astrolabe/references/design-verification.md"] + }, + { + "id": "ambiguous-selector", + "prompt": "Review the Checkout screen. The requirement names the visible \"Total\" label, but find_nodes returns two visible UILabel candidates with identical text: a sticky footer and an accessibility mirror. Their frames differ and neither hierarchy path is specified by the contract. Target Context is 390x844 portrait. Verify the label font size.", + "expected_output": "inconclusive; report selector ambiguity and require a unique node before checking.", + "files": ["skills/astrolabe/SKILL.md", "skills/astrolabe/references/design-verification.md"] + }, + { + "id": "image-center-mode-overflow", + "prompt": "Review the Wallet empty-state icon. Design requirement: the icon must be fully contained inside an exact 27x27 logical-unit box. Target Context: 390x844 portrait at displayScale 3. Frozen Runtime evidence: image view frame 27x27, intrinsic image 54x54, contentMode center, imageScale 1, clipsToBounds false, and no mask or transform reduces the rendered footprint. The latest screenshot shows the icon extending beyond its 27x27 box. Verify the required image rendering.", + "expected_output": "failed; the declared containment requirement conflicts with center-mode overflow. Do not treat center mode as universally incorrect.", + "files": ["skills/astrolabe/SKILL.md", "skills/astrolabe/references/design-verification.md"] + }, + { + "id": "target-absent-ios-physical-device", + "prompt": "On an iOS physical device, list_apps and inspect_screen show Home, while the requested SubscriptionOffer screen is absent. The selected app has no interaction Tool and no interaction capability. The source mentions a Router route, but no developer authorization exists for a shortcut. What should happen before design acceptance?", + "expected_output": "do not start acceptance or issue a verdict; request developer manual navigation, real-condition preparation, or authorized debug fixture/mock. Do not use the Router.", + "files": ["skills/astrolabe/SKILL.md", "skills/astrolabe/references/design-verification.md"] + }, + { + "id": "authorized-mock-presentation-only", + "prompt": "A developer explicitly authorizes a debug mock that presents OrderTracking with a delayed shipment card. After rediscovery and formal snapshot capture, verify the card's presentation against the supplied design contract. State the correct verification scope.", + "expected_output": "verify presentation-only scope and state that navigation, business conditions, data mapping, lifecycle, and release behavior remain unverified.", + "files": ["skills/astrolabe/SKILL.md", "skills/astrolabe/references/design-verification.md"] + }, + { + "id": "exact-policy-pass", + "prompt": "Review the Search field in a compact Android phone context: 360x800, fontScale 1.0. Token radius-md requires an exact corner radius of 12 logical units. The unique input node detail reports cornerRadius 12.0 with a 0.1 measurement tolerance. Verify the required exact expectation.", + "expected_output": "passed; actual 12.0 satisfies exact 12 within measurement tolerance.", + "files": ["skills/astrolabe/references/design-verification.md"] + }, + { + "id": "exact-policy-fail", + "prompt": "Review the Search field in the same compact Android context. Token radius-md requires an exact corner radius of 12 logical units. The unique input node detail reports cornerRadius 16.0 with a 0.1 measurement tolerance. Verify the required exact expectation.", + "expected_output": "failed; actual 16.0 violates exact 12.", + "files": ["skills/astrolabe/references/design-verification.md"] + }, + { + "id": "minimum-policy-pass", + "prompt": "Review an iOS Account row at 390x844. Figma Auto Layout declares that the username-to-chevron gap has a minimum of 12 logical units. Unique frozen frames measure 20 units; fixed row edges and center alignment also hold. Verify the required minimum expectation.", + "expected_output": "passed; actual 20 is not below the declared minimum 12.", + "files": ["skills/astrolabe/references/design-verification.md"] + }, + { + "id": "minimum-policy-fail", + "prompt": "Review the same iOS Account row at 320x568 with a long localized username. The contract still requires the username-to-chevron gap to be at least 12 logical units. Unique frozen frames measure 6 units. Verify the required minimum expectation.", + "expected_output": "failed; actual 6 is below the declared minimum 12.", + "files": ["skills/astrolabe/references/design-verification.md"] + }, + { + "id": "maximum-policy-pass", + "prompt": "Review the Android tablet notification banner at 1280x800. The PRD limits the banner width to a maximum of 640 logical units. The unique banner frame reports width 600. Verify the required maximum expectation.", + "expected_output": "passed; actual 600 does not exceed maximum 640.", + "files": ["skills/astrolabe/references/design-verification.md"] + }, + { + "id": "maximum-policy-fail", + "prompt": "Review the Android tablet notification banner at 1280x800. The PRD limits the banner width to a maximum of 640 logical units. The unique banner frame reports width 712. Verify the required maximum expectation.", + "expected_output": "failed; actual 712 exceeds maximum 640.", + "files": ["skills/astrolabe/references/design-verification.md"] + }, + { + "id": "range-policy-pass", + "prompt": "Review the iOS onboarding illustration spacer in regular portrait size class. The design system permits a range of 24 through 48 logical units after safe-area adjustment. Target Context: 834x1194, top safe area 24. Unique frozen frames measure 36. Verify the required range expectation.", + "expected_output": "passed; actual 36 lies within the inclusive range 24 through 48.", + "files": ["skills/astrolabe/references/design-verification.md"] + }, + { + "id": "range-policy-fail", + "prompt": "Review the iOS onboarding illustration spacer in regular portrait size class. The design system permits a range of 24 through 48 logical units after safe-area adjustment. Target Context: 834x1194, top safe area 24. Unique frozen frames measure 56. Verify the required range expectation.", + "expected_output": "failed; actual 56 lies outside the inclusive range 24 through 48.", + "files": ["skills/astrolabe/references/design-verification.md"] + }, + { + "id": "relation-policy-pass", + "prompt": "Review a MusicPlayer control strip on Android. The design contract requires previous, play, and next icons to share one centerY relation. Target Context: 412x915 portrait. Unique frozen frames produce centerY values 768, 768, and 768. Verify the required relation expectation.", + "expected_output": "passed; all three unique nodes satisfy the declared centerY relation.", + "files": ["skills/astrolabe/references/design-verification.md"] + }, + { + "id": "relation-policy-fail", + "prompt": "Review a MusicPlayer control strip on Android. The design contract requires previous, play, and next icons to share one centerY relation. Target Context: 412x915 portrait. Unique frozen frames produce centerY values 768, 768, and 776. Verify the required relation expectation.", + "expected_output": "failed; the next icon violates the declared centerY relation.", + "files": ["skills/astrolabe/references/design-verification.md"] + }, + { + "id": "derived-policy-pass", + "prompt": "Review a tablet compose screen. The design contract derives the editor height as viewportHeight 900 minus fixed top bar 64, fixed action bar 56, bottom safe area 20, and two fixed 16-unit vertical gaps: 728 logical units. Target Context is 600x900 with bottom safe area 20. The unique editor frame height is 728. Verify the required derived expectation.", + "expected_output": "passed; actual 728 equals the declared Target Context derivation.", + "files": ["skills/astrolabe/references/design-verification.md"] + }, + { + "id": "derived-policy-fail", + "prompt": "Review a tablet compose screen. The design contract derives the editor height as viewportHeight 900 minus fixed top bar 64, fixed action bar 56, bottom safe area 20, and two fixed 16-unit vertical gaps: 728 logical units. Target Context is 600x900 with bottom safe area 20. The unique editor frame height is 692. Verify the required derived expectation.", + "expected_output": "failed; actual 692 does not equal the declared Target Context derivation of 728.", + "files": ["skills/astrolabe/references/design-verification.md"] + }, + { + "id": "conditional-policy-pass", + "prompt": "Review the Android navigation rail. The contract is conditional: compact width uses exact 16-unit content edges; expanded width uses exact 24-unit content edges. Target Context is expanded width 840. The unique rail content frame begins 24 units from the edge. Verify the required conditional expectation.", + "expected_output": "passed; expanded-width condition selects the exact 24-unit policy and actual is 24.", + "files": ["skills/astrolabe/references/design-verification.md"] + }, + { + "id": "conditional-policy-fail", + "prompt": "Review the Android navigation rail. The contract is conditional: compact width uses exact 16-unit content edges; expanded width uses exact 24-unit content edges. Target Context is expanded width 840. The unique rail content frame begins 16 units from the edge. Verify the required conditional expectation.", + "expected_output": "failed; expanded-width condition selects 24 but actual is 16.", + "files": ["skills/astrolabe/references/design-verification.md"] + } + ] +} diff --git a/skills/astrolabe/references/design-verification.md b/skills/astrolabe/references/design-verification.md new file mode 100644 index 0000000..a649762 --- /dev/null +++ b/skills/astrolabe/references/design-verification.md @@ -0,0 +1,132 @@ +# Design Verification + +Use this reference only after Target State Readiness is `ready`. It defines +design intent; Runtime evidence only reports what is rendered. + +## Target State Readiness + +Observe the current screen first. If the target screen or View is absent, use +visible normal UI interaction only when both the Tool and Runtime capability +exist. Otherwise request one developer choice: manual navigation, preparation +of real business conditions, or explicit debug-fixture/mock authorization. + +Never use an App Router, internal route, private initializer, unapproved deep +link, source shortcut, or business-state mutation. After preparation, +rediscover and re-observe the target, then discard every preliminary readiness +snapshot. Only the later formal-acceptance step creates the authoritative +`inspect_screen` snapshot. Before readiness, report the blocking condition and +preparation request, not a page verdict. + +Record `status`, observed and target screen, blocking condition, preparation +mode, developer authorization, and verification scope. An authorized mock is +`presentation-only`: it can verify rendering but leaves navigation, business +conditions, data mapping, lifecycle, and release behavior unverified. + +## Design Source Priority and Target Context + +Use design sources in this priority order: + +1. Explicit PRD requirements, design annotations, and Design Tokens. +2. The matching Figma variant and its Auto Layout, constraints, min/max, + hug/fill, spacing mode, and breakpoint rules. +3. An accepted baseline with the same Target Context. +4. No authoritative source: mark each affected required expectation + `inconclusive`. + +Target Context records the Runtime conditions that determine which contract +applies: `viewportWidth`, `viewportHeight`, `safeAreaInsets`, `orientation`, +`sizeClass`, `displayScale`, `fontScale`, `locale`, `appearance`, and +`systemOccupancy`. Keep logical units distinct from screenshot pixels. + +## Design Expectation Record + +Create one record for every required design fact. Explain every field in the +review artifact: + +| Field | Meaning | +| --- | --- | +| `identifier` | Stable, reportable requirement name. | +| `category` | Content/state, geometry, spacing, typography, color, appearance, image rendering, adaptive behavior, or final composition. | +| `targets` | One or more uniquely located design nodes. | +| `metric` | Measured fact, such as width, spacing, centerY, fontSize, or color. | +| `policy` | Comparison semantics: exact, minimum, maximum, range, relation, derived, or conditional. | +| `expected` | Value, boundary, relation, or formula inputs required by the policy. | +| `unit` | Logical unit, pixel, ratio, color, or another explicit measurement unit. | +| `tolerance` | Measurement or floating-point error only. It is not layout flexibility. | +| `conditions` | Viewport, orientation, size class, locale, or state where this record applies. | +| `required` | Whether this record affects the page verdict. | +| `source` | PRD entry, Figma node, Design Token, or accepted baseline provenance. | + +Do not create expectations from the current implementation. A selector must +identify one unique node before a check can pass. Missing uniqueness, +coordinate-system clarity, authoritative expectation, or required evidence is +`inconclusive`. + +## Policy Semantics + +| Policy | Meaning | +| --- | --- | +| `exact` | Actual equals the fixed expected value within measurement error. | +| `minimum` | Actual is not below the declared lower bound. | +| `maximum` | Actual is not above the declared upper bound. | +| `range` | Actual lies in the declared inclusive interval. | +| `relation` | Nodes satisfy declared alignment, equality, ordering, or spacing relation. | +| `derived` | Expected value is calculated from Target Context and declared fixed inputs. | +| `conditional` | Select a declared sub-policy for the matching breakpoint, orientation, or state. | + +Treat font, size, weight, color, corner radius, border, fixed component size, +fixed edge inset, and fixed internal gap as `exact` unless the source says +otherwise. Treat a gap as flexible only when its source declares a minimum, +maximum, range, remaining-space formula, hug/fill, space-between, safe-area +rule, or breakpoint policy. Tolerance is measurement error only; never use a +large tolerance to imitate a flexible range. + +## Coverage and Evidence Gates + +Maintain a Coverage Ledger with required expectation counts for: content and +state; geometry; spatial relations; typography; color; appearance; image +rendering; adaptive behavior; and final composition. A page has complete +coverage only when every required expectation is passed, failed, inconclusive, +or `notApplicable` with its evidence status recorded. `notApplicable` is valid +only when the authoritative contract says the expectation does not apply to +current conditions. It is neither unchecked nor a result that blocks `passed` +by itself; an Agent must not use it to evade a required check. + +Use frozen Runtime evidence for exact values and relations. Confirm coordinates +use the same coordinate space before calculating a gap or alignment. Use latest +screenshots for composition and correlate them to Runtime nodes. If a screenshot +reveals an unexplained overflow, clipping, occlusion, or other contradiction, +the page cannot pass even when structured checks pass; investigate with a fresh, +consistent evidence chain. + +Aggregate only after readiness: + +```text +any required failed -> failed +otherwise any required inconclusive or unchecked -> inconclusive +otherwise all applicable required expectations passed and no unexplained screenshot contradiction -> passed +``` + +## ContactCell Example + +This example illustrates per-gap policies; it is not a universal component +template. For `ContentView`, `Avatar`, `Title`, and `CallIcon`: + +- Avatar and CallIcon are exactly 20x20. +- Avatar leading and CallIcon trailing edges are exactly 15. +- Avatar-to-Title spacing is exactly 10. +- Title-to-CallIcon spacing is a minimum of 10. +- Avatar, Title, and CallIcon each have a `centerY` relation to ContentView. + +The Title-to-CallIcon gap can grow with remaining space, while the other stated +gaps remain fixed. A static Figma coordinate alone does not authorize that +growth; the flexible contract does. + +## Reporting + +For every design verification, report readiness and preparation, authorization +and scope, design provenance and reference viewport, Target Context, Coverage +Ledger counts, every failed or inconclusive expectation with expected/actual and +tolerance or range, screenshot/structured-evidence consistency, and final +verdict. For `presentation-only`, explicitly list end-to-end facts left +unverified. diff --git a/test/review-contract.test.mjs b/test/review-contract.test.mjs index 0f5c8ad..3ef3e1e 100644 --- a/test/review-contract.test.mjs +++ b/test/review-contract.test.mjs @@ -108,6 +108,93 @@ test("Astrolabe skill loads advanced inspection guidance progressively", async ( assert.match(skill, /references\/temporary-patches\.md/); }); +test("Astrolabe skill gates design verification on target readiness and complete design evidence", async () => { + const skill = await readProjectFile("skills/astrolabe/SKILL.md"); + const guidance = await readProjectFile( + "skills/astrolabe/references/design-verification.md" + ).catch(() => ""); + const evals = await readProjectFile("skills/astrolabe/evals/evals.json").catch(() => ""); + + assert.ok(skill.split("\n").length <= 260); + assert.match(skill, /design-verification\.md/); + assert.match(skill, /Target State Readiness/i); + assert.match(skill, /fresh[\s\S]*formal acceptance[\s\S]*snapshot/i); + assert.match(skill, /before readiness.*do not issue.*passed.*failed.*inconclusive/is); + assert.match(skill, /visible normal UI/i); + assert.match(skill, /App Router|private initializer|unapproved deep link/i); + assert.match(skill, /presentation-only/i); + assert.match(skill, /snapshot.*preliminary.*discard|discard.*preliminary.*snapshot/is); + assert.match(skill, /Design Expectations.*Coverage Ledger.*before.*checks/is); + assert.match(skill, /formal acceptance.*after readiness.*passed.*failed.*inconclusive/is); + + assert.match(guidance, /Design source priority/i); + assert.match(guidance, /Target Context/i); + assert.match(guidance, /Design Expectation/i); + for (const policy of [ + "exact", + "minimum", + "maximum", + "range", + "relation", + "derived", + "conditional" + ]) { + assert.match(guidance, new RegExp(`\\b${policy}\\b`, "i")); + } + assert.match(guidance, /tolerance.*measurement error/i); + assert.match(guidance, /fixed.*flexible|flexible.*fixed/is); + assert.match(guidance, /unique node|coordinate.*evidence/i); + assert.match(guidance, /Coverage Ledger/i); + assert.match(guidance, /screenshot.*contradiction|contradiction.*screenshot/is); + assert.match(guidance, /required failed.*failed/is); + assert.match(guidance, /required inconclusive.*unchecked.*inconclusive/is); + assert.match(guidance, /notApplicable/); + assert.match(guidance, /authoritative contract.*does not apply.*current conditions/is); + assert.match(guidance, /notApplicable.*neither.*unchecked.*blocks.*passed/is); + assert.match(guidance, /20x20/); + assert.match(guidance, /leading.*15/i); + assert.match(guidance, /Avatar-to-Title.*10/i); + assert.match(guidance, /Title-to-CallIcon.*minimum.*10/i); + assert.match(guidance, /centerY/i); + + const evalCatalog = JSON.parse(evals); + assert.ok(Array.isArray(evalCatalog.evals)); + const evalsByID = new Map(evalCatalog.evals.map((entry) => [entry.id, entry])); + for (const id of [ + "fixed-spacing-failure", + "legal-adaptive-spacing", + "missing-adaptive-contract", + "ambiguous-selector", + "image-center-mode-overflow", + "target-absent-ios-physical-device", + "authorized-mock-presentation-only", + "exact-policy-pass", + "exact-policy-fail", + "minimum-policy-pass", + "minimum-policy-fail", + "maximum-policy-pass", + "maximum-policy-fail", + "range-policy-pass", + "range-policy-fail", + "relation-policy-pass", + "relation-policy-fail", + "derived-policy-pass", + "derived-policy-fail", + "conditional-policy-pass", + "conditional-policy-fail" + ]) { + assert.ok(evalsByID.has(id), `missing stable eval: ${id}`); + } + for (const entry of evalCatalog.evals) { + assert.equal(typeof entry.prompt, "string"); + assert.equal(typeof entry.expected_output, "string"); + assert.ok(Array.isArray(entry.files)); + } + assert.match(evalsByID.get("image-center-mode-overflow").prompt, /27x27/); + assert.match(evalsByID.get("image-center-mode-overflow").prompt, /54x54/); + assert.match(evalsByID.get("image-center-mode-overflow").prompt, /fully contained/i); +}); + test("Astrolabe skill separates rendered content from its layout container", async () => { const skill = await readProjectFile("skills/astrolabe/SKILL.md"); const guidance = await readProjectFile( From 1068a16ca1f4bfc91076f360e92ab3d479e1e7b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BD=A9=E8=BE=95=E5=8D=81=E5=9B=9B?= Date: Mon, 17 Aug 2026 18:51:48 +0800 Subject: [PATCH 08/10] feat: add device interaction core --- .../Interaction/Core/InteractionModels.swift | 224 +++++++++ .../Core/InteractionRegistry.swift | 64 +++ .../Core/PlatformInteractionPerforming.swift | 47 ++ .../Core/InteractionRegistryTests.swift | 425 ++++++++++++++++++ 4 files changed, 760 insertions(+) create mode 100644 Sources/AstrolabeCLI/Interaction/Core/InteractionModels.swift create mode 100644 Sources/AstrolabeCLI/Interaction/Core/InteractionRegistry.swift create mode 100644 Sources/AstrolabeCLI/Interaction/Core/PlatformInteractionPerforming.swift create mode 100644 Tests/AstrolabeCLITests/Interaction/Core/InteractionRegistryTests.swift diff --git a/Sources/AstrolabeCLI/Interaction/Core/InteractionModels.swift b/Sources/AstrolabeCLI/Interaction/Core/InteractionModels.swift new file mode 100644 index 0000000..3f3781f --- /dev/null +++ b/Sources/AstrolabeCLI/Interaction/Core/InteractionModels.swift @@ -0,0 +1,224 @@ +// +// InteractionModels.swift +// astrolabe +// +// Created by 轩辕十四 on 2026/8/17. +// + +import Foundation + +package enum InteractionActionKind: String, Equatable, Hashable { + case tap + case longPress + case inputText + case swipe + case pressKey +} + +package struct InteractionPoint: Equatable { + /// Horizontal position in the logical viewport coordinate space. + package let x: Double + + /// Vertical position in the logical viewport coordinate space. + package let y: Double + + package init(x: Double, y: Double) throws { + guard x.isFinite, y.isFinite, x >= 0, y >= 0 else { + throw InteractionError.invalidInput("Interaction point must be finite and nonnegative") + } + self.x = x + self.y = y + } +} + +package struct InteractionViewport: Equatable { + /// Logical viewport width. + package let width: Double + + /// Logical viewport height. + package let height: Double + + package init(width: Double, height: Double) throws { + guard width.isFinite, height.isFinite, width > 0, height > 0 else { + throw InteractionError.invalidInput("Interaction viewport dimensions must be finite and positive") + } + self.width = width + self.height = height + } + + func contains(_ point: InteractionPoint) -> Bool { + point.x <= width && point.y <= height + } +} + +package enum InteractionOrientation: Equatable { + case portrait + case landscape +} + +package enum InteractionDeviceKind: Equatable, Hashable { + case virtual + case physical +} + +package enum InteractionLocator: Equatable { + case stableIdentifier(String) + case accessibilityLabel(String) + case alias(String) + case point(InteractionPoint) +} + +package enum InteractionAction: Equatable { + case tap + case longPress(duration: TimeInterval) + case inputText(String) + case swipe(to: InteractionPoint, duration: TimeInterval) + case pressKey(String) + + package var kind: InteractionActionKind { + switch self { + case .tap: + .tap + case .longPress: + .longPress + case .inputText: + .inputText + case .swipe: + .swipe + case .pressKey: + .pressKey + } + } + + package init(validating action: InteractionAction) throws { + try action.validate() + self = action + } + + func validate() throws { + switch self { + case .longPress(let duration), .swipe(_, let duration): + guard duration.isFinite, duration > 0 else { + throw InteractionError.invalidInput("Interaction duration must be finite and positive") + } + case .pressKey(let key): + guard !key.isEmpty else { + throw InteractionError.invalidInput("Interaction key must not be empty") + } + case .tap, .inputText: + break + } + } +} + +package struct InteractionTarget: Equatable { + /// Runtime app identifier resolved by the platform resolver. + package let appId: String + + /// Physical or virtual device identifier where the interaction occurs. + package let deviceIdentifier: String + + /// Device category used for Provider routing. + package let deviceKind: InteractionDeviceKind + + /// Foreground app process identifier observed before interaction. + package let processIdentifier: String + + /// Unix timestamp when the target state was observed. + package let observedAtUnixTime: TimeInterval + + /// Orientation observed with the target state. + package let orientation: InteractionOrientation + + /// Logical viewport observed with the target state. + package let viewport: InteractionViewport + + /// Semantic or coordinate locator for the interaction target. + package let locator: InteractionLocator + + package init( + appId: String, + deviceIdentifier: String, + deviceKind: InteractionDeviceKind, + processIdentifier: String, + observedAtUnixTime: TimeInterval, + orientation: InteractionOrientation, + viewport: InteractionViewport, + locator: InteractionLocator + ) throws { + guard !appId.isEmpty, !deviceIdentifier.isEmpty, !processIdentifier.isEmpty else { + throw InteractionError.invalidInput("Interaction identifiers must not be empty") + } + guard observedAtUnixTime.isFinite, observedAtUnixTime >= 0 else { + throw InteractionError.invalidInput("Interaction observation time must be finite and nonnegative") + } + if case .stableIdentifier(let identifier) = locator, identifier.isEmpty { + throw InteractionError.invalidInput("Interaction stable identifier must not be empty") + } + if case .accessibilityLabel(let label) = locator, label.isEmpty { + throw InteractionError.invalidInput("Interaction accessibility label must not be empty") + } + if case .alias(let alias) = locator, alias.isEmpty { + throw InteractionError.invalidInput("Interaction alias must not be empty") + } + if case .point(let point) = locator, !viewport.contains(point) { + throw InteractionError.invalidInput("Interaction point must be inside the viewport") + } + self.appId = appId + self.deviceIdentifier = deviceIdentifier + self.deviceKind = deviceKind + self.processIdentifier = processIdentifier + self.observedAtUnixTime = observedAtUnixTime + self.orientation = orientation + self.viewport = viewport + self.locator = locator + } +} + +package struct InteractionRequest: Equatable { + /// Requested user interaction. + package let action: InteractionAction + + /// Current target context for the interaction. + package let target: InteractionTarget + + package init(action: InteractionAction, target: InteractionTarget) throws { + try action.validate() + if case .swipe(let destination, _) = action, !target.viewport.contains(destination) { + throw InteractionError.invalidInput("Interaction swipe destination must be inside the viewport") + } + self.action = action + self.target = target + } +} + +package enum InteractionExecutionStatus: Equatable { + case sent + case confirmed + case uncertain +} + +package struct InteractionResult: Equatable { + /// Exact request accepted by the selected Provider. + package let request: InteractionRequest + + /// Execution certainty reported by the selected Provider. + package let status: InteractionExecutionStatus + + package init(request: InteractionRequest, status: InteractionExecutionStatus) { + self.request = request + self.status = status + } +} + +package enum InteractionError: Error, Equatable { + case invalidInput(String) + case duplicateProviderIdentifier(String) + case providerUnavailable(platform: RuntimeUIPlatform, deviceKind: InteractionDeviceKind) + case unsupportedAction(InteractionActionKind) + case ambiguousProviders([String]) + case staleTarget + case foregroundAppChanged + case coordinateContextChanged + case executionFailed(String) +} diff --git a/Sources/AstrolabeCLI/Interaction/Core/InteractionRegistry.swift b/Sources/AstrolabeCLI/Interaction/Core/InteractionRegistry.swift new file mode 100644 index 0000000..d1bea73 --- /dev/null +++ b/Sources/AstrolabeCLI/Interaction/Core/InteractionRegistry.swift @@ -0,0 +1,64 @@ +// +// InteractionRegistry.swift +// astrolabe +// +// Created by 轩辕十四 on 2026/8/17. +// + +import Foundation + +package final class InteractionRegistry { + /// Resolver used to derive Runtime platform from the current app identifier. + private let platformResolver: any RuntimeUIPlatformResolving + + /// Registered interaction Provider strategies. + private let providers: [any PlatformInteractionPerforming] + + package init( + platformResolver: any RuntimeUIPlatformResolving, + providers: [any PlatformInteractionPerforming] + ) throws { + let identifiers = providers.map(\.descriptor.identifier) + if let duplicate = Self.firstDuplicate(in: identifiers) { + throw InteractionError.duplicateProviderIdentifier(duplicate) + } + self.platformResolver = platformResolver + self.providers = providers + } + + package func perform(_ request: InteractionRequest) throws -> InteractionResult { + let platform = try platformResolver.platform(for: request.target.appId) + let contextualProviders = providers.filter { + $0.descriptor.platform == platform + && $0.descriptor.supportedDeviceKinds.contains(request.target.deviceKind) + } + guard !contextualProviders.isEmpty else { + throw InteractionError.providerUnavailable( + platform: platform, + deviceKind: request.target.deviceKind + ) + } + let actionProviders = contextualProviders.filter { + $0.descriptor.supportedActionKinds.contains(request.action.kind) + } + guard !actionProviders.isEmpty else { + throw InteractionError.unsupportedAction(request.action.kind) + } + let capableProviders = actionProviders.filter { $0.canHandle(target: request.target) } + guard !capableProviders.isEmpty else { + throw InteractionError.providerUnavailable( + platform: platform, + deviceKind: request.target.deviceKind + ) + } + guard capableProviders.count == 1 else { + throw InteractionError.ambiguousProviders(capableProviders.map(\.descriptor.identifier)) + } + return try capableProviders[0].perform(request) + } + + private static func firstDuplicate(in values: [Value]) -> Value? { + var seen = Set() + return values.first { !seen.insert($0).inserted } + } +} diff --git a/Sources/AstrolabeCLI/Interaction/Core/PlatformInteractionPerforming.swift b/Sources/AstrolabeCLI/Interaction/Core/PlatformInteractionPerforming.swift new file mode 100644 index 0000000..3499756 --- /dev/null +++ b/Sources/AstrolabeCLI/Interaction/Core/PlatformInteractionPerforming.swift @@ -0,0 +1,47 @@ +// +// PlatformInteractionPerforming.swift +// astrolabe +// +// Created by 轩辕十四 on 2026/8/17. +// + +package struct InteractionProviderDescriptor: Equatable { + /// Stable identifier for this interaction Provider. + package let identifier: String + + /// Runtime platform supported by this Provider. + package let platform: RuntimeUIPlatform + + /// Device categories supported by this Provider. + package let supportedDeviceKinds: Set + + /// Action kinds supported by this Provider. + package let supportedActionKinds: Set + + package init( + identifier: String, + platform: RuntimeUIPlatform, + supportedDeviceKinds: Set, + supportedActionKinds: Set + ) throws { + guard !identifier.isEmpty else { + throw InteractionError.invalidInput("Interaction Provider identifier must not be empty") + } + guard !supportedDeviceKinds.isEmpty else { + throw InteractionError.invalidInput("Interaction Provider device kinds must not be empty") + } + guard !supportedActionKinds.isEmpty else { + throw InteractionError.invalidInput("Interaction Provider action kinds must not be empty") + } + self.identifier = identifier + self.platform = platform + self.supportedDeviceKinds = supportedDeviceKinds + self.supportedActionKinds = supportedActionKinds + } +} + +package protocol PlatformInteractionPerforming { + var descriptor: InteractionProviderDescriptor { get } + func canHandle(target: InteractionTarget) -> Bool + func perform(_ request: InteractionRequest) throws -> InteractionResult +} diff --git a/Tests/AstrolabeCLITests/Interaction/Core/InteractionRegistryTests.swift b/Tests/AstrolabeCLITests/Interaction/Core/InteractionRegistryTests.swift new file mode 100644 index 0000000..7e194c4 --- /dev/null +++ b/Tests/AstrolabeCLITests/Interaction/Core/InteractionRegistryTests.swift @@ -0,0 +1,425 @@ +// +// InteractionRegistryTests.swift +// astrolabe +// +// Created by 轩辕十四 on 2026/8/17. +// + +import XCTest + +@testable import AstrolabeCLI + +final class InteractionRegistryTests: XCTestCase { + func testTargetRejectsEachEmptyIdentifier() throws { + let updates: [(inout TargetValues) -> Void] = [ + { target in target.appId = "" }, + { target in target.deviceIdentifier = "" }, + { target in target.processIdentifier = "" } + ] + for update in updates { + var values = TargetValues() + update(&values) + assertInvalidInput { try makeTarget(values: values) } + } + } + + func testTargetRejectsEachEmptyStringLocator() { + for locator in [ + InteractionLocator.stableIdentifier(""), + .accessibilityLabel(""), + .alias("") + ] { + assertInvalidInput { try self.makeTarget(locator: locator) } + } + } + + func testPointRejectsInvalidXAndYValuesIndependently() { + let invalidValues: [Double] = [.nan, .infinity, -.infinity, -1] + for value in invalidValues { + assertInvalidInput { try InteractionPoint(x: value, y: 1) } + assertInvalidInput { try InteractionPoint(x: 1, y: value) } + } + } + + func testViewportRejectsInvalidWidthAndHeightValuesIndependently() { + let invalidValues: [Double] = [.nan, .infinity, -.infinity, 0, -1] + for value in invalidValues { + assertInvalidInput { try InteractionViewport(width: value, height: 1) } + assertInvalidInput { try InteractionViewport(width: 1, height: value) } + } + } + + func testTargetRejectsInvalidObservationTimes() { + let invalidValues: [TimeInterval] = [.nan, .infinity, -.infinity, -1] + for value in invalidValues { + assertInvalidInput { try self.makeTarget(observedAtUnixTime: value) } + } + } + + func testActionsRejectInvalidLongPressAndSwipeDurations() throws { + let invalidValues: [TimeInterval] = [.nan, .infinity, -.infinity, 0, -1] + let point = try InteractionPoint(x: 1, y: 1) + for value in invalidValues { + assertInvalidInput { + try InteractionAction(validating: .longPress(duration: value)) + } + assertInvalidInput { + try InteractionAction(validating: .swipe(to: point, duration: value)) + } + } + } + + func testActionRejectsEmptyPlatformNeutralKey() { + assertInvalidInput { + try InteractionAction(validating: .pressKey("")) + } + } + + func testTargetRejectsLocatorPointOutsideEachViewportAxis() throws { + let points = [ + try InteractionPoint(x: 101, y: 1), + try InteractionPoint(x: 1, y: 201) + ] + for point in points { + assertInvalidInput { try self.makeTarget(locator: .point(point)) } + } + } + + func testRequestRejectsSwipeDestinationOutsideEachViewportAxis() throws { + let target = try makeTarget(locator: .point(try InteractionPoint(x: 1, y: 1))) + let destinations = [ + try InteractionPoint(x: 101, y: 1), + try InteractionPoint(x: 1, y: 201) + ] + for destination in destinations { + let action = try InteractionAction(validating: .swipe(to: destination, duration: 1)) + assertInvalidInput { try InteractionRequest(action: action, target: target) } + } + } + + func testProviderDescriptorRejectsMissingIdentifierOrCapabilities() { + assertInvalidInput { + try InteractionProviderDescriptor( + identifier: "", + platform: .ios, + supportedDeviceKinds: [.virtual], + supportedActionKinds: [.tap] + ) + } + assertInvalidInput { + try InteractionProviderDescriptor( + identifier: "provider", + platform: .ios, + supportedDeviceKinds: [], + supportedActionKinds: [.tap] + ) + } + assertInvalidInput { + try InteractionProviderDescriptor( + identifier: "provider", + platform: .ios, + supportedDeviceKinds: [.virtual], + supportedActionKinds: [] + ) + } + } + + func testRegistryUsesResolvedPlatformInsteadOfAppIDText() throws { + let iosProvider = FakeProvider( + identifier: "ios", + platform: .ios, + deviceKinds: [.virtual], + actionKinds: [.tap] + ) + let registry = try InteractionRegistry( + platformResolver: FakePlatformResolver(platform: .ios), + providers: [iosProvider] + ) + + _ = try registry.perform(try makeRequest(appId: "android-looking-id")) + + XCTAssertEqual(iosProvider.callCount, 1) + } + + func testRegistrySelectsDeviceKindSpecificProviderOnSamePlatform() throws { + let virtualProvider = FakeProvider( + identifier: "ios-simulator", + platform: .ios, + deviceKinds: [.virtual], + actionKinds: [.tap] + ) + let physicalProvider = FakeProvider( + identifier: "ios-device", + platform: .ios, + deviceKinds: [.physical], + actionKinds: [.tap] + ) + let registry = try makeRegistry(providers: [virtualProvider, physicalProvider]) + + _ = try registry.perform(try makeRequest(deviceKind: .physical)) + + XCTAssertEqual(virtualProvider.callCount, 0) + XCTAssertEqual(physicalProvider.callCount, 1) + } + + func testRegistrySelectsProviderByActionCapability() throws { + let tapProvider = FakeProvider( + identifier: "tap", + platform: .ios, + deviceKinds: [.virtual], + actionKinds: [.tap] + ) + let inputProvider = FakeProvider( + identifier: "input", + platform: .ios, + deviceKinds: [.virtual], + actionKinds: [.inputText] + ) + let registry = try makeRegistry(providers: [tapProvider, inputProvider]) + let request = try makeRequest(action: .inputText("Hello")) + + _ = try registry.perform(request) + + XCTAssertEqual(tapProvider.callCount, 0) + XCTAssertEqual(inputProvider.callCount, 1) + } + + func testRegistrySelectsOnlyProviderThatCanHandleTarget() throws { + let rejectedProvider = FakeProvider(identifier: "rejected") + rejectedProvider.handlesTarget = false + let selectedProvider = FakeProvider(identifier: "selected") + let registry = try makeRegistry(providers: [rejectedProvider, selectedProvider]) + + _ = try registry.perform(try makeRequest()) + + XCTAssertEqual(rejectedProvider.callCount, 0) + XCTAssertEqual(selectedProvider.callCount, 1) + } + + func testRegistryRejectsUnavailableProviderContext() throws { + let registry = try makeRegistry(providers: []) + + XCTAssertThrowsError(try registry.perform(try makeRequest())) { error in + XCTAssertEqual( + error as? InteractionError, + .providerUnavailable(platform: .ios, deviceKind: .virtual) + ) + } + } + + func testRegistryRejectsUnavailableProviderWhenAllCanHandleReturnFalse() throws { + let first = FakeProvider(identifier: "first") + let second = FakeProvider(identifier: "second") + first.handlesTarget = false + second.handlesTarget = false + let registry = try makeRegistry(providers: [first, second]) + + XCTAssertThrowsError(try registry.perform(try makeRequest())) { error in + XCTAssertEqual( + error as? InteractionError, + .providerUnavailable(platform: .ios, deviceKind: .virtual) + ) + } + XCTAssertEqual(first.callCount, 0) + XCTAssertEqual(second.callCount, 0) + } + + func testRegistryRejectsUnsupportedAction() throws { + let provider = FakeProvider( + identifier: "tap", + platform: .ios, + deviceKinds: [.virtual], + actionKinds: [.tap] + ) + let registry = try makeRegistry(providers: [provider]) + + XCTAssertThrowsError( + try registry.perform(try makeRequest(action: .inputText("Hello"))) + ) { error in + XCTAssertEqual(error as? InteractionError, .unsupportedAction(.inputText)) + } + } + + func testRegistryRejectsDuplicateProviderIdentifiers() throws { + let first = FakeProvider(identifier: "duplicate") + let second = FakeProvider(identifier: "duplicate") + + XCTAssertThrowsError( + try makeRegistry(providers: [first, second]) + ) { error in + XCTAssertEqual(error as? InteractionError, .duplicateProviderIdentifier("duplicate")) + } + } + + func testRegistryRejectsAmbiguousCapableProviders() throws { + let first = FakeProvider(identifier: "first") + let second = FakeProvider(identifier: "second") + let registry = try makeRegistry(providers: [first, second]) + + XCTAssertThrowsError(try registry.perform(try makeRequest())) { error in + XCTAssertEqual( + error as? InteractionError, + .ambiguousProviders(["first", "second"]) + ) + } + XCTAssertEqual(first.callCount, 0) + XCTAssertEqual(second.callCount, 0) + } + + func testRegistryCallsSelectedProviderOnceAndReturnsUnchangedUncertainResult() throws { + let provider = FakeProvider(identifier: "selected") + let registry = try makeRegistry(providers: [provider]) + let request = try makeRequest() + let expected = InteractionResult(request: request, status: .uncertain) + provider.result = expected + + let result = try registry.perform(request) + + XCTAssertEqual(provider.callCount, 1) + XCTAssertEqual(result, expected) + } + + func testRegistryPropagatesProviderFailureWithoutRetryOrWrapping() throws { + let provider = FakeProvider(identifier: "failing") + provider.error = InteractionError.executionFailed("transport failed") + let registry = try makeRegistry(providers: [provider]) + + XCTAssertThrowsError(try registry.perform(try makeRequest())) { error in + XCTAssertEqual(error as? InteractionError, .executionFailed("transport failed")) + } + XCTAssertEqual(provider.callCount, 1) + } + + private func assertInvalidInput( + _ expression: () throws -> Value, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertThrowsError(try expression(), file: file, line: line) { error in + guard case .invalidInput = error as? InteractionError else { + return XCTFail("Expected invalid input, got \(error)", file: file, line: line) + } + } + } + + private func makeRegistry( + providers: [any PlatformInteractionPerforming] + ) throws -> InteractionRegistry { + try InteractionRegistry( + platformResolver: FakePlatformResolver(platform: .ios), + providers: providers + ) + } + + private func makeRequest( + appId: String = "app", + deviceKind: InteractionDeviceKind = .virtual, + action: InteractionAction = .tap + ) throws -> InteractionRequest { + try InteractionRequest( + action: action, + target: try makeTarget(appId: appId, deviceKind: deviceKind) + ) + } + + private func makeTarget( + appId: String = "app", + deviceKind: InteractionDeviceKind = .virtual, + observedAtUnixTime: TimeInterval = 1, + locator: InteractionLocator = .stableIdentifier("button"), + values: TargetValues? = nil + ) throws -> InteractionTarget { + let values = values ?? TargetValues( + appId: appId, + deviceKind: deviceKind, + observedAtUnixTime: observedAtUnixTime, + locator: locator + ) + return try InteractionTarget( + appId: values.appId, + deviceIdentifier: values.deviceIdentifier, + deviceKind: values.deviceKind, + processIdentifier: values.processIdentifier, + observedAtUnixTime: values.observedAtUnixTime, + orientation: .portrait, + viewport: try InteractionViewport(width: 100, height: 200), + locator: values.locator + ) + } +} + +private struct TargetValues { + /// App identifier used by the target fixture. + var appId: String = "app" + + /// Device identifier used by the target fixture. + var deviceIdentifier: String = "device" + + /// Device category used by the target fixture. + var deviceKind: InteractionDeviceKind = .virtual + + /// Process identifier used by the target fixture. + var processIdentifier: String = "process" + + /// Observation time used by the target fixture. + var observedAtUnixTime: TimeInterval = 1 + + /// Locator used by the target fixture. + var locator: InteractionLocator = .stableIdentifier("button") +} + +private final class FakePlatformResolver: RuntimeUIPlatformResolving { + /// Platform returned for every app identifier. + private let resolvedPlatform: RuntimeUIPlatform + + init(platform: RuntimeUIPlatform) { + resolvedPlatform = platform + } + + func platform(for appId: String) throws -> RuntimeUIPlatform { + resolvedPlatform + } +} + +private final class FakeProvider: PlatformInteractionPerforming { + /// Provider capability descriptor. + let descriptor: InteractionProviderDescriptor + + /// Number of requests performed by this Provider. + private(set) var callCount = 0 + + /// Result returned after a successful request. + var result: InteractionResult? + + /// Error returned instead of a successful request. + var error: Error? + + /// Whether this Provider accepts the target context. + var handlesTarget = true + + init( + identifier: String, + platform: RuntimeUIPlatform = .ios, + deviceKinds: Set = [.virtual], + actionKinds: Set = [.tap] + ) { + descriptor = try! InteractionProviderDescriptor( + identifier: identifier, + platform: platform, + supportedDeviceKinds: deviceKinds, + supportedActionKinds: actionKinds + ) + } + + func canHandle(target: InteractionTarget) -> Bool { + handlesTarget + } + + func perform(_ request: InteractionRequest) throws -> InteractionResult { + callCount += 1 + if let error { + throw error + } + return result ?? InteractionResult(request: request, status: .sent) + } +} From ed6e576886de3d85dd93f3967ffa64c811eef9ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BD=A9=E8=BE=95=E5=8D=81=E5=9B=9B?= Date: Mon, 17 Aug 2026 19:45:10 +0800 Subject: [PATCH 09/10] fix: refine Skill layout verification --- skills/astrolabe/evals/evals.json | 6 ++++ .../references/design-verification.md | 35 +++++++++++++++++++ test/review-contract.test.mjs | 16 ++++++++- 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/skills/astrolabe/evals/evals.json b/skills/astrolabe/evals/evals.json index 95c9587..2c20462 100644 --- a/skills/astrolabe/evals/evals.json +++ b/skills/astrolabe/evals/evals.json @@ -6,6 +6,12 @@ "expected_output": "failed; report exact expected 10, actual 18, measurement tolerance, and the unique node evidence.", "files": ["skills/astrolabe/SKILL.md", "skills/astrolabe/references/design-verification.md"] }, + { + "id": "contact-cell-scale2-quantization-fixed-gap-regression", + "prompt": "This is a Skill internal rules regression using supplied frozen evidence, not a normal acceptance request that should start or require additional devices. Review the presentation-only ContactCell design fixture for one supplied iPad mini Target Context: 744x1133 at displayScale 2. Scope is only uiGraph.contactCell.section and its four uniquely identified rows: short, long, extreme, and invalidFixedGapRegression. Coverage inventory: geometry 12 required, spatial relations 28 required, final composition 1 required from tail-truncation screenshot evidence. Typography identity, color, glyph rendering, navigation, business conditions, lifecycle, and release behavior are outside scope; do not count adaptive behavior separately from spatial relations. Avatar and CallIcon sizes are 20x20; Avatar leading and CallIcon trailing edges are 15 for every row. Avatar-to-Title exact-10 gaps for short/long/extreme/invalid are 10, 10, 10, and 18; the invalid row source tolerance is 0.01. Title-to-Call minimum-10 gaps for short/long/extreme/invalid are 597, 447, 10, and 405.5. All other required centerY differences are 0; title-to-call same-center-y actual is -0.25, with no source-declared tolerance. Screenshot has no contradiction. Determine the scoped verdict and report raw actuals.", + "expected_output": "failed overall because the required fixed Avatar-to-Title gap is expected 10 and actual 18 beyond tolerance 0.01; retain the raw actual. Pass the legal Title-to-Call minimum gaps. Derive the display-scale-2 quantization budget as 1/2 = 0.5 logical unit and pass title-to-call same-center-y actual -0.25 as measurement quantization, not layout flexibility. Report the supplied-evidence Coverage Ledger as geometry 12/12 passed, spatial relations 27/28 passed with 1 failed, and final composition 1/1 passed, plus the scoped presentation-only range without requiring a normal acceptance run on multiple devices.", + "files": ["skills/astrolabe/SKILL.md", "skills/astrolabe/references/design-verification.md"] + }, { "id": "legal-adaptive-spacing", "prompt": "Review a Settings row on an iPhone SE. Figma shows a 40-unit title-to-toggle gap on a wider reference frame, but its Auto Layout notes declare a minimum gap of 10 and distribute remaining space. Target Context: 320x568 portrait, English, fontScale 1.0. Frozen Runtime frames measure a 18-unit title-to-toggle gap, with fixed 16-unit row edges preserved. Verify this required adaptive spacing.", diff --git a/skills/astrolabe/references/design-verification.md b/skills/astrolabe/references/design-verification.md index a649762..f0dfd43 100644 --- a/skills/astrolabe/references/design-verification.md +++ b/skills/astrolabe/references/design-verification.md @@ -38,6 +38,13 @@ applies: `viewportWidth`, `viewportHeight`, `safeAreaInsets`, `orientation`, `sizeClass`, `displayScale`, `fontScale`, `locale`, `appearance`, and `systemOccupancy`. Keep logical units distinct from screenshot pixels. +Default acceptance scope is the current Target Context supplied by the +developer. A passing current device is sufficient for that verdict. Do not +automatically start, switch to, or require another device, simulator, viewport, +or breakpoint because an adaptive or display-scale policy exists. Expand scope +only when the developer explicitly requests multi-device, breakpoint, or +additional Target Context acceptance. + ## Design Expectation Record Create one record for every required design fact. Explain every field in the @@ -81,6 +88,30 @@ maximum, range, remaining-space formula, hug/fill, space-between, safe-area rule, or breakpoint policy. Tolerance is measurement error only; never use a large tolerance to imitate a flexible range. +## Display Quantization Tolerance + +Tolerance covers renderer measurement and display quantization only; it never +adds layout flexibility or changes a fixed source contract. For frame-derived +geometry and relations, derive the default quantization budget from Target +Context `displayScale`: at most `1 / displayScale` logical unit, or one physical +pixel. Use the smallest tolerance that explains the evidence. If a source or +token declares a stricter tolerance, use that stricter value. If display scale +or coordinate provenance is unavailable, do not infer a budget. + +Report raw `actual` values even when they pass within this budget. Apply the +declared tolerance explicitly: `exact` passes when absolute difference is at +most tolerance; `minimum` passes when actual is at least lower bound minus +tolerance; `maximum` passes when actual is at most upper bound plus tolerance; +and `range` passes when actual is within the inclusive bounds expanded by the +tolerance. This budget cannot conceal a meaningful boundary violation, overflow, +or fixed-gap regression. + +For example, at displayScale 2, a measured `centerY` relation difference of +`-0.25` passes within the 0.5 logical-unit quantization budget. An exact +Avatar-to-Title requirement of 10 with actual 18 and tolerance 0.01 still +fails. Fixed component sizes and edge insets remain exact unless their source +declares another policy. + ## Coverage and Evidence Gates Maintain a Coverage Ledger with required expectation counts for: content and @@ -92,6 +123,10 @@ only when the authoritative contract says the expectation does not apply to current conditions. It is neither unchecked nor a result that blocks `passed` by itself; an Agent must not use it to evade a required check. +The Coverage Ledger must be complete for the current acceptance scope and +Target Context only. Other viewports are outside the current verdict by default; +their absence neither blocks `passed` nor makes the result `inconclusive`. + Use frozen Runtime evidence for exact values and relations. Confirm coordinates use the same coordinate space before calculating a gap or alignment. Use latest screenshots for composition and correlate them to Runtime nodes. If a screenshot diff --git a/test/review-contract.test.mjs b/test/review-contract.test.mjs index 3ef3e1e..f71e309 100644 --- a/test/review-contract.test.mjs +++ b/test/review-contract.test.mjs @@ -130,6 +130,9 @@ test("Astrolabe skill gates design verification on target readiness and complete assert.match(guidance, /Design source priority/i); assert.match(guidance, /Target Context/i); assert.match(guidance, /Design Expectation/i); + assert.match(guidance, /current Target Context.*current device.*sufficient/is); + assert.match(guidance, /do not\s+automatically.*another device/is); + assert.match(guidance, /Other viewports.*neither blocks.*passed.*inconclusive/is); for (const policy of [ "exact", "minimum", @@ -143,6 +146,10 @@ test("Astrolabe skill gates design verification on target readiness and complete } assert.match(guidance, /tolerance.*measurement error/i); assert.match(guidance, /fixed.*flexible|flexible.*fixed/is); + assert.match(guidance, /1\s*\/\s*displayScale/i); + assert.match(guidance, /one physical\s+pixel/i); + assert.match(guidance, /raw `actual`/i); + assert.match(guidance, /never.*layout flexibility/is); assert.match(guidance, /unique node|coordinate.*evidence/i); assert.match(guidance, /Coverage Ledger/i); assert.match(guidance, /screenshot.*contradiction|contradiction.*screenshot/is); @@ -181,7 +188,8 @@ test("Astrolabe skill gates design verification on target readiness and complete "derived-policy-pass", "derived-policy-fail", "conditional-policy-pass", - "conditional-policy-fail" + "conditional-policy-fail", + "contact-cell-scale2-quantization-fixed-gap-regression" ]) { assert.ok(evalsByID.has(id), `missing stable eval: ${id}`); } @@ -193,6 +201,12 @@ test("Astrolabe skill gates design verification on target readiness and complete assert.match(evalsByID.get("image-center-mode-overflow").prompt, /27x27/); assert.match(evalsByID.get("image-center-mode-overflow").prompt, /54x54/); assert.match(evalsByID.get("image-center-mode-overflow").prompt, /fully contained/i); + const quantizationEval = evalsByID.get( + "contact-cell-scale2-quantization-fixed-gap-regression" + ); + assert.match(quantizationEval.prompt, /displayScale 2/i); + assert.match(quantizationEval.prompt, /-0\.25/); + assert.match(quantizationEval.prompt, /Avatar-to-Title.*18/i); }); test("Astrolabe skill separates rendered content from its layout container", async () => { From b8e6f697a53a2df18659c172c437f97a22199340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BD=A9=E8=BE=95=E5=8D=81=E5=9B=9B?= Date: Mon, 17 Aug 2026 20:05:57 +0800 Subject: [PATCH 10/10] chore: release 2.2.2 --- Sources/AstrolabeCLI/CommandLine/AstrolabeHostMetadata.swift | 2 +- docs/protocol-architecture.md | 2 +- mcp-adapter/package-lock.json | 4 ++-- mcp-adapter/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Sources/AstrolabeCLI/CommandLine/AstrolabeHostMetadata.swift b/Sources/AstrolabeCLI/CommandLine/AstrolabeHostMetadata.swift index ea9fc3c..96d635e 100644 --- a/Sources/AstrolabeCLI/CommandLine/AstrolabeHostMetadata.swift +++ b/Sources/AstrolabeCLI/CommandLine/AstrolabeHostMetadata.swift @@ -7,5 +7,5 @@ package enum AstrolabeHostMetadata { /// Host release version advertised during Runtime negotiation. - package static let version = "2.2.1" + package static let version = "2.2.2" } diff --git a/docs/protocol-architecture.md b/docs/protocol-architecture.md index 800e338..31cdafb 100644 --- a/docs/protocol-architecture.md +++ b/docs/protocol-architecture.md @@ -121,7 +121,7 @@ The protocol has three independent versions that must not be conflated: | --- | --- | --- | | Package version | `AstrolabeProtocol 2.0.0`, `astrolabe-protocol-kotlin 2.0.0` | SwiftPM and Maven Central dependencies and releases | | Wire Protocol version | `2.0` | Runtime handshake and compatibility decisions | -| Product version | Host 2.1.0 release snapshot: `astrolabe 2.2.1`, `runtime-ios 2.1.0`, `runtime-android 2.0.1` | Independent Host and platform SDK releases | +| Product version | Host 2.1.0 release snapshot: `astrolabe 2.2.2`, `runtime-ios 2.1.0`, `runtime-android 2.0.1` | Independent Host and platform SDK releases | The product-version example is a release snapshot, not a lockstep versioning requirement. diff --git a/mcp-adapter/package-lock.json b/mcp-adapter/package-lock.json index cf7f9ac..2c54cbf 100644 --- a/mcp-adapter/package-lock.json +++ b/mcp-adapter/package-lock.json @@ -1,12 +1,12 @@ { "name": "@astrolabe/mcp-adapter", - "version": "2.2.1", + "version": "2.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@astrolabe/mcp-adapter", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", "zod": "^3.25.76" diff --git a/mcp-adapter/package.json b/mcp-adapter/package.json index 91f5cf3..45c5102 100644 --- a/mcp-adapter/package.json +++ b/mcp-adapter/package.json @@ -1,6 +1,6 @@ { "name": "@astrolabe/mcp-adapter", - "version": "2.2.1", + "version": "2.2.2", "private": true, "type": "module", "scripts": { diff --git a/package-lock.json b/package-lock.json index 0d8e9b5..0f71307 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "astrolabe", - "version": "2.2.1", + "version": "2.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "astrolabe", - "version": "2.2.1", + "version": "2.2.2", "workspaces": [ "mcp-adapter" ], diff --git a/package.json b/package.json index 59d3449..c28db8f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "astrolabe", - "version": "2.2.1", + "version": "2.2.2", "private": true, "workspaces": [ "mcp-adapter"