@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
@@ -2,6 +2,7 @@ import { campaignIdentitiesMatch } from "./spec-source-identity.mjs";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { markDoctorSidecarStale, writeDoctorSidecar, writeJsonAtomic } from "./doctor-sidecar.mjs";
4
4
  import { STATUS as QA_STATUS } from "./qa-verdict.mjs";
5
+ import { assessInputCurrency, currentPacketInputs, effectiveStageStatus, effectiveStatusIsTerminal, wellFormedBriefMaterial } from "./input-currency.mjs";
5
6
  import { isPlainObject, normalizeString as optionalString } from "./repo-scan.mjs";
6
7
  import { withTargetLockSync } from "./target-lock.mjs";
7
8
  import {
@@ -44,6 +45,8 @@ function terminalStatus(disposition) {
44
45
  // `evidence` shapes archive, object and array alike; an array of operator notes
45
46
  // is exactly the evidence a producer has no standing to silently drop.
46
47
  const QA_OWNED_FIELDS = Object.freeze(["verdict_run_id", "evidence", "purchase_proof"]);
48
+ // The input stamps a QA write restates from its verdict, like the fields above.
49
+ const QA_STAMP_FIELDS = Object.freeze(["source_brief_material", "source_spec_material_hash"]);
47
50
 
48
51
  // Bounded so a committed handoff artifact cannot grow without limit, and deep
49
52
  // enough that a couple of repair attempts do not evict the state a reviewer
@@ -85,11 +88,8 @@ function meaningfulEvidence(value) {
85
88
  // IS meaning) before comparing.
86
89
  function canonicalize(value) {
87
90
  if (Array.isArray(value)) return value.map(canonicalize);
88
- if (isPlainObject(value)) {
89
- const out = {};
90
- for (const key of Object.keys(value).sort()) out[key] = canonicalize(value[key]);
91
- return out;
92
- }
91
+ // Entry-wise, so a key named __proto__ is compared as data.
92
+ if (isPlainObject(value)) return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]));
93
93
  return value;
94
94
  }
95
95
 
@@ -97,29 +97,178 @@ function sameJson(a, b) {
97
97
  return JSON.stringify(canonicalize(a ?? null)) === JSON.stringify(canonicalize(b ?? null));
98
98
  }
99
99
 
100
- /**
101
- * Archive the previous producer-owned identity, if there was one, without
102
- * inventing anything it did not carry.
103
- */
104
- function archivePreviousIdentity(previous, incoming) {
105
- const hadIdentity = typeof previous.verdict_run_id === "string" && previous.verdict_run_id.trim()
106
- ? previous.verdict_run_id.trim()
107
- : null;
108
- const hadEvidence = meaningfulEvidence(previous.evidence);
109
- if (!hadIdentity && !hadEvidence) return null;
110
- // An unchanged verdict is a re-record, not a new chapter: re-running the same
111
- // producer against the same verdict must not grow history. Both sides are
112
- // normalized the same way, so an empty incoming evidence block compares equal
113
- // to an empty previous one instead of looking like a change.
114
- if (sameJson(hadIdentity, incoming.verdict_run_id ?? null) && sameJson(hadEvidence, meaningfulEvidence(incoming.evidence))) return null;
100
+ // The closed field list a history entry copies from the record it archives,
101
+ // beside archived_at, archived_by and reason_code. History is display only:
102
+ // no reader takes completion or currency from it.
103
+ const HISTORY_ENTRY_FIELDS = Object.freeze([
104
+ "status", "completed_at", "recorded_by", "performed_by",
105
+ "build_fingerprint", "source_build_fingerprint", "source_package_material_fingerprint",
106
+ "source_brief_material", "source_spec_material_hash",
107
+ "verdict_run_id", "checked_at", "purchase_proof",
108
+ "outputs", "evidence",
109
+ ]);
110
+ // The entry fields that say what a record was, rather than when or by whom it
111
+ // was written: a replacement equal on all of them is a re-record, not a new
112
+ // chapter, and archives nothing.
113
+ const HISTORY_IDENTITY_FIELDS = Object.freeze([
114
+ "build_fingerprint", "source_build_fingerprint", "source_package_material_fingerprint",
115
+ "source_brief_material", "source_spec_material_hash", "verdict_run_id", "purchase_proof", "outputs", "evidence",
116
+ ]);
117
+ export const HISTORY_ARCHIVED_BY = Object.freeze(["record brief", "record spec", "record build", "record polish", "qa run", "prepare-build --force"]);
118
+ export const HISTORY_REASON_CODES = Object.freeze(["brief_presentation_changed", "brief_qa_policy_changed", "spec_material_changed", "rerecorded", "force_reset", "build_output_changed"]);
119
+ const ARCHIVED_STAGE_KEYS = Object.freeze(["assembly", "polish", "qa"]);
120
+
121
+ const isCompletedStatus = (status) => typeof status === "string" && status.startsWith("completed");
122
+
123
+ // What a record says about itself, compared for the dedup. Evidence reads
124
+ // through meaningfulEvidence (an empty block is absent) and a verdict id is
125
+ // trimmed, as the QA producer always compared them.
126
+ function recordIdentity(record) {
127
+ const identity = {};
128
+ for (const field of HISTORY_IDENTITY_FIELDS) {
129
+ const value = field === "evidence"
130
+ ? meaningfulEvidence(record?.evidence)
131
+ : field === "verdict_run_id"
132
+ ? (typeof record?.verdict_run_id === "string" && record.verdict_run_id.trim() ? record.verdict_run_id.trim() : null)
133
+ : record?.[field] ?? null;
134
+ if (value !== null && value !== undefined) identity[field] = value;
135
+ }
136
+ return identity;
137
+ }
138
+
139
+ // The entry for `previous`: the closed fields it carries, copied, with no
140
+ // value it did not have (an absent timestamp stays absent).
141
+ function historyEntry(previous, { by, reason, at }) {
115
142
  const entry = {};
116
- if (typeof previous.status === "string" && previous.status.trim()) entry.status = previous.status;
117
- if (typeof previous.checked_at === "string" && previous.checked_at.trim()) entry.checked_at = previous.checked_at;
118
- if (hadIdentity) entry.verdict_run_id = hadIdentity;
119
- if (hadEvidence) entry.evidence = JSON.parse(JSON.stringify(hadEvidence));
143
+ if (at !== undefined) entry.archived_at = at;
144
+ if (by !== undefined) entry.archived_by = by;
145
+ if (reason !== undefined) entry.reason_code = reason;
146
+ for (const field of HISTORY_ENTRY_FIELDS) {
147
+ if (!Object.hasOwn(previous, field)) continue;
148
+ if (field === "evidence") {
149
+ const evidence = meaningfulEvidence(previous.evidence);
150
+ if (evidence) entry.evidence = JSON.parse(JSON.stringify(evidence));
151
+ continue;
152
+ }
153
+ if (field === "verdict_run_id") {
154
+ if (typeof previous.verdict_run_id === "string" && previous.verdict_run_id.trim()) entry.verdict_run_id = previous.verdict_run_id.trim();
155
+ continue;
156
+ }
157
+ if (field === "status" || field === "checked_at" || field === "completed_at") {
158
+ if (typeof previous[field] === "string" && previous[field].trim()) entry[field] = previous[field];
159
+ continue;
160
+ }
161
+ entry[field] = JSON.parse(JSON.stringify(previous[field]));
162
+ }
120
163
  return entry;
121
164
  }
122
165
 
166
+ // A record worth an entry: a completed record, or (QA) any record carrying a
167
+ // verdict identity or evidence, which the QA producer has always archived.
168
+ function archivable(stageKey, previous) {
169
+ if (!isPlainObject(previous)) return false;
170
+ if (isCompletedStatus(previous.status)) return true;
171
+ return stageKey === "qa" && Boolean(recordIdentity(previous).verdict_run_id || meaningfulEvidence(previous.evidence));
172
+ }
173
+
174
+ /**
175
+ * Append `previous` to `stage.history` when `incoming` replaces it with a
176
+ * different record: the closed entry fields, stamped archived_at, archived_by
177
+ * (`by`) and reason_code (`reason`). A replacement equal on every identity
178
+ * field is a re-record and archives nothing. History keeps the last
179
+ * PRODUCER_STAGE_HISTORY_LIMIT entries, oldest first evicted. Returns the
180
+ * stage record (a copy) carrying the history.
181
+ */
182
+ export function archiveStageRecord(stage, previous, incoming, { by, reason, at = new Date().toISOString(), stageKey = stage?.stage } = {}) {
183
+ const target = isPlainObject(stage) ? { ...stage } : {};
184
+ if (!archivable(stageKey, previous)) return target;
185
+ if (sameJson(recordIdentity(previous), recordIdentity(incoming))) return target;
186
+ return appendHistoryEntry(target, historyEntry(previous, { by, reason, at }));
187
+ }
188
+
189
+ function appendHistoryEntry(stage, entry) {
190
+ const prior = Array.isArray(stage.history) ? stage.history.filter(isPlainObject) : [];
191
+ return { ...stage, history: [...prior, entry].slice(-PRODUCER_STAGE_HISTORY_LIMIT) };
192
+ }
193
+
194
+ /**
195
+ * Archive a whole superseded record into its own stage's history (a demotion,
196
+ * or intake with --force), without the re-record dedup.
197
+ */
198
+ export function archiveSupersededRecord(stage, previous, { by, reason, at }) {
199
+ return appendHistoryEntry(isPlainObject(stage) ? { ...stage } : {}, historyEntry(previous, { by, reason, at }));
200
+ }
201
+
202
+ /**
203
+ * True when any stage of `report` carries a non-empty history: recorded
204
+ * operator-visible state an intake must not silently drop.
205
+ */
206
+ export function stageHistoryPresent(report) {
207
+ const stages = isPlainObject(report?.stages) ? report.stages : {};
208
+ return Object.values(stages).some((stage) => isPlainObject(stage) && Array.isArray(stage.history) && stage.history.length > 0);
209
+ }
210
+
211
+ /**
212
+ * The input change a write records on a stage whose stamps differ from the
213
+ * current inputs: when, why, the build the stage was bound to, and the stamps
214
+ * of the record it supersedes. A record that carries no build or stamps of
215
+ * its own (a stage intake reseeded) keeps those of the input change already
216
+ * on it, so a later change never drops the superseded build a replay is
217
+ * compared with.
218
+ */
219
+ export function inputChangeFor(stageKey, previous, { at, reason }) {
220
+ const stamp = previous?.source_brief_material;
221
+ const build = stageKey === "assembly" ? previous?.build_fingerprint : stageKey === "polish" ? previous?.source_build_fingerprint : null;
222
+ const isFingerprint = (value) => typeof value === "string" && /^sha256:[0-9a-f]{64}$/.test(value);
223
+ const earlier = isPlainObject(previous?.input_change) ? previous.input_change : {};
224
+ const earlierInputs = isPlainObject(earlier.superseded_inputs) ? earlier.superseded_inputs : {};
225
+ return {
226
+ at,
227
+ reason,
228
+ superseded_build_fingerprint: isFingerprint(build) ? build : isFingerprint(earlier.superseded_build_fingerprint) ? earlier.superseded_build_fingerprint : null,
229
+ superseded_inputs: {
230
+ brief_material: wellFormedBriefMaterial(stamp)
231
+ ? { presentation: stamp.presentation, qa_policy: stamp.qa_policy }
232
+ : wellFormedBriefMaterial(earlierInputs.brief_material) ? { presentation: earlierInputs.brief_material.presentation, qa_policy: earlierInputs.brief_material.qa_policy } : null,
233
+ spec_material_hash: isFingerprint(previous?.source_spec_material_hash) ? previous.source_spec_material_hash : isFingerprint(earlierInputs.spec_material_hash) ? earlierInputs.spec_material_hash : null,
234
+ },
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Intake with --force: before the reseeded report is published, every
240
+ * completed assembly, polish and qa record of `previousReport` goes to its
241
+ * stage's history (archived_by "prepare-build --force", reason_code
242
+ * "force_reset"); every stage's existing history, and each assembly, polish
243
+ * and qa input_change, are carried into `nextReport`; and a record whose
244
+ * stamps differ from the inputs this run binds gets a new input_change, so a
245
+ * replay over the superseded output still reads owed. `detectChange(key,
246
+ * record)` names the changed input's reason, or null.
247
+ */
248
+ export function archiveForForceReset(previousReport, nextReport, { now, detectChange }) {
249
+ if (!isPlainObject(previousReport?.stages) || !isPlainObject(nextReport?.stages)) return nextReport;
250
+ const stages = { ...nextReport.stages };
251
+ for (const key of ASSEMBLY_REPORT_STAGE_KEYS) {
252
+ const previous = Object.hasOwn(previousReport.stages, key) ? previousReport.stages[key] : null;
253
+ if (!isPlainObject(previous) || !Object.hasOwn(stages, key) || !isPlainObject(stages[key])) continue;
254
+ let reseeded = { ...stages[key] };
255
+ const priorHistory = Array.isArray(previous.history) ? previous.history.filter(isPlainObject) : [];
256
+ if (priorHistory.length) reseeded.history = priorHistory.slice(-PRODUCER_STAGE_HISTORY_LIMIT);
257
+ if (!ARCHIVED_STAGE_KEYS.includes(key)) {
258
+ stages[key] = reseeded;
259
+ continue;
260
+ }
261
+ if (Object.hasOwn(previous, "input_change")) reseeded.input_change = JSON.parse(JSON.stringify(previous.input_change));
262
+ if (isCompletedStatus(previous.status)) {
263
+ reseeded = archiveSupersededRecord(reseeded, previous, { by: "prepare-build --force", reason: "force_reset", at: now });
264
+ const reason = typeof detectChange === "function" ? detectChange(key, previous) : null;
265
+ if (reason) reseeded.input_change = inputChangeFor(key, previous, { at: now, reason });
266
+ }
267
+ stages[key] = reseeded;
268
+ }
269
+ return { ...nextReport, stages };
270
+ }
271
+
123
272
  function withoutStageTimestamps(report, stage) {
124
273
  const copy = JSON.parse(JSON.stringify(report ?? null));
125
274
  const stageValue = copy?.stages?.[stage];
@@ -162,6 +311,8 @@ export function recordProducerStageOutcome(report, {
162
311
  identity = null,
163
312
  evidence = null,
164
313
  proof = null,
314
+ stamps = null,
315
+ inputs = null,
165
316
  } = {}) {
166
317
  if (!PRODUCER_STAGES.has(stage)) throw new Error("Producer stage must be doctor or qa.");
167
318
  if (typeof timestamp !== "string" || !Number.isFinite(Date.parse(timestamp))) {
@@ -199,15 +350,24 @@ export function recordProducerStageOutcome(report, {
199
350
  : null;
200
351
  const incomingEvidenceSource = evidenceValue(evidence);
201
352
  const incomingEvidence = incomingEvidenceSource ? JSON.parse(JSON.stringify(incomingEvidenceSource)) : null;
202
- const archived = archivePreviousIdentity(previous, { verdict_run_id: incomingRunId, evidence: incomingEvidence });
203
- for (const field of QA_OWNED_FIELDS) delete next[field];
353
+ for (const field of [...QA_OWNED_FIELDS, ...QA_STAMP_FIELDS]) delete next[field];
204
354
  if (incomingRunId) next.verdict_run_id = incomingRunId;
205
355
  if (incomingEvidence) next.evidence = incomingEvidence;
206
356
  if (isPlainObject(proof)) next.purchase_proof = JSON.parse(JSON.stringify(proof));
207
- if (archived) {
208
- const priorHistory = Array.isArray(previous.history) ? previous.history.filter(isPlainObject) : [];
209
- next.history = [...priorHistory, archived].slice(-PRODUCER_STAGE_HISTORY_LIMIT);
357
+ // The verdict's own stamps: the brief material qa run bound at run start
358
+ // and the spec material it judged.
359
+ for (const field of QA_STAMP_FIELDS) {
360
+ if (stamps?.[field] != null) next[field] = JSON.parse(JSON.stringify(stamps[field]));
210
361
  }
362
+ // `inputs`: {detectChange(record) -> reason|null, stampsCurrent(record)}.
363
+ // A replaced completed record whose stamps differ from the current inputs
364
+ // records the change; a verdict stamped with the current inputs then
365
+ // clears it, the one it just recorded included; any other write carries it.
366
+ const detected = inputs && isCompletedStatus(previous.status) ? inputs.detectChange(previous) : null;
367
+ if (detected) next.input_change = inputChangeFor("qa", previous, { at: timestamp, reason: detected });
368
+ if (inputs && inputs.stampsCurrent(next)) delete next.input_change;
369
+ const archived = archiveStageRecord(next, previous, next, { by: "qa run", reason: detected || "rerecorded", at: timestamp, stageKey: "qa" });
370
+ if (Array.isArray(archived.history) && archived.history !== next.history) next.history = archived.history;
211
371
  }
212
372
  stages[stage] = next;
213
373
  updated.stages = stages;
@@ -253,7 +413,7 @@ function nextBlock(stage, action, { ownerKey = stage, ...extras } = {}) {
253
413
 
254
414
  /**
255
415
  * The Assembly Report's top-level summary, computed from the stage ledger it
256
- * carries and nothing else. Every write of the report restates it
416
+ * carries and the campaign's current inputs. Every write of the report restates it
257
417
  * (commitAssemblyReport, and prepare-build's initial write), so the summary
258
418
  * can never lag the stages: before this it was written once by prepare-build
259
419
  * and a finished ladder still read `status: "prepared"`, `next.stage:
@@ -284,15 +444,24 @@ function nextBlock(stage, action, { ownerKey = stage, ...extras } = {}) {
284
444
  * own blocker values, not copies: the summary is computed for a write, and
285
445
  * the report is serialized right after.
286
446
  *
287
- * Pure: reads `report`, returns a fresh summary, copies nothing else.
447
+ * Every stage is read at its effective status against `inputs`, the current
448
+ * `{briefMaterial, specMaterial}` (src/input-currency.mjs): a build, Polish or
449
+ * QA record made against earlier brief or CampaignSpec content reads
450
+ * `required`, and one whose inputs cannot be confirmed (no stamp, or inputs
451
+ * that cannot be read) reads `unknown`. Neither is terminal, so such a report
452
+ * reads `prepared` with `next` naming that stage, never `completed`.
453
+ *
454
+ * Pure: reads `report` and `inputs`, returns a fresh summary, copies nothing else.
288
455
  */
289
- export function deriveAssemblyReportSummary(report) {
456
+ export function deriveAssemblyReportSummary(report, { briefMaterial = null, specMaterial = null } = {}) {
290
457
  if (!isPlainObject(report)) throw new TypeError("deriveAssemblyReportSummary requires an Assembly Report object.");
458
+ const currency = assessInputCurrency({ report, briefMaterial, specMaterial });
459
+ const statusOf = (key) => effectiveStageStatus(key, stageOf(report, key), currency);
291
460
  const blockers = [];
292
461
  const seen = new Set();
293
462
  for (const key of ASSEMBLY_REPORT_STAGE_KEYS) {
294
463
  const stage = stageOf(report, key);
295
- if (!stageIsBlocked(stage?.status)) continue;
464
+ if (!stageIsBlocked(statusOf(key))) continue;
296
465
  for (const blocker of stageBlockers(stage)) {
297
466
  const id = JSON.stringify(canonicalize(blocker));
298
467
  if (seen.has(id)) continue;
@@ -300,30 +469,35 @@ export function deriveAssemblyReportSummary(report) {
300
469
  blockers.push(blocker);
301
470
  }
302
471
  }
303
- const anyBlocked = anyAssemblyReportStageBlocked(report);
472
+ const anyBlocked = ASSEMBLY_REPORT_STAGE_KEYS.some((key) => stageIsBlocked(statusOf(key)));
304
473
 
305
474
  let next = null;
306
475
  for (const gate of PRE_LADDER_GATES) {
307
- if (!stageIsBlocked(stageOf(report, gate.reportKey)?.status)) continue;
476
+ if (!stageIsBlocked(statusOf(gate.reportKey))) continue;
308
477
  next = nextBlock(gate.blockedStage, `Stage "${gate.reportKey}" is blocked; resolve its blockers before any stage runs.`, { blocked: true });
309
478
  break;
310
479
  }
311
480
  if (!next) {
312
481
  for (const { cliStage, reportKey } of NEXT_STAGE_CONTRACTS) {
313
- const status = stageOf(report, reportKey)?.status;
482
+ const status = statusOf(reportKey);
314
483
  if (stageIsBlocked(status)) {
315
484
  next = nextBlock(cliStage, `Stage "${reportKey}" is blocked; unblock it, then run ${cliStage}.`, { blocked: true });
316
485
  break;
317
486
  }
318
- if (!stageIsTerminal(status)) {
319
- next = nextBlock(cliStage, `Run ${cliStage} with this packet.`);
487
+ if (!effectiveStatusIsTerminal(status)) {
488
+ const recorded = stageOf(report, reportKey)?.status;
489
+ next = nextBlock(cliStage, status === "required" && recorded !== "required"
490
+ ? `Stage "${reportKey}" was recorded against earlier brief or CampaignSpec content; run ${cliStage} again with this packet.`
491
+ : status === "unknown"
492
+ ? `Stage "${reportKey}" does not record which brief and CampaignSpec content it was made against; run ${cliStage} again with this packet.`
493
+ : `Run ${cliStage} with this packet.`);
320
494
  break;
321
495
  }
322
496
  }
323
497
  }
324
498
  if (!next) {
325
499
  for (const gate of PRE_LADDER_GATES) {
326
- if (stageIsTerminal(stageOf(report, gate.reportKey)?.status)) continue;
500
+ if (effectiveStatusIsTerminal(statusOf(gate.reportKey))) continue;
327
501
  next = nextBlock(gate.pendingStage, `Stage "${gate.reportKey}" has not recorded a terminal outcome; run it before treating the report as complete.`, { ownerKey: gate.blockedStage });
328
502
  break;
329
503
  }
@@ -339,9 +513,10 @@ export function deriveAssemblyReportSummary(report) {
339
513
  * stages, in place, and return it. The report is the caller's own object
340
514
  * (the fresh one prepare-build built, or the copy a producer's
341
515
  * recordProducerStageOutcome already made), so nothing is cloned here.
516
+ * `inputs` is the campaign's current `{briefMaterial, specMaterial}`.
342
517
  */
343
- export function applyDerivedAssemblyReportSummary(report) {
344
- return Object.assign(report, deriveAssemblyReportSummary(report));
518
+ export function applyDerivedAssemblyReportSummary(report, inputs = {}) {
519
+ return Object.assign(report, deriveAssemblyReportSummary(report, inputs));
345
520
  }
346
521
 
347
522
  // QA-owned gate evidence on the qa stage. The QA producer records, beside
@@ -366,7 +541,11 @@ export function qaGateEvidence(report, gate) {
366
541
  };
367
542
  }
368
543
 
369
- export function qaGatePassedForCurrentBuild(report, gate, { buildFingerprint }) {
544
+ // `qaCurrency` is QA's read-time input currency
545
+ // (derived.input_currency.stages.qa): while QA is owed again or its inputs
546
+ // cannot be confirmed, no QA gate pass counts.
547
+ export function qaGatePassedForCurrentBuild(report, gate, { buildFingerprint, qaCurrency = null }) {
548
+ if (qaCurrency === "owed" || qaCurrency === "unknown") return false;
370
549
  const outcome = qaGateEvidence(report, gate);
371
550
  const current = optionalString(buildFingerprint);
372
551
  return Boolean(outcome && outcome.status === QA_STATUS.PASS && current && outcome.source_build_fingerprint === current);
@@ -478,6 +657,17 @@ export function commitAssemblyReport(workspace, mutate, {
478
657
  });
479
658
  }
480
659
 
660
+ /**
661
+ * The current `{briefMaterial, specMaterial}` of a campaign workspace: the
662
+ * normalized brief and the CampaignSpec its packet names, read from disk
663
+ * (null where they cannot be read).
664
+ */
665
+ export function workspaceInputs(workspace) {
666
+ return isPlainObject(workspace?.packet) && optionalString(workspace?.packetPath)
667
+ ? currentPacketInputs({ packet: workspace.packet, packetPath: workspace.packetPath })
668
+ : { briefMaterial: null, specMaterial: null };
669
+ }
670
+
481
671
  function commitAssemblyReportUnderLock(workspace, mutate, {
482
672
  refreshDoctor, staleReason, command, stage, hasRefresh, reportPath, doctorOutPath, targetRepo,
483
673
  }) {
@@ -527,8 +717,9 @@ function commitAssemblyReportUnderLock(workspace, mutate, {
527
717
  // commit; after that the restatement is a no-op and the unchanged check
528
718
  // below keeps the file's bytes alone. `mutated` is the mutator's own object
529
719
  // (every mutator in this repo returns a copy), so the restatement is in
530
- // place rather than a second deep clone.
531
- const next = applyDerivedAssemblyReportSummary(mutated);
720
+ // place rather than a second deep clone. Stages are read at their effective
721
+ // status against the inputs the workspace's packet names now.
722
+ const next = applyDerivedAssemblyReportSummary(mutated, workspaceInputs(workspace));
532
723
  if (stage && producerStageOutcomeUnchanged(report, next, stage)) {
533
724
  outcome.skipped = "unchanged";
534
725
  return finish();