@nextcommerce/campaigns-os 1.60.0 → 1.62.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 (161) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +244 -0
  3. package/README.md +86 -438
  4. package/agents/claude/CLAUDE.md +20 -0
  5. package/agents/codex/AGENTS.md +19 -0
  6. package/agents/copilot/copilot-instructions.md +19 -0
  7. package/agents/cursor/campaigns-os.mdc +21 -0
  8. package/campaign-spec/README.md +7 -0
  9. package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
  10. package/campaign-spec/dist/analytics-vendors.js +44 -0
  11. package/campaign-spec/dist/checkout-flow.d.ts +18 -0
  12. package/campaign-spec/dist/checkout-flow.js +28 -0
  13. package/campaign-spec/dist/index.d.ts +4 -2
  14. package/campaign-spec/dist/index.js +5 -1
  15. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
  16. package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
  17. package/campaign-spec/dist/rules/campaign-currencies.d.ts +29 -0
  18. package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
  19. package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
  20. package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
  21. package/campaign-spec/dist/rules/index.d.ts +2 -0
  22. package/campaign-spec/dist/rules/index.js +6 -0
  23. package/campaign-spec/dist/types.d.ts +43 -9
  24. package/compatibility.json +1 -1
  25. package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
  26. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  27. package/contracts/commerce-surface-catalog.json +68 -61
  28. package/contracts/effects.v1.json +549 -165
  29. package/contracts/fixtures/runtime-recipe/accept/current.json +3 -0
  30. package/contracts/fixtures/runtime-recipe/accept/minimal.json +3 -0
  31. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  32. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +3 -0
  33. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +3 -0
  34. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +3 -0
  35. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +3 -0
  36. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +3 -0
  37. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +3 -0
  38. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +3 -0
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +3 -0
  40. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +3 -0
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +3 -0
  42. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +3 -0
  43. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +3 -0
  44. package/contracts/release-ledger.json +1767 -0
  45. package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -0
  46. package/contracts/supported-surface.json +10 -10
  47. package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
  48. package/docs/access-model.md +20 -0
  49. package/docs/brand-theme-bridge.md +62 -3
  50. package/docs/build-packet.md +456 -36
  51. package/docs/campaign-build-brief.md +16 -0
  52. package/docs/campaign-standardization-report.md +4 -4
  53. package/docs/campaigns-os-build-flow.md +112 -2
  54. package/docs/campaignspec-authoring-examples.md +141 -0
  55. package/docs/design-source-package.md +64 -8
  56. package/docs/effects.md +17 -9
  57. package/docs/entry-points.md +188 -0
  58. package/docs/local-setup.md +1 -1
  59. package/docs/orientation-contract-reference.md +1 -1
  60. package/docs/polish-evidence.md +42 -2
  61. package/docs/progress-snapshots.md +7 -4
  62. package/docs/qa-and-test-orders.md +197 -3
  63. package/docs/quickstart.md +438 -0
  64. package/docs/readback.md +154 -6
  65. package/docs/runtime-readiness.md +6 -3
  66. package/docs/sdk-storage-compatibility.md +4 -2
  67. package/docs/skills-revision.md +10 -10
  68. package/docs/source-adapters.md +213 -0
  69. package/package.json +10 -3
  70. package/schemas/campaign-design-source-package.v0.schema.json +8 -0
  71. package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
  72. package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
  73. package/schemas/campaign-spec.v4.schema.json +19 -3
  74. package/schemas/campaign-spec.v5.schema.json +19 -3
  75. package/schemas/campaigns-os-readback.v2.schema.json +238 -1
  76. package/schemas/source-html-manifest.v0.schema.json +3 -0
  77. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  78. package/skills/campaign-readback-classification/SKILL.md +3 -3
  79. package/skills/campaign-run-evidence/SKILL.md +3 -3
  80. package/skills/contribution-intake/SKILL.md +3 -3
  81. package/skills/next-campaigns-build/SKILL.md +5 -4
  82. package/skills/next-campaigns-os/SKILL.md +7 -4
  83. package/skills/next-campaigns-os-setup/SKILL.md +5 -3
  84. package/skills/next-campaigns-polish/SKILL.md +8 -10
  85. package/skills/next-campaigns-qa/SKILL.md +7 -4
  86. package/skills/next-campaigns-sdk-bump/SKILL.md +151 -25
  87. package/skills.json +11 -11
  88. package/src/brand-theme.mjs +415 -9
  89. package/src/brief-summary.mjs +6 -2
  90. package/src/browser-launch.mjs +2 -1
  91. package/src/build-brief.mjs +13 -3
  92. package/src/built-site-scope.mjs +2 -2
  93. package/src/campaign-workspace.mjs +288 -15
  94. package/src/change-scope.mjs +284 -0
  95. package/src/cli.mjs +358 -97
  96. package/src/commercial-journey.mjs +16 -3
  97. package/src/commercial-parity.mjs +15 -7
  98. package/src/content-residue.mjs +4 -2
  99. package/src/design-file-reference.mjs +93 -0
  100. package/src/design-source-package.mjs +91 -1
  101. package/src/design-source-publication.mjs +9 -6
  102. package/src/directory-lock.mjs +73 -19
  103. package/src/doctor/checks.mjs +87 -81
  104. package/src/doctor/ci.mjs +177 -0
  105. package/src/doctor/inspect.mjs +41 -6
  106. package/src/doctor/next-step.mjs +7 -0
  107. package/src/doctor/source-provenance.mjs +74 -0
  108. package/src/doctor-sidecar.mjs +3 -2
  109. package/src/evidence-debt.mjs +159 -0
  110. package/src/finding-cause.mjs +12 -11
  111. package/src/input-refresh.mjs +339 -32
  112. package/src/invocation.mjs +7 -1
  113. package/src/live-campaign-refs.mjs +7 -2
  114. package/src/local-preview-policy.mjs +35 -2
  115. package/src/page-kit-analytics-vendors.mjs +132 -0
  116. package/src/parity/compare.mjs +316 -0
  117. package/src/parity/design-access.mjs +54 -0
  118. package/src/parity/element-parity-record.mjs +569 -0
  119. package/src/parity/probe.mjs +205 -0
  120. package/src/parity/snapshot.mjs +110 -0
  121. package/src/png-header.mjs +87 -0
  122. package/src/polish-browser.mjs +189 -10
  123. package/src/polish-captures-record.mjs +52 -0
  124. package/src/polish-deadline.mjs +14 -0
  125. package/src/polish-gate.mjs +25 -6
  126. package/src/polish-node.mjs +81 -7
  127. package/src/polish-readability.mjs +221 -9
  128. package/src/polish-visual-fixtures.mjs +74 -0
  129. package/src/polish-visual.mjs +613 -0
  130. package/src/progress-node.mjs +3 -2
  131. package/src/qa-analytics-correctness.mjs +14 -2
  132. package/src/qa-analytics-parity.mjs +111 -6
  133. package/src/qa-browser.mjs +40 -4
  134. package/src/qa-commercial-parity.mjs +50 -3
  135. package/src/qa-node.mjs +235 -22
  136. package/src/qa-publish.mjs +9 -8
  137. package/src/qa-sidecar.mjs +16 -4
  138. package/src/qa-vendor-beacons.mjs +232 -0
  139. package/src/qa-vendor-endpoints.mjs +306 -0
  140. package/src/qc-accept.mjs +176 -6
  141. package/src/qc-check-registry.mjs +42 -2
  142. package/src/qc-results.mjs +477 -42
  143. package/src/readback.mjs +113 -21
  144. package/src/route-identity.mjs +10 -0
  145. package/src/run-record.mjs +18 -10
  146. package/src/runtime-state-ignore.mjs +6 -2
  147. package/src/sdk-attribute-index.mjs +11 -11
  148. package/src/sdk-markup.mjs +77 -2
  149. package/src/sdk-storage-compatibility.mjs +115 -6
  150. package/src/source-html-intake.mjs +15 -1
  151. package/src/source-html-manifest.mjs +7 -0
  152. package/src/source-html-server.mjs +129 -0
  153. package/src/spec-derive-campaign.mjs +216 -0
  154. package/src/spec-derive.mjs +473 -53
  155. package/src/spec-identity.mjs +14 -0
  156. package/src/stage-ledger.mjs +40 -1
  157. package/src/stage-record.mjs +100 -17
  158. package/src/target-lock.mjs +11 -1
  159. package/src/theme-gate.mjs +2 -2
  160. package/src/visual-diff.mjs +296 -0
  161. package/src/visual-reference.mjs +126 -0
package/src/readback.mjs CHANGED
@@ -10,7 +10,9 @@
10
10
  * usually subdirectories of their enclosing campaign repository — to locate the
11
11
  * Git directory, and that directory's `logs/HEAD` reflog), plus the CampaignSpec
12
12
  * and normalized Campaign Build Brief the Build Packet names, whose current
13
- * content decides each stage's effective status. It writes nothing,
13
+ * content decides each stage's effective status. It also hashes built output
14
+ * and maps file drift against the recorded assembly manifest to evidence debt.
15
+ * It writes nothing,
14
16
  * starts no process, and touches no network. That contract is why the CLI
15
17
  * exempts `readback` from lifecycle-journal capture the way it exempts doctor
16
18
  * inspection: a command declared read-only must not append a journal entry.
@@ -53,6 +55,9 @@ import { fileURLToPath } from "node:url";
53
55
 
54
56
  import { assessInputCurrency, currentPacketInputs, effectiveStageStatus } from "./input-currency.mjs";
55
57
  import { deriveAssemblyReportSummary } from "./stage-ledger.mjs";
58
+ import { assessEvidenceDebt } from "./evidence-debt.mjs";
59
+ import { RUNTIME_DIR_REL_PATH, packetRuntimeRelPath } from "./campaign-workspace.mjs";
60
+ import { normalizePublicRouteSlug } from "./route-identity.mjs";
56
61
 
57
62
  export const MAX_ARTIFACT_BYTES = 32 * 1024 * 1024;
58
63
  export const MAX_GIT_METADATA_BYTES = 64 * 1024;
@@ -190,7 +195,9 @@ export function readReflogTail(path) {
190
195
 
191
196
  // Artifact keys, in render order, with the default location of each artifact
192
197
  // relative to the target repository root. The packet lives at the root; the
193
- // sidecars live under .campaign-runtime/.
198
+ // sidecars live under .campaign-runtime/, or under the runtime directory the
199
+ // selected packet records (assembly.runtime_dir) when the project holds
200
+ // several funnels.
194
201
  export const DEFAULT_RELATIVE_PATHS = {
195
202
  packet: "campaign-runtime.build.json",
196
203
  doctor: ".campaign-runtime/doctor-output.json",
@@ -218,8 +225,10 @@ export const RECOGNIZED_SCHEMA_VERSIONS = {
218
225
  };
219
226
 
220
227
  // Root-level Build Packets other than the default-named one. A second run in
221
- // the same repository leaves its record under a suffixed name; both shapes are
222
- // discovery candidates when no `--packet` was given.
228
+ // the same repository leaves its record under a suffixed name, and every
229
+ // funnel after a project's first has its packet at
230
+ // campaign-runtime-<route>.build.json; both shapes are discovery candidates
231
+ // when no `--packet` was given.
223
232
  const PACKET_CANDIDATE_PATTERN = /^campaign-runtime-.*\.build\.json$/;
224
233
 
225
234
  // Sidecar path some write-ends have used instead of the contracted root home.
@@ -942,7 +951,7 @@ function renderStaleness(staleness, lines) {
942
951
  for (const key of staleness.stale_keys) {
943
952
  lines.push(` ${ARTIFACT_TITLES[key]} (generated ${formatUtc(staleness.artifacts[key].generated_at)})`);
944
953
  }
945
- lines.push(" Every section below describes the repository as it was when the artifacts");
954
+ lines.push(" Recorded artifact sections describe the repository as it was when the artifacts");
946
955
  lines.push(" were generated, not necessarily as it is now; a new run must regenerate");
947
956
  lines.push(" them before this view is current.");
948
957
  } else {
@@ -1284,6 +1293,43 @@ export function readbackInputs(views) {
1284
1293
  return currentPacketInputs({ packet, packetPath: resolve(views.packet.path) });
1285
1294
  }
1286
1295
 
1296
+ function readbackEvidenceDebt(views) {
1297
+ return assessEvidenceDebt({
1298
+ packet: artifactData(views, "packet"),
1299
+ packetPath: views.packet?.path,
1300
+ report: artifactData(views, "report"),
1301
+ });
1302
+ }
1303
+
1304
+ function renderEvidenceDebt(debt, lines) {
1305
+ lines.push("EVIDENCE DEBT [the readback's own projection layer: recorded build bindings versus built output]");
1306
+ for (const name of ["polish", "qa"]) {
1307
+ const stage = debt[name];
1308
+ const explanation = stage.status === "current" ? "recorded build matches the current build"
1309
+ : stage.status === "owed" ? "evidence is owed for the current build"
1310
+ : `evidence currency is unknown (${stage.reason}); absence of proof is not no debt`;
1311
+ lines.push(` ${name}: ${stage.status} — ${explanation}`);
1312
+ lines.push(` recorded: ${stage.recorded_build_fingerprint ?? "unknown"}; current: ${stage.current_build_fingerprint ?? "unknown"}`);
1313
+ if (stage.change) {
1314
+ const owes = stage.advisory ? "advisory: reported, not required"
1315
+ : stage.scope === "pages" ? `owed on ${stage.owed_pages.map((page) => oneLine(page.page_id)).join(", ")} (qa run --page)` : "the full stage is owed";
1316
+ lines.push(` change since its build: ${stage.change.kind}; ${owes}`);
1317
+ for (const page of stage.change.changed_pages) lines.push(` changed page: ${oneLine(page.page_id)} (${oneLine(page.built_path)})`);
1318
+ for (const reason of stage.change.reasons) lines.push(` commerce: ${oneLine(reason.code)}${reason.path ? ` (${oneLine(reason.path)})` : ""}`);
1319
+ }
1320
+ }
1321
+ const changes = debt.changes;
1322
+ lines.push(` changes against assembly manifest: ${changes.status}${changes.reason ? ` (${changes.reason})` : ""}`);
1323
+ lines.push(` recorded: ${changes.recorded_build_fingerprint ?? "unknown"}; current: ${changes.current_build_fingerprint ?? "unknown"}`);
1324
+ for (const page of changes.changed_pages) lines.push(` changed page: ${oneLine(page.page_id)} (${oneLine(page.built_path)})`);
1325
+ for (const path of changes.unmapped_files) lines.push(` unmapped file: ${oneLine(path)}`);
1326
+ for (const kind of ["extra", "missing", "changed"]) {
1327
+ if (changes[kind].length) lines.push(` ${kind} files: ${changes[kind].map(oneLine).join(", ")}`);
1328
+ }
1329
+ if (changes.status === "known" && !changes.changed_pages.length && !changes.unmapped_files.length) lines.push(" no files differ from the recorded assembly manifest");
1330
+ lines.push(" Page changes use the assembly baseline; older stage manifests are not reconstructed.", "");
1331
+ }
1332
+
1287
1333
  // Superseded records shown per stage, newest last (the report keeps five).
1288
1334
  const HISTORY_RENDER_CAP = 5;
1289
1335
 
@@ -1544,7 +1590,7 @@ function renderFindings(views, lines) {
1544
1590
  * `inputs` is readbackInputs(views); when omitted, every input reads as not
1545
1591
  * readable, so a build, Polish or QA record reads unknown.
1546
1592
  */
1547
- export function projectReadback(views, staleness = null, packetSelection = null, inputs = null) {
1593
+ export function projectReadback(views, staleness = null, packetSelection = null, inputs = null, evidenceDebt = assessEvidenceDebt()) {
1548
1594
  const lines = [
1549
1595
  "CAMPAIGNS OS RUN-ARTIFACT READBACK",
1550
1596
  "A read-only projection of this run's emitted artifacts. Campaigns OS",
@@ -1557,6 +1603,7 @@ export function projectReadback(views, staleness = null, packetSelection = null,
1557
1603
  renderArtifactTable(views, lines, packetSelection);
1558
1604
  renderIdentity(views, lines);
1559
1605
  renderStages(views, lines, inputs);
1606
+ renderEvidenceDebt(evidenceDebt, lines);
1560
1607
  renderContext(views, lines);
1561
1608
  renderDoctor(views, lines);
1562
1609
  renderVerdict(views, lines);
@@ -1602,13 +1649,16 @@ export function serializeStaleness(staleness) {
1602
1649
  /**
1603
1650
  * Whether this projection shows nothing the readback can call wrong.
1604
1651
  *
1605
- * True only when all five conditions hold: every artifact the readback found is
1652
+ * True only when all six conditions hold: every artifact the readback found is
1606
1653
  * loaded and recognized (absent artifacts are not counted against it — a run
1607
1654
  * that emitted no findings export is not thereby unclean, while an unreadable
1608
1655
  * or unrecognized one always is); the staleness comparison is computable and NO
1609
1656
  * loaded artifact is stale; NO loaded artifact recorded a `generated_at` this
1610
1657
  * readback could not parse; no cross-artifact divergence was found; and the
1611
- * doctor output records zero errors.
1658
+ * doctor output records zero errors; and no evidence-debt stage is owed
1659
+ * (advisory Polish for a page-only change does not count).
1660
+ * Unknown build binding does not independently change this integrity flag;
1661
+ * callers must read evidence_debt rather than treating clean as no debt.
1612
1662
  *
1613
1663
  * The fourth of those is the unknown-age rule: an artifact whose recorded age
1614
1664
  * did not parse was never shown to be current, and `clean` states that the
@@ -1626,7 +1676,10 @@ export function serializeStaleness(staleness) {
1626
1676
  * OS recorded. docs/readback.md states the rule and its limits for callers
1627
1677
  * gating on it.
1628
1678
  */
1629
- export function computeClean(views, staleness) {
1679
+ export function computeClean(views, staleness, evidenceDebt) {
1680
+ if (evidenceDebt == null) throw new TypeError("computeClean requires an evidence-debt assessment");
1681
+ // Advisory Polish (a page-only change) is reported, never required.
1682
+ if ([evidenceDebt?.polish, evidenceDebt?.qa].some((stage) => stage?.status === "owed" && !stage.advisory)) return false;
1630
1683
  if (Object.values(views).some((view) => !CLEAN_ARTIFACT_STATES.has(view.state))) return false;
1631
1684
  if (!staleness || !staleness.computable || staleness.stale) return false;
1632
1685
  if (staleness.unparseable_keys.length) return false;
@@ -1661,8 +1714,12 @@ function artifactRowDetail(view, staleness) {
1661
1714
  *
1662
1715
  * `packet_selection` and `staleness` are `null` when a programmatic caller
1663
1716
  * builds a payload without them; the CLI always supplies both.
1717
+ * `evidenceDebt` is projectTarget's or projectExample's assessment. The default
1718
+ * calls assessEvidenceDebt with no packet inputs, which returns unknown with
1719
+ * packet_unavailable before any filesystem access. Callers with loaded packet
1720
+ * data must supply their computed assessment; this serializer never hashes files.
1664
1721
  */
1665
- export function buildJsonPayload(views, staleness = null, packetSelection = null) {
1722
+ export function buildJsonPayload(views, staleness = null, packetSelection = null, evidenceDebt = assessEvidenceDebt()) {
1666
1723
  return {
1667
1724
  schema_version: JSON_SCHEMA_VERSION,
1668
1725
  artifacts: Object.values(views).map((view) => ({
@@ -1676,7 +1733,8 @@ export function buildJsonPayload(views, staleness = null, packetSelection = null
1676
1733
  doctor: computeDoctorSummary(views),
1677
1734
  skip_cascades: computeSkipCascades(views),
1678
1735
  divergences: computeDivergences(views),
1679
- clean: computeClean(views, staleness),
1736
+ evidence_debt: evidenceDebt,
1737
+ clean: computeClean(views, staleness, evidenceDebt),
1680
1738
  };
1681
1739
  }
1682
1740
 
@@ -1792,6 +1850,18 @@ export function selectPacketPath(rootPath, override) {
1792
1850
 
1793
1851
  if (considered.length === 1) return chose(considered[0], "sole_candidate");
1794
1852
 
1853
+ // Packets for different routes are different funnels, each with its own
1854
+ // records: freshness would only say which funnel was set up last, so the
1855
+ // caller names the one to project.
1856
+ const routes = new Map(considered.map((name) => [name, normalizePublicRouteSlug(views.get(name).data?.campaign?.public_route_slug) || null]));
1857
+ if (new Set(routes.values()).size > 1) {
1858
+ const detail = considered.map((name) => `${name} (route ${routes.get(name) || "unnamed"})`).join(", ");
1859
+ throw new ReadbackUsageError(
1860
+ `the target root holds Build Packets for more than one funnel: ${detail}. ` +
1861
+ "Pass --packet to name the funnel's Build Packet to project.",
1862
+ );
1863
+ }
1864
+
1795
1865
  if (considered.some((name) => times.get(name) === null)) {
1796
1866
  const detail = considered
1797
1867
  .map((name) => {
@@ -1826,24 +1896,45 @@ export function selectPacketPath(rootPath, override) {
1826
1896
  /**
1827
1897
  * Return `{ paths, packetSelection, packetView }` for one target root.
1828
1898
  *
1829
- * `packetView` is the Build Packet view discovery already loaded, ready to hand
1830
- * to loadArtifacts as `preloaded` so no artifact is read twice; it is null when
1831
- * discovery read no packet.
1899
+ * `packetView` is the Build Packet view already loaded (by discovery, or here
1900
+ * for an explicit `--packet` that names a file), ready to hand to
1901
+ * loadArtifacts as `preloaded` so no artifact is read twice; it is null when
1902
+ * there is no packet file to read. The sidecar paths sit under the runtime
1903
+ * directory that packet records.
1832
1904
  */
1833
1905
  export function resolveProjection(root, overrides = {}) {
1834
1906
  const info = statOrNull(root);
1835
1907
  if (!info || !info.isDirectory()) {
1836
1908
  throw new ReadbackUsageError(`target repository root must name an existing directory: ${root}`);
1837
1909
  }
1838
- const { path: packetPath, selection, view } = selectPacketPath(root, overrides.packet);
1910
+ const selected = selectPacketPath(root, overrides.packet);
1911
+ const { path: packetPath, selection } = selected;
1912
+ // The sidecars follow the packet: read it now when discovery did not, so
1913
+ // its runtime directory is known and it is still read only once.
1914
+ const view = selected.view || (isFilePath(packetPath) ? loadArtifact("packet", packetPath) : null);
1915
+ const runtime = packetRuntimeDirFor(view);
1839
1916
  const paths = { packet: packetPath };
1840
1917
  for (const [key, relative] of Object.entries(DEFAULT_RELATIVE_PATHS)) {
1841
1918
  if (key === "packet") continue;
1842
- paths[key] = overrides[key] ? String(overrides[key]) : join(root, relative);
1919
+ paths[key] = overrides[key] ? String(overrides[key]) : join(root, runtime, relative.slice(RUNTIME_DIR_REL_PATH.length + 1));
1843
1920
  }
1844
1921
  return { paths, packetSelection: selection, packetView: view };
1845
1922
  }
1846
1923
 
1924
+ // The runtime directory the selected packet records, relative to the root;
1925
+ // the project-level .campaign-runtime when there is no readable packet or it
1926
+ // records none. A value the packet may not carry leaves no way to know whose
1927
+ // records to show, and showing the project-level ones could project another
1928
+ // funnel's, so the readback cannot form a projection.
1929
+ function packetRuntimeDirFor(view) {
1930
+ if (view?.state !== "loaded") return RUNTIME_DIR_REL_PATH;
1931
+ try {
1932
+ return packetRuntimeRelPath(view.data);
1933
+ } catch (error) {
1934
+ throw new ReadbackUsageError(`${view.path}: ${error.message}`);
1935
+ }
1936
+ }
1937
+
1847
1938
  /** The artifact paths to project; discovery result discarded. */
1848
1939
  export function resolvePaths(root, overrides = {}) {
1849
1940
  return resolveProjection(root, overrides).paths;
@@ -1853,7 +1944,7 @@ export function resolvePaths(root, overrides = {}) {
1853
1944
  export function projectTarget(root, overrides = {}) {
1854
1945
  const { paths, packetSelection, packetView } = resolveProjection(root, overrides);
1855
1946
  const views = loadArtifacts(paths, { packet: packetView });
1856
- return { views, staleness: assessStaleness(root, views), packetSelection, inputs: readbackInputs(views) };
1947
+ return { views, staleness: assessStaleness(root, views), packetSelection, inputs: readbackInputs(views), evidenceDebt: readbackEvidenceDebt(views) };
1857
1948
  }
1858
1949
 
1859
1950
  // The bundled synthetic sample `--example` projects. It is already on the
@@ -1885,6 +1976,7 @@ export function projectExample() {
1885
1976
  const { paths, packetSelection, packetView } = resolveProjection(EXAMPLE_ROOT, {});
1886
1977
  const views = loadArtifacts(paths, { packet: packetView });
1887
1978
  const inputs = readbackInputs(views);
1979
+ const evidenceDebt = readbackEvidenceDebt(views);
1888
1980
  for (const view of Object.values(views)) {
1889
1981
  const within = view.path.slice(EXAMPLE_ROOT.length).replace(/\\/g, "/").replace(/^\/+/, "");
1890
1982
  view.path = `${EXAMPLE_RELATIVE_ROOT}/${within}`;
@@ -1892,7 +1984,7 @@ export function projectExample() {
1892
1984
  const staleness = assessStaleness(EXAMPLE_ROOT, views, {
1893
1985
  headMovement: { time: null, detail: EXAMPLE_HEAD_DETAIL },
1894
1986
  });
1895
- return { views, staleness, packetSelection, inputs };
1987
+ return { views, staleness, packetSelection, inputs, evidenceDebt };
1896
1988
  }
1897
1989
 
1898
1990
  const OVERRIDE_FLAGS = {
@@ -1986,9 +2078,9 @@ export function runReadbackCommand(args) {
1986
2078
  if (error instanceof ReadbackUsageError) return { exitCode: 2, text: `${error.message}\n` };
1987
2079
  throw error;
1988
2080
  }
1989
- const { views, staleness, packetSelection, inputs } = projection;
2081
+ const { views, staleness, packetSelection, inputs, evidenceDebt } = projection;
1990
2082
  const text = request.json
1991
- ? `${JSON.stringify(buildJsonPayload(views, staleness, packetSelection), null, 2)}\n`
1992
- : projectReadback(views, staleness, packetSelection, inputs);
2083
+ ? `${JSON.stringify(buildJsonPayload(views, staleness, packetSelection, evidenceDebt), null, 2)}\n`
2084
+ : projectReadback(views, staleness, packetSelection, inputs, evidenceDebt);
1993
2085
  return { exitCode: 0, text };
1994
2086
  }
@@ -39,6 +39,16 @@ export function normalizePageKitRoute(value) {
39
39
  return clean ? `${clean}/` : "";
40
40
  }
41
41
 
42
+ // The public route slug a CampaignSpec names for itself, normalized:
43
+ // spec_identity.public_route_slug, else campaign.slug, else campaign.id. One
44
+ // reading for every command that checks a spec belongs to its packet.
45
+ export function specPublicRouteSlug(spec) {
46
+ for (const value of [spec?.spec_identity?.public_route_slug, spec?.campaign?.slug, spec?.campaign?.id]) {
47
+ if (typeof value === "string" && value.trim()) return normalizePublicRouteSlug(value);
48
+ }
49
+ return "";
50
+ }
51
+
42
52
  // The route with the slug prefix removed, when it carries one: "slug/checkout/"
43
53
  // → "checkout/", "slug/" → "", "checkout/" → "checkout/".
44
54
  export function stripPublicRoutePrefix(route, publicRouteSlug) {
@@ -12,13 +12,14 @@ import { localSpecIdentityFields, resolveCampaignIdentity } from "./spec-source-
12
12
 
13
13
  import { randomBytes } from "node:crypto";
14
14
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
15
- import { dirname, join, resolve } from "node:path";
15
+ import { dirname, join, relative, resolve } from "node:path";
16
16
  import { ADAPTER_DECISION_STRATEGY_FIELDS } from "./adapter-decision-contract.mjs";
17
17
  import { currentBuildFingerprint } from "./polish-gate.mjs";
18
18
  import { qaRecordedBuildFingerprint, qaRecordedForCurrentBuild } from "./stage-ledger.mjs";
19
19
 
20
20
  export const RUN_RECORD_SCHEMA = "campaigns-os-run-record/v0";
21
21
  export const RUN_RECORDS_DIR_REL_PATH = ".campaign-runtime/run-records";
22
+ const RUNTIME_DIR_REL_PATH = ".campaign-runtime";
22
23
 
23
24
  // Improvement-surface taxonomy: a list, not an enum — real signal is rarely
24
25
  // one surface (see Improvement-Surface Taxonomy in the design).
@@ -377,12 +378,19 @@ function sanitizeRunId(runId) {
377
378
  }
378
379
 
379
380
  /**
380
- * Resolve where a Run Record is written: <baseDir>/.campaign-runtime/
381
- * run-records/<run_id>.json. baseDir is the run root (the CLI passes the
382
- * packet's directory) so the records sit next to the findings journal.
381
+ * Resolve where a Run Record is written: <baseDir>/<runtime dir>/run-records/
382
+ * <run_id>.json. baseDir is the run root (the CLI passes the packet's
383
+ * directory) so the records sit next to the findings journal. The runtime
384
+ * directory is the packet's (packetRuntimeRelPath in campaign-workspace.mjs):
385
+ * `.campaign-runtime` unless the packet is one of several funnels in its
386
+ * project, so each funnel keeps its own records.
383
387
  */
384
- export function resolveRunRecordPath(runId, baseDir = process.cwd()) {
385
- return join(resolve(baseDir), RUN_RECORDS_DIR_REL_PATH, `${sanitizeRunId(runId)}.json`);
388
+ export function resolveRunRecordPath(runId, baseDir = process.cwd(), runtimeRelPath = RUNTIME_DIR_REL_PATH) {
389
+ return join(runRecordsDir(baseDir, runtimeRelPath), `${sanitizeRunId(runId)}.json`);
390
+ }
391
+
392
+ function runRecordsDir(baseDir, runtimeRelPath = RUNTIME_DIR_REL_PATH) {
393
+ return join(resolve(baseDir), runtimeRelPath, relative(RUNTIME_DIR_REL_PATH, RUN_RECORDS_DIR_REL_PATH));
386
394
  }
387
395
 
388
396
  function extractDoctorObservations(doctor) {
@@ -758,13 +766,13 @@ export function assembleRunRecord({
758
766
  * invalid record (a malformed manifest is a build-tooling bug, not run state
759
767
  * to swallow). Returns the written path.
760
768
  */
761
- export function writeRunRecord(record, { baseDir = process.cwd() } = {}) {
769
+ export function writeRunRecord(record, { baseDir = process.cwd(), runtimeRelPath = RUNTIME_DIR_REL_PATH } = {}) {
762
770
  const validation = validateRunRecord(record);
763
771
  if (!validation.ok) {
764
772
  const detail = validation.errors.map((error) => `[${error.code}] ${error.message}`).join("; ");
765
773
  throw new Error(`Run Record failed validation; refusing to write: ${detail}`);
766
774
  }
767
- const path = resolveRunRecordPath(record.run_id, baseDir);
775
+ const path = resolveRunRecordPath(record.run_id, baseDir, runtimeRelPath);
768
776
  mkdirSync(dirname(path), { recursive: true });
769
777
  const tmpPath = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
770
778
  writeFileSync(tmpPath, `${JSON.stringify(record, null, 2)}\n`);
@@ -831,9 +839,9 @@ export function orderRunRecordFileNames(names) {
831
839
  });
832
840
  }
833
841
 
834
- export function readRunRecordsForTarget(baseDir) {
842
+ export function readRunRecordsForTarget(baseDir, runtimeRelPath = RUNTIME_DIR_REL_PATH) {
835
843
  try {
836
- const dir = join(resolve(baseDir), RUN_RECORDS_DIR_REL_PATH);
844
+ const dir = runRecordsDir(baseDir, runtimeRelPath);
837
845
  if (!existsSync(dir) || !statSync(dir).isDirectory()) return [];
838
846
  const names = orderRunRecordFileNames(readdirSync(dir).filter((name) => RUN_RECORD_FILE_NAME_PATTERN.test(name)))
839
847
  .slice(0, RUN_RECORDS_SCAN_LIMIT);
@@ -2,7 +2,8 @@
2
2
  // history, and which are not. Three kinds share the directory:
3
3
  //
4
4
  // 1. The readback bundle (build-context, assembly-report, doctor-output,
5
- // qa-verdict) — current readback truth; docs/migration-sidecar-bundle.md
5
+ // qa-verdict, at the top level or under funnels/<route>/ for a project's
6
+ // second and later funnels) — current readback truth; docs/migration-sidecar-bundle.md
6
7
  // names it and `bundle check` validates it. Committed by design.
7
8
  // 2. Handoff inputs and agent context (input/, theme/, agent-context/,
8
9
  // setup-handoff.json) — durable build handoff. Committed by design.
@@ -28,6 +29,9 @@ export const RUNTIME_STATE_IGNORED_PATHS = [
28
29
  ".campaign-runtime/agent-deviations.jsonl",
29
30
  ".campaign-runtime/workflow-findings.jsonl",
30
31
  ".campaign-runtime/run-records/",
32
+ // A project's second and later funnels keep their Run Records under their
33
+ // own runtime directory (src/campaign-workspace.mjs).
34
+ ".campaign-runtime/funnels/*/run-records/",
31
35
  ".campaign-runtime/fetched-specs/",
32
36
  ".campaign-runtime/progress/",
33
37
  ".campaign-runtime/polish-evidence/",
@@ -47,7 +51,7 @@ export const RUNTIME_STATE_IGNORED_PATHS = [
47
51
  // must gain it on the next setup command rather than on the next accidental
48
52
  // commit. An entry an operator has placed anywhere else in the file counts as
49
53
  // present.
50
- export const RUNTIME_STATE_REQUIRED_IGNORED_PATHS = ["qa-output/", ".campaign-runtime/progress/"];
54
+ export const RUNTIME_STATE_REQUIRED_IGNORED_PATHS = ["qa-output/", ".campaign-runtime/progress/", ".campaign-runtime/funnels/*/run-records/"];
51
55
 
52
56
  export const RUNTIME_STATE_IGNORE_BLOCK = [
53
57
  RUNTIME_STATE_IGNORE_MARKER,
@@ -2,10 +2,10 @@
2
2
  // checks (#303).
3
3
  //
4
4
  // Source: the SDK's own source in NextCommerceCo/campaign-cart at tag
5
- // v0.4.42 (the SDK pin every certified starter family ships today): every
6
- // data-next-* name the SDK's non-test TypeScript names as a string, plus every
7
- // `dataset.next*` property it reads or writes, turned back into its
8
- // attribute. Only the data-next-* names are kept: the checks that read this
5
+ // v0.4.43; the certified starter families and the SDK support policy still
6
+ // name 0.4.42, whose names are the same: every data-next-* name the SDK's non-test TypeScript names as a
7
+ // string, plus every `dataset.next*` property it reads or writes, turned back
8
+ // into its attribute. Only the data-next-* names are kept: the checks that read this
9
9
  // list ask "does the SDK read this data-next-* attribute at all", and the
10
10
  // SDK's other attribute families (data-item-*, data-package-*, plain data-*)
11
11
  // are not what an agent kit misspells.
@@ -15,8 +15,8 @@
15
15
  // whole from the source at a named tag, never edited by hand.
16
16
  //
17
17
  // paths=('src/**/*.ts' ':!src/**/*.test.ts' ':!src/**/tests/**' ':!src/docs/**')
18
- // { git -C ../campaign-cart grep -h -oP 'data-next-[a-z0-9]+(?:-[a-z0-9]+)*-?' v0.4.42 -- "${paths[@]}"
19
- // git -C ../campaign-cart grep -h -oP 'dataset\.next[A-Z][A-Za-z0-9]*' v0.4.42 -- "${paths[@]}" \
18
+ // { git -C ../campaign-cart grep -h -oP 'data-next-[a-z0-9]+(?:-[a-z0-9]+)*-?' v0.4.43 -- "${paths[@]}"
19
+ // git -C ../campaign-cart grep -h -oP 'dataset\.next[A-Z][A-Za-z0-9]*' v0.4.43 -- "${paths[@]}" \
20
20
  // | sed 's/^dataset\.//' | perl -pe 's/([A-Z])/-\L$1/g; s/^/data-/'
21
21
  // } | sort -u
22
22
  //
@@ -31,14 +31,14 @@
31
31
  // data-next-bump-section, data-next-button-text, the variant option state).
32
32
  // At v0.4.41 it returns every name of the v0.4.38 list this replaces, plus
33
33
  // those and data-next-phone-e164 / data-next-phone-country. At v0.4.42 it
34
- // returns the same 149 names as at v0.4.41.
34
+ // returns the same 149 names as at v0.4.41, and at v0.4.43 the same again.
35
35
  //
36
36
  // A name ending in "-" (data-next-class-) is a prefix the SDK reads with any
37
37
  // suffix.
38
38
 
39
39
  import { RELEASED_SDK_VERSION_PATTERN } from "../campaign-spec/dist/index.js";
40
40
 
41
- export const SDK_ATTRIBUTE_INDEX_VERSION = "0.4.42";
41
+ export const SDK_ATTRIBUTE_INDEX_VERSION = "0.4.43";
42
42
 
43
43
  export const SDK_DATA_NEXT_ATTRIBUTES = Object.freeze([
44
44
  "data-next-accordion",
@@ -365,8 +365,8 @@ export const SDK_TEMPLATE_PLACEHOLDERS = Object.freeze({
365
365
  });
366
366
 
367
367
  // The SDK versions whose renderer files above were verified unchanged from
368
- // v0.4.38 (git blob ids equal at v0.4.38, v0.4.39, v0.4.40, v0.4.41 and v0.4.42, for
369
- // the eight renderer files cited above plus cart-item-list.enhancer.ts and
368
+ // v0.4.38 (git blob ids equal at v0.4.38, v0.4.39, v0.4.40, v0.4.41, v0.4.42 and
369
+ // v0.4.43, for the eight renderer files cited above plus cart-item-list.enhancer.ts and
370
370
  // order-item-list.enhancer.ts). A page whose loader pins another version
371
371
  // cannot pass the placeholder check.
372
- export const SDK_TEMPLATE_PLACEHOLDERS_VERIFIED_PINS = Object.freeze(["0.4.38", "0.4.39", "0.4.40", "0.4.41", "0.4.42"]);
372
+ export const SDK_TEMPLATE_PLACEHOLDERS_VERIFIED_PINS = Object.freeze(["0.4.38", "0.4.39", "0.4.40", "0.4.41", "0.4.42", "0.4.43"]);
@@ -45,6 +45,18 @@
45
45
  // TEMPLATE_DOUBLE_BRACE `{{` inside an SDK-owned <template>. SDK tokens
46
46
  // are single-brace and conditions are no-brace;
47
47
  // a double brace renders literally.
48
+ // PAGE_TYPE_ATTRIBUTE data-next-page-type on a page with no
49
+ // <meta name="next-page-type"> (campaigns-os#683).
50
+ // The SDK never reads the attribute. The meta tag
51
+ // is the source every reader uses; a
52
+ // window.nextConfig.pageType reaches the config
53
+ // store only, not the upsell page view, so it
54
+ // does not stand in for the meta here.
55
+ // UPSELL_PAGE_TYPE_MISSING a data-next-upsell container on a page whose
56
+ // next-page-type meta is not "upsell". The SDK
57
+ // records the upsell page view only from that meta
58
+ // tag, so reports show the purchase with no upsell
59
+ // view before it.
48
60
  //
49
61
  // Not a finding: data-next-is-upsell="true" on a checkout order bump. The
50
62
  // selected bump is a line item on the checkout order, tagged as an upsell so
@@ -91,10 +103,34 @@ export const SDK_MARKUP_CODES = Object.freeze({
91
103
  RECEIPT_ORDER_ITEMS_CART_VISIBILITY: { code: `${SDK_MARKUP}.receipt_order_items_cart_visibility`, severity: "error" },
92
104
  DOUBLE_SELECTED: { code: `${SDK_MARKUP}.double_selected`, severity: "warning" },
93
105
  TEMPLATE_DOUBLE_BRACE: { code: `${SDK_MARKUP}.template_double_brace`, severity: "warning" },
106
+ PAGE_TYPE_ATTRIBUTE: { code: `${SDK_MARKUP}.page_type_attribute`, severity: "warning" },
107
+ UPSELL_PAGE_TYPE_MISSING: { code: `${SDK_MARKUP}.upsell_page_type_missing`, severity: "warning" },
94
108
  // Unknown data-next-* names are not a finding and carry no code: they are
95
109
  // information on the gate (unknown_attributes[]) and one advisory ready line.
96
110
  });
97
111
 
112
+ // The page types the SDK has (Campaign Cart v0.4.42 `PageType`), so a remedy
113
+ // never proposes a value the SDK does not have.
114
+ export const SDK_PAGE_TYPES = Object.freeze(["product", "cart", "checkout", "upsell", "receipt"]);
115
+ // Campaign page-type names this repository already reads as one of those, and
116
+ // only those; anything else is left for the operator to name. Sources:
117
+ // - downsell: a post-purchase page (POST_PURCHASE_PAGE_TYPES in
118
+ // upsell-selector-scope.mjs); the SDK records its page view only as upsell.
119
+ // - thank-you, thankyou, confirmation: the receipt role (normalizedPageRole in
120
+ // design-source-package.mjs).
121
+ // - presell, landing, advertorial: pre-checkout pages whose SDK page type is
122
+ // product (PRE_CHECKOUT_PAGE_TYPES in doctor/checks.mjs; normalizedPageRole).
123
+ export const SDK_PAGE_TYPE_ALIASES = Object.freeze({
124
+ downsell: "upsell",
125
+ "thank-you": "receipt", thankyou: "receipt", confirmation: "receipt",
126
+ presell: "product", landing: "product", advertorial: "product",
127
+ });
128
+
129
+ function sdkPageType(value) {
130
+ const key = String(value || "").trim().toLowerCase();
131
+ return SDK_PAGE_TYPES.includes(key) ? key : SDK_PAGE_TYPE_ALIASES[key] || null;
132
+ }
133
+
98
134
  // Attributes whose value names a <template> by id. The SDK reads the template
99
135
  // they point at, so that template is SDK-owned wherever it sits.
100
136
  export const TEMPLATE_ID_ATTRIBUTES = /^data-(?:next-)?[a-z0-9-]*template-id$/;
@@ -210,9 +246,48 @@ export function scanPageMarkup({ page_id, file = null, content = "", sdk_version
210
246
  const templates = []; // { entry, sdkOwned }
211
247
  const referencedTemplateIds = new Set();
212
248
  let receiptPage = String(page_type || "").trim().toLowerCase() === "receipt";
213
- walkElements(document, ({ tag, attrs: a }) => {
214
- if (tag === "meta" && a.get("name")?.toLowerCase() === "next-page-type" && a.get("content")?.trim().toLowerCase() === "receipt") receiptPage = true;
249
+ // The page's own next-page-type meta (first one, as querySelector reads it),
250
+ // the first data-next-page-type element and the first upsell container.
251
+ let pageTypeMeta = null;
252
+ let pageTypeAttribute = null;
253
+ let upsellContainer = null;
254
+ walkElements(document, (entry) => {
255
+ const { tag, attrs: a } = entry;
256
+ if (tag === "meta" && a.get("name")?.trim().toLowerCase() === "next-page-type") {
257
+ if (a.get("content")?.trim().toLowerCase() === "receipt") receiptPage = true;
258
+ if (pageTypeMeta === null) pageTypeMeta = (a.get("content") || "").trim();
259
+ }
260
+ if (!pageTypeAttribute && a.has("data-next-page-type")) pageTypeAttribute = entry;
261
+ if (!upsellContainer && a.has("data-next-upsell")) upsellContainer = entry;
215
262
  });
263
+ // The meta value the remedy names: a receipt is "receipt" and an upsell
264
+ // offer page "upsell", whatever the attribute says; otherwise the
265
+ // attribute's SDK page type, or none when it has no SDK name.
266
+ let attributeNamesUpsell = false;
267
+ if (pageTypeAttribute && pageTypeMeta === null) {
268
+ const value = (pageTypeAttribute.attrs.get("data-next-page-type") || "").trim();
269
+ const fromValue = sdkPageType(value);
270
+ const mapped = receiptPage ? "receipt" : upsellContainer ? "upsell" : fromValue;
271
+ attributeNamesUpsell = mapped === "upsell";
272
+ const note = !mapped || !value || value.toLowerCase() === mapped
273
+ ? ""
274
+ : fromValue === mapped
275
+ ? ` (the SDK's name for "${value}" is "${mapped}")`
276
+ : ` (this page is ${receiptPage ? "a receipt" : "an upsell offer, with a data-next-upsell container"}, so its SDK page type is "${mapped}")`;
277
+ const remedy = mapped
278
+ ? `Add <meta name="next-page-type" content="${mapped}"> to the page head${note}.`
279
+ : `Add <meta name="next-page-type"> to the page head with the SDK page type this page is: one of ${SDK_PAGE_TYPES.join(", ")}.`;
280
+ findings.push(finding("PAGE_TYPE_ATTRIBUTE", page_id, where,
281
+ `${describe(pageTypeAttribute)} on ${where} carries data-next-page-type="${value}", which the SDK does not read, and the page has no <meta name="next-page-type">. The SDK takes the page type from that meta tag, so this page runs as the default "product" step${attributeNamesUpsell ? " and the upsell page view is never recorded" : ""}. ${remedy}`,
282
+ { value, suggested: mapped, element: pageTypeAttribute.tag }));
283
+ }
284
+ // One finding per root cause: skipped only when the attribute finding
285
+ // above already names the upsell meta.
286
+ if (upsellContainer && !attributeNamesUpsell && (pageTypeMeta || "").toLowerCase() !== "upsell" && !receiptPage) {
287
+ findings.push(finding("UPSELL_PAGE_TYPE_MISSING", page_id, where,
288
+ `${describe(upsellContainer)} on ${where} is an upsell offer (data-next-upsell), but the page's next-page-type meta is ${pageTypeMeta === null ? "missing" : `"${pageTypeMeta}"`}. The SDK records the upsell page view only from <meta name="next-page-type" content="upsell">, so reports show the purchase with no upsell view before it. Add that meta tag to the page head.`,
289
+ { meta_content: pageTypeMeta }));
290
+ }
216
291
 
217
292
  walkElements(document, (entry) => {
218
293
  const { tag, attrs: a, ancestors } = entry;