@nextcommerce/campaigns-os 1.62.0 → 1.66.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 (74) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/campaign-spec/dist/rules/campaign-currencies.d.ts +1 -0
  3. package/campaign-spec/dist/rules/campaign-currencies.js +1 -1
  4. package/campaign-spec/dist/rules/index.d.ts +1 -0
  5. package/campaign-spec/dist/rules/index.js +3 -0
  6. package/campaign-spec/dist/rules/package-currency-prices.d.ts +29 -0
  7. package/campaign-spec/dist/rules/package-currency-prices.js +137 -0
  8. package/campaign-spec/dist/types.d.ts +16 -0
  9. package/compatibility.json +1 -1
  10. package/contracts/effects.v1.json +129 -1
  11. package/contracts/fixtures/runtime-recipe/accept/current.json +1 -0
  12. package/contracts/fixtures/runtime-recipe/accept/minimal.json +1 -0
  13. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  14. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +1 -0
  15. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +1 -0
  16. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +1 -0
  17. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +1 -0
  18. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +1 -0
  19. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +1 -0
  20. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +1 -0
  21. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +1 -0
  22. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +1 -0
  23. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +1 -0
  24. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +1 -0
  25. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +1 -0
  26. package/contracts/release-ledger.json +810 -0
  27. package/contracts/runtime-recipe.campaigns-os-node-v1.json +1 -0
  28. package/contracts/supported-surface.json +6 -6
  29. package/docs/build-packet.md +31 -5
  30. package/docs/campaigns-os-build-flow.md +38 -0
  31. package/docs/campaignspec-authoring-examples.md +31 -0
  32. package/docs/effects.md +8 -0
  33. package/docs/local-setup.md +1 -1
  34. package/docs/orientation-contract-reference.md +1 -1
  35. package/docs/qa-and-test-orders.md +44 -10
  36. package/docs/readback.md +8 -1
  37. package/docs/runtime-readiness.md +4 -3
  38. package/docs/skills-revision.md +10 -10
  39. package/docs/supported-surface.md +1 -1
  40. package/package.json +1 -1
  41. package/schemas/campaign-spec.v4.schema.json +15 -0
  42. package/schemas/campaign-spec.v5.schema.json +15 -0
  43. package/schemas/campaigns-os-qa-verdict.v0.schema.json +17 -2
  44. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  45. package/skills/campaign-readback-classification/SKILL.md +3 -3
  46. package/skills/campaign-run-evidence/SKILL.md +3 -3
  47. package/skills/contribution-intake/SKILL.md +3 -3
  48. package/skills/next-campaigns-build/SKILL.md +3 -3
  49. package/skills/next-campaigns-os/SKILL.md +3 -3
  50. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  51. package/skills/next-campaigns-polish/SKILL.md +3 -3
  52. package/skills/next-campaigns-qa/SKILL.md +4 -4
  53. package/skills/next-campaigns-sdk-bump/SKILL.md +3 -3
  54. package/skills.json +11 -11
  55. package/src/change-scope.mjs +25 -6
  56. package/src/cli.mjs +120 -7
  57. package/src/commercial-journey.mjs +3 -0
  58. package/src/commercial-parity.mjs +157 -7
  59. package/src/doctor/checks.mjs +28 -3
  60. package/src/doctor/ci.mjs +36 -1
  61. package/src/evidence-debt.mjs +9 -1
  62. package/src/input-currency.mjs +4 -2
  63. package/src/input-refresh.mjs +1 -1
  64. package/src/invocation.mjs +3 -2
  65. package/src/orchestration-stage-contract.mjs +3 -1
  66. package/src/polish-gate.mjs +10 -0
  67. package/src/qa-browser.mjs +46 -0
  68. package/src/qa-commercial-parity.mjs +173 -18
  69. package/src/qa-node.mjs +10 -5
  70. package/src/qa-verdict.mjs +3 -1
  71. package/src/readback.mjs +1 -0
  72. package/src/runtime-state-ignore.mjs +44 -19
  73. package/src/stage-ledger.mjs +35 -9
  74. package/src/stage-record.mjs +93 -14
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: campaign-run-evidence
3
- version: 1.0.48
3
+ version: 1.0.52
4
4
  description: Interpret existing Campaigns OS doctor, QA and proof-depth evidence without claiming more proof than the artifacts contain.
5
5
  ---
6
6
 
7
- Bundle revision: 1.62.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.62.0+skills.1`
7
+ Bundle revision: 1.66.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.66.0+skills.1`
9
9
  from the campaign's Page Kit folder, where it runs the project's pinned copy and
10
10
  never installs one, at the start of each task. Start a fresh session if it
11
11
  reports `mismatch`: this text is already in your context and is never re-read
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: contribution-intake
3
- version: 1.0.48
3
+ version: 1.0.52
4
4
  description: Turn a suggestion about the agent surface into a classified, evidence-checked proposal and, only with attended approval, one issue on this repository's tracker.
5
5
  ---
6
6
 
7
- Bundle revision: 1.62.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.62.0+skills.1`
7
+ Bundle revision: 1.66.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.66.0+skills.1`
9
9
  from the campaign's Page Kit folder, where it runs the project's pinned copy and
10
10
  never installs one, at the start of each task. Start a fresh session if it
11
11
  reports `mismatch`: this text is already in your context and is never re-read
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: next-campaigns-build
3
- version: 1.0.53
3
+ version: 1.0.57
4
4
  description: Assemble a NEXT campaign from a doctor-cleared Build Packet, CampaignSpec/API values, prepared HTML/assets, page-kit, and starter-template contracts.
5
5
  ---
6
6
 
7
- Bundle revision: 1.62.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.62.0+skills.1`
7
+ Bundle revision: 1.66.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.66.0+skills.1`
9
9
  from the campaign's Page Kit folder, where it runs the project's pinned copy and
10
10
  never installs one, at the start of each task. Start a fresh session if it
11
11
  reports `mismatch`: this text is already in your context and is never re-read
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: next-campaigns-os
3
- version: 1.0.68
3
+ version: 1.0.72
4
4
  description: Coordinate Campaigns OS lifecycle workflows from CampaignSpec, Build Packet, starter-template contracts, stage reports, deploy evidence, and QA proof depth.
5
5
  ---
6
6
 
7
- Bundle revision: 1.62.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.62.0+skills.1`
7
+ Bundle revision: 1.66.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.66.0+skills.1`
9
9
  from the campaign's Page Kit folder, where it runs the project's pinned copy and
10
10
  never installs one, at the start of each task. Start a fresh session if it
11
11
  reports `mismatch`: this text is already in your context and is never re-read
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: next-campaigns-os-setup
3
- version: 2.0.51
3
+ version: 2.0.55
4
4
  description: Bootstrap or prepare a target page-kit campaign repo from a doctor-cleared Campaigns OS Build Packet before full build wiring. Formerly installed as next-campaigns-setup; renamed 2026-08 to stop colliding with the published NextCommerceCo/skills scaffolder of that name.
5
5
  ---
6
6
 
7
- Bundle revision: 1.62.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.62.0+skills.1`
7
+ Bundle revision: 1.66.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.66.0+skills.1`
9
9
  from the campaign's Page Kit folder, where it runs the project's pinned copy and
10
10
  never installs one, at the start of each task. Start a fresh session if it
11
11
  reports `mismatch`: this text is already in your context and is never re-read
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: next-campaigns-polish
3
- version: 1.1.53
3
+ version: 1.1.57
4
4
  description: Run the visual/runtime polish pass after build and before QA for a Campaigns OS campaign.
5
5
  ---
6
6
 
7
- Bundle revision: 1.62.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.62.0+skills.1`
7
+ Bundle revision: 1.66.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.66.0+skills.1`
9
9
  from the campaign's Page Kit folder, where it runs the project's pinned copy and
10
10
  never installs one, at the start of each task. Start a fresh session if it
11
11
  reports `mismatch`: this text is already in your context and is never re-read
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: next-campaigns-qa
3
- version: 1.3.52
3
+ version: 1.3.56
4
4
  description: Run spec-aware QA from a saved Map or local-spec Build Packet and tested campaign URL after build, polish, and deploy/local evidence exist, including Playwright typed-card test-order proof.
5
5
  ---
6
6
 
7
- Bundle revision: 1.62.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.62.0+skills.1`
7
+ Bundle revision: 1.66.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.66.0+skills.1`
9
9
  from the campaign's Page Kit folder, where it runs the project's pinned copy and
10
10
  never installs one, at the start of each task. Start a fresh session if it
11
11
  reports `mismatch`: this text is already in your context and is never re-read
@@ -75,7 +75,7 @@ Inputs:
75
75
  Rules:
76
76
 
77
77
  - Use the public Node/npm `campaigns-os qa` commands for campaign QA runs.
78
- - Canonical `qa run` automatically runs commercial parity for enabled pages with package rows when their source evidence is available. It reuses each page's read-only source fetch and the existing read-only `POST /api/price-preview`; neither creates carts, orders, or transactions. The lane is warn-first: proven price, recurring-cadence, and voucher mismatches become warn-severity `pricing` assertions, with compact coverage and findings at top-level `verdict.commercial`. Omitted or otherwise unprovable claims emit no mismatch assertion and do not change an otherwise-ready disposition; incomplete coverage remains visible in `verdict.commercial`.
78
+ - Canonical `qa run` automatically runs commercial parity for enabled pages with package rows when their source evidence is available. It reuses each page's read-only source fetch and the existing read-only `POST /api/price-preview`; neither creates carts, orders, or transactions. The lane is warn-first: proven price, recurring-cadence, and voucher mismatches become warn-severity `pricing` assertions, with compact coverage and findings at top-level `verdict.commercial`. Unprovable claims never read as a mismatch. Incomplete coverage (for example a spec with no `campaign.campaigns_api_key`, so no scenario ran) keeps the run at `ready_with_exceptions` at least, never `blocked`, with a warn row `commercial-parity:incomplete:<code>` in `exceptions[]` that names the cause and the fix. Report it to the operator, and do not call a run whose commercial status is `incomplete` fully proven.
79
79
  - Use `qa parity` for migration cells that carry a parity fixture; select the fixture scenario and drive that offer through the candidate funnel.
80
80
  - Parity capture blocking proof is the voucher-adjusted persisted line from typed-card order readback. Browser totals and client state do not replace the persisted-line voucher guard.
81
81
  - Read client purchase values per event. A whole-cart `dl_purchase` must not mask or supply an offer-level upsell purchase expectation.
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: next-campaigns-sdk-bump
3
- version: 1.0.8
3
+ version: 1.0.12
4
4
  description: Bump a live campaign's Campaign Cart SDK from one 0.4.x release to a later 0.4.x on a page-kit or static repo. Use for any request to bump, upgrade or move a campaign to SDK 0.4.x, including when the repository has its own BUMP.md. Find every pin, check the release diff and storage against the campaign's own code, prove on a preview with approved test orders, and report what is not proven.
5
5
  ---
6
6
 
7
- Bundle revision: 1.62.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.62.0+skills.1`
7
+ Bundle revision: 1.66.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.66.0+skills.1`
9
9
  from the campaign's Page Kit folder, where it runs the project's pinned copy and
10
10
  never installs one, at the start of each task. Start a fresh session if it
11
11
  reports `mismatch`: this text is already in your context and is never re-read
package/skills.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "campaigns-os-skills",
3
3
  "description": "Skills bundled with Campaigns OS. `skills.sh` installs these into the shared agent skill directories (~/.claude/skills, ~/.codex/skills), so each one is a versioned package: bump the version whenever a package changes. `bundle_revision` identifies the bundle as a whole — `<package version>+skills.<n>`, where the prefix is this package's version and `<n>` counts the skill-text revisions published against it. It advances whenever ANY bundled skill changes, every SKILL.md states it on its first body line, and `campaigns-os tooling status --skills-revision <value>` compares the value an agent read from a skill against the bundle on disk. See docs/skills-revision.md.",
4
- "bundle_revision": "1.62.0+skills.1",
4
+ "bundle_revision": "1.66.0+skills.1",
5
5
  "homepage": "https://github.com/NextCommerceCo/campaigns-os",
6
6
  "retired_skills": [
7
7
  {
@@ -16,7 +16,7 @@
16
16
  {
17
17
  "id": "next-campaigns-os",
18
18
  "name": "Campaigns OS Lifecycle",
19
- "version": "1.0.68",
19
+ "version": "1.0.72",
20
20
  "path": "skills/next-campaigns-os/SKILL.md",
21
21
  "domain": "campaigns",
22
22
  "description": "Coordinate Campaigns OS lifecycle workflows from CampaignSpec, Build Packet, starter-template contracts, stage reports, deploy evidence, and QA proof depth."
@@ -24,7 +24,7 @@
24
24
  {
25
25
  "id": "next-campaigns-os-setup",
26
26
  "name": "Campaigns OS Setup",
27
- "version": "2.0.51",
27
+ "version": "2.0.55",
28
28
  "path": "skills/next-campaigns-os-setup/SKILL.md",
29
29
  "domain": "campaigns",
30
30
  "description": "Bootstrap or prepare a target page-kit campaign repo from a doctor-cleared Campaigns OS Build Packet before full build wiring. Formerly next-campaigns-setup; renamed to release that name to the published NextCommerceCo/skills scaffolder."
@@ -32,7 +32,7 @@
32
32
  {
33
33
  "id": "next-campaigns-build",
34
34
  "name": "Campaign Build",
35
- "version": "1.0.53",
35
+ "version": "1.0.57",
36
36
  "path": "skills/next-campaigns-build/SKILL.md",
37
37
  "domain": "campaigns",
38
38
  "description": "Assemble a NEXT campaign from a doctor-cleared Build Packet, CampaignSpec/API values, prepared HTML/assets, page-kit, and starter-template contracts."
@@ -40,7 +40,7 @@
40
40
  {
41
41
  "id": "next-campaigns-polish",
42
42
  "name": "Campaign Polish",
43
- "version": "1.1.53",
43
+ "version": "1.1.57",
44
44
  "path": "skills/next-campaigns-polish/SKILL.md",
45
45
  "domain": "campaigns",
46
46
  "description": "Run the visual/runtime polish pass after build and before QA for a Campaigns OS campaign."
@@ -48,7 +48,7 @@
48
48
  {
49
49
  "id": "next-campaigns-qa",
50
50
  "name": "Campaign QA",
51
- "version": "1.3.52",
51
+ "version": "1.3.56",
52
52
  "path": "skills/next-campaigns-qa/SKILL.md",
53
53
  "domain": "campaigns",
54
54
  "description": "Run spec-aware QA from a saved Map or local-spec Build Packet and tested campaign URL after build, polish, and deploy/local evidence exist, including Playwright typed-card test-order proof."
@@ -56,7 +56,7 @@
56
56
  {
57
57
  "id": "campaign-lifecycle-orientation",
58
58
  "name": "Campaign Lifecycle Orientation",
59
- "version": "1.0.48",
59
+ "version": "1.0.52",
60
60
  "path": "skills/campaign-lifecycle-orientation/SKILL.md",
61
61
  "domain": "campaigns",
62
62
  "description": "Orient a reader to the Campaigns OS lifecycle artifacts a run has already emitted, without advancing their state: the shared vocabulary, doctor as a gate, the stage record, and the store-theme/Page Kit two-worlds trap."
@@ -64,7 +64,7 @@
64
64
  {
65
65
  "id": "campaign-run-evidence",
66
66
  "name": "Campaign Run Evidence",
67
- "version": "1.0.48",
67
+ "version": "1.0.52",
68
68
  "path": "skills/campaign-run-evidence/SKILL.md",
69
69
  "domain": "campaigns",
70
70
  "description": "Interpret existing doctor, QA verdict and proof-depth evidence without claiming more proof than the artifacts contain, and keep funnel proof apart from merchant launch readiness."
@@ -72,7 +72,7 @@
72
72
  {
73
73
  "id": "campaign-readback-classification",
74
74
  "name": "Campaign Readback Classification",
75
- "version": "1.0.48",
75
+ "version": "1.0.52",
76
76
  "path": "skills/campaign-readback-classification/SKILL.md",
77
77
  "domain": "campaigns",
78
78
  "description": "Classify a selected campaign from the campaigns-os-readback/v2 fields (artifacts, staleness.stale_keys, clean, doctor, divergences, skip_cascades) and write a read-only handoff without turning diagnosis into permission."
@@ -80,7 +80,7 @@
80
80
  {
81
81
  "id": "contribution-intake",
82
82
  "name": "Contribution Intake",
83
- "version": "1.0.48",
83
+ "version": "1.0.52",
84
84
  "path": "skills/contribution-intake/SKILL.md",
85
85
  "domain": "campaigns",
86
86
  "description": "Turn a suggestion about the agent surface into a classified, evidence-checked and redacted proposal, filed on this repository tracker only with attended approval."
@@ -88,7 +88,7 @@
88
88
  {
89
89
  "id": "next-campaigns-sdk-bump",
90
90
  "name": "Campaign SDK Bump",
91
- "version": "1.0.8",
91
+ "version": "1.0.12",
92
92
  "path": "skills/next-campaigns-sdk-bump/SKILL.md",
93
93
  "domain": "campaigns",
94
94
  "description": "Bump a live campaign from one Campaign Cart 0.4.x release to a later 0.4.x on a page-kit or static repo: find every pin, check the release diff and storage against the campaign code, prove on a preview with approved test orders, and report what is not proven."
@@ -149,11 +149,12 @@ export function classifyDrift({ changes, baseline, outputRoot, targetRepo, publi
149
149
  if (changes?.status !== "known") return { kind: "unknown", reasons: [] };
150
150
  if (!changes.changed_pages.length && !changes.unmapped_files.length) return { kind: "none", reasons: [] };
151
151
  const reasons = [];
152
- for (const path of changes.unmapped_files) reasons.push({ code: "unmapped_file", path });
153
152
  const signature = recordedSignature(baseline);
153
+ // First, so the list's cap never drops it: `record baseline` reads it.
154
154
  if (!signature) reasons.push({ code: "commerce_signature_unavailable" });
155
- else if (signature.campaign_config.startsWith("unavailable:")
156
- || campaignConfigFingerprint(targetRepo, publicRouteSlug) !== signature.campaign_config) {
155
+ for (const path of changes.unmapped_files) reasons.push({ code: "unmapped_file", path });
156
+ if (signature && (signature.campaign_config.startsWith("unavailable:")
157
+ || campaignConfigFingerprint(targetRepo, publicRouteSlug) !== signature.campaign_config)) {
157
158
  reasons.push({ code: signature.campaign_config.startsWith("unavailable:") ? "campaign_config_unavailable" : "campaign_config_changed" });
158
159
  }
159
160
  for (const page of changes.changed_pages) {
@@ -177,6 +178,13 @@ export function classifyDrift({ changes, baseline, outputRoot, targetRepo, publi
177
178
  return { kind: reasons.length ? "commerce_affecting" : "page_only", reasons: capped(reasons) };
178
179
  }
179
180
 
181
+ // True when a kept rebuild was read against a build recorded before markup
182
+ // hashes existed (pre-1.62), the one kind of record `record baseline` is for.
183
+ export function rebuiltFromBuildWithoutSignature(assembly) {
184
+ return Array.isArray(assembly?.build_changes) && assembly.build_changes.some((entry) => Array.isArray(entry?.reasons)
185
+ && entry.reasons.some((reason) => reason?.code === "commerce_signature_unavailable" && !reason.path));
186
+ }
187
+
180
188
  /**
181
189
  * One build_changes entry for a rebuild from `from` to `to`, from the debt
182
190
  * assessment of the new output against the previous record.
@@ -230,8 +238,12 @@ export function worseChangeKind(a, b) {
230
238
  * The change a stage that reviewed `anchor` owes for: the recorded rebuilds
231
239
  * from `anchor` to the recorded build, then the drift from the recorded build
232
240
  * to the output on disk. Returns `{ kind, since_build_fingerprint,
233
- * changed_pages, unmapped_files, reasons }`; `kind` is unknown when the
234
- * history does not reach `anchor` or the drift cannot be read.
241
+ * changed_pages, unmapped_files, reasons, commerce_rebuild }`; `kind` is
242
+ * unknown when the history does not reach `anchor` or the drift cannot be read.
243
+ * `commerce_rebuild`, when there is one, is the latest recorded rebuild since
244
+ * `anchor` that was not page-only, with the kind of the latest change
245
+ * (`latest_change_kind`), so a page-only edit on top of it can say why it still
246
+ * owes the full stages.
235
247
  */
236
248
  export function changeSince(anchor, { assembly, drift, driftClassification }) {
237
249
  const recorded = typeof assembly?.build_fingerprint === "string" ? assembly.build_fingerprint : null;
@@ -257,9 +269,16 @@ export function changeSince(anchor, { assembly, drift, driftClassification }) {
257
269
  for (const path of part.unmapped_files || []) unmapped.add(path);
258
270
  result.reasons.push(...(reasons || []));
259
271
  };
260
- for (const segment of segments) add(segment, segment.kind, segment.reasons);
272
+ for (const segment of segments) {
273
+ add(segment, segment.kind, segment.reasons);
274
+ if (!["none", "page_only"].includes(segment.kind)) result.commerce_rebuild = { recorded_at: segment.recorded_at ?? null, build_fingerprint: segment.build_fingerprint, kind: segment.kind };
275
+ }
261
276
  if (drift?.status !== "known") kind = "unknown";
262
277
  else add(drift, driftClassification?.kind || "unknown", driftClassification?.reasons);
278
+ if (result.commerce_rebuild) {
279
+ const driftKind = driftClassification?.kind || "unknown";
280
+ result.commerce_rebuild.latest_change_kind = driftKind === "none" ? segments.at(-1).kind : driftKind;
281
+ }
263
282
  return {
264
283
  ...result,
265
284
  kind,
package/src/cli.mjs CHANGED
@@ -32,7 +32,7 @@ import { routeMaskedSpecMaterialHash, specMaterialHash, specHashesMatch } from "
32
32
  // The same predicate stage-ledger.mjs judges a mutator's result with, imported
33
33
  // rather than re-stated so the waive preview and the commit agree by identity.
34
34
  import { isPlainObject } from "./repo-scan.mjs";
35
- import { applyDerivedAssemblyReportSummary, campaignHandedOff, commitAssemblyReport, QA_GATE_PLACEHOLDER_TEXT_RESIDUE, recordProducerStageOutcome } from "./stage-ledger.mjs";
35
+ import { applyDerivedAssemblyReportSummary, campaignHandedOff, commitAssemblyReport, QA_GATE_PLACEHOLDER_TEXT_RESIDUE, recordProducerStageOutcome, qaRestsOnBaseline } from "./stage-ledger.mjs";
36
36
  import { SESSION_ENDING_DISPOSITIONS, summarizePlaceholderTextGate, summarizePurchaseProof } from "./qa-verdict.mjs";
37
37
  import { assessRunRecordCloseout, identityMatches, latestMatchingRunRecord, reasonIsRemitRecovery } from "./run-record-closeout.mjs";
38
38
  import {
@@ -190,7 +190,7 @@ import {
190
190
  createCampaignBuildBriefArtifact,
191
191
  inferBuildBriefPath,
192
192
  } from "./build-brief.mjs";
193
- import { briefIntakeBindings, currentPacketInputs, inputRefreshCommands, inputStamps, stageWriteInputs } from "./input-currency.mjs";
193
+ import { briefIntakeBindings, currentPacketInputs, currentSpecMaterial, inputRefreshCommands, inputStamps, specMaterialStamp, stageWriteInputs } from "./input-currency.mjs";
194
194
  import { prepareBuildStageStatus, priorBriefInputsForIntake } from "./input-refresh.mjs";
195
195
  import {
196
196
  DESIGN_SOURCE_PACKAGE_REL_PATH,
@@ -347,6 +347,7 @@ Usage:
347
347
  campaigns-os checkpoint waive --packet <campaign-runtime.build.json> --gate <checkpoint-id> [--page <page_id>] --reason "<why>" --waived-by "<named human>" [--expires-at <ISO>] [--review-condition "<trigger>"] [--report <json>] [--dry-run] [--json] # one bound is required; registered gates: page_kit.store_profile, page_kit.sdk_version, polish.hidden_eager_media, built_output.upsell_selector_scope, source_html.producer_provenance (per page: --page <page_id> is required, for a Figma-typed page whose approved source is hand-written HTML). --dry-run runs every check (named human, bounds, registered and waivable gate) and prints the waiver it would write, without touching the report
348
348
  campaigns-os checkpoint accept --packet <campaign-runtime.build.json> --result <result_id>@<fingerprint12> [--result ...] --reason "<the operator's reason>" --accepted-by "<named human>" [--expires-at <ISO>] [--review-condition "<trigger>"] [--report <json>] [--dry-run] [--json] # record the operator's accept of a measured QC warning, as the QC handoff of next prints it; changes no readiness and lapses when the measured state or the build changes. --dry-run runs every check and prints the accepts it would record, without touching the report
349
349
  campaigns-os page-kit sync --packet <campaign-runtime.build.json> [--dry-run] [--json] # write the CampaignSpec's Store Profile fields (campaign.store_*) and SDK pin (global_config.sdk_version, runtime.sdk_version alias) into the target's _data/campaigns.json entry for the packet's route, printing a field-by-field diff; the recovery for a doctor blocked on page_kit.store_profile / page_kit.sdk_version after a fresh scaffold. Writes only those ten fields, only from usable spec values (a bad pin, a non-http URL, a non-tel: phone URI or the demo value itself is reported as not synced, status PARTIAL); exit 2 when the entry or the spec is missing, or the spec identifies another campaign.
350
+ campaigns-os spec fetch --packet <campaign-runtime.build.json> [--proxy-base <url>] [--json] # restore a saved-Map packet's CampaignSpec to spec.local_path on a checkout that does not have it, then commit it. Sends one GET of {proxy-base}/api/spec/<map-id>. A copy already on disk is never replaced and nothing is sent, because it can hold spec derive edits the Map does not. Takes fetched-specs/ out of an older managed .gitignore block so the file can be committed. Reports whether the content matches the records (bound: matches, differs or unknown); differs means doctor will show build, Polish and QA owed. Exit 2 when the packet is not a saved-Map packet, the Map returns another campaign's spec, or the fetch fails.
350
351
  campaigns-os spec derive --packet <campaign-runtime.build.json> [--dry-run] [--json] [--report <json>] [--no-live-refs] [--from-store <subdomain> [--store-token-source env:<VAR>]] [--write-map] [--proxy-base <url>] # write the fields the target repo already states into the packet's local CampaignSpec (spec.local_path): the SDK pin from _data/campaigns.json[<route>].sdk_version (global_config.sdk_version, and the runtime.sdk_version alias when declared), each page's page_url from the page tree under src/<route>/ (filename or permalink), and the analytics ids the entry carries (gtm_id -> analytics.providers.gtm.containerId, fb_pixel_id -> analytics.providers.facebook.pixelId, and each starter-template vendor's id key, such as ga4_id or triplewhale_name -> analytics.vendors.<vendor>.id); prints a field-by-field before -> after diff and writes nothing else. On a handed-off campaign (deploy or QA completed, now or in the stage's history) the page tree also adds pages (a file with a page-kit page_type no spec page binds; id from the file name, appended to the funnel, routing from its next_url/decline_url/next_step frontmatter) and removes pages (one whose built file the packet named is gone), matched by page id; page order is reported as not derived. When the packet's built _site/<route>/ exists and a public Campaigns API key resolves, it also makes the one read-only GET of {proxy-base}/api/campaign doctor makes and refreshes the fields the spec mirrors from the Campaigns App (each page package's name, price, product, variant and availability; the offer records and each page's offer copies; live offers missing from offers[] are added) — a price is never written from the repo; --no-live-refs sends nothing and leaves those fields as they are; --proxy-base names the proxy. Otherwise repo-derived fields only; --from-store <subdomain> (the <store> of <store>.29next.store) also reads through campaigns-os login gateway credentials (--store-token-source env:<VAR> explicitly selects the warned break-glass Admin path; a token never goes on the command line) and writes the nine campaign.store_* Store Profile fields: store_name and store_url (primary domain) and store_phone/store_phone_tel from GET /store/, and store_terms/privacy/contact/returns/shipping as https://<primary domain>/<slug>/ from the one storefront page (GET /pages/) whose slug or title names each policy; an empty store field, no page or several never empties the spec's value. A field the repo or store cannot state (a scaffold's seeded pin, an unbound page, an empty or malformed id, an active page_kit.sdk_version waiver, an empty store field, an unbound policy page) is reported as not derived, status PARTIAL; exit 2 when the packet, the spec or the target entry is missing, the spec identifies another campaign, or the store cannot be read (credential missing, 401/403, no such store, unreachable). --write-map also records the derived pin into the saved Map's Build hints (Campaign Cart SDK version) through the proxy Worker (PUT /api/maps/<spec.map_id> under X-Campaign-Key, the packet's Campaigns API key, with the Map's spec_hash as the X-Spec-Hash precondition): written when the Map declares no pin or one behind the repo, unchanged when equal, refused (warning, exit 0) when the Map pin is ahead or cannot be ordered, failed (error, exit 2) when the key is missing or mismatched, the Map is gone, was saved in between, or the proxy refuses the body; the write is recorded on the Assembly Report evidence[] and in the result's map object. --proxy-base overrides the canonical proxy (https, or a loopback host over http); --dry-run reads the Map and reports would_write without a PUT.
351
352
  campaigns-os page-kit parity --packet <campaign-runtime.build.json> [--report <json>] [--json] # local proof mode (deploy.target local-serve): render the current source in development and production through the target's page-kit into temp dirs, assert the served _site/ is the current development render and that production differs from it only in environment-gated output (same page set, same route slugs, same Campaign Cart pin and next-api-key); records stages.assembly.evidence.local_proof.production_parity, which doctor reads as local_proof.production_parity. Exit 2 on a non-gated difference.
352
353
  campaigns-os polish capture --packet <campaign-runtime.build.json> --base-url <url> [--report <json>] [--headed] [--auth-cookie <cookie>] [--json]
@@ -354,6 +355,7 @@ Usage:
354
355
  campaigns-os record spec --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json]
355
356
  campaigns-os record setup --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json] # record setup complete once the campaign output directory exists: Build Context scaffold.required=false and stages.setup completed, validated against their schemas before either is written
356
357
  campaigns-os record build --packet <campaign-runtime.build.json> [--build-environment <development|production>] [--adapter-decision <key>=<value>[,<key>=<value>...]] [--context <json>] [--report <json>] [--dry-run] [--json] # after page-kit build: records scalar adapter decisions on the Assembly Report (all pairs in one flag; a repeated flag keeps only the last), stamps stages.assembly.build_fingerprint from doctor's derived.build_output_fingerprint.value (and the Design Source Package material fingerprint when present), and makes Polish required unless its evidence is bound to this output; --build-environment records stages.assembly.evidence.build_environment (local proof mode records development)
358
+ campaigns-os record baseline --packet <campaign-runtime.build.json> --accepted-by "<named person>" --reason "<why>" [--context <json>] [--report <json>] [--dry-run] [--json] # one time per funnel, for a handed-off funnel whose records predate markup hashes (before 1.62.0) and whose last QA completed: accept the recorded build as the starting point without testing it, so a later page-only change owes QA on that page alone. Polish and QA read baseline_accepted, never completed, and what they held moves to history. A commerce-affecting change still owes the full stages. Run record build first. Writes the Assembly Report.
357
359
  campaigns-os record polish --packet <campaign-runtime.build.json> --evidence <polish-evidence.json> [--context <json>] [--report <json>] [--dry-run] [--json] # after polish capture: stages.polish from the file's status (completed, completed_with_warnings, blocked with blockers, or skipped with skip_reason), evidence and optional repair_loop_defect, bound to doctor's current fingerprint; a completed status is refused, writing nothing, unless the polish gate doctor evaluates would pass. --dry-run runs every check and writes nothing
358
360
  campaigns-os record theme --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json] # after the brand layer is linked and build is recorded: report.theme becomes applied with load_order after-next-core, css_path, commerce_pages and per-page evidence, only when each built commerce page that loads next-core.css loads brand-theme.css (or checkout-brand.css) after it and at least one does; a page loading neither is left out as the design's own markup; refused, writing nothing, otherwise. --dry-run runs every check and writes nothing
359
361
  campaigns-os record deploy --packet <campaign-runtime.build.json> --base-url <served url> [--context <json>] [--report <json>] [--dry-run] [--json] # a served deploy after polish is recorded: GETs every built page under the URL (the campaign route root), then records deploy.preview_url on the packet and stages.deploy completed with the URL in outputs and the build fingerprint it probed (a later record build of other output makes deploy required again). A local-serve packet takes a loopback URL and each page must answer 2xx; any other deploy target takes an https URL (plain http only on a loopback host), each page must answer 200, and every script and stylesheet the built pages load from that origin must be served byte-identical (sha256) to the built output (HTML is not compared). Refused, writing nothing, when the URL breaks those rules or is not the route root, a page does not answer, a served asset differs or is missing, the build changed since it was recorded, or the theme gate is blocked. --dry-run runs every check, the requests included, and writes nothing
@@ -720,6 +722,22 @@ function hasDoneRecommendation(session) {
720
722
  return session?.last_recommendation?.stage === "done";
721
723
  }
722
724
 
725
+ // A page-scoped pass on a baseline paid for those pages and proved nothing
726
+ // else, so QA still reads baseline_accepted. Only a completed full QA ends the
727
+ // baseline: a blocked run proved nothing, so it leaves the marker alone.
728
+ function settleQaBaseline(onBaseline, report) {
729
+ const qa = report.stages?.qa;
730
+ if (!isObject(qa) || !String(qa.status).startsWith("completed")) return report;
731
+ if (!onBaseline) {
732
+ delete qa.baseline_accepted_at;
733
+ return report;
734
+ }
735
+ qa.status = "baseline_accepted";
736
+ delete qa.completed_at;
737
+ delete qa.purchase_proof;
738
+ return report;
739
+ }
740
+
723
741
  export function recordQaStageOutcome(args, result) {
724
742
  try {
725
743
  const packetArg = optionalString(args.packet);
@@ -757,7 +775,8 @@ export function recordQaStageOutcome(args, result) {
757
775
  throw new Error("The build was recorded again during this page-scoped QA run; report evidence was not changed. Re-run qa run --page against the recorded build.");
758
776
  }
759
777
  const evidence = qaStageGateEvidence(verdict, report, result);
760
- return recordProducerStageOutcome(report, {
778
+ const onBaseline = Boolean(pageScope && report.stages?.qa?.baseline_accepted_at);
779
+ return settleQaBaseline(onBaseline, recordProducerStageOutcome(report, {
761
780
  stage: "qa",
762
781
  disposition: verdict.disposition,
763
782
  timestamp: verdict.completed_at,
@@ -783,12 +802,14 @@ export function recordQaStageOutcome(args, result) {
783
802
  // path from a `--test-order off` diagnostic. A page-scoped run places
784
803
  // no orders and the change it pays touches no commerce, so the
785
804
  // purchase proof of the full run it extends is kept.
786
- proof: pageScope && isObject(report.stages?.qa?.purchase_proof)
805
+ // On a baseline there is none to keep, and a zero from this run would
806
+ // read as a purchase that failed, not one nobody tested.
807
+ proof: pageScope && (isObject(report.stages?.qa?.purchase_proof) || onBaseline)
787
808
  ? report.stages.qa.purchase_proof
788
809
  : summarizePurchaseProof({ verdict, proofPolicy: packet.qa?.proof_policy }),
789
810
  stamps: qaStamps,
790
811
  inputs: qaInputs,
791
- });
812
+ }));
792
813
  }, {
793
814
  stage: "qa",
794
815
  // The sidecar names this refresh as its producer (generated_by, #312).
@@ -1251,7 +1272,12 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
1251
1272
  // Inequality on purpose: src/invocation.test.mjs harvests top-level
1252
1273
  // command literals by an equality pattern a subcommand equality would
1253
1274
  // also match.
1254
- if (subcommand !== "derive") throw refused("Unknown spec subcommand. Use: campaigns-os spec derive --packet <campaign-runtime.build.json> [--dry-run] [--json].");
1275
+ if (subcommand !== "derive" && subcommand !== "fetch") throw refused("Unknown spec subcommand. Use: campaigns-os spec derive --packet <campaign-runtime.build.json> [--dry-run] [--json], or campaigns-os spec fetch --packet <campaign-runtime.build.json> [--proxy-base <url>] [--json].");
1276
+ if (subcommand !== "derive") {
1277
+ const fetched = await specFetchCommand(args);
1278
+ writeResult(fetched, args, fetched.ok ? 0 : 2);
1279
+ return;
1280
+ }
1255
1281
  const result = await specDeriveWithMapWriteback(args);
1256
1282
  if (args.json) console.log(JSON.stringify(result, null, 2));
1257
1283
  else for (const line of specDeriveWriteMapTextLines(result)) console.log(line);
@@ -3745,6 +3771,93 @@ function listPageKitPageFiles(outputDir, publicRouteSlug, { assetsHtmlIgnored =
3745
3771
  return files.sort((a, b) => a.path.localeCompare(b.path));
3746
3772
  }
3747
3773
 
3774
+ const SPEC_FETCH_FLAGS = ["_", "packet", "proxy-base", "json"];
3775
+
3776
+ // `spec fetch`: put a saved-Map packet's CampaignSpec back on a checkout that
3777
+ // lacks it. Only ever fills a gap: the copy on disk can hold `spec derive`
3778
+ // edits the Map does not, so it is never replaced. The packet and the records
3779
+ // are not touched either; a Map that has moved since they were made is
3780
+ // reported as `bound: "differs"` for doctor and `record spec` to deal with.
3781
+ export async function specFetchCommand(args, { fetchImpl = undefined } = {}) {
3782
+ const unknown = Object.keys(args).filter((key) => !SPEC_FETCH_FLAGS.includes(key));
3783
+ if (unknown.length) {
3784
+ throw refused(`Unknown flag${unknown.length > 1 ? "s" : ""} for spec fetch: ${unknown.map((key) => `--${key}`).join(", ")}. Known flags: --packet, --proxy-base, --json.`);
3785
+ }
3786
+ if (Object.hasOwn(args, "proxy-base") && !optionalString(args["proxy-base"])) throw refused("spec fetch: --proxy-base needs a URL (https, or a loopback host); nothing was written.");
3787
+ const packetPath = resolve(requireArg(args, "packet"));
3788
+ const result = { ok: false, status: "blocked", packet_path: packetPath, spec_path: null, map_id: null, bound: "unknown", gitignore: null, errors: [], warnings: [], ready: [] };
3789
+ const workspace = (() => {
3790
+ try {
3791
+ return resolveCampaignWorkspace(packetPath, { followContextPointer: true });
3792
+ } catch (error) {
3793
+ addIssue(result.errors, "spec.fetch.packet_unreadable", `Could not read the packet: ${singleLineDetail(error.message)}`);
3794
+ return null;
3795
+ }
3796
+ })();
3797
+ if (!workspace) return result;
3798
+ const mapId = optionalString(workspace.packet?.spec?.map_id);
3799
+ const localPath = optionalString(workspace.packet?.spec?.local_path);
3800
+ if (!mapId || !localPath || !workspace.targetRepo) {
3801
+ addIssue(result.errors, "spec.fetch.not_saved_map", "This packet does not name a saved Map and a spec.local_path, so there is nothing to fetch. A local-spec packet's CampaignSpec is restored from wherever it is kept.");
3802
+ return result;
3803
+ }
3804
+ result.map_id = mapId;
3805
+ const cachePath = join(workspace.targetRepo, ".campaign-runtime", "fetched-specs", `${sanitizeMapIdForFilename(mapId)}.json`);
3806
+ const specPath = resolveFromFile(packetPath, localPath);
3807
+ result.spec_path = specPath;
3808
+ if (specPath !== cachePath) {
3809
+ addIssue(result.errors, "spec.fetch.not_saved_map", `spec.local_path is ${localPath}, not this Map's copy (.campaign-runtime/fetched-specs/${basename(cachePath)}), so spec fetch cannot restore it. Restore that file from where it is kept.`);
3810
+ return result;
3811
+ }
3812
+ let boundMaterial;
3813
+ let specMaterial;
3814
+ try {
3815
+ const report = existsSync(workspace.reportPath) ? readJson(workspace.reportPath) : null;
3816
+ boundMaterial = specMaterialStamp(report?.identity?.spec_material_hash);
3817
+ if (existsSync(specPath)) {
3818
+ specMaterial = currentSpecMaterial(readJson(specPath));
3819
+ result.status = "present";
3820
+ result.ready.push(`${localPath} is already here; it was left as it is and nothing was fetched.`);
3821
+ } else {
3822
+ assertFetchedSpecCacheWritable(workspace.targetRepo, cachePath);
3823
+ const fetched = await fetchSpecByMapId(mapId, { proxyBase: optionalString(args["proxy-base"]) || DEFAULT_PROXY_BASE, fetchImpl });
3824
+ // This file is what the records and spec derive read, so it must be this campaign's.
3825
+ const fetchedMapId = optionalString(fetched?.spec_identity?.map_id) || optionalString(fetched?.map_id);
3826
+ const fetchedSlug = specPublicRouteSlug(fetched);
3827
+ const packetSlug = normalizePublicRouteSlug(workspace.packet?.campaign?.public_route_slug);
3828
+ if ((fetchedMapId && fetchedMapId !== mapId) || (fetchedSlug && packetSlug && fetchedSlug !== packetSlug)) {
3829
+ addIssue(result.errors, "spec.fetch.identity_mismatch", `The Map returned a spec for ${singleLineField(fetchedMapId || mapId)} on route "${singleLineField(fetchedSlug || "")}", but this packet is ${singleLineField(mapId)} on route "${singleLineField(packetSlug || "")}"; nothing was written.`);
3830
+ return result;
3831
+ }
3832
+ // The same reduction intake made, so the copy reads as the records' did.
3833
+ const { spec } = stripHostPrefixedRoutes(fetched);
3834
+ // Again at the write: the links can change during the request.
3835
+ assertFetchedSpecCacheWritable(workspace.targetRepo, cachePath);
3836
+ mkdirSync(dirname(cachePath), { recursive: true });
3837
+ replaceFetchedSpec(cachePath, `${JSON.stringify(spec, null, 2)}\n`);
3838
+ specMaterial = currentSpecMaterial(spec);
3839
+ result.status = "restored";
3840
+ result.ready.push(`Restored ${localPath} from the saved Map. Commit it so the next checkout has it.`);
3841
+ }
3842
+ } catch (error) {
3843
+ addIssue(result.errors, "spec.fetch.failed", singleLineDetail(error.message));
3844
+ return result;
3845
+ }
3846
+ result.ok = true;
3847
+ const current = specMaterialStamp(specMaterial);
3848
+ result.bound = boundMaterial && current ? (boundMaterial === current ? "matches" : "differs") : "unknown";
3849
+ if (result.bound === "differs") {
3850
+ addIssue(result.warnings, "spec.fetch.differs_from_records", "This spec differs from the one the campaign's records were made against: the Map changed since then, or the earlier copy held `spec derive` edits the Map does not. Doctor will show build, Polish and QA owed until `record spec` binds it.");
3851
+ }
3852
+ if (result.bound === "matches") result.ready.push("It matches the spec the campaign's records were made against.");
3853
+ if (result.bound === "unknown") result.ready.push("The report records no spec to compare it with.");
3854
+ // The file has to be committable, and older repositories' managed block says otherwise.
3855
+ result.gitignore = ensureRuntimeStateIgnored(workspace.targetRepo);
3856
+ if (result.gitignore.removed_entries?.length) result.ready.push("Took fetched-specs/ out of the managed .gitignore block.");
3857
+ if (result.gitignore.action === "skipped") addIssue(result.warnings, "spec.fetch.gitignore_not_updated", `Could not update .gitignore (${singleLineDetail(result.gitignore.reason)}). If it still lists .campaign-runtime/fetched-specs/, remove that line so this file can be committed.`);
3858
+ return result;
3859
+ }
3860
+
3748
3861
  export function specDeriveCommand(args, { store: storeRead = null, liveCampaign = undefined } = {}) {
3749
3862
  // A command that rewrites the spec rejects flags it does not know: a
3750
3863
  // mistyped --dryrun must not fall through to a real write. --report names
@@ -4643,7 +4756,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4643
4756
  errors,
4644
4757
  warnings,
4645
4758
  ready,
4646
- prompt: `Pipeline complete. All stages in the assembly report are in a terminal status. To repeat build work, do the work and run \`${cmd("record")} build --packet <path>\`; this makes downstream stages owed as needed. \`${cmd("record")} deploy\` records the served deploy, a loopback URL for a local-serve target or an https URL for a hosted one. Run \`${cmd("qa")} run\` for QA. Then call \`${cmd("next")}\` again. If a run session is active, finish it with \`${cmd("run")} end\` so the Run Record is assembled and the session closes.${report?.stages?.qa?.status === "completed_with_warnings" ? " QA passed with exceptions; report them to the operator without clearing or waiving them or changing markup just to make them pass." : ""}`,
4759
+ prompt: `Pipeline complete. All stages in the assembly report are in a terminal status. To repeat build work, do the work and run \`${cmd("record")} build --packet <path>\`; this makes downstream stages owed as needed. \`${cmd("record")} deploy\` records the served deploy, a loopback URL for a local-serve target or an https URL for a hosted one. Run \`${cmd("qa")} run\` for QA. Then call \`${cmd("next")}\` again. If a run session is active, finish it with \`${cmd("run")} end\` so the Run Record is assembled and the session closes.${report?.stages?.qa?.status === "completed_with_warnings" ? " QA passed with exceptions; report them to the operator without clearing or waiving them or changing markup just to make them pass." : ""}${qaRestsOnBaseline(report) ? ` QA rests on a baseline ${report.commerce_baseline.accepted_by} accepted, not a test; say so when you report this campaign.` : ""}${warnings.some((issue) => issue.code === "built_output.absent") ? " The built output is not on this checkout, so these records were not compared with it; build before treating the campaign as current." : ""}`,
4647
4760
  });
4648
4761
  }
4649
4762
  stage = picked.stage;
@@ -632,6 +632,9 @@ function normalizeLine(echo, state, packages) {
632
632
  })),
633
633
  final: moneyFact(line.total, state),
634
634
  savings: moneyFact(line.total_discount, state),
635
+ // What one unit costs after offers: the figure a printed per-unit price is
636
+ // held against. A response without it leaves that claim unresolved.
637
+ ...(present(line.unit_price) ? { unit_price: moneyFact(line.unit_price, state) } : {}),
635
638
  };
636
639
  }
637
640