@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
@@ -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
+ }