@nextcommerce/campaigns-os 1.63.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 (50) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/campaign-spec/dist/types.d.ts +1 -1
  3. package/compatibility.json +1 -1
  4. package/contracts/effects.v1.json +129 -1
  5. package/contracts/release-ledger.json +549 -0
  6. package/contracts/supported-surface.json +3 -3
  7. package/docs/build-packet.md +31 -5
  8. package/docs/campaigns-os-build-flow.md +38 -0
  9. package/docs/campaignspec-authoring-examples.md +3 -2
  10. package/docs/effects.md +8 -0
  11. package/docs/local-setup.md +1 -1
  12. package/docs/orientation-contract-reference.md +1 -1
  13. package/docs/qa-and-test-orders.md +44 -10
  14. package/docs/readback.md +8 -1
  15. package/docs/runtime-readiness.md +1 -1
  16. package/docs/skills-revision.md +10 -10
  17. package/docs/supported-surface.md +1 -1
  18. package/package.json +1 -1
  19. package/schemas/campaigns-os-qa-verdict.v0.schema.json +17 -2
  20. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  21. package/skills/campaign-readback-classification/SKILL.md +3 -3
  22. package/skills/campaign-run-evidence/SKILL.md +3 -3
  23. package/skills/contribution-intake/SKILL.md +3 -3
  24. package/skills/next-campaigns-build/SKILL.md +3 -3
  25. package/skills/next-campaigns-os/SKILL.md +3 -3
  26. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  27. package/skills/next-campaigns-polish/SKILL.md +3 -3
  28. package/skills/next-campaigns-qa/SKILL.md +4 -4
  29. package/skills/next-campaigns-sdk-bump/SKILL.md +3 -3
  30. package/skills.json +11 -11
  31. package/src/change-scope.mjs +25 -6
  32. package/src/cli.mjs +120 -7
  33. package/src/commercial-journey.mjs +3 -0
  34. package/src/commercial-parity.mjs +157 -7
  35. package/src/doctor/checks.mjs +28 -3
  36. package/src/doctor/ci.mjs +36 -1
  37. package/src/evidence-debt.mjs +9 -1
  38. package/src/input-currency.mjs +4 -2
  39. package/src/input-refresh.mjs +1 -1
  40. package/src/invocation.mjs +3 -2
  41. package/src/orchestration-stage-contract.mjs +3 -1
  42. package/src/polish-gate.mjs +10 -0
  43. package/src/qa-browser.mjs +46 -0
  44. package/src/qa-commercial-parity.mjs +173 -18
  45. package/src/qa-node.mjs +10 -5
  46. package/src/qa-verdict.mjs +3 -1
  47. package/src/readback.mjs +1 -0
  48. package/src/runtime-state-ignore.mjs +44 -19
  49. package/src/stage-ledger.mjs +35 -9
  50. package/src/stage-record.mjs +93 -14
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.63.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.69",
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.52",
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.54",
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.54",
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.53",
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.49",
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.49",
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.49",
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.49",
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.9",
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
 
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * Dependency-free extraction and Exact-only commercial parity comparison.
3
3
  *
4
- * Source HTML is intentional: Campaign Cart replaces authored placeholders
5
- * after readiness, which can hide an authored mismatch in the rendered DOM.
4
+ * Source HTML is intentional for bundle prices: Campaign Cart replaces authored
5
+ * placeholders after readiness, which can hide an authored mismatch in the
6
+ * rendered DOM. package.price is the other way round: its authored text is a
7
+ * template placeholder no shopper is meant to read, so only its rendered value
8
+ * is compared and the source merely says the claim exists.
6
9
  */
7
10
 
8
11
  import {
@@ -416,6 +419,48 @@ function governedPriceBinding(value) {
416
419
  return typeof value === "string" && /^bundle\.[^.]+\.price$/.test(value.trim());
417
420
  }
418
421
 
422
+ // The SDK's own package.price paths: an id in the path, or none and the nearest
423
+ // package id around the node. No other package.* field is a price claim here.
424
+ const PACKAGE_PRICE_BINDING = /^package\.(?:([1-9]\d*)\.)?price$/;
425
+ const PACKAGE_CONTEXT_ATTRIBUTES = Object.freeze(["data-next-package-id", "data-next-package", "data-package-id"]);
426
+
427
+ export const PACKAGE_PRICE_NOT_RENDERED = "package_price_not_rendered";
428
+ export const PACKAGE_PRICE_NOT_FILLED = "package_price_not_filled";
429
+ export const PACKAGE_PRICE_UNRESOLVED = "package_price_unresolved";
430
+
431
+ function packageContextId(node) {
432
+ for (let current = node; current; current = current.parent) {
433
+ for (const attribute of PACKAGE_CONTEXT_ATTRIBUTES) {
434
+ const id = positiveInteger(current.attrs?.[attribute]);
435
+ if (id !== null) return id;
436
+ }
437
+ }
438
+ return null;
439
+ }
440
+
441
+ // Lists the claims, deliberately without their text: what the source holds is
442
+ // the template's placeholder, and comparing it would report every starter page.
443
+ function extractPackagePrices(nodeIndex, consumeClaim) {
444
+ const seen = new Set();
445
+ const claims = [];
446
+ nodeIndex.nodes.forEach((node) => {
447
+ const binding = String(node.attrs?.["data-next-display"] || "").trim();
448
+ const match = PACKAGE_PRICE_BINDING.exec(binding);
449
+ const format = String(node.attrs?.["data-next-format"] || "currency").trim().toLowerCase();
450
+ if (!match
451
+ || format !== "currency"
452
+ || nodeIndex.hidden.get(node) === true
453
+ || nodeIndex.comparison.get(node) === true) return;
454
+ const packageId = positiveInteger(match[1]) ?? packageContextId(node);
455
+ const key = `${binding}\u0000${packageId}`;
456
+ if (seen.has(key)) return;
457
+ seen.add(key);
458
+ consumeClaim();
459
+ claims.push({ binding, package_id: packageId });
460
+ });
461
+ return claims;
462
+ }
463
+
419
464
  function extractPrices(nodeIndex, consumeClaim) {
420
465
  const seen = new Set();
421
466
  const prices = [];
@@ -529,6 +574,7 @@ export function extractCommercialClaims(html, options = {}) {
529
574
  }
530
575
  };
531
576
  const priceClaims = extractPrices(nodeIndex, consumeClaim);
577
+ const packagePriceClaims = extractPackagePrices(nodeIndex, consumeClaim);
532
578
  const recurrenceClaims = extractRecurrences(nodeIndex, extractionErrors, consumeClaim);
533
579
  const vouchers = extractVouchers(nodeIndex, extractionErrors, consumeClaim);
534
580
  return {
@@ -537,6 +583,7 @@ export function extractCommercialClaims(html, options = {}) {
537
583
  price_claims: priceClaims,
538
584
  recurrence_claims: recurrenceClaims,
539
585
  vouchers,
586
+ ...(packagePriceClaims.length ? { package_price_claims: packagePriceClaims } : {}),
540
587
  ...(extractionErrors.length ? { extraction_errors: extractionErrors } : {}),
541
588
  };
542
589
  }
@@ -563,8 +610,23 @@ function validVoucherClaim(claim) {
563
610
  return record(claim) && typeof claim.code === "string" && present(claim.code);
564
611
  }
565
612
 
613
+ function validPackagePriceClaim(claim) {
614
+ return record(claim)
615
+ && typeof claim.binding === "string"
616
+ && PACKAGE_PRICE_BINDING.test(claim.binding)
617
+ && (claim.package_id === null || positiveInteger(claim.package_id) !== null);
618
+ }
619
+
620
+ function validRenderedPackagePrice(claim) {
621
+ return validPackagePriceClaim(claim)
622
+ && typeof claim.filled === "boolean"
623
+ && (claim.text === null || typeof claim.text === "string");
624
+ }
625
+
566
626
  function validCaptureEnvelope(capture) {
567
627
  if (!record(capture)) return false;
628
+ if (capture.package_price_claims !== undefined && !Array.isArray(capture.package_price_claims)) return false;
629
+ if (capture.rendered_package_prices !== undefined && !Array.isArray(capture.rendered_package_prices)) return false;
568
630
  if (capture.page_id !== undefined
569
631
  && capture.page_id !== null
570
632
  && (typeof capture.page_id !== "string" || !present(capture.page_id))) return false;
@@ -580,7 +642,9 @@ function validCapture(capture) {
580
642
  return validCaptureEnvelope(capture)
581
643
  && capture.price_claims.every(validPriceClaim)
582
644
  && capture.recurrence_claims.every(validRecurrenceClaim)
583
- && capture.vouchers.every(validVoucherClaim);
645
+ && capture.vouchers.every(validVoucherClaim)
646
+ && array(capture.package_price_claims).every(validPackagePriceClaim)
647
+ && array(capture.rendered_package_prices).every(validRenderedPackagePrice);
584
648
  }
585
649
 
586
650
  function captureCandidates(capturesValue) {
@@ -626,6 +690,64 @@ function priceComparisonClaims(capture, page) {
626
690
  return { compared, unresolved: claims.length - compared.length };
627
691
  }
628
692
 
693
+ // Printed money as the SDK formats it: a symbol or code around one amount. A
694
+ // decimal comma or anything else stays unread, so it is unresolved, not wrong.
695
+ function renderedMoney(text) {
696
+ const match = decodeEntities(String(text ?? "")).replace(/[\u00a0\s]+/g, " ").trim()
697
+ .match(/^[^\d-]*(\d[\d,]*(?:\.\d{1,2})?)\s*[A-Za-z]*$/);
698
+ return match ? normalizeMoney(match[1]) : null;
699
+ }
700
+
701
+ // One distinct Exact per-unit price for the package on this page, or nothing:
702
+ // two rows of one package at different unit prices (quantity tiers, say) do not
703
+ // say which one a generic package.price means.
704
+ function exactPackageUnitPrice(page, packageId) {
705
+ const prices = new Set(array(page?.rows)
706
+ .filter((row) => String(row?.package_id) === String(packageId))
707
+ .map((row) => (row?.state === PricingState.Exact ? exactMoneyFact(row?.unit_price)?.value : null)));
708
+ return prices.size === 1 ? [...prices][0] ?? null : null;
709
+ }
710
+
711
+ // The rendered read is the authority once it found any. When it found none, the
712
+ // source still says the claims exist, so they are not allowed to disappear.
713
+ export function packagePriceClaimCount(capture) {
714
+ const rendered = array(capture?.rendered_package_prices);
715
+ return rendered.length || array(capture?.package_price_claims).length;
716
+ }
717
+
718
+ /**
719
+ * Sort a page's package.price claims into compared and unresolved. Only a
720
+ * rendered value the SDK filled is ever compared. Each unresolved claim names
721
+ * its reason, so the run can say what to do about it. Distinct claims only: one
722
+ * price repeated around an upsell page is one claim.
723
+ */
724
+ function packagePriceComparisonClaims(capture, page) {
725
+ const rendered = array(capture?.rendered_package_prices);
726
+ if (!rendered.length) {
727
+ return {
728
+ compared: [],
729
+ unresolved: array(capture?.package_price_claims).map(() => PACKAGE_PRICE_NOT_RENDERED),
730
+ };
731
+ }
732
+ const compared = [];
733
+ const unresolved = [];
734
+ const voucherUnpriced = voucherComparisonClaims(capture, page).unresolved > 0;
735
+ rendered.forEach((claim) => {
736
+ if (!claim?.filled) {
737
+ unresolved.push(PACKAGE_PRICE_NOT_FILLED);
738
+ return;
739
+ }
740
+ const value = renderedMoney(claim.text);
741
+ const truth = claim.package_id === null || voucherUnpriced ? null : exactPackageUnitPrice(page, claim.package_id);
742
+ if (!value || !truth) {
743
+ unresolved.push(PACKAGE_PRICE_UNRESOLVED);
744
+ return;
745
+ }
746
+ compared.push({ package_id: claim.package_id, value, truth });
747
+ });
748
+ return { compared, unresolved };
749
+ }
750
+
629
751
  function pageForCapture(capture, journey) {
630
752
  const pages = array(journey?.pages);
631
753
  if (present(capture?.page_id)) {
@@ -745,6 +867,19 @@ export function diffCommercialParity(capturesValue, journey, options = {}) {
745
867
  });
746
868
  }
747
869
 
870
+ (compareMoney ? packagePriceComparisonClaims(capture, page).compared : []).forEach(({ package_id: packageId, value, truth }) => {
871
+ if (moneyToCents(value) === moneyToCents(truth)) return;
872
+ findings.push({
873
+ type: "price-claim-mismatch",
874
+ page_id: pageId,
875
+ ...source,
876
+ binding: `package.${packageId}.price`,
877
+ package_id: packageId,
878
+ claimed: value,
879
+ calculated: truth,
880
+ });
881
+ });
882
+
748
883
  (compareMoney ? recurrenceComparisonClaims(capture, page).compared : []).forEach(({ claim, truth }) => {
749
884
  const packageId = positiveInteger(claim?.package_id);
750
885
  const claimed = {
@@ -817,7 +952,11 @@ function assertionForFinding(finding, id) {
817
952
  ...common,
818
953
  expected: finding.calculated,
819
954
  actual: finding.claimed,
820
- evidence: { type: finding.type, binding: finding.binding },
955
+ evidence: {
956
+ type: finding.type,
957
+ binding: finding.binding,
958
+ ...(finding.package_id !== undefined ? { package_id: finding.package_id } : {}),
959
+ },
821
960
  };
822
961
  }
823
962
  if (finding.type === "cadence-disclosure-mismatch") {
@@ -952,7 +1091,7 @@ export function createCommercialParityReport(capturesValue, journey, options = {
952
1091
  .filter((page) => !pageKeys.has(String(page?.page_id ?? "page")))
953
1092
  .map((page) => ({ page_id: present(page?.page_id) ? String(page.page_id) : null }));
954
1093
  const extractedPriceClaims = matched.reduce(
955
- (sum, { capture }) => sum + capture.price_claims.length,
1094
+ (sum, { capture }) => sum + capture.price_claims.length + packagePriceClaimCount(capture),
956
1095
  0,
957
1096
  );
958
1097
  const extractedRecurrenceClaims = matched.reduce(
@@ -963,12 +1102,22 @@ export function createCommercialParityReport(capturesValue, journey, options = {
963
1102
  (sum, { capture }) => sum + capture.vouchers.length,
964
1103
  0,
965
1104
  );
1105
+ const packagePriceCounts = {
1106
+ extracted: matched.reduce((sum, { capture }) => sum + packagePriceClaimCount(capture), 0),
1107
+ compared: 0,
1108
+ };
1109
+ const packagePriceUnresolved = {};
966
1110
  const priceComparisonCounts = countsOnly || moneyReason
967
1111
  ? { compared: 0, unresolved: extractedPriceClaims }
968
1112
  : matched.reduce((counts, { capture, page }) => {
969
1113
  const comparison = priceComparisonClaims(capture, page);
970
- counts.compared += comparison.compared.length;
971
- counts.unresolved += comparison.unresolved;
1114
+ const packagePrices = packagePriceComparisonClaims(capture, page);
1115
+ packagePriceCounts.compared += packagePrices.compared.length;
1116
+ packagePrices.unresolved.forEach((code) => {
1117
+ packagePriceUnresolved[code] = (packagePriceUnresolved[code] || 0) + 1;
1118
+ });
1119
+ counts.compared += comparison.compared.length + packagePrices.compared.length;
1120
+ counts.unresolved += comparison.unresolved + packagePrices.unresolved.length;
972
1121
  return counts;
973
1122
  }, { compared: 0, unresolved: 0 });
974
1123
  const recurrenceComparisonCounts = countsOnly || moneyReason
@@ -1007,6 +1156,7 @@ export function createCommercialParityReport(capturesValue, journey, options = {
1007
1156
  extracted_price_claims: extractedPriceClaims,
1008
1157
  compared_price_claims: priceComparisonCounts.compared,
1009
1158
  unresolved_price_claims: priceComparisonCounts.unresolved,
1159
+ package_price_claims: { ...packagePriceCounts, unresolved: packagePriceUnresolved },
1010
1160
  extracted_recurrence_claims: extractedRecurrenceClaims,
1011
1161
  compared_recurrence_claims: recurrenceComparisonCounts.compared,
1012
1162
  unresolved_recurrence_claims: recurrenceComparisonCounts.unresolved,