@nextcommerce/campaigns-os 1.63.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 (50) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/campaign-spec/dist/types.d.ts +1 -1
  3. package/compatibility.json +1 -1
  4. package/contracts/effects.v1.json +129 -1
  5. package/contracts/release-ledger.json +549 -0
  6. package/contracts/supported-surface.json +3 -3
  7. package/docs/build-packet.md +31 -5
  8. package/docs/campaigns-os-build-flow.md +38 -0
  9. package/docs/campaignspec-authoring-examples.md +3 -2
  10. package/docs/effects.md +8 -0
  11. package/docs/local-setup.md +1 -1
  12. package/docs/orientation-contract-reference.md +1 -1
  13. package/docs/qa-and-test-orders.md +44 -10
  14. package/docs/readback.md +8 -1
  15. package/docs/runtime-readiness.md +1 -1
  16. package/docs/skills-revision.md +10 -10
  17. package/docs/supported-surface.md +1 -1
  18. package/package.json +1 -1
  19. package/schemas/campaigns-os-qa-verdict.v0.schema.json +17 -2
  20. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  21. package/skills/campaign-readback-classification/SKILL.md +3 -3
  22. package/skills/campaign-run-evidence/SKILL.md +3 -3
  23. package/skills/contribution-intake/SKILL.md +3 -3
  24. package/skills/next-campaigns-build/SKILL.md +3 -3
  25. package/skills/next-campaigns-os/SKILL.md +3 -3
  26. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  27. package/skills/next-campaigns-polish/SKILL.md +3 -3
  28. package/skills/next-campaigns-qa/SKILL.md +4 -4
  29. package/skills/next-campaigns-sdk-bump/SKILL.md +3 -3
  30. package/skills.json +11 -11
  31. package/src/change-scope.mjs +25 -6
  32. package/src/cli.mjs +120 -7
  33. package/src/commercial-journey.mjs +3 -0
  34. package/src/commercial-parity.mjs +157 -7
  35. package/src/doctor/checks.mjs +28 -3
  36. package/src/doctor/ci.mjs +36 -1
  37. package/src/evidence-debt.mjs +9 -1
  38. package/src/input-currency.mjs +4 -2
  39. package/src/input-refresh.mjs +1 -1
  40. package/src/invocation.mjs +3 -2
  41. package/src/orchestration-stage-contract.mjs +3 -1
  42. package/src/polish-gate.mjs +10 -0
  43. package/src/qa-browser.mjs +46 -0
  44. package/src/qa-commercial-parity.mjs +173 -18
  45. package/src/qa-node.mjs +10 -5
  46. package/src/qa-verdict.mjs +3 -1
  47. package/src/readback.mjs +1 -0
  48. package/src/runtime-state-ignore.mjs +44 -19
  49. package/src/stage-ledger.mjs +35 -9
  50. package/src/stage-record.mjs +93 -14
@@ -6,7 +6,10 @@
6
6
  // second and later funnels) — current readback truth; docs/migration-sidecar-bundle.md
7
7
  // names it and `bundle check` validates it. Committed by design.
8
8
  // 2. Handoff inputs and agent context (input/, theme/, agent-context/,
9
- // setup-handoff.json) — durable build handoff. Committed by design.
9
+ // setup-handoff.json) and the CampaignSpec a Map intake fetched
10
+ // (fetched-specs/) — durable build handoff. The committed records are
11
+ // bound to that spec, and spec derive edits it, so another machine cannot
12
+ // get it back from the Map. Committed by design.
10
13
  // 3. Telemetry, sessions, caches, and evidence — per-machine, append-only,
11
14
  // or carrying live URLs and absolute paths. Never meant for the repo, and
12
15
  // nothing wrote a rule for them: three campaign repositories were found
@@ -32,7 +35,6 @@ export const RUNTIME_STATE_IGNORED_PATHS = [
32
35
  // A project's second and later funnels keep their Run Records under their
33
36
  // own runtime directory (src/campaign-workspace.mjs).
34
37
  ".campaign-runtime/funnels/*/run-records/",
35
- ".campaign-runtime/fetched-specs/",
36
38
  ".campaign-runtime/progress/",
37
39
  ".campaign-runtime/polish-evidence/",
38
40
  ".campaign-runtime/evidence/",
@@ -53,12 +55,17 @@ export const RUNTIME_STATE_IGNORED_PATHS = [
53
55
  // present.
54
56
  export const RUNTIME_STATE_REQUIRED_IGNORED_PATHS = ["qa-output/", ".campaign-runtime/progress/", ".campaign-runtime/funnels/*/run-records/"];
55
57
 
58
+ // Entries an earlier toolkit wrote into the managed block that are now meant
59
+ // to be committed. They leave the managed block on the next setup command or
60
+ // `spec fetch`; the same line elsewhere in the file is the operator's own.
61
+ export const RUNTIME_STATE_RETIRED_IGNORED_PATHS = [".campaign-runtime/fetched-specs/"];
62
+
56
63
  export const RUNTIME_STATE_IGNORE_BLOCK = [
57
64
  RUNTIME_STATE_IGNORE_MARKER,
58
- "# Sessions, journals, Run Records, spec caches, capture evidence, and full",
59
- "# QA verdicts are per-machine. The readback bundle (build-context, assembly-report,",
60
- "# doctor-output, qa-verdict), input/, theme/, and agent-context/ are meant",
61
- "# to be committed and are deliberately not listed here.",
65
+ "# Sessions, journals, Run Records, capture evidence, and full QA verdicts are",
66
+ "# per-machine. The readback bundle (build-context, assembly-report, doctor-output,",
67
+ "# qa-verdict), fetched-specs/, input/, theme/, and agent-context/ are meant to be",
68
+ "# committed and are deliberately not listed here.",
62
69
  ...RUNTIME_STATE_IGNORED_PATHS,
63
70
  ].join("\n");
64
71
 
@@ -66,14 +73,29 @@ function ignoreRuleLines(text) {
66
73
  return new Set(text.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#")));
67
74
  }
68
75
 
69
- // Append the missing required entries at the end of the managed block: after
70
- // the marker, up to the first blank line or the end of the file.
71
- function withRequiredEntries(existing, missing) {
72
- const lines = existing.split("\n");
73
- const markerIndex = lines.findIndex((line) => line.trim() === RUNTIME_STATE_IGNORE_MARKER);
76
+ // The managed block spans the marker up to the first blank line or the end of
77
+ // the file.
78
+ function managedBlockEnd(lines, markerIndex) {
74
79
  let end = markerIndex + 1;
75
80
  while (end < lines.length && lines[end].trim() !== "") end += 1;
76
- lines.splice(end, 0, ...missing);
81
+ return end;
82
+ }
83
+
84
+ function retiredEntriesInBlock(existing) {
85
+ const lines = existing.split("\n");
86
+ const markerIndex = lines.findIndex((line) => line.trim() === RUNTIME_STATE_IGNORE_MARKER);
87
+ return lines.slice(markerIndex + 1, managedBlockEnd(lines, markerIndex)).map((line) => line.trim())
88
+ .filter((line) => RUNTIME_STATE_RETIRED_IGNORED_PATHS.includes(line));
89
+ }
90
+
91
+ // Append the missing required entries at the end of the managed block and drop
92
+ // the retired ones from it.
93
+ function withBlockEntries(existing, missing) {
94
+ const lines = existing.split("\n");
95
+ const markerIndex = lines.findIndex((line) => line.trim() === RUNTIME_STATE_IGNORE_MARKER);
96
+ const end = managedBlockEnd(lines, markerIndex);
97
+ const block = lines.slice(markerIndex + 1, end).filter((line) => !RUNTIME_STATE_RETIRED_IGNORED_PATHS.includes(line.trim()));
98
+ lines.splice(markerIndex + 1, end - markerIndex - 1, ...block, ...missing);
77
99
  return lines.join("\n");
78
100
  }
79
101
 
@@ -81,10 +103,11 @@ function withRequiredEntries(existing, missing) {
81
103
  * Ensure the target's .gitignore carries the runtime-state block. Idempotent
82
104
  * on the marker line: an existing block, however edited beneath it, is left
83
105
  * alone — except for RUNTIME_STATE_REQUIRED_IGNORED_PATHS, which are appended
84
- * to an existing block when the file carries them nowhere. Never throws for
85
- * an unwritable target — the build must not fail on housekeeping — but
106
+ * to an existing block when the file carries them nowhere, and
107
+ * RUNTIME_STATE_RETIRED_IGNORED_PATHS, which are removed from it. Never throws
108
+ * for an unwritable target — the build must not fail on housekeeping — but
86
109
  * reports what happened so callers can surface it.
87
- * Returns `{ path, action: "added" | "updated" | "present" | "skipped", reason?, added_entries? }`.
110
+ * Returns `{ path, action: "added" | "updated" | "present" | "skipped", reason?, added_entries?, removed_entries? }`.
88
111
  */
89
112
  export function ensureRuntimeStateIgnored(targetRepo, { dryRun = false } = {}) {
90
113
  const path = join(resolve(targetRepo), ".gitignore");
@@ -98,14 +121,16 @@ export function ensureRuntimeStateIgnored(targetRepo, { dryRun = false } = {}) {
98
121
  if (existing.includes(RUNTIME_STATE_IGNORE_MARKER)) {
99
122
  const present = ignoreRuleLines(existing);
100
123
  const missing = RUNTIME_STATE_REQUIRED_IGNORED_PATHS.filter((entry) => !present.has(entry));
101
- if (missing.length === 0) return { path, action: "present" };
102
- if (dryRun) return { path, action: "updated", dry_run: true, added_entries: missing };
124
+ const retired = retiredEntriesInBlock(existing);
125
+ if (missing.length === 0 && retired.length === 0) return { path, action: "present" };
126
+ const changes = { added_entries: missing, ...(retired.length ? { removed_entries: retired } : {}) };
127
+ if (dryRun) return { path, action: "updated", dry_run: true, ...changes };
103
128
  try {
104
- writeFileSync(path, withRequiredEntries(existing, missing));
129
+ writeFileSync(path, withBlockEntries(existing, missing));
105
130
  } catch (error) {
106
131
  return { path, action: "skipped", reason: `unwritable: ${error.message}` };
107
132
  }
108
- return { path, action: "updated", added_entries: missing };
133
+ return { path, action: "updated", ...changes };
109
134
  }
110
135
  }
111
136
  if (dryRun) return { path, action: "added", dry_run: true };
@@ -106,20 +106,23 @@ const HISTORY_ENTRY_FIELDS = Object.freeze([
106
106
  "build_fingerprint", "source_build_fingerprint", "source_package_material_fingerprint",
107
107
  "source_brief_material", "source_spec_material_hash",
108
108
  "verdict_run_id", "checked_at", "purchase_proof",
109
- "outputs", "evidence",
109
+ "outputs", "evidence", "baseline_accepted_at",
110
110
  ]);
111
111
  // The entry fields that say what a record was, rather than when or by whom it
112
112
  // was written: a replacement equal on all of them is a re-record, not a new
113
113
  // chapter, and archives nothing.
114
114
  const HISTORY_IDENTITY_FIELDS = Object.freeze([
115
115
  "build_fingerprint", "source_build_fingerprint", "source_package_material_fingerprint",
116
- "source_brief_material", "source_spec_material_hash", "verdict_run_id", "purchase_proof", "outputs", "evidence",
116
+ "source_brief_material", "source_spec_material_hash", "verdict_run_id", "purchase_proof", "outputs", "evidence", "baseline_accepted_at",
117
117
  ]);
118
- export const HISTORY_ARCHIVED_BY = Object.freeze(["record brief", "record spec", "record build", "record polish", "qa run", "prepare-build --force"]);
119
- export const HISTORY_REASON_CODES = Object.freeze(["brief_presentation_changed", "brief_qa_policy_changed", "spec_material_changed", "rerecorded", "force_reset", "build_output_changed"]);
118
+ export const HISTORY_ARCHIVED_BY = Object.freeze(["record brief", "record spec", "record build", "record polish", "record baseline", "qa run", "prepare-build --force"]);
119
+ export const HISTORY_REASON_CODES = Object.freeze(["brief_presentation_changed", "brief_qa_policy_changed", "spec_material_changed", "rerecorded", "force_reset", "build_output_changed", "baseline_accepted"]);
120
120
  const ARCHIVED_STAGE_KEYS = Object.freeze(["assembly", "polish", "qa"]);
121
121
 
122
122
  const isCompletedStatus = (status) => typeof status === "string" && status.startsWith("completed");
123
+ // A record a later one replaces into history. A baseline counts here, and not
124
+ // as completed: it never hands a campaign off or proves anything.
125
+ const isReplacedRecord = (status) => isCompletedStatus(status) || status === "baseline_accepted";
123
126
 
124
127
  // What a record says about itself, compared for the dedup. Evidence reads
125
128
  // through meaningfulEvidence (an empty block is absent) and a verdict id is
@@ -168,7 +171,7 @@ function historyEntry(previous, { by, reason, at }) {
168
171
  // verdict identity or evidence, which the QA producer has always archived.
169
172
  function archivable(stageKey, previous) {
170
173
  if (!isPlainObject(previous)) return false;
171
- if (isCompletedStatus(previous.status)) return true;
174
+ if (isReplacedRecord(previous.status)) return true;
172
175
  return stageKey === "qa" && Boolean(recordIdentity(previous).verdict_run_id || meaningfulEvidence(previous.evidence));
173
176
  }
174
177
 
@@ -200,6 +203,19 @@ export function archiveSupersededRecord(stage, previous, { by, reason, at }) {
200
203
  return appendHistoryEntry(isPlainObject(stage) ? { ...stage } : {}, historyEntry(previous, { by, reason, at }));
201
204
  }
202
205
 
206
+ // The baseline QA rests on, or null. The marker on the record is the rule, not
207
+ // its status: a blocked page-scoped run or an input change leaves QA resting on
208
+ // the baseline, and only a completed full QA clears the marker.
209
+ export function qaRestsOnBaseline(report) {
210
+ return report?.stages?.qa?.baseline_accepted_at && isPlainObject(report?.commerce_baseline) ? report.commerce_baseline : null;
211
+ }
212
+
213
+ // A stage's baseline record, with what the stage held moved to its history.
214
+ export function archiveForBaseline(record, previous, { at }) {
215
+ const carried = Array.isArray(previous?.history) ? { ...record, history: previous.history } : record;
216
+ return archiveSupersededRecord(carried, previous, { by: "record baseline", reason: "baseline_accepted", at });
217
+ }
218
+
203
219
  /**
204
220
  * #668: the Polish record a CampaignSpec change moved to `polish.history`,
205
221
  * restored as the live record stamped with `stamps` (the current inputs), or
@@ -241,6 +257,15 @@ export function campaignHandedOff(report) {
241
257
  return completedIn(report?.stages?.deploy) || completedIn(report?.stages?.qa);
242
258
  }
243
259
 
260
+ // True when QA has ever completed, now or in the stage's history. Not
261
+ // campaignHandedOff: a baseline stands in for a QA result, and a completed
262
+ // deploy is not one.
263
+ export function qaEverCompleted(report) {
264
+ const qa = report?.stages?.qa;
265
+ return isPlainObject(qa) && (isCompletedStatus(qa.status)
266
+ || (Array.isArray(qa.history) && qa.history.some((entry) => isPlainObject(entry) && isCompletedStatus(entry.status))));
267
+ }
268
+
244
269
  /**
245
270
  * True when any stage of `report` carries a non-empty history: recorded
246
271
  * operator-visible state an intake must not silently drop.
@@ -301,7 +326,7 @@ export function archiveForForceReset(previousReport, nextReport, { now, detectCh
301
326
  continue;
302
327
  }
303
328
  if (Object.hasOwn(previous, "input_change")) reseeded.input_change = JSON.parse(JSON.stringify(previous.input_change));
304
- if (isCompletedStatus(previous.status)) {
329
+ if (isReplacedRecord(previous.status)) {
305
330
  reseeded = archiveSupersededRecord(reseeded, previous, { by: "prepare-build --force", reason: "force_reset", at: now });
306
331
  const reason = typeof detectChange === "function" ? detectChange(key, previous) : null;
307
332
  if (reason) reseeded.input_change = inputChangeFor(key, previous, { at: now, reason });
@@ -405,7 +430,7 @@ export function recordProducerStageOutcome(report, {
405
430
  // A replaced completed record whose stamps differ from the current inputs
406
431
  // records the change; a verdict stamped with the current inputs then
407
432
  // clears it, the one it just recorded included; any other write carries it.
408
- const detected = inputs && isCompletedStatus(previous.status) ? inputs.detectChange(previous) : null;
433
+ const detected = inputs && isReplacedRecord(previous.status) ? inputs.detectChange(previous) : null;
409
434
  if (detected) next.input_change = inputChangeFor("qa", previous, { at: timestamp, reason: detected });
410
435
  if (inputs && inputs.stampsCurrent(next)) delete next.input_change;
411
436
  const archived = archiveStageRecord(next, previous, next, { by: "qa run", reason: detected || "rerecorded", at: timestamp, stageKey: "qa" });
@@ -591,9 +616,10 @@ export function qaRecordedBuildFingerprint(report) {
591
616
  // #720: the build whose completed QA a page-scoped run extends. A completed
592
617
  // QA record extends its own build. A page-scoped run that did not complete
593
618
  // extends the build of the full QA it was based on, which was completed when
594
- // that run was accepted. Any other record (blocked or pending full QA, a
619
+ // that run was accepted. A baseline extends its own build too, and has no
620
+ // purchase proof to pass on. Any other record (blocked or pending full QA, a
595
621
  // skip, a required stage) extends nothing, so it owes a full QA run.
596
- const QA_EXTENDABLE_STATUSES = Object.freeze(["completed", "completed_with_warnings"]);
622
+ const QA_EXTENDABLE_STATUSES = Object.freeze(["completed", "completed_with_warnings", "baseline_accepted"]);
597
623
  export function qaExtendedBuildFingerprint(report) {
598
624
  const qa = report?.stages?.qa;
599
625
  if (QA_EXTENDABLE_STATUSES.includes(qa?.status)) return qaRecordedBuildFingerprint(report);
@@ -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);