@nextcommerce/campaigns-os 1.54.0 → 1.57.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 (111) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/CONTEXT.md +19 -5
  3. package/README.md +26 -12
  4. package/campaign-spec/README.md +15 -1
  5. package/campaign-spec/dist/checkout-flow.d.ts +90 -0
  6. package/campaign-spec/dist/checkout-flow.js +229 -0
  7. package/campaign-spec/dist/index.d.ts +1 -0
  8. package/campaign-spec/dist/index.js +6 -1
  9. package/campaign-spec/dist/normalize.d.ts +3 -2
  10. package/campaign-spec/dist/normalize.js +7 -5
  11. package/campaign-spec/dist/routing.d.ts +6 -3
  12. package/campaign-spec/dist/routing.js +6 -3
  13. package/campaign-spec/dist/rules/checkout-forward-target.d.ts +17 -0
  14. package/campaign-spec/dist/rules/checkout-forward-target.js +48 -0
  15. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.d.ts +12 -0
  16. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.js +37 -0
  17. package/campaign-spec/dist/rules/exit-intent-validation.js +2 -1
  18. package/campaign-spec/dist/rules/index.d.ts +4 -0
  19. package/campaign-spec/dist/rules/index.js +12 -0
  20. package/campaign-spec/dist/rules/one-checkout-per-path.d.ts +25 -0
  21. package/campaign-spec/dist/rules/one-checkout-per-path.js +84 -0
  22. package/campaign-spec/dist/rules/pre-payment-forward-target.d.ts +12 -0
  23. package/campaign-spec/dist/rules/pre-payment-forward-target.js +42 -0
  24. package/campaign-spec/dist/rules/promo-code-input-validation.js +2 -1
  25. package/campaign-spec/dist/rules/schema-version.d.ts +9 -4
  26. package/campaign-spec/dist/rules/schema-version.js +10 -5
  27. package/campaign-spec/dist/types.d.ts +20 -3
  28. package/campaign-spec/dist/types.js +3 -2
  29. package/compatibility.json +2 -2
  30. package/contracts/fixtures/runtime-recipe/accept/current.json +6 -1
  31. package/contracts/fixtures/runtime-recipe/accept/minimal.json +6 -1
  32. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  33. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +6 -1
  34. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +6 -1
  35. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +6 -1
  36. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +6 -1
  37. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +6 -1
  38. package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +1 -1
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +6 -1
  40. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +6 -1
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +6 -1
  42. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +5 -0
  43. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +6 -1
  44. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +6 -1
  45. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +6 -1
  46. package/contracts/release-ledger.json +1146 -0
  47. package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
  48. package/contracts/supported-surface.json +9 -5
  49. package/docs/brand-theme-bridge.md +10 -0
  50. package/docs/build-packet.md +86 -26
  51. package/docs/campaigns-os-build-flow.md +1 -1
  52. package/docs/effects.md +2 -5
  53. package/docs/local-setup.md +1 -1
  54. package/docs/orientation-contract-reference.md +1 -1
  55. package/docs/qa-and-test-orders.md +133 -29
  56. package/docs/runtime-readiness.md +8 -3
  57. package/docs/skills-revision.md +10 -10
  58. package/docs/supported-surface.md +1 -1
  59. package/docs/versioning.md +4 -1
  60. package/docs/workflow-findings-sidecar.md +57 -2
  61. package/package.json +4 -1
  62. package/schemas/campaign-runtime-build-context.v0.schema.json +3 -1
  63. package/schemas/campaign-runtime-build-packet.v0.schema.json +3 -1
  64. package/schemas/campaign-spec.v5.schema.json +586 -0
  65. package/schemas/campaigns-os-run-record.v0.schema.json +20 -15
  66. package/skills/campaign-lifecycle-orientation/SKILL.md +10 -8
  67. package/skills/campaign-readback-classification/SKILL.md +3 -3
  68. package/skills/campaign-run-evidence/SKILL.md +3 -3
  69. package/skills/contribution-intake/SKILL.md +3 -3
  70. package/skills/next-campaigns-build/SKILL.md +6 -4
  71. package/skills/next-campaigns-os/SKILL.md +3 -3
  72. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  73. package/skills/next-campaigns-polish/SKILL.md +21 -6
  74. package/skills/next-campaigns-qa/SKILL.md +5 -4
  75. package/skills.json +10 -10
  76. package/src/adapter-decision-contract.mjs +27 -13
  77. package/src/brand-theme.mjs +44 -8
  78. package/src/build-brief.mjs +14 -5
  79. package/src/campaign-identity.mjs +33 -4
  80. package/src/cli-helpers.mjs +129 -0
  81. package/src/cli.mjs +156 -111
  82. package/src/commercial-journey.mjs +25 -2
  83. package/src/consent.mjs +3 -2
  84. package/src/design-source-package.mjs +1 -1
  85. package/src/design-source-publication.mjs +8 -8
  86. package/src/deviation.mjs +2 -1
  87. package/src/doctor/checks.mjs +212 -19
  88. package/src/doctor/inspect.mjs +4 -2
  89. package/src/doctor/next-step.mjs +46 -16
  90. package/src/doctor-sidecar.mjs +5 -3
  91. package/src/gate-actions.mjs +21 -1
  92. package/src/input-refresh.mjs +6 -6
  93. package/src/invocation.mjs +4 -3
  94. package/src/lifecycle.mjs +88 -6
  95. package/src/local-preview-policy.mjs +116 -12
  96. package/src/login.mjs +2 -1
  97. package/src/polish-browser.mjs +8 -2
  98. package/src/polish-node.mjs +9 -4
  99. package/src/polish-readability.mjs +2 -2
  100. package/src/qa-browser.mjs +741 -120
  101. package/src/qa-cart-entry.mjs +107 -20
  102. package/src/qa-node.mjs +179 -72
  103. package/src/qa-test-order-topology.mjs +118 -1
  104. package/src/qa-verdict-discovery.mjs +21 -4
  105. package/src/qa-verdict-publish.mjs +2 -1
  106. package/src/run-record.mjs +46 -34
  107. package/src/runtime-state-ignore.mjs +7 -5
  108. package/src/sdk-markup.mjs +41 -1
  109. package/src/source-html-intake.mjs +46 -4
  110. package/src/stage-ledger.mjs +18 -4
  111. package/src/stage-record.mjs +53 -16
@@ -5,9 +5,126 @@ const ACTIONS = Object.freeze([
5
5
  ["accept", "expected_accept_url"],
6
6
  ]);
7
7
 
8
+ // The page types of a checkout path, as pageType() spells them (lowercase, `_`
9
+ // and `-` dropped). `checkout` is the page that takes payment; `select` and
10
+ // `checkout_step` lead into it without placing an order (campaigns-os#641).
11
+ const PAYMENT_PAGE_TYPE = "checkout";
12
+ const PRE_PAYMENT_PAGE_TYPES = new Set(["select", "checkoutstep"]);
13
+
14
+ export function isPaymentTopologyPage(page) {
15
+ return pageType(page) === PAYMENT_PAGE_TYPE;
16
+ }
17
+
18
+ export function isCheckoutStepTopologyPage(page) {
19
+ return pageType(page) === "checkoutstep";
20
+ }
21
+
22
+ // Follow forward links (expected_next_url) from `start` to the page that takes
23
+ // payment. Null when the walk leaves the topology, loops, or reaches any page
24
+ // that is neither a pre-payment page nor the Checkout.
25
+ export function paymentPageFrom(topology, start) {
26
+ const pages = Array.isArray(topology?.pages) ? topology.pages : [];
27
+ const seen = new Set();
28
+ let page = start || null;
29
+ while (page && !seen.has(page)) {
30
+ seen.add(page);
31
+ if (isPaymentTopologyPage(page)) return page;
32
+ const key = canonicalHttpUrl(page.expected_next_url);
33
+ const next = key ? pages.find((candidate) => canonicalHttpUrl(candidate?.url) === key) : null;
34
+ if (!next) return null;
35
+ if (!isPaymentTopologyPage(next) && !PRE_PAYMENT_PAGE_TYPES.has(pageType(next))) return null;
36
+ page = next;
37
+ }
38
+ return null;
39
+ }
40
+
41
+ // The topology's entry pages: flagged is_entry, else those no page links to
42
+ // (offer and receipt pages excluded: an orphaned upsell is not where a shopper
43
+ // starts), else the first page. Never decided by page names.
44
+ function topologyEntryPages(pages) {
45
+ const flagged = pages.filter((page) => page?.is_entry === true);
46
+ if (flagged.length) return flagged;
47
+ const targeted = new Set();
48
+ for (const page of pages) {
49
+ for (const field of ["expected_next_url", "expected_accept_url", "expected_decline_url"]) {
50
+ const key = canonicalHttpUrl(page?.[field]);
51
+ if (key && key !== canonicalHttpUrl(page?.url)) targeted.add(key);
52
+ }
53
+ }
54
+ const roots = pages.filter((page) => {
55
+ const key = canonicalHttpUrl(page?.url);
56
+ const type = pageType(page);
57
+ return !(key && targeted.has(key)) && !OFFER_PAGE_TYPES.has(type) && !RECEIPT_PAGE_TYPES.has(type);
58
+ });
59
+ return roots.length ? roots : pages.slice(0, 1);
60
+ }
61
+
62
+ // Every Checkout the topology's entry paths reach, in entry order, then any
63
+ // Checkout no entry path reaches (a landing CTA into checkout declares no
64
+ // forward field). "The first page typed checkout" is never the answer on its
65
+ // own: on a multi-step path it is a step, and with split-test paths there are
66
+ // several (campaigns-os#641).
67
+ export function paymentPagesForTopology(topology) {
68
+ const pages = Array.isArray(topology?.pages) ? topology.pages : [];
69
+ const out = [];
70
+ for (const entry of topologyEntryPages(pages)) {
71
+ const checkout = paymentPageFrom(topology, entry);
72
+ if (checkout && !out.includes(checkout)) out.push(checkout);
73
+ }
74
+ for (const page of pages) if (isPaymentTopologyPage(page) && !out.includes(page)) out.push(page);
75
+ return out;
76
+ }
77
+
78
+ // The Checkout of the first entry path of the first topology that has one.
79
+ export function findPaymentPage(topologies = []) {
80
+ for (const topology of Array.isArray(topologies) ? topologies : []) {
81
+ const page = paymentPagesForTopology(topology)[0];
82
+ if (page) return page;
83
+ }
84
+ return null;
85
+ }
86
+
87
+ // The pages a shopper passes through to reach `checkoutPage`, in order and
88
+ // ending with it: an optional select page, then each checkout_step, then the
89
+ // Checkout. Walks backwards over forward links, so it holds whichever path the
90
+ // Checkout sits on; where two pages lead into the same page a checkout_step is
91
+ // preferred over a select page, which can only start the chain.
92
+ export function checkoutPathPages(topology, checkoutPage) {
93
+ const pages = Array.isArray(topology?.pages) ? topology.pages : [];
94
+ if (!checkoutPage) return [];
95
+ const chain = [checkoutPage];
96
+ const seen = new Set(chain);
97
+ let current = checkoutPage;
98
+ for (;;) {
99
+ const key = canonicalHttpUrl(current?.url);
100
+ if (!key) break;
101
+ const leading = pages.filter((page) => !seen.has(page)
102
+ && PRE_PAYMENT_PAGE_TYPES.has(pageType(page))
103
+ && canonicalHttpUrl(page?.expected_next_url) === key);
104
+ const previous = leading.find(isCheckoutStepTopologyPage) || leading[0] || null;
105
+ if (!previous) break;
106
+ chain.unshift(previous);
107
+ seen.add(previous);
108
+ if (!isCheckoutStepTopologyPage(previous)) break;
109
+ current = previous;
110
+ }
111
+ return chain;
112
+ }
113
+
114
+ // Only the checkout_step pages of that path, in order.
115
+ export function checkoutStepPages(topology, checkoutPage) {
116
+ return checkoutPathPages(topology, checkoutPage).filter(isCheckoutStepTopologyPage);
117
+ }
118
+
8
119
  export function resolveTestOrderTopology(topology = {}, checkoutPage = null) {
9
120
  const pages = Array.isArray(topology?.pages) ? topology.pages : [];
10
- const checkout = checkoutPage || pages.find((page) => pageType(page) === "checkout") || null;
121
+ // A caller may hand in a select or checkout_step page; the path's Checkout is
122
+ // the page whose forward link leads to the offers. Starting from a step made
123
+ // its next step a `nonterminal_target` and hid every offer path.
124
+ const given = checkoutPage && !isPaymentTopologyPage(checkoutPage) && PRE_PAYMENT_PAGE_TYPES.has(pageType(checkoutPage))
125
+ ? paymentPageFrom(topology, checkoutPage) || checkoutPage
126
+ : checkoutPage;
127
+ const checkout = given || paymentPagesForTopology(topology)[0] || null;
11
128
  const pagesByUrl = new Map();
12
129
  for (const page of pages) {
13
130
  const key = canonicalHttpUrl(page?.url);
@@ -13,7 +13,7 @@ import { resolveCampaignIdentity, localQaIdentifier } from "./spec-source-identi
13
13
 
14
14
  import { createHash } from "node:crypto";
15
15
  import { readdirSync, readFileSync, statSync } from "node:fs";
16
- import { dirname, join, relative, resolve } from "node:path";
16
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
17
17
  import { QA_OUTPUT_REL_PATH } from "./campaign-workspace.mjs";
18
18
  import { absentOrMalformed } from "./fs-identity.mjs";
19
19
  import { isPlainObject as isObject, normalizeString as optionalString } from "./repo-scan.mjs";
@@ -53,7 +53,8 @@ export function qaVerdictDir(root, identifier) {
53
53
 
54
54
  // The verdict paths the Assembly Report's qa stage records (any `*.json`
55
55
  // string under a path/file/verdict/output-named key, or inside outputs[] /
56
- // artifacts[] / qa), relative to the report unless absolute.
56
+ // artifacts[] / qa), relative to the target root unless absolute. Older
57
+ // report-relative hints remain readable.
57
58
  export function qaVerdictPathHints(report) {
58
59
  const paths = [];
59
60
  const visit = (value) => {
@@ -90,7 +91,8 @@ function sha256File(path) {
90
91
  }
91
92
 
92
93
  // Every verdict candidate for a campaign: the files the report records
93
- // (source "assembly_report", resolved against the report) and every `*.json`
94
+ // (source "assembly_report", resolved against the report first, then the target)
95
+ // and every `*.json`
94
96
  // under `qa-output/<identifier>/` beneath each root (source "qa_output").
95
97
  // { path, repoRelPath, source, verdict, sha256, mtimeMs, identityMatch, trusted }
96
98
  // `verdict` is null for a recorded path that exists but is not a JSON object
@@ -141,8 +143,14 @@ export function* iterateQaVerdicts({ packet = null, report = null, reportPath =
141
143
  };
142
144
 
143
145
  const reportBase = reportPath ? dirname(resolve(reportPath)) : null;
146
+ const targetRoot = uniqueTargetRoot(roots, reportBase);
144
147
  for (const hint of qaVerdictPathHints(report)) {
145
- const found = candidate(reportBase ? resolve(reportBase, hint) : hint, "assembly_report", null);
148
+ const reportRelative = (isAbsolute(hint) || reportBase)
149
+ ? candidate(isAbsolute(hint) ? hint : resolve(reportBase, hint), "assembly_report", null)
150
+ : null;
151
+ const found = reportRelative || (!isAbsolute(hint) && targetRoot
152
+ ? candidate(resolve(targetRoot, hint), "assembly_report", null)
153
+ : null);
146
154
  if (found) yield found;
147
155
  }
148
156
 
@@ -172,6 +180,15 @@ export function* iterateQaVerdicts({ packet = null, report = null, reportPath =
172
180
  }
173
181
  }
174
182
 
183
+ function uniqueTargetRoot(roots, reportBase) {
184
+ for (let directory = reportBase; directory; directory = dirname(directory)) {
185
+ if (basename(directory) === ".campaign-runtime") return dirname(directory);
186
+ if (dirname(directory) === directory) break;
187
+ }
188
+ const candidates = [...new Set(roots.filter((root) => typeof root === "string" && root).map((root) => resolve(root)))];
189
+ return candidates.length === 1 ? candidates[0] : null;
190
+ }
191
+
175
192
  export function discoverQaVerdicts(options = {}) {
176
193
  return [...iterateQaVerdicts(options)];
177
194
  }
@@ -83,7 +83,7 @@ export function skippedQaVerdictPublish() {
83
83
  * remit (`ok` when the receiver's answer counts as stored, `failed` when it
84
84
  * refused or never answered, `skipped` when nothing was sent).
85
85
  */
86
- export function qaVerdictPublishBlock(outcome, { verdictRunId, publisher, publishedAt = null }) {
86
+ export function qaVerdictPublishBlock(outcome, { verdictRunId, publisher, publishedAt = null, reason = null }) {
87
87
  const attempted = Boolean(outcome?.attempted);
88
88
  const ok = attempted ? outcome.ok === true : null;
89
89
  return {
@@ -94,6 +94,7 @@ export function qaVerdictPublishBlock(outcome, { verdictRunId, publisher, publis
94
94
  error: attempted && typeof outcome.error === "string" ? outcome.error : null,
95
95
  endpoint: attempted ? outcome.endpoint ?? QA_VERDICT_PUBLISH_ENDPOINT : null,
96
96
  state: attempted ? (ok ? "ok" : "failed") : "skipped",
97
+ reason: reason ?? outcome?.reason ?? null,
97
98
  result: attempted ? outcome.result ?? null : null,
98
99
  base_kind: attempted ? outcome.base_kind ?? null : null,
99
100
  published_at: attempted && ok ? publishedAt : null,
@@ -14,6 +14,8 @@ import { randomBytes } from "node:crypto";
14
14
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
15
15
  import { dirname, join, resolve } from "node:path";
16
16
  import { ADAPTER_DECISION_STRATEGY_FIELDS } from "./adapter-decision-contract.mjs";
17
+ import { currentBuildFingerprint } from "./polish-gate.mjs";
18
+ import { qaRecordedBuildFingerprint, qaRecordedForCurrentBuild } from "./stage-ledger.mjs";
17
19
 
18
20
  export const RUN_RECORD_SCHEMA = "campaigns-os-run-record/v0";
19
21
  export const RUN_RECORDS_DIR_REL_PATH = ".campaign-runtime/run-records";
@@ -60,6 +62,14 @@ export const RUN_RECORD_REMIT_BASE_KINDS = ["canonical", "loopback", "proxy"];
60
62
  // to refuse re-posting a verdict the portal already holds.
61
63
  export const RUN_RECORD_QA_VERDICT_PUBLISH_STATES = ["skipped", "ok", "failed"];
62
64
  export const RUN_RECORD_QA_VERDICT_PUBLISHERS = ["qa run", "qa publish"];
65
+ export const RUN_RECORD_QA_VERDICT_PUBLISH_REASONS = ["loopback_base_url", "portal_managed_default", "consent_off", "flag_opt_out", "flag_opt_in", "default", "local_spec"];
66
+ export const RUN_RECORD_CLOSERS = ["run_end", "qa_auto_end", "stale_sweep", "manual"];
67
+ const RUN_RECORD_FIELDS = new Set([
68
+ "schema_version", "run_id", "package_version", "surface_version", "toolkit_commit", "command", "argv_shape", "created_at",
69
+ "consent_state", "consent_source", "remit_attempted", "remit_ok", "remit_error", "remit_endpoint", "remit_state",
70
+ "remit_result", "remit_base_kind", "qa_verdict_publish", "closed_by", "identity", "artifacts", "observations",
71
+ "surfaces", "primary_surface", "surface_confidence", "lifecycle",
72
+ ]);
63
73
 
64
74
  // Required core. Strict here; permissive about optional sub-structures (the
65
75
  // validator checks shapes, not nested artifact bodies — those are referenced
@@ -105,6 +115,12 @@ export function validateRunRecord(record) {
105
115
  if (record.schema_version != null && record.schema_version !== RUN_RECORD_SCHEMA) {
106
116
  add("record.schema_version", `Expected schema_version "${RUN_RECORD_SCHEMA}".`);
107
117
  }
118
+ for (const key of Object.keys(record)) {
119
+ if (!RUN_RECORD_FIELDS.has(key)) add(`record.${key}`, `unknown Run Record field "${key}".`);
120
+ }
121
+ if (record.closed_by !== undefined && !RUN_RECORD_CLOSERS.includes(record.closed_by)) {
122
+ add("record.closed_by", `closed_by must be one of: ${RUN_RECORD_CLOSERS.join(", ")}.`);
123
+ }
108
124
 
109
125
  if (record.argv_shape == null || !isStringArray(record.argv_shape)) {
110
126
  add("record.argv_shape", "argv_shape is required and must be an array of strings (flag names, never values).");
@@ -215,25 +231,16 @@ export function validateRunRecord(record) {
215
231
  const q = obs.qa;
216
232
  if (typeof q !== "object" || Array.isArray(q)) {
217
233
  add("record.observations.qa", "qa must be an object.");
218
- } else if (q.gap_classes != null && !isStringArray(q.gap_classes)) {
219
- add("record.observations.qa.gap_classes", "gap_classes must be an array of strings.");
220
- }
221
- }
222
- }
223
- }
224
-
225
- if (record.agent_usage != null) {
226
- const usage = record.agent_usage;
227
- if (typeof usage !== "object" || Array.isArray(usage)) {
228
- add("record.agent_usage", "agent_usage must be an object when present.");
229
- } else {
230
- for (const field of ["input_tokens", "output_tokens", "tool_output_tokens", "total_tokens", "elapsed_ms"]) {
231
- if (usage[field] != null && (!Number.isInteger(usage[field]) || usage[field] < 0)) {
232
- add(`record.agent_usage.${field}`, `${field} must be a non-negative integer when present.`);
234
+ } else {
235
+ if (q.gap_classes != null && !isStringArray(q.gap_classes)) add("record.observations.qa.gap_classes", "gap_classes must be an array of strings.");
236
+ if (q.verdict_run_id != null && !isNonEmptyString(q.verdict_run_id)) add("record.observations.qa.verdict_run_id", "verdict_run_id must be a non-empty string or null.");
237
+ if (q.build_fingerprint != null && !isNonEmptyString(q.build_fingerprint)) add("record.observations.qa.build_fingerprint", "build_fingerprint must be a non-empty string or null.");
238
+ if (q.stale != null && typeof q.stale !== "boolean") add("record.observations.qa.stale", "stale must be a boolean or null.");
239
+ for (const key of Object.keys(q)) {
240
+ if (!["disposition", "gap_classes", "verdict_run_id", "build_fingerprint", "stale"].includes(key)) add(`record.observations.qa.${key}`, `unknown qa observation field "${key}".`);
241
+ }
233
242
  }
234
243
  }
235
- if (usage.model != null && typeof usage.model !== "string") add("record.agent_usage.model", "model must be a string when present.");
236
- if (usage.source != null && typeof usage.source !== "string") add("record.agent_usage.source", "source must be a string when present.");
237
244
  }
238
245
  }
239
246
 
@@ -301,7 +308,10 @@ export function validateQaVerdictPublish(block) {
301
308
  if (block.published_at != null && !isNonEmptyString(block.published_at)) {
302
309
  add("record.qa_verdict_publish.published_at", "published_at must be a non-empty string or null.");
303
310
  }
304
- const allowed = new Set(["verdict_run_id", "publisher", "attempted", "ok", "error", "endpoint", "state", "result", "base_kind", "published_at"]);
311
+ if (block.reason != null && !RUN_RECORD_QA_VERDICT_PUBLISH_REASONS.includes(block.reason)) {
312
+ add("record.qa_verdict_publish.reason", `reason must be one of: ${RUN_RECORD_QA_VERDICT_PUBLISH_REASONS.join(", ")} (or null).`);
313
+ }
314
+ const allowed = new Set(["verdict_run_id", "publisher", "attempted", "ok", "error", "endpoint", "state", "result", "base_kind", "published_at", "reason"]);
305
315
  for (const key of Object.keys(block)) {
306
316
  if (!allowed.has(key)) add(`record.qa_verdict_publish.${key}`, `unknown qa_verdict_publish field "${key}".`);
307
317
  }
@@ -324,6 +334,9 @@ export function validateRunRecordLifecycle(lc) {
324
334
  if (lc.duration_ms != null && typeof lc.duration_ms !== "number") add("record.lifecycle.duration_ms", "duration_ms must be a number or null.");
325
335
  if (lc.wall_clock_duration_ms != null && typeof lc.wall_clock_duration_ms !== "number") add("record.lifecycle.wall_clock_duration_ms", "wall_clock_duration_ms must be a number or null.");
326
336
  if (lc.repair_loop_count != null && !Number.isInteger(lc.repair_loop_count)) add("record.lifecycle.repair_loop_count", "repair_loop_count must be an integer or null.");
337
+ for (const field of ["needs_input_count", "failure_count"]) {
338
+ if (lc[field] !== undefined && (!Number.isInteger(lc[field]) || lc[field] < 0)) add(`record.lifecycle.${field}`, `${field} must be a non-negative integer.`);
339
+ }
327
340
  if (lc.stages != null) {
328
341
  if (!Array.isArray(lc.stages)) {
329
342
  add("record.lifecycle.stages", "stages must be an array when present.");
@@ -334,6 +347,8 @@ export function validateRunRecordLifecycle(lc) {
334
347
  } else {
335
348
  if (stage.duration_ms != null && typeof stage.duration_ms !== "number") add(`record.lifecycle.stages[${index}].duration_ms`, "stage duration_ms must be a number or null.");
336
349
  if (stage.exit_status != null && !Number.isInteger(stage.exit_status)) add(`record.lifecycle.stages[${index}].exit_status`, "stage exit_status must be an integer or null.");
350
+ if (stage.wait_ms != null && (!Number.isInteger(stage.wait_ms) || stage.wait_ms < 0)) add(`record.lifecycle.stages[${index}].wait_ms`, "stage wait_ms must be a non-negative integer or null.");
351
+ if (stage.finding_codes !== undefined && (!isStringArray(stage.finding_codes) || stage.finding_codes.length > 5 || new Set(stage.finding_codes).size !== stage.finding_codes.length || !Number.isInteger(stage.exit_status) || stage.exit_status === 0)) add(`record.lifecycle.stages[${index}].finding_codes`, "finding_codes must have at most five distinct strings and require a non-zero exit_status.");
337
352
  }
338
353
  });
339
354
  }
@@ -395,8 +410,14 @@ function extractSpecValidationRuleIds(doctor) {
395
410
  return [...new Set(ids)];
396
411
  }
397
412
 
398
- function extractQaObservations(verdict) {
413
+ function extractQaObservations(verdict, report) {
399
414
  if (!verdict || typeof verdict !== "object") return null;
415
+ const verdictRunId = isNonEmptyString(verdict.run_id) ? verdict.run_id : null;
416
+ // The verdict owns the disposition. The report owns QA's build stamp, so
417
+ // only bind the two when its QA stage names this exact verdict.
418
+ const sameVerdict = verdictRunId !== null && report?.stages?.qa?.verdict_run_id === verdictRunId;
419
+ const recorded = sameVerdict ? qaRecordedBuildFingerprint(report) : null;
420
+ const current = currentBuildFingerprint(report);
400
421
  const families = new Set();
401
422
  for (const exception of Array.isArray(verdict.exceptions) ? verdict.exceptions : []) {
402
423
  if (typeof exception?.family === "string") families.add(exception.family);
@@ -404,6 +425,9 @@ function extractQaObservations(verdict) {
404
425
  return {
405
426
  disposition: typeof verdict.disposition === "string" ? verdict.disposition : null,
406
427
  gap_classes: [...families],
428
+ verdict_run_id: verdictRunId,
429
+ build_fingerprint: recorded,
430
+ stale: recorded && current ? !qaRecordedForCurrentBuild(report, current) : null,
407
431
  };
408
432
  }
409
433
 
@@ -465,17 +489,6 @@ function normalizeRemitState(remit) {
465
489
  return "skipped";
466
490
  }
467
491
 
468
- function normalizeAgentUsage(usage) {
469
- if (!usage || typeof usage !== "object" || Array.isArray(usage)) return null;
470
- const out = {};
471
- for (const field of ["input_tokens", "output_tokens", "tool_output_tokens", "total_tokens", "elapsed_ms"]) {
472
- if (Number.isInteger(usage[field]) && usage[field] >= 0) out[field] = usage[field];
473
- }
474
- if (isNonEmptyString(usage.model)) out.model = usage.model.trim();
475
- if (isNonEmptyString(usage.source)) out.source = usage.source.trim();
476
- return Object.keys(out).length ? out : null;
477
- }
478
-
479
492
  const PRIMARY_SURFACE_PRIORITY = ["platform", "template", "design-source", "spec-rule", "cli", "skill", "docs"];
480
493
 
481
494
  const ADAPTER_DECISION_SURFACE_BY_FIELD = {
@@ -667,7 +680,7 @@ export function assembleRunRecord({
667
680
  primarySurface = null,
668
681
  surfaceConfidence = null,
669
682
  lifecycle = null,
670
- agentUsage = null,
683
+ closedBy = null,
671
684
  qaVerdictPublish = null,
672
685
  now = new Date(),
673
686
  } = {}) {
@@ -679,7 +692,7 @@ export function assembleRunRecord({
679
692
  }
680
693
  const adapter = extractAdapterDecisions(selectAdapterDecisions({ packet, report, context }));
681
694
  if (adapter) observations.adapter_decisions = adapter;
682
- const qaObs = extractQaObservations(qaVerdict);
695
+ const qaObs = extractQaObservations(qaVerdict, report);
683
696
  if (qaObs) observations.qa = qaObs;
684
697
  observations.finding_ids = selectRunFindingIds(journal, runId);
685
698
  const malformedJournalLines = Array.isArray(journal?.malformed)
@@ -732,8 +745,7 @@ export function assembleRunRecord({
732
745
  }
733
746
  if (surfaceConfidence) record.surface_confidence = surfaceConfidence;
734
747
  if (lifecycle && typeof lifecycle === "object" && !Array.isArray(lifecycle)) record.lifecycle = lifecycle;
735
- const normalizedUsage = normalizeAgentUsage(agentUsage);
736
- if (normalizedUsage) record.agent_usage = normalizedUsage;
748
+ if (RUN_RECORD_CLOSERS.includes(closedBy)) record.closed_by = closedBy;
737
749
  // Present only when a publish outcome is known: an absent block reads as
738
750
  // "nothing recorded", which is what every record before this field says.
739
751
  if (qaVerdictPublish && typeof qaVerdictPublish === "object" && !Array.isArray(qaVerdictPublish)) record.qa_verdict_publish = qaVerdictPublish;
@@ -29,6 +29,7 @@ export const RUNTIME_STATE_IGNORED_PATHS = [
29
29
  ".campaign-runtime/workflow-findings.jsonl",
30
30
  ".campaign-runtime/run-records/",
31
31
  ".campaign-runtime/fetched-specs/",
32
+ ".campaign-runtime/progress/",
32
33
  ".campaign-runtime/polish-evidence/",
33
34
  ".campaign-runtime/evidence/",
34
35
  // Full QA verdicts carry live storefront URLs and order references; the
@@ -41,11 +42,12 @@ export const RUNTIME_STATE_IGNORED_PATHS = [
41
42
 
42
43
  // Entries the toolkit adds to an existing managed block when they are
43
44
  // missing from the file. The block is otherwise the operator's to edit, but
44
- // these name directories the toolkit itself fills with live URLs and order
45
- // references, so a target set up before the entry existed must gain it on the
46
- // next setup command rather than on the next accidental commit. An entry an
47
- // operator has placed anywhere else in the file counts as present.
48
- export const RUNTIME_STATE_REQUIRED_IGNORED_PATHS = ["qa-output/"];
45
+ // these name directories the toolkit itself fills with campaign observations,
46
+ // live URLs, or order references. A target set up before the entry existed
47
+ // must gain it on the next setup command rather than on the next accidental
48
+ // commit. An entry an operator has placed anywhere else in the file counts as
49
+ // present.
50
+ export const RUNTIME_STATE_REQUIRED_IGNORED_PATHS = ["qa-output/", ".campaign-runtime/progress/"];
49
51
 
50
52
  export const RUNTIME_STATE_IGNORE_BLOCK = [
51
53
  RUNTIME_STATE_IGNORE_MARKER,
@@ -88,6 +88,7 @@ export const SDK_MARKUP_CODES = Object.freeze({
88
88
  WRONG_FIELD_NAME: { code: `${SDK_MARKUP}.wrong_field_name`, severity: "error" },
89
89
  MISSING_SELECTOR_ID_MATCH: { code: `${SDK_MARKUP}.missing_selector_id_match`, severity: "error" },
90
90
  ORPHANED_UPSELL_ACTION: { code: `${SDK_MARKUP}.orphaned_upsell_action`, severity: "error" },
91
+ RECEIPT_ORDER_ITEMS_CART_VISIBILITY: { code: `${SDK_MARKUP}.receipt_order_items_cart_visibility`, severity: "error" },
91
92
  DOUBLE_SELECTED: { code: `${SDK_MARKUP}.double_selected`, severity: "warning" },
92
93
  TEMPLATE_DOUBLE_BRACE: { code: `${SDK_MARKUP}.template_double_brace`, severity: "warning" },
93
94
  // Unknown data-next-* names are not a finding and carry no code: they are
@@ -122,6 +123,31 @@ export const TEMPLATE_CONTAINER_ATTRIBUTES = [
122
123
  // Elements that ARE a selector an add-to-cart button can link to by id.
123
124
  const SELECTOR_ATTRIBUTES = ["data-next-bundle-selector", "data-next-package-selector", "data-next-cart-selector", "data-next-upsell-selector"];
124
125
 
126
+ function cartVisibilityCondition(entry) {
127
+ return ["data-next-hide", "data-next-show"]
128
+ .find((name) => referencesCartState(entry.attrs.get(name))) || null;
129
+ }
130
+
131
+ function referencesCartState(condition) {
132
+ const source = String(condition || "");
133
+ let outside = "";
134
+ let quote = null;
135
+ for (let at = 0; at < source.length; at += 1) {
136
+ const char = source[at];
137
+ if (quote) {
138
+ if (char === "\\") at += 1;
139
+ else if (char === quote) quote = null;
140
+ outside += " ";
141
+ } else if (char === "'" || char === '"' || char === "`") {
142
+ quote = char;
143
+ outside += " ";
144
+ } else {
145
+ outside += char;
146
+ }
147
+ }
148
+ return /(?:^|[^\w.])cart\.[A-Za-z_$][\w$]*/i.test(outside);
149
+ }
150
+
125
151
  function attrs(node) {
126
152
  const map = new Map();
127
153
  for (const attr of node.attrs || []) map.set(attr.name.toLowerCase(), attr.value);
@@ -171,7 +197,7 @@ function describe(entry) {
171
197
  * `sdk_version` is the campaign's SDK pin, used when the page carries no
172
198
  * exact loader pin of its own.
173
199
  */
174
- export function scanPageMarkup({ page_id, file = null, content = "", sdk_version = null }) {
200
+ export function scanPageMarkup({ page_id, file = null, content = "", sdk_version = null, page_type = null }) {
175
201
  const document = parse(String(content || ""));
176
202
  const where = file || page_id;
177
203
  const sdk = resolvePageSdkVersion(document, sdk_version);
@@ -183,6 +209,10 @@ export function scanPageMarkup({ page_id, file = null, content = "", sdk_version
183
209
  const selectorIds = new Set(); // ids of elements that are themselves a selector
184
210
  const templates = []; // { entry, sdkOwned }
185
211
  const referencedTemplateIds = new Set();
212
+ let receiptPage = String(page_type || "").trim().toLowerCase() === "receipt";
213
+ walkElements(document, ({ tag, attrs: a }) => {
214
+ if (tag === "meta" && a.get("name")?.toLowerCase() === "next-page-type" && a.get("content")?.trim().toLowerCase() === "receipt") receiptPage = true;
215
+ });
186
216
 
187
217
  walkElements(document, (entry) => {
188
218
  const { tag, attrs: a, ancestors } = entry;
@@ -217,6 +247,16 @@ export function scanPageMarkup({ page_id, file = null, content = "", sdk_version
217
247
  { tag, action: value }));
218
248
  }
219
249
 
250
+ if (receiptPage && a.has("data-next-order-items")) {
251
+ const holder = [entry, ...ancestors].find(cartVisibilityCondition);
252
+ if (holder) {
253
+ const conditionAttribute = cartVisibilityCondition(holder);
254
+ findings.push(finding("RECEIPT_ORDER_ITEMS_CART_VISIBILITY", page_id, where,
255
+ `${describe(entry)} on ${where} ${holder === entry ? "carries" : `is inside ${describe(holder)} with`} ${conditionAttribute}="${holder.attrs.get(conditionAttribute)}". After checkout the cart can be empty, so receipt order lines disappear. Remove the cart-state visibility condition from the order-items element and its ancestors.`,
256
+ { condition_attribute: conditionAttribute, condition: holder.attrs.get(conditionAttribute) }));
257
+ }
258
+ }
259
+
220
260
  const action = (a.get("data-next-action") || "").trim().toLowerCase();
221
261
  if (action === "add-to-cart") {
222
262
  addToCartButtons.push({ entry, selectorId: (a.get("data-next-selector-id") || "").trim() || null });
@@ -4,7 +4,7 @@ import {
4
4
  } from "./source-html-manifest.mjs";
5
5
  // Built output of campaign-spec (same import shape as src/page-kit-sdk-version.mjs),
6
6
  // so build-time wiring and spec-time analysis share one edge resolver.
7
- import { declineRouteTarget, forwardRouteTarget } from "../campaign-spec/dist/index.js";
7
+ import { checkoutPathFrom, declineRouteTarget, forwardRouteTarget, forwardTargetPage, isCheckoutStepPage } from "../campaign-spec/dist/index.js";
8
8
  import { isAbsoluteHttpUrl, normalizePageKitRoute, normalizePublicRouteSlug, stripPublicRoutePrefix } from "./route-identity.mjs";
9
9
 
10
10
  const CPK_PAGE_TYPES = new Set(["product", "checkout", "upsell", "receipt"]);
@@ -30,7 +30,7 @@ export function createSourceHtmlIntake({
30
30
  const mappings = matched.mappings.map((mapping) => {
31
31
  const specPage = pageById.get(mapping.page_id);
32
32
  if (!specPage || !mapping.path) return mapping;
33
- const pageKit = pageKitProjectionForPage(specPage, { pageById, publicRouteSlug, outputDir });
33
+ const pageKit = pageKitProjectionForPage(specPage, { pageById, publicRouteSlug, outputDir, specPages: specPages || [] });
34
34
  projectionDecisions.push({
35
35
  id: `dec_page_kit_target_${specPage.id}`,
36
36
  stage: "prepare_build",
@@ -559,7 +559,7 @@ function addSpecHints(mapping, page) {
559
559
  if (variantLabels) mapping.variant_labels = variantLabels;
560
560
  }
561
561
 
562
- function pageKitProjectionForPage(page, { pageById, publicRouteSlug, outputDir }) {
562
+ function pageKitProjectionForPage(page, { pageById, publicRouteSlug, outputDir, specPages = [] }) {
563
563
  const specRoute = publicRouteForPage(page);
564
564
  const relativeRoute = stripPublicRoutePrefix(specRoute, publicRouteSlug);
565
565
  const publicRoute = rootedCampaignRoute(relativeRoute, publicRouteSlug);
@@ -574,6 +574,7 @@ function pageKitProjectionForPage(page, { pageById, publicRouteSlug, outputDir }
574
574
  if (nextUrl) frontmatter.next_url = nextUrl;
575
575
  const declineUrl = declineUrlForPage(page, pageById, publicRouteSlug);
576
576
  if (declineUrl && declineUrl !== nextUrl) frontmatter.decline_url = declineUrl;
577
+ Object.assign(frontmatter, checkoutFlowFrontmatter(page, { specPages, pageById, publicRouteSlug }));
577
578
 
578
579
  return {
579
580
  target_path: targetPath,
@@ -586,6 +587,43 @@ function pageKitProjectionForPage(page, { pageById, publicRouteSlug, outputDir }
586
587
  };
587
588
  }
588
589
 
590
+ // Multi-step checkout wiring (campaigns-os#641), for any page that leads into a
591
+ // Checkout without being it: a select page, a checkout_step, or a landing page
592
+ // whose forward links reach a Checkout.
593
+ // success_url the Checkout's post-payment destination (its own next_url).
594
+ // The page's next-success-url meta tag carries it, because the
595
+ // SDK reads that tag for express orders placed on this page; an
596
+ // express order on step 1 must land on the first upsell, not on
597
+ // step 2. Forward navigation never uses that tag: a step form
598
+ // navigates by data-next-checkout-step, a select page by a link.
599
+ // step_number on a checkout_step only: its position among the steps of its
600
+ // path, from 1, for the form's data-next-step-number.
601
+ function checkoutFlowFrontmatter(page, { specPages, pageById, publicRouteSlug }) {
602
+ if (!page || page.type === "checkout") return {};
603
+ const path = checkoutPathFrom(specPages, page);
604
+ if (!path || path.length < 2) return {};
605
+ const out = {};
606
+ const successUrl = nextUrlForPage(path[path.length - 1], pageById, publicRouteSlug);
607
+ if (successUrl) out.success_url = successUrl;
608
+ if (isCheckoutStepPage(page)) out.step_number = checkoutStepNumber(page, specPages);
609
+ return out;
610
+ }
611
+
612
+ function checkoutStepNumber(page, specPages) {
613
+ let number = 1;
614
+ const seen = new Set([page]);
615
+ let current = page;
616
+ for (;;) {
617
+ const previous = specPages.find((candidate) => !seen.has(candidate)
618
+ && isCheckoutStepPage(candidate)
619
+ && forwardTargetPage(specPages, candidate) === current);
620
+ if (!previous) return number;
621
+ number += 1;
622
+ seen.add(previous);
623
+ current = previous;
624
+ }
625
+ }
626
+
589
627
  function pageKitTargetPrompts(mappings) {
590
628
  const prompts = [];
591
629
  const byOutputPath = new Map();
@@ -664,12 +702,15 @@ function cpkPageTypeForSpecType(type) {
664
702
  if (type === "presell" || type === "landing") return "product";
665
703
  if (type === "thankyou" || type === "receipt") return "receipt";
666
704
  if (type === "upsell" || type === "downsell") return "upsell";
667
- if (type === "checkout" || type === "select") return "checkout";
705
+ // select, checkout_step and checkout all render the SDK checkout surface
706
+ // (campaigns-os#641); only the Checkout places the order.
707
+ if (type === "checkout" || type === "checkout_step" || type === "select") return "checkout";
668
708
  return CPK_PAGE_TYPES.has(type) ? type : "product";
669
709
  }
670
710
 
671
711
  function defaultRouteForSpecType(type) {
672
712
  if (type === "thankyou" || type === "receipt") return "receipt/";
713
+ if (type === "checkout_step") return "checkout-step/";
673
714
  if (["presell", "landing", "checkout", "upsell", "downsell"].includes(type)) return `${type}/`;
674
715
  return `${type || "page"}/`;
675
716
  }
@@ -696,6 +737,7 @@ function pageMatchKeys(page, ordinal) {
696
737
  }
697
738
  if (page.type === "landing" || page.type === "presell") keys.add("index");
698
739
  if (page.type === "checkout") keys.add("checkout");
740
+ if (page.type === "checkout_step") keys.add("checkout-step");
699
741
  if (page.type === "upsell") keys.add("upsell");
700
742
  if (page.type === "downsell") keys.add("downsell");
701
743
  return [...keys];
@@ -5,6 +5,7 @@ import { STATUS as QA_STATUS } from "./qa-verdict.mjs";
5
5
  import { assessInputCurrency, currentPacketInputs, effectiveStageStatus, effectiveStatusIsTerminal, wellFormedBriefMaterial } from "./input-currency.mjs";
6
6
  import { isPlainObject, normalizeString as optionalString } from "./repo-scan.mjs";
7
7
  import { withTargetLockSync } from "./target-lock.mjs";
8
+ import { portableArtifactPaths } from "./cli-helpers.mjs";
8
9
  import {
9
10
  ASSEMBLY_REPORT_STAGE_KEYS,
10
11
  NEXT_STAGE_CONTRACTS,
@@ -541,14 +542,26 @@ export function qaGateEvidence(report, gate) {
541
542
  };
542
543
  }
543
544
 
545
+ export function qaRecordedBuildFingerprint(report) {
546
+ return optionalString(report?.stages?.qa?.evidence?.qc_build_fingerprint)
547
+ || optionalString(report?.stages?.qa?.evidence?.source_build_fingerprint);
548
+ }
549
+
550
+ export function qaRecordedForCurrentBuild(report, buildFingerprint) {
551
+ const recorded = qaRecordedBuildFingerprint(report);
552
+ const current = optionalString(buildFingerprint);
553
+ return Boolean(recorded && current && recorded === current);
554
+ }
555
+
544
556
  // `qaCurrency` is QA's read-time input currency
545
557
  // (derived.input_currency.stages.qa): while QA is owed again or its inputs
546
558
  // cannot be confirmed, no QA gate pass counts.
547
559
  export function qaGatePassedForCurrentBuild(report, gate, { buildFingerprint, qaCurrency = null }) {
548
560
  if (qaCurrency === "owed" || qaCurrency === "unknown") return false;
549
561
  const outcome = qaGateEvidence(report, gate);
550
- const current = optionalString(buildFingerprint);
551
- return Boolean(outcome && outcome.status === QA_STATUS.PASS && current && outcome.source_build_fingerprint === current);
562
+ return Boolean(outcome && outcome.status === QA_STATUS.PASS
563
+ && outcome.source_build_fingerprint === optionalString(buildFingerprint)
564
+ && qaRecordedForCurrentBuild(report, buildFingerprint));
552
565
  }
553
566
 
554
567
  /**
@@ -675,7 +688,7 @@ function commitAssemblyReportUnderLock(workspace, mutate, {
675
688
  const finish = () => {
676
689
  if (hasRefresh) {
677
690
  const doctor = refreshDoctor(outcome);
678
- if (doctor !== null && doctor !== undefined) writeDoctorSidecar(doctorOutPath, doctor, { command: command.trim() });
691
+ if (doctor !== null && doctor !== undefined) writeDoctorSidecar(doctorOutPath, doctor, { command: command.trim(), targetRepo });
679
692
  } else if (outcome.written) {
680
693
  markDoctorSidecarStale(targetRepo, { command: command.trim(), reason: staleReason.trim() });
681
694
  }
@@ -719,7 +732,8 @@ function commitAssemblyReportUnderLock(workspace, mutate, {
719
732
  // (every mutator in this repo returns a copy), so the restatement is in
720
733
  // place rather than a second deep clone. Stages are read at their effective
721
734
  // status against the inputs the workspace's packet names now.
722
- const next = applyDerivedAssemblyReportSummary(mutated, workspaceInputs(workspace));
735
+ const nextReport = applyDerivedAssemblyReportSummary(mutated, workspaceInputs(workspace));
736
+ const next = targetRepo ? portableArtifactPaths(nextReport, targetRepo, { artifactPath: reportPath }) : nextReport;
723
737
  if (stage && producerStageOutcomeUnchanged(report, next, stage)) {
724
738
  outcome.skipped = "unchanged";
725
739
  return finish();