@checkbox-site/mcp 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.'),
103
+ account_url: z.string().max(2048).nullable().optional().describe('Account page where subscribers manage / cancel (null clears a URL).'),
104
+ terms_url: z.string().max(2048).nullable().optional().describe('Terms of service URL. Checkbox fetches and checks it.'),
105
+ privacy_url: z.string().max(2048).nullable().optional().describe('Privacy policy URL.'),
106
+ refund_url: z.string().max(2048).nullable().optional().describe('Refund policy URL.'),
107
+ cancellation_url: z.string().max(2048).nullable().optional().describe('Cancellation policy URL, if separate from the Terms.'),
66
108
  support_email: z.string().max(320).optional().describe('Support address subscribers can write to.'),
67
- merchant_name: z.string().max(100).optional().describe('The brand subscribers know (names the seller in generated disclosures), e.g. from the <title> or package.json. Default: the host name, which the plan then asks you to replace.'),
109
+ merchant_name: z.string().max(100).optional().describe('The brand subscribers know (names the seller in generated disclosures), e.g. from the <title> or package.json (also accepted as "brand"). Default: the host name, which the plan then asks you to replace.'),
68
110
  billing_platform: z
69
111
  .enum(['stripe', 'shopify', 'recharge', 'chargebee', 'recurly', 'paddle', 'woocommerce', 'custom'])
70
112
  .optional()
71
113
  .describe('Billing platform the code uses.'),
72
114
  plans: z
73
- .array(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, price, interval, trial, Stripe price id) with update_setup, or ask the human to connect Stripe.';
410
+ const lines = plans.slice(0, 6).map((p) => {
411
+ const every = (p.interval_count ?? 1) > 1 ? `every ${p.interval_count} ${p.interval}s` : `/${p.interval ?? '?'}`;
412
+ const trial = p.trial_days ? `, ${p.trial_days}-day trial` : ', no trial';
413
+ const ids = p.stripe_price_ids?.length ? `, ${p.stripe_price_ids.slice(0, 3).join(' ')}` : '';
414
+ return `${untrusted(p.name ?? 'plan', 80)} ${p.price ?? ''}${every}${trial}${ids}`;
415
+ });
416
+ return `Plans Checkbox judges against (${plans.length}): ${lines.join('; ')}${plans.length > 6 ? '; …' : ''}.`;
417
+ }
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).',
305
- '',
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.',
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).',
309
514
  '',
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 it returns test-mode API keys (shown once): save CHECKBOX_SECRET_KEY to .env.local (git-ignored), never commit it, and never ask the human to paste a key.',
519
+ '',
520
+ 'After this: follow next_action (usually verify the domain and install the snippet, then get_setup_status with verify=true), then get_compliance_plan.',
316
521
  ].join('\n'),
317
522
  input: {
318
523
  ...setupFactsShape,
@@ -347,6 +552,8 @@ const setupCheckbox = {
347
552
  notes.push(`claude mcp add --transport http checkbox ${ctx.mcpUrl} --header "Authorization: Bearer $CHECKBOX_SECRET_KEY" (Cursor / VS Code: add the same header to the server config). Until then, the plan below is all you need; the HTTP API works with the key right away.`);
348
553
  }
349
554
  notes.push('The secret key is shown only this once. Never commit it; the publishable key (pk_) is safe in browser code (e.g. NEXT_PUBLIC_CHECKBOX_PUBLISHABLE_KEY).');
555
+ // Round 16 (pilot 15, sveltekit / change-guard): two public keys in one response, unexplained.
556
+ notes.push("Two public keys: the snippet tag's data-key is the site's snippet key (install.snippet_key; use the script_tag exactly as given); publishable_key is the account key for Checkbox Elements and consent receipts only.");
350
557
  // In the same call, with the new key: apply the facts given (they are not lost between the two calls), run the
351
558
  // first checks, and return the setup checklist's next step — exactly what `checkbox init` does.
352
559
  // Normal-round pilot (sveltekit): the first checks failed with "No API key provided" and the facts given with the
@@ -354,26 +561,53 @@ const setupCheckbox = {
354
561
  const keyed = ctx.apiForKey?.(res.secret_key) ?? (ctx.adoptKeys ? ctx.api : undefined);
355
562
  let next;
356
563
  let firstCheck;
564
+ let site = res.site ?? null;
565
+ let inputWarnings = [];
566
+ let snippetKey;
357
567
  if (keyed && res.setup_session) {
358
568
  try {
359
569
  const facts = { ...(PENDING_SETUP_FACTS.get(args.device_code) ?? {}), ...pick(args, Object.keys(setupFactsShape)) };
360
570
  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 });
571
+ // Pilot round 13 (change-guard): `agent` given with the device_code call (or known only now: a stateless
572
+ // remote connection has no client name on the first call) was never stored, so the session said agent null.
573
+ const agentName = args.agent ?? ctx.key.appName ?? ctx.clientName;
574
+ if (agentName)
575
+ facts.client = { name: 'checkbox-mcp', version: ctx.serverVersion, agent: String(agentName).slice(0, 100) };
576
+ if (Object.keys(facts).length) {
577
+ const upd = await keyed.request('POST', `/v1/setup_sessions/${res.setup_session}`, { body: facts });
578
+ if (Array.isArray(upd?.warnings))
579
+ inputWarnings = upd.warnings;
580
+ }
363
581
  const v = await keyed.request('POST', `/v1/setup_sessions/${res.setup_session}/verify`, { body: {} });
364
582
  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}`) ?? [] };
583
+ snippetKey = v.setup_session?.install?.snippet_key;
584
+ // Pilot round 13 (change-guard): the site created by these facts was missing from the result (site: null next
585
+ // to "site: done"), and the JSON next_action was the device token's, not the checklist's.
586
+ if (typeof v.setup_session?.site === 'string')
587
+ site = v.setup_session.site;
588
+ firstCheck = { evaluated: v.evaluated ?? false, scan: v.scan ?? null, steps: v.setup_session?.steps?.map((st) => `${st.key}: ${st.status}`) ?? [], ...(Array.isArray(v.setup_session?.site_plans) ? { site_plans: v.setup_session.site_plans } : {}) };
366
589
  notes.push(firstCheckNote(next));
590
+ const pl = sitePlansNote(v.setup_session?.site_plans);
591
+ if (pl)
592
+ notes.push(pl);
367
593
  }
368
594
  catch (err) {
369
595
  notes.push(`First checks did not run yet (${err instanceof Error ? err.message.slice(0, 160) : 'error'}); call get_setup_status with verify=true.`);
370
596
  }
371
597
  }
372
598
  return {
373
- headline: `Approved. Checkbox account ready${res.site ? ` with test-mode site ${res.site}` : ''}; setup session ${res.setup_session ?? '(none)'}.`,
599
+ headline: `Approved. Checkbox account ready${site ? ` with test-mode site ${site}` : ''}; setup session ${res.setup_session ?? '(none)'}.`,
374
600
  notes,
375
601
  ...(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 } : {}) },
602
+ data: {
603
+ ...res,
604
+ site,
605
+ ...(next ? { next_action: next } : {}),
606
+ env: { CHECKBOX_SECRET_KEY: res.secret_key, ...(res.publishable_key ? { CHECKBOX_PUBLISHABLE_KEY: res.publishable_key } : {}) },
607
+ public_keys: labelledPublicKeys(snippetKey ?? res.install?.snippet_key, res.publishable_key),
608
+ ...(firstCheck ? { first_check: firstCheck } : {}),
609
+ ...(inputWarnings.length ? { warnings: inputWarnings } : {}),
610
+ },
377
611
  };
378
612
  }
379
613
  if (!api.hasKey) {
@@ -412,13 +646,31 @@ const setupCheckbox = {
412
646
  const notes = session.review_url ? [`Human review page: ${session.review_url}`] : [];
413
647
  if (session.next_action?.type === 'verify_domain')
414
648
  notes.push(firstCheckNote(session.next_action));
649
+ const plansLine = sitePlansNote(session.site_plans);
650
+ if (plansLine)
651
+ notes.push(plansLine);
415
652
  return {
416
653
  headline: `Setup session ${session.id} started${session.business?.site_url ? ` for ${untrusted(session.business.site_url, 100)}` : ''}${session.site ? ` (test-mode site ${session.site})` : ''}.`,
417
654
  notes,
418
- data: session,
655
+ data: (() => {
656
+ const keys = labelledPublicKeys(session.install?.snippet_key, session.publishable_key);
657
+ return keys.length ? { ...withoutLegacyScore(session), public_keys: keys } : withoutLegacyScore(session);
658
+ })(),
419
659
  };
420
660
  },
421
661
  };
662
+ /**
663
+ * Round 20 (pilot 19 item 16, sveltekit / change-guard: a pk_ site key and a pk_test_ key side by side, unlabelled): each
664
+ * public key says what it is for.
665
+ */
666
+ export function labelledPublicKeys(snippetKey, publishableKey) {
667
+ const out = [];
668
+ if (typeof snippetKey === 'string' && snippetKey)
669
+ out.push({ key: snippetKey, name: 'Site snippet key', use: 'The data-key of the Checkbox snippet <script> tag, for this site only (use install.script_tag exactly as given).' });
670
+ if (typeof publishableKey === 'string' && publishableKey)
671
+ out.push({ key: publishableKey, name: 'Account publishable key', use: 'For Checkbox Elements (<checkbox-disclosure>) and consent receipts (data-publishable-key) only; never the snippet data-key.' });
672
+ return out;
673
+ }
422
674
  const getSetupStatus = {
423
675
  name: 'get_setup_status',
424
676
  title: 'Get the setup checklist (optionally re-check the site)',
@@ -439,14 +691,30 @@ const getSetupStatus = {
439
691
  if (!keyAllows(key, 'setup_sessions:write'))
440
692
  throw new ToolError('verify=true needs a key with setup_sessions write access; call again without verify.');
441
693
  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) };
694
+ const notes = [];
695
+ if (res.domain_verified)
696
+ notes.push(`Domain verified: ${res.domain_verified.method === 'snippet' ? `the Checkbox snippet is live on ${res.domain_verified.domain}` : `${res.domain_verified.domain} (${res.domain_verified.method})`}.`);
697
+ if (res.unverified_scan?.queued)
698
+ notes.push('First read-only scan queued (unverified: no checkout is submitted until you verify the domain).');
699
+ else if (res.unverified_scan?.reason)
700
+ notes.push(`No new read-only scan: ${untrusted(res.unverified_scan.reason, 200)}.`);
701
+ const ro = unverifiedScanNote(res.setup_session?.unverified_scan);
702
+ if (ro && !res.unverified_scan?.queued)
703
+ notes.push(ro);
704
+ const pl = sitePlansNote(res.setup_session?.site_plans);
705
+ if (pl)
706
+ notes.push(pl);
707
+ return { headline: `Re-checked setup session ${id}.`, ...(notes.length ? { notes } : {}), data: withoutLegacyScore(res), next: nextActionOf(res) ?? nextActionOf(res.session) };
443
708
  }
444
709
  const session = await api.request('GET', `/v1/setup_sessions/${enc(id)}`);
445
710
  const open = (session.steps ?? []).filter((s) => s.status && !['done', 'skipped', 'not_applicable'].includes(s.status));
711
+ const ro = unverifiedScanNote(session.unverified_scan);
712
+ const plansLine = sitePlansNote(session.site_plans);
446
713
  return {
447
714
  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,
715
+ // The policies step says what an unverified preview read (or that the pages wait for domain verification).
716
+ notes: [...open.slice(0, 8).map((s) => `${s.status}: ${s.title ?? s.key}${s.key === 'policies' && s.summary ? ` (${s.summary.slice(0, 240)})` : ''}`), ...(ro ? [ro] : []), ...(plansLine ? [plansLine] : [])],
717
+ data: withoutLegacyScore(session),
450
718
  };
451
719
  },
452
720
  };
@@ -472,9 +740,24 @@ const updateSetup = {
472
740
  const id = await resolveSetupSession(api, args.setup_session);
473
741
  const body = pick(args, [...Object.keys(setupFactsShape), 'snippet_installed_in', 'env_written', 'live_site', 'refetch_policies']);
474
742
  const session = await api.request('POST', `/v1/setup_sessions/${enc(id)}`, { body });
475
- return { headline: `Updated setup session ${session.id}.`, data: session };
743
+ // Round 20 (pilot 19 item 6): a plans change re-evaluates the site; say where the new plan's tasks show up.
744
+ const notes = Array.isArray(args.plans) && args.plans.length
745
+ ? ['Plans saved: Checkbox re-evaluates the site with them now, so get_compliance_plan lists each plan\'s tasks (e.g. the trial reminder of a plan with a trial). If one seems missing, call verify_site to re-check at once.']
746
+ : [];
747
+ return { headline: `Updated setup session ${session.id}.`, notes, data: withoutLegacyScore(session) };
476
748
  },
477
749
  };
750
+ /** Round 13 (pilot 12, codex: "sent" while nothing new arrived): the summary email's real delivery status, with when. */
751
+ export function summaryEmailLine(status, d) {
752
+ const when = d?.at ? ` at ${new Date(d.at * 1000).toISOString().slice(0, 16).replace('T', ' ')} UTC` : '';
753
+ if (status === 'sent')
754
+ return `sent${d?.to ? ` to ${d.to}` : ''} (the mail server accepted it${when}; it is sent once per setup)`;
755
+ if (status === 'failed')
756
+ return `failed${d?.error ? ` (${d.error})` : ''}; call complete_setup again to retry`;
757
+ if (status === 'not_configured')
758
+ return 'not sent: this deployment has no email delivery';
759
+ return 'not sent';
760
+ }
478
761
  const completeSetup = {
479
762
  name: 'complete_setup',
480
763
  title: 'Finish setup and email the human a summary',
@@ -486,15 +769,32 @@ const completeSetup = {
486
769
  const id = await resolveSetupSession(api, args.setup_session);
487
770
  const session = await api.request('POST', `/v1/setup_sessions/${enc(id)}/complete`, { body: {} });
488
771
  return {
489
- headline: `Setup ${session.id} completed; summary email: ${session.owner_email ?? 'not sent'}.`,
772
+ headline: `Setup ${session.id} completed; summary email: ${summaryEmailLine(session.owner_email, session.owner_email_detail)}.`,
490
773
  notes: session.review_url ? [`Human review page: ${session.review_url}`] : [],
491
- data: session,
774
+ data: withoutLegacyScore(session),
492
775
  };
493
776
  },
494
777
  };
778
+ // ---------------------------------------------------------------------------------------------------------------------
779
+ // Compliance plan loop
780
+ // ---------------------------------------------------------------------------------------------------------------------
781
+ /**
782
+ * Round 21 (pilot 20 item 8, lawyer): the plan status in words ("Checks pass"), never the bare machine value `compliant`
783
+ * an agent would repeat to a merchant. Older APIs without status_label: the same mapping here.
784
+ */
785
+ const PLAN_STATUS_WORDS = { action_required: 'Fixes needed', verifying: 'Checking your changes', compliant: 'Checks pass', blocked: 'Your call' };
786
+ export function planStatusWords(status, label) {
787
+ return label || PLAN_STATUS_WORDS[status] || status;
788
+ }
789
+ /** "Old task (covered by: Open task)": the open task sharing a check with a superseded one. Pure, exported for tests. */
790
+ export function coveredLine(t, open) {
791
+ const ids = t.check_ids ?? [];
792
+ const by = open.find((o) => o.key !== t.key && (o.check_ids ?? []).some((c) => ids.includes(c)));
793
+ return by ? `${t.title} (covered by: ${by.title})` : `${t.title} (covered by another open task)`;
794
+ }
495
795
  /** The plan with each task reduced to what an agent needs to choose the next one (the full task: get_task). */
496
796
  export function slimPlan(plan) {
497
- const clip = (v, n) => (typeof v === 'string' && v.length > n ? `${v.slice(0, n - 1)}…` : v);
797
+ const clip = clipWords;
498
798
  const tasks = (plan.tasks ?? []).map((t) => {
499
799
  const full = t;
500
800
  const change = (full.change ?? {});
@@ -518,15 +818,28 @@ export function slimPlan(plan) {
518
818
  });
519
819
  return { ...plan, tasks: tasks };
520
820
  }
821
+ /**
822
+ * One-time review hint: when a site's plan is compliant, the API hands out the prompt once per site
823
+ * (claimReviewPrompt). Never fails the tool: older servers or a missing scope simply mean no hint.
824
+ */
825
+ async function reviewHint(api, site, trigger) {
826
+ try {
827
+ const p = await api.request('POST', `/v1/sites/${enc(site)}/review_prompt`, { body: { trigger } });
828
+ return p.show && p.message ? [`One-time: ${p.message}`] : [];
829
+ }
830
+ catch {
831
+ return [];
832
+ }
833
+ }
521
834
  const getCompliancePlan = {
522
835
  name: 'get_compliance_plan',
523
836
  title: 'Get the compliance plan (ordered tasks)',
524
837
  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.',
838
+ 'The main tool after setup. Returns the ordered tasks that make the site pass the automatic-renewal checks, recomputed from the latest observations.',
526
839
  'Each task has: requirement + legal citation, why (what Checkbox observed), where (file / component / URL / CSS selector / Stripe object), change (exact unified-diff template, copy text or Stripe params), and verify (how to check).',
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).',
840
+ 'Plan status: action_required (apply open tasks), verifying (waiting for confirming observations), compliant (done: the checks pass — "pending_live_check" tasks count as passing: they complete on their own or with a proof run), blocked (only human questions — "Your call" — left).',
528
841
  'Tasks are tagged tier required / recommended / extra. Only required tasks matter for compliance; recommended and extra tasks are optional suggestions (mention them once, do not push them).',
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.',
842
+ 'Loop: apply the first open required task in the code → verify_task → get_compliance_plan → … until the checks pass (status compliant). Tasks with status "manual" carry a question with a recommended answer: ask the HUMAN, then answer_task. Never invent answers.',
530
843
  ].join('\n'),
531
844
  input: { site: siteId.optional() },
532
845
  scopes: ['compliance_plans:read'],
@@ -537,17 +850,39 @@ const getCompliancePlan = {
537
850
  const open = (plan.tasks ?? []).filter((t) => !['done', 'wont_fix'].includes(t.status));
538
851
  const required = open.filter((t) => (t.tier ?? 'required') === 'required');
539
852
  const optional = open.filter((t) => (t.tier ?? 'required') !== 'required');
853
+ // One to-do list: the same ordered open tasks (titles, order, counts) as the dashboard, report, emails and CLI.
854
+ const todo = planTodo((plan.tasks ?? []));
855
+ const rest = required.filter((t) => !todo.some((x) => x.id === t.id));
856
+ const { score: _score, insurable: _insurable, ...shown } = plan;
540
857
  return {
541
- headline: `Plan for ${site}: ${plan.compliance?.label ?? statusLine(plan.status)} (plan ${plan.status}), score ${plan.score ?? 'n/a'}. ${plan.summary ?? ''}`,
858
+ // Audit P2: one Next line (the plan's next_action below); the summary's own "Next: …" is not repeated.
859
+ headline: `Plan for ${site}: ${plainLabel(plan.compliance?.label ?? 'Not checked yet')} (plan status: ${planStatusWords(plan.status, plan.status_label)}).`,
542
860
  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`.'] : []),
861
+ ...(todo.length ? [`To do (${todo.length}, in order):`] : []),
862
+ ...todo.map((t, i) => `${i + 1}. ${t.id} ${todoLine(t)}`),
863
+ ...rest.map((t) => `${t.id} [${t.status}${t.priority ? `, ${t.priority}` : ''}${t.change?.type ? `, ${t.change.type}` : ''}] ${t.title}`),
864
+ // Round 14: a task closed because it no longer applies is "Not applicable", never "done". Round 16 (pilot 15,
865
+ // codex): a task closed by a passing verification is "Done (verified)", never "Not applicable".
866
+ ...(() => {
867
+ const closed = (plan.tasks ?? []);
868
+ const verified = closed.filter((t) => t.status === 'done' && t.closed_reason === 'verified' && t.compliance_status !== 'not_applicable');
869
+ // Round 20 (pilot 19 item 9): a task merged into another open task is "covered by" it, never "not applicable".
870
+ const covered = closed.filter((t) => t.closed_reason === 'superseded');
871
+ const na = closed.filter((t) => t.compliance_status === 'not_applicable' && t.closed_reason !== 'verified' && t.closed_reason !== 'superseded');
872
+ return [
873
+ ...(verified.length ? [`Done (verified): ${verified.map((t) => t.title).join('; ')}`] : []),
874
+ ...(covered.length ? [`Merged into another task: ${covered.map((t) => coveredLine(t, open)).join('; ')}`] : []),
875
+ ...(na.length ? [`Not applicable (closed without a change, nothing observed passing): ${na.map((t) => t.title).join('; ')}`] : []),
876
+ ];
877
+ })(),
878
+ ...(optional.length ? [`Optional suggestions (${plan.protection_level ?? 'balanced'} protection; never block the checks from passing): ${optional.map((t) => t.title).join('; ')}`] : []),
879
+ ...(plan.optional_improvements ? ['Optional (never blocking, not part of compliance): conversion suggestions for this site with `suggest_improvements`.'] : []),
546
880
  'Each task below is a summary; call get_task with its id for the full change (diff, copy or params) before applying it.',
881
+ ...(plan.status === 'compliant' ? await reviewHint(api, site, 'plan_compliant') : []),
547
882
  ],
548
883
  // Normal-round pilot (sveltekit): the full plan (53K characters) was cut mid-JSON by the client. The plan here is a
549
884
  // compact summary of every task; get_task returns one task in full.
550
- data: slimPlan(plan),
885
+ data: slimPlan(shown),
551
886
  };
552
887
  },
553
888
  };
@@ -560,9 +895,23 @@ const getTask = {
560
895
  annotations: { ...ro, title: 'Task' },
561
896
  async run(args, { api }) {
562
897
  const task = await api.request('GET', `/v1/tasks/${enc(String(args.task))}`);
563
- return { headline: `Task ${task.id} [${task.status}]: ${task.title}`, data: task };
898
+ // Audit P3: a task citing every state's law listed dozens of citations; the first 8 and a count are enough here.
899
+ const cites = task.requirement?.citations;
900
+ const shown = Array.isArray(cites) && cites.length > 8 ? { ...task, requirement: { ...task.requirement, citations: cites.slice(0, 8), citations_more: cites.length - 8 } } : task;
901
+ const word = task.compliance_status === 'not_applicable' ? 'not applicable' : task.status;
902
+ return { headline: `Task ${task.id} [${word}]: ${task.title}`, data: shown };
564
903
  },
565
904
  };
905
+ /** Audit: the pre-check's per-check results, one compact line each (agents only saw the overall verdict). */
906
+ export function precheckLines(pre, max = 12) {
907
+ const checks = pre?.checks ?? [];
908
+ if (!checks.length)
909
+ return [];
910
+ const order = { fail: 0, needs_review: 1, missing_data: 1, pass: 2, not_applicable: 3 };
911
+ const sorted = [...checks].sort((a, b) => (order[a.status] ?? 1) - (order[b.status] ?? 1));
912
+ const lines = sorted.slice(0, max).map((c) => `${c.check_id}: ${c.status}${c.reason ? ` — ${untrusted(c.reason, 180)}` : ''}${c.fix ? ` Fix: ${untrusted(c.fix, 200)}` : ''}`);
913
+ return [`Pre-check (${pre?.verdict ?? 'n/a'}), per check:`, ...lines, ...(checks.length > max ? [`and ${checks.length - max} more (data.precheck.checks)`] : [])];
914
+ }
566
915
  const verifyTask = {
567
916
  name: 'verify_task',
568
917
  title: 'Verify a task after applying its change',
@@ -578,29 +927,56 @@ const verifyTask = {
578
927
  task: z.string().max(100).describe('Task id (task_…).'),
579
928
  html: z.string().max(950_000).optional().describe('Page tasks: the rendered page HTML.'),
580
929
  stripe_checkout_session: z.record(z.string(), z.unknown()).optional().describe('Stripe tasks: the params your code passes to stripe.checkout.sessions.create.'),
930
+ stripe_checkout_sessions: z.array(z.record(z.string(), z.unknown())).max(10).optional().describe('Stripe tasks, one params object per plan.'),
581
931
  email: z
582
932
  .object({
583
- kind: z.enum(['acknowledgment', 'trial_reminder', 'renewal_reminder', 'price_change', 'annual_terms', 'cancellation_confirmation']).optional(),
933
+ kind: emailKind.optional(),
584
934
  subject: z.string().max(1000),
585
935
  text: z.string().max(100_000).optional(),
586
936
  html: z.string().max(100_000).optional(),
587
937
  })
588
938
  .optional()
589
- .describe('Email tasks: the new template.'),
939
+ .describe('Email tasks: the new template (passes when it matches one plan fully).'),
940
+ plan: z.string().min(1).max(200).optional().describe('Email tasks: only this plan (name).'),
941
+ price_id: z.string().min(1).max(200).optional().describe('Email tasks: only this plan (Price id).'),
590
942
  recheck: z.boolean().optional().describe('true re-checks a task that is already done (evaluates again now and queues a scan) instead of returning its last result.'),
591
943
  wait,
592
944
  },
593
945
  scopes: ['compliance_plans:write'],
594
946
  annotations: { ...write(true, true), title: 'Verify task' },
595
947
  async run(args, { api }) {
596
- const body = pick(args, ['html', 'stripe_checkout_session', 'email', 'recheck']);
948
+ const body = pick(args, ['html', 'stripe_checkout_session', 'stripe_checkout_sessions', 'email', 'plan', 'price_id', 'recheck']);
949
+ if (body.email && typeof body.email === 'object' && body.email.kind)
950
+ body.email = { ...body.email, kind: checkKind(body.email.kind) };
597
951
  const v = await api.request('POST', `/v1/tasks/${enc(String(args.task))}/verify`, {
598
952
  query: { wait: args.wait ?? 25 },
599
953
  body,
600
954
  });
955
+ const hint = v.plan_status === 'compliant' && v.site ? await reviewHint(api, v.site, 'last_task_verified') : [];
601
956
  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).'] : [],
957
+ headline: `Verification ${v.id} of ${String(args.task)}: ${statusLine(v.status)}${v.plan_status ? `; plan is now ${planStatusWords(v.plan_status, v.plan_status_label)}` : ''}.${v.checked ? ` Checked: ${untrusted(v.checked, 300)}.` : ''}`,
958
+ notes: [
959
+ ...precheckLines(v.precheck),
960
+ ...((v.status === 'failed' || v.status === 'not_checked') && v.next_action?.type === 'verify_domain'
961
+ ? ['The domain is not verified yet. Waiting for the human to publish the DNS record / file / meta tag or install the snippet; don\'t retry until they say it\'s done. Nothing to change in the code.']
962
+ : v.next_action?.type === 'retry_later'
963
+ ? ['Checkbox could not confirm it (a problem on Checkbox\'s side): nothing to change in the code. Continue with the next task; verify again later.']
964
+ : v.next_action?.type === 'live_check_pending' && v.status === 'pending'
965
+ ? // Round 14 (pilot 13): Checkbox Notices sends this email; never "add the BCC".
966
+ [v.next_action.description ?? 'Checkbox Notices sends this email: nothing to add to your code. Continue with the next task.']
967
+ : v.next_action?.type === 'bcc_then_continue'
968
+ ? ['The copy is checked; the task closes when the first real email arrives with the BCC. Add the BCC to your sending code and continue with the next task (no need to poll).']
969
+ : v.status === 'failed'
970
+ ? ['The change did not satisfy the check: read diff (expected vs observed), fix the code, and call verify_task again.']
971
+ : v.status === 'not_checked'
972
+ ? ['Not checked yet: Checkbox is missing evidence (see next_action), not a code problem. Do not change the code for this; provide what next_action asks for.']
973
+ : v.status === 'pending_live_check'
974
+ ? ['Live check pending: the email content passes; Checkbox has not seen it sent for a real event yet. Nothing to change in the code: it completes on the first real send (or a proof run).']
975
+ : v.status === 'pending'
976
+ ? ['Waiting for a confirming observation; call get_verification later (or continue with other tasks).']
977
+ : []),
978
+ ...hint,
979
+ ],
604
980
  data: v,
605
981
  };
606
982
  },
@@ -608,7 +984,7 @@ const verifyTask = {
608
984
  const answerTask = {
609
985
  name: 'answer_task',
610
986
  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.',
987
+ description: 'For tasks with status "manual": record the HUMAN\'s answer to task.question. Ask the human first and pass their words; never answer on their behalf (answers are legal attestations stored in the evidence vault). answer is one of task.question.options; yes / no also work (= confirmed / needs_change). "no" turns the task into a change you can apply.',
612
988
  input: {
613
989
  task: z.string().max(100).describe('Task id (task_…) with status manual.'),
614
990
  answer: z.string().max(40).describe('One of the task.question.options, as the human chose.'),
@@ -618,25 +994,31 @@ const answerTask = {
618
994
  annotations: { ...write(false), title: 'Answer task' },
619
995
  async run(args, { api }) {
620
996
  const task = await api.request('POST', `/v1/tasks/${enc(String(args.task))}/answer`, { body: pick(args, ['answer', 'note']) });
997
+ // Round 21 (pilot 20 item 16): "done" on a task Checkbox already closed succeeds without recording anything.
998
+ if (task.status === 'done' && task.next_action?.type === 'retrieve_plan')
999
+ return { headline: `${task.id} was already done (Checkbox closed it itself): nothing to answer, nothing recorded.`, data: task };
621
1000
  return { headline: `Recorded the answer on ${task.id}; task is now ${task.status}.`, data: task };
622
1001
  },
623
1002
  };
624
1003
  const verifySite = {
625
1004
  name: 'verify_site',
626
1005
  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.',
1006
+ description: 'Re-reads the connected billing platform (Stripe portal, prices, Checkout Sessions; rate-limited), re-fetches the registered policies, pre-checks the checkout, re-evaluates every law and queues a scan. Use it for the first check after setup, after deploying several changes, after changing a Stripe setting, or when the plan looks stale. Waits up to `wait` seconds, then call get_compliance_plan.',
628
1007
  input: { site: siteId.optional(), wait },
629
1008
  scopes: ['compliance_plans:write'],
630
1009
  annotations: { ...write(true, true), title: 'Verify site' },
631
1010
  async run(args, { api }) {
632
1011
  const site = await resolveSite(api, args.site);
633
1012
  const v = await api.request('POST', `/v1/sites/${enc(site)}/verify`, { query: { wait: args.wait ?? 25 }, body: {} });
1013
+ // Pilot 11: say what was re-read from the billing platform (the note's first sentence), so a stale answer is visible.
1014
+ const reread = /^(?:Re-read |Could not re-read |[A-Z][\w ]{0,40} was read ).*?(?:before judging\.|seconds\)\.|ago\.|earlier\.)/.exec(v.precheck?.note ?? '')?.[0];
634
1015
  // 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'
1016
+ const base = v.next_action?.type === 'verify_domain'
636
1017
  ? `Site check ${v.id}: nothing was fetched or scanned because the site's domain is not verified yet (Checkbox only visits websites their owners have verified). Follow Next to verify it.`
637
1018
  : 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}.` : ''}`
1019
+ ? `${v.next_action?.type === 'first_check_complete' ? 'First check complete' : 'Site check complete'} (${v.id}): the open tasks of get_compliance_plan are the to-do list, not a failed change.${v.plan_status ? ` Plan: ${v.plan_status}.` : ''}`
639
1020
  : `Site check ${v.id}: ${statusLine(v.status)}${v.plan_status ? `; plan is ${v.plan_status}` : ''}.`;
1021
+ const headline = reread ? `${base} ${reread}` : base;
640
1022
  return { headline, data: v };
641
1023
  },
642
1024
  };
@@ -644,12 +1026,13 @@ const getVerification = {
644
1026
  name: 'get_verification',
645
1027
  title: 'Get a verification result',
646
1028
  description: 'Status of a verification from verify_task / verify_site: pending, passed or failed, expected vs observed per check, the diff and next_action. Pass wait (≤ 30 s) to wait for a pending one.',
647
- input: { verification: z.string().max(100).describe('Verification id (ver_…).'), wait: wait },
1029
+ input: { verification: z.string().max(100).describe('ver_…, or task_… (its newest)'), wait: wait },
648
1030
  scopes: ['compliance_plans:read'],
649
1031
  annotations: { ...ro, title: 'Verification' },
650
1032
  async run(args, { api }) {
651
1033
  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 };
1034
+ const notes = precheckLines(v.precheck);
1035
+ return { headline: `Verification ${v.id}: ${statusLine(v.status)}.`, ...(notes.length ? { notes } : {}), data: v };
653
1036
  },
654
1037
  };
655
1038
  // ---------------------------------------------------------------------------------------------------------------------
@@ -671,8 +1054,8 @@ const startTestRun = {
671
1054
  title: 'Run a synthetic test subscription (test mode)',
672
1055
  description: [
673
1056
  'Observes the WHOLE subscription lifecycle, not just the checkout: in the billing platform\'s TEST mode Checkbox subscribes a test customer (card 4242…) whose email is a Checkbox inbox, waits for your acknowledgment email, fast-forwards a Stripe test clock through every legal notice window before trial end and renewal (collecting reminder emails), then cancels online and waits for the cancellation confirmation. Nothing is charged.',
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.',
1057
+ 'Needs a Stripe TEST key stored for the site — the human adds it in the Checkbox dashboard (the site\'s Proof page); never ask them to paste it into the chat. If missing, the error\'s next_action says so. For non-Stripe sites use type "generic".',
1058
+ 'Usually 2-4 minutes: poll get_test_run (its next_action says when). Each step reports observations, check results and agent-readable fixes; apply them and run again.',
676
1059
  'mode "real_checkout" is the PROOF run: the shopper buys through the merchant\'s own checkout on its test deployment (stripe.checkout_url or stripe.payment_link), so the merchant\'s webhook handlers and email code fire; give stripe.test_account + stripe.cancel_url when customers cancel in the app. A step is "passed" only with evidence; "warning" means needs review.',
677
1060
  ].join('\n'),
678
1061
  input: {
@@ -727,8 +1110,12 @@ const startTestRun = {
727
1110
  const getTestRun = {
728
1111
  name: 'get_test_run',
729
1112
  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() },
1113
+ description: 'A synthetic test subscription with every step (checkout, acknowledgment email, reminders at each legal window, cancellation, confirmation): what was observed, check results, evidence ids and fixes. While running, next_action.type is "wait" and waiting_for says what it waits for (pass wait: 30 to get the answer as soon as the run moves on). When finished, apply the fixes (usually via get_compliance_plan) and start another run to verify. Without test_run, returns the newest run of the site.',
1114
+ input: {
1115
+ test_run: z.string().max(100).optional().describe('Test run id (run_…). Default: the newest run of the site.'),
1116
+ site: siteId.optional(),
1117
+ wait: z.number().int().min(0).max(30).optional().describe('Long-poll: wait up to this many seconds for the run to move on before answering.'),
1118
+ },
732
1119
  scopes: ['test_runs:read'],
733
1120
  annotations: { ...ro, title: 'Test run' },
734
1121
  async run(args, { api }) {
@@ -740,10 +1127,14 @@ const getTestRun = {
740
1127
  throw new ToolError(`Site ${site} has no test run yet.`, { type: 'start_test_run', message: 'Call start_test_run.', operation_id: 'createTestRun' });
741
1128
  id = list.data[0].id;
742
1129
  }
743
- const run = await api.request('GET', `/v1/test_runs/${enc(id)}`);
1130
+ const wait = typeof args.wait === 'number' && args.wait > 0 ? Math.min(30, Math.floor(args.wait)) : undefined;
1131
+ const run = await api.request('GET', `/v1/test_runs/${enc(id)}`, wait ? { query: { wait } } : undefined);
1132
+ const w = run.waiting_for;
1133
+ const progress = w ? [w.step && w.steps_total ? `step ${w.step} of ${w.steps_total}` : '', w.minutes_left ? `about ${w.minutes_left} min left` : ''].filter(Boolean).join(', ') : '';
1134
+ const waiting = w ? `Waiting for: ${w.what}${w.until ? ` (until ${new Date(w.until * 1000).toISOString().slice(0, 16).replace('T', ' ')} UTC at the latest)` : ''}${progress ? ` [${progress}]` : ''}` : null;
744
1135
  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),
1136
+ headline: `Test run ${run.id}: ${run.status_label ?? statusLine(run.status)}${run.phase && run.status !== 'succeeded' && run.status !== 'failed' ? ` (phase ${run.phase})` : ''}.`,
1137
+ notes: [...(waiting ? [waiting] : []), ...(run.steps ?? []).map((s) => `${s.status ?? '?'}: ${s.title ?? s.key}${s.code ? ` (${s.code})` : ''}`)].slice(0, 13),
747
1138
  data: run,
748
1139
  };
749
1140
  },
@@ -755,9 +1146,9 @@ const checkDisclosure = {
755
1146
  name: 'check_disclosure',
756
1147
  title: 'Check checkout disclosure text',
757
1148
  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).',
1149
+ 'Before shipping a checkout: checks the automatic-renewal disclosure shown next to the purchase button against every applicable law (ROSCA, CA, NY, …) and returns per-check verdicts with citations plus a REPLACEMENT verified by the rules engine (disclosure_text, consent_label, button_text, drop-in html and jsx).',
759
1150
  'Pass the text exactly as rendered plus the plan terms. Example: {"text":"Renews monthly at $19.99 until you cancel.","price":19.99,"interval":"month","trial_days":14,"cancel_url":"https://acme.example/account/cancel","merchant_name":"Acme","consent":{"checkbox":true,"prechecked":false}}.',
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.',
1151
+ 'If verdict is fail, put replacement.jsx (React) or .html above the button, then call again with the new text, consent and placement.bold: true (it is semibold). needs_review on styling means a rendered scan must confirm.',
761
1152
  ].join('\n'),
762
1153
  input: {
763
1154
  text: z.string().min(1).max(5000).describe('The disclosure shown next to the purchase button, exactly as rendered.'),
@@ -765,8 +1156,7 @@ const checkDisclosure = {
765
1156
  interval: intervalStr,
766
1157
  button_text: z.string().max(200).optional().describe('Purchase button label, e.g. "Start free trial".'),
767
1158
  order_summary: z.string().max(1000).optional().describe('Other text next to the button (order summary), if any.'),
768
- placement: z
769
- .object({
1159
+ placement: strictObject({
770
1160
  above_button: z.boolean().optional(),
771
1161
  same_page_as_payment: z.boolean().optional(),
772
1162
  font_size_px: z.number().min(4).max(96).optional(),
@@ -774,16 +1164,15 @@ const checkDisclosure = {
774
1164
  bold: z.boolean().optional(),
775
1165
  boxed: z.boolean().optional(),
776
1166
  body_font_size_px: z.number().min(4).max(96).optional(),
777
- })
1167
+ }, 'placement')
778
1168
  .optional()
779
1169
  .describe('Where and how the disclosure is shown (defaults: above the button, same page, 14px, high contrast).'),
780
- consent: z
781
- .object({
1170
+ consent: strictObject({
782
1171
  checkbox: z.boolean(),
783
1172
  prechecked: z.boolean().optional(),
784
1173
  label: z.string().max(1000).optional(),
785
1174
  required: z.boolean().optional(),
786
- })
1175
+ }, 'consent')
787
1176
  .optional()
788
1177
  .describe('The consent checkbox, if the page has one. Omit when there is none.'),
789
1178
  jurisdictions: jurisdictions.optional(),
@@ -793,7 +1182,8 @@ const checkDisclosure = {
793
1182
  annotations: { ...ro, title: 'Check disclosure' },
794
1183
  async run(args, { api }) {
795
1184
  const r = await api.request('POST', '/v1/checks/disclosure', { body: args });
796
- return { headline: verdictHeadline('Disclosure', r), notes: checkNotes(r), data: slimCheckResult(r) };
1185
+ const covered = r.replacement?.verified ? { by: 'replacement (disclosure_text above the button + consent_label, or paste replacement.jsx in React / replacement.html elsewhere)', except: r.replacement.remaining_checks ?? [] } : undefined;
1186
+ return { headline: verdictHeadline('Disclosure', r), notes: checkNotes(r, 8, covered), data: withoutUnneededFix(slimCheckResult(r)) };
797
1187
  },
798
1188
  };
799
1189
  const checkStripeCheckoutSession = {
@@ -801,12 +1191,14 @@ const checkStripeCheckoutSession = {
801
1191
  title: 'Check Stripe Checkout Session params (returns corrected params)',
802
1192
  description: [
803
1193
  'Before shipping Stripe Checkout code: pass EXACTLY the object your code passes to stripe.checkout.sessions.create (mode "subscription"). Checkbox evaluates it against automatic-renewal laws and returns correction.corrected_params — the full params with the fix applied, ready to paste — plus an RFC 6902 JSON patch and before/after verification per check.',
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.',
1194
+ 'With a Price id (price_…) in line_items, pass site (its plans are used) or merchant { price, interval, trial_days, merchant_name, cancel_url }.',
1195
+ 'Note: Stripe styles custom_text itself, so "clear and conspicuous" stays needs_review here; the plan shows it as a note, never a fix.',
806
1196
  ].join('\n'),
807
1197
  input: {
808
1198
  params: z.record(z.string(), z.unknown()).describe('The Checkout Session params object, e.g. {"mode":"subscription","line_items":[{"price":"price_123","quantity":1}],"success_url":"https://acme.example/welcome"}.'),
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.'),
1199
+ merchant: merchantTerms.optional().describe('What the params do not say: merchant_name, cancel_url, and price/interval when line_items use a Price id.'),
1200
+ site: z.string().max(100).optional().describe('site_… whose plans judge it (as verify_task)'),
1201
+ plan: z.string().max(200).optional().describe('With site: plan name or price_…'),
810
1202
  jurisdictions: jurisdictions.optional(),
811
1203
  as_of: asOf.optional(),
812
1204
  },
@@ -814,12 +1206,14 @@ const checkStripeCheckoutSession = {
814
1206
  annotations: { ...ro, title: 'Check Stripe Checkout Session' },
815
1207
  async run(args, { api }) {
816
1208
  const r = await api.request('POST', '/v1/checks/stripe_checkout_session', { body: args });
817
- const notes = checkNotes(r);
818
- if (r.correction?.patch?.length)
1209
+ const fixing = !!r.correction?.patch?.length;
1210
+ const notes = checkNotes(r, 8, fixing && r.correction.verification?.passes ? { by: 'correction.corrected_params' } : undefined);
1211
+ if (fixing)
819
1212
  notes.unshift(`Replace the params in your code with correction.corrected_params (${r.correction.patch.length} change(s); verified passes: ${r.correction.verification?.passes ?? 'n/a'}).`);
820
- if (r.correction?.verification?.note)
1213
+ // Agent audit round 2: on a pass the same Stripe-styling note was printed four times; it is printed once.
1214
+ if (fixing && r.correction?.verification?.note)
821
1215
  notes.push(r.correction.verification.note);
822
- const { correction, ...rest } = slimCheckResult(r);
1216
+ const { correction, ...rest } = withoutUnneededFix(slimCheckResult(r));
823
1217
  return { headline: verdictHeadline('Stripe Checkout Session', r), notes, data: { correction, ...rest } };
824
1218
  },
825
1219
  };
@@ -827,32 +1221,35 @@ const checkEmail = {
827
1221
  name: 'check_email',
828
1222
  title: 'Check a subscription lifecycle email',
829
1223
  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).',
1224
+ 'Checks an acknowledgment, trial reminder, renewal reminder, price-change, annual-terms or cancellation-confirmation email against the laws that require it, and returns a template that passes the checks (subject, text, html with merge tags), verified by the rules engine, plus required_by (which laws make this email mandatory for this plan).',
831
1225
  'Example: {"kind":"trial_reminder","subject":"Your trial ends soon","text":"…","terms":{"price":19.99,"interval":"month","trial_days":14,"cancel_url":"https://acme.example/account/cancel","merchant_name":"Acme"}}.',
832
1226
  'For WHEN each email must be sent, use get_notice_schedule.',
833
1227
  ].join('\n'),
834
1228
  input: {
835
- kind: z.enum(['acknowledgment', 'trial_reminder', 'renewal_reminder', 'price_change', 'annual_terms', 'cancellation_confirmation']).describe('Which lifecycle email this is.'),
1229
+ kind: emailKind.describe('Which email (trial_reminder = trial_ending).'),
836
1230
  subject: z.string().min(1).max(1000).describe('Subject line.'),
837
1231
  text: z.string().max(100_000).optional().describe('Plain-text body (give text, html or both).'),
838
1232
  html: z.string().max(100_000).optional().describe('HTML body.'),
839
- terms: planTerms
840
- .extend({
1233
+ terms: strictObject({
1234
+ ...planTermsShape,
841
1235
  previous_price: z.union([z.number().positive(), z.string().max(40)]).optional().describe('price_change: price before the change, major units.'),
842
1236
  previous_amount: z.number().int().positive().optional(),
843
1237
  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.'),
1238
+ }, 'terms')
1239
+ .optional()
1240
+ .describe('price, interval, cancel_url (optional with site); price_change: NEW price'),
1241
+ site: z.string().max(100).optional().describe('site_… whose Stripe prices judge it (default: your Stripe site).'),
1242
+ plan: z.string().max(200).optional().describe('Plan name or price_… (default: the one named)'),
846
1243
  jurisdictions: jurisdictions.optional(),
847
1244
  as_of: asOf.optional(),
848
1245
  },
849
1246
  scopes: ['checks:read'],
850
1247
  annotations: { ...ro, title: 'Check email' },
851
1248
  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) };
1249
+ const r = await api.request('POST', '/v1/checks/email', { body: { ...args, kind: checkKind(args.kind) } });
1250
+ const notes = checkNotes(r, 8, r.template?.verified ? { by: 'template (subject + text or html; add your greeting and sign-off around it if you like)', except: r.template.remaining_checks ?? [] } : undefined);
1251
+ notes.unshift(r.required_by?.length ? `Legally required for this plan by: ${citationList(r.required_by, r.requirements)}.` : 'Not legally required for this plan (still good practice).');
1252
+ return { headline: verdictHeadline(`Email (${String(args.kind)}${r.plan ? `, plan ${untrusted(r.plan, 60)}` : ''})`, r), notes, data: slimCheckResult(r) };
856
1253
  },
857
1254
  };
858
1255
  const checkPageHtml = {
@@ -867,7 +1264,10 @@ const checkPageHtml = {
867
1264
  html: z.string().max(1_000_000).optional().describe('Rendered checkout page HTML. Give html or url.'),
868
1265
  url: z.string().max(2048).optional().describe('Live public page URL to fetch instead (live, activated accounts).'),
869
1266
  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).'),
1267
+ terms: planTerms.optional().describe('The plan sold on the page. merchant_name / cancel_url alone are fine (no price compared).'),
1268
+ plans: z.array(strictObject(planTermsShape, 'plans[]')).max(20).optional().describe('Multi-plan pricing page: EVERY plan it sells (price, interval, trial_days each). Each plan card is judged against its own plan; plan_replacements gives each plan its own markup for its own button.'),
1269
+ checkout_type: z.enum(['own_page', 'stripe_checkout', 'payment_link']).optional().describe('Where shoppers pay (default: inferred).'),
1270
+ site: z.string().max(100).optional().describe("Your site_…: judge it as verify_task does (the site's Stripe-checked plans)."),
871
1271
  jurisdictions: jurisdictions.optional(),
872
1272
  as_of: asOf.optional(),
873
1273
  },
@@ -878,25 +1278,74 @@ const checkPageHtml = {
878
1278
  throw new ToolError('Pass html (the rendered page) or url.');
879
1279
  const r = await api.request('POST', '/v1/checks/page', { body: args });
880
1280
  const notes = checkNotes(r);
1281
+ // Round 17 (python-flask): on a Payment Link / Stripe Checkout page, replacement.html is the terms only.
1282
+ if (r.replacement?.consent_on && r.replacement.note && r.verdict !== 'pass')
1283
+ notes.push(`Replacement: ${r.replacement.note}`);
881
1284
  if (r.extraction?.purchase_buttons) {
882
1285
  const b = r.extraction.purchase_buttons;
883
1286
  notes.unshift(b.length ? `Purchase buttons found: ${b.length} (first: ${untrusted(b[0], 60)}; texts in extraction.purchase_buttons).` : 'Purchase buttons found: none (send the rendered HTML).');
884
1287
  }
885
- return { headline: verdictHeadline('Page', r), notes, data: slimCheckResult(r) };
1288
+ return { headline: verdictHeadline('Page', r), notes, data: withoutUnneededFix(slimCheckResult(r)) };
886
1289
  },
887
1290
  };
1291
+ const DAY_S = 86_400;
1292
+ /**
1293
+ * Agent audit round 2: "2026-11-14: trial_ending (5 days before; US-UT)" said neither before what nor the allowed window,
1294
+ * which is what a cron job needs; the window was only in epoch seconds. The line now states both relative to the event.
1295
+ */
1296
+ export function noticeScheduleLine(n) {
1297
+ const anchor = n.anchor_date ? Date.parse(`${n.anchor_date}T00:00:00Z`) / 1000 : undefined;
1298
+ const event = n.occasion ? n.occasion.replace(/\s*\(.*\)$/, '').toLowerCase() : 'the event';
1299
+ if (n.days_before === 0 || anchor === undefined || n.window_opens_at === undefined || n.window_closes_at === undefined) {
1300
+ return `${n.send_date}: ${n.kind} — ${n.days_before === 0 ? `at ${event} (same day)` : `${n.days_before} days before ${event}`} (${n.jurisdictions.join(', ')})`;
1301
+ }
1302
+ const most = Math.round((anchor - n.window_opens_at) / DAY_S);
1303
+ const least = Math.round((anchor - n.window_closes_at) / DAY_S);
1304
+ const window = least >= 0 && most >= least ? `; allowed ${least === most ? `exactly ${least}` : `${least}–${most}`} days before` : '';
1305
+ return `${n.send_date}: ${n.kind} — ${n.days_before} days before ${event} (${n.anchor_date})${window} (${n.jurisdictions.join(', ')})`;
1306
+ }
1307
+ /** One line per notice; a kind sent every charge shows its first date and how many follow. */
1308
+ export function noticeScheduleLines(notices) {
1309
+ const out = [];
1310
+ const seen = new Map();
1311
+ for (const n of notices)
1312
+ seen.set(n.kind, (seen.get(n.kind) ?? 0) + 1);
1313
+ const shown = new Set();
1314
+ for (const n of notices) {
1315
+ const count = seen.get(n.kind);
1316
+ if (count > 2) {
1317
+ if (shown.has(n.kind))
1318
+ continue;
1319
+ shown.add(n.kind);
1320
+ out.push(`${noticeScheduleLine(n)} — then the same before each of the next ${count - 1} charges (all in notices[])`);
1321
+ }
1322
+ else
1323
+ out.push(noticeScheduleLine(n));
1324
+ }
1325
+ return out.slice(0, 12);
1326
+ }
1327
+ /** Notice kinds of the schedule → the compliance plan's email task for it (same names as get_compliance_plan). */
1328
+ export const NOTICE_KIND_TASKS = {
1329
+ acknowledgment: { name: 'acknowledgment email', task: 'acknowledgment_email' },
1330
+ trial_ending: { name: 'trial-ending reminder', task: 'trial_reminder' },
1331
+ renewal_reminder: { name: 'renewal reminder', task: 'renewal_reminder' },
1332
+ annual_terms: { name: 'yearly terms reminder', task: 'annual_terms_notice' },
1333
+ charge_notice: { name: 'charge notice', task: 'charge_notice' },
1334
+ price_change: { name: 'price-change notice', task: 'price_change_notice' },
1335
+ };
888
1336
  const getNoticeSchedule = {
889
1337
  name: 'get_notice_schedule',
890
1338
  title: 'Get the dates each required notice must be sent',
891
1339
  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.',
1340
+ 'For a plan (price, interval, trial) and a sign-up date: every legally required notice (acknowledgment, trial-ending reminder, renewal reminder, yearly terms reminder, charge notice, price-change notice — the same emails as the compliance plan\'s email tasks) with the recommended send date, the allowed send window (earliest/latest), the jurisdictions it satisfies and citations; plus notice kinds NOT required and why.',
893
1341
  'Use it to implement reminder emails / cron jobs correctly. Example: {"terms":{"price":99,"interval":"year","trial_days":30},"start_date":"2026-11-01"}.',
894
1342
  ].join('\n'),
895
1343
  input: {
896
- terms: planTerms.describe('The plan: price (or amount), interval, interval_count, trial_days.'),
1344
+ terms: planTerms.optional().describe('The plan: price (or amount), interval, interval_count, trial_days (or site + plan).'),
1345
+ site: z.string().max(100).optional().describe('site_… (its plans, jurisdictions)'),
1346
+ plan: z.string().max(200).optional().describe('With site: plan name or price_…'),
897
1347
  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() })
1348
+ price_change: strictObject({ effective_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), new_price: z.number().positive().optional() }, 'price_change')
900
1349
  .optional()
901
1350
  .describe('A planned price change to schedule the notice for.'),
902
1351
  cycles: z.number().int().min(1).max(24).optional().describe('How many renewals to plan (default one year).'),
@@ -908,11 +1357,19 @@ const getNoticeSchedule = {
908
1357
  annotations: { ...ro, title: 'Notice schedule' },
909
1358
  async run(args, { api }) {
910
1359
  const r = await api.request('POST', '/v1/checks/notice_schedule', { body: args });
1360
+ const singleDay = [...new Set(r.notices.flatMap((n) => (n.timing_note ? [`${n.kind}: ${n.timing_note}`] : [])))].slice(0, 3);
1361
+ const kinds = [...new Set(r.notices.map((n) => n.kind))];
911
1362
  return {
912
- headline: `${r.notices.length} required notice(s) for this plan.`,
1363
+ // Polish round: one email per kind, named like the compliance plan's email tasks (the per-send dates follow).
1364
+ headline: kinds.length
1365
+ ? `${kinds.length} required email${kinds.length === 1 ? '' : 's'} for ${r.plan ? `plan ${untrusted(r.plan, 60)}` : 'this plan'}: ${kinds.map((k) => NOTICE_KIND_TASKS[k] ? `${NOTICE_KIND_TASKS[k].name} (plan task ${NOTICE_KIND_TASKS[k].task})` : k).join(', ')}; ${r.notices.length} send${r.notices.length === 1 ? '' : 's'} in the period below.`
1366
+ : 'No required notices for this plan under these jurisdictions.',
913
1367
  notes: [
914
- ...r.notices.slice(0, 12).map((n) => `${n.send_date}: ${n.kind} (${n.days_before} days before; ${n.jurisdictions.join(', ')})`),
1368
+ ...noticeScheduleLines(r.notices),
1369
+ ...singleDay,
1370
+ ...(r.rounding_rule ? [`Timing rule: ${r.rounding_rule}`] : []),
915
1371
  ...(r.not_required ?? []).map((n) => `Not required: ${n.kind} — ${n.reason}`),
1372
+ ...(r.warnings ?? []).filter((w) => typeof w?.message === 'string').slice(0, 3).map((w) => w.message),
916
1373
  ],
917
1374
  data: r,
918
1375
  };
@@ -930,7 +1387,7 @@ const SUGGESTION_SURFACE_NAMES = [
930
1387
  ];
931
1388
  const suggestImprovements = {
932
1389
  name: 'suggest_improvements',
933
- title: 'Suggest compliant conversion improvements',
1390
+ title: 'Suggest conversion improvements that keep the checks passing',
934
1391
  description: [
935
1392
  'OPTIONAL, after compliance: concrete ways to make the checkout page, Stripe Checkout, lifecycle emails and the cancellation flow convert or retain better WITHOUT breaking automatic-renewal laws. Deterministic library; every after-example was run through Checkbox\'s own checks for the merchant\'s states before it is offered.',
936
1393
  'Each suggestion gives apply.where + apply.content (the exact copy, Stripe JSON patch or flow change to make), apply.instructions, preconditions that must be true (never apply one whose precondition is false), and guardrails with catalog requirement ids (e.g. California / Colorado click-to-cancel next to any save offer, Minnesota ask-first). Never invent figures or add urgency, pre-checked boxes or obstacles.',
@@ -940,7 +1397,7 @@ const suggestImprovements = {
940
1397
  input: {
941
1398
  site: siteId.optional(),
942
1399
  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).'),
1400
+ other_plans: z.array(strictObject({ ...planTermsShape, name: z.string().max(80).optional().describe('Plan name, e.g. "Lite" (or plan_name).') }, 'other_plans[]')).max(10).optional().describe('Other plans you sell (enables cheaper-plan / plan-choice suggestions).'),
944
1401
  disclosure: z
945
1402
  .object({
946
1403
  text: z.string().min(1).max(5000).describe('Disclosure next to the purchase button, as rendered.'),
@@ -1001,7 +1458,10 @@ const suggestImprovements = {
1001
1458
  r.suggestions.slice(0, 15).forEach((s, i) => {
1002
1459
  notes.push([
1003
1460
  `${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))}`,
1461
+ // "From your copy" only for the merchant's own text; anything else is an example (never present it as theirs).
1462
+ s.before.source === 'your_copy' || (!s.before.source && s.match === 'detected')
1463
+ ? ` before, from your copy (${s.before.label}): ${untrusted(s.before.text, 160)}`
1464
+ : ` before, example (${s.before.label}): ${JSON.stringify(s.before.text.slice(0, 160))}`,
1005
1465
  ` apply (${s.apply.type}) at ${s.apply.where}: ${JSON.stringify(s.apply.content.length > 600 ? `${s.apply.content.slice(0, 600)}…` : s.apply.content)}`,
1006
1466
  ` how: ${s.apply.instructions}`,
1007
1467
  ...(s.preconditions.length ? [` only if true: ${s.preconditions.join(' ')}`] : []),
@@ -1014,7 +1474,7 @@ const suggestImprovements = {
1014
1474
  notes.push(`…and ${r.suggestions.length - 15} more in data.suggestions.`);
1015
1475
  const held = r.surfaces.filter((s) => s.held_by.length);
1016
1476
  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('; ')}.`);
1477
+ notes.push(`Held (fix first with get_compliance_plan): ${held.map((s) => `${s.surface} (${s.held_label ? `${s.held_label}: ` : ''}${s.held_by.join(', ')})`).join('; ')}.`);
1018
1478
  if (r.withheld.length)
1019
1479
  notes.push(`Withheld: ${r.withheld.slice(0, 8).map((w) => `${w.key}: ${w.reason}`).join(' | ')}`);
1020
1480
  return { headline: `Suggestions for ${where}: ${r.summary}`, notes, data: r };
@@ -1023,10 +1483,25 @@ const suggestImprovements = {
1023
1483
  // ---------------------------------------------------------------------------------------------------------------------
1024
1484
  // Sites, findings, scans, law updates
1025
1485
  // ---------------------------------------------------------------------------------------------------------------------
1486
+ /** One site in list_sites: what an agent needs to pick it. */
1487
+ export function siteListItem(s) {
1488
+ const x = s;
1489
+ return {
1490
+ id: s.id,
1491
+ object: 'site',
1492
+ name: s.name ?? null,
1493
+ url: s.url,
1494
+ ...(x.platform ? { platform: x.platform } : {}),
1495
+ ...(x.checkout?.url ? { checkout_url: x.checkout.url } : {}),
1496
+ status: plainLabel(s.compliance?.label ?? 'Not checked yet'),
1497
+ domain_verified: x.monitoring?.held_for_domain_verification !== true,
1498
+ ...(x.archived ? { archived: true } : {}),
1499
+ };
1500
+ }
1026
1501
  const listSites = {
1027
1502
  name: 'list_sites',
1028
1503
  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.',
1504
+ description: 'The account\'s sites in the current mode (test keys see only test-mode sites), newest first, each with its compliance status label. Use it to find a site id.',
1030
1505
  input: { limit: listLimit, starting_after: startingAfter, archived: z.boolean().optional().describe('true lists only archived sites.') },
1031
1506
  scopes: ['sites:read'],
1032
1507
  annotations: { ...ro, title: 'List sites' },
@@ -1036,9 +1511,14 @@ const listSites = {
1036
1511
  });
1037
1512
  return {
1038
1513
  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' },
1514
+ // Audit P2: name + URL per site, and a Next line either way.
1515
+ notes: list.data.map((s) => `${s.id} ${s.name ? `${untrusted(s.name, 60)} ` : ''}${untrusted(s.url, 100)} — ${plainLabel(s.compliance?.label ?? 'Not checked yet')}`),
1516
+ // Agent audit round 2: each site carried install details, webhook URLs, retention and legal hold (~1.5 KB) when
1517
+ // an agent needs the id, URL and status; get_site_status / the API have the rest.
1518
+ data: { ...list, data: list.data.map(siteListItem) },
1519
+ next: list.data.length
1520
+ ? { type: 'review_plan', message: list.data.length === 1 ? `Call get_compliance_plan with site "${list.data[0].id}" for what to change next.` : 'Call get_compliance_plan with the id of the site whose URL matches this project (listed above) for what to change next.', operation_id: 'retrieveCompliancePlan' }
1521
+ : { type: 'setup', message: 'No sites yet: call setup_checkbox with the site URL.', operation_id: 'createSetupSession' },
1042
1522
  };
1043
1523
  },
1044
1524
  };
@@ -1090,10 +1570,14 @@ const updateSite = {
1090
1570
  name: z.string().max(200).optional().describe('Merchant / brand name (used in generated disclosures).'),
1091
1571
  checkout_url: z.string().max(2048).optional().describe('Page with the Subscribe / Buy button.'),
1092
1572
  checkout_steps: z.array(z.record(z.string(), z.unknown())).max(20).optional().describe('Steps to reach the purchase button (clicks / fills), as in the API.'),
1093
- test_account: z
1094
- .object({ login_url: z.string().max(2048), email: z.string().max(320), password: z.string().max(200) })
1573
+ test_account: strictObject({
1574
+ login_url: z.string().max(2048),
1575
+ email: z.string().max(320),
1576
+ password: z.string().max(200),
1577
+ reactivate_url: z.string().max(2048).optional().describe('Test-only page that re-subscribes the test account.'),
1578
+ }, 'test_account')
1095
1579
  .optional()
1096
- .describe('A TEST subscriber account the scanner logs in with to try cancelling online.'),
1580
+ .describe('A TEST subscriber account the scanner logs in with to try cancelling online (echoed back without the password).'),
1097
1581
  platform: z.enum(['stripe', 'shopify', 'recharge', 'chargebee', 'recurly', 'paddle', 'woocommerce', 'custom']).optional(),
1098
1582
  jurisdictions: z.array(z.string().max(10)).max(60).optional(),
1099
1583
  monitoring_interval_minutes: z.number().int().min(0).optional(),
@@ -1114,54 +1598,94 @@ const updateSite = {
1114
1598
  };
1115
1599
  },
1116
1600
  };
1601
+ /** The site's Shopify connection in one line, naming the tool (or the human step) that comes next. */
1602
+ export function shopifyLines(sh, site) {
1603
+ if (!sh)
1604
+ return [];
1605
+ if (sh.connection === 'app') {
1606
+ return sh.terms_published
1607
+ ? [`Shopify: ${sh.shop ?? 'The store'} is connected through the Checkbox app; renewal terms published to the app block on ${new Date(sh.terms_published.published * 1000).toISOString().slice(0, 10)}. If the storefront does not show them, the merchant still has to place the "Renewal terms + consent" block once: ${sh.theme_editor_url ?? 'theme editor'}. publish_shopify_terms republishes after plan changes.`]
1608
+ : [`Shopify: ${sh.shop ?? 'The store'} is connected through the Checkbox app; the renewal terms are not published yet: call publish_shopify_terms with site "${site}", then the merchant places the block once (${sh.theme_editor_url ?? 'theme editor'}).`];
1609
+ }
1610
+ if (sh.connection === 'own_app')
1611
+ return [`Shopify: ${sh.shop ?? 'The store'} is connected with its own custom-app token (read-only): use the standalone theme snippet from the plan's sign-up task, or have the merchant install the Checkbox Shopify app so publish_shopify_terms can update the block.`];
1612
+ return ['Shopify: the store is not connected. Only the merchant can connect it: install the Checkbox app in the Shopify admin, open it and click "Connect to Checkbox" (then publish_shopify_terms works).'];
1613
+ }
1117
1614
  const getSiteStatus = {
1118
1615
  name: 'get_site_status',
1119
1616
  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.',
1617
+ description: 'Where a site stands: one status (Checks pass, N fixes needed, Not set up yet, Not checked yet, Your call) and the to-do list: the compliance plan\'s ordered open tasks, as on the dashboard. Use it for "am I compliant?"; get_task gives each change.',
1121
1618
  input: { site: siteId.optional() },
1122
1619
  scopes: ['sites:read'],
1123
1620
  annotations: { ...ro, title: 'Site status' },
1124
1621
  async run(args, { api }) {
1125
1622
  const id = await resolveSite(api, args.site);
1126
- const [site, evaluation, findings] = await Promise.all([
1623
+ const [site, evaluation, plan] = await Promise.all([
1127
1624
  api.request('GET', `/v1/sites/${enc(id)}`),
1128
1625
  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 } })),
1626
+ optional(api.request('GET', `/v1/sites/${enc(id)}/plan`)),
1130
1627
  ]);
1131
1628
  // Round-4 pilot (nextjs): "nothing left to do" while 13 critical/high checks were needs_review. Count them too.
1132
1629
  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 : [];
1630
+ const evaluated = 'id' in evaluation;
1135
1631
  const toReview = 'data' in review ? review.data : [];
1136
1632
  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 : {});
1633
+ const blocking = evaluated ? (evaluation.blockers ?? []).map((b) => b.check_id) : [];
1634
+ // One to-do list: the plan's ordered open tasks, never a list built from findings.
1635
+ const todo = 'tasks' in plan && Array.isArray(plan.tasks) ? planTodo(plan.tasks) : [];
1636
+ const { checks: _omit, score: _score, insurable: _insurable, ...evalLite } = (evaluated ? evaluation : {});
1143
1637
  // Status labels: the roll-up decides the headline; pending live checks count as compliant and say how they complete.
1144
1638
  const roll = site.compliance?.rollup;
1145
1639
  const pendingLive = roll?.pending_live_checks ?? [];
1146
1640
  const rollStatus = site.compliance?.status;
1147
1641
  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
- }
1642
+ const ro = unverifiedScanNote(site.unverified_scan);
1643
+ const notes = [
1644
+ ...(todo.length ? [`To do (${todo.length}, from the compliance plan, in order):`, ...todo.map((t, i) => `${i + 1}. ${t.id} ${todoLine(t)}`)] : []),
1645
+ ...(evaluated && label ? pendingLive.slice(0, 6).map((p) => `LIVE CHECK PENDING ${p.check_id}: ${p.what} — ${p.completes}`) : []),
1646
+ ...(ro ? [ro] : []),
1647
+ ...shopifyLines(site.shopify, site.id),
1648
+ ];
1649
+ const { open_findings: _open, needs_review: _nr, ...complianceLite } = (site.compliance ?? {});
1151
1650
  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.`,
1651
+ headline: evaluated ? `${site.url}: ${plainLabel(label ?? 'Not checked yet')}.` : `${site.url}: Not checked yet.`,
1157
1652
  notes,
1158
- data: { site, evaluation: evaluated ? evalLite : null, top_findings: top, needs_review: { count: toReview.length, critical_or_high: seriousReview.length } },
1653
+ data: { site: site.compliance ? { ...site, compliance: complianceLite } : site, evaluation: evaluated ? evalLite : null, todo },
1159
1654
  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.' }
1655
+ ? todo.length && (rollStatus === 'needs_fix' || todo.some((t) => t.status === 'needs_fix' || t.status === 'not_set_up'))
1656
+ ? { type: 'fix', message: `Start with the first task (${todo[0].id}: ${todo[0].title}); get_compliance_plan / get_task give the exact change.`, operation_id: 'retrieveCompliancePlan' }
1657
+ : rollStatus === 'needs_fix'
1658
+ ? { type: 'fix', message: 'Call get_compliance_plan for the ordered changes.', operation_id: 'retrieveCompliancePlan' }
1659
+ : rollStatus === 'your_call' || todo.some((t) => t.status === 'your_call')
1660
+ ? { type: 'review', message: 'Some items need a decision from the human: call get_compliance_plan for the questions (each has a recommended answer).', operation_id: 'retrieveCompliancePlan' }
1661
+ : rollStatus === 'compliant' && !todo.length
1662
+ ? // Round 15 (pilot 14, change-guard: "Checks pass · 3 live checks pending" next to "the site is not shown
1663
+ // as passing yet"): one status, one sentence. When the roll-up says Checks pass, next_action says the
1664
+ // same; the live checks are named as what completes it, never as a reason it does not pass.
1665
+ pendingLive.length
1666
+ ? {
1667
+ type: 'complete_live_checks',
1668
+ message: `${plainLabel(label ?? 'Checks pass')}: nothing needs a code change. ${pendingLive.length} live check(s) complete on their own (${pendingLive
1669
+ .slice(0, 3)
1670
+ .map((p) => p.what)
1671
+ .join('; ')}); get_compliance_plan says how each one completes (a proof run, BCC real sends, a test account).`,
1672
+ operation_id: 'retrieveCompliancePlan',
1673
+ }
1674
+ : { type: 'none', message: `${plainLabel(label ?? 'Checks pass')}: every check that applies passes; nothing left to do. Keep monitoring on.` }
1675
+ : blocking.length || pendingLive.length || seriousReview.length || todo.length
1676
+ ? // Normal round 4 (nextjs): "nothing left to do — Compliant" next to 2 blocking checks. Never call a site
1677
+ // compliant while a blocking check, a pending live check or an open plan task remains.
1678
+ {
1679
+ type: 'complete_live_checks',
1680
+ message: `Nothing needs a code change now, but the site is not shown as passing yet (${plainLabel(label ?? 'Not checked yet')}): ${[
1681
+ todo.length ? `${todo.length} task(s) on the plan (${todo.slice(0, 3).map((t) => t.title).join('; ')})` : '',
1682
+ blocking.length ? `blocking check(s) ${blocking.slice(0, 4).join(', ')}` : '',
1683
+ pendingLive.length ? `${pendingLive.length} live check(s) pending` : '',
1684
+ seriousReview.length && !todo.length ? `${seriousReview.length} check(s) to review` : '',
1685
+ ].filter(Boolean).join(', ')}. Call get_compliance_plan for how each one completes (a proof run, BCC real sends, a test account).`,
1686
+ operation_id: 'retrieveCompliancePlan',
1687
+ }
1688
+ : { type: 'none', message: 'Every check that applies has passed; nothing left to do. Keep monitoring on.' }
1165
1689
  : { type: 'run_first_check', message: 'Call verify_site (or run_scan) for the first verdict.', operation_id: 'verifySite' },
1166
1690
  };
1167
1691
  },
@@ -1169,10 +1693,10 @@ const getSiteStatus = {
1169
1693
  const listFindings = {
1170
1694
  name: 'list_findings',
1171
1695
  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.',
1696
+ description: 'Findings (one per legal check per site) ordered by urgency: failing first, then by severity, each with citations. Filter by site, status (fail, needs_review, pass, not_applicable) and state (open, acknowledged, resolved, accepted_risk). Default: every open finding of every status. Use get_finding for the fix of one.',
1173
1697
  input: {
1174
1698
  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.'),
1699
+ status: z.enum(['pass', 'fail', 'needs_review', 'not_applicable']).optional().describe('Default: all statuses.'),
1176
1700
  state: z.enum(['open', 'acknowledged', 'resolved', 'accepted_risk']).optional().describe('Default open.'),
1177
1701
  limit: listLimit,
1178
1702
  starting_after: startingAfter,
@@ -1183,7 +1707,8 @@ const listFindings = {
1183
1707
  const list = await api.request('GET', '/v1/findings', {
1184
1708
  query: {
1185
1709
  site: args.site,
1186
- status: args.status ?? 'fail',
1710
+ // Normal round 3: with no filter, every open finding (it returned "0 finding(s)" while 13 were active).
1711
+ status: args.status,
1187
1712
  state: args.state ?? 'open',
1188
1713
  limit: args.limit,
1189
1714
  starting_after: args.starting_after,
@@ -1222,6 +1747,36 @@ const getFinding = {
1222
1747
  };
1223
1748
  },
1224
1749
  };
1750
+ const publishShopifyTerms = {
1751
+ name: 'publish_shopify_terms',
1752
+ title: 'Publish renewal terms to the Shopify app block (write)',
1753
+ description: [
1754
+ 'WRITE (admin key). Shopify store linked through the Checkbox Shopify app (get_site_status: shopify.connection "app"): publishes the engine-verified renewal terms + consent label for the site\'s real plan to the Checkbox app block. Read-only on the store except that app-owned metafield (no theme/product/checkout edits); safe to repeat.',
1755
+ 'The merchant still places the "Renewal terms + consent" block once: give them theme_editor_url from the result. Then verify_site.',
1756
+ 'shopify_not_connected: only the merchant can install the app in Shopify and click "Connect to Checkbox". terms_incomplete: follow next_action.',
1757
+ ].join('\n'),
1758
+ input: {
1759
+ site: requiredSite,
1760
+ cancel_url: z.string().max(2048).optional().describe('Cancellation URL to state (default: detected).'),
1761
+ plan: z.string().max(200).optional().describe('Plan / price id (default: detected).'),
1762
+ },
1763
+ scopes: ['fixes:write'],
1764
+ annotations: { ...write(true, true), title: 'Publish Shopify terms' },
1765
+ async run(args, { api }) {
1766
+ const site = String(args.site);
1767
+ const r = await api.request('POST', `/v1/sites/${enc(site)}/fixes/shopify/publish`, { body: pick(args, ['cancel_url', 'plan']) });
1768
+ return {
1769
+ headline: `Published the verified renewal terms to the Checkbox app block on ${r.shop} (${r.id}).`,
1770
+ notes: [
1771
+ `Disclosure: ${untrusted(r.disclosure, 600)}`,
1772
+ `Consent checkbox: ${untrusted(r.consent_label, 200)}`,
1773
+ `Only the merchant can place the block (once): ask them to open ${r.theme_editor_url} , check the "Renewal terms + consent" block sits directly above the add-to-cart button, and click Save. Later publishes update it in place.`,
1774
+ ],
1775
+ data: r,
1776
+ next: r.next_action ?? { type: 'place_theme_block', message: `After the merchant saved the block in the theme editor, call verify_site for ${r.site}.`, operation_id: 'verifySite' },
1777
+ };
1778
+ },
1779
+ };
1225
1780
  const runScan = {
1226
1781
  name: 'run_scan',
1227
1782
  title: 'Run a synthetic-shopper scan (write)',
@@ -1242,15 +1797,26 @@ const runScan = {
1242
1797
  const getScan = {
1243
1798
  name: 'get_scan',
1244
1799
  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.',
1800
+ description: 'A scan\'s status (queued → running → completed | failed), warnings, and once completed the evaluation it produced (the site status and to-do list update from it).',
1246
1801
  input: { scan: z.string().max(100).describe('Scan id (scan_…).') },
1247
1802
  scopes: ['scans:read'],
1248
1803
  annotations: { ...ro, title: 'Scan' },
1249
1804
  async run(args, { api }) {
1250
1805
  const scan = await api.request('GET', `/v1/scans/${enc(String(args.scan))}`);
1251
1806
  const done = scan.status === 'completed';
1807
+ // Audit P1: a queued scan of a site held for domain verification does not run until the domain is verified.
1808
+ if (scan.status === 'queued' && scan.site) {
1809
+ const site = await optional(api.request('GET', `/v1/sites/${enc(scan.site)}`));
1810
+ if ('monitoring' in site && site.monitoring?.held_for_domain_verification) {
1811
+ return {
1812
+ headline: `Scan ${scan.id}: queued (waiting for domain verification). It does not run until the site's domain is verified.`,
1813
+ data: scan,
1814
+ next: { type: 'verify_domain', message: `Do the plan's verify_domain task first (get_compliance_plan for site ${scan.site}); polling this scan changes nothing until then.`, operation_id: 'retrieveCompliancePlan' },
1815
+ };
1816
+ }
1817
+ }
1252
1818
  return {
1253
- headline: `Scan ${scan.id}: ${statusLine(scan.status)}${done ? `, score ${scan.score ?? 'n/a'}` : ''}${scan.error ? ` — error: ${untrusted(scan.error, 160)}` : ''}.`,
1819
+ headline: `Scan ${scan.id}: ${statusLine(scan.status)}${done && scan.compliance?.label ? ` — ${plainLabel(scan.compliance.label)}` : ''}${done ? '; the site status and to-do list are updated (get_site_status)' : ''}${scan.error ? ` — error: ${untrusted(scan.error, 160)}` : ''}.`,
1254
1820
  data: scan,
1255
1821
  next: done
1256
1822
  ? { type: 'review', message: 'Call get_compliance_plan for what to change.', operation_id: 'retrieveCompliancePlan' }
@@ -1263,32 +1829,58 @@ const getScan = {
1263
1829
  const sendFeedback = {
1264
1830
  name: 'send_feedback',
1265
1831
  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.',
1832
+ description: 'Bug, idea or question: what you tried, happened, expected. type review: only after the human said yes and saw the text; about Checkbox, not the site: no site names, URLs, quotes, secrets, customer data or claims the site broke a law. score 0-10 (would recommend); ≤5 phrases in liked, disliked, wrong_verdicts ("check.id: why wrong"); stack e.g. "Next.js + Stripe".',
1267
1833
  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).'),
1834
+ type: z.enum(['bug', 'idea', 'question', 'review']).optional(),
1835
+ message: z.string().optional(),
1836
+ score: z.number().int().min(0).max(10).optional(),
1837
+ liked: z.array(z.string()).optional(),
1838
+ disliked: z.array(z.string()).optional(),
1839
+ wrong_verdicts: z.array(z.string()).optional(),
1840
+ would_keep_using: z.boolean().optional(),
1841
+ stack: z.string().optional(),
1842
+ site: z.string().optional(),
1843
+ request_id: z.string().optional(),
1844
+ tool: z.string().optional(),
1273
1845
  },
1274
1846
  scopes: ['account:read'],
1275
1847
  annotations: { ...write(false, false), title: 'Send feedback' },
1276
1848
  async run(args, ctx) {
1277
1849
  const context = {};
1278
- for (const k of ['site', 'request_id', 'tool'])
1850
+ // Reviews are about Checkbox, not the merchant's site: no site or request id is attached to them.
1851
+ for (const k of args.type === 'review' ? ['tool'] : ['site', 'request_id', 'tool'])
1279
1852
  if (typeof args[k] === 'string' && args[k])
1280
1853
  context[k] = String(args[k]);
1281
1854
  const agent = ctx.key.appName ?? ctx.clientName;
1282
1855
  if (agent)
1283
1856
  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 } : {}) } });
1857
+ if (args.type !== 'review' && !(typeof args.message === 'string' && args.message.trim()))
1858
+ throw new ToolError('Write a message (what you tried, what happened, what you expected).');
1859
+ if (args.type === 'review' && typeof args.score !== 'number')
1860
+ throw new ToolError('A review needs score (0-10). Ask the human; never guess it.');
1861
+ const body = { ...pick(args, ['type', 'message', 'score', 'liked', 'disliked', 'wrong_verdicts', 'would_keep_using', 'stack']), ...(Object.keys(context).length ? { context } : {}) };
1862
+ const fb = await ctx.api.request('POST', '/v1/feedback', { body });
1285
1863
  return {
1286
- headline: `Feedback ${fb.id} sent to the Checkbox team (${fb.status}).`,
1864
+ headline: `${args.type === 'review' ? 'Review' : 'Feedback'} ${fb.id} sent to the Checkbox team (${fb.status}).${fb.redacted ? ' Some text looked like a secret, card number or email address and was replaced with [redacted].' : ''}`,
1287
1865
  data: fb,
1288
1866
  next: { type: 'none', message: 'Nothing else to do; the team replies by email to the account owner when needed.' },
1289
1867
  };
1290
1868
  },
1291
1869
  };
1870
+ const LAW_CHANGES_SHOWN = 10;
1871
+ /**
1872
+ * Agent audit round 2: list_law_updates with limit 3 printed 62 KB (one catalog update carried 195 requirement changes,
1873
+ * each with its full summary). Each update keeps its first changes in short form, with the total.
1874
+ */
1875
+ export function lawUpdateItem(u) {
1876
+ const changes = Array.isArray(u.changes) ? u.changes : [];
1877
+ const { changes: _c, ...rest } = u;
1878
+ return {
1879
+ ...rest,
1880
+ changes: changes.slice(0, LAW_CHANGES_SHOWN).map((c) => pick(c, ['requirement_id', 'change', 'citation', 'title', 'effective_date'])),
1881
+ ...(changes.length > LAW_CHANGES_SHOWN ? { changes_total: changes.length } : {}),
1882
+ };
1883
+ }
1292
1884
  const listLawUpdates = {
1293
1885
  name: 'list_law_updates',
1294
1886
  title: 'List law updates and their impact',
@@ -1303,17 +1895,21 @@ const listLawUpdates = {
1303
1895
  return {
1304
1896
  headline: `${list.data.length}${list.has_more ? '+' : ''} law update(s).`,
1305
1897
  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,
1898
+ data: { ...list, data: list.data.map(lawUpdateItem) },
1307
1899
  };
1308
1900
  },
1309
1901
  };
1310
1902
  function toDisclosureTerms(t) {
1311
- const out = pick(t, ['merchant_name', 'amount', 'currency', 'interval', 'interval_count', 'trial_days', 'cancel_url', 'cancel_timing']);
1903
+ const out = pick(t, ['merchant_name', 'amount', 'currency', 'interval', 'interval_count', 'trial_days', 'intro_percent_off', 'intro_periods', 'cancel_url', 'cancel_timing']);
1904
+ if (out.merchant_name === undefined && typeof t.brand_name === 'string')
1905
+ out.merchant_name = t.brand_name;
1312
1906
  if (out.amount === undefined && t.price !== undefined) {
1313
1907
  const n = typeof t.price === 'number' ? t.price : Number(String(t.price).replace(/[^0-9.]/g, ''));
1314
1908
  if (Number.isFinite(n) && n > 0)
1315
1909
  out.amount = Math.round(n * 100);
1316
1910
  }
1911
+ if (typeof t.intro_price === 'number' && t.intro_price >= 0)
1912
+ out.intro_amount = Math.round(t.intro_price * 100);
1317
1913
  return out;
1318
1914
  }
1319
1915
  function reactSnippet(d) {
@@ -1334,7 +1930,7 @@ function reactSnippet(d) {
1334
1930
  const elementsInstallNotes = [
1335
1931
  'Put script_tag in <head>; put element_html inside the sign-up form directly ABOVE the pay button. It keeps the button disabled until the shopper ticks the (unchecked, required) box.',
1336
1932
  '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.',
1933
+ 'Do not restyle it below body size or hide it; guard rails restore the required styles and report what they found.',
1338
1934
  ];
1339
1935
  const elementsTermsShape = {
1340
1936
  merchant_name: planTermsShape.merchant_name,
@@ -1349,29 +1945,30 @@ const elementsTermsShape = {
1349
1945
  };
1350
1946
  const getCompliantDisclosure = {
1351
1947
  name: 'get_compliant_disclosure',
1352
- title: 'Generate a compliant disclosure for given terms',
1948
+ title: 'Generate a disclosure that passes the checks for given terms',
1353
1949
  description: [
1354
1950
  'Generates the locked, engine-verified automatic-renewal disclosure for a plan: disclosure_text, consent_label (for an unchecked, required checkbox), button_text and drop-in html. The same wording Checkbox Elements and Fixes use.',
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"}.',
1951
+ 'Give the terms: price (major units) or amount (minor), interval, trial_days, cancel_url, merchant_name, promo (intro_price or intro_percent_off, intro_periods). Example: {"merchant_name":"Acme","price":19.99,"interval":"month","trial_days":14,"cancel_url":"https://acme.example/account/cancel"}.',
1356
1952
  'With site (and a key allowed to create disclosures), it also returns the Checkbox Elements snippet (script tag + <checkbox-disclosure> element + React usage) that records each consent as evidence.',
1357
1953
  ].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() },
1954
+ // Round 15 (P4): a promo (intro price) is stated with how long it lasts.
1955
+ input: { ...elementsTermsShape, brand_name: planTermsShape.brand_name, intro_price: planTermsShape.intro_price, intro_percent_off: planTermsShape.intro_percent_off, intro_periods: planTermsShape.intro_periods, interval: z.enum(['day', 'week', 'month', 'year']).describe('Billing interval.'), site: z.string().max(100).optional().describe('Site id (site_…) to also get the Elements snippet for.'), jurisdictions: jurisdictions.optional() },
1359
1956
  scopes: ['checks:read'],
1360
- annotations: { ...ro, readOnlyHint: false, title: 'Compliant disclosure' },
1957
+ annotations: { ...ro, readOnlyHint: false, title: 'Disclosure that passes the checks' },
1361
1958
  async run(args, { api, key }) {
1362
1959
  if (typeof args.site === 'string' && args.site && keyAllows(key, 'consents:write')) {
1363
1960
  const d = await api.request('POST', '/v1/disclosures', { body: { site: args.site, terms: toDisclosureTerms(args) } });
1364
1961
  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)'}.`,
1962
+ headline: `Disclosure ${d.id}${d.verification?.passes === undefined ? '' : d.verification.passes ? ' (passes the sign-up checks on your checkout)' : ' (see verification: not every check passes yet)'}.`,
1366
1963
  notes: [`Disclosure: ${d.text}`, `Consent label: ${d.consent_label}`, ...elementsInstallNotes],
1367
1964
  data: { ...d, react: reactSnippet(d) },
1368
1965
  };
1369
1966
  }
1370
1967
  // 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']) };
1968
+ const body = { text: '(no disclosure yet)', ...pick(args, ['merchant_name', 'brand_name', 'plan_name', 'price', 'amount', 'currency', 'interval', 'interval_count', 'trial_days', 'intro_price', 'intro_percent_off', 'intro_periods', 'cancel_url', 'cancel_timing', 'jurisdictions']) };
1372
1969
  const r = await api.request('POST', '/v1/checks/disclosure', { body });
1373
1970
  return {
1374
- headline: `Compliant disclosure generated (engine-verified: ${r.replacement.verified}).`,
1971
+ headline: `Suggested disclosure generated (${r.replacement.verified ? 'passes the checks, verified by the rules engine' : 'not fully verified by the rules engine'}).`,
1375
1972
  notes: [
1376
1973
  `Disclosure: ${r.replacement.disclosure_text}`,
1377
1974
  `Consent label: ${r.replacement.consent_label}`,
@@ -1388,7 +1985,7 @@ const getElementsSnippet = {
1388
1985
  title: 'Get the Checkbox Elements snippet for a site',
1389
1986
  description: [
1390
1987
  'Returns paste-ready Checkbox Elements markup for a site: the pinned <script> tag (with SRI) for <head>, the <checkbox-disclosure> element for directly above the pay button, the publishable key, and a React usage example.',
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.',
1988
+ 'The element renders the locked disclosure (it passes the checks) with an unchecked, required consent checkbox, gates the pay button, and records each consent (cons_…) in the evidence vault. Terms come from the site\'s billing connection unless you pass them.',
1392
1989
  'This creates a disclosure record (write, safe to repeat). Consents themselves are recorded by the browser, never by agents.',
1393
1990
  ].join('\n'),
1394
1991
  input: { site: requiredSite, plan: z.string().max(200).optional().describe('Billing price / plan id (price_…) when the site has several.'), ...elementsTermsShape },
@@ -1493,7 +2090,7 @@ const listDueNotices = {
1493
2090
  annotations: { ...ro, title: 'Due notices' },
1494
2091
  async run(args, { api }) {
1495
2092
  const list = await api.request('GET', '/v1/notices/due', {
1496
- query: pick(args, ['site', 'status', 'kind', 'subscription', 'limit', 'starting_after']),
2093
+ query: { ...pick(args, ['site', 'status', 'kind', 'subscription', 'limit', 'starting_after']), ...(typeof args.kind === 'string' ? { kind: args.kind.split(',').map((k) => noticeKind(k.trim())).join(',') } : {}) },
1497
2094
  });
1498
2095
  const overdue = list.data.filter((d) => d.status === 'overdue').length;
1499
2096
  return {
@@ -1538,7 +2135,7 @@ const getNoticeTemplates = {
1538
2135
  site: z.string().max(100).optional().describe('Site (site_…): adds BCC addresses; required with check.'),
1539
2136
  check: z
1540
2137
  .object({
1541
- kind: z.enum(['acknowledgment', 'trial_ending', 'renewal_reminder', 'annual_terms', 'price_change', 'cancellation_confirmation']).describe('Notice kind.'),
2138
+ kind: emailKind.describe('Notice kind.'),
1542
2139
  subject: z.string().max(1000).describe('Subject (merge fields allowed).'),
1543
2140
  text: z.string().max(100_000).optional().describe('Plain-text body.'),
1544
2141
  html: z.string().max(100_000).optional().describe('HTML body.'),
@@ -1554,7 +2151,7 @@ const getNoticeTemplates = {
1554
2151
  if (!args.site)
1555
2152
  throw new Error('site is required to check a template (its plan and jurisdictions scope the check).');
1556
2153
  const r = await api.request('POST', '/v1/notices/templates/check', {
1557
- body: { site: args.site, ...check },
2154
+ body: { site: args.site, ...check, kind: noticeKind(check.kind) },
1558
2155
  });
1559
2156
  return {
1560
2157
  headline: `Template check: ${r.verdict.toUpperCase()}.`,
@@ -1577,8 +2174,9 @@ const callApi = {
1577
2174
  name: 'call_api',
1578
2175
  title: 'Call any Checkbox API endpoint',
1579
2176
  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"}.',
2177
+ 'For a next_action whose operation has no dedicated tool (e.g. createStripeCheckoutConfig, updateTask, updateNoticeSettings, listConsents). Calls the Checkbox /v1 API with this server\'s connection; paths and bodies are in https://app.checkbox.site/v1/openapi.json.',
2178
+ 'Examples: {"method":"GET","path":"/v1/account"} ; {"method":"POST","path":"/v1/sites/site_123/domain_verification/check"}.',
2179
+ 'Never send API keys or Stripe keys through it: keys are added by the human in the Checkbox dashboard, never pasted into the chat.',
1582
2180
  'GET is read-only. POST/DELETE change data: only do what the user or a next_action asked for. DELETE archives or removes objects — confirm with the user first.',
1583
2181
  ].join('\n'),
1584
2182
  input: {
@@ -1603,10 +2201,74 @@ const callApi = {
1603
2201
  },
1604
2202
  };
1605
2203
  /** Every tool, in the order clients list them. */
2204
+ /**
2205
+ * Round 12 (pilot 11, nextjs + agent users): setup_checkbox dropped unknown fields silently; the homepage prompt says
2206
+ * "brand", the schema says merchant_name, so the site was named "localhost" with no warning. Common spellings of the
2207
+ * brand are read as merchant_name; any other unknown field is listed back as ignored.
2208
+ */
2209
+ export const SETUP_FIELD_ALIASES = Object.freeze({ brand: 'merchant_name', brand_name: 'merchant_name', business_name: 'merchant_name', company_name: 'merchant_name', platform: 'billing_platform' });
2210
+ const BILLING_PLATFORMS = new Set(['stripe', 'shopify', 'recharge', 'chargebee', 'recurly', 'paddle', 'woocommerce', 'custom']);
2211
+ export function normalizeSetupArgs(raw, known) {
2212
+ const args = {};
2213
+ const aliased = [];
2214
+ const ignored = [];
2215
+ for (const [k, v] of Object.entries(raw))
2216
+ if (known.has(k))
2217
+ args[k] = v;
2218
+ for (const [k, v] of Object.entries(raw)) {
2219
+ if (known.has(k))
2220
+ continue;
2221
+ const to = SETUP_FIELD_ALIASES[k];
2222
+ const ok = typeof v === 'string' && !!v.trim() && (to !== 'billing_platform' || BILLING_PLATFORMS.has(v.trim().toLowerCase()));
2223
+ if (to && known.has(to) && ok && args[to] === undefined) {
2224
+ args[to] = to === 'billing_platform' ? v.trim().toLowerCase() : v.trim().slice(0, 100);
2225
+ aliased.push(`${k} (read as ${to})`);
2226
+ }
2227
+ else
2228
+ ignored.push(k);
2229
+ }
2230
+ return { args, aliased, ignored };
2231
+ }
2232
+ /** Messages of the API's `warnings` ({ code, message }) on a setup result (or its session). */
2233
+ export function apiWarningMessages(data) {
2234
+ if (!data || typeof data !== 'object')
2235
+ return [];
2236
+ const d = data;
2237
+ const list = Array.isArray(d.warnings) ? d.warnings : Array.isArray(d.session?.warnings) ? d.session.warnings : [];
2238
+ return list.flatMap((w) => (w && typeof w === 'object' && typeof w.message === 'string' ? [`Warning: ${w.message}`] : []));
2239
+ }
2240
+ function acceptingSetupAliases(def) {
2241
+ const known = new Set(Object.keys(def.input));
2242
+ return {
2243
+ ...def,
2244
+ passthrough: true,
2245
+ async run(raw, ctx) {
2246
+ const { args, aliased, ignored } = normalizeSetupArgs(raw, known);
2247
+ // Round 18 (item 19, pilot 17): update_setup refuses unknown top-level fields, as it refuses unknown nested ones
2248
+ // (an ignored field read as "saved" to the agent).
2249
+ if (ignored.length && def.name === 'update_setup') {
2250
+ throw new ToolError(unknownArgumentsMessage(def.name, ignored, [...known]), { type: 'fix_request', message: 'Rename or remove those fields and call update_setup again.' });
2251
+ }
2252
+ const out = await def.run(args, ctx);
2253
+ // Pilot round 13: the API's input warnings (plan amounts that look like dollars, or differ from the Stripe Price)
2254
+ // are shown, and kept next to the field warnings below.
2255
+ const apiWarnings = apiWarningMessages(out.data);
2256
+ if (!aliased.length && !ignored.length && !apiWarnings.length)
2257
+ return out;
2258
+ const warnings = [
2259
+ ...apiWarnings,
2260
+ ...(aliased.length ? [`Read ${aliased.join(', ')}.`] : []),
2261
+ ...(ignored.length ? [`Ignored unknown field${ignored.length === 1 ? '' : 's'}: ${ignored.join(', ')}. ${def.name} accepts: ${[...known].filter((k) => k !== 'device_code').join(', ')}.`] : []),
2262
+ ];
2263
+ const data = out.data && typeof out.data === 'object' && !Array.isArray(out.data) ? { ...out.data, warnings } : out.data;
2264
+ return { ...out, notes: [...warnings, ...(out.notes ?? [])], data };
2265
+ },
2266
+ };
2267
+ }
1606
2268
  export const TOOLS = [
1607
- setupCheckbox,
2269
+ acceptingSetupAliases(setupCheckbox),
1608
2270
  getSetupStatus,
1609
- updateSetup,
2271
+ acceptingSetupAliases(updateSetup),
1610
2272
  completeSetup,
1611
2273
  getCompliancePlan,
1612
2274
  getTask,
@@ -1628,6 +2290,7 @@ export const TOOLS = [
1628
2290
  getSiteStatus,
1629
2291
  listFindings,
1630
2292
  getFinding,
2293
+ publishShopifyTerms,
1631
2294
  runScan,
1632
2295
  getScan,
1633
2296
  listLawUpdates,
@@ -1648,9 +2311,9 @@ export const TOOL_OPERATIONS = {
1648
2311
  update_setup: ['updateSetupSession'],
1649
2312
  update_site: ['updateSite'],
1650
2313
  complete_setup: ['completeSetupSession'],
1651
- get_compliance_plan: ['retrieveCompliancePlan'],
2314
+ get_compliance_plan: ['retrieveCompliancePlan', 'claimReviewPrompt'],
1652
2315
  get_task: ['retrieveTask'],
1653
- verify_task: ['verifyTask'],
2316
+ verify_task: ['verifyTask', 'claimReviewPrompt'],
1654
2317
  answer_task: ['answerTask'],
1655
2318
  verify_site: ['verifySite'],
1656
2319
  get_verification: ['retrieveVerification'],
@@ -1667,6 +2330,7 @@ export const TOOL_OPERATIONS = {
1667
2330
  get_site_status: ['retrieveSite', 'retrieveLatestEvaluation', 'listFindings'],
1668
2331
  list_findings: ['listFindings'],
1669
2332
  get_finding: ['retrieveFinding', 'retrieveFindingFix'],
2333
+ publish_shopify_terms: ['publishShopifyTerms'],
1670
2334
  run_scan: ['createScan'],
1671
2335
  get_scan: ['retrieveScan'],
1672
2336
  list_law_updates: ['listLawUpdates'],