@nextcommerce/campaigns-os 1.52.0 → 1.54.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 (76) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/README.md +1 -0
  3. package/agents/claude/CLAUDE.md +2 -0
  4. package/agents/codex/AGENTS.md +2 -0
  5. package/agents/copilot/copilot-instructions.md +2 -0
  6. package/agents/cursor/campaigns-os.mdc +2 -0
  7. package/compatibility.json +1 -1
  8. package/contracts/campaign-cart-checkout-field-contract.v0.json +10 -0
  9. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  10. package/contracts/commerce-surface-catalog.json +27 -27
  11. package/contracts/effects.v1.json +295 -47
  12. package/contracts/release-ledger.json +660 -0
  13. package/contracts/supported-surface.json +3 -3
  14. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  15. package/docs/brand-theme-bridge.md +2 -0
  16. package/docs/build-packet.md +67 -19
  17. package/docs/campaign-build-brief.md +48 -17
  18. package/docs/campaign-standardization-report.md +32 -0
  19. package/docs/design-source-package.md +7 -3
  20. package/docs/effects.md +10 -1
  21. package/docs/local-setup.md +1 -1
  22. package/docs/orientation-contract-reference.md +1 -1
  23. package/docs/polish-evidence.md +2 -0
  24. package/docs/progress-snapshots.md +5 -5
  25. package/docs/qa-and-test-orders.md +30 -3
  26. package/docs/readback.md +19 -2
  27. package/docs/runtime-readiness.md +1 -1
  28. package/docs/sdk-storage-compatibility.md +15 -0
  29. package/docs/skills-revision.md +10 -10
  30. package/docs/supported-surface.md +1 -0
  31. package/package.json +1 -1
  32. package/schemas/campaign-runtime-assembly-report.v0.schema.json +5 -1
  33. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  34. package/skills/campaign-readback-classification/SKILL.md +3 -3
  35. package/skills/campaign-run-evidence/SKILL.md +3 -3
  36. package/skills/contribution-intake/SKILL.md +3 -3
  37. package/skills/next-campaigns-build/SKILL.md +5 -3
  38. package/skills/next-campaigns-os/SKILL.md +10 -19
  39. package/skills/next-campaigns-os/references/session-intake.md +10 -6
  40. package/skills/next-campaigns-os-setup/SKILL.md +4 -3
  41. package/skills/next-campaigns-polish/SKILL.md +7 -5
  42. package/skills/next-campaigns-qa/SKILL.md +5 -3
  43. package/skills.json +10 -10
  44. package/src/brand-theme.mjs +21 -20
  45. package/src/brief-summary.mjs +275 -0
  46. package/src/build-brief.mjs +167 -15
  47. package/src/campaign-ecosystem.mjs +153 -31
  48. package/src/cli.mjs +182 -115
  49. package/src/contrast.mjs +649 -0
  50. package/src/design-source-publication.mjs +110 -3
  51. package/src/directory-lock.mjs +79 -7
  52. package/src/doctor/checks.mjs +141 -16
  53. package/src/doctor/inspect.mjs +13 -0
  54. package/src/doctor/next-step.mjs +15 -6
  55. package/src/input-currency.mjs +394 -0
  56. package/src/input-refresh.mjs +708 -0
  57. package/src/invocation.mjs +6 -2
  58. package/src/polish-browser.mjs +503 -201
  59. package/src/polish-deadline.mjs +7 -0
  60. package/src/polish-node.mjs +326 -46
  61. package/src/polish-readability.mjs +828 -0
  62. package/src/progress-node.mjs +18 -5
  63. package/src/qa-browser.mjs +211 -93
  64. package/src/qa-node.mjs +47 -7
  65. package/src/qc-accept.mjs +44 -8
  66. package/src/qc-check-registry.mjs +15 -7
  67. package/src/qc-results.mjs +326 -34
  68. package/src/readback.mjs +58 -10
  69. package/src/sdk-attribute-index.mjs +111 -23
  70. package/src/sdk-markup.mjs +49 -10
  71. package/src/sdk-repin.mjs +282 -0
  72. package/src/stage-ledger.mjs +235 -44
  73. package/src/stage-record.mjs +154 -30
  74. package/src/standardization-report.mjs +26 -1
  75. package/src/target-lock.mjs +9 -1
  76. package/src/tooling-setup.mjs +1 -1
@@ -7,7 +7,11 @@ import { writeThemeArtifacts } from "./brand-theme.mjs";
7
7
  import { cloneJson, filesystemPathsMatch, isObject, isNonEmptyString, optionalString, readJson, resolveFromFile, sha256File } from "./cli-helpers.mjs";
8
8
  import { DESIGN_SOURCE_PACKAGE_REL_PATH, createDesignSourcePackageArtifactReference, hashSerializedDesignSourcePackage, serializeDesignSourcePackage, synthesizeHtmlFunnelDesignSourcePackage, validateDesignSourcePackage } from "./design-source-package.mjs";
9
9
  import { stampDoctorProducer } from "./doctor-sidecar.mjs";
10
+ import { stageWriteInputs } from "./input-currency.mjs";
11
+ import { cmd } from "./install-invocation.mjs";
10
12
  import { ASSEMBLY_REPORT_STAGE_KEYS } from "./orchestration-stage-contract.mjs";
13
+ import { HOST_STRIPPED_CODE } from "./source-html-intake.mjs";
14
+ import { archiveForForceReset, stageHistoryPresent } from "./stage-ledger.mjs";
11
15
  import { targetLockPath, withTargetLock } from "./target-lock.mjs";
12
16
  import { resolveTemplateFamilyDesignSource } from "./template-reference.mjs";
13
17
 
@@ -71,12 +75,93 @@ function guardAssemblyReportOverwrite(reportPath, args, { announced = null } = {
71
75
  const unannounced = announced ? stageKeys.filter((key) => !announced.includes(key)) : stageKeys;
72
76
  if (unannounced.length > 0) {
73
77
  console.warn(
74
- `[campaigns-os prepare-build] --force: overwriting assembly report at ${reportPath}; clearing stage evidence for: ${unannounced.join(", ")}.`,
78
+ `[campaigns-os prepare-build] --force: overwriting assembly report at ${reportPath}; clearing stage evidence for: ${unannounced.join(", ")}. Completed build, Polish and QA records are archived into their stage history, which is kept.`,
75
79
  );
76
80
  }
77
81
  return stageKeys;
78
82
  }
79
83
 
84
+ // Operator state on the report an intake would replace, one line per item:
85
+ // recorded waivers and warning accepts, stage history, an applied theme, and
86
+ // report evidence other than intake's own host-strip lines.
87
+ function assemblyReportOperatorState(existingReport) {
88
+ if (!isObject(existingReport)) return [];
89
+ const items = [];
90
+ const count = (value) => (Array.isArray(value) ? value.length : 0);
91
+ if (count(existingReport.waivers)) items.push(`waivers (${count(existingReport.waivers)} recorded waiver${count(existingReport.waivers) === 1 ? "" : "s"})`);
92
+ if (count(existingReport.qc_accepts)) items.push(`qc_accepts (${count(existingReport.qc_accepts)} recorded warning accept${count(existingReport.qc_accepts) === 1 ? "" : "s"})`);
93
+ if (stageHistoryPresent(existingReport)) items.push("stage history (superseded build, Polish or QA records)");
94
+ if (existingReport.theme?.status === "applied") items.push("theme (status applied)");
95
+ const evidence = Array.isArray(existingReport.evidence) ? existingReport.evidence.filter((entry) => !(isObject(entry) && entry.code === HOST_STRIPPED_CODE)) : [];
96
+ if (evidence.length) items.push(`evidence (${evidence.length} recorded line${evidence.length === 1 ? "" : "s"})`);
97
+ return items;
98
+ }
99
+
100
+ // Packet settings recorded since the last intake that this run would
101
+ // overwrite (every setting `qa policy set` records): each named with the
102
+ // value on disk and the value this run writes.
103
+ const INTAKE_PACKET_SETTINGS = Object.freeze([
104
+ ["deploy.target", (packet) => packet?.deploy?.target],
105
+ ["deploy.preview_url", (packet) => packet?.deploy?.preview_url],
106
+ ["deploy.production_url", (packet) => packet?.deploy?.production_url],
107
+ ["qa.proof_policy.order_path_depth", (packet) => packet?.qa?.proof_policy?.order_path_depth],
108
+ ["campaign.allowed_domains_confirmed", (packet) => packet?.campaign?.allowed_domains_confirmed],
109
+ ]);
110
+
111
+ function packetSettingChanges(packetPath, planned) {
112
+ if (!isObject(planned) || !existsSync(packetPath)) return [];
113
+ let existing;
114
+ try {
115
+ existing = readJson(packetPath);
116
+ } catch {
117
+ return [];
118
+ }
119
+ const items = [];
120
+ for (const [field, read] of INTAKE_PACKET_SETTINGS) {
121
+ const onDisk = read(existing) ?? null;
122
+ const written = read(planned) ?? null;
123
+ if (onDisk !== written) items.push(`${field} (recorded ${JSON.stringify(onDisk)}; this run would write ${JSON.stringify(written)})`);
124
+ }
125
+ return items;
126
+ }
127
+
128
+ // Intake replaces the report and packet whole, so recorded operator state
129
+ // would be lost silently. Without --force it refuses and names each item;
130
+ // with --force it names each item as cleared (once).
131
+ function guardIntakeOperatorState(reportPath, packetPath, { force, planned = null, announced = [] }) {
132
+ let existingReport = null;
133
+ try {
134
+ existingReport = existsSync(reportPath) ? readJson(reportPath) : null;
135
+ } catch {
136
+ existingReport = null;
137
+ }
138
+ const items = [...assemblyReportOperatorState(existingReport), ...packetSettingChanges(packetPath, planned)];
139
+ if (!items.length) return announced;
140
+ if (force !== true) {
141
+ throw new Error(
142
+ `Rerunning prepare-build/start/build would discard recorded operator state: ${items.join("; ")}. `
143
+ + `To save brief answers or a CampaignSpec change without discarding anything, run ${cmd("record")} brief or ${cmd("record")} spec instead. `
144
+ + "To rerun intake anyway, pass the original deploy and order-path flags, or --force to clear the listed state (destructive; completed build, Polish and QA records are archived into stage history). "
145
+ + `After --force, record a cleared packet setting again with ${cmd("qa")} policy set.`,
146
+ );
147
+ }
148
+ const unannounced = items.filter((item) => !announced.includes(item));
149
+ if (unannounced.length) {
150
+ console.warn(`[campaigns-os prepare-build] --force: clearing recorded operator state: ${unannounced.join("; ")}.`);
151
+ }
152
+ return [...announced, ...unannounced];
153
+ }
154
+
155
+ // The report this run replaces, read for the --force archive; null when there
156
+ // is none or it cannot be read.
157
+ function readReplacedReport(reportPath) {
158
+ try {
159
+ return existsSync(reportPath) ? readJson(reportPath) : null;
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+
80
165
  function artifactRelativePath(artifactPath, targetPath) {
81
166
  const rel = relative(dirname(resolve(artifactPath)), resolve(targetPath)).replaceAll("\\", "/");
82
167
  return rel || ".";
@@ -806,12 +891,20 @@ export async function withDesignSourcePublication({ targetRepo, outputs, force =
806
891
  // fetched, and a run waiting here cannot replace it meanwhile.
807
892
  publishSpec?.();
808
893
  let announcedStageEvidence = guardAssemblyReportOverwrite(reportPath, { force });
894
+ // Operator state is checked once the run knows the packet settings it
895
+ // would write, before the package.
896
+ let plannedPacketSettings = null;
897
+ let announcedOperatorState = null;
898
+ const checkOperatorState = () => {
899
+ announcedOperatorState = guardIntakeOperatorState(reportPath, packetPath, { force, planned: plannedPacketSettings, announced: announcedOperatorState || [] });
900
+ };
809
901
  // Stage producers commit the report without this lock, so evidence can land
810
902
  // while the run works. Re-check before each write that is not rolled back:
811
903
  // the theme artifacts, then the JSON outputs. --force names a stage once.
812
904
  const recheckStageEvidence = () => {
813
905
  const found = guardAssemblyReportOverwrite(reportPath, { force }, { announced: announcedStageEvidence });
814
906
  announcedStageEvidence = [...new Set([...announcedStageEvidence, ...found])];
907
+ checkOperatorState();
815
908
  };
816
909
  let completed = 0;
817
910
  const enter = (index, { once = false } = {}) => {
@@ -821,8 +914,13 @@ export async function withDesignSourcePublication({ targetRepo, outputs, force =
821
914
  let designSourcePackage = null;
822
915
  return fn(Object.freeze({
823
916
  paths,
824
- package(inputs) {
917
+ // `plannedPacket`: the part of the packet this run would write that
918
+ // carries the settings INTAKE_PACKET_SETTINGS names, checked against
919
+ // the packet on disk.
920
+ package({ plannedPacket = null, ...inputs }) {
825
921
  enter(0, { once: true });
922
+ plannedPacketSettings = plannedPacket;
923
+ checkOperatorState();
826
924
  // Reject deterministic destination failures before publishing the fixed
827
925
  // DSP. The DSP intentionally precedes the theme and JSON artifacts that
828
926
  // reference it; a later I/O race may therefore leave a valid DSP for a
@@ -858,8 +956,17 @@ export async function withDesignSourcePublication({ targetRepo, outputs, force =
858
956
  completed = Math.max(completed, 2);
859
957
  return written;
860
958
  },
861
- publish({ packet, brief, context, report }) {
959
+ publish({ packet, brief, context, report: reseeded }) {
862
960
  enter(2, { once: true });
961
+ // The records this run replaces stay visible: completed build, Polish
962
+ // and QA records go to their stage history (only --force reaches here
963
+ // with any), and each stage's history and input change carry over.
964
+ const now = new Date().toISOString();
965
+ const bound = { briefMaterial: reseeded?.build_brief?.material ?? null, specMaterial: reseeded?.identity?.spec_material_hash ?? null };
966
+ const report = archiveForForceReset(readReplacedReport(reportPath), reseeded, {
967
+ now,
968
+ detectChange: (key, record) => stageWriteInputs(key, bound).detectChange(record),
969
+ });
863
970
  // Before the report goes out, narrow the pending record to exactly what the
864
971
  // report will say: the package's own hash when it is synthesized, nothing
865
972
  // when it is adopted. A candidate this run or an earlier failed one never
@@ -18,7 +18,13 @@
18
18
  // to an older writer that may still be alive between its mkdir and its owner
19
19
  // write (#501). A waiter refuses it after a short grace, leaving it for the
20
20
  // documented offline procedure. (See publishStagedDirectory for the one
21
- // mixed-version race this cannot close, tracked in #514.)
21
+ // mixed-version race this cannot close, tracked in #514.) Anything else at the
22
+ // lock path, a file or a directory holding other entries, is not a lock at
23
+ // all: it is refused by name and never removed (#514).
24
+ //
25
+ // Staging and released siblings a dead process left beside the lock are swept
26
+ // by the next holder, under the lock; a sibling whose owner is alive or
27
+ // unreadable is left alone (#514).
22
28
  //
23
29
  // The lock is reentrant for its holder: code running inside `fn` (in the
24
30
  // same async context) that asks for the same lock enters directly instead of
@@ -26,7 +32,7 @@
26
32
  // stage writers that take it too.
27
33
  import { AsyncLocalStorage } from "node:async_hooks";
28
34
  import { randomBytes } from "node:crypto";
29
- import { lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
35
+ import { lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
30
36
  import { basename, dirname, join, resolve } from "node:path";
31
37
 
32
38
  const heldLocks = new AsyncLocalStorage();
@@ -39,6 +45,47 @@ const exists = (path) => {
39
45
  try { lstatSync(path); return true; } catch { return false; }
40
46
  };
41
47
 
48
+ // True only when the owner record names a process that no longer exists. An
49
+ // unreadable record, or a pid this process may not signal, is not dead.
50
+ const ownerIsDead = (owner) => {
51
+ if (!(Number.isInteger(owner?.pid) && owner.pid > 0 && typeof owner.token === "string")) return false;
52
+ try { process.kill(owner.pid, 0); return false; } catch (error) { return error.code === "ESRCH"; }
53
+ };
54
+
55
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
56
+
57
+ // The siblings this module creates beside a lock and normally removes itself:
58
+ // a crash leaves staging and recovery-staging ones, a release whose tomb could
59
+ // not be put back leaves a released one. Each carries an owner.json.
60
+ function siblingPattern(path) {
61
+ return new RegExp(`^${escapeRegExp(basename(path))}\\.(?:staging|recovery-staging|released)-[0-9a-f]{32}$`);
62
+ }
63
+
64
+ // Called only by the holder of the lock, so no two sweeps race, and a
65
+ // sibling is removed only when its owner is a dead process: a live owner may
66
+ // still be staging or releasing, and an unreadable one may be between its
67
+ // mkdir and its owner write. Best effort; a failure leaves the sibling.
68
+ function sweepDeadSiblings(path) {
69
+ const parent = dirname(path);
70
+ const pattern = siblingPattern(path);
71
+ let entries;
72
+ try { entries = readdirSync(parent); } catch { return; }
73
+ for (const entry of entries) {
74
+ if (!pattern.test(entry)) continue;
75
+ const sibling = join(parent, entry);
76
+ try {
77
+ const stat = lstatSync(sibling);
78
+ if (!stat.isDirectory() || stat.isSymbolicLink()) continue;
79
+ if (!ownerIsDead(readOwner(join(sibling, "owner.json")))) continue;
80
+ rmSync(sibling, { recursive: true, force: true });
81
+ } catch {}
82
+ }
83
+ }
84
+
85
+ // What a lock directory may hold besides its owner record: the recovery
86
+ // claim. A directory without owner.json that holds anything else is not a lock.
87
+ const LOCK_ENTRIES = new Set(["owner.json", ".recovery"]);
88
+
42
89
  // The reentrancy key names the lock directory through its real parent, so a
43
90
  // holder that reached the target through a symlink still recognizes itself.
44
91
  function lockKey(path) {
@@ -106,9 +153,7 @@ function createLock(path, { budgetMs, unavailable, now = Date.now, ownerlessGrac
106
153
  try {
107
154
  const stat = lstatSync(path);
108
155
  if (!stat.isDirectory() || stat.isSymbolicLink()) return false;
109
- const current = readOwner(ownerPath);
110
- if (!(Number.isInteger(current?.pid) && current.pid > 0 && typeof current.token === "string")) return false;
111
- try { process.kill(current.pid, 0); return false; } catch (error) { return error.code === "ESRCH"; }
156
+ return ownerIsDead(readOwner(ownerPath));
112
157
  } catch {
113
158
  return false;
114
159
  }
@@ -179,7 +224,32 @@ function createLock(path, { budgetMs, unavailable, now = Date.now, ownerlessGrac
179
224
  return current - start >= budgetMs;
180
225
  };
181
226
  const fail = (error) => unavailable(error);
182
- const contended = () => Object.assign(new Error(`Lock is held: ${path}`), { code: "EEXIST" });
227
+ // Names what occupies the lock path when it is not a lock, else null. A
228
+ // file there, or a directory with no owner record that holds entries a lock
229
+ // never has, will not clear by waiting and is never removed here.
230
+ const obstruction = () => {
231
+ let stat;
232
+ try { stat = lstatSync(path); } catch { return null; }
233
+ if (stat.isSymbolicLink()) return "a symbolic link, not a lock";
234
+ if (!stat.isDirectory()) return stat.isFile() ? "a regular file, not a lock" : "a special file, not a lock";
235
+ if (exists(ownerPath)) return null;
236
+ let foreign;
237
+ try { foreign = readdirSync(path).filter((entry) => !LOCK_ENTRIES.has(entry)).sort(); } catch { return null; }
238
+ if (foreign.length === 0) return null;
239
+ const shown = foreign.slice(0, 3).join(", ") + (foreign.length > 3 ? `, and ${foreign.length - 3} more` : "");
240
+ return `a directory that is not a lock (no owner.json; it holds ${shown})`;
241
+ };
242
+ // The refusal once waiting ends, telling a held lock, an ownerless lock and
243
+ // a non-lock obstruction apart.
244
+ const contended = () => {
245
+ const found = obstruction();
246
+ if (found) {
247
+ return Object.assign(new Error(`Lock path is occupied by ${found}: ${path}`), { code: "ENOTLOCK", obstruction: found });
248
+ }
249
+ if (ownerless()) return Object.assign(new Error(`Lock has no owner record: ${path}`), { code: "EEXIST" });
250
+ return Object.assign(new Error(`Lock is held: ${path}`), { code: "EEXIST" });
251
+ };
252
+ const sweep = () => sweepDeadSiblings(path);
183
253
 
184
254
  const release = () => {
185
255
  if (readOwner(ownerPath)?.token !== token) return;
@@ -193,7 +263,7 @@ function createLock(path, { budgetMs, unavailable, now = Date.now, ownerlessGrac
193
263
  }
194
264
  };
195
265
 
196
- return { token, stage, publish, recover, heldBySelfProcess, expired, fail, contended, release };
266
+ return { token, stage, publish, recover, heldBySelfProcess, expired, fail, contended, sweep, release };
197
267
  }
198
268
 
199
269
  function reentrantKey(path) {
@@ -232,6 +302,7 @@ export async function withDirectoryLock(path, fn, options) {
232
302
  await sleep(20);
233
303
  }
234
304
  try {
305
+ lock.sweep();
235
306
  return await runHolding(key, lock.token, fn);
236
307
  } finally {
237
308
  lock.release();
@@ -263,6 +334,7 @@ export function withDirectoryLockSync(path, fn, options) {
263
334
  (options.sleep ?? sleepSync)(20);
264
335
  }
265
336
  try {
337
+ lock.sweep();
266
338
  return runHolding(key, lock.token, fn);
267
339
  } finally {
268
340
  lock.release();
@@ -1,6 +1,7 @@
1
1
  // Doctor checks: the check registries, validatePacket and the validators they run.
2
2
  import { campaignSpecIdentity, resolveCampaignIdentity, campaignIdentitiesMatch } from "../spec-source-identity.mjs";
3
3
  import { withHtmlScanSnapshot, readHtmlScanText } from "../html-scan.mjs";
4
+ import { createHash } from "node:crypto";
4
5
  import { accessSync, constants as fsConstants, existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
5
6
  import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
6
7
  import { describeSdkIgnoredMetaTags, isSdkIgnoredMetaTag } from "../sdk-meta-tags.mjs";
@@ -90,6 +91,7 @@ import { SMOKE_QC, SMOKE_QC_LIMITS, builtFileOf, evaluateSmokeQc, insideRoot, is
90
91
  import { recordQcResults } from "../qc-results.mjs";
91
92
  import { FIGMA_EXPORT_FILE_CODES, SOURCE_PROVENANCE_SCOPE, evaluateSourceProvenanceGates, generatorClaimsFigmaExport, isSourceProvenanceCode } from "./source-provenance.mjs";
92
93
  import { validateCampaignBuildBriefArtifact } from "../build-brief.mjs";
94
+ import { briefFileUnusable, deriveInputCurrency, wellFormedBriefMaterial } from "../input-currency.mjs";
93
95
  import { ASSEMBLY_REPORT_STAGE_KEYS, stageIsTerminal } from "../orchestration-stage-contract.mjs";
94
96
  import {
95
97
  assemblySourcePackageFingerprintMissing,
@@ -106,7 +108,7 @@ import { evaluatePageKitSdkVersion, PAGE_KIT_SDK_VERSION_SCOPE } from "../page-k
106
108
  // `npm run build:spec` (tsc -> campaign-spec/dist) so the package runs on the
107
109
  // node engine in package.json without type-stripping. build runs on `prepare`,
108
110
  // so a fresh install (including the git-ref consumer) always has dist.
109
- import { normalize as normalizeCampaignSpec, runRules, specOnlyRules } from "../../campaign-spec/dist/index.js";
111
+ import { isReleasedSdkVersion, normalize as normalizeCampaignSpec, runRules, specOnlyRules } from "../../campaign-spec/dist/index.js";
110
112
  import { cmd, asInvocation } from "../install-invocation.mjs";
111
113
  import { specHashesMatch, specMaterialHash } from "../spec-identity.mjs";
112
114
  import {
@@ -511,7 +513,10 @@ const PACKET_DOCTOR_CHECKS = createDoctorCheckRegistry([
511
513
  {
512
514
  id: "build_brief.artifact",
513
515
  phase: "brief",
514
- run: ({ packet, packetPath, spec, context, errors, warnings, ready }) => validateBuildBrief(packet, packetPath, spec, context, errors, warnings, ready),
516
+ run: ({ packet, packetPath, spec, context, report, errors, warnings, ready, derived }) => {
517
+ validateBuildBrief(packet, packetPath, spec, context, errors, warnings, ready);
518
+ validateInputCurrency({ packet, packetPath, spec, report, warnings, derived });
519
+ },
515
520
  },
516
521
  ], { registryId: "packet.always" });
517
522
 
@@ -731,22 +736,20 @@ function validatePacket(packet, packetPath, errors, warnings, ready, derived, bu
731
736
  }
732
737
  buildState.specStatus = specStatus;
733
738
  if (specStatus === "ok") {
734
- const specMapId = spec.spec_identity?.map_id || spec.map_id;
735
- if ((specMapId && specMapId !== packet.spec.map_id)
736
- || ((spec.spec_identity?.local_spec_id != null || packet.spec?.local_spec_id != null)
737
- && !campaignIdentitiesMatch(campaignSpecIdentity(spec), packet.spec))) {
738
- const localIdentity = spec.spec_identity?.local_spec_id != null || packet.spec?.local_spec_id != null;
739
- addIssue(errors, localIdentity ? "spec.local_identity" : "spec.map_id", "Packet identity does not match the CampaignSpec map_id/local_spec_id.", { kind: localIdentity ? "local_spec" : "saved_map" });
739
+ const identityKind = specIdentityMismatch(spec, packet.spec);
740
+ if (identityKind) {
741
+ addIssue(errors, identityKind === "local_spec" ? "spec.local_identity" : "spec.map_id", "Packet identity does not match the CampaignSpec map_id/local_spec_id.", { kind: identityKind });
740
742
  }
741
743
  ready.push("Local CampaignSpec parsed");
742
- // QA refuses a local-spec run whose spec no longer has the material hash
743
- // prepare-build bound on the Assembly Report; doctor (and next, which
744
- // prints doctor's warnings) name it at the first read instead.
744
+ // The spec no longer has the material hash the Assembly Report binds:
745
+ // a local spec, or the copy a saved-Map or gateway packet fetched.
746
+ // Doctor (and next, which prints doctor's warnings) name it at the
747
+ // first read.
745
748
  const boundMaterialHash = buildState.report?.identity?.spec_material_hash;
746
- if (packet.spec?.local_spec_id != null && isNonEmptyString(boundMaterialHash)) {
749
+ if (isNonEmptyString(boundMaterialHash)) {
747
750
  const currentMaterialHash = specMaterialHash(spec);
748
751
  if (!specHashesMatch(boundMaterialHash, currentMaterialHash)) {
749
- addIssue(warnings, "spec.material_stale", `The CampaignSpec at ${localSpecPath} changed materially since prepare-build bound it (Assembly Report identity.spec_material_hash ${boundMaterialHash}; the spec now hashes to ${currentMaterialHash}). The build and its recorded evidence predate the edit, and QA refuses a stale spec. Re-run ${cmd("prepare-build")} from the edited spec before building on it further.`, { spec_path: localSpecPath, bound_material_hash: boundMaterialHash, current_material_hash: currentMaterialHash });
752
+ addIssue(warnings, "spec.material_stale", SPEC_MATERIAL_STALE_MESSAGE, { spec_path: localSpecPath, bound_material_hash: boundMaterialHash, current_material_hash: currentMaterialHash });
750
753
  }
751
754
  }
752
755
  runDoctorChecks(SPEC_DOCTOR_CHECKS, { packet, packetPath, spec, targetRepo, errors, warnings, ready, derived, buildState });
@@ -762,6 +765,9 @@ function validatePacket(packet, packetPath, errors, warnings, ready, derived, bu
762
765
  if (synthesizedBuiltSite) {
763
766
  ready.push("Packet source/proof checks skipped for synthesized built-site packet");
764
767
  } else {
768
+ // Computed once, before the checks that read it (the QA gate pass the
769
+ // built-output residue scan defers to) and the input warnings below.
770
+ derived.input_currency = deriveInputCurrency({ packet, packetPath, report: buildState.report, spec: isObject(spec) ? spec : null });
765
771
  runDoctorChecks(PACKET_DOCTOR_CHECKS, { packet, packetPath, spec, context: buildState.context, report: buildState.report, errors, warnings, ready, derived, buildState });
766
772
  }
767
773
 
@@ -781,6 +787,25 @@ function validatePacket(packet, packetPath, errors, warnings, ready, derived, bu
781
787
  // stage progression.
782
788
  }
783
789
 
790
+ // A stage's spec stamp, as record build, record polish and the QA write store it.
791
+ const SPEC_STAMP = /^sha256:[0-9a-f]{64}$/;
792
+ const SPEC_MATERIAL_STALE_MESSAGE = "CampaignSpec material changed since it was bound; run `record spec` (keeps history). `prepare-build --force` resets every stage and archives their records.";
793
+
794
+ /**
795
+ * Whether a parsed CampaignSpec names another campaign than `bound` (the
796
+ * packet's spec block, or an Assembly Report identity): null when they
797
+ * agree, otherwise the kind of identity that differs ("local_spec" when
798
+ * either side carries a local spec id, else "saved_map").
799
+ */
800
+ export function specIdentityMismatch(spec, bound) {
801
+ const specMapId = spec?.spec_identity?.map_id || spec?.map_id;
802
+ const localIdentity = spec?.spec_identity?.local_spec_id != null || bound?.local_spec_id != null;
803
+ if ((specMapId && specMapId !== bound?.map_id) || (localIdentity && !campaignIdentitiesMatch(campaignSpecIdentity(spec), bound))) {
804
+ return localIdentity ? "local_spec" : "saved_map";
805
+ }
806
+ return null;
807
+ }
808
+
784
809
  function validateCampaignSpecRuleRegistry(spec, errors, warnings) {
785
810
  // ADR-003: run the shared campaign-spec rule registry — the single public
786
811
  // source of CampaignSpec validation. specOnlyRules is the right preset here:
@@ -876,7 +901,17 @@ function validateBuildBrief(packet, packetPath, spec, context, errors, warnings,
876
901
  return;
877
902
  }
878
903
 
879
- const brief = readJson(resolvedPath);
904
+ let brief;
905
+ try {
906
+ brief = readJson(resolvedPath);
907
+ } catch {
908
+ addIssue(errors, "build_brief.normalized_path", `Campaign Build Brief normalized artifact is not valid JSON: ${normalizedPath}. Restore it, or save the brief again with ${cmd("record")} brief --packet <packet>.`);
909
+ return;
910
+ }
911
+ if (!isObject(brief)) {
912
+ addIssue(errors, "build_brief.normalized_path", `Campaign Build Brief normalized artifact is not a valid brief object: ${normalizedPath}. Restore it, or save the brief again with ${cmd("record")} brief --packet <packet>.`);
913
+ return;
914
+ }
880
915
  const result = validateCampaignBuildBriefArtifact(brief, { spec, normalizedPath });
881
916
  for (const issue of result.errors) errors.push(issue);
882
917
  for (const issue of result.warnings) warnings.push(issue);
@@ -887,6 +922,86 @@ function validateBuildBrief(packet, packetPath, spec, context, errors, warnings,
887
922
  }
888
923
  }
889
924
 
925
+ // derived.input_currency, computed once per doctor read, and the warnings it
926
+ // gives: a stage owed on the brief or on the CampaignSpec, a completed stage
927
+ // with no valid brief or spec stamp, or read against a brief or CampaignSpec
928
+ // that cannot be read, a brief file edited (or unreadable) since it was saved,
929
+ // and the build replay after an input change (owed, or kept by the
930
+ // operator's recorded decision). None is an error; `next` routes to the owed
931
+ // stage itself.
932
+ function validateInputCurrency({ packet, packetPath, spec, report, warnings, derived }) {
933
+ const currency = derived.input_currency || deriveInputCurrency({ packet, packetPath, report, spec: isObject(spec) ? spec : null });
934
+ derived.input_currency = currency;
935
+ if (!isObject(report?.stages)) return;
936
+ // A stage stamped with other spec content than the file holds now, while
937
+ // the report identity already names the file's content (an identity edited
938
+ // by hand): the same warning the identity comparison gives.
939
+ const owedOnSpec = Object.keys(currency.reasons).filter((key) => currency.reasons[key] === "spec_material_changed");
940
+ if (owedOnSpec.length && !warnings.some((issue) => issue.code === "spec.material_stale")) {
941
+ addIssue(warnings, "spec.material_stale", SPEC_MATERIAL_STALE_MESSAGE, { stages: owedOnSpec, bound_material_hash: currency.spec.bound, current_material_hash: currency.spec.current });
942
+ }
943
+ const specUnstamped = Object.keys(currency.stages).filter((key) => currency.reasons[key] === "input_binding_unknown" && !SPEC_STAMP.test(String(report.stages[key]?.source_spec_material_hash ?? "")));
944
+ if (specUnstamped.length) {
945
+ addIssue(warnings, "spec.binding_unknown", `Recorded ${specUnstamped.join(", ")} ${specUnstamped.length === 1 ? "does" : "do"} not say which CampaignSpec content ${specUnstamped.length === 1 ? "it was" : "they were"} made against, so ${specUnstamped.length === 1 ? "it reads" : "they read"} unconfirmed, not current. Record ${specUnstamped.join(", ")} again (${specUnstamped.map((key) => (key === "assembly" ? "record build" : key === "polish" ? "record polish" : "qa run")).join(", ")}).`, { stages: specUnstamped });
946
+ }
947
+ const owedOnBrief = Object.keys(currency.reasons).filter((key) => currency.reasons[key] === "brief_material_changed");
948
+ if (owedOnBrief.length) {
949
+ addIssue(warnings, "build_brief.material_changed", `The Campaign Build Brief's content changed since ${owedOnBrief.join(", ")} ${owedOnBrief.length === 1 ? "was" : "were"} recorded, so ${owedOnBrief.length === 1 ? "it is" : "they are"} owed again. Save the brief with ${cmd("record")} brief --packet <packet> (it keeps stage history), then record ${owedOnBrief.join(", ")} again.`, { stages: owedOnBrief });
950
+ }
951
+ const unstamped = Object.keys(currency.stages).filter((key) => {
952
+ const stage = report.stages[key];
953
+ if (currency.reasons[key] !== "input_binding_unknown") return false;
954
+ const stamp = stage?.source_brief_material;
955
+ return !wellFormedBriefMaterial(stamp);
956
+ });
957
+ if (unstamped.length) {
958
+ addIssue(warnings, "build_brief.binding_unknown", `Recorded ${unstamped.join(", ")} ${unstamped.length === 1 ? "does" : "do"} not say which Campaign Build Brief content ${unstamped.length === 1 ? "it was" : "they were"} made against, so ${unstamped.length === 1 ? "it reads" : "they read"} unconfirmed, not current. Record ${unstamped.join(", ")} again (${unstamped.map((key) => (key === "assembly" ? "record build" : key === "polish" ? "record polish" : "qa run")).join(", ")}).`, { stages: unstamped });
959
+ }
960
+ // A completed stage with a well-formed stamp also reads unknown when the
961
+ // current brief or CampaignSpec cannot be read: there is nothing to compare
962
+ // that stamp with. The warning names the file to restore; a stage whose
963
+ // stamp is missing or malformed is named by the stamp warning above only.
964
+ const unknownStages = Object.keys(currency.stages).filter((key) => currency.reasons[key] === "input_binding_unknown");
965
+ const unconfirmed = (stages) => `recorded ${stages.join(", ")} ${stages.length === 1 ? "reads" : "read"} unconfirmed, not current`;
966
+ const briefUnreadable = unknownStages.filter((key) => !unstamped.includes(key));
967
+ if (briefUnreadable.length && currency.brief.current === null) {
968
+ const normalizedPath = optionalString(packet?.build_brief?.normalized_path);
969
+ addIssue(warnings, "build_brief.binding_unknown", `${normalizedPath ? `The normalized Campaign Build Brief ${normalizedPath} cannot be read as a brief object` : "The Build Packet names no build_brief.normalized_path, so the current Campaign Build Brief cannot be read"}, so ${unconfirmed(briefUnreadable)}. Restore it, or save the brief again with ${cmd("record")} brief --packet <packet>.`, { stages: briefUnreadable, ...(normalizedPath ? { normalized_path: normalizedPath } : {}) });
970
+ }
971
+ const specPath = optionalString(packet?.spec?.local_path);
972
+ const specUnreadable = unknownStages.filter((key) => !specUnstamped.includes(key));
973
+ if (specUnreadable.length && currency.spec.current === null && specPath) {
974
+ addIssue(warnings, "spec.binding_unknown", `The CampaignSpec ${specPath} cannot be read as a JSON object, so ${unconfirmed(specUnreadable)}. Restore it, then run doctor again.`, { stages: specUnreadable, spec_path: specPath });
975
+ }
976
+ const inputPath = optionalString(packet?.build_brief?.input_path);
977
+ const savedSha = optionalString(report.build_brief?.input_sha256);
978
+ const briefFile = inputPath ? resolveFromFile(packetPath, inputPath) : null;
979
+ if (briefFile && savedSha && existsSync(briefFile)) {
980
+ // Read only a readable regular file: a pipe would block, a directory throws.
981
+ const unusable = briefFileUnusable(briefFile);
982
+ let bytes = null;
983
+ if (!unusable) {
984
+ try {
985
+ bytes = `sha256:${createHash("sha256").update(readFileSync(briefFile)).digest("hex")}`;
986
+ } catch {
987
+ bytes = null;
988
+ }
989
+ }
990
+ if (bytes === null) {
991
+ addIssue(warnings, "build_brief.input_unsaved", `The brief file ${inputPath} ${unusable || "cannot be read"}, so doctor cannot tell whether it changed since it was saved; the build reads the saved brief. Restore it, or save a readable brief file with ${cmd("record")} brief --packet <packet> --brief <file>.`, { input_path: inputPath });
992
+ } else if (bytes !== savedSha) {
993
+ addIssue(warnings, "build_brief.input_unsaved", `The brief file ${inputPath} changed since it was saved. Save it with ${cmd("record")} brief --packet <packet>; until then the build reads the saved brief.`, { input_path: inputPath });
994
+ }
995
+ }
996
+ if (currency.reasons.assembly === "output_unchanged_after_input_change") {
997
+ addIssue(warnings, "assembly.output_unchanged_after_input_change", `The build output is unchanged since the brief or CampaignSpec change was recorded, so the change has not reached the pages and build is owed again. Rebuild, or, only on the operator's explicit decision that the change needs no output change, record build with --deviation-reason "<the operator's reason>".`);
998
+ }
999
+ const keptReason = report.stages.assembly?.unchanged_output_reason;
1000
+ if (currency.stages.assembly === "current" && isObject(keptReason) && typeof keptReason.text === "string") {
1001
+ addIssue(warnings, "assembly.output_unchanged_by_operator_decision", `The build output was kept unchanged after an input change by the operator's recorded decision: "${keptReason.text}".`, { reason: keptReason.text });
1002
+ }
1003
+ }
1004
+
890
1005
  function validateProofPolicyObject(policy, location, warnings, ready, { requireBrowserQa = false } = {}) {
891
1006
  if (!isObject(policy)) {
892
1007
  addIssue(warnings, location, `${location} must be an object when present.`);
@@ -2407,7 +2522,7 @@ function recordSmokeQc({ subject, targetRepo, pages, environment, deployBase, wa
2407
2522
  }
2408
2523
 
2409
2524
  function recordSdkMarkupGate({ subject, pages, errors, warnings, ready, derived }) {
2410
- const gate = evaluateSdkMarkup({ subject, pages });
2525
+ const gate = evaluateSdkMarkup({ subject, pages, sdkVersion: campaignSdkPin(derived?.target_repo, subject?.public_route_slug) });
2411
2526
  if (Array.isArray(derived?.checkpoint_gates)) derived.checkpoint_gates.push(gate);
2412
2527
 
2413
2528
  if (gate.status === "not_applicable") {
@@ -2438,6 +2553,16 @@ function recordSdkMarkupGate({ subject, pages, errors, warnings, ready, derived
2438
2553
  return gate;
2439
2554
  }
2440
2555
 
2556
+ // The campaign's SDK pin from the target's campaigns.json entry, when it is an
2557
+ // exact released version; else null. The SDK markup check reads a page's own
2558
+ // loader pin first and falls back to this one.
2559
+ function campaignSdkPin(targetRepo, publicRouteSlug) {
2560
+ if (!targetRepo || !publicRouteSlug) return null;
2561
+ const load = loadPageKitCampaignEntry({ targetRepo, publicRouteSlug });
2562
+ const version = load.status === "ok" ? load.entry?.sdk_version : null;
2563
+ return isReleasedSdkVersion(version) ? version : null;
2564
+ }
2565
+
2441
2566
  // Route drift: a CampaignSpec page whose declared route has no built page at
2442
2567
  // that path. page-kit derives the public route from the source FILENAME, so a
2443
2568
  // file named presell-running.html builds at /presell-running/ even if the spec
@@ -4257,7 +4382,7 @@ export function validateBuiltPlaceholderTextResidue(brandContract, warnings, rea
4257
4382
  return;
4258
4383
  }
4259
4384
  const terms = [...new Set(hits.map((hit) => hit.label))].join(", ");
4260
- if (report && qaGatePassedForCurrentBuild(report, QA_GATE_PLACEHOLDER_TEXT_RESIDUE, { buildFingerprint: currentBuildFingerprint(report) })) {
4385
+ if (report && qaGatePassedForCurrentBuild(report, QA_GATE_PLACEHOLDER_TEXT_RESIDUE, { buildFingerprint: currentBuildFingerprint(report), qaCurrency: derived.input_currency?.stages?.qa ?? "unknown" })) {
4261
4386
  ready.push(`Static scan still sees placeholder-term text (${terms}: ${summarizeCopyMatches(hits)}), but the browser residue gate passed on this build; QA's rendered-text verdict stands.`);
4262
4387
  return;
4263
4388
  }
@@ -5,6 +5,7 @@ import { withHtmlScanSnapshot } from "../html-scan.mjs";
5
5
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
6
6
  import { dirname, join, resolve } from "node:path";
7
7
  import { shellToken } from "../shell-token.mjs";
8
+ import { cmd } from "../install-invocation.mjs";
8
9
  import { commitAssemblyReport, recordProducerStageOutcome } from "../stage-ledger.mjs";
9
10
  import { annotateDoctorIssueCauses } from "../finding-cause.mjs";
10
11
  import { DOCTOR_SIDECAR_SCHEMA } from "../doctor-sidecar.mjs";
@@ -244,6 +245,18 @@ export function doctorBuiltOutput(args) {
244
245
  qc_results: [],
245
246
  };
246
247
  ready.push(`Resolved ${scope.html_count} built page(s) from ${relFromDir(targetRepo, scope.campaign_dir)} (slug "${scope.slug || "(site root)"}")`);
248
+ // #583: --base-url reads like a check of the deployed site, but these checks
249
+ // read only the local built files. Say so wherever the URL is given, and
250
+ // name the command that does load the served pages.
251
+ if (baseUrl) {
252
+ derived.base_url = { value: baseUrl, fetched: false, recorded_as: "deploy.preview_url" };
253
+ // Without --family there is no value to print that the CLI would accept,
254
+ // so the command is printed only when it is runnable as shown.
255
+ const servedCheck = family
256
+ ? `To check the served pages, run ${cmd("qa", `run --site ${shellToken(String(args.built || args.site))} --base-url ${shellToken(baseUrl)} --family ${shellToken(family)} --browser`)}.`
257
+ : `To check the served pages, run ${cmd("qa")} run --site with this --base-url and the campaign's --family, plus --browser.`;
258
+ ready.push(`Checked the local built files only: --base-url ${baseUrl} was not fetched; it only fills deploy.preview_url in the minimal Build Packet. ${servedCheck}`);
259
+ }
247
260
 
248
261
  const resolution = resolveBrandContractOnce(derived, family);
249
262
  let brandContract = null;
@@ -12,12 +12,12 @@ import {
12
12
  import {
13
13
  NEXT_STAGE_ORDER,
14
14
  NEXT_STAGE_OWNERS,
15
- STAGE_TERMINAL_STATUS_PREFIXES,
16
15
  reportKeyForCliStage,
17
16
  stageIsBlocked,
18
17
  stageIsTerminal,
19
18
  } from "../orchestration-stage-contract.mjs";
20
19
  import { evaluatePolishGate } from "../polish-gate.mjs";
20
+ import { effectiveStageStatus, effectiveStatusIsTerminal } from "../input-currency.mjs";
21
21
  import { cmd } from "../install-invocation.mjs";
22
22
  import { isObject, isNonEmptyString, optionalString, resolveFromFile, addIssue, filesystemPathsMatch } from "../cli-helpers.mjs";
23
23
  import { orderPathDepthDrift } from "./checks.mjs";
@@ -440,6 +440,16 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
440
440
  };
441
441
  }
442
442
 
443
+ // A build made against earlier brief or CampaignSpec content, or one whose
444
+ // inputs cannot be confirmed, is owed before anything downstream of it.
445
+ const inputCurrency = derived?.input_currency || null;
446
+ if (["owed", "unknown"].includes(inputCurrency?.stages?.assembly)) {
447
+ return {
448
+ stage: "build",
449
+ reason: inputCurrency.reasons?.assembly || "input_binding_unknown",
450
+ };
451
+ }
452
+
443
453
  if (polishGate.status === "blocked") {
444
454
  if (polishGateRequiresBuild(polishGate)) {
445
455
  return {
@@ -474,7 +484,9 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
474
484
  reason: `Stage "${reportKey}" is not recorded in the assembly report; run "${cliStage}" next.`,
475
485
  };
476
486
  }
477
- const status = String(stage.status || "");
487
+ // The effective status: a stage owed again by an input change reads
488
+ // required, and one whose inputs or raw status cannot be read, unknown.
489
+ const status = effectiveStageStatus(reportKey, stage, inputCurrency);
478
490
  if (stageIsBlocked(status)) {
479
491
  return {
480
492
  stage: cliStage,
@@ -482,10 +494,7 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
482
494
  blocked: true,
483
495
  };
484
496
  }
485
- // Match by prefix so "completed_with_warnings" and future suffixes
486
- // (e.g. "completed_partial") count as terminal.
487
- const isTerminal = STAGE_TERMINAL_STATUS_PREFIXES.some((t) => status.startsWith(t));
488
- if (!isTerminal) {
497
+ if (!effectiveStatusIsTerminal(status)) {
489
498
  return {
490
499
  stage: cliStage,
491
500
  reason: `Stage "${reportKey}" has status "${status || "(unset)"}"; run "${cliStage}" next.`,