@nextcommerce/campaigns-os 1.52.0 → 1.53.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 (58) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/agents/claude/CLAUDE.md +2 -0
  3. package/agents/codex/AGENTS.md +2 -0
  4. package/agents/copilot/copilot-instructions.md +2 -0
  5. package/agents/cursor/campaigns-os.mdc +2 -0
  6. package/compatibility.json +1 -1
  7. package/contracts/effects.v1.json +201 -12
  8. package/contracts/release-ledger.json +327 -0
  9. package/contracts/supported-surface.json +2 -2
  10. package/docs/brand-theme-bridge.md +2 -0
  11. package/docs/build-packet.md +56 -15
  12. package/docs/campaign-build-brief.md +48 -17
  13. package/docs/effects.md +8 -0
  14. package/docs/local-setup.md +1 -1
  15. package/docs/orientation-contract-reference.md +1 -1
  16. package/docs/polish-evidence.md +2 -0
  17. package/docs/progress-snapshots.md +2 -4
  18. package/docs/qa-and-test-orders.md +2 -0
  19. package/docs/readback.md +19 -2
  20. package/docs/runtime-readiness.md +1 -1
  21. package/docs/skills-revision.md +10 -10
  22. package/package.json +1 -1
  23. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  24. package/skills/campaign-readback-classification/SKILL.md +3 -3
  25. package/skills/campaign-run-evidence/SKILL.md +3 -3
  26. package/skills/contribution-intake/SKILL.md +3 -3
  27. package/skills/next-campaigns-build/SKILL.md +5 -3
  28. package/skills/next-campaigns-os/SKILL.md +10 -19
  29. package/skills/next-campaigns-os/references/session-intake.md +10 -6
  30. package/skills/next-campaigns-os-setup/SKILL.md +4 -3
  31. package/skills/next-campaigns-polish/SKILL.md +7 -5
  32. package/skills/next-campaigns-qa/SKILL.md +5 -3
  33. package/skills.json +10 -10
  34. package/src/brand-theme.mjs +21 -20
  35. package/src/brief-summary.mjs +275 -0
  36. package/src/build-brief.mjs +167 -15
  37. package/src/cli.mjs +160 -112
  38. package/src/contrast.mjs +649 -0
  39. package/src/design-source-publication.mjs +110 -3
  40. package/src/doctor/checks.mjs +129 -14
  41. package/src/doctor/next-step.mjs +15 -6
  42. package/src/input-currency.mjs +394 -0
  43. package/src/input-refresh.mjs +708 -0
  44. package/src/invocation.mjs +3 -1
  45. package/src/polish-browser.mjs +503 -201
  46. package/src/polish-deadline.mjs +7 -0
  47. package/src/polish-node.mjs +326 -46
  48. package/src/polish-readability.mjs +828 -0
  49. package/src/progress-node.mjs +15 -3
  50. package/src/qa-browser.mjs +131 -84
  51. package/src/qa-node.mjs +14 -3
  52. package/src/qc-accept.mjs +44 -8
  53. package/src/qc-check-registry.mjs +15 -7
  54. package/src/qc-results.mjs +326 -34
  55. package/src/readback.mjs +58 -10
  56. package/src/stage-ledger.mjs +235 -44
  57. package/src/stage-record.mjs +119 -26
  58. 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
@@ -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,
@@ -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.`);
@@ -4257,7 +4372,7 @@ export function validateBuiltPlaceholderTextResidue(brandContract, warnings, rea
4257
4372
  return;
4258
4373
  }
4259
4374
  const terms = [...new Set(hits.map((hit) => hit.label))].join(", ");
4260
- if (report && qaGatePassedForCurrentBuild(report, QA_GATE_PLACEHOLDER_TEXT_RESIDUE, { buildFingerprint: currentBuildFingerprint(report) })) {
4375
+ if (report && qaGatePassedForCurrentBuild(report, QA_GATE_PLACEHOLDER_TEXT_RESIDUE, { buildFingerprint: currentBuildFingerprint(report), qaCurrency: derived.input_currency?.stages?.qa ?? "unknown" })) {
4261
4376
  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
4377
  return;
4263
4378
  }
@@ -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.`,