@nextcommerce/campaigns-os 1.62.0 → 1.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/campaign-spec/dist/rules/campaign-currencies.d.ts +1 -0
  3. package/campaign-spec/dist/rules/campaign-currencies.js +1 -1
  4. package/campaign-spec/dist/rules/index.d.ts +1 -0
  5. package/campaign-spec/dist/rules/index.js +3 -0
  6. package/campaign-spec/dist/rules/package-currency-prices.d.ts +29 -0
  7. package/campaign-spec/dist/rules/package-currency-prices.js +137 -0
  8. package/campaign-spec/dist/types.d.ts +16 -0
  9. package/compatibility.json +1 -1
  10. package/contracts/effects.v1.json +129 -1
  11. package/contracts/fixtures/runtime-recipe/accept/current.json +1 -0
  12. package/contracts/fixtures/runtime-recipe/accept/minimal.json +1 -0
  13. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  14. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +1 -0
  15. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +1 -0
  16. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +1 -0
  17. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +1 -0
  18. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +1 -0
  19. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +1 -0
  20. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +1 -0
  21. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +1 -0
  22. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +1 -0
  23. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +1 -0
  24. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +1 -0
  25. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +1 -0
  26. package/contracts/release-ledger.json +810 -0
  27. package/contracts/runtime-recipe.campaigns-os-node-v1.json +1 -0
  28. package/contracts/supported-surface.json +6 -6
  29. package/docs/build-packet.md +31 -5
  30. package/docs/campaigns-os-build-flow.md +38 -0
  31. package/docs/campaignspec-authoring-examples.md +31 -0
  32. package/docs/effects.md +8 -0
  33. package/docs/local-setup.md +1 -1
  34. package/docs/orientation-contract-reference.md +1 -1
  35. package/docs/qa-and-test-orders.md +44 -10
  36. package/docs/readback.md +8 -1
  37. package/docs/runtime-readiness.md +4 -3
  38. package/docs/skills-revision.md +10 -10
  39. package/docs/supported-surface.md +1 -1
  40. package/package.json +1 -1
  41. package/schemas/campaign-spec.v4.schema.json +15 -0
  42. package/schemas/campaign-spec.v5.schema.json +15 -0
  43. package/schemas/campaigns-os-qa-verdict.v0.schema.json +17 -2
  44. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  45. package/skills/campaign-readback-classification/SKILL.md +3 -3
  46. package/skills/campaign-run-evidence/SKILL.md +3 -3
  47. package/skills/contribution-intake/SKILL.md +3 -3
  48. package/skills/next-campaigns-build/SKILL.md +3 -3
  49. package/skills/next-campaigns-os/SKILL.md +3 -3
  50. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  51. package/skills/next-campaigns-polish/SKILL.md +3 -3
  52. package/skills/next-campaigns-qa/SKILL.md +4 -4
  53. package/skills/next-campaigns-sdk-bump/SKILL.md +3 -3
  54. package/skills.json +11 -11
  55. package/src/change-scope.mjs +25 -6
  56. package/src/cli.mjs +120 -7
  57. package/src/commercial-journey.mjs +3 -0
  58. package/src/commercial-parity.mjs +157 -7
  59. package/src/doctor/checks.mjs +28 -3
  60. package/src/doctor/ci.mjs +36 -1
  61. package/src/evidence-debt.mjs +9 -1
  62. package/src/input-currency.mjs +4 -2
  63. package/src/input-refresh.mjs +1 -1
  64. package/src/invocation.mjs +3 -2
  65. package/src/orchestration-stage-contract.mjs +3 -1
  66. package/src/polish-gate.mjs +10 -0
  67. package/src/qa-browser.mjs +46 -0
  68. package/src/qa-commercial-parity.mjs +173 -18
  69. package/src/qa-node.mjs +10 -5
  70. package/src/qa-verdict.mjs +3 -1
  71. package/src/readback.mjs +1 -0
  72. package/src/runtime-state-ignore.mjs +44 -19
  73. package/src/stage-ledger.mjs +35 -9
  74. package/src/stage-record.mjs +93 -14
@@ -9,8 +9,12 @@ import {
9
9
  import {
10
10
  COMMERCIAL_PARITY_SCHEMA_VERSION,
11
11
  CommercialParityLimitError,
12
+ PACKAGE_PRICE_NOT_FILLED,
13
+ PACKAGE_PRICE_NOT_RENDERED,
14
+ PACKAGE_PRICE_UNRESOLVED,
12
15
  createCommercialParityReport,
13
16
  extractCommercialClaims,
17
+ packagePriceClaimCount,
14
18
  recurringClaimAbsences,
15
19
  recurringClaimAbsentAssertions,
16
20
  } from "./commercial-parity.mjs";
@@ -450,6 +454,7 @@ function catalogImportedAt(spec) {
450
454
 
451
455
  function captureClaimCount(capture) {
452
456
  return array(capture?.price_claims).length
457
+ + packagePriceClaimCount(capture)
453
458
  + array(capture?.recurrence_claims).length
454
459
  + array(capture?.vouchers).length;
455
460
  }
@@ -466,6 +471,108 @@ function captureIssues(captures) {
466
471
  return issues;
467
472
  }
468
473
 
474
+ // One reason a run's commercial proof stopped short: what was not compared and
475
+ // what to do about it, for an agent that reads only the exceptions list. Text
476
+ // only, never a key or a URL: it is persisted.
477
+ function cause(code, summary, fix, count = 1) {
478
+ return { code, count, summary, fix };
479
+ }
480
+
481
+ // What each reason a package.price went uncompared means for the operator.
482
+ const PACKAGE_PRICE_CAUSES = Object.freeze({
483
+ [PACKAGE_PRICE_NOT_RENDERED]: {
484
+ summary: (count) => `${count} package.price claim(s) in the page source were not read from a rendered page, so they were not compared.`,
485
+ fix: "Run qa run with --browser. If it did, check that the tested URL served this page; prices hidden on purpose need no repair.",
486
+ },
487
+ [PACKAGE_PRICE_NOT_FILLED]: {
488
+ summary: (count) => `The SDK never filled ${count} visible package.price node(s), so a shopper may be reading the template's placeholder price.`,
489
+ fix: "Check that the SDK loaded and that the package ref is on the campaign, then re-run qa run.",
490
+ },
491
+ [PACKAGE_PRICE_UNRESOLVED]: {
492
+ summary: (count) => `${count} package.price claim(s) could not be tied to one calculated price: no package id around the node, no single exact row for the package on the page, or text that is not a money amount.`,
493
+ fix: "Put data-next-package-id around the price, or check it by hand.",
494
+ },
495
+ });
496
+
497
+ const OVER_LIMITS_FIX = "This funnel is over the check's limits, so no re-run will cover it: check these prices by hand and tell the operator.";
498
+
499
+ /**
500
+ * The causes of an incomplete proof, the ones found while running first. Later
501
+ * causes stay quiet when an earlier one explains them, so a missing key does not
502
+ * also report every page as unpriced. Not every cause has a repair: a store
503
+ * coupon or a run in another currency only says what was not compared.
504
+ */
505
+ function incompleteCauses(stopped, commercial, journey) {
506
+ const causes = [...stopped];
507
+ // A page whose capture was refused also counts as missing from the comparison.
508
+ const refused = new Set(array(commercial.invalid_captures).map((capture) => capture.page_id));
509
+ const unread = [
510
+ ...array(commercial.invalid_captures),
511
+ ...array(commercial.missing_pages).filter((page) => !refused.has(page.page_id)),
512
+ ];
513
+ if (unread.length) {
514
+ causes.push(cause(
515
+ "commercial_pages_unread",
516
+ `${unread.length} page(s) could not be read for commercial claims: ${unread.map((page) => page.page_id ?? "(no id)").join(", ")}.`,
517
+ "Fix the page's fetch failure, its URL in the spec or its malformed markup, then re-run qa run. A page over the claim limit needs its prices checked by hand.",
518
+ unread.length,
519
+ ));
520
+ }
521
+ if (causes.length) return causes;
522
+
523
+ const inexact = array(journey?.pages).filter((page) => page?.state !== PricingState.Exact);
524
+ if (inexact.length) {
525
+ const named = inexact.map((page) => `${page.page_id} (${page.reason || page.representative_total?.reason || page.state})`);
526
+ return [cause(
527
+ "commercial_page_not_exact",
528
+ `Calculate gave no exact total for ${inexact.length} page(s): ${named.join(", ")}.`,
529
+ "Correct the spec where the reason names it. A store coupon cannot be previewed, so check that page's prices by hand. Re-run qa run if the reason is Stale.",
530
+ inexact.length,
531
+ )];
532
+ }
533
+ if (present(commercial.unresolved_money_claims_reason)) {
534
+ const count = commercial.unresolved_price_claims + commercial.unresolved_recurrence_claims;
535
+ return [cause(
536
+ String(commercial.unresolved_money_claims_reason),
537
+ `${count} price claim(s) in the page source hold the Map currency's fallback text, so they were not compared with totals in the run currency.`,
538
+ "No page repair is owed. A qa run in the campaign's own currency compares them.",
539
+ count,
540
+ )];
541
+ }
542
+ const packagePrices = Object.entries(commercial.package_price_claims?.unresolved || {});
543
+ packagePrices.forEach(([code, count]) => {
544
+ causes.push(cause(code, PACKAGE_PRICE_CAUSES[code].summary(count), PACKAGE_PRICE_CAUSES[code].fix, count));
545
+ });
546
+ const unresolvedPrices = commercial.unresolved_price_claims - packagePrices.reduce((sum, [, count]) => sum + count, 0);
547
+ const unresolved = unresolvedPrices + commercial.unresolved_recurrence_claims + commercial.unresolved_voucher_claims;
548
+ if (unresolved > 0) {
549
+ causes.push(cause(
550
+ "commercial_claims_unresolved",
551
+ `${unresolved} claim(s) could not be tied to one calculated value (${unresolvedPrices} price, ${commercial.unresolved_recurrence_claims} recurring, ${commercial.unresolved_voucher_claims} voucher).`,
552
+ "A page with more than one bound bundle price, or a voucher the plan cannot price, stays uncompared: check these by hand, or declare the voucher in the spec.",
553
+ unresolved,
554
+ ));
555
+ }
556
+ return causes;
557
+ }
558
+
559
+ /** One warn row per named cause: what `exceptions[]` carries and the sidecar keeps. */
560
+ function commercialIncompleteAssertions(reasons) {
561
+ return array(reasons).map((entry) => ({
562
+ id: `commercial-parity:incomplete:${entry.code}`,
563
+ family: "pricing",
564
+ page: "campaign",
565
+ status: "warn",
566
+ severity: "warn",
567
+ expected: "the page's commercial claims compared with calculated totals",
568
+ actual: `${entry.summary} Fix: ${entry.fix}`,
569
+ evidence: {
570
+ code: entry.code,
571
+ count: entry.count,
572
+ },
573
+ }));
574
+ }
575
+
469
576
  function compactReport(report, journey, plan, executed, issues, observedClaims, claimLimit, plannedScenarioCount = plan.length) {
470
577
  const exactPages = array(journey?.pages).filter((page) => page?.state === PricingState.Exact).length;
471
578
  const stalePages = array(journey?.pages).filter((page) => page?.state === PricingState.Stale).length;
@@ -498,6 +605,7 @@ function compactReport(report, journey, plan, executed, issues, observedClaims,
498
605
  extracted_price_claims: report.extracted_price_claims,
499
606
  compared_price_claims: report.compared_price_claims,
500
607
  unresolved_price_claims: report.unresolved_price_claims,
608
+ package_price_claims: report.package_price_claims,
501
609
  extracted_recurrence_claims: report.extracted_recurrence_claims,
502
610
  compared_recurrence_claims: report.compared_recurrence_claims,
503
611
  unresolved_recurrence_claims: report.unresolved_recurrence_claims,
@@ -568,6 +676,7 @@ export function unavailableCommercialReport(code, { status = "not_run" } = {}) {
568
676
  extracted_price_claims: 0,
569
677
  compared_price_claims: 0,
570
678
  unresolved_price_claims: 0,
679
+ package_price_claims: { extracted: 0, compared: 0, unresolved: {} },
571
680
  extracted_recurrence_claims: 0,
572
681
  compared_recurrence_claims: 0,
573
682
  unresolved_recurrence_claims: 0,
@@ -586,6 +695,19 @@ export function unavailableCommercialReport(code, { status = "not_run" } = {}) {
586
695
  };
587
696
  }
588
697
 
698
+ /** What a crash in the check reports: the same warn row as any other stop. */
699
+ export function crashedCommercialResult() {
700
+ const reason = cause(
701
+ "commercial_runner_error",
702
+ "The commercial parity check crashed before it finished, so no price was compared.",
703
+ "Re-run qa run. If it crashes again, record it with findings add and tell the operator.",
704
+ );
705
+ return {
706
+ assertions: commercialIncompleteAssertions([reason]),
707
+ commercial: { ...unavailableCommercialReport(reason.code, { status: "incomplete" }), incomplete_reasons: [reason] },
708
+ };
709
+ }
710
+
589
711
  // One blocker row per currency the calculate endpoint answered in other than
590
712
  // the run's: expected totals were asked for in `currency` and came back priced
591
713
  // in another one. Answered codes are compared and named upper-cased, so `usd`
@@ -641,13 +763,15 @@ export async function runCommercialParity({
641
763
  const observedClaims = array(captures).reduce((sum, capture) => sum + captureClaimCount(capture), 0);
642
764
  const aggregateClaimOverflow = observedClaims > limits.max_aggregate_claims;
643
765
  const orderedPreflightIssues = [
644
- { exceeded: aggregateClaimOverflow, code: "commercial_aggregate_claim_limit" },
645
- { exceeded: scenarioOverflow, code: "commercial_scenario_limit" },
646
- ];
766
+ { exceeded: aggregateClaimOverflow, code: "commercial_aggregate_claim_limit", summary: `The funnel carries ${observedClaims} commercial claims, over the limit of ${limits.max_aggregate_claims}, so none were compared.` },
767
+ { exceeded: scenarioOverflow, code: "commercial_scenario_limit", summary: "The funnel plans more calculate scenarios than the limit, so none ran." },
768
+ ].filter((issue) => issue.exceeded);
647
769
  const issues = [
648
770
  ...captureIssues(captures),
649
- ...orderedPreflightIssues.filter((issue) => issue.exceeded).map(({ code }) => ({ code })),
771
+ ...orderedPreflightIssues.map(({ code }) => ({ code })),
650
772
  ];
773
+ // Why calculate did not give this run its answers, said where it is found.
774
+ const stopped = orderedPreflightIssues.map(({ code, summary }) => cause(code, summary, OVER_LIMITS_FIX));
651
775
  const apiKeyResolution = resolveCommercialApiKey(resolved);
652
776
  const apiKey = apiKeyResolution.value;
653
777
  let executed = [];
@@ -657,8 +781,18 @@ export async function runCommercialParity({
657
781
  // preflight issues above remain visible when multiple ceilings are hit.
658
782
  } else if (apiKeyResolution.unsupported) {
659
783
  issues.push({ code: "unsupported_campaigns_api_key_source" });
784
+ stopped.push(cause(
785
+ "unsupported_campaigns_api_key_source",
786
+ `The packet's campaign.api_key_source names an environment variable QA refuses to read, so 0 of ${plan.length} planned calculate scenarios ran.`,
787
+ "Use env:CAMPAIGNS_API_KEY, or set campaign.campaigns_api_key in the CampaignSpec, then re-run qa run.",
788
+ ));
660
789
  } else if (plan.some((descriptor) => !descriptor?.unresolved) && !present(apiKey)) {
661
790
  issues.push({ code: "missing_campaigns_api_key" });
791
+ stopped.push(cause(
792
+ "missing_campaigns_api_key",
793
+ `No campaign key was resolved, so 0 of ${plan.length} planned calculate scenarios ran and no price was compared.`,
794
+ "Set campaign.campaigns_api_key in the CampaignSpec (or the Build Packet) to the campaign's API key, then re-run qa run.",
795
+ ));
662
796
  } else if (typeof fetchImpl !== "function") {
663
797
  issues.push({ code: "price_preview_fetch_unavailable" });
664
798
  } else {
@@ -669,9 +803,17 @@ export async function runCommercialParity({
669
803
  fetchImpl,
670
804
  limits,
671
805
  }));
672
- executed.filter((entry) => entry?.ok === false).forEach((entry) => {
673
- issues.push({ code: entry.error_code || "price_preview_failed" });
674
- });
806
+ const failures = executed.filter((entry) => entry?.ok === false)
807
+ .map((entry) => entry.error_code || "price_preview_failed");
808
+ failures.forEach((code) => issues.push({ code }));
809
+ if (failures.length) {
810
+ stopped.push(cause(
811
+ "price_preview_failed",
812
+ `The calculate endpoint failed for ${failures.length} of ${plan.length} planned scenarios (${[...new Set(failures)].join(", ")}).`,
813
+ "Check that the key belongs to this campaign and that the proxy is reachable, then re-run qa run.",
814
+ failures.length,
815
+ ));
816
+ }
675
817
  }
676
818
 
677
819
  const journey = normalizeJourney(
@@ -715,17 +857,30 @@ export async function runCommercialParity({
715
857
  maxAssertions: Math.max(0, maxAssertions - parity.assertions.length),
716
858
  });
717
859
  parity.recurring_claim_absences = absences;
860
+ const commercial = compactReport(
861
+ parity,
862
+ parityJourney,
863
+ plan,
864
+ executed,
865
+ issues,
866
+ observedClaims,
867
+ limits.max_aggregate_claims,
868
+ commercialPlanning.observed_scenarios ?? plan.length,
869
+ );
870
+ const assertions = [...currencyAssertions, ...parity.assertions, ...absentAssertions];
871
+ if (commercial.status !== "incomplete") return { assertions, commercial };
872
+ // An incomplete run always names a cause in the exceptions list. A currency
873
+ // blocker or an absent rebill claim already wrote its own row.
874
+ const reasons = incompleteCauses(stopped, commercial, parityJourney);
875
+ if (!reasons.length && !currencyAssertions.length && !absentAssertions.length) {
876
+ reasons.push(cause(
877
+ "commercial_coverage_incomplete",
878
+ `Commercial parity stopped short: issues ${array(commercial.issues).map((issue) => issue.code).join(", ") || "none"}.`,
879
+ "Read verdict.commercial for what was and was not compared, and tell the operator.",
880
+ ));
881
+ }
718
882
  return {
719
- assertions: [...currencyAssertions, ...parity.assertions, ...absentAssertions],
720
- commercial: compactReport(
721
- parity,
722
- parityJourney,
723
- plan,
724
- executed,
725
- issues,
726
- observedClaims,
727
- limits.max_aggregate_claims,
728
- commercialPlanning.observed_scenarios ?? plan.length,
729
- ),
883
+ assertions: [...assertions, ...commercialIncompleteAssertions(reasons)],
884
+ commercial: { ...commercial, incomplete_reasons: reasons },
730
885
  };
731
886
  }
package/src/qa-node.mjs CHANGED
@@ -103,6 +103,7 @@ import {
103
103
  import {
104
104
  captureCommercialClaims,
105
105
  COMMERCIAL_QA_LIMITS,
106
+ crashedCommercialResult,
106
107
  mapConcurrent,
107
108
  createPageSourceLoader,
108
109
  planCommercialParity,
@@ -2609,8 +2610,10 @@ async function runResolvedQa(args, resolvedInputs, { runSessionActive = false, l
2609
2610
  const vendorBeacons = vendorBeaconRun ? createVendorBeaconLedger({ mode: vendorBeaconRun, analytics: resolved.spec?.analytics }) : null;
2610
2611
  if (vendorBeacons) args = { ...args, [QA_VENDOR_BEACONS]: vendorBeacons };
2611
2612
  const qcResults = [];
2613
+ const renderedPackagePrices = new Map();
2612
2614
  if (args.browser === true) {
2613
2615
  const browserAssertions = await runBrowserChecks(resolved.topologies, args, {
2616
+ renderedPackagePrices,
2614
2617
  brandContract: resolved.brandContract,
2615
2618
  // With no generatable brand theme on the local preview, the starter
2616
2619
  // template is the design: its residue is a warning (local-preview-policy.mjs).
@@ -2649,16 +2652,15 @@ async function runResolvedQa(args, resolvedInputs, { runSessionActive = false, l
2649
2652
  try {
2650
2653
  commercialResult = await runCommercialParity({
2651
2654
  resolved,
2652
- captures: [...capturesByPageId.values()],
2655
+ captures: [...capturesByPageId.entries()].map(([pageId, capture]) => (renderedPackagePrices.has(pageId)
2656
+ ? { ...capture, rendered_package_prices: renderedPackagePrices.get(pageId) }
2657
+ : capture)),
2653
2658
  planning: commercialPlanning,
2654
2659
  maxAssertions: remainingAssertionBudget,
2655
2660
  currency,
2656
2661
  });
2657
2662
  } catch (error) {
2658
- commercialResult = {
2659
- assertions: [],
2660
- commercial: unavailableCommercialReport("commercial_runner_error"),
2661
- };
2663
+ commercialResult = crashedCommercialResult();
2662
2664
  reportCommercialRunnerError(args, error);
2663
2665
  }
2664
2666
  assertions.push(...commercialResult.assertions);
@@ -3795,6 +3797,9 @@ function output(value, args) {
3795
3797
  printThemeGateLines(value.theme_gate, value.packet_path, value.report_path);
3796
3798
  if (value.commercial) {
3797
3799
  console.log(`Commercial parity: ${value.commercial.status} (${value.commercial.finding_count || 0} findings, ${value.commercial.checked_pages || 0} pages checked)`);
3800
+ for (const entry of value.commercial.incomplete_reasons || []) {
3801
+ console.log(` ${entry.code}: ${entry.summary} Fix: ${entry.fix}`);
3802
+ }
3798
3803
  }
3799
3804
  console.log(`Local copy: ${value.local_path}`);
3800
3805
  if (value.posted?.ok && value.dashboard_url) {
@@ -88,7 +88,9 @@ export function computeDisposition(assertions) {
88
88
  function dispositionWithCommercial(assertions, commercial) {
89
89
  const disposition = computeDisposition(assertions);
90
90
  if (disposition === "blocked") return disposition;
91
- return commercial?.status === "mismatch"
91
+ // Warn-only either way: a proven mismatch and a proof that stopped short are
92
+ // both things the operator has to hear about, and neither ever blocks.
93
+ return commercial?.status === "mismatch" || commercial?.status === "incomplete"
92
94
  ? "ready_with_exceptions"
93
95
  : disposition;
94
96
  }
package/src/readback.mjs CHANGED
@@ -1310,6 +1310,7 @@ function renderEvidenceDebt(debt, lines) {
1310
1310
  : `evidence currency is unknown (${stage.reason}); absence of proof is not no debt`;
1311
1311
  lines.push(` ${name}: ${stage.status} — ${explanation}`);
1312
1312
  lines.push(` recorded: ${stage.recorded_build_fingerprint ?? "unknown"}; current: ${stage.current_build_fingerprint ?? "unknown"}`);
1313
+ if (stage.baseline) lines.push(` baseline: accepted by ${oneLine(stage.baseline.accepted_by)} at ${oneLine(stage.baseline.accepted_at)}, not tested; no purchase tested since`);
1313
1314
  if (stage.change) {
1314
1315
  const owes = stage.advisory ? "advisory: reported, not required"
1315
1316
  : stage.scope === "pages" ? `owed on ${stage.owed_pages.map((page) => oneLine(page.page_id)).join(", ")} (qa run --page)` : "the full stage is owed";
@@ -6,7 +6,10 @@
6
6
  // second and later funnels) — current readback truth; docs/migration-sidecar-bundle.md
7
7
  // names it and `bundle check` validates it. Committed by design.
8
8
  // 2. Handoff inputs and agent context (input/, theme/, agent-context/,
9
- // setup-handoff.json) — durable build handoff. Committed by design.
9
+ // setup-handoff.json) and the CampaignSpec a Map intake fetched
10
+ // (fetched-specs/) — durable build handoff. The committed records are
11
+ // bound to that spec, and spec derive edits it, so another machine cannot
12
+ // get it back from the Map. Committed by design.
10
13
  // 3. Telemetry, sessions, caches, and evidence — per-machine, append-only,
11
14
  // or carrying live URLs and absolute paths. Never meant for the repo, and
12
15
  // nothing wrote a rule for them: three campaign repositories were found
@@ -32,7 +35,6 @@ export const RUNTIME_STATE_IGNORED_PATHS = [
32
35
  // A project's second and later funnels keep their Run Records under their
33
36
  // own runtime directory (src/campaign-workspace.mjs).
34
37
  ".campaign-runtime/funnels/*/run-records/",
35
- ".campaign-runtime/fetched-specs/",
36
38
  ".campaign-runtime/progress/",
37
39
  ".campaign-runtime/polish-evidence/",
38
40
  ".campaign-runtime/evidence/",
@@ -53,12 +55,17 @@ export const RUNTIME_STATE_IGNORED_PATHS = [
53
55
  // present.
54
56
  export const RUNTIME_STATE_REQUIRED_IGNORED_PATHS = ["qa-output/", ".campaign-runtime/progress/", ".campaign-runtime/funnels/*/run-records/"];
55
57
 
58
+ // Entries an earlier toolkit wrote into the managed block that are now meant
59
+ // to be committed. They leave the managed block on the next setup command or
60
+ // `spec fetch`; the same line elsewhere in the file is the operator's own.
61
+ export const RUNTIME_STATE_RETIRED_IGNORED_PATHS = [".campaign-runtime/fetched-specs/"];
62
+
56
63
  export const RUNTIME_STATE_IGNORE_BLOCK = [
57
64
  RUNTIME_STATE_IGNORE_MARKER,
58
- "# Sessions, journals, Run Records, spec caches, capture evidence, and full",
59
- "# QA verdicts are per-machine. The readback bundle (build-context, assembly-report,",
60
- "# doctor-output, qa-verdict), input/, theme/, and agent-context/ are meant",
61
- "# to be committed and are deliberately not listed here.",
65
+ "# Sessions, journals, Run Records, capture evidence, and full QA verdicts are",
66
+ "# per-machine. The readback bundle (build-context, assembly-report, doctor-output,",
67
+ "# qa-verdict), fetched-specs/, input/, theme/, and agent-context/ are meant to be",
68
+ "# committed and are deliberately not listed here.",
62
69
  ...RUNTIME_STATE_IGNORED_PATHS,
63
70
  ].join("\n");
64
71
 
@@ -66,14 +73,29 @@ function ignoreRuleLines(text) {
66
73
  return new Set(text.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#")));
67
74
  }
68
75
 
69
- // Append the missing required entries at the end of the managed block: after
70
- // the marker, up to the first blank line or the end of the file.
71
- function withRequiredEntries(existing, missing) {
72
- const lines = existing.split("\n");
73
- const markerIndex = lines.findIndex((line) => line.trim() === RUNTIME_STATE_IGNORE_MARKER);
76
+ // The managed block spans the marker up to the first blank line or the end of
77
+ // the file.
78
+ function managedBlockEnd(lines, markerIndex) {
74
79
  let end = markerIndex + 1;
75
80
  while (end < lines.length && lines[end].trim() !== "") end += 1;
76
- lines.splice(end, 0, ...missing);
81
+ return end;
82
+ }
83
+
84
+ function retiredEntriesInBlock(existing) {
85
+ const lines = existing.split("\n");
86
+ const markerIndex = lines.findIndex((line) => line.trim() === RUNTIME_STATE_IGNORE_MARKER);
87
+ return lines.slice(markerIndex + 1, managedBlockEnd(lines, markerIndex)).map((line) => line.trim())
88
+ .filter((line) => RUNTIME_STATE_RETIRED_IGNORED_PATHS.includes(line));
89
+ }
90
+
91
+ // Append the missing required entries at the end of the managed block and drop
92
+ // the retired ones from it.
93
+ function withBlockEntries(existing, missing) {
94
+ const lines = existing.split("\n");
95
+ const markerIndex = lines.findIndex((line) => line.trim() === RUNTIME_STATE_IGNORE_MARKER);
96
+ const end = managedBlockEnd(lines, markerIndex);
97
+ const block = lines.slice(markerIndex + 1, end).filter((line) => !RUNTIME_STATE_RETIRED_IGNORED_PATHS.includes(line.trim()));
98
+ lines.splice(markerIndex + 1, end - markerIndex - 1, ...block, ...missing);
77
99
  return lines.join("\n");
78
100
  }
79
101
 
@@ -81,10 +103,11 @@ function withRequiredEntries(existing, missing) {
81
103
  * Ensure the target's .gitignore carries the runtime-state block. Idempotent
82
104
  * on the marker line: an existing block, however edited beneath it, is left
83
105
  * alone — except for RUNTIME_STATE_REQUIRED_IGNORED_PATHS, which are appended
84
- * to an existing block when the file carries them nowhere. Never throws for
85
- * an unwritable target — the build must not fail on housekeeping — but
106
+ * to an existing block when the file carries them nowhere, and
107
+ * RUNTIME_STATE_RETIRED_IGNORED_PATHS, which are removed from it. Never throws
108
+ * for an unwritable target — the build must not fail on housekeeping — but
86
109
  * reports what happened so callers can surface it.
87
- * Returns `{ path, action: "added" | "updated" | "present" | "skipped", reason?, added_entries? }`.
110
+ * Returns `{ path, action: "added" | "updated" | "present" | "skipped", reason?, added_entries?, removed_entries? }`.
88
111
  */
89
112
  export function ensureRuntimeStateIgnored(targetRepo, { dryRun = false } = {}) {
90
113
  const path = join(resolve(targetRepo), ".gitignore");
@@ -98,14 +121,16 @@ export function ensureRuntimeStateIgnored(targetRepo, { dryRun = false } = {}) {
98
121
  if (existing.includes(RUNTIME_STATE_IGNORE_MARKER)) {
99
122
  const present = ignoreRuleLines(existing);
100
123
  const missing = RUNTIME_STATE_REQUIRED_IGNORED_PATHS.filter((entry) => !present.has(entry));
101
- if (missing.length === 0) return { path, action: "present" };
102
- if (dryRun) return { path, action: "updated", dry_run: true, added_entries: missing };
124
+ const retired = retiredEntriesInBlock(existing);
125
+ if (missing.length === 0 && retired.length === 0) return { path, action: "present" };
126
+ const changes = { added_entries: missing, ...(retired.length ? { removed_entries: retired } : {}) };
127
+ if (dryRun) return { path, action: "updated", dry_run: true, ...changes };
103
128
  try {
104
- writeFileSync(path, withRequiredEntries(existing, missing));
129
+ writeFileSync(path, withBlockEntries(existing, missing));
105
130
  } catch (error) {
106
131
  return { path, action: "skipped", reason: `unwritable: ${error.message}` };
107
132
  }
108
- return { path, action: "updated", added_entries: missing };
133
+ return { path, action: "updated", ...changes };
109
134
  }
110
135
  }
111
136
  if (dryRun) return { path, action: "added", dry_run: true };
@@ -106,20 +106,23 @@ const HISTORY_ENTRY_FIELDS = Object.freeze([
106
106
  "build_fingerprint", "source_build_fingerprint", "source_package_material_fingerprint",
107
107
  "source_brief_material", "source_spec_material_hash",
108
108
  "verdict_run_id", "checked_at", "purchase_proof",
109
- "outputs", "evidence",
109
+ "outputs", "evidence", "baseline_accepted_at",
110
110
  ]);
111
111
  // The entry fields that say what a record was, rather than when or by whom it
112
112
  // was written: a replacement equal on all of them is a re-record, not a new
113
113
  // chapter, and archives nothing.
114
114
  const HISTORY_IDENTITY_FIELDS = Object.freeze([
115
115
  "build_fingerprint", "source_build_fingerprint", "source_package_material_fingerprint",
116
- "source_brief_material", "source_spec_material_hash", "verdict_run_id", "purchase_proof", "outputs", "evidence",
116
+ "source_brief_material", "source_spec_material_hash", "verdict_run_id", "purchase_proof", "outputs", "evidence", "baseline_accepted_at",
117
117
  ]);
118
- export const HISTORY_ARCHIVED_BY = Object.freeze(["record brief", "record spec", "record build", "record polish", "qa run", "prepare-build --force"]);
119
- export const HISTORY_REASON_CODES = Object.freeze(["brief_presentation_changed", "brief_qa_policy_changed", "spec_material_changed", "rerecorded", "force_reset", "build_output_changed"]);
118
+ export const HISTORY_ARCHIVED_BY = Object.freeze(["record brief", "record spec", "record build", "record polish", "record baseline", "qa run", "prepare-build --force"]);
119
+ export const HISTORY_REASON_CODES = Object.freeze(["brief_presentation_changed", "brief_qa_policy_changed", "spec_material_changed", "rerecorded", "force_reset", "build_output_changed", "baseline_accepted"]);
120
120
  const ARCHIVED_STAGE_KEYS = Object.freeze(["assembly", "polish", "qa"]);
121
121
 
122
122
  const isCompletedStatus = (status) => typeof status === "string" && status.startsWith("completed");
123
+ // A record a later one replaces into history. A baseline counts here, and not
124
+ // as completed: it never hands a campaign off or proves anything.
125
+ const isReplacedRecord = (status) => isCompletedStatus(status) || status === "baseline_accepted";
123
126
 
124
127
  // What a record says about itself, compared for the dedup. Evidence reads
125
128
  // through meaningfulEvidence (an empty block is absent) and a verdict id is
@@ -168,7 +171,7 @@ function historyEntry(previous, { by, reason, at }) {
168
171
  // verdict identity or evidence, which the QA producer has always archived.
169
172
  function archivable(stageKey, previous) {
170
173
  if (!isPlainObject(previous)) return false;
171
- if (isCompletedStatus(previous.status)) return true;
174
+ if (isReplacedRecord(previous.status)) return true;
172
175
  return stageKey === "qa" && Boolean(recordIdentity(previous).verdict_run_id || meaningfulEvidence(previous.evidence));
173
176
  }
174
177
 
@@ -200,6 +203,19 @@ export function archiveSupersededRecord(stage, previous, { by, reason, at }) {
200
203
  return appendHistoryEntry(isPlainObject(stage) ? { ...stage } : {}, historyEntry(previous, { by, reason, at }));
201
204
  }
202
205
 
206
+ // The baseline QA rests on, or null. The marker on the record is the rule, not
207
+ // its status: a blocked page-scoped run or an input change leaves QA resting on
208
+ // the baseline, and only a completed full QA clears the marker.
209
+ export function qaRestsOnBaseline(report) {
210
+ return report?.stages?.qa?.baseline_accepted_at && isPlainObject(report?.commerce_baseline) ? report.commerce_baseline : null;
211
+ }
212
+
213
+ // A stage's baseline record, with what the stage held moved to its history.
214
+ export function archiveForBaseline(record, previous, { at }) {
215
+ const carried = Array.isArray(previous?.history) ? { ...record, history: previous.history } : record;
216
+ return archiveSupersededRecord(carried, previous, { by: "record baseline", reason: "baseline_accepted", at });
217
+ }
218
+
203
219
  /**
204
220
  * #668: the Polish record a CampaignSpec change moved to `polish.history`,
205
221
  * restored as the live record stamped with `stamps` (the current inputs), or
@@ -241,6 +257,15 @@ export function campaignHandedOff(report) {
241
257
  return completedIn(report?.stages?.deploy) || completedIn(report?.stages?.qa);
242
258
  }
243
259
 
260
+ // True when QA has ever completed, now or in the stage's history. Not
261
+ // campaignHandedOff: a baseline stands in for a QA result, and a completed
262
+ // deploy is not one.
263
+ export function qaEverCompleted(report) {
264
+ const qa = report?.stages?.qa;
265
+ return isPlainObject(qa) && (isCompletedStatus(qa.status)
266
+ || (Array.isArray(qa.history) && qa.history.some((entry) => isPlainObject(entry) && isCompletedStatus(entry.status))));
267
+ }
268
+
244
269
  /**
245
270
  * True when any stage of `report` carries a non-empty history: recorded
246
271
  * operator-visible state an intake must not silently drop.
@@ -301,7 +326,7 @@ export function archiveForForceReset(previousReport, nextReport, { now, detectCh
301
326
  continue;
302
327
  }
303
328
  if (Object.hasOwn(previous, "input_change")) reseeded.input_change = JSON.parse(JSON.stringify(previous.input_change));
304
- if (isCompletedStatus(previous.status)) {
329
+ if (isReplacedRecord(previous.status)) {
305
330
  reseeded = archiveSupersededRecord(reseeded, previous, { by: "prepare-build --force", reason: "force_reset", at: now });
306
331
  const reason = typeof detectChange === "function" ? detectChange(key, previous) : null;
307
332
  if (reason) reseeded.input_change = inputChangeFor(key, previous, { at: now, reason });
@@ -405,7 +430,7 @@ export function recordProducerStageOutcome(report, {
405
430
  // A replaced completed record whose stamps differ from the current inputs
406
431
  // records the change; a verdict stamped with the current inputs then
407
432
  // clears it, the one it just recorded included; any other write carries it.
408
- const detected = inputs && isCompletedStatus(previous.status) ? inputs.detectChange(previous) : null;
433
+ const detected = inputs && isReplacedRecord(previous.status) ? inputs.detectChange(previous) : null;
409
434
  if (detected) next.input_change = inputChangeFor("qa", previous, { at: timestamp, reason: detected });
410
435
  if (inputs && inputs.stampsCurrent(next)) delete next.input_change;
411
436
  const archived = archiveStageRecord(next, previous, next, { by: "qa run", reason: detected || "rerecorded", at: timestamp, stageKey: "qa" });
@@ -591,9 +616,10 @@ export function qaRecordedBuildFingerprint(report) {
591
616
  // #720: the build whose completed QA a page-scoped run extends. A completed
592
617
  // QA record extends its own build. A page-scoped run that did not complete
593
618
  // extends the build of the full QA it was based on, which was completed when
594
- // that run was accepted. Any other record (blocked or pending full QA, a
619
+ // that run was accepted. A baseline extends its own build too, and has no
620
+ // purchase proof to pass on. Any other record (blocked or pending full QA, a
595
621
  // skip, a required stage) extends nothing, so it owes a full QA run.
596
- const QA_EXTENDABLE_STATUSES = Object.freeze(["completed", "completed_with_warnings"]);
622
+ const QA_EXTENDABLE_STATUSES = Object.freeze(["completed", "completed_with_warnings", "baseline_accepted"]);
597
623
  export function qaExtendedBuildFingerprint(report) {
598
624
  const qa = report?.stages?.qa;
599
625
  if (QA_EXTENDABLE_STATUSES.includes(qa?.status)) return qaRecordedBuildFingerprint(report);