@nextcommerce/campaigns-os 1.43.1 → 1.46.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 (92) hide show
  1. package/AGENTS.md +9 -2
  2. package/CHANGELOG.md +1099 -5103
  3. package/README.md +34 -13
  4. package/agents/claude/CLAUDE.md +1 -1
  5. package/agents/codex/AGENTS.md +1 -1
  6. package/agents/copilot/copilot-instructions.md +1 -1
  7. package/agents/cursor/campaigns-os.mdc +1 -1
  8. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +2 -2
  9. package/campaign-spec/dist/rules/analytics-contract-shape.js +2 -2
  10. package/campaign-spec/dist/rules/store-profile-shape.d.ts +5 -1
  11. package/campaign-spec/dist/rules/store-profile-shape.js +8 -10
  12. package/campaign-spec/dist/types.d.ts +2 -2
  13. package/contracts/agent-relevant-change-policy.v1.json +5 -0
  14. package/contracts/archive/CHANGELOG.2026-09-30.md +5111 -0
  15. package/contracts/archive/release-ledger.2026-09-30.json +5068 -0
  16. package/contracts/effects.v1.json +1184 -121
  17. package/contracts/orientation-reason-codes.v1.json +7 -0
  18. package/contracts/release-ledger.json +2190 -5260
  19. package/contracts/supported-surface.json +7 -4
  20. package/contracts/template-slot-manifest.shared-content-core.v0.json +403 -0
  21. package/docs/brand-theme-bridge.md +81 -0
  22. package/docs/build-packet.md +222 -23
  23. package/docs/campaigns-os-build-flow.md +4 -3
  24. package/docs/design-source-package.md +162 -15
  25. package/docs/effects.md +66 -12
  26. package/docs/gateway-login.md +3 -0
  27. package/docs/local-setup.md +1 -1
  28. package/docs/orientation-contract-reference.md +42 -2
  29. package/docs/polish-evidence.md +74 -0
  30. package/docs/progress-snapshots.md +10 -6
  31. package/docs/qa-and-test-orders.md +230 -20
  32. package/docs/release-ledger-authoring-guide.md +70 -8
  33. package/docs/runtime-readiness.md +1 -1
  34. package/docs/sdk-storage-compatibility.md +1 -1
  35. package/docs/skills-revision.md +10 -10
  36. package/docs/supported-surface.md +2 -2
  37. package/docs/versioning.md +4 -1
  38. package/package.json +1 -1
  39. package/schemas/campaigns-os-release-ledger.v1.schema.json +32 -2
  40. package/schemas/campaigns-os-tooling-orientation.v1.schema.json +1 -0
  41. package/skills/campaign-lifecycle-orientation/SKILL.md +16 -5
  42. package/skills/campaign-readback-classification/SKILL.md +3 -3
  43. package/skills/campaign-run-evidence/SKILL.md +7 -6
  44. package/skills/contribution-intake/SKILL.md +3 -3
  45. package/skills/next-campaigns-build/SKILL.md +7 -6
  46. package/skills/next-campaigns-os/SKILL.md +7 -7
  47. package/skills/next-campaigns-os/references/session-intake.md +9 -3
  48. package/skills/next-campaigns-os-setup/SKILL.md +5 -5
  49. package/skills/next-campaigns-polish/SKILL.md +28 -9
  50. package/skills/next-campaigns-qa/SKILL.md +7 -4
  51. package/skills.json +10 -10
  52. package/src/brand-theme.mjs +320 -20
  53. package/src/build-brief.mjs +6 -4
  54. package/src/built-script-syntax.mjs +480 -0
  55. package/src/built-site-scope.mjs +16 -4
  56. package/src/campaigns-api-key.mjs +99 -0
  57. package/src/cli-helpers.mjs +118 -0
  58. package/src/cli.mjs +1530 -7580
  59. package/src/commercial-parity.mjs +48 -2
  60. package/src/design-source-package.mjs +1 -1
  61. package/src/design-source-publication.mjs +898 -0
  62. package/src/deviation.mjs +13 -1
  63. package/src/diagnostic.mjs +6 -2
  64. package/src/directory-lock.mjs +270 -0
  65. package/src/doctor/checks.mjs +4654 -0
  66. package/src/doctor/inspect.mjs +678 -0
  67. package/src/doctor/next-step.mjs +731 -0
  68. package/src/doctor/source-provenance.mjs +184 -0
  69. package/src/install-invocation.mjs +29 -0
  70. package/src/invocation.mjs +183 -0
  71. package/src/live-campaign-refs.mjs +466 -0
  72. package/src/login.mjs +2 -2
  73. package/src/page-kit-store-profile.mjs +69 -12
  74. package/src/page-kit-sync.mjs +31 -12
  75. package/src/private-template-source.mjs +1 -1
  76. package/src/progress-node.mjs +9 -36
  77. package/src/proof-policy.mjs +1 -1
  78. package/src/qa-analytics-correctness.mjs +3 -0
  79. package/src/qa-binding-evidence.mjs +76 -11
  80. package/src/qa-browser.mjs +1316 -105
  81. package/src/qa-build-scope.mjs +47 -0
  82. package/src/qa-commercial-parity.mjs +48 -5
  83. package/src/qa-node.mjs +339 -19
  84. package/src/qa-test-order-topology.mjs +148 -0
  85. package/src/sdk-markup.mjs +72 -8
  86. package/src/source-html-intake.mjs +117 -1
  87. package/src/source-html-manifest.mjs +9 -2
  88. package/src/stage-ledger.mjs +28 -0
  89. package/src/stage-record.mjs +551 -0
  90. package/src/target-lock.mjs +54 -0
  91. package/src/template-brand-contract.mjs +17 -1
  92. package/src/upsell-selector-scope.mjs +112 -2
@@ -0,0 +1,678 @@
1
+ // Doctor entry points: the doctor command, packet inspection and built-output inspection.
2
+ import { resolveCampaignIdentity } from "../spec-source-identity.mjs";
3
+ import { withHtmlScanSnapshot } from "../html-scan.mjs";
4
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import { shellToken } from "../shell-token.mjs";
7
+ import { commitAssemblyReport, recordProducerStageOutcome } from "../stage-ledger.mjs";
8
+ import { annotateDoctorIssueCauses } from "../finding-cause.mjs";
9
+ import { DOCTOR_SIDECAR_SCHEMA } from "../doctor-sidecar.mjs";
10
+ import { resolveCampaignWorkspace } from "../campaign-workspace.mjs";
11
+ import { normalizePublicRouteSlug } from "../route-identity.mjs";
12
+ import { DEFAULT_PROXY_BASE } from "../spec-fetch.mjs";
13
+ import { disabledLiveRead, liveRefsDisabled, readLiveCampaignForPacket } from "../live-campaign-refs.mjs";
14
+ import { evaluateThemeGate } from "../theme-gate.mjs";
15
+ import { findForbiddenPriceHides } from "../template-brand-contract.mjs";
16
+ import { resolveBuiltSiteScope, synthesizeMinimalBuildPacket } from "../built-site-scope.mjs";
17
+ import { UPSELL_SELECTOR_SCOPE, builtPageTypeOverRouteGuess } from "../upsell-selector-scope.mjs";
18
+ import { CAMPAIGN_IDENTITY } from "../campaign-identity.mjs";
19
+ import { SDK_MARKUP } from "../sdk-markup.mjs";
20
+ import { SCRIPT_SYNTAX, collectBuiltScriptSyntaxInputs } from "../built-script-syntax.mjs";
21
+ import { stageIsTerminal } from "../orchestration-stage-contract.mjs";
22
+ import { evaluatePolishGate } from "../polish-gate.mjs";
23
+ import { evaluateRecordedHiddenEagerMediaCheckpoint } from "../polish-node.mjs";
24
+ import {
25
+ requireArg,
26
+ isObject,
27
+ isNonEmptyString,
28
+ optionalString,
29
+ readJsonIfExists,
30
+ relFromDir,
31
+ isLocalAbsolutePath,
32
+ addIssue,
33
+ } from "../cli-helpers.mjs";
34
+ import {
35
+ PACKET_SCHEMA,
36
+ runDoctorChecks,
37
+ ARTIFACT_DOCTOR_CHECKS,
38
+ validatePacket,
39
+ recordUpsellSelectorScopeGate,
40
+ collectBuiltPageIdentityInputs,
41
+ recordCampaignIdentityGate,
42
+ recordScriptSyntaxGate,
43
+ recordSdkMarkupGate,
44
+ summarizeCopyMatches,
45
+ resolveBrandContractOnce,
46
+ reportBrandContractDefectOnce,
47
+ validateBuiltPlaceholderTextResidue,
48
+ validateBuiltDemoAssetFidelity,
49
+ collectGenericTemplateResidueMatches,
50
+ } from "./checks.mjs";
51
+ import {
52
+ gateIssue,
53
+ pushGateIssue,
54
+ nextPrepareBuildBindingIssues,
55
+ prepareBuildGateIssue,
56
+ addPrepareBuildGateErrors,
57
+ checkpointExceptionPresent,
58
+ buildNextStep,
59
+ } from "./next-step.mjs";
60
+
61
+ function writeJson(path, value) {
62
+ mkdirSync(dirname(resolve(path)), { recursive: true });
63
+ writeFileSync(resolve(path), `${JSON.stringify(value, null, 2)}\n`);
64
+ }
65
+
66
+ function relativizeDoctorOutput(result, baseDir) {
67
+ const replacements = new Map();
68
+ for (const value of Object.values(result.derived || {})) {
69
+ if (isLocalAbsolutePath(value)) {
70
+ replacements.set(value, relFromDir(baseDir, value));
71
+ }
72
+ }
73
+ const sortedReplacements = [...replacements.entries()].sort((a, b) => b[0].length - a[0].length);
74
+
75
+ function visit(value) {
76
+ if (Array.isArray(value)) return value.map(visit);
77
+ if (isObject(value)) {
78
+ return Object.fromEntries(Object.entries(value).map(([key, entryValue]) => [key, visit(entryValue)]));
79
+ }
80
+ if (typeof value !== "string") return value;
81
+ if (isLocalAbsolutePath(value)) return relFromDir(baseDir, value);
82
+ let nextValue = value;
83
+ for (const [absolutePath, relativePath] of sortedReplacements) {
84
+ nextValue = nextValue.split(absolutePath).join(relativePath);
85
+ }
86
+ return nextValue;
87
+ }
88
+
89
+ return visit(result);
90
+ }
91
+ // Sidecar producer name; see the producer comment above NEXT_PRODUCER in src/cli.mjs.
92
+ const DOCTOR_PRODUCER = "doctor";
93
+
94
+ // The live campaign read the `doctor` command makes before it inspects (#533):
95
+ // packet mode only, only when the packet's built _site/<route>/ exists (with
96
+ // no built page there is nothing to compare, so nothing is sent), and only
97
+ // under the public Campaigns API key the packet, its local CampaignSpec or its
98
+ // declared campaign-key env var resolves. One GET of {proxy-base}/api/campaign;
99
+ // --proxy-base names the proxy, else the canonical one; --no-live-refs sends
100
+ // nothing and records not_run with reason `disabled`. `undefined` means no
101
+ // read was due, which doctor records as not_run; every other outcome,
102
+ // failures included, is a readLiveCampaign result.
103
+ export async function readDoctorLiveCampaign(args, { fetchImpl = globalThis.fetch, env = process.env, warn = undefined } = {}) {
104
+ if (((args.built || args.site) && !args.packet) || !isNonEmptyString(args.packet)) return undefined;
105
+ if (liveRefsDisabled(args)) return disabledLiveRead();
106
+ let workspace;
107
+ try {
108
+ workspace = resolveCampaignWorkspace(resolve(args.packet), { followContextPointer: false });
109
+ } catch {
110
+ // An unreadable packet is doctor's own blocker; there is nothing to read for.
111
+ return undefined;
112
+ }
113
+ const slug = normalizePublicRouteSlug(workspace.packet?.campaign?.public_route_slug);
114
+ if (!workspace.targetRepo || !slug || !existsSync(join(workspace.targetRepo, "_site", slug))) return undefined;
115
+ return readLiveCampaignForPacket({
116
+ packet: workspace.packet,
117
+ packetPath: workspace.packetPath,
118
+ env,
119
+ fetchImpl,
120
+ proxyBase: optionalString(args["proxy-base"]) || DEFAULT_PROXY_BASE,
121
+ ...(warn ? { warn } : {}),
122
+ });
123
+ }
124
+
125
+ export function doctorCommand(args, { runDoctor = doctorPacket, liveCampaign = undefined } = {}) {
126
+ // Non-packet mode (learnings L7): doctor a `campaign-build`'d page-kit
127
+ // campaign that has only a built _site/ and no full Build Packet. Resolves
128
+ // scope from the built output and runs the built-output residue/text/
129
+ // demo-asset/pricing gates against the chosen family's brand contract.
130
+ const builtArg = args.built || args.site;
131
+ if (builtArg && !args.packet) {
132
+ return doctorBuiltOutput(args);
133
+ }
134
+ const packetPath = resolve(requireArg(args, "packet"));
135
+ const explicitSidecarArgs = Boolean(args.context || args.report);
136
+ const doctorOptions = {
137
+ contextPath: args.context ? resolve(args.context) : explicitSidecarArgs ? null : undefined,
138
+ reportPath: args.report ? resolve(args.report) : explicitSidecarArgs ? null : undefined,
139
+ outputBaseDir: args["strip-paths"] === true ? dirname(packetPath) : null,
140
+ // A live campaign read (live-campaign-refs.mjs) the caller already made;
141
+ // absent, the live ref check is recorded not_run.
142
+ ...(liveCampaign !== undefined ? { liveCampaign } : {}),
143
+ };
144
+ const result = runDoctor(packetPath, doctorOptions);
145
+ // Inspection and recording are separate operations. A laptop's untracked
146
+ // built output can be stale while the delivered build's evidence is valid.
147
+ // Only an explicit producer action may replace the retained proof artifacts;
148
+ // --no-write wins if both flags are supplied.
149
+ if (args.write === true && args["no-write"] !== true) {
150
+ // The stage write-back restates the outcome into the report the
151
+ // inspection read: the one --report named, else the one the Build Context
152
+ // binds (`derived.assembly_report_path`, a `prepare-build --report-out`
153
+ // campaign's report), else the default location. Following the binding is
154
+ // what keeps the doctor stage on a bound report current; a report of
155
+ // another campaign is still refused by commitAssemblyReport's identity
156
+ // check, so the outcome never lands in another run's evidence. The
157
+ // sidecar itself goes under the target repo, where prepare-build, next
158
+ // and the QA stage refresh write it — not beside the packet.
159
+ const inspectedReportPath = optionalString(result.derived?.assembly_report_path);
160
+ const workspace = resolveCampaignWorkspace(packetPath, {
161
+ reportPath: args.report
162
+ ? resolve(args.report)
163
+ : inspectedReportPath
164
+ ? resolve(dirname(packetPath), inspectedReportPath)
165
+ : undefined,
166
+ doctorOutPath: args["doctor-out"] ? resolve(args["doctor-out"]) : undefined,
167
+ followContextPointer: false,
168
+ });
169
+ // The sidecar is this inspection's result, written whether or not the
170
+ // report gained a new chapter (a re-run restating the outcome already on
171
+ // disk leaves the report's bytes, and every digest of them, alone). A
172
+ // report this inspection did not read is not opened at all: its state,
173
+ // malformed included, is not this run's concern.
174
+ // This function is the `doctor` command; it states its own name for the
175
+ // stage record and the sidecar's generated_by rather than re-reading
176
+ // argv, which a programmatic caller may not have shifted (#312).
177
+ commitAssemblyReport(workspace, (report) => recordDoctorStageOutcome(report, result, {
178
+ command: `campaigns-os ${DOCTOR_PRODUCER}`,
179
+ doctorOutPath: workspace.doctorOutPath,
180
+ }), { stage: "doctor", command: DOCTOR_PRODUCER, refreshDoctor: () => result });
181
+ }
182
+ return result;
183
+ }
184
+
185
+ function recordDoctorStageOutcome(report, result, { command, doctorOutPath }) {
186
+ return recordProducerStageOutcome(report, {
187
+ stage: "doctor",
188
+ disposition: result.ok ? (result.warnings?.length ? "ready_with_warnings" : "ready") : "blocked",
189
+ timestamp: result.generated_at,
190
+ command,
191
+ outputs: [doctorOutPath],
192
+ blockers: (result.errors || []).map((issue) => issue?.message).filter(isNonEmptyString),
193
+ warnings: (result.warnings || []).map((issue) => issue?.message).filter(isNonEmptyString),
194
+ });
195
+ }
196
+
197
+
198
+ // L7 non-packet doctor: resolve scope from a built _site/, run the built-output
199
+ // gates the family brand contract drives, and auto-emit a minimal Build Packet
200
+ // (optionally written with --emit-packet) so QA can run against the same
201
+ // campaign without a hand-authored packet.
202
+ export function doctorBuiltOutput(args) {
203
+ const targetRepo = resolve(String(args.built || args.site));
204
+ const errors = [];
205
+ const warnings = [];
206
+ const ready = [];
207
+ if (!existsSync(targetRepo) || !statSync(targetRepo).isDirectory()) {
208
+ addIssue(errors, "built_site.target", `Built campaign directory does not exist: ${targetRepo}`);
209
+ return { ok: false, status: "blocked", mode: "built_site", errors, warnings, ready, derived: { mode: "built_site" }, next: null };
210
+ }
211
+
212
+ const scope = resolveBuiltSiteScope(targetRepo, { slug: optionalString(args.slug) });
213
+ if (!scope.ok) {
214
+ addIssue(errors, "built_site.scope", scope.error || "Could not resolve scope from the built _site/.");
215
+ return { ok: false, status: "blocked", mode: "built_site", errors, warnings, ready, derived: { mode: "built_site", scope }, next: null };
216
+ }
217
+
218
+ const family = optionalString(args.family) || null;
219
+ const baseUrl = optionalString(args["base-url"]);
220
+ const mapId = optionalString(args["map-id"]);
221
+ const deployTarget = optionalString(args["deploy-target"], "unknown");
222
+
223
+ const derived = {
224
+ mode: "built_site",
225
+ map_id: mapId || scope.slug || null,
226
+ public_route_slug: scope.slug || null,
227
+ template_family: family,
228
+ target_repo: targetRepo,
229
+ target_output_dir: scope.campaign_dir,
230
+ site_root: scope.site_root,
231
+ built_pages: scope.pages.map((page) => ({ page_id: page.page_id, type: page.page_type, route: page.route })),
232
+ doctor_checks: [],
233
+ checkpoint_gates: [],
234
+ };
235
+ ready.push(`Resolved ${scope.html_count} built page(s) from ${relFromDir(targetRepo, scope.campaign_dir)} (slug "${scope.slug || "(site root)"}")`);
236
+
237
+ const resolution = resolveBrandContractOnce(derived, family);
238
+ let brandContract = null;
239
+ if (!family) {
240
+ addIssue(warnings, "assembly.template_family", "No --family given; the residue/placeholder-text/demo-asset gates need a family brand contract to run. Pass --family <family> (the family the campaign was built from).");
241
+ } else {
242
+ reportBrandContractDefectOnce(resolution, warnings, family);
243
+ brandContract = resolution.contract;
244
+ if (!brandContract) {
245
+ addIssue(warnings, "template_contract.brand_contract", `No brand/residue/pricing contract found for family "${family}". Built-output residue gates cannot run; confirm the family slug.`);
246
+ } else {
247
+ ready.push(`Template brand/residue/pricing contract loaded for ${family}`);
248
+ validateBuiltPlaceholderTextResidue(brandContract, warnings, ready, derived);
249
+ validateBuiltDemoAssetFidelity(brandContract, warnings, ready, derived);
250
+ // Pricing CSS-hide scan (report omitted -> a missing assets/css dir reads
251
+ // as a skipped ready-line, not a false "scan did not run" warning, since
252
+ // built page-kit output may lay CSS out differently).
253
+ runPricingCssHideCheck({ packet: { assembly: { template_family: family } }, derived, warnings, ready, report: null });
254
+ }
255
+ }
256
+
257
+ // Family-agnostic generic placeholder residue (XXCODE / Product Title /
258
+ // next-logo.png ...) always runs against the built output.
259
+ const genericHits = collectGenericTemplateResidueMatches(scope.campaign_dir);
260
+ if (genericHits.length) {
261
+ addIssue(
262
+ warnings,
263
+ "template_contract.literal_residue",
264
+ `Built output contains generic starter/template placeholders: ${summarizeCopyMatches(genericHits)}. Replace these from CampaignSpec/API or remove dead template references.`,
265
+ );
266
+ } else {
267
+ ready.push("Built output has no generic starter placeholder or promo-code residue");
268
+ }
269
+
270
+ // Upsell selector scope (#270). Deliberately outside the family/brand-contract
271
+ // branch above: the defect is family-independent, and this mode is reached
272
+ // without --family more often than with it. Page roles come from the built
273
+ // route (resolveBuiltSiteScope infers them) and from each page's own
274
+ // next-page-type meta, so no packet or spec is needed; a `checkout` meta the
275
+ // browser reads, declared unambiguously, replaces an ambiguous route guess
276
+ // such as "/checkout-oto-1/", never an explicit upsell or downsell route (#529). No
277
+ // assembly report exists on this path, so there are no waivers to assess — a
278
+ // blocker here is repaired in the source, or waived through the packet path.
279
+ recordUpsellSelectorScopeGate({
280
+ subject: {
281
+ public_route_slug: scope.slug || null,
282
+ site_root: relFromDir(targetRepo, scope.campaign_dir),
283
+ },
284
+ pages: scope.pages.map((page) => {
285
+ const content = readFileSync(page.built_path, "utf8");
286
+ return {
287
+ page_id: page.page_id,
288
+ page_type: builtPageTypeOverRouteGuess({ route: page.route, route_type: page.page_type, content }),
289
+ file: relFromDir(targetRepo, page.built_path),
290
+ content,
291
+ };
292
+ }),
293
+ waivers: null,
294
+ errors,
295
+ warnings,
296
+ ready,
297
+ derived,
298
+ });
299
+ derived.doctor_checks.push(UPSELL_SELECTOR_SCOPE);
300
+
301
+ // Cross-page campaign identity (#301). Same placement and the same reasons:
302
+ // family-independent, needs no packet, and the borrowed-page defect it gates
303
+ // is most often introduced on exactly the page-kit campaigns this path
304
+ // inspects.
305
+ recordCampaignIdentityGate({
306
+ subject: {
307
+ public_route_slug: scope.slug || null,
308
+ site_root: relFromDir(targetRepo, scope.campaign_dir),
309
+ },
310
+ pages: collectBuiltPageIdentityInputs(scope, targetRepo),
311
+ errors,
312
+ ready,
313
+ derived,
314
+ });
315
+ derived.doctor_checks.push(CAMPAIGN_IDENTITY);
316
+
317
+ // Static SDK markup checks (#303). Same placement, same reasons.
318
+ recordSdkMarkupGate({
319
+ subject: {
320
+ public_route_slug: scope.slug || null,
321
+ site_root: relFromDir(targetRepo, scope.campaign_dir),
322
+ },
323
+ pages: collectBuiltPageIdentityInputs(scope, targetRepo),
324
+ errors,
325
+ warnings,
326
+ ready,
327
+ derived,
328
+ });
329
+ derived.doctor_checks.push(SDK_MARKUP);
330
+
331
+ // Campaign-owned script syntax (#480). Same placement, same reasons: a
332
+ // hand-edited script that no longer parses is invisible to every HTML gate.
333
+ recordScriptSyntaxGate({
334
+ subject: {
335
+ public_route_slug: scope.slug || null,
336
+ site_root: relFromDir(targetRepo, scope.campaign_dir),
337
+ },
338
+ inputs: collectBuiltScriptSyntaxInputs(scope, targetRepo),
339
+ errors,
340
+ warnings,
341
+ ready,
342
+ derived,
343
+ });
344
+ derived.doctor_checks.push(SCRIPT_SYNTAX);
345
+
346
+ const synthesized = synthesizeMinimalBuildPacket({
347
+ schemaVersion: PACKET_SCHEMA,
348
+ targetRepo,
349
+ scope,
350
+ family,
351
+ mapId,
352
+ baseUrl,
353
+ deployTarget,
354
+ });
355
+ derived.synthesized_packet = synthesized;
356
+
357
+ let emittedPacketPath = null;
358
+ if (args["emit-packet"]) {
359
+ emittedPacketPath = args["emit-packet"] === true
360
+ ? join(targetRepo, ".campaign-runtime", "minimal-build-packet.json")
361
+ : resolve(String(args["emit-packet"]));
362
+ mkdirSync(dirname(emittedPacketPath), { recursive: true });
363
+ writeJson(emittedPacketPath, synthesized);
364
+ ready.push(`Emitted minimal Build Packet to ${relFromDir(targetRepo, emittedPacketPath)}`);
365
+ }
366
+
367
+ const next = buildNextStep(errors, warnings, derived, null);
368
+ const status = errors.length ? "blocked" : warnings.length ? "ready_with_warnings" : "ready";
369
+ return {
370
+ ok: errors.length === 0,
371
+ status,
372
+ mode: "built_site",
373
+ errors,
374
+ warnings,
375
+ ready,
376
+ derived,
377
+ scope: { slug: scope.slug, html_count: scope.html_count, pages: derived.built_pages, campaign_dir: scope.campaign_dir },
378
+ synthesized_packet: synthesized,
379
+ emitted_packet_path: emittedPacketPath,
380
+ next,
381
+ };
382
+ }
383
+
384
+ export function doctorPacket(packetPath, options = {}) {
385
+ const result = withHtmlScanSnapshot(() => inspectDoctorPacket(packetPath, options));
386
+ // Per-finding cause classification lives HERE, at the single production
387
+ // boundary, and not in the doctor command. Four producers persist
388
+ // .campaign-runtime/doctor-output.json from a doctorPacket result — `doctor
389
+ // --write`, `next`, `start`/`build` (prepare-build runs no doctor), and the
390
+ // QA stage refresh — and annotating only one of them means running QA after
391
+ // doctor silently strips the labels back out of the retained artifact. Every
392
+ // consumer of a doctor result gets the same shape, whether or not it writes
393
+ // one. Each producer stamps the artifact with its own name on the way out
394
+ // (`generated_by`, #312; writeDoctorSidecar / stampDoctorProducer), so a
395
+ // retained sidecar always says which of the four wrote it. `standardize` is
396
+ // not one of them: it reads the target and writes nothing.
397
+ //
398
+ // The comparison set is the previous Run Record's own doctor observations
399
+ // (error_codes / warning_codes), which every Run Record ever written already
400
+ // carries — so this works against existing history rather than needing a run
401
+ // to go by first. Code granularity, because that is the granularity the
402
+ // record stores. baseDir is the packet directory, the same root the Run
403
+ // Record writes under.
404
+ // An invalid identity cannot select history. In particular, withholding a
405
+ // malformed local ID from derived must not turn it into an unfiltered or
406
+ // Map-only lookup of another campaign's findings.
407
+ const comparableIdentity = resolveCampaignIdentity(result.derived)
408
+ && !result.errors.some(issue => issue.code === "spec.local_identity" || issue.code === "spec.map_id");
409
+ result.cause_summary = annotateDoctorIssueCauses({
410
+ errors: result.errors,
411
+ warnings: result.warnings,
412
+ baseDir: comparableIdentity ? dirname(resolve(packetPath)) : null,
413
+ mapId: result.derived?.map_id || null,
414
+ localSpecId: result.derived?.local_spec_id || null,
415
+ });
416
+ return result;
417
+ }
418
+
419
+ function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath = undefined, outputBaseDir = null, liveCampaign = undefined } = {}) {
420
+ // The Build Context records where prepare-build wrote the report
421
+ // (--report-out). `next` follows that pointer when no --report is given;
422
+ // doctor reads the same report so its gates and its next block cannot
423
+ // disagree with the ladder over which report is the campaign's.
424
+ const { packet, targetRepo: gateTargetRepo, contextPath: resolvedContextPath, reportPath: resolvedReportPath } = resolveCampaignWorkspace(packetPath, {
425
+ contextPath,
426
+ reportPath,
427
+ followContextPointer: true,
428
+ });
429
+ const context = readJsonIfExists(resolvedContextPath);
430
+ const report = readJsonIfExists(resolvedReportPath);
431
+ const errors = [];
432
+ const warnings = [];
433
+ const ready = [];
434
+ const packetIdentity = resolveCampaignIdentity(packet?.spec);
435
+ const derived = {
436
+ packet_path: packetPath,
437
+ // The report this inspection read (null when the caller switched the
438
+ // report off), so a writer can refuse to restate the outcome into a
439
+ // different file.
440
+ assembly_report_path: typeof resolvedReportPath === "string" ? resolvedReportPath : null,
441
+ map_id: packet?.spec?.map_id || null,
442
+ ...(packetIdentity?.kind === "local_spec" ? { local_spec_id: packetIdentity.id } : {}),
443
+ public_route_slug: packet?.campaign?.public_route_slug || null,
444
+ template_family: packet?.assembly?.template_family || null,
445
+ source_root: null,
446
+ target_repo: null,
447
+ target_output_dir: null,
448
+ spec_path: null,
449
+ doctor_checks: [],
450
+ checkpoint_gates: [],
451
+ polish_checkpoint_gate: null,
452
+ // The prepare-build gate `next` acts on, stored like every other gate so
453
+ // the ladder consumes doctor's evaluation instead of computing its own.
454
+ prepare_build_gate: null,
455
+ // The family brand contract, resolved once per run: { state, family } plus
456
+ // { code, detail } for a defect. The `next` advisories read it.
457
+ brand_contract: null,
458
+ page_kit_campaign_config: null,
459
+ scaffold_required: false,
460
+ scaffold_reason: null,
461
+ scope: {
462
+ mode: "unknown",
463
+ built_pages: [],
464
+ out_of_scope_pages: [],
465
+ previewable_routes: [],
466
+ blocked_runtime_pages: [],
467
+ },
468
+ };
469
+
470
+ validatePacket(packet, packetPath, errors, warnings, ready, derived, { context, report, liveCampaign });
471
+ runDoctorChecks(ARTIFACT_DOCTOR_CHECKS, { context, report, errors, warnings, ready, derived });
472
+
473
+ // Doctor and the stage ladder must agree over one packet (#238): when the
474
+ // recorded assembly report holds prepare_build at "blocked" (or claims a
475
+ // terminal status while retaining blocking evidence), every `next <stage>`
476
+ // command refuses to run — so doctor surfaces the same blockers as errors
477
+ // instead of exiting 0 and naming a stage the ladder then rejects. Doctor
478
+ // exit 0 means the command it names will actually run.
479
+ const prepareBuildLadderGate = prepareBuildGateIssue(report);
480
+ if (prepareBuildLadderGate?.blocked) {
481
+ addPrepareBuildGateErrors(errors, report, prepareBuildLadderGate);
482
+ }
483
+
484
+ // Theme gate: evaluated once here so `next`, QA, and run telemetry all read
485
+ // the same decision from derived.theme_gate. The doctor reports a blocked
486
+ // gate as a WARNING (not an error) because the fix happens during the build
487
+ // stage — but `next polish|deploy|qa` and `qa run` treat the same gate
488
+ // result as a hard blocker.
489
+ const themeGate = evaluateThemeGate({
490
+ reportTheme: report?.theme || null,
491
+ contextTheme: context?.theme || null,
492
+ scope: derived.scope,
493
+ packetPath,
494
+ });
495
+ derived.theme_gate = themeGate;
496
+ if (themeGate.status === "blocked") {
497
+ pushGateIssue({ errors, warnings }, gateIssue("theme_gate", themeGate));
498
+ } else if (themeGate.status === "waived") {
499
+ ready.push(`Theme gate waived: ${themeGate.waiver?.reason || "(no reason recorded)"}`);
500
+ } else if (themeGate.status === "pass") {
501
+ // The gate passes on two different facts (a brand layer applied, or no
502
+ // generatable brand theme at all); print the one it found, never the
503
+ // other. An operator reading ready[] on a token-less campaign must not
504
+ // believe brand styling shipped.
505
+ ready.push(`Theme gate passed: ${themeGate.reason}`);
506
+ }
507
+ runPricingCssHideCheck({ packet, derived, warnings, ready, report });
508
+
509
+ const polishCheckpointGate = evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report });
510
+ derived.polish_checkpoint_gate = polishCheckpointGate;
511
+ const polishGate = evaluatePolishGate({
512
+ report,
513
+ hiddenEagerMediaGate: polishCheckpointGate,
514
+ currentOutputFingerprint: derived.build_output_fingerprint?.value || null,
515
+ });
516
+ derived.polish_gate = polishGate;
517
+ if (polishGate.status === "blocked" && !polishGate.owned_checkpoint_only) {
518
+ pushGateIssue({ errors, warnings }, gateIssue("polish_gate", polishGate));
519
+ } else if (polishGate.status === "waived" && !polishGate.owned_checkpoint_only) {
520
+ ready.push(`Polish gate passed under waiver: ${polishGate.waiver?.reason || "(no reason recorded)"}`);
521
+ } else if (polishGate.status === "pass") {
522
+ ready.push("Polish gate passed: structured evidence is current for this build.");
523
+ }
524
+
525
+ if (polishCheckpointGate.status === "blocked") {
526
+ pushGateIssue({ errors, warnings }, gateIssue("polish_checkpoint_gate", polishCheckpointGate));
527
+ } else if (polishCheckpointGate.status === "waived") {
528
+ addIssue(
529
+ warnings,
530
+ polishCheckpointGate.code,
531
+ `${polishCheckpointGate.reason} Waived by ${polishCheckpointGate.waiver.waived_by}: ${polishCheckpointGate.waiver.reason}`,
532
+ { polish_checkpoint_gate: polishCheckpointGate },
533
+ );
534
+ ready.push(`Hidden eager-media checkpoint accepted under named-human exception (${polishCheckpointGate.waiver.waived_by}).`);
535
+ } else if (polishCheckpointGate.status === "pass") {
536
+ ready.push("Hidden eager-media checkpoint passed: package-owned page-load evidence is complete and current.");
537
+ } else {
538
+ ready.push("Hidden eager-media checkpoint not applicable before completed assembly.");
539
+ }
540
+
541
+ // The same fully resolved prepare-build gate the `next` command evaluates:
542
+ // DSP-required packets, the recorded report path and the context/report
543
+ // binding checks. A weaker gate here would let doctor name setup or build
544
+ // while `next` still answers prepare-build.
545
+ // With no context on hand (doctor --report alone) the binding checks have
546
+ // nothing to compare and are skipped; the report itself was resolved
547
+ // above the way `next` resolves it.
548
+ // The stage decision runs over exactly the artifacts the checks ran over.
549
+ // A caller that named one sidecar and not the other (doctor --context C)
550
+ // is inspecting, and its report checks are deliberately off; its next
551
+ // block decides without the report too, and says so in `reason`. The
552
+ // binding is evaluated whether or not a Build Context was found: an absent
553
+ // context is itself a binding failure for a packet that declares a Design
554
+ // Source Package, and `next` consumes this gate rather than computing its
555
+ // own, so the two cannot answer differently on the same repo.
556
+ const prepareBuildGate = prepareBuildGateIssue(report, {
557
+ required: isObject(packet?.design_source_package),
558
+ reportPath: resolvedReportPath,
559
+ bindingIssues: isObject(packet)
560
+ ? nextPrepareBuildBindingIssues({
561
+ packet,
562
+ packetPath,
563
+ context,
564
+ contextPath: resolvedContextPath,
565
+ report,
566
+ reportPath: resolvedReportPath,
567
+ targetRepo: gateTargetRepo,
568
+ explicitReport: typeof reportPath === "string",
569
+ })
570
+ : [],
571
+ });
572
+ derived.prepare_build_gate = prepareBuildGate;
573
+ // Portable output (outputBaseDir set: start's generated doctor output,
574
+ // doctor --strip-paths) rebases them onto that base like every other path
575
+ // in the output, so a relocated handoff does not name the original machine.
576
+ const sidecarArg = (path) => shellToken(outputBaseDir ? relFromDir(outputBaseDir, path) : path);
577
+ const sidecarArgs = [
578
+ ...(typeof contextPath === "string" ? [` --context ${sidecarArg(contextPath)}`] : []),
579
+ ...(typeof reportPath === "string" ? [` --report ${sidecarArg(reportPath)}`] : []),
580
+ ].join("");
581
+ const next = buildNextStep(errors, warnings, derived, report, packet, prepareBuildGate, { sidecarArgs });
582
+ // Portable output: a sidecar path the picker's reason names is rebased
583
+ // like every other path in the output.
584
+ if (outputBaseDir && typeof next?.reason === "string") {
585
+ for (const path of [resolvedContextPath, resolvedReportPath]) {
586
+ if (typeof path === "string" && next.reason.includes(path)) {
587
+ next.reason = next.reason.split(path).join(relFromDir(outputBaseDir, path));
588
+ }
589
+ }
590
+ }
591
+ const status = errors.length
592
+ ? "blocked"
593
+ : checkpointExceptionPresent(derived)
594
+ ? "ready_with_waivers"
595
+ : warnings.length
596
+ ? "ready_with_warnings"
597
+ : "ready";
598
+ const result = {
599
+ schema_version: DOCTOR_SIDECAR_SCHEMA,
600
+ generated_at: new Date().toISOString(),
601
+ ok: errors.length === 0,
602
+ status,
603
+ errors,
604
+ warnings,
605
+ ready,
606
+ derived,
607
+ next,
608
+ };
609
+ return outputBaseDir ? relativizeDoctorOutput(result, outputBaseDir) : result;
610
+ }
611
+
612
+ // Pricing surfaces are rendered by mode-driven partials, never hidden with
613
+ // campaign CSS — a display:none on a price wrapper is how the recovery-relief
614
+ // dogfood run shipped a full-price upsell with NO visible price. Deterministic
615
+ // static scan: campaign-owned CSS files (not the family core stylesheet, not
616
+ // the generated brand layer) must not display:none any selector the family
617
+ // brand contract lists under pricing_surfaces.forbidden_css_hides. Doctor
618
+ // reports a warning with the exact rule; browser QA enforces the outcome
619
+ // (zero visible price rows) as a blocker.
620
+ export function runPricingCssHideCheck({ packet, derived, warnings, ready, report = null }) {
621
+ const family = packet?.assembly?.template_family;
622
+ const resolution = resolveBrandContractOnce(derived, family);
623
+ if (resolution.error) {
624
+ reportBrandContractDefectOnce(resolution, warnings, family);
625
+ return;
626
+ }
627
+ const contract = resolution.contract;
628
+ if (!contract?.pricing_surfaces?.forbidden_css_hides?.length) {
629
+ ready.push(`Pricing CSS scan not applicable for template family "${family || "(none)"}" (no brand contract with forbidden_css_hides)`);
630
+ return;
631
+ }
632
+ // Missing campaign output is normal before setup/build (audit ready-line),
633
+ // but anomalous once the assembly stage is recorded terminal — at that
634
+ // point a missing dir means the scan that should have covered built CSS
635
+ // never ran, which the operator must see as a warning, not a footnote.
636
+ const assemblyDone = stageIsTerminal(report?.stages?.assembly?.status);
637
+ const skipScan = (reason) => {
638
+ if (assemblyDone) {
639
+ addIssue(warnings, "template_contract.price_css_scan_skipped", `Pricing CSS scan did NOT run although assembly is recorded terminal: ${reason}. Check assembly.target_repo / output_dir configuration.`);
640
+ } else {
641
+ ready.push(`Pricing CSS scan skipped: ${reason} (runs after setup/build)`);
642
+ }
643
+ };
644
+ const outputDir = derived.target_output_dir;
645
+ if (!outputDir || !existsSync(outputDir)) {
646
+ skipScan("target output directory does not exist");
647
+ return;
648
+ }
649
+ const cssDir = join(outputDir, "assets/css");
650
+ if (!existsSync(cssDir)) {
651
+ skipScan("campaign assets/css directory does not exist");
652
+ return;
653
+ }
654
+ const coreStylesheet = contract.css_load_order?.core_stylesheet || "next-core.css";
655
+ const campaignCssFiles = readdirSync(cssDir)
656
+ .filter((name) => name.endsWith(".css") && name !== coreStylesheet && name !== "brand-theme.css");
657
+ let hideCount = 0;
658
+ for (const name of campaignCssFiles) {
659
+ const cssPath = join(cssDir, name);
660
+ let cssText = "";
661
+ try {
662
+ cssText = readFileSync(cssPath, "utf8");
663
+ } catch {
664
+ continue;
665
+ }
666
+ for (const hit of findForbiddenPriceHides(contract, cssText)) {
667
+ hideCount += 1;
668
+ addIssue(
669
+ warnings,
670
+ "template_contract.price_css_hide",
671
+ `Campaign CSS ${name} hides a pricing surface: "${hit.selector}" sets display:none on ${hit.target}. Use the template's declared pricing modes instead of hiding price rows; browser QA blocks upsells with zero visible price rows.`,
672
+ );
673
+ }
674
+ }
675
+ if (hideCount === 0 && campaignCssFiles.length) {
676
+ ready.push(`Campaign CSS has no display:none rules on ${family} pricing surfaces`);
677
+ }
678
+ }