@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
package/src/doctor/ci.mjs CHANGED
@@ -1,16 +1,47 @@
1
+ import { worseChangeKind } from "../change-scope.mjs";
1
2
  import { assessEvidenceDebt } from "../evidence-debt.mjs";
3
+ import { REFUSED_INVOCATION } from "../lifecycle.mjs";
2
4
 
3
5
  export const DOCTOR_CI_EXIT_CODES = Object.freeze({ current: 0, owed: 3, unknown: 4, usage: 2 });
4
6
 
5
7
  export function assessDoctorCi(inputs) {
6
8
  const debt = assessEvidenceDebt(inputs);
7
- const stages = [debt.polish, debt.qa];
9
+ // Advisory Polish (a page-only change) is reported, never required.
10
+ const stages = [debt.polish, debt.qa].filter((stage) => !stage.advisory);
8
11
  // Any known debt is owed; otherwise incomplete evidence remains unknown.
9
12
  const status = stages.some((stage) => stage.status === "owed") ? "owed"
10
13
  : stages.some((stage) => stage.status === "unknown") ? "unknown" : "current";
11
14
  return { status, exit_code: DOCTOR_CI_EXIT_CODES[status], ...debt };
12
15
  }
13
16
 
17
+ // What the change owes, from the stages it left owed: "pages" when the only
18
+ // required stage is QA on the changed pages (Polish advisory or current),
19
+ // "full" when a full stage is owed, null when nothing is owed. `kind` is the
20
+ // worst change kind among the owed stages.
21
+ export function ciChangeScope(ci) {
22
+ const owed = ["polish", "qa"].filter((stage) => ci[stage]?.status === "owed");
23
+ if (!owed.length) return { scope: null, kind: null };
24
+ const kind = owed.map((stage) => ci[stage].change?.kind || "unknown").reduce(worseChangeKind, "none");
25
+ const required = owed.filter((stage) => !ci[stage].advisory);
26
+ const scope = required.every((stage) => stage === "qa" && ci.qa.scope === "pages") ? "pages" : "full";
27
+ return { scope, kind };
28
+ }
29
+
30
+ // Page ids come from built routes ([A-Za-z0-9_-]); anything else is encoded.
31
+ const pageArg = (id) => (/^[A-Za-z0-9_-]+$/.test(String(id)) ? String(id) : text(id));
32
+
33
+ const commerceReasonText = new Map([
34
+ ["unmapped_file", "a file outside any page changed"],
35
+ ["page_added", "a page was added"],
36
+ ["page_removed", "a page was removed"],
37
+ ["checkout_page", "a checkout page changed"],
38
+ ["commerce_markup_changed", "package, offer, shipping, upsell, route or script markup changed"],
39
+ ["commerce_signature_unavailable", "the recorded build has no commerce signature to compare against"],
40
+ ["campaign_config_changed", "the campaign's Page Kit config (SDK pin, analytics ids, store fields) changed"],
41
+ ["campaign_config_unavailable", "the campaign's Page Kit config entry could not be read"],
42
+ ["change_history_unavailable", "the rebuilds since the reviewed build were not kept"],
43
+ ]);
44
+
14
45
  // Values can come from campaign files and will be rendered in a PR comment.
15
46
  // Encode punctuation as well as HTML: diagnostics are plain text, and may
16
47
  // contain Markdown, autolinks, code fences or line breaks from campaign files.
@@ -38,10 +69,10 @@ const requiredStageReasons = new Map([
38
69
  ["stage_required", "the lifecycle requires this stage again"],
39
70
  ]);
40
71
 
41
- export function doctorCiFailure(error, { usage = false } = {}) {
72
+ export function doctorCiFailure(error, { usage = false, code = usage ? "doctor.ci.packet" : "doctor.ci.failed" } = {}) {
42
73
  return {
43
74
  ok: false, status: "blocked",
44
- errors: [{ code: usage ? "doctor.ci.packet" : "doctor.ci.failed", message: String(error?.message ?? error) }],
75
+ errors: [{ code, message: String(error?.message ?? error) }],
45
76
  warnings: [], exit_code: usage ? DOCTOR_CI_EXIT_CODES.usage : DOCTOR_CI_EXIT_CODES.unknown,
46
77
  };
47
78
  }
@@ -54,7 +85,8 @@ export async function withDoctorCiReport(args, invoke) {
54
85
  return await invoke(args);
55
86
  } catch (error) {
56
87
  if (command !== "doctor" || args.ci !== true) throw error;
57
- const result = doctorCiFailure(error);
88
+ // A flag refused up front (such as --packet=<path>) is a usage error.
89
+ const result = error?.code === REFUSED_INVOCATION ? doctorCiFailure(error, { usage: true, code: "doctor.ci.usage" }) : doctorCiFailure(error);
58
90
  console.log(args.json ? JSON.stringify(result, null, 2) : formatDoctorCi(result));
59
91
  process.exitCode = result.exit_code;
60
92
  }
@@ -66,22 +98,51 @@ export function formatDoctorCi(result) {
66
98
  return [
67
99
  `## Campaign evidence: could not evaluate (exit ${result.exit_code ?? DOCTOR_CI_EXIT_CODES.usage})`, "",
68
100
  ...(result.errors || []).map((issue) => `- ${code(issue.code)}: ${text(issue.message)}`), "",
69
- "Remedy: correct the packet or inspection failure and re-run `campaigns-os doctor --packet <packet> --ci --no-live-refs`.",
101
+ result.errors?.[0]?.code === "doctor.ci.usage"
102
+ ? "Remedy: correct the command's flags (each value as the next argument, `--flag value`) and re-run `campaigns-os doctor --packet <packet> --ci --no-live-refs`."
103
+ : "Remedy: correct the packet or inspection failure and re-run `campaigns-os doctor --packet <packet> --ci --no-live-refs`.",
70
104
  ].join("\n");
71
105
  }
72
106
  const lines = [`## Campaign evidence: ${text(ci.status)} (exit ${ci.exit_code})`, ""];
73
- const owed = ["polish", "qa"].filter((stage) => ci[stage].status === "owed");
74
- lines.push(`Owed stages: ${owed.length ? owed.join(", ") : "none"}.`);
107
+ const advisory = ["polish", "qa"].filter((stage) => ci[stage].status === "owed" && ci[stage].advisory);
108
+ const owed = ["polish", "qa"].filter((stage) => ci[stage].status === "owed" && !ci[stage].advisory);
109
+ const change = ciChangeScope(ci);
110
+ const pageScoped = change.scope === "pages";
111
+ if (pageScoped) {
112
+ lines.push(`Change found: page-only. ${owed.length ? `It owes QA on ${ci.qa.owed_pages.map((page) => code(page.page_id)).join(", ")}` : "QA is current"}; Polish is advisory.`);
113
+ } else if (change.scope === "full") {
114
+ const reasons = [...new Set((ci.polish.change?.reasons || []).concat(ci.qa.change?.reasons || [])
115
+ .map((reason) => commerceReasonText.get(reason.code)).filter(Boolean))];
116
+ const found = change.kind === "commerce_affecting" ? "commerce-affecting"
117
+ : change.kind === "page_only" ? "page-only, but an input change or a Polish or QA that did not complete requires the full stages" : "not classified";
118
+ // Name only the stages that count, each at the scope it is owed: an
119
+ // advisory Polish is not owed, and QA can still be owed on pages only.
120
+ const qaPages = ci.qa.scope === "pages" ? `QA on ${ci.qa.owed_pages.map((page) => code(page.page_id)).join(", ")}` : null;
121
+ const owes = owed.length === 2 ? (qaPages ? `the full Polish, and ${qaPages}` : "the full Polish and QA")
122
+ : owed[0] === "polish" ? "the full Polish" : qaPages || "a full QA run";
123
+ lines.push(`Change found: ${found}${reasons.length ? ` (${reasons.join("; ")})` : ""}. It owes ${owes}${advisory.includes("polish") ? "; Polish is advisory" : ""}.`);
124
+ }
125
+ lines.push(`Owed stages: ${owed.length ? owed.map((stage) => stage === "qa" && ci.qa.scope === "pages" ? "qa (changed pages only)" : stage).join(", ") : "none"}.`);
75
126
  for (const stage of owed) {
76
127
  const reason = ci[stage].reason === "build_fingerprint_changed"
77
128
  ? "the recorded build fingerprint differs from the current build"
78
129
  : requiredStageReasons.get(ci[stage].reason) ?? "the recorded stage evidence is owed";
79
130
  lines.push(`- Owed ${stage}: ${reason}.`);
80
131
  }
81
- for (const page of ci.changes.changed_pages) {
132
+ for (const stage of advisory) {
133
+ lines.push(`- Advisory ${stage}: the change since its last review is page-only, so it is reported and not required.`);
134
+ }
135
+ // The pages changed since the builds the owed stages reviewed, which
136
+ // record build keeps; without them, the drift from the assembly manifest.
137
+ const sincePages = new Map();
138
+ for (const stage of [...owed, ...advisory]) {
139
+ for (const page of ci[stage].change?.changed_pages || []) sincePages.set(page.built_path, page);
140
+ }
141
+ const changedPages = sincePages.size ? [...sincePages.values()] : ci.changes.changed_pages;
142
+ for (const page of changedPages) {
82
143
  lines.push(`- Changed page: ${code(page.page_id)} (${code(page.built_path)})`);
83
144
  }
84
- if (!ci.changes.changed_pages.length && (owed.length || ci.changes.status === "unknown")) {
145
+ if (!changedPages.length && (owed.length || ci.changes.status === "unknown")) {
85
146
  lines.push(ci.changes.status === "unknown"
86
147
  ? `Changed pages unavailable: ${unavailablePageChanges.get(ci.changes.reason) ?? "the page-change reason is unrecognized."}`
87
148
  : "Changed page ids unavailable: the retained assembly manifest may already describe the rebuilt output.");
@@ -90,7 +151,11 @@ export function formatDoctorCi(result) {
90
151
  if (ci[stage].status === "unknown") lines.push(`- Unknown ${stage}: ${code(ci[stage].reason)}`);
91
152
  }
92
153
  if (ci.changes.status === "unknown") lines.push(`- Unknown page changes: ${code(ci.changes.reason)}`);
93
- if (ci.status === "owed" || ci.status === "unknown") {
154
+ if (ci.status === "owed" && pageScoped && owed.length) {
155
+ const pages = ci.qa.owed_pages.map((page) => ` --page ${pageArg(page.page_id)}`).join("");
156
+ const recorded = ci.changes.recorded_build_fingerprint && ci.changes.recorded_build_fingerprint === ci.changes.current_build_fingerprint;
157
+ lines.push("", `Remedy: ${recorded ? "" : "run `campaigns-os record build --packet <packet>` for the rebuilt output, then "}run \`campaigns-os qa run --packet <packet>${pages}\` against that build. Polish capture is optional for a page-only change.`);
158
+ } else if (ci.status === "owed" || ci.status === "unknown") {
94
159
  const matchingRequired = owed.length > 0
95
160
  && owed.every((stage) => requiredStageReasons.has(ci[stage].reason) && ci[stage].matches_current_build === true)
96
161
  && ["polish", "qa"].every((stage) => ci[stage].status !== "unknown");
@@ -1,7 +1,8 @@
1
1
  // Doctor entry points: the doctor command, packet inspection and built-output inspection.
2
2
  import { resolveCampaignIdentity } from "../spec-source-identity.mjs";
3
3
  import { assessDoctorCi, doctorCiFailure } from "./ci.mjs";
4
- import { applyLocalPreviewToCheckpoint, applyLocalPreviewToPolishGate, carriedForwardMessage, CARRIED_FORWARD } from "../local-preview-policy.mjs";
4
+ import { applyLocalPreviewToCheckpoint, applyLocalPreviewToPolishGate, applyPageOnlyChangeToCheckpoint, applyPageOnlyChangeToPolishGate, carriedForwardMessage, CARRIED_FORWARD } from "../local-preview-policy.mjs";
5
+ import { assessEvidenceDebt } from "../evidence-debt.mjs";
5
6
  import { withHtmlScanSnapshot } from "../html-scan.mjs";
6
7
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
7
8
  import { dirname, join, resolve } from "node:path";
@@ -11,7 +12,7 @@ import { commitAssemblyReport, recordProducerStageOutcome } from "../stage-ledge
11
12
  import { annotateDoctorIssueCauses } from "../finding-cause.mjs";
12
13
  import { DOCTOR_SIDECAR_SCHEMA } from "../doctor-sidecar.mjs";
13
14
  import { recordQcResults } from "../qc-results.mjs";
14
- import { resolveCampaignWorkspace } from "../campaign-workspace.mjs";
15
+ import { packetRuntimeRelPath, resolveCampaignWorkspace } from "../campaign-workspace.mjs";
15
16
  import { normalizePublicRouteSlug } from "../route-identity.mjs";
16
17
  import { DEFAULT_PROXY_BASE } from "../spec-fetch.mjs";
17
18
  import { disabledLiveRead, liveRefsDisabled, readLiveCampaignForPacket } from "../live-campaign-refs.mjs";
@@ -458,6 +459,16 @@ export function doctorBuiltOutput(args) {
458
459
  };
459
460
  }
460
461
 
462
+ // Inspection already refused a packet whose runtime directory is unreadable;
463
+ // the history lookup then falls back to the project-level directory.
464
+ function packetRuntimeRelPathOrDefault(packetPath) {
465
+ try {
466
+ return packetRuntimeRelPath(JSON.parse(readFileSync(resolve(packetPath), "utf8")));
467
+ } catch {
468
+ return undefined;
469
+ }
470
+ }
471
+
461
472
  export function doctorPacket(packetPath, options = {}) {
462
473
  const result = withHtmlScanSnapshot(() => inspectDoctorPacket(packetPath, options));
463
474
  // Per-finding cause classification lives HERE, at the single production
@@ -487,6 +498,7 @@ export function doctorPacket(packetPath, options = {}) {
487
498
  errors: result.errors,
488
499
  warnings: result.warnings,
489
500
  baseDir: comparableIdentity ? dirname(resolve(packetPath)) : null,
501
+ runtimeRelPath: comparableIdentity ? packetRuntimeRelPathOrDefault(packetPath) : undefined,
490
502
  mapId: result.derived?.map_id || null,
491
503
  localSpecId: result.derived?.local_spec_id || null,
492
504
  });
@@ -587,16 +599,21 @@ function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath =
587
599
 
588
600
  // The local preview policy (local-preview-policy.mjs) carries missing
589
601
  // polish and page-load evidence forward as warnings on a local-serve packet.
590
- const polishCheckpointGate = applyLocalPreviewToCheckpoint(
602
+ // A page-only change since Polish last reviewed the campaign carries its
603
+ // stale evidence on any target: Polish is advisory for it (#720). The
604
+ // evidence debt is the one readback and doctor --ci assess.
605
+ const evidenceDebt = assessEvidenceDebt({ packet, packetPath, report });
606
+ derived.evidence_debt = evidenceDebt;
607
+ const polishCheckpointGate = applyPageOnlyChangeToCheckpoint(applyLocalPreviewToCheckpoint(
591
608
  evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report }),
592
609
  { packet, report },
593
- );
610
+ ), evidenceDebt);
594
611
  derived.polish_checkpoint_gate = polishCheckpointGate;
595
- const polishGate = applyLocalPreviewToPolishGate(evaluatePolishGate({
612
+ const polishGate = applyPageOnlyChangeToPolishGate(applyLocalPreviewToPolishGate(evaluatePolishGate({
596
613
  report,
597
614
  hiddenEagerMediaGate: polishCheckpointGate,
598
615
  currentOutputFingerprint: derived.build_output_fingerprint?.value || null,
599
- }), { packet, checkpointGate: polishCheckpointGate });
616
+ }), { packet, checkpointGate: polishCheckpointGate }), { debt: evidenceDebt, checkpointGate: polishCheckpointGate });
600
617
  derived.polish_gate = polishGate;
601
618
  if (polishGate.status === "blocked" && !polishGate.owned_checkpoint_only) {
602
619
  pushGateIssue({ errors, warnings }, gateIssue("polish_gate", polishGate));
@@ -503,6 +503,13 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
503
503
  }
504
504
  if (cliStage === "qa" && qaRecordedBuildFingerprint(report) && currentBuildFingerprint(report)
505
505
  && !qaRecordedForCurrentBuild(report, currentBuildFingerprint(report))) {
506
+ // A page-only change since the build QA reviewed owes QA on the
507
+ // changed pages only (#720); qa run --page pays it.
508
+ const qaDebt = derived?.evidence_debt?.qa;
509
+ if (qaDebt?.scope === "pages" && qaDebt.owed_pages?.length) {
510
+ const pages = qaDebt.owed_pages.map((page) => `--page ${page.page_id}`).join(" ");
511
+ return { stage: "qa", reason: `QA was recorded for a different build, and the change since then is page-only: run QA on the changed pages with ${cmd("qa")} run --packet <packet> ${pages}.` };
512
+ }
506
513
  return { stage: "qa", reason: "QA was recorded for a different build; run QA against the current build." };
507
514
  }
508
515
  // A terminal QA status is not the same claim as purchase proof. QA finalizes
@@ -73,8 +73,9 @@ export function writeDoctorSidecar(path, doctor, { command, targetRepo = null }
73
73
  // qa policy set) stamp the retained snapshot stale instead of leaving a green
74
74
  // lie on disk, while commands that DO recompute (doctor, prepare-build/start,
75
75
  // next) rewrite the sidecar wholesale — which clears any stale stamp.
76
- export function markDoctorSidecarStale(targetBaseDir, { command = null, reason = null } = {}) {
77
- const path = doctorSidecarPath(targetBaseDir);
76
+ // `path` is the funnel's own sidecar (campaign-workspace.mjs doctorOutPath);
77
+ // without it, the project-level one under `targetBaseDir`.
78
+ export function markDoctorSidecarStale(targetBaseDir, { command = null, reason = null, path = doctorSidecarPath(targetBaseDir) } = {}) {
78
79
  if (!existsSync(path)) return null;
79
80
  let sidecar;
80
81
  try {
@@ -9,9 +9,10 @@ import {
9
9
  resolveBuiltSiteScope,
10
10
  routeForFile,
11
11
  } from "./built-site-scope.mjs";
12
+ import { changeSince, classifyDrift } from "./change-scope.mjs";
12
13
  import { normalizePublicRouteSlug } from "./route-identity.mjs";
13
14
  import { isFileReadFailure } from "./cart-placeholders.mjs";
14
- import { qaRecordedBuildFingerprint, qaRecordedForCurrentBuild } from "./stage-ledger.mjs";
15
+ import { polishReviewedBuild, qaExtendedBuildFingerprint, qaRecordedBuildFingerprint, qaRecordedForCurrentBuild } from "./stage-ledger.mjs";
15
16
 
16
17
  export const EVIDENCE_DEBT_STATUSES = Object.freeze(["current", "owed", "unknown"]);
17
18
 
@@ -24,6 +25,12 @@ const portable = (value) => value.split(sep).join("/");
24
25
  * the build an older stage reviewed. We cannot reconstruct an older manifest.
25
26
  * Missing build inputs remain unknown; lifecycle-required stages are owed
26
27
  * even without a stage binding. A matching binding alone is not a verdict.
28
+ *
29
+ * Each owed stage also carries `change`: what changed since the build that
30
+ * stage reviewed (change-scope.mjs), read from the rebuilds `record build`
31
+ * kept plus the drift on disk. A page-only change makes owed Polish
32
+ * `advisory` and owed QA `scope: "pages"` on `owed_pages`; anything else
33
+ * owes the full stage.
27
34
  */
28
35
  export function assessEvidenceDebt({ packet = null, packetPath = null, report = null } = {}) {
29
36
  const assembly = report?.stages?.assembly;
@@ -38,12 +45,13 @@ export function assessEvidenceDebt({ packet = null, packetPath = null, report =
38
45
  let outputRoot = null;
39
46
  let reason = null;
40
47
  let scopeReason = null;
48
+ let targetRepo = null;
41
49
  const target = fingerprint(packet?.assembly?.target_repo);
42
50
  const slug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
43
51
  if (!packet || !packetPath) reason = "packet_unavailable";
44
52
  else if (!target || !slug) reason = "build_output_location_unknown";
45
53
  else {
46
- const targetRepo = resolve(dirname(resolve(packetPath)), target);
54
+ targetRepo = resolve(dirname(resolve(packetPath)), target);
47
55
  outputRoot = join(targetRepo, "_site", slug);
48
56
  try {
49
57
  current = computeBuildFingerprint(outputRoot);
@@ -111,9 +119,41 @@ export function assessEvidenceDebt({ packet = null, packetPath = null, report =
111
119
  else changes.unmapped_files.push(path);
112
120
  }
113
121
  }
114
- return {
115
- polish: stage(report?.stages?.polish, polishBuild, Boolean(polishBuild && currentBuild && polishBuild === currentBuild)),
116
- qa: stage(report?.stages?.qa, qaBuild, qaRecordedForCurrentBuild(report, currentBuild)),
117
- changes,
118
- };
122
+ const driftClassification = classifyDrift({ changes, baseline: assembly, outputRoot, targetRepo, publicRouteSlug: slug });
123
+ changes.kind = driftClassification.kind;
124
+ changes.reasons = driftClassification.reasons;
125
+ const changeFor = (anchor) => changeSince(anchor, { assembly, drift: changes, driftClassification });
126
+
127
+ // Polish reviewed the build a terminal record is bound to; once `record
128
+ // build` has made it required, the build it reviewed is kept as
129
+ // owed_since_build_fingerprint (polishReviewedBuild). Only a Polish that
130
+ // reviewed a build can be advisory: a blocked or pending one, or one a
131
+ // brief or spec change made required, owes the full stage, before and
132
+ // after record build alike.
133
+ const polishRecord = report?.stages?.polish;
134
+ const polish = stage(polishRecord, polishBuild, Boolean(polishBuild && currentBuild && polishBuild === currentBuild));
135
+ if (polish.status === "owed") {
136
+ const reviewed = polishReviewedBuild(polishRecord);
137
+ polish.change = changeFor(reviewed || polishBuild || fingerprint(polishRecord?.owed_since_build_fingerprint));
138
+ polish.advisory = Boolean(reviewed) && polish.change.kind === "page_only";
139
+ } else {
140
+ polish.change = null;
141
+ polish.advisory = false;
142
+ }
143
+ // QA on the changed pages only extends a completed QA: the change is read
144
+ // from the build that QA checked (qaExtendedBuildFingerprint). A blocked,
145
+ // pending or skipped full run extends nothing and owes the full stage.
146
+ const qaRecord = report?.stages?.qa;
147
+ const qa = stage(qaRecord, qaBuild, qaRecordedForCurrentBuild(report, currentBuild));
148
+ if (qa.status === "owed") {
149
+ const extended = qaExtendedBuildFingerprint(report);
150
+ qa.change = changeFor(extended || qaBuild);
151
+ qa.scope = extended && qaRecord?.status !== "required" && qa.change.kind === "page_only" ? "pages" : "full";
152
+ qa.owed_pages = qa.scope === "pages" ? qa.change.changed_pages : [];
153
+ } else {
154
+ qa.change = null;
155
+ qa.scope = null;
156
+ qa.owed_pages = [];
157
+ }
158
+ return { polish, qa, changes };
119
159
  }
@@ -330,7 +330,8 @@ import { iterateQaVerdicts } from "./qa-verdict-discovery.mjs";
330
330
  import { readRunRecordsForTarget } from "./run-record.mjs";
331
331
 
332
332
  /**
333
- * The most recent Run Record for the same campaign identity under `baseDir`,
333
+ * The most recent Run Record for the same campaign identity under `baseDir`
334
+ * (in the packet's runtime directory, `runtimeRelPath`, when one is given),
334
335
  * or null. Records are discovered newest-first, and the current run's record
335
336
  * does not exist yet at classification time (both `qa run` and `doctor`
336
337
  * complete before `run-record` assembles anything), so the first identity
@@ -341,9 +342,9 @@ import { readRunRecordsForTarget } from "./run-record.mjs";
341
342
  * against a non-adjacent one and report findings introduced in between as
342
343
  * pre-existing.
343
344
  */
344
- export function findPriorRunRecord({ baseDir, mapId = null, localSpecId = null, currentRunId = null } = {}) {
345
+ export function findPriorRunRecord({ baseDir, runtimeRelPath = undefined, mapId = null, localSpecId = null, currentRunId = null } = {}) {
345
346
  if (!text(baseDir)) return null;
346
- for (const entry of readRunRecordsForTarget(baseDir)) {
347
+ for (const entry of readRunRecordsForTarget(baseDir, runtimeRelPath)) {
347
348
  const record = entry?.record;
348
349
  if (!record || typeof record !== "object" || Array.isArray(record)) continue;
349
350
  if (currentRunId && record.run_id === currentRunId) continue;
@@ -448,8 +449,8 @@ function locateExternalPriorVerdict({ targetRepo, record, ref }) {
448
449
  * The single-record boundary is unchanged: this still reads the final attempt
449
450
  * of exactly one earlier run, never a merged view across runs.
450
451
  */
451
- export function loadPriorQaVerdict({ baseDir, targetRepo = null, mapId = null, localSpecId = null, currentRunId = null } = {}) {
452
- const record = findPriorRunRecord({ baseDir, mapId, localSpecId, currentRunId });
452
+ export function loadPriorQaVerdict({ baseDir, runtimeRelPath = undefined, targetRepo = null, mapId = null, localSpecId = null, currentRunId = null } = {}) {
453
+ const record = findPriorRunRecord({ baseDir, runtimeRelPath, mapId, localSpecId, currentRunId });
453
454
  if (!record) return { verdict: null, record: null, path: null, reason: "no_prior_run" };
454
455
  const ref = (Array.isArray(record.artifacts) ? record.artifacts : []).findLast((artifact) => artifact?.kind === "qa_verdict");
455
456
  const refPath = text(ref?.path);
@@ -472,8 +473,8 @@ export function loadPriorQaVerdict({ baseDir, targetRepo = null, mapId = null, l
472
473
  * carries these, so doctor cause labels work against existing history with no
473
474
  * upgrade window. Returns `{ prior, record, reason }`.
474
475
  */
475
- function loadPriorDoctorFindings({ baseDir, mapId = null, localSpecId = null, currentRunId = null } = {}) {
476
- const record = findPriorRunRecord({ baseDir, mapId, localSpecId, currentRunId });
476
+ function loadPriorDoctorFindings({ baseDir, runtimeRelPath = undefined, mapId = null, localSpecId = null, currentRunId = null } = {}) {
477
+ const record = findPriorRunRecord({ baseDir, runtimeRelPath, mapId, localSpecId, currentRunId });
477
478
  if (!record) return { prior: null, record: null, reason: "no_prior_run" };
478
479
  const prior = priorSetFromRunRecordDoctor(record);
479
480
  if (!prior) return { prior: null, record, reason: "prior_run_without_doctor_observations" };
@@ -496,9 +497,9 @@ function loadPriorDoctorFindings({ baseDir, mapId = null, localSpecId = null, cu
496
497
  * raw map id) have no Run Record home, so they get `unknown` / `no_prior_run`
497
498
  * throughout — which is the truth, not a silence.
498
499
  */
499
- export function annotateQaAssertionCauses(assertions, { baseDir = null, targetRepo = null, mapId = null, localSpecId = null, currentRunId = null, isFinding } = {}) {
500
+ export function annotateQaAssertionCauses(assertions, { baseDir = null, runtimeRelPath = undefined, targetRepo = null, mapId = null, localSpecId = null, currentRunId = null, isFinding } = {}) {
500
501
  const lookup = baseDir
501
- ? loadPriorQaVerdict({ baseDir, targetRepo, mapId, localSpecId, currentRunId })
502
+ ? loadPriorQaVerdict({ baseDir, runtimeRelPath, targetRepo, mapId, localSpecId, currentRunId })
502
503
  : { verdict: null, record: null, path: null, reason: "no_prior_run" };
503
504
  const prior = lookup.verdict ? priorSetFromVerdict(lookup.verdict, { isFinding }) : null;
504
505
  const noPriorReason = lookup.reason || "no_prior_run";
@@ -532,9 +533,9 @@ const CAUSE_SUMMARY_SCHEMA = "campaigns-os-finding-cause/v0";
532
533
  * The doctor twin. `errors` and `warnings` are the doctor output's own arrays;
533
534
  * both are stamped in place. Returns the same summary shape.
534
535
  */
535
- export function annotateDoctorIssueCauses({ errors = [], warnings = [], baseDir = null, mapId = null, localSpecId = null, currentRunId = null } = {}) {
536
+ export function annotateDoctorIssueCauses({ errors = [], warnings = [], baseDir = null, runtimeRelPath = undefined, mapId = null, localSpecId = null, currentRunId = null } = {}) {
536
537
  const lookup = baseDir
537
- ? loadPriorDoctorFindings({ baseDir, mapId, localSpecId, currentRunId })
538
+ ? loadPriorDoctorFindings({ baseDir, runtimeRelPath, mapId, localSpecId, currentRunId })
538
539
  : { prior: null, record: null, reason: "no_prior_run" };
539
540
  const noPriorReason = lookup.reason || "no_prior_run";
540
541
  const findings = [];