@nextcommerce/campaigns-os 1.52.0 → 1.54.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/README.md +1 -0
  3. package/agents/claude/CLAUDE.md +2 -0
  4. package/agents/codex/AGENTS.md +2 -0
  5. package/agents/copilot/copilot-instructions.md +2 -0
  6. package/agents/cursor/campaigns-os.mdc +2 -0
  7. package/compatibility.json +1 -1
  8. package/contracts/campaign-cart-checkout-field-contract.v0.json +10 -0
  9. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  10. package/contracts/commerce-surface-catalog.json +27 -27
  11. package/contracts/effects.v1.json +295 -47
  12. package/contracts/release-ledger.json +660 -0
  13. package/contracts/supported-surface.json +3 -3
  14. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  15. package/docs/brand-theme-bridge.md +2 -0
  16. package/docs/build-packet.md +67 -19
  17. package/docs/campaign-build-brief.md +48 -17
  18. package/docs/campaign-standardization-report.md +32 -0
  19. package/docs/design-source-package.md +7 -3
  20. package/docs/effects.md +10 -1
  21. package/docs/local-setup.md +1 -1
  22. package/docs/orientation-contract-reference.md +1 -1
  23. package/docs/polish-evidence.md +2 -0
  24. package/docs/progress-snapshots.md +5 -5
  25. package/docs/qa-and-test-orders.md +30 -3
  26. package/docs/readback.md +19 -2
  27. package/docs/runtime-readiness.md +1 -1
  28. package/docs/sdk-storage-compatibility.md +15 -0
  29. package/docs/skills-revision.md +10 -10
  30. package/docs/supported-surface.md +1 -0
  31. package/package.json +1 -1
  32. package/schemas/campaign-runtime-assembly-report.v0.schema.json +5 -1
  33. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  34. package/skills/campaign-readback-classification/SKILL.md +3 -3
  35. package/skills/campaign-run-evidence/SKILL.md +3 -3
  36. package/skills/contribution-intake/SKILL.md +3 -3
  37. package/skills/next-campaigns-build/SKILL.md +5 -3
  38. package/skills/next-campaigns-os/SKILL.md +10 -19
  39. package/skills/next-campaigns-os/references/session-intake.md +10 -6
  40. package/skills/next-campaigns-os-setup/SKILL.md +4 -3
  41. package/skills/next-campaigns-polish/SKILL.md +7 -5
  42. package/skills/next-campaigns-qa/SKILL.md +5 -3
  43. package/skills.json +10 -10
  44. package/src/brand-theme.mjs +21 -20
  45. package/src/brief-summary.mjs +275 -0
  46. package/src/build-brief.mjs +167 -15
  47. package/src/campaign-ecosystem.mjs +153 -31
  48. package/src/cli.mjs +182 -115
  49. package/src/contrast.mjs +649 -0
  50. package/src/design-source-publication.mjs +110 -3
  51. package/src/directory-lock.mjs +79 -7
  52. package/src/doctor/checks.mjs +141 -16
  53. package/src/doctor/inspect.mjs +13 -0
  54. package/src/doctor/next-step.mjs +15 -6
  55. package/src/input-currency.mjs +394 -0
  56. package/src/input-refresh.mjs +708 -0
  57. package/src/invocation.mjs +6 -2
  58. package/src/polish-browser.mjs +503 -201
  59. package/src/polish-deadline.mjs +7 -0
  60. package/src/polish-node.mjs +326 -46
  61. package/src/polish-readability.mjs +828 -0
  62. package/src/progress-node.mjs +18 -5
  63. package/src/qa-browser.mjs +211 -93
  64. package/src/qa-node.mjs +47 -7
  65. package/src/qc-accept.mjs +44 -8
  66. package/src/qc-check-registry.mjs +15 -7
  67. package/src/qc-results.mjs +326 -34
  68. package/src/readback.mjs +58 -10
  69. package/src/sdk-attribute-index.mjs +111 -23
  70. package/src/sdk-markup.mjs +49 -10
  71. package/src/sdk-repin.mjs +282 -0
  72. package/src/stage-ledger.mjs +235 -44
  73. package/src/stage-record.mjs +154 -30
  74. package/src/standardization-report.mjs +26 -1
  75. package/src/target-lock.mjs +9 -1
  76. package/src/tooling-setup.mjs +1 -1
package/src/qc-accept.mjs CHANGED
@@ -14,6 +14,7 @@ import { isNamedHuman, validateWaiverAttribution } from "./checkpoint-waiver.mjs
14
14
  import { DOCTOR_SIDECAR_SCHEMA } from "./doctor-sidecar.mjs";
15
15
  import { cmd } from "./install-invocation.mjs";
16
16
  import { canonicalJson } from "./polish-capture.mjs";
17
+ import { QC_CHECK_REGISTRY } from "./qc-check-registry.mjs";
17
18
  import { QC_LEGS, QC_REASON, fingerprint12, qcResultRef } from "./qc-results.mjs";
18
19
  import { shellToken } from "./shell-token.mjs";
19
20
 
@@ -22,7 +23,18 @@ export const QC_ACCEPT_SCOPE = "qc_accept";
22
23
  export const QC_ACCEPT_RECORDER = "campaigns-os checkpoint accept";
23
24
  export const QC_ACCEPT_STATUSES = Object.freeze(["active", "lapsed", "orphaned", "expired", "inert"]);
24
25
  export const QC_DISPOSITION = Object.freeze({ OPEN: "open", OPERATOR_ACCEPTED: "operator_accepted" });
25
- export const QC_MEASURED_SOURCES = Object.freeze({ doctor: "doctor_sidecar", polish: "polish_media_weight", qa: "qa_full_verdict" });
26
+ // The evidence an accept's measurement came from: the doctor sidecar, the
27
+ // full QA verdict, or, on the Polish leg, the package-owned record the
28
+ // accepted check is read from (QC_CHECK_REGISTRY `record`).
29
+ const QC_LEG_SOURCES = Object.freeze({ doctor: "doctor_sidecar", qa: "qa_full_verdict" });
30
+ export const QC_POLISH_RECORD_SOURCES = Object.freeze({ media_weight: "polish_media_weight", readability: "polish_readability" });
31
+
32
+ // Null for a Polish check with no record, or a leg with no source.
33
+ export function measuredSourceFor(leg, check) {
34
+ if (leg !== "polish") return Object.hasOwn(QC_LEG_SOURCES, leg) ? QC_LEG_SOURCES[leg] : null;
35
+ const entry = Object.hasOwn(QC_CHECK_REGISTRY, check) ? QC_CHECK_REGISTRY[check] : null;
36
+ return entry?.leg === "polish" && Object.hasOwn(QC_POLISH_RECORD_SOURCES, entry.record) ? QC_POLISH_RECORD_SOURCES[entry.record] : null;
37
+ }
26
38
 
27
39
  // The command's closed refusal list.
28
40
  export const QC_ACCEPT_REFUSALS = Object.freeze({
@@ -100,7 +112,7 @@ export function createQcAccept(result, { measuredAt, attribution }) {
100
112
  state_fingerprint: result.state_fingerprint,
101
113
  result_at_accept: "warning",
102
114
  measured_at: measuredAt,
103
- measured_source: QC_MEASURED_SOURCES[result.leg],
115
+ measured_source: measuredSourceFor(result.leg, result.check),
104
116
  ...attribution,
105
117
  recorded_by: QC_ACCEPT_RECORDER,
106
118
  };
@@ -119,7 +131,8 @@ function malformed(record) {
119
131
  && FINGERPRINT.test(record.state_fingerprint || "")
120
132
  && record.result_at_accept === "warning"
121
133
  && validTime(record.measured_at)
122
- && record.measured_source === QC_MEASURED_SOURCES[record.leg]
134
+ && isNonEmptyString(record.measured_source)
135
+ && record.measured_source === measuredSourceFor(record.leg, record.check)
123
136
  && typeof record.reason === "string"
124
137
  && typeof record.accepted_by === "string"
125
138
  && typeof record.accepted_at === "string"
@@ -291,7 +304,29 @@ export function planQcAccepts({ refs, results, sidecarPath, now, attribution })
291
304
  const LEG_ORDER = Object.freeze({ doctor: 0, polish: 1, qa: 2 });
292
305
  const compareText = (a, b) => String(a ?? "").localeCompare(String(b ?? ""));
293
306
 
307
+ // A readability colour pair reads as its roles, colours, ratio and
308
+ // requirement: `<roles>: <fg8> on <bg8>, <ratio>:1, needs <required>:1
309
+ // (<size_class> text)`, from the row's observation (the lowest ratio of the
310
+ // rows given). Null for any other row.
311
+ function readabilityPairSummary(rows) {
312
+ const pairs = rows.map((row) => (row?.check === "readability.contrast" && String(row.subject?.key ?? "").startsWith("pair:") && isPlainObject(row.observation) ? row.observation : null));
313
+ if (!pairs.length || pairs.some((observation) => observation === null)) return null;
314
+ const roles = [...new Set(pairs.flatMap((observation) => (Array.isArray(observation.roles) ? observation.roles : [])))].sort(compareText);
315
+ const lowest = pairs.reduce((low, observation) => (observation.ratio < low.ratio ? observation : low));
316
+ return `${roles.join(", ")}: ${lowest.fg8} on ${lowest.bg8}, ${lowest.display_ratio}:1, needs ${lowest.required}:1 (${lowest.size_class} text)`;
317
+ }
318
+
319
+ // The crop path of a readability review row: its first member's crop.
320
+ // Undefined for a review row with no crop, and for any other row.
321
+ function readabilityReviewCrop(row) {
322
+ if (row?.check !== "readability.contrast" || row.result !== "review") return undefined;
323
+ const members = Array.isArray(row.observation?.members) ? row.observation.members : [];
324
+ const ref = members.find((member) => isPlainObject(member?.crop_ref))?.crop_ref;
325
+ return typeof ref?.path === "string" ? ref.path : undefined;
326
+ }
327
+
294
328
  function handoffEntry(row) {
329
+ const crop = readabilityReviewCrop(row);
295
330
  return {
296
331
  result_ref: qcResultRef(row),
297
332
  check: row.check,
@@ -303,7 +338,8 @@ function handoffEntry(row) {
303
338
  reason_code: row.reason_code,
304
339
  accept_eligible: row.accept_eligible === true,
305
340
  members: Array.isArray(row.members) ? row.members : [],
306
- summary: `${row.check} ${row.result}${row.reason_code ? ` (${row.reason_code})` : ""} on ${row.subject?.page ?? "(no page)"}${row.subject?.key == null ? "" : `: ${row.subject.key}`}`,
341
+ summary: readabilityPairSummary([row]) ?? `${row.check} ${row.result}${row.reason_code ? ` (${row.reason_code})` : ""} on ${row.subject?.page ?? "(no page)"}${row.subject?.key == null ? "" : `: ${row.subject.key}`}`,
342
+ ...(crop === undefined ? {} : { crop }),
307
343
  };
308
344
  }
309
345
 
@@ -315,7 +351,7 @@ const byCheckKeyPage = (a, b) => compareText(a.check, b.check) || compareText(a.
315
351
  // first page's fields, `pages`, and `results`, which keeps every page's result
316
352
  // with its own ref, accept eligibility and members, so every member stays
317
353
  // visible. The entry is accept-eligible only when every result in it is.
318
- function groupOpenWarnings(entries) {
354
+ function groupOpenWarnings(entries, rows = []) {
319
355
  const groups = new Map();
320
356
  for (const entry of [...entries].sort(byCheckKeyPage)) {
321
357
  const key = JSON.stringify([entry.check, entry.key]);
@@ -330,7 +366,7 @@ function groupOpenWarnings(entries) {
330
366
  pages,
331
367
  accept_eligible: results.every((entry) => entry.accept_eligible),
332
368
  summary: results.length > 1
333
- ? `${lead.check} ${lead.result}${lead.reason_code ? ` (${lead.reason_code})` : ""} on ${pages.join(", ")}${lead.key == null ? "" : `: ${lead.key}`}`
369
+ ? readabilityPairSummary(results.map((entry) => rows.find((row) => qcResultRef(row) === entry.result_ref))) ?? `${lead.check} ${lead.result}${lead.reason_code ? ` (${lead.reason_code})` : ""} on ${pages.join(", ")}${lead.key == null ? "" : `: ${lead.key}`}`
334
370
  : lead.summary,
335
371
  results,
336
372
  };
@@ -401,7 +437,7 @@ export function buildQcHandoff({ results, coverage = [], accepts, now = new Date
401
437
  const coverageEntries = [...grouped.values(), ...coverage]
402
438
  .map((entry) => ({ ...entry, pages: [...entry.pages].sort(compareText) }))
403
439
  .sort((a, b) => (LEG_ORDER[a.leg] ?? 9) - (LEG_ORDER[b.leg] ?? 9) || compareText(a.check, b.check) || compareText(a.reason_code, b.reason_code) || compareText(a.result, b.result));
404
- const openGroups = groupOpenWarnings(open);
440
+ const openGroups = groupOpenWarnings(open, results);
405
441
  review.sort(byCheckKeyPage);
406
442
  lapsed.sort(byCheckKeyPage);
407
443
  accepted.sort(byCheckKeyPage);
@@ -435,7 +471,7 @@ export function qcHandoffTextLines(handoff) {
435
471
  section("Open warnings", handoff.open, (entry) => (entry.results?.length > 1
436
472
  ? [entry.summary, ...entry.results.map((result) => ` ${openLine(result)}`)]
437
473
  : openLine(entry)));
438
- section("Review", handoff.review, (entry) => `${entry.result_ref} ${entry.summary}${members(entry)}`);
474
+ section("Review", handoff.review, (entry) => `${entry.result_ref} ${entry.summary}${entry.crop ? ` crop: ${entry.crop}` : ""}${members(entry)}`);
439
475
  section("Lapsed accepts", handoff.lapsed, (entry) => `${entry.result_ref} ${entry.summary}; accepted by ${entry.accepted_by} at ${entry.accepted_at}; lapsed: ${entry.why}`);
440
476
  section("Coverage", handoff.coverage, (entry) => `${entry.leg} ${entry.check}: ${entry.result} (${entry.reason_code})${entry.count ? ` x${entry.count}` : ""}${entry.pages.length ? ` on ${entry.pages.join(", ")}` : ""}`);
441
477
  section("Accepted", handoff.accepted, (entry) => `${entry.result_ref} accepted by ${entry.accepted_by} at ${entry.accepted_at}: ${entry.reason}${members(entry)}`);
@@ -1,15 +1,19 @@
1
1
  // The one map from a QC check id to the check module that re-derives its
2
- // results. The QC readers
3
- // (src/qc-results.mjs) reach every check's rules through here, so a check
4
- // lands by adding its module, never by editing the readers.
2
+ // results. The QC readers (src/qc-results.mjs) reach every check's rules
3
+ // through here. A QA check lands by adding its module; a Polish check reads
4
+ // one package-owned visual_review record, named by `record`, and each record
5
+ // has its own reader in the Polish dispatch (POLISH_RECORDS), so a check for
6
+ // a new record also adds that reader.
5
7
  //
6
8
  // Each entry names a module specifier, imported lazily and relative to this
7
9
  // file, and the export the reader calls:
8
10
  // - QA checks: `rederiveQcResult(observation)` returns a Derived result
9
11
  // ({check, subject, result, reason_code, members, accept_eligible, coverage,
10
12
  // state}) or null when the observation cannot be re-derived.
11
- // - Polish checks: `MEDIA_WEIGHT_QC_RULES` is {thresholds, vocabulary,
12
- // evaluate(cell, thresholds) => Derived[]}.
13
+ // - Polish checks: the record's rules, {thresholds, vocabulary,
14
+ // evaluate(cell, thresholds) => Derived[]} (MEDIA_WEIGHT_QC_RULES for
15
+ // media_weight; READABILITY_QC_RULES for readability, which also carries
16
+ // the record's fixed producer values, limits and route set).
13
17
  // - Doctor checks recompute from the built HTML on every read and are called
14
18
  // by doctor itself; their entries name the evaluator for completeness.
15
19
  //
@@ -23,11 +27,13 @@ export const QC_CHECK_REGISTRY = Object.freeze({
23
27
  // Content params
24
28
  content_param: Object.freeze({ leg: "qa", unit: "1.2", module: "./qa-content-params.mjs", rederive: "rederiveQcResult" }),
25
29
  // Media weight and oversizing
26
- "media.weight": Object.freeze({ leg: "polish", unit: "1.3", module: "./polish-media-weight.mjs", rederive: "MEDIA_WEIGHT_QC_RULES" }),
27
- "media.oversize": Object.freeze({ leg: "polish", unit: "1.3", module: "./polish-media-weight.mjs", rederive: "MEDIA_WEIGHT_QC_RULES" }),
30
+ "media.weight": Object.freeze({ leg: "polish", unit: "1.3", module: "./polish-media-weight.mjs", rederive: "MEDIA_WEIGHT_QC_RULES", record: "media_weight" }),
31
+ "media.oversize": Object.freeze({ leg: "polish", unit: "1.3", module: "./polish-media-weight.mjs", rederive: "MEDIA_WEIGHT_QC_RULES", record: "media_weight" }),
28
32
  // Policy links
29
33
  "policy.presence": Object.freeze({ leg: "qa", unit: "1.4", module: "./qa-policy-links.mjs", rederive: "rederiveQcResult" }),
30
34
  "policy.availability": Object.freeze({ leg: "qa", unit: "1.4", module: "./qa-policy-links.mjs", rederive: "rederiveQcResult" }),
35
+ // Text readability
36
+ "readability.contrast": Object.freeze({ leg: "polish", unit: "2.4", module: "./polish-readability.mjs", rederive: "READABILITY_QC_RULES", record: "readability" }),
31
37
  // Cart placeholders
32
38
  cart_placeholders: Object.freeze({ leg: "doctor", unit: "1.5", module: "./cart-placeholders.mjs", rederive: "evaluateCartPlaceholders" }),
33
39
  // Built-output smoke checks
@@ -35,6 +41,8 @@ export const QC_CHECK_REGISTRY = Object.freeze({
35
41
  });
36
42
 
37
43
  export const qcChecksForLeg = (leg) => Object.keys(QC_CHECK_REGISTRY).filter((check) => QC_CHECK_REGISTRY[check].leg === leg);
44
+ // The Polish checks read from one visual_review record.
45
+ export const qcChecksForRecord = (record) => Object.keys(QC_CHECK_REGISTRY).filter((check) => QC_CHECK_REGISTRY[check].leg === "polish" && QC_CHECK_REGISTRY[check].record === record);
38
46
 
39
47
  // A module "does not exist yet" only when Node reports ERR_MODULE_NOT_FOUND
40
48
  // for that exact specifier. A missing transitive import names another URL,