@nextcommerce/campaigns-os 1.50.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 (87) hide show
  1. package/CHANGELOG.md +474 -0
  2. package/agents/claude/CLAUDE.md +4 -2
  3. package/agents/codex/AGENTS.md +3 -1
  4. package/agents/copilot/copilot-instructions.md +3 -1
  5. package/agents/cursor/campaigns-os.mdc +3 -1
  6. package/campaign-spec/dist/rules/campaign-metadata.d.ts +5 -1
  7. package/campaign-spec/dist/rules/campaign-metadata.js +9 -2
  8. package/campaign-spec/dist/rules/design-source-shape.js +13 -3
  9. package/campaign-spec/dist/rules/sdk-version.js +2 -1
  10. package/compatibility.json +1 -1
  11. package/contracts/commerce-surface-catalog.json +26 -46
  12. package/contracts/effects.v1.json +282 -14
  13. package/contracts/release-ledger.json +1233 -0
  14. package/contracts/supported-surface.json +2 -2
  15. package/contracts/template-brand-contract.shared-commerce.v0.json +2 -2
  16. package/contracts/template-slot-manifest.shared-content-core.v0.json +24 -0
  17. package/docs/brand-theme-bridge.md +2 -0
  18. package/docs/build-packet.md +148 -23
  19. package/docs/campaign-build-brief.md +57 -2
  20. package/docs/effects.md +14 -0
  21. package/docs/local-setup.md +1 -1
  22. package/docs/orientation-contract-reference.md +1 -1
  23. package/docs/polish-evidence.md +12 -0
  24. package/docs/progress-snapshots.md +2 -4
  25. package/docs/qa-and-test-orders.md +47 -4
  26. package/docs/readback.md +19 -2
  27. package/docs/runtime-readiness.md +1 -1
  28. package/docs/sdk-storage-compatibility.md +1 -1
  29. package/docs/skills-revision.md +10 -10
  30. package/package.json +1 -1
  31. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  32. package/skills/campaign-readback-classification/SKILL.md +3 -3
  33. package/skills/campaign-run-evidence/SKILL.md +3 -3
  34. package/skills/contribution-intake/SKILL.md +3 -3
  35. package/skills/next-campaigns-build/SKILL.md +5 -3
  36. package/skills/next-campaigns-os/SKILL.md +11 -20
  37. package/skills/next-campaigns-os/references/session-intake.md +17 -9
  38. package/skills/next-campaigns-os-setup/SKILL.md +4 -3
  39. package/skills/next-campaigns-polish/SKILL.md +7 -5
  40. package/skills/next-campaigns-qa/SKILL.md +8 -5
  41. package/skills.json +10 -10
  42. package/src/adapter-decision-contract.mjs +1 -1
  43. package/src/brand-theme.mjs +33 -20
  44. package/src/brief-summary.mjs +275 -0
  45. package/src/build-brief.mjs +229 -30
  46. package/src/built-site-scope.mjs +39 -6
  47. package/src/built-smoke-qc.mjs +1117 -0
  48. package/src/campaign-identity.mjs +36 -2
  49. package/src/cart-placeholders.mjs +730 -0
  50. package/src/cli.mjs +467 -143
  51. package/src/commercial-journey.mjs +65 -4
  52. package/src/commercial-parity.mjs +6 -1
  53. package/src/contrast.mjs +649 -0
  54. package/src/design-source-publication.mjs +110 -3
  55. package/src/doctor/checks.mjs +415 -33
  56. package/src/doctor/inspect.mjs +53 -2
  57. package/src/doctor/next-step.mjs +16 -7
  58. package/src/input-currency.mjs +394 -0
  59. package/src/input-refresh.mjs +708 -0
  60. package/src/invocation.mjs +5 -2
  61. package/src/local-preview-policy.mjs +1 -1
  62. package/src/local-proof.mjs +4 -1
  63. package/src/polish-browser.mjs +660 -141
  64. package/src/polish-capture.mjs +1 -1
  65. package/src/polish-deadline.mjs +7 -0
  66. package/src/polish-media-weight.mjs +492 -0
  67. package/src/polish-node.mjs +400 -28
  68. package/src/polish-readability.mjs +828 -0
  69. package/src/progress-node.mjs +19 -3
  70. package/src/qa-browser.mjs +439 -180
  71. package/src/qa-content-params.mjs +889 -0
  72. package/src/qa-node.mjs +116 -13
  73. package/src/qa-order-bump.mjs +22 -1
  74. package/src/qa-policy-links.mjs +1019 -0
  75. package/src/qa-tracking-params.mjs +1389 -0
  76. package/src/qa-url-privacy.mjs +168 -0
  77. package/src/qc-accept.mjs +482 -0
  78. package/src/qc-check-registry.mjs +91 -0
  79. package/src/qc-results.mjs +1341 -0
  80. package/src/readback.mjs +58 -10
  81. package/src/sdk-attribute-index.mjs +71 -0
  82. package/src/sdk-markup.mjs +2 -2
  83. package/src/sdk-storage-compatibility.mjs +63 -3
  84. package/src/source-prep.mjs +37 -7
  85. package/src/stage-ledger.mjs +235 -44
  86. package/src/stage-record.mjs +165 -33
  87. package/src/tooling-setup.mjs +1 -1
@@ -8,6 +8,7 @@ import { shellToken } from "../shell-token.mjs";
8
8
  import { commitAssemblyReport, recordProducerStageOutcome } from "../stage-ledger.mjs";
9
9
  import { annotateDoctorIssueCauses } from "../finding-cause.mjs";
10
10
  import { DOCTOR_SIDECAR_SCHEMA } from "../doctor-sidecar.mjs";
11
+ import { recordQcResults } from "../qc-results.mjs";
11
12
  import { resolveCampaignWorkspace } from "../campaign-workspace.mjs";
12
13
  import { normalizePublicRouteSlug } from "../route-identity.mjs";
13
14
  import { DEFAULT_PROXY_BASE } from "../spec-fetch.mjs";
@@ -19,6 +20,8 @@ import { UPSELL_SELECTOR_SCOPE, builtPageTypeOverRouteGuess } from "../upsell-se
19
20
  import { CAMPAIGN_IDENTITY } from "../campaign-identity.mjs";
20
21
  import { SDK_MARKUP } from "../sdk-markup.mjs";
21
22
  import { SCRIPT_SYNTAX, collectBuiltScriptSyntaxInputs } from "../built-script-syntax.mjs";
23
+ import { CART_PLACEHOLDERS } from "../cart-placeholders.mjs";
24
+ import { SMOKE_QC } from "../built-smoke-qc.mjs";
22
25
  import { stageIsTerminal } from "../orchestration-stage-contract.mjs";
23
26
  import { evaluatePolishGate } from "../polish-gate.mjs";
24
27
  import { evaluateRecordedHiddenEagerMediaCheckpoint } from "../polish-node.mjs";
@@ -42,6 +45,9 @@ import {
42
45
  recordCampaignIdentityGate,
43
46
  recordScriptSyntaxGate,
44
47
  recordSdkMarkupGate,
48
+ recordCartPlaceholders,
49
+ collectCartPlaceholderPages,
50
+ recordSmokeQc,
45
51
  summarizeCopyMatches,
46
52
  resolveBrandContractOnce,
47
53
  reportBrandContractDefectOnce,
@@ -123,7 +129,7 @@ export async function readDoctorLiveCampaign(args, { fetchImpl = globalThis.fetc
123
129
  });
124
130
  }
125
131
 
126
- export function doctorCommand(args, { runDoctor = doctorPacket, liveCampaign = undefined } = {}) {
132
+ export function doctorCommand(args, { runDoctor = doctorPacket, liveCampaign = undefined, qcStandIns = undefined } = {}) {
127
133
  // Non-packet mode (learnings L7): doctor a `campaign-build`'d page-kit
128
134
  // campaign that has only a built _site/ and no full Build Packet. Resolves
129
135
  // scope from the built output and runs the built-output residue/text/
@@ -141,6 +147,8 @@ export function doctorCommand(args, { runDoctor = doctorPacket, liveCampaign = u
141
147
  // A live campaign read (live-campaign-refs.mjs) the caller already made;
142
148
  // absent, the live ref check is recorded not_run.
143
149
  ...(liveCampaign !== undefined ? { liveCampaign } : {}),
150
+ // In-process QC stand-in checks (tests only; see qc-results.mjs).
151
+ ...(qcStandIns !== undefined ? { qcStandIns } : {}),
144
152
  };
145
153
  const result = runDoctor(packetPath, doctorOptions);
146
154
  // Inspection and recording are separate operations. A laptop's untracked
@@ -232,6 +240,8 @@ export function doctorBuiltOutput(args) {
232
240
  built_pages: scope.pages.map((page) => ({ page_id: page.page_id, type: page.page_type, route: page.route })),
233
241
  doctor_checks: [],
234
242
  checkpoint_gates: [],
243
+ // QC results: every result a QC check recomputed this run.
244
+ qc_results: [],
235
245
  };
236
246
  ready.push(`Resolved ${scope.html_count} built page(s) from ${relFromDir(targetRepo, scope.campaign_dir)} (slug "${scope.slug || "(site root)"}")`);
237
247
 
@@ -344,6 +354,39 @@ export function doctorBuiltOutput(args) {
344
354
  });
345
355
  derived.doctor_checks.push(SCRIPT_SYNTAX);
346
356
 
357
+ // Raw cart placeholders. Same placement, same reasons; a QC
358
+ // check whose warnings no blocker above withholds.
359
+ recordCartPlaceholders({
360
+ subject: {
361
+ public_route_slug: scope.slug || null,
362
+ site_root: relFromDir(targetRepo, scope.campaign_dir),
363
+ },
364
+ pages: collectCartPlaceholderPages(targetRepo, optionalString(args.slug)),
365
+ warnings,
366
+ ready,
367
+ derived,
368
+ });
369
+ derived.doctor_checks.push(CART_PLACEHOLDERS);
370
+
371
+ // Built-output smoke checks. Same placement, same reasons. This path has
372
+ // no Assembly Report and no deploy URL, so the build environment and the
373
+ // deploy base are unknown: the production-only rules and an absolute
374
+ // og:image read unexercised here.
375
+ recordSmokeQc({
376
+ subject: {
377
+ public_route_slug: scope.slug || null,
378
+ site_root: relFromDir(targetRepo, scope.campaign_dir),
379
+ },
380
+ targetRepo,
381
+ pages: collectCartPlaceholderPages(targetRepo, optionalString(args.slug)),
382
+ environment: "unknown",
383
+ deployBase: null,
384
+ warnings,
385
+ ready,
386
+ derived,
387
+ });
388
+ derived.doctor_checks.push(SMOKE_QC);
389
+
347
390
  const synthesized = synthesizeMinimalBuildPacket({
348
391
  schemaVersion: PACKET_SCHEMA,
349
392
  targetRepo,
@@ -417,7 +460,7 @@ export function doctorPacket(packetPath, options = {}) {
417
460
  return result;
418
461
  }
419
462
 
420
- function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath = undefined, outputBaseDir = null, liveCampaign = undefined } = {}) {
463
+ function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath = undefined, outputBaseDir = null, liveCampaign = undefined, qcStandIns = null } = {}) {
421
464
  // The Build Context records where prepare-build wrote the report
422
465
  // (--report-out). `next` follows that pointer when no --report is given;
423
466
  // doctor reads the same report so its gates and its next block cannot
@@ -449,6 +492,8 @@ function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath =
449
492
  spec_path: null,
450
493
  doctor_checks: [],
451
494
  checkpoint_gates: [],
495
+ // QC results: every result a QC check recomputed this run.
496
+ qc_results: [],
452
497
  polish_checkpoint_gate: null,
453
498
  // The prepare-build gate `next` acts on, stored like every other gate so
454
499
  // the ladder consumes doctor's evaluation instead of computing its own.
@@ -548,6 +593,12 @@ function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath =
548
593
  ready.push("Hidden eager-media checkpoint not applicable before completed assembly.");
549
594
  }
550
595
 
596
+ // QC stand-in checks reach doctor only through this in-process option
597
+ // (tests); the shipped checks record through recordQcResults themselves.
598
+ for (const check of Array.isArray(qcStandIns?.doctor) ? qcStandIns.doctor : []) {
599
+ recordQcResults({ derived, warnings, results: check() });
600
+ }
601
+
551
602
  // The same fully resolved prepare-build gate the `next` command evaluates:
552
603
  // DSP-required packets, the recorded report path and the context/report
553
604
  // binding checks. A weaker gate here would let doctor name setup or build
@@ -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.`,
@@ -591,7 +600,7 @@ function doctorNextActions(errors, warnings, derived, { polishBlocked, polishGat
591
600
  actions.push("Confirm any rendered promo discount percentage claims against the build request, merchant notes, or CampaignSpec before launch.");
592
601
  }
593
602
  if (codes.has("template_contract.placeholder_text_residue")) {
594
- actions.push("Replace literal template placeholder text (Lorem/Placeholder/TODO/Product Name) with CampaignSpec/design copy before QA; the browser residue gate blocks on these terms.");
603
+ actions.push("Replace literal template placeholder text (Lorem/Placeholder/TODO/Product Name/Benefit one…four) with CampaignSpec/design copy before QA; the browser residue gate blocks on these terms.");
595
604
  }
596
605
  if (codes.has("template_contract.demo_asset_residue")) {
597
606
  actions.push("Re-skin template demo placeholder assets (spacer SVGs, repeated benefit icons, starter imagery) to the campaign's real assets before launch.");
@@ -0,0 +1,394 @@
1
+ // Read-time input currency: whether a recorded build, Polish or QA stage was
2
+ // made against the brief and CampaignSpec content the campaign holds now.
3
+ //
4
+ // A stage stamps the inputs it was recorded against (source_brief_material,
5
+ // source_spec_material_hash). At read time each stamp is compared with the
6
+ // current value; a stage is current only when its raw status is a recognized
7
+ // terminal status, every applicable stamp equals the current value, and no
8
+ // replay rule holds. A missing or malformed stamp reads unknown, never
9
+ // current: nothing else on the report stands in for a stamp. The stage's
10
+ // superseded records are display only and are never read here.
11
+ import { createHash } from "node:crypto";
12
+ import { accessSync, constants as fsConstants, existsSync, lstatSync, readFileSync, statSync } from "node:fs";
13
+
14
+ import { BRIEF_ABSENT, briefMaterialFingerprint } from "./build-brief.mjs";
15
+ import { isObject, optionalString, resolveFromFile } from "./cli-helpers.mjs";
16
+ import { specMaterialHash } from "./spec-identity.mjs";
17
+
18
+ // Every raw stage status the lifecycle writes. Any other value reads unknown.
19
+ export const RECOGNIZED_STAGE_STATUSES = Object.freeze(["pending", "blocked", "required", "completed", "completed_with_warnings", "completed_partial", "skipped"]);
20
+ const COMPLETED_STATUSES = Object.freeze(["completed", "completed_with_warnings", "completed_partial"]);
21
+ const NOT_APPLICABLE_STATUSES = Object.freeze(["pending", "blocked", "required", "skipped"]);
22
+ const TERMINAL_EFFECTIVE_STATUSES = Object.freeze([...COMPLETED_STATUSES, "skipped"]);
23
+
24
+ // The stages that carry input stamps, and the inputs each one depends on.
25
+ export const STAMPED_STAGES = Object.freeze(["assembly", "polish", "qa"]);
26
+ const APPLICABLE_BRIEF_PARTITIONS = Object.freeze({ assembly: ["presentation"], polish: ["presentation"], qa: ["presentation", "qa_policy"] });
27
+
28
+ export const INPUT_CHANGE_REASONS = Object.freeze(["brief_presentation_changed", "brief_qa_policy_changed", "spec_material_changed"]);
29
+
30
+ // The fixed stage dependency map: for each changed input, the stages it makes
31
+ // owed and what each owed stage is required by and for. prepare_build is
32
+ // re-derived by the brief save itself, doctor is recomputed on every read, and
33
+ // setup and deploy are kept.
34
+ export const INPUT_DEPENDENCY_MAP = Object.freeze({
35
+ presentation: Object.freeze({
36
+ reason: "brief_presentation_changed",
37
+ stages: Object.freeze({
38
+ assembly: Object.freeze({ required_by: "brief", required_for: Object.freeze(["polish", "qa"]) }),
39
+ polish: Object.freeze({ required_by: "brief", required_for: Object.freeze(["qa"]) }),
40
+ qa: Object.freeze({ required_by: "brief", required_for: Object.freeze([]) }),
41
+ }),
42
+ }),
43
+ qa_policy: Object.freeze({
44
+ reason: "brief_qa_policy_changed",
45
+ stages: Object.freeze({
46
+ qa: Object.freeze({ required_by: "brief", required_for: Object.freeze([]) }),
47
+ }),
48
+ }),
49
+ spec: Object.freeze({
50
+ reason: "spec_material_changed",
51
+ stages: Object.freeze({
52
+ assembly: Object.freeze({ required_by: "spec", required_for: Object.freeze(["polish", "qa"]) }),
53
+ polish: Object.freeze({ required_by: "spec", required_for: Object.freeze(["qa"]) }),
54
+ qa: Object.freeze({ required_by: "spec", required_for: Object.freeze([]) }),
55
+ }),
56
+ }),
57
+ });
58
+ // When several inputs changed at once, the reason recorded is the first here.
59
+ const CAUSE_PRECEDENCE = Object.freeze(["spec", "presentation", "qa_policy"]);
60
+
61
+ const SHA256_STAMP = /^sha256:[0-9a-f]{64}$/;
62
+
63
+ export const isWellFormedStamp = (value) => typeof value === "string" && (SHA256_STAMP.test(value) || value === BRIEF_ABSENT);
64
+ const isFingerprint = (value) => typeof value === "string" && SHA256_STAMP.test(value);
65
+ const isCanonicalIso = (value) => typeof value === "string" && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
66
+
67
+ export const absentBriefMaterial = () => ({ presentation: BRIEF_ABSENT, qa_policy: BRIEF_ABSENT });
68
+
69
+ // A closed shape: an object with exactly these keys, no more and no fewer.
70
+ const hasExactKeys = (value, keys) => isObject(value)
71
+ && Object.keys(value).length === keys.length
72
+ && keys.every((key) => Object.hasOwn(value, key));
73
+
74
+ // Brief material, as bound, stamped or recorded on a verdict: exactly
75
+ // {presentation, qa_policy}, each "sha256:<64 hex>" or the absent value.
76
+ export function wellFormedBriefMaterial(value) {
77
+ return hasExactKeys(value, ["presentation", "qa_policy"]) && isWellFormedStamp(value.presentation) && isWellFormedStamp(value.qa_policy);
78
+ }
79
+
80
+ // The spec material as a stamp ("sha256:<64 hex>"), or null.
81
+ export function specMaterialStamp(value) {
82
+ if (typeof value !== "string") return null;
83
+ const hex = value.trim().toLowerCase().replace(/^sha256:/, "");
84
+ return /^[0-9a-f]{64}$/.test(hex) ? `sha256:${hex}` : null;
85
+ }
86
+
87
+ /**
88
+ * The brief material the packet's inputs hold now: {presentation, qa_policy}
89
+ * fingerprinted from the normalized brief, the absent value for both when the
90
+ * packet records no Campaign Build Brief, or null when the normalized brief
91
+ * cannot be read.
92
+ */
93
+ export function currentBriefMaterial({ packet, packetPath }) {
94
+ if (!isObject(packet?.build_brief)) return absentBriefMaterial();
95
+ const normalizedPath = optionalString(packet.build_brief.normalized_path);
96
+ const resolved = normalizedPath && packetPath ? resolveFromFile(packetPath, normalizedPath) : null;
97
+ if (!resolved || !existsSync(resolved)) return null;
98
+ try {
99
+ const brief = JSON.parse(readFileSync(resolved, "utf8"));
100
+ return isObject(brief) ? briefMaterialFingerprint(brief) : null;
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ // Why `path` is not a brief file this run can read, or null when it is one.
107
+ // A brief file is a regular file, reached through any symlink, with read
108
+ // permission. Nothing here opens the path, so a pipe or socket never blocks.
109
+ export function briefFileUnusable(path) {
110
+ let stats;
111
+ try {
112
+ stats = statSync(path);
113
+ } catch {
114
+ try {
115
+ lstatSync(path);
116
+ return "is a symlink to nothing";
117
+ } catch {
118
+ return "does not exist";
119
+ }
120
+ }
121
+ if (stats.isDirectory()) return "is a directory, not a file";
122
+ if (!stats.isFile()) return "is not a regular file";
123
+ try {
124
+ accessSync(path, fsConstants.R_OK);
125
+ } catch {
126
+ return "cannot be read";
127
+ }
128
+ return null;
129
+ }
130
+
131
+ // The current spec material of a parsed CampaignSpec, or null.
132
+ export function currentSpecMaterial(spec) {
133
+ return isObject(spec) ? specMaterialHash(spec) : null;
134
+ }
135
+
136
+ // The current inputs a record stamps, in stamp shape.
137
+ export function inputStamps({ briefMaterial, specMaterial }) {
138
+ return {
139
+ source_brief_material: wellFormedBriefMaterial(briefMaterial) ? { presentation: briefMaterial.presentation, qa_policy: briefMaterial.qa_policy } : null,
140
+ source_spec_material_hash: specMaterialStamp(specMaterial),
141
+ };
142
+ }
143
+
144
+ // input_change: exactly {at, reason, superseded_build_fingerprint,
145
+ // superseded_inputs: {brief_material, spec_material_hash}}.
146
+ export function inputChangeWellFormed(change) {
147
+ if (!hasExactKeys(change, ["at", "reason", "superseded_build_fingerprint", "superseded_inputs"])) return false;
148
+ if (!isCanonicalIso(change.at) || !INPUT_CHANGE_REASONS.includes(change.reason)) return false;
149
+ if (change.superseded_build_fingerprint !== null && !isFingerprint(change.superseded_build_fingerprint)) return false;
150
+ const inputs = change.superseded_inputs;
151
+ if (!hasExactKeys(inputs, ["brief_material", "spec_material_hash"])) return false;
152
+ if (inputs.brief_material !== null && !wellFormedBriefMaterial(inputs.brief_material)) return false;
153
+ return inputs.spec_material_hash === null || isFingerprint(inputs.spec_material_hash);
154
+ }
155
+
156
+ // unchanged_output_reason: exactly {text, for_inputs: {brief_material,
157
+ // spec_material_hash}}, with non-empty text and the stamps the record wrote.
158
+ export function unchangedOutputReasonWellFormed(reason) {
159
+ if (!hasExactKeys(reason, ["text", "for_inputs"]) || typeof reason.text !== "string" || !reason.text.trim()) return false;
160
+ const inputs = reason.for_inputs;
161
+ return hasExactKeys(inputs, ["brief_material", "spec_material_hash"]) && wellFormedBriefMaterial(inputs.brief_material) && isFingerprint(inputs.spec_material_hash);
162
+ }
163
+
164
+ // The partitions and spec a stage depends on, compared stamp by stamp. Returns
165
+ // "unknown" when a stamp or a current value is missing or malformed, the name
166
+ // of the first changed input ("spec" before the brief), or null when equal.
167
+ function compareStamps(key, stage, current) {
168
+ const partitions = APPLICABLE_BRIEF_PARTITIONS[key];
169
+ const stamp = stage.source_brief_material;
170
+ const specStamp = stage.source_spec_material_hash;
171
+ if (!wellFormedBriefMaterial(stamp)) return "unknown";
172
+ if (!isFingerprint(specStamp)) return "unknown";
173
+ if (!wellFormedBriefMaterial(current.brief)) return "unknown";
174
+ if (!isFingerprint(current.spec)) return "unknown";
175
+ if (specStamp !== current.spec) return "spec";
176
+ return partitions.find((partition) => stamp[partition] !== current.brief[partition]) || null;
177
+ }
178
+
179
+ // Assembly's unchanged_output_reason holds for the current inputs when its
180
+ // for_inputs equal them on every input assembly depends on.
181
+ function operatorKeptOutput(stage, current) {
182
+ const inputs = stage.unchanged_output_reason?.for_inputs;
183
+ return isObject(inputs)
184
+ && inputs.spec_material_hash === current.spec
185
+ && APPLICABLE_BRIEF_PARTITIONS.assembly.every((partition) => inputs.brief_material?.[partition] === current.brief?.[partition]);
186
+ }
187
+
188
+ // The capture time of the package page_load attached to Polish, or null.
189
+ function polishCaptureTime(stage) {
190
+ const capturedAt = stage.evidence?.visual_review?.page_load?.captured_at;
191
+ return typeof capturedAt === "string" && Number.isFinite(Date.parse(capturedAt)) ? capturedAt : null;
192
+ }
193
+
194
+ /**
195
+ * One stamped stage's currency at read time, by the first rule that holds:
196
+ * an unrecognized status reads unknown; a non-completed status is not
197
+ * applicable; a completed stage with a missing or malformed stamp, an input
198
+ * that cannot be computed, or a malformed input_change or
199
+ * unchanged_output_reason reads unknown; a stamp unequal to the current value
200
+ * reads owed; an assembly whose output is still the build the input change
201
+ * superseded (and no operator decision for these inputs), or a Polish whose
202
+ * capture does not postdate the input change, reads owed; otherwise current.
203
+ */
204
+ export function assessStageCurrency(key, stage, current) {
205
+ const record = isObject(stage) ? stage : {};
206
+ const status = typeof record.status === "string" ? record.status : "";
207
+ if (!RECOGNIZED_STAGE_STATUSES.includes(status)) return { currency: "unknown", reason: "status_unrecognized" };
208
+ if (NOT_APPLICABLE_STATUSES.includes(status)) return { currency: "not_applicable", reason: null };
209
+ const compared = compareStamps(key, record, current);
210
+ const malformedChange = Object.hasOwn(record, "input_change") && !inputChangeWellFormed(record.input_change);
211
+ const malformedReason = key === "assembly" && Object.hasOwn(record, "unchanged_output_reason") && !unchangedOutputReasonWellFormed(record.unchanged_output_reason);
212
+ if (compared === "unknown" || malformedChange || malformedReason) return { currency: "unknown", reason: "input_binding_unknown" };
213
+ if (compared) return { currency: "owed", reason: compared === "spec" ? "spec_material_changed" : "brief_material_changed" };
214
+ const change = record.input_change;
215
+ if (key === "assembly" && change
216
+ && optionalString(record.build_fingerprint) === change.superseded_build_fingerprint
217
+ && !operatorKeptOutput(record, current)) {
218
+ return { currency: "owed", reason: "output_unchanged_after_input_change" };
219
+ }
220
+ if (key === "polish" && change) {
221
+ const capturedAt = polishCaptureTime(record);
222
+ if (!capturedAt || Date.parse(capturedAt) <= Date.parse(change.at)) return { currency: "owed", reason: "polish_capture_predates_input_change" };
223
+ }
224
+ return { currency: "current", reason: null };
225
+ }
226
+
227
+ function briefStatus(bound, current) {
228
+ if (isObject(current) && current.presentation === BRIEF_ABSENT && current.qa_policy === BRIEF_ABSENT) return "absent";
229
+ if (!wellFormedBriefMaterial(bound) || !wellFormedBriefMaterial(current)) return "unknown";
230
+ return bound.presentation === current.presentation && bound.qa_policy === current.qa_policy ? "current" : "owed";
231
+ }
232
+
233
+ /**
234
+ * derived.input_currency for a report, given the current inputs:
235
+ * `briefMaterial` = {presentation, qa_policy} (each "sha256:<64 hex>", or the
236
+ * absent value for a packet with no brief; null when it cannot be computed)
237
+ * and `specMaterial` = "sha256:<64 hex>" (null when it cannot be computed).
238
+ */
239
+ export function assessInputCurrency({ report, briefMaterial = null, specMaterial = null } = {}) {
240
+ const current = {
241
+ brief: wellFormedBriefMaterial(briefMaterial) ? { presentation: briefMaterial.presentation, qa_policy: briefMaterial.qa_policy } : null,
242
+ spec: specMaterialStamp(specMaterial),
243
+ };
244
+ const boundBrief = isObject(report?.build_brief?.material) ? report.build_brief.material : null;
245
+ const stages = {};
246
+ const reasons = {};
247
+ for (const key of STAMPED_STAGES) {
248
+ const assessed = assessStageCurrency(key, report?.stages?.[key], current);
249
+ stages[key] = assessed.currency;
250
+ reasons[key] = assessed.reason;
251
+ }
252
+ const boundSpec = optionalString(report?.identity?.spec_material_hash) || null;
253
+ return {
254
+ brief: {
255
+ bound: boundBrief ? JSON.parse(JSON.stringify(boundBrief)) : null,
256
+ current: current.brief,
257
+ status: briefStatus(boundBrief, current.brief),
258
+ },
259
+ spec: {
260
+ bound: boundSpec,
261
+ current: current.spec,
262
+ snapshot_material: snapshotMaterialStatus(boundSpec, current.spec),
263
+ },
264
+ stages,
265
+ reasons,
266
+ };
267
+ }
268
+
269
+ // The selected local spec file against the material the report binds: the
270
+ // copy on disk only, never a remote revision.
271
+ function snapshotMaterialStatus(bound, current) {
272
+ const boundStamp = specMaterialStamp(bound);
273
+ if (!boundStamp || !current) return "unknown";
274
+ return boundStamp === current ? "current" : "owed";
275
+ }
276
+
277
+ /**
278
+ * The status every effective-status reader uses for a stage. For assembly,
279
+ * polish and qa: the recorded status when the stage is current or not
280
+ * applicable, `required` when owed, `unknown` when unknown. For the other
281
+ * stages, which carry no stamps: the recorded status when it is recognized,
282
+ * otherwise `unknown`.
283
+ */
284
+ export function effectiveStageStatus(key, stage, inputCurrency = null) {
285
+ const status = typeof stage?.status === "string" ? stage.status : "";
286
+ if (!STAMPED_STAGES.includes(key)) return RECOGNIZED_STAGE_STATUSES.includes(status) ? status : "unknown";
287
+ const currency = inputCurrency?.stages?.[key];
288
+ if (currency === "current" || currency === "not_applicable") return status;
289
+ if (currency === "owed") return "required";
290
+ return "unknown";
291
+ }
292
+
293
+ export const effectiveStatusIsTerminal = (status) => TERMINAL_EFFECTIVE_STATUSES.includes(status);
294
+
295
+ // The change a stage's stamps show against the current inputs, as the
296
+ // dependency-map cause that wins ("spec", "presentation", "qa_policy"), or
297
+ // null. A missing or malformed stamp is not a detected change.
298
+ export function detectedStageChange(key, stage, { briefMaterial, specMaterial }) {
299
+ const stamp = stage?.source_brief_material;
300
+ const changed = [];
301
+ const spec = specMaterialStamp(specMaterial);
302
+ if (isFingerprint(stage?.source_spec_material_hash) && spec && stage.source_spec_material_hash !== spec) changed.push("spec");
303
+ if (wellFormedBriefMaterial(stamp) && wellFormedBriefMaterial(briefMaterial)) {
304
+ for (const partition of APPLICABLE_BRIEF_PARTITIONS[key] || []) {
305
+ if (stamp[partition] !== briefMaterial[partition]) changed.push(partition);
306
+ }
307
+ }
308
+ return CAUSE_PRECEDENCE.find((cause) => changed.includes(cause)) || null;
309
+ }
310
+
311
+ // The reason recorded for a set of changed inputs (spec first, then presentation).
312
+ export function changeReason(causes) {
313
+ const cause = CAUSE_PRECEDENCE.find((candidate) => causes.includes(candidate));
314
+ return cause ? INPUT_DEPENDENCY_MAP[cause].reason : null;
315
+ }
316
+
317
+ /**
318
+ * The current inputs of a packet, read from disk: the normalized brief's
319
+ * material and the material of the CampaignSpec at spec.local_path (null when
320
+ * it cannot be read).
321
+ */
322
+ export function currentPacketInputs({ packet, packetPath }) {
323
+ const localPath = optionalString(packet?.spec?.local_path);
324
+ const specPath = localPath && packetPath ? resolveFromFile(packetPath, localPath) : null;
325
+ let spec = null;
326
+ try {
327
+ spec = specPath && existsSync(specPath) ? JSON.parse(readFileSync(specPath, "utf8")) : null;
328
+ } catch {
329
+ spec = null;
330
+ }
331
+ return { briefMaterial: currentBriefMaterial({ packet, packetPath }), specMaterial: currentSpecMaterial(spec) };
332
+ }
333
+
334
+ // True when every stamp `key` depends on equals the current inputs.
335
+ export function stampsMatchInputs(key, record, { briefMaterial, specMaterial }) {
336
+ const current = { brief: wellFormedBriefMaterial(briefMaterial) ? briefMaterial : null, spec: specMaterialStamp(specMaterial) };
337
+ return compareStamps(key, isObject(record) ? record : {}, current) === null;
338
+ }
339
+
340
+ // What a stage write needs to detect, carry or clear input_change against
341
+ // the current inputs: the reason a replaced record's stamps show a change
342
+ // (or null), and whether a record's stamps equal the current inputs.
343
+ export function stageWriteInputs(key, inputs) {
344
+ return {
345
+ detectChange: (record) => {
346
+ const cause = detectedStageChange(key, record, inputs);
347
+ return cause ? INPUT_DEPENDENCY_MAP[cause].reason : null;
348
+ },
349
+ stampsCurrent: (record) => stampsMatchInputs(key, record, inputs),
350
+ };
351
+ }
352
+
353
+ /**
354
+ * The bindings intake and record brief write beside a brief result on the
355
+ * packet, the Build Context and the Assembly Report: the material
356
+ * fingerprints, and the sha256 of the brief file's bytes (null for a
357
+ * generated draft).
358
+ */
359
+ export function briefIntakeBindings(buildBrief) {
360
+ return {
361
+ material: briefMaterialFingerprint(buildBrief.artifact),
362
+ input_sha256: buildBrief.inputPath ? `sha256:${createHash("sha256").update(readFileSync(buildBrief.inputPath)).digest("hex")}` : null,
363
+ };
364
+ }
365
+
366
+ /**
367
+ * The refresh `next` offers first when a stage reads owed on a stamp:
368
+ * `record brief` for a brief stamp, `record spec` for a spec stamp. Returns
369
+ * the commands (subcommand names) in that order, or [].
370
+ */
371
+ export function inputRefreshCommands(inputCurrency) {
372
+ const reasons = Object.values(isObject(inputCurrency?.reasons) ? inputCurrency.reasons : {});
373
+ return [
374
+ ...(reasons.includes("brief_material_changed") ? ["brief"] : []),
375
+ ...(reasons.includes("spec_material_changed") ? ["spec"] : []),
376
+ ];
377
+ }
378
+
379
+ /**
380
+ * derived.input_currency for a doctor read: the report's stamps against the
381
+ * normalized brief and the CampaignSpec on disk now. A saved-Map or gateway
382
+ * packet compares the copy intake fetched; no offline read can confirm that
383
+ * copy is the latest remote revision, so it also reports the remote as
384
+ * unconfirmed.
385
+ */
386
+ export function deriveInputCurrency({ packet, packetPath, report, spec }) {
387
+ const currency = assessInputCurrency({
388
+ report,
389
+ briefMaterial: currentBriefMaterial({ packet, packetPath }),
390
+ specMaterial: currentSpecMaterial(spec),
391
+ });
392
+ if (optionalString(packet?.spec?.map_id)) currency.spec.remote_currency = "unconfirmed";
393
+ return currency;
394
+ }