@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.
- package/CHANGELOG.md +109 -0
- package/README.md +1 -0
- package/agents/claude/CLAUDE.md +2 -0
- package/agents/codex/AGENTS.md +2 -0
- package/agents/copilot/copilot-instructions.md +2 -0
- package/agents/cursor/campaigns-os.mdc +2 -0
- package/compatibility.json +1 -1
- package/contracts/campaign-cart-checkout-field-contract.v0.json +10 -0
- package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +27 -27
- package/contracts/effects.v1.json +295 -47
- package/contracts/release-ledger.json +660 -0
- package/contracts/supported-surface.json +3 -3
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/brand-theme-bridge.md +2 -0
- package/docs/build-packet.md +67 -19
- package/docs/campaign-build-brief.md +48 -17
- package/docs/campaign-standardization-report.md +32 -0
- package/docs/design-source-package.md +7 -3
- package/docs/effects.md +10 -1
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +2 -0
- package/docs/progress-snapshots.md +5 -5
- package/docs/qa-and-test-orders.md +30 -3
- package/docs/readback.md +19 -2
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +15 -0
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +1 -0
- package/package.json +1 -1
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +5 -1
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +5 -3
- package/skills/next-campaigns-os/SKILL.md +10 -19
- package/skills/next-campaigns-os/references/session-intake.md +10 -6
- package/skills/next-campaigns-os-setup/SKILL.md +4 -3
- package/skills/next-campaigns-polish/SKILL.md +7 -5
- package/skills/next-campaigns-qa/SKILL.md +5 -3
- package/skills.json +10 -10
- package/src/brand-theme.mjs +21 -20
- package/src/brief-summary.mjs +275 -0
- package/src/build-brief.mjs +167 -15
- package/src/campaign-ecosystem.mjs +153 -31
- package/src/cli.mjs +182 -115
- package/src/contrast.mjs +649 -0
- package/src/design-source-publication.mjs +110 -3
- package/src/directory-lock.mjs +79 -7
- package/src/doctor/checks.mjs +141 -16
- package/src/doctor/inspect.mjs +13 -0
- package/src/doctor/next-step.mjs +15 -6
- package/src/input-currency.mjs +394 -0
- package/src/input-refresh.mjs +708 -0
- package/src/invocation.mjs +6 -2
- package/src/polish-browser.mjs +503 -201
- package/src/polish-deadline.mjs +7 -0
- package/src/polish-node.mjs +326 -46
- package/src/polish-readability.mjs +828 -0
- package/src/progress-node.mjs +18 -5
- package/src/qa-browser.mjs +211 -93
- package/src/qa-node.mjs +47 -7
- package/src/qc-accept.mjs +44 -8
- package/src/qc-check-registry.mjs +15 -7
- package/src/qc-results.mjs +326 -34
- package/src/readback.mjs +58 -10
- package/src/sdk-attribute-index.mjs +111 -23
- package/src/sdk-markup.mjs +49 -10
- package/src/sdk-repin.mjs +282 -0
- package/src/stage-ledger.mjs +235 -44
- package/src/stage-record.mjs +154 -30
- package/src/standardization-report.mjs +26 -1
- package/src/target-lock.mjs +9 -1
- package/src/tooling-setup.mjs +1 -1
package/src/stage-ledger.mjs
CHANGED
|
@@ -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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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 (
|
|
117
|
-
if (
|
|
118
|
-
if (
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
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
|
-
*
|
|
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(
|
|
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 =
|
|
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(
|
|
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 =
|
|
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 (!
|
|
319
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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();
|