@nextcommerce/campaigns-os 1.59.0 → 1.61.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 (96) hide show
  1. package/CHANGELOG.md +242 -0
  2. package/README.md +11 -10
  3. package/agents/claude/CLAUDE.md +15 -0
  4. package/agents/codex/AGENTS.md +15 -0
  5. package/agents/copilot/copilot-instructions.md +15 -0
  6. package/agents/cursor/campaigns-os.mdc +15 -0
  7. package/compatibility.json +1 -1
  8. package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
  9. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  10. package/contracts/commerce-surface-catalog.json +27 -26
  11. package/contracts/release-ledger.json +1311 -0
  12. package/contracts/supported-surface.json +4 -4
  13. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  14. package/docs/access-model.md +20 -0
  15. package/docs/brand-theme-bridge.md +62 -3
  16. package/docs/build-packet.md +42 -13
  17. package/docs/campaign-build-brief.md +17 -1
  18. package/docs/campaigns-os-build-flow.md +62 -3
  19. package/docs/campaignspec-authoring-examples.md +116 -0
  20. package/docs/design-source-package.md +46 -2
  21. package/docs/entry-points.md +188 -0
  22. package/docs/local-setup.md +1 -1
  23. package/docs/orientation-contract-reference.md +1 -1
  24. package/docs/polish-evidence.md +41 -3
  25. package/docs/qa-and-test-orders.md +50 -2
  26. package/docs/quickstart.md +438 -0
  27. package/docs/readback.md +119 -6
  28. package/docs/runtime-readiness.md +1 -1
  29. package/docs/sdk-storage-compatibility.md +3 -1
  30. package/docs/skills-revision.md +10 -10
  31. package/docs/source-adapters.md +213 -0
  32. package/package.json +10 -3
  33. package/schemas/campaign-design-source-package.v0.schema.json +8 -0
  34. package/schemas/campaigns-os-readback.v2.schema.json +177 -1
  35. package/schemas/source-html-manifest.v0.schema.json +3 -0
  36. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  37. package/skills/campaign-readback-classification/SKILL.md +3 -3
  38. package/skills/campaign-run-evidence/SKILL.md +3 -3
  39. package/skills/contribution-intake/SKILL.md +3 -3
  40. package/skills/next-campaigns-build/SKILL.md +5 -5
  41. package/skills/next-campaigns-os/SKILL.md +4 -3
  42. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  43. package/skills/next-campaigns-polish/SKILL.md +12 -16
  44. package/skills/next-campaigns-qa/SKILL.md +3 -3
  45. package/skills/next-campaigns-sdk-bump/SKILL.md +286 -0
  46. package/skills.json +18 -10
  47. package/src/brand-theme.mjs +413 -8
  48. package/src/brief-summary.mjs +6 -2
  49. package/src/browser-launch.mjs +2 -1
  50. package/src/build-brief.mjs +9 -2
  51. package/src/built-site-scope.mjs +7 -14
  52. package/src/campaign-identity.mjs +6 -4
  53. package/src/cli.mjs +89 -73
  54. package/src/design-file-reference.mjs +93 -0
  55. package/src/design-source-package.mjs +55 -1
  56. package/src/doctor/checks.mjs +14 -77
  57. package/src/doctor/ci.mjs +112 -0
  58. package/src/doctor/inspect.mjs +20 -8
  59. package/src/doctor/next-step.mjs +6 -3
  60. package/src/doctor/source-provenance.mjs +74 -0
  61. package/src/evidence-debt.mjs +119 -0
  62. package/src/orchestration-stage-contract.mjs +6 -3
  63. package/src/page-kit-build-summary.mjs +21 -1
  64. package/src/page-kit-campaign-config.mjs +19 -0
  65. package/src/parity/compare.mjs +316 -0
  66. package/src/parity/design-access.mjs +54 -0
  67. package/src/parity/element-parity-record.mjs +569 -0
  68. package/src/parity/probe.mjs +205 -0
  69. package/src/parity/snapshot.mjs +110 -0
  70. package/src/png-header.mjs +87 -0
  71. package/src/polish-browser.mjs +186 -9
  72. package/src/polish-captures-record.mjs +52 -0
  73. package/src/polish-deadline.mjs +14 -0
  74. package/src/polish-gate.mjs +26 -11
  75. package/src/polish-node.mjs +81 -7
  76. package/src/polish-visual-fixtures.mjs +74 -0
  77. package/src/polish-visual.mjs +606 -0
  78. package/src/progress-node.mjs +2 -1
  79. package/src/qa-analytics-correctness.mjs +14 -2
  80. package/src/qa-analytics-parity.mjs +94 -5
  81. package/src/qa-node.mjs +39 -6
  82. package/src/qc-accept.mjs +148 -6
  83. package/src/qc-check-registry.mjs +37 -1
  84. package/src/qc-results.mjs +404 -16
  85. package/src/readback.mjs +57 -13
  86. package/src/sdk-attribute-index.mjs +11 -11
  87. package/src/sdk-markup.mjs +77 -2
  88. package/src/sdk-storage-compatibility.mjs +94 -4
  89. package/src/source-html-manifest.mjs +7 -0
  90. package/src/source-html-server.mjs +129 -0
  91. package/src/spec-derive.mjs +24 -9
  92. package/src/stage-ledger.mjs +27 -1
  93. package/src/stage-record.mjs +63 -9
  94. package/src/theme-gate.mjs +2 -2
  95. package/src/visual-diff.mjs +296 -0
  96. package/src/visual-reference.mjs +126 -0
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { resolveDesignFileReferences } from "./design-file-reference.mjs";
2
3
 
3
4
  export const DESIGN_SOURCE_PACKAGE_SCHEMA = "campaign-design-source-package/v0";
4
5
  export const DESIGN_SOURCE_PACKAGE_REL_PATH = ".campaign-runtime/input/design-source-package.json";
@@ -166,7 +167,7 @@ const PRESENTATION_INTENT_FIELDS = new Set([
166
167
  "brand",
167
168
  "responsive_behavior",
168
169
  ]);
169
- const SOURCE_REF_FIELDS = new Set(["id", "kind", "path", "url", "sha256", "role", "media_type", "notes"]);
170
+ const SOURCE_REF_FIELDS = new Set(["id", "kind", "path", "url", "sha256", "role", "media_type", "notes", "design_file_type"]);
170
171
  const VISUAL_REF_FIELDS = new Set([
171
172
  "id",
172
173
  "kind",
@@ -195,6 +196,7 @@ const TEMPLATE_REFERENCE_FIELDS = new Set([
195
196
  "standard_viewport_refs",
196
197
  ]);
197
198
  const COVERAGE_MAPPING_FIELDS = new Set([
199
+ "derived_from",
198
200
  "id",
199
201
  "surface_id",
200
202
  "coverage_role",
@@ -337,6 +339,7 @@ export function synthesizeHtmlFunnelDesignSourcePackage({
337
339
  });
338
340
 
339
341
  const manifestInput = normalizeManifestInput(manifest, manifestPath);
342
+ const designFiles = resolveDesignFileReferences({ activePages, mappings: resolvedMappings, manifest: manifestInput.value });
340
343
  const crawl = isObject(sourceAssetCrawl) ? sourceAssetCrawl : isObject(assetCrawl) ? assetCrawl : null;
341
344
  const crawlInputName = isObject(sourceAssetCrawl) ? "sourceAssetCrawl" : "assetCrawl";
342
345
  assertOptionalInputArray(manifestInput.value?.files, "manifest.files");
@@ -480,6 +483,9 @@ export function synthesizeHtmlFunnelDesignSourcePackage({
480
483
  htmlCoverage.push({
481
484
  id: `html-${surfaceId}-coverage`,
482
485
  surface_id: surfaceId,
486
+ ...(designFiles.pages.some((entry) => entry.page_id === page.pageId) ? {
487
+ derived_from: designFiles.files.find((file) => file.pages.includes(page.pageId)).contribution_id,
488
+ } : {}),
483
489
  coverage_role: explicitRole || "primary_design",
484
490
  confidence: normalizeConfidence(mapping?.confidence)
485
491
  || confidenceForHtmlMapping(mapping, manifestPage),
@@ -525,6 +531,37 @@ export function synthesizeHtmlFunnelDesignSourcePackage({
525
531
  };
526
532
 
527
533
  const contributions = [htmlContribution];
534
+ for (const file of [...designFiles.files].sort((a, b) => a.contribution_id.localeCompare(b.contribution_id))) {
535
+ const id = file.contribution_id;
536
+ // kind collapses non-Figma tools to "other". Use a new field so existing
537
+ // source-reference notes remain nonmaterial, including design_file refs.
538
+ const sourceRefs = [{ id: `${id}-file`, kind: "url", url: file.file_url, role: "design_file",
539
+ ...(!["figma", "other"].includes(file.identity.type) ? { design_file_type: file.identity.type } : {}),
540
+ }];
541
+ const mappings = file.pages.map((pageId) => {
542
+ const surfaceId = surfaceByPageId.get(pageId);
543
+ const declaration = designFiles.pages.find((page) => page.page_id === pageId).declaration;
544
+ const frameRefs = Object.entries(declaration.frames).map(([viewport, frame]) => ({
545
+ id: `${id}-${surfaceId}-frame-${viewport}`, kind: "url", url: frame.url, role: `design_frame:${viewport}`,
546
+ }));
547
+ sourceRefs.push(...frameRefs);
548
+ return { id: `${id}-${surfaceId}-coverage`, surface_id: surfaceId, coverage_role: "reference_only", confidence: "high", source_refs: [`${id}-file`, ...frameRefs.map((ref) => ref.id)], screenshot_refs: [], reference_refs: [] };
549
+ });
550
+ const fromManifest = file.producer === "source_html_manifest";
551
+ contributions.push({
552
+ id, kind: file.identity.type === "figma" ? "figma_frames" : "other", trust: file.identity.type === "figma" ? "native" : "opaque", renderable: false,
553
+ provenance: {
554
+ source_type: "design_file", adapter: "design-file-reference", producer: file.producer,
555
+ generator: null, generator_version: null, source_root: null,
556
+ manifest_schema_version: fromManifest ? optionalString(manifestInput.value?.schema_version) : null,
557
+ manifest_path: fromManifest ? optionalString(manifestInput.path) : null,
558
+ manifest_sha256: fromManifest ? validSha(manifestInput.value?.sha256) : null,
559
+ producer_material_fingerprint: null, asset_crawl_schema_version: null,
560
+ },
561
+ presentation_intent: { summary: "Design file the page's HTML export was produced from. Recorded as the design authority; this version does not read it." },
562
+ source_refs: sourceRefs, screenshot_refs: [], reference_refs: [], mappings,
563
+ });
564
+ }
528
565
  const template = normalizeTemplateFamily(templateFamily);
529
566
  const qualifyingHtmlSurfaces = new Set(
530
567
  htmlCoverage
@@ -998,6 +1035,15 @@ export function validateDesignSourcePackage(value, {
998
1035
  if (contributionIds.has(contribution.id)) add("design_source_package.contribution_id_duplicate", `${path}.id`, `Duplicate contribution id "${contribution.id}".`);
999
1036
  contributionIds.add(contribution.id);
1000
1037
  });
1038
+ contributions.forEach((contribution, index) => {
1039
+ arrayOrEmpty(contribution?.mappings).forEach((mapping, mappingIndex) => {
1040
+ if (!Object.hasOwn(mapping || {}, "derived_from")) return;
1041
+ const target = contributions.find((entry) => entry?.id === mapping.derived_from && entry !== contribution);
1042
+ if (!target || !["figma_frames", "figma_sections", "other"].includes(target.kind) || !arrayOrEmpty(target.mappings).some((entry) => entry?.surface_id === mapping.surface_id)) {
1043
+ add("design_source_package.derived_from_ref", `$.contributions[${index}].mappings[${mappingIndex}].derived_from`, "derived_from must name another design contribution mapped to the same surface_id.");
1044
+ }
1045
+ });
1046
+ });
1001
1047
 
1002
1048
  validateRecordArray(value.source_gaps, "$.source_gaps", GAP_FIELDS,
1003
1049
  ["id", "kind", "scope", "applies_to", "reason", "status", "attributed_by"], add,
@@ -1394,6 +1440,8 @@ function projectContribution(contribution) {
1394
1440
  function projectSourceRef(ref) {
1395
1441
  return compactDefined({
1396
1442
  ...pickFields(ref, ["id", "kind", "path", "url", "role", "media_type"]),
1443
+ // Absent from every BASE-valid package; never project existing prose notes.
1444
+ design_file_type: ref?.design_file_type,
1397
1445
  sha256: canonicalHashSpelling(ref?.sha256),
1398
1446
  });
1399
1447
  }
@@ -1451,6 +1499,7 @@ function projectProvenance(provenance) {
1451
1499
 
1452
1500
  function projectCoverageMapping(mapping) {
1453
1501
  return compactDefined({
1502
+ derived_from: mapping?.derived_from,
1454
1503
  id: mapping?.id,
1455
1504
  surface_id: mapping?.surface_id,
1456
1505
  coverage_role: mapping?.coverage_role,
@@ -2252,6 +2301,10 @@ function validateSourceReference(value, path, add) {
2252
2301
  if (!checkStrictObject(value, path, SOURCE_REF_FIELDS, ["id", "kind"], add)) return;
2253
2302
  checkNonEmptyString(value.id, `${path}.id`, add);
2254
2303
  checkEnum(value.kind, SOURCE_REF_KINDS, `${path}.kind`, add);
2304
+ if (Object.hasOwn(value, "design_file_type")) {
2305
+ checkNonEmptyString(value.design_file_type, `${path}.design_file_type`, add);
2306
+ if (value.role !== "design_file") add("design_source_package.design_file_type", `${path}.design_file_type`, "design_file_type requires role design_file.");
2307
+ }
2255
2308
  if (!isNonEmptyString(value.path) && !isNonEmptyString(value.url)) add("design_source_package.source_ref_location", path, "Source reference requires path or url.");
2256
2309
  for (const field of ["path", "url", "role", "media_type"]) if (value[field] != null) checkNonEmptyString(value[field], `${path}.${field}`, add);
2257
2310
  if (value.sha256 != null && !SHA256_PATTERN.test(value.sha256)) add("design_source_package.sha256", `${path}.sha256`, "sha256 must be 64 lowercase hex, with optional sha256: prefix.");
@@ -2293,6 +2346,7 @@ function validateCoverageMapping(value, path, add) {
2293
2346
  "id", "surface_id", "coverage_role", "confidence", "source_refs", "screenshot_refs", "reference_refs",
2294
2347
  ], add)) return false;
2295
2348
  checkNonEmptyString(value.id, `${path}.id`, add);
2349
+ if (Object.hasOwn(value, "derived_from")) checkNonEmptyString(value.derived_from, `${path}.derived_from`, add);
2296
2350
  checkNonEmptyString(value.surface_id, `${path}.surface_id`, add);
2297
2351
  checkEnum(value.coverage_role, new Set(COVERAGE_ROLES), `${path}.coverage_role`, add);
2298
2352
  checkEnum(value.confidence, new Set(MAPPING_CONFIDENCE_LEVELS), `${path}.confidence`, add);
@@ -23,7 +23,6 @@ import {
23
23
  } from "../source-html-intake.mjs";
24
24
  import {
25
25
  readSourceHtmlManifestFile,
26
- SOURCE_HASH_PATTERN,
27
26
  SOURCE_HTML_MANIFEST_SCHEMA,
28
27
  } from "../source-html-manifest.mjs";
29
28
  import { validateAssemblyReportThemeBlock, validateThemeContextBlock } from "../brand-theme.mjs";
@@ -89,7 +88,8 @@ import { SCRIPT_SYNTAX, collectBuiltScriptSyntaxInputs, evaluateBuiltScriptSynta
89
88
  import { CART_PLACEHOLDERS, CART_PLACEHOLDERS_LIMITS, evaluateCartPlaceholders, isFileReadFailure } from "../cart-placeholders.mjs";
90
89
  import { SMOKE_QC, SMOKE_QC_LIMITS, builtFileOf, evaluateSmokeQc, insideRoot, isBuiltPageReadFailure, pageScriptSources, parseBuiltPage, realPathOf } from "../built-smoke-qc.mjs";
91
90
  import { recordQcResults } from "../qc-results.mjs";
92
- import { FIGMA_EXPORT_FILE_CODES, SOURCE_PROVENANCE_SCOPE, evaluateSourceProvenanceGates, generatorClaimsFigmaExport, isSourceProvenanceCode } from "./source-provenance.mjs";
91
+ import { evaluateDesignAccess } from "../parity/design-access.mjs";
92
+ import { collectFigmaProvenanceFindings, FIGMA_EXPORT_FILE_CODES, SOURCE_PROVENANCE_SCOPE, evaluateSourceProvenanceGates, generatorClaimsFigmaExport, isSourceProvenanceCode } from "./source-provenance.mjs";
93
93
  import { validateCampaignBuildBriefArtifact } from "../build-brief.mjs";
94
94
  import { briefFileUnusable, deriveInputCurrency, wellFormedBriefMaterial } from "../input-currency.mjs";
95
95
  import { ASSEMBLY_REPORT_STAGE_KEYS, stageIsTerminal } from "../orchestration-stage-contract.mjs";
@@ -99,7 +99,7 @@ import {
99
99
  assessAssemblySourcePackageFreshnessWaivers,
100
100
  currentBuildFingerprint,
101
101
  } from "../polish-gate.mjs";
102
- import { loadPageKitCampaignEntry, projectPageKitCampaignLoad } from "../page-kit-campaign-config.mjs";
102
+ import { loadPageKitCampaignEntry, pageKitIgnoresAssetsHtml, projectPageKitCampaignLoad } from "../page-kit-campaign-config.mjs";
103
103
  import { evaluatePageKitStoreProfile, PAGE_KIT_STORE_PROFILE_SCOPE, storeProfileIntentionallyEmptyNote } from "../page-kit-store-profile.mjs";
104
104
  import { evaluatePageKitSdkVersion, PAGE_KIT_SDK_VERSION_SCOPE } from "../page-kit-sdk-version.mjs";
105
105
  // ADR-003: the public, canonical CampaignSpec rule registry. The doctor and any
@@ -3039,7 +3039,7 @@ function builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived = {}) {
3039
3039
  mapping: derived?.page_kit_entry_mappings?.[page?.id],
3040
3040
  });
3041
3041
  if (entryRoute) return join(targetRepo, "_site", publicRouteSlug, ...(entryRoute.route ? [entryRoute.route] : []), "index.html");
3042
- const sourcePermalink = sourcePermalinkForPage(derived?.target_output_dir, publicRouteSlug, page);
3042
+ const sourcePermalink = sourcePermalinkForPage(derived?.target_output_dir, publicRouteSlug, page, { assetsHtmlIgnored: pageKitIgnoresAssetsHtml(targetRepo) });
3043
3043
  const route = sourcePermalink || runtimeRelativeRouteForSpecValue(publicRouteForPage(page), publicRouteSlug);
3044
3044
  if (!route) return join(targetRepo, "_site", publicRouteSlug, "index.html");
3045
3045
  const clean = route.replace(/^\/+|\/+$/g, "");
@@ -3103,13 +3103,16 @@ export function entryPageKitFileRoute(targetRepo, publicRouteSlug, page, { targe
3103
3103
  return routes[0] ?? null;
3104
3104
  }
3105
3105
 
3106
- function sourcePermalinkForPage(targetOutputDir, publicRouteSlug, page) {
3106
+ // page-kit 0.3.0 renders no .html under the campaign's top-level assets/, so
3107
+ // a permalink stated there is no route on that version (assetsHtmlIgnored).
3108
+ function sourcePermalinkForPage(targetOutputDir, publicRouteSlug, page, { assetsHtmlIgnored = false } = {}) {
3107
3109
  if (!targetOutputDir || !existsSync(targetOutputDir) || !statSync(targetOutputDir).isDirectory()) return null;
3108
3110
 
3109
3111
  const expectedTerminal = terminalRouteSegment(publicRouteForPage(page));
3110
3112
  const candidates = [];
3111
3113
  for (const file of collectHtmlFiles(targetOutputDir)) {
3112
3114
  if (file.path.includes("_includes/") || file.path.includes("_layouts/")) continue;
3115
+ if (assetsHtmlIgnored && file.path.split(sep)[0] === "assets") continue;
3113
3116
  const fullPath = join(targetOutputDir, file.path);
3114
3117
  const content = readFileSync(fullPath, "utf8");
3115
3118
  const permalink = extractFrontmatterValue(content, "permalink");
@@ -3671,6 +3674,12 @@ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, read
3671
3674
  derived,
3672
3675
  });
3673
3676
  const active = activeSpecPages(spec);
3677
+ const designPackagePath = resolveFromFile(packetPath, packet?.design_source_package?.path);
3678
+ let designSourcePackage = null;
3679
+ try { if (designPackagePath) designSourcePackage = JSON.parse(readFileSync(designPackagePath, "utf8")); } catch { /* Missing or unreadable package is checked separately. */ }
3680
+ const designManifest = isNonEmptyString(sourceRoot) && existsSync(sourceRoot) && statSync(sourceRoot).isDirectory()
3681
+ ? readSourceHtmlManifestFile(sourceRoot, { manifestPath: recordedDesignManifestPath(packet, packetPath) }).manifest : null;
3682
+ recordQcResults({ derived, warnings, results: evaluateDesignAccess({ activePages: active, mappings: pages, manifest: designManifest, designSourcePackage, measured_at: new Date().toISOString() }) });
3674
3683
  const specPartialScope = spec?.build_scope?.mode === "partial";
3675
3684
  const specPartialReasons = Array.isArray(spec?.build_scope?.reasons) ? spec.build_scope.reasons.filter(isNonEmptyString) : [];
3676
3685
  const activeIds = new Set(active.map((page) => page.id));
@@ -3985,78 +3994,6 @@ function validateSourceProducerProvenance(manifest, { spec, errors, warnings, re
3985
3994
  }
3986
3995
  }
3987
3996
 
3988
- // Returns every Figma-provenance finding in emission order, each tagged with
3989
- // its severity: "provenance" (the waivable source_html.producer_provenance*
3990
- // and source_html.files.partial/asset blockers) or "warning".
3991
- function collectFigmaProvenanceFindings(manifest, provenance, rawProvenance) {
3992
- const findings = [];
3993
- const add = (severity, code, message) => findings.push({ severity, code, message });
3994
- if (!rawProvenance) {
3995
- add(
3996
- "provenance",
3997
- "source_html.producer_provenance",
3998
- "Figma source manifest is missing producer_provenance. Re-run figma-sections-export handoff so Campaigns OS can gate semantic exporter provenance before assembly."
3999
- );
4000
- }
4001
-
4002
- if (provenance.source_type !== "semantic_figma_export") {
4003
- add(
4004
- "provenance",
4005
- "source_html.producer_provenance.source_type",
4006
- `Figma source manifest source_type is "${provenance.source_type || "missing"}"; expected "semantic_figma_export". Screenshot or hand-authored fallback output cannot satisfy the Figma provenance gate.`
4007
- );
4008
- }
4009
- if (provenance.screenshot_fallback_used !== false) {
4010
- add(
4011
- "provenance",
4012
- "source_html.producer_provenance.screenshot_fallback_used",
4013
- "Figma source manifest reports screenshot_fallback_used=true. Re-run semantic figma-sections-export before assembly."
4014
- );
4015
- }
4016
- if (!Number.isInteger(provenance.semantic_section_count) || provenance.semantic_section_count <= 0) {
4017
- add(
4018
- "provenance",
4019
- "source_html.producer_provenance.semantic_section_count",
4020
- "Figma source manifest must report semantic_section_count > 0."
4021
- );
4022
- }
4023
- if (!SOURCE_HASH_PATTERN.test(String(provenance.material_fingerprint || ""))) {
4024
- add(
4025
- "provenance",
4026
- "source_html.producer_provenance.material_fingerprint",
4027
- "Figma source manifest must include a 64-character material_fingerprint over the handed-off source package."
4028
- );
4029
- }
4030
-
4031
- const files = Array.isArray(manifest?.files) ? manifest.files : [];
4032
- if (!files.some((entry) => entry.role === "partial")) {
4033
- add("provenance", "source_html.files.partial", "Figma source manifest files[] must include section partials.");
4034
- }
4035
- if (!files.some((entry) => entry.role === "asset")) {
4036
- add("provenance", "source_html.files.asset", "Figma source manifest files[] must include exported assets.");
4037
- }
4038
-
4039
- const sectionExports = Array.isArray(provenance.section_exports) ? provenance.section_exports : [];
4040
- if (!sectionExports.length) {
4041
- add("provenance", "source_html.producer_provenance.section_exports", "Figma source manifest must include section_exports with Figma node IDs and extraction commands.");
4042
- } else {
4043
- const withoutNodeIds = sectionExports
4044
- .filter((entry) => {
4045
- const nodeIds = isObject(entry?.node_ids) ? entry.node_ids : null;
4046
- return !nodeIds || !Object.keys(nodeIds).length;
4047
- })
4048
- .map((entry) => entry?.section || "unknown");
4049
- if (withoutNodeIds.length) {
4050
- add(
4051
- "warning",
4052
- "source_html.producer_provenance.section_exports.node_ids",
4053
- `Some Figma section exports do not list node_ids: ${withoutNodeIds.slice(0, 6).join(", ")}${withoutNodeIds.length > 6 ? ", ..." : ""}.`
4054
- );
4055
- }
4056
- }
4057
-
4058
- return findings;
4059
- }
4060
3997
 
4061
3998
  function hasFigmaDesignSource(page) {
4062
3999
  const designSource = page && isObject(page.design_source) ? page.design_source : null;
@@ -0,0 +1,112 @@
1
+ import { assessEvidenceDebt } from "../evidence-debt.mjs";
2
+
3
+ export const DOCTOR_CI_EXIT_CODES = Object.freeze({ current: 0, owed: 3, unknown: 4, usage: 2 });
4
+
5
+ export function assessDoctorCi(inputs) {
6
+ const debt = assessEvidenceDebt(inputs);
7
+ const stages = [debt.polish, debt.qa];
8
+ // Any known debt is owed; otherwise incomplete evidence remains unknown.
9
+ const status = stages.some((stage) => stage.status === "owed") ? "owed"
10
+ : stages.some((stage) => stage.status === "unknown") ? "unknown" : "current";
11
+ return { status, exit_code: DOCTOR_CI_EXIT_CODES[status], ...debt };
12
+ }
13
+
14
+ // Values can come from campaign files and will be rendered in a PR comment.
15
+ // Encode punctuation as well as HTML: diagnostics are plain text, and may
16
+ // contain Markdown, autolinks, code fences or line breaks from campaign files.
17
+ const text = (value) => String(value).replace(/\s/g, " ")
18
+ .replace(/[!-/:-@\[-`{-~]/g, (char) => `&#${char.charCodeAt(0)};`);
19
+ // Inline code already protects ordinary path punctuation; preserve those
20
+ // bytes for readable page paths and issue codes, while preventing breakouts.
21
+ const code = (value) => `\`${String(value).replace(/\s/g, " ")
22
+ .replace(/[!-/:-@\[-`{-~]/g, (char) => "./_-".includes(char) ? char : `&#${char.charCodeAt(0)};`)}\``;
23
+
24
+ const unavailablePageChanges = new Map([
25
+ ["packet_unavailable", "the build packet or its path is unavailable."],
26
+ ["build_output_location_unknown", "the built output location cannot be determined."],
27
+ ["build_output_missing", "the built output is missing."],
28
+ ["build_output_unreadable", "the built output could not be read."],
29
+ ["assembly_build_fingerprint_missing", "the assembly build fingerprint is missing."],
30
+ ["build_output_pages_unresolved", "the built output could not be mapped to pages."],
31
+ ["build_manifest_unavailable", "no usable assembly manifest is available."],
32
+ ]);
33
+
34
+ const requiredStageReasons = new Map([
35
+ ["stage_required_by_build", "the build requires this stage again"],
36
+ ["stage_required_by_brief", "the brief requires this stage again"],
37
+ ["stage_required_by_spec", "the spec requires this stage again"],
38
+ ["stage_required", "the lifecycle requires this stage again"],
39
+ ]);
40
+
41
+ export function doctorCiFailure(error, { usage = false } = {}) {
42
+ return {
43
+ ok: false, status: "blocked",
44
+ errors: [{ code: usage ? "doctor.ci.packet" : "doctor.ci.failed", message: String(error?.message ?? error) }],
45
+ warnings: [], exit_code: usage ? DOCTOR_CI_EXIT_CODES.usage : DOCTOR_CI_EXIT_CODES.unknown,
46
+ };
47
+ }
48
+
49
+ // Presentation boundary only: invocation policy still decides session and
50
+ // lifecycle behavior. Setup can throw before doctor's dispatch branch runs.
51
+ export async function withDoctorCiReport(args, invoke) {
52
+ const command = args._[0] === "campaigns-os" ? args._[1] : args._[0];
53
+ try {
54
+ return await invoke(args);
55
+ } catch (error) {
56
+ if (command !== "doctor" || args.ci !== true) throw error;
57
+ const result = doctorCiFailure(error);
58
+ console.log(args.json ? JSON.stringify(result, null, 2) : formatDoctorCi(result));
59
+ process.exitCode = result.exit_code;
60
+ }
61
+ }
62
+
63
+ export function formatDoctorCi(result) {
64
+ const ci = result.ci;
65
+ if (!ci) {
66
+ return [
67
+ `## Campaign evidence: could not evaluate (exit ${result.exit_code ?? DOCTOR_CI_EXIT_CODES.usage})`, "",
68
+ ...(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`.",
70
+ ].join("\n");
71
+ }
72
+ 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"}.`);
75
+ for (const stage of owed) {
76
+ const reason = ci[stage].reason === "build_fingerprint_changed"
77
+ ? "the recorded build fingerprint differs from the current build"
78
+ : requiredStageReasons.get(ci[stage].reason) ?? "the recorded stage evidence is owed";
79
+ lines.push(`- Owed ${stage}: ${reason}.`);
80
+ }
81
+ for (const page of ci.changes.changed_pages) {
82
+ lines.push(`- Changed page: ${code(page.page_id)} (${code(page.built_path)})`);
83
+ }
84
+ if (!ci.changes.changed_pages.length && (owed.length || ci.changes.status === "unknown")) {
85
+ lines.push(ci.changes.status === "unknown"
86
+ ? `Changed pages unavailable: ${unavailablePageChanges.get(ci.changes.reason) ?? "the page-change reason is unrecognized."}`
87
+ : "Changed page ids unavailable: the retained assembly manifest may already describe the rebuilt output.");
88
+ }
89
+ for (const stage of ["polish", "qa"]) {
90
+ if (ci[stage].status === "unknown") lines.push(`- Unknown ${stage}: ${code(ci[stage].reason)}`);
91
+ }
92
+ if (ci.changes.status === "unknown") lines.push(`- Unknown page changes: ${code(ci.changes.reason)}`);
93
+ if (ci.status === "owed" || ci.status === "unknown") {
94
+ const matchingRequired = owed.length > 0
95
+ && owed.every((stage) => requiredStageReasons.has(ci[stage].reason) && ci[stage].matches_current_build === true)
96
+ && ["polish", "qa"].every((stage) => ci[stage].status !== "unknown");
97
+ if (matchingRequired) {
98
+ const commands = [];
99
+ if (owed.includes("polish")) commands.push("`campaigns-os polish capture --packet <packet> --base-url <served-current-build-url>` for the whole route", "`campaigns-os record polish --packet <packet> --evidence <polish-evidence.json>`");
100
+ if (owed.includes("qa")) commands.push("`campaigns-os qa run --packet <packet>`");
101
+ lines.push("", `Remedy: re-run the required stages against the existing build: ${commands.join(", then ")}.`);
102
+ } else {
103
+ lines.push("", "Remedy: rebuild, then run `campaigns-os record build --packet <packet>`, `campaigns-os polish capture --packet <packet> --base-url <served-current-build-url>` for the whole route, and `campaigns-os record polish --packet <packet> --evidence <polish-evidence.json>`, in that order. When QA is owed, run `campaigns-os qa run --packet <packet>` against that build.");
104
+ }
105
+ }
106
+ lines.push("", `Doctor inspection: ${(result.errors || []).length} error(s), ${(result.warnings || []).length} warning(s); CI exit status depends only on evidence debt.`);
107
+ if (result.errors?.length) {
108
+ lines.push(`Doctor errors: ${[...new Set(result.errors.map((issue) => issue.code))].map(code).join(", ")}.`);
109
+ }
110
+ lines.push("Use ordinary `doctor` or `--ci --json` for full inspection details.");
111
+ return lines.join("\n");
112
+ }
@@ -1,5 +1,6 @@
1
1
  // Doctor entry points: the doctor command, packet inspection and built-output inspection.
2
2
  import { resolveCampaignIdentity } from "../spec-source-identity.mjs";
3
+ import { assessDoctorCi, doctorCiFailure } from "./ci.mjs";
3
4
  import { applyLocalPreviewToCheckpoint, applyLocalPreviewToPolishGate, carriedForwardMessage, CARRIED_FORWARD } from "../local-preview-policy.mjs";
4
5
  import { withHtmlScanSnapshot } from "../html-scan.mjs";
5
6
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
@@ -63,7 +64,7 @@ import {
63
64
  nextPrepareBuildBindingIssues,
64
65
  prepareBuildGateIssue,
65
66
  addPrepareBuildGateErrors,
66
- checkpointExceptionPresent,
67
+ readinessStatus,
67
68
  buildNextStep,
68
69
  } from "./next-step.mjs";
69
70
 
@@ -132,6 +133,13 @@ export async function readDoctorLiveCampaign(args, { fetchImpl = globalThis.fetc
132
133
  }
133
134
 
134
135
  export function doctorCommand(args, { runDoctor = doctorPacket, liveCampaign = undefined, qcStandIns = undefined } = {}) {
136
+ if (args.ci === true) {
137
+ try {
138
+ JSON.parse(readFileSync(resolve(requireArg(args, "packet")), "utf8"));
139
+ } catch (error) {
140
+ return doctorCiFailure(error, { usage: true });
141
+ }
142
+ }
135
143
  // Non-packet mode (learnings L7): doctor a `campaign-build`'d page-kit
136
144
  // campaign that has only a built _site/ and no full Build Packet. Resolves
137
145
  // scope from the built output and runs the built-output residue/text/
@@ -153,6 +161,16 @@ export function doctorCommand(args, { runDoctor = doctorPacket, liveCampaign = u
153
161
  ...(qcStandIns !== undefined ? { qcStandIns } : {}),
154
162
  };
155
163
  const result = runDoctor(packetPath, doctorOptions);
164
+ if (args.ci === true) {
165
+ // Use precisely the context/report selection that inspection used,
166
+ // including explicit sidecar suppression and the context's report binding.
167
+ const workspace = resolveCampaignWorkspace(packetPath, {
168
+ contextPath: doctorOptions.contextPath,
169
+ reportPath: doctorOptions.reportPath,
170
+ followContextPointer: true,
171
+ });
172
+ result.ci = assessDoctorCi({ packet: workspace.packet, packetPath, report: readJsonIfExists(workspace.reportPath) });
173
+ }
156
174
  // Inspection and recording are separate operations. A laptop's untracked
157
175
  // built output can be stale while the delivered build's evidence is valid.
158
176
  // Only an explicit producer action may replace the retained proof artifacts;
@@ -664,13 +682,7 @@ function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath =
664
682
  }
665
683
  }
666
684
  }
667
- const status = errors.length
668
- ? "blocked"
669
- : checkpointExceptionPresent(derived)
670
- ? "ready_with_waivers"
671
- : warnings.length
672
- ? "ready_with_warnings"
673
- : "ready";
685
+ const status = errors.length ? "blocked" : readinessStatus(warnings, derived);
674
686
  const result = {
675
687
  schema_version: DOCTOR_SIDECAR_SCHEMA,
676
688
  generated_at: new Date().toISOString(),
@@ -413,7 +413,7 @@ export function assessPurchaseProofCoverage({ packet = null, report = null } = {
413
413
  };
414
414
  }
415
415
 
416
- function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate, purchaseProof = null, packet = null) {
416
+ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate, purchaseProof = null) {
417
417
  const polishGate = derived?.polish_gate || evaluatePolishGate({ report });
418
418
  const polishCheckpointGate = derived?.polish_checkpoint_gate || null;
419
419
  // prepare-build is the earliest lifecycle prerequisite. Surface its
@@ -542,6 +542,9 @@ function checkpointExceptionPresent(derived) {
542
542
  || derived?.polish_checkpoint_gate?.status === "waived";
543
543
  }
544
544
 
545
+ // The readiness ladder below "blocked", written once: doctor's status, the
546
+ // `next` command's status and doctor's `next` block each decide "blocked"
547
+ // first, then call it with their own warnings.
545
548
  function readinessStatus(warnings, derived) {
546
549
  if (checkpointExceptionPresent(derived)) return "ready_with_waivers";
547
550
  return warnings.length ? "ready_with_warnings" : "ready";
@@ -664,7 +667,7 @@ function buildNextStep(errors, warnings, derived, report = null, packet = null,
664
667
  && (polishGate.status === "blocked" || polishCheckpointGate?.status === "blocked");
665
668
  const codes = new Set([...errors, ...warnings].map((issue) => issue.code));
666
669
  const purchaseProof = report ? assessPurchaseProofCoverage({ packet, report }) : null;
667
- const picked = pickNextStage(report, { errors, derived }, prepareBuildGate, purchaseProof, packet);
670
+ const picked = pickNextStage(report, { errors, derived }, prepareBuildGate, purchaseProof);
668
671
  // The picker's vocabulary and this table must not drift apart: a stage the
669
672
  // table does not know would otherwise be relabelled as an operator step and
670
673
  // sliced into the whole ladder. Fail loudly instead.
@@ -758,6 +761,6 @@ export {
758
761
  prepareBuildGateIssue,
759
762
  addPrepareBuildGateErrors,
760
763
  deployUrlFromReportOutputs,
761
- checkpointExceptionPresent,
764
+ readinessStatus,
762
765
  buildNextStep,
763
766
  };
@@ -18,6 +18,7 @@
18
18
  // validity, wrapper policy, screenshot proof) is evaluated elsewhere and is
19
19
  // untouched by this gate.
20
20
 
21
+ import { SOURCE_HASH_PATTERN } from "../source-html-manifest.mjs";
21
22
  import {
22
23
  assessCheckpointWaivers,
23
24
  checkpointStateFingerprint,
@@ -182,3 +183,76 @@ export function evaluateSourceProvenanceGates({ pages = [], blockingCodes = [],
182
183
 
183
184
  return { gates, inert: { counts, pages: inertPages } };
184
185
  }
186
+
187
+ // Returns every Figma-provenance finding in emission order, each tagged with
188
+ // its severity: "provenance" (the waivable source_html.producer_provenance*
189
+ // and source_html.files.partial/asset blockers) or "warning".
190
+ export function collectFigmaProvenanceFindings(manifest, provenance = manifest?.producer_provenance || {}, rawProvenance = manifest?.producer_provenance) {
191
+ const findings = [];
192
+ const add = (severity, code, message) => findings.push({ severity, code, message });
193
+ if (!rawProvenance) {
194
+ add(
195
+ "provenance",
196
+ "source_html.producer_provenance",
197
+ "Figma source manifest is missing producer_provenance. Re-run figma-sections-export handoff so Campaigns OS can gate semantic exporter provenance before assembly."
198
+ );
199
+ }
200
+
201
+ if (provenance.source_type !== "semantic_figma_export") {
202
+ add(
203
+ "provenance",
204
+ "source_html.producer_provenance.source_type",
205
+ `Figma source manifest source_type is "${provenance.source_type || "missing"}"; expected "semantic_figma_export". Screenshot or hand-authored fallback output cannot satisfy the Figma provenance gate.`
206
+ );
207
+ }
208
+ if (provenance.screenshot_fallback_used !== false) {
209
+ add(
210
+ "provenance",
211
+ "source_html.producer_provenance.screenshot_fallback_used",
212
+ "Figma source manifest reports screenshot_fallback_used=true. Re-run semantic figma-sections-export before assembly."
213
+ );
214
+ }
215
+ if (!Number.isInteger(provenance.semantic_section_count) || provenance.semantic_section_count <= 0) {
216
+ add(
217
+ "provenance",
218
+ "source_html.producer_provenance.semantic_section_count",
219
+ "Figma source manifest must report semantic_section_count > 0."
220
+ );
221
+ }
222
+ if (!SOURCE_HASH_PATTERN.test(String(provenance.material_fingerprint || ""))) {
223
+ add(
224
+ "provenance",
225
+ "source_html.producer_provenance.material_fingerprint",
226
+ "Figma source manifest must include a 64-character material_fingerprint over the handed-off source package."
227
+ );
228
+ }
229
+
230
+ const files = Array.isArray(manifest?.files) ? manifest.files : [];
231
+ if (!files.some((entry) => entry.role === "partial")) {
232
+ add("provenance", "source_html.files.partial", "Figma source manifest files[] must include section partials.");
233
+ }
234
+ if (!files.some((entry) => entry.role === "asset")) {
235
+ add("provenance", "source_html.files.asset", "Figma source manifest files[] must include exported assets.");
236
+ }
237
+
238
+ const sectionExports = Array.isArray(provenance.section_exports) ? provenance.section_exports : [];
239
+ if (!sectionExports.length) {
240
+ add("provenance", "source_html.producer_provenance.section_exports", "Figma source manifest must include section_exports with Figma node IDs and extraction commands.");
241
+ } else {
242
+ const withoutNodeIds = sectionExports
243
+ .filter((entry) => {
244
+ const nodeIds = isPlainObject(entry?.node_ids) ? entry.node_ids : null;
245
+ return !nodeIds || !Object.keys(nodeIds).length;
246
+ })
247
+ .map((entry) => entry?.section || "unknown");
248
+ if (withoutNodeIds.length) {
249
+ add(
250
+ "warning",
251
+ "source_html.producer_provenance.section_exports.node_ids",
252
+ `Some Figma section exports do not list node_ids: ${withoutNodeIds.slice(0, 6).join(", ")}${withoutNodeIds.length > 6 ? ", ..." : ""}.`
253
+ );
254
+ }
255
+ }
256
+
257
+ return findings;
258
+ }