@nextcommerce/campaigns-os 1.59.0 → 1.60.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 (41) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/README.md +6 -5
  3. package/compatibility.json +1 -1
  4. package/contracts/commerce-surface-catalog.json +18 -18
  5. package/contracts/release-ledger.json +543 -0
  6. package/contracts/supported-surface.json +1 -1
  7. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  8. package/docs/build-packet.md +14 -12
  9. package/docs/campaign-build-brief.md +1 -1
  10. package/docs/campaigns-os-build-flow.md +1 -1
  11. package/docs/local-setup.md +1 -1
  12. package/docs/orientation-contract-reference.md +1 -1
  13. package/docs/polish-evidence.md +1 -1
  14. package/docs/runtime-readiness.md +1 -1
  15. package/docs/skills-revision.md +10 -10
  16. package/package.json +1 -1
  17. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  18. package/skills/campaign-readback-classification/SKILL.md +3 -3
  19. package/skills/campaign-run-evidence/SKILL.md +3 -3
  20. package/skills/contribution-intake/SKILL.md +3 -3
  21. package/skills/next-campaigns-build/SKILL.md +4 -4
  22. package/skills/next-campaigns-os/SKILL.md +3 -3
  23. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  24. package/skills/next-campaigns-polish/SKILL.md +9 -10
  25. package/skills/next-campaigns-qa/SKILL.md +3 -3
  26. package/skills/next-campaigns-sdk-bump/SKILL.md +193 -0
  27. package/skills.json +18 -10
  28. package/src/built-site-scope.mjs +5 -12
  29. package/src/campaign-identity.mjs +6 -4
  30. package/src/cli.mjs +37 -60
  31. package/src/doctor/checks.mjs +6 -3
  32. package/src/doctor/inspect.mjs +2 -8
  33. package/src/doctor/next-step.mjs +6 -3
  34. package/src/orchestration-stage-contract.mjs +6 -3
  35. package/src/page-kit-build-summary.mjs +21 -1
  36. package/src/page-kit-campaign-config.mjs +19 -0
  37. package/src/polish-gate.mjs +1 -5
  38. package/src/progress-node.mjs +2 -1
  39. package/src/spec-derive.mjs +24 -9
  40. package/src/stage-ledger.mjs +27 -1
  41. package/src/stage-record.mjs +18 -2
@@ -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
@@ -193,13 +193,9 @@ import {
193
193
  import {
194
194
  ASSEMBLY_REPORT_STAGE_KEYS,
195
195
  NEXT_STAGE_ORDER,
196
- STAGE_TERMINAL_STATUS_PREFIXES,
197
196
  stageIsTerminal,
198
197
  } from "./orchestration-stage-contract.mjs";
199
- import {
200
- currentBuildFingerprint,
201
- evaluatePolishGate,
202
- } from "./polish-gate.mjs";
198
+ import { currentBuildFingerprint } from "./polish-gate.mjs";
203
199
  import {
204
200
  assertPolishCaptureBindingUnchanged,
205
201
  capturePolishPageLoad,
@@ -221,6 +217,7 @@ import {
221
217
  import {
222
218
  loadPageKitCampaignEntry,
223
219
  PAGE_KIT_CAMPAIGNS_REL_PATH,
220
+ pageKitIgnoresAssetsHtml,
224
221
  } from "./page-kit-campaign-config.mjs";
225
222
  import {
226
223
  evaluatePageKitStoreProfile,
@@ -240,6 +237,7 @@ import {
240
237
  applySpecDerive,
241
238
  formatDeriveValue,
242
239
  isPageTreeIgnoredDir,
240
+ isPageTreeIgnoredPath,
243
241
  frontmatterPermalink,
244
242
  pageRouteForFile,
245
243
  permalinkRoute,
@@ -293,7 +291,7 @@ import {
293
291
  addPrepareBuildGateErrors,
294
292
  assessPurchaseProofCoverage,
295
293
  deployUrlFromReportOutputs,
296
- checkpointExceptionPresent,
294
+ readinessStatus,
297
295
  } from "./doctor/next-step.mjs";
298
296
 
299
297
  export { derivePackagePin, localInstallStatus };
@@ -3546,10 +3544,10 @@ export async function specDeriveFromStoreCommand(args, { fetchImpl = globalThis.
3546
3544
  // the route each one builds to (filename-derived, or the file's permalink;
3547
3545
  // a permalink derive cannot read as a campaign route carries `problem` and a
3548
3546
  // 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) {
3547
+ // `_layouts/` and `_includes/` (and, from page-kit 0.3.0, the top-level
3548
+ // `assets/`), symlinked entries included. Returns null when the directory
3549
+ // does not exist, so the plan can say "no page tree" rather than "no pages".
3550
+ function listPageKitPageFiles(outputDir, publicRouteSlug, { assetsHtmlIgnored = false } = {}) {
3553
3551
  if (!outputDir || !existsSync(outputDir) || !statSync(outputDir).isDirectory()) return null;
3554
3552
  const files = [];
3555
3553
  const walk = (dir) => {
@@ -3563,8 +3561,7 @@ function listPageKitPageFiles(outputDir, publicRouteSlug) {
3563
3561
  }
3564
3562
  if (!stats.isFile() || extname(entry.name).toLowerCase() !== ".html") continue;
3565
3563
  const path = relative(outputDir, fullPath).split(sep).join("/");
3566
- const segments = path.split("/");
3567
- if (segments.slice(0, -1).some(isPageTreeIgnoredDir)) continue;
3564
+ if (isPageTreeIgnoredPath(path, { assetsHtmlIgnored })) continue;
3568
3565
  // page-kit honours a permalink before its filename rule, so the
3569
3566
  // frontmatter is read first (quoting, comments, BOM and CRLF as
3570
3567
  // page-kit's reader takes them); only a file with no usable permalink
@@ -3773,7 +3770,7 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
3773
3770
  try {
3774
3771
  if (existsSync(outputDir)) {
3775
3772
  const realOutputDir = realpathSync(outputDir);
3776
- if (realOutputDir === realTargetRepo || realOutputDir.startsWith(`${realTargetRepo}${sep}`)) pageFiles = listPageKitPageFiles(realOutputDir, publicRouteSlug);
3773
+ if (realOutputDir === realTargetRepo || realOutputDir.startsWith(`${realTargetRepo}${sep}`)) pageFiles = listPageKitPageFiles(realOutputDir, publicRouteSlug, { assetsHtmlIgnored: pageKitIgnoresAssetsHtml(targetRepo) });
3777
3774
  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
3775
  }
3779
3776
  } catch (error) {
@@ -4318,7 +4315,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4318
4315
  }
4319
4316
  }
4320
4317
  const themeGate = doctor.derived?.theme_gate || null;
4321
- const polishGate = doctor.derived?.polish_gate || evaluatePolishGate({ report });
4318
+ const polishGate = doctor.derived?.polish_gate;
4322
4319
  const polishCheckpointGate = doctor.derived?.polish_checkpoint_gate || null;
4323
4320
  // Packet 03 (INV-5 first slice): compare the ledger's self-report against
4324
4321
  // the repo's artifacts before any recommendation goes out. Additive:
@@ -4398,21 +4395,25 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4398
4395
  const ready = [...doctor.ready];
4399
4396
  if (!doctor.ok && !doctorHasOnlyPolishGateErrors) errors.push(...doctor.errors.map((issue) => withPacketSubstitutedIssue(issue, packetPath)));
4400
4397
 
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) {
4398
+ // prepare-build is upstream of every stage. While its authoritative gate is
4399
+ // blocked or unavailable, doctor's picker chooses prepare-build, and an
4400
+ // explicit stage request gets the same recovery-only shape (requested_stage
4401
+ // names it) instead of that stage's prompt or executable actions.
4402
+ if (prepareBuildGate) {
4406
4403
  addPrepareBuildGateErrors(errors, report, prepareBuildGate);
4407
4404
  return finalize({
4408
4405
  ok: false,
4409
4406
  status: "blocked",
4410
4407
  stage: "prepare-build",
4411
- requested_stage: stage,
4408
+ ...(stage ? { requested_stage: stage } : {}),
4412
4409
  reason: prepareBuildGate.reason,
4413
4410
  errors,
4414
4411
  warnings,
4415
4412
  ready,
4413
+ // Packet 03: when the artifacts contradict the ledger, do NOT tell
4414
+ // the operator to start over — rerunning prepare-build/start is the
4415
+ // most destructive documented recovery, and the ledger alone is not
4416
+ // trustworthy evidence that it is needed.
4416
4417
  prompt: prepareBuildRecoveryPrompt,
4417
4418
  stage_blocked: true,
4418
4419
  });
@@ -4438,34 +4439,16 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4438
4439
  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
4440
  });
4440
4441
  }
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
4442
  if (picked.stage === "done") {
4460
4443
  return finalize({
4461
4444
  ok: true,
4462
- status: checkpointExceptionPresent(doctor.derived) ? "ready_with_waivers" : (warnings.length ? "ready_with_warnings" : "ready"),
4445
+ status: readinessStatus(warnings, doctor.derived),
4463
4446
  stage: "done",
4464
4447
  reason: picked.reason,
4465
4448
  errors,
4466
4449
  warnings,
4467
4450
  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." : ""}`,
4451
+ 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
4452
  });
4470
4453
  }
4471
4454
  stage = picked.stage;
@@ -4509,13 +4492,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
4509
4492
  addThemeGateErrors(errors, themeGate, "qa");
4510
4493
  prompt = qaPrompt(packetPath, reportPath, packet, spec, intentSummary, polishCheckpointGate);
4511
4494
  }
4512
- const status = errors.length
4513
- ? "blocked"
4514
- : checkpointExceptionPresent(doctor.derived)
4515
- ? "ready_with_waivers"
4516
- : warnings.length
4517
- ? "ready_with_warnings"
4518
- : "ready";
4495
+ const status = errors.length ? "blocked" : readinessStatus(warnings, doctor.derived);
4519
4496
  const result = {
4520
4497
  ok: errors.length === 0,
4521
4498
  status,
@@ -5105,7 +5082,7 @@ Rules:
5105
5082
  - 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
5083
  - 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
5084
  - 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)}`;
5085
+ - 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
5086
  }
5110
5087
 
5111
5088
  // Local proof mode lines for the build prompt: under local-serve the build is
@@ -5248,10 +5225,8 @@ function localServePlan(packet) {
5248
5225
  }
5249
5226
 
5250
5227
  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)}\`.`;
5228
+ if (hostedTemplateNeedsUsablePreview(packet, polishCheckpointGate)) {
5229
+ 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
5230
  }
5256
5231
  const target = packet.deploy?.target || "unknown";
5257
5232
  const liveUrlPath = packet.deploy?.live_url_path || packet.campaign?.live_url_path || campaignRouteRoot(packet) || "/<slug>/";
@@ -5344,10 +5319,8 @@ function orderBumpRunReason(bumpCart) {
5344
5319
  }
5345
5320
 
5346
5321
  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)}\`.`;
5322
+ if (hostedTemplateNeedsUsablePreview(packet, polishCheckpointGate)) {
5323
+ 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
5324
  }
5352
5325
  const url = packet.deploy?.preview_url || packet.deploy?.production_url || "<preview-url>";
5353
5326
  const briefPath = packet.build_brief?.normalized_path || "(missing)";
@@ -7117,6 +7090,12 @@ function runSessionStatus(args, ambient = null) {
7117
7090
  // Session progress: incomplete assembly-report stages, the deviation count,
7118
7091
  // and the exact next command. Read-only and best-effort; the status command
7119
7092
  // must never fail because an artifact is missing or torn.
7093
+ //
7094
+ // The incomplete stages are the recorded statuses. Whether every stage is
7095
+ // actually done is `next`'s decision, not this one: an input change or a new
7096
+ // build owes a stage again while its recorded status still reads completed.
7097
+ // So the next command is always `next`, which names `run end` once the
7098
+ // campaign is done and a session is active.
7120
7099
  function runSessionProgress(found) {
7121
7100
  const packetPath = found.session.packet;
7122
7101
  if (!isNonEmptyString(packetPath) || !existsSync(packetPath)) return null;
@@ -7126,7 +7105,7 @@ function runSessionProgress(found) {
7126
7105
  const incomplete = [];
7127
7106
  for (const key of ASSEMBLY_REPORT_STAGE_KEYS) {
7128
7107
  const status = String(report?.stages?.[key]?.status || "");
7129
- if (!STAGE_TERMINAL_STATUS_PREFIXES.some((prefix) => status.startsWith(prefix))) {
7108
+ if (!stageIsTerminal(status)) {
7130
7109
  incomplete.push({ stage: key, status: status || null });
7131
7110
  }
7132
7111
  }
@@ -7135,9 +7114,7 @@ function runSessionProgress(found) {
7135
7114
  incomplete_stages: incomplete,
7136
7115
  deviations,
7137
7116
  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}`,
7117
+ next_command: `${cmd("next")} --packet ${packetPath} --json`,
7141
7118
  };
7142
7119
  } catch {
7143
7120
  return null;
@@ -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");
@@ -63,7 +63,7 @@ import {
63
63
  nextPrepareBuildBindingIssues,
64
64
  prepareBuildGateIssue,
65
65
  addPrepareBuildGateErrors,
66
- checkpointExceptionPresent,
66
+ readinessStatus,
67
67
  buildNextStep,
68
68
  } from "./next-step.mjs";
69
69
 
@@ -664,13 +664,7 @@ function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath =
664
664
  }
665
665
  }
666
666
  }
667
- const status = errors.length
668
- ? "blocked"
669
- : checkpointExceptionPresent(derived)
670
- ? "ready_with_waivers"
671
- : warnings.length
672
- ? "ready_with_warnings"
673
- : "ready";
667
+ const status = errors.length ? "blocked" : readinessStatus(warnings, derived);
674
668
  const result = {
675
669
  schema_version: DOCTOR_SIDECAR_SCHEMA,
676
670
  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
  };
@@ -50,9 +50,12 @@ export function reportKeyForCliStage(cliStage) {
50
50
  * "completed", "completed_with_warnings", and "completed_partial" all
51
51
  * count as terminal under "completed". This matches how the existing
52
52
  * stages already report sub-statuses (see report.stages.assembly.status
53
- * shapes in src/cli.mjs and qa/shared/qa-verdict.js). Shared here so the
54
- * `next` picker, the session progress summary and the Assembly Report's own
55
- * derived summary (src/stage-ledger.mjs) read one definition of "done".
53
+ * shapes in src/cli.mjs and qa/shared/qa-verdict.js). This is the rule for a
54
+ * RECORDED status only (the session progress summary's incomplete stages,
55
+ * the prepare-build gate, doctor's checks). Whether a stage is done is
56
+ * decided on its effective status (src/input-currency.mjs), which the `next`
57
+ * picker and the Assembly Report's derived summary (src/stage-ledger.mjs)
58
+ * read: a stage owed again is not done, whatever status it records.
56
59
  */
57
60
  export const STAGE_TERMINAL_STATUS_PREFIXES = Object.freeze(["completed", "skipped"]);
58
61
 
@@ -165,9 +165,29 @@ export function evaluatePageKitBuildSummary({
165
165
  }
166
166
  }
167
167
 
168
+ // page-kit >= 0.3.0 neither builds nor copies .html under assets/ and lists
169
+ // each such file in a top-level assetWarnings array (ASSET_IGNORED), never
170
+ // in pages[].warnings. A link or iframe to one 404s on the built site, and
171
+ // this is the only signal, so it is surfaced, but never escalated: removing
172
+ // or moving the file is the author's call. Entries carry no campaignSlug;
173
+ // the file path (src/<slug>/assets/...) names the campaign.
174
+ const assetWarnings = Array.isArray(summary.assetWarnings)
175
+ ? summary.assetWarnings.filter((warning) => typeof warning?.file === "string" && `/${warning.file.replace(/\\/g, "/")}`.includes(`/${publicRouteSlug}/assets/`))
176
+ : [];
177
+ for (const warning of assetWarnings) {
178
+ flagged += 1;
179
+ warnings.push({
180
+ code: "built_output.build_asset_warning",
181
+ message:
182
+ `Page Kit did not build or copy "${warning.file}": [${warning.code}] ${warning.message}. ` +
183
+ `A link or iframe to it 404s on the built site; move it out of assets/ if it is a page, or remove it.`,
184
+ detail: { file: warning.file, warning_code: warning.code },
185
+ });
186
+ }
187
+
168
188
  if (failed === 0 && flagged === 0) {
169
189
  ready.push(
170
- `Page Kit build summary verified for "${publicRouteSlug}": ${campaignPages.length} page(s) built, no build errors or shape warnings`
190
+ `Page Kit build summary verified for "${publicRouteSlug}": ${campaignPages.length} page(s) built, no build errors, shape warnings or asset warnings`
171
191
  );
172
192
  }
173
193
 
@@ -1,9 +1,28 @@
1
1
  import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { compareVersions, extractVersion } from "./repo-scan.mjs";
3
4
  import { normalizePublicRouteSlug } from "./route-identity.mjs";
4
5
 
5
6
  export const PAGE_KIT_CAMPAIGNS_REL_PATH = "_data/campaigns.json";
6
7
 
8
+ // next-campaign-page-kit 0.3.0 stopped building or copying .html under a
9
+ // campaign's top-level assets/. Source-page discovery follows the target's
10
+ // installed page-kit, read from node_modules the way tooling setup reads it;
11
+ // a missing, unreadable or malformed install keeps the 0.2.x rule. That is
12
+ // deliberate: doctor's built-page lookup calls this from about ten checks, so
13
+ // throwing would take doctor down over a broken install that the build itself
14
+ // fails on loudly, and tooling setup already refuses a declared page-kit that
15
+ // is not installed.
16
+ export const PAGE_KIT_ASSETS_HTML_IGNORED_FROM = "0.3.0";
17
+ export function pageKitIgnoresAssetsHtml(targetRepo) {
18
+ try {
19
+ const { version } = JSON.parse(readFileSync(join(targetRepo, "node_modules", "next-campaign-page-kit", "package.json"), "utf8"));
20
+ return extractVersion(version) !== null && compareVersions(version, PAGE_KIT_ASSETS_HTML_IGNORED_FROM) >= 0;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
7
26
  // Public doctor/next/verdict evidence must never inherit arbitrary merchant
8
27
  // configuration from campaigns.json. The raw entry is validation-local; this
9
28
  // fixed projection is the only loader metadata safe to retain in artifacts.
@@ -45,11 +45,7 @@ function terminalAssembly(report) {
45
45
  }
46
46
 
47
47
  export function currentBuildFingerprint(report) {
48
- return normalizeString(report?.stages?.assembly?.build_fingerprint)
49
- || normalizeString(report?.stages?.assembly?.artifact_fingerprint)
50
- || normalizeString(report?.build_fingerprint)
51
- || normalizeString(report?.artifact_fingerprint)
52
- || null;
48
+ return normalizeString(report?.stages?.assembly?.build_fingerprint) || null;
53
49
  }
54
50
 
55
51
  export function currentSourcePackageMaterialFingerprint(report) {
@@ -6,6 +6,7 @@ import {join,resolve,dirname} from 'node:path';
6
6
  import {PROGRESS_SCHEMA_VERSION,PROGRESS_STAGES,PROGRESS_STAGE_STATUSES,PROGRESS_CONTINUATIONS,PROGRESS_ACTION_IDS,PROGRESS_GATE_IDS,canonicalProgressJson,progressSnapshotId,verifyProgressSnapshot} from './progress.mjs';
7
7
  import {specMaterialHash} from './spec-identity.mjs';
8
8
  import {effectiveStageStatus} from './input-currency.mjs';
9
+ import {qaRecordedBuildFingerprint} from './stage-ledger.mjs';
9
10
  import {sameFile} from './fs-identity.mjs';
10
11
  import {withDirectoryLock} from './directory-lock.mjs';
11
12
  import {resolveConsent,CANONICAL_REMIT_SCOPE,normalizeConsentScope,announceDefaultOnTelemetry} from './consent.mjs';
@@ -64,7 +65,7 @@ export function projectProgressObservation({workspace,context,report,doctor,cont
64
65
  // The report is also bound to the spec material on disk now, so output and
65
66
  // verdict evidence recorded against it can match.
66
67
  const reportBound=stagesBound&&localHash===hash(report?.identity?.spec_material_hash);
67
- const qaSource=hash(report?.stages?.qa?.evidence?.source_build_fingerprint);
68
+ const qaSource=hash(qaRecordedBuildFingerprint(report));
68
69
  const verdict=qaResult?.verdict;
69
70
  const qa=verdict?{
70
71
  verdict_id:id(verdict.run_id),disposition:accepted(verdict.disposition,['ready','ready_with_exceptions','blocked']),
@@ -56,15 +56,29 @@ const ROUTING_HINT_FIELDS = Object.freeze({
56
56
  });
57
57
 
58
58
  // The directories page-kit's own discovery ignores (`_layouts`, `_includes`;
59
- // everything else under the campaign root, `assets/` and `_data/` included,
60
- // is rendered when it is .html), plus the two no build reads. The CLI walker
61
- // prunes with this and pageRouteForFile filters with it, so the list lives
62
- // in one place.
59
+ // on 0.2.x everything else under the campaign root, `assets/` and `_data/`
60
+ // included, is rendered when it is .html), plus the two no build reads. The
61
+ // CLI walker prunes with this and pageRouteForFile filters with it, so the
62
+ // list lives in one place.
63
63
  export const PAGE_TREE_IGNORED_DIRS = Object.freeze(["_layouts", "_includes", "node_modules", ".git"]);
64
64
  export function isPageTreeIgnoredDir(name) {
65
65
  return PAGE_TREE_IGNORED_DIRS.includes(name);
66
66
  }
67
67
 
68
+ // Whether a campaign-relative file sits where page-kit renders no page. From
69
+ // 0.3.0 page-kit also skips the campaign's top-level `assets/` (its
70
+ // `*/assets/**` glob; a nested `offers/assets/` still renders), so the caller
71
+ // says which rule the target's installed page-kit follows. Backslashes and
72
+ // leading slashes read as pageRouteForFile reads them.
73
+ export function isPageTreeIgnoredPath(relativePath, { assetsHtmlIgnored = false } = {}) {
74
+ const dirs = normalizePageTreePath(relativePath).split("/").slice(0, -1);
75
+ return dirs.some(isPageTreeIgnoredDir) || (assetsHtmlIgnored && dirs[0] === "assets");
76
+ }
77
+
78
+ function normalizePageTreePath(relativePath) {
79
+ return String(relativePath || "").replace(/\\/g, "/").replace(/^\/+/, "");
80
+ }
81
+
68
82
  function isPlainObject(value) {
69
83
  return value !== null && typeof value === "object" && !Array.isArray(value);
70
84
  }
@@ -114,13 +128,14 @@ function containerProblem(spec, path) {
114
128
  // `upsell/`, intermediate directories ignored with its NESTED_NO_PERMALINK
115
129
  // warning), and `index.html` is the entry route "". A nested `index.html`
116
130
  // would collide with the campaign root (page-kit's DUPLICATE_OUTPUT), so it
117
- // is not a page this reads. Files under `_layouts/` or `_includes/` return
118
- // null, as page-kit never renders them.
119
- export function pageRouteForFile(relativePath) {
120
- const path = String(relativePath || "").replace(/\\/g, "/").replace(/^\/+/, "");
131
+ // is not a page this reads. Files under `_layouts/` or `_includes/` (and,
132
+ // with assetsHtmlIgnored, the top-level `assets/`) return null, as page-kit
133
+ // never renders them.
134
+ export function pageRouteForFile(relativePath, { assetsHtmlIgnored = false } = {}) {
135
+ const path = normalizePageTreePath(relativePath);
121
136
  if (!path || !/\.html$/i.test(path)) return null;
122
137
  const segments = path.split("/");
123
- if (segments.slice(0, -1).some(isPageTreeIgnoredDir)) return null;
138
+ if (isPageTreeIgnoredPath(path, { assetsHtmlIgnored })) return null;
124
139
  const basename = segments[segments.length - 1].replace(/\.html$/i, "");
125
140
  if (basename === "index") return segments.length === 1 ? "" : null;
126
141
  return `${basename}/`;
@@ -11,7 +11,6 @@ import {
11
11
  NEXT_STAGE_CONTRACTS,
12
12
  NEXT_STAGE_OWNERS,
13
13
  stageIsBlocked,
14
- stageIsTerminal,
15
14
  } from "./orchestration-stage-contract.mjs";
16
15
 
17
16
  const PRODUCER_STAGES = new Set(["doctor", "qa"]);
@@ -200,6 +199,33 @@ export function archiveSupersededRecord(stage, previous, { by, reason, at }) {
200
199
  return appendHistoryEntry(isPlainObject(stage) ? { ...stage } : {}, historyEntry(previous, { by, reason, at }));
201
200
  }
202
201
 
202
+ /**
203
+ * #668: the Polish record a CampaignSpec change moved to `polish.history`,
204
+ * restored as the live record stamped with `stamps` (the current inputs), or
205
+ * null. It is the same review of the same pages, so it is restored only when
206
+ * the live record's input_change is a CampaignSpec change over exactly
207
+ * `fingerprint`, the newest history entry is the completed Polish that change
208
+ * archived (same build fingerprint and source package fingerprint), and the
209
+ * brief's presentation stamp is unchanged: a presentation change still owes a
210
+ * new review. The caller decides that the operator kept this output.
211
+ */
212
+ export function restorePolishKeptWithOutput(polish, { fingerprint, sourcePackageFingerprint = null, stamps = {}, completedStatuses }) {
213
+ const change = isPlainObject(polish) ? polish.input_change : null;
214
+ if (!isPlainObject(change) || change.reason !== "spec_material_changed" || change.superseded_build_fingerprint !== fingerprint) return null;
215
+ const history = Array.isArray(polish.history) ? polish.history : [];
216
+ const superseded = history[history.length - 1];
217
+ if (!isPlainObject(superseded) || superseded.archived_at !== change.at || !completedStatuses.includes(superseded.status)) return null;
218
+ if (superseded.source_build_fingerprint !== fingerprint) return null;
219
+ if ((superseded.source_package_material_fingerprint ?? null) !== (sourcePackageFingerprint ?? null)) return null;
220
+ if ((superseded.source_brief_material?.presentation ?? null) !== (stamps.source_brief_material?.presentation ?? null)) return null;
221
+ const restored = { ...polish };
222
+ for (const field of ["required_by", "required_for", "input_change", "source_brief_material", "source_spec_material_hash"]) delete restored[field];
223
+ for (const field of ["status", "completed_at", "recorded_by", "performed_by", "source_build_fingerprint", "source_package_material_fingerprint", "outputs", "evidence"]) {
224
+ if (Object.hasOwn(superseded, field)) restored[field] = JSON.parse(JSON.stringify(superseded[field]));
225
+ }
226
+ return { ...restored, stage: "polish", ...stamps };
227
+ }
228
+
203
229
  /**
204
230
  * True when any stage of `report` carries a non-empty history: recorded
205
231
  * operator-visible state an intake must not silently drop.