@checkbox-site/mcp 0.1.0 → 0.2.0
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/README.md +3 -3
- package/dist/api.d.ts +7 -0
- package/dist/api.js +11 -1
- package/dist/format.d.ts +32 -1
- package/dist/format.js +261 -20
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/permissions.js +2 -2
- package/dist/resources.js +11 -10
- package/dist/server.d.ts +28 -3
- package/dist/server.js +104 -11
- package/dist/stdio.js +2 -1
- package/dist/todo.d.ts +25 -0
- package/dist/todo.js +35 -0
- package/dist/tools.d.ts +196 -1
- package/dist/tools.js +1084 -184
- package/dist/unknown-args.d.ts +22 -0
- package/dist/unknown-args.js +119 -0
- package/dist/version-check.d.ts +22 -0
- package/dist/version-check.js +50 -0
- package/package.json +2 -2
package/dist/tools.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// The Checkbox MCP tools. Names are stable snake_case; descriptions are written for an LLM (when to use, what to pass,
|
|
2
2
|
// what the output means, what to do next). Every tool is a thin call (or two) to the public /v1 API, so scopes,
|
|
3
3
|
// test/live isolation and validation are exactly the API's.
|
|
4
|
+
import { nestedUnknownMessage, unknownArgumentsMessage, unknownKeysParams } from "./unknown-args.js";
|
|
4
5
|
import { z } from 'zod';
|
|
6
|
+
import { planTodo, todoLine } from "./todo.js";
|
|
5
7
|
import { CheckboxApiError } from "./api.js";
|
|
6
|
-
import { nextActionOf, untrusted } from "./format.js";
|
|
8
|
+
import { ciHint, nextActionOf, untrusted } from "./format.js";
|
|
7
9
|
import { keyAllows } from "./permissions.js";
|
|
8
10
|
/** A failure the tool explains itself (not an API error): rendered as an error result with a next step. */
|
|
9
11
|
export class ToolError extends Error {
|
|
@@ -31,20 +33,47 @@ const jurisdictions = z
|
|
|
31
33
|
.union([z.literal('all'), z.array(z.string().max(10)).max(60)])
|
|
32
34
|
.describe('Where the business sells: "all" (default) or state codes like ["US-CA","US-NY"] ("CA" works too). Federal law is always included.');
|
|
33
35
|
const asOf = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe('Evaluate the law as of this date, YYYY-MM-DD (default today).');
|
|
34
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Audit P2: notice kinds have two names for the trial email (trial_reminder in checks / verify, trial_ending in Notices).
|
|
38
|
+
* Every MCP tool accepts both (and charge_notice where the API checks it) and sends the API the name it expects.
|
|
39
|
+
*/
|
|
40
|
+
const EMAIL_KINDS = ['acknowledgment', 'trial_reminder', 'trial_ending', 'renewal_reminder', 'price_change', 'annual_terms', 'charge_notice', 'cancellation_confirmation'];
|
|
41
|
+
const emailKind = z.enum(EMAIL_KINDS);
|
|
42
|
+
/** The checks / verify API name (trial_reminder). */
|
|
43
|
+
export const checkKind = (k) => (k === 'trial_ending' ? 'trial_reminder' : k);
|
|
44
|
+
/** The Notices API name (trial_ending). */
|
|
45
|
+
export const noticeKind = (k) => (k === 'trial_reminder' ? 'trial_ending' : k);
|
|
46
|
+
const intervalStr = z.string().max(20).describe('Billing interval: day, week, month or year.');
|
|
35
47
|
const planTermsShape = {
|
|
36
48
|
merchant_name: z.string().max(200).optional().describe('Brand shown to subscribers, e.g. "Acme".'),
|
|
49
|
+
brand_name: z.string().max(200).optional().describe('Alias of merchant_name.'),
|
|
37
50
|
plan_name: z.string().max(200).optional().describe('Plan / product name, e.g. "Pro".'),
|
|
38
|
-
price: z.union([z.number().positive(), z.string().max(40)]).optional().describe('Recurring price
|
|
39
|
-
amount: z.number().int().positive().optional().describe('
|
|
51
|
+
price: z.union([z.number().positive(), z.string().max(40)]).optional().describe('Recurring price, major units (19.99).'),
|
|
52
|
+
amount: z.number().int().positive().optional().describe('Or minor units (1999 = $19.99).'),
|
|
40
53
|
currency: z.string().length(3).optional().describe('ISO currency, default "usd".'),
|
|
41
54
|
interval: intervalStr.optional(),
|
|
42
|
-
interval_count: z.number().int().min(1).max(36).optional().describe('
|
|
55
|
+
interval_count: z.number().int().min(1).max(36).optional().describe('Default 1; 3 + month = quarterly.'),
|
|
43
56
|
trial_days: z.number().int().min(0).max(730).optional().describe('Free-trial days (0 or absent = no trial).'),
|
|
44
|
-
|
|
57
|
+
// Pilot round 12 (change-guard): no way to describe a promo, so "$5 first month (50% off)" passed against a 20% coupon.
|
|
58
|
+
// The API also takes intro_amount / intro_amount_off (minor units); kept out here for the tool list's size.
|
|
59
|
+
intro_price: z.number().positive().optional().describe('Promo first-period price, major units ($8 → 8).'),
|
|
60
|
+
intro_percent_off: z.number().gt(0).lt(100).optional().describe('Or the promo as % off (coupon percent_off).'),
|
|
61
|
+
intro_periods: z.number().int().min(1).max(36).optional().describe('Billing periods the promo lasts (default 1).'),
|
|
62
|
+
cancel_url: z.string().max(2048).optional().describe('https URL where subscribers cancel online (used in generated text).'),
|
|
45
63
|
cancel_timing: z.enum(['period_end', 'immediately']).optional().describe('When a cancellation takes effect (default period_end).'),
|
|
46
64
|
};
|
|
47
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Pilot round 13 (sveltekit, change-guard, api-only): nested objects were not strict, so plans[].price_cents, a bogus
|
|
67
|
+
* key or merchant.name were dropped with no word. Nested objects now refuse unknown keys with the closest known one.
|
|
68
|
+
*/
|
|
69
|
+
export function strictObject(shape, where, hints = {}) {
|
|
70
|
+
const known = Object.keys(shape);
|
|
71
|
+
return z
|
|
72
|
+
.object(shape, unknownKeysParams((keys) => nestedUnknownMessage(where, keys, known, hints)))
|
|
73
|
+
.strict();
|
|
74
|
+
}
|
|
75
|
+
const planTerms = strictObject(planTermsShape, 'terms');
|
|
76
|
+
const merchantTerms = strictObject(planTermsShape, 'merchant');
|
|
48
77
|
// Values above 30 are clamped to 30 instead of rejected (pilot: wait=60 was a raw -32602 validation error).
|
|
49
78
|
const wait = z
|
|
50
79
|
.number()
|
|
@@ -55,32 +84,51 @@ const wait = z
|
|
|
55
84
|
.describe('Seconds to wait for the result before answering (0-30; larger values wait 30; default 25). Use 0 to return at once and poll later.');
|
|
56
85
|
const listLimit = z.number().int().min(1).max(100).optional().describe('How many to return (1-100, default 10).');
|
|
57
86
|
const startingAfter = z.string().max(100).optional().describe('Pagination cursor: the last id of the previous page (when has_more was true).');
|
|
87
|
+
/** Plan fields agents reach for, mapped to the setup field (plans[] amounts are minor units). */
|
|
88
|
+
const PLAN_FIELD_HINTS = Object.freeze({
|
|
89
|
+
price: 'amount (minor units: 800 = $8.00)',
|
|
90
|
+
price_cents: 'amount (minor units: 800 = $8.00)',
|
|
91
|
+
amount_cents: 'amount',
|
|
92
|
+
unit_amount: 'amount',
|
|
93
|
+
trial_period_days: 'trial_days',
|
|
94
|
+
billing_interval: 'interval',
|
|
95
|
+
plan_name: 'name',
|
|
96
|
+
stripe_price: 'price_id',
|
|
97
|
+
intro_price: 'intro_amount (minor units)',
|
|
98
|
+
cancel_url: 'account_url (a site field, not a plan field)',
|
|
99
|
+
});
|
|
58
100
|
const setupFactsShape = {
|
|
59
101
|
site_url: z.string().max(2048).optional().describe('Public URL of the site (production, or a preview deployment), e.g. "https://acme.example".'),
|
|
60
102
|
checkout_url: z.string().max(2048).optional().describe('Page where customers subscribe (pricing or checkout page).'),
|
|
61
|
-
account_url: z.string().max(2048).optional().describe('Account page where subscribers manage / cancel.'),
|
|
62
|
-
terms_url: z.string().max(2048).optional().describe('Terms of service URL. Checkbox fetches and checks it.'),
|
|
63
|
-
privacy_url: z.string().max(2048).optional().describe('Privacy policy URL.'),
|
|
64
|
-
refund_url: z.string().max(2048).optional().describe('Refund policy URL.'),
|
|
65
|
-
cancellation_url: z.string().max(2048).optional().describe('Cancellation policy URL, if separate from the Terms.'),
|
|
103
|
+
account_url: z.string().max(2048).nullable().optional().describe('Account page where subscribers manage / cancel (null clears a URL).'),
|
|
104
|
+
terms_url: z.string().max(2048).nullable().optional().describe('Terms of service URL. Checkbox fetches and checks it.'),
|
|
105
|
+
privacy_url: z.string().max(2048).nullable().optional().describe('Privacy policy URL.'),
|
|
106
|
+
refund_url: z.string().max(2048).nullable().optional().describe('Refund policy URL.'),
|
|
107
|
+
cancellation_url: z.string().max(2048).nullable().optional().describe('Cancellation policy URL, if separate from the Terms.'),
|
|
66
108
|
support_email: z.string().max(320).optional().describe('Support address subscribers can write to.'),
|
|
67
|
-
merchant_name: z.string().max(100).optional().describe('The brand subscribers know (names the seller in generated disclosures), e.g. from the <title> or package.json. Default: the host name, which the plan then asks you to replace.'),
|
|
109
|
+
merchant_name: z.string().max(100).optional().describe('The brand subscribers know (names the seller in generated disclosures), e.g. from the <title> or package.json (also accepted as "brand"). Default: the host name, which the plan then asks you to replace.'),
|
|
68
110
|
billing_platform: z
|
|
69
111
|
.enum(['stripe', 'shopify', 'recharge', 'chargebee', 'recurly', 'paddle', 'woocommerce', 'custom'])
|
|
70
112
|
.optional()
|
|
71
113
|
.describe('Billing platform the code uses.'),
|
|
72
114
|
plans: z
|
|
73
|
-
.array(
|
|
115
|
+
.array(strictObject({
|
|
74
116
|
name: z.string().max(200).optional(),
|
|
75
117
|
amount: z.number().int().min(0).optional().describe('Minor units (1900 = $19).'),
|
|
76
118
|
currency: z.string().length(3).optional(),
|
|
77
119
|
interval: z.enum(['day', 'week', 'month', 'year']).optional(),
|
|
78
120
|
interval_count: z.number().int().min(1).max(36).optional(),
|
|
79
121
|
trial_days: z.number().int().min(0).max(730).optional(),
|
|
80
|
-
|
|
122
|
+
// Pilot round 13 (change-guard): intro / promo terms had nowhere to live, so the generated copy dropped them.
|
|
123
|
+
intro_amount: z.number().int().min(0).optional().describe('Promo first-period price, minor units.'),
|
|
124
|
+
intro_percent_off: z.number().gt(0).lt(100).optional(),
|
|
125
|
+
intro_amount_off: z.number().int().positive().optional(),
|
|
126
|
+
intro_periods: z.number().int().min(1).max(36).optional(),
|
|
127
|
+
price_id: z.string().max(200).optional().describe('Stripe Price id.'),
|
|
128
|
+
}, 'plans[]', PLAN_FIELD_HINTS))
|
|
81
129
|
.max(50)
|
|
82
130
|
.optional()
|
|
83
|
-
.describe('Plans and prices found in the code, e.g. [{"name":"Pro","amount":1900,"currency":"usd","interval":"month","trial_days":14}]. They decide which laws apply
|
|
131
|
+
.describe('Plans and prices found in the code, e.g. [{"name":"Pro","amount":1900,"currency":"usd","interval":"month","trial_days":14}]; a promo adds intro_percent_off and intro_periods. They decide which laws apply and what generated copy states.'),
|
|
84
132
|
project: z
|
|
85
133
|
.object({
|
|
86
134
|
name: z.string().max(200).optional().describe('package.json name.'),
|
|
@@ -88,13 +136,15 @@ const setupFactsShape = {
|
|
|
88
136
|
package_manager: z.string().max(20).optional(),
|
|
89
137
|
layout_file: z.string().max(500).optional().describe('Root layout / template where the snippet belongs, e.g. "app/layout.tsx".'),
|
|
90
138
|
public_dir: z.string().max(500).optional(),
|
|
139
|
+
// Round-3 pilot (nextjs): kind was optional here but required by the API (parameter_missing
|
|
140
|
+
// project.checkout_code.0.kind). The schema now matches the API.
|
|
91
141
|
checkout_code: z
|
|
92
|
-
.array(z.object({ file: z.string().max(500), line: z.number().int().min(1).optional(), kind: z.string().max(60).
|
|
142
|
+
.array(z.object({ file: z.string().max(500), line: z.number().int().min(1).optional(), kind: z.string().min(1).max(60).describe('Required: stripe_checkout_session, stripe_payment_link, stripe_pricing_table, stripe_elements, subscribe_button, shopify_checkout…') }))
|
|
93
143
|
.max(100)
|
|
94
144
|
.optional()
|
|
95
145
|
.describe('Where Stripe Checkout Sessions / payment links are created, e.g. [{"file":"app/api/checkout/route.ts","line":12,"kind":"stripe_checkout_session"}].'),
|
|
96
146
|
pay_buttons: z
|
|
97
|
-
.array(z.object({ file: z.string().max(500), line: z.number().int().min(1).optional(), kind: z.string().max(60).
|
|
147
|
+
.array(z.object({ file: z.string().max(500), line: z.number().int().min(1).optional(), kind: z.string().min(1).max(60).describe('Required, e.g. subscribe_button.') }))
|
|
98
148
|
.max(100)
|
|
99
149
|
.optional()
|
|
100
150
|
.describe('Where the subscribe / pay buttons are rendered.'),
|
|
@@ -130,9 +180,7 @@ async function resolveSite(api, site) {
|
|
|
130
180
|
operation_id: 'createSetupSession',
|
|
131
181
|
});
|
|
132
182
|
}
|
|
133
|
-
throw new ToolError(`The account has ${list.data.length}${list.has_more ? '+' : ''} sites; pass "site". Sites: ${list.data.map((s) => `${s.id} (${s.url})`).join(', ')}.`,
|
|
134
|
-
sites: list.data.map((s) => ({ id: s.id, url: s.url })),
|
|
135
|
-
});
|
|
183
|
+
throw new ToolError(`The account has ${list.data.length}${list.has_more ? '+' : ''} sites; pass "site". Sites: ${list.data.map((s) => `${s.id} (${s.name ? `${untrusted(s.name, 60)}, ` : ''}${untrusted(s.url, 100)})`).join(', ')}.`, { type: 'pass_site', message: `Call the same tool again with "site" set to one of the ids above${list.has_more ? ' (list_sites shows the rest)' : ''}.` }, { sites: list.data.map((s) => ({ id: s.id, name: s.name ?? null, url: s.url })) });
|
|
136
184
|
}
|
|
137
185
|
/** The setup session to act on: the given id or the newest. */
|
|
138
186
|
async function resolveSetupSession(api, id) {
|
|
@@ -145,40 +193,158 @@ async function resolveSetupSession(api, id) {
|
|
|
145
193
|
return list.data[0].id;
|
|
146
194
|
}
|
|
147
195
|
const statusLine = (v) => (typeof v === 'string' ? v.toUpperCase() : String(v));
|
|
148
|
-
/**
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
196
|
+
/**
|
|
197
|
+
* One bullet per check that needs something from the human, in the user-facing labels (status.ts): FIX NEEDED with the
|
|
198
|
+
* one change, YOUR CALL with the question and the recommended answer, LIVE CHECK PENDING with how it completes. The
|
|
199
|
+
* texts are Checkbox's own wording (never quoted from the checked page / email).
|
|
200
|
+
*
|
|
201
|
+
* Pilot round 12 (change-guard): the bullet said "NEEDS A FIX" / "LOOKS COMPLIANT" while the JSON said needs_review.
|
|
202
|
+
* Each bullet now carries the JSON status in brackets and its label never says more than the status does: a fix
|
|
203
|
+
* Checkbox could not confirm (needs_review) is YOUR CALL, and a note is CHECKS PASS (on a pass) or NOTE (needs_review).
|
|
204
|
+
* Round 16 (item 21, change-guard: the headline said "1 fix needed" over a "YOUR CALL" bullet): the bullet uses the
|
|
205
|
+
* roll-up's own word. A check the roll-up counts as a fix is FIX NEEDED; the bracket still says when it is needs_review.
|
|
206
|
+
*/
|
|
207
|
+
export function complianceBullet(checkId, c, fallbackStatus, title) {
|
|
208
|
+
const st = ['pass', 'fail', 'needs_review', 'not_applicable'].includes(fallbackStatus) ? ` [${fallbackStatus}]` : '';
|
|
209
|
+
if (!c)
|
|
210
|
+
return fallbackStatus === 'fail' || fallbackStatus === 'needs_review' ? `${fallbackStatus === 'fail' ? 'FIX NEEDED' : 'YOUR CALL'} ${checkId}${st}: ${title}` : null;
|
|
211
|
+
switch (c.status) {
|
|
212
|
+
case 'needs_fix':
|
|
213
|
+
return fallbackStatus === 'needs_review' ? `FIX NEEDED ${checkId}${st}: Checkbox could not confirm this is met; the fix: ${c.fix ?? title}` : `FIX NEEDED ${checkId}${st}: ${c.fix ?? title}`;
|
|
214
|
+
case 'your_call':
|
|
215
|
+
return `YOUR CALL ${checkId}${st}: ${c.question?.text ?? title}${c.question?.recommended ? ` Recommended: ${c.question.recommended}` : ''}`;
|
|
216
|
+
case 'pending_live_check':
|
|
217
|
+
return `LIVE CHECK PENDING ${checkId}${st}: ${c.pending_live_check ? `${c.pending_live_check.what} — ${c.pending_live_check.completes}` : title} (counts as passing the checks)`;
|
|
218
|
+
case 'compliant':
|
|
219
|
+
return c.note ? `${fallbackStatus === 'needs_review' ? 'NOTE' : 'CHECKS PASS'} ${checkId}${st}: ${c.note}` : null;
|
|
220
|
+
default:
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/** Bullets for the checks of a /v1/checks result that need something (the model reads these before the JSON). */
|
|
225
|
+
function checkNotes(r, max = 8, covered) {
|
|
226
|
+
const rank = { needs_fix: 0, your_call: 1, pending_live_check: 2, compliant: 3 };
|
|
227
|
+
// Agent audit round 2: six "NEEDS A FIX" lines with generic "<interval> at <price>" wording competed with the one
|
|
228
|
+
// engine-verified replacement that fixes all of them; when a verified fix covers a check, the line says so instead.
|
|
229
|
+
const coveredBy = (c) => !!covered && c.compliance?.status === 'needs_fix' && !(covered.except ?? []).includes(c.check_id);
|
|
230
|
+
const lines = r.checks
|
|
231
|
+
.map((c) => {
|
|
232
|
+
const line = coveredBy(c) ? `FIX NEEDED ${c.check_id} [${c.status}]: fixed by ${covered.by}` : complianceBullet(c.check_id, c.compliance, c.status, c.title);
|
|
152
233
|
const cite = c.requirements?.[0]?.citation;
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
234
|
+
return { c, line: line && cite && c.compliance_status !== 'compliant' ? `${line} [${cite}]` : line, r: rank[c.compliance_status ?? (c.status === 'fail' ? 'needs_fix' : 'your_call')] ?? 9 };
|
|
235
|
+
})
|
|
236
|
+
.filter((x) => !!x.line)
|
|
237
|
+
.sort((a, b) => a.r - b.r);
|
|
238
|
+
const notes = lines.slice(0, max).map((x) => x.line);
|
|
239
|
+
if (lines.length > max)
|
|
240
|
+
notes.push(`…and ${lines.length - max} more (see checks in the JSON).`);
|
|
158
241
|
for (const w of r.warnings ?? [])
|
|
159
242
|
notes.push(`Warning ${w.code}: ${w.message}`);
|
|
243
|
+
// A pass, or a needs_review whose roll-up is compliant (e.g. Stripe's own styling of custom_text, shown as a note).
|
|
244
|
+
const ci = ciHint(r.verdict === 'pass' || ['compliant', 'pending_live_check', 'verified_live'].includes(r.compliance?.status ?? ''));
|
|
245
|
+
if (ci)
|
|
246
|
+
notes.push(ci);
|
|
160
247
|
return notes;
|
|
161
248
|
}
|
|
162
|
-
|
|
249
|
+
/**
|
|
250
|
+
* Audit P1: a headline never says "Compliant": it says "Checks pass" (or "Not required for this plan" when no check
|
|
251
|
+
* applies), then the counts.
|
|
252
|
+
*/
|
|
253
|
+
export function plainLabel(label) {
|
|
254
|
+
return label
|
|
255
|
+
.replace(/^Looks compliant(?: with a note)?(?: — live check pending)?/i, 'Checks pass')
|
|
256
|
+
.replace(/\bCompliant\b/g, 'Checks pass')
|
|
257
|
+
.replace(/ is compliant\b/g, ': checks pass');
|
|
258
|
+
}
|
|
259
|
+
/** Audit P3: a check's evidence.summary / item details that only repeat its explanation are dropped. */
|
|
260
|
+
function withoutDuplicateEvidence(c) {
|
|
261
|
+
const explanation = typeof c.explanation === 'string' ? c.explanation : '';
|
|
262
|
+
const ev = c.evidence;
|
|
263
|
+
if (!ev || !explanation)
|
|
264
|
+
return c;
|
|
265
|
+
const { summary, items, ...rest } = ev;
|
|
266
|
+
const same = (t) => typeof t === 'string' && t.length > 0 && explanation.includes(t);
|
|
267
|
+
return {
|
|
268
|
+
...c,
|
|
269
|
+
evidence: {
|
|
270
|
+
...rest,
|
|
271
|
+
...(summary && !same(summary) ? { summary } : {}),
|
|
272
|
+
...(items ? { items: items.map(({ detail, ...i }) => (same(detail) ? i : { ...i, detail })) } : {}),
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
/** Audit P3: requirement ids as their citations (deduplicated), at most `max` plus "and N more". */
|
|
277
|
+
export function citationList(ids, requirements, max = 8) {
|
|
278
|
+
const byId = new Map((requirements ?? []).map((q) => [q.id, q.citation]));
|
|
279
|
+
const cites = [...new Set(ids.map((id) => byId.get(id) ?? id))];
|
|
280
|
+
return `${cites.slice(0, max).join('; ')}${cites.length > max ? `; and ${cites.length - max} more` : ''}`;
|
|
281
|
+
}
|
|
282
|
+
/** Audit P3: shortens at a word boundary, never mid-word. */
|
|
283
|
+
export function clipWords(v, n) {
|
|
284
|
+
if (typeof v !== 'string' || v.length <= n)
|
|
285
|
+
return v;
|
|
286
|
+
const cut = v.slice(0, n - 1);
|
|
287
|
+
const space = cut.lastIndexOf(' ');
|
|
288
|
+
return `${(space > n * 0.6 ? cut.slice(0, space) : cut).replace(/[\s,;:.(—-]+$/, '')}…`;
|
|
289
|
+
}
|
|
290
|
+
const verdictHeadline = (what, r) => {
|
|
291
|
+
if (r.verdict === 'not_applicable')
|
|
292
|
+
return `${what}: Not required for this plan.`;
|
|
293
|
+
const label = r.compliance ? plainLabel(r.compliance.label) : statusLine(r.verdict);
|
|
294
|
+
// One counting system per headline: the task-based status label only (the per-check counts stay in data.summary).
|
|
295
|
+
// Pilot round 12: "Checks pass" next to verdict needs_review read as a contradiction; the headline names the verdict
|
|
296
|
+
// and the checks that keep it open.
|
|
297
|
+
if (r.verdict !== 'pass' && /^Checks pass/.test(label)) {
|
|
298
|
+
const open = r.checks.filter((c) => c.status === r.verdict).map((c) => c.check_id);
|
|
299
|
+
return `${what}: ${label} (verdict ${r.verdict}${open.length ? `: ${open.slice(0, 4).join(', ')}` : ''}).`;
|
|
300
|
+
}
|
|
301
|
+
return `${what}: ${label}.`;
|
|
302
|
+
};
|
|
163
303
|
/**
|
|
164
304
|
* Keeps what the model needs to act and drops the bulk (everything stays available through the API): failing / unclear
|
|
165
305
|
* checks with their first citations, the ids of passing checks, and failing / unclear requirements only. A check run
|
|
166
306
|
* over "all" jurisdictions otherwise lists every state's requirement on every check (> 60 KB).
|
|
167
307
|
*/
|
|
308
|
+
const FAILING_REQUIREMENTS_SHOWN = 8;
|
|
309
|
+
/** Agent audit round 2: a passing result no longer echoes the 2 KB replacement / corrected params it did not need. */
|
|
310
|
+
function withoutUnneededFix(r) {
|
|
311
|
+
const counts = r.compliance?.counts;
|
|
312
|
+
if (!counts || (counts.needs_fix ?? 0) > 0 || r.verdict === 'fail')
|
|
313
|
+
return r;
|
|
314
|
+
const { replacement: _r, plan_replacements: _pr, ...rest } = r;
|
|
315
|
+
const correction = rest.correction;
|
|
316
|
+
if (correction && !correction.patch?.length) {
|
|
317
|
+
const { corrected_params: _c, ...c } = correction;
|
|
318
|
+
return { ...rest, correction: c };
|
|
319
|
+
}
|
|
320
|
+
return rest;
|
|
321
|
+
}
|
|
168
322
|
function slimCheckResult(r) {
|
|
169
323
|
const { checks, requirements, ...rest } = r;
|
|
170
324
|
const open = (s) => s === 'fail' || s === 'needs_review';
|
|
325
|
+
const needsSomething = (c) => open(c.status) || c.compliance_status === 'pending_live_check' || c.compliance?.note;
|
|
171
326
|
return {
|
|
172
327
|
...rest,
|
|
173
328
|
checks: (checks ?? [])
|
|
174
|
-
.filter((c) =>
|
|
329
|
+
.filter((c) => needsSomething(c))
|
|
175
330
|
.map(({ requirements: reqs, ...c }) => ({
|
|
176
|
-
...c,
|
|
331
|
+
...withoutDuplicateEvidence(c),
|
|
177
332
|
citations: [...new Set((reqs ?? []).map((q) => q.citation))].slice(0, 6),
|
|
178
333
|
jurisdictions: [...new Set((reqs ?? []).map((q) => q.jurisdiction))],
|
|
179
334
|
})),
|
|
180
335
|
passing_checks: (checks ?? []).filter((c) => c.status === 'pass').map((c) => c.check_id),
|
|
181
|
-
|
|
336
|
+
// Audit P1: failing_requirements lists only failing requirements; the ones left to review are counted apart.
|
|
337
|
+
// Agent audit round 2: a failing disclosure listed ~58 failing requirements (most of the payload); every check
|
|
338
|
+
// already carries its citations. The first ones are kept, with the total.
|
|
339
|
+
...(() => {
|
|
340
|
+
const failing = (requirements ?? []).filter((q) => q.status === 'fail').map((q) => ({ id: q.id, citation: q.citation, status: q.status }));
|
|
341
|
+
return { failing_requirements: failing.slice(0, FAILING_REQUIREMENTS_SHOWN), ...(failing.length > FAILING_REQUIREMENTS_SHOWN ? { failing_requirements_total: failing.length } : {}) };
|
|
342
|
+
})(),
|
|
343
|
+
review_requirements: (() => {
|
|
344
|
+
const review = (requirements ?? []).filter((q) => q.status === 'needs_review');
|
|
345
|
+
const citations = [...new Set(review.map((q) => q.citation))];
|
|
346
|
+
return { count: review.length, citations: citations.slice(0, 8), ...(citations.length > 8 ? { more: citations.length - 8 } : {}) };
|
|
347
|
+
})(),
|
|
182
348
|
};
|
|
183
349
|
}
|
|
184
350
|
async function optional(p) {
|
|
@@ -213,12 +379,42 @@ async function pollDeviceToken(api, deviceCode, maxWaitMs, intervalMs) {
|
|
|
213
379
|
}
|
|
214
380
|
}
|
|
215
381
|
/** What the first checks did: ran, or held because the site's domain is not verified yet (scan gate). */
|
|
216
|
-
function firstCheckNote(next) {
|
|
382
|
+
function firstCheckNote(next, verify) {
|
|
217
383
|
if (next?.type === 'verify_domain') {
|
|
218
|
-
|
|
384
|
+
const ro = verify?.unverified_scan?.queued
|
|
385
|
+
? ' First read-only scan queued (unverified: no checkout is submitted until you verify the domain); its results are labeled "unverified scan" and are not evidence.'
|
|
386
|
+
: '';
|
|
387
|
+
return `Site registered, but Checkbox has not scanned it fully: it only submits checkouts on websites their owners have verified. Until then it reads the site's own public homepage and Terms / privacy pages as an unverified preview (nothing stored as evidence; see the policies step).${ro} Follow Next to verify the domain (ask the human to publish the DNS TXT record or file, or add the meta tag; or install the snippet: Checkbox verifies the domain when it sees it live), then call get_setup_status with verify=true. Meanwhile check pages by sending their HTML (check_page_html / verify_task with html).`;
|
|
219
388
|
}
|
|
220
389
|
return 'First checks ran (site, Terms and policies fetched, first evaluation); follow Next.';
|
|
221
390
|
}
|
|
391
|
+
/** One line for a read-only (unverified) scan: always labeled, never evidence. */
|
|
392
|
+
export function unverifiedScanNote(u) {
|
|
393
|
+
if (!u || typeof u !== 'object')
|
|
394
|
+
return null;
|
|
395
|
+
const x = u;
|
|
396
|
+
const c = x.counts ?? {};
|
|
397
|
+
if (x.status === 'queued' || x.status === 'running')
|
|
398
|
+
return 'UNVERIFIED SCAN queued: a read-only look at the site\'s own public pages (no checkout is submitted until the domain is verified).';
|
|
399
|
+
if (x.status !== 'completed')
|
|
400
|
+
return `UNVERIFIED SCAN ${x.status ?? 'unknown'}${x.error ? `: ${untrusted(x.error, 160)}` : ''}.`;
|
|
401
|
+
const failing = (x.findings ?? []).filter((f) => f.status === 'fail').slice(0, 5).map((f) => f.check_id).filter(Boolean);
|
|
402
|
+
return `UNVERIFIED SCAN (read-only, not evidence, no checkout submitted): ${c.pages_captured ?? 0} page(s) read; ${c.failing ?? 0} failing, ${c.needs_review ?? 0} to review, ${c.passing ?? 0} passing${failing.length ? ` (failing: ${failing.join(', ')})` : ''}. Verify the domain for the full scan, which replaces it.`;
|
|
403
|
+
}
|
|
404
|
+
/** Round 20: one line with the plans Checkbox judges the site against (setup's site_plans), so no second call is needed. */
|
|
405
|
+
export function sitePlansNote(plans) {
|
|
406
|
+
if (!Array.isArray(plans))
|
|
407
|
+
return null;
|
|
408
|
+
if (!plans.length)
|
|
409
|
+
return 'Plans: none known yet. Pass plans[] (name, price, interval, trial, Stripe price id) with update_setup, or ask the human to connect Stripe.';
|
|
410
|
+
const lines = plans.slice(0, 6).map((p) => {
|
|
411
|
+
const every = (p.interval_count ?? 1) > 1 ? `every ${p.interval_count} ${p.interval}s` : `/${p.interval ?? '?'}`;
|
|
412
|
+
const trial = p.trial_days ? `, ${p.trial_days}-day trial` : ', no trial';
|
|
413
|
+
const ids = p.stripe_price_ids?.length ? `, ${p.stripe_price_ids.slice(0, 3).join(' ')}` : '';
|
|
414
|
+
return `${untrusted(p.name ?? 'plan', 80)} ${p.price ?? ''}${every}${trial}${ids}`;
|
|
415
|
+
});
|
|
416
|
+
return `Plans Checkbox judges against (${plans.length}): ${lines.join('; ')}${plans.length > 6 ? '; …' : ''}.`;
|
|
417
|
+
}
|
|
222
418
|
/**
|
|
223
419
|
* setup_checkbox on an OAuth connection (one-click connector): the human approved in the browser already and the
|
|
224
420
|
* connection itself is the credential, so nothing is pasted or saved. The site is registered by the setup session
|
|
@@ -233,12 +429,22 @@ async function setupOverOAuth(api, session, ctx) {
|
|
|
233
429
|
notes.push(`Human review page: ${session.review_url}`);
|
|
234
430
|
let next;
|
|
235
431
|
let firstCheck;
|
|
432
|
+
// Normal round 3 (nextjs): the response's steps said "checks: action_required, Not started" next to first_check's
|
|
433
|
+
// "checks: done". The session returned is the one after the first check, so both agree.
|
|
434
|
+
let fresh;
|
|
236
435
|
if (session.business?.site_url) {
|
|
237
436
|
try {
|
|
238
437
|
const v = await api.request('POST', `/v1/setup_sessions/${enc(session.id)}/verify`, { body: {} });
|
|
438
|
+
if (v.setup_session && typeof v.setup_session === 'object')
|
|
439
|
+
fresh = v.setup_session;
|
|
239
440
|
next = v.setup_session?.next_action;
|
|
240
|
-
firstCheck = { evaluated: v.evaluated ?? false, scan: v.scan ?? null, steps: v.setup_session?.steps?.map((st) => `${st.key}: ${st.status}`) ?? [] };
|
|
241
|
-
|
|
441
|
+
firstCheck = { evaluated: v.evaluated ?? false, scan: v.scan ?? null, unverified_scan: v.unverified_scan ?? null, steps: v.setup_session?.steps?.map((st) => `${st.key}: ${st.status}`) ?? [] };
|
|
442
|
+
if (v.domain_verified)
|
|
443
|
+
notes.push(`Domain verified: ${v.domain_verified.method === 'snippet' ? `the Checkbox snippet is live on ${v.domain_verified.domain}` : `${v.domain_verified.domain} (${v.domain_verified.method})`}.`);
|
|
444
|
+
notes.push(firstCheckNote(next, v));
|
|
445
|
+
const pl = sitePlansNote(v.setup_session?.site_plans);
|
|
446
|
+
if (pl)
|
|
447
|
+
notes.push(pl);
|
|
242
448
|
}
|
|
243
449
|
catch (err) {
|
|
244
450
|
notes.push(`First checks did not run yet (${err instanceof Error ? err.message.slice(0, 160) : 'error'}); call get_setup_status with verify=true.`);
|
|
@@ -251,26 +457,67 @@ async function setupOverOAuth(api, session, ctx) {
|
|
|
251
457
|
headline: `Setup session ${session.id} started${session.business?.site_url ? ` for ${untrusted(session.business.site_url, 100)}` : ''}${session.site ? ` (test-mode site ${session.site})` : ''}.`,
|
|
252
458
|
notes,
|
|
253
459
|
...(next ? { next } : {}),
|
|
254
|
-
data:
|
|
460
|
+
data: (() => {
|
|
461
|
+
const doc = { ...session, ...(fresh ?? {}) };
|
|
462
|
+
const keys = labelledPublicKeys(doc.install?.snippet_key, doc.publishable_key);
|
|
463
|
+
return withoutLegacyScore({ ...doc, ...(keys.length ? { public_keys: keys } : {}), ...(firstCheck ? { first_check: firstCheck } : {}) });
|
|
464
|
+
})(),
|
|
255
465
|
};
|
|
256
466
|
}
|
|
467
|
+
/**
|
|
468
|
+
* Pilot round 9 (nextjs): setup_checkbox still showed `insurable: false` and `score: 26.8` next to the status label.
|
|
469
|
+
* The API keeps both (deprecated) for backward compatibility; agents see only the one status.
|
|
470
|
+
*/
|
|
471
|
+
export function withoutLegacyScore(data) {
|
|
472
|
+
if (!data || typeof data !== 'object')
|
|
473
|
+
return data;
|
|
474
|
+
const d = data;
|
|
475
|
+
const strip = (c) => {
|
|
476
|
+
if (!c || typeof c !== 'object')
|
|
477
|
+
return c;
|
|
478
|
+
// Audit P2: the deprecated per-check counts (failing / needs_review) disagreed with the label; the label and todo stay.
|
|
479
|
+
const { score: _score, insurable: _insurable, failing: _failing, needs_review: _needsReview, ...rest } = c;
|
|
480
|
+
return rest;
|
|
481
|
+
};
|
|
482
|
+
const out = { ...d };
|
|
483
|
+
if (d.compliance)
|
|
484
|
+
out.compliance = strip(d.compliance);
|
|
485
|
+
// Audit P3: a step whose next_action is the session's own next_action does not repeat it.
|
|
486
|
+
if (Array.isArray(d.steps) && d.next_action && typeof d.next_action === 'object') {
|
|
487
|
+
const top = JSON.stringify(d.next_action);
|
|
488
|
+
out.steps = d.steps.map((st) => (st && typeof st === 'object' && JSON.stringify(st.next_action) === top ? { ...st, next_action: 'same as next_action' } : st));
|
|
489
|
+
}
|
|
490
|
+
if (d.session && typeof d.session === 'object')
|
|
491
|
+
out.session = withoutLegacyScore(d.session);
|
|
492
|
+
if (d.first_check && typeof d.first_check === 'object')
|
|
493
|
+
out.first_check = withoutLegacyScore(d.first_check);
|
|
494
|
+
return out;
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Facts given with the first setup_checkbox call, kept (in this MCP process) until the device_code poll applies them, so
|
|
498
|
+
* an agent that polls with only the device_code does not lose them. Bounded; entries expire with the device code.
|
|
499
|
+
*/
|
|
500
|
+
const PENDING_SETUP_FACTS = new Map();
|
|
501
|
+
function rememberSetupFacts(deviceCode, facts) {
|
|
502
|
+
if (!deviceCode || !Object.keys(facts).length)
|
|
503
|
+
return;
|
|
504
|
+
if (PENDING_SETUP_FACTS.size >= 50)
|
|
505
|
+
PENDING_SETUP_FACTS.delete(PENDING_SETUP_FACTS.keys().next().value);
|
|
506
|
+
PENDING_SETUP_FACTS.set(deviceCode, facts);
|
|
507
|
+
}
|
|
257
508
|
const setupCheckbox = {
|
|
258
509
|
name: 'setup_checkbox',
|
|
259
510
|
title: 'Set up Checkbox for this project',
|
|
260
511
|
description: [
|
|
261
|
-
'Start here when the user says "add Checkbox", "make my subscriptions compliant", or
|
|
262
|
-
'Checkbox monitors a subscription business against automatic-renewal laws (FTC ROSCA, California ARL, New York and
|
|
512
|
+
'Start here when the user says "add Checkbox", "make my subscriptions compliant", or the project is not connected to Checkbox yet.',
|
|
513
|
+
'Checkbox monitors a subscription business against automatic-renewal laws (federal law, 27 states, DC and New York City: FTC ROSCA, California ARL, New York and the other state laws).',
|
|
263
514
|
'',
|
|
264
|
-
'
|
|
265
|
-
'Show them the link in one line, then call setup_checkbox again with the returned device_code — it waits up to ~50 s for the approval; call again while it says pending.',
|
|
266
|
-
'On approval it returns test-mode API keys (shown once) and a setup session. Save CHECKBOX_SECRET_KEY to .env.local (git-ignored) and never commit it.',
|
|
267
|
-
'',
|
|
268
|
-
'With an API key: starts a setup session for the project (no human step) and returns the setup checklist with next_action.',
|
|
269
|
-
'Connected through the Checkbox connector (OAuth: claude.ai, Claude Desktop, Claude Code /mcp, Cursor): the human already approved; there is NO key to paste or save. Call it with site_url (and what else you know): it registers the site and runs the first checks in the same call.',
|
|
515
|
+
'Connected through the Checkbox connector (OAuth: claude.ai, Claude Desktop, Claude Code /mcp, Cursor, VS Code): the human already approved in the browser; there is NO key to ask for, paste or save. Call it with site_url (and what else you know): it registers the site and runs the first checks in the same call.',
|
|
270
516
|
'Pass everything you already know from the codebase: site_url, checkout_url, terms/privacy/refund/cancellation URLs, billing_platform, plans, project.layout_file. Checkbox fetches and checks the Terms itself.',
|
|
271
517
|
'',
|
|
272
|
-
'
|
|
273
|
-
'
|
|
518
|
+
'Only for clients without OAuth (no API key configured on this server): it returns a link for the HUMAN to open and click Approve (free, no card). Show them the link in one line, then call setup_checkbox again with the returned device_code — it waits up to ~50 s; call again while it says pending. On approval it returns test-mode API keys (shown once): save CHECKBOX_SECRET_KEY to .env.local (git-ignored), never commit it, and never ask the human to paste a key.',
|
|
519
|
+
'',
|
|
520
|
+
'After this: follow next_action (usually verify the domain and install the snippet, then get_setup_status with verify=true), then get_compliance_plan.',
|
|
274
521
|
].join('\n'),
|
|
275
522
|
input: {
|
|
276
523
|
...setupFactsShape,
|
|
@@ -305,30 +552,62 @@ const setupCheckbox = {
|
|
|
305
552
|
notes.push(`claude mcp add --transport http checkbox ${ctx.mcpUrl} --header "Authorization: Bearer $CHECKBOX_SECRET_KEY" (Cursor / VS Code: add the same header to the server config). Until then, the plan below is all you need; the HTTP API works with the key right away.`);
|
|
306
553
|
}
|
|
307
554
|
notes.push('The secret key is shown only this once. Never commit it; the publishable key (pk_) is safe in browser code (e.g. NEXT_PUBLIC_CHECKBOX_PUBLISHABLE_KEY).');
|
|
555
|
+
// Round 16 (pilot 15, sveltekit / change-guard): two public keys in one response, unexplained.
|
|
556
|
+
notes.push("Two public keys: the snippet tag's data-key is the site's snippet key (install.snippet_key; use the script_tag exactly as given); publishable_key is the account key for Checkbox Elements and consent receipts only.");
|
|
308
557
|
// In the same call, with the new key: apply the facts given (they are not lost between the two calls), run the
|
|
309
558
|
// first checks, and return the setup checklist's next step — exactly what `checkbox init` does.
|
|
310
|
-
|
|
559
|
+
// Normal-round pilot (sveltekit): the first checks failed with "No API key provided" and the facts given with the
|
|
560
|
+
// first call were dropped. Always call with the new key, and apply the facts of BOTH calls (this one wins).
|
|
561
|
+
const keyed = ctx.apiForKey?.(res.secret_key) ?? (ctx.adoptKeys ? ctx.api : undefined);
|
|
311
562
|
let next;
|
|
312
563
|
let firstCheck;
|
|
564
|
+
let site = res.site ?? null;
|
|
565
|
+
let inputWarnings = [];
|
|
566
|
+
let snippetKey;
|
|
313
567
|
if (keyed && res.setup_session) {
|
|
314
568
|
try {
|
|
315
|
-
const facts = pick(args, Object.keys(setupFactsShape));
|
|
316
|
-
|
|
317
|
-
|
|
569
|
+
const facts = { ...(PENDING_SETUP_FACTS.get(args.device_code) ?? {}), ...pick(args, Object.keys(setupFactsShape)) };
|
|
570
|
+
PENDING_SETUP_FACTS.delete(args.device_code);
|
|
571
|
+
// Pilot round 13 (change-guard): `agent` given with the device_code call (or known only now: a stateless
|
|
572
|
+
// remote connection has no client name on the first call) was never stored, so the session said agent null.
|
|
573
|
+
const agentName = args.agent ?? ctx.key.appName ?? ctx.clientName;
|
|
574
|
+
if (agentName)
|
|
575
|
+
facts.client = { name: 'checkbox-mcp', version: ctx.serverVersion, agent: String(agentName).slice(0, 100) };
|
|
576
|
+
if (Object.keys(facts).length) {
|
|
577
|
+
const upd = await keyed.request('POST', `/v1/setup_sessions/${res.setup_session}`, { body: facts });
|
|
578
|
+
if (Array.isArray(upd?.warnings))
|
|
579
|
+
inputWarnings = upd.warnings;
|
|
580
|
+
}
|
|
318
581
|
const v = await keyed.request('POST', `/v1/setup_sessions/${res.setup_session}/verify`, { body: {} });
|
|
319
582
|
next = v.setup_session?.next_action;
|
|
320
|
-
|
|
583
|
+
snippetKey = v.setup_session?.install?.snippet_key;
|
|
584
|
+
// Pilot round 13 (change-guard): the site created by these facts was missing from the result (site: null next
|
|
585
|
+
// to "site: done"), and the JSON next_action was the device token's, not the checklist's.
|
|
586
|
+
if (typeof v.setup_session?.site === 'string')
|
|
587
|
+
site = v.setup_session.site;
|
|
588
|
+
firstCheck = { evaluated: v.evaluated ?? false, scan: v.scan ?? null, steps: v.setup_session?.steps?.map((st) => `${st.key}: ${st.status}`) ?? [], ...(Array.isArray(v.setup_session?.site_plans) ? { site_plans: v.setup_session.site_plans } : {}) };
|
|
321
589
|
notes.push(firstCheckNote(next));
|
|
590
|
+
const pl = sitePlansNote(v.setup_session?.site_plans);
|
|
591
|
+
if (pl)
|
|
592
|
+
notes.push(pl);
|
|
322
593
|
}
|
|
323
594
|
catch (err) {
|
|
324
595
|
notes.push(`First checks did not run yet (${err instanceof Error ? err.message.slice(0, 160) : 'error'}); call get_setup_status with verify=true.`);
|
|
325
596
|
}
|
|
326
597
|
}
|
|
327
598
|
return {
|
|
328
|
-
headline: `Approved. Checkbox account ready${
|
|
599
|
+
headline: `Approved. Checkbox account ready${site ? ` with test-mode site ${site}` : ''}; setup session ${res.setup_session ?? '(none)'}.`,
|
|
329
600
|
notes,
|
|
330
601
|
...(next ? { next } : {}),
|
|
331
|
-
data: {
|
|
602
|
+
data: {
|
|
603
|
+
...res,
|
|
604
|
+
site,
|
|
605
|
+
...(next ? { next_action: next } : {}),
|
|
606
|
+
env: { CHECKBOX_SECRET_KEY: res.secret_key, ...(res.publishable_key ? { CHECKBOX_PUBLISHABLE_KEY: res.publishable_key } : {}) },
|
|
607
|
+
public_keys: labelledPublicKeys(snippetKey ?? res.install?.snippet_key, res.publishable_key),
|
|
608
|
+
...(firstCheck ? { first_check: firstCheck } : {}),
|
|
609
|
+
...(inputWarnings.length ? { warnings: inputWarnings } : {}),
|
|
610
|
+
},
|
|
332
611
|
};
|
|
333
612
|
}
|
|
334
613
|
if (!api.hasKey) {
|
|
@@ -338,6 +617,8 @@ const setupCheckbox = {
|
|
|
338
617
|
};
|
|
339
618
|
if (typeof args.billing_platform === 'string')
|
|
340
619
|
body.billing_platform = args.billing_platform;
|
|
620
|
+
if (typeof args.merchant_name === 'string' && args.merchant_name.trim())
|
|
621
|
+
body.merchant_name = args.merchant_name.trim();
|
|
341
622
|
const project = args.project;
|
|
342
623
|
if (project?.name)
|
|
343
624
|
body.project_name = project.name;
|
|
@@ -346,6 +627,7 @@ const setupCheckbox = {
|
|
|
346
627
|
if (project?.package_manager)
|
|
347
628
|
body.package_manager = project.package_manager;
|
|
348
629
|
const auth = await api.request('POST', '/v1/device/authorize', { anonymous: true, body });
|
|
630
|
+
rememberSetupFacts(auth.device_code, pick(args, Object.keys(setupFactsShape)));
|
|
349
631
|
return {
|
|
350
632
|
headline: `Ask the human to open ${auth.verification_uri_complete} and click Approve (code ${auth.user_code}). It expires in ${Math.round(auth.expires_in / 60)} minutes.`,
|
|
351
633
|
notes: [
|
|
@@ -364,13 +646,31 @@ const setupCheckbox = {
|
|
|
364
646
|
const notes = session.review_url ? [`Human review page: ${session.review_url}`] : [];
|
|
365
647
|
if (session.next_action?.type === 'verify_domain')
|
|
366
648
|
notes.push(firstCheckNote(session.next_action));
|
|
649
|
+
const plansLine = sitePlansNote(session.site_plans);
|
|
650
|
+
if (plansLine)
|
|
651
|
+
notes.push(plansLine);
|
|
367
652
|
return {
|
|
368
653
|
headline: `Setup session ${session.id} started${session.business?.site_url ? ` for ${untrusted(session.business.site_url, 100)}` : ''}${session.site ? ` (test-mode site ${session.site})` : ''}.`,
|
|
369
654
|
notes,
|
|
370
|
-
data:
|
|
655
|
+
data: (() => {
|
|
656
|
+
const keys = labelledPublicKeys(session.install?.snippet_key, session.publishable_key);
|
|
657
|
+
return keys.length ? { ...withoutLegacyScore(session), public_keys: keys } : withoutLegacyScore(session);
|
|
658
|
+
})(),
|
|
371
659
|
};
|
|
372
660
|
},
|
|
373
661
|
};
|
|
662
|
+
/**
|
|
663
|
+
* Round 20 (pilot 19 item 16, sveltekit / change-guard: a pk_ site key and a pk_test_ key side by side, unlabelled): each
|
|
664
|
+
* public key says what it is for.
|
|
665
|
+
*/
|
|
666
|
+
export function labelledPublicKeys(snippetKey, publishableKey) {
|
|
667
|
+
const out = [];
|
|
668
|
+
if (typeof snippetKey === 'string' && snippetKey)
|
|
669
|
+
out.push({ key: snippetKey, name: 'Site snippet key', use: 'The data-key of the Checkbox snippet <script> tag, for this site only (use install.script_tag exactly as given).' });
|
|
670
|
+
if (typeof publishableKey === 'string' && publishableKey)
|
|
671
|
+
out.push({ key: publishableKey, name: 'Account publishable key', use: 'For Checkbox Elements (<checkbox-disclosure>) and consent receipts (data-publishable-key) only; never the snippet data-key.' });
|
|
672
|
+
return out;
|
|
673
|
+
}
|
|
374
674
|
const getSetupStatus = {
|
|
375
675
|
name: 'get_setup_status',
|
|
376
676
|
title: 'Get the setup checklist (optionally re-check the site)',
|
|
@@ -391,14 +691,30 @@ const getSetupStatus = {
|
|
|
391
691
|
if (!keyAllows(key, 'setup_sessions:write'))
|
|
392
692
|
throw new ToolError('verify=true needs a key with setup_sessions write access; call again without verify.');
|
|
393
693
|
const res = await api.request('POST', `/v1/setup_sessions/${enc(id)}/verify`, { body: {} });
|
|
394
|
-
|
|
694
|
+
const notes = [];
|
|
695
|
+
if (res.domain_verified)
|
|
696
|
+
notes.push(`Domain verified: ${res.domain_verified.method === 'snippet' ? `the Checkbox snippet is live on ${res.domain_verified.domain}` : `${res.domain_verified.domain} (${res.domain_verified.method})`}.`);
|
|
697
|
+
if (res.unverified_scan?.queued)
|
|
698
|
+
notes.push('First read-only scan queued (unverified: no checkout is submitted until you verify the domain).');
|
|
699
|
+
else if (res.unverified_scan?.reason)
|
|
700
|
+
notes.push(`No new read-only scan: ${untrusted(res.unverified_scan.reason, 200)}.`);
|
|
701
|
+
const ro = unverifiedScanNote(res.setup_session?.unverified_scan);
|
|
702
|
+
if (ro && !res.unverified_scan?.queued)
|
|
703
|
+
notes.push(ro);
|
|
704
|
+
const pl = sitePlansNote(res.setup_session?.site_plans);
|
|
705
|
+
if (pl)
|
|
706
|
+
notes.push(pl);
|
|
707
|
+
return { headline: `Re-checked setup session ${id}.`, ...(notes.length ? { notes } : {}), data: withoutLegacyScore(res), next: nextActionOf(res) ?? nextActionOf(res.session) };
|
|
395
708
|
}
|
|
396
709
|
const session = await api.request('GET', `/v1/setup_sessions/${enc(id)}`);
|
|
397
710
|
const open = (session.steps ?? []).filter((s) => s.status && !['done', 'skipped', 'not_applicable'].includes(s.status));
|
|
711
|
+
const ro = unverifiedScanNote(session.unverified_scan);
|
|
712
|
+
const plansLine = sitePlansNote(session.site_plans);
|
|
398
713
|
return {
|
|
399
714
|
headline: `Setup session ${session.id}: ${session.status}; ${open.length} checklist step(s) open.`,
|
|
400
|
-
|
|
401
|
-
|
|
715
|
+
// The policies step says what an unverified preview read (or that the pages wait for domain verification).
|
|
716
|
+
notes: [...open.slice(0, 8).map((s) => `${s.status}: ${s.title ?? s.key}${s.key === 'policies' && s.summary ? ` (${s.summary.slice(0, 240)})` : ''}`), ...(ro ? [ro] : []), ...(plansLine ? [plansLine] : [])],
|
|
717
|
+
data: withoutLegacyScore(session),
|
|
402
718
|
};
|
|
403
719
|
},
|
|
404
720
|
};
|
|
@@ -424,9 +740,24 @@ const updateSetup = {
|
|
|
424
740
|
const id = await resolveSetupSession(api, args.setup_session);
|
|
425
741
|
const body = pick(args, [...Object.keys(setupFactsShape), 'snippet_installed_in', 'env_written', 'live_site', 'refetch_policies']);
|
|
426
742
|
const session = await api.request('POST', `/v1/setup_sessions/${enc(id)}`, { body });
|
|
427
|
-
|
|
743
|
+
// Round 20 (pilot 19 item 6): a plans change re-evaluates the site; say where the new plan's tasks show up.
|
|
744
|
+
const notes = Array.isArray(args.plans) && args.plans.length
|
|
745
|
+
? ['Plans saved: Checkbox re-evaluates the site with them now, so get_compliance_plan lists each plan\'s tasks (e.g. the trial reminder of a plan with a trial). If one seems missing, call verify_site to re-check at once.']
|
|
746
|
+
: [];
|
|
747
|
+
return { headline: `Updated setup session ${session.id}.`, notes, data: withoutLegacyScore(session) };
|
|
428
748
|
},
|
|
429
749
|
};
|
|
750
|
+
/** Round 13 (pilot 12, codex: "sent" while nothing new arrived): the summary email's real delivery status, with when. */
|
|
751
|
+
export function summaryEmailLine(status, d) {
|
|
752
|
+
const when = d?.at ? ` at ${new Date(d.at * 1000).toISOString().slice(0, 16).replace('T', ' ')} UTC` : '';
|
|
753
|
+
if (status === 'sent')
|
|
754
|
+
return `sent${d?.to ? ` to ${d.to}` : ''} (the mail server accepted it${when}; it is sent once per setup)`;
|
|
755
|
+
if (status === 'failed')
|
|
756
|
+
return `failed${d?.error ? ` (${d.error})` : ''}; call complete_setup again to retry`;
|
|
757
|
+
if (status === 'not_configured')
|
|
758
|
+
return 'not sent: this deployment has no email delivery';
|
|
759
|
+
return 'not sent';
|
|
760
|
+
}
|
|
430
761
|
const completeSetup = {
|
|
431
762
|
name: 'complete_setup',
|
|
432
763
|
title: 'Finish setup and email the human a summary',
|
|
@@ -438,20 +769,77 @@ const completeSetup = {
|
|
|
438
769
|
const id = await resolveSetupSession(api, args.setup_session);
|
|
439
770
|
const session = await api.request('POST', `/v1/setup_sessions/${enc(id)}/complete`, { body: {} });
|
|
440
771
|
return {
|
|
441
|
-
headline: `Setup ${session.id} completed; summary email: ${session.owner_email
|
|
772
|
+
headline: `Setup ${session.id} completed; summary email: ${summaryEmailLine(session.owner_email, session.owner_email_detail)}.`,
|
|
442
773
|
notes: session.review_url ? [`Human review page: ${session.review_url}`] : [],
|
|
443
|
-
data: session,
|
|
774
|
+
data: withoutLegacyScore(session),
|
|
444
775
|
};
|
|
445
776
|
},
|
|
446
777
|
};
|
|
778
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
779
|
+
// Compliance plan loop
|
|
780
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
781
|
+
/**
|
|
782
|
+
* Round 21 (pilot 20 item 8, lawyer): the plan status in words ("Checks pass"), never the bare machine value `compliant`
|
|
783
|
+
* an agent would repeat to a merchant. Older APIs without status_label: the same mapping here.
|
|
784
|
+
*/
|
|
785
|
+
const PLAN_STATUS_WORDS = { action_required: 'Fixes needed', verifying: 'Checking your changes', compliant: 'Checks pass', blocked: 'Your call' };
|
|
786
|
+
export function planStatusWords(status, label) {
|
|
787
|
+
return label || PLAN_STATUS_WORDS[status] || status;
|
|
788
|
+
}
|
|
789
|
+
/** "Old task (covered by: Open task)": the open task sharing a check with a superseded one. Pure, exported for tests. */
|
|
790
|
+
export function coveredLine(t, open) {
|
|
791
|
+
const ids = t.check_ids ?? [];
|
|
792
|
+
const by = open.find((o) => o.key !== t.key && (o.check_ids ?? []).some((c) => ids.includes(c)));
|
|
793
|
+
return by ? `${t.title} (covered by: ${by.title})` : `${t.title} (covered by another open task)`;
|
|
794
|
+
}
|
|
795
|
+
/** The plan with each task reduced to what an agent needs to choose the next one (the full task: get_task). */
|
|
796
|
+
export function slimPlan(plan) {
|
|
797
|
+
const clip = clipWords;
|
|
798
|
+
const tasks = (plan.tasks ?? []).map((t) => {
|
|
799
|
+
const full = t;
|
|
800
|
+
const change = (full.change ?? {});
|
|
801
|
+
return {
|
|
802
|
+
id: t.id,
|
|
803
|
+
key: full.key,
|
|
804
|
+
title: t.title,
|
|
805
|
+
status: t.status,
|
|
806
|
+
priority: t.priority,
|
|
807
|
+
order: t.order,
|
|
808
|
+
tier: t.tier,
|
|
809
|
+
compliance_status: full.compliance_status,
|
|
810
|
+
why: clip(full.why, 400),
|
|
811
|
+
where: t.where,
|
|
812
|
+
change: { type: change.type, operation_id: change.operation_id ?? null, endpoint: change.endpoint ?? null },
|
|
813
|
+
blocked_on: full.blocked_on ?? null,
|
|
814
|
+
blocked_by: full.blocked_by ?? [],
|
|
815
|
+
question: full.question ?? null,
|
|
816
|
+
next_action: full.next_action ?? null,
|
|
817
|
+
};
|
|
818
|
+
});
|
|
819
|
+
return { ...plan, tasks: tasks };
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* One-time review hint: when a site's plan is compliant, the API hands out the prompt once per site
|
|
823
|
+
* (claimReviewPrompt). Never fails the tool: older servers or a missing scope simply mean no hint.
|
|
824
|
+
*/
|
|
825
|
+
async function reviewHint(api, site, trigger) {
|
|
826
|
+
try {
|
|
827
|
+
const p = await api.request('POST', `/v1/sites/${enc(site)}/review_prompt`, { body: { trigger } });
|
|
828
|
+
return p.show && p.message ? [`One-time: ${p.message}`] : [];
|
|
829
|
+
}
|
|
830
|
+
catch {
|
|
831
|
+
return [];
|
|
832
|
+
}
|
|
833
|
+
}
|
|
447
834
|
const getCompliancePlan = {
|
|
448
835
|
name: 'get_compliance_plan',
|
|
449
836
|
title: 'Get the compliance plan (ordered tasks)',
|
|
450
837
|
description: [
|
|
451
|
-
'The main tool after setup. Returns the ordered tasks that make the site
|
|
838
|
+
'The main tool after setup. Returns the ordered tasks that make the site pass the automatic-renewal checks, recomputed from the latest observations.',
|
|
452
839
|
'Each task has: requirement + legal citation, why (what Checkbox observed), where (file / component / URL / CSS selector / Stripe object), change (exact unified-diff template, copy text or Stripe params), and verify (how to check).',
|
|
453
|
-
'Plan status: action_required (apply open tasks), verifying (waiting for confirming observations), compliant (done), blocked (only human questions left).',
|
|
454
|
-
'
|
|
840
|
+
'Plan status: action_required (apply open tasks), verifying (waiting for confirming observations), compliant (done: the checks pass — "pending_live_check" tasks count as passing: they complete on their own or with a proof run), blocked (only human questions — "Your call" — left).',
|
|
841
|
+
'Tasks are tagged tier required / recommended / extra. Only required tasks matter for compliance; recommended and extra tasks are optional suggestions (mention them once, do not push them).',
|
|
842
|
+
'Loop: apply the first open required task in the code → verify_task → get_compliance_plan → … until the checks pass (status compliant). Tasks with status "manual" carry a question with a recommended answer: ask the HUMAN, then answer_task. Never invent answers.',
|
|
455
843
|
].join('\n'),
|
|
456
844
|
input: { site: siteId.optional() },
|
|
457
845
|
scopes: ['compliance_plans:read'],
|
|
@@ -460,10 +848,41 @@ const getCompliancePlan = {
|
|
|
460
848
|
const site = await resolveSite(api, args.site);
|
|
461
849
|
const plan = await api.request('GET', `/v1/sites/${enc(site)}/plan`);
|
|
462
850
|
const open = (plan.tasks ?? []).filter((t) => !['done', 'wont_fix'].includes(t.status));
|
|
851
|
+
const required = open.filter((t) => (t.tier ?? 'required') === 'required');
|
|
852
|
+
const optional = open.filter((t) => (t.tier ?? 'required') !== 'required');
|
|
853
|
+
// One to-do list: the same ordered open tasks (titles, order, counts) as the dashboard, report, emails and CLI.
|
|
854
|
+
const todo = planTodo((plan.tasks ?? []));
|
|
855
|
+
const rest = required.filter((t) => !todo.some((x) => x.id === t.id));
|
|
856
|
+
const { score: _score, insurable: _insurable, ...shown } = plan;
|
|
463
857
|
return {
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
858
|
+
// Audit P2: one Next line (the plan's next_action below); the summary's own "Next: …" is not repeated.
|
|
859
|
+
headline: `Plan for ${site}: ${plainLabel(plan.compliance?.label ?? 'Not checked yet')} (plan status: ${planStatusWords(plan.status, plan.status_label)}).`,
|
|
860
|
+
notes: [
|
|
861
|
+
...(todo.length ? [`To do (${todo.length}, in order):`] : []),
|
|
862
|
+
...todo.map((t, i) => `${i + 1}. ${t.id} ${todoLine(t)}`),
|
|
863
|
+
...rest.map((t) => `${t.id} [${t.status}${t.priority ? `, ${t.priority}` : ''}${t.change?.type ? `, ${t.change.type}` : ''}] ${t.title}`),
|
|
864
|
+
// Round 14: a task closed because it no longer applies is "Not applicable", never "done". Round 16 (pilot 15,
|
|
865
|
+
// codex): a task closed by a passing verification is "Done (verified)", never "Not applicable".
|
|
866
|
+
...(() => {
|
|
867
|
+
const closed = (plan.tasks ?? []);
|
|
868
|
+
const verified = closed.filter((t) => t.status === 'done' && t.closed_reason === 'verified' && t.compliance_status !== 'not_applicable');
|
|
869
|
+
// Round 20 (pilot 19 item 9): a task merged into another open task is "covered by" it, never "not applicable".
|
|
870
|
+
const covered = closed.filter((t) => t.closed_reason === 'superseded');
|
|
871
|
+
const na = closed.filter((t) => t.compliance_status === 'not_applicable' && t.closed_reason !== 'verified' && t.closed_reason !== 'superseded');
|
|
872
|
+
return [
|
|
873
|
+
...(verified.length ? [`Done (verified): ${verified.map((t) => t.title).join('; ')}`] : []),
|
|
874
|
+
...(covered.length ? [`Merged into another task: ${covered.map((t) => coveredLine(t, open)).join('; ')}`] : []),
|
|
875
|
+
...(na.length ? [`Not applicable (closed without a change, nothing observed passing): ${na.map((t) => t.title).join('; ')}`] : []),
|
|
876
|
+
];
|
|
877
|
+
})(),
|
|
878
|
+
...(optional.length ? [`Optional suggestions (${plan.protection_level ?? 'balanced'} protection; never block the checks from passing): ${optional.map((t) => t.title).join('; ')}`] : []),
|
|
879
|
+
...(plan.optional_improvements ? ['Optional (never blocking, not part of compliance): conversion suggestions for this site with `suggest_improvements`.'] : []),
|
|
880
|
+
'Each task below is a summary; call get_task with its id for the full change (diff, copy or params) before applying it.',
|
|
881
|
+
...(plan.status === 'compliant' ? await reviewHint(api, site, 'plan_compliant') : []),
|
|
882
|
+
],
|
|
883
|
+
// Normal-round pilot (sveltekit): the full plan (53K characters) was cut mid-JSON by the client. The plan here is a
|
|
884
|
+
// compact summary of every task; get_task returns one task in full.
|
|
885
|
+
data: slimPlan(shown),
|
|
467
886
|
};
|
|
468
887
|
},
|
|
469
888
|
};
|
|
@@ -476,9 +895,23 @@ const getTask = {
|
|
|
476
895
|
annotations: { ...ro, title: 'Task' },
|
|
477
896
|
async run(args, { api }) {
|
|
478
897
|
const task = await api.request('GET', `/v1/tasks/${enc(String(args.task))}`);
|
|
479
|
-
|
|
898
|
+
// Audit P3: a task citing every state's law listed dozens of citations; the first 8 and a count are enough here.
|
|
899
|
+
const cites = task.requirement?.citations;
|
|
900
|
+
const shown = Array.isArray(cites) && cites.length > 8 ? { ...task, requirement: { ...task.requirement, citations: cites.slice(0, 8), citations_more: cites.length - 8 } } : task;
|
|
901
|
+
const word = task.compliance_status === 'not_applicable' ? 'not applicable' : task.status;
|
|
902
|
+
return { headline: `Task ${task.id} [${word}]: ${task.title}`, data: shown };
|
|
480
903
|
},
|
|
481
904
|
};
|
|
905
|
+
/** Audit: the pre-check's per-check results, one compact line each (agents only saw the overall verdict). */
|
|
906
|
+
export function precheckLines(pre, max = 12) {
|
|
907
|
+
const checks = pre?.checks ?? [];
|
|
908
|
+
if (!checks.length)
|
|
909
|
+
return [];
|
|
910
|
+
const order = { fail: 0, needs_review: 1, missing_data: 1, pass: 2, not_applicable: 3 };
|
|
911
|
+
const sorted = [...checks].sort((a, b) => (order[a.status] ?? 1) - (order[b.status] ?? 1));
|
|
912
|
+
const lines = sorted.slice(0, max).map((c) => `${c.check_id}: ${c.status}${c.reason ? ` — ${untrusted(c.reason, 180)}` : ''}${c.fix ? ` Fix: ${untrusted(c.fix, 200)}` : ''}`);
|
|
913
|
+
return [`Pre-check (${pre?.verdict ?? 'n/a'}), per check:`, ...lines, ...(checks.length > max ? [`and ${checks.length - max} more (data.precheck.checks)`] : [])];
|
|
914
|
+
}
|
|
482
915
|
const verifyTask = {
|
|
483
916
|
name: 'verify_task',
|
|
484
917
|
title: 'Verify a task after applying its change',
|
|
@@ -494,29 +927,56 @@ const verifyTask = {
|
|
|
494
927
|
task: z.string().max(100).describe('Task id (task_…).'),
|
|
495
928
|
html: z.string().max(950_000).optional().describe('Page tasks: the rendered page HTML.'),
|
|
496
929
|
stripe_checkout_session: z.record(z.string(), z.unknown()).optional().describe('Stripe tasks: the params your code passes to stripe.checkout.sessions.create.'),
|
|
930
|
+
stripe_checkout_sessions: z.array(z.record(z.string(), z.unknown())).max(10).optional().describe('Stripe tasks, one params object per plan.'),
|
|
497
931
|
email: z
|
|
498
932
|
.object({
|
|
499
|
-
kind:
|
|
933
|
+
kind: emailKind.optional(),
|
|
500
934
|
subject: z.string().max(1000),
|
|
501
935
|
text: z.string().max(100_000).optional(),
|
|
502
936
|
html: z.string().max(100_000).optional(),
|
|
503
937
|
})
|
|
504
938
|
.optional()
|
|
505
|
-
.describe('Email tasks: the new template.'),
|
|
939
|
+
.describe('Email tasks: the new template (passes when it matches one plan fully).'),
|
|
940
|
+
plan: z.string().min(1).max(200).optional().describe('Email tasks: only this plan (name).'),
|
|
941
|
+
price_id: z.string().min(1).max(200).optional().describe('Email tasks: only this plan (Price id).'),
|
|
506
942
|
recheck: z.boolean().optional().describe('true re-checks a task that is already done (evaluates again now and queues a scan) instead of returning its last result.'),
|
|
507
943
|
wait,
|
|
508
944
|
},
|
|
509
945
|
scopes: ['compliance_plans:write'],
|
|
510
946
|
annotations: { ...write(true, true), title: 'Verify task' },
|
|
511
947
|
async run(args, { api }) {
|
|
512
|
-
const body = pick(args, ['html', 'stripe_checkout_session', 'email', 'recheck']);
|
|
948
|
+
const body = pick(args, ['html', 'stripe_checkout_session', 'stripe_checkout_sessions', 'email', 'plan', 'price_id', 'recheck']);
|
|
949
|
+
if (body.email && typeof body.email === 'object' && body.email.kind)
|
|
950
|
+
body.email = { ...body.email, kind: checkKind(body.email.kind) };
|
|
513
951
|
const v = await api.request('POST', `/v1/tasks/${enc(String(args.task))}/verify`, {
|
|
514
952
|
query: { wait: args.wait ?? 25 },
|
|
515
953
|
body,
|
|
516
954
|
});
|
|
955
|
+
const hint = v.plan_status === 'compliant' && v.site ? await reviewHint(api, v.site, 'last_task_verified') : [];
|
|
517
956
|
return {
|
|
518
|
-
headline: `Verification ${v.id} of ${String(args.task)}: ${statusLine(v.status)}${v.plan_status ? `; plan is now ${v.plan_status}` : ''}
|
|
519
|
-
notes:
|
|
957
|
+
headline: `Verification ${v.id} of ${String(args.task)}: ${statusLine(v.status)}${v.plan_status ? `; plan is now ${planStatusWords(v.plan_status, v.plan_status_label)}` : ''}.${v.checked ? ` Checked: ${untrusted(v.checked, 300)}.` : ''}`,
|
|
958
|
+
notes: [
|
|
959
|
+
...precheckLines(v.precheck),
|
|
960
|
+
...((v.status === 'failed' || v.status === 'not_checked') && v.next_action?.type === 'verify_domain'
|
|
961
|
+
? ['The domain is not verified yet. Waiting for the human to publish the DNS record / file / meta tag or install the snippet; don\'t retry until they say it\'s done. Nothing to change in the code.']
|
|
962
|
+
: v.next_action?.type === 'retry_later'
|
|
963
|
+
? ['Checkbox could not confirm it (a problem on Checkbox\'s side): nothing to change in the code. Continue with the next task; verify again later.']
|
|
964
|
+
: v.next_action?.type === 'live_check_pending' && v.status === 'pending'
|
|
965
|
+
? // Round 14 (pilot 13): Checkbox Notices sends this email; never "add the BCC".
|
|
966
|
+
[v.next_action.description ?? 'Checkbox Notices sends this email: nothing to add to your code. Continue with the next task.']
|
|
967
|
+
: v.next_action?.type === 'bcc_then_continue'
|
|
968
|
+
? ['The copy is checked; the task closes when the first real email arrives with the BCC. Add the BCC to your sending code and continue with the next task (no need to poll).']
|
|
969
|
+
: v.status === 'failed'
|
|
970
|
+
? ['The change did not satisfy the check: read diff (expected vs observed), fix the code, and call verify_task again.']
|
|
971
|
+
: v.status === 'not_checked'
|
|
972
|
+
? ['Not checked yet: Checkbox is missing evidence (see next_action), not a code problem. Do not change the code for this; provide what next_action asks for.']
|
|
973
|
+
: v.status === 'pending_live_check'
|
|
974
|
+
? ['Live check pending: the email content passes; Checkbox has not seen it sent for a real event yet. Nothing to change in the code: it completes on the first real send (or a proof run).']
|
|
975
|
+
: v.status === 'pending'
|
|
976
|
+
? ['Waiting for a confirming observation; call get_verification later (or continue with other tasks).']
|
|
977
|
+
: []),
|
|
978
|
+
...hint,
|
|
979
|
+
],
|
|
520
980
|
data: v,
|
|
521
981
|
};
|
|
522
982
|
},
|
|
@@ -524,7 +984,7 @@ const verifyTask = {
|
|
|
524
984
|
const answerTask = {
|
|
525
985
|
name: 'answer_task',
|
|
526
986
|
title: 'Record the human\'s answer to a manual task',
|
|
527
|
-
description: 'For tasks with status "manual": record the HUMAN\'s answer to task.question. Ask the human first and pass their words; never answer on their behalf (answers are legal attestations stored in the evidence vault). answer is one of task.question.options
|
|
987
|
+
description: 'For tasks with status "manual": record the HUMAN\'s answer to task.question. Ask the human first and pass their words; never answer on their behalf (answers are legal attestations stored in the evidence vault). answer is one of task.question.options; yes / no also work (= confirmed / needs_change). "no" turns the task into a change you can apply.',
|
|
528
988
|
input: {
|
|
529
989
|
task: z.string().max(100).describe('Task id (task_…) with status manual.'),
|
|
530
990
|
answer: z.string().max(40).describe('One of the task.question.options, as the human chose.'),
|
|
@@ -534,25 +994,31 @@ const answerTask = {
|
|
|
534
994
|
annotations: { ...write(false), title: 'Answer task' },
|
|
535
995
|
async run(args, { api }) {
|
|
536
996
|
const task = await api.request('POST', `/v1/tasks/${enc(String(args.task))}/answer`, { body: pick(args, ['answer', 'note']) });
|
|
997
|
+
// Round 21 (pilot 20 item 16): "done" on a task Checkbox already closed succeeds without recording anything.
|
|
998
|
+
if (task.status === 'done' && task.next_action?.type === 'retrieve_plan')
|
|
999
|
+
return { headline: `${task.id} was already done (Checkbox closed it itself): nothing to answer, nothing recorded.`, data: task };
|
|
537
1000
|
return { headline: `Recorded the answer on ${task.id}; task is now ${task.status}.`, data: task };
|
|
538
1001
|
},
|
|
539
1002
|
};
|
|
540
1003
|
const verifySite = {
|
|
541
1004
|
name: 'verify_site',
|
|
542
1005
|
title: 'Re-check the whole site now',
|
|
543
|
-
description: 'Re-fetches the registered policies, pre-checks the checkout, re-evaluates every law and queues a scan. Use it for the first check after setup, after deploying several changes, or when the plan looks stale. Waits up to `wait` seconds, then call get_compliance_plan.',
|
|
1006
|
+
description: 'Re-reads the connected billing platform (Stripe portal, prices, Checkout Sessions; rate-limited), re-fetches the registered policies, pre-checks the checkout, re-evaluates every law and queues a scan. Use it for the first check after setup, after deploying several changes, after changing a Stripe setting, or when the plan looks stale. Waits up to `wait` seconds, then call get_compliance_plan.',
|
|
544
1007
|
input: { site: siteId.optional(), wait },
|
|
545
1008
|
scopes: ['compliance_plans:write'],
|
|
546
1009
|
annotations: { ...write(true, true), title: 'Verify site' },
|
|
547
1010
|
async run(args, { api }) {
|
|
548
1011
|
const site = await resolveSite(api, args.site);
|
|
549
1012
|
const v = await api.request('POST', `/v1/sites/${enc(site)}/verify`, { query: { wait: args.wait ?? 25 }, body: {} });
|
|
1013
|
+
// Pilot 11: say what was re-read from the billing platform (the note's first sentence), so a stale answer is visible.
|
|
1014
|
+
const reread = /^(?:Re-read |Could not re-read |[A-Z][\w ]{0,40} was read ).*?(?:before judging\.|seconds\)\.|ago\.|earlier\.)/.exec(v.precheck?.note ?? '')?.[0];
|
|
550
1015
|
// A whole-site check that finds open checks produced the to-do list; it is not a failed change.
|
|
551
|
-
const
|
|
1016
|
+
const base = v.next_action?.type === 'verify_domain'
|
|
552
1017
|
? `Site check ${v.id}: nothing was fetched or scanned because the site's domain is not verified yet (Checkbox only visits websites their owners have verified). Follow Next to verify it.`
|
|
553
1018
|
: v.status === 'failed'
|
|
554
|
-
? `${v.next_action?.type === 'first_check_complete' ? 'First check complete' : 'Site check complete'} (${v.id}):
|
|
1019
|
+
? `${v.next_action?.type === 'first_check_complete' ? 'First check complete' : 'Site check complete'} (${v.id}): the open tasks of get_compliance_plan are the to-do list, not a failed change.${v.plan_status ? ` Plan: ${v.plan_status}.` : ''}`
|
|
555
1020
|
: `Site check ${v.id}: ${statusLine(v.status)}${v.plan_status ? `; plan is ${v.plan_status}` : ''}.`;
|
|
1021
|
+
const headline = reread ? `${base} ${reread}` : base;
|
|
556
1022
|
return { headline, data: v };
|
|
557
1023
|
},
|
|
558
1024
|
};
|
|
@@ -560,27 +1026,41 @@ const getVerification = {
|
|
|
560
1026
|
name: 'get_verification',
|
|
561
1027
|
title: 'Get a verification result',
|
|
562
1028
|
description: 'Status of a verification from verify_task / verify_site: pending, passed or failed, expected vs observed per check, the diff and next_action. Pass wait (≤ 30 s) to wait for a pending one.',
|
|
563
|
-
input: { verification: z.string().max(100).describe('
|
|
1029
|
+
input: { verification: z.string().max(100).describe('ver_…, or task_… (its newest)'), wait: wait },
|
|
564
1030
|
scopes: ['compliance_plans:read'],
|
|
565
1031
|
annotations: { ...ro, title: 'Verification' },
|
|
566
1032
|
async run(args, { api }) {
|
|
567
1033
|
const v = await api.request('GET', `/v1/verifications/${enc(String(args.verification))}`, { query: { wait: args.wait ?? 0 } });
|
|
568
|
-
|
|
1034
|
+
const notes = precheckLines(v.precheck);
|
|
1035
|
+
return { headline: `Verification ${v.id}: ${statusLine(v.status)}.`, ...(notes.length ? { notes } : {}), data: v };
|
|
569
1036
|
},
|
|
570
1037
|
};
|
|
571
1038
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
572
1039
|
// Synthetic test subscription
|
|
573
1040
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
1041
|
+
/** createTestRun body from the tool's arguments (top-level shortcuts merged into `stripe`). Exported for tests. */
|
|
1042
|
+
export function startTestRunBody(args) {
|
|
1043
|
+
const body = pick(args, ['mode', 'type', 'advance_time', 'cancel', 'email_wait_seconds', 'region', 'stripe', 'generic']);
|
|
1044
|
+
const shortcuts = pick(args, ['checkout_url', 'test_account', 'checkout_steps', 'cancel_url', 'cancel_steps']);
|
|
1045
|
+
if (Object.keys(shortcuts).length && body.type !== 'generic') {
|
|
1046
|
+
body.stripe = { ...shortcuts, ...(body.stripe ?? {}) };
|
|
1047
|
+
if (!body.mode && shortcuts.checkout_url)
|
|
1048
|
+
body.mode = 'real_checkout';
|
|
1049
|
+
}
|
|
1050
|
+
return body;
|
|
1051
|
+
}
|
|
574
1052
|
const startTestRun = {
|
|
575
1053
|
name: 'start_test_run',
|
|
576
1054
|
title: 'Run a synthetic test subscription (test mode)',
|
|
577
1055
|
description: [
|
|
578
1056
|
'Observes the WHOLE subscription lifecycle, not just the checkout: in the billing platform\'s TEST mode Checkbox subscribes a test customer (card 4242…) whose email is a Checkbox inbox, waits for your acknowledgment email, fast-forwards a Stripe test clock through every legal notice window before trial end and renewal (collecting reminder emails), then cancels online and waits for the cancellation confirmation. Nothing is charged.',
|
|
579
|
-
'Needs a Stripe TEST key stored for the site
|
|
580
|
-
'
|
|
1057
|
+
'Needs a Stripe TEST key stored for the site — the human adds it in the Checkbox dashboard (the site\'s Proof page); never ask them to paste it into the chat. If missing, the error\'s next_action says so. For non-Stripe sites use type "generic".',
|
|
1058
|
+
'Usually 2-4 minutes: poll get_test_run (its next_action says when). Each step reports observations, check results and agent-readable fixes; apply them and run again.',
|
|
1059
|
+
'mode "real_checkout" is the PROOF run: the shopper buys through the merchant\'s own checkout on its test deployment (stripe.checkout_url or stripe.payment_link), so the merchant\'s webhook handlers and email code fire; give stripe.test_account + stripe.cancel_url when customers cancel in the app. A step is "passed" only with evidence; "warning" means needs review.',
|
|
581
1060
|
].join('\n'),
|
|
582
1061
|
input: {
|
|
583
1062
|
site: siteId.optional(),
|
|
1063
|
+
mode: z.enum(['mirror', 'real_checkout']).optional().describe('Stripe runs: mirror (default; test clock through every notice window) or real_checkout (the proof run through the merchant\'s own checkout, webhooks and emails).'),
|
|
584
1064
|
type: z.enum(['stripe', 'generic']).optional().describe('stripe (default when a Stripe test key is stored) or generic (test coupon / test account, no time travel).'),
|
|
585
1065
|
advance_time: z.boolean().optional().describe('Advance a Stripe test clock through trial end and first renewal (default true).'),
|
|
586
1066
|
cancel: z.boolean().optional().describe('Cancel online at the end (default true).'),
|
|
@@ -588,22 +1068,38 @@ const startTestRun = {
|
|
|
588
1068
|
region: z.string().max(10).optional().describe('State the synthetic shopper visits from, e.g. "US-CA".'),
|
|
589
1069
|
stripe: z
|
|
590
1070
|
.object({
|
|
591
|
-
checkout_config: z.string().max(100).optional().describe('Registered checkout config (scc_…) to mirror; default the newest, else the latest live Checkout Session.'),
|
|
1071
|
+
checkout_config: z.string().max(100).optional().describe('Registered checkout config (scc_…) to mirror; default the newest, else the latest live Checkout Session; "none" skips registered configs.'),
|
|
592
1072
|
price: z.string().max(200).optional().describe('A TEST-mode price id (price_…) to subscribe to when nothing is registered.'),
|
|
593
1073
|
trial_period_days: z.number().int().min(0).max(730).optional(),
|
|
594
1074
|
price_map: z.record(z.string(), z.string()).optional().describe('Live price id → test-mode price id.'),
|
|
595
1075
|
cancel_url: z.string().max(2048).optional().describe('Cancel on your own page instead of the Stripe customer portal.'),
|
|
1076
|
+
checkout_url: z.string().max(2000).optional().describe('real_checkout: your checkout page on your TEST deployment.'),
|
|
1077
|
+
payment_link: z.string().max(300).optional().describe('A Payment Link (plink_… or buy.stripe.com URL) to mirror or buy through.'),
|
|
1078
|
+
pre_checkout_steps: z.array(z.record(z.string(), z.unknown())).max(30).optional().describe('real_checkout: clicks to reach the payment page, e.g. [{"action":"click","text":"Subscribe"}].'),
|
|
1079
|
+
checkout_steps: z.array(z.record(z.string(), z.unknown())).max(30).optional().describe('real_checkout with an EMBEDDED checkout (Stripe Elements / Payment Element on your page): the steps that pay, e.g. [{"action":"fill","selector":"#email","value":"{{email}}"},{"action":"fill","selector":"#card","value":"4242424242424242"},{"action":"click","text":"I agree"},{"action":"click","text":"Pay"}]. {{email}} is the shopper\'s address (the run inbox).'),
|
|
1080
|
+
test_account: z.object({ login_url: z.string().max(2000), email: z.string().max(320), password: z.string().max(500).optional() }).optional().describe('Test account on your deployment (password stored encrypted). email "{{inbox}}" signs up with the run\'s own test inbox.'),
|
|
1081
|
+
cleanup_url: z.string().max(2000).optional().describe('Page that ends the test account\'s subscription before the checkout, so every run starts without one.'),
|
|
1082
|
+
cancel_steps: z.array(z.record(z.string(), z.unknown())).max(30).optional().describe('Clicks that cancel in your flow.'),
|
|
1083
|
+
cancel_via: z.enum(['merchant_flow', 'stripe_portal']).optional().describe('How your customers cancel.'),
|
|
1084
|
+
reset_url: z.string().max(2000).optional().describe('Page that resets the test account before the checkout.'),
|
|
596
1085
|
})
|
|
597
1086
|
.optional()
|
|
598
1087
|
.describe('Stripe options.'),
|
|
599
1088
|
generic: z.record(z.string(), z.unknown()).optional().describe('Generic (non-Stripe) options as in the API: checkout_url, checkout_steps, complete_purchase { steps, promo_code }, account { login_url, email, password }, cancel_steps.'),
|
|
1089
|
+
// Round-4 pilot (sveltekit, Cursor): clients that do not show nested schemas could not start the proof run. The most
|
|
1090
|
+
// used real_checkout fields are also accepted at the top level and merged into `stripe`.
|
|
1091
|
+
checkout_url: z.string().max(2000).optional().describe('Shortcut for stripe.checkout_url (real_checkout: your checkout page on your TEST deployment).'),
|
|
1092
|
+
test_account: z.object({ login_url: z.string().max(2000), email: z.string().max(320), password: z.string().max(500).optional() }).optional().describe('Shortcut for stripe.test_account. email "{{inbox}}" uses the run inbox.'),
|
|
1093
|
+
checkout_steps: z.array(z.record(z.string(), z.unknown())).max(30).optional().describe('Shortcut for stripe.checkout_steps (embedded checkout: fill email/card, tick consent, click pay).'),
|
|
1094
|
+
cancel_url: z.string().max(2048).optional().describe('Shortcut for stripe.cancel_url.'),
|
|
1095
|
+
cancel_steps: z.array(z.record(z.string(), z.unknown())).max(30).optional().describe('Shortcut for stripe.cancel_steps.'),
|
|
600
1096
|
},
|
|
601
1097
|
scopes: ['test_runs:write'],
|
|
602
1098
|
annotations: { ...write(false, true), title: 'Start test run' },
|
|
603
1099
|
async run(args, { api }) {
|
|
604
1100
|
const site = await resolveSite(api, args.site);
|
|
605
1101
|
const run = await api.request('POST', `/v1/sites/${enc(site)}/test_runs`, {
|
|
606
|
-
body:
|
|
1102
|
+
body: startTestRunBody(args),
|
|
607
1103
|
});
|
|
608
1104
|
return {
|
|
609
1105
|
headline: `Test run ${run.id} (${run.type}) ${run.status} for ${site}.${run.inbox_address ? ` Test subscriber inbox: ${run.inbox_address}.` : ''}`,
|
|
@@ -614,8 +1110,12 @@ const startTestRun = {
|
|
|
614
1110
|
const getTestRun = {
|
|
615
1111
|
name: 'get_test_run',
|
|
616
1112
|
title: 'Get a test run (steps, observations, fixes)',
|
|
617
|
-
description: 'A synthetic test subscription with every step (checkout, acknowledgment email, reminders at each legal window, cancellation, confirmation): what was observed, check results, evidence ids and fixes. While running, next_action.type is "wait" (
|
|
618
|
-
input: {
|
|
1113
|
+
description: 'A synthetic test subscription with every step (checkout, acknowledgment email, reminders at each legal window, cancellation, confirmation): what was observed, check results, evidence ids and fixes. While running, next_action.type is "wait" and waiting_for says what it waits for (pass wait: 30 to get the answer as soon as the run moves on). When finished, apply the fixes (usually via get_compliance_plan) and start another run to verify. Without test_run, returns the newest run of the site.',
|
|
1114
|
+
input: {
|
|
1115
|
+
test_run: z.string().max(100).optional().describe('Test run id (run_…). Default: the newest run of the site.'),
|
|
1116
|
+
site: siteId.optional(),
|
|
1117
|
+
wait: z.number().int().min(0).max(30).optional().describe('Long-poll: wait up to this many seconds for the run to move on before answering.'),
|
|
1118
|
+
},
|
|
619
1119
|
scopes: ['test_runs:read'],
|
|
620
1120
|
annotations: { ...ro, title: 'Test run' },
|
|
621
1121
|
async run(args, { api }) {
|
|
@@ -627,10 +1127,14 @@ const getTestRun = {
|
|
|
627
1127
|
throw new ToolError(`Site ${site} has no test run yet.`, { type: 'start_test_run', message: 'Call start_test_run.', operation_id: 'createTestRun' });
|
|
628
1128
|
id = list.data[0].id;
|
|
629
1129
|
}
|
|
630
|
-
const
|
|
1130
|
+
const wait = typeof args.wait === 'number' && args.wait > 0 ? Math.min(30, Math.floor(args.wait)) : undefined;
|
|
1131
|
+
const run = await api.request('GET', `/v1/test_runs/${enc(id)}`, wait ? { query: { wait } } : undefined);
|
|
1132
|
+
const w = run.waiting_for;
|
|
1133
|
+
const progress = w ? [w.step && w.steps_total ? `step ${w.step} of ${w.steps_total}` : '', w.minutes_left ? `about ${w.minutes_left} min left` : ''].filter(Boolean).join(', ') : '';
|
|
1134
|
+
const waiting = w ? `Waiting for: ${w.what}${w.until ? ` (until ${new Date(w.until * 1000).toISOString().slice(0, 16).replace('T', ' ')} UTC at the latest)` : ''}${progress ? ` [${progress}]` : ''}` : null;
|
|
631
1135
|
return {
|
|
632
|
-
headline: `Test run ${run.id}: ${statusLine(run.status)}${run.phase ? ` (phase ${run.phase})` : ''}.`,
|
|
633
|
-
notes: (run.steps ?? []).map((s) => `${s.status ?? '?'}: ${s.title ?? s.key}${s.code ? ` (${s.code})` : ''}`).slice(0,
|
|
1136
|
+
headline: `Test run ${run.id}: ${run.status_label ?? statusLine(run.status)}${run.phase && run.status !== 'succeeded' && run.status !== 'failed' ? ` (phase ${run.phase})` : ''}.`,
|
|
1137
|
+
notes: [...(waiting ? [waiting] : []), ...(run.steps ?? []).map((s) => `${s.status ?? '?'}: ${s.title ?? s.key}${s.code ? ` (${s.code})` : ''}`)].slice(0, 13),
|
|
634
1138
|
data: run,
|
|
635
1139
|
};
|
|
636
1140
|
},
|
|
@@ -642,9 +1146,9 @@ const checkDisclosure = {
|
|
|
642
1146
|
name: 'check_disclosure',
|
|
643
1147
|
title: 'Check checkout disclosure text',
|
|
644
1148
|
description: [
|
|
645
|
-
'Before shipping a checkout: checks the automatic-renewal disclosure shown next to the purchase button against every applicable law (ROSCA, CA, NY, …) and returns per-check verdicts with citations plus a REPLACEMENT verified by the rules engine (disclosure_text, consent_label, button_text
|
|
1149
|
+
'Before shipping a checkout: checks the automatic-renewal disclosure shown next to the purchase button against every applicable law (ROSCA, CA, NY, …) and returns per-check verdicts with citations plus a REPLACEMENT verified by the rules engine (disclosure_text, consent_label, button_text, drop-in html and jsx).',
|
|
646
1150
|
'Pass the text exactly as rendered plus the plan terms. Example: {"text":"Renews monthly at $19.99 until you cancel.","price":19.99,"interval":"month","trial_days":14,"cancel_url":"https://acme.example/account/cancel","merchant_name":"Acme","consent":{"checkbox":true,"prechecked":false}}.',
|
|
647
|
-
'If verdict is fail,
|
|
1151
|
+
'If verdict is fail, put replacement.jsx (React) or .html above the button, then call again with the new text, consent and placement.bold: true (it is semibold). needs_review on styling means a rendered scan must confirm.',
|
|
648
1152
|
].join('\n'),
|
|
649
1153
|
input: {
|
|
650
1154
|
text: z.string().min(1).max(5000).describe('The disclosure shown next to the purchase button, exactly as rendered.'),
|
|
@@ -652,8 +1156,7 @@ const checkDisclosure = {
|
|
|
652
1156
|
interval: intervalStr,
|
|
653
1157
|
button_text: z.string().max(200).optional().describe('Purchase button label, e.g. "Start free trial".'),
|
|
654
1158
|
order_summary: z.string().max(1000).optional().describe('Other text next to the button (order summary), if any.'),
|
|
655
|
-
placement:
|
|
656
|
-
.object({
|
|
1159
|
+
placement: strictObject({
|
|
657
1160
|
above_button: z.boolean().optional(),
|
|
658
1161
|
same_page_as_payment: z.boolean().optional(),
|
|
659
1162
|
font_size_px: z.number().min(4).max(96).optional(),
|
|
@@ -661,16 +1164,15 @@ const checkDisclosure = {
|
|
|
661
1164
|
bold: z.boolean().optional(),
|
|
662
1165
|
boxed: z.boolean().optional(),
|
|
663
1166
|
body_font_size_px: z.number().min(4).max(96).optional(),
|
|
664
|
-
})
|
|
1167
|
+
}, 'placement')
|
|
665
1168
|
.optional()
|
|
666
1169
|
.describe('Where and how the disclosure is shown (defaults: above the button, same page, 14px, high contrast).'),
|
|
667
|
-
consent:
|
|
668
|
-
.object({
|
|
1170
|
+
consent: strictObject({
|
|
669
1171
|
checkbox: z.boolean(),
|
|
670
1172
|
prechecked: z.boolean().optional(),
|
|
671
1173
|
label: z.string().max(1000).optional(),
|
|
672
1174
|
required: z.boolean().optional(),
|
|
673
|
-
})
|
|
1175
|
+
}, 'consent')
|
|
674
1176
|
.optional()
|
|
675
1177
|
.describe('The consent checkbox, if the page has one. Omit when there is none.'),
|
|
676
1178
|
jurisdictions: jurisdictions.optional(),
|
|
@@ -680,7 +1182,8 @@ const checkDisclosure = {
|
|
|
680
1182
|
annotations: { ...ro, title: 'Check disclosure' },
|
|
681
1183
|
async run(args, { api }) {
|
|
682
1184
|
const r = await api.request('POST', '/v1/checks/disclosure', { body: args });
|
|
683
|
-
|
|
1185
|
+
const covered = r.replacement?.verified ? { by: 'replacement (disclosure_text above the button + consent_label, or paste replacement.jsx in React / replacement.html elsewhere)', except: r.replacement.remaining_checks ?? [] } : undefined;
|
|
1186
|
+
return { headline: verdictHeadline('Disclosure', r), notes: checkNotes(r, 8, covered), data: withoutUnneededFix(slimCheckResult(r)) };
|
|
684
1187
|
},
|
|
685
1188
|
};
|
|
686
1189
|
const checkStripeCheckoutSession = {
|
|
@@ -688,12 +1191,14 @@ const checkStripeCheckoutSession = {
|
|
|
688
1191
|
title: 'Check Stripe Checkout Session params (returns corrected params)',
|
|
689
1192
|
description: [
|
|
690
1193
|
'Before shipping Stripe Checkout code: pass EXACTLY the object your code passes to stripe.checkout.sessions.create (mode "subscription"). Checkbox evaluates it against automatic-renewal laws and returns correction.corrected_params — the full params with the fix applied, ready to paste — plus an RFC 6902 JSON patch and before/after verification per check.',
|
|
691
|
-
'
|
|
692
|
-
'Note:
|
|
1194
|
+
'With a Price id (price_…) in line_items, pass site (its plans are used) or merchant { price, interval, trial_days, merchant_name, cancel_url }.',
|
|
1195
|
+
'Note: Stripe styles custom_text itself, so "clear and conspicuous" stays needs_review here; the plan shows it as a note, never a fix.',
|
|
693
1196
|
].join('\n'),
|
|
694
1197
|
input: {
|
|
695
1198
|
params: z.record(z.string(), z.unknown()).describe('The Checkout Session params object, e.g. {"mode":"subscription","line_items":[{"price":"price_123","quantity":1}],"success_url":"https://acme.example/welcome"}.'),
|
|
696
|
-
merchant:
|
|
1199
|
+
merchant: merchantTerms.optional().describe('What the params do not say: merchant_name, cancel_url, and price/interval when line_items use a Price id.'),
|
|
1200
|
+
site: z.string().max(100).optional().describe('site_… whose plans judge it (as verify_task)'),
|
|
1201
|
+
plan: z.string().max(200).optional().describe('With site: plan name or price_…'),
|
|
697
1202
|
jurisdictions: jurisdictions.optional(),
|
|
698
1203
|
as_of: asOf.optional(),
|
|
699
1204
|
},
|
|
@@ -701,12 +1206,14 @@ const checkStripeCheckoutSession = {
|
|
|
701
1206
|
annotations: { ...ro, title: 'Check Stripe Checkout Session' },
|
|
702
1207
|
async run(args, { api }) {
|
|
703
1208
|
const r = await api.request('POST', '/v1/checks/stripe_checkout_session', { body: args });
|
|
704
|
-
const
|
|
705
|
-
|
|
1209
|
+
const fixing = !!r.correction?.patch?.length;
|
|
1210
|
+
const notes = checkNotes(r, 8, fixing && r.correction.verification?.passes ? { by: 'correction.corrected_params' } : undefined);
|
|
1211
|
+
if (fixing)
|
|
706
1212
|
notes.unshift(`Replace the params in your code with correction.corrected_params (${r.correction.patch.length} change(s); verified passes: ${r.correction.verification?.passes ?? 'n/a'}).`);
|
|
707
|
-
|
|
1213
|
+
// Agent audit round 2: on a pass the same Stripe-styling note was printed four times; it is printed once.
|
|
1214
|
+
if (fixing && r.correction?.verification?.note)
|
|
708
1215
|
notes.push(r.correction.verification.note);
|
|
709
|
-
const { correction, ...rest } = slimCheckResult(r);
|
|
1216
|
+
const { correction, ...rest } = withoutUnneededFix(slimCheckResult(r));
|
|
710
1217
|
return { headline: verdictHeadline('Stripe Checkout Session', r), notes, data: { correction, ...rest } };
|
|
711
1218
|
},
|
|
712
1219
|
};
|
|
@@ -714,32 +1221,35 @@ const checkEmail = {
|
|
|
714
1221
|
name: 'check_email',
|
|
715
1222
|
title: 'Check a subscription lifecycle email',
|
|
716
1223
|
description: [
|
|
717
|
-
'Checks an acknowledgment, trial reminder, renewal reminder, price-change, annual-terms or cancellation-confirmation email against the laws that require it, and returns a
|
|
1224
|
+
'Checks an acknowledgment, trial reminder, renewal reminder, price-change, annual-terms or cancellation-confirmation email against the laws that require it, and returns a template that passes the checks (subject, text, html with merge tags), verified by the rules engine, plus required_by (which laws make this email mandatory for this plan).',
|
|
718
1225
|
'Example: {"kind":"trial_reminder","subject":"Your trial ends soon","text":"…","terms":{"price":19.99,"interval":"month","trial_days":14,"cancel_url":"https://acme.example/account/cancel","merchant_name":"Acme"}}.',
|
|
719
1226
|
'For WHEN each email must be sent, use get_notice_schedule.',
|
|
720
1227
|
].join('\n'),
|
|
721
1228
|
input: {
|
|
722
|
-
kind:
|
|
1229
|
+
kind: emailKind.describe('Which email (trial_reminder = trial_ending).'),
|
|
723
1230
|
subject: z.string().min(1).max(1000).describe('Subject line.'),
|
|
724
1231
|
text: z.string().max(100_000).optional().describe('Plain-text body (give text, html or both).'),
|
|
725
1232
|
html: z.string().max(100_000).optional().describe('HTML body.'),
|
|
726
|
-
terms:
|
|
727
|
-
|
|
1233
|
+
terms: strictObject({
|
|
1234
|
+
...planTermsShape,
|
|
728
1235
|
previous_price: z.union([z.number().positive(), z.string().max(40)]).optional().describe('price_change: price before the change, major units.'),
|
|
729
1236
|
previous_amount: z.number().int().positive().optional(),
|
|
730
1237
|
support_contact: z.string().max(200).optional().describe('Support email / URL for the template.'),
|
|
731
|
-
})
|
|
732
|
-
.
|
|
1238
|
+
}, 'terms')
|
|
1239
|
+
.optional()
|
|
1240
|
+
.describe('price, interval, cancel_url (optional with site); price_change: NEW price'),
|
|
1241
|
+
site: z.string().max(100).optional().describe('site_… whose Stripe prices judge it (default: your Stripe site).'),
|
|
1242
|
+
plan: z.string().max(200).optional().describe('Plan name or price_… (default: the one named)'),
|
|
733
1243
|
jurisdictions: jurisdictions.optional(),
|
|
734
1244
|
as_of: asOf.optional(),
|
|
735
1245
|
},
|
|
736
1246
|
scopes: ['checks:read'],
|
|
737
1247
|
annotations: { ...ro, title: 'Check email' },
|
|
738
1248
|
async run(args, { api }) {
|
|
739
|
-
const r = await api.request('POST', '/v1/checks/email', { body: args });
|
|
740
|
-
const notes = checkNotes(r);
|
|
741
|
-
notes.unshift(r.required_by?.length ? `Legally required for this plan by: ${r.required_by
|
|
742
|
-
return { headline: verdictHeadline(`Email (${String(args.kind)})`, r), notes, data: slimCheckResult(r) };
|
|
1249
|
+
const r = await api.request('POST', '/v1/checks/email', { body: { ...args, kind: checkKind(args.kind) } });
|
|
1250
|
+
const notes = checkNotes(r, 8, r.template?.verified ? { by: 'template (subject + text or html; add your greeting and sign-off around it if you like)', except: r.template.remaining_checks ?? [] } : undefined);
|
|
1251
|
+
notes.unshift(r.required_by?.length ? `Legally required for this plan by: ${citationList(r.required_by, r.requirements)}.` : 'Not legally required for this plan (still good practice).');
|
|
1252
|
+
return { headline: verdictHeadline(`Email (${String(args.kind)}${r.plan ? `, plan ${untrusted(r.plan, 60)}` : ''})`, r), notes, data: slimCheckResult(r) };
|
|
743
1253
|
},
|
|
744
1254
|
};
|
|
745
1255
|
const checkPageHtml = {
|
|
@@ -754,7 +1264,10 @@ const checkPageHtml = {
|
|
|
754
1264
|
html: z.string().max(1_000_000).optional().describe('Rendered checkout page HTML. Give html or url.'),
|
|
755
1265
|
url: z.string().max(2048).optional().describe('Live public page URL to fetch instead (live, activated accounts).'),
|
|
756
1266
|
page_kind: z.enum(['checkout', 'pricing']).optional().describe('Default checkout.'),
|
|
757
|
-
terms: planTerms.optional().describe('The plan sold on the page
|
|
1267
|
+
terms: planTerms.optional().describe('The plan sold on the page. merchant_name / cancel_url alone are fine (no price compared).'),
|
|
1268
|
+
plans: z.array(strictObject(planTermsShape, 'plans[]')).max(20).optional().describe('Multi-plan pricing page: EVERY plan it sells (price, interval, trial_days each). Each plan card is judged against its own plan; plan_replacements gives each plan its own markup for its own button.'),
|
|
1269
|
+
checkout_type: z.enum(['own_page', 'stripe_checkout', 'payment_link']).optional().describe('Where shoppers pay (default: inferred).'),
|
|
1270
|
+
site: z.string().max(100).optional().describe("Your site_…: judge it as verify_task does (the site's Stripe-checked plans)."),
|
|
758
1271
|
jurisdictions: jurisdictions.optional(),
|
|
759
1272
|
as_of: asOf.optional(),
|
|
760
1273
|
},
|
|
@@ -765,25 +1278,74 @@ const checkPageHtml = {
|
|
|
765
1278
|
throw new ToolError('Pass html (the rendered page) or url.');
|
|
766
1279
|
const r = await api.request('POST', '/v1/checks/page', { body: args });
|
|
767
1280
|
const notes = checkNotes(r);
|
|
1281
|
+
// Round 17 (python-flask): on a Payment Link / Stripe Checkout page, replacement.html is the terms only.
|
|
1282
|
+
if (r.replacement?.consent_on && r.replacement.note && r.verdict !== 'pass')
|
|
1283
|
+
notes.push(`Replacement: ${r.replacement.note}`);
|
|
768
1284
|
if (r.extraction?.purchase_buttons) {
|
|
769
1285
|
const b = r.extraction.purchase_buttons;
|
|
770
1286
|
notes.unshift(b.length ? `Purchase buttons found: ${b.length} (first: ${untrusted(b[0], 60)}; texts in extraction.purchase_buttons).` : 'Purchase buttons found: none (send the rendered HTML).');
|
|
771
1287
|
}
|
|
772
|
-
return { headline: verdictHeadline('Page', r), notes, data: slimCheckResult(r) };
|
|
1288
|
+
return { headline: verdictHeadline('Page', r), notes, data: withoutUnneededFix(slimCheckResult(r)) };
|
|
773
1289
|
},
|
|
774
1290
|
};
|
|
1291
|
+
const DAY_S = 86_400;
|
|
1292
|
+
/**
|
|
1293
|
+
* Agent audit round 2: "2026-11-14: trial_ending (5 days before; US-UT)" said neither before what nor the allowed window,
|
|
1294
|
+
* which is what a cron job needs; the window was only in epoch seconds. The line now states both relative to the event.
|
|
1295
|
+
*/
|
|
1296
|
+
export function noticeScheduleLine(n) {
|
|
1297
|
+
const anchor = n.anchor_date ? Date.parse(`${n.anchor_date}T00:00:00Z`) / 1000 : undefined;
|
|
1298
|
+
const event = n.occasion ? n.occasion.replace(/\s*\(.*\)$/, '').toLowerCase() : 'the event';
|
|
1299
|
+
if (n.days_before === 0 || anchor === undefined || n.window_opens_at === undefined || n.window_closes_at === undefined) {
|
|
1300
|
+
return `${n.send_date}: ${n.kind} — ${n.days_before === 0 ? `at ${event} (same day)` : `${n.days_before} days before ${event}`} (${n.jurisdictions.join(', ')})`;
|
|
1301
|
+
}
|
|
1302
|
+
const most = Math.round((anchor - n.window_opens_at) / DAY_S);
|
|
1303
|
+
const least = Math.round((anchor - n.window_closes_at) / DAY_S);
|
|
1304
|
+
const window = least >= 0 && most >= least ? `; allowed ${least === most ? `exactly ${least}` : `${least}–${most}`} days before` : '';
|
|
1305
|
+
return `${n.send_date}: ${n.kind} — ${n.days_before} days before ${event} (${n.anchor_date})${window} (${n.jurisdictions.join(', ')})`;
|
|
1306
|
+
}
|
|
1307
|
+
/** One line per notice; a kind sent every charge shows its first date and how many follow. */
|
|
1308
|
+
export function noticeScheduleLines(notices) {
|
|
1309
|
+
const out = [];
|
|
1310
|
+
const seen = new Map();
|
|
1311
|
+
for (const n of notices)
|
|
1312
|
+
seen.set(n.kind, (seen.get(n.kind) ?? 0) + 1);
|
|
1313
|
+
const shown = new Set();
|
|
1314
|
+
for (const n of notices) {
|
|
1315
|
+
const count = seen.get(n.kind);
|
|
1316
|
+
if (count > 2) {
|
|
1317
|
+
if (shown.has(n.kind))
|
|
1318
|
+
continue;
|
|
1319
|
+
shown.add(n.kind);
|
|
1320
|
+
out.push(`${noticeScheduleLine(n)} — then the same before each of the next ${count - 1} charges (all in notices[])`);
|
|
1321
|
+
}
|
|
1322
|
+
else
|
|
1323
|
+
out.push(noticeScheduleLine(n));
|
|
1324
|
+
}
|
|
1325
|
+
return out.slice(0, 12);
|
|
1326
|
+
}
|
|
1327
|
+
/** Notice kinds of the schedule → the compliance plan's email task for it (same names as get_compliance_plan). */
|
|
1328
|
+
export const NOTICE_KIND_TASKS = {
|
|
1329
|
+
acknowledgment: { name: 'acknowledgment email', task: 'acknowledgment_email' },
|
|
1330
|
+
trial_ending: { name: 'trial-ending reminder', task: 'trial_reminder' },
|
|
1331
|
+
renewal_reminder: { name: 'renewal reminder', task: 'renewal_reminder' },
|
|
1332
|
+
annual_terms: { name: 'yearly terms reminder', task: 'annual_terms_notice' },
|
|
1333
|
+
charge_notice: { name: 'charge notice', task: 'charge_notice' },
|
|
1334
|
+
price_change: { name: 'price-change notice', task: 'price_change_notice' },
|
|
1335
|
+
};
|
|
775
1336
|
const getNoticeSchedule = {
|
|
776
1337
|
name: 'get_notice_schedule',
|
|
777
1338
|
title: 'Get the dates each required notice must be sent',
|
|
778
1339
|
description: [
|
|
779
|
-
'For a plan (price, interval, trial) and a sign-up date: every legally required notice (acknowledgment, trial-ending reminder, renewal reminder,
|
|
1340
|
+
'For a plan (price, interval, trial) and a sign-up date: every legally required notice (acknowledgment, trial-ending reminder, renewal reminder, yearly terms reminder, charge notice, price-change notice — the same emails as the compliance plan\'s email tasks) with the recommended send date, the allowed send window (earliest/latest), the jurisdictions it satisfies and citations; plus notice kinds NOT required and why.',
|
|
780
1341
|
'Use it to implement reminder emails / cron jobs correctly. Example: {"terms":{"price":99,"interval":"year","trial_days":30},"start_date":"2026-11-01"}.',
|
|
781
1342
|
].join('\n'),
|
|
782
1343
|
input: {
|
|
783
|
-
terms: planTerms.describe('The plan: price (or amount), interval, interval_count, trial_days.'),
|
|
1344
|
+
terms: planTerms.optional().describe('The plan: price (or amount), interval, interval_count, trial_days (or site + plan).'),
|
|
1345
|
+
site: z.string().max(100).optional().describe('site_… (its plans, jurisdictions)'),
|
|
1346
|
+
plan: z.string().max(200).optional().describe('With site: plan name or price_…'),
|
|
784
1347
|
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Sign-up date, YYYY-MM-DD (default today).'),
|
|
785
|
-
price_change: z
|
|
786
|
-
.object({ effective_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), new_price: z.number().positive().optional() })
|
|
1348
|
+
price_change: strictObject({ effective_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), new_price: z.number().positive().optional() }, 'price_change')
|
|
787
1349
|
.optional()
|
|
788
1350
|
.describe('A planned price change to schedule the notice for.'),
|
|
789
1351
|
cycles: z.number().int().min(1).max(24).optional().describe('How many renewals to plan (default one year).'),
|
|
@@ -795,23 +1357,151 @@ const getNoticeSchedule = {
|
|
|
795
1357
|
annotations: { ...ro, title: 'Notice schedule' },
|
|
796
1358
|
async run(args, { api }) {
|
|
797
1359
|
const r = await api.request('POST', '/v1/checks/notice_schedule', { body: args });
|
|
1360
|
+
const singleDay = [...new Set(r.notices.flatMap((n) => (n.timing_note ? [`${n.kind}: ${n.timing_note}`] : [])))].slice(0, 3);
|
|
1361
|
+
const kinds = [...new Set(r.notices.map((n) => n.kind))];
|
|
798
1362
|
return {
|
|
799
|
-
|
|
1363
|
+
// Polish round: one email per kind, named like the compliance plan's email tasks (the per-send dates follow).
|
|
1364
|
+
headline: kinds.length
|
|
1365
|
+
? `${kinds.length} required email${kinds.length === 1 ? '' : 's'} for ${r.plan ? `plan ${untrusted(r.plan, 60)}` : 'this plan'}: ${kinds.map((k) => NOTICE_KIND_TASKS[k] ? `${NOTICE_KIND_TASKS[k].name} (plan task ${NOTICE_KIND_TASKS[k].task})` : k).join(', ')}; ${r.notices.length} send${r.notices.length === 1 ? '' : 's'} in the period below.`
|
|
1366
|
+
: 'No required notices for this plan under these jurisdictions.',
|
|
800
1367
|
notes: [
|
|
801
|
-
...r.notices
|
|
1368
|
+
...noticeScheduleLines(r.notices),
|
|
1369
|
+
...singleDay,
|
|
1370
|
+
...(r.rounding_rule ? [`Timing rule: ${r.rounding_rule}`] : []),
|
|
802
1371
|
...(r.not_required ?? []).map((n) => `Not required: ${n.kind} — ${n.reason}`),
|
|
1372
|
+
...(r.warnings ?? []).filter((w) => typeof w?.message === 'string').slice(0, 3).map((w) => w.message),
|
|
803
1373
|
],
|
|
804
1374
|
data: r,
|
|
805
1375
|
};
|
|
806
1376
|
},
|
|
807
1377
|
};
|
|
1378
|
+
const SUGGESTION_SURFACE_NAMES = [
|
|
1379
|
+
'checkout_page',
|
|
1380
|
+
'stripe_checkout',
|
|
1381
|
+
'acknowledgment_email',
|
|
1382
|
+
'trial_reminder_email',
|
|
1383
|
+
'renewal_reminder_email',
|
|
1384
|
+
'price_change_email',
|
|
1385
|
+
'cancellation_flow',
|
|
1386
|
+
'cancellation_confirmation_email',
|
|
1387
|
+
];
|
|
1388
|
+
const suggestImprovements = {
|
|
1389
|
+
name: 'suggest_improvements',
|
|
1390
|
+
title: 'Suggest conversion improvements that keep the checks passing',
|
|
1391
|
+
description: [
|
|
1392
|
+
'OPTIONAL, after compliance: concrete ways to make the checkout page, Stripe Checkout, lifecycle emails and the cancellation flow convert or retain better WITHOUT breaking automatic-renewal laws. Deterministic library; every after-example was run through Checkbox\'s own checks for the merchant\'s states before it is offered.',
|
|
1393
|
+
'Each suggestion gives apply.where + apply.content (the exact copy, Stripe JSON patch or flow change to make), apply.instructions, preconditions that must be true (never apply one whose precondition is false), and guardrails with catalog requirement ids (e.g. California / Colorado click-to-cancel next to any save offer, Minnesota ask-first). Never invent figures or add urgency, pre-checked boxes or obstacles.',
|
|
1394
|
+
'With a site id (or one site in this mode): suggestions from what Checkbox observed. Or send the copy you are writing: terms + any of disclosure, page_html, stripe_checkout_session_params, emails, cancellation_flow (as in the check_* tools).',
|
|
1395
|
+
'Surfaces whose compliance checks fail are held: fix them first (get_compliance_plan). Suggestions never block and never count toward compliance. After applying one, re-run the check named in verification.operation_id on the real copy.',
|
|
1396
|
+
].join('\n'),
|
|
1397
|
+
input: {
|
|
1398
|
+
site: siteId.optional(),
|
|
1399
|
+
terms: planTerms.optional().describe('The plan sold (price or amount, interval, trial_days, merchant_name, cancel_url). Required with disclosure or emails.'),
|
|
1400
|
+
other_plans: z.array(strictObject({ ...planTermsShape, name: z.string().max(80).optional().describe('Plan name, e.g. "Lite" (or plan_name).') }, 'other_plans[]')).max(10).optional().describe('Other plans you sell (enables cheaper-plan / plan-choice suggestions).'),
|
|
1401
|
+
disclosure: z
|
|
1402
|
+
.object({
|
|
1403
|
+
text: z.string().min(1).max(5000).describe('Disclosure next to the purchase button, as rendered.'),
|
|
1404
|
+
button_text: z.string().max(200).optional(),
|
|
1405
|
+
order_summary: z.string().max(1000).optional(),
|
|
1406
|
+
placement: z.object({ above_button: z.boolean().optional(), bold: z.boolean().optional(), boxed: z.boolean().optional(), font_size_px: z.number().min(4).max(96).optional() }).optional(),
|
|
1407
|
+
consent: z.object({ checkbox: z.boolean(), prechecked: z.boolean().optional(), label: z.string().max(1000).optional() }).optional(),
|
|
1408
|
+
})
|
|
1409
|
+
.optional()
|
|
1410
|
+
.describe('Your own checkout (as in check_disclosure, plan terms in terms).'),
|
|
1411
|
+
page_html: z.string().max(1_000_000).optional().describe('Rendered checkout page HTML (as in check_page_html).'),
|
|
1412
|
+
stripe_checkout_session_params: z.record(z.string(), z.unknown()).optional().describe('Exactly what your code passes to stripe.checkout.sessions.create.'),
|
|
1413
|
+
emails: z
|
|
1414
|
+
.array(z.object({
|
|
1415
|
+
kind: z.enum(['acknowledgment', 'trial_reminder', 'renewal_reminder', 'price_change', 'cancellation_confirmation']),
|
|
1416
|
+
subject: z.string().min(1).max(1000),
|
|
1417
|
+
text: z.string().max(100_000).optional(),
|
|
1418
|
+
html: z.string().max(100_000).optional(),
|
|
1419
|
+
previous_price: z.union([z.number().positive(), z.string().max(40)]).optional().describe('price_change: the old price.'),
|
|
1420
|
+
}))
|
|
1421
|
+
.max(8)
|
|
1422
|
+
.optional()
|
|
1423
|
+
.describe('Lifecycle emails (as in check_email).'),
|
|
1424
|
+
cancellation_flow: z
|
|
1425
|
+
.object({
|
|
1426
|
+
steps: z.array(z.object({ text: z.string().max(5000), buttons: z.array(z.string().max(200)).max(10).optional(), offer: z.boolean().optional() })).min(1).max(10),
|
|
1427
|
+
confirmation_shown: z.boolean().optional(),
|
|
1428
|
+
})
|
|
1429
|
+
.optional()
|
|
1430
|
+
.describe('Your cancellation flow, one entry per screen from the account page to the confirmation.'),
|
|
1431
|
+
surfaces: z.array(z.enum(SUGGESTION_SURFACE_NAMES)).max(8).optional().describe('Only these surfaces.'),
|
|
1432
|
+
trial_reminders: z.boolean().optional().describe('You send a trial-ending reminder to every trial subscriber.'),
|
|
1433
|
+
jurisdictions: jurisdictions.optional(),
|
|
1434
|
+
as_of: asOf.optional(),
|
|
1435
|
+
},
|
|
1436
|
+
scopes: ['checks:read'],
|
|
1437
|
+
annotations: { ...ro, title: 'Suggest improvements' },
|
|
1438
|
+
async run(args, { api }) {
|
|
1439
|
+
const stateless = ['terms', 'disclosure', 'page_html', 'stripe_checkout_session_params', 'emails', 'cancellation_flow'].some((k) => args[k] !== undefined);
|
|
1440
|
+
let r;
|
|
1441
|
+
let where;
|
|
1442
|
+
if (stateless && args.site === undefined) {
|
|
1443
|
+
const body = pick(args, ['terms', 'other_plans', 'disclosure', 'emails', 'cancellation_flow', 'surfaces', 'trial_reminders', 'jurisdictions', 'as_of']);
|
|
1444
|
+
if (args.page_html !== undefined)
|
|
1445
|
+
body.page = { html: args.page_html };
|
|
1446
|
+
if (args.stripe_checkout_session_params !== undefined)
|
|
1447
|
+
body.stripe_checkout_session = { params: args.stripe_checkout_session_params };
|
|
1448
|
+
r = await api.request('POST', '/v1/suggestions', { body });
|
|
1449
|
+
where = 'the copy you sent';
|
|
1450
|
+
}
|
|
1451
|
+
else {
|
|
1452
|
+
const site = await resolveSite(api, args.site);
|
|
1453
|
+
const surfaces = Array.isArray(args.surfaces) ? args.surfaces.join(',') : undefined;
|
|
1454
|
+
r = await api.request('GET', `/v1/sites/${enc(site)}/suggestions`, { ...(surfaces ? { query: { surfaces } } : {}) });
|
|
1455
|
+
where = site;
|
|
1456
|
+
}
|
|
1457
|
+
const notes = ['Optional: never blocking, never counted toward compliance. Check every precondition before applying.'];
|
|
1458
|
+
r.suggestions.slice(0, 15).forEach((s, i) => {
|
|
1459
|
+
notes.push([
|
|
1460
|
+
`${i + 1}. [${s.surface} · ${s.match}] ${s.title} (${s.key})`,
|
|
1461
|
+
// "From your copy" only for the merchant's own text; anything else is an example (never present it as theirs).
|
|
1462
|
+
s.before.source === 'your_copy' || (!s.before.source && s.match === 'detected')
|
|
1463
|
+
? ` before, from your copy (${s.before.label}): ${untrusted(s.before.text, 160)}`
|
|
1464
|
+
: ` before, example (${s.before.label}): ${JSON.stringify(s.before.text.slice(0, 160))}`,
|
|
1465
|
+
` apply (${s.apply.type}) at ${s.apply.where}: ${JSON.stringify(s.apply.content.length > 600 ? `${s.apply.content.slice(0, 600)}…` : s.apply.content)}`,
|
|
1466
|
+
` how: ${s.apply.instructions}`,
|
|
1467
|
+
...(s.preconditions.length ? [` only if true: ${s.preconditions.join(' ')}`] : []),
|
|
1468
|
+
` guardrails: ${s.guardrails.map((g) => g.requirement_ids.join('/')).join('; ')}`,
|
|
1469
|
+
...(s.placeholders.length ? [` fill: ${s.placeholders.join(', ')}`] : []),
|
|
1470
|
+
` verified by ${s.verification?.operation_id ?? 'n/a'} (${s.verification?.verified ? `passes${s.verification.compliance_label ? `: ${s.verification.compliance_label}` : ''}` : 'not verified'}); re-run it on your real copy after the change.`,
|
|
1471
|
+
].join('\n'));
|
|
1472
|
+
});
|
|
1473
|
+
if (r.suggestions.length > 15)
|
|
1474
|
+
notes.push(`…and ${r.suggestions.length - 15} more in data.suggestions.`);
|
|
1475
|
+
const held = r.surfaces.filter((s) => s.held_by.length);
|
|
1476
|
+
if (held.length)
|
|
1477
|
+
notes.push(`Held (fix first with get_compliance_plan): ${held.map((s) => `${s.surface} (${s.held_label ? `${s.held_label}: ` : ''}${s.held_by.join(', ')})`).join('; ')}.`);
|
|
1478
|
+
if (r.withheld.length)
|
|
1479
|
+
notes.push(`Withheld: ${r.withheld.slice(0, 8).map((w) => `${w.key}: ${w.reason}`).join(' | ')}`);
|
|
1480
|
+
return { headline: `Suggestions for ${where}: ${r.summary}`, notes, data: r };
|
|
1481
|
+
},
|
|
1482
|
+
};
|
|
808
1483
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
809
1484
|
// Sites, findings, scans, law updates
|
|
810
1485
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
1486
|
+
/** One site in list_sites: what an agent needs to pick it. */
|
|
1487
|
+
export function siteListItem(s) {
|
|
1488
|
+
const x = s;
|
|
1489
|
+
return {
|
|
1490
|
+
id: s.id,
|
|
1491
|
+
object: 'site',
|
|
1492
|
+
name: s.name ?? null,
|
|
1493
|
+
url: s.url,
|
|
1494
|
+
...(x.platform ? { platform: x.platform } : {}),
|
|
1495
|
+
...(x.checkout?.url ? { checkout_url: x.checkout.url } : {}),
|
|
1496
|
+
status: plainLabel(s.compliance?.label ?? 'Not checked yet'),
|
|
1497
|
+
domain_verified: x.monitoring?.held_for_domain_verification !== true,
|
|
1498
|
+
...(x.archived ? { archived: true } : {}),
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
811
1501
|
const listSites = {
|
|
812
1502
|
name: 'list_sites',
|
|
813
1503
|
title: 'List sites',
|
|
814
|
-
description: 'The account\'s sites in the current mode (test keys see only test-mode sites), newest first, each with its compliance
|
|
1504
|
+
description: 'The account\'s sites in the current mode (test keys see only test-mode sites), newest first, each with its compliance status label. Use it to find a site id.',
|
|
815
1505
|
input: { limit: listLimit, starting_after: startingAfter, archived: z.boolean().optional().describe('true lists only archived sites.') },
|
|
816
1506
|
scopes: ['sites:read'],
|
|
817
1507
|
annotations: { ...ro, title: 'List sites' },
|
|
@@ -821,9 +1511,14 @@ const listSites = {
|
|
|
821
1511
|
});
|
|
822
1512
|
return {
|
|
823
1513
|
headline: `${list.data.length}${list.has_more ? '+' : ''} site(s) in ${key.mode === 'unknown' ? 'this' : key.mode} mode.`,
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
1514
|
+
// Audit P2: name + URL per site, and a Next line either way.
|
|
1515
|
+
notes: list.data.map((s) => `${s.id} ${s.name ? `${untrusted(s.name, 60)} ` : ''}${untrusted(s.url, 100)} — ${plainLabel(s.compliance?.label ?? 'Not checked yet')}`),
|
|
1516
|
+
// Agent audit round 2: each site carried install details, webhook URLs, retention and legal hold (~1.5 KB) when
|
|
1517
|
+
// an agent needs the id, URL and status; get_site_status / the API have the rest.
|
|
1518
|
+
data: { ...list, data: list.data.map(siteListItem) },
|
|
1519
|
+
next: list.data.length
|
|
1520
|
+
? { type: 'review_plan', message: list.data.length === 1 ? `Call get_compliance_plan with site "${list.data[0].id}" for what to change next.` : 'Call get_compliance_plan with the id of the site whose URL matches this project (listed above) for what to change next.', operation_id: 'retrieveCompliancePlan' }
|
|
1521
|
+
: { type: 'setup', message: 'No sites yet: call setup_checkbox with the site URL.', operation_id: 'createSetupSession' },
|
|
827
1522
|
};
|
|
828
1523
|
},
|
|
829
1524
|
};
|
|
@@ -867,7 +1562,7 @@ const updateSite = {
|
|
|
867
1562
|
name: 'update_site',
|
|
868
1563
|
title: 'Update a site\'s settings (write)',
|
|
869
1564
|
description: [
|
|
870
|
-
'WRITE: updates a site — the fields plan tasks of change.type "config" ask for (their change.content is this tool\'s input): checkout_url, checkout_steps, test_account { login_url, email, password } (stored encrypted; the response echoes it without the password), name (the merchant name in generated legal copy), platform, jurisdictions, monitoring_interval_minutes.',
|
|
1565
|
+
'WRITE: updates a site — the fields plan tasks of change.type "config" ask for (their change.content is this tool\'s input): checkout_url, checkout_steps, test_account { login_url, email, password } (stored encrypted; the response echoes it without the password), name (the merchant name in generated legal copy), platform, jurisdictions, monitoring_interval_minutes, protection_level (minimum = legal floor only; balanced = + recommended suggestions, the default; maximum = + extras; only required items ever affect compliance).',
|
|
871
1566
|
'The response is the site; check checkout.url / checkout.test_account to confirm. Then verify_task the task that asked for it.',
|
|
872
1567
|
].join('\n'),
|
|
873
1568
|
input: {
|
|
@@ -875,19 +1570,24 @@ const updateSite = {
|
|
|
875
1570
|
name: z.string().max(200).optional().describe('Merchant / brand name (used in generated disclosures).'),
|
|
876
1571
|
checkout_url: z.string().max(2048).optional().describe('Page with the Subscribe / Buy button.'),
|
|
877
1572
|
checkout_steps: z.array(z.record(z.string(), z.unknown())).max(20).optional().describe('Steps to reach the purchase button (clicks / fills), as in the API.'),
|
|
878
|
-
test_account:
|
|
879
|
-
|
|
1573
|
+
test_account: strictObject({
|
|
1574
|
+
login_url: z.string().max(2048),
|
|
1575
|
+
email: z.string().max(320),
|
|
1576
|
+
password: z.string().max(200),
|
|
1577
|
+
reactivate_url: z.string().max(2048).optional().describe('Test-only page that re-subscribes the test account.'),
|
|
1578
|
+
}, 'test_account')
|
|
880
1579
|
.optional()
|
|
881
|
-
.describe('A TEST subscriber account the scanner logs in with to try cancelling online.'),
|
|
1580
|
+
.describe('A TEST subscriber account the scanner logs in with to try cancelling online (echoed back without the password).'),
|
|
882
1581
|
platform: z.enum(['stripe', 'shopify', 'recharge', 'chargebee', 'recurly', 'paddle', 'woocommerce', 'custom']).optional(),
|
|
883
1582
|
jurisdictions: z.array(z.string().max(10)).max(60).optional(),
|
|
884
1583
|
monitoring_interval_minutes: z.number().int().min(0).optional(),
|
|
1584
|
+
protection_level: z.enum(['minimum', 'balanced', 'maximum']).optional().describe('Which tiers the plan lists: minimum (required only), balanced (default: + recommended suggestions), maximum (+ extras). Ask the human before changing it.'),
|
|
885
1585
|
},
|
|
886
1586
|
scopes: ['sites:write'],
|
|
887
1587
|
annotations: { ...write(false, true), title: 'Update site' },
|
|
888
1588
|
async run(args, { api }) {
|
|
889
1589
|
const site = await resolveSite(api, args.site);
|
|
890
|
-
const body = pick(args, ['name', 'checkout_url', 'checkout_steps', 'test_account', 'platform', 'jurisdictions', 'monitoring_interval_minutes']);
|
|
1590
|
+
const body = pick(args, ['name', 'checkout_url', 'checkout_steps', 'test_account', 'platform', 'jurisdictions', 'monitoring_interval_minutes', 'protection_level']);
|
|
891
1591
|
if (!Object.keys(body).length)
|
|
892
1592
|
throw new ToolError('Pass at least one field to change (e.g. checkout_url, test_account, name).');
|
|
893
1593
|
const updated = await api.request('POST', `/v1/sites/${enc(site)}`, { body });
|
|
@@ -898,37 +1598,94 @@ const updateSite = {
|
|
|
898
1598
|
};
|
|
899
1599
|
},
|
|
900
1600
|
};
|
|
1601
|
+
/** The site's Shopify connection in one line, naming the tool (or the human step) that comes next. */
|
|
1602
|
+
export function shopifyLines(sh, site) {
|
|
1603
|
+
if (!sh)
|
|
1604
|
+
return [];
|
|
1605
|
+
if (sh.connection === 'app') {
|
|
1606
|
+
return sh.terms_published
|
|
1607
|
+
? [`Shopify: ${sh.shop ?? 'The store'} is connected through the Checkbox app; renewal terms published to the app block on ${new Date(sh.terms_published.published * 1000).toISOString().slice(0, 10)}. If the storefront does not show them, the merchant still has to place the "Renewal terms + consent" block once: ${sh.theme_editor_url ?? 'theme editor'}. publish_shopify_terms republishes after plan changes.`]
|
|
1608
|
+
: [`Shopify: ${sh.shop ?? 'The store'} is connected through the Checkbox app; the renewal terms are not published yet: call publish_shopify_terms with site "${site}", then the merchant places the block once (${sh.theme_editor_url ?? 'theme editor'}).`];
|
|
1609
|
+
}
|
|
1610
|
+
if (sh.connection === 'own_app')
|
|
1611
|
+
return [`Shopify: ${sh.shop ?? 'The store'} is connected with its own custom-app token (read-only): use the standalone theme snippet from the plan's sign-up task, or have the merchant install the Checkbox Shopify app so publish_shopify_terms can update the block.`];
|
|
1612
|
+
return ['Shopify: the store is not connected. Only the merchant can connect it: install the Checkbox app in the Shopify admin, open it and click "Connect to Checkbox" (then publish_shopify_terms works).'];
|
|
1613
|
+
}
|
|
901
1614
|
const getSiteStatus = {
|
|
902
1615
|
name: 'get_site_status',
|
|
903
1616
|
title: 'Get a site\'s compliance status',
|
|
904
|
-
description: 'Where a site stands:
|
|
1617
|
+
description: 'Where a site stands: one status (Checks pass, N fixes needed, Not set up yet, Not checked yet, Your call) and the to-do list: the compliance plan\'s ordered open tasks, as on the dashboard. Use it for "am I compliant?"; get_task gives each change.',
|
|
905
1618
|
input: { site: siteId.optional() },
|
|
906
1619
|
scopes: ['sites:read'],
|
|
907
1620
|
annotations: { ...ro, title: 'Site status' },
|
|
908
1621
|
async run(args, { api }) {
|
|
909
1622
|
const id = await resolveSite(api, args.site);
|
|
910
|
-
const [site, evaluation,
|
|
1623
|
+
const [site, evaluation, plan] = await Promise.all([
|
|
911
1624
|
api.request('GET', `/v1/sites/${enc(id)}`),
|
|
912
1625
|
optional(api.request('GET', `/v1/sites/${enc(id)}/evaluation`)),
|
|
913
|
-
optional(api.request('GET',
|
|
1626
|
+
optional(api.request('GET', `/v1/sites/${enc(id)}/plan`)),
|
|
914
1627
|
]);
|
|
915
|
-
|
|
916
|
-
const
|
|
1628
|
+
// Round-4 pilot (nextjs): "nothing left to do" while 13 critical/high checks were needs_review. Count them too.
|
|
1629
|
+
const review = await optional(api.request('GET', '/v1/findings', { query: { site: id, status: 'needs_review', state: 'open', limit: 100 } }));
|
|
1630
|
+
const evaluated = 'id' in evaluation;
|
|
1631
|
+
const toReview = 'data' in review ? review.data : [];
|
|
1632
|
+
const seriousReview = toReview.filter((f) => f.severity === 'critical' || f.severity === 'high');
|
|
1633
|
+
const blocking = evaluated ? (evaluation.blockers ?? []).map((b) => b.check_id) : [];
|
|
1634
|
+
// One to-do list: the plan's ordered open tasks, never a list built from findings.
|
|
1635
|
+
const todo = 'tasks' in plan && Array.isArray(plan.tasks) ? planTodo(plan.tasks) : [];
|
|
1636
|
+
const { checks: _omit, score: _score, insurable: _insurable, ...evalLite } = (evaluated ? evaluation : {});
|
|
1637
|
+
// Status labels: the roll-up decides the headline; pending live checks count as compliant and say how they complete.
|
|
1638
|
+
const roll = site.compliance?.rollup;
|
|
1639
|
+
const pendingLive = roll?.pending_live_checks ?? [];
|
|
1640
|
+
const rollStatus = site.compliance?.status;
|
|
1641
|
+
const label = site.compliance?.label;
|
|
1642
|
+
const ro = unverifiedScanNote(site.unverified_scan);
|
|
917
1643
|
const notes = [
|
|
918
|
-
...(
|
|
919
|
-
...
|
|
1644
|
+
...(todo.length ? [`To do (${todo.length}, from the compliance plan, in order):`, ...todo.map((t, i) => `${i + 1}. ${t.id} ${todoLine(t)}`)] : []),
|
|
1645
|
+
...(evaluated && label ? pendingLive.slice(0, 6).map((p) => `LIVE CHECK PENDING ${p.check_id}: ${p.what} — ${p.completes}`) : []),
|
|
1646
|
+
...(ro ? [ro] : []),
|
|
1647
|
+
...shopifyLines(site.shopify, site.id),
|
|
920
1648
|
];
|
|
921
|
-
const {
|
|
1649
|
+
const { open_findings: _open, needs_review: _nr, ...complianceLite } = (site.compliance ?? {});
|
|
922
1650
|
return {
|
|
923
|
-
headline: evaluated
|
|
924
|
-
? `${site.url}: score ${evaluation.score}, ${evaluation.insurable ? 'insurable' : 'NOT insurable'}, ${site.compliance?.open_findings ?? top.length} open failing finding(s).`
|
|
925
|
-
: `${site.url}: not evaluated yet.`,
|
|
1651
|
+
headline: evaluated ? `${site.url}: ${plainLabel(label ?? 'Not checked yet')}.` : `${site.url}: Not checked yet.`,
|
|
926
1652
|
notes,
|
|
927
|
-
data: { site, evaluation: evaluated ? evalLite : null,
|
|
1653
|
+
data: { site: site.compliance ? { ...site, compliance: complianceLite } : site, evaluation: evaluated ? evalLite : null, todo },
|
|
928
1654
|
next: evaluated
|
|
929
|
-
?
|
|
930
|
-
? { type: 'fix', message:
|
|
931
|
-
:
|
|
1655
|
+
? todo.length && (rollStatus === 'needs_fix' || todo.some((t) => t.status === 'needs_fix' || t.status === 'not_set_up'))
|
|
1656
|
+
? { type: 'fix', message: `Start with the first task (${todo[0].id}: ${todo[0].title}); get_compliance_plan / get_task give the exact change.`, operation_id: 'retrieveCompliancePlan' }
|
|
1657
|
+
: rollStatus === 'needs_fix'
|
|
1658
|
+
? { type: 'fix', message: 'Call get_compliance_plan for the ordered changes.', operation_id: 'retrieveCompliancePlan' }
|
|
1659
|
+
: rollStatus === 'your_call' || todo.some((t) => t.status === 'your_call')
|
|
1660
|
+
? { type: 'review', message: 'Some items need a decision from the human: call get_compliance_plan for the questions (each has a recommended answer).', operation_id: 'retrieveCompliancePlan' }
|
|
1661
|
+
: rollStatus === 'compliant' && !todo.length
|
|
1662
|
+
? // Round 15 (pilot 14, change-guard: "Checks pass · 3 live checks pending" next to "the site is not shown
|
|
1663
|
+
// as passing yet"): one status, one sentence. When the roll-up says Checks pass, next_action says the
|
|
1664
|
+
// same; the live checks are named as what completes it, never as a reason it does not pass.
|
|
1665
|
+
pendingLive.length
|
|
1666
|
+
? {
|
|
1667
|
+
type: 'complete_live_checks',
|
|
1668
|
+
message: `${plainLabel(label ?? 'Checks pass')}: nothing needs a code change. ${pendingLive.length} live check(s) complete on their own (${pendingLive
|
|
1669
|
+
.slice(0, 3)
|
|
1670
|
+
.map((p) => p.what)
|
|
1671
|
+
.join('; ')}); get_compliance_plan says how each one completes (a proof run, BCC real sends, a test account).`,
|
|
1672
|
+
operation_id: 'retrieveCompliancePlan',
|
|
1673
|
+
}
|
|
1674
|
+
: { type: 'none', message: `${plainLabel(label ?? 'Checks pass')}: every check that applies passes; nothing left to do. Keep monitoring on.` }
|
|
1675
|
+
: blocking.length || pendingLive.length || seriousReview.length || todo.length
|
|
1676
|
+
? // Normal round 4 (nextjs): "nothing left to do — Compliant" next to 2 blocking checks. Never call a site
|
|
1677
|
+
// compliant while a blocking check, a pending live check or an open plan task remains.
|
|
1678
|
+
{
|
|
1679
|
+
type: 'complete_live_checks',
|
|
1680
|
+
message: `Nothing needs a code change now, but the site is not shown as passing yet (${plainLabel(label ?? 'Not checked yet')}): ${[
|
|
1681
|
+
todo.length ? `${todo.length} task(s) on the plan (${todo.slice(0, 3).map((t) => t.title).join('; ')})` : '',
|
|
1682
|
+
blocking.length ? `blocking check(s) ${blocking.slice(0, 4).join(', ')}` : '',
|
|
1683
|
+
pendingLive.length ? `${pendingLive.length} live check(s) pending` : '',
|
|
1684
|
+
seriousReview.length && !todo.length ? `${seriousReview.length} check(s) to review` : '',
|
|
1685
|
+
].filter(Boolean).join(', ')}. Call get_compliance_plan for how each one completes (a proof run, BCC real sends, a test account).`,
|
|
1686
|
+
operation_id: 'retrieveCompliancePlan',
|
|
1687
|
+
}
|
|
1688
|
+
: { type: 'none', message: 'Every check that applies has passed; nothing left to do. Keep monitoring on.' }
|
|
932
1689
|
: { type: 'run_first_check', message: 'Call verify_site (or run_scan) for the first verdict.', operation_id: 'verifySite' },
|
|
933
1690
|
};
|
|
934
1691
|
},
|
|
@@ -936,10 +1693,10 @@ const getSiteStatus = {
|
|
|
936
1693
|
const listFindings = {
|
|
937
1694
|
name: 'list_findings',
|
|
938
1695
|
title: 'List compliance findings',
|
|
939
|
-
description: 'Findings (one per legal check per site) ordered by urgency: failing first, then by severity, each with citations. Filter by site, status (fail, needs_review, pass, not_applicable) and state (open, acknowledged, resolved, accepted_risk). Default: open
|
|
1696
|
+
description: 'Findings (one per legal check per site) ordered by urgency: failing first, then by severity, each with citations. Filter by site, status (fail, needs_review, pass, not_applicable) and state (open, acknowledged, resolved, accepted_risk). Default: every open finding of every status. Use get_finding for the fix of one.',
|
|
940
1697
|
input: {
|
|
941
1698
|
site: z.string().max(100).optional().describe('Only this site (site_…).'),
|
|
942
|
-
status: z.enum(['pass', 'fail', 'needs_review', 'not_applicable']).optional().describe('Default
|
|
1699
|
+
status: z.enum(['pass', 'fail', 'needs_review', 'not_applicable']).optional().describe('Default: all statuses.'),
|
|
943
1700
|
state: z.enum(['open', 'acknowledged', 'resolved', 'accepted_risk']).optional().describe('Default open.'),
|
|
944
1701
|
limit: listLimit,
|
|
945
1702
|
starting_after: startingAfter,
|
|
@@ -950,7 +1707,8 @@ const listFindings = {
|
|
|
950
1707
|
const list = await api.request('GET', '/v1/findings', {
|
|
951
1708
|
query: {
|
|
952
1709
|
site: args.site,
|
|
953
|
-
|
|
1710
|
+
// Normal round 3: with no filter, every open finding (it returned "0 finding(s)" while 13 were active).
|
|
1711
|
+
status: args.status,
|
|
954
1712
|
state: args.state ?? 'open',
|
|
955
1713
|
limit: args.limit,
|
|
956
1714
|
starting_after: args.starting_after,
|
|
@@ -958,7 +1716,7 @@ const listFindings = {
|
|
|
958
1716
|
});
|
|
959
1717
|
return {
|
|
960
1718
|
headline: `${list.data.length}${list.has_more ? '+' : ''} finding(s).`,
|
|
961
|
-
notes: list.data.map((f) => `${f.severity.toUpperCase()} ${f.id} [${f.status}] ${f.check_id}: ${f.title}${f.requirements?.[0] ? ` [${f.requirements[0].citation}]` : ''}`),
|
|
1719
|
+
notes: list.data.map((f) => `${f.severity.toUpperCase()} ${f.id} [${f.compliance?.label ?? f.status}] ${f.check_id}: ${f.title}${f.requirements?.[0] ? ` [${f.requirements[0].citation}]` : ''}`),
|
|
962
1720
|
data: list,
|
|
963
1721
|
};
|
|
964
1722
|
},
|
|
@@ -982,12 +1740,43 @@ const getFinding = {
|
|
|
982
1740
|
? await optional(api.request('GET', `/v1/findings/${enc(id)}/fix`, { query: pick(args, ['target', 'cancel_url', 'plan']) }))
|
|
983
1741
|
: { unavailable: 'This key has no fixes:read permission.' };
|
|
984
1742
|
return {
|
|
985
|
-
headline: `${finding.severity.toUpperCase()} ${finding.id} [${finding.status}] ${finding.check_id}: ${finding.title}`,
|
|
1743
|
+
headline: `${finding.severity.toUpperCase()} ${finding.id} [${finding.compliance?.label ?? finding.status}] ${finding.check_id}: ${finding.title}`,
|
|
1744
|
+
notes: [complianceBullet(finding.check_id, finding.compliance, finding.status, finding.title)].filter((x) => !!x),
|
|
986
1745
|
data: { finding, fix },
|
|
987
1746
|
next: nextActionOf(fix) ?? { type: 'apply_fix', message: 'Apply fix (or the matching task from get_compliance_plan), then call verify_site.', operation_id: 'verifySite' },
|
|
988
1747
|
};
|
|
989
1748
|
},
|
|
990
1749
|
};
|
|
1750
|
+
const publishShopifyTerms = {
|
|
1751
|
+
name: 'publish_shopify_terms',
|
|
1752
|
+
title: 'Publish renewal terms to the Shopify app block (write)',
|
|
1753
|
+
description: [
|
|
1754
|
+
'WRITE (admin key). Shopify store linked through the Checkbox Shopify app (get_site_status: shopify.connection "app"): publishes the engine-verified renewal terms + consent label for the site\'s real plan to the Checkbox app block. Read-only on the store except that app-owned metafield (no theme/product/checkout edits); safe to repeat.',
|
|
1755
|
+
'The merchant still places the "Renewal terms + consent" block once: give them theme_editor_url from the result. Then verify_site.',
|
|
1756
|
+
'shopify_not_connected: only the merchant can install the app in Shopify and click "Connect to Checkbox". terms_incomplete: follow next_action.',
|
|
1757
|
+
].join('\n'),
|
|
1758
|
+
input: {
|
|
1759
|
+
site: requiredSite,
|
|
1760
|
+
cancel_url: z.string().max(2048).optional().describe('Cancellation URL to state (default: detected).'),
|
|
1761
|
+
plan: z.string().max(200).optional().describe('Plan / price id (default: detected).'),
|
|
1762
|
+
},
|
|
1763
|
+
scopes: ['fixes:write'],
|
|
1764
|
+
annotations: { ...write(true, true), title: 'Publish Shopify terms' },
|
|
1765
|
+
async run(args, { api }) {
|
|
1766
|
+
const site = String(args.site);
|
|
1767
|
+
const r = await api.request('POST', `/v1/sites/${enc(site)}/fixes/shopify/publish`, { body: pick(args, ['cancel_url', 'plan']) });
|
|
1768
|
+
return {
|
|
1769
|
+
headline: `Published the verified renewal terms to the Checkbox app block on ${r.shop} (${r.id}).`,
|
|
1770
|
+
notes: [
|
|
1771
|
+
`Disclosure: ${untrusted(r.disclosure, 600)}`,
|
|
1772
|
+
`Consent checkbox: ${untrusted(r.consent_label, 200)}`,
|
|
1773
|
+
`Only the merchant can place the block (once): ask them to open ${r.theme_editor_url} , check the "Renewal terms + consent" block sits directly above the add-to-cart button, and click Save. Later publishes update it in place.`,
|
|
1774
|
+
],
|
|
1775
|
+
data: r,
|
|
1776
|
+
next: r.next_action ?? { type: 'place_theme_block', message: `After the merchant saved the block in the theme editor, call verify_site for ${r.site}.`, operation_id: 'verifySite' },
|
|
1777
|
+
};
|
|
1778
|
+
},
|
|
1779
|
+
};
|
|
991
1780
|
const runScan = {
|
|
992
1781
|
name: 'run_scan',
|
|
993
1782
|
title: 'Run a synthetic-shopper scan (write)',
|
|
@@ -1008,15 +1797,26 @@ const runScan = {
|
|
|
1008
1797
|
const getScan = {
|
|
1009
1798
|
name: 'get_scan',
|
|
1010
1799
|
title: 'Get a scan',
|
|
1011
|
-
description: 'A scan\'s status (queued → running → completed | failed), warnings, and once completed the evaluation it produced
|
|
1800
|
+
description: 'A scan\'s status (queued → running → completed | failed), warnings, and once completed the evaluation it produced (the site status and to-do list update from it).',
|
|
1012
1801
|
input: { scan: z.string().max(100).describe('Scan id (scan_…).') },
|
|
1013
1802
|
scopes: ['scans:read'],
|
|
1014
1803
|
annotations: { ...ro, title: 'Scan' },
|
|
1015
1804
|
async run(args, { api }) {
|
|
1016
1805
|
const scan = await api.request('GET', `/v1/scans/${enc(String(args.scan))}`);
|
|
1017
1806
|
const done = scan.status === 'completed';
|
|
1807
|
+
// Audit P1: a queued scan of a site held for domain verification does not run until the domain is verified.
|
|
1808
|
+
if (scan.status === 'queued' && scan.site) {
|
|
1809
|
+
const site = await optional(api.request('GET', `/v1/sites/${enc(scan.site)}`));
|
|
1810
|
+
if ('monitoring' in site && site.monitoring?.held_for_domain_verification) {
|
|
1811
|
+
return {
|
|
1812
|
+
headline: `Scan ${scan.id}: queued (waiting for domain verification). It does not run until the site's domain is verified.`,
|
|
1813
|
+
data: scan,
|
|
1814
|
+
next: { type: 'verify_domain', message: `Do the plan's verify_domain task first (get_compliance_plan for site ${scan.site}); polling this scan changes nothing until then.`, operation_id: 'retrieveCompliancePlan' },
|
|
1815
|
+
};
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1018
1818
|
return {
|
|
1019
|
-
headline: `Scan ${scan.id}: ${statusLine(scan.status)}${done ?
|
|
1819
|
+
headline: `Scan ${scan.id}: ${statusLine(scan.status)}${done && scan.compliance?.label ? ` — ${plainLabel(scan.compliance.label)}` : ''}${done ? '; the site status and to-do list are updated (get_site_status)' : ''}${scan.error ? ` — error: ${untrusted(scan.error, 160)}` : ''}.`,
|
|
1020
1820
|
data: scan,
|
|
1021
1821
|
next: done
|
|
1022
1822
|
? { type: 'review', message: 'Call get_compliance_plan for what to change.', operation_id: 'retrieveCompliancePlan' }
|
|
@@ -1029,32 +1829,58 @@ const getScan = {
|
|
|
1029
1829
|
const sendFeedback = {
|
|
1030
1830
|
name: 'send_feedback',
|
|
1031
1831
|
title: 'Send feedback to the Checkbox team',
|
|
1032
|
-
description: '
|
|
1832
|
+
description: 'Bug, idea or question: what you tried, happened, expected. type review: only after the human said yes and saw the text; about Checkbox, not the site: no site names, URLs, quotes, secrets, customer data or claims the site broke a law. score 0-10 (would recommend); ≤5 phrases in liked, disliked, wrong_verdicts ("check.id: why wrong"); stack e.g. "Next.js + Stripe".',
|
|
1033
1833
|
input: {
|
|
1034
|
-
type: z.enum(['bug', 'idea', 'question']).optional()
|
|
1035
|
-
message: z.string().
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1834
|
+
type: z.enum(['bug', 'idea', 'question', 'review']).optional(),
|
|
1835
|
+
message: z.string().optional(),
|
|
1836
|
+
score: z.number().int().min(0).max(10).optional(),
|
|
1837
|
+
liked: z.array(z.string()).optional(),
|
|
1838
|
+
disliked: z.array(z.string()).optional(),
|
|
1839
|
+
wrong_verdicts: z.array(z.string()).optional(),
|
|
1840
|
+
would_keep_using: z.boolean().optional(),
|
|
1841
|
+
stack: z.string().optional(),
|
|
1842
|
+
site: z.string().optional(),
|
|
1843
|
+
request_id: z.string().optional(),
|
|
1844
|
+
tool: z.string().optional(),
|
|
1039
1845
|
},
|
|
1040
1846
|
scopes: ['account:read'],
|
|
1041
1847
|
annotations: { ...write(false, false), title: 'Send feedback' },
|
|
1042
1848
|
async run(args, ctx) {
|
|
1043
1849
|
const context = {};
|
|
1044
|
-
|
|
1850
|
+
// Reviews are about Checkbox, not the merchant's site: no site or request id is attached to them.
|
|
1851
|
+
for (const k of args.type === 'review' ? ['tool'] : ['site', 'request_id', 'tool'])
|
|
1045
1852
|
if (typeof args[k] === 'string' && args[k])
|
|
1046
1853
|
context[k] = String(args[k]);
|
|
1047
1854
|
const agent = ctx.key.appName ?? ctx.clientName;
|
|
1048
1855
|
if (agent)
|
|
1049
1856
|
context.agent = String(agent).slice(0, 100);
|
|
1050
|
-
|
|
1857
|
+
if (args.type !== 'review' && !(typeof args.message === 'string' && args.message.trim()))
|
|
1858
|
+
throw new ToolError('Write a message (what you tried, what happened, what you expected).');
|
|
1859
|
+
if (args.type === 'review' && typeof args.score !== 'number')
|
|
1860
|
+
throw new ToolError('A review needs score (0-10). Ask the human; never guess it.');
|
|
1861
|
+
const body = { ...pick(args, ['type', 'message', 'score', 'liked', 'disliked', 'wrong_verdicts', 'would_keep_using', 'stack']), ...(Object.keys(context).length ? { context } : {}) };
|
|
1862
|
+
const fb = await ctx.api.request('POST', '/v1/feedback', { body });
|
|
1051
1863
|
return {
|
|
1052
|
-
headline:
|
|
1864
|
+
headline: `${args.type === 'review' ? 'Review' : 'Feedback'} ${fb.id} sent to the Checkbox team (${fb.status}).${fb.redacted ? ' Some text looked like a secret, card number or email address and was replaced with [redacted].' : ''}`,
|
|
1053
1865
|
data: fb,
|
|
1054
1866
|
next: { type: 'none', message: 'Nothing else to do; the team replies by email to the account owner when needed.' },
|
|
1055
1867
|
};
|
|
1056
1868
|
},
|
|
1057
1869
|
};
|
|
1870
|
+
const LAW_CHANGES_SHOWN = 10;
|
|
1871
|
+
/**
|
|
1872
|
+
* Agent audit round 2: list_law_updates with limit 3 printed 62 KB (one catalog update carried 195 requirement changes,
|
|
1873
|
+
* each with its full summary). Each update keeps its first changes in short form, with the total.
|
|
1874
|
+
*/
|
|
1875
|
+
export function lawUpdateItem(u) {
|
|
1876
|
+
const changes = Array.isArray(u.changes) ? u.changes : [];
|
|
1877
|
+
const { changes: _c, ...rest } = u;
|
|
1878
|
+
return {
|
|
1879
|
+
...rest,
|
|
1880
|
+
changes: changes.slice(0, LAW_CHANGES_SHOWN).map((c) => pick(c, ['requirement_id', 'change', 'citation', 'title', 'effective_date'])),
|
|
1881
|
+
...(changes.length > LAW_CHANGES_SHOWN ? { changes_total: changes.length } : {}),
|
|
1882
|
+
};
|
|
1883
|
+
}
|
|
1058
1884
|
const listLawUpdates = {
|
|
1059
1885
|
name: 'list_law_updates',
|
|
1060
1886
|
title: 'List law updates and their impact',
|
|
@@ -1069,17 +1895,21 @@ const listLawUpdates = {
|
|
|
1069
1895
|
return {
|
|
1070
1896
|
headline: `${list.data.length}${list.has_more ? '+' : ''} law update(s).`,
|
|
1071
1897
|
notes: list.data.map((u) => `${u.date} ${u.id} (${(u.jurisdictions ?? []).join(', ')}): ${u.summary}${u.impacts?.length ? ` — affects ${u.impacts.length} site(s)` : ''}`),
|
|
1072
|
-
data: list,
|
|
1898
|
+
data: { ...list, data: list.data.map(lawUpdateItem) },
|
|
1073
1899
|
};
|
|
1074
1900
|
},
|
|
1075
1901
|
};
|
|
1076
1902
|
function toDisclosureTerms(t) {
|
|
1077
|
-
const out = pick(t, ['merchant_name', 'amount', 'currency', 'interval', 'interval_count', 'trial_days', 'cancel_url', 'cancel_timing']);
|
|
1903
|
+
const out = pick(t, ['merchant_name', 'amount', 'currency', 'interval', 'interval_count', 'trial_days', 'intro_percent_off', 'intro_periods', 'cancel_url', 'cancel_timing']);
|
|
1904
|
+
if (out.merchant_name === undefined && typeof t.brand_name === 'string')
|
|
1905
|
+
out.merchant_name = t.brand_name;
|
|
1078
1906
|
if (out.amount === undefined && t.price !== undefined) {
|
|
1079
1907
|
const n = typeof t.price === 'number' ? t.price : Number(String(t.price).replace(/[^0-9.]/g, ''));
|
|
1080
1908
|
if (Number.isFinite(n) && n > 0)
|
|
1081
1909
|
out.amount = Math.round(n * 100);
|
|
1082
1910
|
}
|
|
1911
|
+
if (typeof t.intro_price === 'number' && t.intro_price >= 0)
|
|
1912
|
+
out.intro_amount = Math.round(t.intro_price * 100);
|
|
1083
1913
|
return out;
|
|
1084
1914
|
}
|
|
1085
1915
|
function reactSnippet(d) {
|
|
@@ -1100,7 +1930,7 @@ function reactSnippet(d) {
|
|
|
1100
1930
|
const elementsInstallNotes = [
|
|
1101
1931
|
'Put script_tag in <head>; put element_html inside the sign-up form directly ABOVE the pay button. It keeps the button disabled until the shopper ticks the (unchecked, required) box.',
|
|
1102
1932
|
'Your server receives checkbox_consent_id (cons_…) with the form: store it with the subscription (e.g. Stripe metadata.checkbox_consent).',
|
|
1103
|
-
'Do not restyle it below body size or hide it; guard rails restore
|
|
1933
|
+
'Do not restyle it below body size or hide it; guard rails restore the required styles and report what they found.',
|
|
1104
1934
|
];
|
|
1105
1935
|
const elementsTermsShape = {
|
|
1106
1936
|
merchant_name: planTermsShape.merchant_name,
|
|
@@ -1115,29 +1945,30 @@ const elementsTermsShape = {
|
|
|
1115
1945
|
};
|
|
1116
1946
|
const getCompliantDisclosure = {
|
|
1117
1947
|
name: 'get_compliant_disclosure',
|
|
1118
|
-
title: 'Generate a
|
|
1948
|
+
title: 'Generate a disclosure that passes the checks for given terms',
|
|
1119
1949
|
description: [
|
|
1120
1950
|
'Generates the locked, engine-verified automatic-renewal disclosure for a plan: disclosure_text, consent_label (for an unchecked, required checkbox), button_text and drop-in html. The same wording Checkbox Elements and Fixes use.',
|
|
1121
|
-
'Give the terms: price (major units) or amount (minor), interval, trial_days, cancel_url, merchant_name. Example: {"merchant_name":"Acme","price":19.99,"interval":"month","trial_days":14,"cancel_url":"https://acme.example/account/cancel"}.',
|
|
1951
|
+
'Give the terms: price (major units) or amount (minor), interval, trial_days, cancel_url, merchant_name, promo (intro_price or intro_percent_off, intro_periods). Example: {"merchant_name":"Acme","price":19.99,"interval":"month","trial_days":14,"cancel_url":"https://acme.example/account/cancel"}.',
|
|
1122
1952
|
'With site (and a key allowed to create disclosures), it also returns the Checkbox Elements snippet (script tag + <checkbox-disclosure> element + React usage) that records each consent as evidence.',
|
|
1123
1953
|
].join('\n'),
|
|
1124
|
-
|
|
1954
|
+
// Round 15 (P4): a promo (intro price) is stated with how long it lasts.
|
|
1955
|
+
input: { ...elementsTermsShape, brand_name: planTermsShape.brand_name, intro_price: planTermsShape.intro_price, intro_percent_off: planTermsShape.intro_percent_off, intro_periods: planTermsShape.intro_periods, interval: z.enum(['day', 'week', 'month', 'year']).describe('Billing interval.'), site: z.string().max(100).optional().describe('Site id (site_…) to also get the Elements snippet for.'), jurisdictions: jurisdictions.optional() },
|
|
1125
1956
|
scopes: ['checks:read'],
|
|
1126
|
-
annotations: { ...ro, readOnlyHint: false, title: '
|
|
1957
|
+
annotations: { ...ro, readOnlyHint: false, title: 'Disclosure that passes the checks' },
|
|
1127
1958
|
async run(args, { api, key }) {
|
|
1128
1959
|
if (typeof args.site === 'string' && args.site && keyAllows(key, 'consents:write')) {
|
|
1129
1960
|
const d = await api.request('POST', '/v1/disclosures', { body: { site: args.site, terms: toDisclosureTerms(args) } });
|
|
1130
1961
|
return {
|
|
1131
|
-
headline: `
|
|
1962
|
+
headline: `Disclosure ${d.id}${d.verification?.passes === undefined ? '' : d.verification.passes ? ' (passes the sign-up checks on your checkout)' : ' (see verification: not every check passes yet)'}.`,
|
|
1132
1963
|
notes: [`Disclosure: ${d.text}`, `Consent label: ${d.consent_label}`, ...elementsInstallNotes],
|
|
1133
1964
|
data: { ...d, react: reactSnippet(d) },
|
|
1134
1965
|
};
|
|
1135
1966
|
}
|
|
1136
1967
|
// No site: generate from the terms with the stateless check (the replacement does not depend on the text checked).
|
|
1137
|
-
const body = { text: '(no disclosure yet)', ...pick(args, ['merchant_name', 'plan_name', 'price', 'amount', 'currency', 'interval', 'interval_count', 'trial_days', 'cancel_url', 'cancel_timing', 'jurisdictions']) };
|
|
1968
|
+
const body = { text: '(no disclosure yet)', ...pick(args, ['merchant_name', 'brand_name', 'plan_name', 'price', 'amount', 'currency', 'interval', 'interval_count', 'trial_days', 'intro_price', 'intro_percent_off', 'intro_periods', 'cancel_url', 'cancel_timing', 'jurisdictions']) };
|
|
1138
1969
|
const r = await api.request('POST', '/v1/checks/disclosure', { body });
|
|
1139
1970
|
return {
|
|
1140
|
-
headline: `
|
|
1971
|
+
headline: `Suggested disclosure generated (${r.replacement.verified ? 'passes the checks, verified by the rules engine' : 'not fully verified by the rules engine'}).`,
|
|
1141
1972
|
notes: [
|
|
1142
1973
|
`Disclosure: ${r.replacement.disclosure_text}`,
|
|
1143
1974
|
`Consent label: ${r.replacement.consent_label}`,
|
|
@@ -1154,7 +1985,7 @@ const getElementsSnippet = {
|
|
|
1154
1985
|
title: 'Get the Checkbox Elements snippet for a site',
|
|
1155
1986
|
description: [
|
|
1156
1987
|
'Returns paste-ready Checkbox Elements markup for a site: the pinned <script> tag (with SRI) for <head>, the <checkbox-disclosure> element for directly above the pay button, the publishable key, and a React usage example.',
|
|
1157
|
-
'The element renders the locked
|
|
1988
|
+
'The element renders the locked disclosure (it passes the checks) with an unchecked, required consent checkbox, gates the pay button, and records each consent (cons_…) in the evidence vault. Terms come from the site\'s billing connection unless you pass them.',
|
|
1158
1989
|
'This creates a disclosure record (write, safe to repeat). Consents themselves are recorded by the browser, never by agents.',
|
|
1159
1990
|
].join('\n'),
|
|
1160
1991
|
input: { site: requiredSite, plan: z.string().max(200).optional().describe('Billing price / plan id (price_…) when the site has several.'), ...elementsTermsShape },
|
|
@@ -1259,7 +2090,7 @@ const listDueNotices = {
|
|
|
1259
2090
|
annotations: { ...ro, title: 'Due notices' },
|
|
1260
2091
|
async run(args, { api }) {
|
|
1261
2092
|
const list = await api.request('GET', '/v1/notices/due', {
|
|
1262
|
-
query: pick(args, ['site', 'status', 'kind', 'subscription', 'limit', 'starting_after']),
|
|
2093
|
+
query: { ...pick(args, ['site', 'status', 'kind', 'subscription', 'limit', 'starting_after']), ...(typeof args.kind === 'string' ? { kind: args.kind.split(',').map((k) => noticeKind(k.trim())).join(',') } : {}) },
|
|
1263
2094
|
});
|
|
1264
2095
|
const overdue = list.data.filter((d) => d.status === 'overdue').length;
|
|
1265
2096
|
return {
|
|
@@ -1304,7 +2135,7 @@ const getNoticeTemplates = {
|
|
|
1304
2135
|
site: z.string().max(100).optional().describe('Site (site_…): adds BCC addresses; required with check.'),
|
|
1305
2136
|
check: z
|
|
1306
2137
|
.object({
|
|
1307
|
-
kind:
|
|
2138
|
+
kind: emailKind.describe('Notice kind.'),
|
|
1308
2139
|
subject: z.string().max(1000).describe('Subject (merge fields allowed).'),
|
|
1309
2140
|
text: z.string().max(100_000).optional().describe('Plain-text body.'),
|
|
1310
2141
|
html: z.string().max(100_000).optional().describe('HTML body.'),
|
|
@@ -1320,7 +2151,7 @@ const getNoticeTemplates = {
|
|
|
1320
2151
|
if (!args.site)
|
|
1321
2152
|
throw new Error('site is required to check a template (its plan and jurisdictions scope the check).');
|
|
1322
2153
|
const r = await api.request('POST', '/v1/notices/templates/check', {
|
|
1323
|
-
body: { site: args.site, ...check },
|
|
2154
|
+
body: { site: args.site, ...check, kind: noticeKind(check.kind) },
|
|
1324
2155
|
});
|
|
1325
2156
|
return {
|
|
1326
2157
|
headline: `Template check: ${r.verdict.toUpperCase()}.`,
|
|
@@ -1343,8 +2174,9 @@ const callApi = {
|
|
|
1343
2174
|
name: 'call_api',
|
|
1344
2175
|
title: 'Call any Checkbox API endpoint',
|
|
1345
2176
|
description: [
|
|
1346
|
-
'For a next_action whose operation has no dedicated tool (e.g.
|
|
1347
|
-
'Examples: {"method":"
|
|
2177
|
+
'For a next_action whose operation has no dedicated tool (e.g. createStripeCheckoutConfig, updateTask, updateNoticeSettings, listConsents). Calls the Checkbox /v1 API with this server\'s connection; paths and bodies are in https://app.checkbox.site/v1/openapi.json.',
|
|
2178
|
+
'Examples: {"method":"GET","path":"/v1/account"} ; {"method":"POST","path":"/v1/sites/site_123/domain_verification/check"}.',
|
|
2179
|
+
'Never send API keys or Stripe keys through it: keys are added by the human in the Checkbox dashboard, never pasted into the chat.',
|
|
1348
2180
|
'GET is read-only. POST/DELETE change data: only do what the user or a next_action asked for. DELETE archives or removes objects — confirm with the user first.',
|
|
1349
2181
|
].join('\n'),
|
|
1350
2182
|
input: {
|
|
@@ -1369,10 +2201,74 @@ const callApi = {
|
|
|
1369
2201
|
},
|
|
1370
2202
|
};
|
|
1371
2203
|
/** Every tool, in the order clients list them. */
|
|
2204
|
+
/**
|
|
2205
|
+
* Round 12 (pilot 11, nextjs + agent users): setup_checkbox dropped unknown fields silently; the homepage prompt says
|
|
2206
|
+
* "brand", the schema says merchant_name, so the site was named "localhost" with no warning. Common spellings of the
|
|
2207
|
+
* brand are read as merchant_name; any other unknown field is listed back as ignored.
|
|
2208
|
+
*/
|
|
2209
|
+
export const SETUP_FIELD_ALIASES = Object.freeze({ brand: 'merchant_name', brand_name: 'merchant_name', business_name: 'merchant_name', company_name: 'merchant_name', platform: 'billing_platform' });
|
|
2210
|
+
const BILLING_PLATFORMS = new Set(['stripe', 'shopify', 'recharge', 'chargebee', 'recurly', 'paddle', 'woocommerce', 'custom']);
|
|
2211
|
+
export function normalizeSetupArgs(raw, known) {
|
|
2212
|
+
const args = {};
|
|
2213
|
+
const aliased = [];
|
|
2214
|
+
const ignored = [];
|
|
2215
|
+
for (const [k, v] of Object.entries(raw))
|
|
2216
|
+
if (known.has(k))
|
|
2217
|
+
args[k] = v;
|
|
2218
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
2219
|
+
if (known.has(k))
|
|
2220
|
+
continue;
|
|
2221
|
+
const to = SETUP_FIELD_ALIASES[k];
|
|
2222
|
+
const ok = typeof v === 'string' && !!v.trim() && (to !== 'billing_platform' || BILLING_PLATFORMS.has(v.trim().toLowerCase()));
|
|
2223
|
+
if (to && known.has(to) && ok && args[to] === undefined) {
|
|
2224
|
+
args[to] = to === 'billing_platform' ? v.trim().toLowerCase() : v.trim().slice(0, 100);
|
|
2225
|
+
aliased.push(`${k} (read as ${to})`);
|
|
2226
|
+
}
|
|
2227
|
+
else
|
|
2228
|
+
ignored.push(k);
|
|
2229
|
+
}
|
|
2230
|
+
return { args, aliased, ignored };
|
|
2231
|
+
}
|
|
2232
|
+
/** Messages of the API's `warnings` ({ code, message }) on a setup result (or its session). */
|
|
2233
|
+
export function apiWarningMessages(data) {
|
|
2234
|
+
if (!data || typeof data !== 'object')
|
|
2235
|
+
return [];
|
|
2236
|
+
const d = data;
|
|
2237
|
+
const list = Array.isArray(d.warnings) ? d.warnings : Array.isArray(d.session?.warnings) ? d.session.warnings : [];
|
|
2238
|
+
return list.flatMap((w) => (w && typeof w === 'object' && typeof w.message === 'string' ? [`Warning: ${w.message}`] : []));
|
|
2239
|
+
}
|
|
2240
|
+
function acceptingSetupAliases(def) {
|
|
2241
|
+
const known = new Set(Object.keys(def.input));
|
|
2242
|
+
return {
|
|
2243
|
+
...def,
|
|
2244
|
+
passthrough: true,
|
|
2245
|
+
async run(raw, ctx) {
|
|
2246
|
+
const { args, aliased, ignored } = normalizeSetupArgs(raw, known);
|
|
2247
|
+
// Round 18 (item 19, pilot 17): update_setup refuses unknown top-level fields, as it refuses unknown nested ones
|
|
2248
|
+
// (an ignored field read as "saved" to the agent).
|
|
2249
|
+
if (ignored.length && def.name === 'update_setup') {
|
|
2250
|
+
throw new ToolError(unknownArgumentsMessage(def.name, ignored, [...known]), { type: 'fix_request', message: 'Rename or remove those fields and call update_setup again.' });
|
|
2251
|
+
}
|
|
2252
|
+
const out = await def.run(args, ctx);
|
|
2253
|
+
// Pilot round 13: the API's input warnings (plan amounts that look like dollars, or differ from the Stripe Price)
|
|
2254
|
+
// are shown, and kept next to the field warnings below.
|
|
2255
|
+
const apiWarnings = apiWarningMessages(out.data);
|
|
2256
|
+
if (!aliased.length && !ignored.length && !apiWarnings.length)
|
|
2257
|
+
return out;
|
|
2258
|
+
const warnings = [
|
|
2259
|
+
...apiWarnings,
|
|
2260
|
+
...(aliased.length ? [`Read ${aliased.join(', ')}.`] : []),
|
|
2261
|
+
...(ignored.length ? [`Ignored unknown field${ignored.length === 1 ? '' : 's'}: ${ignored.join(', ')}. ${def.name} accepts: ${[...known].filter((k) => k !== 'device_code').join(', ')}.`] : []),
|
|
2262
|
+
];
|
|
2263
|
+
const data = out.data && typeof out.data === 'object' && !Array.isArray(out.data) ? { ...out.data, warnings } : out.data;
|
|
2264
|
+
return { ...out, notes: [...warnings, ...(out.notes ?? [])], data };
|
|
2265
|
+
},
|
|
2266
|
+
};
|
|
2267
|
+
}
|
|
1372
2268
|
export const TOOLS = [
|
|
1373
|
-
setupCheckbox,
|
|
2269
|
+
acceptingSetupAliases(setupCheckbox),
|
|
1374
2270
|
getSetupStatus,
|
|
1375
|
-
updateSetup,
|
|
2271
|
+
acceptingSetupAliases(updateSetup),
|
|
1376
2272
|
completeSetup,
|
|
1377
2273
|
getCompliancePlan,
|
|
1378
2274
|
getTask,
|
|
@@ -1387,12 +2283,14 @@ export const TOOLS = [
|
|
|
1387
2283
|
checkEmail,
|
|
1388
2284
|
checkPageHtml,
|
|
1389
2285
|
getNoticeSchedule,
|
|
2286
|
+
suggestImprovements,
|
|
1390
2287
|
listSites,
|
|
1391
2288
|
createSite,
|
|
1392
2289
|
updateSite,
|
|
1393
2290
|
getSiteStatus,
|
|
1394
2291
|
listFindings,
|
|
1395
2292
|
getFinding,
|
|
2293
|
+
publishShopifyTerms,
|
|
1396
2294
|
runScan,
|
|
1397
2295
|
getScan,
|
|
1398
2296
|
listLawUpdates,
|
|
@@ -1413,9 +2311,9 @@ export const TOOL_OPERATIONS = {
|
|
|
1413
2311
|
update_setup: ['updateSetupSession'],
|
|
1414
2312
|
update_site: ['updateSite'],
|
|
1415
2313
|
complete_setup: ['completeSetupSession'],
|
|
1416
|
-
get_compliance_plan: ['retrieveCompliancePlan'],
|
|
2314
|
+
get_compliance_plan: ['retrieveCompliancePlan', 'claimReviewPrompt'],
|
|
1417
2315
|
get_task: ['retrieveTask'],
|
|
1418
|
-
verify_task: ['verifyTask'],
|
|
2316
|
+
verify_task: ['verifyTask', 'claimReviewPrompt'],
|
|
1419
2317
|
answer_task: ['answerTask'],
|
|
1420
2318
|
verify_site: ['verifySite'],
|
|
1421
2319
|
get_verification: ['retrieveVerification'],
|
|
@@ -1426,11 +2324,13 @@ export const TOOL_OPERATIONS = {
|
|
|
1426
2324
|
check_email: ['checkEmail'],
|
|
1427
2325
|
check_page_html: ['checkPage'],
|
|
1428
2326
|
get_notice_schedule: ['checkNoticeSchedule'],
|
|
2327
|
+
suggest_improvements: ['listSiteSuggestions', 'suggestImprovements'],
|
|
1429
2328
|
list_sites: ['listSites'],
|
|
1430
2329
|
create_site: ['createSite'],
|
|
1431
2330
|
get_site_status: ['retrieveSite', 'retrieveLatestEvaluation', 'listFindings'],
|
|
1432
2331
|
list_findings: ['listFindings'],
|
|
1433
2332
|
get_finding: ['retrieveFinding', 'retrieveFindingFix'],
|
|
2333
|
+
publish_shopify_terms: ['publishShopifyTerms'],
|
|
1434
2334
|
run_scan: ['createScan'],
|
|
1435
2335
|
get_scan: ['retrieveScan'],
|
|
1436
2336
|
list_law_updates: ['listLawUpdates'],
|