@amityco/social-plus-vise 0.14.10 → 0.14.12

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/CHANGELOG.md CHANGED
@@ -4,6 +4,26 @@ All notable changes to `@amityco/social-plus-vise` are documented in this file.
4
4
 
5
5
  The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 0.14.12 — 2026-06-05
8
+
9
+ ### Changed
10
+ - **Multi-sensor shared expectations:** `comments.thread-read-write` now groups the concrete comment page-size, creation-affordance, observer-cleanup, and UI-state sensors under one public product expectation while preserving `contractRuleId` and `validator.sensorId` evidence.
11
+ - **Ambiguous attestation guidance:** when a shared public expectation maps to multiple contract rules in the same sidecar, `vise check` now emits exact `vise attest --rule <contract-rule-id>` commands and `vise attest --rule <public-id>` rejects with a concrete rule list.
12
+ - **Public benchmark claim:** README benchmark copy now describes the current full workflow, current release validation, and evidence boundaries without relying on local benchmark artifact links.
13
+
14
+ ### Verified
15
+ - Packed-package smoke for `@amityco/social-plus-vise@0.14.12` confirmed plan question surfacing, design confirmation, optional feed capability sensors, shared comment expectation grouping, and ambiguous attestation handling.
16
+
17
+ ## 0.14.11 — 2026-06-05
18
+
19
+ ### Added
20
+ - **Cross-platform product validators:** rich post composer scope, chat unread visibility, chat message ordering, and profile follower/following counts now have deterministic sensors beyond Android where the bundled SDK surface supports the capability.
21
+ - **Shared expectation bindings:** TypeScript, React Native, Flutter, and iOS product gaps now report shared public expectation IDs such as `feed.rich-post-composer-scope`, `comments.thread-read-write`, `chat.unread-visible`, and `profile.social-counts` while retaining platform sensor evidence.
22
+
23
+ ### Changed
24
+ - **Availability-aware chat gating:** TypeScript and React Native unread support is now recognized from `ChannelUnread` SDK facts; message-order validation remains direct `validate_setup` only there until the SDK surface proves a message-specific sort API.
25
+ - **Plan validation IDs:** add-feed and add-chat plans use shared product expectation IDs across platforms instead of Android-only public validation IDs.
26
+
7
27
  ## 0.14.10 — 2026-06-05
8
28
 
9
29
  ### Changed
package/README.md CHANGED
@@ -143,27 +143,40 @@ A bench vise holds the workpiece steady so the craftsman's hands are free to sha
143
143
 
144
144
  ## Benchmark Results: Current Claim
145
145
 
146
- > **Vise gives AI coding agents a governed workflow for social.plus integrations, improving feature completeness, SDK compliance, and design consistency in greenfield work.**
146
+ > **Vise gives AI coding agents a governed workflow for social.plus integrations: it makes scope explicit, checks the local code, and turns missing SDK capabilities or compliance gaps into repair work before the agent stops.**
147
147
 
148
- The strongest current claim is not a universal speed or quality promise. It is narrower and more useful: when agents build greenfield social.plus SDK features, the Vise workflow makes scope explicit, checks local code, and turns missing capabilities or SDK violations into concrete repair work before the agent stops.
148
+ The strongest current claim is not a universal speed or quality promise. It is narrower and more useful: for greenfield social.plus SDK work, Vise improves the stopping condition. The agent is not done after reading docs or producing code; it is done when the local contract is green, attested, or blocked on explicit customer input.
149
149
 
150
- ### Latest headline: feed completeness
150
+ ### Latest Quantitative Benchmark
151
151
 
152
- The latest Vise 0.14.5 opt-in comparison is the headline product proof. Same feed request, same SDK docs, no Vise workflow in the baseline; the Vise arm explicitly selected `feed_optional_capabilities=post-image-upload,post-poll-creation,post-edit`, persisted that choice into `sp-vise/compliance.json`, and activated the selected sensors.
152
+ The latest feed-completeness benchmark remains the headline product proof. Same feed request, same SDK docs, no Vise workflow in the baseline; the Vise arm explicitly selected feed optional capabilities, persisted that choice into `sp-vise/compliance.json`, and activated selected source sensors.
153
153
 
154
- | Agent / model | Docs-only baseline | Vise 0.14.5 opt-in arm | Readout |
154
+ | Agent / model | Docs-only baseline | Vise opt-in arm | Readout |
155
155
  |---|---:|---:|---|
156
156
  | Cursor / Composer 2.5 | 30% (3.3/11 avg) | **97% (32/33)** | One seed surfaced the remaining item instead of silently dropping it. |
157
157
  | Claude / Sonnet 4.6 medium | 27% (3.0/11 avg) | **100% (33/33)** | All three Vise seeds reached 11/11. |
158
158
  | Codex / GPT-5.4 medium | 21% (2.3/11 avg) | **100% (33/33)** | All three Vise seeds reached 11/11. |
159
159
 
160
- Aggregate: **98/99 expected feed capabilities** and **27/27 selected optional capabilities** implemented across the latest Vise arm. See the full table and per-seed grader links in [`benchmarks/CURSOR_VISE_0.14.5_RESULTS.html`](benchmarks/CURSOR_VISE_0.14.5_RESULTS.html).
160
+ Aggregate: **98/99 expected feed capabilities** and **27/27 selected optional capabilities** implemented across the Vise arm.
161
161
 
162
- ### Supporting proof
162
+ ### Current Release Validation
163
+
164
+ Version 0.14.12 adds current release proof around the full feed-forward and validation flow:
165
+
166
+ | Surface | What was validated |
167
+ |---|---|
168
+ | **Product flow** | Local end-to-end smoke covers design extraction, plan feed-forward, blocking intake, answered init, capability check, design conformance, and sensor discovery. |
169
+ | **Plan questions** | Plans surface blocking questions such as `feature_surface` and `design_contract_confirmation`, plus optional choices such as `feed_optional_capabilities`. |
170
+ | **Capability-to-sensor flow** | Vise checks platform support, matches the prompt to available capabilities, offers supported features as questions, records answers, and turns selected answers into sensors in `vise check`. |
171
+ | **Shared product expectations** | Public IDs such as `comments.thread-read-write` stay platform-agnostic while check results retain concrete `contractRuleId` and `validator.sensorId` evidence. |
172
+ | **Rule detection** | TP-track dashboard detects **311/311 seeded rule gaps (100.0%)** in the static corpus. |
173
+ | **Packed-package smoke** | A real Antigravity agent smoke tested the 0.14.12 tarball, opted into surfaced plan questions, repaired selected optional poll capability sensors, and verified ambiguous shared comment attestations require exact contract rule IDs. |
174
+
175
+ ### Supporting Proof
163
176
 
164
177
  | Surface | Safe claim | Evidence |
165
178
  |---|---|---|
166
- | **Feature completeness** | Vise helps agents build more of the expected SDK capability surface. | Latest comparison: **21-30% without Vise vs 97-100% with Vise 0.14.5**. Earlier pre-registered Capability Matrix Row 2 also shipped a feature-completeness win: silently dropped items fell from 7.67/11 to 4.0/11. |
179
+ | **Feature completeness** | Vise helps agents build more of the expected SDK capability surface. | Latest comparison: **21-30% without Vise vs 97-100% with Vise**, with **98/99** expected feed capabilities implemented in aggregate. Earlier Capability Matrix work also showed silently dropped items falling from 7.67/11 to 4.0/11. |
167
180
  | **SDK compliance** | Vise checks catch greenfield SDK compliance gaps that docs or static guidance can miss. | Commune benchmark: Vise averaged **100% greenfield SDK compliance** where docs/RAG-style controls averaged **67%** across the reported slices. |
168
181
  | **Design conformance** | Vise design checks reduce design drift under ambiguous briefs. | Ambiguous Spotify-style design test: Vise design runs produced **0 / 0 / 0 hex literals** across three seeds; without Vise, runs varied **0 / 2 / 15**. This supports variance reduction, not pixel-perfect visual quality. |
169
182
 
@@ -172,10 +185,10 @@ Aggregate: **98/99 expected feed capabilities** and **27/27 selected optional ca
172
185
  The benchmark story is the product flow:
173
186
 
174
187
  1. **Inspect** — Vise detects platform, app surface, SDK surface, sensors, and design signals from the local repo.
175
- 2. **Plan** — Vise classifies the outcome, asks blocking intake questions, surfaces capability availability, and offers optional feed capabilities only when the platform SDK surface supports them.
188
+ 2. **Plan** — Vise classifies the outcome, asks blocking intake questions, checks platform capability availability, and offers optional features only when the platform SDK surface supports them.
176
189
  3. **Confirm design** — `vise design extract` writes a preview; `plan`/`init` withhold design feed-forward until the user confirms `design_contract_confirmation=yes`.
177
190
  4. **Initialize** — `vise init` records the resolved compliance contract, intake answers, selected optional capabilities, inspection result, and accepted design digest.
178
- 5. **Build / check / repair** — the agent edits locally, runs `vise check`, fixes deterministic findings, resolves completeness gaps or selected-capability failures, and then runs project sensors.
191
+ 5. **Build / check / repair** — selected answers become sensors. The agent edits locally, runs `vise check`, fixes deterministic findings, resolves completeness gaps or selected-capability failures, and then runs project sensors.
179
192
 
180
193
  Static docs can tell an agent what exists. Vise changes the stopping condition: the agent is not done until the local contract is green, attested, or blocked on explicit customer input.
181
194
 
@@ -183,14 +196,12 @@ Static docs can tell an agent what exists. Vise changes the stopping condition:
183
196
 
184
197
  The benchmark suite is intentionally reported with boundaries:
185
198
 
186
- - **Latest feed-completeness comparison** is the current product claim for the opt-in capability flow in Vise 0.14.5. It is a best-case/opt-in comparison across Cursor, Claude, and Codex, not a universal result for every prompt.
199
+ - **Latest feed-completeness comparison** is the current quantitative product claim for the opt-in capability flow. It is a best-case/opt-in comparison across Cursor, Claude, and Codex, not a universal result for every prompt.
187
200
  - **Capability Matrix v1** remains the pre-registered follow-up. It shipped the Row 2 feature-completeness claim, found **no Row 1 SDK-compliance claim** on chat/moderation/push under its registered margin, and withheld the Row 3 design claim on a technicality despite higher by-name token use.
188
201
  - **Commune Phase 1** remains useful historical evidence for the compliance loop: two agents reached 9/9 with Vise vs 5-7/9 under controls, but it was N=1 per cell and the grader overlaps Vise's own rules.
189
202
  - **Design tests** support design-drift reduction and token cleanup. They do not prove visual taste, pixel perfection, or production-ready UI without human review.
190
203
  - **Negative results must travel with the claim:** no measured Vise advantage on day-2 bug fixing; the push slice exposed a non-converging attestation loop when docs and SDK disagreed; earlier enumerative plan-time design guidance measured negative and was retracted; the original `scope-omit` affordance went unused in the matrix.
191
204
 
192
- Full evidence: [`benchmarks/CURSOR_VISE_0.14.5_RESULTS.html`](benchmarks/CURSOR_VISE_0.14.5_RESULTS.html), [`benchmarks/capability-matrix/RESULTS.md`](benchmarks/capability-matrix/RESULTS.md), [`benchmarks/commune/RESULTS.md`](benchmarks/commune/RESULTS.md), and [`benchmarks/brand/design-test/RESULTS.md`](benchmarks/brand/design-test/RESULTS.md).
193
-
194
205
  ### Which mode should I use?
195
206
 
196
207
  | If you… | Use | Why |
@@ -468,7 +468,7 @@ export const SHARED_PRODUCT_EXPECTATIONS = [
468
468
  ],
469
469
  },
470
470
  ],
471
- deterministicPlatforms: ["android"],
471
+ deterministicPlatforms: ["android", "flutter", "ios", "typescript"],
472
472
  hint: "implement the selected rich composer paths or record an explicit text-only/rich-post scope decision",
473
473
  },
474
474
  {
@@ -480,7 +480,7 @@ export const SHARED_PRODUCT_EXPECTATIONS = [
480
480
  { label: "SDK comment query", symbols: [/\bgetComments\b/i, /\bqueryComments\b/i] },
481
481
  { label: "SDK comment creation", symbols: [/\bcreateComment\b/i] },
482
482
  ],
483
- deterministicPlatforms: ["android"],
483
+ deterministicPlatforms: ["android", "flutter", "ios", "typescript"],
484
484
  hint: "if comments are shown, pair the list with a composer and loading/error/empty states unless the surface is explicitly read-only",
485
485
  },
486
486
  {
@@ -493,6 +493,8 @@ export const SHARED_PRODUCT_EXPECTATIONS = [
493
493
  label: "SDK unread state",
494
494
  symbols: [
495
495
  /\bunreadCount\b/i,
496
+ /\bChannelUnread\b/i,
497
+ /\bChannelUnreadInfo\b/i,
496
498
  /\bgetUnread\w*\b/i,
497
499
  /\bgetSubChannelsUnreadCount\b/i,
498
500
  /\bobserveUserUnread\b/i,
@@ -501,7 +503,7 @@ export const SHARED_PRODUCT_EXPECTATIONS = [
501
503
  ],
502
504
  },
503
505
  ],
504
- deterministicPlatforms: ["android"],
506
+ deterministicPlatforms: ["android", "flutter", "ios", "typescript"],
505
507
  hint: "render channel-row or global unread state from the SDK, or explicitly choose an unread-free chat surface",
506
508
  },
507
509
  {
@@ -515,7 +517,7 @@ export const SHARED_PRODUCT_EXPECTATIONS = [
515
517
  symbols: [/\bsortBy\b/i, /\bSortOption\b/i, /\bMessageQuerySort\b/i, /\bAmityMessageQuerySortOption\b/i],
516
518
  },
517
519
  ],
518
- deterministicPlatforms: ["android"],
520
+ deterministicPlatforms: ["android", "flutter", "ios", "typescript"],
519
521
  hint: "declare first-created/newest-created order in the SDK query or a clearly named UI sort so the thread cannot be reversed by defaults",
520
522
  },
521
523
  {
@@ -536,7 +538,7 @@ export const SHARED_PRODUCT_EXPECTATIONS = [
536
538
  ],
537
539
  },
538
540
  ],
539
- deterministicPlatforms: ["android"],
541
+ deterministicPlatforms: ["android", "flutter", "ios", "typescript"],
540
542
  hint: "if follower/following labels are rendered, source the counts or lists from the SDK instead of placeholders",
541
543
  },
542
544
  ];
package/dist/outcomes.js CHANGED
@@ -655,13 +655,9 @@ const addFeed = {
655
655
  "no invented communityId/targetId/feedId",
656
656
  `${platform}.feed.target.literal`,
657
657
  "feed.rich-post-rendering",
658
- ...(platform === "android"
659
- ? [
660
- "feed.rich-post-composer-scope",
661
- "comments.thread-read-write",
662
- "profile.social-counts",
663
- ]
664
- : []),
658
+ "feed.rich-post-composer-scope",
659
+ "comments.thread-read-write",
660
+ "profile.social-counts",
665
661
  ],
666
662
  stopConditions: (ctx) => filterStops(ctx.answers, [
667
663
  { id: "feed_target", text: "The concrete feed target is unknown; do not invent communityId, targetId, feedId, or global feed assumptions." },
@@ -792,8 +788,7 @@ const addComments = {
792
788
  "comment target resolved",
793
789
  "no invented postId/commentId",
794
790
  `${platform}.comments.target-resolved`,
795
- `${platform}.comments.observer-cleanup`,
796
- `${platform}.comments.ui-states-present`,
791
+ "comments.thread-read-write",
797
792
  `${platform}.comments.moderation-affordance-present`,
798
793
  ],
799
794
  stopConditions: (ctx) => filterStops(ctx.answers, [
@@ -1027,7 +1022,8 @@ const addChat = {
1027
1022
  `${platform}.chat.message-observer-cleanup`,
1028
1023
  `${platform}.chat.send-error-handling`,
1029
1024
  `${platform}.chat.moderation-affordance-present`,
1030
- ...(platform === "android" ? ["chat.unread-visible", "chat.message-order-explicit"] : []),
1025
+ "chat.unread-visible",
1026
+ ...(platform === "android" || platform === "flutter" || platform === "ios" ? ["chat.message-order-explicit"] : []),
1031
1027
  ],
1032
1028
  stopConditions: (ctx) => filterStops(ctx.answers, [
1033
1029
  { id: "chat_shape", text: "The chat shape is unknown; cannot implement without knowing 1:1, group, or community channel." },
@@ -24,31 +24,201 @@ export const PRODUCT_EXPECTATION_BINDINGS = [
24
24
  sensorId: "ios.feed.post-datatype-handled",
25
25
  platform: "ios",
26
26
  },
27
+ {
28
+ expectationId: "feed.rich-post-composer-scope",
29
+ sensorId: "typescript.feed.rich-post-composer-surfaced",
30
+ platform: "typescript",
31
+ },
32
+ {
33
+ expectationId: "feed.rich-post-composer-scope",
34
+ sensorId: "react-native.feed.rich-post-composer-surfaced",
35
+ platform: "react-native",
36
+ },
27
37
  {
28
38
  expectationId: "feed.rich-post-composer-scope",
29
39
  sensorId: "android.feed.rich-post-composer-surfaced",
30
40
  platform: "android",
31
41
  },
42
+ {
43
+ expectationId: "feed.rich-post-composer-scope",
44
+ sensorId: "flutter.feed.rich-post-composer-surfaced",
45
+ platform: "flutter",
46
+ },
47
+ {
48
+ expectationId: "feed.rich-post-composer-scope",
49
+ sensorId: "ios.feed.rich-post-composer-surfaced",
50
+ platform: "ios",
51
+ },
52
+ {
53
+ expectationId: "comments.thread-read-write",
54
+ sensorId: "typescript.comments.query-has-limit",
55
+ platform: "typescript",
56
+ },
57
+ {
58
+ expectationId: "comments.thread-read-write",
59
+ sensorId: "typescript.comments.creation-affordance-present",
60
+ platform: "typescript",
61
+ },
62
+ {
63
+ expectationId: "comments.thread-read-write",
64
+ sensorId: "typescript.comments.observer-cleanup",
65
+ platform: "typescript",
66
+ },
67
+ {
68
+ expectationId: "comments.thread-read-write",
69
+ sensorId: "typescript.comments.ui-states-present",
70
+ platform: "typescript",
71
+ },
72
+ {
73
+ expectationId: "comments.thread-read-write",
74
+ sensorId: "react-native.comments.query-has-limit",
75
+ platform: "react-native",
76
+ },
77
+ {
78
+ expectationId: "comments.thread-read-write",
79
+ sensorId: "react-native.comments.creation-affordance-present",
80
+ platform: "react-native",
81
+ },
82
+ {
83
+ expectationId: "comments.thread-read-write",
84
+ sensorId: "react-native.comments.observer-cleanup",
85
+ platform: "react-native",
86
+ },
87
+ {
88
+ expectationId: "comments.thread-read-write",
89
+ sensorId: "react-native.comments.ui-states-present",
90
+ platform: "react-native",
91
+ },
92
+ {
93
+ expectationId: "comments.thread-read-write",
94
+ sensorId: "android.comments.query-has-limit",
95
+ platform: "android",
96
+ },
97
+ {
98
+ expectationId: "comments.thread-read-write",
99
+ sensorId: "android.comments.creation-affordance-present",
100
+ platform: "android",
101
+ },
102
+ {
103
+ expectationId: "comments.thread-read-write",
104
+ sensorId: "android.comments.observer-cleanup",
105
+ platform: "android",
106
+ },
107
+ {
108
+ expectationId: "comments.thread-read-write",
109
+ sensorId: "android.comments.ui-states-present",
110
+ platform: "android",
111
+ },
32
112
  {
33
113
  expectationId: "comments.thread-read-write",
34
114
  sensorId: "android.comments.thread-ui-states-present",
35
115
  platform: "android",
36
116
  },
117
+ {
118
+ expectationId: "comments.thread-read-write",
119
+ sensorId: "flutter.comments.query-has-limit",
120
+ platform: "flutter",
121
+ },
122
+ {
123
+ expectationId: "comments.thread-read-write",
124
+ sensorId: "flutter.comments.creation-affordance-present",
125
+ platform: "flutter",
126
+ },
127
+ {
128
+ expectationId: "comments.thread-read-write",
129
+ sensorId: "flutter.comments.observer-cleanup",
130
+ platform: "flutter",
131
+ },
132
+ {
133
+ expectationId: "comments.thread-read-write",
134
+ sensorId: "flutter.comments.ui-states-present",
135
+ platform: "flutter",
136
+ },
137
+ {
138
+ expectationId: "comments.thread-read-write",
139
+ sensorId: "ios.comments.query-has-limit",
140
+ platform: "ios",
141
+ },
142
+ {
143
+ expectationId: "comments.thread-read-write",
144
+ sensorId: "ios.comments.creation-affordance-present",
145
+ platform: "ios",
146
+ },
147
+ {
148
+ expectationId: "comments.thread-read-write",
149
+ sensorId: "ios.comments.observer-cleanup",
150
+ platform: "ios",
151
+ },
152
+ {
153
+ expectationId: "comments.thread-read-write",
154
+ sensorId: "ios.comments.ui-states-present",
155
+ platform: "ios",
156
+ },
157
+ {
158
+ expectationId: "chat.unread-visible",
159
+ sensorId: "typescript.chat.unread-visible",
160
+ platform: "typescript",
161
+ },
162
+ {
163
+ expectationId: "chat.unread-visible",
164
+ sensorId: "react-native.chat.unread-visible",
165
+ platform: "react-native",
166
+ },
37
167
  {
38
168
  expectationId: "chat.unread-visible",
39
169
  sensorId: "android.chat.unread-visible",
40
170
  platform: "android",
41
171
  },
172
+ {
173
+ expectationId: "chat.unread-visible",
174
+ sensorId: "flutter.chat.unread-visible",
175
+ platform: "flutter",
176
+ },
177
+ {
178
+ expectationId: "chat.unread-visible",
179
+ sensorId: "ios.chat.unread-visible",
180
+ platform: "ios",
181
+ },
42
182
  {
43
183
  expectationId: "chat.message-order-explicit",
44
184
  sensorId: "android.chat.sort-explicit",
45
185
  platform: "android",
46
186
  },
187
+ {
188
+ expectationId: "chat.message-order-explicit",
189
+ sensorId: "flutter.chat.sort-explicit",
190
+ platform: "flutter",
191
+ },
192
+ {
193
+ expectationId: "chat.message-order-explicit",
194
+ sensorId: "ios.chat.sort-explicit",
195
+ platform: "ios",
196
+ },
197
+ {
198
+ expectationId: "profile.social-counts",
199
+ sensorId: "typescript.profile.social-counts-from-sdk",
200
+ platform: "typescript",
201
+ },
202
+ {
203
+ expectationId: "profile.social-counts",
204
+ sensorId: "react-native.profile.social-counts-from-sdk",
205
+ platform: "react-native",
206
+ },
47
207
  {
48
208
  expectationId: "profile.social-counts",
49
209
  sensorId: "android.profile.social-counts-from-sdk",
50
210
  platform: "android",
51
211
  },
212
+ {
213
+ expectationId: "profile.social-counts",
214
+ sensorId: "flutter.profile.social-counts-from-sdk",
215
+ platform: "flutter",
216
+ },
217
+ {
218
+ expectationId: "profile.social-counts",
219
+ sensorId: "ios.profile.social-counts-from-sdk",
220
+ platform: "ios",
221
+ },
52
222
  ];
53
223
  const bindingsBySensorId = new Map(PRODUCT_EXPECTATION_BINDINGS.map((binding) => [binding.sensorId, binding]));
54
224
  export function productExpectationBindingForSensor(sensorId) {
@@ -79,3 +249,6 @@ export function contractRuleCandidatesForPublicId(ruleId) {
79
249
  .filter((binding) => binding.expectationId === ruleId)
80
250
  .map((binding) => binding.sensorId);
81
251
  }
252
+ export function hasMultipleContractRuleCandidates(ruleId) {
253
+ return contractRuleCandidatesForPublicId(ruleId).length > 1;
254
+ }
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { assessProjectCompleteness, assessProjectSelectedOptionalCapabilities, availableOptionalCapabilityIds, optionalCapabilityChecklist, platformCapabilityAvailability, selectedOptionalCapabilityIds, } from "../capabilities.js";
6
6
  import { getOutcomeDefinition, hasAnswer, planContextFor, resolveOutcome, } from "../outcomes.js";
7
- import { contractRuleCandidatesForPublicId, productExpectationBindingForSensor, publicProductRuleId, } from "../productExpectations.js";
7
+ import { contractRuleCandidatesForPublicId, hasMultipleContractRuleCandidates, productExpectationBindingForSensor, publicProductRuleId, } from "../productExpectations.js";
8
8
  import { objectInput, optionalBooleanField, optionalStringField, stringField, textResult } from "../types.js";
9
9
  import { packageVersion } from "../version.js";
10
10
  import { DESIGN_CONTRACT_CONFIRMATION_ANSWER_ID, buildDesignBrief, designContractConfirmationFromAnswers, designPreviewPath, readDesignContract, } from "./design.js";
@@ -568,7 +568,7 @@ export async function checkCompliance(repoPath) {
568
568
  recommendation: finding?.recommendation,
569
569
  rationale: rule.rationale,
570
570
  current_rule: ruleSummary(rule),
571
- ...(failStatus === "attestation-needed" && rule.enforcement.attestation.allowed && attestHint(rule)),
571
+ ...(failStatus === "attestation-needed" && rule.enforcement.attestation.allowed && attestHint(rule, compliance)),
572
572
  });
573
573
  continue;
574
574
  }
@@ -593,7 +593,7 @@ export async function checkCompliance(repoPath) {
593
593
  rationale: rule.rationale,
594
594
  current_rule: ruleSummary(rule),
595
595
  source_fingerprint_status: sourceFingerprintStatus,
596
- ...(fingerprintStatus === "attestation-needed" && rule.enforcement.attestation.allowed && attestHint(rule)),
596
+ ...(fingerprintStatus === "attestation-needed" && rule.enforcement.attestation.allowed && attestHint(rule, compliance)),
597
597
  });
598
598
  continue;
599
599
  }
@@ -639,7 +639,7 @@ export async function checkCompliance(repoPath) {
639
639
  recommendation: finding?.recommendation,
640
640
  current_rule: ruleSummary(rule),
641
641
  ...(isInferential && { inferential_prompt: rule.enforcement.inferential?.prompt }),
642
- ...(baseStatus === "attestation-needed" && rule.enforcement.attestation.allowed && attestHint(rule)),
642
+ ...(baseStatus === "attestation-needed" && rule.enforcement.attestation.allowed && attestHint(rule, compliance)),
643
643
  });
644
644
  }
645
645
  const summary = summarize(results);
@@ -738,6 +738,14 @@ export async function attestRule(args) {
738
738
  const contractRuleId = resolveRuleIdForContract(args.ruleId, compliance, rules);
739
739
  const rule = contractRuleId ? rules.get(contractRuleId) : undefined;
740
740
  if (!rule || !contractRuleId) {
741
+ const ambiguousCandidates = contractRuleCandidatesForContract(args.ruleId, compliance, rules);
742
+ if (ambiguousCandidates.length > 1) {
743
+ const candidateIds = ambiguousCandidates
744
+ .filter((candidate) => rules.get(candidate)?.enforcement.attestation.allowed)
745
+ .slice(0, 8);
746
+ const ids = candidateIds.length > 0 ? candidateIds : ambiguousCandidates.slice(0, 8);
747
+ throw new Error(`Rule is ambiguous in this compliance contract: ${args.ruleId}. Attest one contract rule at a time: ${ids.join(", ")}.`);
748
+ }
741
749
  // Collect up to 8 applicable attestable rule ids from this contract for the error hint. Prefer
742
750
  // ids that share the bad id's non-wildcard prefix so the agent can narrow down quickly.
743
751
  const attestableIds = compliance.rules
@@ -746,7 +754,10 @@ export async function attestRule(args) {
746
754
  const prefix = args.ruleId.replace(/\.\*$|\*$/, "");
747
755
  const prefixed = prefix !== args.ruleId ? attestableIds.filter((r) => r.id.startsWith(prefix) || r.id.includes(prefix) || publicProductRuleId(r.id).startsWith(prefix)) : [];
748
756
  const candidates = prefixed.length > 0 ? prefixed : attestableIds;
749
- const hintIds = candidates.slice(0, 8).map((r) => publicProductRuleId(r.id));
757
+ const hintIds = uniqueStrings(candidates.slice(0, 8).map((r) => {
758
+ const publicId = publicProductRuleId(r.id);
759
+ return publicId === r.id ? r.id : `${publicId} (${r.id})`;
760
+ }));
750
761
  const hintSuffix = hintIds.length > 0 ? ` Applicable attestable rules: ${hintIds.join(", ")}.` : " Applicable attestable rules: none.";
751
762
  const preamble = args.ruleId.includes("*")
752
763
  ? `Wildcards are not supported — attest one rule at a time.`
@@ -780,6 +791,31 @@ export async function explainRule(ruleId) {
780
791
  const rules = await rulesById();
781
792
  const contractRuleId = resolveRuleIdForInstalledRules(ruleId, rules);
782
793
  const rule = contractRuleId ? rules.get(contractRuleId) : undefined;
794
+ const publicCandidates = contractRuleCandidatesForPublicId(ruleId)
795
+ .map((candidate) => rules.get(candidate))
796
+ .filter((candidate) => candidate !== undefined);
797
+ if (!rule && publicCandidates.length > 1) {
798
+ return {
799
+ id: ruleId,
800
+ kind: "product_expectation",
801
+ note: "This public product expectation is validated by multiple concrete contract rules. Use the contract_rule_id when attesting one sensor.",
802
+ contract_rules: publicCandidates.map((candidate) => {
803
+ const binding = productExpectationBindingForSensor(candidate.id);
804
+ return {
805
+ contract_rule_id: candidate.id,
806
+ ...(binding && { validator: { platform: binding.platform, sensorId: binding.sensorId } }),
807
+ version: candidate.version,
808
+ title: candidate.title,
809
+ severity: candidate.severity,
810
+ rationale: candidate.rationale,
811
+ applies_when: candidate.applies_when,
812
+ attestation: candidate.enforcement.attestation,
813
+ summary: ruleSummary(candidate),
814
+ rule_digest: digestRule(candidate),
815
+ };
816
+ }),
817
+ };
818
+ }
783
819
  if (!rule || !contractRuleId) {
784
820
  throw new Error(`Unknown compliance rule: ${ruleId}`);
785
821
  }
@@ -865,9 +901,12 @@ function resolveRuleIdForContract(ruleId, compliance, rules) {
865
901
  if (rules.has(ruleId) && compliance.rules.some((ref) => ref.rule_id === ruleId)) {
866
902
  return ruleId;
867
903
  }
868
- const candidates = contractRuleCandidatesForPublicId(ruleId).filter((candidate) => rules.has(candidate) && compliance.rules.some((ref) => ref.rule_id === candidate));
904
+ const candidates = contractRuleCandidatesForContract(ruleId, compliance, rules);
869
905
  return candidates.length === 1 ? candidates[0] : null;
870
906
  }
907
+ function contractRuleCandidatesForContract(ruleId, compliance, rules) {
908
+ return contractRuleCandidatesForPublicId(ruleId).filter((candidate) => rules.has(candidate) && compliance.rules.some((ref) => ref.rule_id === candidate));
909
+ }
871
910
  function resolveRuleIdForInstalledRules(ruleId, rules) {
872
911
  if (rules.has(ruleId)) {
873
912
  return ruleId;
@@ -895,12 +934,16 @@ function ruleRefForFile(rule) {
895
934
  }
896
935
  // Benchmark-measured friction: agents looped on attest dialect for ~25 min/cell when docs and SDK
897
936
  // disagreed on exact invocation syntax (capability-matrix 2026-06, Row 5). Hand them the exact incantation.
898
- function attestHint(rule) {
937
+ function attestHint(rule, compliance) {
899
938
  const minConfidence = rule.enforcement.attestation.host_agent_min_confidence ?? "high";
900
939
  const fields = rule.enforcement.attestation.evidence_required ?? [];
901
940
  const publicRuleId = publicProductRuleId(rule.id);
941
+ const candidatesInContract = compliance
942
+ ? contractRuleCandidatesForPublicId(publicRuleId).filter((candidate) => compliance.rules.some((ref) => ref.rule_id === candidate))
943
+ : contractRuleCandidatesForPublicId(publicRuleId);
944
+ const ruleArg = hasMultipleContractRuleCandidates(publicRuleId) && candidatesInContract.length > 1 ? rule.id : publicRuleId;
902
945
  return {
903
- attest_command: `vise attest --rule ${publicRuleId} --confidence ${minConfidence} --signer host-agent --evidence-file sp-vise/evidence/${rule.id}.json --rationale "<why this rule is satisfied (or cannot apply) in this codebase>"`,
946
+ attest_command: `vise attest --rule ${ruleArg} --confidence ${minConfidence} --signer host-agent --evidence-file sp-vise/evidence/${rule.id}.json --rationale "<why this rule is satisfied (or cannot apply) in this codebase>"`,
904
947
  evidence_template: Object.fromEntries(fields.map((f) => [f.field, `<${f.description}>`])),
905
948
  };
906
949
  }
@@ -939,7 +982,7 @@ function contractDrift(compliance, rules) {
939
982
  status: "stale",
940
983
  reason,
941
984
  current_rule: ruleSummary(rule),
942
- ...(rule.enforcement.attestation.allowed && attestHint(rule)),
985
+ ...(rule.enforcement.attestation.allowed && attestHint(rule, compliance)),
943
986
  });
944
987
  }
945
988
  }
@@ -959,6 +1002,18 @@ function deterministicFindingIds(rule) {
959
1002
  .filter((check) => check.check === "validator-finding-absent")
960
1003
  .map((check) => check.finding_rule_id);
961
1004
  }
1005
+ function uniqueStrings(values) {
1006
+ const seen = new Set();
1007
+ const result = [];
1008
+ for (const value of values) {
1009
+ if (seen.has(value)) {
1010
+ continue;
1011
+ }
1012
+ seen.add(value);
1013
+ result.push(value);
1014
+ }
1015
+ return result;
1016
+ }
962
1017
  function buildAttestation(compliance, rule, signer, confidence, identity, rationale, evidence, sourceFingerprints = []) {
963
1018
  const ref = compliance.rules.find((item) => item.rule_id === rule.id);
964
1019
  if (!ref) {