@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
@@ -96,14 +96,14 @@ function listHtmlFiles(root, links = null) {
96
96
  return files.sort();
97
97
  }
98
98
 
99
- function routeForFile(campaignDir, file) {
99
+ export function routeForFile(campaignDir, file) {
100
100
  const rel = relative(campaignDir, file).split(sep).join("/");
101
101
  if (rel === "index.html") return "";
102
102
  if (rel.endsWith("/index.html")) return rel.slice(0, -"/index.html".length);
103
103
  return rel.replace(/\.html$/i, "");
104
104
  }
105
105
 
106
- function pageIdForRoute(route) {
106
+ export function pageIdForRoute(route) {
107
107
  if (!route) return "index";
108
108
  return route.replace(/\//g, "-").replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "index";
109
109
  }
@@ -290,9 +290,8 @@ export function synthesizeMinimalBuildPacket({
290
290
  // the same build. The manifest is `<path>\n<sha256>\n` per file, paths
291
291
  // relative to the output root with `/` separators and sorted by code point,
292
292
  // so directory walk order and the absolute location never leak into the
293
- // value. Page Kit (0.2.0) writes only rendered HTML and copied assets into
294
- // _site/, nothing it timestamps, so nothing is excluded by default; `exclude`
295
- // takes root-relative paths for a consumer whose build does stamp a file.
293
+ // value. Page Kit (0.2.x and 0.3.x) writes only rendered HTML and copied
294
+ // assets into _site/, nothing it timestamps, so every file is hashed.
296
295
  // Symbolic links are never build output and are skipped, not followed.
297
296
  export const BUILD_FINGERPRINT_ALGORITHM = "sha256-manifest/v1";
298
297
 
@@ -326,22 +325,17 @@ function sha256Hex(buffer) {
326
325
  * hand (`sha256sum` over each file, sort by path, hash the lines). `ok: false`
327
326
  * with `error` when the root is not a directory; an empty tree still hashes
328
327
  * (the empty manifest) so a wiped _site/ reads as a changed build, not a
329
- * missing one.
328
+ * missing one. `excluded` is always empty; doctor's build_output_fingerprint
329
+ * still reports it.
330
330
  */
331
- export function computeBuildFingerprint(outputDir, { exclude = [] } = {}) {
331
+ export function computeBuildFingerprint(outputDir) {
332
332
  const root = String(outputDir || "");
333
333
  const base = { ok: false, algorithm: BUILD_FINGERPRINT_ALGORITHM, root, fingerprint: null, file_count: 0, excluded: [], manifest: null };
334
334
  if (!root || !existsSync(root) || !statSync(root).isDirectory()) {
335
335
  return { ...base, error: `Build output directory does not exist: ${root || "(empty)"}` };
336
336
  }
337
- const excludeSet = new Set((Array.isArray(exclude) ? exclude : []).map((path) => String(path).split(sep).join("/")));
338
- const excluded = [];
339
337
  const lines = [];
340
338
  for (const path of listFilesRelative(root)) {
341
- if (excludeSet.has(path)) {
342
- excluded.push(path);
343
- continue;
344
- }
345
339
  lines.push(`${path}\n${sha256Hex(readFileSync(join(root, path)))}\n`);
346
340
  }
347
341
  const manifest = lines.join("");
@@ -350,7 +344,6 @@ export function computeBuildFingerprint(outputDir, { exclude = [] } = {}) {
350
344
  ok: true,
351
345
  fingerprint: `sha256:${sha256Hex(Buffer.from(manifest, "utf8"))}`,
352
346
  file_count: lines.length,
353
- excluded,
354
347
  manifest,
355
348
  error: null,
356
349
  };
@@ -263,9 +263,11 @@ function gateBase(subject) {
263
263
  * A page marked `spec_page: false` is built output that no CampaignSpec page
264
264
  * builds to. It is still scanned, because it is still served, but a finding
265
265
  * that names it says so: such a file is usually left in `_site/` by an earlier
266
- * build, or built from an HTML file copied into the source (a design export's
267
- * `index.html` under `assets/`), and the repair is to remove it, not to retag
268
- * it.
266
+ * build, or built from an HTML file copied into the source, and the repair is
267
+ * to remove it, not to retag it. Both are page-kit 0.2.x behaviour (no
268
+ * `_site/` pruning; a design export's `index.html` under `assets/` builds as
269
+ * a page). 0.3.0 cleans `_site/<slug>/` and ignores `.html` under `assets/`,
270
+ * so there a stray is a source page no spec page claims.
269
271
  *
270
272
  * @param {{ subject: object, pages: Array<{ page_id: string, route?: string|null,
271
273
  * file?: string|null, content: string, spec_page?: boolean,
@@ -415,7 +417,7 @@ export function evaluateCampaignIdentity({ subject, pages = [], expectedApiKey =
415
417
  // own instruction (retag, or add a missing tag to a real page) is dropped:
416
418
  // that edit would land on the wrong file.
417
419
  const named = stray.length > 2 ? `${stray.slice(0, -1).join(", ")}, and ${stray.at(-1)}` : stray.join(" and ");
418
- finding.message = `${named} ${stray.length === 1 ? "is" : "are"} not the built page of any CampaignSpec page: built output left by an earlier build, or an HTML file copied into the source (a design export's index.html under assets/ builds as its own page). Remove the source file if there is one, delete the built file, then rebuild and record the build again; do not retag either page. What it causes: ${finding.evidence}`;
420
+ finding.message = `${named} ${stray.length === 1 ? "is" : "are"} not the built page of any CampaignSpec page: built output left by an earlier build, or an HTML file copied into the source (on page-kit 0.2.x a design export's index.html under assets/ builds as its own page). Remove the source file if there is one, delete the built file, then rebuild and record the build again; do not retag either page. What it causes: ${finding.evidence}`;
419
421
  }
420
422
 
421
423
  // evidence is the message's first sentence, kept apart only so a stray
package/src/cli.mjs CHANGED
@@ -1,5 +1,7 @@
1
1
  import { campaignSpecIdentity, resolveCampaignIdentity, campaignIdentitiesMatch, localSpecIdentityFields } from "./spec-source-identity.mjs";
2
+ import { withDoctorCiReport } from "./doctor/ci.mjs";
2
3
  import { createHash, randomUUID } from "node:crypto";
4
+ import { resolveDesignFileReferences } from "./design-file-reference.mjs";
3
5
  import { withDesignSourcePublication } from "./design-source-publication.mjs";
4
6
  import { createDemo, demoArguments } from "./demo.mjs";
5
7
  import { execFileSync } from "node:child_process";
@@ -193,13 +195,9 @@ import {
193
195
  import {
194
196
  ASSEMBLY_REPORT_STAGE_KEYS,
195
197
  NEXT_STAGE_ORDER,
196
- STAGE_TERMINAL_STATUS_PREFIXES,
197
198
  stageIsTerminal,
198
199
  } from "./orchestration-stage-contract.mjs";
199
- import {
200
- currentBuildFingerprint,
201
- evaluatePolishGate,
202
- } from "./polish-gate.mjs";
200
+ import { currentBuildFingerprint } from "./polish-gate.mjs";
203
201
  import {
204
202
  assertPolishCaptureBindingUnchanged,
205
203
  capturePolishPageLoad,
@@ -221,6 +219,7 @@ import {
221
219
  import {
222
220
  loadPageKitCampaignEntry,
223
221
  PAGE_KIT_CAMPAIGNS_REL_PATH,
222
+ pageKitIgnoresAssetsHtml,
224
223
  } from "./page-kit-campaign-config.mjs";
225
224
  import {
226
225
  evaluatePageKitStoreProfile,
@@ -240,6 +239,7 @@ import {
240
239
  applySpecDerive,
241
240
  formatDeriveValue,
242
241
  isPageTreeIgnoredDir,
242
+ isPageTreeIgnoredPath,
243
243
  frontmatterPermalink,
244
244
  pageRouteForFile,
245
245
  permalinkRoute,
@@ -293,7 +293,7 @@ import {
293
293
  addPrepareBuildGateErrors,
294
294
  assessPurchaseProofCoverage,
295
295
  deployUrlFromReportOutputs,
296
- checkpointExceptionPresent,
296
+ readinessStatus,
297
297
  } from "./doctor/next-step.mjs";
298
298
 
299
299
  export { derivePackagePin, localInstallStatus };
@@ -322,7 +322,7 @@ Usage:
322
322
  [--wrapper-policy <strip_document_wrappers|preserve_document_wrappers|not_required|unknown>] [--design-manifest <path>]
323
323
  [--allow-uncertified-template "<reason>"] [--order-path-depth <off|common|full>]
324
324
  [--deploy-target <target>] [--preview-url <url>] [--production-url <url>] [--no-run-session] [--force] # intake alias for prepare-build + doctor
325
- campaigns-os doctor --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--strip-paths] [--write] [--no-write] [--doctor-out <path>] [--proxy-base <url>] [--no-live-refs] [--json] # inspection by default; --doctor-out requires --write; --no-write wins. When the packet's built _site/<route>/ exists and a public Campaigns API key resolves (packet, its local CampaignSpec, or the declared campaign-key env var), doctor makes one read-only GET of {proxy-base}/api/campaign under X-Campaign-Key to check each built page's shipping and package refs against the live campaign; --proxy-base overrides the canonical proxy (https, or a loopback host over http). --no-live-refs skips the read and records not_run with reason disabled. No key, no built page, or a failed read records derived.live_campaign_refs as not_run with its reason. Only doctor and qa run make this read; other commands that run doctor record not_read
325
+ campaigns-os doctor --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--strip-paths] [--write] [--no-write] [--doctor-out <path>] [--proxy-base <url>] [--no-live-refs] [--ci] [--json] # inspection by default; --doctor-out requires --write; --no-write wins. --ci: Markdown (or JSON ci object), exits 0 current / 3 owed / 4 unknown / 2 packet usage error; other doctor errors do not affect the CI exit. Without --ci: 0 ok / 2 errors. When the packet's built _site/<route>/ exists and a public Campaigns API key resolves (packet, its local CampaignSpec, or the declared campaign-key env var), doctor makes one read-only GET of {proxy-base}/api/campaign under X-Campaign-Key to check each built page's shipping and package refs against the live campaign; --proxy-base overrides the canonical proxy (https, or a loopback host over http). --no-live-refs skips the read and records not_run with reason disabled. No key, no built page, or a failed read records derived.live_campaign_refs as not_run with its reason. Only doctor and qa run make this read; other commands that run doctor record not_read
326
326
  campaigns-os doctor --built <page-kit-target-repo> --family <family> [--slug <slug>] [--base-url <url>] [--emit-packet [path]] [--json] # L7: doctor a built _site/ with no Build Packet. Reads the local files only: --base-url is not fetched, it fills deploy.preview_url in the minimal packet (check served pages with qa run --site)
327
327
  campaigns-os bundle check --packet <campaign-runtime.build.json> [--require-qa] [--json] # validate the canonical migration/readback JSON bundle; never substitutes markdown
328
328
  campaigns-os sdk storage-check --target <git-root> --target-sdk <x.y.z> --manifest <SDK-manifest.json> --scope <dir,file> [--exclude <dir,file>] [--json]
@@ -353,7 +353,7 @@ Usage:
353
353
  campaigns-os logout [--store <subdomain>]
354
354
  campaigns-os tooling status [--platform <claude|codex|agents|all>] [--target <skills-dir>] [--skills-revision <bundle-revision|skill-id@version>] [--packet <campaign-runtime.build.json>] [--force] [--json] # install-mode, git, skill freshness, and local gateway login/store/expiry/reported version. --skills-revision checks the bundle revision the skill you loaded states on its first body line (or that one skill's <skill-id>@<version>) against the bundle THIS CLI ships: revision_check is match, mismatch or unchecked, and a mismatch prints the full status and exits 2 because skill text already in context cannot be refreshed by re-running — start a fresh session. The pin check reports one executable per project: the project pin first — the first exact spec for this package (x.y.z, =x.y.z or vx.y.z) on the walk up from the nearest package.json, devDependencies then dependencies in each, entering a workspace root and stopping there, never peerDependencies or optionalDependencies — then the Build Packet's campaigns_os_version (the project's campaign-runtime.build.json, or --packet <path>); the Pin: line names the key and manifest (or packet) each version came from; pin.status is match, stale_pin (the pin is not the running version), conflicting_pin (the two sources disagree) or unpinned (neither, or only a range; exit 0). stale_pin and conflicting_pin exit 2 with the file to change; --force (bare) overrides them, is reported as pin.forced and lands on the lifecycle journal entry. See docs/skills-revision.md
355
355
  campaigns-os tooling diagnose [--packet <packet>] [--platform <claude|codex|agents|all>] [--json] # read-only redacted support summary
356
- campaigns-os install-agent-context --target <page-kit-dir> [--dry-run]
356
+ campaigns-os install-agent-context --target <page-kit-dir> [--dry-run] [--json]
357
357
  campaigns-os next [${NEXT_STAGE_ORDER.join("|")}] --packet <json> [--no-write] [--no-remit] [--proxy-base <url>] [--json] # no stage self-decides; returns gates[] + next_actions[] alongside the prompt
358
358
  campaigns-os next setup --packet <json> [--context <json>] [--report <json>] [--json]
359
359
  campaigns-os next build --packet <json> [--context <json>] [--report <json>] [--json]
@@ -361,7 +361,7 @@ Usage:
361
361
  campaigns-os next deploy --packet <json> --report <json> [--json]
362
362
  campaigns-os next qa --packet <json> --report <json> [--json]
363
363
  campaigns-os qa resolve --packet <json> [--base-url <url>] [--no-probe] [--probe-timeout-ms <ms>] [--json] # probes the derived entry URLs; a dead route set reports routes_unresolved, an unprobed one ready_unprobed
364
- campaigns-os qa run --packet <json> [--base-url <url>] [--currency <code>] [--browser] [--test-order <mode>] [--select-package <ref[:qty],...>] [--apply-coupon <code>] [--no-post-verdict] [--no-remit] [--no-live-refs] [--output-dir <dir>] [--json]
364
+ campaigns-os qa run --packet <json> [--base-url <url>] [--currency <code>] [--browser] [--test-order <mode>] [--select-package <ref[:qty],...>] [--apply-coupon <code>] [--test-email <email>] [--test-country <code> --test-address1 <line> --test-city <city> --test-province <code> --test-postal <code>] [--no-post-verdict] [--no-remit] [--no-live-refs] [--output-dir <dir>] [--json]
365
365
  campaigns-os qa promote --packet <json> --verdict <full-verdict.json> [--json] # project one explicit qa-output verdict to the committed .campaign-runtime/qa-verdict.json sidecar
366
366
  campaigns-os qa publish --packet <json> [--verdict <full-verdict.json>] [--republish] [--proxy-base <url>] [--dry-run] [--json] # post an already-stored verdict (the sidecar's run, or --verdict) to the QA portal without a re-run or an order; refuses a stale spec_hash or an already-published verdict. --dry-run runs every one of those refusal checks and prints what would be posted (endpoint, verdict run id, payload bytes) without the POST
367
367
  campaigns-os qa policy set --packet <json> [--allowed-domains-confirmed true|false] [--deploy-target <target>] [--preview-url <url>] [--production-url <url>] [--order-path-depth <off|common|full>] [--json] # --order-path-depth writes qa.proof_policy.order_path_depth and refreshes the assembly report's proof_policy mirror
@@ -486,14 +486,14 @@ export async function main(argv, { authentication, qcStandIns } = {}) {
486
486
  console.log(packageVersion());
487
487
  return;
488
488
  }
489
- return runInvocation(parseArgs(argv), {
489
+ return withDoctorCiReport(parseArgs(argv), (args) => runInvocation(args, {
490
490
  dispatch: (command, args, context) => dispatch(command, args, { ...context, argv, authentication, qcStandIns }),
491
491
  closeOutStaleRunSessions,
492
492
  ambientRunSession,
493
493
  lifecycleIdentity,
494
494
  persistLifecycle: persistLifecycleIfRequested,
495
495
  autoEndAfterQa: autoEndRunSessionAfterTerminalQa,
496
- });
496
+ }));
497
497
  }
498
498
 
499
499
  // The lifecycle entry's identity (T6): its argv shape and its run_id. Ambient
@@ -1051,8 +1051,15 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
1051
1051
  const liveCampaign = await readDoctorLiveCampaign(args);
1052
1052
  const result = doctorCommand(args, { liveCampaign, qcStandIns });
1053
1053
  if (!result.ok) recorder.recordFindingCodes(result.errors?.map((issue) => issue.code));
1054
- writeResult(result, args, result.ok ? 0 : 2);
1055
- printDoctorTinyPrompt(result, args);
1054
+ if (args.ci === true) {
1055
+ const { formatDoctorCi, DOCTOR_CI_EXIT_CODES } = await import("./doctor/ci.mjs");
1056
+ if (!args.json) console.log(formatDoctorCi(result));
1057
+ else writeResult(result, args, 0);
1058
+ process.exitCode = result.ci?.exit_code ?? result.exit_code ?? DOCTOR_CI_EXIT_CODES.usage;
1059
+ } else {
1060
+ writeResult(result, args, result.ok ? 0 : 2);
1061
+ printDoctorTinyPrompt(result, args);
1062
+ }
1056
1063
  return;
1057
1064
  }
1058
1065
 
@@ -1174,8 +1181,15 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
1174
1181
 
1175
1182
  if (command === "install-agent-context") {
1176
1183
  const target = requireArg(args, "target");
1177
- const result = installAgentContext(resolve(target), Boolean(args["dry-run"]));
1178
- writeResult(result, args, 0);
1184
+ const dryRun = isDryRun(args);
1185
+ const result = installAgentContext(resolve(target), dryRun);
1186
+ if (dryRun && !args.json) {
1187
+ for (const file of result.file_contents) {
1188
+ console.log(`Destination: ${file.destination}\n${file.content}`);
1189
+ }
1190
+ } else {
1191
+ writeResult(result, args, 0);
1192
+ }
1179
1193
  return;
1180
1194
  }
1181
1195
 
@@ -1847,6 +1861,7 @@ function prepareBuildUnderLock({
1847
1861
  // campaign's report), and the previous brief carries field provenance.
1848
1862
  const priorBriefInputs = priorBriefInputsForIntake({ reportPath, briefPath, identity: { spec: { map_id: mapId, ...(localSpecId ? { local_spec_id: localSpecId } : {}) }, campaign: { public_route_slug: publicRouteSlug } } });
1849
1863
  const buildBrief = createCampaignBuildBriefArtifact({
1864
+ designFiles: resolveDesignFileReferences({ activePages, mappings: matched.mappings, manifest: manifestResult.manifest }).pages,
1850
1865
  inputPath: briefDiscovery?.path || null,
1851
1866
  inputSource: briefDiscovery?.source || null,
1852
1867
  spec,
@@ -2287,7 +2302,7 @@ function assemblyThemeFromContext(theme) {
2287
2302
  load_order: "unknown",
2288
2303
  commerce_pages: [],
2289
2304
  evidence: wroteCss
2290
- ? ["prepare-build auto-generated .campaign-runtime/theme/brand-theme.css; build should copy that existing artifact into campaign assets and load it after next-core.css on commerce pages."]
2305
+ ? ["prepare-build auto-generated .campaign-runtime/theme/brand-theme.css; build should copy that existing artifact (and brand-fonts.css, when written) into campaign assets and load it after next-core.css on commerce pages."]
2291
2306
  : theme.generated?.can_generate
2292
2307
  ? ["theme inspect found a generatable brand theme; run theme generate or explicit auto policy before applying."]
2293
2308
  : ["theme inspect completed; no generated brand theme was applied during prepare-build."],
@@ -2807,6 +2822,8 @@ export async function polishCaptureCommand(args, options = {}) {
2807
2822
  adapterCloseDeadlineMs: options.adapterCloseDeadlineMs,
2808
2823
  probeClock: options.probeClock,
2809
2824
  targetRepo,
2825
+ visual: { packetPath, targetRepo, ...options.visual },
2826
+ onFrozen: options.onFrozen,
2810
2827
  });
2811
2828
  // Nothing was measured: the held plan error stands.
2812
2829
  if (heldPlanError && !capture.readability) throw heldPlanError;
@@ -2836,6 +2853,8 @@ export async function polishCaptureCommand(args, options = {}) {
2836
2853
  pageLoad: capture.page_load,
2837
2854
  mediaWeight: capture.media_weight,
2838
2855
  readability: capture.readability,
2856
+ captures: capture.captures,
2857
+ elementParity: capture.element_parity,
2839
2858
  now: options.now ?? new Date(),
2840
2859
  });
2841
2860
  checkpoint = evaluateRecordedHiddenEagerMediaCheckpoint({
@@ -2881,6 +2900,15 @@ export async function polishCaptureCommand(args, options = {}) {
2881
2900
  route_scope: capture.plan?.route_scope ?? null,
2882
2901
  routes: (capture.plan?.routes ?? []).map((route) => route.requested_route),
2883
2902
  viewports: (capture.plan?.viewports ?? []).map((viewport) => viewport.key),
2903
+ visual: capture.captures ? { cells: capture.captures.cells.length,
2904
+ rendered: capture.captures.cells.filter((cell) => cell.status === "rendered").length,
2905
+ comparisons_compared: capture.captures.comparisons.filter((comparison) => comparison.status === "compared").length,
2906
+ comparisons_unsupported: capture.captures.comparisons.filter((comparison) => comparison.status === "unsupported").length,
2907
+ dependencies: capture.captures.dependencies, adapters: capture.captures.adapters,
2908
+ // A run budget spent: a cell never rendered, or a decode or diff never ran (§2.4).
2909
+ budget_exhausted: capture.captures.cells.some((cell) => cell.status === "run_budget_exhausted")
2910
+ || capture.captures.references.some((reference) => reference.verification?.reason === "verify_budget_exhausted")
2911
+ || capture.captures.comparisons.some((comparison) => comparison.reason === "diff_budget_exhausted") } : null,
2884
2912
  },
2885
2913
  };
2886
2914
  }
@@ -3546,10 +3574,10 @@ export async function specDeriveFromStoreCommand(args, { fetchImpl = globalThis.
3546
3574
  // the route each one builds to (filename-derived, or the file's permalink;
3547
3575
  // a permalink derive cannot read as a campaign route carries `problem` and a
3548
3576
  // null route). Discovery follows page-kit's own: every .html outside
3549
- // `_layouts/` and `_includes/`, symlinked entries included. Returns null when
3550
- // the directory does not exist, so the plan can say "no page tree" rather
3551
- // than "no pages".
3552
- function listPageKitPageFiles(outputDir, publicRouteSlug) {
3577
+ // `_layouts/` and `_includes/` (and, from page-kit 0.3.0, the top-level
3578
+ // `assets/`), symlinked entries included. Returns null when the directory
3579
+ // does not exist, so the plan can say "no page tree" rather than "no pages".
3580
+ function listPageKitPageFiles(outputDir, publicRouteSlug, { assetsHtmlIgnored = false } = {}) {
3553
3581
  if (!outputDir || !existsSync(outputDir) || !statSync(outputDir).isDirectory()) return null;
3554
3582
  const files = [];
3555
3583
  const walk = (dir) => {
@@ -3563,8 +3591,7 @@ function listPageKitPageFiles(outputDir, publicRouteSlug) {
3563
3591
  }
3564
3592
  if (!stats.isFile() || extname(entry.name).toLowerCase() !== ".html") continue;
3565
3593
  const path = relative(outputDir, fullPath).split(sep).join("/");
3566
- const segments = path.split("/");
3567
- if (segments.slice(0, -1).some(isPageTreeIgnoredDir)) continue;
3594
+ if (isPageTreeIgnoredPath(path, { assetsHtmlIgnored })) continue;
3568
3595
  // page-kit honours a permalink before its filename rule, so the
3569
3596
  // frontmatter is read first (quoting, comments, BOM and CRLF as
3570
3597
  // page-kit's reader takes them); only a file with no usable permalink
@@ -3773,7 +3800,7 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
3773
3800
  try {
3774
3801
  if (existsSync(outputDir)) {
3775
3802
  const realOutputDir = realpathSync(outputDir);
3776
- if (realOutputDir === realTargetRepo || realOutputDir.startsWith(`${realTargetRepo}${sep}`)) pageFiles = listPageKitPageFiles(realOutputDir, publicRouteSlug);
3803
+ if (realOutputDir === realTargetRepo || realOutputDir.startsWith(`${realTargetRepo}${sep}`)) pageFiles = listPageKitPageFiles(realOutputDir, publicRouteSlug, { assetsHtmlIgnored: pageKitIgnoresAssetsHtml(targetRepo) });
3777
3804
  else addIssue(result.warnings, "spec.derive.page_tree_escapes_repo", `${outputDir} resolves to ${realOutputDir}, outside the target repo; routes were not read from it.`);
3778
3805
  }
3779
3806
  } catch (error) {
@@ -4318,7 +4345,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4318
4345
  }
4319
4346
  }
4320
4347
  const themeGate = doctor.derived?.theme_gate || null;
4321
- const polishGate = doctor.derived?.polish_gate || evaluatePolishGate({ report });
4348
+ const polishGate = doctor.derived?.polish_gate;
4322
4349
  const polishCheckpointGate = doctor.derived?.polish_checkpoint_gate || null;
4323
4350
  // Packet 03 (INV-5 first slice): compare the ledger's self-report against
4324
4351
  // the repo's artifacts before any recommendation goes out. Additive:
@@ -4398,21 +4425,25 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4398
4425
  const ready = [...doctor.ready];
4399
4426
  if (!doctor.ok && !doctorHasOnlyPolishGateErrors) errors.push(...doctor.errors.map((issue) => withPacketSubstitutedIssue(issue, packetPath)));
4400
4427
 
4401
- // Explicit stage requests are still downstream of prepare-build. Do not
4402
- // construct the requested stage's prompt or executable actions when the
4403
- // authoritative prepare gate is blocked or unavailable; return the same
4404
- // recovery-only shape as automatic stage selection.
4405
- if (NEXT_STAGE_ORDER.includes(stage) && prepareBuildGate) {
4428
+ // prepare-build is upstream of every stage. While its authoritative gate is
4429
+ // blocked or unavailable, doctor's picker chooses prepare-build, and an
4430
+ // explicit stage request gets the same recovery-only shape (requested_stage
4431
+ // names it) instead of that stage's prompt or executable actions.
4432
+ if (prepareBuildGate) {
4406
4433
  addPrepareBuildGateErrors(errors, report, prepareBuildGate);
4407
4434
  return finalize({
4408
4435
  ok: false,
4409
4436
  status: "blocked",
4410
4437
  stage: "prepare-build",
4411
- requested_stage: stage,
4438
+ ...(stage ? { requested_stage: stage } : {}),
4412
4439
  reason: prepareBuildGate.reason,
4413
4440
  errors,
4414
4441
  warnings,
4415
4442
  ready,
4443
+ // Packet 03: when the artifacts contradict the ledger, do NOT tell
4444
+ // the operator to start over — rerunning prepare-build/start is the
4445
+ // most destructive documented recovery, and the ledger alone is not
4446
+ // trustworthy evidence that it is needed.
4416
4447
  prompt: prepareBuildRecoveryPrompt,
4417
4448
  stage_blocked: true,
4418
4449
  });
@@ -4438,34 +4469,16 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4438
4469
  prompt: `Resolve the doctor errors above before continuing. Re-run \`${cmd("doctor")} --packet <path> --write\` to record the recovery, then \`${cmd("next")} --packet <path>\` to advance.`,
4439
4470
  });
4440
4471
  }
4441
- if (picked.stage === "prepare-build") {
4442
- addPrepareBuildGateErrors(errors, report, prepareBuildGate);
4443
- return finalize({
4444
- ok: false,
4445
- status: "blocked",
4446
- stage: "prepare-build",
4447
- reason: picked.reason,
4448
- errors,
4449
- warnings,
4450
- ready,
4451
- // Packet 03: when the artifacts contradict the ledger, do NOT tell
4452
- // the operator to start over — rerunning prepare-build/start is the
4453
- // most destructive documented recovery, and the ledger alone is not
4454
- // trustworthy evidence that it is needed.
4455
- prompt: prepareBuildRecoveryPrompt,
4456
- stage_blocked: true,
4457
- });
4458
- }
4459
4472
  if (picked.stage === "done") {
4460
4473
  return finalize({
4461
4474
  ok: true,
4462
- status: checkpointExceptionPresent(doctor.derived) ? "ready_with_waivers" : (warnings.length ? "ready_with_warnings" : "ready"),
4475
+ status: readinessStatus(warnings, doctor.derived),
4463
4476
  stage: "done",
4464
4477
  reason: picked.reason,
4465
4478
  errors,
4466
4479
  warnings,
4467
4480
  ready,
4468
- prompt: `Pipeline complete. All stages in the assembly report are in a terminal status. To repeat build work, do the work and run \`${cmd("record")} build --packet <path>\`; this makes downstream stages owed as needed. Run \`${cmd("qa")} run\` for QA. For a hosted deploy, record the URL and stage outcome as the deploy prompt describes. Then call \`${cmd("next")}\` again. If a run session is active, finish it with \`${cmd("run")} end\` so the Run Record is assembled and the session closes.${report?.stages?.qa?.status === "completed_with_warnings" ? " QA passed with exceptions; report them to the operator without clearing or waiving them or changing markup just to make them pass." : ""}`,
4481
+ prompt: `Pipeline complete. All stages in the assembly report are in a terminal status. To repeat build work, do the work and run \`${cmd("record")} build --packet <path>\`; this makes downstream stages owed as needed. \`${cmd("record")} deploy\` records the served deploy, a loopback URL for a local-serve target or an https URL for a hosted one. Run \`${cmd("qa")} run\` for QA. Then call \`${cmd("next")}\` again. If a run session is active, finish it with \`${cmd("run")} end\` so the Run Record is assembled and the session closes.${report?.stages?.qa?.status === "completed_with_warnings" ? " QA passed with exceptions; report them to the operator without clearing or waiving them or changing markup just to make them pass." : ""}`,
4469
4482
  });
4470
4483
  }
4471
4484
  stage = picked.stage;
@@ -4509,13 +4522,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4509
4522
  addThemeGateErrors(errors, themeGate, "qa");
4510
4523
  prompt = qaPrompt(packetPath, reportPath, packet, spec, intentSummary, polishCheckpointGate);
4511
4524
  }
4512
- const status = errors.length
4513
- ? "blocked"
4514
- : checkpointExceptionPresent(doctor.derived)
4515
- ? "ready_with_waivers"
4516
- : warnings.length
4517
- ? "ready_with_warnings"
4518
- : "ready";
4525
+ const status = errors.length ? "blocked" : readinessStatus(warnings, doctor.derived);
4519
4526
  const result = {
4520
4527
  ok: errors.length === 0,
4521
4528
  status,
@@ -5094,7 +5101,7 @@ Rules:
5094
5101
  - Prepared AI/exported HTML must be converted into page-kit-ready source first: keep page-owned body markup, strip document wrappers, add YAML frontmatter, move shared CSS/assets into the campaign structure, and use Liquid helpers only for page-kit links/assets/includes. Page Kit publishes src/<slug>/assets/config.js as /<slug>/config.js and src/<slug>/assets/products/foo.png as /<slug>/products/foo.png; do not leave raw /assets/... or /<slug>/assets/... references in rendered pages.
5095
5102
  - Preserve prepared source HTML for landing/presell pages when it is a real standalone design.
5096
5103
  - For checkout/upsell/downsell/receipt, use the starter template as the SDK contract reference only: preserve required data-next controls and runtime wiring, but let the campaign/source own visual chrome, copy hierarchy, imagery, and brand layer.
5097
- - Read context.theme and .campaign-runtime/theme/theme-report.json when present. If a fresh brand-theme.css exists, copy it into the campaign assets/css folder and list it after next-core.css in checkout/upsell/downsell/receipt frontmatter styles; if policy is inspect_only, generate or skip explicitly before applying a new brand layer.
5104
+ - Read context.theme and .campaign-runtime/theme/theme-report.json when present. If a fresh brand-theme.css exists, copy it into the campaign assets/css folder and list it after next-core.css in checkout/upsell/downsell/receipt frontmatter styles; when theme generate also wrote brand-fonts.css (the design's own faces), copy it beside brand-theme.css and list it on the same pages; if policy is inspect_only, generate or skip explicitly before applying a new brand layer.
5098
5105
  - Generated brand-theme.css v0 is root-variable-only. Do not edit SDK-owned selectors, package controls, payment fields, totals, submit controls, receipt templates, route meta tags, or SDK JavaScript as part of theme application.
5099
5106
  - If you copy starter-template files, copy the selected family atomically with dependent pages, _includes, _layouts, assets/css, and assets/js; do not copy only checkout.html and receipt.html.
5100
5107
  - Resolve SDK routing meta tags to campaign-root paths such as ${campaignRouteRoot(packet) || `/${packet.campaign.public_route_slug}/`}upsell/, not source filenames or unrooted spec literals.
@@ -5105,7 +5112,7 @@ Rules:
5105
5112
  - Each checkout_step page's checkout form (<form data-next-checkout="form">) carries data-next-checkout-step="<its next_step>" and data-next-step-number="<step_number>" (Build Context page_kit frontmatter next_step and step_number); the checkout page's form carries neither. On select and checkout_step pages, frontmatter next_url is the Checkout's post-payment destination, the same meaning it has on the checkout page, and the layout renders it as next-success-url because the SDK reads that tag for express orders; next_step is the forward link. A landing page keeps next_url as its call-to-action target; if it has express checkout buttons, set its next-success-url to the Checkout's own next_url from the Build Context page_map, never to the landing page's next_url. Forward navigation uses the step form attribute or a plain link, never that meta tag. Page Kit frontmatter has no success_url key (success_url remains a CampaignSpec field on checkout pages).
5106
5113
  - After page-kit build, inspect rendered _site output before handoff: each active page should have a body, Campaign Cart runtime markers, SDK meta tags from CampaignSpec sdk_hints.meta_tags, and no stale copied funnel attribution.
5107
5114
  - Run page-kit build and SDK/template lint, then record build before polish: \`${cmd("record")} build --packet ${packetPath}${isLocalServePacket(packet) ? ` --build-environment ${LOCAL_PROOF_BUILD_ENVIRONMENT}` : ""} --adapter-decision <key>=<value>[,<key>=<value>...]\`. Record the value that is true for this build: for example, raw_html_conversion_status=completed once source HTML is converted, or not_required when there is none. Put all scalar adapter decisions in one comma-separated flag; a repeated flag keeps only the last. Use the allowed values in docs/build-packet.md. Record the nine recordable scalar decisions with the command, never by editing those fields in the Assembly Report. wrapper_policy is selected at intake with prepare-build --wrapper-policy or the source-html manifest's wrapper_policy option; the object-valued template_files_copied proof has no flag. It stamps stages.assembly.build_fingerprint with the fingerprint doctor computes from the built output (derived.build_output_fingerprint.value, sha256 over the sorted path+sha256 manifest of _site/<slug>/; doctor reports built_output.fingerprint_stale whenever the output on disk stops matching the recorded value), records report.design_source_package.material_fingerprint on stages.assembly.source_package_material_fingerprint when present, and sets stages.polish to "required" (required_by="build", required_for=["qa"]). Re-run it after every rebuild; never hand-edit these fields. Build must not mark stages.polish as completed/completed_with_warnings/skipped. If you applied a brand theme, run \`${cmd("record")} theme --packet ${packetPath}\` after record build: it reads each built commerce page's stylesheet links and records report.theme (status applied, load_order=after-next-core, css_path, commerce_pages, evidence), and refuses, writing nothing, when a page that loads next-core.css does not load the brand layer after it. Never hand-edit report.theme.
5108
- - Capture the machine-readable build summary as an artifact: \`${PAGE_KIT_BUILD_SUMMARY_CAPTURE_COMMAND}\` (requires next-campaign-page-kit >= 0.1.4). Doctor verifies it for per-page build errors and Page Kit shape warnings (NESTED_NO_PERMALINK, DUPLICATE_OUTPUT, MISSING_FRONTMATTER, LAYOUT_NOT_FOUND). If the installed page-kit predates --json, record that in the assembly report instead of skipping silently.${localProofPromptLines(packet, packetPath)}`;
5115
+ - Capture the machine-readable build summary as an artifact: \`${PAGE_KIT_BUILD_SUMMARY_CAPTURE_COMMAND}\` (requires next-campaign-page-kit >= 0.1.4). Doctor verifies it for per-page build errors and Page Kit shape warnings (NESTED_NO_PERMALINK, DUPLICATE_OUTPUT, MISSING_FRONTMATTER, LAYOUT_NOT_FOUND), and on page-kit >= 0.3.0 for its top-level assetWarnings: ASSET_IGNORED names an .html file under assets/ that is neither built nor copied, so a link or iframe to it 404s. If the installed page-kit predates --json, record that in the assembly report instead of skipping silently.${localProofPromptLines(packet, packetPath)}`;
5109
5116
  }
5110
5117
 
5111
5118
  // Local proof mode lines for the build prompt: under local-serve the build is
@@ -5248,10 +5255,8 @@ function localServePlan(packet) {
5248
5255
  }
5249
5256
 
5250
5257
  function deployPrompt(packetPath, reportPath, packet, polishGate = null, polishCheckpointGate = null) {
5251
- if (packet.deploy?.target !== LOCAL_SERVE_DEPLOY_TARGET
5252
- && polishCheckpointGate?.code === "polish.hidden_eager_media.no_capturable_routes"
5253
- && !packet.deploy?.preview_url) {
5254
- return `Every mapped page is template stock, and the hosted preview URL is not recorded. Run \`${cmd("qa")} policy set --packet ${shellToken(packetPath)} --preview-url <url>\` after the preview is deployed, then \`${cmd("next")} --packet ${shellToken(packetPath)}\`.`;
5258
+ if (hostedTemplateNeedsUsablePreview(packet, polishCheckpointGate)) {
5259
+ return `Every mapped page is template stock, and deploy needs a hosted preview URL that differs from the production URL. Run \`${cmd("qa")} policy set --packet ${shellToken(packetPath)} --preview-url <url>\` after the preview is deployed, then \`${cmd("next")} --packet ${shellToken(packetPath)}\`.`;
5255
5260
  }
5256
5261
  const target = packet.deploy?.target || "unknown";
5257
5262
  const liveUrlPath = packet.deploy?.live_url_path || packet.campaign?.live_url_path || campaignRouteRoot(packet) || "/<slug>/";
@@ -5344,10 +5349,8 @@ function orderBumpRunReason(bumpCart) {
5344
5349
  }
5345
5350
 
5346
5351
  function qaPrompt(packetPath, reportPath, packet, spec = null, intent, polishCheckpointGate = null) {
5347
- if (packet.deploy?.target !== LOCAL_SERVE_DEPLOY_TARGET
5348
- && polishCheckpointGate?.code === "polish.hidden_eager_media.no_capturable_routes"
5349
- && !packet.deploy?.preview_url) {
5350
- return `Every mapped page is template stock, and QA needs the hosted preview URL. Run \`${cmd("qa")} policy set --packet ${shellToken(packetPath)} --preview-url <url>\`, then \`${cmd("next")} qa --packet ${shellToken(packetPath)}\`.`;
5352
+ if (hostedTemplateNeedsUsablePreview(packet, polishCheckpointGate)) {
5353
+ return `Every mapped page is template stock, and QA needs a hosted preview URL that differs from the production URL. Run \`${cmd("qa")} policy set --packet ${shellToken(packetPath)} --preview-url <url>\`, then \`${cmd("next")} --packet ${shellToken(packetPath)}\`.`;
5351
5354
  }
5352
5355
  const url = packet.deploy?.preview_url || packet.deploy?.production_url || "<preview-url>";
5353
5356
  const briefPath = packet.build_brief?.normalized_path || "(missing)";
@@ -5493,10 +5496,13 @@ function installAgentContext(targetRepo, dryRun = false) {
5493
5496
  ["copilot-instructions.md", join(ROOT, "agents/copilot/copilot-instructions.md")],
5494
5497
  ];
5495
5498
  const written = [];
5499
+ const fileContents = [];
5496
5500
  if (!dryRun) mkdirSync(outDir, { recursive: true });
5497
5501
  for (const [name, source] of files) {
5498
5502
  const dest = join(outDir, name);
5499
- if (!dryRun) writeFileSync(dest, readFileSync(source, "utf8"));
5503
+ const content = readFileSync(source, "utf8");
5504
+ if (!dryRun) writeFileSync(dest, content);
5505
+ else fileContents.push({ destination: dest, content });
5500
5506
  written.push(dest);
5501
5507
  }
5502
5508
  const gitignore = ensureRuntimeStateIgnored(targetRepo, { dryRun });
@@ -5506,6 +5512,7 @@ function installAgentContext(targetRepo, dryRun = false) {
5506
5512
  target_repo: targetRepo,
5507
5513
  directory: outDir,
5508
5514
  files: written,
5515
+ ...(dryRun ? { file_contents: fileContents } : {}),
5509
5516
  gitignore,
5510
5517
  note: "Context files are staged under .campaign-runtime/agent-context and do not overwrite root agent files.",
5511
5518
  };
@@ -6107,7 +6114,12 @@ function toolingCommand(args) {
6107
6114
  ? stalePlatforms.map((platform) => ["--platform", platform])
6108
6115
  : [["--platform", args.platform || "all"]];
6109
6116
  const commands = invocations.map((skillArgs) => `${cli.invocation_prefix} install-skills ${skillArgs.join(" ")}`);
6110
- actions.push(`Refresh installed skills: ${commands.join(" and ")}. ${SKILLS_READ_NOW_FOLLOW_UP}`);
6117
+ // A per-platform refresh names the harnesses it is for, so a session in
6118
+ // another harness does not read it as its own required step (#683).
6119
+ const forHarnesses = !args.target && skillScope.scope === "installed_platforms" && stalePlatforms.length < SKILL_PLATFORMS.length
6120
+ ? ` (for ${stalePlatforms.map((id) => SKILL_PLATFORMS.find((platform) => platform.id === id)?.label || id).join(" and ")}; not needed in a session that uses another harness)`
6121
+ : "";
6122
+ actions.push(`Refresh installed skills: ${commands.join(" and ")}${forHarnesses}. ${SKILLS_READ_NOW_FOLLOW_UP}`);
6111
6123
  }
6112
6124
 
6113
6125
  if (install.mode === "checkout" && cli.global_binary.status === "not_found") {
@@ -7117,6 +7129,12 @@ function runSessionStatus(args, ambient = null) {
7117
7129
  // Session progress: incomplete assembly-report stages, the deviation count,
7118
7130
  // and the exact next command. Read-only and best-effort; the status command
7119
7131
  // must never fail because an artifact is missing or torn.
7132
+ //
7133
+ // The incomplete stages are the recorded statuses. Whether every stage is
7134
+ // actually done is `next`'s decision, not this one: an input change or a new
7135
+ // build owes a stage again while its recorded status still reads completed.
7136
+ // So the next command is always `next`, which names `run end` once the
7137
+ // campaign is done and a session is active.
7120
7138
  function runSessionProgress(found) {
7121
7139
  const packetPath = found.session.packet;
7122
7140
  if (!isNonEmptyString(packetPath) || !existsSync(packetPath)) return null;
@@ -7126,7 +7144,7 @@ function runSessionProgress(found) {
7126
7144
  const incomplete = [];
7127
7145
  for (const key of ASSEMBLY_REPORT_STAGE_KEYS) {
7128
7146
  const status = String(report?.stages?.[key]?.status || "");
7129
- if (!STAGE_TERMINAL_STATUS_PREFIXES.some((prefix) => status.startsWith(prefix))) {
7147
+ if (!stageIsTerminal(status)) {
7130
7148
  incomplete.push({ stage: key, status: status || null });
7131
7149
  }
7132
7150
  }
@@ -7135,9 +7153,7 @@ function runSessionProgress(found) {
7135
7153
  incomplete_stages: incomplete,
7136
7154
  deviations,
7137
7155
  qa_attempt_count: Array.isArray(found.session.qa_attempts) ? found.session.qa_attempts.length : 0,
7138
- next_command: incomplete.length
7139
- ? `${cmd("next")} --packet ${packetPath} --json`
7140
- : `${cmd("run")} end --packet ${packetPath}`,
7156
+ next_command: `${cmd("next")} --packet ${packetPath} --json`,
7141
7157
  };
7142
7158
  } catch {
7143
7159
  return null;
@@ -0,0 +1,93 @@
1
+ import { collectFigmaProvenanceFindings } from "./doctor/source-provenance.mjs";
2
+ import { createHash } from "node:crypto";
3
+ import { canonicalJson } from "./polish-capture.mjs";
4
+
5
+ const VIEWPORTS = ["desktop", "tablet", "mobile"];
6
+ const nonEmpty = (value) => typeof value === "string" && value.trim().length > 0;
7
+ const object = (value) => value && typeof value === "object" && !Array.isArray(value);
8
+ const absoluteHttps = (value) => {
9
+ if (!nonEmpty(value)) return null;
10
+ try {
11
+ const url = new URL(value);
12
+ return url.protocol === "https:" ? url : null;
13
+ } catch { return null; }
14
+ };
15
+
16
+ // Declared links are data. No design-file reader or network access lives here.
17
+ export function normalizeDesignFileReference(value) {
18
+ const warnings = [];
19
+ const url = object(value) && absoluteHttps(value.file_url);
20
+ if (!url) return { declaration: null, warnings: ["file_url must be a non-empty absolute https: URL"] };
21
+ const type = nonEmpty(value.type) ? value.type.toLowerCase() : (url.hostname === "figma.com" || url.hostname.endsWith(".figma.com")) ? "figma" : "other";
22
+ let fileKey = null;
23
+ if (type === "figma") {
24
+ const parts = url.pathname.split("/");
25
+ const index = parts.findIndex((part) => ["file", "design", "proto", "board"].includes(part));
26
+ let candidate = index < 0 ? null : parts[index + 1];
27
+ const branch = parts.indexOf("branch", index + 2);
28
+ if (index >= 0 && branch >= 0 && /^[A-Za-z0-9]+$/.test(parts[branch + 1] ?? "")) candidate = parts[branch + 1];
29
+ if (typeof candidate === "string" && /^[A-Za-z0-9]+$/.test(candidate)) fileKey = candidate;
30
+ }
31
+ const identity = fileKey == null ? { type, url: url.origin + url.pathname } : { type, file_key: fileKey };
32
+ const file_identity_sha256 = createHash("sha256").update(canonicalJson(identity)).digest("hex");
33
+ const frames = {};
34
+ for (const viewport of VIEWPORTS) {
35
+ if (!object(value.breakpoints) || !Object.hasOwn(value.breakpoints, viewport)) continue;
36
+ const link = value.breakpoints[viewport];
37
+ let frame;
38
+ try { frame = nonEmpty(link) ? new URL(link) : null; } catch { frame = null; }
39
+ if (!frame) { warnings.push(`breakpoints.${viewport} must be an absolute frame URL`); continue; }
40
+ frames[viewport] = { url: link, node_id: frame.searchParams.get("node-id")?.replaceAll("-", ":") ?? null };
41
+ }
42
+ return { declaration: { type, file_url: value.file_url, file_key: fileKey, identity, file_identity_sha256, frames }, warnings };
43
+ }
44
+
45
+ export function resolveDesignFileReferences({ activePages = [], mappings = [], manifest = null, specChannel = { mode: "intake" } } = {}) {
46
+ const pages = [];
47
+ const files = new Map();
48
+ // Intake records only Figma spec pointers backed by #534's provenance test.
49
+ // Doctor first uses today's intake declaration. Only a page with recorded
50
+ // design-file lineage can fall back to its Figma spec without provenance.
51
+ // Manifest declarations stand alone; a shared file's producer is not per-page.
52
+ const exportProvenance = manifest != null && !collectFigmaProvenanceFindings(manifest).some((finding) => finding.severity === "provenance");
53
+ const producer = manifest?.producer_provenance;
54
+ const producerKeys = new Set([producer?.figma_file_key, ...(Array.isArray(producer?.figma_file_keys) ? producer.figma_file_keys : [])].filter(nonEmpty));
55
+ for (const page of activePages) {
56
+ const mapping = mappings.find((entry) => entry?.page_id === page.id);
57
+ if (!nonEmpty(mapping?.path)) continue;
58
+ const matches = (Array.isArray(manifest?.pages) ? manifest.pages : []).filter((entry) => entry?.page_id === page.id && entry.path === mapping.path);
59
+ const manifestValue = matches.length === 1 ? matches[0].design_file : null;
60
+ const specDeclaration = normalizeDesignFileReference(page.design_source).declaration;
61
+ const fromManifest = normalizeDesignFileReference(manifestValue);
62
+ const intakeSpec = specDeclaration?.type === "figma" && exportProvenance ? specDeclaration : null;
63
+ const fallbackSpec = !intakeSpec && !fromManifest.declaration
64
+ && specChannel.mode === "doctor" && specChannel.recordedPageIds?.has(page.id)
65
+ && specDeclaration?.type === "figma" ? specDeclaration : null;
66
+ const fromSpec = intakeSpec || fallbackSpec;
67
+ const declaration = fromSpec || fromManifest.declaration;
68
+ if (!declaration) continue;
69
+ const declared_by = fromSpec ? "campaign_spec" : "source_html_manifest";
70
+ let conflict = null;
71
+ // Provenance decides which declaration records lineage, never whether a
72
+ // Figma spec that disagrees with the manifest is reported.
73
+ const specNamed = fromSpec || (specDeclaration?.type === "figma" ? specDeclaration : null);
74
+ if (specNamed && fromManifest.declaration) {
75
+ const other = fromManifest.declaration;
76
+ const frames = VIEWPORTS.filter((viewport) => specNamed.frames[viewport] && other.frames[viewport] && specNamed.frames[viewport].node_id !== other.frames[viewport].node_id).sort();
77
+ if (specNamed.file_identity_sha256 !== other.file_identity_sha256 || frames.length) {
78
+ conflict = { kind: "spec_vs_manifest", identities: [specNamed.file_identity_sha256, other.file_identity_sha256].sort(), ...(frames.length ? { frames } : {}) };
79
+ }
80
+ }
81
+ if (!conflict && declaration.type === "figma" && declaration.file_key && producerKeys.size && !producerKeys.has(declaration.file_key)) {
82
+ conflict = { kind: "producer_file_key", identities: [declaration.file_identity_sha256] };
83
+ }
84
+ pages.push({ page_id: page.id, declaration, declared_by, conflict, warnings: manifestValue == null ? [] : fromManifest.warnings });
85
+ const sha = declaration.file_identity_sha256;
86
+ if (!files.has(sha)) files.set(sha, { contribution_id: `design-file-${sha.slice(0, 12)}`, identity: declaration.identity, file_url: declaration.file_url, producer: declared_by, pages: [] });
87
+ const file = files.get(sha);
88
+ // A shared file keeps the manifest's provenance if any page declared it there.
89
+ if (declared_by === "source_html_manifest") file.producer = declared_by;
90
+ file.pages.push(page.id);
91
+ }
92
+ return { pages, files: [...files.values()] };
93
+ }