@checkbox-site/mcp 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
- const intervalStr = z.string().max(20).describe('Billing interval: "day", "week", "month" or "year".');
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 in MAJOR units, e.g. 19.99. Or give amount.'),
39
- amount: z.number().int().positive().optional().describe('Recurring price in MINOR units, Stripe style: 1999 = $19.99.'),
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('Intervals between charges (default 1; 3 + month = quarterly).'),
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
- cancel_url: z.string().max(2048).optional().describe('https URL where subscribers cancel online. Strongly recommended: generated text uses it.'),
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
- const planTerms = z.object(planTermsShape);
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.'),
66
- 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.'),
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.'),
108
+ support_email: z.string().max(320).optional().describe('Support address for subscribers.'),
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
- .enum(['stripe', 'shopify', 'recharge', 'chargebee', 'recurly', 'paddle', 'woocommerce', 'custom'])
111
+ .enum(['stripe', 'shopify', 'recharge', 'chargebee', 'recurly', 'paddle', 'lemon_squeezy', 'woocommerce', 'custom'])
70
112
  .optional()
71
- .describe('Billing platform the code uses.'),
113
+ .describe('Billing platform.'),
72
114
  plans: z
73
- .array(z.object({
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 (trials, annual terms).'),
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.'),
@@ -132,9 +180,7 @@ async function resolveSite(api, site) {
132
180
  operation_id: 'createSetupSession',
133
181
  });
134
182
  }
135
- 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(', ')}.`, undefined, {
136
- sites: list.data.map((s) => ({ id: s.id, url: s.url })),
137
- });
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 })) });
138
184
  }
139
185
  /** The setup session to act on: the given id or the newest. */
140
186
  async function resolveSetupSession(api, id) {
@@ -148,32 +194,42 @@ async function resolveSetupSession(api, id) {
148
194
  }
149
195
  const statusLine = (v) => (typeof v === 'string' ? v.toUpperCase() : String(v));
150
196
  /**
151
- * One bullet per check that needs something from the human, in the user-facing labels (status.ts): NEEDS A FIX with the
197
+ * One bullet per check that needs something from the human, in the user-facing labels (status.ts): FIX NEEDED with the
152
198
  * one change, YOUR CALL with the question and the recommended answer, LIVE CHECK PENDING with how it completes. The
153
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.
154
206
  */
155
207
  export function complianceBullet(checkId, c, fallbackStatus, title) {
208
+ const st = ['pass', 'fail', 'needs_review', 'not_applicable'].includes(fallbackStatus) ? ` [${fallbackStatus}]` : '';
156
209
  if (!c)
157
- return fallbackStatus === 'fail' || fallbackStatus === 'needs_review' ? `${fallbackStatus === 'fail' ? 'FAIL' : 'REVIEW'} ${checkId}: ${title}` : null;
210
+ return fallbackStatus === 'fail' || fallbackStatus === 'needs_review' ? `${fallbackStatus === 'fail' ? 'FIX NEEDED' : 'YOUR CALL'} ${checkId}${st}: ${title}` : null;
158
211
  switch (c.status) {
159
212
  case 'needs_fix':
160
- return `NEEDS A FIX ${checkId}: ${c.fix ?? title}`;
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}`;
161
214
  case 'your_call':
162
- return `YOUR CALL ${checkId}: ${c.question?.text ?? title}${c.question?.recommended ? ` Recommended: ${c.question.recommended}` : ''}`;
215
+ return `YOUR CALL ${checkId}${st}: ${c.question?.text ?? title}${c.question?.recommended ? ` Recommended: ${c.question.recommended}` : ''}`;
163
216
  case 'pending_live_check':
164
- return `LIVE CHECK PENDING ${checkId}: ${c.pending_live_check ? `${c.pending_live_check.what} — ${c.pending_live_check.completes}` : title} (counts as compliant)`;
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)`;
165
218
  case 'compliant':
166
- return c.note ? `LOOKS COMPLIANT ${checkId}: ${c.note}` : null;
219
+ return c.note ? `${fallbackStatus === 'needs_review' ? 'NOTE' : 'CHECKS PASS'} ${checkId}${st}: ${c.note}` : null;
167
220
  default:
168
221
  return null;
169
222
  }
170
223
  }
171
224
  /** Bullets for the checks of a /v1/checks result that need something (the model reads these before the JSON). */
172
- function checkNotes(r, max = 8) {
225
+ function checkNotes(r, max = 8, covered) {
173
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);
174
230
  const lines = r.checks
175
231
  .map((c) => {
176
- const line = complianceBullet(c.check_id, c.compliance, c.status, c.title);
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);
177
233
  const cite = c.requirements?.[0]?.citation;
178
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 };
179
235
  })
@@ -184,16 +240,85 @@ function checkNotes(r, max = 8) {
184
240
  notes.push(`…and ${lines.length - max} more (see checks in the JSON).`);
185
241
  for (const w of r.warnings ?? [])
186
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);
187
247
  return notes;
188
248
  }
189
- const verdictHeadline = (what, r) => r.compliance
190
- ? `${what}: ${r.compliance.label} (machine verdict ${r.verdict}: ${r.summary.fail} failing, ${r.summary.needs_review} undecided, ${r.summary.pass} passing).`
191
- : `${what}: ${statusLine(r.verdict)} (${r.summary.fail} failing, ${r.summary.needs_review} need review, ${r.summary.pass} passing).`;
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
+ };
192
303
  /**
193
304
  * Keeps what the model needs to act and drops the bulk (everything stays available through the API): failing / unclear
194
305
  * checks with their first citations, the ids of passing checks, and failing / unclear requirements only. A check run
195
306
  * over "all" jurisdictions otherwise lists every state's requirement on every check (> 60 KB).
196
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
+ }
197
322
  function slimCheckResult(r) {
198
323
  const { checks, requirements, ...rest } = r;
199
324
  const open = (s) => s === 'fail' || s === 'needs_review';
@@ -203,12 +328,23 @@ function slimCheckResult(r) {
203
328
  checks: (checks ?? [])
204
329
  .filter((c) => needsSomething(c))
205
330
  .map(({ requirements: reqs, ...c }) => ({
206
- ...c,
331
+ ...withoutDuplicateEvidence(c),
207
332
  citations: [...new Set((reqs ?? []).map((q) => q.citation))].slice(0, 6),
208
333
  jurisdictions: [...new Set((reqs ?? []).map((q) => q.jurisdiction))],
209
334
  })),
210
335
  passing_checks: (checks ?? []).filter((c) => c.status === 'pass').map((c) => c.check_id),
211
- failing_requirements: (requirements ?? []).filter((q) => open(q.status)).map((q) => ({ id: q.id, citation: q.citation, status: q.status })),
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
+ })(),
212
348
  };
213
349
  }
214
350
  async function optional(p) {
@@ -243,12 +379,42 @@ async function pollDeviceToken(api, deviceCode, maxWaitMs, intervalMs) {
243
379
  }
244
380
  }
245
381
  /** What the first checks did: ran, or held because the site's domain is not verified yet (scan gate). */
246
- function firstCheckNote(next) {
382
+ function firstCheckNote(next, verify) {
247
383
  if (next?.type === 'verify_domain') {
248
- return 'Site registered, but Checkbox has not scanned or fetched anything from it: it only visits websites their owners have verified. Follow Next to verify the domain (ask the human to publish the DNS TXT record or file), then call get_setup_status with verify=true. Meanwhile check pages by sending their HTML (check_page_html / verify_task with html).';
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).`;
249
388
  }
250
389
  return 'First checks ran (site, Terms and policies fetched, first evaluation); follow Next.';
251
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, amount, currency, interval, trial_days, 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
+ }
252
418
  /**
253
419
  * setup_checkbox on an OAuth connection (one-click connector): the human approved in the browser already and the
254
420
  * connection itself is the credential, so nothing is pasted or saved. The site is registered by the setup session
@@ -263,12 +429,22 @@ async function setupOverOAuth(api, session, ctx) {
263
429
  notes.push(`Human review page: ${session.review_url}`);
264
430
  let next;
265
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;
266
435
  if (session.business?.site_url) {
267
436
  try {
268
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;
269
440
  next = v.setup_session?.next_action;
270
- firstCheck = { evaluated: v.evaluated ?? false, scan: v.scan ?? null, steps: v.setup_session?.steps?.map((st) => `${st.key}: ${st.status}`) ?? [] };
271
- notes.push(firstCheckNote(next));
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);
272
448
  }
273
449
  catch (err) {
274
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.`);
@@ -281,8 +457,41 @@ async function setupOverOAuth(api, session, ctx) {
281
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})` : ''}.`,
282
458
  notes,
283
459
  ...(next ? { next } : {}),
284
- data: { ...session, ...(firstCheck ? { first_check: firstCheck } : {}) },
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
+ })(),
465
+ };
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;
285
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;
286
495
  }
287
496
  /**
288
497
  * Facts given with the first setup_checkbox call, kept (in this MCP process) until the device_code poll applies them, so
@@ -300,19 +509,15 @@ const setupCheckbox = {
300
509
  name: 'setup_checkbox',
301
510
  title: 'Set up Checkbox for this project',
302
511
  description: [
303
- 'Start here when the user says "add Checkbox", "make my subscriptions compliant", or no Checkbox key is configured.',
304
- 'Checkbox monitors a subscription business against automatic-renewal laws (FTC ROSCA, California ARL, New York and ~26 other states).',
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).',
305
514
  '',
306
- 'Without an API key (first run): returns a link for the HUMAN to open and click Approve (they sign up with an email link or GitHub/Google if new; free, no card).',
307
- '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.',
308
- '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.',
309
- '',
310
- 'With an API key: starts a setup session for the project (no human step) and returns the setup checklist with next_action.',
311
- '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.',
312
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.',
313
517
  '',
314
- 'After this: follow next_action (usually install the snippet, then update_setup / get_setup_status with verify=true), then get_compliance_plan.',
315
- 'The `checkbox init` CLI does the same and also patches the layout file (pre-release: not on npm yet; never install an npm package named "checkbox", it is not ours).',
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 the stdio server saves the key to the git-ignored .env.local, showing a hint; the remote one returns it once: save it to .env.local, never commit it or ask for a pasted 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.',
316
521
  ].join('\n'),
317
522
  input: {
318
523
  ...setupFactsShape,
@@ -337,16 +542,30 @@ const setupCheckbox = {
337
542
  };
338
543
  }
339
544
  const notes = [];
545
+ const saved = ctx.saveKeys ? await ctx.saveKeys(res).catch((err) => ({ ok: false, error: err instanceof Error ? err.message : String(err) })) : null;
340
546
  if (ctx.adoptKeys) {
341
547
  await ctx.adoptKeys(res);
342
548
  notes.push('This MCP server now uses the new test-mode key for the rest of the session (more tools are now available).');
343
- notes.push('Save CHECKBOX_SECRET_KEY to .env.local (and set CHECKBOX_API_KEY in the MCP server config) so future sessions keep working.');
549
+ if (saved?.ok) {
550
+ notes.push(`The secret key was saved to ${saved.file} in this project${saved.gitignored ? ' (git-ignored)' : ' (add it to .gitignore: this folder is not a git repository)'} and is not shown here: never print, copy or commit it. ${saved.note ?? ''}This server reads it from there on its next start.`.replace(/ +/g, ' '));
551
+ }
552
+ else if (saved) {
553
+ notes.push(`The key could not be saved in this project (${saved.error.slice(0, 160)}), and it is not shown here. This server uses it until it stops; for a lasting key, the human creates one in the Checkbox dashboard (Agent & API) and sets CHECKBOX_API_KEY in the MCP server config.`);
554
+ }
555
+ else {
556
+ notes.push('Save CHECKBOX_SECRET_KEY to .env.local (and set CHECKBOX_API_KEY in the MCP server config) so future sessions keep working.');
557
+ }
344
558
  }
345
559
  else {
346
560
  notes.push('This remote MCP connection is stateless and keeps no key: save CHECKBOX_SECRET_KEY to .env.local and pass it on the connection (Authorization: Bearer) for the next calls:');
347
561
  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.`);
348
562
  }
349
- 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).');
563
+ if (!saved)
564
+ 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).');
565
+ else
566
+ notes.push('The publishable key (pk_) is safe in browser code (e.g. NEXT_PUBLIC_CHECKBOX_PUBLISHABLE_KEY).');
567
+ // Round 16 (pilot 15, sveltekit / change-guard): two public keys in one response, unexplained.
568
+ 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.");
350
569
  // In the same call, with the new key: apply the facts given (they are not lost between the two calls), run the
351
570
  // first checks, and return the setup checklist's next step — exactly what `checkbox init` does.
352
571
  // Normal-round pilot (sveltekit): the first checks failed with "No API key provided" and the facts given with the
@@ -354,26 +573,54 @@ const setupCheckbox = {
354
573
  const keyed = ctx.apiForKey?.(res.secret_key) ?? (ctx.adoptKeys ? ctx.api : undefined);
355
574
  let next;
356
575
  let firstCheck;
576
+ let site = res.site ?? null;
577
+ let inputWarnings = [];
578
+ let snippetKey;
357
579
  if (keyed && res.setup_session) {
358
580
  try {
359
581
  const facts = { ...(PENDING_SETUP_FACTS.get(args.device_code) ?? {}), ...pick(args, Object.keys(setupFactsShape)) };
360
582
  PENDING_SETUP_FACTS.delete(args.device_code);
361
- if (Object.keys(facts).length)
362
- await keyed.request('POST', `/v1/setup_sessions/${res.setup_session}`, { body: facts });
583
+ // Pilot round 13 (change-guard): `agent` given with the device_code call (or known only now: a stateless
584
+ // remote connection has no client name on the first call) was never stored, so the session said agent null.
585
+ const agentName = args.agent ?? ctx.key.appName ?? ctx.clientName;
586
+ if (agentName)
587
+ facts.client = { name: 'checkbox-mcp', version: ctx.serverVersion, agent: String(agentName).slice(0, 100) };
588
+ if (Object.keys(facts).length) {
589
+ const upd = await keyed.request('POST', `/v1/setup_sessions/${res.setup_session}`, { body: facts });
590
+ if (Array.isArray(upd?.warnings))
591
+ inputWarnings = upd.warnings;
592
+ }
363
593
  const v = await keyed.request('POST', `/v1/setup_sessions/${res.setup_session}/verify`, { body: {} });
364
594
  next = v.setup_session?.next_action;
365
- firstCheck = { evaluated: v.evaluated ?? false, scan: v.scan ?? null, steps: v.setup_session?.steps?.map((st) => `${st.key}: ${st.status}`) ?? [] };
595
+ snippetKey = v.setup_session?.install?.snippet_key;
596
+ // Pilot round 13 (change-guard): the site created by these facts was missing from the result (site: null next
597
+ // to "site: done"), and the JSON next_action was the device token's, not the checklist's.
598
+ if (typeof v.setup_session?.site === 'string')
599
+ site = v.setup_session.site;
600
+ 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 } : {}) };
366
601
  notes.push(firstCheckNote(next));
602
+ const pl = sitePlansNote(v.setup_session?.site_plans);
603
+ if (pl)
604
+ notes.push(pl);
367
605
  }
368
606
  catch (err) {
369
607
  notes.push(`First checks did not run yet (${err instanceof Error ? err.message.slice(0, 160) : 'error'}); call get_setup_status with verify=true.`);
370
608
  }
371
609
  }
372
610
  return {
373
- headline: `Approved. Checkbox account ready${res.site ? ` with test-mode site ${res.site}` : ''}; setup session ${res.setup_session ?? '(none)'}.`,
611
+ headline: `Approved. Checkbox account ready${site ? ` with test-mode site ${site}` : ''}; setup session ${res.setup_session ?? '(none)'}.`,
374
612
  notes,
375
613
  ...(next ? { next } : {}),
376
- data: { ...res, env: { CHECKBOX_SECRET_KEY: res.secret_key, ...(res.publishable_key ? { CHECKBOX_PUBLISHABLE_KEY: res.publishable_key } : {}) }, ...(firstCheck ? { first_check: firstCheck } : {}) },
614
+ data: {
615
+ ...(saved ? withoutSecrets(res) : res),
616
+ site,
617
+ ...(next ? { next_action: next } : {}),
618
+ ...(saved?.ok ? { key_file: saved.file } : {}),
619
+ env: { CHECKBOX_SECRET_KEY: saved ? `${hintOf(res.secret_key)} (${saved.ok ? `saved in ${saved.file}` : 'not shown'})` : res.secret_key, ...(res.publishable_key ? { CHECKBOX_PUBLISHABLE_KEY: res.publishable_key } : {}) },
620
+ public_keys: labelledPublicKeys(snippetKey ?? res.install?.snippet_key, res.publishable_key),
621
+ ...(firstCheck ? { first_check: firstCheck } : {}),
622
+ ...(inputWarnings.length ? { warnings: inputWarnings } : {}),
623
+ },
377
624
  };
378
625
  }
379
626
  if (!api.hasKey) {
@@ -412,13 +659,42 @@ const setupCheckbox = {
412
659
  const notes = session.review_url ? [`Human review page: ${session.review_url}`] : [];
413
660
  if (session.next_action?.type === 'verify_domain')
414
661
  notes.push(firstCheckNote(session.next_action));
662
+ const plansLine = sitePlansNote(session.site_plans);
663
+ if (plansLine)
664
+ notes.push(plansLine);
415
665
  return {
416
666
  headline: `Setup session ${session.id} started${session.business?.site_url ? ` for ${untrusted(session.business.site_url, 100)}` : ''}${session.site ? ` (test-mode site ${session.site})` : ''}.`,
417
667
  notes,
418
- data: session,
668
+ data: (() => {
669
+ const keys = labelledPublicKeys(session.install?.snippet_key, session.publishable_key);
670
+ return keys.length ? { ...withoutLegacyScore(session), public_keys: keys } : withoutLegacyScore(session);
671
+ })(),
419
672
  };
420
673
  },
421
674
  };
675
+ /** "ck_test_…ab12": recognisable, never usable. */
676
+ function hintOf(key) {
677
+ if (!key)
678
+ return null;
679
+ const m = /^([a-z]{2}_(?:live|test)_)/.exec(key);
680
+ return `${m?.[1] ?? key.slice(0, 3)}…${key.slice(-4)}`;
681
+ }
682
+ /** The device token without its secret keys (hints only): what the stdio server returns once it saved them. */
683
+ function withoutSecrets(res) {
684
+ return { ...res, secret_key: hintOf(res.secret_key), ...(res.live_secret_key ? { live_secret_key: hintOf(res.live_secret_key) } : {}) };
685
+ }
686
+ /**
687
+ * Round 20 (pilot 19 item 16, sveltekit / change-guard: a pk_ site key and a pk_test_ key side by side, unlabelled): each
688
+ * public key says what it is for.
689
+ */
690
+ export function labelledPublicKeys(snippetKey, publishableKey) {
691
+ const out = [];
692
+ if (typeof snippetKey === 'string' && snippetKey)
693
+ 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).' });
694
+ if (typeof publishableKey === 'string' && publishableKey)
695
+ 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.' });
696
+ return out;
697
+ }
422
698
  const getSetupStatus = {
423
699
  name: 'get_setup_status',
424
700
  title: 'Get the setup checklist (optionally re-check the site)',
@@ -439,14 +715,30 @@ const getSetupStatus = {
439
715
  if (!keyAllows(key, 'setup_sessions:write'))
440
716
  throw new ToolError('verify=true needs a key with setup_sessions write access; call again without verify.');
441
717
  const res = await api.request('POST', `/v1/setup_sessions/${enc(id)}/verify`, { body: {} });
442
- return { headline: `Re-checked setup session ${id}.`, data: res, next: nextActionOf(res) ?? nextActionOf(res.session) };
718
+ const notes = [];
719
+ if (res.domain_verified)
720
+ 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})`}.`);
721
+ if (res.unverified_scan?.queued)
722
+ notes.push('First read-only scan queued (unverified: no checkout is submitted until you verify the domain).');
723
+ else if (res.unverified_scan?.reason)
724
+ notes.push(`No new read-only scan: ${untrusted(res.unverified_scan.reason, 200)}.`);
725
+ const ro = unverifiedScanNote(res.setup_session?.unverified_scan);
726
+ if (ro && !res.unverified_scan?.queued)
727
+ notes.push(ro);
728
+ const pl = sitePlansNote(res.setup_session?.site_plans);
729
+ if (pl)
730
+ notes.push(pl);
731
+ return { headline: `Re-checked setup session ${id}.`, ...(notes.length ? { notes } : {}), data: withoutLegacyScore(res), next: nextActionOf(res) ?? nextActionOf(res.session) };
443
732
  }
444
733
  const session = await api.request('GET', `/v1/setup_sessions/${enc(id)}`);
445
734
  const open = (session.steps ?? []).filter((s) => s.status && !['done', 'skipped', 'not_applicable'].includes(s.status));
735
+ const ro = unverifiedScanNote(session.unverified_scan);
736
+ const plansLine = sitePlansNote(session.site_plans);
446
737
  return {
447
738
  headline: `Setup session ${session.id}: ${session.status}; ${open.length} checklist step(s) open.`,
448
- notes: open.slice(0, 8).map((s) => `${s.status}: ${s.title ?? s.key}`),
449
- data: session,
739
+ // The policies step says what an unverified preview read (or that the pages wait for domain verification).
740
+ 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] : [])],
741
+ data: withoutLegacyScore(session),
450
742
  };
451
743
  },
452
744
  };
@@ -472,9 +764,24 @@ const updateSetup = {
472
764
  const id = await resolveSetupSession(api, args.setup_session);
473
765
  const body = pick(args, [...Object.keys(setupFactsShape), 'snippet_installed_in', 'env_written', 'live_site', 'refetch_policies']);
474
766
  const session = await api.request('POST', `/v1/setup_sessions/${enc(id)}`, { body });
475
- return { headline: `Updated setup session ${session.id}.`, data: session };
767
+ // Round 20 (pilot 19 item 6): a plans change re-evaluates the site; say where the new plan's tasks show up.
768
+ const notes = Array.isArray(args.plans) && args.plans.length
769
+ ? ['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.']
770
+ : [];
771
+ return { headline: `Updated setup session ${session.id}.`, notes, data: withoutLegacyScore(session) };
476
772
  },
477
773
  };
774
+ /** Round 13 (pilot 12, codex: "sent" while nothing new arrived): the summary email's real delivery status, with when. */
775
+ export function summaryEmailLine(status, d) {
776
+ const when = d?.at ? ` at ${new Date(d.at * 1000).toISOString().slice(0, 16).replace('T', ' ')} UTC` : '';
777
+ if (status === 'sent')
778
+ return `sent${d?.to ? ` to ${d.to}` : ''} (the mail server accepted it${when}; it is sent once per setup)`;
779
+ if (status === 'failed')
780
+ return `failed${d?.error ? ` (${d.error})` : ''}; call complete_setup again to retry`;
781
+ if (status === 'not_configured')
782
+ return 'not sent: this deployment has no email delivery';
783
+ return 'not sent';
784
+ }
478
785
  const completeSetup = {
479
786
  name: 'complete_setup',
480
787
  title: 'Finish setup and email the human a summary',
@@ -486,15 +793,32 @@ const completeSetup = {
486
793
  const id = await resolveSetupSession(api, args.setup_session);
487
794
  const session = await api.request('POST', `/v1/setup_sessions/${enc(id)}/complete`, { body: {} });
488
795
  return {
489
- headline: `Setup ${session.id} completed; summary email: ${session.owner_email ?? 'not sent'}.`,
796
+ headline: `Setup ${session.id} completed; summary email: ${summaryEmailLine(session.owner_email, session.owner_email_detail)}.`,
490
797
  notes: session.review_url ? [`Human review page: ${session.review_url}`] : [],
491
- data: session,
798
+ data: withoutLegacyScore(session),
492
799
  };
493
800
  },
494
801
  };
802
+ // ---------------------------------------------------------------------------------------------------------------------
803
+ // Compliance plan loop
804
+ // ---------------------------------------------------------------------------------------------------------------------
805
+ /**
806
+ * Round 21 (pilot 20 item 8, lawyer): the plan status in words ("Checks pass"), never the bare machine value `compliant`
807
+ * an agent would repeat to a merchant. Older APIs without status_label: the same mapping here.
808
+ */
809
+ const PLAN_STATUS_WORDS = { action_required: 'Fixes needed', verifying: 'Checking your changes', compliant: 'Checks pass', blocked: 'Your call' };
810
+ export function planStatusWords(status, label) {
811
+ return label || PLAN_STATUS_WORDS[status] || status;
812
+ }
813
+ /** "Old task (covered by: Open task)": the open task sharing a check with a superseded one. Pure, exported for tests. */
814
+ export function coveredLine(t, open) {
815
+ const ids = t.check_ids ?? [];
816
+ const by = open.find((o) => o.key !== t.key && (o.check_ids ?? []).some((c) => ids.includes(c)));
817
+ return by ? `${t.title} (covered by: ${by.title})` : `${t.title} (covered by another open task)`;
818
+ }
495
819
  /** The plan with each task reduced to what an agent needs to choose the next one (the full task: get_task). */
496
820
  export function slimPlan(plan) {
497
- const clip = (v, n) => (typeof v === 'string' && v.length > n ? `${v.slice(0, n - 1)}…` : v);
821
+ const clip = clipWords;
498
822
  const tasks = (plan.tasks ?? []).map((t) => {
499
823
  const full = t;
500
824
  const change = (full.change ?? {});
@@ -518,15 +842,28 @@ export function slimPlan(plan) {
518
842
  });
519
843
  return { ...plan, tasks: tasks };
520
844
  }
845
+ /**
846
+ * One-time review hint: when a site's plan is compliant, the API hands out the prompt once per site
847
+ * (claimReviewPrompt). Never fails the tool: older servers or a missing scope simply mean no hint.
848
+ */
849
+ async function reviewHint(api, site, trigger) {
850
+ try {
851
+ const p = await api.request('POST', `/v1/sites/${enc(site)}/review_prompt`, { body: { trigger } });
852
+ return p.show && p.message ? [`One-time: ${p.message}`] : [];
853
+ }
854
+ catch {
855
+ return [];
856
+ }
857
+ }
521
858
  const getCompliancePlan = {
522
859
  name: 'get_compliance_plan',
523
860
  title: 'Get the compliance plan (ordered tasks)',
524
861
  description: [
525
- 'The main tool after setup. Returns the ordered tasks that make the site compliant with automatic-renewal laws, recomputed from the latest observations.',
862
+ 'The main tool after setup. Returns the ordered tasks that make the site pass the automatic-renewal checks, recomputed from the latest observations.',
526
863
  '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).',
527
- 'Plan status: action_required (apply open tasks), verifying (waiting for confirming observations), compliant (done — "pending_live_check" tasks count as compliant: they complete on their own or with a proof run), blocked (only human questions — "Your call" — left).',
864
+ '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).',
528
865
  '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).',
529
- 'Loop: apply the first open required task in the code → verify_task → get_compliance_plan → … until status is compliant. Tasks with status "manual" carry a question with a recommended answer: ask the HUMAN, then answer_task. Never invent answers.',
866
+ '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.',
530
867
  ].join('\n'),
531
868
  input: { site: siteId.optional() },
532
869
  scopes: ['compliance_plans:read'],
@@ -537,17 +874,39 @@ const getCompliancePlan = {
537
874
  const open = (plan.tasks ?? []).filter((t) => !['done', 'wont_fix'].includes(t.status));
538
875
  const required = open.filter((t) => (t.tier ?? 'required') === 'required');
539
876
  const optional = open.filter((t) => (t.tier ?? 'required') !== 'required');
877
+ // One to-do list: the same ordered open tasks (titles, order, counts) as the dashboard, report, emails and CLI.
878
+ const todo = planTodo((plan.tasks ?? []));
879
+ const rest = required.filter((t) => !todo.some((x) => x.id === t.id));
880
+ const { score: _score, insurable: _insurable, ...shown } = plan;
540
881
  return {
541
- headline: `Plan for ${site}: ${plan.compliance?.label ?? statusLine(plan.status)} (plan ${plan.status}), score ${plan.score ?? 'n/a'}. ${plan.summary ?? ''}`,
882
+ // Audit P2: one Next line (the plan's next_action below); the summary's own "Next: …" is not repeated.
883
+ headline: `Plan for ${site}: ${plainLabel(plan.compliance?.label ?? 'Not checked yet')} (plan status: ${planStatusWords(plan.status, plan.status_label)}).`,
542
884
  notes: [
543
- ...required.map((t) => `#${t.order ?? '?'} ${t.id} [${t.status}${t.priority ? `, ${t.priority}` : ''}${t.change?.type ? `, ${t.change.type}` : ''}] ${t.title}`),
544
- ...(optional.length ? [`Optional suggestions (${plan.protection_level ?? 'balanced'} protection; never block compliant): ${optional.map((t) => t.title).join('; ')}`] : []),
545
- ...(plan.optional_improvements ? ['Optional (never blocking, not part of compliance): compliant conversion suggestions for this site with `suggest_improvements`.'] : []),
885
+ ...(todo.length ? [`To do (${todo.length}, in order):`] : []),
886
+ ...todo.map((t, i) => `${i + 1}. ${t.id} ${todoLine(t)}`),
887
+ ...rest.map((t) => `${t.id} [${t.status}${t.priority ? `, ${t.priority}` : ''}${t.change?.type ? `, ${t.change.type}` : ''}] ${t.title}`),
888
+ // Round 14: a task closed because it no longer applies is "Not applicable", never "done". Round 16 (pilot 15,
889
+ // codex): a task closed by a passing verification is "Done (verified)", never "Not applicable".
890
+ ...(() => {
891
+ const closed = (plan.tasks ?? []);
892
+ const verified = closed.filter((t) => t.status === 'done' && t.closed_reason === 'verified' && t.compliance_status !== 'not_applicable');
893
+ // Round 20 (pilot 19 item 9): a task merged into another open task is "covered by" it, never "not applicable".
894
+ const covered = closed.filter((t) => t.closed_reason === 'superseded');
895
+ const na = closed.filter((t) => t.compliance_status === 'not_applicable' && t.closed_reason !== 'verified' && t.closed_reason !== 'superseded');
896
+ return [
897
+ ...(verified.length ? [`Done (verified): ${verified.map((t) => t.title).join('; ')}`] : []),
898
+ ...(covered.length ? [`Merged into another task: ${covered.map((t) => coveredLine(t, open)).join('; ')}`] : []),
899
+ ...(na.length ? [`Not applicable (closed without a change, nothing observed passing): ${na.map((t) => t.title).join('; ')}`] : []),
900
+ ];
901
+ })(),
902
+ ...(optional.length ? [`Optional suggestions (${plan.protection_level ?? 'balanced'} protection; never block the checks from passing): ${optional.map((t) => t.title).join('; ')}`] : []),
903
+ ...(plan.optional_improvements ? ['Optional (never blocking, not part of compliance): conversion suggestions for this site with `suggest_improvements`.'] : []),
546
904
  'Each task below is a summary; call get_task with its id for the full change (diff, copy or params) before applying it.',
905
+ ...(plan.status === 'compliant' ? await reviewHint(api, site, 'plan_compliant') : []),
547
906
  ],
548
907
  // Normal-round pilot (sveltekit): the full plan (53K characters) was cut mid-JSON by the client. The plan here is a
549
908
  // compact summary of every task; get_task returns one task in full.
550
- data: slimPlan(plan),
909
+ data: slimPlan(shown),
551
910
  };
552
911
  },
553
912
  };
@@ -560,9 +919,23 @@ const getTask = {
560
919
  annotations: { ...ro, title: 'Task' },
561
920
  async run(args, { api }) {
562
921
  const task = await api.request('GET', `/v1/tasks/${enc(String(args.task))}`);
563
- return { headline: `Task ${task.id} [${task.status}]: ${task.title}`, data: task };
922
+ // Audit P3: a task citing every state's law listed dozens of citations; the first 8 and a count are enough here.
923
+ const cites = task.requirement?.citations;
924
+ const shown = Array.isArray(cites) && cites.length > 8 ? { ...task, requirement: { ...task.requirement, citations: cites.slice(0, 8), citations_more: cites.length - 8 } } : task;
925
+ const word = task.compliance_status === 'not_applicable' ? 'not applicable' : task.status;
926
+ return { headline: `Task ${task.id} [${word}]: ${task.title}`, data: shown };
564
927
  },
565
928
  };
929
+ /** Audit: the pre-check's per-check results, one compact line each (agents only saw the overall verdict). */
930
+ export function precheckLines(pre, max = 12) {
931
+ const checks = pre?.checks ?? [];
932
+ if (!checks.length)
933
+ return [];
934
+ const order = { fail: 0, needs_review: 1, missing_data: 1, pass: 2, not_applicable: 3 };
935
+ const sorted = [...checks].sort((a, b) => (order[a.status] ?? 1) - (order[b.status] ?? 1));
936
+ 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)}` : ''}`);
937
+ return [`Pre-check (${pre?.verdict ?? 'n/a'}), per check:`, ...lines, ...(checks.length > max ? [`and ${checks.length - max} more (data.precheck.checks)`] : [])];
938
+ }
566
939
  const verifyTask = {
567
940
  name: 'verify_task',
568
941
  title: 'Verify a task after applying its change',
@@ -578,29 +951,56 @@ const verifyTask = {
578
951
  task: z.string().max(100).describe('Task id (task_…).'),
579
952
  html: z.string().max(950_000).optional().describe('Page tasks: the rendered page HTML.'),
580
953
  stripe_checkout_session: z.record(z.string(), z.unknown()).optional().describe('Stripe tasks: the params your code passes to stripe.checkout.sessions.create.'),
954
+ stripe_checkout_sessions: z.array(z.record(z.string(), z.unknown())).max(10).optional().describe('Stripe tasks, one params object per plan.'),
581
955
  email: z
582
956
  .object({
583
- kind: z.enum(['acknowledgment', 'trial_reminder', 'renewal_reminder', 'price_change', 'annual_terms', 'cancellation_confirmation']).optional(),
957
+ kind: emailKind.optional(),
584
958
  subject: z.string().max(1000),
585
959
  text: z.string().max(100_000).optional(),
586
960
  html: z.string().max(100_000).optional(),
587
961
  })
588
962
  .optional()
589
- .describe('Email tasks: the new template.'),
963
+ .describe('Email tasks: the new template (passes when it matches one plan fully).'),
964
+ plan: z.string().min(1).max(200).optional().describe('Email tasks: only this plan (name).'),
965
+ price_id: z.string().min(1).max(200).optional().describe('Email tasks: only this plan (Price id).'),
590
966
  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.'),
591
967
  wait,
592
968
  },
593
969
  scopes: ['compliance_plans:write'],
594
970
  annotations: { ...write(true, true), title: 'Verify task' },
595
971
  async run(args, { api }) {
596
- const body = pick(args, ['html', 'stripe_checkout_session', 'email', 'recheck']);
972
+ const body = pick(args, ['html', 'stripe_checkout_session', 'stripe_checkout_sessions', 'email', 'plan', 'price_id', 'recheck']);
973
+ if (body.email && typeof body.email === 'object' && body.email.kind)
974
+ body.email = { ...body.email, kind: checkKind(body.email.kind) };
597
975
  const v = await api.request('POST', `/v1/tasks/${enc(String(args.task))}/verify`, {
598
976
  query: { wait: args.wait ?? 25 },
599
977
  body,
600
978
  });
979
+ const hint = v.plan_status === 'compliant' && v.site ? await reviewHint(api, v.site, 'last_task_verified') : [];
601
980
  return {
602
- headline: `Verification ${v.id} of ${String(args.task)}: ${statusLine(v.status)}${v.plan_status ? `; plan is now ${v.plan_status}` : ''}.`,
603
- notes: v.status === 'failed' ? ['The change did not satisfy the check: read diff (expected vs observed), fix the code, and call verify_task again.'] : v.status === 'pending' ? ['Waiting for a confirming observation; call get_verification later (or continue with other tasks).'] : [],
981
+ 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)}.` : ''}`,
982
+ notes: [
983
+ ...precheckLines(v.precheck),
984
+ ...((v.status === 'failed' || v.status === 'not_checked') && v.next_action?.type === 'verify_domain'
985
+ ? ['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.']
986
+ : v.next_action?.type === 'retry_later'
987
+ ? ['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.']
988
+ : v.next_action?.type === 'live_check_pending' && v.status === 'pending'
989
+ ? // Round 14 (pilot 13): Checkbox Notices sends this email; never "add the BCC".
990
+ [v.next_action.description ?? 'Checkbox Notices sends this email: nothing to add to your code. Continue with the next task.']
991
+ : v.next_action?.type === 'bcc_then_continue'
992
+ ? ['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).']
993
+ : v.status === 'failed'
994
+ ? ['The change did not satisfy the check: read diff (expected vs observed), fix the code, and call verify_task again.']
995
+ : v.status === 'not_checked'
996
+ ? ['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.']
997
+ : v.status === 'pending_live_check'
998
+ ? ['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).']
999
+ : v.status === 'pending'
1000
+ ? ['Waiting for a confirming observation; call get_verification later (or continue with other tasks).']
1001
+ : []),
1002
+ ...hint,
1003
+ ],
604
1004
  data: v,
605
1005
  };
606
1006
  },
@@ -608,7 +1008,7 @@ const verifyTask = {
608
1008
  const answerTask = {
609
1009
  name: 'answer_task',
610
1010
  title: 'Record the human\'s answer to a manual task',
611
- 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, or confirmed / needs_change). "no" / "needs_change" turns the task into a change you can apply.',
1011
+ 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.',
612
1012
  input: {
613
1013
  task: z.string().max(100).describe('Task id (task_…) with status manual.'),
614
1014
  answer: z.string().max(40).describe('One of the task.question.options, as the human chose.'),
@@ -618,25 +1018,31 @@ const answerTask = {
618
1018
  annotations: { ...write(false), title: 'Answer task' },
619
1019
  async run(args, { api }) {
620
1020
  const task = await api.request('POST', `/v1/tasks/${enc(String(args.task))}/answer`, { body: pick(args, ['answer', 'note']) });
1021
+ // Round 21 (pilot 20 item 16): "done" on a task Checkbox already closed succeeds without recording anything.
1022
+ if (task.status === 'done' && task.next_action?.type === 'retrieve_plan')
1023
+ return { headline: `${task.id} was already done (Checkbox closed it itself): nothing to answer, nothing recorded.`, data: task };
621
1024
  return { headline: `Recorded the answer on ${task.id}; task is now ${task.status}.`, data: task };
622
1025
  },
623
1026
  };
624
1027
  const verifySite = {
625
1028
  name: 'verify_site',
626
1029
  title: 'Re-check the whole site now',
627
- 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.',
1030
+ 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.',
628
1031
  input: { site: siteId.optional(), wait },
629
1032
  scopes: ['compliance_plans:write'],
630
1033
  annotations: { ...write(true, true), title: 'Verify site' },
631
1034
  async run(args, { api }) {
632
1035
  const site = await resolveSite(api, args.site);
633
1036
  const v = await api.request('POST', `/v1/sites/${enc(site)}/verify`, { query: { wait: args.wait ?? 25 }, body: {} });
1037
+ // Pilot 11: say what was re-read from the billing platform (the note's first sentence), so a stale answer is visible.
1038
+ 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];
634
1039
  // A whole-site check that finds open checks produced the to-do list; it is not a failed change.
635
- const headline = v.next_action?.type === 'verify_domain'
1040
+ const base = v.next_action?.type === 'verify_domain'
636
1041
  ? `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.`
637
1042
  : v.status === 'failed'
638
- ? `${v.next_action?.type === 'first_check_complete' ? 'First check complete' : 'Site check complete'} (${v.id}): ${v.diff?.length ?? 0} check(s) do not pass yet — this is the to-do list, not a failed change.${v.plan_status ? ` Plan: ${v.plan_status}.` : ''}`
1043
+ ? `${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}.` : ''}`
639
1044
  : `Site check ${v.id}: ${statusLine(v.status)}${v.plan_status ? `; plan is ${v.plan_status}` : ''}.`;
1045
+ const headline = reread ? `${base} ${reread}` : base;
640
1046
  return { headline, data: v };
641
1047
  },
642
1048
  };
@@ -644,12 +1050,13 @@ const getVerification = {
644
1050
  name: 'get_verification',
645
1051
  title: 'Get a verification result',
646
1052
  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.',
647
- input: { verification: z.string().max(100).describe('Verification id (ver_…).'), wait: wait },
1053
+ input: { verification: z.string().max(100).describe('ver_…, or task_… (its newest)'), wait: wait },
648
1054
  scopes: ['compliance_plans:read'],
649
1055
  annotations: { ...ro, title: 'Verification' },
650
1056
  async run(args, { api }) {
651
1057
  const v = await api.request('GET', `/v1/verifications/${enc(String(args.verification))}`, { query: { wait: args.wait ?? 0 } });
652
- return { headline: `Verification ${v.id}: ${statusLine(v.status)}.`, data: v };
1058
+ const notes = precheckLines(v.precheck);
1059
+ return { headline: `Verification ${v.id}: ${statusLine(v.status)}.`, ...(notes.length ? { notes } : {}), data: v };
653
1060
  },
654
1061
  };
655
1062
  // ---------------------------------------------------------------------------------------------------------------------
@@ -657,7 +1064,7 @@ const getVerification = {
657
1064
  // ---------------------------------------------------------------------------------------------------------------------
658
1065
  /** createTestRun body from the tool's arguments (top-level shortcuts merged into `stripe`). Exported for tests. */
659
1066
  export function startTestRunBody(args) {
660
- const body = pick(args, ['mode', 'type', 'advance_time', 'cancel', 'email_wait_seconds', 'region', 'stripe', 'generic']);
1067
+ const body = pick(args, ['mode', 'type', 'advance_time', 'cancel', 'email_wait_seconds', 'region', 'plan', 'stripe', 'generic']);
661
1068
  const shortcuts = pick(args, ['checkout_url', 'test_account', 'checkout_steps', 'cancel_url', 'cancel_steps']);
662
1069
  if (Object.keys(shortcuts).length && body.type !== 'generic') {
663
1070
  body.stripe = { ...shortcuts, ...(body.stripe ?? {}) };
@@ -666,13 +1073,41 @@ export function startTestRunBody(args) {
666
1073
  }
667
1074
  return body;
668
1075
  }
1076
+ /**
1077
+ * Round 22 (founder decision 2): the agent stores the site's ONE Stripe TEST-mode restricted key itself
1078
+ * (createTestCredential). Live keys and sk_test_ secret keys are refused by the API with what to do instead.
1079
+ */
1080
+ const storeStripeTestKey = {
1081
+ name: 'store_stripe_test_key',
1082
+ title: 'Store the Stripe TEST restricted key (rk_test_)',
1083
+ description: 'Stores the site\'s ONE Stripe key in TEST mode: a restricted key (rk_test_…) from the project\'s env. Checked with Stripe; on a test-mode site it also connects Stripe (checks, fixes after the human approves, test runs). The human sees it on the dashboard and can revoke it. Live and sk_test_ keys are refused (the error says what to do). Never ask the human to paste a key into the chat.',
1084
+ input: {
1085
+ site: siteId.optional(),
1086
+ secret_key: z.string().min(10).max(255).describe('rk_test_… key. Stored encrypted; never returned.'),
1087
+ confirm_account_mismatch: z.boolean().optional().describe('Human said yes'),
1088
+ type: z.enum(['stripe_test', 'stripe']).optional().describe('Optional: "stripe_test" (the REST body\'s type; "stripe" is accepted too). Always stored as a Stripe TEST key.'),
1089
+ },
1090
+ scopes: ['test_runs:write'],
1091
+ annotations: { ...write(true, true), title: 'Store Stripe test key' },
1092
+ async run(args, { api }) {
1093
+ const site = await resolveSite(api, args.site);
1094
+ const cred = await api.request('POST', `/v1/sites/${enc(site)}/test_credentials`, {
1095
+ body: { type: 'stripe_test', secret_key: String(args.secret_key ?? ''), ...(args.confirm_account_mismatch === true ? { confirm_account_mismatch: true } : {}) },
1096
+ });
1097
+ return {
1098
+ headline: `Stripe test key ${cred.key_hint} stored for ${site} (checked with Stripe). The human sees it on the dashboard and can revoke it.`,
1099
+ data: cred,
1100
+ next: cred.next_action ?? { type: 'continue_setup', message: 'Call get_setup_status for the next step (start_test_run once a checkout config is registered).', operation_id: 'retrieveSetupSession' },
1101
+ };
1102
+ },
1103
+ };
669
1104
  const startTestRun = {
670
1105
  name: 'start_test_run',
671
1106
  title: 'Run a synthetic test subscription (test mode)',
672
1107
  description: [
673
1108
  '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.',
674
- 'Needs a Stripe TEST key stored for the site (setup asks the human) — if missing, the error\'s next_action says how (call_api POST /v1/sites/{site}/test_credentials). For non-Stripe sites use type "generic".',
675
- 'Takes ~5-20 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.',
1109
+ 'Needs a Stripe TEST-mode restricted key (rk_test_…) stored for the site: store it yourself with store_stripe_test_key (one key for checks, fixes and test runs; live keys stay with the human in the dashboard). Never ask the human to paste a key into the chat. If missing, the error\'s next_action says so. For non-Stripe sites use type "generic".',
1110
+ '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.',
676
1111
  '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.',
677
1112
  ].join('\n'),
678
1113
  input: {
@@ -683,6 +1118,7 @@ const startTestRun = {
683
1118
  cancel: z.boolean().optional().describe('Cancel online at the end (default true).'),
684
1119
  email_wait_seconds: z.number().int().min(15).max(900).optional().describe('How long to wait for each email (default 120).'),
685
1120
  region: z.string().max(10).optional().describe('State the synthetic shopper visits from, e.g. "US-CA".'),
1121
+ plan: z.string().max(200).optional().describe('Plan to run: a registered checkout config name or a price_… its line_items use.'),
686
1122
  stripe: z
687
1123
  .object({
688
1124
  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.'),
@@ -727,8 +1163,12 @@ const startTestRun = {
727
1163
  const getTestRun = {
728
1164
  name: 'get_test_run',
729
1165
  title: 'Get a test run (steps, observations, fixes)',
730
- 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" (poll again in a minute or two). 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.',
731
- input: { test_run: z.string().max(100).optional().describe('Test run id (run_…). Default: the newest run of the site.'), site: siteId.optional() },
1166
+ 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.',
1167
+ input: {
1168
+ test_run: z.string().max(100).optional().describe('Test run id (run_…). Default: the newest run of the site.'),
1169
+ site: siteId.optional(),
1170
+ 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.'),
1171
+ },
732
1172
  scopes: ['test_runs:read'],
733
1173
  annotations: { ...ro, title: 'Test run' },
734
1174
  async run(args, { api }) {
@@ -740,10 +1180,14 @@ const getTestRun = {
740
1180
  throw new ToolError(`Site ${site} has no test run yet.`, { type: 'start_test_run', message: 'Call start_test_run.', operation_id: 'createTestRun' });
741
1181
  id = list.data[0].id;
742
1182
  }
743
- const run = await api.request('GET', `/v1/test_runs/${enc(id)}`);
1183
+ const wait = typeof args.wait === 'number' && args.wait > 0 ? Math.min(30, Math.floor(args.wait)) : undefined;
1184
+ const run = await api.request('GET', `/v1/test_runs/${enc(id)}`, wait ? { query: { wait } } : undefined);
1185
+ const w = run.waiting_for;
1186
+ 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(', ') : '';
1187
+ 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;
744
1188
  return {
745
- headline: `Test run ${run.id}: ${statusLine(run.status)}${run.phase ? ` (phase ${run.phase})` : ''}.`,
746
- notes: (run.steps ?? []).map((s) => `${s.status ?? '?'}: ${s.title ?? s.key}${s.code ? ` (${s.code})` : ''}`).slice(0, 12),
1189
+ headline: `Test run ${run.id}: ${run.status_label ?? statusLine(run.status)}${run.phase && run.status !== 'succeeded' && run.status !== 'failed' ? ` (phase ${run.phase})` : ''}.`,
1190
+ notes: [...(waiting ? [waiting] : []), ...(run.steps ?? []).map((s) => `${s.status ?? '?'}: ${s.title ?? s.key}${s.code ? ` (${s.code})` : ''}`)].slice(0, 13),
747
1191
  data: run,
748
1192
  };
749
1193
  },
@@ -755,18 +1199,19 @@ const checkDisclosure = {
755
1199
  name: 'check_disclosure',
756
1200
  title: 'Check checkout disclosure text',
757
1201
  description: [
758
- '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 and drop-in html).',
1202
+ '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).',
759
1203
  '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}}.',
760
- 'If verdict is fail, use replacement.html (or its texts) in the code, then call again to confirm pass. needs_review on styling means a rendered scan must confirm.',
1204
+ '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.',
761
1205
  ].join('\n'),
762
1206
  input: {
763
1207
  text: z.string().min(1).max(5000).describe('The disclosure shown next to the purchase button, exactly as rendered.'),
764
1208
  ...planTermsShape,
765
- interval: intervalStr,
1209
+ interval: intervalStr.optional().describe('Billing interval: day, week, month or year (optional with site).'),
766
1210
  button_text: z.string().max(200).optional().describe('Purchase button label, e.g. "Start free trial".'),
767
1211
  order_summary: z.string().max(1000).optional().describe('Other text next to the button (order summary), if any.'),
768
- placement: z
769
- .object({
1212
+ site: z.string().max(100).optional().describe('site_… whose plans judge it (as verify_task)'),
1213
+ plan: z.string().max(200).optional().describe('With site: plan name or price_…'),
1214
+ placement: strictObject({
770
1215
  above_button: z.boolean().optional(),
771
1216
  same_page_as_payment: z.boolean().optional(),
772
1217
  font_size_px: z.number().min(4).max(96).optional(),
@@ -774,16 +1219,15 @@ const checkDisclosure = {
774
1219
  bold: z.boolean().optional(),
775
1220
  boxed: z.boolean().optional(),
776
1221
  body_font_size_px: z.number().min(4).max(96).optional(),
777
- })
1222
+ }, 'placement')
778
1223
  .optional()
779
1224
  .describe('Where and how the disclosure is shown (defaults: above the button, same page, 14px, high contrast).'),
780
- consent: z
781
- .object({
1225
+ consent: strictObject({
782
1226
  checkbox: z.boolean(),
783
1227
  prechecked: z.boolean().optional(),
784
1228
  label: z.string().max(1000).optional(),
785
1229
  required: z.boolean().optional(),
786
- })
1230
+ }, 'consent')
787
1231
  .optional()
788
1232
  .describe('The consent checkbox, if the page has one. Omit when there is none.'),
789
1233
  jurisdictions: jurisdictions.optional(),
@@ -793,7 +1237,8 @@ const checkDisclosure = {
793
1237
  annotations: { ...ro, title: 'Check disclosure' },
794
1238
  async run(args, { api }) {
795
1239
  const r = await api.request('POST', '/v1/checks/disclosure', { body: args });
796
- return { headline: verdictHeadline('Disclosure', r), notes: checkNotes(r), data: slimCheckResult(r) };
1240
+ 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;
1241
+ return { headline: verdictHeadline('Disclosure', r), notes: checkNotes(r, 8, covered), data: withoutUnneededFix(slimCheckResult(r)) };
797
1242
  },
798
1243
  };
799
1244
  const checkStripeCheckoutSession = {
@@ -801,12 +1246,14 @@ const checkStripeCheckoutSession = {
801
1246
  title: 'Check Stripe Checkout Session params (returns corrected params)',
802
1247
  description: [
803
1248
  '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.',
804
- 'When line_items reference a Price id (price_…), also pass merchant { price or amount, interval, trial_days, merchant_name, cancel_url } because the params alone do not state them.',
805
- 'Note: on Stripe-hosted pages "clear and conspicuous" stays needs_review (Stripe styles custom_text itself); the response explains this.',
1249
+ 'With a Price id (price_…) in line_items, pass site (its plans are used) or merchant { price, interval, trial_days, merchant_name, cancel_url }.',
1250
+ 'Note: Stripe styles custom_text itself, so "clear and conspicuous" stays needs_review here; the plan shows it as a note, never a fix.',
806
1251
  ].join('\n'),
807
1252
  input: {
808
1253
  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"}.'),
809
- merchant: planTerms.optional().describe('What the params do not say: merchant_name, cancel_url, and price/interval when line_items use a Price id.'),
1254
+ merchant: merchantTerms.optional().describe('What the params do not say: merchant_name, cancel_url, and price/interval when line_items use a Price id.'),
1255
+ site: z.string().max(100).optional().describe('site_… whose plans judge it (as verify_task)'),
1256
+ plan: z.string().max(200).optional().describe('With site: plan name or price_…'),
810
1257
  jurisdictions: jurisdictions.optional(),
811
1258
  as_of: asOf.optional(),
812
1259
  },
@@ -814,12 +1261,14 @@ const checkStripeCheckoutSession = {
814
1261
  annotations: { ...ro, title: 'Check Stripe Checkout Session' },
815
1262
  async run(args, { api }) {
816
1263
  const r = await api.request('POST', '/v1/checks/stripe_checkout_session', { body: args });
817
- const notes = checkNotes(r);
818
- if (r.correction?.patch?.length)
1264
+ const fixing = !!r.correction?.patch?.length;
1265
+ const notes = checkNotes(r, 8, fixing && r.correction.verification?.passes ? { by: 'correction.corrected_params' } : undefined);
1266
+ if (fixing)
819
1267
  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'}).`);
820
- if (r.correction?.verification?.note)
1268
+ // Agent audit round 2: on a pass the same Stripe-styling note was printed four times; it is printed once.
1269
+ if (fixing && r.correction?.verification?.note)
821
1270
  notes.push(r.correction.verification.note);
822
- const { correction, ...rest } = slimCheckResult(r);
1271
+ const { correction, ...rest } = withoutUnneededFix(slimCheckResult(r));
823
1272
  return { headline: verdictHeadline('Stripe Checkout Session', r), notes, data: { correction, ...rest } };
824
1273
  },
825
1274
  };
@@ -827,32 +1276,35 @@ const checkEmail = {
827
1276
  name: 'check_email',
828
1277
  title: 'Check a subscription lifecycle email',
829
1278
  description: [
830
- 'Checks an acknowledgment, trial reminder, renewal reminder, price-change, annual-terms or cancellation-confirmation email against the laws that require it, and returns a compliant template (subject, text, html with merge tags) verified by the rules engine, plus required_by (which laws make this email mandatory for this plan).',
1279
+ '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).',
831
1280
  '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"}}.',
832
1281
  'For WHEN each email must be sent, use get_notice_schedule.',
833
1282
  ].join('\n'),
834
1283
  input: {
835
- kind: z.enum(['acknowledgment', 'trial_reminder', 'renewal_reminder', 'price_change', 'annual_terms', 'cancellation_confirmation']).describe('Which lifecycle email this is.'),
1284
+ kind: emailKind.describe('Which email (trial_reminder = trial_ending).'),
836
1285
  subject: z.string().min(1).max(1000).describe('Subject line.'),
837
1286
  text: z.string().max(100_000).optional().describe('Plain-text body (give text, html or both).'),
838
1287
  html: z.string().max(100_000).optional().describe('HTML body.'),
839
- terms: planTerms
840
- .extend({
1288
+ terms: strictObject({
1289
+ ...planTermsShape,
841
1290
  previous_price: z.union([z.number().positive(), z.string().max(40)]).optional().describe('price_change: price before the change, major units.'),
842
1291
  previous_amount: z.number().int().positive().optional(),
843
1292
  support_contact: z.string().max(200).optional().describe('Support email / URL for the template.'),
844
- })
845
- .describe('The plan the email is about: price (or amount), interval and cancel_url at least.'),
1293
+ }, 'terms')
1294
+ .optional()
1295
+ .describe('price, interval, cancel_url (optional with site); price_change: NEW price'),
1296
+ site: z.string().max(100).optional().describe('site_… whose Stripe prices judge it (default: your Stripe site).'),
1297
+ plan: z.string().max(200).optional().describe('Plan name or price_… (default: the one named)'),
846
1298
  jurisdictions: jurisdictions.optional(),
847
1299
  as_of: asOf.optional(),
848
1300
  },
849
1301
  scopes: ['checks:read'],
850
1302
  annotations: { ...ro, title: 'Check email' },
851
1303
  async run(args, { api }) {
852
- const r = await api.request('POST', '/v1/checks/email', { body: args });
853
- const notes = checkNotes(r);
854
- notes.unshift(r.required_by?.length ? `Legally required for this plan by: ${r.required_by.join(', ')}.` : 'Not legally required for this plan (still good practice).');
855
- return { headline: verdictHeadline(`Email (${String(args.kind)})`, r), notes, data: slimCheckResult(r) };
1304
+ const r = await api.request('POST', '/v1/checks/email', { body: { ...args, kind: checkKind(args.kind) } });
1305
+ 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);
1306
+ 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).');
1307
+ return { headline: verdictHeadline(`Email (${String(args.kind)}${r.plan ? `, plan ${untrusted(r.plan, 60)}` : ''})`, r), notes, data: slimCheckResult(r) };
856
1308
  },
857
1309
  };
858
1310
  const checkPageHtml = {
@@ -867,7 +1319,10 @@ const checkPageHtml = {
867
1319
  html: z.string().max(1_000_000).optional().describe('Rendered checkout page HTML. Give html or url.'),
868
1320
  url: z.string().max(2048).optional().describe('Live public page URL to fetch instead (live, activated accounts).'),
869
1321
  page_kind: z.enum(['checkout', 'pricing']).optional().describe('Default checkout.'),
870
- terms: planTerms.optional().describe('The plan sold on the page (helps judge price / interval statements).'),
1322
+ terms: planTerms.optional().describe('The plan sold on the page. merchant_name / cancel_url alone are fine (no price compared).'),
1323
+ 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.'),
1324
+ checkout_type: z.enum(['own_page', 'stripe_checkout', 'payment_link']).optional().describe('Where shoppers pay (default: inferred).'),
1325
+ site: z.string().max(100).optional().describe("Your site_…: judge it as verify_task does (the site's Stripe-checked plans)."),
871
1326
  jurisdictions: jurisdictions.optional(),
872
1327
  as_of: asOf.optional(),
873
1328
  },
@@ -878,25 +1333,74 @@ const checkPageHtml = {
878
1333
  throw new ToolError('Pass html (the rendered page) or url.');
879
1334
  const r = await api.request('POST', '/v1/checks/page', { body: args });
880
1335
  const notes = checkNotes(r);
1336
+ // Round 17 (python-flask): on a Payment Link / Stripe Checkout page, replacement.html is the terms only.
1337
+ if (r.replacement?.consent_on && r.replacement.note && r.verdict !== 'pass')
1338
+ notes.push(`Replacement: ${r.replacement.note}`);
881
1339
  if (r.extraction?.purchase_buttons) {
882
1340
  const b = r.extraction.purchase_buttons;
883
1341
  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).');
884
1342
  }
885
- return { headline: verdictHeadline('Page', r), notes, data: slimCheckResult(r) };
1343
+ return { headline: verdictHeadline('Page', r), notes, data: withoutUnneededFix(slimCheckResult(r)) };
886
1344
  },
887
1345
  };
1346
+ const DAY_S = 86_400;
1347
+ /**
1348
+ * Agent audit round 2: "2026-11-14: trial_ending (5 days before; US-UT)" said neither before what nor the allowed window,
1349
+ * which is what a cron job needs; the window was only in epoch seconds. The line now states both relative to the event.
1350
+ */
1351
+ export function noticeScheduleLine(n) {
1352
+ const anchor = n.anchor_date ? Date.parse(`${n.anchor_date}T00:00:00Z`) / 1000 : undefined;
1353
+ const event = n.occasion ? n.occasion.replace(/\s*\(.*\)$/, '').toLowerCase() : 'the event';
1354
+ if (n.days_before === 0 || anchor === undefined || n.window_opens_at === undefined || n.window_closes_at === undefined) {
1355
+ return `${n.send_date}: ${n.kind} — ${n.days_before === 0 ? `at ${event} (same day)` : `${n.days_before} days before ${event}`} (${n.jurisdictions.join(', ')})`;
1356
+ }
1357
+ const most = Math.round((anchor - n.window_opens_at) / DAY_S);
1358
+ const least = Math.round((anchor - n.window_closes_at) / DAY_S);
1359
+ const window = least >= 0 && most >= least ? `; allowed ${least === most ? `exactly ${least}` : `${least}–${most}`} days before` : '';
1360
+ return `${n.send_date}: ${n.kind} — ${n.days_before} days before ${event} (${n.anchor_date})${window} (${n.jurisdictions.join(', ')})`;
1361
+ }
1362
+ /** One line per notice; a kind sent every charge shows its first date and how many follow. */
1363
+ export function noticeScheduleLines(notices) {
1364
+ const out = [];
1365
+ const seen = new Map();
1366
+ for (const n of notices)
1367
+ seen.set(n.kind, (seen.get(n.kind) ?? 0) + 1);
1368
+ const shown = new Set();
1369
+ for (const n of notices) {
1370
+ const count = seen.get(n.kind);
1371
+ if (count > 2) {
1372
+ if (shown.has(n.kind))
1373
+ continue;
1374
+ shown.add(n.kind);
1375
+ out.push(`${noticeScheduleLine(n)} — then the same before each of the next ${count - 1} charges (all in notices[])`);
1376
+ }
1377
+ else
1378
+ out.push(noticeScheduleLine(n));
1379
+ }
1380
+ return out.slice(0, 12);
1381
+ }
1382
+ /** Notice kinds of the schedule → the compliance plan's email task for it (same names as get_compliance_plan). */
1383
+ export const NOTICE_KIND_TASKS = {
1384
+ acknowledgment: { name: 'acknowledgment email', task: 'acknowledgment_email' },
1385
+ trial_ending: { name: 'trial-ending reminder', task: 'trial_reminder' },
1386
+ renewal_reminder: { name: 'renewal reminder', task: 'renewal_reminder' },
1387
+ annual_terms: { name: 'yearly terms reminder', task: 'annual_terms_notice' },
1388
+ charge_notice: { name: 'charge notice', task: 'charge_notice' },
1389
+ price_change: { name: 'price-change notice', task: 'price_change_notice' },
1390
+ };
888
1391
  const getNoticeSchedule = {
889
1392
  name: 'get_notice_schedule',
890
1393
  title: 'Get the dates each required notice must be sent',
891
1394
  description: [
892
- 'For a plan (price, interval, trial) and a sign-up date: every legally required notice (acknowledgment, trial-ending reminder, renewal reminder, annual terms, price-change notice) with the recommended send date, the compliant window (earliest/latest), the jurisdictions it satisfies and citations; plus notice kinds NOT required and why.',
1395
+ '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.',
893
1396
  'Use it to implement reminder emails / cron jobs correctly. Example: {"terms":{"price":99,"interval":"year","trial_days":30},"start_date":"2026-11-01"}.',
894
1397
  ].join('\n'),
895
1398
  input: {
896
- terms: planTerms.describe('The plan: price (or amount), interval, interval_count, trial_days.'),
1399
+ terms: planTerms.optional().describe('The plan: price (or amount), interval, interval_count, trial_days (or site + plan).'),
1400
+ site: z.string().max(100).optional().describe('site_… (its plans, jurisdictions)'),
1401
+ plan: z.string().max(200).optional().describe('With site: plan name or price_…'),
897
1402
  start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Sign-up date, YYYY-MM-DD (default today).'),
898
- price_change: z
899
- .object({ effective_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), new_price: z.number().positive().optional() })
1403
+ price_change: strictObject({ effective_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), new_price: z.number().positive().optional() }, 'price_change')
900
1404
  .optional()
901
1405
  .describe('A planned price change to schedule the notice for.'),
902
1406
  cycles: z.number().int().min(1).max(24).optional().describe('How many renewals to plan (default one year).'),
@@ -908,11 +1412,19 @@ const getNoticeSchedule = {
908
1412
  annotations: { ...ro, title: 'Notice schedule' },
909
1413
  async run(args, { api }) {
910
1414
  const r = await api.request('POST', '/v1/checks/notice_schedule', { body: args });
1415
+ const singleDay = [...new Set(r.notices.flatMap((n) => (n.timing_note ? [`${n.kind}: ${n.timing_note}`] : [])))].slice(0, 3);
1416
+ const kinds = [...new Set(r.notices.map((n) => n.kind))];
911
1417
  return {
912
- headline: `${r.notices.length} required notice(s) for this plan.`,
1418
+ // Polish round: one email per kind, named like the compliance plan's email tasks (the per-send dates follow).
1419
+ headline: kinds.length
1420
+ ? `${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.`
1421
+ : 'No required notices for this plan under these jurisdictions.',
913
1422
  notes: [
914
- ...r.notices.slice(0, 12).map((n) => `${n.send_date}: ${n.kind} (${n.days_before} days before; ${n.jurisdictions.join(', ')})`),
1423
+ ...noticeScheduleLines(r.notices),
1424
+ ...singleDay,
1425
+ ...(r.rounding_rule ? [`Timing rule: ${r.rounding_rule}`] : []),
915
1426
  ...(r.not_required ?? []).map((n) => `Not required: ${n.kind} — ${n.reason}`),
1427
+ ...(r.warnings ?? []).filter((w) => typeof w?.message === 'string').slice(0, 3).map((w) => w.message),
916
1428
  ],
917
1429
  data: r,
918
1430
  };
@@ -930,7 +1442,7 @@ const SUGGESTION_SURFACE_NAMES = [
930
1442
  ];
931
1443
  const suggestImprovements = {
932
1444
  name: 'suggest_improvements',
933
- title: 'Suggest compliant conversion improvements',
1445
+ title: 'Suggest conversion improvements that keep the checks passing',
934
1446
  description: [
935
1447
  '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.',
936
1448
  '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.',
@@ -940,7 +1452,7 @@ const suggestImprovements = {
940
1452
  input: {
941
1453
  site: siteId.optional(),
942
1454
  terms: planTerms.optional().describe('The plan sold (price or amount, interval, trial_days, merchant_name, cancel_url). Required with disclosure or emails.'),
943
- other_plans: z.array(planTerms.extend({ name: z.string().max(80).describe('Plan name, e.g. "Lite".') })).max(10).optional().describe('Other plans you sell (enables cheaper-plan / plan-choice suggestions).'),
1455
+ 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).'),
944
1456
  disclosure: z
945
1457
  .object({
946
1458
  text: z.string().min(1).max(5000).describe('Disclosure next to the purchase button, as rendered.'),
@@ -1001,7 +1513,10 @@ const suggestImprovements = {
1001
1513
  r.suggestions.slice(0, 15).forEach((s, i) => {
1002
1514
  notes.push([
1003
1515
  `${i + 1}. [${s.surface} · ${s.match}] ${s.title} (${s.key})`,
1004
- ` before (${s.before.label}): ${s.match === 'detected' ? untrusted(s.before.text, 160) : JSON.stringify(s.before.text.slice(0, 160))}`,
1516
+ // "From your copy" only for the merchant's own text; anything else is an example (never present it as theirs).
1517
+ s.before.source === 'your_copy' || (!s.before.source && s.match === 'detected')
1518
+ ? ` before, from your copy (${s.before.label}): ${untrusted(s.before.text, 160)}`
1519
+ : ` before, example (${s.before.label}): ${JSON.stringify(s.before.text.slice(0, 160))}`,
1005
1520
  ` apply (${s.apply.type}) at ${s.apply.where}: ${JSON.stringify(s.apply.content.length > 600 ? `${s.apply.content.slice(0, 600)}…` : s.apply.content)}`,
1006
1521
  ` how: ${s.apply.instructions}`,
1007
1522
  ...(s.preconditions.length ? [` only if true: ${s.preconditions.join(' ')}`] : []),
@@ -1014,7 +1529,7 @@ const suggestImprovements = {
1014
1529
  notes.push(`…and ${r.suggestions.length - 15} more in data.suggestions.`);
1015
1530
  const held = r.surfaces.filter((s) => s.held_by.length);
1016
1531
  if (held.length)
1017
- notes.push(`Held (fix first with get_compliance_plan): ${held.map((s) => `${s.surface} (${s.held_label ? `${s.held_label.toUpperCase()}: ` : ''}${s.held_by.join(', ')})`).join('; ')}.`);
1532
+ 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('; ')}.`);
1018
1533
  if (r.withheld.length)
1019
1534
  notes.push(`Withheld: ${r.withheld.slice(0, 8).map((w) => `${w.key}: ${w.reason}`).join(' | ')}`);
1020
1535
  return { headline: `Suggestions for ${where}: ${r.summary}`, notes, data: r };
@@ -1023,10 +1538,25 @@ const suggestImprovements = {
1023
1538
  // ---------------------------------------------------------------------------------------------------------------------
1024
1539
  // Sites, findings, scans, law updates
1025
1540
  // ---------------------------------------------------------------------------------------------------------------------
1541
+ /** One site in list_sites: what an agent needs to pick it. */
1542
+ export function siteListItem(s) {
1543
+ const x = s;
1544
+ return {
1545
+ id: s.id,
1546
+ object: 'site',
1547
+ name: s.name ?? null,
1548
+ url: s.url,
1549
+ ...(x.platform ? { platform: x.platform } : {}),
1550
+ ...(x.checkout?.url ? { checkout_url: x.checkout.url } : {}),
1551
+ status: plainLabel(s.compliance?.label ?? 'Not checked yet'),
1552
+ domain_verified: x.monitoring?.held_for_domain_verification !== true,
1553
+ ...(x.archived ? { archived: true } : {}),
1554
+ };
1555
+ }
1026
1556
  const listSites = {
1027
1557
  name: 'list_sites',
1028
1558
  title: 'List sites',
1029
- description: 'The account\'s sites in the current mode (test keys see only test-mode sites), newest first, each with its compliance score, status and open findings. Use it to find a site id.',
1559
+ 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.',
1030
1560
  input: { limit: listLimit, starting_after: startingAfter, archived: z.boolean().optional().describe('true lists only archived sites.') },
1031
1561
  scopes: ['sites:read'],
1032
1562
  annotations: { ...ro, title: 'List sites' },
@@ -1036,9 +1566,14 @@ const listSites = {
1036
1566
  });
1037
1567
  return {
1038
1568
  headline: `${list.data.length}${list.has_more ? '+' : ''} site(s) in ${key.mode === 'unknown' ? 'this' : key.mode} mode.`,
1039
- notes: list.data.map((s) => `${s.id} ${untrusted(s.url, 100)} — score ${s.compliance?.score ?? 'n/a'}${s.compliance?.label ? `, ${s.compliance.label}` : ''}, open findings ${s.compliance?.open_findings ?? 0}`),
1040
- data: list,
1041
- next: list.data.length ? null : { type: 'setup', message: 'No sites yet: call setup_checkbox with the site URL.', operation_id: 'createSetupSession' },
1569
+ // Audit P2: name + URL per site, and a Next line either way.
1570
+ notes: list.data.map((s) => `${s.id} ${s.name ? `${untrusted(s.name, 60)} ` : ''}${untrusted(s.url, 100)} — ${plainLabel(s.compliance?.label ?? 'Not checked yet')}`),
1571
+ // Agent audit round 2: each site carried install details, webhook URLs, retention and legal hold (~1.5 KB) when
1572
+ // an agent needs the id, URL and status; get_site_status / the API have the rest.
1573
+ data: { ...list, data: list.data.map(siteListItem) },
1574
+ next: list.data.length
1575
+ ? { 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' }
1576
+ : { type: 'setup', message: 'No sites yet: call setup_checkbox with the site URL.', operation_id: 'createSetupSession' },
1042
1577
  };
1043
1578
  },
1044
1579
  };
@@ -1090,10 +1625,14 @@ const updateSite = {
1090
1625
  name: z.string().max(200).optional().describe('Merchant / brand name (used in generated disclosures).'),
1091
1626
  checkout_url: z.string().max(2048).optional().describe('Page with the Subscribe / Buy button.'),
1092
1627
  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.'),
1093
- test_account: z
1094
- .object({ login_url: z.string().max(2048), email: z.string().max(320), password: z.string().max(200) })
1628
+ test_account: strictObject({
1629
+ login_url: z.string().max(2048),
1630
+ email: z.string().max(320),
1631
+ password: z.string().max(200),
1632
+ reactivate_url: z.string().max(2048).optional().describe('Test-only page that re-subscribes the test account.'),
1633
+ }, 'test_account')
1095
1634
  .optional()
1096
- .describe('A TEST subscriber account the scanner logs in with to try cancelling online.'),
1635
+ .describe('A TEST subscriber account the scanner logs in with to try cancelling online (echoed back without the password).'),
1097
1636
  platform: z.enum(['stripe', 'shopify', 'recharge', 'chargebee', 'recurly', 'paddle', 'woocommerce', 'custom']).optional(),
1098
1637
  jurisdictions: z.array(z.string().max(10)).max(60).optional(),
1099
1638
  monitoring_interval_minutes: z.number().int().min(0).optional(),
@@ -1114,54 +1653,95 @@ const updateSite = {
1114
1653
  };
1115
1654
  },
1116
1655
  };
1656
+ /** The site's Shopify connection in one line, naming the tool (or the human step) that comes next. */
1657
+ export function shopifyLines(sh, site) {
1658
+ if (!sh)
1659
+ return [];
1660
+ if (sh.connection === 'app') {
1661
+ return sh.terms_published
1662
+ ? [`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.`]
1663
+ : [`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'}).`];
1664
+ }
1665
+ if (sh.connection === 'own_app')
1666
+ 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.`];
1667
+ 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).'];
1668
+ }
1117
1669
  const getSiteStatus = {
1118
1670
  name: 'get_site_status',
1119
1671
  title: 'Get a site\'s compliance status',
1120
- description: 'Where a site stands: compliance score (0-100) and status, what blocks it (critical/high checks failing or unverified), the top open failing findings, latest scan, and whether the snippet is installed. Use it for "am I compliant?". For what to change, use get_compliance_plan.',
1672
+ 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.',
1121
1673
  input: { site: siteId.optional() },
1122
1674
  scopes: ['sites:read'],
1123
1675
  annotations: { ...ro, title: 'Site status' },
1124
1676
  async run(args, { api }) {
1125
1677
  const id = await resolveSite(api, args.site);
1126
- const [site, evaluation, findings] = await Promise.all([
1678
+ const [site, evaluation, plan] = await Promise.all([
1127
1679
  api.request('GET', `/v1/sites/${enc(id)}`),
1128
1680
  optional(api.request('GET', `/v1/sites/${enc(id)}/evaluation`)),
1129
- optional(api.request('GET', '/v1/findings', { query: { site: id, status: 'fail', state: 'open', limit: 5 } })),
1681
+ optional(api.request('GET', `/v1/sites/${enc(id)}/plan`)),
1130
1682
  ]);
1131
1683
  // Round-4 pilot (nextjs): "nothing left to do" while 13 critical/high checks were needs_review. Count them too.
1132
1684
  const review = await optional(api.request('GET', '/v1/findings', { query: { site: id, status: 'needs_review', state: 'open', limit: 100 } }));
1133
- const evaluated = 'score' in evaluation;
1134
- const top = 'data' in findings ? findings.data : [];
1685
+ const evaluated = 'id' in evaluation;
1135
1686
  const toReview = 'data' in review ? review.data : [];
1136
1687
  const seriousReview = toReview.filter((f) => f.severity === 'critical' || f.severity === 'high');
1137
- const notes = [
1138
- ...(evaluated && evaluation.blockers?.length ? [`Blocking checks: ${evaluation.blockers.map((b) => b.check_id).join(', ')}`] : []),
1139
- ...top.map((f) => `${f.severity.toUpperCase()} ${f.id} ${f.check_id}: ${f.title}`),
1140
- ...(toReview.length ? [`${toReview.length} check(s) need review (${seriousReview.length} critical/high): ${toReview.slice(0, 6).map((f) => f.check_id).join(', ')}${toReview.length > 6 ? ', …' : ''}`] : []),
1141
- ];
1142
- const { checks: _omit, ...evalLite } = (evaluated ? evaluation : {});
1688
+ const blocking = evaluated ? (evaluation.blockers ?? []).map((b) => b.check_id) : [];
1689
+ // One to-do list: the plan's ordered open tasks, never a list built from findings.
1690
+ const todo = 'tasks' in plan && Array.isArray(plan.tasks) ? planTodo(plan.tasks) : [];
1691
+ const { checks: _omit, score: _score, insurable: _insurable, ...evalLite } = (evaluated ? evaluation : {});
1143
1692
  // Status labels: the roll-up decides the headline; pending live checks count as compliant and say how they complete.
1144
1693
  const roll = site.compliance?.rollup;
1145
1694
  const pendingLive = roll?.pending_live_checks ?? [];
1146
1695
  const rollStatus = site.compliance?.status;
1147
1696
  const label = site.compliance?.label;
1148
- if (evaluated && label) {
1149
- notes.unshift(...pendingLive.slice(0, 6).map((p) => `LIVE CHECK PENDING ${p.check_id}: ${p.what} — ${p.completes}`));
1150
- }
1697
+ const ro = unverifiedScanNote(site.unverified_scan);
1698
+ const notes = [
1699
+ ...(todo.length ? [`To do (${todo.length}, from the compliance plan, in order):`, ...todo.map((t, i) => `${i + 1}. ${t.id} ${todoLine(t)}`)] : []),
1700
+ // Round 25 (pilot 24 item 22: "4 live checks pending" listed 3): every pending live check the label counts.
1701
+ ...(evaluated && label ? pendingLive.map((p) => `LIVE CHECK PENDING ${p.check_id}: ${p.what} — ${p.completes}`) : []),
1702
+ ...(ro ? [ro] : []),
1703
+ ...shopifyLines(site.shopify, site.id),
1704
+ ];
1705
+ const { open_findings: _open, needs_review: _nr, ...complianceLite } = (site.compliance ?? {});
1151
1706
  return {
1152
- headline: evaluated
1153
- ? label
1154
- ? `${site.url}: ${label} (score ${evaluation.score}).`
1155
- : `${site.url}: score ${evaluation.score}, ${site.compliance?.open_findings ?? top.length} open failing finding(s), ${toReview.length} to review.`
1156
- : `${site.url}: not evaluated yet.`,
1707
+ headline: evaluated ? `${site.url}: ${plainLabel(label ?? 'Not checked yet')}.` : `${site.url}: Not checked yet.`,
1157
1708
  notes,
1158
- data: { site, evaluation: evaluated ? evalLite : null, top_findings: top, needs_review: { count: toReview.length, critical_or_high: seriousReview.length } },
1709
+ data: { site: site.compliance ? { ...site, compliance: complianceLite } : site, evaluation: evaluated ? evalLite : null, todo },
1159
1710
  next: evaluated
1160
- ? rollStatus === 'needs_fix' || (!rollStatus && top.length)
1161
- ? { type: 'fix', message: 'Call get_compliance_plan for the ordered changes that fix these.', operation_id: 'retrieveCompliancePlan' }
1162
- : rollStatus === 'your_call' || (!rollStatus && toReview.length)
1163
- ? { 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' }
1164
- : { type: 'none', message: pendingLive.length ? `Compliant. ${pendingLive.length} live check(s) pending; each completes on its own or with the step listed (run a proof / BCC real sends).` : 'Compliant: nothing left to do.' }
1711
+ ? todo.length && (rollStatus === 'needs_fix' || todo.some((t) => t.status === 'needs_fix' || t.status === 'not_set_up'))
1712
+ ? { 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' }
1713
+ : rollStatus === 'needs_fix'
1714
+ ? { type: 'fix', message: 'Call get_compliance_plan for the ordered changes.', operation_id: 'retrieveCompliancePlan' }
1715
+ : rollStatus === 'your_call' || todo.some((t) => t.status === 'your_call')
1716
+ ? { 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' }
1717
+ : rollStatus === 'compliant' && !todo.length
1718
+ ? // Round 15 (pilot 14, change-guard: "Checks pass · 3 live checks pending" next to "the site is not shown
1719
+ // as passing yet"): one status, one sentence. When the roll-up says Checks pass, next_action says the
1720
+ // same; the live checks are named as what completes it, never as a reason it does not pass.
1721
+ pendingLive.length
1722
+ ? {
1723
+ type: 'complete_live_checks',
1724
+ message: `${plainLabel(label ?? 'Checks pass')}: nothing needs a code change. ${pendingLive.length} live check(s) complete on their own (${pendingLive
1725
+ .slice(0, 3)
1726
+ .map((p) => p.what)
1727
+ .join('; ')}); get_compliance_plan says how each one completes (a proof run, BCC real sends, a test account).`,
1728
+ operation_id: 'retrieveCompliancePlan',
1729
+ }
1730
+ : { type: 'none', message: `${plainLabel(label ?? 'Checks pass')}: every check that applies passes; nothing left to do. Keep monitoring on.` }
1731
+ : blocking.length || pendingLive.length || seriousReview.length || todo.length
1732
+ ? // Normal round 4 (nextjs): "nothing left to do — Compliant" next to 2 blocking checks. Never call a site
1733
+ // compliant while a blocking check, a pending live check or an open plan task remains.
1734
+ {
1735
+ type: 'complete_live_checks',
1736
+ message: `Nothing needs a code change now, but the site is not shown as passing yet (${plainLabel(label ?? 'Not checked yet')}): ${[
1737
+ todo.length ? `${todo.length} task(s) on the plan (${todo.slice(0, 3).map((t) => t.title).join('; ')})` : '',
1738
+ blocking.length ? `blocking check(s) ${blocking.slice(0, 4).join(', ')}` : '',
1739
+ pendingLive.length ? `${pendingLive.length} live check(s) pending` : '',
1740
+ seriousReview.length && !todo.length ? `${seriousReview.length} check(s) to review` : '',
1741
+ ].filter(Boolean).join(', ')}. Call get_compliance_plan for how each one completes (a proof run, BCC real sends, a test account).`,
1742
+ operation_id: 'retrieveCompliancePlan',
1743
+ }
1744
+ : { type: 'none', message: 'Every check that applies has passed; nothing left to do. Keep monitoring on.' }
1165
1745
  : { type: 'run_first_check', message: 'Call verify_site (or run_scan) for the first verdict.', operation_id: 'verifySite' },
1166
1746
  };
1167
1747
  },
@@ -1169,10 +1749,10 @@ const getSiteStatus = {
1169
1749
  const listFindings = {
1170
1750
  name: 'list_findings',
1171
1751
  title: 'List compliance findings',
1172
- 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 failing findings. Use get_finding for the fix of one.',
1752
+ 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.',
1173
1753
  input: {
1174
1754
  site: z.string().max(100).optional().describe('Only this site (site_…).'),
1175
- status: z.enum(['pass', 'fail', 'needs_review', 'not_applicable']).optional().describe('Default fail.'),
1755
+ status: z.enum(['pass', 'fail', 'needs_review', 'not_applicable']).optional().describe('Default: all statuses.'),
1176
1756
  state: z.enum(['open', 'acknowledged', 'resolved', 'accepted_risk']).optional().describe('Default open.'),
1177
1757
  limit: listLimit,
1178
1758
  starting_after: startingAfter,
@@ -1183,7 +1763,8 @@ const listFindings = {
1183
1763
  const list = await api.request('GET', '/v1/findings', {
1184
1764
  query: {
1185
1765
  site: args.site,
1186
- status: args.status ?? 'fail',
1766
+ // Normal round 3: with no filter, every open finding (it returned "0 finding(s)" while 13 were active).
1767
+ status: args.status,
1187
1768
  state: args.state ?? 'open',
1188
1769
  limit: args.limit,
1189
1770
  starting_after: args.starting_after,
@@ -1222,6 +1803,36 @@ const getFinding = {
1222
1803
  };
1223
1804
  },
1224
1805
  };
1806
+ const publishShopifyTerms = {
1807
+ name: 'publish_shopify_terms',
1808
+ title: 'Publish renewal terms to the Shopify app block (write)',
1809
+ description: [
1810
+ '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.',
1811
+ 'The merchant still places the "Renewal terms + consent" block once: give them theme_editor_url from the result. Then verify_site.',
1812
+ 'shopify_not_connected: only the merchant can install the app in Shopify and click "Connect to Checkbox". terms_incomplete: follow next_action.',
1813
+ ].join('\n'),
1814
+ input: {
1815
+ site: requiredSite,
1816
+ cancel_url: z.string().max(2048).optional().describe('Cancellation URL to state (default: detected).'),
1817
+ plan: z.string().max(200).optional().describe('Plan / price id (default: detected).'),
1818
+ },
1819
+ scopes: ['fixes:write'],
1820
+ annotations: { ...write(true, true), title: 'Publish Shopify terms' },
1821
+ async run(args, { api }) {
1822
+ const site = String(args.site);
1823
+ const r = await api.request('POST', `/v1/sites/${enc(site)}/fixes/shopify/publish`, { body: pick(args, ['cancel_url', 'plan']) });
1824
+ return {
1825
+ headline: `Published the verified renewal terms to the Checkbox app block on ${r.shop} (${r.id}).`,
1826
+ notes: [
1827
+ `Disclosure: ${untrusted(r.disclosure, 600)}`,
1828
+ `Consent checkbox: ${untrusted(r.consent_label, 200)}`,
1829
+ `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.`,
1830
+ ],
1831
+ data: r,
1832
+ 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' },
1833
+ };
1834
+ },
1835
+ };
1225
1836
  const runScan = {
1226
1837
  name: 'run_scan',
1227
1838
  title: 'Run a synthetic-shopper scan (write)',
@@ -1242,15 +1853,26 @@ const runScan = {
1242
1853
  const getScan = {
1243
1854
  name: 'get_scan',
1244
1855
  title: 'Get a scan',
1245
- description: 'A scan\'s status (queued → running → completed | failed), warnings, and once completed the evaluation it produced with its score.',
1856
+ 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).',
1246
1857
  input: { scan: z.string().max(100).describe('Scan id (scan_…).') },
1247
1858
  scopes: ['scans:read'],
1248
1859
  annotations: { ...ro, title: 'Scan' },
1249
1860
  async run(args, { api }) {
1250
1861
  const scan = await api.request('GET', `/v1/scans/${enc(String(args.scan))}`);
1251
1862
  const done = scan.status === 'completed';
1863
+ // Audit P1: a queued scan of a site held for domain verification does not run until the domain is verified.
1864
+ if (scan.status === 'queued' && scan.site) {
1865
+ const site = await optional(api.request('GET', `/v1/sites/${enc(scan.site)}`));
1866
+ if ('monitoring' in site && site.monitoring?.held_for_domain_verification) {
1867
+ return {
1868
+ headline: `Scan ${scan.id}: queued (waiting for domain verification). It does not run until the site's domain is verified.`,
1869
+ data: scan,
1870
+ 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' },
1871
+ };
1872
+ }
1873
+ }
1252
1874
  return {
1253
- headline: `Scan ${scan.id}: ${statusLine(scan.status)}${done ? `, score ${scan.score ?? 'n/a'}` : ''}${scan.error ? ` — error: ${untrusted(scan.error, 160)}` : ''}.`,
1875
+ 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)}` : ''}.`,
1254
1876
  data: scan,
1255
1877
  next: done
1256
1878
  ? { type: 'review', message: 'Call get_compliance_plan for what to change.', operation_id: 'retrieveCompliancePlan' }
@@ -1263,32 +1885,58 @@ const getScan = {
1263
1885
  const sendFeedback = {
1264
1886
  name: 'send_feedback',
1265
1887
  title: 'Send feedback to the Checkbox team',
1266
- description: 'Report a problem or a missing feature in Checkbox. Include what you tried and what you expected (and the site / request id or tool name when there is one). Use type "bug" for something broken, "idea" for a missing feature, "question" for something unclear. The Checkbox team reads every message.',
1888
+ 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".',
1267
1889
  input: {
1268
- type: z.enum(['bug', 'idea', 'question']).optional().describe('bug, idea or question (default idea).'),
1269
- message: z.string().min(1).max(5000).describe('What you tried, what happened, what you expected.'),
1270
- site: z.string().max(200).optional().describe('Site id it is about (optional).'),
1271
- request_id: z.string().max(200).optional().describe('Request-Id of the API call it is about (optional).'),
1272
- tool: z.string().max(200).optional().describe('The tool or command involved (optional).'),
1890
+ type: z.enum(['bug', 'idea', 'question', 'review']).optional(),
1891
+ message: z.string().optional(),
1892
+ score: z.number().int().min(0).max(10).optional(),
1893
+ liked: z.array(z.string()).optional(),
1894
+ disliked: z.array(z.string()).optional(),
1895
+ wrong_verdicts: z.array(z.string()).optional(),
1896
+ would_keep_using: z.boolean().optional(),
1897
+ stack: z.string().optional(),
1898
+ site: z.string().optional(),
1899
+ request_id: z.string().optional(),
1900
+ tool: z.string().optional(),
1273
1901
  },
1274
1902
  scopes: ['account:read'],
1275
1903
  annotations: { ...write(false, false), title: 'Send feedback' },
1276
1904
  async run(args, ctx) {
1277
1905
  const context = {};
1278
- for (const k of ['site', 'request_id', 'tool'])
1906
+ // Reviews are about Checkbox, not the merchant's site: no site or request id is attached to them.
1907
+ for (const k of args.type === 'review' ? ['tool'] : ['site', 'request_id', 'tool'])
1279
1908
  if (typeof args[k] === 'string' && args[k])
1280
1909
  context[k] = String(args[k]);
1281
1910
  const agent = ctx.key.appName ?? ctx.clientName;
1282
1911
  if (agent)
1283
1912
  context.agent = String(agent).slice(0, 100);
1284
- const fb = await ctx.api.request('POST', '/v1/feedback', { body: { ...(args.type ? { type: args.type } : {}), message: args.message, ...(Object.keys(context).length ? { context } : {}) } });
1913
+ if (args.type !== 'review' && !(typeof args.message === 'string' && args.message.trim()))
1914
+ throw new ToolError('Write a message (what you tried, what happened, what you expected).');
1915
+ if (args.type === 'review' && typeof args.score !== 'number')
1916
+ throw new ToolError('A review needs score (0-10). Ask the human; never guess it.');
1917
+ const body = { ...pick(args, ['type', 'message', 'score', 'liked', 'disliked', 'wrong_verdicts', 'would_keep_using', 'stack']), ...(Object.keys(context).length ? { context } : {}) };
1918
+ const fb = await ctx.api.request('POST', '/v1/feedback', { body });
1285
1919
  return {
1286
- headline: `Feedback ${fb.id} sent to the Checkbox team (${fb.status}).`,
1920
+ 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].' : ''}`,
1287
1921
  data: fb,
1288
1922
  next: { type: 'none', message: 'Nothing else to do; the team replies by email to the account owner when needed.' },
1289
1923
  };
1290
1924
  },
1291
1925
  };
1926
+ const LAW_CHANGES_SHOWN = 10;
1927
+ /**
1928
+ * Agent audit round 2: list_law_updates with limit 3 printed 62 KB (one catalog update carried 195 requirement changes,
1929
+ * each with its full summary). Each update keeps its first changes in short form, with the total.
1930
+ */
1931
+ export function lawUpdateItem(u) {
1932
+ const changes = Array.isArray(u.changes) ? u.changes : [];
1933
+ const { changes: _c, ...rest } = u;
1934
+ return {
1935
+ ...rest,
1936
+ changes: changes.slice(0, LAW_CHANGES_SHOWN).map((c) => pick(c, ['requirement_id', 'change', 'citation', 'title', 'effective_date'])),
1937
+ ...(changes.length > LAW_CHANGES_SHOWN ? { changes_total: changes.length } : {}),
1938
+ };
1939
+ }
1292
1940
  const listLawUpdates = {
1293
1941
  name: 'list_law_updates',
1294
1942
  title: 'List law updates and their impact',
@@ -1303,17 +1951,21 @@ const listLawUpdates = {
1303
1951
  return {
1304
1952
  headline: `${list.data.length}${list.has_more ? '+' : ''} law update(s).`,
1305
1953
  notes: list.data.map((u) => `${u.date} ${u.id} (${(u.jurisdictions ?? []).join(', ')}): ${u.summary}${u.impacts?.length ? ` — affects ${u.impacts.length} site(s)` : ''}`),
1306
- data: list,
1954
+ data: { ...list, data: list.data.map(lawUpdateItem) },
1307
1955
  };
1308
1956
  },
1309
1957
  };
1310
1958
  function toDisclosureTerms(t) {
1311
- const out = pick(t, ['merchant_name', 'amount', 'currency', 'interval', 'interval_count', 'trial_days', 'cancel_url', 'cancel_timing']);
1959
+ const out = pick(t, ['merchant_name', 'amount', 'currency', 'interval', 'interval_count', 'trial_days', 'intro_percent_off', 'intro_periods', 'cancel_url', 'cancel_timing']);
1960
+ if (out.merchant_name === undefined && typeof t.brand_name === 'string')
1961
+ out.merchant_name = t.brand_name;
1312
1962
  if (out.amount === undefined && t.price !== undefined) {
1313
1963
  const n = typeof t.price === 'number' ? t.price : Number(String(t.price).replace(/[^0-9.]/g, ''));
1314
1964
  if (Number.isFinite(n) && n > 0)
1315
1965
  out.amount = Math.round(n * 100);
1316
1966
  }
1967
+ if (typeof t.intro_price === 'number' && t.intro_price >= 0)
1968
+ out.intro_amount = Math.round(t.intro_price * 100);
1317
1969
  return out;
1318
1970
  }
1319
1971
  function reactSnippet(d) {
@@ -1334,7 +1986,7 @@ function reactSnippet(d) {
1334
1986
  const elementsInstallNotes = [
1335
1987
  '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.',
1336
1988
  'Your server receives checkbox_consent_id (cons_…) with the form: store it with the subscription (e.g. Stripe metadata.checkbox_consent).',
1337
- 'Do not restyle it below body size or hide it; guard rails restore compliant styles and report violations.',
1989
+ 'Do not restyle it below body size or hide it; guard rails restore the required styles and report what they found.',
1338
1990
  ];
1339
1991
  const elementsTermsShape = {
1340
1992
  merchant_name: planTermsShape.merchant_name,
@@ -1349,31 +2001,33 @@ const elementsTermsShape = {
1349
2001
  };
1350
2002
  const getCompliantDisclosure = {
1351
2003
  name: 'get_compliant_disclosure',
1352
- title: 'Generate a compliant disclosure for given terms',
2004
+ title: 'Generate a disclosure that passes the checks for given terms',
1353
2005
  description: [
1354
2006
  '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.',
1355
- '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"}.',
2007
+ '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"}.',
1356
2008
  '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.',
1357
2009
  ].join('\n'),
1358
- input: { ...elementsTermsShape, 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() },
2010
+ // Round 15 (P4): a promo (intro price) is stated with how long it lasts.
2011
+ 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() },
1359
2012
  scopes: ['checks:read'],
1360
- annotations: { ...ro, readOnlyHint: false, title: 'Compliant disclosure' },
2013
+ annotations: { ...ro, readOnlyHint: false, title: 'Disclosure that passes the checks' },
1361
2014
  async run(args, { api, key }) {
1362
2015
  if (typeof args.site === 'string' && args.site && keyAllows(key, 'consents:write')) {
1363
2016
  const d = await api.request('POST', '/v1/disclosures', { body: { site: args.site, terms: toDisclosureTerms(args) } });
1364
2017
  return {
1365
- headline: `Compliant 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)'}.`,
2018
+ 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)'}.`,
1366
2019
  notes: [`Disclosure: ${d.text}`, `Consent label: ${d.consent_label}`, ...elementsInstallNotes],
1367
2020
  data: { ...d, react: reactSnippet(d) },
1368
2021
  };
1369
2022
  }
1370
2023
  // No site: generate from the terms with the stateless check (the replacement does not depend on the text checked).
1371
- 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']) };
2024
+ 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']) };
1372
2025
  const r = await api.request('POST', '/v1/checks/disclosure', { body });
1373
2026
  return {
1374
- headline: `Compliant disclosure generated (engine-verified: ${r.replacement.verified}).`,
2027
+ headline: `Suggested disclosure generated (${r.replacement.verified ? 'passes the checks, verified by the rules engine' : 'not fully verified by the rules engine'}).`,
1375
2028
  notes: [
1376
- `Disclosure: ${r.replacement.disclosure_text}`,
2029
+ // Round 23 (pilot 22 item 30): the short line first when the engine passes it; the full text as the fuller version.
2030
+ ...(r.replacement.short_disclosure_text ? [`Disclosure: ${r.replacement.short_disclosure_text}`, `Fuller version (also passes): ${r.replacement.disclosure_text}`] : [`Disclosure: ${r.replacement.disclosure_text}`]),
1377
2031
  `Consent label: ${r.replacement.consent_label}`,
1378
2032
  ...(r.replacement.placeholders?.length ? [`Fill these placeholders: ${r.replacement.placeholders.join(', ')}.`] : []),
1379
2033
  'Place html directly above the purchase button; never pre-check the box. For consent records as evidence, pass site to get the Elements snippet instead.',
@@ -1388,7 +2042,7 @@ const getElementsSnippet = {
1388
2042
  title: 'Get the Checkbox Elements snippet for a site',
1389
2043
  description: [
1390
2044
  '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.',
1391
- 'The element renders the locked compliant disclosure 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.',
2045
+ '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.',
1392
2046
  'This creates a disclosure record (write, safe to repeat). Consents themselves are recorded by the browser, never by agents.',
1393
2047
  ].join('\n'),
1394
2048
  input: { site: requiredSite, plan: z.string().max(200).optional().describe('Billing price / plan id (price_…) when the site has several.'), ...elementsTermsShape },
@@ -1493,7 +2147,7 @@ const listDueNotices = {
1493
2147
  annotations: { ...ro, title: 'Due notices' },
1494
2148
  async run(args, { api }) {
1495
2149
  const list = await api.request('GET', '/v1/notices/due', {
1496
- query: pick(args, ['site', 'status', 'kind', 'subscription', 'limit', 'starting_after']),
2150
+ query: { ...pick(args, ['site', 'status', 'kind', 'subscription', 'limit', 'starting_after']), ...(typeof args.kind === 'string' ? { kind: args.kind.split(',').map((k) => noticeKind(k.trim())).join(',') } : {}) },
1497
2151
  });
1498
2152
  const overdue = list.data.filter((d) => d.status === 'overdue').length;
1499
2153
  return {
@@ -1538,7 +2192,7 @@ const getNoticeTemplates = {
1538
2192
  site: z.string().max(100).optional().describe('Site (site_…): adds BCC addresses; required with check.'),
1539
2193
  check: z
1540
2194
  .object({
1541
- kind: z.enum(['acknowledgment', 'trial_ending', 'renewal_reminder', 'annual_terms', 'price_change', 'cancellation_confirmation']).describe('Notice kind.'),
2195
+ kind: emailKind.describe('Notice kind.'),
1542
2196
  subject: z.string().max(1000).describe('Subject (merge fields allowed).'),
1543
2197
  text: z.string().max(100_000).optional().describe('Plain-text body.'),
1544
2198
  html: z.string().max(100_000).optional().describe('HTML body.'),
@@ -1554,7 +2208,7 @@ const getNoticeTemplates = {
1554
2208
  if (!args.site)
1555
2209
  throw new Error('site is required to check a template (its plan and jurisdictions scope the check).');
1556
2210
  const r = await api.request('POST', '/v1/notices/templates/check', {
1557
- body: { site: args.site, ...check },
2211
+ body: { site: args.site, ...check, kind: noticeKind(check.kind) },
1558
2212
  });
1559
2213
  return {
1560
2214
  headline: `Template check: ${r.verdict.toUpperCase()}.`,
@@ -1577,8 +2231,9 @@ const callApi = {
1577
2231
  name: 'call_api',
1578
2232
  title: 'Call any Checkbox API endpoint',
1579
2233
  description: [
1580
- 'For a next_action whose operation has no dedicated tool (e.g. createTestCredential, createStripeCheckoutConfig, checkDomainVerification, updateTask, updateSite, listConsents). Calls the Checkbox /v1 API with this server\'s key; paths and bodies are in https://app.checkbox.site/v1/openapi.json.',
1581
- 'Examples: {"method":"POST","path":"/v1/sites/site_123/test_credentials","body":{"type":"stripe_test","secret_key":"rk_test_…"}} ; {"method":"GET","path":"/v1/account"}.',
2234
+ '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.',
2235
+ 'Examples: {"method":"GET","path":"/v1/account"} ; {"method":"POST","path":"/v1/sites/site_123/domain_verification/check"}.',
2236
+ 'Never send API keys or Stripe keys through it, except a Stripe TEST restricted key via store_stripe_test_key (not call_api). Live keys are added by the human in the Checkbox dashboard, never pasted into the chat.',
1582
2237
  '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.',
1583
2238
  ].join('\n'),
1584
2239
  input: {
@@ -1603,10 +2258,74 @@ const callApi = {
1603
2258
  },
1604
2259
  };
1605
2260
  /** Every tool, in the order clients list them. */
2261
+ /**
2262
+ * Round 12 (pilot 11, nextjs + agent users): setup_checkbox dropped unknown fields silently; the homepage prompt says
2263
+ * "brand", the schema says merchant_name, so the site was named "localhost" with no warning. Common spellings of the
2264
+ * brand are read as merchant_name; any other unknown field is listed back as ignored.
2265
+ */
2266
+ 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' });
2267
+ const BILLING_PLATFORMS = new Set(['stripe', 'shopify', 'recharge', 'chargebee', 'recurly', 'paddle', 'lemon_squeezy', 'woocommerce', 'custom']);
2268
+ export function normalizeSetupArgs(raw, known) {
2269
+ const args = {};
2270
+ const aliased = [];
2271
+ const ignored = [];
2272
+ for (const [k, v] of Object.entries(raw))
2273
+ if (known.has(k))
2274
+ args[k] = v;
2275
+ for (const [k, v] of Object.entries(raw)) {
2276
+ if (known.has(k))
2277
+ continue;
2278
+ const to = SETUP_FIELD_ALIASES[k];
2279
+ const ok = typeof v === 'string' && !!v.trim() && (to !== 'billing_platform' || BILLING_PLATFORMS.has(v.trim().toLowerCase()));
2280
+ if (to && known.has(to) && ok && args[to] === undefined) {
2281
+ args[to] = to === 'billing_platform' ? v.trim().toLowerCase() : v.trim().slice(0, 100);
2282
+ aliased.push(`${k} (read as ${to})`);
2283
+ }
2284
+ else
2285
+ ignored.push(k);
2286
+ }
2287
+ return { args, aliased, ignored };
2288
+ }
2289
+ /** Messages of the API's `warnings` ({ code, message }) on a setup result (or its session). */
2290
+ export function apiWarningMessages(data) {
2291
+ if (!data || typeof data !== 'object')
2292
+ return [];
2293
+ const d = data;
2294
+ const list = Array.isArray(d.warnings) ? d.warnings : Array.isArray(d.session?.warnings) ? d.session.warnings : [];
2295
+ return list.flatMap((w) => (w && typeof w === 'object' && typeof w.message === 'string' ? [`Warning: ${w.message}`] : []));
2296
+ }
2297
+ function acceptingSetupAliases(def) {
2298
+ const known = new Set(Object.keys(def.input));
2299
+ return {
2300
+ ...def,
2301
+ passthrough: true,
2302
+ async run(raw, ctx) {
2303
+ const { args, aliased, ignored } = normalizeSetupArgs(raw, known);
2304
+ // Round 18 (item 19, pilot 17): update_setup refuses unknown top-level fields, as it refuses unknown nested ones
2305
+ // (an ignored field read as "saved" to the agent).
2306
+ if (ignored.length && def.name === 'update_setup') {
2307
+ throw new ToolError(unknownArgumentsMessage(def.name, ignored, [...known]), { type: 'fix_request', message: 'Rename or remove those fields and call update_setup again.' });
2308
+ }
2309
+ const out = await def.run(args, ctx);
2310
+ // Pilot round 13: the API's input warnings (plan amounts that look like dollars, or differ from the Stripe Price)
2311
+ // are shown, and kept next to the field warnings below.
2312
+ const apiWarnings = apiWarningMessages(out.data);
2313
+ if (!aliased.length && !ignored.length && !apiWarnings.length)
2314
+ return out;
2315
+ const warnings = [
2316
+ ...apiWarnings,
2317
+ ...(aliased.length ? [`Read ${aliased.join(', ')}.`] : []),
2318
+ ...(ignored.length ? [`Ignored unknown field${ignored.length === 1 ? '' : 's'}: ${ignored.join(', ')}. ${def.name} accepts: ${[...known].filter((k) => k !== 'device_code').join(', ')}.`] : []),
2319
+ ];
2320
+ const data = out.data && typeof out.data === 'object' && !Array.isArray(out.data) ? { ...out.data, warnings } : out.data;
2321
+ return { ...out, notes: [...warnings, ...(out.notes ?? [])], data };
2322
+ },
2323
+ };
2324
+ }
1606
2325
  export const TOOLS = [
1607
- setupCheckbox,
2326
+ acceptingSetupAliases(setupCheckbox),
1608
2327
  getSetupStatus,
1609
- updateSetup,
2328
+ acceptingSetupAliases(updateSetup),
1610
2329
  completeSetup,
1611
2330
  getCompliancePlan,
1612
2331
  getTask,
@@ -1614,6 +2333,7 @@ export const TOOLS = [
1614
2333
  answerTask,
1615
2334
  verifySite,
1616
2335
  getVerification,
2336
+ storeStripeTestKey,
1617
2337
  startTestRun,
1618
2338
  getTestRun,
1619
2339
  checkDisclosure,
@@ -1628,6 +2348,7 @@ export const TOOLS = [
1628
2348
  getSiteStatus,
1629
2349
  listFindings,
1630
2350
  getFinding,
2351
+ publishShopifyTerms,
1631
2352
  runScan,
1632
2353
  getScan,
1633
2354
  listLawUpdates,
@@ -1648,12 +2369,13 @@ export const TOOL_OPERATIONS = {
1648
2369
  update_setup: ['updateSetupSession'],
1649
2370
  update_site: ['updateSite'],
1650
2371
  complete_setup: ['completeSetupSession'],
1651
- get_compliance_plan: ['retrieveCompliancePlan'],
2372
+ get_compliance_plan: ['retrieveCompliancePlan', 'claimReviewPrompt'],
1652
2373
  get_task: ['retrieveTask'],
1653
- verify_task: ['verifyTask'],
2374
+ verify_task: ['verifyTask', 'claimReviewPrompt'],
1654
2375
  answer_task: ['answerTask'],
1655
2376
  verify_site: ['verifySite'],
1656
2377
  get_verification: ['retrieveVerification'],
2378
+ store_stripe_test_key: ['createTestCredential'],
1657
2379
  start_test_run: ['createTestRun'],
1658
2380
  get_test_run: ['listTestRuns', 'retrieveTestRun'],
1659
2381
  check_disclosure: ['checkDisclosure'],
@@ -1667,6 +2389,7 @@ export const TOOL_OPERATIONS = {
1667
2389
  get_site_status: ['retrieveSite', 'retrieveLatestEvaluation', 'listFindings'],
1668
2390
  list_findings: ['listFindings'],
1669
2391
  get_finding: ['retrieveFinding', 'retrieveFindingFix'],
2392
+ publish_shopify_terms: ['publishShopifyTerms'],
1670
2393
  run_scan: ['createScan'],
1671
2394
  get_scan: ['retrieveScan'],
1672
2395
  list_law_updates: ['listLawUpdates'],