@nextcommerce/campaigns-os 1.61.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 (127) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +118 -0
  3. package/README.md +86 -438
  4. package/agents/claude/CLAUDE.md +9 -4
  5. package/agents/codex/AGENTS.md +8 -4
  6. package/agents/copilot/copilot-instructions.md +8 -4
  7. package/agents/cursor/campaigns-os.mdc +10 -4
  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/commerce-surface-catalog.json +59 -53
  26. package/contracts/effects.v1.json +549 -165
  27. package/contracts/fixtures/runtime-recipe/accept/current.json +3 -0
  28. package/contracts/fixtures/runtime-recipe/accept/minimal.json +3 -0
  29. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  30. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +3 -0
  31. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +3 -0
  32. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +3 -0
  33. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +3 -0
  34. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +3 -0
  35. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +3 -0
  36. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +3 -0
  37. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +3 -0
  38. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +3 -0
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +3 -0
  40. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +3 -0
  41. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +3 -0
  42. package/contracts/release-ledger.json +999 -0
  43. package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -0
  44. package/contracts/supported-surface.json +8 -8
  45. package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
  46. package/docs/build-packet.md +430 -37
  47. package/docs/campaign-standardization-report.md +4 -4
  48. package/docs/campaigns-os-build-flow.md +54 -3
  49. package/docs/campaignspec-authoring-examples.md +29 -4
  50. package/docs/design-source-package.md +18 -6
  51. package/docs/effects.md +17 -9
  52. package/docs/local-setup.md +1 -1
  53. package/docs/orientation-contract-reference.md +1 -1
  54. package/docs/polish-evidence.md +2 -0
  55. package/docs/progress-snapshots.md +7 -4
  56. package/docs/qa-and-test-orders.md +147 -1
  57. package/docs/readback.md +40 -5
  58. package/docs/runtime-readiness.md +6 -3
  59. package/docs/sdk-storage-compatibility.md +1 -1
  60. package/docs/skills-revision.md +10 -10
  61. package/package.json +1 -1
  62. package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
  63. package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
  64. package/schemas/campaign-spec.v4.schema.json +19 -3
  65. package/schemas/campaign-spec.v5.schema.json +19 -3
  66. package/schemas/campaigns-os-readback.v2.schema.json +62 -1
  67. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  68. package/skills/campaign-readback-classification/SKILL.md +3 -3
  69. package/skills/campaign-run-evidence/SKILL.md +3 -3
  70. package/skills/contribution-intake/SKILL.md +3 -3
  71. package/skills/next-campaigns-build/SKILL.md +4 -3
  72. package/skills/next-campaigns-os/SKILL.md +6 -4
  73. package/skills/next-campaigns-os-setup/SKILL.md +5 -3
  74. package/skills/next-campaigns-polish/SKILL.md +5 -4
  75. package/skills/next-campaigns-qa/SKILL.md +7 -4
  76. package/skills/next-campaigns-sdk-bump/SKILL.md +42 -9
  77. package/skills.json +11 -11
  78. package/src/brand-theme.mjs +2 -1
  79. package/src/build-brief.mjs +4 -1
  80. package/src/campaign-workspace.mjs +288 -15
  81. package/src/change-scope.mjs +284 -0
  82. package/src/cli.mjs +308 -86
  83. package/src/commercial-journey.mjs +16 -3
  84. package/src/commercial-parity.mjs +15 -7
  85. package/src/content-residue.mjs +4 -2
  86. package/src/design-source-package.mjs +36 -0
  87. package/src/design-source-publication.mjs +9 -6
  88. package/src/directory-lock.mjs +73 -19
  89. package/src/doctor/checks.mjs +79 -7
  90. package/src/doctor/ci.mjs +75 -10
  91. package/src/doctor/inspect.mjs +23 -6
  92. package/src/doctor/next-step.mjs +7 -0
  93. package/src/doctor-sidecar.mjs +3 -2
  94. package/src/evidence-debt.mjs +47 -7
  95. package/src/finding-cause.mjs +12 -11
  96. package/src/input-refresh.mjs +339 -32
  97. package/src/invocation.mjs +7 -1
  98. package/src/live-campaign-refs.mjs +7 -2
  99. package/src/local-preview-policy.mjs +35 -2
  100. package/src/page-kit-analytics-vendors.mjs +132 -0
  101. package/src/polish-browser.mjs +3 -1
  102. package/src/polish-readability.mjs +221 -9
  103. package/src/polish-visual.mjs +9 -2
  104. package/src/progress-node.mjs +3 -2
  105. package/src/qa-analytics-parity.mjs +17 -1
  106. package/src/qa-browser.mjs +40 -4
  107. package/src/qa-commercial-parity.mjs +50 -3
  108. package/src/qa-node.mjs +196 -16
  109. package/src/qa-publish.mjs +9 -8
  110. package/src/qa-sidecar.mjs +16 -4
  111. package/src/qa-vendor-beacons.mjs +232 -0
  112. package/src/qa-vendor-endpoints.mjs +306 -0
  113. package/src/qc-accept.mjs +29 -1
  114. package/src/qc-check-registry.mjs +5 -1
  115. package/src/qc-results.mjs +73 -26
  116. package/src/readback.mjs +58 -10
  117. package/src/route-identity.mjs +10 -0
  118. package/src/run-record.mjs +18 -10
  119. package/src/runtime-state-ignore.mjs +6 -2
  120. package/src/sdk-storage-compatibility.mjs +21 -2
  121. package/src/source-html-intake.mjs +15 -1
  122. package/src/spec-derive-campaign.mjs +216 -0
  123. package/src/spec-derive.mjs +473 -53
  124. package/src/spec-identity.mjs +14 -0
  125. package/src/stage-ledger.mjs +40 -1
  126. package/src/stage-record.mjs +58 -13
  127. package/src/target-lock.mjs +11 -1
@@ -21,7 +21,7 @@ import { createHash } from "node:crypto";
21
21
  import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
22
22
  import { isAbsolute, relative, resolve } from "node:path";
23
23
 
24
- import { campaignSidecarPaths } from "./campaign-workspace.mjs";
24
+ import { campaignSidecarPaths, packetRuntimeRelPath } from "./campaign-workspace.mjs";
25
25
  import { checkpointStateFingerprint } from "./checkpoint-waiver.mjs";
26
26
  import { contrastToolkit } from "./contrast.mjs";
27
27
  import { sameFile } from "./fs-identity.mjs";
@@ -299,10 +299,14 @@ export function readQaFullVerdict({ stage, targetRepo, packetPath = null, report
299
299
  const stats = statSync(real);
300
300
  if (!stats.isFile()) return null;
301
301
  if (!inside(realpathSync(outputDir), real)) return null;
302
+ // This funnel's default report and every other funnel's project-level
303
+ // one (#719): neither is a verdict.
304
+ const packet = isNonEmptyString(packetPath) ? readPacketForGuard(packetPath) : null;
302
305
  const forbidden = [
303
306
  reportPath,
304
307
  campaignSidecarPaths(targetRepo).reportPath,
305
- isNonEmptyString(packetPath) ? sidecarPathForPacket(packetPath) : null,
308
+ packet ? campaignSidecarPaths(targetRepo, packetRuntimeRelPath(packet)).reportPath : null,
309
+ isNonEmptyString(packetPath) ? sidecarPathForPacket(packetPath, packet) : null,
306
310
  ...outputs.filter((path) => path !== candidates[0]),
307
311
  ].filter(isNonEmptyString);
308
312
  for (const path of forbidden) {
@@ -317,6 +321,15 @@ export function readQaFullVerdict({ stage, targetRepo, packetPath = null, report
317
321
  }
318
322
  }
319
323
 
324
+ function readPacketForGuard(packetPath) {
325
+ try {
326
+ const parsed = JSON.parse(readFileSync(packetPath, "utf8"));
327
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
328
+ } catch {
329
+ return null;
330
+ }
331
+ }
332
+
320
333
  // `qaCurrency` is QA's read-time input currency
321
334
  // (derived.input_currency.stages.qa). While QA is owed again or its inputs
322
335
  // cannot be confirmed, every row that reproduces reads stale_binding, as a
@@ -1038,30 +1051,46 @@ function readabilityCropResolves(ref, targetRepo) {
1038
1051
  // the record lacks, or one over the route cap, reads page_not_captured;
1039
1052
  // a recorded route outside it, or different uncaptured routes or
1040
1053
  // enumeration cap, → evidence_not_reproducible;
1041
- // 4. the rows READABILITY_QC_RULES.evaluate derives from each cell.
1054
+ // 4. the rows READABILITY_QC_RULES.evaluate derives from each cell, and,
1055
+ // for a record that stores layout_conventions, the layout.conventions
1056
+ // rows LAYOUT_QC_RULES.evaluate derives from it.
1042
1057
  // `campaignSlug` is the packet's slug; a missing one never matches.
1043
- export function readReadability({ record, pageLoad = null, currentBuild = null, currentSource = null, buildOutput = null, campaignSlug = null, targetRepo = null, qcStandIns = null, rederivers = null } = {}) {
1058
+ export function readReadability({ record, pageLoad = null, currentBuild = null, currentSource = null, buildOutput = undefined, campaignSlug = null, targetRepo = null, qcStandIns = null, rederivers = null } = {}) {
1044
1059
  if (record === undefined || record === null) return [];
1045
- const [check] = POLISH_RECORDS.readability.checks;
1060
+ const [check, layoutCheck = null] = POLISH_RECORDS.readability.checks;
1046
1061
  const measuredAt = canonicalTime(record?.measured_at) ? record.measured_at : null;
1047
1062
  const cells = Array.isArray(record?.cells) ? record.cells.filter(isPlainObject) : [];
1048
- const subjectOf = (page, viewport, key) => ({ check, page, viewport, key });
1049
- const rowOf = (subject, reasonCode) => unreproducedRow({ subject, check }, reasonCode, { leg: "polish", measuredAt });
1063
+ const subjectOf = (page, viewport, key, of = check) => ({ check: of, page, viewport, key });
1064
+ const rowOf = (subject, reasonCode) => unreproducedRow({ subject, check: subject.check }, reasonCode, { leg: "polish", measuredAt });
1050
1065
  const unique = (rows) => [...new Map(rows.map((row) => [row.id, row])).values()];
1051
1066
  let rules = null;
1067
+ // The layout check reads only a record that stores the conventions it was
1068
+ // measured with; its rules load on their own.
1069
+ const layoutRecorded = isPlainObject(record) && Object.hasOwn(record, "layout_conventions") && isNonEmptyString(layoutCheck);
1070
+ const layoutResolved = layoutRecorded ? resolvePolishRules({ check: layoutCheck, qcStandIns, rederivers }) : { status: "missing" };
1071
+ const layoutRules = layoutResolved.status === "loaded" ? layoutResolved.rules : null;
1072
+ const layoutRead = layoutRecorded && layoutResolved.status !== "missing";
1073
+ const namedKeys = (rowKeys, cell) => {
1074
+ try {
1075
+ const named = typeof rowKeys === "function" ? rowKeys(cell) : [];
1076
+ return Array.isArray(named) ? named.filter(isNonEmptyString) : [];
1077
+ } catch {
1078
+ return [];
1079
+ }
1080
+ };
1081
+ // A failed cell's layout rows: one per row the layout rules name for the
1082
+ // cell (its `cell` row among them; none at a viewport no convention
1083
+ // applies to), or its `cell` row when the rules did not load.
1084
+ const failLayout = (cell) => (layoutRead
1085
+ ? [...new Set(layoutRules ? namedKeys(layoutRules.rowKeys, cell) : ["cell"])].map((key) => rowOf(subjectOf(cell?.route ?? null, cell?.viewport ?? null, key, layoutCheck), E))
1086
+ : []);
1052
1087
  // A failed cell: one result per row the cell names and its `cell` row, so
1053
1088
  // a failed cell is never silent and an accept of one of its rows lapses
1054
1089
  // rather than orphans. The keys come from rules.rowKeys, which reads
1055
1090
  // neither the record's thresholds nor any field the checks reject.
1056
1091
  const failCell = (cell) => {
1057
- let named = [];
1058
- try {
1059
- named = typeof rules?.rowKeys === "function" ? rules.rowKeys(cell) : [];
1060
- } catch {
1061
- named = [];
1062
- }
1063
- const keys = [...new Set([...(Array.isArray(named) ? named.filter(isNonEmptyString) : []), "cell"])];
1064
- return keys.map((key) => rowOf(subjectOf(cell?.route ?? null, cell?.viewport ?? null, key), E));
1092
+ const keys = [...new Set([...namedKeys(rules?.rowKeys, cell), "cell"])];
1093
+ return [...keys.map((key) => rowOf(subjectOf(cell?.route ?? null, cell?.viewport ?? null, key), E)), ...failLayout(cell)];
1065
1094
  };
1066
1095
  // A record-level failure: every listed cell fails; every route the subject
1067
1096
  // or a cell names, at every recorded or fixed viewport, that no cell lists
@@ -1124,7 +1153,8 @@ export function readReadability({ record, pageLoad = null, currentBuild = null,
1124
1153
  || typeof record.route_enumeration_capped !== "boolean"
1125
1154
  || !Array.isArray(record.cells)
1126
1155
  || cells.length !== record.cells.length
1127
- || !cells.every((cell) => rules.cellShapeOk(cell))) return failAll();
1156
+ || !cells.every((cell) => rules.cellShapeOk(cell))
1157
+ || (layoutRecorded && layoutRules && !sameJson(record.layout_conventions, layoutRules.thresholds))) return failAll();
1128
1158
  // The cell set is exactly routes × viewports: none missing, none added,
1129
1159
  // none twice, every cell's route and viewport in the subject.
1130
1160
  const gridKeys = declaredGrid(subject).map(({ route, viewport }) => cellKey(route, viewport));
@@ -1151,7 +1181,9 @@ export function readReadability({ record, pageLoad = null, currentBuild = null,
1151
1181
  const bound = isNonEmptyString(currentBuild)
1152
1182
  && subject.build_fingerprint === currentBuild
1153
1183
  && subject.source_package_material_fingerprint === (isNonEmptyString(currentSource) ? currentSource : null)
1154
- && !outputDrifted(buildOutput ?? null);
1184
+ // As in the captures reader: no buildOutput (undefined) reads without the
1185
+ // output check; null is drift.
1186
+ && !outputDrifted(buildOutput);
1155
1187
 
1156
1188
  // 3. The recomputed route set, read only when the output is the build's.
1157
1189
  const results = [];
@@ -1182,22 +1214,37 @@ export function readReadability({ record, pageLoad = null, currentBuild = null,
1182
1214
  }
1183
1215
  const derived = evaluate(cell, cropAvailable);
1184
1216
  // Every derived subject is the cell's own page and viewport, each key once.
1185
- const keys = Array.isArray(derived) ? derived.map((item) => item?.subject?.key) : [];
1186
- if (!Array.isArray(derived) || new Set(keys).size !== keys.length || !derived.every((item) => item?.check === check
1187
- && validDerived(item, check)
1188
- && hasExactly(item.subject, ["check", "page", "viewport", "key"])
1189
- && item.subject.page === cell.route
1190
- && item.subject.viewport === cell.viewport
1191
- && isNonEmptyString(item.subject.key))) {
1217
+ const derivedOk = (items, of) => {
1218
+ const keys = Array.isArray(items) ? items.map((item) => item?.subject?.key) : [];
1219
+ return Array.isArray(items) && new Set(keys).size === keys.length && items.every((item) => item?.check === of
1220
+ && validDerived(item, of)
1221
+ && hasExactly(item.subject, ["check", "page", "viewport", "key"])
1222
+ && item.subject.page === cell.route
1223
+ && item.subject.viewport === cell.viewport
1224
+ && isNonEmptyString(item.subject.key));
1225
+ };
1226
+ if (!derivedOk(derived, check)) {
1192
1227
  results.push(...failCell(cell));
1193
1228
  continue;
1194
1229
  }
1195
- for (const item of derived) {
1230
+ let layoutDerived = [];
1231
+ if (layoutRead) {
1232
+ try {
1233
+ layoutDerived = layoutRules ? layoutRules.evaluate(cell, record.layout_conventions) : null;
1234
+ } catch {
1235
+ layoutDerived = null;
1236
+ }
1237
+ if (!derivedOk(layoutDerived, layoutCheck)) {
1238
+ results.push(...failLayout(cell));
1239
+ layoutDerived = [];
1240
+ }
1241
+ }
1242
+ for (const item of [...derived, ...layoutDerived]) {
1196
1243
  const members = item.members ?? [];
1197
1244
  const row = {
1198
1245
  schema: QC_RESULT_SCHEMA,
1199
1246
  id: qcResultId(item.subject),
1200
- check,
1247
+ check: item.check,
1201
1248
  leg: "polish",
1202
1249
  result: item.result,
1203
1250
  reason_code: item.result === "pass" ? null : item.reason_code,
package/src/readback.mjs CHANGED
@@ -56,6 +56,8 @@ import { fileURLToPath } from "node:url";
56
56
  import { assessInputCurrency, currentPacketInputs, effectiveStageStatus } from "./input-currency.mjs";
57
57
  import { deriveAssemblyReportSummary } from "./stage-ledger.mjs";
58
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";
59
61
 
60
62
  export const MAX_ARTIFACT_BYTES = 32 * 1024 * 1024;
61
63
  export const MAX_GIT_METADATA_BYTES = 64 * 1024;
@@ -193,7 +195,9 @@ export function readReflogTail(path) {
193
195
 
194
196
  // Artifact keys, in render order, with the default location of each artifact
195
197
  // relative to the target repository root. The packet lives at the root; the
196
- // 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.
197
201
  export const DEFAULT_RELATIVE_PATHS = {
198
202
  packet: "campaign-runtime.build.json",
199
203
  doctor: ".campaign-runtime/doctor-output.json",
@@ -221,8 +225,10 @@ export const RECOGNIZED_SCHEMA_VERSIONS = {
221
225
  };
222
226
 
223
227
  // Root-level Build Packets other than the default-named one. A second run in
224
- // the same repository leaves its record under a suffixed name; both shapes are
225
- // 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.
226
232
  const PACKET_CANDIDATE_PATTERN = /^campaign-runtime-.*\.build\.json$/;
227
233
 
228
234
  // Sidecar path some write-ends have used instead of the contracted root home.
@@ -1304,6 +1310,13 @@ function renderEvidenceDebt(debt, lines) {
1304
1310
  : `evidence currency is unknown (${stage.reason}); absence of proof is not no debt`;
1305
1311
  lines.push(` ${name}: ${stage.status} — ${explanation}`);
1306
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
+ }
1307
1320
  }
1308
1321
  const changes = debt.changes;
1309
1322
  lines.push(` changes against assembly manifest: ${changes.status}${changes.reason ? ` (${changes.reason})` : ""}`);
@@ -1642,7 +1655,8 @@ export function serializeStaleness(staleness) {
1642
1655
  * or unrecognized one always is); the staleness comparison is computable and NO
1643
1656
  * loaded artifact is stale; NO loaded artifact recorded a `generated_at` this
1644
1657
  * readback could not parse; no cross-artifact divergence was found; and the
1645
- * doctor output records zero errors; and no evidence-debt stage is owed.
1658
+ * doctor output records zero errors; and no evidence-debt stage is owed
1659
+ * (advisory Polish for a page-only change does not count).
1646
1660
  * Unknown build binding does not independently change this integrity flag;
1647
1661
  * callers must read evidence_debt rather than treating clean as no debt.
1648
1662
  *
@@ -1664,7 +1678,8 @@ export function serializeStaleness(staleness) {
1664
1678
  */
1665
1679
  export function computeClean(views, staleness, evidenceDebt) {
1666
1680
  if (evidenceDebt == null) throw new TypeError("computeClean requires an evidence-debt assessment");
1667
- if ([evidenceDebt?.polish, evidenceDebt?.qa].some((stage) => stage?.status === "owed")) return false;
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;
1668
1683
  if (Object.values(views).some((view) => !CLEAN_ARTIFACT_STATES.has(view.state))) return false;
1669
1684
  if (!staleness || !staleness.computable || staleness.stale) return false;
1670
1685
  if (staleness.unparseable_keys.length) return false;
@@ -1835,6 +1850,18 @@ export function selectPacketPath(rootPath, override) {
1835
1850
 
1836
1851
  if (considered.length === 1) return chose(considered[0], "sole_candidate");
1837
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
+
1838
1865
  if (considered.some((name) => times.get(name) === null)) {
1839
1866
  const detail = considered
1840
1867
  .map((name) => {
@@ -1869,24 +1896,45 @@ export function selectPacketPath(rootPath, override) {
1869
1896
  /**
1870
1897
  * Return `{ paths, packetSelection, packetView }` for one target root.
1871
1898
  *
1872
- * `packetView` is the Build Packet view discovery already loaded, ready to hand
1873
- * to loadArtifacts as `preloaded` so no artifact is read twice; it is null when
1874
- * 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.
1875
1904
  */
1876
1905
  export function resolveProjection(root, overrides = {}) {
1877
1906
  const info = statOrNull(root);
1878
1907
  if (!info || !info.isDirectory()) {
1879
1908
  throw new ReadbackUsageError(`target repository root must name an existing directory: ${root}`);
1880
1909
  }
1881
- 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);
1882
1916
  const paths = { packet: packetPath };
1883
1917
  for (const [key, relative] of Object.entries(DEFAULT_RELATIVE_PATHS)) {
1884
1918
  if (key === "packet") continue;
1885
- 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));
1886
1920
  }
1887
1921
  return { paths, packetSelection: selection, packetView: view };
1888
1922
  }
1889
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
+
1890
1938
  /** The artifact paths to project; discovery result discarded. */
1891
1939
  export function resolvePaths(root, overrides = {}) {
1892
1940
  return resolveProjection(root, overrides).paths;
@@ -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,
@@ -7,6 +7,7 @@ import { readFileSync, lstatSync, realpathSync, statSync } from 'node:fs';
7
7
  import { resolve, relative, dirname, posix, sep } from 'node:path';
8
8
  import { createHash } from 'node:crypto';
9
9
  import { isUtf8 } from 'node:buffer';
10
+ import { singleLineFragment } from './text-safety.mjs';
10
11
  const digest = value => createHash('sha256').update(value).digest('hex');
11
12
  const version = value => {
12
13
  if (typeof value !== 'string' || !/^\d+\.\d+\.\d+$/.test(value))
@@ -361,6 +362,19 @@ function frontmatterScripts(text) {
361
362
  // linePos is 1-based within the YAML block, which starts on the file's line 2.
362
363
  return { scripts: scripts.items.map(item => ({ value: item.value, line: lineCounter.linePos(item.range[0]).line + 1 })) };
363
364
  }
365
+ const INVENTORY_MAX_BYTES = 256 * 1024 * 1024;
366
+ const INVENTORY_DETAIL_MAX = 200;
367
+
368
+ // A missing git already fails at rev-parse, above the inventory.
369
+ export function inventoryFailure(error) {
370
+ if (error.code === 'ENOBUFS') return `the tracked-file list under --scope is larger than ${INVENTORY_MAX_BYTES / 1024 / 1024} MiB; narrow --scope.`;
371
+ const stderr = typeof error.stderr === 'string' ? error.stderr : error.stderr?.toString() ?? '';
372
+ const detail = singleLineFragment(stderr.trim().split('\n')[0], `git exited ${error.status ?? error.signal ?? error.code ?? 'abnormally'} with no message`);
373
+ const chars = Array.from(detail);
374
+ const bounded = chars.length > INVENTORY_DETAIL_MAX ? `${chars.slice(0, INVENTORY_DETAIL_MAX).join('')}…` : detail;
375
+ return `${bounded.replace(/\.$/, '')}.`;
376
+ }
377
+
364
378
  export function scanSdkStorageCompatibility({ cwd = process.cwd(), targetSdkVersion, manifestPath, scope, exclude = [] }) {
365
379
  version(targetSdkVersion);
366
380
  if (!Array.isArray(scope) || !scope.length)
@@ -375,9 +389,14 @@ export function scanSdkStorageCompatibility({ cwd = process.cwd(), targetSdkVers
375
389
  if (rootIdentity.dev !== targetIdentity.dev || rootIdentity.ino !== targetIdentity.ino)
376
390
  throw new Error('--target must be the Git repository root.');
377
391
  const { value: manifest, evidence } = readStorageManifest(manifestPath);
392
+ // Inventory only the scope: a whole-repository list can pass Node's 1 MiB
393
+ // default buffer on a large repository (#717). Scopes are literal paths.
378
394
  let tracked;
379
- try { tracked = execFileSync('git', ['-C', root, 'ls-files', '-z'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).split('\0').filter(Boolean); }
380
- catch { throw new Error('Cannot inventory tracked Git source files.'); }
395
+ try {
396
+ tracked = execFileSync('git', ['--literal-pathspecs', '-C', root, 'ls-files', '-z', '--', ...scope], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: INVENTORY_MAX_BYTES }).split('\0').filter(Boolean);
397
+ } catch (error) {
398
+ throw new Error(`Cannot inventory tracked Git source files: ${inventoryFailure(error)}`);
399
+ }
381
400
  for (const item of scope)
382
401
  if (!tracked.some(p => inside(p, item)))
383
402
  throw new Error(`Scope has no tracked files: ${item}`);
@@ -559,6 +559,20 @@ function addSpecHints(mapping, page) {
559
559
  if (variantLabels) mapping.variant_labels = variantLabels;
560
560
  }
561
561
 
562
+ /**
563
+ * The page-kit projection of every page in `specPages` (a Map of page id to
564
+ * projection), as intake projects a mapped page: target path, public route,
565
+ * page_type and the frontmatter links to the other pages. `record spec`
566
+ * re-projects with it when routes move or, after handoff, when pages are
567
+ * added or removed (#531, #721); every page is re-projected because a page's
568
+ * next_url and decline_url read the routes of the pages it links to.
569
+ */
570
+ export function projectPageKitTargets({ specPages = [], publicRouteSlug, outputDir }) {
571
+ const pages = Array.isArray(specPages) ? specPages : [];
572
+ const pageById = new Map(pages.map((page) => [page.id, page]));
573
+ return new Map(pages.map((page) => [page.id, pageKitProjectionForPage(page, { pageById, publicRouteSlug, outputDir, specPages: pages })]));
574
+ }
575
+
562
576
  function pageKitProjectionForPage(page, { pageById, publicRouteSlug, outputDir, specPages = [] }) {
563
577
  const specRoute = publicRouteForPage(page);
564
578
  const relativeRoute = stripPublicRoutePrefix(specRoute, publicRouteSlug);
@@ -707,7 +721,7 @@ function normalizeRootedRoute(value) {
707
721
  return normalized ? `/${normalized}` : "/";
708
722
  }
709
723
 
710
- function cpkPageTypeForSpecType(type) {
724
+ export function cpkPageTypeForSpecType(type) {
711
725
  if (type === "presell" || type === "landing") return "product";
712
726
  if (type === "thankyou" || type === "receipt") return "receipt";
713
727
  if (type === "upsell" || type === "downsell") return "upsell";