@nextcommerce/campaigns-os 1.62.0 → 1.66.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 (74) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/campaign-spec/dist/rules/campaign-currencies.d.ts +1 -0
  3. package/campaign-spec/dist/rules/campaign-currencies.js +1 -1
  4. package/campaign-spec/dist/rules/index.d.ts +1 -0
  5. package/campaign-spec/dist/rules/index.js +3 -0
  6. package/campaign-spec/dist/rules/package-currency-prices.d.ts +29 -0
  7. package/campaign-spec/dist/rules/package-currency-prices.js +137 -0
  8. package/campaign-spec/dist/types.d.ts +16 -0
  9. package/compatibility.json +1 -1
  10. package/contracts/effects.v1.json +129 -1
  11. package/contracts/fixtures/runtime-recipe/accept/current.json +1 -0
  12. package/contracts/fixtures/runtime-recipe/accept/minimal.json +1 -0
  13. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  14. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +1 -0
  15. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +1 -0
  16. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +1 -0
  17. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +1 -0
  18. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +1 -0
  19. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +1 -0
  20. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +1 -0
  21. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +1 -0
  22. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +1 -0
  23. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +1 -0
  24. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +1 -0
  25. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +1 -0
  26. package/contracts/release-ledger.json +810 -0
  27. package/contracts/runtime-recipe.campaigns-os-node-v1.json +1 -0
  28. package/contracts/supported-surface.json +6 -6
  29. package/docs/build-packet.md +31 -5
  30. package/docs/campaigns-os-build-flow.md +38 -0
  31. package/docs/campaignspec-authoring-examples.md +31 -0
  32. package/docs/effects.md +8 -0
  33. package/docs/local-setup.md +1 -1
  34. package/docs/orientation-contract-reference.md +1 -1
  35. package/docs/qa-and-test-orders.md +44 -10
  36. package/docs/readback.md +8 -1
  37. package/docs/runtime-readiness.md +4 -3
  38. package/docs/skills-revision.md +10 -10
  39. package/docs/supported-surface.md +1 -1
  40. package/package.json +1 -1
  41. package/schemas/campaign-spec.v4.schema.json +15 -0
  42. package/schemas/campaign-spec.v5.schema.json +15 -0
  43. package/schemas/campaigns-os-qa-verdict.v0.schema.json +17 -2
  44. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  45. package/skills/campaign-readback-classification/SKILL.md +3 -3
  46. package/skills/campaign-run-evidence/SKILL.md +3 -3
  47. package/skills/contribution-intake/SKILL.md +3 -3
  48. package/skills/next-campaigns-build/SKILL.md +3 -3
  49. package/skills/next-campaigns-os/SKILL.md +3 -3
  50. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  51. package/skills/next-campaigns-polish/SKILL.md +3 -3
  52. package/skills/next-campaigns-qa/SKILL.md +4 -4
  53. package/skills/next-campaigns-sdk-bump/SKILL.md +3 -3
  54. package/skills.json +11 -11
  55. package/src/change-scope.mjs +25 -6
  56. package/src/cli.mjs +120 -7
  57. package/src/commercial-journey.mjs +3 -0
  58. package/src/commercial-parity.mjs +157 -7
  59. package/src/doctor/checks.mjs +28 -3
  60. package/src/doctor/ci.mjs +36 -1
  61. package/src/evidence-debt.mjs +9 -1
  62. package/src/input-currency.mjs +4 -2
  63. package/src/input-refresh.mjs +1 -1
  64. package/src/invocation.mjs +3 -2
  65. package/src/orchestration-stage-contract.mjs +3 -1
  66. package/src/polish-gate.mjs +10 -0
  67. package/src/qa-browser.mjs +46 -0
  68. package/src/qa-commercial-parity.mjs +173 -18
  69. package/src/qa-node.mjs +10 -5
  70. package/src/qa-verdict.mjs +3 -1
  71. package/src/readback.mjs +1 -0
  72. package/src/runtime-state-ignore.mjs +44 -19
  73. package/src/stage-ledger.mjs +35 -9
  74. package/src/stage-record.mjs +93 -14
@@ -37,7 +37,9 @@ import { LOCAL_PROOF_BUILD_ENVIRONMENT, LOCAL_PROOF_PRODUCTION_ENVIRONMENT, isLo
37
37
  import { polishCarriedForwardForLadder } from "./local-preview-policy.mjs";
38
38
  import { isLoopbackHostname } from "./remit.mjs";
39
39
  import { campaignRouteRoot, normalizePublicRouteSlug } from "./route-identity.mjs";
40
- import { buildChangeEntry, buildCommerceSignature, nextBuildChanges } from "./change-scope.mjs";
40
+ import { buildChangeEntry, buildCommerceSignature, nextBuildChanges, rebuiltFromBuildWithoutSignature } from "./change-scope.mjs";
41
+ import { isNamedHuman } from "./checkpoint-waiver.mjs";
42
+ import { singleLineFragment } from "./text-safety.mjs";
41
43
  import { assessEvidenceDebt } from "./evidence-debt.mjs";
42
44
  import { writeJsonAtomic } from "./doctor-sidecar.mjs";
43
45
  import { doctorPacket } from "./doctor/inspect.mjs";
@@ -54,7 +56,7 @@ import {
54
56
  import { evaluateRecordedHiddenEagerMediaCheckpoint } from "./polish-node.mjs";
55
57
  import { effectiveStageStatus, effectiveStatusIsTerminal, inputStamps, stageWriteInputs } from "./input-currency.mjs";
56
58
  import { demoteStages, recordBrief, recordSpec } from "./input-refresh.mjs";
57
- import { applyDerivedAssemblyReportSummary, archiveStageRecord, archiveSupersededRecord, assemblyReportMatchesPacket, commitAssemblyReport, inputChangeFor, polishReviewedBuild, qaExtendedBuildFingerprint, qaRecordedBuildFingerprint, restorePolishKeptWithOutput } from "./stage-ledger.mjs";
59
+ import { applyDerivedAssemblyReportSummary, archiveStageRecord, archiveForBaseline, archiveSupersededRecord, assemblyReportMatchesPacket, campaignHandedOff, qaEverCompleted, commitAssemblyReport, inputChangeFor, polishReviewedBuild, qaExtendedBuildFingerprint, qaRecordedBuildFingerprint, restorePolishKeptWithOutput } from "./stage-ledger.mjs";
58
60
  import { withTargetLockSync } from "./target-lock.mjs";
59
61
  import { commerceScopeFromScope } from "./theme-gate.mjs";
60
62
 
@@ -71,7 +73,9 @@ const BUILD_RECORD_FLAGS = Object.freeze(["build-environment", "adapter-decision
71
73
  // discovery finds it).
72
74
  const BRIEF_RECORD_FLAGS = Object.freeze(["brief"]);
73
75
  // `record spec` binds the CampaignSpec as it is now; it takes no extra flag.
74
- const RECORD_SUBCOMMANDS = Object.freeze([...RECORD_STAGES, "brief", "spec"]);
76
+ // `record baseline` accepts the current build as the starting point: who, and why.
77
+ const BASELINE_RECORD_FLAGS = Object.freeze(["accepted-by", "reason"]);
78
+ const RECORD_SUBCOMMANDS = Object.freeze([...RECORD_STAGES, "brief", "spec", "baseline"]);
75
79
  // The page-kit environment the built output was rendered in, recorded on
76
80
  // stages.assembly.evidence.build_environment (local proof mode builds in
77
81
  // development; doctor and page-kit parity read it).
@@ -174,7 +178,7 @@ export function parseRecordArgs(args) {
174
178
  if (!RECORD_SUBCOMMANDS.includes(stage) || args._.length !== 2) {
175
179
  throw refused(`Use: ${cmd("record")} <${RECORD_SUBCOMMANDS.join("|")}> --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json]; record polish also takes --evidence <polish-evidence.json>, record deploy --base-url <served url>, record build [--build-environment <${BUILD_ENVIRONMENTS.join("|")}>] [--adapter-decision <key=value[,key=value...]>], and record brief [--brief <yaml|json>].`);
176
180
  }
177
- const known = new Set([...RECORD_FLAGS, ...(stage === "polish" ? POLISH_RECORD_FLAGS : []), ...(stage === "deploy" ? DEPLOY_RECORD_FLAGS : []), ...(stage === "build" ? BUILD_RECORD_FLAGS : []), ...(stage === "brief" ? BRIEF_RECORD_FLAGS : [])]);
181
+ const known = new Set([...RECORD_FLAGS, ...(stage === "polish" ? POLISH_RECORD_FLAGS : []), ...(stage === "deploy" ? DEPLOY_RECORD_FLAGS : []), ...(stage === "build" ? BUILD_RECORD_FLAGS : []), ...(stage === "brief" ? BRIEF_RECORD_FLAGS : []), ...(stage === "baseline" ? BASELINE_RECORD_FLAGS : [])]);
178
182
  const unknown = Object.keys(args).filter((key) => key !== "_" && !known.has(key));
179
183
  if (unknown.length) {
180
184
  throw refused(`Unknown flag${unknown.length > 1 ? "s" : ""} for record ${stage}: ${unknown.map((key) => `--${key}`).join(", ")}. Known flags: ${[...known].map((key) => `--${key}`).join(", ")}.`);
@@ -186,6 +190,10 @@ export function parseRecordArgs(args) {
186
190
  throw refused(`--dry-run takes no value (got ${JSON.stringify(args["dry-run"])}); write \`--dry-run\` on its own, after the other flags.`);
187
191
  }
188
192
  if (Object.hasOwn(args, "json") && args.json !== true) throw refused("--json is a boolean flag and takes no value.");
193
+ if (stage === "baseline") {
194
+ if (!isNamedHuman(args["accepted-by"])) throw refused("record baseline: --accepted-by needs the named person accepting this build; placeholders and automation identities are not accepted.");
195
+ if (!optionalString(args.reason)) throw refused("record baseline: --reason needs the reason this build is being accepted untested.");
196
+ }
189
197
  const buildEnvironment = Object.hasOwn(args, "build-environment") ? args["build-environment"] : null;
190
198
  if (buildEnvironment !== null && !BUILD_ENVIRONMENTS.includes(buildEnvironment)) {
191
199
  throw refused(`--build-environment must be one of: ${BUILD_ENVIRONMENTS.join(", ")} (got ${JSON.stringify(buildEnvironment)}).`);
@@ -201,6 +209,7 @@ export function parseRecordArgs(args) {
201
209
  adapterDecisions,
202
210
  briefPath: stage === "brief" && Object.hasOwn(args, "brief") ? resolve(args.brief) : null,
203
211
  deviationReason: optionalString(args["deviation-reason"]),
212
+ baseline: stage === "baseline" ? { acceptedBy: args["accepted-by"].trim().replace(/\s+/g, " "), reason: args.reason.trim() } : null,
204
213
  };
205
214
  }
206
215
 
@@ -322,7 +331,7 @@ function composeSetup(report, context, { now, recordedBy }) {
322
331
  // operator decision that applies only to the record that carried it.
323
332
  const STAMP_FIELDS = Object.freeze(["source_brief_material", "source_spec_material_hash"]);
324
333
  // A replaced record in any of these statuses is checked for an input change.
325
- const REPLACED_COMPLETED_STATUSES = Object.freeze(["completed", "completed_with_warnings", "completed_partial"]);
334
+ const REPLACED_COMPLETED_STATUSES = Object.freeze(["completed", "completed_with_warnings", "completed_partial", "baseline_accepted"]);
326
335
 
327
336
  // The stamps a completed record writes: the inputs current at this write
328
337
  // (the absent brief value for a packet without a brief). A value that cannot
@@ -473,6 +482,73 @@ function deployAfterBuild(previousDeploy, fingerprint, now) {
473
482
  return archiveSupersededRecord(demoted, previousDeploy, { by: "record build", reason: "build_output_changed", at: now });
474
483
  }
475
484
 
485
+ // `record baseline`: a named person accepts a funnel from before markup hashes
486
+ // as the starting point for page-only changes, without testing it. Polish and
487
+ // QA take the status baseline_accepted, which ends the ladder but is never
488
+ // completed; what they held moves to history, so its purchase proof stops
489
+ // counting. One time per funnel: report.commerce_baseline stays after a later
490
+ // full QA, so this can never be a way past QA that a commerce change owes.
491
+ function composeBaseline(report, { now, recordedBy, fingerprint, input, inputs, packet, packetPath }) {
492
+ const earlier = report.commerce_baseline;
493
+ if (isObject(earlier)) {
494
+ throw refuseRecord("baseline", [`${earlier.accepted_by} accepted a baseline for this funnel at ${earlier.accepted_at}, and a funnel gets one. Run ${cmd("qa")} run for what is owed now.`]);
495
+ }
496
+ if (!campaignHandedOff(report)) {
497
+ throw refuseRecord("baseline", ["This funnel has no completed deploy or QA, now or in its history. A baseline is for a funnel already handed off; run the full stages."]);
498
+ }
499
+ if (!rebuiltFromBuildWithoutSignature(report.stages?.assembly)) {
500
+ throw refuseRecord("baseline", [`This funnel's records have markup hashes (Campaigns OS 1.62.0 or later), or ${cmd("record")} build has not run on this toolkit. Page-only changes can be read from a full QA; a baseline is only for records from before those hashes. If you have not, run ${cmd("record")} build first.`]);
501
+ }
502
+ // A baseline stands in for a QA that passed on an older toolkit, so it needs
503
+ // one. A blocked QA found a problem a sign-off cannot vouch for.
504
+ const lastQa = stageObject(report, "qa");
505
+ if (lastQa.status === "blocked") {
506
+ const blockers = (Array.isArray(lastQa.blockers) ? lastQa.blockers : []).slice(0, 3).map((blocker) => singleLineFragment(typeof blocker === "string" ? blocker : blocker?.message || blocker?.code || "")).filter(Boolean);
507
+ throw refuseRecord("baseline", [`The last QA was blocked${blockers.length ? ` (${blockers.join("; ")})` : ""}, so it cannot be accepted untested. Run a full ${cmd("qa")} run; once it passes, page-only changes work without a baseline.`]);
508
+ }
509
+ if (!qaEverCompleted(report)) {
510
+ throw refuseRecord("baseline", [`No QA has completed on this funnel, so there is no result to start from. Run a full ${cmd("qa")} run; once it passes, page-only changes work without a baseline.`]);
511
+ }
512
+ const debt = assessEvidenceDebt({ packet, packetPath, report }).qa;
513
+ if (debt.status !== "owed" || debt.scope !== "full") {
514
+ throw refuseRecord("baseline", [`A full QA is not owed (QA reads ${debt.status}${debt.scope ? `, scope ${debt.scope}` : ""}), so there is nothing to accept.`]);
515
+ }
516
+ const stamps = currentStamps(inputs);
517
+ // History keeps no warnings or skip reasons, and a baseline must still say
518
+ // what it replaced.
519
+ const replacedRecord = (key) => {
520
+ const before = stageObject(report, key);
521
+ return { status: before.status ?? null, blockers: before.blockers ?? [], warnings: before.warnings ?? [], ...(before.skip_reason ? { skip_reason: before.skip_reason } : {}) };
522
+ };
523
+ const replace = (key, fields) => {
524
+ const previous = stageObject(report, key);
525
+ return archiveForBaseline({
526
+ stage: key,
527
+ status: "baseline_accepted",
528
+ recorded_by: recordedBy,
529
+ checked_at: now,
530
+ baseline_accepted_at: now,
531
+ ...stamps,
532
+ blockers: [],
533
+ warnings: [],
534
+ outputs: [],
535
+ ...fields,
536
+ }, previous, { at: now });
537
+ };
538
+ return {
539
+ report: {
540
+ ...report,
541
+ commerce_baseline: { accepted_by: input.acceptedBy, accepted_at: now, reason: input.reason, build_fingerprint: fingerprint, replaced_polish: replacedRecord("polish"), replaced_qa: replacedRecord("qa") },
542
+ stages: {
543
+ ...report.stages,
544
+ polish: replace("polish", { source_build_fingerprint: fingerprint }),
545
+ qa: replace("qa", { evidence: { source_build_fingerprint: fingerprint } }),
546
+ },
547
+ },
548
+ context: null,
549
+ };
550
+ }
551
+
476
552
  function composePolish(report, { now, recordedBy, fingerprint, input, inputs = {} }) {
477
553
  const previous = stageObject(report, "polish");
478
554
  const completed = POLISH_COMPLETED_STATUSES.includes(input.status);
@@ -1009,7 +1085,7 @@ function doctorFacts(stage, doctor, report, packet) {
1009
1085
  const binding = bindingProblems(doctor, report, packet);
1010
1086
  if (binding.length) throw refuseRecord(stage, binding);
1011
1087
  // The brand layer is applied to built output, so theme is checked as polish is.
1012
- const ladder = ladderProblems(stage === "theme" ? "polish" : stage, doctor, report);
1088
+ const ladder = ladderProblems(stage === "theme" || stage === "baseline" ? "polish" : stage, doctor, report);
1013
1089
  if (ladder.length) throw refuseRecord(stage, ladder);
1014
1090
  if (stage === "setup") {
1015
1091
  const outputDir = optionalString(derived.target_output_dir);
@@ -1026,13 +1102,13 @@ function doctorFacts(stage, doctor, report, packet) {
1026
1102
  if (stage === "build" && derived.scaffold_required === true) {
1027
1103
  throw refuseRecord(stage, [`Setup is still required (${derived.scaffold_reason || "Build Context scaffold.required is true"}); run ${cmd("record")} setup first.`]);
1028
1104
  }
1029
- if (stage === "polish" || stage === "theme" || stage === "deploy") {
1105
+ if (stage === "polish" || stage === "theme" || stage === "deploy" || stage === "baseline") {
1030
1106
  const recorded = optionalString(report?.stages?.assembly?.build_fingerprint);
1031
1107
  if (!String(report?.stages?.assembly?.status || "").startsWith("completed") || !recorded) {
1032
1108
  throw refuseRecord(stage, [`Build is not recorded (stages.assembly needs a completed status and build_fingerprint); run ${cmd("record")} build first.`]);
1033
1109
  }
1034
1110
  if (recorded !== fingerprint) {
1035
- throw refuseRecord(stage, [stage === "theme" || stage === "deploy"
1111
+ throw refuseRecord(stage, [stage === "theme" || stage === "deploy" || stage === "baseline"
1036
1112
  ? `The built output changed since build was recorded (recorded ${recorded}, current ${fingerprint}); run ${cmd("record")} build, then record ${stage} again.`
1037
1113
  : `The built output changed since build was recorded (recorded ${recorded}, current ${fingerprint}); run ${cmd("record")} build, then ${cmd("polish")} capture, then record polish again.`]);
1038
1114
  }
@@ -1143,12 +1219,12 @@ function readPacketFile(stage, packetPath) {
1143
1219
  * composed or written.
1144
1220
  */
1145
1221
  export function recordStageCommand(args, { now = () => new Date(), beforeLock = null, afterDoctorRead = null, probe = null } = {}) {
1146
- const { stage, packetPath, evidencePath, dryRun, buildEnvironment, adapterDecisions, deviationReason } = parseRecordArgs(args);
1222
+ const { stage, packetPath, evidencePath, dryRun, buildEnvironment, adapterDecisions, deviationReason, baseline } = parseRecordArgs(args);
1147
1223
  if (!existsSync(packetPath)) throw new Error(`record ${stage}: Build Packet not found at ${packetPath}; run ${cmd("start")} or ${cmd("prepare-build")} first.`);
1148
1224
  if (stage === "deploy" && !probe) throw new Error("record deploy needs the served-route probe; run it through recordCommand.");
1149
1225
  // Operator input, not target state: no campaigns-os writer produces it.
1150
1226
  // For deploy, the probe of the running server (probeLocalPreview).
1151
- const input = stage === "polish" ? readPolishEvidenceFile(evidencePath) : stage === "deploy" ? probe : null;
1227
+ const input = stage === "polish" ? readPolishEvidenceFile(evidencePath) : stage === "deploy" ? probe : baseline;
1152
1228
  const sidecars = {
1153
1229
  contextPath: args.context ? resolve(args.context) : undefined,
1154
1230
  reportPath: args.report ? resolve(args.report) : undefined,
@@ -1169,7 +1245,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
1169
1245
  const recorded = dryRun ? run() : withTargetLockSync(lockedTarget, run, { command: `record ${stage}` });
1170
1246
  const { composed, facts, layer, reportPath, contextPath, after } = recorded;
1171
1247
 
1172
- const stageKey = stage === "build" ? "assembly" : stage;
1248
+ const stageKey = stage === "build" ? "assembly" : stage === "baseline" ? "qa" : stage;
1173
1249
  const writes = [...(composed.context ? [contextPath] : []), ...(composed.packet ? [packetPath] : []), reportPath];
1174
1250
  const ready = stage === "deploy" ? [
1175
1251
  `stages.deploy.status = completed; deploy.preview_url = ${input.url}`,
@@ -1186,6 +1262,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
1186
1262
  ...(stage === "build" && adapterDecisions ? Object.entries(adapterDecisions).map(([key, value]) => `report.adapter_decisions.${key} = ${value}`) : []),
1187
1263
  ...(stage === "build" ? buildChangeReadyLines(composed.report.stages.assembly) : []),
1188
1264
  ...(stage === "build" ? [`stages.polish.status = ${composed.report.stages.polish.status}`] : []),
1265
+ ...(stage === "baseline" ? [`Polish and QA read baseline_accepted: ${baseline.acceptedBy} accepted build ${facts.fingerprint}. Nothing was tested, and no purchase is proven from here until a full ${cmd("qa")} run.`] : []),
1189
1266
  ...(stage === "build" && composed.report.stages.deploy?.required_by === "build" ? [`stages.deploy.status = ${composed.report.stages.deploy.status} (the recorded deploy probed other output; run ${cmd("record")} deploy again)`] : []),
1190
1267
  ...(composed.context ? ["Build Context scaffold.required = false"] : []),
1191
1268
  ];
@@ -1200,7 +1277,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
1200
1277
  ...(dryRun ? { would_write: writes } : { written: writes }),
1201
1278
  build_fingerprint: facts.fingerprint || null,
1202
1279
  // The manifest is kept in the report, not echoed: it lists every built file.
1203
- record: stage === "theme" ? composed.report.theme : stage === "build" ? withoutKeys(composed.report.stages.assembly, ["build_manifest", "commerce_signature"]) : composed.report.stages[stageKey],
1280
+ record: stage === "baseline" ? composed.report.commerce_baseline : stage === "theme" ? composed.report.theme : stage === "build" ? withoutKeys(composed.report.stages.assembly, ["build_manifest", "commerce_signature"]) : composed.report.stages[stageKey],
1204
1281
  ...(stage === "build" ? { polish: composed.report.stages.polish } : {}),
1205
1282
  ...(composed.context ? { scaffold: composed.context.scaffold } : {}),
1206
1283
  ...(stage === "polish" && input.hasRepairLoopDefect ? { repair_loop_defect: input.repairLoopDefect } : {}),
@@ -1273,7 +1350,9 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
1273
1350
  ? composeTheme(report, { now: timestamp, recordedBy, layer })
1274
1351
  : stage === "deploy"
1275
1352
  ? composeDeploy(report, packet, { now: timestamp, recordedBy, probe: input, fingerprint: facts.fingerprint })
1276
- : composePolish(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, input, inputs: recordInputs(doctor) });
1353
+ : stage === "baseline"
1354
+ ? composeBaseline(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, input, inputs: recordInputs(doctor), packet, packetPath })
1355
+ : composePolish(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, input, inputs: recordInputs(doctor) });
1277
1356
  applyDerivedAssemblyReportSummary(next.report, recordInputs(doctor));
1278
1357
  // The packet's one new value, deploy.preview_url, is checked by
1279
1358
  // deployPreviewUrl; the rest of the packet is as the operator left it.
@@ -1290,7 +1369,7 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
1290
1369
  // Already inside the target lock, which commitAssemblyReport re-enters.
1291
1370
  commitAssemblyReport(workspace, compose, {
1292
1371
  command: `record ${stage}`,
1293
- staleReason: `${stage === "theme" ? "theme" : `stages.${stage === "build" ? "assembly" : stage}`} was recorded after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
1372
+ staleReason: `${stage === "theme" ? "theme" : stage === "baseline" ? "a baseline" : `stages.${stage === "build" ? "assembly" : stage}`} was recorded after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
1294
1373
  ...(dryRun ? { lock: false } : {}),
1295
1374
  });
1296
1375
  const after = dryRun ? null : doctorPacket(packetPath, sidecars);